mirror of
https://github.com/apache/superset.git
synced 2026-06-10 01:59:17 +00:00
Compare commits
97 Commits
sl-fixes
...
ci/cypress
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fed40796fd | ||
|
|
5a11cc2177 | ||
|
|
39f93eb63c | ||
|
|
cf5307d0c6 | ||
|
|
9d1bc6b2cc | ||
|
|
6a125bf774 | ||
|
|
43fde2fb07 | ||
|
|
2be2246a00 | ||
|
|
80a5f6b787 | ||
|
|
c373da1bb9 | ||
|
|
80ea36c852 | ||
|
|
6ea4e22785 | ||
|
|
fcb1e299ac | ||
|
|
f4dfb7f026 | ||
|
|
001834470b | ||
|
|
e5c7200551 | ||
|
|
cb2a56d16e | ||
|
|
e5ff6de790 | ||
|
|
accc94da51 | ||
|
|
c914df5a67 | ||
|
|
e3ba85b1a5 | ||
|
|
b8a2f925ee | ||
|
|
77c2bed5f7 | ||
|
|
56fd991efd | ||
|
|
61b32d1b7d | ||
|
|
3191b0fdcd | ||
|
|
cf08a5ebf7 | ||
|
|
f7f50a7977 | ||
|
|
725f5ed2a9 | ||
|
|
faa76f6741 | ||
|
|
8e4a460cc7 | ||
|
|
b9dc9d722e | ||
|
|
fa41769a08 | ||
|
|
df21fe6571 | ||
|
|
12bef03f4a | ||
|
|
0b9764aed5 | ||
|
|
ac522ded1c | ||
|
|
c54990c861 | ||
|
|
3bbb35e8a3 | ||
|
|
a2a369cb5c | ||
|
|
9af6746dbe | ||
|
|
6abee0289b | ||
|
|
8c62f533d7 | ||
|
|
17d1a45bc9 | ||
|
|
6eaee211aa | ||
|
|
3e589436fa | ||
|
|
a9df2c7e5e | ||
|
|
8508af3201 | ||
|
|
49f3dbba73 | ||
|
|
616c243278 | ||
|
|
00dd31494d | ||
|
|
b97d3ef520 | ||
|
|
4d2b10d916 | ||
|
|
86fa5bb46f | ||
|
|
19c2b67d09 | ||
|
|
d2d46169bf | ||
|
|
1b8099811b | ||
|
|
242c27a974 | ||
|
|
24422c8311 | ||
|
|
1632b235ae | ||
|
|
093b43c7a5 | ||
|
|
4996d7c277 | ||
|
|
d26a7aac3d | ||
|
|
699e741c69 | ||
|
|
fc0245bdb0 | ||
|
|
7275116f4c | ||
|
|
88abd41c8b | ||
|
|
ddb647cd3a | ||
|
|
aba6ea536c | ||
|
|
ca8855dc03 | ||
|
|
052e567f77 | ||
|
|
e2ed989639 | ||
|
|
2abbb64e6b | ||
|
|
c6faa50338 | ||
|
|
817a35f445 | ||
|
|
a6d2c95480 | ||
|
|
c29591b3b1 | ||
|
|
365914f1c7 | ||
|
|
41da35e9db | ||
|
|
861e668f74 | ||
|
|
41059c68bb | ||
|
|
94092d2f72 | ||
|
|
986148d924 | ||
|
|
f04221a06c | ||
|
|
70aa96458a | ||
|
|
8beea84952 | ||
|
|
3f0fbbaac9 | ||
|
|
ce602fc5a8 | ||
|
|
8731974e5c | ||
|
|
a06eb8fc78 | ||
|
|
aa8b474c58 | ||
|
|
efdfefeea2 | ||
|
|
f77fa3ae39 | ||
|
|
bffc3fc58f | ||
|
|
2b8e31bf68 | ||
|
|
74d1c83ec5 | ||
|
|
1523d797ca |
4
.github/workflows/bashlib.sh
vendored
4
.github/workflows/bashlib.sh
vendored
@@ -20,10 +20,6 @@ set -e
|
||||
GITHUB_WORKSPACE=${GITHUB_WORKSPACE:-.}
|
||||
ASSETS_MANIFEST="$GITHUB_WORKSPACE/superset/static/assets/manifest.json"
|
||||
|
||||
# Rounded job start time, used to create a unique Cypress build id for
|
||||
# parallelization so we can manually rerun a job after 20 minutes
|
||||
NONCE=$(echo "$(date "+%Y%m%d%H%M") - ($(date +%M)%20)" | bc)
|
||||
|
||||
# Echo only when not in parallel mode
|
||||
say() {
|
||||
if [[ $(echo "$INPUT_PARALLEL" | tr '[:lower:]' '[:upper:]') != 'TRUE' ]]; then
|
||||
|
||||
13
.github/workflows/check-python-deps.yml
vendored
13
.github/workflows/check-python-deps.yml
vendored
@@ -38,6 +38,19 @@ jobs:
|
||||
if: steps.check.outputs.python
|
||||
uses: ./.github/actions/setup-backend/
|
||||
|
||||
# Authenticate the Docker daemon so the python:slim pull in
|
||||
# uv-pip-compile.sh uses our (much higher) authenticated rate limit
|
||||
# instead of the shared-runner anonymous one. Best-effort: on fork PRs the
|
||||
# secrets are unavailable, so this no-ops and the pull falls back to
|
||||
# anonymous (covered by the retry loop in the script).
|
||||
- name: Login to Docker Hub
|
||||
if: steps.check.outputs.python
|
||||
continue-on-error: true
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USER }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Run uv
|
||||
if: steps.check.outputs.python
|
||||
run: ./scripts/uv-pip-compile.sh
|
||||
|
||||
@@ -12,6 +12,11 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# cancel previous workflow jobs for PRs
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
|
||||
validate-all-ghas:
|
||||
|
||||
5
.github/workflows/labeler.yml
vendored
5
.github/workflows/labeler.yml
vendored
@@ -2,6 +2,11 @@ name: "Pull Request Labeler"
|
||||
on:
|
||||
- pull_request_target
|
||||
|
||||
# cancel previous workflow jobs for PRs
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
labeler:
|
||||
permissions:
|
||||
|
||||
5
.github/workflows/pr-lint.yml
vendored
5
.github/workflows/pr-lint.yml
vendored
@@ -8,6 +8,11 @@ on:
|
||||
# Possible values: https://help.github.com/en/actions/reference/events-that-trigger-workflows#pull-request-event-pull_request
|
||||
types: [opened, edited, reopened, synchronize]
|
||||
|
||||
# cancel previous workflow jobs for PRs
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lint-check:
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
138
.github/workflows/superset-e2e.yml
vendored
138
.github/workflows/superset-e2e.yml
vendored
@@ -1,12 +1,16 @@
|
||||
name: E2E
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "master"
|
||||
- "[0-9].[0-9]*"
|
||||
pull_request:
|
||||
types: [synchronize, opened, reopened, ready_for_review]
|
||||
# Gated behind pre-commit: this workflow runs only after the "pre-commit
|
||||
# checks" workflow completes, and (via the job-level `if` below) only when
|
||||
# it succeeded. That keeps the expensive Cypress/Playwright runners from
|
||||
# spinning up while a PR still has formatting/lint/type errors that
|
||||
# pre-commit catches in a fraction of the time. pre-commit itself runs on
|
||||
# push (master/release) and pull_request, so this preserves coverage for
|
||||
# both event types.
|
||||
workflow_run:
|
||||
workflows: ["pre-commit checks"]
|
||||
types: [completed]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
use_dashboard:
|
||||
@@ -23,11 +27,46 @@ on:
|
||||
default: ''
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
# workflow_run has no PR number in context; key on the originating branch.
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
# The pre-commit gate: only proceed when pre-commit succeeded (or on a
|
||||
# manual dispatch). On failure this job is skipped, and every downstream
|
||||
# job (needs: changes) is skipped with it — no runners are provisioned.
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
python: ${{ steps.check.outputs.python }}
|
||||
frontend: ${{ steps.check.outputs.frontend }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }}
|
||||
# The shared change-detector action reads the live event context, which
|
||||
# under workflow_run points at the default branch. Call the script
|
||||
# directly instead, passing the originating event/SHA/PR via WF_RUN_*.
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
WF_RUN_EVENT: ${{ github.event.workflow_run.event }}
|
||||
WF_RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
WF_RUN_PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }}
|
||||
run: python scripts/change_detector.py
|
||||
|
||||
cypress-matrix:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.python == 'true' || needs.changes.outputs.frontend == 'true'
|
||||
# Somehow one test flakes on 24.04 for unknown reasons, this is the only GHA left on 22.04
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
@@ -40,9 +79,14 @@ jobs:
|
||||
# https://github.com/cypress-io/github-action/issues/48
|
||||
fail-fast: false
|
||||
matrix:
|
||||
parallel_id: [0, 1, 2, 3, 4, 5]
|
||||
parallel_id: [0, 1]
|
||||
browser: ["chrome"]
|
||||
app_root: ${{ github.event_name == 'push' && fromJSON('["", "/app/prefix"]') || fromJSON('[""]') }}
|
||||
app_root: ${{ github.event.workflow_run.event == 'push' && fromJSON('["", "/app/prefix"]') || fromJSON('[""]') }}
|
||||
# The /app/prefix variant (push events only) is smoke-tested on a single
|
||||
# shard rather than the full matrix, so exclude it from the other shards.
|
||||
exclude:
|
||||
- parallel_id: 1
|
||||
app_root: "/app/prefix"
|
||||
env:
|
||||
SUPERSET_ENV: development
|
||||
SUPERSET_CONFIG: tests.integration_tests.superset_test_config
|
||||
@@ -67,13 +111,13 @@ jobs:
|
||||
steps:
|
||||
# -------------------------------------------------------
|
||||
# Conditional checkout based on context
|
||||
- name: Checkout for push or pull_request event
|
||||
if: github.event_name == 'push' || github.event_name == 'pull_request'
|
||||
- name: Checkout (gated by pre-commit via workflow_run)
|
||||
if: github.event_name == 'workflow_run'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
- name: Checkout using ref (workflow_dispatch)
|
||||
if: github.event_name == 'workflow_dispatch' && github.event.inputs.ref != ''
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
@@ -89,51 +133,38 @@ jobs:
|
||||
ref: refs/pull/${{ github.event.inputs.pr_id }}/merge
|
||||
submodules: recursive
|
||||
# -------------------------------------------------------
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-backend/
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
- name: Setup postgres
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: setup-postgres
|
||||
- name: Import test data
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: testdata
|
||||
- name: Setup Node.js
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version-file: './superset-frontend/.nvmrc'
|
||||
- name: Install npm dependencies
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: npm-install
|
||||
- name: Build javascript packages
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: build-instrumented-assets
|
||||
- name: Install cypress
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: cypress-install
|
||||
- name: Run Cypress
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
env:
|
||||
CYPRESS_BROWSER: ${{ matrix.browser }}
|
||||
PARALLEL_ID: ${{ matrix.parallel_id }}
|
||||
PARALLELISM: 6
|
||||
PARALLELISM: 2
|
||||
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
with:
|
||||
@@ -154,6 +185,8 @@ jobs:
|
||||
name: cypress-artifact-${{ github.run_id }}-${{ github.job }}-${{ matrix.browser }}-${{ matrix.parallel_id }}--${{ steps.set-safe-app-root.outputs.safe_app_root }}
|
||||
|
||||
playwright-tests:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.python == 'true' || needs.changes.outputs.frontend == 'true'
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -162,7 +195,7 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
browser: ["chromium"]
|
||||
app_root: ${{ github.event_name == 'push' && fromJSON('["", "/app/prefix"]') || fromJSON('[""]') }}
|
||||
app_root: ${{ github.event.workflow_run.event == 'push' && fromJSON('["", "/app/prefix"]') || fromJSON('[""]') }}
|
||||
env:
|
||||
SUPERSET_ENV: development
|
||||
SUPERSET_CONFIG: tests.integration_tests.superset_test_config
|
||||
@@ -185,13 +218,13 @@ jobs:
|
||||
steps:
|
||||
# -------------------------------------------------------
|
||||
# Conditional checkout based on context (same as Cypress workflow)
|
||||
- name: Checkout for push or pull_request event
|
||||
if: github.event_name == 'push' || github.event_name == 'pull_request'
|
||||
- name: Checkout (gated by pre-commit via workflow_run)
|
||||
if: github.event_name == 'workflow_run'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
- name: Checkout using ref (workflow_dispatch)
|
||||
if: github.event_name == 'workflow_dispatch' && github.event.inputs.ref != ''
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
@@ -207,51 +240,37 @@ jobs:
|
||||
ref: refs/pull/${{ github.event.inputs.pr_id }}/merge
|
||||
submodules: recursive
|
||||
# -------------------------------------------------------
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-backend/
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
- name: Setup postgres
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: setup-postgres
|
||||
- name: Import test data
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: playwright_testdata
|
||||
- name: Setup Node.js
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version-file: './superset-frontend/.nvmrc'
|
||||
- name: Install npm dependencies
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: npm-install
|
||||
- name: Build javascript packages
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: build-instrumented-assets
|
||||
- name: Build embedded SDK
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: build-embedded-sdk
|
||||
- name: Install Playwright
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: playwright-install
|
||||
- name: Run Playwright (Required Tests)
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
env:
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
@@ -273,3 +292,34 @@ jobs:
|
||||
${{ github.workspace }}/superset-frontend/playwright-results/
|
||||
${{ github.workspace }}/superset-frontend/test-results/
|
||||
name: playwright-artifact-${{ github.run_id }}-${{ github.job }}-${{ matrix.browser }}--${{ steps.set-safe-app-root.outputs.safe_app_root }}
|
||||
|
||||
# workflow_run runs don't attach their checks to the originating PR, so post
|
||||
# an aggregate commit status back onto the PR head SHA. Make THIS the required
|
||||
# status check in branch protection (in place of the individual E2E jobs).
|
||||
report-status:
|
||||
needs: [cypress-matrix, playwright-tests]
|
||||
if: always() && github.event_name == 'workflow_run'
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
statuses: write
|
||||
steps:
|
||||
- name: Report aggregate E2E status to PR commit
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
// 'skipped' is acceptable: the change-detector legitimately skips
|
||||
// jobs when no relevant files changed. Only real failures fail.
|
||||
const results = [
|
||||
'${{ needs.cypress-matrix.result }}',
|
||||
'${{ needs.playwright-tests.result }}',
|
||||
];
|
||||
const ok = results.every((r) => r === 'success' || r === 'skipped');
|
||||
await github.rest.repos.createCommitStatus({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
sha: context.payload.workflow_run.head_sha,
|
||||
state: ok ? 'success' : 'failure',
|
||||
context: 'E2E / required',
|
||||
description: ok ? 'E2E passed (or skipped)' : 'E2E failed',
|
||||
target_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
|
||||
});
|
||||
|
||||
36
.github/workflows/superset-playwright.yml
vendored
36
.github/workflows/superset-playwright.yml
vendored
@@ -23,9 +23,30 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
python: ${{ steps.check.outputs.python }}
|
||||
frontend: ${{ steps.check.outputs.frontend }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# NOTE: Required Playwright tests are in superset-e2e.yml (E2E / playwright-tests)
|
||||
# This workflow contains only experimental tests that run in shadow mode
|
||||
playwright-tests-experimental:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.python == 'true' || needs.changes.outputs.frontend == 'true'
|
||||
runs-on: ubuntu-22.04
|
||||
continue-on-error: true
|
||||
permissions:
|
||||
@@ -80,58 +101,43 @@ jobs:
|
||||
ref: refs/pull/${{ github.event.inputs.pr_id }}/merge
|
||||
submodules: recursive
|
||||
# -------------------------------------------------------
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-backend/
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
- name: Setup postgres
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: setup-postgres
|
||||
- name: Import test data
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: playwright_testdata
|
||||
- name: Setup Node.js
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version-file: './superset-frontend/.nvmrc'
|
||||
- name: Install npm dependencies
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: npm-install
|
||||
- name: Build javascript packages
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: build-instrumented-assets
|
||||
- name: Build embedded SDK
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: build-embedded-sdk
|
||||
- name: Install Playwright
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: playwright-install
|
||||
- name: Run Playwright (Experimental Tests)
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
env:
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
with:
|
||||
run: playwright-run "${{ matrix.app_root }}" experimental/
|
||||
- name: Run Playwright (Embedded Tests)
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
env:
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
|
||||
@@ -14,7 +14,27 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
python: ${{ steps.check.outputs.python }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
test-mysql:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.python == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -27,6 +47,8 @@ jobs:
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
# Authenticated pulls use our higher Docker Hub rate limit. Empty on
|
||||
# fork PRs (secrets unavailable) -> runner falls back to anonymous.
|
||||
env:
|
||||
MYSQL_ROOT_PASSWORD: root
|
||||
ports:
|
||||
@@ -47,26 +69,17 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-backend/
|
||||
if: steps.check.outputs.python
|
||||
- name: Setup MySQL
|
||||
if: steps.check.outputs.python
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: setup-mysql
|
||||
- name: Start Celery worker
|
||||
if: steps.check.outputs.python
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: celery-worker
|
||||
- name: Python integration tests (MySQL)
|
||||
if: steps.check.outputs.python
|
||||
run: |
|
||||
./scripts/python_tests.sh
|
||||
- name: Upload code coverage
|
||||
@@ -77,7 +90,6 @@ jobs:
|
||||
use_oidc: true
|
||||
slug: apache/superset
|
||||
- name: Generate database diagnostics for docs
|
||||
if: steps.check.outputs.python
|
||||
env:
|
||||
SUPERSET_CONFIG: tests.integration_tests.superset_test_config
|
||||
SUPERSET__SQLALCHEMY_DATABASE_URI: |
|
||||
@@ -100,13 +112,14 @@ jobs:
|
||||
print(f'Generated diagnostics for {len(docs)} databases')
|
||||
"
|
||||
- name: Upload database diagnostics artifact
|
||||
if: steps.check.outputs.python
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: database-diagnostics
|
||||
path: databases-diagnostics.json
|
||||
retention-days: 7
|
||||
test-postgres:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.python == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -138,29 +151,20 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-backend/
|
||||
if: steps.check.outputs.python
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Setup Postgres
|
||||
if: steps.check.outputs.python
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: |
|
||||
setup-postgres
|
||||
- name: Start Celery worker
|
||||
if: steps.check.outputs.python
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: celery-worker
|
||||
- name: Python integration tests (PostgreSQL)
|
||||
if: steps.check.outputs.python
|
||||
run: |
|
||||
./scripts/python_tests.sh
|
||||
- name: Upload code coverage
|
||||
@@ -172,6 +176,8 @@ jobs:
|
||||
slug: apache/superset
|
||||
|
||||
test-sqlite:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.python == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -194,28 +200,19 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-backend/
|
||||
if: steps.check.outputs.python
|
||||
- name: Install dependencies
|
||||
if: steps.check.outputs.python
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: |
|
||||
# sqlite needs this working directory
|
||||
mkdir ${{ github.workspace }}/.temp
|
||||
- name: Start Celery worker
|
||||
if: steps.check.outputs.python
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: celery-worker
|
||||
- name: Python integration tests (SQLite)
|
||||
if: steps.check.outputs.python
|
||||
run: |
|
||||
./scripts/python_tests.sh
|
||||
- name: Upload code coverage
|
||||
|
||||
@@ -15,7 +15,27 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
python: ${{ steps.check.outputs.python }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
test-postgres-presto:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.python == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -54,28 +74,17 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-backend/
|
||||
if: steps.check.outputs.python == 'true'
|
||||
- name: Setup Postgres
|
||||
if: steps.check.outputs.python
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: |
|
||||
echo "${{ steps.check.outputs.python }}"
|
||||
setup-postgres
|
||||
run: setup-postgres
|
||||
- name: Start Celery worker
|
||||
if: steps.check.outputs.python
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: celery-worker
|
||||
- name: Python unit tests (PostgreSQL)
|
||||
if: steps.check.outputs.python
|
||||
run: |
|
||||
./scripts/python_tests.sh -m 'chart_data_flow or sql_json_flow'
|
||||
- name: Upload code coverage
|
||||
@@ -87,6 +96,8 @@ jobs:
|
||||
slug: apache/superset
|
||||
|
||||
test-postgres-hive:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.python == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -117,35 +128,23 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Create csv upload directory
|
||||
if: steps.check.outputs.python
|
||||
run: sudo mkdir -p /tmp/.superset/uploads
|
||||
- name: Give write access to the csv upload directory
|
||||
if: steps.check.outputs.python
|
||||
run: sudo chown -R $USER:$USER /tmp/.superset
|
||||
- name: Start hadoop and hive
|
||||
if: steps.check.outputs.python
|
||||
run: docker compose -f scripts/databases/hive/docker-compose.yml up -d
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-backend/
|
||||
if: steps.check.outputs.python
|
||||
- name: Setup Postgres
|
||||
if: steps.check.outputs.python
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: setup-postgres
|
||||
- name: Start Celery worker
|
||||
if: steps.check.outputs.python
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: celery-worker
|
||||
- name: Python unit tests (PostgreSQL)
|
||||
if: steps.check.outputs.python
|
||||
run: |
|
||||
pip install -e .[hive]
|
||||
./scripts/python_tests.sh -m 'chart_data_flow or sql_json_flow'
|
||||
|
||||
28
.github/workflows/superset-python-unittest.yml
vendored
28
.github/workflows/superset-python-unittest.yml
vendored
@@ -15,7 +15,27 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
python: ${{ steps.check.outputs.python }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
unit-tests:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.python == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -30,25 +50,17 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-backend/
|
||||
if: steps.check.outputs.python
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Python unit tests
|
||||
if: steps.check.outputs.python
|
||||
env:
|
||||
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
|
||||
- name: Python 100% coverage unit tests
|
||||
if: steps.check.outputs.python
|
||||
env:
|
||||
SUPERSET_TESTENV: true
|
||||
SUPERSET_SECRET_KEY: not-a-secret
|
||||
|
||||
10
.github/workflows/superset-translations.yml
vendored
10
.github/workflows/superset-translations.yml
vendored
@@ -113,13 +113,9 @@ jobs:
|
||||
--translations-dir /tmp/base-worktree/superset/translations \
|
||||
> /tmp/before.json
|
||||
|
||||
# Reset the PR worktree's translations to the pristine BASE state so
|
||||
# both babel_update runs start from the same .po files. The only
|
||||
# difference between the runs is the source code.
|
||||
- name: Reset PR worktree translations to pristine BASE
|
||||
if: steps.check.outputs.python == 'true' || steps.check.outputs.frontend == 'true'
|
||||
run: git checkout FETCH_HEAD -- superset/translations/
|
||||
|
||||
# Run babel_update against the PR source and PR translations. This keeps
|
||||
# committed .po fixes in play while the base babel_update above still
|
||||
# cancels out translation drift already present on the base branch.
|
||||
- name: Run babel_update against PR source
|
||||
if: steps.check.outputs.python == 'true' || steps.check.outputs.frontend == 'true'
|
||||
run: ./scripts/translations/babel_update.sh
|
||||
|
||||
@@ -109,7 +109,7 @@ If yes, it is in scope. If no, it is out of scope. The lists below apply that te
|
||||
- Any action an Admin role can perform through documented configuration, API, or UI. The Admin role is a trusted operational principal by policy. Per MITRE CNA Operational Rules 4.1, a qualifying vulnerability must violate a security policy; behavior within a documented trust boundary does not.
|
||||
- Deployment or operator decisions: the values of secrets and tokens, whether internal networks are reachable from the server, which database connectors or cache backends are enabled, which feature flags are set, where notifications are delivered, and which third-party plugins are loaded.
|
||||
- Compromise, modification, or malicious control of trusted backend infrastructure. Apache Superset assumes the integrity of its metastore, cache backends (for example Redis or Memcached), message brokers, secret stores, and other operator-managed infrastructure. Findings that require an attacker to read from, write to, or otherwise tamper with these systems, including injecting malicious state, serialized objects, cache entries, task metadata, configuration, or database records, are post-compromise scenarios and do not constitute vulnerabilities in Apache Superset itself. A finding remains in scope only if an unprivileged user can cause such modification through a vulnerability in Apache Superset.
|
||||
- Code paths whose intended purpose is example data, demos, fixtures, local development, or documentation, rather than the production runtime.
|
||||
- The continued presence of expired key-value or metastore-cache entries that have not yet been deleted from the metadata database. Such entries are excluded from reads once expired, are purged opportunistically on write, and are removed in bulk by the scheduled `prune_key_value` maintenance task; their lingering until purged is an eventual-cleanup property, not a security boundary, and does not constitute a vulnerability.
|
||||
- How a downstream application (spreadsheet program, email client, browser handling user-downloaded files) interprets output Apache Superset produced for it.
|
||||
- Findings without a reproducible proof of concept against a supported release. The burden of demonstrating exploitability rests with the reporter; findings closed for lack of a proof of concept may be refiled if one is later produced.
|
||||
- Brute force, rate limiting, denial of service, or resource exhaustion that does not bypass a documented control.
|
||||
|
||||
10
UPDATING.md
10
UPDATING.md
@@ -24,6 +24,16 @@ assists people when migrating to a new version.
|
||||
|
||||
## Next
|
||||
|
||||
### YDB now uses a native sqlglot dialect
|
||||
|
||||
YDB SQL parsing now relies on the dedicated [`ydb-sqlglot-plugin`](https://pypi.org/project/ydb-sqlglot-plugin/) dialect, which registers itself with sqlglot automatically. YDB users must install this plugin (e.g., via `pip install "apache-superset[ydb]"`) to avoid a `ValueError` when Superset parses YDB queries.
|
||||
|
||||
### Embedded dashboards enforce configured Allowed Domains for postMessage
|
||||
|
||||
The embedded dashboard page now validates the origin of incoming `postMessage` events against the dashboard's configured **Allowed Domains**. The server-rendered embedded page exposes the configured domains in its bootstrap payload, and the frontend rejects message events whose origin is not in that list.
|
||||
|
||||
Enforcement only applies when the Allowed Domains list is non-empty. If the list is empty (the default), any origin is accepted, so there is no behavior change for embeds that did not configure Allowed Domains.
|
||||
|
||||
### Dataset import validates catalog against the target connection
|
||||
|
||||
Importing a dataset now validates the `catalog` field against the target database connection. When the connection has multi-catalog disabled (`allow_multi_catalog` off) and the dataset's catalog is not the connection's default catalog, the import fails instead of silently persisting the non-default catalog. This matches the validation already enforced on the dataset update path and prevents imported datasets from querying an unintended database.
|
||||
|
||||
@@ -80,7 +80,23 @@ case "${1}" in
|
||||
;;
|
||||
app)
|
||||
echo "Starting web app (using development server)..."
|
||||
flask run -p $PORT --reload --debugger --host=0.0.0.0 --exclude-patterns "*/node_modules/*:*/.venv/*:*/build/*:*/__pycache__/*:*/superset-frontend/*"
|
||||
|
||||
# Environment-based debugger control for security
|
||||
# Only enable Werkzeug interactive debugger when explicitly requested
|
||||
# Modern Werkzeug (3.0+) includes PIN protection, but defense-in-depth approach
|
||||
# Override FLASK_DEBUG so the effective state matches SUPERSET_DEBUG_ENABLED even
|
||||
# when FLASK_DEBUG=true is inherited from docker/.env or .flaskenv
|
||||
if [[ "${SUPERSET_DEBUG_ENABLED:-}" == "true" ]]; then
|
||||
export FLASK_DEBUG=1
|
||||
DEBUGGER_FLAG="--debugger"
|
||||
echo " ⚠️ Werkzeug debugger enabled (requires PIN for /console access)"
|
||||
else
|
||||
export FLASK_DEBUG=0
|
||||
DEBUGGER_FLAG="--no-debugger"
|
||||
echo " 🔒 Werkzeug debugger disabled (set SUPERSET_DEBUG_ENABLED=true to enable)"
|
||||
fi
|
||||
|
||||
flask run -p $PORT --reload $DEBUGGER_FLAG --host=0.0.0.0 --exclude-patterns "*/node_modules/*:*/.venv/*:*/build/*:*/__pycache__/*:*/superset-frontend/*"
|
||||
;;
|
||||
app-gunicorn)
|
||||
echo "Starting web app..."
|
||||
|
||||
@@ -157,8 +157,15 @@ superset load_examples
|
||||
superset init
|
||||
|
||||
# To start a development web server on port 8088, use -p to bind to another port
|
||||
superset run -p 8088 --with-threads --reload --debugger
|
||||
superset run -p 8088 --with-threads --reload
|
||||
|
||||
# For debugging with interactive console (⚠️ localhost only)
|
||||
# superset run -p 8088 --with-threads --reload --debugger
|
||||
```
|
||||
|
||||
:::warning Security Note
|
||||
The `--debugger` flag enables Werkzeug's interactive console at `/console`. Only use this for local development and never bind to `0.0.0.0` or expose the server to networks when debugging is enabled.
|
||||
:::
|
||||
|
||||
If everything worked, you should be able to navigate to `hostname:port` in your browser (e.g.
|
||||
locally by default at `localhost:8088`) and login using the username and password you created.
|
||||
|
||||
@@ -157,8 +157,15 @@ superset load_examples
|
||||
superset init
|
||||
|
||||
# To start a development web server on port 8088, use -p to bind to another port
|
||||
superset run -p 8088 --with-threads --reload --debugger
|
||||
superset run -p 8088 --with-threads --reload
|
||||
|
||||
# For debugging with interactive console (⚠️ localhost only)
|
||||
# superset run -p 8088 --with-threads --reload --debugger
|
||||
```
|
||||
|
||||
:::warning Security Note
|
||||
The `--debugger` flag enables Werkzeug's interactive console at `/console`. Only use this for local development and never bind to `0.0.0.0` or expose the server to networks when debugging is enabled.
|
||||
:::
|
||||
|
||||
If everything worked, you should be able to navigate to `hostname:port` in your browser (e.g.
|
||||
locally by default at `localhost:8088`) and login using the username and password you created.
|
||||
|
||||
@@ -102,6 +102,8 @@ Affecting the Docker build process:
|
||||
save some precious time on startup by `SUPERSET_LOAD_EXAMPLES=no docker compose up`
|
||||
- **SUPERSET_LOG_LEVEL (default=info)**: Can be set to debug, info, warning, error, critical
|
||||
for more verbose logging
|
||||
- **SUPERSET_DEBUG_ENABLED (default=false)**: Enable Werkzeug debugger with interactive console.
|
||||
Set to `true` for debugging: `SUPERSET_DEBUG_ENABLED=true docker compose up`
|
||||
|
||||
For more env vars that affect your configuration, see this
|
||||
[superset_config.py](https://github.com/apache/superset/blob/master/docker/pythonpath_dev/superset_config.py)
|
||||
|
||||
@@ -25,9 +25,17 @@
|
||||
command = "yarn install && yarn build"
|
||||
# Output directory (relative to base)
|
||||
publish = "build"
|
||||
# Skip builds when no docs changes (exit 0 = skip, exit 1 = build)
|
||||
# Checks for changes in docs/ and README.md (which gets pulled into docs)
|
||||
ignore = "git diff --quiet $CACHED_COMMIT_REF $COMMIT_REF -- . ../README.md"
|
||||
# Skip builds when no docs changes (exit 0 = skip, non-zero = build).
|
||||
# Checks for changes in docs/ and README.md (which gets pulled into docs).
|
||||
#
|
||||
# $CACHED_COMMIT_REF is the last *deployed* commit. On a PR's first build it
|
||||
# is empty, so the original `git diff` errored and Netlify fell back to
|
||||
# building -- which is why every PR built a docs preview once even with no
|
||||
# docs changes. When it is empty we instead diff the whole branch against its
|
||||
# merge-base with master, so non-docs PRs are skipped from the very first
|
||||
# build. Subsequent builds (and the master production build) keep the cheaper
|
||||
# incremental $CACHED_COMMIT_REF diff. Any failure exits non-zero -> build.
|
||||
ignore = 'if [ -n "$CACHED_COMMIT_REF" ]; then git diff --quiet "$CACHED_COMMIT_REF" "$COMMIT_REF" -- . ../README.md; else git fetch origin master --depth=100 >/dev/null 2>&1; git diff --quiet "$(git merge-base origin/master "$COMMIT_REF" 2>/dev/null || echo origin/master)" "$COMMIT_REF" -- . ../README.md; fi'
|
||||
|
||||
[build.environment]
|
||||
# Node version matching docs/.nvmrc
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/react": "^19.1.8",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.3",
|
||||
"@typescript-eslint/parser": "^8.59.3",
|
||||
"@typescript-eslint/parser": "^8.60.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
@@ -109,8 +109,8 @@
|
||||
"globals": "^17.6.0",
|
||||
"prettier": "^3.8.3",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.59.4",
|
||||
"webpack": "^5.107.1"
|
||||
"typescript-eslint": "^8.60.0",
|
||||
"webpack": "^5.107.2"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
||||
180
docs/yarn.lock
180
docs/yarn.lock
@@ -4812,100 +4812,110 @@
|
||||
dependencies:
|
||||
"@types/yargs-parser" "*"
|
||||
|
||||
"@typescript-eslint/eslint-plugin@8.59.4", "@typescript-eslint/eslint-plugin@^8.59.3":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz#c67bfee32caae9cb587dce1ac59c3bf43b659707"
|
||||
integrity sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==
|
||||
"@typescript-eslint/eslint-plugin@8.60.0", "@typescript-eslint/eslint-plugin@^8.59.3":
|
||||
version "8.60.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz#8fc1e0a950c43270eaf0212dc060f7edaa42f9cf"
|
||||
integrity sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==
|
||||
dependencies:
|
||||
"@eslint-community/regexpp" "^4.12.2"
|
||||
"@typescript-eslint/scope-manager" "8.59.4"
|
||||
"@typescript-eslint/type-utils" "8.59.4"
|
||||
"@typescript-eslint/utils" "8.59.4"
|
||||
"@typescript-eslint/visitor-keys" "8.59.4"
|
||||
"@typescript-eslint/scope-manager" "8.60.0"
|
||||
"@typescript-eslint/type-utils" "8.60.0"
|
||||
"@typescript-eslint/utils" "8.60.0"
|
||||
"@typescript-eslint/visitor-keys" "8.60.0"
|
||||
ignore "^7.0.5"
|
||||
natural-compare "^1.4.0"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/parser@8.59.4", "@typescript-eslint/parser@^8.59.3":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.59.4.tgz#77d99e3b27663e7a22cf12c3fb769db509e5e93c"
|
||||
integrity sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==
|
||||
"@typescript-eslint/parser@8.60.0", "@typescript-eslint/parser@^8.60.0":
|
||||
version "8.60.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.60.0.tgz#38d611b8e658cb10850d4975e8a175a222fbcd6a"
|
||||
integrity sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==
|
||||
dependencies:
|
||||
"@typescript-eslint/scope-manager" "8.59.4"
|
||||
"@typescript-eslint/types" "8.59.4"
|
||||
"@typescript-eslint/typescript-estree" "8.59.4"
|
||||
"@typescript-eslint/visitor-keys" "8.59.4"
|
||||
"@typescript-eslint/scope-manager" "8.60.0"
|
||||
"@typescript-eslint/types" "8.60.0"
|
||||
"@typescript-eslint/typescript-estree" "8.60.0"
|
||||
"@typescript-eslint/visitor-keys" "8.60.0"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/project-service@8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.59.4.tgz#5830535a0e7a3ae806e2669964f47a74c4bc6b0e"
|
||||
integrity sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==
|
||||
"@typescript-eslint/project-service@8.60.0":
|
||||
version "8.60.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.60.0.tgz#b82ab12e64d005d0c7163d1240c432381f1bde0f"
|
||||
integrity sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==
|
||||
dependencies:
|
||||
"@typescript-eslint/tsconfig-utils" "^8.59.4"
|
||||
"@typescript-eslint/types" "^8.59.4"
|
||||
"@typescript-eslint/tsconfig-utils" "^8.60.0"
|
||||
"@typescript-eslint/types" "^8.60.0"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/scope-manager@8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz#507d1258c758147dac1adee9517a205a8ac1e046"
|
||||
integrity sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==
|
||||
"@typescript-eslint/scope-manager@8.60.0":
|
||||
version "8.60.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz#7617a4617c043fe235dcf066f9a40f106cfd2fd5"
|
||||
integrity sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.59.4"
|
||||
"@typescript-eslint/visitor-keys" "8.59.4"
|
||||
"@typescript-eslint/types" "8.60.0"
|
||||
"@typescript-eslint/visitor-keys" "8.60.0"
|
||||
|
||||
"@typescript-eslint/tsconfig-utils@8.59.4", "@typescript-eslint/tsconfig-utils@^8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz#218ba229d96dde35212e3a76a7d0a6bc831398be"
|
||||
integrity sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==
|
||||
"@typescript-eslint/tsconfig-utils@8.60.0":
|
||||
version "8.60.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz#3af78c48956227a407dea9626b8db8ca53f130d2"
|
||||
integrity sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==
|
||||
|
||||
"@typescript-eslint/type-utils@8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.59.4.tgz#359fc53ba39a1f1860fddda40ebe5bfe0d87faed"
|
||||
integrity sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==
|
||||
"@typescript-eslint/tsconfig-utils@^8.60.0":
|
||||
version "8.60.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.1.tgz#bee8b942a13679a878101c9c74577d732062ed93"
|
||||
integrity sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==
|
||||
|
||||
"@typescript-eslint/type-utils@8.60.0":
|
||||
version "8.60.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.60.0.tgz#6971a61bc4f3a1b2df45dcc14e26a43a88a4cb6a"
|
||||
integrity sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.59.4"
|
||||
"@typescript-eslint/typescript-estree" "8.59.4"
|
||||
"@typescript-eslint/utils" "8.59.4"
|
||||
"@typescript-eslint/types" "8.60.0"
|
||||
"@typescript-eslint/typescript-estree" "8.60.0"
|
||||
"@typescript-eslint/utils" "8.60.0"
|
||||
debug "^4.4.3"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/types@8.59.4", "@typescript-eslint/types@^8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.59.4.tgz#c29d5c21bfbaa8347ddc677d3ac1fcd2db0f848e"
|
||||
integrity sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==
|
||||
"@typescript-eslint/types@8.60.0":
|
||||
version "8.60.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.60.0.tgz#e77ad768e933263b1960b2fe79de75fe1cc6e7db"
|
||||
integrity sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==
|
||||
|
||||
"@typescript-eslint/typescript-estree@8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz#d005e5e1fb425526f39685594bed34a04ad755ea"
|
||||
integrity sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==
|
||||
"@typescript-eslint/types@^8.60.0":
|
||||
version "8.60.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.60.1.tgz#ccdc482ba9e17f9723a10ce240b5e67dad3046c4"
|
||||
integrity sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==
|
||||
|
||||
"@typescript-eslint/typescript-estree@8.60.0":
|
||||
version "8.60.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz#c102196a44414481190041c99eea1d854e66001b"
|
||||
integrity sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==
|
||||
dependencies:
|
||||
"@typescript-eslint/project-service" "8.59.4"
|
||||
"@typescript-eslint/tsconfig-utils" "8.59.4"
|
||||
"@typescript-eslint/types" "8.59.4"
|
||||
"@typescript-eslint/visitor-keys" "8.59.4"
|
||||
"@typescript-eslint/project-service" "8.60.0"
|
||||
"@typescript-eslint/tsconfig-utils" "8.60.0"
|
||||
"@typescript-eslint/types" "8.60.0"
|
||||
"@typescript-eslint/visitor-keys" "8.60.0"
|
||||
debug "^4.4.3"
|
||||
minimatch "^10.2.2"
|
||||
semver "^7.7.3"
|
||||
tinyglobby "^0.2.15"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/utils@8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.59.4.tgz#8ccd2b08aecc72c7efc0d7ac6695631d199d256e"
|
||||
integrity sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==
|
||||
"@typescript-eslint/utils@8.60.0":
|
||||
version "8.60.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.60.0.tgz#6110cddaef87606ae4ca6f8bf81bb5949fc8e098"
|
||||
integrity sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils" "^4.9.1"
|
||||
"@typescript-eslint/scope-manager" "8.59.4"
|
||||
"@typescript-eslint/types" "8.59.4"
|
||||
"@typescript-eslint/typescript-estree" "8.59.4"
|
||||
"@typescript-eslint/scope-manager" "8.60.0"
|
||||
"@typescript-eslint/types" "8.60.0"
|
||||
"@typescript-eslint/typescript-estree" "8.60.0"
|
||||
|
||||
"@typescript-eslint/visitor-keys@8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz#1ac23b747b011f5cbdb449da97769f6c5f3a9355"
|
||||
integrity sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==
|
||||
"@typescript-eslint/visitor-keys@8.60.0":
|
||||
version "8.60.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz#f2c41eedd3d7b03b808369fb2e3fb40a93783ec2"
|
||||
integrity sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.59.4"
|
||||
"@typescript-eslint/types" "8.60.0"
|
||||
eslint-visitor-keys "^5.0.0"
|
||||
|
||||
"@ungap/structured-clone@^1.0.0":
|
||||
@@ -7248,10 +7258,10 @@ encodeurl@~2.0.0:
|
||||
resolved "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz"
|
||||
integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==
|
||||
|
||||
enhanced-resolve@^5.21.4:
|
||||
version "5.21.5"
|
||||
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.21.5.tgz#8f80167d009d8f01267ad61035e59fe5c94ac3a6"
|
||||
integrity sha512-mLCNbrQli11K1ySUmuNt4ZUB3OpGIDq4q2vTBTf5cL2lpsRjI9QKqSD0ndjW8FyvcW/Jj46gMe9syyHAsvMa/A==
|
||||
enhanced-resolve@^5.22.0:
|
||||
version "5.22.1"
|
||||
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.22.1.tgz#c34bc3f414298496fc244b21bbe316440782da17"
|
||||
integrity sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==
|
||||
dependencies:
|
||||
graceful-fs "^4.2.4"
|
||||
tapable "^2.3.3"
|
||||
@@ -14372,15 +14382,15 @@ types-ramda@^0.30.1:
|
||||
dependencies:
|
||||
ts-toolbelt "^9.6.0"
|
||||
|
||||
typescript-eslint@^8.59.4:
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.59.4.tgz#834e3b53f4d1a764a985ceb8592c4a95d6a8da7c"
|
||||
integrity sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ==
|
||||
typescript-eslint@^8.60.0:
|
||||
version "8.60.0"
|
||||
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.60.0.tgz#6686fecb1f4f367c0bf0075828e93b7ecacbc62b"
|
||||
integrity sha512-9f65qWLZdAW9m1JaxBDUHcqRUfL8bkxxXL7XxEfI+F09q56PkBvIfCjLF3yInsDM/BBmwkqmCQdCZe/RYlIWEw==
|
||||
dependencies:
|
||||
"@typescript-eslint/eslint-plugin" "8.59.4"
|
||||
"@typescript-eslint/parser" "8.59.4"
|
||||
"@typescript-eslint/typescript-estree" "8.59.4"
|
||||
"@typescript-eslint/utils" "8.59.4"
|
||||
"@typescript-eslint/eslint-plugin" "8.60.0"
|
||||
"@typescript-eslint/parser" "8.60.0"
|
||||
"@typescript-eslint/typescript-estree" "8.60.0"
|
||||
"@typescript-eslint/utils" "8.60.0"
|
||||
|
||||
typescript@~6.0.3:
|
||||
version "6.0.3"
|
||||
@@ -14930,20 +14940,20 @@ webpack-merge@^6.0.1:
|
||||
flat "^5.0.2"
|
||||
wildcard "^2.0.1"
|
||||
|
||||
webpack-sources@^3.4.1:
|
||||
version "3.4.1"
|
||||
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.4.1.tgz#009d110999ebd9fb3a6fa8d32eec6f84d940e65d"
|
||||
integrity sha512-eACpxRN02yaawnt+uUNIF7Qje6A9zArxBbcAJjK1PK3S9Ycg5jIuJ8pW4q8EMnwNZCEGltcjkRx1QzOxOkKD8A==
|
||||
webpack-sources@^3.5.0:
|
||||
version "3.5.0"
|
||||
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.5.0.tgz#87bf7f5801a4e985b1f1c92b64b9620a02f76d08"
|
||||
integrity sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==
|
||||
|
||||
webpack-virtual-modules@^0.6.2:
|
||||
version "0.6.2"
|
||||
resolved "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz"
|
||||
integrity sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==
|
||||
|
||||
webpack@^5.107.1, webpack@^5.88.1, webpack@^5.95.0:
|
||||
version "5.107.1"
|
||||
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.107.1.tgz#01ad63131b7c413f607cc00a8136f467c1f10af0"
|
||||
integrity sha512-mvdIWxj/H6QsfgDdH9djne3a5dYcmEmtsXGESkypaGN5jXjF/b+9KDlmTDQ2TKlFUeA2fI9Y65kihD30JOdB+Q==
|
||||
webpack@^5.107.2, webpack@^5.88.1, webpack@^5.95.0:
|
||||
version "5.107.2"
|
||||
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.107.2.tgz#dea14dcb177b46b29de15f952f7303691ee2b596"
|
||||
integrity sha512-v7RhXaJbpMlV0D7hC7lb2EbnxkoeUqf9qhKr6lozx3Q48pmFrqqNRmZFUEGmi7pSwm6fCQ2H1IjvCkHqdpVdjQ==
|
||||
dependencies:
|
||||
"@types/estree" "^1.0.8"
|
||||
"@types/json-schema" "^7.0.15"
|
||||
@@ -14954,7 +14964,7 @@ webpack@^5.107.1, webpack@^5.88.1, webpack@^5.95.0:
|
||||
acorn-import-phases "^1.0.3"
|
||||
browserslist "^4.28.1"
|
||||
chrome-trace-event "^1.0.2"
|
||||
enhanced-resolve "^5.21.4"
|
||||
enhanced-resolve "^5.22.0"
|
||||
es-module-lexer "^2.1.0"
|
||||
eslint-scope "5.1.1"
|
||||
events "^3.2.0"
|
||||
@@ -14967,7 +14977,7 @@ webpack@^5.107.1, webpack@^5.88.1, webpack@^5.95.0:
|
||||
tapable "^2.3.0"
|
||||
terser-webpack-plugin "^5.5.0"
|
||||
watchpack "^2.5.1"
|
||||
webpack-sources "^3.4.1"
|
||||
webpack-sources "^3.5.0"
|
||||
|
||||
webpackbar@^7.0.0:
|
||||
version "7.0.0"
|
||||
|
||||
@@ -154,7 +154,7 @@ fastmcp = [
|
||||
]
|
||||
firebird = ["sqlalchemy-firebird>=0.7.0, <2.2"]
|
||||
firebolt = ["firebolt-sqlalchemy>=1.0.0, <2"]
|
||||
gevent = ["gevent>=23.9.1"]
|
||||
gevent = ["gevent>=26.4.0"]
|
||||
gsheets = ["shillelagh[gsheetsapi]>=1.4.4, <2"]
|
||||
hana = ["hdbcli==2.28.20", "sqlalchemy_hana==0.4.0"]
|
||||
hive = [
|
||||
@@ -208,7 +208,7 @@ netezza = ["nzalchemy>=11.0.2"]
|
||||
starrocks = ["starrocks>=1.0.0"]
|
||||
doris = ["pydoris>=1.0.0, <2.0.0"]
|
||||
oceanbase = ["oceanbase_py>=0.0.1"]
|
||||
ydb = ["ydb-sqlalchemy>=0.1.2"]
|
||||
ydb = ["ydb-sqlalchemy>=0.1.2", "ydb-sqlglot-plugin>=0.2.5"]
|
||||
development = [
|
||||
# no bounds for apache-superset-extensions-cli until a stable version
|
||||
"apache-superset-extensions-cli",
|
||||
@@ -225,7 +225,7 @@ development = [
|
||||
"progress>=1.5,<2",
|
||||
"psutil",
|
||||
"pyfakefs",
|
||||
"pyinstrument>=4.0.2,<6",
|
||||
"pyinstrument>=5.1.2,<6",
|
||||
"pylint",
|
||||
"pytest<8.0.0", # hairy issue with pytest >=8 where current_app proxies are not set in time
|
||||
"pytest-asyncio",
|
||||
@@ -456,6 +456,7 @@ authorized_licenses = [
|
||||
"isc license (iscl)",
|
||||
"isc license",
|
||||
"mit",
|
||||
"mit and psf-2.0",
|
||||
"mit-cmu",
|
||||
"mozilla public license 2.0 (mpl 2.0)",
|
||||
"osi approved",
|
||||
|
||||
@@ -161,7 +161,7 @@ geopy==2.4.1
|
||||
# via apache-superset (pyproject.toml)
|
||||
google-auth==2.43.0
|
||||
# via shillelagh
|
||||
greenlet==3.1.1
|
||||
greenlet==3.5.0
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# shillelagh
|
||||
|
||||
@@ -331,7 +331,7 @@ geopy==2.4.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
gevent==24.2.1
|
||||
gevent==26.4.0
|
||||
# via apache-superset
|
||||
google-api-core==2.23.0
|
||||
# via
|
||||
@@ -373,7 +373,7 @@ googleapis-common-protos==1.66.0
|
||||
# via
|
||||
# google-api-core
|
||||
# grpcio-status
|
||||
greenlet==3.1.1
|
||||
greenlet==3.5.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -768,7 +768,7 @@ pygments==2.20.0
|
||||
# rich
|
||||
pyhive==0.7.0
|
||||
# via apache-superset
|
||||
pyinstrument==4.4.0
|
||||
pyinstrument==5.1.2
|
||||
# via apache-superset
|
||||
pyjwt==2.12.0
|
||||
# via
|
||||
|
||||
16
scripts/__init__.py
Normal file
16
scripts/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
# 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.
|
||||
@@ -109,6 +109,37 @@ def is_int(s: str) -> bool:
|
||||
return bool(re.match(r"^-?\d+$", s))
|
||||
|
||||
|
||||
def resolve_workflow_run_files(repo: str, sha: str) -> Optional[List[str]]:
|
||||
"""Resolve changed files for a workflow_run-triggered run.
|
||||
|
||||
When a workflow is gated behind another (e.g. running only after
|
||||
pre-commit succeeds), GitHub re-dispatches it as a `workflow_run` event
|
||||
whose context points at the default branch rather than the originating
|
||||
diff. Recover the original event and head SHA from the workflow_run
|
||||
payload, exposed via the WF_RUN_* env vars. Returns ``None`` (meaning
|
||||
"assume all changed") when the diff can't be resolved.
|
||||
"""
|
||||
original_event = os.getenv("WF_RUN_EVENT") or "push"
|
||||
print("ORIGINAL_EVENT", original_event)
|
||||
if original_event == "pull_request":
|
||||
pr_number = os.getenv("WF_RUN_PR_NUMBER", "")
|
||||
if not is_int(pr_number):
|
||||
# Fork PRs don't populate workflow_run.pull_requests, so we can't
|
||||
# resolve the diff -> assume all changed (run everything).
|
||||
print("workflow_run without PR context, assuming all changed")
|
||||
return None
|
||||
files = fetch_changed_files_pr(repo, pr_number)
|
||||
print("PR files:")
|
||||
print_files(files)
|
||||
return files
|
||||
|
||||
head_sha = os.getenv("WF_RUN_HEAD_SHA") or sha
|
||||
files = fetch_changed_files_push(repo, head_sha)
|
||||
print("Files touched since previous commit:")
|
||||
print_files(files)
|
||||
return files
|
||||
|
||||
|
||||
def main(event_type: str, sha: str, repo: str) -> None:
|
||||
"""Main function to check for file changes based on event context."""
|
||||
print("SHA:", sha)
|
||||
@@ -126,6 +157,9 @@ def main(event_type: str, sha: str, repo: str) -> None:
|
||||
print("Files touched since previous commit:")
|
||||
print_files(files)
|
||||
|
||||
elif event_type == "workflow_run":
|
||||
files = resolve_workflow_run_files(repo, sha)
|
||||
|
||||
elif event_type in ("workflow_dispatch", "schedule"):
|
||||
# Manual or cron-triggered runs aren't tied to a specific diff, so
|
||||
# treat every group as changed. `files = None` makes the loop below
|
||||
|
||||
@@ -18,14 +18,31 @@
|
||||
"""
|
||||
Check that source-code changes don't cause translation regressions.
|
||||
|
||||
What counts as a regression
|
||||
---------------------------
|
||||
A regression is an *existing translation that a source change invalidated* —
|
||||
i.e. a string was renamed/reworded so its committed translation no longer
|
||||
applies. ``babel_update.sh`` (``pybabel update --ignore-obsolete``) surfaces
|
||||
exactly these as **newly fuzzy** entries: the old translation is fuzzy-matched
|
||||
onto the new ``msgid`` and flagged ``#, fuzzy``.
|
||||
|
||||
Crucially, *deleting* a translatable string is **not** a regression. With
|
||||
``--ignore-obsolete`` a removed string is dropped from the catalogs entirely;
|
||||
no fuzzy entry is created. So a PR that intentionally removes a string (e.g. a
|
||||
security fix that stops rendering a value) legitimately lowers the translated
|
||||
count without introducing any fuzzies, and must not be flagged. We therefore
|
||||
key the check on the **increase in fuzzy entries**, not on a drop in the
|
||||
translated count (a drop happens identically for a benign deletion and a real
|
||||
rename, so it cannot distinguish the two).
|
||||
|
||||
Usage
|
||||
-----
|
||||
Count non-fuzzy translated entries in all .po files and write JSON to stdout:
|
||||
Count translated + fuzzy entries in all .po files and write JSON to stdout:
|
||||
|
||||
python check_translation_regression.py --count
|
||||
|
||||
Compare the current .po state against a previously-recorded baseline and fail
|
||||
if any language lost translations:
|
||||
if a source change invalidated existing translations (new fuzzies):
|
||||
|
||||
python check_translation_regression.py --compare /path/to/before.json
|
||||
|
||||
@@ -44,13 +61,14 @@ Typical CI workflow
|
||||
1. Create a base-branch worktree alongside the PR worktree
|
||||
2. Run babel_update.sh in the base worktree (extract from BASE source)
|
||||
3. Record baseline: python ... --count --translations-dir BASE_TREE > before.json
|
||||
4. Run babel_update.sh in the PR worktree (extract from PR source) starting
|
||||
from the same pristine BASE translations
|
||||
4. Run babel_update.sh in the PR worktree (extract from PR source and keep
|
||||
any committed PR .po updates)
|
||||
5. Compare: python ... --compare before.json [--report report.md]
|
||||
|
||||
Comparing two babel_update outputs that started from the same BASE .po files
|
||||
isolates regressions caused by the PR's source diff from any pre-existing
|
||||
drift on the base branch.
|
||||
Running babel_update on the base branch first isolates regressions caused by
|
||||
the PR's source diff from any pre-existing drift on the base branch, while the
|
||||
PR worktree run still allows committed .po updates to resolve the fuzzies (and
|
||||
thus clear the regression) before merging.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -70,8 +88,13 @@ DEFAULT_TRANSLATIONS_DIR = (
|
||||
SKIP_LANGS = {"en"}
|
||||
|
||||
|
||||
def count_translated(po_file: Path) -> int:
|
||||
"""Return the number of non-fuzzy translated messages in a .po file.
|
||||
def count_stats(po_file: Path) -> dict[str, int]:
|
||||
"""Return ``{"translated": int, "fuzzy": int}`` for a .po file.
|
||||
|
||||
``translated`` is the number of non-fuzzy translated messages; ``fuzzy`` is
|
||||
the number of fuzzy translations. The fuzzy count is what the regression
|
||||
check keys on — a source rename invalidates an existing translation by
|
||||
making it fuzzy, whereas a deletion simply drops it (``--ignore-obsolete``).
|
||||
|
||||
Raises:
|
||||
subprocess.CalledProcessError: if ``msgfmt`` fails (e.g. malformed
|
||||
@@ -89,29 +112,50 @@ def count_translated(po_file: Path) -> int:
|
||||
check=True,
|
||||
)
|
||||
# stderr: "123 translated messages, 4 fuzzy translations, 56 untranslated messages."
|
||||
match = re.search(r"(\d+) translated message", result.stderr)
|
||||
if not match:
|
||||
# The fuzzy and untranslated clauses are omitted by msgfmt when they are 0.
|
||||
translated_match = re.search(r"(\d+) translated message", result.stderr)
|
||||
if not translated_match:
|
||||
raise RuntimeError(
|
||||
f"Could not parse msgfmt --statistics output for {po_file}: "
|
||||
f"{result.stderr!r}"
|
||||
)
|
||||
return int(match.group(1))
|
||||
fuzzy_match = re.search(r"(\d+) fuzzy translation", result.stderr)
|
||||
return {
|
||||
"translated": int(translated_match.group(1)),
|
||||
"fuzzy": int(fuzzy_match.group(1)) if fuzzy_match else 0,
|
||||
}
|
||||
|
||||
|
||||
def get_counts(translations_dir: Path) -> dict[str, int]:
|
||||
counts: dict[str, int] = {}
|
||||
def get_counts(
|
||||
translations_dir: Path,
|
||||
failures: Optional[set[str]] = None,
|
||||
) -> dict[str, dict[str, int]]:
|
||||
"""Count translated/fuzzy entries for every ``.po`` file in a directory.
|
||||
|
||||
If ``failures`` is provided, the name of each language whose ``.po`` file
|
||||
is present on disk but could not be counted (msgfmt non-zero exit, or
|
||||
unparseable output) is added to it. Such a language is deliberately absent
|
||||
from the returned mapping — but, unlike a language whose catalog was simply
|
||||
deleted, it must not be mistaken for an intentional removal: a caller that
|
||||
cares about the distinction (see :func:`cmd_compare`) can inspect
|
||||
``failures`` and treat it as a hard error.
|
||||
"""
|
||||
counts: dict[str, dict[str, int]] = {}
|
||||
for po_file in sorted(translations_dir.glob("*/LC_MESSAGES/messages.po")):
|
||||
lang = po_file.parent.parent.name
|
||||
if lang in SKIP_LANGS:
|
||||
continue
|
||||
try:
|
||||
counts[lang] = count_translated(po_file)
|
||||
counts[lang] = count_stats(po_file)
|
||||
except (subprocess.CalledProcessError, RuntimeError) as exc:
|
||||
# A malformed .po file (msgfmt non-zero exit, or stderr we
|
||||
# can't parse) is a real problem worth seeing, but it shouldn't
|
||||
# take the whole regression check down with it — that would
|
||||
# hide every other language's status. Skip and warn instead;
|
||||
# the missing lang will not appear in the comparison output.
|
||||
# hide every other language's status. Skip and warn here; the
|
||||
# caller is told which langs failed via ``failures`` so it can
|
||||
# decide whether a present-but-uncountable catalog is fatal.
|
||||
if failures is not None:
|
||||
failures.add(lang)
|
||||
print(
|
||||
f"WARNING: skipping {lang} — {po_file} could not be counted: {exc}",
|
||||
file=sys.stderr,
|
||||
@@ -119,18 +163,42 @@ def get_counts(translations_dir: Path) -> dict[str, int]:
|
||||
return counts
|
||||
|
||||
|
||||
def _normalize(entry: object) -> dict[str, int]:
|
||||
"""Coerce a baseline entry into ``{"translated", "fuzzy"}``.
|
||||
|
||||
Tolerates the legacy baseline format where each language mapped directly to
|
||||
an integer translated count (no fuzzy data); such entries contribute a
|
||||
fuzzy baseline of 0.
|
||||
"""
|
||||
if isinstance(entry, dict):
|
||||
return {
|
||||
"translated": int(entry.get("translated", 0)),
|
||||
"fuzzy": int(entry.get("fuzzy", 0)),
|
||||
}
|
||||
if isinstance(entry, int):
|
||||
return {"translated": entry, "fuzzy": 0}
|
||||
raise TypeError(f"Unsupported baseline entry: {entry!r}")
|
||||
|
||||
|
||||
def build_regression_report(regressions: list[tuple[str, int, int]]) -> str:
|
||||
"""Build a markdown report for posting as a PR comment."""
|
||||
"""Build a markdown report for posting as a PR comment.
|
||||
|
||||
Each regression tuple is ``(lang, before_fuzzy, after_fuzzy)``.
|
||||
"""
|
||||
rows = "\n".join(
|
||||
f"| `{lang}` | {b} | {a} | -{b - a} |" for lang, b, a in regressions
|
||||
f"| `{lang}` | {b} | {a} | +{a - b} |" for lang, b, a in regressions
|
||||
)
|
||||
affected = ", ".join(f"`{lang}`" for lang, _, _ in regressions)
|
||||
return (
|
||||
"## ⚠️ Translation Regression Detected\n\n"
|
||||
f"This PR causes existing translations to become fuzzy or be removed "
|
||||
f"in {affected}. Please fix the affected `.po` files before merging.\n\n"
|
||||
"| Language | Before | After | Lost |\n"
|
||||
"|----------|-------:|------:|-----:|\n"
|
||||
f"A source change in this PR renamed or reworded strings, invalidating "
|
||||
f"existing translations (they are now `#, fuzzy`) in {affected}. Please "
|
||||
f"resolve the affected `.po` files before merging.\n\n"
|
||||
"_Note: intentionally **deleting** a translatable string is not a "
|
||||
"regression and is not flagged here — only translations invalidated by "
|
||||
"a renamed/reworded source string are._\n\n"
|
||||
"| Language | Fuzzy before | Fuzzy after | New |\n"
|
||||
"|----------|-------------:|------------:|----:|\n"
|
||||
f"{rows}\n\n"
|
||||
"### How to fix\n\n"
|
||||
"**1. Install dependencies** (if not already set up):\n\n"
|
||||
@@ -168,26 +236,49 @@ def cmd_compare(
|
||||
report_path: Optional[str] = None,
|
||||
) -> None:
|
||||
with open(before_path) as f:
|
||||
before: dict[str, int] = json.load(f)
|
||||
before_raw: dict[str, object] = json.load(f)
|
||||
before = {lang: _normalize(entry) for lang, entry in before_raw.items()}
|
||||
|
||||
after = get_counts(translations_dir)
|
||||
failures: set[str] = set()
|
||||
after = get_counts(translations_dir, failures=failures)
|
||||
|
||||
# A baseline language whose catalog is *missing* from `after` is fine —
|
||||
# that's an intentional catalog deletion (handled below like any other
|
||||
# deletion). But a language whose .po file is still present yet could not
|
||||
# be counted (msgfmt failed / output unparseable) is a hard error: leaving
|
||||
# it out silently would let a corrupt catalog pass as "no regression".
|
||||
broken = sorted(lang for lang in failures if lang in before)
|
||||
if broken:
|
||||
print("Translation check failed!\n")
|
||||
for lang in broken:
|
||||
print(f" {lang}: catalog present but could not be counted (msgfmt error)")
|
||||
print(
|
||||
"\nFix the malformed .po file(s) above before merging — a catalog "
|
||||
"that cannot be parsed must not be silently dropped."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# A regression is an *increase* in fuzzy entries: the PR's source diff
|
||||
# renamed/reworded strings, leaving their committed translations stranded.
|
||||
# A plain drop in the translated count is NOT used — deleting a string
|
||||
# lowers it identically to a rename but is a legitimate change, and with
|
||||
# `pybabel update --ignore-obsolete` a deletion creates no fuzzy entry.
|
||||
regressions: list[tuple[str, int, int]] = []
|
||||
for lang, before_count in sorted(before.items()):
|
||||
after_count = after.get(lang, 0)
|
||||
if after_count < before_count:
|
||||
regressions.append((lang, before_count, after_count))
|
||||
for lang, before_stats in sorted(before.items()):
|
||||
after_stats = after.get(lang, {"translated": 0, "fuzzy": 0})
|
||||
if after_stats["fuzzy"] > before_stats["fuzzy"]:
|
||||
regressions.append((lang, before_stats["fuzzy"], after_stats["fuzzy"]))
|
||||
|
||||
if regressions:
|
||||
print("Translation regression detected!\n")
|
||||
for lang, b, a in regressions:
|
||||
lost = b - a
|
||||
print(f" {lang}: {b} -> {a} (-{lost} string(s) became fuzzy or removed)")
|
||||
print(
|
||||
f" {lang}: {a - b} translation(s) invalidated "
|
||||
f"(fuzzy {b} -> {a}) by a renamed/reworded source string"
|
||||
)
|
||||
print(
|
||||
"\nStrings renamed or deleted by this PR invalidated existing translations."
|
||||
)
|
||||
print(
|
||||
"Update the affected .po files to restore the lost entries before merging."
|
||||
"\nResolve the newly-fuzzy entries in the affected .po files "
|
||||
"before merging."
|
||||
)
|
||||
if report_path:
|
||||
Path(report_path).write_text(
|
||||
@@ -198,15 +289,15 @@ def cmd_compare(
|
||||
# All good — print a summary so it's easy to read in CI logs.
|
||||
print("No translation regressions.\n")
|
||||
for lang in sorted(after):
|
||||
b = before.get(lang, 0)
|
||||
a = after[lang]
|
||||
if a > b:
|
||||
delta = f"+{a - b}"
|
||||
elif a == b:
|
||||
delta = "no change"
|
||||
else:
|
||||
delta = f"-{b - a}"
|
||||
print(f" {lang}: {b} -> {a} ({delta})")
|
||||
before_stats = before.get(lang, {"translated": 0, "fuzzy": 0})
|
||||
after_stats = after[lang]
|
||||
t_delta = after_stats["translated"] - before_stats["translated"]
|
||||
f_delta = after_stats["fuzzy"] - before_stats["fuzzy"]
|
||||
print(
|
||||
f" {lang}: translated {before_stats['translated']} -> "
|
||||
f"{after_stats['translated']} ({t_delta:+d}), fuzzy "
|
||||
f"{before_stats['fuzzy']} -> {after_stats['fuzzy']} ({f_delta:+d})"
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
||||
@@ -31,11 +31,32 @@ if [ -z "$RUNNING_IN_DOCKER" ]; then
|
||||
|
||||
echo "Running in Docker (Python ${PYTHON_VERSION} on Linux)..."
|
||||
|
||||
IMAGE="python:${PYTHON_VERSION}-slim"
|
||||
|
||||
# Pre-pull the image with a few retries to absorb transient Docker Hub
|
||||
# registry failures ("context deadline exceeded" / anonymous rate-limit blips
|
||||
# on shared CI runners). Without this a flaky pull fails the whole
|
||||
# check-python-deps job on an infrastructure hiccup rather than a real
|
||||
# dependency drift. The pull is in the `until` condition so `set -e` does not
|
||||
# abort on an individual failed attempt.
|
||||
attempt=1
|
||||
max_attempts=4
|
||||
until docker pull "$IMAGE"; do
|
||||
if [ "$attempt" -ge "$max_attempts" ]; then
|
||||
echo "docker pull $IMAGE failed after ${max_attempts} attempts" >&2
|
||||
exit 1
|
||||
fi
|
||||
delay=$((attempt * 10))
|
||||
echo "docker pull $IMAGE failed (attempt ${attempt}/${max_attempts}); retrying in ${delay}s..." >&2
|
||||
sleep "$delay"
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
docker run --rm \
|
||||
-v "$(pwd)":/app \
|
||||
-w /app \
|
||||
-e RUNNING_IN_DOCKER=1 \
|
||||
python:${PYTHON_VERSION}-slim \
|
||||
"$IMAGE" \
|
||||
bash -c "pip install uv && ./scripts/uv-pip-compile.sh $*"
|
||||
|
||||
exit $?
|
||||
|
||||
@@ -80,7 +80,7 @@ const restrictedImportsRules = {
|
||||
'no-jest-mock-console': {
|
||||
name: 'jest-mock-console',
|
||||
message: 'Please use native Jest spies, i.e. jest.spyOn(console, "warn")',
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
|
||||
1340
superset-frontend/package-lock.json
generated
1340
superset-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -98,6 +98,7 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@apache-superset/core": "file:packages/superset-core",
|
||||
"@braintree/sanitize-url": "^7.1.2",
|
||||
"@deck.gl/aggregation-layers": "~9.2.5",
|
||||
"@deck.gl/core": "~9.2.5",
|
||||
"@deck.gl/extensions": "~9.2.5",
|
||||
@@ -204,7 +205,7 @@
|
||||
"query-string": "9.3.1",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react": "^18.2.0",
|
||||
"react-arborist": "^3.7.0",
|
||||
"react-arborist": "^3.8.0",
|
||||
"react-checkbox-tree": "^1.8.0",
|
||||
"react-diff-viewer-continued": "^4.2.2",
|
||||
"react-dnd": "^11.1.3",
|
||||
@@ -243,22 +244,22 @@
|
||||
"yargs": "^18.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.28.6",
|
||||
"@babel/cli": "^7.29.7",
|
||||
"@babel/compat-data": "^7.28.4",
|
||||
"@babel/core": "^7.29.0",
|
||||
"@babel/eslint-parser": "^7.28.6",
|
||||
"@babel/eslint-parser": "^7.29.7",
|
||||
"@babel/node": "^7.29.0",
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
|
||||
"@babel/plugin-transform-export-namespace-from": "^7.27.1",
|
||||
"@babel/plugin-transform-modules-commonjs": "^7.28.6",
|
||||
"@babel/plugin-transform-runtime": "^7.29.0",
|
||||
"@babel/preset-env": "^7.29.5",
|
||||
"@babel/preset-react": "^7.28.5",
|
||||
"@babel/preset-typescript": "^7.28.5",
|
||||
"@babel/plugin-transform-export-namespace-from": "^7.29.7",
|
||||
"@babel/plugin-transform-modules-commonjs": "^7.29.7",
|
||||
"@babel/plugin-transform-runtime": "^7.29.7",
|
||||
"@babel/preset-env": "^7.29.7",
|
||||
"@babel/preset-react": "^7.29.7",
|
||||
"@babel/preset-typescript": "^7.29.7",
|
||||
"@babel/register": "^7.29.3",
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"@babel/runtime-corejs3": "^7.29.2",
|
||||
"@babel/types": "^7.28.6",
|
||||
"@babel/types": "^7.29.7",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
"@emotion/jest": "^11.14.2",
|
||||
"@istanbuljs/nyc-config-typescript": "^1.0.1",
|
||||
@@ -305,7 +306,7 @@
|
||||
"@types/rison": "0.1.0",
|
||||
"@types/tinycolor2": "^1.4.3",
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.4",
|
||||
"@typescript-eslint/eslint-plugin": "^8.60.0",
|
||||
"@typescript-eslint/parser": "^8.59.4",
|
||||
"babel-jest": "^30.4.1",
|
||||
"babel-loader": "^10.1.1",
|
||||
|
||||
@@ -73,11 +73,11 @@
|
||||
"author": "Apache Software Foundation",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.28.6",
|
||||
"@babel/cli": "^7.29.7",
|
||||
"@babel/core": "^7.29.0",
|
||||
"@babel/preset-env": "^7.29.5",
|
||||
"@babel/preset-react": "^7.28.5",
|
||||
"@babel/preset-typescript": "^7.28.5",
|
||||
"@babel/preset-env": "^7.29.7",
|
||||
"@babel/preset-react": "^7.29.7",
|
||||
"@babel/preset-typescript": "^7.29.7",
|
||||
"typescript": "^5.0.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@types/lodash": "^4.17.24",
|
||||
|
||||
@@ -115,6 +115,21 @@ export const GlobalStyles = () => {
|
||||
display: flex;
|
||||
margin-top: ${theme.marginXS}px;
|
||||
}
|
||||
|
||||
.superset-explore-popover.ant-popover
|
||||
.ant-popover-inner:has(.ant-popover-title) {
|
||||
padding-top: 0;
|
||||
}
|
||||
.superset-explore-popover.ant-popover .ant-popover-title {
|
||||
padding-top: ${theme.paddingXS}px;
|
||||
margin-bottom: ${theme.paddingSM}px;
|
||||
line-height: 1;
|
||||
}
|
||||
.superset-explore-popover.ant-popover
|
||||
.ant-popover-inner:has(.ant-popover-title)
|
||||
.ant-tabs-tab {
|
||||
padding-top: 0;
|
||||
}
|
||||
`}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
} from '@superset-ui/core';
|
||||
import { PostProcessingFactory } from './types';
|
||||
|
||||
const PERCENTILE_REGEX = /(\d+)\/(\d+) percentiles/;
|
||||
const PERCENTILE_REGEX = /(\d{1,3})\/(\d{1,3}) percentiles/;
|
||||
|
||||
export const boxplotOperator: PostProcessingFactory<PostProcessingBoxplot> = (
|
||||
formData,
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.2.3",
|
||||
"@apache-superset/core": "*",
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"@babel/runtime": "^7.29.7",
|
||||
"@braintree/sanitize-url": "^7.1.2",
|
||||
"@types/json-bigint": "^1.0.4",
|
||||
"@visx/responsive": "^3.12.0",
|
||||
"ace-builds": "^1.44.0",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { sanitizeUrl } from '@braintree/sanitize-url';
|
||||
import { FC } from 'react';
|
||||
import { styled, useTheme, css } from '@apache-superset/core/theme';
|
||||
import { Skeleton } from '../Skeleton';
|
||||
@@ -140,7 +141,7 @@ const ThinSkeleton = styled(Skeleton)`
|
||||
const paragraphConfig = { rows: 1, width: 150 };
|
||||
|
||||
const AnchorLink: FC<LinkProps> = ({ to, children }) => (
|
||||
<a href={to}>{children}</a>
|
||||
<a href={to !== undefined ? sanitizeUrl(to) : undefined}>{children}</a>
|
||||
);
|
||||
|
||||
function ListViewCard({
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { sanitizeUrl } from '@braintree/sanitize-url';
|
||||
import callApiAndParseWithTimeout from './callApi/callApiAndParseWithTimeout';
|
||||
import {
|
||||
ClientConfig,
|
||||
@@ -123,7 +124,7 @@ export default class SupersetClientClass {
|
||||
if (endpoint) {
|
||||
await this.ensureAuth();
|
||||
const hiddenForm = document.createElement('form');
|
||||
hiddenForm.action = this.getUrl({ endpoint });
|
||||
hiddenForm.action = sanitizeUrl(this.getUrl({ endpoint }));
|
||||
hiddenForm.method = 'POST';
|
||||
hiddenForm.target = target;
|
||||
const payloadWithToken: Record<string, any> = {
|
||||
|
||||
@@ -33,6 +33,35 @@ describe('sanitizeHtml', () => {
|
||||
const sanitizedString = sanitizeHtml(htmlString);
|
||||
expect(sanitizedString).not.toContain('script');
|
||||
});
|
||||
|
||||
test('should preserve allowed presentational CSS properties', () => {
|
||||
const htmlString =
|
||||
'<div style="color: red; background-color: blue; font-size: 12px; text-align: center">x</div>';
|
||||
const sanitizedString = sanitizeHtml(htmlString);
|
||||
expect(sanitizedString).toContain('color:red');
|
||||
expect(sanitizedString).toContain('background-color:blue');
|
||||
expect(sanitizedString).toContain('font-size:12px');
|
||||
expect(sanitizedString).toContain('text-align:center');
|
||||
});
|
||||
|
||||
test('should strip layout and positioning CSS properties', () => {
|
||||
const htmlString =
|
||||
'<div style="color: red; position: fixed; z-index: 9999; width: 100%; height: 100%">x</div>';
|
||||
const sanitizedString = sanitizeHtml(htmlString);
|
||||
expect(sanitizedString).toContain('color:red');
|
||||
expect(sanitizedString).not.toContain('position');
|
||||
expect(sanitizedString).not.toContain('z-index');
|
||||
expect(sanitizedString).not.toContain('width');
|
||||
expect(sanitizedString).not.toContain('height');
|
||||
});
|
||||
|
||||
test('should strip unsafe CSS property values', () => {
|
||||
const htmlString =
|
||||
'<div style="background-color: url(javascript:alert(1)); color: blue">x</div>';
|
||||
const sanitizedString = sanitizeHtml(htmlString);
|
||||
expect(sanitizedString).not.toContain('javascript');
|
||||
expect(sanitizedString).not.toContain('url(');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isProbablyHTML', () => {
|
||||
|
||||
@@ -19,6 +19,50 @@
|
||||
import { FilterXSS, getDefaultWhiteList } from 'xss';
|
||||
import { DataRecordValue } from '../types';
|
||||
|
||||
// Restrict inline `style` attributes to a small set of presentational CSS
|
||||
// properties. Overlay/positioning properties (e.g. position, z-index, top,
|
||||
// left, transform) and sizing properties that could cover the page (e.g.
|
||||
// width, height) are intentionally excluded so that sanitized markup cannot
|
||||
// escape its container to overlay or obscure the surrounding page. The
|
||||
// allowlisted spacing/border properties (margin, padding, border) can still
|
||||
// affect layout within the container, which is acceptable. The `xss` library
|
||||
// also validates property values against this allowlist, stripping unsupported
|
||||
// constructs such as url()/expression().
|
||||
const allowedCssProperties = {
|
||||
color: true,
|
||||
'background-color': true,
|
||||
'text-align': true,
|
||||
'text-decoration': true,
|
||||
'font-family': true,
|
||||
'font-size': true,
|
||||
'font-style': true,
|
||||
'font-weight': true,
|
||||
'line-height': true,
|
||||
'letter-spacing': true,
|
||||
'white-space': true,
|
||||
padding: true,
|
||||
'padding-top': true,
|
||||
'padding-right': true,
|
||||
'padding-bottom': true,
|
||||
'padding-left': true,
|
||||
margin: true,
|
||||
'margin-top': true,
|
||||
'margin-right': true,
|
||||
'margin-bottom': true,
|
||||
'margin-left': true,
|
||||
border: true,
|
||||
'border-color': true,
|
||||
'border-style': true,
|
||||
'border-width': true,
|
||||
'border-radius': true,
|
||||
'vertical-align': true,
|
||||
// Needed by ECharts tooltips for row transparency and text truncation.
|
||||
opacity: true,
|
||||
'max-width': true,
|
||||
overflow: true,
|
||||
'text-overflow': true,
|
||||
};
|
||||
|
||||
const xssFilter = new FilterXSS({
|
||||
whiteList: {
|
||||
...getDefaultWhiteList(),
|
||||
@@ -45,7 +89,7 @@ const xssFilter = new FilterXSS({
|
||||
tfoot: ['align', 'valign', 'style'],
|
||||
},
|
||||
stripIgnoreTag: true,
|
||||
css: false,
|
||||
css: { whiteList: allowedCssProperties },
|
||||
});
|
||||
|
||||
export function sanitizeHtml(htmlString: string) {
|
||||
|
||||
@@ -161,10 +161,13 @@ test('should preserve table styling after sanitization (fixes ECharts tooltip fo
|
||||
</table>
|
||||
`;
|
||||
|
||||
// The `xss` CSS filter normalizes declarations, dropping the space after
|
||||
// each colon (e.g. `opacity: 0.8;` becomes `opacity:0.8;`) while preserving
|
||||
// the property/value pairs themselves.
|
||||
const sanitized = sanitizeHtml(tableWithStyles);
|
||||
expect(sanitized).toContain('style="opacity: 0.8;"');
|
||||
expect(sanitized).toContain('style="text-align: left; padding-left: 0px;"');
|
||||
expect(sanitized).toContain('style="text-align: right; padding-left: 16px;"');
|
||||
expect(sanitized).toContain('style="opacity:0.8;"');
|
||||
expect(sanitized).toContain('style="text-align:left; padding-left:0px;"');
|
||||
expect(sanitized).toContain('style="text-align:right; padding-left:16px;"');
|
||||
|
||||
const data = [
|
||||
['Metric', 'Value'],
|
||||
@@ -172,10 +175,10 @@ test('should preserve table styling after sanitization (fixes ECharts tooltip fo
|
||||
];
|
||||
const html = tooltipHtml(data, 'Test Tooltip');
|
||||
|
||||
expect(html).toContain('style="opacity: 0.8;"');
|
||||
expect(html).toContain('text-align: left');
|
||||
expect(html).toContain('text-align: right');
|
||||
expect(html).toContain('padding-left: 0px');
|
||||
expect(html).toContain('padding-left: 16px');
|
||||
expect(html).toContain('max-width: 300px');
|
||||
expect(html).toContain('style="opacity:0.8;"');
|
||||
expect(html).toContain('text-align:left');
|
||||
expect(html).toContain('text-align:right');
|
||||
expect(html).toContain('padding-left:0px');
|
||||
expect(html).toContain('padding-left:16px');
|
||||
expect(html).toContain('max-width:300px');
|
||||
});
|
||||
|
||||
@@ -19,7 +19,9 @@
|
||||
|
||||
import { getTimeFormatter } from '@superset-ui/core';
|
||||
|
||||
// Cal-Heatmap provides local timestamps. We subtract the offset so that utcFormat displays the correct local date.
|
||||
// Cal-Heatmap provides local timestamps (UTC shifted by the browser's timezone
|
||||
// offset). We subtract that offset so the formatter displays the correct UTC
|
||||
// date regardless of the browser's timezone.
|
||||
export const getFormattedUTCTime = (
|
||||
ts: number | string,
|
||||
timeFormat?: string,
|
||||
|
||||
@@ -299,18 +299,23 @@ var CalHeatMap = function () {
|
||||
// Takes the fetched "data" object as argument, must return a json object
|
||||
// formatted like {timestamp:count, timestamp2:count2},
|
||||
afterLoadData: function (timestamps) {
|
||||
// See https://github.com/wa0x6e/cal-heatmap/issues/126#issuecomment-373301803
|
||||
const stdTimezoneOffset = date => {
|
||||
const jan = new Date(date.getFullYear(), 0, 1);
|
||||
const jul = new Date(date.getFullYear(), 6, 1);
|
||||
return Math.max(jan.getTimezoneOffset(), jul.getTimezoneOffset());
|
||||
};
|
||||
const offset = stdTimezoneOffset(new Date()) * 60;
|
||||
// Use the DST-aware timezone offset for each individual timestamp so that
|
||||
// every data point is shifted by its own local offset (not a fixed
|
||||
// standard-time offset). This prevents data from landing in phantom hours
|
||||
// during DST transitions and keeps the offset consistent with what
|
||||
// getFormattedUTCTime undoes when formatting the tooltip.
|
||||
//
|
||||
// Around DST transitions two distinct UTC timestamps can shift to the
|
||||
// same adjusted key (e.g. the "spring forward" hour that doesn't exist
|
||||
// locally). Accumulate values on collision so no datapoints are silently
|
||||
// dropped in hourly/minutely views.
|
||||
let results = {};
|
||||
for (let timestamp in timestamps) {
|
||||
const value = timestamps[timestamp];
|
||||
timestamp = parseInt(timestamp, 10);
|
||||
results[timestamp + offset] = value;
|
||||
const ts = parseInt(timestamp, 10);
|
||||
const offset = new Date(ts * 1000).getTimezoneOffset() * 60;
|
||||
const adjustedTs = ts + offset;
|
||||
results[adjustedTs] = (results[adjustedTs] || 0) + value;
|
||||
}
|
||||
return results;
|
||||
},
|
||||
@@ -4005,6 +4010,10 @@ function mergeRecursive(obj1, obj2) {
|
||||
|
||||
/*jshint forin:false */
|
||||
for (var p in obj2) {
|
||||
// Skip keys that could pollute the object prototype.
|
||||
if (p === '__proto__' || p === 'constructor' || p === 'prototype') {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
// Property in destination object set; update its value.
|
||||
if (obj2[p].constructor === Object) {
|
||||
|
||||
@@ -19,78 +19,71 @@
|
||||
|
||||
import { getFormattedUTCTime, convertUTCTimestampToLocal } from '../src/utils';
|
||||
|
||||
describe('getFormattedUTCTime', () => {
|
||||
test('formats local timestamp for display as UTC date', () => {
|
||||
const utcTimestamp = 1420070400000; // 2015-01-01 00:00:00 UTC
|
||||
const localTimestamp = convertUTCTimestampToLocal(utcTimestamp);
|
||||
const formattedTime = getFormattedUTCTime(
|
||||
localTimestamp,
|
||||
'%Y-%m-%d %H:%M:%S',
|
||||
);
|
||||
test('getFormattedUTCTime formats local timestamp for display as UTC date', () => {
|
||||
const utcTimestamp = 1420070400000; // 2015-01-01 00:00:00 UTC
|
||||
const localTimestamp = convertUTCTimestampToLocal(utcTimestamp);
|
||||
// Cal-Heatmap's afterLoadData adjusts timestamps similarly, so
|
||||
// getFormattedUTCTime receives already-adjusted timestamps and
|
||||
// formats them directly. The date component should be correct.
|
||||
const formattedTime = getFormattedUTCTime(localTimestamp, '%Y-%m-%d');
|
||||
|
||||
expect(formattedTime).toEqual('2015-01-01 00:00:00');
|
||||
});
|
||||
expect(formattedTime).toEqual('2015-01-01');
|
||||
});
|
||||
|
||||
describe('convertUTCTimestampToLocal', () => {
|
||||
test('adjusts timestamp so local Date shows UTC date', () => {
|
||||
const utcTimestamp = 1704067200000;
|
||||
const adjustedTimestamp = convertUTCTimestampToLocal(utcTimestamp);
|
||||
const adjustedDate = new Date(adjustedTimestamp);
|
||||
test('convertUTCTimestampToLocal adjusts timestamp so local Date shows UTC date', () => {
|
||||
const utcTimestamp = 1704067200000;
|
||||
const adjustedTimestamp = convertUTCTimestampToLocal(utcTimestamp);
|
||||
const adjustedDate = new Date(adjustedTimestamp);
|
||||
|
||||
expect(adjustedDate.getFullYear()).toEqual(2024);
|
||||
expect(adjustedDate.getMonth()).toEqual(0);
|
||||
expect(adjustedDate.getDate()).toEqual(1);
|
||||
});
|
||||
|
||||
test('handles month boundaries', () => {
|
||||
const utcTimestamp = 1706745600000;
|
||||
const adjustedDate = new Date(convertUTCTimestampToLocal(utcTimestamp));
|
||||
|
||||
expect(adjustedDate.getFullYear()).toEqual(2024);
|
||||
expect(adjustedDate.getMonth()).toEqual(1);
|
||||
expect(adjustedDate.getDate()).toEqual(1);
|
||||
});
|
||||
|
||||
test('handles year boundaries', () => {
|
||||
const utcTimestamp = 1735689600000;
|
||||
const adjustedDate = new Date(convertUTCTimestampToLocal(utcTimestamp));
|
||||
|
||||
expect(adjustedDate.getFullYear()).toEqual(2025);
|
||||
expect(adjustedDate.getMonth()).toEqual(0);
|
||||
expect(adjustedDate.getDate()).toEqual(1);
|
||||
});
|
||||
|
||||
test('adds timezone offset to timestamp', () => {
|
||||
const utcTimestamp = 1704067200000;
|
||||
const adjustedTimestamp = convertUTCTimestampToLocal(utcTimestamp);
|
||||
const expectedOffset =
|
||||
new Date(utcTimestamp).getTimezoneOffset() * 60 * 1000;
|
||||
|
||||
expect(adjustedTimestamp - utcTimestamp).toEqual(expectedOffset);
|
||||
});
|
||||
expect(adjustedDate.getFullYear()).toEqual(2024);
|
||||
expect(adjustedDate.getMonth()).toEqual(0);
|
||||
expect(adjustedDate.getDate()).toEqual(1);
|
||||
});
|
||||
|
||||
describe('integration', () => {
|
||||
test('fixes timezone bug for CalHeatMap', () => {
|
||||
const febFirst2024UTC = 1706745600000;
|
||||
const adjustedDate = new Date(convertUTCTimestampToLocal(febFirst2024UTC));
|
||||
test('convertUTCTimestampToLocal handles month boundaries', () => {
|
||||
const utcTimestamp = 1706745600000;
|
||||
const adjustedDate = new Date(convertUTCTimestampToLocal(utcTimestamp));
|
||||
|
||||
expect(adjustedDate.getMonth()).toEqual(1);
|
||||
expect(adjustedDate.getDate()).toEqual(1);
|
||||
});
|
||||
|
||||
test('both functions work together to display dates correctly', () => {
|
||||
const utcTimestamp = 1704067200000;
|
||||
|
||||
// convertUTCTimestampToLocal adjusts UTC for Cal-Heatmap (which interprets as local)
|
||||
const localTimestamp = convertUTCTimestampToLocal(utcTimestamp);
|
||||
const calHeatmapDate = new Date(localTimestamp);
|
||||
expect(calHeatmapDate.getMonth()).toEqual(0);
|
||||
expect(calHeatmapDate.getDate()).toEqual(1);
|
||||
|
||||
// getFormattedUTCTime receives LOCAL timestamp (from Cal-Heatmap) and formats it
|
||||
const formattedTime = getFormattedUTCTime(localTimestamp, '%Y-%m-%d');
|
||||
expect(formattedTime).toContain('2024-01-01');
|
||||
});
|
||||
expect(adjustedDate.getFullYear()).toEqual(2024);
|
||||
expect(adjustedDate.getMonth()).toEqual(1);
|
||||
expect(adjustedDate.getDate()).toEqual(1);
|
||||
});
|
||||
|
||||
test('convertUTCTimestampToLocal handles year boundaries', () => {
|
||||
const utcTimestamp = 1735689600000;
|
||||
const adjustedDate = new Date(convertUTCTimestampToLocal(utcTimestamp));
|
||||
|
||||
expect(adjustedDate.getFullYear()).toEqual(2025);
|
||||
expect(adjustedDate.getMonth()).toEqual(0);
|
||||
expect(adjustedDate.getDate()).toEqual(1);
|
||||
});
|
||||
|
||||
test('convertUTCTimestampToLocal adds timezone offset to timestamp', () => {
|
||||
const utcTimestamp = 1704067200000;
|
||||
const adjustedTimestamp = convertUTCTimestampToLocal(utcTimestamp);
|
||||
const expectedOffset = new Date(utcTimestamp).getTimezoneOffset() * 60 * 1000;
|
||||
|
||||
expect(adjustedTimestamp - utcTimestamp).toEqual(expectedOffset);
|
||||
});
|
||||
|
||||
test('convertUTCTimestampToLocal fixes timezone bug for CalHeatMap', () => {
|
||||
const febFirst2024UTC = 1706745600000;
|
||||
const adjustedDate = new Date(convertUTCTimestampToLocal(febFirst2024UTC));
|
||||
|
||||
expect(adjustedDate.getMonth()).toEqual(1);
|
||||
expect(adjustedDate.getDate()).toEqual(1);
|
||||
});
|
||||
|
||||
test('convertUTCTimestampToLocal and getFormattedUTCTime work together to display dates correctly', () => {
|
||||
const utcTimestamp = 1704067200000;
|
||||
|
||||
// convertUTCTimestampToLocal adjusts UTC for Cal-Heatmap (which interprets as local)
|
||||
const localTimestamp = convertUTCTimestampToLocal(utcTimestamp);
|
||||
const calHeatmapDate = new Date(localTimestamp);
|
||||
expect(calHeatmapDate.getMonth()).toEqual(0);
|
||||
expect(calHeatmapDate.getDate()).toEqual(1);
|
||||
|
||||
// getFormattedUTCTime receives LOCAL timestamp (from Cal-Heatmap) and formats it
|
||||
const formattedTime = getFormattedUTCTime(localTimestamp, '%Y-%m-%d');
|
||||
expect(formattedTime).toContain('2024-01-01');
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
getNumberFormatter,
|
||||
getTimeFormatter,
|
||||
CategoricalColorNamespace,
|
||||
sanitizeHtml,
|
||||
} from '@superset-ui/core';
|
||||
|
||||
interface PartitionDataNode {
|
||||
@@ -345,7 +346,7 @@ function Icicle(element: HTMLElement, props: IcicleProps): void {
|
||||
t += '</tbody></table>';
|
||||
const [tipX, tipY] = d3.mouse(element);
|
||||
tip
|
||||
.html(t)
|
||||
.html(sanitizeHtml(t))
|
||||
.style('left', `${tipX + 15}px`)
|
||||
.style('top', `${tipY}px`);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
getTimeFormatter,
|
||||
getNumberFormatter,
|
||||
CategoricalColorNamespace,
|
||||
sanitizeHtml,
|
||||
} from '@superset-ui/core';
|
||||
|
||||
interface RoseDataEntry {
|
||||
@@ -146,24 +147,32 @@ function Rose(element: HTMLElement, props: RoseProps): void {
|
||||
function legendData(adatum: RoseData) {
|
||||
return adatum[times[0]].map((v: RoseDataEntry, i: number) => ({
|
||||
disabled: state.disabled[i],
|
||||
// Keep the raw name as `key` so it matches the value used for arc
|
||||
// fills (colorFn is called with d.name on arcs and d.key on the
|
||||
// legend). nvd3-fork's legend renders `key` via .text(), so the
|
||||
// raw value is escaped at the DOM sink.
|
||||
key: v.name,
|
||||
}));
|
||||
}
|
||||
|
||||
function tooltipData(d: ArcDatum, i: number, adatum: RoseData) {
|
||||
const timeIndex = Math.floor(d.arcId / numGroups);
|
||||
// nvd3-fork's nv.models.tooltip renders the `key` strings via .html(),
|
||||
// so any HTML in user-controlled column values would execute. Pass the
|
||||
// keys through sanitizeHtml to strip dangerous markup while preserving
|
||||
// legitimate text content.
|
||||
const series = useRichTooltip
|
||||
? adatum[times[timeIndex]]
|
||||
.filter(v => !state.disabled[v.id % numGroups])
|
||||
.map(v => ({
|
||||
key: v.name,
|
||||
key: sanitizeHtml(v.name),
|
||||
value: v.value,
|
||||
color: colorFn(v.name, sliceId),
|
||||
highlight: v.id === d.arcId,
|
||||
}))
|
||||
: [
|
||||
{
|
||||
key: d.name,
|
||||
key: sanitizeHtml(d.name),
|
||||
value: d.val,
|
||||
color: colorFn(d.name, sliceId),
|
||||
},
|
||||
|
||||
@@ -150,7 +150,8 @@ function WorldMap(element: HTMLElement, props: WorldMapProps): void {
|
||||
fillColor: colorFn(d.name, sliceId),
|
||||
}));
|
||||
} else {
|
||||
const rawExtents = d3Extent(filteredData, d => d.m1);
|
||||
const colorableData = filteredData.filter(d => d.m1 != null);
|
||||
const rawExtents = d3Extent(colorableData, d => d.m1);
|
||||
const extents: [number, number] =
|
||||
rawExtents[0] != null && rawExtents[1] != null
|
||||
? [rawExtents[0], rawExtents[1]]
|
||||
@@ -163,7 +164,7 @@ function WorldMap(element: HTMLElement, props: WorldMapProps): void {
|
||||
processedData = filteredData.map(d => ({
|
||||
...d,
|
||||
radius: radiusScale(Math.sqrt(d.m2)),
|
||||
fillColor: colorFn(d.m1) ?? theme.colorBorder,
|
||||
fillColor: d.m1 != null ? colorFn(d.m1) ?? theme.colorBorder : theme.colorBorder,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -511,7 +511,7 @@ test('assigns fill colors from sequential scheme when colorBy is metric', () =>
|
||||
expect(data.CAN.fillColor).toMatch(/^(#|rgb)/);
|
||||
});
|
||||
|
||||
test('falls back to theme.colorBorder when metric values are null', () => {
|
||||
test('renders countries with null metric as no-data fill when colorBy is metric', () => {
|
||||
WorldMap(container, {
|
||||
...baseProps,
|
||||
colorBy: ColorBy.Metric,
|
||||
@@ -519,17 +519,66 @@ test('falls back to theme.colorBorder when metric values are null', () => {
|
||||
{
|
||||
country: 'USA',
|
||||
name: 'United States',
|
||||
m1: null as unknown as number,
|
||||
m1: 100,
|
||||
m2: 200,
|
||||
code: 'US',
|
||||
latitude: 37.0902,
|
||||
longitude: -95.7129,
|
||||
},
|
||||
{
|
||||
country: 'CAN',
|
||||
name: 'Canada',
|
||||
m1: null as unknown as number,
|
||||
m2: 100,
|
||||
code: 'CA',
|
||||
latitude: 56.1304,
|
||||
longitude: -106.3468,
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
});
|
||||
|
||||
const data = lastDatamapConfig?.data as Record<string, { fillColor: string }>;
|
||||
expect(data.USA.fillColor).toBe('#e0e0e0');
|
||||
const data = lastDatamapConfig?.data as Record<
|
||||
string,
|
||||
WorldMapDataEntry & { fillColor: string }
|
||||
>;
|
||||
expect(data).toHaveProperty('USA');
|
||||
expect(data).toHaveProperty('CAN');
|
||||
expect(data.CAN.fillColor).toBe('#e0e0e0');
|
||||
});
|
||||
|
||||
test('renders countries with zero metric using the color scale when colorBy is metric', () => {
|
||||
WorldMap(container, {
|
||||
...baseProps,
|
||||
colorBy: ColorBy.Metric,
|
||||
data: [
|
||||
{
|
||||
country: 'USA',
|
||||
name: 'United States',
|
||||
m1: 100,
|
||||
m2: 200,
|
||||
code: 'US',
|
||||
latitude: 37.0902,
|
||||
longitude: -95.7129,
|
||||
},
|
||||
{
|
||||
country: 'MEX',
|
||||
name: 'Mexico',
|
||||
m1: 0,
|
||||
m2: 50,
|
||||
code: 'MX',
|
||||
latitude: 23.6345,
|
||||
longitude: -102.5528,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const data = lastDatamapConfig?.data as Record<
|
||||
string,
|
||||
WorldMapDataEntry & { fillColor: string }
|
||||
>;
|
||||
expect(data).toHaveProperty('MEX');
|
||||
expect(data.MEX.fillColor).toMatch(/^(#|rgb)/);
|
||||
expect(data.MEX.fillColor).not.toBe('#e0e0e0');
|
||||
});
|
||||
|
||||
test('does not throw with empty data and metric coloring', () => {
|
||||
|
||||
@@ -152,7 +152,7 @@ export function generateMultiLineTooltipContent(d, xFormatter, yFormatters) {
|
||||
|
||||
d.series.forEach((series, i) => {
|
||||
const yFormatter = yFormatters[i];
|
||||
const key = getFormattedKey(series.key, false);
|
||||
const key = getFormattedKey(series.key, true);
|
||||
tooltip +=
|
||||
"<tr><td class='legend-color-guide'>" +
|
||||
`<div style="background-color: ${series.color};"></div></td>` +
|
||||
@@ -162,7 +162,7 @@ export function generateMultiLineTooltipContent(d, xFormatter, yFormatters) {
|
||||
|
||||
tooltip += '</tbody></table>';
|
||||
|
||||
return tooltip;
|
||||
return dompurify.sanitize(tooltip);
|
||||
}
|
||||
|
||||
export function generateTimePivotTooltip(d, xFormatter, yFormatter) {
|
||||
@@ -223,7 +223,7 @@ export function generateBubbleTooltipContent({
|
||||
s += createHTMLRow(getLabel(sizeField), sizeFormatter(point.size));
|
||||
s += '</table>';
|
||||
|
||||
return s;
|
||||
return dompurify.sanitize(s);
|
||||
}
|
||||
|
||||
// shouldRemove indicates whether the nvtooltips should be removed from the DOM
|
||||
@@ -262,35 +262,42 @@ export function wrapTooltip(chart) {
|
||||
: chart;
|
||||
const tooltipGeneratorFunc = tooltipLayer.tooltip.contentGenerator();
|
||||
tooltipLayer.tooltip.contentGenerator(d => {
|
||||
let tooltip = `<div>`;
|
||||
tooltip += tooltipGeneratorFunc(d);
|
||||
tooltip += '</div>';
|
||||
|
||||
return tooltip;
|
||||
// The nvd3-fork default contentGenerator builds tooltip HTML with
|
||||
// unescaped series keys (and feeds them into the tooltip's `.html()`
|
||||
// sink at render time). Run the final string through DOMPurify so
|
||||
// charts that do NOT install a custom contentGenerator (Line, Bar,
|
||||
// Area, Pie, BoxPlot, etc.) cannot execute stored payloads in
|
||||
// column or series names. Custom contentGenerators set elsewhere in
|
||||
// this module already return sanitized output, making this a
|
||||
// belt-and-braces wrap.
|
||||
const tooltip = `<div>${tooltipGeneratorFunc(d)}</div>`;
|
||||
return dompurify.sanitize(tooltip);
|
||||
});
|
||||
}
|
||||
|
||||
// Builds the sanitized HTML for an annotation layer's tooltip. Title and
|
||||
// description values come from the annotation data source, so the output is
|
||||
// run through dompurify before being inserted into the DOM by d3-tip.
|
||||
export function generateAnnotationTooltipContent(layer, d) {
|
||||
const title =
|
||||
d[layer.titleColumn] && d[layer.titleColumn].length > 0
|
||||
? `${d[layer.titleColumn]} - ${layer.name}`
|
||||
: layer.name;
|
||||
const body = Array.isArray(layer.descriptionColumns)
|
||||
? layer.descriptionColumns.map(c => d[c])
|
||||
: Object.values(d);
|
||||
|
||||
return dompurify.sanitize(
|
||||
`<div><strong>${title}</strong></div><br/><div>${body.join(', ')}</div>`,
|
||||
);
|
||||
}
|
||||
|
||||
export function tipFactory(layer) {
|
||||
return d3tip()
|
||||
.attr('class', `d3-tip ${layer.annotationTipClass || ''}`)
|
||||
.direction('n')
|
||||
.offset([-5, 0])
|
||||
.html(d => {
|
||||
if (!d) {
|
||||
return '';
|
||||
}
|
||||
const title =
|
||||
d[layer.titleColumn] && d[layer.titleColumn].length > 0
|
||||
? `${d[layer.titleColumn]} - ${layer.name}`
|
||||
: layer.name;
|
||||
const body = Array.isArray(layer.descriptionColumns)
|
||||
? layer.descriptionColumns.map(c => d[c])
|
||||
: Object.values(d);
|
||||
|
||||
return `<div><strong>${title}</strong></div><br/><div>${body.join(
|
||||
', ',
|
||||
)}</div>`;
|
||||
});
|
||||
.html(d => (d ? generateAnnotationTooltipContent(layer, d) : ''));
|
||||
}
|
||||
|
||||
export function getMaxLabelSize(svg, axisClass) {
|
||||
|
||||
@@ -24,8 +24,12 @@ import {
|
||||
|
||||
import {
|
||||
computeYDomain,
|
||||
generateAnnotationTooltipContent,
|
||||
generateBubbleTooltipContent,
|
||||
generateMultiLineTooltipContent,
|
||||
getTimeOrNumberFormatter,
|
||||
formatLabel,
|
||||
tipFactory,
|
||||
} from '../src/utils';
|
||||
|
||||
const DATA = [
|
||||
@@ -122,6 +126,42 @@ describe('nvd3/utils', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('generateMultiLineTooltipContent()', () => {
|
||||
const identity = (value: any) => value;
|
||||
|
||||
test('renders the series key in the tooltip markup', () => {
|
||||
const tooltip = generateMultiLineTooltipContent(
|
||||
{
|
||||
value: 'x-value',
|
||||
series: [{ key: 'Region A', color: '#fff', value: 1 }],
|
||||
},
|
||||
identity,
|
||||
[identity],
|
||||
);
|
||||
expect(tooltip).toContain('Region A');
|
||||
});
|
||||
|
||||
test('strips a script payload from a malicious series key', () => {
|
||||
const tooltip = generateMultiLineTooltipContent(
|
||||
{
|
||||
value: 'x-value',
|
||||
series: [
|
||||
{
|
||||
key: '<img src=x onerror="alert(1)">',
|
||||
color: '#fff',
|
||||
value: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
identity,
|
||||
[identity],
|
||||
);
|
||||
// DOMPurify removes the event handler that would execute on render.
|
||||
expect(tooltip).not.toContain('onerror');
|
||||
expect(tooltip).not.toContain('alert(1)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTimeOrNumberFormatter(format)', () => {
|
||||
test('is a function', () => {
|
||||
expect(typeof getTimeOrNumberFormatter).toBe('function');
|
||||
@@ -181,4 +221,138 @@ describe('nvd3/utils', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Tooltip HTML sanitisation (XSS regression).
|
||||
// Each helper below feeds user-controlled column values into a
|
||||
// d3 / nvd3 .html() sink; the sanitised return must strip dangerous
|
||||
// markup so a stored payload cannot execute on hover.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
describe('generateBubbleTooltipContent() sanitises user input', () => {
|
||||
test('strips <script> from the entity column', () => {
|
||||
const html = generateBubbleTooltipContent({
|
||||
point: {
|
||||
color: 'red',
|
||||
group: 'g',
|
||||
entity: '<script>alert(1)</script>',
|
||||
x: 1,
|
||||
y: 2,
|
||||
size: 3,
|
||||
},
|
||||
entity: 'entity',
|
||||
xField: 'x',
|
||||
yField: 'y',
|
||||
sizeField: 'size',
|
||||
xFormatter: (v: number) => String(v),
|
||||
yFormatter: (v: number) => String(v),
|
||||
sizeFormatter: (v: number) => String(v),
|
||||
});
|
||||
expect(html).not.toMatch(/<script/i);
|
||||
expect(html).not.toMatch(/onerror=/i);
|
||||
});
|
||||
|
||||
test('strips <img onerror> injected via the group column', () => {
|
||||
const html = generateBubbleTooltipContent({
|
||||
point: {
|
||||
color: 'red',
|
||||
group: '<img src=x onerror=alert(1)>',
|
||||
entity: 'safe',
|
||||
x: 1,
|
||||
y: 2,
|
||||
size: 3,
|
||||
},
|
||||
entity: 'entity',
|
||||
xField: 'x',
|
||||
yField: 'y',
|
||||
sizeField: 'size',
|
||||
xFormatter: (v: number) => String(v),
|
||||
yFormatter: (v: number) => String(v),
|
||||
sizeFormatter: (v: number) => String(v),
|
||||
});
|
||||
expect(html).not.toMatch(/onerror/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateMultiLineTooltipContent() sanitises user input', () => {
|
||||
test('strips <script> from a series key', () => {
|
||||
const html = generateMultiLineTooltipContent(
|
||||
{
|
||||
value: 0,
|
||||
series: [
|
||||
{
|
||||
key: '<script>alert(1)</script>',
|
||||
color: 'red',
|
||||
value: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
(v: number) => String(v),
|
||||
[(v: number) => String(v)],
|
||||
);
|
||||
expect(html).not.toMatch(/<script/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tipFactory() sanitises annotation columns', () => {
|
||||
test('strips <script> from a description column value', () => {
|
||||
const tip = tipFactory({
|
||||
annotationTipClass: 'foo',
|
||||
titleColumn: 'title',
|
||||
descriptionColumns: ['desc'],
|
||||
name: 'layer',
|
||||
});
|
||||
// d3-tip's .html(fn) stores the callback as the renderer; invoke
|
||||
// it directly to assert the sanitised output.
|
||||
const datum = {
|
||||
title: 'normal',
|
||||
desc: '<script>alert(1)</script>payload',
|
||||
};
|
||||
const html = tip.html()(datum);
|
||||
expect(html).not.toMatch(/<script/i);
|
||||
expect(html).toContain('payload');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateAnnotationTooltipContent()', () => {
|
||||
const layer = {
|
||||
name: 'My annotations',
|
||||
titleColumn: 'title',
|
||||
descriptionColumns: ['description'],
|
||||
};
|
||||
|
||||
test('renders the annotation title and description', () => {
|
||||
const html = generateAnnotationTooltipContent(layer, {
|
||||
title: 'Release',
|
||||
description: 'Shipped v1',
|
||||
});
|
||||
expect(html).toContain('Release - My annotations');
|
||||
expect(html).toContain('Shipped v1');
|
||||
});
|
||||
|
||||
test('falls back to the layer name when the title column is empty', () => {
|
||||
const html = generateAnnotationTooltipContent(layer, {
|
||||
title: '',
|
||||
description: 'Shipped v1',
|
||||
});
|
||||
expect(html).toContain('My annotations');
|
||||
});
|
||||
|
||||
test('strips an event-handler payload from the title column', () => {
|
||||
const html = generateAnnotationTooltipContent(layer, {
|
||||
title: '<img src=x onerror="alert(1)">',
|
||||
description: 'ok',
|
||||
});
|
||||
expect(html).not.toContain('onerror');
|
||||
expect(html).not.toContain('alert(1)');
|
||||
});
|
||||
|
||||
test('strips a script payload from a description column', () => {
|
||||
const html = generateAnnotationTooltipContent(layer, {
|
||||
title: 'Release',
|
||||
description: '<script>alert(document.cookie)</script>',
|
||||
});
|
||||
expect(html).not.toContain('<script>');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -86,6 +86,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
onChartStateChange,
|
||||
chartState,
|
||||
metricSqlExpressions,
|
||||
showNumberedColumn,
|
||||
} = props;
|
||||
|
||||
const [searchOptions, setSearchOptions] = useState<SearchOption[]>([]);
|
||||
@@ -230,6 +231,9 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
: (columns as InputColumn[]),
|
||||
data,
|
||||
serverPagination,
|
||||
serverPaginationData,
|
||||
serverPageLength,
|
||||
showNumberedColumn: showNumberedColumn && !emitCrossFilters,
|
||||
isRawRecords,
|
||||
defaultAlignPN: alignPositiveNegative,
|
||||
showCellBars,
|
||||
|
||||
@@ -42,3 +42,5 @@ export const FILTER_CONDITION_BODY_INDEX = {
|
||||
FIRST: 0,
|
||||
SECOND: 1,
|
||||
} as const;
|
||||
|
||||
export const ROW_NUMBER_COL_ID = '__row_number__';
|
||||
|
||||
@@ -281,11 +281,7 @@ const config: ControlPanelConfig = {
|
||||
{ controls, datasource, form_data }: ControlPanelState,
|
||||
controlState: ControlState,
|
||||
) => ({
|
||||
columns: datasource?.columns[0]?.hasOwnProperty('filterable')
|
||||
? (datasource as Dataset)?.columns?.filter(
|
||||
(c: ColumnMeta) => c.filterable,
|
||||
)
|
||||
: datasource?.columns,
|
||||
columns: datasource?.columns || [],
|
||||
savedMetrics: defineSavedMetrics(datasource),
|
||||
// current active adhoc metrics
|
||||
selectedMetrics:
|
||||
@@ -500,6 +496,18 @@ const config: ControlPanelConfig = {
|
||||
label: t('Visual formatting'),
|
||||
expanded: true,
|
||||
controlSetRows: [
|
||||
[
|
||||
{
|
||||
name: 'show_numbered_column',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Add numbered column'),
|
||||
renderTrigger: true,
|
||||
default: false,
|
||||
description: t('Whether to display the numbered column'),
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'column_config',
|
||||
|
||||
@@ -216,7 +216,13 @@ function convertFilterToSQL(
|
||||
if (conditions.length === 0) return null;
|
||||
if (conditions.length === 1) return conditions[0];
|
||||
|
||||
return `(${conditions.join(` ${filter.operator} `)})`;
|
||||
// The join operator is interpolated into raw SQL, so only allow the two
|
||||
// boolean connectors AG Grid produces.
|
||||
const joinOperator = String(filter.operator).toUpperCase();
|
||||
if (joinOperator !== 'AND' && joinOperator !== 'OR') {
|
||||
return null;
|
||||
}
|
||||
return `(${conditions.join(` ${joinOperator} `)})`;
|
||||
}
|
||||
|
||||
// Handle blank/notBlank operators for all filter types
|
||||
@@ -263,20 +269,26 @@ function convertFilterToSQL(
|
||||
filter.filter !== undefined &&
|
||||
filter.type
|
||||
) {
|
||||
// Number values are interpolated into the clause without quoting, so they
|
||||
// must be finite numbers. Coerce and skip the filter if it is not numeric.
|
||||
const numericValue = Number(filter.filter);
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
return null;
|
||||
}
|
||||
// Map number filter types to SQL operators
|
||||
switch (filter.type) {
|
||||
case FILTER_OPERATORS.EQUALS:
|
||||
return `${colId} ${SQL_OPERATORS.EQUALS} ${filter.filter}`;
|
||||
return `${colId} ${SQL_OPERATORS.EQUALS} ${numericValue}`;
|
||||
case FILTER_OPERATORS.NOT_EQUAL:
|
||||
return `${colId} ${SQL_OPERATORS.NOT_EQUALS} ${filter.filter}`;
|
||||
return `${colId} ${SQL_OPERATORS.NOT_EQUALS} ${numericValue}`;
|
||||
case FILTER_OPERATORS.LESS_THAN:
|
||||
return `${colId} ${SQL_OPERATORS.LESS_THAN} ${filter.filter}`;
|
||||
return `${colId} ${SQL_OPERATORS.LESS_THAN} ${numericValue}`;
|
||||
case FILTER_OPERATORS.LESS_THAN_OR_EQUAL:
|
||||
return `${colId} ${SQL_OPERATORS.LESS_THAN_OR_EQUAL} ${filter.filter}`;
|
||||
return `${colId} ${SQL_OPERATORS.LESS_THAN_OR_EQUAL} ${numericValue}`;
|
||||
case FILTER_OPERATORS.GREATER_THAN:
|
||||
return `${colId} ${SQL_OPERATORS.GREATER_THAN} ${filter.filter}`;
|
||||
return `${colId} ${SQL_OPERATORS.GREATER_THAN} ${numericValue}`;
|
||||
case FILTER_OPERATORS.GREATER_THAN_OR_EQUAL:
|
||||
return `${colId} ${SQL_OPERATORS.GREATER_THAN_OR_EQUAL} ${filter.filter}`;
|
||||
return `${colId} ${SQL_OPERATORS.GREATER_THAN_OR_EQUAL} ${numericValue}`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -314,7 +326,9 @@ export function convertFilterModel(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const sqlClauses: Record<string, string> = {};
|
||||
// Null-prototype object: column ids come from the (user-influenced) filter
|
||||
// model and are used as keys here, so avoid prototype-chain keys.
|
||||
const sqlClauses: Record<string, string> = Object.create(null);
|
||||
|
||||
Object.entries(filterModel).forEach(([colId, filter]) => {
|
||||
const sqlClause = convertFilterToSQL(colId, filter);
|
||||
|
||||
@@ -419,5 +419,11 @@ export const StyledChartContainer = styled.div<{
|
||||
color: ${theme.colorTextQuaternary};
|
||||
}
|
||||
}
|
||||
|
||||
.ag-header-center {
|
||||
.ag-header-cell-label {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
@@ -507,6 +507,7 @@ const transformProps = (
|
||||
conditional_formatting: conditionalFormatting,
|
||||
comparison_color_enabled: comparisonColorEnabled = false,
|
||||
comparison_color_scheme: comparisonColorScheme = ColorSchemeEnum.Green,
|
||||
show_numbered_column: showNumberedColumn = false,
|
||||
} = formData;
|
||||
|
||||
const allowRearrangeColumns = true;
|
||||
@@ -785,6 +786,7 @@ const transformProps = (
|
||||
metricSqlExpressions,
|
||||
chartState,
|
||||
onChartStateChange,
|
||||
showNumberedColumn,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@ export type TableChartFormData = QueryFormData & {
|
||||
time_grain_sqla?: TimeGranularity;
|
||||
column_config?: Record<string, TableColumnConfig>;
|
||||
allow_rearrange_columns?: boolean;
|
||||
show_numbered_column?: boolean;
|
||||
};
|
||||
|
||||
export interface TableChartProps extends ChartProps {
|
||||
@@ -131,6 +132,7 @@ export interface AgGridTableChartTransformedProps<
|
||||
metricSqlExpressions: Record<string, string>;
|
||||
onChartStateChange?: (chartState: JsonObject) => void;
|
||||
chartState?: AgGridChartState;
|
||||
showNumberedColumn: boolean;
|
||||
}
|
||||
|
||||
export interface SortState {
|
||||
|
||||
@@ -17,9 +17,10 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { ColDef } from '@superset-ui/core/components/ThemedAgGridReact';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { DataRecord, DataRecordValue } from '@superset-ui/core';
|
||||
import { DataRecord, DataRecordValue, JsonObject } from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import { useTheme } from '@apache-superset/core/theme';
|
||||
import { ColorFormatters } from '@superset-ui/chart-controls';
|
||||
@@ -41,7 +42,7 @@ import { getAggFunc } from './getAggFunc';
|
||||
import { TextCellRenderer } from '../renderers/TextCellRenderer';
|
||||
import { NumericCellRenderer } from '../renderers/NumericCellRenderer';
|
||||
import CustomHeader from '../AgGridTable/components/CustomHeader';
|
||||
import { NOOP_FILTER_COMPARATOR } from '../consts';
|
||||
import { NOOP_FILTER_COMPARATOR, ROW_NUMBER_COL_ID } from '../consts';
|
||||
import { valueFormatter, valueGetter } from './formatValue';
|
||||
import getCellStyle from './getCellStyle';
|
||||
|
||||
@@ -53,6 +54,9 @@ type UseColDefsProps = {
|
||||
columns: InputColumn[];
|
||||
data: InputData[];
|
||||
serverPagination: boolean;
|
||||
serverPaginationData: JsonObject;
|
||||
serverPageLength: number;
|
||||
showNumberedColumn: boolean;
|
||||
isRawRecords: boolean;
|
||||
defaultAlignPN: boolean;
|
||||
showCellBars: boolean;
|
||||
@@ -216,6 +220,9 @@ export const useColDefs = ({
|
||||
columns,
|
||||
data,
|
||||
serverPagination,
|
||||
serverPaginationData,
|
||||
serverPageLength,
|
||||
showNumberedColumn,
|
||||
isRawRecords,
|
||||
defaultAlignPN,
|
||||
showCellBars,
|
||||
@@ -459,5 +466,61 @@ export const useColDefs = ({
|
||||
}, []);
|
||||
}, [stringifiedCols, getCommonColProps]);
|
||||
|
||||
return colDefs;
|
||||
const rawPageSize = serverPaginationData?.pageSize ?? serverPageLength;
|
||||
const pageSize = rawPageSize && rawPageSize > 0 ? rawPageSize : data.length;
|
||||
const currentPage = serverPaginationData?.currentPage ?? 0;
|
||||
const maxVisibleRowNumber = serverPagination
|
||||
? currentPage * pageSize + data.length
|
||||
: data.length;
|
||||
const rowIndexLength = `${Math.max(maxVisibleRowNumber, 1)}`.length;
|
||||
const rowNumberCol = useMemo<ColDef>(
|
||||
() => ({
|
||||
headerName: t('№'),
|
||||
headerClass: 'ag-header-center',
|
||||
field: ROW_NUMBER_COL_ID,
|
||||
valueGetter: params => {
|
||||
if (params.node?.rowPinned != null) return '';
|
||||
if (serverPagination && serverPaginationData) {
|
||||
return currentPage * pageSize + (params.node?.rowIndex ?? 0) + 1;
|
||||
}
|
||||
return (params.node?.rowIndex ?? 0) + 1;
|
||||
},
|
||||
headerStyle: {
|
||||
backgroundColor: theme.colorFillTertiary,
|
||||
fontSize: '1em',
|
||||
color: theme.colorTextTertiary,
|
||||
},
|
||||
width: 30 + rowIndexLength * 6,
|
||||
minWidth: 30 + rowIndexLength * 6,
|
||||
sortable: false,
|
||||
filter: false,
|
||||
pinned: 'left' as const,
|
||||
lockPosition: true,
|
||||
suppressNavigable: true,
|
||||
resizable: false,
|
||||
suppressMovable: true,
|
||||
suppressSizeToFit: true,
|
||||
cellStyle: {
|
||||
backgroundColor: theme.colorFillTertiary,
|
||||
padding: '0',
|
||||
textAlign: 'center',
|
||||
fontSize: '0.9em',
|
||||
color: theme.colorTextTertiary,
|
||||
},
|
||||
}),
|
||||
[
|
||||
currentPage,
|
||||
pageSize,
|
||||
rowIndexLength,
|
||||
serverPagination,
|
||||
serverPaginationData,
|
||||
theme.colorFillTertiary,
|
||||
theme.colorTextTertiary,
|
||||
],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => (showNumberedColumn ? [rowNumberCol, ...colDefs] : colDefs),
|
||||
[showNumberedColumn, rowNumberCol, colDefs],
|
||||
);
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import { QueryFormData } from '@superset-ui/core';
|
||||
import {
|
||||
ColumnMeta,
|
||||
Dataset,
|
||||
isCustomControlItem,
|
||||
ControlConfig,
|
||||
@@ -45,6 +46,32 @@ const findConditionalFormattingControl = (): ControlConfig | null => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const findMetricsMapStateToProps = ():
|
||||
| ControlConfig['mapStateToProps']
|
||||
| null => {
|
||||
for (const section of config.controlPanelSections) {
|
||||
if (!section) continue;
|
||||
for (const row of section.controlSetRows) {
|
||||
for (const control of row) {
|
||||
if (
|
||||
control &&
|
||||
typeof control === 'object' &&
|
||||
'name' in control &&
|
||||
(control as { name: string }).name === 'metrics' &&
|
||||
'override' in control
|
||||
) {
|
||||
return (
|
||||
control as {
|
||||
override: { mapStateToProps: ControlConfig['mapStateToProps'] };
|
||||
}
|
||||
).override.mapStateToProps;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const createMockControlState = (value: string[] | undefined): ControlState => ({
|
||||
type: 'SelectControl',
|
||||
value,
|
||||
@@ -206,3 +233,47 @@ test('static extraColorChoices removed from config', () => {
|
||||
|
||||
expect(controlConfig?.extraColorChoices).toBeUndefined();
|
||||
});
|
||||
|
||||
const createMockExploreWithColumns = (
|
||||
columns: Partial<ColumnMeta>[],
|
||||
): ControlPanelState => ({
|
||||
slice: { slice_id: 123 },
|
||||
datasource: {
|
||||
verbose_map: {},
|
||||
columns,
|
||||
metrics: [],
|
||||
} as Partial<Dataset> as Dataset,
|
||||
controls: {},
|
||||
form_data: {
|
||||
datasource: 'test',
|
||||
viz_type: 'table',
|
||||
} as QueryFormData,
|
||||
common: {},
|
||||
metadata: {},
|
||||
});
|
||||
|
||||
const createMockMetricsControlState = (): ControlState => ({
|
||||
type: 'MetricsControl',
|
||||
value: [],
|
||||
label: '',
|
||||
default: undefined,
|
||||
renderTrigger: false,
|
||||
});
|
||||
|
||||
test('metrics control includes non-filterable columns', () => {
|
||||
const mapStateToProps = findMetricsMapStateToProps();
|
||||
expect(mapStateToProps).toBeTruthy();
|
||||
|
||||
const explore = createMockExploreWithColumns([
|
||||
{ column_name: 'filterable_col', filterable: true },
|
||||
{ column_name: 'non_filterable_col', filterable: false },
|
||||
]);
|
||||
const result = mapStateToProps!(explore, createMockMetricsControlState());
|
||||
|
||||
expect(result.columns).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ column_name: 'filterable_col' }),
|
||||
expect.objectContaining({ column_name: 'non_filterable_col' }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 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 { convertFilterModel } from '../src/stateConversion';
|
||||
|
||||
describe('convertFilterModel', () => {
|
||||
test('emits a clause for a valid numeric comparison filter', () => {
|
||||
const result = convertFilterModel({
|
||||
revenue: { filterType: 'number', type: 'greaterThan', filter: 100 },
|
||||
} as any);
|
||||
|
||||
expect(result?.sqlClauses?.revenue).toBe('revenue > 100');
|
||||
});
|
||||
|
||||
test('drops a number filter whose value is not numeric', () => {
|
||||
const result = convertFilterModel({
|
||||
revenue: { filterType: 'number', type: 'equals', filter: '1 OR 1=1' },
|
||||
} as any);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
test('joins conditions with a valid boolean operator', () => {
|
||||
const result = convertFilterModel({
|
||||
revenue: {
|
||||
filterType: 'number',
|
||||
operator: 'AND',
|
||||
condition1: { filterType: 'number', type: 'greaterThan', filter: 1 },
|
||||
condition2: { filterType: 'number', type: 'lessThan', filter: 9 },
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(result?.sqlClauses?.revenue).toBe('(revenue > 1 AND revenue < 9)');
|
||||
});
|
||||
|
||||
test('drops a compound filter whose join operator is not AND/OR', () => {
|
||||
const result = convertFilterModel({
|
||||
revenue: {
|
||||
filterType: 'number',
|
||||
operator: 'AND 1=1) OR (1=1',
|
||||
condition1: { filterType: 'number', type: 'greaterThan', filter: 1 },
|
||||
condition2: { filterType: 'number', type: 'lessThan', filter: 9 },
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
test('stores clauses on a null-prototype map (prototype-safe column ids)', () => {
|
||||
const result = convertFilterModel({
|
||||
constructor: { filterType: 'number', type: 'equals', filter: 5 },
|
||||
} as any);
|
||||
|
||||
// The map has no prototype, so a column id like "constructor" is just data.
|
||||
expect(Object.getPrototypeOf(result?.sqlClauses)).toBeNull();
|
||||
expect(result?.sqlClauses?.constructor).toBe('constructor = 5');
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ import tinycolor from 'tinycolor2';
|
||||
import { createElement, type ComponentProps, ReactNode } from 'react';
|
||||
import { useColDefs } from '../../src/utils/useColDefs';
|
||||
import { InputColumn } from '../../src/types';
|
||||
import { ROW_NUMBER_COL_ID } from '../../src/consts';
|
||||
|
||||
type TestCellStyleFunc = (params: unknown) => unknown;
|
||||
|
||||
@@ -122,6 +123,9 @@ const defaultThemeWrapper = makeThemeWrapper(supersetTheme);
|
||||
const defaultProps = {
|
||||
data: [{ test_col: 'value' }],
|
||||
serverPagination: false,
|
||||
serverPaginationData: {},
|
||||
serverPageLength: 0,
|
||||
showNumberedColumn: false,
|
||||
isRawRecords: true,
|
||||
defaultAlignPN: false,
|
||||
showCellBars: false,
|
||||
@@ -136,6 +140,12 @@ const defaultProps = {
|
||||
slice_id: 1,
|
||||
};
|
||||
|
||||
const basePropsNumericColumns = {
|
||||
...defaultProps,
|
||||
columns: [makeColumn({ key: 'a', label: 'A' })],
|
||||
data: [{ a: 1 }, { a: 2 }, { a: 3 }],
|
||||
};
|
||||
|
||||
test('boolean columns use agCheckboxCellRenderer', () => {
|
||||
const booleanCol = makeColumn({
|
||||
key: 'is_active',
|
||||
@@ -828,3 +838,233 @@ test('cellStyle respects explicit horizontal alignment overrides', () => {
|
||||
textAlign: 'center',
|
||||
});
|
||||
});
|
||||
|
||||
test('is not added when showNumberedColumn is false', () => {
|
||||
const { result } = renderHook(
|
||||
() => useColDefs({ ...basePropsNumericColumns, showNumberedColumn: false }),
|
||||
{ wrapper: defaultThemeWrapper },
|
||||
);
|
||||
expect(result.current.some(col => col.field === ROW_NUMBER_COL_ID)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(result.current.length).toBe(1);
|
||||
});
|
||||
|
||||
test('is added as first column when showNumberedColumn is true', () => {
|
||||
const { result } = renderHook(
|
||||
() => useColDefs({ ...basePropsNumericColumns, showNumberedColumn: true }),
|
||||
{ wrapper: defaultThemeWrapper },
|
||||
);
|
||||
expect(result.current[0].field).toBe(ROW_NUMBER_COL_ID);
|
||||
expect(result.current[0].headerName).toBe('№');
|
||||
expect(result.current.length).toBe(2);
|
||||
});
|
||||
|
||||
test('width defaults to 36 when maxVisibleRowNumber is 0 (empty data)', () => {
|
||||
const emptyProps = {
|
||||
...basePropsNumericColumns,
|
||||
data: [],
|
||||
showNumberedColumn: true,
|
||||
};
|
||||
const { result } = renderHook(() => useColDefs(emptyProps), {
|
||||
wrapper: defaultThemeWrapper,
|
||||
});
|
||||
expect(result.current[0].width).toBe(36);
|
||||
});
|
||||
|
||||
test('width uses server pagination row count when available', () => {
|
||||
const serverProps = {
|
||||
...basePropsNumericColumns,
|
||||
serverPagination: true,
|
||||
serverPaginationData: { currentPage: 0, pageSize: 5 },
|
||||
data: [{ a: 1 }, { a: 2 }],
|
||||
showNumberedColumn: true,
|
||||
};
|
||||
const { result } = renderHook(() => useColDefs(serverProps), {
|
||||
wrapper: defaultThemeWrapper,
|
||||
});
|
||||
|
||||
expect(result.current[0].width).toBe(36);
|
||||
|
||||
const laterPageProps = {
|
||||
...serverProps,
|
||||
serverPaginationData: { currentPage: 3, pageSize: 5 },
|
||||
data: [{ a: 21 }, { a: 22 }],
|
||||
};
|
||||
const { result: resultLater } = renderHook(() => useColDefs(laterPageProps), {
|
||||
wrapper: defaultThemeWrapper,
|
||||
});
|
||||
expect(resultLater.current[0].width).toBe(42);
|
||||
});
|
||||
|
||||
test('valueGetter returns row numbers without server pagination', () => {
|
||||
const { result } = renderHook(
|
||||
() => useColDefs({ ...basePropsNumericColumns, showNumberedColumn: true }),
|
||||
{ wrapper: defaultThemeWrapper },
|
||||
);
|
||||
const colDef = result.current[0];
|
||||
const { valueGetter } = colDef;
|
||||
expect(valueGetter).toBeDefined();
|
||||
expect(typeof valueGetter).toBe('function');
|
||||
|
||||
const getter = valueGetter as (params: {
|
||||
node?: { rowIndex?: number };
|
||||
data?: Record<string, unknown>;
|
||||
}) => number;
|
||||
|
||||
const params = (rowIndex: number) => ({
|
||||
node: { rowIndex },
|
||||
data: basePropsNumericColumns.data[rowIndex],
|
||||
});
|
||||
|
||||
expect(getter(params(0))).toBe(1);
|
||||
expect(getter(params(1))).toBe(2);
|
||||
expect(getter(params(2))).toBe(3);
|
||||
});
|
||||
|
||||
test('valueGetter respects server pagination', () => {
|
||||
const serverProps = {
|
||||
...basePropsNumericColumns,
|
||||
serverPagination: true,
|
||||
serverPaginationData: { currentPage: 2, pageSize: 5 },
|
||||
data: [{ a: 11 }, { a: 12 }],
|
||||
};
|
||||
const { result } = renderHook(
|
||||
() => useColDefs({ ...serverProps, showNumberedColumn: true }),
|
||||
{ wrapper: defaultThemeWrapper },
|
||||
);
|
||||
const { valueGetter } = result.current[0];
|
||||
expect(typeof valueGetter).toBe('function');
|
||||
|
||||
const getter = valueGetter as (params: {
|
||||
node?: { rowIndex?: number };
|
||||
data?: Record<string, unknown>;
|
||||
}) => number;
|
||||
|
||||
expect(getter({ node: { rowIndex: 0 } })).toBe(11);
|
||||
expect(getter({ node: { rowIndex: 1 } })).toBe(12);
|
||||
});
|
||||
|
||||
test('has correct static column properties', () => {
|
||||
const { result } = renderHook(
|
||||
() => useColDefs({ ...basePropsNumericColumns, showNumberedColumn: true }),
|
||||
{ wrapper: defaultThemeWrapper },
|
||||
);
|
||||
const colDef = result.current[0];
|
||||
expect(colDef.sortable).toBe(false);
|
||||
expect(colDef.filter).toBe(false);
|
||||
expect(colDef.pinned).toBe('left');
|
||||
expect(colDef.lockPosition).toBe(true);
|
||||
expect(colDef.suppressNavigable).toBe(true);
|
||||
expect(colDef.suppressSizeToFit).toBe(true);
|
||||
expect(colDef.resizable).toBe(false);
|
||||
expect(colDef.suppressMovable).toBe(true);
|
||||
expect(colDef.headerStyle).toBeDefined();
|
||||
expect(colDef.cellStyle).toBeDefined();
|
||||
});
|
||||
|
||||
test('pageSize priority chain works correctly', () => {
|
||||
const props1 = {
|
||||
...basePropsNumericColumns,
|
||||
serverPagination: true,
|
||||
serverPaginationData: { currentPage: 2, pageSize: 10 },
|
||||
serverPageLength: 5,
|
||||
data: Array.from({ length: 3 }, () => ({ a: 1 })),
|
||||
showNumberedColumn: true,
|
||||
};
|
||||
const { result: res1 } = renderHook(() => useColDefs(props1), {
|
||||
wrapper: defaultThemeWrapper,
|
||||
});
|
||||
|
||||
expect(res1.current[0].width).toBe(42);
|
||||
|
||||
const props2 = {
|
||||
...props1,
|
||||
serverPaginationData: { currentPage: 2 },
|
||||
serverPageLength: 20,
|
||||
};
|
||||
const { result: res2 } = renderHook(() => useColDefs(props2), {
|
||||
wrapper: defaultThemeWrapper,
|
||||
});
|
||||
|
||||
expect(res2.current[0].width).toBe(42);
|
||||
});
|
||||
|
||||
test('column width adapts to max row number length in client mode', () => {
|
||||
const base = {
|
||||
...basePropsNumericColumns,
|
||||
showNumberedColumn: true,
|
||||
serverPagination: false,
|
||||
};
|
||||
|
||||
const props9 = { ...base, data: Array.from({ length: 9 }, () => ({ a: 1 })) };
|
||||
const { result: res9 } = renderHook(() => useColDefs(props9), {
|
||||
wrapper: defaultThemeWrapper,
|
||||
});
|
||||
expect(res9.current[0].width).toBe(36);
|
||||
|
||||
const props10 = {
|
||||
...base,
|
||||
data: Array.from({ length: 10 }, () => ({ a: 1 })),
|
||||
};
|
||||
const { result: res10 } = renderHook(() => useColDefs(props10), {
|
||||
wrapper: defaultThemeWrapper,
|
||||
});
|
||||
expect(res10.current[0].width).toBe(42);
|
||||
|
||||
const props100 = {
|
||||
...base,
|
||||
data: Array.from({ length: 100 }, () => ({ a: 1 })),
|
||||
};
|
||||
const { result: res100 } = renderHook(() => useColDefs(props100), {
|
||||
wrapper: defaultThemeWrapper,
|
||||
});
|
||||
expect(res100.current[0].width).toBe(48);
|
||||
});
|
||||
|
||||
test('row number column uses theme variables for styling', () => {
|
||||
const { result } = renderHook(
|
||||
() => useColDefs({ ...basePropsNumericColumns, showNumberedColumn: true }),
|
||||
{ wrapper: defaultThemeWrapper },
|
||||
);
|
||||
const colDef = result.current[0];
|
||||
expect(colDef.cellStyle).toMatchObject({
|
||||
backgroundColor: expect.any(String),
|
||||
padding: '0',
|
||||
textAlign: 'center',
|
||||
fontSize: '0.9em',
|
||||
color: expect.any(String),
|
||||
});
|
||||
expect(colDef.headerStyle).toMatchObject({
|
||||
backgroundColor: expect.any(String),
|
||||
fontSize: '1em',
|
||||
color: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
test('valueGetter handles missing node or rowIndex gracefully', () => {
|
||||
const { result } = renderHook(
|
||||
() => useColDefs({ ...basePropsNumericColumns, showNumberedColumn: true }),
|
||||
{ wrapper: defaultThemeWrapper },
|
||||
);
|
||||
const getter = result.current[0].valueGetter as (params: {
|
||||
node?: { rowIndex?: number };
|
||||
}) => number;
|
||||
expect(getter({})).toBe(1);
|
||||
expect(getter({ node: {} })).toBe(1);
|
||||
|
||||
const serverProps = {
|
||||
...basePropsNumericColumns,
|
||||
serverPagination: true,
|
||||
serverPaginationData: { currentPage: 2, pageSize: 5 },
|
||||
showNumberedColumn: true,
|
||||
};
|
||||
const { result: serverResult } = renderHook(() => useColDefs(serverProps), {
|
||||
wrapper: defaultThemeWrapper,
|
||||
});
|
||||
const serverGetter = serverResult.current[0].valueGetter as (params: {
|
||||
node?: { rowIndex?: number };
|
||||
}) => number;
|
||||
expect(serverGetter({})).toBe(2 * 5 + 1);
|
||||
expect(serverGetter({ node: {} })).toBe(11);
|
||||
});
|
||||
|
||||
@@ -288,9 +288,7 @@ describe('BigNumberWithTrendline transformProps', () => {
|
||||
height: 300,
|
||||
queriesData: [
|
||||
{
|
||||
data: [
|
||||
{ __timestamp: 1, value: 100 },
|
||||
] as unknown as BigNumberDatum[],
|
||||
data: [{ __timestamp: 1, value: 100 }] as unknown as BigNumberDatum[],
|
||||
colnames: ['__timestamp', 'value'],
|
||||
coltypes: ['TEMPORAL', 'NUMERIC'],
|
||||
},
|
||||
|
||||
@@ -21,13 +21,24 @@ import { histogramOperator } from '@superset-ui/chart-controls';
|
||||
import { HistogramFormData } from './types';
|
||||
|
||||
export default function buildQuery(formData: HistogramFormData) {
|
||||
const { column, groupby = [] } = formData;
|
||||
const { column, groupby = [], adhoc_filters } = formData;
|
||||
const hasHavingFilter = (adhoc_filters ?? []).some(
|
||||
(filter: { clause?: string }) => filter.clause === 'HAVING',
|
||||
);
|
||||
return buildQueryContext(formData, baseQueryObject => [
|
||||
{
|
||||
...baseQueryObject,
|
||||
columns: [...groupby, column],
|
||||
post_processing: [histogramOperator(formData, baseQueryObject)],
|
||||
metrics: undefined,
|
||||
metrics: hasHavingFilter
|
||||
? [
|
||||
{
|
||||
expressionType: 'SQL' as const,
|
||||
sqlExpression: 'COUNT(*)',
|
||||
label: 'COUNT(*)',
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -284,8 +284,11 @@ function Echart(
|
||||
// setOption(notMerge:true) replaces the dataZoom config, dropping any
|
||||
// range the user has engaged. Preserve it across the call.
|
||||
const previousZoom = notMerge
|
||||
? (chartRef.current?.getOption() as { dataZoom?: DataZoomComponentOption[] })
|
||||
?.dataZoom
|
||||
? (
|
||||
chartRef.current?.getOption() as {
|
||||
dataZoom?: DataZoomComponentOption[];
|
||||
}
|
||||
)?.dataZoom
|
||||
: undefined;
|
||||
chartRef.current?.setOption(themedEchartOptions, {
|
||||
notMerge,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 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 buildQuery from '../../src/Histogram/buildQuery';
|
||||
import { HistogramFormData } from '../../src/Histogram/types';
|
||||
|
||||
const baseFormData: HistogramFormData = {
|
||||
datasource: '5__table',
|
||||
granularity_sqla: 'ds',
|
||||
column: 'price',
|
||||
groupby: [],
|
||||
bins: 10,
|
||||
viz_type: 'histogram',
|
||||
cumulative: false,
|
||||
normalize: false,
|
||||
sliceId: 1,
|
||||
showLegend: false,
|
||||
showValue: false,
|
||||
xAxisFormat: '',
|
||||
xAxisTitle: '',
|
||||
yAxisFormat: '',
|
||||
yAxisTitle: '',
|
||||
};
|
||||
|
||||
test('should build query with column and no metrics', () => {
|
||||
const queryContext = buildQuery(baseFormData);
|
||||
const [query] = queryContext.queries;
|
||||
expect(query.columns).toContain('price');
|
||||
expect(query.metrics).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should include groupby columns in query columns', () => {
|
||||
const queryContext = buildQuery({ ...baseFormData, groupby: ['category'] });
|
||||
const [query] = queryContext.queries;
|
||||
expect(query.columns).toEqual(['category', 'price']);
|
||||
});
|
||||
|
||||
test('Regression for #30330: HAVING-clause metric filters require aggregation in the query', () => {
|
||||
/**
|
||||
* buildQuery unconditionally sets metrics: undefined, which means any
|
||||
* HAVING-clause adhoc_filter produces SQL with a HAVING clause but no
|
||||
* GROUP BY or aggregated metric — invalid SQL that most databases reject.
|
||||
*
|
||||
* The fix should preserve (or synthesise) a metric so the HAVING clause
|
||||
* has an aggregated value to filter on. This test asserts that desired
|
||||
* behaviour: when a HAVING adhoc_filter is present, query.metrics must
|
||||
* not be undefined or empty.
|
||||
*/
|
||||
const formDataWithHavingFilter: HistogramFormData = {
|
||||
...baseFormData,
|
||||
adhoc_filters: [
|
||||
{
|
||||
clause: 'HAVING',
|
||||
expressionType: 'SQL',
|
||||
sqlExpression: 'COUNT(*) > 5',
|
||||
},
|
||||
],
|
||||
};
|
||||
const queryContext = buildQuery(formDataWithHavingFilter);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
// HAVING filters without aggregation produce invalid SQL.
|
||||
// The query must include at least one metric when HAVING filters are present.
|
||||
expect(query.metrics).toBeDefined();
|
||||
expect((query.metrics as unknown[]).length).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -20,8 +20,6 @@ import {
|
||||
ControlSetItem,
|
||||
ExtraControlProps,
|
||||
sharedControls,
|
||||
Dataset,
|
||||
ColumnMeta,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { ensureIsArray } from '@superset-ui/core';
|
||||
@@ -35,11 +33,7 @@ const dndAllColumns: typeof sharedControls.groupby = {
|
||||
mapStateToProps({ datasource, controls }, controlState) {
|
||||
const newState: ExtraControlProps = {};
|
||||
if (datasource) {
|
||||
if (datasource?.columns[0]?.hasOwnProperty('filterable')) {
|
||||
newState.options = (datasource as Dataset)?.columns?.filter(
|
||||
(c: ColumnMeta) => c.filterable,
|
||||
);
|
||||
} else newState.options = datasource.columns;
|
||||
newState.options = datasource.columns || [];
|
||||
}
|
||||
newState.queryMode = getQueryMode(controls);
|
||||
newState.externalValidationErrors =
|
||||
|
||||
@@ -21,8 +21,6 @@ import {
|
||||
ControlSetItem,
|
||||
ControlState,
|
||||
sharedControls,
|
||||
Dataset,
|
||||
ColumnMeta,
|
||||
defineSavedMetrics,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
@@ -77,11 +75,7 @@ export const metricsControlSetItem: ControlSetItem = {
|
||||
{ controls, datasource, form_data }: ControlPanelState,
|
||||
controlState: ControlState,
|
||||
) => ({
|
||||
columns: datasource?.columns[0]?.hasOwnProperty('filterable')
|
||||
? (datasource as Dataset)?.columns?.filter(
|
||||
(c: ColumnMeta) => c.filterable,
|
||||
)
|
||||
: datasource?.columns,
|
||||
columns: datasource?.columns || [],
|
||||
savedMetrics: defineSavedMetrics(datasource),
|
||||
// current active adhoc metrics
|
||||
selectedMetrics:
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/types": "^7.29.0",
|
||||
"@babel/types": "^7.29.7",
|
||||
"@types/jest": "^30.0.0",
|
||||
"jest": "^30.4.2"
|
||||
}
|
||||
|
||||
@@ -304,11 +304,7 @@ const config: ControlPanelConfig = {
|
||||
{ controls, datasource, form_data }: ControlPanelState,
|
||||
controlState: ControlState,
|
||||
) => ({
|
||||
columns: datasource?.columns[0]?.hasOwnProperty('filterable')
|
||||
? (datasource as Dataset)?.columns?.filter(
|
||||
(c: ColumnMeta) => c.filterable,
|
||||
)
|
||||
: datasource?.columns,
|
||||
columns: datasource?.columns || [],
|
||||
savedMetrics: defineSavedMetrics(datasource),
|
||||
// current active adhoc metrics
|
||||
selectedMetrics:
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import { QueryFormData } from '@superset-ui/core';
|
||||
import {
|
||||
ColumnMeta,
|
||||
Dataset,
|
||||
isCustomControlItem,
|
||||
ControlConfig,
|
||||
@@ -46,6 +47,32 @@ const findConditionalFormattingControl = (): ControlConfig | null => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const findMetricsMapStateToProps = ():
|
||||
| ControlConfig['mapStateToProps']
|
||||
| null => {
|
||||
for (const section of config.controlPanelSections) {
|
||||
if (!section) continue;
|
||||
for (const row of section.controlSetRows) {
|
||||
for (const control of row) {
|
||||
if (
|
||||
control &&
|
||||
typeof control === 'object' &&
|
||||
'name' in control &&
|
||||
(control as { name: string }).name === 'metrics' &&
|
||||
'override' in control
|
||||
) {
|
||||
return (
|
||||
control as {
|
||||
override: { mapStateToProps: ControlConfig['mapStateToProps'] };
|
||||
}
|
||||
).override.mapStateToProps;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const createMockControlState = (value: string[] | undefined): ControlState => ({
|
||||
type: 'SelectControl',
|
||||
value,
|
||||
@@ -209,6 +236,50 @@ test('static extraColorChoices removed from config', () => {
|
||||
expect(controlConfig?.extraColorChoices).toBeUndefined();
|
||||
});
|
||||
|
||||
const createMockExploreWithColumns = (
|
||||
columns: Partial<ColumnMeta>[],
|
||||
): ControlPanelState => ({
|
||||
slice: { slice_id: 123 },
|
||||
datasource: {
|
||||
verbose_map: {},
|
||||
columns,
|
||||
metrics: [],
|
||||
} as Partial<Dataset> as Dataset,
|
||||
controls: {},
|
||||
form_data: {
|
||||
datasource: 'test',
|
||||
viz_type: 'table',
|
||||
} as QueryFormData,
|
||||
common: {},
|
||||
metadata: {},
|
||||
});
|
||||
|
||||
const createMockMetricsControlState = (): ControlState => ({
|
||||
type: 'MetricsControl',
|
||||
value: [],
|
||||
label: '',
|
||||
default: undefined,
|
||||
renderTrigger: false,
|
||||
});
|
||||
|
||||
test('metrics control includes non-filterable columns', () => {
|
||||
const mapStateToProps = findMetricsMapStateToProps();
|
||||
expect(mapStateToProps).toBeTruthy();
|
||||
|
||||
const explore = createMockExploreWithColumns([
|
||||
{ column_name: 'filterable_col', filterable: true },
|
||||
{ column_name: 'non_filterable_col', filterable: false },
|
||||
]);
|
||||
const result = mapStateToProps!(explore, createMockMetricsControlState());
|
||||
|
||||
expect(result.columns).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ column_name: 'filterable_col' }),
|
||||
expect.objectContaining({ column_name: 'non_filterable_col' }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('columnOptions falls back to datasource columns when queriesResponse is empty', () => {
|
||||
const controlConfig = findConditionalFormattingControl();
|
||||
expect(controlConfig).toBeTruthy();
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
import { useRef, useEffect, FC, useMemo } from 'react';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import {
|
||||
SqlLabRootState,
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { shallowEqual, useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
|
||||
import { usePrevious } from '@superset-ui/core';
|
||||
import { css, useTheme } from '@apache-superset/core/theme';
|
||||
import { Global } from '@emotion/react';
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useStore } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { getExtensionsRegistry } from '@superset-ui/core';
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { VizType } from '@superset-ui/core';
|
||||
import {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
|
||||
import URI from 'urijs';
|
||||
import { pick } from 'lodash';
|
||||
import { useComponentDidUpdate } from '@superset-ui/core';
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
import { useRef } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
|
||||
import { isObject } from 'lodash';
|
||||
import rison from 'rison';
|
||||
import {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { Dropdown, Button } from '@superset-ui/core/components';
|
||||
import { Menu } from '@superset-ui/core/components/Menu';
|
||||
|
||||
@@ -31,7 +31,7 @@ import { t } from '@apache-superset/core/translation';
|
||||
import { QueryResponse, QueryState } from '@superset-ui/core';
|
||||
import { useTheme } from '@apache-superset/core/theme';
|
||||
import { shallowEqual, useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
|
||||
|
||||
import {
|
||||
queryEditorSetSql,
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { sanitizeUrl } from '@braintree/sanitize-url';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -28,7 +29,7 @@ import {
|
||||
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { shallowEqual, useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { pick } from 'lodash';
|
||||
import {
|
||||
@@ -378,7 +379,7 @@ const ResultSet = ({
|
||||
{ rows: rowsCount.toLocaleString() },
|
||||
),
|
||||
onConfirm: () => {
|
||||
window.location.href = getExportCsvUrl(query.id);
|
||||
window.location.href = sanitizeUrl(getExportCsvUrl(query.id));
|
||||
},
|
||||
confirmText: t('OK'),
|
||||
cancelText: t('Close'),
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { sanitizeUrl } from '@braintree/sanitize-url';
|
||||
import { useCallback, useState, FormEvent } from 'react';
|
||||
import { ModalTitleWithIcon } from 'src/components/ModalTitleWithIcon';
|
||||
import { Radio, RadioChangeEvent } from '@superset-ui/core/components/Radio';
|
||||
@@ -43,7 +43,8 @@ import {
|
||||
} from '@superset-ui/core';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { extendedDayjs as dayjs } from '@superset-ui/core/utils/dates';
|
||||
import { useAppDispatch, useAppSelector } from 'src/views/store';
|
||||
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
|
||||
import { useAppSelector } from 'src/SqlLab/hooks/useAppSelector';
|
||||
import rison from 'rison';
|
||||
import { createDatasource } from 'src/SqlLab/actions/sqlLab';
|
||||
import { addDangerToast } from 'src/components/MessageToasts/actions';
|
||||
@@ -244,9 +245,9 @@ export const SaveDatasetModal = ({
|
||||
|
||||
const createWindow = (url: string) => {
|
||||
if (openWindow) {
|
||||
window.open(url, '_blank', 'noreferrer');
|
||||
window.open(sanitizeUrl(url), '_blank', 'noreferrer');
|
||||
} else {
|
||||
window.location.href = url;
|
||||
window.location.href = sanitizeUrl(url);
|
||||
}
|
||||
};
|
||||
const formDataWithDefaults = {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
import { createRef, useCallback, useMemo } from 'react';
|
||||
import { shallowEqual, useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
|
||||
import { nanoid } from 'nanoid';
|
||||
import Tabs from '@superset-ui/core/components/Tabs';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
import type { editors } from '@apache-superset/core';
|
||||
import useEffectEvent from 'src/hooks/useEffectEvent';
|
||||
import { shallowEqual, useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
|
||||
|
||||
import { resetState } from 'src/SqlLab/actions/sqlLab';
|
||||
import {
|
||||
|
||||
@@ -20,7 +20,7 @@ import { useMemo, FC } from 'react';
|
||||
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { useSelector, shallowEqual } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
|
||||
import { MenuDotsDropdown } from '@superset-ui/core/components';
|
||||
import { Menu, MenuItemType } from '@superset-ui/core/components/Menu';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
import { useEffect, useCallback, useMemo, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
|
||||
|
||||
import { SqlLabRootState } from 'src/SqlLab/types';
|
||||
import {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
|
||||
import type { QueryEditor, SqlLabRootState, Table } from 'src/SqlLab/types';
|
||||
import {
|
||||
ButtonGroup,
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { useSelector, shallowEqual } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
|
||||
import { styled, css, useTheme } from '@apache-superset/core/theme';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useMemo, useReducer, useCallback } from 'react';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import {
|
||||
Table,
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
import { type FC, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { shallowEqual, useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { ClientErrorObject, getExtensionsRegistry } from '@superset-ui/core';
|
||||
|
||||
28
superset-frontend/src/SqlLab/hooks/useAppDispatch.ts
Normal file
28
superset-frontend/src/SqlLab/hooks/useAppDispatch.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 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 { useDispatch } from 'react-redux';
|
||||
import type { AppDispatch } from 'src/views/store';
|
||||
|
||||
// In Module Federation deployments where the host shell shares src/views/store
|
||||
// as a singleton, a version skew between the shell and the SQL Lab chunk can
|
||||
// leave useAppDispatch undefined at runtime even though TypeScript types it as
|
||||
// always-present. Keep this hook free of runtime imports from src/views/store:
|
||||
// store initialization imports SqlLab persistence helpers, so importing store
|
||||
// values here can create an app-startup circular dependency.
|
||||
export const useAppDispatch: () => AppDispatch = useDispatch;
|
||||
24
superset-frontend/src/SqlLab/hooks/useAppSelector.ts
Normal file
24
superset-frontend/src/SqlLab/hooks/useAppSelector.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 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 { useSelector, type TypedUseSelectorHook } from 'react-redux';
|
||||
import type { RootState } from 'src/views/store';
|
||||
|
||||
// Keep this hook free of runtime imports from src/views/store for the same
|
||||
// Module Federation version-skew case handled by useAppDispatch.
|
||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
||||
@@ -16,6 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { sanitizeUrl } from '@braintree/sanitize-url';
|
||||
import rison from 'rison';
|
||||
import { PureComponent, useCallback, type ReactNode } from 'react';
|
||||
import { connect, ConnectedProps } from 'react-redux';
|
||||
@@ -1771,7 +1772,7 @@ class DatasourceEditor extends PureComponent<
|
||||
renderOpenInSqlLabLink(isError = false) {
|
||||
return (
|
||||
<a
|
||||
href={this.getSQLLabUrl()}
|
||||
href={sanitizeUrl(this.getSQLLabUrl())}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
css={theme => css`
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { sanitizeUrl } from '@braintree/sanitize-url';
|
||||
import { PropsWithoutRef, RefAttributes } from 'react';
|
||||
import { Link, LinkProps } from 'react-router-dom';
|
||||
import { isUrlExternal, parseUrl } from 'src/utils/urlUtils';
|
||||
@@ -31,7 +31,7 @@ export const GenericLink = <S,>({
|
||||
}: PropsWithoutRef<LinkProps<S>> & RefAttributes<HTMLAnchorElement>) => {
|
||||
if (typeof to === 'string' && isUrlExternal(to)) {
|
||||
return (
|
||||
<a data-test="external-link" href={parseUrl(to)} {...rest}>
|
||||
<a data-test="external-link" href={sanitizeUrl(parseUrl(to))} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
|
||||
@@ -611,6 +611,21 @@ test('should toggle the edit mode', () => {
|
||||
expect(logEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should NOT render the Edit dashboard button when embedded', () => {
|
||||
// Embedded (Embedded SDK) dashboards authenticate with a guest token and so
|
||||
// have no userId. The Edit button must be hidden even with edit permission,
|
||||
// since the embedded context cannot handle entering/exiting edit mode.
|
||||
const embeddedCanEditState = {
|
||||
dashboardInfo: {
|
||||
...initialState.dashboardInfo,
|
||||
dash_edit_perm: true,
|
||||
userId: undefined,
|
||||
},
|
||||
};
|
||||
setup(embeddedCanEditState);
|
||||
expect(screen.queryByTestId('edit-dashboard-button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render the dropdown icon', () => {
|
||||
setup();
|
||||
expect(screen.getByRole('img', { name: 'ellipsis' })).toBeInTheDocument();
|
||||
|
||||
@@ -749,7 +749,7 @@ const Header = (): JSX.Element => {
|
||||
) : (
|
||||
<div css={actionButtonsStyle}>
|
||||
{NavExtension && <NavExtension />}
|
||||
{userCanEdit && (
|
||||
{userCanEdit && !isEmbedded && (
|
||||
<Button
|
||||
buttonStyle="secondary"
|
||||
onClick={handleEnterEditMode}
|
||||
@@ -776,6 +776,7 @@ const Header = (): JSX.Element => {
|
||||
handleCtrlZ,
|
||||
handleEnterEditMode,
|
||||
hasUnsavedChanges,
|
||||
isEmbedded,
|
||||
overwriteDashboard,
|
||||
redoLength,
|
||||
undoLength,
|
||||
|
||||
@@ -38,10 +38,6 @@ import {
|
||||
} from '@superset-ui/core';
|
||||
|
||||
import withToasts from 'src/components/MessageToasts/withToasts';
|
||||
import {
|
||||
OWNER_TEXT_LABEL_PROP,
|
||||
OWNER_EMAIL_PROP,
|
||||
} from 'src/features/owners/OwnerSelectLabel';
|
||||
import { fetchTags, OBJECT_TYPES } from 'src/features/tags/tags';
|
||||
import {
|
||||
applyColors,
|
||||
@@ -65,6 +61,7 @@ import {
|
||||
CertificationSection,
|
||||
AdvancedSection,
|
||||
} from './sections';
|
||||
import { parseSelectedOwners, type OwnerOption } from './utils';
|
||||
|
||||
type PropertiesModalProps = {
|
||||
dashboardId: number;
|
||||
@@ -254,17 +251,12 @@ const PropertiesModal = ({
|
||||
};
|
||||
|
||||
const handleOnChangeOwners = (
|
||||
owners: { value: number; label: string }[],
|
||||
options: Record<string, unknown>[],
|
||||
selectedOwners: OwnerOption[],
|
||||
options: OwnerOption[],
|
||||
) => {
|
||||
const parsedOwners: Owners = ensureIsArray(owners).map((o, i) => ({
|
||||
id: o.value,
|
||||
full_name:
|
||||
(options?.[i]?.[OWNER_TEXT_LABEL_PROP] as string) ||
|
||||
(typeof o.label === 'string' ? o.label : ''),
|
||||
email: (options?.[i]?.[OWNER_EMAIL_PROP] as string) || '',
|
||||
}));
|
||||
setOwners(parsedOwners);
|
||||
// Use the functional updater so the parse always reads the latest owners
|
||||
// state rather than the value captured in this render's closure.
|
||||
setOwners(prev => parseSelectedOwners(selectedOwners, options, prev));
|
||||
};
|
||||
|
||||
const handleOnChangeRoles = (roles: { value: number; label: string }[]) => {
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
OWNER_OPTION_FILTER_PROPS,
|
||||
} from 'src/features/owners/OwnerSelectLabel';
|
||||
import { useAccessOptions } from '../hooks/useAccessOptions';
|
||||
import { type OwnerOption } from '../utils';
|
||||
|
||||
type Roles = { id: number; name: string }[];
|
||||
type Owners = {
|
||||
@@ -47,10 +48,7 @@ interface AccessSectionProps {
|
||||
owners: Owners;
|
||||
roles: Roles;
|
||||
tags: TagType[];
|
||||
onChangeOwners: (
|
||||
owners: { value: number; label: string }[],
|
||||
options: Record<string, unknown>[],
|
||||
) => void;
|
||||
onChangeOwners: (owners: OwnerOption[], options: OwnerOption[]) => void;
|
||||
onChangeRoles: (roles: { value: number; label: string }[]) => void;
|
||||
onChangeTags: (tags: { label: string; value: number }[]) => void;
|
||||
onClearTags: () => void;
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
OWNER_TEXT_LABEL_PROP,
|
||||
OWNER_EMAIL_PROP,
|
||||
} from 'src/features/owners/OwnerSelectLabel';
|
||||
import { parseSelectedOwners } from './utils';
|
||||
|
||||
test('preserves a remaining owner from state when the option cache is partial', () => {
|
||||
// Owners A(1) and B(2) were loaded from the dashboard, so their full data
|
||||
// lives only in component state (the controlled `value`), not in the
|
||||
// AsyncSelect option cache.
|
||||
const existingOwners = [
|
||||
{ id: 1, full_name: 'Alice Adams', email: 'alice@example.com' },
|
||||
{ id: 2, full_name: 'Bob Brown', email: 'bob@example.com' },
|
||||
];
|
||||
// The user removes A; onChange fires with only B, and `options` does not
|
||||
// contain B (it was never searched/loaded).
|
||||
const selectedOwners = [{ value: 2, label: 'Bob Brown' }];
|
||||
const options: never[] = [];
|
||||
|
||||
// Regression: B must keep its real name/email rather than collapsing into a
|
||||
// nameless ("undefined undefined") owner.
|
||||
expect(parseSelectedOwners(selectedOwners, options, existingOwners)).toEqual([
|
||||
{ id: 2, full_name: 'Bob Brown', email: 'bob@example.com' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('builds a new owner from the option text label when not already in state', () => {
|
||||
const options = [
|
||||
{
|
||||
value: 3,
|
||||
// Real labels are OwnerSelectLabel React elements; a number is used here
|
||||
// simply as a non-string ReactNode for the test.
|
||||
label: 1,
|
||||
[OWNER_TEXT_LABEL_PROP]: 'Carol Clark',
|
||||
[OWNER_EMAIL_PROP]: 'carol@example.com',
|
||||
},
|
||||
];
|
||||
|
||||
expect(parseSelectedOwners([{ value: 3, label: 1 }], options, [])).toEqual([
|
||||
{ id: 3, full_name: 'Carol Clark', email: 'carol@example.com' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('leaves email undefined when option is cached but carries no email', () => {
|
||||
// The option exists in the cache (so `opt` is defined) but has no
|
||||
// OWNER_EMAIL_PROP, exercising the `?? undefined` branch directly.
|
||||
const options = [
|
||||
{
|
||||
value: 6,
|
||||
label: 'Dave Doe',
|
||||
[OWNER_TEXT_LABEL_PROP]: 'Dave Doe',
|
||||
// intentionally no OWNER_EMAIL_PROP
|
||||
},
|
||||
];
|
||||
|
||||
expect(
|
||||
parseSelectedOwners([{ value: 6, label: 'Dave Doe' }], options, []),
|
||||
).toEqual([{ id: 6, full_name: 'Dave Doe', email: undefined }]);
|
||||
});
|
||||
|
||||
test('falls back to a string label when the option has no text label', () => {
|
||||
// No option in the cache => email stays undefined (not an empty string).
|
||||
expect(
|
||||
parseSelectedOwners([{ value: 4, label: 'Plain Name' }], [], []),
|
||||
).toEqual([{ id: 4, full_name: 'Plain Name', email: undefined }]);
|
||||
});
|
||||
|
||||
test('yields an empty name for a non-string label with no text label', () => {
|
||||
expect(parseSelectedOwners([{ value: 5, label: 1 }], [], [])).toEqual([
|
||||
{ id: 5, full_name: '', email: undefined },
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { type ReactNode } from 'react';
|
||||
import { ensureIsArray } from '@superset-ui/core';
|
||||
import {
|
||||
OWNER_TEXT_LABEL_PROP,
|
||||
OWNER_EMAIL_PROP,
|
||||
} from 'src/features/owners/OwnerSelectLabel';
|
||||
|
||||
/**
|
||||
* An owners AsyncSelect option. The `label` is the rendered `OwnerSelectLabel`
|
||||
* React element (not a string), so the plain-text name is carried separately on
|
||||
* `OWNER_TEXT_LABEL_PROP` for the options the component constructs.
|
||||
*/
|
||||
export type OwnerOption = {
|
||||
value: number;
|
||||
label: ReactNode;
|
||||
[OWNER_TEXT_LABEL_PROP]?: string;
|
||||
[OWNER_EMAIL_PROP]?: string;
|
||||
};
|
||||
|
||||
export type ParsedOwner = {
|
||||
id: number;
|
||||
full_name?: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
email?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the owner objects to persist when the owners AsyncSelect changes.
|
||||
*
|
||||
* AsyncSelect only caches the options the user has actually loaded or searched,
|
||||
* so `options` can be a partial set that is missing owners which only ever
|
||||
* existed in the controlled `value` prop. We therefore prefer the full owner
|
||||
* object already in component state (it carries the real name/email from the
|
||||
* API) and only fall back to the option cache — and finally a string label —
|
||||
* for genuinely new owners. This prevents a removed owner from collapsing the
|
||||
* remaining owners into nameless entries.
|
||||
*/
|
||||
export function parseSelectedOwners(
|
||||
selectedOwners: OwnerOption[],
|
||||
options: OwnerOption[],
|
||||
existingOwners: ParsedOwner[],
|
||||
): ParsedOwner[] {
|
||||
const optionsById = new Map(options.map(opt => [opt.value, opt]));
|
||||
return ensureIsArray(selectedOwners).map(o => {
|
||||
const existingOwner = existingOwners.find(ow => ow.id === o.value);
|
||||
if (existingOwner) {
|
||||
return existingOwner;
|
||||
}
|
||||
const opt = optionsById.get(o.value);
|
||||
return {
|
||||
id: o.value,
|
||||
full_name:
|
||||
opt?.[OWNER_TEXT_LABEL_PROP] ||
|
||||
// `label` is a React element unless the option came from a plain-text
|
||||
// source, so only use it as a name when it is actually a string.
|
||||
(typeof o.label === 'string' ? o.label : ''),
|
||||
// Leave email undefined when the option carries no email, rather than
|
||||
// fabricating an empty string — keeps the optional `email?` type honest.
|
||||
email: opt?.[OWNER_EMAIL_PROP] ?? undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
} from 'src/hooks/apiResources';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import CrudThemeProvider from 'src/components/CrudThemeProvider';
|
||||
import { hydrateDashboard } from 'src/dashboard/actions/hydrate';
|
||||
import { clearDashboardHistory } from 'src/dashboard/actions/dashboardLayout';
|
||||
import DashboardPage from './DashboardPage';
|
||||
|
||||
const mockTheme = {
|
||||
@@ -64,6 +66,11 @@ jest.mock('src/dashboard/actions/hydrate', () => ({
|
||||
hydrateDashboard: jest.fn(() => ({ type: 'MOCK_HYDRATE' })),
|
||||
}));
|
||||
|
||||
jest.mock('src/dashboard/actions/dashboardLayout', () => ({
|
||||
...jest.requireActual('src/dashboard/actions/dashboardLayout'),
|
||||
clearDashboardHistory: jest.fn(() => ({ type: 'MOCK_CLEAR_HISTORY' })),
|
||||
}));
|
||||
|
||||
jest.mock('src/dashboard/actions/datasources', () => ({
|
||||
...jest.requireActual('src/dashboard/actions/datasources'),
|
||||
setDatasources: jest.fn(() => ({ type: 'MOCK_SET_DATASOURCES' })),
|
||||
@@ -254,3 +261,31 @@ test('passes null theme when Redux dashboardInfo.theme is explicitly null (theme
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
test('clears undo history after hydrating the dashboard', async () => {
|
||||
render(
|
||||
<Suspense fallback="loading">
|
||||
<DashboardPage idOrSlug="1" />
|
||||
</Suspense>,
|
||||
{
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: {
|
||||
dashboardInfo: { id: 1, metadata: {} },
|
||||
dashboardState: { sliceIds: [] },
|
||||
nativeFilters: { filters: {} },
|
||||
dataMask: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('loading')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(hydrateDashboard).toHaveBeenCalled();
|
||||
expect(clearDashboardHistory).toHaveBeenCalled();
|
||||
const hydrateOrder = (hydrateDashboard as jest.Mock).mock.invocationCallOrder[0];
|
||||
const clearOrder = (clearDashboardHistory as jest.Mock).mock.invocationCallOrder[0];
|
||||
expect(clearOrder).toBeGreaterThan(hydrateOrder);
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
useDashboardDatasets,
|
||||
} from 'src/hooks/apiResources';
|
||||
import { hydrateDashboard } from 'src/dashboard/actions/hydrate';
|
||||
import { clearDashboardHistory } from 'src/dashboard/actions/dashboardLayout';
|
||||
import { setDatasources } from 'src/dashboard/actions/datasources';
|
||||
import injectCustomCss from 'src/dashboard/util/injectCustomCss';
|
||||
import {
|
||||
@@ -278,6 +279,7 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
|
||||
chartStates: chartStates ?? null,
|
||||
} as unknown as Parameters<typeof hydrateDashboard>[0]),
|
||||
);
|
||||
dispatch(clearDashboardHistory());
|
||||
|
||||
// Scroll to anchor element if specified in permalink state
|
||||
if (anchor) {
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
} from './EmbeddedContextProviders';
|
||||
import { embeddedApi } from './api';
|
||||
import { getDataMaskChangeTrigger } from './utils';
|
||||
import { validateMessageEvent } from './originValidation';
|
||||
|
||||
setupPlugins();
|
||||
setupCodeOverrides({ embedded: true });
|
||||
@@ -125,8 +126,6 @@ const EmbeddedApp = () => (
|
||||
|
||||
const appMountPoint = document.getElementById('app')!;
|
||||
|
||||
const MESSAGE_TYPE = '__embedded_comms__';
|
||||
|
||||
function showFailureMessage(message: string) {
|
||||
appMountPoint.innerHTML = message;
|
||||
}
|
||||
@@ -139,17 +138,6 @@ if (!window.parent || window.parent === window) {
|
||||
);
|
||||
}
|
||||
|
||||
// if the page is embedded in an origin that hasn't
|
||||
// been authorized by the curator, we forbid access entirely.
|
||||
// todo: check the referrer on the route serving this page instead
|
||||
// const ALLOW_ORIGINS = ['http://127.0.0.1:9001', 'http://localhost:9001'];
|
||||
// const parentOrigin = new URL(document.referrer).origin;
|
||||
// if (!ALLOW_ORIGINS.includes(parentOrigin)) {
|
||||
// throw new Error(
|
||||
// `[superset] iframe parent ${parentOrigin} is not in the list of allowed origins`,
|
||||
// );
|
||||
// }
|
||||
|
||||
let displayedUnauthorizedToast = false;
|
||||
let root: Root | null = null;
|
||||
let started = false;
|
||||
@@ -225,21 +213,9 @@ function setupGuestClient(guestToken: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function validateMessageEvent(event: MessageEvent) {
|
||||
// if (!ALLOW_ORIGINS.includes(event.origin)) {
|
||||
// throw new Error('Message origin is not in the allowed list');
|
||||
// }
|
||||
|
||||
if (typeof event.data !== 'object' || event.data.type !== MESSAGE_TYPE) {
|
||||
throw new Error(`Message type does not match type used for embedded comms`);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', function embeddedPageInitializer(event) {
|
||||
try {
|
||||
validateMessageEvent(event);
|
||||
} catch (err) {
|
||||
log('ignoring message unrelated to embedded comms', err, event);
|
||||
if (!validateMessageEvent(event, bootstrapData.embedded?.allowed_domains)) {
|
||||
log('ignoring message unrelated to embedded comms', event);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user