mirror of
https://github.com/apache/superset.git
synced 2026-08-12 11:11:01 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46be6a032d | ||
|
|
116142e57f | ||
|
|
ab59385427 |
@@ -40,7 +40,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-supersetbot/
|
||||
|
||||
- name: Set up Python ${{ inputs.python-version }}
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@e0647621c2984b5ed2f768cb892365bf2a616ad1 # v4.37.2
|
||||
uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -75,6 +75,6 @@ jobs:
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@e0647621c2984b5ed2f768cb892365bf2a616ad1 # v4.37.2
|
||||
uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
@@ -32,6 +32,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
- name: "Dependency Review"
|
||||
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
|
||||
continue-on-error: true
|
||||
with:
|
||||
fail-on-severity: critical
|
||||
# compatible/incompatible licenses addressed here: https://www.apache.org/legal/resolved.html
|
||||
|
||||
@@ -2,10 +2,10 @@ name: supersetbot orglabel based on author
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
types: [created, edited]
|
||||
|
||||
pull_request:
|
||||
types: [opened, edited]
|
||||
types: [created, edited]
|
||||
|
||||
jobs:
|
||||
superbot-orglabel:
|
||||
@@ -30,4 +30,4 @@ jobs:
|
||||
# Label the issue with the appropriate org using supersetbot
|
||||
# - this requires for the author to be publicly associated with their org
|
||||
# - and for the org to be listed in `supersetbot/src/metadata.js`
|
||||
supersetbot orglabel --issue ${{ github.event.number || github.event.issue.number }} --repo ${{ github.repository }} || true
|
||||
supersetbot orglabel --issue ${{ github.event.number }} --repo ${{ github.repository }} || true
|
||||
|
||||
@@ -21,8 +21,8 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lychee:
|
||||
# See docs here: https://github.com/lycheeverse/lychee-action
|
||||
linkinator:
|
||||
# See docs here: https://github.com/marketplace/actions/linkinator
|
||||
# Only run on pull_request, not workflow_run
|
||||
if: github.event_name == 'pull_request'
|
||||
name: Link Checking
|
||||
@@ -31,52 +31,37 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
# Restores the on-disk request cache (.lycheecache) lychee writes when
|
||||
# `--cache` is set, so re-runs don't re-check every external link from
|
||||
# scratch. Key never matches exactly (github.run_id is unique per run),
|
||||
# so actions/cache always saves a fresh copy afterwards; restore-keys
|
||||
# falls back to the most recent prior cache on lookup.
|
||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: .lycheecache
|
||||
key: lychee-cache-${{ github.run_id }}
|
||||
restore-keys: lychee-cache-
|
||||
# v2.8.0 and v2.9.0 are both on ASF Infra's action allowlist
|
||||
# (apache/infrastructure-actions approved_patterns.yml) as of this
|
||||
# writing. Do not bump past v2.9.0 without opening an Infra ticket
|
||||
# to allow the new SHA first!
|
||||
- uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2.9.0
|
||||
# Do not bump this linkinator-action version without opening
|
||||
# an ASF Infra ticket to allow the new version first!
|
||||
- uses: JustinBeckwith/linkinator-action@af984b9f30f63e796ae2ea5be5e07cb587f1bbd9 # v2.3
|
||||
continue-on-error: true # This will make the job advisory (non-blocking, no red X)
|
||||
with:
|
||||
fail: false
|
||||
args: >-
|
||||
--verbose --no-progress
|
||||
--cache --max-cache-age 7d
|
||||
--exclude '^https://github.com/apache/(superset|incubator-superset)/(pull|issues)/\d+'
|
||||
--exclude '^https://github.com/apache/(superset|incubator-superset)/commit/[a-f0-9]+'
|
||||
--exclude 'superset-frontend/.*CHANGELOG\.md'
|
||||
--exclude 'http://localhost:8088/'
|
||||
--exclude 'http://127.0.0.1:3000/'
|
||||
--exclude 'http://localhost:9001/'
|
||||
--exclude 'https://charts.bitnami.com/bitnami'
|
||||
--exclude 'https://www.li.me/'
|
||||
--exclude 'https://www.fanatics.com/'
|
||||
--exclude 'https://tails.com/gb/'
|
||||
--exclude 'https://www.techaudit.info/'
|
||||
--exclude 'https://avetilearning.com/'
|
||||
--exclude 'https://www.udemy.com/'
|
||||
--exclude 'https://trustmedis.com/'
|
||||
--exclude 'http://theiconic.com.au/'
|
||||
--exclude 'https://dev.mysql.com/doc/refman/5.7/en/innodb-limits.html'
|
||||
--exclude '^https://img\.shields\.io/.*'
|
||||
--exclude 'https://vkusvill.ru/'
|
||||
--exclude 'https://www.linkedin.com/in/mark-thomas-b16751158/'
|
||||
--exclude 'https://theiconic.com.au/'
|
||||
--exclude 'https://wattbewerb.de/'
|
||||
--exclude 'https://timbr.ai/'
|
||||
--exclude 'https://opensource.org/license/apache-2-0'
|
||||
--exclude 'https://www.plaidcloud.com/'
|
||||
'./**/*.md' './**/*.mdx'
|
||||
paths: "**/*.md, **/*.mdx"
|
||||
linksToSkip: >-
|
||||
^https://github.com/apache/(superset|incubator-superset)/(pull|issues)/\d+,
|
||||
^https://github.com/apache/(superset|incubator-superset)/commit/[a-f0-9]+,
|
||||
superset-frontend/.*CHANGELOG\.md,
|
||||
http://localhost:8088/,
|
||||
http://127.0.0.1:3000/,
|
||||
http://localhost:9001/,
|
||||
https://charts.bitnami.com/bitnami,
|
||||
https://www.li.me/,
|
||||
https://www.fanatics.com/,
|
||||
https://tails.com/gb/,
|
||||
https://www.techaudit.info/,
|
||||
https://avetilearning.com/,
|
||||
https://www.udemy.com/,
|
||||
https://trustmedis.com/,
|
||||
http://theiconic.com.au/,
|
||||
https://dev.mysql.com/doc/refman/5.7/en/innodb-limits.html,
|
||||
^https://img\.shields\.io/.*,
|
||||
https://vkusvill.ru/,
|
||||
https://www.linkedin.com/in/mark-thomas-b16751158/,
|
||||
https://theiconic.com.au/,
|
||||
https://wattbewerb.de/,
|
||||
https://timbr.ai/,
|
||||
https://opensource.org/license/apache-2-0,
|
||||
https://www.plaidcloud.com/
|
||||
|
||||
build-on-pr:
|
||||
# Build docs when PR changes docs/** (uses committed databases.json)
|
||||
|
||||
@@ -122,13 +122,25 @@ jobs:
|
||||
pattern: coverage-artifacts-*
|
||||
path: coverage/
|
||||
|
||||
- name: Reorganize test result reports
|
||||
run: |
|
||||
find coverage/
|
||||
for i in {1..8}; do
|
||||
mv coverage/coverage-artifacts-${i}/coverage-final.json coverage/coverage-shard-${i}.json
|
||||
done
|
||||
shell: bash
|
||||
|
||||
- name: Merge Code Coverage
|
||||
run: npx nyc merge coverage/ merged-output/coverage-summary.json
|
||||
|
||||
- name: Upload Code Coverage
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
with:
|
||||
flags: javascript
|
||||
use_oidc: true
|
||||
verbose: true
|
||||
directory: coverage
|
||||
disable_search: true
|
||||
files: merged-output/coverage-summary.json
|
||||
slug: apache/superset
|
||||
|
||||
lint-frontend:
|
||||
|
||||
-10
@@ -358,16 +358,6 @@ A read-only companion to the version-history endpoints: each entity type gains a
|
||||
|
||||
Authorization reuses the resource's `can_read` permission and per-object `raise_for_access`; related-entity rows are visibility-filtered to what the caller may see. The stream is empty unless version capture is on (`ENABLE_VERSIONING_CAPTURE`).
|
||||
|
||||
### Version-history retention (pruning)
|
||||
|
||||
Entity version history (the `version_transaction` / `*_version` shadow tables that back version capture) is aged out by a nightly Celery beat task, `version_history.prune_old_versions` (`superset.tasks.version_history_retention`).
|
||||
|
||||
| Key | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `SUPERSET_VERSION_HISTORY_RETENTION_DAYS` | `30` | Version rows whose owning `version_transaction.issued_at` is older than this many days are pruned. Each entity's live row (`end_transaction_id IS NULL`) is always preserved, as are the live rows of its children and associations; closed historical rows (including the baseline) age out. Set to `0` or a negative value to disable pruning. |
|
||||
|
||||
The task ships in the default `CeleryConfig.beat_schedule`; a deployment that overrides `CELERY_CONFIG` without inheriting the default will log a startup warning that the prune task is absent (so it never silently stops running). Retention only prunes whatever history exists — capture itself is gated separately by `ENABLE_VERSIONING_CAPTURE` (ships off).
|
||||
|
||||
### Webhook alerts/reports block private/internal hosts by default
|
||||
|
||||
Webhook alert/report dispatch (`WebhookNotification.send`) now validates the target URL's host against the same private/internal-IP block applied to dataset import URLs. If the resolved host is in a loopback, link-local, private (RFC-1918), shared-CGNAT, or multicast range, the webhook is rejected with `NotificationParamException`.
|
||||
|
||||
@@ -28,10 +28,8 @@ x-superset-image: &superset-image apachesuperset.docker.scarf.sh/apache/superset
|
||||
x-superset-volumes:
|
||||
&superset-volumes # /app/pythonpath_docker will be appended to the PYTHONPATH in the final container
|
||||
- ./docker:/app/docker
|
||||
- ./superset-core:/app/superset-core
|
||||
- superset_home:/app/superset_home
|
||||
x-superset-environment: &superset-environment
|
||||
# Tagged images must use their bundled packages rather than host bind mounts.
|
||||
DEV_MODE: "false"
|
||||
|
||||
services:
|
||||
redis:
|
||||
@@ -60,7 +58,6 @@ services:
|
||||
required: true
|
||||
- path: docker/.env-local # optional override
|
||||
required: false
|
||||
environment: *superset-environment
|
||||
image: *superset-image
|
||||
container_name: superset_app
|
||||
command: ["/app/docker/docker-bootstrap.sh", "app-gunicorn"]
|
||||
@@ -82,7 +79,6 @@ services:
|
||||
required: true
|
||||
- path: docker/.env-local # optional override
|
||||
required: false
|
||||
environment: *superset-environment
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_started
|
||||
@@ -102,7 +98,6 @@ services:
|
||||
required: true
|
||||
- path: docker/.env-local # optional override
|
||||
required: false
|
||||
environment: *superset-environment
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
superset-init:
|
||||
@@ -125,7 +120,6 @@ services:
|
||||
required: true
|
||||
- path: docker/.env-local # optional override
|
||||
required: false
|
||||
environment: *superset-environment
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
superset-init:
|
||||
|
||||
+12
-12
@@ -53,16 +53,16 @@
|
||||
"@emotion/core": "^11.0.0",
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@fontsource/fira-code": "^5.3.0",
|
||||
"@fontsource/ibm-plex-mono": "^5.3.0",
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
"@fontsource/fira-code": "^5.2.7",
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@mdx-js/react": "^3.1.1",
|
||||
"@saucelabs/theme-github-codeblock": "^0.3.0",
|
||||
"@storybook/addon-docs": "^10.5.3",
|
||||
"@storybook/addon-docs": "^10.5.0",
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"@swc/core": "^1.15.46",
|
||||
"@swc/core": "^1.15.43",
|
||||
"antd": "^6.5.1",
|
||||
"baseline-browser-mapping": "^2.10.44",
|
||||
"baseline-browser-mapping": "^2.10.43",
|
||||
"caniuse-lite": "^1.0.30001806",
|
||||
"docusaurus-plugin-openapi-docs": "^5.1.2",
|
||||
"docusaurus-theme-openapi-docs": "^5.1.2",
|
||||
@@ -77,8 +77,8 @@
|
||||
"react-table": "^7.8.0",
|
||||
"remark-import-partial": "^0.0.2",
|
||||
"reselect": "^5.2.0",
|
||||
"storybook": "^10.5.3",
|
||||
"swagger-ui-react": "^5.32.9",
|
||||
"storybook": "^10.5.0",
|
||||
"swagger-ui-react": "^5.32.8",
|
||||
"swc-loader": "^0.2.7",
|
||||
"tinycolor2": "^1.4.2",
|
||||
"unist-util-visit": "^5.1.0"
|
||||
@@ -89,16 +89,16 @@
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/react": "^19.1.8",
|
||||
"@typescript-eslint/eslint-plugin": "^8.65.0",
|
||||
"@typescript-eslint/parser": "^8.65.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.64.0",
|
||||
"@typescript-eslint/parser": "^8.64.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"globals": "^17.7.0",
|
||||
"prettier": "^3.9.6",
|
||||
"prettier": "^3.9.5",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.65.0",
|
||||
"typescript-eslint": "^8.64.0",
|
||||
"webpack": "^5.108.2"
|
||||
},
|
||||
"browserslist": {
|
||||
|
||||
+184
-184
@@ -2529,20 +2529,20 @@
|
||||
resolved "https://registry.yarnpkg.com/@faker-js/faker/-/faker-5.5.3.tgz#18e3af6b8eae7984072bbeb0c0858474d7c4cefe"
|
||||
integrity sha512-R11tGE6yIFwqpaIqcfkcg7AICXzFg14+5h5v0TfF/9+RMDL6jhzCy/pxHVOfbALGdtVYdt6JdR21tuxEgl34dw==
|
||||
|
||||
"@fontsource/fira-code@^5.3.0":
|
||||
version "5.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@fontsource/fira-code/-/fira-code-5.3.0.tgz#487475cedfa1f7ba7650810ddce00b6c7b1b7a25"
|
||||
integrity sha512-EJL968RJRkakubAj/coU8pSUaeTE5UNoRjtzAr6kGiSZ3jWuN8/AKWHwym/PFUaQL1q7IL/H+EXs4358YhrTBQ==
|
||||
"@fontsource/fira-code@^5.2.7":
|
||||
version "5.2.7"
|
||||
resolved "https://registry.yarnpkg.com/@fontsource/fira-code/-/fira-code-5.2.7.tgz#9ecbd909d53e7196a5d895b601747fe34491fc6a"
|
||||
integrity sha512-tnB9NNund9TwIym8/7DMJe573nlPEQb+fKUV5GL8TBYXjIhDvL0D7mgmNVNQUPhXp+R7RylQeiBdkA4EbOHPGQ==
|
||||
|
||||
"@fontsource/ibm-plex-mono@^5.3.0":
|
||||
version "5.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.3.0.tgz#1879699d104602d5331e28e103f33c3b1b766b17"
|
||||
integrity sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==
|
||||
"@fontsource/ibm-plex-mono@^5.2.7":
|
||||
version "5.2.7"
|
||||
resolved "https://registry.yarnpkg.com/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.2.7.tgz#ef5b6f052115fdf6666208a5f8a0f13fcd7ba1fd"
|
||||
integrity sha512-MKAb8qV+CaiMQn2B0dIi1OV3565NYzp3WN5b4oT6LTkk+F0jR6j0ZN+5BKJiIhffDC3rtBULsYZE65+0018z9w==
|
||||
|
||||
"@fontsource/inter@^5.3.0":
|
||||
version "5.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@fontsource/inter/-/inter-5.3.0.tgz#866278dc2357659f8be1755c8b67f29d3697b440"
|
||||
integrity sha512-RofMylZmjlJEfELXeNHFWBRcSs75rGU/6bV2S2jfnvv/3rPXPGe0LgUJTklcHZ9lM4OZmAVFhcJPnACfb91A3g==
|
||||
"@fontsource/inter@^5.2.8":
|
||||
version "5.2.8"
|
||||
resolved "https://registry.yarnpkg.com/@fontsource/inter/-/inter-5.2.8.tgz#10c95d877d972c7de5bd4592309d42fb6a5e1a5b"
|
||||
integrity sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==
|
||||
|
||||
"@hapi/hoek@^9.0.0", "@hapi/hoek@^9.3.0":
|
||||
version "9.3.0"
|
||||
@@ -4005,23 +4005,23 @@
|
||||
resolved "https://registry.yarnpkg.com/@standard-schema/utils/-/utils-0.3.0.tgz#3d5e608f16c2390c10528e98e59aef6bf73cae7b"
|
||||
integrity sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==
|
||||
|
||||
"@storybook/addon-docs@^10.5.3":
|
||||
version "10.5.3"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-10.5.3.tgz#b5666cf20361d85c98cc836b2bfcdbbb72af3e97"
|
||||
integrity sha512-MI1VDMSMQk78YxjIdt7WlrVOiA3TzTP00lRed1LeXh0fCvA9jxz9YXJI2+XigsLaxCSuOAEf/l35/GTLDMHD8A==
|
||||
"@storybook/addon-docs@^10.5.0":
|
||||
version "10.5.2"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-10.5.2.tgz#e8023d1a68f637de9188be093f719895a8252d19"
|
||||
integrity sha512-MoBANDsh5qEA14U+JaBoQcYsKbayJDDMopigFN0NdVAsZTdBfVIsL7cnjTFBL6ubB3ifb5M0tCXbScpml1KqiQ==
|
||||
dependencies:
|
||||
"@mdx-js/react" "^3.0.0"
|
||||
"@storybook/csf-plugin" "10.5.3"
|
||||
"@storybook/csf-plugin" "10.5.2"
|
||||
"@storybook/icons" "^2.0.2"
|
||||
"@storybook/react-dom-shim" "10.5.3"
|
||||
"@storybook/react-dom-shim" "10.5.2"
|
||||
react "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
react-dom "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
ts-dedent "^2.0.0"
|
||||
|
||||
"@storybook/csf-plugin@10.5.3":
|
||||
version "10.5.3"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-10.5.3.tgz#0818709c2761910f1e42710f6231f773eb97fb41"
|
||||
integrity sha512-mkPq6zru8fN5+46uC1cZEbKW2ws1hh9KvF4g4/Gu8pNbKnvqULPhk0/Bf0ZCtlr7zI7DvcFhyCy3dbvN+2n4Gw==
|
||||
"@storybook/csf-plugin@10.5.2":
|
||||
version "10.5.2"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-10.5.2.tgz#a359dffe0b842dfffacb0e94ad98da0ccb13ba69"
|
||||
integrity sha512-PK/wXiALFf88mt4HmDtiZJ6NRvhExSXEM9uFIN+OIHxGqg7Xbp6MB0SPdhsTbMY9720ahiu/DJx5iIzkidcA3w==
|
||||
dependencies:
|
||||
unplugin "^2.3.5"
|
||||
|
||||
@@ -4035,10 +4035,10 @@
|
||||
resolved "https://registry.yarnpkg.com/@storybook/icons/-/icons-2.1.0.tgz#edfc2450a39c5e780f28c6cbc49acd7bff59b41a"
|
||||
integrity sha512-Fxh9vYpX9bQqFeHRiY8h2ApeRGDzRSMLwJwNZ/AIRqnyOKHxRKL+yFe+ctEkVJmuptRE9u1Hrn8ZZNHyfDKKNg==
|
||||
|
||||
"@storybook/react-dom-shim@10.5.3":
|
||||
version "10.5.3"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-10.5.3.tgz#e176a6549aa02002e375cb66467a00c5403745f8"
|
||||
integrity sha512-eUWBsRRax5R3MDJVFs/CrFDF1bYS58AMB9tX02lLRuiZe6xy1cKh3CRFS+2xH571l0fNaXQ+7j69TOJ0fk2tmA==
|
||||
"@storybook/react-dom-shim@10.5.2":
|
||||
version "10.5.2"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-10.5.2.tgz#993077c517ee1a1c065c82bf0143ae08856a3c90"
|
||||
integrity sha512-TbdYVLuD7gwj1CFsDJhCHUiwfVmzFWzalKEUGy9XgXyNpyOV1CYRsdmRdhaOHgmn2ljQZuTAxSnG7NlElghVaw==
|
||||
|
||||
"@superset-ui/core@^0.20.4":
|
||||
version "0.20.4"
|
||||
@@ -4697,86 +4697,86 @@
|
||||
dependencies:
|
||||
apg-lite "^1.0.4"
|
||||
|
||||
"@swc/core-darwin-arm64@1.15.46":
|
||||
version "1.15.46"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.46.tgz#393903c7eda790dbd89abd8fa0afdd9041543e5f"
|
||||
integrity sha512-IsISIT22EfktVJrlvIpnAxG2u/A9aob9l99HMlx80x72WlFmFPk1V3UhkEzx86eJP8hw049KTFv/RISho2cq2Q==
|
||||
"@swc/core-darwin-arm64@1.15.43":
|
||||
version "1.15.43"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.43.tgz#386294f8427dde2df1a70dd0a5826d67af70e996"
|
||||
integrity sha512-v1aVuvXdo/BHxJzco9V2xpHrvwWmhfS8t6gziY5wJxd+Z2h8AeJRnAwPD8itCDaGXVBwJ/CaKfxEzTkG0Va0OA==
|
||||
|
||||
"@swc/core-darwin-x64@1.15.46":
|
||||
version "1.15.46"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.15.46.tgz#ddf16787e320636621180df480a3490fd9a868ca"
|
||||
integrity sha512-4Tj4ppVIPCmUMpmGFiGtyEriwLyJ+yi/US4WfBrP/ok8COGddDZXLEzQETnKyK46mjvr1v0jevrS23zjoff7vA==
|
||||
"@swc/core-darwin-x64@1.15.43":
|
||||
version "1.15.43"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.15.43.tgz#c4823529c424e2ae25b7eb786438474741521fcb"
|
||||
integrity sha512-lp3d4Lamc8dt5huYdGLSR+9hLxmfr1jb0l+4XXG2zPqZwYWRN9R0U2qYoTrggiU2RWW0oV9VbWM3kBnqIc2kdQ==
|
||||
|
||||
"@swc/core-linux-arm-gnueabihf@1.15.46":
|
||||
version "1.15.46"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.46.tgz#7bee01b7311c43b913771ef9c7012931871de73b"
|
||||
integrity sha512-i8tUGnNjyOgMmfmgFSg4aeJLQoFyfpIHK5FjpQAwpRyQIqEUB2w1e8zIDQzY1WhOxx8NoS1S5iUL813Un4Sf5A==
|
||||
"@swc/core-linux-arm-gnueabihf@1.15.43":
|
||||
version "1.15.43"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.43.tgz#c0a0ed17cffc5d4af192935667f12f05feeb39f9"
|
||||
integrity sha512-JWTQQELtsG5GgphDrr/XqqmM2pDN3cZqbMS0Mrg+iTiXL3F74sn/S2IyYE/5u4h2KLkTf9qQ7dXyxsbx7YzkeA==
|
||||
|
||||
"@swc/core-linux-arm64-gnu@1.15.46":
|
||||
version "1.15.46"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.46.tgz#964596d757d18f04a02873d85a3660416c09c187"
|
||||
integrity sha512-c0OnhqzdhfOvv6qhNCcByepB+sNYOGZyhtr2Qa6ZCHvAWTYhSRw4j/u92Stue9PbZ/6q74b9nHzi76+kVzqQHQ==
|
||||
"@swc/core-linux-arm64-gnu@1.15.43":
|
||||
version "1.15.43"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.43.tgz#1eb2d9c5eeee5bb9d00599b475ddc31dc2870d22"
|
||||
integrity sha512-B4otJRdPWIsmiSBf0uG7Z/+vMWmkufjz5MmYxubwKuZazDW14Zd3symga1N62QR4RT+kEFeHEgsXfZGyn/w0hw==
|
||||
|
||||
"@swc/core-linux-arm64-musl@1.15.46":
|
||||
version "1.15.46"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.46.tgz#213d3ece772689a8166ed51064836346c6ce1c2a"
|
||||
integrity sha512-imyRpNEcUzFQFV2LE4jL68ErvmKEuZCbvZru77iQREunJ+bR4i658cupTgtG1mLYM3F1Tzy3Sb9xYb02KghWTg==
|
||||
"@swc/core-linux-arm64-musl@1.15.43":
|
||||
version "1.15.43"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.43.tgz#ea6b5c38088f3921a57922d3931b2d74fd23a9fd"
|
||||
integrity sha512-6zB6OnpViBxYy4tgY3v2i6AZY9fwkcHZ032UOwtwUuW1d19sdT07qF0kZe6/3UR1tUaK6jjg2rmVcUIBCEYVjQ==
|
||||
|
||||
"@swc/core-linux-ppc64-gnu@1.15.46":
|
||||
version "1.15.46"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.46.tgz#4d2ec554103c6bef60cc1e294f374ea5a5edaf78"
|
||||
integrity sha512-ctEfcl/HcUeomK33cbySiHZm98GEDIxTm1EkpBsYCiHxElYBzvTXVeuQT2YwbUXn9XCrjiw4ipyUNk33k26qRg==
|
||||
"@swc/core-linux-ppc64-gnu@1.15.43":
|
||||
version "1.15.43"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.43.tgz#538fac30bbd5f1e678bb7bac9ccc62246a6f6d7a"
|
||||
integrity sha512-coxE1ZWdB3uSDVNoEtYNrRi/1epvckZx9cTJ8ICUxTMTxGk+yvQ/Twacp3ruZSaMPGCriUjP86C37VhaT6nyRg==
|
||||
|
||||
"@swc/core-linux-s390x-gnu@1.15.46":
|
||||
version "1.15.46"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.46.tgz#097a19792ec22e2f51f6bfac02da1e0b3f5e5bb1"
|
||||
integrity sha512-DxlMdnt84TtRVTv7WL/thWyz9+QU8QZNNoAP9rrk0P68LziuhfePp8MjQ44zIprpTHTsEwyziIuGUUN5iSC1bQ==
|
||||
"@swc/core-linux-s390x-gnu@1.15.43":
|
||||
version "1.15.43"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.43.tgz#ee564b45f3f578b1fc82136c4dab163189316641"
|
||||
integrity sha512-lXfLhs+LpBsD5inuYx+YDH5WsPPBQ95KPUiy8P5wq9ob9xKDZFqwNfU2QW6bGO8NqRO/H9JQomTSt5Yyh+FGfA==
|
||||
|
||||
"@swc/core-linux-x64-gnu@1.15.46":
|
||||
version "1.15.46"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.46.tgz#39c1ca215f9ca643a4aa3ca6250cc38ba5f5c673"
|
||||
integrity sha512-SKxI7J6t90XPl8hRUqtJi9NfGdunN/E/vZMc7Bc0figeRdOPDBT+Tm8g7cx9xM0T0mewh2l+8dewa3Am27/P+A==
|
||||
"@swc/core-linux-x64-gnu@1.15.43":
|
||||
version "1.15.43"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.43.tgz#e6e3bfea76921c7f5e16d50a126615f2e04ce1c8"
|
||||
integrity sha512-07XnKwTmKy8TGOZG3D9fRnLWGynxPjwQnZLVmBFbo6F+7vHYzBIOuwXEhemrChBWb6yDNZsVCcMWCPX6FDD2xg==
|
||||
|
||||
"@swc/core-linux-x64-musl@1.15.46":
|
||||
version "1.15.46"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.46.tgz#323a720bc965fffeedacdc3167b46a291553b5e0"
|
||||
integrity sha512-qj9T6B7bosI0VEsrWOVXZN1OXxS8Tp63ywyrLxNdOycnUtLdkgYcoBsN5y8ImnDDsnwrEWZOy1e+J4xSe7mA3Q==
|
||||
"@swc/core-linux-x64-musl@1.15.43":
|
||||
version "1.15.43"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.43.tgz#539f6f2721c0cc32e5db5cf0d453c82045f6662d"
|
||||
integrity sha512-TJc+bsSIaBh+hZvZ5GRtW/K1bw66TJ9vsUwvVIsZdiWxU5ObLwZvfcnZ3UpgVfMnFibRes9uriJrQNBHEEogRQ==
|
||||
|
||||
"@swc/core-win32-arm64-msvc@1.15.46":
|
||||
version "1.15.46"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.46.tgz#9c2cfd2a59be74671a018097b8914f8cfbcc698d"
|
||||
integrity sha512-8p7l4c3LU+eA5g9Et1JPhNeMC1oQwXTGU+uah8DPIBX7YXzqswvaBtyKVmXefVGi/DJU1x3YJsc3mbAp9aWzSQ==
|
||||
"@swc/core-win32-arm64-msvc@1.15.43":
|
||||
version "1.15.43"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.43.tgz#b7bb6b611d484ac19d0ee21469e7012d646c28b5"
|
||||
integrity sha512-jfd7s2/bUQYkOHLs+LWQNKZdmDa8+sufKLllhpWAhVQ2GDCwsHe3vR/j+OSiItZNtkzFuaawa3+SAKz9y5gYfw==
|
||||
|
||||
"@swc/core-win32-ia32-msvc@1.15.46":
|
||||
version "1.15.46"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.46.tgz#bd7bd009a47b0f9826212e7ed36385d32fe193d8"
|
||||
integrity sha512-tUEnfr3Bn9u6FOjUb3PN9p+09qZC2j+wNDLKHzXXZn22rqGcUqR/ohCRSS+nG9B9+X+U+3FewNEHJkTmdIvMjQ==
|
||||
"@swc/core-win32-ia32-msvc@1.15.43":
|
||||
version "1.15.43"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.43.tgz#e5b25722a7d27bb0c9a9bdee7863f29c8674364e"
|
||||
integrity sha512-rLAE8JvucqEW1ZGohxPQrQWPBQeJG4+ypKbWfdlU/qmKScvCkxf9/Jxnzki1dkUQCQ7P5Enp13RlvqOlvx/32g==
|
||||
|
||||
"@swc/core-win32-x64-msvc@1.15.46":
|
||||
version "1.15.46"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.46.tgz#8371845a5bdb330cf05b009f602bb8c4636c6beb"
|
||||
integrity sha512-Vux7UDzBJYQggSuPfcl2w9iu+IJpgpRCxHzgCaVkELnAXAE4XZMOTX9HNcaNiwfeIDqdu2rkr69RuDm6wY8neA==
|
||||
"@swc/core-win32-x64-msvc@1.15.43":
|
||||
version "1.15.43"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.43.tgz#d28842621201c345383d468d40c09648b6cd6e68"
|
||||
integrity sha512-h8MLDHZcfIukwQWj03rIJZx1I0E81AYj2X7J/nGErG4nz+QAv6G1Z+peotvinL3lqpbo32tLYSMFo32/ySzxKg==
|
||||
|
||||
"@swc/core@^1.15.40", "@swc/core@^1.15.46":
|
||||
version "1.15.46"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.15.46.tgz#8acc0f68ee55010fdc876adf2a8faf0b097c681b"
|
||||
integrity sha512-Ri3em2mBpq3h2zSPliCYl63otDGqek8PPEfv2nWgRQEbZ/VBCNyypVTVQ6cEbTCXBhy+WE2T3fQb08moIyuYaw==
|
||||
"@swc/core@^1.15.40", "@swc/core@^1.15.43":
|
||||
version "1.15.43"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.15.43.tgz#653e6573968fd5c74163b9885ea0a933012c9f22"
|
||||
integrity sha512-1CuKjFkPxIgGdeHVuNbkxmBxkcbdc08u0aiI43pFq6yY1tTVKmXT9hFEooyyKs/sJ3xf1GPHyEwTtk9Xl8dvQw==
|
||||
dependencies:
|
||||
"@swc/counter" "^0.1.3"
|
||||
"@swc/types" "^0.1.27"
|
||||
optionalDependencies:
|
||||
"@swc/core-darwin-arm64" "1.15.46"
|
||||
"@swc/core-darwin-x64" "1.15.46"
|
||||
"@swc/core-linux-arm-gnueabihf" "1.15.46"
|
||||
"@swc/core-linux-arm64-gnu" "1.15.46"
|
||||
"@swc/core-linux-arm64-musl" "1.15.46"
|
||||
"@swc/core-linux-ppc64-gnu" "1.15.46"
|
||||
"@swc/core-linux-s390x-gnu" "1.15.46"
|
||||
"@swc/core-linux-x64-gnu" "1.15.46"
|
||||
"@swc/core-linux-x64-musl" "1.15.46"
|
||||
"@swc/core-win32-arm64-msvc" "1.15.46"
|
||||
"@swc/core-win32-ia32-msvc" "1.15.46"
|
||||
"@swc/core-win32-x64-msvc" "1.15.46"
|
||||
"@swc/core-darwin-arm64" "1.15.43"
|
||||
"@swc/core-darwin-x64" "1.15.43"
|
||||
"@swc/core-linux-arm-gnueabihf" "1.15.43"
|
||||
"@swc/core-linux-arm64-gnu" "1.15.43"
|
||||
"@swc/core-linux-arm64-musl" "1.15.43"
|
||||
"@swc/core-linux-ppc64-gnu" "1.15.43"
|
||||
"@swc/core-linux-s390x-gnu" "1.15.43"
|
||||
"@swc/core-linux-x64-gnu" "1.15.43"
|
||||
"@swc/core-linux-x64-musl" "1.15.43"
|
||||
"@swc/core-win32-arm64-msvc" "1.15.43"
|
||||
"@swc/core-win32-ia32-msvc" "1.15.43"
|
||||
"@swc/core-win32-x64-msvc" "1.15.43"
|
||||
|
||||
"@swc/counter@^0.1.3":
|
||||
version "0.1.3"
|
||||
@@ -5527,100 +5527,100 @@
|
||||
dependencies:
|
||||
"@types/yargs-parser" "*"
|
||||
|
||||
"@typescript-eslint/eslint-plugin@8.65.0", "@typescript-eslint/eslint-plugin@^8.65.0":
|
||||
version "8.65.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz#0a58df6fea8c0bf6b396f518077099bc8b762bb5"
|
||||
integrity sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==
|
||||
"@typescript-eslint/eslint-plugin@8.64.0", "@typescript-eslint/eslint-plugin@^8.64.0":
|
||||
version "8.64.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.64.0.tgz#71a0c3d5f8a5e6c5dfdb4f0f04bd1bfb572d5e24"
|
||||
integrity sha512-CGvQPBxN3wZLu6Rz2kFUpZeoCm78xUic92ck39KPePkO1NPOwjCqdQnm5Q87tpWw9vcBvW8XLrDXjH9PWYtJ3Q==
|
||||
dependencies:
|
||||
"@eslint-community/regexpp" "^4.12.2"
|
||||
"@typescript-eslint/scope-manager" "8.65.0"
|
||||
"@typescript-eslint/type-utils" "8.65.0"
|
||||
"@typescript-eslint/utils" "8.65.0"
|
||||
"@typescript-eslint/visitor-keys" "8.65.0"
|
||||
"@typescript-eslint/scope-manager" "8.64.0"
|
||||
"@typescript-eslint/type-utils" "8.64.0"
|
||||
"@typescript-eslint/utils" "8.64.0"
|
||||
"@typescript-eslint/visitor-keys" "8.64.0"
|
||||
ignore "^7.0.5"
|
||||
natural-compare "^1.4.0"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/parser@8.65.0", "@typescript-eslint/parser@^8.65.0":
|
||||
version "8.65.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.65.0.tgz#5295c1058c0a1dd746ef28baaf9c0341dbdf03dc"
|
||||
integrity sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==
|
||||
"@typescript-eslint/parser@8.64.0", "@typescript-eslint/parser@^8.64.0":
|
||||
version "8.64.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.64.0.tgz#c9864a1cc28a13ff29a7314fbdef0528bb122f72"
|
||||
integrity sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==
|
||||
dependencies:
|
||||
"@typescript-eslint/scope-manager" "8.65.0"
|
||||
"@typescript-eslint/types" "8.65.0"
|
||||
"@typescript-eslint/typescript-estree" "8.65.0"
|
||||
"@typescript-eslint/visitor-keys" "8.65.0"
|
||||
"@typescript-eslint/scope-manager" "8.64.0"
|
||||
"@typescript-eslint/types" "8.64.0"
|
||||
"@typescript-eslint/typescript-estree" "8.64.0"
|
||||
"@typescript-eslint/visitor-keys" "8.64.0"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/project-service@8.65.0":
|
||||
version "8.65.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.65.0.tgz#65fbbc9a1591abffaeab5513200f848271cb0aa5"
|
||||
integrity sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==
|
||||
"@typescript-eslint/project-service@8.64.0":
|
||||
version "8.64.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.64.0.tgz#14c4e29390d7325a7f8a1218c2788fd649b85da6"
|
||||
integrity sha512-tk4WpOJ6IEbGrVHaNmM0YRrwAD3exZlIK3iadQNAxh4YKk6jvUQ4ecq18n+v7+meh+cJ3j+D8nbk8sRKhlwLQg==
|
||||
dependencies:
|
||||
"@typescript-eslint/tsconfig-utils" "^8.65.0"
|
||||
"@typescript-eslint/types" "^8.65.0"
|
||||
"@typescript-eslint/tsconfig-utils" "^8.64.0"
|
||||
"@typescript-eslint/types" "^8.64.0"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/scope-manager@8.65.0":
|
||||
version "8.65.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz#9547202ce7e608e7b6283df585703b980a0ea70d"
|
||||
integrity sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==
|
||||
"@typescript-eslint/scope-manager@8.64.0":
|
||||
version "8.64.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.64.0.tgz#d45f15304a94c85c39db317b717b158fb6259958"
|
||||
integrity sha512-CXEaFdYXjSTgKhisNkwCcJwTP8Pl+fmRrEQrri4nm3vU743bALrxzLmq7fHG/7e6a5xO0lDYeURpZmBuhHk54w==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.65.0"
|
||||
"@typescript-eslint/visitor-keys" "8.65.0"
|
||||
"@typescript-eslint/types" "8.64.0"
|
||||
"@typescript-eslint/visitor-keys" "8.64.0"
|
||||
|
||||
"@typescript-eslint/tsconfig-utils@8.65.0", "@typescript-eslint/tsconfig-utils@^8.65.0":
|
||||
version "8.65.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz#36f168fcdbb1295f7446ff0379667f98c3cf1bf3"
|
||||
integrity sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==
|
||||
"@typescript-eslint/tsconfig-utils@8.64.0", "@typescript-eslint/tsconfig-utils@^8.64.0":
|
||||
version "8.64.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.64.0.tgz#c62ac8ea9173c3cac8b38b8e66e30a046b548851"
|
||||
integrity sha512-2yo8rRNKuzbVWQp5kslhANqZ2uDAeROQHBRZNPu8JDsHmeFNj/XJJhX/FhNUWmkHHvoNsKa6+tHJiig87EzsQw==
|
||||
|
||||
"@typescript-eslint/type-utils@8.65.0":
|
||||
version "8.65.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz#d316d7522d93cff4cd14f305e02f3df2d804f9c1"
|
||||
integrity sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==
|
||||
"@typescript-eslint/type-utils@8.64.0":
|
||||
version "8.64.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.64.0.tgz#106fa7d58cf9cf7758f3dd8e426ac8237eceacf3"
|
||||
integrity sha512-XWG4Fmmv/6SvyS9nH8jWrKs6terwJvE8cyRt1CzYYqzp9OrPhCT4cMc/f7C6RZCwG+qMmiffJS1/qJP8G1URtg==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.65.0"
|
||||
"@typescript-eslint/typescript-estree" "8.65.0"
|
||||
"@typescript-eslint/utils" "8.65.0"
|
||||
"@typescript-eslint/types" "8.64.0"
|
||||
"@typescript-eslint/typescript-estree" "8.64.0"
|
||||
"@typescript-eslint/utils" "8.64.0"
|
||||
debug "^4.4.3"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/types@8.65.0", "@typescript-eslint/types@^8.65.0":
|
||||
version "8.65.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.65.0.tgz#3e86738416a777c8b8925ab46745f48ecf904c9f"
|
||||
integrity sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==
|
||||
"@typescript-eslint/types@8.64.0", "@typescript-eslint/types@^8.64.0":
|
||||
version "8.64.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.64.0.tgz#b41f8ef5dd40616908658b991197a9d486cda60b"
|
||||
integrity sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==
|
||||
|
||||
"@typescript-eslint/typescript-estree@8.65.0":
|
||||
version "8.65.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz#f1f514808f6aa713e2d678ae8ff592a65e1632af"
|
||||
integrity sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==
|
||||
"@typescript-eslint/typescript-estree@8.64.0":
|
||||
version "8.64.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.64.0.tgz#b8d51255e2d726eb4bd80d397a4fb4170c02eecc"
|
||||
integrity sha512-Pztpsn1aCE1oWDvDEfUk31nngvvF7vUB5SwHFEaZIFpvw7WJtqUHHL4plBZDA9HfWJJjL13BdG0YrJInTUvoVA==
|
||||
dependencies:
|
||||
"@typescript-eslint/project-service" "8.65.0"
|
||||
"@typescript-eslint/tsconfig-utils" "8.65.0"
|
||||
"@typescript-eslint/types" "8.65.0"
|
||||
"@typescript-eslint/visitor-keys" "8.65.0"
|
||||
"@typescript-eslint/project-service" "8.64.0"
|
||||
"@typescript-eslint/tsconfig-utils" "8.64.0"
|
||||
"@typescript-eslint/types" "8.64.0"
|
||||
"@typescript-eslint/visitor-keys" "8.64.0"
|
||||
debug "^4.4.3"
|
||||
minimatch "^10.2.2"
|
||||
semver "^7.7.3"
|
||||
tinyglobby "^0.2.15"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/utils@8.65.0":
|
||||
version "8.65.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.65.0.tgz#afedd974a0c8deeef553b509df5800bafd615a72"
|
||||
integrity sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==
|
||||
"@typescript-eslint/utils@8.64.0":
|
||||
version "8.64.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.64.0.tgz#98bb2010cfb754b41985b9c93e6e8b3dcd7bd600"
|
||||
integrity sha512-aJUGVB3+U0htrrCjoA8qukw8cm8fNCGAxK/tVoS70k8aeb7DETKeFozRiVFIwEeN9WJLsjaP3ph8I60tY2XZoQ==
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils" "^4.9.1"
|
||||
"@typescript-eslint/scope-manager" "8.65.0"
|
||||
"@typescript-eslint/types" "8.65.0"
|
||||
"@typescript-eslint/typescript-estree" "8.65.0"
|
||||
"@typescript-eslint/scope-manager" "8.64.0"
|
||||
"@typescript-eslint/types" "8.64.0"
|
||||
"@typescript-eslint/typescript-estree" "8.64.0"
|
||||
|
||||
"@typescript-eslint/visitor-keys@8.65.0":
|
||||
version "8.65.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz#e3704c13cb4a1c22454c1abf28ff4737e15018c6"
|
||||
integrity sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==
|
||||
"@typescript-eslint/visitor-keys@8.64.0":
|
||||
version "8.64.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.64.0.tgz#7a08421d10e54960733352cd7c95fab1784e8473"
|
||||
integrity sha512-mrtuL8Nsn6gi2H4mo5KMTp823M+3Q19Ew/i+Zlikq20tIMm99C3Ez0dCmkWWnxut20esQvTg8aUSEhMcAOXhEw==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.65.0"
|
||||
"@typescript-eslint/types" "8.64.0"
|
||||
eslint-visitor-keys "^5.0.0"
|
||||
|
||||
"@ungap/structured-clone@^1.0.0":
|
||||
@@ -6368,10 +6368,10 @@ base64-js@^1.3.1, base64-js@^1.5.1:
|
||||
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
|
||||
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
|
||||
|
||||
baseline-browser-mapping@^2.10.38, baseline-browser-mapping@^2.10.44, baseline-browser-mapping@^2.9.19:
|
||||
version "2.10.44"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.44.tgz#2e074675e640b67cb3835c54c11aa2dd9119c0f8"
|
||||
integrity sha512-T3ghW+sl/ZJ8w1v/yQx3qvJ9040DWoLBz8JT/CILbAKcFyG9b2MRe75v6W5uXjv6uH1lumK2Kv46y2zSkcej0Q==
|
||||
baseline-browser-mapping@^2.10.38, baseline-browser-mapping@^2.10.43, baseline-browser-mapping@^2.9.19:
|
||||
version "2.10.43"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz#7b5d11590ce5acdbe4859443e3c940e81ce8c02d"
|
||||
integrity sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==
|
||||
|
||||
batch@0.6.1:
|
||||
version "0.6.1"
|
||||
@@ -7007,7 +7007,7 @@ copy-text-to-clipboard@^3.2.0:
|
||||
resolved "https://registry.yarnpkg.com/copy-text-to-clipboard/-/copy-text-to-clipboard-3.2.2.tgz#99bc79db3f2d355ec33a08d573aff6804491ddb9"
|
||||
integrity sha512-T6SqyLd1iLuqPA90J5N4cTalrtovCySh58iiZDGJ6FGznbclKh4UI+FGacQSgFzwKG77W7XT5gwbVEbd9cIH1A==
|
||||
|
||||
copy-to-clipboard@^3.3.3:
|
||||
copy-to-clipboard@^3.3.1:
|
||||
version "3.3.3"
|
||||
resolved "https://registry.yarnpkg.com/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz#55ac43a1db8ae639a4bd99511c148cdd1b83a1b0"
|
||||
integrity sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==
|
||||
@@ -13029,10 +13029,10 @@ prettier-linter-helpers@^1.0.1:
|
||||
dependencies:
|
||||
fast-diff "^1.1.2"
|
||||
|
||||
prettier@^3.9.6:
|
||||
version "3.9.6"
|
||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.9.6.tgz#b3ea5146515d40fc53f18aa63f74dfab1e10dbf6"
|
||||
integrity sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==
|
||||
prettier@^3.9.5:
|
||||
version "3.9.5"
|
||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.9.5.tgz#4fec97736e33b9d0b620b48914fe93b530e835ad"
|
||||
integrity sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==
|
||||
|
||||
pretty-error@^4.0.0:
|
||||
version "4.0.0"
|
||||
@@ -13243,12 +13243,12 @@ rc@1.2.8:
|
||||
minimist "^1.2.0"
|
||||
strip-json-comments "~2.0.1"
|
||||
|
||||
react-copy-to-clipboard@5.1.1:
|
||||
version "5.1.1"
|
||||
resolved "https://registry.yarnpkg.com/react-copy-to-clipboard/-/react-copy-to-clipboard-5.1.1.tgz#76adb8be03616e99692fcf3f762365ed3fb5ff16"
|
||||
integrity sha512-s+HrzLyJBxrpGTYXF15dTgMjAJpEPZT/Yp6NytAtZMRngejxt6Pt5WrfFxLAcsqUDU6sY1Jz6tyHwIicE1U2Xg==
|
||||
react-copy-to-clipboard@5.1.0:
|
||||
version "5.1.0"
|
||||
resolved "https://registry.yarnpkg.com/react-copy-to-clipboard/-/react-copy-to-clipboard-5.1.0.tgz#09aae5ec4c62750ccb2e6421a58725eabc41255c"
|
||||
integrity sha512-k61RsNgAayIJNoy9yDsYzDe/yAZAzEbEgcz3DZMhF686LEyukcE1hzurxe85JandPUG+yTfGVFzuEw3xt8WP/A==
|
||||
dependencies:
|
||||
copy-to-clipboard "^3.3.3"
|
||||
copy-to-clipboard "^3.3.1"
|
||||
prop-types "^15.8.1"
|
||||
|
||||
react-debounce-input@=3.3.0:
|
||||
@@ -14627,10 +14627,10 @@ stop-iteration-iterator@^1.1.0:
|
||||
es-errors "^1.3.0"
|
||||
internal-slot "^1.1.0"
|
||||
|
||||
storybook@^10.5.3:
|
||||
version "10.5.3"
|
||||
resolved "https://registry.yarnpkg.com/storybook/-/storybook-10.5.3.tgz#72ee7cc02e3b6353eeec9633c2367437cf2ac86e"
|
||||
integrity sha512-c8Wumu5qz0N2fnzWBxcPzUsY+8BpKBKChNyl4BEh9qhMV6KW587gL8il8emRB+4Hay+zMjDHA7cIeTkl4FKYuw==
|
||||
storybook@^10.5.0:
|
||||
version "10.5.2"
|
||||
resolved "https://registry.yarnpkg.com/storybook/-/storybook-10.5.2.tgz#751aca53cbeea086886d84ceb46c8a29a5f802f3"
|
||||
integrity sha512-zkYxVZoDMj8njzZc3EH5UyY7885wpi9a1mmWVwFiNHSo+i5r2Go84E2OI1cdOctRymLkNvgs1j5jqAKA0ftBqg==
|
||||
dependencies:
|
||||
"@storybook/global" "^5.0.0"
|
||||
"@storybook/icons" "^2.0.2"
|
||||
@@ -14925,10 +14925,10 @@ swagger-client@^3.37.4:
|
||||
ramda "^0.30.1"
|
||||
ramda-adjunct "^5.1.0"
|
||||
|
||||
swagger-ui-react@^5.32.9:
|
||||
version "5.32.9"
|
||||
resolved "https://registry.yarnpkg.com/swagger-ui-react/-/swagger-ui-react-5.32.9.tgz#d0e8521091b270e35f8d87771fba49eede425b38"
|
||||
integrity sha512-zs6PG4G6XEWG2evKHSwx32nWXIlk48a9Hw9YS9hlt55O5W0FM1cpltMrM/AnU+mohdnV7oEx0ap2TaG3fbfLIA==
|
||||
swagger-ui-react@^5.32.8:
|
||||
version "5.32.8"
|
||||
resolved "https://registry.yarnpkg.com/swagger-ui-react/-/swagger-ui-react-5.32.8.tgz#0608b45cf552f33fcc9b3fc5e07740c9a854861f"
|
||||
integrity sha512-Cstx4Tq8fT5l2TBxHxts8pG+ks0qKSkuO1pwUwgrQQiZ241Mqs+KUODLVIonsYXL/gqX143rkcipUa4d0Rid7w==
|
||||
dependencies:
|
||||
"@babel/runtime-corejs3" "^7.27.1"
|
||||
"@scarf/scarf" "=1.4.0"
|
||||
@@ -14946,7 +14946,7 @@ swagger-ui-react@^5.32.9:
|
||||
prop-types "^15.8.1"
|
||||
randexp "^0.5.3"
|
||||
randombytes "^2.1.0"
|
||||
react-copy-to-clipboard "5.1.1"
|
||||
react-copy-to-clipboard "5.1.0"
|
||||
react-debounce-input "=3.3.0"
|
||||
react-immutable-proptypes "2.2.0"
|
||||
react-immutable-pure-component "^2.2.0"
|
||||
@@ -15309,15 +15309,15 @@ types-ramda@^0.30.1:
|
||||
dependencies:
|
||||
ts-toolbelt "^9.6.0"
|
||||
|
||||
typescript-eslint@^8.65.0:
|
||||
version "8.65.0"
|
||||
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.65.0.tgz#754c953fd6a9a3d36fa54015bdba80a6eb2a4957"
|
||||
integrity sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==
|
||||
typescript-eslint@^8.64.0:
|
||||
version "8.64.0"
|
||||
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.64.0.tgz#4984dae4de9dc8bf892acf5c394d0a2a5f08c3e1"
|
||||
integrity sha512-0qg+pDNMnqYzqH9AnNK+39tejHvsShUOUUoRUgtnTGE7QuMZhiFDnozq8nHJVq+Wae6NMLKNWLg5WmkcC/ndyQ==
|
||||
dependencies:
|
||||
"@typescript-eslint/eslint-plugin" "8.65.0"
|
||||
"@typescript-eslint/parser" "8.65.0"
|
||||
"@typescript-eslint/typescript-estree" "8.65.0"
|
||||
"@typescript-eslint/utils" "8.65.0"
|
||||
"@typescript-eslint/eslint-plugin" "8.64.0"
|
||||
"@typescript-eslint/parser" "8.64.0"
|
||||
"@typescript-eslint/typescript-estree" "8.64.0"
|
||||
"@typescript-eslint/utils" "8.64.0"
|
||||
|
||||
typescript@~6.0.3:
|
||||
version "6.0.3"
|
||||
|
||||
@@ -29,7 +29,7 @@ maintainers:
|
||||
- name: craig-rueda
|
||||
email: craig@craigrueda.com
|
||||
url: https://github.com/craig-rueda
|
||||
version: 0.22.4 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
|
||||
version: 0.22.3 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
|
||||
dependencies:
|
||||
- name: postgresql
|
||||
version: 16.7.27
|
||||
|
||||
@@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs
|
||||
|
||||
# superset
|
||||
|
||||

|
||||

|
||||
|
||||
Apache Superset is a modern, enterprise-ready business intelligence web application
|
||||
|
||||
@@ -159,10 +159,6 @@ Alternatively, perform a fresh install. This is a one-time migration; subsequent
|
||||
| init.extraInitContainers | list | `[]` | Extra init containers appended after init job initContainers |
|
||||
| init.initContainers | list | a container waiting for postgres | List of initContainers |
|
||||
| init.initscript | string | unused; kept for backwards-compatibility only | DEPRECATED: this field is no longer used by the chart. The init script is rendered entirely from the internal `superset.initScript` template (which runs `superset db upgrade`, `superset init`, admin creation, and examples). Any customization placed here is silently ignored. See UPGRADING.md. |
|
||||
| init.istio | object | `{"disableSidecarInjection":false,"quitEndpoint":"http://localhost:15020/quitquitquit","terminateSidecarOnExit":false}` | Configuration for compatibility with the Istio service mesh. Without these mitigations, when the init-db Job runs in a namespace with automatic Istio sidecar injection enabled, the injected envoy-proxy container keeps running after the init container has exited, preventing the Job from ever reaching the Completed state. See https://github.com/apache/superset/issues/25798 |
|
||||
| init.istio.disableSidecarInjection | bool | `false` | When true, adds the `sidecar.istio.io/inject: "false"` label to the init job pod template, opting it out of Istio automatic sidecar injection. This is the recommended fix when the cluster's mesh policy allows opting out per pod. |
|
||||
| init.istio.quitEndpoint | string | `"http://localhost:15020/quitquitquit"` | Endpoint that the trap POSTs to in order to terminate the sidecar. The default matches the standard Istio pilot-agent admin port. |
|
||||
| init.istio.terminateSidecarOnExit | bool | `false` | When true, the rendered `superset_init.sh` script (from the internal `superset.initScript` template) registers an `EXIT` trap that POSTs to the Istio pilot-agent's `/quitquitquit` endpoint after the init logic finishes, gracefully terminating an injected envoy-proxy sidecar so the Job can complete. Enable this when sidecar injection cannot be disabled per pod (e.g. when enforced by a cluster-wide Istio policy). Note: requires `curl` to be available in the init container image (it is included in the default `apache/superset` image) and only takes effect when `init.command` sources the rendered `superset_init.sh`. |
|
||||
| init.jobAnnotations."helm.sh/hook" | string | `"post-install,post-upgrade"` | |
|
||||
| init.jobAnnotations."helm.sh/hook-delete-policy" | string | `"before-hook-creation"` | |
|
||||
| init.loadExamples | bool | `false` | |
|
||||
|
||||
@@ -685,14 +685,6 @@ TALISMAN_CONFIG = {
|
||||
{{- define "superset.initScript" -}}
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
{{- if dig "istio" "terminateSidecarOnExit" false .Values.init }}
|
||||
# Notify the Istio pilot-agent sidecar to exit when this script completes
|
||||
# (whether successfully or via `set -e`), so that the Job can reach the
|
||||
# Completed state instead of hanging on a still-running envoy-proxy.
|
||||
# See https://github.com/apache/superset/issues/25798
|
||||
ISTIO_QUIT_ENDPOINT={{ dig "istio" "quitEndpoint" "http://localhost:15020/quitquitquit" .Values.init | replace "'" "'\\''" | squote }}
|
||||
trap 'rc=$?; curl -fsS -m 5 -X POST "$ISTIO_QUIT_ENDPOINT" >/dev/null 2>&1 || echo "WARNING: failed to notify Istio sidecar at $ISTIO_QUIT_ENDPOINT to quit; the Job may hang if sidecar injection is active" >&2; exit $rc' EXIT
|
||||
{{- end }}
|
||||
echo "Upgrading DB schema..."
|
||||
superset db upgrade
|
||||
echo "Initializing roles and permissions..."
|
||||
|
||||
@@ -35,7 +35,6 @@ spec:
|
||||
{{- if .Values.init.podAnnotations }}
|
||||
annotations: {{- toYaml .Values.init.podAnnotations | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- $istioDisableInject := dig "istio" "disableSidecarInjection" false .Values.init }}
|
||||
labels:
|
||||
{{- include "superset.componentSelectorLabels" (dict "component" "init" "root" .) | nindent 8 }}
|
||||
job: {{ template "superset.fullname" . }}-init-db
|
||||
@@ -45,9 +44,6 @@ spec:
|
||||
{{- if .Values.init.podLabels }}
|
||||
{{- toYaml .Values.init.podLabels | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- if $istioDisableInject }}
|
||||
sidecar.istio.io/inject: "false"
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .Values.init.additionalPodSpec }}
|
||||
{{- tpl (toYaml .Values.init.additionalPodSpec) . | nindent 6 }}
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
# contributor license agreements. See the NOTICE file distributed with
|
||||
# this work for additional information regarding copyright ownership.
|
||||
# The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
# (the "License"); you may not use this file except in compliance with
|
||||
# the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# Renders the chart with `helm template` for several `init.istio.*` value
|
||||
# combinations and asserts that the relevant manifests contain (or omit)
|
||||
# the expected fields. Intended to be run from the chart directory or via
|
||||
# `bash helm/superset/tests/test-istio.sh` from the repo root.
|
||||
#
|
||||
# Covers the fix for:
|
||||
# https://github.com/apache/superset/issues/25798
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
CHART_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
pass=0
|
||||
fail=0
|
||||
|
||||
assert_contains() {
|
||||
local label="$1"
|
||||
local needle="$2"
|
||||
local haystack="$3"
|
||||
if grep -qF -- "${needle}" <<<"${haystack}"; then
|
||||
echo " PASS: ${label}"
|
||||
pass=$((pass + 1))
|
||||
else
|
||||
echo " FAIL: ${label}"
|
||||
echo " expected to contain: ${needle}"
|
||||
fail=$((fail + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
assert_not_contains() {
|
||||
local label="$1"
|
||||
local needle="$2"
|
||||
local haystack="$3"
|
||||
if ! grep -qF -- "${needle}" <<<"${haystack}"; then
|
||||
echo " PASS: ${label}"
|
||||
pass=$((pass + 1))
|
||||
else
|
||||
echo " FAIL: ${label}"
|
||||
echo " expected NOT to contain: ${needle}"
|
||||
fail=$((fail + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
render() {
|
||||
helm template release "${CHART_DIR}" "$@"
|
||||
}
|
||||
|
||||
extract_init_job() {
|
||||
awk '
|
||||
/^# Source: superset\/templates\/init-job\.yaml/ { capture = 1 }
|
||||
capture && /^---$/ { capture = 0 }
|
||||
capture { print }
|
||||
' <<<"$1"
|
||||
}
|
||||
|
||||
extract_config_secret() {
|
||||
awk '
|
||||
/^# Source: superset\/templates\/secret-superset-config\.yaml/ { capture = 1 }
|
||||
capture && /^---$/ { capture = 0 }
|
||||
capture { print }
|
||||
' <<<"$1"
|
||||
}
|
||||
|
||||
echo "==> defaults: no istio mitigations applied"
|
||||
out_default="$(render)"
|
||||
init_job_default="$(extract_init_job "${out_default}")"
|
||||
config_default="$(extract_config_secret "${out_default}")"
|
||||
assert_not_contains "default does not set sidecar.istio.io/inject label" \
|
||||
'sidecar.istio.io/inject' "${init_job_default}"
|
||||
assert_not_contains "default initscript does not register quitquitquit trap" \
|
||||
'quitquitquit' "${config_default}"
|
||||
|
||||
echo "==> init.istio.disableSidecarInjection=true"
|
||||
out_disable="$(render --set init.istio.disableSidecarInjection=true)"
|
||||
init_job_disable="$(extract_init_job "${out_disable}")"
|
||||
config_disable="$(extract_config_secret "${out_disable}")"
|
||||
assert_contains "init job sets sidecar.istio.io/inject: \"false\"" \
|
||||
'sidecar.istio.io/inject: "false"' "${init_job_disable}"
|
||||
assert_not_contains "disableSidecarInjection alone does not add quitquitquit trap" \
|
||||
'quitquitquit' "${config_disable}"
|
||||
|
||||
echo "==> init.istio.terminateSidecarOnExit=true"
|
||||
out_terminate="$(render --set init.istio.terminateSidecarOnExit=true)"
|
||||
init_job_terminate="$(extract_init_job "${out_terminate}")"
|
||||
config_terminate="$(extract_config_secret "${out_terminate}")"
|
||||
assert_not_contains "terminateSidecarOnExit alone does not add inject label" \
|
||||
'sidecar.istio.io/inject' "${init_job_terminate}"
|
||||
assert_contains "initscript registers EXIT trap that calls quitquitquit" \
|
||||
"trap 'rc=\$?; curl -fsS -m 5 -X POST" "${config_terminate}"
|
||||
assert_contains "initscript trap targets the configured quit endpoint" \
|
||||
'http://localhost:15020/quitquitquit' "${config_terminate}"
|
||||
assert_contains "initscript trap binds to the EXIT signal" \
|
||||
"' EXIT" "${config_terminate}"
|
||||
|
||||
echo "==> init.istio.quitEndpoint override"
|
||||
out_endpoint="$(render --set init.istio.terminateSidecarOnExit=true \
|
||||
--set init.istio.quitEndpoint=http://127.0.0.1:15020/quitquitquit)"
|
||||
config_endpoint="$(extract_config_secret "${out_endpoint}")"
|
||||
assert_contains "trap honours custom quitEndpoint" \
|
||||
'http://127.0.0.1:15020/quitquitquit' "${config_endpoint}"
|
||||
|
||||
echo "==> both options combined"
|
||||
out_both="$(render --set init.istio.disableSidecarInjection=true \
|
||||
--set init.istio.terminateSidecarOnExit=true)"
|
||||
init_job_both="$(extract_init_job "${out_both}")"
|
||||
config_both="$(extract_config_secret "${out_both}")"
|
||||
assert_contains "combined: inject label present" \
|
||||
'sidecar.istio.io/inject: "false"' "${init_job_both}"
|
||||
assert_contains "combined: trap present" \
|
||||
'quitquitquit' "${config_both}"
|
||||
|
||||
echo "==> existing init.podLabels are preserved alongside istio label"
|
||||
out_labels="$(render --set init.istio.disableSidecarInjection=true \
|
||||
--set init.podLabels.team=data-platform)"
|
||||
init_job_labels="$(extract_init_job "${out_labels}")"
|
||||
assert_contains "user-supplied podLabel still rendered" \
|
||||
'team: data-platform' "${init_job_labels}"
|
||||
assert_contains "istio inject label rendered alongside" \
|
||||
'sidecar.istio.io/inject: "false"' "${init_job_labels}"
|
||||
|
||||
echo "==> init.istio explicitly overridden to null"
|
||||
out_null_istio="$(render --set init.istio=null)"
|
||||
init_job_null_istio="$(extract_init_job "${out_null_istio}")"
|
||||
config_null_istio="$(extract_config_secret "${out_null_istio}")"
|
||||
assert_not_contains "null init.istio does not set inject label" \
|
||||
'sidecar.istio.io/inject' "${init_job_null_istio}"
|
||||
assert_not_contains "null init.istio does not register quitquitquit trap" \
|
||||
'quitquitquit' "${config_null_istio}"
|
||||
|
||||
echo "==> EXIT trap propagates the script's exit code, not the notification's"
|
||||
# The trap's own curl call is best-effort (failures are logged with
|
||||
# `|| echo ... >&2`, not swallowed with `|| true`) and must not mask a
|
||||
# failed migration. Extract the two rendered lines and actually run them,
|
||||
# with curl pointed at a closed local port so the notification itself fails,
|
||||
# to make sure the wrapped script's real exit code still comes through.
|
||||
quit_endpoint_line="$(grep -F 'ISTIO_QUIT_ENDPOINT=' <<<"${config_terminate}" || true)"
|
||||
trap_line="$(grep -F "trap 'rc=\$?; curl" <<<"${config_terminate}" || true)"
|
||||
if [[ -z "${quit_endpoint_line}" || -z "${trap_line}" ]]; then
|
||||
echo " FAIL: script exit code (42) survives a failing quitquitquit notification"
|
||||
echo " could not locate the rendered ISTIO_QUIT_ENDPOINT/trap lines to exercise"
|
||||
fail=$((fail + 1))
|
||||
else
|
||||
set +e
|
||||
(
|
||||
eval "${quit_endpoint_line}"
|
||||
ISTIO_QUIT_ENDPOINT="http://127.0.0.1:1/quitquitquit"
|
||||
eval "${trap_line}"
|
||||
exit 42
|
||||
)
|
||||
trap_test_rc=$?
|
||||
set -e
|
||||
if [[ "${trap_test_rc}" -eq 42 ]]; then
|
||||
echo " PASS: script exit code (42) survives a failing quitquitquit notification"
|
||||
pass=$((pass + 1))
|
||||
else
|
||||
echo " FAIL: script exit code (42) survives a failing quitquitquit notification"
|
||||
echo " got exit code: ${trap_test_rc}"
|
||||
fail=$((fail + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "passed: ${pass}, failed: ${fail}"
|
||||
if [[ ${fail} -gt 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
@@ -1241,31 +1241,6 @@ init:
|
||||
topologySpreadConstraints: []
|
||||
# -- Set priorityClassName for init job pods
|
||||
priorityClassName: ~
|
||||
# -- Configuration for compatibility with the Istio service mesh.
|
||||
# Without these mitigations, when the init-db Job runs in a namespace with
|
||||
# automatic Istio sidecar injection enabled, the injected envoy-proxy
|
||||
# container keeps running after the init container has exited, preventing
|
||||
# the Job from ever reaching the Completed state.
|
||||
# See https://github.com/apache/superset/issues/25798
|
||||
istio:
|
||||
# -- When true, adds the `sidecar.istio.io/inject: "false"` label to the
|
||||
# init job pod template, opting it out of Istio automatic sidecar
|
||||
# injection. This is the recommended fix when the cluster's mesh policy
|
||||
# allows opting out per pod.
|
||||
disableSidecarInjection: false
|
||||
# -- When true, the rendered `superset_init.sh` script (from the internal
|
||||
# `superset.initScript` template) registers an `EXIT` trap that POSTs to
|
||||
# the Istio pilot-agent's `/quitquitquit` endpoint after the init logic
|
||||
# finishes, gracefully terminating an injected envoy-proxy sidecar so
|
||||
# the Job can complete. Enable this when sidecar injection cannot be
|
||||
# disabled per pod (e.g. when enforced by a cluster-wide Istio policy).
|
||||
# Note: requires `curl` to be available in the init container image
|
||||
# (it is included in the default `apache/superset` image) and only
|
||||
# takes effect when `init.command` sources the rendered `superset_init.sh`.
|
||||
terminateSidecarOnExit: false
|
||||
# -- Endpoint that the trap POSTs to in order to terminate the sidecar.
|
||||
# The default matches the standard Istio pilot-agent admin port.
|
||||
quitEndpoint: "http://localhost:15020/quitquitquit"
|
||||
|
||||
# -- Configuration values for the postgresql dependency.
|
||||
# ref: https://github.com/bitnami/charts/tree/main/bitnami/postgresql
|
||||
|
||||
@@ -39,7 +39,6 @@ RETRYABLE_STATUS_CODES: frozenset[int] = frozenset({403, 429})
|
||||
PATTERNS = {
|
||||
"python": [
|
||||
r"^\.github/workflows/.*python",
|
||||
r"^docker-compose-image-tag\.yml$",
|
||||
r"^tests/",
|
||||
r"^superset/",
|
||||
r"^scripts/",
|
||||
|
||||
Generated
+666
-338
File diff suppressed because it is too large
Load Diff
@@ -115,9 +115,9 @@
|
||||
"@emotion/cache": "^11.4.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@fontsource/fira-code": "^5.3.0",
|
||||
"@fontsource/ibm-plex-mono": "^5.3.0",
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
"@fontsource/fira-code": "^5.2.7",
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@googleapis/sheets": "^13.0.2",
|
||||
"@great-expectations/jsonforms-antd-renderers": "^2.2.10",
|
||||
"@jsonforms/core": "^3.7.0",
|
||||
@@ -130,9 +130,9 @@
|
||||
"@luma.gl/shadertools": "~9.2.5",
|
||||
"@luma.gl/webgl": "~9.2.5",
|
||||
"@reduxjs/toolkit": "^1.9.3",
|
||||
"@rjsf/core": "^6.7.0",
|
||||
"@rjsf/core": "^6.6.2",
|
||||
"@rjsf/utils": "^6.6.2",
|
||||
"@rjsf/validator-ajv8": "^6.7.0",
|
||||
"@rjsf/validator-ajv8": "^6.6.2",
|
||||
"@scarf/scarf": "^1.4.0",
|
||||
"@superset-ui/chart-controls": "file:./packages/superset-ui-chart-controls",
|
||||
"@superset-ui/core": "file:./packages/superset-ui-core",
|
||||
@@ -169,7 +169,7 @@
|
||||
"ag-grid-community": "36.0.1",
|
||||
"ag-grid-react": "36.0.1",
|
||||
"antd": "^6.5.1",
|
||||
"chrono-node": "^2.10.1",
|
||||
"chrono-node": "^2.10.0",
|
||||
"classnames": "^2.2.5",
|
||||
"content-disposition": "^2.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
@@ -196,7 +196,7 @@
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.18.1",
|
||||
"mapbox-gl": "^3.26.0",
|
||||
"markdown-to-jsx": "^9.9.0",
|
||||
"markdown-to-jsx": "^9.8.2",
|
||||
"match-sorter": "^8.3.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
"mousetrap": "^1.6.5",
|
||||
@@ -206,13 +206,14 @@
|
||||
"query-string": "9.4.1",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react": "^18.3.0",
|
||||
"react-arborist": "^3.15.0",
|
||||
"react-arborist": "^3.13.2",
|
||||
"react-checkbox-tree": "^1.8.0",
|
||||
"react-diff-viewer-continued": "^4.4.0",
|
||||
"react-dnd": "^11.1.3",
|
||||
"react-dnd-html5-backend": "^11.1.3",
|
||||
"react-dom": "^18.3.0",
|
||||
"react-google-recaptcha": "^3.1.0",
|
||||
"react-grid-layout": "^2.2.4",
|
||||
"react-intersection-observer": "^10.1.0",
|
||||
"react-json-tree": "^0.20.0",
|
||||
"react-lines-ellipsis": "^0.16.1",
|
||||
@@ -267,12 +268,12 @@
|
||||
"@istanbuljs/nyc-config-typescript": "^1.0.1",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
|
||||
"@storybook/addon-docs": "10.5.3",
|
||||
"@storybook/addon-links": "10.5.3",
|
||||
"@storybook/react-webpack5": "10.5.3",
|
||||
"@storybook/addon-docs": "10.5.2",
|
||||
"@storybook/addon-links": "10.5.2",
|
||||
"@storybook/react-webpack5": "10.5.2",
|
||||
"@storybook/test-runner": "0.24.4",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.46",
|
||||
"@swc/core": "^1.15.43",
|
||||
"@swc/plugin-emotion": "^14.15.0",
|
||||
"@swc/plugin-transform-imports": "^12.5.0",
|
||||
"@testing-library/dom": "^9.3.4",
|
||||
@@ -300,13 +301,13 @@
|
||||
"@types/rison": "0.1.0",
|
||||
"@types/tinycolor2": "^1.4.3",
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"@typescript-eslint/eslint-plugin": "^8.65.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.64.0",
|
||||
"@typescript-eslint/parser": "^8.63.0",
|
||||
"babel-jest": "^30.4.1",
|
||||
"babel-loader": "^10.1.1",
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"baseline-browser-mapping": "^2.10.44",
|
||||
"baseline-browser-mapping": "^2.10.43",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^10.0.3",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
@@ -325,7 +326,7 @@
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"eslint-plugin-react-prefer-function-component": "^5.0.0",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.1",
|
||||
"eslint-plugin-storybook": "10.5.3",
|
||||
"eslint-plugin-storybook": "10.5.2",
|
||||
"eslint-plugin-testing-library": "^7.16.2",
|
||||
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
|
||||
"fetch-mock": "^12.6.0",
|
||||
@@ -340,26 +341,26 @@
|
||||
"js-yaml-loader": "^1.2.2",
|
||||
"jsdom": "^29.1.1",
|
||||
"lerna": "^9.0.4",
|
||||
"lightningcss": "^1.33.0",
|
||||
"lightningcss": "^1.32.0",
|
||||
"mini-css-extract-plugin": "^2.10.2",
|
||||
"minimizer-webpack-plugin": "^5.6.1",
|
||||
"open-cli": "^9.0.0",
|
||||
"oxlint": "^1.74.0",
|
||||
"po2json": "^0.4.5",
|
||||
"postcss-styled-syntax": "^0.7.2",
|
||||
"prettier": "3.9.6",
|
||||
"prettier": "3.9.5",
|
||||
"prettier-plugin-packagejson": "^3.0.2",
|
||||
"process": "^0.11.10",
|
||||
"react-dnd-test-backend": "^16.0.1",
|
||||
"react-refresh": "^0.18.0",
|
||||
"react-resizable": "^4.0.2",
|
||||
"redux-mock-store": "^1.5.4",
|
||||
"source-map": "^0.8.0",
|
||||
"source-map": "^0.7.6",
|
||||
"source-map-support": "^0.5.21",
|
||||
"speed-measure-webpack-plugin": "^1.6.0",
|
||||
"storybook": "10.5.3",
|
||||
"storybook": "10.5.2",
|
||||
"style-loader": "^4.0.0",
|
||||
"stylelint": "^17.14.1",
|
||||
"stylelint": "^17.14.0",
|
||||
"swc-loader": "^0.2.7",
|
||||
"ts-jest": "^29.4.11",
|
||||
"tscw-config": "^1.1.2",
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
"types": "./lib/chat/index.d.ts",
|
||||
"default": "./lib/chat/index.js"
|
||||
},
|
||||
"./dashboard": {
|
||||
"types": "./lib/dashboard/index.d.ts",
|
||||
"default": "./lib/dashboard/index.js"
|
||||
},
|
||||
"./navigation": {
|
||||
"types": "./lib/navigation/index.d.ts",
|
||||
"default": "./lib/navigation/index.js"
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Dashboard building API for Superset extensions (prototype).
|
||||
*
|
||||
* Structural/layout operations on the "Dashboard v2" prototype dashboard: a
|
||||
* flat, addressable tree of nodes (not nested JSX-in-JSON) so small,
|
||||
* targeted edits are cheap — the shape an AI agent (most likely a `chat`
|
||||
* extension) or any other extension calls to place, move, resize, and
|
||||
* remove nodes. This is an early sketch of the design doc's platform-API
|
||||
* section, not yet backed by persistence, real chart execution, or the
|
||||
* `ChartPlugin`/building-block catalog — every method here is synchronous
|
||||
* and in-memory.
|
||||
*
|
||||
* Deliberately granular, matching `sqlLab`'s own style (`getCurrentTab()`,
|
||||
* `tab.getEditor()`, ...): there is no single "get everything" call. Start
|
||||
* from {@link getRoot}, walk down via each node's `children` and
|
||||
* {@link getNode}, and re-query after {@link onDidLayoutChange} fires.
|
||||
*
|
||||
* `dashboard` owns node placement and layout only. Block-instance content
|
||||
* (props/style/dataBinding) is intentionally out of scope here — it belongs
|
||||
* to a future `buildingBlocks` namespace mirroring this one.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { dashboard } from '@apache-superset/core';
|
||||
*
|
||||
* const root = dashboard.getRoot();
|
||||
* const id = dashboard.addBuildingBlock(root.id, 0, {
|
||||
* type: 'text',
|
||||
* props: { content: 'Hello dashboard' },
|
||||
* });
|
||||
* dashboard.updateLayout(id, { colSpan: 12 });
|
||||
* ```
|
||||
*/
|
||||
|
||||
import type { Event } from '../common';
|
||||
|
||||
/**
|
||||
* Layout of a single node: a grid it lays out its own children in (only
|
||||
* meaningful when the node is a container — ignored on leaf nodes), plus
|
||||
* where the node itself sits within its *parent's* grid. A node can be both
|
||||
* at once — a `canvas` nested inside another `canvas` both holds a grid for
|
||||
* its own children and occupies cells in its parent's.
|
||||
*
|
||||
* There is no separate "flow" or "absolute" mode: a single-column grid with
|
||||
* every child left at its default full-width span behaves like a plain
|
||||
* top-to-bottom stack — it falls out of the same schema rather than
|
||||
* requiring a different one.
|
||||
*/
|
||||
export interface LayoutProps {
|
||||
// --- Container side: how this node arranges its own children. Ignored
|
||||
// on a node with no `children`. ---
|
||||
/** Number of equal fractional column tracks. Default: 24. */
|
||||
columns?: number;
|
||||
gap?: number;
|
||||
/**
|
||||
* Pixel height of one row track. Rows are never predivided from a fixed
|
||||
* total — the grid creates as many as its content needs, each this tall,
|
||||
* so a fixed unit keeps every child's height predictable without
|
||||
* requiring a canvas of a predetermined total height.
|
||||
*/
|
||||
rowUnit?: number;
|
||||
|
||||
// --- Child side: where this node sits within its parent's grid. ---
|
||||
/** How many of the parent's columns this node spans. Default: every column (full width). */
|
||||
colSpan?: number;
|
||||
/** How many row tracks this node spans. Default: 1. */
|
||||
rowSpan?: number;
|
||||
/** Explicit start column (1-based). Omit to let the grid auto-place this node in the next available cell. */
|
||||
col?: number;
|
||||
/** Explicit start row (1-based). Omit to let the grid auto-place this node in the next available cell. */
|
||||
row?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single node in the dashboard tree. `canvas` and `text` are native
|
||||
* layout primitives; any other `type` is a building-block registry key (a
|
||||
* chart, metric tile, or extension-contributed block).
|
||||
*
|
||||
* `props`/`style` are inlined directly on the node for now. Once a
|
||||
* `buildingBlocks` content namespace exists, block-type nodes will instead
|
||||
* carry a `ref` into it — matching the design doc's split between dashboard
|
||||
* layout and building-block content.
|
||||
*/
|
||||
export interface DashboardNode {
|
||||
id: string;
|
||||
/** Registry key used to pick a renderer — not part of this API's concern. */
|
||||
type: string;
|
||||
layout?: LayoutProps;
|
||||
/**
|
||||
* `canvas` nodes only — child node ids, in reading/DOM/tab order. This is
|
||||
* independent of each child's visual position (its own `layout.col`/`row`)
|
||||
* — moving a node within this array never changes where it's drawn, and
|
||||
* repositioning a node on the canvas never changes this array.
|
||||
*/
|
||||
children?: string[];
|
||||
/** Leaf/building-block nodes only — functional/content config. */
|
||||
props?: Record<string, unknown>;
|
||||
/** Leaf/building-block nodes only — visual customization. */
|
||||
style?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Everything needed to create a new node, passed to {@link addBuildingBlock}. */
|
||||
export interface BuildingBlockSpec {
|
||||
type: string;
|
||||
layout?: LayoutProps;
|
||||
props?: Record<string, unknown>;
|
||||
style?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the root `canvas` node — the entry point for walking the tree.
|
||||
* Its `children` array holds the top-level node ids.
|
||||
*/
|
||||
export declare function getRoot(): DashboardNode;
|
||||
|
||||
/**
|
||||
* Returns a specific node, or undefined if `id` doesn't exist.
|
||||
*/
|
||||
export declare function getNode(id: string): DashboardNode | undefined;
|
||||
|
||||
/**
|
||||
* Creates a new node and inserts it into a `canvas` parent's children at
|
||||
* `index`.
|
||||
*
|
||||
* @param parentId Id of an existing `canvas` node.
|
||||
* @param index Position among the parent's existing children; out-of-range
|
||||
* values are clamped.
|
||||
* @param spec The new node's type, layout, props, and style.
|
||||
* @returns The new node's id.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const root = dashboard.getRoot();
|
||||
* dashboard.addBuildingBlock(root.id, 0, {
|
||||
* type: 'canvas',
|
||||
* layout: { colSpan: 12, columns: 4, gap: 16 },
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export declare function addBuildingBlock(
|
||||
parentId: string,
|
||||
index: number,
|
||||
spec: BuildingBlockSpec,
|
||||
): string;
|
||||
|
||||
/**
|
||||
* Removes a node and its entire subtree (if it's a `canvas`), detaching it
|
||||
* from its parent. No-op if `id` doesn't exist. Throws if `id` is the root.
|
||||
*/
|
||||
export declare function removeBuildingBlock(id: string): void;
|
||||
|
||||
/**
|
||||
* Moves an existing node to a new `canvas` parent at `newIndex`, detaching
|
||||
* it from wherever it currently sits. Throws if `newParentId` is `id` itself
|
||||
* or one of its own descendants.
|
||||
*/
|
||||
export declare function moveBuildingBlock(
|
||||
id: string,
|
||||
newParentId: string,
|
||||
newIndex: number,
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Merges `layout` into a node's existing layout object.
|
||||
*/
|
||||
export declare function updateLayout(
|
||||
id: string,
|
||||
layout: Partial<LayoutProps>,
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Shallow-merges `props` into a node's existing props — the content-side
|
||||
* counterpart to {@link updateLayout}. Use this to edit an existing block
|
||||
* in place (e.g. a chart's `dataBinding`/`echartsOptions`, or a markdown
|
||||
* node's `content`) rather than removing and re-adding the node just to
|
||||
* change what it renders, which loses its position, layout, and identity.
|
||||
*/
|
||||
export declare function updateProps(
|
||||
id: string,
|
||||
props: Record<string, unknown>,
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Event fired after any structural or layout change. Carries no payload —
|
||||
* re-query {@link getRoot}/{@link getNode} for whatever you need, since a
|
||||
* single mutation (e.g. a move) can touch more than one node.
|
||||
*/
|
||||
export declare const onDidLayoutChange: Event<void>;
|
||||
|
||||
/**
|
||||
* What an `echarts`-type building block queries. Deliberately generic (no
|
||||
* `viz_type`): {@link fetchQueryData} always hits the same code path
|
||||
* Superset falls back to when a form_data's `viz_type` has no registered
|
||||
* ChartPlugin, so it works for any chart shape without per-viz-type
|
||||
* integration.
|
||||
*/
|
||||
export interface DataBindingSpec {
|
||||
datasetId: number;
|
||||
/** Each entry is either a saved metric's exact name, or an ad hoc metric object. */
|
||||
metrics: unknown[];
|
||||
dimensions?: string[];
|
||||
filters?: Record<string, unknown>[];
|
||||
rowLimit?: number;
|
||||
}
|
||||
|
||||
export type DataRow = Record<string, string | number | boolean | null>;
|
||||
|
||||
export interface QueryDataResult {
|
||||
columns: string[];
|
||||
rows: DataRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs an ad hoc query against a dataset and returns plain tabular rows.
|
||||
* Rejects with a descriptive error (e.g. an unknown column/metric name) if
|
||||
* the query is invalid — callers that create `echarts` nodes should await
|
||||
* this *before* calling {@link addBuildingBlock}, so a bad `dataBinding`
|
||||
* surfaces as an immediate, correctable tool error instead of a node that
|
||||
* silently fails to render later.
|
||||
*/
|
||||
export declare function fetchQueryData(
|
||||
binding: DataBindingSpec,
|
||||
): Promise<QueryDataResult>;
|
||||
@@ -20,6 +20,7 @@ export * as common from './common';
|
||||
export * as authentication from './authentication';
|
||||
export * as chat from './chat';
|
||||
export * as commands from './commands';
|
||||
export * as dashboard from './dashboard';
|
||||
export * as editors from './editors';
|
||||
export * as extensions from './extensions';
|
||||
export * as menus from './menus';
|
||||
|
||||
@@ -31,8 +31,8 @@ import { Event } from '../common';
|
||||
/**
|
||||
* The set of top-level application surfaces.
|
||||
*
|
||||
* `'explore'`, `'dashboard'` and `'dataset'` are the single-entity
|
||||
* editing/viewing surfaces. `'chart_list'`, `'dashboard_list'` and
|
||||
* `'explore'`, `'dashboard'`, `'dashboard_v2'` and `'dataset'` are the
|
||||
* single-entity editing/viewing surfaces. `'chart_list'`, `'dashboard_list'` and
|
||||
* `'dataset_list'` are the browse/list surfaces, distinct from those because no
|
||||
* single entity is active. `'sqllab'` is the SQL editor where
|
||||
* `sqlLab.getCurrentTab()` resolves; `'query_history'` and `'saved_queries'`
|
||||
@@ -41,6 +41,7 @@ import { Event } from '../common';
|
||||
*/
|
||||
export type Page =
|
||||
| 'dashboard'
|
||||
| 'dashboard_v2'
|
||||
| 'dashboard_list'
|
||||
| 'explore'
|
||||
| 'chart_list'
|
||||
|
||||
@@ -58,7 +58,10 @@ export interface View {
|
||||
*
|
||||
* @param view The view descriptor (id and name).
|
||||
* @param location The location where this view should appear (e.g. "sqllab.panels").
|
||||
* @param component The React component to render at that location.
|
||||
* @param component The React component to render at that location. Most
|
||||
* locations render it with no props; check the target location's own docs
|
||||
* for whether it passes any (e.g. "dashboard.buildingBlocks" passes
|
||||
* `{ nodeId }`).
|
||||
* @returns A Disposable that unregisters the view when disposed.
|
||||
*
|
||||
* @example
|
||||
@@ -73,7 +76,7 @@ export interface View {
|
||||
export declare function registerView(
|
||||
view: View,
|
||||
location: string,
|
||||
component: ComponentType,
|
||||
component: ComponentType<any>,
|
||||
): Disposable;
|
||||
|
||||
/**
|
||||
|
||||
@@ -86,6 +86,7 @@ import {
|
||||
FundProjectionScreenOutlined,
|
||||
FunctionOutlined,
|
||||
HighlightOutlined,
|
||||
HolderOutlined,
|
||||
HomeOutlined,
|
||||
InfoCircleOutlined,
|
||||
InfoCircleFilled,
|
||||
@@ -121,6 +122,7 @@ import {
|
||||
PushpinFilled,
|
||||
PushpinOutlined,
|
||||
QuestionCircleOutlined,
|
||||
RedoOutlined,
|
||||
ReloadOutlined,
|
||||
RightOutlined,
|
||||
SaveOutlined,
|
||||
@@ -137,6 +139,7 @@ import {
|
||||
TagsOutlined,
|
||||
TableOutlined,
|
||||
LockOutlined,
|
||||
UndoOutlined,
|
||||
UnlockOutlined,
|
||||
UploadOutlined,
|
||||
UpOutlined,
|
||||
@@ -245,6 +248,7 @@ const AntdIcons = {
|
||||
GoogleOutlined,
|
||||
GroupOutlined,
|
||||
HighlightOutlined,
|
||||
HolderOutlined,
|
||||
HomeOutlined,
|
||||
InfoCircleOutlined,
|
||||
InfoCircleFilled,
|
||||
@@ -281,6 +285,7 @@ const AntdIcons = {
|
||||
PushpinOutlined,
|
||||
ReloadOutlined,
|
||||
QuestionCircleOutlined,
|
||||
RedoOutlined,
|
||||
RightOutlined,
|
||||
SaveOutlined,
|
||||
SearchOutlined,
|
||||
@@ -296,6 +301,7 @@ const AntdIcons = {
|
||||
TagsOutlined,
|
||||
TableOutlined,
|
||||
LockOutlined,
|
||||
UndoOutlined,
|
||||
UploadOutlined,
|
||||
UnlockOutlined,
|
||||
UpOutlined,
|
||||
|
||||
@@ -89,7 +89,6 @@ const StyledItem = styled.div<{
|
||||
& .metadata-text {
|
||||
color: ${theme.colorTextSecondary};
|
||||
min-width: ${TEXT_MIN_WIDTH}px;
|
||||
max-width: ${TEXT_MAX_WIDTH}px;
|
||||
overflow: hidden;
|
||||
text-overflow: ${collapsed ? 'unset' : 'ellipsis'};
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -1032,35 +1032,6 @@ test('do not count unselected disabled options in "Select all"', async () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('"Select all" does not count null-valued options', async () => {
|
||||
// A falsy-valued option (e.g. <NULL>, value: null) is skipped by
|
||||
// handleSelectAll, so it must not be counted in the "Select all" badge or
|
||||
// the count overstates the selection. Regression test for #40228. Uses a
|
||||
// local options array to stay isolated from tests that mutate OPTIONS.
|
||||
const localOptions = [
|
||||
{ label: 'Alpha', value: 1 },
|
||||
{ label: 'Bravo', value: 2 },
|
||||
];
|
||||
render(
|
||||
<Select
|
||||
{...defaultProps}
|
||||
options={[...localOptions, NULL_OPTION]}
|
||||
mode="multiple"
|
||||
maxTagCount={0}
|
||||
/>,
|
||||
);
|
||||
await open();
|
||||
// Three options are visible, but the <NULL> option is not bulk-selectable,
|
||||
// so the badge must count only the two real options (would be 3 before fix).
|
||||
await userEvent.click(
|
||||
await screen.findByText(selectAllButtonText(localOptions.length)),
|
||||
);
|
||||
// And Select all selects exactly those two — the null option is skipped.
|
||||
const values = await findAllSelectValues();
|
||||
expect(values.length).toBe(1);
|
||||
expect(values[0]).toHaveTextContent(`+ ${localOptions.length} ...`);
|
||||
});
|
||||
|
||||
test('"Deselect all" counts all selected options', async () => {
|
||||
render(<Select {...defaultProps} allowNewOptions mode="multiple" />);
|
||||
await open();
|
||||
|
||||
@@ -332,12 +332,7 @@ const Select = forwardRef(
|
||||
const isDisabled = option.disabled;
|
||||
const isNew = option.isNewOption;
|
||||
|
||||
// Mirror handleSelectAll, which skips falsy-valued options (e.g. the
|
||||
// <NULL> option whose value is null): they are not bulk-selectable,
|
||||
// so counting them here makes the "Select all" badge overstate what
|
||||
// gets selected.
|
||||
if (
|
||||
option.value &&
|
||||
(!isDisabled || isSelected) &&
|
||||
((isNew && isSelected) || !isNew)
|
||||
) {
|
||||
|
||||
+304
-1056
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+69
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
// @ts-nocheck -- vendor file; not fully typed
|
||||
/* eslint camelcase: 0 */
|
||||
import URI from 'urijs';
|
||||
import safeStringify from 'fast-safe-stringify';
|
||||
|
||||
const MAX_URL_LENGTH = 8000;
|
||||
|
||||
export function getURIDirectory(formData, endpointType = 'base') {
|
||||
// Building the directory part of the URI
|
||||
let directory = '/explore/';
|
||||
if (['json', 'csv', 'query', 'results', 'samples'].includes(endpointType)) {
|
||||
directory = '/superset/explore_json/';
|
||||
}
|
||||
|
||||
return directory;
|
||||
}
|
||||
|
||||
export function getExploreLongUrl(
|
||||
formData,
|
||||
endpointType,
|
||||
allowOverflow = true,
|
||||
extraSearch = {},
|
||||
) {
|
||||
if (!formData.datasource) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const uri = new URI('/');
|
||||
const directory = getURIDirectory(formData, endpointType);
|
||||
const search = uri.search(true);
|
||||
Object.keys(extraSearch).forEach(key => {
|
||||
search[key] = extraSearch[key];
|
||||
});
|
||||
search.form_data = safeStringify(formData);
|
||||
if (endpointType === 'standalone') {
|
||||
search.standalone = 'true';
|
||||
}
|
||||
const url = uri.directory(directory).search(search).toString();
|
||||
if (!allowOverflow && url.length > MAX_URL_LENGTH) {
|
||||
const minimalFormData = {
|
||||
datasource: formData.datasource,
|
||||
viz_type: formData.viz_type,
|
||||
};
|
||||
|
||||
return getExploreLongUrl(minimalFormData, endpointType, false, {
|
||||
URL_IS_TOO_LONG_TO_SHARE: null,
|
||||
});
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
+10
-26
@@ -457,23 +457,13 @@ export default function transformProps(
|
||||
const seriesName = inverted[entryName] || entryName;
|
||||
const colorScaleKey = getOriginalSeries(seriesName, array);
|
||||
|
||||
const labelMapValues = rawLabelMap?.[seriesName];
|
||||
|
||||
let displayName: string;
|
||||
|
||||
if (groupby.length > 0) {
|
||||
// When we have groupby, format as "metric, dimension". Each series
|
||||
// belongs to the metric recorded in its label-map tuple
|
||||
// ([metric, ...dimensions]) — always using the first metric would
|
||||
// prepend it to every other metric's series (#37921). Tuples without
|
||||
// a metric part fall back to the first metric as before.
|
||||
const metricDisplayName =
|
||||
labelMapValues && labelMapValues.length > 1
|
||||
? getMetricDisplayName(labelMapValues[0], verboseMap)
|
||||
: MetricDisplayNameA;
|
||||
// When we have groupby, format as "metric, dimension"
|
||||
const metricPart: string = showQueryIdentifiers
|
||||
? `${metricDisplayName} (Query A)`
|
||||
: metricDisplayName;
|
||||
? `${MetricDisplayNameA} (Query A)`
|
||||
: MetricDisplayNameA;
|
||||
displayName = entryName.includes(metricPart)
|
||||
? entryName
|
||||
: `${metricPart}, ${entryName}`;
|
||||
@@ -481,6 +471,8 @@ export default function transformProps(
|
||||
// When no groupby, format as just the entry name with optional query identifier
|
||||
displayName = showQueryIdentifiers ? `${entryName} (Query A)` : entryName;
|
||||
}
|
||||
|
||||
const labelMapValues = rawLabelMap?.[seriesName];
|
||||
if (labelMapValues) {
|
||||
displayLabelMap[displayName] = labelMapValues;
|
||||
}
|
||||
@@ -544,23 +536,13 @@ export default function transformProps(
|
||||
const seriesEntry = inverted[entryName] || entryName;
|
||||
const colorScaleKey = getOriginalSeries(seriesEntry, array);
|
||||
|
||||
const labelMapValuesB = rawLabelMapB?.[seriesEntry];
|
||||
|
||||
let displayName: string;
|
||||
|
||||
if (groupbyB.length > 0) {
|
||||
// When we have groupby, format as "metric, dimension". Each series
|
||||
// belongs to the metric recorded in its label-map tuple
|
||||
// ([metric, ...dimensions]) — always using the first metric would
|
||||
// prepend it to every other metric's series (#37921). Tuples without
|
||||
// a metric part fall back to the first metric as before.
|
||||
const metricDisplayName =
|
||||
labelMapValuesB && labelMapValuesB.length > 1
|
||||
? getMetricDisplayName(labelMapValuesB[0], verboseMap)
|
||||
: MetricDisplayNameB;
|
||||
// When we have groupby, format as "metric, dimension"
|
||||
const metricPart: string = showQueryIdentifiers
|
||||
? `${metricDisplayName} (Query B)`
|
||||
: metricDisplayName;
|
||||
? `${MetricDisplayNameB} (Query B)`
|
||||
: MetricDisplayNameB;
|
||||
displayName = entryName.includes(metricPart)
|
||||
? entryName
|
||||
: `${metricPart}, ${entryName}`;
|
||||
@@ -568,6 +550,8 @@ export default function transformProps(
|
||||
// When no groupby, format as just the entry name with optional query identifier
|
||||
displayName = showQueryIdentifiers ? `${entryName} (Query B)` : entryName;
|
||||
}
|
||||
|
||||
const labelMapValuesB = rawLabelMapB?.[seriesEntry];
|
||||
if (labelMapValuesB) {
|
||||
displayLabelMapB[displayName] = labelMapValuesB;
|
||||
}
|
||||
|
||||
@@ -325,8 +325,9 @@ const config: ControlPanelConfig = {
|
||||
description: t(
|
||||
'Total angle covered by the chart, in degrees. ' +
|
||||
'360° draws a full circle and 180° draws a half donut. ' +
|
||||
'Partial arcs are automatically re-centered and scaled ' +
|
||||
'to make use of the available space.',
|
||||
'When the sweep is 180° or less and the start angle is a ' +
|
||||
'multiple of 90°, the chart is automatically re-centered ' +
|
||||
'to make use of the empty space.',
|
||||
),
|
||||
renderTrigger: true,
|
||||
default: DEFAULT_FORM_DATA.sweptAngle,
|
||||
|
||||
@@ -39,6 +39,9 @@ import {
|
||||
EchartsPieLabelType,
|
||||
PieChartDataItem,
|
||||
PieChartTransformedProps,
|
||||
TotalValuePaddingProps,
|
||||
PaddingResult,
|
||||
HalfDonut,
|
||||
} from './types';
|
||||
import { DEFAULT_LEGEND_FORM_DATA, OpacityEnum } from '../constants';
|
||||
import {
|
||||
@@ -73,153 +76,120 @@ export function parseParams({
|
||||
return [name, formattedValue, formattedPercent];
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounding box of the pie arc in unit coordinates: outer radius = 1,
|
||||
* mathematical y-up convention matching ECharts' angle convention
|
||||
* (0° points right, 90° points up, angles sweep clockwise from
|
||||
* `startAngle` to `startAngle - sweptAngle`).
|
||||
*/
|
||||
export interface ArcBoundingBox {
|
||||
minX: number;
|
||||
maxX: number;
|
||||
minY: number;
|
||||
maxY: number;
|
||||
}
|
||||
const HALF_DONUT_SWEEP_LIMIT = 180;
|
||||
|
||||
/**
|
||||
* Computes the bounding box of an annular sector from its angles.
|
||||
* Geometric configuration for each type of semi-circular layout.
|
||||
*
|
||||
* The box is spanned by the outer arc endpoints, the inner arc endpoints
|
||||
* (which collapse to the pie origin when `innerRatio` is 0), and every axis
|
||||
* extreme (0°/90°/180°/270°) the arc sweeps through.
|
||||
* - `centerOffset` — offset of the chart center from the baseline 50% on the X and Y axes.
|
||||
* Resulting position: `50% + offset`.
|
||||
* - `totalBase` — base position of the "Total" text as a percentage on the X and Y axes.
|
||||
*
|
||||
* @param startAngle - The start angle of the arc in degrees.
|
||||
* @param sweptAngle - The total angle covered by the arc in degrees.
|
||||
* @param innerRatio - Inner radius as a fraction of the outer radius (0–1).
|
||||
* The values are empirically tuned so that the "Total" text visually remains
|
||||
* at the geometric center of the arc after the chart is re-centered. The
|
||||
* `left`/`right` totalBase values sit 5% inside the shifted chart center
|
||||
* (60% and 40% respectively) to compensate for the text being positioned by
|
||||
* its left edge rather than its midpoint.
|
||||
*/
|
||||
export function getArcBoundingBox(
|
||||
const HALF_DONUT_LAYOUT: Record<
|
||||
HalfDonut,
|
||||
{
|
||||
centerOffset: { x: number; y: number };
|
||||
totalBase: { left: number; top: number };
|
||||
}
|
||||
> = {
|
||||
top: { centerOffset: { x: 0, y: 20 }, totalBase: { left: 50, top: 68.5 } },
|
||||
bottom: { centerOffset: { x: 0, y: -20 }, totalBase: { left: 50, top: 30 } },
|
||||
left: { centerOffset: { x: 10, y: 0 }, totalBase: { left: 55, top: 50 } },
|
||||
right: { centerOffset: { x: -10, y: 0 }, totalBase: { left: 35, top: 50 } },
|
||||
none: { centerOffset: { x: 0, y: 0 }, totalBase: { left: 50, top: 50 } },
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines the type of semicircular layout based on the start angle and swept angle.
|
||||
*
|
||||
* All four semicircle orientations are supported:
|
||||
* - `'top'` — the arc is positioned at the top; the chart center shifts downwards.
|
||||
* - `'bottom'` — the arc is positioned at the bottom; the chart center shifts upwards.
|
||||
* - `'left'` — the arc is positioned at the left; the chart center shifts right.
|
||||
* - `'right'` — the arc is positioned at the right; the chart center shifts left.
|
||||
*
|
||||
* @param startAngle - The start angle of the arc in degrees (0–360).
|
||||
* @param sweptAngle - The swept angle of the arc in degrees (10–360).
|
||||
* @returns The type of semicircular layout.
|
||||
*/
|
||||
export const getHalfDonut = (
|
||||
startAngle: number,
|
||||
sweptAngle: number,
|
||||
innerRatio: number,
|
||||
): ArcBoundingBox {
|
||||
if (sweptAngle >= 360) {
|
||||
return { minX: -1, maxX: 1, minY: -1, maxY: 1 };
|
||||
}
|
||||
const toRad = (deg: number) => (deg * Math.PI) / 180;
|
||||
const endAngle = startAngle - sweptAngle;
|
||||
const points: [number, number][] = [];
|
||||
[startAngle, endAngle].forEach(angle => {
|
||||
const x = Math.cos(toRad(angle));
|
||||
const y = Math.sin(toRad(angle));
|
||||
points.push([x, y], [innerRatio * x, innerRatio * y]);
|
||||
});
|
||||
for (
|
||||
let axis = Math.ceil(endAngle / 90) * 90;
|
||||
axis <= startAngle;
|
||||
axis += 90
|
||||
) {
|
||||
points.push([Math.cos(toRad(axis)), Math.sin(toRad(axis))]);
|
||||
}
|
||||
const xs = points.map(([x]) => x);
|
||||
const ys = points.map(([, y]) => y);
|
||||
return {
|
||||
minX: Math.min(...xs),
|
||||
maxX: Math.max(...xs),
|
||||
minY: Math.min(...ys),
|
||||
maxY: Math.max(...ys),
|
||||
};
|
||||
}
|
||||
): HalfDonut => {
|
||||
if (sweptAngle > HALF_DONUT_SWEEP_LIMIT) return 'none';
|
||||
|
||||
/**
|
||||
* Arcs covering only a sliver of the circle would otherwise scale up without
|
||||
* bound; cap the fit scale at the factor a quarter arc reaches naturally.
|
||||
*/
|
||||
const MAX_RADIUS_SCALE = 2;
|
||||
const normalized = startAngle % 360;
|
||||
|
||||
export interface PieLayout {
|
||||
/** Pie origin in px, relative to the padded series rect. */
|
||||
center: [number, number];
|
||||
/** Inner/outer radius percent strings, scaled to fit the arc's box. */
|
||||
radius: [string, string];
|
||||
/** Pie origin in px, in container coordinates (for the graphic component). */
|
||||
totalAnchor: { x: number; y: number };
|
||||
}
|
||||
if (normalized === 180) return 'top';
|
||||
if (normalized === 0) return 'bottom';
|
||||
if (normalized === 270) return 'left';
|
||||
if (normalized === 90) return 'right';
|
||||
|
||||
/**
|
||||
* Lays out the pie geometrically for any start/sweep angle combination:
|
||||
* scales the radius until the arc's bounding box fills the available rect
|
||||
* (so partial arcs reclaim the space a full circle would leave empty) and
|
||||
* shifts the pie origin so that box is centered. A full circle reproduces
|
||||
* ECharts' default layout exactly.
|
||||
*/
|
||||
export function getPieLayout({
|
||||
return 'none';
|
||||
};
|
||||
|
||||
const getHalfDonutLayout = (startAngle: number, sweptAngle: number) =>
|
||||
HALF_DONUT_LAYOUT[getHalfDonut(startAngle, sweptAngle)];
|
||||
|
||||
export function getTotalValuePadding({
|
||||
chartPadding,
|
||||
donut,
|
||||
width,
|
||||
height,
|
||||
padding,
|
||||
startAngle,
|
||||
sweptAngle,
|
||||
donut,
|
||||
innerRadius,
|
||||
outerRadius,
|
||||
}: {
|
||||
width: number;
|
||||
height: number;
|
||||
padding: { top: number; bottom: number; left: number; right: number };
|
||||
startAngle: number;
|
||||
sweptAngle: number;
|
||||
donut: boolean;
|
||||
innerRadius: number;
|
||||
outerRadius: number;
|
||||
}): PieLayout {
|
||||
const rectWidth = Math.max(width - padding.left - padding.right, 1);
|
||||
const rectHeight = Math.max(height - padding.top - padding.bottom, 1);
|
||||
const innerRatio = donut ? innerRadius / Math.max(outerRadius, 1) : 0;
|
||||
const box = getArcBoundingBox(startAngle, sweptAngle, innerRatio);
|
||||
const boxWidth = box.maxX - box.minX;
|
||||
const boxHeight = box.maxY - box.minY;
|
||||
}: TotalValuePaddingProps): PaddingResult {
|
||||
const safeHeight = height || 1;
|
||||
const safeWidth = width || 1;
|
||||
|
||||
// ECharts resolves percentage radii against min(rect width, height) / 2.
|
||||
// Grow that basis until the arc's bounding box hits the rect on one axis.
|
||||
const fullBasis = Math.min(rectWidth, rectHeight) / 2;
|
||||
const fitBasis = Math.min(rectWidth / boxWidth, rectHeight / boxHeight);
|
||||
const scale = Math.min(fitBasis / fullBasis, MAX_RADIUS_SCALE);
|
||||
const outerPx = (outerRadius / 100) * fullBasis * scale;
|
||||
const halfType = getHalfDonut(startAngle, sweptAngle);
|
||||
const layout = HALF_DONUT_LAYOUT[halfType];
|
||||
const isHalf = halfType !== 'none';
|
||||
|
||||
// Place the pie origin so the arc's box is centered in the rect. Unit y
|
||||
// points up while screen y points down, hence the sign flip.
|
||||
const round = (value: number) => Math.round(value * 100) / 100;
|
||||
const centerX = rectWidth / 2 - ((box.minX + box.maxX) / 2) * outerPx;
|
||||
const centerY = rectHeight / 2 + ((box.minY + box.maxY) / 2) * outerPx;
|
||||
const calculateTop = (): string => {
|
||||
if (chartPadding.bottom) {
|
||||
return donut
|
||||
? `${layout.totalBase.top - (chartPadding.bottom / safeHeight) * 50}%`
|
||||
: '0';
|
||||
}
|
||||
|
||||
// The pie origin is the natural spot for the "Total" text (the middle of
|
||||
// the hole, or the flat edge of a half donut), but for narrow arcs it can
|
||||
// fall far outside the drawn shape, even off-canvas. Clamp it into the
|
||||
// arc's bounding box, which always sits within the rect.
|
||||
const clamp = (value: number, min: number, max: number) =>
|
||||
Math.min(Math.max(value, min), max);
|
||||
const halfBoxWidth = (boxWidth / 2) * outerPx;
|
||||
const halfBoxHeight = (boxHeight / 2) * outerPx;
|
||||
const anchorX = clamp(
|
||||
centerX,
|
||||
rectWidth / 2 - halfBoxWidth,
|
||||
rectWidth / 2 + halfBoxWidth,
|
||||
);
|
||||
const anchorY = clamp(
|
||||
centerY,
|
||||
rectHeight / 2 - halfBoxHeight,
|
||||
rectHeight / 2 + halfBoxHeight,
|
||||
);
|
||||
if (chartPadding.top || isHalf) {
|
||||
if (donut) {
|
||||
return `${layout.totalBase.top + (chartPadding.top / safeHeight) * 50}%`;
|
||||
}
|
||||
return `${(chartPadding.top / safeHeight) * 100}%`;
|
||||
}
|
||||
|
||||
return donut ? 'middle' : '0';
|
||||
};
|
||||
|
||||
const calculateLeft = (): string => {
|
||||
if (chartPadding.right) {
|
||||
const rightPercent = (chartPadding.right / safeWidth) * 100;
|
||||
return `${layout.totalBase.left - rightPercent * 0.75}%`;
|
||||
}
|
||||
|
||||
if (chartPadding.left) {
|
||||
const leftPercent = (chartPadding.left / safeWidth) * 100;
|
||||
return `${layout.totalBase.left + leftPercent * 0.25}%`;
|
||||
}
|
||||
|
||||
if (isHalf && (halfType === 'left' || halfType === 'right')) {
|
||||
return `${layout.totalBase.left}%`;
|
||||
}
|
||||
|
||||
return 'center';
|
||||
};
|
||||
|
||||
return {
|
||||
center: [round(centerX), round(centerY)],
|
||||
radius: [
|
||||
`${round(donut ? innerRadius * scale : 0)}%`,
|
||||
`${round(outerRadius * scale)}%`,
|
||||
],
|
||||
totalAnchor: {
|
||||
x: round(padding.left + anchorX),
|
||||
y: round(padding.top + anchorY),
|
||||
},
|
||||
top: calculateTop(),
|
||||
left: calculateLeft(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -508,16 +478,7 @@ export default function transformProps(
|
||||
effectiveLegendMargin,
|
||||
);
|
||||
|
||||
const pieLayout = getPieLayout({
|
||||
width,
|
||||
height,
|
||||
padding: chartPadding,
|
||||
startAngle,
|
||||
sweptAngle,
|
||||
donut,
|
||||
innerRadius,
|
||||
outerRadius,
|
||||
});
|
||||
const { centerOffset } = getHalfDonutLayout(startAngle, sweptAngle);
|
||||
|
||||
const series: PieSeriesOption[] = [
|
||||
{
|
||||
@@ -525,8 +486,8 @@ export default function transformProps(
|
||||
...chartPadding,
|
||||
animation: false,
|
||||
roseType: roseType || undefined,
|
||||
radius: pieLayout.radius,
|
||||
center: pieLayout.center,
|
||||
radius: [`${donut ? innerRadius : 0}%`, `${outerRadius}%`],
|
||||
center: [`${50 + centerOffset.x}%`, `${50 + centerOffset.y}%`],
|
||||
startAngle,
|
||||
endAngle: startAngle - sweptAngle,
|
||||
avoidLabelOverlap: true,
|
||||
@@ -589,18 +550,16 @@ export default function transformProps(
|
||||
graphic: showTotal
|
||||
? {
|
||||
type: 'text',
|
||||
// Donut: center the text on the pie origin (the middle of the
|
||||
// hole, or the flat edge of a partial arc). Pie: park it at the
|
||||
// top center of the padded rect so it doesn't overlap the slices.
|
||||
x: donut
|
||||
? pieLayout.totalAnchor.x
|
||||
: chartPadding.left +
|
||||
(width - chartPadding.left - chartPadding.right) / 2,
|
||||
y: donut ? pieLayout.totalAnchor.y : chartPadding.top,
|
||||
...getTotalValuePadding({
|
||||
chartPadding,
|
||||
donut,
|
||||
width,
|
||||
height,
|
||||
startAngle,
|
||||
sweptAngle,
|
||||
}),
|
||||
style: {
|
||||
text: t('Total: %s', numberFormatter(totalValue)),
|
||||
align: 'center',
|
||||
verticalAlign: donut ? 'middle' : 'top',
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
fill: theme.colorText,
|
||||
|
||||
@@ -103,3 +103,36 @@ export interface PieChartDataItem {
|
||||
};
|
||||
isOther?: boolean;
|
||||
}
|
||||
|
||||
interface ChartPadding {
|
||||
top: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
right: number;
|
||||
}
|
||||
|
||||
export interface TotalValuePaddingProps {
|
||||
chartPadding: ChartPadding;
|
||||
donut: boolean;
|
||||
width: number;
|
||||
height: number;
|
||||
sweptAngle: number;
|
||||
startAngle: number;
|
||||
}
|
||||
|
||||
export interface PaddingResult {
|
||||
top?: string;
|
||||
left?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Semicircular chart layout type.
|
||||
*
|
||||
* - `'top'` — arc at the top, center shifted downwards.
|
||||
* - `'bottom'` — arc at the bottom, center shifted upwards.
|
||||
* - `'left'` — arc on the left, center shifted to the right.
|
||||
* - `'right'` — arc on the right, center shifted to the left.
|
||||
* - `'none'` — full circle (no recentering).
|
||||
* @see getHalfDonut
|
||||
*/
|
||||
export type HalfDonut = 'top' | 'bottom' | 'left' | 'right' | 'none';
|
||||
|
||||
-80
@@ -1161,83 +1161,3 @@ test('x-axis dedup keeps the forced min label when the endpoints format identica
|
||||
|
||||
expect(formatter(min)).toBe('May');
|
||||
});
|
||||
|
||||
test('regression #37921: multi-metric Query A with groupby does not duplicate first metric in series names', () => {
|
||||
// Regression test for https://github.com/apache/superset/issues/37921
|
||||
// ("Residual" follow-up to #37055).
|
||||
//
|
||||
// When Query A has multiple metrics + at least one Group By dimension,
|
||||
// the display-name builder in transformProps.ts used to prepend the FIRST
|
||||
// metric's display name to every series that didn't literally contain it:
|
||||
// name: `${MetricDisplayNameA}, ${entryName}`
|
||||
// For series belonging to the *second* metric, this produced a
|
||||
// cross-contaminated label like `score_one, score_two, A` — the
|
||||
// user-visible "first metric duplicated" symptom in the legend / tooltip.
|
||||
// The fix derives each series' metric from its label-map tuple instead.
|
||||
const multiMetricRows = [
|
||||
{
|
||||
'score_one, A': 1,
|
||||
'score_one, B': 2,
|
||||
'score_two, A': 3,
|
||||
'score_two, B': 4,
|
||||
ds: 599616000000,
|
||||
},
|
||||
{
|
||||
'score_one, A': 5,
|
||||
'score_one, B': 6,
|
||||
'score_two, A': 7,
|
||||
'score_two, B': 8,
|
||||
ds: 599916000000,
|
||||
},
|
||||
];
|
||||
const multiMetricLabelMap = {
|
||||
ds: ['ds'],
|
||||
'score_one, A': ['score_one', 'A'],
|
||||
'score_one, B': ['score_one', 'B'],
|
||||
'score_two, A': ['score_two', 'A'],
|
||||
'score_two, B': ['score_two', 'B'],
|
||||
};
|
||||
|
||||
const queryAData = createTestQueryData(multiMetricRows, {
|
||||
label_map: multiMetricLabelMap,
|
||||
});
|
||||
// Query B keeps the existing single-metric shape — the bug is on
|
||||
// Query A's path so we just need a valid Query B alongside.
|
||||
const queryBData = createTestQueryData(defaultQueryRows, {
|
||||
label_map: defaultLabelMap,
|
||||
});
|
||||
|
||||
const chartProps = createEchartsTimeseriesTestChartProps<
|
||||
EchartsMixedTimeseriesFormData,
|
||||
EchartsMixedTimeseriesProps
|
||||
>({
|
||||
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
|
||||
defaultQueriesData: [queryAData, queryBData],
|
||||
formData: {
|
||||
...formData,
|
||||
metrics: ['score_one', 'score_two'],
|
||||
groupby: ['category'],
|
||||
},
|
||||
queriesData: [queryAData, queryBData],
|
||||
});
|
||||
const transformed = transformProps(chartProps);
|
||||
|
||||
const queryASeriesNames = (transformed.echartOptions.series as any[])
|
||||
.map((s: any) => String(s.name))
|
||||
.filter((n: string) => n.includes('score_'));
|
||||
|
||||
// Each (metric, dim_value) combo from Query A should appear exactly once
|
||||
// with the *correct* metric prefix — not the first-metric-prepended-to-
|
||||
// everything-else form. Comparing the sorted array (rather than using
|
||||
// separate toContain assertions) also catches a regression that emits
|
||||
// duplicate series for the same name.
|
||||
expect([...queryASeriesNames].sort()).toEqual(
|
||||
['score_one, A', 'score_one, B', 'score_two, A', 'score_two, B'].sort(),
|
||||
);
|
||||
|
||||
// And explicitly: no series name should contain *both* metric names —
|
||||
// that's the smoking gun for the duplication bug.
|
||||
for (const name of queryASeriesNames) {
|
||||
expect(name).not.toMatch(/score_one,\s+score_two/);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -29,20 +29,12 @@ import type {
|
||||
} from 'echarts/types/src/util/types';
|
||||
import transformProps, {
|
||||
parseParams,
|
||||
getArcBoundingBox,
|
||||
getPieLayout,
|
||||
getHalfDonut,
|
||||
getTotalValuePadding,
|
||||
} from '../../src/Pie/transformProps';
|
||||
import { EchartsPieChartProps, PieChartDataItem } from '../../src/Pie/types';
|
||||
import { LegendOrientation, LegendType } from '../../src/types';
|
||||
|
||||
const getGraphic = (transformed: ReturnType<typeof transformProps>) =>
|
||||
transformed.echartOptions.graphic as {
|
||||
type: string;
|
||||
x: number;
|
||||
y: number;
|
||||
style: { text: string; align: string; verticalAlign: string };
|
||||
};
|
||||
|
||||
describe('Pie transformProps', () => {
|
||||
const formData: SqlaFormData = {
|
||||
colorScheme: 'bnbColors',
|
||||
@@ -360,60 +352,109 @@ describe('Total value positioning with legends', () => {
|
||||
|
||||
test('should center total text when legend is on the right', () => {
|
||||
const props = getChartPropsWithLegend(true, true, 'right', true);
|
||||
const graphic = getGraphic(transformProps(props));
|
||||
const transformed = transformProps(props);
|
||||
|
||||
expect(graphic.type).toBe('text');
|
||||
expect(graphic.style.text).toContain('Total:');
|
||||
expect(graphic.style.align).toBe('center');
|
||||
expect(graphic.style.verticalAlign).toBe('middle');
|
||||
// Anchored on the pie origin, which shifts left of the container center
|
||||
// because the right legend narrows the series rect.
|
||||
expect(graphic.x).toBeLessThan(400);
|
||||
expect(graphic.y).toBe(300);
|
||||
expect(transformed.echartOptions.graphic).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'text',
|
||||
left: expect.stringMatching(/^\d+(\.\d+)?%$/),
|
||||
top: 'middle',
|
||||
style: expect.objectContaining({
|
||||
text: expect.stringContaining('Total:'),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// The left position should be less than 50% (shifted left)
|
||||
const leftValue = parseFloat(
|
||||
(transformed.echartOptions.graphic as any).left.replace('%', ''),
|
||||
);
|
||||
expect(leftValue).toBeLessThan(50);
|
||||
expect(leftValue).toBeGreaterThan(30); // Should be reasonable positioning
|
||||
});
|
||||
|
||||
test('should center total text when legend is on the left', () => {
|
||||
const props = getChartPropsWithLegend(true, true, 'left', true);
|
||||
const graphic = getGraphic(transformProps(props));
|
||||
const transformed = transformProps(props);
|
||||
|
||||
// The left legend pads the rect, pushing the pie origin right.
|
||||
expect(graphic.x).toBeGreaterThan(400);
|
||||
expect(graphic.y).toBe(300);
|
||||
expect(transformed.echartOptions.graphic).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'text',
|
||||
left: expect.stringMatching(/^\d+(\.\d+)?%$/),
|
||||
top: 'middle',
|
||||
}),
|
||||
);
|
||||
|
||||
// The left position should be greater than 50% (shifted right)
|
||||
const leftValue = parseFloat(
|
||||
(transformed.echartOptions.graphic as any).left.replace('%', ''),
|
||||
);
|
||||
expect(leftValue).toBeGreaterThan(50);
|
||||
expect(leftValue).toBeLessThan(70); // Should be reasonable positioning
|
||||
});
|
||||
|
||||
test('should center total text when legend is on top', () => {
|
||||
const props = getChartPropsWithLegend(true, true, 'top', true);
|
||||
const graphic = getGraphic(transformProps(props));
|
||||
const transformed = transformProps(props);
|
||||
|
||||
expect(graphic.x).toBe(400);
|
||||
expect(graphic.y).toBeGreaterThan(300);
|
||||
expect(transformed.echartOptions.graphic).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'text',
|
||||
left: 'center',
|
||||
top: expect.stringMatching(/^\d+(\.\d+)?%$/),
|
||||
}),
|
||||
);
|
||||
|
||||
// The top position should be adjusted for top legend
|
||||
const topValue = parseFloat(
|
||||
(transformed.echartOptions.graphic as any).top.replace('%', ''),
|
||||
);
|
||||
expect(topValue).toBeGreaterThan(50); // Shifted down for top legend
|
||||
});
|
||||
|
||||
test('should center total text when legend is on bottom', () => {
|
||||
const props = getChartPropsWithLegend(true, true, 'bottom', true);
|
||||
const graphic = getGraphic(transformProps(props));
|
||||
const transformed = transformProps(props);
|
||||
|
||||
expect(graphic.x).toBe(400);
|
||||
expect(graphic.y).toBeLessThan(300);
|
||||
expect(transformed.echartOptions.graphic).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'text',
|
||||
left: 'center',
|
||||
top: expect.stringMatching(/^\d+(\.\d+)?%$/),
|
||||
}),
|
||||
);
|
||||
|
||||
// The top position should be adjusted for bottom legend
|
||||
const topValue = parseFloat(
|
||||
(transformed.echartOptions.graphic as any).top.replace('%', ''),
|
||||
);
|
||||
expect(topValue).toBeLessThan(50); // Shifted up for bottom legend
|
||||
});
|
||||
|
||||
test('should center on the pie origin when no legend is shown', () => {
|
||||
test('should use default positioning when no legend is shown', () => {
|
||||
const props = getChartPropsWithLegend(true, false, 'right', true);
|
||||
const graphic = getGraphic(transformProps(props));
|
||||
const transformed = transformProps(props);
|
||||
|
||||
expect(graphic.x).toBe(400);
|
||||
expect(graphic.y).toBe(300);
|
||||
expect(graphic.style.verticalAlign).toBe('middle');
|
||||
expect(transformed.echartOptions.graphic).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'text',
|
||||
left: 'center',
|
||||
top: 'middle',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('should park total at the top of the rect for non-donut charts', () => {
|
||||
test('should handle regular pie chart (non-donut) positioning', () => {
|
||||
const props = getChartPropsWithLegend(true, true, 'right', false);
|
||||
const graphic = getGraphic(transformProps(props));
|
||||
const transformed = transformProps(props);
|
||||
|
||||
expect(graphic.y).toBe(0);
|
||||
expect(graphic.style.verticalAlign).toBe('top');
|
||||
// Horizontally centered over the narrowed rect, not the container.
|
||||
expect(graphic.x).toBeLessThan(400);
|
||||
expect(transformed.echartOptions.graphic).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'text',
|
||||
top: '0', // Non-donut charts use '0' as default top position
|
||||
left: expect.stringMatching(/^\d+(\.\d+)?%$/), // Should still adjust left for right legend
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('should not show total graphic when showTotal is false', () => {
|
||||
@@ -614,7 +655,6 @@ const getAngleChartProps = (
|
||||
startAngle,
|
||||
sweptAngle,
|
||||
show_total: true,
|
||||
show_legend: false,
|
||||
};
|
||||
|
||||
return new ChartProps({
|
||||
@@ -633,110 +673,217 @@ const getAngleChartProps = (
|
||||
}) as EchartsPieChartProps;
|
||||
};
|
||||
|
||||
const getSeries = (props: EchartsPieChartProps) =>
|
||||
transformProps(props).echartOptions.series as PieSeriesOption[];
|
||||
|
||||
test('keeps ECharts default layout for a full donut', () => {
|
||||
const series = getSeries(getAngleChartProps(true, 360, 90));
|
||||
expect(series[0].center).toEqual([400, 300]);
|
||||
expect(series[0].radius).toEqual(['30%', '70%']);
|
||||
test('sets center to 70% for half-donut', () => {
|
||||
const props = getAngleChartProps(true, 180);
|
||||
const transformed = transformProps(props);
|
||||
const series = transformed.echartOptions.series as PieSeriesOption[];
|
||||
expect(series[0].center).toEqual(['50%', '70%']);
|
||||
});
|
||||
|
||||
test('recenters and scales up a top half-donut', () => {
|
||||
// Bounding box is 2 wide x 1 tall, so an 800x600 canvas fits a radius
|
||||
// basis of min(800/2, 600/1) = 400 instead of 300: scale 4/3.
|
||||
const series = getSeries(getAngleChartProps(true, 180, 180));
|
||||
expect(series[0].center).toEqual([400, 440]);
|
||||
expect(series[0].radius).toEqual(['40%', '93.33%']);
|
||||
test('keeps center at 50% for full donut', () => {
|
||||
const props = getAngleChartProps(true, 360);
|
||||
const transformed = transformProps(props);
|
||||
const series = transformed.echartOptions.series as PieSeriesOption[];
|
||||
expect(series[0].center).toEqual(['50%', '50%']);
|
||||
});
|
||||
|
||||
test('recenters a bottom half-donut upwards', () => {
|
||||
const series = getSeries(getAngleChartProps(true, 180, 0));
|
||||
expect(series[0].center).toEqual([400, 160]);
|
||||
test('calculates endAngle for a quarter donut', () => {
|
||||
const props = getAngleChartProps(true, 90);
|
||||
const transformed = transformProps(props);
|
||||
const series = transformed.echartOptions.series as PieSeriesOption[];
|
||||
expect(series[0].endAngle).toBe(90);
|
||||
});
|
||||
|
||||
test('recenters a right half-donut leftwards without scaling', () => {
|
||||
// A lateral half is 1 wide x 2 tall; height binds at the full-circle
|
||||
// basis, so the radius stays put and only the center shifts.
|
||||
const series = getSeries(getAngleChartProps(true, 180, 90));
|
||||
expect(series[0].center).toEqual([295, 300]);
|
||||
expect(series[0].radius).toEqual(['30%', '70%']);
|
||||
test('sets center to 30% for bottom half-donut (startAngle=0)', () => {
|
||||
const props = getAngleChartProps(true, 180, 0);
|
||||
const transformed = transformProps(props);
|
||||
const series = transformed.echartOptions.series as PieSeriesOption[];
|
||||
expect(series[0].center).toEqual(['50%', '30%']);
|
||||
});
|
||||
|
||||
test('recenters a left half-donut rightwards', () => {
|
||||
const series = getSeries(getAngleChartProps(true, 180, 270));
|
||||
expect(series[0].center).toEqual([505, 300]);
|
||||
test('sets center to 30% for bottom half-donut (startAngle=360)', () => {
|
||||
const props = getAngleChartProps(true, 180, 360);
|
||||
const transformed = transformProps(props);
|
||||
const series = transformed.echartOptions.series as PieSeriesOption[];
|
||||
expect(series[0].center).toEqual(['50%', '30%']);
|
||||
});
|
||||
|
||||
test('recenters non-cardinal start angles too', () => {
|
||||
const series = getSeries(getAngleChartProps(true, 180, 170));
|
||||
expect(series[0].center).not.toEqual([400, 300]);
|
||||
test('shifts center left for right half-donut (startAngle=90)', () => {
|
||||
const props = getAngleChartProps(true, 180, 90);
|
||||
const transformed = transformProps(props);
|
||||
const series = transformed.echartOptions.series as PieSeriesOption[];
|
||||
expect(series[0].center).toEqual(['40%', '50%']);
|
||||
});
|
||||
|
||||
test('scales a quarter donut to the fit cap', () => {
|
||||
const series = getSeries(getAngleChartProps(true, 90, 180));
|
||||
expect(series[0].center).toEqual([610, 510]);
|
||||
expect(series[0].radius).toEqual(['60%', '140%']);
|
||||
test('shifts center right for left half-donut (startAngle=270)', () => {
|
||||
const props = getAngleChartProps(true, 180, 270);
|
||||
const transformed = transformProps(props);
|
||||
const series = transformed.echartOptions.series as PieSeriesOption[];
|
||||
expect(series[0].center).toEqual(['60%', '50%']);
|
||||
});
|
||||
|
||||
test('passes startAngle through and derives endAngle from the sweep', () => {
|
||||
const series = getSeries(getAngleChartProps(true, 180, 90));
|
||||
test('keeps center at 50% for non-cardinal start angle even when sweep ≤ 180', () => {
|
||||
const props = getAngleChartProps(true, 180, 45);
|
||||
const transformed = transformProps(props);
|
||||
const series = transformed.echartOptions.series as PieSeriesOption[];
|
||||
expect(series[0].center).toEqual(['50%', '50%']);
|
||||
});
|
||||
|
||||
test('allows endAngle to go negative for right half-donut', () => {
|
||||
const props = getAngleChartProps(true, 180, 90);
|
||||
const transformed = transformProps(props);
|
||||
const series = transformed.echartOptions.series as PieSeriesOption[];
|
||||
expect(series[0].startAngle).toBe(90);
|
||||
expect(series[0].endAngle).toBe(-90);
|
||||
});
|
||||
|
||||
test('anchors the total on the pie origin for a top half-donut', () => {
|
||||
const graphic = getGraphic(
|
||||
transformProps(getAngleChartProps(true, 180, 180)),
|
||||
);
|
||||
expect(graphic.x).toBe(400);
|
||||
expect(graphic.y).toBe(440);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['full circle', 90, 360, 0.3, { minX: -1, maxX: 1, minY: -1, maxY: 1 }],
|
||||
['top half', 180, 180, 0, { minX: -1, maxX: 1, minY: 0, maxY: 1 }],
|
||||
['bottom half', 0, 180, 0, { minX: -1, maxX: 1, minY: -1, maxY: 0 }],
|
||||
['right half', 90, 180, 0, { minX: 0, maxX: 1, minY: -1, maxY: 1 }],
|
||||
['left half', 270, 180, 0, { minX: -1, maxX: 0, minY: -1, maxY: 1 }],
|
||||
['top-left quarter', 180, 90, 0.5, { minX: -1, maxX: 0, minY: 0, maxY: 1 }],
|
||||
])('getArcBoundingBox: %s', (_label, start, sweep, inner, expected) => {
|
||||
const box = getArcBoundingBox(start, sweep, inner);
|
||||
expect(box.minX).toBeCloseTo(expected.minX, 10);
|
||||
expect(box.maxX).toBeCloseTo(expected.maxX, 10);
|
||||
expect(box.minY).toBeCloseTo(expected.minY, 10);
|
||||
expect(box.maxY).toBeCloseTo(expected.maxY, 10);
|
||||
[180, 180, 'top'],
|
||||
[180, 90, 'top'],
|
||||
[180, 45, 'top'],
|
||||
[0, 180, 'bottom'],
|
||||
[360, 180, 'bottom'],
|
||||
[360, 90, 'bottom'],
|
||||
[90, 180, 'right'],
|
||||
[90, 90, 'right'],
|
||||
[270, 180, 'left'],
|
||||
[270, 90, 'left'],
|
||||
[45, 180, 'none'],
|
||||
[170, 180, 'none'],
|
||||
[180, 360, 'none'],
|
||||
[180, 181, 'none'],
|
||||
[0, 360, 'none'],
|
||||
])('startAngle=%i, sweptAngle=%i → %s', (start, swept, expected) => {
|
||||
expect(getHalfDonut(start, swept)).toBe(expected);
|
||||
});
|
||||
|
||||
test('getArcBoundingBox includes inner arc endpoints for narrow donuts', () => {
|
||||
// A 20-degree sliver straddling 12 o'clock: the lowest point of the
|
||||
// annular sector is an inner endpoint, not an outer one.
|
||||
const box = getArcBoundingBox(100, 20, 0.5);
|
||||
expect(box.minY).toBeCloseTo(0.5 * Math.sin((80 * Math.PI) / 180), 10);
|
||||
expect(box.maxY).toBeCloseTo(1, 10);
|
||||
});
|
||||
const baseProps = {
|
||||
donut: true,
|
||||
width: 800,
|
||||
height: 600,
|
||||
startAngle: 180,
|
||||
sweptAngle: 360,
|
||||
};
|
||||
|
||||
test('getPieLayout centers the bounding box within legend padding', () => {
|
||||
const layout = getPieLayout({
|
||||
width: 800,
|
||||
height: 600,
|
||||
padding: { top: 0, bottom: 0, left: 0, right: 200 },
|
||||
startAngle: 90,
|
||||
sweptAngle: 360,
|
||||
donut: true,
|
||||
innerRadius: 30,
|
||||
outerRadius: 70,
|
||||
test('returns "middle" for donut without padding and not half', () => {
|
||||
const result = getTotalValuePadding({
|
||||
...baseProps,
|
||||
chartPadding: { top: 0, bottom: 0, left: 0, right: 0 },
|
||||
});
|
||||
// Rect is 600x600; the pie centers within it and the total anchor is
|
||||
// reported in container coordinates.
|
||||
expect(layout.center).toEqual([300, 300]);
|
||||
expect(layout.totalAnchor).toEqual({ x: 300, y: 300 });
|
||||
expect(result.top).toBe('middle');
|
||||
});
|
||||
|
||||
test('clamps the total anchor into the arc box for narrow arcs', () => {
|
||||
// A 20-degree sliver's pie origin falls far below the drawn wedge; the
|
||||
// anchor must stay within the arc's bounding box so the text is visible.
|
||||
const graphic = getGraphic(transformProps(getAngleChartProps(true, 20, 100)));
|
||||
expect(graphic.x).toBe(400);
|
||||
expect(graphic.y).toBeCloseTo(421.37, 1);
|
||||
test('returns "0" for non-donut without padding and not half', () => {
|
||||
const result = getTotalValuePadding({
|
||||
...baseProps,
|
||||
donut: false,
|
||||
chartPadding: { top: 0, bottom: 0, left: 0, right: 0 },
|
||||
});
|
||||
expect(result.top).toBe('0');
|
||||
});
|
||||
|
||||
test('adjusts top for donut with bottom padding', () => {
|
||||
const result = getTotalValuePadding({
|
||||
...baseProps,
|
||||
chartPadding: { top: 0, bottom: 60, left: 0, right: 0 },
|
||||
});
|
||||
expect(result.top).toBe('45%');
|
||||
});
|
||||
|
||||
test('returns "0" for non-donut with bottom padding', () => {
|
||||
const result = getTotalValuePadding({
|
||||
...baseProps,
|
||||
donut: false,
|
||||
chartPadding: { top: 0, bottom: 60, left: 0, right: 0 },
|
||||
});
|
||||
expect(result.top).toBe('0');
|
||||
});
|
||||
|
||||
test('adjusts top for donut with top padding', () => {
|
||||
const result = getTotalValuePadding({
|
||||
...baseProps,
|
||||
chartPadding: { top: 60, bottom: 0, left: 0, right: 0 },
|
||||
});
|
||||
expect(result.top).toBe('55%');
|
||||
});
|
||||
|
||||
test('adjusts top for non-donut with top padding', () => {
|
||||
const result = getTotalValuePadding({
|
||||
...baseProps,
|
||||
donut: false,
|
||||
chartPadding: { top: 60, bottom: 0, left: 0, right: 0 },
|
||||
});
|
||||
expect(result.top).toBe('10%');
|
||||
});
|
||||
|
||||
test('positions total at 68.5% for top half-donut without padding', () => {
|
||||
const result = getTotalValuePadding({
|
||||
...baseProps,
|
||||
sweptAngle: 180,
|
||||
chartPadding: { top: 0, bottom: 0, left: 0, right: 0 },
|
||||
});
|
||||
expect(result.top).toBe('68.5%');
|
||||
});
|
||||
|
||||
test('adjusts total position from 68.5% base for top half-donut with top padding', () => {
|
||||
const result = getTotalValuePadding({
|
||||
...baseProps,
|
||||
sweptAngle: 180,
|
||||
chartPadding: { top: 60, bottom: 0, left: 0, right: 0 },
|
||||
});
|
||||
expect(result.top).toBe('73.5%');
|
||||
});
|
||||
|
||||
test('returns "center" when no left/right padding', () => {
|
||||
const result = getTotalValuePadding({
|
||||
...baseProps,
|
||||
chartPadding: { top: 0, bottom: 0, left: 0, right: 0 },
|
||||
});
|
||||
expect(result.left).toBe('center');
|
||||
});
|
||||
|
||||
test('adjusts left for left padding', () => {
|
||||
const result = getTotalValuePadding({
|
||||
...baseProps,
|
||||
chartPadding: { top: 0, bottom: 0, left: 80, right: 0 },
|
||||
});
|
||||
expect(result.left).toBe('52.5%');
|
||||
});
|
||||
|
||||
test('adjusts left for right padding', () => {
|
||||
const result = getTotalValuePadding({
|
||||
...baseProps,
|
||||
chartPadding: { top: 0, bottom: 0, left: 0, right: 80 },
|
||||
});
|
||||
expect(result.left).toBe('42.5%');
|
||||
});
|
||||
|
||||
test('prioritizes right padding over left padding', () => {
|
||||
const result = getTotalValuePadding({
|
||||
...baseProps,
|
||||
chartPadding: { top: 0, bottom: 0, left: 80, right: 80 },
|
||||
});
|
||||
expect(result.left).toBe('42.5%');
|
||||
});
|
||||
|
||||
test('positions total inside the shifted center for left half-donut', () => {
|
||||
const result = getTotalValuePadding({
|
||||
...baseProps,
|
||||
startAngle: 270,
|
||||
sweptAngle: 180,
|
||||
chartPadding: { top: 0, bottom: 0, left: 0, right: 0 },
|
||||
});
|
||||
expect(result.left).toBe('55%');
|
||||
expect(result.top).toBe('50%');
|
||||
});
|
||||
|
||||
test('positions total inside the shifted center for right half-donut', () => {
|
||||
const result = getTotalValuePadding({
|
||||
...baseProps,
|
||||
startAngle: 90,
|
||||
sweptAngle: 180,
|
||||
chartPadding: { top: 0, bottom: 0, left: 0, right: 0 },
|
||||
});
|
||||
expect(result.left).toBe('35%');
|
||||
expect(result.top).toBe('50%');
|
||||
});
|
||||
|
||||
@@ -26,7 +26,7 @@ export function getURIDirectory(endpointType = 'base') {
|
||||
// Building the directory part of the URI
|
||||
let directory = '/explore/';
|
||||
if (['json', 'csv', 'query', 'results', 'samples'].includes(endpointType)) {
|
||||
directory = '/explore_json/';
|
||||
directory = '/superset/explore_json/';
|
||||
}
|
||||
|
||||
return directory;
|
||||
|
||||
@@ -713,8 +713,8 @@ export function handleChartDataResponse(
|
||||
// Query is running asynchronously and we must await the results.
|
||||
// When status is 202, result contains async event data (job_id, channel_id, etc.)
|
||||
// which differs from QueryData. We cast through unknown to handle this safely.
|
||||
// The optional signal lets a caller abort the wait (Stop pressed, chart
|
||||
// superseded or unmounted), cancelling the job and avoiding leaked listeners.
|
||||
// The optional signal lets a caller (e.g. StatefulChart) cancel the wait
|
||||
// when its chart is superseded or unmounted, avoiding leaked listeners.
|
||||
if (useLegacyApi) {
|
||||
return waitForAsyncData(
|
||||
result[0] as unknown as Parameters<typeof waitForAsyncData>[0],
|
||||
@@ -786,25 +786,15 @@ export function exploreJSON(
|
||||
const [useLegacyApi] = getQuerySettings(formData);
|
||||
const chartDataRequestCaught = chartDataRequest
|
||||
.then(({ response, json }) =>
|
||||
handleChartDataResponse(
|
||||
response,
|
||||
json,
|
||||
useLegacyApi,
|
||||
controller.signal,
|
||||
),
|
||||
handleChartDataResponse(response, json, useLegacyApi),
|
||||
)
|
||||
.then(queriesResponse => {
|
||||
// Drop stale responses: if this request was aborted (Stop, or a newer
|
||||
// query that aborted ours), or a newer query has since replaced our
|
||||
// controller in state, ignore the result so we don't clobber newer
|
||||
// data or a 'stopped' status. Checking the signal is authoritative
|
||||
// because the reducer nulls out queryController when a query stops.
|
||||
// Drop stale responses: if a newer query has started for this chart,
|
||||
// its controller will have replaced ours in state, so ignore this
|
||||
// response to avoid clobbering newer data with older results.
|
||||
if (key != null) {
|
||||
const currentController = getState().charts?.[key]?.queryController;
|
||||
if (
|
||||
controller.signal.aborted ||
|
||||
(currentController != null && currentController !== controller)
|
||||
) {
|
||||
if (currentController && currentController !== controller) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -863,10 +853,7 @@ export function exploreJSON(
|
||||
// so a slow earlier request can't mark a newer one as failed.
|
||||
if (key != null) {
|
||||
const currentController = getState().charts?.[key]?.queryController;
|
||||
if (
|
||||
controller.signal.aborted ||
|
||||
(currentController != null && currentController !== controller)
|
||||
) {
|
||||
if (currentController && currentController !== controller) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,10 +47,6 @@ export const URL_PARAMS = {
|
||||
name: 'show_filters',
|
||||
type: 'boolean',
|
||||
},
|
||||
showDownload: {
|
||||
name: 'show_download',
|
||||
type: 'boolean',
|
||||
},
|
||||
expandFilters: {
|
||||
name: 'expand_filters',
|
||||
type: 'boolean',
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import DashboardProvider from './DashboardProvider';
|
||||
import { registerBuiltInBuildingBlocks } from './registerBuiltInBuildingBlocks';
|
||||
import BuildingBlockView from './BuildingBlockView';
|
||||
|
||||
const provider = DashboardProvider.getInstance();
|
||||
|
||||
beforeAll(() => {
|
||||
registerBuiltInBuildingBlocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
provider.reset();
|
||||
});
|
||||
|
||||
const withBlock = () => {
|
||||
const rootId = provider.getRoot().id;
|
||||
const id = provider.addBuildingBlock(rootId, 0, {
|
||||
type: 'metric-tile',
|
||||
props: { label: 'Quarterly notes' },
|
||||
});
|
||||
render(<BuildingBlockView nodeId={id} />);
|
||||
return { rootId, id };
|
||||
};
|
||||
|
||||
test('a block says which one it is', () => {
|
||||
const { id } = withBlock();
|
||||
|
||||
// Named by the same call the Outline names its rows by, so a block is not
|
||||
// "Quarterly notes" in one place and "Metric Tile" in the other.
|
||||
expect(screen.getByTestId(`block-title-${id}`)).toHaveTextContent(
|
||||
'Quarterly notes',
|
||||
);
|
||||
});
|
||||
|
||||
test('the delete control does not have to be found first', () => {
|
||||
const { id } = withBlock();
|
||||
|
||||
// It used to appear only on hover, which is a control you have to already
|
||||
// know is there. `toBeVisible` fails on the opacity that hid it.
|
||||
expect(screen.getByTestId(`block-remove-${id}`)).toBeVisible();
|
||||
});
|
||||
|
||||
test('removing a block is offered as a bin, not as a cross', () => {
|
||||
const { id } = withBlock();
|
||||
|
||||
// A cross on a card is the gesture for dismissing the card — closing it,
|
||||
// putting it away, getting it off screen. This takes the block off the
|
||||
// dashboard, and the bin is what says that everywhere else in the app.
|
||||
expect(
|
||||
screen.getByTestId(`block-remove-${id}`).querySelector('.anticon-delete'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('the root carries no header of its own', () => {
|
||||
const rootId = provider.getRoot().id;
|
||||
render(<BuildingBlockView nodeId={rootId} />);
|
||||
|
||||
// The root is the dashboard rather than something on it: a header there
|
||||
// would label it "Canvas" and offer a delete the provider refuses.
|
||||
expect(
|
||||
screen.queryByTestId(`block-header-${rootId}`),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId(`block-remove-${rootId}`),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("a block's name reads as its title, not as a caption on it", () => {
|
||||
const { id } = withBlock();
|
||||
|
||||
// Set in the secondary colour at the small size, it read as an annotation
|
||||
// hanging above the block rather than as the name of the thing below it —
|
||||
// which is what it is, and the first thing anyone scanning the canvas uses
|
||||
// to tell one block from the next.
|
||||
const title = screen.getByTestId(`block-title-${id}`);
|
||||
|
||||
expect(title).toHaveStyle({ color: 'rgba(0, 0, 0, 0.88)' });
|
||||
// Compared rather than pinned: `fontWeightStrong` is a theme token, and it
|
||||
// does not resolve to the same number here as it does in the app. Asserting
|
||||
// the literal would be asserting the test theme's value, which is not the
|
||||
// one that ships.
|
||||
expect(Number(getComputedStyle(title).fontWeight)).toBeGreaterThan(400);
|
||||
});
|
||||
|
||||
/** The element a node draws itself as — the card, for a block that has one. */
|
||||
const frameOf = (id: string) =>
|
||||
document.querySelector(`[data-node-id="${id}"]`) as HTMLElement;
|
||||
|
||||
test('a block hides what it is drawn over, name and all', () => {
|
||||
const { rootId, id } = withBlock();
|
||||
|
||||
// A free canvas lets blocks overlap, and only the leaf's own box was ever
|
||||
// opaque — so a block raised to the front still showed whatever sat behind
|
||||
// it through the strip carrying its name, and two overlapping blocks
|
||||
// rendered their names on top of each other.
|
||||
expect(frameOf(id)).toHaveStyle({ backgroundColor: '#FFFFFF' });
|
||||
// The root is the canvas everything is arranged on, not a card on it.
|
||||
render(<BuildingBlockView nodeId={rootId} />);
|
||||
expect(frameOf(rootId)).not.toHaveStyle({ backgroundColor: '#FFFFFF' });
|
||||
});
|
||||
|
||||
test('a block is one card, with its name inside the frame rather than above it', () => {
|
||||
const { id } = withBlock();
|
||||
|
||||
// The frame was drawn by the leaf, which begins below the header — so a
|
||||
// card's top edge ran between a block's name and its contents, and the name
|
||||
// read as a caption floating over a separate box rather than as the head of
|
||||
// the card it belongs to. Drawn once, around both, it is one card.
|
||||
const frame = frameOf(id);
|
||||
expect(frame.style.border).toMatch(/^1px solid /);
|
||||
expect(frame.style.borderRadius).not.toBe('');
|
||||
// Nothing can spill past the corners the frame rounds.
|
||||
expect(frame).toHaveStyle({ overflow: 'hidden' });
|
||||
|
||||
// And the band no longer paints a surface of its own over the one it is on:
|
||||
// two backgrounds meeting at the header's edge is the seam this removes.
|
||||
expect(screen.getByTestId(`block-header-${id}`).style.backgroundColor).toBe(
|
||||
'',
|
||||
);
|
||||
});
|
||||
|
||||
test('a leaf block no longer frames itself, so there is one border and not two', () => {
|
||||
const { id } = withBlock();
|
||||
|
||||
const leaf = screen.getByTestId(`block-content-${id}`)
|
||||
.firstElementChild as HTMLElement;
|
||||
expect(leaf.style.border).toBe('');
|
||||
expect(leaf.style.borderRadius).toBe('');
|
||||
expect(leaf.style.backgroundColor).toBe('');
|
||||
});
|
||||
|
||||
test('the header takes its height out of the block, not out of the canvas', () => {
|
||||
const { rootId, id } = withBlock();
|
||||
|
||||
// A leaf block resolves `height: 100%` against this box — a chart measures
|
||||
// the result to size its canvas — so the band above it has to come out of
|
||||
// the height rather than be added to it, or every block overflows its cell
|
||||
// by exactly the header.
|
||||
expect(screen.getByTestId(`block-content-${id}`).style.height).toMatch(
|
||||
/^calc\(100% - \d+px\)$/,
|
||||
);
|
||||
// The root has no header to subtract.
|
||||
render(<BuildingBlockView nodeId={rootId} />);
|
||||
expect(screen.getByTestId(`block-content-${rootId}`)).toHaveStyle({
|
||||
height: '100%',
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,417 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { forwardRef, type HTMLAttributes } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { css, styled, useTheme } from '@apache-superset/core/theme';
|
||||
import { ActionButton, Flex, Typography } from '@superset-ui/core/components';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { ErrorBoundary } from 'src/components';
|
||||
import { provider, useDashboardRevision } from './store';
|
||||
import { resolveBuildingBlockView } from './resolveBuildingBlockView';
|
||||
import { blockLabel } from './blockLabel';
|
||||
import { blockHeaderControl } from './blockHeaderControl';
|
||||
import RootGrid from './RootGrid';
|
||||
|
||||
function UnsupportedBlockPlaceholder({ nodeId }: { nodeId: string }) {
|
||||
const theme = useTheme();
|
||||
const node = provider.getNode(nodeId);
|
||||
if (!node) return null;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
vertical
|
||||
align="center"
|
||||
justify="center"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: `1px dashed ${theme.colorBorderSecondary}`,
|
||||
borderRadius: theme.borderRadiusLG,
|
||||
padding: theme.padding,
|
||||
backgroundColor: theme.colorFillQuaternary,
|
||||
}}
|
||||
>
|
||||
<Typography.Text type="secondary">
|
||||
{t('Unsupported block type:')} {node.type}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A block's name, and what can be done to the block.
|
||||
*
|
||||
* Carries no surface of its own and no rule under it. The card behind this is
|
||||
* opaque and unbroken, so a second background here would only draw a seam
|
||||
* across it a hand's width below the top edge — the block would read as a
|
||||
* strip and a box rather than as one card with a name on it.
|
||||
*/
|
||||
const BlockHeader = styled.div`
|
||||
${({ theme }) => css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${theme.sizeUnit}px;
|
||||
height: ${theme.controlHeightSM}px;
|
||||
flex: 0 0 auto;
|
||||
`}
|
||||
`;
|
||||
|
||||
/**
|
||||
* What the remove control (and a type's own extra header control, if it has
|
||||
* one — see `blockHeaderControl`) sit inside together, pushed to the end of
|
||||
* the header as one group.
|
||||
*
|
||||
* Grouped rather than each carrying its own `margin-left: auto`: the two
|
||||
* controls have to land beside each other with nothing but the header's own
|
||||
* gap between them, which a shared wrapper gives for free and two
|
||||
* independently-pushed elements would not (each would land flush against
|
||||
* the header's own right edge, stacking on top of one another instead of
|
||||
* sitting side by side). Pushed to the end whether or not a name is there
|
||||
* to share the row with — an unnamed type (see blockLabel's UNNAMED set)
|
||||
* leaves nothing on the other side to grow and do this instead.
|
||||
*/
|
||||
const HeaderTrailingControls = styled.span`
|
||||
${({ theme }) => css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${theme.sizeUnit}px;
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
`}
|
||||
`;
|
||||
|
||||
/**
|
||||
* What a type's own extra header control (see `blockHeaderControl`) is
|
||||
* wrapped in, and why — the identical reasoning `RemoveSlot`, below, is
|
||||
* wrapped for: the control itself is an `ActionButton` (or built from one)
|
||||
* whose `onClick` carries no event, so the two gestures this sits inside are
|
||||
* stopped here instead — a press on it must act rather than select the
|
||||
* block it is drawn on, and a pointer down on it must not start a
|
||||
* react-grid-layout drag.
|
||||
*
|
||||
* `data-block-header-control` is the other half of that second one —
|
||||
* `RootGrid` names it in `draggableCancel`, and react-grid-layout matches
|
||||
* the selector up the ancestors, so carrying it here covers the control
|
||||
* inside.
|
||||
*/
|
||||
const HeaderControlSlot = styled.span`
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
`;
|
||||
|
||||
/**
|
||||
* What the remove control is wrapped in, and why it is wrapped at all.
|
||||
*
|
||||
* The control itself is `ActionButton` — the shared component for an icon
|
||||
* action carried on a surface that is already something else, and the one the
|
||||
* dashboard list uses for its own Delete. It takes an `onClick` with no event,
|
||||
* so the two gestures this sits inside are stopped here instead: a click on
|
||||
* the bin must remove rather than select the block it is drawn on, and a
|
||||
* pointer down on it must not start a react-grid-layout drag.
|
||||
*
|
||||
* `data-block-remove` is the other half of that second one — `RootGrid`
|
||||
* names it in `draggableCancel`, and react-grid-layout matches the selector up
|
||||
* the ancestors, so carrying it here covers the button inside.
|
||||
*/
|
||||
const RemoveSlot = styled.span`
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
`;
|
||||
|
||||
interface BuildingBlockViewProps extends HTMLAttributes<HTMLDivElement> {
|
||||
nodeId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The single entry point for rendering a dashboard node. A node's `type` is
|
||||
* resolved against `dashboard.buildingBlocks` views — built-in types
|
||||
* (markdown/echarts/...) and extension-contributed ones are registered
|
||||
* identically (see `registerBuiltInBuildingBlocks`), so nothing here knows
|
||||
* or cares which kind it's rendering. Falls back to a placeholder if the
|
||||
* node doesn't exist, or nothing is registered for its `type`.
|
||||
*
|
||||
* The root is the one exception: it is not a Building Block (see the
|
||||
* composition/layout design doc), so there is nothing to look up for it in
|
||||
* that registry — its renderer, `RootGrid`, is resolved directly instead.
|
||||
* `RootGrid` renders its children through `react-grid-layout`, which
|
||||
* positions/sizes each child by cloning it and
|
||||
* injecting `ref`/`style`/drag-and-resize handlers directly onto whatever
|
||||
* element it renders — hence `forwardRef` and spreading `...rest` onto this
|
||||
* component's own root div, rather than each block doing that itself. That's
|
||||
* deliberate: a block, built-in or extension-contributed, should only ever
|
||||
* need to fill 100% of whatever box it's given, not know it's sitting in a
|
||||
* grid at all, let alone that the grid is draggable/resizable. Before this
|
||||
* existed, every block (and every third-party extension) had to resolve its
|
||||
* own placement, which meant reimplementing (and risking drifting from) the
|
||||
* same parent-lookup logic — see `dashboard-insights`'s own
|
||||
* `getParentDirection` for what that duplication looked like from outside
|
||||
* the host bundle, where `DashboardProvider` isn't importable at all.
|
||||
* `children` (when present) is `react-grid-layout`'s own resize-handle
|
||||
* element, appended after this node's content rather than replacing it.
|
||||
*
|
||||
* Wrapped per-node in an ErrorBoundary: a block's content (e.g. an
|
||||
* AI-authored `echartsOptions` that turns out malformed at render/effect
|
||||
* time) is untrusted input the same way a dataset value is, and one bad
|
||||
* block must not unmount the rest of the dashboard along with it.
|
||||
*/
|
||||
const BuildingBlockView = forwardRef<HTMLDivElement, BuildingBlockViewProps>(
|
||||
function BuildingBlockView({ nodeId, children, ...rest }, ref) {
|
||||
useDashboardRevision();
|
||||
const theme = useTheme();
|
||||
const node = provider.getNode(nodeId);
|
||||
if (!node) return null;
|
||||
|
||||
const selected = provider.getSelection() === nodeId;
|
||||
// The root is the dashboard itself rather than something on it: it has no
|
||||
// name of its own to show, and removing it is refused by the provider, so
|
||||
// a header there would be a label saying "Grid" over a button that only
|
||||
// ever raises an error.
|
||||
const chrome = nodeId !== provider.getRoot().id;
|
||||
const isRoot = !chrome;
|
||||
// The root's renderer is not looked up in the building-block registry —
|
||||
// see this component's own doc comment — since the root was never
|
||||
// registered there in the first place.
|
||||
const resolved = isRoot ? (
|
||||
<RootGrid nodeId={nodeId} />
|
||||
) : (
|
||||
resolveBuildingBlockView(node.type, nodeId)
|
||||
);
|
||||
// The same token `BlockHeader` is drawn at: the content box below is this
|
||||
// element's height minus the band, so the two have to be one number.
|
||||
const headerHeight = theme.controlHeightSM;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
{...rest}
|
||||
// Where a node is on screen, for the panels that reach into the
|
||||
// canvas from outside it — the Outline scrolls to the block it just
|
||||
// selected by finding it here. Set after the spread so a parent
|
||||
// renderer cannot displace a node's own identity.
|
||||
data-node-id={nodeId}
|
||||
// Every block is a thing an author selects, so every block is a
|
||||
// control — announced as one, reachable by Tab, and answering the
|
||||
// keys a control answers. The outline offers the same selection in a
|
||||
// tree, but a block you can point at and not reach from the keyboard
|
||||
// is still a block half the people using this cannot select.
|
||||
// A real `button` is not available: react-grid-layout clones this
|
||||
// element to inject its own ref, style and drag handlers, and a
|
||||
// block's content is interactive in its own right — a chart, a
|
||||
// table — which a `button` may not contain.
|
||||
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={selected}
|
||||
aria-label={node.type}
|
||||
// The propagation stop is what makes a click on a block inside a
|
||||
// container select the block rather than the container holding it —
|
||||
// both are nodes and both render through here, so the innermost one
|
||||
// has to claim the gesture.
|
||||
onClick={event => {
|
||||
event.stopPropagation();
|
||||
provider.setSelection(nodeId);
|
||||
}}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
provider.setSelection(nodeId);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
...rest.style,
|
||||
// A block's contents are positioned against this element.
|
||||
// react-grid-layout positions its children itself, so its own value
|
||||
// is kept wherever it set one and `relative` only fills the gap
|
||||
// when it did not.
|
||||
position: rest.style?.position ?? 'relative',
|
||||
// Sized the same way every other block already is — everything
|
||||
// that is not the root gets its width/height from
|
||||
// react-grid-layout, which clones the grid item and injects both
|
||||
// directly (captured above through `...rest.style`). Nothing
|
||||
// clones the root; it is rendered directly, with no props, so
|
||||
// without this it has no width or height at all and shrinks to
|
||||
// whatever its own content happens to be — which is exactly one
|
||||
// block tall, with nothing below it to drop onto and a scrollbar
|
||||
// that flickers in and out as that one block resizes against it.
|
||||
width: isRoot ? '100%' : rest.style?.width,
|
||||
height: isRoot ? '100%' : rest.style?.height,
|
||||
// The card, drawn around the whole of a block rather than around
|
||||
// part of it.
|
||||
//
|
||||
// This used to be each leaf block's own — every one of them opened
|
||||
// with the same background, border and radius — and a leaf begins
|
||||
// below the header, so the card's top edge ran between a block's
|
||||
// name and its contents. The name sat outside the box it names,
|
||||
// reading as a caption dropped over a separate card. Drawn here it
|
||||
// encloses both, which is also the only place it can be drawn from:
|
||||
// whether a node has a header at all is this component's to know,
|
||||
// not the leaf's.
|
||||
//
|
||||
// Opaque for the same reason it is one card: on a free canvas
|
||||
// blocks overlap, and anything a block does not paint is a window
|
||||
// onto whatever is behind it.
|
||||
//
|
||||
// None of this is true of the root. The root is not a block on the
|
||||
// dashboard, it *is* the dashboard — the surface everything else is
|
||||
// arranged on, not a card among them — so it gets none of a card's
|
||||
// trappings: no fill, no border, no rounded corners of its own.
|
||||
backgroundColor: isRoot ? undefined : theme.colorBgContainer,
|
||||
border: isRoot
|
||||
? undefined
|
||||
: `1px solid ${theme.colorBorderSecondary}`,
|
||||
borderRadius: isRoot ? undefined : theme.borderRadiusLG,
|
||||
// Nothing reaches past the corners this rounds — a block's content
|
||||
// is square and would otherwise fill them back in. Moot on the
|
||||
// root, which rounds nothing.
|
||||
overflow: isRoot ? undefined : 'hidden',
|
||||
// One inset for the whole card — the name and the content both
|
||||
// sit inside it, rather than each drawing its own. `border-box`
|
||||
// keeps it inside the pixel box `RootGrid`/`FlowItem` gave this
|
||||
// element (a chart resizes to what's left after this is
|
||||
// subtracted) instead of adding to it. The root gets none: it is
|
||||
// not a card, and RootGrid already fills it exactly.
|
||||
padding: isRoot ? undefined : theme.padding,
|
||||
boxSizing: 'border-box',
|
||||
// Drawn over the block rather than around it: an outline takes no
|
||||
// space, so nothing on screen shifts when a selection moves.
|
||||
//
|
||||
// Never on the root: it can still be selected (see `EditorPanel`'s
|
||||
// own Properties for it), but the root is the canvas itself, not a
|
||||
// block sitting on it, and an outline meant to mark one block out
|
||||
// from its neighbors instead reads as a frame around the entire
|
||||
// dashboard when it is the root wearing it.
|
||||
outline:
|
||||
selected && !isRoot ? `2px solid ${theme.colorPrimary}` : undefined,
|
||||
outlineOffset: selected && !isRoot ? -2 : undefined,
|
||||
}}
|
||||
>
|
||||
{/* What this block is, and how to be rid of it.
|
||||
The name comes from `blockLabel`, the same call the Outline names
|
||||
a row by, so a block is not "Sales by Territory" in one place and
|
||||
"ECharts" in the other. A chart's name is authored in its ECharts
|
||||
option and ChartBlock stops ECharts drawing it, so it appears here
|
||||
once instead of twice.
|
||||
|
||||
`data-block-remove` is what keeps a press on the button from
|
||||
starting a react-grid-layout drag; see RootGrid's
|
||||
`draggableCancel`. The propagation stops are the same idea for the
|
||||
two gestures it sits inside: a click here removes rather than
|
||||
selects, and a pointer down here grabs nothing.
|
||||
|
||||
The button is nested inside a control, which is not ideal and is
|
||||
the price of the wrapper itself being selectable — the alternative
|
||||
was a block you can delete only from the panel. The keyboard path
|
||||
is not this button: the Outline selects any block with proper tree
|
||||
semantics and Properties carries the same Delete. */}
|
||||
{chrome && (
|
||||
<BlockHeader data-test={`block-header-${nodeId}`}>
|
||||
{/* Skipped entirely for a type `blockLabel` leaves unnamed
|
||||
(markdown, whose rendered body is right below this and needs
|
||||
no caption repeating it) — an empty `Typography.Text` would
|
||||
still be a blank strip claiming the header's whole left
|
||||
side, not nothing. */}
|
||||
{blockLabel(node.type, node.props) && (
|
||||
<Typography.Text
|
||||
ellipsis
|
||||
data-test={`block-title-${nodeId}`}
|
||||
style={{
|
||||
flex: '1 1 auto',
|
||||
// The name of the thing below it, not a note about it. At the
|
||||
// small size in the secondary colour it read as a caption
|
||||
// hanging over the block — and this is the first thing anyone
|
||||
// scanning a canvas uses to tell one block from the next, so
|
||||
// it is drawn at the weight that job deserves.
|
||||
fontSize: theme.fontSize,
|
||||
fontWeight: theme.fontWeightStrong,
|
||||
color: theme.colorText,
|
||||
}}
|
||||
>
|
||||
{blockLabel(node.type, node.props)}
|
||||
</Typography.Text>
|
||||
)}
|
||||
<HeaderTrailingControls>
|
||||
{/* A type's own extra header control — e.g. `collapsible`'s
|
||||
expand/collapse toggle — sits beside Remove rather than
|
||||
below the header, so a block with one of these is still
|
||||
just a title and its content, not a title, a second bar,
|
||||
and its content. See `blockHeaderControl`. */}
|
||||
{blockHeaderControl(node.type, nodeId) && (
|
||||
<HeaderControlSlot
|
||||
data-block-header-control
|
||||
onMouseDown={event => event.stopPropagation()}
|
||||
onPointerDown={event => event.stopPropagation()}
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
{blockHeaderControl(node.type, nodeId)}
|
||||
</HeaderControlSlot>
|
||||
)}
|
||||
<RemoveSlot
|
||||
data-block-remove
|
||||
onMouseDown={event => event.stopPropagation()}
|
||||
onPointerDown={event => event.stopPropagation()}
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
<ActionButton
|
||||
label={t('Remove block')}
|
||||
tooltip={t('Remove block')}
|
||||
placement="bottom"
|
||||
dataTest={`block-remove-${nodeId}`}
|
||||
onClick={() => provider.removeBuildingBlock(nodeId)}
|
||||
// A bin rather than a cross. A cross on a card is the gesture
|
||||
// for dismissing the card — closing it, putting it away — and
|
||||
// this does not put the block away, it takes it off the
|
||||
// dashboard. The bin is what the rest of the app uses to say
|
||||
// so, and it is the same act the panel offers as Delete.
|
||||
//
|
||||
// Quiet at rest and primary under the pointer, which is
|
||||
// `ActionButton`'s own behaviour and the same answer the
|
||||
// dashboard list gives for its Delete: a bin on every block,
|
||||
// all of them lit red, would make a canvas read as a row of
|
||||
// things about to be deleted.
|
||||
icon={<Icons.DeleteOutlined iconSize="s" />}
|
||||
/>
|
||||
</RemoveSlot>
|
||||
</HeaderTrailingControls>
|
||||
</BlockHeader>
|
||||
)}
|
||||
{/* The block's own box, which is the whole of this element's minus
|
||||
the band above it. Subtracted in pixels off a percentage rather
|
||||
than left to a flex column, because what a leaf block does with
|
||||
the box is resolve `height: 100%` against it — a chart measures
|
||||
the result to size its canvas — and that wants a height there is
|
||||
no question about. */}
|
||||
<div
|
||||
data-test={`block-content-${nodeId}`}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: chrome ? `calc(100% - ${headerHeight}px)` : '100%',
|
||||
}}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
{resolved ?? <UnsupportedBlockPlaceholder nodeId={nodeId} />}
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default BuildingBlockView;
|
||||
@@ -0,0 +1,450 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import DashboardProvider, { registerContainerType } from './DashboardProvider';
|
||||
|
||||
// A stand-in container type for exercising generic container mechanics
|
||||
// (holding children, being a valid move/collision target) — 'canvas' itself
|
||||
// is no longer addable (it's reserved for the root, see `addBuildingBlock`),
|
||||
// and this suite tests `DashboardProvider` in isolation, without the
|
||||
// registration (`registerBuiltInBuildingBlocks`) that gives 'tabs'/'tab'
|
||||
// their own container status.
|
||||
const TEST_CONTAINER_TYPE = 'container';
|
||||
|
||||
beforeAll(() => {
|
||||
registerContainerType(TEST_CONTAINER_TYPE);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
DashboardProvider.getInstance().reset();
|
||||
});
|
||||
|
||||
test('returns the singleton instance', () => {
|
||||
expect(DashboardProvider.getInstance()).toBe(DashboardProvider.getInstance());
|
||||
});
|
||||
|
||||
test('starts with a blank root grid', () => {
|
||||
const provider = DashboardProvider.getInstance();
|
||||
|
||||
expect(provider.getRoot()).toEqual({
|
||||
id: 'root',
|
||||
type: 'grid',
|
||||
layout: { columns: 24, gap: 16 },
|
||||
children: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('getNode returns undefined for an unknown id', () => {
|
||||
expect(DashboardProvider.getInstance().getNode('missing')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('addBuildingBlock inserts a node into the parent at the given index', () => {
|
||||
const provider = DashboardProvider.getInstance();
|
||||
const rootId = provider.getRoot().id;
|
||||
|
||||
const firstId = provider.addBuildingBlock(rootId, 0, { type: 'text' });
|
||||
const secondId = provider.addBuildingBlock(rootId, 0, { type: 'text' });
|
||||
|
||||
expect(provider.getRoot().children).toEqual([secondId, firstId]);
|
||||
});
|
||||
|
||||
test('addBuildingBlock clamps an out-of-range index', () => {
|
||||
const provider = DashboardProvider.getInstance();
|
||||
const rootId = provider.getRoot().id;
|
||||
|
||||
const id = provider.addBuildingBlock(rootId, 99, { type: 'text' });
|
||||
|
||||
expect(provider.getRoot().children).toEqual([id]);
|
||||
});
|
||||
|
||||
test('addBuildingBlock gives container nodes an empty children array', () => {
|
||||
const provider = DashboardProvider.getInstance();
|
||||
const rootId = provider.getRoot().id;
|
||||
|
||||
const id = provider.addBuildingBlock(rootId, 0, {
|
||||
type: TEST_CONTAINER_TYPE,
|
||||
});
|
||||
|
||||
expect(provider.getNode(id)?.children).toEqual([]);
|
||||
});
|
||||
|
||||
test('addBuildingBlock throws for an unknown parent', () => {
|
||||
const provider = DashboardProvider.getInstance();
|
||||
|
||||
expect(() =>
|
||||
provider.addBuildingBlock('missing', 0, { type: 'text' }),
|
||||
).toThrow(/Unknown parent node/);
|
||||
});
|
||||
|
||||
test('addBuildingBlock throws when the parent cannot hold children', () => {
|
||||
const provider = DashboardProvider.getInstance();
|
||||
const rootId = provider.getRoot().id;
|
||||
const leafId = provider.addBuildingBlock(rootId, 0, { type: 'text' });
|
||||
|
||||
expect(() => provider.addBuildingBlock(leafId, 0, { type: 'text' })).toThrow(
|
||||
/not a container/,
|
||||
);
|
||||
});
|
||||
|
||||
test('removeBuildingBlock detaches the node from its parent', () => {
|
||||
const provider = DashboardProvider.getInstance();
|
||||
const rootId = provider.getRoot().id;
|
||||
const id = provider.addBuildingBlock(rootId, 0, { type: 'text' });
|
||||
|
||||
provider.removeBuildingBlock(id);
|
||||
|
||||
expect(provider.getNode(id)).toBeUndefined();
|
||||
expect(provider.getRoot().children).toEqual([]);
|
||||
});
|
||||
|
||||
test('removeBuildingBlock removes an entire container subtree', () => {
|
||||
const provider = DashboardProvider.getInstance();
|
||||
const rootId = provider.getRoot().id;
|
||||
const containerId = provider.addBuildingBlock(rootId, 0, {
|
||||
type: TEST_CONTAINER_TYPE,
|
||||
});
|
||||
const childId = provider.addBuildingBlock(containerId, 0, { type: 'text' });
|
||||
|
||||
provider.removeBuildingBlock(containerId);
|
||||
|
||||
expect(provider.getNode(containerId)).toBeUndefined();
|
||||
expect(provider.getNode(childId)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('removeBuildingBlock throws for the root node', () => {
|
||||
const provider = DashboardProvider.getInstance();
|
||||
const rootId = provider.getRoot().id;
|
||||
|
||||
expect(() => provider.removeBuildingBlock(rootId)).toThrow(
|
||||
/Cannot remove the root node/,
|
||||
);
|
||||
});
|
||||
|
||||
test('removeBuildingBlock is a no-op for an unknown id', () => {
|
||||
const provider = DashboardProvider.getInstance();
|
||||
const revisionBefore = provider.getRevision();
|
||||
|
||||
provider.removeBuildingBlock('missing');
|
||||
|
||||
expect(provider.getRevision()).toBe(revisionBefore);
|
||||
});
|
||||
|
||||
test('moveBuildingBlock relocates a node to a new parent at the given index', () => {
|
||||
const provider = DashboardProvider.getInstance();
|
||||
const rootId = provider.getRoot().id;
|
||||
const containerId = provider.addBuildingBlock(rootId, 0, {
|
||||
type: TEST_CONTAINER_TYPE,
|
||||
});
|
||||
const id = provider.addBuildingBlock(rootId, 1, { type: 'text' });
|
||||
|
||||
provider.moveBuildingBlock(id, containerId, 0);
|
||||
|
||||
expect(provider.getRoot().children).toEqual([containerId]);
|
||||
expect(provider.getNode(containerId)?.children).toEqual([id]);
|
||||
});
|
||||
|
||||
test('moveBuildingBlock keeps an explicit position when the parent is unchanged', () => {
|
||||
const provider = DashboardProvider.getInstance();
|
||||
const rootId = provider.getRoot().id;
|
||||
const first = provider.addBuildingBlock(rootId, 0, { type: 'text' });
|
||||
const second = provider.addBuildingBlock(rootId, 1, { type: 'text' });
|
||||
provider.updateLayout(first, { col: 3, row: 2, colSpan: 6 });
|
||||
|
||||
// A move within one parent reorders reading/DOM/tab order alone — the
|
||||
// node's own placement is not part of what changed.
|
||||
provider.moveBuildingBlock(first, rootId, 1);
|
||||
|
||||
expect(provider.getRoot().children).toEqual([second, first]);
|
||||
expect(provider.getNode(first)?.layout).toMatchObject({
|
||||
col: 3,
|
||||
row: 2,
|
||||
colSpan: 6,
|
||||
});
|
||||
});
|
||||
|
||||
test('getParentId returns the container holding a node', () => {
|
||||
const provider = DashboardProvider.getInstance();
|
||||
const rootId = provider.getRoot().id;
|
||||
const containerId = provider.addBuildingBlock(rootId, 0, {
|
||||
type: TEST_CONTAINER_TYPE,
|
||||
});
|
||||
const childId = provider.addBuildingBlock(containerId, 0, { type: 'text' });
|
||||
|
||||
expect(provider.getParentId(childId)).toBe(containerId);
|
||||
expect(provider.getParentId(containerId)).toBe(rootId);
|
||||
});
|
||||
|
||||
test('getParentId returns undefined for the root and for an unknown id', () => {
|
||||
const provider = DashboardProvider.getInstance();
|
||||
|
||||
expect(provider.getParentId(provider.getRoot().id)).toBeUndefined();
|
||||
expect(provider.getParentId('missing')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('moveBuildingBlock throws when moving the root node', () => {
|
||||
const provider = DashboardProvider.getInstance();
|
||||
const rootId = provider.getRoot().id;
|
||||
const containerId = provider.addBuildingBlock(rootId, 0, {
|
||||
type: TEST_CONTAINER_TYPE,
|
||||
});
|
||||
|
||||
expect(() => provider.moveBuildingBlock(rootId, containerId, 0)).toThrow(
|
||||
/Cannot move the root node/,
|
||||
);
|
||||
});
|
||||
|
||||
test('moveBuildingBlock throws when the target cannot hold children', () => {
|
||||
const provider = DashboardProvider.getInstance();
|
||||
const rootId = provider.getRoot().id;
|
||||
const leafId = provider.addBuildingBlock(rootId, 0, { type: 'text' });
|
||||
const otherId = provider.addBuildingBlock(rootId, 1, { type: 'text' });
|
||||
|
||||
expect(() => provider.moveBuildingBlock(otherId, leafId, 0)).toThrow(
|
||||
/not a container/,
|
||||
);
|
||||
});
|
||||
|
||||
test('moveBuildingBlock throws when moving a node into its own subtree', () => {
|
||||
const provider = DashboardProvider.getInstance();
|
||||
const rootId = provider.getRoot().id;
|
||||
const containerId = provider.addBuildingBlock(rootId, 0, {
|
||||
type: TEST_CONTAINER_TYPE,
|
||||
});
|
||||
const childContainerId = provider.addBuildingBlock(containerId, 0, {
|
||||
type: TEST_CONTAINER_TYPE,
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
provider.moveBuildingBlock(containerId, childContainerId, 0),
|
||||
).toThrow(/into itself or one of its own descendants/);
|
||||
});
|
||||
|
||||
test("updateLayout merges into the node's existing layout", () => {
|
||||
const provider = DashboardProvider.getInstance();
|
||||
const rootId = provider.getRoot().id;
|
||||
const id = provider.addBuildingBlock(rootId, 0, {
|
||||
type: 'text',
|
||||
layout: { colSpan: 12 },
|
||||
});
|
||||
|
||||
provider.updateLayout(id, { rowSpan: 2 });
|
||||
|
||||
expect(provider.getNode(id)?.layout).toEqual({
|
||||
colSpan: 12,
|
||||
rowSpan: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test('updateLayout throws for an unknown node', () => {
|
||||
const provider = DashboardProvider.getInstance();
|
||||
|
||||
expect(() => provider.updateLayout('missing', { colSpan: 12 })).toThrow(
|
||||
/Unknown node/,
|
||||
);
|
||||
});
|
||||
|
||||
test('updateLayout displaces an explicitly placed sibling it now collides with', () => {
|
||||
const provider = DashboardProvider.getInstance();
|
||||
const rootId = provider.getRoot().id;
|
||||
const firstId = provider.addBuildingBlock(rootId, 0, {
|
||||
type: 'text',
|
||||
layout: { col: 1, row: 1, colSpan: 24 },
|
||||
});
|
||||
const secondId = provider.addBuildingBlock(rootId, 1, {
|
||||
type: 'text',
|
||||
layout: { col: 1, row: 2, colSpan: 24 },
|
||||
});
|
||||
|
||||
// Growing `first` down into row 2 now overlaps `second`, which is also
|
||||
// explicitly placed — this mirrors what an AI tool call (not a mouse
|
||||
// drag) can do, since it goes through this method directly rather than
|
||||
// through RootGrid/react-grid-layout's own collision handling.
|
||||
provider.updateLayout(firstId, { rowSpan: 2 });
|
||||
|
||||
expect(provider.getNode(firstId)?.layout).toEqual({
|
||||
col: 1,
|
||||
row: 1,
|
||||
colSpan: 24,
|
||||
rowSpan: 2,
|
||||
});
|
||||
expect(provider.getNode(secondId)?.layout).toEqual({
|
||||
col: 1,
|
||||
row: 3,
|
||||
colSpan: 24,
|
||||
});
|
||||
});
|
||||
|
||||
test('addBuildingBlock displaces the new node when it collides with an earlier explicit sibling', () => {
|
||||
const provider = DashboardProvider.getInstance();
|
||||
const rootId = provider.getRoot().id;
|
||||
const existingId = provider.addBuildingBlock(rootId, 0, {
|
||||
type: 'text',
|
||||
layout: { col: 1, row: 1, colSpan: 24 },
|
||||
});
|
||||
|
||||
// Inserted after `existingId` in children order, so — same rule
|
||||
// `resolveExplicitCollisions` uses (earlier in `children` order keeps its
|
||||
// declared position) — it's the new node that gets pushed down, not the
|
||||
// one already there.
|
||||
const newId = provider.addBuildingBlock(rootId, 1, {
|
||||
type: 'text',
|
||||
layout: { col: 1, row: 1, colSpan: 24 },
|
||||
});
|
||||
|
||||
expect(provider.getNode(existingId)?.layout).toEqual({
|
||||
col: 1,
|
||||
row: 1,
|
||||
colSpan: 24,
|
||||
});
|
||||
expect(provider.getNode(newId)?.layout).toEqual({
|
||||
col: 1,
|
||||
row: 2,
|
||||
colSpan: 24,
|
||||
});
|
||||
});
|
||||
|
||||
test('updateLayout does not resolve collisions for a node with no parent (the root)', () => {
|
||||
const provider = DashboardProvider.getInstance();
|
||||
const rootId = provider.getRoot().id;
|
||||
|
||||
expect(() => provider.updateLayout(rootId, { gap: 24 })).not.toThrow();
|
||||
expect(provider.getRoot().layout).toEqual({ columns: 24, gap: 24 });
|
||||
});
|
||||
|
||||
test('updateLayouts merges a layout update into each node in a single commit', () => {
|
||||
const provider = DashboardProvider.getInstance();
|
||||
const rootId = provider.getRoot().id;
|
||||
const firstId = provider.addBuildingBlock(rootId, 0, {
|
||||
type: 'text',
|
||||
layout: { colSpan: 6 },
|
||||
});
|
||||
const secondId = provider.addBuildingBlock(rootId, 1, {
|
||||
type: 'text',
|
||||
layout: { colSpan: 6 },
|
||||
});
|
||||
const revisionBefore = provider.getRevision();
|
||||
|
||||
provider.updateLayouts({
|
||||
[firstId]: { col: 1, row: 1 },
|
||||
[secondId]: { col: 7, row: 1 },
|
||||
});
|
||||
|
||||
expect(provider.getNode(firstId)?.layout).toEqual({
|
||||
colSpan: 6,
|
||||
col: 1,
|
||||
row: 1,
|
||||
});
|
||||
expect(provider.getNode(secondId)?.layout).toEqual({
|
||||
colSpan: 6,
|
||||
col: 7,
|
||||
row: 1,
|
||||
});
|
||||
expect(provider.getRevision()).toBe(revisionBefore + 1);
|
||||
});
|
||||
|
||||
test('updateLayouts silently skips an unknown node id', () => {
|
||||
const provider = DashboardProvider.getInstance();
|
||||
const rootId = provider.getRoot().id;
|
||||
const id = provider.addBuildingBlock(rootId, 0, { type: 'text' });
|
||||
|
||||
expect(() =>
|
||||
provider.updateLayouts({ missing: { col: 1 }, [id]: { col: 2 } }),
|
||||
).not.toThrow();
|
||||
expect(provider.getNode(id)?.layout).toEqual({ col: 2 });
|
||||
});
|
||||
|
||||
test('onDidLayoutChange fires on every mutation', () => {
|
||||
const provider = DashboardProvider.getInstance();
|
||||
const rootId = provider.getRoot().id;
|
||||
const listener = jest.fn();
|
||||
const disposable = provider.onDidLayoutChange(listener);
|
||||
|
||||
provider.addBuildingBlock(rootId, 0, { type: 'text' });
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
disposable.dispose();
|
||||
});
|
||||
|
||||
test('getRevision increments on every mutation and is stable otherwise', () => {
|
||||
const provider = DashboardProvider.getInstance();
|
||||
const rootId = provider.getRoot().id;
|
||||
const before = provider.getRevision();
|
||||
|
||||
expect(provider.getRevision()).toBe(before);
|
||||
|
||||
provider.addBuildingBlock(rootId, 0, { type: 'text' });
|
||||
|
||||
expect(provider.getRevision()).toBe(before + 1);
|
||||
});
|
||||
|
||||
/**
|
||||
* Selection is host-internal state, like the revision counter: a property of
|
||||
* one person looking at one screen, not of the dashboard.
|
||||
*/
|
||||
test('selecting a node reports it back', () => {
|
||||
const provider = DashboardProvider.getInstance();
|
||||
const id = provider.addBuildingBlock(provider.getRoot().id, 0, {
|
||||
type: 'markdown',
|
||||
});
|
||||
|
||||
provider.setSelection(id);
|
||||
|
||||
expect(provider.getSelection()).toBe(id);
|
||||
});
|
||||
|
||||
test('removing the selected node clears the selection', () => {
|
||||
const provider = DashboardProvider.getInstance();
|
||||
const id = provider.addBuildingBlock(provider.getRoot().id, 0, {
|
||||
type: 'markdown',
|
||||
});
|
||||
provider.setSelection(id);
|
||||
|
||||
provider.removeBuildingBlock(id);
|
||||
|
||||
// A selection is a reference to a node, and a node that is gone cannot be
|
||||
// the thing being edited — an inspector reading a dangling id would show a
|
||||
// block that no longer exists.
|
||||
expect(provider.getSelection()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('removing a container clears a selection inside its subtree', () => {
|
||||
const provider = DashboardProvider.getInstance();
|
||||
const sectionId = provider.addBuildingBlock(provider.getRoot().id, 0, {
|
||||
type: TEST_CONTAINER_TYPE,
|
||||
});
|
||||
const childId = provider.addBuildingBlock(sectionId, 0, { type: 'markdown' });
|
||||
provider.setSelection(childId);
|
||||
|
||||
provider.removeBuildingBlock(sectionId);
|
||||
|
||||
// The node that vanished was a descendant of the one actually removed,
|
||||
// which is why the check belongs in the commit rather than at the removal.
|
||||
expect(provider.getSelection()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('reset clears the selection along with the tree', () => {
|
||||
const provider = DashboardProvider.getInstance();
|
||||
const id = provider.addBuildingBlock(provider.getRoot().id, 0, {
|
||||
type: 'markdown',
|
||||
});
|
||||
provider.setSelection(id);
|
||||
|
||||
provider.reset();
|
||||
|
||||
expect(provider.getSelection()).toBeUndefined();
|
||||
});
|
||||
@@ -0,0 +1,458 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import type { dashboard as dashboardApi } from '@apache-superset/core';
|
||||
import { createEventEmitter } from '../utils';
|
||||
import { DEFAULT_COLUMNS } from './layoutStyle';
|
||||
import { resolveExplicitCollisions } from './gridPacking';
|
||||
|
||||
type DashboardNode = dashboardApi.DashboardNode;
|
||||
type BuildingBlockSpec = dashboardApi.BuildingBlockSpec;
|
||||
type LayoutProps = dashboardApi.LayoutProps;
|
||||
|
||||
/** Node data as stored internally — same as the public `DashboardNode`, minus `id` (the map key already is the id). */
|
||||
type StoredNode = Omit<DashboardNode, 'id'>;
|
||||
|
||||
const ROOT_ID = 'root';
|
||||
|
||||
/**
|
||||
* The root's own type — the one container every dashboard always has,
|
||||
* whether or not anything has registered itself as one. Named here because
|
||||
* two things need to agree on it and neither should learn it by string
|
||||
* comparison of its own: this provider, deciding whether a new node gets a
|
||||
* `children` array at all, and the palette, deciding that placing this
|
||||
* specific type is not an authored feature (it's reserved for the root).
|
||||
* Rendered by `Grid` — see `BuildingBlockView`, which resolves the root's
|
||||
* renderer to it directly rather than through the building-block registry.
|
||||
*/
|
||||
export const GRID_TYPE = 'grid';
|
||||
|
||||
/**
|
||||
* Container types beyond the root's own — each registered by whatever adds
|
||||
* that building block (see `registerBuiltInBuildingBlocks`), the same way a
|
||||
* type registers its renderer. A container's own arrangement of its
|
||||
* children is that type's business, not this provider's (see the
|
||||
* composition/layout design doc) — this set only ever answers the one
|
||||
* question the provider itself needs: whether a freshly added node of this
|
||||
* type gets a `children` array to hold them in.
|
||||
*/
|
||||
const registeredContainerTypes = new Set<string>();
|
||||
|
||||
/** Marks `type` as a container, alongside the root's own `grid`. */
|
||||
export function registerContainerType(type: string): void {
|
||||
registeredContainerTypes.add(type);
|
||||
}
|
||||
|
||||
/** Whether placing this type produces something other nodes can go inside. */
|
||||
export const isContainerType = (type: string): boolean =>
|
||||
type === GRID_TYPE || registeredContainerTypes.has(type);
|
||||
|
||||
function createBlankNodes(): Record<string, StoredNode> {
|
||||
return {
|
||||
[ROOT_ID]: {
|
||||
type: GRID_TYPE,
|
||||
// No total height is set here, and none is needed — the root grid's
|
||||
// rows are created on demand (see resolveContainerGridStyle), so the
|
||||
// grid is always exactly as tall as its content.
|
||||
layout: { columns: DEFAULT_COLUMNS, gap: 16 },
|
||||
children: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let nextNodeId = 0;
|
||||
function generateNodeId(): string {
|
||||
nextNodeId += 1;
|
||||
return `node_${nextNodeId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton in-memory store for the active Dashboard v2 prototype's node
|
||||
* tree. No persistence — deliberately granular (mirroring `sqlLab`'s own
|
||||
* accessor style) rather than exposing a single "get everything" snapshot:
|
||||
* callers walk the tree from {@link getRoot} via {@link getNode}.
|
||||
*
|
||||
* `getRevision()` is host-internal only (not part of the public API) — a
|
||||
* cheap invalidation counter the prototype's own canvas renderer subscribes
|
||||
* to via `useSyncExternalStore`, re-reading whatever nodes it needs through
|
||||
* the same granular accessors extensions use.
|
||||
*/
|
||||
class DashboardProvider {
|
||||
private static instance: DashboardProvider;
|
||||
|
||||
private nodes: Record<string, StoredNode> = createBlankNodes();
|
||||
|
||||
private revision = 0;
|
||||
|
||||
/**
|
||||
* Which node the author is working on.
|
||||
*
|
||||
* Host-internal, exactly like {@link getRevision} and for the same reason:
|
||||
* it is a property of one person looking at one screen, not of the
|
||||
* dashboard. Two people opening the same tree select different things, and
|
||||
* nothing about a selection belongs in a document or in the public API an
|
||||
* extension calls.
|
||||
*
|
||||
* It lives here rather than in page state because the canvas draws it and
|
||||
* the editor panel reads it, and those sit in different layers — putting it
|
||||
* in the one place both already subscribe to beats threading it through the
|
||||
* render tree that `BuildingBlockView` deliberately keeps ignorant.
|
||||
*/
|
||||
private selection: string | undefined;
|
||||
|
||||
private layoutChangeEmitter = createEventEmitter<void>();
|
||||
|
||||
private stateSubscribers = new Set<() => void>();
|
||||
|
||||
public static getInstance(): DashboardProvider {
|
||||
if (!DashboardProvider.instance) {
|
||||
DashboardProvider.instance = new DashboardProvider();
|
||||
}
|
||||
return DashboardProvider.instance;
|
||||
}
|
||||
|
||||
public subscribe = (listener: () => void): (() => void) => {
|
||||
this.stateSubscribers.add(listener);
|
||||
return () => this.stateSubscribers.delete(listener);
|
||||
};
|
||||
|
||||
public getRevision = (): number => this.revision;
|
||||
|
||||
public getSelection = (): string | undefined => this.selection;
|
||||
|
||||
/**
|
||||
* Selects a node, or clears the selection with `undefined`.
|
||||
*
|
||||
* Ticks the same revision every mutation does, so everything already
|
||||
* subscribed re-reads without needing a second subscription of its own.
|
||||
*/
|
||||
public setSelection = (id: string | undefined): void => {
|
||||
if (this.selection === id) {
|
||||
return;
|
||||
}
|
||||
this.selection = id;
|
||||
this.revision += 1;
|
||||
this.stateSubscribers.forEach(fn => fn());
|
||||
};
|
||||
|
||||
private commit(nodes: Record<string, StoredNode>): void {
|
||||
// A selection is a reference to a node, and a node that is gone cannot be
|
||||
// the thing being edited. Clearing it here — rather than at each removal
|
||||
// site — covers a subtree deletion too, where the node that vanished was
|
||||
// a descendant of the one actually removed.
|
||||
if (this.selection !== undefined && !nodes[this.selection]) {
|
||||
this.selection = undefined;
|
||||
}
|
||||
this.nodes = nodes;
|
||||
this.revision += 1;
|
||||
this.layoutChangeEmitter.fire();
|
||||
this.stateSubscribers.forEach(fn => fn());
|
||||
}
|
||||
|
||||
private toNode(id: string): DashboardNode | undefined {
|
||||
const data = this.nodes[id];
|
||||
return data ? { id, ...data } : undefined;
|
||||
}
|
||||
|
||||
public getRoot = (): DashboardNode => this.toNode(ROOT_ID)!;
|
||||
|
||||
public getNode = (id: string): DashboardNode | undefined => this.toNode(id);
|
||||
|
||||
/**
|
||||
* The container a node sits in, or `undefined` for the root and for a
|
||||
* node that is not in the tree.
|
||||
*
|
||||
* {@link moveBuildingBlock} takes the destination parent as an argument, so
|
||||
* every caller that moves a node already has to know which parent it is in
|
||||
* — a caller reordering a node within its own container most of all. The
|
||||
* walk itself is one line, and leaving it out meant each caller wrote that
|
||||
* line again over a `nodes` map only this class is supposed to hold.
|
||||
*/
|
||||
public getParentId = (id: string): string | undefined =>
|
||||
this.findParentId(id, this.nodes);
|
||||
|
||||
/** True if `targetId` is `nodeId` itself or nested somewhere in its subtree. */
|
||||
private isNodeOrDescendant(nodeId: string, targetId: string): boolean {
|
||||
if (nodeId === targetId) return true;
|
||||
return (
|
||||
this.nodes[nodeId]?.children?.some(childId =>
|
||||
this.isNodeOrDescendant(childId, targetId),
|
||||
) ?? false
|
||||
);
|
||||
}
|
||||
|
||||
private findParentId(
|
||||
id: string,
|
||||
nodes: Record<string, StoredNode>,
|
||||
): string | undefined {
|
||||
return Object.entries(nodes).find(([, node]) =>
|
||||
node.children?.includes(id),
|
||||
)?.[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Displaces any of `parentId`'s explicitly placed children that now
|
||||
* collide with one another (see {@link resolveExplicitCollisions}) and
|
||||
* folds the result into `nodes`. `addBuildingBlock`/`updateLayout` are the
|
||||
* two ways an extension's AI tools place a node without going through
|
||||
* `RootGrid`'s interactive drag/resize at all — this gives that
|
||||
* programmatic path the same "nothing ends up stuck overlapping"
|
||||
* guarantee a mouse-driven resize gets for free from `react-grid-layout`,
|
||||
* rather than leaving it to whatever the renderer happens to paper over
|
||||
* on screen without ever writing the correction back to the store.
|
||||
*/
|
||||
private resolveParentCollisions(
|
||||
parentId: string,
|
||||
nodes: Record<string, StoredNode>,
|
||||
): Record<string, StoredNode> {
|
||||
const parent = nodes[parentId];
|
||||
if (!parent?.children) return nodes;
|
||||
|
||||
const columns = parent.layout?.columns ?? DEFAULT_COLUMNS;
|
||||
const getNode = (nodeId: string): DashboardNode | undefined => {
|
||||
const data = nodes[nodeId];
|
||||
return data ? { id: nodeId, ...data } : undefined;
|
||||
};
|
||||
const adjustments = resolveExplicitCollisions(
|
||||
parent.children,
|
||||
columns,
|
||||
getNode,
|
||||
);
|
||||
if (Object.keys(adjustments).length === 0) return nodes;
|
||||
|
||||
const result = { ...nodes };
|
||||
Object.entries(adjustments).forEach(([id, layout]) => {
|
||||
const node = result[id];
|
||||
if (node) result[id] = { ...node, layout: { ...node.layout, ...layout } };
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
public addBuildingBlock(
|
||||
parentId: string,
|
||||
index: number,
|
||||
spec: BuildingBlockSpec,
|
||||
): string {
|
||||
if (spec.type === GRID_TYPE) {
|
||||
throw new Error(
|
||||
`[dashboard] Cannot add a "${GRID_TYPE}" node — it is reserved for the dashboard root, not a Building Block`,
|
||||
);
|
||||
}
|
||||
|
||||
const parent = this.nodes[parentId];
|
||||
if (!parent) {
|
||||
throw new Error(`[dashboard] Unknown parent node "${parentId}"`);
|
||||
}
|
||||
if (!parent.children) {
|
||||
throw new Error(
|
||||
`[dashboard] Node "${parentId}" cannot hold children (not a container)`,
|
||||
);
|
||||
}
|
||||
|
||||
const id = generateNodeId();
|
||||
const node: StoredNode = {
|
||||
type: spec.type,
|
||||
layout: spec.layout,
|
||||
props: spec.props,
|
||||
style: spec.style,
|
||||
...(isContainerType(spec.type) ? { children: [] } : {}),
|
||||
};
|
||||
|
||||
const children = [...parent.children];
|
||||
const clampedIndex = Math.max(0, Math.min(index, children.length));
|
||||
children.splice(clampedIndex, 0, id);
|
||||
|
||||
const nodes = {
|
||||
...this.nodes,
|
||||
[parentId]: { ...parent, children },
|
||||
[id]: node,
|
||||
};
|
||||
|
||||
this.commit(this.resolveParentCollisions(parentId, nodes));
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
public removeBuildingBlock(id: string): void {
|
||||
if (id === ROOT_ID) {
|
||||
throw new Error('[dashboard] Cannot remove the root node');
|
||||
}
|
||||
if (!this.nodes[id]) return;
|
||||
|
||||
const nodes = { ...this.nodes };
|
||||
const removeSubtree = (nodeId: string) => {
|
||||
nodes[nodeId]?.children?.forEach(removeSubtree);
|
||||
delete nodes[nodeId];
|
||||
};
|
||||
removeSubtree(id);
|
||||
|
||||
Object.entries(nodes).forEach(([parentId, parent]) => {
|
||||
if (parent.children?.includes(id)) {
|
||||
nodes[parentId] = {
|
||||
...parent,
|
||||
children: parent.children.filter(childId => childId !== id),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
this.commit(nodes);
|
||||
}
|
||||
|
||||
public moveBuildingBlock(
|
||||
id: string,
|
||||
newParentId: string,
|
||||
newIndex: number,
|
||||
): void {
|
||||
if (id === ROOT_ID) {
|
||||
throw new Error('[dashboard] Cannot move the root node');
|
||||
}
|
||||
if (!this.nodes[id]) {
|
||||
throw new Error(`[dashboard] Unknown node "${id}"`);
|
||||
}
|
||||
if (!this.nodes[newParentId]?.children) {
|
||||
throw new Error(
|
||||
`[dashboard] Node "${newParentId}" cannot hold children (not a container)`,
|
||||
);
|
||||
}
|
||||
if (this.isNodeOrDescendant(id, newParentId)) {
|
||||
throw new Error(
|
||||
`[dashboard] Cannot move node "${id}" into itself or one of its own descendants`,
|
||||
);
|
||||
}
|
||||
|
||||
const oldParentId = this.findParentId(id, this.nodes);
|
||||
|
||||
const nodes = { ...this.nodes };
|
||||
Object.entries(nodes).forEach(([parentId, parent]) => {
|
||||
if (parent.children?.includes(id)) {
|
||||
nodes[parentId] = {
|
||||
...parent,
|
||||
children: parent.children.filter(childId => childId !== id),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const targetParent = nodes[newParentId];
|
||||
const children = [...(targetParent.children ?? [])];
|
||||
const clampedIndex = Math.max(0, Math.min(newIndex, children.length));
|
||||
children.splice(clampedIndex, 0, id);
|
||||
nodes[newParentId] = { ...targetParent, children };
|
||||
|
||||
// An explicit col/row (or a colSpan wider than the new parent's own
|
||||
// column count) was only ever meaningful in the *old* parent's grid —
|
||||
// carrying it over verbatim into the new one is how a moved node ends
|
||||
// up silently overlapping or overflowing its new siblings. Interactive
|
||||
// drag-based reparenting (see `RootGrid`'s `handleDragStop`) already
|
||||
// resets exactly these two things on drop; this is that same reset,
|
||||
// applied here so the programmatic path gives the same guarantee.
|
||||
//
|
||||
// None of which is true when the parent has not changed. A move within
|
||||
// one container is a reorder of reading/DOM/tab order alone, and the
|
||||
// position it keeps is the one the author placed it at. Resetting it
|
||||
// would teleport the block to auto-placement as the price of a reorder.
|
||||
if (oldParentId !== newParentId) {
|
||||
const node = nodes[id];
|
||||
const destColumns = targetParent.layout?.columns ?? DEFAULT_COLUMNS;
|
||||
nodes[id] = {
|
||||
...node,
|
||||
layout: {
|
||||
...node.layout,
|
||||
col: undefined,
|
||||
row: undefined,
|
||||
colSpan:
|
||||
node.layout?.colSpan != null
|
||||
? Math.min(node.layout.colSpan, destColumns)
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
this.commit(nodes);
|
||||
}
|
||||
|
||||
public updateLayout(id: string, layout: Partial<LayoutProps>): void {
|
||||
const node = this.nodes[id];
|
||||
if (!node) {
|
||||
throw new Error(`[dashboard] Unknown node "${id}"`);
|
||||
}
|
||||
|
||||
const nodes = {
|
||||
...this.nodes,
|
||||
[id]: { ...node, layout: { ...node.layout, ...layout } },
|
||||
};
|
||||
|
||||
const parentId = this.findParentId(id, nodes);
|
||||
this.commit(
|
||||
parentId ? this.resolveParentCollisions(parentId, nodes) : nodes,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges a `layout` update into each of several nodes at once, in a single
|
||||
* commit. A drag or resize that displaces siblings (see `RootGrid`)
|
||||
* resolves *all* of their new positions together — committing them one
|
||||
* {@link updateLayout} call at a time would tick the revision counter, and
|
||||
* so re-render every subscriber, once per displaced sibling instead of
|
||||
* once for the whole gesture.
|
||||
*/
|
||||
public updateLayouts(updates: Record<string, Partial<LayoutProps>>): void {
|
||||
const nodes = { ...this.nodes };
|
||||
Object.entries(updates).forEach(([id, layout]) => {
|
||||
const node = nodes[id];
|
||||
if (!node) return;
|
||||
nodes[id] = { ...node, layout: { ...node.layout, ...layout } };
|
||||
});
|
||||
|
||||
this.commit(nodes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shallow-merges `props` into a node's existing props — the content-side
|
||||
* counterpart to {@link updateLayout}. Lets a chart's `echartsOptions`
|
||||
* (or a markdown block's `content`) be edited in place, instead of the
|
||||
* only alternative being remove + re-add, which loses the node's
|
||||
* position, layout, and identity just to change what it renders.
|
||||
*/
|
||||
public updateProps(id: string, props: Record<string, unknown>): void {
|
||||
const node = this.nodes[id];
|
||||
if (!node) {
|
||||
throw new Error(`[dashboard] Unknown node "${id}"`);
|
||||
}
|
||||
|
||||
this.commit({
|
||||
...this.nodes,
|
||||
[id]: { ...node, props: { ...node.props, ...props } },
|
||||
});
|
||||
}
|
||||
|
||||
public get onDidLayoutChange() {
|
||||
return this.layoutChangeEmitter.subscribe;
|
||||
}
|
||||
|
||||
/** Test/demo helper — discards all nodes back to a blank canvas. */
|
||||
public reset(): void {
|
||||
this.nodes = createBlankNodes();
|
||||
this.selection = undefined;
|
||||
this.revision = 0;
|
||||
this.layoutChangeEmitter = createEventEmitter<void>();
|
||||
this.stateSubscribers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export default DashboardProvider;
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { fireEvent, render, screen } from 'spec/helpers/testing-library';
|
||||
import DashboardProvider from './DashboardProvider';
|
||||
import RootGrid from './RootGrid';
|
||||
|
||||
/**
|
||||
* What a gesture on the grid commits.
|
||||
*
|
||||
* `react-grid-layout` is mocked down to the props RootGrid feeds it. Everything
|
||||
* about how it draws is its own business and covered by its own tests; what
|
||||
* matters here is that RootGrid always compacts and never allows overlap.
|
||||
*/
|
||||
jest.mock('react-grid-layout/legacy', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
children,
|
||||
compactType,
|
||||
allowOverlap,
|
||||
draggableCancel,
|
||||
resizeHandles,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
compactType: string | null;
|
||||
allowOverlap?: boolean;
|
||||
draggableCancel?: string;
|
||||
resizeHandles?: string[];
|
||||
}) => (
|
||||
<div
|
||||
data-test="rgl"
|
||||
data-compact-type={String(compactType)}
|
||||
data-allow-overlap={String(!!allowOverlap)}
|
||||
data-draggable-cancel={draggableCancel ?? ''}
|
||||
data-resize-handles={(resizeHandles ?? []).join(',')}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
WidthProvider: (component: unknown) => component,
|
||||
}));
|
||||
jest.mock('react-grid-layout/css/styles.css', () => ({}), { virtual: true });
|
||||
|
||||
const provider = DashboardProvider.getInstance();
|
||||
|
||||
beforeEach(() => {
|
||||
provider.reset();
|
||||
});
|
||||
|
||||
const mount = () => {
|
||||
const rootId = provider.getRoot().id;
|
||||
const first = provider.addBuildingBlock(rootId, 0, { type: 'markdown' });
|
||||
const second = provider.addBuildingBlock(rootId, 1, { type: 'markdown' });
|
||||
render(<RootGrid nodeId={rootId} />);
|
||||
return { rootId, first, second };
|
||||
};
|
||||
|
||||
test('a grid compacts its children and does not let them overlap', () => {
|
||||
mount();
|
||||
|
||||
const grid = screen.getByTestId('rgl');
|
||||
expect(grid).toHaveAttribute('data-compact-type', 'vertical');
|
||||
expect(grid).toHaveAttribute('data-allow-overlap', 'false');
|
||||
});
|
||||
|
||||
test('a block resizes from all four corners', () => {
|
||||
mount();
|
||||
|
||||
// Used to exclude the north-east corner, which sat under the remove
|
||||
// control -- react-grid-layout appends its handles after a block's own
|
||||
// content, so a handle there took every click aimed at the button beneath
|
||||
// it. The single card-wide inset (see `BuildingBlockView`) moved the
|
||||
// button far enough from the true corner that both now fit.
|
||||
expect(screen.getByTestId('rgl')).toHaveAttribute(
|
||||
'data-resize-handles',
|
||||
'se,sw,nw,ne',
|
||||
);
|
||||
});
|
||||
|
||||
/** A drag payload jsdom's synthetic events do not carry on their own. */
|
||||
const paletteTransfer = (type: string) => {
|
||||
const data = new Map([['application/x-dashboard-building-block', type]]);
|
||||
return {
|
||||
types: [...data.keys()],
|
||||
getData: (key: string) => data.get(key) ?? '',
|
||||
setData: (key: string, value: string) => data.set(key, value),
|
||||
dropEffect: '',
|
||||
effectAllowed: '',
|
||||
};
|
||||
};
|
||||
|
||||
test('dropping a palette block on a container places it there', () => {
|
||||
const { rootId } = mount();
|
||||
|
||||
fireEvent.drop(screen.getByTestId('grid-container'), {
|
||||
dataTransfer: paletteTransfer('markdown'),
|
||||
});
|
||||
|
||||
const children = provider.getNode(rootId)?.children ?? [];
|
||||
expect(children).toHaveLength(3);
|
||||
expect(provider.getNode(children[2])?.type).toBe('markdown');
|
||||
});
|
||||
|
||||
test('a drop carrying something else is not read as a block', () => {
|
||||
const { rootId } = mount();
|
||||
const before = provider.getNode(rootId)?.children?.length;
|
||||
|
||||
fireEvent.drop(screen.getByTestId('grid-container'), {
|
||||
dataTransfer: {
|
||||
types: ['text/plain'],
|
||||
getData: () => '',
|
||||
dropEffect: '',
|
||||
effectAllowed: '',
|
||||
},
|
||||
});
|
||||
|
||||
// A private type rather than text/plain is what keeps a dragged file, or a
|
||||
// selection of text from another window, from placing a block.
|
||||
expect(provider.getNode(rootId)?.children?.length).toBe(before);
|
||||
});
|
||||
|
||||
test('a placed block offers a way to remove it, and the root does not', () => {
|
||||
const { rootId, first } = mount();
|
||||
|
||||
expect(screen.getByTestId(`block-remove-${first}`)).toBeInTheDocument();
|
||||
// Removing the root is refused by the provider, so offering the button
|
||||
// would be offering an error.
|
||||
expect(
|
||||
screen.queryByTestId(`block-remove-${rootId}`),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('the remove control removes that block and nothing else', () => {
|
||||
const { rootId, first, second } = mount();
|
||||
|
||||
fireEvent.click(screen.getByTestId(`block-remove-${first}`));
|
||||
|
||||
expect(provider.getNode(rootId)?.children).toEqual([second]);
|
||||
});
|
||||
|
||||
test('the grid is told not to start a drag from the remove control', () => {
|
||||
const { first } = mount();
|
||||
|
||||
// react-grid-layout begins a drag on a press anywhere in the block it is
|
||||
// positioning, and the button sits inside that block. `draggableCancel` is
|
||||
// what it reads to exclude a region, so the selector and the attribute the
|
||||
// control carries have to agree — aiming at the bin would otherwise drag the
|
||||
// block it is attached to.
|
||||
const cancel = screen
|
||||
.getByTestId('rgl')
|
||||
.getAttribute('data-draggable-cancel');
|
||||
expect(cancel).toContain('[data-block-remove]');
|
||||
// On the control or above it: react-draggable matches the selector against
|
||||
// the pressed element and then walks its ancestors up to the grid item, so
|
||||
// the attribute excludes the whole region it is set on. The bin is the
|
||||
// shared `ActionButton`, which renders its own element and forwards no
|
||||
// arbitrary attributes to it — the region is what carries this.
|
||||
expect(
|
||||
screen.getByTestId(`block-remove-${first}`).closest('[data-block-remove]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
test('the grid is also told not to start a drag from a nested block resize handle', () => {
|
||||
mount();
|
||||
|
||||
// The same guard as the remove control, for the same reason — a `tabs`
|
||||
// block sitting on this grid renders its own resize handle (see
|
||||
// `TabsBlock`'s `FlowItem`) for a block flowed into one of its panes, and
|
||||
// without this, resizing that block also drags the `tabs` item holding it.
|
||||
const cancel = screen
|
||||
.getByTestId('rgl')
|
||||
.getAttribute('data-draggable-cancel');
|
||||
expect(cancel).toContain('[data-block-resize]');
|
||||
});
|
||||
|
||||
test('clicking the remove control removes rather than selects', () => {
|
||||
const { first, second } = mount();
|
||||
|
||||
fireEvent.click(screen.getByTestId(`block-remove-${second}`));
|
||||
|
||||
// The wrapper selects on click and the button sits inside it. Without the
|
||||
// stop, removing a block would also try to select the thing just removed.
|
||||
expect(provider.getSelection()).toBeUndefined();
|
||||
expect(provider.getNode(second)).toBeUndefined();
|
||||
expect(provider.getNode(first)).toBeDefined();
|
||||
});
|
||||
@@ -0,0 +1,549 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useCallback } from 'react';
|
||||
import type { DragEvent as ReactDragEvent } from 'react';
|
||||
// The v1-compatible flat-props API (this component's own model: a plain
|
||||
// `cols`/`rowHeight`/`margin` grid, no responsive breakpoints — see the
|
||||
// design doc's "Explicitly deferred" section) lives at this subpath in v2;
|
||||
// the package's main entry exports only the newer composable API.
|
||||
import GridLayoutLegacy, { WidthProvider } from 'react-grid-layout/legacy';
|
||||
import type { Layout, LayoutItem } from 'react-grid-layout/legacy';
|
||||
import 'react-grid-layout/css/styles.css';
|
||||
import type { dashboard as dashboardApi } from '@apache-superset/core';
|
||||
import { css, styled, useTheme } from '@apache-superset/core/theme';
|
||||
import { provider, useDashboardRevision } from './store';
|
||||
import { resolveGridMetrics } from './layoutStyle';
|
||||
import { availableDropSpan, packChildLayout } from './gridPacking';
|
||||
import {
|
||||
FALLBACK_ROW_SPAN,
|
||||
PALETTE_MIME,
|
||||
placeBlock,
|
||||
placeBlockAt,
|
||||
} from './placement';
|
||||
import BuildingBlockView from './BuildingBlockView';
|
||||
|
||||
type LayoutProps = dashboardApi.LayoutProps;
|
||||
|
||||
const ResizableGridLayout = WidthProvider(GridLayoutLegacy);
|
||||
|
||||
/** `react-grid-layout`'s own item id for the live drop preview — see `handleDropDragOver`. Distinct from `DashboardProvider`'s own `node_<n>` ids by construction, so it can never collide with a real node while a drag is in progress. */
|
||||
const DROPPING_ITEM_ID = '__dropping-block__';
|
||||
|
||||
/**
|
||||
* Where a dragged or (via `isDroppable`, below) freshly dropped block is
|
||||
* about to land — the one on-screen answer to "where, exactly" for a
|
||||
* gesture that otherwise only shows the cursor.
|
||||
*
|
||||
* `react-grid-layout/css/styles.css` (imported above) styles its
|
||||
* `.react-grid-placeholder` red at 0.2 opacity by default — a rectangle this
|
||||
* restyles in the app's own primary colour instead, rather than editing the
|
||||
* package's own stylesheet (not this codebase's to change, and gone on the
|
||||
* next install). A descendant selector under this element's own generated
|
||||
* class outweighs the plain two-class rule it is overriding, so this wins
|
||||
* without needing `!important`.
|
||||
*
|
||||
* `react-grid-layout` renders the identical placeholder for a *resize* too,
|
||||
* carrying its own extra `placeholder-resizing` class — resizing an
|
||||
* existing block already shows that block's own edges moving live under the
|
||||
* pointer, so a second box drawn on top of it would only repeat what is
|
||||
* already on screen. The extra class is what tells the two apart; the base
|
||||
* rule is left for the drag/drop case this was actually added for.
|
||||
*/
|
||||
const GridSurface = styled.div`
|
||||
${({ theme }) => css`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
|
||||
.react-grid-item.react-grid-placeholder {
|
||||
background-color: ${theme.colorPrimaryBg};
|
||||
border: 2px dashed ${theme.colorPrimary};
|
||||
border-radius: ${theme.borderRadiusLG}px;
|
||||
opacity: 1;
|
||||
|
||||
&.placeholder-resizing {
|
||||
background: transparent;
|
||||
border: none;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
// DEBUG INSTRUMENTATION — kept in place on purpose while the drag/resize
|
||||
// interaction is still being worked out; remove only when explicitly asked
|
||||
// to. Logs which node id/type a gesture actually started on (so it's
|
||||
// obvious, e.g., when a click lands on a nested container instead of the
|
||||
// chart the user meant to grab) and what RootGrid ends up committing at the end
|
||||
// of it.
|
||||
const DASHBOARD_DRAG_DEBUG = true;
|
||||
function debugLog(event: string, detail: Record<string, unknown>) {
|
||||
if (!DASHBOARD_DRAG_DEBUG) return;
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug(`[dashboard-drag] ${event}`, detail);
|
||||
}
|
||||
function describeNode(id: string | undefined) {
|
||||
if (!id) return undefined;
|
||||
const node = provider.getNode(id);
|
||||
return { id, type: node?.type };
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the deepest dashboard container actually under a screen point,
|
||||
* ignoring `excludeEl`'s own subtree — the block being dragged might itself
|
||||
* be, or contain, a container, and a drop "onto itself" isn't a valid
|
||||
* reparent target. Every container's outer element carries
|
||||
* `data-container-id` (this component's own does; others set it on whatever
|
||||
* DOM they render a drop target from), so this is the one piece of
|
||||
* cross-container awareness a drag needs: which container the pointer is
|
||||
* over right now, at any nesting depth, without any container needing to
|
||||
* know about any other container's existence.
|
||||
*
|
||||
* `excludeEl`'s `pointer-events` is toggled off for the single synchronous
|
||||
* `elementFromPoint` call so the hit-test sees through the dragged element
|
||||
* to whatever is actually underneath it on screen (otherwise the dragged
|
||||
* element — positioned directly under the cursor by definition — would
|
||||
* always be its own top hit).
|
||||
*/
|
||||
function findContainerIdAt(
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
excludeEl: HTMLElement,
|
||||
): string | null {
|
||||
const previousPointerEvents = excludeEl.style.pointerEvents;
|
||||
excludeEl.style.pointerEvents = 'none';
|
||||
const hit = document.elementFromPoint(clientX, clientY);
|
||||
excludeEl.style.pointerEvents = previousPointerEvents;
|
||||
return (
|
||||
hit?.closest<HTMLElement>('[data-container-id]')?.dataset.containerId ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The dashboard's own grid — not a Building Block (see the composition/
|
||||
* layout design doc), which is why it lives here rather than in `blocks/`
|
||||
* alongside the things that get placed on it. There is exactly one of these
|
||||
* per dashboard, rendered for the root and only the root: `BuildingBlockView`
|
||||
* resolves the root's renderer to this component directly, rather than
|
||||
* through the `dashboard.buildingBlocks` registry every real building block
|
||||
* goes through — nothing places a `RootGrid`, and nothing ever will, the same
|
||||
* way nothing places the dashboard itself.
|
||||
*
|
||||
* Backed by `react-grid-layout`. All position/size math — including
|
||||
* collision handling — is `react-grid-layout`'s: resizing a block never
|
||||
* shrinks a sibling, only displaces it to the next open slot (clamped to
|
||||
* `minW`/`minH: 1`), which leaves every block's own authored (hand- or
|
||||
* AI-set) span untouched. This component's job is translating between the
|
||||
* stored `col`/`row`/`colSpan`/`rowSpan` schema (which allows a child to
|
||||
* omit its position entirely, to be auto-placed) and `react-grid-layout`'s
|
||||
* own `{x, y, w, h}` — see `packChildLayout` for the auto-placement piece RGL
|
||||
* has no concept of — and committing back to the store only once a gesture
|
||||
* ends (`onDragStop`/`onResizeStop`), never on every intermediate frame.
|
||||
*
|
||||
* Dragging a block into a *different* container (reparenting, as opposed to
|
||||
* repositioning within this one) is handled by hit-testing which
|
||||
* `data-container-id` is under the pointer when the drag ends — deliberately
|
||||
* not something `react-grid-layout` (scoped to one grid instance) handles on
|
||||
* its own.
|
||||
*/
|
||||
export default function RootGrid({ nodeId }: { nodeId: string }) {
|
||||
useDashboardRevision();
|
||||
const theme = useTheme();
|
||||
const node = provider.getNode(nodeId);
|
||||
|
||||
const commitLayout = useCallback(
|
||||
(rglLayout: Layout) => {
|
||||
const updates: Record<string, Partial<LayoutProps>> = {};
|
||||
rglLayout.forEach(item => {
|
||||
updates[item.i] = {
|
||||
col: item.x + 1,
|
||||
row: item.y + 1,
|
||||
colSpan: item.w,
|
||||
rowSpan: item.h,
|
||||
};
|
||||
});
|
||||
debugLog('commitLayout', { containerId: nodeId, updates });
|
||||
provider.updateLayouts(updates);
|
||||
},
|
||||
[nodeId],
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(_layout: Layout, _oldItem: unknown, newItem: { i: string } | null) => {
|
||||
debugLog('dragStart', {
|
||||
containerId: nodeId,
|
||||
dragged: describeNode(newItem?.i),
|
||||
});
|
||||
},
|
||||
[nodeId],
|
||||
);
|
||||
|
||||
const handleResizeStart = useCallback(
|
||||
(_layout: Layout, _oldItem: unknown, newItem: { i: string } | null) => {
|
||||
debugLog('resizeStart', {
|
||||
containerId: nodeId,
|
||||
resized: describeNode(newItem?.i),
|
||||
});
|
||||
},
|
||||
[nodeId],
|
||||
);
|
||||
|
||||
const handleResizeStop = useCallback(
|
||||
(rglLayout: Layout, _oldItem: unknown, newItem: { i: string } | null) => {
|
||||
debugLog('resizeStop', {
|
||||
containerId: nodeId,
|
||||
resized: describeNode(newItem?.i),
|
||||
});
|
||||
commitLayout(rglLayout);
|
||||
},
|
||||
[nodeId, commitLayout],
|
||||
);
|
||||
|
||||
const handleDragStop = useCallback(
|
||||
(
|
||||
rglLayout: Layout,
|
||||
_oldItem: unknown,
|
||||
newItem: { i: string } | null,
|
||||
_placeholder: unknown,
|
||||
event: Event,
|
||||
element: HTMLElement | null,
|
||||
) => {
|
||||
const { clientX, clientY } = event as MouseEvent;
|
||||
const targetContainerId =
|
||||
newItem && element && clientX != null && clientY != null
|
||||
? findContainerIdAt(clientX, clientY, element)
|
||||
: null;
|
||||
|
||||
debugLog('dragStop', {
|
||||
containerId: nodeId,
|
||||
dragged: describeNode(newItem?.i),
|
||||
targetContainerId,
|
||||
reparenting: !!targetContainerId && targetContainerId !== nodeId,
|
||||
});
|
||||
|
||||
if (targetContainerId && targetContainerId !== nodeId && newItem) {
|
||||
try {
|
||||
// moveBuildingBlock itself clears col/row and clamps colSpan to
|
||||
// the destination's own column count — old coordinates were only
|
||||
// ever meaningful in *this* container's grid.
|
||||
const destIndex =
|
||||
provider.getNode(targetContainerId)?.children?.length ?? 0;
|
||||
provider.moveBuildingBlock(newItem.i, targetContainerId, destIndex);
|
||||
return;
|
||||
} catch {
|
||||
// Dropped onto itself or one of its own descendants — not a valid
|
||||
// reparent target. Fall through and keep it in this container.
|
||||
}
|
||||
}
|
||||
|
||||
commitLayout(rglLayout);
|
||||
},
|
||||
[nodeId, commitLayout],
|
||||
);
|
||||
|
||||
if (!node) return null;
|
||||
|
||||
const { columns, gap, rowUnitPx } = resolveGridMetrics(node.layout, theme);
|
||||
const children = node.children ?? [];
|
||||
|
||||
const packed = packChildLayout(children, columns, provider.getNode);
|
||||
const layout: Layout = children.map(id => ({
|
||||
i: id,
|
||||
minW: 1,
|
||||
minH: 1,
|
||||
...packed[id],
|
||||
}));
|
||||
|
||||
/**
|
||||
* How wide a block being dragged in from the palette should preview at,
|
||||
* for wherever the pointer currently is — react-grid-layout's own
|
||||
* `isDroppable` calls this on every dragover and merges the `w`/`h` it
|
||||
* returns into the live placeholder, so this is the one place that decides
|
||||
* "how much room is actually here" rather than the block always claiming
|
||||
* a fixed span regardless of what it is being dropped next to.
|
||||
*
|
||||
* Not a palette drag at all (a file from the desktop, dragged text, …) is
|
||||
* `false`: nothing else on the grid should react to it, and returning
|
||||
* anything else would show a preview for a drop this grid never accepts
|
||||
* in the first place.
|
||||
*
|
||||
* Hovering over a *nested* container (a `tabs`/`collapsible`/`carousel`
|
||||
* block sitting on this grid, or anything a third party contributes) is
|
||||
* `false` for the identical reason: that container has its own drop
|
||||
* target (`FlowContent`/`EmptyArea`, tagged `data-container-id`, the same
|
||||
* way this component's own `GridSurface` is) and is where the block
|
||||
* actually belongs, not beside or on top of the container's own card on
|
||||
* *this* grid. Without this, both react to the same hover — this preview
|
||||
* showing a push-down where the container sits, the container's own
|
||||
* unaware that anything is above it — and only one of them ever runs its
|
||||
* cleanup on drop (`FlowContent`'s own `onDrop` stops the event there),
|
||||
* leaving this one's placeholder on screen with nothing left to clear it.
|
||||
*
|
||||
* The width and height themselves are `availableDropSpan`'s own call (see
|
||||
* `gridPacking.ts`) — this only has to translate the cursor's pixel
|
||||
* position into the grid cell underneath it, the same conversion
|
||||
* `react-grid-layout` itself does internally to place every other item. A
|
||||
* gap between two existing blocks that is already tall enough on its own
|
||||
* is exactly where that matters: without shrinking to fit it, the preview
|
||||
* would keep asking for its own full, unshrunk height regardless of how
|
||||
* little of it the gap actually has room for, and collide its way into
|
||||
* shoving the block below down to clear space that was never short to
|
||||
* begin with.
|
||||
*
|
||||
* `dragOffsetX`/`dragOffsetY` correct for something react-grid-layout does
|
||||
* on its own that reads wrong for this gesture: left uncorrected, it
|
||||
* centers the preview *on* the cursor, so a block six rows tall starts
|
||||
* three rows above wherever the pointer actually is. Dropped "just below"
|
||||
* a row of blocks, that is enough to land back on top of them instead —
|
||||
* exactly what this offsets away, by feeding back half of whatever pixel
|
||||
* size the preview is about to be (`react-grid-layout`'s own
|
||||
* `calcGridItemWHPx` math, reproduced here since nothing shorter reaches
|
||||
* it) so the centering it does and the anchoring this undoes cancel out,
|
||||
* and the preview's top-left corner ends up *at* the cursor instead.
|
||||
*/
|
||||
const handleDropDragOver = (
|
||||
event: ReactDragEvent<HTMLDivElement>,
|
||||
):
|
||||
| {
|
||||
w?: number;
|
||||
h?: number;
|
||||
dragOffsetX?: number;
|
||||
dragOffsetY?: number;
|
||||
}
|
||||
| false => {
|
||||
if (!event.dataTransfer.types.includes(PALETTE_MIME)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hoveredContainerId = (
|
||||
event.target as HTMLElement
|
||||
).closest<HTMLElement>('[data-container-id]')?.dataset.containerId;
|
||||
if (hoveredContainerId && hoveredContainerId !== nodeId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const gridRect = event.currentTarget.getBoundingClientRect();
|
||||
const colWidth = (gridRect.width - gap * (columns - 1)) / columns;
|
||||
const colStride = colWidth + gap;
|
||||
const rowStride = rowUnitPx + gap;
|
||||
const cursorCol = Math.min(
|
||||
columns - 1,
|
||||
Math.max(0, Math.floor((event.clientX - gridRect.left) / colStride)),
|
||||
);
|
||||
const cursorRow = Math.max(
|
||||
0,
|
||||
Math.floor((event.clientY - gridRect.top) / rowStride),
|
||||
);
|
||||
|
||||
const { w, h } = availableDropSpan(
|
||||
packed,
|
||||
columns,
|
||||
cursorCol,
|
||||
cursorRow,
|
||||
FALLBACK_ROW_SPAN,
|
||||
);
|
||||
const pixelWidth = Math.round(colWidth * w + Math.max(0, w - 1) * gap);
|
||||
const pixelHeight = Math.round(rowUnitPx * h + Math.max(0, h - 1) * gap);
|
||||
|
||||
return {
|
||||
w,
|
||||
h,
|
||||
dragOffsetX: pixelWidth / 2,
|
||||
dragOffsetY: pixelHeight / 2,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Where a palette drag actually lands, once released — the counterpart to
|
||||
* `GridSurface`'s own plain `onDrop` (below, for a drop that missed the
|
||||
* grid entirely, into the leftover space past its last row): this one
|
||||
* fires when react-grid-layout's own `isDroppable` resolved a real x/y/w/h
|
||||
* for the drop, live, during the drag itself (see `handleDropDragOver`),
|
||||
* so there is no position left to compute here — only a type to read off
|
||||
* the browser's own drag data, and where among the existing children this
|
||||
* new one's own row/col actually falls (see `placeBlockAt`'s own doc
|
||||
* comment for why that has to be figured out here rather than left to
|
||||
* default to "at the end").
|
||||
*/
|
||||
const handleExternalDrop = (
|
||||
_rglLayout: Layout,
|
||||
item: LayoutItem | undefined,
|
||||
event: Event,
|
||||
) => {
|
||||
const type = (event as DragEvent).dataTransfer?.getData(PALETTE_MIME);
|
||||
if (!type || !item) return;
|
||||
|
||||
const readingOrderIndex = children.findIndex(id => {
|
||||
const rect = packed[id];
|
||||
return rect.y > item.y || (rect.y === item.y && rect.x >= item.x);
|
||||
});
|
||||
const index =
|
||||
readingOrderIndex === -1 ? children.length : readingOrderIndex;
|
||||
|
||||
debugLog('dropOnGrid', { containerId: nodeId, type, item, index });
|
||||
placeBlockAt(nodeId, type, index, {
|
||||
col: item.x + 1,
|
||||
row: item.y + 1,
|
||||
colSpan: item.w,
|
||||
rowSpan: item.h,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<GridSurface
|
||||
data-container-id={nodeId}
|
||||
data-test="grid-container"
|
||||
// The grid itself (`ResizableGridLayout`, below) now answers a drop
|
||||
// anywhere over its own rendered rows via `isDroppable` — this pair
|
||||
// only still fires for the leftover space past its last row (the
|
||||
// grid is exactly as tall as its content, and this element is not),
|
||||
// where appending full-width at the end is still the right answer,
|
||||
// the same one it always was.
|
||||
//
|
||||
// Every container is a drop target, not just the root: a nested
|
||||
// section is exactly where an author means to put something when they
|
||||
// drag it there, and the stop (in `onDrop`, below) is what makes the
|
||||
// innermost container under the pointer the one that takes it rather
|
||||
// than every ancestor claiming the same drop.
|
||||
onDragOver={event => {
|
||||
if (event.dataTransfer.types.includes(PALETTE_MIME)) {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
}}
|
||||
onDrop={event => {
|
||||
const type = event.dataTransfer.getData(PALETTE_MIME);
|
||||
if (type !== '') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
placeBlock(nodeId, type);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ResizableGridLayout
|
||||
layout={layout}
|
||||
cols={columns}
|
||||
// Left to its own `autoSize`, this element is only ever as tall as
|
||||
// its own rows — meaning dropping "below" sparse content means
|
||||
// finding the few remaining pixels this element actually renders
|
||||
// across, past which there's no element left to fire a dragover on
|
||||
// at all (`GridSurface`'s own plain fallback, below, takes over
|
||||
// there instead — no live preview, an append at the end). A CSS
|
||||
// floor is deliberately as far as this goes: it changes nothing
|
||||
// about `layout` or what `isDroppable` tracks internally, only how
|
||||
// tall the element hovered over already is.
|
||||
//
|
||||
// A live drop preview can still push the *content* height briefly
|
||||
// past what this floor alone guarantees (the synthetic dropping
|
||||
// item can land below everything currently on screen), and this
|
||||
// element's own width is measured (by `WidthProvider`) on a
|
||||
// `ResizeObserver` callback, not synchronously — so for a frame or
|
||||
// two after that height change forces `GridSurface` to grow a
|
||||
// vertical scrollbar, this element can still think it has the width
|
||||
// from before that scrollbar took some of it back, and render
|
||||
// wider than the space actually left. `GridSurface`'s own
|
||||
// `overflow-x: hidden` (below) is what keeps that transient
|
||||
// mismatch from ever showing up as a horizontal scrollbar rather
|
||||
// than trying to keep the two measurements in perfect lockstep.
|
||||
style={{ minHeight: '100%' }}
|
||||
rowHeight={rowUnitPx}
|
||||
margin={[gap, gap] as const}
|
||||
containerPadding={[0, 0] as const}
|
||||
// Explicit rather than relying on the (identical) default, since the
|
||||
// obvious-looking alternative doesn't work: `compactType={null}`
|
||||
// (no compaction) looks like it should mean "displace only the
|
||||
// sibling actually being collided with," but react-grid-layout's
|
||||
// own collision-resolution branch for `compactType === null`
|
||||
// (`moveElementAwayFromCollision`'s "collisionNorth" case) re-adds
|
||||
// a colliding sibling's full height on *every* drag frame the
|
||||
// collision persists rather than settling once it's clear —
|
||||
// confirmed live: dragging one tile so it merely brushes a
|
||||
// neighbor's edge sent that neighbor hundreds of pixels away within
|
||||
// a few mouse-move events. `"vertical"` resolves the same collision
|
||||
// by moving the sibling down exactly once, by exactly its own
|
||||
// height, every time.
|
||||
compactType="vertical"
|
||||
allowOverlap={false}
|
||||
preventCollision={false}
|
||||
// A palette drag is react-grid-layout's own concern from here on,
|
||||
// not `GridSurface`'s: `isDroppable` is what makes it track the drag
|
||||
// live and shows the preview `GridSurface`'s own CSS now styles
|
||||
// (see that component's doc comment), with `handleDropDragOver`
|
||||
// answering "how wide, here" on every frame and `handleExternalDrop`
|
||||
// reading the result back out once the drag actually ends.
|
||||
// `droppingItem` is only the shape's starting point — its `i` is
|
||||
// never a real node, and its `w`/`h` are overwritten by
|
||||
// `handleDropDragOver` before the preview is ever drawn — but
|
||||
// react-grid-layout still wants one up front to size the very first
|
||||
// frame against.
|
||||
isDroppable
|
||||
droppingItem={{
|
||||
i: DROPPING_ITEM_ID,
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: columns,
|
||||
h: FALLBACK_ROW_SPAN,
|
||||
}}
|
||||
onDropDragOver={handleDropDragOver}
|
||||
onDrop={handleExternalDrop}
|
||||
// All four corners. The top-right one used to sit under the remove
|
||||
// control — react-grid-layout appends its handles after the block's
|
||||
// own content, so a 20px handle sat over that button and took every
|
||||
// click aimed at it. The single card-wide inset (see
|
||||
// `BuildingBlockView`) moved the button further from the true
|
||||
// corner than it sat before, which is what makes room for both here.
|
||||
resizeHandles={['se', 'sw', 'nw', 'ne']}
|
||||
// This is the only grid a dashboard has (see this component's own
|
||||
// doc comment) — a nested container never renders another `RootGrid` —
|
||||
// but a nested container (e.g. a `tabs` block) still renders its own
|
||||
// `[data-container-id]` drop target, and a click anywhere inside one
|
||||
// of those (on a leaf grandchild, or on the empty space between them)
|
||||
// must never start dragging it as a single item here, or "drag one
|
||||
// chart out of a tab" and "the whole tabs block comes with it"
|
||||
// become indistinguishable at the DOM level. A leaf child
|
||||
// (chart/markdown) never renders `data-container-id` itself, so this
|
||||
// never affects dragging a leaf.
|
||||
//
|
||||
// `[data-block-resize]` is the same guard for a different gesture —
|
||||
// `TabsBlock`'s own resize handle on a block flowed into a pane
|
||||
// (see `FlowItem`). Without it, resizing that block and dragging the
|
||||
// `tabs` item that holds it are indistinguishable for the same
|
||||
// reason: both start with a pointer-down somewhere inside this grid
|
||||
// item, and this component has no way to tell them apart except by
|
||||
// where, exactly, that press landed.
|
||||
//
|
||||
// `[data-block-header-control]` is the same guard again for a
|
||||
// type's own extra header control (see `blockHeaderControl`) — a
|
||||
// press on `collapsible`'s expand/collapse toggle must toggle it,
|
||||
// not start dragging the block it sits on.
|
||||
draggableCancel="[data-container-id],[data-block-remove],[data-block-resize],[data-block-header-control]"
|
||||
onDragStart={handleDragStart}
|
||||
onDragStop={handleDragStop}
|
||||
onResizeStart={handleResizeStart}
|
||||
onResizeStop={handleResizeStop}
|
||||
>
|
||||
{children.map(childId => (
|
||||
<BuildingBlockView key={childId} nodeId={childId} />
|
||||
))}
|
||||
</ResizableGridLayout>
|
||||
</GridSurface>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview A second control in a block's own header, beside the remove
|
||||
* button — the header-side counterpart to `blockLabel`. Most block types
|
||||
* have nothing to put there and get nothing rendered. `collapsible` needs an
|
||||
* expand/collapse toggle next to its remove control rather than a second bar
|
||||
* of its own further down the card (see `CollapsibleBlock`); `carousel`
|
||||
* needs a way to add a slide that isn't the dot strip itself, since the dot
|
||||
* strip is meant to read as a plain position indicator rather than a row of
|
||||
* controls (see `CarouselBlock`).
|
||||
*/
|
||||
import type { ReactElement } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { ActionButton } from '@superset-ui/core/components';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { provider } from './store';
|
||||
import { SLIDE_TYPE, untitledSlideLabel } from './blocks/CarouselBlock';
|
||||
|
||||
/**
|
||||
* How tall a collapsed block stays — just enough for `BuildingBlockView`'s
|
||||
* own header, plus a little room around it, rather than the bare minimum
|
||||
* (`1`) either unit accepts: at exactly the header's own height a collapsed
|
||||
* block reads as clipped rather than deliberately shut. Read in
|
||||
* `layout.rowSpan`'s own unit, whatever this node's container happens to
|
||||
* interpret that as (a grid row on the root's own grid, a pixel inside a
|
||||
* flow area — see the composition/layout design doc).
|
||||
*/
|
||||
const COLLAPSED_ROW_SPAN = 2;
|
||||
|
||||
/**
|
||||
* The height restored on expanding, when nothing narrower was ever
|
||||
* authored to begin with — the same default a freshly placed container
|
||||
* arrives with (see `placeBlock`), so expanding a block nobody has resized
|
||||
* yet returns it to exactly the size it was placed at.
|
||||
*/
|
||||
const DEFAULT_EXPANDED_ROW_SPAN = 4;
|
||||
|
||||
function CollapsibleToggle({ nodeId }: { nodeId: string }): ReactElement {
|
||||
const node = provider.getNode(nodeId);
|
||||
const collapsed = Boolean(node?.props?.collapsed);
|
||||
|
||||
const toggle = (): void => {
|
||||
const current = provider.getNode(nodeId);
|
||||
if (!current) return;
|
||||
if (collapsed) {
|
||||
const restored =
|
||||
(current.props?.expandedRowSpan as number | undefined) ??
|
||||
DEFAULT_EXPANDED_ROW_SPAN;
|
||||
provider.updateLayout(nodeId, { rowSpan: restored });
|
||||
provider.updateProps(nodeId, { collapsed: false });
|
||||
} else {
|
||||
// The height about to be given up is saved so expanding again
|
||||
// returns to it rather than always to the default — an author who
|
||||
// grew a collapsible before collapsing it should not find it back at
|
||||
// its original size on the way out.
|
||||
provider.updateProps(nodeId, {
|
||||
collapsed: true,
|
||||
expandedRowSpan: current.layout?.rowSpan ?? DEFAULT_EXPANDED_ROW_SPAN,
|
||||
});
|
||||
provider.updateLayout(nodeId, { rowSpan: COLLAPSED_ROW_SPAN });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ActionButton
|
||||
label={collapsed ? t('Expand block') : t('Collapse block')}
|
||||
tooltip={collapsed ? t('Expand') : t('Collapse')}
|
||||
placement="bottom"
|
||||
dataTest={`block-collapse-toggle-${nodeId}`}
|
||||
onClick={toggle}
|
||||
icon={
|
||||
collapsed ? (
|
||||
<Icons.CaretRightOutlined iconSize="s" />
|
||||
) : (
|
||||
<Icons.CaretDownOutlined iconSize="s" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a new slide and selects nothing itself — `CarouselBlock` notices
|
||||
* the growth on its own next render and switches to it (see its own
|
||||
* comment). This component can't do that switching directly: it renders as
|
||||
* `CarouselBlock`'s sibling in `BuildingBlockView`'s header, not as
|
||||
* anything that could hold or reach the active-slide state living inside
|
||||
* `CarouselBlock`.
|
||||
*/
|
||||
function CarouselAddSlide({ nodeId }: { nodeId: string }): ReactElement {
|
||||
const addSlide = (): void => {
|
||||
const index = provider.getNode(nodeId)?.children?.length ?? 0;
|
||||
provider.addBuildingBlock(nodeId, index, {
|
||||
type: SLIDE_TYPE,
|
||||
props: { label: untitledSlideLabel(index) },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ActionButton
|
||||
label={t('Add slide')}
|
||||
tooltip={t('Add slide')}
|
||||
placement="bottom"
|
||||
dataTest={`carousel-add-${nodeId}`}
|
||||
onClick={addSlide}
|
||||
icon={<Icons.PlusOutlined iconSize="s" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const HEADER_CONTROLS: Record<string, (nodeId: string) => ReactElement> = {
|
||||
collapsible: nodeId => <CollapsibleToggle nodeId={nodeId} />,
|
||||
carousel: nodeId => <CarouselAddSlide nodeId={nodeId} />,
|
||||
};
|
||||
|
||||
/**
|
||||
* A second control for the block of `type` to show in its own header,
|
||||
* beside the remove button — or `null` for every type that has nothing to
|
||||
* put there, which is nearly all of them.
|
||||
*/
|
||||
export function blockHeaderControl(
|
||||
type: string,
|
||||
nodeId: string,
|
||||
): ReactElement | null {
|
||||
return HEADER_CONTROLS[type]?.(nodeId) ?? null;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { registerBuiltInBuildingBlocks } from './registerBuiltInBuildingBlocks';
|
||||
import { blockLabel } from './blockLabel';
|
||||
|
||||
beforeAll(() => {
|
||||
registerBuiltInBuildingBlocks();
|
||||
});
|
||||
|
||||
test('a chart is named by the title its author wrote into the option', () => {
|
||||
expect(
|
||||
blockLabel('echarts', {
|
||||
echartsOptions: { title: { text: 'Sales by Territory' } },
|
||||
}),
|
||||
).toBe('Sales by Territory');
|
||||
});
|
||||
|
||||
test('a chart carrying several titles is named by the first', () => {
|
||||
// ECharts takes one title or a list of them; the first is the chart's and
|
||||
// the rest annotate parts of it.
|
||||
expect(
|
||||
blockLabel('echarts', {
|
||||
echartsOptions: { title: [{ text: 'Revenue' }, { text: 'Units' }] },
|
||||
}),
|
||||
).toBe('Revenue');
|
||||
});
|
||||
|
||||
test('a metric tile is named by the label it displays', () => {
|
||||
expect(blockLabel('metric-tile', { label: 'Total Revenue' })).toBe(
|
||||
'Total Revenue',
|
||||
);
|
||||
});
|
||||
|
||||
test('a tab pane is named by its own label', () => {
|
||||
expect(blockLabel('tab', { label: 'Overview' })).toBe('Overview');
|
||||
});
|
||||
|
||||
test('a tabs block with no panes yet falls back to its registered name', () => {
|
||||
expect(blockLabel('tabs', {})).toBe('Tabs');
|
||||
});
|
||||
|
||||
test('markdown goes unnamed — its rendered body is already its name', () => {
|
||||
// Unlike a chart's title or a tile's label, markdown's `content` is the
|
||||
// whole of what the block renders rather than a field carved out of it,
|
||||
// and its registered name ("Markdown") says only what it is, not which
|
||||
// one — worth nothing sitting right above the content itself. Both would
|
||||
// repeat what a reader is already looking at, so this returns '' rather
|
||||
// than falling back to either.
|
||||
expect(
|
||||
blockLabel('markdown', { content: '# Acme Corp\n\nGenerated November' }),
|
||||
).toBe('');
|
||||
expect(blockLabel('markdown', {})).toBe('');
|
||||
});
|
||||
|
||||
test('a block with no name of its own is named by what it is', () => {
|
||||
// "Table" says what a block is rather than which one it is — worth little,
|
||||
// and still better than an empty header.
|
||||
expect(blockLabel('ag-grid-table', {})).toBe('Table');
|
||||
expect(blockLabel('echarts', undefined)).toBe('ECharts');
|
||||
});
|
||||
|
||||
test('a name of nothing but spaces is no name', () => {
|
||||
expect(
|
||||
blockLabel('echarts', { echartsOptions: { title: { text: ' ' } } }),
|
||||
).toBe('ECharts');
|
||||
});
|
||||
|
||||
test('a type nothing registered still says something', () => {
|
||||
// An extension's block whose registration failed, or arrived late.
|
||||
expect(blockLabel('acme-widget', undefined)).toBe('acme-widget');
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview What a block is called, in the one place every panel that
|
||||
* names one can reach.
|
||||
*
|
||||
* A block is named in more than one part of the editor — its own header on
|
||||
* the canvas, its row in the outline — and those have to agree. A block
|
||||
* called "Sales by Territory" in one and "ECharts" in the other reads as two
|
||||
* different blocks.
|
||||
*/
|
||||
|
||||
import { views } from 'src/core/views';
|
||||
import { DASHBOARD_BUILDING_BLOCKS_LOCATION } from './resolveBuildingBlockView';
|
||||
|
||||
type Props = Record<string, unknown> | undefined;
|
||||
|
||||
/**
|
||||
* The ECharts option's own title, which is where a chart's name is authored.
|
||||
*
|
||||
* ECharts accepts either one title or an array of them; the first is the
|
||||
* chart's, and any others annotate parts of it.
|
||||
*/
|
||||
const echartsTitle = (props: Props): unknown => {
|
||||
const title = (props?.echartsOptions as { title?: unknown } | undefined)
|
||||
?.title;
|
||||
const first = Array.isArray(title) ? title[0] : title;
|
||||
return (first as { text?: unknown } | undefined)?.text;
|
||||
};
|
||||
|
||||
/**
|
||||
* Where a block type carries a name of its own, distinct from what it
|
||||
* renders.
|
||||
*
|
||||
* `markdown` is deliberately not here. A chart's title or a tile's label is
|
||||
* a field the block reads once and renders once — naming the block by it
|
||||
* and having `ChartBlock` skip drawing its own copy (see `ChartBlock`'s own
|
||||
* comment) is what keeps it appearing exactly once, in the header, rather
|
||||
* than twice. Markdown's `content` is not that: it is the whole of what the
|
||||
* block renders, not a field carved out of it, so echoing it into the
|
||||
* header would print the same words a second time right above the ones the
|
||||
* author actually wrote — most visibly when that content is nothing but a
|
||||
* heading, where the two would read as identical. Everything else here is
|
||||
* named by its registration.
|
||||
*/
|
||||
const NAMED_BY: Record<string, (props: Props) => unknown> = {
|
||||
echarts: echartsTitle,
|
||||
'metric-tile': props => props?.label,
|
||||
tab: props => props?.label,
|
||||
collapsible: props => props?.label,
|
||||
slide: props => props?.label,
|
||||
};
|
||||
|
||||
/**
|
||||
* Types that go unnamed rather than falling back to their registered name.
|
||||
*
|
||||
* Every other type says something a reader cannot already see just by
|
||||
* looking at the block — "Table" for a grid with no title of its own,
|
||||
* "ECharts" for a chart nobody has titled yet. A markdown block has no such
|
||||
* gap to fill: its entire rendered body sits right below the header, so
|
||||
* "Markdown" would be one more label repeating what the reader is already
|
||||
* looking at, rather than standing in for something otherwise missing.
|
||||
* `carousel` is here for a different reason: it is meant to read as just a
|
||||
* slide's own content and the dots beside it, not as a slide sitting inside
|
||||
* a captioned card — the same idea `CarouselBlock`'s own missing title bar
|
||||
* carries further.
|
||||
*/
|
||||
const UNNAMED: ReadonlySet<string> = new Set(['markdown', 'carousel']);
|
||||
|
||||
/**
|
||||
* What to call the block of `type` holding `props`, or `''` for one that
|
||||
* goes unnamed (see `UNNAMED`) — callers skip the header's name entirely
|
||||
* for those rather than rendering an empty label.
|
||||
*
|
||||
* A name the block's own content carries wins, because that is the name its
|
||||
* author gave it and the one they will look for. Only when there is none does
|
||||
* this fall back to the registered block name — "Table" — which says what a
|
||||
* block is rather than which one it is, and is worth nothing at all when
|
||||
* five of them sit in a column.
|
||||
*
|
||||
* Returned whole: how much of a long name fits is the caller's business,
|
||||
* since a row in a panel and a header on a wide chart cut at different
|
||||
* points.
|
||||
*/
|
||||
export function blockLabel(type: string, props: Props): string {
|
||||
if (UNNAMED.has(type)) return '';
|
||||
const own = NAMED_BY[type]?.(props);
|
||||
if (typeof own === 'string' && own.trim() !== '') {
|
||||
return own.trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
const registered = views
|
||||
.getViews(DASHBOARD_BUILDING_BLOCKS_LOCATION)
|
||||
?.find(view => view.id === type);
|
||||
return registered?.name ?? type;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { dashboard as dashboardApi } from '@apache-superset/core';
|
||||
import {
|
||||
Flex,
|
||||
Loading,
|
||||
ThemedAgGridReact,
|
||||
Typography,
|
||||
} from '@superset-ui/core/components';
|
||||
import type { ColDef } from '@superset-ui/core/components/ThemedAgGridReact';
|
||||
import { provider, useDashboardRevision } from '../store';
|
||||
import { fetchQueryData } from '../chartData';
|
||||
|
||||
type DataBindingSpec = dashboardApi.DataBindingSpec;
|
||||
type DataRow = dashboardApi.DataRow;
|
||||
|
||||
// No module registration needed here, unlike `ChartBlock`'s own
|
||||
// `echarts.use([...])` call — `setupAGGridModules()` already runs
|
||||
// unconditionally at app bootstrap (see `src/views/App.tsx`), well before
|
||||
// this (or any other) AG Grid consumer ever renders.
|
||||
|
||||
function deriveColumnDefs(columns: string[]): ColDef[] {
|
||||
return columns.map(field => ({ field, headerName: field }));
|
||||
}
|
||||
|
||||
/**
|
||||
* The built-in `ag-grid-table` building block — registered like any other
|
||||
* block (see `registerBuiltInBuildingBlocks`). Fetches its `dataBinding`
|
||||
* (generic, viz_type-less — see `chartData.ts`) the same way `ChartBlock`
|
||||
* does, then hands the rows straight to AG Grid via the already-themed
|
||||
* `ThemedAgGridReact` wrapper. Unlike `echarts`, a table's `rowData`/
|
||||
* `columnDefs` map directly onto query results with no `$bind`-style
|
||||
* splicing needed — `columnDefs` can optionally be authored explicitly
|
||||
* (e.g. for custom headers, formatting, or widths), but when omitted,
|
||||
* columns are derived one-to-one from the query's own result columns.
|
||||
*/
|
||||
export default function AgGridTableBlock({ nodeId }: { nodeId: string }) {
|
||||
useDashboardRevision();
|
||||
const [rows, setRows] = useState<DataRow[] | null>(null);
|
||||
const [columns, setColumns] = useState<string[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const node = provider.getNode(nodeId);
|
||||
const dataBinding = node?.props?.dataBinding as DataBindingSpec | undefined;
|
||||
const bindingKey = JSON.stringify(dataBinding);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dataBinding) {
|
||||
setError('This table block has no dataBinding.');
|
||||
setRows(null);
|
||||
setColumns(null);
|
||||
return undefined;
|
||||
}
|
||||
let cancelled = false;
|
||||
setError(null);
|
||||
setRows(null);
|
||||
setColumns(null);
|
||||
fetchQueryData(dataBinding)
|
||||
.then(result => {
|
||||
if (!cancelled) {
|
||||
setRows(result.rows);
|
||||
setColumns(result.columns);
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// dataBinding is a fresh object every render — bindingKey is its stable,
|
||||
// value-equality-comparable proxy.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [bindingKey]);
|
||||
|
||||
if (!node) return null;
|
||||
|
||||
const columnDefs =
|
||||
(node.props?.columnDefs as ColDef[] | undefined) ??
|
||||
(columns ? deriveColumnDefs(columns) : undefined);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
// Fills the box `BuildingBlockView`'s placement wrapper gives this
|
||||
// block — always a definite pixel box, same as `ChartBlock`.
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
// Surface, border and corners belong to the card `BuildingBlockView`
|
||||
// draws around this block and the name above it, so that the name is
|
||||
// inside the frame rather than over it.
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{error && (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
>
|
||||
<Typography.Text type="danger">{error}</Typography.Text>
|
||||
</Flex>
|
||||
)}
|
||||
{!error && !rows && (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
>
|
||||
<Loading position="inline-centered" size="s" />
|
||||
</Flex>
|
||||
)}
|
||||
{!error && rows && columnDefs && (
|
||||
<ThemedAgGridReact rowData={rows} columnDefs={columnDefs} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useRef, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { css, styled } from '@apache-superset/core/theme';
|
||||
import { EmptyState } from '@superset-ui/core/components';
|
||||
import { provider, useDashboardRevision } from '../store';
|
||||
import { PALETTE_MIME, placeBlock } from '../placement';
|
||||
import { FlowContent } from './flowContent';
|
||||
|
||||
/**
|
||||
* A slide's own child type — the vertical-navigation counterpart to
|
||||
* `TabsBlock`'s `TAB_TYPE`, and not registered as a building block for the
|
||||
* identical reason: nothing ever resolves one through
|
||||
* `resolveBuildingBlockView`, since `CarouselBlock` renders a slide's
|
||||
* children directly. It only needs to be a recognized container type so
|
||||
* `addBuildingBlock` gives it a `children` array (see `registerContainerType`
|
||||
* in `DashboardProvider`).
|
||||
*/
|
||||
export const SLIDE_TYPE = 'slide';
|
||||
|
||||
/**
|
||||
* The negative margin is what keeps the nav column full-height — see
|
||||
* `TabsBlock`'s own `Root`, which this mirrors for the identical reason: the
|
||||
* card's own padding (`BuildingBlockView`) is right for a single thing
|
||||
* filling the card, but wrong for chrome that has to reach the card's own
|
||||
* edges to read as one. The top is left alone: `carousel` has no *title* in
|
||||
* its header (see `blockLabel`'s `UNNAMED` set), but the header itself —
|
||||
* carrying at least the remove control — is still there for every non-root
|
||||
* node, so this box starts below it rather than at the card's true top edge
|
||||
* regardless.
|
||||
*/
|
||||
const Root = styled.div`
|
||||
${({ theme }) => css`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0 -${theme.padding}px -${theme.padding}px;
|
||||
`}
|
||||
`;
|
||||
|
||||
/**
|
||||
* The dot strip itself — shown only once there is something to navigate
|
||||
* between (see `CarouselBlock`'s own render). A dot rather than a labelled
|
||||
* button: this is the one built-in container whose own switching control is
|
||||
* meant to read as a lightweight indicator of position among slides, the
|
||||
* way a carousel's dots do elsewhere, rather than as a row of named
|
||||
* destinations the way `TabsBlock`'s tab bar is.
|
||||
*/
|
||||
const NavColumn = styled.div`
|
||||
${({ theme }) => css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
gap: ${theme.sizeUnit * 2}px;
|
||||
padding: ${theme.sizeUnit * 3}px;
|
||||
border-right: 1px solid ${theme.colorBorderSecondary};
|
||||
overflow-y: auto;
|
||||
`}
|
||||
`;
|
||||
|
||||
const Dot = styled.button<{ $active: boolean }>`
|
||||
${({ theme, $active }) => css`
|
||||
appearance: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
flex: 0 0 auto;
|
||||
width: ${theme.sizeUnit * 2}px;
|
||||
height: ${theme.sizeUnit * 2}px;
|
||||
border-radius: 50%;
|
||||
background-color: ${$active ? theme.colorPrimary : theme.colorBorder};
|
||||
cursor: pointer;
|
||||
transition: background-color ${theme.motionDurationMid};
|
||||
|
||||
&:hover {
|
||||
background-color: ${
|
||||
$active ? theme.colorPrimary : theme.colorPrimaryBorder
|
||||
};
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid ${theme.colorPrimaryBorder};
|
||||
outline-offset: 2px;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Where a carousel with no slides yet still has to take a drop — a fresh
|
||||
* `FlowContent` area has a `containerId` to drop into from the moment it
|
||||
* exists (see its own comment), but a carousel with zero slides has no
|
||||
* slide node at all yet for one to be the content of. This is that same
|
||||
* drop target one level up: it makes the first `slide` itself, then hands
|
||||
* the dropped type to `placeBlock` the same way `FlowContent` would have.
|
||||
*/
|
||||
const EmptyArea = styled.div`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
/** What a slide is called before an author (or the assistant) names it. */
|
||||
export const untitledSlideLabel = (index: number): string =>
|
||||
t('Slide %s', index + 1);
|
||||
|
||||
/**
|
||||
* The built-in `carousel` building block — a container whose own children
|
||||
* (each a `slide`, itself a container) are switchable one at a time through
|
||||
* a vertical strip of dots, rather than the horizontal tab bar `TabsBlock`
|
||||
* uses for the same idea. Registered like any other block (see
|
||||
* `registerBuiltInBuildingBlocks`), and like `tabs`, it has no grid of its
|
||||
* own: which slide is showing is this component's own concern, not a
|
||||
* `layout` fact the document carries (composition/layout design doc).
|
||||
*
|
||||
* Unlike `tabs`, nothing here fills a carousel in on its own — a fresh one
|
||||
* shows the same empty state a fresh pane would, rather than one slide
|
||||
* already made for it, which is what keeps the dots from ever needing to
|
||||
* appear over a single slide nobody asked for. They join the moment a first
|
||||
* slide actually exists.
|
||||
*
|
||||
* Which slide is *active* is intentionally not persisted, for the identical
|
||||
* reason `TabsBlock`'s active pane is not: it is a fact about who is looking
|
||||
* at the dashboard right now, not about the dashboard itself. It resets to
|
||||
* the first slide whenever the previously active one no longer exists.
|
||||
*/
|
||||
export default function CarouselBlock({
|
||||
nodeId,
|
||||
}: {
|
||||
nodeId: string;
|
||||
}): ReactElement | null {
|
||||
useDashboardRevision();
|
||||
const node = provider.getNode(nodeId);
|
||||
const slides = node?.children ?? [];
|
||||
|
||||
const [activeSlideId, setActiveSlideId] = useState<string | undefined>(
|
||||
slides[0],
|
||||
);
|
||||
const activeIsValid =
|
||||
activeSlideId !== undefined && slides.includes(activeSlideId);
|
||||
if (!activeIsValid && activeSlideId !== slides[0]) {
|
||||
setActiveSlideId(slides[0]);
|
||||
}
|
||||
|
||||
// A slide added since the last render — whether from `blockHeaderControl`'s
|
||||
// "+" (see its own comment) or a palette drop into an empty carousel above
|
||||
// — is one nobody has seen yet, so it becomes the one shown rather than
|
||||
// landing silently behind whichever slide was already active. Both of
|
||||
// those additions always append, so the newest slide is always the last
|
||||
// one; a ref rather than a prop is what lets this component notice the
|
||||
// growth at all, since the button that causes it renders as this one's
|
||||
// sibling in `BuildingBlockView`'s header, not as anything that could pass
|
||||
// it a callback.
|
||||
const previousSlideCount = useRef(slides.length);
|
||||
if (slides.length > previousSlideCount.current) {
|
||||
setActiveSlideId(slides[slides.length - 1]);
|
||||
}
|
||||
previousSlideCount.current = slides.length;
|
||||
|
||||
if (!node) return null;
|
||||
|
||||
return (
|
||||
<Root data-test={`carousel-${nodeId}`}>
|
||||
{slides.length > 0 && (
|
||||
<NavColumn
|
||||
role="tablist"
|
||||
aria-label={t('Carousel slides')}
|
||||
aria-orientation="vertical"
|
||||
>
|
||||
{slides.map((slideId, index) => {
|
||||
const active = slideId === activeSlideId;
|
||||
return (
|
||||
<Dot
|
||||
key={slideId}
|
||||
type="button"
|
||||
role="tab"
|
||||
tabIndex={0}
|
||||
aria-selected={active}
|
||||
aria-label={t('Slide %s', index + 1)}
|
||||
$active={active}
|
||||
data-test={`slide-${slideId}`}
|
||||
onClick={() => setActiveSlideId(slideId)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</NavColumn>
|
||||
)}
|
||||
{activeSlideId ? (
|
||||
<FlowContent
|
||||
containerId={activeSlideId}
|
||||
emptyTitle={t('Nothing on this slide yet')}
|
||||
emptyDescription={t('Ask the assistant to add something here.')}
|
||||
dataTest={`carousel-slide-${nodeId}`}
|
||||
/>
|
||||
) : (
|
||||
<EmptyArea
|
||||
data-test={`carousel-empty-${nodeId}`}
|
||||
data-container-id={nodeId}
|
||||
onDragOver={event => {
|
||||
if (event.dataTransfer.types.includes(PALETTE_MIME)) {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
}}
|
||||
onDrop={event => {
|
||||
const type = event.dataTransfer.getData(PALETTE_MIME);
|
||||
if (type !== '') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const slideId = provider.addBuildingBlock(nodeId, 0, {
|
||||
type: SLIDE_TYPE,
|
||||
props: { label: untitledSlideLabel(0) },
|
||||
});
|
||||
placeBlock(slideId, type);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<EmptyState
|
||||
size="small"
|
||||
image="empty.svg"
|
||||
title={t('Nothing in this carousel yet')}
|
||||
description={t('Ask the assistant to add a slide.')}
|
||||
/>
|
||||
</EmptyArea>
|
||||
)}
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, waitFor } from 'spec/helpers/testing-library';
|
||||
import DashboardProvider from '../DashboardProvider';
|
||||
import ChartBlock from './ChartBlock';
|
||||
|
||||
const mockSetOption = jest.fn();
|
||||
|
||||
jest.mock('echarts/core', () => ({
|
||||
__esModule: true,
|
||||
use: jest.fn(),
|
||||
init: jest.fn(() => ({
|
||||
setOption: mockSetOption,
|
||||
resize: jest.fn(),
|
||||
dispose: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../chartData', () => ({
|
||||
__esModule: true,
|
||||
fetchQueryData: jest.fn(async () => ({ rows: [{ x: 'a', y: 1 }] })),
|
||||
}));
|
||||
|
||||
/**
|
||||
* The stock test double never calls back, so nothing this component draws is
|
||||
* ever measured. ECharts has no self-sizing — it draws what it is told to
|
||||
* resize to — so a size has to arrive for the canvas to exist at all.
|
||||
*/
|
||||
beforeAll(() => {
|
||||
window.ResizeObserver = class {
|
||||
constructor(private callback: ResizeObserverCallback) {}
|
||||
|
||||
observe() {
|
||||
this.callback(
|
||||
[{ contentRect: { width: 400, height: 300 } } as ResizeObserverEntry],
|
||||
this as unknown as ResizeObserver,
|
||||
);
|
||||
}
|
||||
|
||||
unobserve() {}
|
||||
|
||||
disconnect() {}
|
||||
};
|
||||
});
|
||||
|
||||
const provider = DashboardProvider.getInstance();
|
||||
|
||||
beforeEach(() => {
|
||||
provider.reset();
|
||||
mockSetOption.mockClear();
|
||||
});
|
||||
|
||||
test('a chart does not draw the name its header already carries', async () => {
|
||||
const id = provider.addBuildingBlock(provider.getRoot().id, 0, {
|
||||
type: 'echarts',
|
||||
props: {
|
||||
dataBinding: { datasource: 1, columns: ['x'], metrics: [] },
|
||||
echartsOptions: {
|
||||
title: { text: 'Sales by Territory' },
|
||||
series: [{ type: 'bar' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
render(<ChartBlock nodeId={id} />);
|
||||
|
||||
await waitFor(() => expect(mockSetOption).toHaveBeenCalled());
|
||||
|
||||
// `blockLabel` reads the title out of this same option to name the block,
|
||||
// so leaving it here would print the chart's name twice, at two sizes, in
|
||||
// two places. The rest of the option has to survive untouched.
|
||||
const [option] = mockSetOption.mock.calls[0];
|
||||
expect(option).not.toHaveProperty('title');
|
||||
expect(option).toHaveProperty('series');
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import * as echarts from 'echarts/core';
|
||||
import type { EChartsCoreOption, ECharts } from 'echarts/core';
|
||||
import {
|
||||
BarChart,
|
||||
BoxplotChart,
|
||||
CustomChart,
|
||||
FunnelChart,
|
||||
GaugeChart,
|
||||
GraphChart,
|
||||
HeatmapChart,
|
||||
LineChart,
|
||||
PieChart,
|
||||
RadarChart,
|
||||
SankeyChart,
|
||||
ScatterChart,
|
||||
SunburstChart,
|
||||
TreeChart,
|
||||
TreemapChart,
|
||||
} from 'echarts/charts';
|
||||
import {
|
||||
AriaComponent,
|
||||
DataZoomComponent,
|
||||
GraphicComponent,
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
MarkAreaComponent,
|
||||
MarkLineComponent,
|
||||
TitleComponent,
|
||||
ToolboxComponent,
|
||||
TooltipComponent,
|
||||
VisualMapComponent,
|
||||
} from 'echarts/components';
|
||||
import { LabelLayout } from 'echarts/features';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import type { dashboard as dashboardApi } from '@apache-superset/core';
|
||||
import { useTheme } from '@apache-superset/core/theme';
|
||||
import { Flex, Loading, Typography } from '@superset-ui/core/components';
|
||||
import { provider, useDashboardRevision } from '../store';
|
||||
import { fetchQueryData } from '../chartData';
|
||||
import { resolveBindings } from '../resolveBindings';
|
||||
|
||||
type DataBindingSpec = dashboardApi.DataBindingSpec;
|
||||
type DataRow = dashboardApi.DataRow;
|
||||
|
||||
// Registers the renderer plus a broad set of chart/component types, once, at
|
||||
// module load. Mirrors plugin-chart-echarts's own Echart.tsx registration —
|
||||
// that component isn't reusable here (not part of the package's public
|
||||
// exports, and has a Redux dependency this page has no reason to take on),
|
||||
// and nothing else guarantees these are registered before a chart renders:
|
||||
// a ChartPlugin's registration is metadata-only, and the real render module
|
||||
// (with its own `use([...])` call) only loads lazily the first time that
|
||||
// specific plugin actually renders — which never happens on this page,
|
||||
// since it bypasses ChartPlugin/SuperChart entirely. AI-authored options can
|
||||
// use any of these series/component types, hence registering broadly rather
|
||||
// than guessing which ones this page will need.
|
||||
echarts.use([
|
||||
CanvasRenderer,
|
||||
BarChart,
|
||||
BoxplotChart,
|
||||
CustomChart,
|
||||
FunnelChart,
|
||||
GaugeChart,
|
||||
GraphChart,
|
||||
HeatmapChart,
|
||||
LineChart,
|
||||
PieChart,
|
||||
RadarChart,
|
||||
SankeyChart,
|
||||
ScatterChart,
|
||||
SunburstChart,
|
||||
TreeChart,
|
||||
TreemapChart,
|
||||
AriaComponent,
|
||||
DataZoomComponent,
|
||||
GraphicComponent,
|
||||
GridComponent,
|
||||
MarkAreaComponent,
|
||||
MarkLineComponent,
|
||||
LegendComponent,
|
||||
ToolboxComponent,
|
||||
TooltipComponent,
|
||||
TitleComponent,
|
||||
VisualMapComponent,
|
||||
LabelLayout,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Tracks an element's rendered pixel size — ECharts has no self-sizing (it
|
||||
* only reacts to explicit `resize({width, height})` calls), so whatever
|
||||
* renders it owns measuring the DOM. This measures both dimensions: a grid
|
||||
* item's cell is always a definite pixel box (its column share of the
|
||||
* container's width, its `rowSpan × rowUnit` height, both enforced by the
|
||||
* parent grid — see `RootGrid`), so there's no case here where a
|
||||
* measured size is ambiguous or collapses to zero the way an unconstrained
|
||||
* flex height could.
|
||||
*/
|
||||
function useElementSize() {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [size, setSize] = useState({ width: 0, height: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return undefined;
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
if (entry) {
|
||||
setSize({
|
||||
width: entry.contentRect.width,
|
||||
height: entry.contentRect.height,
|
||||
});
|
||||
}
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return [ref, size] as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* A minimal, self-contained ECharts canvas — deliberately not the
|
||||
* `<Echart>` wrapper `plugin-chart-echarts` uses internally (that component
|
||||
* isn't part of the package's public exports, and pulls in a Redux
|
||||
* dependency this prototype has no reason to take on). The renderer and
|
||||
* chart/component modules it needs are registered by this module's own
|
||||
* `use([...])` call above, so this only needs to init/setOption/resize.
|
||||
*/
|
||||
function EchartsCanvas({
|
||||
width,
|
||||
height,
|
||||
option,
|
||||
}: {
|
||||
width: number;
|
||||
height: number;
|
||||
option: EChartsCoreOption;
|
||||
}) {
|
||||
const divRef = useRef<HTMLDivElement>(null);
|
||||
const chartRef = useRef<ECharts>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!divRef.current) return undefined;
|
||||
chartRef.current = echarts.init(divRef.current);
|
||||
return () => {
|
||||
chartRef.current?.dispose();
|
||||
chartRef.current = undefined;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (width > 0 && height > 0) {
|
||||
chartRef.current?.resize({ width, height });
|
||||
}
|
||||
}, [width, height]);
|
||||
|
||||
useEffect(() => {
|
||||
// notMerge: true — an AI edit can change series/axis shapes drastically
|
||||
// between calls (e.g. pie -> bar), so stale config from the previous
|
||||
// option must not linger.
|
||||
chartRef.current?.setOption(option, true);
|
||||
}, [option]);
|
||||
|
||||
return <div ref={divRef} style={{ width, height }} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* The built-in `echarts` building block — registered like any other block
|
||||
* (see `registerBuiltInBuildingBlocks`). Fetches its `dataBinding`
|
||||
* (generic, viz_type-less — see `chartData.ts`), resolves any `$bind`
|
||||
* markers in its `echartsOptions` against the results, and draws the
|
||||
* result. No `SuperChart`/`ChartPlugin`/`buildQuery`/`transformProps`
|
||||
* involved — the AI authors close to a real ECharts `option` directly.
|
||||
*/
|
||||
export default function ChartBlock({ nodeId }: { nodeId: string }) {
|
||||
useDashboardRevision();
|
||||
const theme = useTheme();
|
||||
const [containerRef, size] = useElementSize();
|
||||
const [rows, setRows] = useState<DataRow[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const node = provider.getNode(nodeId);
|
||||
const dataBinding = node?.props?.dataBinding as DataBindingSpec | undefined;
|
||||
const bindingKey = JSON.stringify(dataBinding);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dataBinding) {
|
||||
setError('This chart block has no dataBinding.');
|
||||
setRows(null);
|
||||
return undefined;
|
||||
}
|
||||
let cancelled = false;
|
||||
setError(null);
|
||||
setRows(null);
|
||||
fetchQueryData(dataBinding)
|
||||
.then(result => {
|
||||
if (!cancelled) setRows(result.rows);
|
||||
})
|
||||
.catch(e => {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// dataBinding is a fresh object every render — bindingKey is its stable,
|
||||
// value-equality-comparable proxy.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [bindingKey]);
|
||||
|
||||
const option = useMemo(() => {
|
||||
if (!rows) return undefined;
|
||||
const resolved = resolveBindings(
|
||||
(node?.props?.echartsOptions as Record<string, unknown>) ?? {},
|
||||
{ rows, theme },
|
||||
);
|
||||
// The chart's name is drawn by the block's header, which reads it from
|
||||
// this same option (see `blockLabel`). Leaving it here too would print it
|
||||
// twice, at two sizes, in two places — and the header's copy is the one
|
||||
// that sits where every other block's name sits.
|
||||
const withoutTitle = { ...resolved };
|
||||
delete withoutTitle.title;
|
||||
return withoutTitle;
|
||||
}, [node?.props?.echartsOptions, rows, theme]);
|
||||
|
||||
if (!node) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
// Fills the box `BuildingBlockView`'s placement wrapper gives this
|
||||
// block — that wrapper is always a definite pixel box (its column
|
||||
// share of the container's width, its `rowSpan × rowUnit` height),
|
||||
// so this is never zero or ambiguous.
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
// Surface, border and corners belong to the card `BuildingBlockView`
|
||||
// draws around this block and the name above it, so that the name is
|
||||
// inside the frame rather than over it.
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{error && (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
>
|
||||
<Typography.Text type="danger">{error}</Typography.Text>
|
||||
</Flex>
|
||||
)}
|
||||
{!error && !option && (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
>
|
||||
<Loading position="inline-centered" size="s" />
|
||||
</Flex>
|
||||
)}
|
||||
{!error && option && size.width > 0 && size.height > 0 && (
|
||||
<EchartsCanvas
|
||||
width={size.width}
|
||||
height={size.height}
|
||||
option={option}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { ReactElement } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { css, styled } from '@apache-superset/core/theme';
|
||||
import { provider, useDashboardRevision } from '../store';
|
||||
import { FlowContent } from './flowContent';
|
||||
|
||||
/**
|
||||
* The negative margin is what lets `FlowContent`'s own inset (see its own
|
||||
* comment) reach the card's true edges instead of sitting inside it twice —
|
||||
* see `TabsBlock`'s identical `Root`, which this mirrors for the identical
|
||||
* reason. The top is left alone, since this box already starts below the
|
||||
* card's header rather than at its true top edge.
|
||||
*/
|
||||
const Root = styled.div`
|
||||
${({ theme }) => css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0 -${theme.padding}px -${theme.padding}px;
|
||||
`}
|
||||
`;
|
||||
|
||||
/**
|
||||
* The built-in `collapsible` building block — a container that holds a
|
||||
* single child, shown or hidden behind one toggle. Registered like any
|
||||
* other block (see `registerBuiltInBuildingBlocks`), and like `tabs`, it has
|
||||
* no grid of its own.
|
||||
*
|
||||
* The toggle itself is not drawn here: `BuildingBlockView`'s own header
|
||||
* already carries this block's name and its remove control for every block
|
||||
* type, and a second bar in the content below repeating the same idea would
|
||||
* make this the only block type with two header-shaped rows stacked on top
|
||||
* of each other. `blockHeaderControl` puts the toggle in that same header,
|
||||
* beside the remove control, so a collapsible block is — per its own name —
|
||||
* a title and its content, nothing else. This component's whole job is
|
||||
* therefore just the content half: nothing at all while collapsed (the
|
||||
* header above still reads fine on its own), the flowed child once
|
||||
* expanded.
|
||||
*
|
||||
* `props.collapsed` is what `blockHeaderControl`'s toggle flips, and it also
|
||||
* resizes this node's own `layout.rowSpan` down to a header-only height
|
||||
* while collapsed (see its own comment) — a fact about the dashboard's own
|
||||
* state an author sets deliberately, not a transient fact about who is
|
||||
* looking at it right now, so it is persisted rather than kept the way
|
||||
* `TabsBlock`'s active pane is.
|
||||
*
|
||||
* There is no intermediate pane node the way `tabs` has one per tab — one
|
||||
* child is already the simplest container `FlowContent` can hold, so
|
||||
* `nodeId` itself is the flow area's own `containerId`. `accepts` closes
|
||||
* the drop target the moment that one child exists, which is what makes
|
||||
* "single child" an actual constraint rather than a suggestion.
|
||||
*/
|
||||
export default function CollapsibleBlock({
|
||||
nodeId,
|
||||
}: {
|
||||
nodeId: string;
|
||||
}): ReactElement | null {
|
||||
useDashboardRevision();
|
||||
const node = provider.getNode(nodeId);
|
||||
if (!node) return null;
|
||||
|
||||
const collapsed = Boolean(node.props?.collapsed);
|
||||
if (collapsed) return null;
|
||||
|
||||
const children = node.children ?? [];
|
||||
|
||||
return (
|
||||
<Root data-test={`collapsible-${nodeId}`}>
|
||||
<FlowContent
|
||||
containerId={nodeId}
|
||||
accepts={children.length === 0}
|
||||
emptyTitle={t('Nothing here yet')}
|
||||
emptyDescription={t('Ask the assistant to add something here.')}
|
||||
dataTest={`collapsible-content-${nodeId}`}
|
||||
/>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { SafeMarkdown } from '@superset-ui/core/components';
|
||||
import { provider, useDashboardRevision } from '../store';
|
||||
|
||||
/**
|
||||
* The built-in `markdown` building block — registered like any other block
|
||||
* (see `registerBuiltInBuildingBlocks`). Fills the box `BuildingBlockView`'s
|
||||
* placement wrapper gives it (`width`/`height: 100%`) rather than resolving
|
||||
* its own grid placement.
|
||||
*/
|
||||
export default function MarkdownBlock({ nodeId }: { nodeId: string }) {
|
||||
useDashboardRevision();
|
||||
const node = provider.getNode(nodeId);
|
||||
if (!node) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
// Surface, border, corners and inset all belong to the card
|
||||
// `BuildingBlockView` draws around this block and the name above
|
||||
// it, so that the name is inside the frame rather than over it.
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
<SafeMarkdown source={String(node.props?.content ?? '')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { dashboard as dashboardApi } from '@apache-superset/core';
|
||||
import { styled, useTheme } from '@apache-superset/core/theme';
|
||||
import { Flex, Loading, Typography } from '@superset-ui/core/components';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { provider, useDashboardRevision } from '../store';
|
||||
import { fetchQueryData } from '../chartData';
|
||||
|
||||
type DataBindingSpec = dashboardApi.DataBindingSpec;
|
||||
type Theme = ReturnType<typeof useTheme>;
|
||||
|
||||
interface DeltaSpec {
|
||||
value: number;
|
||||
direction?: 'up' | 'down' | 'flat';
|
||||
suffix?: string;
|
||||
}
|
||||
|
||||
// The && bumps specificity above antd's own Title margin rules, which a
|
||||
// plain inline style prop can't override (same trick as
|
||||
// `DashboardBuilderV2`'s own `HeaderTitle`) — without it the default margin
|
||||
// throws off vertical centering against the label/delta stacked below it.
|
||||
const BigNumber = styled(Typography.Title)`
|
||||
&& {
|
||||
margin: 0;
|
||||
line-height: 1.1;
|
||||
}
|
||||
`;
|
||||
|
||||
function formatNumber(value: unknown, decimals: number): string {
|
||||
const num = typeof value === 'number' ? value : Number(value);
|
||||
if (value == null || Number.isNaN(num))
|
||||
return value == null ? '—' : String(value);
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
}).format(num);
|
||||
}
|
||||
|
||||
function DeltaIndicator({ delta, theme }: { delta: DeltaSpec; theme: Theme }) {
|
||||
const direction =
|
||||
delta.direction ??
|
||||
(delta.value > 0 ? 'up' : delta.value < 0 ? 'down' : 'flat');
|
||||
const color =
|
||||
direction === 'up'
|
||||
? theme.colorSuccess
|
||||
: direction === 'down'
|
||||
? theme.colorError
|
||||
: theme.colorTextSecondary;
|
||||
const Icon =
|
||||
direction === 'up'
|
||||
? Icons.CaretUpOutlined
|
||||
: direction === 'down'
|
||||
? Icons.CaretDownOutlined
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Flex align="center" gap={4}>
|
||||
{Icon && <Icon style={{ color }} />}
|
||||
<Typography.Text style={{ color }}>
|
||||
{formatNumber(Math.abs(delta.value), 1)}
|
||||
{delta.suffix ?? ''}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The built-in `metric-tile` building block ("big number") — registered
|
||||
* like any other block (see `registerBuiltInBuildingBlocks`). Fetches its
|
||||
* `dataBinding` the same generic way `ChartBlock`/`AgGridTableBlock` do, and
|
||||
* renders the first result row's value directly as text — no ECharts
|
||||
* gauge/`graphic` text workaround (what an AI reached for before this block
|
||||
* existed), and no `$bind` splicing, since there's nothing here to splice
|
||||
* into: the whole point of this block is a single live number.
|
||||
*
|
||||
* `dataBinding` is expected to resolve one column (one metric, no
|
||||
* `dimensions`) — the value shown is always the *first* row's value for
|
||||
* that column; a tile shows one number, so grouping isn't meaningful here
|
||||
* the way it is for a chart or table.
|
||||
*/
|
||||
export default function MetricTileBlock({ nodeId }: { nodeId: string }) {
|
||||
useDashboardRevision();
|
||||
const theme = useTheme();
|
||||
const [value, setValue] = useState<unknown>(undefined);
|
||||
const [columnLabel, setColumnLabel] = useState<string | null>(null);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const node = provider.getNode(nodeId);
|
||||
const dataBinding = node?.props?.dataBinding as DataBindingSpec | undefined;
|
||||
const bindingKey = JSON.stringify(dataBinding);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dataBinding) {
|
||||
setError('This metric tile has no dataBinding.');
|
||||
setLoaded(false);
|
||||
return undefined;
|
||||
}
|
||||
let cancelled = false;
|
||||
setError(null);
|
||||
setLoaded(false);
|
||||
fetchQueryData(dataBinding)
|
||||
.then(result => {
|
||||
if (cancelled) return;
|
||||
const [column] = result.columns;
|
||||
setColumnLabel(column ?? null);
|
||||
setValue(column ? result.rows[0]?.[column] : undefined);
|
||||
setLoaded(true);
|
||||
})
|
||||
.catch(e => {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// dataBinding is a fresh object every render — bindingKey is its stable,
|
||||
// value-equality-comparable proxy.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [bindingKey]);
|
||||
|
||||
if (!node) return null;
|
||||
|
||||
const decimals = (node.props?.decimals as number | undefined) ?? 0;
|
||||
const prefix = (node.props?.prefix as string | undefined) ?? '';
|
||||
const suffix = (node.props?.suffix as string | undefined) ?? '';
|
||||
const label = (node.props?.label as string | undefined) ?? columnLabel ?? '';
|
||||
const delta = node.props?.delta as DeltaSpec | undefined;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
vertical
|
||||
justify="center"
|
||||
style={{
|
||||
// Fills the box `BuildingBlockView`'s placement wrapper gives this
|
||||
// block — always a definite pixel box, same as `ChartBlock`.
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
// Surface, border, corners and inset all belong to the card
|
||||
// `BuildingBlockView` draws around this block and the name above
|
||||
// it, so that the name is inside the frame rather than over it.
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{error && <Typography.Text type="danger">{error}</Typography.Text>}
|
||||
{!error && !loaded && <Loading position="inline-centered" size="s" />}
|
||||
{!error && loaded && (
|
||||
<>
|
||||
<BigNumber level={2}>
|
||||
{prefix}
|
||||
{formatNumber(value, decimals)}
|
||||
{suffix}
|
||||
</BigNumber>
|
||||
{label && (
|
||||
<Typography.Text
|
||||
type="secondary"
|
||||
style={{ marginTop: theme.marginXS }}
|
||||
>
|
||||
{label}
|
||||
</Typography.Text>
|
||||
)}
|
||||
{delta && (
|
||||
<div style={{ marginTop: theme.marginXS }}>
|
||||
<DeltaIndicator delta={delta} theme={theme} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { act, fireEvent, render, screen } from 'spec/helpers/testing-library';
|
||||
import DashboardProvider from '../DashboardProvider';
|
||||
import { registerBuiltInBuildingBlocks } from '../registerBuiltInBuildingBlocks';
|
||||
import TabsBlock from './TabsBlock';
|
||||
|
||||
const provider = DashboardProvider.getInstance();
|
||||
|
||||
beforeAll(() => {
|
||||
registerBuiltInBuildingBlocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
provider.reset();
|
||||
});
|
||||
|
||||
/** Creates a bare `tabs` node under the root — rendering is each test's own call, made once its setup (if any) is done. */
|
||||
const createTabs = (): string => {
|
||||
const rootId = provider.getRoot().id;
|
||||
return provider.addBuildingBlock(rootId, 0, { type: 'tabs' });
|
||||
};
|
||||
|
||||
test('a freshly placed tabs block already has one tab, selected', () => {
|
||||
const tabsId = createTabs();
|
||||
render(<TabsBlock nodeId={tabsId} />);
|
||||
|
||||
// A tabs block with nothing to switch between is not a useful starting
|
||||
// point, so this fills it in rather than leaving it for the "+" — see
|
||||
// TabsBlock's own layout effect.
|
||||
const tab = screen.getByRole('tab', { name: 'Tab 1' });
|
||||
expect(tab).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.getByText('Nothing in this tab yet')).toBeVisible();
|
||||
|
||||
const paneId = provider.getNode(tabsId)?.children?.[0] as string;
|
||||
expect(provider.getNode(paneId)?.props?.label).toBe('Tab 1');
|
||||
expect(screen.queryByTestId(`tab-remove-${paneId}`)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('adding a tab is named after its own position, not the count at click time', () => {
|
||||
const tabsId = createTabs();
|
||||
render(<TabsBlock nodeId={tabsId} />);
|
||||
|
||||
// Tab 1 already exists (see the test above) — each click adds the next.
|
||||
fireEvent.click(screen.getByTestId(`tabs-add-${tabsId}`));
|
||||
fireEvent.click(screen.getByTestId(`tabs-add-${tabsId}`));
|
||||
|
||||
expect(screen.getByRole('tab', { name: 'Tab 1' })).toBeVisible();
|
||||
expect(screen.getByRole('tab', { name: 'Tab 2' })).toBeVisible();
|
||||
expect(screen.getByRole('tab', { name: 'Tab 3' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('the only tab offers no way to remove itself', () => {
|
||||
const tabsId = createTabs();
|
||||
render(<TabsBlock nodeId={tabsId} />);
|
||||
|
||||
const paneId = provider.getNode(tabsId)?.children?.[0] as string;
|
||||
|
||||
// Removing it would leave a blank tabs block the layout effect would
|
||||
// immediately refill anyway, which reads as the control having silently
|
||||
// done nothing.
|
||||
expect(screen.queryByTestId(`tab-remove-${paneId}`)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('a second tab can be removed, falling back to the remaining one', () => {
|
||||
const tabsId = createTabs();
|
||||
render(<TabsBlock nodeId={tabsId} />);
|
||||
const firstPaneId = provider.getNode(tabsId)?.children?.[0] as string;
|
||||
|
||||
fireEvent.click(screen.getByTestId(`tabs-add-${tabsId}`));
|
||||
const secondTab = screen.getByRole('tab', { name: 'Tab 2' });
|
||||
fireEvent.click(secondTab);
|
||||
fireEvent.click(
|
||||
screen.getByTestId(`tab-remove-${provider.getNode(tabsId)?.children?.[1]}`),
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('tab', { name: 'Tab 2' })).not.toBeInTheDocument();
|
||||
expect(provider.getNode(tabsId)?.children).toEqual([firstPaneId]);
|
||||
expect(screen.getByRole('tab', { name: 'Tab 1' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
test('dropping a palette block onto the active pane places it there', () => {
|
||||
const tabsId = createTabs();
|
||||
const paneId = provider.addBuildingBlock(tabsId, 0, {
|
||||
type: 'tab',
|
||||
props: { label: 'Overview' },
|
||||
});
|
||||
render(<TabsBlock nodeId={tabsId} />);
|
||||
|
||||
const data = new Map([
|
||||
['application/x-dashboard-building-block', 'markdown'],
|
||||
]);
|
||||
fireEvent.drop(screen.getByTestId(`tabs-panes-${tabsId}`), {
|
||||
dataTransfer: {
|
||||
types: [...data.keys()],
|
||||
getData: (key: string) => data.get(key) ?? '',
|
||||
dropEffect: '',
|
||||
effectAllowed: '',
|
||||
},
|
||||
});
|
||||
|
||||
expect(provider.getNode(paneId)?.children).toHaveLength(1);
|
||||
const droppedId = provider.getNode(paneId)?.children?.[0] as string;
|
||||
expect(provider.getNode(droppedId)?.type).toBe('markdown');
|
||||
});
|
||||
|
||||
test('clicking a tab shows its own content and hides the other panes', async () => {
|
||||
const tabsId = createTabs();
|
||||
const firstPane = provider.addBuildingBlock(tabsId, 0, {
|
||||
type: 'tab',
|
||||
props: { label: 'Overview' },
|
||||
});
|
||||
const secondPane = provider.addBuildingBlock(tabsId, 1, {
|
||||
type: 'tab',
|
||||
props: { label: 'Detail' },
|
||||
});
|
||||
provider.addBuildingBlock(firstPane, 0, {
|
||||
type: 'markdown',
|
||||
props: { content: 'Overview content' },
|
||||
});
|
||||
provider.addBuildingBlock(secondPane, 0, {
|
||||
type: 'markdown',
|
||||
props: { content: 'Detail content' },
|
||||
});
|
||||
render(<TabsBlock nodeId={tabsId} />);
|
||||
|
||||
// The first pane is active by default. `findByText` rather than
|
||||
// `getByText`: `SafeMarkdown` lazy-loads `react-markdown` itself and
|
||||
// renders nothing until that resolves, so the text is not necessarily
|
||||
// there yet on the tick right after `render`.
|
||||
expect(await screen.findByText('Overview content')).toBeVisible();
|
||||
expect(screen.queryByText('Detail content')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Detail' }));
|
||||
|
||||
expect(screen.queryByText('Overview content')).not.toBeInTheDocument();
|
||||
expect(await screen.findByText('Detail content')).toBeVisible();
|
||||
});
|
||||
|
||||
test('a flowed block with no height of its own flexes to fill the area, and fixes to a number once grown from the keyboard', () => {
|
||||
const tabsId = createTabs();
|
||||
const pane = provider.addBuildingBlock(tabsId, 0, {
|
||||
type: 'tab',
|
||||
props: { label: 'Overview' },
|
||||
});
|
||||
const chartId = provider.addBuildingBlock(pane, 0, {
|
||||
type: 'markdown',
|
||||
props: { content: 'Chart stand-in' },
|
||||
});
|
||||
render(<TabsBlock nodeId={tabsId} />);
|
||||
|
||||
const handle = screen.getByTestId(`flow-resize-${chartId}`);
|
||||
// No `rowSpan` of its own yet, so there is no number to report — the
|
||||
// block is flexing to fill the area rather than sitting at a fixed size.
|
||||
expect(handle).not.toHaveAttribute('aria-valuenow');
|
||||
|
||||
handle.focus();
|
||||
fireEvent.keyDown(handle, { key: 'ArrowDown' });
|
||||
|
||||
// The first resize is what fixes it to an explicit size — measured off
|
||||
// the rendered box in a real browser, or `DEFAULT_FLOW_ITEM_HEIGHT` here,
|
||||
// where nothing is actually laid out to measure.
|
||||
expect(handle).toHaveAttribute('aria-valuenow', '376');
|
||||
expect(provider.getNode(chartId)?.layout?.rowSpan).toBe(376);
|
||||
});
|
||||
|
||||
test('a flowed block cannot be shrunk past the minimum height', () => {
|
||||
const tabsId = createTabs();
|
||||
const pane = provider.addBuildingBlock(tabsId, 0, {
|
||||
type: 'tab',
|
||||
props: { label: 'Overview' },
|
||||
});
|
||||
const chartId = provider.addBuildingBlock(pane, 0, {
|
||||
type: 'markdown',
|
||||
layout: { rowSpan: 124 },
|
||||
props: { content: 'Chart stand-in' },
|
||||
});
|
||||
render(<TabsBlock nodeId={tabsId} />);
|
||||
|
||||
const handle = screen.getByTestId(`flow-resize-${chartId}`);
|
||||
handle.focus();
|
||||
fireEvent.keyDown(handle, { key: 'ArrowUp' });
|
||||
fireEvent.keyDown(handle, { key: 'ArrowUp' });
|
||||
|
||||
expect(provider.getNode(chartId)?.layout?.rowSpan).toBe(120);
|
||||
});
|
||||
|
||||
test('removing the active pane falls back to the first remaining tab', () => {
|
||||
const tabsId = createTabs();
|
||||
provider.addBuildingBlock(tabsId, 0, {
|
||||
type: 'tab',
|
||||
props: { label: 'Overview' },
|
||||
});
|
||||
const detailPane = provider.addBuildingBlock(tabsId, 1, {
|
||||
type: 'tab',
|
||||
props: { label: 'Detail' },
|
||||
});
|
||||
render(<TabsBlock nodeId={tabsId} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Detail' }));
|
||||
act(() => provider.removeBuildingBlock(detailPane));
|
||||
|
||||
expect(screen.getByRole('tab', { name: 'Overview' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useLayoutEffect, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { css, styled } from '@apache-superset/core/theme';
|
||||
import { ActionButton } from '@superset-ui/core/components';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { provider, useDashboardRevision } from '../store';
|
||||
import { FlowContent } from './flowContent';
|
||||
|
||||
/**
|
||||
* A pane's own child type — not registered as a building block in its own
|
||||
* right (see `registerBuiltInBuildingBlocks`), since nothing ever resolves
|
||||
* one through `resolveBuildingBlockView`: this component renders a pane's
|
||||
* `children` directly rather than rendering the pane node itself through
|
||||
* `BuildingBlockView`. It only needs to be a *container* type (so
|
||||
* `addBuildingBlock` gives it a `children` array) — see
|
||||
* `registerContainerType` in `DashboardProvider`.
|
||||
*/
|
||||
export const TAB_TYPE = 'tab';
|
||||
|
||||
/**
|
||||
* The negative margin is what keeps the tab bar full-width.
|
||||
*
|
||||
* `BuildingBlockView` now insets every block's content by the card's own
|
||||
* padding (see its own comment) — right for a chart or a table, which is a
|
||||
* single thing filling the card, but wrong for a strip of tabs, which reads
|
||||
* as cut short the moment it does not reach the card's edges the way a
|
||||
* header would. `FlowContent` keeps its own inset (see its own comment) for
|
||||
* the content flowed into a pane, which is the single-thing case the
|
||||
* padding was written for; this cancels that same padding for the chrome
|
||||
* around it — on three sides only. The top is not the card's own padding to
|
||||
* begin with:
|
||||
* this box starts right where the header already ends, not at the card's
|
||||
* true top edge, so cancelling it as well pulled the tab bar up past the
|
||||
* header instead of just to the card's left/right/bottom edges, overlapping
|
||||
* the two.
|
||||
*/
|
||||
const Root = styled.div`
|
||||
${({ theme }) => css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0 -${theme.padding}px -${theme.padding}px;
|
||||
`}
|
||||
`;
|
||||
|
||||
const TabBar = styled.div`
|
||||
${({ theme }) => css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${theme.sizeUnit}px;
|
||||
flex: 0 0 auto;
|
||||
padding: 0 ${theme.sizeUnit * 2}px;
|
||||
border-bottom: 1px solid ${theme.colorBorderSecondary};
|
||||
overflow-x: auto;
|
||||
`}
|
||||
`;
|
||||
|
||||
const TabButton = styled.button<{ $active: boolean }>`
|
||||
${({ theme, $active }) => css`
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: none;
|
||||
flex: 0 0 auto;
|
||||
padding: ${theme.sizeUnit * 2}px ${theme.sizeUnit * 3}px;
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
font-weight: ${$active ? theme.fontWeightStrong : theme.fontWeightNormal};
|
||||
color: ${$active ? theme.colorPrimaryText : theme.colorTextSecondary};
|
||||
border-bottom: 2px solid ${$active ? theme.colorPrimary : 'transparent'};
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color ${theme.motionDurationMid},
|
||||
border-color ${theme.motionDurationMid};
|
||||
|
||||
&:hover {
|
||||
color: ${theme.colorPrimaryText};
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid ${theme.colorPrimaryBorder};
|
||||
outline-offset: -2px;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
/**
|
||||
* A tab and its own remove control, as siblings rather than one nested
|
||||
* inside the other.
|
||||
*
|
||||
* `TabButton` is a real `<button>` — switching tabs is what most presses on
|
||||
* it mean, and a button is what answers Enter/Space and reads as one to a
|
||||
* screen reader. The remove control is `ActionButton`, itself a `<button>`,
|
||||
* and a button inside a button is invalid HTML that nothing downstream can
|
||||
* reliably navigate into. Wrapped here as two controls sharing a row
|
||||
* instead, the same shape `BuildingBlockView`'s own header takes for a name
|
||||
* and its own remove control.
|
||||
*/
|
||||
const TabItem = styled.span`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
`;
|
||||
|
||||
/** What a pane is called before an author (or the assistant) names it. */
|
||||
const untitledLabel = (index: number): string => t('Tab %s', index + 1);
|
||||
|
||||
/**
|
||||
* The built-in `tabs` building block — a container whose own children (each
|
||||
* a `tab` pane, itself a container) are switchable rather than all shown at
|
||||
* once. Registered like any other block (see `registerBuiltInBuildingBlocks`),
|
||||
* it holds children of its own like the root's own grid does — but unlike
|
||||
* the root, it has no grid: which pane is showing is this component's own
|
||||
* concern, not a `layout` fact the document carries. Per the
|
||||
* composition/layout design doc, that's the point — a container answers
|
||||
* "how do I arrange what's inside me" for itself, and this is simply one
|
||||
* answer among many, no more privileged than the root's own grid.
|
||||
*
|
||||
* Which pane is *active* is intentionally not persisted: it's a fact about
|
||||
* who's looking at the dashboard right now, not about the dashboard itself
|
||||
* (the same reasoning `DashboardProvider`'s own `selection` field is
|
||||
* host-internal rather than part of a node). It resets to the first pane
|
||||
* whenever the previously active one no longer exists — most commonly right
|
||||
* after that pane is removed, or on a first render with no pane yet.
|
||||
*/
|
||||
export default function TabsBlock({
|
||||
nodeId,
|
||||
}: {
|
||||
nodeId: string;
|
||||
}): ReactElement | null {
|
||||
useDashboardRevision();
|
||||
const node = provider.getNode(nodeId);
|
||||
const panes = node?.children ?? [];
|
||||
|
||||
const [activeTabId, setActiveTabId] = useState<string | undefined>(panes[0]);
|
||||
const activeIsValid =
|
||||
activeTabId !== undefined && panes.includes(activeTabId);
|
||||
if (!activeIsValid && activeTabId !== panes[0]) {
|
||||
setActiveTabId(panes[0]);
|
||||
}
|
||||
|
||||
const addTab = (): void => {
|
||||
const id = provider.addBuildingBlock(nodeId, panes.length, {
|
||||
type: TAB_TYPE,
|
||||
props: { label: untitledLabel(panes.length) },
|
||||
});
|
||||
setActiveTabId(id);
|
||||
};
|
||||
|
||||
// A tabs block with nothing in it yet has nothing to switch between —
|
||||
// the first thing anyone would do with one is press + once anyway, so
|
||||
// this does it for them. An effect rather than done inline during render:
|
||||
// render must not mutate the document itself, only read it. Laid out
|
||||
// rather than a plain effect so the fill-in happens before the browser
|
||||
// ever paints the empty state, which would otherwise flash for one frame
|
||||
// on every block placed from the palette.
|
||||
useLayoutEffect(() => {
|
||||
if (node && panes.length === 0) {
|
||||
addTab();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [nodeId, node, panes.length]);
|
||||
|
||||
if (!node) return null;
|
||||
|
||||
return (
|
||||
<Root data-test={`tabs-${nodeId}`}>
|
||||
<TabBar role="tablist" aria-label={t('Tabs')}>
|
||||
{panes.map((paneId, index) => {
|
||||
const pane = provider.getNode(paneId);
|
||||
const label =
|
||||
(pane?.props?.label as string | undefined) || untitledLabel(index);
|
||||
const active = paneId === activeTabId;
|
||||
return (
|
||||
<TabItem key={paneId}>
|
||||
<TabButton
|
||||
type="button"
|
||||
role="tab"
|
||||
tabIndex={0}
|
||||
aria-selected={active}
|
||||
$active={active}
|
||||
data-test={`tab-${paneId}`}
|
||||
onClick={() => setActiveTabId(paneId)}
|
||||
>
|
||||
{label}
|
||||
</TabButton>
|
||||
{/* Offered once there is a second tab to fall back to —
|
||||
removing the only one just left a blank tabs block the
|
||||
effect above would immediately refill, which reads as the
|
||||
control having silently done nothing. */}
|
||||
{panes.length > 1 && (
|
||||
<ActionButton
|
||||
label={t('Remove tab')}
|
||||
tooltip={t('Remove tab')}
|
||||
placement="bottom"
|
||||
dataTest={`tab-remove-${paneId}`}
|
||||
icon={<Icons.CloseOutlined iconSize="s" />}
|
||||
onClick={() => provider.removeBuildingBlock(paneId)}
|
||||
/>
|
||||
)}
|
||||
</TabItem>
|
||||
);
|
||||
})}
|
||||
<ActionButton
|
||||
label={t('Add tab')}
|
||||
tooltip={t('Add tab')}
|
||||
placement="bottom"
|
||||
dataTest={`tabs-add-${nodeId}`}
|
||||
icon={<Icons.PlusOutlined iconSize="s" />}
|
||||
onClick={addTab}
|
||||
/>
|
||||
</TabBar>
|
||||
{/* `activeTabId` is only briefly undefined, on the very first render
|
||||
before the layout effect above fills the tabs block in — skipping
|
||||
`FlowContent` for that one tick is what keeps this from ever
|
||||
needing a pane id it does not have yet. */}
|
||||
{activeTabId && (
|
||||
<FlowContent
|
||||
containerId={activeTabId}
|
||||
emptyTitle={t('Nothing in this tab yet')}
|
||||
emptyDescription={t('Ask the assistant to add something here.')}
|
||||
dataTest={`tabs-panes-${nodeId}`}
|
||||
/>
|
||||
)}
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview A container's "flow" — one child under the next, each
|
||||
* resizable and the whole thing a drop target — shared by every built-in
|
||||
* container that is not the root's own grid (`tabs`, `collapsible`,
|
||||
* `carousel`). Each of those still decides for itself how many flow areas
|
||||
* it has and which one is currently showing (a single one for
|
||||
* `collapsible`, one of several panes for `tabs`/`carousel`) — that part is
|
||||
* genuinely each container's own business (composition/layout design doc).
|
||||
* What they do not each reimplement is what a *single* flow area is once
|
||||
* you have picked one: a resizable stack of blocks, droppable from the
|
||||
* palette, exactly like `RootGrid`'s own drop target for the reason given
|
||||
* on `FlowContent` below.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { KeyboardEvent, PointerEvent, ReactElement } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { css, styled } from '@apache-superset/core/theme';
|
||||
import { EmptyState } from '@superset-ui/core/components';
|
||||
import { provider } from '../store';
|
||||
import { PALETTE_MIME, placeBlock } from '../placement';
|
||||
import BuildingBlockView from '../BuildingBlockView';
|
||||
|
||||
/**
|
||||
* The height `FlowItem` falls back to only when it has to measure *something*
|
||||
* and nothing has rendered yet to measure (see `currentHeight` there) — not
|
||||
* a block's actual starting height any more. A block with no `layout.rowSpan`
|
||||
* of its own (nothing has resized it yet) flexes to fill whatever room the
|
||||
* flow area actually has instead, which is a real available-height number,
|
||||
* not a guess at one.
|
||||
*/
|
||||
export const DEFAULT_FLOW_ITEM_HEIGHT = 360;
|
||||
|
||||
/** How short a resize may make a flowed block — short of this and there is nothing left to grab the handle off of. */
|
||||
export const MIN_FLOW_ITEM_HEIGHT = 120;
|
||||
|
||||
/** How far one arrow press resizes a block. */
|
||||
const RESIZE_STEP = 16;
|
||||
|
||||
/**
|
||||
* A flowed block's own resize handle.
|
||||
*
|
||||
* `BuildingBlockView` renders whatever it is given as `children` last, after
|
||||
* its own header and content — the slot `react-grid-layout` injects its
|
||||
* handles through on the root's own grid. A flowed block has no grid, and so
|
||||
* no react-grid-layout handle of its own, but the slot serves the same
|
||||
* purpose for the same reason: this needs to sit inside the block's own box,
|
||||
* on top of it, without becoming part of what the block itself renders.
|
||||
*
|
||||
* A strip along the whole bottom edge rather than a corner square: this
|
||||
* resizes one axis, not two (a flow's blocks are already full width, so
|
||||
* there is no second dimension to change), and a full-width strip is a
|
||||
* wider target than a handful of pixels in a corner would be.
|
||||
*/
|
||||
const ResizeGrip = styled.div`
|
||||
${({ theme }) => css`
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: ${theme.sizeUnit * 2}px;
|
||||
cursor: row-resize;
|
||||
touch-action: none;
|
||||
z-index: 1;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 2px;
|
||||
background-color: transparent;
|
||||
transition: background-color ${theme.motionDurationMid};
|
||||
}
|
||||
|
||||
&:hover::after,
|
||||
&:focus-visible::after,
|
||||
&:active::after {
|
||||
background-color: ${theme.colorPrimary};
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
/**
|
||||
* One block, flowed into an area, with its own height and a way to change
|
||||
* it.
|
||||
*
|
||||
* The height rendered is `layout.rowSpan` once an author has set one —
|
||||
* reused rather than a field of its own, for the same reason `colSpan` is
|
||||
* meaningless outside a grid and nobody invented a second name for "this
|
||||
* many columns" to go with it: `rowSpan` already means "how tall," and a
|
||||
* flow container is free to read it in its own unit (a pixel, here) the same
|
||||
* way a grid container reads it in row tracks (see the composition/layout
|
||||
* design doc — a container's own arrangement is its own business).
|
||||
*
|
||||
* `height` (and `liveHeight`, its local draft) is `undefined` for a block
|
||||
* nobody has resized yet — rather than defaulting it to some fixed number,
|
||||
* this flexes (`flex: 1 1 auto`) to fill whatever the flow area actually has
|
||||
* available, the same way the very first block dropped into an empty area
|
||||
* should read as filling it rather than sitting in a corner of it. The
|
||||
* moment an author (or the resize handle below) sets an explicit height,
|
||||
* that becomes authoritative and this switches to a fixed one instead
|
||||
* (`flex: 0 0 auto`) — a size someone chose is never overridden by whatever
|
||||
* space happens to be around it.
|
||||
*
|
||||
* The drag is tracked locally and committed with `provider.updateLayout`
|
||||
* only once it ends, the same reason `RootGrid` commits a resize on
|
||||
* `onResizeStop` rather than on every intermediate frame: a revision tick —
|
||||
* and the re-render of everything subscribed to it — per pixel dragged
|
||||
* would make the drag itself the slow part of resizing.
|
||||
*/
|
||||
export function FlowItem({
|
||||
nodeId,
|
||||
height,
|
||||
}: {
|
||||
nodeId: string;
|
||||
height: number | undefined;
|
||||
}): ReactElement {
|
||||
const [liveHeight, setLiveHeight] = useState(height);
|
||||
// What was accepted replaces the draft, because the draft was a view of
|
||||
// it: a resize the assistant makes while this is on screen has to show.
|
||||
useEffect(() => setLiveHeight(height), [height]);
|
||||
const from = useRef<{ y: number; height: number } | null>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// A resize gesture always needs a starting height to measure a delta
|
||||
// against — for a block that flexed to fill its space rather than being
|
||||
// given one, that is only ever knowable by measuring what got rendered,
|
||||
// never by reading `liveHeight` (which is `undefined` for exactly this
|
||||
// block). `|| DEFAULT_FLOW_ITEM_HEIGHT` rather than `??`: a measured `0`
|
||||
// (nothing painted yet to measure, or a collapsed flex box) is exactly as
|
||||
// unusable a starting point for a resize as no measurement at all.
|
||||
const currentHeight = (): number => {
|
||||
if (liveHeight !== undefined) return liveHeight;
|
||||
return (
|
||||
wrapperRef.current?.getBoundingClientRect().height ||
|
||||
DEFAULT_FLOW_ITEM_HEIGHT
|
||||
);
|
||||
};
|
||||
|
||||
const startDrag = (event: PointerEvent<HTMLDivElement>): void => {
|
||||
// This block sits inside the root's own grid (the flow's own container
|
||||
// is a grid item like any other), whose react-draggable instance
|
||||
// otherwise reads this same pointer-down as the start of a drag on that
|
||||
// item — the whole container moving on the root grid instead of this
|
||||
// one block resizing inside it. `data-block-resize` is `RootGrid`'s own
|
||||
// `draggableCancel` half of the same guard (see `BuildingBlockView`'s
|
||||
// `data-block-remove`, which exists for the identical reason).
|
||||
event.stopPropagation();
|
||||
from.current = { y: event.clientY, height: currentHeight() };
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
};
|
||||
|
||||
const drag = (event: PointerEvent<HTMLDivElement>): void => {
|
||||
if (from.current !== null) {
|
||||
setLiveHeight(
|
||||
Math.max(
|
||||
MIN_FLOW_ITEM_HEIGHT,
|
||||
from.current.height + event.clientY - from.current.y,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const endDrag = (event: PointerEvent<HTMLDivElement>): void => {
|
||||
// `liveHeight` only turns into a real number once `drag` has actually
|
||||
// fired at least once — a press and release with no movement in between
|
||||
// is not a resize, and must not fix a block that was flexing in place
|
||||
// to whatever `currentHeight` happened to measure at that instant.
|
||||
if (from.current !== null && liveHeight !== undefined) {
|
||||
provider.updateLayout(nodeId, { rowSpan: liveHeight });
|
||||
}
|
||||
from.current = null;
|
||||
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
||||
};
|
||||
|
||||
const resize = (event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
const moves: Record<string, (current: number) => number> = {
|
||||
ArrowDown: current => current + RESIZE_STEP,
|
||||
ArrowUp: current => Math.max(MIN_FLOW_ITEM_HEIGHT, current - RESIZE_STEP),
|
||||
};
|
||||
const move = moves[event.key];
|
||||
if (move !== undefined) {
|
||||
event.preventDefault();
|
||||
const next = move(currentHeight());
|
||||
setLiveHeight(next);
|
||||
provider.updateLayout(nodeId, { rowSpan: next });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
style={
|
||||
liveHeight === undefined
|
||||
? {
|
||||
width: '100%',
|
||||
flex: '1 1 auto',
|
||||
minHeight: MIN_FLOW_ITEM_HEIGHT,
|
||||
position: 'relative',
|
||||
}
|
||||
: {
|
||||
width: '100%',
|
||||
height: liveHeight,
|
||||
flex: '0 0 auto',
|
||||
position: 'relative',
|
||||
}
|
||||
}
|
||||
>
|
||||
<BuildingBlockView
|
||||
nodeId={nodeId}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
>
|
||||
<ResizeGrip
|
||||
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
aria-label={t('Resize block')}
|
||||
aria-valuenow={liveHeight}
|
||||
aria-valuemin={MIN_FLOW_ITEM_HEIGHT}
|
||||
tabIndex={0}
|
||||
data-test={`flow-resize-${nodeId}`}
|
||||
data-block-resize
|
||||
onPointerDown={startDrag}
|
||||
onPointerMove={drag}
|
||||
onPointerUp={endDrag}
|
||||
onKeyDown={resize}
|
||||
/>
|
||||
</BuildingBlockView>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const FlowArea = styled.div`
|
||||
${({ theme }) => css`
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.sizeUnit * 2}px;
|
||||
/* The area's own inset — the same gutter the root gives its own
|
||||
children (see BuildingBlockView), so a block flowed in here reads as
|
||||
sitting a comfortable distance inside the container rather than
|
||||
pressed against its edges. */
|
||||
padding: ${theme.sizeUnit * 4}px;
|
||||
`}
|
||||
`;
|
||||
|
||||
/**
|
||||
* One flow area's worth of content: a resizable stack of blocks, a drop
|
||||
* target for the palette, and an empty state when there is nothing in it
|
||||
* yet.
|
||||
*
|
||||
* `accepts` gates the drop rather than the caller doing it before ever
|
||||
* rendering this — `collapsible`'s one area holds a single block, and
|
||||
* disabling the drop once that block exists (rather than never offering a
|
||||
* drop target at all) is what lets the empty state's own instruction stay
|
||||
* honest right up until the moment it stops applying.
|
||||
*
|
||||
* `data-container-id` is what makes this a valid reparent target for an
|
||||
* *existing* block being dragged on the root's own grid, not just new ones
|
||||
* from the palette — `RootGrid`'s hit-testing looks for this attribute at
|
||||
* any nesting depth (see its own doc comment), and a flow area answers it
|
||||
* the same way `RootGrid`'s own grid does. Stopping propagation on drop is
|
||||
* what keeps the same event from also reaching `RootGrid`'s handler on its
|
||||
* way up the tree — without it, a block dropped here would be placed twice,
|
||||
* once in this area and once on the root.
|
||||
*/
|
||||
export function FlowContent({
|
||||
containerId,
|
||||
emptyTitle,
|
||||
emptyDescription,
|
||||
accepts = true,
|
||||
dataTest,
|
||||
}: {
|
||||
containerId: string;
|
||||
emptyTitle: string;
|
||||
emptyDescription: string;
|
||||
accepts?: boolean;
|
||||
dataTest?: string;
|
||||
}): ReactElement {
|
||||
const children = provider.getNode(containerId)?.children ?? [];
|
||||
|
||||
return (
|
||||
<FlowArea
|
||||
data-test={dataTest}
|
||||
data-container-id={containerId}
|
||||
onDragOver={event => {
|
||||
if (accepts && event.dataTransfer.types.includes(PALETTE_MIME)) {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
}}
|
||||
onDrop={event => {
|
||||
const type = event.dataTransfer.getData(PALETTE_MIME);
|
||||
if (accepts && type !== '') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
placeBlock(containerId, type);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children.length === 0 && (
|
||||
<EmptyState
|
||||
size="small"
|
||||
image="empty.svg"
|
||||
title={emptyTitle}
|
||||
description={emptyDescription}
|
||||
/>
|
||||
)}
|
||||
{children.map(childId => (
|
||||
<FlowItem
|
||||
key={childId}
|
||||
nodeId={childId}
|
||||
height={provider.getNode(childId)?.layout?.rowSpan}
|
||||
/>
|
||||
))}
|
||||
</FlowArea>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { buildQueryContext, SupersetClient } from '@superset-ui/core';
|
||||
import type { QueryFormData } from '@superset-ui/core';
|
||||
import type { dashboard as dashboardApi } from '@apache-superset/core';
|
||||
|
||||
type DataBindingSpec = dashboardApi.DataBindingSpec;
|
||||
type DataRow = dashboardApi.DataRow;
|
||||
type QueryDataResult = dashboardApi.QueryDataResult;
|
||||
|
||||
interface ChartDataResponseResult {
|
||||
data?: DataRow[];
|
||||
colnames?: string[];
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
// SupersetClient rejects a non-2xx response with the raw, unparsed Response
|
||||
// object rather than an Error (see parseResponse.ts) — left as-is, a caller
|
||||
// doing `String(e)` on that gets the useless "[object Response]". This pulls
|
||||
// the actual `{message}`/`{errors: [...]}` body Superset's API sends back.
|
||||
async function describeFetchError(e: unknown): Promise<string> {
|
||||
if (typeof Response !== 'undefined' && e instanceof Response) {
|
||||
try {
|
||||
const body = await e.clone().json();
|
||||
const detail =
|
||||
body?.message ??
|
||||
(Array.isArray(body?.errors)
|
||||
? body.errors.map((err: { message?: string }) => err.message).join('; ')
|
||||
: undefined);
|
||||
return detail ? `${e.status} ${e.statusText}: ${detail}` : `${e.status} ${e.statusText}`;
|
||||
} catch {
|
||||
return `${e.status} ${e.statusText}`;
|
||||
}
|
||||
}
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
export async function fetchQueryData(
|
||||
binding: DataBindingSpec,
|
||||
): Promise<QueryDataResult> {
|
||||
const formData = {
|
||||
datasource: `${binding.datasetId}__table`,
|
||||
metrics: binding.metrics,
|
||||
groupby: binding.dimensions ?? [],
|
||||
adhoc_filters: binding.filters ?? [],
|
||||
row_limit: binding.rowLimit ?? 1000,
|
||||
result_format: 'json',
|
||||
result_type: 'full',
|
||||
} as unknown as QueryFormData;
|
||||
|
||||
const queryContext = buildQueryContext(formData);
|
||||
|
||||
let json: { result?: ChartDataResponseResult[] } | undefined;
|
||||
try {
|
||||
({ json } = await SupersetClient.post({
|
||||
endpoint: '/api/v1/chart/data',
|
||||
jsonPayload: queryContext,
|
||||
}));
|
||||
} catch (e) {
|
||||
throw new Error(await describeFetchError(e));
|
||||
}
|
||||
|
||||
const result = json?.result?.[0];
|
||||
if (!result || result.error) {
|
||||
throw new Error(result?.error ?? 'Chart data request returned no result');
|
||||
}
|
||||
|
||||
return { columns: result.colnames ?? [], rows: result.data ?? [] };
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { dashboard as dashboardApi } from '@apache-superset/core';
|
||||
import { packChildLayout, resolveExplicitCollisions } from './gridPacking';
|
||||
|
||||
type DashboardNode = dashboardApi.DashboardNode;
|
||||
|
||||
function nodeMap(nodes: Record<string, Partial<DashboardNode>>) {
|
||||
return (id: string) =>
|
||||
nodes[id] ? ({ id, ...nodes[id] } as DashboardNode) : undefined;
|
||||
}
|
||||
|
||||
test('auto-places children top-to-bottom, left-to-right in order, wrapping at the column count', () => {
|
||||
const getNode = nodeMap({
|
||||
a: { layout: { colSpan: 12 } },
|
||||
b: { layout: { colSpan: 6 } },
|
||||
c: { layout: { colSpan: 6 } },
|
||||
d: { layout: { colSpan: 12 } },
|
||||
});
|
||||
|
||||
expect(packChildLayout(['a', 'b', 'c', 'd'], 24, getNode)).toEqual({
|
||||
a: { x: 0, y: 0, w: 12, h: 1 },
|
||||
b: { x: 12, y: 0, w: 6, h: 1 },
|
||||
c: { x: 18, y: 0, w: 6, h: 1 },
|
||||
d: { x: 0, y: 1, w: 12, h: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
test('defaults an omitted colSpan to the full column count and rowSpan to 1', () => {
|
||||
const getNode = nodeMap({ a: {}, b: {} });
|
||||
|
||||
expect(packChildLayout(['a', 'b'], 24, getNode)).toEqual({
|
||||
a: { x: 0, y: 0, w: 24, h: 1 },
|
||||
b: { x: 0, y: 1, w: 24, h: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
test('reserves cells for explicitly placed children before auto-placing the rest', () => {
|
||||
const getNode = nodeMap({
|
||||
hero: { layout: { col: 1, row: 1, colSpan: 8, rowSpan: 2 } },
|
||||
auto: { layout: { colSpan: 4 } },
|
||||
});
|
||||
|
||||
expect(packChildLayout(['hero', 'auto'], 24, getNode)).toEqual({
|
||||
hero: { x: 0, y: 0, w: 8, h: 2 },
|
||||
auto: { x: 8, y: 0, w: 4, h: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
test('auto-placed children flow around an explicitly placed obstacle', () => {
|
||||
const getNode = nodeMap({
|
||||
badge: { layout: { col: 1, row: 1, colSpan: 24 } },
|
||||
auto: { layout: { colSpan: 24 } },
|
||||
});
|
||||
|
||||
expect(packChildLayout(['badge', 'auto'], 24, getNode)).toEqual({
|
||||
badge: { x: 0, y: 0, w: 24, h: 1 },
|
||||
auto: { x: 0, y: 1, w: 24, h: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
test('clamps a colSpan wider than the container to the column count', () => {
|
||||
const getNode = nodeMap({ a: { layout: { colSpan: 99 } } });
|
||||
|
||||
expect(packChildLayout(['a'], 24, getNode)).toEqual({
|
||||
a: { x: 0, y: 0, w: 24, h: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveExplicitCollisions leaves non-colliding explicit children untouched', () => {
|
||||
const getNode = nodeMap({
|
||||
a: { layout: { col: 1, row: 1, colSpan: 12 } },
|
||||
b: { layout: { col: 13, row: 1, colSpan: 12 } },
|
||||
});
|
||||
|
||||
expect(resolveExplicitCollisions(['a', 'b'], 24, getNode)).toEqual({});
|
||||
});
|
||||
|
||||
test('resolveExplicitCollisions ignores auto-placed children entirely', () => {
|
||||
const getNode = nodeMap({
|
||||
a: { layout: { col: 1, row: 1, colSpan: 24 } },
|
||||
auto: { layout: { colSpan: 24 } },
|
||||
});
|
||||
|
||||
expect(resolveExplicitCollisions(['a', 'auto'], 24, getNode)).toEqual({});
|
||||
});
|
||||
|
||||
test('resolveExplicitCollisions pushes a later, colliding explicit child straight down', () => {
|
||||
const getNode = nodeMap({
|
||||
first: { layout: { col: 1, row: 1, colSpan: 24 } },
|
||||
second: { layout: { col: 1, row: 1, colSpan: 24 } },
|
||||
});
|
||||
|
||||
expect(resolveExplicitCollisions(['first', 'second'], 24, getNode)).toEqual({
|
||||
second: { col: 1, row: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveExplicitCollisions cascades past every already-placed row it still overlaps', () => {
|
||||
const getNode = nodeMap({
|
||||
first: { layout: { col: 1, row: 1, colSpan: 24 } },
|
||||
second: { layout: { col: 1, row: 2, colSpan: 24 } },
|
||||
third: { layout: { col: 1, row: 1, colSpan: 24 } },
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveExplicitCollisions(['first', 'second', 'third'], 24, getNode),
|
||||
).toEqual({
|
||||
third: { col: 1, row: 3 },
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveExplicitCollisions does not move an explicit child whose column only overlaps a different row', () => {
|
||||
const getNode = nodeMap({
|
||||
a: { layout: { col: 1, row: 1, colSpan: 12 } },
|
||||
b: { layout: { col: 1, row: 2, colSpan: 6 } },
|
||||
});
|
||||
|
||||
expect(resolveExplicitCollisions(['a', 'b'], 24, getNode)).toEqual({});
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import type { dashboard as dashboardApi } from '@apache-superset/core';
|
||||
|
||||
type DashboardNode = dashboardApi.DashboardNode;
|
||||
|
||||
/** A child's resolved position/size, in `react-grid-layout`'s own 0-based coordinate convention. */
|
||||
export interface PackedRect {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a definite `{x, y, w, h}` for every child of a container, given
|
||||
* only each child's own `layout` — which, per the dashboard schema, may omit
|
||||
* `col`/`row` entirely to request auto-placement. `react-grid-layout` has no
|
||||
* "auto" position of its own; every item in the layout array fed to it needs
|
||||
* concrete coordinates, so this is the one-time translation from "no
|
||||
* position specified" to "here's where that currently lands."
|
||||
*
|
||||
* This runs fresh on every render from the stored layout rather than being
|
||||
* persisted: a node stays auto-placed, and keeps reflowing around whatever
|
||||
* its siblings are doing, until something actually moves *it* (a drag, or a
|
||||
* resize elsewhere that displaces it) — at which point the caller persists
|
||||
* an explicit `col`/`row` for that one node.
|
||||
*
|
||||
* Explicitly placed children reserve their cells first; auto-placed ones
|
||||
* fill the next open run of cells that fits their span, scanning
|
||||
* top-to-bottom, left-to-right, in `children` order — the same order that
|
||||
* governs reading/DOM/tab order, and the same shape a single-column flow's
|
||||
* own top-to-bottom auto-placement produces.
|
||||
*/
|
||||
export function packChildLayout(
|
||||
children: readonly string[],
|
||||
columns: number,
|
||||
getNode: (id: string) => DashboardNode | undefined,
|
||||
): Record<string, PackedRect> {
|
||||
const occupied = new Set<string>();
|
||||
const result: Record<string, PackedRect> = {};
|
||||
|
||||
const occupy = (x: number, y: number, w: number, h: number) => {
|
||||
for (let dy = 0; dy < h; dy += 1) {
|
||||
for (let dx = 0; dx < w; dx += 1) {
|
||||
occupied.add(`${x + dx},${y + dy}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const fits = (x: number, y: number, w: number, h: number) => {
|
||||
if (x + w > columns) return false;
|
||||
for (let dy = 0; dy < h; dy += 1) {
|
||||
for (let dx = 0; dx < w; dx += 1) {
|
||||
if (occupied.has(`${x + dx},${y + dy}`)) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const autoPlaced: { id: string; w: number; h: number }[] = [];
|
||||
|
||||
children.forEach(id => {
|
||||
const layout = getNode(id)?.layout;
|
||||
const w = Math.min(layout?.colSpan ?? columns, columns);
|
||||
const h = layout?.rowSpan ?? 1;
|
||||
if (layout?.col != null && layout?.row != null) {
|
||||
const x = layout.col - 1;
|
||||
const y = layout.row - 1;
|
||||
result[id] = { x, y, w, h };
|
||||
occupy(x, y, w, h);
|
||||
} else {
|
||||
autoPlaced.push({ id, w, h });
|
||||
}
|
||||
});
|
||||
|
||||
autoPlaced.forEach(({ id, w, h }) => {
|
||||
let y = 0;
|
||||
let placed = false;
|
||||
while (!placed) {
|
||||
for (let x = 0; x <= columns - w; x += 1) {
|
||||
if (fits(x, y, w, h)) {
|
||||
result[id] = { x, y, w, h };
|
||||
occupy(x, y, w, h);
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
y += 1;
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** A rectangular span a block being dropped in has room for — see `availableDropSpan`. */
|
||||
export interface AvailableDropSpan {
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* How big a block being dropped in at `(cursorCol, cursorRow)` has room for
|
||||
* — up to `maxRowSpan` tall — given `packed`'s existing occupancy: the pure
|
||||
* geometry half of `RootGrid`'s own live drop preview (see its own
|
||||
* `handleDropDragOver`), split out here so it can be exercised without a
|
||||
* real drag gesture, the same reason `packChildLayout`'s own placement math
|
||||
* lives here rather than inside a component.
|
||||
*
|
||||
* Open space returns exactly as wide a span as that row has free, capped at
|
||||
* `columns` — a wholly empty grid included, since nothing anywhere is
|
||||
* occupied there either — and exactly as tall a span as the rows below it,
|
||||
* within that same width, stay just as free, capped at `maxRowSpan`. A gap
|
||||
* that turns out to be as tall as it is wide is not pushing anything out of
|
||||
* the way at all: it was already free on every side. No minimum width of
|
||||
* its own beyond that: every block's own `minW` is 1 (see `RootGrid`'s own
|
||||
* `layout` construction), so a single free column is already as legitimate
|
||||
* a place to drop one as a whole free row is — narrower than that and there
|
||||
* is no width left to report at all.
|
||||
*
|
||||
* Directly over another block there is no *beside* to speak of, only
|
||||
* *above* or *below* it (which `compactType="vertical"` resolves the same
|
||||
* way it already does for repositioning an existing block), so that returns
|
||||
* the full row at `maxRowSpan` instead.
|
||||
*/
|
||||
export function availableDropSpan(
|
||||
packed: Record<string, PackedRect>,
|
||||
columns: number,
|
||||
cursorCol: number,
|
||||
cursorRow: number,
|
||||
maxRowSpan: number,
|
||||
): AvailableDropSpan {
|
||||
const occupied = new Set<string>();
|
||||
Object.values(packed).forEach(rect => {
|
||||
for (let dy = 0; dy < rect.h; dy += 1) {
|
||||
for (let dx = 0; dx < rect.w; dx += 1) {
|
||||
occupied.add(`${rect.x + dx},${rect.y + dy}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (occupied.has(`${cursorCol},${cursorRow}`)) {
|
||||
return { w: columns, h: maxRowSpan };
|
||||
}
|
||||
|
||||
let left = cursorCol;
|
||||
while (left > 0 && !occupied.has(`${left - 1},${cursorRow}`)) {
|
||||
left -= 1;
|
||||
}
|
||||
let right = cursorCol;
|
||||
while (right < columns - 1 && !occupied.has(`${right + 1},${cursorRow}`)) {
|
||||
right += 1;
|
||||
}
|
||||
const w = right - left + 1;
|
||||
|
||||
const rowIsFree = (row: number): boolean => {
|
||||
for (let dx = 0; dx < w; dx += 1) {
|
||||
if (occupied.has(`${left + dx},${row}`)) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
let bottom = cursorRow;
|
||||
while (bottom - cursorRow + 1 < maxRowSpan && rowIsFree(bottom + 1)) {
|
||||
bottom += 1;
|
||||
}
|
||||
const h = bottom - cursorRow + 1;
|
||||
|
||||
return { w, h };
|
||||
}
|
||||
|
||||
function rectsOverlap(
|
||||
a: { col: number; row: number; colSpan: number; rowSpan: number },
|
||||
b: { col: number; row: number; colSpan: number; rowSpan: number },
|
||||
): boolean {
|
||||
return (
|
||||
a.col < b.col + b.colSpan &&
|
||||
b.col < a.col + a.colSpan &&
|
||||
a.row < b.row + b.rowSpan &&
|
||||
b.row < a.row + a.rowSpan
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves collisions among a container's *explicitly* placed children
|
||||
* (both `col` and `row` set) by pushing a later-declared one straight down,
|
||||
* one row at a time, until it no longer overlaps an earlier one — the same
|
||||
* "displace, never shrink" rule interactive resize/drag already gets from
|
||||
* `react-grid-layout` (see `RootGrid`), applied here for the
|
||||
* programmatic placement path (`DashboardProvider.addBuildingBlock`/
|
||||
* `updateLayout`, which an extension's AI tools call directly) so both give
|
||||
* the same "nothing ends up stuck overlapping" guarantee, not just the one
|
||||
* driven by a mouse.
|
||||
*
|
||||
* Auto-placed children (`col`/`row` omitted) are skipped entirely — they
|
||||
* have no fixed position to resolve; they flow around whatever's explicit
|
||||
* at render time instead (see `packChildLayout`).
|
||||
*
|
||||
* Returns only the children whose position actually needed to change, in
|
||||
* `{col, row}` form ready for `DashboardProvider.updateLayouts`.
|
||||
*/
|
||||
export function resolveExplicitCollisions(
|
||||
children: readonly string[],
|
||||
columns: number,
|
||||
getNode: (id: string) => DashboardNode | undefined,
|
||||
): Record<string, { col: number; row: number }> {
|
||||
const placed: {
|
||||
col: number;
|
||||
row: number;
|
||||
colSpan: number;
|
||||
rowSpan: number;
|
||||
}[] = [];
|
||||
const adjustments: Record<string, { col: number; row: number }> = {};
|
||||
|
||||
children.forEach(id => {
|
||||
const layout = getNode(id)?.layout;
|
||||
if (layout?.col == null || layout?.row == null) return;
|
||||
|
||||
const rect = {
|
||||
col: layout.col,
|
||||
row: layout.row,
|
||||
colSpan: Math.min(layout.colSpan ?? columns, columns),
|
||||
rowSpan: layout.rowSpan ?? 1,
|
||||
};
|
||||
while (placed.some(other => rectsOverlap(other, rect))) {
|
||||
rect.row += 1;
|
||||
}
|
||||
if (rect.row !== layout.row) {
|
||||
adjustments[id] = { col: rect.col, row: rect.row };
|
||||
}
|
||||
placed.push(rect);
|
||||
});
|
||||
|
||||
return adjustments;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Host implementation of the `dashboard` contribution type
|
||||
* (prototype). Extensions/the AI agent call the public `dashboard.*` API
|
||||
* (`@apache-superset/core`) to place/move/resize/remove nodes; the host owns
|
||||
* the single in-memory node tree backing the "Dashboard v2" prototype page.
|
||||
*
|
||||
* The public namespace (`dashboard`) is exposed to extensions on
|
||||
* `window.superset`. `useDashboardRevision` is host-internal and NOT part of
|
||||
* the public `@apache-superset/core` API — it's how the prototype's own
|
||||
* canvas renderer knows to re-render, then walks the tree via the same
|
||||
* `getRoot`/`getNode` accessors extensions use.
|
||||
*/
|
||||
|
||||
import type { dashboard as dashboardApi } from '@apache-superset/core';
|
||||
import { provider, useDashboardRevision } from './store';
|
||||
import { fetchQueryData } from './chartData';
|
||||
import { registerBuiltInBuildingBlocks } from './registerBuiltInBuildingBlocks';
|
||||
|
||||
// Built-in block types (canvas/markdown/echarts) are registered the same
|
||||
// way an extension registers its own — see registerBuiltInBuildingBlocks.
|
||||
// Doing this here guarantees it happens before anything imports `dashboard`
|
||||
// to render a node, regardless of which page or bridge triggers the import.
|
||||
registerBuiltInBuildingBlocks();
|
||||
|
||||
export { useDashboardRevision };
|
||||
|
||||
export const dashboard: typeof dashboardApi = {
|
||||
getRoot: provider.getRoot,
|
||||
getNode: provider.getNode,
|
||||
addBuildingBlock: provider.addBuildingBlock.bind(provider),
|
||||
removeBuildingBlock: provider.removeBuildingBlock.bind(provider),
|
||||
moveBuildingBlock: provider.moveBuildingBlock.bind(provider),
|
||||
updateLayout: provider.updateLayout.bind(provider),
|
||||
updateProps: provider.updateProps.bind(provider),
|
||||
onDidLayoutChange: provider.onDidLayoutChange,
|
||||
fetchQueryData,
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { supersetTheme } from '@apache-superset/core/theme';
|
||||
import { resolveGridMetrics } from './layoutStyle';
|
||||
|
||||
const theme = supersetTheme as unknown as Parameters<
|
||||
typeof resolveGridMetrics
|
||||
>[1];
|
||||
|
||||
test('a container with no layout still resolves default grid metrics', () => {
|
||||
expect(resolveGridMetrics(undefined, theme)).toEqual({
|
||||
columns: 24,
|
||||
gap: 16,
|
||||
rowUnitPx: theme.sizeUnit * 8,
|
||||
});
|
||||
});
|
||||
|
||||
test('a container names its own columns, gap and row height', () => {
|
||||
expect(
|
||||
resolveGridMetrics({ columns: 12, gap: 24, rowUnit: 40 }, theme),
|
||||
).toEqual({ columns: 12, gap: 24, rowUnitPx: 40 });
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { dashboard as dashboardApi } from '@apache-superset/core';
|
||||
import type { useTheme } from '@apache-superset/core/theme';
|
||||
|
||||
type LayoutProps = dashboardApi.LayoutProps;
|
||||
type Theme = ReturnType<typeof useTheme>;
|
||||
|
||||
/** Column count a container falls back to when its layout omits `columns`. */
|
||||
export const DEFAULT_COLUMNS = 24;
|
||||
|
||||
const DEFAULT_GAP = 16;
|
||||
|
||||
/** A container's resolved grid geometry, in the plain numbers `RootGrid` feeds to `react-grid-layout` (`cols`/`rowHeight`/`margin`). */
|
||||
export interface GridMetrics {
|
||||
columns: number;
|
||||
gap: number;
|
||||
rowUnitPx: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a container's grid geometry, applying the same defaults every
|
||||
* consumer of a node's `layout` needs to agree on — `rowUnit` falls back to
|
||||
* a size derived from the theme rather than a bare literal, since it's meant
|
||||
* to track the app's own spacing scale, not an arbitrary pixel value.
|
||||
*/
|
||||
export function resolveGridMetrics(
|
||||
layout: LayoutProps | undefined,
|
||||
theme: Theme,
|
||||
): GridMetrics {
|
||||
return {
|
||||
columns: layout?.columns ?? DEFAULT_COLUMNS,
|
||||
gap: layout?.gap ?? DEFAULT_GAP,
|
||||
rowUnitPx: layout?.rowUnit ?? theme.sizeUnit * 8,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Placing a new block, in the one place both ways of asking
|
||||
* for it can reach.
|
||||
*
|
||||
* A block arrives on a dashboard two ways — clicked in the palette, or
|
||||
* dragged from it onto a container — and they must produce the same node. Two
|
||||
* copies of "what a freshly placed block looks like" is how a block dropped
|
||||
* into a section ends up subtly different from the same block clicked into
|
||||
* it, and the difference is invisible until someone hits it.
|
||||
*/
|
||||
|
||||
import { GRID_TYPE, isContainerType } from './DashboardProvider';
|
||||
import { DEFAULT_COLUMNS } from './layoutStyle';
|
||||
import { provider } from './store';
|
||||
|
||||
/**
|
||||
* What a palette drag carries.
|
||||
*
|
||||
* A private type rather than `text/plain` so a drop of anything else — a
|
||||
* file, a selection of text, a drag from another application — is not read
|
||||
* as a request to place a block.
|
||||
*/
|
||||
export const PALETTE_MIME = 'application/x-dashboard-building-block';
|
||||
|
||||
/**
|
||||
* A type's own starting `rowSpan` on the root grid, in row tracks (a row is
|
||||
* `rowUnitPx` tall, 32px by default — see `layoutStyle.ts`). Left unset
|
||||
* (`gridPacking.ts`'s own fallback of 1 row) a freshly placed block sits
|
||||
* shorter than `BuildingBlockView`'s header-plus-padding chrome alone, before
|
||||
* any content of its own — every leaf type would open already clipped or
|
||||
* scrolling. Tuned per type instead of one shared number because what fills
|
||||
* that box varies enough that one height leaves half of them cramped and the
|
||||
* other half wasting space: a metric tile is a few lines centered in a card,
|
||||
* a table wants room for a header row and several data rows, a fresh `tabs`
|
||||
* pays for a tab bar and a flow area's own padding before its empty state
|
||||
* even starts. An author who wants a block smaller still has the resize
|
||||
* handle for that — this is only the size nobody has touched it at yet.
|
||||
*
|
||||
* Root-grid-only: a `rowSpan` off this table is meaningless (and, worse,
|
||||
* silently wrong) for a block placed into anything else — a `tabs` pane, a
|
||||
* `collapsible`, a `carousel` slide — since those read `rowSpan` in their
|
||||
* own unit, a flow area's own pixel (see `FlowContent`'s own comment), not
|
||||
* a grid row track. `placeBlock`/`placeBlockAt` only reach into this table
|
||||
* once they've confirmed the parent actually is the root's own grid.
|
||||
*/
|
||||
const DEFAULT_ROW_SPAN: Record<string, number> = {
|
||||
markdown: 5,
|
||||
echarts: 8,
|
||||
'ag-grid-table': 9,
|
||||
'metric-tile': 4,
|
||||
tabs: 6,
|
||||
collapsible: 5,
|
||||
carousel: 5,
|
||||
};
|
||||
|
||||
/**
|
||||
* Row span for a type this module has no specific tuning for — an
|
||||
* extension-contributed block, most likely — and also `RootGrid`'s own
|
||||
* starting height for a block whose *position* came from a palette drag
|
||||
* rather than this module's per-type table (see `placeBlockAt`): a drag
|
||||
* already answers where a block lands, live, as the gesture happens, and
|
||||
* asking it to also settle on a bespoke height per type at the same time is
|
||||
* more than one gesture should have to carry. Generous rather than tight:
|
||||
* better an unfamiliar block opens a little taller than it needed to than
|
||||
* clipped or scrolling before anyone has seen what it renders.
|
||||
*/
|
||||
export const FALLBACK_ROW_SPAN = 6;
|
||||
|
||||
/**
|
||||
* Places a new block of `type` at the end of `parentId`'s children and
|
||||
* selects it, returning its id.
|
||||
*
|
||||
* A container arrives with the grid every other container defaults to, so a
|
||||
* nested canvas is usable the moment it lands rather than needing its columns
|
||||
* set before anything can go inside it. Selecting what was just placed is
|
||||
* what brings its properties forward: placing something is the moment you
|
||||
* want to configure it.
|
||||
*
|
||||
* `rowSpan` is only ever set here when `parentId` is the root's own grid —
|
||||
* everywhere else (a `tabs` pane, a `collapsible`, a `carousel` slide) it's
|
||||
* left unset entirely, on purpose, so `FlowItem` (see `flowContent.tsx`)
|
||||
* reads that as "no height chosen yet" and flexes the block to fill
|
||||
* whatever room the container actually has, rather than a grid-row number
|
||||
* misread as a pixel count.
|
||||
*/
|
||||
export function placeBlock(parentId: string, type: string): string {
|
||||
const index = provider.getNode(parentId)?.children?.length ?? 0;
|
||||
const onRootGrid = provider.getNode(parentId)?.type === GRID_TYPE;
|
||||
const rowSpan = onRootGrid
|
||||
? (DEFAULT_ROW_SPAN[type] ?? FALLBACK_ROW_SPAN)
|
||||
: undefined;
|
||||
const id = provider.addBuildingBlock(parentId, index, {
|
||||
type,
|
||||
layout: isContainerType(type)
|
||||
? { columns: DEFAULT_COLUMNS, gap: 16, colSpan: DEFAULT_COLUMNS, rowSpan }
|
||||
: { rowSpan },
|
||||
});
|
||||
provider.setSelection(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Places a new block of `type` at an explicit grid cell and an explicit
|
||||
* spot in `parentId`'s own reading order, rather than appending it
|
||||
* full-width at the end the way `placeBlock` does — `RootGrid`'s own
|
||||
* counterpart for a palette block dropped onto the root's grid, where
|
||||
* *where* (and how wide, next to whatever it landed beside) was the entire
|
||||
* point of the gesture.
|
||||
*
|
||||
* `position` arrives already resolved: react-grid-layout ran its own
|
||||
* collision avoidance live, during the drag itself, the same as it does for
|
||||
* repositioning an existing block (see `RootGrid`'s own `isDroppable`
|
||||
* wiring) — nothing here recomputes a position, only writes the one already
|
||||
* shown as the drop's own preview.
|
||||
*
|
||||
* `index`, unlike `placeBlock`'s own implicit "at the end," is the caller's
|
||||
* to get right: `DashboardProvider`'s own collision resolution (see
|
||||
* `resolveExplicitCollisions`) settles a tie between two explicitly placed
|
||||
* siblings by pushing down whichever comes *later* in `children` — so a
|
||||
* block dropped, say, between two existing rows has to land earlier in that
|
||||
* order than the row it is displacing, or collision resolution reads it
|
||||
* backwards and pushes the new block itself down past everything instead of
|
||||
* making room for it where it was actually dropped. `RootGrid` is what
|
||||
* already knows every sibling's own current position (it just packed them,
|
||||
* to draw this render's preview in the first place), so it is the one that
|
||||
* resolves reading order too, rather than this module re-deriving it from
|
||||
* scratch here.
|
||||
*/
|
||||
export function placeBlockAt(
|
||||
parentId: string,
|
||||
type: string,
|
||||
index: number,
|
||||
position: { col: number; row: number; colSpan: number; rowSpan: number },
|
||||
): string {
|
||||
const id = provider.addBuildingBlock(parentId, index, {
|
||||
type,
|
||||
layout: isContainerType(type)
|
||||
? { columns: DEFAULT_COLUMNS, gap: 16, ...position }
|
||||
: { ...position },
|
||||
});
|
||||
provider.setSelection(id);
|
||||
return id;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { views } from 'src/core/views';
|
||||
import { DASHBOARD_BUILDING_BLOCKS_LOCATION } from './resolveBuildingBlockView';
|
||||
import { registerContainerType } from './DashboardProvider';
|
||||
import MarkdownBlock from './blocks/MarkdownBlock';
|
||||
import ChartBlock from './blocks/ChartBlock';
|
||||
import AgGridTableBlock from './blocks/AgGridTableBlock';
|
||||
import MetricTileBlock from './blocks/MetricTileBlock';
|
||||
import TabsBlock, { TAB_TYPE } from './blocks/TabsBlock';
|
||||
import CollapsibleBlock from './blocks/CollapsibleBlock';
|
||||
import CarouselBlock, { SLIDE_TYPE } from './blocks/CarouselBlock';
|
||||
|
||||
let registered = false;
|
||||
|
||||
/**
|
||||
* Registers the built-in block types through the exact same `views` call an
|
||||
* extension uses to contribute one of its own — markdown/echarts/
|
||||
* ag-grid-table/metric-tile/tabs have no special status in the render path
|
||||
* (see `BuildingBlockView`), they're just pre-registered here before
|
||||
* anything else has a chance to render a dashboard node.
|
||||
*
|
||||
* `grid` — the root's own type — is deliberately not among them. The root
|
||||
* is not a Building Block (see the composition/layout design doc): nothing
|
||||
* ever places one, and `BuildingBlockView` resolves the root's renderer
|
||||
* directly rather than through this registry (the same reason `tab`, below,
|
||||
* isn't registered either — see `TabsBlock`).
|
||||
*/
|
||||
export function registerBuiltInBuildingBlocks(): void {
|
||||
if (registered) return;
|
||||
registered = true;
|
||||
|
||||
views.registerView(
|
||||
{
|
||||
id: 'markdown',
|
||||
name: 'Markdown',
|
||||
description: 'Renders Markdown content.',
|
||||
},
|
||||
DASHBOARD_BUILDING_BLOCKS_LOCATION,
|
||||
MarkdownBlock,
|
||||
);
|
||||
views.registerView(
|
||||
{
|
||||
id: 'echarts',
|
||||
name: 'ECharts',
|
||||
description: 'Renders a chart from an ECharts option object.',
|
||||
},
|
||||
DASHBOARD_BUILDING_BLOCKS_LOCATION,
|
||||
ChartBlock,
|
||||
);
|
||||
views.registerView(
|
||||
{
|
||||
id: 'ag-grid-table',
|
||||
name: 'Table',
|
||||
description: 'Renders query results as an AG Grid table.',
|
||||
},
|
||||
DASHBOARD_BUILDING_BLOCKS_LOCATION,
|
||||
AgGridTableBlock,
|
||||
);
|
||||
views.registerView(
|
||||
{
|
||||
id: 'metric-tile',
|
||||
name: 'Metric Tile',
|
||||
description: 'Renders a single live metric value as a "big number".',
|
||||
},
|
||||
DASHBOARD_BUILDING_BLOCKS_LOCATION,
|
||||
MetricTileBlock,
|
||||
);
|
||||
views.registerView(
|
||||
{
|
||||
id: 'tabs',
|
||||
name: 'Tabs',
|
||||
description: 'Groups building blocks into switchable tabs.',
|
||||
},
|
||||
DASHBOARD_BUILDING_BLOCKS_LOCATION,
|
||||
TabsBlock,
|
||||
);
|
||||
views.registerView(
|
||||
{
|
||||
id: 'collapsible',
|
||||
name: 'Collapsible',
|
||||
description: 'Holds a single building block behind a show/hide toggle.',
|
||||
},
|
||||
DASHBOARD_BUILDING_BLOCKS_LOCATION,
|
||||
CollapsibleBlock,
|
||||
);
|
||||
views.registerView(
|
||||
{
|
||||
id: 'carousel',
|
||||
name: 'Carousel',
|
||||
description:
|
||||
'Groups building blocks into slides, navigated vertically one at a time.',
|
||||
},
|
||||
DASHBOARD_BUILDING_BLOCKS_LOCATION,
|
||||
CarouselBlock,
|
||||
);
|
||||
|
||||
// A tab pane / carousel slide holds its own children (in flow — see
|
||||
// `TabsBlock`/`CarouselBlock`), but neither is registered as a view:
|
||||
// nothing ever resolves one through `resolveBuildingBlockView` — each
|
||||
// renders its pane's/slide's children directly rather than rendering the
|
||||
// node itself. They only need to be recognized container types so
|
||||
// `addBuildingBlock` gives them a `children` array. `collapsible` needs no
|
||||
// such private type: its one child is held directly, with no intermediate
|
||||
// pane (see `CollapsibleBlock`).
|
||||
registerContainerType('tabs');
|
||||
registerContainerType(TAB_TYPE);
|
||||
registerContainerType('collapsible');
|
||||
registerContainerType('carousel');
|
||||
registerContainerType(SLIDE_TYPE);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { dashboard as dashboardApi } from '@apache-superset/core';
|
||||
import type { useTheme } from '@apache-superset/core/theme';
|
||||
|
||||
type DataRow = dashboardApi.DataRow;
|
||||
type Theme = ReturnType<typeof useTheme>;
|
||||
|
||||
export interface BindContext {
|
||||
rows: DataRow[];
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
interface BindMarker {
|
||||
$bind: {
|
||||
source: 'metric' | 'dimension' | 'theme' | 'records';
|
||||
/** `metric`/`dimension` — the row field to pull one column's values from. */
|
||||
alias?: string;
|
||||
/** `theme` — the theme token to substitute. */
|
||||
token?: string;
|
||||
/**
|
||||
* `records` — zip several row fields into one array of plain objects,
|
||||
* one per row: `{ [outputKey]: row[columnAlias], ... }`. This is the
|
||||
* shape ECharts wants for e.g. pie's `series[].data` (`{name, value}`
|
||||
* pairs), which a single flat column can't express on its own.
|
||||
*/
|
||||
fields?: Record<string, string>;
|
||||
/**
|
||||
* `metric`/`dimension` only — return just the first row's value (a
|
||||
* scalar) instead of an array of every row's value. A query with no
|
||||
* `dimensions` (a single aggregate — the "big number"/gauge case) only
|
||||
* ever has one row, but `metric`/`dimension` still resolve to a
|
||||
* one-element ARRAY by default, since that's what every *other* chart
|
||||
* shape (bar/line/pie, one point per row) needs. Anywhere ECharts wants
|
||||
* a plain number/string instead — a gauge's `series[].data[].value`, a
|
||||
* `graphic[].style.text` label — set `single: true` to get that one
|
||||
* value unwrapped, rather than `[value]`.
|
||||
*/
|
||||
single?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const BIND_SOURCES = new Set(['metric', 'dimension', 'theme', 'records']);
|
||||
|
||||
function isBindMarker(value: unknown): value is BindMarker {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'$bind' in (value as Record<string, unknown>)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* True for the mistake of writing a `$bind` marker's *inner* shape —
|
||||
* `{"source": "records", "fields": {...}}` — directly in place of a value,
|
||||
* omitting the `"$bind"` wrapper key itself. Easy to make (the inner shape
|
||||
* is what everything actually reads), and costly to miss: unwrapped, it
|
||||
* isn't a bind marker at all as far as `resolveValue` is concerned, so it
|
||||
* passes straight through as a literal object — a hard crash later if the
|
||||
* spot expected an array (e.g. `series[].data`), or a silently wrong value
|
||||
* if it didn't (e.g. a theme color quietly becoming `{source, token}`
|
||||
* instead of the color string it names).
|
||||
*/
|
||||
function looksLikeUnwrappedBind(
|
||||
value: Record<string, unknown>,
|
||||
): value is BindMarker['$bind'] {
|
||||
return typeof value.source === 'string' && BIND_SOURCES.has(value.source);
|
||||
}
|
||||
|
||||
/**
|
||||
* A malformed `$bind` marker (a missing `alias`/`fields`/`token`, or an
|
||||
* unrecognized `source`) used to resolve to `undefined` — which, spliced
|
||||
* into e.g. a chart's `series[].data`, doesn't fail here at all. It fails
|
||||
* much later, inside ECharts' own `setOption`, as a generic
|
||||
* "series.data ... must be an array" console error with no indication that
|
||||
* the actual cause was an incomplete `$bind` several layers up. Throwing
|
||||
* here instead — during `resolveBindings`, called from `ChartBlock`'s
|
||||
* render — gets caught by the `ErrorBoundary` already wrapping every block
|
||||
* (see `BuildingBlockView`) and reported as this specific block's error,
|
||||
* naming the exact marker that was incomplete.
|
||||
*/
|
||||
function resolveBind(bind: BindMarker['$bind'], ctx: BindContext): unknown {
|
||||
if (bind.source === 'theme') {
|
||||
if (!bind.token) {
|
||||
throw new Error('$bind with source "theme" is missing "token"');
|
||||
}
|
||||
return (ctx.theme as unknown as Record<string, unknown>)[bind.token];
|
||||
}
|
||||
if (bind.source === 'metric' || bind.source === 'dimension') {
|
||||
if (!bind.alias) {
|
||||
throw new Error(`$bind with source "${bind.source}" is missing "alias"`);
|
||||
}
|
||||
const values = ctx.rows.map(row => row[bind.alias as string]);
|
||||
return bind.single ? values[0] : values;
|
||||
}
|
||||
if (bind.source === 'records') {
|
||||
if (!bind.fields || Object.keys(bind.fields).length === 0) {
|
||||
throw new Error('$bind with source "records" is missing "fields"');
|
||||
}
|
||||
const { fields } = bind;
|
||||
return ctx.rows.map(row => {
|
||||
const record: Record<string, unknown> = {};
|
||||
Object.entries(fields).forEach(([outputKey, columnAlias]) => {
|
||||
record[outputKey] = row[columnAlias];
|
||||
});
|
||||
return record;
|
||||
});
|
||||
}
|
||||
throw new Error(
|
||||
`Unknown $bind source: "${(bind as { source: string }).source}"`,
|
||||
);
|
||||
}
|
||||
|
||||
// ECharts option keys documented as accepting *only* a JavaScript function
|
||||
// (no string-template alternative the way `formatter` has) — a JSON
|
||||
// tool-call argument can never supply a function, so any value found under
|
||||
// one of these keys is unconditionally wrong, not just wrong in some cases.
|
||||
// Left unchecked, this fails deep inside ECharts' own `setOption` as e.g.
|
||||
// "valueFormatter is not a function," with nothing pointing back at the
|
||||
// AI-authored option key that caused it.
|
||||
const FUNCTION_ONLY_KEYS = new Set(['valueFormatter', 'labelLayout']);
|
||||
|
||||
function resolveValue(value: unknown, ctx: BindContext): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(item => resolveValue(item, ctx));
|
||||
}
|
||||
if (isBindMarker(value)) {
|
||||
return resolveBind(value.$bind, ctx);
|
||||
}
|
||||
if (value !== null && typeof value === 'object') {
|
||||
if (looksLikeUnwrappedBind(value as Record<string, unknown>)) {
|
||||
throw new Error(
|
||||
`Found a $bind object without its "$bind" wrapper: ${JSON.stringify(value)} — ` +
|
||||
`did you mean {"$bind": ${JSON.stringify(value)}}?`,
|
||||
);
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>).map(([key, v]) => {
|
||||
if (FUNCTION_ONLY_KEYS.has(key)) {
|
||||
throw new Error(
|
||||
`"${key}" must be a JavaScript function in ECharts, which a JSON-authored ` +
|
||||
'option can never provide. Remove it and use a string-template field ' +
|
||||
'instead where one exists (e.g. "formatter" on tooltip/axisLabel/label, ' +
|
||||
'not "valueFormatter" — a string like "{b}: ${c}" works directly on ' +
|
||||
'"formatter").',
|
||||
);
|
||||
}
|
||||
return [key, resolveValue(v, ctx)];
|
||||
}),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively walks a near-raw ECharts `option` object and replaces every
|
||||
* `{"$bind": {...}}` marker with the real value it references — query
|
||||
* results or a theme token (decision 12 of the design doc's unified `$bind`
|
||||
* construct). Everything else in the tree passes through unchanged, so an
|
||||
* AI-authored option can mix literal ECharts config with bound values
|
||||
* anywhere a literal would otherwise go.
|
||||
*/
|
||||
export function resolveBindings(
|
||||
echartsOptions: Record<string, unknown>,
|
||||
ctx: BindContext,
|
||||
): Record<string, unknown> {
|
||||
return resolveValue(echartsOptions, ctx) as Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { ReactElement } from 'react';
|
||||
import { resolveView, views } from 'src/core/views';
|
||||
|
||||
/**
|
||||
* The `views` location a dashboard node's `type` must be registered at to
|
||||
* be renderable as a building block — built-in types (markdown/echarts/...,
|
||||
* see `registerBuiltInBuildingBlocks`) and extension-contributed ones
|
||||
* register here identically, through the same `views.registerView` call.
|
||||
* The root's own type (`grid`) is deliberately not among them — it is not
|
||||
* a Building Block, and `BuildingBlockView` resolves its renderer directly
|
||||
* rather than through this location.
|
||||
*/
|
||||
export const DASHBOARD_BUILDING_BLOCKS_LOCATION = 'dashboard.buildingBlocks';
|
||||
|
||||
/**
|
||||
* Resolves a node's registered view, scoped to
|
||||
* `DASHBOARD_BUILDING_BLOCKS_LOCATION` — `resolveView` alone resolves by id
|
||||
* only, ignoring location, so without this check a node whose `type`
|
||||
* happened to collide with some unrelated view id registered elsewhere in
|
||||
* the app could render the wrong thing. Returns undefined if no building
|
||||
* block is registered for `type`, so the caller can fall back to an
|
||||
* "unsupported" placeholder.
|
||||
*/
|
||||
export function resolveBuildingBlockView(
|
||||
type: string,
|
||||
nodeId: string,
|
||||
): ReactElement | undefined {
|
||||
const isRegistered = views
|
||||
.getViews(DASHBOARD_BUILDING_BLOCKS_LOCATION)
|
||||
?.some(view => view.id === type);
|
||||
if (!isRegistered) return undefined;
|
||||
return resolveView(type, { nodeId });
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Leaf module wrapping the `DashboardProvider` singleton.
|
||||
*
|
||||
* Building block components (built-in or extension-contributed) read from
|
||||
* `provider` and subscribe via `useDashboardRevision` directly — importing
|
||||
* from here rather than from `./index` avoids a cycle, since `./index` is
|
||||
* what registers the built-in blocks (which import the provider) in the
|
||||
* first place.
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import DashboardProvider from './DashboardProvider';
|
||||
|
||||
export const provider = DashboardProvider.getInstance();
|
||||
|
||||
/** Ticks on every dashboard.* mutation so a subscribed component re-reads the tree. */
|
||||
export const useDashboardRevision = () =>
|
||||
useSyncExternalStore(provider.subscribe, provider.getRevision);
|
||||
@@ -29,6 +29,7 @@ export const core: typeof coreType = {
|
||||
export * from './authentication';
|
||||
export * from './chat';
|
||||
export * from './commands';
|
||||
export * from './dashboard';
|
||||
export * from './editors';
|
||||
export * from './extensions';
|
||||
export * from './menus';
|
||||
|
||||
@@ -99,6 +99,12 @@ test('chart and dashboard list pages get their own page types', async () => {
|
||||
expect(navigation.getPage()).toBe('dashboard_list');
|
||||
});
|
||||
|
||||
test('Dashboard v2 is distinct from the classic dashboard route', async () => {
|
||||
const { notifyLocationChanged, navigation } = await importNavigation();
|
||||
notifyLocationChanged('/dashboard/v2/new/');
|
||||
expect(navigation.getPage()).toBe('dashboard_v2');
|
||||
});
|
||||
|
||||
test('dataset list and single-dataset pages get distinct page types', async () => {
|
||||
const { notifyLocationChanged, navigation } = await importNavigation();
|
||||
notifyLocationChanged('/tablemodelview/list/');
|
||||
|
||||
@@ -41,6 +41,7 @@ const PAGE_ROUTES: { path: string; page: Page }[] = [
|
||||
// greedily capture `/dashboard/list/` (idOrSlug='list'), so the more specific
|
||||
// list route has to win first — mirroring the `routes.tsx` Switch precedence.
|
||||
{ path: RoutePaths.DASHBOARD_LIST, page: 'dashboard_list' },
|
||||
{ path: RoutePaths.DASHBOARD_V2_NEW, page: 'dashboard_v2' },
|
||||
{ path: RoutePaths.DASHBOARD, page: 'dashboard' },
|
||||
{ path: RoutePaths.QUERY_HISTORY, page: 'query_history' },
|
||||
{ path: RoutePaths.SAVED_QUERIES, page: 'saved_queries' },
|
||||
|
||||
@@ -37,7 +37,7 @@ type ViewUnregisteredEvent = viewsApi.ViewUnregisteredEvent;
|
||||
|
||||
const viewRegistry: Map<
|
||||
string,
|
||||
{ view: View; location: string; component: ComponentType }
|
||||
{ view: View; location: string; component: ComponentType<any> }
|
||||
> = new Map();
|
||||
|
||||
const locationIndex: Map<string, Set<string>> = new Map();
|
||||
@@ -66,7 +66,7 @@ const notifyUnregister = (event: ViewUnregisteredEvent) => {
|
||||
const registerView: typeof viewsApi.registerView = (
|
||||
view: View,
|
||||
location: string,
|
||||
component: ComponentType,
|
||||
component: ComponentType<any>,
|
||||
): Disposable => {
|
||||
const { id } = view;
|
||||
|
||||
@@ -84,7 +84,10 @@ const registerView: typeof viewsApi.registerView = (
|
||||
});
|
||||
};
|
||||
|
||||
export const resolveView = (id: string): React.ReactElement => {
|
||||
export const resolveView = (
|
||||
id: string,
|
||||
props?: Record<string, unknown>,
|
||||
): React.ReactElement => {
|
||||
const entry = viewRegistry.get(id);
|
||||
if (!entry) {
|
||||
return React.createElement(ExtensionPlaceholder, { id });
|
||||
@@ -92,7 +95,7 @@ export const resolveView = (id: string): React.ReactElement => {
|
||||
return React.createElement(
|
||||
ErrorBoundary,
|
||||
null,
|
||||
React.createElement(entry.component),
|
||||
React.createElement(entry.component, props),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import { initSliceEntities } from 'src/dashboard/reducers/sliceEntities';
|
||||
import { getInitialState as getInitialNativeFilterState } from 'src/dashboard/reducers/nativeFilters';
|
||||
import { applyDefaultFormData } from 'src/explore/store';
|
||||
import { buildActiveFilters } from 'src/dashboard/util/activeDashboardFilters';
|
||||
import { canDownloadData, findPermission } from 'src/utils/findPermission';
|
||||
import { findPermission } from 'src/utils/findPermission';
|
||||
import {
|
||||
canUserEditDashboard,
|
||||
canUserSaveAsDashboard,
|
||||
@@ -369,7 +369,7 @@ export const hydrateDashboard =
|
||||
'Superset',
|
||||
roles,
|
||||
),
|
||||
superset_can_download: canDownloadData(roles),
|
||||
superset_can_download: findPermission('can_csv', 'Superset', roles),
|
||||
common: {
|
||||
// legacy, please use state.common instead
|
||||
conf: common?.conf,
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
RenderResult,
|
||||
@@ -26,10 +25,6 @@ import {
|
||||
} from 'spec/helpers/testing-library';
|
||||
|
||||
import { DASHBOARD_GRID_ID } from 'src/dashboard/util/constants';
|
||||
import {
|
||||
FORCE_IN_VIEW_EVENT,
|
||||
RESTORE_VIRTUALIZATION_EVENT,
|
||||
} from 'src/dashboard/constants';
|
||||
import { getMockStore } from 'spec/fixtures/mockStore';
|
||||
import { dashboardLayout as mockLayout } from 'spec/fixtures/mockDashboardLayout';
|
||||
import { initialState } from 'src/SqlLab/fixtures';
|
||||
@@ -333,58 +328,4 @@ describe('visibility handling for intersection observers', () => {
|
||||
expect(() => callback([nonIntersectingEntry])).not.toThrow();
|
||||
expect(callback([nonIntersectingEntry])).toBe(false);
|
||||
});
|
||||
|
||||
test('force-in-view event with no detail disconnects the observers for every row', () => {
|
||||
setup({ isComponentVisible: true });
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event(FORCE_IN_VIEW_EVENT));
|
||||
});
|
||||
|
||||
expect(mockDisconnect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('force-in-view event scoped to other rowIds does not disconnect this row', () => {
|
||||
setup({ isComponentVisible: true });
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(FORCE_IN_VIEW_EVENT, {
|
||||
detail: { rowIds: ['SOME_OTHER_ROW_ID'] },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockDisconnect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('force-in-view event scoped to this rowId disconnects the observers', () => {
|
||||
setup({ isComponentVisible: true });
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(FORCE_IN_VIEW_EVENT, {
|
||||
detail: { rowIds: [props.id] },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockDisconnect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('restore-virtualization event re-observes after a force-in-view', () => {
|
||||
setup({ isComponentVisible: true });
|
||||
expect(mockObserve).toHaveBeenCalledTimes(2);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event(FORCE_IN_VIEW_EVENT));
|
||||
});
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event(RESTORE_VIRTUALIZATION_EVENT));
|
||||
});
|
||||
|
||||
// The initial mount observes twice (enabler + disabler); restoring
|
||||
// after a force-in-view re-observes both again.
|
||||
expect(mockObserve).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,11 +46,7 @@ import WithPopoverMenu from 'src/dashboard/components/menu/WithPopoverMenu';
|
||||
import backgroundStyleOptions from 'src/dashboard/util/backgroundStyleOptions';
|
||||
import { BACKGROUND_TRANSPARENT } from 'src/dashboard/util/constants';
|
||||
import { isEmbedded } from 'src/dashboard/util/isEmbedded';
|
||||
import {
|
||||
EMPTY_CONTAINER_Z_INDEX,
|
||||
FORCE_IN_VIEW_EVENT,
|
||||
RESTORE_VIRTUALIZATION_EVENT,
|
||||
} from 'src/dashboard/constants';
|
||||
import { EMPTY_CONTAINER_Z_INDEX } from 'src/dashboard/constants';
|
||||
import { isCurrentUserBot } from 'src/utils/isBot';
|
||||
|
||||
export type RowProps = {
|
||||
@@ -211,49 +207,6 @@ const Row = memo((props: RowProps) => {
|
||||
observerEnabler.observe(element);
|
||||
observerDisabler.observe(element);
|
||||
}
|
||||
|
||||
// Client-side "Download as Image/PDF" (see src/utils/downloadUtils.ts)
|
||||
// dispatches these events so off-screen rows render before the capture
|
||||
// and lazy loading is restored afterwards. Without this, virtualized
|
||||
// charts are exported as loading spinners.
|
||||
//
|
||||
// The force event optionally carries a `rowIds` batch in its detail so
|
||||
// large dashboards can be force-rendered a few rows at a time instead
|
||||
// of every row at once (see FORCE_RENDER_BATCH_SIZE in
|
||||
// downloadUtils.ts). No detail means "force every row", preserving the
|
||||
// original single-shot behavior for any other future caller.
|
||||
const handleForceInView = (event: Event) => {
|
||||
const rowIds = (event as CustomEvent<{ rowIds?: string[] }>).detail
|
||||
?.rowIds;
|
||||
if (rowIds && !rowIds.includes(rowComponent.id as string)) {
|
||||
return;
|
||||
}
|
||||
observerEnabler?.disconnect();
|
||||
observerDisabler?.disconnect();
|
||||
setIsInView(true);
|
||||
};
|
||||
const handleRestoreVirtualization = () => {
|
||||
const el = containerRef.current;
|
||||
if (el) {
|
||||
observerEnabler?.observe(el);
|
||||
observerDisabler?.observe(el);
|
||||
}
|
||||
};
|
||||
window.addEventListener(FORCE_IN_VIEW_EVENT, handleForceInView);
|
||||
window.addEventListener(
|
||||
RESTORE_VIRTUALIZATION_EVENT,
|
||||
handleRestoreVirtualization,
|
||||
);
|
||||
|
||||
return () => {
|
||||
observerEnabler?.disconnect();
|
||||
observerDisabler?.disconnect();
|
||||
window.removeEventListener(FORCE_IN_VIEW_EVENT, handleForceInView);
|
||||
window.removeEventListener(
|
||||
RESTORE_VIRTUALIZATION_EVENT,
|
||||
handleRestoreVirtualization,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
return () => {
|
||||
@@ -352,7 +305,6 @@ const Row = memo((props: RowProps) => {
|
||||
backgroundStyle.className,
|
||||
)}
|
||||
data-test={`grid-row-${backgroundStyle.className}`}
|
||||
data-row-id={rowComponent.id}
|
||||
ref={containerRef}
|
||||
editMode={editMode}
|
||||
>
|
||||
|
||||
-65
@@ -48,50 +48,10 @@ const mockProps: TabsRendererProps = {
|
||||
tabBarPaddingLeft: 16,
|
||||
};
|
||||
|
||||
// Mirrors the tab label markup of Tab.tsx: the title lives in a
|
||||
// .dragdroppable-tab container and renders as a textarea via EditableTitle
|
||||
const draggableTabProps: TabsRendererProps = {
|
||||
...mockProps,
|
||||
editMode: true,
|
||||
tabItems: [
|
||||
{
|
||||
...mockTabItems[0],
|
||||
label: (
|
||||
<div className="dragdroppable-tab">
|
||||
<span className="editable-title">
|
||||
<textarea defaultValue="Tab 1" />
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
mockTabItems[1],
|
||||
],
|
||||
};
|
||||
|
||||
// jsdom implements no PointerEvent, so @dnd-kit's PointerSensor never activates
|
||||
class MockPointerEvent extends MouseEvent {
|
||||
isPrimary: boolean;
|
||||
|
||||
pointerId: number;
|
||||
|
||||
constructor(type: string, init: PointerEventInit = {}) {
|
||||
super(type, init);
|
||||
this.isPrimary = init.isPrimary ?? true;
|
||||
this.pointerId = init.pointerId ?? 1;
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('TabsRenderer', () => {
|
||||
const { PointerEvent: OriginalPointerEvent } = globalThis;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
globalThis.PointerEvent = MockPointerEvent as typeof PointerEvent;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.PointerEvent = OriginalPointerEvent;
|
||||
});
|
||||
|
||||
test('renders tabs container with correct test attributes', () => {
|
||||
@@ -239,29 +199,4 @@ describe('TabsRenderer', () => {
|
||||
expect(screen.getByText('Tab 1 Content')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Tab 2 Content')).not.toBeInTheDocument(); // Not active
|
||||
});
|
||||
|
||||
test('drags from the tab title and shows the drag indicator only then', () => {
|
||||
render(<TabsRenderer {...draggableTabProps} />);
|
||||
const container = screen.getByTestId('dashboard-component-tabs');
|
||||
const title = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||
|
||||
// At rest the title keeps the text cursor it sets on itself
|
||||
expect(container).not.toHaveStyleRule('cursor', 'move', {
|
||||
target: '.dragdroppable-tab *',
|
||||
});
|
||||
|
||||
// Pressing on the title and moving past the sensor's distance constraint
|
||||
// has to start a drag: the title covers most of the tab, so a tab that
|
||||
// cannot be dragged from there cannot really be dragged at all
|
||||
fireEvent.pointerDown(title, { button: 0, isPrimary: true, clientX: 0 });
|
||||
fireEvent.pointerMove(document, {
|
||||
button: 0,
|
||||
isPrimary: true,
|
||||
clientX: 50,
|
||||
});
|
||||
|
||||
expect(container).toHaveStyleRule('cursor', 'move', {
|
||||
target: '.dragdroppable-tab *',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+1
-7
@@ -72,16 +72,10 @@ const StyledTabsContainer = styled.div<{ isDragging?: boolean }>`
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide ink-bar during drag */
|
||||
${({ isDragging }) =>
|
||||
isDragging &&
|
||||
`
|
||||
/* Show the drag indicator during drag, over the tab title textarea too.
|
||||
The doubled parent outranks the title's own cursor; a single & loses. */
|
||||
&& .dragdroppable-tab * {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
/* Hide ink-bar during drag */
|
||||
.ant-tabs-card > .ant-tabs-nav .ant-tabs-ink-bar,
|
||||
.ant-tabs > .ant-tabs-nav .ant-tabs-ink-bar {
|
||||
display: none !important;
|
||||
|
||||
+3
-86
@@ -547,7 +547,7 @@ test('Clear All stages filter_select clear without dispatching until Apply', asy
|
||||
});
|
||||
expect(updateDataMaskSpy).toHaveBeenCalledWith(filterId, {
|
||||
id: filterId,
|
||||
filterState: { value: null, validateStatus: undefined },
|
||||
filterState: { value: undefined, validateStatus: undefined },
|
||||
extraFormData: {},
|
||||
});
|
||||
updateDataMaskSpy.mockRestore();
|
||||
@@ -685,95 +685,12 @@ test('Clear All + Apply only dispatches for filters present in dataMask', async
|
||||
expect(updateDataMaskSpy).toHaveBeenCalledTimes(1);
|
||||
expect(updateDataMaskSpy).toHaveBeenCalledWith(idInMask, {
|
||||
id: idInMask,
|
||||
filterState: { value: null, validateStatus: undefined },
|
||||
filterState: { value: undefined, validateStatus: undefined },
|
||||
extraFormData: {},
|
||||
});
|
||||
updateDataMaskSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('Clear All in horizontal bar does not re-apply default values', async () => {
|
||||
fetchMock.post(
|
||||
'glob:*/api/v1/chart/data',
|
||||
{
|
||||
result: [
|
||||
{
|
||||
data: [{ test_column: 'East' }, { test_column: 'West' }],
|
||||
colnames: ['test_column'],
|
||||
coltypes: [1],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ name: 'horizontal-clear-chart-data' },
|
||||
);
|
||||
const filterId = 'NATIVE_FILTER-horizontal-default';
|
||||
const updateDataMaskSpy = jest.spyOn(dataMaskActions, 'updateDataMask');
|
||||
const filterWithDefault = createFilter({
|
||||
id: filterId,
|
||||
name: 'Region',
|
||||
filterType: 'filter_select',
|
||||
targets: [{ datasetId: 7, column: { name: 'test_column' } }],
|
||||
defaultDataMask: {
|
||||
filterState: { value: ['East'] },
|
||||
extraFormData: {
|
||||
filters: [{ col: 'test_column', op: 'IN', val: ['East'] }],
|
||||
},
|
||||
},
|
||||
chartsInScope: [18],
|
||||
});
|
||||
const stateHorizontal = {
|
||||
...stateWithoutNativeFilters,
|
||||
dashboardInfo: {
|
||||
id: 1,
|
||||
dash_edit_perm: true,
|
||||
filterBarOrientation: FilterBarOrientation.Horizontal,
|
||||
metadata: {
|
||||
native_filter_configuration: [filterWithDefault],
|
||||
chart_configuration: {},
|
||||
},
|
||||
},
|
||||
dashboardState: {
|
||||
...stateWithoutNativeFilters.dashboardState,
|
||||
activeTabs: ['ROOT_ID'],
|
||||
},
|
||||
dataMask: {
|
||||
[filterId]: createDataMask(filterId, ['East'], {
|
||||
filters: [{ col: 'test_column', op: 'IN', val: ['East'] }],
|
||||
}),
|
||||
},
|
||||
nativeFilters: {
|
||||
filters: { [filterId]: filterWithDefault },
|
||||
filtersState: {},
|
||||
},
|
||||
};
|
||||
|
||||
render(<FilterBar orientation={FilterBarOrientation.Horizontal} />, {
|
||||
initialState: stateHorizontal,
|
||||
useDnd: true,
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
});
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
const clearBtn = screen.getByTestId(getTestId('clear-button'));
|
||||
expect(clearBtn).not.toBeDisabled();
|
||||
await act(async () => {
|
||||
userEvent.click(clearBtn);
|
||||
});
|
||||
// Let the clear-all trigger round-trip through the filter plugin
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
// The staged clear must survive the trigger completing: the default value
|
||||
// must not be re-applied and Apply must stay enabled
|
||||
expect(updateDataMaskSpy).not.toHaveBeenCalled();
|
||||
expect(screen.queryByTitle('East')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId(getTestId('apply-button'))).not.toBeDisabled();
|
||||
updateDataMaskSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('FilterBar Clear All only clears in-scope filters, not out-of-scope ones', async () => {
|
||||
const inScopeFilterId = 'NATIVE_FILTER-in-scope';
|
||||
const outOfScopeRequiredFilterId = 'NATIVE_FILTER-out-of-scope-required';
|
||||
@@ -914,7 +831,7 @@ test('FilterBar Clear All only clears in-scope filters, not out-of-scope ones',
|
||||
|
||||
expect(updateDataMaskSpy).toHaveBeenCalledWith(inScopeFilterId, {
|
||||
id: inScopeFilterId,
|
||||
filterState: { value: null, validateStatus: undefined },
|
||||
filterState: { value: undefined, validateStatus: undefined },
|
||||
extraFormData: {},
|
||||
});
|
||||
|
||||
|
||||
@@ -517,21 +517,22 @@ const FilterBar: FC<FiltersBarProps> = ({
|
||||
// Only clear in-scope filters
|
||||
if (!inScopeFilterIds.has(id)) return;
|
||||
|
||||
// Cleared values stage as explicit null ([null, null] for ranges), never
|
||||
// undefined: the select plugin's init effect treats undefined as
|
||||
// "uninitialized" and would re-apply default values once the clear-all
|
||||
// trigger completes.
|
||||
const clearedValue = filterType === 'filter_range' ? [null, null] : null;
|
||||
// Range filters use [null, null] as the cleared value; others use undefined
|
||||
const clearedValue =
|
||||
filterType === 'filter_range' ? [null, null] : undefined;
|
||||
const isRequired = !!filter.controlValues?.enableEmptyFilter;
|
||||
if (dataMaskSelected[id]) {
|
||||
// Stage the cleared value locally; do NOT dispatch to Redux here.
|
||||
// Persistence happens when the user clicks Apply.
|
||||
setDataMaskSelected(draft => {
|
||||
if (draft[id].filterState?.value !== undefined) {
|
||||
draft[id].filterState!.value = clearedValue;
|
||||
}
|
||||
draft[id].extraFormData = {};
|
||||
const { filterState } = draft[id];
|
||||
if (filterState) {
|
||||
filterState.value = clearedValue;
|
||||
filterState.validateStatus = isRequired ? 'error' : undefined;
|
||||
if (draft[id].filterState) {
|
||||
draft[id].filterState!.validateStatus = isRequired
|
||||
? 'error'
|
||||
: undefined;
|
||||
}
|
||||
});
|
||||
newClearAllTriggers[id] = true;
|
||||
|
||||
@@ -51,16 +51,3 @@ export const DEFAULT_CROSS_FILTER_SCOPING: NativeFilterScope = {
|
||||
|
||||
export const CHART_WIDTH = 4;
|
||||
export const CHART_HEIGHT = 50;
|
||||
|
||||
/**
|
||||
* Window events used to coordinate dashboard virtualization when capturing the
|
||||
* dashboard for a client-side export (Download as Image/PDF).
|
||||
*
|
||||
* When DASHBOARD_VIRTUALIZATION is enabled, charts more than a viewport away
|
||||
* are unmounted, so a naive DOM capture records loading spinners instead of the
|
||||
* charts. The export utilities dispatch FORCE_IN_VIEW_EVENT to make every Row
|
||||
* render its content, wait for the charts to finish loading, capture, then
|
||||
* dispatch RESTORE_VIRTUALIZATION_EVENT to re-enable lazy loading.
|
||||
*/
|
||||
export const FORCE_IN_VIEW_EVENT = 'superset-force-all-in-view';
|
||||
export const RESTORE_VIRTUALIZATION_EVENT = 'superset-restore-virtualization';
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { executeQuery } from './actions';
|
||||
import fetchMock from 'fetch-mock';
|
||||
|
||||
fetchMock.post('glob:*/sqllab/execute', { result: [] });
|
||||
|
||||
afterAll(() => {
|
||||
fetchMock.clearHistory().removeRoutes();
|
||||
});
|
||||
|
||||
test('executeQuery', async () => {
|
||||
const mockDispatch = jest.fn();
|
||||
const mockedQueryExecutePayload = {
|
||||
client_id: 'client_id_1',
|
||||
database_id: 1,
|
||||
runAsync: false,
|
||||
catalog: null,
|
||||
schema: 'schema_1',
|
||||
sql: '1',
|
||||
tmp_table_name: 'tmp_table_1',
|
||||
select_as_cta: false,
|
||||
ctas_method: 'SELECT',
|
||||
queryLimit: 10,
|
||||
expand_data: false,
|
||||
};
|
||||
|
||||
const returnedDispatchFunc = executeQuery(mockedQueryExecutePayload);
|
||||
await returnedDispatchFunc(mockDispatch);
|
||||
|
||||
const [
|
||||
[setQueryIsLoadingActionObject],
|
||||
[setQueryResultActionObject],
|
||||
[setQueryIsNotLoadingActionObject],
|
||||
] = mockDispatch.mock.calls;
|
||||
expect(setQueryIsLoadingActionObject).toStrictEqual({
|
||||
type: 'SET_QUERY_IS_LOADING',
|
||||
payload: true,
|
||||
});
|
||||
expect(setQueryResultActionObject).toStrictEqual({
|
||||
type: 'SET_QUERY_RESULT',
|
||||
payload: {
|
||||
result: [],
|
||||
},
|
||||
});
|
||||
expect(setQueryIsNotLoadingActionObject).toStrictEqual({
|
||||
type: 'SET_QUERY_IS_LOADING',
|
||||
payload: false,
|
||||
});
|
||||
});
|
||||
@@ -67,7 +67,7 @@ export function executeQuery(payload: QueryExecutePayload) {
|
||||
const result = await executeQueryApi(payload);
|
||||
dispatch(setQueryResult(result as QueryExecuteResponse));
|
||||
} catch (error) {
|
||||
dispatch(setQueryError((error as Error).message));
|
||||
dispatch(setQueryError(error.message));
|
||||
} finally {
|
||||
dispatch(setQueryIsLoading(false));
|
||||
}
|
||||
|
||||
@@ -31,15 +31,6 @@ import {
|
||||
import ChartContainerComponent from 'src/explore/components/ExploreChartPanel';
|
||||
import { setItem, LocalStorageKeys } from 'src/utils/localStorageHelpers';
|
||||
|
||||
jest.mock('./StandaloneDownloadControl', () => ({
|
||||
__esModule: true,
|
||||
default: () => (
|
||||
<button type="button" data-test="standalone-download-button">
|
||||
Download
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
// Cast to accept partial mock props in tests
|
||||
const ChartContainer = ChartContainerComponent as unknown as React.FC<
|
||||
Record<string, any>
|
||||
@@ -84,10 +75,6 @@ const createProps = (overrides = {}) => ({
|
||||
describe('ChartContainer', () => {
|
||||
jest.setTimeout(10000);
|
||||
|
||||
afterEach(() => {
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
});
|
||||
|
||||
test('renders when vizType is line', () => {
|
||||
const props = createProps();
|
||||
expect(isValidElement(<ChartContainer {...props} />)).toBe(true);
|
||||
@@ -201,77 +188,4 @@ describe('ChartContainer', () => {
|
||||
expect(await screen.findByRole('timer')).toBeInTheDocument();
|
||||
expect(gutter).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('does not render standalone download control when show_download is absent', () => {
|
||||
const props = createProps({
|
||||
standalone: true,
|
||||
can_download: true,
|
||||
});
|
||||
|
||||
render(<ChartContainer {...props} />, { useRedux: true });
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('standalone-download-button'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not render standalone download control when show_download is disabled', () => {
|
||||
window.history.replaceState({}, '', '?show_download=0');
|
||||
|
||||
const props = createProps({
|
||||
standalone: true,
|
||||
can_download: true,
|
||||
});
|
||||
|
||||
render(<ChartContainer {...props} />, { useRedux: true });
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('standalone-download-button'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders standalone download control when show_download is enabled and user can download', () => {
|
||||
window.history.replaceState({}, '', '?show_download=1');
|
||||
|
||||
const props = createProps({
|
||||
standalone: true,
|
||||
can_download: true,
|
||||
});
|
||||
|
||||
render(<ChartContainer {...props} />, { useRedux: true });
|
||||
|
||||
expect(
|
||||
screen.getByTestId('standalone-download-button'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not render standalone download control when user cannot download', () => {
|
||||
window.history.replaceState({}, '', '?show_download=1');
|
||||
|
||||
const props = createProps({
|
||||
standalone: true,
|
||||
can_download: false,
|
||||
});
|
||||
|
||||
render(<ChartContainer {...props} />, { useRedux: true });
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('standalone-download-button'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not render standalone download control outside standalone mode', () => {
|
||||
window.history.replaceState({}, '', '?show_download=1');
|
||||
|
||||
const props = createProps({
|
||||
standalone: false,
|
||||
can_download: true,
|
||||
});
|
||||
|
||||
render(<ChartContainer {...props} />, { useRedux: true });
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('standalone-download-button'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
-147
@@ -1,147 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { render, screen, userEvent } from 'spec/helpers/testing-library';
|
||||
import { VizType } from '@superset-ui/core';
|
||||
import { ExportStatus } from 'src/components/StreamingExportModal/StreamingExportModal';
|
||||
import { useExploreDataExport } from '../useExploreAdditionalActionsMenu/useExploreDataExport';
|
||||
import StandaloneDownloadControl from './StandaloneDownloadControl';
|
||||
|
||||
jest.mock('../useExploreAdditionalActionsMenu/useExploreDataExport', () => ({
|
||||
useExploreDataExport: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUseExploreDataExport = useExploreDataExport as jest.MockedFunction<
|
||||
typeof useExploreDataExport
|
||||
>;
|
||||
|
||||
const exportCSV = jest.fn();
|
||||
const exportJson = jest.fn();
|
||||
const exportExcel = jest.fn();
|
||||
|
||||
const streamingExportState = {
|
||||
isVisible: false,
|
||||
progress: {
|
||||
rowsProcessed: 0,
|
||||
totalRows: undefined,
|
||||
totalSize: 0,
|
||||
speed: 0,
|
||||
mbPerSecond: 0,
|
||||
elapsedTime: 0,
|
||||
status: ExportStatus.STREAMING,
|
||||
},
|
||||
onCancel: jest.fn(),
|
||||
onRetry: jest.fn(),
|
||||
onDownload: jest.fn(),
|
||||
};
|
||||
|
||||
const latestQueryFormData = {
|
||||
viz_type: VizType.Histogram,
|
||||
datasource: '49__table',
|
||||
};
|
||||
|
||||
describe('StandaloneDownloadControl', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockUseExploreDataExport.mockReturnValue({
|
||||
exportCSV,
|
||||
exportCSVPivoted: jest.fn(),
|
||||
exportJson,
|
||||
exportExcel,
|
||||
handleExportError: jest.fn(),
|
||||
streamingExportState,
|
||||
});
|
||||
});
|
||||
|
||||
test('renders the download button', () => {
|
||||
render(
|
||||
<StandaloneDownloadControl
|
||||
latestQueryFormData={latestQueryFormData}
|
||||
canDownload
|
||||
/>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('standalone-download-button'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders export options when download button is clicked', async () => {
|
||||
render(
|
||||
<StandaloneDownloadControl
|
||||
latestQueryFormData={latestQueryFormData}
|
||||
canDownload
|
||||
/>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
userEvent.click(screen.getByTestId('standalone-download-button'));
|
||||
|
||||
expect(await screen.findByText('Export to .CSV')).toBeInTheDocument();
|
||||
expect(screen.getByText('Export to .JSON')).toBeInTheDocument();
|
||||
expect(screen.getByText('Export to Excel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('exports CSV when CSV option is clicked', async () => {
|
||||
render(
|
||||
<StandaloneDownloadControl
|
||||
latestQueryFormData={latestQueryFormData}
|
||||
canDownload
|
||||
/>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
userEvent.click(screen.getByTestId('standalone-download-button'));
|
||||
userEvent.click(await screen.findByText('Export to .CSV'));
|
||||
|
||||
expect(exportCSV).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('exports JSON when JSON option is clicked', async () => {
|
||||
render(
|
||||
<StandaloneDownloadControl
|
||||
latestQueryFormData={latestQueryFormData}
|
||||
canDownload
|
||||
/>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
userEvent.click(screen.getByTestId('standalone-download-button'));
|
||||
userEvent.click(await screen.findByText('Export to .JSON'));
|
||||
|
||||
expect(exportJson).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('exports Excel when Excel option is clicked', async () => {
|
||||
render(
|
||||
<StandaloneDownloadControl
|
||||
latestQueryFormData={latestQueryFormData}
|
||||
canDownload
|
||||
/>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
userEvent.click(screen.getByTestId('standalone-download-button'));
|
||||
userEvent.click(await screen.findByText('Export to Excel'));
|
||||
|
||||
expect(exportExcel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
-108
@@ -1,108 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { JsonObject, LatestQueryFormData } from '@superset-ui/core';
|
||||
import { Button, Dropdown } from '@superset-ui/core/components';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { css, useTheme } from '@apache-superset/core/theme';
|
||||
import { StreamingExportModal } from 'src/components/StreamingExportModal';
|
||||
import { Slice } from 'src/types/Chart';
|
||||
import { useExploreDataExport } from '../useExploreAdditionalActionsMenu/useExploreDataExport';
|
||||
|
||||
interface StandaloneDownloadControlProps {
|
||||
latestQueryFormData: LatestQueryFormData;
|
||||
canDownload: boolean;
|
||||
slice?: Slice;
|
||||
ownState?: JsonObject;
|
||||
}
|
||||
|
||||
const StandaloneDownloadControl = ({
|
||||
latestQueryFormData,
|
||||
canDownload,
|
||||
slice,
|
||||
ownState,
|
||||
}: StandaloneDownloadControlProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const { exportCSV, exportJson, exportExcel, streamingExportState } =
|
||||
useExploreDataExport({
|
||||
latestQueryFormData,
|
||||
canDownloadCSV: canDownload,
|
||||
slice,
|
||||
ownState,
|
||||
});
|
||||
|
||||
const downloadMenuItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'export_csv',
|
||||
label: t('Export to .CSV'),
|
||||
onClick: exportCSV,
|
||||
},
|
||||
{
|
||||
key: 'export_json',
|
||||
label: t('Export to .JSON'),
|
||||
onClick: exportJson,
|
||||
},
|
||||
{
|
||||
key: 'export_excel',
|
||||
label: t('Export to Excel'),
|
||||
onClick: exportExcel,
|
||||
},
|
||||
],
|
||||
[exportCSV, exportExcel, exportJson],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
menu={{
|
||||
selectable: false,
|
||||
items: downloadMenuItems,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
aria-label={t('Download')}
|
||||
data-test="standalone-download-button"
|
||||
css={css`
|
||||
position: absolute;
|
||||
top: ${theme.sizeUnit * 2}px;
|
||||
right: ${theme.sizeUnit * 2}px;
|
||||
z-index: 1;
|
||||
`}
|
||||
>
|
||||
<Icons.DownloadOutlined />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
|
||||
<StreamingExportModal
|
||||
visible={streamingExportState.isVisible}
|
||||
onCancel={streamingExportState.onCancel}
|
||||
onRetry={streamingExportState.onRetry}
|
||||
onDownload={streamingExportState.onDownload}
|
||||
progress={streamingExportState.progress}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default StandaloneDownloadControl;
|
||||
@@ -32,8 +32,6 @@ import {
|
||||
getExtensionsRegistry,
|
||||
} from '@superset-ui/core';
|
||||
import { Alert } from '@apache-superset/core/components';
|
||||
import { URL_PARAMS } from 'src/constants';
|
||||
import { getUrlParam } from 'src/utils/urlUtils';
|
||||
import { css, styled, useTheme } from '@apache-superset/core/theme';
|
||||
import ChartContainer from 'src/components/Chart/ChartContainer';
|
||||
import { updateExploreChartState } from 'src/explore/actions/exploreActions';
|
||||
@@ -58,7 +56,6 @@ import { DataTablesPane } from '../DataTablesPane';
|
||||
import { ChartPills } from '../ChartPills';
|
||||
import { ExploreAlert } from '../ExploreAlert';
|
||||
import useResizeDetectorByObserver from './useResizeDetectorByObserver';
|
||||
import StandaloneDownloadControl from './StandaloneDownloadControl';
|
||||
|
||||
const extensionsRegistry = getExtensionsRegistry();
|
||||
const DefaultHeader: React.FC<{ children?: React.ReactNode }> = ({
|
||||
@@ -180,7 +177,6 @@ const ExploreChartPanel = ({
|
||||
}: ExploreChartPanelProps) => {
|
||||
const theme = useTheme();
|
||||
const dispatch = useDispatch();
|
||||
const showDownload = getUrlParam(URL_PARAMS.showDownload);
|
||||
const gutterMargin = theme.sizeUnit * GUTTER_SIZE_FACTOR;
|
||||
const gutterHeight = theme.sizeUnit * GUTTER_SIZE_FACTOR;
|
||||
|
||||
@@ -556,23 +552,7 @@ const ExploreChartPanel = ({
|
||||
document.body.className += ` ${standaloneClass}`;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
id="app"
|
||||
data-test="standalone-app"
|
||||
css={css`
|
||||
position: relative;
|
||||
height: 100%;
|
||||
`}
|
||||
>
|
||||
{showDownload && canDownload && (
|
||||
<StandaloneDownloadControl
|
||||
latestQueryFormData={chart.latestQueryFormData}
|
||||
canDownload={canDownload}
|
||||
slice={slice}
|
||||
ownState={mergedOwnState}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div id="app" data-test="standalone-app">
|
||||
{standaloneChartBody}
|
||||
</div>
|
||||
);
|
||||
|
||||
+195
-13
@@ -46,6 +46,7 @@ import {
|
||||
} from '@superset-ui/core/components';
|
||||
import { Menu, MenuProps } from '@superset-ui/core/components/Menu';
|
||||
import { useToasts } from 'src/components/MessageToasts/withToasts';
|
||||
import { DEFAULT_CSV_STREAMING_ROW_THRESHOLD } from 'src/constants';
|
||||
import { exportChart, getChartKey } from 'src/explore/exploreUtils';
|
||||
import downloadAsImage from 'src/utils/downloadAsImage';
|
||||
import downloadAsPdf from 'src/utils/downloadAsPdf';
|
||||
@@ -64,14 +65,16 @@ import {
|
||||
LOG_ACTIONS_CHART_DOWNLOAD_AS_XLS,
|
||||
} from 'src/logger/LogUtils';
|
||||
import exportPivotExcel from 'src/utils/downloadAsPivotExcel';
|
||||
import { StreamingProgress } from 'src/components/StreamingExportModal';
|
||||
import {
|
||||
useStreamingExport,
|
||||
StreamingProgress,
|
||||
} from 'src/components/StreamingExportModal';
|
||||
import { Slice } from 'src/types/Chart';
|
||||
import { ChartState, ExplorePageInitialData } from 'src/explore/types';
|
||||
import { ReportObject } from 'src/features/reports/types';
|
||||
import ViewQueryModal from '../controls/ViewQueryModal';
|
||||
import EmbedCodeContent from '../EmbedCodeContent';
|
||||
import { useDashboardsMenuItems } from './DashboardsSubMenu';
|
||||
import { useExploreDataExport } from './useExploreDataExport';
|
||||
|
||||
export const SEARCH_THRESHOLD = 10;
|
||||
|
||||
@@ -314,6 +317,11 @@ export const useExploreAdditionalActionsMenu = (
|
||||
const chart = useSelector<ExploreState, ChartState | undefined>(state =>
|
||||
state.explore ? state.charts?.[getChartKey(state.explore)] : undefined,
|
||||
);
|
||||
const streamingThreshold = useSelector<ExploreState, number>(
|
||||
state =>
|
||||
state.common?.conf?.CSV_STREAMING_ROW_THRESHOLD ||
|
||||
DEFAULT_CSV_STREAMING_ROW_THRESHOLD,
|
||||
);
|
||||
const exploreChartState = useSelector<ExploreState, JsonObject | undefined>(
|
||||
state => {
|
||||
const chartKey = state.explore ? getChartKey(state.explore) : undefined;
|
||||
@@ -354,20 +362,32 @@ export const useExploreAdditionalActionsMenu = (
|
||||
);
|
||||
|
||||
// Streaming export state and handlers
|
||||
const [isStreamingModalVisible, setIsStreamingModalVisible] = useState(false);
|
||||
const {
|
||||
exportCSV,
|
||||
exportCSVPivoted,
|
||||
exportJson,
|
||||
exportExcel,
|
||||
handleExportError,
|
||||
streamingExportState,
|
||||
} = useExploreDataExport({
|
||||
latestQueryFormData,
|
||||
canDownloadCSV,
|
||||
slice,
|
||||
ownState,
|
||||
progress,
|
||||
isExporting: _isExporting,
|
||||
startExport,
|
||||
cancelExport: _cancelExport,
|
||||
resetExport,
|
||||
retryExport,
|
||||
} = useStreamingExport({
|
||||
onComplete: () => {
|
||||
// Don't show toast here - wait for user to click Download button
|
||||
},
|
||||
onError: () => {
|
||||
addDangerToast(t('Export failed - please try again'));
|
||||
},
|
||||
});
|
||||
|
||||
const handleCloseStreamingModal = useCallback(() => {
|
||||
setIsStreamingModalVisible(false);
|
||||
resetExport();
|
||||
}, [resetExport]);
|
||||
|
||||
const handleDownloadComplete = useCallback(() => {
|
||||
addSuccessToast(t('CSV file downloaded successfully'));
|
||||
}, [addSuccessToast]);
|
||||
|
||||
// Use the updated report menu items hook
|
||||
const reportMenuItem = useHeaderReportMenuItems({
|
||||
chart,
|
||||
@@ -419,6 +439,159 @@ export const useExploreAdditionalActionsMenu = (
|
||||
}
|
||||
}, [addDangerToast, latestQueryFormData, permalinkChartState]);
|
||||
|
||||
const handleExportError = useCallback(
|
||||
(error: unknown) => {
|
||||
const exportError = error as Error & {
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
response?: { status?: number };
|
||||
};
|
||||
const status = exportError.status || exportError.response?.status;
|
||||
if (status === 413) {
|
||||
addDangerToast(
|
||||
t(
|
||||
'The chart data is too large to download. Please try reducing the date range, limiting rows, or using fewer columns.',
|
||||
),
|
||||
);
|
||||
} else {
|
||||
const errorMessage =
|
||||
exportError.message ||
|
||||
exportError.statusText ||
|
||||
t(
|
||||
'Failed to export chart data. Please try again or contact your administrator.',
|
||||
);
|
||||
addDangerToast(errorMessage);
|
||||
}
|
||||
},
|
||||
[addDangerToast],
|
||||
);
|
||||
|
||||
const exportCSV = useCallback(async () => {
|
||||
if (!canDownloadCSV) return null;
|
||||
|
||||
// Determine row count for streaming threshold check
|
||||
let actualRowCount;
|
||||
const isTableViz = latestQueryFormData?.viz_type === 'table';
|
||||
const queriesResponse = chart?.queriesResponse;
|
||||
|
||||
if (
|
||||
isTableViz &&
|
||||
queriesResponse &&
|
||||
queriesResponse.length > 1 &&
|
||||
queriesResponse[1]?.data?.[0]?.rowcount
|
||||
) {
|
||||
actualRowCount = queriesResponse[1].data[0].rowcount;
|
||||
} else if (queriesResponse && queriesResponse[0]?.sql_rowcount != null) {
|
||||
actualRowCount = queriesResponse[0].sql_rowcount;
|
||||
} else if (queriesResponse && queriesResponse[0]?.rowcount != null) {
|
||||
actualRowCount = queriesResponse[0].rowcount;
|
||||
} else {
|
||||
actualRowCount = latestQueryFormData?.row_limit;
|
||||
}
|
||||
|
||||
// Check if streaming should be used
|
||||
const shouldUseStreaming =
|
||||
actualRowCount && actualRowCount >= streamingThreshold;
|
||||
|
||||
let filename: string | undefined;
|
||||
if (shouldUseStreaming) {
|
||||
const now = new Date();
|
||||
const date = now.toISOString().slice(0, 10);
|
||||
const time = now.toISOString().slice(11, 19).replace(/:/g, '');
|
||||
const timestamp = `_${date}_${time}`;
|
||||
const chartName =
|
||||
slice?.slice_name || latestQueryFormData.viz_type || 'chart';
|
||||
const safeChartName = chartName.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
filename = `${safeChartName}${timestamp}.csv`;
|
||||
}
|
||||
|
||||
try {
|
||||
await exportChart({
|
||||
formData: latestQueryFormData as QueryFormData,
|
||||
ownState,
|
||||
resultType: 'full',
|
||||
resultFormat: 'csv',
|
||||
onStartStreamingExport: shouldUseStreaming
|
||||
? exportParams => {
|
||||
if (exportParams.url) {
|
||||
setIsStreamingModalVisible(true);
|
||||
startExport({
|
||||
...exportParams,
|
||||
url: exportParams.url,
|
||||
filename,
|
||||
expectedRows: actualRowCount,
|
||||
exportType: exportParams.exportType as 'csv' | 'xlsx',
|
||||
});
|
||||
}
|
||||
}
|
||||
: null,
|
||||
});
|
||||
} catch (error) {
|
||||
handleExportError(error);
|
||||
}
|
||||
return null;
|
||||
}, [
|
||||
canDownloadCSV,
|
||||
latestQueryFormData,
|
||||
ownState,
|
||||
chart,
|
||||
streamingThreshold,
|
||||
slice,
|
||||
startExport,
|
||||
handleExportError,
|
||||
]);
|
||||
|
||||
const exportCSVPivoted = useCallback(async () => {
|
||||
if (!canDownloadCSV) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await exportChart({
|
||||
formData: latestQueryFormData as QueryFormData,
|
||||
ownState,
|
||||
resultType: 'post_processed',
|
||||
resultFormat: 'csv',
|
||||
});
|
||||
} catch (error) {
|
||||
handleExportError(error);
|
||||
}
|
||||
return null;
|
||||
}, [canDownloadCSV, latestQueryFormData, ownState, handleExportError]);
|
||||
|
||||
const exportJson = useCallback(async () => {
|
||||
if (!canDownloadCSV) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await exportChart({
|
||||
formData: latestQueryFormData as QueryFormData,
|
||||
ownState,
|
||||
resultType: 'results',
|
||||
resultFormat: 'json',
|
||||
});
|
||||
} catch (error) {
|
||||
handleExportError(error);
|
||||
}
|
||||
return null;
|
||||
}, [canDownloadCSV, latestQueryFormData, ownState, handleExportError]);
|
||||
|
||||
const exportExcel = useCallback(async () => {
|
||||
if (!canDownloadCSV) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await exportChart({
|
||||
formData: latestQueryFormData as QueryFormData,
|
||||
ownState,
|
||||
resultType: 'results',
|
||||
resultFormat: 'xlsx',
|
||||
});
|
||||
} catch (error) {
|
||||
handleExportError(error);
|
||||
}
|
||||
return null;
|
||||
}, [canDownloadCSV, latestQueryFormData, ownState, handleExportError]);
|
||||
|
||||
const copyLink = useCallback(async () => {
|
||||
try {
|
||||
if (!latestQueryFormData?.datasource) {
|
||||
@@ -1073,5 +1246,14 @@ export const useExploreAdditionalActionsMenu = (
|
||||
canExportImage,
|
||||
]);
|
||||
|
||||
// Return streaming modal state and handlers for parent to render
|
||||
const streamingExportState = {
|
||||
isVisible: isStreamingModalVisible,
|
||||
progress,
|
||||
onCancel: handleCloseStreamingModal,
|
||||
onRetry: retryExport,
|
||||
onDownload: handleDownloadComplete,
|
||||
};
|
||||
|
||||
return [menu, isDropdownVisible, setIsDropdownVisible, streamingExportState];
|
||||
};
|
||||
|
||||
-223
@@ -1,223 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import * as exploreUtils from 'src/explore/exploreUtils';
|
||||
import { useExploreDataExport } from './useExploreDataExport';
|
||||
|
||||
const mockAddDangerToast = jest.fn();
|
||||
const mockAddSuccessToast = jest.fn();
|
||||
const mockStartExport = jest.fn();
|
||||
const mockResetExport = jest.fn();
|
||||
const mockRetryExport = jest.fn();
|
||||
|
||||
jest.mock('src/components/MessageToasts/withToasts', () => ({
|
||||
useToasts: () => ({
|
||||
addDangerToast: mockAddDangerToast,
|
||||
addSuccessToast: mockAddSuccessToast,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('src/components/StreamingExportModal', () => ({
|
||||
useStreamingExport: jest.fn(() => ({
|
||||
progress: {
|
||||
rowsProcessed: 0,
|
||||
totalRows: undefined,
|
||||
totalSize: 0,
|
||||
speed: 0,
|
||||
mbPerSecond: 0,
|
||||
elapsedTime: 0,
|
||||
status: 'streaming',
|
||||
},
|
||||
startExport: mockStartExport,
|
||||
resetExport: mockResetExport,
|
||||
retryExport: mockRetryExport,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('src/explore/exploreUtils', () => ({
|
||||
__esModule: true,
|
||||
...jest.requireActual('src/explore/exploreUtils'),
|
||||
exportChart: jest.fn(),
|
||||
getChartKey: jest.fn(() => 1),
|
||||
}));
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useSelector: jest.fn(callback =>
|
||||
callback({
|
||||
charts: {
|
||||
1: {
|
||||
queriesResponse: [{ sql_rowcount: 10 }],
|
||||
},
|
||||
},
|
||||
explore: {
|
||||
slice: {
|
||||
slice_id: 1,
|
||||
slice_name: 'Test Chart',
|
||||
},
|
||||
},
|
||||
common: {
|
||||
conf: {
|
||||
CSV_STREAMING_ROW_THRESHOLD: 1000,
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
const mockExportChart = exploreUtils.exportChart as jest.Mock;
|
||||
|
||||
const defaultProps = {
|
||||
latestQueryFormData: {
|
||||
datasource: '1__table',
|
||||
viz_type: 'table',
|
||||
row_limit: 10000,
|
||||
} as any,
|
||||
canDownloadCSV: true,
|
||||
slice: {
|
||||
slice_id: 1,
|
||||
slice_name: 'Test Chart',
|
||||
} as any,
|
||||
ownState: {},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockExportChart.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
test('exports CSV with full result type', async () => {
|
||||
const { result } = renderHook(() => useExploreDataExport(defaultProps));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.exportCSV();
|
||||
});
|
||||
|
||||
expect(mockExportChart).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
formData: defaultProps.latestQueryFormData,
|
||||
ownState: defaultProps.ownState,
|
||||
resultType: 'full',
|
||||
resultFormat: 'csv',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('exports pivoted CSV with post processed result type', async () => {
|
||||
const { result } = renderHook(() => useExploreDataExport(defaultProps));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.exportCSVPivoted();
|
||||
});
|
||||
|
||||
expect(mockExportChart).toHaveBeenCalledWith({
|
||||
formData: defaultProps.latestQueryFormData,
|
||||
ownState: defaultProps.ownState,
|
||||
resultType: 'post_processed',
|
||||
resultFormat: 'csv',
|
||||
});
|
||||
});
|
||||
|
||||
test('exports JSON with results result type', async () => {
|
||||
const { result } = renderHook(() => useExploreDataExport(defaultProps));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.exportJson();
|
||||
});
|
||||
|
||||
expect(mockExportChart).toHaveBeenCalledWith({
|
||||
formData: defaultProps.latestQueryFormData,
|
||||
ownState: defaultProps.ownState,
|
||||
resultType: 'results',
|
||||
resultFormat: 'json',
|
||||
});
|
||||
});
|
||||
|
||||
test('exports Excel with results result type', async () => {
|
||||
const { result } = renderHook(() => useExploreDataExport(defaultProps));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.exportExcel();
|
||||
});
|
||||
|
||||
expect(mockExportChart).toHaveBeenCalledWith({
|
||||
formData: defaultProps.latestQueryFormData,
|
||||
ownState: defaultProps.ownState,
|
||||
resultType: 'results',
|
||||
resultFormat: 'xlsx',
|
||||
});
|
||||
});
|
||||
|
||||
test('does not export when downloads are disabled', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useExploreDataExport({
|
||||
...defaultProps,
|
||||
canDownloadCSV: false,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.exportCSV();
|
||||
await result.current.exportCSVPivoted();
|
||||
await result.current.exportJson();
|
||||
await result.current.exportExcel();
|
||||
});
|
||||
|
||||
expect(mockExportChart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('shows large data error toast when export fails with status 413', async () => {
|
||||
mockExportChart.mockRejectedValue({
|
||||
status: 413,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useExploreDataExport(defaultProps));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.exportCSV();
|
||||
});
|
||||
|
||||
expect(mockAddDangerToast).toHaveBeenCalledWith(
|
||||
expect.stringContaining('too large to download'),
|
||||
);
|
||||
});
|
||||
|
||||
test('shows export error message when export fails', async () => {
|
||||
mockExportChart.mockRejectedValue(new Error('Export exploded'));
|
||||
|
||||
const { result } = renderHook(() => useExploreDataExport(defaultProps));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.exportJson();
|
||||
});
|
||||
|
||||
expect(mockAddDangerToast).toHaveBeenCalledWith('Export exploded');
|
||||
});
|
||||
|
||||
test('exposes streaming export modal state and handlers', () => {
|
||||
const { result } = renderHook(() => useExploreDataExport(defaultProps));
|
||||
|
||||
expect(result.current.streamingExportState).toEqual(
|
||||
expect.objectContaining({
|
||||
isVisible: false,
|
||||
onRetry: mockRetryExport,
|
||||
}),
|
||||
);
|
||||
});
|
||||
-275
@@ -1,275 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
JsonObject,
|
||||
LatestQueryFormData,
|
||||
QueryFormData,
|
||||
} from '@superset-ui/core';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { useToasts } from 'src/components/MessageToasts/withToasts';
|
||||
import {
|
||||
StreamingProgress,
|
||||
useStreamingExport,
|
||||
} from 'src/components/StreamingExportModal';
|
||||
import { DEFAULT_CSV_STREAMING_ROW_THRESHOLD } from 'src/constants';
|
||||
import { exportChart, getChartKey } from 'src/explore/exploreUtils';
|
||||
import { ChartState } from 'src/explore/types';
|
||||
import { Slice } from 'src/types/Chart';
|
||||
|
||||
interface ExploreSlice {
|
||||
slice?: Slice | null;
|
||||
}
|
||||
|
||||
interface ExploreState {
|
||||
charts?: Record<number, ChartState>;
|
||||
explore?: ExploreSlice;
|
||||
common?: {
|
||||
conf?: {
|
||||
CSV_STREAMING_ROW_THRESHOLD?: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface StreamingExportState {
|
||||
isVisible: boolean;
|
||||
progress: StreamingProgress;
|
||||
onCancel: () => void;
|
||||
onRetry: () => void;
|
||||
onDownload: () => void;
|
||||
}
|
||||
|
||||
interface UseExploreDataExportProps {
|
||||
latestQueryFormData: LatestQueryFormData;
|
||||
canDownloadCSV: boolean;
|
||||
slice?: Slice | null;
|
||||
ownState?: JsonObject;
|
||||
}
|
||||
|
||||
export const useExploreDataExport = ({
|
||||
latestQueryFormData,
|
||||
canDownloadCSV,
|
||||
slice,
|
||||
ownState,
|
||||
}: UseExploreDataExportProps) => {
|
||||
const { addDangerToast, addSuccessToast } = useToasts();
|
||||
|
||||
const chart = useSelector<ExploreState, ChartState | undefined>(state =>
|
||||
state.explore ? state.charts?.[getChartKey(state.explore)] : undefined,
|
||||
);
|
||||
|
||||
const streamingThreshold = useSelector<ExploreState, number>(
|
||||
state =>
|
||||
state.common?.conf?.CSV_STREAMING_ROW_THRESHOLD ||
|
||||
DEFAULT_CSV_STREAMING_ROW_THRESHOLD,
|
||||
);
|
||||
|
||||
const [isStreamingModalVisible, setIsStreamingModalVisible] = useState(false);
|
||||
|
||||
const { progress, startExport, resetExport, retryExport } =
|
||||
useStreamingExport({
|
||||
onComplete: () => {
|
||||
// Wait for the user to click Download before showing a success toast.
|
||||
},
|
||||
onError: () => {
|
||||
addDangerToast(t('Export failed - please try again'));
|
||||
},
|
||||
});
|
||||
|
||||
const handleCloseStreamingModal = useCallback(() => {
|
||||
setIsStreamingModalVisible(false);
|
||||
resetExport();
|
||||
}, [resetExport]);
|
||||
|
||||
const handleDownloadComplete = useCallback(() => {
|
||||
addSuccessToast(t('CSV file downloaded successfully'));
|
||||
}, [addSuccessToast]);
|
||||
|
||||
const handleExportError = useCallback(
|
||||
(error: unknown) => {
|
||||
const exportError = error as Error & {
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
response?: { status?: number };
|
||||
};
|
||||
const status = exportError.status || exportError.response?.status;
|
||||
|
||||
if (status === 413) {
|
||||
addDangerToast(
|
||||
t(
|
||||
'The chart data is too large to download. Please try reducing the date range, limiting rows, or using fewer columns.',
|
||||
),
|
||||
);
|
||||
} else {
|
||||
const errorMessage =
|
||||
exportError.message ||
|
||||
exportError.statusText ||
|
||||
t(
|
||||
'Failed to export chart data. Please try again or contact your administrator.',
|
||||
);
|
||||
addDangerToast(errorMessage);
|
||||
}
|
||||
},
|
||||
[addDangerToast],
|
||||
);
|
||||
|
||||
const exportCSV = useCallback(async () => {
|
||||
if (!canDownloadCSV) return null;
|
||||
|
||||
let actualRowCount;
|
||||
const isTableViz = latestQueryFormData?.viz_type === 'table';
|
||||
const queriesResponse = chart?.queriesResponse;
|
||||
|
||||
if (
|
||||
isTableViz &&
|
||||
queriesResponse &&
|
||||
queriesResponse.length > 1 &&
|
||||
queriesResponse[1]?.data?.[0]?.rowcount
|
||||
) {
|
||||
actualRowCount = queriesResponse[1].data[0].rowcount;
|
||||
} else if (queriesResponse && queriesResponse[0]?.sql_rowcount != null) {
|
||||
actualRowCount = queriesResponse[0].sql_rowcount;
|
||||
} else if (queriesResponse && queriesResponse[0]?.rowcount != null) {
|
||||
actualRowCount = queriesResponse[0].rowcount;
|
||||
} else {
|
||||
actualRowCount = latestQueryFormData?.row_limit;
|
||||
}
|
||||
|
||||
const shouldUseStreaming =
|
||||
actualRowCount && actualRowCount >= streamingThreshold;
|
||||
|
||||
let filename: string | undefined;
|
||||
if (shouldUseStreaming) {
|
||||
const now = new Date();
|
||||
const date = now.toISOString().slice(0, 10);
|
||||
const time = now.toISOString().slice(11, 19).replace(/:/g, '');
|
||||
const timestamp = `_${date}_${time}`;
|
||||
const chartName =
|
||||
slice?.slice_name || latestQueryFormData.viz_type || 'chart';
|
||||
const safeChartName = chartName.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
filename = `${safeChartName}${timestamp}.csv`;
|
||||
}
|
||||
|
||||
try {
|
||||
await exportChart({
|
||||
formData: latestQueryFormData as QueryFormData,
|
||||
ownState,
|
||||
resultType: 'full',
|
||||
resultFormat: 'csv',
|
||||
onStartStreamingExport: shouldUseStreaming
|
||||
? exportParams => {
|
||||
if (exportParams.url) {
|
||||
setIsStreamingModalVisible(true);
|
||||
startExport({
|
||||
...exportParams,
|
||||
url: exportParams.url,
|
||||
filename,
|
||||
expectedRows: actualRowCount,
|
||||
exportType: exportParams.exportType as 'csv' | 'xlsx',
|
||||
});
|
||||
}
|
||||
}
|
||||
: null,
|
||||
});
|
||||
} catch (error) {
|
||||
handleExportError(error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [
|
||||
canDownloadCSV,
|
||||
latestQueryFormData,
|
||||
ownState,
|
||||
chart,
|
||||
streamingThreshold,
|
||||
slice,
|
||||
startExport,
|
||||
handleExportError,
|
||||
]);
|
||||
|
||||
const exportCSVPivoted = useCallback(async () => {
|
||||
if (!canDownloadCSV) return null;
|
||||
|
||||
try {
|
||||
await exportChart({
|
||||
formData: latestQueryFormData as QueryFormData,
|
||||
ownState,
|
||||
resultType: 'post_processed',
|
||||
resultFormat: 'csv',
|
||||
});
|
||||
} catch (error) {
|
||||
handleExportError(error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [canDownloadCSV, latestQueryFormData, ownState, handleExportError]);
|
||||
|
||||
const exportJson = useCallback(async () => {
|
||||
if (!canDownloadCSV) return null;
|
||||
|
||||
try {
|
||||
await exportChart({
|
||||
formData: latestQueryFormData as QueryFormData,
|
||||
ownState,
|
||||
resultType: 'results',
|
||||
resultFormat: 'json',
|
||||
});
|
||||
} catch (error) {
|
||||
handleExportError(error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [canDownloadCSV, latestQueryFormData, ownState, handleExportError]);
|
||||
|
||||
const exportExcel = useCallback(async () => {
|
||||
if (!canDownloadCSV) return null;
|
||||
|
||||
try {
|
||||
await exportChart({
|
||||
formData: latestQueryFormData as QueryFormData,
|
||||
ownState,
|
||||
resultType: 'results',
|
||||
resultFormat: 'xlsx',
|
||||
});
|
||||
} catch (error) {
|
||||
handleExportError(error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [canDownloadCSV, latestQueryFormData, ownState, handleExportError]);
|
||||
|
||||
const streamingExportState: StreamingExportState = {
|
||||
isVisible: isStreamingModalVisible,
|
||||
progress,
|
||||
onCancel: handleCloseStreamingModal,
|
||||
onRetry: retryExport,
|
||||
onDownload: handleDownloadComplete,
|
||||
};
|
||||
|
||||
return {
|
||||
exportCSV,
|
||||
exportCSVPivoted,
|
||||
exportJson,
|
||||
exportExcel,
|
||||
handleExportError,
|
||||
streamingExportState,
|
||||
};
|
||||
};
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
chat,
|
||||
core,
|
||||
commands,
|
||||
dashboard,
|
||||
editors,
|
||||
extensions,
|
||||
menus,
|
||||
@@ -59,6 +60,7 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
|
||||
chat,
|
||||
core,
|
||||
commands,
|
||||
dashboard,
|
||||
editors,
|
||||
extensions,
|
||||
menus,
|
||||
|
||||
@@ -31,6 +31,7 @@ import type {
|
||||
chat,
|
||||
commands,
|
||||
core,
|
||||
dashboard,
|
||||
editors,
|
||||
extensions,
|
||||
menus,
|
||||
@@ -45,6 +46,7 @@ export interface Namespaces {
|
||||
core: typeof core;
|
||||
chat: typeof chat;
|
||||
commands: typeof commands;
|
||||
dashboard: typeof dashboard;
|
||||
editors: typeof editors;
|
||||
extensions: typeof extensions;
|
||||
menus: typeof menus;
|
||||
|
||||
@@ -251,6 +251,19 @@ const RightMenu = ({
|
||||
perm: 'can_write',
|
||||
view: 'Dashboard',
|
||||
},
|
||||
{
|
||||
label: t('Dashboard v2'),
|
||||
// Keep the URL relative so isFrontendRoute() matches and Link navigates
|
||||
// via React Router — see the SQL query entry's comment above.
|
||||
url: '/dashboard/v2/new/',
|
||||
icon: (
|
||||
<Icons.ThunderboltOutlined
|
||||
data-test={`menu-item-${t('Dashboard v2')}`}
|
||||
/>
|
||||
),
|
||||
perm: 'can_write',
|
||||
view: 'Dashboard',
|
||||
},
|
||||
];
|
||||
|
||||
const checkAllowUploads = () => {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { AppSection, type ChartProps } from '@superset-ui/core';
|
||||
import { AppSection } from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
@@ -443,35 +443,6 @@ describe('RangeFilterPlugin', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('clears a filter still sitting at its default value', () => {
|
||||
const renderWith = (value: [number | null, number | null]) => (
|
||||
<RangeFilterPlugin
|
||||
{...(transformProps({
|
||||
...rangeProps,
|
||||
filterState: { value },
|
||||
} as unknown as ChartProps) as PluginFilterRangeProps)}
|
||||
setDataMask={setDataMask}
|
||||
/>
|
||||
);
|
||||
|
||||
// The filter loads at its default and the user never touches it.
|
||||
const { rerender } = render(renderWith([10, 70]));
|
||||
setDataMask.mockClear();
|
||||
|
||||
// Clear All stages [null, null] for range filters.
|
||||
rerender(renderWith([null, null]));
|
||||
|
||||
expect(setDataMask).toHaveBeenCalledWith({
|
||||
extraFormData: {},
|
||||
filterState: {
|
||||
value: [null, null],
|
||||
label: '',
|
||||
validateStatus: undefined,
|
||||
validateMessage: '',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('calculateStep returns ~100 steps for integer ranges', () => {
|
||||
|
||||
@@ -363,14 +363,8 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear all case. The filter bar stages [null, null] for range filters, so
|
||||
// matching only undefined let a filter still sitting at its default fall
|
||||
// through to the default-restoring branch below.
|
||||
if (
|
||||
(filterState.value === undefined ||
|
||||
isEqualArray(filterState.value, [null, null])) &&
|
||||
!filterState.validateStatus
|
||||
) {
|
||||
// Clear all case
|
||||
if (filterState.value === undefined && !filterState.validateStatus) {
|
||||
setInputValue([null, null]);
|
||||
updateDataMaskValue([null, null]);
|
||||
return;
|
||||
|
||||
@@ -134,44 +134,6 @@ describe('asyncEvent middleware', () => {
|
||||
expect(fetchMock.callHistory.calls(CACHED_DATA_ENDPOINT)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('rejects with an AbortError and cancels the job when the signal aborts', async () => {
|
||||
const CANCEL_ENDPOINT = 'glob:*/api/v1/async_event/*/cancel';
|
||||
fetchMock.post(CANCEL_ENDPOINT, { status: 200, body: {} });
|
||||
|
||||
const controller = new AbortController();
|
||||
const promise = asyncEvent.waitForAsyncData(
|
||||
asyncPendingEvent,
|
||||
controller.signal,
|
||||
);
|
||||
controller.abort();
|
||||
|
||||
let error: any = null;
|
||||
try {
|
||||
await promise;
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
expect(error?.name).toBe('AbortError');
|
||||
// The cancel POST is fire-and-forget; let its microtask flush.
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
expect(fetchMock.callHistory.calls(CANCEL_ENDPOINT)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('rejects immediately when given an already-aborted signal', async () => {
|
||||
const CANCEL_ENDPOINT = 'glob:*/api/v1/async_event/*/cancel';
|
||||
fetchMock.post(CANCEL_ENDPOINT, { status: 200, body: {} });
|
||||
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
await expect(
|
||||
asyncEvent.waitForAsyncData(asyncPendingEvent, controller.signal),
|
||||
).rejects.toMatchObject({ name: 'AbortError' });
|
||||
// The cancel POST is fire-and-forget; let its microtask flush.
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
expect(fetchMock.callHistory.calls(CANCEL_ENDPOINT)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('rejects on event error status', async () => {
|
||||
fetchMock.clearHistory().removeRoutes();
|
||||
fetchMock.get(EVENTS_ENDPOINT, {
|
||||
|
||||
@@ -104,17 +104,6 @@ const fetchCachedData = async (
|
||||
return { status, data };
|
||||
};
|
||||
|
||||
const cancelAsyncJob = (jobId: string) => {
|
||||
// Best-effort server-side cancel; the request stops the running Celery task
|
||||
// so it no longer consumes warehouse resources. Failures are non-fatal: the
|
||||
// client has already stopped waiting on the job.
|
||||
SupersetClient.post({
|
||||
endpoint: `/api/v1/async_event/${jobId}/cancel`,
|
||||
}).catch(error => {
|
||||
logging.warn('Failed to cancel async job', jobId, error);
|
||||
});
|
||||
};
|
||||
|
||||
export const waitForAsyncData = async (
|
||||
asyncResponse: AsyncEvent,
|
||||
signal?: AbortSignal,
|
||||
@@ -133,7 +122,6 @@ export const waitForAsyncData = async (
|
||||
// Bail immediately if the caller has already aborted (e.g. the chart was
|
||||
// unmounted before the job started), avoiding a leaked listener.
|
||||
if (signal?.aborted) {
|
||||
cancelAsyncJob(jobId);
|
||||
reject(new DOMException('Aborted', 'AbortError'));
|
||||
return;
|
||||
}
|
||||
@@ -173,13 +161,11 @@ export const waitForAsyncData = async (
|
||||
}
|
||||
};
|
||||
|
||||
// When the caller aborts (Stop pressed, chart superseded/unmounted), stop
|
||||
// listening so the listener and its retained closure don't leak, and ask the
|
||||
// server to cancel the job so it stops consuming warehouse resources.
|
||||
// When the caller aborts (chart superseded/unmounted), stop listening so the
|
||||
// listener and its retained closure don't leak and keep the poller busy.
|
||||
if (signal) {
|
||||
onAbort = () => {
|
||||
cleanup();
|
||||
cancelAsyncJob(jobId);
|
||||
reject(new DOMException('Aborted', 'AbortError'));
|
||||
};
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import DashboardProvider from 'src/core/dashboard/DashboardProvider';
|
||||
import DashboardHeader from './DashboardHeader';
|
||||
|
||||
const provider = DashboardProvider.getInstance();
|
||||
|
||||
const ADMIN = { userId: 1, firstName: 'Admin', lastName: 'User' };
|
||||
|
||||
/** The header reads the session for who is authoring, so it needs the store. */
|
||||
const renderHeader = () =>
|
||||
render(<DashboardHeader />, {
|
||||
useRedux: true,
|
||||
initialState: { user: ADMIN },
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
provider.reset();
|
||||
});
|
||||
|
||||
test('the header carries the dashboard-level affordances', () => {
|
||||
renderHeader();
|
||||
|
||||
expect(screen.getByTestId('header-templates')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('header-history')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('header-favorite')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('header-published')).toHaveTextContent('Draft');
|
||||
expect(screen.getByTestId('header-undo')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('header-redo')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('header-save')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('everything the builder cannot actually do is disabled, not silently dead', () => {
|
||||
renderHeader();
|
||||
|
||||
// The builder keeps its tree in memory with no dashboard row behind it:
|
||||
// nothing here can be saved, favourited, published or refreshed, and there
|
||||
// is no history to step through. A control that looks live and does
|
||||
// nothing teaches something false about all of them.
|
||||
[
|
||||
'header-templates',
|
||||
'header-history',
|
||||
'header-favorite',
|
||||
'header-undo',
|
||||
'header-redo',
|
||||
'header-save',
|
||||
].forEach(test => expect(screen.getByTestId(test)).toBeDisabled());
|
||||
});
|
||||
|
||||
test('the record of what was written sits beside writing it', () => {
|
||||
renderHeader();
|
||||
|
||||
const order = [
|
||||
...screen.getByTestId('dashboard-header').querySelectorAll('[data-test]'),
|
||||
].map(el => el.getAttribute('data-test'));
|
||||
|
||||
// Saving commits a version; History is the versions already committed.
|
||||
// They are one concern read in one place, so History leaves the far left —
|
||||
// where it sat beside Templates as a thing asked before the work — and
|
||||
// comes to rest immediately before the button that produces what it lists.
|
||||
expect(order.indexOf('header-history')).toBe(
|
||||
order.indexOf('header-save') - 1,
|
||||
);
|
||||
});
|
||||
|
||||
test('how the dashboard is arranged is not asked in the header', () => {
|
||||
renderHeader();
|
||||
|
||||
// Arranging the canvas is authoring, not chrome. It belongs with the rest
|
||||
// of the root's properties, where the columns and the gap it works with
|
||||
// already live — see Inspector's Arrangement section.
|
||||
expect(screen.queryByTestId('layout-mode-switcher')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('what acts on the canvas is not offered from the bar above it', () => {
|
||||
renderHeader();
|
||||
|
||||
// Arranging and refreshing both act on the canvas as a whole, not on the
|
||||
// dashboard's identity, so neither belongs in this bar.
|
||||
expect(screen.queryByTestId('canvas-arrange')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('header-arrange')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('header-refresh')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('the header says who is making the dashboard', () => {
|
||||
renderHeader();
|
||||
|
||||
// The one piece of dashboard metadata this page can state truthfully: a
|
||||
// dashboard being created is being created by whoever is looking at it.
|
||||
expect(screen.getByTestId('header-metadata')).toHaveTextContent('Admin User');
|
||||
});
|
||||
|
||||
test('the header does not claim a dashboard with no row behind it was saved', () => {
|
||||
renderHeader();
|
||||
|
||||
// Every other unavailable affordance here says so. A humanized "a day ago"
|
||||
// beside them would be the only thing on the bar inventing a fact.
|
||||
expect(screen.getByTestId('header-metadata')).toHaveTextContent(
|
||||
'Not saved yet',
|
||||
);
|
||||
});
|
||||
|
||||
test('the dashboard is nameable, and the name is stored on the dashboard', async () => {
|
||||
renderHeader();
|
||||
|
||||
await userEvent.type(screen.getByTestId('header-title'), 'Vaccine rollout');
|
||||
await userEvent.tab();
|
||||
|
||||
// On the root node rather than in this component's state: a name is
|
||||
// something the dashboard has, so the assistant can read and rename it too.
|
||||
expect(provider.getRoot().props?.title).toBe('Vaccine rollout');
|
||||
});
|
||||
|
||||
test('the title shows a rename made anywhere else', () => {
|
||||
provider.updateProps(provider.getRoot().id, { title: 'From the assistant' });
|
||||
|
||||
renderHeader();
|
||||
|
||||
expect(screen.getByTestId('header-title')).toHaveValue('From the assistant');
|
||||
});
|
||||
|
||||
test('emptying the title is not a rename', async () => {
|
||||
provider.updateProps(provider.getRoot().id, { title: 'Quarterly review' });
|
||||
renderHeader();
|
||||
|
||||
await userEvent.clear(screen.getByTestId('header-title'));
|
||||
await userEvent.tab();
|
||||
|
||||
// A stray select-all-and-delete must not silently leave the dashboard
|
||||
// nameless; the field goes back to what the dashboard is still called.
|
||||
expect(provider.getRoot().props?.title).toBe('Quarterly review');
|
||||
expect(screen.getByTestId('header-title')).toHaveValue('Quarterly review');
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { css, styled } from '@apache-superset/core/theme';
|
||||
import { Divider, Input, PublishedLabel } from '@superset-ui/core/components';
|
||||
import MetadataBar, {
|
||||
MetadataType,
|
||||
} from '@superset-ui/core/components/MetadataBar';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import type { BootstrapUser } from 'src/types/bootstrapTypes';
|
||||
import { provider, useDashboardRevision } from 'src/core/dashboard/store';
|
||||
import Inert from './InertControl';
|
||||
|
||||
/**
|
||||
* Who is making this dashboard, and when it was last written down.
|
||||
*
|
||||
* The two facts a dashboard carries about itself rather than about its
|
||||
* contents, drawn with the same `MetadataBar` the saved dashboard header
|
||||
* uses — so a dashboard being built and one being read state them in the
|
||||
* same shape, with the same icons, in the same place.
|
||||
*
|
||||
* Only one of them can be true here. A dashboard being created is being
|
||||
* created by whoever is looking at it, so the creator is read from the
|
||||
* session rather than invented. There is no row behind this page and nothing
|
||||
* has ever been written, so there is no modified time to humanize — and
|
||||
* "a day ago" beside a Save button that is disabled for having nothing to
|
||||
* save would be the only thing on this bar stating a fact that is not one.
|
||||
*/
|
||||
/**
|
||||
* The signed-in person's name.
|
||||
*
|
||||
* Assembled here rather than through `getUserName`, which reads the
|
||||
* `first_name`/`last_name` an API hands back for an owner. The session user
|
||||
* is the same person in a different shape — `firstName`/`lastName` off the
|
||||
* bootstrap — and passing one to the other returns an empty string rather
|
||||
* than failing, which is how this first went out reading "Not available"
|
||||
* over a perfectly well-known name.
|
||||
*/
|
||||
const nameOf = (user: BootstrapUser): string =>
|
||||
[user?.firstName, user?.lastName].filter(Boolean).join(' ') ||
|
||||
user?.username ||
|
||||
'';
|
||||
|
||||
const Metadata = (): ReactElement => {
|
||||
const user = useSelector<{ user?: BootstrapUser }, BootstrapUser>(
|
||||
state => state.user,
|
||||
);
|
||||
const author = nameOf(user) || t('Not available');
|
||||
const unsaved = t('Not saved yet');
|
||||
|
||||
return (
|
||||
<span data-test="header-metadata">
|
||||
<MetadataBar
|
||||
tooltipPlacement="bottom"
|
||||
items={[
|
||||
{
|
||||
type: MetadataType.Editor,
|
||||
createdBy: author,
|
||||
editors: t('None'),
|
||||
createdOn: unsaved,
|
||||
},
|
||||
{
|
||||
type: MetadataType.LastModified,
|
||||
value: unsaved,
|
||||
modifiedBy: author,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The name, drawn as a name rather than as a field.
|
||||
*
|
||||
* A bordered box on a bar of small controls read as one more control, and the
|
||||
* one thing on the bar that says what you are looking at was the hardest thing
|
||||
* on it to find. Borderless at the heading weight, it reads as the title it
|
||||
* is; the surface arriving under the pointer and on focus is what still says
|
||||
* it can be typed into, which is the same trade the editable titles elsewhere
|
||||
* in the app make.
|
||||
*/
|
||||
const TitleInput = styled(Input)`
|
||||
${({ theme }) => css`
|
||||
max-width: ${theme.sizeUnit * 60}px;
|
||||
height: ${theme.controlHeightSM}px;
|
||||
padding-inline: ${theme.sizeUnit}px;
|
||||
font-size: ${theme.fontSizeLG}px;
|
||||
font-weight: ${theme.fontWeightStrong};
|
||||
color: ${theme.colorText};
|
||||
background-color: transparent;
|
||||
transition: background-color ${theme.motionDurationMid};
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: ${theme.colorFillQuaternary};
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
font-weight: ${theme.fontWeightNormal};
|
||||
color: ${theme.colorTextTertiary};
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
/**
|
||||
* The dashboard's name, edited where it is read.
|
||||
*
|
||||
* It is stored on the root node rather than in this component, because a name
|
||||
* is something the dashboard has and not something this screen remembers: put
|
||||
* in page state it would be invisible to the assistant, unreachable by the
|
||||
* client tools, and gone on the next navigation. The root canvas is the only
|
||||
* node a dashboard-level fact can belong to, so that is where it lives.
|
||||
*
|
||||
* A title is also a `markdown` block an author can place at the top of the
|
||||
* canvas, and that stays true — this is a different thing with a different
|
||||
* job. That one is content, laid out and arranged like any other block; this
|
||||
* one is what the dashboard is called.
|
||||
*
|
||||
* The draft commits on blur rather than on every keystroke: a name being
|
||||
* typed is not a name, and one commit per character would be one revision
|
||||
* tick per character for everything subscribed to the store.
|
||||
*/
|
||||
const Title = ({ nodeId, title }: { nodeId: string; title: string }) => {
|
||||
const [draft, setDraft] = useState(title);
|
||||
// What was accepted replaces the draft, because the draft was a view of it:
|
||||
// a rename the assistant makes while this is on screen has to show.
|
||||
useEffect(() => setDraft(title), [title]);
|
||||
|
||||
return (
|
||||
<TitleInput
|
||||
size="small"
|
||||
variant="borderless"
|
||||
value={draft}
|
||||
aria-label={t('Dashboard title')}
|
||||
placeholder={t('Untitled dashboard')}
|
||||
data-test="header-title"
|
||||
onChange={event => setDraft(event.target.value)}
|
||||
onBlur={() => {
|
||||
const next = draft.trim();
|
||||
// An empty name is not a rename. Restoring the draft rather than
|
||||
// writing the blank is what keeps a stray select-all-and-delete from
|
||||
// silently leaving the dashboard nameless.
|
||||
if (next === '') {
|
||||
setDraft(title);
|
||||
} else if (next !== title) {
|
||||
provider.updateProps(nodeId, { title: next });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The bar itself.
|
||||
*
|
||||
* Inset horizontally the way the rest of the app insets a page header, so the
|
||||
* left edge of the bar and the left edge of the work below it are one line
|
||||
* rather than two a few pixels apart. The rule beneath is `colorSplit` — what
|
||||
* this app draws a separator with — rather than the heavier border it shares
|
||||
* with the boxes that hold things.
|
||||
*/
|
||||
const Bar = styled.header`
|
||||
${({ theme }) => css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${theme.sizeUnit * 2}px;
|
||||
flex: 0 0 auto;
|
||||
padding: ${theme.sizeUnit * 2}px ${theme.sizeUnit * 4}px;
|
||||
border-bottom: 1px solid ${theme.colorSplit};
|
||||
background-color: ${theme.colorBgContainer};
|
||||
`}
|
||||
`;
|
||||
|
||||
/** What an author does to the whole dashboard, at the end they read last. */
|
||||
const Actions = styled.span`
|
||||
${({ theme }) => css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${theme.sizeUnit * 2}px;
|
||||
margin-left: auto;
|
||||
`}
|
||||
`;
|
||||
|
||||
/**
|
||||
* The dashboard's header: what this dashboard is, and what can be done to it.
|
||||
*
|
||||
* Two kinds of thing share the bar. On the left is the dashboard as the
|
||||
* product would know it — where to start from, where it has been, what it is
|
||||
* called, whether it is published, and whose it is. On the right is what an
|
||||
* author does to the whole of it: step back through what they did, or write
|
||||
* it down.
|
||||
*
|
||||
* How the canvas is arranged is not among them. It reads like chrome and is
|
||||
* not: it is a property of the root node, sitting in the same `layout` the
|
||||
* columns and the gap sit in, and asking for it here put one third of that
|
||||
* one decision on the other side of the screen from the rest. It is asked in
|
||||
* the root's own properties now, where a canvas is selected and arranged in
|
||||
* one place.
|
||||
*/
|
||||
export default function DashboardHeader(): ReactElement {
|
||||
useDashboardRevision();
|
||||
const root = provider.getRoot();
|
||||
|
||||
return (
|
||||
<Bar data-test="dashboard-header">
|
||||
{/* Where this dashboard came from: a starting point to build on, asked
|
||||
before the work rather than during it, which is why it leads the
|
||||
bar. History used to sit beside it on that reasoning and has gone to
|
||||
the other end — it is read against saving, not against starting. */}
|
||||
<Inert label={t('Templates')} test="header-templates" reads>
|
||||
{t('Templates')}
|
||||
</Inert>
|
||||
<Title
|
||||
nodeId={root.id}
|
||||
title={typeof root.props?.title === 'string' ? root.props.title : ''}
|
||||
/>
|
||||
<Inert label={t('Favorite')} test="header-favorite" buttonStyle="link">
|
||||
<Icons.StarOutlined iconSize="m" />
|
||||
</Inert>
|
||||
{/* Nothing here can publish, so the chip states the only status this
|
||||
page can honestly claim. */}
|
||||
<span data-test="header-published">
|
||||
<PublishedLabel isPublished={false} />
|
||||
</span>
|
||||
{/* Beside the status rather than opposite it: whether a dashboard is a
|
||||
draft, whose it is, and when it was last written are one answer to
|
||||
one question — what state is this in — and they are read together. */}
|
||||
<Metadata />
|
||||
|
||||
<Actions>
|
||||
{/* Icons, not words, because these two are reached by muscle memory
|
||||
far more often than they are read. The name stays on them for
|
||||
anyone not reading with their eyes. */}
|
||||
<Inert label={t('Undo')} test="header-undo">
|
||||
<Icons.UndoOutlined iconSize="s" />
|
||||
</Inert>
|
||||
<Inert label={t('Redo')} test="header-redo">
|
||||
<Icons.RedoOutlined iconSize="s" />
|
||||
</Inert>
|
||||
{/* Stepping back through the work and writing it down are two
|
||||
different acts on the bar's one crowded end, and at an even gap
|
||||
the four of them read as one run of controls. The rule is what
|
||||
says where one pair stops and the other starts. */}
|
||||
<Divider type="vertical" />
|
||||
{/* Saving commits a version; History is the versions already
|
||||
committed. One concern, read in one place — so the record sits
|
||||
immediately before the button that produces what it lists, rather
|
||||
than at the far side of the bar from it. */}
|
||||
<Inert label={t('History')} test="header-history" reads>
|
||||
{t('History')}
|
||||
</Inert>
|
||||
<Inert label={t('Save')} test="header-save" reads>
|
||||
{t('Save')}
|
||||
</Inert>
|
||||
</Actions>
|
||||
</Bar>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user