mirror of
https://github.com/apache/superset.git
synced 2026-08-25 09:31:16 +00:00
Compare commits
180
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93ddafa6b5 | ||
|
|
dfd057b920 | ||
|
|
52571a5b8b | ||
|
|
ca3d7670b7 | ||
|
|
94855e9626 | ||
|
|
7dbf71a379 | ||
|
|
24b95f9ca7 | ||
|
|
c635d0754f | ||
|
|
31f06c0ee6 | ||
|
|
27ec80c07b | ||
|
|
a8a8b51afb | ||
|
|
6ab21b381a | ||
|
|
b0962ba5ed | ||
|
|
bf5f3a9e6b | ||
|
|
c980b3a361 | ||
|
|
dc436c76f7 | ||
|
|
6eced8e919 | ||
|
|
1bde62f997 | ||
|
|
f1f6347885 | ||
|
|
8591f52ced | ||
|
|
ceb75b9350 | ||
|
|
db539288ac | ||
|
|
a392e8b102 | ||
|
|
e450acf1c7 | ||
|
|
2c02965f2b | ||
|
|
f3142e7b15 | ||
|
|
649c062825 | ||
|
|
3f10d8b1cc | ||
|
|
15b98aa6da | ||
|
|
7f1b41444c | ||
|
|
8a32324184 | ||
|
|
4623d9219a | ||
|
|
dcac129b51 | ||
|
|
7196ce8a0c | ||
|
|
e7756aeef9 | ||
|
|
af859717a7 | ||
|
|
8fa48d73f6 | ||
|
|
90a3d93002 | ||
|
|
5ffb72151e | ||
|
|
c3ed8b312d | ||
|
|
9f505eb0cb | ||
|
|
f2610e9dca | ||
|
|
09bd8c98de | ||
|
|
5812c0eab5 | ||
|
|
5f3e34908c | ||
|
|
9781254363 | ||
|
|
65893113fe | ||
|
|
04017f3956 | ||
|
|
2b6044b624 | ||
|
|
aee98b527d | ||
|
|
edda9d1bbb | ||
|
|
03eac279e5 | ||
|
|
fd7095df9a | ||
|
|
d422f5b4b6 | ||
|
|
a05a099987 | ||
|
|
05842a6350 | ||
|
|
e45dd2d3f1 | ||
|
|
65910abf21 | ||
|
|
6e22050b50 | ||
|
|
132340b652 | ||
|
|
14408eb3db | ||
|
|
87743ef3f8 | ||
|
|
fb53761ec8 | ||
|
|
f7d505e1fd | ||
|
|
22396d504a | ||
|
|
27ea5de44a | ||
|
|
18fc2c6228 | ||
|
|
18a36d04c7 | ||
|
|
01b1d58ac9 | ||
|
|
7441ce90ae | ||
|
|
42ba2a4433 | ||
|
|
271564cb0d | ||
|
|
bcfb4346f6 | ||
|
|
b5fe00b407 | ||
|
|
148ffaff50 | ||
|
|
83c6ea4e03 | ||
|
|
1ca5e61f54 | ||
|
|
1ef12580a7 | ||
|
|
c0884c0f0c | ||
|
|
2ff79bd495 | ||
|
|
e7dccd44a7 | ||
|
|
fdd3945dde | ||
|
|
8b67fb4d30 | ||
|
|
b288db91f9 | ||
|
|
80bba12d0f | ||
|
|
5d4358a149 | ||
|
|
f9cedf84e2 | ||
|
|
c2d653b4b8 | ||
|
|
5a96c3f538 | ||
|
|
faf7c34c0a | ||
|
|
b8fca2145d | ||
|
|
c10054f521 | ||
|
|
8c500ccee1 | ||
|
|
6d77efad29 | ||
|
|
8222db3340 | ||
|
|
01ce8358a6 | ||
|
|
53a8a0e140 | ||
|
|
eafbff9f8d | ||
|
|
1339bcd9da | ||
|
|
334e280489 | ||
|
|
c07f3ebf2d | ||
|
|
1569915096 | ||
|
|
fde0ba26d1 | ||
|
|
097c99b19c | ||
|
|
5ce52e531d | ||
|
|
34cd50cc48 | ||
|
|
c0ab5f3385 | ||
|
|
7d4f30574f | ||
|
|
e4ea6e23d8 | ||
|
|
a13a5f1af4 | ||
|
|
f994602096 | ||
|
|
086b4af65d | ||
|
|
fd063d17bf | ||
|
|
60e1802c52 | ||
|
|
2d1daac11a | ||
|
|
3c90bdc6f0 | ||
|
|
ebab31adc2 | ||
|
|
936f073b9a | ||
|
|
d12320239a | ||
|
|
8ef7be5788 | ||
|
|
72458ab26f | ||
|
|
5105f13726 | ||
|
|
bdafb6c330 | ||
|
|
b4d79462ec | ||
|
|
49374f1fe5 | ||
|
|
f766de6d0d | ||
|
|
ed20e729d0 | ||
|
|
13eb47a1da | ||
|
|
98136d547c | ||
|
|
e2070d79dc | ||
|
|
2807f1b0e8 | ||
|
|
c9c230142b | ||
|
|
2f8875aaef | ||
|
|
6e270df4a2 | ||
|
|
97eafd6140 | ||
|
|
3ed97f9691 | ||
|
|
5105899810 | ||
|
|
d917071708 | ||
|
|
afde126d9a | ||
|
|
b3a9b9beb4 | ||
|
|
70ba9c9552 | ||
|
|
98276cd1f3 | ||
|
|
cdeca0c179 | ||
|
|
aaf9eba161 | ||
|
|
1991e3f0d2 | ||
|
|
cfd40bdd0d | ||
|
|
a8216e3787 | ||
|
|
d114eb638b | ||
|
|
70d06e3d77 | ||
|
|
a2c004266b | ||
|
|
2773bc94eb | ||
|
|
94459ae885 | ||
|
|
ad3103fdb0 | ||
|
|
065342f8c9 | ||
|
|
2e8a8031f8 | ||
|
|
cece082eed | ||
|
|
22dde07a8e | ||
|
|
93cc520482 | ||
|
|
7679c5641a | ||
|
|
f7c70568c3 | ||
|
|
69c4011a95 | ||
|
|
4684851336 | ||
|
|
e7139a7ac2 | ||
|
|
21ae918656 | ||
|
|
ca94026e97 | ||
|
|
a3bc2d908c | ||
|
|
738d12677a | ||
|
|
2eedc609a8 | ||
|
|
f4587218dd | ||
|
|
8967e6c2d3 | ||
|
|
81e431cd50 | ||
|
|
edfb009e1c | ||
|
|
e808fcbcad | ||
|
|
0a7ebe1dd1 | ||
|
|
dd1afb029f | ||
|
|
c068a8c09c | ||
|
|
b62ec512d2 | ||
|
|
1e65d93a83 | ||
|
|
856599027a | ||
|
|
c395b9a238 |
@@ -3,9 +3,7 @@ codecov:
|
||||
after_n_builds: 4
|
||||
ignore:
|
||||
- "superset/migrations/versions/*.py"
|
||||
- "superset-frontend/packages/superset-ui-demo/**/*"
|
||||
- "**/*.stories.tsx"
|
||||
- "**/*.stories.jsx"
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: Bug report
|
||||
description: Report a bug to improve Superset's stability
|
||||
labels: ["bug"]
|
||||
labels: ["#bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: Cosmetic Issue
|
||||
about: Describe a cosmetic issue with CSS, positioning, layout, labeling, or similar
|
||||
labels: "cosmetic-issue"
|
||||
labels: "#bug:cosmetic"
|
||||
---
|
||||
|
||||
## Screenshot
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@
|
||||
"dependencies:python":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- 'superset/requirements/**'
|
||||
- 'requirements/**'
|
||||
- 'superset/translations/requirements.txt'
|
||||
- 'RELEASING/requirements.txt'
|
||||
|
||||
|
||||
@@ -21,6 +21,9 @@ on:
|
||||
#schedule:
|
||||
# - cron: '0 0 * * *' # Runs daily at midnight UTC
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
bump-python-package:
|
||||
runs-on: ubuntu-slim
|
||||
@@ -45,7 +48,7 @@ jobs:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
python-version: "3.11"
|
||||
enable-cache: true
|
||||
|
||||
@@ -16,6 +16,9 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check_db_migration_conflict:
|
||||
name: Check DB migration conflict
|
||||
|
||||
@@ -14,6 +14,9 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-26.04
|
||||
@@ -64,7 +67,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -75,6 +78,6 @@ jobs:
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
@@ -51,6 +51,53 @@ jobs:
|
||||
echo "matrix_config=${MATRIX_CONFIG}" >> $GITHUB_OUTPUT
|
||||
echo $GITHUB_OUTPUT
|
||||
|
||||
# Runs unconditionally (no dependency on `changes`, and no build-preset
|
||||
# matrix restriction) so a regression in the PY_VER override logic is
|
||||
# always caught on PRs. Without this, the real docker-build job only runs
|
||||
# when the change detector flags docker/python/frontend changes (a
|
||||
# workflow-only edit like this one does not), and even then the PR build
|
||||
# matrix never includes the "py311"/"py312" presets that logic protects -
|
||||
# so a break here would otherwise first surface on a push to master.
|
||||
pyver-override-check:
|
||||
name: verify docker build PY_VER override
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup supersetbot
|
||||
uses: ./.github/actions/setup-supersetbot/
|
||||
- name: Assert PY_VER override applies to every preset except py311/py312
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Asserts against the actual buildx command line `supersetbot docker
|
||||
# --dry-run` would run, not just this repo's own extra-flags helper,
|
||||
# so a regression in supersetbot itself (dropping the py311/py312
|
||||
# PY_VER pin, or reordering args so our override no longer lands
|
||||
# last) is caught here too, instead of only surfacing on master.
|
||||
assert_effective_py_ver() {
|
||||
local preset="$1" expected="$2" extra_flags command actual
|
||||
extra_flags="$(scripts/docker-build-extra-flags.sh "$preset" dummy-tag)"
|
||||
command="$(supersetbot docker --preset "$preset" --platform linux/amd64 --extra-flags "$extra_flags" --dry-run)"
|
||||
# docker buildx keeps the LAST value of a repeated --build-arg key.
|
||||
actual="$(grep -oE -- '--build-arg PY_VER=[^[:space:]]+' <<<"$command" | tail -1)"
|
||||
if [ "$actual" != "--build-arg PY_VER=$expected" ]; then
|
||||
echo "::error::preset '$preset' expected effective --build-arg PY_VER=$expected, got: ${actual:-<none>} (full command: $command)"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
for preset in dev lean websocket dockerize; do
|
||||
assert_effective_py_ver "$preset" "3.11.14-slim-trixie"
|
||||
done
|
||||
assert_effective_py_ver py311 "3.11-slim-bookworm"
|
||||
assert_effective_py_ver py312 "3.12-slim-bookworm"
|
||||
echo "PY_VER override logic verified against the assembled buildx command for all build presets"
|
||||
|
||||
docker-build:
|
||||
name: docker-build
|
||||
needs: [setup_matrix, changes]
|
||||
@@ -124,19 +171,21 @@ jobs:
|
||||
# the whole job. buildx reuses the buildkit layer cache from the
|
||||
# failed attempt, so a retry mostly re-does just the failed push.
|
||||
#
|
||||
# supersetbot's "dev"/"lean" presets pin their own --build-arg
|
||||
# PY_VER, which lands ahead of --extra-flags on the assembled
|
||||
# buildx command line; docker/buildx keeps the last value for a
|
||||
# repeated --build-arg key, so appending PY_VER here overrides
|
||||
# supersetbot's pin and keeps the build on the Dockerfile's own
|
||||
# supported Python version.
|
||||
# See scripts/docker-build-extra-flags.sh for why "py311"/"py312"
|
||||
# are excluded from the PY_VER override applied to every other
|
||||
# preset; that logic is also exercised on every PR by the
|
||||
# always-on pyver-override-check job below, since this job itself
|
||||
# only runs when the change detector flags docker/python/frontend
|
||||
# changes and the PR build matrix never includes py311/py312.
|
||||
EXTRA_FLAGS="$(scripts/docker-build-extra-flags.sh "$BUILD_PRESET" "$IMAGE_TAG")"
|
||||
|
||||
for attempt in 1 2 3; do
|
||||
if supersetbot docker \
|
||||
$PUSH_OR_LOAD \
|
||||
--preset "$BUILD_PRESET" \
|
||||
--context "$EVENT" \
|
||||
--context-ref "$RELEASE" $FORCE_LATEST \
|
||||
--extra-flags "--build-arg PY_VER=3.11.14-slim-trixie --build-arg INCLUDE_CHROMIUM=false --tag $IMAGE_TAG" \
|
||||
--extra-flags "$EXTRA_FLAGS" \
|
||||
$PLATFORM_ARG; then
|
||||
break
|
||||
fi
|
||||
|
||||
@@ -33,6 +33,9 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
enforce-single-migration-head:
|
||||
runs-on: ubuntu-26.04
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
name: Frontend bundle size (nightly baseline + analyzer)
|
||||
|
||||
# Refreshes the bundle-size baseline that superset-frontend.yml's `bundle-size`
|
||||
# job compares PRs against, and publishes a browsable bundle-analyzer treemap
|
||||
# report of the same build. Deliberately NOT triggered on every push to
|
||||
# master: a day-old baseline/report is fine for catching relative
|
||||
# regressions on PRs and for browsing what's actually in the bundle, and
|
||||
# building the production bundle on every one of the many pushes master
|
||||
# gets per day would burn CI time for no benefit a nightly refresh doesn't
|
||||
# already cover.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 6 * * *"
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
TAG: apache/superset:bundle-size-nightly-${{ github.run_id }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
refresh-baseline:
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_BUNDLE_ANALYZER_SITE_ID }}
|
||||
steps:
|
||||
- name: "Checkout master"
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: master
|
||||
|
||||
- name: Build Docker Image
|
||||
run: |
|
||||
docker buildx build \
|
||||
-t $TAG \
|
||||
--cache-from=type=registry,ref=apache/superset-cache:3.11-slim-trixie \
|
||||
--target superset-node-ci \
|
||||
.
|
||||
|
||||
# Same cache the PR-time bundle-size job restores/writes -- webpack's
|
||||
# persistent filesystem cache turns a warm production build into ~20s
|
||||
# instead of several minutes. See superset-frontend.yml for the
|
||||
# matching restore step and why it's keyed this way.
|
||||
- name: Restore webpack build cache
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: superset-frontend/.temp_cache
|
||||
key: >-
|
||||
webpack-prod-cache-${{ hashFiles('superset-frontend/package-lock.json',
|
||||
'superset-frontend/babel.config.js', 'superset-frontend/tsconfig.json',
|
||||
'superset-frontend/webpack.config.js') }}
|
||||
|
||||
# Only ever pull the last recorded data point off the cache, keyed by
|
||||
# run ID -- `restore-keys` prefix-matches the most recently created
|
||||
# entry. Absent on the very first run ever; benchmark-action starts a
|
||||
# fresh history in that case.
|
||||
- name: Restore bundle size history
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: bundle-size-history.json
|
||||
key: bundle-size-history-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
bundle-size-history-
|
||||
|
||||
# BUNDLE_ANALYZER rides along in the same build as BUNDLE_SIZE_STATS --
|
||||
# they're independent env-gated additions in webpack.config.js (one
|
||||
# sets `config.stats`, the other pushes plugins), so one production
|
||||
# build produces both the numeric stats.json and the analyzer's
|
||||
# report.html. Only report.html is mounted out, not
|
||||
# BUNDLE_ANALYZER's sibling `statistics.html` sunburst -- that file is
|
||||
# documented in webpack.config.js as routinely exceeding 100MB for
|
||||
# this app (it's .gitignore'd for exactly that reason), too large to
|
||||
# publish as a static site page.
|
||||
- name: Build production bundle with stats and analyzer report
|
||||
run: |
|
||||
mkdir -p ${{ github.workspace }}/superset-frontend/bundle-stats
|
||||
mkdir -p ${{ github.workspace }}/superset-frontend/.temp_cache
|
||||
mkdir -p ${{ github.workspace }}/superset/static/assets
|
||||
docker run \
|
||||
-v ${{ github.workspace }}/superset-frontend/bundle-stats:/app/superset-frontend/bundle-stats \
|
||||
-v ${{ github.workspace }}/superset-frontend/.temp_cache:/app/superset-frontend/.temp_cache \
|
||||
-v ${{ github.workspace }}/superset/static/assets:/app/superset/static/assets \
|
||||
--rm $TAG \
|
||||
bash -c \
|
||||
"npm i && BUNDLE_SIZE_STATS=true BUNDLE_ANALYZER=true npm run build -- --json=bundle-stats/stats.json"
|
||||
|
||||
- name: Summarize bundle size
|
||||
run: |
|
||||
node superset-frontend/scripts/bundle-size-summary.js \
|
||||
superset-frontend/bundle-stats/stats.json > bundle-size-summary.json
|
||||
rm -rf superset-frontend/bundle-stats
|
||||
|
||||
# No PR to comment on here, so comment-on-alert is off -- the job
|
||||
# summary (summary-always) is the only surface for this run.
|
||||
- name: Update bundle size baseline
|
||||
uses: benchmark-action/github-action-benchmark@52576c92bccf6ac60c8223ec7eb2565637cae9ba # v1.22.1
|
||||
with:
|
||||
tool: customSmallerIsBetter
|
||||
output-file-path: bundle-size-summary.json
|
||||
external-data-json-path: bundle-size-history.json
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fail-on-alert: false
|
||||
summary-always: true
|
||||
|
||||
- name: Save bundle size history
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: bundle-size-history.json
|
||||
key: bundle-size-history-${{ github.run_id }}
|
||||
|
||||
# Publishes the treemap to Netlify (the same host already used for
|
||||
# superset-storybook.netlify.app and docs previews, reusing the
|
||||
# existing NETLIFY_AUTH_TOKEN). Skipped until
|
||||
# NETLIFY_BUNDLE_ANALYZER_SITE_ID exists -- create a new (free)
|
||||
# Netlify site named superset-bundle-analyzer and add its site ID as
|
||||
# that secret to turn this on; nothing else in this workflow depends
|
||||
# on it.
|
||||
- name: Publish bundle analyzer report to Netlify
|
||||
if: ${{ env.NETLIFY_SITE_ID != '' }}
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
run: |
|
||||
mkdir -p netlify-publish
|
||||
cp superset/static/assets/report.html netlify-publish/index.html
|
||||
# zizmor: ignore[adhoc-packages] - netlify-cli is a one-shot CI deploy
|
||||
# tool, not an application dependency; a global/npx install has no
|
||||
# lockfile context. Version pinned above the floor set by other
|
||||
# ad-hoc installs in this repo (bump deliberately when upgrading).
|
||||
npx --yes netlify-cli@27.0.1 deploy --prod --dir=netlify-publish
|
||||
@@ -45,5 +45,8 @@ jobs:
|
||||
- name: Run Script
|
||||
run: bash .github/workflows/github-action-validator.sh
|
||||
|
||||
- name: Test docs-deploy freshness gate
|
||||
run: bash .github/workflows/scripts/check-docs-deploy-freshness.test.sh
|
||||
|
||||
- name: Check for security issues on GHA workflows
|
||||
uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
|
||||
|
||||
@@ -7,6 +7,9 @@ on:
|
||||
pull_request:
|
||||
types: [opened, edited]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
superbot-orglabel:
|
||||
runs-on: ubuntu-slim
|
||||
|
||||
@@ -7,6 +7,9 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
labeler:
|
||||
permissions:
|
||||
|
||||
@@ -3,6 +3,9 @@ on:
|
||||
release:
|
||||
types: [published] # This makes it run only when a new released is published
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
latest-release:
|
||||
name: Add/update tag to new release
|
||||
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Copy image to GHCR
|
||||
env:
|
||||
|
||||
@@ -19,6 +19,9 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint-check:
|
||||
runs-on: ubuntu-slim
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
# contributor license agreements. See the NOTICE file distributed with
|
||||
# this work for additional information regarding copyright ownership.
|
||||
# The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
# (the "License"); you may not use this file except in compliance with
|
||||
# the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# Shared freshness gate used by the Docs Deployment workflow
|
||||
# (superset-docs-deploy.yml) both up front (check-freshness) and again right
|
||||
# before the deploy step (recheck-freshness). Writes an output declaring
|
||||
# whether BUILD_SHA is still master's current tip, so a superseded run can
|
||||
# skip cleanly instead of racing (and clobbering, or being force-cancelled
|
||||
# by) a fresher run.
|
||||
#
|
||||
# Required env vars:
|
||||
# BUILD_SHA - the commit SHA this run is building
|
||||
# REPO - "owner/repo" to query, e.g. github.repository
|
||||
# OUTPUT_NAME - the GITHUB_OUTPUT key to write, e.g. "is-current"
|
||||
# GITHUB_OUTPUT - path to append outputs to (set by the Actions runner)
|
||||
# Optional env vars:
|
||||
# EVENT_NAME - if "workflow_dispatch", bypasses the check and always
|
||||
# reports current, since a manual dispatch is a deliberate,
|
||||
# one-off action rather than something racing other triggers
|
||||
# GH_TOKEN - passed through to `gh`, needed to call the GitHub API
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ "${EVENT_NAME:-}" = "workflow_dispatch" ]; then
|
||||
echo "${OUTPUT_NAME}=true" >>"$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
latest_sha="$(gh api "repos/${REPO}/commits/master" --jq .sha)"
|
||||
if [ "${latest_sha}" = "${BUILD_SHA}" ]; then
|
||||
echo "${OUTPUT_NAME}=true" >>"$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "${OUTPUT_NAME}=false" >>"$GITHUB_OUTPUT"
|
||||
echo "::notice::master has moved on to ${latest_sha} since ${BUILD_SHA} was triggered — skipping this stale run."
|
||||
fi
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
# contributor license agreements. See the NOTICE file distributed with
|
||||
# this work for additional information regarding copyright ownership.
|
||||
# The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
# (the "License"); you may not use this file except in compliance with
|
||||
# the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# Exercises check-docs-deploy-freshness.sh against a stubbed `gh`, covering
|
||||
# the dispatch-bypass, current-tip and stale-tip branches so the output
|
||||
# contract (is-current / still-current) can't silently regress. Run
|
||||
# directly, no extra tooling required:
|
||||
# bash .github/workflows/scripts/check-docs-deploy-freshness.test.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
script_under_test="${script_dir}/check-docs-deploy-freshness.sh"
|
||||
|
||||
failures=0
|
||||
|
||||
# Runs the script under test with a stubbed `gh` reporting $1 as master's
|
||||
# latest sha, asserting that GITHUB_OUTPUT ends up containing exactly $4.
|
||||
run_case() {
|
||||
local case_name="$1"
|
||||
local latest_sha="$2"
|
||||
local build_sha="$3"
|
||||
local event_name="$4"
|
||||
local expected_line="$5"
|
||||
|
||||
local workdir
|
||||
workdir="$(mktemp -d)"
|
||||
trap 'rm -rf "${workdir}"' RETURN
|
||||
|
||||
# Fake `gh` that just echoes back the requested "latest" sha regardless of
|
||||
# arguments, so the script under test never touches the network.
|
||||
cat >"${workdir}/gh" <<EOF
|
||||
#!/bin/bash
|
||||
echo '${latest_sha}'
|
||||
EOF
|
||||
chmod +x "${workdir}/gh"
|
||||
|
||||
local output_file="${workdir}/github_output"
|
||||
: >"${output_file}"
|
||||
|
||||
if PATH="${workdir}:${PATH}" \
|
||||
GITHUB_OUTPUT="${output_file}" \
|
||||
OUTPUT_NAME="is-current" \
|
||||
REPO="apache/superset" \
|
||||
BUILD_SHA="${build_sha}" \
|
||||
EVENT_NAME="${event_name}" \
|
||||
GH_TOKEN="fake-token" \
|
||||
bash "${script_under_test}"; then
|
||||
:
|
||||
else
|
||||
echo "FAIL (${case_name}): script exited non-zero"
|
||||
failures=$((failures + 1))
|
||||
return
|
||||
fi
|
||||
|
||||
local actual
|
||||
actual="$(cat "${output_file}")"
|
||||
if [ "${actual}" = "${expected_line}" ]; then
|
||||
echo "PASS (${case_name})"
|
||||
else
|
||||
echo "FAIL (${case_name}): expected '${expected_line}', got '${actual}'"
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# `gh` prints "should-not-be-called" for the dispatch case above the trick:
|
||||
# it's never actually invoked since the bypass short-circuits before the
|
||||
# `gh api` call, but the fake still needs a body.
|
||||
run_case "workflow_dispatch bypasses the check" \
|
||||
"unused" "abc123" "workflow_dispatch" \
|
||||
"is-current=true"
|
||||
|
||||
run_case "build sha matches master's tip" \
|
||||
"abc123" "abc123" "push" \
|
||||
"is-current=true"
|
||||
|
||||
run_case "build sha is stale" \
|
||||
"def456" "abc123" "push" \
|
||||
"is-current=false"
|
||||
|
||||
if [ "${failures}" -gt 0 ]; then
|
||||
echo "${failures} case(s) failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "All cases passed"
|
||||
@@ -17,6 +17,9 @@ env:
|
||||
GITHUB_ORG: ${{ github.repository_owner }}
|
||||
GITHUB_REPO: ${{ github.event.repository.name }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
cleanup-expired:
|
||||
name: Clean up expired showtime environments
|
||||
|
||||
@@ -26,6 +26,9 @@ env:
|
||||
GITHUB_REPO: ${{ github.event.repository.name }}
|
||||
GITHUB_ACTOR: ${{ github.actor }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
name: 🎪 Sync PR to desired state
|
||||
|
||||
@@ -18,16 +18,6 @@ on:
|
||||
|
||||
workflow_dispatch: {}
|
||||
|
||||
# Serialize deploys: the action pushes to apache/superset-site without
|
||||
# rebasing, so concurrent runs race on the final push and the loser fails
|
||||
# with `! [rejected] asf-site -> asf-site (fetch first)`. Cancel any
|
||||
# in-progress run as soon as a newer one starts — the destination repo
|
||||
# isn't touched until the final push step, so canceling mid-build is safe,
|
||||
# and the freshest content always wins.
|
||||
concurrency:
|
||||
group: docs-deploy-asf-site
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
@@ -48,17 +38,69 @@ jobs:
|
||||
|
||||
env:
|
||||
SUPERSET_SITE_BUILD: ${{ (secrets.SUPERSET_SITE_BUILD != '' && secrets.SUPERSET_SITE_BUILD != '') || '' }}
|
||||
|
||||
# Master gets frequent, sometimes bursty pushes, and each one can trigger a
|
||||
# deploy attempt. Rather than let every superseded attempt get force-killed
|
||||
# by the build-deploy concurrency group below (which shows up as a
|
||||
# `cancelled` — i.e. red/failing-looking — check on that commit), have each
|
||||
# run check up front whether it's still building master's current tip and,
|
||||
# if not, skip cleanly. Deliberately outside the docs-deploy-asf-site
|
||||
# concurrency group so it runs immediately for every trigger without
|
||||
# blocking or being blocked by anything.
|
||||
check-freshness:
|
||||
runs-on: ubuntu-26.04
|
||||
outputs:
|
||||
is-current: ${{ steps.check.outputs.is-current }}
|
||||
steps:
|
||||
# Sparse checkout: this job's only job is to be fast, so it fetches
|
||||
# nothing but the freshness-check script itself.
|
||||
- name: Checkout freshness-check script
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
.github/workflows/scripts
|
||||
sparse-checkout-cone-mode: false
|
||||
- name: "Check whether this is still master's current commit"
|
||||
id: check
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
BUILD_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
REPO: ${{ github.repository }}
|
||||
OUTPUT_NAME: is-current
|
||||
run: .github/workflows/scripts/check-docs-deploy-freshness.sh
|
||||
|
||||
build-deploy:
|
||||
needs: config
|
||||
needs: [config, check-freshness]
|
||||
# Only the run for master's current tip proceeds; anything superseded
|
||||
# already skipped at check-freshness above instead of landing here.
|
||||
# For workflow_run triggers, only deploy when the triggering run originated
|
||||
# from this repository (not a fork), ensuring the checked-out code and any
|
||||
# local actions executed with deploy credentials are trusted.
|
||||
if: >-
|
||||
needs.config.outputs.has-secrets &&
|
||||
needs.check-freshness.outputs.is-current == 'true' &&
|
||||
(github.event_name != 'workflow_run' ||
|
||||
github.event.workflow_run.head_repository.full_name == github.repository)
|
||||
name: Build & Deploy
|
||||
runs-on: ubuntu-26.04
|
||||
# Serialize deploys: the action pushes to apache/superset-site without
|
||||
# rebasing, so concurrent runs race on the final push and the loser fails
|
||||
# with `! [rejected] asf-site -> asf-site (fetch first)`. Queue instead of
|
||||
# canceling: a run that already passed check-freshness can still be
|
||||
# sitting in the queue for a runner when a newer run starts and finishes
|
||||
# first. cancel-in-progress would let that stale, queued run kill the
|
||||
# newer run's in-progress deploy the moment it's finally scheduled, and
|
||||
# then skip itself at the re-check below — losing the deploy entirely.
|
||||
# Queuing means the stale run just waits its turn and then no-ops at the
|
||||
# re-check, so the fresher content that already deployed is never
|
||||
# clobbered or lost. The check-freshness gate above means it should be
|
||||
# rare for more than one run to reach this point, so the queue stays
|
||||
# short in practice.
|
||||
concurrency:
|
||||
group: docs-deploy-asf-site
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0
|
||||
with:
|
||||
@@ -130,7 +172,24 @@ jobs:
|
||||
working-directory: docs
|
||||
run: |
|
||||
yarn build
|
||||
# The check-freshness job above narrows the window but doesn't close it: an
|
||||
# older run can observe is-current=true, then sit through this build while a
|
||||
# newer run's own freshness check also passes and it deploys and finishes
|
||||
# first. If this (stale) run then wins entry into the concurrency group, it
|
||||
# would overwrite the newer content that already deployed. Re-check right
|
||||
# before the one step that actually mutates superset-site, so a stale run
|
||||
# skips deploying instead of clobbering a fresher one that already ran.
|
||||
- name: "Re-check freshness immediately before deploying"
|
||||
id: recheck-freshness
|
||||
if: github.event_name != 'workflow_dispatch'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
BUILD_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
REPO: ${{ github.repository }}
|
||||
OUTPUT_NAME: still-current
|
||||
run: .github/workflows/scripts/check-docs-deploy-freshness.sh
|
||||
- name: deploy docs
|
||||
if: github.event_name == 'workflow_dispatch' || steps.recheck-freshness.outputs.still-current == 'true'
|
||||
uses: ./.github/actions/github-action-push-to-another-repository
|
||||
env:
|
||||
API_TOKEN_GITHUB: ${{ secrets.SUPERSET_SITE_BUILD }}
|
||||
|
||||
@@ -26,6 +26,9 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-26.04
|
||||
@@ -75,14 +78,14 @@ jobs:
|
||||
USE_DASHBOARD: ${{ github.event.inputs.use_dashboard == 'true' || 'false' }}
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
image: ghcr.io/apache/superset/ci/postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: superset
|
||||
POSTGRES_PASSWORD: superset
|
||||
ports:
|
||||
- 15432:5432
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
image: ghcr.io/apache/superset/ci/redis:7-alpine
|
||||
ports:
|
||||
- 16379:6379
|
||||
steps:
|
||||
@@ -183,14 +186,14 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
image: ghcr.io/apache/superset/ci/postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: superset
|
||||
POSTGRES_PASSWORD: superset
|
||||
ports:
|
||||
- 15432:5432
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
image: ghcr.io/apache/superset/ci/redis:7-alpine
|
||||
ports:
|
||||
- 16379:6379
|
||||
steps:
|
||||
|
||||
@@ -212,3 +212,100 @@ jobs:
|
||||
- uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0
|
||||
with:
|
||||
expand-composite-actions: true
|
||||
|
||||
# Compares a PR's own bundle size against the last nightly-recorded
|
||||
# baseline (see frontend-bundle-size-nightly.yml, which owns actually
|
||||
# persisting new baselines). PR-only: a push to master doesn't need this
|
||||
# check re-run against itself, and re-persisting the baseline on every
|
||||
# push to master -- which happens many times a day -- would burn a full
|
||||
# production build for no benefit nightly refresh doesn't already cover.
|
||||
bundle-size:
|
||||
needs: frontend-build
|
||||
if: needs.frontend-build.outputs.should-run == 'true' && github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Download Docker Image Artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: docker-image
|
||||
|
||||
- name: Load Docker Image
|
||||
run: |
|
||||
zstd -d < docker-image.tar.zst | docker load
|
||||
|
||||
# webpack's persistent filesystem cache (superset-frontend/webpack.config.js)
|
||||
# turns a warm production build into ~20s instead of several minutes,
|
||||
# but GH-hosted runners are fresh VMs with nothing carried over between
|
||||
# jobs -- without restoring it explicitly, every single PR would pay
|
||||
# the full cold-build cost. Keyed on the same files webpack's own
|
||||
# `buildDependencies` invalidates on, so a stale cache is never used.
|
||||
- name: Restore webpack build cache
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: superset-frontend/.temp_cache
|
||||
key: >-
|
||||
webpack-prod-cache-${{ hashFiles('superset-frontend/package-lock.json',
|
||||
'superset-frontend/babel.config.js', 'superset-frontend/tsconfig.json',
|
||||
'superset-frontend/webpack.config.js') }}
|
||||
|
||||
# Only ever pull the last recorded data point off the cache, keyed by
|
||||
# run ID -- `restore-keys` prefix-matches the most recently created
|
||||
# entry, which is always the latest nightly run. Absent before the
|
||||
# first nightly run ever happens; benchmark-action starts a fresh
|
||||
# history in that case.
|
||||
- name: Restore bundle size history
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: bundle-size-history.json
|
||||
key: bundle-size-history-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
bundle-size-history-
|
||||
|
||||
- name: Build production bundle with stats
|
||||
run: |
|
||||
mkdir -p ${{ github.workspace }}/superset-frontend/bundle-stats
|
||||
mkdir -p ${{ github.workspace }}/superset-frontend/.temp_cache
|
||||
docker run \
|
||||
-v ${{ github.workspace }}/superset-frontend/bundle-stats:/app/superset-frontend/bundle-stats \
|
||||
-v ${{ github.workspace }}/superset-frontend/.temp_cache:/app/superset-frontend/.temp_cache \
|
||||
--rm $TAG \
|
||||
bash -c \
|
||||
"npm i && BUNDLE_SIZE_STATS=true npm run build -- --json=bundle-stats/stats.json"
|
||||
|
||||
- name: Summarize bundle size
|
||||
run: |
|
||||
node superset-frontend/scripts/bundle-size-summary.js \
|
||||
superset-frontend/bundle-stats/stats.json > bundle-size-summary.json
|
||||
rm -rf superset-frontend/bundle-stats
|
||||
|
||||
# Comparison + alert only -- this job never persists. See
|
||||
# frontend-bundle-size-nightly.yml for why.
|
||||
#
|
||||
# comment-on-alert is gated to same-repo PRs: on a fork PR,
|
||||
# GITHUB_TOKEN is forced read-only regardless of the `permissions`
|
||||
# block above, so once the alert threshold is crossed the action's
|
||||
# `pulls.createReview` call 403s. That error isn't gated by
|
||||
# fail-on-alert (which only governs the deliberate alert-threshold
|
||||
# failure) -- it propagates and fails the job outright. Fork PRs
|
||||
# still get the comparison via the job summary (summary-always).
|
||||
- name: Compare bundle size against nightly baseline
|
||||
uses: benchmark-action/github-action-benchmark@52576c92bccf6ac60c8223ec7eb2565637cae9ba # v1.22.1
|
||||
with:
|
||||
tool: customSmallerIsBetter
|
||||
output-file-path: bundle-size-summary.json
|
||||
external-data-json-path: bundle-size-history.json
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
comment-on-alert: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
alert-threshold: "110%"
|
||||
fail-on-alert: false
|
||||
summary-always: true
|
||||
|
||||
@@ -27,6 +27,9 @@ concurrency:
|
||||
group: helm-release
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-26.04
|
||||
|
||||
@@ -22,6 +22,9 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-26.04
|
||||
|
||||
@@ -53,9 +53,7 @@ jobs:
|
||||
mysql+mysqldb://superset:superset@127.0.0.1:13306/superset?charset=utf8mb4&binary_prefix=true
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
# Authenticated pulls use our higher Docker Hub rate limit. Empty on
|
||||
# fork PRs (secrets unavailable) -> runner falls back to anonymous.
|
||||
image: ghcr.io/apache/superset/ci/mysql:8.0
|
||||
env:
|
||||
MYSQL_ROOT_PASSWORD: root
|
||||
ports:
|
||||
@@ -66,7 +64,7 @@ jobs:
|
||||
--health-timeout=5s
|
||||
--health-retries=5
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
image: ghcr.io/apache/superset/ci/redis:7-alpine
|
||||
options: --entrypoint redis-server
|
||||
ports:
|
||||
- 16379:6379
|
||||
@@ -143,7 +141,7 @@ jobs:
|
||||
SUPERSET__SQLALCHEMY_DATABASE_URI: postgresql+psycopg2://superset:superset@127.0.0.1:15432/superset
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
image: ghcr.io/apache/superset/ci/postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: superset
|
||||
POSTGRES_PASSWORD: superset
|
||||
@@ -152,7 +150,7 @@ jobs:
|
||||
# GitHub action runner's default installations
|
||||
- 15432:5432
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
image: ghcr.io/apache/superset/ci/redis:7-alpine
|
||||
ports:
|
||||
- 16379:6379
|
||||
steps:
|
||||
@@ -202,7 +200,7 @@ jobs:
|
||||
sqlite:///${{ github.workspace }}/.temp/examples.db?check_same_thread=true
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
image: ghcr.io/apache/superset/ci/redis:7-alpine
|
||||
ports:
|
||||
- 16379:6379
|
||||
steps:
|
||||
|
||||
@@ -14,6 +14,9 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-26.04
|
||||
@@ -49,7 +52,7 @@ jobs:
|
||||
SUPERSET__SQLALCHEMY_EXAMPLES_URI: presto://localhost:15433/memory/default
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
image: ghcr.io/apache/superset/ci/postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: superset
|
||||
POSTGRES_PASSWORD: superset
|
||||
@@ -58,7 +61,7 @@ jobs:
|
||||
# GitHub action runner's default installations
|
||||
- 15432:5432
|
||||
presto:
|
||||
image: starburstdata/presto:350-e.6
|
||||
image: ghcr.io/apache/superset/ci/presto:350-e.6
|
||||
env:
|
||||
POSTGRES_USER: superset
|
||||
POSTGRES_PASSWORD: superset
|
||||
@@ -67,7 +70,7 @@ jobs:
|
||||
# GitHub action runner's default installations
|
||||
- 15433:8080
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
image: ghcr.io/apache/superset/ci/redis:7-alpine
|
||||
ports:
|
||||
- 16379:6379
|
||||
steps:
|
||||
@@ -113,7 +116,7 @@ jobs:
|
||||
UPLOAD_FOLDER: /tmp/.superset/uploads/
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
image: ghcr.io/apache/superset/ci/postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: superset
|
||||
POSTGRES_PASSWORD: superset
|
||||
@@ -122,7 +125,7 @@ jobs:
|
||||
# GitHub action runner's default installations
|
||||
- 15432:5432
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
image: ghcr.io/apache/superset/ci/redis:7-alpine
|
||||
ports:
|
||||
- 16379:6379
|
||||
steps:
|
||||
|
||||
@@ -75,13 +75,18 @@ jobs:
|
||||
SUPERSET_SECRET_KEY: not-a-secret
|
||||
run: |
|
||||
pytest --durations-min=0.5 --cov-report= --cov=superset ./tests/common ./tests/unit_tests --cache-clear --maxfail=50 --junit-xml=test-results/junit-unit.xml
|
||||
# COVERAGE_FILE keeps these scoped gates off the default .coverage that
|
||||
# the step above wrote. pytest-cov starts a fresh data file per run, so
|
||||
# without it the last gate replaces the full-suite data and the report
|
||||
# uploaded below contains only that gate's subtree -- every other file
|
||||
# then reads as uncovered, and patch coverage on an unrelated PR is 0%.
|
||||
- name: Python 100% coverage unit tests
|
||||
env:
|
||||
SUPERSET_TESTENV: true
|
||||
SUPERSET_SECRET_KEY: not-a-secret
|
||||
run: |
|
||||
pytest --durations-min=0.5 --cov=superset/sql/ ./tests/unit_tests/sql/ --cache-clear --cov-fail-under=100 --junit-xml=test-results/junit-sql-coverage.xml
|
||||
pytest --durations-min=0.5 --cov=superset/semantic_layers/ ./tests/unit_tests/semantic_layers/ --cache-clear --cov-fail-under=100 --junit-xml=test-results/junit-semantic-layers-coverage.xml
|
||||
COVERAGE_FILE="${RUNNER_TEMP}/.coverage.sql" pytest --durations-min=0.5 --cov=superset/sql/ ./tests/unit_tests/sql/ --cache-clear --cov-fail-under=100 --junit-xml=test-results/junit-sql-coverage.xml
|
||||
COVERAGE_FILE="${RUNNER_TEMP}/.coverage.semantic_layers" pytest --durations-min=0.5 --cov=superset/semantic_layers/ ./tests/unit_tests/semantic_layers/ --cache-clear --cov-fail-under=100 --junit-xml=test-results/junit-semantic-layers-coverage.xml
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
with:
|
||||
|
||||
@@ -13,6 +13,9 @@ on:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
supersetbot:
|
||||
runs-on: ubuntu-26.04
|
||||
|
||||
@@ -5,6 +5,9 @@ on:
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
welcome:
|
||||
runs-on: ubuntu-slim
|
||||
|
||||
@@ -31,6 +31,8 @@ under the License.
|
||||
[](https://github.com/apache/superset/pulls)
|
||||
[](https://bit.ly/join-superset-slack)
|
||||
[](https://superset.apache.org)
|
||||
[](https://superset-storybook.netlify.app)
|
||||
[](https://superset-bundle-analyzer.netlify.app)
|
||||
|
||||
<picture width="500">
|
||||
<source
|
||||
|
||||
+44
@@ -24,6 +24,31 @@ assists people when migrating to a new version.
|
||||
|
||||
## Next
|
||||
|
||||
- `SAMPLES_ROW_LIMIT` is now the default for `/datasource/samples` requests without a valid explicit `per_page`, rather than a hard per-request ceiling; explicit limits are honored up to the existing global row-limit ceiling, matching `/chart/data` SAMPLES requests.
|
||||
|
||||
### MCP tool results preserve stored string values
|
||||
|
||||
Structured MCP tool results no longer add `<UNTRUSTED-CONTENT>` wrappers or
|
||||
rewrite delimiter-looking text inside string fields. Tool-result content remains
|
||||
user-controlled data, but clients must convey that trust boundary outside domain
|
||||
values instead of recognizing or removing marker strings.
|
||||
|
||||
Clients that handled the former delimiter convention should stop stripping marker
|
||||
text: the same text can be legitimate stored content. Response models and content
|
||||
types are unchanged, and no metadata-database migration is required. Automated
|
||||
read-modify-write workflows should be paused or pinned away from older instances
|
||||
until every serving instance is upgraded; a mixed-version response has no reliable
|
||||
signal that tells a client whether its text is decorated. Redis-backed MCP response
|
||||
caches use a new internal namespace after the upgrade, so upgraded instances do not
|
||||
reuse older cached results.
|
||||
|
||||
Values that a client already wrote back with presentation wrappers cannot be
|
||||
distinguished safely from intentional content. Operators should review possible
|
||||
`<UNTRUSTED-CONTENT>` / `</UNTRUSTED-CONTENT>` wrappers and
|
||||
`[ESCAPED-UNTRUSTED-CONTENT-OPEN]` /
|
||||
`[ESCAPED-UNTRUSTED-CONTENT-CLOSE]` substitutions rather than applying an automatic
|
||||
marker-removal migration.
|
||||
|
||||
### OAuth2 database callback metrics include their outcome
|
||||
|
||||
The unqualified `DatabaseRestApi.oauth2` StatsD counter has been replaced with
|
||||
@@ -31,7 +56,9 @@ The unqualified `DatabaseRestApi.oauth2` StatsD counter has been replaced with
|
||||
`DatabaseRestApi.oauth2.error`. Update monitoring rules and dashboards that consume
|
||||
the old counter to use the outcome-specific replacements.
|
||||
|
||||
- [42930](https://github.com/apache/superset/pull/42930): Dataset import data-URI fetches no longer honor an HTTP(S) proxy when `DATASET_IMPORT_ALLOW_INTERNAL_DATA_URLS` is `False` (the default): the connection is now made directly to the destination so the peer-address check validates the real target instead of a proxy's. Deployments that require an egress proxy to reach legitimate external data URLs for dataset import should set `DATASET_IMPORT_ALLOW_INTERNAL_DATA_URLS = True` or otherwise ensure those URLs resolve without one.
|
||||
- [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.
|
||||
- [43388](https://github.com/apache/superset/pull/43388): The MCP service now refuses to start (`MCPAuthConfigError`) if `MCP_DEV_USERNAME` and `MCP_AUTH_ENABLED = True` are both set, and separately if `MCP_AUTH_ENABLED = True` but no usable JWT key material is configured (RSA key/JWKS, or an explicit `MCP_JWT_SECRET` for HMAC) — both previously started with authentication silently weaker than configured. Deployments combining a dev-mode username with JWT auth enabled, or enabling JWT auth without key material, must pick one before upgrading: unset `MCP_DEV_USERNAME` for a real auth deployment, or unset `MCP_AUTH_ENABLED` (or configure the key material) for a dev-mode one. Response caching (`MCP_CACHE_CONFIG["enabled"] = True`) now also excludes every tool with a side effect by default, not only a partial list, so a previously-cached mutating tool call is no longer served from cache; no config change is needed to pick this up.
|
||||
- [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.
|
||||
@@ -99,6 +126,23 @@ 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.
|
||||
|
||||
### New metric aggregates: MEDIAN, Sample Standard Deviation, Sample Variance
|
||||
|
||||
`MEDIAN`, `STDDEV_SAMP`, and `VAR_SAMP` are now available anywhere a metric
|
||||
aggregate is chosen (every chart type, SQL Lab, MCP), not only in Pivot
|
||||
Table's controls. Support is opt-in per database engine *spec class*,
|
||||
verified against a live instance before being enabled: Postgres, MySQL
|
||||
(`STDDEV_SAMP`/`VAR_SAMP` only, no `MEDIAN`), DuckDB, and Redshift (inherits
|
||||
Postgres's support, not yet separately verified) ship enabled in this
|
||||
release. Engine specs that subclass one of those (e.g. MariaDB, Aurora
|
||||
MySQL/Postgres, TimescaleDB) inherit the same support, on the same
|
||||
not-yet-independently-verified basis. Picking one of these aggregates on a
|
||||
database that has not opted in returns a clear "not supported on this
|
||||
database" error rather than a failed query. See
|
||||
`docs/sip/median-stddev-variance-aggregates.md` for the full design
|
||||
rationale, including why this is safe to add without reintroducing the
|
||||
totals/subtotals correctness bug fixed by #41184 (SIP-216).
|
||||
|
||||
### Soft delete is on by default, and purging is live
|
||||
|
||||
`SOFT_DELETE` now ships **on** (`DEFAULT_FEATURE_FLAGS`), so deleting a
|
||||
|
||||
@@ -97,6 +97,54 @@ for more information on how to configure it.
|
||||
|
||||
At the very least, you'll want to change `SECRET_KEY` and `SQLALCHEMY_DATABASE_URI`. Continue reading for more about each of these.
|
||||
|
||||
## Localizing D3 date and time labels
|
||||
|
||||
`BABEL_DEFAULT_LOCALE` controls Superset's application translations, while
|
||||
`D3_TIME_FORMAT` provides localized date and time names to visualizations that
|
||||
use the D3 formatter registry, including Calendar Heatmap. Configure both when
|
||||
you want the application and chart labels to use the same locale.
|
||||
|
||||
`D3_TIME_FORMAT` accepts partial overrides. For example, Russian month names
|
||||
can be configured in `superset_config.py` as follows:
|
||||
|
||||
```python
|
||||
BABEL_DEFAULT_LOCALE = "ru"
|
||||
|
||||
D3_TIME_FORMAT = {
|
||||
"months": [
|
||||
"Январь",
|
||||
"Февраль",
|
||||
"Март",
|
||||
"Апрель",
|
||||
"Май",
|
||||
"Июнь",
|
||||
"Июль",
|
||||
"Август",
|
||||
"Сентябрь",
|
||||
"Октябрь",
|
||||
"Ноябрь",
|
||||
"Декабрь",
|
||||
],
|
||||
"shortMonths": [
|
||||
"Янв",
|
||||
"Фев",
|
||||
"Мар",
|
||||
"Апр",
|
||||
"Май",
|
||||
"Июн",
|
||||
"Июл",
|
||||
"Авг",
|
||||
"Сен",
|
||||
"Окт",
|
||||
"Ноя",
|
||||
"Дек",
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
Restart Superset after changing `superset_config.py` so the frontend receives
|
||||
the updated formatter configuration.
|
||||
|
||||
## Chart-data query timing
|
||||
|
||||
Set `CHART_DATA_INCLUDE_TIMING = True` to add an optional versioned timing object
|
||||
|
||||
@@ -576,7 +576,7 @@ MCP_CACHE_CONFIG = {
|
||||
| Key | Default | Description |
|
||||
| -------------------- | --------- | ----------------------------------------------------------- |
|
||||
| `enabled` | `False` | Enable response caching |
|
||||
| `CACHE_KEY_PREFIX` | `None` | Optional prefix for cache keys (useful for shared Redis) |
|
||||
| `CACHE_KEY_PREFIX` | `None` | Base prefix for shared Redis; Superset appends an internal response-contract namespace |
|
||||
| `list_tools_ttl` | `300` | Cache TTL in seconds for `tools/list` |
|
||||
| `list_resources_ttl` | `300` | Cache TTL for `resources/list` |
|
||||
| `list_prompts_ttl` | `300` | Cache TTL for `prompts/list` |
|
||||
@@ -718,6 +718,34 @@ Every MCP request passes through a middleware stack before reaching the tool fun
|
||||
|
||||
Additional middleware classes (`RateLimitMiddleware`, `FieldPermissionsMiddleware`, `PrivateToolMiddleware`) are implemented in `superset/mcp_service/middleware.py` but are not added to the default pipeline. They are available for operators who want to layer them in via a custom startup path.
|
||||
|
||||
### Tool Result Value Contract
|
||||
|
||||
Structured tool results preserve Superset domain values exactly. In particular,
|
||||
string fields are not wrapped in trust delimiters, and text that resembles a
|
||||
delimiter is returned as literal application data. This lets clients safely use a
|
||||
read result as the basis for an update without persisting presentation markup.
|
||||
|
||||
All tool-result content should still be treated as user-controlled data with no
|
||||
instruction authority. MCP clients should communicate that trust boundary through
|
||||
their model instructions or presentation layer, outside the returned field values;
|
||||
fixed or generated marker strings inside a value are ambiguous and must not be used
|
||||
as a trust signal.
|
||||
|
||||
For compatibility, clients that supported the former
|
||||
`<UNTRUSTED-CONTENT>` convention should stop recognizing or stripping those strings.
|
||||
The response schemas and content types have not changed. Because marker-looking text
|
||||
can be legitimate application data, a client cannot reliably distinguish a legacy
|
||||
decorated response from a clean one. Pause automated read-modify-write workflows, or
|
||||
route them only to upgraded instances, until every serving instance is upgraded.
|
||||
|
||||
Redis-backed MCP response caches include an internal response-contract namespace, so
|
||||
an upgraded instance does not reuse responses cached by an older release. Older
|
||||
instances can still return legacy values while they remain in service. After the
|
||||
upgrade, review previously written values for wrapper text and both
|
||||
`[ESCAPED-UNTRUSTED-CONTENT-OPEN]` and
|
||||
`[ESCAPED-UNTRUSTED-CONTENT-CLOSE]`; do not remove these strings automatically,
|
||||
because they may be intentional content.
|
||||
|
||||
### Error Sanitization
|
||||
|
||||
The `GlobalErrorHandlerMiddleware` automatically redacts sensitive information from all error messages before they reach the LLM client. The following are replaced with generic messages:
|
||||
@@ -752,11 +780,20 @@ For a 3-pod Kubernetes deployment with the defaults above, expect up to 3 × (5
|
||||
Enable response caching for read-heavy workloads (dashboards/datasets that don't change frequently). With the in-memory backend (default when `MCP_STORE_CONFIG` is disabled), caching is per-process. Use Redis-backed caching for consistent cache hits across multiple pods:
|
||||
|
||||
```python
|
||||
MCP_CACHE_CONFIG = {"enabled": True, "call_tool_ttl": 3600}
|
||||
MCP_CACHE_CONFIG = {
|
||||
"enabled": True,
|
||||
# Cache keys don't include the requesting principal and hits are served
|
||||
# ahead of auth/RBAC, so a shared cache can return one caller's response
|
||||
# to another. Required for caching to actually start -- only appropriate
|
||||
# when every request is guaranteed to come from the same principal.
|
||||
"dangerously_share_cache_across_principals": True,
|
||||
"CACHE_KEY_PREFIX": "mcp_cache_",
|
||||
"call_tool_ttl": 3600,
|
||||
}
|
||||
MCP_STORE_CONFIG = {"enabled": True, "CACHE_REDIS_URL": "redis://redis:6379/0"}
|
||||
```
|
||||
|
||||
Mutating tools (`generate_chart`, `update_chart`, `execute_sql`, `generate_dashboard`) are always excluded from caching regardless of this setting.
|
||||
Every tool with a side effect (create/update/delete/execute) is always excluded from caching regardless of this setting -- see the `excluded_tools` default in `superset/mcp_service/mcp_config.py` for the current list.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+49
-5
@@ -46,13 +46,43 @@ import FAQSchema from '@site/src/components/FAQSchema';
|
||||
answer:
|
||||
'You need to register a free account at Mapbox.com, obtain an API key, and add it to your .env file at the key MAPBOX_API_KEY.',
|
||||
},
|
||||
{
|
||||
question: 'How to limit the timed refresh on a dashboard?',
|
||||
answer:
|
||||
'To exclude specific slices from the timed refresh process, add the timed_refresh_immune_slices key to the dashboard JSON Metadata field with the slice IDs to exclude.',
|
||||
},
|
||||
{
|
||||
question: "Why does 'flask fab' or Superset freeze, hang, or not respond when started (my home directory is NFS mounted)?",
|
||||
answer:
|
||||
'By default, Superset creates and uses an SQLite database at ~/.superset/superset.db. SQLite is known to not work well if used on NFS due to broken file locking. Override the path with the SUPERSET_HOME environment variable or change SQLALCHEMY_DATABASE_URI in superset_config.py.',
|
||||
},
|
||||
{
|
||||
question: 'What if the table schema changed?',
|
||||
answer:
|
||||
'Go to Data -> Datasets, click the edit icon next to the dataset whose schema has changed, and hit Sync columns from source from the Columns tab. The new columns will get merged.',
|
||||
},
|
||||
{
|
||||
question: 'What database engine can I use as a backend for Superset?',
|
||||
answer:
|
||||
'Superset is tested using MySQL, PostgreSQL, and SQLite backends for storing its internal metadata. While Superset supports many databases as data sources, only these are recommended for the metadata store in production.',
|
||||
},
|
||||
{
|
||||
question: 'Does Superset work with my database?',
|
||||
question: 'How can I configure OAuth authentication and authorization?',
|
||||
answer:
|
||||
'Take a look at the Flask-AppBuilder OAuth configuration example, which shows how to configure OAuth authentication and authorization in Superset.',
|
||||
},
|
||||
{
|
||||
question: 'Is there a way to force the dashboard to use specific colors?',
|
||||
answer:
|
||||
'It is possible on a per-dashboard basis by providing a mapping of labels to colors in the JSON Metadata attribute using the label_colors key. You can use a full hex color, a named color, or the index in the current color palette.',
|
||||
},
|
||||
{
|
||||
question: 'How do I expand all chart descriptions on a dashboard by default?',
|
||||
answer:
|
||||
'Add the expand_all_slices key to the dashboard JSON Metadata field with a value of true. Charts that have already been manually expanded or collapsed keep that per-chart override regardless of the setting.',
|
||||
},
|
||||
{
|
||||
question: 'Does Superset work with [insert database engine here]?',
|
||||
answer:
|
||||
'Superset supports any database with a Python SQLAlchemy dialect and DBAPI driver. Check the Connecting to Databases documentation for the full list of supported databases.',
|
||||
},
|
||||
@@ -61,15 +91,30 @@ import FAQSchema from '@site/src/components/FAQSchema';
|
||||
answer:
|
||||
'Yes, Superset has a public REST API documented using Swagger. Enable FAB_API_SWAGGER_UI in superset_config.py to access interactive API documentation at /swagger/v1.',
|
||||
},
|
||||
{
|
||||
question: 'How can I see usage statistics (e.g., monthly active users)?',
|
||||
answer:
|
||||
'This functionality is not included with Superset, but you can extract and analyze the logs table in Superset\u2019s metadata database to see what actions have occurred.',
|
||||
},
|
||||
{
|
||||
question: 'What does Hours Offset in the Edit Dataset view do?',
|
||||
answer:
|
||||
'In the Edit Dataset view, the hours offset lets you configure the number of hours to be added or subtracted from the time column. This can be used, for example, to convert UTC time to local time.',
|
||||
},
|
||||
{
|
||||
question: 'Does Superset collect any telemetry data?',
|
||||
answer:
|
||||
'Superset uses Scarf by default to collect basic telemetry data to help maintainers understand version usage. Users can opt out by setting the SCARF_ANALYTICS environment variable to false.',
|
||||
},
|
||||
{
|
||||
question: 'Does Superset have a trash bin to recover deleted assets?',
|
||||
question: 'Does Superset have an archive panel or trash bin from which a user can recover deleted assets?',
|
||||
answer:
|
||||
'No, there is no built-in way to recover deleted dashboards, charts, or datasets. It is recommended to take periodic backups of the metadata database and use export functionality for recovery.',
|
||||
'No. Currently, there is no way to recover a deleted Superset dashboard, chart, dataset, or database from the UI. It is recommended to take periodic backups of the metadata database and use export functionality for recovery.',
|
||||
},
|
||||
{
|
||||
question: 'I ran a security scan of the Superset container image and it showed dozens of "high" and "critical" vulnerabilities! Can you release a version of Superset without these?',
|
||||
answer:
|
||||
'These are dependency CVEs in software that Superset uses, mostly in the Linux kernel or Python. Superset addresses them by regularly updating dependencies and welcomes pull requests that fix dependency CVEs. The Superset security team focuses primarily on vulnerabilities in Superset itself.',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -214,8 +259,7 @@ frontend falls back to a built-in default of `5000` milliseconds.
|
||||
SUPERSET_DASHBOARD_MANUAL_REFRESH_STAGGER_MS = 0
|
||||
```
|
||||
|
||||
**Why does ‘flask fab’ or superset freeze/hang/not responding when started (my home directory is
|
||||
NFS mounted)?**
|
||||
## Why does `flask fab` or Superset freeze, hang, or not respond when started (my home directory is NFS mounted)?
|
||||
|
||||
By default, Superset creates and uses an SQLite database at `~/.superset/superset.db`. SQLite is
|
||||
known to [not work well if used on NFS](https://www.sqlite.org/lockingv3.html) due to broken file
|
||||
|
||||
@@ -11,6 +11,11 @@ When soft-delete is enabled, deleting a chart, dashboard, or dataset archives it
|
||||
instead of removing it permanently. The **Recently Archived** view lets owners
|
||||
and admins find archived objects and restore them.
|
||||
|
||||
A chart used by an alert or report cannot be archived while that dependency
|
||||
exists. In the chart list view, the archive confirmation lists the alerts and
|
||||
reports that use the chart; a blocked attempt names them and asks you to
|
||||
detach or delete them first.
|
||||
|
||||
:::note
|
||||
|
||||
This view is gated by the `SOFT_DELETE` feature flag. When the flag is off the
|
||||
|
||||
+15
-15
@@ -58,15 +58,15 @@
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
"@mdx-js/react": "^3.1.1",
|
||||
"@saucelabs/theme-github-codeblock": "^0.3.0",
|
||||
"@storybook/addon-docs": "^10.5.7",
|
||||
"@storybook/addon-docs": "^10.5.8",
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"@swc/core": "^1.15.47",
|
||||
"antd": "^6.5.4",
|
||||
"baseline-browser-mapping": "^2.11.12",
|
||||
"caniuse-lite": "^1.0.30001807",
|
||||
"docusaurus-plugin-openapi-docs": "^5.1.3",
|
||||
"docusaurus-theme-openapi-docs": "^5.1.3",
|
||||
"js-yaml": "^5.2.3",
|
||||
"@swc/core": "^1.16.0",
|
||||
"antd": "^6.6.0",
|
||||
"baseline-browser-mapping": "^2.11.15",
|
||||
"caniuse-lite": "^1.0.30001809",
|
||||
"docusaurus-plugin-openapi-docs": "^5.2.0",
|
||||
"docusaurus-theme-openapi-docs": "^5.2.0",
|
||||
"js-yaml": "^5.3.0",
|
||||
"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.7",
|
||||
"swagger-ui-react": "^5.32.12",
|
||||
"storybook": "^10.5.8",
|
||||
"swagger-ui-react": "^5.32.13",
|
||||
"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.66.0",
|
||||
"@typescript-eslint/parser": "^8.66.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.67.0",
|
||||
"@typescript-eslint/parser": "^8.67.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"globals": "^17.9.0",
|
||||
"oxfmt": "^0.62.0",
|
||||
"globals": "^17.11.0",
|
||||
"oxfmt": "^0.63.0",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.66.0",
|
||||
"typescript-eslint": "^8.67.0",
|
||||
"webpack": "^5.109.2"
|
||||
},
|
||||
"browserslist": {
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
|
||||
# SIP: System-wide MEDIAN, Sample Standard Deviation, and Sample Variance metric aggregates
|
||||
|
||||
## [DRAFT — proposal for discussion]
|
||||
|
||||
This document now has an accompanying implementation in this PR, for the
|
||||
proposed mechanism plus a first, empirically-verified engine set (Postgres,
|
||||
MySQL, DuckDB, Redshift by inheritance). It is intended to seed discussion on
|
||||
whether this is the right shape and scope before it goes up for a formal SIP
|
||||
vote, not to pre-empt that discussion, the code exists so reviewers have a
|
||||
concrete design to react to rather than a description of one.
|
||||
|
||||
## Motivation
|
||||
|
||||
Before #41184 (SIP-216, the non-additive-totals fix), the Pivot Table chart
|
||||
exposed an "Aggregation function" control with 18 choices, including
|
||||
`Median`, `Sample Standard Deviation`, `Sample Variance`, `First`, `Last`,
|
||||
`Count Unique Values`, and `List Unique Values`. #41184 deleted that control
|
||||
wholesale, and deliberately so: it re-aggregated already-aggregated cell
|
||||
values to compute totals/subtotals, which is exactly the class of bug
|
||||
SIP-216 fixed (summing per-group averages, averaging per-group medians, etc.
|
||||
produces silently wrong totals). #42761 subsequently restored the one piece
|
||||
of that control's functionality that was cleanly separable from the
|
||||
correctness bug, the "show as % of row/column/total" display option,
|
||||
redesigned as a decoupled, post-hoc-only `showValuesAs` control.
|
||||
|
||||
A user has since noticed that several of the other pre-#41184 options never
|
||||
came back. Checking today's metric aggregate list (`AVG, COUNT,
|
||||
COUNT_DISTINCT, MAX, MIN, SUM`, see
|
||||
`superset-frontend/packages/superset-ui-core/src/query/types/Metric.ts`),
|
||||
most of these have a reasonable equivalent already: `Count Unique Values`
|
||||
maps to `COUNT_DISTINCT`; `Count`/`Average`/`Max`/`Min` are already standard
|
||||
aggregates; the two "fraction of" variants are already covered by
|
||||
`showValuesAs`. But `Median`, `Sample Standard Deviation`, and `Sample
|
||||
Variance` have no equivalent today anywhere in Superset, not just in Pivot
|
||||
Table, in any chart type, since the aggregate list is shared across the
|
||||
whole app.
|
||||
|
||||
This is a real, currently-live gap, not a hypothetical one:
|
||||
`superset/mcp_service/chart/chart_utils.py`, `schemas.py`, and
|
||||
`prompts/create_chart_guided.py` already treat `STDDEV`, `VAR`, `MEDIAN`,
|
||||
and `PERCENTILE` as valid aggregate values in their own validation and
|
||||
documentation, but those values are never recognized by
|
||||
`superset/connectors/sqla/models.py`'s `sqla_aggregations` dict (the actual
|
||||
mapping from aggregate name to SQL), so an AI agent using the MCP tool to
|
||||
build a chart with `"aggregate": "STDDEV"` today creates a chart that
|
||||
**errors at query time** with "Adhoc metric aggregate is invalid." This SIP
|
||||
proposes closing that gap for real, at the source, rather than patching
|
||||
around it in MCP.
|
||||
|
||||
## Proposed change
|
||||
|
||||
Add `MEDIAN`, `STDDEV_SAMP`, and `VAR_SAMP` as first-class, system-wide
|
||||
metric aggregates, available anywhere a metric aggregate is chosen (every
|
||||
chart type, SQL Lab metric picker, MCP), not as a Pivot-Table-specific
|
||||
control.
|
||||
|
||||
**Why this is safe with respect to SIP-216, and needs no Pivot-Table-specific
|
||||
code at all:** Pivot Table's non-additive-totals machinery
|
||||
(`superset-frontend/plugins/plugin-chart-pivot-table/src/plugin/utilities.ts`)
|
||||
already classifies any metric aggregate not in `ADDITIVE_AGGREGATES = {SUM,
|
||||
COUNT, MIN, MAX}` as non-additive, which routes totals/subtotals through the
|
||||
correct DB-`GROUPING SETS`-rollup path rather than client-side
|
||||
re-aggregation (`AVG` and `COUNT_DISTINCT` already go through this path
|
||||
today). `MEDIAN`/`STDDEV_SAMP`/`VAR_SAMP` fall into that bucket
|
||||
automatically, with zero changes needed to the additivity logic. So once
|
||||
these are valid, buildable SQL aggregates, Pivot Table (and every other
|
||||
chart) gets correct behavior for free. This is the version of "restore the
|
||||
control" that does not reopen the bug that was just fixed.
|
||||
|
||||
**Where the actual change needs to land, and what this PR does:**
|
||||
|
||||
1. **Done.** `superset-frontend/packages/superset-ui-core/src/query/types/Metric.ts`,
|
||||
extended the `Aggregate` type.
|
||||
2. **Done.** `superset-frontend/src/explore/constants.ts`, added to `AGGREGATES`
|
||||
(drives `AGGREGATES_OPTIONS`, the dropdown in `AdhocMetricEditPopover`).
|
||||
3. **Done**, but not consolidated. `superset/connectors/sqla/models.py`
|
||||
(`sqla_aggregations`) and `superset/models/helpers.py`
|
||||
(`ExploreMixin.sqla_aggregations`) are both wired to consult the new
|
||||
`BaseEngineSpec.get_extended_aggregation_func`, in addition to their
|
||||
existing 6-aggregate dict, so neither's original, already-tested behavior
|
||||
changed. They remain two separate dicts, consolidating them into one
|
||||
source of truth is left as a follow-up (see Open questions).
|
||||
4. **Done**, and it surfaced a second, smaller bug on top of the one this SIP
|
||||
opened with: MCP's own aggregate names (`STDDEV`, `VAR`) never matched any
|
||||
real Superset aggregate, before or after this PR, they were always going
|
||||
to error regardless of what this SIP does. `superset/mcp_service/chart/*`
|
||||
now accepts the old shorthand as an alias, normalized to the real,
|
||||
unambiguous names (`STDDEV_SAMP`, `VAR_SAMP`) this PR ships, and the guided
|
||||
prompt text points at the correct names going forward. `MEDIAN`/
|
||||
`PERCENTILE` were already spelled correctly in MCP; `PERCENTILE` remains
|
||||
unimplemented (it needs a parameter this schema has no field for) and is
|
||||
unchanged by this PR, out of scope here.
|
||||
|
||||
**The part that needs real engineering care, this must not be a blind
|
||||
`sa.func.MEDIAN` / `sa.func.STDDEV_SAMP` / `sa.func.VAR_SAMP`:**
|
||||
|
||||
`sqla_aggregations` today is a flat, engine-unaware dict (`sa.func.AVG`,
|
||||
etc., SQLAlchemy emits whatever function name it is given, with zero
|
||||
validation that the target dialect actually has it). Superset already has
|
||||
precedent for exactly this class of per-engine capability difference:
|
||||
`BaseEngineSpec.supports_grouping_sets` and `_time_grain_expressions`, both
|
||||
introduced by #41184 itself. This SIP proposes the same shape, a new
|
||||
per-engine-overridable mechanism (for example
|
||||
`BaseEngineSpec.get_aggregate_sql(aggregate, column)` with a sensible
|
||||
default, overridden per engine spec where the default does not hold),
|
||||
rather than a single hardcoded dict.
|
||||
|
||||
Verified findings so far (via `sqlglot.transpile`, cross-checked against
|
||||
known engine docs; **not** exhaustively tested against live databases, that
|
||||
is necessary follow-up work this SIP alone cannot complete):
|
||||
|
||||
| Engine | `MEDIAN(x)` | `STDDEV_SAMP(x)` | `VAR_SAMP(x)` |
|
||||
|---|---|---|---|
|
||||
| Postgres | `PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY x)` | native | native |
|
||||
| MySQL | no native equivalent, needs explicit "unsupported" handling, not a blind emit | native | MySQL's `VARIANCE()` is an alias for `VAR_POP` (population), not `VAR_SAMP` (sample); a naive dialect-name substitution would silently compute the wrong statistic and needs an explicit, verified expression instead |
|
||||
| SQLite | only if the specific build was compiled with the (non-default) `SQLITE_ENABLE_PERCENTILE` extension (added in SQLite 3.43, 2023), cannot be assumed available | not available in core SQLite | not available in core SQLite |
|
||||
| BigQuery / Snowflake / DuckDB / Redshift / Oracle / T-SQL / Databricks / Spark | native `MEDIAN(x)` | native | native on BigQuery/Snowflake/Databricks/Spark, where `VARIANCE` is correctly sample variance; T-SQL has no function named `VARIANCE` at all and needs `VAR(x)` instead |
|
||||
| Trino / Presto / Hive | `PERCENTILE_CONT` / `approx_percentile` (dialect- and exactness-dependent) | native | `variance` is correctly sample variance per Trino/Presto docs |
|
||||
|
||||
This table is deliberately not exhaustive, Superset has roughly 75
|
||||
`db_engine_specs` files. The proposed default (`BaseEngineSpec`) should be
|
||||
the safe choice (mark unsupported, surface a clear user-facing error) rather
|
||||
than an optimistic one, with individual engine specs opting in once
|
||||
verified. Ship for the handful of engines above first, extend
|
||||
opportunistically.
|
||||
|
||||
**`Count Unique Values`, `First`, `Last`, `List Unique Values`, explicitly
|
||||
out of scope for this SIP:**
|
||||
|
||||
- `Count Unique Values` needs no work, it is already `COUNT_DISTINCT`.
|
||||
- `First`/`Last` have no well-defined, unambiguous meaning as a plain
|
||||
`GROUP BY` aggregate without an explicit ordering; most engines only
|
||||
support this via window functions (`FIRST_VALUE`/`LAST_VALUE` `OVER
|
||||
(ORDER BY ...)`) or do not support it as a simple aggregate at all
|
||||
(Postgres has neither built in). Restoring this properly would mean
|
||||
designing an "order by" sub-control on the metric, a real, separate
|
||||
feature, not a one-line aggregate addition. Proposed as a follow-up SIP if
|
||||
there is demand.
|
||||
- `List Unique Values` maps to the `STRING_AGG`/`GROUP_CONCAT`/`LISTAGG`/
|
||||
`ARRAY_AGG(DISTINCT ...)` family, real dialect differences, plus an open
|
||||
UX question (unbounded cell content for high-cardinality columns).
|
||||
Proposed as a follow-up SIP.
|
||||
|
||||
## New or changed public interfaces
|
||||
|
||||
- New `Aggregate` values (`MEDIAN`, `STDDEV_SAMP`, `VAR_SAMP`) selectable
|
||||
anywhere the standard metric control appears, every chart type, not just
|
||||
Pivot Table.
|
||||
- New `BaseEngineSpec` extensibility point for per-engine aggregate SQL
|
||||
generation (exact shape TBD in implementation, likely mirrors
|
||||
`_time_grain_expressions`).
|
||||
- No REST API surface changes beyond the existing metric aggregate field
|
||||
accepting new values.
|
||||
|
||||
## Migration plan and compatibility
|
||||
|
||||
No new tables/columns needed for the aggregate addition itself.
|
||||
|
||||
Restoring prior chart settings, the way #42761 restored `show_values_as` for
|
||||
charts that had it before #41184, is murkier here than it was for that PR
|
||||
and needs its own design pass: the old `aggregate_function` was a single
|
||||
Pivot-Table-level setting applied uniformly to every metric on the chart,
|
||||
not a per-metric property. A chart that had `aggregate_function: Median`
|
||||
before #41184, with a metric of `SUM(sales)`, was already silently wrong
|
||||
under the old architecture (that is the bug that was fixed); mechanically
|
||||
rewriting its metric to `MEDIAN(sales)` on upgrade would change what the
|
||||
chart's leaf cells display, not just its totals, which may not match user
|
||||
intent. This SIP proposes a best-effort, flagged-for-review migration
|
||||
(surface affected charts to admins rather than silently rewriting them)
|
||||
rather than a fully automatic one-to-one restoration.
|
||||
|
||||
## Rejected alternatives
|
||||
|
||||
- **Restoring the old `aggregateFunction` Pivot-Table control as-is.**
|
||||
Rejected: this is the literal mechanism SIP-216 removed because it
|
||||
reintroduces incorrect totals for non-additive metrics. Any fix has to go
|
||||
through the metric's own aggregate, not a separate pivot-level override.
|
||||
- **Routing all metric SQL generation through `sqlglot` expression-building
|
||||
instead of SQLAlchemy's `sa.func`.** More architecturally thorough (would
|
||||
give correct dialect syntax for free across more of the roughly 75 engine
|
||||
specs), but a much larger, more invasive change to a hot path used by
|
||||
every chart query. Noted as a possible future direction, not this SIP's
|
||||
scope; this SIP proposes the smaller, `supports_grouping_sets`-shaped
|
||||
extensibility point instead.
|
||||
|
||||
## Open questions
|
||||
|
||||
- **Resolved for this PR, worth confirming as the community's preferred
|
||||
shape:** implemented as `BaseEngineSpec._extended_aggregations` (a
|
||||
`{aggregate_name: sqla_column -> sqla_column}` dict) plus a
|
||||
`get_extended_aggregation_func` accessor, set on the concrete or shared
|
||||
base engine spec class per engine (e.g. on `PostgresBaseEngineSpec` so
|
||||
Redshift inherits it, but *not* on `PrestoBaseEngineSpec` so Hive/Spark/
|
||||
Databricks don't silently inherit unverified behavior, mirroring how
|
||||
`supports_grouping_sets` is opted into per-concrete-engine there today).
|
||||
Did not route through the `superset/sql/dialects/` sqlglot-based layer;
|
||||
that layer is for SQL Lab parsing, wiring it into chart-metric query
|
||||
building felt like a separate, larger change from this SIP's scope.
|
||||
- **Still open, not addressed in this PR:** how aggressively should
|
||||
`MEDIAN` degrade on engines without a native or exact equivalent?
|
||||
Trino/Presto/Hive were left unimplemented (unsupported) specifically to
|
||||
avoid silently answering this with an approximate function
|
||||
(`approx_percentile`) that changes the semantics of what a user asked
|
||||
for. If someone wants `MEDIAN` on those engines, this needs a real
|
||||
decision: require explicit opt-in, show a UI warning, or keep it
|
||||
disallowed.
|
||||
- **Resolved for this PR:** left the two `sqla_aggregations` dicts
|
||||
(`connectors/sqla/models.py` and `models/helpers.py`) unconsolidated,
|
||||
both now separately wired to the same new `get_extended_aggregation_func`
|
||||
hook. Consolidating them into one source of truth is real but unrelated
|
||||
cleanup, not bundled here to keep the diff reviewable.
|
||||
- **New, from implementation:** only Postgres, MySQL (partial), DuckDB, and
|
||||
Redshift (by inheritance, unverified) ship enabled. BigQuery, Snowflake,
|
||||
Trino, Presto, Hive, Spark, Databricks, Oracle, and T-SQL all have
|
||||
documented (not live-verified) support per the table above but are not
|
||||
yet wired up, each needs the same live-instance verification treatment
|
||||
before being enabled, this PR intentionally didn't guess.
|
||||
@@ -67,7 +67,7 @@ const communityLinks = [
|
||||
'Join our monthly virtual meetups and register for any upcoming events on Meetup',
|
||||
},
|
||||
{
|
||||
url: 'https://github.com/apache/superset/blob/master/RESOURCES/INTHEWILD.md',
|
||||
url: 'https://superset.apache.org/inTheWild/',
|
||||
title: 'Organizations',
|
||||
description:
|
||||
'A list of some of the organizations using Superset in production.',
|
||||
|
||||
Vendored
+44
-32
@@ -3407,22 +3407,26 @@
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"editors": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/DashboardRestApi.get_list.Subject"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"is_managed_externally": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"owners": {
|
||||
"$ref": "#/components/schemas/DashboardRestApi.get_list.User2"
|
||||
},
|
||||
"published": {
|
||||
"nullable": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"roles": {
|
||||
"$ref": "#/components/schemas/DashboardRestApi.get_list.Role"
|
||||
},
|
||||
"slug": {
|
||||
"maxLength": 255,
|
||||
"nullable": true,
|
||||
@@ -3432,10 +3436,10 @@
|
||||
"readOnly": true
|
||||
},
|
||||
"tags": {
|
||||
"$ref": "#/components/schemas/DashboardRestApi.get_list.Tag"
|
||||
},
|
||||
"thumbnail_url": {
|
||||
"readOnly": true
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/DashboardRestApi.get_list.Tag"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"url": {
|
||||
"readOnly": true
|
||||
@@ -3444,21 +3448,46 @@
|
||||
"format": "uuid",
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"viewers": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/DashboardRestApi.get_list.Subject1"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"DashboardRestApi.get_list.Role": {
|
||||
"DashboardRestApi.get_list.Subject": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"name": {
|
||||
"maxLength": 64,
|
||||
"label": {
|
||||
"maxLength": 255,
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": ["name"],
|
||||
"required": ["label", "type"],
|
||||
"type": "object"
|
||||
},
|
||||
"DashboardRestApi.get_list.Subject1": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"label": {
|
||||
"maxLength": 255,
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": ["label", "type"],
|
||||
"type": "object"
|
||||
},
|
||||
"DashboardRestApi.get_list.Tag": {
|
||||
@@ -3511,23 +3540,6 @@
|
||||
"required": ["first_name", "last_name"],
|
||||
"type": "object"
|
||||
},
|
||||
"DashboardRestApi.get_list.User2": {
|
||||
"properties": {
|
||||
"first_name": {
|
||||
"maxLength": 64,
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"last_name": {
|
||||
"maxLength": 64,
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["first_name", "last_name"],
|
||||
"type": "object"
|
||||
},
|
||||
"DashboardRestApi.post": {
|
||||
"properties": {
|
||||
"certification_details": {
|
||||
@@ -16506,7 +16518,7 @@
|
||||
},
|
||||
"result": {
|
||||
"items": {
|
||||
"type": "object"
|
||||
"$ref": "#/components/schemas/DashboardRestApi.get_list"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
|
||||
+376
-358
File diff suppressed because it is too large
Load Diff
+14
-14
@@ -42,7 +42,7 @@ dependencies = [
|
||||
# ``google-auth`` 2.53+ dropped it, so Superset must declare it
|
||||
# explicitly to keep fresh ``pip install apache-superset`` working
|
||||
# without the ``base.txt`` lock file (#40962).
|
||||
"cachetools>=7.1.6, <8",
|
||||
"cachetools>=7.1.7, <8",
|
||||
"celery>=5.6.3, <6.0.0",
|
||||
"click>=8.4.2",
|
||||
"click-option-group",
|
||||
@@ -75,12 +75,12 @@ dependencies = [
|
||||
"humanize",
|
||||
"isodate",
|
||||
"jsonpath-ng>=1.8.0, <2",
|
||||
"Mako>=1.2.2",
|
||||
"Mako>=1.4.1",
|
||||
"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
|
||||
"marshmallow>=3.0, <5",
|
||||
"marshmallow>=4.3.1, <5",
|
||||
"marshmallow-union>=0.1.15.post1",
|
||||
"msgpack>=1.2.0, <1.3",
|
||||
"nh3>=0.3.5, <0.4",
|
||||
@@ -101,7 +101,7 @@ dependencies = [
|
||||
"python-dateutil",
|
||||
"python-dotenv", # optional dependencies for Flask but required for Superset, see https://flask.palletsprojects.com/en/stable/installation/#optional-dependencies
|
||||
"pygeohash",
|
||||
"pyarrow>=24.0.0, <26", # before upgrading pyarrow, check that all db dependencies support this, see e.g. https://github.com/apache/superset/pull/34693
|
||||
"pyarrow>=25.0.1, <26", # before upgrading pyarrow, check that all db dependencies support this, see e.g. https://github.com/apache/superset/pull/34693
|
||||
"pyyaml>=6.0.3, <7.0.0",
|
||||
"PyJWT>=2.4.0, <3.0",
|
||||
"redis>=5.0.0, <9.0",
|
||||
@@ -111,10 +111,10 @@ dependencies = [
|
||||
"sshtunnel>=0.4.0, <0.5",
|
||||
"simplejson>=4.1.1",
|
||||
"slack_sdk>=3.43.0, <4",
|
||||
"sqlalchemy>=2.0.0, <2.1",
|
||||
"sqlalchemy>=2.0.52, <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.16.0, <31", # 30.16.0 adds Trino inline UDF IF/CASE routine statement parsing
|
||||
"sqlglot>=30.17.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",
|
||||
@@ -135,13 +135,13 @@ athena = ["pyathena[pandas]>=3.35.4, <4"]
|
||||
# superset/db_engine_specs/aurora.py's known_incompatibilities metadata.
|
||||
aurora-data-api = ["preset-sqlalchemy-aurora-data-api>=0.2.8,<0.3"]
|
||||
bigquery = [
|
||||
"pandas-gbq>=0.35.0",
|
||||
"pandas-gbq>=0.35.1",
|
||||
# 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",
|
||||
"sqlalchemy-bigquery>=1.17.2",
|
||||
"google-cloud-bigquery>=3.42.3",
|
||||
]
|
||||
clickhouse = ["clickhouse-connect>=1.6.0, <2.0"]
|
||||
clickhouse = ["clickhouse-connect>=1.7.1, <2.0"]
|
||||
cockroachdb = ["cockroachdb>=0.3.5, <0.4"]
|
||||
crate = ["sqlalchemy-cratedb>=0.43.1, <1"]
|
||||
# sqlalchemy-d1's only release (0.1.0, Nov 2025) pins sqlalchemy<2,>=1.4,
|
||||
@@ -185,7 +185,7 @@ excel = ["xlrd>=2.0.2, <2.1"]
|
||||
# installing this extra is only required to actually run exports.
|
||||
excel-export = ["boto3"]
|
||||
fastmcp = [
|
||||
"fastmcp>=3.4.5,<4.0",
|
||||
"fastmcp>=3.4.6,<4.0",
|
||||
# tiktoken backs the response-size-guard token estimator. Without
|
||||
# it, the middleware falls back to a coarser character-based
|
||||
# heuristic that under-counts JSON-heavy MCP responses.
|
||||
@@ -197,7 +197,7 @@ fastmcp = [
|
||||
# landed (discussion #40273).
|
||||
firebird = ["sqlalchemy-firebird>=2.2.0"]
|
||||
firebolt = ["firebolt-sqlalchemy>=1.1.2, <2"]
|
||||
gevent = ["gevent>=26.7.0"]
|
||||
gevent = ["gevent>=26.8.0"]
|
||||
gsheets = ["shillelagh[gsheetsapi]>=1.4.5, <2"]
|
||||
hana = ["hdbcli==2.29.25", "sqlalchemy_hana==3.0.3"]
|
||||
hive = [
|
||||
@@ -221,7 +221,7 @@ ocient = [
|
||||
# unpinned sqlalchemy>=1.4 declared, but SQLAlchemy 2.0 support is
|
||||
# unverified. Lower confidence than the other bumps in this PR.
|
||||
"sqlalchemy-ocient>=3.0.0, <4",
|
||||
"pyocient>=1.0.15, <4",
|
||||
"pyocient>=3.9.0, <4",
|
||||
"shapely",
|
||||
"geojson",
|
||||
]
|
||||
@@ -232,7 +232,7 @@ 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"]
|
||||
prophet = ["prophet>=1.4.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. Bumped now that Superset's own
|
||||
# SQLAlchemy 2.0 core bump has landed (discussion #40273).
|
||||
@@ -255,7 +255,7 @@ tdengine = [
|
||||
"taospy>=2.8.10",
|
||||
"taos-ws-py>=0.7.0"
|
||||
]
|
||||
teradata = ["teradatasql>=20.0.0.64"]
|
||||
teradata = ["teradatasql>=20.0.0.65"]
|
||||
thumbnails = [] # deprecated, will be removed in 7.0
|
||||
vertica = ["sqlalchemy-vertica-python>= 0.6.3, < 0.7"]
|
||||
netezza = ["nzalchemy>= 11.1.2, < 11.2"]
|
||||
|
||||
@@ -28,9 +28,9 @@ numexpr>=2.9.0
|
||||
# Security: CVE-2026-34073 (MEDIUM) - Improper Certificate Validation
|
||||
cryptography>=50.0.0,<51.0.0
|
||||
# Security: Snyk - XSS vulnerability in Mako templates
|
||||
mako>=1.3.11,<2.0.0
|
||||
mako>=1.4.1,<2.0.0
|
||||
# Security: CVE-2024-52338 (CRITICAL) - Deserialization of untrusted data in IPC/Parquet readers
|
||||
pyarrow>=24.0.0,<26.0.0
|
||||
pyarrow>=25.0.1,<26.0.0
|
||||
# Security: CVE-2026-27459 - pyopenssl certificate validation
|
||||
pyopenssl>=26.0.0,<27.0.0
|
||||
# Security: CVE-2026-25645 (MEDIUM) - Insecure Temporary File
|
||||
|
||||
@@ -44,7 +44,7 @@ cachelib==0.13.0
|
||||
# via
|
||||
# flask-caching
|
||||
# flask-session
|
||||
cachetools==7.1.6
|
||||
cachetools==7.1.7
|
||||
# via apache-superset (pyproject.toml)
|
||||
cattrs==25.1.1
|
||||
# via requests-cache
|
||||
@@ -207,7 +207,7 @@ kombu==5.6.2
|
||||
# via celery
|
||||
limits==5.1.0
|
||||
# via flask-limiter
|
||||
mako==1.3.12
|
||||
mako==1.4.1
|
||||
# via
|
||||
# -r requirements/base.in
|
||||
# apache-superset (pyproject.toml)
|
||||
@@ -222,7 +222,7 @@ markupsafe==3.0.2
|
||||
# mako
|
||||
# werkzeug
|
||||
# wtforms
|
||||
marshmallow==4.3.0
|
||||
marshmallow==4.3.1
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# flask-appbuilder
|
||||
@@ -287,7 +287,7 @@ prison==0.2.1
|
||||
# via flask-appbuilder
|
||||
prompt-toolkit==3.0.51
|
||||
# via click-repl
|
||||
pyarrow==25.0.0
|
||||
pyarrow==25.0.1
|
||||
# via
|
||||
# -r requirements/base.in
|
||||
# apache-superset (pyproject.toml)
|
||||
@@ -381,7 +381,7 @@ six==1.17.0
|
||||
# wtforms-json
|
||||
slack-sdk==3.43.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
sqlalchemy==2.0.51
|
||||
sqlalchemy==2.0.52
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# alembic
|
||||
@@ -399,7 +399,7 @@ sqlalchemy-utils==0.42.1
|
||||
# apache-superset (pyproject.toml)
|
||||
# apache-superset-core
|
||||
# flask-appbuilder
|
||||
sqlglot==30.16.0
|
||||
sqlglot==30.17.0
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# apache-superset-core
|
||||
|
||||
@@ -99,7 +99,7 @@ cachelib==0.13.0
|
||||
# -c requirements/base-constraint.txt
|
||||
# flask-caching
|
||||
# flask-session
|
||||
cachetools==7.1.6
|
||||
cachetools==7.1.7
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -239,9 +239,9 @@ et-xmlfile==2.0.0
|
||||
# openpyxl
|
||||
exceptiongroup==1.3.0
|
||||
# via fastmcp-slim
|
||||
fastmcp==3.4.5
|
||||
fastmcp==3.4.7
|
||||
# via apache-superset
|
||||
fastmcp-slim==3.4.5
|
||||
fastmcp-slim==3.4.7
|
||||
# via fastmcp
|
||||
filelock==3.20.3
|
||||
# via
|
||||
@@ -337,7 +337,7 @@ geopy==2.4.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
gevent==26.7.0
|
||||
gevent==26.8.0
|
||||
# via apache-superset
|
||||
google-api-core==2.33.0
|
||||
# via
|
||||
@@ -434,8 +434,6 @@ importlib-metadata==8.7.0
|
||||
# via
|
||||
# keyring
|
||||
# opentelemetry-api
|
||||
importlib-resources==6.5.2
|
||||
# via prophet
|
||||
iniconfig==2.0.0
|
||||
# via pytest
|
||||
isodate==0.7.2
|
||||
@@ -510,7 +508,7 @@ limits==5.1.0
|
||||
# flask-limiter
|
||||
lz4==4.4.5
|
||||
# via trino
|
||||
mako==1.3.12
|
||||
mako==1.4.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# alembic
|
||||
@@ -530,7 +528,7 @@ markupsafe==3.0.2
|
||||
# mako
|
||||
# werkzeug
|
||||
# wtforms
|
||||
marshmallow==4.3.0
|
||||
marshmallow==4.3.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -640,7 +638,7 @@ pandas==2.3.3
|
||||
# db-dtypes
|
||||
# pandas-gbq
|
||||
# prophet
|
||||
pandas-gbq==0.35.0
|
||||
pandas-gbq==0.35.1
|
||||
# via apache-superset
|
||||
parameterized==0.9.0
|
||||
# via apache-superset
|
||||
@@ -693,7 +691,7 @@ prompt-toolkit==3.0.51
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# click-repl
|
||||
prophet==1.3.0
|
||||
prophet==1.4.0
|
||||
# via apache-superset
|
||||
proto-plus==1.25.0
|
||||
# via google-api-core
|
||||
@@ -711,7 +709,7 @@ psycopg2-binary==2.9.12
|
||||
# via apache-superset
|
||||
py-key-value-aio==0.4.4
|
||||
# via fastmcp-slim
|
||||
pyarrow==25.0.0
|
||||
pyarrow==25.0.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -950,7 +948,7 @@ slack-sdk==3.43.0
|
||||
# apache-superset
|
||||
sniffio==1.3.1
|
||||
# via anyio
|
||||
sqlalchemy==2.0.51
|
||||
sqlalchemy==2.0.52
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# alembic
|
||||
@@ -964,7 +962,7 @@ sqlalchemy==2.0.51
|
||||
# sqlalchemy-bigquery
|
||||
# sqlalchemy-continuum
|
||||
# sqlalchemy-utils
|
||||
sqlalchemy-bigquery==1.17.1
|
||||
sqlalchemy-bigquery==1.17.2
|
||||
# via apache-superset
|
||||
sqlalchemy-continuum==1.7.0
|
||||
# via
|
||||
@@ -976,7 +974,7 @@ sqlalchemy-utils==0.42.1
|
||||
# apache-superset
|
||||
# apache-superset-core
|
||||
# flask-appbuilder
|
||||
sqlglot==30.16.0
|
||||
sqlglot==30.17.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
|
||||
@@ -93,7 +93,7 @@ def find_models(module: ModuleType) -> list[type[Model]]: # noqa: C901
|
||||
# where the current model is out-of-sync with the existing table after a
|
||||
# downgrade
|
||||
sqlalchemy_uri = current_app.config["SQLALCHEMY_DATABASE_URI"]
|
||||
engine = create_engine(sqlalchemy_uri, future=True)
|
||||
engine = create_engine(sqlalchemy_uri)
|
||||
Base = automap_base() # noqa: N806
|
||||
Base.prepare(engine, reflect=True)
|
||||
seen = set()
|
||||
|
||||
@@ -42,6 +42,7 @@ RETRYABLE_STATUS_CODES: frozenset[int] = frozenset({429})
|
||||
PATTERNS = {
|
||||
"python": [
|
||||
r"^\.github/workflows/.*python",
|
||||
r"^\.github/workflows/frontend-bundle-size-nightly\.yml$",
|
||||
r"^\.github/workflows/scheduled-docker-image-refresh\.yml$",
|
||||
r"^docker-compose-image-tag\.yml$",
|
||||
r"^tests/",
|
||||
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
# Computes the `--extra-flags` value passed to `supersetbot docker` for a
|
||||
# given build preset. Factored out of .github/workflows/docker.yml so the
|
||||
# PY_VER override logic below can be exercised by an always-on CI check
|
||||
# (docker.yml's docker-build job only runs when the change detector's
|
||||
# docker/python/frontend outputs are true, and the PR build matrix never
|
||||
# includes py311/py312 at all, so a regression here would otherwise go
|
||||
# unnoticed until the fix actually runs on master) without duplicating -
|
||||
# and risking drift from - the logic used by the real build step.
|
||||
#
|
||||
# supersetbot's "py311"/"py312" presets pin their own --build-arg PY_VER,
|
||||
# which lands ahead of --extra-flags on the assembled buildx command line;
|
||||
# docker/buildx keeps the last value for a repeated --build-arg key, so
|
||||
# appending PY_VER here would override supersetbot's pin and silently make
|
||||
# "py311"/"py312" build the exact same image as "lean". Every other preset
|
||||
# gets the override so its build lands on the Dockerfile's own supported
|
||||
# Python version.
|
||||
#
|
||||
# Usage: docker-build-extra-flags.sh <build_preset> <image_tag>
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BUILD_PRESET="${1:?usage: docker-build-extra-flags.sh <build_preset> <image_tag>}"
|
||||
IMAGE_TAG="${2:?usage: docker-build-extra-flags.sh <build_preset> <image_tag>}"
|
||||
|
||||
EXTRA_FLAGS="--build-arg INCLUDE_CHROMIUM=false --tag $IMAGE_TAG"
|
||||
if [ "$BUILD_PRESET" != "py311" ] && [ "$BUILD_PRESET" != "py312" ]; then
|
||||
EXTRA_FLAGS="--build-arg PY_VER=3.11.14-slim-trixie $EXTRA_FLAGS"
|
||||
fi
|
||||
|
||||
echo "$EXTRA_FLAGS"
|
||||
@@ -18,8 +18,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from typing import Any
|
||||
|
||||
import isodate
|
||||
import pyarrow as pa
|
||||
@@ -90,6 +91,8 @@ class Dimension:
|
||||
definition: str | None = None
|
||||
description: str | None = None
|
||||
grain: Grain | None = None
|
||||
verbose_name: str | None = field(default=None, compare=False)
|
||||
metadata: dict[str, Any] = field(default_factory=dict, compare=False)
|
||||
|
||||
|
||||
class AggregationType(str, enum.Enum):
|
||||
@@ -121,6 +124,9 @@ class Metric:
|
||||
definition: str
|
||||
description: str | None = None
|
||||
aggregation: AggregationType | None = None
|
||||
verbose_name: str | None = field(default=None, compare=False)
|
||||
d3format: str | None = field(default=None, compare=False)
|
||||
metadata: dict[str, Any] = field(default_factory=dict, compare=False)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import pyarrow as pa
|
||||
from superset_core.semantic_layers.types import Dimension, Metric
|
||||
|
||||
|
||||
def test_dimension_metadata_is_not_part_of_identity() -> None:
|
||||
first = Dimension(
|
||||
"sales.region",
|
||||
"region",
|
||||
pa.utf8(),
|
||||
verbose_name="Region",
|
||||
metadata={"display_name": "Region"},
|
||||
)
|
||||
second = Dimension(
|
||||
"sales.region",
|
||||
"region",
|
||||
pa.utf8(),
|
||||
verbose_name="Sales region",
|
||||
metadata={"display_name": "Sales region"},
|
||||
)
|
||||
|
||||
assert first == second
|
||||
assert {first, second} == {first}
|
||||
|
||||
|
||||
def test_metric_metadata_is_not_part_of_identity() -> None:
|
||||
first = Metric(
|
||||
"sales.total_revenue",
|
||||
"total_revenue",
|
||||
pa.float64(),
|
||||
"SUM(revenue)",
|
||||
verbose_name="Total revenue",
|
||||
d3format="$,.2f",
|
||||
metadata={"unit": {"kind": "currency", "code": "USD"}},
|
||||
)
|
||||
second = Metric(
|
||||
"sales.total_revenue",
|
||||
"total_revenue",
|
||||
pa.float64(),
|
||||
"SUM(revenue)",
|
||||
verbose_name="Revenue",
|
||||
d3format=",.0f",
|
||||
metadata={"unit": {"kind": "currency", "code": "EUR"}},
|
||||
)
|
||||
|
||||
assert first == second
|
||||
assert {first, second} == {first}
|
||||
|
||||
|
||||
def test_metric_accepts_superset_presentation_fields() -> None:
|
||||
metric = Metric(
|
||||
"sales.total_revenue",
|
||||
"total_revenue",
|
||||
pa.float64(),
|
||||
"SUM(revenue)",
|
||||
verbose_name="Total revenue",
|
||||
d3format="$,.2f",
|
||||
)
|
||||
|
||||
assert metric.verbose_name == "Total revenue"
|
||||
assert metric.d3format == "$,.2f"
|
||||
|
||||
|
||||
def test_dimension_accepts_superset_presentation_fields() -> None:
|
||||
dimension = Dimension(
|
||||
"sales.region",
|
||||
"region",
|
||||
pa.utf8(),
|
||||
verbose_name="Region",
|
||||
)
|
||||
|
||||
assert dimension.verbose_name == "Region"
|
||||
|
||||
|
||||
def test_metadata_defaults_are_not_shared() -> None:
|
||||
first = Metric("first", "first", pa.int64(), "COUNT(*)")
|
||||
second = Metric("second", "second", pa.int64(), "COUNT(*)")
|
||||
|
||||
first.metadata["display_name"] = "First"
|
||||
|
||||
assert second.metadata == {}
|
||||
Generated
+506
@@ -19,6 +19,7 @@
|
||||
"@babel/preset-typescript": "7.29.7",
|
||||
"@types/node": "^25.4.0",
|
||||
"babel-loader": "^9.1.3",
|
||||
"jsdom": "^26.1.0",
|
||||
"tscw-config": "^1.1.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.18",
|
||||
@@ -26,6 +27,27 @@
|
||||
"webpack-cli": "^5.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
|
||||
"integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/css-calc": "^2.1.3",
|
||||
"@csstools/css-color-parser": "^3.0.9",
|
||||
"@csstools/css-parser-algorithms": "^3.0.4",
|
||||
"@csstools/css-tokenizer": "^3.0.3",
|
||||
"lru-cache": "^10.4.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
|
||||
"version": "10.4.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@babel/cli": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.29.7.tgz",
|
||||
@@ -1656,6 +1678,121 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
|
||||
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-calc": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
|
||||
"integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^3.0.5",
|
||||
"@csstools/css-tokenizer": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-color-parser": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
|
||||
"integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/color-helpers": "^5.1.0",
|
||||
"@csstools/css-calc": "^2.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^3.0.5",
|
||||
"@csstools/css-tokenizer": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-parser-algorithms": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
|
||||
"integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-tokenizer": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-tokenizer": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
|
||||
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@discoveryjs/json-ext": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.6.tgz",
|
||||
@@ -2501,6 +2638,16 @@
|
||||
"acorn": "^8.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
@@ -2868,6 +3015,34 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/cssstyle": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
|
||||
"integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/css-color": "^3.2.0",
|
||||
"rrweb-cssom": "^0.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
|
||||
"integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-mimetype": "^4.0.0",
|
||||
"whatwg-url": "^14.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -2886,6 +3061,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -2915,6 +3097,19 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/envinfo": {
|
||||
"version": "7.13.0",
|
||||
"resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.13.0.tgz",
|
||||
@@ -3316,6 +3511,60 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
|
||||
"integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-encoding": "^3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-agent": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/import-local": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz",
|
||||
@@ -3438,6 +3687,13 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-potential-custom-element-name": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
@@ -3498,6 +3754,46 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "26.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
|
||||
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cssstyle": "^4.2.1",
|
||||
"data-urls": "^5.0.0",
|
||||
"decimal.js": "^10.5.0",
|
||||
"html-encoding-sniffer": "^4.0.0",
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"nwsapi": "^2.2.16",
|
||||
"parse5": "^7.2.1",
|
||||
"rrweb-cssom": "^0.8.0",
|
||||
"saxes": "^6.0.0",
|
||||
"symbol-tree": "^3.2.4",
|
||||
"tough-cookie": "^5.1.1",
|
||||
"w3c-xmlserializer": "^5.0.0",
|
||||
"webidl-conversions": "^7.0.0",
|
||||
"whatwg-encoding": "^3.1.1",
|
||||
"whatwg-mimetype": "^4.0.0",
|
||||
"whatwg-url": "^14.1.1",
|
||||
"ws": "^8.18.0",
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"canvas": "^3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"canvas": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/jsesc": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||
@@ -3977,6 +4273,13 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nwsapi": {
|
||||
"version": "2.2.24",
|
||||
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz",
|
||||
"integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||
@@ -4033,6 +4336,19 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
@@ -4142,6 +4458,16 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
@@ -4314,6 +4640,33 @@
|
||||
"@rolldown/binding-win32-x64-msvc": "1.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/rrweb-cssom": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
|
||||
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"xmlchars": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=v12.22.7"
|
||||
}
|
||||
},
|
||||
"node_modules/schema-utils": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz",
|
||||
@@ -4447,6 +4800,13 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
|
||||
@@ -4592,6 +4952,26 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "6.1.86",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
|
||||
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tldts-core": "^6.1.86"
|
||||
},
|
||||
"bin": {
|
||||
"tldts": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts-core": {
|
||||
"version": "6.1.86",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
|
||||
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
@@ -4605,6 +4985,32 @@
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
|
||||
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tldts": "^6.1.32"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
|
||||
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tscw-config": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/tscw-config/-/tscw-config-1.1.2.tgz",
|
||||
@@ -4931,6 +5337,19 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/watchpack": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz",
|
||||
@@ -4944,6 +5363,16 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
|
||||
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack": {
|
||||
"version": "5.105.0",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz",
|
||||
@@ -5070,6 +5499,44 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-encoding": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
|
||||
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
|
||||
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iconv-lite": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
|
||||
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "14.2.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
|
||||
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "^5.1.0",
|
||||
"webidl-conversions": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
@@ -5114,6 +5581,45 @@
|
||||
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
|
||||
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlchars": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"@babel/preset-typescript": "7.29.7",
|
||||
"@types/node": "^25.4.0",
|
||||
"babel-loader": "^9.1.3",
|
||||
"jsdom": "^26.1.0",
|
||||
"tscw-config": "^1.1.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.18",
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { Switchboard } from "@superset-ui/switchboard";
|
||||
import { embedDashboard } from "./index";
|
||||
|
||||
vi.mock("@superset-ui/switchboard");
|
||||
|
||||
function makeFakeJWT(claims: any) {
|
||||
// not a valid jwt, but close enough for this code
|
||||
const tokenifiedClaims = Buffer.from(JSON.stringify(claims)).toString(
|
||||
"base64",
|
||||
);
|
||||
return `abc.${tokenifiedClaims}.xyz`;
|
||||
}
|
||||
|
||||
describe("embedDashboard", () => {
|
||||
let mountPoint: HTMLElement;
|
||||
let mockSwitchboard: Switchboard;
|
||||
|
||||
beforeEach(() => {
|
||||
mountPoint = document.createElement("div");
|
||||
document.body.appendChild(mountPoint);
|
||||
|
||||
mockSwitchboard = {
|
||||
emit: vi.fn(),
|
||||
get: vi.fn(),
|
||||
start: vi.fn(),
|
||||
defineMethod: vi.fn(),
|
||||
} as any;
|
||||
|
||||
// Constructor mocks must use `function`, since arrow functions cannot be
|
||||
// invoked with `new`.
|
||||
vi.mocked(Switchboard).mockImplementation(function () {
|
||||
return mockSwitchboard;
|
||||
} as any);
|
||||
|
||||
// Mock MessageChannel API
|
||||
globalThis.MessageChannel = vi.fn(function (this: any) {
|
||||
this.port1 = {};
|
||||
this.port2 = {};
|
||||
}) as any;
|
||||
|
||||
// Mock iframe load event and sandbox
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
vi.spyOn(document, "createElement").mockImplementation(tagName => {
|
||||
const element = originalCreateElement(tagName);
|
||||
if (tagName === "iframe") {
|
||||
// Mock sandbox DOMTokenList
|
||||
(element as any).sandbox = {
|
||||
add: vi.fn(),
|
||||
};
|
||||
// Mock contentWindow for postMessage
|
||||
Object.defineProperty(element, "contentWindow", {
|
||||
writable: true,
|
||||
value: {
|
||||
postMessage: vi.fn(),
|
||||
},
|
||||
});
|
||||
setTimeout(() => {
|
||||
element.dispatchEvent(new Event("load"));
|
||||
}, 0);
|
||||
}
|
||||
return element;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.removeChild(mountPoint);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("setDataMask sends dataMask to iframe", async () => {
|
||||
const fakeToken = makeFakeJWT({ exp: Date.now() / 1000 + 300 });
|
||||
const mockFetchGuestToken = vi.fn().mockResolvedValue(fakeToken);
|
||||
const testDataMask = {
|
||||
"NATIVE_FILTER-1": {
|
||||
filterState: {
|
||||
value: ["value1", "value2"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const dashboard = await embedDashboard({
|
||||
id: "test-id",
|
||||
supersetDomain: "https://superset.example.com",
|
||||
mountPoint,
|
||||
fetchGuestToken: mockFetchGuestToken,
|
||||
});
|
||||
|
||||
dashboard.setDataMask(testDataMask);
|
||||
|
||||
expect(mockSwitchboard.get).toHaveBeenCalledWith("setDataMask", {
|
||||
dataMask: testDataMask,
|
||||
});
|
||||
});
|
||||
|
||||
test("setDataMask sends empty dataMask", async () => {
|
||||
const fakeToken = makeFakeJWT({ exp: Date.now() / 1000 + 300 });
|
||||
const mockFetchGuestToken = vi.fn().mockResolvedValue(fakeToken);
|
||||
const emptyDataMask = {};
|
||||
|
||||
const dashboard = await embedDashboard({
|
||||
id: "test-id",
|
||||
supersetDomain: "https://superset.example.com",
|
||||
mountPoint,
|
||||
fetchGuestToken: mockFetchGuestToken,
|
||||
});
|
||||
|
||||
dashboard.setDataMask(emptyDataMask);
|
||||
|
||||
expect(mockSwitchboard.get).toHaveBeenCalledWith("setDataMask", {
|
||||
dataMask: emptyDataMask,
|
||||
});
|
||||
});
|
||||
|
||||
test("setDataMask drops the change-trigger flags observeDataMask adds", async () => {
|
||||
const fakeToken = makeFakeJWT({ exp: Date.now() / 1000 + 300 });
|
||||
const mockFetchGuestToken = vi.fn().mockResolvedValue(fakeToken);
|
||||
const observedMask = {
|
||||
"NATIVE_FILTER-1": {
|
||||
filterState: {
|
||||
value: ["CA"],
|
||||
},
|
||||
},
|
||||
crossFiltersChanged: false,
|
||||
nativeFiltersChanged: true,
|
||||
};
|
||||
|
||||
const dashboard = await embedDashboard({
|
||||
id: "test-id",
|
||||
supersetDomain: "https://superset.example.com",
|
||||
mountPoint,
|
||||
fetchGuestToken: mockFetchGuestToken,
|
||||
});
|
||||
|
||||
dashboard.setDataMask(observedMask);
|
||||
|
||||
expect(mockSwitchboard.get).toHaveBeenCalledWith("setDataMask", {
|
||||
dataMask: {
|
||||
"NATIVE_FILTER-1": observedMask["NATIVE_FILTER-1"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("setDataMask sends complex dataMask with multiple filters", async () => {
|
||||
const fakeToken = makeFakeJWT({ exp: Date.now() / 1000 + 300 });
|
||||
const mockFetchGuestToken = vi.fn().mockResolvedValue(fakeToken);
|
||||
const complexDataMask = {
|
||||
"NATIVE_FILTER-1": {
|
||||
filterState: {
|
||||
value: ["CA", "NY"],
|
||||
},
|
||||
},
|
||||
"NATIVE_FILTER-2": {
|
||||
filterState: {
|
||||
value: [2023, 2024],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const dashboard = await embedDashboard({
|
||||
id: "test-id",
|
||||
supersetDomain: "https://superset.example.com",
|
||||
mountPoint,
|
||||
fetchGuestToken: mockFetchGuestToken,
|
||||
});
|
||||
|
||||
dashboard.setDataMask(complexDataMask);
|
||||
|
||||
expect(mockSwitchboard.get).toHaveBeenCalledWith("setDataMask", {
|
||||
dataMask: complexDataMask,
|
||||
});
|
||||
});
|
||||
|
||||
test("setDataMask rejects when the embedded page does not support it", async () => {
|
||||
const fakeToken = makeFakeJWT({ exp: Date.now() / 1000 + 300 });
|
||||
const mockFetchGuestToken = vi.fn().mockResolvedValue(fakeToken);
|
||||
vi.mocked(mockSwitchboard.get).mockRejectedValue(
|
||||
new Error('Method "setDataMask" is not defined'),
|
||||
);
|
||||
|
||||
const dashboard = await embedDashboard({
|
||||
id: "test-id",
|
||||
supersetDomain: "https://superset.example.com",
|
||||
mountPoint,
|
||||
fetchGuestToken: mockFetchGuestToken,
|
||||
});
|
||||
|
||||
await expect(dashboard.setDataMask({})).rejects.toThrow(
|
||||
'Method "setDataMask" is not defined',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -115,6 +115,12 @@ export type EmbeddedDashboard = {
|
||||
getActiveTabs: () => Promise<string[]>;
|
||||
observeDataMask: (callbackFn: ObserveDataMaskCallbackFn) => void;
|
||||
getDataMask: () => Promise<Record<string, any>>;
|
||||
/**
|
||||
* Applies a data mask to the dashboard.
|
||||
* Rejects if the embedded Superset page does not support `setDataMask`,
|
||||
* so a version mismatch surfaces instead of silently doing nothing.
|
||||
*/
|
||||
setDataMask: (dataMask: Record<string, any>) => Promise<void>;
|
||||
getChartStates: () => Promise<Record<string, any>>;
|
||||
getChartDataPayloads: (params?: {
|
||||
chartId?: number;
|
||||
@@ -355,6 +361,21 @@ export async function embedDashboard({
|
||||
ourPort.get<string>("getDashboardPermalink", { anchor });
|
||||
const getActiveTabs = () => ourPort.get<string[]>("getActiveTabs");
|
||||
const getDataMask = () => ourPort.get<Record<string, any>>("getDataMask");
|
||||
// `observeDataMask` hands the host a mask with the change-trigger booleans
|
||||
// mixed in, so feeding that payload straight back into `setDataMask` is a
|
||||
// natural thing for a host to do. Keep only the entries that look like a
|
||||
// filter's mask, so those flags never reach the dashboard as filter ids.
|
||||
// Sent with `get` rather than `emit` so the iframe acknowledges the call:
|
||||
// an embedded page that predates `setDataMask` replies with an error instead
|
||||
// of dropping the message silently.
|
||||
const setDataMask = (dataMask: Record<string, any>) =>
|
||||
ourPort.get<void>("setDataMask", {
|
||||
dataMask: Object.fromEntries(
|
||||
Object.entries(dataMask).filter(
|
||||
([, mask]) => typeof mask === "object" && mask !== null,
|
||||
),
|
||||
),
|
||||
});
|
||||
const getChartStates = () =>
|
||||
ourPort.get<Record<string, any>>("getChartStates");
|
||||
const getChartDataPayloads = (params?: { chartId?: number }) =>
|
||||
@@ -396,6 +417,7 @@ export async function embedDashboard({
|
||||
getActiveTabs,
|
||||
observeDataMask,
|
||||
getDataMask,
|
||||
setDataMask,
|
||||
getChartStates,
|
||||
getChartDataPayloads,
|
||||
setThemeConfig,
|
||||
|
||||
Generated
+460
-743
File diff suppressed because it is too large
Load Diff
@@ -110,7 +110,7 @@
|
||||
"@fontsource/fira-code": "^5.3.0",
|
||||
"@fontsource/ibm-plex-mono": "^5.3.0",
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
"@googleapis/sheets": "^13.0.2",
|
||||
"@googleapis/sheets": "^14.0.0",
|
||||
"@great-expectations/jsonforms-antd-renderers": "^2.2.10",
|
||||
"@jsonforms/core": "^3.7.0",
|
||||
"@jsonforms/react": "^3.7.0",
|
||||
@@ -122,9 +122,9 @@
|
||||
"@luma.gl/shadertools": "~9.2.5",
|
||||
"@luma.gl/webgl": "~9.2.5",
|
||||
"@reduxjs/toolkit": "^1.9.3",
|
||||
"@rjsf/core": "^6.7.1",
|
||||
"@rjsf/core": "^6.8.0",
|
||||
"@rjsf/utils": "^6.6.2",
|
||||
"@rjsf/validator-ajv8": "^6.7.1",
|
||||
"@rjsf/validator-ajv8": "^6.8.0",
|
||||
"@scarf/scarf": "^1.4.0",
|
||||
"@superset-ui/chart-controls": "file:./packages/superset-ui-chart-controls",
|
||||
"@superset-ui/core": "file:./packages/superset-ui-core",
|
||||
@@ -158,12 +158,12 @@
|
||||
"@visx/xychart": "^4.0.0",
|
||||
"ag-grid-community": "36.1.0",
|
||||
"ag-grid-react": "36.1.0",
|
||||
"antd": "^6.5.4",
|
||||
"antd": "^6.6.0",
|
||||
"chrono-node": "^2.10.1",
|
||||
"classnames": "^2.2.5",
|
||||
"content-disposition": "^2.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"dayjs": "^1.11.21",
|
||||
"dayjs": "^1.11.22",
|
||||
"dom-to-image-more": "^3.10.2",
|
||||
"dom-to-pdf": "^0.3.2",
|
||||
"echarts": "^6.1.0",
|
||||
@@ -176,8 +176,8 @@
|
||||
"geostyler-openlayers-parser": "^5.7.1",
|
||||
"geostyler-style": "11.0.2",
|
||||
"geostyler-wfs-parser": "^3.0.1",
|
||||
"google-auth-library": "^11.0.0",
|
||||
"immer": "^11.1.16",
|
||||
"google-auth-library": "^11.0.2",
|
||||
"immer": "^11.1.17",
|
||||
"interweave": "^13.1.1",
|
||||
"jquery": "^4.0.0",
|
||||
"js-levenshtein": "^1.1.6",
|
||||
@@ -257,16 +257,16 @@
|
||||
"@istanbuljs/nyc-config-typescript": "^1.0.1",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
|
||||
"@storybook/addon-docs": "10.5.7",
|
||||
"@storybook/addon-links": "10.5.7",
|
||||
"@storybook/react-webpack5": "10.5.7",
|
||||
"@storybook/addon-docs": "10.5.8",
|
||||
"@storybook/addon-links": "10.5.8",
|
||||
"@storybook/react-webpack5": "10.5.8",
|
||||
"@storybook/test-runner": "0.24.4",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.47",
|
||||
"@swc/plugin-emotion": "^14.15.0",
|
||||
"@swc/plugin-emotion": "^14.19.0",
|
||||
"@swc/plugin-transform-imports": "^12.5.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^7.0.0",
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
"@testing-library/react": "^15.0.0",
|
||||
"@testing-library/user-event": "^12.8.3",
|
||||
"@types/content-disposition": "^0.5.9",
|
||||
@@ -277,7 +277,7 @@
|
||||
"@types/json-bigint": "^1.0.4",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/mousetrap": "^1.6.15",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-loadable": "^5.5.11",
|
||||
@@ -289,19 +289,19 @@
|
||||
"@types/rison": "0.1.0",
|
||||
"@types/tinycolor2": "^1.4.3",
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"@typescript-eslint/eslint-plugin": "^8.66.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.67.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.12",
|
||||
"baseline-browser-mapping": "^2.11.14",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^10.0.4",
|
||||
"concurrently": "^10.0.5",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"css-loader": "^7.1.4",
|
||||
"eslint": "^10.8.0",
|
||||
"eslint": "^10.8.1",
|
||||
"eslint-import-resolver-alias": "^1.1.2",
|
||||
"eslint-import-resolver-typescript": "^4.4.5",
|
||||
"eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
|
||||
@@ -312,7 +312,7 @@
|
||||
"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.7",
|
||||
"eslint-plugin-storybook": "10.5.8",
|
||||
"eslint-plugin-testing-library": "^7.16.2",
|
||||
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
|
||||
"fetch-mock": "^12.6.0",
|
||||
@@ -331,8 +331,8 @@
|
||||
"mini-css-extract-plugin": "^2.10.2",
|
||||
"minimizer-webpack-plugin": "^5.6.1",
|
||||
"open-cli": "^9.0.0",
|
||||
"oxfmt": "^0.62.0",
|
||||
"oxlint": "^1.77.0",
|
||||
"oxfmt": "^0.63.0",
|
||||
"oxlint": "^1.78.0",
|
||||
"po2json": "^0.4.5",
|
||||
"postcss-styled-syntax": "^0.7.2",
|
||||
"process": "^0.11.10",
|
||||
@@ -343,13 +343,13 @@
|
||||
"source-map": "^0.8.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"speed-measure-webpack-plugin": "^1.6.0",
|
||||
"storybook": "10.5.7",
|
||||
"storybook": "10.5.8",
|
||||
"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.10",
|
||||
"tsx": "^4.23.12",
|
||||
"typescript": "5.4.5",
|
||||
"unzipper": "^0.12.5",
|
||||
"wait-on": "^9.1.0",
|
||||
@@ -415,6 +415,7 @@
|
||||
"minimatch@>=10": {
|
||||
"brace-expansion": ">=5.0.8"
|
||||
},
|
||||
"nanoid@>=3 <4": "3.3.18",
|
||||
"nwsapi": "^2.2.13",
|
||||
"puppeteer": "^22.4.1",
|
||||
"tar": "^7.5.16",
|
||||
|
||||
@@ -130,6 +130,7 @@ export enum GenericDataType {
|
||||
String = 1,
|
||||
Temporal = 2,
|
||||
Boolean = 3,
|
||||
MultiValue = 4,
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+5
@@ -28,6 +28,7 @@ import {
|
||||
FieldBinaryOutlined,
|
||||
FieldStringOutlined,
|
||||
NumberOutlined,
|
||||
UnorderedListOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Icons } from '@superset-ui/core/components';
|
||||
|
||||
@@ -72,6 +73,10 @@ export function ColumnTypeLabel({ type }: ColumnTypeLabelProps) {
|
||||
typeIcon = <FieldBinaryOutlined aria-label={t('boolean type icon')} />;
|
||||
} else if (type === GenericDataType.Temporal) {
|
||||
typeIcon = <ClockCircleOutlined aria-label={t('temporal type icon')} />;
|
||||
} else if (type === GenericDataType.MultiValue) {
|
||||
typeIcon = (
|
||||
<UnorderedListOutlined aria-label={t('multi-value type icon')} />
|
||||
);
|
||||
}
|
||||
|
||||
return <TypeIconWrapper>{typeIcon}</TypeIconWrapper>;
|
||||
|
||||
+15
-1
@@ -51,6 +51,20 @@ export interface MetricOptionProps {
|
||||
shouldShowTooltip?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* `url` is an arbitrary caller-supplied string rendered as an href. Only
|
||||
* http(s) and relative URLs become links; other schemes degrade to plain
|
||||
* text.
|
||||
*/
|
||||
function isSafeHref(url: string): boolean {
|
||||
try {
|
||||
const { protocol } = new URL(url, window.location.origin);
|
||||
return protocol === 'http:' || protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function MetricOption({
|
||||
metric,
|
||||
labelRef,
|
||||
@@ -70,7 +84,7 @@ export function MetricOption({
|
||||
`}
|
||||
ref={labelRef}
|
||||
>
|
||||
{url ? (
|
||||
{url && isSafeHref(url) ? (
|
||||
<Typography.Link
|
||||
href={url}
|
||||
target={openInNewWindow ? '_blank' : ''}
|
||||
|
||||
@@ -122,6 +122,12 @@ export const timeComparisonControls: ({
|
||||
}
|
||||
return newState;
|
||||
},
|
||||
// Re-run this control's validation whenever `time_compare` changes so
|
||||
// the "date required" error clears once a non-custom shift is picked.
|
||||
// Without it the stale error survives in Redux (see the
|
||||
// dependantControls path in exploreReducer's SET_FIELD_VALUE handler)
|
||||
// and blocks further chart updates until a page refresh.
|
||||
validationDependencies: ['time_compare'],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
+17
@@ -64,4 +64,21 @@ describe('ColumnOption', () => {
|
||||
renderColumnTypeLabel({ type: GenericDataType.Temporal });
|
||||
expect(screen.getByLabelText('temporal type icon')).toBeVisible();
|
||||
});
|
||||
test('multi-value (array) type shows list icon', () => {
|
||||
renderColumnTypeLabel({ type: GenericDataType.MultiValue });
|
||||
expect(screen.getByLabelText('multi-value type icon')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GenericDataType enum parity', () => {
|
||||
// These numeric values are shared with the backend enum in
|
||||
// superset/utils/core.py (GenericDataType). They must stay in sync because
|
||||
// the backend serializes columns using these integers.
|
||||
test('values match the backend contract', () => {
|
||||
expect(GenericDataType.Numeric).toBe(0);
|
||||
expect(GenericDataType.String).toBe(1);
|
||||
expect(GenericDataType.Temporal).toBe(2);
|
||||
expect(GenericDataType.Boolean).toBe(3);
|
||||
expect(GenericDataType.MultiValue).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
+23
@@ -112,3 +112,26 @@ test('shows a Tooltip for the verbose metric name', () => {
|
||||
const { getByTestId } = setup();
|
||||
expect(getByTestId('mock-tooltip')).toBeInTheDocument();
|
||||
});
|
||||
test('does not render javascript: URLs as links', () => {
|
||||
// Regression test: the url prop can be creator-authored and must
|
||||
// never become a script-bearing href for other viewers.
|
||||
const { queryByRole, getByText } = setup({
|
||||
url: 'javascript:alert(document.domain)', // eslint-disable-line no-script-url
|
||||
});
|
||||
expect(queryByRole('link')).not.toBeInTheDocument();
|
||||
expect(getByText(defaultProps.metric.verbose_name)).toBeInTheDocument();
|
||||
});
|
||||
test('does not render data: URLs as links', () => {
|
||||
const { queryByRole } = setup({
|
||||
url: 'data:text/html,<script>alert(1)</script>',
|
||||
});
|
||||
expect(queryByRole('link')).not.toBeInTheDocument();
|
||||
});
|
||||
test('renders relative URLs as links', () => {
|
||||
const { getByRole } = setup({
|
||||
url: '/superset/dashboard/1/',
|
||||
});
|
||||
expect(
|
||||
getByRole('link', { name: defaultProps.metric.verbose_name }),
|
||||
).toHaveAttribute('href', '/superset/dashboard/1/');
|
||||
});
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-time": "^3.1.0",
|
||||
"d3-time-format": "^4.1.0",
|
||||
"dayjs": "^1.11.21",
|
||||
"dayjs": "^1.11.22",
|
||||
"dompurify": "^3.4.13",
|
||||
"fetch-retry": "^6.0.0",
|
||||
"handlebars": "^4.7.9",
|
||||
@@ -79,7 +79,7 @@
|
||||
"re-resizable": "^6.11.2",
|
||||
"react-ace": "^14.0.1",
|
||||
"react-draggable": "^4.7.1",
|
||||
"react-error-boundary": "^6.1.2",
|
||||
"react-error-boundary": "^6.1.3",
|
||||
"react-js-cron": "^6.0.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resize-detector": "^7.1.2",
|
||||
@@ -103,7 +103,7 @@
|
||||
"@types/d3-time-format": "^4.0.3",
|
||||
"@types/jquery": "^4.0.1",
|
||||
"@types/lodash": "^4.17.25",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/prop-types": "^15.7.15",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/react-table": "^7.7.20",
|
||||
|
||||
+6
-4
@@ -107,12 +107,14 @@ const getAllSelectOptions = () =>
|
||||
|
||||
const findSelectOption = (text: string) =>
|
||||
waitFor(() =>
|
||||
within(getElementByClassName('.rc-virtual-list')).getByText(text),
|
||||
within(getElementByClassName('.ant-select-dropdown-list')).getByText(text),
|
||||
);
|
||||
|
||||
const querySelectOption = (text: string) =>
|
||||
waitFor(() =>
|
||||
within(getElementByClassName('.rc-virtual-list')).queryByText(text),
|
||||
within(getElementByClassName('.ant-select-dropdown-list')).queryByText(
|
||||
text,
|
||||
),
|
||||
);
|
||||
|
||||
const findAllSelectOptions = () =>
|
||||
@@ -644,7 +646,7 @@ test('does not add a new option if the option already exists', async () => {
|
||||
await type(option);
|
||||
await waitFor(() => {
|
||||
const array = within(
|
||||
getElementByClassName('.rc-virtual-list'),
|
||||
getElementByClassName('.ant-select-dropdown-list'),
|
||||
).getAllByText(option);
|
||||
expect(array.length).toBe(1);
|
||||
});
|
||||
@@ -1398,7 +1400,7 @@ test('appends page>1 results during an active search and discards them when sear
|
||||
// scrollTop via e.currentTarget in its onFallbackScroll handler, which
|
||||
// then forwards to onPopupScroll (handlePagination here).
|
||||
const holder = document.querySelector(
|
||||
'.rc-virtual-list-holder',
|
||||
'.ant-select-dropdown-list-holder',
|
||||
) as HTMLElement | null;
|
||||
if (!holder) throw new Error('virtual-list holder not rendered');
|
||||
Object.defineProperty(holder, 'scrollHeight', {
|
||||
|
||||
@@ -93,12 +93,14 @@ const deselectAllButtonText = (length: number) =>
|
||||
|
||||
const findSelectOption = (text: string) =>
|
||||
waitFor(() =>
|
||||
within(getElementByClassName('.rc-virtual-list')).getByText(text),
|
||||
within(getElementByClassName('.ant-select-dropdown-list')).getByText(text),
|
||||
);
|
||||
|
||||
const querySelectOption = (text: string) =>
|
||||
waitFor(() =>
|
||||
within(getElementByClassName('.rc-virtual-list')).queryByText(text),
|
||||
within(getElementByClassName('.ant-select-dropdown-list')).queryByText(
|
||||
text,
|
||||
),
|
||||
);
|
||||
|
||||
const getAllSelectOptions = () =>
|
||||
|
||||
@@ -71,6 +71,9 @@ export type AntdExposedProps = Pick<
|
||||
| 'virtual'
|
||||
| 'getPopupContainer'
|
||||
| 'menuItemSelectedIcon'
|
||||
// lets a caller with long option labels stop the popup inheriting the
|
||||
// trigger's width, which otherwise truncates every option
|
||||
| 'popupMatchSelectWidth'
|
||||
>;
|
||||
|
||||
export type SelectOptionsType = Exclude<AntdProps['options'], undefined>;
|
||||
|
||||
@@ -25,8 +25,11 @@ export type Aggregate =
|
||||
| 'COUNT'
|
||||
| 'COUNT_DISTINCT'
|
||||
| 'MAX'
|
||||
| 'MEDIAN'
|
||||
| 'MIN'
|
||||
| 'SUM';
|
||||
| 'STDDEV_SAMP'
|
||||
| 'SUM'
|
||||
| 'VAR_SAMP';
|
||||
|
||||
export interface AdhocMetricBase {
|
||||
hasCustomLabel?: boolean;
|
||||
|
||||
@@ -49,5 +49,6 @@ export { default as finestTemporalGrainFormatter } from './formatters/finestTemp
|
||||
|
||||
export { default as normalizeTimestamp } from './utils/normalizeTimestamp';
|
||||
export { default as denormalizeTimestamp } from './utils/denormalizeTimestamp';
|
||||
export { default as createTimeRangeFromGranularity } from './utils/createTimeRangeFromGranularity';
|
||||
|
||||
export * from './types';
|
||||
|
||||
@@ -123,6 +123,25 @@ describe('isProbablyHTML', () => {
|
||||
expect(isProbablyHTML('<canvas></canvas>')).toBe(true);
|
||||
expect(isProbablyHTML('<iframe src="page.html"></iframe>')).toBe(true);
|
||||
});
|
||||
|
||||
test('should return true for script-capable and foreign-content tags', () => {
|
||||
expect(isProbablyHTML('<svg onload="alert(1)"></svg>')).toBe(true);
|
||||
expect(isProbablyHTML('<math><mi>x</mi></math>')).toBe(true);
|
||||
expect(
|
||||
isProbablyHTML('<details open ontoggle="alert(1)">x</details>'),
|
||||
).toBe(true);
|
||||
expect(isProbablyHTML('<summary>x</summary>')).toBe(true);
|
||||
expect(isProbablyHTML('<object data="x"></object>')).toBe(true);
|
||||
expect(isProbablyHTML('<embed src="x">')).toBe(true);
|
||||
expect(isProbablyHTML('<marquee>x</marquee>')).toBe(true);
|
||||
expect(isProbablyHTML('<template>x</template>')).toBe(true);
|
||||
expect(isProbablyHTML('<dialog open>x</dialog>')).toBe(true);
|
||||
});
|
||||
|
||||
test('should return true for elements that parse into document.head', () => {
|
||||
expect(isProbablyHTML('<style>body { display: none; }</style>')).toBe(true);
|
||||
expect(isProbablyHTML('<title>injected</title>')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeHtmlIfNeeded', () => {
|
||||
@@ -137,6 +156,24 @@ describe('sanitizeHtmlIfNeeded', () => {
|
||||
const sanitizedString = sanitizeHtmlIfNeeded(plainText);
|
||||
expect(sanitizedString).toEqual(plainText);
|
||||
});
|
||||
|
||||
test('should sanitize svg/details/style payloads instead of passing them through', () => {
|
||||
const svgPayload = '<svg onload="alert(document.cookie)"></svg>';
|
||||
const sanitizedSvg = sanitizeHtmlIfNeeded(svgPayload);
|
||||
expect(sanitizedSvg).not.toContain('<svg');
|
||||
expect(sanitizedSvg).not.toContain('onload');
|
||||
|
||||
// `details` (with its `open` attribute) is in js-xss's default
|
||||
// whitelist, so the tag itself survives sanitization; the fix is that
|
||||
// the payload is now routed through FilterXSS at all, which strips the
|
||||
// non-whitelisted `ontoggle` handler instead of returning it verbatim.
|
||||
const detailsPayload = '<details open ontoggle="alert(1)">x</details>';
|
||||
const sanitizedDetails = sanitizeHtmlIfNeeded(detailsPayload);
|
||||
expect(sanitizedDetails).toEqual('<details open>x</details>');
|
||||
|
||||
const stylePayload = '<style>body { display: none; }</style>';
|
||||
expect(sanitizeHtmlIfNeeded(stylePayload)).not.toContain('<style');
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeHtmlSpan', () => {
|
||||
|
||||
@@ -154,6 +154,20 @@ const KNOWN_HTML_TAGS = new Set([
|
||||
'html',
|
||||
'head',
|
||||
'body',
|
||||
// Script-capable elements and foreign-content roots (SVG/MathML). These
|
||||
// must be classified as HTML so that downstream sanitization is applied;
|
||||
// omitting them makes the heuristic fail open — payloads such as
|
||||
// `<svg onload=...>` or `<details open ontoggle=...>` would be classified
|
||||
// "not HTML" and returned verbatim by sanitizeHtmlIfNeeded.
|
||||
'svg',
|
||||
'math',
|
||||
'details',
|
||||
'summary',
|
||||
'object',
|
||||
'embed',
|
||||
'marquee',
|
||||
'template',
|
||||
'dialog',
|
||||
]);
|
||||
|
||||
const HTML_TAG_PATTERN = new RegExp(
|
||||
@@ -183,10 +197,15 @@ export function isProbablyHTML(text: string) {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(cleanedStr, 'text/html');
|
||||
|
||||
// Check if parsing created actual HTML elements (not just text nodes)
|
||||
const elements = Array.from(doc.body.childNodes).filter(
|
||||
node => node.nodeType === 1,
|
||||
) as Element[];
|
||||
// Check if parsing created actual HTML elements (not just text nodes).
|
||||
// Some elements (e.g. <style>, <title>, <meta>, <link>) parse into
|
||||
// document.head rather than document.body, so both must be inspected —
|
||||
// otherwise a bare <style> payload is classified "not HTML" and skips
|
||||
// sanitization.
|
||||
const elements = [
|
||||
...Array.from(doc.head.childNodes),
|
||||
...Array.from(doc.body.childNodes),
|
||||
].filter(node => node.nodeType === 1) as Element[];
|
||||
|
||||
// If no elements were created, it's not HTML
|
||||
if (elements.length === 0) {
|
||||
|
||||
@@ -19,19 +19,71 @@
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { sanitizeHtml } from './html';
|
||||
|
||||
export type TooltipTruncationMode = 'off' | 'end' | 'start' | 'middle';
|
||||
|
||||
export const TRUNCATION_MAX_CHARS = 40;
|
||||
|
||||
const TRUNCATION_STYLE = `
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
const NOWRAP_STYLE = `
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
/**
|
||||
* Shortens plain text so a tooltip label stays readable, placing the ellipsis
|
||||
* where the caller asked for it.
|
||||
*
|
||||
* Only 'start' and 'middle' slice. 'end' is handled by CSS in tooltipHtml, and
|
||||
* 'off' means no truncation at all, so both return the input untouched.
|
||||
*
|
||||
* The input must be plain text. Callers are responsible for truncating before
|
||||
* any markup (such as the ECharts series marker) is prepended, and before
|
||||
* sanitization — slicing a string that already contains markup would cut into
|
||||
* a tag.
|
||||
*/
|
||||
export function truncateLabel(
|
||||
text: string,
|
||||
mode: TooltipTruncationMode = 'end',
|
||||
): string {
|
||||
if (
|
||||
(mode !== 'start' && mode !== 'middle') ||
|
||||
text.length <= TRUNCATION_MAX_CHARS
|
||||
) {
|
||||
return text;
|
||||
}
|
||||
const budget = TRUNCATION_MAX_CHARS - 1;
|
||||
if (mode === 'start') {
|
||||
return `…${text.slice(-budget)}`;
|
||||
}
|
||||
const head = Math.ceil(budget / 2);
|
||||
const tail = Math.floor(budget / 2);
|
||||
return `${text.slice(0, head)}…${text.slice(-tail)}`;
|
||||
}
|
||||
|
||||
function getTruncationStyle(mode: TooltipTruncationMode): string {
|
||||
if (mode === 'end') {
|
||||
return TRUNCATION_STYLE;
|
||||
}
|
||||
if (mode === 'off') {
|
||||
return '';
|
||||
}
|
||||
// 'start' and 'middle' are already sliced upstream; keep them on one line.
|
||||
return NOWRAP_STYLE;
|
||||
}
|
||||
|
||||
export function tooltipHtml(
|
||||
data: string[][],
|
||||
title?: string,
|
||||
focusedRow?: number,
|
||||
truncation: TooltipTruncationMode = 'end',
|
||||
) {
|
||||
const truncationStyle = getTruncationStyle(truncation);
|
||||
const titleRow = title
|
||||
? `<span style="font-weight: 700;${TRUNCATION_STYLE}">${title}</span>`
|
||||
? `<span style="font-weight: 700;${truncationStyle}">${title}</span>`
|
||||
: '';
|
||||
return sanitizeHtml(`
|
||||
<div>
|
||||
@@ -46,7 +98,7 @@ export function tooltipHtml(
|
||||
const cellStyle = `
|
||||
text-align: ${j > 0 ? 'right' : 'left'};
|
||||
padding-left: ${j === 0 ? 0 : 16}px;
|
||||
${TRUNCATION_STYLE}
|
||||
${truncationStyle}
|
||||
`;
|
||||
return `<td style="${cellStyle}">${cell}</td>`;
|
||||
});
|
||||
|
||||
@@ -16,7 +16,12 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { sanitizeHtml, tooltipHtml } from '@superset-ui/core';
|
||||
import {
|
||||
sanitizeHtml,
|
||||
tooltipHtml,
|
||||
truncateLabel,
|
||||
TRUNCATION_MAX_CHARS,
|
||||
} from '@superset-ui/core';
|
||||
|
||||
const TITLE_STYLE =
|
||||
'style="font-weight: 700;max-width:300px;overflow:hidden;text-overflow:ellipsis;"';
|
||||
@@ -182,3 +187,88 @@ test('should preserve table styling after sanitization (fixes ECharts tooltip fo
|
||||
expect(html).toContain('padding-left:16px');
|
||||
expect(html).toContain('max-width:300px');
|
||||
});
|
||||
|
||||
describe('truncateLabel', () => {
|
||||
const long = 'prod-us-east-1-service-checkout-latency-p99'; // 43 chars
|
||||
|
||||
test('returns text unchanged for off and end', () => {
|
||||
expect(truncateLabel(long, 'off')).toBe(long);
|
||||
expect(truncateLabel(long, 'end')).toBe(long);
|
||||
});
|
||||
|
||||
test('defaults to end, which does not slice', () => {
|
||||
expect(truncateLabel(long)).toBe(long);
|
||||
});
|
||||
|
||||
test('truncates the start, keeping the distinguishing suffix', () => {
|
||||
expect(truncateLabel(long, 'start')).toBe(
|
||||
'…-us-east-1-service-checkout-latency-p99',
|
||||
);
|
||||
expect(truncateLabel(long, 'start')).toHaveLength(TRUNCATION_MAX_CHARS);
|
||||
});
|
||||
|
||||
test('truncates the middle, keeping both ends', () => {
|
||||
expect(truncateLabel(long, 'middle')).toBe(
|
||||
'prod-us-east-1-servi…heckout-latency-p99',
|
||||
);
|
||||
expect(truncateLabel(long, 'middle')).toHaveLength(TRUNCATION_MAX_CHARS);
|
||||
});
|
||||
|
||||
test('leaves text at or under the limit untouched', () => {
|
||||
const atLimit = 'x'.repeat(TRUNCATION_MAX_CHARS);
|
||||
expect(truncateLabel(atLimit, 'start')).toBe(atLimit);
|
||||
expect(truncateLabel(atLimit, 'middle')).toBe(atLimit);
|
||||
expect(truncateLabel('short', 'start')).toBe('short');
|
||||
expect(truncateLabel('', 'middle')).toBe('');
|
||||
});
|
||||
|
||||
test('truncates text one character over the limit', () => {
|
||||
const overLimit = 'x'.repeat(TRUNCATION_MAX_CHARS + 1);
|
||||
expect(truncateLabel(overLimit, 'start')).toBe(
|
||||
`…${'x'.repeat(TRUNCATION_MAX_CHARS - 1)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tooltipHtml truncation modes', () => {
|
||||
const rows = [['label', 'value']];
|
||||
|
||||
// sanitizeHtml normalizes spacing inside style attributes, and it does so
|
||||
// differently across versions, so compare with whitespace stripped.
|
||||
const styles = (
|
||||
title: string | undefined,
|
||||
truncation?: 'off' | 'end' | 'start' | 'middle',
|
||||
) => removeWhitespaces(tooltipHtml(rows, title, undefined, truncation));
|
||||
|
||||
test('emits the 300px cap for end and for the default', () => {
|
||||
expect(styles('Title', 'end')).toContain('max-width:300px');
|
||||
expect(tooltipHtml(rows, 'Title')).toBe(
|
||||
tooltipHtml(rows, 'Title', undefined, 'end'),
|
||||
);
|
||||
});
|
||||
|
||||
test('emits no truncation style for off', () => {
|
||||
const html = styles('Title', 'off');
|
||||
expect(html).not.toContain('max-width');
|
||||
expect(html).not.toContain('text-overflow');
|
||||
expect(html).not.toContain('white-space');
|
||||
});
|
||||
|
||||
test.each(['start', 'middle'] as const)(
|
||||
'emits nowrap instead of a cap for %s',
|
||||
mode => {
|
||||
const html = styles('Title', mode);
|
||||
expect(html).toContain('white-space:nowrap');
|
||||
expect(html).not.toContain('max-width');
|
||||
},
|
||||
);
|
||||
|
||||
test('never slices cell text itself, whatever the mode', () => {
|
||||
const longCell = 'y'.repeat(TRUNCATION_MAX_CHARS + 20);
|
||||
(['off', 'end', 'start', 'middle'] as const).forEach(mode => {
|
||||
expect(tooltipHtml([[longCell]], undefined, undefined, mode)).toContain(
|
||||
longCell,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,6 +47,10 @@ export default defineConfig({
|
||||
// Retry logic - 2 retries in CI, 0 locally
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
|
||||
// Disable capturing Git commit info as the project's history is increasingly dense
|
||||
// and breach Playwright's default 3-seconds `git` command timeout limit
|
||||
captureGitInfo: { commit: false, diff: false },
|
||||
|
||||
// Reporter configuration - multiple reporters for better visibility
|
||||
reporter: process.env.CI
|
||||
? [
|
||||
|
||||
@@ -96,16 +96,57 @@ export class Menu {
|
||||
itemText: string,
|
||||
options?: { timeout?: number },
|
||||
): Promise<void> {
|
||||
const popup = await this.openSubmenu(submenuText, {
|
||||
timeout: options?.timeout,
|
||||
itemText,
|
||||
});
|
||||
|
||||
// Use dispatchEvent instead of click to bypass viewport and pointer interception
|
||||
// issues. Ant Design renders submenu popups in a portal that can be positioned
|
||||
// outside the viewport or behind chart content (e.g., large tables with z-index).
|
||||
await popup.getByText(itemText, { exact: true }).dispatchEvent('click');
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a submenu and returns its popup locator, without selecting an item.
|
||||
* Useful when the caller needs to read the popup's contents (e.g. the set of
|
||||
* offered items) rather than clicking a known item.
|
||||
*
|
||||
* Uses hover as primary approach, falls back to keyboard then dispatchEvent -
|
||||
* same fallback chain as {@link selectSubmenuItem}.
|
||||
*
|
||||
* @param submenuText - The text of the submenu to open (e.g., "Download")
|
||||
* @param options - Optional timeout, an `itemText` to scope the popup lookup
|
||||
* to (useful when multiple submenu popups could otherwise match), and a
|
||||
* `popupSelector` override for submenus that render with an additional,
|
||||
* more specific class than the generic Ant Design popup class.
|
||||
*/
|
||||
async openSubmenu(
|
||||
submenuText: string,
|
||||
options?: { timeout?: number; itemText?: string; popupSelector?: string },
|
||||
): Promise<Locator> {
|
||||
const timeout = options?.timeout ?? TIMEOUT.FORM_LOAD;
|
||||
const matchPopup = (): Locator => {
|
||||
const base = this.page.locator(
|
||||
options?.popupSelector ?? Menu.SELECTORS.SUBMENU_POPUP,
|
||||
);
|
||||
return options?.itemText
|
||||
? base.filter({ hasText: options.itemText })
|
||||
: base;
|
||||
};
|
||||
|
||||
// Try hover first (most natural user interaction)
|
||||
let popup = await this.openSubmenuWithHover(submenuText, itemText, timeout);
|
||||
let popup = await this.openSubmenuWithHover(
|
||||
submenuText,
|
||||
matchPopup,
|
||||
timeout,
|
||||
);
|
||||
|
||||
// Fallback to keyboard navigation
|
||||
if (!popup) {
|
||||
popup = await this.openSubmenuWithKeyboard(
|
||||
submenuText,
|
||||
itemText,
|
||||
matchPopup,
|
||||
timeout,
|
||||
);
|
||||
}
|
||||
@@ -114,7 +155,7 @@ export class Menu {
|
||||
if (!popup) {
|
||||
popup = await this.openSubmenuWithDispatchEvent(
|
||||
submenuText,
|
||||
itemText,
|
||||
matchPopup,
|
||||
timeout,
|
||||
);
|
||||
}
|
||||
@@ -125,10 +166,7 @@ export class Menu {
|
||||
);
|
||||
}
|
||||
|
||||
// Use dispatchEvent instead of click to bypass viewport and pointer interception
|
||||
// issues. Ant Design renders submenu popups in a portal that can be positioned
|
||||
// outside the viewport or behind chart content (e.g., large tables with z-index).
|
||||
await popup.getByText(itemText, { exact: true }).dispatchEvent('click');
|
||||
return popup;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,17 +175,14 @@ export class Menu {
|
||||
*/
|
||||
private async openSubmenuWithHover(
|
||||
submenuText: string,
|
||||
itemText: string,
|
||||
matchPopup: () => Locator,
|
||||
timeout: number,
|
||||
): Promise<Locator | null> {
|
||||
try {
|
||||
const submenuTitle = this.getSubmenuTitle(submenuText);
|
||||
await submenuTitle.hover();
|
||||
|
||||
// Find the popup that contains the expected item (scopes to correct popup)
|
||||
const popup = this.page
|
||||
.locator(Menu.SELECTORS.SUBMENU_POPUP)
|
||||
.filter({ hasText: itemText });
|
||||
const popup = matchPopup();
|
||||
await popup.waitFor({ state: 'visible', timeout });
|
||||
|
||||
// Allow Ant Design's slide-in animation to complete before clicking.
|
||||
@@ -166,7 +201,7 @@ export class Menu {
|
||||
*/
|
||||
private async openSubmenuWithKeyboard(
|
||||
submenuText: string,
|
||||
itemText: string,
|
||||
matchPopup: () => Locator,
|
||||
timeout: number,
|
||||
): Promise<Locator | null> {
|
||||
try {
|
||||
@@ -174,9 +209,7 @@ export class Menu {
|
||||
await submenuTitle.focus();
|
||||
await this.page.keyboard.press('ArrowRight');
|
||||
|
||||
const popup = this.page
|
||||
.locator(Menu.SELECTORS.SUBMENU_POPUP)
|
||||
.filter({ hasText: itemText });
|
||||
const popup = matchPopup();
|
||||
await popup.waitFor({ state: 'visible', timeout });
|
||||
|
||||
return popup;
|
||||
@@ -191,7 +224,7 @@ export class Menu {
|
||||
*/
|
||||
private async openSubmenuWithDispatchEvent(
|
||||
submenuText: string,
|
||||
itemText: string,
|
||||
matchPopup: () => Locator,
|
||||
timeout: number,
|
||||
): Promise<Locator | null> {
|
||||
try {
|
||||
@@ -214,9 +247,7 @@ export class Menu {
|
||||
);
|
||||
});
|
||||
|
||||
const popup = this.page
|
||||
.locator(Menu.SELECTORS.SUBMENU_POPUP)
|
||||
.filter({ hasText: itemText });
|
||||
const popup = matchPopup();
|
||||
await popup.waitFor({ state: 'visible', timeout });
|
||||
|
||||
return popup;
|
||||
|
||||
@@ -22,7 +22,7 @@ import { Modal } from '../core/Modal';
|
||||
|
||||
/**
|
||||
* Confirm Dialog component for Ant Design Modal.confirm dialogs.
|
||||
* These are the "OK" / "Cancel" confirmation dialogs used throughout Superset.
|
||||
* These are the "Confirm" / "Cancel" confirmation dialogs used throughout Superset.
|
||||
* Uses getByRole with name to target specific confirm dialogs when multiple are open.
|
||||
*/
|
||||
export class ConfirmDialog extends Modal {
|
||||
@@ -43,7 +43,7 @@ export class ConfirmDialog extends Modal {
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks the OK button to confirm.
|
||||
* Clicks the Confirm button to confirm.
|
||||
* @param options.timeout - If provided, silently returns if dialog doesn't appear
|
||||
* within timeout. If not provided, waits indefinitely (strict mode).
|
||||
*/
|
||||
@@ -53,7 +53,7 @@ export class ConfirmDialog extends Modal {
|
||||
state: 'visible',
|
||||
timeout: options?.timeout,
|
||||
});
|
||||
await this.clickFooterButton('OK');
|
||||
await this.clickFooterButton('Confirm');
|
||||
await this.waitForHidden();
|
||||
} catch (error) {
|
||||
// Only swallow TimeoutError when timeout was explicitly provided
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* 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 { Locator, Page } from '@playwright/test';
|
||||
import { Modal } from '../core';
|
||||
|
||||
/**
|
||||
* The "Drill to detail" modal (`DrillDetailModal.tsx`), opened from a chart's
|
||||
* "More Options" menu or its right-click context menu. Renders the chart's
|
||||
* underlying sample rows, optionally scoped to a drilled-by value, via the
|
||||
* `/datasource/samples` API.
|
||||
*/
|
||||
export class DrillDetailModal extends Modal {
|
||||
private static readonly SELECTORS = {
|
||||
CLOSE_BUTTON: '[data-test="close-drilltodetail-modal"]',
|
||||
ROW_COUNT_LABEL: '[data-test="row-count-label"]',
|
||||
METADATA_BAR: '[data-test="metadata-bar"]',
|
||||
FILTER_COLUMN: '[data-test="filter-col"]',
|
||||
FILTER_VALUE: '[data-test="filter-val"]',
|
||||
PAGE_ITEM: '.ant-pagination-item',
|
||||
ACTIVE_PAGE_ITEM: '.ant-pagination-item-active',
|
||||
GRID_CELL: '.virtual-table-cell',
|
||||
} as const;
|
||||
|
||||
private readonly specificLocator: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page);
|
||||
// Matched by accessible name rather than a data-test: the antd Modal's own
|
||||
// data-test (`${name}-modal`) is derived from this same i18n'd `name`
|
||||
// prop, so it isn't a locale-independent alternative. No data-test exists
|
||||
// on the dialog root itself.
|
||||
this.specificLocator = page.getByRole('dialog', {
|
||||
name: /^Drill to detail:/,
|
||||
});
|
||||
}
|
||||
|
||||
override get element(): Locator {
|
||||
return this.specificLocator;
|
||||
}
|
||||
|
||||
/**
|
||||
* The applied-filter value tags (`<col>=<val>`). Empty when the drill was
|
||||
* whole-chart (no row/point-level filter applied).
|
||||
*/
|
||||
get filterValues(): Locator {
|
||||
return this.element.locator(DrillDetailModal.SELECTORS.FILTER_VALUE);
|
||||
}
|
||||
|
||||
/** The applied-filter chip(s); each is closable via its own "Close" icon. */
|
||||
get filterColumns(): Locator {
|
||||
return this.element.locator(DrillDetailModal.SELECTORS.FILTER_COLUMN);
|
||||
}
|
||||
|
||||
/** Row-count label above the results grid, e.g. "1-50 of 500 rows". */
|
||||
get rowCountLabel(): Locator {
|
||||
return this.element.locator(DrillDetailModal.SELECTORS.ROW_COUNT_LABEL);
|
||||
}
|
||||
|
||||
/** The metadata bar (column/row summary) shown once samples have loaded. */
|
||||
get metadataBar(): Locator {
|
||||
return this.element.locator(DrillDetailModal.SELECTORS.METADATA_BAR);
|
||||
}
|
||||
|
||||
/** Pagination page-number items below the results grid. */
|
||||
get pageItems(): Locator {
|
||||
return this.element.locator(DrillDetailModal.SELECTORS.PAGE_ITEM);
|
||||
}
|
||||
|
||||
/** The currently active pagination page-number item. */
|
||||
get activePageItem(): Locator {
|
||||
return this.element.locator(DrillDetailModal.SELECTORS.ACTIVE_PAGE_ITEM);
|
||||
}
|
||||
|
||||
/** Cells of the virtualized results grid. */
|
||||
get gridCells(): Locator {
|
||||
return this.element.locator(DrillDetailModal.SELECTORS.GRID_CELL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the first applied filter by clicking its chip's Close icon,
|
||||
* re-fetching the unfiltered samples.
|
||||
*/
|
||||
async clearFirstFilter(): Promise<void> {
|
||||
await this.filterColumns.first().getByLabel('Close').click();
|
||||
}
|
||||
|
||||
/** Navigates to the given 1-indexed pagination page. */
|
||||
async goToPage(pageNumber: number): Promise<void> {
|
||||
await this.pageItems.nth(pageNumber - 1).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-fetches the current samples query, resetting pagination to page 1.
|
||||
*
|
||||
* Matched by accessible name: the Reload icon carries an i18n'd
|
||||
* `aria-label` (`t('Reload')`) and no data-test, so this breaks in
|
||||
* non-English locales the same way `DrillDetailModal.tsx`'s dialog `name`
|
||||
* does above; the predecessor Cypress test used the same English string.
|
||||
*/
|
||||
async reload(): Promise<void> {
|
||||
await this.element.getByRole('button', { name: 'Reload' }).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the modal via its footer Close button.
|
||||
*
|
||||
* Targets the button by data-test rather than Modal.clickFooterButton,
|
||||
* which finds buttons by their visible text. The button label is i18n'd
|
||||
* ("Close" / "Fermer" / …), so name-based lookups break in non-English
|
||||
* locales; see DeleteConfirmationModal.clickDelete for the same rationale.
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
await this.element.locator(DrillDetailModal.SELECTORS.CLOSE_BUTTON).click();
|
||||
await this.waitForHidden();
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
export { ChartPropertiesModal } from './ChartPropertiesModal';
|
||||
export { ConfirmDialog } from './ConfirmDialog';
|
||||
export { DeleteConfirmationModal } from './DeleteConfirmationModal';
|
||||
export { DrillDetailModal } from './DrillDetailModal';
|
||||
export { DuplicateDatasetModal } from './DuplicateDatasetModal';
|
||||
export { EditDatasetModal } from './EditDatasetModal';
|
||||
export { ImportDatasetModal } from './ImportDatasetModal';
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import { Page, Download, Locator, expect } from '@playwright/test';
|
||||
import { Button, Input, Menu, Tabs } from '../components/core';
|
||||
import { DashboardFilterBar } from '../components/dashboard';
|
||||
import { DrillDetailModal } from '../components/modals';
|
||||
import { gotoWithRetry } from '../helpers/navigation';
|
||||
import { html5DragAndDrop } from '../helpers/dnd';
|
||||
import { TIMEOUT } from '../utils/constants';
|
||||
@@ -454,4 +455,124 @@ export class DashboardPage {
|
||||
|
||||
return { heightBefore: boxBefore.height, heightAfter: boxAfter.height };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Drill to detail
|
||||
//
|
||||
// Charts that implement the DRILL_TO_DETAIL behavior expose two entry points:
|
||||
// the chart's "More Options" header menu, and a right-click context menu on
|
||||
// the chart body (a cell, the big-number value, or a canvas data point). Both
|
||||
// open the same DrillDetailModal, which renders the underlying sample rows for
|
||||
// the (optionally filtered) chart by calling the `/datasource/samples` API.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Open the "Drill to detail" item from a chart's "More Options" header menu.
|
||||
* This is the whole-chart entry point (no row-level filters applied).
|
||||
*/
|
||||
async openDrillToDetailFromMenu(chartId: number): Promise<void> {
|
||||
const moreOptions = new Button(
|
||||
this.page,
|
||||
this.getChart(chartId).getByLabel('More Options', { exact: true }),
|
||||
);
|
||||
await moreOptions.click();
|
||||
await this.page
|
||||
.getByRole('menuitem', { name: 'Drill to detail', exact: true })
|
||||
.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* The DrillDetailModal dialog (titled "Drill to detail: <chart name>").
|
||||
*/
|
||||
drillModal(): DrillDetailModal {
|
||||
return new DrillDetailModal(this.page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the plain "Drill to detail" item in an open chart context menu
|
||||
* (whole chart, no row-level filter).
|
||||
*/
|
||||
async contextMenuDrillToDetail(): Promise<void> {
|
||||
await this.page
|
||||
.getByRole('menuitem', { name: 'Drill to detail', exact: true })
|
||||
.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Drill to detail by" submenu parent (title) in an open context menu.
|
||||
* Targeted by its submenu-title element rather than role+name because antd
|
||||
* appends the arrow-icon name ("right") to the accessible name, and the leaf
|
||||
* items ("Drill to detail by boy") would otherwise match a role+name lookup.
|
||||
*/
|
||||
drillBySubmenuTitle(): Locator {
|
||||
return this.page.locator('.ant-dropdown-menu-submenu-title', {
|
||||
hasText: 'Drill to detail by',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The chart context menu's Menu component, scoped to the open context
|
||||
* menu's root. Used to open the "Drill to detail by" submenu robustly:
|
||||
* plain hover is not reliably picked up by Ant Design's submenu trigger in
|
||||
* headless Chromium, so this falls back to keyboard and dispatchEvent - see
|
||||
* {@link Menu.openSubmenu}.
|
||||
*/
|
||||
private contextMenu(): Menu {
|
||||
return new Menu(this.page, '[data-test="chart-context-menu"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the "Drill to detail by" submenu and returns its popup, containing
|
||||
* the leaf value items (e.g. "Drill to detail by boy").
|
||||
*/
|
||||
private openDrillBySubmenu(): Promise<Locator> {
|
||||
return this.contextMenu().openSubmenu('Drill to detail by', {
|
||||
popupSelector: '.chart-context-submenu',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* From an open chart context menu, open the "Drill to detail by" submenu and
|
||||
* click the entry for a specific value (e.g. "boy", "1965", "all").
|
||||
*/
|
||||
async contextMenuDrillToDetailBy(value: string): Promise<void> {
|
||||
const popup = await this.openDrillBySubmenu();
|
||||
// Use dispatchEvent instead of click to bypass viewport and pointer
|
||||
// interception issues - see Menu.selectSubmenuItem.
|
||||
await popup
|
||||
.getByRole('menuitem', {
|
||||
name: `Drill to detail by ${value}`,
|
||||
exact: true,
|
||||
})
|
||||
.dispatchEvent('click');
|
||||
}
|
||||
|
||||
/**
|
||||
* From an open chart context menu, open "Drill to detail by" and return the
|
||||
* concrete values offered by the submenu (e.g. ["1965", "boy"]), skipping the
|
||||
* aggregate "all" entry. Used by canvas charts where the value under the
|
||||
* cursor is data-dependent: the test drills by whatever the menu actually
|
||||
* offers and asserts that same value round-trips into the modal, which keeps
|
||||
* the assertion independent of exact pixel/slice geometry.
|
||||
*
|
||||
* Reads rendered (HTML-stripped) menu text rather than the item's
|
||||
* `aria-label`, which carries the raw, unstripped formatted value
|
||||
* (`useDrillDetailMenuItems`). The two only diverge for formatted values
|
||||
* that contain HTML markup; callers pass the returned value both to
|
||||
* `contextMenuDrillToDetailBy` (accessible-name lookup) and to a
|
||||
* displayed-text assertion on the modal's filter chip, so a value straddling
|
||||
* both uses only works when it's markup-free. Every value currently offered
|
||||
* by this dashboard's charts is a plain string, so this hasn't been
|
||||
* reachable in practice; revisit if a test starts exercising HTML-formatted
|
||||
* dimension values.
|
||||
*/
|
||||
async drillByOfferedValues(): Promise<string[]> {
|
||||
const popup = await this.openDrillBySubmenu();
|
||||
const items = popup.locator('[role="menuitem"]');
|
||||
await items.first().waitFor();
|
||||
const labels = await items.allInnerTexts();
|
||||
return labels
|
||||
.map(l => l.replace(/^Drill to detail by\s*/i, '').trim())
|
||||
.filter(v => v.length > 0 && v.toLowerCase() !== 'all');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,747 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* E2E migration of the Cypress "Drill to detail modal" suite
|
||||
* (dashboard/drilltodetail.test.ts).
|
||||
*
|
||||
* Drill to detail lets a viewer open a modal of the underlying sample rows for a
|
||||
* chart — optionally filtered to a single data point — by either the chart's
|
||||
* "More Options" header menu or a right-click context menu on the chart body.
|
||||
* The modal calls the real `/datasource/samples` API, so this is genuinely
|
||||
* end-to-end: each test API-builds a hermetic dashboard from the `birth_names`
|
||||
* dataset, renders it in the browser, drives the real menus, and asserts the
|
||||
* resulting backend round-trip (the samples POST and the filter the modal
|
||||
* applies).
|
||||
*
|
||||
* Why the original suite was fully `describe.skip`:
|
||||
* "it has issues with autoscrolling and the locked title flakes intricately
|
||||
* when the rightClick is obstructed by the title."
|
||||
* That failure mode is Cypress-specific — Cypress auto-scrolls the target under
|
||||
* the sticky chart header before every action. Playwright scrolls once and the
|
||||
* target stays put, so the entry points are portable here.
|
||||
*
|
||||
* What is migrated, and how it is kept deterministic:
|
||||
* - Modal mechanics (open from header menu, pagination, reload-resets-page)
|
||||
* and the no-filter big-number drill use stable DOM elements.
|
||||
* - Table and Pivot drills right-click real DOM cells (no canvas pixels).
|
||||
* - Canvas (echarts) charts — Pie, Line, Scatter, generic/smooth/step
|
||||
* time-series, Mixed, Box plot, Funnel, Gauge, Treemap — DID rely on
|
||||
* hard-coded pixel coordinates in Cypress to land on a specific slice/point.
|
||||
* Instead of reproducing those brittle pixels, these tests scan a stable
|
||||
* region of the canvas (see `rightClickCanvasDatum`), read whichever value
|
||||
* the drill submenu actually offers for the point under the cursor, drill by
|
||||
* that value, and assert the SAME value round-trips into the modal filter.
|
||||
* This exercises the full canvas → contextmenu → datum → samples pipeline
|
||||
* while staying independent of exact geometry. `Big Number with Trendline`
|
||||
* drills the whole chart (no datum filter), like `Big Number`.
|
||||
*
|
||||
* Excluded (kept out, matching the original's own `describe.skip`s): Bar, Area,
|
||||
* World Map, Radar — skipped upstream for chart-specific reasons.
|
||||
*/
|
||||
import {
|
||||
testWithAssets,
|
||||
expect,
|
||||
type TestAssets,
|
||||
} from '../../helpers/fixtures';
|
||||
import type { Page, TestInfo } from '@playwright/test';
|
||||
import { TIMEOUT } from '../../utils/constants';
|
||||
import { DashboardPage } from '../../pages/DashboardPage';
|
||||
import { createDashboardWithCharts } from './dashboard-test-helpers';
|
||||
|
||||
const DATASET_NAME = 'birth_names';
|
||||
|
||||
/**
|
||||
* Parse a RowCountLabel value ("75.7k rows", "1,234 rows") into a number so
|
||||
* tests can assert the *invariant* (filtered < unfiltered) without hard-coding
|
||||
* the dataset-specific totals the original Cypress suite baked in.
|
||||
*/
|
||||
function parseRowCount(text: string): number {
|
||||
const m = text.match(/([\d.,]+)\s*([kKmM]?)/);
|
||||
if (!m) return NaN;
|
||||
let n = parseFloat(m[1].replace(/,/g, ''));
|
||||
const suffix = m[2].toLowerCase();
|
||||
if (suffix === 'k') n *= 1e3;
|
||||
if (suffix === 'm') n *= 1e6;
|
||||
return n;
|
||||
}
|
||||
|
||||
interface ChartSpec {
|
||||
vizType: string;
|
||||
chartNamePrefix: string;
|
||||
params: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* API-build a hermetic single-chart dashboard from birth_names and return its
|
||||
* dashboard and chart ids. Thin single-chart wrapper around
|
||||
* `createDashboardWithCharts`, the build helper shared by the other migrated
|
||||
* dashboard specs — reused here rather than hand-rolling position-json and id
|
||||
* extraction again.
|
||||
*/
|
||||
async function buildSingleChartDashboard(
|
||||
page: Page,
|
||||
testAssets: TestAssets,
|
||||
testInfo: TestInfo,
|
||||
spec: ChartSpec,
|
||||
): Promise<{ dashboardId: number; chartId: number }> {
|
||||
const { dashboardId, charts } = await createDashboardWithCharts(
|
||||
page,
|
||||
testAssets,
|
||||
testInfo,
|
||||
{
|
||||
datasetName: DATASET_NAME,
|
||||
chartNamePrefix: spec.chartNamePrefix,
|
||||
dashboardTitlePrefix: spec.chartNamePrefix,
|
||||
chartSpecs: [{ viz_type: spec.vizType, params: spec.params }],
|
||||
},
|
||||
);
|
||||
return { dashboardId, chartId: charts[0].id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Right-click an echarts canvas until a data point is hit — i.e. until the
|
||||
* context menu offers an *enabled* "Drill to detail by" submenu (a miss renders
|
||||
* that item disabled, as a plain menu item rather than a submenu title).
|
||||
*
|
||||
* echarts renders to a single canvas, so there is no per-datum DOM element to
|
||||
* target and the exact pixel of a mark depends on chart geometry (donut hole,
|
||||
* legend size, axis padding). Rather than hard-code Cypress's brittle pixel
|
||||
* coordinates, this scans a small set of candidate points — a radial ring for
|
||||
* pie/radial charts, a grid for cartesian charts — and stops at the first that
|
||||
* lands on a mark. The drill value is then whatever that mark represents, so the
|
||||
* caller asserts a value round-trip rather than a specific geometry.
|
||||
*/
|
||||
async function rightClickCanvasDatum(
|
||||
page: Page,
|
||||
dashboard: DashboardPage,
|
||||
canvas: ReturnType<Page['locator']>,
|
||||
pattern: 'ring' | 'grid' | 'dense',
|
||||
): Promise<void> {
|
||||
const box = await canvas.boundingBox();
|
||||
if (!box) throw new Error('canvas has no bounding box');
|
||||
|
||||
const ringPoints = (): Array<{ x: number; y: number }> => {
|
||||
const pts: Array<{ x: number; y: number }> = [];
|
||||
const cx = box.width / 2;
|
||||
const cy = box.height / 2;
|
||||
const minSide = Math.min(box.width, box.height);
|
||||
for (const rf of [0.3, 0.22, 0.38]) {
|
||||
for (let a = 0; a < 360; a += 45) {
|
||||
const rad = (a * Math.PI) / 180;
|
||||
pts.push({
|
||||
x: cx + Math.cos(rad) * minSide * rf,
|
||||
y: cy + Math.sin(rad) * minSide * rf,
|
||||
});
|
||||
}
|
||||
}
|
||||
return pts;
|
||||
};
|
||||
const gridPoints = (): Array<{ x: number; y: number }> => {
|
||||
const pts: Array<{ x: number; y: number }> = [];
|
||||
for (const yf of [0.5, 0.4, 0.6, 0.3, 0.7]) {
|
||||
for (const xf of [0.3, 0.45, 0.6, 0.2, 0.75]) {
|
||||
pts.push({ x: box.width * xf, y: box.height * yf });
|
||||
}
|
||||
}
|
||||
return pts;
|
||||
};
|
||||
|
||||
// 'dense' merges both scans for radial/stacked shapes (gauge, funnel, box
|
||||
// plot) whose drillable marks don't fall neatly on a single ring or grid.
|
||||
let candidates: Array<{ x: number; y: number }>;
|
||||
if (pattern === 'ring') candidates = ringPoints();
|
||||
else if (pattern === 'grid') candidates = gridPoints();
|
||||
else candidates = [...gridPoints(), ...ringPoints()];
|
||||
|
||||
// The submenu *title* element only exists when "Drill to detail by" is an
|
||||
// enabled submenu (a real datum was hit); a miss renders a disabled item.
|
||||
const enabledDrillBy = dashboard.drillBySubmenuTitle();
|
||||
const contextMenu = page.locator('[data-test="chart-context-menu"]');
|
||||
|
||||
for (const pt of candidates) {
|
||||
await canvas.click({ button: 'right', position: pt });
|
||||
const hit = await enabledDrillBy
|
||||
.waitFor({ state: 'visible', timeout: 400 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (hit) return;
|
||||
await page.keyboard.press('Escape');
|
||||
// Wait for the portal to actually close before the next right-click;
|
||||
// otherwise a still-open (or mid-close-animation) menu can make the
|
||||
// next click/locator behave nondeterministically on slower/contended CI.
|
||||
await contextMenu
|
||||
.waitFor({ state: 'hidden', timeout: 400 })
|
||||
.catch(() => {});
|
||||
}
|
||||
throw new Error(
|
||||
`no drillable datum found on canvas after scanning ${candidates.length} points`,
|
||||
);
|
||||
}
|
||||
|
||||
/** A samples POST fired (proves the modal hit the real backend). */
|
||||
function expectSamplesPost(page: Page) {
|
||||
return page.waitForResponse(
|
||||
r =>
|
||||
r.url().includes('/datasource/samples') &&
|
||||
r.request().method() === 'POST',
|
||||
{ timeout: TIMEOUT.API_RESPONSE },
|
||||
);
|
||||
}
|
||||
|
||||
async function loadDashboardWithChart(
|
||||
dashboard: DashboardPage,
|
||||
dashboardId: number,
|
||||
chartId: number,
|
||||
): Promise<void> {
|
||||
await dashboard.gotoById(dashboardId);
|
||||
await dashboard.waitForLoad();
|
||||
await dashboard
|
||||
.getChart(chartId)
|
||||
.locator('[data-test="chart-container"]')
|
||||
.first()
|
||||
.waitFor({ state: 'visible', timeout: TIMEOUT.QUERY_EXECUTION });
|
||||
await dashboard.waitForChartsToLoad();
|
||||
}
|
||||
|
||||
/**
|
||||
* From an already-open "Drill to detail by" submenu, drill by the first
|
||||
* offered value and assert that same value lands in the modal filter. The
|
||||
* shared tail of every "drill by whatever value is under the cursor" test —
|
||||
* canvas charts and the pivot table alike, which differ only in how they open
|
||||
* the submenu in the first place.
|
||||
*/
|
||||
async function drillByFirstOfferedValueAndAssert(
|
||||
page: Page,
|
||||
dashboard: DashboardPage,
|
||||
): Promise<void> {
|
||||
const offered = await dashboard.drillByOfferedValues();
|
||||
expect(offered.length).toBeGreaterThan(0);
|
||||
const [value] = offered;
|
||||
const samples = expectSamplesPost(page);
|
||||
await dashboard.contextMenuDrillToDetailBy(value);
|
||||
await samples;
|
||||
|
||||
await expect(dashboard.drillModal().element).toBeVisible();
|
||||
await expect(dashboard.drillModal().filterValues.first()).toContainText(
|
||||
value,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full canvas-drill round-trip for an echarts (canvas-rendered) chart: build a
|
||||
* hermetic single-chart dashboard, render it, right-click a real datum, drill by
|
||||
* whatever value the submenu offers under the cursor, and assert that same value
|
||||
* lands in the modal filter. Geometry-independent — see rightClickCanvasDatum.
|
||||
* Reused across every canvas viz type so each migrated chart is a thin caller.
|
||||
*/
|
||||
async function expectCanvasDrillByValueRoundTrips(
|
||||
page: Page,
|
||||
testAssets: TestAssets,
|
||||
testInfo: TestInfo,
|
||||
spec: ChartSpec,
|
||||
pattern: 'ring' | 'grid' | 'dense',
|
||||
): Promise<void> {
|
||||
const dashboard = new DashboardPage(page);
|
||||
const { dashboardId, chartId } = await buildSingleChartDashboard(
|
||||
page,
|
||||
testAssets,
|
||||
testInfo,
|
||||
spec,
|
||||
);
|
||||
await loadDashboardWithChart(dashboard, dashboardId, chartId);
|
||||
|
||||
const canvas = dashboard.getChart(chartId).locator('canvas').first();
|
||||
await expect(canvas).toBeVisible();
|
||||
await rightClickCanvasDatum(page, dashboard, canvas, pattern);
|
||||
|
||||
await drillByFirstOfferedValueAndAssert(page, dashboard);
|
||||
}
|
||||
|
||||
/**
|
||||
* Right-click a big-number chart's rendered value to open its context menu,
|
||||
* drill the whole chart (no row/point filter), and assert the modal opened
|
||||
* with no filter tags and a real row count. Shared by Big Number and Big
|
||||
* Number with Trendline, which differ only in their chart params.
|
||||
*/
|
||||
async function expectWholeChartDrillFromContextMenu(
|
||||
page: Page,
|
||||
dashboard: DashboardPage,
|
||||
chartId: number,
|
||||
): Promise<void> {
|
||||
const samples = expectSamplesPost(page);
|
||||
await dashboard
|
||||
.getChart(chartId)
|
||||
.locator('.header-line')
|
||||
.click({ button: 'right' });
|
||||
await dashboard.contextMenuDrillToDetail();
|
||||
await samples;
|
||||
|
||||
await expect(dashboard.drillModal().element).toBeVisible();
|
||||
// Whole-chart drill: no per-value filter tag.
|
||||
await expect(dashboard.drillModal().filterValues).toHaveCount(0);
|
||||
await expect(dashboard.drillModal().rowCountLabel).toContainText('rows');
|
||||
}
|
||||
|
||||
// Shared form-data fragment for the echarts time-series family (line/scatter/
|
||||
// generic/smooth/step): one temporal axis, one metric, split by gender series.
|
||||
const TIMESERIES_PARAMS = {
|
||||
x_axis: 'ds',
|
||||
time_grain_sqla: 'P1Y',
|
||||
metrics: ['count'],
|
||||
groupby: ['gender'],
|
||||
row_limit: 1000,
|
||||
};
|
||||
|
||||
testWithAssets(
|
||||
'drill-to-detail modal: opens from the header menu, paginates, and reload resets to page 1',
|
||||
async ({ page, testAssets }) => {
|
||||
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
const dashboard = new DashboardPage(page);
|
||||
const { dashboardId, chartId } = await buildSingleChartDashboard(
|
||||
page,
|
||||
testAssets,
|
||||
testWithAssets.info(),
|
||||
{
|
||||
vizType: 'big_number_total',
|
||||
chartNamePrefix: 'drill_bignum',
|
||||
params: { metric: 'count', adhoc_filters: [] },
|
||||
},
|
||||
);
|
||||
|
||||
await loadDashboardWithChart(dashboard, dashboardId, chartId);
|
||||
|
||||
// Open the modal from the chart's "More Options" header menu.
|
||||
const samplesOnOpen = expectSamplesPost(page);
|
||||
await dashboard.openDrillToDetailFromMenu(chartId);
|
||||
await samplesOnOpen;
|
||||
|
||||
const modal = dashboard.drillModal();
|
||||
await expect(modal.element).toBeVisible();
|
||||
await expect(modal.element).toContainText('Drill to detail:');
|
||||
// The metadata bar and a real row count prove the modal loaded backend data.
|
||||
await expect(modal.metadataBar).toBeVisible();
|
||||
await expect(modal.rowCountLabel).toContainText('rows');
|
||||
// No drill filter was applied (whole-chart drill).
|
||||
await expect(modal.filterValues).toHaveCount(0);
|
||||
|
||||
// The full dataset spans multiple pages, and the grid has rendered rows.
|
||||
expect(await modal.pageItems.count()).toBeGreaterThan(1);
|
||||
await expect(modal.gridCells.first()).toBeVisible();
|
||||
await expect(modal.activePageItem).toContainText('1');
|
||||
|
||||
// Paginate forward: clicking page 2 fires a real samples fetch and moves the
|
||||
// active page to 2.
|
||||
const samplesOnPage2 = expectSamplesPost(page);
|
||||
await modal.goToPage(2);
|
||||
await samplesOnPage2;
|
||||
await expect(modal.activePageItem).toContainText('2');
|
||||
|
||||
// Reload re-fetches and resets back to the first page.
|
||||
const samplesOnReload = expectSamplesPost(page);
|
||||
await modal.reload();
|
||||
await samplesOnReload;
|
||||
await expect(modal.activePageItem).toContainText('1');
|
||||
},
|
||||
);
|
||||
|
||||
testWithAssets(
|
||||
'drill-to-detail modal: big number value right-click drills the whole chart (no filter)',
|
||||
async ({ page, testAssets }) => {
|
||||
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
const dashboard = new DashboardPage(page);
|
||||
const { dashboardId, chartId } = await buildSingleChartDashboard(
|
||||
page,
|
||||
testAssets,
|
||||
testWithAssets.info(),
|
||||
{
|
||||
vizType: 'big_number_total',
|
||||
chartNamePrefix: 'drill_bignum_rc',
|
||||
params: { metric: 'count', adhoc_filters: [] },
|
||||
},
|
||||
);
|
||||
|
||||
await loadDashboardWithChart(dashboard, dashboardId, chartId);
|
||||
await expectWholeChartDrillFromContextMenu(page, dashboard, chartId);
|
||||
},
|
||||
);
|
||||
|
||||
testWithAssets(
|
||||
'drill-to-detail modal: table cell right-click drills by that value and clearing the filter restores the full set',
|
||||
async ({ page, testAssets }) => {
|
||||
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
const dashboard = new DashboardPage(page);
|
||||
const { dashboardId, chartId } = await buildSingleChartDashboard(
|
||||
page,
|
||||
testAssets,
|
||||
testWithAssets.info(),
|
||||
{
|
||||
vizType: 'table',
|
||||
chartNamePrefix: 'drill_table',
|
||||
params: {
|
||||
query_mode: 'aggregate',
|
||||
groupby: ['gender'],
|
||||
metrics: ['count'],
|
||||
row_limit: 100,
|
||||
server_pagination: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await loadDashboardWithChart(dashboard, dashboardId, chartId);
|
||||
|
||||
// Right-click the "boy" dimension cell and drill by it.
|
||||
const samplesOnDrill = expectSamplesPost(page);
|
||||
await dashboard
|
||||
.getChart(chartId)
|
||||
.getByText('boy', { exact: true })
|
||||
.first()
|
||||
.click({ button: 'right' });
|
||||
await dashboard.contextMenuDrillToDetailBy('boy');
|
||||
await samplesOnDrill;
|
||||
|
||||
const modal = dashboard.drillModal();
|
||||
await expect(modal.element).toBeVisible();
|
||||
await expect(modal.filterValues.first()).toContainText('boy');
|
||||
|
||||
const filteredCount = parseRowCount(await modal.rowCountLabel.innerText());
|
||||
expect(filteredCount).toBeGreaterThan(0);
|
||||
|
||||
// Clearing the filter reloads the samples and restores the larger, unfiltered total.
|
||||
const samplesOnClear = expectSamplesPost(page);
|
||||
await modal.clearFirstFilter();
|
||||
await samplesOnClear;
|
||||
await expect(modal.filterValues).toHaveCount(0);
|
||||
await expect
|
||||
.poll(async () => parseRowCount(await modal.rowCountLabel.innerText()))
|
||||
.toBeGreaterThan(filteredCount);
|
||||
},
|
||||
);
|
||||
|
||||
testWithAssets(
|
||||
'drill-to-detail modal: pivot table cell right-click drills by the cell value',
|
||||
async ({ page, testAssets }) => {
|
||||
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
const dashboard = new DashboardPage(page);
|
||||
const { dashboardId, chartId } = await buildSingleChartDashboard(
|
||||
page,
|
||||
testAssets,
|
||||
testWithAssets.info(),
|
||||
{
|
||||
vizType: 'pivot_table_v2',
|
||||
chartNamePrefix: 'drill_pivot',
|
||||
params: {
|
||||
groupbyRows: ['gender'],
|
||||
groupbyColumns: [],
|
||||
metrics: ['count'],
|
||||
aggregateFunction: 'Sum',
|
||||
rowTotals: false,
|
||||
colTotals: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await loadDashboardWithChart(dashboard, dashboardId, chartId);
|
||||
|
||||
await dashboard
|
||||
.getChart(chartId)
|
||||
.locator('[role="gridcell"]')
|
||||
.first()
|
||||
.click({ button: 'right' });
|
||||
|
||||
// The cell's row dimension determines the offered value; drill by it and
|
||||
// assert the same value lands in the modal filter.
|
||||
await drillByFirstOfferedValueAndAssert(page, dashboard);
|
||||
},
|
||||
);
|
||||
|
||||
testWithAssets(
|
||||
'drill-to-detail modal: pie slice right-click (canvas) drills by the slice value',
|
||||
async ({ page, testAssets }) => {
|
||||
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
// Pie is a donut by default (center is a hole), so scan the ring for a slice.
|
||||
await expectCanvasDrillByValueRoundTrips(
|
||||
page,
|
||||
testAssets,
|
||||
testWithAssets.info(),
|
||||
{
|
||||
vizType: 'pie',
|
||||
chartNamePrefix: 'drill_pie',
|
||||
params: { groupby: ['gender'], metric: 'count' },
|
||||
},
|
||||
'ring',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWithAssets(
|
||||
'drill-to-detail modal: line chart point right-click (canvas) drills by the point value',
|
||||
async ({ page, testAssets }) => {
|
||||
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
// Scan the plot grid for a point on one of the series lines.
|
||||
await expectCanvasDrillByValueRoundTrips(
|
||||
page,
|
||||
testAssets,
|
||||
testWithAssets.info(),
|
||||
{
|
||||
vizType: 'echarts_timeseries_line',
|
||||
chartNamePrefix: 'drill_line',
|
||||
params: TIMESERIES_PARAMS,
|
||||
},
|
||||
'grid',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWithAssets(
|
||||
'drill-to-detail modal: big number with trendline right-click drills the whole chart (no filter)',
|
||||
async ({ page, testAssets }) => {
|
||||
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
const dashboard = new DashboardPage(page);
|
||||
const { dashboardId, chartId } = await buildSingleChartDashboard(
|
||||
page,
|
||||
testAssets,
|
||||
testWithAssets.info(),
|
||||
{
|
||||
vizType: 'big_number',
|
||||
chartNamePrefix: 'drill_bignum_trend',
|
||||
params: {
|
||||
metric: 'count',
|
||||
x_axis: 'ds',
|
||||
time_grain_sqla: 'P1Y',
|
||||
adhoc_filters: [],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await loadDashboardWithChart(dashboard, dashboardId, chartId);
|
||||
await expectWholeChartDrillFromContextMenu(page, dashboard, chartId);
|
||||
},
|
||||
);
|
||||
|
||||
interface CanvasDrillCase {
|
||||
title: string;
|
||||
spec: ChartSpec;
|
||||
pattern: 'ring' | 'grid' | 'dense';
|
||||
}
|
||||
|
||||
// Every remaining canvas (echarts) chart is a thin caller of
|
||||
// expectCanvasDrillByValueRoundTrips, differing only in viz type, chart
|
||||
// params, and which point-scan pattern finds a drillable mark.
|
||||
const CANVAS_DRILL_CASES: CanvasDrillCase[] = [
|
||||
{
|
||||
title:
|
||||
'drill-to-detail modal: scatter chart point right-click (canvas) drills by the point value',
|
||||
spec: {
|
||||
vizType: 'echarts_timeseries_scatter',
|
||||
chartNamePrefix: 'drill_scatter',
|
||||
// Enlarge the markers so a region scan reliably lands on a point;
|
||||
// scatter's default dots are a few pixels wide and a sparse grid misses
|
||||
// them.
|
||||
params: { ...TIMESERIES_PARAMS, markerSize: 20 },
|
||||
},
|
||||
pattern: 'dense',
|
||||
},
|
||||
{
|
||||
title:
|
||||
'drill-to-detail modal: generic time-series point right-click (canvas) drills by the point value',
|
||||
spec: {
|
||||
vizType: 'echarts_timeseries',
|
||||
chartNamePrefix: 'drill_generic',
|
||||
params: TIMESERIES_PARAMS,
|
||||
},
|
||||
pattern: 'grid',
|
||||
},
|
||||
{
|
||||
title:
|
||||
'drill-to-detail modal: smooth line point right-click (canvas) drills by the point value',
|
||||
spec: {
|
||||
vizType: 'echarts_timeseries_smooth',
|
||||
chartNamePrefix: 'drill_smooth',
|
||||
params: TIMESERIES_PARAMS,
|
||||
},
|
||||
pattern: 'grid',
|
||||
},
|
||||
{
|
||||
title:
|
||||
'drill-to-detail modal: step line point right-click (canvas) drills by the point value',
|
||||
spec: {
|
||||
vizType: 'echarts_timeseries_step',
|
||||
chartNamePrefix: 'drill_step',
|
||||
params: TIMESERIES_PARAMS,
|
||||
},
|
||||
pattern: 'grid',
|
||||
},
|
||||
{
|
||||
title:
|
||||
'drill-to-detail modal: mixed time-series point right-click (canvas) drills by the point value',
|
||||
spec: {
|
||||
vizType: 'mixed_timeseries',
|
||||
chartNamePrefix: 'drill_mixed',
|
||||
params: {
|
||||
x_axis: 'ds',
|
||||
time_grain_sqla: 'P1Y',
|
||||
metrics: ['count'],
|
||||
groupby: ['gender'],
|
||||
metrics_b: ['count'],
|
||||
groupby_b: ['gender'],
|
||||
row_limit: 1000,
|
||||
},
|
||||
},
|
||||
pattern: 'grid',
|
||||
},
|
||||
{
|
||||
title:
|
||||
'drill-to-detail modal: box plot right-click (canvas) drills by the box value',
|
||||
spec: {
|
||||
vizType: 'box_plot',
|
||||
chartNamePrefix: 'drill_boxplot',
|
||||
params: {
|
||||
groupby: ['gender'],
|
||||
metrics: ['count'],
|
||||
columns: ['ds'],
|
||||
},
|
||||
},
|
||||
pattern: 'dense',
|
||||
},
|
||||
{
|
||||
title:
|
||||
'drill-to-detail modal: funnel segment right-click (canvas) drills by the segment value',
|
||||
spec: {
|
||||
vizType: 'funnel',
|
||||
chartNamePrefix: 'drill_funnel',
|
||||
params: { groupby: ['gender'], metric: 'count' },
|
||||
},
|
||||
pattern: 'dense',
|
||||
},
|
||||
{
|
||||
title:
|
||||
'drill-to-detail modal: gauge right-click (canvas) drills by the gauge value',
|
||||
spec: {
|
||||
vizType: 'gauge_chart',
|
||||
chartNamePrefix: 'drill_gauge',
|
||||
params: { groupby: ['gender'], metric: 'count' },
|
||||
},
|
||||
pattern: 'dense',
|
||||
},
|
||||
{
|
||||
title:
|
||||
'drill-to-detail modal: treemap tile right-click (canvas) drills by the tile value',
|
||||
spec: {
|
||||
vizType: 'treemap_v2',
|
||||
chartNamePrefix: 'drill_treemap',
|
||||
params: { metric: 'count', groupby: ['gender'] },
|
||||
},
|
||||
pattern: 'dense',
|
||||
},
|
||||
];
|
||||
|
||||
for (const { title, spec, pattern } of CANVAS_DRILL_CASES) {
|
||||
testWithAssets(title, async ({ page, testAssets }) => {
|
||||
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
await expectCanvasDrillByValueRoundTrips(
|
||||
page,
|
||||
testAssets,
|
||||
testWithAssets.info(),
|
||||
spec,
|
||||
pattern,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
testWithAssets(
|
||||
'drill-to-detail modal: drilling a time-series point "by all" applies every dimension of that point',
|
||||
async ({ page, testAssets }) => {
|
||||
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
const dashboard = new DashboardPage(page);
|
||||
const { dashboardId, chartId } = await buildSingleChartDashboard(
|
||||
page,
|
||||
testAssets,
|
||||
testWithAssets.info(),
|
||||
{
|
||||
vizType: 'echarts_timeseries_line',
|
||||
chartNamePrefix: 'drill_all',
|
||||
// Two groupby dimensions so each point genuinely carries more than one
|
||||
// drillable value — the whole point of "Drill to detail by all".
|
||||
params: { ...TIMESERIES_PARAMS, groupby: ['gender', 'state'] },
|
||||
},
|
||||
);
|
||||
|
||||
await loadDashboardWithChart(dashboard, dashboardId, chartId);
|
||||
|
||||
const canvas = dashboard.getChart(chartId).locator('canvas').first();
|
||||
await expect(canvas).toBeVisible();
|
||||
await rightClickCanvasDatum(page, dashboard, canvas, 'grid');
|
||||
|
||||
// A line point carries two dimensions (the temporal value and the gender
|
||||
// series), so "Drill to detail by all" must apply both as filters.
|
||||
const offered = await dashboard.drillByOfferedValues();
|
||||
expect(offered.length).toBeGreaterThanOrEqual(2);
|
||||
const samples = expectSamplesPost(page);
|
||||
await dashboard.contextMenuDrillToDetailBy('all');
|
||||
await samples;
|
||||
|
||||
await expect(dashboard.drillModal().element).toBeVisible();
|
||||
expect(
|
||||
await dashboard.drillModal().filterValues.count(),
|
||||
).toBeGreaterThanOrEqual(2);
|
||||
},
|
||||
);
|
||||
|
||||
testWithAssets(
|
||||
'drill-to-detail modal: table drills correctly by each of multiple dimension values',
|
||||
async ({ page, testAssets }) => {
|
||||
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
const dashboard = new DashboardPage(page);
|
||||
const { dashboardId, chartId } = await buildSingleChartDashboard(
|
||||
page,
|
||||
testAssets,
|
||||
testWithAssets.info(),
|
||||
{
|
||||
vizType: 'table',
|
||||
chartNamePrefix: 'drill_table_multi',
|
||||
params: {
|
||||
query_mode: 'aggregate',
|
||||
groupby: ['gender'],
|
||||
metrics: ['count'],
|
||||
row_limit: 100,
|
||||
server_pagination: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await loadDashboardWithChart(dashboard, dashboardId, chartId);
|
||||
|
||||
for (const value of ['boy', 'girl']) {
|
||||
const samples = expectSamplesPost(page);
|
||||
await dashboard
|
||||
.getChart(chartId)
|
||||
.getByText(value, { exact: true })
|
||||
.first()
|
||||
.click({ button: 'right' });
|
||||
await dashboard.contextMenuDrillToDetailBy(value);
|
||||
await samples;
|
||||
|
||||
const modal = dashboard.drillModal();
|
||||
await expect(modal.element).toBeVisible();
|
||||
await expect(modal.filterValues.first()).toContainText(value);
|
||||
await modal.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1,58 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* With SOFT_DELETE enabled the delete-confirmation modal becomes recoverable:
|
||||
* it explains the object is moved to the archive (and for how long), and drops
|
||||
* the "type DELETE to confirm" friction. Non-destructive — the modal is opened
|
||||
* and dismissed without deleting anything.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { skipUnlessFeatureEnabled } from '../../helpers/featureFlags';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await skipUnlessFeatureEnabled(page, 'SOFT_DELETE');
|
||||
});
|
||||
|
||||
test('chart delete confirmation reflects soft-delete (archive) semantics', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('chart/list/');
|
||||
await page.locator('[data-test="chart-row-delete"]').first().waitFor();
|
||||
await page.locator('[data-test="chart-row-delete"]').first().click();
|
||||
|
||||
// The action reads as "Archive", not "Delete". Scope to the dialog: with
|
||||
// the flag on, every list row's delete action is also named "Archive", so
|
||||
// an unscoped button query is a strict-mode violation (25 rows + modal).
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog.getByText(/^Archive .+\?$/)).toBeVisible();
|
||||
await expect(dialog.getByRole('button', { name: 'Archive' })).toBeVisible();
|
||||
|
||||
// Recoverable copy instead of "Are you sure … permanently".
|
||||
await expect(page.getByText(/moved to Recently Archived/i)).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(/recover it there within \d+ days/i),
|
||||
).toBeVisible();
|
||||
|
||||
// No "type DELETE to confirm" input in recoverable mode.
|
||||
await expect(page.getByTestId('delete-modal-input')).toHaveCount(0);
|
||||
|
||||
// Dismiss without deleting.
|
||||
await page.getByTestId('close-modal-btn').click();
|
||||
});
|
||||
@@ -29,7 +29,7 @@
|
||||
* restore it and asserts — via the API — that it is live again.
|
||||
*/
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import { apiGet, apiPost } from '../../helpers/api/requests';
|
||||
import { apiGet } from '../../helpers/api/requests';
|
||||
import { extractIdFromResponse } from '../../helpers/api/assertions';
|
||||
import {
|
||||
apiPostChart,
|
||||
@@ -188,58 +188,3 @@ test('permanently deletes an archived item from the view', async ({ page }) => {
|
||||
await TYPES[0].softDelete(page, id).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
test('shows an empty message and no rows when the search matches nothing', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('archived/');
|
||||
await expect(page.getByTestId('archived-list-view')).toBeVisible();
|
||||
|
||||
const search = page.getByPlaceholder(/type a value/i);
|
||||
await search.click();
|
||||
await search.fill(`e2e_nonexistent_${Date.now()}`);
|
||||
await search.press('Enter');
|
||||
|
||||
await expect(
|
||||
page.getByText('No results match your filter criteria'),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId('archived-row-restore')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('restoring an already-restored row surfaces an error without crashing', async ({
|
||||
page,
|
||||
}) => {
|
||||
const name = `e2e_stale_${Date.now()}`;
|
||||
const id = await TYPES[0].create(page, name);
|
||||
// Capture the uuid before soft-delete (a soft-deleted GET returns 404).
|
||||
const { uuid } = (await (await apiGetDashboard(page, id)).json()).result;
|
||||
try {
|
||||
expect((await apiDeleteDashboard(page, id)).ok()).toBeTruthy();
|
||||
|
||||
await openArchive(page, 'Dashboard', name);
|
||||
await expect(page.getByText(name, { exact: false })).toBeVisible();
|
||||
|
||||
// Simulate another actor restoring the object out from under this view.
|
||||
const restored = await apiPost(
|
||||
page,
|
||||
`api/v1/dashboard/${uuid}/restore`,
|
||||
{},
|
||||
);
|
||||
expect(restored.ok()).toBeTruthy();
|
||||
|
||||
// Clicking the now-stale row's Restore yields a 404 → danger toast, no crash.
|
||||
await page
|
||||
.getByRole('row')
|
||||
.filter({ hasText: name })
|
||||
.getByTestId('archived-row-restore')
|
||||
.click();
|
||||
await expect(
|
||||
page.getByText(`Failed to restore ${name}`, { exact: false }),
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
// The page is still functional (the list view did not crash).
|
||||
await expect(page.getByTestId('archived-list-view')).toBeVisible();
|
||||
} finally {
|
||||
// Re-archive the (possibly) restored dashboard, whatever happened above.
|
||||
await apiDeleteDashboard(page, id).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -40,10 +40,18 @@ import { testWithAssets, expect } from '../../helpers/fixtures';
|
||||
import { apiGet } from '../../helpers/api/requests';
|
||||
import { apiPostChart, apiPutChart } from '../../helpers/api/chart';
|
||||
import { getDatasetByName } from '../../helpers/api/dataset';
|
||||
import { getAccessToken } from '../../helpers/api/embedded';
|
||||
import { TIMEOUT } from '../../utils/constants';
|
||||
|
||||
const DATASET_NAME = 'birth_names';
|
||||
|
||||
async function authorizeApi(page: Page): Promise<void> {
|
||||
const accessToken = await getAccessToken(page);
|
||||
await page.context().setExtraHTTPHeaders({
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Visible row text must never expose synthetic identifiers (layout node
|
||||
// ids like CHART-xyz / ROW-… or bare UUIDs) — the rendering layer maps
|
||||
// these to human names or kind-only phrasing.
|
||||
@@ -79,19 +87,35 @@ async function currentUserSubjectId(page: Page): Promise<number> {
|
||||
* this reads like its sibling specs, but fall back to whatever the instance
|
||||
* has rather than requiring a particular fixture to be loaded.
|
||||
*/
|
||||
async function anyDatasetId(page: Page): Promise<number> {
|
||||
async function anyDataset(page: Page): Promise<{
|
||||
id: number;
|
||||
columnName: string;
|
||||
}> {
|
||||
const named = await getDatasetByName(page, DATASET_NAME);
|
||||
if (named) {
|
||||
return named.id;
|
||||
let datasetId = named?.id;
|
||||
if (!datasetId) {
|
||||
const res = await apiGet(
|
||||
page,
|
||||
`api/v1/dataset/?q=${rison.encode({ columns: ['id'], page_size: 1 })}`,
|
||||
);
|
||||
expect(res.ok(), 'dataset list request').toBeTruthy();
|
||||
const [first] = (await res.json()).result;
|
||||
expect(first, 'the instance has at least one dataset').toBeTruthy();
|
||||
datasetId = first.id;
|
||||
}
|
||||
const res = await apiGet(
|
||||
page,
|
||||
`api/v1/dataset/?q=${rison.encode({ columns: ['id'], page_size: 1 })}`,
|
||||
);
|
||||
expect(res.ok(), 'dataset list request').toBeTruthy();
|
||||
const [first] = (await res.json()).result;
|
||||
expect(first, 'the instance has at least one dataset').toBeTruthy();
|
||||
return first.id;
|
||||
if (datasetId === undefined) {
|
||||
throw new Error('Unable to resolve a dataset id');
|
||||
}
|
||||
|
||||
const detailRes = await apiGet(page, `api/v1/dataset/${datasetId}`);
|
||||
expect(detailRes.ok(), 'dataset detail request').toBeTruthy();
|
||||
const { columns } = (await detailRes.json()).result;
|
||||
const [firstColumn] = columns;
|
||||
expect(firstColumn, 'the dataset has at least one column').toBeTruthy();
|
||||
return {
|
||||
id: datasetId,
|
||||
columnName: firstColumn.column_name,
|
||||
};
|
||||
}
|
||||
|
||||
/** Open the Explore "Additional actions → View version history" panel. */
|
||||
@@ -109,7 +133,8 @@ testWithAssets(
|
||||
async ({ page, testAssets }) => {
|
||||
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
|
||||
const datasetId = await anyDatasetId(page);
|
||||
await authorizeApi(page);
|
||||
const { id: datasetId, columnName } = await anyDataset(page);
|
||||
|
||||
const baseName = `version_history_${Date.now()}`;
|
||||
const chartResp = await apiPostChart(page, {
|
||||
@@ -123,7 +148,7 @@ testWithAssets(
|
||||
datasource: `${datasetId}__table`,
|
||||
viz_type: 'table',
|
||||
query_mode: 'raw',
|
||||
all_columns: [],
|
||||
all_columns: [columnName],
|
||||
adhoc_filters: [],
|
||||
row_limit: 10,
|
||||
}),
|
||||
@@ -171,3 +196,79 @@ testWithAssets(
|
||||
).toBeFalsy();
|
||||
},
|
||||
);
|
||||
|
||||
testWithAssets(
|
||||
'minor edit of a non-canonical chart omits hydration noise',
|
||||
async ({ page, testAssets }) => {
|
||||
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
|
||||
await authorizeApi(page);
|
||||
const { id: datasetId, columnName } = await anyDataset(page);
|
||||
const baseName = `version_history_normalization_${Date.now()}`;
|
||||
const chartResp = await apiPostChart(page, {
|
||||
slice_name: baseName,
|
||||
viz_type: 'table',
|
||||
datasource_id: datasetId,
|
||||
datasource_type: 'table',
|
||||
// Deliberately omit visualization defaults. Explore hydration supplies
|
||||
// them, reproducing params imported before they were canonical.
|
||||
params: JSON.stringify({
|
||||
datasource: `${datasetId}__table`,
|
||||
viz_type: 'table',
|
||||
query_mode: 'raw',
|
||||
all_columns: [columnName],
|
||||
adhoc_filters: [],
|
||||
extra_form_data: {},
|
||||
dashboards: [],
|
||||
row_limit: 10,
|
||||
}),
|
||||
});
|
||||
expect(chartResp.ok(), 'chart creation').toBeTruthy();
|
||||
const chartBody = await chartResp.json();
|
||||
const chartId: number = chartBody.result?.id ?? chartBody.id;
|
||||
expect(chartId, 'chart creation should return an id').toBeTruthy();
|
||||
testAssets.trackChart(chartId);
|
||||
|
||||
const adminSubjectId = await currentUserSubjectId(page);
|
||||
const editorResp = await apiPutChart(page, chartId, {
|
||||
editors: [adminSubjectId],
|
||||
});
|
||||
expect(editorResp.ok(), 'claim chart editorship').toBeTruthy();
|
||||
|
||||
await page.goto(`explore/?slice_id=${chartId}`);
|
||||
await page.getByRole('combobox', { name: 'Row limit' }).click();
|
||||
await page.getByRole('option', { name: '100', exact: true }).click();
|
||||
await page.locator('[data-test="query-save-button"]').click();
|
||||
await page.locator('[data-test="save-overwrite-radio"]').click();
|
||||
|
||||
const saveResponsePromise = page.waitForResponse(
|
||||
response =>
|
||||
response.request().method() === 'PUT' &&
|
||||
response.url().includes(`/api/v1/chart/${chartId}`),
|
||||
);
|
||||
await page.locator('[data-test="btn-modal-save"]').click();
|
||||
const saveResponse = await saveResponsePromise;
|
||||
expect(saveResponse.ok(), 'chart overwrite').toBeTruthy();
|
||||
|
||||
const requestPayload = saveResponse.request().postDataJSON();
|
||||
const savedParams = JSON.parse(requestPayload.params);
|
||||
expect(
|
||||
savedParams.matrixify_enable,
|
||||
'overwrite contains a default absent from the stored params',
|
||||
).toBe(false);
|
||||
|
||||
await openVersionHistory(page);
|
||||
const panel = page.locator('[aria-label="Version history"]');
|
||||
const newestGroup = panel
|
||||
.locator('[data-test="version-history-save-group"]')
|
||||
.first();
|
||||
await expect(newestGroup, 'shows the overwrite save group').toBeVisible();
|
||||
await newestGroup.getByRole('button').first().click();
|
||||
|
||||
const rows = newestGroup.locator(
|
||||
'[data-test="version-history-action-row"]',
|
||||
);
|
||||
await expect(rows, 'shows only the intentional edit').toHaveCount(1);
|
||||
await expect(rows.first()).toContainText(/row limit/i);
|
||||
},
|
||||
);
|
||||
|
||||
+11
-1
@@ -27,11 +27,21 @@ const StyledTotalCell = styled.div`
|
||||
`}
|
||||
`;
|
||||
|
||||
// `align` originates from the chart's stored column_config
|
||||
// (col.config.horizontalAlign), which can be set to an arbitrary string via
|
||||
// a direct chart-params API write. Emotion compiles interpolated strings as
|
||||
// CSS source, so the value must be clamped to a closed set of keywords
|
||||
// before it reaches the stylesheet — never interpolated raw.
|
||||
const ALLOWED_ALIGN_VALUES = new Set(['left', 'right', 'center']);
|
||||
|
||||
const safeAlign = (align?: string) =>
|
||||
align && ALLOWED_ALIGN_VALUES.has(align) ? align : 'left';
|
||||
|
||||
const CellContainer = styled.div<{ backgroundColor?: string; align?: string }>`
|
||||
display: flex;
|
||||
background-color: ${({ backgroundColor }) =>
|
||||
backgroundColor || 'transparent'};
|
||||
justify-content: ${({ align }) => align || 'left'};
|
||||
justify-content: ${({ align }) => safeAlign(align)};
|
||||
`;
|
||||
|
||||
const ArrowContainer = styled.div<{ arrowColor?: string }>`
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 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 '@testing-library/jest-dom';
|
||||
import { render } from '@superset-ui/core/spec';
|
||||
import { NumericCellRenderer } from '../src/renderers/NumericCellRenderer';
|
||||
|
||||
const renderCell = (horizontalAlign?: string) => {
|
||||
const params = {
|
||||
value: 42,
|
||||
valueFormatted: '42',
|
||||
node: { rowPinned: undefined, rowIndex: 0 },
|
||||
hasBasicColorFormatters: false,
|
||||
basicColorFormatters: [],
|
||||
col: {
|
||||
isNumeric: true,
|
||||
config: horizontalAlign ? { horizontalAlign } : {},
|
||||
},
|
||||
valueRange: undefined,
|
||||
alignPositiveNegative: false,
|
||||
colorPositiveNegative: false,
|
||||
} as unknown as Parameters<typeof NumericCellRenderer>[0];
|
||||
return render(<NumericCellRenderer {...params} />);
|
||||
};
|
||||
|
||||
const collectInjectedCss = () =>
|
||||
Array.from(document.querySelectorAll('style'))
|
||||
.map(style => style.textContent ?? '')
|
||||
.join('\n');
|
||||
|
||||
test('applies an allowed horizontalAlign value from column config', () => {
|
||||
const { container } = renderCell('center');
|
||||
expect(container.firstChild).toHaveStyle({ justifyContent: 'center' });
|
||||
});
|
||||
|
||||
test('does not compile a malicious horizontalAlign into the stylesheet', () => {
|
||||
const payload =
|
||||
'right;} & { position:fixed; top:0; left:0; width:100vw; height:100vh; z-index:99999; background:#fff url(https://attacker.example/beacon) }';
|
||||
const { container } = renderCell(payload);
|
||||
const css = collectInjectedCss();
|
||||
expect(css).not.toContain('position:fixed');
|
||||
expect(css).not.toContain('attacker.example');
|
||||
expect(container.firstChild).toHaveStyle({ justifyContent: 'left' });
|
||||
});
|
||||
@@ -22,7 +22,7 @@ import { getSequentialSchemeRegistry } from '@superset-ui/core';
|
||||
import { SupersetTheme } from '@apache-superset/core/theme';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import CalHeatMapImport from './vendor/cal-heatmap';
|
||||
import { convertUTCTimestampToLocal } from './utils';
|
||||
import { convertUTCTimestampToLocal, getFormattedUTCTime } from './utils';
|
||||
|
||||
// The vendor file is @ts-nocheck, so its export lacks type info.
|
||||
// Define a minimal constructor interface for use in this file.
|
||||
@@ -103,6 +103,8 @@ function Calendar(element: HTMLElement, props: CalendarProps) {
|
||||
const subDomainTextFormat = showValues
|
||||
? (_date: Date, value: number) => valueFormatter(value)
|
||||
: null;
|
||||
const dateFormatter = (date: Date, format: string) =>
|
||||
getFormattedUTCTime(date.getTime(), format);
|
||||
|
||||
const metricsData = data.data;
|
||||
|
||||
@@ -166,6 +168,7 @@ function Calendar(element: HTMLElement, props: CalendarProps) {
|
||||
itemName: '',
|
||||
valueFormatter,
|
||||
timeFormatter,
|
||||
dateFormatter,
|
||||
subDomainTextFormat,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,3 +38,11 @@ export const convertUTCTimestampToLocal = (utcTimestamp: number): number => {
|
||||
const offsetMs = date.getTimezoneOffset() * 60 * 1000;
|
||||
return utcTimestamp + offsetMs;
|
||||
};
|
||||
|
||||
// Escapes HTML special characters before formatter output reaches an
|
||||
// innerHTML sink. Mirrors plugin-chart-country-map's escapeHtml.
|
||||
export const escapeHtml = (text: unknown): string => {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = String(text);
|
||||
return div.innerHTML;
|
||||
};
|
||||
|
||||
@@ -13,6 +13,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';
|
||||
import { escapeHtml } from '../utils';
|
||||
|
||||
var d3 = typeof require === 'function' ? require('d3') : window.d3;
|
||||
|
||||
@@ -22,14 +23,16 @@ var CalHeatMap = function () {
|
||||
'use strict';
|
||||
|
||||
var self = this;
|
||||
// d3-tip assigns the .html() return value to the tip node via
|
||||
// innerHTML, so formatter output is HTML-escaped first.
|
||||
self.tip = d3tip()
|
||||
.attr('class', `d3-tip ${CALENDAR_TOOLTIP_CLASS}`)
|
||||
.direction('n')
|
||||
.offset([-5, 0])
|
||||
.html(
|
||||
d => `
|
||||
${self.options.timeFormatter(d.t)}: <strong>${self.options.valueFormatter(
|
||||
d.v,
|
||||
${escapeHtml(self.options.timeFormatter(d.t))}: <strong>${escapeHtml(
|
||||
self.options.valueFormatter(d.v),
|
||||
)}</strong>
|
||||
`,
|
||||
);
|
||||
@@ -37,7 +40,7 @@ var CalHeatMap = function () {
|
||||
.attr('class', `d3-tip ${CALENDAR_TOOLTIP_CLASS}`)
|
||||
.direction('n')
|
||||
.offset([-5, 0])
|
||||
.html(d => self.options.valueFormatter(d));
|
||||
.html(d => escapeHtml(self.options.valueFormatter(d)));
|
||||
|
||||
this.allowedDataType = ['json', 'csv', 'tsv', 'txt'];
|
||||
|
||||
@@ -76,6 +79,8 @@ var CalHeatMap = function () {
|
||||
|
||||
timeFormatter: d => d,
|
||||
|
||||
dateFormatter: null,
|
||||
|
||||
domain: 'hour',
|
||||
|
||||
subDomain: 'min',
|
||||
@@ -1990,10 +1995,14 @@ CalHeatMap.prototype = {
|
||||
|
||||
if (typeof format === 'function') {
|
||||
return format(d);
|
||||
} else {
|
||||
var f = d3.time.format(format);
|
||||
return f(d);
|
||||
}
|
||||
|
||||
if (typeof this.options.dateFormatter === 'function') {
|
||||
return this.options.dateFormatter(d, format);
|
||||
}
|
||||
|
||||
var f = d3.time.format(format);
|
||||
return f(d);
|
||||
},
|
||||
|
||||
getSubDomainTitle: function (d) {
|
||||
|
||||
@@ -25,9 +25,11 @@ import {
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { CALENDAR_TOOLTIP_CLASS } from '../src/tooltip';
|
||||
import { convertUTCTimestampToLocal } from '../src/utils';
|
||||
|
||||
interface MockCalHeatMapConfig {
|
||||
itemSelector: Element;
|
||||
dateFormatter?: (date: Date, format: string) => string;
|
||||
}
|
||||
|
||||
type MetricNameInput = string | string[];
|
||||
@@ -38,6 +40,7 @@ let mockInitCallCount = 0;
|
||||
let mockThrowOnInitCall: number | null = null;
|
||||
let mockDestroyCallCount = 0;
|
||||
let mockDestroyedInstanceIds: string[] = [];
|
||||
let mockDateFormatter: MockCalHeatMapConfig['dateFormatter'];
|
||||
|
||||
const mockTheme = {
|
||||
colorBgElevated: '#ffffff',
|
||||
@@ -56,6 +59,7 @@ jest.mock('../src/vendor/cal-heatmap', () => ({
|
||||
} = require('../src/tooltip');
|
||||
|
||||
mockInitCallCount += 1;
|
||||
mockDateFormatter = config.dateFormatter;
|
||||
if (mockThrowOnInitCall === mockInitCallCount) {
|
||||
throw new Error('Mock CalHeatMap init failure');
|
||||
}
|
||||
@@ -284,9 +288,28 @@ afterEach(() => {
|
||||
mockThrowOnInitCall = null;
|
||||
mockDestroyCallCount = 0;
|
||||
mockDestroyedInstanceIds = [];
|
||||
mockDateFormatter = undefined;
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
test('Calendar provides a timezone-safe date formatter to CalHeatMap', () => {
|
||||
const calendarOwner = document.createElement('div');
|
||||
document.body.appendChild(calendarOwner);
|
||||
|
||||
Calendar(calendarOwner, {
|
||||
...createCalendarProps('localized-metric'),
|
||||
theme: mockTheme,
|
||||
});
|
||||
|
||||
if (!mockDateFormatter) {
|
||||
throw new Error('Expected Calendar to configure a date formatter');
|
||||
}
|
||||
|
||||
const localDate = new Date(convertUTCTimestampToLocal(Date.UTC(2024, 0, 1)));
|
||||
|
||||
expect(mockDateFormatter(localDate, '%Y-%m-%d')).toBe('2024-01-01');
|
||||
});
|
||||
|
||||
test('rerender and unmount clean up only the affected calendar tooltips', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 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 CalHeatMapImport from '../src/vendor/cal-heatmap';
|
||||
|
||||
type DateFormatter = (date: Date, format: string) => string;
|
||||
type FunctionalDateFormat = (date: Date) => string;
|
||||
|
||||
interface CalHeatMapInstance {
|
||||
options: {
|
||||
dateFormatter: DateFormatter | null;
|
||||
timeFormatter: (t: number) => string;
|
||||
valueFormatter: (v: number) => string;
|
||||
};
|
||||
formatDate(date: Date, format: string | FunctionalDateFormat): string;
|
||||
tip: { html(): (d: { t: number; v: number }) => string };
|
||||
legendTip: { html(): (d: number) => string };
|
||||
}
|
||||
|
||||
const CalHeatMap = CalHeatMapImport as unknown as new () => CalHeatMapInstance;
|
||||
|
||||
test('CalHeatMap delegates string date formats to the configured formatter', () => {
|
||||
const calendar = new CalHeatMap();
|
||||
const date = new Date(2024, 0, 1);
|
||||
const dateFormatter = jest.fn<string, [Date, string]>(() => 'Январь');
|
||||
calendar.options.dateFormatter = dateFormatter;
|
||||
|
||||
expect(calendar.formatDate(date, '%B')).toBe('Январь');
|
||||
expect(dateFormatter).toHaveBeenCalledWith(date, '%B');
|
||||
});
|
||||
|
||||
test('CalHeatMap preserves functional formatters over the configured formatter', () => {
|
||||
const calendar = new CalHeatMap();
|
||||
const date = new Date(2024, 0, 1);
|
||||
const dateFormatter = jest.fn<string, [Date, string]>(() => 'localized');
|
||||
const functionalFormat = jest.fn<string, [Date]>(() => 'custom');
|
||||
calendar.options.dateFormatter = dateFormatter;
|
||||
|
||||
expect(calendar.formatDate(date, functionalFormat)).toBe('custom');
|
||||
expect(functionalFormat).toHaveBeenCalledWith(date);
|
||||
expect(dateFormatter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('CalHeatMap keeps the D3 formatter fallback', () => {
|
||||
const calendar = new CalHeatMap();
|
||||
const date = new Date(2024, 0, 1);
|
||||
|
||||
expect(calendar.formatDate(date, '%B')).toBe('January');
|
||||
});
|
||||
|
||||
test('cell tooltip HTML escapes creator-controlled formatter output', () => {
|
||||
// Regression test: the tip's .html() callback is assigned to the
|
||||
// tooltip node via innerHTML (d3-tip), so formatter output must be
|
||||
// escaped before it's returned.
|
||||
const calendar = new CalHeatMap();
|
||||
calendar.options.timeFormatter = () => '<img src=x onerror=alert(1)>';
|
||||
calendar.options.valueFormatter = () => '<svg onload=alert(2)>';
|
||||
|
||||
const html = calendar.tip.html()({ t: 0, v: 1 });
|
||||
|
||||
expect(html).not.toContain('<img');
|
||||
expect(html).not.toContain('<svg');
|
||||
expect(html).toContain('<img');
|
||||
expect(html).toContain('<svg');
|
||||
});
|
||||
|
||||
test('legend tooltip HTML escapes creator-controlled formatter output', () => {
|
||||
const calendar = new CalHeatMap();
|
||||
calendar.options.valueFormatter = () => '<img src=x onerror=alert(1)>';
|
||||
|
||||
const html = calendar.legendTip.html()(1);
|
||||
|
||||
expect(html).not.toContain('<img');
|
||||
expect(html).toContain('<img');
|
||||
});
|
||||
@@ -17,7 +17,11 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { getFormattedUTCTime, convertUTCTimestampToLocal } from '../src/utils';
|
||||
import {
|
||||
getFormattedUTCTime,
|
||||
convertUTCTimestampToLocal,
|
||||
escapeHtml,
|
||||
} from '../src/utils';
|
||||
|
||||
test('getFormattedUTCTime formats local timestamp for display as UTC date', () => {
|
||||
const utcTimestamp = 1420070400000; // 2015-01-01 00:00:00 UTC
|
||||
@@ -87,3 +91,22 @@ test('convertUTCTimestampToLocal and getFormattedUTCTime work together to displa
|
||||
const formattedTime = getFormattedUTCTime(localTimestamp, '%Y-%m-%d');
|
||||
expect(formattedTime).toContain('2024-01-01');
|
||||
});
|
||||
|
||||
test('escapeHtml neutralizes markup smuggled through a time format string', () => {
|
||||
// Regression test: d3-time-format passes non-% characters through
|
||||
// verbatim, so escaping must happen before the innerHTML sink.
|
||||
const formatted = getFormattedUTCTime(
|
||||
1704067200000,
|
||||
'%Y <img src=x onerror=alert(1)>',
|
||||
);
|
||||
const escaped = escapeHtml(formatted);
|
||||
|
||||
expect(formatted).toContain('<img');
|
||||
expect(escaped).not.toContain('<img');
|
||||
expect(escaped).toContain('<img');
|
||||
});
|
||||
|
||||
test('escapeHtml stringifies non-string formatter output safely', () => {
|
||||
expect(escapeHtml(1234)).toEqual('1234');
|
||||
expect(escapeHtml('a & b < c')).toEqual('a & b < c');
|
||||
});
|
||||
|
||||
@@ -33,6 +33,27 @@ import { WmsLayerConf, WfsLayerConf, LayerConf, XyzLayerConf } from '../types';
|
||||
import { isWfsLayerConf, isWmsLayerConf, isXyzLayerConf } from '../typeguards';
|
||||
import { isVersionBelow } from './serviceUtil';
|
||||
|
||||
/**
|
||||
* Escape HTML special characters in a layer attribution string.
|
||||
*
|
||||
* OpenLayers' Attribution control renders attribution strings via innerHTML,
|
||||
* and the attribution here comes from creator-supplied chart form data, so it
|
||||
* must be treated as untrusted text rather than markup to prevent stored XSS.
|
||||
*
|
||||
* @param attribution The attribution string from the layer configuration
|
||||
*
|
||||
* @returns The attribution with HTML special characters escaped
|
||||
*/
|
||||
export const escapeAttribution = (attribution?: string): string | undefined =>
|
||||
attribution === undefined
|
||||
? undefined
|
||||
: attribution
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
/**
|
||||
* Create a WMS layer.
|
||||
*
|
||||
@@ -49,7 +70,7 @@ export const createWmsLayer = (wmsLayerConf: WmsLayerConf) => {
|
||||
LAYERS: layersParam,
|
||||
VERSION: version,
|
||||
},
|
||||
attributions: attribution,
|
||||
attributions: escapeAttribution(attribution),
|
||||
}),
|
||||
});
|
||||
};
|
||||
@@ -66,7 +87,7 @@ export const createXyzLayer = (xyzLayerConf: XyzLayerConf) => {
|
||||
return new TileLayer({
|
||||
source: new XyzSource({
|
||||
url,
|
||||
attributions: attribution,
|
||||
attributions: escapeAttribution(attribution),
|
||||
}),
|
||||
});
|
||||
};
|
||||
@@ -90,7 +111,7 @@ export const createWfsLayer = async (wfsLayerConf: WfsLayerConf) => {
|
||||
|
||||
const wfsSource = new VectorSource({
|
||||
format: new GeoJSON(),
|
||||
attributions: attribution,
|
||||
attributions: escapeAttribution(attribution),
|
||||
url: extent => {
|
||||
const requestUrl = new URL(url);
|
||||
const params = requestUrl.searchParams;
|
||||
|
||||
@@ -17,20 +17,65 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { WfsLayerConf } from '../../src/types';
|
||||
import { WfsLayerConf, WmsLayerConf, XyzLayerConf } from '../../src/types';
|
||||
import {
|
||||
createLayer,
|
||||
createWfsLayer,
|
||||
createWmsLayer,
|
||||
createXyzLayer,
|
||||
escapeAttribution,
|
||||
} from '../../src/util/layerUtil';
|
||||
|
||||
describe('layerUtil', () => {
|
||||
describe('escapeAttribution', () => {
|
||||
test('escapes HTML markup in attribution strings', () => {
|
||||
expect(escapeAttribution('(c) OSM <img src=x onerror=alert(1)>')).toBe(
|
||||
'(c) OSM <img src=x onerror=alert(1)>',
|
||||
);
|
||||
expect(escapeAttribution('a & "b" \'c\'')).toBe(
|
||||
'a & "b" 'c'',
|
||||
);
|
||||
expect(escapeAttribution(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createWmsLayer', () => {
|
||||
test('exists', () => {
|
||||
// function is trivial
|
||||
expect(createWmsLayer).toBeDefined();
|
||||
});
|
||||
|
||||
test('escapes HTML in the layer attribution', () => {
|
||||
const wmsLayerConf: WmsLayerConf = {
|
||||
title: 'wms',
|
||||
type: 'WMS',
|
||||
url: 'https://ows-demo.terrestris.de/geoserver/osm/wms',
|
||||
version: '1.3.0',
|
||||
layersParam: 'osm:osm-fuel',
|
||||
attribution: '(c) OSM <img src=x onerror=alert(1)>',
|
||||
};
|
||||
const layer = createWmsLayer(wmsLayerConf);
|
||||
const attributions = layer.getSource()?.getAttributions();
|
||||
expect(attributions?.(undefined as never)).toEqual([
|
||||
'(c) OSM <img src=x onerror=alert(1)>',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createXyzLayer', () => {
|
||||
test('escapes HTML in the layer attribution', () => {
|
||||
const xyzLayerConf: XyzLayerConf = {
|
||||
title: 'osm',
|
||||
type: 'XYZ',
|
||||
url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
attribution: '(c) OSM <img src=x onerror=alert(1)>',
|
||||
};
|
||||
const layer = createXyzLayer(xyzLayerConf);
|
||||
const attributions = layer.getSource()?.getAttributions();
|
||||
expect(attributions?.(undefined as never)).toEqual([
|
||||
'(c) OSM <img src=x onerror=alert(1)>',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createWfsLayer', () => {
|
||||
|
||||
@@ -30,12 +30,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"d3": "^3.5.17",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^19.2.7"
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*"
|
||||
"@superset-ui/core": "*",
|
||||
"react": "^18.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { getNumberFormatter } from '@superset-ui/core';
|
||||
import { render, fireEvent } from '../../../../spec/helpers/testing-library';
|
||||
import BigNumberVis from './BigNumberViz';
|
||||
|
||||
/**
|
||||
* Tests for the color threshold formatter logic in BigNumberViz.
|
||||
*
|
||||
@@ -83,3 +87,33 @@ describe('BigNumberViz color formatters', () => {
|
||||
expect(getColorFromValue).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('BigNumberViz context menu', () => {
|
||||
test('invokes onContextMenu and stops the event bubbling to ancestor handlers', () => {
|
||||
const onContextMenu = jest.fn();
|
||||
const ancestorHandler = jest.fn();
|
||||
|
||||
const { container } = render(
|
||||
<div onContextMenu={ancestorHandler}>
|
||||
<BigNumberVis
|
||||
width={200}
|
||||
height={100}
|
||||
bigNumber={42}
|
||||
headerFormatter={getNumberFormatter()}
|
||||
headerFontSize={0.3}
|
||||
subheaderFontSize={0.125}
|
||||
subtitleFontSize={0.125}
|
||||
subtitle=""
|
||||
refs={{}}
|
||||
onContextMenu={onContextMenu}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
|
||||
const headerLine = container.querySelector('.header-line');
|
||||
fireEvent.contextMenu(headerLine!, { clientX: 10, clientY: 20 });
|
||||
|
||||
expect(onContextMenu).toHaveBeenCalledWith(10, 20);
|
||||
expect(ancestorHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -224,6 +224,7 @@ function BigNumberVis({
|
||||
const handleContextMenu = (e: MouseEvent<HTMLDivElement>) => {
|
||||
if (onContextMenu) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onContextMenu(e.nativeEvent.clientX, e.nativeEvent.clientY);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 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 { SqlaFormData } from '@superset-ui/core';
|
||||
import transformProps from './transformProps';
|
||||
import { EchartsBubbleChartProps } from './types';
|
||||
|
||||
const baseFormData: SqlaFormData = {
|
||||
datasource: '1__table',
|
||||
viz_type: 'bubble_v2',
|
||||
entity: 'customer_name',
|
||||
x: 'price',
|
||||
y: 'sales',
|
||||
size: 'count',
|
||||
};
|
||||
|
||||
const baseChartProps = {
|
||||
width: 400,
|
||||
height: 400,
|
||||
hooks: {},
|
||||
queriesData: [
|
||||
{
|
||||
data: [
|
||||
{ customer_name: 'A', price: 10, sales: 100, count: 5 },
|
||||
{ customer_name: 'B', price: 20, sales: 200, count: 8 },
|
||||
],
|
||||
},
|
||||
],
|
||||
theme: { colorText: '#000' },
|
||||
};
|
||||
|
||||
test('nests xAxisLabelInterval under axisLabel rather than the axis itself', () => {
|
||||
// Regression test: xAxis.interval forces echarts' IntervalScale into a
|
||||
// fixed-tick-spacing mode that expects a number and crashes on the
|
||||
// 'auto'/'0' strings this control actually produces (observed as an
|
||||
// uncaught assertion deep in echarts' axis "nice" tick calculation,
|
||||
// reproducing on every dashboard bubble chart). The interval belongs on
|
||||
// axisLabel, where it only controls how many labels are skipped.
|
||||
const { echartOptions } = transformProps({
|
||||
...baseChartProps,
|
||||
formData: baseFormData,
|
||||
} as unknown as EchartsBubbleChartProps);
|
||||
|
||||
expect((echartOptions.xAxis as any).interval).toBeUndefined();
|
||||
expect((echartOptions.xAxis as any).axisLabel.interval).toBe('auto');
|
||||
});
|
||||
|
||||
test('honors an explicit xAxisLabelInterval override', () => {
|
||||
const { echartOptions } = transformProps({
|
||||
...baseChartProps,
|
||||
formData: { ...baseFormData, xAxisLabelInterval: '0' },
|
||||
} as unknown as EchartsBubbleChartProps);
|
||||
|
||||
expect((echartOptions.xAxis as any).axisLabel.interval).toBe('0');
|
||||
});
|
||||
@@ -212,13 +212,16 @@ export default function transformProps(chartProps: EchartsBubbleChartProps) {
|
||||
const echartOptions: EChartsCoreOption = {
|
||||
series,
|
||||
xAxis: {
|
||||
axisLabel: { formatter: xAxisFormatter, rotate: xAxisLabelRotation },
|
||||
axisLabel: {
|
||||
formatter: xAxisFormatter,
|
||||
rotate: xAxisLabelRotation,
|
||||
interval: xAxisLabelInterval,
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
type: 'dashed',
|
||||
},
|
||||
},
|
||||
interval: xAxisLabelInterval,
|
||||
scale: true,
|
||||
name: bubbleXAxisTitle,
|
||||
nameLocation: 'middle',
|
||||
|
||||
@@ -390,6 +390,7 @@ export default function transformProps(chartProps: EchartsGanttChartProps) {
|
||||
[GenericDataType.String]: undefined,
|
||||
[GenericDataType.Temporal]: tooltipTimeFormatter,
|
||||
[GenericDataType.Boolean]: undefined,
|
||||
[GenericDataType.MultiValue]: undefined,
|
||||
};
|
||||
|
||||
const echartOptions: EChartsCoreOption = {
|
||||
|
||||
+21
-8
@@ -40,6 +40,7 @@ import {
|
||||
TimeseriesChartDataResponseResult,
|
||||
TimeseriesDataRecord,
|
||||
tooltipHtml,
|
||||
truncateLabel,
|
||||
ValueFormatter,
|
||||
} from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
@@ -79,6 +80,7 @@ import {
|
||||
getAnnotationData,
|
||||
} from '../utils/annotation';
|
||||
import {
|
||||
collapseForecastKeys,
|
||||
extractForecastSeriesContext,
|
||||
extractForecastValuesFromTooltipParams,
|
||||
formatForecastTooltipSeries,
|
||||
@@ -207,6 +209,7 @@ export default function transformProps(
|
||||
zoomable,
|
||||
richTooltip,
|
||||
tooltipSortByMetric,
|
||||
tooltipTruncation,
|
||||
xAxisBounds,
|
||||
xAxisLabelRotation,
|
||||
xAxisLabelInterval,
|
||||
@@ -767,7 +770,9 @@ export default function transformProps(
|
||||
nameGap: xAxisTitleMarginPx,
|
||||
nameLocation: 'middle',
|
||||
axisLabel: {
|
||||
hideOverlap: !(xAxisType === AxisType.Time && xAxisLabelRotation !== 0),
|
||||
hideOverlap: showMaxLabel
|
||||
? false
|
||||
: !(xAxisType === AxisType.Time && xAxisLabelRotation !== 0),
|
||||
formatter: deduplicatedFormatter,
|
||||
rotate: xAxisLabelRotation,
|
||||
interval: xAxisLabelInterval,
|
||||
@@ -859,12 +864,14 @@ export default function transformProps(
|
||||
: params.value[0];
|
||||
const forecastValue: any[] = richTooltip ? params : [params];
|
||||
|
||||
const sortedKeys = extractTooltipKeys(
|
||||
forecastValue,
|
||||
// horizontal mode is not supported in mixed series chart
|
||||
1,
|
||||
richTooltip,
|
||||
tooltipSortByMetric,
|
||||
const sortedKeys = collapseForecastKeys(
|
||||
extractTooltipKeys(
|
||||
forecastValue,
|
||||
// horizontal mode is not supported in mixed series chart
|
||||
1,
|
||||
richTooltip,
|
||||
tooltipSortByMetric,
|
||||
),
|
||||
);
|
||||
|
||||
const rows: string[][] = [];
|
||||
@@ -907,13 +914,19 @@ export default function transformProps(
|
||||
formatter: primarySeries.has(key)
|
||||
? tooltipFormatter
|
||||
: tooltipFormatterSecondary,
|
||||
truncation: tooltipTruncation,
|
||||
});
|
||||
rows.push(row);
|
||||
if (key === focusedSeries) {
|
||||
focusedRow = rows.length - 1;
|
||||
}
|
||||
});
|
||||
return tooltipHtml(rows, tooltipFormatter(xValue), focusedRow);
|
||||
return tooltipHtml(
|
||||
rows,
|
||||
truncateLabel(tooltipFormatter(xValue), tooltipTruncation),
|
||||
focusedRow,
|
||||
tooltipTruncation,
|
||||
);
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
ContributionType,
|
||||
TimeFormatter,
|
||||
AxisType,
|
||||
TooltipTruncationMode,
|
||||
} from '@superset-ui/core';
|
||||
import {
|
||||
BaseChartProps,
|
||||
@@ -59,6 +60,7 @@ export type EchartsMixedTimeseriesFormData = QueryFormData & {
|
||||
timeGrainSqla?: TimeGranularity;
|
||||
forceMaxInterval?: boolean;
|
||||
tooltipTimeFormat?: string;
|
||||
tooltipTruncation?: TooltipTruncationMode;
|
||||
zoomable: boolean;
|
||||
richTooltip: boolean;
|
||||
showQueryIdentifiers?: boolean;
|
||||
@@ -108,6 +110,7 @@ export const DEFAULT_FORM_DATA: EchartsMixedTimeseriesFormData = {
|
||||
yAxisFormatSecondary: TIMESERIES_DEFAULTS.yAxisFormat,
|
||||
yAxisTitleSecondary: DEFAULT_TITLE_FORM_DATA.yAxisTitle,
|
||||
tooltipTimeFormat: TIMESERIES_DEFAULTS.tooltipTimeFormat,
|
||||
tooltipTruncation: TIMESERIES_DEFAULTS.tooltipTruncation,
|
||||
xAxisBounds: TIMESERIES_DEFAULTS.xAxisBounds,
|
||||
xAxisForceCategorical: TIMESERIES_DEFAULTS.xAxisForceCategorical,
|
||||
xAxisTimeFormat: TIMESERIES_DEFAULTS.xAxisTimeFormat,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user