Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31218987ec | ||
|
|
8f78f9eb9c | ||
|
|
930ba64fdf | ||
|
|
3fb58900b9 | ||
|
|
a63483b9b1 | ||
|
|
01d0772d4f | ||
|
|
3c7633f935 | ||
|
|
f7a2f0ec50 | ||
|
|
dc9547554b | ||
|
|
67bbe0ac17 | ||
|
|
6211f9936b | ||
|
|
5b28158519 | ||
|
|
117b92a517 | ||
|
|
e1468a709c | ||
|
|
c8cb09d458 | ||
|
|
ec136f6d8d | ||
|
|
d0658bacc8 | ||
|
|
b8ca729f9f | ||
|
|
de4def38cb | ||
|
|
25c2ca1127 | ||
|
|
d5ae93c9b8 | ||
|
|
fbeba10f75 | ||
|
|
c8305b0ba9 | ||
|
|
e1777737a7 | ||
|
|
762fdccfde | ||
|
|
5554b892ff | ||
|
|
e2bb33b1da | ||
|
|
59361581cd | ||
|
|
08dfca9631 | ||
|
|
276d6f04f5 | ||
|
|
524d99159a | ||
|
|
657a2a4cb2 | ||
|
|
eefb3e3835 | ||
|
|
07accd56f2 | ||
|
|
22caf221b7 | ||
|
|
de2276225a | ||
|
|
685f26b1bf | ||
|
|
739365979d | ||
|
|
bc1a8e0858 | ||
|
|
0915a39bcb | ||
|
|
8014f782d3 | ||
|
|
eb7d4cba42 | ||
|
|
01ecefd732 | ||
|
|
0c2f91968e | ||
|
|
ed696b9933 | ||
|
|
4baf1cf648 | ||
|
|
aefee48223 | ||
|
|
cc35056bc9 | ||
|
|
a0d7ec9faf | ||
|
|
a501fed560 | ||
|
|
4354b37b96 | ||
|
|
d840568f3b | ||
|
|
174d35380d | ||
|
|
2c10e6260f | ||
|
|
8f6587d0e6 | ||
|
|
b4f3fae288 | ||
|
|
8e455034d0 | ||
|
|
8734a232d8 | ||
|
|
56573fa2cd | ||
|
|
885f00130c | ||
|
|
584466e02b | ||
|
|
d570335f67 | ||
|
|
a0099af88f | ||
|
|
4f77538bc2 | ||
|
|
5bbd4c6d23 | ||
|
|
3b98869e0a | ||
|
|
e7fa8529ce | ||
|
|
c487c006e9 | ||
|
|
fd27a0c2fd | ||
|
|
42c6746339 | ||
|
|
fbd2e6032e | ||
|
|
e66b2f8be5 | ||
|
|
04bd07ad6e | ||
|
|
1c98c0711e | ||
|
|
13dd061907 | ||
|
|
bf47750682 | ||
|
|
990785cfa4 | ||
|
|
7174af24ca | ||
|
|
bbd021925a | ||
|
|
464674f193 | ||
|
|
c7a83794d2 | ||
|
|
dbc561f80f | ||
|
|
86003221bc | ||
|
|
9615e7c024 | ||
|
|
80c8d9f388 | ||
|
|
59f1d91b07 | ||
|
|
a4fc9ae493 | ||
|
|
5bbe3ac750 | ||
|
|
1aeff038d7 | ||
|
|
b73f6a8bd5 | ||
|
|
1859b375a7 | ||
|
|
b85468ee17 | ||
|
|
2261c61791 | ||
|
|
9fe31e3ec5 | ||
|
|
c7f0167a28 | ||
|
|
52b5110a53 | ||
|
|
a33ab097e1 | ||
|
|
05ce4d41bf | ||
|
|
8cd636bcd0 | ||
|
|
18231d9bd4 | ||
|
|
ca17830175 | ||
|
|
2be95c6283 | ||
|
|
6edeafff6b | ||
|
|
b2d2aa4452 | ||
|
|
09a034c573 | ||
|
|
f2fd536823 | ||
|
|
9c6b1f0eb6 | ||
|
|
f2055d8446 | ||
|
|
fbd088af48 | ||
|
|
d7c34215a2 | ||
|
|
a82128cde0 | ||
|
|
9ff15cb53e | ||
|
|
ac7f53326f | ||
|
|
feb224686b | ||
|
|
f93cc1a67b | ||
|
|
d2911672bf | ||
|
|
f9dfea8791 | ||
|
|
a5aa619668 | ||
|
|
dfc745c922 | ||
|
|
936ec951d5 | ||
|
|
acc93dfe33 | ||
|
|
a973290890 | ||
|
|
377e8e6345 | ||
|
|
71443460d1 | ||
|
|
dcda71e869 | ||
|
|
d59120a632 | ||
|
|
ba795ccfa0 | ||
|
|
6a5049daed | ||
|
|
83e7098ee8 | ||
|
|
b71ea77080 | ||
|
|
b3435a04cf | ||
|
|
1771f05d33 | ||
|
|
3dfe193ca0 | ||
|
|
c54abcfda8 | ||
|
|
b2849d826a | ||
|
|
2cd9873540 | ||
|
|
8948b5af94 | ||
|
|
3bcb003eeb | ||
|
|
480fbe0e06 | ||
|
|
3abbe1c84a | ||
|
|
311b0087f3 | ||
|
|
0fea3c88a5 | ||
|
|
e61610bd98 | ||
|
|
9729306c06 | ||
|
|
93a02449db | ||
|
|
cbbcf1bed2 | ||
|
|
218c64296f | ||
|
|
39eced2c2e | ||
|
|
bef4680ffd | ||
|
|
5870259219 | ||
|
|
a4bcd2cc87 | ||
|
|
f8dd65cc0b | ||
|
|
b1bc8fa8f2 | ||
|
|
5d86215a9d | ||
|
|
f866f25546 | ||
|
|
3392576923 | ||
|
|
e95b37450e | ||
|
|
ff73f2c070 | ||
|
|
612fd0ac5d | ||
|
|
83dbdfa355 | ||
|
|
fc3872fe9e | ||
|
|
0946df50a1 | ||
|
|
d9c3a2cf4c | ||
|
|
f84d7c664e | ||
|
|
45fd4117a9 | ||
|
|
79acc3a712 | ||
|
|
62d0cfb6d3 | ||
|
|
b885657ad0 | ||
|
|
a191f72698 | ||
|
|
498400e974 | ||
|
|
b9a8321802 | ||
|
|
902ebcdf72 | ||
|
|
f76471f30f | ||
|
|
b671356748 | ||
|
|
ff273478ff | ||
|
|
f1cc9367de | ||
|
|
02e4b4be7d | ||
|
|
ecb4aa93f6 | ||
|
|
4e1913d436 | ||
|
|
b6ae8fb25d | ||
|
|
10f2461b5f | ||
|
|
4e715f66dc | ||
|
|
6dfae09cc5 | ||
|
|
a4f74b09bb | ||
|
|
9f39fe3e10 | ||
|
|
c6f8706aa1 | ||
|
|
07a6406d97 | ||
|
|
a585a0901e | ||
|
|
fff5351a33 | ||
|
|
b4f8d87a49 | ||
|
|
3256ee4fbb | ||
|
|
e5b3ba9efc | ||
|
|
ae93dc2041 | ||
|
|
792d0fd2b6 | ||
|
|
8dcb83fd3c | ||
|
|
8f07ab4840 | ||
|
|
89d64b89b8 | ||
|
|
4ce589dcda | ||
|
|
a31210df08 | ||
|
|
00e95dcb87 | ||
|
|
c9960002f1 | ||
|
|
dbac244857 | ||
|
|
4483cb09f0 | ||
|
|
28d977bc0c | ||
|
|
b248c4d7c0 | ||
|
|
3e9fdc4cdf | ||
|
|
387b742303 | ||
|
|
533f90e4c6 | ||
|
|
b887c30573 | ||
|
|
2cd369d02a | ||
|
|
4b3bbe19f6 | ||
|
|
28eae5956e | ||
|
|
239e3a4323 | ||
|
|
573054346f | ||
|
|
a42c7e3be0 | ||
|
|
935b4acdbd | ||
|
|
bba2d5f812 | ||
|
|
7d45b8ae7e | ||
|
|
5942d570c3 | ||
|
|
45adb8429d | ||
|
|
3a03f0a820 | ||
|
|
105f6d0bc7 | ||
|
|
c8d6faf7e8 | ||
|
|
ae15dd883d | ||
|
|
2afa775db5 | ||
|
|
e17d49bec0 | ||
|
|
68324050cd | ||
|
|
2a55ad77f4 | ||
|
|
cd535f89e2 | ||
|
|
8b2c07f5f3 | ||
|
|
a9045296d9 | ||
|
|
32cbc4a7a6 | ||
|
|
4f47eda5de | ||
|
|
ba2b0d6ea3 | ||
|
|
df176cfa57 | ||
|
|
66e911eeea | ||
|
|
759457264d | ||
|
|
5722b96280 | ||
|
|
deb6cacea7 | ||
|
|
0f8e2a274d | ||
|
|
7e0de356d4 | ||
|
|
4e93b58a61 | ||
|
|
45b232cba0 | ||
|
|
bcc58d27f8 | ||
|
|
b5df673d21 |
@@ -79,6 +79,7 @@ github:
|
||||
- lint-check
|
||||
- cypress-matrix-required
|
||||
- dependency-review
|
||||
- enforce-single-migration-head
|
||||
- frontend-build
|
||||
- playwright-tests-required
|
||||
- pre-commit (current)
|
||||
|
||||
@@ -1,22 +1,3 @@
|
||||
# Notify all committers of DB migration changes, per SIP-59
|
||||
|
||||
/superset/migrations/ @mistercrunch @michael-s-molina @betodealmeida @eschutho @sadpandajoe @rusackas
|
||||
|
||||
# Notify some committers of changes in the components
|
||||
|
||||
/superset-frontend/src/components/Select/ @michael-s-molina @geido @kgabryje
|
||||
/superset-frontend/src/components/MetadataBar/ @michael-s-molina @geido @kgabryje
|
||||
/superset-frontend/src/components/DropdownContainer/ @michael-s-molina @geido @kgabryje
|
||||
|
||||
# Notify Helm Chart maintainers about changes in it
|
||||
|
||||
/helm/superset/ @dpgaspar @villebro @nytai @michael-s-molina @mistercrunch @rusackas @Antonio-RiveroMartnez @hainenber
|
||||
|
||||
# Notify E2E test maintainers of changes
|
||||
|
||||
/superset-frontend/playwright/ @sadpandajoe @geido @eschutho @rusackas @mistercrunch
|
||||
/superset-frontend/cypress-base/ @sadpandajoe @geido @eschutho @rusackas @mistercrunch
|
||||
|
||||
# Notify PMC members of changes to GitHub Actions
|
||||
|
||||
/.github/ @villebro @geido @eschutho @rusackas @betodealmeida @nytai @mistercrunch @kgabryje @sha174n @dpgaspar @sadpandajoe @hainenber
|
||||
@@ -30,23 +11,19 @@
|
||||
|
||||
/.asf.yaml @villebro @geido @eschutho @rusackas @betodealmeida @nytai @mistercrunch @kgabryje @dpgaspar @sha174n @Antonio-RiveroMartnez
|
||||
|
||||
# Maps are a finicky contribution process we care about
|
||||
# Maps are fragile and political. GeoJson edits MUST be made in the Jupyter notebook or they'll be overwritten.
|
||||
|
||||
**/*.geojson @villebro @rusackas
|
||||
**/*.ipynb @villebro @rusackas
|
||||
/superset-frontend/plugins/plugin-chart-country-map/ @villebro @rusackas
|
||||
|
||||
# Notify translation maintainers of changes to translations
|
||||
|
||||
/superset/translations/ @sfirke @rusackas @villebro @sadpandajoe @hainenber
|
||||
|
||||
# Notify PMC members of changes to extension-related files
|
||||
|
||||
/docs/developer_portal/extensions/ @michael-s-molina @villebro @rusackas
|
||||
/superset-core/ @michael-s-molina @villebro @geido @eschutho @rusackas @kgabryje
|
||||
/superset-extensions-cli/ @michael-s-molina @villebro @geido @eschutho @rusackas @kgabryje
|
||||
/superset/core/ @michael-s-molina @villebro @geido @eschutho @rusackas @kgabryje
|
||||
/superset/extensions/ @michael-s-molina @villebro @geido @eschutho @rusackas @kgabryje
|
||||
/superset-frontend/src/packages/superset-core/ @michael-s-molina @villebro @geido @eschutho @rusackas @kgabryje
|
||||
/superset-frontend/src/core/ @michael-s-molina @villebro @geido @eschutho @rusackas @kgabryje
|
||||
/superset-frontend/src/extensions/ @michael-s-molina @villebro @geido @eschutho @rusackas @kgabryje
|
||||
/superset-extensions-cli/ @michael-s-molina @villebro @rusackas @sadpandajoe
|
||||
/superset/extensions/ @michael-s-molina @villebro @rusackas @sadpandajoe
|
||||
/superset-frontend/src/extensions/ @michael-s-molina @villebro @rusackas @sadpandajoe
|
||||
|
||||
# Notify PMC members of config changes e.g. feature flags
|
||||
|
||||
/superset/config.py @michael-s-molina @villebro @rusackas @sadpandajoe
|
||||
|
||||
@@ -5,10 +5,6 @@ inputs:
|
||||
description: 'Python version to set up. Accepts a version number, "current", or "next".'
|
||||
required: true
|
||||
default: 'current'
|
||||
cache:
|
||||
description: 'Cache dependencies. Options: pip'
|
||||
required: false
|
||||
default: 'pip'
|
||||
requirements-type:
|
||||
description: 'Type of requirements to install. Options: base, development, default'
|
||||
required: false
|
||||
@@ -43,17 +39,31 @@ runs:
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: ${{ steps.set-python-version.outputs.python-version }}
|
||||
cache: ${{ inputs.cache }}
|
||||
- name: Install uv
|
||||
if: inputs.install-superset == 'true'
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
python-version: ${{ steps.set-python-version.outputs.python-version }}
|
||||
enable-cache: true
|
||||
- name: Update apt package lists
|
||||
# cache-apt-pkgs-action assumes a fresh `apt-cache` index (true on GitHub-hosted
|
||||
# runners, not on all self-hosted/custom runner images), so refresh it explicitly
|
||||
# or package lookups silently resolve to an empty list.
|
||||
if: inputs.install-superset == 'true'
|
||||
shell: bash
|
||||
run: sudo apt-get update
|
||||
- name: Install apt packages
|
||||
if: inputs.install-superset == 'true'
|
||||
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
|
||||
with:
|
||||
packages: libldap2-dev libsasl2-dev
|
||||
version: 1.0
|
||||
- name: Install dependencies
|
||||
env:
|
||||
INPUT_INSTALL_SUPERSET: ${{ inputs.install-superset }}
|
||||
INPUT_REQUIREMENTS_TYPE: ${{ inputs.requirements-type }}
|
||||
run: |
|
||||
if [ "$INPUT_INSTALL_SUPERSET" = "true" ]; then
|
||||
sudo apt-get update && sudo apt-get -y install libldap2-dev libsasl2-dev
|
||||
|
||||
pip install --upgrade pip setuptools wheel uv
|
||||
|
||||
if [ "$INPUT_REQUIREMENTS_TYPE" = "dev" ]; then
|
||||
uv pip install --system -r requirements/development.txt
|
||||
elif [ "$INPUT_REQUIREMENTS_TYPE" = "base" ]; then
|
||||
|
||||
@@ -45,7 +45,10 @@ jobs:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install uv
|
||||
run: pip install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
enable-cache: true
|
||||
|
||||
- name: supersetbot bump-python -p "${{ github.event.inputs.package }}"
|
||||
env:
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# 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.
|
||||
name: Enforce single Alembic migration head
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "master"
|
||||
- "[0-9].[0-9]*"
|
||||
pull_request:
|
||||
types: [synchronize, opened, reopened, ready_for_review]
|
||||
|
||||
# No `paths:` filter on purpose: this job is a required status check, and a
|
||||
# required check that never runs for a given PR blocks that PR from merging
|
||||
# forever. It has to fire on every PR so it always reports a status; whether
|
||||
# migrations changed is decided inside the job, not the trigger.
|
||||
|
||||
# cancel previous workflow jobs for PRs
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
enforce-single-migration-head:
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check for migration file changes
|
||||
id: check
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
script: |
|
||||
if (context.eventName === 'push') {
|
||||
core.setOutput('changed', 'true');
|
||||
return;
|
||||
}
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number,
|
||||
});
|
||||
const changed = files.some((f) => f.filename.startsWith('superset/migrations/'));
|
||||
core.setOutput('changed', String(changed));
|
||||
- name: Setup Python
|
||||
if: steps.check.outputs.changed == 'true'
|
||||
uses: ./.github/actions/setup-backend/
|
||||
with:
|
||||
requirements-type: base
|
||||
- name: Assert a single Alembic head
|
||||
if: steps.check.outputs.changed == 'true'
|
||||
env:
|
||||
SUPERSET__SQLALCHEMY_DATABASE_URI: "sqlite:///:memory:"
|
||||
run: |
|
||||
heads="$(superset db heads)"
|
||||
echo "$heads"
|
||||
head_count=$(printf '%s\n' "$heads" | grep -c .)
|
||||
if [ "$head_count" -ne 1 ]; then
|
||||
echo "::error::superset/migrations resolves to $head_count Alembic heads (expected exactly 1)."
|
||||
echo "Another migration already landed with the same down_revision this branch was cut from."
|
||||
echo "Add a no-op merge revision joining the heads: https://superset.apache.org/docs/contributing/development#merging-db-migrations"
|
||||
exit 1
|
||||
fi
|
||||
@@ -53,6 +53,15 @@ jobs:
|
||||
- name: Install helm-docs
|
||||
run: go install github.com/norwoodj/helm-docs/cmd/helm-docs@v1.14.2
|
||||
|
||||
# Spike: run the existing .pre-commit-config.yaml through prek (a Rust
|
||||
# reimplementation of pre-commit) instead of pre-commit itself, to see
|
||||
# whether it's viable to speed up this job. CI-only — contributors keep
|
||||
# installing/running `pre-commit` locally exactly as documented; nothing
|
||||
# here changes that.
|
||||
- name: Install prek
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/j178/prek/releases/download/v0.4.11/prek-installer.sh | sh
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
@@ -70,13 +79,13 @@ jobs:
|
||||
cd docs
|
||||
yarn install --immutable
|
||||
|
||||
- name: Cache pre-commit environments
|
||||
- name: Cache prek environments
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ~/.cache/pre-commit
|
||||
key: pre-commit-v2-${{ runner.os }}-py${{ matrix.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }}
|
||||
path: ~/.cache/prek
|
||||
key: prek-v1-${{ runner.os }}-py${{ matrix.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }}
|
||||
restore-keys: |
|
||||
pre-commit-v2-${{ runner.os }}-py${{ matrix.python-version }}-
|
||||
prek-v1-${{ runner.os }}-py${{ matrix.python-version }}-
|
||||
|
||||
- name: Determine changed files
|
||||
id: changed_files
|
||||
@@ -142,7 +151,7 @@ jobs:
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: pre-commit
|
||||
- name: pre-commit (via prek)
|
||||
env:
|
||||
MODE: ${{ steps.changed_files.outputs.mode }}
|
||||
CHANGED_FILES: ${{ steps.changed_files.outputs.files }}
|
||||
@@ -152,22 +161,22 @@ jobs:
|
||||
|
||||
case "${MODE}" in
|
||||
all)
|
||||
echo "ℹ️ Running pre-commit on all files."
|
||||
pre-commit run --all-files
|
||||
echo "ℹ️ Running prek on all files."
|
||||
prek run --all-files
|
||||
;;
|
||||
files)
|
||||
echo "ℹ️ Running pre-commit on changed files:"
|
||||
echo "ℹ️ Running prek on changed files:"
|
||||
echo "${CHANGED_FILES}"
|
||||
# shellcheck disable=SC2086
|
||||
pre-commit run --files ${CHANGED_FILES}
|
||||
prek run --files ${CHANGED_FILES}
|
||||
;;
|
||||
none)
|
||||
echo "ℹ️ No source files changed; nothing for pre-commit to check."
|
||||
echo "ℹ️ No source files changed; nothing for prek to check."
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "⚠️ Unrecognized changed-files mode '${MODE}'; checking all files."
|
||||
pre-commit run --all-files
|
||||
prek run --all-files
|
||||
;;
|
||||
esac
|
||||
PRE_COMMIT_EXIT_CODE=$?
|
||||
|
||||
@@ -81,7 +81,11 @@ jobs:
|
||||
distribution: "zulu"
|
||||
java-version: "21"
|
||||
- name: Install Graphviz
|
||||
run: sudo apt-get install -y graphviz
|
||||
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
|
||||
with:
|
||||
packages: graphviz
|
||||
version: 1.0
|
||||
execute_install_scripts: true
|
||||
- name: Compute Entity Relationship diagram (ERD)
|
||||
env:
|
||||
SUPERSET_SECRET_KEY: not-a-secret
|
||||
|
||||
@@ -155,6 +155,17 @@ jobs:
|
||||
INCLUDE_EMBEDDED: "true"
|
||||
with:
|
||||
run: playwright-run "${{ matrix.app_root }}" embedded
|
||||
- name: Run Playwright (Mobile Tests)
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
env:
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
# Scoped to this step for the same reason as the embedded flags
|
||||
# above: the mobile consumption mode should not alter Flask's
|
||||
# configuration for the required desktop test steps.
|
||||
SUPERSET_FEATURE_MOBILE_CONSUMPTION_MODE: "true"
|
||||
INCLUDE_MOBILE: "true"
|
||||
with:
|
||||
run: playwright-run "${{ matrix.app_root }}" mobile/
|
||||
- name: Set safe app root
|
||||
if: failure()
|
||||
id: set-safe-app-root
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
name: Python Unit Test Results
|
||||
|
||||
on:
|
||||
# zizmor: ignore[dangerous-triggers] - runs in base-branch context and only consumes artifacts uploaded by Python-Unit; never checks out PR code (see note below)
|
||||
workflow_run:
|
||||
workflows: ["Python-Unit"]
|
||||
types: [completed]
|
||||
|
||||
# This workflow publishes a check run annotating failing Python unit tests
|
||||
# inline on the PR diff, using JUnit XML uploaded by the Python-Unit workflow.
|
||||
# It uses the workflow_run trigger so that it always runs in the base-branch
|
||||
# context and can safely be granted write permissions, even for PRs from
|
||||
# forks or Dependabot.
|
||||
#
|
||||
# IMPORTANT: This workflow must NEVER check out code from the PR branch. All
|
||||
# data comes from artifacts uploaded by the Python-Unit workflow.
|
||||
permissions:
|
||||
contents: read
|
||||
checks: write
|
||||
issues: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
report:
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 10
|
||||
if: >
|
||||
github.event.workflow_run.conclusion == 'success' ||
|
||||
github.event.workflow_run.conclusion == 'failure'
|
||||
steps:
|
||||
# Fails soft (continue-on-error) because the source unit-tests job is
|
||||
# itself gated on change detection: a docs-only PR skips it entirely,
|
||||
# so there is nothing to download or report on.
|
||||
- name: Download JUnit results
|
||||
id: download
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
# merge-multiple is intentionally omitted: each matrix leg's
|
||||
# artifact (junit-results-current, junit-results-next) uses the
|
||||
# same XML filenames, so merging them into one directory would let
|
||||
# one Python version's results overwrite the other's. Downloading
|
||||
# into per-artifact subdirectories keeps both, and the glob below
|
||||
# is recursive so it still picks up every XML file.
|
||||
pattern: "junit-results-*"
|
||||
path: artifacts
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Download event file
|
||||
id: download-event
|
||||
if: steps.download.outcome == 'success'
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: "Event File"
|
||||
path: event
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Publish test results
|
||||
if: steps.download.outcome == 'success' && steps.download-event.outcome == 'success'
|
||||
uses: EnricoMi/publish-unit-test-result-action@d0a4676d0e0b938bc201470d88276b7c74c712b3 # v2.24.0
|
||||
with:
|
||||
commit: ${{ github.event.workflow_run.head_sha }}
|
||||
event_file: event/event.json
|
||||
event_name: ${{ github.event.workflow_run.event }}
|
||||
files: "artifacts/**/*.xml"
|
||||
check_name: "Python Unit Test Results"
|
||||
comment_mode: "off"
|
||||
@@ -74,14 +74,14 @@ jobs:
|
||||
SUPERSET_TESTENV: true
|
||||
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
|
||||
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
|
||||
- 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
|
||||
pytest --durations-min=0.5 --cov=superset/semantic_layers/ ./tests/unit_tests/semantic_layers/ --cache-clear --cov-fail-under=100
|
||||
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
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
with:
|
||||
@@ -89,6 +89,33 @@ jobs:
|
||||
verbose: true
|
||||
use_oidc: true
|
||||
slug: apache/superset
|
||||
# Uploaded even when a pytest step above fails, since that is exactly
|
||||
# when the JUnit results are needed downstream, to annotate the PR with
|
||||
# the failing tests. Consumed by the "Python Unit Test Results" workflow
|
||||
# via workflow_run (see that workflow for why it can't just be a step
|
||||
# here: it needs to run with write permissions, which this PR-triggered
|
||||
# job can't safely have on a fork PR).
|
||||
- name: Upload JUnit test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: junit-results-${{ matrix.python-version }}
|
||||
path: test-results/
|
||||
retention-days: 7
|
||||
|
||||
# Uploads the raw pull_request event payload so the "Python Unit Test
|
||||
# Results" workflow (running via workflow_run, in base-branch context) can
|
||||
# look up which PR/commit to annotate without checking out untrusted code.
|
||||
event-file:
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Upload event file
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: Event File
|
||||
path: ${{ github.event_path }}
|
||||
retention-days: 7
|
||||
|
||||
# Stable required-status-check anchor. `unit-tests` is a matrix job gated on
|
||||
# change detection, so on non-Python PRs it is skipped and never produces its
|
||||
|
||||
@@ -78,7 +78,10 @@ jobs:
|
||||
|
||||
- name: Install gettext tools
|
||||
if: steps.check.outputs.python == 'true' || steps.check.outputs.frontend == 'true'
|
||||
run: sudo apt-get update && sudo apt-get install -y gettext
|
||||
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
|
||||
with:
|
||||
packages: gettext
|
||||
version: 1.0
|
||||
|
||||
# Fetch the base ref so we can compare PR-introduced regressions
|
||||
# against a fair baseline (also runs babel_update against the base
|
||||
|
||||
@@ -24,8 +24,16 @@ assists people when migrating to a new version.
|
||||
|
||||
## Next
|
||||
|
||||
### OAuth2 database callback metrics include their outcome
|
||||
|
||||
The unqualified `DatabaseRestApi.oauth2` StatsD counter has been replaced with
|
||||
`DatabaseRestApi.oauth2.success`, `DatabaseRestApi.oauth2.warning`, and
|
||||
`DatabaseRestApi.oauth2.error`. Update monitoring rules and dashboards that consume
|
||||
the old counter to use the outcome-specific replacements.
|
||||
|
||||
- [42935](https://github.com/apache/superset/pull/42935): The MCP service now refuses to start (`MCPAuthConfigError`) when `MCP_JWT_ISSUER` trusts more than one issuer and no `MCP_USER_RESOLVER` is configured, instead of only logging a warning. This was already a documented misconfiguration (the default resolver isn't issuer-scoped, so distinct trusted issuers minting the same username/email would resolve to the same Superset user); deployments trusting multiple issuers must configure an `MCP_USER_RESOLVER` that derives its identity from the token's `iss` claim before upgrading. Single-issuer deployments are unaffected.
|
||||
- [42393](https://github.com/apache/superset/pull/42393): Exported dataset YAML now carries a `uuid` for each metric and column so that custom folder assignments (which reference metrics/columns by UUID) survive an import into another workspace. This affects any export bundle that contains datasets, not just a dataset export: chart, dashboard, database and full-asset exports all embed the same dataset YAML, so a dashboard exported from this release also fails to import into an older one even though no dataset was exported directly. As with `folders` and `currency_code_column`, the affected `datasets/` files fail schema validation (`Unknown field: uuid`) when imported into Superset releases that predate this change; regenerate or hand-edit exports for older targets in mixed-version fleets.
|
||||
- [42300](https://github.com/apache/superset/pull/42300): Timeseries charts (line/area/bar) with a Y-axis bound in effect — either an explicit `yAxisBounds` or one derived from `truncateYAxis` — now clamp out-of-range data points to that bound instead of letting ECharts drop the point (and the line segments around it) entirely. Any existing chart with a configured Y-axis bound and data outside it will look different after upgrading: a gap becomes a point pinned to the boundary. The clamp also rewrites the value ECharts reads for that point's tooltip and data label, so the displayed value is the bound rather than the true observation.
|
||||
- [42087](https://github.com/apache/superset/pull/42087): Stored calculated-column and metric expressions are validated when a query is built, under the same sub-query policy already applied to adhoc expressions. Previously only the dataset update path checked them on save, so expressions written by v1 import, by dataset duplication, or before that check existed were never validated. Since `ALLOW_ADHOC_SUBQUERY` defaults to `False` (see [19242](https://github.com/apache/superset/pull/19242)), a dataset whose stored expression contains a sub-query works before upgrading and afterwards fails at chart render with `Custom SQL fields cannot contain sub-queries.` There is no migration step, and the error does not name the offending dataset column, so audit stored expressions before upgrading: either rewrite them without the sub-query, or set `ALLOW_ADHOC_SUBQUERY = True` to keep the previous behaviour for both stored and adhoc expressions.
|
||||
|
||||
### Selenium support removed — Playwright is now required for screenshots
|
||||
@@ -54,6 +62,43 @@ pip install playwright && playwright install chromium
|
||||
2. Remove any references to the removed config keys from custom `superset_config.py`
|
||||
3. If you subclassed `MachineAuthProvider`, remove any `authenticate_webdriver` override and migrate auth logic to `authenticate_browser_context`
|
||||
|
||||
### CSV/XLSX report exports of Table charts keep raw numeric values
|
||||
|
||||
Table and Pivot Table charts sent as text in a report email now apply the
|
||||
chart's number and currency formatting so the values match what a user sees in
|
||||
Explore. As part of this, the CSV and XLSX result formats return early before
|
||||
formatting: previously the Table post-processor applied `d3NumberFormat` to
|
||||
every result format, so CSV/XLSX exports contained pre-formatted strings.
|
||||
CSV/XLSX exports now preserve numeric values and column types, which is better
|
||||
for downstream analysis but is a visible change for anyone who relied on the
|
||||
formatted text in those files. The rendered email body (the only place the
|
||||
formatting is intended for) is unaffected.
|
||||
|
||||
### SQLAlchemy bumped to 2.0, flask-sqlalchemy to 3.1.1
|
||||
|
||||
Superset's core ORM dependencies move from SQLAlchemy 1.4 to 2.0 and
|
||||
flask-sqlalchemy `<3.0` to 3.1.1, completing the migration tracked in
|
||||
[discussion #40273](https://github.com/apache/superset/discussions/40273).
|
||||
|
||||
**Custom `db_engine_specs`, plugins, or extensions that import SQLAlchemy
|
||||
internals directly** should review the
|
||||
[SQLAlchemy 1.4-to-2.0 migration guide](https://docs.sqlalchemy.org/en/20/changelog/migration_20.html)
|
||||
for API changes that affect them — most 1.4 code already runs unmodified
|
||||
under 2.0's compatibility mode, but patterns like `Engine.execute()`,
|
||||
string-keyed `Row` access, and `MetaData(bind=)` are removed outright.
|
||||
|
||||
**Several optional DB-connector extras remain capped below their
|
||||
SQLAlchemy-2.0-only releases**, either because that bump is a separate
|
||||
follow-up ([#42891](https://github.com/apache/superset/pull/42891): dremio,
|
||||
exasol, firebird, redshift, risingwave) or because the upstream dialect
|
||||
package has no SQLAlchemy 2.0 support yet at all (aurora-data-api, d1,
|
||||
kusto, solr; ocient's 2.0 compatibility is unverified). Installing one of
|
||||
these extras continues to pull a SQLAlchemy-1.4-line version of that
|
||||
dialect; each package's constraint in `pyproject.toml` documents why.
|
||||
|
||||
No application-level configuration changes are required for deployments
|
||||
that don't touch SQLAlchemy directly.
|
||||
|
||||
### Soft delete is on by default, and purging is live
|
||||
|
||||
`SOFT_DELETE` now ships **on** (`DEFAULT_FEATURE_FLAGS`), so deleting a
|
||||
@@ -159,6 +204,36 @@ will now get a TypeScript error and must remove the prop; keeping a manual
|
||||
override was exactly the footgun this change removes (see #42510). No
|
||||
callers in the Superset frontend codebase itself passed this prop.
|
||||
|
||||
### Row-level security now filters table reads a same-named CTE used to hide
|
||||
|
||||
`extract_tables_from_statement()` decided whether a reference was a CTE by matching its
|
||||
bare name against the enclosing scope's CTE names; it now resolves the name through
|
||||
`Scope.cte_sources`. Three kinds of real table read whose bare name collided with a CTE's
|
||||
were mistaken for the CTE and dropped from a statement's tables, so they were neither
|
||||
RLS-filtered nor access-checked: a schema- or catalog-qualified reference, a non-recursive
|
||||
CTE's own name inside its body, and a forward reference to a later `WITH` item.
|
||||
|
||||
```sql
|
||||
WITH orders AS (SELECT 1 AS d) SELECT * FROM (SELECT * FROM public.orders) AS z
|
||||
WITH orders AS (SELECT * FROM orders) SELECT * FROM orders
|
||||
WITH q1 AS (SELECT key FROM q2), q2 AS (SELECT 1 AS key) SELECT * FROM q1
|
||||
```
|
||||
|
||||
Each read is now reported, so it is filtered when `RLS_IN_SQLLAB` is enabled, matched
|
||||
against `DISALLOWED_SQL_TABLES`, and requires dataset access under
|
||||
`raise_for_access(force_dataset_match=True)`. A query that previously ran, reading those
|
||||
rows unfiltered, may now be filtered or rejected. There is no opt-out — the previous
|
||||
behavior was a row-level-security bypass.
|
||||
|
||||
### Table aliases keep their quoting through the row-level security rewrite
|
||||
|
||||
Both RLS transformers took the table alias as a string with its quoting stripped and
|
||||
emitted it verbatim; they now carry the parsed identifier. Emitted SQL is unchanged for an
|
||||
unquoted identifier; a quoted one keeps its quoting, and a column-alias list
|
||||
(`FROM t AS x (c1, c2)`) survives the rewrite instead of being dropped. This repairs
|
||||
row-level security for any aliased table on Snowflake, and for at least one statement shape
|
||||
on MSSQL where the rewrite previously raised `AttributeError`.
|
||||
|
||||
### Principal listing APIs now honour related-field filters
|
||||
|
||||
Two authorization-related listing behaviors changed for API clients. Neither
|
||||
@@ -828,6 +903,8 @@ With the flag enabled: `DELETE /api/v1/chart/<id>` no longer hard-deletes the ch
|
||||
|
||||
- [39914](https://github.com/apache/superset/pull/39914) `ALERT_REPORT_SLACK_V2` now defaults to `True` and the legacy Slack v1 integration (`Slack` recipient type, `files.upload` API) is deprecated for removal in the next major. Slack blocked new apps from `files.upload` in May 2024 and fully retired the method for all apps on November 12, 2025; because the v1 path sends files through `files.upload`, v1 file-bearing sends now fail at the API level — only text-only `chat_postMessage` still works via the legacy path. Grant your Slack bot the `channels:read` and `groups:read` scopes so existing `Slack` recipients can be auto-upgraded to `SlackV2` on next send. Operators who explicitly override the flag to `False`, or whose Slack bot is missing those scopes, will see deprecation warnings while text-only sends continue through the legacy path.
|
||||
|
||||
- [42089](https://github.com/apache/superset/pull/42089) automatically upgrades resolvable Slack v1 recipients, preserves text-only v1 delivery with execution warnings when migration cannot finish, and rejects retired v1 file uploads with actionable scope guidance. Slack delivery uses at-most-once terminal writes and a schedule-wide retry budget configured by `SLACK_SEND_RETRY_MAX_TIME`, clamped to the report's remaining working timeout. Deployments using `SupersetMetastoreCache` for the Slack channel cache must schedule the `slack.cache_channels` Celery task to repopulate misses outside report transactions; see [Alerts and Reports](https://superset.apache.org/admin-docs/configuration/alerts-reports#slack-delivery-timeouts-and-retries).
|
||||
|
||||
### Soft delete and restore for dashboards
|
||||
|
||||
**Everything in this section applies only when the `SOFT_DELETE` feature flag is enabled. The flag defaults to `False`** (`@lifecycle: development`), so on a default deployment `DELETE /api/v1/dashboard/<id>` continues to **hard-delete permanently** — nothing is recoverable. Enable `SOFT_DELETE` to get the behavior described below.
|
||||
|
||||
@@ -118,6 +118,7 @@ FEATURE_FLAGS = {
|
||||
"ALERT_REPORTS": True,
|
||||
"DATASET_FOLDERS": True,
|
||||
"ENABLE_EXTENSIONS": True,
|
||||
"MOBILE_CONSUMPTION_MODE": True,
|
||||
"SEMANTIC_LAYERS": True,
|
||||
}
|
||||
EXTENSIONS_PATH = "/app/docker/extensions"
|
||||
|
||||
@@ -83,6 +83,28 @@ SLACK_CACHE_TIMEOUT = int(timedelta(days=2).total_seconds())
|
||||
SLACK_API_RATE_LIMIT_RETRY_COUNT = 5
|
||||
```
|
||||
|
||||
When the cache backend is `SupersetMetastoreCache`, report execution does not
|
||||
write channel listings into the cache because that backend commits the report's
|
||||
database session. Schedule the dedicated warm-up task so cache misses are
|
||||
repopulated outside report transactions:
|
||||
|
||||
```python
|
||||
from celery.schedules import crontab
|
||||
|
||||
from superset.config import CeleryConfig
|
||||
|
||||
class CustomCeleryConfig(CeleryConfig):
|
||||
beat_schedule = {
|
||||
**CeleryConfig.beat_schedule,
|
||||
"slack.cache_channels": {
|
||||
"task": "slack.cache_channels",
|
||||
"schedule": crontab(minute="0", hour="*"),
|
||||
},
|
||||
}
|
||||
|
||||
CELERY_CONFIG = CustomCeleryConfig
|
||||
```
|
||||
|
||||
#### Slack Enterprise Grid (org-scoped tokens)
|
||||
|
||||
On a Slack Enterprise Grid org, an org-scoped token spans multiple workspaces, so
|
||||
@@ -98,6 +120,38 @@ SLACK_TEAM_ID = "T01234567"
|
||||
This defaults to `None` and only needs to be set when using an org-scoped token;
|
||||
it is accepted but ignored for standard workspace-level tokens.
|
||||
|
||||
#### Slack delivery timeouts and retries
|
||||
|
||||
Slack delivery uses a request timeout and an application retry budget:
|
||||
|
||||
```python
|
||||
# Timeout for one Slack API request, in seconds
|
||||
SLACK_API_TIMEOUT = 30
|
||||
|
||||
# Retry budget shared by every Slack destination and upload phase
|
||||
SLACK_SEND_RETRY_MAX_TIME = 150
|
||||
|
||||
# Number of explicit HTTP 429 responses retried using Slack's Retry-After value
|
||||
SLACK_API_RATE_LIMIT_RETRY_COUNT = 2
|
||||
|
||||
# Cooldown after an on-demand channel-cache refresh
|
||||
SLACK_CHANNEL_REFRESH_COOLDOWN_SECONDS = 300
|
||||
```
|
||||
|
||||
All channels and upload phases in one report execution share a single
|
||||
`SLACK_SEND_RETRY_MAX_TIME` budget. This prevents a large recipient list from
|
||||
multiplying the report's wall-clock retry time. The budget is also clamped to
|
||||
the report's remaining working timeout, leaving Celery's configured timeout lag
|
||||
available for final state persistence. The effective configured budget is at
|
||||
least one second longer than `SLACK_API_TIMEOUT`.
|
||||
|
||||
To avoid posting the same report twice, Superset does not replay terminal
|
||||
`chat.postMessage` or `files.completeUploadExternal` operations after ambiguous
|
||||
server or transport failures. Explicit Slack HTTP 429 responses remain
|
||||
retryable. These delivery settings and semantics apply to Slack v2 reports and
|
||||
legacy text-only Slack delivery, independently of the
|
||||
`ALERT_REPORT_SLACK_V2` feature flag.
|
||||
|
||||
### Webhook integration
|
||||
|
||||
Superset can send alert and report notifications to any HTTP endpoint — useful for chat platforms, incident management tools, or custom automation.
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
title: Mobile Experience
|
||||
sidebar_position: 7
|
||||
version: 1
|
||||
---
|
||||
|
||||
import useBaseUrl from "@docusaurus/useBaseUrl";
|
||||
|
||||
# Mobile Experience
|
||||
|
||||
Superset ships an optional, consumption-only mobile experience for viewing
|
||||
dashboards on phones and other small screens. When enabled, screens below
|
||||
768px wide get a layout built for touch: dashboards render their charts
|
||||
stacked full-width, navigation collapses into a drawer, and dashboard
|
||||
filters open in a slide-out panel.
|
||||
|
||||
The mobile experience is **read-only by design**. It is aimed at consumers
|
||||
of analytics — people checking a dashboard from a phone — not at dashboard
|
||||
authors. Authoring surfaces (chart builder, SQL Lab, dataset management,
|
||||
and administrative screens) remain desktop-only.
|
||||
|
||||
## Enabling the mobile experience
|
||||
|
||||
The mobile experience is gated behind the `MOBILE_CONSUMPTION_MODE` feature
|
||||
flag, which is off by default. Enable it in your `superset_config.py`:
|
||||
|
||||
```python
|
||||
FEATURE_FLAGS = {
|
||||
"MOBILE_CONSUMPTION_MODE": True,
|
||||
}
|
||||
```
|
||||
|
||||
With the flag disabled, Superset renders identically at every screen size,
|
||||
and phones display the desktop layout scaled down (the pre-existing
|
||||
behavior). The flag also controls whether Superset serves a viewport meta
|
||||
tag, which is required for mobile browsers to apply the responsive layout
|
||||
at their native width.
|
||||
|
||||
## What works on mobile
|
||||
|
||||
| Area | Mobile behavior |
|
||||
| --- | --- |
|
||||
| **Dashboards** | Charts stack vertically at full width, sized to the screen. Tab bars are sticky and swipeable. Native filters open in a drawer via the filter icon in the header. |
|
||||
| **Dashboard list** | Card view with full-width cards; search and filters open in a drawer. |
|
||||
| **Home** | Recents (dashboards only) and dashboard cards; desktop-only sections are hidden. |
|
||||
| **Navigation** | A hamburger menu opens a drawer with links to dashboards, theme and language selection, and user info/logout. |
|
||||
|
||||
<div style={{display: 'flex', gap: '1rem', flexWrap: 'wrap'}}>
|
||||
<img src={useBaseUrl("/img/screenshots/mobile/mobile_dashboard.jpg")} alt="A dashboard on mobile with charts stacked full width" width="260" />
|
||||
<img src={useBaseUrl("/img/screenshots/mobile/mobile_filter_drawer.jpg")} alt="The dashboard filter drawer on mobile" width="260" />
|
||||
<img src={useBaseUrl("/img/screenshots/mobile/mobile_dashboard_list.jpg")} alt="The dashboard list in card view on mobile" width="260" />
|
||||
</div>
|
||||
|
||||
<div style={{display: 'flex', gap: '1rem', flexWrap: 'wrap', marginTop: '1rem'}}>
|
||||
<img src={useBaseUrl("/img/screenshots/mobile/mobile_home.jpg")} alt="The Superset home page on mobile" width="260" />
|
||||
<img src={useBaseUrl("/img/screenshots/mobile/mobile_nav_drawer.jpg")} alt="The mobile navigation drawer" width="260" />
|
||||
<img src={useBaseUrl("/img/screenshots/mobile/mobile_unsupported.jpg")} alt="The screen shown for views that are not available on mobile" width="260" />
|
||||
</div>
|
||||
|
||||
## What doesn't work on mobile
|
||||
|
||||
Everything not listed above shows a friendly "This view isn't available on
|
||||
mobile" screen with shortcuts back to dashboards and the home page. That
|
||||
includes:
|
||||
|
||||
- Chart builder (Explore) and chart-level links — chart titles on
|
||||
dashboards are plain text on mobile, and chart entries are filtered out
|
||||
of the home page's Recents feed
|
||||
- SQL Lab and query history
|
||||
- Creating or editing dashboards, charts, datasets, and databases
|
||||
- List views other than dashboards (charts, datasets, saved queries, etc.)
|
||||
- Administrative and settings screens
|
||||
|
||||
Editing controls are also removed from the screens that *are* supported:
|
||||
the dashboard header hides the edit, publish, and favorite controls, and
|
||||
dashboard/chart kebab menus are reduced to view-oriented actions.
|
||||
|
||||
If a device crosses the 768px threshold — for example, rotating a tablet
|
||||
to landscape or resizing a window — the full desktop experience becomes
|
||||
available immediately.
|
||||
|
||||
## Notes for operators
|
||||
|
||||
- The flag is deployment-wide; there is no per-role or per-user targeting.
|
||||
- Dashboard permalinks and links shared from desktop resolve normally on
|
||||
mobile as long as they point at dashboards.
|
||||
- Embedded dashboards are unaffected: the embedded SDK controls its own
|
||||
layout, and the viewport meta tag is only interpreted by the top-level
|
||||
page.
|
||||
- Dashboards loaded with a `standalone` URL param (used for iframe embeds
|
||||
and kiosk-style displays) always render the desktop layout, regardless
|
||||
of viewport width, since the standalone chrome doesn't expose the mobile
|
||||
filter drawer's trigger.
|
||||
@@ -68,6 +68,8 @@ function getProviders() {
|
||||
const { themeObject } = require('@apache-superset/core/theme');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { App, ConfigProvider } = require('antd');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { useColorMode } = require('@docusaurus/theme-common');
|
||||
|
||||
// Configure Ant Design to render portals (tooltips, dropdowns, etc.)
|
||||
// inside the closest .storybook-example container instead of document.body
|
||||
@@ -78,15 +80,39 @@ function getProviders() {
|
||||
return container || document.body;
|
||||
};
|
||||
|
||||
// `themeObject` is a module-level singleton (superset-core/src/theme
|
||||
// index.tsx: `Theme.fromConfig()`), created once with no dark/light
|
||||
// config, so SupersetThemeProvider always rendered whatever that default
|
||||
// algorithm was -- it had no way to know about Docusaurus's theme toggle.
|
||||
// Docusaurus tracks the toggle in React context (useColorMode), so
|
||||
// mirror it onto the singleton via the toggleDarkMode() method Theme
|
||||
// already exposes for exactly this purpose.
|
||||
//
|
||||
// Use useLayoutEffect (not useEffect) so the sync runs before the
|
||||
// browser paints. This component only ever mounts client-side (it's
|
||||
// built inside a BrowserOnly callback), so there's no SSR mismatch
|
||||
// concern -- and running synchronously before paint avoids a brief
|
||||
// flash of the singleton's previous palette when a page loads directly
|
||||
// in dark mode or the toggle fires during route navigation.
|
||||
function ThemeSync({ children }) {
|
||||
const { colorMode } = useColorMode();
|
||||
React.useLayoutEffect(() => {
|
||||
themeObject.toggleDarkMode(colorMode === 'dark');
|
||||
}, [colorMode]);
|
||||
return children;
|
||||
}
|
||||
|
||||
SupersetProviders = ({ children }) => (
|
||||
<themeObject.SupersetThemeProvider>
|
||||
<ConfigProvider
|
||||
getPopupContainer={getPopupContainer}
|
||||
getTargetContainer={() => document.body}
|
||||
>
|
||||
<App>{children}</App>
|
||||
</ConfigProvider>
|
||||
</themeObject.SupersetThemeProvider>
|
||||
<ThemeSync>
|
||||
<themeObject.SupersetThemeProvider>
|
||||
<ConfigProvider
|
||||
getPopupContainer={getPopupContainer}
|
||||
getTargetContainer={() => document.body}
|
||||
>
|
||||
<App>{children}</App>
|
||||
</ConfigProvider>
|
||||
</themeObject.SupersetThemeProvider>
|
||||
</ThemeSync>
|
||||
);
|
||||
return SupersetProviders;
|
||||
} catch (error) {
|
||||
@@ -133,7 +159,7 @@ function LoadingPlaceholder() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid #e8e8e8',
|
||||
border: '1px solid var(--ifm-color-emphasis-300)',
|
||||
borderRadius: '4px',
|
||||
padding: '20px',
|
||||
marginBottom: '20px',
|
||||
@@ -141,7 +167,7 @@ function LoadingPlaceholder() {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#999',
|
||||
color: 'var(--ifm-color-emphasis-600)',
|
||||
}}
|
||||
>
|
||||
Loading component...
|
||||
@@ -162,7 +188,7 @@ export function StoryExample({ component, props = {} }) {
|
||||
<div
|
||||
className="storybook-example"
|
||||
style={{
|
||||
border: '1px solid #e8e8e8',
|
||||
border: '1px solid var(--ifm-color-emphasis-300)',
|
||||
borderRadius: '4px',
|
||||
padding: '20px',
|
||||
marginBottom: '20px',
|
||||
@@ -172,7 +198,7 @@ export function StoryExample({ component, props = {} }) {
|
||||
{Component ? (
|
||||
<Component {...restProps}>{children}</Component>
|
||||
) : (
|
||||
<div style={{ color: '#999' }}>
|
||||
<div style={{ color: 'var(--ifm-color-emphasis-600)' }}>
|
||||
Component "{String(component)}" not found
|
||||
</div>
|
||||
)}
|
||||
@@ -373,7 +399,7 @@ function StoryWithControlsInner({
|
||||
<div
|
||||
className="storybook-example"
|
||||
style={{
|
||||
border: '1px solid #e8e8e8',
|
||||
border: '1px solid var(--ifm-color-emphasis-300)',
|
||||
borderRadius: '4px',
|
||||
padding: '20px',
|
||||
marginBottom: '20px',
|
||||
@@ -393,7 +419,7 @@ function StoryWithControlsInner({
|
||||
</Component>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ color: '#999' }}>
|
||||
<div style={{ color: 'var(--ifm-color-emphasis-600)' }}>
|
||||
Component "{String(componentToRender)}" not found
|
||||
</div>
|
||||
)}
|
||||
@@ -403,7 +429,7 @@ function StoryWithControlsInner({
|
||||
<div
|
||||
className="storybook-controls"
|
||||
style={{
|
||||
border: '1px solid #e8e8e8',
|
||||
border: '1px solid var(--ifm-color-emphasis-300)',
|
||||
borderRadius: '4px',
|
||||
padding: '20px',
|
||||
marginBottom: '20px',
|
||||
@@ -545,7 +571,7 @@ function ComponentGalleryInner({
|
||||
|
||||
if (!Component) {
|
||||
return (
|
||||
<div style={{ color: '#999' }}>
|
||||
<div style={{ color: 'var(--ifm-color-emphasis-600)' }}>
|
||||
Component "{String(component)}" not found
|
||||
</div>
|
||||
);
|
||||
@@ -556,7 +582,14 @@ function ComponentGalleryInner({
|
||||
<div className="component-gallery">
|
||||
{sizes.map(size => (
|
||||
<div key={size} style={{ marginBottom: 40 }}>
|
||||
<h4 style={{ marginBottom: 16, color: '#666' }}>{size}</h4>
|
||||
<h4
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
color: 'var(--ifm-color-emphasis-700)',
|
||||
}}
|
||||
>
|
||||
{size}
|
||||
</h4>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
|
||||
@@ -69,6 +69,12 @@
|
||||
"lifecycle": "development",
|
||||
"description": "Enable Matrixify feature for matrix-style chart layouts"
|
||||
},
|
||||
{
|
||||
"name": "MOBILE_CONSUMPTION_MODE",
|
||||
"default": false,
|
||||
"lifecycle": "development",
|
||||
"description": "Serve a consumption-only mobile experience (dashboards, dashboard list, and home page) on small screens; other views show a \"not supported on mobile\" screen. Authoring features are hidden on mobile when enabled."
|
||||
},
|
||||
{
|
||||
"name": "OPTIMIZE_SQL",
|
||||
"default": false,
|
||||
|
||||
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 42 KiB |
@@ -29,7 +29,7 @@ maintainers:
|
||||
- name: craig-rueda
|
||||
email: craig@craigrueda.com
|
||||
url: https://github.com/craig-rueda
|
||||
version: 0.22.5 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
|
||||
version: 0.22.6 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
|
||||
dependencies:
|
||||
- name: postgresql
|
||||
version: 16.7.27
|
||||
|
||||
@@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs
|
||||
|
||||
# superset
|
||||
|
||||

|
||||

|
||||
|
||||
Apache Superset is a modern, enterprise-ready business intelligence web application
|
||||
|
||||
@@ -205,9 +205,9 @@ Alternatively, perform a fresh install. This is a one-time migration; subsequent
|
||||
| supersetCeleryBeat.forceReload | bool | `false` | If true, forces deployment to reload on each upgrade |
|
||||
| supersetCeleryBeat.initContainers | list | a container waiting for postgres | List of init containers |
|
||||
| supersetCeleryBeat.podAnnotations | object | `{}` | Annotations to be added to supersetCeleryBeat pods |
|
||||
| supersetCeleryBeat.podDisruptionBudget | object | `{"enabled":false,"maxUnavailable":1,"minAvailable":1}` | Sets the [pod disruption budget](https://kubernetes.io/docs/tasks/run-application/configure-pdb/) for supersetCeleryBeat pods |
|
||||
| supersetCeleryBeat.podDisruptionBudget | object | `{"enabled":false,"maxUnavailable":null,"minAvailable":1}` | Sets the [pod disruption budget](https://kubernetes.io/docs/tasks/run-application/configure-pdb/) for supersetCeleryBeat pods |
|
||||
| supersetCeleryBeat.podDisruptionBudget.enabled | bool | `false` | Whether the pod disruption budget should be created |
|
||||
| supersetCeleryBeat.podDisruptionBudget.maxUnavailable | int | `1` | If set, minAvailable must not be set - see https://kubernetes.io/docs/tasks/run-application/configure-pdb/#specifying-a-poddisruptionbudget |
|
||||
| supersetCeleryBeat.podDisruptionBudget.maxUnavailable | string | `nil` | If set, minAvailable must be unset (`minAvailable: ~`) - see https://kubernetes.io/docs/tasks/run-application/configure-pdb/#specifying-a-poddisruptionbudget |
|
||||
| supersetCeleryBeat.podDisruptionBudget.minAvailable | int | `1` | If set, maxUnavailable must not be set - see https://kubernetes.io/docs/tasks/run-application/configure-pdb/#specifying-a-poddisruptionbudget |
|
||||
| supersetCeleryBeat.podLabels | object | `{}` | Labels to be added to supersetCeleryBeat pods |
|
||||
| supersetCeleryBeat.podSecurityContext | object | `{}` | |
|
||||
@@ -231,9 +231,9 @@ Alternatively, perform a fresh install. This is a one-time migration; subsequent
|
||||
| supersetCeleryFlower.livenessProbe.successThreshold | int | `1` | |
|
||||
| supersetCeleryFlower.livenessProbe.timeoutSeconds | int | `1` | |
|
||||
| supersetCeleryFlower.podAnnotations | object | `{}` | Annotations to be added to supersetCeleryFlower pods |
|
||||
| supersetCeleryFlower.podDisruptionBudget | object | `{"enabled":false,"maxUnavailable":1,"minAvailable":1}` | Sets the [pod disruption budget](https://kubernetes.io/docs/tasks/run-application/configure-pdb/) for supersetCeleryFlower pods |
|
||||
| supersetCeleryFlower.podDisruptionBudget | object | `{"enabled":false,"maxUnavailable":null,"minAvailable":1}` | Sets the [pod disruption budget](https://kubernetes.io/docs/tasks/run-application/configure-pdb/) for supersetCeleryFlower pods |
|
||||
| supersetCeleryFlower.podDisruptionBudget.enabled | bool | `false` | Whether the pod disruption budget should be created |
|
||||
| supersetCeleryFlower.podDisruptionBudget.maxUnavailable | int | `1` | If set, minAvailable must not be set - see https://kubernetes.io/docs/tasks/run-application/configure-pdb/#specifying-a-poddisruptionbudget |
|
||||
| supersetCeleryFlower.podDisruptionBudget.maxUnavailable | string | `nil` | If set, minAvailable must be unset (`minAvailable: ~`) - see https://kubernetes.io/docs/tasks/run-application/configure-pdb/#specifying-a-poddisruptionbudget |
|
||||
| supersetCeleryFlower.podDisruptionBudget.minAvailable | int | `1` | If set, maxUnavailable must not be set - see https://kubernetes.io/docs/tasks/run-application/configure-pdb/#specifying-a-poddisruptionbudget |
|
||||
| supersetCeleryFlower.podLabels | object | `{}` | Labels to be added to supersetCeleryFlower pods |
|
||||
| supersetCeleryFlower.podSecurityContext | object | `{}` | |
|
||||
@@ -285,10 +285,10 @@ Alternatively, perform a fresh install. This is a one-time migration; subsequent
|
||||
| supersetMcp.livenessProbe.successThreshold | int | `1` | |
|
||||
| supersetMcp.livenessProbe.timeoutSeconds | int | `3` | |
|
||||
| supersetMcp.podAnnotations | object | `{}` | Annotations to be added to supersetMcp pods |
|
||||
| supersetMcp.podDisruptionBudget | object | `{"enabled":false,"maxUnavailable":1,"minAvailable":1}` | Sets the [pod disruption budget](https://kubernetes.io/docs/tasks/run-application/configure-pdb/) for supersetMcp pods |
|
||||
| supersetMcp.podDisruptionBudget | object | `{"enabled":false,"maxUnavailable":null,"minAvailable":1}` | Sets the [pod disruption budget](https://kubernetes.io/docs/tasks/run-application/configure-pdb/) for supersetMcp pods |
|
||||
| supersetMcp.podDisruptionBudget.enabled | bool | `false` | Whether the pod disruption budget should be created |
|
||||
| supersetMcp.podDisruptionBudget.maxUnavailable | int | `1` | If set, minAvailable must not be set - see https://kubernetes.io/docs/tasks/run-application/configure-pdb/\#specifying-a-poddisruptionbudget |
|
||||
| supersetMcp.podDisruptionBudget.minAvailable | int | `1` | If set, maxUnavailable must not be set - see https://kubernetes.io/docs/tasks/run-application/configure-pdb/\#specifying-a-poddisruptionbudget |
|
||||
| supersetMcp.podDisruptionBudget.maxUnavailable | string | `nil` | If set, minAvailable must be unset (`minAvailable: ~`) - see https://kubernetes.io/docs/tasks/run-application/configure-pdb/#specifying-a-poddisruptionbudget |
|
||||
| supersetMcp.podDisruptionBudget.minAvailable | int | `1` | If set, maxUnavailable must not be set - see https://kubernetes.io/docs/tasks/run-application/configure-pdb/#specifying-a-poddisruptionbudget |
|
||||
| supersetMcp.podLabels | object | `{}` | Labels to be added to supersetMcp pods |
|
||||
| supersetMcp.podSecurityContext | object | `{}` | |
|
||||
| supersetMcp.priorityClassName | string | `nil` | Set priorityClassName for supersetMcp pods |
|
||||
@@ -341,9 +341,9 @@ Alternatively, perform a fresh install. This is a one-time migration; subsequent
|
||||
| supersetNode.livenessProbe.successThreshold | int | `1` | |
|
||||
| supersetNode.livenessProbe.timeoutSeconds | int | `1` | |
|
||||
| supersetNode.podAnnotations | object | `{}` | Annotations to be added to supersetNode pods |
|
||||
| supersetNode.podDisruptionBudget | object | `{"enabled":false,"maxUnavailable":1,"minAvailable":1}` | Sets the [pod disruption budget](https://kubernetes.io/docs/tasks/run-application/configure-pdb/) for supersetNode pods |
|
||||
| supersetNode.podDisruptionBudget | object | `{"enabled":false,"maxUnavailable":null,"minAvailable":1}` | Sets the [pod disruption budget](https://kubernetes.io/docs/tasks/run-application/configure-pdb/) for supersetNode pods |
|
||||
| supersetNode.podDisruptionBudget.enabled | bool | `false` | Whether the pod disruption budget should be created |
|
||||
| supersetNode.podDisruptionBudget.maxUnavailable | int | `1` | If set, minAvailable must not be set - see https://kubernetes.io/docs/tasks/run-application/configure-pdb/#specifying-a-poddisruptionbudget |
|
||||
| supersetNode.podDisruptionBudget.maxUnavailable | string | `nil` | If set, minAvailable must be unset (`minAvailable: ~`) - see https://kubernetes.io/docs/tasks/run-application/configure-pdb/#specifying-a-poddisruptionbudget |
|
||||
| supersetNode.podDisruptionBudget.minAvailable | int | `1` | If set, maxUnavailable must not be set - see https://kubernetes.io/docs/tasks/run-application/configure-pdb/#specifying-a-poddisruptionbudget |
|
||||
| supersetNode.podLabels | object | `{}` | Labels to be added to supersetNode pods |
|
||||
| supersetNode.podSecurityContext | object | `{}` | |
|
||||
@@ -391,9 +391,9 @@ Alternatively, perform a fresh install. This is a one-time migration; subsequent
|
||||
| supersetWebsockets.livenessProbe.successThreshold | int | `1` | |
|
||||
| supersetWebsockets.livenessProbe.timeoutSeconds | int | `1` | |
|
||||
| supersetWebsockets.podAnnotations | object | `{}` | |
|
||||
| supersetWebsockets.podDisruptionBudget | object | `{"enabled":false,"maxUnavailable":1,"minAvailable":1}` | Sets the [pod disruption budget](https://kubernetes.io/docs/tasks/run-application/configure-pdb/) for supersetWebsockets pods |
|
||||
| supersetWebsockets.podDisruptionBudget | object | `{"enabled":false,"maxUnavailable":null,"minAvailable":1}` | Sets the [pod disruption budget](https://kubernetes.io/docs/tasks/run-application/configure-pdb/) for supersetWebsockets pods |
|
||||
| supersetWebsockets.podDisruptionBudget.enabled | bool | `false` | Whether the pod disruption budget should be created |
|
||||
| supersetWebsockets.podDisruptionBudget.maxUnavailable | int | `1` | If set, minAvailable must not be set - see https://kubernetes.io/docs/tasks/run-application/configure-pdb/#specifying-a-poddisruptionbudget |
|
||||
| supersetWebsockets.podDisruptionBudget.maxUnavailable | string | `nil` | If set, minAvailable must be unset (`minAvailable: ~`) - see https://kubernetes.io/docs/tasks/run-application/configure-pdb/#specifying-a-poddisruptionbudget |
|
||||
| supersetWebsockets.podDisruptionBudget.minAvailable | int | `1` | If set, maxUnavailable must not be set - see https://kubernetes.io/docs/tasks/run-application/configure-pdb/#specifying-a-poddisruptionbudget |
|
||||
| supersetWebsockets.podLabels | object | `{}` | |
|
||||
| supersetWebsockets.podSecurityContext | object | `{}` | |
|
||||
@@ -448,9 +448,9 @@ Alternatively, perform a fresh install. This is a one-time migration; subsequent
|
||||
| supersetWorker.livenessProbe.successThreshold | int | `1` | |
|
||||
| supersetWorker.livenessProbe.timeoutSeconds | int | `60` | |
|
||||
| supersetWorker.podAnnotations | object | `{}` | Annotations to be added to supersetWorker pods |
|
||||
| supersetWorker.podDisruptionBudget | object | `{"enabled":false,"maxUnavailable":1,"minAvailable":1}` | Sets the [pod disruption budget](https://kubernetes.io/docs/tasks/run-application/configure-pdb/) for supersetWorker pods |
|
||||
| supersetWorker.podDisruptionBudget | object | `{"enabled":false,"maxUnavailable":null,"minAvailable":1}` | Sets the [pod disruption budget](https://kubernetes.io/docs/tasks/run-application/configure-pdb/) for supersetWorker pods |
|
||||
| supersetWorker.podDisruptionBudget.enabled | bool | `false` | Whether the pod disruption budget should be created |
|
||||
| supersetWorker.podDisruptionBudget.maxUnavailable | int | `1` | If set, minAvailable must not be set - see https://kubernetes.io/docs/tasks/run-application/configure-pdb/#specifying-a-poddisruptionbudget |
|
||||
| supersetWorker.podDisruptionBudget.maxUnavailable | string | `nil` | If set, minAvailable must be unset (`minAvailable: ~`) - see https://kubernetes.io/docs/tasks/run-application/configure-pdb/#specifying-a-poddisruptionbudget |
|
||||
| supersetWorker.podDisruptionBudget.minAvailable | int | `1` | If set, maxUnavailable must not be set - see https://kubernetes.io/docs/tasks/run-application/configure-pdb/#specifying-a-poddisruptionbudget |
|
||||
| supersetWorker.podLabels | object | `{}` | Labels to be added to supersetWorker pods |
|
||||
| supersetWorker.podSecurityContext | object | `{}` | |
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
{{- with .Values.supersetCeleryBeat.podDisruptionBudget }}
|
||||
{{- if .enabled -}}
|
||||
{{- if and .minAvailable .maxUnavailable }}
|
||||
{{- fail "Only one of minAvailable or maxUnavailable should be set" }}
|
||||
{{- fail "supersetCeleryBeat.podDisruptionBudget: only one of minAvailable or maxUnavailable should be set - unset the other one (set it to null)" }}
|
||||
{{- end}}
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
{{- with .Values.supersetCeleryFlower.podDisruptionBudget }}
|
||||
{{- if .enabled -}}
|
||||
{{- if and .minAvailable .maxUnavailable }}
|
||||
{{- fail "Only one of minAvailable or maxUnavailable should be set" }}
|
||||
{{- fail "supersetCeleryFlower.podDisruptionBudget: only one of minAvailable or maxUnavailable should be set - unset the other one (set it to null)" }}
|
||||
{{- end}}
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
{{- with .Values.supersetMcp.podDisruptionBudget }}
|
||||
{{- if .enabled -}}
|
||||
{{- if and .minAvailable .maxUnavailable }}
|
||||
{{- fail "Only one of minAvailable or maxUnavailable should be set" }}
|
||||
{{- fail "supersetMcp.podDisruptionBudget: only one of minAvailable or maxUnavailable should be set - unset the other one (set it to null)" }}
|
||||
{{- end}}
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
{{- with .Values.supersetWorker.podDisruptionBudget }}
|
||||
{{- if .enabled -}}
|
||||
{{- if and .minAvailable .maxUnavailable }}
|
||||
{{- fail "Only one of minAvailable or maxUnavailable should be set" }}
|
||||
{{- fail "supersetWorker.podDisruptionBudget: only one of minAvailable or maxUnavailable should be set - unset the other one (set it to null)" }}
|
||||
{{- end}}
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
{{- with .Values.supersetWebsockets.podDisruptionBudget }}
|
||||
{{- if .enabled -}}
|
||||
{{- if and .minAvailable .maxUnavailable }}
|
||||
{{- fail "Only one of minAvailable or maxUnavailable should be set" }}
|
||||
{{- fail "supersetWebsockets.podDisruptionBudget: only one of minAvailable or maxUnavailable should be set - unset the other one (set it to null)" }}
|
||||
{{- end}}
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
{{- with .Values.supersetNode.podDisruptionBudget }}
|
||||
{{- if .enabled -}}
|
||||
{{- if and .minAvailable .maxUnavailable }}
|
||||
{{- fail "Only one of minAvailable or maxUnavailable should be set" }}
|
||||
{{- fail "supersetNode.podDisruptionBudget: only one of minAvailable or maxUnavailable should be set - unset the other one (set it to null)" }}
|
||||
{{- end}}
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
#
|
||||
# Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
# contributor license agreements. See the NOTICE file distributed with
|
||||
# this work for additional information regarding copyright ownership.
|
||||
# The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
# (the "License"); you may not use this file except in compliance with
|
||||
# the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
suite: pod disruption budgets
|
||||
templates:
|
||||
- pdb.yaml
|
||||
- pdb-worker.yaml
|
||||
- pdb-beat.yaml
|
||||
- pdb-flower.yaml
|
||||
- pdb-ws.yaml
|
||||
- pdb-mcp.yaml
|
||||
|
||||
# The chart must not ship conflicting minAvailable/maxUnavailable defaults: enabling a
|
||||
# PDB with `enabled: true` alone has to render, since Helm merges user values on top of
|
||||
# the chart defaults and the templates fail when both fields are set.
|
||||
tests:
|
||||
- it: renders no PDB by default
|
||||
asserts:
|
||||
- hasDocuments:
|
||||
count: 0
|
||||
|
||||
- it: renders with minAvailable only when just enabled is set
|
||||
set:
|
||||
supersetNode.podDisruptionBudget.enabled: true
|
||||
supersetWorker.podDisruptionBudget.enabled: true
|
||||
supersetCeleryBeat.podDisruptionBudget.enabled: true
|
||||
supersetCeleryFlower.podDisruptionBudget.enabled: true
|
||||
supersetWebsockets.podDisruptionBudget.enabled: true
|
||||
supersetMcp.podDisruptionBudget.enabled: true
|
||||
asserts:
|
||||
- hasDocuments:
|
||||
count: 1
|
||||
- isKind:
|
||||
of: PodDisruptionBudget
|
||||
- equal:
|
||||
path: spec.minAvailable
|
||||
value: 1
|
||||
- notExists:
|
||||
path: spec.maxUnavailable
|
||||
|
||||
- it: honors an overridden minAvailable
|
||||
template: pdb.yaml
|
||||
set:
|
||||
supersetNode.podDisruptionBudget.enabled: true
|
||||
supersetNode.podDisruptionBudget.minAvailable: 2
|
||||
asserts:
|
||||
- equal:
|
||||
path: spec.minAvailable
|
||||
value: 2
|
||||
- notExists:
|
||||
path: spec.maxUnavailable
|
||||
|
||||
- it: honors maxUnavailable when minAvailable is unset
|
||||
template: pdb-worker.yaml
|
||||
set:
|
||||
supersetWorker.podDisruptionBudget.enabled: true
|
||||
supersetWorker.podDisruptionBudget.minAvailable: null
|
||||
supersetWorker.podDisruptionBudget.maxUnavailable: 1
|
||||
asserts:
|
||||
- equal:
|
||||
path: spec.maxUnavailable
|
||||
value: 1
|
||||
- notExists:
|
||||
path: spec.minAvailable
|
||||
|
||||
- it: fails when both minAvailable and maxUnavailable are set explicitly
|
||||
template: pdb.yaml
|
||||
set:
|
||||
supersetNode.podDisruptionBudget.enabled: true
|
||||
supersetNode.podDisruptionBudget.minAvailable: 1
|
||||
supersetNode.podDisruptionBudget.maxUnavailable: 1
|
||||
asserts:
|
||||
- failedTemplate:
|
||||
errorMessage: "supersetNode.podDisruptionBudget: only one of minAvailable or maxUnavailable should be set - unset the other one (set it to null)"
|
||||
@@ -410,8 +410,8 @@ supersetNode:
|
||||
enabled: false
|
||||
# -- If set, maxUnavailable must not be set - see https://kubernetes.io/docs/tasks/run-application/configure-pdb/#specifying-a-poddisruptionbudget
|
||||
minAvailable: 1
|
||||
# -- If set, minAvailable must not be set - see https://kubernetes.io/docs/tasks/run-application/configure-pdb/#specifying-a-poddisruptionbudget
|
||||
maxUnavailable: 1
|
||||
# -- If set, minAvailable must be unset (`minAvailable: ~`) - see https://kubernetes.io/docs/tasks/run-application/configure-pdb/#specifying-a-poddisruptionbudget
|
||||
maxUnavailable: ~
|
||||
|
||||
# -- Startup command
|
||||
# @default -- See `values.yaml`
|
||||
@@ -542,8 +542,8 @@ supersetWorker:
|
||||
enabled: false
|
||||
# -- If set, maxUnavailable must not be set - see https://kubernetes.io/docs/tasks/run-application/configure-pdb/#specifying-a-poddisruptionbudget
|
||||
minAvailable: 1
|
||||
# -- If set, minAvailable must not be set - see https://kubernetes.io/docs/tasks/run-application/configure-pdb/#specifying-a-poddisruptionbudget
|
||||
maxUnavailable: 1
|
||||
# -- If set, minAvailable must be unset (`minAvailable: ~`) - see https://kubernetes.io/docs/tasks/run-application/configure-pdb/#specifying-a-poddisruptionbudget
|
||||
maxUnavailable: ~
|
||||
# -- Worker startup command
|
||||
# @default -- a `celery worker` command
|
||||
command:
|
||||
@@ -665,8 +665,8 @@ supersetCeleryBeat:
|
||||
enabled: false
|
||||
# -- If set, maxUnavailable must not be set - see https://kubernetes.io/docs/tasks/run-application/configure-pdb/#specifying-a-poddisruptionbudget
|
||||
minAvailable: 1
|
||||
# -- If set, minAvailable must not be set - see https://kubernetes.io/docs/tasks/run-application/configure-pdb/#specifying-a-poddisruptionbudget
|
||||
maxUnavailable: 1
|
||||
# -- If set, minAvailable must be unset (`minAvailable: ~`) - see https://kubernetes.io/docs/tasks/run-application/configure-pdb/#specifying-a-poddisruptionbudget
|
||||
maxUnavailable: ~
|
||||
# -- Command
|
||||
# @default -- a `celery beat` command
|
||||
command:
|
||||
@@ -751,8 +751,8 @@ supersetCeleryFlower:
|
||||
enabled: false
|
||||
# -- If set, maxUnavailable must not be set - see https://kubernetes.io/docs/tasks/run-application/configure-pdb/#specifying-a-poddisruptionbudget
|
||||
minAvailable: 1
|
||||
# -- If set, minAvailable must not be set - see https://kubernetes.io/docs/tasks/run-application/configure-pdb/#specifying-a-poddisruptionbudget
|
||||
maxUnavailable: 1
|
||||
# -- If set, minAvailable must be unset (`minAvailable: ~`) - see https://kubernetes.io/docs/tasks/run-application/configure-pdb/#specifying-a-poddisruptionbudget
|
||||
maxUnavailable: ~
|
||||
# -- Command
|
||||
# @default -- a `celery flower` command
|
||||
command:
|
||||
@@ -869,8 +869,8 @@ supersetWebsockets:
|
||||
enabled: false
|
||||
# -- If set, maxUnavailable must not be set - see https://kubernetes.io/docs/tasks/run-application/configure-pdb/#specifying-a-poddisruptionbudget
|
||||
minAvailable: 1
|
||||
# -- If set, minAvailable must not be set - see https://kubernetes.io/docs/tasks/run-application/configure-pdb/#specifying-a-poddisruptionbudget
|
||||
maxUnavailable: 1
|
||||
# -- If set, minAvailable must be unset (`minAvailable: ~`) - see https://kubernetes.io/docs/tasks/run-application/configure-pdb/#specifying-a-poddisruptionbudget
|
||||
maxUnavailable: ~
|
||||
ingress:
|
||||
path: /ws
|
||||
pathType: Prefix
|
||||
@@ -999,10 +999,10 @@ supersetMcp:
|
||||
podDisruptionBudget:
|
||||
# -- Whether the pod disruption budget should be created
|
||||
enabled: false
|
||||
# -- If set, maxUnavailable must not be set - see https://kubernetes.io/docs/tasks/run-application/configure-pdb/\#specifying-a-poddisruptionbudget
|
||||
# -- If set, maxUnavailable must not be set - see https://kubernetes.io/docs/tasks/run-application/configure-pdb/#specifying-a-poddisruptionbudget
|
||||
minAvailable: 1
|
||||
# -- If set, minAvailable must not be set - see https://kubernetes.io/docs/tasks/run-application/configure-pdb/\#specifying-a-poddisruptionbudget
|
||||
maxUnavailable: 1
|
||||
# -- If set, minAvailable must be unset (`minAvailable: ~`) - see https://kubernetes.io/docs/tasks/run-application/configure-pdb/#specifying-a-poddisruptionbudget
|
||||
maxUnavailable: ~
|
||||
# -- Command
|
||||
# @default -- a `superset mcp run` command
|
||||
command:
|
||||
|
||||
@@ -60,15 +60,11 @@ dependencies = [
|
||||
"flask-login>=0.6.0, < 1.0",
|
||||
"flask-migrate>=4.1.0, <5.0",
|
||||
"flask-session>=0.4.0, <1.0",
|
||||
# Pinned explicitly below 3.0: 3.0.5 resolves without conflict and
|
||||
# supports both SQLAlchemy 1.4 and 2.0, but real CI runs surfaced a
|
||||
# structural incompatibility with Superset's current session/app-context
|
||||
# handling across Celery task boundaries (see PR #42542) -- widespread
|
||||
# "NoneType has no attribute X" failures and MySQL lock-wait timeouts,
|
||||
# not just a connection-pool quirk. Needs dedicated investigation, not a
|
||||
# driver-compat-prep bump; revisit alongside the actual SQLAlchemy 2.0
|
||||
# core bump (discussion #40273, step 6).
|
||||
"flask-sqlalchemy>=2.5.1, <4.0",
|
||||
# Bumped to 3.1.1 alongside the SQLAlchemy 2.0 core bump (discussion
|
||||
# #40273, step 6), which resolves the session/app-context handling
|
||||
# across Celery task boundaries that previously blocked this (see
|
||||
# PR #42542).
|
||||
"flask-sqlalchemy>=3.1.1, <4.0",
|
||||
"flask-wtf>=1.3.0, <2.0",
|
||||
"geopy",
|
||||
"greenlet<=3.5.4, >=3.5.4",
|
||||
@@ -115,7 +111,7 @@ dependencies = [
|
||||
"sshtunnel>=0.4.0, <0.5",
|
||||
"simplejson>=4.1.1",
|
||||
"slack_sdk>=3.43.0, <4",
|
||||
"sqlalchemy>=1.4.43, <2", # 1.4.43 adds the python-oracledb (oracle+oracledb) dialect
|
||||
"sqlalchemy>=2.0.0, <2.1",
|
||||
"sqlalchemy-continuum>=1.6.0, <2.0.0",
|
||||
"sqlalchemy-utils>=0.42.1, <0.43", # expanding lowerbound to work with pydoris
|
||||
"sqlglot>=30.16.0, <31", # 30.16.0 adds Trino inline UDF IF/CASE routine statement parsing
|
||||
@@ -164,11 +160,10 @@ databricks = [
|
||||
datafusion = ["flightsql-dbapi>=0.2.2, <0.3"]
|
||||
db2 = ["ibm-db-sa<=0.4.4, >=0.4.4"]
|
||||
denodo = ["denodo-sqlalchemy>=2.0.5,<2.1.0"]
|
||||
# sqlalchemy-dremio 3.0.5+ hard-pins sqlalchemy~=2.0.41, dropping 1.4; 3.0.4
|
||||
# is the last dual-compat release. Capped below 3.0.5 for now; widen back to
|
||||
# <4 in lockstep with Superset's own SQLAlchemy 2.0 core bump (discussion
|
||||
# #40273), not before.
|
||||
dremio = ["sqlalchemy-dremio>=1.2.1, <3.0.5"]
|
||||
# sqlalchemy-dremio 3.0.5+ hard-pins sqlalchemy~=2.0.41, dropping 1.4.
|
||||
# Widened now that Superset's own SQLAlchemy 2.0 core bump has landed
|
||||
# (discussion #40273).
|
||||
dremio = ["sqlalchemy-dremio>=3.0.5, <4"]
|
||||
# <2 was an artificial ceiling; upstream has no SQLAlchemy version cap and
|
||||
# 1.1.10 already supports SQLAlchemy 2.0 (added `import_dbapi` in 1.1.7).
|
||||
drill = ["sqlalchemy-drill>=1.1.10, <3"]
|
||||
@@ -181,10 +176,9 @@ dynamodb = ["pydynamodb>=0.8.2"]
|
||||
solr = ["sqlalchemy-solr>=0.2.4.3"]
|
||||
elasticsearch = ["elasticsearch-dbapi>=0.2.13, <0.3.0"]
|
||||
# sqlalchemy-exasol cuts hard from SQLAlchemy 1.4-only (<6.0.0) to 2.0-only
|
||||
# (>=6.0.0) with no dual-compat release. Capped below 6.0.0 for now; bump to
|
||||
# >=6.0.0,<8.0 in lockstep with Superset's own SQLAlchemy 2.0 core bump
|
||||
# (discussion #40273), not before.
|
||||
exasol = ["sqlalchemy-exasol>=2.4.0, <6.0.0"]
|
||||
# (>=6.0.0) with no dual-compat release. Bumped now that Superset's own
|
||||
# SQLAlchemy 2.0 core bump has landed (discussion #40273).
|
||||
exasol = ["sqlalchemy-exasol>=6.0.0, <8.0"]
|
||||
excel = ["xlrd>=2.0.2, <2.1"]
|
||||
# Async dashboard "Export Data/Images to Excel": uploads the workbook to S3 and
|
||||
# emails a pre-signed link. boto3 is imported lazily by superset.utils.s3, so
|
||||
@@ -199,9 +193,9 @@ fastmcp = [
|
||||
]
|
||||
# sqlalchemy-firebird >=2.0.0 unconditionally requires SQLAlchemy 2.0 on
|
||||
# Python >=3.8 (which covers Superset's >=3.11 floor), with no dual-compat
|
||||
# release. Capped below 2.0.0 for now; bump to >=2.2.0 in lockstep with
|
||||
# Superset's own SQLAlchemy 2.0 core bump (discussion #40273), not before.
|
||||
firebird = ["sqlalchemy-firebird>=0.8.0, <2.0.0"]
|
||||
# release. Bumped now that Superset's own SQLAlchemy 2.0 core bump has
|
||||
# landed (discussion #40273).
|
||||
firebird = ["sqlalchemy-firebird>=2.2.0"]
|
||||
firebolt = ["firebolt-sqlalchemy>=1.1.2, <2"]
|
||||
gevent = ["gevent>=26.7.0"]
|
||||
gsheets = ["shillelagh[gsheetsapi]>=1.4.5, <2"]
|
||||
@@ -240,15 +234,14 @@ presto = ["pyhive[presto]>=0.6.5"]
|
||||
trino = ["trino>=0.338.0"]
|
||||
prophet = ["prophet>=1.3.0, <2"]
|
||||
# sqlalchemy-redshift cuts hard from SQLAlchemy 1.4-only (0.8.x) to 2.0-only
|
||||
# (>=1.0.0) with no dual-compat release; the existing <0.9 ceiling already
|
||||
# keeps this on the 1.4-only line. Bump to >=1.0.0 in lockstep with
|
||||
# Superset's own SQLAlchemy 2.0 core bump (discussion #40273), not before.
|
||||
redshift = ["sqlalchemy-redshift>=0.8.1, <0.9"]
|
||||
# (>=1.0.0) with no dual-compat release. Bumped now that Superset's own
|
||||
# SQLAlchemy 2.0 core bump has landed (discussion #40273).
|
||||
redshift = ["sqlalchemy-redshift>=1.0.0"]
|
||||
# No release of sqlalchemy-risingwave has ever supported both SQLAlchemy 1.4
|
||||
# and 2.0 (version numbers don't track SQLAlchemy compat monotonically); pin
|
||||
# to the newest 1.4-only release for now. Bump to >=2.0.0 in lockstep with
|
||||
# Superset's own SQLAlchemy 2.0 core bump (discussion #40273), not before.
|
||||
risingwave = ["sqlalchemy-risingwave>=1.4.1, <3.0.0"]
|
||||
# and 2.0 (version numbers don't track SQLAlchemy compat monotonically).
|
||||
# Bumped to the 2.0-only line now that Superset's own SQLAlchemy 2.0 core
|
||||
# bump has landed (discussion #40273).
|
||||
risingwave = ["sqlalchemy-risingwave>=2.0.0"]
|
||||
shillelagh = ["shillelagh[all]>=1.4.5, <2"]
|
||||
singlestore = ["sqlalchemy-singlestoredb>=1.2.1, <2"]
|
||||
snowflake = ["snowflake-sqlalchemy>=1.11.0, <2"]
|
||||
|
||||
@@ -23,25 +23,5 @@ python_files = *_test.py test_*.py *_tests.py *viz/utils.py
|
||||
asyncio_mode = auto
|
||||
|
||||
# `ignore` is effectively equivalent to `-p no:warnings`.
|
||||
# Always print RemovedIn20Warning when SQLALCHEMY_WARN_20=1.
|
||||
# Additionally, raise errors for refactored RemovedIn20Warning cases to prevent regression.
|
||||
filterwarnings =
|
||||
ignore
|
||||
always::sqlalchemy.exc.RemovedIn20Warning
|
||||
error:Passing a string to Connection.execute\(\) is deprecated:sqlalchemy.exc.RemovedIn20Warning
|
||||
error:"Query" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning
|
||||
error:"ReportExecutionLog" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning
|
||||
error:"ReportRecipients" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning
|
||||
error:"SavedQuery" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning
|
||||
error:"SqlaTable" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning
|
||||
error:"SqlMetric" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning
|
||||
error:"SSHTunnel" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning
|
||||
error:"TableColumn" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning
|
||||
error:"TaggedObject" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning
|
||||
error:The autoload parameter is deprecated:sqlalchemy.exc.RemovedIn20Warning
|
||||
error:The connection.execute\(\) method:sqlalchemy.exc.RemovedIn20Warning
|
||||
error:The current statement is being autocommitted using implicit autocommit:sqlalchemy.exc.RemovedIn20Warning
|
||||
error:The ``declarative_base\(\)`` function is now available:sqlalchemy.exc.RemovedIn20Warning
|
||||
error:The Engine.execute\(\) method is considered legacy:sqlalchemy.exc.RemovedIn20Warning
|
||||
error:The legacy calling style of select\(\) is deprecated:sqlalchemy.exc.RemovedIn20Warning
|
||||
error:The "whens" argument to case:sqlalchemy.exc.RemovedIn20Warning
|
||||
|
||||
@@ -144,7 +144,7 @@ flask-migrate==4.1.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
flask-session==0.8.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
flask-sqlalchemy==2.5.1
|
||||
flask-sqlalchemy==3.1.1
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# flask-appbuilder
|
||||
@@ -381,7 +381,7 @@ six==1.17.0
|
||||
# wtforms-json
|
||||
slack-sdk==3.43.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
sqlalchemy==1.4.54
|
||||
sqlalchemy==2.0.51
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# alembic
|
||||
@@ -419,6 +419,7 @@ typing-extensions==4.16.0
|
||||
# pyopenssl
|
||||
# referencing
|
||||
# shillelagh
|
||||
# sqlalchemy
|
||||
# typing-inspection
|
||||
typing-inspection==0.4.2
|
||||
# via pydantic
|
||||
|
||||
@@ -306,7 +306,7 @@ flask-session==0.8.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
flask-sqlalchemy==2.5.1
|
||||
flask-sqlalchemy==3.1.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -950,7 +950,7 @@ slack-sdk==3.43.0
|
||||
# apache-superset
|
||||
sniffio==1.3.1
|
||||
# via anyio
|
||||
sqlalchemy==1.4.54
|
||||
sqlalchemy==2.0.51
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# alembic
|
||||
@@ -1033,6 +1033,7 @@ typing-extensions==4.16.0
|
||||
# pyopenssl
|
||||
# referencing
|
||||
# shillelagh
|
||||
# sqlalchemy
|
||||
# starlette
|
||||
# typing-inspection
|
||||
typing-inspection==0.4.2
|
||||
|
||||
@@ -45,7 +45,7 @@ dependencies = [
|
||||
"isodate>=0.7.0",
|
||||
"pyarrow>=16.0.0",
|
||||
"pydantic>=2.8.0",
|
||||
"sqlalchemy>=1.4.0,<2.0",
|
||||
"sqlalchemy>=2.0.0,<2.1",
|
||||
"sqlalchemy-utils>=0.38.0, <0.43", # expanding lowerbound to work with pydoris
|
||||
"sqlglot>=30.8.0, <31",
|
||||
"typing-extensions>=4.0.0",
|
||||
|
||||
@@ -3215,16 +3215,17 @@
|
||||
"integrity": "sha512-Fc8Ne62jJlKHiG/ajlonC4Sd66Pq68fFwK4ihJGNZpGqboc324SQk+lRvMzpPRuJOmfrJefdG8/7JdWX4bzJ2Q=="
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
|
||||
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
@@ -11184,9 +11185,9 @@
|
||||
"integrity": "sha512-Fc8Ne62jJlKHiG/ajlonC4Sd66Pq68fFwK4ihJGNZpGqboc324SQk+lRvMzpPRuJOmfrJefdG8/7JdWX4bzJ2Q=="
|
||||
},
|
||||
"brace-expansion": {
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
|
||||
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"requires": {
|
||||
|
||||
@@ -36,6 +36,9 @@
|
||||
"cypress": {
|
||||
"form-data": "^2.3.4"
|
||||
},
|
||||
"minimatch@>=10": {
|
||||
"brace-expansion": ">=5.0.8"
|
||||
},
|
||||
"qs": "^6.14.2",
|
||||
"uuid": "^11.1.1"
|
||||
},
|
||||
|
||||
@@ -79,8 +79,8 @@
|
||||
"@visx/scale": "^4.0.0",
|
||||
"@visx/tooltip": "^4.0.0",
|
||||
"@visx/xychart": "^4.0.0",
|
||||
"ag-grid-community": "36.0.2",
|
||||
"ag-grid-react": "36.0.2",
|
||||
"ag-grid-community": "36.1.0",
|
||||
"ag-grid-react": "36.1.0",
|
||||
"antd": "^6.5.3",
|
||||
"chrono-node": "^2.10.1",
|
||||
"classnames": "^2.2.5",
|
||||
@@ -100,7 +100,7 @@
|
||||
"geostyler-style": "11.0.2",
|
||||
"geostyler-wfs-parser": "^3.0.1",
|
||||
"google-auth-library": "^11.0.0",
|
||||
"immer": "^11.1.15",
|
||||
"immer": "^11.1.16",
|
||||
"interweave": "^13.1.1",
|
||||
"jquery": "^4.0.0",
|
||||
"js-levenshtein": "^1.1.6",
|
||||
@@ -108,7 +108,7 @@
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.18.1",
|
||||
"mapbox-gl": "^3.27.0",
|
||||
"mapbox-gl": "^3.28.0",
|
||||
"markdown-to-jsx": "^9.10.2",
|
||||
"match-sorter": "^8.3.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
@@ -272,7 +272,7 @@
|
||||
"swc-loader": "^0.2.7",
|
||||
"ts-jest": "^29.4.12",
|
||||
"tscw-config": "^1.1.2",
|
||||
"tsx": "^4.23.5",
|
||||
"tsx": "^4.23.9",
|
||||
"typescript": "5.4.5",
|
||||
"unzipper": "^0.12.5",
|
||||
"wait-on": "^9.1.0",
|
||||
@@ -15025,28 +15025,28 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ag-charts-types": {
|
||||
"version": "14.0.2",
|
||||
"resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-14.0.2.tgz",
|
||||
"integrity": "sha512-F7ZG0g8Y+iKhJi50AfZRwEyUM/TBsNyh2IoXB0JaDN97lnbemIK8GE5kF1eBtXtN4mcC+lPXK9oZUeVXwO9EWA==",
|
||||
"version": "14.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-14.1.0.tgz",
|
||||
"integrity": "sha512-mmzkng88c0l+Z9PvCMowMilhVeNgDU/iMuMemcOQD4BG/qO4vbkxWvyQO0iqju8Dx7YOY81PNzC/RmElQNiVkA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ag-grid-community": {
|
||||
"version": "36.0.2",
|
||||
"resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-36.0.2.tgz",
|
||||
"integrity": "sha512-TINZfuFvMY2nc3JfQHiUWT7dNIxI89ZxS5XkXIPi/rYICoNupRqpaM41KVzGPPfSkM0AwhuzTFxAiF08zEkV1Q==",
|
||||
"version": "36.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-36.1.0.tgz",
|
||||
"integrity": "sha512-WnvSQ4csRs8gv/b1B0lPHykQXvUJVgi2u5mpY0Aa6dv3pvhDVVqCT8dwUyOeCog4JNPptaXGrOrZ8qP2XoJzVA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ag-charts-types": "14.0.2",
|
||||
"ag-stack": "36.0.2"
|
||||
"ag-charts-types": "14.1.0",
|
||||
"ag-stack": "36.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ag-grid-react": {
|
||||
"version": "36.0.2",
|
||||
"resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-36.0.2.tgz",
|
||||
"integrity": "sha512-yVPmqdhx1zp06FLyZmwmIxIO57w4ko+qN64MXgPlBMJVL0MNA5hULqAY/+SoB4cd0bBHqz8okuuAbHHpu5QoHQ==",
|
||||
"version": "36.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-36.1.0.tgz",
|
||||
"integrity": "sha512-FNsmrOmr+taZY11sCA+6Ow0mKs2JqnmrjBrMjkKC2rVTXPARtks+tv+Ne4ZkLGGtvIMDlBFCDbSzFXVP0CdwXQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ag-grid-community": "36.0.2",
|
||||
"ag-grid-community": "36.1.0",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -15055,9 +15055,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ag-stack": {
|
||||
"version": "36.0.2",
|
||||
"resolved": "https://registry.npmjs.org/ag-stack/-/ag-stack-36.0.2.tgz",
|
||||
"integrity": "sha512-YuhQExQw5YsWK0wxrksRyYBAqOU0v08lJH5uxRsKx+49ko5vkDgnJuhX4yF995BBVdLY1LKlXukLEub+olKyuA==",
|
||||
"version": "36.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ag-stack/-/ag-stack-36.1.0.tgz",
|
||||
"integrity": "sha512-Kmkf5iRZmyduNx2KtW450j6GbCdJ4ejacSpb1AWl0yrdquRmScLRc1SlLolx6lyCbTBSJGo4tRraKbBzre+GlQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
@@ -18005,11 +18005,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "3.49.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
|
||||
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
|
||||
"version": "3.50.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz",
|
||||
"integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/core-js"
|
||||
@@ -24184,9 +24187,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "11.1.15",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.15.tgz",
|
||||
"integrity": "sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ==",
|
||||
"version": "11.1.16",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.16.tgz",
|
||||
"integrity": "sha512-Xs7H9rBc+kti1J6RueUvbEBkmOz7jqj11XYgf+YMXAYzu8EeE7hwZ9poLXdVfVnGmJu7QAf41T7H2KuF6QoK6Q==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -28745,15 +28748,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mapbox-gl": {
|
||||
"version": "3.27.0",
|
||||
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.27.0.tgz",
|
||||
"integrity": "sha512-K8W9LTTjFEJsg9qsnJbKk+zbXrmSqa+nU1EiFXez5gQ0T0RMtylZUelgg1/RE6vCUMvHX0gaYfWU9g2mTWuA0g==",
|
||||
"version": "3.28.0",
|
||||
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.28.0.tgz",
|
||||
"integrity": "sha512-WEbvl2ju0MUZ+R83HeCosmJBTyYdhmFcajeQ7kwLyJ0EHUw9YG/k2QLcMmAQ8sXZpkWq1BbmfjT5lh/oInOnCw==",
|
||||
"license": "SEE LICENSE IN LICENSE.txt",
|
||||
"workspaces": [
|
||||
"src/style-spec",
|
||||
"plugins/mapbox-gl-pmtiles-provider",
|
||||
"test/build/vite",
|
||||
"test/build/webpack",
|
||||
"test/bundlers/*",
|
||||
"test/build/typings"
|
||||
]
|
||||
},
|
||||
@@ -39964,9 +39966,9 @@
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.23.5",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.5.tgz",
|
||||
"integrity": "sha512-rw55FUaqOoI7RvlQwLbhO4nSDApnQ4/CykPuiQ/EPvtrX3WA9Ig55jIt9VvbBJbzJuj12ueRu4PMZ2SxPVbihg==",
|
||||
"version": "4.23.9",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.9.tgz",
|
||||
"integrity": "sha512-6q8uTORRGauQVjqMQnKUucLFoeXZAfw6zKvG35GLbdKWbLdeOtZ3H4mhyA5mxuUd2o2cRTskhj59nLLQseUvUw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -43229,11 +43231,11 @@
|
||||
"@types/json-bigint": "^1.0.4",
|
||||
"@visx/responsive": "^4.0.0",
|
||||
"ace-builds": "^1.44.0",
|
||||
"ag-grid-community": "36.0.2",
|
||||
"ag-grid-react": "36.0.2",
|
||||
"ag-grid-community": "36.1.0",
|
||||
"ag-grid-react": "36.1.0",
|
||||
"brace": "^0.11.1",
|
||||
"classnames": "^2.5.1",
|
||||
"core-js": "^3.49.0",
|
||||
"core-js": "^3.50.0",
|
||||
"csstype": "^3.2.3",
|
||||
"d3-format": "^3.1.2",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
@@ -43934,7 +43936,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@math.gl/web-mercator": "^4.1.0",
|
||||
"mapbox-gl": "^3.27.0",
|
||||
"mapbox-gl": "^3.28.0",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"react-map-gl": "^8.1.2",
|
||||
"supercluster": "^8.0.1"
|
||||
|
||||
@@ -78,6 +78,7 @@
|
||||
"playwright:debug": "playwright test --debug",
|
||||
"playwright:report": "playwright show-report",
|
||||
"docs:screenshots": "playwright test --config=playwright/generators/playwright.config.ts docs/",
|
||||
"playwright:thumbnails": "CAPTURE_THUMBNAILS=1 playwright test tests/tools/capture-viz-thumbnails.spec.ts --project chromium",
|
||||
"prod": "npm run build",
|
||||
"prune": "rm -rf ./{packages,plugins}/*/{node_modules,lib,esm,tsconfig.tsbuildinfo,package-lock.json} ./.temp_cache",
|
||||
"storybook": "cross-env NODE_ENV=development BABEL_ENV=development storybook dev -p 6006",
|
||||
@@ -156,8 +157,8 @@
|
||||
"@visx/scale": "^4.0.0",
|
||||
"@visx/tooltip": "^4.0.0",
|
||||
"@visx/xychart": "^4.0.0",
|
||||
"ag-grid-community": "36.0.2",
|
||||
"ag-grid-react": "36.0.2",
|
||||
"ag-grid-community": "36.1.0",
|
||||
"ag-grid-react": "36.1.0",
|
||||
"antd": "^6.5.3",
|
||||
"chrono-node": "^2.10.1",
|
||||
"classnames": "^2.2.5",
|
||||
@@ -177,7 +178,7 @@
|
||||
"geostyler-style": "11.0.2",
|
||||
"geostyler-wfs-parser": "^3.0.1",
|
||||
"google-auth-library": "^11.0.0",
|
||||
"immer": "^11.1.15",
|
||||
"immer": "^11.1.16",
|
||||
"interweave": "^13.1.1",
|
||||
"jquery": "^4.0.0",
|
||||
"js-levenshtein": "^1.1.6",
|
||||
@@ -185,7 +186,7 @@
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.18.1",
|
||||
"mapbox-gl": "^3.27.0",
|
||||
"mapbox-gl": "^3.28.0",
|
||||
"markdown-to-jsx": "^9.10.2",
|
||||
"match-sorter": "^8.3.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
@@ -349,7 +350,7 @@
|
||||
"swc-loader": "^0.2.7",
|
||||
"ts-jest": "^29.4.12",
|
||||
"tscw-config": "^1.1.2",
|
||||
"tsx": "^4.23.5",
|
||||
"tsx": "^4.23.9",
|
||||
"typescript": "5.4.5",
|
||||
"unzipper": "^0.12.5",
|
||||
"wait-on": "^9.1.0",
|
||||
@@ -412,6 +413,9 @@
|
||||
"lerna": {
|
||||
"js-yaml": "^4.3.0"
|
||||
},
|
||||
"minimatch@>=10": {
|
||||
"brace-expansion": ">=5.0.8"
|
||||
},
|
||||
"nwsapi": "^2.2.13",
|
||||
"puppeteer": "^22.4.1",
|
||||
"tar": "^7.5.16",
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useLayoutEffect, type ReactNode } from 'react';
|
||||
import { render, screen, act } from '@testing-library/react';
|
||||
import { theme as antdThemeImport } from 'antd';
|
||||
import { Theme } from './Theme';
|
||||
|
||||
// SupersetThemeProvider stores theme state via React.useState, then
|
||||
// registers a listener (in a useLayoutEffect, on mount) that calls
|
||||
// setThemeState whenever a *later* call to setConfig/toggleDarkMode runs
|
||||
// on the same Theme instance. Every provider currently mounted from that
|
||||
// instance listens independently, so toggling the instance updates all of
|
||||
// them, not just the most recently rendered one. Consumers
|
||||
// (docs/src/components/StorybookWrapper.jsx in particular) rely on this:
|
||||
// they call toggleDarkMode() from outside, on one or more already-mounted
|
||||
// providers sharing a single Theme instance, expecting it to propagate to
|
||||
// all of them.
|
||||
//
|
||||
// The probe below reads the theme via antd's theme.useToken() -- the same
|
||||
// context-consumption path every real antd component (Button, Input, ...)
|
||||
// uses internally -- rather than reading themeObject.theme directly off the
|
||||
// singleton. That distinction matters: React bails out of re-rendering a
|
||||
// child whose element reference didn't change (the common "static children
|
||||
// prop" case, true here since <Probe /> is passed once and never
|
||||
// recreated), UNLESS that child consumes a React Context whose value
|
||||
// changed, which bypasses the bail-out. A probe reading the plain object
|
||||
// directly would misleadingly appear "not updated" even though every real
|
||||
// themed component downstream re-renders correctly.
|
||||
function makeProbe() {
|
||||
let renderCount = 0;
|
||||
let lastColorBgBase: string | undefined;
|
||||
function Probe() {
|
||||
const { token } = antdThemeImport.useToken();
|
||||
renderCount += 1;
|
||||
lastColorBgBase = token.colorBgBase;
|
||||
return <div data-test="probe" />;
|
||||
}
|
||||
return {
|
||||
Probe,
|
||||
getRenderCount: () => renderCount,
|
||||
getLastColorBgBase: () => lastColorBgBase,
|
||||
};
|
||||
}
|
||||
|
||||
test('an already-mounted SupersetThemeProvider re-renders context-consuming children when toggleDarkMode is called on the same instance', () => {
|
||||
const themeObject = Theme.fromConfig();
|
||||
const { Probe, getRenderCount, getLastColorBgBase } = makeProbe();
|
||||
|
||||
render(
|
||||
<themeObject.SupersetThemeProvider>
|
||||
<Probe />
|
||||
</themeObject.SupersetThemeProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('probe')).toBeTruthy();
|
||||
const rendersBefore = getRenderCount();
|
||||
const tokenBefore = getLastColorBgBase();
|
||||
|
||||
act(() => {
|
||||
themeObject.toggleDarkMode(true);
|
||||
});
|
||||
|
||||
expect(getRenderCount()).toBeGreaterThan(rendersBefore);
|
||||
expect(getLastColorBgBase()).not.toBe(tokenBefore);
|
||||
});
|
||||
|
||||
test('toggleDarkMode updates every concurrently mounted provider for the same theme instance', () => {
|
||||
const themeObject = Theme.fromConfig();
|
||||
const first = makeProbe();
|
||||
const second = makeProbe();
|
||||
|
||||
render(
|
||||
<>
|
||||
<themeObject.SupersetThemeProvider>
|
||||
<first.Probe />
|
||||
</themeObject.SupersetThemeProvider>
|
||||
<themeObject.SupersetThemeProvider>
|
||||
<second.Probe />
|
||||
</themeObject.SupersetThemeProvider>
|
||||
</>,
|
||||
);
|
||||
|
||||
const firstTokenBefore = first.getLastColorBgBase();
|
||||
const secondTokenBefore = second.getLastColorBgBase();
|
||||
|
||||
act(() => {
|
||||
themeObject.toggleDarkMode(true);
|
||||
});
|
||||
|
||||
// Both providers share the same Theme instance, so both must pick up the
|
||||
// toggle -- not just whichever one rendered last.
|
||||
expect(first.getLastColorBgBase()).not.toBe(firstTokenBefore);
|
||||
expect(second.getLastColorBgBase()).not.toBe(secondTokenBefore);
|
||||
});
|
||||
|
||||
test('a toggleDarkMode call on a different theme instance does not affect a mounted provider', () => {
|
||||
const mounted = Theme.fromConfig();
|
||||
const other = Theme.fromConfig();
|
||||
const { Probe, getRenderCount, getLastColorBgBase } = makeProbe();
|
||||
|
||||
render(
|
||||
<mounted.SupersetThemeProvider>
|
||||
<Probe />
|
||||
</mounted.SupersetThemeProvider>,
|
||||
);
|
||||
|
||||
const rendersBefore = getRenderCount();
|
||||
const tokenBefore = getLastColorBgBase();
|
||||
|
||||
act(() => {
|
||||
other.toggleDarkMode(true);
|
||||
});
|
||||
|
||||
// Each Theme instance owns its own set of provider listeners; toggling a
|
||||
// *different* instance must not re-render a provider mounted from another.
|
||||
expect(getRenderCount()).toBe(rendersBefore);
|
||||
expect(getLastColorBgBase()).toBe(tokenBefore);
|
||||
});
|
||||
|
||||
test('a toggleDarkMode call after a provider unmounts does not throw and no longer updates it', () => {
|
||||
const themeObject = Theme.fromConfig();
|
||||
const { Probe, getRenderCount, getLastColorBgBase } = makeProbe();
|
||||
|
||||
const { unmount } = render(
|
||||
<themeObject.SupersetThemeProvider>
|
||||
<Probe />
|
||||
</themeObject.SupersetThemeProvider>,
|
||||
);
|
||||
|
||||
const rendersBefore = getRenderCount();
|
||||
const tokenBefore = getLastColorBgBase();
|
||||
|
||||
unmount();
|
||||
|
||||
expect(() => {
|
||||
act(() => {
|
||||
themeObject.toggleDarkMode(true);
|
||||
});
|
||||
}).not.toThrow();
|
||||
|
||||
// The unmounted provider's listener was deregistered, so it shouldn't
|
||||
// have re-rendered (or updated) in response to the toggle.
|
||||
expect(getRenderCount()).toBe(rendersBefore);
|
||||
expect(getLastColorBgBase()).toBe(tokenBefore);
|
||||
});
|
||||
|
||||
test('a toggleDarkMode call from an ancestor layout effect during the initial commit is not dropped', () => {
|
||||
// Regression harness for the initial-mount race: StorybookWrapper.jsx
|
||||
// toggles the singleton from its own layout effect (ThemeSync) as soon
|
||||
// as a demo mounts. SupersetThemeProvider must have its listener
|
||||
// registered *before* that ancestor effect fires, which only holds if
|
||||
// registration itself runs in a layout effect -- layout effects fire
|
||||
// bottom-up, so this component (nested inside the toggling ancestor)
|
||||
// registers first. If that registration ever regresses to a plain
|
||||
// useEffect, it runs after the ancestor's toggle (passive effects are
|
||||
// deferred until after all layout effects), the notification is
|
||||
// dropped, and this probe would still show the pre-toggle palette.
|
||||
const lightBaseline = Theme.fromConfig();
|
||||
const baseline = makeProbe();
|
||||
|
||||
render(
|
||||
<lightBaseline.SupersetThemeProvider>
|
||||
<baseline.Probe />
|
||||
</lightBaseline.SupersetThemeProvider>,
|
||||
);
|
||||
|
||||
const lightColorBgBase = baseline.getLastColorBgBase();
|
||||
|
||||
const themeObject = Theme.fromConfig();
|
||||
const { Probe, getLastColorBgBase } = makeProbe();
|
||||
|
||||
function AncestorToggler({ children }: { children: ReactNode }) {
|
||||
useLayoutEffect(() => {
|
||||
themeObject.toggleDarkMode(true);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
return children;
|
||||
}
|
||||
|
||||
render(
|
||||
<AncestorToggler>
|
||||
<themeObject.SupersetThemeProvider>
|
||||
<Probe />
|
||||
</themeObject.SupersetThemeProvider>
|
||||
</AncestorToggler>,
|
||||
);
|
||||
|
||||
expect(getLastColorBgBase()).not.toBe(lightColorBgBase);
|
||||
});
|
||||
@@ -243,6 +243,47 @@ test('Theme.toggleDarkMode preserves other algorithms when toggling dark mode',
|
||||
expect(serialized.algorithm).not.toContain(ThemeAlgorithm.DARK);
|
||||
});
|
||||
|
||||
test('Theme.toggleDarkMode is a no-op when the requested mode is already active', () => {
|
||||
// Pages with many live component demos (see docs/src/components/
|
||||
// StorybookWrapper.jsx's ThemeSync) mount one dark-mode-sync bridge per
|
||||
// demo, so a single toggle event can call toggleDarkMode once per demo
|
||||
// with the same isDark value. Only the first of those calls should
|
||||
// actually recompute the theme and fan out to providers.
|
||||
const theme = Theme.fromConfig();
|
||||
const setConfigSpy = jest.spyOn(theme, 'setConfig');
|
||||
|
||||
theme.toggleDarkMode(true);
|
||||
expect(setConfigSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Repeating the same toggle should not recompute the theme again.
|
||||
theme.toggleDarkMode(true);
|
||||
theme.toggleDarkMode(true);
|
||||
expect(setConfigSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Toggling to the other mode should still go through.
|
||||
theme.toggleDarkMode(false);
|
||||
expect(setConfigSpy).toHaveBeenCalledTimes(2);
|
||||
|
||||
setConfigSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('Theme.toggleDarkMode no-op check accounts for other algorithms in the array', () => {
|
||||
// Start already in dark mode alongside a non-mode algorithm (compact).
|
||||
const theme = Theme.fromConfig({
|
||||
algorithm: [
|
||||
antdThemeImport.compactAlgorithm,
|
||||
antdThemeImport.darkAlgorithm,
|
||||
],
|
||||
});
|
||||
const setConfigSpy = jest.spyOn(theme, 'setConfig');
|
||||
|
||||
// Already dark, so this should be a no-op rather than reordering the array.
|
||||
theme.toggleDarkMode(true);
|
||||
expect(setConfigSpy).not.toHaveBeenCalled();
|
||||
|
||||
setConfigSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('Theme.toSerializedConfig serializes theme config correctly', () => {
|
||||
const theme = Theme.fromConfig({
|
||||
token: {
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
CacheProvider as EmotionCacheProvider,
|
||||
} from '@emotion/react';
|
||||
import createCache from '@emotion/cache';
|
||||
import { noop, mergeWith } from 'lodash-es';
|
||||
import { mergeWith } from 'lodash-es';
|
||||
import { GlobalStyles } from './GlobalStyles';
|
||||
import {
|
||||
AntdThemeConfig,
|
||||
@@ -156,8 +156,8 @@ export class Theme {
|
||||
}),
|
||||
} as SupersetTheme;
|
||||
|
||||
// Update the providers with the fully formed theme
|
||||
this.updateProviders(
|
||||
// Update every mounted provider with the fully formed theme
|
||||
this.notifyProviders(
|
||||
this.theme,
|
||||
this.antdConfig,
|
||||
createCache({ key: 'superset' }),
|
||||
@@ -196,6 +196,27 @@ export class Theme {
|
||||
newConfig.algorithm = newAlgorithm;
|
||||
}
|
||||
|
||||
// Skip the update (and the notifyProviders fan-out it triggers) if the
|
||||
// theme is already in the requested mode. Docs pages mount one
|
||||
// dark-mode-sync bridge per live component demo (see
|
||||
// docs/src/components/StorybookWrapper.jsx's ThemeSync), so a single
|
||||
// toggle event calls this once per demo on the page. Without this
|
||||
// check, every one of those calls would recompute the theme and
|
||||
// notify every mounted provider, turning a single real toggle into
|
||||
// O(n^2) provider notifications across n demos.
|
||||
// Compare the algorithm sets rather than positions: reordering
|
||||
// non-mode algorithms to the front doesn't change the effective
|
||||
// theme, so it shouldn't count as a change either.
|
||||
const currentAlgorithm = this.antdConfig.algorithm;
|
||||
const algorithmUnchanged = Array.isArray(newConfig.algorithm)
|
||||
? Array.isArray(currentAlgorithm) &&
|
||||
newConfig.algorithm.length === currentAlgorithm.length &&
|
||||
newConfig.algorithm.every(alg => currentAlgorithm.includes(alg))
|
||||
: newConfig.algorithm === currentAlgorithm;
|
||||
if (algorithmUnchanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the theme with the new configuration
|
||||
this.setConfig(newConfig);
|
||||
}
|
||||
@@ -204,13 +225,29 @@ export class Theme {
|
||||
return JSON.stringify(serializeThemeConfig(this.antdConfig), null, 2);
|
||||
}
|
||||
|
||||
private updateProviders(
|
||||
// Every currently-mounted SupersetThemeProvider for this Theme instance
|
||||
// registers a listener here (see the useEffect below). A single
|
||||
// "last write wins" callback isn't enough once more than one provider can
|
||||
// be mounted from the same Theme instance at a time -- e.g. multiple live
|
||||
// component demos on one docs page -- since each render would overwrite
|
||||
// the previous provider's callback and only the most-recently-rendered
|
||||
// provider would ever hear about a setConfig/toggleDarkMode call.
|
||||
private providerListeners = new Set<
|
||||
(
|
||||
theme: SupersetTheme,
|
||||
antdConfig: AntdThemeConfig,
|
||||
emotionCache: any,
|
||||
) => void
|
||||
>();
|
||||
|
||||
private notifyProviders(
|
||||
theme: SupersetTheme,
|
||||
antdConfig: AntdThemeConfig,
|
||||
emotionCache: any,
|
||||
): void {
|
||||
noop(theme, antdConfig, emotionCache);
|
||||
// Overridden at runtime by SupersetThemeProvider using setThemeState
|
||||
this.providerListeners.forEach(listener =>
|
||||
listener(theme, antdConfig, emotionCache),
|
||||
);
|
||||
}
|
||||
|
||||
SupersetThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
@@ -225,9 +262,42 @@ export class Theme {
|
||||
emotionCache: createCache({ key: 'superset' }),
|
||||
});
|
||||
|
||||
this.updateProviders = (theme, antdConfig, emotionCache) => {
|
||||
setThemeState({ theme, antdConfig, emotionCache });
|
||||
};
|
||||
// Register (and, on unmount, deregister) this provider instance's own
|
||||
// listener rather than assigning a single shared callback on every
|
||||
// render, so every concurrently mounted provider for this Theme
|
||||
// instance receives updates, not just the last one to render.
|
||||
//
|
||||
// Use useLayoutEffect (not useEffect) so registration happens in the
|
||||
// same commit phase as any layout effect elsewhere that might call
|
||||
// setConfig/toggleDarkMode on this instance during mount (e.g. the
|
||||
// docs site's dark-mode sync in StorybookWrapper.jsx, which reads the
|
||||
// toggle and pushes it onto the singleton via a layout effect of its
|
||||
// own). Layout effects run bottom-up, so a listener registered here
|
||||
// (this component is nested inside that caller) is guaranteed to be
|
||||
// in place before an ancestor's layout effect can fire and notify it.
|
||||
// If this were a passive effect instead, an ancestor's layout effect
|
||||
// could call toggleDarkMode before this listener exists, dropping that
|
||||
// notification, and the provider would render stale until a later
|
||||
// toggle.
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
React.useLayoutEffect(() => {
|
||||
const listener = (
|
||||
nextTheme: SupersetTheme,
|
||||
nextAntdConfig: AntdThemeConfig,
|
||||
nextEmotionCache: any,
|
||||
) => {
|
||||
setThemeState({
|
||||
theme: nextTheme,
|
||||
antdConfig: nextAntdConfig,
|
||||
emotionCache: nextEmotionCache,
|
||||
});
|
||||
};
|
||||
this.providerListeners.add(listener);
|
||||
return () => {
|
||||
this.providerListeners.delete(listener);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<EmotionCacheProvider value={themeState.emotionCache}>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* 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 { QueryFormMetric } from '@superset-ui/core';
|
||||
import { getTotalsMetrics } from './getTotalsMetrics';
|
||||
|
||||
const simpleMetric = (aggregate: string): QueryFormMetric =>
|
||||
({
|
||||
label: 'simple_metric',
|
||||
expressionType: 'SIMPLE',
|
||||
column: { column_name: 'col' },
|
||||
aggregate,
|
||||
}) as QueryFormMetric;
|
||||
|
||||
const sqlMetric = (): QueryFormMetric =>
|
||||
({
|
||||
label: 'sql_metric',
|
||||
expressionType: 'SQL',
|
||||
sqlExpression: 'SUM(col) / COUNT(*)',
|
||||
}) as QueryFormMetric;
|
||||
|
||||
const savedMetric = (): QueryFormMetric => 'saved_metric';
|
||||
|
||||
describe('getTotalsMetrics', () => {
|
||||
test('overrides the aggregate on simple (adhoc) metrics', () => {
|
||||
const [result] = getTotalsMetrics([simpleMetric('SUM')], 'AVG');
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({ aggregate: 'AVG', expressionType: 'SIMPLE' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('is a no-op when the simple metric already uses the requested aggregate', () => {
|
||||
const [result] = getTotalsMetrics([simpleMetric('SUM')], 'SUM');
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({ aggregate: 'SUM', expressionType: 'SIMPLE' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('leaves custom SQL metrics unchanged', () => {
|
||||
const metric = sqlMetric();
|
||||
const [result] = getTotalsMetrics([metric], 'AVG');
|
||||
expect(result).toBe(metric);
|
||||
});
|
||||
|
||||
test('leaves saved (string) metrics unchanged', () => {
|
||||
const metric = savedMetric();
|
||||
const [result] = getTotalsMetrics([metric], 'AVG');
|
||||
expect(result).toBe(metric);
|
||||
});
|
||||
|
||||
test('handles a mix of metric types, only rewriting simple metrics', () => {
|
||||
const metrics = [simpleMetric('SUM'), sqlMetric(), savedMetric()];
|
||||
const result = getTotalsMetrics(metrics, 'AVG');
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0]).toEqual(expect.objectContaining({ aggregate: 'AVG' }));
|
||||
expect(result[1]).toBe(metrics[1]);
|
||||
expect(result[2]).toBe(metrics[2]);
|
||||
});
|
||||
|
||||
test('returns an empty array when given no metrics', () => {
|
||||
expect(getTotalsMetrics([], 'AVG')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 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 { isAdhocMetricSimple, QueryFormMetric } from '@superset-ui/core';
|
||||
|
||||
export type TotalsAggregate = 'SUM' | 'AVG';
|
||||
|
||||
/**
|
||||
* Build the metrics for a chart's "Show summary" totals query, overriding
|
||||
* each Simple (adhoc) metric's aggregate function with the user-chosen
|
||||
* totals aggregate. The totals query has no GROUP BY, so the database
|
||||
* evaluates each metric fresh over all rows -- swapping the aggregate here
|
||||
* is a correct, independent computation, not a re-aggregation of
|
||||
* already-aggregated per-row values.
|
||||
*
|
||||
* Custom-SQL metrics and saved (string) metrics pass through unchanged:
|
||||
* there is no safe way to rewrite an arbitrary SQL expression's aggregate
|
||||
* function without parsing it, so the totals row keeps their own native
|
||||
* aggregate for those.
|
||||
*/
|
||||
export function getTotalsMetrics(
|
||||
metrics: QueryFormMetric[],
|
||||
aggregate: TotalsAggregate,
|
||||
): QueryFormMetric[] {
|
||||
return metrics.map(metric =>
|
||||
isAdhocMetricSimple(metric) ? { ...metric, aggregate } : metric,
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export * from './selectOptions';
|
||||
export * from './D3Formatting';
|
||||
export * from './expandControlConfig';
|
||||
export * from './getColorFormatters';
|
||||
export * from './getTotalsMetrics';
|
||||
export { default as mainMetric } from './mainMetric';
|
||||
export { default as columnChoices, columnsByType } from './columnChoices';
|
||||
export * from './defineSavedMetrics';
|
||||
|
||||
@@ -56,11 +56,11 @@
|
||||
"@types/json-bigint": "^1.0.4",
|
||||
"@visx/responsive": "^4.0.0",
|
||||
"ace-builds": "^1.44.0",
|
||||
"ag-grid-community": "36.0.2",
|
||||
"ag-grid-react": "36.0.2",
|
||||
"ag-grid-community": "36.1.0",
|
||||
"ag-grid-react": "36.1.0",
|
||||
"brace": "^0.11.1",
|
||||
"classnames": "^2.5.1",
|
||||
"core-js": "^3.49.0",
|
||||
"core-js": "^3.50.0",
|
||||
"csstype": "^3.2.3",
|
||||
"d3-format": "^3.1.2",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
|
||||
@@ -17,12 +17,45 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { createRef } from 'react';
|
||||
import { render, fireEvent, screen } from '@superset-ui/core/spec';
|
||||
import { NoAnimationDropdown } from '.';
|
||||
import { MenuDotsDropdown, NoAnimationDropdown } from '.';
|
||||
|
||||
const props = {
|
||||
overlay: <div>Test Overlay</div>,
|
||||
};
|
||||
|
||||
describe('MenuDotsDropdown', () => {
|
||||
test('renders a focusable, labeled button trigger', () => {
|
||||
render(<MenuDotsDropdown {...props} />);
|
||||
expect(screen.getByTestId('dropdown-trigger')).toEqual(
|
||||
screen.getByRole('button', { name: 'Actions' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('forwards a ref to the trigger so callers can focus it programmatically', () => {
|
||||
const ref = createRef<HTMLButtonElement>();
|
||||
render(<MenuDotsDropdown {...props} ref={ref} />);
|
||||
ref.current?.focus();
|
||||
expect(screen.getByTestId('dropdown-trigger')).toHaveFocus();
|
||||
});
|
||||
|
||||
test('opens the menu when activated with the keyboard', async () => {
|
||||
// Callers (e.g. the SQL Lab tab menu) open the dropdown on click, since
|
||||
// antd's default trigger is hover, which keyboard activation can't
|
||||
// reach.
|
||||
render(<MenuDotsDropdown {...props} trigger={['click']} />);
|
||||
const trigger = screen.getByTestId('dropdown-trigger');
|
||||
trigger.focus();
|
||||
// A native <button> converts an Enter keypress into a click once
|
||||
// activated, so we simulate that browser behavior directly since
|
||||
// jsdom does not implement it for us.
|
||||
fireEvent.keyDown(trigger, { key: 'Enter', code: 'Enter' });
|
||||
fireEvent.click(trigger);
|
||||
expect(await screen.findByText('Test Overlay')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('NoAnimationDropdown', () => {
|
||||
test('requires children', () => {
|
||||
expect(() => {
|
||||
|
||||
@@ -16,10 +16,11 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ReactElement, cloneElement } from 'react';
|
||||
import { ReactElement, cloneElement, forwardRef } from 'react';
|
||||
|
||||
import { Dropdown as AntdDropdown, DropdownProps } from 'antd';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import {
|
||||
IconOrientation,
|
||||
@@ -65,11 +66,14 @@ const MenuDots = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const MenuDotsWrapper = styled.div`
|
||||
const MenuDotsWrapper = styled.button`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: ${({ theme }) => theme.sizeUnit * 2}px;
|
||||
padding-left: ${({ theme }) => theme.sizeUnit}px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
const RenderIcon = (
|
||||
@@ -84,17 +88,23 @@ const RenderIcon = (
|
||||
return component;
|
||||
};
|
||||
|
||||
export const MenuDotsDropdown = ({
|
||||
overlay,
|
||||
iconOrientation = IconOrientation.Vertical,
|
||||
...rest
|
||||
}: MenuDotsDropdownProps) => (
|
||||
export const MenuDotsDropdown = forwardRef<
|
||||
HTMLButtonElement,
|
||||
MenuDotsDropdownProps
|
||||
>(({ overlay, iconOrientation = IconOrientation.Vertical, ...rest }, ref) => (
|
||||
<AntdDropdown popupRender={() => overlay} {...rest}>
|
||||
<MenuDotsWrapper data-test="dropdown-trigger">
|
||||
<MenuDotsWrapper
|
||||
ref={ref}
|
||||
type="button"
|
||||
aria-label={t('Actions')}
|
||||
data-test="dropdown-trigger"
|
||||
>
|
||||
{RenderIcon(iconOrientation)}
|
||||
</MenuDotsWrapper>
|
||||
</AntdDropdown>
|
||||
);
|
||||
));
|
||||
|
||||
MenuDotsDropdown.displayName = 'MenuDotsDropdown';
|
||||
|
||||
export const NoAnimationDropdown = (props: NoAnimationDropdownProps) => {
|
||||
const { children, onBlur, onKeyDown, ...rest } = props;
|
||||
|
||||
@@ -49,6 +49,7 @@ const titleStyles = (theme: SupersetTheme) => css`
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 0;
|
||||
font-weight: inherit;
|
||||
color: ${theme.colorText};
|
||||
background-color: ${theme.colorBgContainer};
|
||||
|
||||
@@ -127,6 +128,21 @@ export const DynamicEditableTitle = memo(
|
||||
}
|
||||
}, [currentTitle, placeholder]);
|
||||
|
||||
// Webfont metrics differ from the fallback font's, so a measurement
|
||||
// taken before fonts finish loading under- or over-sizes the input.
|
||||
// Re-measure once all fonts are ready.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
document.fonts?.ready?.then(() => {
|
||||
if (!cancelled && sizerRef.current) {
|
||||
setInputWidth(sizerRef.current.offsetWidth);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const inputElement = inputRef.current?.input;
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import { ReactNode, ReactElement, memo } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { css, SupersetTheme, useTheme } from '@apache-superset/core/theme';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { FeatureFlag, isFeatureEnabled } from '../../utils/featureFlags';
|
||||
import type { DropdownProps } from '../Dropdown/types';
|
||||
import type { TooltipPlacement } from '../Tooltip/types';
|
||||
import type { CertifiedBadgeProps } from '../CertifiedBadge/types';
|
||||
@@ -82,6 +83,20 @@ const headerStyles = (theme: SupersetTheme) => css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Mobile consumption mode: center the title between left/right panels */
|
||||
${
|
||||
isFeatureEnabled(FeatureFlag.MobileConsumptionMode) &&
|
||||
css`
|
||||
@media (max-width: ${theme.screenSMMax}px) {
|
||||
.title-panel {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
`;
|
||||
|
||||
const buttonsStyles = (theme: SupersetTheme) => css`
|
||||
@@ -109,6 +124,7 @@ export type PageHeaderWithActionsProps = {
|
||||
showFaveStar: boolean;
|
||||
showMenuDropdown?: boolean;
|
||||
faveStarProps: FaveStarProps;
|
||||
leftPanelItems?: ReactNode;
|
||||
titlePanelAdditionalItems: ReactNode;
|
||||
rightPanelAdditionalItems: ReactNode;
|
||||
additionalActionsMenu: ReactElement;
|
||||
@@ -126,6 +142,7 @@ export const PageHeaderWithActions = memo(
|
||||
certificatiedBadgeProps,
|
||||
showFaveStar,
|
||||
faveStarProps,
|
||||
leftPanelItems,
|
||||
titlePanelAdditionalItems,
|
||||
rightPanelAdditionalItems,
|
||||
additionalActionsMenu,
|
||||
@@ -136,6 +153,7 @@ export const PageHeaderWithActions = memo(
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<div css={headerStyles} className="header-with-actions">
|
||||
{leftPanelItems}
|
||||
<div className="title-panel">
|
||||
<DynamicEditableTitle {...editableTitleProps} />
|
||||
{showTitlePanelItems && (
|
||||
|
||||
@@ -989,6 +989,52 @@ test('shows all options when filterOption is false', async () => {
|
||||
expect(options[0]).toHaveTextContent('Server 0');
|
||||
});
|
||||
|
||||
test('renders a server-matched option whose label diverges from the search term when filterOption is false (regression for #42041)', async () => {
|
||||
// Mirrors the real permissions-search bug: the remote fetch legitimately
|
||||
// matches the raw, underscore-containing value (e.g. a schema name like
|
||||
// "stg_silver"), but the returned option's displayed label has had
|
||||
// underscores replaced with spaces (see formatPermissionLabel in
|
||||
// features/roles/utils.ts). filterOption defaults to true, which
|
||||
// re-filters already-matched options against that same relabeled text
|
||||
// client-side, so the underscore search term never matches and the
|
||||
// legitimately fetched option gets hidden -- this is why
|
||||
// PermissionsField (features/roles/RoleFormItems.tsx) sets
|
||||
// filterOption={false}: the loader is already the authoritative filter,
|
||||
// and its match doesn't depend on the label used to render the option.
|
||||
const searchData = [{ label: 'stg silver', value: 100 }];
|
||||
const loadOptions = jest.fn(async (search: string) =>
|
||||
// totalCount must exceed the empty initial page here, otherwise
|
||||
// AsyncSelect marks allValuesLoaded and short-circuits every later
|
||||
// fetch, including the search request this test depends on.
|
||||
search === ''
|
||||
? { data: [], totalCount: 1 }
|
||||
: { data: searchData, totalCount: 1 },
|
||||
);
|
||||
|
||||
render(
|
||||
<AsyncSelect
|
||||
{...defaultProps}
|
||||
options={loadOptions}
|
||||
filterOption={false}
|
||||
/>,
|
||||
);
|
||||
await open();
|
||||
|
||||
await type('stg_silver');
|
||||
await waitFor(() =>
|
||||
expect(loadOptions).toHaveBeenCalledWith(
|
||||
'stg_silver',
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
),
|
||||
);
|
||||
|
||||
// The backend legitimately matched and returned this option (asserted
|
||||
// above); it should render in the dropdown despite the search term using
|
||||
// underscores while the label uses spaces.
|
||||
expect(await findSelectOption('stg silver')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('preserves new option entry across search fetch when allowNewOptions is on', async () => {
|
||||
const page0Data = Array.from({ length: 10 }, (_, i) => ({
|
||||
label: `Option ${i}`,
|
||||
|
||||
@@ -57,6 +57,7 @@ export enum FeatureFlag {
|
||||
GranularExportControls = 'GRANULAR_EXPORT_CONTROLS',
|
||||
ListviewsDefaultCardView = 'LISTVIEWS_DEFAULT_CARD_VIEW',
|
||||
Matrixify = 'MATRIXIFY',
|
||||
MobileConsumptionMode = 'MOBILE_CONSUMPTION_MODE',
|
||||
ScheduledQueries = 'SCHEDULED_QUERIES',
|
||||
SemanticLayers = 'SEMANTIC_LAYERS',
|
||||
SoftDelete = 'SOFT_DELETE',
|
||||
|
||||
@@ -336,3 +336,29 @@ test('getErrorText', async () => {
|
||||
),
|
||||
).toEqual('Sorry, an unknown error occurred.');
|
||||
});
|
||||
|
||||
test('getErrorText for a non-JSON 403 response', async () => {
|
||||
// A 403 originating outside Superset (reverse proxy, WAF, SSO gateway)
|
||||
// carries an HTML or plain-text body instead of the API's JSON
|
||||
// `{"message": "Forbidden"}`, so it must fall back to the generic
|
||||
// status-derived text rather than the permission-denied copy.
|
||||
const proxyForbidden = new Response(
|
||||
'<html><head><title>403 Forbidden</title></head><body>Forbidden</body></html>',
|
||||
{
|
||||
status: 403,
|
||||
statusText: 'Forbidden',
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
},
|
||||
);
|
||||
expect(await getErrorText(proxyForbidden, 'dashboard')).toEqual(
|
||||
'Sorry, there was an error saving this dashboard: Forbidden',
|
||||
);
|
||||
|
||||
const supersetForbidden = new Response(
|
||||
JSON.stringify({ message: 'Forbidden' }),
|
||||
{ status: 403, statusText: 'FORBIDDEN' },
|
||||
);
|
||||
expect(await getErrorText(supersetForbidden, 'dashboard')).toEqual(
|
||||
'You do not have permission to edit this dashboard',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -96,6 +96,7 @@ export default defineConfig({
|
||||
'**/tests/auth/**/*.spec.ts',
|
||||
'**/tests/sqllab/**/*.spec.ts',
|
||||
'**/tests/embedded/**/*.spec.ts',
|
||||
'**/tests/mobile/**/*.spec.ts',
|
||||
...(process.env.INCLUDE_EXPERIMENTAL ? [] : ['**/experimental/**']),
|
||||
],
|
||||
use: {
|
||||
@@ -156,6 +157,23 @@ export default defineConfig({
|
||||
},
|
||||
]
|
||||
: []),
|
||||
// Mobile consumption-mode tests need the MOBILE_CONSUMPTION_MODE feature
|
||||
// flag enabled in the Flask backend (the workflow's mobile step sets
|
||||
// SUPERSET_FEATURE_MOBILE_CONSUMPTION_MODE), so they only run when the
|
||||
// environment opts in. Same strict 'true' check as INCLUDE_EMBEDDED.
|
||||
...(process.env.INCLUDE_MOBILE?.toLowerCase() === 'true'
|
||||
? [
|
||||
{
|
||||
name: 'chromium-mobile',
|
||||
testMatch: '**/tests/mobile/**/*.spec.ts',
|
||||
use: {
|
||||
browserName: 'chromium' as const,
|
||||
testIdAttribute: 'data-test',
|
||||
storageState: 'playwright/.auth/user.json',
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
|
||||
// Web server setup - disabled in CI (Flask started separately in workflow)
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Mobile Experience Documentation Screenshot Generator
|
||||
*
|
||||
* Captures phone-sized screenshots for the mobile consumption mode docs
|
||||
* (docs/docs/using-superset/mobile-experience.mdx). Depends on example data
|
||||
* loaded via `superset load_examples` AND the MOBILE_CONSUMPTION_MODE
|
||||
* feature flag being enabled in the target environment:
|
||||
*
|
||||
* FEATURE_FLAGS = {"MOBILE_CONSUMPTION_MODE": True}
|
||||
*
|
||||
* Run locally:
|
||||
* cd superset-frontend
|
||||
* PLAYWRIGHT_BASE_URL=http://localhost:8088 PLAYWRIGHT_ADMIN_PASSWORD=admin npm run docs:screenshots
|
||||
*
|
||||
* Screenshots are saved under docs/static/img/screenshots/mobile/.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { Page, test, expect } from '@playwright/test';
|
||||
import { URL } from '../../utils/urls';
|
||||
|
||||
const MOBILE_SCREENSHOTS_DIR = path.resolve(
|
||||
__dirname,
|
||||
'../../../../docs/static/img/screenshots/mobile',
|
||||
);
|
||||
// Committed to the repo alongside the generated images, but create it
|
||||
// defensively in case someone deletes the directory and re-runs this
|
||||
// generator standalone (Playwright does not create missing parent
|
||||
// directories for screenshot paths).
|
||||
fs.mkdirSync(MOBILE_SCREENSHOTS_DIR, { recursive: true });
|
||||
|
||||
// iPhone 12-class viewport; 2x scale factor for crisp docs images
|
||||
test.use({
|
||||
viewport: { width: 390, height: 844 },
|
||||
deviceScaleFactor: 2,
|
||||
hasTouch: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Waits for animations and async renders to settle before taking a
|
||||
* screenshot. ECharts entry animations, drawer transitions, and image
|
||||
* lazy-loading require a short pause that can't be expressed as a
|
||||
* deterministic wait condition.
|
||||
*/
|
||||
async function settle(page: Page, ms = 1000): Promise<void> {
|
||||
await page.waitForTimeout(ms);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the Sales Dashboard (from example data) at phone size and waits for
|
||||
* the stacked charts to finish rendering.
|
||||
*/
|
||||
async function openSalesDashboardMobile(page: Page): Promise<void> {
|
||||
await page.goto(URL.DASHBOARD_LIST);
|
||||
// Mobile list is card-only; cards navigate on tap (titles are plain
|
||||
// text, not links, in consumption mode)
|
||||
const dashboardCard = page.getByText('Sales Dashboard', { exact: true });
|
||||
await expect(dashboardCard.first()).toBeVisible({ timeout: 15000 });
|
||||
await dashboardCard.first().click();
|
||||
|
||||
await expect(
|
||||
page.locator('[data-test="dashboard-content-wrapper"]'),
|
||||
).toBeVisible({ timeout: 30000 });
|
||||
await expect(
|
||||
page.locator('.dashboard-component-chart-holder canvas').first(),
|
||||
).toBeVisible({ timeout: 30000 });
|
||||
}
|
||||
|
||||
test('mobile dashboard screenshot', async ({ page }) => {
|
||||
await openSalesDashboardMobile(page);
|
||||
await settle(page, 2000);
|
||||
await page.screenshot({
|
||||
path: path.join(MOBILE_SCREENSHOTS_DIR, 'mobile_dashboard.jpg'),
|
||||
type: 'jpeg',
|
||||
});
|
||||
});
|
||||
|
||||
test('mobile dashboard filter drawer screenshot', async ({ page }) => {
|
||||
await openSalesDashboardMobile(page);
|
||||
|
||||
const filterTrigger = page.locator('[data-test="mobile-filters-trigger"]');
|
||||
await expect(filterTrigger).toBeVisible({ timeout: 15000 });
|
||||
await filterTrigger.click();
|
||||
|
||||
// Wait for the drawer and its filter controls to render
|
||||
await expect(page.locator('.ant-drawer-body')).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(page.locator('[data-test="filter-bar"]')).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
// Park the pointer so no hover card is open in the capture
|
||||
await page.mouse.move(5, 830);
|
||||
await settle(page);
|
||||
await page.screenshot({
|
||||
path: path.join(MOBILE_SCREENSHOTS_DIR, 'mobile_filter_drawer.jpg'),
|
||||
type: 'jpeg',
|
||||
});
|
||||
});
|
||||
|
||||
test('mobile dashboard list screenshot', async ({ page }) => {
|
||||
await page.goto(URL.DASHBOARD_LIST);
|
||||
// Card view is forced on mobile; wait for cards to render
|
||||
await expect(page.locator('[data-test="styled-card"]').first()).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
await settle(page);
|
||||
await page.screenshot({
|
||||
path: path.join(MOBILE_SCREENSHOTS_DIR, 'mobile_dashboard_list.jpg'),
|
||||
type: 'jpeg',
|
||||
});
|
||||
});
|
||||
|
||||
test('mobile home screenshot', async ({ page }) => {
|
||||
await page.goto(URL.WELCOME);
|
||||
await expect(page.getByText('Recents')).toBeVisible({ timeout: 15000 });
|
||||
await settle(page, 2000);
|
||||
await page.screenshot({
|
||||
path: path.join(MOBILE_SCREENSHOTS_DIR, 'mobile_home.jpg'),
|
||||
type: 'jpeg',
|
||||
});
|
||||
});
|
||||
|
||||
test('mobile navigation drawer screenshot', async ({ page }) => {
|
||||
await page.goto(URL.WELCOME);
|
||||
await expect(page.getByText('Recents')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const menuButton = page.getByRole('button', { name: 'Menu' });
|
||||
await expect(menuButton).toBeVisible({ timeout: 10000 });
|
||||
await menuButton.click();
|
||||
|
||||
await expect(page.locator('.ant-drawer-body')).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(page.getByText('Dashboards').first()).toBeVisible();
|
||||
await settle(page);
|
||||
await page.screenshot({
|
||||
path: path.join(MOBILE_SCREENSHOTS_DIR, 'mobile_nav_drawer.jpg'),
|
||||
type: 'jpeg',
|
||||
});
|
||||
});
|
||||
|
||||
test('mobile unsupported route screenshot', async ({ page }) => {
|
||||
await page.goto(URL.SQLLAB);
|
||||
await expect(
|
||||
page.getByText("This view isn't available on mobile"),
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
await settle(page);
|
||||
await page.screenshot({
|
||||
path: path.join(MOBILE_SCREENSHOTS_DIR, 'mobile_unsupported.jpg'),
|
||||
type: 'jpeg',
|
||||
});
|
||||
});
|
||||
@@ -103,16 +103,40 @@ export class EmbeddedPage {
|
||||
/**
|
||||
* Wait for dashboard content to render inside the iframe.
|
||||
* Looks for the grid-container which indicates charts are loading/loaded.
|
||||
*
|
||||
* Races the grid against the test app's `#error` box so an embed failure
|
||||
* surfaces its message immediately, instead of blindly timing out on the
|
||||
* grid selector and hiding the real reason.
|
||||
*/
|
||||
async waitForDashboardContent(options?: { timeout?: number }): Promise<void> {
|
||||
const frame = this.iframe;
|
||||
await frame
|
||||
const timeout = options?.timeout ?? EMBEDDED.DASHBOARD_RENDER;
|
||||
const grid = this.iframe
|
||||
.locator('.grid-container, [data-test="grid-container"]')
|
||||
.first()
|
||||
.waitFor({
|
||||
state: 'visible',
|
||||
timeout: options?.timeout ?? EMBEDDED.DASHBOARD_RENDER,
|
||||
});
|
||||
.first();
|
||||
const errorBox = this.page.locator(EmbeddedPage.SELECTORS.ERROR);
|
||||
|
||||
const ready = grid
|
||||
.waitFor({ state: 'visible', timeout })
|
||||
.then(() => 'ready' as const)
|
||||
.catch(() => 'gridTimeout' as const);
|
||||
const failed = errorBox
|
||||
.waitFor({ state: 'visible', timeout })
|
||||
.then(() => 'error' as const)
|
||||
.catch(() => 'errorTimeout' as const);
|
||||
|
||||
const outcome = await Promise.race([ready, failed]);
|
||||
if (outcome === 'ready') return;
|
||||
if (outcome === 'error') {
|
||||
const message = (await errorBox.textContent())?.trim() || 'unknown error';
|
||||
throw new Error(`Embedded dashboard failed to render: ${message}`);
|
||||
}
|
||||
const status = (
|
||||
await this.page.locator(EmbeddedPage.SELECTORS.STATUS).textContent()
|
||||
)?.trim();
|
||||
throw new Error(
|
||||
`Embedded dashboard did not render within ${timeout}ms ` +
|
||||
`(status: ${status ?? 'unknown'})`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Regression coverage for the Drill to Detail modal's results table.
|
||||
*
|
||||
* The drill pane measures its available height with a resize detector and
|
||||
* hands it to a virtualized table, so the rows only render if the modal's
|
||||
* internal height chain (resizable wrapper -> modal container -> modal body ->
|
||||
* flex pane) actually resolves to a real height. That chain is wired together
|
||||
* with CSS selectors targeting Ant Design's internal modal classes, which
|
||||
* TypeScript cannot see and unit tests do not exercise: when the antd v6
|
||||
* upgrade renamed `.ant-modal-content` to `.ant-modal-container`, the chain
|
||||
* silently broke, the pane measured ~0, and the table briefly flashed its rows
|
||||
* before collapsing to an empty body with only the header and pagination
|
||||
* visible.
|
||||
*
|
||||
* Only a real browser sees layout, so this is pinned here rather than in the
|
||||
* DOM-contract unit suite. Because the failure mode is
|
||||
* render-then-collapse, a single "rows are visible" read could pass during
|
||||
* the initial flash — the assertion therefore lets the height settle once,
|
||||
* then requires it to hold across further spaced reads with no more
|
||||
* retrying, so a later or partial collapse cannot be masked by an early-exit
|
||||
* retry that stopped at the first passing sample.
|
||||
*
|
||||
* CI green => the drill modal's table renders rows at a stable, non-collapsed
|
||||
* height.
|
||||
* CI red => the modal height chain broke again (or drill-to-detail failed to
|
||||
* open/load at all).
|
||||
*/
|
||||
import { testWithAssets, expect } from '../../helpers/fixtures';
|
||||
import { TIMEOUT } from '../../utils/constants';
|
||||
import { DashboardPage } from '../../pages/DashboardPage';
|
||||
import { createDashboardWithCharts } from './dashboard-test-helpers';
|
||||
|
||||
const MIN_STABLE_BODY_HEIGHT = 100;
|
||||
// Max fraction the body height may drift from the settled baseline below; a
|
||||
// partial collapse (e.g. 400px -> 150px) still clears MIN_STABLE_BODY_HEIGHT
|
||||
// but fails this, so the check enforces stability, not just a floor.
|
||||
const HEIGHT_DRIFT_TOLERANCE = 0.25;
|
||||
// Extra spaced reads taken *after* the height has settled, and the gap
|
||||
// between them. These are plain assertions, not wrapped in a retrying
|
||||
// helper: once settled, a retry would return on the first passing sample
|
||||
// and could mask a collapse that only shows up later in the window.
|
||||
const HEIGHT_SAMPLE_COUNT = 2;
|
||||
const HEIGHT_SAMPLE_INTERVAL_MS = 300;
|
||||
|
||||
testWithAssets(
|
||||
'drill to detail modal renders result rows at a stable height',
|
||||
async ({ page, testAssets }) => {
|
||||
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
|
||||
const { dashboardId } = await createDashboardWithCharts(
|
||||
page,
|
||||
testAssets,
|
||||
testWithAssets.info(),
|
||||
{
|
||||
datasetName: 'birth_names',
|
||||
chartNamePrefix: 'drill_detail',
|
||||
dashboardTitlePrefix: 'drill_detail_modal',
|
||||
chartSpecs: [
|
||||
{
|
||||
viz_type: 'pie',
|
||||
params: {
|
||||
groupby: ['gender'],
|
||||
metric: 'count',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
const dashboard = new DashboardPage(page);
|
||||
await dashboard.gotoById(dashboardId);
|
||||
await dashboard.waitForLoad();
|
||||
await dashboard.waitForChartsToLoad();
|
||||
|
||||
// Open the chart context menu. The ECharts canvas exposes no data-test
|
||||
// hooks for its regions, so right-click the centre of the chart container;
|
||||
// the exact-text match below then works whether the click landed on a
|
||||
// slice (which adds "Drill to detail by" items) or on the chart background.
|
||||
// The first right-click after load can be swallowed by a chart re-render
|
||||
// closing the menu, so retry the click until the menu actually shows.
|
||||
const chart = page.locator('[data-test="chart-container"]').first();
|
||||
await chart.scrollIntoViewIfNeeded();
|
||||
const contextMenu = page.locator('[data-test="chart-context-menu"]');
|
||||
await expect(async () => {
|
||||
const box = await chart.boundingBox();
|
||||
if (!box) {
|
||||
throw new Error('chart container has no bounding box');
|
||||
}
|
||||
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2, {
|
||||
button: 'right',
|
||||
});
|
||||
await expect(contextMenu).toBeVisible({ timeout: 2000 });
|
||||
}).toPass({ timeout: TIMEOUT.CHART_RENDER });
|
||||
await page
|
||||
.getByRole('menuitem', { name: 'Drill to detail', exact: true })
|
||||
.click();
|
||||
|
||||
const modal = page.locator('.ant-modal:visible');
|
||||
await expect(modal).toBeVisible({ timeout: TIMEOUT.FORM_LOAD });
|
||||
|
||||
// Wait for the samples request to resolve into a rendered table: the row
|
||||
// count pill and the virtualized body both come from the loaded page.
|
||||
const tableBody = modal.locator('.virtual-grid');
|
||||
await expect(tableBody).toBeAttached({ timeout: TIMEOUT.CHART_RENDER });
|
||||
|
||||
// The regression collapses the body *after* first paint, so first let the
|
||||
// height settle above the floor (retrying is safe here: the collapse is
|
||||
// persistent, so a broken build never finds a passing read and this
|
||||
// still times out red), then, without any further retrying, take extra
|
||||
// spaced reads and require each to hold within tolerance of that settled
|
||||
// baseline — a delayed or partial collapse can no longer be masked by an
|
||||
// early-exit retry that stopped at the first passing sample.
|
||||
let baselineHeight = 0;
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
baselineHeight = (await tableBody.boundingBox())?.height ?? 0;
|
||||
return baselineHeight;
|
||||
},
|
||||
{ timeout: TIMEOUT.CHART_RENDER },
|
||||
)
|
||||
.toBeGreaterThan(MIN_STABLE_BODY_HEIGHT);
|
||||
for (let sample = 0; sample < HEIGHT_SAMPLE_COUNT; sample += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop -- reads must be sequential
|
||||
// and spaced out to observe a delayed collapse; there is nothing to
|
||||
// parallelize.
|
||||
await page.waitForTimeout(HEIGHT_SAMPLE_INTERVAL_MS);
|
||||
// eslint-disable-next-line no-await-in-loop -- see above
|
||||
const box = await tableBody.boundingBox();
|
||||
const height = box?.height ?? 0;
|
||||
expect(height).toBeGreaterThan(MIN_STABLE_BODY_HEIGHT);
|
||||
expect(Math.abs(height - baselineHeight)).toBeLessThanOrEqual(
|
||||
baselineHeight * HEIGHT_DRIFT_TOLERANCE,
|
||||
);
|
||||
}
|
||||
|
||||
// And the rows are real data, not just an expanded empty scroller:
|
||||
// birth_names sample rows always carry a gender value.
|
||||
await expect(modal.getByText(/^(boy|girl)$/).first()).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* 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 { test, expect, devices, Page } from '@playwright/test';
|
||||
|
||||
// NOTE: These tests exercise the mobile consumption experience and require
|
||||
// the MOBILE_CONSUMPTION_MODE feature flag to be enabled in the target
|
||||
// environment (FEATURE_FLAGS = {"MOBILE_CONSUMPTION_MODE": True}).
|
||||
import { TIMEOUT } from '../../utils/constants';
|
||||
import { URL } from '../../utils/urls';
|
||||
|
||||
/**
|
||||
* Mobile dashboard viewing tests verify that dashboards can be viewed
|
||||
* and interacted with on mobile devices.
|
||||
*
|
||||
* These tests assume the World Bank's Health sample dashboard exists.
|
||||
*/
|
||||
|
||||
// Use iPhone 12 viewport for mobile tests
|
||||
const mobileViewport = devices['iPhone 12'];
|
||||
|
||||
/**
|
||||
* Navigates to the dashboard list, clicks the first available dashboard
|
||||
* card, and waits for navigation into that dashboard. Skips the current
|
||||
* test when no dashboards are available to open.
|
||||
*/
|
||||
async function openFirstDashboard(page: Page): Promise<void> {
|
||||
await page.goto(URL.DASHBOARD_LIST);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const cards = page.locator('[data-test="styled-card"]');
|
||||
const cardCount = await cards.count();
|
||||
|
||||
test.skip(cardCount === 0, 'No dashboards available to open on mobile');
|
||||
|
||||
await cards.first().click();
|
||||
|
||||
await page.waitForURL(url => /\/dashboard\/(?!list)/.test(url.pathname), {
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigates to the World Bank's Health dashboard and returns a locator
|
||||
* for its mobile filter button. Skips the current test when the fixture
|
||||
* has no native filters configured.
|
||||
*/
|
||||
async function getMobileFilterButton(page: Page) {
|
||||
// Navigate directly to the World Bank's Health dashboard, which this
|
||||
// spec's fixtures require, rather than an arbitrary first card from
|
||||
// the list. Whether it has native filters configured depends on the
|
||||
// fixture, so callers skip themselves when none are present.
|
||||
await page.goto('dashboard/world_health/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Give filters time to load
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const filterButton = page
|
||||
.locator('[data-test="mobile-filters-trigger"]')
|
||||
.or(page.locator('[aria-label="Open filters"]'));
|
||||
|
||||
const filterCount = await filterButton.count();
|
||||
|
||||
test.skip(
|
||||
filterCount === 0,
|
||||
'world_health dashboard fixture has no native filters configured; ' +
|
||||
'cannot verify mobile filter behavior.',
|
||||
);
|
||||
|
||||
return filterButton;
|
||||
}
|
||||
|
||||
test.describe('Mobile Dashboard Viewing', () => {
|
||||
test.use({
|
||||
viewport: mobileViewport.viewport,
|
||||
userAgent: mobileViewport.userAgent,
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Navigate to dashboard list to find a dashboard
|
||||
await page.goto(URL.DASHBOARD_LIST);
|
||||
await page.waitForLoadState('networkidle');
|
||||
});
|
||||
|
||||
test('dashboard list renders in card view on mobile', async ({ page }) => {
|
||||
// On mobile, dashboard list should show cards, not table
|
||||
// Look for card elements
|
||||
const cards = page.locator('[data-test="styled-card"]');
|
||||
|
||||
// Should have at least one card if dashboards exist
|
||||
// (This test may need adjustment based on test data availability)
|
||||
const cardCount = await cards.count();
|
||||
|
||||
// Either cards are visible, or the empty state is shown; the table
|
||||
// view must never render on mobile
|
||||
if (cardCount > 0) {
|
||||
await expect(cards.first()).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
} else {
|
||||
await expect(page.locator('[data-test="empty-state"]')).toBeVisible({
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
});
|
||||
}
|
||||
await expect(page.locator('[data-test="listview-table"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('mobile search button appears in dashboard list', async ({ page }) => {
|
||||
// On mobile, the search/filter button should appear in the header
|
||||
const searchButton = page
|
||||
.locator('[aria-label="Search"]')
|
||||
.or(page.locator('[data-test="mobile-search-button"]'));
|
||||
|
||||
// Search button should be visible on mobile
|
||||
await expect(searchButton.first()).toBeVisible({
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
});
|
||||
});
|
||||
|
||||
test('tapping dashboard card opens the dashboard', async ({ page }) => {
|
||||
// Find a dashboard card
|
||||
const cards = page.locator('[data-test="styled-card"]');
|
||||
const cardCount = await cards.count();
|
||||
|
||||
if (cardCount > 0) {
|
||||
// Click the first card
|
||||
await cards.first().click();
|
||||
|
||||
// Should navigate to dashboard view
|
||||
await page.waitForURL(url => /\/dashboard\/(?!list)/.test(url.pathname), {
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
});
|
||||
|
||||
// Dashboard should load (look for dashboard content)
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-test="dashboard-content-wrapper"]')
|
||||
.or(page.locator('.dashboard')),
|
||||
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
} else {
|
||||
test.skip();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Mobile Dashboard Interaction', () => {
|
||||
test.use({
|
||||
viewport: mobileViewport.viewport,
|
||||
userAgent: mobileViewport.userAgent,
|
||||
});
|
||||
|
||||
// Skip this test suite if no dashboards exist
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
// browser.newPage() does not inherit the project's `storageState`, so
|
||||
// it must be passed explicitly to reuse the authenticated session -
|
||||
// otherwise this check hits the login page and always finds 0 cards.
|
||||
const page = await browser.newPage({
|
||||
viewport: mobileViewport.viewport,
|
||||
userAgent: mobileViewport.userAgent,
|
||||
storageState: 'playwright/.auth/user.json',
|
||||
});
|
||||
|
||||
await page.goto(URL.DASHBOARD_LIST);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const cards = page.locator('[data-test="styled-card"]');
|
||||
const cardCount = await cards.count();
|
||||
|
||||
await page.close();
|
||||
|
||||
if (cardCount === 0) {
|
||||
test.skip();
|
||||
}
|
||||
});
|
||||
|
||||
test('dashboard loads and shows charts on mobile', async ({ page }) => {
|
||||
await openFirstDashboard(page);
|
||||
|
||||
// Dashboard content should be visible
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-test="dashboard-content-wrapper"]')
|
||||
.or(page.locator('.dashboard')),
|
||||
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
|
||||
// Charts should start loading (look for chart containers)
|
||||
const chartContainers = page
|
||||
.locator('[data-test="chart-container"]')
|
||||
.or(page.locator('.dashboard-chart'));
|
||||
|
||||
// Wait for at least one chart to be visible (with timeout)
|
||||
await expect(chartContainers.first()).toBeVisible({
|
||||
timeout: TIMEOUT.PAGE_LOAD * 2,
|
||||
});
|
||||
});
|
||||
|
||||
test('dashboard header shows hamburger menu on mobile', async ({ page }) => {
|
||||
await openFirstDashboard(page);
|
||||
|
||||
// Look for the hamburger menu / more actions button
|
||||
const menuButton = page
|
||||
.locator('[data-test="actions-trigger"]')
|
||||
.or(page.locator('[aria-label="Menu actions trigger"]'));
|
||||
|
||||
await expect(menuButton.first()).toBeVisible({
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
});
|
||||
});
|
||||
|
||||
test('refresh dashboard works from mobile menu', async ({ page }) => {
|
||||
await openFirstDashboard(page);
|
||||
|
||||
// Open the actions menu
|
||||
const menuButton = page
|
||||
.locator('[data-test="actions-trigger"]')
|
||||
.or(page.locator('[aria-label="Menu actions trigger"]'));
|
||||
|
||||
test.skip(
|
||||
(await menuButton.count()) === 0,
|
||||
'Mobile actions menu button not found on this dashboard',
|
||||
);
|
||||
|
||||
await menuButton.first().click();
|
||||
|
||||
// Look for refresh option
|
||||
const refreshOption = page.getByText('Refresh dashboard');
|
||||
|
||||
test.skip(
|
||||
(await refreshOption.count()) === 0,
|
||||
'Refresh dashboard option not found in mobile actions menu',
|
||||
);
|
||||
|
||||
await refreshOption.click();
|
||||
|
||||
// Should show success toast or refresh the charts
|
||||
// This is hard to verify without checking network requests
|
||||
// Just verify the menu closes and we're still on the dashboard
|
||||
await page.waitForTimeout(1000);
|
||||
expect(page.url()).toMatch(/\/dashboard\/(?!list)/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Mobile Filter Drawer', () => {
|
||||
test.use({
|
||||
viewport: mobileViewport.viewport,
|
||||
userAgent: mobileViewport.userAgent,
|
||||
});
|
||||
|
||||
test('filter button appears on dashboards with filters', async ({ page }) => {
|
||||
const filterButton = await getMobileFilterButton(page);
|
||||
|
||||
await expect(filterButton.first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('filter drawer opens when filter button is tapped', async ({ page }) => {
|
||||
const filterButton = await getMobileFilterButton(page);
|
||||
|
||||
await filterButton.first().click();
|
||||
|
||||
// Filter drawer should open
|
||||
const drawer = page
|
||||
.locator('.ant-drawer-open')
|
||||
.or(page.locator('[data-test="filter-bar"]'));
|
||||
|
||||
await expect(drawer.first()).toBeVisible({
|
||||
timeout: TIMEOUT.FORM_LOAD,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* 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 { test, expect, devices } from '@playwright/test';
|
||||
|
||||
// NOTE: These tests exercise the mobile consumption experience and require
|
||||
// the MOBILE_CONSUMPTION_MODE feature flag to be enabled in the target
|
||||
// environment (FEATURE_FLAGS = {"MOBILE_CONSUMPTION_MODE": True}).
|
||||
import { URL } from '../../utils/urls';
|
||||
import { TIMEOUT } from '../../utils/constants';
|
||||
|
||||
/**
|
||||
* Mobile navigation tests verify the MobileRouteGuard behavior
|
||||
* and mobile-specific navigation patterns.
|
||||
*
|
||||
* These tests run with a mobile viewport to trigger mobile-specific behavior.
|
||||
*/
|
||||
|
||||
// Use iPhone 12 viewport for mobile tests
|
||||
const mobileViewport = devices['iPhone 12'];
|
||||
|
||||
test.describe('Mobile Navigation', () => {
|
||||
test.use({
|
||||
viewport: mobileViewport.viewport,
|
||||
userAgent: mobileViewport.userAgent,
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test('mobile viewport redirects from chart list to MobileUnsupported page', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Navigate to chart list (not mobile-supported)
|
||||
await page.goto(URL.CHART_LIST);
|
||||
|
||||
// Should show the MobileUnsupported page
|
||||
await expect(
|
||||
page.getByText("This view isn't available on mobile"),
|
||||
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
|
||||
// Primary action buttons should be visible
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'View Dashboards' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Go to Welcome Page' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('mobile viewport allows access to dashboard list', async ({ page }) => {
|
||||
// Navigate to dashboard list (mobile-supported)
|
||||
await page.goto(URL.DASHBOARD_LIST);
|
||||
|
||||
// Should NOT show MobileUnsupported page
|
||||
await expect(
|
||||
page.getByText("This view isn't available on mobile"),
|
||||
).not.toBeVisible({ timeout: TIMEOUT.FORM_LOAD });
|
||||
|
||||
// Should show dashboard list content (look for dashboard list elements)
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-test="listview-table"]')
|
||||
.or(page.locator('[data-test="styled-card"]'))
|
||||
.first(),
|
||||
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
});
|
||||
|
||||
test('mobile viewport allows access to welcome page', async ({ page }) => {
|
||||
// Navigate to welcome page (mobile-supported)
|
||||
await page.goto(URL.WELCOME);
|
||||
|
||||
// Should NOT show MobileUnsupported page
|
||||
await expect(
|
||||
page.getByText("This view isn't available on mobile"),
|
||||
).not.toBeVisible({ timeout: TIMEOUT.FORM_LOAD });
|
||||
|
||||
// Should show welcome page content
|
||||
await expect(
|
||||
page.getByText('Recents').or(page.getByText('Dashboards')).first(),
|
||||
).toBeVisible({
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
});
|
||||
});
|
||||
|
||||
test('View Dashboards button navigates to dashboard list', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Navigate to unsupported route
|
||||
await page.goto(URL.CHART_LIST);
|
||||
|
||||
// Wait for MobileUnsupported page
|
||||
await expect(
|
||||
page.getByText("This view isn't available on mobile"),
|
||||
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
|
||||
// Click View Dashboards button
|
||||
await page.getByRole('button', { name: 'View Dashboards' }).click();
|
||||
|
||||
// Should navigate to dashboard list
|
||||
await page.waitForURL(url => url.pathname.includes('dashboard/list'), {
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
});
|
||||
|
||||
// Dashboard list should be accessible
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-test="listview-table"]')
|
||||
.or(page.locator('[data-test="styled-card"]'))
|
||||
.first(),
|
||||
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
});
|
||||
|
||||
test('Go to Welcome Page button navigates to welcome', async ({ page }) => {
|
||||
// Navigate to unsupported route
|
||||
await page.goto(URL.CHART_LIST);
|
||||
|
||||
// Wait for MobileUnsupported page
|
||||
await expect(
|
||||
page.getByText("This view isn't available on mobile"),
|
||||
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
|
||||
// Click Go to Welcome Page button
|
||||
await page.getByRole('button', { name: 'Go to Welcome Page' }).click();
|
||||
|
||||
// Should navigate to welcome page
|
||||
await page.waitForURL(url => url.pathname.includes('welcome'), {
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
});
|
||||
});
|
||||
|
||||
test('unsupported screen offers no bypass', async ({ page }) => {
|
||||
// The "Continue anyway" bypass was removed: desktop views are unusable
|
||||
// at phone width, and growing the viewport unblocks routes automatically
|
||||
await page.goto(URL.CHART_LIST);
|
||||
|
||||
await expect(
|
||||
page.getByText("This view isn't available on mobile"),
|
||||
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
|
||||
await expect(page.getByText('Continue anyway')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('SQL Lab is not accessible on mobile', async ({ page }) => {
|
||||
// Navigate to SQL Lab (not mobile-supported)
|
||||
await page.goto(URL.SQLLAB);
|
||||
|
||||
// Should show the MobileUnsupported page
|
||||
await expect(
|
||||
page.getByText("This view isn't available on mobile"),
|
||||
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Desktop Navigation (control group)', () => {
|
||||
// Use default desktop viewport
|
||||
|
||||
test('desktop viewport allows access to all routes', async ({ page }) => {
|
||||
// Navigate to chart list
|
||||
await page.goto(URL.CHART_LIST);
|
||||
|
||||
// Should NOT show MobileUnsupported page
|
||||
await expect(
|
||||
page.getByText("This view isn't available on mobile"),
|
||||
).not.toBeVisible({ timeout: TIMEOUT.FORM_LOAD });
|
||||
|
||||
// Should show chart list content
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-test="listview-table"]')
|
||||
.or(page.locator('[data-test="styled-card"]'))
|
||||
.first(),
|
||||
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,463 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/**
|
||||
* Crawls every dashboard on the target instance and refreshes viz-picker
|
||||
* gallery thumbnails from live example charts.
|
||||
*
|
||||
* This is a maintenance tool, not a test: charts are DISCOVERED via the
|
||||
* API (dashboards -> charts), so it keeps working as example dashboards
|
||||
* evolve. For each viz type found, one representative chart is rendered
|
||||
* standalone at 512x512 and the plugin's `thumbnail.png` is overwritten.
|
||||
* The only static piece is the viz type -> image path map below, which
|
||||
* changes when plugins are added or moved — never when examples change.
|
||||
* Viz types found on dashboards but missing from the map are reported at
|
||||
* the end without failing the run.
|
||||
*
|
||||
* It only runs when CAPTURE_THUMBNAILS=1 is set, so the regular
|
||||
* Playwright suites never execute it.
|
||||
*
|
||||
* Usage (requires a running Superset with examples loaded):
|
||||
* npm run playwright:thumbnails
|
||||
* VIZ_TYPES=bullet,rose npm run playwright:thumbnails # subset
|
||||
*
|
||||
* Notes:
|
||||
* - Dark variants (`thumbnail-dark.png`, `example-dark.jpg`) are captured
|
||||
* via prefers-color-scheme emulation whenever the sibling file exists;
|
||||
* if the app ignores the emulation (dark theming disabled) the dark
|
||||
* file is left untouched.
|
||||
* - New gallery images (e.g. the Line percent-change example) still need
|
||||
* to be registered in the plugin's metadata before they render in the
|
||||
* gallery; the capture logs a reminder for any new image.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
|
||||
const THUMBNAIL_SIZE = 512;
|
||||
const RENDERED_CHART_SELECTOR =
|
||||
'[data-test="chart-container"]:has(svg, canvas, table):not(:has([data-test="loading-indicator"]))';
|
||||
/**
|
||||
* Charts that render plain markup — no svg/canvas/table for the rendered
|
||||
* selector to key on — get a looser signal plus a longer settle.
|
||||
*/
|
||||
const TEXT_ONLY_VIZ_TYPES = new Set([
|
||||
'ag-grid-table',
|
||||
'big_number_total',
|
||||
'handlebars',
|
||||
'pop_kpi',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Hover this element just before the screenshot, so charts whose identity
|
||||
* benefits from an interaction (a visible tooltip) capture mid-hover.
|
||||
*/
|
||||
const HOVER_BEFORE_CAPTURE: Record<string, string> = {
|
||||
cal_heatmap: '[data-test="chart-container"] svg rect[class*=" r"]',
|
||||
};
|
||||
const TEXT_RENDERED_CHART_SELECTOR =
|
||||
'[data-test="chart-container"]:not(:has([data-test="loading-indicator"]))';
|
||||
|
||||
/** superset-frontend root, resolved from this spec's location. */
|
||||
const FRONTEND_ROOT = path.resolve(__dirname, '..', '..', '..');
|
||||
|
||||
const ECHARTS = 'plugins/plugin-chart-echarts/src';
|
||||
const DECKGL = 'plugins/preset-chart-deckgl/src/layers';
|
||||
|
||||
/**
|
||||
* Where each viz type's gallery thumbnail lives, relative to
|
||||
* superset-frontend. One entry per registered viz type with a thumbnail;
|
||||
* add a line when a new plugin ships.
|
||||
*/
|
||||
const VIZ_TYPE_THUMBNAILS: Record<string, string> = {
|
||||
'ag-grid-table':
|
||||
'plugins/plugin-chart-ag-grid-table/src/images/thumbnail.png',
|
||||
big_number: `${ECHARTS}/BigNumber/BigNumberWithTrendline/images/thumbnail.png`,
|
||||
big_number_total: `${ECHARTS}/BigNumber/BigNumberTotal/images/thumbnail.png`,
|
||||
box_plot: `${ECHARTS}/BoxPlot/images/thumbnail.png`,
|
||||
bubble_v2: `${ECHARTS}/Bubble/images/thumbnail.png`,
|
||||
bullet: `${ECHARTS}/Bullet/images/thumbnail.png`,
|
||||
cal_heatmap: 'plugins/plugin-chart-calendar/src/images/thumbnail.png',
|
||||
cartodiagram: 'plugins/plugin-chart-cartodiagram/src/images/thumbnail.png',
|
||||
chord: 'plugins/plugin-chart-chord/src/images/thumbnail.png',
|
||||
country_map: 'plugins/plugin-chart-country-map/src/images/thumbnail.png',
|
||||
deck_arc: `${DECKGL}/Arc/images/thumbnail.png`,
|
||||
deck_contour: `${DECKGL}/Contour/images/thumbnail.png`,
|
||||
deck_geojson: `${DECKGL}/Geojson/images/thumbnail.png`,
|
||||
deck_grid: `${DECKGL}/Grid/images/thumbnail.png`,
|
||||
deck_heatmap: `${DECKGL}/Heatmap/images/thumbnail.png`,
|
||||
deck_hex: `${DECKGL}/Hex/images/thumbnail.png`,
|
||||
deck_multi: 'plugins/preset-chart-deckgl/src/Multi/images/thumbnail.png',
|
||||
deck_path: `${DECKGL}/Path/images/thumbnail.png`,
|
||||
deck_polygon: `${DECKGL}/Polygon/images/thumbnail.png`,
|
||||
deck_scatter: `${DECKGL}/Scatter/images/thumbnail.png`,
|
||||
deck_screengrid: `${DECKGL}/Screengrid/images/thumbnail.png`,
|
||||
echarts_area: `${ECHARTS}/Timeseries/Area/images/thumbnail.png`,
|
||||
echarts_timeseries: `${ECHARTS}/Timeseries/images/thumbnail.png`,
|
||||
echarts_timeseries_bar: `${ECHARTS}/Timeseries/Regular/Bar/images/thumbnail.png`,
|
||||
echarts_timeseries_line: `${ECHARTS}/Timeseries/Regular/Line/images/thumbnail.png`,
|
||||
echarts_timeseries_scatter: `${ECHARTS}/Timeseries/Regular/Scatter/images/thumbnail.png`,
|
||||
echarts_timeseries_smooth: `${ECHARTS}/Timeseries/Regular/SmoothLine/images/thumbnail.png`,
|
||||
echarts_timeseries_step: `${ECHARTS}/Timeseries/Step/images/thumbnail.png`,
|
||||
funnel: `${ECHARTS}/Funnel/images/thumbnail.png`,
|
||||
gantt_chart: `${ECHARTS}/Gantt/images/thumbnail.png`,
|
||||
gauge_chart: `${ECHARTS}/Gauge/images/thumbnail.png`,
|
||||
graph_chart: `${ECHARTS}/Graph/images/thumbnail.png`,
|
||||
// handlebars intentionally unmapped: its generic logo represents the
|
||||
// template-anything nature of the chart better than any one example.
|
||||
heatmap_v2: `${ECHARTS}/Heatmap/images/thumbnail.png`,
|
||||
histogram_v2: `${ECHARTS}/Histogram/images/thumbnail.png`,
|
||||
horizon: 'plugins/plugin-chart-horizon/src/images/thumbnail.png',
|
||||
mixed_timeseries: `${ECHARTS}/MixedTimeseries/images/thumbnail.png`,
|
||||
paired_ttest: 'plugins/plugin-chart-paired-t-test/src/images/thumbnail.png',
|
||||
para: 'plugins/plugin-chart-parallel-coordinates/src/images/thumbnail.png',
|
||||
partition: 'plugins/plugin-chart-partition/src/images/thumbnail.png',
|
||||
pie: `${ECHARTS}/Pie/images/thumbnail.png`,
|
||||
pivot_table_v2: 'plugins/plugin-chart-pivot-table/src/images/thumbnail.png',
|
||||
point_cluster_map:
|
||||
'plugins/plugin-chart-point-cluster-map/src/images/thumbnail.png',
|
||||
pop_kpi: `${ECHARTS}/BigNumber/BigNumberPeriodOverPeriod/images/thumbnail.png`,
|
||||
radar: `${ECHARTS}/Radar/images/thumbnail.png`,
|
||||
rose: 'plugins/plugin-chart-rose/src/images/thumbnail.png',
|
||||
sankey_v2: `${ECHARTS}/Sankey/images/thumbnail.png`,
|
||||
sunburst_v2: `${ECHARTS}/Sunburst/images/thumbnail.png`,
|
||||
table: 'plugins/plugin-chart-table/src/images/thumbnail.png',
|
||||
time_pivot: `${ECHARTS}/TimePivot/images/thumbnail.png`,
|
||||
time_table: 'src/visualizations/TimeTable/images/thumbnail.png',
|
||||
tree_chart: `${ECHARTS}/Tree/images/thumbnail.png`,
|
||||
treemap_v2: `${ECHARTS}/Treemap/images/thumbnail.png`,
|
||||
waterfall: `${ECHARTS}/Waterfall/images/thumbnail.png`,
|
||||
word_cloud: 'plugins/plugin-chart-word-cloud/src/images/thumbnail.png',
|
||||
world_map: 'plugins/plugin-chart-world-map/src/images/thumbnail.png',
|
||||
};
|
||||
|
||||
/**
|
||||
* When several example charts share a viz type, prefer these slices over
|
||||
* the default alphabetically-first pick. Missing slices fall back to the
|
||||
* default, so stale entries degrade gracefully.
|
||||
*/
|
||||
const PREFERRED_SLICES: Record<string, string> = {
|
||||
big_number: 'Sales Year over Year',
|
||||
bubble_v2: 'Life Expectancy VS Rural %',
|
||||
bullet: 'Total Sales Bullet',
|
||||
cal_heatmap: 'Sales Calendar Heatmap',
|
||||
chord: 'Product Line Chord',
|
||||
echarts_area: 'Sales Stacked Area',
|
||||
echarts_timeseries_bar: 'Sales Stacked Bars',
|
||||
echarts_timeseries_line: 'Monthly Sales Line',
|
||||
echarts_timeseries_smooth: 'Monthly Sales Smooth',
|
||||
echarts_timeseries_step: 'Quarterly Sales Steps',
|
||||
funnel: 'Population Funnel',
|
||||
gauge_chart: 'Rural Population Gauge',
|
||||
heatmap_v2: 'Sales Grid Heatmap',
|
||||
histogram_v2: 'Life Expectancy Histogram',
|
||||
horizon: 'Population Growth Horizon',
|
||||
mixed_timeseries: 'Sales Mixed Chart',
|
||||
paired_ttest: 'Population Paired t-Test',
|
||||
partition: 'Population Partition',
|
||||
pie: 'Product Line Donut',
|
||||
pivot_table_v2: 'Sales Pivot Highlights',
|
||||
radar: 'Game Sales Radar',
|
||||
rose: 'Population Nightingale Rose',
|
||||
sunburst_v2: 'Population Sunburst',
|
||||
table: 'Sales Summary Table',
|
||||
time_pivot: 'Sales Period Pivot',
|
||||
time_table: 'Product Line Time Table',
|
||||
tree_chart: 'Sales Territory Tree',
|
||||
treemap_v2: 'Population Treemap',
|
||||
waterfall: 'Quarterly Sales Waterfall',
|
||||
};
|
||||
|
||||
/** Gallery example images use a wide aspect, matching the existing art. */
|
||||
const EXAMPLE_WIDTH = 800;
|
||||
const EXAMPLE_HEIGHT = 460;
|
||||
|
||||
interface ExtraCapture {
|
||||
sliceName: string;
|
||||
output: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extra captures beyond the per-viz-type thumbnail: gallery example
|
||||
* images that are obsolete (the Bullet/TimePivot/TimeTable examples
|
||||
* still show the removed nvd3 renderers) and brand-new gallery images.
|
||||
* Register new images in the plugin metadata after capturing.
|
||||
*/
|
||||
const EXTRA_CAPTURES: ExtraCapture[] = [
|
||||
{
|
||||
sliceName: 'Population Percent Change',
|
||||
output: `${ECHARTS}/Timeseries/Regular/Line/images/Line3.png`,
|
||||
width: EXAMPLE_WIDTH,
|
||||
height: EXAMPLE_HEIGHT,
|
||||
},
|
||||
{
|
||||
sliceName: 'Total Sales Bullet',
|
||||
output: `${ECHARTS}/Bullet/images/example.jpg`,
|
||||
width: EXAMPLE_WIDTH,
|
||||
height: EXAMPLE_HEIGHT,
|
||||
},
|
||||
{
|
||||
sliceName: 'Sales Period Pivot',
|
||||
output: `${ECHARTS}/TimePivot/images/example.jpg`,
|
||||
width: EXAMPLE_WIDTH,
|
||||
height: EXAMPLE_HEIGHT,
|
||||
},
|
||||
{
|
||||
sliceName: 'Product Line Time Table',
|
||||
output: 'src/visualizations/TimeTable/images/example.jpg',
|
||||
width: EXAMPLE_WIDTH,
|
||||
height: EXAMPLE_HEIGHT,
|
||||
},
|
||||
];
|
||||
|
||||
interface ExampleChart {
|
||||
id: number;
|
||||
sliceName: string;
|
||||
vizType: string;
|
||||
}
|
||||
|
||||
interface DashboardRow {
|
||||
id: number;
|
||||
}
|
||||
|
||||
interface DashboardChartRow {
|
||||
id?: number;
|
||||
slice_name?: string;
|
||||
form_data?: { viz_type?: string };
|
||||
}
|
||||
|
||||
/** Pages through a list endpoint, returning every result row. */
|
||||
async function fetchAllPages<T>(page: Page, endpoint: string): Promise<T[]> {
|
||||
const rows: T[] = [];
|
||||
const pageSize = 100;
|
||||
for (let pageNum = 0; ; pageNum += 1) {
|
||||
const q = encodeURIComponent(
|
||||
`(page_size:${pageSize},page:${pageNum},order_direction:asc)`,
|
||||
);
|
||||
const response = await page.request.get(`${endpoint}?q=${q}`);
|
||||
expect(response.ok(), `GET ${endpoint} page ${pageNum}`).toBeTruthy();
|
||||
const { result }: { result: T[] } = await response.json();
|
||||
rows.push(...result);
|
||||
if (result.length < pageSize) return rows;
|
||||
}
|
||||
}
|
||||
|
||||
/** Discovers every chart placed on any dashboard. */
|
||||
async function discoverDashboardCharts(page: Page): Promise<ExampleChart[]> {
|
||||
const dashboards = await fetchAllPages<DashboardRow>(
|
||||
page,
|
||||
'/api/v1/dashboard/',
|
||||
);
|
||||
const chartsById = new Map<number, ExampleChart>();
|
||||
for (const dashboard of dashboards) {
|
||||
const response = await page.request.get(
|
||||
`/api/v1/dashboard/${dashboard.id}/charts`,
|
||||
);
|
||||
if (!response.ok()) continue;
|
||||
const { result }: { result: DashboardChartRow[] } = await response.json();
|
||||
for (const chart of result) {
|
||||
const vizType = chart.form_data?.viz_type;
|
||||
if (chart.id && chart.slice_name && vizType) {
|
||||
chartsById.set(chart.id, {
|
||||
id: chart.id,
|
||||
sliceName: chart.slice_name,
|
||||
vizType,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...chartsById.values()];
|
||||
}
|
||||
|
||||
/** thumbnail.png -> thumbnail-dark.png, example.jpg -> example-dark.jpg */
|
||||
function darkSibling(output: string): string {
|
||||
return output.replace(/\.(png|jpg)$/, '-dark.$1');
|
||||
}
|
||||
|
||||
async function renderAndShoot(
|
||||
page: Page,
|
||||
chart: ExampleChart,
|
||||
colorScheme: 'light' | 'dark',
|
||||
): Promise<Buffer> {
|
||||
// An explicit navigation timeout keeps one hung load from stalling the
|
||||
// whole crawl until the test timeout.
|
||||
await page.goto(`/explore/?slice_id=${chart.id}&standalone=1`, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
// Dashboards render charts on colorBgContainer cards, but the standalone
|
||||
// explore page paints the gray colorBgLayout (via the antd Layout
|
||||
// wrapper); match the dashboard context so thumbnails look like charts
|
||||
// do where users see them.
|
||||
await page
|
||||
.addStyleTag({
|
||||
content: `body, .ant-layout { background: ${
|
||||
colorScheme === 'dark' ? '#141414' : '#ffffff'
|
||||
} !important; }`,
|
||||
})
|
||||
.catch(() => {});
|
||||
const textOnly = TEXT_ONLY_VIZ_TYPES.has(chart.vizType);
|
||||
await page
|
||||
.locator(textOnly ? TEXT_RENDERED_CHART_SELECTOR : RENDERED_CHART_SELECTOR)
|
||||
.first()
|
||||
.waitFor({ state: 'visible', timeout: 60_000 });
|
||||
// Give animations/map tiles (or text-only chart data) time to settle
|
||||
await page.waitForTimeout(textOnly ? 4_000 : 2_000);
|
||||
// Some thumbnails read better mid-interaction (e.g. the calendar heatmap
|
||||
// showing its tooltip); hover the configured element before the still.
|
||||
const hoverSelector = HOVER_BEFORE_CAPTURE[chart.vizType];
|
||||
if (hoverSelector) {
|
||||
// Hover a mid-chart element rather than the first (often an empty
|
||||
// corner cell), falling back to the first when there are few.
|
||||
const cells = page.locator(hoverSelector);
|
||||
const count = await cells.count().catch(() => 0);
|
||||
await cells
|
||||
.nth(Math.floor(count / 2))
|
||||
.hover({ timeout: 5_000 })
|
||||
.catch(() => {});
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
return page.screenshot();
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures a chart light and (when a dark variant is wanted) dark. Dark
|
||||
* rendering relies on the app following prefers-color-scheme (theme mode
|
||||
* SYSTEM); if the dark render is byte-identical to the light one the app
|
||||
* ignored the emulation, and the dark file is left untouched rather than
|
||||
* overwritten with light-theme art.
|
||||
*/
|
||||
async function captureChart(
|
||||
page: Page,
|
||||
chart: ExampleChart,
|
||||
output: string,
|
||||
size: { width: number; height: number },
|
||||
): Promise<void> {
|
||||
const outputPath = path.join(FRONTEND_ROOT, output);
|
||||
const darkPath = path.join(FRONTEND_ROOT, darkSibling(output));
|
||||
const isNewImage = !fs.existsSync(outputPath);
|
||||
const wantDark = fs.existsSync(darkPath) || isNewImage;
|
||||
|
||||
await page.setViewportSize(size);
|
||||
await page.emulateMedia({ colorScheme: 'light' });
|
||||
const lightShot = await renderAndShoot(page, chart, 'light');
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
fs.writeFileSync(outputPath, lightShot);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`captured ${chart.sliceName} (${chart.vizType}) -> ${output}`);
|
||||
|
||||
if (wantDark) {
|
||||
await page.emulateMedia({ colorScheme: 'dark' });
|
||||
const darkShot = await renderAndShoot(page, chart, 'dark');
|
||||
if (darkShot.equals(lightShot)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`SKIPPED dark variant for ${chart.sliceName}: the app ignored the dark color-scheme emulation (is dark theming enabled?)`,
|
||||
);
|
||||
} else {
|
||||
fs.writeFileSync(darkPath, darkShot);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`captured ${chart.sliceName} (dark) -> ${darkSibling(output)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isNewImage) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`NOTE: ${output} is a new gallery image — register it in the plugin metadata (exampleGallery) to surface it.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('capture viz thumbnails', () => {
|
||||
test.skip(
|
||||
!process.env.CAPTURE_THUMBNAILS,
|
||||
'Thumbnail capture only runs with CAPTURE_THUMBNAILS=1',
|
||||
);
|
||||
|
||||
test('crawls example dashboards and refreshes gallery thumbnails', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90 * 60_000);
|
||||
|
||||
const vizTypeFilter = process.env.VIZ_TYPES
|
||||
? new Set(process.env.VIZ_TYPES.split(',').map(v => v.trim()))
|
||||
: null;
|
||||
|
||||
const charts = await discoverDashboardCharts(page);
|
||||
expect(
|
||||
charts.length,
|
||||
'no dashboard charts found — are examples loaded?',
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
// One representative chart per viz type: the preferred slice when
|
||||
// present, else the alphabetically-first slice for determinism.
|
||||
const byVizType = new Map<string, ExampleChart[]>();
|
||||
for (const chart of charts) {
|
||||
const group = byVizType.get(chart.vizType) ?? [];
|
||||
group.push(chart);
|
||||
byVizType.set(chart.vizType, group);
|
||||
}
|
||||
|
||||
const thumbnailSize = { width: THUMBNAIL_SIZE, height: THUMBNAIL_SIZE };
|
||||
const unmapped: string[] = [];
|
||||
const failures: string[] = [];
|
||||
for (const [vizType, group] of [...byVizType.entries()].sort()) {
|
||||
if (vizTypeFilter && !vizTypeFilter.has(vizType)) continue;
|
||||
const output = VIZ_TYPE_THUMBNAILS[vizType];
|
||||
if (!output) {
|
||||
unmapped.push(vizType);
|
||||
continue;
|
||||
}
|
||||
group.sort((a, b) => a.sliceName.localeCompare(b.sliceName));
|
||||
const chart =
|
||||
group.find(c => c.sliceName === PREFERRED_SLICES[vizType]) ?? group[0];
|
||||
try {
|
||||
await captureChart(page, chart, output, thumbnailSize);
|
||||
} catch (error) {
|
||||
failures.push(`${vizType} (${chart.sliceName}): ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const extra of EXTRA_CAPTURES) {
|
||||
const chart = charts.find(c => c.sliceName === extra.sliceName);
|
||||
if (!chart || (vizTypeFilter && !vizTypeFilter.has(chart.vizType))) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await captureChart(page, chart, extra.output, {
|
||||
width: extra.width,
|
||||
height: extra.height,
|
||||
});
|
||||
} catch (error) {
|
||||
failures.push(`${extra.sliceName}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (unmapped.length) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`viz types on dashboards with no thumbnail mapping (add to VIZ_TYPE_THUMBNAILS if wanted): ${unmapped.join(', ')}`,
|
||||
);
|
||||
}
|
||||
expect(failures, failures.join('\n')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -101,7 +101,7 @@ export const EMBEDDED = {
|
||||
/** Timeout for iframe to appear in the DOM */
|
||||
IFRAME_LOAD: 15000, // 15s
|
||||
/** Timeout for dashboard content to render inside the iframe */
|
||||
DASHBOARD_RENDER: 30000, // 30s
|
||||
DASHBOARD_RENDER: 60000, // 60s (embedded dashboards are slow to render on cold CI)
|
||||
/** Timeout for individual chart cells to finish rendering */
|
||||
CHART_RENDER: TIMEOUT.CHART_RENDER,
|
||||
} as const;
|
||||
|
||||
@@ -35,8 +35,10 @@ import {
|
||||
BuildQuery,
|
||||
} from '@superset-ui/core';
|
||||
import {
|
||||
getTotalsMetrics,
|
||||
isTimeComparison,
|
||||
timeCompareOperator,
|
||||
TotalsAggregate,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { TableChartFormData } from './types';
|
||||
@@ -694,6 +696,19 @@ export const buildQueryUncached: BuildQuery<TableChartFormData> = (
|
||||
formData.show_totals &&
|
||||
queryMode === QueryMode.Aggregate,
|
||||
);
|
||||
const totalsAggregate: TotalsAggregate =
|
||||
formData.totals_aggregate === 'AVG' ? 'AVG' : 'SUM';
|
||||
const totalsMetrics =
|
||||
rawSummaryColumns.length > 0
|
||||
? rawSummaryColumns.map(columnName => ({
|
||||
expressionType: 'SIMPLE' as const,
|
||||
aggregate: totalsAggregate,
|
||||
column: { column_name: columnName },
|
||||
label: columnName,
|
||||
}))
|
||||
: showAggregateTotals
|
||||
? getTotalsMetrics(metrics ?? [], totalsAggregate)
|
||||
: undefined;
|
||||
|
||||
if (showAggregateTotals || rawSummaryColumns.length > 0) {
|
||||
// Create a copy of extras without the AG Grid WHERE clause
|
||||
@@ -728,14 +743,7 @@ export const buildQueryUncached: BuildQuery<TableChartFormData> = (
|
||||
extraQueries.push({
|
||||
...queryObject,
|
||||
columns: [],
|
||||
...(rawSummaryColumns.length > 0 && {
|
||||
metrics: rawSummaryColumns.map(columnName => ({
|
||||
expressionType: 'SIMPLE' as const,
|
||||
aggregate: 'SUM' as const,
|
||||
column: { column_name: columnName },
|
||||
label: columnName,
|
||||
})),
|
||||
}),
|
||||
...(totalsMetrics ? { metrics: totalsMetrics } : {}),
|
||||
extras: totalsExtras, // Use extras with AG Grid WHERE removed
|
||||
row_limit: 0,
|
||||
row_offset: 0,
|
||||
|
||||
@@ -490,11 +490,36 @@ const config: ControlPanelConfig = {
|
||||
default: false,
|
||||
renderTrigger: true,
|
||||
description: t(
|
||||
'Show a summary row of total aggregations: the selected metrics in aggregate mode, or the sum of numeric columns in raw records mode. Note that row limit does not apply to the result.',
|
||||
'Show a summary row of total aggregations: the selected metrics in aggregate mode, or an aggregation of numeric columns in raw records mode. Note that row limit does not apply to the result.',
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'totals_aggregate',
|
||||
config: {
|
||||
type: 'SelectControl',
|
||||
label: t('Summary aggregation'),
|
||||
renderTrigger: true,
|
||||
description: t(
|
||||
'Aggregation used for the summary row, independent of each ' +
|
||||
"metric's own aggregation. Only applies to simple metrics " +
|
||||
'(a metric built from custom SQL keeps its own aggregation ' +
|
||||
'in the summary row).',
|
||||
),
|
||||
default: 'SUM',
|
||||
clearable: false,
|
||||
choices: [
|
||||
['SUM', t('Sum')],
|
||||
['AVG', t('Average')],
|
||||
],
|
||||
visibility: ({ controls }) =>
|
||||
Boolean(controls?.show_totals?.value),
|
||||
resetOnHide: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'show_numbered_column',
|
||||
|
||||
|
Before Width: | Height: | Size: 32 KiB |
@@ -1560,6 +1560,74 @@ describe('plugin-chart-ag-grid-table', () => {
|
||||
expect(queries[1].columns).toEqual([]);
|
||||
expect(queries[1].metrics).toEqual(['count']);
|
||||
});
|
||||
|
||||
test('defaults aggregate-mode totals to SUM for a simple metric', () => {
|
||||
const simpleMetric = {
|
||||
expressionType: 'SIMPLE' as const,
|
||||
column: { column_name: 'sales' },
|
||||
aggregate: 'SUM' as const,
|
||||
label: 'sum_sales',
|
||||
};
|
||||
const { queries } = buildQuery(
|
||||
{
|
||||
viz_type: VizType.Table,
|
||||
datasource: '11__table',
|
||||
query_mode: QueryMode.Aggregate,
|
||||
groupby: ['state'],
|
||||
metrics: [simpleMetric],
|
||||
show_totals: true,
|
||||
},
|
||||
{ ownState: {} },
|
||||
);
|
||||
|
||||
expect(queries[1].metrics).toEqual([
|
||||
{ ...simpleMetric, aggregate: 'SUM' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('overrides aggregate-mode totals to AVG for a simple metric when totals_aggregate is set', () => {
|
||||
const simpleMetric = {
|
||||
expressionType: 'SIMPLE' as const,
|
||||
column: { column_name: 'sales' },
|
||||
aggregate: 'SUM' as const,
|
||||
label: 'sum_sales',
|
||||
};
|
||||
const { queries } = buildQuery(
|
||||
{
|
||||
viz_type: VizType.Table,
|
||||
datasource: '11__table',
|
||||
query_mode: QueryMode.Aggregate,
|
||||
groupby: ['state'],
|
||||
metrics: [simpleMetric],
|
||||
show_totals: true,
|
||||
totals_aggregate: 'AVG',
|
||||
},
|
||||
{ ownState: {} },
|
||||
);
|
||||
|
||||
// Main query keeps the metric's own aggregation.
|
||||
expect(queries[0].metrics).toEqual([simpleMetric]);
|
||||
// Summary query uses the chosen totals aggregate instead.
|
||||
expect(queries[1].metrics).toEqual([
|
||||
{ ...simpleMetric, aggregate: 'AVG' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('applies totals_aggregate to raw-mode summary columns', () => {
|
||||
const { queries } = buildQuery(
|
||||
{ ...rawFormData, totals_aggregate: 'AVG' },
|
||||
{ ownState: { rawSummaryColumns: ['num'] } },
|
||||
);
|
||||
|
||||
expect(queries[1].metrics).toEqual([
|
||||
{
|
||||
expressionType: 'SIMPLE',
|
||||
aggregate: 'AVG',
|
||||
column: { column_name: 'num' },
|
||||
label: 'num',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildQuery - server pagination row limit', () => {
|
||||
|
||||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 14 KiB |
@@ -195,7 +195,10 @@ const config: ControlPanelConfig = {
|
||||
(!control?.value ||
|
||||
(Array.isArray(control?.value) && control.value.length === 0))
|
||||
) {
|
||||
return [getTemporalColumns(state.datasource).defaultTemporalColumn];
|
||||
const { defaultTemporalColumn } = getTemporalColumns(
|
||||
state.datasource,
|
||||
);
|
||||
return defaultTemporalColumn ? [defaultTemporalColumn] : [];
|
||||
}
|
||||
return control.value;
|
||||
},
|
||||
|
||||
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 978 B After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 12 KiB |