mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
Merge remote-tracking branch 'upstream/main' into icon-access
This commit is contained in:
commit
d94cc601e0
52
.github/ISSUE_TEMPLATE/00-bug-issue.yml
vendored
Normal file
52
.github/ISSUE_TEMPLATE/00-bug-issue.yml
vendored
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
# Inspired by PeerTube templates:
|
||||||
|
# https://github.com/Chocobozzz/PeerTube/blob/3d4d49a23eae71f3ce62cbbd7d93f07336a106b7/.github/ISSUE_TEMPLATE/00-bug-issue.yml
|
||||||
|
name: 🐛 Bug Report
|
||||||
|
description: Use this template for reporting a bug
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for taking time to fill out this bug report!
|
||||||
|
Please search among past open/closed issues for a similar one beforehand:
|
||||||
|
- https://github.com/gristlabs/grist-core/issues?q=
|
||||||
|
- https://community.getgrist.com/
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Describe the current behavior
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Steps to reproduce
|
||||||
|
value: |
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Describe the expected behavior
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
attributes:
|
||||||
|
label: Where have you encountered this bug?
|
||||||
|
options:
|
||||||
|
- label: On [docs.getgrist.com](https://docs.getgrist.com)
|
||||||
|
- label: On a self-hosted instance
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Instance information (when self-hosting only)
|
||||||
|
description: In case you self-host, please share information above. You can discard any question you don't know the answer.
|
||||||
|
value: |
|
||||||
|
* Grist instance:
|
||||||
|
* Version:
|
||||||
|
* URL (if it's OK for you to share it):
|
||||||
|
* Installation mode: docker/kubernetes/...
|
||||||
|
* Architecture: single-worker/multi-workers
|
||||||
|
|
||||||
|
* Browser name, version and platforms on which you could reproduce the bug:
|
||||||
|
* Link to browser console log if relevant:
|
||||||
|
* Link to server log if relevant:
|
33
.github/ISSUE_TEMPLATE/10-installation-issue.yml
vendored
Normal file
33
.github/ISSUE_TEMPLATE/10-installation-issue.yml
vendored
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
# Inspired by PeerTube templates:
|
||||||
|
# https://github.com/Chocobozzz/PeerTube/blob/master/.github/ISSUE_TEMPLATE/10-installation-issue.yml
|
||||||
|
name: 🛠️ Installation/Upgrade Issue
|
||||||
|
description: Use this template for installation/upgrade issues
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Please check first the official documentation for self-hosting: https://support.getgrist.com/self-managed/
|
||||||
|
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Please search among past open/closed issues for a similar one beforehand:
|
||||||
|
- https://github.com/gristlabs/grist-core/issues?q=
|
||||||
|
- https://community.getgrist.com/
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Describe the problem
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Additional information
|
||||||
|
value: |
|
||||||
|
* Grist version:
|
||||||
|
* Grist instance URL:
|
||||||
|
* SSO solution used and its version (if relevant):
|
||||||
|
* S3 storage solution and its version (if relevant):
|
||||||
|
* Docker version (if relevant):
|
||||||
|
* NodeJS version (if relevant):
|
||||||
|
* Redis version (if relevant):
|
||||||
|
* PostgreSQL version (if relevant):
|
23
.github/ISSUE_TEMPLATE/20-feature-request.yml
vendored
Normal file
23
.github/ISSUE_TEMPLATE/20-feature-request.yml
vendored
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# Inspired by PeerTube templates:
|
||||||
|
# https://github.com/Chocobozzz/PeerTube/blob/master/.github/ISSUE_TEMPLATE/30-feature-request.yml
|
||||||
|
---
|
||||||
|
name: ✨ Feature Request
|
||||||
|
description: Use this template to ask for new features and suggest new ideas 💡
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for taking time to share your ideas!
|
||||||
|
Please search among past open/closed issues for a similar one beforehand:
|
||||||
|
- https://github.com/gristlabs/grist-core/issues?q=
|
||||||
|
- https://community.getgrist.com/
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Describe the problem to be solved
|
||||||
|
description: Provide a clear and concise description of what the problem is
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Describe the solution you would like
|
||||||
|
description: Provide a clear and concise description of what you want to happen
|
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: 🤷💻🤦 Question/Forum
|
||||||
|
url: https://community.getgrist.com/
|
||||||
|
about: You can ask and answer other questions here
|
||||||
|
- name: 💬 Discord
|
||||||
|
url: https://discord.com/invite/MYKpYQ3fbP
|
||||||
|
about: Chat with us via Discord for quick Q/A here and sharing tips
|
27
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
27
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
<!-- Please include a summary of the change, with motivation and context -->
|
||||||
|
<!-- Bonus: if you are comfortable writing one, please insert a user-story https://en.wikipedia.org/wiki/User_story#Common_templates -->
|
||||||
|
|
||||||
|
## Proposed solution
|
||||||
|
|
||||||
|
<!-- Describe here how you address the issue -->
|
||||||
|
|
||||||
|
## Related issues
|
||||||
|
|
||||||
|
<!-- If suggesting a new feature or change, please discuss it in an issue first -->
|
||||||
|
<!-- If fixing a bug, there should be an issue describing it with steps to reproduce -->
|
||||||
|
<!-- If this does not solve entirely the issue, make also a checklist of what is done or not: -->
|
||||||
|
|
||||||
|
## Has this been tested?
|
||||||
|
|
||||||
|
<!-- Put an `x` in the box that applies: -->
|
||||||
|
|
||||||
|
- [ ] 👍 yes, I added tests to the test suite
|
||||||
|
- [ ] 💭 no, because this PR is a draft and still needs work
|
||||||
|
- [ ] 🙅 no, because this is not relevant here
|
||||||
|
- [ ] 🙋 no, because I need help <!-- Detail how we can help you -->
|
||||||
|
|
||||||
|
## Screenshots / Screencasts
|
||||||
|
|
||||||
|
<!-- delete if not relevant -->
|
57
.github/workflows/docker.yml
vendored
57
.github/workflows/docker.yml
vendored
@ -5,36 +5,67 @@ on:
|
|||||||
types: [published]
|
types: [published]
|
||||||
# Allows you to run this workflow manually from the Actions tab
|
# Allows you to run this workflow manually from the Actions tab
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: "Tag for the resulting images"
|
||||||
|
type: string
|
||||||
|
required: True
|
||||||
|
default: 'stable'
|
||||||
|
|
||||||
|
env:
|
||||||
|
TAG: ${{ inputs.tag || 'stable' }}
|
||||||
|
DOCKER_HUB_OWNER: ${{ vars.DOCKER_HUB_OWNER || github.repository_owner }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
push_to_registry:
|
push_to_registry:
|
||||||
name: Push Docker image to Docker Hub
|
name: Push Docker images to Docker Hub
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
image:
|
||||||
|
# We build two images, `grist-oss` and `grist`.
|
||||||
|
# See https://github.com/gristlabs/grist-core?tab=readme-ov-file#available-docker-images
|
||||||
|
- name: "grist-oss"
|
||||||
|
repo: "grist-core"
|
||||||
|
- name: "grist"
|
||||||
|
repo: "grist-ee"
|
||||||
steps:
|
steps:
|
||||||
- name: Check out the repo
|
- name: Check out the repo
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Add a dummy ext/ directory
|
||||||
|
run:
|
||||||
|
mkdir ext && touch ext/dummy
|
||||||
|
|
||||||
|
- name: Check out the ext/ directory
|
||||||
|
if: matrix.image.name != 'grist-oss'
|
||||||
|
run: buildtools/checkout-ext-directory.sh ${{ matrix.image.repo }}
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v4
|
uses: docker/metadata-action@v4
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ github.repository_owner }}/grist
|
${{ github.repository_owner }}/${{ matrix.image.name }}
|
||||||
tags: |
|
tags: |
|
||||||
type=ref,event=branch
|
type=ref,event=branch
|
||||||
type=ref,event=pr
|
type=ref,event=pr
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
type=semver,pattern={{major}}
|
type=semver,pattern={{major}}
|
||||||
stable
|
${{ env.TAG }}
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v1
|
uses: docker/setup-qemu-action@v1
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v1
|
uses: docker/setup-buildx-action@v1
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@v1
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
- name: Push to Docker Hub
|
- name: Push to Docker Hub
|
||||||
uses: docker/build-push-action@v2
|
uses: docker/build-push-action@v2
|
||||||
with:
|
with:
|
||||||
@ -44,3 +75,19 @@ jobs:
|
|||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
build-contexts: ext=ext
|
||||||
|
|
||||||
|
- name: Push Enterprise to Docker Hub
|
||||||
|
if: ${{ matrix.image.name == 'grist' }}
|
||||||
|
uses: docker/build-push-action@v2
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
build-args: |
|
||||||
|
BASE_IMAGE=${{ env.DOCKER_HUB_OWNER }}/${{ matrix.image.name}}
|
||||||
|
BASE_VERSION=${{ env.TAG }}
|
||||||
|
file: ext/Dockerfile
|
||||||
|
platforms: ${{ env.PLATFORMS }}
|
||||||
|
push: true
|
||||||
|
tags: ${{ env.DOCKER_HUB_OWNER }}/grist-ee:${{ env.TAG }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
120
.github/workflows/docker_latest.yml
vendored
120
.github/workflows/docker_latest.yml
vendored
@ -10,6 +10,37 @@ on:
|
|||||||
# Run at 5:41 UTC daily
|
# Run at 5:41 UTC daily
|
||||||
- cron: '41 5 * * *'
|
- cron: '41 5 * * *'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
branch:
|
||||||
|
description: "Branch from which to create the latest Docker image (default: latest_candidate)"
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
default: latest_candidate
|
||||||
|
disable_tests:
|
||||||
|
description: "Should the tests be skipped?"
|
||||||
|
type: boolean
|
||||||
|
required: True
|
||||||
|
default: False
|
||||||
|
platforms:
|
||||||
|
description: "Platforms to build"
|
||||||
|
type: choice
|
||||||
|
required: True
|
||||||
|
options:
|
||||||
|
- linux/amd64
|
||||||
|
- linux/arm64/v8
|
||||||
|
- linux/amd64,linux/arm64/v8
|
||||||
|
default: linux/amd64,linux/arm64/v8
|
||||||
|
tag:
|
||||||
|
description: "Tag for the resulting images"
|
||||||
|
type: string
|
||||||
|
required: True
|
||||||
|
default: 'latest'
|
||||||
|
|
||||||
|
env:
|
||||||
|
BRANCH: ${{ inputs.branch || 'latest_candidate' }}
|
||||||
|
PLATFORMS: ${{ inputs.platforms || 'linux/amd64,linux/arm64/v8' }}
|
||||||
|
TAG: ${{ inputs.tag || 'latest' }}
|
||||||
|
DOCKER_HUB_OWNER: ${{ vars.DOCKER_HUB_OWNER || github.repository_owner }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
push_to_registry:
|
push_to_registry:
|
||||||
@ -17,56 +48,131 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: [3.9]
|
python-version: [3.11]
|
||||||
node-version: [18.x]
|
node-version: [18.x]
|
||||||
|
image:
|
||||||
|
# We build two images, `grist-oss` and `grist`.
|
||||||
|
# See https://github.com/gristlabs/grist-core?tab=readme-ov-file#available-docker-images
|
||||||
|
- name: "grist-oss"
|
||||||
|
repo: "grist-core"
|
||||||
|
- name: "grist"
|
||||||
|
repo: "grist-ee"
|
||||||
steps:
|
steps:
|
||||||
|
- name: Build settings
|
||||||
|
run: |
|
||||||
|
echo "Branch: $BRANCH"
|
||||||
|
echo "Platforms: $PLATFORMS"
|
||||||
|
echo "Docker Hub Owner: $DOCKER_HUB_OWNER"
|
||||||
|
echo "Tag: $TAG"
|
||||||
|
|
||||||
- name: Check out the repo
|
- name: Check out the repo
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
ref: latest_candidate
|
ref: ${{ env.BRANCH }}
|
||||||
|
|
||||||
|
- name: Add a dummy ext/ directory
|
||||||
|
run:
|
||||||
|
mkdir ext && touch ext/dummy
|
||||||
|
|
||||||
|
- name: Check out the ext/ directory
|
||||||
|
if: matrix.image.name != 'grist-oss'
|
||||||
|
run: buildtools/checkout-ext-directory.sh ${{ matrix.image.repo }}
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v1
|
uses: docker/setup-qemu-action@v1
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v1
|
uses: docker/setup-buildx-action@v1
|
||||||
|
|
||||||
- name: Prepare image but do not push it yet
|
- name: Prepare image but do not push it yet
|
||||||
uses: docker/build-push-action@v2
|
uses: docker/build-push-action@v2
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
load: true
|
load: true
|
||||||
tags: ${{ github.repository_owner }}/grist:latest
|
tags: ${{ env.DOCKER_HUB_OWNER }}/${{ matrix.image.name }}:${{ env.TAG }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
|
build-contexts: ext=ext
|
||||||
|
|
||||||
- name: Use Node.js ${{ matrix.node-version }} for testing
|
- name: Use Node.js ${{ matrix.node-version }} for testing
|
||||||
|
if: ${{ !inputs.disable_tests }}
|
||||||
uses: actions/setup-node@v1
|
uses: actions/setup-node@v1
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node-version }}
|
node-version: ${{ matrix.node-version }}
|
||||||
|
|
||||||
- name: Set up Python ${{ matrix.python-version }} for testing - maybe not needed
|
- name: Set up Python ${{ matrix.python-version }} for testing - maybe not needed
|
||||||
|
if: ${{ !inputs.disable_tests }}
|
||||||
uses: actions/setup-python@v2
|
uses: actions/setup-python@v2
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
- name: Install Python packages
|
- name: Install Python packages
|
||||||
|
if: ${{ !inputs.disable_tests }}
|
||||||
run: |
|
run: |
|
||||||
pip install virtualenv
|
pip install virtualenv
|
||||||
yarn run install:python
|
yarn run install:python
|
||||||
|
|
||||||
- name: Install Node.js packages
|
- name: Install Node.js packages
|
||||||
|
if: ${{ !inputs.disable_tests }}
|
||||||
run: yarn install
|
run: yarn install
|
||||||
|
|
||||||
|
- name: Disable the ext/ directory
|
||||||
|
if: ${{ !inputs.disable_tests }}
|
||||||
|
run: mv ext/ ext-disabled/
|
||||||
|
|
||||||
- name: Build Node.js code
|
- name: Build Node.js code
|
||||||
|
if: ${{ !inputs.disable_tests }}
|
||||||
run: yarn run build:prod
|
run: yarn run build:prod
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: TEST_IMAGE=${{ github.repository_owner }}/grist VERBOSE=1 DEBUG=1 MOCHA_WEBDRIVER_HEADLESS=1 yarn run test:docker
|
if: ${{ !inputs.disable_tests }}
|
||||||
|
run: TEST_IMAGE=${{ env.DOCKER_HUB_OWNER }}/${{ matrix.image.name }}:${{ env.TAG }} VERBOSE=1 DEBUG=1 MOCHA_WEBDRIVER_HEADLESS=1 yarn run test:docker
|
||||||
|
|
||||||
|
- name: Re-enable the ext/ directory
|
||||||
|
if: ${{ !inputs.disable_tests }}
|
||||||
|
run: mv ext-disabled/ ext/
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@v1
|
uses: docker/login-action@v1
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
- name: Push to Docker Hub
|
- name: Push to Docker Hub
|
||||||
uses: docker/build-push-action@v2
|
uses: docker/build-push-action@v2
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64/v8
|
platforms: ${{ env.PLATFORMS }}
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ github.repository_owner }}/grist:latest
|
tags: ${{ env.DOCKER_HUB_OWNER }}/${{ matrix.image.name }}:${{ env.TAG }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
build-contexts: ext=ext
|
||||||
|
|
||||||
|
- name: Push Enterprise to Docker Hub
|
||||||
|
if: ${{ matrix.image.name == 'grist' }}
|
||||||
|
uses: docker/build-push-action@v2
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
build-args: |
|
||||||
|
BASE_IMAGE=${{ env.DOCKER_HUB_OWNER }}/${{ matrix.image.name}}
|
||||||
|
BASE_VERSION=${{ env.TAG }}
|
||||||
|
file: ext/Dockerfile
|
||||||
|
platforms: ${{ env.PLATFORMS }}
|
||||||
|
push: true
|
||||||
|
tags: ${{ env.DOCKER_HUB_OWNER }}/grist-ee:${{ env.TAG }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
update_latest_branch:
|
||||||
|
name: Update latest branch
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: push_to_registry
|
||||||
|
steps:
|
||||||
|
- name: Check out the repo
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.latest_branch }}
|
||||||
|
|
||||||
- name: Update latest branch
|
- name: Update latest branch
|
||||||
uses: ad-m/github-push-action@8407731efefc0d8f72af254c74276b7a90be36e1
|
uses: ad-m/github-push-action@8407731efefc0d8f72af254c74276b7a90be36e1
|
||||||
with:
|
with:
|
||||||
|
44
.github/workflows/fly-build.yml
vendored
Normal file
44
.github/workflows/fly-build.yml
vendored
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
# fly-deploy will be triggered on completion of this workflow to actually deploy the code to fly.io.
|
||||||
|
|
||||||
|
name: fly.io Build
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [ main ]
|
||||||
|
types: [labeled, opened, synchronize, reopened]
|
||||||
|
|
||||||
|
# Allows running this workflow manually from the Actions tab
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build Docker image
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# Build when the 'preview' label is added, or when PR is updated with this label present.
|
||||||
|
if: >
|
||||||
|
github.event_name == 'workflow_dispatch' ||
|
||||||
|
(github.event_name == 'pull_request' &&
|
||||||
|
contains(github.event.pull_request.labels.*.name, 'preview'))
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Build and export Docker image
|
||||||
|
id: docker-build
|
||||||
|
run: >
|
||||||
|
./buildtools/checkout-ext-directory.sh grist-ee &&
|
||||||
|
docker build -t grist-core:preview . --build-context ext=ext &&
|
||||||
|
docker image save grist-core:preview -o grist-core.tar
|
||||||
|
- name: Save PR information
|
||||||
|
run: |
|
||||||
|
echo PR_NUMBER=${{ github.event.number }} >> ./pr-info.txt
|
||||||
|
echo PR_SOURCE=${{ github.event.pull_request.head.repo.full_name }}-${{ github.event.pull_request.head.ref }} >> ./pr-info.txt
|
||||||
|
echo PR_SHASUM=${{ github.event.pull_request.head.sha }} >> ./pr-info.txt
|
||||||
|
# PR_SOURCE looks like <owner>/<repo>-<branch>.
|
||||||
|
# For example, if the GitHub user "foo" forked grist-core as "grist-bar", and makes a PR from their branch named "baz",
|
||||||
|
# it will be "foo/grist-bar-baz". deploy.js later replaces "/" with "-", making it "foo-grist-bar-baz".
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: docker-image
|
||||||
|
path: |
|
||||||
|
./grist-core.tar
|
||||||
|
./pr-info.txt
|
||||||
|
if-no-files-found: "error"
|
20
.github/workflows/fly-cleanup.yml
vendored
20
.github/workflows/fly-cleanup.yml
vendored
@ -1,4 +1,4 @@
|
|||||||
name: Fly Cleanup
|
name: fly.io Cleanup
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
# Once a day, clean up jobs marked as expired
|
# Once a day, clean up jobs marked as expired
|
||||||
@ -12,12 +12,12 @@ env:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
clean:
|
clean:
|
||||||
name: Clean stale deployed apps
|
name: Clean stale deployed apps
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.repository_owner == 'gristlabs'
|
if: github.repository_owner == 'gristlabs'
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: superfly/flyctl-actions/setup-flyctl@master
|
- uses: superfly/flyctl-actions/setup-flyctl@master
|
||||||
with:
|
with:
|
||||||
version: 0.1.66
|
version: 0.2.72
|
||||||
- run: node buildtools/fly-deploy.js clean
|
- run: node buildtools/fly-deploy.js clean
|
||||||
|
70
.github/workflows/fly-deploy.yml
vendored
Normal file
70
.github/workflows/fly-deploy.yml
vendored
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
# Follow-up of fly-build, with access to secrets for making deployments.
|
||||||
|
# This workflow runs in the target repo context. It does not, and should never execute user-supplied code.
|
||||||
|
# See https://securitylab.github.com/research/github-actions-preventing-pwn-requests/
|
||||||
|
|
||||||
|
name: fly.io Deploy
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: ["fly.io Build"]
|
||||||
|
types:
|
||||||
|
- completed
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
name: Deploy app to fly.io
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: |
|
||||||
|
github.event.workflow_run.event == 'pull_request' &&
|
||||||
|
github.event.workflow_run.conclusion == 'success'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Set up flyctl
|
||||||
|
uses: superfly/flyctl-actions/setup-flyctl@master
|
||||||
|
with:
|
||||||
|
version: 0.2.72
|
||||||
|
- name: Download artifacts
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
var artifacts = await github.rest.actions.listWorkflowRunArtifacts({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
run_id: ${{ github.event.workflow_run.id }},
|
||||||
|
});
|
||||||
|
var matchArtifact = artifacts.data.artifacts.filter((artifact) => {
|
||||||
|
return artifact.name == "docker-image"
|
||||||
|
})[0];
|
||||||
|
var download = await github.rest.actions.downloadArtifact({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
artifact_id: matchArtifact.id,
|
||||||
|
archive_format: 'zip',
|
||||||
|
});
|
||||||
|
var fs = require('fs');
|
||||||
|
fs.writeFileSync('${{github.workspace}}/docker-image.zip', Buffer.from(download.data));
|
||||||
|
- name: Extract artifacts
|
||||||
|
id: extract_artifacts
|
||||||
|
run: |
|
||||||
|
unzip docker-image.zip
|
||||||
|
cat ./pr-info.txt >> $GITHUB_OUTPUT
|
||||||
|
- name: Load Docker image
|
||||||
|
run: docker load --input grist-core.tar
|
||||||
|
- name: Deploy to fly.io
|
||||||
|
id: fly_deploy
|
||||||
|
env:
|
||||||
|
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
|
||||||
|
BRANCH_NAME: ${{ steps.extract_artifacts.outputs.PR_SOURCE }}
|
||||||
|
run: |
|
||||||
|
node buildtools/fly-deploy.js deploy
|
||||||
|
flyctl config -c ./fly.toml env | awk '/APP_HOME_URL/{print "DEPLOY_URL=" $2}' >> $GITHUB_OUTPUT
|
||||||
|
flyctl config -c ./fly.toml env | awk '/FLY_DEPLOY_EXPIRATION/{print "EXPIRES=" $2}' >> $GITHUB_OUTPUT
|
||||||
|
- name: Comment on PR
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
github.rest.issues.createComment({
|
||||||
|
issue_number: ${{ steps.extract_artifacts.outputs.PR_NUMBER }},
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
body: `Deployed commit \`${{ steps.extract_artifacts.outputs.PR_SHASUM }}\` as ${{ steps.fly_deploy.outputs.DEPLOY_URL }} (until ${{ steps.fly_deploy.outputs.EXPIRES }})`
|
||||||
|
})
|
36
.github/workflows/fly-destroy.yml
vendored
Normal file
36
.github/workflows/fly-destroy.yml
vendored
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# This workflow runs in the target repo context, as it is triggered via pull_request_target.
|
||||||
|
# It does not, and should not have access to code in the PR.
|
||||||
|
# See https://securitylab.github.com/research/github-actions-preventing-pwn-requests/
|
||||||
|
|
||||||
|
name: fly.io Destroy
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
branches: [ main ]
|
||||||
|
types: [unlabeled, closed]
|
||||||
|
|
||||||
|
# Allows running this workflow manually from the Actions tab
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
destroy:
|
||||||
|
name: Remove app from fly.io
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# Remove the deployment when 'preview' label is removed, or the PR is closed.
|
||||||
|
if: |
|
||||||
|
github.event_name == 'workflow_dispatch' ||
|
||||||
|
(github.event_name == 'pull_request_target' &&
|
||||||
|
(github.event.action == 'closed' ||
|
||||||
|
(github.event.action == 'unlabeled' && github.event.label.name == 'preview')))
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Set up flyctl
|
||||||
|
uses: superfly/flyctl-actions/setup-flyctl@master
|
||||||
|
with:
|
||||||
|
version: 0.2.72
|
||||||
|
- name: Destroy fly.io app
|
||||||
|
env:
|
||||||
|
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
|
||||||
|
BRANCH_NAME: ${{ github.event.pull_request.head.repo.full_name }}-${{ github.event.pull_request.head.ref }}
|
||||||
|
# See fly-build for what BRANCH_NAME looks like.
|
||||||
|
id: fly_destroy
|
||||||
|
run: node buildtools/fly-deploy.js destroy
|
64
.github/workflows/fly.yml
vendored
64
.github/workflows/fly.yml
vendored
@ -1,64 +0,0 @@
|
|||||||
name: Fly Deploy
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
branches: [ main ]
|
|
||||||
types: [labeled, unlabeled, closed, opened, synchronize, reopened]
|
|
||||||
|
|
||||||
# Allows running this workflow manually from the Actions tab
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
env:
|
|
||||||
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
|
|
||||||
BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
name: Deploy app
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
# Deploy when the 'preview' label is added, or when PR is updated with this label present.
|
|
||||||
if: |
|
|
||||||
github.repository_owner == 'gristlabs' &&
|
|
||||||
github.event_name == 'pull_request' && (
|
|
||||||
github.event.action == 'labeled' ||
|
|
||||||
github.event.action == 'opened' ||
|
|
||||||
github.event.action == 'synchronize' ||
|
|
||||||
github.event.action == 'reopened'
|
|
||||||
) &&
|
|
||||||
contains(github.event.pull_request.labels.*.name, 'preview')
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- uses: superfly/flyctl-actions/setup-flyctl@master
|
|
||||||
with:
|
|
||||||
version: 0.1.89
|
|
||||||
- id: fly_deploy
|
|
||||||
run: |
|
|
||||||
node buildtools/fly-deploy.js deploy
|
|
||||||
flyctl config -c ./fly.toml env | awk '/APP_HOME_URL/{print "DEPLOY_URL=" $2}' >> $GITHUB_OUTPUT
|
|
||||||
flyctl config -c ./fly.toml env | awk '/FLY_DEPLOY_EXPIRATION/{print "EXPIRES=" $2}' >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- uses: actions/github-script@v6
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
github.rest.issues.createComment({
|
|
||||||
issue_number: context.issue.number,
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
body: `Deployed as ${{ steps.fly_deploy.outputs.DEPLOY_URL }} (until ${{ steps.fly_deploy.outputs.EXPIRES }})`
|
|
||||||
})
|
|
||||||
|
|
||||||
destroy:
|
|
||||||
name: Remove app
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
# Remove the deployment when 'preview' label is removed, or the PR is closed.
|
|
||||||
if: |
|
|
||||||
github.repository_owner == 'gristlabs' &&
|
|
||||||
github.event_name == 'pull_request' &&
|
|
||||||
(github.event.action == 'closed' ||
|
|
||||||
(github.event.action == 'unlabeled' && github.event.label.name == 'preview'))
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- uses: superfly/flyctl-actions/setup-flyctl@master
|
|
||||||
with:
|
|
||||||
version: 0.1.89
|
|
||||||
- id: fly_destroy
|
|
||||||
run: node buildtools/fly-deploy.js destroy
|
|
5
.github/workflows/main.yml
vendored
5
.github/workflows/main.yml
vendored
@ -17,7 +17,7 @@ jobs:
|
|||||||
# even when there is a failure.
|
# even when there is a failure.
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
python-version: [3.9]
|
python-version: [3.11]
|
||||||
node-version: [18.x]
|
node-version: [18.x]
|
||||||
tests:
|
tests:
|
||||||
- ':lint:python:client:common:smoke:stubs:'
|
- ':lint:python:client:common:smoke:stubs:'
|
||||||
@ -32,9 +32,6 @@ jobs:
|
|||||||
- tests: ':lint:python:client:common:smoke:'
|
- tests: ':lint:python:client:common:smoke:'
|
||||||
node-version: 18.x
|
node-version: 18.x
|
||||||
python-version: '3.10'
|
python-version: '3.10'
|
||||||
- tests: ':lint:python:client:common:smoke:'
|
|
||||||
node-version: 18.x
|
|
||||||
python-version: '3.11'
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
8
.gitignore
vendored
8
.gitignore
vendored
@ -80,3 +80,11 @@ xunit.xml
|
|||||||
.clipboard.lock
|
.clipboard.lock
|
||||||
|
|
||||||
**/_build
|
**/_build
|
||||||
|
|
||||||
|
# ext directory can be overwritten
|
||||||
|
ext/**
|
||||||
|
|
||||||
|
# Docker compose examples - persistent values and secrets
|
||||||
|
/docker-compose-examples/*/persist
|
||||||
|
/docker-compose-examples/*/secrets
|
||||||
|
/docker-compose-examples/grist-traefik-oidc-auth/.env
|
||||||
|
@ -4,5 +4,5 @@ You are eager to contribute to Grist? That's awesome! See below some contributio
|
|||||||
- [translate](/documentation/translations.md)
|
- [translate](/documentation/translations.md)
|
||||||
- [write tutorials and user documentation](https://github.com/gristlabs/grist-help?tab=readme-ov-file#grist-help-center)
|
- [write tutorials and user documentation](https://github.com/gristlabs/grist-help?tab=readme-ov-file#grist-help-center)
|
||||||
- [develop](/documentation/develop.md)
|
- [develop](/documentation/develop.md)
|
||||||
- [report issues or suggest enhancement](https://github.com/gristlabs/grist-core/issues/new)
|
- [report issues or suggest enhancement](https://github.com/gristlabs/grist-core/issues/new/choose)
|
||||||
|
|
||||||
|
19
Dockerfile
19
Dockerfile
@ -4,13 +4,13 @@
|
|||||||
## docker buildx build -t ... --build-context=ext=<path> .
|
## docker buildx build -t ... --build-context=ext=<path> .
|
||||||
## The code in <path> will then be built along with the rest of Grist.
|
## The code in <path> will then be built along with the rest of Grist.
|
||||||
################################################################################
|
################################################################################
|
||||||
FROM scratch as ext
|
FROM scratch AS ext
|
||||||
|
|
||||||
################################################################################
|
################################################################################
|
||||||
## Javascript build stage
|
## Javascript build stage
|
||||||
################################################################################
|
################################################################################
|
||||||
|
|
||||||
FROM node:18-buster as builder
|
FROM node:18-buster AS builder
|
||||||
|
|
||||||
# Install all node dependencies.
|
# Install all node dependencies.
|
||||||
WORKDIR /grist
|
WORKDIR /grist
|
||||||
@ -46,7 +46,7 @@ RUN \
|
|||||||
################################################################################
|
################################################################################
|
||||||
|
|
||||||
# Fetch python3.11 and python2.7
|
# Fetch python3.11 and python2.7
|
||||||
FROM python:3.11-slim-buster as collector
|
FROM python:3.11-slim-buster AS collector
|
||||||
|
|
||||||
# Install all python dependencies.
|
# Install all python dependencies.
|
||||||
ADD sandbox/requirements.txt requirements.txt
|
ADD sandbox/requirements.txt requirements.txt
|
||||||
@ -66,7 +66,7 @@ RUN \
|
|||||||
# Fetch gvisor-based sandbox. Note, to enable it to run within default
|
# Fetch gvisor-based sandbox. Note, to enable it to run within default
|
||||||
# unprivileged docker, layers of protection that require privilege have
|
# unprivileged docker, layers of protection that require privilege have
|
||||||
# been stripped away, see https://github.com/google/gvisor/issues/4371
|
# been stripped away, see https://github.com/google/gvisor/issues/4371
|
||||||
FROM docker.io/gristlabs/gvisor-unprivileged:buster as sandbox
|
FROM docker.io/gristlabs/gvisor-unprivileged:buster AS sandbox
|
||||||
|
|
||||||
################################################################################
|
################################################################################
|
||||||
## Run-time stage
|
## Run-time stage
|
||||||
@ -122,6 +122,15 @@ RUN \
|
|||||||
mv /grist/static-built/* /grist/static && \
|
mv /grist/static-built/* /grist/static && \
|
||||||
rmdir /grist/static-built
|
rmdir /grist/static-built
|
||||||
|
|
||||||
|
# To ensure non-root users can run grist, 'other' users need read access (and execute on directories)
|
||||||
|
# This should be the case by default when copying files in.
|
||||||
|
# Only uncomment this if running into permissions issues, as it takes a long time to execute on some systems.
|
||||||
|
# RUN chmod -R o+rX /grist
|
||||||
|
|
||||||
|
# Add a user to allow de-escalating from root on startup
|
||||||
|
RUN useradd -ms /bin/bash grist
|
||||||
|
ENV GRIST_DOCKER_USER=grist \
|
||||||
|
GRIST_DOCKER_GROUP=grist
|
||||||
WORKDIR /grist
|
WORKDIR /grist
|
||||||
|
|
||||||
# Set some default environment variables to give a setup that works out of the box when
|
# Set some default environment variables to give a setup that works out of the box when
|
||||||
@ -151,5 +160,5 @@ ENV \
|
|||||||
|
|
||||||
EXPOSE 8484
|
EXPOSE 8484
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/bin/tini", "-s", "--"]
|
ENTRYPOINT ["./sandbox/docker_entrypoint.sh"]
|
||||||
CMD ["node", "./sandbox/supervisor.mjs"]
|
CMD ["node", "./sandbox/supervisor.mjs"]
|
||||||
|
26
README.md
26
README.md
@ -83,7 +83,8 @@ If you just want a quick demo of Grist:
|
|||||||
* Or you can see a fully in-browser build of Grist at [gristlabs.github.io/grist-static](https://gristlabs.github.io/grist-static/).
|
* Or you can see a fully in-browser build of Grist at [gristlabs.github.io/grist-static](https://gristlabs.github.io/grist-static/).
|
||||||
* Or you can download Grist as a desktop app from [github.com/gristlabs/grist-desktop](https://github.com/gristlabs/grist-desktop).
|
* Or you can download Grist as a desktop app from [github.com/gristlabs/grist-desktop](https://github.com/gristlabs/grist-desktop).
|
||||||
|
|
||||||
To get `grist-core` running on your computer with [Docker](https://www.docker.com/get-started), do:
|
To get the default version of `grist-core` running on your computer
|
||||||
|
with [Docker](https://www.docker.com/get-started), do:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker pull gristlabs/grist
|
docker pull gristlabs/grist
|
||||||
@ -117,6 +118,22 @@ You can find a lot more about configuring Grist, setting up authentication,
|
|||||||
and running it on a public server in our
|
and running it on a public server in our
|
||||||
[Self-Managed Grist](https://support.getgrist.com/self-managed/) handbook.
|
[Self-Managed Grist](https://support.getgrist.com/self-managed/) handbook.
|
||||||
|
|
||||||
|
## Available Docker images
|
||||||
|
|
||||||
|
The default Docker image is `gristlabs/grist`. This contains all of
|
||||||
|
the standard Grist functionality, as well as extra source-available
|
||||||
|
code for enterprise customers taken from the
|
||||||
|
[grist-ee](https://github.com/gristlabs/grist-ee) repository. This
|
||||||
|
extra code is not under a free or open source license. By default,
|
||||||
|
however, the code from the `grist-ee` repository is completely inert
|
||||||
|
and inactive. This code becomes active only when enabled from the
|
||||||
|
administrator panel.
|
||||||
|
|
||||||
|
If you would rather use an image that contains exclusively free and
|
||||||
|
open source code, the `gristlabs/grist-oss` Docker image is available
|
||||||
|
for this purpose. It is by default functionally equivalent to the
|
||||||
|
`gristlabs/grist` image.
|
||||||
|
|
||||||
## The administrator panel
|
## The administrator panel
|
||||||
|
|
||||||
You can turn on a special admininistrator panel to inspect the status
|
You can turn on a special admininistrator panel to inspect the status
|
||||||
@ -185,7 +202,7 @@ and Google/Microsoft sign-ins via [Dex](https://dexidp.io/).
|
|||||||
|
|
||||||
We use [Weblate](https://hosted.weblate.org/engage/grist/) to manage translations.
|
We use [Weblate](https://hosted.weblate.org/engage/grist/) to manage translations.
|
||||||
Thanks to everyone who is pitching in. Thanks especially to the ANCT developers who
|
Thanks to everyone who is pitching in. Thanks especially to the ANCT developers who
|
||||||
did the hard work of making a good chunk of the application localizable. Merci bien!
|
did the hard work of making a good chunk of the application localizable. Merci beaucoup !
|
||||||
|
|
||||||
<a href="https://hosted.weblate.org/engage/grist/">
|
<a href="https://hosted.weblate.org/engage/grist/">
|
||||||
<img src="https://hosted.weblate.org/widgets/grist/-/open-graph.png" alt="Translation status" width=480 />
|
<img src="https://hosted.weblate.org/widgets/grist/-/open-graph.png" alt="Translation status" width=480 />
|
||||||
@ -295,11 +312,12 @@ Grist can be configured in many ways. Here are the main environment variables it
|
|||||||
| GRIST_UI_FEATURES | comma-separated list of UI features to enable. Allowed names of parts: `helpCenter,billing,templates,createSite,multiSite,multiAccounts,sendToDrive,tutorials,supportGrist`. If a part also exists in GRIST_HIDE_UI_ELEMENTS, it won't be enabled. |
|
| GRIST_UI_FEATURES | comma-separated list of UI features to enable. Allowed names of parts: `helpCenter,billing,templates,createSite,multiSite,multiAccounts,sendToDrive,tutorials,supportGrist`. If a part also exists in GRIST_HIDE_UI_ELEMENTS, it won't be enabled. |
|
||||||
| GRIST_UNTRUSTED_PORT | if set, plugins will be served from the given port. This is an alternative to setting APP_UNTRUSTED_URL. |
|
| GRIST_UNTRUSTED_PORT | if set, plugins will be served from the given port. This is an alternative to setting APP_UNTRUSTED_URL. |
|
||||||
| GRIST_WIDGET_LIST_URL | a url pointing to a widget manifest, by default `https://github.com/gristlabs/grist-widget/releases/download/latest/manifest.json` is used |
|
| GRIST_WIDGET_LIST_URL | a url pointing to a widget manifest, by default `https://github.com/gristlabs/grist-widget/releases/download/latest/manifest.json` is used |
|
||||||
|
| GRIST_LOG_HTTP | When set to `true`, log HTTP requests and responses information. Defaults to `false`. |
|
||||||
|
| GRIST_LOG_HTTP_BODY | When this variable and `GRIST_LOG_HTTP` are set to `true` , log the body along with the HTTP requests. :warning: Be aware it may leak confidential information in the logs.:warning: Defaults to `false`. |
|
||||||
| COOKIE_MAX_AGE | session cookie max age, defaults to 90 days; can be set to "none" to make it a session cookie |
|
| COOKIE_MAX_AGE | session cookie max age, defaults to 90 days; can be set to "none" to make it a session cookie |
|
||||||
| HOME_PORT | port number to listen on for REST API server; if set to "share", add API endpoints to regular grist port. |
|
| HOME_PORT | port number to listen on for REST API server; if set to "share", add API endpoints to regular grist port. |
|
||||||
| PORT | port number to listen on for Grist server |
|
| PORT | port number to listen on for Grist server |
|
||||||
| REDIS_URL | optional redis server for browser sessions and db query caching |
|
| REDIS_URL | optional redis server for browser sessions and db query caching |
|
||||||
| GRIST_SKIP_REDIS_CHECKSUM_MISMATCH | Experimental. If set, only warn if the checksum in Redis differs with the one in your S3 backend storage. You may turn it on if your backend storage implements the [read-after-write consistency](https://aws.amazon.com/fr/blogs/aws/amazon-s3-update-strong-read-after-write-consistency/). Defaults to false. |
|
|
||||||
| GRIST_SNAPSHOT_TIME_CAP | optional. Define the caps for tracking buckets. Usage: {"hour": 25, "day": 32, "isoWeek": 12, "month": 96, "year": 1000} |
|
| GRIST_SNAPSHOT_TIME_CAP | optional. Define the caps for tracking buckets. Usage: {"hour": 25, "day": 32, "isoWeek": 12, "month": 96, "year": 1000} |
|
||||||
| GRIST_SNAPSHOT_KEEP | optional. Number of recent snapshots to retain unconditionally for a document, regardless of when they were made |
|
| GRIST_SNAPSHOT_KEEP | optional. Number of recent snapshots to retain unconditionally for a document, regardless of when they were made |
|
||||||
| GRIST_PROMCLIENT_PORT | optional. If set, serve the Prometheus metrics on the specified port number. ⚠️ Be sure to use a port which is not publicly exposed ⚠️. |
|
| GRIST_PROMCLIENT_PORT | optional. If set, serve the Prometheus metrics on the specified port number. ⚠️ Be sure to use a port which is not publicly exposed ⚠️. |
|
||||||
@ -448,7 +466,7 @@ Then, you can run the main test suite like so:
|
|||||||
yarn test
|
yarn test
|
||||||
```
|
```
|
||||||
|
|
||||||
Python tests may also be run locally. (Note: currently requires Python 3.9 - 3.11.)
|
Python tests may also be run locally. (Note: currently requires Python 3.10 - 3.11.)
|
||||||
|
|
||||||
```
|
```
|
||||||
yarn test:python
|
yarn test:python
|
||||||
|
@ -293,6 +293,25 @@ function initialize(appModel: AppModel) {
|
|||||||
|
|
||||||
function requestInterceptor(request: SwaggerUI.Request) {
|
function requestInterceptor(request: SwaggerUI.Request) {
|
||||||
delete request.headers.Authorization;
|
delete request.headers.Authorization;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
// Swagger will use this request interceptor for several kinds of
|
||||||
|
// requests, such as requesting the API YAML spec from Github:
|
||||||
|
//
|
||||||
|
// Function to intercept remote definition, "Try it out",
|
||||||
|
// and OAuth 2.0 requests.
|
||||||
|
//
|
||||||
|
// https://swagger.io/docs/open-source-tools/swagger-ui/usage/configuration/
|
||||||
|
//
|
||||||
|
// We want to ensure that only "Try it out" requests have XHR, so
|
||||||
|
// that they pass a same origin request, even if they're not GET,
|
||||||
|
// HEAD, or OPTIONS. "Try it out" requests are the requests to the
|
||||||
|
// same origin.
|
||||||
|
if (url.origin === window.origin) {
|
||||||
|
// Without this header, unauthenticated multipart POST requests
|
||||||
|
// (i.e. file uploads) would fail in the API console. We want those
|
||||||
|
// requests to succeed.
|
||||||
|
request.headers['X-Requested-With'] = 'XMLHttpRequest';
|
||||||
|
}
|
||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -78,6 +78,9 @@ export function isNumericLike(col: ColumnRec, use: UseCB = unwrap) {
|
|||||||
return ['Numeric', 'Int', 'Any'].includes(colType);
|
return ['Numeric', 'Int', 'Any'].includes(colType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isCategoryType(pureType: string): boolean {
|
||||||
|
return !['Numeric', 'Int', 'Any', 'Date', 'DateTime'].includes(pureType);
|
||||||
|
}
|
||||||
|
|
||||||
interface ChartOptions {
|
interface ChartOptions {
|
||||||
multiseries?: boolean;
|
multiseries?: boolean;
|
||||||
@ -106,8 +109,9 @@ type RowPropGetter = (rowId: number) => Datum;
|
|||||||
// We convert Grist data to a list of Series first, from which we then construct Plotly traces.
|
// We convert Grist data to a list of Series first, from which we then construct Plotly traces.
|
||||||
interface Series {
|
interface Series {
|
||||||
label: string; // Corresponds to the column name.
|
label: string; // Corresponds to the column name.
|
||||||
group?: Datum; // The group value, when grouped.
|
|
||||||
values: Datum[];
|
values: Datum[];
|
||||||
|
pureType?: string; // The pure type of the column.
|
||||||
|
group?: Datum; // The group value, when grouped.
|
||||||
isInSortSpec?: boolean; // Whether this series is present in sort spec for this chart.
|
isInSortSpec?: boolean; // Whether this series is present in sort spec for this chart.
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -273,6 +277,7 @@ export class ChartView extends Disposable {
|
|||||||
const pureType = field.displayColModel().pureType();
|
const pureType = field.displayColModel().pureType();
|
||||||
const fullGetter = (pureType === 'Date' || pureType === 'DateTime') ? dateGetter(getter) : getter;
|
const fullGetter = (pureType === 'Date' || pureType === 'DateTime') ? dateGetter(getter) : getter;
|
||||||
return {
|
return {
|
||||||
|
pureType,
|
||||||
label: field.label(),
|
label: field.label(),
|
||||||
values: rowIds.map(fullGetter),
|
values: rowIds.map(fullGetter),
|
||||||
isInSortSpec: Boolean(Sort.findCol(this._sortSpec, field.colRef.peek())),
|
isInSortSpec: Boolean(Sort.findCol(this._sortSpec, field.colRef.peek())),
|
||||||
@ -1121,7 +1126,15 @@ function basicPlot(series: Series[], options: ChartOptions, dataOptions: Data):
|
|||||||
export const chartTypes: {[name: string]: ChartFunc} = {
|
export const chartTypes: {[name: string]: ChartFunc} = {
|
||||||
// TODO There is a lot of code duplication across chart types. Some refactoring is in order.
|
// TODO There is a lot of code duplication across chart types. Some refactoring is in order.
|
||||||
bar(series: Series[], options: ChartOptions): PlotData {
|
bar(series: Series[], options: ChartOptions): PlotData {
|
||||||
return basicPlot(series, options, {type: 'bar'});
|
// If the X axis is not from numerical column, treat it as category.
|
||||||
|
const data = basicPlot(series, options, {type: 'bar'});
|
||||||
|
const useCategory = series[0]?.pureType && isCategoryType(series[0].pureType);
|
||||||
|
const xaxisName = options.orientation === 'h' ? 'yaxis' : 'xaxis';
|
||||||
|
if (useCategory && data.layout && data.layout[xaxisName]) {
|
||||||
|
const axisConfig = data.layout[xaxisName]!;
|
||||||
|
axisConfig.type = 'category';
|
||||||
|
}
|
||||||
|
return data;
|
||||||
},
|
},
|
||||||
line(series: Series[], options: ChartOptions): PlotData {
|
line(series: Series[], options: ChartOptions): PlotData {
|
||||||
sortByXValues(series);
|
sortByXValues(series);
|
||||||
|
@ -1,3 +1,13 @@
|
|||||||
|
/*
|
||||||
|
* Ensure the custom view section fits within its allocated area even if it needs to scroll inside
|
||||||
|
* of it. This is not an issue when it contains an iframe, but .custom_view_no_mapping element
|
||||||
|
* could be taller, but its intrinsic height should not affect the container.
|
||||||
|
*/
|
||||||
|
.custom_view_container {
|
||||||
|
overflow: auto;
|
||||||
|
flex-basis: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
iframe.custom_view {
|
iframe.custom_view {
|
||||||
border: none;
|
border: none;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@ -12,7 +22,6 @@ iframe.custom_view {
|
|||||||
.custom_view_no_mapping {
|
.custom_view_no_mapping {
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
margin: 15px;
|
margin: 15px;
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
@ -22,10 +22,6 @@ interface Props {
|
|||||||
* Actual element to put into the editor. This is the main content of the editor.
|
* Actual element to put into the editor. This is the main content of the editor.
|
||||||
*/
|
*/
|
||||||
content: DomContents,
|
content: DomContents,
|
||||||
/**
|
|
||||||
* Click handler. If not provided, then clicking on the editor will select it.
|
|
||||||
*/
|
|
||||||
click?: (ev: MouseEvent, box: BoxModel) => void,
|
|
||||||
/**
|
/**
|
||||||
* Whether to show the remove button. Defaults to true.
|
* Whether to show the remove button. Defaults to true.
|
||||||
*/
|
*/
|
||||||
@ -75,22 +71,6 @@ export function buildEditor(props: Props, ...args: IDomArgs<HTMLElement>) {
|
|||||||
style.cssRemoveButton.cls('-right', props.removePosition === 'right'),
|
style.cssRemoveButton.cls('-right', props.removePosition === 'right'),
|
||||||
);
|
);
|
||||||
|
|
||||||
const onClick = (ev: MouseEvent) => {
|
|
||||||
// Only if the click was in this element.
|
|
||||||
const target = ev.target as HTMLElement;
|
|
||||||
if (!target.closest) { return; }
|
|
||||||
// Make sure that the closest editor is this one.
|
|
||||||
const closest = target.closest(`.${style.cssFieldEditor.className}`);
|
|
||||||
if (closest !== element) { return; }
|
|
||||||
|
|
||||||
ev.stopPropagation();
|
|
||||||
ev.preventDefault();
|
|
||||||
props.click?.(ev, props.box);
|
|
||||||
|
|
||||||
// Mark this box as selected.
|
|
||||||
box.view.selectedBox.set(box);
|
|
||||||
};
|
|
||||||
|
|
||||||
const dragAbove = Observable.create(owner, false);
|
const dragAbove = Observable.create(owner, false);
|
||||||
const dragBelow = Observable.create(owner, false);
|
const dragBelow = Observable.create(owner, false);
|
||||||
const dragging = Observable.create(owner, false);
|
const dragging = Observable.create(owner, false);
|
||||||
@ -111,7 +91,10 @@ export function buildEditor(props: Props, ...args: IDomArgs<HTMLElement>) {
|
|||||||
testId('field-editor-selected', box.selected),
|
testId('field-editor-selected', box.selected),
|
||||||
|
|
||||||
// Select on click.
|
// Select on click.
|
||||||
dom.on('click', onClick),
|
dom.on('click', (ev) => {
|
||||||
|
stopEvent(ev);
|
||||||
|
box.view.selectedBox.set(box);
|
||||||
|
}),
|
||||||
|
|
||||||
// Attach context menu.
|
// Attach context menu.
|
||||||
buildMenu({
|
buildMenu({
|
||||||
@ -122,6 +105,15 @@ export function buildEditor(props: Props, ...args: IDomArgs<HTMLElement>) {
|
|||||||
// And now drag and drop support.
|
// And now drag and drop support.
|
||||||
{draggable: "true"},
|
{draggable: "true"},
|
||||||
|
|
||||||
|
// In Firefox, 'draggable' interferes with mouse selection in child input elements. Workaround
|
||||||
|
// is to turn off 'draggable' temporarily (see https://stackoverflow.com/q/21680363/328565).
|
||||||
|
dom.on('mousedown', (ev, elem) => {
|
||||||
|
const isInput = ["INPUT", "TEXTAREA"].includes((ev.target as Element)?.tagName);
|
||||||
|
// Turn off 'draggable' for inputs only, to support selection there; keep it on elsewhere.
|
||||||
|
elem.draggable = !isInput;
|
||||||
|
}),
|
||||||
|
dom.on('mouseup', (ev, elem) => { elem.draggable = true; }),
|
||||||
|
|
||||||
// When started, we just put the box into the dataTransfer as a plain text.
|
// When started, we just put the box into the dataTransfer as a plain text.
|
||||||
// TODO: this might be very sofisticated in the future.
|
// TODO: this might be very sofisticated in the future.
|
||||||
dom.on('dragstart', (ev) => {
|
dom.on('dragstart', (ev) => {
|
||||||
|
@ -1,85 +0,0 @@
|
|||||||
import * as css from './styles';
|
|
||||||
import {buildEditor} from 'app/client/components/Forms/Editor';
|
|
||||||
import {BoxModel} from 'app/client/components/Forms/Model';
|
|
||||||
import {stopEvent} from 'app/client/lib/domUtils';
|
|
||||||
import {not} from 'app/common/gutil';
|
|
||||||
import {Computed, dom, Observable} from 'grainjs';
|
|
||||||
|
|
||||||
export class LabelModel extends BoxModel {
|
|
||||||
public edit = Observable.create(this, false);
|
|
||||||
|
|
||||||
protected defaultValue = '';
|
|
||||||
|
|
||||||
public render(): HTMLElement {
|
|
||||||
let element: HTMLTextAreaElement;
|
|
||||||
const text = this.prop('text', this.defaultValue) as Observable<string|undefined>;
|
|
||||||
const cssClass = this.prop('cssClass', '') as Observable<string>;
|
|
||||||
const editableText = Observable.create(this, text.get() || '');
|
|
||||||
const overlay = Computed.create(this, use => !use(this.edit));
|
|
||||||
|
|
||||||
this.autoDispose(text.addListener((v) => editableText.set(v || '')));
|
|
||||||
|
|
||||||
const save = (ok: boolean) => {
|
|
||||||
if (ok) {
|
|
||||||
text.set(editableText.get());
|
|
||||||
void this.parent?.save().catch(reportError);
|
|
||||||
} else {
|
|
||||||
editableText.set(text.get() || '');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const mode = (edit: boolean) => {
|
|
||||||
if (this.isDisposed() || this.edit.isDisposed()) { return; }
|
|
||||||
if (this.edit.get() === edit) { return; }
|
|
||||||
this.edit.set(edit);
|
|
||||||
};
|
|
||||||
|
|
||||||
return buildEditor(
|
|
||||||
{
|
|
||||||
box: this,
|
|
||||||
editMode: this.edit,
|
|
||||||
overlay,
|
|
||||||
click: (ev) => {
|
|
||||||
stopEvent(ev);
|
|
||||||
// If selected, then edit.
|
|
||||||
if (!this.selected.get()) { return; }
|
|
||||||
if (document.activeElement === element) { return; }
|
|
||||||
editableText.set(text.get() || '');
|
|
||||||
this.edit.set(true);
|
|
||||||
setTimeout(() => {
|
|
||||||
element.focus();
|
|
||||||
element.select();
|
|
||||||
}, 10);
|
|
||||||
},
|
|
||||||
content: element = css.cssEditableLabel(
|
|
||||||
editableText,
|
|
||||||
{onInput: true, autoGrow: true},
|
|
||||||
{placeholder: `Empty label`},
|
|
||||||
dom.on('click', ev => {
|
|
||||||
stopEvent(ev);
|
|
||||||
}),
|
|
||||||
// Styles saved (for titles and such)
|
|
||||||
css.cssEditableLabel.cls(use => `-${use(cssClass)}`),
|
|
||||||
// Disable editing if not in edit mode.
|
|
||||||
dom.boolAttr('readonly', not(this.edit)),
|
|
||||||
// Pass edit to css.
|
|
||||||
css.cssEditableLabel.cls('-edit', this.edit),
|
|
||||||
// Attach default save controls (Enter, Esc) and so on.
|
|
||||||
css.saveControls(this.edit, save),
|
|
||||||
// Turn off resizable for textarea.
|
|
||||||
dom.style('resize', 'none'),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
dom.onKeyDown({Enter$: (ev) => {
|
|
||||||
// If no in edit mode, change it.
|
|
||||||
if (!this.edit.get()) {
|
|
||||||
mode(true);
|
|
||||||
ev.stopPropagation();
|
|
||||||
ev.stopImmediatePropagation();
|
|
||||||
ev.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -19,7 +19,6 @@ export class ParagraphModel extends BoxModel {
|
|||||||
public override render(): HTMLElement {
|
public override render(): HTMLElement {
|
||||||
const box = this;
|
const box = this;
|
||||||
const editMode = box.edit;
|
const editMode = box.edit;
|
||||||
let element: HTMLElement;
|
|
||||||
const text = this.prop('text', this.defaultValue) as Observable<string|undefined>;
|
const text = this.prop('text', this.defaultValue) as Observable<string|undefined>;
|
||||||
|
|
||||||
// There is a spacial hack here. We might be created as a separator component, but the rendering
|
// There is a spacial hack here. We might be created as a separator component, but the rendering
|
||||||
@ -44,18 +43,21 @@ export class ParagraphModel extends BoxModel {
|
|||||||
this.cssClass ? dom.cls(this.cssClass, not(editMode)) : null,
|
this.cssClass ? dom.cls(this.cssClass, not(editMode)) : null,
|
||||||
dom.maybe(editMode, () => {
|
dom.maybe(editMode, () => {
|
||||||
const draft = Observable.create(null, text.get() || '');
|
const draft = Observable.create(null, text.get() || '');
|
||||||
setTimeout(() => element?.focus(), 10);
|
return cssTextArea(draft, {autoGrow: true, onInput: true},
|
||||||
return [
|
cssTextArea.cls('-edit', editMode),
|
||||||
element = cssTextArea(draft, {autoGrow: true, onInput: true},
|
(elem) => {
|
||||||
cssTextArea.cls('-edit', editMode),
|
setTimeout(() => {
|
||||||
css.saveControls(editMode, (ok) => {
|
elem.focus();
|
||||||
if (ok && editMode.get()) {
|
elem.setSelectionRange(elem.value.length, elem.value.length);
|
||||||
text.set(draft.get());
|
}, 10);
|
||||||
this.save().catch(reportError);
|
},
|
||||||
}
|
css.saveControls(editMode, (ok) => {
|
||||||
})
|
if (ok && editMode.get()) {
|
||||||
),
|
text.set(draft.get());
|
||||||
];
|
this.save().catch(reportError);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
@ -13,7 +13,6 @@ export * from "./Section";
|
|||||||
export * from './Field';
|
export * from './Field';
|
||||||
export * from './Columns';
|
export * from './Columns';
|
||||||
export * from './Submit';
|
export * from './Submit';
|
||||||
export * from './Label';
|
|
||||||
|
|
||||||
export function defaultElement(type: FormLayoutNodeType): FormLayoutNode {
|
export function defaultElement(type: FormLayoutNodeType): FormLayoutNode {
|
||||||
switch(type) {
|
switch(type) {
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import type {App} from 'app/client/ui/App';
|
||||||
import {textarea} from 'app/client/ui/inputs';
|
import {textarea} from 'app/client/ui/inputs';
|
||||||
import {sanitizeHTML} from 'app/client/ui/sanitizeHTML';
|
import {sanitizeHTML} from 'app/client/ui/sanitizeHTML';
|
||||||
import {basicButton, basicButtonLink, primaryButtonLink, textButton} from 'app/client/ui2018/buttons';
|
import {basicButton, basicButtonLink, primaryButtonLink, textButton} from 'app/client/ui2018/buttons';
|
||||||
@ -759,11 +760,17 @@ export function saveControls(editMode: Observable<boolean>, save: (ok: boolean)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
dom.on('blur', (ev) => {
|
dom.create((owner) => {
|
||||||
if (!editMode.isDisposed() && editMode.get()) {
|
// Whenever focus returns to the Clipboard component, close the editor by saving the value.
|
||||||
save(true);
|
function saveEdit() {
|
||||||
editMode.set(false);
|
if (!editMode.isDisposed() && editMode.get()) {
|
||||||
|
save(true);
|
||||||
|
editMode.set(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
const app = (window as any).gristApp as App;
|
||||||
|
app.on('clipboard_focus', saveEdit);
|
||||||
|
owner.onDispose(() => app.off('clipboard_focus', saveEdit));
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
@ -44,7 +44,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.gridview_corner_spacer { /* spacer in .gridview_data_header */
|
.gridview_corner_spacer { /* spacer in .gridview_data_header */
|
||||||
width: 4rem; /* matches row_num width */
|
width: 52px; /* matches row_num width */
|
||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,7 +68,7 @@
|
|||||||
position: sticky;
|
position: sticky;
|
||||||
left: 0px;
|
left: 0px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
width: 4rem; /* Also should match width for .gridview_header_corner, and the overlay elements */
|
width: 52px; /* Also should match width for .gridview_header_corner, and the overlay elements */
|
||||||
flex: none;
|
flex: none;
|
||||||
|
|
||||||
border-bottom: 1px solid var(--grist-theme-table-header-border, lightgray);
|
border-bottom: 1px solid var(--grist-theme-table-header-border, lightgray);
|
||||||
@ -131,7 +131,7 @@
|
|||||||
border-left: 1px solid var(--grist-color-dark-grey);
|
border-left: 1px solid var(--grist-color-dark-grey);
|
||||||
}
|
}
|
||||||
.print-widget .gridview_data_header {
|
.print-widget .gridview_data_header {
|
||||||
padding-left: 4rem !important;
|
padding-left: 52px !important;
|
||||||
}
|
}
|
||||||
.print-widget .gridview_data_pane .print-all-rows {
|
.print-widget .gridview_data_pane .print-all-rows {
|
||||||
display: table-row-group;
|
display: table-row-group;
|
||||||
@ -155,7 +155,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.gridview_data_corner_overlay {
|
.gridview_data_corner_overlay {
|
||||||
width: 4rem;
|
width: 52px;
|
||||||
height: calc(var(--gridview-header-height) + 1px); /* matches gridview_data_header height (+border) */
|
height: calc(var(--gridview-header-height) + 1px); /* matches gridview_data_header height (+border) */
|
||||||
top: 1px; /* go under 1px border on scrollpane */
|
top: 1px; /* go under 1px border on scrollpane */
|
||||||
border-bottom: 1px solid var(--grist-theme-table-header-border, lightgray);
|
border-bottom: 1px solid var(--grist-theme-table-header-border, lightgray);
|
||||||
@ -177,7 +177,7 @@
|
|||||||
- frozen-offset: when frozen columns are wider then the screen, we want them to move left initially,
|
- frozen-offset: when frozen columns are wider then the screen, we want them to move left initially,
|
||||||
this value is the position where this movement should stop.
|
this value is the position where this movement should stop.
|
||||||
*/
|
*/
|
||||||
left: calc(4em + (var(--frozen-width, 0) - min(var(--frozen-scroll-offset, 0), var(--frozen-offset, 0))) * 1px);
|
left: calc(52px + (var(--frozen-width, 0) - min(var(--frozen-scroll-offset, 0), var(--frozen-offset, 0))) * 1px);
|
||||||
box-shadow: -6px 0 6px 6px var(--grist-theme-table-scroll-shadow, #444);
|
box-shadow: -6px 0 6px 6px var(--grist-theme-table-scroll-shadow, #444);
|
||||||
/* shadow should only show to the right of it (10px should be enough) */
|
/* shadow should only show to the right of it (10px should be enough) */
|
||||||
-webkit-clip-path: polygon(0 0, 10px 0, 10px 100%, 0 100%);
|
-webkit-clip-path: polygon(0 0, 10px 0, 10px 100%, 0 100%);
|
||||||
@ -189,7 +189,7 @@
|
|||||||
.scroll_shadow_frozen {
|
.scroll_shadow_frozen {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 0px;
|
width: 0px;
|
||||||
left: 4em;
|
left: 52px;
|
||||||
box-shadow: -8px 0 14px 4px var(--grist-theme-table-scroll-shadow, #444);
|
box-shadow: -8px 0 14px 4px var(--grist-theme-table-scroll-shadow, #444);
|
||||||
-webkit-clip-path: polygon(0 0, 10px 0, 10px 100%, 0 100%);
|
-webkit-clip-path: polygon(0 0, 10px 0, 10px 100%, 0 100%);
|
||||||
clip-path: polygon(0 0, 28px 0, 24px 100%, 0 100%);
|
clip-path: polygon(0 0, 28px 0, 24px 100%, 0 100%);
|
||||||
@ -205,7 +205,7 @@
|
|||||||
/* this value is the same as for the left shadow - but doesn't need to really on the scroll offset
|
/* this value is the same as for the left shadow - but doesn't need to really on the scroll offset
|
||||||
as this component will be hidden when the scroll starts
|
as this component will be hidden when the scroll starts
|
||||||
*/
|
*/
|
||||||
left: calc(4em + var(--frozen-width, 0) * 1px);
|
left: calc(52px + var(--frozen-width, 0) * 1px);
|
||||||
background-color: var(--grist-theme-table-frozen-columns-border, #999999);
|
background-color: var(--grist-theme-table-frozen-columns-border, #999999);
|
||||||
z-index: 30;
|
z-index: 30;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
@ -226,7 +226,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.gridview_header_backdrop_left {
|
.gridview_header_backdrop_left {
|
||||||
width: calc(4rem + 1px); /* Matches rowid width (+border) */
|
width: calc(52px + 1px); /* Matches rowid width (+border) */
|
||||||
height:100%;
|
height:100%;
|
||||||
top: 1px; /* go under 1px border on scrollpane */
|
top: 1px; /* go under 1px border on scrollpane */
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
@ -311,7 +311,7 @@
|
|||||||
/* style header and a data field */
|
/* style header and a data field */
|
||||||
.record .field.frozen {
|
.record .field.frozen {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
left: calc(4em + 1px + (var(--frozen-position, 0) - var(--frozen-offset, 0)) * 1px); /* 4em for row number + total width of cells + 1px for border*/
|
left: calc(52px + 1px + (var(--frozen-position, 0) - var(--frozen-offset, 0)) * 1px); /* 52px (4em) for row number + total width of cells + 1px for border*/
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
/* for data field we need to reuse color from record (add-row and zebra stripes) */
|
/* for data field we need to reuse color from record (add-row and zebra stripes) */
|
||||||
|
@ -69,9 +69,10 @@ const SHORT_CLICK_IN_MS = 500;
|
|||||||
|
|
||||||
// size of the plus width ()
|
// size of the plus width ()
|
||||||
const PLUS_WIDTH = 40;
|
const PLUS_WIDTH = 40;
|
||||||
// size of the row number field (we assume 4rem)
|
// size of the row number field (we assume 4rem, 1rem = 13px in grist)
|
||||||
const ROW_NUMBER_WIDTH = 52;
|
const ROW_NUMBER_WIDTH = 52;
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GridView component implements the view of a grid of cells.
|
* GridView component implements the view of a grid of cells.
|
||||||
*/
|
*/
|
||||||
@ -96,8 +97,7 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) {
|
|||||||
|
|
||||||
this.cellSelector = selector.CellSelector.create(this, this);
|
this.cellSelector = selector.CellSelector.create(this, this);
|
||||||
|
|
||||||
if (!isPreview) {
|
if (!isPreview && !this.gristDoc.comparison) {
|
||||||
// Disable summaries in import previews, for now.
|
|
||||||
this.selectionSummary = SelectionSummary.create(this,
|
this.selectionSummary = SelectionSummary.create(this,
|
||||||
this.cellSelector, this.tableModel.tableData, this.sortedRows, this.viewSection.viewFields);
|
this.cellSelector, this.tableModel.tableData, this.sortedRows, this.viewSection.viewFields);
|
||||||
}
|
}
|
||||||
|
@ -42,6 +42,7 @@ import {getFilterFunc, QuerySetManager} from 'app/client/models/QuerySet';
|
|||||||
import TableModel from 'app/client/models/TableModel';
|
import TableModel from 'app/client/models/TableModel';
|
||||||
import {getUserOrgPrefObs, getUserOrgPrefsObs, markAsSeen} from 'app/client/models/UserPrefs';
|
import {getUserOrgPrefObs, getUserOrgPrefsObs, markAsSeen} from 'app/client/models/UserPrefs';
|
||||||
import {App} from 'app/client/ui/App';
|
import {App} from 'app/client/ui/App';
|
||||||
|
import {showCustomWidgetGallery} from 'app/client/ui/CustomWidgetGallery';
|
||||||
import {DocHistory} from 'app/client/ui/DocHistory';
|
import {DocHistory} from 'app/client/ui/DocHistory';
|
||||||
import {startDocTour} from "app/client/ui/DocTour";
|
import {startDocTour} from "app/client/ui/DocTour";
|
||||||
import {DocTutorial} from 'app/client/ui/DocTutorial';
|
import {DocTutorial} from 'app/client/ui/DocTutorial';
|
||||||
@ -138,6 +139,13 @@ interface PopupSectionOptions {
|
|||||||
close: () => void;
|
close: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AddSectionOptions {
|
||||||
|
/** If focus should move to the new section. Defaults to `true`. */
|
||||||
|
focus?: boolean;
|
||||||
|
/** If popups should be shown (e.g. Card Layout tip). Defaults to `true`. */
|
||||||
|
popups?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export class GristDoc extends DisposableWithEvents {
|
export class GristDoc extends DisposableWithEvents {
|
||||||
public docModel: DocModel;
|
public docModel: DocModel;
|
||||||
public viewModel: ViewRec;
|
public viewModel: ViewRec;
|
||||||
@ -894,38 +902,27 @@ export class GristDoc extends DisposableWithEvents {
|
|||||||
/**
|
/**
|
||||||
* Adds a view section described by val to the current page.
|
* Adds a view section described by val to the current page.
|
||||||
*/
|
*/
|
||||||
public async addWidgetToPage(val: IPageWidget) {
|
public async addWidgetToPage(widget: IPageWidget) {
|
||||||
const docData = this.docModel.docData;
|
const {table, type} = widget;
|
||||||
const viewName = this.viewModel.name.peek();
|
|
||||||
let tableId: string | null | undefined;
|
let tableId: string | null | undefined;
|
||||||
if (val.table === 'New Table') {
|
if (table === 'New Table') {
|
||||||
tableId = await this._promptForName();
|
tableId = await this._promptForName();
|
||||||
if (tableId === undefined) {
|
if (tableId === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (type === 'custom') {
|
||||||
const widgetType = getTelemetryWidgetTypeFromPageWidget(val);
|
return showCustomWidgetGallery(this, {
|
||||||
logTelemetryEvent('addedWidget', {full: {docIdDigest: this.docId(), widgetType}});
|
addWidget: () => this._addWidgetToPage(widget, tableId),
|
||||||
if (val.link !== NoLink) {
|
});
|
||||||
logTelemetryEvent('linkedWidget', {full: {docIdDigest: this.docId(), widgetType}});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const res: {sectionRef: number} = await docData.bundleActions(
|
const viewName = this.viewModel.name.peek();
|
||||||
|
const {sectionRef} = await this.docData.bundleActions(
|
||||||
t("Added new linked section to view {{viewName}}", {viewName}),
|
t("Added new linked section to view {{viewName}}", {viewName}),
|
||||||
() => this.addWidgetToPageImpl(val, tableId ?? null)
|
() => this._addWidgetToPage(widget, tableId ?? null)
|
||||||
);
|
);
|
||||||
|
return sectionRef;
|
||||||
// The newly-added section should be given focus.
|
|
||||||
this.viewModel.activeSectionId(res.sectionRef);
|
|
||||||
|
|
||||||
this._maybeShowEditCardLayoutTip(val.type).catch(reportError);
|
|
||||||
|
|
||||||
if (AttachedCustomWidgets.guard(val.type)) {
|
|
||||||
this._handleNewAttachedCustomWidget(val.type).catch(reportError);
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.sectionRef;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async onCreateForm() {
|
public async onCreateForm() {
|
||||||
@ -941,80 +938,31 @@ export class GristDoc extends DisposableWithEvents {
|
|||||||
commands.allCommands.expandSection.run();
|
commands.allCommands.expandSection.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The actual implementation of addWidgetToPage
|
|
||||||
*/
|
|
||||||
public async addWidgetToPageImpl(val: IPageWidget, tableId: string | null = null) {
|
|
||||||
const viewRef = this.activeViewId.get();
|
|
||||||
const tableRef = val.table === 'New Table' ? 0 : val.table;
|
|
||||||
const result = await this.docData.sendAction(
|
|
||||||
['CreateViewSection', tableRef, viewRef, val.type, val.summarize ? val.columns : null, tableId]
|
|
||||||
);
|
|
||||||
if (val.type === 'chart') {
|
|
||||||
await this._ensureOneNumericSeries(result.sectionRef);
|
|
||||||
}
|
|
||||||
if (val.type === 'form') {
|
|
||||||
await this._setDefaultFormLayoutSpec(result.sectionRef);
|
|
||||||
}
|
|
||||||
await this.saveLink(val.link, result.sectionRef);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds a new page (aka: view) with a single view section (aka: page widget) described by `val`.
|
* Adds a new page (aka: view) with a single view section (aka: page widget) described by `val`.
|
||||||
*/
|
*/
|
||||||
public async addNewPage(val: IPageWidget) {
|
public async addNewPage(val: IPageWidget) {
|
||||||
logTelemetryEvent('addedPage', {full: {docIdDigest: this.docId()}});
|
const {table, type} = val;
|
||||||
logTelemetryEvent('addedWidget', {
|
let tableId: string | null | undefined;
|
||||||
full: {
|
if (table === 'New Table') {
|
||||||
docIdDigest: this.docId(),
|
tableId = await this._promptForName();
|
||||||
widgetType: getTelemetryWidgetTypeFromPageWidget(val),
|
if (tableId === undefined) { return; }
|
||||||
},
|
}
|
||||||
});
|
if (type === 'custom') {
|
||||||
|
return showCustomWidgetGallery(this, {
|
||||||
let viewRef: IDocPage;
|
addWidget: () => this._addPage(val, tableId ?? null) as Promise<{
|
||||||
let sectionRef: number | undefined;
|
viewRef: number;
|
||||||
await this.docData.bundleActions('Add new page', async () => {
|
sectionRef: number;
|
||||||
if (val.table === 'New Table') {
|
}>,
|
||||||
const name = await this._promptForName();
|
});
|
||||||
if (name === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (val.type === WidgetType.Table) {
|
|
||||||
const result = await this.docData.sendAction(['AddEmptyTable', name]);
|
|
||||||
viewRef = result.views[0].id;
|
|
||||||
} else {
|
|
||||||
// This will create a new table and page.
|
|
||||||
const result = await this.docData.sendAction(
|
|
||||||
['CreateViewSection', /* new table */0, 0, val.type, null, name]
|
|
||||||
);
|
|
||||||
[viewRef, sectionRef] = [result.viewRef, result.sectionRef];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const result = await this.docData.sendAction(
|
|
||||||
['CreateViewSection', val.table, 0, val.type, val.summarize ? val.columns : null, null]
|
|
||||||
);
|
|
||||||
[viewRef, sectionRef] = [result.viewRef, result.sectionRef];
|
|
||||||
if (val.type === 'chart') {
|
|
||||||
await this._ensureOneNumericSeries(sectionRef!);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (val.type === 'form') {
|
|
||||||
await this._setDefaultFormLayoutSpec(sectionRef!);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.openDocPage(viewRef!);
|
|
||||||
if (sectionRef) {
|
|
||||||
// The newly-added section should be given focus.
|
|
||||||
this.viewModel.activeSectionId(sectionRef);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this._maybeShowEditCardLayoutTip(val.type).catch(reportError);
|
const {sectionRef, viewRef} = await this.docData.bundleActions(
|
||||||
|
'Add new page',
|
||||||
if (AttachedCustomWidgets.guard(val.type)) {
|
() => this._addPage(val, tableId ?? null)
|
||||||
this._handleNewAttachedCustomWidget(val.type).catch(reportError);
|
);
|
||||||
}
|
await this._focus({sectionRef, viewRef});
|
||||||
|
this._showNewWidgetPopups(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -1460,6 +1408,90 @@ export class GristDoc extends DisposableWithEvents {
|
|||||||
return values;
|
return values;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async _addWidgetToPage(
|
||||||
|
widget: IPageWidget,
|
||||||
|
tableId: string | null = null,
|
||||||
|
{focus = true, popups = true}: AddSectionOptions= {}
|
||||||
|
) {
|
||||||
|
const {columns, link, summarize, table, type} = widget;
|
||||||
|
const viewRef = this.activeViewId.get();
|
||||||
|
const tableRef = table === 'New Table' ? 0 : table;
|
||||||
|
const result: {viewRef: number, sectionRef: number} = await this.docData.sendAction(
|
||||||
|
['CreateViewSection', tableRef, viewRef, type, summarize ? columns : null, tableId]
|
||||||
|
);
|
||||||
|
if (type === 'chart') {
|
||||||
|
await this._ensureOneNumericSeries(result.sectionRef);
|
||||||
|
}
|
||||||
|
if (type === 'form') {
|
||||||
|
await this._setDefaultFormLayoutSpec(result.sectionRef);
|
||||||
|
}
|
||||||
|
await this.saveLink(link, result.sectionRef);
|
||||||
|
const widgetType = getTelemetryWidgetTypeFromPageWidget(widget);
|
||||||
|
logTelemetryEvent('addedWidget', {full: {docIdDigest: this.docId(), widgetType}});
|
||||||
|
if (link !== NoLink) {
|
||||||
|
logTelemetryEvent('linkedWidget', {full: {docIdDigest: this.docId(), widgetType}});
|
||||||
|
}
|
||||||
|
if (focus) { await this._focus({sectionRef: result.sectionRef}); }
|
||||||
|
if (popups) { this._showNewWidgetPopups(type); }
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _addPage(
|
||||||
|
widget: IPageWidget,
|
||||||
|
tableId: string | null = null,
|
||||||
|
{focus = true, popups = true}: AddSectionOptions = {}
|
||||||
|
) {
|
||||||
|
const {columns, summarize, table, type} = widget;
|
||||||
|
let viewRef: number;
|
||||||
|
let sectionRef: number | undefined;
|
||||||
|
if (table === 'New Table') {
|
||||||
|
if (type === WidgetType.Table) {
|
||||||
|
const result = await this.docData.sendAction(['AddEmptyTable', tableId]);
|
||||||
|
viewRef = result.views[0].id;
|
||||||
|
} else {
|
||||||
|
// This will create a new table and page.
|
||||||
|
const result = await this.docData.sendAction(
|
||||||
|
['CreateViewSection', 0, 0, type, null, tableId]
|
||||||
|
);
|
||||||
|
[viewRef, sectionRef] = [result.viewRef, result.sectionRef];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const result = await this.docData.sendAction(
|
||||||
|
['CreateViewSection', table, 0, type, summarize ? columns : null, null]
|
||||||
|
);
|
||||||
|
[viewRef, sectionRef] = [result.viewRef, result.sectionRef];
|
||||||
|
if (type === 'chart') {
|
||||||
|
await this._ensureOneNumericSeries(sectionRef!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (type === 'form') {
|
||||||
|
await this._setDefaultFormLayoutSpec(sectionRef!);
|
||||||
|
}
|
||||||
|
logTelemetryEvent('addedPage', {full: {docIdDigest: this.docId()}});
|
||||||
|
logTelemetryEvent('addedWidget', {
|
||||||
|
full: {
|
||||||
|
docIdDigest: this.docId(),
|
||||||
|
widgetType: getTelemetryWidgetTypeFromPageWidget(widget),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (focus) { await this._focus({viewRef, sectionRef}); }
|
||||||
|
if (popups) { this._showNewWidgetPopups(type); }
|
||||||
|
return {viewRef, sectionRef};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _focus({viewRef, sectionRef}: {viewRef?: number, sectionRef?: number}) {
|
||||||
|
if (viewRef) { await this.openDocPage(viewRef); }
|
||||||
|
if (sectionRef) { this.viewModel.activeSectionId(sectionRef); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private _showNewWidgetPopups(type: IWidgetType) {
|
||||||
|
this._maybeShowEditCardLayoutTip(type).catch(reportError);
|
||||||
|
|
||||||
|
if (AttachedCustomWidgets.guard(type)) {
|
||||||
|
this._handleNewAttachedCustomWidget(type).catch(reportError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens popup with a section data (used by Raw Data view).
|
* Opens popup with a section data (used by Raw Data view).
|
||||||
*/
|
*/
|
||||||
@ -1718,7 +1750,7 @@ export class GristDoc extends DisposableWithEvents {
|
|||||||
const sectionId = section.id();
|
const sectionId = section.id();
|
||||||
|
|
||||||
// create a new section
|
// create a new section
|
||||||
const sectionCreationResult = await this.addWidgetToPageImpl(newVal);
|
const sectionCreationResult = await this._addWidgetToPage(newVal, null, {focus: false, popups: false});
|
||||||
|
|
||||||
// update section name
|
// update section name
|
||||||
const newSection: ViewSectionRec = docModel.viewSections.getRowModel(sectionCreationResult.sectionRef);
|
const newSection: ViewSectionRec = docModel.viewSections.getRowModel(sectionCreationResult.sectionRef);
|
||||||
|
@ -195,7 +195,7 @@ export class LayoutTray extends DisposableWithEvents {
|
|||||||
box.dispose();
|
box.dispose();
|
||||||
|
|
||||||
// And ask the viewLayout to save the specs.
|
// And ask the viewLayout to save the specs.
|
||||||
viewLayout.saveLayoutSpec();
|
viewLayout.saveLayoutSpec().catch(reportError);
|
||||||
},
|
},
|
||||||
restoreSection: () => {
|
restoreSection: () => {
|
||||||
// Get the section that is collapsed and clicked (we are setting this value).
|
// Get the section that is collapsed and clicked (we are setting this value).
|
||||||
@ -206,23 +206,28 @@ export class LayoutTray extends DisposableWithEvents {
|
|||||||
viewLayout.viewModel.activeCollapsedSections.peek().filter(x => x !== leafId)
|
viewLayout.viewModel.activeCollapsedSections.peek().filter(x => x !== leafId)
|
||||||
);
|
);
|
||||||
viewLayout.viewModel.activeSectionId(leafId);
|
viewLayout.viewModel.activeSectionId(leafId);
|
||||||
viewLayout.saveLayoutSpec();
|
viewLayout.saveLayoutSpec().catch(reportError);
|
||||||
},
|
},
|
||||||
// Delete collapsed section.
|
// Delete collapsed section.
|
||||||
deleteCollapsedSection: () => {
|
deleteCollapsedSection: async () => {
|
||||||
// This section is still in the view (but not in the layout). So we can just remove it.
|
// This section is still in the view (but not in the layout). So we can just remove it.
|
||||||
const leafId = viewLayout.viewModel.activeCollapsedSectionId();
|
const leafId = viewLayout.viewModel.activeCollapsedSectionId();
|
||||||
if (!leafId) { return; }
|
if (!leafId) { return; }
|
||||||
this.viewLayout.removeViewSection(leafId);
|
|
||||||
// We need to manually update the layout. Main layout editor doesn't care about missing sections.
|
viewLayout.docModel.docData.bundleActions('removing section', async () => {
|
||||||
// but we can't afford that. Without removing it, user can add another section that will be collapsed
|
if (!await this.viewLayout.removeViewSection(leafId)) {
|
||||||
// from the start, as the id will be the same as the one we just removed.
|
return;
|
||||||
const currentSpec = viewLayout.viewModel.layoutSpecObj();
|
}
|
||||||
const validSections = new Set(viewLayout.viewModel.viewSections.peek().peek().map(vs => vs.id.peek()));
|
// We need to manually update the layout. Main layout editor doesn't care about missing sections.
|
||||||
validSections.delete(leafId);
|
// but we can't afford that. Without removing it, user can add another section that will be collapsed
|
||||||
currentSpec.collapsed = currentSpec.collapsed
|
// from the start, as the id will be the same as the one we just removed.
|
||||||
?.filter(x => typeof x.leaf === 'number' && validSections.has(x.leaf));
|
const currentSpec = viewLayout.viewModel.layoutSpecObj();
|
||||||
viewLayout.saveLayoutSpec(currentSpec);
|
const validSections = new Set(viewLayout.viewModel.viewSections.peek().peek().map(vs => vs.id.peek()));
|
||||||
|
validSections.delete(leafId);
|
||||||
|
currentSpec.collapsed = currentSpec.collapsed
|
||||||
|
?.filter(x => typeof x.leaf === 'number' && validSections.has(x.leaf));
|
||||||
|
await viewLayout.saveLayoutSpec(currentSpec);
|
||||||
|
}).catch(reportError);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
this.autoDispose(commands.createGroup(commandGroup, this, true));
|
this.autoDispose(commands.createGroup(commandGroup, this, true));
|
||||||
@ -843,7 +848,7 @@ class ExternalLeaf extends Disposable implements Dropped {
|
|||||||
// and the section won't be created on time.
|
// and the section won't be created on time.
|
||||||
this.model.viewLayout.layoutEditor.triggerUserEditStop();
|
this.model.viewLayout.layoutEditor.triggerUserEditStop();
|
||||||
// Manually save the layout.
|
// Manually save the layout.
|
||||||
this.model.viewLayout.saveLayoutSpec();
|
this.model.viewLayout.saveLayoutSpec().catch(reportError);
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
@ -20,7 +20,12 @@ import {reportError} from 'app/client/models/errors';
|
|||||||
import {getTelemetryWidgetTypeFromVS} from 'app/client/ui/widgetTypesMap';
|
import {getTelemetryWidgetTypeFromVS} from 'app/client/ui/widgetTypesMap';
|
||||||
import {isNarrowScreen, mediaSmall, testId, theme} from 'app/client/ui2018/cssVars';
|
import {isNarrowScreen, mediaSmall, testId, theme} from 'app/client/ui2018/cssVars';
|
||||||
import {icon} from 'app/client/ui2018/icons';
|
import {icon} from 'app/client/ui2018/icons';
|
||||||
|
import {ISaveModalOptions, saveModal} from 'app/client/ui2018/modals';
|
||||||
import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
|
import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
|
||||||
|
import {makeT} from 'app/client/lib/localization';
|
||||||
|
import {urlState} from 'app/client/models/gristUrlState';
|
||||||
|
import {cssRadioCheckboxOptions, radioCheckboxOption} from 'app/client/ui2018/checkbox';
|
||||||
|
import {cssLink} from 'app/client/ui2018/links';
|
||||||
import {mod} from 'app/common/gutil';
|
import {mod} from 'app/common/gutil';
|
||||||
import {
|
import {
|
||||||
Computed,
|
Computed,
|
||||||
@ -39,6 +44,8 @@ import * as ko from 'knockout';
|
|||||||
import debounce from 'lodash/debounce';
|
import debounce from 'lodash/debounce';
|
||||||
import * as _ from 'underscore';
|
import * as _ from 'underscore';
|
||||||
|
|
||||||
|
const t = makeT('ViewLayout');
|
||||||
|
|
||||||
// tslint:disable:no-console
|
// tslint:disable:no-console
|
||||||
|
|
||||||
const viewSectionTypes: {[key: string]: any} = {
|
const viewSectionTypes: {[key: string]: any} = {
|
||||||
@ -125,7 +132,7 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
|
|||||||
this.listenTo(this.layout, 'layoutUserEditStop', () => {
|
this.listenTo(this.layout, 'layoutUserEditStop', () => {
|
||||||
this.isResizing.set(false);
|
this.isResizing.set(false);
|
||||||
this.layoutSaveDelay.schedule(1000, () => {
|
this.layoutSaveDelay.schedule(1000, () => {
|
||||||
this.saveLayoutSpec();
|
this.saveLayoutSpec().catch(reportError);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -187,7 +194,7 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const commandGroup = {
|
const commandGroup = {
|
||||||
deleteSection: () => { this.removeViewSection(this.viewModel.activeSectionId()); },
|
deleteSection: () => { this.removeViewSection(this.viewModel.activeSectionId()).catch(reportError); },
|
||||||
nextSection: () => { this._otherSection(+1); },
|
nextSection: () => { this._otherSection(+1); },
|
||||||
prevSection: () => { this._otherSection(-1); },
|
prevSection: () => { this._otherSection(-1); },
|
||||||
printSection: () => { printViewSection(this.layout, this.viewModel.activeSection()).catch(reportError); },
|
printSection: () => { printViewSection(this.layout, this.viewModel.activeSection()).catch(reportError); },
|
||||||
@ -265,31 +272,83 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
|
|||||||
this._savePending.set(false);
|
this._savePending.set(false);
|
||||||
// Cancel the automatic delay.
|
// Cancel the automatic delay.
|
||||||
this.layoutSaveDelay.cancel();
|
this.layoutSaveDelay.cancel();
|
||||||
if (!this.layout) { return; }
|
if (!this.layout) { return Promise.resolve(); }
|
||||||
// Only save layout changes when the document isn't read-only.
|
// Only save layout changes when the document isn't read-only.
|
||||||
if (!this.gristDoc.isReadonly.get()) {
|
if (!this.gristDoc.isReadonly.get()) {
|
||||||
if (!specs) {
|
if (!specs) {
|
||||||
specs = this.layout.getLayoutSpec();
|
specs = this.layout.getLayoutSpec();
|
||||||
specs.collapsed = this.viewModel.activeCollapsedSections.peek().map((leaf)=> ({leaf}));
|
specs.collapsed = this.viewModel.activeCollapsedSections.peek().map((leaf)=> ({leaf}));
|
||||||
}
|
}
|
||||||
this.viewModel.layoutSpecObj.setAndSave(specs).catch(reportError);
|
return this.viewModel.layoutSpecObj.setAndSave(specs).catch(reportError);
|
||||||
}
|
}
|
||||||
this._onResize();
|
this._onResize();
|
||||||
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Removes a view section from the current view. Should only be called if there is
|
/**
|
||||||
// more than one viewsection in the view.
|
* Removes a view section from the current view. Should only be called if there is more than
|
||||||
public removeViewSection(viewSectionRowId: number) {
|
* one viewsection in the view.
|
||||||
|
* @returns A promise that resolves with true when the view section is removed. If user was
|
||||||
|
* prompted and decided to cancel, the promise resolves with false.
|
||||||
|
*/
|
||||||
|
public async removeViewSection(viewSectionRowId: number) {
|
||||||
this.maximized.set(null);
|
this.maximized.set(null);
|
||||||
const viewSection = this.viewModel.viewSections().all().find(s => s.getRowId() === viewSectionRowId);
|
const viewSection = this.viewModel.viewSections().all().find(s => s.getRowId() === viewSectionRowId);
|
||||||
if (!viewSection) {
|
if (!viewSection) {
|
||||||
throw new Error(`Section not found: ${viewSectionRowId}`);
|
throw new Error(`Section not found: ${viewSectionRowId}`);
|
||||||
}
|
}
|
||||||
|
const tableId = viewSection.table.peek().tableId.peek();
|
||||||
|
|
||||||
const widgetType = getTelemetryWidgetTypeFromVS(viewSection);
|
// Check if this is a UserTable (not summary) and if so, if it is available on any other page
|
||||||
logTelemetryEvent('deletedWidget', {full: {docIdDigest: this.gristDoc.docId(), widgetType}});
|
// we have access to (or even on this page but in different widget). If yes, then we are safe
|
||||||
|
// to remove it, otherwise we need to warn the user.
|
||||||
|
|
||||||
this.gristDoc.docData.sendAction(['RemoveViewSection', viewSectionRowId]).catch(reportError);
|
const logTelemetry = () => {
|
||||||
|
const widgetType = getTelemetryWidgetTypeFromVS(viewSection);
|
||||||
|
logTelemetryEvent('deletedWidget', {full: {docIdDigest: this.gristDoc.docId(), widgetType}});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isUserTable = () => viewSection.table.peek().isSummary.peek() === false;
|
||||||
|
|
||||||
|
const notInAnyOtherSection = () => {
|
||||||
|
// Get all viewSection we have access to, and check if the table is used in any of them.
|
||||||
|
const others = this.gristDoc.docModel.viewSections.rowModels
|
||||||
|
.filter(vs => !vs.isDisposed())
|
||||||
|
.filter(vs => vs.id.peek() !== viewSectionRowId)
|
||||||
|
.filter(vs => vs.isRaw.peek() === false)
|
||||||
|
.filter(vs => vs.isRecordCard.peek() === false)
|
||||||
|
.filter(vs => vs.tableId.peek() === viewSection.tableId.peek());
|
||||||
|
return others.length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const REMOVED = true, IGNORED = false;
|
||||||
|
|
||||||
|
const possibleActions = {
|
||||||
|
[DELETE_WIDGET]: async () => {
|
||||||
|
logTelemetry();
|
||||||
|
await this.gristDoc.docData.sendAction(['RemoveViewSection', viewSectionRowId]);
|
||||||
|
return REMOVED;
|
||||||
|
},
|
||||||
|
[DELETE_DATA]: async () => {
|
||||||
|
logTelemetry();
|
||||||
|
await this.gristDoc.docData.sendActions([
|
||||||
|
['RemoveViewSection', viewSectionRowId],
|
||||||
|
['RemoveTable', tableId],
|
||||||
|
]);
|
||||||
|
return REMOVED;
|
||||||
|
},
|
||||||
|
[CANCEL]: async () => IGNORED,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tableName = () => viewSection.table.peek().tableNameDef.peek();
|
||||||
|
|
||||||
|
const needPrompt = isUserTable() && notInAnyOtherSection();
|
||||||
|
|
||||||
|
const decision = needPrompt
|
||||||
|
? widgetRemovalPrompt(tableName())
|
||||||
|
: Promise.resolve(DELETE_WIDGET as PromptAction);
|
||||||
|
|
||||||
|
return possibleActions[await decision]();
|
||||||
}
|
}
|
||||||
|
|
||||||
public rebuildLayout(layoutSpec: BoxSpec) {
|
public rebuildLayout(layoutSpec: BoxSpec) {
|
||||||
@ -417,6 +476,47 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DELETE_WIDGET = 'deleteOnlyWidget';
|
||||||
|
const DELETE_DATA = 'deleteDataAndWidget';
|
||||||
|
const CANCEL = 'cancel';
|
||||||
|
type PromptAction = typeof DELETE_WIDGET | typeof DELETE_DATA | typeof CANCEL;
|
||||||
|
|
||||||
|
function widgetRemovalPrompt(tableName: string): Promise<PromptAction> {
|
||||||
|
return new Promise<PromptAction>((resolve) => {
|
||||||
|
saveModal((ctl, owner): ISaveModalOptions => {
|
||||||
|
const selected = Observable.create<PromptAction | ''>(owner, '');
|
||||||
|
const saveDisabled = Computed.create(owner, use => use(selected) === '');
|
||||||
|
const saveFunc = async () => selected.get() && resolve(selected.get() as PromptAction);
|
||||||
|
owner.onDispose(() => resolve(CANCEL));
|
||||||
|
return {
|
||||||
|
title: t('Table {{tableName}} will no longer be visible', { tableName }),
|
||||||
|
body: dom('div',
|
||||||
|
testId('removePopup'),
|
||||||
|
cssRadioCheckboxOptions(
|
||||||
|
radioCheckboxOption(selected, DELETE_DATA, t("Delete data and this widget.")),
|
||||||
|
radioCheckboxOption(selected, DELETE_WIDGET,
|
||||||
|
t(
|
||||||
|
`Keep data and delete widget. Table will remain available in {{rawDataLink}}`,
|
||||||
|
{
|
||||||
|
rawDataLink: cssLink(
|
||||||
|
t('raw data page'),
|
||||||
|
urlState().setHref({docPage: 'data'}),
|
||||||
|
{target: '_blank'},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
saveDisabled,
|
||||||
|
saveLabel: t("Delete"),
|
||||||
|
saveFunc,
|
||||||
|
width: 'fixed-wide',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const cssLayoutBox = styled('div', `
|
const cssLayoutBox = styled('div', `
|
||||||
@media screen and ${mediaSmall} {
|
@media screen and ${mediaSmall} {
|
||||||
&-active, &-inactive {
|
&-active, &-inactive {
|
||||||
|
@ -223,10 +223,15 @@ export class WidgetFrame extends DisposableWithEvents {
|
|||||||
|
|
||||||
// Appends access level to query string.
|
// Appends access level to query string.
|
||||||
private _urlWithAccess(url: string) {
|
private _urlWithAccess(url: string) {
|
||||||
if (!url) {
|
if (!url) { return url; }
|
||||||
|
|
||||||
|
let urlObj: URL;
|
||||||
|
try {
|
||||||
|
urlObj = new URL(url);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
const urlObj = new URL(url);
|
|
||||||
urlObj.searchParams.append('access', this._options.access);
|
urlObj.searchParams.append('access', this._options.access);
|
||||||
urlObj.searchParams.append('readonly', String(this._options.readonly));
|
urlObj.searchParams.append('readonly', String(this._options.readonly));
|
||||||
// Append user and document preferences to query string.
|
// Append user and document preferences to query string.
|
||||||
|
@ -25,7 +25,6 @@ export type CommandName =
|
|||||||
| 'expandSection'
|
| 'expandSection'
|
||||||
| 'leftPanelOpen'
|
| 'leftPanelOpen'
|
||||||
| 'rightPanelOpen'
|
| 'rightPanelOpen'
|
||||||
| 'videoTourToolsOpen'
|
|
||||||
| 'cursorDown'
|
| 'cursorDown'
|
||||||
| 'cursorUp'
|
| 'cursorUp'
|
||||||
| 'cursorRight'
|
| 'cursorRight'
|
||||||
@ -269,11 +268,6 @@ export const groups: CommendGroupDef[] = [{
|
|||||||
keys: [],
|
keys: [],
|
||||||
desc: 'Shortcut to open the right panel',
|
desc: 'Shortcut to open the right panel',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'videoTourToolsOpen',
|
|
||||||
keys: [],
|
|
||||||
desc: 'Shortcut to open video tour from home left panel',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'activateAssistant',
|
name: 'activateAssistant',
|
||||||
keys: [],
|
keys: [],
|
||||||
|
@ -134,6 +134,10 @@ div:hover > .kf_tooltip {
|
|||||||
z-index: 11;
|
z-index: 11;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.kf_prompt_content:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
.kf_draggable {
|
.kf_draggable {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
29
app/client/lib/markdown.ts
Normal file
29
app/client/lib/markdown.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { sanitizeHTML } from 'app/client/ui/sanitizeHTML';
|
||||||
|
import { BindableValue, DomElementMethod, subscribeElem } from 'grainjs';
|
||||||
|
import { marked } from 'marked';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function for using Markdown in grainjs elements. It accepts
|
||||||
|
* both plain Markdown strings, as well as methods that use an observable.
|
||||||
|
* Example usage:
|
||||||
|
*
|
||||||
|
* cssSection(markdown(t(`# New Markdown Function
|
||||||
|
*
|
||||||
|
* We can _write_ [the usual Markdown](https://markdownguide.org) *inside*
|
||||||
|
* a Grainjs element.`)));
|
||||||
|
*
|
||||||
|
* or
|
||||||
|
*
|
||||||
|
* cssSection(markdown(use => use(toggle) ? t('The toggle is **on**') : t('The toggle is **off**'));
|
||||||
|
*
|
||||||
|
* Markdown strings are easier for our translators to handle, as it's possible
|
||||||
|
* to include all of the context around a single markdown string without
|
||||||
|
* breaking it up into separate strings for grainjs elements.
|
||||||
|
*/
|
||||||
|
export function markdown(markdownObs: BindableValue<string>): DomElementMethod {
|
||||||
|
return elem => subscribeElem(elem, markdownObs, value => setMarkdownValue(elem, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMarkdownValue(elem: Element, markdownValue: string): void {
|
||||||
|
elem.innerHTML = sanitizeHTML(marked(markdownValue));
|
||||||
|
}
|
@ -62,8 +62,6 @@ export interface TopAppModel {
|
|||||||
orgs: Observable<Organization[]>;
|
orgs: Observable<Organization[]>;
|
||||||
users: Observable<FullUser[]>;
|
users: Observable<FullUser[]>;
|
||||||
|
|
||||||
customWidgets: Observable<ICustomWidget[]|null>;
|
|
||||||
|
|
||||||
// Reinitialize the app. This is called when org or user changes.
|
// Reinitialize the app. This is called when org or user changes.
|
||||||
initialize(): void;
|
initialize(): void;
|
||||||
|
|
||||||
@ -162,26 +160,26 @@ export class TopAppModelImpl extends Disposable implements TopAppModel {
|
|||||||
public readonly orgs = Observable.create<Organization[]>(this, []);
|
public readonly orgs = Observable.create<Organization[]>(this, []);
|
||||||
public readonly users = Observable.create<FullUser[]>(this, []);
|
public readonly users = Observable.create<FullUser[]>(this, []);
|
||||||
public readonly plugins: LocalPlugin[] = [];
|
public readonly plugins: LocalPlugin[] = [];
|
||||||
public readonly customWidgets = Observable.create<ICustomWidget[]|null>(this, null);
|
private readonly _gristConfig? = this._window.gristConfig;
|
||||||
private readonly _gristConfig?: GristLoadConfig;
|
|
||||||
// Keep a list of available widgets, once requested, so we don't have to
|
// Keep a list of available widgets, once requested, so we don't have to
|
||||||
// keep reloading it. Downside: browser page will need reloading to pick
|
// keep reloading it. Downside: browser page will need reloading to pick
|
||||||
// up new widgets - that seems ok.
|
// up new widgets - that seems ok.
|
||||||
private readonly _widgets: AsyncCreate<ICustomWidget[]>;
|
private readonly _widgets: AsyncCreate<ICustomWidget[]>;
|
||||||
|
|
||||||
constructor(window: {gristConfig?: GristLoadConfig},
|
constructor(private _window: {gristConfig?: GristLoadConfig},
|
||||||
public readonly api: UserAPI = newUserAPIImpl(),
|
public readonly api: UserAPI = newUserAPIImpl(),
|
||||||
public readonly options: TopAppModelOptions = {}
|
public readonly options: TopAppModelOptions = {}
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
setErrorNotifier(this.notifier);
|
setErrorNotifier(this.notifier);
|
||||||
this.isSingleOrg = Boolean(window.gristConfig && window.gristConfig.singleOrg);
|
this.isSingleOrg = Boolean(this._gristConfig?.singleOrg);
|
||||||
this.productFlavor = getFlavor(window.gristConfig && window.gristConfig.org);
|
this.productFlavor = getFlavor(this._gristConfig?.org);
|
||||||
this._gristConfig = window.gristConfig;
|
|
||||||
this._widgets = new AsyncCreate<ICustomWidget[]>(async () => {
|
this._widgets = new AsyncCreate<ICustomWidget[]>(async () => {
|
||||||
const widgets = this.options.useApi === false ? [] : await this.api.getWidgets();
|
if (this.options.useApi === false || !this._gristConfig?.enableWidgetRepository) {
|
||||||
this.customWidgets.set(widgets);
|
return [];
|
||||||
return widgets;
|
}
|
||||||
|
|
||||||
|
return await this.api.getWidgets();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initially, and on any change to subdomain, call initialize() to get the full Organization
|
// Initially, and on any change to subdomain, call initialize() to get the full Organization
|
||||||
@ -214,8 +212,7 @@ export class TopAppModelImpl extends Disposable implements TopAppModel {
|
|||||||
public async testReloadWidgets() {
|
public async testReloadWidgets() {
|
||||||
console.log("testReloadWidgets");
|
console.log("testReloadWidgets");
|
||||||
this._widgets.clear();
|
this._widgets.clear();
|
||||||
this.customWidgets.set(null);
|
console.log("testReloadWidgets cleared");
|
||||||
console.log("testReloadWidgets cleared and nulled");
|
|
||||||
const result = await this.getWidgets();
|
const result = await this.getWidgets();
|
||||||
console.log("testReloadWidgets got", {result});
|
console.log("testReloadWidgets got", {result});
|
||||||
}
|
}
|
||||||
@ -392,6 +389,10 @@ export class AppModelImpl extends Disposable implements AppModel {
|
|||||||
this.behavioralPromptsManager.reset();
|
this.behavioralPromptsManager.reset();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
G.window.resetOnboarding = () => {
|
||||||
|
getUserPrefObs(this.userPrefsObs, 'showNewUserQuestions').set(true);
|
||||||
|
};
|
||||||
|
|
||||||
this.autoDispose(subscribe(urlState().state, this.topAppModel.orgs, async (_use, s, orgs) => {
|
this.autoDispose(subscribe(urlState().state, this.topAppModel.orgs, async (_use, s, orgs) => {
|
||||||
this._updateLastVisitedOrgDomain(s, orgs);
|
this._updateLastVisitedOrgDomain(s, orgs);
|
||||||
}));
|
}));
|
||||||
|
@ -223,16 +223,21 @@ export class DocModel {
|
|||||||
this.allPages = ko.computed(() => allPages.all());
|
this.allPages = ko.computed(() => allPages.all());
|
||||||
this.menuPages = ko.computed(() => {
|
this.menuPages = ko.computed(() => {
|
||||||
const pagesToShow = this.allPages().filter(p => !p.isSpecial()).sort((a, b) => a.pagePos() - b.pagePos());
|
const pagesToShow = this.allPages().filter(p => !p.isSpecial()).sort((a, b) => a.pagePos() - b.pagePos());
|
||||||
// Helper to find all children of a page.
|
const parent = memoize((page: PageRec) => {
|
||||||
const children = memoize((page: PageRec) => {
|
const myIdentation = page.indentation();
|
||||||
const following = pagesToShow.slice(pagesToShow.indexOf(page) + 1);
|
if (myIdentation === 0) { return null; }
|
||||||
const firstOutside = following.findIndex(p => p.indentation() <= page.indentation());
|
const idx = pagesToShow.indexOf(page);
|
||||||
return firstOutside >= 0 ? following.slice(0, firstOutside) : following;
|
// Find first page starting from before that has lower indentation then mine.
|
||||||
|
const beforeMe = pagesToShow.slice(0, idx).reverse();
|
||||||
|
return beforeMe.find(p => p.indentation() < myIdentation) ?? null;
|
||||||
});
|
});
|
||||||
// Helper to test if the page is hidden and all its children are hidden.
|
const ancestors = memoize((page: PageRec): PageRec[] => {
|
||||||
// In that case, we won't show it at all.
|
const anc = parent(page);
|
||||||
const hide = memoize((page: PageRec): boolean => page.isCensored() && children(page).every(p => hide(p)));
|
return anc ? [anc, ...ancestors(anc)] : [];
|
||||||
return pagesToShow.filter(p => !hide(p));
|
});
|
||||||
|
// Helper to test if the page is hidden or is in a hidden branch.
|
||||||
|
const hidden = memoize((page: PageRec): boolean => page.isHidden() || ancestors(page).some(p => p.isHidden()));
|
||||||
|
return pagesToShow.filter(p => !hidden(p));
|
||||||
});
|
});
|
||||||
this.visibleDocPages = ko.computed(() => this.allPages().filter(p => !p.isHidden()));
|
this.visibleDocPages = ko.computed(() => this.allPages().filter(p => !p.isHidden()));
|
||||||
|
|
||||||
|
@ -7,7 +7,7 @@ import {AppModel, reportError} from 'app/client/models/AppModel';
|
|||||||
import {reportMessage, UserError} from 'app/client/models/errors';
|
import {reportMessage, UserError} from 'app/client/models/errors';
|
||||||
import {urlState} from 'app/client/models/gristUrlState';
|
import {urlState} from 'app/client/models/gristUrlState';
|
||||||
import {ownerName} from 'app/client/models/WorkspaceInfo';
|
import {ownerName} from 'app/client/models/WorkspaceInfo';
|
||||||
import {IHomePage} from 'app/common/gristUrls';
|
import {IHomePage, isFeatureEnabled} from 'app/common/gristUrls';
|
||||||
import {isLongerThan} from 'app/common/gutil';
|
import {isLongerThan} from 'app/common/gutil';
|
||||||
import {SortPref, UserOrgPrefs, ViewPref} from 'app/common/Prefs';
|
import {SortPref, UserOrgPrefs, ViewPref} from 'app/common/Prefs';
|
||||||
import * as roles from 'app/common/roles';
|
import * as roles from 'app/common/roles';
|
||||||
@ -59,6 +59,8 @@ export interface HomeModel {
|
|||||||
|
|
||||||
shouldShowAddNewTip: Observable<boolean>;
|
shouldShowAddNewTip: Observable<boolean>;
|
||||||
|
|
||||||
|
onboardingTutorial: Observable<Document|null>;
|
||||||
|
|
||||||
createWorkspace(name: string): Promise<void>;
|
createWorkspace(name: string): Promise<void>;
|
||||||
renameWorkspace(id: number, name: string): Promise<void>;
|
renameWorkspace(id: number, name: string): Promise<void>;
|
||||||
deleteWorkspace(id: number, forever: boolean): Promise<void>;
|
deleteWorkspace(id: number, forever: boolean): Promise<void>;
|
||||||
@ -141,6 +143,8 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings
|
|||||||
public readonly shouldShowAddNewTip = Observable.create(this,
|
public readonly shouldShowAddNewTip = Observable.create(this,
|
||||||
!this._app.behavioralPromptsManager.hasSeenPopup('addNew'));
|
!this._app.behavioralPromptsManager.hasSeenPopup('addNew'));
|
||||||
|
|
||||||
|
public readonly onboardingTutorial = Observable.create<Document|null>(this, null);
|
||||||
|
|
||||||
private _userOrgPrefs = Observable.create<UserOrgPrefs|undefined>(this, this._app.currentOrg?.userOrgPrefs);
|
private _userOrgPrefs = Observable.create<UserOrgPrefs|undefined>(this, this._app.currentOrg?.userOrgPrefs);
|
||||||
|
|
||||||
constructor(private _app: AppModel, clientScope: ClientScope) {
|
constructor(private _app: AppModel, clientScope: ClientScope) {
|
||||||
@ -176,6 +180,8 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings
|
|||||||
this.importSources.set(importSources);
|
this.importSources.set(importSources);
|
||||||
|
|
||||||
this._app.refreshOrgUsage().catch(reportError);
|
this._app.refreshOrgUsage().catch(reportError);
|
||||||
|
|
||||||
|
this._loadWelcomeTutorial().catch(reportError);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Accessor for the AppModel containing this HomeModel.
|
// Accessor for the AppModel containing this HomeModel.
|
||||||
@ -370,6 +376,28 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings
|
|||||||
return templateWss;
|
return templateWss;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async _loadWelcomeTutorial() {
|
||||||
|
const {templateOrg, onboardingTutorialDocId} = getGristConfig();
|
||||||
|
if (
|
||||||
|
!isFeatureEnabled('tutorials') ||
|
||||||
|
!templateOrg ||
|
||||||
|
!onboardingTutorialDocId ||
|
||||||
|
this._app.dismissedPopups.get().includes('onboardingCards')
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const doc = await this._app.api.getTemplate(onboardingTutorialDocId);
|
||||||
|
if (this.isDisposed()) { return; }
|
||||||
|
|
||||||
|
this.onboardingTutorial.set(doc);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
reportError('Failed to load welcome tutorial');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async _saveUserOrgPref<K extends keyof UserOrgPrefs>(key: K, value: UserOrgPrefs[K]) {
|
private async _saveUserOrgPref<K extends keyof UserOrgPrefs>(key: K, value: UserOrgPrefs[K]) {
|
||||||
const org = this._app.currentOrg;
|
const org = this._app.currentOrg;
|
||||||
if (org) {
|
if (org) {
|
||||||
|
44
app/client/models/ToggleEnterpriseModel.ts
Normal file
44
app/client/models/ToggleEnterpriseModel.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import {getHomeUrl} from 'app/client/models/AppModel';
|
||||||
|
import {Disposable, Observable} from "grainjs";
|
||||||
|
import {ConfigAPI} from 'app/common/ConfigAPI';
|
||||||
|
import {delay} from 'app/common/delay';
|
||||||
|
|
||||||
|
export class ToggleEnterpriseModel extends Disposable {
|
||||||
|
public readonly edition: Observable<string | null> = Observable.create(this, null);
|
||||||
|
private readonly _configAPI: ConfigAPI = new ConfigAPI(getHomeUrl());
|
||||||
|
|
||||||
|
public async fetchEnterpriseToggle(): Promise<void> {
|
||||||
|
const edition = await this._configAPI.getValue('edition');
|
||||||
|
this.edition.set(edition);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateEnterpriseToggle(edition: string): Promise<void> {
|
||||||
|
// We may be restarting the server, so these requests may well
|
||||||
|
// fail if done in quick succession.
|
||||||
|
await retryOnNetworkError(() => this._configAPI.setValue({edition}));
|
||||||
|
this.edition.set(edition);
|
||||||
|
await retryOnNetworkError(() => this._configAPI.restartServer());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from DocPageModel.ts
|
||||||
|
const reconnectIntervals = [1000, 1000, 2000, 5000, 10000];
|
||||||
|
async function retryOnNetworkError<R>(func: () => Promise<R>): Promise<R> {
|
||||||
|
for (let attempt = 0; ; attempt++) {
|
||||||
|
try {
|
||||||
|
return await func();
|
||||||
|
} catch (err) {
|
||||||
|
// fetch() promises that network errors are reported as TypeError. We'll accept NetworkError too.
|
||||||
|
if (err.name !== "TypeError" && err.name !== "NetworkError") {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
// We really can't reach the server. Make it known.
|
||||||
|
if (attempt >= reconnectIntervals.length) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const reconnectTimeout = reconnectIntervals[attempt];
|
||||||
|
console.warn(`Call to ${func.name} failed, will retry in ${reconnectTimeout} ms`, err);
|
||||||
|
await delay(reconnectTimeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -39,6 +39,7 @@ export interface TableRec extends IRowModel<"_grist_Tables"> {
|
|||||||
// If user can select this table in various places.
|
// If user can select this table in various places.
|
||||||
// Note: Some hidden tables can still be visible on RawData view.
|
// Note: Some hidden tables can still be visible on RawData view.
|
||||||
isHidden: ko.Computed<boolean>;
|
isHidden: ko.Computed<boolean>;
|
||||||
|
isSummary: ko.Computed<boolean>;
|
||||||
|
|
||||||
tableColor: string;
|
tableColor: string;
|
||||||
disableAddRemoveRows: ko.Computed<boolean>;
|
disableAddRemoveRows: ko.Computed<boolean>;
|
||||||
@ -68,6 +69,8 @@ export function createTableRec(this: TableRec, docModel: DocModel): void {
|
|||||||
this.primaryTableId = ko.pureComputed(() =>
|
this.primaryTableId = ko.pureComputed(() =>
|
||||||
this.summarySourceTable() ? this.summarySource().tableId() : this.tableId());
|
this.summarySourceTable() ? this.summarySource().tableId() : this.tableId());
|
||||||
|
|
||||||
|
this.isSummary = this.autoDispose(ko.pureComputed(() => Boolean(this.summarySourceTable())));
|
||||||
|
|
||||||
this.groupByColumns = ko.pureComputed(() => this.columns().all().filter(c => c.summarySourceCol()));
|
this.groupByColumns = ko.pureComputed(() => this.columns().all().filter(c => c.summarySourceCol()));
|
||||||
|
|
||||||
this.groupDesc = ko.pureComputed(() => {
|
this.groupDesc = ko.pureComputed(() => {
|
||||||
|
@ -93,9 +93,12 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleO
|
|||||||
// in which case the UI prevents various things like hiding columns or changing the widget type.
|
// in which case the UI prevents various things like hiding columns or changing the widget type.
|
||||||
isRaw: ko.Computed<boolean>;
|
isRaw: ko.Computed<boolean>;
|
||||||
|
|
||||||
tableRecordCard: ko.Computed<ViewSectionRec>
|
/** Is this table card viewsection (the one available after pressing spacebar) */
|
||||||
isRecordCard: ko.Computed<boolean>;
|
isRecordCard: ko.Computed<boolean>;
|
||||||
|
|
||||||
|
/** Card record viewSection for associated table (might be the same section) */
|
||||||
|
tableRecordCard: ko.Computed<ViewSectionRec>;
|
||||||
|
|
||||||
/** True if this section is disabled. Currently only used by Record Card sections. */
|
/** True if this section is disabled. Currently only used by Record Card sections. */
|
||||||
disabled: modelUtil.KoSaveableObservable<boolean>;
|
disabled: modelUtil.KoSaveableObservable<boolean>;
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import {AppModel} from 'app/client/models/AppModel';
|
import {AppModel} from 'app/client/models/AppModel';
|
||||||
import {DocPageModel} from 'app/client/models/DocPageModel';
|
import {DocPageModel} from 'app/client/models/DocPageModel';
|
||||||
import {getLoginOrSignupUrl, getLoginUrl, getLogoutUrl, getSignupUrl, urlState} from 'app/client/models/gristUrlState';
|
import {getLoginOrSignupUrl, getLoginUrl, getLogoutUrl, getSignupUrl, urlState} from 'app/client/models/gristUrlState';
|
||||||
import {getAdminPanelName} from 'app/client/ui/AdminPanel';
|
import {getAdminPanelName} from 'app/client/ui/AdminPanelName';
|
||||||
import {manageTeamUsers} from 'app/client/ui/OpenUserManager';
|
import {manageTeamUsers} from 'app/client/ui/OpenUserManager';
|
||||||
import {createUserImage} from 'app/client/ui/UserImage';
|
import {createUserImage} from 'app/client/ui/UserImage';
|
||||||
import * as viewport from 'app/client/ui/viewport';
|
import * as viewport from 'app/client/ui/viewport';
|
||||||
|
@ -1,13 +1,7 @@
|
|||||||
import {HomeModel} from 'app/client/models/HomeModel';
|
import {HomeModel} from 'app/client/models/HomeModel';
|
||||||
import {shouldShowWelcomeQuestions} from 'app/client/ui/WelcomeQuestions';
|
|
||||||
|
|
||||||
export function attachAddNewTip(home: HomeModel): (el: Element) => void {
|
export function attachAddNewTip(home: HomeModel): (el: Element) => void {
|
||||||
return () => {
|
return () => {
|
||||||
const {app: {userPrefsObs}} = home;
|
|
||||||
if (shouldShowWelcomeQuestions(userPrefsObs)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldShowAddNewTip(home)) {
|
if (shouldShowAddNewTip(home)) {
|
||||||
showAddNewTip(home);
|
showAddNewTip(home);
|
||||||
}
|
}
|
||||||
|
@ -9,6 +9,7 @@ import {AppHeader} from 'app/client/ui/AppHeader';
|
|||||||
import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon';
|
import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon';
|
||||||
import {pagePanels} from 'app/client/ui/PagePanels';
|
import {pagePanels} from 'app/client/ui/PagePanels';
|
||||||
import {SupportGristPage} from 'app/client/ui/SupportGristPage';
|
import {SupportGristPage} from 'app/client/ui/SupportGristPage';
|
||||||
|
import {ToggleEnterpriseWidget} from 'app/client/ui/ToggleEnterpriseWidget';
|
||||||
import {createTopBarHome} from 'app/client/ui/TopBar';
|
import {createTopBarHome} from 'app/client/ui/TopBar';
|
||||||
import {cssBreadcrumbs, separator} from 'app/client/ui2018/breadcrumbs';
|
import {cssBreadcrumbs, separator} from 'app/client/ui2018/breadcrumbs';
|
||||||
import {basicButton} from 'app/client/ui2018/buttons';
|
import {basicButton} from 'app/client/ui2018/buttons';
|
||||||
@ -24,17 +25,14 @@ import * as version from 'app/common/version';
|
|||||||
import {Computed, Disposable, dom, IDisposable,
|
import {Computed, Disposable, dom, IDisposable,
|
||||||
IDisposableOwner, MultiHolder, Observable, styled} from 'grainjs';
|
IDisposableOwner, MultiHolder, Observable, styled} from 'grainjs';
|
||||||
import {AdminSection, AdminSectionItem, HidableToggle} from 'app/client/ui/AdminPanelCss';
|
import {AdminSection, AdminSectionItem, HidableToggle} from 'app/client/ui/AdminPanelCss';
|
||||||
|
import {getAdminPanelName} from 'app/client/ui/AdminPanelName';
|
||||||
|
import {showEnterpriseToggle} from 'app/client/ui/ActivationPage';
|
||||||
|
|
||||||
const t = makeT('AdminPanel');
|
const t = makeT('AdminPanel');
|
||||||
|
|
||||||
// Translated "Admin Panel" name, made available to other modules.
|
|
||||||
export function getAdminPanelName() {
|
|
||||||
return t("Admin Panel");
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AdminPanel extends Disposable {
|
export class AdminPanel extends Disposable {
|
||||||
private _supportGrist = SupportGristPage.create(this, this._appModel);
|
private _supportGrist = SupportGristPage.create(this, this._appModel);
|
||||||
|
private _toggleEnterprise = ToggleEnterpriseWidget.create(this);
|
||||||
private readonly _installAPI: InstallAPI = new InstallAPIImpl(getHomeUrl());
|
private readonly _installAPI: InstallAPI = new InstallAPIImpl(getHomeUrl());
|
||||||
private _checks: AdminChecks;
|
private _checks: AdminChecks;
|
||||||
|
|
||||||
@ -145,6 +143,13 @@ Please log in as an administrator.`)),
|
|||||||
description: t('Current authentication method'),
|
description: t('Current authentication method'),
|
||||||
value: this._buildAuthenticationDisplay(owner),
|
value: this._buildAuthenticationDisplay(owner),
|
||||||
expandedContent: this._buildAuthenticationNotice(owner),
|
expandedContent: this._buildAuthenticationNotice(owner),
|
||||||
|
}),
|
||||||
|
dom.create(AdminSectionItem, {
|
||||||
|
id: 'session',
|
||||||
|
name: t('Session Secret'),
|
||||||
|
description: t('Key to sign sessions with'),
|
||||||
|
value: this._buildSessionSecretDisplay(owner),
|
||||||
|
expandedContent: this._buildSessionSecretNotice(owner),
|
||||||
})
|
})
|
||||||
]),
|
]),
|
||||||
dom.create(AdminSection, t('Version'), [
|
dom.create(AdminSection, t('Version'), [
|
||||||
@ -154,6 +159,7 @@ Please log in as an administrator.`)),
|
|||||||
description: t('Current version of Grist'),
|
description: t('Current version of Grist'),
|
||||||
value: cssValueLabel(`Version ${version.version}`),
|
value: cssValueLabel(`Version ${version.version}`),
|
||||||
}),
|
}),
|
||||||
|
this._maybeAddEnterpriseToggle(),
|
||||||
this._buildUpdates(owner),
|
this._buildUpdates(owner),
|
||||||
]),
|
]),
|
||||||
dom.create(AdminSection, t('Self Checks'), [
|
dom.create(AdminSection, t('Self Checks'), [
|
||||||
@ -175,6 +181,19 @@ Please log in as an administrator.`)),
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _maybeAddEnterpriseToggle() {
|
||||||
|
if (!showEnterpriseToggle()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return dom.create(AdminSectionItem, {
|
||||||
|
id: 'enterprise',
|
||||||
|
name: t('Enterprise'),
|
||||||
|
description: t('Enable Grist Enterprise'),
|
||||||
|
value: dom.create(HidableToggle, this._toggleEnterprise.getEnterpriseToggleObservable()),
|
||||||
|
expandedContent: this._toggleEnterprise.buildEnterpriseSection(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private _buildSandboxingDisplay(owner: IDisposableOwner) {
|
private _buildSandboxingDisplay(owner: IDisposableOwner) {
|
||||||
return dom.domComputed(
|
return dom.domComputed(
|
||||||
use => {
|
use => {
|
||||||
@ -241,6 +260,27 @@ We recommend enabling one of these if Grist is accessible over the network or be
|
|||||||
to multiple people.');
|
to multiple people.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _buildSessionSecretDisplay(owner: IDisposableOwner) {
|
||||||
|
return dom.domComputed(
|
||||||
|
use => {
|
||||||
|
const req = this._checks.requestCheckById(use, 'session-secret');
|
||||||
|
const result = req ? use(req.result) : undefined;
|
||||||
|
|
||||||
|
if (result?.status === 'warning') {
|
||||||
|
return cssValueLabel(cssDangerText('default'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return cssValueLabel(cssHappyText('configured'));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _buildSessionSecretNotice(owner: IDisposableOwner) {
|
||||||
|
return t('Grist signs user session cookies with a secret key. Please set this key via the environment variable \
|
||||||
|
GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice \
|
||||||
|
in the future as session IDs generated since v1.1.16 are inherently cryptographically secure.');
|
||||||
|
}
|
||||||
|
|
||||||
private _buildUpdates(owner: MultiHolder) {
|
private _buildUpdates(owner: MultiHolder) {
|
||||||
// We can be in those states:
|
// We can be in those states:
|
||||||
enum State {
|
enum State {
|
||||||
@ -472,7 +512,11 @@ to multiple people.');
|
|||||||
return dom.domComputed(
|
return dom.domComputed(
|
||||||
use => [
|
use => [
|
||||||
...use(this._checks.probes).map(probe => {
|
...use(this._checks.probes).map(probe => {
|
||||||
const isRedundant = probe.id === 'sandboxing';
|
const isRedundant = [
|
||||||
|
'sandboxing',
|
||||||
|
'authentication',
|
||||||
|
'session-secret'
|
||||||
|
].includes(probe.id);
|
||||||
const show = isRedundant ? options.showRedundant : options.showNovel;
|
const show = isRedundant ? options.showRedundant : options.showNovel;
|
||||||
if (!show) { return null; }
|
if (!show) { return null; }
|
||||||
const req = this._checks.requestCheck(probe);
|
const req = this._checks.requestCheck(probe);
|
||||||
|
11
app/client/ui/AdminPanelName.ts
Normal file
11
app/client/ui/AdminPanelName.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
// Separated out into its own file because this is used in several modules, but we'd like to avoid
|
||||||
|
// pulling in the full AdminPanel into their bundle.
|
||||||
|
|
||||||
|
import {makeT} from 'app/client/lib/localization';
|
||||||
|
|
||||||
|
const t = makeT('AdminPanel');
|
||||||
|
|
||||||
|
// Translated "Admin Panel" name, made available to other modules.
|
||||||
|
export function getAdminPanelName() {
|
||||||
|
return t("Admin Panel");
|
||||||
|
}
|
45
app/client/ui/AdminTogglesCss.ts
Normal file
45
app/client/ui/AdminTogglesCss.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import {bigBasicButton, bigBasicButtonLink, bigPrimaryButton} from 'app/client/ui2018/buttons';
|
||||||
|
import {theme} from 'app/client/ui2018/cssVars';
|
||||||
|
import {styled} from 'grainjs';
|
||||||
|
|
||||||
|
export const cssSection = styled('div', ``);
|
||||||
|
|
||||||
|
export const cssParagraph = styled('div', `
|
||||||
|
color: ${theme.text};
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
export const cssOptInOutMessage = styled(cssParagraph, `
|
||||||
|
line-height: 40px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 24px;
|
||||||
|
margin-bottom: 0px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
export const cssOptInButton = styled(bigPrimaryButton, `
|
||||||
|
margin-top: 24px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
export const cssOptOutButton = styled(bigBasicButton, `
|
||||||
|
margin-top: 24px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
export const cssSponsorButton = styled(bigBasicButtonLink, `
|
||||||
|
margin-top: 24px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
export const cssButtonIconAndText = styled('div', `
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
`);
|
||||||
|
|
||||||
|
export const cssButtonText = styled('span', `
|
||||||
|
margin-left: 8px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
export const cssSpinnerBox = styled('div', `
|
||||||
|
margin-top: 24px;
|
||||||
|
text-align: center;
|
||||||
|
`);
|
@ -13,6 +13,7 @@ import {createDocMenu} from 'app/client/ui/DocMenu';
|
|||||||
import {createForbiddenPage, createNotFoundPage, createOtherErrorPage} from 'app/client/ui/errorPages';
|
import {createForbiddenPage, createNotFoundPage, createOtherErrorPage} from 'app/client/ui/errorPages';
|
||||||
import {createHomeLeftPane} from 'app/client/ui/HomeLeftPane';
|
import {createHomeLeftPane} from 'app/client/ui/HomeLeftPane';
|
||||||
import {buildSnackbarDom} from 'app/client/ui/NotifyUI';
|
import {buildSnackbarDom} from 'app/client/ui/NotifyUI';
|
||||||
|
import {OnboardingPage, shouldShowOnboardingPage} from 'app/client/ui/OnboardingPage';
|
||||||
import {pagePanels} from 'app/client/ui/PagePanels';
|
import {pagePanels} from 'app/client/ui/PagePanels';
|
||||||
import {RightPanel} from 'app/client/ui/RightPanel';
|
import {RightPanel} from 'app/client/ui/RightPanel';
|
||||||
import {createTopBarDoc, createTopBarHome} from 'app/client/ui/TopBar';
|
import {createTopBarDoc, createTopBarHome} from 'app/client/ui/TopBar';
|
||||||
@ -90,6 +91,10 @@ function createMainPage(appModel: AppModel, appObj: App) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pagePanelsHome(owner: IDisposableOwner, appModel: AppModel, app: App) {
|
function pagePanelsHome(owner: IDisposableOwner, appModel: AppModel, app: App) {
|
||||||
|
if (shouldShowOnboardingPage(appModel.userPrefsObs)) {
|
||||||
|
return dom.create(OnboardingPage, appModel);
|
||||||
|
}
|
||||||
|
|
||||||
const pageModel = HomeModelImpl.create(owner, appModel, app.clientScope);
|
const pageModel = HomeModelImpl.create(owner, appModel, app.clientScope);
|
||||||
const leftPanelOpen = Observable.create(owner, true);
|
const leftPanelOpen = Observable.create(owner, true);
|
||||||
|
|
||||||
|
@ -1,11 +1,22 @@
|
|||||||
import {allCommands} from 'app/client/components/commands';
|
import {allCommands} from 'app/client/components/commands';
|
||||||
import {GristDoc} from 'app/client/components/GristDoc';
|
import {GristDoc} from 'app/client/components/GristDoc';
|
||||||
import {makeTestId} from 'app/client/lib/domUtils';
|
import {makeTestId} from 'app/client/lib/domUtils';
|
||||||
|
import {FocusLayer} from 'app/client/lib/FocusLayer';
|
||||||
import * as kf from 'app/client/lib/koForm';
|
import * as kf from 'app/client/lib/koForm';
|
||||||
import {makeT} from 'app/client/lib/localization';
|
import {makeT} from 'app/client/lib/localization';
|
||||||
|
import {localStorageBoolObs} from 'app/client/lib/localStorageObs';
|
||||||
import {ColumnToMapImpl} from 'app/client/models/ColumnToMap';
|
import {ColumnToMapImpl} from 'app/client/models/ColumnToMap';
|
||||||
import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel';
|
import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel';
|
||||||
import {reportError} from 'app/client/models/errors';
|
import {
|
||||||
|
cssDeveloperLink,
|
||||||
|
cssWidgetMetadata,
|
||||||
|
cssWidgetMetadataName,
|
||||||
|
cssWidgetMetadataRow,
|
||||||
|
cssWidgetMetadataValue,
|
||||||
|
CUSTOM_URL_WIDGET_ID,
|
||||||
|
getWidgetName,
|
||||||
|
showCustomWidgetGallery,
|
||||||
|
} from 'app/client/ui/CustomWidgetGallery';
|
||||||
import {cssHelp, cssLabel, cssRow, cssSeparator} from 'app/client/ui/RightPanelStyles';
|
import {cssHelp, cssLabel, cssRow, cssSeparator} from 'app/client/ui/RightPanelStyles';
|
||||||
import {hoverTooltip} from 'app/client/ui/tooltips';
|
import {hoverTooltip} from 'app/client/ui/tooltips';
|
||||||
import {cssDragRow, cssFieldEntry, cssFieldLabel} from 'app/client/ui/VisibleFieldsConfig';
|
import {cssDragRow, cssFieldEntry, cssFieldLabel} from 'app/client/ui/VisibleFieldsConfig';
|
||||||
@ -14,16 +25,15 @@ import {theme, vars} from 'app/client/ui2018/cssVars';
|
|||||||
import {cssDragger} from 'app/client/ui2018/draggableList';
|
import {cssDragger} from 'app/client/ui2018/draggableList';
|
||||||
import {textInput} from 'app/client/ui2018/editableLabel';
|
import {textInput} from 'app/client/ui2018/editableLabel';
|
||||||
import {icon} from 'app/client/ui2018/icons';
|
import {icon} from 'app/client/ui2018/icons';
|
||||||
import {cssLink} from 'app/client/ui2018/links';
|
|
||||||
import {cssOptionLabel, IOption, IOptionFull, menu, menuItem, menuText, select} from 'app/client/ui2018/menus';
|
import {cssOptionLabel, IOption, IOptionFull, menu, menuItem, menuText, select} from 'app/client/ui2018/menus';
|
||||||
import {AccessLevel, ICustomWidget, isSatisfied, matchWidget} from 'app/common/CustomWidget';
|
import {AccessLevel, ICustomWidget, isSatisfied, matchWidget} from 'app/common/CustomWidget';
|
||||||
import {GristLoadConfig} from 'app/common/gristUrls';
|
|
||||||
import {not, unwrap} from 'app/common/gutil';
|
import {not, unwrap} from 'app/common/gutil';
|
||||||
import {
|
import {
|
||||||
bundleChanges,
|
bundleChanges,
|
||||||
Computed,
|
Computed,
|
||||||
Disposable,
|
Disposable,
|
||||||
dom,
|
dom,
|
||||||
|
DomContents,
|
||||||
fromKo,
|
fromKo,
|
||||||
MultiHolder,
|
MultiHolder,
|
||||||
Observable,
|
Observable,
|
||||||
@ -33,22 +43,8 @@ import {
|
|||||||
|
|
||||||
const t = makeT('CustomSectionConfig');
|
const t = makeT('CustomSectionConfig');
|
||||||
|
|
||||||
// Custom URL widget id - used as mock id for selectbox.
|
|
||||||
const CUSTOM_ID = 'custom';
|
|
||||||
const testId = makeTestId('test-config-widget-');
|
const testId = makeTestId('test-config-widget-');
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom Widget section.
|
|
||||||
* Allows to select custom widget from the list of available widgets
|
|
||||||
* (taken from /widgets endpoint), or enter a Custom URL.
|
|
||||||
* When Custom Widget has a desired access level (in accessLevel field),
|
|
||||||
* will prompt user to approve it. "None" access level is auto approved,
|
|
||||||
* so prompt won't be shown.
|
|
||||||
*
|
|
||||||
* When gristConfig.enableWidgetRepository is set to false, it will only
|
|
||||||
* allow to specify the custom URL.
|
|
||||||
*/
|
|
||||||
|
|
||||||
class ColumnPicker extends Disposable {
|
class ColumnPicker extends Disposable {
|
||||||
constructor(
|
constructor(
|
||||||
private _value: Observable<number|number[]|null>,
|
private _value: Observable<number|number[]|null>,
|
||||||
@ -319,17 +315,17 @@ class ColumnListPicker extends Disposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class CustomSectionConfigurationConfig extends Disposable{
|
class CustomSectionConfigurationConfig extends Disposable{
|
||||||
// Does widget has custom configuration.
|
private readonly _hasConfiguration = Computed.create(this, use =>
|
||||||
private readonly _hasConfiguration: Computed<boolean>;
|
Boolean(use(this._section.hasCustomOptions) || use(this._section.columnsToMap)));
|
||||||
|
|
||||||
constructor(private _section: ViewSectionRec, private _gristDoc: GristDoc) {
|
constructor(private _section: ViewSectionRec, private _gristDoc: GristDoc) {
|
||||||
super();
|
super();
|
||||||
this._hasConfiguration = Computed.create(this, use => use(_section.hasCustomOptions));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public buildDom() {
|
public buildDom() {
|
||||||
// Show prompt, when desired access level is different from actual one.
|
return dom.maybe(this._hasConfiguration, () => [
|
||||||
return dom(
|
cssSeparator(),
|
||||||
'div',
|
dom.maybe(this._section.hasCustomOptions, () =>
|
||||||
dom.maybe(this._hasConfiguration, () =>
|
|
||||||
cssSection(
|
cssSection(
|
||||||
textButton(
|
textButton(
|
||||||
t("Open configuration"),
|
t("Open configuration"),
|
||||||
@ -363,7 +359,7 @@ class CustomSectionConfigurationConfig extends Disposable{
|
|||||||
: dom.create(ColumnPicker, m.value, m.column, this._section)),
|
: dom.create(ColumnPicker, m.value, m.column, this._section)),
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
);
|
]);
|
||||||
}
|
}
|
||||||
private _openConfiguration(): void {
|
private _openConfiguration(): void {
|
||||||
allCommands.openWidgetConfiguration.run();
|
allCommands.openWidgetConfiguration.run();
|
||||||
@ -384,274 +380,107 @@ class CustomSectionConfigurationConfig extends Disposable{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom widget configuration.
|
||||||
|
*
|
||||||
|
* Allows picking a custom widget from a gallery of available widgets
|
||||||
|
* (fetched from the `/widgets` endpoint), which includes the Custom URL
|
||||||
|
* widget.
|
||||||
|
*
|
||||||
|
* When a custom widget has a desired `accessLevel` set to a value other
|
||||||
|
* than `"None"`, a prompt will be shown to grant the requested access level
|
||||||
|
* to the widget.
|
||||||
|
*
|
||||||
|
* When `gristConfig.enableWidgetRepository` is set to false, only the
|
||||||
|
* Custom URL widget will be available to select in the gallery.
|
||||||
|
*/
|
||||||
export class CustomSectionConfig extends Disposable {
|
export class CustomSectionConfig extends Disposable {
|
||||||
|
protected _customSectionConfigurationConfig = new CustomSectionConfigurationConfig(
|
||||||
|
this._section, this._gristDoc);
|
||||||
|
|
||||||
protected _customSectionConfigurationConfig: CustomSectionConfigurationConfig;
|
private readonly _widgetId = Computed.create(this, use => {
|
||||||
// Holds all available widget definitions.
|
// Stored in one of two places, depending on age of document.
|
||||||
private _widgets: Observable<ICustomWidget[]|null>;
|
const widgetId = use(this._section.customDef.widgetId) ||
|
||||||
// Holds selected option (either custom string or a widgetId).
|
use(this._section.customDef.widgetDef)?.widgetId;
|
||||||
private readonly _selectedId: Computed<string | null>;
|
if (widgetId) {
|
||||||
// Holds custom widget URL.
|
const pluginId = use(this._section.customDef.pluginId);
|
||||||
private readonly _url: Computed<string>;
|
return (pluginId || '') + ':' + widgetId;
|
||||||
// Enable or disable widget repository.
|
} else {
|
||||||
private readonly _canSelect: boolean = true;
|
return CUSTOM_URL_WIDGET_ID;
|
||||||
// When widget is changed, it sets its desired access level. We will prompt
|
}
|
||||||
// user to approve or reject it.
|
});
|
||||||
private readonly _desiredAccess: Observable<AccessLevel|null>;
|
|
||||||
// Current access level (stored inside a section).
|
|
||||||
private readonly _currentAccess: Computed<AccessLevel>;
|
|
||||||
|
|
||||||
|
private readonly _isCustomUrlWidget = Computed.create(this, this._widgetId, (_use, widgetId) => {
|
||||||
|
return widgetId === CUSTOM_URL_WIDGET_ID;
|
||||||
|
});
|
||||||
|
|
||||||
|
private readonly _currentAccess = Computed.create(this, use =>
|
||||||
|
(use(this._section.customDef.access) as AccessLevel) || AccessLevel.none)
|
||||||
|
.onWrite(async newAccess => {
|
||||||
|
await this._section.customDef.access.setAndSave(newAccess);
|
||||||
|
});
|
||||||
|
|
||||||
|
private readonly _desiredAccess = fromKo(this._section.desiredAccessLevel);
|
||||||
|
|
||||||
|
private readonly _url = Computed.create(this, use => use(this._section.customDef.url) || '')
|
||||||
|
.onWrite(async newUrl => {
|
||||||
|
bundleChanges(() => {
|
||||||
|
this._section.customDef.renderAfterReady(false);
|
||||||
|
if (newUrl) {
|
||||||
|
this._section.customDef.widgetId(null);
|
||||||
|
this._section.customDef.pluginId('');
|
||||||
|
this._section.customDef.widgetDef(null);
|
||||||
|
}
|
||||||
|
this._section.customDef.url(newUrl);
|
||||||
|
});
|
||||||
|
await this._section.saveCustomDef();
|
||||||
|
});
|
||||||
|
|
||||||
|
private readonly _requiresAccess = Computed.create(this, use => {
|
||||||
|
const [currentAccess, desiredAccess] = [use(this._currentAccess), use(this._desiredAccess)];
|
||||||
|
return desiredAccess && !isSatisfied(currentAccess, desiredAccess);
|
||||||
|
});
|
||||||
|
|
||||||
|
private readonly _widgetDetailsExpanded: Observable<boolean>;
|
||||||
|
|
||||||
|
private readonly _widgets: Observable<ICustomWidget[] | null> = Observable.create(this, null);
|
||||||
|
|
||||||
|
private readonly _selectedWidget = Computed.create(this, use => {
|
||||||
|
const id = use(this._widgetId);
|
||||||
|
if (id === CUSTOM_URL_WIDGET_ID) { return null; }
|
||||||
|
|
||||||
|
const widgets = use(this._widgets);
|
||||||
|
if (!widgets) { return null; }
|
||||||
|
|
||||||
|
const [pluginId, widgetId] = id.split(':');
|
||||||
|
return matchWidget(widgets, {pluginId, widgetId}) ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
constructor(protected _section: ViewSectionRec, private _gristDoc: GristDoc) {
|
constructor(protected _section: ViewSectionRec, private _gristDoc: GristDoc) {
|
||||||
super();
|
super();
|
||||||
this._customSectionConfigurationConfig = new CustomSectionConfigurationConfig(_section, _gristDoc);
|
|
||||||
|
|
||||||
// Test if we can offer widget list.
|
const userId = this._gristDoc.appModel.currentUser?.id ?? 0;
|
||||||
const gristConfig: GristLoadConfig = (window as any).gristConfig || {};
|
this._widgetDetailsExpanded = this.autoDispose(localStorageBoolObs(
|
||||||
this._canSelect = gristConfig.enableWidgetRepository ?? true;
|
`u:${userId};customWidgetDetailsExpanded`,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
|
||||||
// Array of available widgets - will be updated asynchronously.
|
this._getWidgets()
|
||||||
this._widgets = _gristDoc.app.topAppModel.customWidgets;
|
.then(widgets => {
|
||||||
this._getWidgets().catch(reportError);
|
if (this.isDisposed()) { return; }
|
||||||
// Request for rest of the widgets.
|
|
||||||
|
|
||||||
// Selected value from the dropdown (contains widgetId or "custom" string for Custom URL)
|
this._widgets.set(widgets);
|
||||||
this._selectedId = Computed.create(this, use => {
|
})
|
||||||
// widgetId could be stored in one of two places, depending on
|
.catch(reportError);
|
||||||
// age of document.
|
|
||||||
const widgetId = use(_section.customDef.widgetId) ||
|
|
||||||
use(_section.customDef.widgetDef)?.widgetId;
|
|
||||||
const pluginId = use(_section.customDef.pluginId);
|
|
||||||
if (widgetId) {
|
|
||||||
// selection id is "pluginId:widgetId"
|
|
||||||
return (pluginId || '') + ':' + widgetId;
|
|
||||||
}
|
|
||||||
return CUSTOM_ID;
|
|
||||||
});
|
|
||||||
this._selectedId.onWrite(async value => {
|
|
||||||
if (value === CUSTOM_ID) {
|
|
||||||
// Select Custom URL
|
|
||||||
bundleChanges(() => {
|
|
||||||
// Reset whether widget should render after `grist.ready()`.
|
|
||||||
_section.customDef.renderAfterReady(false);
|
|
||||||
// Clear url.
|
|
||||||
_section.customDef.url(null);
|
|
||||||
// Clear widgetId
|
|
||||||
_section.customDef.widgetId(null);
|
|
||||||
_section.customDef.widgetDef(null);
|
|
||||||
// Clear pluginId
|
|
||||||
_section.customDef.pluginId('');
|
|
||||||
// Reset access level to none.
|
|
||||||
_section.customDef.access(AccessLevel.none);
|
|
||||||
// Clear all saved options.
|
|
||||||
_section.customDef.widgetOptions(null);
|
|
||||||
// Reset custom configuration flag.
|
|
||||||
_section.hasCustomOptions(false);
|
|
||||||
// Clear column mappings.
|
|
||||||
_section.customDef.columnsMapping(null);
|
|
||||||
_section.columnsToMap(null);
|
|
||||||
this._desiredAccess.set(AccessLevel.none);
|
|
||||||
});
|
|
||||||
await _section.saveCustomDef();
|
|
||||||
} else {
|
|
||||||
const [pluginId, widgetId] = value?.split(':') || [];
|
|
||||||
// Select Widget
|
|
||||||
const selectedWidget = matchWidget(this._widgets.get()||[], {
|
|
||||||
widgetId,
|
|
||||||
pluginId,
|
|
||||||
});
|
|
||||||
if (!selectedWidget) {
|
|
||||||
// should not happen
|
|
||||||
throw new Error('Error accessing widget from the list');
|
|
||||||
}
|
|
||||||
// If user selected the same one, do nothing.
|
|
||||||
if (_section.customDef.widgetId.peek() === widgetId &&
|
|
||||||
_section.customDef.pluginId.peek() === pluginId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
bundleChanges(() => {
|
|
||||||
// Reset whether widget should render after `grist.ready()`.
|
|
||||||
_section.customDef.renderAfterReady(selectedWidget.renderAfterReady ?? false);
|
|
||||||
// Clear access level
|
|
||||||
_section.customDef.access(AccessLevel.none);
|
|
||||||
// When widget wants some access, set desired access level.
|
|
||||||
this._desiredAccess.set(selectedWidget.accessLevel || AccessLevel.none);
|
|
||||||
|
|
||||||
// Keep a record of the original widget definition.
|
|
||||||
// Don't rely on this much, since the document could
|
|
||||||
// have moved installation since, and widgets could be
|
|
||||||
// served from elsewhere.
|
|
||||||
_section.customDef.widgetDef(selectedWidget);
|
|
||||||
|
|
||||||
// Update widgetId.
|
|
||||||
_section.customDef.widgetId(selectedWidget.widgetId);
|
|
||||||
// Update pluginId.
|
|
||||||
_section.customDef.pluginId(selectedWidget.source?.pluginId || '');
|
|
||||||
// Update widget URL. Leave blank when widgetId is set.
|
|
||||||
_section.customDef.url(null);
|
|
||||||
// Clear options.
|
|
||||||
_section.customDef.widgetOptions(null);
|
|
||||||
// Clear has custom configuration.
|
|
||||||
_section.hasCustomOptions(false);
|
|
||||||
// Clear column mappings.
|
|
||||||
_section.customDef.columnsMapping(null);
|
|
||||||
_section.columnsToMap(null);
|
|
||||||
});
|
|
||||||
await _section.saveCustomDef();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Url for the widget, taken either from widget definition, or provided by hand for Custom URL.
|
|
||||||
// For custom widget, we will store url also in section definition.
|
|
||||||
this._url = Computed.create(this, use => use(_section.customDef.url) || '');
|
|
||||||
this._url.onWrite(async newUrl => {
|
|
||||||
bundleChanges(() => {
|
|
||||||
_section.customDef.renderAfterReady(false);
|
|
||||||
if (newUrl) {
|
|
||||||
// When a URL is set explicitly, make sure widgetId/pluginId/widgetDef
|
|
||||||
// is empty.
|
|
||||||
_section.customDef.widgetId(null);
|
|
||||||
_section.customDef.pluginId('');
|
|
||||||
_section.customDef.widgetDef(null);
|
|
||||||
}
|
|
||||||
_section.customDef.url(newUrl);
|
|
||||||
});
|
|
||||||
await _section.saveCustomDef();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Compute current access level.
|
|
||||||
this._currentAccess = Computed.create(
|
|
||||||
this,
|
|
||||||
use => (use(_section.customDef.access) as AccessLevel) || AccessLevel.none
|
|
||||||
);
|
|
||||||
this._currentAccess.onWrite(async newAccess => {
|
|
||||||
await _section.customDef.access.setAndSave(newAccess);
|
|
||||||
});
|
|
||||||
// From the start desired access level is the same as current one.
|
|
||||||
this._desiredAccess = fromKo(_section.desiredAccessLevel);
|
|
||||||
|
|
||||||
// Clear intermediate state when section changes.
|
// Clear intermediate state when section changes.
|
||||||
this.autoDispose(_section.id.subscribe(() => this._reject()));
|
this.autoDispose(_section.id.subscribe(() => this._dismissAccessPrompt()));
|
||||||
}
|
}
|
||||||
|
|
||||||
public buildDom() {
|
public buildDom(): DomContents {
|
||||||
// UI observables holder.
|
return dom('div',
|
||||||
const holder = new MultiHolder();
|
this._buildWidgetSelector(),
|
||||||
|
this._buildAccessLevelConfig(),
|
||||||
// Show prompt, when desired access level is different from actual one.
|
|
||||||
const prompt = Computed.create(holder, use =>
|
|
||||||
use(this._desiredAccess)
|
|
||||||
&& !isSatisfied(use(this._currentAccess), use(this._desiredAccess)!));
|
|
||||||
// If this is empty section or not.
|
|
||||||
const isSelected = Computed.create(holder, use => Boolean(use(this._selectedId)));
|
|
||||||
// If user is using custom url.
|
|
||||||
const isCustom = Computed.create(holder, use => use(this._selectedId) === CUSTOM_ID || !this._canSelect);
|
|
||||||
// Options for the select-box (all widgets definitions and Custom URL)
|
|
||||||
const options = Computed.create(holder, use => [
|
|
||||||
{label: 'Custom URL', value: 'custom'},
|
|
||||||
...(use(this._widgets) || [])
|
|
||||||
.filter(w => w?.published !== false)
|
|
||||||
.map(w => ({
|
|
||||||
label: w.source?.name ? `${w.name} (${w.source.name})` : w.name,
|
|
||||||
value: (w.source?.pluginId || '') + ':' + w.widgetId,
|
|
||||||
})),
|
|
||||||
]);
|
|
||||||
function buildPrompt(level: AccessLevel|null) {
|
|
||||||
if (!level) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
switch(level) {
|
|
||||||
case AccessLevel.none: return cssConfirmLine(t("Widget does not require any permissions."));
|
|
||||||
case AccessLevel.read_table:
|
|
||||||
return cssConfirmLine(t("Widget needs to {{read}} the current table.", {read: dom("b", "read")}));
|
|
||||||
case AccessLevel.full:
|
|
||||||
return cssConfirmLine(t("Widget needs {{fullAccess}} to this document.", {
|
|
||||||
fullAccess: dom("b", "full access")
|
|
||||||
}));
|
|
||||||
default: throw new Error(`Unsupported ${level} access level`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Options for access level.
|
|
||||||
const levels: IOptionFull<string>[] = [
|
|
||||||
{label: t("No document access"), value: AccessLevel.none},
|
|
||||||
{label: t("Read selected table"), value: AccessLevel.read_table},
|
|
||||||
{label: t("Full document access"), value: AccessLevel.full},
|
|
||||||
];
|
|
||||||
return dom(
|
|
||||||
'div',
|
|
||||||
dom.autoDispose(holder),
|
|
||||||
this.shouldRenderWidgetSelector() &&
|
|
||||||
this._canSelect
|
|
||||||
? cssRow(
|
|
||||||
select(this._selectedId, options, {
|
|
||||||
defaultLabel: t("Select Custom Widget"),
|
|
||||||
menuCssClass: cssMenu.className,
|
|
||||||
}),
|
|
||||||
testId('select')
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
dom.maybe((use) => use(isCustom) && this.shouldRenderWidgetSelector(), () => [
|
|
||||||
cssRow(
|
|
||||||
cssTextInput(
|
|
||||||
this._url,
|
|
||||||
async value => this._url.set(value),
|
|
||||||
dom.attr('placeholder', t("Enter Custom URL")),
|
|
||||||
testId('url')
|
|
||||||
),
|
|
||||||
this._gristDoc.behavioralPromptsManager.attachPopup('customURL', {
|
|
||||||
popupOptions: {
|
|
||||||
placement: 'left-start',
|
|
||||||
},
|
|
||||||
isDisabled: () => {
|
|
||||||
// Disable tip if a custom widget is already selected.
|
|
||||||
return Boolean(this._selectedId.get() && !(isCustom.get() && this._url.get().trim() === ''));
|
|
||||||
},
|
|
||||||
})
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
dom.maybe(prompt, () =>
|
|
||||||
kf.prompt(
|
|
||||||
{tabindex: '-1'},
|
|
||||||
cssColumns(
|
|
||||||
cssWarningWrapper(icon('Lock')),
|
|
||||||
dom(
|
|
||||||
'div',
|
|
||||||
cssConfirmRow(
|
|
||||||
dom.domComputed(this._desiredAccess, (level) => buildPrompt(level))
|
|
||||||
),
|
|
||||||
cssConfirmRow(
|
|
||||||
primaryButton(
|
|
||||||
'Accept',
|
|
||||||
testId('access-accept'),
|
|
||||||
dom.on('click', () => this._accept())
|
|
||||||
),
|
|
||||||
basicButton(
|
|
||||||
'Reject',
|
|
||||||
testId('access-reject'),
|
|
||||||
dom.on('click', () => this._reject())
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
dom.maybe(
|
|
||||||
use => use(isSelected) || !this._canSelect,
|
|
||||||
() => [
|
|
||||||
cssLabel('ACCESS LEVEL'),
|
|
||||||
cssRow(select(this._currentAccess, levels), testId('access')),
|
|
||||||
]
|
|
||||||
),
|
|
||||||
cssSection(
|
|
||||||
cssLink(
|
|
||||||
dom.attr('href', 'https://support.getgrist.com/widget-custom'),
|
|
||||||
dom.attr('target', '_blank'),
|
|
||||||
t("Learn more about custom widgets")
|
|
||||||
)
|
|
||||||
),
|
|
||||||
cssSeparator(),
|
|
||||||
this._customSectionConfigurationConfig.buildDom(),
|
this._customSectionConfigurationConfig.buildDom(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -661,21 +490,194 @@ export class CustomSectionConfig extends Disposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected async _getWidgets() {
|
protected async _getWidgets() {
|
||||||
await this._gristDoc.app.topAppModel.getWidgets();
|
return await this._gristDoc.app.topAppModel.getWidgets();
|
||||||
}
|
}
|
||||||
|
|
||||||
private _accept() {
|
private _buildWidgetSelector() {
|
||||||
|
if (!this.shouldRenderWidgetSelector()) { return null; }
|
||||||
|
|
||||||
|
return [
|
||||||
|
cssRow(
|
||||||
|
cssWidgetSelector(
|
||||||
|
this._buildShowWidgetDetailsButton(),
|
||||||
|
this._buildWidgetName(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
this._maybeBuildWidgetDetails(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private _buildShowWidgetDetailsButton() {
|
||||||
|
return cssShowWidgetDetails(
|
||||||
|
cssShowWidgetDetailsIcon(
|
||||||
|
'Dropdown',
|
||||||
|
cssShowWidgetDetailsIcon.cls('-collapsed', use => !use(this._widgetDetailsExpanded)),
|
||||||
|
testId('toggle-custom-widget-details'),
|
||||||
|
testId(use => !use(this._widgetDetailsExpanded)
|
||||||
|
? 'show-custom-widget-details'
|
||||||
|
: 'hide-custom-widget-details'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
cssWidgetLabel(t('Widget')),
|
||||||
|
dom.on('click', () => {
|
||||||
|
this._widgetDetailsExpanded.set(!this._widgetDetailsExpanded.get());
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _buildWidgetName() {
|
||||||
|
return cssWidgetName(
|
||||||
|
dom.text(use => {
|
||||||
|
if (use(this._isCustomUrlWidget)) {
|
||||||
|
return t('Custom URL');
|
||||||
|
} else {
|
||||||
|
const widget = use(this._selectedWidget) ?? use(this._section.customDef.widgetDef);
|
||||||
|
return widget ? getWidgetName(widget) : use(this._widgetId);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
dom.on('click', () => showCustomWidgetGallery(this._gristDoc, {
|
||||||
|
sectionRef: this._section.id(),
|
||||||
|
})),
|
||||||
|
testId('open-custom-widget-gallery'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _maybeBuildWidgetDetails() {
|
||||||
|
return dom.maybe(this._widgetDetailsExpanded, () =>
|
||||||
|
dom.domComputed(this._selectedWidget, (widget) =>
|
||||||
|
cssRow(
|
||||||
|
this._buildWidgetDetails(widget),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _buildWidgetDetails(widget: ICustomWidget | null) {
|
||||||
|
return dom.domComputed(this._isCustomUrlWidget, (isCustomUrlWidget) => {
|
||||||
|
if (isCustomUrlWidget) {
|
||||||
|
return cssCustomUrlDetails(
|
||||||
|
cssTextInput(
|
||||||
|
this._url,
|
||||||
|
async value => this._url.set(value),
|
||||||
|
dom.show(this._isCustomUrlWidget),
|
||||||
|
{placeholder: t('Enter Custom URL')},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (!widget?.description && !widget?.authors?.[0] && !widget?.lastUpdatedAt) {
|
||||||
|
return cssDetailsMessage(t('Missing description and author information.'));
|
||||||
|
} else {
|
||||||
|
return cssWidgetDetails(
|
||||||
|
!widget?.description ? null : cssWidgetDescription(
|
||||||
|
widget.description,
|
||||||
|
testId('custom-widget-description'),
|
||||||
|
),
|
||||||
|
cssWidgetMetadata(
|
||||||
|
!widget?.authors?.[0] ? null : cssWidgetMetadataRow(
|
||||||
|
cssWidgetMetadataName(t('Developer:')),
|
||||||
|
cssWidgetMetadataValue(
|
||||||
|
widget.authors[0].url
|
||||||
|
? cssDeveloperLink(
|
||||||
|
widget.authors[0].name,
|
||||||
|
{href: widget.authors[0].url, target: '_blank'},
|
||||||
|
testId('custom-widget-developer'),
|
||||||
|
)
|
||||||
|
: dom('span',
|
||||||
|
widget.authors[0].name,
|
||||||
|
testId('custom-widget-developer'),
|
||||||
|
),
|
||||||
|
testId('custom-widget-developer'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
!widget?.lastUpdatedAt ? null : cssWidgetMetadataRow(
|
||||||
|
cssWidgetMetadataName(t('Last updated:')),
|
||||||
|
cssWidgetMetadataValue(
|
||||||
|
new Date(widget.lastUpdatedAt).toLocaleDateString('default', {
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
}),
|
||||||
|
testId('custom-widget-last-updated'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private _buildAccessLevelConfig() {
|
||||||
|
return [
|
||||||
|
cssSeparator({style: 'margin-top: 0px'}),
|
||||||
|
cssLabel(t('ACCESS LEVEL')),
|
||||||
|
cssRow(select(this._currentAccess, getAccessLevels()), testId('access')),
|
||||||
|
dom.maybeOwned(this._requiresAccess, (owner) => kf.prompt(
|
||||||
|
(elem: HTMLDivElement) => { FocusLayer.create(owner, {defaultFocusElem: elem, pauseMousetrap: true}); },
|
||||||
|
cssColumns(
|
||||||
|
cssWarningWrapper(icon('Lock')),
|
||||||
|
dom('div',
|
||||||
|
cssConfirmRow(
|
||||||
|
dom.domComputed(this._desiredAccess, (level) => this._buildAccessLevelPrompt(level))
|
||||||
|
),
|
||||||
|
cssConfirmRow(
|
||||||
|
primaryButton(
|
||||||
|
t('Accept'),
|
||||||
|
testId('access-accept'),
|
||||||
|
dom.on('click', () => this._grantDesiredAccess())
|
||||||
|
),
|
||||||
|
basicButton(
|
||||||
|
t('Reject'),
|
||||||
|
testId('access-reject'),
|
||||||
|
dom.on('click', () => this._dismissAccessPrompt())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
dom.onKeyDown({
|
||||||
|
Enter: () => this._grantDesiredAccess(),
|
||||||
|
Escape:() => this._dismissAccessPrompt(),
|
||||||
|
}),
|
||||||
|
)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private _buildAccessLevelPrompt(level: AccessLevel | null) {
|
||||||
|
if (!level) { return null; }
|
||||||
|
|
||||||
|
switch (level) {
|
||||||
|
case AccessLevel.none: {
|
||||||
|
return cssConfirmLine(t("Widget does not require any permissions."));
|
||||||
|
}
|
||||||
|
case AccessLevel.read_table: {
|
||||||
|
return cssConfirmLine(t("Widget needs to {{read}} the current table.", {read: dom("b", "read")}));
|
||||||
|
}
|
||||||
|
case AccessLevel.full: {
|
||||||
|
return cssConfirmLine(t("Widget needs {{fullAccess}} to this document.", {
|
||||||
|
fullAccess: dom("b", "full access")
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _grantDesiredAccess() {
|
||||||
if (this._desiredAccess.get()) {
|
if (this._desiredAccess.get()) {
|
||||||
this._currentAccess.set(this._desiredAccess.get()!);
|
this._currentAccess.set(this._desiredAccess.get()!);
|
||||||
}
|
}
|
||||||
this._reject();
|
this._dismissAccessPrompt();
|
||||||
}
|
}
|
||||||
|
|
||||||
private _reject() {
|
private _dismissAccessPrompt() {
|
||||||
this._desiredAccess.set(null);
|
this._desiredAccess.set(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAccessLevels(): IOptionFull<string>[] {
|
||||||
|
return [
|
||||||
|
{label: t("No document access"), value: AccessLevel.none},
|
||||||
|
{label: t("Read selected table"), value: AccessLevel.read_table},
|
||||||
|
{label: t("Full document access"), value: AccessLevel.full},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
const cssWarningWrapper = styled('div', `
|
const cssWarningWrapper = styled('div', `
|
||||||
padding-left: 8px;
|
padding-left: 8px;
|
||||||
padding-top: 6px;
|
padding-top: 6px;
|
||||||
@ -700,12 +702,6 @@ const cssSection = styled('div', `
|
|||||||
margin: 16px 16px 12px 16px;
|
margin: 16px 16px 12px 16px;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const cssMenu = styled('div', `
|
|
||||||
& > li:first-child {
|
|
||||||
border-bottom: 1px solid ${theme.menuBorder};
|
|
||||||
}
|
|
||||||
`);
|
|
||||||
|
|
||||||
const cssAddIcon = styled(icon, `
|
const cssAddIcon = styled(icon, `
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
`);
|
`);
|
||||||
@ -748,17 +744,9 @@ const cssAddMapping = styled('div', `
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
const cssTextInput = styled(textInput, `
|
const cssTextInput = styled(textInput, `
|
||||||
flex: 1 0 auto;
|
|
||||||
|
|
||||||
color: ${theme.inputFg};
|
color: ${theme.inputFg};
|
||||||
background-color: ${theme.inputBg};
|
background-color: ${theme.inputBg};
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
color: ${theme.inputDisabledFg};
|
|
||||||
background-color: ${theme.inputDisabledBg};
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: ${theme.inputPlaceholderFg};
|
color: ${theme.inputPlaceholderFg};
|
||||||
}
|
}
|
||||||
@ -771,3 +759,62 @@ const cssDisabledSelect = styled(select, `
|
|||||||
const cssBlank = styled(cssOptionLabel, `
|
const cssBlank = styled(cssOptionLabel, `
|
||||||
--grist-option-label-color: ${theme.lightText};
|
--grist-option-label-color: ${theme.lightText};
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
const cssWidgetSelector = styled('div', `
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
column-gap: 16px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssShowWidgetDetails = styled('div', `
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
column-gap: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssShowWidgetDetailsIcon = styled(icon, `
|
||||||
|
--icon-color: ${theme.lightText};
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&-collapsed {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssWidgetLabel = styled('div', `
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: ${vars.xsmallFontSize};
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssWidgetName = styled('div', `
|
||||||
|
color: ${theme.rightPanelCustomWidgetButtonFg};
|
||||||
|
background-color: ${theme.rightPanelCustomWidgetButtonBg};
|
||||||
|
height: 24px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssWidgetDetails = styled('div', `
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssCustomUrlDetails = styled(cssWidgetDetails, `
|
||||||
|
flex: 1 0 auto;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssDetailsMessage = styled('div', `
|
||||||
|
color: ${theme.lightText};
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssWidgetDescription = styled('div', `
|
||||||
|
margin-bottom: 16px;
|
||||||
|
`);
|
||||||
|
661
app/client/ui/CustomWidgetGallery.ts
Normal file
661
app/client/ui/CustomWidgetGallery.ts
Normal file
@ -0,0 +1,661 @@
|
|||||||
|
import {GristDoc} from 'app/client/components/GristDoc';
|
||||||
|
import {makeT} from 'app/client/lib/localization';
|
||||||
|
import {ViewSectionRec} from 'app/client/models/DocModel';
|
||||||
|
import {textInput} from 'app/client/ui/inputs';
|
||||||
|
import {shadowScroll} from 'app/client/ui/shadowScroll';
|
||||||
|
import {withInfoTooltip} from 'app/client/ui/tooltips';
|
||||||
|
import {bigBasicButton, bigPrimaryButton} from 'app/client/ui2018/buttons';
|
||||||
|
import {theme} from 'app/client/ui2018/cssVars';
|
||||||
|
import {icon} from 'app/client/ui2018/icons';
|
||||||
|
import {cssLink} from 'app/client/ui2018/links';
|
||||||
|
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
||||||
|
import {IModalControl, modal} from 'app/client/ui2018/modals';
|
||||||
|
import {AccessLevel, ICustomWidget, matchWidget, WidgetAuthor} from 'app/common/CustomWidget';
|
||||||
|
import {commonUrls} from 'app/common/gristUrls';
|
||||||
|
import {bundleChanges, Computed, Disposable, dom, makeTestId, Observable, styled} from 'grainjs';
|
||||||
|
import escapeRegExp from 'lodash/escapeRegExp';
|
||||||
|
|
||||||
|
const testId = makeTestId('test-custom-widget-gallery-');
|
||||||
|
|
||||||
|
const t = makeT('CustomWidgetGallery');
|
||||||
|
|
||||||
|
export const CUSTOM_URL_WIDGET_ID = 'custom';
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
sectionRef?: number;
|
||||||
|
addWidget?(): Promise<{viewRef: number, sectionRef: number}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showCustomWidgetGallery(gristDoc: GristDoc, options: Options = {}) {
|
||||||
|
modal((ctl) => [
|
||||||
|
dom.create(CustomWidgetGallery, ctl, gristDoc, options),
|
||||||
|
cssModal.cls(''),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WidgetInfo {
|
||||||
|
variant: WidgetVariant;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
developer?: WidgetAuthor;
|
||||||
|
lastUpdated?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomWidgetACItem extends ICustomWidget {
|
||||||
|
cleanText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type WidgetVariant = 'custom' | 'grist' | 'community';
|
||||||
|
|
||||||
|
class CustomWidgetGallery extends Disposable {
|
||||||
|
private readonly _customUrl: Observable<string>;
|
||||||
|
private readonly _filteredWidgets = Observable.create<ICustomWidget[] | null>(this, null);
|
||||||
|
private readonly _section: ViewSectionRec | null = null;
|
||||||
|
private readonly _searchText = Observable.create(this, '');
|
||||||
|
private readonly _saveDisabled: Computed<boolean>;
|
||||||
|
private readonly _savedWidgetId: Computed<string | null>;
|
||||||
|
private readonly _selectedWidgetId = Observable.create<string | null>(this, null);
|
||||||
|
private readonly _widgets = Observable.create<CustomWidgetACItem[] | null>(this, null);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private _ctl: IModalControl,
|
||||||
|
private _gristDoc: GristDoc,
|
||||||
|
private _options: Options = {}
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
const {sectionRef} = _options;
|
||||||
|
if (sectionRef) {
|
||||||
|
const section = this._gristDoc.docModel.viewSections.getRowModel(sectionRef);
|
||||||
|
if (!section.id.peek()) {
|
||||||
|
throw new Error(`Section ${sectionRef} does not exist`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._section = section;
|
||||||
|
this.autoDispose(section._isDeleted.subscribe((isDeleted) => {
|
||||||
|
if (isDeleted) { this._ctl.close(); }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let customUrl = '';
|
||||||
|
if (this._section) {
|
||||||
|
customUrl = this._section.customDef.url() ?? '';
|
||||||
|
}
|
||||||
|
this._customUrl = Observable.create(this, customUrl);
|
||||||
|
|
||||||
|
this._savedWidgetId = Computed.create(this, (use) => {
|
||||||
|
if (!this._section) { return null; }
|
||||||
|
|
||||||
|
const {customDef} = this._section;
|
||||||
|
// May be stored in one of two places, depending on age of document.
|
||||||
|
const widgetId = use(customDef.widgetId) || use(customDef.widgetDef)?.widgetId;
|
||||||
|
if (widgetId) {
|
||||||
|
const pluginId = use(customDef.pluginId);
|
||||||
|
const widget = matchWidget(use(this._widgets) ?? [], {
|
||||||
|
widgetId,
|
||||||
|
pluginId,
|
||||||
|
});
|
||||||
|
return widget ? `${pluginId}:${widgetId}` : null;
|
||||||
|
} else {
|
||||||
|
return CUSTOM_URL_WIDGET_ID;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this._saveDisabled = Computed.create(this, use => {
|
||||||
|
const selectedWidgetId = use(this._selectedWidgetId);
|
||||||
|
if (!selectedWidgetId) { return true; }
|
||||||
|
if (!this._section) { return false; }
|
||||||
|
|
||||||
|
const savedWidgetId = use(this._savedWidgetId);
|
||||||
|
if (selectedWidgetId === CUSTOM_URL_WIDGET_ID) {
|
||||||
|
return (
|
||||||
|
use(this._savedWidgetId) === CUSTOM_URL_WIDGET_ID &&
|
||||||
|
use(this._customUrl) === use(this._section.customDef.url)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return selectedWidgetId === savedWidgetId;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this._initializeWidgets().catch(reportError);
|
||||||
|
|
||||||
|
this.autoDispose(this._searchText.addListener(() => {
|
||||||
|
this._filterWidgets();
|
||||||
|
this._selectedWidgetId.set(null);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
public buildDom() {
|
||||||
|
return cssCustomWidgetGallery(
|
||||||
|
cssHeader(
|
||||||
|
cssTitle(t('Choose Custom Widget')),
|
||||||
|
cssSearchInputWrapper(
|
||||||
|
cssSearchIcon('Search'),
|
||||||
|
cssSearchInput(
|
||||||
|
this._searchText,
|
||||||
|
{placeholder: t('Search')},
|
||||||
|
(el) => { setTimeout(() => el.focus(), 10); },
|
||||||
|
testId('search'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
shadowScroll(
|
||||||
|
this._buildWidgets(),
|
||||||
|
cssShadowScroll.cls(''),
|
||||||
|
),
|
||||||
|
cssFooter(
|
||||||
|
dom('div',
|
||||||
|
cssHelpLink(
|
||||||
|
{href: commonUrls.helpCustomWidgets, target: '_blank'},
|
||||||
|
cssHelpIcon('Question'),
|
||||||
|
t('Learn more about Custom Widgets'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
cssFooterButtons(
|
||||||
|
bigBasicButton(
|
||||||
|
t('Cancel'),
|
||||||
|
dom.on('click', () => this._ctl.close()),
|
||||||
|
testId('cancel'),
|
||||||
|
),
|
||||||
|
bigPrimaryButton(
|
||||||
|
this._options.addWidget ? t('Add Widget') : t('Change Widget'),
|
||||||
|
dom.on('click', () => this._save()),
|
||||||
|
dom.boolAttr('disabled', this._saveDisabled),
|
||||||
|
testId('save'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
dom.onKeyDown({
|
||||||
|
Enter: () => this._save(),
|
||||||
|
Escape: () => this._deselectOrClose(),
|
||||||
|
}),
|
||||||
|
dom.on('click', (ev) => this._maybeClearSelection(ev)),
|
||||||
|
testId('container'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _initializeWidgets() {
|
||||||
|
const widgets: ICustomWidget[] = [
|
||||||
|
{
|
||||||
|
widgetId: 'custom',
|
||||||
|
name: t('Custom URL'),
|
||||||
|
description: t('Add a widget from outside this gallery.'),
|
||||||
|
url: '',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
try {
|
||||||
|
const remoteWidgets = await this._gristDoc.appModel.topAppModel.getWidgets();
|
||||||
|
if (this.isDisposed()) { return; }
|
||||||
|
|
||||||
|
widgets.push(...remoteWidgets
|
||||||
|
.filter(({published}) => published !== false)
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name)));
|
||||||
|
} catch (e) {
|
||||||
|
reportError(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._widgets.set(widgets.map(w => ({...w, cleanText: getWidgetCleanText(w)})));
|
||||||
|
this._selectedWidgetId.set(this._savedWidgetId.get());
|
||||||
|
this._filterWidgets();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _filterWidgets() {
|
||||||
|
const widgets = this._widgets.get();
|
||||||
|
if (!widgets) { return; }
|
||||||
|
|
||||||
|
const searchText = this._searchText.get();
|
||||||
|
if (!searchText) {
|
||||||
|
this._filteredWidgets.set(widgets);
|
||||||
|
} else {
|
||||||
|
const searchTerms = searchText.trim().split(/\s+/);
|
||||||
|
const searchPatterns = searchTerms.map(term =>
|
||||||
|
new RegExp(`\\b${escapeRegExp(term)}`, 'i'));
|
||||||
|
const filteredWidgets = widgets.filter(({cleanText}) =>
|
||||||
|
searchPatterns.some(pattern => pattern.test(cleanText))
|
||||||
|
);
|
||||||
|
this._filteredWidgets.set(filteredWidgets);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _buildWidgets() {
|
||||||
|
return dom.domComputed(this._filteredWidgets, (widgets) => {
|
||||||
|
if (widgets === null) {
|
||||||
|
return cssLoadingSpinner(loadingSpinner());
|
||||||
|
} else if (widgets.length === 0) {
|
||||||
|
return cssNoMatchingWidgets(t('No matching widgets'));
|
||||||
|
} else {
|
||||||
|
return cssWidgets(
|
||||||
|
widgets.map(widget => {
|
||||||
|
const {description, authors = [], lastUpdatedAt} = widget;
|
||||||
|
|
||||||
|
return this._buildWidget({
|
||||||
|
variant: getWidgetVariant(widget),
|
||||||
|
id: getWidgetId(widget),
|
||||||
|
name: getWidgetName(widget),
|
||||||
|
description,
|
||||||
|
developer: authors[0],
|
||||||
|
lastUpdated: lastUpdatedAt,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private _buildWidget(info: WidgetInfo) {
|
||||||
|
const {variant, id, name, description, developer, lastUpdated} = info;
|
||||||
|
|
||||||
|
return cssWidget(
|
||||||
|
dom.cls('custom-widget'),
|
||||||
|
cssWidgetHeader(
|
||||||
|
variant === 'custom' ? t('Add Your Own Widget') :
|
||||||
|
variant === 'grist' ? t('Grist Widget') :
|
||||||
|
withInfoTooltip(
|
||||||
|
t('Community Widget'),
|
||||||
|
'communityWidgets',
|
||||||
|
{
|
||||||
|
variant: 'hover',
|
||||||
|
iconDomArgs: [cssTooltipIcon.cls('')],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
cssWidgetHeader.cls('-secondary', ['custom', 'community'].includes(variant)),
|
||||||
|
),
|
||||||
|
cssWidgetBody(
|
||||||
|
cssWidgetName(
|
||||||
|
name,
|
||||||
|
testId('widget-name'),
|
||||||
|
),
|
||||||
|
cssWidgetDescription(
|
||||||
|
description ?? t('(Missing info)'),
|
||||||
|
cssWidgetDescription.cls('-missing', !description),
|
||||||
|
testId('widget-description'),
|
||||||
|
),
|
||||||
|
variant === 'custom' ? null : cssWidgetMetadata(
|
||||||
|
variant === 'grist' ? null : cssWidgetMetadataRow(
|
||||||
|
cssWidgetMetadataName(t('Developer:')),
|
||||||
|
cssWidgetMetadataValue(
|
||||||
|
developer?.url
|
||||||
|
? cssDeveloperLink(
|
||||||
|
developer.name,
|
||||||
|
{href: developer.url, target: '_blank'},
|
||||||
|
dom.on('click', (ev) => ev.stopPropagation()),
|
||||||
|
testId('widget-developer'),
|
||||||
|
)
|
||||||
|
: dom('span',
|
||||||
|
developer?.name ?? t('(Missing info)'),
|
||||||
|
testId('widget-developer'),
|
||||||
|
),
|
||||||
|
cssWidgetMetadataValue.cls('-missing', !developer?.name),
|
||||||
|
testId('widget-developer'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
cssWidgetMetadataRow(
|
||||||
|
cssWidgetMetadataName(t('Last updated:')),
|
||||||
|
cssWidgetMetadataValue(
|
||||||
|
lastUpdated ?
|
||||||
|
new Date(lastUpdated).toLocaleDateString('default', {
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
: t('(Missing info)'),
|
||||||
|
cssWidgetMetadataValue.cls('-missing', !lastUpdated),
|
||||||
|
testId('widget-last-updated'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
testId('widget-metadata'),
|
||||||
|
),
|
||||||
|
variant !== 'custom' ? null : cssCustomUrlInput(
|
||||||
|
this._customUrl,
|
||||||
|
{placeholder: t('Widget URL')},
|
||||||
|
testId('custom-url'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
cssWidget.cls('-selected', use => id === use(this._selectedWidgetId)),
|
||||||
|
dom.on('click', () => this._selectedWidgetId.set(id)),
|
||||||
|
testId('widget'),
|
||||||
|
testId(`widget-${variant}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _save() {
|
||||||
|
if (this._saveDisabled.get()) { return; }
|
||||||
|
|
||||||
|
await this._saveSelectedWidget();
|
||||||
|
this._ctl.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _deselectOrClose() {
|
||||||
|
if (this._selectedWidgetId.get()) {
|
||||||
|
this._selectedWidgetId.set(null);
|
||||||
|
} else {
|
||||||
|
this._ctl.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _saveSelectedWidget() {
|
||||||
|
await this._gristDoc.docData.bundleActions(
|
||||||
|
'Save selected custom widget',
|
||||||
|
async () => {
|
||||||
|
let section = this._section;
|
||||||
|
if (!section) {
|
||||||
|
const {addWidget} = this._options;
|
||||||
|
if (!addWidget) {
|
||||||
|
throw new Error('Cannot add custom widget: missing `addWidget` implementation');
|
||||||
|
}
|
||||||
|
|
||||||
|
const {sectionRef} = await addWidget();
|
||||||
|
const newSection = this._gristDoc.docModel.viewSections.getRowModel(sectionRef);
|
||||||
|
if (!newSection.id.peek()) {
|
||||||
|
throw new Error(`Section ${sectionRef} does not exist`);
|
||||||
|
}
|
||||||
|
section = newSection;
|
||||||
|
}
|
||||||
|
const selectedWidgetId = this._selectedWidgetId.get();
|
||||||
|
if (selectedWidgetId === CUSTOM_URL_WIDGET_ID) {
|
||||||
|
return this._saveCustomUrlWidget(section);
|
||||||
|
} else {
|
||||||
|
return this._saveRemoteWidget(section);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _saveCustomUrlWidget(section: ViewSectionRec) {
|
||||||
|
bundleChanges(() => {
|
||||||
|
section.customDef.renderAfterReady(false);
|
||||||
|
section.customDef.url(this._customUrl.get());
|
||||||
|
section.customDef.widgetId(null);
|
||||||
|
section.customDef.widgetDef(null);
|
||||||
|
section.customDef.pluginId('');
|
||||||
|
section.customDef.access(AccessLevel.none);
|
||||||
|
section.customDef.widgetOptions(null);
|
||||||
|
section.hasCustomOptions(false);
|
||||||
|
section.customDef.columnsMapping(null);
|
||||||
|
section.columnsToMap(null);
|
||||||
|
section.desiredAccessLevel(AccessLevel.none);
|
||||||
|
});
|
||||||
|
await section.saveCustomDef();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _saveRemoteWidget(section: ViewSectionRec) {
|
||||||
|
const [pluginId, widgetId] = this._selectedWidgetId.get()!.split(':');
|
||||||
|
const {customDef} = section;
|
||||||
|
if (customDef.pluginId.peek() === pluginId && customDef.widgetId.peek() === widgetId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedWidget = matchWidget(this._widgets.get() ?? [], {widgetId, pluginId});
|
||||||
|
if (!selectedWidget) {
|
||||||
|
throw new Error(`Widget ${this._selectedWidgetId.get()} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
bundleChanges(() => {
|
||||||
|
section.customDef.renderAfterReady(selectedWidget.renderAfterReady ?? false);
|
||||||
|
section.customDef.access(AccessLevel.none);
|
||||||
|
section.desiredAccessLevel(selectedWidget.accessLevel ?? AccessLevel.none);
|
||||||
|
// Keep a record of the original widget definition.
|
||||||
|
// Don't rely on this much, since the document could
|
||||||
|
// have moved installation since, and widgets could be
|
||||||
|
// served from elsewhere.
|
||||||
|
section.customDef.widgetDef(selectedWidget);
|
||||||
|
section.customDef.widgetId(selectedWidget.widgetId);
|
||||||
|
section.customDef.pluginId(selectedWidget.source?.pluginId ?? '');
|
||||||
|
section.customDef.url(null);
|
||||||
|
section.customDef.widgetOptions(null);
|
||||||
|
section.hasCustomOptions(false);
|
||||||
|
section.customDef.columnsMapping(null);
|
||||||
|
section.columnsToMap(null);
|
||||||
|
});
|
||||||
|
await section.saveCustomDef();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _maybeClearSelection(event: MouseEvent) {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (
|
||||||
|
!target.closest('.custom-widget') &&
|
||||||
|
!target.closest('button') &&
|
||||||
|
!target.closest('a') &&
|
||||||
|
!target.closest('input')
|
||||||
|
) {
|
||||||
|
this._selectedWidgetId.set(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWidgetName({name, source}: ICustomWidget) {
|
||||||
|
return source?.name ? `${name} (${source.name})` : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWidgetVariant({isGristLabsMaintained = false, widgetId}: ICustomWidget): WidgetVariant {
|
||||||
|
if (widgetId === CUSTOM_URL_WIDGET_ID) {
|
||||||
|
return 'custom';
|
||||||
|
} else if (isGristLabsMaintained) {
|
||||||
|
return 'grist';
|
||||||
|
} else {
|
||||||
|
return 'community';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWidgetId({source, widgetId}: ICustomWidget) {
|
||||||
|
if (widgetId === CUSTOM_URL_WIDGET_ID) {
|
||||||
|
return CUSTOM_URL_WIDGET_ID;
|
||||||
|
} else {
|
||||||
|
return `${source?.pluginId ?? ''}:${widgetId}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWidgetCleanText({name, description, authors = []}: ICustomWidget) {
|
||||||
|
let cleanText = name;
|
||||||
|
if (description) { cleanText += ` ${description}`; }
|
||||||
|
if (authors[0]) { cleanText += ` ${authors[0].name}`; }
|
||||||
|
return cleanText;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cssWidgetMetadata = styled('div', `
|
||||||
|
margin-top: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
row-gap: 4px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
export const cssWidgetMetadataRow = styled('div', `
|
||||||
|
display: flex;
|
||||||
|
column-gap: 4px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
export const cssWidgetMetadataName = styled('span', `
|
||||||
|
color: ${theme.lightText};
|
||||||
|
font-weight: 600;
|
||||||
|
`);
|
||||||
|
|
||||||
|
export const cssWidgetMetadataValue = styled('div', `
|
||||||
|
&-missing {
|
||||||
|
color: ${theme.lightText};
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
export const cssDeveloperLink = styled(cssLink, `
|
||||||
|
font-weight: 600;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssCustomWidgetGallery = styled('div', `
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
outline: none;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const WIDGET_WIDTH_PX = 240;
|
||||||
|
|
||||||
|
const WIDGETS_GAP_PX = 16;
|
||||||
|
|
||||||
|
const cssHeader = styled('div', `
|
||||||
|
display: flex;
|
||||||
|
column-gap: 16px;
|
||||||
|
row-gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin: 40px 40px 16px 40px;
|
||||||
|
|
||||||
|
/* Don't go beyond the final grid column. */
|
||||||
|
max-width: ${(3 * WIDGET_WIDTH_PX) + (2 * WIDGETS_GAP_PX)}px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssTitle = styled('div', `
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 32px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssSearchInputWrapper = styled('div', `
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssSearchIcon = styled(icon, `
|
||||||
|
margin-left: 8px;
|
||||||
|
position: absolute;
|
||||||
|
--icon-color: ${theme.accentIcon};
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssSearchInput = styled(textInput, `
|
||||||
|
height: 28px;
|
||||||
|
padding-left: 32px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssShadowScroll = styled('div', `
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: unset;
|
||||||
|
flex-grow: 1;
|
||||||
|
padding: 16px 40px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssCenteredFlexGrow = styled('div', `
|
||||||
|
flex-grow: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssLoadingSpinner = cssCenteredFlexGrow;
|
||||||
|
|
||||||
|
const cssNoMatchingWidgets = styled(cssCenteredFlexGrow, `
|
||||||
|
color: ${theme.lightText};
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssWidgets = styled('div', `
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(0px, ${WIDGET_WIDTH_PX}px));
|
||||||
|
gap: ${WIDGETS_GAP_PX}px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssWidget = styled('div', `
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 1px 1px 4px 1px ${theme.widgetGalleryShadow};
|
||||||
|
border-radius: 4px;
|
||||||
|
min-height: 183.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: ${theme.widgetGalleryBgHover};
|
||||||
|
}
|
||||||
|
&-selected {
|
||||||
|
outline: 2px solid ${theme.widgetGalleryBorderSelected};
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssWidgetHeader = styled('div', `
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 2px solid ${theme.widgetGalleryBorder};
|
||||||
|
border-bottom: 1px solid ${theme.widgetGalleryBorder};
|
||||||
|
border-radius: 4px 4px 0px 0px;
|
||||||
|
color: ${theme.lightText};
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 4px 18px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
|
||||||
|
&-secondary {
|
||||||
|
border: 0px;
|
||||||
|
color: ${theme.widgetGallerySecondaryHeaderFg};
|
||||||
|
background-color: ${theme.widgetGallerySecondaryHeaderBg};
|
||||||
|
}
|
||||||
|
.${cssWidget.className}:hover &-secondary {
|
||||||
|
background-color: ${theme.widgetGallerySecondaryHeaderBgHover};
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssWidgetBody = styled('div', `
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-grow: 1;
|
||||||
|
border: 2px solid ${theme.widgetGalleryBorder};
|
||||||
|
border-top: 0px;
|
||||||
|
border-radius: 0px 0px 4px 4px;
|
||||||
|
padding: 16px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssWidgetName = styled('div', `
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssWidgetDescription = styled('div', `
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
&-missing {
|
||||||
|
color: ${theme.lightText};
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssCustomUrlInput = styled(textInput, `
|
||||||
|
height: 28px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssHelpLink = styled(cssLink, `
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
column-gap: 8px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssHelpIcon = styled(icon, `
|
||||||
|
flex-shrink: 0;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssFooter = styled('div', `
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 40px;
|
||||||
|
border-top: 1px solid ${theme.widgetGalleryBorder};
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssFooterButtons = styled('div', `
|
||||||
|
display: flex;
|
||||||
|
column-gap: 8px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssModal = styled('div', `
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-width: 930px;
|
||||||
|
max-height: 623px;
|
||||||
|
padding: 0px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssTooltipIcon = styled('div', `
|
||||||
|
color: ${theme.widgetGallerySecondaryHeaderFg};
|
||||||
|
border-color: ${theme.widgetGallerySecondaryHeaderFg};
|
||||||
|
`);
|
@ -13,16 +13,15 @@ import {attachAddNewTip} from 'app/client/ui/AddNewTip';
|
|||||||
import * as css from 'app/client/ui/DocMenuCss';
|
import * as css from 'app/client/ui/DocMenuCss';
|
||||||
import {buildHomeIntro, buildWorkspaceIntro} from 'app/client/ui/HomeIntro';
|
import {buildHomeIntro, buildWorkspaceIntro} from 'app/client/ui/HomeIntro';
|
||||||
import {buildUpgradeButton} from 'app/client/ui/ProductUpgrades';
|
import {buildUpgradeButton} from 'app/client/ui/ProductUpgrades';
|
||||||
import {buildTutorialCard} from 'app/client/ui/TutorialCard';
|
|
||||||
import {buildPinnedDoc, createPinnedDocs} from 'app/client/ui/PinnedDocs';
|
import {buildPinnedDoc, createPinnedDocs} from 'app/client/ui/PinnedDocs';
|
||||||
import {shadowScroll} from 'app/client/ui/shadowScroll';
|
import {shadowScroll} from 'app/client/ui/shadowScroll';
|
||||||
import {makeShareDocUrl} from 'app/client/ui/ShareMenu';
|
import {makeShareDocUrl} from 'app/client/ui/ShareMenu';
|
||||||
import {transition} from 'app/client/ui/transitions';
|
import {transition} from 'app/client/ui/transitions';
|
||||||
import {shouldShowWelcomeCoachingCall, showWelcomeCoachingCall} from 'app/client/ui/WelcomeCoachingCall';
|
import {shouldShowWelcomeCoachingCall, showWelcomeCoachingCall} from 'app/client/ui/WelcomeCoachingCall';
|
||||||
import {shouldShowWelcomeQuestions, showWelcomeQuestions} from 'app/client/ui/WelcomeQuestions';
|
|
||||||
import {createVideoTourTextButton} from 'app/client/ui/OpenVideoTour';
|
import {createVideoTourTextButton} from 'app/client/ui/OpenVideoTour';
|
||||||
import {buttonSelect, cssButtonSelect} from 'app/client/ui2018/buttonSelect';
|
import {buttonSelect, cssButtonSelect} from 'app/client/ui2018/buttonSelect';
|
||||||
import {isNarrowScreenObs, theme} from 'app/client/ui2018/cssVars';
|
import {isNarrowScreenObs, theme} from 'app/client/ui2018/cssVars';
|
||||||
|
import {buildOnboardingCards} from 'app/client/ui/OnboardingCards';
|
||||||
import {icon} from 'app/client/ui2018/icons';
|
import {icon} from 'app/client/ui2018/icons';
|
||||||
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
||||||
import {menu, menuItem, menuText, select} from 'app/client/ui2018/menus';
|
import {menu, menuItem, menuText, select} from 'app/client/ui2018/menus';
|
||||||
@ -62,10 +61,8 @@ export function createDocMenu(home: HomeModel): DomElementArg[] {
|
|||||||
|
|
||||||
function attachWelcomePopups(home: HomeModel): (el: Element) => void {
|
function attachWelcomePopups(home: HomeModel): (el: Element) => void {
|
||||||
return (element: Element) => {
|
return (element: Element) => {
|
||||||
const {app, app: {userPrefsObs}} = home;
|
const {app} = home;
|
||||||
if (shouldShowWelcomeQuestions(userPrefsObs)) {
|
if (shouldShowWelcomeCoachingCall(app)) {
|
||||||
showWelcomeQuestions(userPrefsObs);
|
|
||||||
} else if (shouldShowWelcomeCoachingCall(app)) {
|
|
||||||
showWelcomeCoachingCall(element, app);
|
showWelcomeCoachingCall(element, app);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -75,117 +72,117 @@ function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) {
|
|||||||
const flashDocId = observable<string|null>(null);
|
const flashDocId = observable<string|null>(null);
|
||||||
const upgradeButton = buildUpgradeButton(owner, home.app);
|
const upgradeButton = buildUpgradeButton(owner, home.app);
|
||||||
return css.docList( /* vbox */
|
return css.docList( /* vbox */
|
||||||
/* first line */
|
/* first line */
|
||||||
dom.create(buildTutorialCard, { app: home.app }),
|
dom.create(buildOnboardingCards, {homeModel: home}),
|
||||||
/* hbox */
|
/* hbox */
|
||||||
css.docListContent(
|
css.docListContent(
|
||||||
/* left column - grow 1 */
|
/* left column - grow 1 */
|
||||||
css.docMenu(
|
css.docMenu(
|
||||||
attachAddNewTip(home),
|
attachAddNewTip(home),
|
||||||
|
|
||||||
dom.maybe(!home.app.currentFeatures?.workspaces, () => [
|
dom.maybe(!home.app.currentFeatures?.workspaces, () => [
|
||||||
css.docListHeader(t("This service is not available right now")),
|
css.docListHeader(t("This service is not available right now")),
|
||||||
dom('span', t("(The organization needs a paid plan)")),
|
dom('span', t("(The organization needs a paid plan)")),
|
||||||
]),
|
]),
|
||||||
|
|
||||||
// currentWS and showIntro observables change together. We capture both in one domComputed call.
|
// currentWS and showIntro observables change together. We capture both in one domComputed call.
|
||||||
dom.domComputed<[IHomePage, Workspace|undefined, boolean]>(
|
dom.domComputed<[IHomePage, Workspace|undefined, boolean]>(
|
||||||
(use) => [use(home.currentPage), use(home.currentWS), use(home.showIntro)],
|
(use) => [use(home.currentPage), use(home.currentWS), use(home.showIntro)],
|
||||||
([page, workspace, showIntro]) => {
|
([page, workspace, showIntro]) => {
|
||||||
const viewSettings: ViewSettings =
|
const viewSettings: ViewSettings =
|
||||||
page === 'trash' ? makeLocalViewSettings(home, 'trash') :
|
page === 'trash' ? makeLocalViewSettings(home, 'trash') :
|
||||||
page === 'templates' ? makeLocalViewSettings(home, 'templates') :
|
page === 'templates' ? makeLocalViewSettings(home, 'templates') :
|
||||||
workspace ? makeLocalViewSettings(home, workspace.id) :
|
workspace ? makeLocalViewSettings(home, workspace.id) :
|
||||||
home;
|
home;
|
||||||
return [
|
return [
|
||||||
buildPrefs(
|
buildPrefs(
|
||||||
viewSettings,
|
viewSettings,
|
||||||
// Hide the sort and view options when showing the intro.
|
// Hide the sort and view options when showing the intro.
|
||||||
{hideSort: showIntro, hideView: showIntro && page === 'all'},
|
{hideSort: showIntro, hideView: showIntro && page === 'all'},
|
||||||
['all', 'workspace'].includes(page)
|
['all', 'workspace'].includes(page)
|
||||||
? upgradeButton.showUpgradeButton(css.upgradeButton.cls(''))
|
? upgradeButton.showUpgradeButton(css.upgradeButton.cls(''))
|
||||||
: null,
|
: null,
|
||||||
),
|
|
||||||
|
|
||||||
// Build the pinned docs dom. Builds nothing if the selectedOrg is unloaded.
|
|
||||||
// TODO: this is shown on all pages, but there is a hack in currentWSPinnedDocs that
|
|
||||||
// removes all pinned docs when on trash page.
|
|
||||||
dom.maybe((use) => use(home.currentWSPinnedDocs).length > 0, () => [
|
|
||||||
css.docListHeader(css.pinnedDocsIcon('PinBig'), t("Pinned Documents")),
|
|
||||||
createPinnedDocs(home, home.currentWSPinnedDocs),
|
|
||||||
]),
|
|
||||||
|
|
||||||
// Build the featured templates dom if on the Examples & Templates page.
|
|
||||||
dom.maybe((use) => page === 'templates' && use(home.featuredTemplates).length > 0, () => [
|
|
||||||
css.featuredTemplatesHeader(
|
|
||||||
css.featuredTemplatesIcon('Idea'),
|
|
||||||
t("Featured"),
|
|
||||||
testId('featured-templates-header')
|
|
||||||
),
|
),
|
||||||
createPinnedDocs(home, home.featuredTemplates, true),
|
|
||||||
]),
|
|
||||||
|
|
||||||
dom.maybe(home.available, () => [
|
// Build the pinned docs dom. Builds nothing if the selectedOrg is unloaded.
|
||||||
buildOtherSites(home),
|
// TODO: this is shown on all pages, but there is a hack in currentWSPinnedDocs that
|
||||||
(showIntro && page === 'all' ?
|
// removes all pinned docs when on trash page.
|
||||||
null :
|
dom.maybe((use) => use(home.currentWSPinnedDocs).length > 0, () => [
|
||||||
css.docListHeader(
|
css.docListHeader(css.pinnedDocsIcon('PinBig'), t("Pinned Documents")),
|
||||||
(
|
createPinnedDocs(home, home.currentWSPinnedDocs),
|
||||||
page === 'all' ? t("All Documents") :
|
]),
|
||||||
page === 'templates' ?
|
|
||||||
dom.domComputed(use => use(home.featuredTemplates).length > 0, (hasFeaturedTemplates) =>
|
// Build the featured templates dom if on the Examples & Templates page.
|
||||||
hasFeaturedTemplates ? t("More Examples and Templates") : t("Examples and Templates")
|
dom.maybe((use) => page === 'templates' && use(home.featuredTemplates).length > 0, () => [
|
||||||
) :
|
css.featuredTemplatesHeader(
|
||||||
page === 'trash' ? t("Trash") :
|
css.featuredTemplatesIcon('Idea'),
|
||||||
workspace && [css.docHeaderIcon(workspace.shareType === 'private' ? 'FolderPrivate' : 'Folder'),
|
t("Featured"),
|
||||||
workspaceName(home.app, workspace)]
|
testId('featured-templates-header')
|
||||||
),
|
),
|
||||||
testId('doc-header'),
|
createPinnedDocs(home, home.featuredTemplates, true),
|
||||||
)
|
]),
|
||||||
),
|
|
||||||
(
|
dom.maybe(home.available, () => [
|
||||||
(page === 'all') ?
|
buildOtherSites(home),
|
||||||
dom('div',
|
(showIntro && page === 'all' ?
|
||||||
showIntro ? buildHomeIntro(home) : null,
|
null :
|
||||||
buildAllDocsBlock(home, home.workspaces, showIntro, flashDocId, viewSettings),
|
css.docListHeader(
|
||||||
shouldShowTemplates(home, showIntro) ? buildAllDocsTemplates(home, viewSettings) : null,
|
(
|
||||||
) :
|
page === 'all' ? t("All Documents") :
|
||||||
(page === 'trash') ?
|
page === 'templates' ?
|
||||||
dom('div',
|
dom.domComputed(use => use(home.featuredTemplates).length > 0, (hasFeaturedTemplates) =>
|
||||||
css.docBlock(t("Documents stay in Trash for 30 days, after which they get deleted permanently.")),
|
hasFeaturedTemplates ? t("More Examples and Templates") : t("Examples and Templates")
|
||||||
dom.maybe((use) => use(home.trashWorkspaces).length === 0, () =>
|
) :
|
||||||
css.docBlock(t("Trash is empty."))
|
page === 'trash' ? t("Trash") :
|
||||||
|
workspace && [css.docHeaderIcon(workspace.shareType === 'private' ? 'FolderPrivate' : 'Folder'), workspaceName(home.app, workspace)]
|
||||||
),
|
),
|
||||||
buildAllDocsBlock(home, home.trashWorkspaces, false, flashDocId, viewSettings),
|
testId('doc-header'),
|
||||||
) :
|
)
|
||||||
(page === 'templates') ?
|
),
|
||||||
dom('div',
|
(
|
||||||
buildAllTemplates(home, home.templateWorkspaces, viewSettings)
|
(page === 'all') ?
|
||||||
) :
|
dom('div',
|
||||||
workspace && !workspace.isSupportWorkspace && workspace.docs?.length ?
|
showIntro ? buildHomeIntro(home) : null,
|
||||||
css.docBlock(
|
buildAllDocsBlock(home, home.workspaces, showIntro, flashDocId, viewSettings),
|
||||||
buildWorkspaceDocBlock(home, workspace, flashDocId, viewSettings),
|
shouldShowTemplates(home, showIntro) ? buildAllDocsTemplates(home, viewSettings) : null,
|
||||||
testId('doc-block')
|
|
||||||
) :
|
) :
|
||||||
workspace && !workspace.isSupportWorkspace && workspace.docs?.length === 0 ?
|
(page === 'trash') ?
|
||||||
buildWorkspaceIntro(home) :
|
dom('div',
|
||||||
css.docBlock(t("Workspace not found"))
|
css.docBlock(t("Documents stay in Trash for 30 days, after which they get deleted permanently.")),
|
||||||
)
|
dom.maybe((use) => use(home.trashWorkspaces).length === 0, () =>
|
||||||
]),
|
css.docBlock(t("Trash is empty."))
|
||||||
|
),
|
||||||
|
buildAllDocsBlock(home, home.trashWorkspaces, false, flashDocId, viewSettings),
|
||||||
|
) :
|
||||||
|
(page === 'templates') ?
|
||||||
|
dom('div',
|
||||||
|
buildAllTemplates(home, home.templateWorkspaces, viewSettings)
|
||||||
|
) :
|
||||||
|
workspace && !workspace.isSupportWorkspace && workspace.docs?.length ?
|
||||||
|
css.docBlock(
|
||||||
|
buildWorkspaceDocBlock(home, workspace, flashDocId, viewSettings),
|
||||||
|
testId('doc-block')
|
||||||
|
) :
|
||||||
|
workspace && !workspace.isSupportWorkspace && workspace.docs?.length === 0 ?
|
||||||
|
buildWorkspaceIntro(home) :
|
||||||
|
css.docBlock(t("Workspace not found"))
|
||||||
|
)
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
testId('doclist')
|
||||||
|
),
|
||||||
|
dom.maybe(use => !use(isNarrowScreenObs()) && ['all', 'workspace'].includes(use(home.currentPage)),
|
||||||
|
() => {
|
||||||
|
// TODO: These don't currently clash (grist-core stubs the upgradeButton), but a way to
|
||||||
|
// manage card popups will be needed if more are added later.
|
||||||
|
return [
|
||||||
|
upgradeButton.showUpgradeCard(css.upgradeCard.cls('')),
|
||||||
|
home.app.supportGristNudge.buildNudgeCard(),
|
||||||
];
|
];
|
||||||
}),
|
}),
|
||||||
testId('doclist')
|
|
||||||
),
|
),
|
||||||
dom.maybe(use => !use(isNarrowScreenObs()) && ['all', 'workspace'].includes(use(home.currentPage)),
|
);
|
||||||
() => {
|
|
||||||
// TODO: These don't currently clash (grist-core stubs the upgradeButton), but a way to
|
|
||||||
// manage card popups will be needed if more are added later.
|
|
||||||
return [
|
|
||||||
upgradeButton.showUpgradeCard(css.upgradeCard.cls('')),
|
|
||||||
home.app.supportGristNudge.buildNudgeCard(),
|
|
||||||
];
|
|
||||||
}),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildAllDocsBlock(
|
function buildAllDocsBlock(
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
import {GristDoc} from 'app/client/components/GristDoc';
|
import {GristDoc} from 'app/client/components/GristDoc';
|
||||||
|
import {makeT} from 'app/client/lib/localization';
|
||||||
import {logTelemetryEvent} from 'app/client/lib/telemetry';
|
import {logTelemetryEvent} from 'app/client/lib/telemetry';
|
||||||
import {getWelcomeHomeUrl, urlState} from 'app/client/models/gristUrlState';
|
import {getWelcomeHomeUrl, urlState} from 'app/client/models/gristUrlState';
|
||||||
import {renderer} from 'app/client/ui/DocTutorialRenderer';
|
import {renderer} from 'app/client/ui/DocTutorialRenderer';
|
||||||
import {cssPopupBody, FLOATING_POPUP_TOOLTIP_KEY, FloatingPopup} from 'app/client/ui/FloatingPopup';
|
import {cssPopupBody, FLOATING_POPUP_TOOLTIP_KEY, FloatingPopup} from 'app/client/ui/FloatingPopup';
|
||||||
import {sanitizeHTML} from 'app/client/ui/sanitizeHTML';
|
import {sanitizeHTML} from 'app/client/ui/sanitizeHTML';
|
||||||
import {hoverTooltip, setHoverTooltip} from 'app/client/ui/tooltips';
|
import {hoverTooltip, setHoverTooltip} from 'app/client/ui/tooltips';
|
||||||
import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
|
import {basicButton, primaryButton, textButton} from 'app/client/ui2018/buttons';
|
||||||
import {mediaXSmall, theme, vars} from 'app/client/ui2018/cssVars';
|
import {mediaXSmall, theme, vars} from 'app/client/ui2018/cssVars';
|
||||||
import {icon} from 'app/client/ui2018/icons';
|
import {icon} from 'app/client/ui2018/icons';
|
||||||
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
||||||
@ -24,6 +25,8 @@ interface DocTutorialSlide {
|
|||||||
imageUrls: string[];
|
imageUrls: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const t = makeT('DocTutorial');
|
||||||
|
|
||||||
const testId = makeTestId('test-doc-tutorial-');
|
const testId = makeTestId('test-doc-tutorial-');
|
||||||
|
|
||||||
export class DocTutorial extends FloatingPopup {
|
export class DocTutorial extends FloatingPopup {
|
||||||
@ -35,12 +38,12 @@ export class DocTutorial extends FloatingPopup {
|
|||||||
private _docId = this._gristDoc.docId();
|
private _docId = this._gristDoc.docId();
|
||||||
private _slides: Observable<DocTutorialSlide[] | null> = Observable.create(this, null);
|
private _slides: Observable<DocTutorialSlide[] | null> = Observable.create(this, null);
|
||||||
private _currentSlideIndex = Observable.create(this, this._currentFork?.options?.tutorial?.lastSlideIndex ?? 0);
|
private _currentSlideIndex = Observable.create(this, this._currentFork?.options?.tutorial?.lastSlideIndex ?? 0);
|
||||||
|
private _percentComplete = this._currentFork?.options?.tutorial?.percentComplete;
|
||||||
|
|
||||||
|
private _saveProgressDebounced = debounce(this._saveProgress, 1000, {
|
||||||
private _saveCurrentSlidePositionDebounced = debounce(this._saveCurrentSlidePosition, 1000, {
|
// Save progress immediately if at least 1 second has passed since the last change.
|
||||||
// Save new position immediately if at least 1 second has passed since the last change.
|
|
||||||
leading: true,
|
leading: true,
|
||||||
// Otherwise, wait for the new position to settle for 1 second before saving it.
|
// Otherwise, wait 1 second before saving.
|
||||||
trailing: true
|
trailing: true
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -49,6 +52,18 @@ export class DocTutorial extends FloatingPopup {
|
|||||||
minimizable: true,
|
minimizable: true,
|
||||||
stopClickPropagationOnMove: true,
|
stopClickPropagationOnMove: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.autoDispose(this._currentSlideIndex.addListener((slideIndex) => {
|
||||||
|
const numSlides = this._slides.get()?.length ?? 0;
|
||||||
|
if (numSlides > 0) {
|
||||||
|
this._percentComplete = Math.max(
|
||||||
|
Math.floor((slideIndex / numSlides) * 100),
|
||||||
|
this._percentComplete ?? 0
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this._percentComplete = undefined;
|
||||||
|
}
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async start() {
|
public async start() {
|
||||||
@ -103,13 +118,6 @@ export class DocTutorial extends FloatingPopup {
|
|||||||
const isFirstSlide = slideIndex === 0;
|
const isFirstSlide = slideIndex === 0;
|
||||||
const isLastSlide = slideIndex === numSlides - 1;
|
const isLastSlide = slideIndex === numSlides - 1;
|
||||||
return [
|
return [
|
||||||
cssFooterButtonsLeft(
|
|
||||||
cssPopupFooterButton(icon('Undo'),
|
|
||||||
hoverTooltip('Restart Tutorial', {key: FLOATING_POPUP_TOOLTIP_KEY}),
|
|
||||||
dom.on('click', () => this._restartTutorial()),
|
|
||||||
testId('popup-restart'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
cssProgressBar(
|
cssProgressBar(
|
||||||
range(slides.length).map((i) => cssProgressBarDot(
|
range(slides.length).map((i) => cssProgressBarDot(
|
||||||
hoverTooltip(slides[i].slideTitle, {
|
hoverTooltip(slides[i].slideTitle, {
|
||||||
@ -121,17 +129,17 @@ export class DocTutorial extends FloatingPopup {
|
|||||||
testId(`popup-slide-${i + 1}`),
|
testId(`popup-slide-${i + 1}`),
|
||||||
)),
|
)),
|
||||||
),
|
),
|
||||||
cssFooterButtonsRight(
|
cssFooterButtons(
|
||||||
basicButton('Previous',
|
basicButton(t('Previous'),
|
||||||
dom.on('click', async () => {
|
dom.on('click', async () => {
|
||||||
await this._previousSlide();
|
await this._previousSlide();
|
||||||
}),
|
}),
|
||||||
{style: `visibility: ${isFirstSlide ? 'hidden' : 'visible'}`},
|
{style: `visibility: ${isFirstSlide ? 'hidden' : 'visible'}`},
|
||||||
testId('popup-previous'),
|
testId('popup-previous'),
|
||||||
),
|
),
|
||||||
primaryButton(isLastSlide ? 'Finish': 'Next',
|
primaryButton(isLastSlide ? t('Finish'): t('Next'),
|
||||||
isLastSlide
|
isLastSlide
|
||||||
? dom.on('click', async () => await this._finishTutorial())
|
? dom.on('click', async () => await this._exitTutorial(true))
|
||||||
: dom.on('click', async () => await this._nextSlide()),
|
: dom.on('click', async () => await this._nextSlide()),
|
||||||
testId('popup-next'),
|
testId('popup-next'),
|
||||||
),
|
),
|
||||||
@ -140,6 +148,21 @@ export class DocTutorial extends FloatingPopup {
|
|||||||
}),
|
}),
|
||||||
testId('popup-footer'),
|
testId('popup-footer'),
|
||||||
),
|
),
|
||||||
|
cssTutorialControls(
|
||||||
|
cssTextButton(
|
||||||
|
cssRestartIcon('Undo'),
|
||||||
|
t('Restart'),
|
||||||
|
dom.on('click', () => this._restartTutorial()),
|
||||||
|
testId('popup-restart'),
|
||||||
|
),
|
||||||
|
cssButtonsSeparator(),
|
||||||
|
cssTextButton(
|
||||||
|
cssSkipIcon('Skip'),
|
||||||
|
t('End tutorial'),
|
||||||
|
dom.on('click', () => this._exitTutorial()),
|
||||||
|
testId('popup-end-tutorial'),
|
||||||
|
),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -161,19 +184,13 @@ export class DocTutorial extends FloatingPopup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _logTelemetryEvent(event: 'tutorialOpened' | 'tutorialProgressChanged') {
|
private _logTelemetryEvent(event: 'tutorialOpened' | 'tutorialProgressChanged') {
|
||||||
const currentSlideIndex = this._currentSlideIndex.get();
|
|
||||||
const numSlides = this._slides.get()?.length;
|
|
||||||
let percentComplete: number | undefined = undefined;
|
|
||||||
if (numSlides !== undefined && numSlides > 0) {
|
|
||||||
percentComplete = Math.floor(((currentSlideIndex + 1) / numSlides) * 100);
|
|
||||||
}
|
|
||||||
logTelemetryEvent(event, {
|
logTelemetryEvent(event, {
|
||||||
full: {
|
full: {
|
||||||
tutorialForkIdDigest: this._currentFork?.id,
|
tutorialForkIdDigest: this._currentFork?.id,
|
||||||
tutorialTrunkIdDigest: this._currentFork?.trunkId,
|
tutorialTrunkIdDigest: this._currentFork?.trunkId,
|
||||||
lastSlideIndex: currentSlideIndex,
|
lastSlideIndex: this._currentSlideIndex.get(),
|
||||||
numSlides,
|
numSlides: this._slides.get()?.length,
|
||||||
percentComplete,
|
percentComplete: this._percentComplete,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -251,14 +268,13 @@ export class DocTutorial extends FloatingPopup {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _saveCurrentSlidePosition() {
|
private async _saveProgress() {
|
||||||
const currentOptions = this._currentDoc?.options ?? {};
|
|
||||||
const currentSlideIndex = this._currentSlideIndex.get();
|
|
||||||
await this._appModel.api.updateDoc(this._docId, {
|
await this._appModel.api.updateDoc(this._docId, {
|
||||||
options: {
|
options: {
|
||||||
...currentOptions,
|
...this._currentFork?.options,
|
||||||
tutorial: {
|
tutorial: {
|
||||||
lastSlideIndex: currentSlideIndex,
|
lastSlideIndex: this._currentSlideIndex.get(),
|
||||||
|
percentComplete: this._percentComplete,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -267,7 +283,7 @@ export class DocTutorial extends FloatingPopup {
|
|||||||
|
|
||||||
private async _changeSlide(slideIndex: number) {
|
private async _changeSlide(slideIndex: number) {
|
||||||
this._currentSlideIndex.set(slideIndex);
|
this._currentSlideIndex.set(slideIndex);
|
||||||
await this._saveCurrentSlidePositionDebounced();
|
await this._saveProgressDebounced();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _previousSlide() {
|
private async _previousSlide() {
|
||||||
@ -278,9 +294,10 @@ export class DocTutorial extends FloatingPopup {
|
|||||||
await this._changeSlide(this._currentSlideIndex.get() + 1);
|
await this._changeSlide(this._currentSlideIndex.get() + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _finishTutorial() {
|
private async _exitTutorial(markAsComplete = false) {
|
||||||
this._saveCurrentSlidePositionDebounced.cancel();
|
this._saveProgressDebounced.cancel();
|
||||||
await this._saveCurrentSlidePosition();
|
if (markAsComplete) { this._percentComplete = 100; }
|
||||||
|
await this._saveProgressDebounced();
|
||||||
const lastVisitedOrg = this._appModel.lastVisitedOrgDomain.get();
|
const lastVisitedOrg = this._appModel.lastVisitedOrgDomain.get();
|
||||||
if (lastVisitedOrg) {
|
if (lastVisitedOrg) {
|
||||||
await urlState().pushUrl({org: lastVisitedOrg});
|
await urlState().pushUrl({org: lastVisitedOrg});
|
||||||
@ -298,8 +315,8 @@ export class DocTutorial extends FloatingPopup {
|
|||||||
};
|
};
|
||||||
|
|
||||||
confirmModal(
|
confirmModal(
|
||||||
'Do you want to restart the tutorial? All progress will be lost.',
|
t('Do you want to restart the tutorial? All progress will be lost.'),
|
||||||
'Restart',
|
t('Restart'),
|
||||||
doRestart,
|
doRestart,
|
||||||
{
|
{
|
||||||
modalOptions: {
|
modalOptions: {
|
||||||
@ -321,7 +338,7 @@ export class DocTutorial extends FloatingPopup {
|
|||||||
// eslint-disable-next-line no-self-assign
|
// eslint-disable-next-line no-self-assign
|
||||||
img.src = img.src;
|
img.src = img.src;
|
||||||
|
|
||||||
setHoverTooltip(img, 'Click to expand', {
|
setHoverTooltip(img, t('Click to expand'), {
|
||||||
key: FLOATING_POPUP_TOOLTIP_KEY,
|
key: FLOATING_POPUP_TOOLTIP_KEY,
|
||||||
modifiers: {
|
modifiers: {
|
||||||
flip: {
|
flip: {
|
||||||
@ -357,14 +374,13 @@ export class DocTutorial extends FloatingPopup {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const cssPopupFooter = styled('div', `
|
const cssPopupFooter = styled('div', `
|
||||||
display: flex;
|
display: flex;
|
||||||
column-gap: 24px;
|
column-gap: 24px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 24px 16px 24px 16px;
|
padding: 16px;
|
||||||
border-top: 1px solid ${theme.tutorialsPopupBorder};
|
border-top: 1px solid ${theme.tutorialsPopupBorder};
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@ -375,19 +391,6 @@ const cssTryItOutBox = styled('div', `
|
|||||||
background-color: ${theme.tutorialsPopupBoxBg};
|
background-color: ${theme.tutorialsPopupBoxBg};
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const cssPopupFooterButton = styled('div', `
|
|
||||||
--icon-color: ${theme.controlSecondaryFg};
|
|
||||||
padding: 4px;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: ${theme.hover};
|
|
||||||
}
|
|
||||||
`);
|
|
||||||
|
|
||||||
const cssProgressBar = styled('div', `
|
const cssProgressBar = styled('div', `
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@ -409,11 +412,7 @@ const cssProgressBarDot = styled('div', `
|
|||||||
}
|
}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const cssFooterButtonsLeft = styled('div', `
|
const cssFooterButtons = styled('div', `
|
||||||
flex-shrink: 0;
|
|
||||||
`);
|
|
||||||
|
|
||||||
const cssFooterButtonsRight = styled('div', `
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
column-gap: 8px;
|
column-gap: 8px;
|
||||||
@ -473,3 +472,34 @@ const cssSpinner = styled('div', `
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
const cssTutorialControls = styled('div', `
|
||||||
|
background-color: ${theme.notificationsPanelHeaderBg};
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssTextButton = styled(textButton, `
|
||||||
|
font-weight: 500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
column-gap: 4px;
|
||||||
|
padding: 0 16px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssRestartIcon = styled(icon, `
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssButtonsSeparator = styled('div', `
|
||||||
|
width: 0;
|
||||||
|
border-right: 1px solid ${theme.controlFg};
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssSkipIcon = styled(icon, `
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
margin: 0px -3px;
|
||||||
|
`);
|
||||||
|
@ -165,7 +165,7 @@ const cssFormContent = styled('form', `
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
& p {
|
& p {
|
||||||
margin: 0px;
|
margin: 0 0 10px 0;
|
||||||
}
|
}
|
||||||
& strong {
|
& strong {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
@ -42,7 +42,8 @@ export type Tooltip =
|
|||||||
| 'formulaColumn'
|
| 'formulaColumn'
|
||||||
| 'accessRulesTableWide'
|
| 'accessRulesTableWide'
|
||||||
| 'setChoiceDropdownCondition'
|
| 'setChoiceDropdownCondition'
|
||||||
| 'setRefDropdownCondition';
|
| 'setRefDropdownCondition'
|
||||||
|
| 'communityWidgets';
|
||||||
|
|
||||||
export type TooltipContentFunc = (...domArgs: DomElementArg[]) => DomContents;
|
export type TooltipContentFunc = (...domArgs: DomElementArg[]) => DomContents;
|
||||||
|
|
||||||
@ -152,6 +153,15 @@ see or edit which parts of your document.')
|
|||||||
),
|
),
|
||||||
...args,
|
...args,
|
||||||
),
|
),
|
||||||
|
communityWidgets: (...args: DomElementArg[]) => cssTooltipContent(
|
||||||
|
dom('div',
|
||||||
|
t('Community widgets are created and maintained by Grist community members.')
|
||||||
|
),
|
||||||
|
dom('div',
|
||||||
|
cssLink({href: commonUrls.helpCustomWidgets, target: '_blank'}, t('Learn more.')),
|
||||||
|
),
|
||||||
|
...args,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface BehavioralPromptContent {
|
export interface BehavioralPromptContent {
|
||||||
@ -307,20 +317,6 @@ to determine who can see or edit which parts of your document.')),
|
|||||||
forceShow: true,
|
forceShow: true,
|
||||||
markAsSeen: false,
|
markAsSeen: false,
|
||||||
},
|
},
|
||||||
customURL: {
|
|
||||||
popupType: 'tip',
|
|
||||||
title: () => t('Custom Widgets'),
|
|
||||||
content: (...args: DomElementArg[]) => cssTooltipContent(
|
|
||||||
dom('div',
|
|
||||||
t(
|
|
||||||
'You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.'
|
|
||||||
),
|
|
||||||
),
|
|
||||||
dom('div', cssLink({href: commonUrls.helpCustomWidgets, target: '_blank'}, t('Learn more.'))),
|
|
||||||
...args,
|
|
||||||
),
|
|
||||||
deploymentTypes: ['saas', 'core', 'enterprise', 'electron'],
|
|
||||||
},
|
|
||||||
calendarConfig: {
|
calendarConfig: {
|
||||||
popupType: 'tip',
|
popupType: 'tip',
|
||||||
title: () => t('Calendar'),
|
title: () => t('Calendar'),
|
||||||
|
@ -83,7 +83,6 @@ function makeViewerTeamSiteIntro(homeModel: HomeModel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function makeTeamSiteIntro(homeModel: HomeModel) {
|
function makeTeamSiteIntro(homeModel: HomeModel) {
|
||||||
const sproutsProgram = cssLink({href: commonUrls.sproutsProgram, target: '_blank'}, t("Sprouts Program"));
|
|
||||||
return [
|
return [
|
||||||
css.docListHeader(
|
css.docListHeader(
|
||||||
t("Welcome to {{- orgName}}", {orgName: homeModel.app.currentOrgName}),
|
t("Welcome to {{- orgName}}", {orgName: homeModel.app.currentOrgName}),
|
||||||
@ -94,8 +93,8 @@ function makeTeamSiteIntro(homeModel: HomeModel) {
|
|||||||
(!isFeatureEnabled('helpCenter') ? null :
|
(!isFeatureEnabled('helpCenter') ? null :
|
||||||
cssIntroLine(
|
cssIntroLine(
|
||||||
t(
|
t(
|
||||||
'Learn more in our {{helpCenterLink}}, or find an expert via our {{sproutsProgram}}.',
|
'Learn more in our {{helpCenterLink}}.',
|
||||||
{helpCenterLink: helpCenterLink(), sproutsProgram}
|
{helpCenterLink: helpCenterLink()}
|
||||||
),
|
),
|
||||||
testId('welcome-text')
|
testId('welcome-text')
|
||||||
)
|
)
|
||||||
|
@ -5,7 +5,7 @@ import {reportError} from 'app/client/models/AppModel';
|
|||||||
import {docUrl, urlState} from 'app/client/models/gristUrlState';
|
import {docUrl, urlState} from 'app/client/models/gristUrlState';
|
||||||
import {HomeModel} from 'app/client/models/HomeModel';
|
import {HomeModel} from 'app/client/models/HomeModel';
|
||||||
import {getWorkspaceInfo, workspaceName} from 'app/client/models/WorkspaceInfo';
|
import {getWorkspaceInfo, workspaceName} from 'app/client/models/WorkspaceInfo';
|
||||||
import {getAdminPanelName} from 'app/client/ui/AdminPanel';
|
import {getAdminPanelName} from 'app/client/ui/AdminPanelName';
|
||||||
import {addNewButton, cssAddNewButton} from 'app/client/ui/AddNewButton';
|
import {addNewButton, cssAddNewButton} from 'app/client/ui/AddNewButton';
|
||||||
import {docImport, importFromPlugin} from 'app/client/ui/HomeImports';
|
import {docImport, importFromPlugin} from 'app/client/ui/HomeImports';
|
||||||
import {
|
import {
|
||||||
@ -31,7 +31,8 @@ export function createHomeLeftPane(leftPanelOpen: Observable<boolean>, home: Hom
|
|||||||
const creating = observable<boolean>(false);
|
const creating = observable<boolean>(false);
|
||||||
const renaming = observable<Workspace|null>(null);
|
const renaming = observable<Workspace|null>(null);
|
||||||
const isAnonymous = !home.app.currentValidUser;
|
const isAnonymous = !home.app.currentValidUser;
|
||||||
const canCreate = !isAnonymous || getGristConfig().enableAnonPlayground;
|
const {enableAnonPlayground, templateOrg, onboardingTutorialDocId} = getGristConfig();
|
||||||
|
const canCreate = !isAnonymous || enableAnonPlayground;
|
||||||
|
|
||||||
return cssContent(
|
return cssContent(
|
||||||
dom.autoDispose(creating),
|
dom.autoDispose(creating),
|
||||||
@ -119,7 +120,7 @@ export function createHomeLeftPane(leftPanelOpen: Observable<boolean>, home: Hom
|
|||||||
)),
|
)),
|
||||||
cssTools(
|
cssTools(
|
||||||
cssPageEntry(
|
cssPageEntry(
|
||||||
dom.show(isFeatureEnabled("templates") && Boolean(getGristConfig().templateOrg)),
|
dom.show(isFeatureEnabled("templates") && Boolean(templateOrg)),
|
||||||
cssPageEntry.cls('-selected', (use) => use(home.currentPage) === "templates"),
|
cssPageEntry.cls('-selected', (use) => use(home.currentPage) === "templates"),
|
||||||
cssPageLink(cssPageIcon('Board'), cssLinkText(t("Examples & Templates")),
|
cssPageLink(cssPageIcon('Board'), cssLinkText(t("Examples & Templates")),
|
||||||
urlState().setLinkUrl({homePage: "templates"}),
|
urlState().setLinkUrl({homePage: "templates"}),
|
||||||
@ -135,9 +136,9 @@ export function createHomeLeftPane(leftPanelOpen: Observable<boolean>, home: Hom
|
|||||||
),
|
),
|
||||||
cssSpacer(),
|
cssSpacer(),
|
||||||
cssPageEntry(
|
cssPageEntry(
|
||||||
dom.show(isFeatureEnabled('tutorials')),
|
dom.show(isFeatureEnabled('tutorials') && Boolean(templateOrg && onboardingTutorialDocId)),
|
||||||
cssPageLink(cssPageIcon('Bookmark'), cssLinkText(t("Tutorial")),
|
cssPageLink(cssPageIcon('Bookmark'), cssLinkText(t("Tutorial")),
|
||||||
{ href: commonUrls.basicTutorial, target: '_blank' },
|
urlState().setLinkUrl({org: templateOrg!, doc: onboardingTutorialDocId}),
|
||||||
testId('dm-basic-tutorial'),
|
testId('dm-basic-tutorial'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
232
app/client/ui/OnboardingCards.ts
Normal file
232
app/client/ui/OnboardingCards.ts
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
import {makeT} from 'app/client/lib/localization';
|
||||||
|
import {urlState} from 'app/client/models/gristUrlState';
|
||||||
|
import {HomeModel} from 'app/client/models/HomeModel';
|
||||||
|
import {openVideoTour} from 'app/client/ui/OpenVideoTour';
|
||||||
|
import {bigPrimaryButtonLink} from 'app/client/ui2018/buttons';
|
||||||
|
import {colors, theme} from 'app/client/ui2018/cssVars';
|
||||||
|
import {icon} from 'app/client/ui2018/icons';
|
||||||
|
import {isFeatureEnabled} from 'app/common/gristUrls';
|
||||||
|
import {getGristConfig} from 'app/common/urlUtils';
|
||||||
|
import {Computed, dom, IDisposableOwner, makeTestId, styled, subscribeElem} from 'grainjs';
|
||||||
|
|
||||||
|
interface BuildOnboardingCardsOptions {
|
||||||
|
homeModel: HomeModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = makeT('OnboardingCards');
|
||||||
|
|
||||||
|
const testId = makeTestId('test-onboarding-');
|
||||||
|
|
||||||
|
export function buildOnboardingCards(
|
||||||
|
owner: IDisposableOwner,
|
||||||
|
{homeModel}: BuildOnboardingCardsOptions
|
||||||
|
) {
|
||||||
|
const {templateOrg, onboardingTutorialDocId} = getGristConfig();
|
||||||
|
if (!isFeatureEnabled('tutorials') || !templateOrg || !onboardingTutorialDocId) { return null; }
|
||||||
|
|
||||||
|
const percentComplete = Computed.create(owner, (use) => {
|
||||||
|
if (!homeModel.app.currentValidUser) { return 0; }
|
||||||
|
|
||||||
|
const tutorial = use(homeModel.onboardingTutorial);
|
||||||
|
if (!tutorial) { return undefined; }
|
||||||
|
|
||||||
|
return tutorial.forks?.[0]?.options?.tutorial?.percentComplete ?? 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const shouldShowCards = Computed.create(owner, (use) =>
|
||||||
|
!use(homeModel.app.dismissedPopups).includes('onboardingCards'));
|
||||||
|
|
||||||
|
let videoPlayButtonElement: HTMLElement;
|
||||||
|
|
||||||
|
return dom.maybe(shouldShowCards, () =>
|
||||||
|
cssOnboardingCards(
|
||||||
|
cssTutorialCard(
|
||||||
|
cssDismissCardsButton(
|
||||||
|
icon('CrossBig'),
|
||||||
|
dom.on('click', () => homeModel.app.dismissPopup('onboardingCards', true)),
|
||||||
|
testId('dismiss-cards'),
|
||||||
|
),
|
||||||
|
cssTutorialCardHeader(
|
||||||
|
t('Complete our basics tutorial'),
|
||||||
|
),
|
||||||
|
cssTutorialCardSubHeader(
|
||||||
|
t('Learn the basics of reference columns, linked widgets, column types, & cards.')
|
||||||
|
),
|
||||||
|
cssTutorialCardBody(
|
||||||
|
cssTutorialProgress(
|
||||||
|
cssTutorialProgressText(
|
||||||
|
cssProgressPercentage(
|
||||||
|
dom.domComputed(percentComplete, (percent) => percent !== undefined ? `${percent}%` : null),
|
||||||
|
testId('tutorial-percent-complete'),
|
||||||
|
),
|
||||||
|
cssStarIcon('Star'),
|
||||||
|
),
|
||||||
|
cssTutorialProgressBar(
|
||||||
|
(elem) => subscribeElem(elem, percentComplete, (val) => {
|
||||||
|
elem.style.setProperty('--percent-complete', String(val ?? 0));
|
||||||
|
})
|
||||||
|
),
|
||||||
|
),
|
||||||
|
bigPrimaryButtonLink(
|
||||||
|
t('Complete the tutorial'),
|
||||||
|
urlState().setLinkUrl({org: templateOrg, doc: onboardingTutorialDocId}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
testId('tutorial-card'),
|
||||||
|
),
|
||||||
|
cssVideoCard(
|
||||||
|
cssVideoThumbnail(
|
||||||
|
cssVideoThumbnailSpacer(),
|
||||||
|
videoPlayButtonElement = cssVideoPlayButton(
|
||||||
|
cssPlayIcon('VideoPlay2'),
|
||||||
|
),
|
||||||
|
cssVideoThumbnailText(t('3 minute video tour')),
|
||||||
|
),
|
||||||
|
dom.on('click', () => openVideoTour(videoPlayButtonElement)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cssOnboardingCards = styled('div', `
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, max-content));
|
||||||
|
gap: 24px;
|
||||||
|
margin: 24px 0;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssTutorialCard = styled('div', `
|
||||||
|
position: relative;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: ${theme.announcementPopupFg};
|
||||||
|
background-color: ${theme.announcementPopupBg};
|
||||||
|
padding: 16px 24px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssTutorialCardHeader = styled('div', `
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 18px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssDismissCardsButton = styled('div', `
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
--icon-color: ${theme.popupCloseButtonFg};
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: ${theme.hover};
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssTutorialCardSubHeader = styled('div', `
|
||||||
|
font-size: 14px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 8px 0;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssTutorialCardBody = styled('div', `
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 24px;
|
||||||
|
margin: 16px 0;
|
||||||
|
align-items: end;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssTutorialProgress = styled('div', `
|
||||||
|
flex: auto;
|
||||||
|
min-width: 120px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssTutorialProgressText = styled('div', `
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssProgressPercentage = styled('div', `
|
||||||
|
font-size: 20px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssStarIcon = styled(icon, `
|
||||||
|
--icon-color: ${theme.accentIcon};
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssTutorialProgressBar = styled('div', `
|
||||||
|
margin-top: 4px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: ${theme.mainPanelBg};
|
||||||
|
--percent-complete: 0;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
border-radius: 8px;
|
||||||
|
background: ${theme.progressBarFg};
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
width: calc((var(--percent-complete) / 100) * 100%);
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssVideoCard = styled('div', `
|
||||||
|
width: 220px;
|
||||||
|
height: 158px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssVideoThumbnail = styled('div', `
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 36px 32px;
|
||||||
|
background-image: url("img/youtube-screenshot.png");
|
||||||
|
background-color: rgba(0, 0, 0, 0.4);
|
||||||
|
background-blend-mode: multiply;
|
||||||
|
background-size: cover;
|
||||||
|
transform: scale(1.2);
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssVideoThumbnailSpacer = styled('div', ``);
|
||||||
|
|
||||||
|
const cssVideoPlayButton = styled('div', `
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
align-self: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background-color: ${theme.controlPrimaryBg};
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
.${cssVideoThumbnail.className}:hover & {
|
||||||
|
background-color: ${theme.controlPrimaryHoverBg};
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssPlayIcon = styled(icon, `
|
||||||
|
--icon-color: ${theme.controlPrimaryFg};
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssVideoThumbnailText = styled('div', `
|
||||||
|
color: ${colors.light};
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
`);
|
747
app/client/ui/OnboardingPage.ts
Normal file
747
app/client/ui/OnboardingPage.ts
Normal file
@ -0,0 +1,747 @@
|
|||||||
|
import {FocusLayer} from 'app/client/lib/FocusLayer';
|
||||||
|
import {makeT} from 'app/client/lib/localization';
|
||||||
|
import {AppModel} from 'app/client/models/AppModel';
|
||||||
|
import {logError} from 'app/client/models/errors';
|
||||||
|
import {getMainOrgUrl, urlState} from 'app/client/models/gristUrlState';
|
||||||
|
import {getUserPrefObs} from 'app/client/models/UserPrefs';
|
||||||
|
import {textInput} from 'app/client/ui/inputs';
|
||||||
|
import {PlayerState, YouTubePlayer} from 'app/client/ui/YouTubePlayer';
|
||||||
|
import {bigBasicButton, bigPrimaryButton, bigPrimaryButtonLink} from 'app/client/ui2018/buttons';
|
||||||
|
import {colors, mediaMedium, mediaXSmall, theme} from 'app/client/ui2018/cssVars';
|
||||||
|
import {icon} from 'app/client/ui2018/icons';
|
||||||
|
import {IconName} from 'app/client/ui2018/IconList';
|
||||||
|
import {modal} from 'app/client/ui2018/modals';
|
||||||
|
import {BaseAPI} from 'app/common/BaseAPI';
|
||||||
|
import {getPageTitleSuffix, ONBOARDING_VIDEO_YOUTUBE_EMBED_ID} from 'app/common/gristUrls';
|
||||||
|
import {UserPrefs} from 'app/common/Prefs';
|
||||||
|
import {getGristConfig} from 'app/common/urlUtils';
|
||||||
|
import {
|
||||||
|
Computed,
|
||||||
|
Disposable,
|
||||||
|
dom,
|
||||||
|
DomContents,
|
||||||
|
IDisposableOwner,
|
||||||
|
input,
|
||||||
|
makeTestId,
|
||||||
|
Observable,
|
||||||
|
styled,
|
||||||
|
subscribeElem,
|
||||||
|
} from 'grainjs';
|
||||||
|
|
||||||
|
const t = makeT('OnboardingPage');
|
||||||
|
|
||||||
|
const testId = makeTestId('test-onboarding-');
|
||||||
|
|
||||||
|
const choices: Array<{icon: IconName, color: string, textKey: string}> = [
|
||||||
|
{icon: 'UseProduct', color: `${colors.lightGreen}`, textKey: 'Product Development' },
|
||||||
|
{icon: 'UseFinance', color: '#0075A2', textKey: 'Finance & Accounting'},
|
||||||
|
{icon: 'UseMedia', color: '#F7B32B', textKey: 'Media Production' },
|
||||||
|
{icon: 'UseMonitor', color: '#F2545B', textKey: 'IT & Technology' },
|
||||||
|
{icon: 'UseChart', color: '#7141F9', textKey: 'Marketing' },
|
||||||
|
{icon: 'UseScience', color: '#231942', textKey: 'Research' },
|
||||||
|
{icon: 'UseSales', color: '#885A5A', textKey: 'Sales' },
|
||||||
|
{icon: 'UseEducate', color: '#4A5899', textKey: 'Education' },
|
||||||
|
{icon: 'UseHr', color: '#688047', textKey: 'HR & Management' },
|
||||||
|
{icon: 'UseOther', color: '#929299', textKey: 'Other' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function shouldShowOnboardingPage(userPrefsObs: Observable<UserPrefs>): boolean {
|
||||||
|
return Boolean(getGristConfig().survey && userPrefsObs.get()?.showNewUserQuestions);
|
||||||
|
}
|
||||||
|
|
||||||
|
type IncrementStep = (delta?: 1 | -1) => void;
|
||||||
|
|
||||||
|
interface Step {
|
||||||
|
state?: QuestionsState | VideoState;
|
||||||
|
buildDom(): DomContents;
|
||||||
|
onNavigateAway?(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuestionsState {
|
||||||
|
organization: Observable<string>;
|
||||||
|
role: Observable<string>;
|
||||||
|
useCases: Array<Observable<boolean>>;
|
||||||
|
useOther: Observable<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VideoState {
|
||||||
|
watched: Observable<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OnboardingPage extends Disposable {
|
||||||
|
private _steps: Array<Step>;
|
||||||
|
private _stepIndex: Observable<number> = Observable.create(this, 0);
|
||||||
|
|
||||||
|
constructor(private _appModel: AppModel) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.autoDispose(this._stepIndex.addListener((_, prevIndex) => {
|
||||||
|
this._steps[prevIndex].onNavigateAway?.();
|
||||||
|
}));
|
||||||
|
|
||||||
|
const incrementStep: IncrementStep = (delta: -1 | 1 = 1) => {
|
||||||
|
this._stepIndex.set(this._stepIndex.get() + delta);
|
||||||
|
};
|
||||||
|
|
||||||
|
this._steps = [
|
||||||
|
{
|
||||||
|
state: {
|
||||||
|
organization: Observable.create(this, ''),
|
||||||
|
role: Observable.create(this, ''),
|
||||||
|
useCases: choices.map(() => Observable.create(this, false)),
|
||||||
|
useOther: Observable.create(this, ''),
|
||||||
|
},
|
||||||
|
buildDom() { return dom.create(buildQuestions, incrementStep, this.state as QuestionsState); },
|
||||||
|
onNavigateAway() { saveQuestions(this.state as QuestionsState); },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
state: {
|
||||||
|
watched: Observable.create(this, false),
|
||||||
|
},
|
||||||
|
buildDom() { return dom.create(buildVideo, incrementStep, this.state as VideoState); },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
buildDom() { return dom.create(buildTutorial, incrementStep); },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
document.title = `Welcome${getPageTitleSuffix(getGristConfig())}`;
|
||||||
|
|
||||||
|
getUserPrefObs(this._appModel.userPrefsObs, 'showNewUserQuestions').set(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
public buildDom() {
|
||||||
|
return cssPageContainer(
|
||||||
|
cssOnboardingPage(
|
||||||
|
cssSidebar(
|
||||||
|
cssSidebarContent(
|
||||||
|
cssSidebarHeading1(t('Welcome')),
|
||||||
|
cssSidebarHeading2(this._appModel.currentUser!.name + '!'),
|
||||||
|
testId('sidebar'),
|
||||||
|
),
|
||||||
|
cssGetStarted(
|
||||||
|
cssGetStartedImg({src: 'img/get-started.png'}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
cssMainPanel(
|
||||||
|
buildStepper(this._steps, this._stepIndex),
|
||||||
|
dom.domComputed(this._stepIndex, index => {
|
||||||
|
return this._steps[index].buildDom();
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
testId('page'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStepper(steps: Step[], stepIndex: Observable<number>) {
|
||||||
|
return cssStepper(
|
||||||
|
steps.map((_, i) =>
|
||||||
|
cssStep(
|
||||||
|
cssStepCircle(
|
||||||
|
cssStepCircle.cls('-done', use => (i < use(stepIndex))),
|
||||||
|
dom.domComputed(use => i < use(stepIndex), (done) => done ? icon('Tick') : String(i + 1)),
|
||||||
|
cssStepCircle.cls('-current', use => (i === use(stepIndex))),
|
||||||
|
dom.on('click', () => { stepIndex.set(i); }),
|
||||||
|
testId(`step-${i + 1}`)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveQuestions(state: QuestionsState) {
|
||||||
|
const {organization, role, useCases, useOther} = state;
|
||||||
|
if (!organization.get() && !role.get() && !useCases.map(useCase => useCase.get()).includes(true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const org_name = organization.get();
|
||||||
|
const org_role = role.get();
|
||||||
|
const use_cases = choices.filter((c, i) => useCases[i].get()).map(c => c.textKey);
|
||||||
|
const use_other = use_cases.includes('Other') ? useOther.get() : '';
|
||||||
|
const submitUrl = new URL(window.location.href);
|
||||||
|
submitUrl.pathname = '/welcome/info';
|
||||||
|
BaseAPI.request(submitUrl.href, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({org_name, org_role, use_cases, use_other})
|
||||||
|
}).catch((e) => logError(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildQuestions(owner: IDisposableOwner, incrementStep: IncrementStep, state: QuestionsState) {
|
||||||
|
const {organization, role, useCases, useOther} = state;
|
||||||
|
const isFilled = Computed.create(owner, (use) => {
|
||||||
|
return Boolean(use(organization) || use(role) || useCases.map(useCase => use(useCase)).includes(true));
|
||||||
|
});
|
||||||
|
|
||||||
|
return cssQuestions(
|
||||||
|
cssHeading(t("Tell us who you are")),
|
||||||
|
cssQuestion(
|
||||||
|
cssFieldHeading(t('What organization are you with?')),
|
||||||
|
cssInput(
|
||||||
|
organization,
|
||||||
|
{type: 'text', placeholder: t('Your organization')},
|
||||||
|
testId('questions-organization'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
cssQuestion(
|
||||||
|
cssFieldHeading(t('What is your role?')),
|
||||||
|
cssInput(
|
||||||
|
role,
|
||||||
|
{type: 'text', placeholder: t('Your role')},
|
||||||
|
testId('questions-role'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
cssQuestion(
|
||||||
|
cssFieldHeading(t("What brings you to Grist (you can select multiple)?")),
|
||||||
|
cssUseCases(
|
||||||
|
choices.map((item, i) => cssUseCase(
|
||||||
|
cssUseCaseIcon(icon(item.icon)),
|
||||||
|
cssUseCase.cls('-selected', useCases[i]),
|
||||||
|
dom.on('click', () => useCases[i].set(!useCases[i].get())),
|
||||||
|
(item.icon !== 'UseOther' ?
|
||||||
|
t(item.textKey) :
|
||||||
|
[
|
||||||
|
cssOtherLabel(t(item.textKey)),
|
||||||
|
cssOtherInput(useOther, {}, {type: 'text', placeholder: t("Type here")},
|
||||||
|
// The following subscribes to changes to selection observable, and focuses the input when
|
||||||
|
// this item is selected.
|
||||||
|
(elem) => subscribeElem(elem, useCases[i], val => val && setTimeout(() => elem.focus(), 0)),
|
||||||
|
// It's annoying if clicking into the input toggles selection; better to turn that
|
||||||
|
// off (user can click icon to deselect).
|
||||||
|
dom.on('click', ev => ev.stopPropagation()),
|
||||||
|
// Similarly, ignore Enter/Escape in "Other" textbox, so that they don't submit/close the form.
|
||||||
|
dom.onKeyDown({
|
||||||
|
Enter: (ev, elem) => elem.blur(),
|
||||||
|
Escape: (ev, elem) => elem.blur(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
testId('questions-use-case'),
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
cssContinue(
|
||||||
|
bigPrimaryButton(
|
||||||
|
t('Next step'),
|
||||||
|
dom.show(isFilled),
|
||||||
|
dom.on('click', () => incrementStep()),
|
||||||
|
testId('next-step'),
|
||||||
|
),
|
||||||
|
bigBasicButton(
|
||||||
|
t('Skip step'),
|
||||||
|
dom.hide(isFilled),
|
||||||
|
dom.on('click', () => incrementStep()),
|
||||||
|
testId('skip-step'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
testId('questions'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildVideo(_owner: IDisposableOwner, incrementStep: IncrementStep, state: VideoState) {
|
||||||
|
const {watched} = state;
|
||||||
|
|
||||||
|
function onPlay() {
|
||||||
|
watched.set(true);
|
||||||
|
|
||||||
|
return modal((ctl, modalOwner) => {
|
||||||
|
const youtubePlayer = YouTubePlayer.create(modalOwner,
|
||||||
|
ONBOARDING_VIDEO_YOUTUBE_EMBED_ID,
|
||||||
|
{
|
||||||
|
onPlayerReady: (player) => player.playVideo(),
|
||||||
|
onPlayerStateChange(_player, {data}) {
|
||||||
|
if (data !== PlayerState.Ended) { return; }
|
||||||
|
|
||||||
|
ctl.close();
|
||||||
|
},
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
origin: getMainOrgUrl(),
|
||||||
|
},
|
||||||
|
cssYouTubePlayer.cls(''),
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
dom.on('click', () => ctl.close()),
|
||||||
|
elem => { FocusLayer.create(modalOwner, {defaultFocusElem: elem, pauseMousetrap: true}); },
|
||||||
|
dom.onKeyDown({
|
||||||
|
Escape: () => ctl.close(),
|
||||||
|
' ': () => youtubePlayer.playPause(),
|
||||||
|
}),
|
||||||
|
cssModalHeader(
|
||||||
|
cssModalCloseButton(
|
||||||
|
cssCloseIcon('CrossBig'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
cssModalBody(
|
||||||
|
cssVideoPlayer(
|
||||||
|
dom.on('click', (ev) => ev.stopPropagation()),
|
||||||
|
youtubePlayer.buildDom(),
|
||||||
|
testId('video-player'),
|
||||||
|
),
|
||||||
|
cssModalButtons(
|
||||||
|
bigPrimaryButton(
|
||||||
|
t('Next step'),
|
||||||
|
dom.on('click', (ev) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
ctl.close();
|
||||||
|
incrementStep();
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
cssVideoPlayerModal.cls(''),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return dom('div',
|
||||||
|
cssHeading(t('Discover Grist in 3 minutes')),
|
||||||
|
cssScreenshot(
|
||||||
|
dom.on('click', onPlay),
|
||||||
|
dom('div',
|
||||||
|
cssScreenshotImg({src: 'img/youtube-screenshot.png'}),
|
||||||
|
cssActionOverlay(
|
||||||
|
cssAction(
|
||||||
|
cssRoundButton(cssVideoPlayIcon('VideoPlay')),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
testId('video-thumbnail'),
|
||||||
|
),
|
||||||
|
cssContinue(
|
||||||
|
cssBackButton(
|
||||||
|
t('Back'),
|
||||||
|
dom.on('click', () => incrementStep(-1)),
|
||||||
|
testId('back'),
|
||||||
|
),
|
||||||
|
bigPrimaryButton(
|
||||||
|
t('Next step'),
|
||||||
|
dom.show(watched),
|
||||||
|
dom.on('click', () => incrementStep()),
|
||||||
|
testId('next-step'),
|
||||||
|
),
|
||||||
|
bigBasicButton(
|
||||||
|
t('Skip step'),
|
||||||
|
dom.hide(watched),
|
||||||
|
dom.on('click', () => incrementStep()),
|
||||||
|
testId('skip-step'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
testId('video'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTutorial(_owner: IDisposableOwner, incrementStep: IncrementStep) {
|
||||||
|
const {templateOrg, onboardingTutorialDocId} = getGristConfig();
|
||||||
|
return dom('div',
|
||||||
|
cssHeading(
|
||||||
|
t('Go hands-on with the Grist Basics tutorial'),
|
||||||
|
cssSubHeading(
|
||||||
|
t("Grist may look like a spreadsheet, but it doesn't always "
|
||||||
|
+ "act like one. Discover what makes Grist different."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
cssTutorial(
|
||||||
|
cssScreenshot(
|
||||||
|
dom.on('click', () => urlState().pushUrl({org: templateOrg!, doc: onboardingTutorialDocId})),
|
||||||
|
cssTutorialScreenshotImg({src: 'img/tutorial-screenshot.png'}),
|
||||||
|
cssTutorialOverlay(
|
||||||
|
cssAction(
|
||||||
|
cssTutorialButton(t('Go to the tutorial!')),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
testId('tutorial-thumbnail'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
cssContinue(
|
||||||
|
cssBackButton(
|
||||||
|
t('Back'),
|
||||||
|
dom.on('click', () => incrementStep(-1)),
|
||||||
|
testId('back'),
|
||||||
|
),
|
||||||
|
bigBasicButton(
|
||||||
|
t('Skip tutorial'),
|
||||||
|
dom.on('click', () => window.location.href = urlState().makeUrl(urlState().state.get())),
|
||||||
|
testId('skip-tutorial'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
testId('tutorial'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cssPageContainer = styled('div', `
|
||||||
|
overflow: auto;
|
||||||
|
height: 100%;
|
||||||
|
background-color: ${theme.mainPanelBg};
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssOnboardingPage = styled('div', `
|
||||||
|
display: flex;
|
||||||
|
min-height: 100%;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssSidebar = styled('div', `
|
||||||
|
width: 460px;
|
||||||
|
background-color: ${colors.lightGreen};
|
||||||
|
color: ${colors.light};
|
||||||
|
background-image:
|
||||||
|
linear-gradient(to bottom, rgb(41, 185, 131) 32px, transparent 32px),
|
||||||
|
linear-gradient(to right, rgb(41, 185, 131) 32px, transparent 32px);
|
||||||
|
background-size: 240px 120px;
|
||||||
|
background-position: 0 0, 40%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
@media ${mediaMedium} {
|
||||||
|
& {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssGetStarted = styled('div', `
|
||||||
|
width: 500px;
|
||||||
|
height: 350px;
|
||||||
|
margin: auto -77px 0 37px;
|
||||||
|
overflow: hidden;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssGetStartedImg = styled('img', `
|
||||||
|
display: block;
|
||||||
|
width: 500px;
|
||||||
|
height: auto;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssSidebarContent = styled('div', `
|
||||||
|
line-height: 32px;
|
||||||
|
margin: 112px 16px 64px 16px;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 48px;
|
||||||
|
font-weight: 500;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssSidebarHeading1 = styled('div', `
|
||||||
|
font-size: 32px;
|
||||||
|
text-align: center;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssSidebarHeading2 = styled('div', `
|
||||||
|
font-size: 28px;
|
||||||
|
text-align: center;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssMainPanel = styled('div', `
|
||||||
|
margin: 56px auto;
|
||||||
|
padding: 0px 96px;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
@media ${mediaMedium} {
|
||||||
|
& {
|
||||||
|
padding: 0px 32px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssHeading = styled('div', `
|
||||||
|
color: ${theme.text};
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 32px 0px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssSubHeading = styled(cssHeading, `
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 400;
|
||||||
|
margin-top: 16px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssStep = styled('div', `
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: default;
|
||||||
|
|
||||||
|
&:not(:last-child)::after {
|
||||||
|
content: "";
|
||||||
|
width: 50px;
|
||||||
|
height: 2px;
|
||||||
|
background-color: var(--grist-color-light-green);
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssStepCircle = styled('div', `
|
||||||
|
--icon-color: ${theme.controlPrimaryFg};
|
||||||
|
--step-color: ${theme.controlPrimaryBg};
|
||||||
|
display: inline-block;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 30px;
|
||||||
|
border: 1px solid var(--step-color);
|
||||||
|
color: var(--step-color);
|
||||||
|
margin: 4px;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
--step-color: ${theme.controlPrimaryHoverBg};
|
||||||
|
}
|
||||||
|
&-current {
|
||||||
|
background-color: var(--step-color);
|
||||||
|
color: ${theme.controlPrimaryFg};
|
||||||
|
outline: 3px solid ${theme.cursorInactive};
|
||||||
|
}
|
||||||
|
&-done {
|
||||||
|
background-color: var(--step-color);
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssQuestions = styled('div', `
|
||||||
|
max-width: 500px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssQuestion = styled('div', `
|
||||||
|
margin: 16px 0 8px 0;
|
||||||
|
text-align: left;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssFieldHeading = styled('div', `
|
||||||
|
color: ${theme.text};
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssContinue = styled('div', `
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 40px;
|
||||||
|
gap: 16px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssUseCases = styled('div', `
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
margin: -8px -4px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssUseCase = styled('div', `
|
||||||
|
flex: 1 0 40%;
|
||||||
|
min-width: 200px;
|
||||||
|
margin: 8px 4px 0 4px;
|
||||||
|
height: 40px;
|
||||||
|
border: 1px solid ${theme.inputBorder};
|
||||||
|
border-radius: 3px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
color: ${theme.text};
|
||||||
|
--icon-color: ${theme.accentIcon};
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: ${theme.hover};
|
||||||
|
}
|
||||||
|
&-selected {
|
||||||
|
border: 2px solid ${theme.controlFg};
|
||||||
|
}
|
||||||
|
&-selected:hover {
|
||||||
|
border: 2px solid ${theme.controlHoverFg};
|
||||||
|
}
|
||||||
|
&-selected:focus-within {
|
||||||
|
box-shadow: 0 0 2px 0px ${theme.controlFg};
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssUseCaseIcon = styled('div', `
|
||||||
|
margin: 0 16px;
|
||||||
|
--icon-color: ${theme.accentIcon};
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssOtherLabel = styled('div', `
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
.${cssUseCase.className}-selected & {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssInput = styled(textInput, `
|
||||||
|
height: 40px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssOtherInput = styled(input, `
|
||||||
|
color: ${theme.inputFg};
|
||||||
|
display: none;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
outline: none;
|
||||||
|
padding: 0px;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: ${theme.inputPlaceholderFg};
|
||||||
|
}
|
||||||
|
.${cssUseCase.className}-selected & {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssTutorial = styled('div', `
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssScreenshot = styled('div', `
|
||||||
|
max-width: 720px;
|
||||||
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 3px solid ${colors.lightGreen};
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssActionOverlay = styled('div', `
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.20);
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssTutorialOverlay = styled(cssActionOverlay, `
|
||||||
|
background-color: transparent;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssAction = styled('div', `
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin: auto;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssVideoPlayIcon = styled(icon, `
|
||||||
|
--icon-color: ${colors.light};
|
||||||
|
width: 38px;
|
||||||
|
height: 33.25px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssCloseIcon = styled(icon, `
|
||||||
|
--icon-color: ${colors.light};
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssYouTubePlayer = styled('iframe', `
|
||||||
|
border-radius: 4px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssModalHeader = styled('div', `
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
justify-content: flex-end;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssModalBody = styled('div', `
|
||||||
|
display: flex;
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssBackButton = styled(bigBasicButton, `
|
||||||
|
border: none;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssModalButtons = styled('div', `
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 24px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssVideoPlayer = styled('div', `
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1280px;
|
||||||
|
height: 100%;
|
||||||
|
max-height: 720px;
|
||||||
|
|
||||||
|
@media ${mediaXSmall} {
|
||||||
|
& {
|
||||||
|
max-height: 240px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssVideoPlayerModal = styled('div', `
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
background-color: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssModalCloseButton = styled('div', `
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: ${theme.hover};
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssScreenshotImg = styled('img', `
|
||||||
|
transform: scale(1.2);
|
||||||
|
width: 100%;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssTutorialScreenshotImg = styled('img', `
|
||||||
|
width: 100%;
|
||||||
|
opacity: 0.4;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssRoundButton = styled('div', `
|
||||||
|
width: 75px;
|
||||||
|
height: 75px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 100px;
|
||||||
|
background: ${colors.lightGreen};
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
--icon-color: var(--light, #FFF);
|
||||||
|
|
||||||
|
.${cssScreenshot.className}:hover & {
|
||||||
|
background: ${colors.darkGreen};
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssStepper = styled('div', `
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 20px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssTutorialButton = styled(bigPrimaryButtonLink, `
|
||||||
|
.${cssScreenshot.className}:hover & {
|
||||||
|
background-color: ${theme.controlPrimaryHoverBg};
|
||||||
|
border-color: ${theme.controlPrimaryHoverBg};
|
||||||
|
}
|
||||||
|
`);
|
@ -1,4 +1,3 @@
|
|||||||
import * as commands from 'app/client/components/commands';
|
|
||||||
import {makeT} from 'app/client/lib/localization';
|
import {makeT} from 'app/client/lib/localization';
|
||||||
import {logTelemetryEvent} from 'app/client/lib/telemetry';
|
import {logTelemetryEvent} from 'app/client/lib/telemetry';
|
||||||
import {getMainOrgUrl} from 'app/client/models/gristUrlState';
|
import {getMainOrgUrl} from 'app/client/models/gristUrlState';
|
||||||
@ -7,15 +6,13 @@ import {YouTubePlayer} from 'app/client/ui/YouTubePlayer';
|
|||||||
import {theme} from 'app/client/ui2018/cssVars';
|
import {theme} from 'app/client/ui2018/cssVars';
|
||||||
import {icon} from 'app/client/ui2018/icons';
|
import {icon} from 'app/client/ui2018/icons';
|
||||||
import {cssModalCloseButton, modal} from 'app/client/ui2018/modals';
|
import {cssModalCloseButton, modal} from 'app/client/ui2018/modals';
|
||||||
import {isFeatureEnabled} from 'app/common/gristUrls';
|
import {isFeatureEnabled, ONBOARDING_VIDEO_YOUTUBE_EMBED_ID} from 'app/common/gristUrls';
|
||||||
import {dom, makeTestId, styled} from 'grainjs';
|
import {dom, keyframes, makeTestId, styled} from 'grainjs';
|
||||||
|
|
||||||
const t = makeT('OpenVideoTour');
|
const t = makeT('OpenVideoTour');
|
||||||
|
|
||||||
const testId = makeTestId('test-video-tour-');
|
const testId = makeTestId('test-video-tour-');
|
||||||
|
|
||||||
const VIDEO_TOUR_YOUTUBE_EMBED_ID = '56AieR9rpww';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens a modal containing a video tour of Grist.
|
* Opens a modal containing a video tour of Grist.
|
||||||
*/
|
*/
|
||||||
@ -23,12 +20,15 @@ const VIDEO_TOUR_YOUTUBE_EMBED_ID = '56AieR9rpww';
|
|||||||
return modal(
|
return modal(
|
||||||
(ctl, owner) => {
|
(ctl, owner) => {
|
||||||
const youtubePlayer = YouTubePlayer.create(owner,
|
const youtubePlayer = YouTubePlayer.create(owner,
|
||||||
VIDEO_TOUR_YOUTUBE_EMBED_ID,
|
ONBOARDING_VIDEO_YOUTUBE_EMBED_ID,
|
||||||
{
|
{
|
||||||
onPlayerReady: (player) => player.playVideo(),
|
onPlayerReady: (player) => player.playVideo(),
|
||||||
height: '100%',
|
height: '100%',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
origin: getMainOrgUrl(),
|
origin: getMainOrgUrl(),
|
||||||
|
playerVars: {
|
||||||
|
rel: 0,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
cssYouTubePlayer.cls(''),
|
cssYouTubePlayer.cls(''),
|
||||||
);
|
);
|
||||||
@ -83,12 +83,7 @@ export function createVideoTourToolsButton(): HTMLDivElement | null {
|
|||||||
|
|
||||||
let iconElement: HTMLElement;
|
let iconElement: HTMLElement;
|
||||||
|
|
||||||
const commandsGroup = commands.createGroup({
|
|
||||||
videoTourToolsOpen: () => openVideoTour(iconElement),
|
|
||||||
}, null, true);
|
|
||||||
|
|
||||||
return cssPageEntryMain(
|
return cssPageEntryMain(
|
||||||
dom.autoDispose(commandsGroup),
|
|
||||||
cssPageLink(
|
cssPageLink(
|
||||||
iconElement = cssPageIcon('Video'),
|
iconElement = cssPageIcon('Video'),
|
||||||
cssLinkText(t("Video Tour")),
|
cssLinkText(t("Video Tour")),
|
||||||
@ -108,10 +103,19 @@ const cssModal = styled('div', `
|
|||||||
max-width: 864px;
|
max-width: 864px;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
const delayedVisibility = keyframes(`
|
||||||
|
to {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
const cssYouTubePlayerContainer = styled('div', `
|
const cssYouTubePlayerContainer = styled('div', `
|
||||||
position: relative;
|
position: relative;
|
||||||
padding-bottom: 56.25%;
|
padding-bottom: 56.25%;
|
||||||
height: 0;
|
height: 0;
|
||||||
|
/* Wait until the modal is finished animating. */
|
||||||
|
visibility: hidden;
|
||||||
|
animation: 0s linear 0.4s forwards ${delayedVisibility};
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const cssYouTubePlayer = styled('div', `
|
const cssYouTubePlayer = styled('div', `
|
||||||
|
@ -95,25 +95,46 @@ export interface IOptions extends ISelectOptions {
|
|||||||
placement?: Popper.Placement;
|
placement?: Popper.Placement;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ICompatibleTypes {
|
||||||
|
|
||||||
|
// true if "New Page" is selected in Page Picker
|
||||||
|
isNewPage: Boolean | undefined;
|
||||||
|
|
||||||
|
// true if can be summarized
|
||||||
|
summarize: Boolean;
|
||||||
|
}
|
||||||
|
|
||||||
const testId = makeTestId('test-wselect-');
|
const testId = makeTestId('test-wselect-');
|
||||||
|
|
||||||
// The picker disables some choices that do not make much sense. This function return the list of
|
// The picker disables some choices that do not make much sense. This function return the list of
|
||||||
// compatible types given the tableId and whether user is creating a new page or not.
|
// compatible types given the tableId and whether user is creating a new page or not.
|
||||||
function getCompatibleTypes(tableId: TableRef, isNewPage: boolean|undefined): IWidgetType[] {
|
function getCompatibleTypes(tableId: TableRef,
|
||||||
|
{isNewPage, summarize}: ICompatibleTypes): IWidgetType[] {
|
||||||
|
let compatibleTypes: Array<IWidgetType> = [];
|
||||||
if (tableId !== 'New Table') {
|
if (tableId !== 'New Table') {
|
||||||
return ['record', 'single', 'detail', 'chart', 'custom', 'custom.calendar', 'form'];
|
compatibleTypes = ['record', 'single', 'detail', 'chart', 'custom', 'custom.calendar', 'form'];
|
||||||
} else if (isNewPage) {
|
} else if (isNewPage) {
|
||||||
// New view + new table means we'll be switching to the primary view.
|
// New view + new table means we'll be switching to the primary view.
|
||||||
return ['record', 'form'];
|
compatibleTypes = ['record', 'form'];
|
||||||
} else {
|
} else {
|
||||||
// The type 'chart' makes little sense when creating a new table.
|
// The type 'chart' makes little sense when creating a new table.
|
||||||
return ['record', 'single', 'detail', 'form'];
|
compatibleTypes = ['record', 'single', 'detail', 'form'];
|
||||||
}
|
}
|
||||||
|
return summarize ? compatibleTypes.filter((el) => isSummaryCompatible(el)) : compatibleTypes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Picker disables some choices that do not make much sense.
|
||||||
|
// This function return a boolean telling if summary can be used with this type.
|
||||||
|
function isSummaryCompatible(widgetType: IWidgetType): boolean {
|
||||||
|
const incompatibleTypes: Array<IWidgetType> = ['form'];
|
||||||
|
return !incompatibleTypes.includes(widgetType);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whether table and type make for a valid selection whether the user is creating a new page or not.
|
// Whether table and type make for a valid selection whether the user is creating a new page or not.
|
||||||
function isValidSelection(table: TableRef, type: IWidgetType, isNewPage: boolean|undefined) {
|
function isValidSelection(table: TableRef,
|
||||||
return table !== null && getCompatibleTypes(table, isNewPage).includes(type);
|
type: IWidgetType,
|
||||||
|
{isNewPage, summarize}: ICompatibleTypes) {
|
||||||
|
return table !== null && getCompatibleTypes(table, {isNewPage, summarize}).includes(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ISaveFunc = (val: IPageWidget) => Promise<any>;
|
export type ISaveFunc = (val: IPageWidget) => Promise<any>;
|
||||||
@ -213,7 +234,13 @@ export function buildPageWidgetPicker(
|
|||||||
|
|
||||||
// whether the current selection is valid
|
// whether the current selection is valid
|
||||||
function isValid() {
|
function isValid() {
|
||||||
return isValidSelection(value.table.get(), value.type.get(), options.isNewPage);
|
return isValidSelection(
|
||||||
|
value.table.get(),
|
||||||
|
value.type.get(),
|
||||||
|
{
|
||||||
|
isNewPage: options.isNewPage,
|
||||||
|
summarize: value.summarize.get()
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Summarizing a table causes the 'Group By' panel to expand on the right. To prevent it from
|
// Summarizing a table causes the 'Group By' panel to expand on the right. To prevent it from
|
||||||
@ -299,7 +326,7 @@ export class PageWidgetSelect extends Disposable {
|
|||||||
null;
|
null;
|
||||||
|
|
||||||
private _isNewTableDisabled = Computed.create(this, this._value.type, (use, type) => !isValidSelection(
|
private _isNewTableDisabled = Computed.create(this, this._value.type, (use, type) => !isValidSelection(
|
||||||
'New Table', type, this._options.isNewPage));
|
'New Table', type, {isNewPage: this._options.isNewPage, summarize: use(this._value.summarize)}));
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private _value: IWidgetValueObs,
|
private _value: IWidgetValueObs,
|
||||||
@ -318,7 +345,9 @@ export class PageWidgetSelect extends Disposable {
|
|||||||
header(t("Select Widget")),
|
header(t("Select Widget")),
|
||||||
sectionTypes.map((value) => {
|
sectionTypes.map((value) => {
|
||||||
const widgetInfo = getWidgetTypes(value);
|
const widgetInfo = getWidgetTypes(value);
|
||||||
const disabled = computed(this._value.table, (use, tid) => this._isTypeDisabled(value, tid));
|
const disabled = computed(this._value.table,
|
||||||
|
(use, tid) => this._isTypeDisabled(value, tid, use(this._value.summarize))
|
||||||
|
);
|
||||||
return cssEntry(
|
return cssEntry(
|
||||||
dom.autoDispose(disabled),
|
dom.autoDispose(disabled),
|
||||||
cssTypeIcon(widgetInfo.icon),
|
cssTypeIcon(widgetInfo.icon),
|
||||||
@ -355,11 +384,14 @@ export class PageWidgetSelect extends Disposable {
|
|||||||
cssEntry.cls('-selected', (use) => use(this._value.table) === table.id()),
|
cssEntry.cls('-selected', (use) => use(this._value.table) === table.id()),
|
||||||
testId('table-label')
|
testId('table-label')
|
||||||
),
|
),
|
||||||
cssPivot(
|
cssPivot(
|
||||||
cssBigIcon('Pivot'),
|
cssBigIcon('Pivot'),
|
||||||
cssEntry.cls('-selected', (use) => use(this._value.summarize) && use(this._value.table) === table.id()),
|
cssEntry.cls('-selected', (use) => use(this._value.summarize) &&
|
||||||
dom.on('click', (ev, el) => this._selectPivot(table.id(), el as HTMLElement)),
|
use(this._value.table) === table.id()
|
||||||
testId('pivot'),
|
),
|
||||||
|
cssEntry.cls('-disabled', (use) => !isSummaryCompatible(use(this._value.type))),
|
||||||
|
dom.on('click', (ev, el) => this._selectPivot(table.id(), el as HTMLElement)),
|
||||||
|
testId('pivot'),
|
||||||
),
|
),
|
||||||
testId('table'),
|
testId('table'),
|
||||||
)
|
)
|
||||||
@ -410,7 +442,12 @@ export class PageWidgetSelect extends Disposable {
|
|||||||
// there are no changes.
|
// there are no changes.
|
||||||
this._options.buttonLabel || t("Add to Page"),
|
this._options.buttonLabel || t("Add to Page"),
|
||||||
dom.prop('disabled', (use) => !isValidSelection(
|
dom.prop('disabled', (use) => !isValidSelection(
|
||||||
use(this._value.table), use(this._value.type), this._options.isNewPage)
|
use(this._value.table),
|
||||||
|
use(this._value.type),
|
||||||
|
{
|
||||||
|
isNewPage: this._options.isNewPage,
|
||||||
|
summarize: use(this._value.summarize)
|
||||||
|
})
|
||||||
),
|
),
|
||||||
dom.on('click', () => this._onSave().catch(reportError)),
|
dom.on('click', () => this._onSave().catch(reportError)),
|
||||||
testId('addBtn'),
|
testId('addBtn'),
|
||||||
@ -464,11 +501,11 @@ export class PageWidgetSelect extends Disposable {
|
|||||||
this._value.columns.set(newIds);
|
this._value.columns.set(newIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _isTypeDisabled(type: IWidgetType, table: TableRef) {
|
private _isTypeDisabled(type: IWidgetType, table: TableRef, isSummaryOn: boolean) {
|
||||||
if (table === null) {
|
if (table === null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return !getCompatibleTypes(table, this._options.isNewPage).includes(type);
|
return !getCompatibleTypes(table, {isNewPage: this._options.isNewPage, summarize: isSummaryOn}).includes(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -535,6 +572,7 @@ const cssEntry = styled('div', `
|
|||||||
&-disabled {
|
&-disabled {
|
||||||
color: ${theme.widgetPickerItemDisabledBg};
|
color: ${theme.widgetPickerItemDisabledBg};
|
||||||
cursor: default;
|
cursor: default;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
&-disabled&-selected {
|
&-disabled&-selected {
|
||||||
background-color: inherit;
|
background-color: inherit;
|
||||||
@ -578,6 +616,10 @@ const cssBigIcon = styled(icon, `
|
|||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
background-color: ${theme.widgetPickerSummaryIcon};
|
background-color: ${theme.widgetPickerSummaryIcon};
|
||||||
|
.${cssEntry.className}-disabled > & {
|
||||||
|
opacity: 0.25;
|
||||||
|
filter: saturate(0);
|
||||||
|
}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const cssFooter = styled('div', `
|
const cssFooter = styled('div', `
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import {GristDoc} from "../components/GristDoc";
|
import {GristDoc} from 'app/client/components/GristDoc';
|
||||||
import {ViewSectionRec} from "../models/entities/ViewSectionRec";
|
import {ViewSectionRec} from 'app/client/models/entities/ViewSectionRec';
|
||||||
import {CustomSectionConfig} from "./CustomSectionConfig";
|
import {CustomSectionConfig} from 'app/client/ui/CustomSectionConfig';
|
||||||
|
import {ICustomWidget} from 'app/common/CustomWidget';
|
||||||
|
|
||||||
export class PredefinedCustomSectionConfig extends CustomSectionConfig {
|
export class PredefinedCustomSectionConfig extends CustomSectionConfig {
|
||||||
|
|
||||||
@ -17,7 +18,7 @@ export class PredefinedCustomSectionConfig extends CustomSectionConfig {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async _getWidgets(): Promise<void> {
|
protected async _getWidgets(): Promise<ICustomWidget[]> {
|
||||||
// Do nothing.
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -29,6 +29,7 @@ import {logTelemetryEvent} from 'app/client/lib/telemetry';
|
|||||||
import {reportError} from 'app/client/models/AppModel';
|
import {reportError} from 'app/client/models/AppModel';
|
||||||
import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel';
|
import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel';
|
||||||
import {CustomSectionConfig} from 'app/client/ui/CustomSectionConfig';
|
import {CustomSectionConfig} from 'app/client/ui/CustomSectionConfig';
|
||||||
|
import {showCustomWidgetGallery} from 'app/client/ui/CustomWidgetGallery';
|
||||||
import {buildDescriptionConfig} from 'app/client/ui/DescriptionConfig';
|
import {buildDescriptionConfig} from 'app/client/ui/DescriptionConfig';
|
||||||
import {BuildEditorOptions} from 'app/client/ui/FieldConfig';
|
import {BuildEditorOptions} from 'app/client/ui/FieldConfig';
|
||||||
import {GridOptions} from 'app/client/ui/GridOptions';
|
import {GridOptions} from 'app/client/ui/GridOptions';
|
||||||
@ -526,7 +527,7 @@ export class RightPanel extends Disposable {
|
|||||||
dom.maybe((use) => use(this._pageWidgetType) === 'custom', () => {
|
dom.maybe((use) => use(this._pageWidgetType) === 'custom', () => {
|
||||||
const parts = vct._buildCustomTypeItems() as any[];
|
const parts = vct._buildCustomTypeItems() as any[];
|
||||||
return [
|
return [
|
||||||
cssLabel(t("CUSTOM")),
|
cssSeparator(),
|
||||||
// If 'customViewPlugin' feature is on, show the toggle that allows switching to
|
// If 'customViewPlugin' feature is on, show the toggle that allows switching to
|
||||||
// plugin mode. Note that the default mode for a new 'custom' view is 'url', so that's
|
// plugin mode. Note that the default mode for a new 'custom' view is 'url', so that's
|
||||||
// the only one that will be shown without the feature flag.
|
// the only one that will be shown without the feature flag.
|
||||||
@ -880,13 +881,20 @@ export class RightPanel extends Disposable {
|
|||||||
|
|
||||||
private _createPageWidgetPicker(): DomElementMethod {
|
private _createPageWidgetPicker(): DomElementMethod {
|
||||||
const gristDoc = this._gristDoc;
|
const gristDoc = this._gristDoc;
|
||||||
const section = gristDoc.viewModel.activeSection;
|
const {activeSection} = gristDoc.viewModel;
|
||||||
const onSave = (val: IPageWidget) => gristDoc.saveViewSection(section.peek(), val);
|
const onSave = async (val: IPageWidget) => {
|
||||||
return (elem) => { attachPageWidgetPicker(elem, gristDoc, onSave, {
|
const {id} = await gristDoc.saveViewSection(activeSection.peek(), val);
|
||||||
buttonLabel: t("Save"),
|
if (val.type === 'custom') {
|
||||||
value: () => toPageWidget(section.peek()),
|
showCustomWidgetGallery(gristDoc, {sectionRef: id()});
|
||||||
selectBy: (val) => gristDoc.selectBy(val),
|
}
|
||||||
}); };
|
};
|
||||||
|
return (elem) => {
|
||||||
|
attachPageWidgetPicker(elem, gristDoc, onSave, {
|
||||||
|
buttonLabel: t("Save"),
|
||||||
|
value: () => toPageWidget(activeSection.peek()),
|
||||||
|
selectBy: (val) => gristDoc.selectBy(val),
|
||||||
|
});
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns dom for a section item.
|
// Returns dom for a section item.
|
||||||
|
@ -1,14 +1,24 @@
|
|||||||
import {makeT} from 'app/client/lib/localization';
|
import {makeT} from 'app/client/lib/localization';
|
||||||
import {AppModel} from 'app/client/models/AppModel';
|
import {AppModel} from 'app/client/models/AppModel';
|
||||||
import {TelemetryModel, TelemetryModelImpl} from 'app/client/models/TelemetryModel';
|
import {TelemetryModel, TelemetryModelImpl} from 'app/client/models/TelemetryModel';
|
||||||
import {basicButtonLink, bigBasicButton, bigBasicButtonLink, bigPrimaryButton} from 'app/client/ui2018/buttons';
|
import {
|
||||||
import {theme} from 'app/client/ui2018/cssVars';
|
cssButtonIconAndText,
|
||||||
|
cssButtonText,
|
||||||
|
cssOptInButton,
|
||||||
|
cssOptInOutMessage,
|
||||||
|
cssOptOutButton,
|
||||||
|
cssParagraph,
|
||||||
|
cssSection,
|
||||||
|
cssSpinnerBox,
|
||||||
|
cssSponsorButton,
|
||||||
|
} from 'app/client/ui/AdminTogglesCss';
|
||||||
|
import {basicButtonLink} from 'app/client/ui2018/buttons';
|
||||||
import {icon} from 'app/client/ui2018/icons';
|
import {icon} from 'app/client/ui2018/icons';
|
||||||
import {cssLink} from 'app/client/ui2018/links';
|
import {cssLink} from 'app/client/ui2018/links';
|
||||||
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
||||||
import {commonUrls} from 'app/common/gristUrls';
|
import {commonUrls} from 'app/common/gristUrls';
|
||||||
import {TelemetryPrefsWithSources} from 'app/common/InstallAPI';
|
import {TelemetryPrefsWithSources} from 'app/common/InstallAPI';
|
||||||
import {Computed, Disposable, dom, makeTestId, styled} from 'grainjs';
|
import {Computed, Disposable, dom, makeTestId} from 'grainjs';
|
||||||
|
|
||||||
const testId = makeTestId('test-support-grist-page-');
|
const testId = makeTestId('test-support-grist-page-');
|
||||||
|
|
||||||
@ -164,45 +174,3 @@ function gristCoreLink() {
|
|||||||
{href: commonUrls.githubGristCore, target: '_blank'},
|
{href: commonUrls.githubGristCore, target: '_blank'},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cssSection = styled('div', ``);
|
|
||||||
|
|
||||||
const cssParagraph = styled('div', `
|
|
||||||
color: ${theme.text};
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 20px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
`);
|
|
||||||
|
|
||||||
const cssOptInOutMessage = styled(cssParagraph, `
|
|
||||||
line-height: 40px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-top: 24px;
|
|
||||||
margin-bottom: 0px;
|
|
||||||
`);
|
|
||||||
|
|
||||||
const cssOptInButton = styled(bigPrimaryButton, `
|
|
||||||
margin-top: 24px;
|
|
||||||
`);
|
|
||||||
|
|
||||||
const cssOptOutButton = styled(bigBasicButton, `
|
|
||||||
margin-top: 24px;
|
|
||||||
`);
|
|
||||||
|
|
||||||
const cssSponsorButton = styled(bigBasicButtonLink, `
|
|
||||||
margin-top: 24px;
|
|
||||||
`);
|
|
||||||
|
|
||||||
const cssButtonIconAndText = styled('div', `
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
`);
|
|
||||||
|
|
||||||
const cssButtonText = styled('span', `
|
|
||||||
margin-left: 8px;
|
|
||||||
`);
|
|
||||||
|
|
||||||
const cssSpinnerBox = styled('div', `
|
|
||||||
margin-top: 24px;
|
|
||||||
text-align: center;
|
|
||||||
`);
|
|
||||||
|
78
app/client/ui/ToggleEnterpriseWidget.ts
Normal file
78
app/client/ui/ToggleEnterpriseWidget.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import {makeT} from 'app/client/lib/localization';
|
||||||
|
import {markdown} from 'app/client/lib/markdown';
|
||||||
|
import {Computed, Disposable, dom, makeTestId} from "grainjs";
|
||||||
|
import {commonUrls} from "app/common/gristUrls";
|
||||||
|
import {ToggleEnterpriseModel} from 'app/client/models/ToggleEnterpriseModel';
|
||||||
|
import {
|
||||||
|
cssOptInButton,
|
||||||
|
cssOptOutButton,
|
||||||
|
cssParagraph,
|
||||||
|
cssSection,
|
||||||
|
} from 'app/client/ui/AdminTogglesCss';
|
||||||
|
|
||||||
|
|
||||||
|
const t = makeT('ToggleEnterprsiePage');
|
||||||
|
const testId = makeTestId('test-toggle-enterprise-page-');
|
||||||
|
|
||||||
|
export class ToggleEnterpriseWidget extends Disposable {
|
||||||
|
private readonly _model: ToggleEnterpriseModel = new ToggleEnterpriseModel();
|
||||||
|
private readonly _isEnterprise = Computed.create(this, this._model.edition, (_use, edition) => {
|
||||||
|
return edition === 'enterprise';
|
||||||
|
}).onWrite(async (enabled) => {
|
||||||
|
await this._model.updateEnterpriseToggle(enabled ? 'enterprise' : 'core');
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this._model.fetchEnterpriseToggle().catch(reportError);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getEnterpriseToggleObservable() {
|
||||||
|
return this._isEnterprise;
|
||||||
|
}
|
||||||
|
|
||||||
|
public buildEnterpriseSection() {
|
||||||
|
return cssSection(
|
||||||
|
dom.domComputed(this._isEnterprise, (enterpriseEnabled) => {
|
||||||
|
return [
|
||||||
|
enterpriseEnabled ?
|
||||||
|
cssParagraph(
|
||||||
|
markdown(t('Grist Enterprise is **enabled**.')),
|
||||||
|
testId('enterprise-opt-out-message'),
|
||||||
|
) : null,
|
||||||
|
cssParagraph(
|
||||||
|
markdown(t(`An activation key is used to run Grist Enterprise after a trial period
|
||||||
|
of 30 days has expired. Get an activation key by [signing up for Grist
|
||||||
|
Enterprise]({{signupLink}}). You do not need an activation key to run
|
||||||
|
Grist Core.
|
||||||
|
|
||||||
|
Learn more in our [Help Center]({{helpCenter}}).`, {
|
||||||
|
signupLink: commonUrls.plans,
|
||||||
|
helpCenter: commonUrls.helpEnterpriseOptIn
|
||||||
|
}))
|
||||||
|
),
|
||||||
|
this._buildEnterpriseSectionButtons(),
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
testId('enterprise-opt-in-section'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public _buildEnterpriseSectionButtons() {
|
||||||
|
return dom.domComputed(this._isEnterprise, (enterpriseEnabled) => {
|
||||||
|
if (enterpriseEnabled) {
|
||||||
|
return [
|
||||||
|
cssOptOutButton(t('Disable Grist Enterprise'),
|
||||||
|
dom.on('click', () => this._isEnterprise.set(false)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
return [
|
||||||
|
cssOptInButton(t('Enable Grist Enterprise'),
|
||||||
|
dom.on('click', () => this._isEnterprise.set(true)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -1,217 +0,0 @@
|
|||||||
import {AppModel} from 'app/client/models/AppModel';
|
|
||||||
import {bigPrimaryButton} from 'app/client/ui2018/buttons';
|
|
||||||
import {isNarrowScreenObs, theme} from 'app/client/ui2018/cssVars';
|
|
||||||
import {icon} from 'app/client/ui2018/icons';
|
|
||||||
import {commonUrls, isFeatureEnabled} from 'app/common/gristUrls';
|
|
||||||
import {Computed, dom, IDisposableOwner, makeTestId, styled} from 'grainjs';
|
|
||||||
|
|
||||||
const testId = makeTestId('test-tutorial-card-');
|
|
||||||
|
|
||||||
interface Options {
|
|
||||||
app: AppModel,
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildTutorialCard(owner: IDisposableOwner, options: Options) {
|
|
||||||
if (!isFeatureEnabled('tutorials')) { return null; }
|
|
||||||
|
|
||||||
const {app} = options;
|
|
||||||
function onClose() {
|
|
||||||
app.dismissPopup('tutorialFirstCard', true);
|
|
||||||
}
|
|
||||||
const visible = Computed.create(owner, (use) =>
|
|
||||||
!use(app.dismissedPopups).includes('tutorialFirstCard')
|
|
||||||
&& !use(isNarrowScreenObs())
|
|
||||||
);
|
|
||||||
return dom.maybe(visible, () => {
|
|
||||||
return cssCard(
|
|
||||||
cssCaption(
|
|
||||||
dom('div', cssNewToGrist("New to Grist?")),
|
|
||||||
cssRelative(
|
|
||||||
cssStartHere("Start here."),
|
|
||||||
cssArrow()
|
|
||||||
),
|
|
||||||
),
|
|
||||||
cssContent(
|
|
||||||
testId('content'),
|
|
||||||
cssImage({src: commonUrls.basicTutorialImage}),
|
|
||||||
cssCardText(
|
|
||||||
cssLine(cssTitle("Grist Basics Tutorial")),
|
|
||||||
cssLine("Learn the basics of reference columns, linked widgets, column types, & cards."),
|
|
||||||
cssLine(cssSub('Beginner - 10 mins')),
|
|
||||||
cssButtonWrapper(
|
|
||||||
cssButtonWrapper.cls('-small'),
|
|
||||||
cssHeroButton("Start Tutorial"),
|
|
||||||
{href: commonUrls.basicTutorial, target: '_blank'},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
cssButtonWrapper(
|
|
||||||
cssButtonWrapper.cls('-big'),
|
|
||||||
cssHeroButton("Start Tutorial"),
|
|
||||||
{href: commonUrls.basicTutorial, target: '_blank'},
|
|
||||||
),
|
|
||||||
cssCloseButton(icon('CrossBig'), dom.on('click', () => onClose?.()), testId('close')),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const cssContent = styled('div', `
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
padding-top: 24px;
|
|
||||||
padding-bottom: 20px;
|
|
||||||
padding-right: 20px;
|
|
||||||
max-width: 460px;
|
|
||||||
`);
|
|
||||||
|
|
||||||
const cssCardText = styled('div', `
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-self: stretch;
|
|
||||||
margin-left: 12px;
|
|
||||||
`);
|
|
||||||
|
|
||||||
const cssRelative = styled('div', `
|
|
||||||
position: relative;
|
|
||||||
`);
|
|
||||||
|
|
||||||
const cssNewToGrist = styled('span', `
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-size: 24px;
|
|
||||||
line-height: 16px;
|
|
||||||
letter-spacing: 0.2px;
|
|
||||||
white-space: nowrap;
|
|
||||||
`);
|
|
||||||
|
|
||||||
const cssStartHere = styled('span', `
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 24px;
|
|
||||||
line-height: 16px;
|
|
||||||
letter-spacing: 0.2px;
|
|
||||||
white-space: nowrap;
|
|
||||||
`);
|
|
||||||
|
|
||||||
const cssCaption = styled('div', `
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
margin-left: 32px;
|
|
||||||
margin-top: 42px;
|
|
||||||
margin-right: 64px;
|
|
||||||
`);
|
|
||||||
|
|
||||||
const cssTitle = styled('span', `
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 20px;
|
|
||||||
`);
|
|
||||||
|
|
||||||
const cssSub = styled('span', `
|
|
||||||
font-size: 12px;
|
|
||||||
color: ${theme.lightText};
|
|
||||||
`);
|
|
||||||
|
|
||||||
const cssLine = styled('div', `
|
|
||||||
margin-bottom: 6px;
|
|
||||||
`);
|
|
||||||
|
|
||||||
const cssHeroButton = styled(bigPrimaryButton, `
|
|
||||||
`);
|
|
||||||
|
|
||||||
const cssButtonWrapper = styled('a', `
|
|
||||||
flex-grow: 1;
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
margin-right: 60px;
|
|
||||||
align-items: center;
|
|
||||||
text-decoration: none;
|
|
||||||
&:hover {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
&-big .${cssHeroButton.className} {
|
|
||||||
padding: 16px 28px;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 20px;
|
|
||||||
line-height: 1em;
|
|
||||||
}
|
|
||||||
`);
|
|
||||||
|
|
||||||
const cssCloseButton = styled('div', `
|
|
||||||
flex-shrink: 0;
|
|
||||||
align-self: flex-end;
|
|
||||||
cursor: pointer;
|
|
||||||
--icon-color: ${theme.controlSecondaryFg};
|
|
||||||
margin: 8px 8px 4px 0px;
|
|
||||||
padding: 2px;
|
|
||||||
border-radius: 4px;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
&:hover {
|
|
||||||
background-color: ${theme.lightHover};
|
|
||||||
}
|
|
||||||
&:active {
|
|
||||||
background-color: ${theme.hover};
|
|
||||||
}
|
|
||||||
`);
|
|
||||||
|
|
||||||
const cssImage = styled('img', `
|
|
||||||
width: 187px;
|
|
||||||
height: 145px;
|
|
||||||
flex: none;
|
|
||||||
`);
|
|
||||||
|
|
||||||
const cssArrow = styled('div', `
|
|
||||||
position: absolute;
|
|
||||||
background-image: var(--icon-GreenArrow);
|
|
||||||
width: 94px;
|
|
||||||
height: 12px;
|
|
||||||
top: calc(50% - 6px);
|
|
||||||
left: calc(100% - 12px);
|
|
||||||
z-index: 1;
|
|
||||||
`);
|
|
||||||
|
|
||||||
|
|
||||||
const cssCard = styled('div', `
|
|
||||||
display: flex;
|
|
||||||
position: relative;
|
|
||||||
color: ${theme.text};
|
|
||||||
border-radius: 3px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
max-width: 1000px;
|
|
||||||
box-shadow: 0 2px 18px 0 ${theme.modalInnerShadow}, 0 0 1px 0 ${theme.modalOuterShadow};
|
|
||||||
& .${cssButtonWrapper.className}-small {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
@media (max-width: 1320px) {
|
|
||||||
& .${cssButtonWrapper.className}-small {
|
|
||||||
flex-direction: column;
|
|
||||||
display: flex;
|
|
||||||
margin-top: 14px;
|
|
||||||
align-self: flex-start;
|
|
||||||
}
|
|
||||||
& .${cssButtonWrapper.className}-big {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@media (max-width: 1000px) {
|
|
||||||
& .${cssArrow.className} {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
& .${cssCaption.className} {
|
|
||||||
flex-direction: row;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
& {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
& .${cssContent.className} {
|
|
||||||
padding: 12px;
|
|
||||||
max-width: 100%;
|
|
||||||
margin-bottom: 28px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`);
|
|
@ -6,8 +6,9 @@
|
|||||||
* It can be instantiated by calling showUserManagerModal with the UserAPI and IUserManagerOptions.
|
* It can be instantiated by calling showUserManagerModal with the UserAPI and IUserManagerOptions.
|
||||||
*/
|
*/
|
||||||
import { makeT } from 'app/client/lib/localization';
|
import { makeT } from 'app/client/lib/localization';
|
||||||
import {commonUrls} from 'app/common/gristUrls';
|
import {commonUrls, isOrgInPathOnly} from 'app/common/gristUrls';
|
||||||
import {capitalizeFirstWord, isLongerThan} from 'app/common/gutil';
|
import {capitalizeFirstWord, isLongerThan} from 'app/common/gutil';
|
||||||
|
import {getGristConfig} from 'app/common/urlUtils';
|
||||||
import {FullUser} from 'app/common/LoginSessionAPI';
|
import {FullUser} from 'app/common/LoginSessionAPI';
|
||||||
import * as roles from 'app/common/roles';
|
import * as roles from 'app/common/roles';
|
||||||
import {Organization, PermissionData, UserAPI} from 'app/common/UserAPI';
|
import {Organization, PermissionData, UserAPI} from 'app/common/UserAPI';
|
||||||
@ -816,15 +817,25 @@ const cssMemberPublicAccess = styled(cssMemberSecondary, `
|
|||||||
function renderTitle(resourceType: ResourceType, resource?: Resource, personal?: boolean) {
|
function renderTitle(resourceType: ResourceType, resource?: Resource, personal?: boolean) {
|
||||||
switch (resourceType) {
|
switch (resourceType) {
|
||||||
case 'organization': {
|
case 'organization': {
|
||||||
if (personal) { return t('Your role for this team site'); }
|
if (personal) {
|
||||||
return [
|
return t('Your role for this team site');
|
||||||
t('Manage members of team site'),
|
}
|
||||||
!resource ? null : cssOrgName(
|
|
||||||
`${(resource as Organization).name} (`,
|
function getOrgDisplay() {
|
||||||
cssOrgDomain(`${(resource as Organization).domain}.getgrist.com`),
|
if (!resource) {
|
||||||
')',
|
return null;
|
||||||
)
|
}
|
||||||
];
|
|
||||||
|
const org = resource as Organization;
|
||||||
|
const gristConfig = getGristConfig();
|
||||||
|
const gristHomeHost = gristConfig.homeUrl ? new URL(gristConfig.homeUrl).host : '';
|
||||||
|
const baseDomain = gristConfig.baseDomain || gristHomeHost;
|
||||||
|
const orgDisplay = isOrgInPathOnly() ? `${baseDomain}/o/${org.domain}` : `${org.domain}${baseDomain}`;
|
||||||
|
|
||||||
|
return cssOrgName(`${org.name} (`, cssOrgDomain(orgDisplay), ')');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [t('Manage members of team site'), getOrgDisplay()];
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
return personal ?
|
return personal ?
|
||||||
|
@ -107,6 +107,12 @@ const WEBHOOK_COLUMNS = [
|
|||||||
type: 'Text',
|
type: 'Text',
|
||||||
label: t('Status'),
|
label: t('Status'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: VirtualId(),
|
||||||
|
colId: 'authorization',
|
||||||
|
type: 'Text',
|
||||||
|
label: t('Header Authorization'),
|
||||||
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -114,10 +120,11 @@ const WEBHOOK_COLUMNS = [
|
|||||||
*/
|
*/
|
||||||
const WEBHOOK_VIEW_FIELDS: Array<(typeof WEBHOOK_COLUMNS)[number]['colId']> = [
|
const WEBHOOK_VIEW_FIELDS: Array<(typeof WEBHOOK_COLUMNS)[number]['colId']> = [
|
||||||
'name', 'memo',
|
'name', 'memo',
|
||||||
'eventTypes', 'url',
|
'eventTypes', 'tableId',
|
||||||
'tableId', 'isReadyColumn',
|
'watchedColIdsText', 'isReadyColumn',
|
||||||
'watchedColIdsText', 'webhookId',
|
'url', 'authorization',
|
||||||
'enabled', 'status'
|
'webhookId', 'enabled',
|
||||||
|
'status'
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -136,7 +143,7 @@ class WebhookExternalTable implements IExternalTable {
|
|||||||
public name = 'GristHidden_WebhookTable';
|
public name = 'GristHidden_WebhookTable';
|
||||||
public initialActions = _prepareWebhookInitialActions(this.name);
|
public initialActions = _prepareWebhookInitialActions(this.name);
|
||||||
public saveableFields = [
|
public saveableFields = [
|
||||||
'tableId', 'watchedColIdsText', 'url', 'eventTypes', 'enabled', 'name', 'memo', 'isReadyColumn',
|
'tableId', 'watchedColIdsText', 'url', 'authorization', 'eventTypes', 'enabled', 'name', 'memo', 'isReadyColumn',
|
||||||
];
|
];
|
||||||
public webhooks: ObservableArray<UIWebhookSummary> = observableArray<UIWebhookSummary>([]);
|
public webhooks: ObservableArray<UIWebhookSummary> = observableArray<UIWebhookSummary>([]);
|
||||||
|
|
||||||
|
@ -1,176 +0,0 @@
|
|||||||
import {makeT} from 'app/client/lib/localization';
|
|
||||||
import * as commands from 'app/client/components/commands';
|
|
||||||
import {getUserPrefObs} from 'app/client/models/UserPrefs';
|
|
||||||
import {colors, testId} from 'app/client/ui2018/cssVars';
|
|
||||||
import {icon} from 'app/client/ui2018/icons';
|
|
||||||
import {IconName} from 'app/client/ui2018/IconList';
|
|
||||||
import {ISaveModalOptions, saveModal} from 'app/client/ui2018/modals';
|
|
||||||
import {BaseAPI} from 'app/common/BaseAPI';
|
|
||||||
import {UserPrefs} from 'app/common/Prefs';
|
|
||||||
import {getGristConfig} from 'app/common/urlUtils';
|
|
||||||
import {dom, input, Observable, styled, subscribeElem} from 'grainjs';
|
|
||||||
|
|
||||||
const t = makeT('WelcomeQuestions');
|
|
||||||
|
|
||||||
export function shouldShowWelcomeQuestions(userPrefsObs: Observable<UserPrefs>): boolean {
|
|
||||||
return Boolean(getGristConfig().survey && userPrefsObs.get()?.showNewUserQuestions);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shows a modal with welcome questions if surveying is enabled and the user hasn't
|
|
||||||
* dismissed the modal before.
|
|
||||||
*/
|
|
||||||
export function showWelcomeQuestions(userPrefsObs: Observable<UserPrefs>) {
|
|
||||||
saveModal((ctl, owner): ISaveModalOptions => {
|
|
||||||
const selection = choices.map(c => Observable.create(owner, false));
|
|
||||||
const otherText = Observable.create(owner, '');
|
|
||||||
const showQuestions = getUserPrefObs(userPrefsObs, 'showNewUserQuestions');
|
|
||||||
|
|
||||||
async function onConfirm() {
|
|
||||||
const use_cases = choices.filter((c, i) => selection[i].get()).map(c => c.textKey);
|
|
||||||
const use_other = use_cases.includes("Other") ? otherText.get() : '';
|
|
||||||
|
|
||||||
const submitUrl = new URL(window.location.href);
|
|
||||||
submitUrl.pathname = '/welcome/info';
|
|
||||||
return BaseAPI.request(submitUrl.href,
|
|
||||||
{method: 'POST', body: JSON.stringify({use_cases, use_other})});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
owner.onDispose(async () => {
|
|
||||||
// Whichever way the modal is closed, don't show the questions again. (We set the value to
|
|
||||||
// undefined to remove it from the JSON prefs object entirely; it's never used again.)
|
|
||||||
showQuestions.set(undefined);
|
|
||||||
|
|
||||||
// Show the Grist video tour when the modal is closed.
|
|
||||||
await commands.allCommands.leftPanelOpen.run();
|
|
||||||
commands.allCommands.videoTourToolsOpen.run();
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: [cssLogo(), dom('div', t("Welcome to Grist!"))],
|
|
||||||
body: buildInfoForm(selection, otherText),
|
|
||||||
saveLabel: 'Start using Grist',
|
|
||||||
saveFunc: onConfirm,
|
|
||||||
hideCancel: true,
|
|
||||||
width: 'fixed-wide',
|
|
||||||
modalArgs: cssModalCentered.cls(''),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const choices: Array<{icon: IconName, color: string, textKey: string}> = [
|
|
||||||
{icon: 'UseProduct', color: `${colors.lightGreen}`, textKey: 'Product Development' },
|
|
||||||
{icon: 'UseFinance', color: '#0075A2', textKey: 'Finance & Accounting' },
|
|
||||||
{icon: 'UseMedia', color: '#F7B32B', textKey: 'Media Production' },
|
|
||||||
{icon: 'UseMonitor', color: '#F2545B', textKey: 'IT & Technology' },
|
|
||||||
{icon: 'UseChart', color: '#7141F9', textKey: 'Marketing' },
|
|
||||||
{icon: 'UseScience', color: '#231942', textKey: 'Research' },
|
|
||||||
{icon: 'UseSales', color: '#885A5A', textKey: 'Sales' },
|
|
||||||
{icon: 'UseEducate', color: '#4A5899', textKey: 'Education' },
|
|
||||||
{icon: 'UseHr', color: '#688047', textKey: 'HR & Management' },
|
|
||||||
{icon: 'UseOther', color: '#929299', textKey: 'Other' },
|
|
||||||
];
|
|
||||||
|
|
||||||
function buildInfoForm(selection: Observable<boolean>[], otherText: Observable<string>) {
|
|
||||||
return [
|
|
||||||
dom('span', t("What brings you to Grist? Please help us serve you better.")),
|
|
||||||
cssChoices(
|
|
||||||
choices.map((item, i) => cssChoice(
|
|
||||||
cssIcon(icon(item.icon), {style: `--icon-color: ${item.color}`}),
|
|
||||||
cssChoice.cls('-selected', selection[i]),
|
|
||||||
dom.on('click', () => selection[i].set(!selection[i].get())),
|
|
||||||
(item.icon !== 'UseOther' ?
|
|
||||||
t(item.textKey) :
|
|
||||||
[
|
|
||||||
cssOtherLabel(t(item.textKey)),
|
|
||||||
cssOtherInput(otherText, {}, {type: 'text', placeholder: t("Type here")},
|
|
||||||
// The following subscribes to changes to selection observable, and focuses the input when
|
|
||||||
// this item is selected.
|
|
||||||
(elem) => subscribeElem(elem, selection[i], val => val && setTimeout(() => elem.focus(), 0)),
|
|
||||||
// It's annoying if clicking into the input toggles selection; better to turn that
|
|
||||||
// off (user can click icon to deselect).
|
|
||||||
dom.on('click', ev => ev.stopPropagation()),
|
|
||||||
// Similarly, ignore Enter/Escape in "Other" textbox, so that they don't submit/close the form.
|
|
||||||
dom.onKeyDown({
|
|
||||||
Enter: (ev, elem) => elem.blur(),
|
|
||||||
Escape: (ev, elem) => elem.blur(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
)),
|
|
||||||
testId('welcome-questions'),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
const cssModalCentered = styled('div', `
|
|
||||||
text-align: center;
|
|
||||||
`);
|
|
||||||
|
|
||||||
const cssLogo = styled('div', `
|
|
||||||
display: inline-block;
|
|
||||||
height: 48px;
|
|
||||||
width: 48px;
|
|
||||||
background-image: var(--icon-GristLogo);
|
|
||||||
background-size: 32px 32px;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-position: center;
|
|
||||||
`);
|
|
||||||
|
|
||||||
const cssChoices = styled('div', `
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
margin-top: 24px;
|
|
||||||
`);
|
|
||||||
|
|
||||||
const cssChoice = styled('div', `
|
|
||||||
flex: 1 0 40%;
|
|
||||||
min-width: 0px;
|
|
||||||
margin: 8px 4px 0 4px;
|
|
||||||
height: 40px;
|
|
||||||
border: 1px solid ${colors.darkGrey};
|
|
||||||
border-radius: 3px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: ${colors.lightGreen};
|
|
||||||
}
|
|
||||||
&-selected {
|
|
||||||
background-color: ${colors.mediumGrey};
|
|
||||||
}
|
|
||||||
&-selected:hover {
|
|
||||||
border-color: ${colors.darkGreen};
|
|
||||||
}
|
|
||||||
&-selected:focus-within {
|
|
||||||
box-shadow: 0 0 2px 0px var(--grist-color-cursor);
|
|
||||||
border-color: ${colors.lightGreen};
|
|
||||||
}
|
|
||||||
`);
|
|
||||||
|
|
||||||
const cssIcon = styled('div', `
|
|
||||||
margin: 0 16px;
|
|
||||||
`);
|
|
||||||
|
|
||||||
const cssOtherLabel = styled('div', `
|
|
||||||
display: block;
|
|
||||||
.${cssChoice.className}-selected & {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
`);
|
|
||||||
|
|
||||||
const cssOtherInput = styled(input, `
|
|
||||||
display: none;
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
outline: none;
|
|
||||||
padding: 0px;
|
|
||||||
.${cssChoice.className}-selected & {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
`);
|
|
@ -11,6 +11,7 @@ export interface Player {
|
|||||||
unMute(): void;
|
unMute(): void;
|
||||||
setVolume(volume: number): void;
|
setVolume(volume: number): void;
|
||||||
getCurrentTime(): number;
|
getCurrentTime(): number;
|
||||||
|
getPlayerState(): PlayerState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlayerOptions {
|
export interface PlayerOptions {
|
||||||
@ -28,6 +29,7 @@ export interface PlayerVars {
|
|||||||
fs?: 0 | 1;
|
fs?: 0 | 1;
|
||||||
iv_load_policy?: 1 | 3;
|
iv_load_policy?: 1 | 3;
|
||||||
modestbranding?: 0 | 1;
|
modestbranding?: 0 | 1;
|
||||||
|
rel?: 0 | 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlayerStateChangeEvent {
|
export interface PlayerStateChangeEvent {
|
||||||
@ -93,6 +95,18 @@ export class YouTubePlayer extends Disposable {
|
|||||||
this._player.playVideo();
|
this._player.playVideo();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public pause() {
|
||||||
|
this._player.pauseVideo();
|
||||||
|
}
|
||||||
|
|
||||||
|
public playPause() {
|
||||||
|
if (this._player.getPlayerState() === PlayerState.Playing) {
|
||||||
|
this._player.pauseVideo();
|
||||||
|
} else {
|
||||||
|
this._player.playVideo();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public setVolume(volume: number) {
|
public setVolume(volume: number) {
|
||||||
this._player.setVolume(volume);
|
this._player.setVolume(volume);
|
||||||
}
|
}
|
||||||
|
@ -15,12 +15,19 @@ const testId = makeTestId('test-');
|
|||||||
|
|
||||||
const t = makeT('errorPages');
|
const t = makeT('errorPages');
|
||||||
|
|
||||||
|
function signInAgainButton() {
|
||||||
|
return cssButtonWrap(bigPrimaryButtonLink(
|
||||||
|
t("Sign in again"), {href: getLoginUrl()}, testId('error-signin')
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
export function createErrPage(appModel: AppModel) {
|
export function createErrPage(appModel: AppModel) {
|
||||||
const {errMessage, errPage} = getGristConfig();
|
const {errMessage, errPage} = getGristConfig();
|
||||||
return errPage === 'signed-out' ? createSignedOutPage(appModel) :
|
return errPage === 'signed-out' ? createSignedOutPage(appModel) :
|
||||||
errPage === 'not-found' ? createNotFoundPage(appModel, errMessage) :
|
errPage === 'not-found' ? createNotFoundPage(appModel, errMessage) :
|
||||||
errPage === 'access-denied' ? createForbiddenPage(appModel, errMessage) :
|
errPage === 'access-denied' ? createForbiddenPage(appModel, errMessage) :
|
||||||
errPage === 'account-deleted' ? createAccountDeletedPage(appModel) :
|
errPage === 'account-deleted' ? createAccountDeletedPage(appModel) :
|
||||||
|
errPage === 'signin-failed' ? createSigninFailedPage(appModel, errMessage) :
|
||||||
createOtherErrorPage(appModel, errMessage);
|
createOtherErrorPage(appModel, errMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,9 +68,7 @@ export function createSignedOutPage(appModel: AppModel) {
|
|||||||
|
|
||||||
return pagePanelsError(appModel, t("Signed out{{suffix}}", {suffix: ''}), [
|
return pagePanelsError(appModel, t("Signed out{{suffix}}", {suffix: ''}), [
|
||||||
cssErrorText(t("You are now signed out.")),
|
cssErrorText(t("You are now signed out.")),
|
||||||
cssButtonWrap(bigPrimaryButtonLink(
|
signInAgainButton(),
|
||||||
t("Sign in again"), {href: getLoginUrl()}, testId('error-signin')
|
|
||||||
))
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -98,6 +103,18 @@ export function createNotFoundPage(appModel: AppModel, message?: string) {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createSigninFailedPage(appModel: AppModel, message?: string) {
|
||||||
|
document.title = t("Sign-in failed{{suffix}}", {suffix: getPageTitleSuffix(getGristConfig())});
|
||||||
|
return pagePanelsError(appModel, t("Sign-in failed{{suffix}}", {suffix: ''}), [
|
||||||
|
cssErrorText(message ??
|
||||||
|
t("Failed to log in.{{separator}}Please try again or contact support.", {
|
||||||
|
separator: dom('br')
|
||||||
|
})),
|
||||||
|
signInAgainButton(),
|
||||||
|
cssButtonWrap(bigBasicButtonLink(t("Contact support"), {href: commonUrls.contactSupport})),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a generic error page with the given message.
|
* Creates a generic error page with the given message.
|
||||||
*/
|
*/
|
||||||
|
@ -38,7 +38,8 @@ function isAtScrollTop(elem: Element): boolean {
|
|||||||
// Indicates that an element is currently scrolled such that the bottom of the element is visible.
|
// Indicates that an element is currently scrolled such that the bottom of the element is visible.
|
||||||
// It is expected that the elem arg has the offsetHeight property set.
|
// It is expected that the elem arg has the offsetHeight property set.
|
||||||
function isAtScrollBtm(elem: HTMLElement): boolean {
|
function isAtScrollBtm(elem: HTMLElement): boolean {
|
||||||
return elem.scrollTop >= (elem.scrollHeight - elem.offsetHeight);
|
// Check we're within a threshold of 1 pixel, to account for possible rounding.
|
||||||
|
return (elem.scrollHeight - elem.offsetHeight - elem.scrollTop) < 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cssScrollMenu = styled('div', `
|
const cssScrollMenu = styled('div', `
|
||||||
|
@ -123,6 +123,7 @@ export type IconName = "ChartArea" |
|
|||||||
"Public" |
|
"Public" |
|
||||||
"PublicColor" |
|
"PublicColor" |
|
||||||
"PublicFilled" |
|
"PublicFilled" |
|
||||||
|
"Question" |
|
||||||
"Redo" |
|
"Redo" |
|
||||||
"Remove" |
|
"Remove" |
|
||||||
"RemoveBig" |
|
"RemoveBig" |
|
||||||
@ -137,13 +138,17 @@ export type IconName = "ChartArea" |
|
|||||||
"Separator" |
|
"Separator" |
|
||||||
"Settings" |
|
"Settings" |
|
||||||
"Share" |
|
"Share" |
|
||||||
|
"Skip" |
|
||||||
"Sort" |
|
"Sort" |
|
||||||
"Sparks" |
|
"Sparks" |
|
||||||
|
"Star" |
|
||||||
"Tick" |
|
"Tick" |
|
||||||
"TickSolid" |
|
"TickSolid" |
|
||||||
"Undo" |
|
"Undo" |
|
||||||
"Validation" |
|
"Validation" |
|
||||||
"Video" |
|
"Video" |
|
||||||
|
"VideoPlay" |
|
||||||
|
"VideoPlay2" |
|
||||||
"Warning" |
|
"Warning" |
|
||||||
"Widget" |
|
"Widget" |
|
||||||
"Wrap" |
|
"Wrap" |
|
||||||
@ -284,6 +289,7 @@ export const IconList: IconName[] = ["ChartArea",
|
|||||||
"Public",
|
"Public",
|
||||||
"PublicColor",
|
"PublicColor",
|
||||||
"PublicFilled",
|
"PublicFilled",
|
||||||
|
"Question",
|
||||||
"Redo",
|
"Redo",
|
||||||
"Remove",
|
"Remove",
|
||||||
"RemoveBig",
|
"RemoveBig",
|
||||||
@ -298,13 +304,17 @@ export const IconList: IconName[] = ["ChartArea",
|
|||||||
"Separator",
|
"Separator",
|
||||||
"Settings",
|
"Settings",
|
||||||
"Share",
|
"Share",
|
||||||
|
"Skip",
|
||||||
"Sort",
|
"Sort",
|
||||||
"Sparks",
|
"Sparks",
|
||||||
|
"Star",
|
||||||
"Tick",
|
"Tick",
|
||||||
"TickSolid",
|
"TickSolid",
|
||||||
"Undo",
|
"Undo",
|
||||||
"Validation",
|
"Validation",
|
||||||
"Video",
|
"Video",
|
||||||
|
"VideoPlay",
|
||||||
|
"VideoPlay2",
|
||||||
"Warning",
|
"Warning",
|
||||||
"Widget",
|
"Widget",
|
||||||
"Wrap",
|
"Wrap",
|
||||||
|
@ -471,6 +471,10 @@ export const theme = {
|
|||||||
undefined, colors.mediumGreyOpaque),
|
undefined, colors.mediumGreyOpaque),
|
||||||
rightPanelFieldSettingsButtonBg: new CustomProp('theme-right-panel-field-settings-button-bg',
|
rightPanelFieldSettingsButtonBg: new CustomProp('theme-right-panel-field-settings-button-bg',
|
||||||
undefined, 'lightgrey'),
|
undefined, 'lightgrey'),
|
||||||
|
rightPanelCustomWidgetButtonFg: new CustomProp('theme-right-panel-custom-widget-button-fg',
|
||||||
|
undefined, colors.dark),
|
||||||
|
rightPanelCustomWidgetButtonBg: new CustomProp('theme-right-panel-custom-widget-button-bg',
|
||||||
|
undefined, colors.darkGrey),
|
||||||
|
|
||||||
/* Document History */
|
/* Document History */
|
||||||
documentHistorySnapshotFg: new CustomProp('theme-document-history-snapshot-fg', undefined,
|
documentHistorySnapshotFg: new CustomProp('theme-document-history-snapshot-fg', undefined,
|
||||||
@ -877,6 +881,20 @@ export const theme = {
|
|||||||
|
|
||||||
/* Numeric Spinners */
|
/* Numeric Spinners */
|
||||||
numericSpinnerFg: new CustomProp('theme-numeric-spinner-fg', undefined, '#606060'),
|
numericSpinnerFg: new CustomProp('theme-numeric-spinner-fg', undefined, '#606060'),
|
||||||
|
|
||||||
|
/* Custom Widget Gallery */
|
||||||
|
widgetGalleryBorder: new CustomProp('theme-widget-gallery-border', undefined, colors.darkGrey),
|
||||||
|
widgetGalleryBorderSelected: new CustomProp('theme-widget-gallery-border-selected', undefined,
|
||||||
|
colors.lightGreen),
|
||||||
|
widgetGalleryShadow: new CustomProp('theme-widget-gallery-shadow', undefined, '#0000001A'),
|
||||||
|
widgetGalleryBgHover: new CustomProp('theme-widget-gallery-bg-hover', undefined,
|
||||||
|
colors.lightGrey),
|
||||||
|
widgetGallerySecondaryHeaderFg: new CustomProp('theme-widget-gallery-secondary-header-fg',
|
||||||
|
undefined, colors.light),
|
||||||
|
widgetGallerySecondaryHeaderBg: new CustomProp('theme-widget-gallery-secondary-header-bg',
|
||||||
|
undefined, colors.slate),
|
||||||
|
widgetGallerySecondaryHeaderBgHover: new CustomProp(
|
||||||
|
'theme-widget-gallery-secondary-header-bg-hover', undefined, '#7E7E85'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const cssColors = values(colors).map(v => v.decl()).join('\n');
|
const cssColors = values(colors).map(v => v.decl()).join('\n');
|
||||||
|
@ -380,59 +380,60 @@ export class FieldEditor extends Disposable {
|
|||||||
if (!editor) { return false; }
|
if (!editor) { return false; }
|
||||||
// Make sure the editor is save ready
|
// Make sure the editor is save ready
|
||||||
const saveIndex = this._cursor.rowIndex();
|
const saveIndex = this._cursor.rowIndex();
|
||||||
await editor.prepForSave();
|
return await this._gristDoc.docData.bundleActions(null, async () => {
|
||||||
if (this.isDisposed()) {
|
await editor.prepForSave();
|
||||||
// We shouldn't normally get disposed here, but if we do, avoid confusing JS errors.
|
if (this.isDisposed()) {
|
||||||
console.warn(t("Unable to finish saving edited cell")); // tslint:disable-line:no-console
|
// We shouldn't normally get disposed here, but if we do, avoid confusing JS errors.
|
||||||
return false;
|
console.warn(t("Unable to finish saving edited cell")); // tslint:disable-line:no-console
|
||||||
}
|
return false;
|
||||||
|
|
||||||
// Then save the value the appropriate way
|
|
||||||
// TODO: this isFormula value doesn't actually reflect if editing the formula, since
|
|
||||||
// editingFormula() is used for toggling column headers, and this is deferred to start of
|
|
||||||
// typing (a double-click or Enter) does not immediately set it. (This can cause a
|
|
||||||
// console.warn below, although harmless.)
|
|
||||||
const isFormula = this._field.editingFormula();
|
|
||||||
const col = this._field.column();
|
|
||||||
let waitPromise: Promise<unknown>|null = null;
|
|
||||||
|
|
||||||
if (isFormula) {
|
|
||||||
const formula = String(editor.getCellValue() ?? '');
|
|
||||||
// Bundle multiple changes so that we can undo them in one step.
|
|
||||||
if (isFormula !== col.isFormula.peek() || formula !== col.formula.peek()) {
|
|
||||||
waitPromise = this._gristDoc.docData.bundleActions(null, () => Promise.all([
|
|
||||||
col.updateColValues({isFormula, formula}),
|
|
||||||
// If we're saving a non-empty formula, then also add an empty record to the table
|
|
||||||
// so that the formula calculation is visible to the user.
|
|
||||||
(!this._detached.get() && this._editRow._isAddRow.peek() && formula !== "" ?
|
|
||||||
this._editRow.updateColValues({}) : undefined),
|
|
||||||
]));
|
|
||||||
}
|
}
|
||||||
} else {
|
// Then save the value the appropriate way
|
||||||
const value = editor.getCellValue();
|
// TODO: this isFormula value doesn't actually reflect if editing the formula, since
|
||||||
if (col.isRealFormula()) {
|
// editingFormula() is used for toggling column headers, and this is deferred to start of
|
||||||
// tslint:disable-next-line:no-console
|
// typing (a double-click or Enter) does not immediately set it. (This can cause a
|
||||||
console.warn(t("It should be impossible to save a plain data value into a formula column"));
|
// console.warn below, although harmless.)
|
||||||
|
const isFormula = this._field.editingFormula();
|
||||||
|
const col = this._field.column();
|
||||||
|
let waitPromise: Promise<unknown>|null = null;
|
||||||
|
|
||||||
|
if (isFormula) {
|
||||||
|
const formula = String(editor.getCellValue() ?? '');
|
||||||
|
// Bundle multiple changes so that we can undo them in one step.
|
||||||
|
if (isFormula !== col.isFormula.peek() || formula !== col.formula.peek()) {
|
||||||
|
waitPromise = Promise.all([
|
||||||
|
col.updateColValues({isFormula, formula}),
|
||||||
|
// If we're saving a non-empty formula, then also add an empty record to the table
|
||||||
|
// so that the formula calculation is visible to the user.
|
||||||
|
(!this._detached.get() && this._editRow._isAddRow.peek() && formula !== "" ?
|
||||||
|
this._editRow.updateColValues({}) : undefined),
|
||||||
|
]);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// This could still be an isFormula column if it's empty (isEmpty is true), but we don't
|
const value = editor.getCellValue();
|
||||||
// need to toggle isFormula in that case, since the data engine takes care of that.
|
if (col.isRealFormula()) {
|
||||||
waitPromise = setAndSave(this._editRow, this._field, value);
|
// tslint:disable-next-line:no-console
|
||||||
|
console.warn(t("It should be impossible to save a plain data value into a formula column"));
|
||||||
|
} else {
|
||||||
|
// This could still be an isFormula column if it's empty (isEmpty is true), but we don't
|
||||||
|
// need to toggle isFormula in that case, since the data engine takes care of that.
|
||||||
|
waitPromise = setAndSave(this._editRow, this._field, value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const event: FieldEditorStateEvent = {
|
const event: FieldEditorStateEvent = {
|
||||||
position : this.cellPosition(),
|
position : this.cellPosition(),
|
||||||
wasModified : this._editorHasChanged,
|
wasModified : this._editorHasChanged,
|
||||||
currentState : this._editorHolder.get()?.editorState?.get(),
|
currentState : this._editorHolder.get()?.editorState?.get(),
|
||||||
type : this._field.column.peek().pureType.peek()
|
type : this._field.column.peek().pureType.peek()
|
||||||
};
|
};
|
||||||
this.saveEmitter.emit(event);
|
this.saveEmitter.emit(event);
|
||||||
|
|
||||||
const cursor = this._cursor;
|
const cursor = this._cursor;
|
||||||
// Deactivate the editor. We are careful to avoid using `this` afterwards.
|
// Deactivate the editor. We are careful to avoid using `this` afterwards.
|
||||||
this.dispose();
|
this.dispose();
|
||||||
await waitPromise;
|
await waitPromise;
|
||||||
return isFormula || (saveIndex !== cursor.rowIndex());
|
return isFormula || (saveIndex !== cursor.rowIndex());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -480,6 +480,7 @@ function _isInIdentifier(line: string, column: number) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Open a formula editor. Returns a Disposable that owns the editor.
|
* Open a formula editor. Returns a Disposable that owns the editor.
|
||||||
|
* This is used for the editor in the side panel.
|
||||||
*/
|
*/
|
||||||
export function openFormulaEditor(options: {
|
export function openFormulaEditor(options: {
|
||||||
gristDoc: GristDoc,
|
gristDoc: GristDoc,
|
||||||
|
@ -8,7 +8,8 @@ export type BootProbeIds =
|
|||||||
'sandboxing' |
|
'sandboxing' |
|
||||||
'system-user' |
|
'system-user' |
|
||||||
'authentication' |
|
'authentication' |
|
||||||
'websockets'
|
'websockets' |
|
||||||
|
'session-secret'
|
||||||
;
|
;
|
||||||
|
|
||||||
export interface BootProbeResult {
|
export interface BootProbeResult {
|
||||||
|
31
app/common/ConfigAPI.ts
Normal file
31
app/common/ConfigAPI.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import {BaseAPI, IOptions} from "app/common/BaseAPI";
|
||||||
|
import {addCurrentOrgToPath} from 'app/common/urlUtils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An API for accessing the internal Grist configuration, stored in
|
||||||
|
* config.json.
|
||||||
|
*/
|
||||||
|
export class ConfigAPI extends BaseAPI {
|
||||||
|
constructor(private _homeUrl: string, options: IOptions = {}) {
|
||||||
|
super(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getValue(key: string): Promise<any> {
|
||||||
|
return (await this.requestJson(`${this._url}/api/config/${key}`, {method: 'GET'})).value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setValue(value: any, restart=false): Promise<void> {
|
||||||
|
await this.request(`${this._url}/api/config`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({config: value, restart}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async restartServer(): Promise<void> {
|
||||||
|
await this.request(`${this._url}/api/admin/restart`, {method: 'POST'});
|
||||||
|
}
|
||||||
|
|
||||||
|
private get _url(): string {
|
||||||
|
return addCurrentOrgToPath(this._homeUrl);
|
||||||
|
}
|
||||||
|
}
|
@ -30,12 +30,10 @@ export interface ICustomWidget {
|
|||||||
* applying the Grist theme.
|
* applying the Grist theme.
|
||||||
*/
|
*/
|
||||||
renderAfterReady?: boolean;
|
renderAfterReady?: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If set to false, do not offer to user in UI.
|
* If set to false, do not offer to user in UI.
|
||||||
*/
|
*/
|
||||||
published?: boolean;
|
published?: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If the widget came from a plugin, we track that here.
|
* If the widget came from a plugin, we track that here.
|
||||||
*/
|
*/
|
||||||
@ -43,6 +41,29 @@ export interface ICustomWidget {
|
|||||||
pluginId: string;
|
pluginId: string;
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* Widget description.
|
||||||
|
*/
|
||||||
|
description?: string;
|
||||||
|
/**
|
||||||
|
* Widget authors.
|
||||||
|
*
|
||||||
|
* The first author is the one shown in the UI.
|
||||||
|
*/
|
||||||
|
authors?: WidgetAuthor[];
|
||||||
|
/**
|
||||||
|
* Date the widget was last updated.
|
||||||
|
*/
|
||||||
|
lastUpdatedAt?: string;
|
||||||
|
/**
|
||||||
|
* If the widget is maintained by Grist Labs.
|
||||||
|
*/
|
||||||
|
isGristLabsMaintained?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WidgetAuthor {
|
||||||
|
name: string;
|
||||||
|
url?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -86,10 +86,10 @@ export const BehavioralPrompt = StringUnion(
|
|||||||
'editCardLayout',
|
'editCardLayout',
|
||||||
'addNew',
|
'addNew',
|
||||||
'rickRow',
|
'rickRow',
|
||||||
'customURL',
|
|
||||||
'calendarConfig',
|
'calendarConfig',
|
||||||
|
|
||||||
// The following were used in the past and should not be re-used.
|
// The following were used in the past and should not be re-used.
|
||||||
|
// 'customURL',
|
||||||
// 'formsAreHere',
|
// 'formsAreHere',
|
||||||
);
|
);
|
||||||
export type BehavioralPrompt = typeof BehavioralPrompt.type;
|
export type BehavioralPrompt = typeof BehavioralPrompt.type;
|
||||||
@ -107,12 +107,15 @@ export interface BehavioralPromptPrefs {
|
|||||||
export const DismissedPopup = StringUnion(
|
export const DismissedPopup = StringUnion(
|
||||||
'deleteRecords', // confirmation for deleting records keyboard shortcut
|
'deleteRecords', // confirmation for deleting records keyboard shortcut
|
||||||
'deleteFields', // confirmation for deleting columns keyboard shortcut
|
'deleteFields', // confirmation for deleting columns keyboard shortcut
|
||||||
'tutorialFirstCard', // first card of the tutorial
|
|
||||||
'formulaHelpInfo', // formula help info shown in the popup editor
|
'formulaHelpInfo', // formula help info shown in the popup editor
|
||||||
'formulaAssistantInfo', // formula assistant info shown in the popup editor
|
'formulaAssistantInfo', // formula assistant info shown in the popup editor
|
||||||
'supportGrist', // nudge to opt in to telemetry
|
'supportGrist', // nudge to opt in to telemetry
|
||||||
'publishForm', // confirmation for publishing a form
|
'publishForm', // confirmation for publishing a form
|
||||||
'unpublishForm', // confirmation for unpublishing a form
|
'unpublishForm', // confirmation for unpublishing a form
|
||||||
|
'onboardingCards', // onboarding cards shown on the doc menu
|
||||||
|
|
||||||
|
/* Deprecated */
|
||||||
|
'tutorialFirstCard', // first card of the tutorial
|
||||||
);
|
);
|
||||||
export type DismissedPopup = typeof DismissedPopup.type;
|
export type DismissedPopup = typeof DismissedPopup.type;
|
||||||
|
|
||||||
|
@ -1,3 +1,9 @@
|
|||||||
|
export class StringUnionError extends TypeError {
|
||||||
|
constructor(errMessage: string, public readonly actual: string, public readonly values: string[]) {
|
||||||
|
super(errMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TypeScript will infer a string union type from the literal values passed to
|
* TypeScript will infer a string union type from the literal values passed to
|
||||||
* this function. Without `extends string`, it would instead generalize them
|
* this function. Without `extends string`, it would instead generalize them
|
||||||
@ -28,7 +34,7 @@ export const StringUnion = <UnionType extends string>(...values: UnionType[]) =>
|
|||||||
if (!guard(value)) {
|
if (!guard(value)) {
|
||||||
const actual = JSON.stringify(value);
|
const actual = JSON.stringify(value);
|
||||||
const expected = values.map(s => JSON.stringify(s)).join(' | ');
|
const expected = values.map(s => JSON.stringify(s)).join(' | ');
|
||||||
throw new TypeError(`Value '${actual}' is not assignable to type '${expected}'.`);
|
throw new StringUnionError(`Value '${actual}' is not assignable to type '${expected}'.`, actual, values);
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
@ -44,6 +50,6 @@ export const StringUnion = <UnionType extends string>(...values: UnionType[]) =>
|
|||||||
return value != null && guard(value) ? value : undefined;
|
return value != null && guard(value) ? value : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const unionNamespace = {guard, check, parse, values, checkAll};
|
const unionNamespace = { guard, check, parse, values, checkAll };
|
||||||
return Object.freeze(unionNamespace as typeof unionNamespace & {type: UnionType});
|
return Object.freeze(unionNamespace as typeof unionNamespace & {type: UnionType});
|
||||||
};
|
};
|
||||||
|
@ -211,6 +211,8 @@ export const ThemeColors = t.iface([], {
|
|||||||
"right-panel-toggle-button-disabled-bg": "string",
|
"right-panel-toggle-button-disabled-bg": "string",
|
||||||
"right-panel-field-settings-bg": "string",
|
"right-panel-field-settings-bg": "string",
|
||||||
"right-panel-field-settings-button-bg": "string",
|
"right-panel-field-settings-button-bg": "string",
|
||||||
|
"right-panel-custom-widget-button-fg": "string",
|
||||||
|
"right-panel-custom-widget-button-bg": "string",
|
||||||
"document-history-snapshot-fg": "string",
|
"document-history-snapshot-fg": "string",
|
||||||
"document-history-snapshot-selected-fg": "string",
|
"document-history-snapshot-selected-fg": "string",
|
||||||
"document-history-snapshot-bg": "string",
|
"document-history-snapshot-bg": "string",
|
||||||
@ -438,6 +440,13 @@ export const ThemeColors = t.iface([], {
|
|||||||
"scroll-shadow": "string",
|
"scroll-shadow": "string",
|
||||||
"toggle-checkbox-fg": "string",
|
"toggle-checkbox-fg": "string",
|
||||||
"numeric-spinner-fg": "string",
|
"numeric-spinner-fg": "string",
|
||||||
|
"widget-gallery-border": "string",
|
||||||
|
"widget-gallery-border-selected": "string",
|
||||||
|
"widget-gallery-shadow": "string",
|
||||||
|
"widget-gallery-bg-hover": "string",
|
||||||
|
"widget-gallery-secondary-header-fg": "string",
|
||||||
|
"widget-gallery-secondary-header-bg": "string",
|
||||||
|
"widget-gallery-secondary-header-bg-hover": "string",
|
||||||
});
|
});
|
||||||
|
|
||||||
const exportedTypeSuite: t.ITypeSuite = {
|
const exportedTypeSuite: t.ITypeSuite = {
|
||||||
|
@ -269,6 +269,8 @@ export interface ThemeColors {
|
|||||||
'right-panel-toggle-button-disabled-bg': string;
|
'right-panel-toggle-button-disabled-bg': string;
|
||||||
'right-panel-field-settings-bg': string;
|
'right-panel-field-settings-bg': string;
|
||||||
'right-panel-field-settings-button-bg': string;
|
'right-panel-field-settings-button-bg': string;
|
||||||
|
'right-panel-custom-widget-button-fg': string;
|
||||||
|
'right-panel-custom-widget-button-bg': string;
|
||||||
|
|
||||||
/* Document History */
|
/* Document History */
|
||||||
'document-history-snapshot-fg': string;
|
'document-history-snapshot-fg': string;
|
||||||
@ -572,6 +574,15 @@ export interface ThemeColors {
|
|||||||
|
|
||||||
/* Numeric Spinners */
|
/* Numeric Spinners */
|
||||||
'numeric-spinner-fg': string;
|
'numeric-spinner-fg': string;
|
||||||
|
|
||||||
|
/* Custom Widget Gallery */
|
||||||
|
'widget-gallery-border': string;
|
||||||
|
'widget-gallery-border-selected': string;
|
||||||
|
'widget-gallery-shadow': string;
|
||||||
|
'widget-gallery-bg-hover': string;
|
||||||
|
'widget-gallery-secondary-header-fg': string;
|
||||||
|
'widget-gallery-secondary-header-bg': string;
|
||||||
|
'widget-gallery-secondary-header-bg-hover': string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ThemePrefsChecker = createCheckers(ThemePrefsTI).ThemePrefs as CheckerT<ThemePrefs>;
|
export const ThemePrefsChecker = createCheckers(ThemePrefsTI).ThemePrefs as CheckerT<ThemePrefs>;
|
||||||
|
@ -14,6 +14,7 @@ export const Webhook = t.iface([], {
|
|||||||
|
|
||||||
export const WebhookFields = t.iface([], {
|
export const WebhookFields = t.iface([], {
|
||||||
"url": "string",
|
"url": "string",
|
||||||
|
"authorization": t.opt("string"),
|
||||||
"eventTypes": t.array(t.union(t.lit("add"), t.lit("update"))),
|
"eventTypes": t.array(t.union(t.lit("add"), t.lit("update"))),
|
||||||
"tableId": "string",
|
"tableId": "string",
|
||||||
"watchedColIds": t.opt(t.array("string")),
|
"watchedColIds": t.opt(t.array("string")),
|
||||||
@ -29,6 +30,7 @@ export const WebhookStatus = t.union(t.lit('idle'), t.lit('sending'), t.lit('ret
|
|||||||
|
|
||||||
export const WebhookSubscribe = t.iface([], {
|
export const WebhookSubscribe = t.iface([], {
|
||||||
"url": "string",
|
"url": "string",
|
||||||
|
"authorization": t.opt("string"),
|
||||||
"eventTypes": t.array(t.union(t.lit("add"), t.lit("update"))),
|
"eventTypes": t.array(t.union(t.lit("add"), t.lit("update"))),
|
||||||
"watchedColIds": t.opt(t.array("string")),
|
"watchedColIds": t.opt(t.array("string")),
|
||||||
"enabled": t.opt("boolean"),
|
"enabled": t.opt("boolean"),
|
||||||
@ -45,6 +47,7 @@ export const WebhookSummary = t.iface([], {
|
|||||||
"id": "string",
|
"id": "string",
|
||||||
"fields": t.iface([], {
|
"fields": t.iface([], {
|
||||||
"url": "string",
|
"url": "string",
|
||||||
|
"authorization": t.opt("string"),
|
||||||
"unsubscribeKey": "string",
|
"unsubscribeKey": "string",
|
||||||
"eventTypes": t.array("string"),
|
"eventTypes": t.array("string"),
|
||||||
"isReadyColumn": t.union("string", "null"),
|
"isReadyColumn": t.union("string", "null"),
|
||||||
@ -64,6 +67,7 @@ export const WebhookUpdate = t.iface([], {
|
|||||||
|
|
||||||
export const WebhookPatch = t.iface([], {
|
export const WebhookPatch = t.iface([], {
|
||||||
"url": t.opt("string"),
|
"url": t.opt("string"),
|
||||||
|
"authorization": t.opt("string"),
|
||||||
"eventTypes": t.opt(t.array(t.union(t.lit("add"), t.lit("update")))),
|
"eventTypes": t.opt(t.array(t.union(t.lit("add"), t.lit("update")))),
|
||||||
"tableId": t.opt("string"),
|
"tableId": t.opt("string"),
|
||||||
"watchedColIds": t.opt(t.array("string")),
|
"watchedColIds": t.opt(t.array("string")),
|
||||||
|
@ -8,6 +8,7 @@ export interface Webhook {
|
|||||||
|
|
||||||
export interface WebhookFields {
|
export interface WebhookFields {
|
||||||
url: string;
|
url: string;
|
||||||
|
authorization?: string;
|
||||||
eventTypes: Array<"add"|"update">;
|
eventTypes: Array<"add"|"update">;
|
||||||
tableId: string;
|
tableId: string;
|
||||||
watchedColIds?: string[];
|
watchedColIds?: string[];
|
||||||
@ -26,6 +27,7 @@ export type WebhookStatus = 'idle'|'sending'|'retrying'|'postponed'|'error'|'inv
|
|||||||
// tableId from the url) but generics are not yet supported by ts-interface-builder
|
// tableId from the url) but generics are not yet supported by ts-interface-builder
|
||||||
export interface WebhookSubscribe {
|
export interface WebhookSubscribe {
|
||||||
url: string;
|
url: string;
|
||||||
|
authorization?: string;
|
||||||
eventTypes: Array<"add"|"update">;
|
eventTypes: Array<"add"|"update">;
|
||||||
watchedColIds?: string[];
|
watchedColIds?: string[];
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
@ -42,6 +44,7 @@ export interface WebhookSummary {
|
|||||||
id: string;
|
id: string;
|
||||||
fields: {
|
fields: {
|
||||||
url: string;
|
url: string;
|
||||||
|
authorization?: string;
|
||||||
unsubscribeKey: string;
|
unsubscribeKey: string;
|
||||||
eventTypes: string[];
|
eventTypes: string[];
|
||||||
isReadyColumn: string|null;
|
isReadyColumn: string|null;
|
||||||
@ -64,6 +67,7 @@ export interface WebhookUpdate {
|
|||||||
// ts-interface-builder
|
// ts-interface-builder
|
||||||
export interface WebhookPatch {
|
export interface WebhookPatch {
|
||||||
url?: string;
|
url?: string;
|
||||||
|
authorization?: string;
|
||||||
eventTypes?: Array<"add"|"update">;
|
eventTypes?: Array<"add"|"update">;
|
||||||
tableId?: string;
|
tableId?: string;
|
||||||
watchedColIds?: string[];
|
watchedColIds?: string[];
|
||||||
|
@ -145,7 +145,7 @@ export interface DocumentOptions {
|
|||||||
|
|
||||||
export interface TutorialMetadata {
|
export interface TutorialMetadata {
|
||||||
lastSlideIndex?: number;
|
lastSlideIndex?: number;
|
||||||
numSlides?: number;
|
percentComplete?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DocumentProperties extends CommonProperties {
|
export interface DocumentProperties extends CommonProperties {
|
||||||
@ -370,6 +370,7 @@ export interface UserAPI {
|
|||||||
getOrgWorkspaces(orgId: number|string, includeSupport?: boolean): Promise<Workspace[]>;
|
getOrgWorkspaces(orgId: number|string, includeSupport?: boolean): Promise<Workspace[]>;
|
||||||
getOrgUsageSummary(orgId: number|string): Promise<OrgUsageSummary>;
|
getOrgUsageSummary(orgId: number|string): Promise<OrgUsageSummary>;
|
||||||
getTemplates(onlyFeatured?: boolean): Promise<Workspace[]>;
|
getTemplates(onlyFeatured?: boolean): Promise<Workspace[]>;
|
||||||
|
getTemplate(docId: string): Promise<Document>;
|
||||||
getDoc(docId: string): Promise<Document>;
|
getDoc(docId: string): Promise<Document>;
|
||||||
newOrg(props: Partial<OrganizationProperties>): Promise<number>;
|
newOrg(props: Partial<OrganizationProperties>): Promise<number>;
|
||||||
newWorkspace(props: Partial<WorkspaceProperties>, orgId: number|string): Promise<number>;
|
newWorkspace(props: Partial<WorkspaceProperties>, orgId: number|string): Promise<number>;
|
||||||
@ -589,6 +590,10 @@ export class UserAPIImpl extends BaseAPI implements UserAPI {
|
|||||||
return this.requestJson(`${this._url}/api/templates?onlyFeatured=${onlyFeatured ? 1 : 0}`, { method: 'GET' });
|
return this.requestJson(`${this._url}/api/templates?onlyFeatured=${onlyFeatured ? 1 : 0}`, { method: 'GET' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getTemplate(docId: string): Promise<Document> {
|
||||||
|
return this.requestJson(`${this._url}/api/templates/${docId}`, { method: 'GET' });
|
||||||
|
}
|
||||||
|
|
||||||
public async getWidgets(): Promise<ICustomWidget[]> {
|
public async getWidgets(): Promise<ICustomWidget[]> {
|
||||||
return await this.requestJson(`${this._url}/api/widgets`, { method: 'GET' });
|
return await this.requestJson(`${this._url}/api/widgets`, { method: 'GET' });
|
||||||
}
|
}
|
||||||
|
@ -84,6 +84,7 @@ export const commonUrls = {
|
|||||||
helpTryingOutChanges: "https://support.getgrist.com/copying-docs/#trying-out-changes",
|
helpTryingOutChanges: "https://support.getgrist.com/copying-docs/#trying-out-changes",
|
||||||
helpCustomWidgets: "https://support.getgrist.com/widget-custom",
|
helpCustomWidgets: "https://support.getgrist.com/widget-custom",
|
||||||
helpTelemetryLimited: "https://support.getgrist.com/telemetry-limited",
|
helpTelemetryLimited: "https://support.getgrist.com/telemetry-limited",
|
||||||
|
helpEnterpriseOptIn: "https://support.getgrist.com/self-managed/#how-do-i-activate-grist-enterprise",
|
||||||
helpCalendarWidget: "https://support.getgrist.com/widget-calendar",
|
helpCalendarWidget: "https://support.getgrist.com/widget-calendar",
|
||||||
helpLinkKeys: "https://support.getgrist.com/examples/2021-04-link-keys",
|
helpLinkKeys: "https://support.getgrist.com/examples/2021-04-link-keys",
|
||||||
helpFilteringReferenceChoices: "https://support.getgrist.com/col-refs/#filtering-reference-choices-in-dropdown",
|
helpFilteringReferenceChoices: "https://support.getgrist.com/col-refs/#filtering-reference-choices-in-dropdown",
|
||||||
@ -93,7 +94,6 @@ export const commonUrls = {
|
|||||||
contactSupport: getContactSupportUrl(),
|
contactSupport: getContactSupportUrl(),
|
||||||
termsOfService: getTermsOfServiceUrl(),
|
termsOfService: getTermsOfServiceUrl(),
|
||||||
plans: "https://www.getgrist.com/pricing",
|
plans: "https://www.getgrist.com/pricing",
|
||||||
sproutsProgram: "https://www.getgrist.com/sprouts-program",
|
|
||||||
contact: "https://www.getgrist.com/contact",
|
contact: "https://www.getgrist.com/contact",
|
||||||
templates: 'https://www.getgrist.com/templates',
|
templates: 'https://www.getgrist.com/templates',
|
||||||
community: 'https://community.getgrist.com',
|
community: 'https://community.getgrist.com',
|
||||||
@ -102,8 +102,6 @@ export const commonUrls = {
|
|||||||
formulas: 'https://support.getgrist.com/formulas',
|
formulas: 'https://support.getgrist.com/formulas',
|
||||||
forms: 'https://www.getgrist.com/forms/?utm_source=grist-forms&utm_medium=grist-forms&utm_campaign=forms-footer',
|
forms: 'https://www.getgrist.com/forms/?utm_source=grist-forms&utm_medium=grist-forms&utm_campaign=forms-footer',
|
||||||
|
|
||||||
basicTutorial: 'https://templates.getgrist.com/woXtXUBmiN5T/Grist-Basics',
|
|
||||||
basicTutorialImage: 'https://www.getgrist.com/wp-content/uploads/2021/08/lightweight-crm.png',
|
|
||||||
gristLabsCustomWidgets: 'https://gristlabs.github.io/grist-widget/',
|
gristLabsCustomWidgets: 'https://gristlabs.github.io/grist-widget/',
|
||||||
gristLabsWidgetRepository: 'https://github.com/gristlabs/grist-widget/releases/download/latest/manifest.json',
|
gristLabsWidgetRepository: 'https://github.com/gristlabs/grist-widget/releases/download/latest/manifest.json',
|
||||||
githubGristCore: 'https://github.com/gristlabs/grist-core',
|
githubGristCore: 'https://github.com/gristlabs/grist-core',
|
||||||
@ -112,6 +110,8 @@ export const commonUrls = {
|
|||||||
versionCheck: 'https://api.getgrist.com/api/version',
|
versionCheck: 'https://api.getgrist.com/api/version',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const ONBOARDING_VIDEO_YOUTUBE_EMBED_ID = '56AieR9rpww';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Values representable in a URL. The current state is available as urlState().state observable
|
* Values representable in a URL. The current state is available as urlState().state observable
|
||||||
* in client. Updates to this state are expected by functions such as makeUrl() and setLinkUrl().
|
* in client. Updates to this state are expected by functions such as makeUrl() and setLinkUrl().
|
||||||
@ -759,7 +759,8 @@ export interface GristLoadConfig {
|
|||||||
// List of registered plugins (used by HomePluginManager and DocPluginManager)
|
// List of registered plugins (used by HomePluginManager and DocPluginManager)
|
||||||
plugins?: LocalPlugin[];
|
plugins?: LocalPlugin[];
|
||||||
|
|
||||||
// If custom widget list is available.
|
// If additional custom widgets (besides the Custom URL widget) should be shown in
|
||||||
|
// the custom widget gallery.
|
||||||
enableWidgetRepository?: boolean;
|
enableWidgetRepository?: boolean;
|
||||||
|
|
||||||
// Whether there is somewhere for survey data to go.
|
// Whether there is somewhere for survey data to go.
|
||||||
@ -809,9 +810,15 @@ export interface GristLoadConfig {
|
|||||||
// The Grist deployment type (e.g. core, enterprise).
|
// The Grist deployment type (e.g. core, enterprise).
|
||||||
deploymentType?: GristDeploymentType;
|
deploymentType?: GristDeploymentType;
|
||||||
|
|
||||||
|
// Force enterprise deployment? For backwards compatibility with grist-ee Docker image
|
||||||
|
forceEnableEnterprise?: boolean;
|
||||||
|
|
||||||
// The org containing public templates and tutorials.
|
// The org containing public templates and tutorials.
|
||||||
templateOrg?: string|null;
|
templateOrg?: string|null;
|
||||||
|
|
||||||
|
// The doc id of the tutorial shown during onboarding.
|
||||||
|
onboardingTutorialDocId?: string;
|
||||||
|
|
||||||
// Whether to show the "Delete Account" button in the account page.
|
// Whether to show the "Delete Account" button in the account page.
|
||||||
canCloseAccount?: boolean;
|
canCloseAccount?: boolean;
|
||||||
|
|
||||||
|
27
app/common/normalizedDateTimeString.ts
Normal file
27
app/common/normalizedDateTimeString.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import moment from 'moment-timezone';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Output an ISO8601 format datetime string, with timezone.
|
||||||
|
* Any string fed in without timezone is expected to be in UTC.
|
||||||
|
*
|
||||||
|
* When connected to postgres, dates will be extracted as Date objects,
|
||||||
|
* with timezone information. The normalization done here is not
|
||||||
|
* really needed in this case.
|
||||||
|
*
|
||||||
|
* Timestamps in SQLite are stored as UTC, and read as strings
|
||||||
|
* (without timezone information). The normalization here is
|
||||||
|
* pretty important in this case.
|
||||||
|
*/
|
||||||
|
export function normalizedDateTimeString(dateTime: any): string {
|
||||||
|
if (!dateTime) { return dateTime; }
|
||||||
|
if (dateTime instanceof Date) {
|
||||||
|
return moment(dateTime).toISOString();
|
||||||
|
}
|
||||||
|
if (typeof dateTime === 'string' || typeof dateTime === 'number') {
|
||||||
|
// When SQLite returns a string, it will be in UTC.
|
||||||
|
// Need to make sure it actually have timezone info in it
|
||||||
|
// (will not by default).
|
||||||
|
return moment.utc(dateTime).toISOString();
|
||||||
|
}
|
||||||
|
throw new Error(`normalizedDateTimeString cannot handle ${dateTime}`);
|
||||||
|
}
|
@ -248,6 +248,8 @@ export const GristDark: ThemeColors = {
|
|||||||
'right-panel-toggle-button-disabled-bg': '#32323F',
|
'right-panel-toggle-button-disabled-bg': '#32323F',
|
||||||
'right-panel-field-settings-bg': '#404150',
|
'right-panel-field-settings-bg': '#404150',
|
||||||
'right-panel-field-settings-button-bg': '#646473',
|
'right-panel-field-settings-button-bg': '#646473',
|
||||||
|
'right-panel-custom-widget-button-fg': '#EFEFEF',
|
||||||
|
'right-panel-custom-widget-button-bg': '#60606D',
|
||||||
|
|
||||||
/* Document History */
|
/* Document History */
|
||||||
'document-history-snapshot-fg': '#EFEFEF',
|
'document-history-snapshot-fg': '#EFEFEF',
|
||||||
@ -551,4 +553,13 @@ export const GristDark: ThemeColors = {
|
|||||||
|
|
||||||
/* Numeric Spinners */
|
/* Numeric Spinners */
|
||||||
'numeric-spinner-fg': '#A4A4B1',
|
'numeric-spinner-fg': '#A4A4B1',
|
||||||
|
|
||||||
|
/* Custom Widget Gallery */
|
||||||
|
'widget-gallery-border': '#555563',
|
||||||
|
'widget-gallery-border-selected': '#17B378',
|
||||||
|
'widget-gallery-shadow': '#00000080',
|
||||||
|
'widget-gallery-bg-hover': '#262633',
|
||||||
|
'widget-gallery-secondary-header-fg': '#FFFFFF',
|
||||||
|
'widget-gallery-secondary-header-bg': '#70707D',
|
||||||
|
'widget-gallery-secondary-header-bg-hover': '#60606D',
|
||||||
};
|
};
|
||||||
|
@ -248,6 +248,8 @@ export const GristLight: ThemeColors = {
|
|||||||
'right-panel-toggle-button-disabled-bg': '#E8E8E8',
|
'right-panel-toggle-button-disabled-bg': '#E8E8E8',
|
||||||
'right-panel-field-settings-bg': '#E8E8E8',
|
'right-panel-field-settings-bg': '#E8E8E8',
|
||||||
'right-panel-field-settings-button-bg': 'lightgrey',
|
'right-panel-field-settings-button-bg': 'lightgrey',
|
||||||
|
'right-panel-custom-widget-button-fg': '#262633',
|
||||||
|
'right-panel-custom-widget-button-bg': '#D9D9D9',
|
||||||
|
|
||||||
/* Document History */
|
/* Document History */
|
||||||
'document-history-snapshot-fg': '#262633',
|
'document-history-snapshot-fg': '#262633',
|
||||||
@ -551,4 +553,13 @@ export const GristLight: ThemeColors = {
|
|||||||
|
|
||||||
/* Numeric Spinners */
|
/* Numeric Spinners */
|
||||||
'numeric-spinner-fg': '#606060',
|
'numeric-spinner-fg': '#606060',
|
||||||
|
|
||||||
|
/* Custom Widget Gallery */
|
||||||
|
'widget-gallery-border': '#D9D9D9',
|
||||||
|
'widget-gallery-border-selected': '#16B378',
|
||||||
|
'widget-gallery-shadow': '#0000001A',
|
||||||
|
'widget-gallery-bg-hover': '#F7F7F7',
|
||||||
|
'widget-gallery-secondary-header-fg': '#FFFFFF',
|
||||||
|
'widget-gallery-secondary-header-bg': '#929299',
|
||||||
|
'widget-gallery-secondary-header-bg-hover': '#7E7E85',
|
||||||
};
|
};
|
||||||
|
@ -9,7 +9,7 @@ import {FullUser} from 'app/common/LoginSessionAPI';
|
|||||||
import {BasicRole} from 'app/common/roles';
|
import {BasicRole} from 'app/common/roles';
|
||||||
import {OrganizationProperties, PermissionDelta} from 'app/common/UserAPI';
|
import {OrganizationProperties, PermissionDelta} from 'app/common/UserAPI';
|
||||||
import {User} from 'app/gen-server/entity/User';
|
import {User} from 'app/gen-server/entity/User';
|
||||||
import {BillingOptions, HomeDBManager, QueryResult, Scope} from 'app/gen-server/lib/HomeDBManager';
|
import {BillingOptions, HomeDBManager, QueryResult, Scope} from 'app/gen-server/lib/homedb/HomeDBManager';
|
||||||
import {getAuthorizedUserId, getUserId, getUserProfiles, RequestWithLogin} from 'app/server/lib/Authorizer';
|
import {getAuthorizedUserId, getUserId, getUserProfiles, RequestWithLogin} from 'app/server/lib/Authorizer';
|
||||||
import {getSessionUser, linkOrgWithEmail} from 'app/server/lib/BrowserSession';
|
import {getSessionUser, linkOrgWithEmail} from 'app/server/lib/BrowserSession';
|
||||||
import {expressWrap} from 'app/server/lib/expressWrap';
|
import {expressWrap} from 'app/server/lib/expressWrap';
|
||||||
@ -302,6 +302,18 @@ export class ApiServer {
|
|||||||
return sendReply(req, res, query);
|
return sendReply(req, res, query);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// GET /api/templates/:did
|
||||||
|
// Get information about a template.
|
||||||
|
this._app.get('/api/templates/:did', expressWrap(async (req, res) => {
|
||||||
|
const templateOrg = getTemplateOrg();
|
||||||
|
if (!templateOrg) {
|
||||||
|
throw new ApiError('Template org is not configured', 501);
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = await this._dbManager.getDoc({...getScope(req), org: templateOrg});
|
||||||
|
return sendOkReply(req, res, query);
|
||||||
|
}));
|
||||||
|
|
||||||
// GET /api/widgets/
|
// GET /api/widgets/
|
||||||
// Get all widget definitions from external source.
|
// Get all widget definitions from external source.
|
||||||
this._app.get('/api/widgets/', expressWrap(async (req, res) => {
|
this._app.get('/api/widgets/', expressWrap(async (req, res) => {
|
||||||
|
@ -22,6 +22,15 @@ export class Activation extends BaseEntity {
|
|||||||
@Column({name: 'updated_at', default: () => "CURRENT_TIMESTAMP"})
|
@Column({name: 'updated_at', default: () => "CURRENT_TIMESTAMP"})
|
||||||
public updatedAt: Date;
|
public updatedAt: Date;
|
||||||
|
|
||||||
|
// When the enterprise activation was first enabled, so we know when
|
||||||
|
// to start counting the trial date.
|
||||||
|
//
|
||||||
|
// Activations are created at Grist installation to track other
|
||||||
|
// things such as prefs, but the user might not enable Enterprise
|
||||||
|
// until later.
|
||||||
|
@Column({name: 'enabled_at', type: nativeValues.dateTimeType, nullable: true})
|
||||||
|
public enabledAt: Date|null;
|
||||||
|
|
||||||
public checkProperties(props: any): props is Partial<InstallProperties> {
|
public checkProperties(props: any): props is Partial<InstallProperties> {
|
||||||
for (const key of Object.keys(props)) {
|
for (const key of Object.keys(props)) {
|
||||||
if (!installPropertyKeys.includes(key)) {
|
if (!installPropertyKeys.includes(key)) {
|
||||||
|
@ -134,12 +134,12 @@ export class Document extends Resource {
|
|||||||
this.options.tutorial = null;
|
this.options.tutorial = null;
|
||||||
} else {
|
} else {
|
||||||
this.options.tutorial = this.options.tutorial || {};
|
this.options.tutorial = this.options.tutorial || {};
|
||||||
if (props.options.tutorial.numSlides !== undefined) {
|
|
||||||
this.options.tutorial.numSlides = props.options.tutorial.numSlides;
|
|
||||||
}
|
|
||||||
if (props.options.tutorial.lastSlideIndex !== undefined) {
|
if (props.options.tutorial.lastSlideIndex !== undefined) {
|
||||||
this.options.tutorial.lastSlideIndex = props.options.tutorial.lastSlideIndex;
|
this.options.tutorial.lastSlideIndex = props.options.tutorial.lastSlideIndex;
|
||||||
}
|
}
|
||||||
|
if (props.options.tutorial.percentComplete !== undefined) {
|
||||||
|
this.options.tutorial.percentComplete = props.options.tutorial.percentComplete;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Normalize so that null equates with absence.
|
// Normalize so that null equates with absence.
|
||||||
|
@ -29,6 +29,9 @@ export class User extends BaseEntity {
|
|||||||
@Column({name: 'first_login_at', type: Date, nullable: true})
|
@Column({name: 'first_login_at', type: Date, nullable: true})
|
||||||
public firstLoginAt: Date | null;
|
public firstLoginAt: Date | null;
|
||||||
|
|
||||||
|
@Column({name: 'last_connection_at', type: Date, nullable: true})
|
||||||
|
public lastConnectionAt: Date | null;
|
||||||
|
|
||||||
@OneToOne(type => Organization, organization => organization.owner)
|
@OneToOne(type => Organization, organization => organization.owner)
|
||||||
public personalOrg: Organization;
|
public personalOrg: Organization;
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { makeId } from 'app/server/lib/idUtils';
|
import { makeId } from 'app/server/lib/idUtils';
|
||||||
import { Activation } from 'app/gen-server/entity/Activation';
|
import { Activation } from 'app/gen-server/entity/Activation';
|
||||||
import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager';
|
import { HomeDBManager } from 'app/gen-server/lib/homedb/HomeDBManager';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manage activations. Not much to do currently, there is at most one
|
* Manage activations. Not much to do currently, there is at most one
|
||||||
|
@ -5,7 +5,7 @@ import {AbortController} from 'node-abort-controller';
|
|||||||
import { ApiError } from 'app/common/ApiError';
|
import { ApiError } from 'app/common/ApiError';
|
||||||
import { SHARE_KEY_PREFIX } from 'app/common/gristUrls';
|
import { SHARE_KEY_PREFIX } from 'app/common/gristUrls';
|
||||||
import { removeTrailingSlash } from 'app/common/gutil';
|
import { removeTrailingSlash } from 'app/common/gutil';
|
||||||
import { HomeDBManager } from "app/gen-server/lib/HomeDBManager";
|
import { HomeDBManager } from "app/gen-server/lib/homedb/HomeDBManager";
|
||||||
import { assertAccess, getOrSetDocAuth, getTransitiveHeaders, RequestWithLogin } from 'app/server/lib/Authorizer';
|
import { assertAccess, getOrSetDocAuth, getTransitiveHeaders, RequestWithLogin } from 'app/server/lib/Authorizer';
|
||||||
import { IDocWorkerMap } from "app/server/lib/DocWorkerMap";
|
import { IDocWorkerMap } from "app/server/lib/DocWorkerMap";
|
||||||
import { expressWrap } from "app/server/lib/expressWrap";
|
import { expressWrap } from "app/server/lib/expressWrap";
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { ApiError } from 'app/common/ApiError';
|
import { ApiError } from 'app/common/ApiError';
|
||||||
import { FullUser } from 'app/common/UserAPI';
|
import { FullUser } from 'app/common/UserAPI';
|
||||||
import { Organization } from 'app/gen-server/entity/Organization';
|
import { Organization } from 'app/gen-server/entity/Organization';
|
||||||
import { HomeDBManager, Scope } from 'app/gen-server/lib/HomeDBManager';
|
import { HomeDBManager, Scope } from 'app/gen-server/lib/homedb/HomeDBManager';
|
||||||
import { INotifier } from 'app/server/lib/INotifier';
|
import { INotifier } from 'app/server/lib/INotifier';
|
||||||
import { scrubUserFromOrg } from 'app/gen-server/lib/scrubUserFromOrg';
|
import { scrubUserFromOrg } from 'app/gen-server/lib/scrubUserFromOrg';
|
||||||
import { GristLoginSystem } from 'app/server/lib/GristServer';
|
import { GristLoginSystem } from 'app/server/lib/GristServer';
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
import { ApiError } from 'app/common/ApiError';
|
import { ApiError } from 'app/common/ApiError';
|
||||||
import { delay } from 'app/common/delay';
|
import { delay } from 'app/common/delay';
|
||||||
import { buildUrlId } from 'app/common/gristUrls';
|
import { buildUrlId } from 'app/common/gristUrls';
|
||||||
|
import { normalizedDateTimeString } from 'app/common/normalizedDateTimeString';
|
||||||
import { BillingAccount } from 'app/gen-server/entity/BillingAccount';
|
import { BillingAccount } from 'app/gen-server/entity/BillingAccount';
|
||||||
import { Document } from 'app/gen-server/entity/Document';
|
import { Document } from 'app/gen-server/entity/Document';
|
||||||
import { Organization } from 'app/gen-server/entity/Organization';
|
import { Organization } from 'app/gen-server/entity/Organization';
|
||||||
import { Product } from 'app/gen-server/entity/Product';
|
import { Product } from 'app/gen-server/entity/Product';
|
||||||
import { Workspace } from 'app/gen-server/entity/Workspace';
|
import { Workspace } from 'app/gen-server/entity/Workspace';
|
||||||
import { HomeDBManager, Scope } from 'app/gen-server/lib/HomeDBManager';
|
import { HomeDBManager, Scope } from 'app/gen-server/lib/homedb/HomeDBManager';
|
||||||
import { fromNow } from 'app/gen-server/sqlUtils';
|
import { fromNow } from 'app/gen-server/sqlUtils';
|
||||||
import { getAuthorizedUserId } from 'app/server/lib/Authorizer';
|
import { getAuthorizedUserId } from 'app/server/lib/Authorizer';
|
||||||
import { expressWrap } from 'app/server/lib/expressWrap';
|
import { expressWrap } from 'app/server/lib/expressWrap';
|
||||||
@ -16,7 +17,6 @@ import log from 'app/server/lib/log';
|
|||||||
import { IPermitStore } from 'app/server/lib/Permit';
|
import { IPermitStore } from 'app/server/lib/Permit';
|
||||||
import { optStringParam, stringParam } from 'app/server/lib/requestUtils';
|
import { optStringParam, stringParam } from 'app/server/lib/requestUtils';
|
||||||
import * as express from 'express';
|
import * as express from 'express';
|
||||||
import moment from 'moment';
|
|
||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
import * as Fetch from 'node-fetch';
|
import * as Fetch from 'node-fetch';
|
||||||
import { EntityManager } from 'typeorm';
|
import { EntityManager } from 'typeorm';
|
||||||
@ -416,32 +416,6 @@ export class Housekeeper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Output an ISO8601 format datetime string, with timezone.
|
|
||||||
* Any string fed in without timezone is expected to be in UTC.
|
|
||||||
*
|
|
||||||
* When connected to postgres, dates will be extracted as Date objects,
|
|
||||||
* with timezone information. The normalization done here is not
|
|
||||||
* really needed in this case.
|
|
||||||
*
|
|
||||||
* Timestamps in SQLite are stored as UTC, and read as strings
|
|
||||||
* (without timezone information). The normalization here is
|
|
||||||
* pretty important in this case.
|
|
||||||
*/
|
|
||||||
function normalizedDateTimeString(dateTime: any): string {
|
|
||||||
if (!dateTime) { return dateTime; }
|
|
||||||
if (dateTime instanceof Date) {
|
|
||||||
return moment(dateTime).toISOString();
|
|
||||||
}
|
|
||||||
if (typeof dateTime === 'string') {
|
|
||||||
// When SQLite returns a string, it will be in UTC.
|
|
||||||
// Need to make sure it actually have timezone info in it
|
|
||||||
// (will not by default).
|
|
||||||
return moment.utc(dateTime).toISOString();
|
|
||||||
}
|
|
||||||
throw new Error(`normalizedDateTimeString cannot handle ${dateTime}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Call callback(item) for each item on the list, sleeping periodically to allow other works to
|
* Call callback(item) for each item on the list, sleeping periodically to allow other works to
|
||||||
* happen. Any time work takes more than SYNC_WORK_LIMIT_MS, will sleep for SYNC_WORK_BREAK_MS.
|
* happen. Any time work takes more than SYNC_WORK_LIMIT_MS, will sleep for SYNC_WORK_BREAK_MS.
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import {Document} from 'app/gen-server/entity/Document';
|
import {Document} from 'app/gen-server/entity/Document';
|
||||||
import {Organization} from 'app/gen-server/entity/Organization';
|
import {Organization} from 'app/gen-server/entity/Organization';
|
||||||
import {User} from 'app/gen-server/entity/User';
|
import {User} from 'app/gen-server/entity/User';
|
||||||
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
|
import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
|
||||||
import log from 'app/server/lib/log';
|
import log from 'app/server/lib/log';
|
||||||
|
|
||||||
// Frequency of logging usage information. Not something we need
|
// Frequency of logging usage information. Not something we need
|
||||||
|
@ -1638,7 +1638,7 @@ export class HomeDBManager extends EventEmitter {
|
|||||||
.where("id = :id AND doc_id = :docId", {id, docId})
|
.where("id = :id AND doc_id = :docId", {id, docId})
|
||||||
.execute();
|
.execute();
|
||||||
if (res.affected !== 1) {
|
if (res.affected !== 1) {
|
||||||
throw new ApiError('secret with given id not found', 404);
|
throw new ApiError('secret with given id not found or nothing was updated', 404);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1653,14 +1653,32 @@ export class HomeDBManager extends EventEmitter {
|
|||||||
|
|
||||||
// Update the webhook url in the webhook's corresponding secret (note: the webhook identifier is
|
// Update the webhook url in the webhook's corresponding secret (note: the webhook identifier is
|
||||||
// its secret identifier).
|
// its secret identifier).
|
||||||
public async updateWebhookUrl(id: string, docId: string, url: string, outerManager?: EntityManager) {
|
public async updateWebhookUrlAndAuth(
|
||||||
|
props: {
|
||||||
|
id: string,
|
||||||
|
docId: string,
|
||||||
|
url: string | undefined,
|
||||||
|
auth: string | undefined,
|
||||||
|
outerManager?: EntityManager}
|
||||||
|
) {
|
||||||
|
const {id, docId, url, auth, outerManager} = props;
|
||||||
return await this._runInTransaction(outerManager, async manager => {
|
return await this._runInTransaction(outerManager, async manager => {
|
||||||
|
if (url === undefined && auth === undefined) {
|
||||||
|
throw new ApiError('None of the Webhook url and auth are defined', 404);
|
||||||
|
}
|
||||||
const value = await this.getSecret(id, docId, manager);
|
const value = await this.getSecret(id, docId, manager);
|
||||||
if (!value) {
|
if (!value) {
|
||||||
throw new ApiError('Webhook with given id not found', 404);
|
throw new ApiError('Webhook with given id not found', 404);
|
||||||
}
|
}
|
||||||
const webhookSecret = JSON.parse(value);
|
const webhookSecret = JSON.parse(value);
|
||||||
webhookSecret.url = url;
|
// As we want to patch the webhookSecret object, only set the url and the authorization when they are defined.
|
||||||
|
// When the user wants to empty the value, we are expected to receive empty strings.
|
||||||
|
if (url !== undefined) {
|
||||||
|
webhookSecret.url = url;
|
||||||
|
}
|
||||||
|
if (auth !== undefined) {
|
||||||
|
webhookSecret.authorization = auth;
|
||||||
|
}
|
||||||
await this.updateSecret(id, docId, JSON.stringify(webhookSecret), manager);
|
await this.updateSecret(id, docId, JSON.stringify(webhookSecret), manager);
|
||||||
});
|
});
|
||||||
}
|
}
|
@ -17,7 +17,7 @@ import { Group } from 'app/gen-server/entity/Group';
|
|||||||
import { Login } from 'app/gen-server/entity/Login';
|
import { Login } from 'app/gen-server/entity/Login';
|
||||||
import { User } from 'app/gen-server/entity/User';
|
import { User } from 'app/gen-server/entity/User';
|
||||||
import { appSettings } from 'app/server/lib/AppSettings';
|
import { appSettings } from 'app/server/lib/AppSettings';
|
||||||
import { HomeDBManager, PermissionDeltaAnalysis, Scope } from 'app/gen-server/lib/HomeDBManager';
|
import { HomeDBManager, PermissionDeltaAnalysis, Scope } from 'app/gen-server/lib/homedb/HomeDBManager';
|
||||||
import {
|
import {
|
||||||
AvailableUsers, GetUserOptions, NonGuestGroup, QueryResult, Resource, RunInTransaction, UserProfileChange
|
AvailableUsers, GetUserOptions, NonGuestGroup, QueryResult, Resource, RunInTransaction, UserProfileChange
|
||||||
} from 'app/gen-server/lib/homedb/Interfaces';
|
} from 'app/gen-server/lib/homedb/Interfaces';
|
||||||
@ -395,14 +395,6 @@ export class UsersManager {
|
|||||||
user.name = (profile && (profile.name || email.split('@')[0])) || '';
|
user.name = (profile && (profile.name || email.split('@')[0])) || '';
|
||||||
needUpdate = true;
|
needUpdate = true;
|
||||||
}
|
}
|
||||||
if (profile && !user.firstLoginAt) {
|
|
||||||
// set first login time to now (remove milliseconds for compatibility with other
|
|
||||||
// timestamps in db set by typeorm, and since second level precision is fine)
|
|
||||||
const nowish = new Date();
|
|
||||||
nowish.setMilliseconds(0);
|
|
||||||
user.firstLoginAt = nowish;
|
|
||||||
needUpdate = true;
|
|
||||||
}
|
|
||||||
if (!user.picture && profile && profile.picture) {
|
if (!user.picture && profile && profile.picture) {
|
||||||
// Set the user's profile picture if our provider knows it.
|
// Set the user's profile picture if our provider knows it.
|
||||||
user.picture = profile.picture;
|
user.picture = profile.picture;
|
||||||
@ -432,6 +424,25 @@ export class UsersManager {
|
|||||||
user.options = {...(user.options ?? {}), authSubject: userOptions.authSubject};
|
user.options = {...(user.options ?? {}), authSubject: userOptions.authSubject};
|
||||||
needUpdate = true;
|
needUpdate = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// get date of now (remove milliseconds for compatibility with other
|
||||||
|
// timestamps in db set by typeorm, and since second level precision is fine)
|
||||||
|
const nowish = new Date();
|
||||||
|
nowish.setMilliseconds(0);
|
||||||
|
if (profile && !user.firstLoginAt) {
|
||||||
|
// set first login time to now
|
||||||
|
user.firstLoginAt = nowish;
|
||||||
|
needUpdate = true;
|
||||||
|
}
|
||||||
|
const getTimestampStartOfDay = (date: Date) => {
|
||||||
|
const timestamp = Math.floor(date.getTime() / 1000); // unix timestamp seconds from epoc
|
||||||
|
const startOfDay = timestamp - (timestamp % 86400 /*24h*/); // start of a day in seconds since epoc
|
||||||
|
return startOfDay;
|
||||||
|
};
|
||||||
|
if (!user.lastConnectionAt || getTimestampStartOfDay(user.lastConnectionAt) !== getTimestampStartOfDay(nowish)) {
|
||||||
|
user.lastConnectionAt = nowish;
|
||||||
|
needUpdate = true;
|
||||||
|
}
|
||||||
if (needUpdate) {
|
if (needUpdate) {
|
||||||
login.user = user;
|
login.user = user;
|
||||||
await manager.save([user, login]);
|
await manager.save([user, login]);
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import {User} from 'app/gen-server/entity/User';
|
|
||||||
import {makeId} from 'app/server/lib/idUtils';
|
import {makeId} from 'app/server/lib/idUtils';
|
||||||
|
import {chunk} from 'lodash';
|
||||||
import {MigrationInterface, QueryRunner, TableColumn} from "typeorm";
|
import {MigrationInterface, QueryRunner, TableColumn} from "typeorm";
|
||||||
|
|
||||||
export class UserUUID1663851423064 implements MigrationInterface {
|
export class UserUUID1663851423064 implements MigrationInterface {
|
||||||
@ -16,11 +16,20 @@ export class UserUUID1663851423064 implements MigrationInterface {
|
|||||||
// Updating so many rows in a multiple queries is not ideal. We will send updates in chunks.
|
// Updating so many rows in a multiple queries is not ideal. We will send updates in chunks.
|
||||||
// 300 seems to be a good number, for 24k rows we have 80 queries.
|
// 300 seems to be a good number, for 24k rows we have 80 queries.
|
||||||
const userList = await queryRunner.manager.createQueryBuilder()
|
const userList = await queryRunner.manager.createQueryBuilder()
|
||||||
.select("users")
|
.select(["users.id", "users.ref"])
|
||||||
.from(User, "users")
|
.from("users", "users")
|
||||||
.getMany();
|
.getMany();
|
||||||
userList.forEach(u => u.ref = makeId());
|
userList.forEach(u => u.ref = makeId());
|
||||||
await queryRunner.manager.save(userList, { chunk: 300 });
|
|
||||||
|
const userChunks = chunk(userList, 300);
|
||||||
|
for (const users of userChunks) {
|
||||||
|
await queryRunner.connection.transaction(async manager => {
|
||||||
|
const queries = users.map((user: any, _index: number, _array: any[]) => {
|
||||||
|
return queryRunner.manager.update("users", user.id, user);
|
||||||
|
});
|
||||||
|
await Promise.all(queries);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// We are not making this column unique yet, because it can fail
|
// We are not making this column unique yet, because it can fail
|
||||||
// if there are some old workers still running, and any new user
|
// if there are some old workers still running, and any new user
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import {User} from 'app/gen-server/entity/User';
|
|
||||||
import {makeId} from 'app/server/lib/idUtils';
|
import {makeId} from 'app/server/lib/idUtils';
|
||||||
|
import {chunk} from 'lodash';
|
||||||
import {MigrationInterface, QueryRunner} from "typeorm";
|
import {MigrationInterface, QueryRunner} from "typeorm";
|
||||||
|
|
||||||
export class UserRefUnique1664528376930 implements MigrationInterface {
|
export class UserRefUnique1664528376930 implements MigrationInterface {
|
||||||
@ -9,12 +9,21 @@ export class UserRefUnique1664528376930 implements MigrationInterface {
|
|||||||
|
|
||||||
// Update users that don't have unique ref set.
|
// Update users that don't have unique ref set.
|
||||||
const userList = await queryRunner.manager.createQueryBuilder()
|
const userList = await queryRunner.manager.createQueryBuilder()
|
||||||
.select("users")
|
.select(["users.id", "users.ref"])
|
||||||
.from(User, "users")
|
.from("users", "users")
|
||||||
.where("ref is null")
|
.where("users.ref is null")
|
||||||
.getMany();
|
.getMany();
|
||||||
userList.forEach(u => u.ref = makeId());
|
userList.forEach(u => u.ref = makeId());
|
||||||
await queryRunner.manager.save(userList, {chunk: 300});
|
|
||||||
|
const userChunks = chunk(userList, 300);
|
||||||
|
for (const users of userChunks) {
|
||||||
|
await queryRunner.connection.transaction(async manager => {
|
||||||
|
const queries = users.map((user: any, _index: number, _array: any[]) => {
|
||||||
|
return queryRunner.manager.update("users", user.id, user);
|
||||||
|
});
|
||||||
|
await Promise.all(queries);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Mark column as unique and non-nullable.
|
// Mark column as unique and non-nullable.
|
||||||
const users = (await queryRunner.getTable('users'))!;
|
const users = (await queryRunner.getTable('users'))!;
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user