mirror of
https://github.com/apache/superset.git
synced 2026-08-12 19:20:40 +00:00
Compare commits
214
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ca609c0a4 | ||
|
|
4c1e7d2ee2 | ||
|
|
c69241bcdf | ||
|
|
d5d3663088 | ||
|
|
2c5b655595 | ||
|
|
ed8dd37dd1 | ||
|
|
885f00130c | ||
|
|
584466e02b | ||
|
|
d570335f67 | ||
|
|
a0099af88f | ||
|
|
8bd25b3fed | ||
|
|
0a2cfb9fba | ||
|
|
5248367587 | ||
|
|
bdf8ce69ab | ||
|
|
d91ca50872 | ||
|
|
f6077eeaa1 | ||
|
|
0d57c5ab50 | ||
|
|
edf30c7019 | ||
|
|
3ded398a1f | ||
|
|
63a81c2125 | ||
|
|
af258c21d0 | ||
|
|
9086bab2c2 | ||
|
|
4c5a3356f3 | ||
|
|
998c408686 | ||
|
|
a3d58d1211 | ||
|
|
7b32b870e1 | ||
|
|
3cd4150539 | ||
|
|
2feb6c2bb5 | ||
|
|
5767c3981d | ||
|
|
ec6b2d2347 | ||
|
|
aade32cad8 | ||
|
|
d6771e96d2 | ||
|
|
305638e98c | ||
|
|
5099b6ffa9 | ||
|
|
bc436bacad | ||
|
|
e31a894be6 | ||
|
|
553808daa3 | ||
|
|
544756a631 | ||
|
|
c4e8a9da90 | ||
|
|
47adbe31d5 | ||
|
|
64165fbdae | ||
|
|
581fb876e4 | ||
|
|
29e8e396ee | ||
|
|
8bbf0cfc20 | ||
|
|
497c73e837 | ||
|
|
a5a6f5a5b1 | ||
|
|
156a59a190 | ||
|
|
590003cbee | ||
|
|
579011dea9 | ||
|
|
65d808a5fe | ||
|
|
5ee279363c | ||
|
|
34b2d3a533 | ||
|
|
7157b752ee | ||
|
|
d84a00f3c6 | ||
|
|
bfa1e775c4 | ||
|
|
265269018e | ||
|
|
5f204a9af2 | ||
|
|
0c09cf087d | ||
|
|
651f75b1e8 | ||
|
|
e48512568d | ||
|
|
4d0eeb7d86 | ||
|
|
fdfc49461f | ||
|
|
2eb1e04283 | ||
|
|
bc85f1eacf | ||
|
|
0dedc55f38 | ||
|
|
178d9c4ceb | ||
|
|
347774f9b8 | ||
|
|
776f70bf63 | ||
|
|
572f86c3c3 | ||
|
|
50c811280e | ||
|
|
9d6832c927 | ||
|
|
b6504eb111 | ||
|
|
d62a9eeda6 | ||
|
|
651c046e69 | ||
|
|
d9ae0ec2fc | ||
|
|
b98e157104 | ||
|
|
4defdbabd2 | ||
|
|
7dc2afd377 | ||
|
|
5bcd9cd078 | ||
|
|
341ca94ba2 | ||
|
|
4b68da82a7 | ||
|
|
a9eab4a0df | ||
|
|
f60370b695 | ||
|
|
c02dc7780b | ||
|
|
daf1718b44 | ||
|
|
b815640b8f | ||
|
|
9e2cb69a39 | ||
|
|
db88abe0bb | ||
|
|
f53f634f23 | ||
|
|
916c50284b | ||
|
|
6a2e20cdb7 | ||
|
|
9719c7de24 | ||
|
|
346dbd6a98 | ||
|
|
73478c5274 | ||
|
|
d7edf610a9 | ||
|
|
e12818897d | ||
|
|
d41032b70a | ||
|
|
10cf2e20f9 | ||
|
|
2be726ddad | ||
|
|
ee5fb095da | ||
|
|
4c9bd69eb4 | ||
|
|
7cd6c74757 | ||
|
|
3539c41dab | ||
|
|
c09d5bf6a9 | ||
|
|
f8925cc726 | ||
|
|
fea84e81ef | ||
|
|
b413060030 | ||
|
|
3b164e4270 | ||
|
|
bf294cfa43 | ||
|
|
42e4030104 | ||
|
|
8181917f79 | ||
|
|
d14ab29dcb | ||
|
|
a75665b014 | ||
|
|
db5cd1aa38 | ||
|
|
abb6dd6e39 | ||
|
|
4a587b8539 | ||
|
|
127a6f92a6 | ||
|
|
25481799f6 | ||
|
|
1da7a7fdb1 | ||
|
|
185b5938ba | ||
|
|
503309531f | ||
|
|
44a86b725f | ||
|
|
38727906a2 | ||
|
|
2a9c5acb01 | ||
|
|
d8b97e31fa | ||
|
|
bd03440ac8 | ||
|
|
a6c478c59d | ||
|
|
fd36419ca2 | ||
|
|
d80f4c841d | ||
|
|
6baa467f59 | ||
|
|
b18015f63b | ||
|
|
eb8592d4bf | ||
|
|
55af4685a2 | ||
|
|
c901f01693 | ||
|
|
4c894affb2 | ||
|
|
0f9049608a | ||
|
|
91b1a6cac5 | ||
|
|
86a328ca9e | ||
|
|
38ba4a69bf | ||
|
|
280253b1fb | ||
|
|
79c2cd367d | ||
|
|
fd6a0a60a6 | ||
|
|
c2b5311f47 | ||
|
|
70a82a28bf | ||
|
|
f348cf425d | ||
|
|
74196cf694 | ||
|
|
fe06ebe796 | ||
|
|
f1411ccd6c | ||
|
|
1770823f0a | ||
|
|
b7b873321d | ||
|
|
d363cabec9 | ||
|
|
8683d1608e | ||
|
|
14583b6a5f | ||
|
|
27498d26aa | ||
|
|
39e66476e1 | ||
|
|
8153a82489 | ||
|
|
448eb25d76 | ||
|
|
9cec7411e9 | ||
|
|
ae66b697e5 | ||
|
|
22fb1b3a44 | ||
|
|
7dd6ce835f | ||
|
|
4f2147009f | ||
|
|
2ecce20e48 | ||
|
|
01a9fdc621 | ||
|
|
ba09f399ac | ||
|
|
e4c306f5eb | ||
|
|
ea8fa58158 | ||
|
|
a00e2952a1 | ||
|
|
de93a19b3c | ||
|
|
57d6e5c637 | ||
|
|
0fc0d21dd3 | ||
|
|
76e6909cfd | ||
|
|
450e43b8f2 | ||
|
|
a7dd86adc6 | ||
|
|
bbd7ac7278 | ||
|
|
05af8eeff2 | ||
|
|
3f413e6e10 | ||
|
|
4278f4b3d9 | ||
|
|
b8c44a1ad5 | ||
|
|
d594a4d157 | ||
|
|
442995481c | ||
|
|
1de35d1361 | ||
|
|
3f011edabb | ||
|
|
f5bca3bf3f | ||
|
|
32e4e3c6a8 | ||
|
|
ad0538935d | ||
|
|
633f393880 | ||
|
|
0e4e368768 | ||
|
|
126c93b495 | ||
|
|
e880711bbb | ||
|
|
47a4dabd14 | ||
|
|
657e2d2267 | ||
|
|
05193ed7e1 | ||
|
|
2c7adcaef8 | ||
|
|
f65b42408e | ||
|
|
482a59ec72 | ||
|
|
e7338a2add | ||
|
|
9c97d4859c | ||
|
|
19399a5068 | ||
|
|
efab63d42c | ||
|
|
816f37f5b9 | ||
|
|
872311b3c2 | ||
|
|
6713f1be45 | ||
|
|
0692607b4a | ||
|
|
fcfee7efbc | ||
|
|
0cc5c51ba3 | ||
|
|
a57c9b34c9 | ||
|
|
ae8c4bd451 | ||
|
|
247f610ee1 | ||
|
|
4c33258b9d | ||
|
|
3cf83f0a5a | ||
|
|
425c70ae37 | ||
|
|
ab124a333c | ||
|
|
7324225c77 |
+1
-1
@@ -34,7 +34,7 @@
|
||||
|
||||
**/*.geojson @villebro @rusackas
|
||||
**/*.ipynb @villebro @rusackas
|
||||
/superset-frontend/plugins/legacy-plugin-chart-country-map/ @villebro @rusackas
|
||||
/superset-frontend/plugins/plugin-chart-country-map/ @villebro @rusackas
|
||||
|
||||
# Notify translation maintainers of changes to translations
|
||||
|
||||
|
||||
@@ -47,6 +47,13 @@ updates:
|
||||
# as a single manual upgrade anyway. TODO: remove when Babel 8 support is viable.
|
||||
- dependency-name: "@babel/*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
# v2.0.0 renamed ZSTDDecompress to decompress and made it async, breaking
|
||||
# the webpack dev proxy (see #38662, #39138, #39139). Dependabot reopened
|
||||
# the same bump in #39369 after the first revert, so pin it here instead
|
||||
# of relying on a package.json comment (package.json is JSON and can't
|
||||
# hold comments). Remove this once the proxy code is updated to await
|
||||
# the async decompress() API.
|
||||
- dependency-name: "simple-zstd"
|
||||
directory: "/superset-frontend/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
|
||||
@@ -201,18 +201,23 @@ cypress-run-all() {
|
||||
# navigation flow under E2E. We diverge from the entrypoint on:
|
||||
# --timeout 120: heavy dashboard import/export specs exceed the 60s
|
||||
# default
|
||||
# --max-requests / --max-requests-jitter: recycle the worker under
|
||||
# test load to avoid leaks accumulating across the run
|
||||
# superset.app:create_app(): explicit factory so we don't depend on
|
||||
# FLASK_APP being exported
|
||||
#
|
||||
# No --max-requests, matching the entrypoint's default of 0 (recycling
|
||||
# off). With a single worker a recycle takes the whole backend offline for
|
||||
# the graceful-timeout drain — browser keep-alive connections hold it open
|
||||
# for the full 30s — plus ~5s of app boot. A run issues ~3800 requests in
|
||||
# ~8 minutes, so recycling every 500 produced seven ~35s outages per run
|
||||
# and flaked whichever specs happened to navigate into one. Lowering
|
||||
# --graceful-timeout is not enough: a dashboard load plus chart render
|
||||
# needs 6-10s, which still lands inside the window.
|
||||
nohup gunicorn \
|
||||
--bind "127.0.0.1:$port" \
|
||||
--workers 1 \
|
||||
--worker-class gthread \
|
||||
--threads 20 \
|
||||
--timeout 120 \
|
||||
--max-requests 500 \
|
||||
--max-requests-jitter 50 \
|
||||
--access-logfile - \
|
||||
--error-logfile - \
|
||||
"superset.app:create_app()" \
|
||||
@@ -294,16 +299,14 @@ playwright-run() {
|
||||
export PLAYWRIGHT_BASE_URL
|
||||
|
||||
# See cypress-run-all() above for the args rationale (1 worker × 20
|
||||
# gthread threads matching docker/entrypoints/run-server.sh, plus a
|
||||
# 120s timeout and request-recycling for heavy E2E load).
|
||||
# gthread threads matching docker/entrypoints/run-server.sh, a 120s
|
||||
# timeout for heavy E2E load, and why worker recycling is off).
|
||||
nohup gunicorn \
|
||||
--bind "127.0.0.1:$port" \
|
||||
--workers 1 \
|
||||
--worker-class gthread \
|
||||
--threads 20 \
|
||||
--timeout 120 \
|
||||
--max-requests 500 \
|
||||
--max-requests-jitter 50 \
|
||||
--access-logfile - \
|
||||
--error-logfile - \
|
||||
"superset.app:create_app()" \
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
check-python-deps:
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1
|
||||
- uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0
|
||||
with:
|
||||
expand-composite-actions: true
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
- name: Login to Docker Hub
|
||||
if: steps.check.outputs.python
|
||||
continue-on-error: true
|
||||
uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USER }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
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@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
# You cannot use a liccheck.ini file in this workflow.
|
||||
runs-on: ubuntu-slim
|
||||
steps:
|
||||
- uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1
|
||||
- uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0
|
||||
with:
|
||||
expand-composite-actions: true
|
||||
|
||||
|
||||
@@ -221,6 +221,6 @@ jobs:
|
||||
permissions:
|
||||
actions: read
|
||||
steps:
|
||||
- uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1
|
||||
- uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0
|
||||
with:
|
||||
expand-composite-actions: true
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "11"
|
||||
|
||||
@@ -46,4 +46,4 @@ jobs:
|
||||
run: bash .github/workflows/github-action-validator.sh
|
||||
|
||||
- name: Check for security issues on GHA workflows
|
||||
uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1
|
||||
uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "11"
|
||||
|
||||
@@ -80,13 +80,13 @@ jobs:
|
||||
- starburstdata/presto:350-e.6
|
||||
steps:
|
||||
- name: Log in to Docker Hub (authenticated source pulls)
|
||||
uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USER }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Log in to GHCR (push target)
|
||||
uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -195,6 +195,6 @@ jobs:
|
||||
if: always()
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1
|
||||
- uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0
|
||||
with:
|
||||
expand-composite-actions: true
|
||||
|
||||
@@ -108,8 +108,18 @@ jobs:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
# Keep workflow tooling on the triggering revision. Release tags can
|
||||
# contain action pins that no longer satisfy the repository allowlist.
|
||||
- name: Checkout workflow actions
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
path: workflow-source
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/actions
|
||||
|
||||
- name: Setup Docker Environment
|
||||
uses: ./.github/actions/setup-docker
|
||||
uses: ./workflow-source/.github/actions/setup-docker
|
||||
with:
|
||||
dockerhub-user: ${{ secrets.DOCKERHUB_USER }}
|
||||
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -122,7 +132,7 @@ jobs:
|
||||
node-version: 20
|
||||
|
||||
- name: Setup supersetbot
|
||||
uses: ./.github/actions/setup-supersetbot/
|
||||
uses: ./workflow-source/.github/actions/setup-supersetbot/
|
||||
|
||||
- name: Rebuild and push
|
||||
env:
|
||||
@@ -171,7 +181,7 @@ jobs:
|
||||
--repo "$REPOSITORY" \
|
||||
--title "Scheduled Docker image refresh failed for ${LATEST_RELEASE}" \
|
||||
--label "infra:container" \
|
||||
--label "bug" \
|
||||
--label "#bug" \
|
||||
--body "The weekly Docker base-image refresh failed for release \`${LATEST_RELEASE}\`. Published images may be missing upstream base-layer security patches until this is resolved.
|
||||
|
||||
Failed run: ${RUN_URL}"
|
||||
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
ports:
|
||||
- 16379:6379
|
||||
steps:
|
||||
- uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1
|
||||
- uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0
|
||||
with:
|
||||
expand-composite-actions: true
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
name: Build & Deploy
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1
|
||||
- uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0
|
||||
with:
|
||||
expand-composite-actions: true
|
||||
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
node-version-file: "./docs/.nvmrc"
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-backend/
|
||||
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: "zulu"
|
||||
java-version: "21"
|
||||
|
||||
@@ -355,6 +355,6 @@ jobs:
|
||||
permissions:
|
||||
actions: read
|
||||
steps:
|
||||
- uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1
|
||||
- uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0
|
||||
with:
|
||||
expand-composite-actions: true
|
||||
|
||||
@@ -76,6 +76,6 @@ jobs:
|
||||
if: always()
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1
|
||||
- uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0
|
||||
with:
|
||||
expand-composite-actions: true
|
||||
|
||||
@@ -182,11 +182,6 @@ jobs:
|
||||
docker run --rm $TAG bash -c \
|
||||
"npm ls --all --package-lock-only --depth=0 --json > /dev/null"
|
||||
|
||||
- name: Build Plugins Packages
|
||||
run: |
|
||||
docker run --rm $TAG bash -c \
|
||||
"npm run plugins:build"
|
||||
|
||||
test-storybook:
|
||||
needs: frontend-build
|
||||
if: needs.frontend-build.outputs.should-run == 'true'
|
||||
@@ -214,6 +209,6 @@ jobs:
|
||||
permissions:
|
||||
actions: read
|
||||
steps:
|
||||
- uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1
|
||||
- uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0
|
||||
with:
|
||||
expand-composite-actions: true
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
lint-test:
|
||||
runs-on: ubuntu-slim
|
||||
steps:
|
||||
- uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1
|
||||
- uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0
|
||||
with:
|
||||
expand-composite-actions: true
|
||||
|
||||
|
||||
@@ -178,6 +178,6 @@ jobs:
|
||||
permissions:
|
||||
actions: read
|
||||
steps:
|
||||
- uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1
|
||||
- uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0
|
||||
with:
|
||||
expand-composite-actions: true
|
||||
|
||||
@@ -263,6 +263,6 @@ jobs:
|
||||
permissions:
|
||||
actions: read
|
||||
steps:
|
||||
- uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1
|
||||
- uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0
|
||||
with:
|
||||
expand-composite-actions: true
|
||||
|
||||
@@ -166,6 +166,6 @@ jobs:
|
||||
permissions:
|
||||
actions: read
|
||||
steps:
|
||||
- uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1
|
||||
- uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0
|
||||
with:
|
||||
expand-composite-actions: true
|
||||
|
||||
@@ -105,7 +105,7 @@ jobs:
|
||||
contents: read
|
||||
actions: read
|
||||
steps:
|
||||
- uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1
|
||||
- uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0
|
||||
with:
|
||||
expand-composite-actions: true
|
||||
|
||||
|
||||
@@ -161,6 +161,6 @@ jobs:
|
||||
permissions:
|
||||
actions: read
|
||||
steps:
|
||||
- uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1
|
||||
- uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0
|
||||
with:
|
||||
expand-composite-actions: true
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
- name: Login to Docker Hub
|
||||
if: ${{ steps.dependabot-metadata.outputs.package-ecosystem == 'pip' }}
|
||||
continue-on-error: true
|
||||
uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USER }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
@@ -138,6 +138,7 @@ PROJECT.md
|
||||
.aider*
|
||||
.claude_rc*
|
||||
.claude/settings.local.json
|
||||
.claude/worktrees/
|
||||
.env.local
|
||||
oxc-custom-build/
|
||||
*.code-workspace
|
||||
|
||||
@@ -2,22 +2,29 @@
|
||||
|
||||
Apache Superset is a data visualization platform with Flask/Python backend and React/TypeScript frontend.
|
||||
|
||||
## ⚠️ CRITICAL: Always Run Pre-commit Before Pushing
|
||||
## Run Pre-commit Before Pushing
|
||||
|
||||
**ALWAYS run `pre-commit run --all-files` before pushing commits.** CI will fail if pre-commit checks don't pass. This is non-negotiable.
|
||||
Always run pre-commit against the files changed by the current branch before
|
||||
pushing. This matches CI and keeps unrelated failures already present on
|
||||
`master` from blocking otherwise independent work.
|
||||
|
||||
```bash
|
||||
# Stage your changes first
|
||||
git add .
|
||||
|
||||
# Run pre-commit on all files
|
||||
pre-commit run --all-files
|
||||
# Run pre-commit on staged files
|
||||
pre-commit run
|
||||
|
||||
# If there are auto-fixes, stage them and commit
|
||||
git add .
|
||||
git commit --amend # or new commit
|
||||
```
|
||||
|
||||
Use `pre-commit run --all-files` when auditing or repairing the repository-wide
|
||||
baseline. If that check finds failures in files untouched by the current branch,
|
||||
fix them in a separate branch rather than adding unrelated changes to the
|
||||
current pull request.
|
||||
|
||||
Common pre-commit failures:
|
||||
- **Formatting** - black, oxfmt, eslint will auto-fix
|
||||
- **Type errors** - mypy failures need manual fixes
|
||||
|
||||
+226
-39
@@ -24,6 +24,141 @@ assists people when migrating to a new version.
|
||||
|
||||
## Next
|
||||
|
||||
- [42935](https://github.com/apache/superset/pull/42935): The MCP service now refuses to start (`MCPAuthConfigError`) when `MCP_JWT_ISSUER` trusts more than one issuer and no `MCP_USER_RESOLVER` is configured, instead of only logging a warning. This was already a documented misconfiguration (the default resolver isn't issuer-scoped, so distinct trusted issuers minting the same username/email would resolve to the same Superset user); deployments trusting multiple issuers must configure an `MCP_USER_RESOLVER` that derives its identity from the token's `iss` claim before upgrading. Single-issuer deployments are unaffected.
|
||||
- [42393](https://github.com/apache/superset/pull/42393): Exported dataset YAML now carries a `uuid` for each metric and column so that custom folder assignments (which reference metrics/columns by UUID) survive an import into another workspace. This affects any export bundle that contains datasets, not just a dataset export: chart, dashboard, database and full-asset exports all embed the same dataset YAML, so a dashboard exported from this release also fails to import into an older one even though no dataset was exported directly. As with `folders` and `currency_code_column`, the affected `datasets/` files fail schema validation (`Unknown field: uuid`) when imported into Superset releases that predate this change; regenerate or hand-edit exports for older targets in mixed-version fleets.
|
||||
- [42087](https://github.com/apache/superset/pull/42087): Stored calculated-column and metric expressions are validated when a query is built, under the same sub-query policy already applied to adhoc expressions. Previously only the dataset update path checked them on save, so expressions written by v1 import, by dataset duplication, or before that check existed were never validated. Since `ALLOW_ADHOC_SUBQUERY` defaults to `False` (see [19242](https://github.com/apache/superset/pull/19242)), a dataset whose stored expression contains a sub-query works before upgrading and afterwards fails at chart render with `Custom SQL fields cannot contain sub-queries.` There is no migration step, and the error does not name the offending dataset column, so audit stored expressions before upgrading: either rewrite them without the sub-query, or set `ALLOW_ADHOC_SUBQUERY = True` to keep the previous behaviour for both stored and adhoc expressions.
|
||||
|
||||
### Selenium support removed — Playwright is now required for screenshots
|
||||
|
||||
Selenium support has been removed. **Playwright is now required** for all
|
||||
report and thumbnail screenshot generation. Install it with:
|
||||
|
||||
```bash
|
||||
pip install playwright && playwright install chromium
|
||||
```
|
||||
|
||||
**Breaking config changes:**
|
||||
|
||||
- `PLAYWRIGHT_REPORTS_AND_THUMBNAILS` feature flag removed (Playwright is the only backend now)
|
||||
- `WEBDRIVER_TYPE` config key removed (Playwright always uses Chromium)
|
||||
- `WEBDRIVER_CONFIGURATION` config key removed (Selenium-only)
|
||||
- `SCREENSHOT_PAGE_LOAD_WAIT` config key removed (Selenium-only)
|
||||
- `SCREENSHOT_SELENIUM_RETRIES` config key removed (Selenium-only)
|
||||
- `SCREENSHOT_WAIT_FOR_ERROR_MODAL_VISIBLE` config key removed (Selenium-only)
|
||||
- `SCREENSHOT_WAIT_FOR_ERROR_MODAL_INVISIBLE` config key removed (Selenium-only)
|
||||
- `MachineAuthProvider.authenticate_webdriver()` removed; use `authenticate_browser_context()` instead
|
||||
|
||||
**What operators should do before upgrading:**
|
||||
|
||||
1. Install Playwright: `pip install playwright && playwright install chromium`
|
||||
2. Remove any references to the removed config keys from custom `superset_config.py`
|
||||
3. If you subclassed `MachineAuthProvider`, remove any `authenticate_webdriver` override and migrate auth logic to `authenticate_browser_context`
|
||||
|
||||
### Soft delete is on by default, and purging is live
|
||||
|
||||
`SOFT_DELETE` now ships **on** (`DEFAULT_FEATURE_FLAGS`), so deleting a
|
||||
dashboard, chart, or dataset archives it rather than removing it. Archived
|
||||
objects are hidden from normal listings, recoverable from **Recently Archived**,
|
||||
and permanently removed once the retention window elapses.
|
||||
`SOFT_DELETE_PURGE_DRY_RUN` also flips to `False`, so the nightly
|
||||
`deletion_retention.purge_soft_deleted` task deletes for real instead of only
|
||||
logging `would_purge` counts.
|
||||
|
||||
**What operators should do before upgrading:**
|
||||
|
||||
- **Size the first live purge.** The first real run removes every entity that
|
||||
aged past `SOFT_DELETE_RETENTION_DAYS` (default 30) since soft delete began
|
||||
capturing, which on a busy deployment can be a large batch in one window. To
|
||||
see the size first, set `SOFT_DELETE_PURGE_DRY_RUN = True`, read the
|
||||
`would_purge` counts from one nightly run, then set it back.
|
||||
- **Check a replaced `CELERY_CONFIG`.** A deployment that redefines it rather
|
||||
than inheriting must carry both `superset.tasks.deletion_retention` in
|
||||
`imports` and the `deletion_retention.purge_soft_deleted` beat entry;
|
||||
a startup warning now names whichever is absent.
|
||||
|
||||
**Both switches are retained.** `SOFT_DELETE = False` restores hard-delete
|
||||
behaviour and `SOFT_DELETE_PURGE_DRY_RUN = True` suspends purging, at any time.
|
||||
One caveat on turning soft delete back off: objects archived while it was on are
|
||||
**resurrected** into normal listings, since the rows were never removed — an
|
||||
emergency stop rather than a clean rollback.
|
||||
|
||||
### Version history is on by default
|
||||
|
||||
`VERSION_HISTORY` and `ENABLE_VERSIONING_CAPTURE` now both ship **on**. Every
|
||||
save of a chart, dashboard, or dataset writes version rows, and the version
|
||||
history panel appears on Explore and Dashboard pages. The two flip together
|
||||
deliberately: a panel with capture off renders an empty "No history yet" that
|
||||
misrepresents the entity as unchanged.
|
||||
|
||||
**What operators should expect:**
|
||||
|
||||
- **Storage growth.** Capture writes shadow rows per save, so the metadata
|
||||
database grows with edit volume. The `version_history.prune_old_versions`
|
||||
beat task removes rows whose transaction is older than
|
||||
`SUPERSET_VERSION_HISTORY_RETENTION_DAYS` (default 30). A deployment that
|
||||
replaces `CELERY_CONFIG` rather than inheriting it must carry both the
|
||||
`superset.tasks.version_history_retention` import and the beat entry; a
|
||||
startup warning names whichever is absent.
|
||||
- **`PUT` responses change shape.** Entity updates now return populated
|
||||
`old_version_uuid` / `new_version_uuid` fields and an `ETag` header, which
|
||||
were null or absent while capture was off.
|
||||
|
||||
`ENABLE_VERSIONING_CAPTURE` is **retained permanently** as an operational
|
||||
kill-switch — not removed with the rollout toggles. Setting it to a falsy value
|
||||
stops capture within a restart, without a revert-and-redeploy. Unlike the
|
||||
soft-delete toggle, turning it off is a clean stop: existing version rows remain
|
||||
readable and no entity state is altered.
|
||||
|
||||
### Scheduled report execution now enforces one application deadline
|
||||
|
||||
Scheduled report (not alert) executions are now governed by a single
|
||||
end-to-end deadline shared by browser readiness, capture/PDF generation,
|
||||
notification delivery, and terminal-state persistence, configured via
|
||||
`ALERT_REPORTS_EXECUTION_BUDGET_SECONDS` (with per-phase reserve settings).
|
||||
Behavior changes to be aware of:
|
||||
|
||||
- The effective budget for a schedule is
|
||||
`min(ALERT_REPORTS_EXECUTION_BUDGET_SECONDS, working_timeout)`. The default
|
||||
budget (one hour) matches the historical `working_timeout` model default,
|
||||
so default installations see no change in how long a report may run —
|
||||
but reports now fail cleanly (with an error notification) at the deadline
|
||||
instead of being killed silently by Celery.
|
||||
- For REPORT schedules, the Celery `soft_time_limit`/`time_limit` are now
|
||||
derived from that same effective budget plus
|
||||
`ALERT_REPORTS_EXECUTION_HARD_TIMEOUT_GRACE_SECONDS`, replacing the
|
||||
previous `working_timeout + ALERT_REPORTS_WORKING_TIME_OUT_LAG` /
|
||||
`+ ALERT_REPORTS_WORKING_SOFT_TIME_OUT_LAG` derivation. Alert schedules
|
||||
keep the previous behavior.
|
||||
- A `working_timeout` smaller than the summed phase reserves is floored at
|
||||
the minimum viable budget (reserves + 30s) with a warning; such reports
|
||||
fail fast at the first phase check rather than erroring at setup.
|
||||
- Dashboard reports whose charts have not mounted are no longer captured
|
||||
blank: readiness is polled until the deadline, and the report fails loudly
|
||||
if charts never mount. Thumbnails and non-report screenshots keep their
|
||||
previous behavior.
|
||||
|
||||
### Embedded (guest token) API responses no longer echo database errors
|
||||
|
||||
API responses served to a guest-token principal now carry a generic
|
||||
`An error occurred while fetching the data.` in place of the underlying error
|
||||
(`You don't have permission to access this resource.` on a 401/403), and drop
|
||||
the `stacktrace` and error `extra` payloads. Engine errors routinely quote
|
||||
catalog, schema, table and column names of the warehouse, which embedded
|
||||
viewers should not see. Errors Superset authors itself — access denials, OAuth2
|
||||
redirects, timeouts, payload validation — keep their message and type, though
|
||||
their `extra` is still reduced to the fields the client needs. Responses to
|
||||
every non-guest principal are unchanged, and the full error is still logged
|
||||
server-side.
|
||||
|
||||
### `UnsavedChangesModal` no longer accepts a `zIndex` prop
|
||||
|
||||
`@superset-ui/core`'s `UnsavedChangesModal` dropped its `zIndex` prop (and the
|
||||
hardcoded default it fed) in favor of letting Ant Design's own stacking
|
||||
handle placement. Callers passing `zIndex` to override the modal's layering
|
||||
will now get a TypeScript error and must remove the prop; keeping a manual
|
||||
override was exactly the footgun this change removes (see #42510). No
|
||||
callers in the Superset frontend codebase itself passed this prop.
|
||||
|
||||
### Principal listing APIs now honour related-field filters
|
||||
|
||||
Two authorization-related listing behaviors changed for API clients. Neither
|
||||
@@ -72,12 +207,24 @@ A new dashboard action exports every chart's data to a single multi-sheet
|
||||
requires a running Celery worker and a configured SMTP transport, since the task
|
||||
emails the requesting user a pre-signed download link. New config keys:
|
||||
`EXCEL_EXPORT_S3_BUCKET`, `EXCEL_EXPORT_S3_KEY_PREFIX`,
|
||||
`EXCEL_EXPORT_LINK_TTL_SECONDS`, `EXCEL_EXPORT_S3_CLIENT_KWARGS`, and
|
||||
`EXCEL_EXPORT_TABLE_VIZ_TYPES`.
|
||||
`EXCEL_EXPORT_LINK_TTL_SECONDS`, `EXCEL_EXPORT_S3_CLIENT_KWARGS`,
|
||||
`EXCEL_EXPORT_TABLE_VIZ_TYPES`, and `EXCEL_EXPORT_QUERY_CONTEXT_BUILDER`.
|
||||
|
||||
The feature depends on `boto3`, which is **not** installed by default; install it
|
||||
with `pip install apache-superset[excel-export]`.
|
||||
|
||||
Charts store their `query_context` only once they have been (re-)saved in
|
||||
Explore, so older charts may have none. For a fixed, conservative set of viz
|
||||
types (`table`, `big_number_total`, `big_number`, `pie`) the export rebuilds a
|
||||
query context from the chart's saved form data so those charts still export.
|
||||
The rebuild is a single-query mapping and does **not** reproduce plugin
|
||||
post-processing (pivot, rolling, forecast) or multi-query charts, so any chart of
|
||||
another type without a saved query context is skipped and listed in the email for
|
||||
the user to re-save. To cover those types, set `EXCEL_EXPORT_QUERY_CONTEXT_BUILDER`
|
||||
to a callable that receives the chart's form data and returns a query-context
|
||||
payload (or `None` to fall back to the built-in rebuild) — for example one backed
|
||||
by a service that runs the chart's real frontend `buildQuery`.
|
||||
|
||||
A second mode, **Export Images to Excel**, embeds non-table charts as rendered
|
||||
images (which viz types stay tabular is controlled by
|
||||
`EXCEL_EXPORT_TABLE_VIZ_TYPES`). It renders through the headless webdriver, so the
|
||||
@@ -233,6 +380,10 @@ are the intended model going forward; deprecating and removing implicit viewersh
|
||||
in a later major version.
|
||||
|
||||
- [41044](https://github.com/apache/superset/issues/41044): Removes the deprecated `AVOID_COLORS_COLLISION` feature flag (it defaulted to `True`). Color-collision avoidance is now permanently enabled; any config override setting it to `False` is ignored.
|
||||
- [41714](https://github.com/apache/superset/pull/41714): **Breaking — the legacy `explore_json` chart-data pipeline is removed** at its long-declared `5.0.0` EOL. The `/superset/explore_json/` and `/superset/explore_json/data/<cache_key>` endpoints, `superset/viz.py`, the `Slice.viz` property, the `get_viz` factory, the `load_explore_json_into_cache` celery task and the `viz=` overload of `security_manager.raise_for_access` are gone. Anything importing `superset.viz` must migrate to the QueryContext / `pandas_postprocessing` pipeline behind `/api/v1/chart/data`. All 15 remaining legacy charts were migrated first: most keep their `viz_type` and renderer (no action needed for saved charts), while saved nvd3 Bubble charts are auto-migrated to the ECharts Bubble Chart (`bubble_v2`) and saved "Time-series Percent Change" (`compare`) charts to the ECharts Line Chart, which restores the nvd3 renderer's interactive percent re-basing via a draggable baseline. The deck.gl Multiple Layers chart now fetches its layers entirely client-side, refitting the viewport as each layer's data arrives, and caps the number of sub-slices fanned out per chart at `DECK_MULTI_MAX_SLICES` (default 50, configurable); dashboard filter badges no longer aggregate child-layer filter metadata.
|
||||
|
||||
- [41714](https://github.com/apache/superset/pull/41714): Charts migrated in place keep a `NULL` saved query context until they are next opened in Explore (which regenerates it automatically) or re-saved. Until then, cache warm-up and annotation layers referencing such a chart report an actionable error rather than warming/rendering; opening the chart once resolves it.
|
||||
- [41714](https://github.com/apache/superset/pull/41714): **Breaking for third-party viz plugins** — the `useLegacyApi` field of `ChartMetadata` in `@superset-ui/core` is removed. Plugins that set it must provide a `buildQuery` and consume `/api/v1/chart/data`. The migrated first-party packages also drop their `legacy-` prefix: `@superset-ui/legacy-plugin-chart-{calendar,chord,country-map,horizon,paired-t-test,parallel-coordinates,partition,rose,world-map}` → `@superset-ui/plugin-chart-*`, and `@superset-ui/legacy-preset-chart-nvd3` → `@superset-ui/preset-chart-nvd3`. The `can_explore_json` permission is no longer created or granted; custom roles referencing it should switch to the `can_read` permissions on `Chart`.
|
||||
|
||||
- [41813](https://github.com/apache/superset/pull/41813): `redis` (the Python client, `redis-py`) is bumped from 5.3.1 to 8.0.1. redis-py 8 changes several connection defaults; Superset's own Redis-backed features (`GLOBAL_ASYNC_QUERIES_CACHE_BACKEND`, `DISTRIBUTED_COORDINATION_CONFIG`, and the MCP Redis store) explicitly pin the pre-upgrade behavior so this bump is a no-op for them: the wire protocol stays RESP2 (not the new RESP3 default, which requires Redis/Sentinel 6+ to speak `HELLO`) and there is still no socket timeout by default (redis-py 8 defaults to 5s, which could otherwise newly time out large cached payloads or slow networks). The no-timeout default can now be overridden via two new config keys, `CACHE_REDIS_SOCKET_TIMEOUT` / `CACHE_REDIS_SOCKET_CONNECT_TIMEOUT`, on any `CacheConfig` dict using `CACHE_TYPE: RedisCache` or `RedisSentinelCache`. Separately, redis-py 6+ changed the default for `ssl_check_hostname` from `False` to `True` for SSL connections using `ssl_cert_reqs="required"` (the default) — this is a security improvement, so it has **not** been reverted; deployments with `CACHE_REDIS_SSL=True` whose certificates lack a hostname matching the connection address should set `CACHE_REDIS_SSL_CERT_REQS="none"` (disables cert verification entirely, matching hostname-check bypass) or replace the certificate. General-purpose cache/results backends configured via `CACHE_CONFIG` / `DATA_CACHE_CONFIG` / `RESULTS_BACKEND` with `CACHE_TYPE: RedisCache` go through `flask-caching`'s own Redis backend (outside Superset's code) and are subject to the same new defaults; pass `socket_timeout` / `protocol` via `CACHE_OPTIONS` there if needed. Celery broker and result-backend connections (built by `kombu`, also outside Superset's code) keep their no-socket-timeout behavior (`kombu` passes `socket_timeout=None` explicitly) but do **not** pin the wire protocol, so they follow redis-py's RESP3 default — which requires a Redis server new enough to speak `HELLO` (Redis 6+). Deployments using a pre-6.0 Redis server (EOL) as a Celery broker should upgrade the server before taking this bump.
|
||||
|
||||
@@ -252,7 +403,7 @@ in a later major version.
|
||||
|
||||
- **`SqlaTable.sql_url` query-string format.** `SqlaTable.sql_url` now URL-encodes `table_name` and joins it as a query parameter rather than concatenating a second `?`. Previously, with `Database.sql_url` returning `/sqllab/?dbid=<id>`, the concatenation produced `/sqllab/?dbid=<id>?table_name=<raw>` — a malformed second `?` that broke the query parser. External code that parsed the legacy `<base>?table_name=<raw>` shape now sees properly percent-encoded values (e.g. `/` → `%2F`, ` ` → `+` or `%20`); decode with `urllib.parse.parse_qsl`.
|
||||
|
||||
- **New config flag `EMBEDDED_DISABLE_PERMALINK_ORIGIN_REWRITE` (default `False`).** Share/permalink URLs now substitute `window.location.origin` for the backend-supplied origin so a proxied or subdirectory-deployed Superset never hands the user an unreachable internal hostname. Operators whose reverse proxy correctly forwards `X-Forwarded-Host` *and* who want permalinks to carry the backend's literal origin can opt out by setting `EMBEDDED_DISABLE_PERMALINK_ORIGIN_REWRITE = True` in `superset_config.py`. Default `False` (rewrite is on); flipping the default would regress the dominant proxied/subdir deployment to an unreachable host.
|
||||
- **New config flag `EMBEDDED_DISABLE_PERMALINK_ORIGIN_REWRITE` (default `False`).** Share/permalink URLs now substitute `window.location.origin` for the backend-supplied origin so a proxied or subdirectory-deployed Superset never hands the user an unreachable internal hostname. Operators whose reverse proxy correctly forwards `X-Forwarded-Host` _and_ who want permalinks to carry the backend's literal origin can opt out by setting `EMBEDDED_DISABLE_PERMALINK_ORIGIN_REWRITE = True` in `superset_config.py`. Default `False` (rewrite is on); flipping the default would regress the dominant proxied/subdir deployment to an unreachable host.
|
||||
|
||||
- [41651](https://github.com/apache/superset/pull/41651): **New do-not-translate standard for translation catalogs.** Strings that must stay identical to the source — icon names (e.g. `bolt`), enum/option values (`step-after`), SQL keywords, API field names (`error_message`), code constants, and example placeholders — are now marked with a `#. do-not-translate` extracted comment. The list lives in the `superset/translations/do-not-translate.txt` registry; `scripts/translations/apply_do_not_translate.py` stamps the marker onto `messages.pot` during `babel_update.sh`, and `pybabel update` propagates it to every `.po`, so the status is consistent across all languages. The AI backfill (`backfill_po.py`) and translators leave these entries untranslated (source fallback). The legacy per-catalog convention (a `# Не переводить` translator comment in the `ru` catalog) is still honored for back-compat but is superseded by this standard; contributors adding new machine-read strings should add the msgid to the registry rather than annotating individual catalogs.
|
||||
|
||||
@@ -289,7 +440,7 @@ Theme tokens are unaffected — antd 6 removed none of the tokens Superset expos
|
||||
|
||||
### Guest-token RLS rules reject unknown fields
|
||||
|
||||
The `rls` rules passed to `POST /api/v1/security/guest_token/` are now validated strictly: a rule may only contain `dataset` and `clause`. Previously unknown fields were silently dropped, so a mistyped or legacy scope key (most commonly `datasource` instead of `dataset`) produced a rule with no `dataset`, which is treated as a *global* rule applied to every dataset the embedded resource can reach. Such a request now returns HTTP 400 identifying the offending field instead of issuing a token with an unintended global rule. Integrators that were sending extra fields in RLS rules must remove them; valid dataset-scoped (`{"dataset": 41, "clause": "..."}`) and global (`{"clause": "..."}`) rules are unaffected.
|
||||
The `rls` rules passed to `POST /api/v1/security/guest_token/` are now validated strictly: a rule may only contain `dataset` and `clause`. Previously unknown fields were silently dropped, so a mistyped or legacy scope key (most commonly `datasource` instead of `dataset`) produced a rule with no `dataset`, which is treated as a _global_ rule applied to every dataset the embedded resource can reach. Such a request now returns HTTP 400 identifying the offending field instead of issuing a token with an unintended global rule. Integrators that were sending extra fields in RLS rules must remove them; valid dataset-scoped (`{"dataset": 41, "clause": "..."}`) and global (`{"clause": "..."}`) rules are unaffected.
|
||||
|
||||
### MCP service requires `MCP_JWT_AUDIENCE` when JWT auth is enabled
|
||||
|
||||
@@ -340,9 +491,23 @@ As a result the per-table **"Aggregation function"** control (which let you pick
|
||||
how totals were aggregated client-side, e.g. Sum/Average/Count) has been
|
||||
removed: totals now always reflect the metric's own definition evaluated at the
|
||||
total's granularity. For additive metrics (`SUM`/`COUNT`/`MIN`/`MAX`) the result
|
||||
is unchanged. Saved charts that set `aggregateFunction` will ignore it; no
|
||||
migration is required. If you previously relied on a plain sum-of-cells total
|
||||
for a non-additive metric, that specific behavior is no longer available.
|
||||
is unchanged. If you previously relied on a plain sum-of-cells total for a
|
||||
non-additive metric, that specific behavior is no longer available.
|
||||
|
||||
The "Sum as Fraction of Total/Rows/Columns" display options are back as a
|
||||
new, standalone **"Show values as"** control (below "Combine metrics" in the
|
||||
Options panel), since those were mathematically correct even before this
|
||||
change and are unrelated to the totals-correctness fix. A DB migration
|
||||
derives the new field from any still-present `aggregateFunction` Sum-fraction
|
||||
value, so a chart that had one of those options configured picks the
|
||||
equivalent "Show values as" setting back up automatically. The "Count as
|
||||
Fraction of ..." variants are **not** migrated: they divided a record count,
|
||||
while the new control divides the metric's own value, so translating them
|
||||
automatically would silently change what the chart displays rather than
|
||||
restore it; those charts need to be manually reconfigured if the value-based
|
||||
percentage is what's wanted. Charts that used any other non-fraction
|
||||
`aggregateFunction` value (Sum, Average, Count, ...) are unaffected, since
|
||||
that specific behavior remains unavailable per the above.
|
||||
|
||||
### `thumbnail_url` removed from dashboard list API response
|
||||
|
||||
@@ -373,6 +538,7 @@ ALTER TABLE tagged_object DROP CONSTRAINT <constraint_name>;
|
||||
-- MySQL: find names via `SHOW CREATE TABLE tagged_object;`
|
||||
ALTER TABLE tagged_object DROP FOREIGN KEY <constraint_name>;
|
||||
```
|
||||
|
||||
### Entity version-history infrastructure (gated off by default)
|
||||
|
||||
Introduces the schema and SQLAlchemy-Continuum wiring that captures version history for charts, dashboards, and datasets, plus read-only `GET /api/v1/{chart,dashboard,dataset}/<uuid>/versions/` endpoints. This ships **inert**: a new config flag `ENABLE_VERSIONING_CAPTURE` defaults to `False`, so no save writes any version rows and the endpoints return empty. It is an operational kill-switch (a release toggle that becomes a permanent ops switch), not a feature flag — set it to `True` to enable capture once validated. The migration is additive; existing entity `PUT` responses gain `old_version_uuid` / `new_version_uuid` body fields and an `ETag` header (both null/absent when capture is off).
|
||||
@@ -389,12 +555,12 @@ These are behavior changes that take effect on upgrade regardless of `ENABLE_VER
|
||||
|
||||
A read-only companion to the version-history endpoints: each entity type gains a `GET /api/v1/{chart,dashboard,dataset}/<uuid>/activity/` endpoint returning a chronological, access-filtered stream of edits — the entity's own edits plus, for charts and dashboards, transitive edits to related entities during their association windows. Datasets have no related layer in V2, so `include=related` returns an empty stream for a dataset and `include=all` reduces to the dataset's own edits.
|
||||
|
||||
| Param | Type | Default | Purpose |
|
||||
|---|---|---|---|
|
||||
| `since` / `until` | ISO 8601 | — | Bound `issued_at` |
|
||||
| `include` | `self` \| `related` \| `all` | `all` | Own edits, related edits, or both |
|
||||
| `q` | string | — | Case-insensitive search over the full history, applied before pagination (so `count` reflects matches) |
|
||||
| `page` / `page_size` | integer | `0` / `25` | Pagination (`page_size` clamped to 200) |
|
||||
| Param | Type | Default | Purpose |
|
||||
| -------------------- | ---------------------------- | ---------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| `since` / `until` | ISO 8601 | — | Bound `issued_at` |
|
||||
| `include` | `self` \| `related` \| `all` | `all` | Own edits, related edits, or both |
|
||||
| `q` | string | — | Case-insensitive search over the full history, applied before pagination (so `count` reflects matches) |
|
||||
| `page` / `page_size` | integer | `0` / `25` | Pagination (`page_size` clamped to 200) |
|
||||
|
||||
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`).
|
||||
|
||||
@@ -406,17 +572,17 @@ Entity version history (the `version_transaction` / `*_version` shadow tables th
|
||||
|---|---|---|
|
||||
| `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).
|
||||
The task ships in the default `CeleryConfig` (both the `superset.tasks.version_history_retention` import and the beat entry). A deployment that overrides `CELERY_CONFIG` without the beat entry logs a startup warning. When the override explicitly defines `imports`, a missing retention module is also reported; an absent `imports` setting is not diagnosed because Celery may register tasks through `include`, autodiscovery, or worker startup imports. Retention only prunes whatever history exists — capture itself is gated separately by `ENABLE_VERSIONING_CAPTURE`, which now ships on.
|
||||
|
||||
### Deletion retention (soft-deleted entities are eventually purged)
|
||||
|
||||
Soft-deleted dashboards, charts, and datasets are now permanently removed after a retention window (default 30 days; `SOFT_DELETE_RETENTION_DAYS`, `0` disables; settable per workspace at runtime via the `deletion-retention set-window` CLI, which takes precedence). The `deletion_retention.purge_soft_deleted` Celery beat task runs daily and removes each aged-out entity together with its M:N join rows, owned children, datasource permission, and version-history shadow rows. After purge an entity is **unrecoverable** — its detail and `/restore` endpoints return 404 and its version history is gone.
|
||||
|
||||
The introducing release **defaults to dry-run** (`SOFT_DELETE_PURGE_DRY_RUN=True`): the task logs `would_purge` counts but deletes nothing, so operators can validate against production before activating real purging by setting it to `False`. Note `would_purge` is an **upper bound** — it counts every entity past the retention window without evaluating deletion blockers, so a real run may purge fewer (entities referenced by report schedules or set as a user's welcome dashboard are blocked and reported separately). The task only acts while the temporary `SOFT_DELETE` rollout flag is on.
|
||||
Purging is **live by default** (`SOFT_DELETE_PURGE_DRY_RUN=False`), so the retention promise above is real on a stock deployment. Set it to `True` to have the task log `would_purge` counts and delete nothing — the lever is retained, so an operator can return to dry-run at any time. Note `would_purge` is an **upper bound** — it counts every entity past the retention window without evaluating deletion blockers, so a real run may purge fewer (entities referenced by report schedules or set as a user's welcome dashboard are blocked and reported separately). The task only acts while the `SOFT_DELETE` rollout flag is on; it now ships on by default.
|
||||
|
||||
Deployments that replace the default `CELERY_CONFIG` must add `superset.tasks.deletion_retention` to the Celery `imports` and schedule the `deletion_retention.purge_soft_deleted` task themselves. The shipped Docker development config includes both entries.
|
||||
Deployments that replace the default `CELERY_CONFIG` must ensure workers register `superset.tasks.deletion_retention` and schedule the `deletion_retention.purge_soft_deleted` task themselves. The shipped Docker development config uses `imports` and includes both entries. While `SOFT_DELETE` is statically enabled, a missing beat entry logs a startup warning; when the override explicitly defines `imports`, a missing purge module is also reported.
|
||||
|
||||
Operators can immediately erase a specific entity for compliance (GDPR) via `superset deletion-retention force-purge --uuid <uuid>`; this applies legacy hard-delete semantics — a live chart referencing a force-purged dataset is left without a datasource until re-pointed (the chart is not modified), and it purges the named entity even when it was never soft-deleted. Every purge writes an immutable, content-free audit record to the new `purge_audit_log` table that survives the entity it names: the **scheduled** purge fails closed (an entity whose audit row cannot be written is skipped and retried next run), while **force-purge** proceeds even if the audit write fails — the operator is present and deletion outranks audit for a compliance erasure.
|
||||
Operators can immediately erase a specific entity for compliance (GDPR) via `superset deletion-retention force-purge --uuid <uuid>`; this applies legacy hard-delete semantics — a live chart referencing a force-purged dataset is left without a datasource until re-pointed (the chart is not modified), and it purges the named entity even when it was never soft-deleted. Every scheduled evaluation writes a provisional, content-free record to the new `purge_audit_log` table before the cascade starts. Meaningful retained outcomes survive the entity they name. Consecutive scheduled evaluations with the same blocked outcome suppress only the redundant current provisional record; completed outcomes, outcome transitions, and every force-purge attempt remain independent and immutable. The **scheduled** purge fails closed when its provisional record cannot be written, while **force-purge** proceeds even if the audit write fails — the operator is present and deletion outranks audit for a compliance erasure. Operators can monitor `deletion_retention.blocked_audit_suppressed` and `deletion_retention.blocked_audit_dedupe_fallback` to verify suppression and fail-safe fallback behavior without changing the existing blocked-workload gauge.
|
||||
|
||||
### Recently Archived view and permanent delete (purge) endpoints
|
||||
|
||||
@@ -478,6 +644,7 @@ Operators can tune or disable the policy via config:
|
||||
### Data uploads bounded by UPLOAD_MAX_FILE_SIZE_BYTES
|
||||
|
||||
Single data-file uploads (CSV, Excel, columnar) are now bounded by the `UPLOAD_MAX_FILE_SIZE_BYTES` config option, which defaults to `100 * 1024 * 1024` (100 MB). Files larger than this are rejected with a `413` before their contents are buffered into memory. Set `UPLOAD_MAX_FILE_SIZE_BYTES = None` to disable the check and restore unbounded uploads.
|
||||
|
||||
### Currency symbol position follows the locale when unset
|
||||
|
||||
When a chart's currency control leaves the **Prefix or suffix** field empty, the currency symbol position is now derived from the deployment locale's own convention via `Intl.NumberFormat` instead of always defaulting to a suffix. For example, under the default `en-US` locale `USD`, `GBP`, and `EUR` render as a prefix (`$ 1,000`), while eurozone locales such as `fr-FR` render `EUR` as a suffix (`1 000 €`). An explicit Prefix/Suffix selection is always honored and is unaffected.
|
||||
@@ -603,7 +770,7 @@ SQLALCHEMY_ENCRYPTED_FIELD_ENGINE = "aes"
|
||||
```bash
|
||||
superset re-encrypt-secrets --engine aes-gcm
|
||||
```
|
||||
A live instance keeps writing *new* secrets as AES-CBC during the window between step 2 and the restart in step 4; this second pass sweeps those up (it is idempotent, so already-migrated values are skipped).
|
||||
A live instance keeps writing _new_ secrets as AES-CBC during the window between step 2 and the restart in step 4; this second pass sweeps those up (it is idempotent, so already-migrated values are skipped).
|
||||
|
||||
Schedule the cutover in a quiet window. Runtime reads use only the single configured engine, so in a multi-worker deployment there is an unavoidable brief decrypt-outage between the migration commit and the last worker restarting with the new config — each migrator run is transactional, but the fleet-wide cutover is not zero-downtime.
|
||||
|
||||
@@ -631,11 +798,11 @@ With the flag enabled: `DELETE /api/v1/dataset/<id>` no longer hard-deletes the
|
||||
|
||||
**Schema migration:** the migration adds a nullable `deleted_at` column and an index on it (`ix_tables_deleted_at`) to the `tables` table. The column add is instant; the index build runs inline (no `CONCURRENTLY`) and may briefly block writes on the `tables` table (INSERT/UPDATE/DELETE are queued while the index builds; reads are unaffected) on large Postgres deployments. MySQL InnoDB builds the index online (no blocking). Production deployments with many thousands of datasets should run this migration during a maintenance window.
|
||||
|
||||
**Rollback note:** if the application code is rolled back after datasets have been soft-deleted, the older code path's visibility filter no longer applies and previously hidden rows become visible to the older code. Pair the rollback with a data decision (restore the rows, hard-delete them, or also downgrade the migration) rather than assuming the old hard-delete semantics still hold. **Downgrading the migration destroys the deletion markers**: `downgrade()` drops the `deleted_at` column, so any not-yet-restored soft-deleted datasets silently become live, active datasets with no record they were ever deleted. Reconcile the trash (restore or hard-delete each row) *before* downgrading, and disable the `SOFT_DELETE` flag first so no new soft deletes land mid-rollback.
|
||||
**Rollback note:** if the application code is rolled back after datasets have been soft-deleted, the older code path's visibility filter no longer applies and previously hidden rows become visible to the older code. Pair the rollback with a data decision (restore the rows, hard-delete them, or also downgrade the migration) rather than assuming the old hard-delete semantics still hold. **Downgrading the migration destroys the deletion markers**: `downgrade()` drops the `deleted_at` column, so any not-yet-restored soft-deleted datasets silently become live, active datasets with no record they were ever deleted. Reconcile the trash (restore or hard-delete each row) _before_ downgrading, and disable the `SOFT_DELETE` flag first so no new soft deletes land mid-rollback.
|
||||
|
||||
**SQL Lab / dataset-creation flows:** creating a dataset over a table whose dataset sits in the trash is refused. The SQL Lab "save as dataset" flow (`get_or_create_dataset`) and file uploads return a **422 naming the hidden twin and the restore endpoint**; the plain create, update, and duplicate paths currently fail with the generic "already exists" 422. In all cases the remediation is the same: restore the hidden dataset (or use a different table name). Perm-string maintenance also covers hidden rows: renaming a database rewrites `perm`/`schema_perm`/`catalog_perm` on soft-deleted datasets and their charts, so a later restore does not resurrect stale permission strings.
|
||||
|
||||
**Importer behavior:** importing a dataset YAML whose UUID matches an existing **soft-deleted** dataset is treated as an implicit restore-with-update — **and this happens even when `overwrite` is not set**. This is a deliberate asymmetry with active rows: an active dataset imported without `overwrite=true` is returned unchanged, but a soft-deleted UUID match is restored *and* has the upload's contents applied regardless of the `overwrite` argument, on the reasoning that re-importing a deleted dataset's exact UUID is an explicit request to bring it back. The restore preserves the original PK, the chart back-reference, `table_columns`, and `sql_metrics`. Non-editors get `ImportFailedError`. Callers without `can_write` get `ImportFailedError` instead of silently receiving the soft-deleted row.
|
||||
**Importer behavior:** importing a dataset YAML whose UUID matches an existing **soft-deleted** dataset is treated as an implicit restore-with-update — **and this happens even when `overwrite` is not set**. This is a deliberate asymmetry with active rows: an active dataset imported without `overwrite=true` is returned unchanged, but a soft-deleted UUID match is restored _and_ has the upload's contents applied regardless of the `overwrite` argument, on the reasoning that re-importing a deleted dataset's exact UUID is an explicit request to bring it back. The restore preserves the original PK, the chart back-reference, `table_columns`, and `sql_metrics`. Non-editors get `ImportFailedError`. Callers without `can_write` get `ImportFailedError` instead of silently receiving the soft-deleted row.
|
||||
|
||||
**Uniqueness-validation changes that apply regardless of the feature flag:** two dataset uniqueness checks were tightened alongside this work and are active even with `SOFT_DELETE` off. (1) Create/update uniqueness treats a dataset whose `catalog` is `NULL` as belonging to the database's default catalog, so a legacy twin pair (`catalog=NULL` vs. `catalog=<default>`, same database/schema/name) that older versions allowed now fails validation with "already exists" when either row is edited — resolve by renaming or removing one of the twins. (2) Duplicating a dataset now checks name collisions scoped to the target (database, catalog, schema) instead of globally by name alone: duplicates into other databases that were previously blocked are now allowed.
|
||||
|
||||
@@ -655,9 +822,9 @@ With the flag enabled: `DELETE /api/v1/chart/<id>` no longer hard-deletes the ch
|
||||
|
||||
**Schema migration:** the migration adds a nullable `deleted_at` column and an index on it (`ix_slices_deleted_at`) to the `slices` table. The column add is instant; the index build runs inline (no `CONCURRENTLY`) and may briefly block writes on the `slices` table (INSERT/UPDATE/DELETE are queued while the index builds; reads are unaffected) on large Postgres deployments. MySQL InnoDB builds the index online (no blocking).
|
||||
|
||||
**Rollback note:** if the application code is rolled back after charts have been soft-deleted, the older code path's visibility filter no longer applies and previously hidden rows become visible to the older code. Pair the rollback with a data decision (restore the rows, hard-delete them, or also downgrade the migration) rather than assuming the old hard-delete semantics still hold. **Downgrading the migration destroys the deletion markers**: `downgrade()` drops the `deleted_at` column, so any not-yet-restored soft-deleted charts silently become live, active charts with no record they were ever deleted. Reconcile the trash (restore or hard-delete each row) *before* downgrading, and disable the `SOFT_DELETE` flag first so no new soft deletes land mid-rollback.
|
||||
**Rollback note:** if the application code is rolled back after charts have been soft-deleted, the older code path's visibility filter no longer applies and previously hidden rows become visible to the older code. Pair the rollback with a data decision (restore the rows, hard-delete them, or also downgrade the migration) rather than assuming the old hard-delete semantics still hold. **Downgrading the migration destroys the deletion markers**: `downgrade()` drops the `deleted_at` column, so any not-yet-restored soft-deleted charts silently become live, active charts with no record they were ever deleted. Reconcile the trash (restore or hard-delete each row) _before_ downgrading, and disable the `SOFT_DELETE` flag first so no new soft deletes land mid-rollback.
|
||||
|
||||
**Importer behavior:** importing a chart YAML whose UUID matches an existing **soft-deleted** chart is treated as an implicit restore-with-update — **and this happens even when `overwrite` is not set**. This is a deliberate asymmetry with active rows: an active chart imported without `overwrite=true` is returned unchanged, but a soft-deleted UUID match is restored *and* has the upload's contents applied regardless of the `overwrite` argument, on the reasoning that re-importing a deleted chart's exact UUID is an explicit request to bring it back. The restore preserves the original PK and all out-of-archive references (`dashboard_slices` junctions, `report.chart_id`, tag rows). The operation is permission-gated: non-editors get `ImportFailedError`, and callers without `can_write` get `ImportFailedError` instead of silently receiving the soft-deleted row.
|
||||
**Importer behavior:** importing a chart YAML whose UUID matches an existing **soft-deleted** chart is treated as an implicit restore-with-update — **and this happens even when `overwrite` is not set**. This is a deliberate asymmetry with active rows: an active chart imported without `overwrite=true` is returned unchanged, but a soft-deleted UUID match is restored _and_ has the upload's contents applied regardless of the `overwrite` argument, on the reasoning that re-importing a deleted chart's exact UUID is an explicit request to bring it back. The restore preserves the original PK and all out-of-archive references (`dashboard_slices` junctions, `report.chart_id`, tag rows). The operation is permission-gated: non-editors get `ImportFailedError`, and callers without `can_write` get `ImportFailedError` instead of silently receiving the soft-deleted row.
|
||||
|
||||
- [39914](https://github.com/apache/superset/pull/39914) `ALERT_REPORT_SLACK_V2` now defaults to `True` and the legacy Slack v1 integration (`Slack` recipient type, `files.upload` API) is deprecated for removal in the next major. Slack blocked new apps from `files.upload` in May 2024 and fully retired the method for all apps on November 12, 2025; because the v1 path sends files through `files.upload`, v1 file-bearing sends now fail at the API level — only text-only `chat_postMessage` still works via the legacy path. Grant your Slack bot the `channels:read` and `groups:read` scopes so existing `Slack` recipients can be auto-upgraded to `SlackV2` on next send. Operators who explicitly override the flag to `False`, or whose Slack bot is missing those scopes, will see deprecation warnings while text-only sends continue through the legacy path.
|
||||
|
||||
@@ -681,7 +848,7 @@ The partial-index replacement is dialect-dependent: PostgreSQL uses a native `WH
|
||||
|
||||
**Slug semantics:** on PostgreSQL and MySQL 8.0.13+, the slug of a soft-deleted dashboard is **free for reuse**. A new active dashboard can claim it immediately. Restoring a soft-deleted dashboard whose slug has since been claimed returns **422 with a clean error** (`DashboardSlugConflictError`) — rename one of the dashboards and retry; the restore is not silently rejected by a database-level constraint violation.
|
||||
|
||||
**Importer behavior:** importing a dashboard YAML whose UUID matches an existing **soft-deleted** dashboard is treated as an implicit restore-with-update — **and this happens even when `overwrite` is not set**. This is a deliberate asymmetry with active rows: an active dashboard imported without `overwrite=true` is returned unchanged (the import never mutates it), but a soft-deleted UUID match is restored *and* has the upload's contents applied regardless of the `overwrite` argument, on the reasoning that re-importing a deleted dashboard's exact UUID is an explicit request to bring it back. The restore preserves the original PK and all pre-deletion relationship rows (`dashboard_slices` junctions, editor/viewer subjects, tags). Callers whose imports must never mutate existing state should treat bundles that may contain previously deleted UUIDs accordingly. The operation is permission-gated: it requires `can_write` and editorship of the deleted row (or admin) — non-editors get `ImportFailedError`, and callers without `can_write` get `ImportFailedError` instead of silently receiving the soft-deleted row.
|
||||
**Importer behavior:** importing a dashboard YAML whose UUID matches an existing **soft-deleted** dashboard is treated as an implicit restore-with-update — **and this happens even when `overwrite` is not set**. This is a deliberate asymmetry with active rows: an active dashboard imported without `overwrite=true` is returned unchanged (the import never mutates it), but a soft-deleted UUID match is restored _and_ has the upload's contents applied regardless of the `overwrite` argument, on the reasoning that re-importing a deleted dashboard's exact UUID is an explicit request to bring it back. The restore preserves the original PK and all pre-deletion relationship rows (`dashboard_slices` junctions, editor/viewer subjects, tags). Callers whose imports must never mutate existing state should treat bundles that may contain previously deleted UUIDs accordingly. The operation is permission-gated: it requires `can_write` and editorship of the deleted row (or admin) — non-editors get `ImportFailedError`, and callers without `can_write` get `ImportFailedError` instead of silently receiving the soft-deleted row.
|
||||
|
||||
### Engine spec capability flag: `supports_offset`
|
||||
|
||||
@@ -693,10 +860,10 @@ A new `BaseEngineSpec.supports_offset` attribute (default `True`) indicates whet
|
||||
|
||||
A new feature flag `GRANULAR_EXPORT_CONTROLS` introduces three fine-grained permissions that replace the legacy `can_csv` permission:
|
||||
|
||||
| Permission | Controls |
|
||||
|---|---|
|
||||
| `can_export_data` | CSV, Excel, JSON exports |
|
||||
| `can_export_image` | Screenshot/PDF exports |
|
||||
| Permission | Controls |
|
||||
| -------------------- | ---------------------------- |
|
||||
| `can_export_data` | CSV, Excel, JSON exports |
|
||||
| `can_export_image` | Screenshot/PDF exports |
|
||||
| `can_copy_clipboard` | Copy-to-clipboard operations |
|
||||
|
||||
When the feature flag is enabled, these permissions are enforced on both the frontend (disabled buttons with tooltips) and backend (403 responses from API endpoints). When disabled, legacy `can_csv` behavior is preserved.
|
||||
@@ -740,14 +907,17 @@ The Kenya country map has been updated to reflect the 47 counties established un
|
||||
MCP (Model Context Protocol) tools now include enhanced observability instrumentation for monitoring and debugging:
|
||||
|
||||
**Two-layer instrumentation:**
|
||||
|
||||
1. **Middleware layer** (`LoggingMiddleware`): Automatically logs all MCP tool calls with `duration_ms` and `success` status in the audit log (Action Log UI, logs table)
|
||||
2. **Sub-operation tracking**: All 19 MCP tools include granular `event_logger.log_context()` blocks for tracking individual operations like validation, database writes, and query execution
|
||||
|
||||
**Action naming convention:**
|
||||
|
||||
- Tool-level logs: `mcp_tool_call` (via middleware)
|
||||
- Sub-operation logs: `mcp.{tool_name}.{operation}` (e.g., `mcp.generate_chart.validation`, `mcp.execute_sql.query_execution`)
|
||||
|
||||
**Querying MCP logs:**
|
||||
|
||||
```sql
|
||||
-- Top slowest MCP operations
|
||||
SELECT action, COUNT(*) as calls, AVG(duration_ms) as avg_ms
|
||||
@@ -782,6 +952,7 @@ A new `DISTRIBUTED_COORDINATION_CONFIG` configuration provides a unified Redis-b
|
||||
The distributed coordination is used by the Global Task Framework (GTF) for abort notifications and task completion signaling, and will eventually replace `GLOBAL_ASYNC_QUERIES_CACHE_BACKEND` as the standard signaling backend. Configuring this is recommended for Redis enabled production deployments.
|
||||
|
||||
Example configuration in `superset_config.py`:
|
||||
|
||||
```python
|
||||
DISTRIBUTED_COORDINATION_CONFIG = {
|
||||
"CACHE_TYPE": "RedisCache",
|
||||
@@ -796,9 +967,11 @@ See `superset/config.py` for complete configuration options.
|
||||
### WebSocket config for GAQ with Docker
|
||||
|
||||
[35896](https://github.com/apache/superset/pull/35896) and [37624](https://github.com/apache/superset/pull/37624) updated documentation on how to run and configure Superset with Docker. Specifically for the WebSocket configuration, a new `docker/superset-websocket/config.example.json` was added to the repo, so that users could copy it to create a `docker/superset-websocket/config.json` file. The existing `docker/superset-websocket/config.json` was removed and git-ignored, so if you're using GAQ / WebSocket make sure to:
|
||||
|
||||
- Stash/backup your existing `config.json` file, to re-apply it after (will get git-ignored going forward)
|
||||
- Update the `volumes` configuration for the `superset-websocket` service in your `docker-compose.override.yml` file, to include the `docker/superset-websocket/config.json` file. For example:
|
||||
``` yaml
|
||||
|
||||
```yaml
|
||||
services:
|
||||
superset-websocket:
|
||||
volumes:
|
||||
@@ -811,7 +984,9 @@ services:
|
||||
### Example Data Loading Improvements
|
||||
|
||||
#### New Directory Structure
|
||||
|
||||
Examples are now organized by name with data and configs co-located:
|
||||
|
||||
```
|
||||
superset/examples/
|
||||
├── _shared/ # Shared database & metadata configs
|
||||
@@ -824,12 +999,14 @@ superset/examples/
|
||||
```
|
||||
|
||||
#### Simplified Parquet-based Loading
|
||||
|
||||
- Auto-discovery: create `superset/examples/my_dataset/data.parquet` to add a new example
|
||||
- Parquet is an Apache project format: compressed (~27% smaller), self-describing schema
|
||||
- YAML configs define datasets, charts, and dashboards declaratively
|
||||
- Removed Python-based data generation from individual example files
|
||||
|
||||
#### Test Data Reorganization
|
||||
|
||||
- Moved `big_data.py` to `superset/cli/test_loaders.py` - better reflects its purpose as a test utility
|
||||
- Fixed inverted logic for `--load-test-data` flag (now correctly includes .test.yaml files when flag is set)
|
||||
- Clarified CLI flags:
|
||||
@@ -839,6 +1016,7 @@ superset/examples/
|
||||
- `--load-big-data` / `-b`: Generate synthetic stress-test data
|
||||
|
||||
#### Bug Fixes
|
||||
|
||||
- Fixed numpy array serialization for PostgreSQL (converts complex types to JSON strings)
|
||||
- Fixed KeyError for `allow_csv_upload` field in database configs (now optional with default)
|
||||
- Fixed test data loading logic that was incorrectly filtering files
|
||||
@@ -848,6 +1026,7 @@ superset/examples/
|
||||
The MCP (Model Context Protocol) service enables AI assistants and automation tools to interact programmatically with Superset.
|
||||
|
||||
#### New Features
|
||||
|
||||
- MCP service infrastructure with FastMCP framework
|
||||
- Tools for dashboards, charts, datasets, SQL Lab, and instance metadata
|
||||
- Optional dependency: install with `pip install apache-superset[fastmcp]`
|
||||
@@ -857,6 +1036,7 @@ The MCP (Model Context Protocol) service enables AI assistants and automation to
|
||||
#### New Configuration Options
|
||||
|
||||
**Development** (single-user, local testing):
|
||||
|
||||
```python
|
||||
# superset_config.py
|
||||
MCP_DEV_USERNAME = "admin" # User for MCP authentication
|
||||
@@ -865,6 +1045,7 @@ MCP_SERVICE_PORT = 5008
|
||||
```
|
||||
|
||||
**Production** (JWT-based, multi-user):
|
||||
|
||||
```python
|
||||
# superset_config.py
|
||||
MCP_AUTH_ENABLED = True
|
||||
@@ -910,12 +1091,14 @@ superset mcp run --port 5008 --use-factory-config
|
||||
The MCP service runs as a **separate process** from the Superset web server.
|
||||
|
||||
**Important**:
|
||||
|
||||
- Requires same Python environment and configuration as Superset
|
||||
- Shares database connections with main Superset app
|
||||
- Can be scaled independently from web server
|
||||
- Requires `fastmcp` package (optional dependency)
|
||||
|
||||
**Installation**:
|
||||
|
||||
```bash
|
||||
# Install with MCP support
|
||||
pip install apache-superset[fastmcp]
|
||||
@@ -929,6 +1112,7 @@ Use systemd, supervisord, or Kubernetes to manage the MCP service process.
|
||||
See `superset/mcp_service/PRODUCTION.md` for deployment guides.
|
||||
|
||||
**Security**:
|
||||
|
||||
- Development: Uses `MCP_DEV_USERNAME` for single-user access
|
||||
- Production: **MUST** configure JWT authentication
|
||||
- See `superset/mcp_service/SECURITY.md` for details
|
||||
@@ -948,8 +1132,10 @@ See `superset/mcp_service/PRODUCTION.md` for deployment guides.
|
||||
- [35062](https://github.com/apache/superset/pull/35062): Changed the function signature of `setupExtensions` to `setupCodeOverrides` with options as arguments.
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- [37370](https://github.com/apache/superset/pull/37370): The `APP_NAME` configuration variable no longer controls the browser window/tab title or other frontend branding. Application names should now be configured using the theme system with the `brandAppName` token. The `APP_NAME` config is still used for backend contexts (MCP service, logs, etc.) and serves as a fallback if `brandAppName` is not set.
|
||||
- **Migration:**
|
||||
|
||||
```python
|
||||
# Before (Superset 5.x)
|
||||
APP_NAME = "My Custom App"
|
||||
@@ -993,16 +1179,16 @@ See `superset/mcp_service/PRODUCTION.md` for deployment guides.
|
||||
|
||||
Eight M:N association tables move from a synthetic `id INTEGER PRIMARY KEY` to a composite `PRIMARY KEY (fk1, fk2)` on their two foreign-key columns. The surrogate `id` is dropped, and the redundant `UNIQUE (fk1, fk2)` on the two tables that carried one is removed (now subsumed by the PK).
|
||||
|
||||
| Table | Composite PK |
|
||||
|---|---|
|
||||
| `dashboard_roles` | `(dashboard_id, role_id)` |
|
||||
| `dashboard_slices` | `(dashboard_id, slice_id)` |
|
||||
| `dashboard_user` | `(user_id, dashboard_id)` |
|
||||
| Table | Composite PK |
|
||||
| ---------------------- | ------------------------------- |
|
||||
| `dashboard_roles` | `(dashboard_id, role_id)` |
|
||||
| `dashboard_slices` | `(dashboard_id, slice_id)` |
|
||||
| `dashboard_user` | `(user_id, dashboard_id)` |
|
||||
| `report_schedule_user` | `(user_id, report_schedule_id)` |
|
||||
| `rls_filter_roles` | `(role_id, rls_filter_id)` |
|
||||
| `rls_filter_tables` | `(table_id, rls_filter_id)` |
|
||||
| `slice_user` | `(user_id, slice_id)` |
|
||||
| `sqlatable_user` | `(user_id, table_id)` |
|
||||
| `rls_filter_roles` | `(role_id, rls_filter_id)` |
|
||||
| `rls_filter_tables` | `(table_id, rls_filter_id)` |
|
||||
| `slice_user` | `(user_id, slice_id)` |
|
||||
| `sqlatable_user` | `(user_id, table_id)` |
|
||||
|
||||
**Before upgrading:**
|
||||
|
||||
@@ -1013,6 +1199,7 @@ Eight M:N association tables move from a synthetic `id INTEGER PRIMARY KEY` to a
|
||||
For large `dashboard_slices` / `report_schedule_user` tables, see the operator runbook in [#39859](https://github.com/apache/superset/pull/39859) — pre-flight inventory queries, per-dialect lock-window sizing, and the duplicate / NULL-FK roll-up — to plan the maintenance window.
|
||||
|
||||
## 6.0.0
|
||||
|
||||
- [33055](https://github.com/apache/superset/pull/33055): Upgrades Flask-AppBuilder to 5.0.0. The AUTH_OID authentication type has been deprecated and is no longer available as an option in Flask-AppBuilder. OpenID (OID) is considered a deprecated authentication protocol - if you are using AUTH_OID, you will need to migrate to an alternative authentication method such as OAuth, LDAP, or database authentication before upgrading.
|
||||
- [34871](https://github.com/apache/superset/pull/34871): Fixed Jest test hanging issue from Ant Design v5 upgrade. MessageChannel is now mocked in test environment to prevent rc-overflow from causing Jest to hang. Test environment only - no production impact.
|
||||
- [34782](https://github.com/apache/superset/pull/34782): Dataset exports now include the dataset ID in their file name (similar to charts and dashboards). If managing assets as code, make sure to rename existing dataset YAMLs to include the ID (and avoid duplicated files).
|
||||
@@ -1021,8 +1208,8 @@ For large `dashboard_slices` / `report_schedule_user` tables, see the operator r
|
||||
- Change any hex color values to one of: `"success"`, `"processing"`, `"error"`, `"warning"`, `"default"`
|
||||
- Custom colors are no longer supported to maintain consistency with Ant Design components
|
||||
- [34561](https://github.com/apache/superset/pull/34561) Added tiled screenshot functionality for Playwright-based reports to handle large dashboards more efficiently. When enabled (default: `SCREENSHOT_TILED_ENABLED = True`), dashboards with 20+ charts or height exceeding 5000px will be captured using multiple viewport-sized tiles and combined into a single image. This improves report generation performance and reliability for large dashboards.
|
||||
Note: Pillow is now a required dependency (previously optional) to support image processing for tiled screenshots.
|
||||
`thumbnails` optional dependency is now deprecated and will be removed in the next major release (7.0).
|
||||
Note: Pillow is now a required dependency (previously optional) to support image processing for tiled screenshots.
|
||||
`thumbnails` optional dependency is now deprecated and will be removed in the next major release (7.0).
|
||||
- [33084](https://github.com/apache/superset/pull/33084) The DISALLOWED_SQL_FUNCTIONS configuration now includes additional potentially sensitive database functions across PostgreSQL, MySQL, SQLite, MS SQL Server, and ClickHouse. Existing queries using these functions may now be blocked. Review your SQL Lab queries and dashboards if you encounter "disallowed function" errors after upgrading
|
||||
- [34235](https://github.com/apache/superset/pull/34235) CSV exports now use `utf-8-sig` encoding by default to include a UTF-8 BOM, improving compatibility with Excel.
|
||||
- [34258](https://github.com/apache/superset/pull/34258) changing the default in Dockerfile to INCLUDE_CHROMIUM="false" (from "true") in the past. This ensures the `lean` layer is lean by default, and people can opt-in to the `chromium` layer by setting the build arg `INCLUDE_CHROMIUM=true`. This is a breaking change for anyone using the `lean` layer, as it will no longer include Chromium by default.
|
||||
|
||||
@@ -122,7 +122,13 @@ FEATURE_FLAGS = {
|
||||
}
|
||||
EXTENSIONS_PATH = "/app/docker/extensions"
|
||||
ALERT_REPORTS_NOTIFICATION_DRY_RUN = True
|
||||
WEBDRIVER_BASEURL = f"http://superset_app{os.environ.get('SUPERSET_APP_ROOT', '/')}/" # When using docker compose baseurl should be http://superset_nginx{ENV{BASEPATH}}/ # noqa: E501
|
||||
# The Docker Compose app service is named "superset" and listens on 8088. Report
|
||||
# paths are root-relative, so urljoin drops the base path; only the scheme, host,
|
||||
# and port must be correct here. SUPERSET_APP_ROOT is kept for consumers that
|
||||
# concatenate paths directly (e.g. cache warm-up). For screenshots in the dev
|
||||
# stack (unbuilt static assets) point this at the nginx service instead:
|
||||
# http://nginx{SUPERSET_APP_ROOT}/
|
||||
WEBDRIVER_BASEURL = f"http://superset:8088{os.environ.get('SUPERSET_APP_ROOT', '/')}/"
|
||||
# The base URL for the email report hyperlinks.
|
||||
WEBDRIVER_BASEURL_USER_FRIENDLY = (
|
||||
f"http://localhost:8888/{os.environ.get('SUPERSET_APP_ROOT', '/')}/"
|
||||
|
||||
@@ -28,6 +28,7 @@ Alerts and reports are disabled by default. To turn them on, you'll need to chan
|
||||
- Users can customize the email subject by including date code placeholders, which will automatically be replaced with the corresponding UTC date when the email is sent. To enable this functionality, activate the `"DATE_FORMAT_IN_EMAIL_SUBJECT"` [feature flag](/admin-docs/configuration/configuring-superset#feature-flags). This enables date formatting in email subjects, preventing all reporting emails from being grouped into the same thread (optional for the reporting feature).
|
||||
- Use date codes from [strftime.org](https://strftime.org/) to create the email subject.
|
||||
- If no date code is provided, the original string will be used as the email subject.
|
||||
- Each alert/report has an "Include a link back to Superset" option (enabled by default) controlling whether the call-to-action link is included in email and Slack notifications. The link text in emails is configurable via `EMAIL_REPORTS_CTA`; the Slack message always uses "Explore in Superset". Uncheck the option when recipients should not receive a link to your Superset host, e.g. for external audiences.
|
||||
|
||||
##### Disable dry-run mode
|
||||
|
||||
@@ -132,7 +133,7 @@ Superset sends an HTTP POST with `Content-Type: application/json`:
|
||||
},
|
||||
"text": "Alert condition met: value exceeded threshold",
|
||||
"description": "Monthly revenue dropped below target",
|
||||
"url": "https://your-superset-host/superset/dashboard/1/"
|
||||
"url": "https://your-superset-host/dashboard/1/"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -174,6 +175,16 @@ ALERT_REPORTS_WEBHOOK_HTTPS_ONLY = True
|
||||
|
||||
When enabled, Superset rejects webhook configurations that use `http://` URLs.
|
||||
|
||||
#### Request Timeout
|
||||
|
||||
Webhook deliveries use a socket timeout so a request can't hang forever if the webhook target is unreachable, which would otherwise leave the report schedule stuck in a `WORKING` state. Configure it with:
|
||||
|
||||
```python
|
||||
ALERT_REPORTS_WEBHOOK_TIMEOUT = 60 # seconds
|
||||
```
|
||||
|
||||
Set to `None` to disable the timeout (not recommended).
|
||||
|
||||
#### Retry Behavior
|
||||
|
||||
Superset automatically retries webhook deliveries on `429 Too Many Requests` and `5xx` server errors using exponential backoff. Retries are bounded to roughly 120 seconds of cumulative wall-clock time (worst case ~210 seconds, because the bound is checked against the time elapsed before each attempt, so the final request can begin just under the limit and still run its full request timeout), after which the delivery is abandoned.
|
||||
@@ -245,6 +256,53 @@ class CeleryConfig:
|
||||
}
|
||||
CELERY_CONFIG = CeleryConfig
|
||||
|
||||
# Scheduled reports share one deadline across browser readiness, capture/PDF
|
||||
# generation, delivery, and terminal-state persistence. The effective budget
|
||||
# for a schedule is min(this value, the schedule's working_timeout), so the
|
||||
# per-schedule field keeps its meaning as a user-facing cap. The default (one
|
||||
# hour) matches the historical working_timeout default, so upgrading changes
|
||||
# no default behavior; lower it to enforce a tighter report SLA.
|
||||
ALERT_REPORTS_EXECUTION_BUDGET_SECONDS = 3600
|
||||
|
||||
# These reserves are part of (not additions to) the total budget and their sum
|
||||
# must be less than it. Readiness polling stops in time to leave capacity for
|
||||
# the later phases.
|
||||
ALERT_REPORTS_EXECUTION_CAPTURE_RESERVE_SECONDS = 60
|
||||
ALERT_REPORTS_EXECUTION_DELIVERY_RESERVE_SECONDS = 120
|
||||
ALERT_REPORTS_EXECUTION_CLEANUP_RESERVE_SECONDS = 30
|
||||
|
||||
# Celery's hard limit leaves this additional window for terminal cleanup after
|
||||
# the soft limit, which equals the resolved execution budget (the configured
|
||||
# budget capped by each schedule's working_timeout).
|
||||
# ALERT_REPORTS_WORKING_TIME_OUT_KILL controls these Celery limits; disabling
|
||||
# it does not disable the application deadline above.
|
||||
ALERT_REPORTS_EXECUTION_HARD_TIMEOUT_GRACE_SECONDS = 30
|
||||
|
||||
# Invalid budget/reserve combinations fail application startup instead of
|
||||
# allowing every scheduled report to fail later. A report Celery soft timeout
|
||||
# records ERROR and increments `reports.execute.celery_soft_timeout`; it does
|
||||
# not attempt an in-band customer error notification during the hard-limit
|
||||
# grace window. Alert schedules retain their existing timeout notifications.
|
||||
#
|
||||
# The application deadline is cooperative between synchronous phases. The
|
||||
# Celery limits provide the final preemption boundary when the worker pool
|
||||
# supports them; PDF construction is checked immediately before and after the
|
||||
# synchronous builder but cannot be interrupted inside that call.
|
||||
#
|
||||
# Sizing the budget against infrastructure limits:
|
||||
# - Kubernetes (or similar) pod termination grace must exceed
|
||||
# budget + hard-timeout grace, or in-flight reports are killed mid-run on
|
||||
# every deploy/node drain despite the application deadline.
|
||||
# - The web server's per-request timeout (e.g. gunicorn ``timeout``) bounds
|
||||
# each individual chart data request made by the headless browser -- not
|
||||
# the report as a whole. Readiness allowance beyond that per-request
|
||||
# ceiling buys nothing for a single slow chart (its request dies at the
|
||||
# web layer and the chart reaches an error state), but multi-chart and
|
||||
# tiled captures legitimately accumulate total time well past it.
|
||||
|
||||
# Screenshot-specific waits continue to apply to thumbnails and other
|
||||
# standalone screenshot calls. Scheduled reports derive their waits from the
|
||||
# shared execution deadline above.
|
||||
SCREENSHOT_LOCATE_WAIT = 100
|
||||
SCREENSHOT_LOAD_WAIT = 600
|
||||
|
||||
@@ -278,7 +336,7 @@ WEBDRIVER_OPTION_ARGS = [
|
||||
]
|
||||
|
||||
# This is for internal use, you can keep http
|
||||
WEBDRIVER_BASEURL = "http://superset:8088" # When running using docker compose use "http://superset_app:8088'
|
||||
WEBDRIVER_BASEURL = "http://superset:8088" # This is also the default for Docker Compose, where the app service is named "superset"
|
||||
# This is the link sent to the recipient. Change to your domain, e.g. https://superset.mydomain.com
|
||||
WEBDRIVER_BASEURL_USER_FRIENDLY = "http://localhost:8088"
|
||||
```
|
||||
@@ -403,9 +461,9 @@ Possible fixes:
|
||||
|
||||
The worker may be unable to reach the report. It will use the value of `WEBDRIVER_BASEURL` to browse to the report. If that route is invalid, or presents an authentication challenge that the worker can't pass, the report screenshot will fail.
|
||||
|
||||
Check this by attempting to `curl` the URL of a report that you see in the error logs of your worker. For instance, from the worker environment, run `curl http://superset_app:8088/superset/dashboard/1/`. You may get different responses depending on whether the dashboard exists - for example, you may need to change the `1` in that URL. If there's a URL in your logs from a failed report screenshot, that's a good place to start. The goal is to determine a valid value for `WEBDRIVER_BASEURL` and determine if an issue like HTTPS or authentication is redirecting your worker.
|
||||
Check this by attempting to `curl` the URL of a report that you see in the error logs of your worker. For instance, from the worker environment, run `curl http://superset:8088/dashboard/1/`. You may get different responses depending on whether the dashboard exists - for example, you may need to change the `1` in that URL. If there's a URL in your logs from a failed report screenshot, that's a good place to start. The goal is to determine a valid value for `WEBDRIVER_BASEURL` and determine if an issue like HTTPS or authentication is redirecting your worker.
|
||||
|
||||
In a deployment with authentication measures enabled like HTTPS and Single Sign-On, it may make sense to have the worker navigate directly to the Superset application running in the same location, avoiding the need to sign in. For instance, you could use `WEBDRIVER_BASEURL="http://superset_app:8088"` for a docker compose deployment, and set `"force_https": False,` in your `TALISMAN_CONFIG`.
|
||||
In a deployment with authentication measures enabled like HTTPS and Single Sign-On, it may make sense to have the worker navigate directly to the Superset application running in the same location, avoiding the need to sign in. For instance, you could use `WEBDRIVER_BASEURL="http://superset:8088"` for a docker compose deployment, and set `"force_https": False,` in your `TALISMAN_CONFIG` — appropriate only for an isolated development stack or when HTTPS is enforced by a trusted proxy in front of Superset.
|
||||
|
||||
### Duplicate report deliveries
|
||||
|
||||
|
||||
@@ -54,8 +54,8 @@ celery --app=superset.tasks.celery_app:app beat
|
||||
|
||||
To setup a result backend, you need to pass an instance of a derivative of `BaseCache` (`from
|
||||
flask_caching.backends.base import BaseCache`) to the RESULTS_BACKEND configuration key in your
|
||||
superset_config.py. You can use Memcached, Redis, S3 (https://pypi.python.org/pypi/s3werkzeugcache),
|
||||
memory or the file system (in a single server-type setup or for testing), or to write your own
|
||||
superset_config.py. You can use Memcached, Redis, S3, MinIO, memory
|
||||
or the file system (in a single server-type setup or for testing), or to write your own
|
||||
caching interface. Your `superset_config.py` may look something like:
|
||||
|
||||
```python
|
||||
@@ -89,6 +89,12 @@ issues arise. Please clear your existing results cache store when upgrading an e
|
||||
- SQL Lab will _only run your queries asynchronously if_ you enable **Asynchronous Query Execution**
|
||||
in your database settings (Sources > Databases > Edit record).
|
||||
|
||||
- In order to use dedicated results backend, additional python libraries must be installed. These libraries can be installed using pip.
|
||||
- [redis-py](https://pypi.org/project/redis/) for Redis.
|
||||
- [pylibmc](https://pypi.org/project/pylibmc/) for memcached
|
||||
- [s3werkzeugcache](https://pypi.python.org/pypi/s3werkzeugcache) for S3
|
||||
- [minio-flask-cache](https://github.com/greggailly/minio-flask-cache) for MinIO or other S3 compatible service
|
||||
|
||||
## Celery Flower
|
||||
|
||||
Flower is a web based tool for monitoring the Celery cluster which you can install from pip:
|
||||
|
||||
@@ -15,9 +15,8 @@ fail-fast behavior ensures operators are immediately aware of infrastructure iss
|
||||
|
||||
Superset uses [Flask-Caching](https://flask-caching.readthedocs.io/) for caching purposes.
|
||||
Flask-Caching supports various caching backends, including Redis (recommended), Memcached,
|
||||
SimpleCache (in-memory), or the local filesystem.
|
||||
[Custom cache backends](https://flask-caching.readthedocs.io/en/latest/#custom-cache-backends)
|
||||
are also supported.
|
||||
SimpleCache (in-memory), MinIO/S3, or the local filesystem.
|
||||
[Custom cache backends](https://flask-caching.readthedocs.io/en/latest/#custom-cache-backends) are also supported.
|
||||
|
||||
Caching can be configured by providing dictionaries in
|
||||
`superset_config.py` that comply with [the Flask-Caching config specifications](https://flask-caching.readthedocs.io/en/latest/#configuring-flask-caching).
|
||||
@@ -47,6 +46,7 @@ In order to use dedicated cache stores, additional python libraries must be inst
|
||||
- For Redis: we recommend the [redis](https://pypi.python.org/pypi/redis) Python package
|
||||
- Memcached: we recommend using [pylibmc](https://pypi.org/project/pylibmc/) client library as
|
||||
`python-memcached` does not handle storing binary data correctly.
|
||||
- MinIO (S3): we recommend using the [minio-flask-cache](https://github.com/greggailly/minio-flask-cache) package
|
||||
|
||||
These libraries can be installed using pip.
|
||||
|
||||
@@ -134,6 +134,50 @@ CELERY_CONFIG = CustomCeleryConfig
|
||||
This will cache the top 5 most popular dashboards every hour. For other
|
||||
strategies, check the `superset/tasks/cache.py` file.
|
||||
|
||||
### Warming Up Native Filter Options
|
||||
|
||||
Native filter Value-type dropdown option queries (e.g. `SELECT DISTINCT column FROM table`) are
|
||||
cached the same way as chart data, via `DATA_CACHE_CONFIG`. However, the strategies above only warm
|
||||
up chart render queries, so the first user to open a dashboard's filter dropdown after a cache entry
|
||||
expires still triggers a fresh database query.
|
||||
|
||||
The `native_filter_options` strategy pre-populates the cache for these dropdown queries. It reads
|
||||
each dashboard's `native_filter_configuration`, builds the same `filter_select` chart-data query the
|
||||
frontend would send, and executes it as the configured `SUPERSET_CACHE_WARMUP_USER`:
|
||||
|
||||
```python
|
||||
class CustomCeleryConfig(CeleryConfig):
|
||||
beat_schedule = {
|
||||
**CeleryConfig.beat_schedule,
|
||||
'cache-warmup-native-filters': {
|
||||
'task': 'cache-warmup',
|
||||
'schedule': crontab(minute=0, hour=3), # daily at 03:00
|
||||
'kwargs': {
|
||||
'strategy_name': 'native_filter_options',
|
||||
'dashboard_ids': [1, 2, 3],
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Requirements and limitations:
|
||||
|
||||
- `SUPERSET_CACHE_WARMUP_USER` must be set to a user with access to the dashboards and datasets
|
||||
referenced by the native filters.
|
||||
- `DATA_CACHE_CONFIG` must use a backend that actually persists entries (Redis recommended); the
|
||||
default `NullCache` discards writes, so warming has nothing to warm. The effective timeout also
|
||||
needs to be positive — `NATIVE_FILTER_OPTIONS_CACHE_TIMEOUT = -1` disables cache writes for these
|
||||
queries entirely, even with a working backend.
|
||||
- Schedule the warm-up at least as often as the effective native filter cache timeout (whichever of
|
||||
`NATIVE_FILTER_OPTIONS_CACHE_TIMEOUT`, the chart/dataset/database timeout, or `DATA_CACHE_CONFIG`'s
|
||||
default applies). A looser schedule still leaves a window of cold, unwarmed queries between expiry
|
||||
and the next run — the daily example above assumes a TTL of a day or more.
|
||||
- Cache entries are warmed under the warm-up user's own cache partition, the same entry that user
|
||||
would create by opening the filter dropdown manually. Users with a different role set or row-level
|
||||
security context may still see a cache miss on first load.
|
||||
- Cascading/dependent native filters and search-term variants of filter option queries are not
|
||||
warmed by this strategy.
|
||||
|
||||
## Caching Thumbnails
|
||||
|
||||
This is an optional feature that can be turned on by activating its [feature flag](/admin-docs/configuration/configuring-superset#feature-flags) on config:
|
||||
@@ -301,6 +345,24 @@ DISTRIBUTED_COORDINATION_CONFIG = {
|
||||
}
|
||||
```
|
||||
|
||||
By default, connections opened for `DISTRIBUTED_COORDINATION_CONFIG` (as well as
|
||||
`GLOBAL_ASYNC_QUERIES_CACHE_BACKEND`, which uses the same `RedisCache`/`RedisSentinelCache`
|
||||
backend) have no socket timeout. This can be overridden with `CACHE_REDIS_SOCKET_TIMEOUT` and
|
||||
`CACHE_REDIS_SOCKET_CONNECT_TIMEOUT`, both in seconds:
|
||||
|
||||
```python
|
||||
DISTRIBUTED_COORDINATION_CONFIG = {
|
||||
"CACHE_TYPE": "RedisCache",
|
||||
"CACHE_REDIS_HOST": "localhost",
|
||||
"CACHE_REDIS_PORT": 6379,
|
||||
"CACHE_REDIS_SOCKET_TIMEOUT": 5, # seconds
|
||||
"CACHE_REDIS_SOCKET_CONNECT_TIMEOUT": 5, # seconds
|
||||
}
|
||||
```
|
||||
|
||||
These apply to `RedisSentinelCache` connections as well, covering both the sentinel-node
|
||||
connections and the resolved master connection.
|
||||
|
||||
### Distributed Lock TTL
|
||||
|
||||
You can configure the default lock TTL (time-to-live) in seconds. Locks automatically expire after
|
||||
|
||||
@@ -307,6 +307,22 @@ to simplify the process of setting up a non-default root path across the service
|
||||
In `docker/.env-local` set `SUPERSET_APP_ROOT` to the desired prefix and then bring the
|
||||
services up with `docker compose up --detach`.
|
||||
|
||||
### Swagger UI
|
||||
|
||||
By default, Superset's Swagger UI and OpenAPI spec (enabled via `FAB_API_SWAGGER_UI`) are
|
||||
served by Flask-AppBuilder and don't account for a non-root `APPLICATION_ROOT` prefix. If
|
||||
you're running Superset behind a URL prefix and want the Swagger UI and OpenAPI spec to
|
||||
resolve correctly, set:
|
||||
|
||||
```python
|
||||
FAB_API_SWAGGER_UI_SUPERSET_APP_ROOT = True
|
||||
```
|
||||
|
||||
in your `superset_config.py` file. This serves an `APPLICATION_ROOT`-aware Swagger UI and
|
||||
OpenAPI spec at `/swagger/<version>` and `/api/<version>/_openapi` respectively, resolved
|
||||
through the configured prefix. This flag only takes effect when `FAB_API_SWAGGER_UI` is
|
||||
also enabled, and defaults to `False`.
|
||||
|
||||
## Custom OAuth2 Configuration
|
||||
|
||||
Superset is built on Flask-AppBuilder (FAB), which supports many providers out of the box
|
||||
|
||||
@@ -18,7 +18,7 @@ code is less ambiguous and is unique to all regions in the world.
|
||||
## Included Maps
|
||||
|
||||
The current list of countries can be found in the src
|
||||
[legacy-plugin-chart-country-map/src/countries.ts](https://github.com/apache/superset/blob/master/superset-frontend/plugins/legacy-plugin-chart-country-map/src/countries.ts)
|
||||
[plugin-chart-country-map/src/countries.ts](https://github.com/apache/superset/blob/master/superset-frontend/plugins/plugin-chart-country-map/src/countries.ts)
|
||||
|
||||
The Country Maps visualization already ships with the maps for the following countries:
|
||||
|
||||
@@ -31,10 +31,10 @@ The Country Maps visualization already ships with the maps for the following cou
|
||||
## Adding a New Country
|
||||
|
||||
To add a new country to the list, you'd have to edit files in
|
||||
[@superset-ui/legacy-plugin-chart-country-map](https://github.com/apache/superset/tree/master/superset-frontend/plugins/legacy-plugin-chart-country-map).
|
||||
[@superset-ui/plugin-chart-country-map](https://github.com/apache/superset/tree/master/superset-frontend/plugins/plugin-chart-country-map).
|
||||
|
||||
1. Generate a new GeoJSON file for your country following the guide in [this Jupyter notebook](https://github.com/apache/superset/blob/master/superset-frontend/plugins/legacy-plugin-chart-country-map/scripts/Country%20Map%20GeoJSON%20Generator.ipynb).
|
||||
2. Edit the countries list in [legacy-plugin-chart-country-map/src/countries.ts](https://github.com/apache/superset/blob/master/superset-frontend/plugins/legacy-plugin-chart-country-map/src/countries.ts).
|
||||
1. Generate a new GeoJSON file for your country following the guide in [this Jupyter notebook](https://github.com/apache/superset/blob/master/superset-frontend/plugins/plugin-chart-country-map/scripts/Country%20Map%20GeoJSON%20Generator.ipynb).
|
||||
2. Edit the countries list in [plugin-chart-country-map/src/countries.ts](https://github.com/apache/superset/blob/master/superset-frontend/plugins/plugin-chart-country-map/src/countries.ts).
|
||||
3. Install superset-frontend dependencies: `cd superset-frontend && npm install`
|
||||
4. Verify your countries in Superset plugins storybook: `npm run plugins:storybook`.
|
||||
5. Build and install Superset from source code.
|
||||
|
||||
@@ -315,6 +315,76 @@ Here's a concrete example:
|
||||
WHERE country_code = 'US'
|
||||
```
|
||||
|
||||
**Guest User Attributes**
|
||||
|
||||
The `{{ get_guest_user_attribute('attribute_name') }}` macro returns a specific attribute value from the guest user context.
|
||||
This is useful when working with embedded Superset where guest tokens can contain custom attributes that need to be
|
||||
accessed in SQL queries.
|
||||
|
||||
This macro only works when the current user is a guest user (authenticated via guest token). If the current user is
|
||||
not a guest user, or if the specified attribute doesn't exist, the macro will return `None` or the provided default value.
|
||||
|
||||
If you have caching enabled in your Superset configuration, then by default the resolved value (whether it
|
||||
came from the guest token, a null attribute, or the provided default) will be used by Superset when
|
||||
calculating the cache key. A cache key is a unique identifier that determines if there's a cache hit in the
|
||||
future and Superset can retrieve cached data. Including the resolved value on every branch ensures two guests
|
||||
whose tokens render different SQL never share a cache entry.
|
||||
|
||||
You can disable the inclusion of the attribute value in the calculation of the
|
||||
cache key by adding the following parameter to your Jinja code, but only do so
|
||||
when the value cannot affect the query results:
|
||||
|
||||
```
|
||||
{{ get_guest_user_attribute('department', add_to_cache_keys=False) }}
|
||||
```
|
||||
|
||||
You can also provide a default value if the attribute is not found:
|
||||
|
||||
```
|
||||
{{ get_guest_user_attribute('region', default='US') }}
|
||||
```
|
||||
|
||||
Here's a concrete example of using guest user attributes in a query:
|
||||
|
||||
```sql
|
||||
SELECT *
|
||||
FROM sales_data
|
||||
WHERE region = '{{ get_guest_user_attribute("user_region", default="global") }}'
|
||||
AND department = '{{ get_guest_user_attribute("department") }}'
|
||||
```
|
||||
|
||||
:::warning[Security Warning]
|
||||
|
||||
Guest token attributes come from the embedding application. By default,
|
||||
`get_guest_user_attribute()` escapes string values — including strings nested inside
|
||||
arrays and object values, and caller-supplied defaults — through the database dialect's
|
||||
literal rendering (the same mechanism as `url_param()`). This covers dialect-specific
|
||||
escape characters such as the backslash on MySQL/MariaDB, so the example above is safe
|
||||
to interpolate directly. If you pass `escape_result=False`, or interpolate non-string
|
||||
values (numbers, booleans), you are responsible for validating or allowlisting the
|
||||
values, since they originate outside Superset.
|
||||
|
||||
If a guest attribute is an array and you plan to pipe it through the `|where_in` filter
|
||||
(for example `full_name IN {{ get_guest_user_attribute('names')|where_in }}`), call
|
||||
`get_guest_user_attribute('names', escape_result=False)`. `where_in` already applies its
|
||||
own dialect-safe quoting, so escaping the values twice can corrupt them (a value such as
|
||||
`O'Brien` would come back doubly escaped and match nothing).
|
||||
|
||||
Only individual string values are escaped as SQL literals. Interpolating an entire array
|
||||
or object directly (rather than through `|where_in`, or by accessing a specific element)
|
||||
renders Python's string form of that structure, which is not valid SQL, and object keys
|
||||
are not escaped at all. Use `|where_in` for arrays, `|tojson` where you need a
|
||||
JSON-stringified value, or read individual keys/elements out of the structure yourself.
|
||||
|
||||
The same double-escaping problem described above for `|where_in` applies to `|tojson`:
|
||||
pass `escape_result=False` before piping to `|tojson` (for example
|
||||
`{{ get_guest_user_attribute('profile', escape_result=False)|tojson }}`), since JSON
|
||||
already handles its own quoting and re-escaping a value first would corrupt it (a nested
|
||||
string such as `O'Brien` would come back as the altered `O''Brien` in the serialized
|
||||
JSON).
|
||||
|
||||
:::
|
||||
|
||||
### Explicitly Including Values in Cache Key
|
||||
|
||||
The `{{ cache_key_wrapper() }}` function explicitly instructs Superset to add a value to the
|
||||
|
||||
@@ -198,6 +198,51 @@ Available per-entity overrides are:
|
||||
When an override is set, it replaces `SUBJECTS_RELATED_TYPES` for that picker. When it is `None`,
|
||||
the picker inherits the global default.
|
||||
|
||||
#### Looking Up Subjects via API
|
||||
|
||||
Superset exposes a read-only REST API for resolving subjects:
|
||||
|
||||
```
|
||||
GET /api/v1/security/subject/
|
||||
```
|
||||
|
||||
The main use case is **id mapping** — given a user, role, or group id, callers (including
|
||||
extensions) can look up the corresponding subject entity, and vice versa. Access is gated by
|
||||
the `can_read` permission on the `Subject` resource, which is granted to **Admins only** by
|
||||
default, since subjects enumerate every user, role, and group on the instance. Callers without
|
||||
that permission receive a `403`. Only `GET` (list, get, info) is exposed — there is no create,
|
||||
update, or delete, because subjects are derived automatically from users, roles, and groups and
|
||||
kept in sync internally.
|
||||
|
||||
**Resolve the subject for a given principal id** using [Rison](https://github.com/Nanonid/rison)
|
||||
query syntax:
|
||||
|
||||
```
|
||||
GET /api/v1/security/subject/?q=(filters:!((col:user_id,opr:eq,value:5)))
|
||||
GET /api/v1/security/subject/?q=(filters:!((col:role_id,opr:eq,value:3)))
|
||||
GET /api/v1/security/subject/?q=(filters:!((col:group_id,opr:eq,value:2)))
|
||||
```
|
||||
|
||||
**Filter by subject type or active status:**
|
||||
|
||||
```
|
||||
GET /api/v1/security/subject/?q=(filters:!((col:type,opr:eq,value:1)))
|
||||
GET /api/v1/security/subject/?q=(filters:!((col:active,opr:eq,value:!t)))
|
||||
```
|
||||
|
||||
The `type` column is an integer enum: `1` for User, `2` for Role, `3` for Group
|
||||
(`superset.subjects.types.SubjectType`).
|
||||
|
||||
**Search by label:**
|
||||
|
||||
```
|
||||
GET /api/v1/security/subject/?q=(filters:!((col:label,opr:subject_all_text,value:finance)))
|
||||
```
|
||||
|
||||
Each subject in the response includes flat scalar ids (`user_id`, `role_id`, `group_id`) rather
|
||||
than a nested object, so callers can match directly on whichever id they already have — only the
|
||||
id field matching the subject's `type` is populated; the others are `null`.
|
||||
|
||||
### Dashboard Access Control
|
||||
|
||||
Access to dashboards is managed via editors (subjects that have edit permissions to the dashboard).
|
||||
|
||||
@@ -277,6 +277,22 @@ second etc). Example:
|
||||
}
|
||||
```
|
||||
|
||||
## How do I expand all chart descriptions on a dashboard by default?
|
||||
|
||||
Charts can have a markdown description, set in the chart's **Edit chart properties** dialog, that's
|
||||
hidden by default and toggled on a per-chart basis from the chart's context menu on a dashboard. If
|
||||
you'd rather have every chart's description expanded by default when the dashboard loads, add the
|
||||
`expand_all_slices` key to the dashboard JSON Metadata field:
|
||||
|
||||
```json
|
||||
{
|
||||
"expand_all_slices": true
|
||||
}
|
||||
```
|
||||
|
||||
Charts that have already been manually expanded or collapsed on the dashboard keep that per-chart
|
||||
override (tracked in the `expanded_slices` key) regardless of the `expand_all_slices` setting.
|
||||
|
||||
## Does Superset work with [insert database engine here]?
|
||||
|
||||
The [Connecting to Databases section](/user-docs/databases/) provides the best
|
||||
|
||||
@@ -165,6 +165,31 @@ You can also certify metrics if you'd like for your team in this view.
|
||||
- [Blog: Unlocking the Power of Virtual Datasets](https://preset.io/blog/unlocking-the-power-of-virtual-datasets-in-apache-superset/)
|
||||
:::
|
||||
|
||||
### Native filters on semantic views
|
||||
|
||||
When the `SEMANTIC_LAYERS` feature flag is enabled, Superset can connect to external semantic layers
|
||||
(such as dbt Semantic Layer or Cube) and expose their semantic views as data sources alongside your
|
||||
regular Datasets. Semantic views can be used as filter targets when adding a native (dashboard) filter,
|
||||
the same way a Dataset can.
|
||||
|
||||
To add a filter on a semantic view:
|
||||
|
||||
1. Open the dashboard, click the **⋮** (more options) menu, and select **Edit dashboard**.
|
||||
2. Open the Filter Bar and click **+ Add/Edit Filters**.
|
||||
3. Add a new filter and, in the datasource dropdown, select a semantic view. Semantic views are listed
|
||||
alongside datasets and can be identified by their type.
|
||||
4. Select one of the semantic view's dimensions in the **Column** field, the same way you'd select a
|
||||
column on a dataset.
|
||||
5. Configure the remaining filter options (filter type, default value, scope, etc.) and click **Save**.
|
||||
|
||||
Any chart on the dashboard that's powered by the same semantic view is filtered by the selected
|
||||
dimension when the filter is applied.
|
||||
|
||||
:::note
|
||||
Semantic views and native filter support for them are part of the experimental Semantic Layers
|
||||
feature and require the `SEMANTIC_LAYERS` feature flag to be enabled.
|
||||
:::
|
||||
|
||||
### Creating charts in Explore view
|
||||
|
||||
Superset has 2 main interfaces for exploring data:
|
||||
@@ -303,6 +328,10 @@ Conditional formatting rules highlight cells based on their values. Rules can be
|
||||
|
||||
Each rule has a **"Use gradient"** toggle: enabled applies a varying opacity (lighter = further from threshold), disabled applies a solid fill at full opacity regardless of value.
|
||||
|
||||
Each rule's color is set with a full color picker rather than a fixed dropdown of presets. Pick any custom color, or use the **Colors** preset swatches, which reference theme tokens (success, warning, error, and their background variants) so a rule's color updates automatically if the active theme changes, including switching between light and dark mode.
|
||||
|
||||
When a rule targets a column with an active time comparison, a **Trend colors** preset also appears, letting you color cells green for an increase and red for a decrease (or the reverse).
|
||||
|
||||
#### HTML Rendering in Table Cells
|
||||
|
||||
Table chart cells can render raw HTML, enabling rich formatting such as hyperlinks, colored badges, and icons directly in the data. Enable this per-column in the chart's **Column Configuration** panel by toggling **Render HTML**.
|
||||
|
||||
@@ -129,3 +129,4 @@ The following URL parameters can be passed through the `urlParams` option in `da
|
||||
- **Guest tokens expire** — their lifetime is controlled by the `GUEST_TOKEN_JWT_EXP_SECONDS` config (default: 5 minutes). Refresh tokens before they expire using a token refresh mechanism in your host app.
|
||||
- **Row-level security** — pass `rls` rules in the guest token request to restrict which rows are visible to the embedded user.
|
||||
- **Allowed domains** — restrict which host origins can embed a dashboard by setting **Allowed Domains** per-dashboard in the _Embed_ settings modal. Superset checks the request's `Referer` header against this list before serving the embedded view; an empty list allows any origin, so configure this explicitly for production.
|
||||
- **Redacted errors** — API responses to a guest token report a generic `An error occurred while fetching the data.` instead of the underlying error, since engine errors quote catalog, schema, table and column names. Errors Superset raises itself — access denials, timeouts, payload validation — keep their message, and the full error is always available in the server logs.
|
||||
|
||||
@@ -352,6 +352,13 @@ The **Custom** time range picker accepts natural language expressions alongside
|
||||
|
||||
These expressions are evaluated at query time, so saved charts always display data relative to the current date.
|
||||
|
||||
### Downloading Drill to Detail and Drill By Results
|
||||
|
||||
The **Drill to detail** and **Drill by** modals, available from a chart's context menu, show the row-level
|
||||
data behind a chart (or behind a specific data point, when one is selected). Use the **Download** button in
|
||||
the modal's toolbar to export the underlying result set as CSV or Excel (XLSX) without leaving the modal —
|
||||
the export isn't limited to the page currently visible in the table.
|
||||
|
||||
:::resources
|
||||
|
||||
- [Chart Walkthroughs](https://docs.preset.io/docs/chart-walkthroughs) - Detailed guides for most chart types
|
||||
|
||||
@@ -32,8 +32,13 @@ Notes on the generated workbook:
|
||||
Excel's 31-character limit; the chart id keeps names unique).
|
||||
- Charts nested in tabs are included.
|
||||
- Data reflects the dashboard's active filter state at the time of export.
|
||||
- A chart with no saved query context is skipped and listed in the email; open
|
||||
the chart in Explore and re-save it to include it next time.
|
||||
- A chart with no saved query context (charts only store one once they've been
|
||||
re-saved in Explore) still exports when it is a `table`, `big_number`,
|
||||
`big_number_total` or `pie`, by rebuilding the query from the chart's saved
|
||||
form data. Charts of other types — and charts relying on post-processing the
|
||||
rebuild can't reproduce — are skipped and listed in the email; open the chart
|
||||
in Explore and re-save it to include it next time, or configure
|
||||
`EXCEL_EXPORT_QUERY_CONTEXT_BUILDER`.
|
||||
- Row counts per sheet are capped the same way as the chart-level CSV/Excel
|
||||
export (`ROW_LIMIT`, bounded by `SQL_MAX_ROW`), and never exceed Excel's
|
||||
per-sheet maximum.
|
||||
@@ -74,6 +79,7 @@ will not register.
|
||||
| `EXCEL_EXPORT_LINK_TTL_SECONDS` | `86400` | Lifetime of the pre-signed download URL (24h). |
|
||||
| `EXCEL_EXPORT_S3_CLIENT_KWARGS` | `{}` | Extra kwargs for `boto3.client("s3", ...)` — e.g. `region_name`, or `endpoint_url` for MinIO/LocalStack. |
|
||||
| `EXCEL_EXPORT_TABLE_VIZ_TYPES` | `None` | Viz types kept tabular in **Export Images to Excel** mode; every other type is embedded as an image. `None` uses the built-in default (`table`, `pivot_table`, `pivot_table_v2`). |
|
||||
| `EXCEL_EXPORT_QUERY_CONTEXT_BUILDER` | `None` | Optional `Callable[[form_data_dict], dict \| None]` to build a query context for a chart missing a saved one, tried before the built-in form-data rebuild. Point it at a service that runs the chart's real frontend `buildQuery` to faithfully export viz types the built-in rebuild can't handle. Must return `None` when it can't build faithfully, so the export falls back. |
|
||||
|
||||
Credentials and region resolve through the standard boto3 chain (environment
|
||||
variables, shared config, or instance role) unless overridden via
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
title: Number Formatting
|
||||
sidebar_position: 11
|
||||
description: Reference for the built-in D3-based number format presets available on chart metrics and axes
|
||||
keywords: [number format, d3 format, formatting, duration, memory, length, distance]
|
||||
---
|
||||
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
|
||||
# Number Formatting
|
||||
|
||||
Most chart types expose a **Number format** control (labeled **D3 Format**, **Y Axis Format**, or similar depending on the chart) wherever a metric or numeric axis can be formatted. This is available from the **Customize** tab, or from the metric's popover editor, depending on the chart type. Selecting one of the built-in presets below applies that formatting to the values Superset renders in the chart.
|
||||
|
||||
When an axis format control's chart has **Comparison display** set to **Percentage** (e.g. period-over-period comparisons), that control's choices are narrowed to percentage-only presets, hiding `SMART_NUMBER`, `~g`, and the duration/memory/length presets.
|
||||
|
||||
You can also type a custom [D3 format string](https://github.com/d3/d3-format) directly into the control if none of the presets fit your needs.
|
||||
|
||||
## Built-in presets
|
||||
|
||||
### General
|
||||
|
||||
| Key | Description |
|
||||
| --- | --- |
|
||||
| `SMART_NUMBER` | Adaptive formatting — automatically picks a reasonable precision based on the value |
|
||||
| `~g` | Original value, using D3's general-format specifier (trims trailing zeros; may switch to exponential notation for very large or small values) |
|
||||
|
||||
### D3 format strings
|
||||
|
||||
These are raw [D3 format specifiers](https://github.com/d3/d3-format#locale_format). The dropdown shows a live preview of each one against a sample value.
|
||||
|
||||
| Format spec | What it does |
|
||||
| --- | --- |
|
||||
| `,d` | Integer, comma-grouped thousands |
|
||||
| `.1s` | SI-prefix notation, 1 significant digit |
|
||||
| `.3s` | SI-prefix notation, 3 significant digits |
|
||||
| `,.1%` | Percentage, 1 decimal place, comma-grouped |
|
||||
| `.2%` | Percentage, 2 decimal places |
|
||||
| `.3%` | Percentage, 3 decimal places |
|
||||
| `.4r` | Rounded to 4 significant digits |
|
||||
| `,.1f` | Fixed-point, 1 decimal place, comma-grouped |
|
||||
| `,.2f` | Fixed-point, 2 decimal places, comma-grouped |
|
||||
| `,.3f` | Fixed-point, 3 decimal places, comma-grouped |
|
||||
| `+,` | Comma-grouped, always shows the sign |
|
||||
| `$,.2f` | Currency, 2 decimal places, comma-grouped |
|
||||
|
||||
### Duration
|
||||
|
||||
| Key | Description |
|
||||
| --- | --- |
|
||||
| `DURATION` | Duration in ms (`66000` => `1m 6s`) |
|
||||
| `DURATION_SUB` | Duration in ms, with sub-second precision (`1.40008` => `1ms 400µs 80ns`) |
|
||||
| `DURATION_COL` | Duration in ms, colon-separated (`10500` => `0:00:10.5`) |
|
||||
|
||||
### Memory
|
||||
|
||||
| Key | Description |
|
||||
| --- | --- |
|
||||
| `MEMORY_DECIMAL` | Memory in bytes, decimal (`1024B` => `1.024kB`) |
|
||||
| `MEMORY_BINARY` | Memory in bytes, binary (`1024B` => `1KiB`) |
|
||||
| `MEMORY_TRANSFER_RATE_DECIMAL` | Memory transfer rate in bytes, decimal (`1024B` => `1.024kB/s`) |
|
||||
| `MEMORY_TRANSFER_RATE_BINARY` | Memory transfer rate in bytes, binary (`1024B` => `1KiB/s`) |
|
||||
|
||||
### Distance / length
|
||||
|
||||
| Key | Description |
|
||||
| --- | --- |
|
||||
| `LENGTH` | Length in meters, converted to kilometers (`12345m` => `12.35km`) |
|
||||
| `LENGTH_CM_KM` | Length in centimeters, converted to kilometers (`12345678cm` => `123.46km`) |
|
||||
| `LENGTH_CM_M` | Length in centimeters, converted to meters (`12345cm` => `123.45m`) |
|
||||
|
||||
Use these when a metric's underlying values are stored in meters or centimeters but are easier to read at a coarser unit — for example, distances traveled, cable/pipe lengths, or elevation changes.
|
||||
|
||||
## Currency
|
||||
|
||||
Some chart types also expose currency-specific formatting, including a dynamic mode that reads the currency from a column value. See [Dynamic Currency Formatting](./creating-your-first-dashboard#dynamic-currency-formatting) for details.
|
||||
+15
-15
@@ -58,15 +58,15 @@
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
"@mdx-js/react": "^3.1.1",
|
||||
"@saucelabs/theme-github-codeblock": "^0.3.0",
|
||||
"@storybook/addon-docs": "^10.5.5",
|
||||
"@storybook/addon-docs": "^10.5.6",
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"@swc/core": "^1.15.46",
|
||||
"antd": "^6.5.2",
|
||||
"baseline-browser-mapping": "^2.11.5",
|
||||
"@swc/core": "^1.15.47",
|
||||
"antd": "^6.5.3",
|
||||
"baseline-browser-mapping": "^2.11.12",
|
||||
"caniuse-lite": "^1.0.30001806",
|
||||
"docusaurus-plugin-openapi-docs": "^5.1.2",
|
||||
"docusaurus-theme-openapi-docs": "^5.1.2",
|
||||
"js-yaml": "^5.2.2",
|
||||
"docusaurus-plugin-openapi-docs": "^5.1.3",
|
||||
"docusaurus-theme-openapi-docs": "^5.1.3",
|
||||
"js-yaml": "^5.2.3",
|
||||
"json-bigint": "^1.0.0",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"react": "^18.3.1",
|
||||
@@ -77,8 +77,8 @@
|
||||
"react-table": "^7.8.0",
|
||||
"remark-import-partial": "^0.0.2",
|
||||
"reselect": "^5.2.0",
|
||||
"storybook": "^10.5.5",
|
||||
"swagger-ui-react": "^5.32.11",
|
||||
"storybook": "^10.5.6",
|
||||
"swagger-ui-react": "^5.32.12",
|
||||
"swc-loader": "^0.2.7",
|
||||
"tinycolor2": "^1.4.2",
|
||||
"unist-util-visit": "^5.1.0"
|
||||
@@ -89,15 +89,15 @@
|
||||
"@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.66.0",
|
||||
"@typescript-eslint/parser": "^8.66.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"globals": "^17.8.0",
|
||||
"oxfmt": "^0.61.0",
|
||||
"globals": "^17.9.0",
|
||||
"oxfmt": "^0.62.0",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.65.0",
|
||||
"webpack": "^5.109.1"
|
||||
"typescript-eslint": "^8.66.0",
|
||||
"webpack": "^5.109.2"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
||||
Vendored
+4
-10
@@ -89,9 +89,9 @@
|
||||
},
|
||||
{
|
||||
"name": "SOFT_DELETE",
|
||||
"default": false,
|
||||
"default": true,
|
||||
"lifecycle": "development",
|
||||
"description": "Temporary rollout / kill-switch gate for soft delete (default off = legacy hard delete). An emergency stop, not a clean rollback: flipping ON->OFF resurrects already-soft-deleted rows. Removed (along with its two gate points \u2014 BaseDAO.delete routing and the do_orm_execute visibility listener) once soft delete is stable."
|
||||
"description": "Temporary rollout / kill-switch gate for soft delete (off = legacy hard delete). An emergency stop, not a clean rollback: flipping ON->OFF resurrects already-soft-deleted rows. Retained through this release as the move-back lever; removed (along with its two gate points \u2014 BaseDAO.delete routing and the do_orm_execute visibility listener) once post-flip confidence is established."
|
||||
},
|
||||
{
|
||||
"name": "TABLE_V2_TIME_COMPARISON_ENABLED",
|
||||
@@ -107,9 +107,9 @@
|
||||
},
|
||||
{
|
||||
"name": "VERSION_HISTORY",
|
||||
"default": false,
|
||||
"default": true,
|
||||
"lifecycle": "development",
|
||||
"description": "Enables the version history panel on Explore and Dashboard pages. History only accrues while ``ENABLE_VERSIONING_CAPTURE`` is also on; with capture off the panel renders but stays empty."
|
||||
"description": "Enables the version history panel on Explore and Dashboard pages. History only accrues while ``ENABLE_VERSIONING_CAPTURE`` is also on; with capture off the panel renders but stays empty, so the two ship with matching defaults and should be changed together."
|
||||
}
|
||||
],
|
||||
"testing": [
|
||||
@@ -221,12 +221,6 @@
|
||||
"lifecycle": "testing",
|
||||
"description": "When impersonating a user, use the email prefix instead of username"
|
||||
},
|
||||
{
|
||||
"name": "PLAYWRIGHT_REPORTS_AND_THUMBNAILS",
|
||||
"default": false,
|
||||
"lifecycle": "testing",
|
||||
"description": "Replace Selenium with Playwright for reports and thumbnails. Supports deck.gl visualizations. Requires playwright pip package."
|
||||
},
|
||||
{
|
||||
"name": "RLS_IN_SQLLAB",
|
||||
"default": false,
|
||||
|
||||
Vendored
+7
@@ -11383,6 +11383,13 @@
|
||||
},
|
||||
"User3": {
|
||||
"properties": {
|
||||
"attributes": {
|
||||
"additionalProperties": {
|
||||
"nullable": true
|
||||
},
|
||||
"nullable": true,
|
||||
"type": "object"
|
||||
},
|
||||
"first_name": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -423,7 +423,7 @@ echo "sqlalchemy-cratedb" >> ./docker/requirements-local.txt
|
||||
#### Databend
|
||||
|
||||
The recommended connector library for Databend is [databend-sqlalchemy](https://pypi.org/project/databend-sqlalchemy/).
|
||||
Superset has been tested on `databend-sqlalchemy>=0.2.3`.
|
||||
Superset has been tested on `databend-sqlalchemy>=0.5.5`.
|
||||
|
||||
The recommended connection string is:
|
||||
|
||||
@@ -434,7 +434,7 @@ databend://{username}:{password}@{host}:{port}/{database_name}
|
||||
Here's a connection string example of Superset connecting to a Databend database:
|
||||
|
||||
```
|
||||
databend://user:password@localhost:8000/default?secure=false
|
||||
databend://user:password@localhost:8000/default?sslmode=disable
|
||||
```
|
||||
|
||||
#### Databricks
|
||||
|
||||
+693
-630
File diff suppressed because it is too large
Load Diff
@@ -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.5 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
|
||||
dependencies:
|
||||
- name: postgresql
|
||||
version: 16.7.27
|
||||
|
||||
@@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs
|
||||
|
||||
# superset
|
||||
|
||||

|
||||

|
||||
|
||||
Apache Superset is a modern, enterprise-ready business intelligence web application
|
||||
|
||||
|
||||
@@ -328,6 +328,7 @@ class CeleryConfig:
|
||||
"superset.tasks.scheduler",
|
||||
"superset.tasks.thumbnails",
|
||||
"superset.tasks.cache",
|
||||
"superset.tasks.slack",
|
||||
)
|
||||
broker_connection_retry_on_startup = True
|
||||
worker_prefetch_multiplier = 10
|
||||
|
||||
+27
-22
@@ -50,7 +50,7 @@ dependencies = [
|
||||
"flask-cors>=6.0.5, <7.0",
|
||||
"croniter>=6.2.4",
|
||||
"cron-descriptor",
|
||||
"cryptography>=49.0.0, <50.0.0",
|
||||
"cryptography>=50.0.0, <51.0.0",
|
||||
"deprecation>=2.1.0, <2.2.0",
|
||||
"flask>=2.2.5, <4.0.0",
|
||||
"flask-appbuilder>=5.2.2, <6.0.0",
|
||||
@@ -68,19 +68,19 @@ dependencies = [
|
||||
# not just a connection-pool quirk. Needs dedicated investigation, not a
|
||||
# driver-compat-prep bump; revisit alongside the actual SQLAlchemy 2.0
|
||||
# core bump (discussion #40273, step 6).
|
||||
"flask-sqlalchemy>=2.5.1, <3.0",
|
||||
"flask-sqlalchemy>=2.5.1, <4.0",
|
||||
"flask-wtf>=1.3.0, <2.0",
|
||||
"geopy",
|
||||
"greenlet<=3.5.4, >=3.5.4",
|
||||
"gunicorn>=26.0.0, <27; sys_platform != 'win32'",
|
||||
"hashids>=1.3.1, <2",
|
||||
# holidays>=0.45 required for security fix
|
||||
"holidays>=0.99, <1",
|
||||
"holidays>=0.101, <1",
|
||||
"humanize",
|
||||
"isodate",
|
||||
"jsonpath-ng>=1.8.0, <2",
|
||||
"Mako>=1.2.2",
|
||||
"markdown>=3.10.2",
|
||||
"markdown>=3.10.3",
|
||||
# marshmallow 4 compatibility: see superset/marshmallow_compatibility.py for a
|
||||
# Flask-AppBuilder workaround. Tracking issue:
|
||||
# https://github.com/apache/superset/issues/33162
|
||||
@@ -88,7 +88,7 @@ dependencies = [
|
||||
"marshmallow-union>=0.1.15.post1",
|
||||
"msgpack>=1.2.0, <1.3",
|
||||
"nh3>=0.3.5, <0.4",
|
||||
"numpy>1.23.5, <2.3",
|
||||
"numpy>=1.23.5, <2.5",
|
||||
"packaging",
|
||||
# --------------------------
|
||||
# pandas and related (wanting pandas[performance] without numba as it's 100+MB and not needed)
|
||||
@@ -110,15 +110,15 @@ dependencies = [
|
||||
"PyJWT>=2.4.0, <3.0",
|
||||
"redis>=5.0.0, <9.0",
|
||||
"rison>=2.0.1, <3.0",
|
||||
"selenium>=4.45.0, <5.0",
|
||||
"shillelagh[gsheetsapi]>=1.4.4, <2.0",
|
||||
|
||||
"shillelagh[gsheetsapi]>=1.4.5, <2.0",
|
||||
"sshtunnel>=0.4.0, <0.5",
|
||||
"simplejson>=4.1.1",
|
||||
"slack_sdk>=3.43.0, <4",
|
||||
"sqlalchemy>=1.4.43, <2", # 1.4.43 adds the python-oracledb (oracle+oracledb) dialect
|
||||
"sqlalchemy-continuum>=1.6.0, <2.0.0",
|
||||
"sqlalchemy-utils>=0.42.1, <0.43", # expanding lowerbound to work with pydoris
|
||||
"sqlglot>=30.12.0, <31",
|
||||
"sqlglot>=30.16.0, <31", # 30.16.0 adds Trino inline UDF IF/CASE routine statement parsing
|
||||
# newer pandas needs 0.9+
|
||||
"tabulate>=0.10.0, <1.0",
|
||||
"typing-extensions>=4.16.0, <5",
|
||||
@@ -131,7 +131,7 @@ dependencies = [
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
athena = ["pyathena[pandas]>=3.35.2, <4"]
|
||||
athena = ["pyathena[pandas]>=3.35.4, <4"]
|
||||
# No SQLAlchemy 2.0 support anywhere in this dialect's ecosystem today: our
|
||||
# own preset-io/sqlalchemy-aurora-data-api fork is dead since 2021, and the
|
||||
# more active community fork (cloud-utils/sqlalchemy-aurora-data-api) has an
|
||||
@@ -143,7 +143,7 @@ bigquery = [
|
||||
# 1.17.1 is likely the final release: googleapis/python-bigquery-sqlalchemy
|
||||
# was archived 2026-05-16. Both 1.17.0 and 1.17.1 support SQLAlchemy 1.4/2.0.
|
||||
"sqlalchemy-bigquery>=1.17.1",
|
||||
"google-cloud-bigquery>=3.42.2",
|
||||
"google-cloud-bigquery>=3.42.3",
|
||||
]
|
||||
clickhouse = ["clickhouse-connect>=1.6.0, <2.0"]
|
||||
cockroachdb = ["cockroachdb>=0.3.5, <0.4"]
|
||||
@@ -173,7 +173,7 @@ dremio = ["sqlalchemy-dremio>=1.2.1, <3.0.5"]
|
||||
# 1.1.10 already supports SQLAlchemy 2.0 (added `import_dbapi` in 1.1.7).
|
||||
drill = ["sqlalchemy-drill>=1.1.10, <3"]
|
||||
druid = ["pydruid>=0.6.5,<0.7"]
|
||||
duckdb = ["duckdb>=1.5.4,<2", "duckdb-engine>=0.17.0"]
|
||||
duckdb = ["duckdb>=1.5.5,<2", "duckdb-engine>=0.17.0"]
|
||||
dynamodb = ["pydynamodb>=0.8.2"]
|
||||
# Effectively unmaintained (only dependabot bumps since 2024); hard-pinned to
|
||||
# SQLAlchemy ~1.4.7 upstream, no SQLAlchemy 2.0 work. See
|
||||
@@ -191,7 +191,7 @@ excel = ["xlrd>=2.0.2, <2.1"]
|
||||
# installing this extra is only required to actually run exports.
|
||||
excel-export = ["boto3"]
|
||||
fastmcp = [
|
||||
"fastmcp>=3.4.3,<4.0",
|
||||
"fastmcp>=3.4.5,<4.0",
|
||||
# tiktoken backs the response-size-guard token estimator. Without
|
||||
# it, the middleware falls back to a coarser character-based
|
||||
# heuristic that under-counts JSON-heavy MCP responses.
|
||||
@@ -203,8 +203,8 @@ fastmcp = [
|
||||
# Superset's own SQLAlchemy 2.0 core bump (discussion #40273), not before.
|
||||
firebird = ["sqlalchemy-firebird>=0.8.0, <2.0.0"]
|
||||
firebolt = ["firebolt-sqlalchemy>=1.1.2, <2"]
|
||||
gevent = ["gevent>=26.4.0"]
|
||||
gsheets = ["shillelagh[gsheetsapi]>=1.4.4, <2"]
|
||||
gevent = ["gevent>=26.7.0"]
|
||||
gsheets = ["shillelagh[gsheetsapi]>=1.4.5, <2"]
|
||||
hana = ["hdbcli==2.29.25", "sqlalchemy_hana==3.0.3"]
|
||||
hive = [
|
||||
"pyhive[hive_pure_sasl]>=0.7.0",
|
||||
@@ -234,7 +234,7 @@ ocient = [
|
||||
oracle = ["oracledb>=4.0.2, <5"]
|
||||
parseable = ["sqlalchemy-parseable>=0.1.6,<0.2.0"]
|
||||
pinot = ["pinotdb>=5.0.0, <10.0.0"]
|
||||
playwright = ["playwright>=1.61.0, <2"]
|
||||
playwright = ["playwright>=1.62.0, <2"]
|
||||
postgres = ["psycopg2-binary==2.9.12"]
|
||||
presto = ["pyhive[presto]>=0.6.5"]
|
||||
trino = ["trino>=0.338.0"]
|
||||
@@ -248,8 +248,8 @@ redshift = ["sqlalchemy-redshift>=0.8.1, <0.9"]
|
||||
# and 2.0 (version numbers don't track SQLAlchemy compat monotonically); pin
|
||||
# to the newest 1.4-only release for now. Bump to >=2.0.0 in lockstep with
|
||||
# Superset's own SQLAlchemy 2.0 core bump (discussion #40273), not before.
|
||||
risingwave = ["sqlalchemy-risingwave>=1.4.1, <2.0.0"]
|
||||
shillelagh = ["shillelagh[all]>=1.4.4, <2"]
|
||||
risingwave = ["sqlalchemy-risingwave>=1.4.1, <3.0.0"]
|
||||
shillelagh = ["shillelagh[all]>=1.4.5, <2"]
|
||||
singlestore = ["sqlalchemy-singlestoredb>=1.2.1, <2"]
|
||||
snowflake = ["snowflake-sqlalchemy>=1.11.0, <2"]
|
||||
sqlite = ["syntaqlite>=0.7.0,<0.8.0"]
|
||||
@@ -259,14 +259,14 @@ spark = [
|
||||
"thrift>=0.24.0, <1",
|
||||
]
|
||||
tdengine = [
|
||||
"taospy>=2.8.9",
|
||||
"taospy>=2.8.10",
|
||||
"taos-ws-py>=0.7.0"
|
||||
]
|
||||
teradata = ["teradatasql>=20.0.0.62"]
|
||||
teradata = ["teradatasql>=20.0.0.64"]
|
||||
thumbnails = [] # deprecated, will be removed in 7.0
|
||||
vertica = ["sqlalchemy-vertica-python>= 0.6.3, < 0.7"]
|
||||
netezza = ["nzalchemy>= 11.1.2, < 11.2"]
|
||||
starrocks = ["starrocks>=1.3.3, <2"]
|
||||
starrocks = ["starrocks>=1.3.4, <2"]
|
||||
doris = ["pydoris>=1.2.0, <2.0.0"]
|
||||
oceanbase = ["oceanbase_py>=0.0.1.2"]
|
||||
ydb = ["ydb-sqlalchemy>=0.1.22", "ydb-sqlglot-plugin>=0.2.8"]
|
||||
@@ -286,7 +286,7 @@ development = [
|
||||
"progress>=1.6.1,<2",
|
||||
"psutil",
|
||||
"pyfakefs",
|
||||
"pyinstrument>=5.1.2,<6",
|
||||
"pyinstrument>=5.1.3,<6",
|
||||
"pylint",
|
||||
"pytest<10.0.0", # cap below the next major until validated; the earlier <8 pin (current_app proxy timing) no longer reproduces
|
||||
"pytest-asyncio",
|
||||
@@ -309,7 +309,7 @@ combine_as_imports = true
|
||||
include_trailing_comma = true
|
||||
line_length = 88
|
||||
known_first_party = "superset, apache-superset-core, apache-superset-extensions-cli"
|
||||
known_third_party = "alembic, apispec, backoff, celery, click, colorama, cron_descriptor, croniter, cryptography, dateutil, deprecation, flask, flask_appbuilder, flask_babel, flask_caching, flask_compress, flask_jwt_extended, flask_login, flask_migrate, flask_sqlalchemy, flask_talisman, flask_testing, flask_wtf, freezegun, geohash, geopy, holidays, humanize, isodate, jinja2, jwt, markdown, markupsafe, marshmallow, marshmallow-union, msgpack, nh3, numpy, pandas, parameterized, parsedatetime, pgsanity, polyline, rison, progress, pyarrow, sqlalchemy_bigquery, pyhive, pyparsing, pytest, pytest_mock, pytz, redis, requests, selenium, setuptools, shillelagh, simplejson, slack, sqlalchemy, sqlalchemy_utils, syntaqlite, typing_extensions, urllib3, werkzeug, wtforms, wtforms_json, yaml"
|
||||
known_third_party = "alembic, apispec, backoff, celery, click, colorama, cron_descriptor, croniter, cryptography, dateutil, deprecation, flask, flask_appbuilder, flask_babel, flask_caching, flask_compress, flask_jwt_extended, flask_login, flask_migrate, flask_sqlalchemy, flask_talisman, flask_testing, flask_wtf, freezegun, geohash, geopy, holidays, humanize, isodate, jinja2, jwt, markdown, markupsafe, marshmallow, marshmallow-union, msgpack, nh3, numpy, pandas, parameterized, parsedatetime, pgsanity, polyline, rison, progress, pyarrow, sqlalchemy_bigquery, pyhive, pyparsing, pytest, pytest_mock, pytz, redis, requests, setuptools, shillelagh, simplejson, slack, sqlalchemy, sqlalchemy_utils, syntaqlite, typing_extensions, urllib3, werkzeug, wtforms, wtforms_json, yaml"
|
||||
multi_line_output = 3
|
||||
order_by_type = false
|
||||
|
||||
@@ -535,6 +535,11 @@ authorized_licenses = [
|
||||
# Seems ok, might need legal review
|
||||
# https://github.com/urschrei/pypolyline/blob/master/LICENSE.md
|
||||
polyline = "2"
|
||||
# NumPy 2.x reports a combined SPDX license expression covering vendored
|
||||
# code (BSD-3-Clause AND 0BSD AND MIT AND Zlib AND CC0-1.0), all of which
|
||||
# are permissive OSI-approved licenses; the package itself remains
|
||||
# BSD-3-Clause. https://github.com/numpy/numpy/blob/main/LICENSE.txt
|
||||
numpy = "2"
|
||||
# --------------------------------------------------------------
|
||||
|
||||
# TODO REMOVE THESE DEPS FROM CODEBASE
|
||||
|
||||
@@ -30,9 +30,12 @@ filterwarnings =
|
||||
always::sqlalchemy.exc.RemovedIn20Warning
|
||||
error:Passing a string to Connection.execute\(\) is deprecated:sqlalchemy.exc.RemovedIn20Warning
|
||||
error:"Query" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning
|
||||
error:"ReportExecutionLog" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning
|
||||
error:"ReportRecipients" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning
|
||||
error:"SavedQuery" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning
|
||||
error:"SqlaTable" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning
|
||||
error:"SqlMetric" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning
|
||||
error:"SSHTunnel" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning
|
||||
error:"TableColumn" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning
|
||||
error:"TaggedObject" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning
|
||||
error:The autoload parameter is deprecated:sqlalchemy.exc.RemovedIn20Warning
|
||||
|
||||
@@ -26,7 +26,7 @@ filelock>=3.20.3,<4.0.0
|
||||
brotli>=1.2.0,<2.0.0
|
||||
numexpr>=2.9.0
|
||||
# Security: CVE-2026-34073 (MEDIUM) - Improper Certificate Validation
|
||||
cryptography>=49.0.0,<50.0.0
|
||||
cryptography>=50.0.0,<51.0.0
|
||||
# Security: Snyk - XSS vulnerability in Mako templates
|
||||
mako>=1.3.11,<2.0.0
|
||||
# Security: CVE-2024-52338 (CRITICAL) - Deserialization of untrusted data in IPC/Parquet readers
|
||||
|
||||
+9
-40
@@ -20,10 +20,8 @@ attrs==25.3.0
|
||||
# via
|
||||
# cattrs
|
||||
# jsonschema
|
||||
# outcome
|
||||
# referencing
|
||||
# requests-cache
|
||||
# trio
|
||||
babel==2.17.0
|
||||
# via flask-babel
|
||||
backoff==2.2.1
|
||||
@@ -53,9 +51,7 @@ cattrs==25.1.1
|
||||
celery==5.6.3
|
||||
# via apache-superset (pyproject.toml)
|
||||
certifi==2026.5.20
|
||||
# via
|
||||
# requests
|
||||
# selenium
|
||||
# via requests
|
||||
cffi==2.0.0
|
||||
# via
|
||||
# cryptography
|
||||
@@ -88,7 +84,7 @@ cron-descriptor==1.4.5
|
||||
# via apache-superset (pyproject.toml)
|
||||
croniter==6.2.4
|
||||
# via apache-superset (pyproject.toml)
|
||||
cryptography==49.0.0
|
||||
cryptography==50.0.0
|
||||
# via
|
||||
# -r requirements/base.in
|
||||
# apache-superset (pyproject.toml)
|
||||
@@ -174,11 +170,9 @@ greenlet==3.5.4
|
||||
# sqlalchemy
|
||||
gunicorn==26.0.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
h11==0.16.0
|
||||
# via wsproto
|
||||
hashids==1.3.1
|
||||
# via apache-superset (pyproject.toml)
|
||||
holidays==0.100
|
||||
holidays==0.102
|
||||
# via apache-superset (pyproject.toml)
|
||||
humanize==4.12.3
|
||||
# via apache-superset (pyproject.toml)
|
||||
@@ -186,7 +180,6 @@ idna==3.15
|
||||
# via
|
||||
# email-validator
|
||||
# requests
|
||||
# trio
|
||||
# url-normalize
|
||||
isodate==0.7.2
|
||||
# via
|
||||
@@ -219,7 +212,7 @@ mako==1.3.12
|
||||
# -r requirements/base.in
|
||||
# apache-superset (pyproject.toml)
|
||||
# alembic
|
||||
markdown==3.10.2
|
||||
markdown==3.10.3
|
||||
# via apache-superset (pyproject.toml)
|
||||
markdown-it-py==3.0.0
|
||||
# via rich
|
||||
@@ -251,7 +244,7 @@ nh3==0.3.6
|
||||
# via apache-superset (pyproject.toml)
|
||||
numexpr==2.10.2
|
||||
# via -r requirements/base.in
|
||||
numpy==1.26.4
|
||||
numpy==2.4.6
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# bottleneck
|
||||
@@ -265,10 +258,6 @@ openpyxl==3.1.5
|
||||
# via pandas
|
||||
ordered-set==4.1.0
|
||||
# via flask-limiter
|
||||
outcome==1.3.0.post0
|
||||
# via
|
||||
# trio
|
||||
# trio-websocket
|
||||
packaging==25.0
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
@@ -326,14 +315,12 @@ pyjwt==2.13.0
|
||||
# flask-jwt-extended
|
||||
pynacl==1.6.2
|
||||
# via paramiko
|
||||
pyopenssl==26.3.0
|
||||
pyopenssl==26.4.0
|
||||
# via
|
||||
# -r requirements/base.in
|
||||
# shillelagh
|
||||
pyparsing==3.3.2
|
||||
# via apache-superset (pyproject.toml)
|
||||
pysocks==1.7.1
|
||||
# via urllib3
|
||||
python-calamine==0.8.2
|
||||
# via pandas
|
||||
python-dateutil==2.9.0.post0
|
||||
@@ -357,7 +344,7 @@ pyyaml==6.0.3
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# apispec
|
||||
redis==8.0.1
|
||||
redis==8.1.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
referencing==0.36.2
|
||||
# via
|
||||
@@ -380,11 +367,9 @@ rpds-py==0.25.0
|
||||
# via
|
||||
# jsonschema
|
||||
# referencing
|
||||
selenium==4.45.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
setuptools==80.9.0
|
||||
# via -r requirements/base.in
|
||||
shillelagh==1.4.4
|
||||
shillelagh==1.4.5
|
||||
# via apache-superset (pyproject.toml)
|
||||
simplejson==4.1.1
|
||||
# via apache-superset (pyproject.toml)
|
||||
@@ -396,10 +381,6 @@ six==1.17.0
|
||||
# wtforms-json
|
||||
slack-sdk==3.43.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
sniffio==1.3.1
|
||||
# via trio
|
||||
sortedcontainers==2.4.0
|
||||
# via trio
|
||||
sqlalchemy==1.4.54
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
@@ -418,7 +399,7 @@ sqlalchemy-utils==0.42.1
|
||||
# apache-superset (pyproject.toml)
|
||||
# apache-superset-core
|
||||
# flask-appbuilder
|
||||
sqlglot==30.12.0
|
||||
sqlglot==30.16.0
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# apache-superset-core
|
||||
@@ -426,12 +407,6 @@ sshtunnel==0.4.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
tabulate==0.10.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
trio==0.33.0
|
||||
# via
|
||||
# selenium
|
||||
# trio-websocket
|
||||
trio-websocket==0.12.2
|
||||
# via selenium
|
||||
typing-extensions==4.16.0
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
@@ -443,7 +418,6 @@ typing-extensions==4.16.0
|
||||
# pydantic-core
|
||||
# pyopenssl
|
||||
# referencing
|
||||
# selenium
|
||||
# shillelagh
|
||||
# typing-inspection
|
||||
typing-inspection==0.4.2
|
||||
@@ -461,7 +435,6 @@ urllib3==2.7.0
|
||||
# -r requirements/base.in
|
||||
# requests
|
||||
# requests-cache
|
||||
# selenium
|
||||
vine==5.1.0
|
||||
# via
|
||||
# amqp
|
||||
@@ -471,8 +444,6 @@ watchdog==6.0.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
wcwidth==0.2.13
|
||||
# via prompt-toolkit
|
||||
websocket-client==1.8.0
|
||||
# via selenium
|
||||
werkzeug==3.1.6
|
||||
# via
|
||||
# -r requirements/base.in
|
||||
@@ -483,8 +454,6 @@ werkzeug==3.1.6
|
||||
# flask-login
|
||||
wrapt==1.17.2
|
||||
# via deprecated
|
||||
wsproto==1.2.0
|
||||
# via trio-websocket
|
||||
wtforms==3.2.2
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
|
||||
@@ -48,10 +48,8 @@ attrs==25.3.0
|
||||
# cattrs
|
||||
# cyclopts
|
||||
# jsonschema
|
||||
# outcome
|
||||
# referencing
|
||||
# requests-cache
|
||||
# trio
|
||||
authlib==1.6.12
|
||||
# via fastmcp-slim
|
||||
babel==2.17.0
|
||||
@@ -122,7 +120,6 @@ certifi==2026.5.20
|
||||
# httpcore
|
||||
# httpx
|
||||
# requests
|
||||
# selenium
|
||||
cffi==2.0.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
@@ -182,7 +179,7 @@ croniter==6.2.4
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
cryptography==49.0.0
|
||||
cryptography==50.0.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -225,7 +222,7 @@ docstring-parser==0.17.0
|
||||
# via cyclopts
|
||||
docutils==0.22.2
|
||||
# via rich-rst
|
||||
duckdb==1.5.4
|
||||
duckdb==1.5.5
|
||||
# via
|
||||
# apache-superset
|
||||
# duckdb-engine
|
||||
@@ -242,9 +239,9 @@ et-xmlfile==2.0.0
|
||||
# openpyxl
|
||||
exceptiongroup==1.3.0
|
||||
# via fastmcp-slim
|
||||
fastmcp==3.4.4
|
||||
fastmcp==3.4.5
|
||||
# via apache-superset
|
||||
fastmcp-slim==3.4.4
|
||||
fastmcp-slim==3.4.5
|
||||
# via fastmcp
|
||||
filelock==3.20.3
|
||||
# via
|
||||
@@ -340,7 +337,7 @@ geopy==2.4.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
gevent==26.4.0
|
||||
gevent==26.7.0
|
||||
# via apache-superset
|
||||
google-api-core==2.33.0
|
||||
# via
|
||||
@@ -363,7 +360,7 @@ google-auth-oauthlib==1.2.1
|
||||
# via
|
||||
# pandas-gbq
|
||||
# pydata-google-auth
|
||||
google-cloud-bigquery==3.42.2
|
||||
google-cloud-bigquery==3.43.0
|
||||
# via
|
||||
# apache-superset
|
||||
# pandas-gbq
|
||||
@@ -400,15 +397,13 @@ gunicorn==26.0.0
|
||||
# apache-superset
|
||||
h11==0.16.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# httpcore
|
||||
# uvicorn
|
||||
# wsproto
|
||||
hashids==1.3.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
holidays==0.100
|
||||
holidays==0.102
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -434,7 +429,6 @@ idna==3.15
|
||||
# email-validator
|
||||
# httpx
|
||||
# requests
|
||||
# trio
|
||||
# url-normalize
|
||||
importlib-metadata==8.7.0
|
||||
# via
|
||||
@@ -521,7 +515,7 @@ mako==1.3.12
|
||||
# -c requirements/base-constraint.txt
|
||||
# alembic
|
||||
# apache-superset
|
||||
markdown==3.10.2
|
||||
markdown==3.10.3
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -581,7 +575,7 @@ nh3==0.3.6
|
||||
# apache-superset
|
||||
nodeenv==1.8.0
|
||||
# via pre-commit
|
||||
numpy==1.26.4
|
||||
numpy==2.4.6
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -619,11 +613,6 @@ ordered-set==4.1.0
|
||||
# flask-limiter
|
||||
orjson==3.11.9
|
||||
# via trino
|
||||
outcome==1.3.0.post0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# trio
|
||||
# trio-websocket
|
||||
packaging==25.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
@@ -776,7 +765,7 @@ pygments==2.20.0
|
||||
# rich
|
||||
pyhive==0.7.0
|
||||
# via apache-superset
|
||||
pyinstrument==5.1.2
|
||||
pyinstrument==5.1.3
|
||||
# via apache-superset
|
||||
pyjwt==2.13.0
|
||||
# via
|
||||
@@ -791,7 +780,7 @@ pynacl==1.6.2
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# paramiko
|
||||
pyopenssl==26.3.0
|
||||
pyopenssl==26.4.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# google-auth
|
||||
@@ -803,10 +792,6 @@ pyparsing==3.3.2
|
||||
# matplotlib
|
||||
pyperclip==1.10.0
|
||||
# via fastmcp-slim
|
||||
pysocks==1.7.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# urllib3
|
||||
pytest==7.4.4
|
||||
# via
|
||||
# apache-superset
|
||||
@@ -874,7 +859,7 @@ pyyaml==6.0.3
|
||||
# fastmcp-slim
|
||||
# jsonschema-path
|
||||
# pre-commit
|
||||
redis==8.0.1
|
||||
redis==8.1.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -934,10 +919,6 @@ s3transfer==0.16.0
|
||||
# via boto3
|
||||
secretstorage==3.5.0
|
||||
# via keyring
|
||||
selenium==4.45.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
semver==3.0.4
|
||||
# via apache-superset-extensions-cli
|
||||
setuptools==80.9.0
|
||||
@@ -948,7 +929,7 @@ setuptools==80.9.0
|
||||
# pydata-google-auth
|
||||
# zope-event
|
||||
# zope-interface
|
||||
shillelagh==1.4.4
|
||||
shillelagh==1.4.5
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -968,14 +949,7 @@ slack-sdk==3.43.0
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
sniffio==1.3.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# anyio
|
||||
# trio
|
||||
sortedcontainers==2.4.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# trio
|
||||
# via anyio
|
||||
sqlalchemy==1.4.54
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
@@ -1002,7 +976,7 @@ sqlalchemy-utils==0.42.1
|
||||
# apache-superset
|
||||
# apache-superset-core
|
||||
# flask-appbuilder
|
||||
sqlglot==30.12.0
|
||||
sqlglot==30.16.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -1039,15 +1013,6 @@ tqdm==4.67.1
|
||||
# prophet
|
||||
trino==0.338.0
|
||||
# via apache-superset
|
||||
trio==0.33.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# selenium
|
||||
# trio-websocket
|
||||
trio-websocket==0.12.2
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# selenium
|
||||
typing-extensions==4.16.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
@@ -1067,7 +1032,6 @@ typing-extensions==4.16.0
|
||||
# pydantic-core
|
||||
# pyopenssl
|
||||
# referencing
|
||||
# selenium
|
||||
# shillelagh
|
||||
# starlette
|
||||
# typing-inspection
|
||||
@@ -1100,7 +1064,6 @@ urllib3==2.7.0
|
||||
# docker
|
||||
# requests
|
||||
# requests-cache
|
||||
# selenium
|
||||
uvicorn==0.37.0
|
||||
# via
|
||||
# fastmcp-slim
|
||||
@@ -1124,10 +1087,6 @@ wcwidth==0.2.13
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# prompt-toolkit
|
||||
websocket-client==1.8.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# selenium
|
||||
websockets==15.0.1
|
||||
# via fastmcp-slim
|
||||
werkzeug==3.1.6
|
||||
@@ -1142,10 +1101,6 @@ wrapt==1.17.2
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# deprecated
|
||||
wsproto==1.2.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# trio-websocket
|
||||
wtforms==3.2.2
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
|
||||
@@ -42,6 +42,7 @@ RETRYABLE_STATUS_CODES: frozenset[int] = frozenset({429})
|
||||
PATTERNS = {
|
||||
"python": [
|
||||
r"^\.github/workflows/.*python",
|
||||
r"^\.github/workflows/scheduled-docker-image-refresh\.yml$",
|
||||
r"^docker-compose-image-tag\.yml$",
|
||||
r"^tests/",
|
||||
r"^superset/",
|
||||
|
||||
@@ -1,82 +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 { SAMPLE_DASHBOARD_1 } from 'cypress/utils/urls';
|
||||
import { drag } from 'cypress/utils';
|
||||
import { interceptGet } from './utils';
|
||||
import { interceptFiltering as interceptCharts } from '../explore/utils';
|
||||
|
||||
function editDashboard() {
|
||||
cy.getBySel('edit-dashboard-button').click();
|
||||
}
|
||||
|
||||
function dragComponent(
|
||||
component = 'Unicode Cloud',
|
||||
target = 'card-title',
|
||||
withFiltering = true,
|
||||
) {
|
||||
if (withFiltering) {
|
||||
cy.getBySel('dashboard-charts-filter-search-input').type(component, {
|
||||
force: true,
|
||||
});
|
||||
cy.wait('@filtering');
|
||||
}
|
||||
cy.wait(500);
|
||||
drag(`[data-test="${target}"]`, component).to(
|
||||
'[data-test="grid-content"] [data-test="dragdroppable-object"]',
|
||||
);
|
||||
}
|
||||
|
||||
function visitEdit(sampleDashboard = SAMPLE_DASHBOARD_1) {
|
||||
interceptCharts();
|
||||
interceptGet();
|
||||
|
||||
if (sampleDashboard === SAMPLE_DASHBOARD_1) {
|
||||
cy.createSampleDashboards([0]);
|
||||
}
|
||||
|
||||
cy.visit(sampleDashboard);
|
||||
cy.wait('@get');
|
||||
editDashboard();
|
||||
cy.get('.grid-container').should('exist');
|
||||
cy.wait('@filtering');
|
||||
cy.wait(500);
|
||||
}
|
||||
|
||||
describe('Dashboard edit', () => {
|
||||
describe('Components', () => {
|
||||
beforeEach(() => {
|
||||
visitEdit();
|
||||
});
|
||||
|
||||
it('should add charts', () => {
|
||||
cy.get('body').then($body => {
|
||||
if ($body.find('.ant-modal-wrap').length > 0) {
|
||||
cy.get('body').type('{esc}', { force: true });
|
||||
cy.wait(1000);
|
||||
cy.get('.ant-modal-close').click({ force: true });
|
||||
cy.wait(500);
|
||||
}
|
||||
});
|
||||
cy.get('input[type="checkbox"]').scrollIntoView();
|
||||
cy.get('input[type="checkbox"]').click({ force: true });
|
||||
dragComponent();
|
||||
cy.getBySel('dashboard-component-chart-holder').should('have.length', 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -138,6 +138,7 @@ export function prepareDashboardFilters(
|
||||
chart_customization_config: [],
|
||||
timed_refresh_immune_slices: [],
|
||||
expanded_slices: {},
|
||||
expand_all_slices: false,
|
||||
refresh_frequency: 0,
|
||||
color_scheme: '',
|
||||
label_colors: {},
|
||||
|
||||
@@ -79,34 +79,6 @@ export function waitForChartLoad(chart: ChartSpec) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag an element and drop it to another element.
|
||||
* Usage:
|
||||
* drag(source).to(target);
|
||||
*/
|
||||
export function drag(selector: string, content: string | number | RegExp) {
|
||||
const dataTransfer = { data: {} };
|
||||
return {
|
||||
to(target: string | Cypress.Chainable) {
|
||||
cy.get('.dragdroppable')
|
||||
.contains(selector, content)
|
||||
.trigger('mousedown', { which: 1, force: true });
|
||||
cy.get('.dragdroppable')
|
||||
.contains(selector, content)
|
||||
.trigger('dragstart', { dataTransfer, force: true });
|
||||
cy.get('.dragdroppable')
|
||||
.contains(selector, content)
|
||||
.trigger('drag', { force: true });
|
||||
|
||||
(typeof target === 'string' ? cy.get(target) : target)
|
||||
.trigger('dragover', { dataTransfer, force: true })
|
||||
.trigger('drop', { dataTransfer, force: true })
|
||||
.trigger('dragend', { dataTransfer, force: true })
|
||||
.trigger('mouseup', { which: 1, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function resize(selector: string) {
|
||||
return {
|
||||
to(cordX: number, cordY: number) {
|
||||
|
||||
@@ -77,7 +77,7 @@ module.exports = {
|
||||
// @ant-design/colors and @ant-design/fast-color are allowed through because
|
||||
// @ant-design/icons >= 6.3 deep-imports the ESM build of @ant-design/colors
|
||||
// from its CJS output, so babel-jest must transform those files.
|
||||
'node_modules/(?!@ant-design/(colors|fast-color)|@formatjs/.*|d3-(array|interpolate|color|time|scale|time-format|format)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|@x0k/.*|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|hastscript|refractor|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|character-reference-invalid|is-alphanumerical|is-alphabetical|is-decimal|is-hexadecimal|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|(?!geostyler)lodash|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect|react-diff-viewer-continued|storybook/*.|json-stringify-pretty-compact|@x0k/json-schema-merge)',
|
||||
'node_modules/(?!@ant-design/(colors|fast-color)|@formatjs/.*|d3-(array|interpolate|color|time|scale|time-format|format|selection)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|@x0k/.*|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|hastscript|refractor|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|character-reference-invalid|is-alphanumerical|is-alphabetical|is-decimal|is-hexadecimal|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|(?!geostyler)lodash|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect|react-diff-viewer-continued|storybook/*.|json-stringify-pretty-compact|@x0k/json-schema-merge)',
|
||||
],
|
||||
preset: 'ts-jest',
|
||||
transform: {
|
||||
|
||||
@@ -276,6 +276,19 @@
|
||||
"test*WithInitialValues"
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
// === ESLint rules ===
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"paths": [
|
||||
{
|
||||
"name": "lodash",
|
||||
"message": "Please use tree-shakeable lodash-es instead"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"overrides": [
|
||||
|
||||
Generated
+1631
-2359
File diff suppressed because it is too large
Load Diff
@@ -87,7 +87,7 @@
|
||||
"test": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=8192\" jest --max-workers=80% --silent",
|
||||
"test-loud": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=8192\" jest --max-workers=80%",
|
||||
"type": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" tsc --noEmit",
|
||||
"update-maps": "cd plugins/legacy-plugin-chart-country-map/scripts && jupyter nbconvert --to notebook --execute --inplace --allow-errors --ExecutePreprocessor.timeout=1200 'Country Map GeoJSON Generator.ipynb'",
|
||||
"update-maps": "cd plugins/plugin-chart-country-map/scripts && jupyter nbconvert --to notebook --execute --inplace --allow-errors --ExecutePreprocessor.timeout=1200 'Country Map GeoJSON Generator.ipynb'",
|
||||
"validate-release": "../RELEASING/validate_this_release.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -128,24 +128,22 @@
|
||||
"@scarf/scarf": "^1.4.0",
|
||||
"@superset-ui/chart-controls": "file:./packages/superset-ui-chart-controls",
|
||||
"@superset-ui/core": "file:./packages/superset-ui-core",
|
||||
"@superset-ui/legacy-plugin-chart-calendar": "file:./plugins/legacy-plugin-chart-calendar",
|
||||
"@superset-ui/legacy-plugin-chart-chord": "file:./plugins/legacy-plugin-chart-chord",
|
||||
"@superset-ui/legacy-plugin-chart-country-map": "file:./plugins/legacy-plugin-chart-country-map",
|
||||
"@superset-ui/legacy-plugin-chart-horizon": "file:./plugins/legacy-plugin-chart-horizon",
|
||||
"@superset-ui/legacy-plugin-chart-paired-t-test": "file:./plugins/legacy-plugin-chart-paired-t-test",
|
||||
"@superset-ui/legacy-plugin-chart-parallel-coordinates": "file:./plugins/legacy-plugin-chart-parallel-coordinates",
|
||||
"@superset-ui/legacy-plugin-chart-partition": "file:./plugins/legacy-plugin-chart-partition",
|
||||
"@superset-ui/legacy-plugin-chart-rose": "file:./plugins/legacy-plugin-chart-rose",
|
||||
"@superset-ui/legacy-plugin-chart-world-map": "file:./plugins/legacy-plugin-chart-world-map",
|
||||
"@superset-ui/legacy-preset-chart-nvd3": "file:./plugins/legacy-preset-chart-nvd3",
|
||||
"@superset-ui/plugin-chart-ag-grid-table": "file:./plugins/plugin-chart-ag-grid-table",
|
||||
"@superset-ui/plugin-chart-calendar": "file:./plugins/plugin-chart-calendar",
|
||||
"@superset-ui/plugin-chart-cartodiagram": "file:./plugins/plugin-chart-cartodiagram",
|
||||
"@superset-ui/plugin-chart-chord": "file:./plugins/plugin-chart-chord",
|
||||
"@superset-ui/plugin-chart-country-map": "file:./plugins/plugin-chart-country-map",
|
||||
"@superset-ui/plugin-chart-echarts": "file:./plugins/plugin-chart-echarts",
|
||||
"@superset-ui/plugin-chart-handlebars": "file:./plugins/plugin-chart-handlebars",
|
||||
"@superset-ui/plugin-chart-horizon": "file:./plugins/plugin-chart-horizon",
|
||||
"@superset-ui/plugin-chart-paired-t-test": "file:./plugins/plugin-chart-paired-t-test",
|
||||
"@superset-ui/plugin-chart-parallel-coordinates": "file:./plugins/plugin-chart-parallel-coordinates",
|
||||
"@superset-ui/plugin-chart-partition": "file:./plugins/plugin-chart-partition",
|
||||
"@superset-ui/plugin-chart-pivot-table": "file:./plugins/plugin-chart-pivot-table",
|
||||
"@superset-ui/plugin-chart-point-cluster-map": "file:./plugins/plugin-chart-point-cluster-map",
|
||||
"@superset-ui/plugin-chart-table": "file:./plugins/plugin-chart-table",
|
||||
"@superset-ui/plugin-chart-word-cloud": "file:./plugins/plugin-chart-word-cloud",
|
||||
"@superset-ui/plugin-chart-world-map": "file:./plugins/plugin-chart-world-map",
|
||||
"@superset-ui/preset-chart-deckgl": "file:./plugins/preset-chart-deckgl",
|
||||
"@superset-ui/switchboard": "file:./packages/superset-ui-switchboard",
|
||||
"@types/d3-format": "^3.0.1",
|
||||
@@ -160,7 +158,7 @@
|
||||
"@visx/xychart": "^4.0.0",
|
||||
"ag-grid-community": "36.0.2",
|
||||
"ag-grid-react": "36.0.2",
|
||||
"antd": "^6.5.2",
|
||||
"antd": "^6.5.3",
|
||||
"chrono-node": "^2.10.1",
|
||||
"classnames": "^2.2.5",
|
||||
"content-disposition": "^2.0.1",
|
||||
@@ -178,7 +176,7 @@
|
||||
"geostyler-openlayers-parser": "^5.7.1",
|
||||
"geostyler-style": "11.0.2",
|
||||
"geostyler-wfs-parser": "^3.0.1",
|
||||
"google-auth-library": "^10.9.1",
|
||||
"google-auth-library": "^11.0.0",
|
||||
"immer": "^11.1.15",
|
||||
"interweave": "^13.1.1",
|
||||
"jquery": "^4.0.0",
|
||||
@@ -188,12 +186,12 @@
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.18.1",
|
||||
"mapbox-gl": "^3.27.0",
|
||||
"markdown-to-jsx": "^9.9.0",
|
||||
"markdown-to-jsx": "^9.10.2",
|
||||
"match-sorter": "^8.3.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
"mousetrap": "^1.6.5",
|
||||
"mustache": "^4.2.0",
|
||||
"nanoid": "^6.0.0",
|
||||
"nanoid": "^6.0.1",
|
||||
"ol": "^10.10.0",
|
||||
"query-string": "9.4.1",
|
||||
"re-resizable": "^6.11.2",
|
||||
@@ -205,7 +203,7 @@
|
||||
"react-dnd-html5-backend": "^11.1.3",
|
||||
"react-dom": "^18.3.0",
|
||||
"react-google-recaptcha": "^3.1.0",
|
||||
"react-intersection-observer": "^10.1.0",
|
||||
"react-intersection-observer": "^11.0.0",
|
||||
"react-json-tree": "^0.20.0",
|
||||
"react-lines-ellipsis": "^0.16.1",
|
||||
"react-loadable": "^5.5.0",
|
||||
@@ -218,14 +216,14 @@
|
||||
"react-table": "^7.8.0",
|
||||
"react-transition-group": "^4.4.5",
|
||||
"react-virtualized-auto-sizer": "^1.0.26",
|
||||
"react-window": "^1.8.10",
|
||||
"react-window": "^2.3.0",
|
||||
"redux": "^4.2.1",
|
||||
"redux-localstorage": "^0.4.1",
|
||||
"redux-thunk": "^2.1.0",
|
||||
"redux-undo": "^1.0.0-beta9-9-7",
|
||||
"rison": "^0.1.1",
|
||||
"scroll-into-view-if-needed": "^3.1.0",
|
||||
"simple-zstd": "^2.1.0",
|
||||
"simple-zstd": "^1.4.2",
|
||||
"stream-browserify": "^3.0.0",
|
||||
"tinycolor2": "^1.6.0",
|
||||
"urijs": "^1.19.8",
|
||||
@@ -252,19 +250,19 @@
|
||||
"@babel/register": "^7.29.7",
|
||||
"@babel/runtime": "^7.29.7",
|
||||
"@babel/runtime-corejs3": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"@babel/types": "^7.29.8",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
"@emotion/jest": "^11.14.2",
|
||||
"@formatjs/intl-durationformat": "^0.10.18",
|
||||
"@istanbuljs/nyc-config-typescript": "^1.0.1",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
|
||||
"@storybook/addon-docs": "10.5.5",
|
||||
"@storybook/addon-links": "10.5.5",
|
||||
"@storybook/react-webpack5": "10.5.5",
|
||||
"@storybook/addon-docs": "10.5.6",
|
||||
"@storybook/addon-links": "10.5.6",
|
||||
"@storybook/react-webpack5": "10.5.6",
|
||||
"@storybook/test-runner": "0.24.4",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.46",
|
||||
"@swc/core": "^1.15.47",
|
||||
"@swc/plugin-emotion": "^14.15.0",
|
||||
"@swc/plugin-transform-imports": "^12.5.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
@@ -286,19 +284,18 @@
|
||||
"@types/react-redux": "^7.1.10",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@types/react-transition-group": "^4.4.12",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@types/redux-localstorage": "^1.0.8",
|
||||
"@types/redux-mock-store": "^1.0.6",
|
||||
"@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.66.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.11.5",
|
||||
"baseline-browser-mapping": "^2.11.12",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^10.0.4",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
@@ -310,12 +307,12 @@
|
||||
"eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
|
||||
"eslint-plugin-icons": "file:eslint-rules/eslint-plugin-icons",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jest-dom": "^5.5.0",
|
||||
"eslint-plugin-jest-dom": "^5.10.1",
|
||||
"eslint-plugin-lodash": "^8.0.0",
|
||||
"eslint-plugin-no-only-tests": "^3.4.0",
|
||||
"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.5",
|
||||
"eslint-plugin-storybook": "10.5.6",
|
||||
"eslint-plugin-testing-library": "^7.16.2",
|
||||
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
|
||||
"fetch-mock": "^12.6.0",
|
||||
@@ -328,14 +325,14 @@
|
||||
"jest-html-reporter": "^4.4.0",
|
||||
"jest-websocket-mock": "^2.5.0",
|
||||
"js-yaml-loader": "^1.2.2",
|
||||
"jsdom": "^30.0.0",
|
||||
"lerna": "^9.0.4",
|
||||
"jsdom": "^30.0.1",
|
||||
"lerna": "^10.0.0",
|
||||
"lightningcss": "^1.33.0",
|
||||
"mini-css-extract-plugin": "^2.10.2",
|
||||
"minimizer-webpack-plugin": "^5.6.1",
|
||||
"open-cli": "^9.0.0",
|
||||
"oxfmt": "^0.61.0",
|
||||
"oxlint": "^1.76.0",
|
||||
"oxfmt": "^0.62.0",
|
||||
"oxlint": "^1.77.0",
|
||||
"po2json": "^0.4.5",
|
||||
"postcss-styled-syntax": "^0.7.2",
|
||||
"process": "^0.11.10",
|
||||
@@ -346,17 +343,17 @@
|
||||
"source-map": "^0.8.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"speed-measure-webpack-plugin": "^1.6.0",
|
||||
"storybook": "10.5.5",
|
||||
"storybook": "10.5.6",
|
||||
"style-loader": "^4.0.0",
|
||||
"stylelint": "^17.14.1",
|
||||
"swc-loader": "^0.2.7",
|
||||
"ts-jest": "^29.4.12",
|
||||
"tscw-config": "^1.1.2",
|
||||
"tsx": "^4.23.1",
|
||||
"tsx": "^4.23.5",
|
||||
"typescript": "5.4.5",
|
||||
"unzipper": "^0.12.5",
|
||||
"wait-on": "^9.1.0",
|
||||
"webpack": "^5.109.1",
|
||||
"webpack": "^5.109.2",
|
||||
"webpack-bundle-analyzer": "^5.3.1",
|
||||
"webpack-cli": "^7.0.3",
|
||||
"webpack-dev-server": "^5.2.5",
|
||||
|
||||
@@ -106,10 +106,9 @@
|
||||
"@testing-library/jest-dom": "*",
|
||||
"@testing-library/react": "^15.0.0",
|
||||
"@testing-library/user-event": "*",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/lodash": "^4.17.25",
|
||||
"@types/react": "*",
|
||||
"@types/react-loadable": "*",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@types/tinycolor2": "*",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
|
||||
@@ -160,6 +160,43 @@ test('Theme.setConfig correctly applies algorithm changes', () => {
|
||||
expect(serialized.algorithm).toBe(ThemeAlgorithm.DARK);
|
||||
});
|
||||
|
||||
test('Theme.setConfig with baseTheme merges the config over the base theme tokens', () => {
|
||||
const baseTheme: AnyThemeConfig = {
|
||||
token: { colorPrimary: '#111111', colorError: '#ff0000' },
|
||||
};
|
||||
const theme = Theme.fromConfig();
|
||||
theme.setConfig({ token: { colorPrimary: '#0000ff' } }, baseTheme);
|
||||
|
||||
// Config wins for colorPrimary; the base theme fills the untouched colorError.
|
||||
expect(theme.theme.colorPrimary).toBe('#0000ff');
|
||||
expect(theme.theme.colorError).toBe('#ff0000');
|
||||
});
|
||||
|
||||
test('Theme.setConfig with baseTheme keeps the base theme ECharts overrides', () => {
|
||||
const baseTheme = {
|
||||
token: { colorPrimary: '#111111' },
|
||||
echartsOptionsOverrides: { backgroundColor: '#123456' },
|
||||
echartsOptionsOverridesByChartType: {
|
||||
pie: { itemStyle: { borderWidth: 2 } },
|
||||
},
|
||||
} as AnyThemeConfig & {
|
||||
echartsOptionsOverrides: Record<string, unknown>;
|
||||
echartsOptionsOverridesByChartType: Record<string, unknown>;
|
||||
};
|
||||
const theme = Theme.fromConfig();
|
||||
|
||||
// In-place update whose config sets no ECharts overrides: the base theme's
|
||||
// overrides must survive, the same way its tokens do.
|
||||
theme.setConfig({ token: { colorPrimary: '#0000ff' } }, baseTheme);
|
||||
|
||||
expect(theme.theme.echartsOptionsOverrides).toEqual({
|
||||
backgroundColor: '#123456',
|
||||
});
|
||||
expect(theme.theme.echartsOptionsOverridesByChartType).toEqual({
|
||||
pie: { itemStyle: { borderWidth: 2 } },
|
||||
});
|
||||
});
|
||||
|
||||
test('Theme.toggleDarkMode switches to dark algorithm when toggling dark mode on', () => {
|
||||
const theme = Theme.fromConfig();
|
||||
|
||||
|
||||
@@ -64,10 +64,12 @@ export class Theme {
|
||||
* @param config - The theme configuration
|
||||
* @param baseTheme - Optional base theme to apply under the config
|
||||
*/
|
||||
static fromConfig(
|
||||
// Merge a config over an optional base theme (arrays replace rather than
|
||||
// deep-merge; a colorPrimary override without colorLink aligns colorLink).
|
||||
private static mergeConfig(
|
||||
config?: AnyThemeConfig,
|
||||
baseTheme?: AnyThemeConfig,
|
||||
): Theme {
|
||||
): AnyThemeConfig | undefined {
|
||||
let mergedConfig: AnyThemeConfig | undefined = config;
|
||||
|
||||
if (baseTheme && config) {
|
||||
@@ -76,9 +78,9 @@ export class Theme {
|
||||
);
|
||||
|
||||
// In Ant Design v5, colorLink derives from colorInfo, not colorPrimary.
|
||||
// Currently we expectlinks to follow the brand/primary color. When the user
|
||||
// overrides colorPrimary without explicitly setting colorLink, update the
|
||||
// merged colorLink so links match the new primary palette.
|
||||
// We expect links to follow the brand/primary color, so when a config
|
||||
// overrides colorPrimary without setting colorLink, align the merged
|
||||
// colorLink with the new primary palette.
|
||||
if (config.token?.colorPrimary && !config.token?.colorLink) {
|
||||
const mToken = mergedConfig?.token;
|
||||
if (mToken) {
|
||||
@@ -89,7 +91,14 @@ export class Theme {
|
||||
mergedConfig = baseTheme;
|
||||
}
|
||||
|
||||
return new Theme({ config: mergedConfig });
|
||||
return mergedConfig;
|
||||
}
|
||||
|
||||
static fromConfig(
|
||||
config?: AnyThemeConfig,
|
||||
baseTheme?: AnyThemeConfig,
|
||||
): Theme {
|
||||
return new Theme({ config: Theme.mergeConfig(config, baseTheme) });
|
||||
}
|
||||
|
||||
private static getFilteredAntdTheme(
|
||||
@@ -110,12 +119,14 @@ export class Theme {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the theme using any theme configuration
|
||||
* Automatically handles both AntdThemeConfig and SerializableThemeConfig
|
||||
* Dark mode should be specified via the algorithm property in the config
|
||||
* Update the theme using any theme configuration, optionally merged over a
|
||||
* base theme. Automatically handles both AntdThemeConfig and
|
||||
* SerializableThemeConfig. Dark mode should be specified via the algorithm
|
||||
* property in the config.
|
||||
*/
|
||||
setConfig(config: AnyThemeConfig): void {
|
||||
const antdConfig = normalizeThemeConfig(config);
|
||||
setConfig(config: AnyThemeConfig, baseTheme?: AnyThemeConfig): void {
|
||||
const mergedConfig = Theme.mergeConfig(config, baseTheme) ?? config;
|
||||
const antdConfig = normalizeThemeConfig(mergedConfig);
|
||||
|
||||
if (antdConfig.token?.colorPrimary && !antdConfig.token?.colorLink) {
|
||||
antdConfig.token.colorLink = antdConfig.token.colorPrimary;
|
||||
@@ -124,11 +135,11 @@ export class Theme {
|
||||
// First phase: Let Ant Design compute the tokens
|
||||
const tokens = Theme.getFilteredAntdTheme(antdConfig);
|
||||
|
||||
// Extract Superset-specific properties from top-level config.
|
||||
// These are custom properties that aren't part of Ant Design's token system
|
||||
// but need to be passed through to the SupersetTheme for ECharts customization.
|
||||
// Extract Superset-specific properties from the merged config (not the raw
|
||||
// config) so a base theme's ECharts overrides survive in-place updates, the
|
||||
// same way the Ant Design tokens above are taken from the merged config.
|
||||
const { echartsOptionsOverrides, echartsOptionsOverridesByChartType } =
|
||||
config as AnyThemeConfig & {
|
||||
mergedConfig as AnyThemeConfig & {
|
||||
echartsOptionsOverrides?: any;
|
||||
echartsOptionsOverridesByChartType?: Record<string, any>;
|
||||
};
|
||||
|
||||
@@ -36,6 +36,7 @@ import type {
|
||||
QueryResponse,
|
||||
TimeFormatter,
|
||||
} from '@superset-ui/core';
|
||||
import { type RGBColor } from '@superset-ui/core/components';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import { sharedControls, sharedControlComponents } from './shared-controls';
|
||||
|
||||
@@ -494,7 +495,7 @@ export type ConditionalFormattingConfig = {
|
||||
targetValueLeft?: number;
|
||||
targetValueRight?: number;
|
||||
column?: string;
|
||||
colorScheme?: string;
|
||||
colorScheme?: RGBColor | string;
|
||||
toAllRow?: boolean;
|
||||
toTextColor?: boolean;
|
||||
useGradient?: boolean;
|
||||
|
||||
@@ -68,6 +68,11 @@ export const D3_FORMAT_OPTIONS: [string, string][] = [
|
||||
'MEMORY_TRANSFER_RATE_BINARY',
|
||||
t('Memory transfer rate in bytes - binary (1024B => 1KiB/s)'),
|
||||
],
|
||||
['NETWORK_THROUGHPUT', t('Network throughput in bits/s (1000000 => 1Mbps)')],
|
||||
[
|
||||
'NETWORK_THROUGHPUT_FROM_BYTES',
|
||||
t('Network throughput in bytes/s as bits (1000000 => 8Mbps)'),
|
||||
],
|
||||
['LENGTH', t('Length in m (12345m => 12.35km)')],
|
||||
['LENGTH_CM_KM', t('Length in cm (12345678cm => 123.46km)')],
|
||||
['LENGTH_CM_M', t('Length in cm (12345cm => 123.45m)')],
|
||||
|
||||
+87
@@ -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 { ensureIsArray, getMetricLabel } from '@superset-ui/core';
|
||||
import type { QueryFormMetric, QueryFormOrderBy } from '@superset-ui/core';
|
||||
|
||||
export interface BuildSortMetricOrderbyConfig {
|
||||
/** The query's already-resolved metrics list. */
|
||||
metrics: QueryFormMetric[];
|
||||
/** The raw `timeseries_limit_metric` form-data value (single or multi). */
|
||||
timeseriesLimitMetric?: QueryFormMetric | QueryFormMetric[] | null;
|
||||
order_desc?: boolean;
|
||||
/**
|
||||
* Falls back to the first selected metric when no sort metric is set.
|
||||
* Charts ported from a legacy viz whose query_obj always had a sort
|
||||
* metric (defaulting to the first one) should set this; charts whose
|
||||
* legacy query_obj left ordering absent without one should not.
|
||||
*/
|
||||
fallbackToFirstMetric?: boolean;
|
||||
/**
|
||||
* When true, only order when `order_desc` is set (matching legacy vizzes
|
||||
* whose query_obj left the result unordered unless the operator asked
|
||||
* for descending). When false, always order (ascending unless
|
||||
* order_desc), matching legacy vizzes that ordered unconditionally.
|
||||
*/
|
||||
orderOnlyWhenDesc?: boolean;
|
||||
}
|
||||
|
||||
export interface SortMetricOrderby {
|
||||
/** `metrics`, with the sort metric appended if it wasn't already selected. */
|
||||
metrics: QueryFormMetric[];
|
||||
orderby: QueryFormOrderBy[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a chart's sort metric and builds the corresponding query_obj
|
||||
* `orderby`, appending the sort metric to `metrics` if it isn't already
|
||||
* selected (so its value is present in the result to sort by). Several
|
||||
* charts ported from the legacy chart-data pipeline share this exact
|
||||
* shape with only the fallback/gating policy differing per their own
|
||||
* legacy `query_obj` behavior -- see `fallbackToFirstMetric` and
|
||||
* `orderOnlyWhenDesc`.
|
||||
*/
|
||||
export function buildSortMetricOrderby({
|
||||
metrics,
|
||||
timeseriesLimitMetric,
|
||||
order_desc: orderDesc,
|
||||
fallbackToFirstMetric = false,
|
||||
orderOnlyWhenDesc = false,
|
||||
}: BuildSortMetricOrderbyConfig): SortMetricOrderby {
|
||||
const sortByMetric =
|
||||
ensureIsArray(timeseriesLimitMetric)[0] ??
|
||||
(fallbackToFirstMetric ? metrics[0] : undefined);
|
||||
|
||||
if (!sortByMetric) {
|
||||
return { metrics, orderby: [] };
|
||||
}
|
||||
|
||||
const sortByLabel = getMetricLabel(sortByMetric);
|
||||
const nextMetrics = metrics.some(
|
||||
metric => getMetricLabel(metric) === sortByLabel,
|
||||
)
|
||||
? metrics
|
||||
: [...metrics, sortByMetric];
|
||||
|
||||
const shouldOrder = orderOnlyWhenDesc ? Boolean(orderDesc) : true;
|
||||
|
||||
return {
|
||||
metrics: nextMetrics,
|
||||
orderby: shouldOrder ? [[sortByMetric, !orderDesc]] : [],
|
||||
};
|
||||
}
|
||||
+41
-5
@@ -19,7 +19,7 @@
|
||||
import memoizeOne from 'memoize-one';
|
||||
import { isString, isBoolean } from 'lodash-es';
|
||||
import { isBlank } from '@apache-superset/core/utils';
|
||||
import { addAlpha, DataRecord } from '@superset-ui/core';
|
||||
import { addAlpha, DataRecord, rgbaToHex } from '@superset-ui/core';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import {
|
||||
ColorFormatters,
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
ConditionalFormattingConfig,
|
||||
MultipleValueComparators,
|
||||
ResolvedColorFormatterResult,
|
||||
ColorSchemeEnum,
|
||||
} from '../types';
|
||||
|
||||
export const round = (num: number, precision = 0) =>
|
||||
@@ -71,6 +72,9 @@ export const getOpacity = (
|
||||
);
|
||||
};
|
||||
|
||||
const isSpecialColor = (value: unknown): value is ColorSchemeEnum =>
|
||||
Object.values(ColorSchemeEnum).includes(value as ColorSchemeEnum);
|
||||
|
||||
export const getColorFunction = (
|
||||
{
|
||||
operator,
|
||||
@@ -270,19 +274,51 @@ export const getColorFunction = (
|
||||
if (compareResult === false) return undefined;
|
||||
const { cutoffValue, extremeValue } = compareResult;
|
||||
|
||||
// If useGradient is explicitly false, return solid color
|
||||
if (useGradient === false) {
|
||||
if (typeof colorScheme === 'string') {
|
||||
if (isSpecialColor(colorScheme)) {
|
||||
return colorScheme;
|
||||
}
|
||||
|
||||
if (
|
||||
useGradient === false ||
|
||||
(useGradient === undefined && colorScheme.length === 9)
|
||||
) {
|
||||
if (alpha === false) {
|
||||
return colorScheme.length === 9
|
||||
? colorScheme.slice(0, 7)
|
||||
: colorScheme;
|
||||
}
|
||||
return colorScheme;
|
||||
}
|
||||
|
||||
const cleanHex =
|
||||
colorScheme.length === 9 ? colorScheme.slice(0, 7) : colorScheme;
|
||||
|
||||
if (alpha === undefined || alpha) {
|
||||
return addAlpha(
|
||||
cleanHex,
|
||||
getOpacity(value, cutoffValue, extremeValue, minOpacity, maxOpacity),
|
||||
);
|
||||
}
|
||||
return colorScheme;
|
||||
}
|
||||
// If useGradient is explicitly false, return solid color
|
||||
if (useGradient === false || useGradient === undefined) {
|
||||
if (alpha === false) {
|
||||
return rgbaToHex({ ...colorScheme, a: 1 });
|
||||
}
|
||||
return rgbaToHex(colorScheme);
|
||||
}
|
||||
|
||||
const baseHexColor = rgbaToHex({ ...colorScheme, a: 1 });
|
||||
// Otherwise apply gradient (default behavior for backward compatibility)
|
||||
if (alpha === undefined || alpha) {
|
||||
return addAlpha(
|
||||
colorScheme,
|
||||
baseHexColor,
|
||||
getOpacity(value, cutoffValue, extremeValue, minOpacity, maxOpacity),
|
||||
);
|
||||
}
|
||||
return colorScheme;
|
||||
return baseHexColor;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -30,3 +30,4 @@ export * from './getTemporalColumns';
|
||||
export * from './displayTimeRelatedControls';
|
||||
export * from './colorControls';
|
||||
export * from './metricColumnFilter';
|
||||
export * from './buildSortMetricOrderby';
|
||||
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 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 { buildSortMetricOrderby } from '../../src';
|
||||
|
||||
test('is a no-op when there is no sort metric and no fallback', () => {
|
||||
const result = buildSortMetricOrderby({
|
||||
metrics: ['sum__num'],
|
||||
timeseriesLimitMetric: undefined,
|
||||
});
|
||||
expect(result).toEqual({ metrics: ['sum__num'], orderby: [] });
|
||||
});
|
||||
|
||||
test('falls back to the first metric when configured to', () => {
|
||||
const result = buildSortMetricOrderby({
|
||||
metrics: ['sum__num', 'avg__num'],
|
||||
timeseriesLimitMetric: undefined,
|
||||
fallbackToFirstMetric: true,
|
||||
});
|
||||
expect(result.metrics).toEqual(['sum__num', 'avg__num']);
|
||||
expect(result.orderby).toEqual([['sum__num', true]]);
|
||||
});
|
||||
|
||||
test('appends the sort metric when it is not already selected', () => {
|
||||
const result = buildSortMetricOrderby({
|
||||
metrics: ['sum__num'],
|
||||
timeseriesLimitMetric: 'count',
|
||||
});
|
||||
expect(result.metrics).toEqual(['sum__num', 'count']);
|
||||
});
|
||||
|
||||
test('does not duplicate the sort metric when already selected', () => {
|
||||
const result = buildSortMetricOrderby({
|
||||
metrics: ['sum__num', 'count'],
|
||||
timeseriesLimitMetric: 'count',
|
||||
});
|
||||
expect(result.metrics).toEqual(['sum__num', 'count']);
|
||||
});
|
||||
|
||||
test('unconditional ordering (orderOnlyWhenDesc: false) always orders, flipping direction', () => {
|
||||
const ascending = buildSortMetricOrderby({
|
||||
metrics: ['sum__num'],
|
||||
timeseriesLimitMetric: 'count',
|
||||
order_desc: false,
|
||||
});
|
||||
expect(ascending.orderby).toEqual([['count', true]]);
|
||||
|
||||
const descending = buildSortMetricOrderby({
|
||||
metrics: ['sum__num'],
|
||||
timeseriesLimitMetric: 'count',
|
||||
order_desc: true,
|
||||
});
|
||||
expect(descending.orderby).toEqual([['count', false]]);
|
||||
});
|
||||
|
||||
test('gated ordering (orderOnlyWhenDesc: true) only orders when order_desc is set', () => {
|
||||
const withoutDesc = buildSortMetricOrderby({
|
||||
metrics: ['sum__num'],
|
||||
timeseriesLimitMetric: 'count',
|
||||
orderOnlyWhenDesc: true,
|
||||
});
|
||||
expect(withoutDesc.metrics).toEqual(['sum__num', 'count']);
|
||||
expect(withoutDesc.orderby).toEqual([]);
|
||||
|
||||
const withDesc = buildSortMetricOrderby({
|
||||
metrics: ['sum__num'],
|
||||
timeseriesLimitMetric: 'count',
|
||||
order_desc: true,
|
||||
orderOnlyWhenDesc: true,
|
||||
});
|
||||
expect(withDesc.orderby).toEqual([['count', false]]);
|
||||
});
|
||||
|
||||
test('resolves a multi-value timeseriesLimitMetric to its first entry', () => {
|
||||
const result = buildSortMetricOrderby({
|
||||
metrics: ['sum__num'],
|
||||
timeseriesLimitMetric: ['count', 'avg__num'],
|
||||
});
|
||||
expect(result.metrics).toEqual(['sum__num', 'count']);
|
||||
expect(result.orderby).toEqual([['count', true]]);
|
||||
});
|
||||
+164
@@ -952,3 +952,167 @@ test('correct column boolean config', () => {
|
||||
expect(colorFormatters[3].getColorFromValue(true)).toEqual('#FF0000FF');
|
||||
expect(colorFormatters[3].getColorFromValue(false)).toEqual('#FF0000FF');
|
||||
});
|
||||
|
||||
test('should return hex color when colorScheme is an RGB object', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.None,
|
||||
colorScheme: { r: 255, g: 128, b: 0, a: 1 },
|
||||
column: 'name',
|
||||
},
|
||||
strValues,
|
||||
);
|
||||
expect(colorFunction('Diana')).toEqual('#ff8000');
|
||||
expect(colorFunction('Carlos')).toEqual('#ff8000');
|
||||
expect(colorFunction('Brian')).toEqual('#ff8000');
|
||||
});
|
||||
|
||||
test('should return token name as-is when colorScheme is a string token', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.None,
|
||||
colorScheme: 'Green',
|
||||
column: 'name',
|
||||
},
|
||||
strValues,
|
||||
);
|
||||
expect(colorFunction('Diana')).toEqual('Green');
|
||||
expect(colorFunction('Carlos')).toEqual('Green');
|
||||
expect(colorFunction('Brian')).toEqual('Green');
|
||||
});
|
||||
|
||||
test('should return solid hex color when useGradient is false or true', () => {
|
||||
const columnConfig = [
|
||||
{
|
||||
operator: Comparator.GreaterThan,
|
||||
targetValue: 50,
|
||||
colorScheme: { r: 0, g: 47, b: 255, a: 1 },
|
||||
column: 'count',
|
||||
useGradient: false,
|
||||
},
|
||||
{
|
||||
operator: Comparator.GreaterThan,
|
||||
targetValue: 50,
|
||||
colorScheme: { r: 255, g: 166, b: 0, a: 1 },
|
||||
column: 'count',
|
||||
useGradient: true,
|
||||
},
|
||||
];
|
||||
const colorFormatters = getColorFormatters(columnConfig, mockData);
|
||||
expect(colorFormatters.length).toEqual(2);
|
||||
|
||||
// First formatter with useGradient: false should return solid color
|
||||
expect(colorFormatters[0].column).toEqual('count');
|
||||
expect(colorFormatters[0].getColorFromValue(100)).toEqual('#002fff');
|
||||
|
||||
// Second formatter with useGradient: true should return gradient color
|
||||
expect(colorFormatters[1].column).toEqual('count');
|
||||
expect(colorFormatters[1].getColorFromValue(100)).toEqual('#ffa600FF');
|
||||
});
|
||||
|
||||
test('should return hex color without alpha for GreaterThan operator with RGB colorScheme', () => {
|
||||
const config = {
|
||||
operator: Comparator.GreaterThan,
|
||||
targetValue: 50,
|
||||
colorScheme: { r: 255, g: 0, b: 0, a: 1 },
|
||||
useGradient: true,
|
||||
};
|
||||
|
||||
const columnValues = [10, 50, 100];
|
||||
|
||||
const alpha = false;
|
||||
const colorFunction = getColorFunction(config, columnValues, alpha);
|
||||
|
||||
expect(colorFunction(100)).toEqual('#ff0000');
|
||||
});
|
||||
|
||||
test('should preserve alpha from colorScheme when useGradient is false', () => {
|
||||
const config = {
|
||||
operator: Comparator.None,
|
||||
colorScheme: { r: 255, g: 0, b: 0, a: 0.5 },
|
||||
useGradient: false,
|
||||
};
|
||||
|
||||
const colorFunction = getColorFunction(config, [10, 20, 30]);
|
||||
const result = colorFunction(20);
|
||||
|
||||
expect(result).not.toBe('#ff0000');
|
||||
expect(result).not.toBe('rgb(255, 0, 0)');
|
||||
});
|
||||
|
||||
test('should force opaque color when useGradient is false but alpha is explicitly false', () => {
|
||||
const config = {
|
||||
operator: Comparator.None,
|
||||
colorScheme: { r: 255, g: 0, b: 0, a: 0.5 },
|
||||
useGradient: false,
|
||||
};
|
||||
|
||||
const colorFunction = getColorFunction(config, [10, 20, 30], false);
|
||||
const result = colorFunction(20);
|
||||
|
||||
expect(result).toBe('#ff0000');
|
||||
});
|
||||
|
||||
test('should return colorScheme as-is when alpha is false and length is 7', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.GreaterThan,
|
||||
targetValue: 50,
|
||||
colorScheme: '#FF0000',
|
||||
useGradient: false,
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
false,
|
||||
);
|
||||
|
||||
expect(colorFunction(100)).toEqual('#FF0000');
|
||||
});
|
||||
|
||||
test('should preserve alpha when alpha is undefined and colorScheme has 9 chars', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.GreaterThan,
|
||||
targetValue: 50,
|
||||
colorScheme: '#FF000080',
|
||||
useGradient: false,
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
|
||||
expect(colorFunction(100)).toEqual('#FF000080');
|
||||
});
|
||||
|
||||
test('should preserve alpha when alpha is true and colorScheme has 9 chars', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.GreaterThan,
|
||||
targetValue: 50,
|
||||
colorScheme: '#FF000080',
|
||||
useGradient: false,
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(colorFunction(100)).toEqual('#FF000080');
|
||||
});
|
||||
|
||||
test('should strip alpha channel when alpha is false and colorScheme has 9 chars', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.GreaterThan,
|
||||
targetValue: 50,
|
||||
colorScheme: '#FF000080',
|
||||
useGradient: false,
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
false,
|
||||
);
|
||||
|
||||
expect(colorFunction(100)).toEqual('#FF0000');
|
||||
expect(colorFunction(100)).toHaveLength(7);
|
||||
});
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
"d3-time": "^3.1.0",
|
||||
"d3-time-format": "^4.1.0",
|
||||
"dayjs": "^1.11.21",
|
||||
"dompurify": "^3.4.12",
|
||||
"dompurify": "^3.4.13",
|
||||
"fetch-retry": "^6.0.0",
|
||||
"handlebars": "^4.7.9",
|
||||
"jed": "^1.1.1",
|
||||
@@ -78,7 +78,7 @@
|
||||
"parse-ms": "^4.0.0",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react-ace": "^14.0.1",
|
||||
"react-draggable": "^4.7.0",
|
||||
"react-draggable": "^4.7.1",
|
||||
"react-error-boundary": "^6.1.2",
|
||||
"react-js-cron": "^6.0.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
@@ -102,7 +102,7 @@
|
||||
"@types/d3-time": "^3.0.4",
|
||||
"@types/d3-time-format": "^4.0.3",
|
||||
"@types/jquery": "^4.0.1",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/lodash": "^4.17.25",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/prop-types": "^15.7.15",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
@@ -123,7 +123,6 @@
|
||||
"@testing-library/user-event": "*",
|
||||
"@types/react": "*",
|
||||
"@types/react-loadable": "*",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@types/tinycolor2": "*",
|
||||
"antd": "^6.0.0",
|
||||
"nanoid": "*",
|
||||
|
||||
@@ -104,30 +104,19 @@ export default class ChartClient {
|
||||
const buildQueryRegistry = getChartBuildQueryRegistry();
|
||||
|
||||
if (metaDataRegistry.has(visType)) {
|
||||
const { useLegacyApi } = metaDataRegistry.get(visType)!;
|
||||
const buildQuery =
|
||||
(await buildQueryRegistry.get(visType)) ?? (() => formData);
|
||||
const requestConfig: RequestConfig = useLegacyApi
|
||||
? {
|
||||
endpoint: '/explore_json/',
|
||||
postPayload: {
|
||||
form_data: buildQuery(formData),
|
||||
},
|
||||
...options,
|
||||
}
|
||||
: {
|
||||
endpoint: '/api/v1/chart/data',
|
||||
jsonPayload: {
|
||||
query_context: buildQuery(formData),
|
||||
},
|
||||
...options,
|
||||
};
|
||||
const requestConfig: RequestConfig = {
|
||||
endpoint: '/api/v1/chart/data',
|
||||
jsonPayload: buildQuery(formData),
|
||||
...options,
|
||||
};
|
||||
|
||||
return this.client
|
||||
.post(requestConfig)
|
||||
.then(response =>
|
||||
Array.isArray(response.json) ? response.json : [response.json],
|
||||
);
|
||||
return this.client.post(requestConfig).then(response => {
|
||||
const { result } = response.json as { result?: QueryData[] };
|
||||
|
||||
return Array.isArray(result) ? result : [response.json as QueryData];
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unknown chart type: ${visType}`));
|
||||
|
||||
+3
-49
@@ -21,7 +21,6 @@ import { render, waitFor, configure, act } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import StatefulChart from './StatefulChart';
|
||||
import getChartControlPanelRegistry from '../registries/ChartControlPanelRegistrySingleton';
|
||||
import getChartMetadataRegistry from '../registries/ChartMetadataRegistrySingleton';
|
||||
import getChartBuildQueryRegistry from '../registries/ChartBuildQueryRegistrySingleton';
|
||||
|
||||
// Configure testing library to use data-test attribute
|
||||
@@ -29,7 +28,6 @@ configure({ testIdAttribute: 'data-test' });
|
||||
|
||||
// Mock the registries
|
||||
jest.mock('../registries/ChartControlPanelRegistrySingleton');
|
||||
jest.mock('../registries/ChartMetadataRegistrySingleton');
|
||||
jest.mock('../registries/ChartBuildQueryRegistrySingleton');
|
||||
jest.mock('../clients/ChartClient');
|
||||
|
||||
@@ -67,12 +65,6 @@ beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Setup default registry mocks
|
||||
jest.mocked(getChartMetadataRegistry).mockReturnValue({
|
||||
get: jest.fn().mockReturnValue({
|
||||
useLegacyApi: false,
|
||||
}),
|
||||
} as unknown as ReturnType<typeof getChartMetadataRegistry>);
|
||||
|
||||
jest.mocked(getChartBuildQueryRegistry).mockReturnValue({
|
||||
get: jest.fn().mockResolvedValue(null),
|
||||
} as unknown as ReturnType<typeof getChartBuildQueryRegistry>);
|
||||
@@ -746,11 +738,10 @@ test('resolves async (202) responses via the injected handleAsyncChartData hook'
|
||||
await waitFor(() => {
|
||||
expect(handleAsyncChartData).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
// Delegates the raw response + job metadata (and useLegacyApi + abort signal)
|
||||
// Delegates the raw response + job metadata (and abort signal)
|
||||
expect(handleAsyncChartData).toHaveBeenCalledWith(
|
||||
{ status: 202 },
|
||||
asyncJob,
|
||||
false,
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
// Chart renders once the async data resolves
|
||||
@@ -798,43 +789,6 @@ test('renders synchronous (200) responses that include a response object', async
|
||||
expect(mockChartClient.client.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('wraps the legacy async body as { result: [body] } for the async handler', async () => {
|
||||
const legacyBody = { job_id: 'j1', channel_id: 'c1', status: 'running' };
|
||||
mockChartClient.client.post.mockResolvedValue({
|
||||
response: { status: 202 } as Response,
|
||||
json: legacyBody,
|
||||
});
|
||||
// Force the legacy API path for this viz type
|
||||
jest.mocked(getChartMetadataRegistry).mockReturnValue({
|
||||
get: jest.fn().mockReturnValue({ useLegacyApi: true }),
|
||||
} as unknown as ReturnType<typeof getChartMetadataRegistry>);
|
||||
const handleAsyncChartData = jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ data: 'legacy result' }]);
|
||||
|
||||
const { getByTestId } = render(
|
||||
<StatefulChart
|
||||
formData={mockFormData}
|
||||
chartType="test_chart"
|
||||
hooks={{ handleAsyncChartData }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(handleAsyncChartData).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
// Legacy body must be wrapped to match the V1 response signature
|
||||
expect(handleAsyncChartData).toHaveBeenCalledWith(
|
||||
{ status: 202 },
|
||||
{ result: [legacyBody] },
|
||||
true,
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('super-chart')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('does not apply a superseded async response over a newer one', async () => {
|
||||
mockChartClient.client.post.mockResolvedValue({
|
||||
response: { status: 202 } as Response,
|
||||
@@ -1024,7 +978,7 @@ test('passes an abort signal to the async handler and aborts it on unmount', asy
|
||||
response: { status: 202 } as Response,
|
||||
json: { job_id: 'j', channel_id: 'c' },
|
||||
});
|
||||
// Typed with a rest param so mock.calls is indexable (the 4th arg is the signal)
|
||||
// Typed with a rest param so mock.calls is indexable (the 3rd arg is the signal)
|
||||
const handleAsyncChartData = jest.fn(
|
||||
(..._args: unknown[]) => new Promise<never>(() => {}), // never resolves
|
||||
);
|
||||
@@ -1040,7 +994,7 @@ test('passes an abort signal to the async handler and aborts it on unmount', asy
|
||||
await waitFor(() => {
|
||||
expect(handleAsyncChartData).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
const signal = handleAsyncChartData.mock.calls[0][3] as AbortSignal;
|
||||
const signal = handleAsyncChartData.mock.calls[0][2] as AbortSignal;
|
||||
expect(signal).toBeInstanceOf(AbortSignal);
|
||||
expect(signal.aborted).toBe(false);
|
||||
|
||||
|
||||
+10
-35
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { isEqual } from 'lodash';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { ParentSize } from '@visx/responsive';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import {
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
import { Loading } from '../../components/Loading';
|
||||
import ChartClient from '../clients/ChartClient';
|
||||
import getChartBuildQueryRegistry from '../registries/ChartBuildQueryRegistrySingleton';
|
||||
import getChartMetadataRegistry from '../registries/ChartMetadataRegistrySingleton';
|
||||
import getChartControlPanelRegistry from '../registries/ChartControlPanelRegistrySingleton';
|
||||
import SuperChart from './SuperChart';
|
||||
|
||||
@@ -281,9 +280,6 @@ export default function StatefulChart(props: StatefulChartProps) {
|
||||
}
|
||||
finalFormData.viz_type = vizType;
|
||||
|
||||
// Get chart metadata
|
||||
const { useLegacyApi } = getChartMetadataRegistry().get(vizType) || {};
|
||||
|
||||
// Build query using the chart's buildQuery function
|
||||
const buildQuery = await getChartBuildQueryRegistry().get(vizType);
|
||||
let queryContext;
|
||||
@@ -295,31 +291,20 @@ export default function StatefulChart(props: StatefulChartProps) {
|
||||
queryContext = buildQueryContext(finalFormData);
|
||||
}
|
||||
|
||||
// Ensure query_context is properly formatted for new API
|
||||
if (!useLegacyApi && !queryContext.queries) {
|
||||
// Ensure query_context is properly formatted for the API
|
||||
if (!queryContext.queries) {
|
||||
queryContext = { queries: [queryContext] };
|
||||
}
|
||||
const endpoint = useLegacyApi ? '/explore_json/' : '/api/v1/chart/data';
|
||||
|
||||
const requestConfig: RequestConfig = {
|
||||
endpoint,
|
||||
endpoint: '/api/v1/chart/data',
|
||||
signal: controller.signal,
|
||||
...(timeout && { timeout: timeout * 1000 }),
|
||||
};
|
||||
|
||||
if (useLegacyApi) {
|
||||
requestConfig.postPayload = {
|
||||
form_data: {
|
||||
...finalFormData,
|
||||
...(force && { force: true }),
|
||||
},
|
||||
};
|
||||
} else {
|
||||
requestConfig.jsonPayload = {
|
||||
jsonPayload: {
|
||||
...queryContext,
|
||||
...(force && { force: true }),
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const clientResponse =
|
||||
await chartClientRef.current!.client.post(requestConfig);
|
||||
@@ -347,18 +332,10 @@ export default function StatefulChart(props: StatefulChartProps) {
|
||||
'the async handler or disable GLOBAL_ASYNC_QUERIES for this chart.',
|
||||
);
|
||||
}
|
||||
// The async handler (handleChartDataResponse) expects the V1 chart data
|
||||
// response signature. The legacy endpoint returns a flat body, so wrap
|
||||
// it as { result: [body] } exactly like legacyChartDataRequest does for
|
||||
// the standard chart path; the V1 body is already correctly shaped.
|
||||
const asyncPayload = useLegacyApi
|
||||
? ({ result: [clientResponse.json] } as JsonObject)
|
||||
: (clientResponse.json as JsonObject);
|
||||
responseData = ensureIsArray(
|
||||
await hooks.handleAsyncChartData(
|
||||
rawResponse,
|
||||
asyncPayload,
|
||||
useLegacyApi,
|
||||
clientResponse.json as JsonObject,
|
||||
controller.signal,
|
||||
),
|
||||
);
|
||||
@@ -374,10 +351,8 @@ export default function StatefulChart(props: StatefulChartProps) {
|
||||
: [clientResponse.json]
|
||||
) as JsonObject[];
|
||||
|
||||
// Handle the nested result structure from the new API
|
||||
responseData = (
|
||||
!useLegacyApi && rows[0]?.result ? rows[0].result : rows
|
||||
) as QueryData[];
|
||||
// Handle the nested result structure from the API
|
||||
responseData = (rows[0]?.result ? rows[0].result : rows) as QueryData[];
|
||||
}
|
||||
|
||||
// Don't pair this request's data with newer props or fire a stale onLoad
|
||||
|
||||
@@ -49,9 +49,14 @@ export type ReactifyProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export interface ReactifyUnmountContext<Props extends object = object> {
|
||||
container?: HTMLDivElement;
|
||||
props: Readonly<Props & ReactifyProps>;
|
||||
}
|
||||
|
||||
// TODO: add more React lifecycle callbacks as needed
|
||||
export type LifeCycleCallbacks = {
|
||||
componentWillUnmount?: () => void;
|
||||
export type LifeCycleCallbacks<Props extends object = object> = {
|
||||
componentWillUnmount?: (this: ReactifyUnmountContext<Props>) => void;
|
||||
};
|
||||
|
||||
export interface RenderFuncType<Props> {
|
||||
@@ -76,7 +81,7 @@ export type ReactifiedComponent<Props> = ForwardRefExoticComponent<
|
||||
// `ReactifiedComponent<Props>` explicitly.
|
||||
export default function reactify<Props extends object>(
|
||||
renderFn: RenderFuncType<Props>,
|
||||
callbacks?: LifeCycleCallbacks,
|
||||
callbacks?: LifeCycleCallbacks<Props>,
|
||||
): ComponentType<Props & ReactifyProps> {
|
||||
const ReactifiedComponent = forwardRef<
|
||||
ReactifiedComponentRef,
|
||||
@@ -89,8 +94,10 @@ export default function reactify<Props extends object>(
|
||||
// assignment only happens for committed renders (safe under Concurrent
|
||||
// Mode) and is in place before the passive unmount effect reads it.
|
||||
const propsRef = useRef(props);
|
||||
const committedContainerRef = useRef<HTMLDivElement>();
|
||||
useLayoutEffect(() => {
|
||||
propsRef.current = props;
|
||||
committedContainerRef.current = containerRef.current ?? undefined;
|
||||
});
|
||||
|
||||
// Expose container via ref for external access
|
||||
@@ -104,6 +111,22 @@ export default function reactify<Props extends object>(
|
||||
[],
|
||||
);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (callbacks?.componentWillUnmount) {
|
||||
// Preserve the legacy `this.props` access pattern and snapshot the
|
||||
// last committed container because React clears refs before passive
|
||||
// effect cleanup runs on unmount.
|
||||
callbacks.componentWillUnmount.call({
|
||||
container: committedContainerRef.current,
|
||||
props: propsRef.current,
|
||||
});
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Execute renderFn on mount and every update (mimics componentDidMount + componentDidUpdate)
|
||||
useEffect(() => {
|
||||
if (containerRef.current) {
|
||||
@@ -118,24 +141,6 @@ export default function reactify<Props extends object>(
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (callbacks?.componentWillUnmount) {
|
||||
// Preserve legacy behavior where `this` was a component instance
|
||||
// exposing `props`. The class version cleared `this.container`
|
||||
// before invoking componentWillUnmount, so mirror that here to
|
||||
// prevent callbacks from touching a DOM node that's being torn
|
||||
// down.
|
||||
callbacks.componentWillUnmount.call({
|
||||
container: undefined,
|
||||
props: propsRef.current,
|
||||
});
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const { id, className } = props;
|
||||
|
||||
return <div ref={containerRef} id={id} className={className} />;
|
||||
|
||||
@@ -40,7 +40,6 @@ export interface ChartMetadataConfig {
|
||||
supportedAnnotationTypes?: string[];
|
||||
thumbnail: string;
|
||||
thumbnailDark?: string;
|
||||
useLegacyApi?: boolean;
|
||||
behaviors?: Behavior[];
|
||||
exampleGallery?: ExampleImage[];
|
||||
tags?: string[];
|
||||
@@ -75,8 +74,6 @@ export default class ChartMetadata {
|
||||
|
||||
thumbnailDark?: string;
|
||||
|
||||
useLegacyApi: boolean;
|
||||
|
||||
behaviors: Behavior[];
|
||||
|
||||
datasourceCount: number;
|
||||
@@ -112,7 +109,6 @@ export default class ChartMetadata {
|
||||
supportedAnnotationTypes = [],
|
||||
thumbnail,
|
||||
thumbnailDark,
|
||||
useLegacyApi = false,
|
||||
behaviors = [],
|
||||
datasourceCount = 1,
|
||||
enableNoResults = true,
|
||||
@@ -144,7 +140,6 @@ export default class ChartMetadata {
|
||||
this.supportedAnnotationTypes = supportedAnnotationTypes;
|
||||
this.thumbnail = thumbnail;
|
||||
this.thumbnailDark = thumbnailDark;
|
||||
this.useLegacyApi = useLegacyApi;
|
||||
this.behaviors = behaviors;
|
||||
this.datasourceCount = datasourceCount;
|
||||
this.enableNoResults = enableNoResults;
|
||||
|
||||
@@ -75,7 +75,6 @@ type Hooks = {
|
||||
handleAsyncChartData?: (
|
||||
response: Response,
|
||||
json: JsonObject,
|
||||
useLegacyApi?: boolean,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<QueryData[]> | QueryData[];
|
||||
} & PlainObject;
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import tinycolor from 'tinycolor2';
|
||||
import { type RGBColor } from '@superset-ui/core/components';
|
||||
|
||||
const rgbRegex = /^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/;
|
||||
export function getContrastingColor(color: string, thresholds = 186) {
|
||||
@@ -120,3 +121,45 @@ export function rgbToHex(red: number, green: number, blue: number) {
|
||||
|
||||
return `#${r}${g}${b}`;
|
||||
}
|
||||
|
||||
export function rgbaToHex(rgb: RGBColor): string {
|
||||
const { r, g, b, a = 1 } = rgb;
|
||||
const clampChannel = (value: number) =>
|
||||
Math.min(255, Math.max(0, Math.round(value)));
|
||||
const clampAlpha = (value: number) => Math.min(1, Math.max(0, value));
|
||||
const toHex = (value: number) => {
|
||||
const hex = value.toString(16);
|
||||
return hex.length === 1 ? `0${hex}` : hex;
|
||||
};
|
||||
const hexColor = `#${toHex(clampChannel(r))}${toHex(clampChannel(g))}${toHex(clampChannel(b))}`;
|
||||
const clampedAlpha = clampAlpha(a);
|
||||
if (clampedAlpha !== 1) {
|
||||
return `${hexColor}${toHex(Math.round(clampedAlpha * 255))}`;
|
||||
}
|
||||
return hexColor;
|
||||
}
|
||||
|
||||
export const forceHexAlpha = (color: string | RGBColor): string => {
|
||||
if (typeof color === 'object' && color !== null) {
|
||||
return rgbaToHex({ ...color, a: 0.6 });
|
||||
}
|
||||
|
||||
let hex = color.startsWith('#') ? color : `#${color}`;
|
||||
|
||||
// Expand shorthand hex (#rgb, #rgba) to full length before appending or
|
||||
// replacing the alpha channel, otherwise the result is not a valid 6- or
|
||||
// 8-digit CSS hex color.
|
||||
if (hex.length === 4 || hex.length === 5) {
|
||||
hex = `#${hex
|
||||
.slice(1)
|
||||
.split('')
|
||||
.map(char => char + char)
|
||||
.join('')}`;
|
||||
}
|
||||
|
||||
if (hex.length === 9) {
|
||||
return `${hex.slice(0, -2)}99`;
|
||||
}
|
||||
|
||||
return `${hex}99`;
|
||||
};
|
||||
|
||||
+11
-9
@@ -34,8 +34,9 @@ export const DropdownButton = ({
|
||||
const { type: buttonType } = rest;
|
||||
// divider implementation for default (non-primary) buttons
|
||||
const defaultBtnCss = css`
|
||||
${(!buttonType || buttonType === 'default') &&
|
||||
`.ant-dropdown-trigger {
|
||||
${
|
||||
(!buttonType || buttonType === 'default') &&
|
||||
`.ant-dropdown-trigger {
|
||||
position: relative;
|
||||
&:before {
|
||||
content: '';
|
||||
@@ -48,7 +49,8 @@ export const DropdownButton = ({
|
||||
.anticon {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}`}
|
||||
}`
|
||||
}
|
||||
`;
|
||||
const button = (
|
||||
<Dropdown.Button
|
||||
@@ -58,13 +60,13 @@ export const DropdownButton = ({
|
||||
defaultBtnCss,
|
||||
css`
|
||||
.ant-btn {
|
||||
height: ${styleConfig?.controlHeight ??
|
||||
theme.buttonControlHeightSM ??
|
||||
30}px;
|
||||
height: ${
|
||||
styleConfig?.controlHeight ?? theme.buttonControlHeightSM ?? 30
|
||||
}px;
|
||||
box-shadow: ${styleConfig?.boxShadow ?? 'none'};
|
||||
font-size: ${styleConfig?.fontSize ??
|
||||
theme.buttonFontSize ??
|
||||
theme.fontSizeSM}px;
|
||||
font-size: ${
|
||||
styleConfig?.fontSize ?? theme.buttonFontSize ?? theme.fontSizeSM
|
||||
}px;
|
||||
font-weight: ${styleConfig?.fontWeight ?? theme.fontWeightStrong};
|
||||
}
|
||||
`,
|
||||
|
||||
+12
-8
@@ -221,18 +221,22 @@ export const DynamicEditableTitle = memo(
|
||||
onPressEnter={handleKeyPress}
|
||||
placeholder={placeholder}
|
||||
css={css`
|
||||
${!canEdit &&
|
||||
`&[disabled] {
|
||||
${
|
||||
!canEdit &&
|
||||
`&[disabled] {
|
||||
cursor: default;
|
||||
}
|
||||
`}
|
||||
`
|
||||
}
|
||||
font-size: ${theme.fontSizeXL}px;
|
||||
transition: auto;
|
||||
${inputWidth &&
|
||||
inputWidth > 0 &&
|
||||
css`
|
||||
width: ${inputWidth}px;
|
||||
`}
|
||||
${
|
||||
inputWidth &&
|
||||
inputWidth > 0 &&
|
||||
css`
|
||||
width: ${inputWidth}px;
|
||||
`
|
||||
}
|
||||
`}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
|
||||
@@ -171,9 +171,11 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
{image && <ImageContainer image={image} size={size} />}
|
||||
<div
|
||||
css={(theme: SupersetTheme) => css`
|
||||
max-width: ${containerSize === 'large'
|
||||
? theme.sizeUnit * 150
|
||||
: theme.sizeUnit * 100}px;
|
||||
max-width: ${
|
||||
containerSize === 'large'
|
||||
? theme.sizeUnit * 150
|
||||
: theme.sizeUnit * 100
|
||||
}px;
|
||||
`}
|
||||
>
|
||||
{title && <Title size={effectiveTextSize}>{title}</Title>}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from '../Button';
|
||||
import { Modal } from './Modal';
|
||||
import type { ModalProps, ModalFuncProps } from './types';
|
||||
@@ -179,3 +180,74 @@ ModalFunctions.args = {
|
||||
maskClosable: true,
|
||||
mask: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Two top-level Modals that are React siblings, not nested inside one
|
||||
* another (e.g. a "View query" modal and a confirmation dialog it can
|
||||
* trigger, like `UnsavedChangesModal`). Ant Design only assigns an
|
||||
* automatically-incremented z-index when a Modal is nested inside another
|
||||
* *currently open* Modal's React tree, so two siblings always fall back to
|
||||
* the same static z-index and are tie-broken by DOM order: whichever
|
||||
* `.ant-modal-wrap` was inserted later paints on top.
|
||||
*
|
||||
* With `destroyOnHidden={false}` (Ant Design's default), a Modal's wrap
|
||||
* node is created once, lazily, on first open, and is never removed or
|
||||
* recreated afterward. So the modal that happens to have been opened
|
||||
* *first ever*, not most recently, keeps winning the DOM-order tiebreak
|
||||
* even after being closed and reopened. Toggle "Reproduce stale DOM order"
|
||||
* off to see the fix: with `destroyOnHidden`, every open recreates the wrap
|
||||
* node at the end of the document, so DOM order (and stacking) always
|
||||
* matches true open-recency and no manual z-index is ever needed.
|
||||
*
|
||||
* To see the bug: click "Open A", close it, then "Open B", then "Open A"
|
||||
* again -- with the toggle on, A renders behind B despite being the modal
|
||||
* that was opened most recently.
|
||||
*/
|
||||
export const SiblingModalStacking = ({
|
||||
reproduceStaleDomOrder,
|
||||
}: {
|
||||
reproduceStaleDomOrder: boolean;
|
||||
}) => {
|
||||
const [showA, setShowA] = useState(false);
|
||||
const [showB, setShowB] = useState(false);
|
||||
return (
|
||||
<div>
|
||||
<Button onClick={() => setShowA(true)} buttonStyle="secondary">
|
||||
Open A
|
||||
</Button>
|
||||
<Button onClick={() => setShowB(true)} buttonStyle="secondary">
|
||||
Open B
|
||||
</Button>
|
||||
<Modal
|
||||
name="modal-a"
|
||||
title="Modal A"
|
||||
show={showA}
|
||||
onHide={() => setShowA(false)}
|
||||
destroyOnHidden={!reproduceStaleDomOrder}
|
||||
>
|
||||
Modal A content
|
||||
</Modal>
|
||||
<Modal
|
||||
name="modal-b"
|
||||
title="Modal B"
|
||||
show={showB}
|
||||
onHide={() => setShowB(false)}
|
||||
destroyOnHidden={!reproduceStaleDomOrder}
|
||||
>
|
||||
Modal B content
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
SiblingModalStacking.args = {
|
||||
reproduceStaleDomOrder: true,
|
||||
};
|
||||
|
||||
SiblingModalStacking.argTypes = {
|
||||
reproduceStaleDomOrder: {
|
||||
control: 'boolean',
|
||||
description:
|
||||
'On: Ant Design default behavior, a modal opened once keeps its DOM position forever (the bug from #42510). Off: destroyOnHidden, DOM order always matches true open-recency (the fix).',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -73,14 +73,16 @@ export const StyledModal = styled(BaseModal)<StyledModalProps>`
|
||||
const closeButtonWidth = theme.sizeUnit * 14;
|
||||
|
||||
return css`
|
||||
${responsive &&
|
||||
css`
|
||||
max-width: ${maxWidth ?? '900px'};
|
||||
padding-left: ${theme.sizeUnit * 3}px;
|
||||
padding-right: ${theme.sizeUnit * 3}px;
|
||||
padding-bottom: 0;
|
||||
top: 0;
|
||||
`}
|
||||
${
|
||||
responsive &&
|
||||
css`
|
||||
max-width: ${maxWidth ?? '900px'};
|
||||
padding-left: ${theme.sizeUnit * 3}px;
|
||||
padding-right: ${theme.sizeUnit * 3}px;
|
||||
padding-bottom: 0;
|
||||
top: 0;
|
||||
`
|
||||
}
|
||||
|
||||
.ant-modal-container {
|
||||
background-color: ${theme.colorBgContainer};
|
||||
@@ -168,40 +170,46 @@ export const StyledModal = styled(BaseModal)<StyledModalProps>`
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
${draggable &&
|
||||
css`
|
||||
.ant-modal-header {
|
||||
padding: 0;
|
||||
${
|
||||
draggable &&
|
||||
css`
|
||||
.ant-modal-header {
|
||||
padding: 0;
|
||||
|
||||
.draggable-trigger {
|
||||
cursor: move;
|
||||
padding: ${theme.sizeUnit * 4}px ${closeButtonWidth}px
|
||||
${theme.sizeUnit * 4}px ${theme.sizeUnit * 4}px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
`}
|
||||
|
||||
${resizable &&
|
||||
css`
|
||||
.resizable {
|
||||
pointer-events: all;
|
||||
|
||||
.resizable-wrapper {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ant-modal-container {
|
||||
height: 100%;
|
||||
|
||||
.ant-modal-body {
|
||||
height: ${hideFooter
|
||||
? `calc(100% - ${MODAL_HEADER_HEIGHT}px)`
|
||||
: `calc(100% - ${MODAL_HEADER_HEIGHT}px - ${MODAL_FOOTER_HEIGHT}px)`};
|
||||
.draggable-trigger {
|
||||
cursor: move;
|
||||
padding: ${theme.sizeUnit * 4}px ${closeButtonWidth}px
|
||||
${theme.sizeUnit * 4}px ${theme.sizeUnit * 4}px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
`}
|
||||
`
|
||||
}
|
||||
|
||||
${
|
||||
resizable &&
|
||||
css`
|
||||
.resizable {
|
||||
pointer-events: all;
|
||||
|
||||
.resizable-wrapper {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ant-modal-container {
|
||||
height: 100%;
|
||||
|
||||
.ant-modal-body {
|
||||
height: ${
|
||||
hideFooter
|
||||
? `calc(100% - ${MODAL_HEADER_HEIGHT}px)`
|
||||
: `calc(100% - ${MODAL_HEADER_HEIGHT}px - ${MODAL_FOOTER_HEIGHT}px)`
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
`;
|
||||
}}
|
||||
`;
|
||||
|
||||
+66
-7
@@ -78,19 +78,78 @@ export function transformLinkUri(uri: string): string {
|
||||
return DANGEROUS_LINK_PROTOCOLS.includes(scheme) ? '' : url;
|
||||
}
|
||||
|
||||
// A hast-util-sanitize attribute definition is either a bare property name
|
||||
// (any value allowed) or a tuple of `[propertyName, ...allowedValues]` (only
|
||||
// the listed values allowed). See hast-util-sanitize's `PropertyDefinition`.
|
||||
type AttributeDefinition = string | readonly [string, ...unknown[]];
|
||||
|
||||
function getAttributeDefinitionKey(
|
||||
definition: AttributeDefinition,
|
||||
): string | undefined {
|
||||
if (typeof definition === 'string') return definition;
|
||||
// htmlSchemaOverrides comes from runtime config and isn't guaranteed to
|
||||
// match the expected shape; a malformed element (e.g. `null`) has no key
|
||||
// rather than crashing the lookup.
|
||||
return Array.isArray(definition) ? definition[0] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge an operator-supplied list of attribute definitions for a tag (or the
|
||||
* `'*'` wildcard) with the corresponding default definitions.
|
||||
*
|
||||
* hast-util-sanitize's `findDefinition` returns only the FIRST definition it
|
||||
* finds for a given property name, so a naive concat leaves whichever list
|
||||
* happens to declare that property first in charge. The default schema
|
||||
* already declares restrictive tuples for some properties (e.g.
|
||||
* `li: [['className', 'task-list-item']]`), so appending an operator's
|
||||
* override after it never took effect. Because a failed allowlist check
|
||||
* returns `[]` rather than `undefined`, hast-util-sanitize's own `'*'`
|
||||
* fallback never kicked in either.
|
||||
*
|
||||
* To fix that, an override definition replaces the default definition for
|
||||
* the same property (by property name) instead of being appended alongside
|
||||
* it.
|
||||
*/
|
||||
function mergeAttributeDefinitions(
|
||||
defaults: readonly AttributeDefinition[],
|
||||
overrides: readonly AttributeDefinition[],
|
||||
): AttributeDefinition[] {
|
||||
const overriddenKeys = new Set(
|
||||
overrides.map(getAttributeDefinitionKey).filter(Boolean),
|
||||
);
|
||||
const remainingDefaults = defaults.filter(
|
||||
definition => !overriddenKeys.has(getAttributeDefinitionKey(definition)),
|
||||
);
|
||||
return [...remainingDefaults, ...overrides];
|
||||
}
|
||||
|
||||
export function getOverrideHtmlSchema(
|
||||
originalSchema: typeof defaultSchema,
|
||||
htmlSchemaOverrides: SafeMarkdownProps['htmlSchemaOverrides'],
|
||||
) {
|
||||
// Merge into a fresh clone: mergeWith mutates its first argument, and the
|
||||
// array customizer concatenates, so merging into the shared defaultSchema
|
||||
// import would progressively widen the sanitization allowlist for every
|
||||
// SafeMarkdown instance app-wide.
|
||||
// Merge into a fresh clone: mergeWith mutates its first argument, so
|
||||
// merging into the shared defaultSchema import would progressively widen
|
||||
// the sanitization allowlist for every SafeMarkdown instance app-wide.
|
||||
const target = cloneDeep(originalSchema);
|
||||
return mergeWith(
|
||||
cloneDeep(originalSchema),
|
||||
target,
|
||||
htmlSchemaOverrides,
|
||||
(objValue, srcValue) =>
|
||||
Array.isArray(objValue) ? objValue.concat(srcValue) : undefined,
|
||||
(objValue, srcValue, _key, object) => {
|
||||
if (!Array.isArray(objValue)) return undefined;
|
||||
// Only the per-tag (and `'*'`) arrays nested under `attributes` hold
|
||||
// property definitions that need dedup-by-key; every other array in
|
||||
// the schema (e.g. `tagNames`, `protocols.href`) is a plain list where
|
||||
// concatenation is the correct merge.
|
||||
if (object === target.attributes) {
|
||||
// htmlSchemaOverrides comes from runtime config and isn't guaranteed
|
||||
// to match the expected shape; fall back to the default definitions
|
||||
// for a tag rather than throwing if an operator supplies something
|
||||
// other than an array of attribute definitions.
|
||||
if (!Array.isArray(srcValue)) return objValue;
|
||||
return mergeAttributeDefinitions(objValue, srcValue);
|
||||
}
|
||||
return objValue.concat(srcValue);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,10 +16,12 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { LabeledValue as AntdLabeledValue } from 'antd/es/select';
|
||||
import {
|
||||
splitWithQuoteEscaping,
|
||||
stripSurroundingQuotes,
|
||||
makeQuoteAwareTokenizer,
|
||||
propertyComparator,
|
||||
} from './utils';
|
||||
|
||||
test('stripSurroundingQuotes removes matching surrounding double quotes', () => {
|
||||
@@ -138,3 +140,47 @@ test('makeQuoteAwareTokenizer detects any separator from the list outside quotes
|
||||
expect(tokenize('a\nb')).toEqual(['a', 'b']);
|
||||
expect(tokenize('"a\nb"')).toEqual(['"a\nb"']);
|
||||
});
|
||||
|
||||
test('propertyComparator sorts string properties lexicographically', () => {
|
||||
const compare = propertyComparator('label');
|
||||
const ten = { label: '10' } as AntdLabeledValue;
|
||||
const two = { label: '2' } as AntdLabeledValue;
|
||||
const hundred = { label: '100' } as AntdLabeledValue;
|
||||
expect([ten, two, hundred].sort(compare)).toEqual([ten, hundred, two]);
|
||||
});
|
||||
|
||||
test('propertyComparator sorts number properties numerically', () => {
|
||||
const compare = propertyComparator('value');
|
||||
const ten = { value: 10 } as unknown as AntdLabeledValue;
|
||||
const two = { value: 2 } as unknown as AntdLabeledValue;
|
||||
const hundred = { value: 100 } as unknown as AntdLabeledValue;
|
||||
expect([ten, two, hundred].sort(compare)).toEqual([two, ten, hundred]);
|
||||
});
|
||||
|
||||
test('propertyComparator sorts bigint properties numerically, not lexicographically', () => {
|
||||
const compare = propertyComparator('value');
|
||||
const ten = { value: 10n } as unknown as AntdLabeledValue;
|
||||
const two = { value: 2n } as unknown as AntdLabeledValue;
|
||||
const hundred = { value: 100n } as unknown as AntdLabeledValue;
|
||||
expect([ten, two, hundred].sort(compare)).toEqual([two, ten, hundred]);
|
||||
});
|
||||
|
||||
test('propertyComparator sorts diverging-digit-length bigint values numerically', () => {
|
||||
const compare = propertyComparator('value');
|
||||
const a = { value: 10000000000000002n } as unknown as AntdLabeledValue;
|
||||
const b = { value: 9000000000000001n } as unknown as AntdLabeledValue;
|
||||
const c = { value: 10000000000000001n } as unknown as AntdLabeledValue;
|
||||
expect([a, b, c].sort(compare)).toEqual([b, c, a]);
|
||||
});
|
||||
|
||||
test('propertyComparator sorts mixed bigint and number properties numerically', () => {
|
||||
const compare = propertyComparator('value');
|
||||
const bigTen = { value: 10n } as unknown as AntdLabeledValue;
|
||||
const two = { value: 2 } as unknown as AntdLabeledValue;
|
||||
const bigHundred = { value: 100n } as unknown as AntdLabeledValue;
|
||||
expect([bigTen, two, bigHundred].sort(compare)).toEqual([
|
||||
two,
|
||||
bigTen,
|
||||
bigHundred,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -79,7 +79,7 @@ export function hasOption(
|
||||
|
||||
/**
|
||||
* It creates a comparator to check for a specific property.
|
||||
* Can be used with string and number property values.
|
||||
* Can be used with string, number, and bigint property values.
|
||||
* */
|
||||
export const propertyComparator =
|
||||
(property: string) => (a: AntdLabeledValue, b: AntdLabeledValue) => {
|
||||
@@ -91,6 +91,18 @@ export const propertyComparator =
|
||||
if (typeof propertyA === 'number' && typeof propertyB === 'number') {
|
||||
return propertyA - propertyB;
|
||||
}
|
||||
// BIGINT columns can decode to native `bigint` values (see json-bigint
|
||||
// parsing of large numeric values). Compare numerically rather than
|
||||
// falling through to the string fallback below, which would sort them
|
||||
// lexicographically (e.g. "10", "100", "2").
|
||||
if (
|
||||
(typeof propertyA === 'bigint' || typeof propertyA === 'number') &&
|
||||
(typeof propertyB === 'bigint' || typeof propertyB === 'number')
|
||||
) {
|
||||
if (propertyA < propertyB) return -1;
|
||||
if (propertyA > propertyB) return 1;
|
||||
return 0;
|
||||
}
|
||||
return String(propertyA).localeCompare(String(propertyB)); // fallback to string comparison
|
||||
};
|
||||
|
||||
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* 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, waitFor } from '@superset-ui/core/spec';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { Table } from './index';
|
||||
|
||||
// These tests exercise VirtualTable's react-window v2 `Grid` wiring
|
||||
// (`cellComponent`/`cellProps`/`gridRef`), which previously had no direct
|
||||
// coverage - `Table.test.tsx` only exercises the non-virtualized code path.
|
||||
|
||||
interface BasicData {
|
||||
columnName: string;
|
||||
columnType: string;
|
||||
}
|
||||
|
||||
const testData: BasicData[] = [
|
||||
{ columnName: 'Number', columnType: 'Numerical' },
|
||||
{ columnName: 'String', columnType: 'Physical' },
|
||||
{ columnName: 'Date', columnType: 'Virtual' },
|
||||
];
|
||||
|
||||
const testColumns: ColumnsType<BasicData> = [
|
||||
{
|
||||
title: 'Column Name',
|
||||
dataIndex: 'columnName',
|
||||
key: 'columnName',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: 'Column Type',
|
||||
dataIndex: 'columnType',
|
||||
key: 'columnType',
|
||||
width: 150,
|
||||
},
|
||||
];
|
||||
|
||||
test('virtualized table renders headers and row content through the react-window Grid', async () => {
|
||||
render(
|
||||
<Table
|
||||
columns={testColumns}
|
||||
data={testData}
|
||||
virtualize
|
||||
height={200}
|
||||
usePagination={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
testColumns.forEach(column =>
|
||||
expect(
|
||||
screen
|
||||
.getAllByText(column.title as string)
|
||||
.find(el => el.closest('th')),
|
||||
).toBeInTheDocument(),
|
||||
),
|
||||
);
|
||||
|
||||
testData.forEach(row => {
|
||||
expect(screen.getByText(row.columnName)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('virtualized table cells keep the DOM hooks other code (cypress, downloadAsImage) relies on', async () => {
|
||||
const { container } = render(
|
||||
<Table
|
||||
columns={testColumns}
|
||||
data={testData}
|
||||
virtualize
|
||||
height={200}
|
||||
usePagination={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('.virtual-grid')).toBeInTheDocument();
|
||||
expect(
|
||||
container.querySelectorAll('.virtual-table-cell').length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('cell render functions receive their row data via cellProps rather than a stale closure', async () => {
|
||||
const columnsWithRender: ColumnsType<BasicData> = [
|
||||
{
|
||||
title: 'Column Name',
|
||||
dataIndex: 'columnName',
|
||||
key: 'columnName',
|
||||
width: 150,
|
||||
render: (value: string) => `rendered:${value}`,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<Table
|
||||
columns={columnsWithRender}
|
||||
data={testData}
|
||||
virtualize
|
||||
height={200}
|
||||
usePagination={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('rendered:Number')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -24,10 +24,14 @@ import {
|
||||
} from 'antd/es/table';
|
||||
import classNames from 'classnames';
|
||||
import { useResizeDetector } from 'react-resize-detector';
|
||||
import { useEffect, useRef, useState, useCallback, CSSProperties } from 'react';
|
||||
import { VariableSizeGrid as Grid } from 'react-window';
|
||||
import { useRef, useState, useCallback, type UIEvent } from 'react';
|
||||
import {
|
||||
Grid,
|
||||
type CellComponentProps,
|
||||
type GridImperativeAPI,
|
||||
} from 'react-window';
|
||||
import { safeHtmlSpan } from '@superset-ui/core';
|
||||
import { useTheme, styled } from '@apache-superset/core/theme';
|
||||
import { useTheme, styled, SupersetTheme } from '@apache-superset/core/theme';
|
||||
|
||||
import { TableSize, ETableAction } from './index';
|
||||
|
||||
@@ -71,6 +75,59 @@ const StyledTable = styled(AntTable)(
|
||||
const SMALL = 39;
|
||||
const MIDDLE = 47;
|
||||
|
||||
interface VirtualGridCellProps {
|
||||
mergedColumns: AntTableProps<any>['columns'];
|
||||
rawData: readonly object[];
|
||||
cellSize: number;
|
||||
allowHTML: boolean;
|
||||
theme: SupersetTheme;
|
||||
}
|
||||
|
||||
// Rendered via `cellComponent`, so it must be a stable reference (module scope)
|
||||
// rather than defined inline on every render of the enclosing table -
|
||||
// otherwise react-window would treat it as a new component type each render
|
||||
// and remount every cell. All the data it needs is threaded through
|
||||
// `cellProps` instead of being closed over.
|
||||
const VirtualGridCell = ({
|
||||
columnIndex,
|
||||
rowIndex,
|
||||
style,
|
||||
mergedColumns,
|
||||
rawData,
|
||||
cellSize,
|
||||
allowHTML,
|
||||
theme,
|
||||
}: CellComponentProps<VirtualGridCellProps>) => {
|
||||
const data: any = rawData?.[rowIndex];
|
||||
// Set default content
|
||||
let content = data?.[(mergedColumns as any)?.[columnIndex]?.dataIndex];
|
||||
// Check if the column has a render function
|
||||
const render = mergedColumns?.[columnIndex]?.render;
|
||||
if (typeof render === 'function') {
|
||||
// Use render function to generate formatted content using column's render function
|
||||
content = render(content, data, rowIndex);
|
||||
}
|
||||
|
||||
if (allowHTML && typeof content === 'string') {
|
||||
content = safeHtmlSpan(content);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledCell
|
||||
className={classNames('virtual-table-cell', {
|
||||
'virtual-table-cell-last':
|
||||
columnIndex === (mergedColumns?.length ?? 0) - 1,
|
||||
})}
|
||||
style={style}
|
||||
title={typeof content === 'string' ? content : undefined}
|
||||
theme={theme}
|
||||
height={cellSize}
|
||||
>
|
||||
{content}
|
||||
</StyledCell>
|
||||
);
|
||||
};
|
||||
|
||||
const VirtualTable = <RecordType extends object>(
|
||||
props: VirtualTableProps<RecordType>,
|
||||
) => {
|
||||
@@ -126,19 +183,15 @@ const VirtualTable = <RecordType extends object>(
|
||||
(lastColumn.width as number) + Math.floor(tableWidth - totalWidth);
|
||||
}
|
||||
|
||||
const gridRef = useRef<any>();
|
||||
const gridRef = useRef<GridImperativeAPI>(null);
|
||||
const [connectObject] = useState<any>(() => {
|
||||
const obj = {};
|
||||
Object.defineProperty(obj, 'scrollLeft', {
|
||||
get: () => {
|
||||
if (gridRef.current) {
|
||||
return gridRef.current?.state?.scrollLeft;
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
get: () => gridRef.current?.element?.scrollLeft ?? 0,
|
||||
set: (scrollLeft: number) => {
|
||||
if (gridRef.current) {
|
||||
gridRef.current.scrollTo({ scrollLeft });
|
||||
const element = gridRef.current?.element;
|
||||
if (element) {
|
||||
element.scrollLeft = scrollLeft;
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -146,14 +199,11 @@ const VirtualTable = <RecordType extends object>(
|
||||
return obj;
|
||||
});
|
||||
|
||||
const resetVirtualGrid = () => {
|
||||
gridRef.current?.resetAfterIndices({
|
||||
columnIndex: 0,
|
||||
shouldForceUpdate: true,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => resetVirtualGrid, [tableWidth, columns, size]);
|
||||
// No manual cache-reset is needed here (react-window v2 has no
|
||||
// `resetAfterIndices`-style API): `columnWidth` below is a fresh inline
|
||||
// closure over `mergedColumns` on every render, so react-window's internal
|
||||
// size cache - which is invalidated whenever the `columnWidth`/`rowHeight`
|
||||
// function reference changes - recomputes automatically.
|
||||
|
||||
/*
|
||||
* antd Table has a runtime error when it tries to fire the onChange event triggered from a pageChange
|
||||
@@ -166,7 +216,10 @@ const VirtualTable = <RecordType extends object>(
|
||||
* We intentionally leave horizontal scroll where it was so user can focus on
|
||||
* specific range of columns as they page through data
|
||||
*/
|
||||
gridRef.current?.scrollTo?.({ scrollTop: 0 });
|
||||
const element = gridRef.current?.element;
|
||||
if (element) {
|
||||
element.scrollTop = 0;
|
||||
}
|
||||
|
||||
onChange?.(
|
||||
{
|
||||
@@ -192,61 +245,31 @@ const VirtualTable = <RecordType extends object>(
|
||||
const cellSize = size === TableSize.Middle ? MIDDLE : SMALL;
|
||||
return (
|
||||
<Grid
|
||||
ref={gridRef}
|
||||
gridRef={gridRef}
|
||||
className="virtual-grid"
|
||||
columnCount={mergedColumns.length}
|
||||
columnWidth={(index: number) => {
|
||||
const { width = DEFAULT_COL_WIDTH } = mergedColumns[index];
|
||||
return width as number;
|
||||
}}
|
||||
height={height || (scroll!.y as number)}
|
||||
rowCount={rawData.length}
|
||||
rowHeight={() => cellSize}
|
||||
width={tableWidth}
|
||||
onScroll={({ scrollLeft }: { scrollLeft: number }) => {
|
||||
onScroll({ scrollLeft });
|
||||
style={{
|
||||
height: height || (scroll!.y as number),
|
||||
width: tableWidth,
|
||||
}}
|
||||
>
|
||||
{({
|
||||
columnIndex,
|
||||
rowIndex,
|
||||
style,
|
||||
}: {
|
||||
columnIndex: number;
|
||||
rowIndex: number;
|
||||
style: CSSProperties;
|
||||
}) => {
|
||||
const data: any = rawData?.[rowIndex];
|
||||
// Set default content
|
||||
let content =
|
||||
data?.[(mergedColumns as any)?.[columnIndex]?.dataIndex];
|
||||
// Check if the column has a render function
|
||||
const render = mergedColumns[columnIndex]?.render;
|
||||
if (typeof render === 'function') {
|
||||
// Use render function to generate formatted content using column's render function
|
||||
content = render(content, data, rowIndex);
|
||||
}
|
||||
|
||||
if (allowHTML && typeof content === 'string') {
|
||||
content = safeHtmlSpan(content);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledCell
|
||||
className={classNames('virtual-table-cell', {
|
||||
'virtual-table-cell-last':
|
||||
columnIndex === mergedColumns.length - 1,
|
||||
})}
|
||||
style={style}
|
||||
title={typeof content === 'string' ? content : undefined}
|
||||
theme={theme}
|
||||
height={cellSize}
|
||||
>
|
||||
{content}
|
||||
</StyledCell>
|
||||
);
|
||||
cellComponent={VirtualGridCell}
|
||||
cellProps={{
|
||||
mergedColumns,
|
||||
rawData,
|
||||
cellSize,
|
||||
allowHTML,
|
||||
theme,
|
||||
}}
|
||||
</Grid>
|
||||
onScroll={(event: UIEvent<HTMLDivElement>) => {
|
||||
onScroll({ scrollLeft: event.currentTarget.scrollLeft });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -51,8 +51,10 @@ const StyledTabs = ({
|
||||
.ant-tabs-body-holder {
|
||||
overflow: ${allowOverflow ? 'visible' : 'auto'};
|
||||
${fullHeight && 'height: 100%;'}
|
||||
${contentHeight &&
|
||||
`height: ${typeof contentHeight === 'number' ? `${contentHeight}px` : contentHeight};`}
|
||||
${
|
||||
contentHeight &&
|
||||
`height: ${typeof contentHeight === 'number' ? `${contentHeight}px` : contentHeight};`
|
||||
}
|
||||
${contentPadding}
|
||||
}
|
||||
.ant-tabs-body {
|
||||
@@ -66,9 +68,11 @@ const StyledTabs = ({
|
||||
margin: 0;
|
||||
}
|
||||
.ant-tabs-nav-wrap {
|
||||
${!(tabBarStyle && 'paddingLeft' in tabBarStyle)
|
||||
? `padding: 0 ${theme.sizeUnit * 4}px;`
|
||||
: ''}
|
||||
${
|
||||
!(tabBarStyle && 'paddingLeft' in tabBarStyle)
|
||||
? `padding: 0 ${theme.sizeUnit * 4}px;`
|
||||
: ''
|
||||
}
|
||||
}
|
||||
.ant-tabs-tab {
|
||||
flex: 1 1 auto;
|
||||
|
||||
+179
-1
@@ -16,7 +16,15 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen, userEvent } from '@superset-ui/core/spec';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@superset-ui/core/spec';
|
||||
import { Modal, RawAntdTooltip } from '@superset-ui/core/components';
|
||||
import { UnsavedChangesModal } from '.';
|
||||
|
||||
test('should render nothing if showModal is false', () => {
|
||||
@@ -94,3 +102,173 @@ test('should only call handleSave when clicking the Save button', async () => {
|
||||
expect(mockOnHide).not.toHaveBeenCalled();
|
||||
expect(mockOnConfirmNavigation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Regression coverage for the underlying bug (#42510): this modal could
|
||||
// render BEHIND another already-open modal (e.g. a draggable "View query"
|
||||
// modal). Two plain top-level Modal siblings (neither nested inside the
|
||||
// other's React tree) fall back to the same static z-index, tie-broken by
|
||||
// DOM order: whichever `.ant-modal-wrap` comes later in the document paints
|
||||
// on top -- `destroyOnHidden` is what makes every open recreate this
|
||||
// modal's wrap fresh at the end of the document, so it wins that tie. But
|
||||
// the real #42510 repro isn't actually a tie: "View query" renders as a
|
||||
// dropdown menu item's label, and Ant Design's Menu.Item wraps every item's
|
||||
// content in a Tooltip (even one that never opens), which hands its
|
||||
// children a real elevated z-index via React context. That's why this
|
||||
// modal also sets an explicit `zIndex` -- comfortably above what that
|
||||
// inherited context can produce -- rather than relying on DOM order alone.
|
||||
function dialogWrap(titleText: string) {
|
||||
const dialogs = screen.queryAllByRole('dialog');
|
||||
// rc-util's `useId` hook always returns the same mocked id ("test-id") in
|
||||
// test environments, so with two dialogs open at once their
|
||||
// `aria-labelledby` ids collide and `getByRole('dialog', { name })` can't
|
||||
// tell them apart. Find each by its title text instead.
|
||||
const dialog = dialogs.find(d => within(d).queryByText(titleText));
|
||||
return dialog?.closest<HTMLElement>('.ant-modal-wrap') ?? null;
|
||||
}
|
||||
|
||||
test('renders above an already-open modal that also has no elevated z-index', async () => {
|
||||
render(
|
||||
<>
|
||||
<Modal show title="Other open modal" onHide={() => {}}>
|
||||
<div>Other modal content</div>
|
||||
</Modal>
|
||||
<UnsavedChangesModal
|
||||
showModal
|
||||
onHide={() => {}}
|
||||
handleSave={() => {}}
|
||||
onConfirmNavigation={() => {}}
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
|
||||
const otherWrap = await waitFor(() => {
|
||||
const wrap = dialogWrap('Other open modal');
|
||||
expect(wrap).not.toBeNull();
|
||||
return wrap as HTMLElement;
|
||||
});
|
||||
const unsavedChangesWrap = await waitFor(() => {
|
||||
const wrap = dialogWrap('Unsaved Changes');
|
||||
expect(wrap).not.toBeNull();
|
||||
return wrap as HTMLElement;
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-bitwise
|
||||
expect(
|
||||
otherWrap.compareDocumentPosition(unsavedChangesWrap) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
// This is the actual #42510 repro, not just a tied-sibling stand-in: "View
|
||||
// query" is rendered as a dropdown menu item's label, so Ant Design's
|
||||
// Menu.Item silently wraps it in a Tooltip (title/open both stay falsy, it
|
||||
// never visibly opens) purely for its own ellipsis-title behavior. That
|
||||
// Tooltip still supplies a real, elevated z-index to its children via
|
||||
// context, so the modal nested inside it doesn't tie with a plain top-level
|
||||
// modal the way the previous test's "Other open modal" does -- DOM order
|
||||
// can't be the tie-breaker for two z-indexes that were never equal.
|
||||
test('renders above a modal nested in a menu item Tooltip wrapper, which gets a real elevated z-index', async () => {
|
||||
render(
|
||||
<>
|
||||
<RawAntdTooltip title={null} open={false}>
|
||||
<Modal show title="View query" onHide={() => {}}>
|
||||
<div>query body</div>
|
||||
</Modal>
|
||||
</RawAntdTooltip>
|
||||
<UnsavedChangesModal
|
||||
showModal
|
||||
onHide={() => {}}
|
||||
handleSave={() => {}}
|
||||
onConfirmNavigation={() => {}}
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
|
||||
const viewQueryWrap = await waitFor(() => {
|
||||
const wrap = dialogWrap('View query');
|
||||
expect(wrap).not.toBeNull();
|
||||
return wrap as HTMLElement;
|
||||
});
|
||||
const unsavedChangesWrap = await waitFor(() => {
|
||||
const wrap = dialogWrap('Unsaved Changes');
|
||||
expect(wrap).not.toBeNull();
|
||||
return wrap as HTMLElement;
|
||||
});
|
||||
|
||||
// The Tooltip wrapper does give "View query" a real inline z-index above
|
||||
// the base -- confirming this test actually exercises an elevated,
|
||||
// non-tied sibling rather than accidentally falling back to the tied
|
||||
// case the previous test already covers.
|
||||
expect(Number(viewQueryWrap.style.zIndex)).toBeGreaterThan(0);
|
||||
expect(Number(unsavedChangesWrap.style.zIndex)).toBeGreaterThan(
|
||||
Number(viewQueryWrap.style.zIndex),
|
||||
);
|
||||
});
|
||||
|
||||
test('still renders on top after being opened, closed, and reopened once the other modal is already open', async () => {
|
||||
function Harness() {
|
||||
const [showOther, setShowOther] = useState(false);
|
||||
const [showUnsaved, setShowUnsaved] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setShowOther(true)}>
|
||||
open other
|
||||
</button>
|
||||
<button type="button" onClick={() => setShowUnsaved(true)}>
|
||||
open unsaved
|
||||
</button>
|
||||
<Modal
|
||||
show={showOther}
|
||||
title="Other open modal"
|
||||
onHide={() => setShowOther(false)}
|
||||
>
|
||||
<div>Other modal content</div>
|
||||
</Modal>
|
||||
<UnsavedChangesModal
|
||||
showModal={showUnsaved}
|
||||
onHide={() => setShowUnsaved(false)}
|
||||
handleSave={() => {}}
|
||||
// Mirrors real callers: confirming navigation is what dismisses
|
||||
// this modal, not `onHide` directly (see the Discard-button test
|
||||
// above -- clicking Discard never calls `onHide` on its own).
|
||||
onConfirmNavigation={() => setShowUnsaved(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
render(<Harness />);
|
||||
|
||||
// Open this modal once -- e.g. some other in-app action tripped it --
|
||||
// before the modal it's supposed to interrupt has ever been opened. Its
|
||||
// wrap node gets created now, first in the document.
|
||||
userEvent.click(screen.getByText('open unsaved'));
|
||||
await waitFor(() => expect(dialogWrap('Unsaved Changes')).not.toBeNull());
|
||||
userEvent.click(await screen.findByRole('button', { name: /discard/i }));
|
||||
await waitFor(() => expect(dialogWrap('Unsaved Changes')).toBeNull());
|
||||
|
||||
// Now open the modal it's meant to interrupt for the first time.
|
||||
userEvent.click(screen.getByText('open other'));
|
||||
const otherWrap = await waitFor(() => {
|
||||
const wrap = dialogWrap('Other open modal');
|
||||
expect(wrap).not.toBeNull();
|
||||
return wrap as HTMLElement;
|
||||
});
|
||||
|
||||
// Reopen this modal -- the real scenario the bug report describes. If its
|
||||
// wrap node were still the one created on the first open above, it would
|
||||
// be stuck earlier in the document than `otherWrap` and render behind it
|
||||
// again.
|
||||
userEvent.click(screen.getByText('open unsaved'));
|
||||
const unsavedChangesWrap = await waitFor(() => {
|
||||
const wrap = dialogWrap('Unsaved Changes');
|
||||
expect(wrap).not.toBeNull();
|
||||
return wrap as HTMLElement;
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-bitwise
|
||||
expect(
|
||||
otherWrap.compareDocumentPosition(unsavedChangesWrap) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
+59
-34
@@ -17,13 +17,10 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { useTheme } from '@apache-superset/core/theme';
|
||||
import { Icons, Modal, Typography, Button } from '@superset-ui/core/components';
|
||||
import type { FC, ReactElement } from 'react';
|
||||
|
||||
// Ant Design's default modal zIndex is 1000. Using a higher value ensures
|
||||
// this dialog always renders above other open modals (e.g. a draggable View SQL modal).
|
||||
const UNSAVED_CHANGES_MODAL_Z_INDEX = 1300;
|
||||
|
||||
export type UnsavedChangesModalProps = {
|
||||
showModal: boolean;
|
||||
onHide: () => void;
|
||||
@@ -31,7 +28,6 @@ export type UnsavedChangesModalProps = {
|
||||
onConfirmNavigation: () => void;
|
||||
title?: string;
|
||||
body?: string;
|
||||
zIndex?: number;
|
||||
};
|
||||
|
||||
export const UnsavedChangesModal: FC<UnsavedChangesModalProps> = ({
|
||||
@@ -41,32 +37,61 @@ export const UnsavedChangesModal: FC<UnsavedChangesModalProps> = ({
|
||||
onConfirmNavigation,
|
||||
title = 'Unsaved Changes',
|
||||
body = "If you don't save, changes will be lost.",
|
||||
zIndex = UNSAVED_CHANGES_MODAL_Z_INDEX,
|
||||
}: UnsavedChangesModalProps): ReactElement => (
|
||||
<Modal
|
||||
centered
|
||||
responsive
|
||||
onHide={onHide}
|
||||
show={showModal}
|
||||
width="444px"
|
||||
zIndex={zIndex}
|
||||
title={
|
||||
<>
|
||||
<Icons.WarningOutlined iconSize="m" style={{ marginRight: 8 }} />
|
||||
{title}
|
||||
</>
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<Button buttonStyle="secondary" onClick={onConfirmNavigation}>
|
||||
{t('Discard')}
|
||||
</Button>
|
||||
<Button buttonStyle="primary" onClick={handleSave}>
|
||||
{t('Save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Typography.Text>{body}</Typography.Text>
|
||||
</Modal>
|
||||
);
|
||||
}: UnsavedChangesModalProps): ReactElement => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Modal
|
||||
centered
|
||||
responsive
|
||||
onHide={onHide}
|
||||
show={showModal}
|
||||
width="444px"
|
||||
// This modal always interrupts something already on screen (a
|
||||
// draggable "View query" modal, an in-progress form, etc). Ant
|
||||
// Design only assigns a higher z-index automatically when a Modal is
|
||||
// nested inside another *currently open Modal's* React tree. This
|
||||
// one is always a top-level sibling of whatever it interrupts, so on
|
||||
// its own it would fall back to the same static base z-index -- BUT
|
||||
// the modal it's interrupting isn't always a plain top-level sibling
|
||||
// itself: "View query" is rendered as a dropdown menu item's label,
|
||||
// and Ant Design's Menu.Item silently wraps every item's content in
|
||||
// a Tooltip (even when that tooltip never opens), which supplies a
|
||||
// real ZIndexContext to its children. That gives the nested "View
|
||||
// query" Modal a genuinely higher, non-tied z-index (theme's popup
|
||||
// base plus ~200) than this modal's plain base value, so DOM order
|
||||
// alone (destroyOnHidden below) can't win the tie -- there isn't
|
||||
// one. An explicit zIndex, comfortably above any such context-fed
|
||||
// value, guarantees this modal isn't shadowed by a sibling that
|
||||
// happens to inherit an elevated stacking context.
|
||||
zIndex={theme.zIndexPopupBase + 1000}
|
||||
// Without destroyOnHidden, a Modal's portal node is created once
|
||||
// (lazily, on first open) and then left in place forever, so if this
|
||||
// dialog is ever opened once before whatever it's interrupting is
|
||||
// opened, a later reopen would go right back to that stale,
|
||||
// now-too-early DOM position. destroyOnHidden tears the portal down
|
||||
// on every close so every open recreates it fresh at the end of the
|
||||
// DOM, keeping DOM order (the tie-breaker for any modals that
|
||||
// genuinely do share this one's base z-index) tracking true
|
||||
// open-recency.
|
||||
destroyOnHidden
|
||||
title={
|
||||
<>
|
||||
<Icons.WarningOutlined iconSize="m" style={{ marginRight: 8 }} />
|
||||
{title}
|
||||
</>
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<Button buttonStyle="secondary" onClick={onConfirmNavigation}>
|
||||
{t('Discard')}
|
||||
</Button>
|
||||
<Button buttonStyle="primary" onClick={handleSave}>
|
||||
{t('Save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Typography.Text>{body}</Typography.Text>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* 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 NumberFormatter from '../NumberFormatter';
|
||||
import { NumberFormatFunction } from '../types';
|
||||
|
||||
const BITS_PER_BYTE = 8;
|
||||
const BASE = 1000;
|
||||
const SUFFIXES = [
|
||||
'bps',
|
||||
'kbps',
|
||||
'Mbps',
|
||||
'Gbps',
|
||||
'Tbps',
|
||||
'Pbps',
|
||||
'Ebps',
|
||||
'Zbps',
|
||||
'Ybps',
|
||||
'Rbps',
|
||||
'Qbps',
|
||||
];
|
||||
|
||||
function formatThroughput(
|
||||
decimals: number,
|
||||
fromBytes: boolean,
|
||||
): NumberFormatFunction {
|
||||
return value => {
|
||||
if (value === 0) {
|
||||
return `0${SUFFIXES[0]}`;
|
||||
}
|
||||
|
||||
const sign = value > 0 ? '' : '-';
|
||||
const magnitude = Math.abs(value);
|
||||
const bits = fromBytes ? magnitude * BITS_PER_BYTE : magnitude;
|
||||
let i = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
SUFFIXES.length - 1,
|
||||
Math.floor(Math.log(bits) / Math.log(BASE)),
|
||||
),
|
||||
);
|
||||
let scaled = parseFloat((bits / Math.pow(BASE, i)).toFixed(decimals));
|
||||
|
||||
if (scaled >= BASE && i < SUFFIXES.length - 1) {
|
||||
i += 1;
|
||||
scaled = parseFloat((bits / Math.pow(BASE, i)).toFixed(decimals));
|
||||
}
|
||||
|
||||
return `${sign}${scaled}${SUFFIXES[i]}`;
|
||||
};
|
||||
}
|
||||
|
||||
export default function createThroughputFormatter(
|
||||
config: {
|
||||
description?: string;
|
||||
id?: string;
|
||||
label?: string;
|
||||
decimals?: number;
|
||||
fromBytes?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const { description, id, label, decimals = 2, fromBytes = false } = config;
|
||||
|
||||
return new NumberFormatter({
|
||||
description,
|
||||
formatFunc: formatThroughput(decimals, fromBytes),
|
||||
id: id ?? 'throughput_format',
|
||||
label: label ?? `Throughput formatter`,
|
||||
});
|
||||
}
|
||||
@@ -36,3 +36,4 @@ export { default as createSiAtMostNDigitFormatter } from './factories/createSiAt
|
||||
export { default as createSmartNumberFormatter } from './factories/createSmartNumberFormatter';
|
||||
export { default as getSmallNumberFormatter } from './getSmallNumberFormatter';
|
||||
export { default as createLengthFormatter } from './factories/createLengthFormatter';
|
||||
export { default as createThroughputFormatter } from './factories/createThroughputFormatter';
|
||||
|
||||
@@ -54,7 +54,12 @@ class LRUCache<T> {
|
||||
if (typeof key !== 'string') {
|
||||
throw new TypeError('The LRUCache key must be string.');
|
||||
}
|
||||
if (this.cache.size >= this.capacity) {
|
||||
if (this.cache.has(key)) {
|
||||
// Overwriting an existing key must not evict anything: the entry count is
|
||||
// unchanged. Deleting first also refreshes the key's recency, since
|
||||
// Map#set keeps the original insertion position for existing keys.
|
||||
this.cache.delete(key);
|
||||
} else if (this.cache.size >= this.capacity) {
|
||||
// Forward-compat: TS 6.0 types IteratorResult.value as `string | undefined`
|
||||
// when not explicitly checked; guard before passing to Map#delete.
|
||||
const oldestKey = this.cache.keys().next().value;
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
DEFAULT_DECK_MULTI_MAX_SLICES,
|
||||
getBootstrapDataFromDocument,
|
||||
getDeckMultiMaxSlices,
|
||||
getDefaultMapRenderer,
|
||||
getMapProviderMapStyle,
|
||||
getMapboxApiKeyFromBootstrap,
|
||||
@@ -58,6 +60,16 @@ test('Mapbox key helpers report absence and presence from bootstrap data', () =>
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('deck.gl multi-slice cap falls back to the default when unset', () => {
|
||||
expect(getDeckMultiMaxSlices({ common: { conf: {} } })).toBe(
|
||||
DEFAULT_DECK_MULTI_MAX_SLICES,
|
||||
);
|
||||
expect(getDeckMultiMaxSlices(undefined)).toBe(DEFAULT_DECK_MULTI_MAX_SLICES);
|
||||
expect(
|
||||
getDeckMultiMaxSlices({ common: { conf: { DECK_MULTI_MAX_SLICES: 5 } } }),
|
||||
).toBe(5);
|
||||
});
|
||||
|
||||
test('bootstrap data helper parses document data safely', () => {
|
||||
document.body.innerHTML = `<div id="app" data-bootstrap='${JSON.stringify({
|
||||
common: { conf: { MAPBOX_API_KEY: 'pk.document' } },
|
||||
|
||||
@@ -83,10 +83,13 @@ type BootstrapData = {
|
||||
conf?: {
|
||||
DEFAULT_MAP_RENDERER?: unknown;
|
||||
MAPBOX_API_KEY?: unknown;
|
||||
DECK_MULTI_MAX_SLICES?: unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export const DEFAULT_DECK_MULTI_MAX_SLICES = 50;
|
||||
|
||||
export function getBootstrapDataFromDocument(): unknown {
|
||||
/* istanbul ignore if -- a missing document only occurs in SSR/worker
|
||||
contexts, which Jest cannot simulate: jsdom pins `document` as a
|
||||
@@ -118,6 +121,16 @@ export function hasMapboxApiKey(
|
||||
return getMapboxApiKeyFromBootstrap(bootstrapData).trim().length > 0;
|
||||
}
|
||||
|
||||
export function getDeckMultiMaxSlices(
|
||||
bootstrapData: unknown = getBootstrapDataFromDocument(),
|
||||
): number {
|
||||
const maxSlices = (bootstrapData as BootstrapData | undefined)?.common?.conf
|
||||
?.DECK_MULTI_MAX_SLICES;
|
||||
return typeof maxSlices === 'number' && Number.isFinite(maxSlices)
|
||||
? maxSlices
|
||||
: DEFAULT_DECK_MULTI_MAX_SLICES;
|
||||
}
|
||||
|
||||
export function getDefaultMapRenderer(
|
||||
bootstrapData: unknown = getBootstrapDataFromDocument(),
|
||||
): MapProvider {
|
||||
|
||||
+22
-44
@@ -122,7 +122,7 @@ describe('ChartClient', () => {
|
||||
});
|
||||
|
||||
describe('.loadQueryData(formData, options)', () => {
|
||||
test('returns a promise of query data for known chart type', () => {
|
||||
test('returns a promise of query data for known chart type', async () => {
|
||||
getChartMetadataRegistry().registerValue(
|
||||
VizType.WordCloud,
|
||||
new ChartMetadata({ name: 'Word Cloud', thumbnail: '' }),
|
||||
@@ -132,14 +132,18 @@ describe('ChartClient', () => {
|
||||
VizType.WordCloud,
|
||||
(formData: QueryFormData) => buildQueryContext(formData),
|
||||
);
|
||||
fetchMock.post('glob:*/api/v1/chart/data', [
|
||||
{
|
||||
field1: 'abc',
|
||||
field2: 'def',
|
||||
},
|
||||
]);
|
||||
// The real /api/v1/chart/data endpoint wraps its results in
|
||||
// `{ result: [...] }`, not a bare array.
|
||||
fetchMock.post('glob:*/api/v1/chart/data', {
|
||||
result: [
|
||||
{
|
||||
field1: 'abc',
|
||||
field2: 'def',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return expect(
|
||||
await expect(
|
||||
chartClient.loadQueryData({
|
||||
granularity: 'minute',
|
||||
viz_type: VizType.WordCloud,
|
||||
@@ -151,6 +155,16 @@ describe('ChartClient', () => {
|
||||
field2: 'def',
|
||||
},
|
||||
]);
|
||||
|
||||
// The query context fields must be posted at the top level of the
|
||||
// request body -- the endpoint's schema does not expect them nested
|
||||
// under a `query_context` key.
|
||||
const calls = fetchMock.callHistory.calls('glob:*/api/v1/chart/data');
|
||||
const requestBody = JSON.parse(
|
||||
(calls[0].options as RequestInit).body as string,
|
||||
);
|
||||
expect(requestBody.query_context).toBeUndefined();
|
||||
expect(requestBody.datasource).toEqual({ id: 1, type: 'table' });
|
||||
});
|
||||
test('returns a promise that rejects for unknown chart type', () =>
|
||||
expect(
|
||||
@@ -160,42 +174,6 @@ describe('ChartClient', () => {
|
||||
datasource: '1__table',
|
||||
}),
|
||||
).rejects.toEqual(new Error('Unknown chart type: rainbow_3d_pie')));
|
||||
|
||||
test('fetches data from the legacy API if ChartMetadata has useLegacyApi=true,', () => {
|
||||
// note legacy charts do not register a buildQuery function in the registry
|
||||
getChartMetadataRegistry().registerValue(
|
||||
'word_cloud_legacy',
|
||||
new ChartMetadata({
|
||||
name: 'Legacy Word Cloud',
|
||||
thumbnail: '.png',
|
||||
useLegacyApi: true,
|
||||
}),
|
||||
);
|
||||
|
||||
fetchMock.post('glob:*/api/v1/chart/data', () =>
|
||||
Promise.reject(new Error('Unexpected all to v1 API')),
|
||||
);
|
||||
|
||||
// post `Superset.route_base = ""`, the legacy endpoint
|
||||
// collapsed from `/superset/explore_json/` to `/explore_json/`.
|
||||
fetchMock.post('glob:*/explore_json/', {
|
||||
field1: 'abc',
|
||||
field2: 'def',
|
||||
});
|
||||
|
||||
return expect(
|
||||
chartClient.loadQueryData({
|
||||
granularity: 'minute',
|
||||
viz_type: 'word_cloud_legacy',
|
||||
datasource: '1__table',
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
{
|
||||
field1: 'abc',
|
||||
field2: 'def',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('.loadDatasource(datasourceKey, options)', () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user