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]
|
||||
# Allows you to run this workflow manually from the Actions tab
|
||||
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:
|
||||
push_to_registry:
|
||||
name: Push Docker image to Docker Hub
|
||||
name: Push Docker images to Docker Hub
|
||||
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:
|
||||
- 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
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
${{ github.repository_owner }}/grist
|
||||
${{ github.repository_owner }}/${{ matrix.image.name }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
stable
|
||||
${{ env.TAG }}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Push to Docker Hub
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
@ -44,3 +75,19 @@ jobs:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
cache-from: type=gha
|
||||
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
|
||||
- cron: '41 5 * * *'
|
||||
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:
|
||||
push_to_registry:
|
||||
@ -17,56 +48,131 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.9]
|
||||
python-version: [3.11]
|
||||
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:
|
||||
- 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
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
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
|
||||
uses: docker/setup-qemu-action@v1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
- name: Prepare image but do not push it yet
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
load: true
|
||||
tags: ${{ github.repository_owner }}/grist:latest
|
||||
tags: ${{ env.DOCKER_HUB_OWNER }}/${{ matrix.image.name }}:${{ env.TAG }}
|
||||
cache-from: type=gha
|
||||
build-contexts: ext=ext
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }} for testing
|
||||
if: ${{ !inputs.disable_tests }}
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }} for testing - maybe not needed
|
||||
if: ${{ !inputs.disable_tests }}
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install Python packages
|
||||
if: ${{ !inputs.disable_tests }}
|
||||
run: |
|
||||
pip install virtualenv
|
||||
yarn run install:python
|
||||
|
||||
- name: Install Node.js packages
|
||||
if: ${{ !inputs.disable_tests }}
|
||||
run: yarn install
|
||||
|
||||
- name: Disable the ext/ directory
|
||||
if: ${{ !inputs.disable_tests }}
|
||||
run: mv ext/ ext-disabled/
|
||||
|
||||
- name: Build Node.js code
|
||||
if: ${{ !inputs.disable_tests }}
|
||||
run: yarn run build:prod
|
||||
|
||||
- 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
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Push to Docker Hub
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
platforms: ${{ env.PLATFORMS }}
|
||||
push: true
|
||||
tags: ${{ github.repository_owner }}/grist:latest
|
||||
tags: ${{ env.DOCKER_HUB_OWNER }}/${{ matrix.image.name }}:${{ env.TAG }}
|
||||
cache-from: type=gha
|
||||
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
|
||||
uses: ad-m/github-push-action@8407731efefc0d8f72af254c74276b7a90be36e1
|
||||
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:
|
||||
schedule:
|
||||
# Once a day, clean up jobs marked as expired
|
||||
@ -12,12 +12,12 @@ env:
|
||||
|
||||
jobs:
|
||||
clean:
|
||||
name: Clean stale deployed apps
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository_owner == 'gristlabs'
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: superfly/flyctl-actions/setup-flyctl@master
|
||||
with:
|
||||
version: 0.1.66
|
||||
- run: node buildtools/fly-deploy.js clean
|
||||
name: Clean stale deployed apps
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository_owner == 'gristlabs'
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: superfly/flyctl-actions/setup-flyctl@master
|
||||
with:
|
||||
version: 0.2.72
|
||||
- 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.
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: [3.9]
|
||||
python-version: [3.11]
|
||||
node-version: [18.x]
|
||||
tests:
|
||||
- ':lint:python:client:common:smoke:stubs:'
|
||||
@ -32,9 +32,6 @@ jobs:
|
||||
- tests: ':lint:python:client:common:smoke:'
|
||||
node-version: 18.x
|
||||
python-version: '3.10'
|
||||
- tests: ':lint:python:client:common:smoke:'
|
||||
node-version: 18.x
|
||||
python-version: '3.11'
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
|
8
.gitignore
vendored
8
.gitignore
vendored
@ -80,3 +80,11 @@ xunit.xml
|
||||
.clipboard.lock
|
||||
|
||||
**/_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)
|
||||
- [write tutorials and user documentation](https://github.com/gristlabs/grist-help?tab=readme-ov-file#grist-help-center)
|
||||
- [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> .
|
||||
## 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
|
||||
################################################################################
|
||||
|
||||
FROM node:18-buster as builder
|
||||
FROM node:18-buster AS builder
|
||||
|
||||
# Install all node dependencies.
|
||||
WORKDIR /grist
|
||||
@ -46,7 +46,7 @@ RUN \
|
||||
################################################################################
|
||||
|
||||
# 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.
|
||||
ADD sandbox/requirements.txt requirements.txt
|
||||
@ -66,7 +66,7 @@ RUN \
|
||||
# Fetch gvisor-based sandbox. Note, to enable it to run within default
|
||||
# unprivileged docker, layers of protection that require privilege have
|
||||
# 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
|
||||
@ -122,6 +122,15 @@ RUN \
|
||||
mv /grist/static-built/* /grist/static && \
|
||||
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
|
||||
|
||||
# Set some default environment variables to give a setup that works out of the box when
|
||||
@ -151,5 +160,5 @@ ENV \
|
||||
|
||||
EXPOSE 8484
|
||||
|
||||
ENTRYPOINT ["/usr/bin/tini", "-s", "--"]
|
||||
ENTRYPOINT ["./sandbox/docker_entrypoint.sh"]
|
||||
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 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
|
||||
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
|
||||
[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
|
||||
|
||||
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.
|
||||
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/">
|
||||
<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_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_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 |
|
||||
| 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 |
|
||||
| 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_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 ⚠️. |
|
||||
@ -448,7 +466,7 @@ Then, you can run the main test suite like so:
|
||||
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
|
||||
|
@ -293,6 +293,25 @@ function initialize(appModel: AppModel) {
|
||||
|
||||
function requestInterceptor(request: SwaggerUI.Request) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
@ -78,6 +78,9 @@ export function isNumericLike(col: ColumnRec, use: UseCB = unwrap) {
|
||||
return ['Numeric', 'Int', 'Any'].includes(colType);
|
||||
}
|
||||
|
||||
function isCategoryType(pureType: string): boolean {
|
||||
return !['Numeric', 'Int', 'Any', 'Date', 'DateTime'].includes(pureType);
|
||||
}
|
||||
|
||||
interface ChartOptions {
|
||||
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.
|
||||
interface Series {
|
||||
label: string; // Corresponds to the column name.
|
||||
group?: Datum; // The group value, when grouped.
|
||||
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.
|
||||
}
|
||||
|
||||
@ -273,6 +277,7 @@ export class ChartView extends Disposable {
|
||||
const pureType = field.displayColModel().pureType();
|
||||
const fullGetter = (pureType === 'Date' || pureType === 'DateTime') ? dateGetter(getter) : getter;
|
||||
return {
|
||||
pureType,
|
||||
label: field.label(),
|
||||
values: rowIds.map(fullGetter),
|
||||
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} = {
|
||||
// TODO There is a lot of code duplication across chart types. Some refactoring is in order.
|
||||
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 {
|
||||
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 {
|
||||
border: none;
|
||||
height: 100%;
|
||||
@ -12,7 +22,6 @@ iframe.custom_view {
|
||||
.custom_view_no_mapping {
|
||||
padding: 15px;
|
||||
margin: 15px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
@ -22,10 +22,6 @@ interface Props {
|
||||
* Actual element to put into the editor. This is the main content of the editor.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
@ -75,22 +71,6 @@ export function buildEditor(props: Props, ...args: IDomArgs<HTMLElement>) {
|
||||
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 dragBelow = 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),
|
||||
|
||||
// Select on click.
|
||||
dom.on('click', onClick),
|
||||
dom.on('click', (ev) => {
|
||||
stopEvent(ev);
|
||||
box.view.selectedBox.set(box);
|
||||
}),
|
||||
|
||||
// Attach context menu.
|
||||
buildMenu({
|
||||
@ -122,6 +105,15 @@ export function buildEditor(props: Props, ...args: IDomArgs<HTMLElement>) {
|
||||
// And now drag and drop support.
|
||||
{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.
|
||||
// TODO: this might be very sofisticated in the future.
|
||||
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 {
|
||||
const box = this;
|
||||
const editMode = box.edit;
|
||||
let element: HTMLElement;
|
||||
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
|
||||
@ -44,18 +43,21 @@ export class ParagraphModel extends BoxModel {
|
||||
this.cssClass ? dom.cls(this.cssClass, not(editMode)) : null,
|
||||
dom.maybe(editMode, () => {
|
||||
const draft = Observable.create(null, text.get() || '');
|
||||
setTimeout(() => element?.focus(), 10);
|
||||
return [
|
||||
element = cssTextArea(draft, {autoGrow: true, onInput: true},
|
||||
cssTextArea.cls('-edit', editMode),
|
||||
css.saveControls(editMode, (ok) => {
|
||||
if (ok && editMode.get()) {
|
||||
text.set(draft.get());
|
||||
this.save().catch(reportError);
|
||||
}
|
||||
})
|
||||
),
|
||||
];
|
||||
return cssTextArea(draft, {autoGrow: true, onInput: true},
|
||||
cssTextArea.cls('-edit', editMode),
|
||||
(elem) => {
|
||||
setTimeout(() => {
|
||||
elem.focus();
|
||||
elem.setSelectionRange(elem.value.length, elem.value.length);
|
||||
}, 10);
|
||||
},
|
||||
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 './Columns';
|
||||
export * from './Submit';
|
||||
export * from './Label';
|
||||
|
||||
export function defaultElement(type: FormLayoutNodeType): FormLayoutNode {
|
||||
switch(type) {
|
||||
|
@ -1,3 +1,4 @@
|
||||
import type {App} from 'app/client/ui/App';
|
||||
import {textarea} from 'app/client/ui/inputs';
|
||||
import {sanitizeHTML} from 'app/client/ui/sanitizeHTML';
|
||||
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) => {
|
||||
if (!editMode.isDisposed() && editMode.get()) {
|
||||
save(true);
|
||||
editMode.set(false);
|
||||
dom.create((owner) => {
|
||||
// Whenever focus returns to the Clipboard component, close the editor by saving the value.
|
||||
function saveEdit() {
|
||||
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 */
|
||||
width: 4rem; /* matches row_num width */
|
||||
width: 52px; /* matches row_num width */
|
||||
flex: none;
|
||||
}
|
||||
|
||||
@ -68,7 +68,7 @@
|
||||
position: sticky;
|
||||
left: 0px;
|
||||
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;
|
||||
|
||||
border-bottom: 1px solid var(--grist-theme-table-header-border, lightgray);
|
||||
@ -131,7 +131,7 @@
|
||||
border-left: 1px solid var(--grist-color-dark-grey);
|
||||
}
|
||||
.print-widget .gridview_data_header {
|
||||
padding-left: 4rem !important;
|
||||
padding-left: 52px !important;
|
||||
}
|
||||
.print-widget .gridview_data_pane .print-all-rows {
|
||||
display: table-row-group;
|
||||
@ -155,7 +155,7 @@
|
||||
}
|
||||
|
||||
.gridview_data_corner_overlay {
|
||||
width: 4rem;
|
||||
width: 52px;
|
||||
height: calc(var(--gridview-header-height) + 1px); /* matches gridview_data_header height (+border) */
|
||||
top: 1px; /* go under 1px border on scrollpane */
|
||||
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,
|
||||
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);
|
||||
/* 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%);
|
||||
@ -189,7 +189,7 @@
|
||||
.scroll_shadow_frozen {
|
||||
height: 100%;
|
||||
width: 0px;
|
||||
left: 4em;
|
||||
left: 52px;
|
||||
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%);
|
||||
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
|
||||
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);
|
||||
z-index: 30;
|
||||
user-select: none;
|
||||
@ -226,7 +226,7 @@
|
||||
}
|
||||
|
||||
.gridview_header_backdrop_left {
|
||||
width: calc(4rem + 1px); /* Matches rowid width (+border) */
|
||||
width: calc(52px + 1px); /* Matches rowid width (+border) */
|
||||
height:100%;
|
||||
top: 1px; /* go under 1px border on scrollpane */
|
||||
z-index: 10;
|
||||
@ -311,7 +311,7 @@
|
||||
/* style header and a data field */
|
||||
.record .field.frozen {
|
||||
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;
|
||||
}
|
||||
/* 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 ()
|
||||
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;
|
||||
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
if (!isPreview) {
|
||||
// Disable summaries in import previews, for now.
|
||||
if (!isPreview && !this.gristDoc.comparison) {
|
||||
this.selectionSummary = SelectionSummary.create(this,
|
||||
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 {getUserOrgPrefObs, getUserOrgPrefsObs, markAsSeen} from 'app/client/models/UserPrefs';
|
||||
import {App} from 'app/client/ui/App';
|
||||
import {showCustomWidgetGallery} from 'app/client/ui/CustomWidgetGallery';
|
||||
import {DocHistory} from 'app/client/ui/DocHistory';
|
||||
import {startDocTour} from "app/client/ui/DocTour";
|
||||
import {DocTutorial} from 'app/client/ui/DocTutorial';
|
||||
@ -138,6 +139,13 @@ interface PopupSectionOptions {
|
||||
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 {
|
||||
public docModel: DocModel;
|
||||
public viewModel: ViewRec;
|
||||
@ -894,38 +902,27 @@ export class GristDoc extends DisposableWithEvents {
|
||||
/**
|
||||
* Adds a view section described by val to the current page.
|
||||
*/
|
||||
public async addWidgetToPage(val: IPageWidget) {
|
||||
const docData = this.docModel.docData;
|
||||
const viewName = this.viewModel.name.peek();
|
||||
public async addWidgetToPage(widget: IPageWidget) {
|
||||
const {table, type} = widget;
|
||||
let tableId: string | null | undefined;
|
||||
if (val.table === 'New Table') {
|
||||
if (table === 'New Table') {
|
||||
tableId = await this._promptForName();
|
||||
if (tableId === undefined) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const widgetType = getTelemetryWidgetTypeFromPageWidget(val);
|
||||
logTelemetryEvent('addedWidget', {full: {docIdDigest: this.docId(), widgetType}});
|
||||
if (val.link !== NoLink) {
|
||||
logTelemetryEvent('linkedWidget', {full: {docIdDigest: this.docId(), widgetType}});
|
||||
if (type === 'custom') {
|
||||
return showCustomWidgetGallery(this, {
|
||||
addWidget: () => this._addWidgetToPage(widget, tableId),
|
||||
});
|
||||
}
|
||||
|
||||
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}),
|
||||
() => this.addWidgetToPageImpl(val, tableId ?? null)
|
||||
() => this._addWidgetToPage(widget, tableId ?? null)
|
||||
);
|
||||
|
||||
// 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;
|
||||
return sectionRef;
|
||||
}
|
||||
|
||||
public async onCreateForm() {
|
||||
@ -941,80 +938,31 @@ export class GristDoc extends DisposableWithEvents {
|
||||
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`.
|
||||
*/
|
||||
public async addNewPage(val: IPageWidget) {
|
||||
logTelemetryEvent('addedPage', {full: {docIdDigest: this.docId()}});
|
||||
logTelemetryEvent('addedWidget', {
|
||||
full: {
|
||||
docIdDigest: this.docId(),
|
||||
widgetType: getTelemetryWidgetTypeFromPageWidget(val),
|
||||
},
|
||||
});
|
||||
|
||||
let viewRef: IDocPage;
|
||||
let sectionRef: number | undefined;
|
||||
await this.docData.bundleActions('Add new page', async () => {
|
||||
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);
|
||||
const {table, type} = val;
|
||||
let tableId: string | null | undefined;
|
||||
if (table === 'New Table') {
|
||||
tableId = await this._promptForName();
|
||||
if (tableId === undefined) { return; }
|
||||
}
|
||||
if (type === 'custom') {
|
||||
return showCustomWidgetGallery(this, {
|
||||
addWidget: () => this._addPage(val, tableId ?? null) as Promise<{
|
||||
viewRef: number;
|
||||
sectionRef: number;
|
||||
}>,
|
||||
});
|
||||
}
|
||||
|
||||
this._maybeShowEditCardLayoutTip(val.type).catch(reportError);
|
||||
|
||||
if (AttachedCustomWidgets.guard(val.type)) {
|
||||
this._handleNewAttachedCustomWidget(val.type).catch(reportError);
|
||||
}
|
||||
const {sectionRef, viewRef} = await this.docData.bundleActions(
|
||||
'Add new page',
|
||||
() => this._addPage(val, tableId ?? null)
|
||||
);
|
||||
await this._focus({sectionRef, viewRef});
|
||||
this._showNewWidgetPopups(type);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1460,6 +1408,90 @@ export class GristDoc extends DisposableWithEvents {
|
||||
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).
|
||||
*/
|
||||
@ -1718,7 +1750,7 @@ export class GristDoc extends DisposableWithEvents {
|
||||
const sectionId = section.id();
|
||||
|
||||
// create a new section
|
||||
const sectionCreationResult = await this.addWidgetToPageImpl(newVal);
|
||||
const sectionCreationResult = await this._addWidgetToPage(newVal, null, {focus: false, popups: false});
|
||||
|
||||
// update section name
|
||||
const newSection: ViewSectionRec = docModel.viewSections.getRowModel(sectionCreationResult.sectionRef);
|
||||
|
@ -195,7 +195,7 @@ export class LayoutTray extends DisposableWithEvents {
|
||||
box.dispose();
|
||||
|
||||
// And ask the viewLayout to save the specs.
|
||||
viewLayout.saveLayoutSpec();
|
||||
viewLayout.saveLayoutSpec().catch(reportError);
|
||||
},
|
||||
restoreSection: () => {
|
||||
// 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.activeSectionId(leafId);
|
||||
viewLayout.saveLayoutSpec();
|
||||
viewLayout.saveLayoutSpec().catch(reportError);
|
||||
},
|
||||
// Delete collapsed section.
|
||||
deleteCollapsedSection: () => {
|
||||
deleteCollapsedSection: async () => {
|
||||
// This section is still in the view (but not in the layout). So we can just remove it.
|
||||
const leafId = viewLayout.viewModel.activeCollapsedSectionId();
|
||||
if (!leafId) { return; }
|
||||
this.viewLayout.removeViewSection(leafId);
|
||||
// We need to manually update the layout. Main layout editor doesn't care about missing sections.
|
||||
// but we can't afford that. Without removing it, user can add another section that will be collapsed
|
||||
// from the start, as the id will be the same as the one we just removed.
|
||||
const currentSpec = viewLayout.viewModel.layoutSpecObj();
|
||||
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));
|
||||
viewLayout.saveLayoutSpec(currentSpec);
|
||||
|
||||
viewLayout.docModel.docData.bundleActions('removing section', async () => {
|
||||
if (!await this.viewLayout.removeViewSection(leafId)) {
|
||||
return;
|
||||
}
|
||||
// We need to manually update the layout. Main layout editor doesn't care about missing sections.
|
||||
// but we can't afford that. Without removing it, user can add another section that will be collapsed
|
||||
// from the start, as the id will be the same as the one we just removed.
|
||||
const currentSpec = viewLayout.viewModel.layoutSpecObj();
|
||||
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));
|
||||
@ -843,7 +848,7 @@ class ExternalLeaf extends Disposable implements Dropped {
|
||||
// and the section won't be created on time.
|
||||
this.model.viewLayout.layoutEditor.triggerUserEditStop();
|
||||
// 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 {isNarrowScreen, mediaSmall, testId, theme} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {ISaveModalOptions, saveModal} from 'app/client/ui2018/modals';
|
||||
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 {
|
||||
Computed,
|
||||
@ -39,6 +44,8 @@ import * as ko from 'knockout';
|
||||
import debounce from 'lodash/debounce';
|
||||
import * as _ from 'underscore';
|
||||
|
||||
const t = makeT('ViewLayout');
|
||||
|
||||
// tslint:disable:no-console
|
||||
|
||||
const viewSectionTypes: {[key: string]: any} = {
|
||||
@ -125,7 +132,7 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
|
||||
this.listenTo(this.layout, 'layoutUserEditStop', () => {
|
||||
this.isResizing.set(false);
|
||||
this.layoutSaveDelay.schedule(1000, () => {
|
||||
this.saveLayoutSpec();
|
||||
this.saveLayoutSpec().catch(reportError);
|
||||
});
|
||||
});
|
||||
|
||||
@ -187,7 +194,7 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
|
||||
}));
|
||||
|
||||
const commandGroup = {
|
||||
deleteSection: () => { this.removeViewSection(this.viewModel.activeSectionId()); },
|
||||
deleteSection: () => { this.removeViewSection(this.viewModel.activeSectionId()).catch(reportError); },
|
||||
nextSection: () => { this._otherSection(+1); },
|
||||
prevSection: () => { this._otherSection(-1); },
|
||||
printSection: () => { printViewSection(this.layout, this.viewModel.activeSection()).catch(reportError); },
|
||||
@ -265,31 +272,83 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
|
||||
this._savePending.set(false);
|
||||
// Cancel the automatic delay.
|
||||
this.layoutSaveDelay.cancel();
|
||||
if (!this.layout) { return; }
|
||||
if (!this.layout) { return Promise.resolve(); }
|
||||
// Only save layout changes when the document isn't read-only.
|
||||
if (!this.gristDoc.isReadonly.get()) {
|
||||
if (!specs) {
|
||||
specs = this.layout.getLayoutSpec();
|
||||
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();
|
||||
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.
|
||||
public removeViewSection(viewSectionRowId: number) {
|
||||
/**
|
||||
* Removes a view section from the current view. Should only be called if there is more than
|
||||
* 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);
|
||||
const viewSection = this.viewModel.viewSections().all().find(s => s.getRowId() === viewSectionRowId);
|
||||
if (!viewSection) {
|
||||
throw new Error(`Section not found: ${viewSectionRowId}`);
|
||||
}
|
||||
const tableId = viewSection.table.peek().tableId.peek();
|
||||
|
||||
const widgetType = getTelemetryWidgetTypeFromVS(viewSection);
|
||||
logTelemetryEvent('deletedWidget', {full: {docIdDigest: this.gristDoc.docId(), widgetType}});
|
||||
// Check if this is a UserTable (not summary) and if so, if it is available on any other page
|
||||
// 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) {
|
||||
@ -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', `
|
||||
@media screen and ${mediaSmall} {
|
||||
&-active, &-inactive {
|
||||
|
@ -223,10 +223,15 @@ export class WidgetFrame extends DisposableWithEvents {
|
||||
|
||||
// Appends access level to query 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;
|
||||
}
|
||||
const urlObj = new URL(url);
|
||||
urlObj.searchParams.append('access', this._options.access);
|
||||
urlObj.searchParams.append('readonly', String(this._options.readonly));
|
||||
// Append user and document preferences to query string.
|
||||
|
@ -25,7 +25,6 @@ export type CommandName =
|
||||
| 'expandSection'
|
||||
| 'leftPanelOpen'
|
||||
| 'rightPanelOpen'
|
||||
| 'videoTourToolsOpen'
|
||||
| 'cursorDown'
|
||||
| 'cursorUp'
|
||||
| 'cursorRight'
|
||||
@ -269,11 +268,6 @@ export const groups: CommendGroupDef[] = [{
|
||||
keys: [],
|
||||
desc: 'Shortcut to open the right panel',
|
||||
},
|
||||
{
|
||||
name: 'videoTourToolsOpen',
|
||||
keys: [],
|
||||
desc: 'Shortcut to open video tour from home left panel',
|
||||
},
|
||||
{
|
||||
name: 'activateAssistant',
|
||||
keys: [],
|
||||
|
@ -134,6 +134,10 @@ div:hover > .kf_tooltip {
|
||||
z-index: 11;
|
||||
}
|
||||
|
||||
.kf_prompt_content:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.kf_draggable {
|
||||
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[]>;
|
||||
users: Observable<FullUser[]>;
|
||||
|
||||
customWidgets: Observable<ICustomWidget[]|null>;
|
||||
|
||||
// Reinitialize the app. This is called when org or user changes.
|
||||
initialize(): void;
|
||||
|
||||
@ -162,26 +160,26 @@ export class TopAppModelImpl extends Disposable implements TopAppModel {
|
||||
public readonly orgs = Observable.create<Organization[]>(this, []);
|
||||
public readonly users = Observable.create<FullUser[]>(this, []);
|
||||
public readonly plugins: LocalPlugin[] = [];
|
||||
public readonly customWidgets = Observable.create<ICustomWidget[]|null>(this, null);
|
||||
private readonly _gristConfig?: GristLoadConfig;
|
||||
private readonly _gristConfig? = this._window.gristConfig;
|
||||
// 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
|
||||
// up new widgets - that seems ok.
|
||||
private readonly _widgets: AsyncCreate<ICustomWidget[]>;
|
||||
|
||||
constructor(window: {gristConfig?: GristLoadConfig},
|
||||
constructor(private _window: {gristConfig?: GristLoadConfig},
|
||||
public readonly api: UserAPI = newUserAPIImpl(),
|
||||
public readonly options: TopAppModelOptions = {}
|
||||
) {
|
||||
super();
|
||||
setErrorNotifier(this.notifier);
|
||||
this.isSingleOrg = Boolean(window.gristConfig && window.gristConfig.singleOrg);
|
||||
this.productFlavor = getFlavor(window.gristConfig && window.gristConfig.org);
|
||||
this._gristConfig = window.gristConfig;
|
||||
this.isSingleOrg = Boolean(this._gristConfig?.singleOrg);
|
||||
this.productFlavor = getFlavor(this._gristConfig?.org);
|
||||
this._widgets = new AsyncCreate<ICustomWidget[]>(async () => {
|
||||
const widgets = this.options.useApi === false ? [] : await this.api.getWidgets();
|
||||
this.customWidgets.set(widgets);
|
||||
return widgets;
|
||||
if (this.options.useApi === false || !this._gristConfig?.enableWidgetRepository) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return await this.api.getWidgets();
|
||||
});
|
||||
|
||||
// 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() {
|
||||
console.log("testReloadWidgets");
|
||||
this._widgets.clear();
|
||||
this.customWidgets.set(null);
|
||||
console.log("testReloadWidgets cleared and nulled");
|
||||
console.log("testReloadWidgets cleared");
|
||||
const result = await this.getWidgets();
|
||||
console.log("testReloadWidgets got", {result});
|
||||
}
|
||||
@ -392,6 +389,10 @@ export class AppModelImpl extends Disposable implements AppModel {
|
||||
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._updateLastVisitedOrgDomain(s, orgs);
|
||||
}));
|
||||
|
@ -223,16 +223,21 @@ export class DocModel {
|
||||
this.allPages = ko.computed(() => allPages.all());
|
||||
this.menuPages = ko.computed(() => {
|
||||
const pagesToShow = this.allPages().filter(p => !p.isSpecial()).sort((a, b) => a.pagePos() - b.pagePos());
|
||||
// Helper to find all children of a page.
|
||||
const children = memoize((page: PageRec) => {
|
||||
const following = pagesToShow.slice(pagesToShow.indexOf(page) + 1);
|
||||
const firstOutside = following.findIndex(p => p.indentation() <= page.indentation());
|
||||
return firstOutside >= 0 ? following.slice(0, firstOutside) : following;
|
||||
const parent = memoize((page: PageRec) => {
|
||||
const myIdentation = page.indentation();
|
||||
if (myIdentation === 0) { return null; }
|
||||
const idx = pagesToShow.indexOf(page);
|
||||
// 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.
|
||||
// In that case, we won't show it at all.
|
||||
const hide = memoize((page: PageRec): boolean => page.isCensored() && children(page).every(p => hide(p)));
|
||||
return pagesToShow.filter(p => !hide(p));
|
||||
const ancestors = memoize((page: PageRec): PageRec[] => {
|
||||
const anc = parent(page);
|
||||
return anc ? [anc, ...ancestors(anc)] : [];
|
||||
});
|
||||
// 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()));
|
||||
|
||||
|
@ -7,7 +7,7 @@ import {AppModel, reportError} from 'app/client/models/AppModel';
|
||||
import {reportMessage, UserError} from 'app/client/models/errors';
|
||||
import {urlState} from 'app/client/models/gristUrlState';
|
||||
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 {SortPref, UserOrgPrefs, ViewPref} from 'app/common/Prefs';
|
||||
import * as roles from 'app/common/roles';
|
||||
@ -59,6 +59,8 @@ export interface HomeModel {
|
||||
|
||||
shouldShowAddNewTip: Observable<boolean>;
|
||||
|
||||
onboardingTutorial: Observable<Document|null>;
|
||||
|
||||
createWorkspace(name: string): Promise<void>;
|
||||
renameWorkspace(id: number, name: string): 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,
|
||||
!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);
|
||||
|
||||
constructor(private _app: AppModel, clientScope: ClientScope) {
|
||||
@ -176,6 +180,8 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings
|
||||
this.importSources.set(importSources);
|
||||
|
||||
this._app.refreshOrgUsage().catch(reportError);
|
||||
|
||||
this._loadWelcomeTutorial().catch(reportError);
|
||||
}
|
||||
|
||||
// Accessor for the AppModel containing this HomeModel.
|
||||
@ -370,6 +376,28 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings
|
||||
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]) {
|
||||
const org = this._app.currentOrg;
|
||||
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.
|
||||
// Note: Some hidden tables can still be visible on RawData view.
|
||||
isHidden: ko.Computed<boolean>;
|
||||
isSummary: ko.Computed<boolean>;
|
||||
|
||||
tableColor: string;
|
||||
disableAddRemoveRows: ko.Computed<boolean>;
|
||||
@ -68,6 +69,8 @@ export function createTableRec(this: TableRec, docModel: DocModel): void {
|
||||
this.primaryTableId = ko.pureComputed(() =>
|
||||
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.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.
|
||||
isRaw: ko.Computed<boolean>;
|
||||
|
||||
tableRecordCard: ko.Computed<ViewSectionRec>
|
||||
/** Is this table card viewsection (the one available after pressing spacebar) */
|
||||
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. */
|
||||
disabled: modelUtil.KoSaveableObservable<boolean>;
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
import {AppModel} from 'app/client/models/AppModel';
|
||||
import {DocPageModel} from 'app/client/models/DocPageModel';
|
||||
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 {createUserImage} from 'app/client/ui/UserImage';
|
||||
import * as viewport from 'app/client/ui/viewport';
|
||||
|
@ -1,13 +1,7 @@
|
||||
import {HomeModel} from 'app/client/models/HomeModel';
|
||||
import {shouldShowWelcomeQuestions} from 'app/client/ui/WelcomeQuestions';
|
||||
|
||||
export function attachAddNewTip(home: HomeModel): (el: Element) => void {
|
||||
return () => {
|
||||
const {app: {userPrefsObs}} = home;
|
||||
if (shouldShowWelcomeQuestions(userPrefsObs)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldShowAddNewTip(home)) {
|
||||
showAddNewTip(home);
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ import {AppHeader} from 'app/client/ui/AppHeader';
|
||||
import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon';
|
||||
import {pagePanels} from 'app/client/ui/PagePanels';
|
||||
import {SupportGristPage} from 'app/client/ui/SupportGristPage';
|
||||
import {ToggleEnterpriseWidget} from 'app/client/ui/ToggleEnterpriseWidget';
|
||||
import {createTopBarHome} from 'app/client/ui/TopBar';
|
||||
import {cssBreadcrumbs, separator} from 'app/client/ui2018/breadcrumbs';
|
||||
import {basicButton} from 'app/client/ui2018/buttons';
|
||||
@ -24,17 +25,14 @@ import * as version from 'app/common/version';
|
||||
import {Computed, Disposable, dom, IDisposable,
|
||||
IDisposableOwner, MultiHolder, Observable, styled} from 'grainjs';
|
||||
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');
|
||||
|
||||
// Translated "Admin Panel" name, made available to other modules.
|
||||
export function getAdminPanelName() {
|
||||
return t("Admin Panel");
|
||||
}
|
||||
|
||||
export class AdminPanel extends Disposable {
|
||||
private _supportGrist = SupportGristPage.create(this, this._appModel);
|
||||
private _toggleEnterprise = ToggleEnterpriseWidget.create(this);
|
||||
private readonly _installAPI: InstallAPI = new InstallAPIImpl(getHomeUrl());
|
||||
private _checks: AdminChecks;
|
||||
|
||||
@ -145,6 +143,13 @@ Please log in as an administrator.`)),
|
||||
description: t('Current authentication method'),
|
||||
value: this._buildAuthenticationDisplay(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'), [
|
||||
@ -154,6 +159,7 @@ Please log in as an administrator.`)),
|
||||
description: t('Current version of Grist'),
|
||||
value: cssValueLabel(`Version ${version.version}`),
|
||||
}),
|
||||
this._maybeAddEnterpriseToggle(),
|
||||
this._buildUpdates(owner),
|
||||
]),
|
||||
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) {
|
||||
return dom.domComputed(
|
||||
use => {
|
||||
@ -241,6 +260,27 @@ We recommend enabling one of these if Grist is accessible over the network or be
|
||||
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) {
|
||||
// We can be in those states:
|
||||
enum State {
|
||||
@ -472,7 +512,11 @@ to multiple people.');
|
||||
return dom.domComputed(
|
||||
use => [
|
||||
...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;
|
||||
if (!show) { return null; }
|
||||
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 {createHomeLeftPane} from 'app/client/ui/HomeLeftPane';
|
||||
import {buildSnackbarDom} from 'app/client/ui/NotifyUI';
|
||||
import {OnboardingPage, shouldShowOnboardingPage} from 'app/client/ui/OnboardingPage';
|
||||
import {pagePanels} from 'app/client/ui/PagePanels';
|
||||
import {RightPanel} from 'app/client/ui/RightPanel';
|
||||
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) {
|
||||
if (shouldShowOnboardingPage(appModel.userPrefsObs)) {
|
||||
return dom.create(OnboardingPage, appModel);
|
||||
}
|
||||
|
||||
const pageModel = HomeModelImpl.create(owner, appModel, app.clientScope);
|
||||
const leftPanelOpen = Observable.create(owner, true);
|
||||
|
||||
|
@ -1,11 +1,22 @@
|
||||
import {allCommands} from 'app/client/components/commands';
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {makeTestId} from 'app/client/lib/domUtils';
|
||||
import {FocusLayer} from 'app/client/lib/FocusLayer';
|
||||
import * as kf from 'app/client/lib/koForm';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {localStorageBoolObs} from 'app/client/lib/localStorageObs';
|
||||
import {ColumnToMapImpl} from 'app/client/models/ColumnToMap';
|
||||
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 {hoverTooltip} from 'app/client/ui/tooltips';
|
||||
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 {textInput} from 'app/client/ui2018/editableLabel';
|
||||
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 {AccessLevel, ICustomWidget, isSatisfied, matchWidget} from 'app/common/CustomWidget';
|
||||
import {GristLoadConfig} from 'app/common/gristUrls';
|
||||
import {not, unwrap} from 'app/common/gutil';
|
||||
import {
|
||||
bundleChanges,
|
||||
Computed,
|
||||
Disposable,
|
||||
dom,
|
||||
DomContents,
|
||||
fromKo,
|
||||
MultiHolder,
|
||||
Observable,
|
||||
@ -33,22 +43,8 @@ import {
|
||||
|
||||
const t = makeT('CustomSectionConfig');
|
||||
|
||||
// Custom URL widget id - used as mock id for selectbox.
|
||||
const CUSTOM_ID = 'custom';
|
||||
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 {
|
||||
constructor(
|
||||
private _value: Observable<number|number[]|null>,
|
||||
@ -319,17 +315,17 @@ class ColumnListPicker extends Disposable {
|
||||
}
|
||||
|
||||
class CustomSectionConfigurationConfig extends Disposable{
|
||||
// Does widget has custom configuration.
|
||||
private readonly _hasConfiguration: Computed<boolean>;
|
||||
private readonly _hasConfiguration = Computed.create(this, use =>
|
||||
Boolean(use(this._section.hasCustomOptions) || use(this._section.columnsToMap)));
|
||||
|
||||
constructor(private _section: ViewSectionRec, private _gristDoc: GristDoc) {
|
||||
super();
|
||||
this._hasConfiguration = Computed.create(this, use => use(_section.hasCustomOptions));
|
||||
}
|
||||
|
||||
public buildDom() {
|
||||
// Show prompt, when desired access level is different from actual one.
|
||||
return dom(
|
||||
'div',
|
||||
dom.maybe(this._hasConfiguration, () =>
|
||||
return dom.maybe(this._hasConfiguration, () => [
|
||||
cssSeparator(),
|
||||
dom.maybe(this._section.hasCustomOptions, () =>
|
||||
cssSection(
|
||||
textButton(
|
||||
t("Open configuration"),
|
||||
@ -363,7 +359,7 @@ class CustomSectionConfigurationConfig extends Disposable{
|
||||
: dom.create(ColumnPicker, m.value, m.column, this._section)),
|
||||
);
|
||||
})
|
||||
);
|
||||
]);
|
||||
}
|
||||
private _openConfiguration(): void {
|
||||
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 {
|
||||
protected _customSectionConfigurationConfig = new CustomSectionConfigurationConfig(
|
||||
this._section, this._gristDoc);
|
||||
|
||||
protected _customSectionConfigurationConfig: CustomSectionConfigurationConfig;
|
||||
// Holds all available widget definitions.
|
||||
private _widgets: Observable<ICustomWidget[]|null>;
|
||||
// Holds selected option (either custom string or a widgetId).
|
||||
private readonly _selectedId: Computed<string | null>;
|
||||
// Holds custom widget URL.
|
||||
private readonly _url: Computed<string>;
|
||||
// Enable or disable widget repository.
|
||||
private readonly _canSelect: boolean = true;
|
||||
// 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 _widgetId = Computed.create(this, use => {
|
||||
// Stored in one of two places, depending on age of document.
|
||||
const widgetId = use(this._section.customDef.widgetId) ||
|
||||
use(this._section.customDef.widgetDef)?.widgetId;
|
||||
if (widgetId) {
|
||||
const pluginId = use(this._section.customDef.pluginId);
|
||||
return (pluginId || '') + ':' + widgetId;
|
||||
} else {
|
||||
return CUSTOM_URL_WIDGET_ID;
|
||||
}
|
||||
});
|
||||
|
||||
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) {
|
||||
super();
|
||||
this._customSectionConfigurationConfig = new CustomSectionConfigurationConfig(_section, _gristDoc);
|
||||
|
||||
// Test if we can offer widget list.
|
||||
const gristConfig: GristLoadConfig = (window as any).gristConfig || {};
|
||||
this._canSelect = gristConfig.enableWidgetRepository ?? true;
|
||||
const userId = this._gristDoc.appModel.currentUser?.id ?? 0;
|
||||
this._widgetDetailsExpanded = this.autoDispose(localStorageBoolObs(
|
||||
`u:${userId};customWidgetDetailsExpanded`,
|
||||
true
|
||||
));
|
||||
|
||||
// Array of available widgets - will be updated asynchronously.
|
||||
this._widgets = _gristDoc.app.topAppModel.customWidgets;
|
||||
this._getWidgets().catch(reportError);
|
||||
// Request for rest of the widgets.
|
||||
this._getWidgets()
|
||||
.then(widgets => {
|
||||
if (this.isDisposed()) { return; }
|
||||
|
||||
// Selected value from the dropdown (contains widgetId or "custom" string for Custom URL)
|
||||
this._selectedId = Computed.create(this, use => {
|
||||
// widgetId could be stored in one of two places, depending on
|
||||
// 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);
|
||||
this._widgets.set(widgets);
|
||||
})
|
||||
.catch(reportError);
|
||||
|
||||
// Clear intermediate state when section changes.
|
||||
this.autoDispose(_section.id.subscribe(() => this._reject()));
|
||||
this.autoDispose(_section.id.subscribe(() => this._dismissAccessPrompt()));
|
||||
}
|
||||
|
||||
public buildDom() {
|
||||
// UI observables holder.
|
||||
const holder = new MultiHolder();
|
||||
|
||||
// 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(),
|
||||
public buildDom(): DomContents {
|
||||
return dom('div',
|
||||
this._buildWidgetSelector(),
|
||||
this._buildAccessLevelConfig(),
|
||||
this._customSectionConfigurationConfig.buildDom(),
|
||||
);
|
||||
}
|
||||
@ -661,21 +490,194 @@ export class CustomSectionConfig extends Disposable {
|
||||
}
|
||||
|
||||
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()) {
|
||||
this._currentAccess.set(this._desiredAccess.get()!);
|
||||
}
|
||||
this._reject();
|
||||
this._dismissAccessPrompt();
|
||||
}
|
||||
|
||||
private _reject() {
|
||||
private _dismissAccessPrompt() {
|
||||
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', `
|
||||
padding-left: 8px;
|
||||
padding-top: 6px;
|
||||
@ -700,12 +702,6 @@ const cssSection = styled('div', `
|
||||
margin: 16px 16px 12px 16px;
|
||||
`);
|
||||
|
||||
const cssMenu = styled('div', `
|
||||
& > li:first-child {
|
||||
border-bottom: 1px solid ${theme.menuBorder};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssAddIcon = styled(icon, `
|
||||
margin-right: 4px;
|
||||
`);
|
||||
@ -748,17 +744,9 @@ const cssAddMapping = styled('div', `
|
||||
`);
|
||||
|
||||
const cssTextInput = styled(textInput, `
|
||||
flex: 1 0 auto;
|
||||
|
||||
color: ${theme.inputFg};
|
||||
background-color: ${theme.inputBg};
|
||||
|
||||
&:disabled {
|
||||
color: ${theme.inputDisabledFg};
|
||||
background-color: ${theme.inputDisabledBg};
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: ${theme.inputPlaceholderFg};
|
||||
}
|
||||
@ -771,3 +759,62 @@ const cssDisabledSelect = styled(select, `
|
||||
const cssBlank = styled(cssOptionLabel, `
|
||||
--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 {buildHomeIntro, buildWorkspaceIntro} from 'app/client/ui/HomeIntro';
|
||||
import {buildUpgradeButton} from 'app/client/ui/ProductUpgrades';
|
||||
import {buildTutorialCard} from 'app/client/ui/TutorialCard';
|
||||
import {buildPinnedDoc, createPinnedDocs} from 'app/client/ui/PinnedDocs';
|
||||
import {shadowScroll} from 'app/client/ui/shadowScroll';
|
||||
import {makeShareDocUrl} from 'app/client/ui/ShareMenu';
|
||||
import {transition} from 'app/client/ui/transitions';
|
||||
import {shouldShowWelcomeCoachingCall, showWelcomeCoachingCall} from 'app/client/ui/WelcomeCoachingCall';
|
||||
import {shouldShowWelcomeQuestions, showWelcomeQuestions} from 'app/client/ui/WelcomeQuestions';
|
||||
import {createVideoTourTextButton} from 'app/client/ui/OpenVideoTour';
|
||||
import {buttonSelect, cssButtonSelect} from 'app/client/ui2018/buttonSelect';
|
||||
import {isNarrowScreenObs, theme} from 'app/client/ui2018/cssVars';
|
||||
import {buildOnboardingCards} from 'app/client/ui/OnboardingCards';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
||||
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 {
|
||||
return (element: Element) => {
|
||||
const {app, app: {userPrefsObs}} = home;
|
||||
if (shouldShowWelcomeQuestions(userPrefsObs)) {
|
||||
showWelcomeQuestions(userPrefsObs);
|
||||
} else if (shouldShowWelcomeCoachingCall(app)) {
|
||||
const {app} = home;
|
||||
if (shouldShowWelcomeCoachingCall(app)) {
|
||||
showWelcomeCoachingCall(element, app);
|
||||
}
|
||||
};
|
||||
@ -75,117 +72,117 @@ function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) {
|
||||
const flashDocId = observable<string|null>(null);
|
||||
const upgradeButton = buildUpgradeButton(owner, home.app);
|
||||
return css.docList( /* vbox */
|
||||
/* first line */
|
||||
dom.create(buildTutorialCard, { app: home.app }),
|
||||
/* hbox */
|
||||
css.docListContent(
|
||||
/* left column - grow 1 */
|
||||
css.docMenu(
|
||||
attachAddNewTip(home),
|
||||
/* first line */
|
||||
dom.create(buildOnboardingCards, {homeModel: home}),
|
||||
/* hbox */
|
||||
css.docListContent(
|
||||
/* left column - grow 1 */
|
||||
css.docMenu(
|
||||
attachAddNewTip(home),
|
||||
|
||||
dom.maybe(!home.app.currentFeatures?.workspaces, () => [
|
||||
css.docListHeader(t("This service is not available right now")),
|
||||
dom('span', t("(The organization needs a paid plan)")),
|
||||
]),
|
||||
dom.maybe(!home.app.currentFeatures?.workspaces, () => [
|
||||
css.docListHeader(t("This service is not available right now")),
|
||||
dom('span', t("(The organization needs a paid plan)")),
|
||||
]),
|
||||
|
||||
// currentWS and showIntro observables change together. We capture both in one domComputed call.
|
||||
dom.domComputed<[IHomePage, Workspace|undefined, boolean]>(
|
||||
(use) => [use(home.currentPage), use(home.currentWS), use(home.showIntro)],
|
||||
([page, workspace, showIntro]) => {
|
||||
const viewSettings: ViewSettings =
|
||||
page === 'trash' ? makeLocalViewSettings(home, 'trash') :
|
||||
page === 'templates' ? makeLocalViewSettings(home, 'templates') :
|
||||
workspace ? makeLocalViewSettings(home, workspace.id) :
|
||||
home;
|
||||
return [
|
||||
buildPrefs(
|
||||
viewSettings,
|
||||
// Hide the sort and view options when showing the intro.
|
||||
{hideSort: showIntro, hideView: showIntro && page === 'all'},
|
||||
['all', 'workspace'].includes(page)
|
||||
? upgradeButton.showUpgradeButton(css.upgradeButton.cls(''))
|
||||
: 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')
|
||||
// currentWS and showIntro observables change together. We capture both in one domComputed call.
|
||||
dom.domComputed<[IHomePage, Workspace|undefined, boolean]>(
|
||||
(use) => [use(home.currentPage), use(home.currentWS), use(home.showIntro)],
|
||||
([page, workspace, showIntro]) => {
|
||||
const viewSettings: ViewSettings =
|
||||
page === 'trash' ? makeLocalViewSettings(home, 'trash') :
|
||||
page === 'templates' ? makeLocalViewSettings(home, 'templates') :
|
||||
workspace ? makeLocalViewSettings(home, workspace.id) :
|
||||
home;
|
||||
return [
|
||||
buildPrefs(
|
||||
viewSettings,
|
||||
// Hide the sort and view options when showing the intro.
|
||||
{hideSort: showIntro, hideView: showIntro && page === 'all'},
|
||||
['all', 'workspace'].includes(page)
|
||||
? upgradeButton.showUpgradeButton(css.upgradeButton.cls(''))
|
||||
: null,
|
||||
),
|
||||
createPinnedDocs(home, home.featuredTemplates, true),
|
||||
]),
|
||||
|
||||
dom.maybe(home.available, () => [
|
||||
buildOtherSites(home),
|
||||
(showIntro && page === 'all' ?
|
||||
null :
|
||||
css.docListHeader(
|
||||
(
|
||||
page === 'all' ? t("All Documents") :
|
||||
page === 'templates' ?
|
||||
dom.domComputed(use => use(home.featuredTemplates).length > 0, (hasFeaturedTemplates) =>
|
||||
hasFeaturedTemplates ? t("More Examples and Templates") : t("Examples and Templates")
|
||||
) :
|
||||
page === 'trash' ? t("Trash") :
|
||||
workspace && [css.docHeaderIcon(workspace.shareType === 'private' ? 'FolderPrivate' : 'Folder'),
|
||||
workspaceName(home.app, workspace)]
|
||||
),
|
||||
testId('doc-header'),
|
||||
)
|
||||
),
|
||||
(
|
||||
(page === 'all') ?
|
||||
dom('div',
|
||||
showIntro ? buildHomeIntro(home) : null,
|
||||
buildAllDocsBlock(home, home.workspaces, showIntro, flashDocId, viewSettings),
|
||||
shouldShowTemplates(home, showIntro) ? buildAllDocsTemplates(home, viewSettings) : null,
|
||||
) :
|
||||
(page === 'trash') ?
|
||||
dom('div',
|
||||
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."))
|
||||
// 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, () => [
|
||||
buildOtherSites(home),
|
||||
(showIntro && page === 'all' ?
|
||||
null :
|
||||
css.docListHeader(
|
||||
(
|
||||
page === 'all' ? t("All Documents") :
|
||||
page === 'templates' ?
|
||||
dom.domComputed(use => use(home.featuredTemplates).length > 0, (hasFeaturedTemplates) =>
|
||||
hasFeaturedTemplates ? t("More Examples and Templates") : t("Examples and Templates")
|
||||
) :
|
||||
page === 'trash' ? t("Trash") :
|
||||
workspace && [css.docHeaderIcon(workspace.shareType === 'private' ? 'FolderPrivate' : 'Folder'), workspaceName(home.app, workspace)]
|
||||
),
|
||||
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')
|
||||
testId('doc-header'),
|
||||
)
|
||||
),
|
||||
(
|
||||
(page === 'all') ?
|
||||
dom('div',
|
||||
showIntro ? buildHomeIntro(home) : null,
|
||||
buildAllDocsBlock(home, home.workspaces, showIntro, flashDocId, viewSettings),
|
||||
shouldShowTemplates(home, showIntro) ? buildAllDocsTemplates(home, viewSettings) : null,
|
||||
) :
|
||||
workspace && !workspace.isSupportWorkspace && workspace.docs?.length === 0 ?
|
||||
buildWorkspaceIntro(home) :
|
||||
css.docBlock(t("Workspace not found"))
|
||||
)
|
||||
]),
|
||||
(page === 'trash') ?
|
||||
dom('div',
|
||||
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(
|
||||
|
@ -1,11 +1,12 @@
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {logTelemetryEvent} from 'app/client/lib/telemetry';
|
||||
import {getWelcomeHomeUrl, urlState} from 'app/client/models/gristUrlState';
|
||||
import {renderer} from 'app/client/ui/DocTutorialRenderer';
|
||||
import {cssPopupBody, FLOATING_POPUP_TOOLTIP_KEY, FloatingPopup} from 'app/client/ui/FloatingPopup';
|
||||
import {sanitizeHTML} from 'app/client/ui/sanitizeHTML';
|
||||
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 {icon} from 'app/client/ui2018/icons';
|
||||
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
||||
@ -24,6 +25,8 @@ interface DocTutorialSlide {
|
||||
imageUrls: string[];
|
||||
}
|
||||
|
||||
const t = makeT('DocTutorial');
|
||||
|
||||
const testId = makeTestId('test-doc-tutorial-');
|
||||
|
||||
export class DocTutorial extends FloatingPopup {
|
||||
@ -35,12 +38,12 @@ export class DocTutorial extends FloatingPopup {
|
||||
private _docId = this._gristDoc.docId();
|
||||
private _slides: Observable<DocTutorialSlide[] | null> = Observable.create(this, null);
|
||||
private _currentSlideIndex = Observable.create(this, this._currentFork?.options?.tutorial?.lastSlideIndex ?? 0);
|
||||
private _percentComplete = this._currentFork?.options?.tutorial?.percentComplete;
|
||||
|
||||
|
||||
private _saveCurrentSlidePositionDebounced = debounce(this._saveCurrentSlidePosition, 1000, {
|
||||
// Save new position immediately if at least 1 second has passed since the last change.
|
||||
private _saveProgressDebounced = debounce(this._saveProgress, 1000, {
|
||||
// Save progress immediately if at least 1 second has passed since the last change.
|
||||
leading: true,
|
||||
// Otherwise, wait for the new position to settle for 1 second before saving it.
|
||||
// Otherwise, wait 1 second before saving.
|
||||
trailing: true
|
||||
});
|
||||
|
||||
@ -49,6 +52,18 @@ export class DocTutorial extends FloatingPopup {
|
||||
minimizable: 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() {
|
||||
@ -103,13 +118,6 @@ export class DocTutorial extends FloatingPopup {
|
||||
const isFirstSlide = slideIndex === 0;
|
||||
const isLastSlide = slideIndex === numSlides - 1;
|
||||
return [
|
||||
cssFooterButtonsLeft(
|
||||
cssPopupFooterButton(icon('Undo'),
|
||||
hoverTooltip('Restart Tutorial', {key: FLOATING_POPUP_TOOLTIP_KEY}),
|
||||
dom.on('click', () => this._restartTutorial()),
|
||||
testId('popup-restart'),
|
||||
),
|
||||
),
|
||||
cssProgressBar(
|
||||
range(slides.length).map((i) => cssProgressBarDot(
|
||||
hoverTooltip(slides[i].slideTitle, {
|
||||
@ -121,17 +129,17 @@ export class DocTutorial extends FloatingPopup {
|
||||
testId(`popup-slide-${i + 1}`),
|
||||
)),
|
||||
),
|
||||
cssFooterButtonsRight(
|
||||
basicButton('Previous',
|
||||
cssFooterButtons(
|
||||
basicButton(t('Previous'),
|
||||
dom.on('click', async () => {
|
||||
await this._previousSlide();
|
||||
}),
|
||||
{style: `visibility: ${isFirstSlide ? 'hidden' : 'visible'}`},
|
||||
testId('popup-previous'),
|
||||
),
|
||||
primaryButton(isLastSlide ? 'Finish': 'Next',
|
||||
primaryButton(isLastSlide ? t('Finish'): t('Next'),
|
||||
isLastSlide
|
||||
? dom.on('click', async () => await this._finishTutorial())
|
||||
? dom.on('click', async () => await this._exitTutorial(true))
|
||||
: dom.on('click', async () => await this._nextSlide()),
|
||||
testId('popup-next'),
|
||||
),
|
||||
@ -140,6 +148,21 @@ export class DocTutorial extends FloatingPopup {
|
||||
}),
|
||||
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') {
|
||||
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, {
|
||||
full: {
|
||||
tutorialForkIdDigest: this._currentFork?.id,
|
||||
tutorialTrunkIdDigest: this._currentFork?.trunkId,
|
||||
lastSlideIndex: currentSlideIndex,
|
||||
numSlides,
|
||||
percentComplete,
|
||||
lastSlideIndex: this._currentSlideIndex.get(),
|
||||
numSlides: this._slides.get()?.length,
|
||||
percentComplete: this._percentComplete,
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -251,14 +268,13 @@ export class DocTutorial extends FloatingPopup {
|
||||
}
|
||||
}
|
||||
|
||||
private async _saveCurrentSlidePosition() {
|
||||
const currentOptions = this._currentDoc?.options ?? {};
|
||||
const currentSlideIndex = this._currentSlideIndex.get();
|
||||
private async _saveProgress() {
|
||||
await this._appModel.api.updateDoc(this._docId, {
|
||||
options: {
|
||||
...currentOptions,
|
||||
...this._currentFork?.options,
|
||||
tutorial: {
|
||||
lastSlideIndex: currentSlideIndex,
|
||||
lastSlideIndex: this._currentSlideIndex.get(),
|
||||
percentComplete: this._percentComplete,
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -267,7 +283,7 @@ export class DocTutorial extends FloatingPopup {
|
||||
|
||||
private async _changeSlide(slideIndex: number) {
|
||||
this._currentSlideIndex.set(slideIndex);
|
||||
await this._saveCurrentSlidePositionDebounced();
|
||||
await this._saveProgressDebounced();
|
||||
}
|
||||
|
||||
private async _previousSlide() {
|
||||
@ -278,9 +294,10 @@ export class DocTutorial extends FloatingPopup {
|
||||
await this._changeSlide(this._currentSlideIndex.get() + 1);
|
||||
}
|
||||
|
||||
private async _finishTutorial() {
|
||||
this._saveCurrentSlidePositionDebounced.cancel();
|
||||
await this._saveCurrentSlidePosition();
|
||||
private async _exitTutorial(markAsComplete = false) {
|
||||
this._saveProgressDebounced.cancel();
|
||||
if (markAsComplete) { this._percentComplete = 100; }
|
||||
await this._saveProgressDebounced();
|
||||
const lastVisitedOrg = this._appModel.lastVisitedOrgDomain.get();
|
||||
if (lastVisitedOrg) {
|
||||
await urlState().pushUrl({org: lastVisitedOrg});
|
||||
@ -298,8 +315,8 @@ export class DocTutorial extends FloatingPopup {
|
||||
};
|
||||
|
||||
confirmModal(
|
||||
'Do you want to restart the tutorial? All progress will be lost.',
|
||||
'Restart',
|
||||
t('Do you want to restart the tutorial? All progress will be lost.'),
|
||||
t('Restart'),
|
||||
doRestart,
|
||||
{
|
||||
modalOptions: {
|
||||
@ -321,7 +338,7 @@ export class DocTutorial extends FloatingPopup {
|
||||
// eslint-disable-next-line no-self-assign
|
||||
img.src = img.src;
|
||||
|
||||
setHoverTooltip(img, 'Click to expand', {
|
||||
setHoverTooltip(img, t('Click to expand'), {
|
||||
key: FLOATING_POPUP_TOOLTIP_KEY,
|
||||
modifiers: {
|
||||
flip: {
|
||||
@ -357,14 +374,13 @@ export class DocTutorial extends FloatingPopup {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const cssPopupFooter = styled('div', `
|
||||
display: flex;
|
||||
column-gap: 24px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
padding: 24px 16px 24px 16px;
|
||||
padding: 16px;
|
||||
border-top: 1px solid ${theme.tutorialsPopupBorder};
|
||||
`);
|
||||
|
||||
@ -375,19 +391,6 @@ const cssTryItOutBox = styled('div', `
|
||||
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', `
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@ -409,11 +412,7 @@ const cssProgressBarDot = styled('div', `
|
||||
}
|
||||
`);
|
||||
|
||||
const cssFooterButtonsLeft = styled('div', `
|
||||
flex-shrink: 0;
|
||||
`);
|
||||
|
||||
const cssFooterButtonsRight = styled('div', `
|
||||
const cssFooterButtons = styled('div', `
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
column-gap: 8px;
|
||||
@ -473,3 +472,34 @@ const cssSpinner = styled('div', `
|
||||
align-items: center;
|
||||
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;
|
||||
}
|
||||
& p {
|
||||
margin: 0px;
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
& strong {
|
||||
font-weight: 600;
|
||||
|
@ -42,7 +42,8 @@ export type Tooltip =
|
||||
| 'formulaColumn'
|
||||
| 'accessRulesTableWide'
|
||||
| 'setChoiceDropdownCondition'
|
||||
| 'setRefDropdownCondition';
|
||||
| 'setRefDropdownCondition'
|
||||
| 'communityWidgets';
|
||||
|
||||
export type TooltipContentFunc = (...domArgs: DomElementArg[]) => DomContents;
|
||||
|
||||
@ -152,6 +153,15 @@ see or edit which parts of your document.')
|
||||
),
|
||||
...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 {
|
||||
@ -307,20 +317,6 @@ to determine who can see or edit which parts of your document.')),
|
||||
forceShow: true,
|
||||
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: {
|
||||
popupType: 'tip',
|
||||
title: () => t('Calendar'),
|
||||
|
@ -83,7 +83,6 @@ function makeViewerTeamSiteIntro(homeModel: HomeModel) {
|
||||
}
|
||||
|
||||
function makeTeamSiteIntro(homeModel: HomeModel) {
|
||||
const sproutsProgram = cssLink({href: commonUrls.sproutsProgram, target: '_blank'}, t("Sprouts Program"));
|
||||
return [
|
||||
css.docListHeader(
|
||||
t("Welcome to {{- orgName}}", {orgName: homeModel.app.currentOrgName}),
|
||||
@ -94,8 +93,8 @@ function makeTeamSiteIntro(homeModel: HomeModel) {
|
||||
(!isFeatureEnabled('helpCenter') ? null :
|
||||
cssIntroLine(
|
||||
t(
|
||||
'Learn more in our {{helpCenterLink}}, or find an expert via our {{sproutsProgram}}.',
|
||||
{helpCenterLink: helpCenterLink(), sproutsProgram}
|
||||
'Learn more in our {{helpCenterLink}}.',
|
||||
{helpCenterLink: helpCenterLink()}
|
||||
),
|
||||
testId('welcome-text')
|
||||
)
|
||||
|
@ -5,7 +5,7 @@ import {reportError} from 'app/client/models/AppModel';
|
||||
import {docUrl, urlState} from 'app/client/models/gristUrlState';
|
||||
import {HomeModel} from 'app/client/models/HomeModel';
|
||||
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 {docImport, importFromPlugin} from 'app/client/ui/HomeImports';
|
||||
import {
|
||||
@ -31,7 +31,8 @@ export function createHomeLeftPane(leftPanelOpen: Observable<boolean>, home: Hom
|
||||
const creating = observable<boolean>(false);
|
||||
const renaming = observable<Workspace|null>(null);
|
||||
const isAnonymous = !home.app.currentValidUser;
|
||||
const canCreate = !isAnonymous || getGristConfig().enableAnonPlayground;
|
||||
const {enableAnonPlayground, templateOrg, onboardingTutorialDocId} = getGristConfig();
|
||||
const canCreate = !isAnonymous || enableAnonPlayground;
|
||||
|
||||
return cssContent(
|
||||
dom.autoDispose(creating),
|
||||
@ -119,7 +120,7 @@ export function createHomeLeftPane(leftPanelOpen: Observable<boolean>, home: Hom
|
||||
)),
|
||||
cssTools(
|
||||
cssPageEntry(
|
||||
dom.show(isFeatureEnabled("templates") && Boolean(getGristConfig().templateOrg)),
|
||||
dom.show(isFeatureEnabled("templates") && Boolean(templateOrg)),
|
||||
cssPageEntry.cls('-selected', (use) => use(home.currentPage) === "templates"),
|
||||
cssPageLink(cssPageIcon('Board'), cssLinkText(t("Examples & Templates")),
|
||||
urlState().setLinkUrl({homePage: "templates"}),
|
||||
@ -135,9 +136,9 @@ export function createHomeLeftPane(leftPanelOpen: Observable<boolean>, home: Hom
|
||||
),
|
||||
cssSpacer(),
|
||||
cssPageEntry(
|
||||
dom.show(isFeatureEnabled('tutorials')),
|
||||
dom.show(isFeatureEnabled('tutorials') && Boolean(templateOrg && onboardingTutorialDocId)),
|
||||
cssPageLink(cssPageIcon('Bookmark'), cssLinkText(t("Tutorial")),
|
||||
{ href: commonUrls.basicTutorial, target: '_blank' },
|
||||
urlState().setLinkUrl({org: templateOrg!, doc: onboardingTutorialDocId}),
|
||||
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 {logTelemetryEvent} from 'app/client/lib/telemetry';
|
||||
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 {icon} from 'app/client/ui2018/icons';
|
||||
import {cssModalCloseButton, modal} from 'app/client/ui2018/modals';
|
||||
import {isFeatureEnabled} from 'app/common/gristUrls';
|
||||
import {dom, makeTestId, styled} from 'grainjs';
|
||||
import {isFeatureEnabled, ONBOARDING_VIDEO_YOUTUBE_EMBED_ID} from 'app/common/gristUrls';
|
||||
import {dom, keyframes, makeTestId, styled} from 'grainjs';
|
||||
|
||||
const t = makeT('OpenVideoTour');
|
||||
|
||||
const testId = makeTestId('test-video-tour-');
|
||||
|
||||
const VIDEO_TOUR_YOUTUBE_EMBED_ID = '56AieR9rpww';
|
||||
|
||||
/**
|
||||
* Opens a modal containing a video tour of Grist.
|
||||
*/
|
||||
@ -23,12 +20,15 @@ const VIDEO_TOUR_YOUTUBE_EMBED_ID = '56AieR9rpww';
|
||||
return modal(
|
||||
(ctl, owner) => {
|
||||
const youtubePlayer = YouTubePlayer.create(owner,
|
||||
VIDEO_TOUR_YOUTUBE_EMBED_ID,
|
||||
ONBOARDING_VIDEO_YOUTUBE_EMBED_ID,
|
||||
{
|
||||
onPlayerReady: (player) => player.playVideo(),
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
origin: getMainOrgUrl(),
|
||||
playerVars: {
|
||||
rel: 0,
|
||||
},
|
||||
},
|
||||
cssYouTubePlayer.cls(''),
|
||||
);
|
||||
@ -83,12 +83,7 @@ export function createVideoTourToolsButton(): HTMLDivElement | null {
|
||||
|
||||
let iconElement: HTMLElement;
|
||||
|
||||
const commandsGroup = commands.createGroup({
|
||||
videoTourToolsOpen: () => openVideoTour(iconElement),
|
||||
}, null, true);
|
||||
|
||||
return cssPageEntryMain(
|
||||
dom.autoDispose(commandsGroup),
|
||||
cssPageLink(
|
||||
iconElement = cssPageIcon('Video'),
|
||||
cssLinkText(t("Video Tour")),
|
||||
@ -108,10 +103,19 @@ const cssModal = styled('div', `
|
||||
max-width: 864px;
|
||||
`);
|
||||
|
||||
const delayedVisibility = keyframes(`
|
||||
to {
|
||||
visibility: visible;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssYouTubePlayerContainer = styled('div', `
|
||||
position: relative;
|
||||
padding-bottom: 56.25%;
|
||||
height: 0;
|
||||
/* Wait until the modal is finished animating. */
|
||||
visibility: hidden;
|
||||
animation: 0s linear 0.4s forwards ${delayedVisibility};
|
||||
`);
|
||||
|
||||
const cssYouTubePlayer = styled('div', `
|
||||
|
@ -95,25 +95,46 @@ export interface IOptions extends ISelectOptions {
|
||||
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-');
|
||||
|
||||
// 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.
|
||||
function getCompatibleTypes(tableId: TableRef, isNewPage: boolean|undefined): IWidgetType[] {
|
||||
function getCompatibleTypes(tableId: TableRef,
|
||||
{isNewPage, summarize}: ICompatibleTypes): IWidgetType[] {
|
||||
let compatibleTypes: Array<IWidgetType> = [];
|
||||
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) {
|
||||
// New view + new table means we'll be switching to the primary view.
|
||||
return ['record', 'form'];
|
||||
compatibleTypes = ['record', 'form'];
|
||||
} else {
|
||||
// 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.
|
||||
function isValidSelection(table: TableRef, type: IWidgetType, isNewPage: boolean|undefined) {
|
||||
return table !== null && getCompatibleTypes(table, isNewPage).includes(type);
|
||||
function isValidSelection(table: TableRef,
|
||||
type: IWidgetType,
|
||||
{isNewPage, summarize}: ICompatibleTypes) {
|
||||
return table !== null && getCompatibleTypes(table, {isNewPage, summarize}).includes(type);
|
||||
}
|
||||
|
||||
export type ISaveFunc = (val: IPageWidget) => Promise<any>;
|
||||
@ -213,7 +234,13 @@ export function buildPageWidgetPicker(
|
||||
|
||||
// whether the current selection is valid
|
||||
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
|
||||
@ -299,7 +326,7 @@ export class PageWidgetSelect extends Disposable {
|
||||
null;
|
||||
|
||||
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(
|
||||
private _value: IWidgetValueObs,
|
||||
@ -318,7 +345,9 @@ export class PageWidgetSelect extends Disposable {
|
||||
header(t("Select Widget")),
|
||||
sectionTypes.map((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(
|
||||
dom.autoDispose(disabled),
|
||||
cssTypeIcon(widgetInfo.icon),
|
||||
@ -355,11 +384,14 @@ export class PageWidgetSelect extends Disposable {
|
||||
cssEntry.cls('-selected', (use) => use(this._value.table) === table.id()),
|
||||
testId('table-label')
|
||||
),
|
||||
cssPivot(
|
||||
cssBigIcon('Pivot'),
|
||||
cssEntry.cls('-selected', (use) => use(this._value.summarize) && use(this._value.table) === table.id()),
|
||||
dom.on('click', (ev, el) => this._selectPivot(table.id(), el as HTMLElement)),
|
||||
testId('pivot'),
|
||||
cssPivot(
|
||||
cssBigIcon('Pivot'),
|
||||
cssEntry.cls('-selected', (use) => use(this._value.summarize) &&
|
||||
use(this._value.table) === table.id()
|
||||
),
|
||||
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'),
|
||||
)
|
||||
@ -410,7 +442,12 @@ export class PageWidgetSelect extends Disposable {
|
||||
// there are no changes.
|
||||
this._options.buttonLabel || t("Add to Page"),
|
||||
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)),
|
||||
testId('addBtn'),
|
||||
@ -464,11 +501,11 @@ export class PageWidgetSelect extends Disposable {
|
||||
this._value.columns.set(newIds);
|
||||
}
|
||||
|
||||
private _isTypeDisabled(type: IWidgetType, table: TableRef) {
|
||||
private _isTypeDisabled(type: IWidgetType, table: TableRef, isSummaryOn: boolean) {
|
||||
if (table === null) {
|
||||
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 {
|
||||
color: ${theme.widgetPickerItemDisabledBg};
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
&-disabled&-selected {
|
||||
background-color: inherit;
|
||||
@ -578,6 +616,10 @@ const cssBigIcon = styled(icon, `
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background-color: ${theme.widgetPickerSummaryIcon};
|
||||
.${cssEntry.className}-disabled > & {
|
||||
opacity: 0.25;
|
||||
filter: saturate(0);
|
||||
}
|
||||
`);
|
||||
|
||||
const cssFooter = styled('div', `
|
||||
|
@ -1,6 +1,7 @@
|
||||
import {GristDoc} from "../components/GristDoc";
|
||||
import {ViewSectionRec} from "../models/entities/ViewSectionRec";
|
||||
import {CustomSectionConfig} from "./CustomSectionConfig";
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {ViewSectionRec} from 'app/client/models/entities/ViewSectionRec';
|
||||
import {CustomSectionConfig} from 'app/client/ui/CustomSectionConfig';
|
||||
import {ICustomWidget} from 'app/common/CustomWidget';
|
||||
|
||||
export class PredefinedCustomSectionConfig extends CustomSectionConfig {
|
||||
|
||||
@ -17,7 +18,7 @@ export class PredefinedCustomSectionConfig extends CustomSectionConfig {
|
||||
return false;
|
||||
}
|
||||
|
||||
protected async _getWidgets(): Promise<void> {
|
||||
// Do nothing.
|
||||
protected async _getWidgets(): Promise<ICustomWidget[]> {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
@ -29,6 +29,7 @@ import {logTelemetryEvent} from 'app/client/lib/telemetry';
|
||||
import {reportError} from 'app/client/models/AppModel';
|
||||
import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel';
|
||||
import {CustomSectionConfig} from 'app/client/ui/CustomSectionConfig';
|
||||
import {showCustomWidgetGallery} from 'app/client/ui/CustomWidgetGallery';
|
||||
import {buildDescriptionConfig} from 'app/client/ui/DescriptionConfig';
|
||||
import {BuildEditorOptions} from 'app/client/ui/FieldConfig';
|
||||
import {GridOptions} from 'app/client/ui/GridOptions';
|
||||
@ -526,7 +527,7 @@ export class RightPanel extends Disposable {
|
||||
dom.maybe((use) => use(this._pageWidgetType) === 'custom', () => {
|
||||
const parts = vct._buildCustomTypeItems() as any[];
|
||||
return [
|
||||
cssLabel(t("CUSTOM")),
|
||||
cssSeparator(),
|
||||
// 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
|
||||
// the only one that will be shown without the feature flag.
|
||||
@ -880,13 +881,20 @@ export class RightPanel extends Disposable {
|
||||
|
||||
private _createPageWidgetPicker(): DomElementMethod {
|
||||
const gristDoc = this._gristDoc;
|
||||
const section = gristDoc.viewModel.activeSection;
|
||||
const onSave = (val: IPageWidget) => gristDoc.saveViewSection(section.peek(), val);
|
||||
return (elem) => { attachPageWidgetPicker(elem, gristDoc, onSave, {
|
||||
buttonLabel: t("Save"),
|
||||
value: () => toPageWidget(section.peek()),
|
||||
selectBy: (val) => gristDoc.selectBy(val),
|
||||
}); };
|
||||
const {activeSection} = gristDoc.viewModel;
|
||||
const onSave = async (val: IPageWidget) => {
|
||||
const {id} = await gristDoc.saveViewSection(activeSection.peek(), val);
|
||||
if (val.type === 'custom') {
|
||||
showCustomWidgetGallery(gristDoc, {sectionRef: id()});
|
||||
}
|
||||
};
|
||||
return (elem) => {
|
||||
attachPageWidgetPicker(elem, gristDoc, onSave, {
|
||||
buttonLabel: t("Save"),
|
||||
value: () => toPageWidget(activeSection.peek()),
|
||||
selectBy: (val) => gristDoc.selectBy(val),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// Returns dom for a section item.
|
||||
|
@ -1,14 +1,24 @@
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {AppModel} from 'app/client/models/AppModel';
|
||||
import {TelemetryModel, TelemetryModelImpl} from 'app/client/models/TelemetryModel';
|
||||
import {basicButtonLink, bigBasicButton, bigBasicButtonLink, bigPrimaryButton} from 'app/client/ui2018/buttons';
|
||||
import {theme} from 'app/client/ui2018/cssVars';
|
||||
import {
|
||||
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 {cssLink} from 'app/client/ui2018/links';
|
||||
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
||||
import {commonUrls} from 'app/common/gristUrls';
|
||||
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-');
|
||||
|
||||
@ -164,45 +174,3 @@ function gristCoreLink() {
|
||||
{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.
|
||||
*/
|
||||
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 {getGristConfig} from 'app/common/urlUtils';
|
||||
import {FullUser} from 'app/common/LoginSessionAPI';
|
||||
import * as roles from 'app/common/roles';
|
||||
import {Organization, PermissionData, UserAPI} from 'app/common/UserAPI';
|
||||
@ -816,15 +817,25 @@ const cssMemberPublicAccess = styled(cssMemberSecondary, `
|
||||
function renderTitle(resourceType: ResourceType, resource?: Resource, personal?: boolean) {
|
||||
switch (resourceType) {
|
||||
case 'organization': {
|
||||
if (personal) { return t('Your role for this team site'); }
|
||||
return [
|
||||
t('Manage members of team site'),
|
||||
!resource ? null : cssOrgName(
|
||||
`${(resource as Organization).name} (`,
|
||||
cssOrgDomain(`${(resource as Organization).domain}.getgrist.com`),
|
||||
')',
|
||||
)
|
||||
];
|
||||
if (personal) {
|
||||
return t('Your role for this team site');
|
||||
}
|
||||
|
||||
function getOrgDisplay() {
|
||||
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: {
|
||||
return personal ?
|
||||
|
@ -107,6 +107,12 @@ const WEBHOOK_COLUMNS = [
|
||||
type: 'Text',
|
||||
label: t('Status'),
|
||||
},
|
||||
{
|
||||
id: VirtualId(),
|
||||
colId: 'authorization',
|
||||
type: 'Text',
|
||||
label: t('Header Authorization'),
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
@ -114,10 +120,11 @@ const WEBHOOK_COLUMNS = [
|
||||
*/
|
||||
const WEBHOOK_VIEW_FIELDS: Array<(typeof WEBHOOK_COLUMNS)[number]['colId']> = [
|
||||
'name', 'memo',
|
||||
'eventTypes', 'url',
|
||||
'tableId', 'isReadyColumn',
|
||||
'watchedColIdsText', 'webhookId',
|
||||
'enabled', 'status'
|
||||
'eventTypes', 'tableId',
|
||||
'watchedColIdsText', 'isReadyColumn',
|
||||
'url', 'authorization',
|
||||
'webhookId', 'enabled',
|
||||
'status'
|
||||
];
|
||||
|
||||
/**
|
||||
@ -136,7 +143,7 @@ class WebhookExternalTable implements IExternalTable {
|
||||
public name = 'GristHidden_WebhookTable';
|
||||
public initialActions = _prepareWebhookInitialActions(this.name);
|
||||
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>([]);
|
||||
|
||||
|
@ -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;
|
||||
setVolume(volume: number): void;
|
||||
getCurrentTime(): number;
|
||||
getPlayerState(): PlayerState;
|
||||
}
|
||||
|
||||
export interface PlayerOptions {
|
||||
@ -28,6 +29,7 @@ export interface PlayerVars {
|
||||
fs?: 0 | 1;
|
||||
iv_load_policy?: 1 | 3;
|
||||
modestbranding?: 0 | 1;
|
||||
rel?: 0 | 1;
|
||||
}
|
||||
|
||||
export interface PlayerStateChangeEvent {
|
||||
@ -93,6 +95,18 @@ export class YouTubePlayer extends Disposable {
|
||||
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) {
|
||||
this._player.setVolume(volume);
|
||||
}
|
||||
|
@ -15,12 +15,19 @@ const testId = makeTestId('test-');
|
||||
|
||||
const t = makeT('errorPages');
|
||||
|
||||
function signInAgainButton() {
|
||||
return cssButtonWrap(bigPrimaryButtonLink(
|
||||
t("Sign in again"), {href: getLoginUrl()}, testId('error-signin')
|
||||
));
|
||||
}
|
||||
|
||||
export function createErrPage(appModel: AppModel) {
|
||||
const {errMessage, errPage} = getGristConfig();
|
||||
return errPage === 'signed-out' ? createSignedOutPage(appModel) :
|
||||
errPage === 'not-found' ? createNotFoundPage(appModel, errMessage) :
|
||||
errPage === 'access-denied' ? createForbiddenPage(appModel, errMessage) :
|
||||
errPage === 'account-deleted' ? createAccountDeletedPage(appModel) :
|
||||
errPage === 'signin-failed' ? createSigninFailedPage(appModel, errMessage) :
|
||||
createOtherErrorPage(appModel, errMessage);
|
||||
}
|
||||
|
||||
@ -61,9 +68,7 @@ export function createSignedOutPage(appModel: AppModel) {
|
||||
|
||||
return pagePanelsError(appModel, t("Signed out{{suffix}}", {suffix: ''}), [
|
||||
cssErrorText(t("You are now signed out.")),
|
||||
cssButtonWrap(bigPrimaryButtonLink(
|
||||
t("Sign in again"), {href: getLoginUrl()}, testId('error-signin')
|
||||
))
|
||||
signInAgainButton(),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
@ -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.
|
||||
// It is expected that the elem arg has the offsetHeight property set.
|
||||
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', `
|
||||
|
@ -123,6 +123,7 @@ export type IconName = "ChartArea" |
|
||||
"Public" |
|
||||
"PublicColor" |
|
||||
"PublicFilled" |
|
||||
"Question" |
|
||||
"Redo" |
|
||||
"Remove" |
|
||||
"RemoveBig" |
|
||||
@ -137,13 +138,17 @@ export type IconName = "ChartArea" |
|
||||
"Separator" |
|
||||
"Settings" |
|
||||
"Share" |
|
||||
"Skip" |
|
||||
"Sort" |
|
||||
"Sparks" |
|
||||
"Star" |
|
||||
"Tick" |
|
||||
"TickSolid" |
|
||||
"Undo" |
|
||||
"Validation" |
|
||||
"Video" |
|
||||
"VideoPlay" |
|
||||
"VideoPlay2" |
|
||||
"Warning" |
|
||||
"Widget" |
|
||||
"Wrap" |
|
||||
@ -284,6 +289,7 @@ export const IconList: IconName[] = ["ChartArea",
|
||||
"Public",
|
||||
"PublicColor",
|
||||
"PublicFilled",
|
||||
"Question",
|
||||
"Redo",
|
||||
"Remove",
|
||||
"RemoveBig",
|
||||
@ -298,13 +304,17 @@ export const IconList: IconName[] = ["ChartArea",
|
||||
"Separator",
|
||||
"Settings",
|
||||
"Share",
|
||||
"Skip",
|
||||
"Sort",
|
||||
"Sparks",
|
||||
"Star",
|
||||
"Tick",
|
||||
"TickSolid",
|
||||
"Undo",
|
||||
"Validation",
|
||||
"Video",
|
||||
"VideoPlay",
|
||||
"VideoPlay2",
|
||||
"Warning",
|
||||
"Widget",
|
||||
"Wrap",
|
||||
|
@ -471,6 +471,10 @@ export const theme = {
|
||||
undefined, colors.mediumGreyOpaque),
|
||||
rightPanelFieldSettingsButtonBg: new CustomProp('theme-right-panel-field-settings-button-bg',
|
||||
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 */
|
||||
documentHistorySnapshotFg: new CustomProp('theme-document-history-snapshot-fg', undefined,
|
||||
@ -877,6 +881,20 @@ export const theme = {
|
||||
|
||||
/* Numeric Spinners */
|
||||
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');
|
||||
|
@ -380,59 +380,60 @@ export class FieldEditor extends Disposable {
|
||||
if (!editor) { return false; }
|
||||
// Make sure the editor is save ready
|
||||
const saveIndex = this._cursor.rowIndex();
|
||||
await editor.prepForSave();
|
||||
if (this.isDisposed()) {
|
||||
// We shouldn't normally get disposed here, but if we do, avoid confusing JS errors.
|
||||
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),
|
||||
]));
|
||||
return await this._gristDoc.docData.bundleActions(null, async () => {
|
||||
await editor.prepForSave();
|
||||
if (this.isDisposed()) {
|
||||
// We shouldn't normally get disposed here, but if we do, avoid confusing JS errors.
|
||||
console.warn(t("Unable to finish saving edited cell")); // tslint:disable-line:no-console
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
const value = editor.getCellValue();
|
||||
if (col.isRealFormula()) {
|
||||
// tslint:disable-next-line:no-console
|
||||
console.warn(t("It should be impossible to save a plain data value into a formula column"));
|
||||
// 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 = 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 {
|
||||
// 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 value = editor.getCellValue();
|
||||
if (col.isRealFormula()) {
|
||||
// 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 = {
|
||||
position : this.cellPosition(),
|
||||
wasModified : this._editorHasChanged,
|
||||
currentState : this._editorHolder.get()?.editorState?.get(),
|
||||
type : this._field.column.peek().pureType.peek()
|
||||
};
|
||||
this.saveEmitter.emit(event);
|
||||
const event: FieldEditorStateEvent = {
|
||||
position : this.cellPosition(),
|
||||
wasModified : this._editorHasChanged,
|
||||
currentState : this._editorHolder.get()?.editorState?.get(),
|
||||
type : this._field.column.peek().pureType.peek()
|
||||
};
|
||||
this.saveEmitter.emit(event);
|
||||
|
||||
const cursor = this._cursor;
|
||||
// Deactivate the editor. We are careful to avoid using `this` afterwards.
|
||||
this.dispose();
|
||||
await waitPromise;
|
||||
return isFormula || (saveIndex !== cursor.rowIndex());
|
||||
const cursor = this._cursor;
|
||||
// Deactivate the editor. We are careful to avoid using `this` afterwards.
|
||||
this.dispose();
|
||||
await waitPromise;
|
||||
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.
|
||||
* This is used for the editor in the side panel.
|
||||
*/
|
||||
export function openFormulaEditor(options: {
|
||||
gristDoc: GristDoc,
|
||||
|
@ -8,7 +8,8 @@ export type BootProbeIds =
|
||||
'sandboxing' |
|
||||
'system-user' |
|
||||
'authentication' |
|
||||
'websockets'
|
||||
'websockets' |
|
||||
'session-secret'
|
||||
;
|
||||
|
||||
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.
|
||||
*/
|
||||
renderAfterReady?: boolean;
|
||||
|
||||
/**
|
||||
* If set to false, do not offer to user in UI.
|
||||
*/
|
||||
published?: boolean;
|
||||
|
||||
/**
|
||||
* If the widget came from a plugin, we track that here.
|
||||
*/
|
||||
@ -43,6 +41,29 @@ export interface ICustomWidget {
|
||||
pluginId: 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',
|
||||
'addNew',
|
||||
'rickRow',
|
||||
'customURL',
|
||||
'calendarConfig',
|
||||
|
||||
// The following were used in the past and should not be re-used.
|
||||
// 'customURL',
|
||||
// 'formsAreHere',
|
||||
);
|
||||
export type BehavioralPrompt = typeof BehavioralPrompt.type;
|
||||
@ -107,12 +107,15 @@ export interface BehavioralPromptPrefs {
|
||||
export const DismissedPopup = StringUnion(
|
||||
'deleteRecords', // confirmation for deleting records keyboard shortcut
|
||||
'deleteFields', // confirmation for deleting columns keyboard shortcut
|
||||
'tutorialFirstCard', // first card of the tutorial
|
||||
'formulaHelpInfo', // formula help info shown in the popup editor
|
||||
'formulaAssistantInfo', // formula assistant info shown in the popup editor
|
||||
'supportGrist', // nudge to opt in to telemetry
|
||||
'publishForm', // confirmation for publishing 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;
|
||||
|
||||
|
@ -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
|
||||
* 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)) {
|
||||
const actual = JSON.stringify(value);
|
||||
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;
|
||||
};
|
||||
@ -44,6 +50,6 @@ export const StringUnion = <UnionType extends string>(...values: UnionType[]) =>
|
||||
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});
|
||||
};
|
||||
|
@ -211,6 +211,8 @@ export const ThemeColors = t.iface([], {
|
||||
"right-panel-toggle-button-disabled-bg": "string",
|
||||
"right-panel-field-settings-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-selected-fg": "string",
|
||||
"document-history-snapshot-bg": "string",
|
||||
@ -438,6 +440,13 @@ export const ThemeColors = t.iface([], {
|
||||
"scroll-shadow": "string",
|
||||
"toggle-checkbox-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 = {
|
||||
|
@ -269,6 +269,8 @@ export interface ThemeColors {
|
||||
'right-panel-toggle-button-disabled-bg': string;
|
||||
'right-panel-field-settings-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-snapshot-fg': string;
|
||||
@ -572,6 +574,15 @@ export interface ThemeColors {
|
||||
|
||||
/* Numeric Spinners */
|
||||
'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>;
|
||||
|
@ -14,6 +14,7 @@ export const Webhook = t.iface([], {
|
||||
|
||||
export const WebhookFields = t.iface([], {
|
||||
"url": "string",
|
||||
"authorization": t.opt("string"),
|
||||
"eventTypes": t.array(t.union(t.lit("add"), t.lit("update"))),
|
||||
"tableId": "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([], {
|
||||
"url": "string",
|
||||
"authorization": t.opt("string"),
|
||||
"eventTypes": t.array(t.union(t.lit("add"), t.lit("update"))),
|
||||
"watchedColIds": t.opt(t.array("string")),
|
||||
"enabled": t.opt("boolean"),
|
||||
@ -45,6 +47,7 @@ export const WebhookSummary = t.iface([], {
|
||||
"id": "string",
|
||||
"fields": t.iface([], {
|
||||
"url": "string",
|
||||
"authorization": t.opt("string"),
|
||||
"unsubscribeKey": "string",
|
||||
"eventTypes": t.array("string"),
|
||||
"isReadyColumn": t.union("string", "null"),
|
||||
@ -64,6 +67,7 @@ export const WebhookUpdate = t.iface([], {
|
||||
|
||||
export const WebhookPatch = t.iface([], {
|
||||
"url": t.opt("string"),
|
||||
"authorization": t.opt("string"),
|
||||
"eventTypes": t.opt(t.array(t.union(t.lit("add"), t.lit("update")))),
|
||||
"tableId": t.opt("string"),
|
||||
"watchedColIds": t.opt(t.array("string")),
|
||||
|
@ -8,6 +8,7 @@ export interface Webhook {
|
||||
|
||||
export interface WebhookFields {
|
||||
url: string;
|
||||
authorization?: string;
|
||||
eventTypes: Array<"add"|"update">;
|
||||
tableId: 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
|
||||
export interface WebhookSubscribe {
|
||||
url: string;
|
||||
authorization?: string;
|
||||
eventTypes: Array<"add"|"update">;
|
||||
watchedColIds?: string[];
|
||||
enabled?: boolean;
|
||||
@ -42,6 +44,7 @@ export interface WebhookSummary {
|
||||
id: string;
|
||||
fields: {
|
||||
url: string;
|
||||
authorization?: string;
|
||||
unsubscribeKey: string;
|
||||
eventTypes: string[];
|
||||
isReadyColumn: string|null;
|
||||
@ -64,6 +67,7 @@ export interface WebhookUpdate {
|
||||
// ts-interface-builder
|
||||
export interface WebhookPatch {
|
||||
url?: string;
|
||||
authorization?: string;
|
||||
eventTypes?: Array<"add"|"update">;
|
||||
tableId?: string;
|
||||
watchedColIds?: string[];
|
||||
|
@ -145,7 +145,7 @@ export interface DocumentOptions {
|
||||
|
||||
export interface TutorialMetadata {
|
||||
lastSlideIndex?: number;
|
||||
numSlides?: number;
|
||||
percentComplete?: number;
|
||||
}
|
||||
|
||||
export interface DocumentProperties extends CommonProperties {
|
||||
@ -370,6 +370,7 @@ export interface UserAPI {
|
||||
getOrgWorkspaces(orgId: number|string, includeSupport?: boolean): Promise<Workspace[]>;
|
||||
getOrgUsageSummary(orgId: number|string): Promise<OrgUsageSummary>;
|
||||
getTemplates(onlyFeatured?: boolean): Promise<Workspace[]>;
|
||||
getTemplate(docId: string): Promise<Document>;
|
||||
getDoc(docId: string): Promise<Document>;
|
||||
newOrg(props: Partial<OrganizationProperties>): 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' });
|
||||
}
|
||||
|
||||
public async getTemplate(docId: string): Promise<Document> {
|
||||
return this.requestJson(`${this._url}/api/templates/${docId}`, { method: 'GET' });
|
||||
}
|
||||
|
||||
public async getWidgets(): Promise<ICustomWidget[]> {
|
||||
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",
|
||||
helpCustomWidgets: "https://support.getgrist.com/widget-custom",
|
||||
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",
|
||||
helpLinkKeys: "https://support.getgrist.com/examples/2021-04-link-keys",
|
||||
helpFilteringReferenceChoices: "https://support.getgrist.com/col-refs/#filtering-reference-choices-in-dropdown",
|
||||
@ -93,7 +94,6 @@ export const commonUrls = {
|
||||
contactSupport: getContactSupportUrl(),
|
||||
termsOfService: getTermsOfServiceUrl(),
|
||||
plans: "https://www.getgrist.com/pricing",
|
||||
sproutsProgram: "https://www.getgrist.com/sprouts-program",
|
||||
contact: "https://www.getgrist.com/contact",
|
||||
templates: 'https://www.getgrist.com/templates',
|
||||
community: 'https://community.getgrist.com',
|
||||
@ -102,8 +102,6 @@ export const commonUrls = {
|
||||
formulas: 'https://support.getgrist.com/formulas',
|
||||
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/',
|
||||
gristLabsWidgetRepository: 'https://github.com/gristlabs/grist-widget/releases/download/latest/manifest.json',
|
||||
githubGristCore: 'https://github.com/gristlabs/grist-core',
|
||||
@ -112,6 +110,8 @@ export const commonUrls = {
|
||||
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
|
||||
* 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)
|
||||
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;
|
||||
|
||||
// Whether there is somewhere for survey data to go.
|
||||
@ -809,9 +810,15 @@ export interface GristLoadConfig {
|
||||
// The Grist deployment type (e.g. core, enterprise).
|
||||
deploymentType?: GristDeploymentType;
|
||||
|
||||
// Force enterprise deployment? For backwards compatibility with grist-ee Docker image
|
||||
forceEnableEnterprise?: boolean;
|
||||
|
||||
// The org containing public templates and tutorials.
|
||||
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.
|
||||
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-field-settings-bg': '#404150',
|
||||
'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-snapshot-fg': '#EFEFEF',
|
||||
@ -551,4 +553,13 @@ export const GristDark: ThemeColors = {
|
||||
|
||||
/* Numeric Spinners */
|
||||
'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-field-settings-bg': '#E8E8E8',
|
||||
'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-snapshot-fg': '#262633',
|
||||
@ -551,4 +553,13 @@ export const GristLight: ThemeColors = {
|
||||
|
||||
/* Numeric Spinners */
|
||||
'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 {OrganizationProperties, PermissionDelta} from 'app/common/UserAPI';
|
||||
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 {getSessionUser, linkOrgWithEmail} from 'app/server/lib/BrowserSession';
|
||||
import {expressWrap} from 'app/server/lib/expressWrap';
|
||||
@ -302,6 +302,18 @@ export class ApiServer {
|
||||
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 all widget definitions from external source.
|
||||
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"})
|
||||
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> {
|
||||
for (const key of Object.keys(props)) {
|
||||
if (!installPropertyKeys.includes(key)) {
|
||||
|
@ -134,12 +134,12 @@ export class Document extends Resource {
|
||||
this.options.tutorial = null;
|
||||
} else {
|
||||
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) {
|
||||
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.
|
||||
|
@ -29,6 +29,9 @@ export class User extends BaseEntity {
|
||||
@Column({name: 'first_login_at', type: Date, nullable: true})
|
||||
public firstLoginAt: Date | null;
|
||||
|
||||
@Column({name: 'last_connection_at', type: Date, nullable: true})
|
||||
public lastConnectionAt: Date | null;
|
||||
|
||||
@OneToOne(type => Organization, organization => organization.owner)
|
||||
public personalOrg: Organization;
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { makeId } from 'app/server/lib/idUtils';
|
||||
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
|
||||
|
@ -5,7 +5,7 @@ import {AbortController} from 'node-abort-controller';
|
||||
import { ApiError } from 'app/common/ApiError';
|
||||
import { SHARE_KEY_PREFIX } from 'app/common/gristUrls';
|
||||
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 { IDocWorkerMap } from "app/server/lib/DocWorkerMap";
|
||||
import { expressWrap } from "app/server/lib/expressWrap";
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { ApiError } from 'app/common/ApiError';
|
||||
import { FullUser } from 'app/common/UserAPI';
|
||||
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 { scrubUserFromOrg } from 'app/gen-server/lib/scrubUserFromOrg';
|
||||
import { GristLoginSystem } from 'app/server/lib/GristServer';
|
||||
|
@ -1,12 +1,13 @@
|
||||
import { ApiError } from 'app/common/ApiError';
|
||||
import { delay } from 'app/common/delay';
|
||||
import { buildUrlId } from 'app/common/gristUrls';
|
||||
import { normalizedDateTimeString } from 'app/common/normalizedDateTimeString';
|
||||
import { BillingAccount } from 'app/gen-server/entity/BillingAccount';
|
||||
import { Document } from 'app/gen-server/entity/Document';
|
||||
import { Organization } from 'app/gen-server/entity/Organization';
|
||||
import { Product } from 'app/gen-server/entity/Product';
|
||||
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 { getAuthorizedUserId } from 'app/server/lib/Authorizer';
|
||||
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 { optStringParam, stringParam } from 'app/server/lib/requestUtils';
|
||||
import * as express from 'express';
|
||||
import moment from 'moment';
|
||||
import fetch from 'node-fetch';
|
||||
import * as Fetch from 'node-fetch';
|
||||
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
|
||||
* 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 {Organization} from 'app/gen-server/entity/Organization';
|
||||
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';
|
||||
|
||||
// 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})
|
||||
.execute();
|
||||
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
|
||||
// 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 => {
|
||||
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);
|
||||
if (!value) {
|
||||
throw new ApiError('Webhook with given id not found', 404);
|
||||
}
|
||||
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);
|
||||
});
|
||||
}
|
@ -17,7 +17,7 @@ import { Group } from 'app/gen-server/entity/Group';
|
||||
import { Login } from 'app/gen-server/entity/Login';
|
||||
import { User } from 'app/gen-server/entity/User';
|
||||
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 {
|
||||
AvailableUsers, GetUserOptions, NonGuestGroup, QueryResult, Resource, RunInTransaction, UserProfileChange
|
||||
} from 'app/gen-server/lib/homedb/Interfaces';
|
||||
@ -395,14 +395,6 @@ export class UsersManager {
|
||||
user.name = (profile && (profile.name || email.split('@')[0])) || '';
|
||||
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) {
|
||||
// Set the user's profile picture if our provider knows it.
|
||||
user.picture = profile.picture;
|
||||
@ -432,6 +424,25 @@ export class UsersManager {
|
||||
user.options = {...(user.options ?? {}), authSubject: userOptions.authSubject};
|
||||
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) {
|
||||
login.user = user;
|
||||
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 {chunk} from 'lodash';
|
||||
import {MigrationInterface, QueryRunner, TableColumn} from "typeorm";
|
||||
|
||||
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.
|
||||
// 300 seems to be a good number, for 24k rows we have 80 queries.
|
||||
const userList = await queryRunner.manager.createQueryBuilder()
|
||||
.select("users")
|
||||
.from(User, "users")
|
||||
.select(["users.id", "users.ref"])
|
||||
.from("users", "users")
|
||||
.getMany();
|
||||
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
|
||||
// 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 {chunk} from 'lodash';
|
||||
import {MigrationInterface, QueryRunner} from "typeorm";
|
||||
|
||||
export class UserRefUnique1664528376930 implements MigrationInterface {
|
||||
@ -9,12 +9,21 @@ export class UserRefUnique1664528376930 implements MigrationInterface {
|
||||
|
||||
// Update users that don't have unique ref set.
|
||||
const userList = await queryRunner.manager.createQueryBuilder()
|
||||
.select("users")
|
||||
.from(User, "users")
|
||||
.where("ref is null")
|
||||
.getMany();
|
||||
.select(["users.id", "users.ref"])
|
||||
.from("users", "users")
|
||||
.where("users.ref is null")
|
||||
.getMany();
|
||||
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.
|
||||
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