mirror of
https://github.com/apache/superset.git
synced 2026-08-13 11:40:37 +00:00
Compare commits
175
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
276d6f04f5 | ||
|
|
524d99159a | ||
|
|
657a2a4cb2 | ||
|
|
eefb3e3835 | ||
|
|
07accd56f2 | ||
|
|
22caf221b7 | ||
|
|
de2276225a | ||
|
|
685f26b1bf | ||
|
|
739365979d | ||
|
|
bc1a8e0858 | ||
|
|
0915a39bcb | ||
|
|
8014f782d3 | ||
|
|
eb7d4cba42 | ||
|
|
01ecefd732 | ||
|
|
0c2f91968e | ||
|
|
ed696b9933 | ||
|
|
4baf1cf648 | ||
|
|
aefee48223 | ||
|
|
cc35056bc9 | ||
|
|
a0d7ec9faf | ||
|
|
a501fed560 | ||
|
|
4354b37b96 | ||
|
|
d840568f3b | ||
|
|
174d35380d | ||
|
|
2c10e6260f | ||
|
|
8f6587d0e6 | ||
|
|
b4f3fae288 | ||
|
|
8e455034d0 | ||
|
|
8734a232d8 | ||
|
|
56573fa2cd | ||
|
|
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 |
+1
-24
@@ -1,22 +1,3 @@
|
||||
# Notify all committers of DB migration changes, per SIP-59
|
||||
|
||||
/superset/migrations/ @mistercrunch @michael-s-molina @betodealmeida @eschutho @sadpandajoe @rusackas
|
||||
|
||||
# Notify some committers of changes in the components
|
||||
|
||||
/superset-frontend/src/components/Select/ @michael-s-molina @geido @kgabryje
|
||||
/superset-frontend/src/components/MetadataBar/ @michael-s-molina @geido @kgabryje
|
||||
/superset-frontend/src/components/DropdownContainer/ @michael-s-molina @geido @kgabryje
|
||||
|
||||
# Notify Helm Chart maintainers about changes in it
|
||||
|
||||
/helm/superset/ @dpgaspar @villebro @nytai @michael-s-molina @mistercrunch @rusackas @Antonio-RiveroMartnez @hainenber
|
||||
|
||||
# Notify E2E test maintainers of changes
|
||||
|
||||
/superset-frontend/playwright/ @sadpandajoe @geido @eschutho @rusackas @mistercrunch
|
||||
/superset-frontend/cypress-base/ @sadpandajoe @geido @eschutho @rusackas @mistercrunch
|
||||
|
||||
# Notify PMC members of changes to GitHub Actions
|
||||
|
||||
/.github/ @villebro @geido @eschutho @rusackas @betodealmeida @nytai @mistercrunch @kgabryje @sha174n @dpgaspar @sadpandajoe @hainenber
|
||||
@@ -30,16 +11,12 @@
|
||||
|
||||
/.asf.yaml @villebro @geido @eschutho @rusackas @betodealmeida @nytai @mistercrunch @kgabryje @dpgaspar @sha174n @Antonio-RiveroMartnez
|
||||
|
||||
# Maps are a finicky contribution process we care about
|
||||
# Maps are fragile and political. GeoJson edits MUST be made in the Jupyter notebook or they'll be overwritten.
|
||||
|
||||
**/*.geojson @villebro @rusackas
|
||||
**/*.ipynb @villebro @rusackas
|
||||
/superset-frontend/plugins/plugin-chart-country-map/ @villebro @rusackas
|
||||
|
||||
# Notify translation maintainers of changes to translations
|
||||
|
||||
/superset/translations/ @sfirke @rusackas @villebro @sadpandajoe @hainenber
|
||||
|
||||
# Notify PMC members of changes to extension-related files
|
||||
|
||||
/docs/developer_portal/extensions/ @michael-s-molina @villebro @rusackas
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
|
||||
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@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+165
-4
@@ -24,6 +24,74 @@ 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.
|
||||
- [42300](https://github.com/apache/superset/pull/42300): Timeseries charts (line/area/bar) with a Y-axis bound in effect — either an explicit `yAxisBounds` or one derived from `truncateYAxis` — now clamp out-of-range data points to that bound instead of letting ECharts drop the point (and the line segments around it) entirely. Any existing chart with a configured Y-axis bound and data outside it will look different after upgrading: a gap becomes a point pinned to the boundary. The clamp also rewrites the value ECharts reads for that point's tooltip and data label, so the displayed value is the bound rather than the true observation.
|
||||
- [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`
|
||||
|
||||
### CSV/XLSX report exports of Table charts keep raw numeric values
|
||||
|
||||
Table and Pivot Table charts sent as text in a report email now apply the
|
||||
chart's number and currency formatting so the values match what a user sees in
|
||||
Explore. As part of this, the CSV and XLSX result formats return early before
|
||||
formatting: previously the Table post-processor applied `d3NumberFormat` to
|
||||
every result format, so CSV/XLSX exports contained pre-formatted strings.
|
||||
CSV/XLSX exports now preserve numeric values and column types, which is better
|
||||
for downstream analysis but is a visible change for anyone who relied on the
|
||||
formatted text in those files. The rendered email body (the only place the
|
||||
formatting is intended for) is unaffected.
|
||||
|
||||
### SQLAlchemy bumped to 2.0, flask-sqlalchemy to 3.1.1
|
||||
|
||||
Superset's core ORM dependencies move from SQLAlchemy 1.4 to 2.0 and
|
||||
flask-sqlalchemy `<3.0` to 3.1.1, completing the migration tracked in
|
||||
[discussion #40273](https://github.com/apache/superset/discussions/40273).
|
||||
|
||||
**Custom `db_engine_specs`, plugins, or extensions that import SQLAlchemy
|
||||
internals directly** should review the
|
||||
[SQLAlchemy 1.4-to-2.0 migration guide](https://docs.sqlalchemy.org/en/20/changelog/migration_20.html)
|
||||
for API changes that affect them — most 1.4 code already runs unmodified
|
||||
under 2.0's compatibility mode, but patterns like `Engine.execute()`,
|
||||
string-keyed `Row` access, and `MetaData(bind=)` are removed outright.
|
||||
|
||||
**Several optional DB-connector extras remain capped below their
|
||||
SQLAlchemy-2.0-only releases**, either because that bump is a separate
|
||||
follow-up ([#42891](https://github.com/apache/superset/pull/42891): dremio,
|
||||
exasol, firebird, redshift, risingwave) or because the upstream dialect
|
||||
package has no SQLAlchemy 2.0 support yet at all (aurora-data-api, d1,
|
||||
kusto, solr; ocient's 2.0 compatibility is unverified). Installing one of
|
||||
these extras continues to pull a SQLAlchemy-1.4-line version of that
|
||||
dialect; each package's constraint in `pyproject.toml` documents why.
|
||||
|
||||
No application-level configuration changes are required for deployments
|
||||
that don't touch SQLAlchemy directly.
|
||||
|
||||
### Soft delete is on by default, and purging is live
|
||||
|
||||
`SOFT_DELETE` now ships **on** (`DEFAULT_FEATURE_FLAGS`), so deleting a
|
||||
@@ -52,6 +120,33 @@ 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
|
||||
@@ -80,6 +175,58 @@ Behavior changes to be aware of:
|
||||
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.
|
||||
|
||||
### Row-level security now filters table reads a same-named CTE used to hide
|
||||
|
||||
`extract_tables_from_statement()` decided whether a reference was a CTE by matching its
|
||||
bare name against the enclosing scope's CTE names; it now resolves the name through
|
||||
`Scope.cte_sources`. Three kinds of real table read whose bare name collided with a CTE's
|
||||
were mistaken for the CTE and dropped from a statement's tables, so they were neither
|
||||
RLS-filtered nor access-checked: a schema- or catalog-qualified reference, a non-recursive
|
||||
CTE's own name inside its body, and a forward reference to a later `WITH` item.
|
||||
|
||||
```sql
|
||||
WITH orders AS (SELECT 1 AS d) SELECT * FROM (SELECT * FROM public.orders) AS z
|
||||
WITH orders AS (SELECT * FROM orders) SELECT * FROM orders
|
||||
WITH q1 AS (SELECT key FROM q2), q2 AS (SELECT 1 AS key) SELECT * FROM q1
|
||||
```
|
||||
|
||||
Each read is now reported, so it is filtered when `RLS_IN_SQLLAB` is enabled, matched
|
||||
against `DISALLOWED_SQL_TABLES`, and requires dataset access under
|
||||
`raise_for_access(force_dataset_match=True)`. A query that previously ran, reading those
|
||||
rows unfiltered, may now be filtered or rejected. There is no opt-out — the previous
|
||||
behavior was a row-level-security bypass.
|
||||
|
||||
### Table aliases keep their quoting through the row-level security rewrite
|
||||
|
||||
Both RLS transformers took the table alias as a string with its quoting stripped and
|
||||
emitted it verbatim; they now carry the parsed identifier. Emitted SQL is unchanged for an
|
||||
unquoted identifier; a quoted one keeps its quoting, and a column-alias list
|
||||
(`FROM t AS x (c1, c2)`) survives the rewrite instead of being dropped. This repairs
|
||||
row-level security for any aliased table on Snowflake, and for at least one statement shape
|
||||
on MSSQL where the rewrite previously raised `AttributeError`.
|
||||
|
||||
### Principal listing APIs now honour related-field filters
|
||||
|
||||
Two authorization-related listing behaviors changed for API clients. Neither
|
||||
@@ -128,12 +275,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
|
||||
@@ -481,7 +640,7 @@ 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` (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` (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)
|
||||
|
||||
@@ -491,7 +650,7 @@ Purging is **live by default** (`SOFT_DELETE_PURGE_DRY_RUN=False`), so the reten
|
||||
|
||||
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
|
||||
|
||||
@@ -737,6 +896,8 @@ With the flag enabled: `DELETE /api/v1/chart/<id>` no longer hard-deletes the ch
|
||||
|
||||
- [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.
|
||||
|
||||
- [42089](https://github.com/apache/superset/pull/42089) automatically upgrades resolvable Slack v1 recipients, preserves text-only v1 delivery with execution warnings when migration cannot finish, and rejects retired v1 file uploads with actionable scope guidance. Slack delivery uses at-most-once terminal writes and a schedule-wide retry budget configured by `SLACK_SEND_RETRY_MAX_TIME`, clamped to the report's remaining working timeout. Deployments using `SupersetMetastoreCache` for the Slack channel cache must schedule the `slack.cache_channels` Celery task to repopulate misses outside report transactions; see [Alerts and Reports](https://superset.apache.org/admin-docs/configuration/alerts-reports#slack-delivery-timeouts-and-retries).
|
||||
|
||||
### Soft delete and restore for dashboards
|
||||
|
||||
**Everything in this section applies only when the `SOFT_DELETE` feature flag is enabled. The flag defaults to `False`** (`@lifecycle: development`), so on a default deployment `DELETE /api/v1/dashboard/<id>` continues to **hard-delete permanently** — nothing is recoverable. Enable `SOFT_DELETE` to get the behavior described below.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -82,6 +83,28 @@ SLACK_CACHE_TIMEOUT = int(timedelta(days=2).total_seconds())
|
||||
SLACK_API_RATE_LIMIT_RETRY_COUNT = 5
|
||||
```
|
||||
|
||||
When the cache backend is `SupersetMetastoreCache`, report execution does not
|
||||
write channel listings into the cache because that backend commits the report's
|
||||
database session. Schedule the dedicated warm-up task so cache misses are
|
||||
repopulated outside report transactions:
|
||||
|
||||
```python
|
||||
from celery.schedules import crontab
|
||||
|
||||
from superset.config import CeleryConfig
|
||||
|
||||
class CustomCeleryConfig(CeleryConfig):
|
||||
beat_schedule = {
|
||||
**CeleryConfig.beat_schedule,
|
||||
"slack.cache_channels": {
|
||||
"task": "slack.cache_channels",
|
||||
"schedule": crontab(minute="0", hour="*"),
|
||||
},
|
||||
}
|
||||
|
||||
CELERY_CONFIG = CustomCeleryConfig
|
||||
```
|
||||
|
||||
#### Slack Enterprise Grid (org-scoped tokens)
|
||||
|
||||
On a Slack Enterprise Grid org, an org-scoped token spans multiple workspaces, so
|
||||
@@ -97,6 +120,38 @@ SLACK_TEAM_ID = "T01234567"
|
||||
This defaults to `None` and only needs to be set when using an org-scoped token;
|
||||
it is accepted but ignored for standard workspace-level tokens.
|
||||
|
||||
#### Slack delivery timeouts and retries
|
||||
|
||||
Slack delivery uses a request timeout and an application retry budget:
|
||||
|
||||
```python
|
||||
# Timeout for one Slack API request, in seconds
|
||||
SLACK_API_TIMEOUT = 30
|
||||
|
||||
# Retry budget shared by every Slack destination and upload phase
|
||||
SLACK_SEND_RETRY_MAX_TIME = 150
|
||||
|
||||
# Number of explicit HTTP 429 responses retried using Slack's Retry-After value
|
||||
SLACK_API_RATE_LIMIT_RETRY_COUNT = 2
|
||||
|
||||
# Cooldown after an on-demand channel-cache refresh
|
||||
SLACK_CHANNEL_REFRESH_COOLDOWN_SECONDS = 300
|
||||
```
|
||||
|
||||
All channels and upload phases in one report execution share a single
|
||||
`SLACK_SEND_RETRY_MAX_TIME` budget. This prevents a large recipient list from
|
||||
multiplying the report's wall-clock retry time. The budget is also clamped to
|
||||
the report's remaining working timeout, leaving Celery's configured timeout lag
|
||||
available for final state persistence. The effective configured budget is at
|
||||
least one second longer than `SLACK_API_TIMEOUT`.
|
||||
|
||||
To avoid posting the same report twice, Superset does not replay terminal
|
||||
`chat.postMessage` or `files.completeUploadExternal` operations after ambiguous
|
||||
server or transport failures. Explicit Slack HTTP 429 responses remain
|
||||
retryable. These delivery settings and semantics apply to Slack v2 reports and
|
||||
legacy text-only Slack delivery, independently of the
|
||||
`ALERT_REPORT_SLACK_V2` feature flag.
|
||||
|
||||
### Webhook integration
|
||||
|
||||
Superset can send alert and report notifications to any HTTP endpoint — useful for chat platforms, incident management tools, or custom automation.
|
||||
@@ -174,6 +229,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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
+11
-11
@@ -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.47",
|
||||
"antd": "^6.5.2",
|
||||
"baseline-browser-mapping": "^2.11.8",
|
||||
"antd": "^6.5.3",
|
||||
"baseline-browser-mapping": "^2.11.12",
|
||||
"caniuse-lite": "^1.0.30001806",
|
||||
"docusaurus-plugin-openapi-docs": "^5.1.3",
|
||||
"docusaurus-theme-openapi-docs": "^5.1.3",
|
||||
"js-yaml": "^5.2.2",
|
||||
"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,14 +89,14 @@
|
||||
"@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",
|
||||
"typescript-eslint": "^8.66.0",
|
||||
"webpack": "^5.109.2"
|
||||
},
|
||||
"browserslist": {
|
||||
|
||||
Vendored
+2
-8
@@ -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"
|
||||
},
|
||||
|
||||
+602
-537
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
|
||||
|
||||
+44
-46
@@ -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",
|
||||
@@ -60,27 +60,23 @@ dependencies = [
|
||||
"flask-login>=0.6.0, < 1.0",
|
||||
"flask-migrate>=4.1.0, <5.0",
|
||||
"flask-session>=0.4.0, <1.0",
|
||||
# Pinned explicitly below 3.0: 3.0.5 resolves without conflict and
|
||||
# supports both SQLAlchemy 1.4 and 2.0, but real CI runs surfaced a
|
||||
# structural incompatibility with Superset's current session/app-context
|
||||
# handling across Celery task boundaries (see PR #42542) -- widespread
|
||||
# "NoneType has no attribute X" failures and MySQL lock-wait timeouts,
|
||||
# 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",
|
||||
# Bumped to 3.1.1 alongside the SQLAlchemy 2.0 core bump (discussion
|
||||
# #40273, step 6), which resolves the session/app-context handling
|
||||
# across Celery task boundaries that previously blocked this (see
|
||||
# PR #42542).
|
||||
"flask-sqlalchemy>=3.1.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 +84,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 +106,15 @@ dependencies = [
|
||||
"PyJWT>=2.4.0, <3.0",
|
||||
"redis>=5.0.0, <9.0",
|
||||
"rison>=2.0.1, <3.0",
|
||||
"selenium>=4.46.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>=2.0.0, <2.1",
|
||||
"sqlalchemy-continuum>=1.6.0, <2.0.0",
|
||||
"sqlalchemy-utils>=0.42.1, <0.43", # expanding lowerbound to work with pydoris
|
||||
"sqlglot>=30.14.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 +127,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 +139,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"]
|
||||
@@ -164,11 +160,10 @@ databricks = [
|
||||
datafusion = ["flightsql-dbapi>=0.2.2, <0.3"]
|
||||
db2 = ["ibm-db-sa<=0.4.4, >=0.4.4"]
|
||||
denodo = ["denodo-sqlalchemy>=2.0.5,<2.1.0"]
|
||||
# sqlalchemy-dremio 3.0.5+ hard-pins sqlalchemy~=2.0.41, dropping 1.4; 3.0.4
|
||||
# is the last dual-compat release. Capped below 3.0.5 for now; widen back to
|
||||
# <4 in lockstep with Superset's own SQLAlchemy 2.0 core bump (discussion
|
||||
# #40273), not before.
|
||||
dremio = ["sqlalchemy-dremio>=1.2.1, <3.0.5"]
|
||||
# sqlalchemy-dremio 3.0.5+ hard-pins sqlalchemy~=2.0.41, dropping 1.4.
|
||||
# Widened now that Superset's own SQLAlchemy 2.0 core bump has landed
|
||||
# (discussion #40273).
|
||||
dremio = ["sqlalchemy-dremio>=3.0.5, <4"]
|
||||
# <2 was an artificial ceiling; upstream has no SQLAlchemy version cap and
|
||||
# 1.1.10 already supports SQLAlchemy 2.0 (added `import_dbapi` in 1.1.7).
|
||||
drill = ["sqlalchemy-drill>=1.1.10, <3"]
|
||||
@@ -181,10 +176,9 @@ dynamodb = ["pydynamodb>=0.8.2"]
|
||||
solr = ["sqlalchemy-solr>=0.2.4.3"]
|
||||
elasticsearch = ["elasticsearch-dbapi>=0.2.13, <0.3.0"]
|
||||
# sqlalchemy-exasol cuts hard from SQLAlchemy 1.4-only (<6.0.0) to 2.0-only
|
||||
# (>=6.0.0) with no dual-compat release. Capped below 6.0.0 for now; bump to
|
||||
# >=6.0.0,<8.0 in lockstep with Superset's own SQLAlchemy 2.0 core bump
|
||||
# (discussion #40273), not before.
|
||||
exasol = ["sqlalchemy-exasol>=2.4.0, <6.0.0"]
|
||||
# (>=6.0.0) with no dual-compat release. Bumped now that Superset's own
|
||||
# SQLAlchemy 2.0 core bump has landed (discussion #40273).
|
||||
exasol = ["sqlalchemy-exasol>=6.0.0, <8.0"]
|
||||
excel = ["xlrd>=2.0.2, <2.1"]
|
||||
# Async dashboard "Export Data/Images to Excel": uploads the workbook to S3 and
|
||||
# emails a pre-signed link. boto3 is imported lazily by superset.utils.s3, so
|
||||
@@ -199,12 +193,12 @@ fastmcp = [
|
||||
]
|
||||
# sqlalchemy-firebird >=2.0.0 unconditionally requires SQLAlchemy 2.0 on
|
||||
# Python >=3.8 (which covers Superset's >=3.11 floor), with no dual-compat
|
||||
# release. Capped below 2.0.0 for now; bump to >=2.2.0 in lockstep with
|
||||
# Superset's own SQLAlchemy 2.0 core bump (discussion #40273), not before.
|
||||
firebird = ["sqlalchemy-firebird>=0.8.0, <2.0.0"]
|
||||
# release. Bumped now that Superset's own SQLAlchemy 2.0 core bump has
|
||||
# landed (discussion #40273).
|
||||
firebird = ["sqlalchemy-firebird>=2.2.0"]
|
||||
firebolt = ["firebolt-sqlalchemy>=1.1.2, <2"]
|
||||
gevent = ["gevent>=26.7.0"]
|
||||
gsheets = ["shillelagh[gsheetsapi]>=1.4.4, <2"]
|
||||
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,22 +228,21 @@ 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"]
|
||||
prophet = ["prophet>=1.3.0, <2"]
|
||||
# sqlalchemy-redshift cuts hard from SQLAlchemy 1.4-only (0.8.x) to 2.0-only
|
||||
# (>=1.0.0) with no dual-compat release; the existing <0.9 ceiling already
|
||||
# keeps this on the 1.4-only line. Bump to >=1.0.0 in lockstep with
|
||||
# Superset's own SQLAlchemy 2.0 core bump (discussion #40273), not before.
|
||||
redshift = ["sqlalchemy-redshift>=0.8.1, <0.9"]
|
||||
# (>=1.0.0) with no dual-compat release. Bumped now that Superset's own
|
||||
# SQLAlchemy 2.0 core bump has landed (discussion #40273).
|
||||
redshift = ["sqlalchemy-redshift>=1.0.0"]
|
||||
# No release of sqlalchemy-risingwave has ever supported both SQLAlchemy 1.4
|
||||
# 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"]
|
||||
# and 2.0 (version numbers don't track SQLAlchemy compat monotonically).
|
||||
# Bumped to the 2.0-only line now that Superset's own SQLAlchemy 2.0 core
|
||||
# bump has landed (discussion #40273).
|
||||
risingwave = ["sqlalchemy-risingwave>=2.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"]
|
||||
@@ -262,11 +255,11 @@ tdengine = [
|
||||
"taospy>=2.8.10",
|
||||
"taos-ws-py>=0.7.0"
|
||||
]
|
||||
teradata = ["teradatasql>=20.0.0.63"]
|
||||
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 +279,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 +302,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 +528,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
|
||||
|
||||
-20
@@ -23,25 +23,5 @@ python_files = *_test.py test_*.py *_tests.py *viz/utils.py
|
||||
asyncio_mode = auto
|
||||
|
||||
# `ignore` is effectively equivalent to `-p no:warnings`.
|
||||
# Always print RemovedIn20Warning when SQLALCHEMY_WARN_20=1.
|
||||
# Additionally, raise errors for refactored RemovedIn20Warning cases to prevent regression.
|
||||
filterwarnings =
|
||||
ignore
|
||||
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
|
||||
error:The connection.execute\(\) method:sqlalchemy.exc.RemovedIn20Warning
|
||||
error:The current statement is being autocommitted using implicit autocommit:sqlalchemy.exc.RemovedIn20Warning
|
||||
error:The ``declarative_base\(\)`` function is now available:sqlalchemy.exc.RemovedIn20Warning
|
||||
error:The Engine.execute\(\) method is considered legacy:sqlalchemy.exc.RemovedIn20Warning
|
||||
error:The legacy calling style of select\(\) is deprecated:sqlalchemy.exc.RemovedIn20Warning
|
||||
error:The "whens" argument to case: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
|
||||
|
||||
+12
-42
@@ -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)
|
||||
@@ -148,7 +144,7 @@ flask-migrate==4.1.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
flask-session==0.8.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
flask-sqlalchemy==2.5.1
|
||||
flask-sqlalchemy==3.1.1
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# flask-appbuilder
|
||||
@@ -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.46.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,11 +381,7 @@ 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
|
||||
sqlalchemy==2.0.51
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# alembic
|
||||
@@ -418,7 +399,7 @@ sqlalchemy-utils==0.42.1
|
||||
# apache-superset (pyproject.toml)
|
||||
# apache-superset-core
|
||||
# flask-appbuilder
|
||||
sqlglot==30.15.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,8 +418,8 @@ typing-extensions==4.16.0
|
||||
# pydantic-core
|
||||
# pyopenssl
|
||||
# referencing
|
||||
# selenium
|
||||
# shillelagh
|
||||
# sqlalchemy
|
||||
# typing-inspection
|
||||
typing-inspection==0.4.2
|
||||
# via pydantic
|
||||
@@ -461,7 +436,6 @@ urllib3==2.7.0
|
||||
# -r requirements/base.in
|
||||
# requests
|
||||
# requests-cache
|
||||
# selenium
|
||||
vine==5.1.0
|
||||
# via
|
||||
# amqp
|
||||
@@ -471,8 +445,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 +455,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
|
||||
@@ -309,7 +306,7 @@ flask-session==0.8.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
flask-sqlalchemy==2.5.1
|
||||
flask-sqlalchemy==3.1.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -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.46.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,15 +949,8 @@ 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
|
||||
sqlalchemy==1.4.54
|
||||
# via anyio
|
||||
sqlalchemy==2.0.51
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# alembic
|
||||
@@ -1002,7 +976,7 @@ sqlalchemy-utils==0.42.1
|
||||
# apache-superset
|
||||
# apache-superset-core
|
||||
# flask-appbuilder
|
||||
sqlglot==30.15.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,8 +1032,8 @@ typing-extensions==4.16.0
|
||||
# pydantic-core
|
||||
# pyopenssl
|
||||
# referencing
|
||||
# selenium
|
||||
# shillelagh
|
||||
# sqlalchemy
|
||||
# starlette
|
||||
# typing-inspection
|
||||
typing-inspection==0.4.2
|
||||
@@ -1100,7 +1065,6 @@ urllib3==2.7.0
|
||||
# docker
|
||||
# requests
|
||||
# requests-cache
|
||||
# selenium
|
||||
uvicorn==0.37.0
|
||||
# via
|
||||
# fastmcp-slim
|
||||
@@ -1124,10 +1088,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 +1102,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/",
|
||||
|
||||
@@ -45,7 +45,7 @@ dependencies = [
|
||||
"isodate>=0.7.0",
|
||||
"pyarrow>=16.0.0",
|
||||
"pydantic>=2.8.0",
|
||||
"sqlalchemy>=1.4.0,<2.0",
|
||||
"sqlalchemy>=2.0.0,<2.1",
|
||||
"sqlalchemy-utils>=0.38.0, <0.43", # expanding lowerbound to work with pydoris
|
||||
"sqlglot>=30.8.0, <31",
|
||||
"typing-extensions>=4.0.0",
|
||||
|
||||
+8
-7
@@ -3215,16 +3215,17 @@
|
||||
"integrity": "sha512-Fc8Ne62jJlKHiG/ajlonC4Sd66Pq68fFwK4ihJGNZpGqboc324SQk+lRvMzpPRuJOmfrJefdG8/7JdWX4bzJ2Q=="
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
|
||||
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
@@ -11184,9 +11185,9 @@
|
||||
"integrity": "sha512-Fc8Ne62jJlKHiG/ajlonC4Sd66Pq68fFwK4ihJGNZpGqboc324SQk+lRvMzpPRuJOmfrJefdG8/7JdWX4bzJ2Q=="
|
||||
},
|
||||
"brace-expansion": {
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
|
||||
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"requires": {
|
||||
|
||||
@@ -36,6 +36,9 @@
|
||||
"cypress": {
|
||||
"form-data": "^2.3.4"
|
||||
},
|
||||
"minimatch@>=10": {
|
||||
"brace-expansion": ">=5.0.8"
|
||||
},
|
||||
"qs": "^6.14.2",
|
||||
"uuid": "^11.1.1"
|
||||
},
|
||||
|
||||
@@ -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
+1221
-2056
File diff suppressed because it is too large
Load Diff
@@ -158,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",
|
||||
@@ -176,8 +176,8 @@
|
||||
"geostyler-openlayers-parser": "^5.7.1",
|
||||
"geostyler-style": "11.0.2",
|
||||
"geostyler-wfs-parser": "^3.0.1",
|
||||
"google-auth-library": "^10.9.1",
|
||||
"immer": "^11.1.15",
|
||||
"google-auth-library": "^11.0.0",
|
||||
"immer": "^11.1.16",
|
||||
"interweave": "^13.1.1",
|
||||
"jquery": "^4.0.0",
|
||||
"js-levenshtein": "^1.1.6",
|
||||
@@ -185,13 +185,13 @@
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.18.1",
|
||||
"mapbox-gl": "^3.27.0",
|
||||
"markdown-to-jsx": "^9.9.0",
|
||||
"mapbox-gl": "^3.28.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",
|
||||
@@ -203,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",
|
||||
@@ -216,7 +216,7 @@
|
||||
"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",
|
||||
@@ -250,16 +250,16 @@
|
||||
"@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.47",
|
||||
@@ -284,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.7",
|
||||
"baseline-browser-mapping": "^2.11.12",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^10.0.4",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
@@ -308,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.6.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",
|
||||
@@ -327,13 +326,13 @@
|
||||
"jest-websocket-mock": "^2.5.0",
|
||||
"js-yaml-loader": "^1.2.2",
|
||||
"jsdom": "^30.0.1",
|
||||
"lerna": "^9.0.4",
|
||||
"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",
|
||||
@@ -344,13 +343,13 @@
|
||||
"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.9",
|
||||
"typescript": "5.4.5",
|
||||
"unzipper": "^0.12.5",
|
||||
"wait-on": "^9.1.0",
|
||||
@@ -413,6 +412,9 @@
|
||||
"lerna": {
|
||||
"js-yaml": "^4.3.0"
|
||||
},
|
||||
"minimatch@>=10": {
|
||||
"brace-expansion": ">=5.0.8"
|
||||
},
|
||||
"nwsapi": "^2.2.13",
|
||||
"puppeteer": "^22.4.1",
|
||||
"tar": "^7.5.16",
|
||||
|
||||
@@ -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)')],
|
||||
|
||||
+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;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+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);
|
||||
});
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
"ag-grid-react": "36.0.2",
|
||||
"brace": "^0.11.1",
|
||||
"classnames": "^2.5.1",
|
||||
"core-js": "^3.49.0",
|
||||
"core-js": "^3.50.0",
|
||||
"csstype": "^3.2.3",
|
||||
"d3-format": "^3.1.2",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
@@ -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",
|
||||
@@ -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": "*",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
@@ -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`;
|
||||
};
|
||||
|
||||
@@ -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).',
|
||||
},
|
||||
};
|
||||
|
||||
+46
@@ -989,6 +989,52 @@ test('shows all options when filterOption is false', async () => {
|
||||
expect(options[0]).toHaveTextContent('Server 0');
|
||||
});
|
||||
|
||||
test('renders a server-matched option whose label diverges from the search term when filterOption is false (regression for #42041)', async () => {
|
||||
// Mirrors the real permissions-search bug: the remote fetch legitimately
|
||||
// matches the raw, underscore-containing value (e.g. a schema name like
|
||||
// "stg_silver"), but the returned option's displayed label has had
|
||||
// underscores replaced with spaces (see formatPermissionLabel in
|
||||
// features/roles/utils.ts). filterOption defaults to true, which
|
||||
// re-filters already-matched options against that same relabeled text
|
||||
// client-side, so the underscore search term never matches and the
|
||||
// legitimately fetched option gets hidden -- this is why
|
||||
// PermissionsField (features/roles/RoleFormItems.tsx) sets
|
||||
// filterOption={false}: the loader is already the authoritative filter,
|
||||
// and its match doesn't depend on the label used to render the option.
|
||||
const searchData = [{ label: 'stg silver', value: 100 }];
|
||||
const loadOptions = jest.fn(async (search: string) =>
|
||||
// totalCount must exceed the empty initial page here, otherwise
|
||||
// AsyncSelect marks allValuesLoaded and short-circuits every later
|
||||
// fetch, including the search request this test depends on.
|
||||
search === ''
|
||||
? { data: [], totalCount: 1 }
|
||||
: { data: searchData, totalCount: 1 },
|
||||
);
|
||||
|
||||
render(
|
||||
<AsyncSelect
|
||||
{...defaultProps}
|
||||
options={loadOptions}
|
||||
filterOption={false}
|
||||
/>,
|
||||
);
|
||||
await open();
|
||||
|
||||
await type('stg_silver');
|
||||
await waitFor(() =>
|
||||
expect(loadOptions).toHaveBeenCalledWith(
|
||||
'stg_silver',
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
),
|
||||
);
|
||||
|
||||
// The backend legitimately matched and returned this option (asserted
|
||||
// above); it should render in the dropdown despite the search term using
|
||||
// underscores while the label uses spaces.
|
||||
expect(await findSelectOption('stg silver')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('preserves new option entry across search fetch when allowNewOptions is on', async () => {
|
||||
const page0Data = Array.from({ length: 10 }, (_, i) => ({
|
||||
label: `Option ${i}`,
|
||||
|
||||
+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 });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+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;
|
||||
|
||||
+35
-3
@@ -45,7 +45,16 @@ describe('reactify(renderFn)', () => {
|
||||
content: 'ghi',
|
||||
};
|
||||
|
||||
const willUnmountCb = jest.fn();
|
||||
let latestUnmountContext:
|
||||
| {
|
||||
container?: HTMLDivElement;
|
||||
props?: { content?: string; id?: string };
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const willUnmountCb = jest.fn(function captureUnmountContext() {
|
||||
latestUnmountContext = this as typeof latestUnmountContext;
|
||||
});
|
||||
|
||||
const TheChart = reactify(renderFn);
|
||||
const TheChartWithWillUnmountHook = reactify(renderFn, {
|
||||
@@ -66,12 +75,22 @@ describe('reactify(renderFn)', () => {
|
||||
}
|
||||
|
||||
function AnotherTestComponent() {
|
||||
return <TheChartWithWillUnmountHook id="another_test" />;
|
||||
const [content, setContent] = useState('abc');
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setContent('def');
|
||||
}, 10);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
return <TheChartWithWillUnmountHook id="another_test" content={content} />;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(renderFn as jest.Mock).mockClear();
|
||||
willUnmountCb.mockClear();
|
||||
latestUnmountContext = undefined;
|
||||
});
|
||||
|
||||
test('returns a React component and re-renders on prop changes', async () => {
|
||||
@@ -126,9 +145,22 @@ describe('reactify(renderFn)', () => {
|
||||
expect(anotherRenderFn).toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
test('calls willUnmount hook when it is provided', () => {
|
||||
test('calls willUnmount hook with the committed container and latest props', async () => {
|
||||
const { unmount } = render(<AnotherTestComponent />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('def')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const committedContainer = screen.getByText('def').parentElement;
|
||||
|
||||
unmount();
|
||||
|
||||
expect(willUnmountCb).toHaveBeenCalledTimes(1);
|
||||
expect(latestUnmountContext?.props).toMatchObject({
|
||||
id: 'another_test',
|
||||
content: 'def',
|
||||
});
|
||||
expect(latestUnmountContext?.container).toBe(committedContainer);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
addAlpha,
|
||||
hexToRgb,
|
||||
rgbToHex,
|
||||
rgbaToHex,
|
||||
forceHexAlpha,
|
||||
} from '@superset-ui/core';
|
||||
|
||||
describe('color utils', () => {
|
||||
@@ -106,4 +108,51 @@ describe('color utils', () => {
|
||||
expect(rgbToHex(0, 0, 0)).toBe('#000000');
|
||||
});
|
||||
});
|
||||
describe('rgbaToHex', () => {
|
||||
test('omits the alpha channel for opaque colors', () => {
|
||||
expect(rgbaToHex({ r: 255, g: 0, b: 0 })).toBe('#ff0000');
|
||||
expect(rgbaToHex({ r: 255, g: 0, b: 0, a: 1 })).toBe('#ff0000');
|
||||
});
|
||||
test('appends the alpha channel for translucent colors', () => {
|
||||
expect(rgbaToHex({ r: 0, g: 150, b: 0, a: 0.2 })).toBe('#00960033');
|
||||
expect(rgbaToHex({ r: 0, g: 0, b: 0, a: 0.5 })).toBe('#00000080');
|
||||
});
|
||||
test('fully transparent colors keep an explicit 00 alpha', () => {
|
||||
expect(rgbaToHex({ r: 255, g: 255, b: 255, a: 0 })).toBe('#ffffff00');
|
||||
});
|
||||
test('zero-pads single-digit channels', () => {
|
||||
expect(rgbaToHex({ r: 1, g: 2, b: 3 })).toBe('#010203');
|
||||
});
|
||||
test('rounds fractional channel values', () => {
|
||||
expect(rgbaToHex({ r: 254.6, g: 0.4, b: 0 })).toBe('#ff0000');
|
||||
});
|
||||
test('clamps out-of-range channel and alpha values', () => {
|
||||
expect(rgbaToHex({ r: 300, g: -10, b: 0 })).toBe('#ff0000');
|
||||
expect(rgbaToHex({ r: 0, g: 0, b: 0, a: 1.5 })).toBe('#000000');
|
||||
expect(rgbaToHex({ r: 0, g: 0, b: 0, a: -0.5 })).toBe('#00000000');
|
||||
});
|
||||
});
|
||||
describe('forceHexAlpha', () => {
|
||||
test('appends 60% alpha to a 6-digit hex string', () => {
|
||||
expect(forceHexAlpha('#ff0000')).toBe('#ff000099');
|
||||
});
|
||||
test('adds the # prefix when missing', () => {
|
||||
expect(forceHexAlpha('ff0000')).toBe('#ff000099');
|
||||
});
|
||||
test('replaces the existing alpha on an 8-digit hex string', () => {
|
||||
expect(forceHexAlpha('#ff000033')).toBe('#ff000099');
|
||||
});
|
||||
test('converts an RGBColor object using 60% alpha', () => {
|
||||
expect(forceHexAlpha({ r: 255, g: 0, b: 0 })).toBe('#ff000099');
|
||||
});
|
||||
test('overrides the alpha of a translucent RGBColor object', () => {
|
||||
expect(forceHexAlpha({ r: 0, g: 150, b: 0, a: 0.2 })).toBe('#00960099');
|
||||
});
|
||||
test('expands a shorthand 3-digit hex string before adding alpha', () => {
|
||||
expect(forceHexAlpha('#fff')).toBe('#ffffff99');
|
||||
});
|
||||
test('expands a shorthand 4-digit hex string before replacing alpha', () => {
|
||||
expect(forceHexAlpha('#ff03')).toBe('#ffff0099');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* 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, createThroughputFormatter } from '@superset-ui/core';
|
||||
|
||||
test('creates an instance of NumberFormatter', () => {
|
||||
const formatter = createThroughputFormatter();
|
||||
expect(formatter).toBeInstanceOf(NumberFormatter);
|
||||
});
|
||||
|
||||
test('uses default id and label', () => {
|
||||
const formatter = createThroughputFormatter();
|
||||
expect(formatter.id).toBe('throughput_format');
|
||||
expect(formatter.label).toBe('Throughput formatter');
|
||||
});
|
||||
|
||||
test('accepts a custom id, label and description', () => {
|
||||
const formatter = createThroughputFormatter({
|
||||
id: 'custom_id',
|
||||
label: 'Custom label',
|
||||
description: 'Custom description',
|
||||
});
|
||||
expect(formatter.id).toBe('custom_id');
|
||||
expect(formatter.label).toBe('Custom label');
|
||||
expect(formatter.description).toBe('Custom description');
|
||||
});
|
||||
|
||||
test('formats bits per second without converting', () => {
|
||||
const formatter = createThroughputFormatter();
|
||||
expect(formatter(0)).toBe('0bps');
|
||||
expect(formatter(500)).toBe('500bps');
|
||||
expect(formatter(1500)).toBe('1.5kbps');
|
||||
expect(formatter(8888)).toBe('8.89kbps');
|
||||
expect(formatter(1000000)).toBe('1Mbps');
|
||||
expect(formatter(1500000)).toBe('1.5Mbps');
|
||||
expect(formatter(1500000000)).toBe('1.5Gbps');
|
||||
});
|
||||
|
||||
test('scales bits per second across the full range of suffixes', () => {
|
||||
const formatter = createThroughputFormatter();
|
||||
expect(formatter(Math.pow(1000, 1))).toBe('1kbps');
|
||||
expect(formatter(Math.pow(1000, 2))).toBe('1Mbps');
|
||||
expect(formatter(Math.pow(1000, 3))).toBe('1Gbps');
|
||||
expect(formatter(Math.pow(1000, 4))).toBe('1Tbps');
|
||||
expect(formatter(Math.pow(1000, 5))).toBe('1Pbps');
|
||||
expect(formatter(Math.pow(1000, 6))).toBe('1Ebps');
|
||||
expect(formatter(Math.pow(1000, 7))).toBe('1Zbps');
|
||||
expect(formatter(Math.pow(1000, 8))).toBe('1Ybps');
|
||||
expect(formatter(Math.pow(1000, 9))).toBe('1Rbps');
|
||||
expect(formatter(Math.pow(1000, 10))).toBe('1Qbps');
|
||||
});
|
||||
|
||||
test('converts bytes per second to bits per second with fromBytes', () => {
|
||||
const formatter = createThroughputFormatter({ fromBytes: true });
|
||||
expect(formatter(0)).toBe('0bps');
|
||||
expect(formatter(1)).toBe('8bps');
|
||||
expect(formatter(100)).toBe('800bps');
|
||||
expect(formatter(125)).toBe('1kbps');
|
||||
expect(formatter(1111)).toBe('8.89kbps');
|
||||
expect(formatter(187500)).toBe('1.5Mbps');
|
||||
expect(formatter(1000000)).toBe('8Mbps');
|
||||
expect(formatter(125000000)).toBe('1Gbps');
|
||||
});
|
||||
|
||||
test('scales bytes per second across the full range of suffixes', () => {
|
||||
const formatter = createThroughputFormatter({ fromBytes: true });
|
||||
expect(formatter(Math.pow(1000, 1) / 8)).toBe('1kbps');
|
||||
expect(formatter(Math.pow(1000, 5) / 8)).toBe('1Pbps');
|
||||
expect(formatter(Math.pow(1000, 10) / 8)).toBe('1Qbps');
|
||||
});
|
||||
|
||||
test('promotes to the next suffix when rounding reaches the base', () => {
|
||||
const formatter = createThroughputFormatter();
|
||||
expect(formatter(999.999)).toBe('1kbps');
|
||||
expect(formatter(999999)).toBe('1Mbps');
|
||||
expect(formatter(999999999)).toBe('1Gbps');
|
||||
expect(formatter(-999999)).toBe('-1Mbps');
|
||||
|
||||
const fromBytes = createThroughputFormatter({ fromBytes: true });
|
||||
expect(fromBytes(124999.99)).toBe('1Mbps');
|
||||
|
||||
const formatter0decimals = createThroughputFormatter({ decimals: 0 });
|
||||
expect(formatter0decimals(999.6)).toBe('1kbps');
|
||||
});
|
||||
|
||||
test('clamps to the largest suffix beyond the known range', () => {
|
||||
const formatter = createThroughputFormatter();
|
||||
expect(formatter(Math.pow(1000, 11))).toBe('1000Qbps');
|
||||
expect(formatter(Math.pow(1000, 12))).toBe('1000000Qbps');
|
||||
});
|
||||
|
||||
test('clamps to the smallest suffix below one bit per second', () => {
|
||||
const formatter = createThroughputFormatter();
|
||||
expect(formatter(0.4)).toBe('0.4bps');
|
||||
|
||||
const fromBytes = createThroughputFormatter({ fromBytes: true });
|
||||
expect(fromBytes(0.05)).toBe('0.4bps');
|
||||
});
|
||||
|
||||
test('formats negative rates', () => {
|
||||
const formatter = createThroughputFormatter();
|
||||
expect(formatter(-1500)).toBe('-1.5kbps');
|
||||
|
||||
const fromBytes = createThroughputFormatter({ fromBytes: true });
|
||||
expect(fromBytes(-187500)).toBe('-1.5Mbps');
|
||||
});
|
||||
|
||||
test('rounds according to the decimals option', () => {
|
||||
const formatter0decimals = createThroughputFormatter({ decimals: 0 });
|
||||
expect(formatter0decimals(0)).toBe('0bps');
|
||||
expect(formatter0decimals(8888)).toBe('9kbps');
|
||||
|
||||
const formatter3decimals = createThroughputFormatter({ decimals: 3 });
|
||||
expect(formatter3decimals(8888)).toBe('8.888kbps');
|
||||
});
|
||||
@@ -336,3 +336,29 @@ test('getErrorText', async () => {
|
||||
),
|
||||
).toEqual('Sorry, an unknown error occurred.');
|
||||
});
|
||||
|
||||
test('getErrorText for a non-JSON 403 response', async () => {
|
||||
// A 403 originating outside Superset (reverse proxy, WAF, SSO gateway)
|
||||
// carries an HTML or plain-text body instead of the API's JSON
|
||||
// `{"message": "Forbidden"}`, so it must fall back to the generic
|
||||
// status-derived text rather than the permission-denied copy.
|
||||
const proxyForbidden = new Response(
|
||||
'<html><head><title>403 Forbidden</title></head><body>Forbidden</body></html>',
|
||||
{
|
||||
status: 403,
|
||||
statusText: 'Forbidden',
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
},
|
||||
);
|
||||
expect(await getErrorText(proxyForbidden, 'dashboard')).toEqual(
|
||||
'Sorry, there was an error saving this dashboard: Forbidden',
|
||||
);
|
||||
|
||||
const supersetForbidden = new Response(
|
||||
JSON.stringify({ message: 'Forbidden' }),
|
||||
{ status: 403, statusText: 'FORBIDDEN' },
|
||||
);
|
||||
expect(await getErrorText(supersetForbidden, 'dashboard')).toEqual(
|
||||
'You do not have permission to edit this dashboard',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -53,6 +53,27 @@ test('LRU operations', () => {
|
||||
expect(cache.capacity).toBe(3);
|
||||
});
|
||||
|
||||
test('overwriting an existing key does not evict another entry', () => {
|
||||
const cache = lruCache<string>(2);
|
||||
cache.set('a', 'a');
|
||||
cache.set('b', 'b');
|
||||
cache.set('b', 'b2');
|
||||
expect(cache.size).toBe(2);
|
||||
expect(cache.has('a')).toBe(true);
|
||||
expect(cache.get('b')).toBe('b2');
|
||||
});
|
||||
|
||||
test('overwriting an existing key refreshes its recency', () => {
|
||||
const cache = lruCache<string>(2);
|
||||
cache.set('a', 'a');
|
||||
cache.set('b', 'b');
|
||||
// `a` becomes the most recently used, so `b` is evicted next
|
||||
cache.set('a', 'a2');
|
||||
cache.set('c', 'c');
|
||||
expect(cache.has('b')).toBe(false);
|
||||
expect(cache.values()).toEqual(['a2', 'c']);
|
||||
});
|
||||
|
||||
test('LRU handle null and undefined', () => {
|
||||
const cache = lruCache();
|
||||
cache.set('a', null);
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Regression coverage for the Drill to Detail modal's results table.
|
||||
*
|
||||
* The drill pane measures its available height with a resize detector and
|
||||
* hands it to a virtualized table, so the rows only render if the modal's
|
||||
* internal height chain (resizable wrapper -> modal container -> modal body ->
|
||||
* flex pane) actually resolves to a real height. That chain is wired together
|
||||
* with CSS selectors targeting Ant Design's internal modal classes, which
|
||||
* TypeScript cannot see and unit tests do not exercise: when the antd v6
|
||||
* upgrade renamed `.ant-modal-content` to `.ant-modal-container`, the chain
|
||||
* silently broke, the pane measured ~0, and the table briefly flashed its rows
|
||||
* before collapsing to an empty body with only the header and pagination
|
||||
* visible.
|
||||
*
|
||||
* Only a real browser sees layout, so this is pinned here rather than in the
|
||||
* DOM-contract unit suite. Because the failure mode is
|
||||
* render-then-collapse, a single "rows are visible" read could pass during
|
||||
* the initial flash — the assertion therefore lets the height settle once,
|
||||
* then requires it to hold across further spaced reads with no more
|
||||
* retrying, so a later or partial collapse cannot be masked by an early-exit
|
||||
* retry that stopped at the first passing sample.
|
||||
*
|
||||
* CI green => the drill modal's table renders rows at a stable, non-collapsed
|
||||
* height.
|
||||
* CI red => the modal height chain broke again (or drill-to-detail failed to
|
||||
* open/load at all).
|
||||
*/
|
||||
import { testWithAssets, expect } from '../../helpers/fixtures';
|
||||
import { TIMEOUT } from '../../utils/constants';
|
||||
import { DashboardPage } from '../../pages/DashboardPage';
|
||||
import { createDashboardWithCharts } from './dashboard-test-helpers';
|
||||
|
||||
const MIN_STABLE_BODY_HEIGHT = 100;
|
||||
// Max fraction the body height may drift from the settled baseline below; a
|
||||
// partial collapse (e.g. 400px -> 150px) still clears MIN_STABLE_BODY_HEIGHT
|
||||
// but fails this, so the check enforces stability, not just a floor.
|
||||
const HEIGHT_DRIFT_TOLERANCE = 0.25;
|
||||
// Extra spaced reads taken *after* the height has settled, and the gap
|
||||
// between them. These are plain assertions, not wrapped in a retrying
|
||||
// helper: once settled, a retry would return on the first passing sample
|
||||
// and could mask a collapse that only shows up later in the window.
|
||||
const HEIGHT_SAMPLE_COUNT = 2;
|
||||
const HEIGHT_SAMPLE_INTERVAL_MS = 300;
|
||||
|
||||
testWithAssets(
|
||||
'drill to detail modal renders result rows at a stable height',
|
||||
async ({ page, testAssets }) => {
|
||||
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
|
||||
const { dashboardId } = await createDashboardWithCharts(
|
||||
page,
|
||||
testAssets,
|
||||
testWithAssets.info(),
|
||||
{
|
||||
datasetName: 'birth_names',
|
||||
chartNamePrefix: 'drill_detail',
|
||||
dashboardTitlePrefix: 'drill_detail_modal',
|
||||
chartSpecs: [
|
||||
{
|
||||
viz_type: 'pie',
|
||||
params: {
|
||||
groupby: ['gender'],
|
||||
metric: 'count',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
const dashboard = new DashboardPage(page);
|
||||
await dashboard.gotoById(dashboardId);
|
||||
await dashboard.waitForLoad();
|
||||
await dashboard.waitForChartsToLoad();
|
||||
|
||||
// Open the chart context menu. The ECharts canvas exposes no data-test
|
||||
// hooks for its regions, so right-click the centre of the chart container;
|
||||
// the exact-text match below then works whether the click landed on a
|
||||
// slice (which adds "Drill to detail by" items) or on the chart background.
|
||||
// The first right-click after load can be swallowed by a chart re-render
|
||||
// closing the menu, so retry the click until the menu actually shows.
|
||||
const chart = page.locator('[data-test="chart-container"]').first();
|
||||
await chart.scrollIntoViewIfNeeded();
|
||||
const contextMenu = page.locator('[data-test="chart-context-menu"]');
|
||||
await expect(async () => {
|
||||
const box = await chart.boundingBox();
|
||||
if (!box) {
|
||||
throw new Error('chart container has no bounding box');
|
||||
}
|
||||
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2, {
|
||||
button: 'right',
|
||||
});
|
||||
await expect(contextMenu).toBeVisible({ timeout: 2000 });
|
||||
}).toPass({ timeout: TIMEOUT.CHART_RENDER });
|
||||
await page
|
||||
.getByRole('menuitem', { name: 'Drill to detail', exact: true })
|
||||
.click();
|
||||
|
||||
const modal = page.locator('.ant-modal:visible');
|
||||
await expect(modal).toBeVisible({ timeout: TIMEOUT.FORM_LOAD });
|
||||
|
||||
// Wait for the samples request to resolve into a rendered table: the row
|
||||
// count pill and the virtualized body both come from the loaded page.
|
||||
const tableBody = modal.locator('.virtual-grid');
|
||||
await expect(tableBody).toBeAttached({ timeout: TIMEOUT.CHART_RENDER });
|
||||
|
||||
// The regression collapses the body *after* first paint, so first let the
|
||||
// height settle above the floor (retrying is safe here: the collapse is
|
||||
// persistent, so a broken build never finds a passing read and this
|
||||
// still times out red), then, without any further retrying, take extra
|
||||
// spaced reads and require each to hold within tolerance of that settled
|
||||
// baseline — a delayed or partial collapse can no longer be masked by an
|
||||
// early-exit retry that stopped at the first passing sample.
|
||||
let baselineHeight = 0;
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
baselineHeight = (await tableBody.boundingBox())?.height ?? 0;
|
||||
return baselineHeight;
|
||||
},
|
||||
{ timeout: TIMEOUT.CHART_RENDER },
|
||||
)
|
||||
.toBeGreaterThan(MIN_STABLE_BODY_HEIGHT);
|
||||
for (let sample = 0; sample < HEIGHT_SAMPLE_COUNT; sample += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop -- reads must be sequential
|
||||
// and spaced out to observe a delayed collapse; there is nothing to
|
||||
// parallelize.
|
||||
await page.waitForTimeout(HEIGHT_SAMPLE_INTERVAL_MS);
|
||||
// eslint-disable-next-line no-await-in-loop -- see above
|
||||
const box = await tableBody.boundingBox();
|
||||
const height = box?.height ?? 0;
|
||||
expect(height).toBeGreaterThan(MIN_STABLE_BODY_HEIGHT);
|
||||
expect(Math.abs(height - baselineHeight)).toBeLessThanOrEqual(
|
||||
baselineHeight * HEIGHT_DRIFT_TOLERANCE,
|
||||
);
|
||||
}
|
||||
|
||||
// And the rows are real data, not just an expanded empty scroller:
|
||||
// birth_names sample rows always carry a gender value.
|
||||
await expect(modal.getByText(/^(boy|girl)$/).first()).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
import { getDatasetByName } from '../../helpers/api/dataset';
|
||||
import { extractIdFromResponse } from '../../helpers/api/assertions';
|
||||
import { DashboardPage } from '../../pages/DashboardPage';
|
||||
import { TIMEOUT } from '../../utils/constants';
|
||||
import {
|
||||
buildFilterJsonMetadata,
|
||||
buildSelectFilter,
|
||||
@@ -54,6 +55,11 @@ const FILTER_VALUE = 'boy';
|
||||
testWithAssets(
|
||||
'Mixed chart applies dashboard filter to both queries (#29519)',
|
||||
async ({ page, testAssets }) => {
|
||||
// Four API round-trips of setup precede a full dashboard load with a
|
||||
// preselected native filter, matching the other dashboard specs that build
|
||||
// their fixtures over the API rather than importing them.
|
||||
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
|
||||
const dataset = await getDatasetByName(page, DATASET_NAME);
|
||||
if (!dataset) {
|
||||
throw new Error(`Dataset ${DATASET_NAME} not found`);
|
||||
|
||||
@@ -707,12 +707,8 @@ const config: ControlPanelConfig = {
|
||||
const extraColorChoices = hasTimeComparison
|
||||
? [
|
||||
{
|
||||
value: ColorSchemeEnum.Green,
|
||||
label: t('Green for increase, red for decrease'),
|
||||
},
|
||||
{
|
||||
value: ColorSchemeEnum.Red,
|
||||
label: t('Red for increase, green for decrease'),
|
||||
label: t('Trend colors'),
|
||||
colors: [ColorSchemeEnum.Green, ColorSchemeEnum.Red],
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
@@ -154,12 +154,8 @@ test('extraColorChoices included when time comparison is enabled', () => {
|
||||
|
||||
expect(result.extraColorChoices).toEqual([
|
||||
{
|
||||
value: ColorSchemeEnum.Green,
|
||||
label: expect.stringContaining('Green for increase'),
|
||||
},
|
||||
{
|
||||
value: ColorSchemeEnum.Red,
|
||||
label: expect.stringContaining('Red for increase'),
|
||||
label: expect.stringContaining('Trend colors'),
|
||||
colors: [ColorSchemeEnum.Green, ColorSchemeEnum.Red],
|
||||
},
|
||||
]);
|
||||
expect(result.columnOptions).not.toEqual(
|
||||
|
||||
@@ -28,6 +28,14 @@ import { convertUTCTimestampToLocal } from './utils';
|
||||
// Define a minimal constructor interface for use in this file.
|
||||
interface CalHeatMapInstance {
|
||||
init(config: Record<string, unknown>): void;
|
||||
destroy(): null;
|
||||
}
|
||||
|
||||
const calendarInstances = new WeakMap<HTMLElement, CalHeatMapInstance[]>();
|
||||
|
||||
export function destroyCalendarInstances(element: HTMLElement) {
|
||||
calendarInstances.get(element)?.forEach(calendar => calendar.destroy());
|
||||
calendarInstances.delete(element);
|
||||
}
|
||||
const CalHeatMap = CalHeatMapImport as unknown as new () => CalHeatMapInstance;
|
||||
|
||||
@@ -82,6 +90,10 @@ function Calendar(element: HTMLElement, props: CalendarProps) {
|
||||
colorRangeStart,
|
||||
} = props;
|
||||
|
||||
destroyCalendarInstances(element);
|
||||
const instances: CalHeatMapInstance[] = [];
|
||||
calendarInstances.set(element, instances);
|
||||
|
||||
const container = d3Select(element)
|
||||
.classed('superset-legacy-chart-calendar', true)
|
||||
.style('height', height);
|
||||
@@ -120,12 +132,13 @@ function Calendar(element: HTMLElement, props: CalendarProps) {
|
||||
const colorScheme = getSequentialSchemeRegistry().get(linearColorScheme);
|
||||
const colorScale = colorScheme
|
||||
? colorScheme.createLinearScale(extents)
|
||||
: (_v: number) => '#ccc'; // fallback if scheme not found
|
||||
: () => '#ccc'; // fallback if scheme not found
|
||||
|
||||
const legend = d3Range(steps).map(i => extents[0] + step * i);
|
||||
const legendColors = legend.map(x => colorScale(x));
|
||||
|
||||
const cal = new CalHeatMap();
|
||||
instances.push(cal);
|
||||
cal.init({
|
||||
start: convertUTCTimestampToLocal(data.start),
|
||||
data: timestamps,
|
||||
|
||||
@@ -19,7 +19,13 @@
|
||||
import { reactify } from '@superset-ui/core';
|
||||
import { styled, css, useTheme } from '@apache-superset/core/theme';
|
||||
import { Global } from '@emotion/react';
|
||||
import Component from './Calendar';
|
||||
import Component, { destroyCalendarInstances } from './Calendar';
|
||||
|
||||
function componentWillUnmount(this: { container?: HTMLDivElement }) {
|
||||
if (this.container) {
|
||||
destroyCalendarInstances(this.container);
|
||||
}
|
||||
}
|
||||
|
||||
// Type-erase the render function to allow flexible prop spreading in the wrapper.
|
||||
// The Calendar render function has typed props, but the wrapper passes props via spread
|
||||
@@ -29,6 +35,7 @@ const ReactComponent = reactify(
|
||||
container: HTMLDivElement,
|
||||
props: Record<string, unknown>,
|
||||
) => void,
|
||||
{ componentWillUnmount },
|
||||
);
|
||||
|
||||
interface CalendarWrapperProps {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export const CALENDAR_TOOLTIP_CLASS = 'superset-legacy-chart-calendar-tooltip';
|
||||
@@ -12,6 +12,7 @@
|
||||
import d3tip from 'd3-tip';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { getContrastingColor } from '@superset-ui/core';
|
||||
import { CALENDAR_TOOLTIP_CLASS } from '../tooltip';
|
||||
|
||||
var d3 = typeof require === 'function' ? require('d3') : window.d3;
|
||||
|
||||
@@ -22,7 +23,7 @@ var CalHeatMap = function () {
|
||||
|
||||
var self = this;
|
||||
self.tip = d3tip()
|
||||
.attr('class', 'd3-tip')
|
||||
.attr('class', `d3-tip ${CALENDAR_TOOLTIP_CLASS}`)
|
||||
.direction('n')
|
||||
.offset([-5, 0])
|
||||
.html(
|
||||
@@ -33,7 +34,7 @@ var CalHeatMap = function () {
|
||||
`,
|
||||
);
|
||||
self.legendTip = d3tip()
|
||||
.attr('class', 'd3-tip')
|
||||
.attr('class', `d3-tip ${CALENDAR_TOOLTIP_CLASS}`)
|
||||
.direction('n')
|
||||
.offset([-5, 0])
|
||||
.html(d => self.options.valueFormatter(d));
|
||||
@@ -3444,6 +3445,14 @@ CalHeatMap.prototype = {
|
||||
destroy: function (callback) {
|
||||
'use strict';
|
||||
|
||||
this.tip.destroy();
|
||||
this.legendTip.destroy();
|
||||
|
||||
// init() can fail validation before creating the calendar root.
|
||||
if (!this.root) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.root
|
||||
.transition()
|
||||
.duration(this.options.animationDuration)
|
||||
|
||||
@@ -0,0 +1,585 @@
|
||||
/**
|
||||
* 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 { Component, memo, StrictMode, type ReactNode } from 'react';
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { CALENDAR_TOOLTIP_CLASS } from '../src/tooltip';
|
||||
|
||||
interface MockCalHeatMapConfig {
|
||||
itemSelector: Element;
|
||||
}
|
||||
|
||||
type MetricNameInput = string | string[];
|
||||
|
||||
let mockNextInstanceId = 0;
|
||||
let mockNextOwnerId = 0;
|
||||
let mockInitCallCount = 0;
|
||||
let mockThrowOnInitCall: number | null = null;
|
||||
let mockDestroyCallCount = 0;
|
||||
let mockDestroyedInstanceIds: string[] = [];
|
||||
|
||||
const mockTheme = {
|
||||
colorBgElevated: '#ffffff',
|
||||
};
|
||||
|
||||
jest.mock('../src/vendor/cal-heatmap', () => ({
|
||||
__esModule: true,
|
||||
default: class MockCalHeatMap {
|
||||
private instanceId?: string;
|
||||
|
||||
private tooltips: HTMLElement[] = [];
|
||||
|
||||
init(config: MockCalHeatMapConfig) {
|
||||
const {
|
||||
CALENDAR_TOOLTIP_CLASS: mockCalendarTooltipClass,
|
||||
} = require('../src/tooltip');
|
||||
|
||||
mockInitCallCount += 1;
|
||||
if (mockThrowOnInitCall === mockInitCallCount) {
|
||||
throw new Error('Mock CalHeatMap init failure');
|
||||
}
|
||||
|
||||
mockNextInstanceId += 1;
|
||||
this.instanceId = String(mockNextInstanceId);
|
||||
|
||||
const owner = config.itemSelector.closest(
|
||||
'.superset-legacy-chart-calendar',
|
||||
) as HTMLElement | null;
|
||||
if (!owner) {
|
||||
throw new Error('Expected tooltip owner calendar container');
|
||||
}
|
||||
|
||||
if (!owner.dataset.tooltipOwner) {
|
||||
mockNextOwnerId += 1;
|
||||
owner.dataset.tooltipOwner = `calendar-owner-${mockNextOwnerId}`;
|
||||
}
|
||||
|
||||
const { tooltipOwner } = owner.dataset;
|
||||
if (!tooltipOwner) {
|
||||
throw new Error('Expected owner-specific tooltip marker');
|
||||
}
|
||||
|
||||
this.tooltips = [0, 1].map(index => {
|
||||
const tooltip = global.document.createElement('div');
|
||||
tooltip.className = ['d3-tip', mockCalendarTooltipClass].join(' ');
|
||||
tooltip.dataset.tooltipOwner = tooltipOwner;
|
||||
tooltip.dataset.tooltipInstance = this.instanceId;
|
||||
tooltip.dataset.tooltipIndex = String(index);
|
||||
tooltip.textContent = `${tooltipOwner}-tooltip-${this.instanceId}-${index}`;
|
||||
global.document.body.appendChild(tooltip);
|
||||
return tooltip;
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
mockDestroyCallCount += 1;
|
||||
if (this.instanceId) {
|
||||
mockDestroyedInstanceIds.push(this.instanceId);
|
||||
}
|
||||
this.tooltips.forEach(tooltip => tooltip.remove());
|
||||
this.tooltips = [];
|
||||
return null;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const ReactCalendar = require('../src/ReactCalendar').default;
|
||||
const Calendar = require('../src/Calendar').default;
|
||||
|
||||
interface CalendarHarnessProps {
|
||||
firstMetricNames: MetricNameInput;
|
||||
secondMetricNames?: MetricNameInput;
|
||||
showFirstCalendar?: boolean;
|
||||
}
|
||||
|
||||
interface CalendarErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface CalendarErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
const CALENDAR_START = 1704067200000;
|
||||
|
||||
function createCalendarProps(metricNames: MetricNameInput) {
|
||||
const normalizedMetricNames = Array.isArray(metricNames)
|
||||
? metricNames
|
||||
: [metricNames];
|
||||
|
||||
return {
|
||||
data: {
|
||||
data: Object.fromEntries(
|
||||
normalizedMetricNames.map((metricName, index) => [
|
||||
metricName,
|
||||
{
|
||||
[String(CALENDAR_START)]: index * 2 + 1,
|
||||
[String(CALENDAR_START + 86400000)]: index * 2 + 2,
|
||||
},
|
||||
]),
|
||||
),
|
||||
domain: 'month',
|
||||
range: 1,
|
||||
start: CALENDAR_START,
|
||||
subdomain: 'day',
|
||||
},
|
||||
height: 160,
|
||||
domainGranularity: 'month',
|
||||
linearColorScheme: 'schemeRdYlBu',
|
||||
showLegend: false,
|
||||
showMetricName: true,
|
||||
showValues: false,
|
||||
steps: 3,
|
||||
subdomainGranularity: 'day',
|
||||
timeFormatter: (value: number | string) => String(value),
|
||||
valueFormatter: (value: number) => String(value),
|
||||
verboseMap: Object.fromEntries(
|
||||
normalizedMetricNames.map(metricName => [metricName, metricName]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
class CalendarErrorBoundary extends Component<
|
||||
CalendarErrorBoundaryProps,
|
||||
CalendarErrorBoundaryState
|
||||
> {
|
||||
state: CalendarErrorBoundaryState = {
|
||||
hasError: false,
|
||||
};
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return <div>Calendar fallback</div>;
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
const StableSecondCalendar = memo(function StableSecondCalendar({
|
||||
metricNames,
|
||||
}: {
|
||||
metricNames: MetricNameInput;
|
||||
}) {
|
||||
return (
|
||||
<div data-test="calendar-second">
|
||||
<ReactCalendar {...createCalendarProps(metricNames)} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function CalendarHarness({
|
||||
firstMetricNames,
|
||||
secondMetricNames = 'second-metric',
|
||||
showFirstCalendar = true,
|
||||
}: CalendarHarnessProps) {
|
||||
return (
|
||||
<>
|
||||
{showFirstCalendar ? (
|
||||
<div data-test="calendar-first">
|
||||
<ReactCalendar {...createCalendarProps(firstMetricNames)} />
|
||||
</div>
|
||||
) : null}
|
||||
<StableSecondCalendar metricNames={secondMetricNames} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getCalendarOwner(testId: string) {
|
||||
const owner = screen
|
||||
.getByTestId(testId)
|
||||
.querySelector('.superset-legacy-chart-calendar');
|
||||
|
||||
if (!(owner instanceof HTMLElement)) {
|
||||
throw new Error(`Expected mounted calendar owner for ${testId}`);
|
||||
}
|
||||
|
||||
return owner;
|
||||
}
|
||||
|
||||
function getTooltipOwnerId(owner: HTMLElement) {
|
||||
const tooltipOwnerId = owner.dataset.tooltipOwner;
|
||||
|
||||
if (!tooltipOwnerId) {
|
||||
throw new Error('Expected mounted calendar to have a tooltip owner id');
|
||||
}
|
||||
|
||||
return tooltipOwnerId;
|
||||
}
|
||||
|
||||
function getOwnerTooltips(tooltipOwnerId: string) {
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLElement>(
|
||||
`.d3-tip[data-tooltip-owner="${tooltipOwnerId}"]`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function getCalendarTooltips() {
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLElement>(`.${CALENDAR_TOOLTIP_CLASS}`),
|
||||
);
|
||||
}
|
||||
|
||||
function getTooltipInstanceIds(tooltips: HTMLElement[]) {
|
||||
return Array.from(
|
||||
new Set(tooltips.map(tooltip => tooltip.dataset.tooltipInstance)),
|
||||
)
|
||||
.filter((tooltipInstanceId): tooltipInstanceId is string =>
|
||||
Boolean(tooltipInstanceId),
|
||||
)
|
||||
.sort();
|
||||
}
|
||||
|
||||
function getSingleTooltipInstanceId(tooltips: HTMLElement[]) {
|
||||
const tooltipInstanceIds = getTooltipInstanceIds(tooltips);
|
||||
|
||||
expect(tooltipInstanceIds).toHaveLength(1);
|
||||
|
||||
return tooltipInstanceIds[0];
|
||||
}
|
||||
|
||||
function flushPendingTimersIfNeeded() {
|
||||
try {
|
||||
act(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
});
|
||||
} catch {
|
||||
// Ignore tests that did not opt into fake timers.
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
flushPendingTimersIfNeeded();
|
||||
jest.useRealTimers();
|
||||
mockNextInstanceId = 0;
|
||||
mockNextOwnerId = 0;
|
||||
mockInitCallCount = 0;
|
||||
mockThrowOnInitCall = null;
|
||||
mockDestroyCallCount = 0;
|
||||
mockDestroyedInstanceIds = [];
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
test('rerender and unmount clean up only the affected calendar tooltips', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const { rerender, unmount } = render(
|
||||
<CalendarHarness firstMetricNames="first-metric-initial" />,
|
||||
);
|
||||
|
||||
const firstCalendarOwner = getCalendarOwner('calendar-first');
|
||||
const secondCalendarOwner = getCalendarOwner('calendar-second');
|
||||
|
||||
const firstTooltipOwnerId = getTooltipOwnerId(firstCalendarOwner);
|
||||
const secondTooltipOwnerId = getTooltipOwnerId(secondCalendarOwner);
|
||||
|
||||
const firstInitialTooltips = getOwnerTooltips(firstTooltipOwnerId);
|
||||
const secondInitialTooltips = getOwnerTooltips(secondTooltipOwnerId);
|
||||
|
||||
expect(firstInitialTooltips).toHaveLength(2);
|
||||
expect(secondInitialTooltips).toHaveLength(2);
|
||||
expect(document.querySelectorAll(`.${CALENDAR_TOOLTIP_CLASS}`)).toHaveLength(
|
||||
4,
|
||||
);
|
||||
|
||||
const firstInitialInstanceId =
|
||||
getSingleTooltipInstanceId(firstInitialTooltips);
|
||||
const secondInitialInstanceId = getSingleTooltipInstanceId(
|
||||
secondInitialTooltips,
|
||||
);
|
||||
|
||||
rerender(<CalendarHarness firstMetricNames="first-metric-rerendered" />);
|
||||
|
||||
const firstRerenderedTooltips = getOwnerTooltips(firstTooltipOwnerId);
|
||||
const secondPreservedTooltips = getOwnerTooltips(secondTooltipOwnerId);
|
||||
|
||||
expect(firstRerenderedTooltips).toHaveLength(2);
|
||||
expect(secondPreservedTooltips).toHaveLength(2);
|
||||
expect(document.querySelectorAll(`.${CALENDAR_TOOLTIP_CLASS}`)).toHaveLength(
|
||||
4,
|
||||
);
|
||||
expect(firstInitialTooltips.every(tooltip => !tooltip.isConnected)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(secondInitialTooltips.every(tooltip => tooltip.isConnected)).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
expect(getSingleTooltipInstanceId(firstRerenderedTooltips)).not.toEqual(
|
||||
firstInitialInstanceId,
|
||||
);
|
||||
expect(getSingleTooltipInstanceId(secondPreservedTooltips)).toEqual(
|
||||
secondInitialInstanceId,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<CalendarHarness
|
||||
firstMetricNames="first-metric-rerendered"
|
||||
showFirstCalendar={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getOwnerTooltips(firstTooltipOwnerId)).toHaveLength(0);
|
||||
expect(getOwnerTooltips(secondTooltipOwnerId)).toHaveLength(2);
|
||||
expect(document.querySelectorAll(`.${CALENDAR_TOOLTIP_CLASS}`)).toHaveLength(
|
||||
2,
|
||||
);
|
||||
expect(
|
||||
getSingleTooltipInstanceId(getOwnerTooltips(secondTooltipOwnerId)),
|
||||
).toEqual(secondInitialInstanceId);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('multi-metric calendar rerender and unmount clean up every owned tooltip while preserving siblings', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const { rerender, unmount } = render(
|
||||
<CalendarHarness
|
||||
firstMetricNames={['first-metric-a', 'first-metric-b']}
|
||||
secondMetricNames="second-metric-initial"
|
||||
/>,
|
||||
);
|
||||
|
||||
const firstCalendarOwner = getCalendarOwner('calendar-first');
|
||||
const secondCalendarOwner = getCalendarOwner('calendar-second');
|
||||
|
||||
const firstTooltipOwnerId = getTooltipOwnerId(firstCalendarOwner);
|
||||
const secondTooltipOwnerId = getTooltipOwnerId(secondCalendarOwner);
|
||||
|
||||
const firstInitialTooltips = getOwnerTooltips(firstTooltipOwnerId);
|
||||
const secondInitialTooltips = getOwnerTooltips(secondTooltipOwnerId);
|
||||
|
||||
expect(firstInitialTooltips).toHaveLength(4);
|
||||
expect(secondInitialTooltips).toHaveLength(2);
|
||||
expect(document.querySelectorAll(`.${CALENDAR_TOOLTIP_CLASS}`)).toHaveLength(
|
||||
6,
|
||||
);
|
||||
|
||||
const firstInitialInstanceIds = getTooltipInstanceIds(firstInitialTooltips);
|
||||
const secondInitialInstanceIds = getTooltipInstanceIds(secondInitialTooltips);
|
||||
|
||||
expect(firstInitialInstanceIds).toHaveLength(2);
|
||||
expect(secondInitialInstanceIds).toHaveLength(1);
|
||||
|
||||
rerender(
|
||||
<CalendarHarness
|
||||
firstMetricNames={['first-metric-c', 'first-metric-d']}
|
||||
secondMetricNames="second-metric-initial"
|
||||
/>,
|
||||
);
|
||||
|
||||
const firstRerenderedTooltips = getOwnerTooltips(firstTooltipOwnerId);
|
||||
const secondPreservedTooltips = getOwnerTooltips(secondTooltipOwnerId);
|
||||
|
||||
expect(firstRerenderedTooltips).toHaveLength(4);
|
||||
expect(secondPreservedTooltips).toHaveLength(2);
|
||||
expect(document.querySelectorAll(`.${CALENDAR_TOOLTIP_CLASS}`)).toHaveLength(
|
||||
6,
|
||||
);
|
||||
expect(firstInitialTooltips.every(tooltip => !tooltip.isConnected)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(secondInitialTooltips.every(tooltip => tooltip.isConnected)).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
expect(getTooltipInstanceIds(firstRerenderedTooltips)).toHaveLength(2);
|
||||
expect(getTooltipInstanceIds(firstRerenderedTooltips)).not.toEqual(
|
||||
firstInitialInstanceIds,
|
||||
);
|
||||
expect(getTooltipInstanceIds(secondPreservedTooltips)).toEqual(
|
||||
secondInitialInstanceIds,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<CalendarHarness
|
||||
firstMetricNames={['first-metric-c', 'first-metric-d']}
|
||||
secondMetricNames="second-metric-initial"
|
||||
showFirstCalendar={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getOwnerTooltips(firstTooltipOwnerId)).toHaveLength(0);
|
||||
expect(getOwnerTooltips(secondTooltipOwnerId)).toHaveLength(2);
|
||||
expect(document.querySelectorAll(`.${CALENDAR_TOOLTIP_CLASS}`)).toHaveLength(
|
||||
2,
|
||||
);
|
||||
expect(getTooltipInstanceIds(getOwnerTooltips(secondTooltipOwnerId))).toEqual(
|
||||
secondInitialInstanceIds,
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('Calendar destroys previously initialized metric instances after a later metric init failure', () => {
|
||||
const calendarOwner = document.createElement('div');
|
||||
document.body.appendChild(calendarOwner);
|
||||
|
||||
mockThrowOnInitCall = 2;
|
||||
|
||||
expect(() => {
|
||||
Calendar(calendarOwner, {
|
||||
...createCalendarProps(['failing-metric-a', 'failing-metric-b']),
|
||||
theme: mockTheme,
|
||||
});
|
||||
}).toThrow('Mock CalHeatMap init failure');
|
||||
|
||||
const failedRenderTooltips = getOwnerTooltips(
|
||||
getTooltipOwnerId(calendarOwner),
|
||||
);
|
||||
const failedRenderInstanceIds = getTooltipInstanceIds(failedRenderTooltips);
|
||||
|
||||
expect(failedRenderTooltips).toHaveLength(2);
|
||||
expect(failedRenderInstanceIds).toHaveLength(1);
|
||||
|
||||
mockThrowOnInitCall = null;
|
||||
Calendar(calendarOwner, {
|
||||
...createCalendarProps('recovered-metric'),
|
||||
theme: mockTheme,
|
||||
});
|
||||
|
||||
const recoveredTooltips = getOwnerTooltips(getTooltipOwnerId(calendarOwner));
|
||||
|
||||
expect(failedRenderTooltips.every(tooltip => !tooltip.isConnected)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(recoveredTooltips).toHaveLength(2);
|
||||
expect(getTooltipInstanceIds(recoveredTooltips)).not.toEqual(
|
||||
failedRenderInstanceIds,
|
||||
);
|
||||
});
|
||||
|
||||
test('surviving calendar rerender preserves sibling cleanup after another calendar unmounts', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const { rerender, unmount } = render(
|
||||
<CalendarHarness
|
||||
firstMetricNames="first-metric-initial"
|
||||
secondMetricNames="second-metric-initial"
|
||||
/>,
|
||||
);
|
||||
|
||||
const firstCalendarOwner = getCalendarOwner('calendar-first');
|
||||
const secondCalendarOwner = getCalendarOwner('calendar-second');
|
||||
|
||||
const firstTooltipOwnerId = getTooltipOwnerId(firstCalendarOwner);
|
||||
const secondTooltipOwnerId = getTooltipOwnerId(secondCalendarOwner);
|
||||
|
||||
const secondInitialInstanceId = getSingleTooltipInstanceId(
|
||||
getOwnerTooltips(secondTooltipOwnerId),
|
||||
);
|
||||
|
||||
rerender(
|
||||
<CalendarHarness
|
||||
firstMetricNames="first-metric-initial"
|
||||
secondMetricNames="second-metric-initial"
|
||||
showFirstCalendar={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getOwnerTooltips(firstTooltipOwnerId)).toHaveLength(0);
|
||||
expect(getOwnerTooltips(secondTooltipOwnerId)).toHaveLength(2);
|
||||
expect(document.querySelectorAll(`.${CALENDAR_TOOLTIP_CLASS}`)).toHaveLength(
|
||||
2,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<CalendarHarness
|
||||
firstMetricNames="first-metric-initial"
|
||||
secondMetricNames="second-metric-rerendered"
|
||||
showFirstCalendar={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const secondRerenderedTooltips = getOwnerTooltips(secondTooltipOwnerId);
|
||||
|
||||
expect(getOwnerTooltips(firstTooltipOwnerId)).toHaveLength(0);
|
||||
expect(secondRerenderedTooltips).toHaveLength(2);
|
||||
expect(document.querySelectorAll(`.${CALENDAR_TOOLTIP_CLASS}`)).toHaveLength(
|
||||
2,
|
||||
);
|
||||
expect(getSingleTooltipInstanceId(secondRerenderedTooltips)).not.toEqual(
|
||||
secondInitialInstanceId,
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('StrictMode unmount cleans up calendar tooltips for multi-metric calendars', () => {
|
||||
const { unmount } = render(
|
||||
<StrictMode>
|
||||
<ReactCalendar
|
||||
{...createCalendarProps(['strict-metric-a', 'strict-metric-b'])}
|
||||
/>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
expect(getCalendarTooltips()).toHaveLength(4);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(getCalendarTooltips()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('ErrorBoundary fallback destroys partially initialized tooltips after a later init failure', async () => {
|
||||
const consoleErrorSpy = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
mockThrowOnInitCall = 2;
|
||||
|
||||
try {
|
||||
const { unmount } = render(
|
||||
<CalendarErrorBoundary>
|
||||
<ReactCalendar
|
||||
{...createCalendarProps(['failing-metric-a', 'failing-metric-b'])}
|
||||
/>
|
||||
</CalendarErrorBoundary>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Calendar fallback')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getCalendarTooltips()).toHaveLength(0);
|
||||
});
|
||||
|
||||
expect(mockDestroyCallCount).toBe(2);
|
||||
expect(mockDestroyedInstanceIds).toEqual(['1']);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(getCalendarTooltips()).toHaveLength(0);
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { CALENDAR_TOOLTIP_CLASS } from '../src/tooltip';
|
||||
|
||||
// The vendor file is @ts-nocheck, so its export lacks type info.
|
||||
// Mirror the minimal constructor interface defined in Calendar.ts.
|
||||
interface CalHeatMapInstance {
|
||||
init(config: Record<string, unknown>): void;
|
||||
destroy(): null;
|
||||
}
|
||||
|
||||
const createSVGPointDescriptor = Object.getOwnPropertyDescriptor(
|
||||
window.SVGSVGElement.prototype,
|
||||
'createSVGPoint',
|
||||
);
|
||||
|
||||
function installCreateSVGPointMock() {
|
||||
Object.defineProperty(window.SVGSVGElement.prototype, 'createSVGPoint', {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
matrixTransform: () => ({ x: 0, y: 0 }),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function restoreCreateSVGPointMock() {
|
||||
if (createSVGPointDescriptor) {
|
||||
Object.defineProperty(
|
||||
window.SVGSVGElement.prototype,
|
||||
'createSVGPoint',
|
||||
createSVGPointDescriptor,
|
||||
);
|
||||
} else {
|
||||
delete (
|
||||
window.SVGSVGElement.prototype as Partial<
|
||||
Pick<SVGSVGElement, 'createSVGPoint'>
|
||||
>
|
||||
).createSVGPoint;
|
||||
}
|
||||
}
|
||||
|
||||
function getCalendarTooltips() {
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLElement>(`.${CALENDAR_TOOLTIP_CLASS}`),
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetModules();
|
||||
restoreCreateSVGPointMock();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
test('CalHeatMap destroy tolerates partially initialized instances', () => {
|
||||
let CalHeatMap!: new () => CalHeatMapInstance;
|
||||
jest.isolateModules(() => {
|
||||
// eslint-disable-next-line global-require
|
||||
CalHeatMap = require('../src/vendor/cal-heatmap')
|
||||
.default as new () => CalHeatMapInstance;
|
||||
});
|
||||
|
||||
const partiallyInitializedHeatmap = new CalHeatMap();
|
||||
|
||||
expect(() => partiallyInitializedHeatmap.destroy()).not.toThrow();
|
||||
expect(getCalendarTooltips()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('CalHeatMap destroy removes only the destroyed instance tooltips', () => {
|
||||
installCreateSVGPointMock();
|
||||
|
||||
let CalHeatMap!: new () => CalHeatMapInstance;
|
||||
jest.isolateModules(() => {
|
||||
// eslint-disable-next-line global-require
|
||||
CalHeatMap = require('../src/vendor/cal-heatmap')
|
||||
.default as new () => CalHeatMapInstance;
|
||||
});
|
||||
|
||||
const firstCalendar = document.createElement('div');
|
||||
const secondCalendar = document.createElement('div');
|
||||
document.body.append(firstCalendar, secondCalendar);
|
||||
|
||||
const firstHeatmap = new CalHeatMap();
|
||||
firstHeatmap.init({
|
||||
itemSelector: firstCalendar,
|
||||
paintOnLoad: false,
|
||||
tooltip: true,
|
||||
valueFormatter: String,
|
||||
timeFormatter: String,
|
||||
});
|
||||
|
||||
const firstTooltips = getCalendarTooltips();
|
||||
expect(firstTooltips).toHaveLength(2);
|
||||
|
||||
const secondHeatmap = new CalHeatMap();
|
||||
secondHeatmap.init({
|
||||
itemSelector: secondCalendar,
|
||||
paintOnLoad: false,
|
||||
tooltip: true,
|
||||
valueFormatter: String,
|
||||
timeFormatter: String,
|
||||
});
|
||||
|
||||
const allTooltips = getCalendarTooltips();
|
||||
const secondTooltips = allTooltips.filter(
|
||||
tooltip => !firstTooltips.includes(tooltip),
|
||||
);
|
||||
|
||||
expect(secondTooltips).toHaveLength(2);
|
||||
|
||||
firstHeatmap.destroy();
|
||||
|
||||
expect(firstTooltips.every(tooltip => !tooltip.isConnected)).toBe(true);
|
||||
expect(secondTooltips.every(tooltip => tooltip.isConnected)).toBe(true);
|
||||
expect(getCalendarTooltips()).toHaveLength(2);
|
||||
|
||||
secondHeatmap.destroy();
|
||||
|
||||
expect(getCalendarTooltips()).toHaveLength(0);
|
||||
});
|
||||
+12
-10
@@ -690,11 +690,11 @@ export default function transformProps(
|
||||
})()
|
||||
: xAxisFormatter;
|
||||
|
||||
const yAxisTitleMarginPx = convertInteger(yAxisTitleMargin);
|
||||
const xAxisTitleMarginPx = convertInteger(xAxisTitleMargin);
|
||||
const addYAxisTitleOffset =
|
||||
!!(yAxisTitle || yAxisTitleSecondary) &&
|
||||
convertInteger(yAxisTitleMargin) !== 0;
|
||||
const addXAxisTitleOffset =
|
||||
!!xAxisTitle && convertInteger(xAxisTitleMargin) !== 0;
|
||||
!!(yAxisTitle || yAxisTitleSecondary) && yAxisTitleMarginPx !== 0;
|
||||
const addXAxisTitleOffset = !!xAxisTitle && xAxisTitleMarginPx !== 0;
|
||||
const baseChartPadding = getPadding(
|
||||
showLegend,
|
||||
legendOrientation,
|
||||
@@ -703,8 +703,8 @@ export default function transformProps(
|
||||
legendMargin,
|
||||
addXAxisTitleOffset,
|
||||
yAxisTitlePosition,
|
||||
convertInteger(yAxisTitleMargin),
|
||||
convertInteger(xAxisTitleMargin),
|
||||
yAxisTitleMarginPx,
|
||||
xAxisTitleMarginPx,
|
||||
);
|
||||
const legendData = series
|
||||
.filter(
|
||||
@@ -748,8 +748,8 @@ export default function transformProps(
|
||||
effectiveLegendMargin,
|
||||
addXAxisTitleOffset,
|
||||
yAxisTitlePosition,
|
||||
convertInteger(yAxisTitleMargin),
|
||||
convertInteger(xAxisTitleMargin),
|
||||
yAxisTitleMarginPx,
|
||||
xAxisTitleMarginPx,
|
||||
);
|
||||
|
||||
const { setDataMask = () => {}, onContextMenu } = hooks;
|
||||
@@ -764,7 +764,7 @@ export default function transformProps(
|
||||
xAxis: {
|
||||
type: xAxisType,
|
||||
name: xAxisTitle,
|
||||
nameGap: convertInteger(xAxisTitleMargin),
|
||||
nameGap: xAxisTitleMarginPx,
|
||||
nameLocation: 'middle',
|
||||
axisLabel: {
|
||||
hideOverlap: !(xAxisType === AxisType.Time && xAxisLabelRotation !== 0),
|
||||
@@ -821,7 +821,7 @@ export default function transformProps(
|
||||
},
|
||||
scale: truncateYAxis,
|
||||
name: yAxisTitle,
|
||||
nameGap: convertInteger(yAxisTitleMargin),
|
||||
nameGap: yAxisTitleMarginPx,
|
||||
nameLocation: yAxisTitlePosition === 'Left' ? 'middle' : 'end',
|
||||
alignTicks,
|
||||
},
|
||||
@@ -844,6 +844,8 @@ export default function transformProps(
|
||||
},
|
||||
scale: truncateYAxis,
|
||||
name: yAxisTitleSecondary,
|
||||
nameGap: yAxisTitleMarginPx,
|
||||
nameLocation: yAxisTitlePosition === 'Left' ? 'middle' : 'end',
|
||||
alignTicks,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -55,15 +55,19 @@ import { getDefaultTooltip } from '../utils/tooltip';
|
||||
import { Refs } from '../types';
|
||||
import { getContributionLabel } from './utils';
|
||||
|
||||
const percentFormatter = getNumberFormatter(NumberFormats.PERCENT_2_POINT);
|
||||
const defaultPercentFormatter = getNumberFormatter(
|
||||
NumberFormats.PERCENT_2_POINT,
|
||||
);
|
||||
|
||||
export function parseParams({
|
||||
params,
|
||||
numberFormatter,
|
||||
percentFormatter = defaultPercentFormatter,
|
||||
sanitizeName = false,
|
||||
}: {
|
||||
params: Pick<CallbackDataParams, 'name' | 'value' | 'percent'>;
|
||||
numberFormatter: ValueFormatter;
|
||||
percentFormatter?: ValueFormatter;
|
||||
sanitizeName?: boolean;
|
||||
}): string[] {
|
||||
const { name: rawName = '', value, percent } = params;
|
||||
@@ -296,6 +300,9 @@ export default function transformProps(
|
||||
currencyCodeColumn,
|
||||
detectedCurrency,
|
||||
);
|
||||
const percentFormatter = numberFormat?.endsWith('%')
|
||||
? getNumberFormatter(numberFormat)
|
||||
: defaultPercentFormatter;
|
||||
|
||||
let data = rawData;
|
||||
const otherRows: DataRecord[] = [];
|
||||
@@ -446,6 +453,7 @@ export default function transformProps(
|
||||
const [name, formattedValue, formattedPercent] = parseParams({
|
||||
params,
|
||||
numberFormatter,
|
||||
percentFormatter,
|
||||
});
|
||||
switch (labelType) {
|
||||
case EchartsPieLabelType.Key:
|
||||
@@ -566,6 +574,7 @@ export default function transformProps(
|
||||
const [name, formattedValue, formattedPercent] = parseParams({
|
||||
params,
|
||||
numberFormatter,
|
||||
percentFormatter,
|
||||
sanitizeName: true,
|
||||
});
|
||||
if (params?.data?.isOther) {
|
||||
|
||||
@@ -182,6 +182,50 @@ function getSymbolMarker(symbol: string, color: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the fully-built ECharts series (each already carrying its resolved
|
||||
* `stack` id, see `getTimeCompareStackId`), find the largest per-index total
|
||||
* across all series sharing a stack, then return the largest such total
|
||||
* across all stacks. Series without a `stack` id (e.g. annotation layers)
|
||||
* are ignored since they aren't part of any stacked total.
|
||||
*/
|
||||
function getMaxStackedValueByStack(
|
||||
series: SeriesOption[],
|
||||
isHorizontal: boolean,
|
||||
): number {
|
||||
const totalsByStack = new Map<string, number[]>();
|
||||
series.forEach(entry => {
|
||||
const rawStackId = (entry as { stack?: unknown }).stack;
|
||||
const stackId = typeof rawStackId === 'string' ? rawStackId : undefined;
|
||||
if (!stackId || !Array.isArray(entry.data)) return;
|
||||
const totals = totalsByStack.get(stackId) ?? [];
|
||||
(entry.data as unknown[]).forEach((datum, idx) => {
|
||||
let value: unknown = datum;
|
||||
if (Array.isArray(datum)) {
|
||||
value = isHorizontal ? datum[0] : datum[1];
|
||||
} else if (datum && typeof datum === 'object' && 'value' in datum) {
|
||||
const rawValue = (datum as { value: unknown }).value;
|
||||
if (Array.isArray(rawValue)) {
|
||||
value = isHorizontal ? rawValue[0] : rawValue[1];
|
||||
} else {
|
||||
value = rawValue;
|
||||
}
|
||||
}
|
||||
if (typeof value === 'number' && !Number.isNaN(value)) {
|
||||
totals[idx] = (totals[idx] ?? 0) + value;
|
||||
}
|
||||
});
|
||||
totalsByStack.set(stackId, totals);
|
||||
});
|
||||
let max = Number.NEGATIVE_INFINITY;
|
||||
totalsByStack.forEach(totals => {
|
||||
totals.forEach(value => {
|
||||
if (value > max) max = value;
|
||||
});
|
||||
});
|
||||
return max;
|
||||
}
|
||||
|
||||
export default function transformProps(
|
||||
chartProps: EchartsTimeseriesChartProps,
|
||||
): TimeseriesChartTransformedProps {
|
||||
@@ -318,6 +362,14 @@ export default function transformProps(
|
||||
rebaseToPercentChange(forecastRebasedData, xAxisLabel || DTTM_ALIAS)
|
||||
: forecastRebasedData;
|
||||
const isHorizontal = orientation === OrientationType.Horizontal;
|
||||
// rebasedData's keys have already been through rebaseForecastDatum, which
|
||||
// renames a key to its verboseMap entry when one is configured for that
|
||||
// metric. extraMetricLabels must be mapped the same way, or a sort-only
|
||||
// metric with a verbose_name set would silently fail to match here (and in
|
||||
// extractSeries below, which has the same requirement).
|
||||
const extraMetricLabels = extractExtraMetrics(chartProps.rawFormData)
|
||||
.map(getMetricLabel)
|
||||
.map(label => verboseMap[label] ?? label);
|
||||
const { totalStackedValues, thresholdValues } = extractDataTotalValues(
|
||||
rebasedData,
|
||||
{
|
||||
@@ -325,11 +377,9 @@ export default function transformProps(
|
||||
percentageThreshold,
|
||||
xAxisCol: xAxisLabel,
|
||||
legendState,
|
||||
extraMetricLabels,
|
||||
},
|
||||
);
|
||||
const extraMetricLabels = extractExtraMetrics(chartProps.rawFormData).map(
|
||||
getMetricLabel,
|
||||
);
|
||||
|
||||
const isMultiSeries = groupBy.length || metrics?.length > 1;
|
||||
const xAxisDataType = dataTypes?.[xAxisLabel] ?? dataTypes?.[xAxisOrig];
|
||||
@@ -777,6 +827,14 @@ export default function transformProps(
|
||||
|
||||
series.unshift(baselineSeries);
|
||||
}
|
||||
|
||||
// Snapshot the observation-series count before annotation layers are
|
||||
// appended below. Annotation series (formula/interval/event/timeseries)
|
||||
// carry their own configured values, which the Y axis clamp further
|
||||
// below must not rewrite, or an annotation could be moved to a location
|
||||
// that doesn't match its configuration.
|
||||
const observationSeriesCount = series.length;
|
||||
|
||||
const selectedValues = (filterState.selectedValues || []).reduce(
|
||||
(acc: Record<string, number>, selectedValue: string) => {
|
||||
const index = series.findIndex(({ name }) => name === selectedValue);
|
||||
@@ -869,7 +927,38 @@ export default function transformProps(
|
||||
// default to 0-100% range when doing row-level contribution chart
|
||||
if ((contributionMode === 'row' || isAreaExpand) && stack) {
|
||||
if (yAxisMin === undefined) yAxisMin = 0;
|
||||
if (yAxisMax === undefined) yAxisMax = 1;
|
||||
if (yAxisMax === undefined) {
|
||||
if (contributionMode === 'row') {
|
||||
// Contribution percentages are normalized so each stacked row should
|
||||
// sum to 1, but floating point rounding can push the actual stacked
|
||||
// total fractionally above 1 (e.g. 1.0000000000000002). Hard-capping
|
||||
// the axis max at exactly 1 in that case causes echarts to clip the
|
||||
// topmost stacked segment entirely rather than just rounding the
|
||||
// pixel width, which is most visible in horizontal orientation where
|
||||
// this axis is swapped onto the x-axis. Pad the max up to the actual
|
||||
// stacked total when it exceeds 1 so no segment gets clipped.
|
||||
//
|
||||
// This padding only applies in row-contribution mode: for an Expand
|
||||
// ("100% stacked") chart, `sortedTotalValues` holds the raw,
|
||||
// pre-normalization row totals (e.g. 100), not values near 1, so
|
||||
// padding against them here would stretch the axis out to the raw
|
||||
// total instead of the intended 0-1 range.
|
||||
//
|
||||
// `sortedTotalValues` sums every series value per row regardless of
|
||||
// which ECharts stack it belongs to, but with time_compare each
|
||||
// comparison period is its own independently-normalized stack (see
|
||||
// getTimeCompareStackId), so a chart with N comparison periods would
|
||||
// sum to ~N instead of ~1. Compute the max per stack instead, using
|
||||
// the already-built series (which carry the resolved stack ids).
|
||||
const stackedTotalMax = getMaxStackedValueByStack(series, isHorizontal);
|
||||
yAxisMax =
|
||||
Number.isFinite(stackedTotalMax) && stackedTotalMax > 1
|
||||
? stackedTotalMax
|
||||
: 1;
|
||||
} else {
|
||||
yAxisMax = 1;
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
logAxis &&
|
||||
yAxisMin === undefined &&
|
||||
@@ -905,6 +994,60 @@ export default function transformProps(
|
||||
}
|
||||
}
|
||||
|
||||
// Whenever a Y axis bound is defined, whether explicitly configured or
|
||||
// derived above from the data, clamp series values to those bounds
|
||||
// instead of leaving raw out-of-range values in place. ECharts axis
|
||||
// clipping can otherwise drop an out-of-bounds point (and the line
|
||||
// segments around it) entirely rather than truncating it at the
|
||||
// boundary (see https://github.com/apache/superset/issues/27449).
|
||||
if (yAxisMin !== undefined || yAxisMax !== undefined) {
|
||||
const valueIndex = isHorizontal ? 0 : 1;
|
||||
type AxisValue = string | number | null | undefined;
|
||||
type AxisPoint = AxisValue[];
|
||||
const clampAxisValue = (value: AxisValue): AxisValue => {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) return value;
|
||||
let clamped = value;
|
||||
if (yAxisMin !== undefined) clamped = Math.max(clamped, yAxisMin);
|
||||
if (yAxisMax !== undefined) clamped = Math.min(clamped, yAxisMax);
|
||||
return clamped;
|
||||
};
|
||||
const clampPoint = (point: AxisPoint): AxisPoint => {
|
||||
const newPoint = [...point];
|
||||
newPoint[valueIndex] = clampAxisValue(newPoint[valueIndex]);
|
||||
return newPoint;
|
||||
};
|
||||
series.forEach((s, index) => {
|
||||
// Skip annotation series appended above; only clamp the chart's own
|
||||
// observation/legend/baseline series.
|
||||
if (index >= observationSeriesCount) return;
|
||||
if (!Array.isArray(s.data)) return;
|
||||
const clampedData = (
|
||||
s.data as (AxisPoint | Record<string, unknown>)[]
|
||||
).map(point => {
|
||||
if (Array.isArray(point)) {
|
||||
return clampPoint(point);
|
||||
}
|
||||
// Some series paths (e.g. colorByPrimaryAxis, or negative bar
|
||||
// label positioning) wrap the tuple in an object of the shape
|
||||
// `{ value: [x, y], ... }` instead of passing the tuple
|
||||
// directly; clamp the wrapped tuple in place so those points
|
||||
// aren't skipped and left to be dropped by ECharts axis clipping.
|
||||
if (
|
||||
point &&
|
||||
typeof point === 'object' &&
|
||||
Array.isArray((point as { value?: unknown }).value)
|
||||
) {
|
||||
return {
|
||||
...point,
|
||||
value: clampPoint((point as { value: AxisPoint }).value),
|
||||
};
|
||||
}
|
||||
return point;
|
||||
});
|
||||
s.data = clampedData as typeof s.data;
|
||||
});
|
||||
}
|
||||
|
||||
// A dashboard-level time grain override (e.g. via a filter or the temporal
|
||||
// range control) is delivered in extraFormData and should take precedence
|
||||
// over the chart's own time grain when formatting temporal axes/tooltips.
|
||||
|
||||
@@ -296,7 +296,13 @@ function Echart(
|
||||
}
|
||||
: {};
|
||||
|
||||
// ECharts' built-in ARIA descriptions are off by default so behavior
|
||||
// doesn't change for existing deployments; a theme or chart's options
|
||||
// can opt in (or further customize aria handling) by overriding this.
|
||||
const ariaDefault = { aria: { enabled: false } };
|
||||
|
||||
const themedEchartOptions = mergeEchartsThemeOverrides(
|
||||
ariaDefault,
|
||||
baseTheme,
|
||||
echartOptions,
|
||||
globalOverrides,
|
||||
|
||||
@@ -387,6 +387,7 @@ export function extractDataTotalValues(
|
||||
percentageThreshold: number;
|
||||
xAxisCol: string;
|
||||
legendState?: LegendState;
|
||||
extraMetricLabels?: string[];
|
||||
},
|
||||
): {
|
||||
totalStackedValues: number[];
|
||||
@@ -394,11 +395,18 @@ export function extractDataTotalValues(
|
||||
} {
|
||||
const totalStackedValues: number[] = [];
|
||||
const thresholdValues: number[] = [];
|
||||
const { stack, percentageThreshold, xAxisCol, legendState } = opts;
|
||||
const {
|
||||
stack,
|
||||
percentageThreshold,
|
||||
xAxisCol,
|
||||
legendState,
|
||||
extraMetricLabels,
|
||||
} = opts;
|
||||
const excludedKeys = new Set([xAxisCol, ...(extraMetricLabels ?? [])]);
|
||||
if (stack) {
|
||||
data.forEach(datum => {
|
||||
const values = Object.keys(datum).reduce((prev, curr) => {
|
||||
if (curr === xAxisCol) {
|
||||
if (excludedKeys.has(curr)) {
|
||||
return prev;
|
||||
}
|
||||
if (legendState && !legendState[curr]) {
|
||||
|
||||
+52
@@ -1243,3 +1243,55 @@ test('regression #37921: multi-metric Query A with groupby does not duplicate fi
|
||||
expect(name).not.toMatch(/score_one,\s+score_two/);
|
||||
}
|
||||
});
|
||||
|
||||
test('y-axis title position: Left sets nameLocation to middle', () => {
|
||||
const chartProps = createEchartsTimeseriesTestChartProps<
|
||||
EchartsMixedTimeseriesFormData,
|
||||
EchartsMixedTimeseriesProps
|
||||
>({
|
||||
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
|
||||
defaultQueriesData: queriesData,
|
||||
formData: {
|
||||
...formData,
|
||||
yAxisTitlePosition: 'Left',
|
||||
yAxisTitleMargin: 20,
|
||||
},
|
||||
queriesData,
|
||||
});
|
||||
const transformed = transformProps(chartProps as EchartsMixedTimeseriesProps);
|
||||
const yAxis = transformed.echartOptions.yAxis as Array<{
|
||||
nameGap: number;
|
||||
nameLocation: string;
|
||||
}>;
|
||||
|
||||
expect(yAxis[0].nameGap).toEqual(20);
|
||||
expect(yAxis[0].nameLocation).toEqual('middle');
|
||||
expect(yAxis[1].nameGap).toEqual(20);
|
||||
expect(yAxis[1].nameLocation).toEqual('middle');
|
||||
});
|
||||
|
||||
test('y-axis title position: non-Left sets nameLocation to end', () => {
|
||||
const chartProps = createEchartsTimeseriesTestChartProps<
|
||||
EchartsMixedTimeseriesFormData,
|
||||
EchartsMixedTimeseriesProps
|
||||
>({
|
||||
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
|
||||
defaultQueriesData: queriesData,
|
||||
formData: {
|
||||
...formData,
|
||||
yAxisTitlePosition: 'Top',
|
||||
yAxisTitleMargin: 30,
|
||||
},
|
||||
queriesData,
|
||||
});
|
||||
const transformed = transformProps(chartProps as EchartsMixedTimeseriesProps);
|
||||
const yAxis = transformed.echartOptions.yAxis as Array<{
|
||||
nameGap: number;
|
||||
nameLocation: string;
|
||||
}>;
|
||||
|
||||
expect(yAxis[0].nameGap).toEqual(30);
|
||||
expect(yAxis[0].nameLocation).toEqual('end');
|
||||
expect(yAxis[1].nameGap).toEqual(30);
|
||||
expect(yAxis[1].nameLocation).toEqual('end');
|
||||
});
|
||||
|
||||
@@ -311,6 +311,26 @@ describe('Pie label string template', () => {
|
||||
).toEqual('Tablet:123,456\n55.50%');
|
||||
});
|
||||
|
||||
test('should format percentages using a percentage number format', () => {
|
||||
expect(
|
||||
format({
|
||||
label_type: 'template',
|
||||
label_template: '{name}:{value}\n{percent}',
|
||||
number_format: '.1%',
|
||||
}),
|
||||
).toEqual('Tablet:12345600.0%\n55.5%');
|
||||
});
|
||||
|
||||
test('should use the default percentage format when number format is absent', () => {
|
||||
expect(
|
||||
format({
|
||||
label_type: 'template',
|
||||
label_template: '{name}:{value}\n{percent}',
|
||||
number_format: undefined,
|
||||
}),
|
||||
).toEqual('Tablet:123k\n55.50%');
|
||||
});
|
||||
|
||||
test('should be compatible with ECharts raw variable syntax', () => {
|
||||
expect(
|
||||
format({
|
||||
|
||||
+354
@@ -23,6 +23,7 @@ import {
|
||||
AxisType,
|
||||
ChartProps,
|
||||
ComparisonType,
|
||||
ContributionType,
|
||||
DataRecord,
|
||||
EventAnnotationLayer,
|
||||
FormulaAnnotationLayer,
|
||||
@@ -755,6 +756,55 @@ describe('Does transformProps transform series correctly', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('should exclude a verbose-named sort-only metric from the stacked total (#42881)', () => {
|
||||
// rebaseForecastDatum renames a data column to its verboseMap entry when
|
||||
// one is configured, so extraMetricLabels (derived from raw metric
|
||||
// labels) must be resolved through the same verboseMap to still match —
|
||||
// otherwise the sort-only metric's value leaks back into the total.
|
||||
const sortMetricVerboseMap = { sort_metric: 'Sort By Metric' };
|
||||
const sortFormData: SqlaFormData = {
|
||||
...formData,
|
||||
onlyTotal: true,
|
||||
groupby: [],
|
||||
metrics: ['San Francisco', 'New York', 'Boston'],
|
||||
timeseries_limit_metric: 'sort_metric',
|
||||
x_axis_sort: 'sort_metric',
|
||||
};
|
||||
const sortQueriesData: ChartDataResponseResult[] = [
|
||||
createTestQueryData(
|
||||
createTestData(
|
||||
[
|
||||
{
|
||||
'San Francisco': 32,
|
||||
'New York': 0,
|
||||
Boston: 0,
|
||||
'Sort By Metric': 2,
|
||||
},
|
||||
],
|
||||
{ intervalMs: 300000000 },
|
||||
),
|
||||
),
|
||||
];
|
||||
const chartProps = createTestChartProps({
|
||||
formData: sortFormData,
|
||||
queriesData: sortQueriesData,
|
||||
datasource: { verboseMap: sortMetricVerboseMap },
|
||||
});
|
||||
|
||||
const transformedSeries = transformProps(chartProps).echartOptions
|
||||
.series as seriesType[];
|
||||
|
||||
const totalLabels = transformedSeries
|
||||
.flatMap((series, seriesIndex) =>
|
||||
series.data.map((value, dataIndex) =>
|
||||
series.label.formatter({ value, dataIndex, seriesIndex }),
|
||||
),
|
||||
)
|
||||
.filter(label => label !== '');
|
||||
|
||||
expect(totalLabels).toEqual(['32']);
|
||||
});
|
||||
|
||||
test('should show labels on values >= percentageThreshold if onlyTotal is false', () => {
|
||||
const chartProps = createTestChartProps({ formData, queriesData });
|
||||
|
||||
@@ -1393,6 +1443,310 @@ test('should not apply axis bounds calculation when seriesType is not Bar for ho
|
||||
expect(xAxisRaw.max).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should not clip small segments when row-contribution percentages float above 1 in horizontal stacked bar charts', () => {
|
||||
// These three shares are individually normalized (each column sums to 1),
|
||||
// but due to floating point rounding their sum can land fractionally
|
||||
// over 1. See https://github.com/apache/superset/issues/30914
|
||||
//
|
||||
// The margin above 1 is chosen large enough (~1e-7) that the sum stays
|
||||
// above 1 no matter which order the underlying series get summed in
|
||||
// (series are sorted by name for stacking, not in the order declared
|
||||
// here), unlike a single-ULP overflow which can round differently
|
||||
// depending on summation order and make this assertion order-dependent.
|
||||
const shareA = 0.42;
|
||||
const shareB = 0.38;
|
||||
const shareC = 0.2000001;
|
||||
expect(shareA + shareB + shareC).toBeGreaterThan(1);
|
||||
|
||||
const queriesData: ChartDataResponseResult[] = [
|
||||
createTestQueryData(
|
||||
createTestData(
|
||||
[{ 'Series A': shareA, 'Series B': shareB, 'Series C': shareC }],
|
||||
{ intervalMs: 300000000 },
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
const chartProps = createTestChartProps({
|
||||
formData: {
|
||||
...baseFormDataHorizontalBar,
|
||||
contributionMode: ContributionType.Row,
|
||||
stack: StackControlsValue.Stack,
|
||||
},
|
||||
queriesData,
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(chartProps);
|
||||
|
||||
// In horizontal orientation, axes are swapped, so yAxis becomes xAxis.
|
||||
// The axis max must not be hard-capped at exactly 1, otherwise echarts
|
||||
// clips the topmost stacked segment entirely instead of just rendering
|
||||
// a negligible sub-pixel overflow.
|
||||
const xAxisRaw = transformedProps.echartOptions.xAxis as any;
|
||||
expect(xAxisRaw.max).toBeGreaterThanOrEqual(shareA + shareB + shareC);
|
||||
});
|
||||
|
||||
test('keeps the 0-1 axis range for Expand (100% stacked) charts instead of padding to the raw row total', () => {
|
||||
// Unlike row-contribution mode, an Expand stack is not pre-normalized in
|
||||
// the query result -- these are raw values (summing to 100, not 1) that
|
||||
// get divided down to a 0-1 range internally. The un-normalized row total
|
||||
// must not be used to pad the axis max, or the chart would only occupy a
|
||||
// sliver of the plot.
|
||||
const queriesData: ChartDataResponseResult[] = [
|
||||
createTestQueryData(
|
||||
createTestData([{ 'Series A': 42, 'Series B': 38, 'Series C': 20 }], {
|
||||
intervalMs: 300000000,
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
const chartProps = createTestChartProps({
|
||||
formData: {
|
||||
...baseFormDataHorizontalBar,
|
||||
stack: StackControlsValue.Expand,
|
||||
},
|
||||
queriesData,
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(chartProps);
|
||||
|
||||
const xAxisRaw = transformedProps.echartOptions.xAxis as any;
|
||||
expect(xAxisRaw.max).toBe(1);
|
||||
});
|
||||
|
||||
test('computes row-contribution axis padding per stack when time_compare splits a row into multiple normalized stacks', () => {
|
||||
// With time_compare, each comparison period is normalized and stacked
|
||||
// independently (see getTimeCompareStackId), so the current-period
|
||||
// columns sum to ~1 in their own stack and the comparison-period columns
|
||||
// (suffixed with the offset) sum to ~1 in a separate stack. The combined
|
||||
// row total across both stacks is therefore ~2, but the axis max must be
|
||||
// computed per stack, not from that combined total, or a 100% bar would
|
||||
// only occupy about half the plot.
|
||||
const queriesData: ChartDataResponseResult[] = [
|
||||
createTestQueryData(
|
||||
createTestData(
|
||||
[
|
||||
{
|
||||
'Series A': 0.6,
|
||||
'Series B': 0.4,
|
||||
'Series A__1 year ago': 0.55,
|
||||
'Series B__1 year ago': 0.45,
|
||||
},
|
||||
],
|
||||
{ intervalMs: 300000000 },
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
const chartProps = createTestChartProps({
|
||||
formData: {
|
||||
...baseFormDataHorizontalBar,
|
||||
contributionMode: ContributionType.Row,
|
||||
stack: StackControlsValue.Stack,
|
||||
time_compare: ['1 year ago'],
|
||||
},
|
||||
queriesData,
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(chartProps);
|
||||
|
||||
const xAxisRaw = transformedProps.echartOptions.xAxis as any;
|
||||
expect(xAxisRaw.max).toBeGreaterThanOrEqual(1);
|
||||
expect(xAxisRaw.max).toBeLessThan(1.5);
|
||||
});
|
||||
|
||||
test('clamps series values to the yAxis max instead of dropping out-of-range points (#27449)', () => {
|
||||
const queriesData: ChartDataResponseResult[] = [
|
||||
createTestQueryData(
|
||||
createTestData(
|
||||
[
|
||||
{ 'Series A': 1 },
|
||||
{ 'Series A': 2 },
|
||||
{ 'Series A': 3 },
|
||||
{ 'Series A': 4 },
|
||||
{ 'Series A': 1000 },
|
||||
{ 'Series A': 4 },
|
||||
{ 'Series A': 2 },
|
||||
],
|
||||
{ intervalMs: 300000000 },
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
const chartProps = createTestChartProps({
|
||||
formData: {
|
||||
...formData,
|
||||
groupby: [],
|
||||
seriesType: EchartsTimeseriesSeriesType.Line,
|
||||
truncateYAxis: true,
|
||||
yAxisBounds: [0, 10],
|
||||
},
|
||||
queriesData,
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(chartProps);
|
||||
const series = transformedProps.echartOptions.series as SeriesOption[];
|
||||
const seriesA = series.find(s => s.name === 'Series A');
|
||||
expect(seriesA).toBeDefined();
|
||||
const data = seriesA!.data as [number, number][];
|
||||
|
||||
// The point that was 1000 should be present (not dropped) and clamped to
|
||||
// the configured yAxis max of 10, rather than disappearing entirely.
|
||||
expect(data).toHaveLength(7);
|
||||
expect(data[4][1]).toBe(10);
|
||||
});
|
||||
|
||||
test('clamps series values to the yAxis min when a value falls below it', () => {
|
||||
const queriesData: ChartDataResponseResult[] = [
|
||||
createTestQueryData(
|
||||
createTestData(
|
||||
[{ 'Series A': -1000 }, { 'Series A': 2 }, { 'Series A': 3 }],
|
||||
{ intervalMs: 300000000 },
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
const chartProps = createTestChartProps({
|
||||
formData: {
|
||||
...formData,
|
||||
groupby: [],
|
||||
seriesType: EchartsTimeseriesSeriesType.Line,
|
||||
truncateYAxis: true,
|
||||
yAxisBounds: [0, 10],
|
||||
},
|
||||
queriesData,
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(chartProps);
|
||||
const series = transformedProps.echartOptions.series as SeriesOption[];
|
||||
const seriesA = series.find(s => s.name === 'Series A');
|
||||
expect(seriesA).toBeDefined();
|
||||
const data = seriesA!.data as [number, number][];
|
||||
|
||||
expect(data).toHaveLength(3);
|
||||
expect(data[0][1]).toBe(0);
|
||||
});
|
||||
|
||||
test('clamps series values to the yAxis bounds when colorByPrimaryAxis wraps points in objects (#27449)', () => {
|
||||
const queriesData: ChartDataResponseResult[] = [
|
||||
createTestQueryData(
|
||||
createTestData(
|
||||
[{ 'Series A': 1 }, { 'Series A': 1000 }, { 'Series A': 2 }],
|
||||
{ intervalMs: 300000000 },
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
const chartProps = createTestChartProps({
|
||||
formData: {
|
||||
...formData,
|
||||
groupby: [],
|
||||
seriesType: EchartsTimeseriesSeriesType.Line,
|
||||
truncateYAxis: true,
|
||||
yAxisBounds: [0, 10],
|
||||
colorByPrimaryAxis: true,
|
||||
},
|
||||
queriesData,
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(chartProps);
|
||||
const series = transformedProps.echartOptions.series as SeriesOption[];
|
||||
const seriesA = series.find(s => s.name === 'Series A');
|
||||
expect(seriesA).toBeDefined();
|
||||
const data = seriesA!.data as { value: [number, number] }[];
|
||||
|
||||
// colorByPrimaryAxis wraps each point as `{ value: [x, y], itemStyle }`
|
||||
// rather than a bare tuple; the wrapped value must still be clamped
|
||||
// instead of being skipped and left for ECharts to drop.
|
||||
expect(data).toHaveLength(3);
|
||||
expect(data[1].value[1]).toBe(10);
|
||||
});
|
||||
|
||||
test('does not clamp a timeseries annotation series to the Y axis bounds (#27449)', () => {
|
||||
const timeseries: TimeseriesAnnotationLayer = {
|
||||
annotationType: AnnotationType.Timeseries,
|
||||
name: 'My Timeseries',
|
||||
show: true,
|
||||
showLabel: true,
|
||||
sourceType: AnnotationSourceType.Line,
|
||||
style: AnnotationStyle.Solid,
|
||||
titleColumn: '',
|
||||
value: 3,
|
||||
};
|
||||
const annotationData = {
|
||||
'My Timeseries': {
|
||||
records: [
|
||||
{ x: 0, y: 11000 },
|
||||
{ x: 300000000, y: 21000 },
|
||||
],
|
||||
},
|
||||
};
|
||||
const queriesData: ChartDataResponseResult[] = [
|
||||
createTestQueryData(
|
||||
createTestData([{ 'Series A': 1 }, { 'Series A': 2 }], {
|
||||
intervalMs: 300000000,
|
||||
}),
|
||||
{ annotation_data: annotationData },
|
||||
),
|
||||
];
|
||||
|
||||
const chartProps = createTestChartProps({
|
||||
formData: {
|
||||
...formData,
|
||||
groupby: [],
|
||||
seriesType: EchartsTimeseriesSeriesType.Line,
|
||||
truncateYAxis: true,
|
||||
yAxisBounds: [0, 10],
|
||||
annotationLayers: [timeseries],
|
||||
},
|
||||
annotationData,
|
||||
queriesData,
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(chartProps);
|
||||
const series = transformedProps.echartOptions.series as SeriesOption[];
|
||||
const annotationSeries = series.find(s => s.id === 'My Timeseries');
|
||||
expect(annotationSeries).toBeDefined();
|
||||
const data = annotationSeries!.data as [number, number][];
|
||||
|
||||
// The annotation carries its own configured values (11000, 21000), which
|
||||
// are unrelated to the chart's own out-of-range-data problem this PR
|
||||
// fixes. They must be left untouched by the Y axis clamp rather than
|
||||
// rewritten to the yAxisBounds max of 10.
|
||||
expect(data[0][1]).toBe(11000);
|
||||
expect(data[1][1]).toBe(21000);
|
||||
});
|
||||
|
||||
test('clamps series values at the correct tuple index for horizontal bar charts (#27449)', () => {
|
||||
const queriesData: ChartDataResponseResult[] = [
|
||||
createTestQueryData(
|
||||
createTestData(
|
||||
[{ 'Series A': 15000 }, { 'Series A': 20000 }, { 'Series A': 18000 }],
|
||||
{ intervalMs: 300000000 },
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
const chartProps = createTestChartProps({
|
||||
formData: {
|
||||
...baseFormDataHorizontalBar,
|
||||
yAxisBounds: [0, 16000],
|
||||
},
|
||||
queriesData,
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(chartProps);
|
||||
const series = transformedProps.echartOptions.series as SeriesOption[];
|
||||
const seriesA = series.find(s => s.name === 'Series A');
|
||||
expect(seriesA).toBeDefined();
|
||||
const data = seriesA!.data as [number, number][];
|
||||
|
||||
// In horizontal orientation the value sits at tuple index 0 (the axes are
|
||||
// swapped), so the clamp must target that index rather than index 1.
|
||||
expect(data).toHaveLength(3);
|
||||
expect(data[1][0]).toBe(16000);
|
||||
});
|
||||
|
||||
test('legend is visible on tall charts when enabled by the user', () => {
|
||||
const chartProps = createTestChartProps({
|
||||
height: 400,
|
||||
|
||||
@@ -28,6 +28,7 @@ import { GenericDataType } from '@apache-superset/core/common';
|
||||
import {
|
||||
calculateLowerLogTick,
|
||||
dedupSeries,
|
||||
extractDataTotalValues,
|
||||
extractGroupbyLabel,
|
||||
extractSeries,
|
||||
extractShowValueIndexes,
|
||||
@@ -467,6 +468,40 @@ test('sortAndFilterSeries by name with numbers desc', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('extractDataTotalValues excludes extraMetricLabels from the stacked total (#42701)', () => {
|
||||
const data: DataRecord[] = [
|
||||
{ category: '1-3d', A: 32, B: 0, Sort: 2 },
|
||||
{ category: '4-6d', A: 10, B: 5, Sort: 1 },
|
||||
];
|
||||
const withoutExclusion = extractDataTotalValues(data, {
|
||||
stack: true,
|
||||
percentageThreshold: 0,
|
||||
xAxisCol: 'category',
|
||||
});
|
||||
// Reproduces the bug: the sort-only metric leaks into the total.
|
||||
expect(withoutExclusion.totalStackedValues).toEqual([34, 16]);
|
||||
|
||||
const withExclusion = extractDataTotalValues(data, {
|
||||
stack: true,
|
||||
percentageThreshold: 0,
|
||||
xAxisCol: 'category',
|
||||
extraMetricLabels: ['Sort'],
|
||||
});
|
||||
expect(withExclusion.totalStackedValues).toEqual([32, 15]);
|
||||
});
|
||||
|
||||
test('extractDataTotalValues still respects legendState alongside extraMetricLabels', () => {
|
||||
const data: DataRecord[] = [{ category: '1-3d', A: 32, B: 8, Sort: 2 }];
|
||||
const result = extractDataTotalValues(data, {
|
||||
stack: true,
|
||||
percentageThreshold: 0,
|
||||
xAxisCol: 'category',
|
||||
extraMetricLabels: ['Sort'],
|
||||
legendState: { A: true, B: false },
|
||||
});
|
||||
expect(result.totalStackedValues).toEqual([32]);
|
||||
});
|
||||
|
||||
describe('extractSeries', () => {
|
||||
test('should generate a valid ECharts timeseries series object', () => {
|
||||
const data = [
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/lodash": "^4.17.25",
|
||||
"jest": "^30.4.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"access": "public"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/types": "^7.29.7",
|
||||
"@babel/types": "^7.29.8",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "*",
|
||||
"@testing-library/react": "^15.0.0",
|
||||
|
||||
@@ -30,7 +30,11 @@ import {
|
||||
TimeFormats,
|
||||
} from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import { getColorFormatters } from '@superset-ui/chart-controls';
|
||||
import {
|
||||
ColorSchemeEnum,
|
||||
ConditionalFormattingConfig,
|
||||
getColorFormatters,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import { DateFormatter, PivotTableQueryFormData, QueryData } from '../types';
|
||||
import buildGroupbyCombinations, {
|
||||
additiveReducerFor,
|
||||
@@ -206,8 +210,17 @@ export default function transformProps(chartProps: ChartProps<QueryFormData>) {
|
||||
},
|
||||
{},
|
||||
);
|
||||
// The "Green"/"Red" trend-color tokens are resolved by the Table chart's
|
||||
// own comparison-aware formatter, which this renderer does not implement.
|
||||
// Filter them out so a stale config (e.g. carried over from switching viz
|
||||
// types) doesn't leak the raw token name through as a literal CSS color.
|
||||
const pivotConditionalFormatting = conditionalFormatting?.filter(
|
||||
(config: ConditionalFormattingConfig) =>
|
||||
config.colorScheme !== ColorSchemeEnum.Green &&
|
||||
config.colorScheme !== ColorSchemeEnum.Red,
|
||||
);
|
||||
const metricColorFormatters = getColorFormatters(
|
||||
conditionalFormatting,
|
||||
pivotConditionalFormatting,
|
||||
mainQuery.data,
|
||||
theme,
|
||||
);
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@math.gl/web-mercator": "^4.1.0",
|
||||
"mapbox-gl": "^3.27.0",
|
||||
"mapbox-gl": "^3.28.0",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"react-map-gl": "^8.1.2",
|
||||
"supercluster": "^8.0.1"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user