Compare commits

..
181 changed files with 13573 additions and 9706 deletions
+1 -1
View File
@@ -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"
+2 -2
View File
@@ -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}}"
+1
View File
@@ -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
+3 -3
View File
@@ -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
+31 -46
View File
@@ -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)
+13 -1
View File
@@ -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
View File
@@ -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`.
+1 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+1 -1
View File
@@ -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
+1 -5
View File
@@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs
# superset
![Version: 0.22.4](https://img.shields.io/badge/Version-0.22.4-informational?style=flat-square)
![Version: 0.22.3](https://img.shields.io/badge/Version-0.22.3-informational?style=flat-square)
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` | |
-8
View File
@@ -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..."
-4
View File
@@ -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 }}
-183
View File
@@ -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
-25
View File
@@ -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
-1
View File
@@ -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/",
+666 -338
View File
File diff suppressed because it is too large Load Diff
+21 -20
View File
@@ -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)
) {
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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;
}
@@ -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 (01).
* 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 (0360).
* @param sweptAngle - The swept angle of the arc in degrees (10360).
* @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';
@@ -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;
}
}
-4
View File
@@ -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);
+1
View File
@@ -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' },
+7 -4
View File
@@ -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}
>
@@ -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 *',
});
});
});
@@ -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;
@@ -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,
});
});
+1 -1
View File
@@ -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();
});
});
@@ -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);
});
});
@@ -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>
);
@@ -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];
};
@@ -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,
}),
);
});
@@ -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, {
+2 -16
View File
@@ -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