mirror of
https://github.com/apache/superset.git
synced 2026-06-10 01:59:17 +00:00
Compare commits
88 Commits
ci/cypress
...
chore/fc-0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21c13e837a | ||
|
|
b85a2cdab1 | ||
|
|
381b99ae84 | ||
|
|
6b0d747939 | ||
|
|
151df43d9d | ||
|
|
3d7021fdf9 | ||
|
|
2babb48081 | ||
|
|
4715cfd372 | ||
|
|
5a6306983e | ||
|
|
7f452e4096 | ||
|
|
7eaaffde89 | ||
|
|
0984839788 | ||
|
|
863e93539a | ||
|
|
81bc3088e2 | ||
|
|
19d01521bf | ||
|
|
1623ceda73 | ||
|
|
e956f82224 | ||
|
|
2aca35cb68 | ||
|
|
44777cc110 | ||
|
|
20024ce3af | ||
|
|
b069b6caf6 | ||
|
|
70ee6e21eb | ||
|
|
550c80f640 | ||
|
|
108e40cbb6 | ||
|
|
8119204857 | ||
|
|
645aa3b1df | ||
|
|
55bb75efe6 | ||
|
|
601f9c2b8c | ||
|
|
fa42b13eb8 | ||
|
|
aa4092ba68 | ||
|
|
45a616439b | ||
|
|
98c096df05 | ||
|
|
42367afb25 | ||
|
|
875673f670 | ||
|
|
79c74af2e9 | ||
|
|
abccd67862 | ||
|
|
141dd0c227 | ||
|
|
7406098708 | ||
|
|
ccce0cab18 | ||
|
|
94c1a1b1f2 | ||
|
|
04939c94cc | ||
|
|
937eff6d52 | ||
|
|
f5f4a41598 | ||
|
|
639866625d | ||
|
|
7d323dc0ae | ||
|
|
0d1b702ce8 | ||
|
|
ddeec68c88 | ||
|
|
0ad09d5cd0 | ||
|
|
6662529306 | ||
|
|
09cd2c26cd | ||
|
|
cbd731e661 | ||
|
|
3f94c9db2d | ||
|
|
80a3df3550 | ||
|
|
6f97d9817e | ||
|
|
7d69f76127 | ||
|
|
9a31362fa5 | ||
|
|
cd5bdf11ac | ||
|
|
75d94ff466 | ||
|
|
c505c70c52 | ||
|
|
23d18743bd | ||
|
|
ddb09f468d | ||
|
|
8dcc7e7eec | ||
|
|
ff5e43c8a0 | ||
|
|
bdb081329f | ||
|
|
aa547da960 | ||
|
|
966c243db6 | ||
|
|
c5689b13a9 | ||
|
|
696705794b | ||
|
|
41572dbf9d | ||
|
|
5ba60d51fd | ||
|
|
4ff360ea2d | ||
|
|
3bf0555259 | ||
|
|
8d5910bbe5 | ||
|
|
b7fa1248b6 | ||
|
|
d65456a4de | ||
|
|
82a2030943 | ||
|
|
6110e92c00 | ||
|
|
4f62910ffc | ||
|
|
ad689bf174 | ||
|
|
338ac0cd8e | ||
|
|
3b77a416b0 | ||
|
|
bae63b1936 | ||
|
|
24d57d043a | ||
|
|
e46e97393b | ||
|
|
aa121095fa | ||
|
|
7da9901358 | ||
|
|
119422f980 | ||
|
|
00960ae319 |
14
.asf.yaml
14
.asf.yaml
@@ -77,23 +77,17 @@ github:
|
||||
# combination here.
|
||||
contexts:
|
||||
- lint-check
|
||||
- cypress-matrix (0, chrome)
|
||||
- cypress-matrix (1, chrome)
|
||||
- cypress-matrix (2, chrome)
|
||||
- cypress-matrix (3, chrome)
|
||||
- cypress-matrix (4, chrome)
|
||||
- cypress-matrix (5, chrome)
|
||||
- cypress-matrix-required
|
||||
- dependency-review
|
||||
- frontend-build
|
||||
- playwright-tests (chromium)
|
||||
- playwright-tests-required
|
||||
- pre-commit (current)
|
||||
- pre-commit (previous)
|
||||
- test-mysql
|
||||
- test-postgres (current)
|
||||
- test-postgres-required
|
||||
- test-postgres-hive
|
||||
- test-postgres-presto
|
||||
- test-sqlite
|
||||
- unit-tests (current)
|
||||
- unit-tests-required
|
||||
|
||||
required_pull_request_reviews:
|
||||
dismiss_stale_reviews: false
|
||||
|
||||
35
.github/workflows/codeql-analysis.yml
vendored
35
.github/workflows/codeql-analysis.yml
vendored
@@ -15,9 +15,35 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
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.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
analyze:
|
||||
name: Analyze
|
||||
needs: changes
|
||||
# Skip on PRs that touch neither code group (e.g. docs-only) so the
|
||||
# analysis runners don't spin up. push/schedule runs always proceed:
|
||||
# the change-detector returns "all changed" for non-PR events.
|
||||
if: needs.changes.outputs.python == 'true' || needs.changes.outputs.frontend == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
@@ -31,16 +57,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4
|
||||
@@ -54,7 +74,6 @@ jobs:
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
54
.github/workflows/docker.yml
vendored
54
.github/workflows/docker.yml
vendored
@@ -19,8 +19,30 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
|
||||
changes:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
python: ${{ steps.check.outputs.python }}
|
||||
frontend: ${{ steps.check.outputs.frontend }}
|
||||
docker: ${{ steps.check.outputs.docker }}
|
||||
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 }}
|
||||
|
||||
setup_matrix:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
matrix_config: ${{ steps.set_matrix.outputs.matrix_config }}
|
||||
steps:
|
||||
@@ -32,8 +54,13 @@ jobs:
|
||||
|
||||
docker-build:
|
||||
name: docker-build
|
||||
needs: setup_matrix
|
||||
needs: [setup_matrix, changes]
|
||||
if: >-
|
||||
needs.changes.outputs.python == 'true' ||
|
||||
needs.changes.outputs.frontend == 'true' ||
|
||||
needs.changes.outputs.docker == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
matrix:
|
||||
build_preset: ${{fromJson(needs.setup_matrix.outputs.matrix_config)}}
|
||||
@@ -50,14 +77,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Docker Environment
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend || steps.check.outputs.docker
|
||||
uses: ./.github/actions/setup-docker
|
||||
with:
|
||||
dockerhub-user: ${{ secrets.DOCKERHUB_USER }}
|
||||
@@ -65,11 +85,9 @@ jobs:
|
||||
build: "true"
|
||||
|
||||
- name: Setup supersetbot
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend || steps.check.outputs.docker
|
||||
uses: ./.github/actions/setup-supersetbot/
|
||||
|
||||
- name: Build Docker Image
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend || steps.check.outputs.docker
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -95,7 +113,7 @@ jobs:
|
||||
|
||||
# in the context of push (using multi-platform build), we need to pull the image locally
|
||||
- name: Docker pull
|
||||
if: github.event_name == 'push' && (steps.check.outputs.python || steps.check.outputs.frontend || steps.check.outputs.docker)
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
for i in 1 2 3; do
|
||||
docker pull $IMAGE_TAG && break
|
||||
@@ -103,7 +121,6 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Print docker stats
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend || steps.check.outputs.docker
|
||||
run: |
|
||||
echo "SHA: ${{ github.sha }}"
|
||||
echo "IMAGE: $IMAGE_TAG"
|
||||
@@ -111,7 +128,7 @@ jobs:
|
||||
docker history $IMAGE_TAG
|
||||
|
||||
- name: docker-compose sanity check
|
||||
if: (steps.check.outputs.python || steps.check.outputs.frontend || steps.check.outputs.docker) && matrix.build_preset == 'dev'
|
||||
if: matrix.build_preset == 'dev'
|
||||
shell: bash
|
||||
env:
|
||||
BUILD_PRESET: ${{ matrix.build_preset }}
|
||||
@@ -124,20 +141,16 @@ jobs:
|
||||
docker-compose-image-tag:
|
||||
# Run this job only on pushes to master (not for PRs)
|
||||
# goal is to check that building the latest image works, not required for all PR pushes
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
|
||||
needs: changes
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/master' && needs.changes.outputs.docker == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
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 }}
|
||||
- name: Setup Docker Environment
|
||||
if: steps.check.outputs.docker
|
||||
uses: ./.github/actions/setup-docker
|
||||
with:
|
||||
dockerhub-user: ${{ secrets.DOCKERHUB_USER }}
|
||||
@@ -145,7 +158,6 @@ jobs:
|
||||
build: "false"
|
||||
install-docker-compose: "true"
|
||||
- name: docker-compose sanity check
|
||||
if: steps.check.outputs.docker
|
||||
shell: bash
|
||||
run: |
|
||||
docker compose -f docker-compose-image-tag.yml up superset-init --exit-code-from superset-init
|
||||
|
||||
8
.github/workflows/pre-commit.yml
vendored
8
.github/workflows/pre-commit.yml
vendored
@@ -19,9 +19,13 @@ concurrency:
|
||||
jobs:
|
||||
pre-commit:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["current", "previous", "next"]
|
||||
# Run the full version spread on push (master/release) and nightly,
|
||||
# but only the current version on PRs — lint/format/type results
|
||||
# rarely differ across patch versions, so 3x per PR is wasteful.
|
||||
python-version: ${{ github.event_name == 'pull_request' && fromJSON('["current"]') || fromJSON('["current", "previous", "next"]') }}
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
@@ -45,6 +49,8 @@ jobs:
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'superset-frontend/package-lock.json'
|
||||
|
||||
- name: Install Frontend Dependencies
|
||||
run: |
|
||||
|
||||
67
.github/workflows/superset-e2e.yml
vendored
67
.github/workflows/superset-e2e.yml
vendored
@@ -29,6 +29,7 @@ concurrency:
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
@@ -51,6 +52,7 @@ jobs:
|
||||
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
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
@@ -129,6 +131,8 @@ jobs:
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version-file: './superset-frontend/.nvmrc'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'superset-frontend/package-lock.json'
|
||||
- name: Install npm dependencies
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
@@ -170,6 +174,7 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.python == 'true' || needs.changes.outputs.frontend == 'true'
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
@@ -236,6 +241,8 @@ jobs:
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version-file: './superset-frontend/.nvmrc'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'superset-frontend/package-lock.json'
|
||||
- name: Install npm dependencies
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
@@ -274,3 +281,63 @@ 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 }}
|
||||
|
||||
# Stable required-status-check anchors. cypress-matrix and playwright-tests
|
||||
# are matrix jobs gated on change detection (python || frontend). On a PR
|
||||
# that touches neither — e.g. a docs-only PR — they are skipped at the job
|
||||
# level, which happens before matrix expansion, so the per-combination
|
||||
# contexts (`cypress-matrix (0, chrome)`, `playwright-tests (chromium)`) are
|
||||
# never produced and branch protection waits on them forever. These
|
||||
# always-running jobs report a single stable context that passes when the
|
||||
# underlying matrix job succeeded or was skipped, and fails only on a real
|
||||
# failure. Require these in .asf.yaml instead of the matrix-expanded names.
|
||||
#
|
||||
# A matrix job reads as "skipped" in two distinct cases, and only the first
|
||||
# is a legitimate pass: (a) change detection succeeded and gated the job off
|
||||
# (docs-only PR); (b) the `changes` job itself failed or was cancelled, in
|
||||
# which case GHA skips its dependents too. Accepting (b) would let a broken
|
||||
# change-detector report a false green, so each anchor first requires
|
||||
# `changes` to have succeeded before honouring a skip.
|
||||
cypress-matrix-required:
|
||||
needs: [changes, cypress-matrix]
|
||||
if: always()
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
permissions: {}
|
||||
steps:
|
||||
- name: Check cypress-matrix result
|
||||
env:
|
||||
CHANGES: ${{ needs.changes.result }}
|
||||
RESULT: ${{ needs.cypress-matrix.result }}
|
||||
run: |
|
||||
if [ "$CHANGES" != "success" ]; then
|
||||
echo "change detection did not succeed (result: $CHANGES); refusing to pass on a skipped matrix"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$RESULT" != "success" ] && [ "$RESULT" != "skipped" ]; then
|
||||
echo "cypress-matrix did not pass (result: $RESULT)"
|
||||
exit 1
|
||||
fi
|
||||
echo "cypress-matrix result: $RESULT (changes: $CHANGES)"
|
||||
|
||||
playwright-tests-required:
|
||||
needs: [changes, playwright-tests]
|
||||
if: always()
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
permissions: {}
|
||||
steps:
|
||||
- name: Check playwright-tests result
|
||||
env:
|
||||
CHANGES: ${{ needs.changes.result }}
|
||||
RESULT: ${{ needs.playwright-tests.result }}
|
||||
run: |
|
||||
if [ "$CHANGES" != "success" ]; then
|
||||
echo "change detection did not succeed (result: $CHANGES); refusing to pass on a skipped matrix"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$RESULT" != "success" ] && [ "$RESULT" != "skipped" ]; then
|
||||
echo "playwright-tests did not pass (result: $RESULT)"
|
||||
exit 1
|
||||
fi
|
||||
echo "playwright-tests result: $RESULT (changes: $CHANGES)"
|
||||
|
||||
@@ -20,9 +20,12 @@ concurrency:
|
||||
jobs:
|
||||
test-superset-extensions-cli-package:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["previous", "current", "next"]
|
||||
# Full version spread on push (master/release) + nightly; current only
|
||||
# on PRs to cut runner cost (cross-version breaks are caught at merge).
|
||||
python-version: ${{ github.event_name == 'pull_request' && fromJSON('["current"]') || fromJSON('["previous", "current", "next"]') }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: superset-extensions-cli
|
||||
|
||||
6
.github/workflows/superset-frontend.yml
vendored
6
.github/workflows/superset-frontend.yml
vendored
@@ -22,6 +22,7 @@ permissions:
|
||||
jobs:
|
||||
frontend-build:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
outputs:
|
||||
should-run: ${{ steps.check.outputs.frontend }}
|
||||
steps:
|
||||
@@ -74,6 +75,7 @@ jobs:
|
||||
shard: [1, 2, 3, 4, 5, 6, 7, 8]
|
||||
fail-fast: false
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Download Docker Image Artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
@@ -103,6 +105,7 @@ jobs:
|
||||
needs: [sharded-jest-tests]
|
||||
if: needs.frontend-build.outputs.should-run == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
@@ -144,6 +147,7 @@ jobs:
|
||||
needs: frontend-build
|
||||
if: needs.frontend-build.outputs.should-run == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Download Docker Image Artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
@@ -168,6 +172,7 @@ jobs:
|
||||
needs: frontend-build
|
||||
if: needs.frontend-build.outputs.should-run == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Download Docker Image Artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
@@ -187,6 +192,7 @@ jobs:
|
||||
needs: frontend-build
|
||||
if: needs.frontend-build.outputs.should-run == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Download Docker Image Artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
|
||||
4
.github/workflows/superset-playwright.yml
vendored
4
.github/workflows/superset-playwright.yml
vendored
@@ -25,6 +25,7 @@ concurrency:
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
@@ -48,6 +49,7 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.python == 'true' || needs.changes.outputs.frontend == 'true'
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 30
|
||||
continue-on-error: true
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -115,6 +117,8 @@ jobs:
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version-file: './superset-frontend/.nvmrc'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'superset-frontend/package-lock.json'
|
||||
- name: Install npm dependencies
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
|
||||
@@ -16,6 +16,7 @@ concurrency:
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
@@ -36,6 +37,7 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.python == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
id-token: write
|
||||
env:
|
||||
@@ -121,11 +123,14 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.python == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
id-token: write
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["current", "previous", "next"]
|
||||
# Full version spread on push (master/release) + nightly; current only
|
||||
# on PRs to cut runner cost (cross-version breaks are caught at merge).
|
||||
python-version: ${{ github.event_name == 'pull_request' && fromJSON('["current"]') || fromJSON('["current", "previous", "next"]') }}
|
||||
env:
|
||||
PYTHONPATH: ${{ github.workspace }}
|
||||
SUPERSET_CONFIG: tests.integration_tests.superset_test_config
|
||||
@@ -179,6 +184,7 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.python == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
id-token: write
|
||||
env:
|
||||
@@ -222,3 +228,25 @@ jobs:
|
||||
verbose: true
|
||||
use_oidc: true
|
||||
slug: apache/superset
|
||||
|
||||
# Stable required-status-check anchor for the matrix-based test-postgres job.
|
||||
# It is gated on change detection, so on non-Python PRs it is skipped and
|
||||
# never produces its `test-postgres (current)` context (a job-level skip
|
||||
# happens before matrix expansion). This always-running job reports a single
|
||||
# context branch protection can require: it passes when test-postgres
|
||||
# succeeded or was skipped, and fails only on a real failure.
|
||||
test-postgres-required:
|
||||
needs: [changes, test-postgres]
|
||||
if: always()
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check test-postgres result
|
||||
env:
|
||||
RESULT: ${{ needs.test-postgres.result }}
|
||||
run: |
|
||||
if [ "$RESULT" != "success" ] && [ "$RESULT" != "skipped" ]; then
|
||||
echo "test-postgres did not pass (result: $RESULT)"
|
||||
exit 1
|
||||
fi
|
||||
echo "test-postgres result: $RESULT"
|
||||
|
||||
@@ -17,6 +17,7 @@ concurrency:
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
@@ -37,6 +38,7 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.python == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
id-token: write
|
||||
env:
|
||||
@@ -99,6 +101,7 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.python == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
id-token: write
|
||||
env:
|
||||
|
||||
28
.github/workflows/superset-python-unittest.yml
vendored
28
.github/workflows/superset-python-unittest.yml
vendored
@@ -17,6 +17,7 @@ concurrency:
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
@@ -37,11 +38,14 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.python == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
id-token: write
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["previous", "current", "next"]
|
||||
# Full version spread on push (master/release) + nightly; current only
|
||||
# on PRs to cut runner cost (cross-version breaks are caught at merge).
|
||||
python-version: ${{ github.event_name == 'pull_request' && fromJSON('["current"]') || fromJSON('["previous", "current", "next"]') }}
|
||||
env:
|
||||
PYTHONPATH: ${{ github.workspace }}
|
||||
steps:
|
||||
@@ -74,3 +78,25 @@ jobs:
|
||||
verbose: true
|
||||
use_oidc: true
|
||||
slug: apache/superset
|
||||
|
||||
# Stable required-status-check anchor. `unit-tests` is a matrix job gated on
|
||||
# change detection, so on non-Python PRs it is skipped and never produces its
|
||||
# `unit-tests (current)` context (a job-level skip happens before matrix
|
||||
# expansion). This always-running job reports a single context that branch
|
||||
# protection can require: it passes when unit-tests succeeded or was skipped,
|
||||
# and fails only on a real failure.
|
||||
unit-tests-required:
|
||||
needs: [changes, unit-tests]
|
||||
if: always()
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check unit-tests result
|
||||
env:
|
||||
RESULT: ${{ needs.unit-tests.result }}
|
||||
run: |
|
||||
if [ "$RESULT" != "success" ] && [ "$RESULT" != "skipped" ]; then
|
||||
echo "unit-tests did not pass (result: $RESULT)"
|
||||
exit 1
|
||||
fi
|
||||
echo "unit-tests result: $RESULT"
|
||||
|
||||
2
.github/workflows/superset-translations.yml
vendored
2
.github/workflows/superset-translations.yml
vendored
@@ -41,6 +41,8 @@ jobs:
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version-file: './superset-frontend/.nvmrc'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'superset-frontend/package-lock.json'
|
||||
- name: Install dependencies
|
||||
if: steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
|
||||
1
.github/workflows/superset-websocket.yml
vendored
1
.github/workflows/superset-websocket.yml
vendored
@@ -22,6 +22,7 @@ concurrency:
|
||||
jobs:
|
||||
app-checks:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
@@ -189,6 +189,11 @@ Try out Superset's [quickstart](https://superset.apache.org/docs/quickstart/) gu
|
||||
- [Join our community's Slack](http://bit.ly/join-superset-slack)
|
||||
and please read our [Slack Community Guidelines](https://github.com/apache/superset/blob/master/CODE_OF_CONDUCT.md#slack-community-guidelines)
|
||||
- [Join our dev@superset.apache.org Mailing list](https://lists.apache.org/list.html?dev@superset.apache.org). To join, simply send an email to [dev-subscribe@superset.apache.org](mailto:dev-subscribe@superset.apache.org)
|
||||
- Follow us on social media:
|
||||
[X](https://x.com/apachesuperset) |
|
||||
[LinkedIn](https://www.linkedin.com/company/apache-superset) |
|
||||
[Bluesky](https://bsky.app/profile/apachesuperset.bsky.social) |
|
||||
[Reddit](https://reddit.com/r/apache-superset)
|
||||
- If you want to help troubleshoot GitHub Issues involving the numerous database drivers that Superset supports, please consider adding your name and the databases you have access to on the [Superset Database Familiarity Rolodex](https://docs.google.com/spreadsheets/d/1U1qxiLvOX0kBTUGME1AHHi6Ywel6ECF8xk_Qy-V9R8c/edit#gid=0)
|
||||
- Join Superset's Town Hall and [Operational Model](https://preset.io/blog/the-superset-operational-model-wants-you/) recurring meetings. Meeting info is available on the [Superset Community Calendar](https://superset.apache.org/community)
|
||||
|
||||
|
||||
23
UPDATING.md
23
UPDATING.md
@@ -24,6 +24,16 @@ assists people when migrating to a new version.
|
||||
|
||||
## Next
|
||||
|
||||
### Duration formatter precision
|
||||
|
||||
The `DURATION` number formatter now uses `Intl.DurationFormat` for locale-aware output. By default, sub-second fields are omitted, so values that previously displayed fractional seconds with `pretty-ms`, such as `10500` milliseconds rendering as `10.5s`, now render as `10s`.
|
||||
|
||||
To preserve sub-second precision in custom duration formatters, enable `formatSubMilliseconds`.
|
||||
|
||||
### Cache warmup authenticates via SUPERSET_CACHE_WARMUP_USER
|
||||
|
||||
The `cache-warmup` Celery task now drives a real WebDriver session for reliable authentication and reads the user to authenticate as from the new `SUPERSET_CACHE_WARMUP_USER` config option. It no longer consults `CACHE_WARMUP_EXECUTORS` for the warmup path. `SUPERSET_CACHE_WARMUP_USER` defaults to `None`, so the task fails fast with a clear message until you set it. Operators who previously relied on `CACHE_WARMUP_EXECUTORS` for cache warmup must set `SUPERSET_CACHE_WARMUP_USER` to a dedicated least-privilege user with access to the dashboards they want warmed up before the next warmup run.
|
||||
|
||||
### 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.
|
||||
@@ -40,6 +50,19 @@ Importing a dataset now validates the `catalog` field against the target databas
|
||||
|
||||
If you relied on importing datasets with a non-default catalog, enable "Allow changing catalogs" on the target connection, or set the dataset's catalog to the connection's default before importing.
|
||||
|
||||
### Extension supply-chain controls (denylist + version policy)
|
||||
|
||||
Two opt-in static gates control which extensions are allowed to load:
|
||||
|
||||
- `EXTENSION_DENYLIST` refuses extensions matching an id (every version) or `id@version` (a single version), e.g. `["compromised-extension", "other-ext@1.2.3"]`.
|
||||
- `EXTENSION_VERSION_POLICY` enforces a minimum version per extension id, e.g. `{"acme.widget": "1.2.0"}` (PEP 440 comparison); a release below the minimum is refused.
|
||||
|
||||
Both default to empty (no behavior change). They apply to both the `LOCAL_EXTENSIONS` and `EXTENSIONS_PATH` load paths.
|
||||
|
||||
### Dynamic Group By respects the sort toggle for display values
|
||||
|
||||
The Dynamic Group By chart customization now orders its display values according to the "Sort display control values" toggle: ascending (A–Z), descending (Z–A), or the dataset's source order when the toggle is unset. Previously the dropdown always sorted alphabetically. Existing dashboards where the toggle was never set will show options in source order instead of A–Z; open the customization and enable the toggle to restore alphabetical ordering.
|
||||
|
||||
### Granular Export Controls
|
||||
|
||||
A new feature flag `GRANULAR_EXPORT_CONTROLS` introduces three fine-grained permissions that replace the legacy `can_csv` permission:
|
||||
|
||||
@@ -61,6 +61,31 @@ services:
|
||||
volumes:
|
||||
- ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./docker/nginx/templates:/etc/nginx/templates:ro
|
||||
# Wait for the webpack dev server's manifest.json to be served before
|
||||
# starting nginx. This prevents 404s on static assets at startup. The
|
||||
# probe targets host.docker.internal so it works regardless of whether
|
||||
# the dev server runs in the superset-node container
|
||||
# (BUILD_SUPERSET_FRONTEND_IN_DOCKER=true, the default) or directly on
|
||||
# the host (BUILD_SUPERSET_FRONTEND_IN_DOCKER=false).
|
||||
command:
|
||||
- /bin/bash
|
||||
- -c
|
||||
- |
|
||||
url="http://host.docker.internal:9000/static/assets/manifest.json"
|
||||
max_attempts=150 # ~5 minutes at 2s intervals
|
||||
echo "Waiting for webpack dev server at $url..."
|
||||
attempt=0
|
||||
until curl -sf --max-time 5 -o /dev/null "$url"; do
|
||||
attempt=$((attempt + 1))
|
||||
if [ "$attempt" -ge "$max_attempts" ]; then
|
||||
echo "ERROR: webpack dev server did not serve $url after $max_attempts attempts (~5 minutes)." >&2
|
||||
echo "Is the dev server running? With BUILD_SUPERSET_FRONTEND_IN_DOCKER=false you must start it on the host (e.g. 'npm run dev' in superset-frontend)." >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "Webpack dev server is ready; starting nginx."
|
||||
exec nginx -g 'daemon off;'
|
||||
|
||||
redis:
|
||||
image: redis:7
|
||||
|
||||
@@ -86,6 +86,39 @@ instead requires a cachelib object.
|
||||
|
||||
See [Async Queries via Celery](/admin-docs/configuration/async-queries-celery) for details.
|
||||
|
||||
## Celery beat
|
||||
|
||||
Superset has a Celery task that will periodically warm up the cache based on different strategies.
|
||||
To use it, add the following to your `superset_config.py`:
|
||||
|
||||
```python
|
||||
from celery.schedules import crontab
|
||||
from superset.config import CeleryConfig
|
||||
|
||||
# User that will be used to authenticate and render dashboards for cache warmup
|
||||
SUPERSET_CACHE_WARMUP_USER = "user_with_permission_to_dashboards"
|
||||
|
||||
# Extend the default CeleryConfig to add cache warmup schedule
|
||||
class CustomCeleryConfig(CeleryConfig):
|
||||
beat_schedule = {
|
||||
**CeleryConfig.beat_schedule,
|
||||
'cache-warmup-hourly': {
|
||||
'task': 'cache-warmup',
|
||||
'schedule': crontab(minute=0, hour='*'), # hourly
|
||||
'kwargs': {
|
||||
'strategy_name': 'top_n_dashboards',
|
||||
'top_n': 5,
|
||||
'since': '7 days ago',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
CELERY_CONFIG = CustomCeleryConfig
|
||||
```
|
||||
|
||||
This will cache the top 5 most popular dashboards every hour. For other
|
||||
strategies, check the `superset/tasks/cache.py` file.
|
||||
|
||||
## Caching Thumbnails
|
||||
|
||||
This is an optional feature that can be turned on by activating its [feature flag](/admin-docs/configuration/configuring-superset#feature-flags) on config:
|
||||
|
||||
@@ -917,6 +917,23 @@ const config: Config = {
|
||||
footer: {
|
||||
links: [],
|
||||
copyright: `
|
||||
<div class="footer__social-links">
|
||||
<a href="https://bit.ly/join-superset-slack" target="_blank" rel="noopener noreferrer" title="Join us on Slack" aria-label="Slack">
|
||||
<img src="/img/community/slack-symbol.svg" alt="Slack" />
|
||||
</a>
|
||||
<a href="https://x.com/apachesuperset" target="_blank" rel="noopener noreferrer" title="Follow us on X" aria-label="X">
|
||||
<img src="/img/community/x-symbol.svg" alt="X" />
|
||||
</a>
|
||||
<a href="https://www.linkedin.com/company/apache-superset" target="_blank" rel="noopener noreferrer" title="Follow us on LinkedIn" aria-label="LinkedIn">
|
||||
<img src="/img/community/linkedin-symbol.svg" alt="LinkedIn" />
|
||||
</a>
|
||||
<a href="https://bsky.app/profile/apachesuperset.bsky.social" target="_blank" rel="noopener noreferrer" title="Follow us on Bluesky" aria-label="Bluesky">
|
||||
<img src="/img/community/bluesky-symbol.svg" alt="Bluesky" />
|
||||
</a>
|
||||
<a href="https://reddit.com/r/apache-superset" target="_blank" rel="noopener noreferrer" title="Follow us on Reddit" aria-label="Reddit">
|
||||
<img src="/img/community/reddit-symbol.svg" alt="Reddit" />
|
||||
</a>
|
||||
</div>
|
||||
<div class="footer__ci-services">
|
||||
<span>CI powered by</span>
|
||||
<a href="https://www.netlify.com/" target="_blank" rel="nofollow noopener noreferrer"><img src="/img/netlify.png" alt="Netlify" title="Netlify - Deploy Previews" /></a>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"copyright": {
|
||||
"message": "\n <div class=\"footer__ci-services\">\n <span>CI powered by</span>\n <a href=\"https://www.netlify.com/\" target=\"_blank\" rel=\"nofollow noopener noreferrer\"><img src=\"/img/netlify.png\" alt=\"Netlify\" title=\"Netlify - Deploy Previews\" /></a>\n </div>\n <p>Copyright © 2026,\n The <a href=\"https://www.apache.org/\" target=\"_blank\" rel=\"noreferrer\">Apache Software Foundation</a>,\n Licensed under the Apache <a href=\"https://apache.org/licenses/LICENSE-2.0\" target=\"_blank\" rel=\"noreferrer\">License</a>.</p>\n <p><small>Apache Superset, Apache, Superset, the Superset logo, and the Apache feather logo are either registered trademarks or trademarks of The Apache Software Foundation. All other products or name brands are trademarks of their respective holders, including The Apache Software Foundation.\n <a href=\"https://www.apache.org/\" target=\"_blank\">Apache Software Foundation</a> resources</small></p>\n <img class=\"footer__divider\" src=\"/img/community/line.png\" alt=\"Divider\" />\n <p>\n <small>\n <a href=\"/docs/security/\" target=\"_blank\" rel=\"noreferrer\">Security</a> | \n <a href=\"https://www.apache.org/foundation/sponsorship.html\" target=\"_blank\" rel=\"noreferrer\">Donate</a> | \n <a href=\"https://www.apache.org/foundation/thanks.html\" target=\"_blank\" rel=\"noreferrer\">Thanks</a> | \n <a href=\"https://apache.org/events/current-event\" target=\"_blank\" rel=\"noreferrer\">Events</a> | \n <a href=\"https://apache.org/licenses/\" target=\"_blank\" rel=\"noreferrer\">License</a> | \n <a href=\"https://privacy.apache.org/policies/privacy-policy-public.html\" target=\"_blank\" rel=\"noreferrer\">Privacy</a>\n </small>\n </p>\n <!-- telemetry/analytics pixel: -->\n <img referrerPolicy=\"no-referrer-when-downgrade\" src=\"https://static.scarf.sh/a.png?x-pxid=39ae6855-95fc-4566-86e5-360d542b0a68\" />\n ",
|
||||
"message": "\n <div class=\"footer__social-links\">\n <a href=\"https://bit.ly/join-superset-slack\" target=\"_blank\" rel=\"noopener noreferrer\" title=\"Join us on Slack\" aria-label=\"Slack\">\n <img src=\"/img/community/slack-symbol.svg\" alt=\"Slack\" />\n </a>\n <a href=\"https://x.com/apachesuperset\" target=\"_blank\" rel=\"noopener noreferrer\" title=\"Follow us on X\" aria-label=\"X\">\n <img src=\"/img/community/x-symbol.svg\" alt=\"X\" />\n </a>\n <a href=\"https://www.linkedin.com/company/apache-superset\" target=\"_blank\" rel=\"noopener noreferrer\" title=\"Follow us on LinkedIn\" aria-label=\"LinkedIn\">\n <img src=\"/img/community/linkedin-symbol.svg\" alt=\"LinkedIn\" />\n </a>\n <a href=\"https://bsky.app/profile/apachesuperset.bsky.social\" target=\"_blank\" rel=\"noopener noreferrer\" title=\"Follow us on Bluesky\" aria-label=\"Bluesky\">\n <img src=\"/img/community/bluesky-symbol.svg\" alt=\"Bluesky\" />\n </a>\n <a href=\"https://reddit.com/r/apache-superset\" target=\"_blank\" rel=\"noopener noreferrer\" title=\"Follow us on Reddit\" aria-label=\"Reddit\">\n <img src=\"/img/community/reddit-symbol.svg\" alt=\"Reddit\" />\n </a>\n </div>\n <div class=\"footer__ci-services\">\n <span>CI powered by</span>\n <a href=\"https://www.netlify.com/\" target=\"_blank\" rel=\"nofollow noopener noreferrer\"><img src=\"/img/netlify.png\" alt=\"Netlify\" title=\"Netlify - Deploy Previews\" /></a>\n </div>\n <p>Copyright © 2026,\n The <a href=\"https://www.apache.org/\" target=\"_blank\" rel=\"noreferrer\">Apache Software Foundation</a>,\n Licensed under the Apache <a href=\"https://apache.org/licenses/LICENSE-2.0\" target=\"_blank\" rel=\"noreferrer\">License</a>.</p>\n <p><small>Apache Superset, Apache, Superset, the Superset logo, and the Apache feather logo are either registered trademarks or trademarks of The Apache Software Foundation. All other products or name brands are trademarks of their respective holders, including The Apache Software Foundation.\n <a href=\"https://www.apache.org/\" target=\"_blank\">Apache Software Foundation</a> resources</small></p>\n <img class=\"footer__divider\" src=\"/img/community/line.png\" alt=\"Divider\" />\n <p>\n <small>\n <a href=\"/admin-docs/security/\" target=\"_blank\" rel=\"noreferrer\">Security</a> | \n <a href=\"https://www.apache.org/foundation/sponsorship.html\" target=\"_blank\" rel=\"noreferrer\">Donate</a> | \n <a href=\"https://www.apache.org/foundation/thanks.html\" target=\"_blank\" rel=\"noreferrer\">Thanks</a> | \n <a href=\"https://apache.org/events/current-event\" target=\"_blank\" rel=\"noreferrer\">Events</a> | \n <a href=\"https://apache.org/licenses/\" target=\"_blank\" rel=\"noreferrer\">License</a> | \n <a href=\"https://privacy.apache.org/policies/privacy-policy-public.html\" target=\"_blank\" rel=\"noreferrer\">Privacy</a>\n </small>\n </p>\n <!-- telemetry/analytics pixel: -->\n <img referrerPolicy=\"no-referrer-when-downgrade\" src=\"https://static.scarf.sh/a.png?x-pxid=39ae6855-95fc-4566-86e5-360d542b0a68\" />\n ",
|
||||
"description": "The footer copyright"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"version:remove:components": "node scripts/manage-versions.mjs remove components"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.2.3",
|
||||
"@ant-design/icons": "^6.2.5",
|
||||
"@docusaurus/core": "^3.10.1",
|
||||
"@docusaurus/faster": "^3.10.1",
|
||||
"@docusaurus/plugin-client-redirects": "^3.10.1",
|
||||
@@ -104,7 +104,7 @@
|
||||
"@typescript-eslint/parser": "^8.60.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"globals": "^17.6.0",
|
||||
"prettier": "^3.8.3",
|
||||
|
||||
@@ -260,10 +260,39 @@ a > span > svg {
|
||||
|
||||
.footer {
|
||||
position: relative;
|
||||
padding-top: 90px;
|
||||
padding-top: 130px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.footer__social-links {
|
||||
background-color: #173036;
|
||||
position: absolute;
|
||||
top: 52px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
padding: 10px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.footer__social-links a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.footer__social-links a:hover {
|
||||
opacity: 0.8;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.footer__social-links img {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
.footer__ci-services {
|
||||
background-color: #0d3e49;
|
||||
color: #e1e1e1;
|
||||
@@ -309,6 +338,21 @@ a > span > svg {
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 996px) {
|
||||
.footer {
|
||||
padding-top: 120px;
|
||||
}
|
||||
|
||||
.footer__social-links {
|
||||
top: 44px;
|
||||
gap: 20px;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.footer__social-links img {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.footer__ci-services {
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
|
||||
21
docs/static/img/community/reddit-symbol.svg
vendored
Normal file
21
docs/static/img/community/reddit-symbol.svg
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="40" height="40" fill="#FF4500">
|
||||
<path d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12c-.688 0-1.25.561-1.25 1.25 0 .687.562 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 0 0-.232-.095z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
21
docs/static/img/community/slack-symbol.svg
vendored
Normal file
21
docs/static/img/community/slack-symbol.svg
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="40" height="40" fill="#4A154B">
|
||||
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.124 2.521a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.52 2.521h-2.522V8.834zm-1.271 0a2.528 2.528 0 0 1-2.521 2.521 2.528 2.528 0 0 1-2.521-2.521V2.522A2.528 2.528 0 0 1 15.166 0a2.528 2.528 0 0 1 2.521 2.522v6.312zm-2.521 10.124a2.528 2.528 0 0 1 2.521 2.522A2.528 2.528 0 0 1 15.166 24a2.528 2.528 0 0 1-2.521-2.52v-2.522h2.521zm0-1.271a2.528 2.528 0 0 1-2.521-2.521 2.528 2.528 0 0 1 2.521-2.521h6.312A2.528 2.528 0 0 1 24 15.165a2.528 2.528 0 0 1-2.52 2.521h-6.313z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -212,14 +212,14 @@
|
||||
resolved "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz"
|
||||
integrity sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==
|
||||
|
||||
"@ant-design/icons@^6.2.3":
|
||||
version "6.2.3"
|
||||
resolved "https://registry.yarnpkg.com/@ant-design/icons/-/icons-6.2.3.tgz#66e1c7fdea009b9c3fab6964062bedc76f308ad8"
|
||||
integrity sha512-Pl3aoAtxQeKryYnt6VvDJtOxMOtA8wrRSACe/pTjOAIG3fdHrWm6Ivb4ku9tsFjYroSXBKirvuxG4QkwBXD9gg==
|
||||
"@ant-design/icons@^6.2.3", "@ant-design/icons@^6.2.5":
|
||||
version "6.2.5"
|
||||
resolved "https://registry.yarnpkg.com/@ant-design/icons/-/icons-6.2.5.tgz#31c142aa6ce5eaf99598aaead222f4c459693512"
|
||||
integrity sha512-0hKtoKqTjGFOndUyJLJmC9Cg6k4rEO7rLo6xmgbNJH+/ZX1C57RVals2v1j1knHl9n7Q+sBOveTvn931wLOCKw==
|
||||
dependencies:
|
||||
"@ant-design/colors" "^8.0.1"
|
||||
"@ant-design/icons-svg" "^4.4.2"
|
||||
"@rc-component/util" "^1.10.1"
|
||||
"@rc-component/util" "^1.11.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@ant-design/react-slick@~2.0.0":
|
||||
@@ -3021,10 +3021,10 @@
|
||||
os-homedir "^1.0.1"
|
||||
regexpu-core "^4.5.4"
|
||||
|
||||
"@pkgr/core@^0.2.9":
|
||||
version "0.2.9"
|
||||
resolved "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz"
|
||||
integrity sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==
|
||||
"@pkgr/core@^0.3.6":
|
||||
version "0.3.6"
|
||||
resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.3.6.tgz#3569708bd4be4d8870ba32bf1c456dac81600d97"
|
||||
integrity sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==
|
||||
|
||||
"@pnpm/config.env-replace@^1.1.0":
|
||||
version "1.1.0"
|
||||
@@ -7522,13 +7522,13 @@ eslint-config-prettier@^10.1.8:
|
||||
resolved "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz"
|
||||
integrity sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==
|
||||
|
||||
eslint-plugin-prettier@^5.5.5:
|
||||
version "5.5.5"
|
||||
resolved "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz"
|
||||
integrity sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==
|
||||
eslint-plugin-prettier@^5.5.6:
|
||||
version "5.5.6"
|
||||
resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.6.tgz#363ebe4d769bce157ccdd8129ce3efd91dc62564"
|
||||
integrity sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==
|
||||
dependencies:
|
||||
prettier-linter-helpers "^1.0.1"
|
||||
synckit "^0.11.12"
|
||||
synckit "^0.11.13"
|
||||
|
||||
eslint-plugin-react@^7.37.5:
|
||||
version "7.37.5"
|
||||
@@ -14096,12 +14096,12 @@ swc-loader@^0.2.6, swc-loader@^0.2.7:
|
||||
dependencies:
|
||||
"@swc/counter" "^0.1.3"
|
||||
|
||||
synckit@^0.11.12:
|
||||
version "0.11.12"
|
||||
resolved "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz"
|
||||
integrity sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==
|
||||
synckit@^0.11.13:
|
||||
version "0.11.13"
|
||||
resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.11.13.tgz#062a5ea57d81befc35892f8254de5c567e97c80a"
|
||||
integrity sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==
|
||||
dependencies:
|
||||
"@pkgr/core" "^0.2.9"
|
||||
"@pkgr/core" "^0.3.6"
|
||||
|
||||
tapable@^2.0.0, tapable@^2.2.1, tapable@^2.3.0, tapable@^2.3.3:
|
||||
version "2.3.3"
|
||||
|
||||
@@ -69,7 +69,7 @@ module.exports = {
|
||||
],
|
||||
coverageReporters: ['lcov', 'json-summary', 'html', 'text'],
|
||||
transformIgnorePatterns: [
|
||||
'node_modules/(?!d3-(array|interpolate|color|time|scale|time-format|format)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|(?!geostyler)lodash|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect|react-diff-viewer-continued)',
|
||||
'node_modules/(?!@formatjs/.*|d3-(array|interpolate|color|time|scale|time-format|format)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|(?!geostyler)lodash|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect|react-diff-viewer-continued)',
|
||||
],
|
||||
preset: 'ts-jest',
|
||||
transform: {
|
||||
|
||||
449
superset-frontend/package-lock.json
generated
449
superset-frontend/package-lock.json
generated
@@ -86,10 +86,10 @@
|
||||
"antd": "^5.26.0",
|
||||
"chrono-node": "^2.9.1",
|
||||
"classnames": "^2.2.5",
|
||||
"content-disposition": "^2.0.0",
|
||||
"content-disposition": "^2.0.1",
|
||||
"d3-color": "^3.1.0",
|
||||
"d3-scale": "^4.0.2",
|
||||
"dayjs": "^1.11.20",
|
||||
"dayjs": "^1.11.21",
|
||||
"dom-to-image-more": "^3.7.2",
|
||||
"dom-to-pdf": "^0.3.2",
|
||||
"echarts": "^5.6.0",
|
||||
@@ -118,8 +118,7 @@
|
||||
"mustache": "^4.2.0",
|
||||
"nanoid": "^5.1.11",
|
||||
"ol": "^10.9.0",
|
||||
"pretty-ms": "^9.3.0",
|
||||
"query-string": "9.3.1",
|
||||
"query-string": "9.4.0",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react": "^18.2.0",
|
||||
"react-arborist": "^3.8.0",
|
||||
@@ -163,9 +162,9 @@
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.29.7",
|
||||
"@babel/compat-data": "^7.28.4",
|
||||
"@babel/core": "^7.29.0",
|
||||
"@babel/core": "^7.29.7",
|
||||
"@babel/eslint-parser": "^7.29.7",
|
||||
"@babel/node": "^7.29.0",
|
||||
"@babel/node": "^7.29.7",
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
|
||||
"@babel/plugin-transform-export-namespace-from": "^7.29.7",
|
||||
"@babel/plugin-transform-modules-commonjs": "^7.29.7",
|
||||
@@ -173,12 +172,13 @@
|
||||
"@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/register": "^7.29.7",
|
||||
"@babel/runtime": "^7.29.7",
|
||||
"@babel/runtime-corejs3": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
"@emotion/jest": "^11.14.2",
|
||||
"@formatjs/intl-durationformat": "^0.10.3",
|
||||
"@istanbuljs/nyc-config-typescript": "^1.0.1",
|
||||
"@mihkeleidast/storybook-addon-source": "^1.0.1",
|
||||
"@playwright/test": "^1.60.0",
|
||||
@@ -248,9 +248,9 @@
|
||||
"eslint-plugin-jest-dom": "^5.5.0",
|
||||
"eslint-plugin-lodash": "^7.4.0",
|
||||
"eslint-plugin-no-only-tests": "^3.4.0",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"eslint-plugin-react-prefer-function-component": "^5.0.0",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.2",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.4",
|
||||
"eslint-plugin-storybook": "^0.8.0",
|
||||
"eslint-plugin-testing-library": "^7.16.2",
|
||||
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
|
||||
@@ -270,7 +270,7 @@
|
||||
"lightningcss": "^1.32.0",
|
||||
"mini-css-extract-plugin": "^2.10.2",
|
||||
"open-cli": "^9.0.0",
|
||||
"oxlint": "^1.66.0",
|
||||
"oxlint": "^1.67.0",
|
||||
"po2json": "^0.4.5",
|
||||
"prettier": "3.8.3",
|
||||
"prettier-plugin-packagejson": "^3.0.2",
|
||||
@@ -284,7 +284,7 @@
|
||||
"storybook": "8.6.18",
|
||||
"style-loader": "^4.0.0",
|
||||
"swc-loader": "^0.2.7",
|
||||
"terser-webpack-plugin": "^5.6.0",
|
||||
"terser-webpack-plugin": "^5.6.1",
|
||||
"ts-jest": "^29.4.11",
|
||||
"tscw-config": "^1.1.2",
|
||||
"tsx": "^4.22.3",
|
||||
@@ -292,7 +292,7 @@
|
||||
"unzipper": "^0.12.3",
|
||||
"vm-browserify": "^1.1.2",
|
||||
"wait-on": "^9.0.10",
|
||||
"webpack": "^5.107.1",
|
||||
"webpack": "^5.107.2",
|
||||
"webpack-bundle-analyzer": "^5.3.0",
|
||||
"webpack-cli": "^6.0.1",
|
||||
"webpack-dev-server": "^5.2.4",
|
||||
@@ -593,21 +593,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/core": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
"@babel/helper-compilation-targets": "^7.28.6",
|
||||
"@babel/helper-module-transforms": "^7.28.6",
|
||||
"@babel/helpers": "^7.28.6",
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/traverse": "^7.29.0",
|
||||
"@babel/types": "^7.29.0",
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
"@babel/helper-compilation-targets": "^7.29.7",
|
||||
"@babel/helper-module-transforms": "^7.29.7",
|
||||
"@babel/helpers": "^7.29.7",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/template": "^7.29.7",
|
||||
"@babel/traverse": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"debug": "^4.1.0",
|
||||
@@ -966,27 +966,27 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helpers": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz",
|
||||
"integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
|
||||
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/types": "^7.28.6"
|
||||
"@babel/template": "^7.29.7",
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/node": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/node/-/node-7.29.0.tgz",
|
||||
"integrity": "sha512-9UeU8F3rx2lOZXneEW2HTnTYdA8+fXP0kr54tk7d0fPomWNlZ6WJ2H9lunr5dSvr8FNY0CDnop3Km6jZ5NAUsQ==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/node/-/node-7.29.7.tgz",
|
||||
"integrity": "sha512-nfdPXz8/mD3/t+1nE1DKwGR14Ccjt5xeF7u3g7sqWnLi4yR6n+9Z0kThIROF8SRM07ZKpEtiWSKpWKxsMiJeew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/register": "^7.28.6",
|
||||
"@babel/register": "^7.29.7",
|
||||
"commander": "^6.2.0",
|
||||
"core-js": "^3.48.0",
|
||||
"node-environment-flags": "^1.0.5",
|
||||
@@ -2576,9 +2576,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/register": {
|
||||
"version": "7.29.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/register/-/register-7.29.3.tgz",
|
||||
"integrity": "sha512-F6C1KpIdoImKQfsD6HSxZ+mS4YY/2Q+JsqrmTC5ApVkTR2rG+nnbpjhWwzA5bDNu8mJjB3AryqDaWFLd4gCbJQ==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/register/-/register-7.29.7.tgz",
|
||||
"integrity": "sha512-AMGJoWuES861riy6pcB0fphE1YXybtQnBYQMuIyPv6mKLiosfa79BKTnAOyx215c/3RJPJpdQwoHZ3earVH7AA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2596,18 +2596,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime-corejs3": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.29.2.tgz",
|
||||
"integrity": "sha512-Lc94FOD5+0aXhdb0Tdg3RUtqT6yWbI/BbFWvlaSJ3gAb9Ks+99nHRDKADVqC37er4eCB0fHyWT+y+K3QOvJKbw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.29.7.tgz",
|
||||
"integrity": "sha512-ppj9ouYku+RX0ljtgZd+KMO5mkM2bCqg8H2PYAFWnLsHEIKIdRojqbJ2i3eVHrisuxy7nOFCmngTDdWtUCdXUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3954,6 +3954,53 @@
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@formatjs/bigdecimal": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/bigdecimal/-/bigdecimal-0.2.0.tgz",
|
||||
"integrity": "sha512-GeaxHZbUoYvHL9tC5eltHLs+1zU70aPw0s7LwqgktIzF5oMhNY4o4deEtusJMsq7WFJF3Ye2zQEzdG8beVk73w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@formatjs/ecma402-abstract": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-3.2.0.tgz",
|
||||
"integrity": "sha512-dHnqHgBo6GXYGRsepaE1wmsC2etaivOWd5VaJstZd+HI2zR3DCUjbDVZRtoPGkkXZmyHvBwrdEUuqfvzhF/DtQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@formatjs/bigdecimal": "0.2.0",
|
||||
"@formatjs/fast-memoize": "3.1.1",
|
||||
"@formatjs/intl-localematcher": "0.8.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@formatjs/fast-memoize": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.1.tgz",
|
||||
"integrity": "sha512-CbNbf+tlJn1baRnPkNePnBqTLxGliG6DDgNa/UtV66abwIjwsliPMOt0172tzxABYzSuxZBZfcp//qI8AvBWPg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@formatjs/intl-durationformat": {
|
||||
"version": "0.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/intl-durationformat/-/intl-durationformat-0.10.3.tgz",
|
||||
"integrity": "sha512-xRS3GaOlsQLwz0n56SvaddwEnl2NLPKBvYg2M32ak/27dodmVxFJz3j7Nqj7EwKyHTu3f/e+BeoKPrIDUSXTuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@formatjs/ecma402-abstract": "3.2.0",
|
||||
"@formatjs/intl-localematcher": "0.8.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@formatjs/intl-localematcher": {
|
||||
"version": "0.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.8.2.tgz",
|
||||
"integrity": "sha512-q05KMYGJLyqFNFtIb8NhWLF5X3aK/k0wYt7dnRFuy6aLQL+vUwQ1cg5cO4qawEiINybeCPXAWlprY2mSBjSXAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@formatjs/fast-memoize": "3.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@gar/promise-retry": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@gar/promise-retry/-/promise-retry-1.0.3.tgz",
|
||||
@@ -8653,9 +8700,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@oxlint/binding-android-arm-eabi": {
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.66.0.tgz",
|
||||
"integrity": "sha512-f7kq8N51T4phpzqfBpA2qaVTI/KrkCmNwaj3t/97I/WLTDI+UhlP5GL9eER+zVxBhtlx5rKXWByJU1/zDAvyaw==",
|
||||
"version": "1.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.67.0.tgz",
|
||||
"integrity": "sha512-VrSi571rDv1N8HaEDM+DEX8nmT0y9jJo8tzzW13vsOWTx59xQczCIJx68n2zWOXRT5YKZsOZXp4qkHN/10x4mw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -8670,9 +8717,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-android-arm64": {
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.66.0.tgz",
|
||||
"integrity": "sha512-xu6QO71tdDS9mjmLZ3AqhtaVHBvdmsOKkYnReNNDgh+XiwnsipeQOIxbiYOOO0iAXycJ+GK0wdMSZP/2j/AmSg==",
|
||||
"version": "1.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.67.0.tgz",
|
||||
"integrity": "sha512-l6+NdYxMoRohix5r5bbigW16LPicceCwGcQ6LKKuE1kUdjgFfQolJjrJsQYPFetIs78Gxj/G/f5TEGoTCwj9nQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -8687,9 +8734,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-darwin-arm64": {
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.66.0.tgz",
|
||||
"integrity": "sha512-HZ24VimSOC7mxuEA99e0H2FS0C1yO3+iW13jPRAk+e2njsUs3QeAXsafCDyaIrV/MirdOVez+etQNQsJE43zNQ==",
|
||||
"version": "1.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.67.0.tgz",
|
||||
"integrity": "sha512-jOzXxS1AxFxhImLIRbtGIMrEwaXcgMw3gR57WB1cRk8ai+vpr6726kxXqVvlNsrXtJ/FrmOm8RxlC0m8SW24Qg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -8704,9 +8751,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-darwin-x64": {
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.66.0.tgz",
|
||||
"integrity": "sha512-awhj8ZvJrrRSnXj7V++rpZvTmnl99L6mi0B7gg7Cp7BN6cKpzuI481bHNLvXGA9GB1/oEgA3ponuyoAc6Md12A==",
|
||||
"version": "1.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.67.0.tgz",
|
||||
"integrity": "sha512-3DFAVY94OqjIZHXIPz37yGRSWwOFTAqChQ64/M69GYLawzP0KiwdhDNfqdKKYT0bTR/DNxmMnQsj3ns+8+X/Lg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -8721,9 +8768,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-freebsd-x64": {
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.66.0.tgz",
|
||||
"integrity": "sha512-KQF0oVV21/FjIqkRuL8Q1vh8ECsE5+ocdH5tcqTQ4ZnYuDVoYibQUNfqBjQaUsP6UIIda5Y75Wpm5p4RgQWiWw==",
|
||||
"version": "1.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.67.0.tgz",
|
||||
"integrity": "sha512-e4dDKZuLu8TR9DEBssWSDahlPgZBwojTTHZUvnjBRJfJJbpxYCjfjKfi0Z1+CSLMiJBwI2yCDtRM1XJQaARjmg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -8738,9 +8785,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.66.0.tgz",
|
||||
"integrity": "sha512-9u1rgwZSEXWb30vbFZzQ78HVXBo0WCKNwJ3a2InRUTNMRng+PUDIoSFmA+m4HdUfBaIqftShq8J8qHc+eE/Vig==",
|
||||
"version": "1.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.67.0.tgz",
|
||||
"integrity": "sha512-BKytFdcQzbITV3xlnzDUDTEDtbUMCCiC4EaNTDZ4FyT8gdNvBC4gfiLucXp/sQl0XU3p7syTlorUWVVVBZab2g==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -8755,9 +8802,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-arm-musleabihf": {
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.66.0.tgz",
|
||||
"integrity": "sha512-Ynot2HR1bHxUaNWoC280MVTDfZuaWuP3XfSMRDhyuZrVjhzoaBCVFlw8h8qeZjWKVUBhPWFIxB7AQTlK8Z2WWg==",
|
||||
"version": "1.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.67.0.tgz",
|
||||
"integrity": "sha512-XYAv0esBDX7BpTzRDjVX2Vdj+zndd8ll2dFQiaeQ6zTZr7A8GRDTN7fH3FP3jU+O0vCDx85oH/EtG7BzPgAXuw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -8772,9 +8819,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-arm64-gnu": {
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.66.0.tgz",
|
||||
"integrity": "sha512-xCbgzciGgo+A4aQZEknsNrNiIwY7sU5SfRuMmRjPIvZAgdF34cIHiKvwOsS5XRLjlTVSFwitmq6YclTtHTfU+g==",
|
||||
"version": "1.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.67.0.tgz",
|
||||
"integrity": "sha512-zizRMjA0i6u/2B0evgda04iycu+MoNuf1pBy6Eh+1CjC5wMEG7qN5zdDKTCvFc0KSYSDM9QTG3gjZHirgtQuKg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -8789,9 +8836,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-arm64-musl": {
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.66.0.tgz",
|
||||
"integrity": "sha512-hmo+ZB/lHkR1HdDmnziNpzSLmulnUSu10VEqX2Yex7OwvoBAbjJQLvy4gIBRV3AAwWnCvAxKp5Nv1GE6LU1QMg==",
|
||||
"version": "1.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.67.0.tgz",
|
||||
"integrity": "sha512-zB/Tf6sUjmmvvbva9Gj3JTJ8rJ9t4I8/U0o6vSRtd0DRIsIuyegBwJAzhSUFQHdMijIRJkW0exs/yBhpw2S20w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -8806,9 +8853,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-ppc64-gnu": {
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.66.0.tgz",
|
||||
"integrity": "sha512-2Invd4Uyy81mVooQC5FBtfxSNrvcX1OxbMlVQ6M2erRrNI2awFYF26YNW2yFxdVFZ4ffNOWKghtMjhnUPsXsVA==",
|
||||
"version": "1.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.67.0.tgz",
|
||||
"integrity": "sha512-kgU40Gt74CK0TCsF51KZymkIwN9U0BajKsMijB52zPqOeZU9NAHkA/NSQkZDHEaCakx42DxhXkODiAqf2b4Gug==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -8823,9 +8870,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-riscv64-gnu": {
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.66.0.tgz",
|
||||
"integrity": "sha512-s0iXPDQVdgayE3RGa/N2DZF7tjgg0TwEtD1sGoDxqPDGrIXgo45H0yHknT0f9A0yteASsweYZtDyTuVlM4aSag==",
|
||||
"version": "1.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.67.0.tgz",
|
||||
"integrity": "sha512-tOYhkk/iaG9aD3FvGpBFd1Lrw0x0RaVoJBxjUkfNzS50rC5NS5BteNCwgr8A2zCdADrIIoze6D7u6U5Ic++/iQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -8840,9 +8887,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-riscv64-musl": {
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.66.0.tgz",
|
||||
"integrity": "sha512-OekL4XFiu7RPK0JIZi8VeHgtIXPREf42t8Cy/rKEsC+P3gcqDgNAAGiyuUOpdbG4wwbfue1q4CHcCO7spSve6w==",
|
||||
"version": "1.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.67.0.tgz",
|
||||
"integrity": "sha512-sEtywrPb+0b+tHYl1SDCrw903fiC4eyKoNqzP3v+f2JT3Xcv4NEYG+P8rj+eEnX7IWhqV/xj8/JmcmVj21CXaA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -8857,9 +8904,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-s390x-gnu": {
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.66.0.tgz",
|
||||
"integrity": "sha512-Ga1D0kj1SFslm34ThA/BdkUlyAYEnTsXyRC4pF0C5agZSwtGdHYWMTQWemUfBGp4RCG4QWXgdO+HmmmKqOtlBg==",
|
||||
"version": "1.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.67.0.tgz",
|
||||
"integrity": "sha512-BvR8Moa0zCLxroOx4vZaZN9nUfwAUpSTwjZdxZyKy4bv3PrzrXrxKR/ZQ0L9wNSvlPhnMJeZfa3q5w6ZCTuN6Q==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -8874,9 +8921,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-x64-gnu": {
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.66.0.tgz",
|
||||
"integrity": "sha512-p5jfP1wUZe/IC3qpQO84n9DRnf9g3lKRtLBlQq23ykyrDglHcVx7sWmVTlPuU6SBw8mNnPzyOn022G3XZHnlww==",
|
||||
"version": "1.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.67.0.tgz",
|
||||
"integrity": "sha512-mm2cxM6fksOpq6l0uFws8BUGKAR4dNa/cZCn37Npq7PFbhD5HDJqWfnoIvTaeRKMy5XdS2tO0MA0qbHDrnXAAA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -8891,9 +8938,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-x64-musl": {
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.66.0.tgz",
|
||||
"integrity": "sha512-vUB/sYlYZorDL1ZD+o9mRv7zbsykrrFRtmgS6R8musZqLtrPRQn1gc1eGpuX+sfdccz42STl/AqldY6XRb2upQ==",
|
||||
"version": "1.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.67.0.tgz",
|
||||
"integrity": "sha512-WmbMuLapKyDlobMkXAaAL0Y+Uczh4LETfIfQsUpbId4Ip8Ai82/jqeYTOoUCkuuhBFapgqP253+d83tLKOksJg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -8908,9 +8955,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-openharmony-arm64": {
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.66.0.tgz",
|
||||
"integrity": "sha512-yde+6p/F59xRkGR9H1HfngWRif1QRJjynZK349l+UI0H6w9hL3G8/AVaTHFyTtLVQ56qtNbX2/5Dc77n1ovnOg==",
|
||||
"version": "1.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.67.0.tgz",
|
||||
"integrity": "sha512-9g/PqxYJelzzTAOR5Y+RiRqdeydhEuXv2KxNeFcAKQ7UsvnWSY1OP4MsuPMbTO2Pf70tz7mFhl1j13H3fyh+8g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -8925,9 +8972,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-win32-arm64-msvc": {
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.66.0.tgz",
|
||||
"integrity": "sha512-O9GLucgoTdmOrbBX+EjzNe7o/Ze5TFOvXcib6bzUOtBOmj6cV+zw18NgB+cGKAkDw1Pdqs8vGkfHbbsLuDtXWg==",
|
||||
"version": "1.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.67.0.tgz",
|
||||
"integrity": "sha512-2VhwE6Gatb0vJGnN0TBuQMbKCOiZlSQ/zJvVWYLK4a9d4iDiJOen/yVQkGpmsJ90MuH66fzi0kEKI0jRQMDxGA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -8942,9 +8989,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-win32-ia32-msvc": {
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.66.0.tgz",
|
||||
"integrity": "sha512-m3Pjwc2MfTcom4E4gOv7DyuGyt7OfGNCbmqDHd+N7EzXmP+ppHuudm2NjcA3AjV5TSeGxaguVF4SbTKHe1USYA==",
|
||||
"version": "1.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.67.0.tgz",
|
||||
"integrity": "sha512-EQ3VExXfeM1InbE5+JjufhZZTWy+kHUwgt3yZR7gQ47Je/mE0WspQPan0OJznh493L5anM210YNJtH1PXjTSFg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -8959,9 +9006,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-win32-x64-msvc": {
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.66.0.tgz",
|
||||
"integrity": "sha512-/DbBvw8UFBhja6PqudUjV4UtfsJr0Oa7jUjWVKB0g86lj/VwnPrkngn0sFql3c9RDA0O16dh7ozsXb6GjNAzBQ==",
|
||||
"version": "1.67.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.67.0.tgz",
|
||||
"integrity": "sha512-bw24y+/1MHS4QDkons3YyHkPT9uCMoLHHgQhb+mb8NOjTYwub1CZ+K9Ngr8aO5DMrDrkqHwTzlTwFP2vS8Y/ZQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -9152,13 +9199,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@pkgr/core": {
|
||||
"version": "0.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz",
|
||||
"integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==",
|
||||
"version": "0.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz",
|
||||
"integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.18.0 || >=16.0.0"
|
||||
"node": "^14.18.0 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/pkgr"
|
||||
@@ -9442,6 +9489,20 @@
|
||||
"react-dom": ">=16.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rc-component/util": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/util/-/util-1.11.1.tgz",
|
||||
"integrity": "sha512-awVlI3ub2vqfqkYxOBc/uQ0efm3jw0wcrhtO/YWLyZfxiKXczKwNbVuhlnyxytDt7H9pbbVQiqr+O6MLATtRYg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-mobile": "^5.0.0",
|
||||
"react-is": "^18.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18.0.0",
|
||||
"react-dom": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-dnd/asap": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.1.tgz",
|
||||
@@ -18934,9 +18995,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.0.tgz",
|
||||
"integrity": "sha512-qqGFOrKmFP1lTfG24opOJFcTMza1BqyTSUKVbMGUP5uRsBH+C00Q1loOk+JSFshyRE0ji4HtCJeNN2WHWd6PGw==",
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz",
|
||||
"integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -20750,9 +20811,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.20",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz",
|
||||
"integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==",
|
||||
"version": "1.11.21",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
|
||||
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debounce": {
|
||||
@@ -21801,9 +21862,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.21.6",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz",
|
||||
"integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==",
|
||||
"version": "5.22.2",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.2.tgz",
|
||||
"integrity": "sha512-0rxICaFZ7NQho/sHely2bvOPRP0Eu2B0NZ9zM54YvRvWMn7jfz3DmnOZDR9LlXDdDcqntAVc6Hfy4gr/tdH/Ag==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -22606,14 +22667,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-prettier": {
|
||||
"version": "5.5.5",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz",
|
||||
"integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==",
|
||||
"version": "5.5.6",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.6.tgz",
|
||||
"integrity": "sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prettier-linter-helpers": "^1.0.1",
|
||||
"synckit": "^0.11.12"
|
||||
"synckit": "^0.11.13"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.18.0 || >=16.0.0"
|
||||
@@ -22644,9 +22705,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eslint-plugin-react-you-might-not-need-an-effect": {
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-you-might-not-need-an-effect/-/eslint-plugin-react-you-might-not-need-an-effect-0.10.2.tgz",
|
||||
"integrity": "sha512-cqm9DXcsISYZHnFXT5zPH+ITsMx/bYscmq6zIsbtYvei1vj4dZ+BxN9LgoMmjEdm7sTaWxKVRY5IqQRQvau/GQ==",
|
||||
"version": "0.10.4",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-you-might-not-need-an-effect/-/eslint-plugin-react-you-might-not-need-an-effect-0.10.4.tgz",
|
||||
"integrity": "sha512-T6UFIOl2yWzVJ7LRk27z6EbJm2pfO4+VCTp2TBRsmAUREkDFUXjtWxoD9NsDcg6NmMFETZLbAD1XzV/w/GOmqw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -37424,9 +37485,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/oxlint": {
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.66.0.tgz",
|
||||
"integrity": "sha512-N4LLxYLd94KEBqXDMDM5f+2PUpItTjDLreXe2Gn5KhjhCK4Qp2YUXaBi8Yu325ryOgKwt22m45fpD7nPOn69Yw==",
|
||||
"version": "1.67.0",
|
||||
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.67.0.tgz",
|
||||
"integrity": "sha512-blwwaHPdoH8piQ5/z0KHeoHFR7FZgl12WluKJfu4qFLPkZl6mK04PkLE45Fw1NxfBRSlh40Gu7MkxHUw++ociQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -37439,32 +37500,36 @@
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@oxlint/binding-android-arm-eabi": "1.66.0",
|
||||
"@oxlint/binding-android-arm64": "1.66.0",
|
||||
"@oxlint/binding-darwin-arm64": "1.66.0",
|
||||
"@oxlint/binding-darwin-x64": "1.66.0",
|
||||
"@oxlint/binding-freebsd-x64": "1.66.0",
|
||||
"@oxlint/binding-linux-arm-gnueabihf": "1.66.0",
|
||||
"@oxlint/binding-linux-arm-musleabihf": "1.66.0",
|
||||
"@oxlint/binding-linux-arm64-gnu": "1.66.0",
|
||||
"@oxlint/binding-linux-arm64-musl": "1.66.0",
|
||||
"@oxlint/binding-linux-ppc64-gnu": "1.66.0",
|
||||
"@oxlint/binding-linux-riscv64-gnu": "1.66.0",
|
||||
"@oxlint/binding-linux-riscv64-musl": "1.66.0",
|
||||
"@oxlint/binding-linux-s390x-gnu": "1.66.0",
|
||||
"@oxlint/binding-linux-x64-gnu": "1.66.0",
|
||||
"@oxlint/binding-linux-x64-musl": "1.66.0",
|
||||
"@oxlint/binding-openharmony-arm64": "1.66.0",
|
||||
"@oxlint/binding-win32-arm64-msvc": "1.66.0",
|
||||
"@oxlint/binding-win32-ia32-msvc": "1.66.0",
|
||||
"@oxlint/binding-win32-x64-msvc": "1.66.0"
|
||||
"@oxlint/binding-android-arm-eabi": "1.67.0",
|
||||
"@oxlint/binding-android-arm64": "1.67.0",
|
||||
"@oxlint/binding-darwin-arm64": "1.67.0",
|
||||
"@oxlint/binding-darwin-x64": "1.67.0",
|
||||
"@oxlint/binding-freebsd-x64": "1.67.0",
|
||||
"@oxlint/binding-linux-arm-gnueabihf": "1.67.0",
|
||||
"@oxlint/binding-linux-arm-musleabihf": "1.67.0",
|
||||
"@oxlint/binding-linux-arm64-gnu": "1.67.0",
|
||||
"@oxlint/binding-linux-arm64-musl": "1.67.0",
|
||||
"@oxlint/binding-linux-ppc64-gnu": "1.67.0",
|
||||
"@oxlint/binding-linux-riscv64-gnu": "1.67.0",
|
||||
"@oxlint/binding-linux-riscv64-musl": "1.67.0",
|
||||
"@oxlint/binding-linux-s390x-gnu": "1.67.0",
|
||||
"@oxlint/binding-linux-x64-gnu": "1.67.0",
|
||||
"@oxlint/binding-linux-x64-musl": "1.67.0",
|
||||
"@oxlint/binding-openharmony-arm64": "1.67.0",
|
||||
"@oxlint/binding-win32-arm64-msvc": "1.67.0",
|
||||
"@oxlint/binding-win32-ia32-msvc": "1.67.0",
|
||||
"@oxlint/binding-win32-x64-msvc": "1.67.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"oxlint-tsgolint": ">=0.22.1"
|
||||
"oxlint-tsgolint": ">=0.22.1",
|
||||
"vite-plus": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"oxlint-tsgolint": {
|
||||
"optional": true
|
||||
},
|
||||
"vite-plus": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -39401,9 +39466,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/query-string": {
|
||||
"version": "9.3.1",
|
||||
"resolved": "https://registry.npmjs.org/query-string/-/query-string-9.3.1.tgz",
|
||||
"integrity": "sha512-5fBfMOcDi5SA9qj5jZhWAcTtDfKF5WFdd2uD9nVNlbxVv1baq65aALy6qofpNEGELHvisjjasxQp7BlM9gvMzw==",
|
||||
"version": "9.4.0",
|
||||
"resolved": "https://registry.npmjs.org/query-string/-/query-string-9.4.0.tgz",
|
||||
"integrity": "sha512-ivvWyHqU9K1Log4hJFhqVIIMoEi0nzmlRhvk2pPcTuQH/Y0K5iTTMxEx7R0PRHD2Z1hMVbWnjfsEWbIKIK+3IA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"decode-uri-component": "^0.4.1",
|
||||
@@ -44375,13 +44440,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/synckit": {
|
||||
"version": "0.11.12",
|
||||
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz",
|
||||
"integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==",
|
||||
"version": "0.11.13",
|
||||
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz",
|
||||
"integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@pkgr/core": "^0.2.9"
|
||||
"@pkgr/core": "^0.3.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.18.0 || >=16.0.0"
|
||||
@@ -44531,9 +44596,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/terser-webpack-plugin": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.0.tgz",
|
||||
"integrity": "sha512-Eum+5ajkaOhf5KbM26osvv21kLD7BaGqQ1UA4Ami4arYwylmGUQTgHFpHDdmJod1q4QXa66p0to/FBKID+J1vA==",
|
||||
"version": "5.6.1",
|
||||
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.1.tgz",
|
||||
"integrity": "sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -47310,9 +47375,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/webpack": {
|
||||
"version": "5.107.1",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.107.1.tgz",
|
||||
"integrity": "sha512-mvdIWxj/H6QsfgDdH9djne3a5dYcmEmtsXGESkypaGN5jXjF/b+9KDlmTDQ2TKlFUeA2fI9Y65kihD30JOdB+Q==",
|
||||
"version": "5.107.2",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.107.2.tgz",
|
||||
"integrity": "sha512-v7RhXaJbpMlV0D7hC7lb2EbnxkoeUqf9qhKr6lozx3Q48pmFrqqNRmZFUEGmi7pSwm6fCQ2H1IjvCkHqdpVdjQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -47325,7 +47390,7 @@
|
||||
"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",
|
||||
@@ -47338,7 +47403,7 @@
|
||||
"tapable": "^2.3.0",
|
||||
"terser-webpack-plugin": "^5.5.0",
|
||||
"watchpack": "^2.5.1",
|
||||
"webpack-sources": "^3.4.1"
|
||||
"webpack-sources": "^3.5.0"
|
||||
},
|
||||
"bin": {
|
||||
"webpack": "bin/webpack.js"
|
||||
@@ -49231,7 +49296,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.29.7",
|
||||
"@babel/core": "^7.29.0",
|
||||
"@babel/core": "^7.29.7",
|
||||
"@babel/preset-env": "^7.29.7",
|
||||
"@babel/preset-react": "^7.29.7",
|
||||
"@babel/preset-typescript": "^7.29.7",
|
||||
@@ -49294,9 +49359,10 @@
|
||||
"version": "0.20.4",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.2.3",
|
||||
"@ant-design/icons": "^6.2.5",
|
||||
"@apache-superset/core": "*",
|
||||
"@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",
|
||||
@@ -49311,14 +49377,14 @@
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-time": "^3.1.0",
|
||||
"d3-time-format": "^4.1.0",
|
||||
"dayjs": "^1.11.20",
|
||||
"dompurify": "^3.4.5",
|
||||
"dayjs": "^1.11.21",
|
||||
"dompurify": "^3.4.7",
|
||||
"fetch-retry": "^6.0.0",
|
||||
"handlebars": "^4.7.9",
|
||||
"jed": "^1.1.1",
|
||||
"lodash": "^4.18.1",
|
||||
"math-expression-evaluator": "^2.0.7",
|
||||
"pretty-ms": "^9.3.0",
|
||||
"parse-ms": "^4.0.0",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react-ace": "^14.0.1",
|
||||
"react-draggable": "^4.5.0",
|
||||
@@ -49326,7 +49392,7 @@
|
||||
"react-js-cron": "^5.2.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-resize-detector": "^7.1.2",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"react-ultimate-pagination": "^1.3.2",
|
||||
"regenerator-runtime": "^0.14.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
@@ -49395,14 +49461,14 @@
|
||||
}
|
||||
},
|
||||
"packages/superset-ui-core/node_modules/@ant-design/icons": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-6.2.3.tgz",
|
||||
"integrity": "sha512-Pl3aoAtxQeKryYnt6VvDJtOxMOtA8wrRSACe/pTjOAIG3fdHrWm6Ivb4ku9tsFjYroSXBKirvuxG4QkwBXD9gg==",
|
||||
"version": "6.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-6.2.5.tgz",
|
||||
"integrity": "sha512-0hKtoKqTjGFOndUyJLJmC9Cg6k4rEO7rLo6xmgbNJH+/ZX1C57RVals2v1j1knHl9n7Q+sBOveTvn931wLOCKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ant-design/colors": "^8.0.1",
|
||||
"@ant-design/icons-svg": "^4.4.2",
|
||||
"@rc-component/util": "^1.10.1",
|
||||
"@rc-component/util": "^1.11.0",
|
||||
"clsx": "^2.1.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -49413,29 +49479,6 @@
|
||||
"react-dom": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"packages/superset-ui-core/node_modules/@ant-design/icons/node_modules/@rc-component/util": {
|
||||
"version": "1.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/util/-/util-1.10.1.tgz",
|
||||
"integrity": "sha512-q++9S6rUa5Idb/xIBNz6jtvumw5+O5YV5V0g4iK9mn9jWs4oGJheE3ZN1kAnE723AXyaD8v95yeOASmdk8Jnng==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-mobile": "^5.0.0",
|
||||
"react-is": "^18.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18.0.0",
|
||||
"react-dom": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"packages/superset-ui-core/node_modules/@babel/runtime": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"packages/superset-ui-core/node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
@@ -49446,9 +49489,9 @@
|
||||
}
|
||||
},
|
||||
"packages/superset-ui-core/node_modules/dompurify": {
|
||||
"version": "3.4.5",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz",
|
||||
"integrity": "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==",
|
||||
"version": "3.4.8",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz",
|
||||
"integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
@@ -49827,7 +49870,7 @@
|
||||
"dependencies": {
|
||||
"d3": "^3.5.17",
|
||||
"d3-tip": "^0.9.1",
|
||||
"dompurify": "^3.4.5",
|
||||
"dompurify": "^3.4.7",
|
||||
"fast-safe-stringify": "^2.1.1",
|
||||
"lodash": "^4.18.1",
|
||||
"nvd3-fork": "^2.0.5",
|
||||
@@ -49838,14 +49881,14 @@
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"dayjs": "^1.11.19",
|
||||
"dayjs": "^1.11.21",
|
||||
"react": "^18.2.0"
|
||||
}
|
||||
},
|
||||
"plugins/legacy-preset-chart-nvd3/node_modules/dompurify": {
|
||||
"version": "3.4.5",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz",
|
||||
"integrity": "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==",
|
||||
"version": "3.4.8",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz",
|
||||
"integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
@@ -49941,7 +49984,7 @@
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"dayjs": "^1.11.19",
|
||||
"dayjs": "^1.11.21",
|
||||
"echarts": "*",
|
||||
"memoize-one": "*",
|
||||
"react": "^18.2.0"
|
||||
@@ -49999,7 +50042,7 @@
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"ace-builds": "^1.4.14",
|
||||
"dayjs": "^1.11.19",
|
||||
"dayjs": "^1.11.21",
|
||||
"handlebars": "^4.7.8",
|
||||
"lodash": "^4.18.1",
|
||||
"react": "^18.2.0",
|
||||
@@ -50215,7 +50258,7 @@
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"dayjs": "^1.11.19",
|
||||
"dayjs": "^1.11.21",
|
||||
"mapbox-gl": ">=1.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
|
||||
@@ -169,10 +169,10 @@
|
||||
"antd": "^5.26.0",
|
||||
"chrono-node": "^2.9.1",
|
||||
"classnames": "^2.2.5",
|
||||
"content-disposition": "^2.0.0",
|
||||
"content-disposition": "^2.0.1",
|
||||
"d3-color": "^3.1.0",
|
||||
"d3-scale": "^4.0.2",
|
||||
"dayjs": "^1.11.20",
|
||||
"dayjs": "^1.11.21",
|
||||
"dom-to-image-more": "^3.7.2",
|
||||
"dom-to-pdf": "^0.3.2",
|
||||
"echarts": "^5.6.0",
|
||||
@@ -201,8 +201,7 @@
|
||||
"mustache": "^4.2.0",
|
||||
"nanoid": "^5.1.11",
|
||||
"ol": "^10.9.0",
|
||||
"pretty-ms": "^9.3.0",
|
||||
"query-string": "9.3.1",
|
||||
"query-string": "9.4.0",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react": "^18.2.0",
|
||||
"react-arborist": "^3.8.0",
|
||||
@@ -246,9 +245,9 @@
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.29.7",
|
||||
"@babel/compat-data": "^7.28.4",
|
||||
"@babel/core": "^7.29.0",
|
||||
"@babel/core": "^7.29.7",
|
||||
"@babel/eslint-parser": "^7.29.7",
|
||||
"@babel/node": "^7.29.0",
|
||||
"@babel/node": "^7.29.7",
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
|
||||
"@babel/plugin-transform-export-namespace-from": "^7.29.7",
|
||||
"@babel/plugin-transform-modules-commonjs": "^7.29.7",
|
||||
@@ -256,12 +255,13 @@
|
||||
"@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/register": "^7.29.7",
|
||||
"@babel/runtime": "^7.29.7",
|
||||
"@babel/runtime-corejs3": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
"@emotion/jest": "^11.14.2",
|
||||
"@formatjs/intl-durationformat": "^0.10.3",
|
||||
"@istanbuljs/nyc-config-typescript": "^1.0.1",
|
||||
"@mihkeleidast/storybook-addon-source": "^1.0.1",
|
||||
"@playwright/test": "^1.60.0",
|
||||
@@ -331,9 +331,9 @@
|
||||
"eslint-plugin-jest-dom": "^5.5.0",
|
||||
"eslint-plugin-lodash": "^7.4.0",
|
||||
"eslint-plugin-no-only-tests": "^3.4.0",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"eslint-plugin-react-prefer-function-component": "^5.0.0",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.2",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.4",
|
||||
"eslint-plugin-storybook": "^0.8.0",
|
||||
"eslint-plugin-testing-library": "^7.16.2",
|
||||
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
|
||||
@@ -353,7 +353,7 @@
|
||||
"lightningcss": "^1.32.0",
|
||||
"mini-css-extract-plugin": "^2.10.2",
|
||||
"open-cli": "^9.0.0",
|
||||
"oxlint": "^1.66.0",
|
||||
"oxlint": "^1.67.0",
|
||||
"po2json": "^0.4.5",
|
||||
"prettier": "3.8.3",
|
||||
"prettier-plugin-packagejson": "^3.0.2",
|
||||
@@ -367,7 +367,7 @@
|
||||
"storybook": "8.6.18",
|
||||
"style-loader": "^4.0.0",
|
||||
"swc-loader": "^0.2.7",
|
||||
"terser-webpack-plugin": "^5.6.0",
|
||||
"terser-webpack-plugin": "^5.6.1",
|
||||
"ts-jest": "^29.4.11",
|
||||
"tscw-config": "^1.1.2",
|
||||
"tsx": "^4.22.3",
|
||||
@@ -375,7 +375,7 @@
|
||||
"unzipper": "^0.12.3",
|
||||
"vm-browserify": "^1.1.2",
|
||||
"wait-on": "^9.0.10",
|
||||
"webpack": "^5.107.1",
|
||||
"webpack": "^5.107.2",
|
||||
"webpack-bundle-analyzer": "^5.3.0",
|
||||
"webpack-cli": "^6.0.1",
|
||||
"webpack-dev-server": "^5.2.4",
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.29.7",
|
||||
"@babel/core": "^7.29.0",
|
||||
"@babel/core": "^7.29.7",
|
||||
"@babel/preset-env": "^7.29.7",
|
||||
"@babel/preset-react": "^7.29.7",
|
||||
"@babel/preset-typescript": "^7.29.7",
|
||||
|
||||
@@ -57,7 +57,7 @@ export const D3_FORMAT_OPTIONS: [string, string][] = [
|
||||
...d3Formatted,
|
||||
['DURATION', t('Duration in ms (66000 => 1m 6s)')],
|
||||
['DURATION_SUB', t('Duration in ms (1.40008 => 1ms 400µs 80ns)')],
|
||||
['DURATION_COL', t('Duration in ms (10500 => 0:10.5)')],
|
||||
['DURATION_COL', t('Duration in ms (10500 => 0:00:10.5)')],
|
||||
['MEMORY_DECIMAL', t('Memory in bytes - decimal (1024B => 1.024kB)')],
|
||||
['MEMORY_BINARY', t('Memory in bytes - binary (1024B => 1KiB)')],
|
||||
[
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"lib"
|
||||
],
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.2.3",
|
||||
"@ant-design/icons": "^6.2.5",
|
||||
"@apache-superset/core": "*",
|
||||
"@babel/runtime": "^7.29.7",
|
||||
"@braintree/sanitize-url": "^7.1.2",
|
||||
@@ -42,14 +42,14 @@
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-time": "^3.1.0",
|
||||
"d3-time-format": "^4.1.0",
|
||||
"dayjs": "^1.11.20",
|
||||
"dompurify": "^3.4.5",
|
||||
"dayjs": "^1.11.21",
|
||||
"dompurify": "^3.4.7",
|
||||
"fetch-retry": "^6.0.0",
|
||||
"handlebars": "^4.7.9",
|
||||
"jed": "^1.1.1",
|
||||
"lodash": "^4.18.1",
|
||||
"math-expression-evaluator": "^2.0.7",
|
||||
"pretty-ms": "^9.3.0",
|
||||
"parse-ms": "^4.0.0",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react-ace": "^14.0.1",
|
||||
"react-draggable": "^4.5.0",
|
||||
@@ -57,7 +57,7 @@
|
||||
"react-js-cron": "^5.2.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-resize-detector": "^7.1.2",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"react-ultimate-pagination": "^1.3.2",
|
||||
"regenerator-runtime": "^0.14.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
|
||||
@@ -72,6 +72,15 @@ export const DropdownContainer = forwardRef(
|
||||
|
||||
const [showOverflow, setShowOverflow] = useState(false);
|
||||
|
||||
// When the item set changes, the overflow index is briefly reset while the
|
||||
// new widths are measured (see the layout effect below). During that window
|
||||
// the dropdown content momentarily becomes empty, which would hide and then
|
||||
// re-show the trigger, causing a flicker. We track whether a recalculation
|
||||
// is pending so the trigger can stay mounted across the transient (when it
|
||||
// was showing content just before) without lingering in the steady state
|
||||
// when nothing actually overflows.
|
||||
const [recalculating, setRecalculating] = useState(false);
|
||||
|
||||
// callback to update item widths so that the useLayoutEffect runs whenever
|
||||
// width of any of the child changes
|
||||
const recalculateItemWidths = useCallback(() => {
|
||||
@@ -171,6 +180,7 @@ export const DropdownContainer = forwardRef(
|
||||
);
|
||||
} else {
|
||||
setOverflowingIndex(-1);
|
||||
setRecalculating(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -211,6 +221,7 @@ export const DropdownContainer = forwardRef(
|
||||
}
|
||||
|
||||
setOverflowingIndex(newOverflowingIndex);
|
||||
setRecalculating(false);
|
||||
}
|
||||
}, [
|
||||
current,
|
||||
@@ -261,6 +272,15 @@ export const DropdownContainer = forwardRef(
|
||||
],
|
||||
);
|
||||
|
||||
// The trigger had content in the previous render if popoverContent was
|
||||
// truthy then. During the brief mid-recalculation render where
|
||||
// popoverContent flips to null, this still reflects the prior (non-empty)
|
||||
// value, letting us keep the trigger mounted across the transient.
|
||||
const hadPopoverContent = usePrevious(!!popoverContent, false);
|
||||
|
||||
const showDropdownButton =
|
||||
!!popoverContent || (recalculating && hadPopoverContent);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (popoverVisible) {
|
||||
// Measures scroll height after rendering the elements
|
||||
@@ -314,7 +334,7 @@ export const DropdownContainer = forwardRef(
|
||||
>
|
||||
{notOverflowedItems.map(item => item.element)}
|
||||
</div>
|
||||
{popoverContent && (
|
||||
{showDropdownButton && (
|
||||
<>
|
||||
<Global
|
||||
styles={css`
|
||||
@@ -348,8 +368,13 @@ export const DropdownContainer = forwardRef(
|
||||
}}
|
||||
content={popoverContent}
|
||||
trigger="click"
|
||||
open={popoverVisible}
|
||||
onOpenChange={visible => setPopoverVisible(visible)}
|
||||
open={popoverVisible && !!popoverContent}
|
||||
onOpenChange={visible => {
|
||||
// While a recalculation keeps the trigger mounted but there is
|
||||
// no content yet, ignore open attempts so it stays visible
|
||||
// without opening an empty popover.
|
||||
if (popoverContent) setPopoverVisible(visible);
|
||||
}}
|
||||
placement="bottom"
|
||||
forceRender={forceRender}
|
||||
fresh // This prop prevents caching and stale data for filter scoping.
|
||||
|
||||
@@ -31,6 +31,53 @@ interface SafeMarkdownProps {
|
||||
htmlSchemaOverrides?: typeof defaultSchema;
|
||||
}
|
||||
|
||||
// Link protocols that can execute script when used as an href.
|
||||
const DANGEROUS_LINK_PROTOCOLS = ['javascript', 'vbscript', 'data'];
|
||||
|
||||
/**
|
||||
* Sanitize link hrefs without using react-markdown's default protocol
|
||||
* allowlist, which would strip the custom link schemes that Superset markdown
|
||||
* is expected to support (see #26211). Instead of allowlisting known-safe
|
||||
* protocols, this blocks the protocols that enable script execution and leaves
|
||||
* everything else (http(s), mailto, relative URLs, anchors and custom schemes)
|
||||
* untouched. Applied regardless of the EscapeMarkdownHtml feature flag.
|
||||
*/
|
||||
export function transformLinkUri(uri: string): string {
|
||||
// Per the WHATWG URL parser, browsers strip leading C0 control
|
||||
// characters (\x00-\x1f) and space before resolving the scheme, so e.g.
|
||||
// "\x01javascript:alert(1)" executes on click. Strip them here too,
|
||||
// otherwise the blocklist check below could be bypassed with a leading
|
||||
// control character. The pattern is anchored at the start so it runs in
|
||||
// linear time; trailing whitespace does not affect the scheme and is
|
||||
// left for the renderer to handle.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const url = (uri || '').replace(/^[\u0000-\u0020]+/, '');
|
||||
const first = url.charAt(0);
|
||||
// Anchors and absolute/relative paths have no protocol.
|
||||
if (first === '#' || first === '/') {
|
||||
return url;
|
||||
}
|
||||
const colon = url.indexOf(':');
|
||||
if (colon === -1) {
|
||||
return url;
|
||||
}
|
||||
// A ':' after a '?' or '#' belongs to the query/fragment, not a scheme.
|
||||
const queryIndex = url.indexOf('?');
|
||||
if (queryIndex !== -1 && colon > queryIndex) {
|
||||
return url;
|
||||
}
|
||||
const hashIndex = url.indexOf('#');
|
||||
if (hashIndex !== -1 && colon > hashIndex) {
|
||||
return url;
|
||||
}
|
||||
// Whitespace and C0 control characters inside the scheme (e.g.
|
||||
// "java\tscript:" or "java\x01script:") are ignored by browsers, so strip
|
||||
// them before comparing against the blocklist.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const scheme = url.slice(0, colon).replace(/[\u0000-\u0020]/g, '').toLowerCase();
|
||||
return DANGEROUS_LINK_PROTOCOLS.includes(scheme) ? '' : url;
|
||||
}
|
||||
|
||||
export function getOverrideHtmlSchema(
|
||||
originalSchema: typeof defaultSchema,
|
||||
htmlSchemaOverrides: SafeMarkdownProps['htmlSchemaOverrides'],
|
||||
@@ -82,7 +129,7 @@ export function SafeMarkdown({
|
||||
rehypePlugins={rehypePlugins}
|
||||
remarkPlugins={[remarkGfm]}
|
||||
skipHtml={false}
|
||||
transformLinkUri={null}
|
||||
transformLinkUri={transformLinkUri}
|
||||
>
|
||||
{source}
|
||||
</ReactMarkdown>
|
||||
|
||||
@@ -214,6 +214,12 @@ test('Bulk selection should work with pagination', () => {
|
||||
// Check that selection checkboxes are rendered
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
expect(checkboxes.length).toBeGreaterThan(0);
|
||||
|
||||
// Guard: the select-all column header carries `data-test="header-toggle-all"`,
|
||||
// which the `header.cell` slot keys on antd's internal `ant-table-selection-column`
|
||||
// class. If antd renames that class, this assertion fails fast at the unit level
|
||||
// instead of leaking into Playwright as a flake.
|
||||
expect(screen.getByTestId('header-toggle-all')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should call setSortBy when clicking sortable column header', () => {
|
||||
|
||||
@@ -196,6 +196,14 @@ function TableCollection<T extends object>({
|
||||
const rowSelection: TableRowSelection | undefined = useMemo(() => {
|
||||
if (!bulkSelectEnabled) return undefined;
|
||||
|
||||
// antd Table's `rowSelection` API renders its own checkbox column.
|
||||
// The select-all `data-test` lives on the `<th>` via `header.cell`
|
||||
// below (keyed on antd's `ant-table-selection-column` className), NOT
|
||||
// via `columnTitle` — rc-table's MeasureCell renders the column
|
||||
// `title` verbatim inside `<tbody>`, so a `columnTitle` wrapper leaks
|
||||
// any `data-test` attr into the measure row and breaks Playwright
|
||||
// strict-mode selectors. `renderCell` only renders in real body rows,
|
||||
// so wrapping per-row checkboxes there is safe.
|
||||
return {
|
||||
selectedRowKeys,
|
||||
onSelect: (record, selected) => {
|
||||
@@ -204,6 +212,9 @@ function TableCollection<T extends object>({
|
||||
onSelectAll: (selected: boolean) => {
|
||||
toggleAllRowsSelected?.(selected);
|
||||
},
|
||||
renderCell: (_value, _record, _index, originNode) => (
|
||||
<span data-test="row-select-checkbox">{originNode}</span>
|
||||
),
|
||||
};
|
||||
}, [
|
||||
bulkSelectEnabled,
|
||||
@@ -306,9 +317,18 @@ function TableCollection<T extends object>({
|
||||
rowClassName={getRowClassName}
|
||||
components={{
|
||||
header: {
|
||||
cell: (props: HTMLAttributes<HTMLTableCellElement>) => (
|
||||
<th {...props} data-test="sort-header" />
|
||||
),
|
||||
cell: (props: HTMLAttributes<HTMLTableCellElement>) => {
|
||||
const isSelectionColumn =
|
||||
props.className?.includes('ant-table-selection-column') ?? false;
|
||||
return (
|
||||
<th
|
||||
{...props}
|
||||
data-test={
|
||||
isSelectionColumn ? 'header-toggle-all' : 'sort-header'
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
body: {
|
||||
row: (props: HTMLAttributes<HTMLTableRowElement>) => (
|
||||
|
||||
@@ -64,15 +64,15 @@ NumberFormats.PERCENT; // ,.2%
|
||||
NumberFormats.PERCENT_3_POINT; // ,.3%
|
||||
```
|
||||
|
||||
There is also a formatter based on [pretty-ms](https://www.npmjs.com/package/pretty-ms) that can be
|
||||
There is also a formatter based on [Intl.DurationFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DurationFormat) that can be
|
||||
used to format time durations:
|
||||
|
||||
```js
|
||||
import { createDurationFormatter, formatNumber, getNumberFormatterRegistry } from '@superset-ui-number-format';
|
||||
|
||||
getNumberFormatterRegistry().registerValue('my_duration_format', createDurationFormatter({ colonNotation: true });
|
||||
getNumberFormatterRegistry().registerValue('my_duration_format', createDurationFormatter({ style: 'digital' }));
|
||||
console.log(formatNumber('my_duration_format', 95500))
|
||||
// prints '1:35.5'
|
||||
// prints '0:01:35'
|
||||
```
|
||||
|
||||
#### API
|
||||
|
||||
@@ -17,8 +17,9 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import prettyMilliseconds, { Options } from 'pretty-ms';
|
||||
import NumberFormatter from '../NumberFormatter';
|
||||
import { getIntlDurationFormatter } from '../utils/getIntlDurationFormatter';
|
||||
import { parseMilliseconds } from '../utils/parseMilliseconds';
|
||||
|
||||
export default function createDurationFormatter(
|
||||
config: {
|
||||
@@ -26,14 +27,48 @@ export default function createDurationFormatter(
|
||||
id?: string;
|
||||
label?: string;
|
||||
multiplier?: number;
|
||||
} & Options = {},
|
||||
locale?: string;
|
||||
formatSubMilliseconds?: boolean;
|
||||
} & Intl.DurationFormatOptions = {},
|
||||
) {
|
||||
const { description, id, label, multiplier = 1, ...prettyMsOptions } = config;
|
||||
|
||||
const {
|
||||
description,
|
||||
id,
|
||||
label,
|
||||
multiplier = 1,
|
||||
locale,
|
||||
formatSubMilliseconds = false,
|
||||
...intlOptions
|
||||
} = config;
|
||||
const durationFormatter = getIntlDurationFormatter(locale, {
|
||||
secondsDisplay: 'auto',
|
||||
style: 'narrow',
|
||||
...intlOptions,
|
||||
});
|
||||
const zeroDurationFormatter = getIntlDurationFormatter(locale, {
|
||||
secondsDisplay: 'always',
|
||||
style: 'narrow',
|
||||
...intlOptions,
|
||||
});
|
||||
return new NumberFormatter({
|
||||
description,
|
||||
formatFunc: value =>
|
||||
prettyMilliseconds(value * multiplier, prettyMsOptions),
|
||||
formatFunc: value => {
|
||||
const durObject = parseMilliseconds(value * multiplier);
|
||||
|
||||
if (!formatSubMilliseconds) {
|
||||
durObject.milliseconds = 0;
|
||||
durObject.microseconds = 0;
|
||||
durObject.nanoseconds = 0;
|
||||
}
|
||||
|
||||
const isAllUnitsZero = Object.values(durObject).every(
|
||||
value => value === 0,
|
||||
);
|
||||
|
||||
return (
|
||||
isAllUnitsZero ? zeroDurationFormatter : durationFormatter
|
||||
).format(durObject);
|
||||
},
|
||||
id: id ?? 'duration_format',
|
||||
label: label ?? `Duration formatter`,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
export function getIntlDurationFormatter(
|
||||
locale?: string,
|
||||
options?: Intl.DurationFormatOptions,
|
||||
): Intl.DurationFormat {
|
||||
const normalizedLocale = locale?.replace(/_/g, '-');
|
||||
try {
|
||||
return new Intl.DurationFormat(normalizedLocale, options);
|
||||
} catch {
|
||||
return new Intl.DurationFormat('en', options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 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 parseMs from 'parse-ms';
|
||||
|
||||
interface Duration {
|
||||
years: number;
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
milliseconds: number;
|
||||
microseconds: number;
|
||||
nanoseconds: number;
|
||||
}
|
||||
|
||||
const DAYS_IN_YEAR = 365;
|
||||
|
||||
/**
|
||||
* Parses milliseconds into a duration object.
|
||||
|
||||
* @param ms - The number of milliseconds to parse
|
||||
* @returns A duration object containing years, days, hours, minutes, seconds,
|
||||
* milliseconds, microseconds, and nanoseconds (1 year = 365 days)
|
||||
* @example
|
||||
* // Parse a complex duration
|
||||
* parseMilliseconds(90061000);
|
||||
* // { years: 0, days: 1, hours: 1, minutes: 1, seconds: 1, milliseconds: 0, ... }
|
||||
*/
|
||||
export function parseMilliseconds(ms: number): Duration {
|
||||
const parsed = parseMs(ms);
|
||||
const totalDays = parsed.days;
|
||||
const years = Math.trunc(totalDays / DAYS_IN_YEAR);
|
||||
const remainingDays = totalDays % DAYS_IN_YEAR;
|
||||
|
||||
return {
|
||||
...parsed,
|
||||
years,
|
||||
days: remainingDays,
|
||||
};
|
||||
}
|
||||
@@ -102,7 +102,6 @@ export type ChartCustomization = {
|
||||
defaultDataMask: DataMask;
|
||||
controlValues: {
|
||||
sortAscending?: boolean;
|
||||
sortMetric?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
description?: string;
|
||||
|
||||
@@ -20,6 +20,7 @@ import { render } from '@testing-library/react';
|
||||
import {
|
||||
getOverrideHtmlSchema,
|
||||
SafeMarkdown,
|
||||
transformLinkUri,
|
||||
} from '../../src/components/SafeMarkdown/SafeMarkdown';
|
||||
|
||||
/**
|
||||
@@ -52,6 +53,63 @@ describe('getOverrideHtmlSchema', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformLinkUri', () => {
|
||||
// Build script-executing protocols via concatenation so the literal URLs
|
||||
// don't trip the no-script-url lint rule.
|
||||
const js = `java${'script'}`;
|
||||
const vbs = `vb${'script'}`;
|
||||
|
||||
// Cases are [label, uri] pairs: the raw URIs contain C0 control characters
|
||||
// (\x00, \x01, \x1F) that are invalid in XML, so they must not be
|
||||
// interpolated into the test name (the HTML/JUnit reporters serialize names
|
||||
// to XML and would crash). The label keeps the reported name printable while
|
||||
// the uri is exercised in the body.
|
||||
test.each([
|
||||
['javascript', `${js}:alert(1)`],
|
||||
['mixed-case JavaScript', `Java${'Script'}:alert(1)`],
|
||||
['leading whitespace', ` ${js}:alert(document.cookie)`],
|
||||
['tab inside scheme', `java\t${'script'}:alert(1)`],
|
||||
// Leading C0 control characters are stripped by the WHATWG URL parser
|
||||
// before the scheme is resolved, so they must not bypass the blocklist.
|
||||
['leading 0x01 control', `\x01${js}:alert(1)`],
|
||||
['leading NUL (0x00)', `\x00${js}:alert(1)`],
|
||||
['leading 0x1F control', `\x1F${js}:alert(1)`],
|
||||
// C0 control characters inside the scheme are ignored by browsers too.
|
||||
['0x01 control inside scheme', `java\x01${'script'}:alert(1)`],
|
||||
['vbscript', `${vbs}:msgbox(1)`],
|
||||
['data: text/html', 'data:text/html,<script>alert(1)</script>'],
|
||||
])(
|
||||
'blocks the script-executing protocol (%s)',
|
||||
(_label: string, uri: string) => {
|
||||
expect(transformLinkUri(uri)).toBe('');
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
'https://superset.apache.org',
|
||||
'http://example.com/path?q=1',
|
||||
'mailto:someone@example.com',
|
||||
'/relative/path',
|
||||
'#section',
|
||||
])('keeps the safe URL %p unchanged', uri => {
|
||||
expect(transformLinkUri(uri)).toBe(uri);
|
||||
});
|
||||
|
||||
test.each([
|
||||
'custom-scheme://open/thing',
|
||||
'slack://channel?id=1',
|
||||
`foo:bar?${js}:alert(1)`,
|
||||
])('preserves custom link scheme %p (see #26211)', uri => {
|
||||
expect(transformLinkUri(uri)).toBe(uri);
|
||||
});
|
||||
|
||||
test('handles empty and nullish input', () => {
|
||||
expect(transformLinkUri('')).toBe('');
|
||||
// @ts-expect-error -- guarding runtime nullish input
|
||||
expect(transformLinkUri(undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SafeMarkdown', () => {
|
||||
describe('remark-gfm compatibility tests', () => {
|
||||
/**
|
||||
|
||||
@@ -26,34 +26,31 @@ test('creates an instance of NumberFormatter', () => {
|
||||
test('format milliseconds in human readable format with default options', () => {
|
||||
const formatter = createDurationFormatter();
|
||||
expect(formatter(-1000)).toBe('-1s');
|
||||
expect(formatter(0)).toBe('0ms');
|
||||
expect(formatter(0)).toBe('0s');
|
||||
expect(formatter(1000)).toBe('1s');
|
||||
expect(formatter(1337)).toBe('1.3s');
|
||||
expect(formatter(10500)).toBe('10.5s');
|
||||
expect(formatter(1337)).toBe('1s');
|
||||
expect(formatter(10500)).toBe('10s');
|
||||
expect(formatter(60 * 1000)).toBe('1m');
|
||||
expect(formatter(90 * 1000)).toBe('1m 30s');
|
||||
});
|
||||
test('format seconds in human readable format with default options', () => {
|
||||
const formatter = createDurationFormatter({ multiplier: 1000 });
|
||||
expect(formatter(-0.5)).toBe('-500ms');
|
||||
expect(formatter(0.5)).toBe('500ms');
|
||||
expect(formatter(-0.5)).toBe('-0s');
|
||||
expect(formatter(0.5)).toBe('0s');
|
||||
expect(formatter(1)).toBe('1s');
|
||||
expect(formatter(30)).toBe('30s');
|
||||
expect(formatter(60)).toBe('1m');
|
||||
expect(formatter(90)).toBe('1m 30s');
|
||||
});
|
||||
test('format milliseconds in human readable format with additional pretty-ms options', () => {
|
||||
test('format milliseconds in human readable format with additional options', () => {
|
||||
const colonNotationFormatter = createDurationFormatter({
|
||||
colonNotation: true,
|
||||
style: 'digital',
|
||||
formatSubMilliseconds: true,
|
||||
fractionalDigits: 1,
|
||||
});
|
||||
expect(colonNotationFormatter(-10500)).toBe('-0:10.5');
|
||||
expect(colonNotationFormatter(10500)).toBe('0:10.5');
|
||||
const zeroDecimalFormatter = createDurationFormatter({
|
||||
secondsDecimalDigits: 0,
|
||||
});
|
||||
expect(zeroDecimalFormatter(10500)).toBe('10s');
|
||||
expect(colonNotationFormatter(10500)).toBe('0:00:10.5');
|
||||
const subMillisecondFormatter = createDurationFormatter({
|
||||
formatSubMilliseconds: true,
|
||||
});
|
||||
expect(subMillisecondFormatter(100.40008)).toBe('100ms 400µs 80ns');
|
||||
expect(subMillisecondFormatter(100.40008)).toBe('100ms 400μs 80ns');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 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 { getIntlDurationFormatter } from '@superset-ui/core/number-format/utils/getIntlDurationFormatter';
|
||||
|
||||
test('getIntlDurationFormatter creates formatter with fallback locale when passed locale is invalid', () => {
|
||||
const formatter = getIntlDurationFormatter('invalid-locale-xyz');
|
||||
expect(formatter).toBeInstanceOf(Intl.DurationFormat);
|
||||
expect(formatter.format({ seconds: 60 })).toBe('60 sec');
|
||||
});
|
||||
|
||||
test('getIntlDurationFormatter creates formatter with custom options', () => {
|
||||
const formatter = getIntlDurationFormatter('en', { style: 'digital' });
|
||||
expect(formatter).toBeInstanceOf(Intl.DurationFormat);
|
||||
expect(formatter.format({ minutes: 5, seconds: 30 })).toContain(':');
|
||||
});
|
||||
|
||||
test('getIntlDurationFormatter normalizes locale underscores', () => {
|
||||
const formatter = getIntlDurationFormatter('zh_Hans_CN');
|
||||
expect(formatter).toBeInstanceOf(Intl.DurationFormat);
|
||||
expect(formatter.resolvedOptions().locale).toMatch(/^zh/);
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* 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 { parseMilliseconds } from '@superset-ui/core/number-format/utils/parseMilliseconds';
|
||||
|
||||
test('parseMilliseconds should parse basic time units correctly', () => {
|
||||
expect(parseMilliseconds(500)).toEqual({
|
||||
years: 0,
|
||||
days: 0,
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
milliseconds: 500,
|
||||
microseconds: 0,
|
||||
nanoseconds: 0,
|
||||
});
|
||||
expect(parseMilliseconds(5000)).toEqual({
|
||||
years: 0,
|
||||
days: 0,
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 5,
|
||||
milliseconds: 0,
|
||||
microseconds: 0,
|
||||
nanoseconds: 0,
|
||||
});
|
||||
expect(parseMilliseconds(120000)).toEqual({
|
||||
years: 0,
|
||||
days: 0,
|
||||
hours: 0,
|
||||
minutes: 2,
|
||||
seconds: 0,
|
||||
milliseconds: 0,
|
||||
microseconds: 0,
|
||||
nanoseconds: 0,
|
||||
});
|
||||
expect(parseMilliseconds(7200000)).toEqual({
|
||||
years: 0,
|
||||
days: 0,
|
||||
hours: 2,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
milliseconds: 0,
|
||||
microseconds: 0,
|
||||
nanoseconds: 0,
|
||||
});
|
||||
expect(parseMilliseconds(172800000)).toEqual({
|
||||
years: 0,
|
||||
days: 2,
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
milliseconds: 0,
|
||||
microseconds: 0,
|
||||
nanoseconds: 0,
|
||||
});
|
||||
expect(parseMilliseconds(31536000000)).toEqual({
|
||||
years: 1,
|
||||
days: 0,
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
milliseconds: 0,
|
||||
microseconds: 0,
|
||||
nanoseconds: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('parseMilliseconds should handle complex duration', () => {
|
||||
expect(parseMilliseconds(90061234)).toEqual({
|
||||
years: 0,
|
||||
days: 1,
|
||||
hours: 1,
|
||||
minutes: 1,
|
||||
seconds: 1,
|
||||
milliseconds: 234,
|
||||
microseconds: 0,
|
||||
nanoseconds: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('parseMilliseconds should handle fractional milliseconds', () => {
|
||||
expect(parseMilliseconds(1.001001)).toEqual({
|
||||
years: 0,
|
||||
days: 0,
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
milliseconds: 1,
|
||||
microseconds: 1,
|
||||
nanoseconds: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('parseMilliseconds should handle zero', () => {
|
||||
expect(parseMilliseconds(0)).toEqual({
|
||||
years: 0,
|
||||
days: 0,
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
milliseconds: 0,
|
||||
microseconds: 0,
|
||||
nanoseconds: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('parseMilliseconds should handle negative duration', () => {
|
||||
expect(parseMilliseconds(-1000)).toEqual({
|
||||
years: -0,
|
||||
days: -0,
|
||||
hours: -0,
|
||||
minutes: -0,
|
||||
seconds: -1,
|
||||
milliseconds: -0,
|
||||
microseconds: -0,
|
||||
nanoseconds: -0,
|
||||
});
|
||||
});
|
||||
|
||||
test('parseMilliseconds should handle negative days without overflowing into years', () => {
|
||||
expect(parseMilliseconds(-31449600000)).toEqual({
|
||||
years: -0,
|
||||
days: -364,
|
||||
hours: -0,
|
||||
minutes: -0,
|
||||
seconds: -0,
|
||||
milliseconds: -0,
|
||||
microseconds: -0,
|
||||
nanoseconds: -0,
|
||||
});
|
||||
});
|
||||
73
superset-frontend/packages/superset-ui-core/types/intl.d.ts
vendored
Normal file
73
superset-frontend/packages/superset-ui-core/types/intl.d.ts
vendored
Normal file
@@ -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.
|
||||
*/
|
||||
|
||||
declare namespace Intl {
|
||||
class DurationFormat {
|
||||
constructor(locale?: string | string[], options?: DurationFormatOptions);
|
||||
format(duration: DurationObject): string;
|
||||
formatToParts(
|
||||
duration: DurationObject,
|
||||
): { type: string; value: string; unit?: string }[];
|
||||
resolvedOptions(): ResolvedDurationFormatOptions;
|
||||
}
|
||||
|
||||
interface DurationObject {
|
||||
years?: number;
|
||||
months?: number;
|
||||
weeks?: number;
|
||||
days?: number;
|
||||
hours?: number;
|
||||
minutes?: number;
|
||||
seconds?: number;
|
||||
milliseconds?: number;
|
||||
microseconds?: number;
|
||||
nanoseconds?: number;
|
||||
}
|
||||
|
||||
interface DurationFormatOptions {
|
||||
localeMatcher?: 'lookup' | 'best fit';
|
||||
numberingSystem?: string;
|
||||
style?: 'long' | 'short' | 'narrow' | 'digital';
|
||||
years?: 'long' | 'short' | 'narrow';
|
||||
yearsDisplay?: 'always' | 'auto';
|
||||
months?: 'long' | 'short' | 'narrow';
|
||||
monthsDisplay?: 'always' | 'auto';
|
||||
weeks?: 'long' | 'short' | 'narrow';
|
||||
weeksDisplay?: 'always' | 'auto';
|
||||
days?: 'long' | 'short' | 'narrow';
|
||||
daysDisplay?: 'always' | 'auto';
|
||||
hours?: 'long' | 'short' | 'narrow' | 'numeric' | '2-digit';
|
||||
hoursDisplay?: 'always' | 'auto';
|
||||
minutes?: 'long' | 'short' | 'narrow' | 'numeric' | '2-digit';
|
||||
minutesDisplay?: 'always' | 'auto';
|
||||
seconds?: 'long' | 'short' | 'narrow' | 'numeric' | '2-digit';
|
||||
secondsDisplay?: 'always' | 'auto';
|
||||
milliseconds?: 'long' | 'short' | 'narrow' | 'numeric';
|
||||
millisecondsDisplay?: 'always' | 'auto';
|
||||
microseconds?: 'long' | 'short' | 'narrow' | 'numeric';
|
||||
microsecondsDisplay?: 'always' | 'auto';
|
||||
nanoseconds?: 'long' | 'short' | 'narrow' | 'numeric';
|
||||
nanosecondsDisplay?: 'always' | 'auto';
|
||||
fractionalDigits?: number;
|
||||
}
|
||||
|
||||
interface ResolvedDurationFormatOptions extends DurationFormatOptions {
|
||||
locale: string;
|
||||
}
|
||||
}
|
||||
@@ -17,14 +17,24 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { Locator, Page } from '@playwright/test';
|
||||
import { Locator, Page, expect } from '@playwright/test';
|
||||
import { Button, Checkbox, Table } from '../core';
|
||||
|
||||
const BULK_SELECT_SELECTORS = {
|
||||
CONTROLS: '[data-test="bulk-select-controls"]',
|
||||
ACTION: '[data-test="bulk-select-action"]',
|
||||
HEADER_TOGGLE: '[data-test="header-toggle-all"]',
|
||||
ROW_CHECKBOX: '[data-test="row-select-checkbox"]',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Stable keys for ListView bulk actions, matching `action.key` in the
|
||||
* `bulkActions` prop passed to `ListView` (see `src/pages/*List`). Using
|
||||
* the key — not the localized button text — keeps selectors valid across
|
||||
* locales.
|
||||
*/
|
||||
export type BulkSelectActionKey = 'delete' | 'export';
|
||||
|
||||
/**
|
||||
* BulkSelect component for Superset ListView bulk operations.
|
||||
* Provides a reusable interface for bulk selection and actions across list pages.
|
||||
@@ -34,7 +44,7 @@ const BULK_SELECT_SELECTORS = {
|
||||
* await bulkSelect.enable();
|
||||
* await bulkSelect.selectRow('my-dataset');
|
||||
* await bulkSelect.selectRow('another-dataset');
|
||||
* await bulkSelect.clickAction('Delete');
|
||||
* await bulkSelect.clickAction('delete');
|
||||
*/
|
||||
export class BulkSelect {
|
||||
private readonly page: Page;
|
||||
@@ -56,35 +66,67 @@ export class BulkSelect {
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables bulk selection mode by clicking the toggle button
|
||||
* Enables bulk selection mode by clicking the toggle button.
|
||||
*
|
||||
* Waits for the bulk-select column header to render so the next row
|
||||
* interaction does not race the table re-render that adds the checkbox
|
||||
* column. The `data-test="header-toggle-all"` attribute is on the
|
||||
* select-all `<th>` itself (see `TableCollection`'s `components.header.cell`
|
||||
* slot, which keys on antd's `ant-table-selection-column` className).
|
||||
* It deliberately is NOT injected via `rowSelection.columnTitle` because
|
||||
* rc-table's measure row in `<tbody>` clones `columnTitle` and any
|
||||
* `data-test` would duplicate, breaking Playwright strict mode.
|
||||
*/
|
||||
async enable(): Promise<void> {
|
||||
await this.getToggleButton().click();
|
||||
await this.page.locator(BULK_SELECT_SELECTORS.HEADER_TOGGLE).waitFor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the checkbox for a row by name
|
||||
* Gets the bulk-select checkbox for a row by name.
|
||||
*
|
||||
* The `data-test="row-select-checkbox"` attribute is on the `<span>`
|
||||
* wrapper that `TableCollection`'s `rowSelection.renderCell` puts around
|
||||
* antd's checkbox originNode (the attribute can't be moved directly
|
||||
* onto antd's `<input>` from `renderCell` because the originNode is
|
||||
* opaque). We drill into `input[type="checkbox"]` so Playwright's
|
||||
* `.check()` operates on the real input — `.check()` on the wrapper
|
||||
* `<span>` throws "Not a checkbox or radio button".
|
||||
*
|
||||
* @param rowName - The name/text identifying the row
|
||||
*/
|
||||
getRowCheckbox(rowName: string): Checkbox {
|
||||
const row = this.table.getRow(rowName);
|
||||
return new Checkbox(this.page, row.getByRole('checkbox'));
|
||||
return new Checkbox(
|
||||
this.page,
|
||||
row.locator(
|
||||
`${BULK_SELECT_SELECTORS.ROW_CHECKBOX} input[type="checkbox"]`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects a row's checkbox in bulk select mode
|
||||
* Selects a row's checkbox in bulk select mode.
|
||||
* Asserts the checkbox is checked afterwards so any state-update race
|
||||
* surfaces here rather than as a missing bulk-action button later.
|
||||
* @param rowName - The name/text identifying the row to select
|
||||
*/
|
||||
async selectRow(rowName: string): Promise<void> {
|
||||
await this.getRowCheckbox(rowName).check();
|
||||
const checkbox = this.getRowCheckbox(rowName);
|
||||
await checkbox.check();
|
||||
await expect(checkbox.element).toBeChecked();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deselects a row's checkbox in bulk select mode
|
||||
* Deselects a row's checkbox in bulk select mode.
|
||||
* Mirrors selectRow: asserts the unchecked state so any lingering selection
|
||||
* surfaces here rather than as a stale bulk-action count later.
|
||||
* @param rowName - The name/text identifying the row to deselect
|
||||
*/
|
||||
async deselectRow(rowName: string): Promise<void> {
|
||||
await this.getRowCheckbox(rowName).uncheck();
|
||||
const checkbox = this.getRowCheckbox(rowName);
|
||||
await checkbox.uncheck();
|
||||
await expect(checkbox.element).not.toBeChecked();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,22 +137,30 @@ export class BulkSelect {
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a bulk action button by name
|
||||
* @param actionName - The name of the bulk action (e.g., "Export", "Delete")
|
||||
* Gets a bulk action button by its stable action key.
|
||||
*
|
||||
* Scoping by `data-test-action-key` (rendered from `action.key`) instead
|
||||
* of visible text keeps this selector valid across locales — the
|
||||
* button's label is localized via i18n, but the action key is not.
|
||||
*
|
||||
* @param actionKey - The stable key of the bulk action (e.g., "delete", "export")
|
||||
*/
|
||||
getActionButton(actionName: string): Button {
|
||||
getActionButton(actionKey: BulkSelectActionKey): Button {
|
||||
const controls = this.getControls();
|
||||
return new Button(
|
||||
this.page,
|
||||
controls.locator(BULK_SELECT_SELECTORS.ACTION, { hasText: actionName }),
|
||||
controls.locator(
|
||||
`${BULK_SELECT_SELECTORS.ACTION}[data-test-action-key="${actionKey}"]`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks a bulk action button by name (e.g., "Export", "Delete")
|
||||
* @param actionName - The name of the bulk action to click
|
||||
* Clicks a bulk action button by its stable action key.
|
||||
* @param actionKey - The stable key of the bulk action to click
|
||||
*/
|
||||
async clickAction(actionName: string): Promise<void> {
|
||||
await this.getActionButton(actionName).click();
|
||||
async clickAction(actionKey: BulkSelectActionKey): Promise<void> {
|
||||
const button = this.getActionButton(actionKey);
|
||||
await button.click();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,3 +19,4 @@
|
||||
|
||||
// ListView-specific Playwright Components for Superset
|
||||
export { BulkSelect } from './BulkSelect';
|
||||
export type { BulkSelectActionKey } from './BulkSelect';
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { expect } from '@playwright/test';
|
||||
import { Modal, Input } from '../core';
|
||||
|
||||
/**
|
||||
@@ -27,7 +28,8 @@ import { Modal, Input } from '../core';
|
||||
*/
|
||||
export class DeleteConfirmationModal extends Modal {
|
||||
private static readonly SELECTORS = {
|
||||
CONFIRMATION_INPUT: 'input[type="text"]',
|
||||
CONFIRMATION_INPUT: '[data-test="delete-modal-input"]',
|
||||
CONFIRM_BUTTON: '[data-test="modal-confirm-button"]',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -36,12 +38,16 @@ export class DeleteConfirmationModal extends Modal {
|
||||
private get confirmationInput(): Input {
|
||||
return new Input(
|
||||
this.page,
|
||||
this.body.locator(DeleteConfirmationModal.SELECTORS.CONFIRMATION_INPUT),
|
||||
this.element.locator(
|
||||
DeleteConfirmationModal.SELECTORS.CONFIRMATION_INPUT,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills the confirmation input with the specified text.
|
||||
* Waits for the input to be visible before filling so callers don't race
|
||||
* with the modal's open animation / focus effect.
|
||||
*
|
||||
* @param confirmationText - The text to type
|
||||
* @param options - Optional fill options (timeout, force)
|
||||
@@ -57,11 +63,25 @@ export class DeleteConfirmationModal extends Modal {
|
||||
confirmationText: string,
|
||||
options?: { timeout?: number; force?: boolean },
|
||||
): Promise<void> {
|
||||
await this.confirmationInput.element.waitFor({
|
||||
state: 'visible',
|
||||
timeout: options?.timeout,
|
||||
});
|
||||
await this.confirmationInput.fill(confirmationText, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks the Delete button in the footer
|
||||
* Clicks the Delete button in the footer.
|
||||
*
|
||||
* Targets the confirm button by data-test rather than going through
|
||||
* Modal.clickFooterButton, which finds buttons by their visible text. The
|
||||
* button label is i18n'd ("Delete" / "Supprimer" / …) so name-based lookups
|
||||
* break in non-English locales.
|
||||
*
|
||||
* Also waits for the button to become enabled before clicking: it is
|
||||
* disabled until the confirmation text matches "DELETE", and React's state
|
||||
* update from fillConfirmationInput is asynchronous, so an immediate click
|
||||
* can race the disabled→enabled transition.
|
||||
*
|
||||
* @param options - Optional click options (timeout, force, delay)
|
||||
*/
|
||||
@@ -70,6 +90,10 @@ export class DeleteConfirmationModal extends Modal {
|
||||
force?: boolean;
|
||||
delay?: number;
|
||||
}): Promise<void> {
|
||||
await this.clickFooterButton('Delete', options);
|
||||
const confirmButton = this.element.locator(
|
||||
DeleteConfirmationModal.SELECTORS.CONFIRM_BUTTON,
|
||||
);
|
||||
await expect(confirmButton).toBeEnabled({ timeout: options?.timeout });
|
||||
await confirmButton.click(options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import { Page, Locator } from '@playwright/test';
|
||||
import { Table } from '../components/core';
|
||||
import { BulkSelect } from '../components/ListView';
|
||||
import { BulkSelect, BulkSelectActionKey } from '../components/ListView';
|
||||
import { gotoWithRetry } from '../helpers/navigation';
|
||||
import { URL } from '../utils/urls';
|
||||
|
||||
@@ -32,13 +32,12 @@ export class ChartListPage {
|
||||
readonly bulkSelect: BulkSelect;
|
||||
|
||||
/**
|
||||
* Action button names for getByRole('button', { name })
|
||||
* Verified: ChartList uses Icons.DeleteOutlined, Icons.UploadOutlined, Icons.EditOutlined
|
||||
* Stable data-test keys for the row action buttons in ChartList.
|
||||
*/
|
||||
private static readonly ACTION_BUTTONS = {
|
||||
DELETE: 'delete',
|
||||
EDIT: 'edit',
|
||||
EXPORT: 'upload',
|
||||
private static readonly ACTION_TEST_IDS = {
|
||||
DELETE: 'chart-row-delete',
|
||||
EDIT: 'chart-row-edit',
|
||||
EXPORT: 'chart-row-export',
|
||||
} as const;
|
||||
|
||||
constructor(page: Page) {
|
||||
@@ -98,9 +97,7 @@ export class ChartListPage {
|
||||
*/
|
||||
async clickDeleteAction(chartName: string): Promise<void> {
|
||||
const row = this.table.getRow(chartName);
|
||||
await row
|
||||
.getByRole('button', { name: ChartListPage.ACTION_BUTTONS.DELETE })
|
||||
.click();
|
||||
await row.getByTestId(ChartListPage.ACTION_TEST_IDS.DELETE).click();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,9 +106,7 @@ export class ChartListPage {
|
||||
*/
|
||||
async clickEditAction(chartName: string): Promise<void> {
|
||||
const row = this.table.getRow(chartName);
|
||||
await row
|
||||
.getByRole('button', { name: ChartListPage.ACTION_BUTTONS.EDIT })
|
||||
.click();
|
||||
await row.getByTestId(ChartListPage.ACTION_TEST_IDS.EDIT).click();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -120,9 +115,7 @@ export class ChartListPage {
|
||||
*/
|
||||
async clickExportAction(chartName: string): Promise<void> {
|
||||
const row = this.table.getRow(chartName);
|
||||
await row
|
||||
.getByRole('button', { name: ChartListPage.ACTION_BUTTONS.EXPORT })
|
||||
.click();
|
||||
await row.getByTestId(ChartListPage.ACTION_TEST_IDS.EXPORT).click();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -141,11 +134,11 @@ export class ChartListPage {
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks a bulk action button by name (e.g., "Export", "Delete")
|
||||
* @param actionName - The name of the bulk action to click
|
||||
* Clicks a bulk action button by its stable action key (e.g., "delete", "export").
|
||||
* @param actionKey - The stable key of the bulk action to click
|
||||
*/
|
||||
async clickBulkAction(actionName: string): Promise<void> {
|
||||
await this.bulkSelect.clickAction(actionName);
|
||||
async clickBulkAction(actionKey: BulkSelectActionKey): Promise<void> {
|
||||
await this.bulkSelect.clickAction(actionKey);
|
||||
}
|
||||
|
||||
// --- Card view methods ---
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import { Page, Locator } from '@playwright/test';
|
||||
import { Button, Table } from '../components/core';
|
||||
import { BulkSelect } from '../components/ListView';
|
||||
import { BulkSelect, BulkSelectActionKey } from '../components/ListView';
|
||||
import { gotoWithRetry } from '../helpers/navigation';
|
||||
import { URL } from '../utils/urls';
|
||||
|
||||
@@ -32,13 +32,12 @@ export class DashboardListPage {
|
||||
readonly bulkSelect: BulkSelect;
|
||||
|
||||
/**
|
||||
* Action button names for getByRole('button', { name })
|
||||
* DashboardList uses Icons.DeleteOutlined, Icons.UploadOutlined, Icons.EditOutlined
|
||||
* Stable data-test keys for the row action buttons in DashboardList.
|
||||
*/
|
||||
private static readonly ACTION_BUTTONS = {
|
||||
DELETE: 'delete',
|
||||
EDIT: 'edit',
|
||||
EXPORT: 'upload',
|
||||
private static readonly ACTION_TEST_IDS = {
|
||||
DELETE: 'dashboard-row-delete',
|
||||
EDIT: 'dashboard-row-edit',
|
||||
EXPORT: 'dashboard-row-export',
|
||||
} as const;
|
||||
|
||||
constructor(page: Page) {
|
||||
@@ -81,9 +80,7 @@ export class DashboardListPage {
|
||||
*/
|
||||
async clickDeleteAction(dashboardName: string): Promise<void> {
|
||||
const row = this.table.getRow(dashboardName);
|
||||
await row
|
||||
.getByRole('button', { name: DashboardListPage.ACTION_BUTTONS.DELETE })
|
||||
.click();
|
||||
await row.getByTestId(DashboardListPage.ACTION_TEST_IDS.DELETE).click();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,9 +89,7 @@ export class DashboardListPage {
|
||||
*/
|
||||
async clickEditAction(dashboardName: string): Promise<void> {
|
||||
const row = this.table.getRow(dashboardName);
|
||||
await row
|
||||
.getByRole('button', { name: DashboardListPage.ACTION_BUTTONS.EDIT })
|
||||
.click();
|
||||
await row.getByTestId(DashboardListPage.ACTION_TEST_IDS.EDIT).click();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,9 +98,7 @@ export class DashboardListPage {
|
||||
*/
|
||||
async clickExportAction(dashboardName: string): Promise<void> {
|
||||
const row = this.table.getRow(dashboardName);
|
||||
await row
|
||||
.getByRole('button', { name: DashboardListPage.ACTION_BUTTONS.EXPORT })
|
||||
.click();
|
||||
await row.getByTestId(DashboardListPage.ACTION_TEST_IDS.EXPORT).click();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,11 +117,11 @@ export class DashboardListPage {
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks a bulk action button by name (e.g., "Export", "Delete")
|
||||
* @param actionName - The name of the bulk action to click
|
||||
* Clicks a bulk action button by its stable action key (e.g., "delete", "export").
|
||||
* @param actionKey - The stable key of the bulk action to click
|
||||
*/
|
||||
async clickBulkAction(actionName: string): Promise<void> {
|
||||
await this.bulkSelect.clickAction(actionName);
|
||||
async clickBulkAction(actionKey: BulkSelectActionKey): Promise<void> {
|
||||
await this.bulkSelect.clickAction(actionKey);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import { Page, Locator } from '@playwright/test';
|
||||
import { Button, Table } from '../components/core';
|
||||
import { BulkSelect } from '../components/ListView';
|
||||
import { BulkSelect, BulkSelectActionKey } from '../components/ListView';
|
||||
import { gotoWithRetry } from '../helpers/navigation';
|
||||
import { URL } from '../utils/urls';
|
||||
|
||||
@@ -36,13 +36,14 @@ export class DatasetListPage {
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Action button names for getByRole('button', { name })
|
||||
* Stable data-test keys for the row action buttons in DatasetList
|
||||
* (shared with the semantic-view rendering since only one renders per row).
|
||||
*/
|
||||
private static readonly ACTION_BUTTONS = {
|
||||
DELETE: 'delete',
|
||||
EDIT: 'edit',
|
||||
EXPORT: 'upload', // Export button uses upload icon
|
||||
DUPLICATE: 'copy',
|
||||
private static readonly ACTION_TEST_IDS = {
|
||||
DELETE: 'dataset-row-delete',
|
||||
EDIT: 'dataset-row-edit',
|
||||
EXPORT: 'dataset-row-export',
|
||||
DUPLICATE: 'dataset-row-duplicate',
|
||||
} as const;
|
||||
|
||||
constructor(page: Page) {
|
||||
@@ -97,9 +98,7 @@ export class DatasetListPage {
|
||||
*/
|
||||
async clickDeleteAction(datasetName: string): Promise<void> {
|
||||
const row = this.table.getRow(datasetName);
|
||||
await row
|
||||
.getByRole('button', { name: DatasetListPage.ACTION_BUTTONS.DELETE })
|
||||
.click();
|
||||
await row.getByTestId(DatasetListPage.ACTION_TEST_IDS.DELETE).click();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,9 +107,7 @@ export class DatasetListPage {
|
||||
*/
|
||||
async clickEditAction(datasetName: string): Promise<void> {
|
||||
const row = this.table.getRow(datasetName);
|
||||
await row
|
||||
.getByRole('button', { name: DatasetListPage.ACTION_BUTTONS.EDIT })
|
||||
.click();
|
||||
await row.getByTestId(DatasetListPage.ACTION_TEST_IDS.EDIT).click();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,9 +116,7 @@ export class DatasetListPage {
|
||||
*/
|
||||
async clickExportAction(datasetName: string): Promise<void> {
|
||||
const row = this.table.getRow(datasetName);
|
||||
await row
|
||||
.getByRole('button', { name: DatasetListPage.ACTION_BUTTONS.EXPORT })
|
||||
.click();
|
||||
await row.getByTestId(DatasetListPage.ACTION_TEST_IDS.EXPORT).click();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,9 +125,7 @@ export class DatasetListPage {
|
||||
*/
|
||||
async clickDuplicateAction(datasetName: string): Promise<void> {
|
||||
const row = this.table.getRow(datasetName);
|
||||
await row
|
||||
.getByRole('button', { name: DatasetListPage.ACTION_BUTTONS.DUPLICATE })
|
||||
.click();
|
||||
await row.getByTestId(DatasetListPage.ACTION_TEST_IDS.DUPLICATE).click();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,11 +144,11 @@ export class DatasetListPage {
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks a bulk action button by name (e.g., "Export", "Delete")
|
||||
* @param actionName - The name of the bulk action to click
|
||||
* Clicks a bulk action button by its stable action key (e.g., "delete", "export").
|
||||
* @param actionKey - The stable key of the bulk action to click
|
||||
*/
|
||||
async clickBulkAction(actionName: string): Promise<void> {
|
||||
await this.bulkSelect.clickAction(actionName);
|
||||
async clickBulkAction(actionKey: BulkSelectActionKey): Promise<void> {
|
||||
await this.bulkSelect.clickAction(actionKey);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
expectStatusOneOf,
|
||||
expectValidExportZip,
|
||||
} from '../../helpers/api/assertions';
|
||||
import { TIMEOUT } from '../../utils/constants';
|
||||
|
||||
/**
|
||||
* Extend testWithAssets with chartListPage navigation (beforeEach equivalent).
|
||||
@@ -62,8 +63,11 @@ test('should delete a chart with confirmation', async ({
|
||||
await chartListPage.goto();
|
||||
await chartListPage.waitForTableLoad();
|
||||
|
||||
// Verify chart is visible in list
|
||||
await expect(chartListPage.getChartRow(chartName)).toBeVisible();
|
||||
// The list query is asynchronous; allow extra time on slow CI before the
|
||||
// freshly-created chart appears.
|
||||
await expect(chartListPage.getChartRow(chartName)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Click delete action button
|
||||
await chartListPage.clickDeleteAction(chartName);
|
||||
@@ -81,12 +85,14 @@ test('should delete a chart with confirmation', async ({
|
||||
// Modal should close
|
||||
await deleteModal.waitForHidden();
|
||||
|
||||
// Verify success toast appears
|
||||
// Verify success toast appears.
|
||||
const toast = new Toast(page);
|
||||
await expect(toast.getSuccess()).toBeVisible();
|
||||
|
||||
// Verify chart is removed from list
|
||||
await expect(chartListPage.getChartRow(chartName)).not.toBeVisible();
|
||||
// Verify chart is removed from list (deleted rows are removed from the DOM, so assert count rather than visibility)
|
||||
await expect(chartListPage.getChartRow(chartName)).toHaveCount(0, {
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Backend verification: API returns 404
|
||||
await expectDeleted(page, ENDPOINTS.CHART, chartId, {
|
||||
@@ -111,8 +117,11 @@ test('should edit chart name via properties modal', async ({
|
||||
await chartListPage.goto();
|
||||
await chartListPage.waitForTableLoad();
|
||||
|
||||
// Verify chart is visible in list
|
||||
await expect(chartListPage.getChartRow(chartName)).toBeVisible();
|
||||
// The list query is asynchronous; allow extra time on slow CI before the
|
||||
// freshly-created chart appears.
|
||||
await expect(chartListPage.getChartRow(chartName)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Click edit action to open properties modal
|
||||
await chartListPage.clickEditAction(chartName);
|
||||
@@ -137,7 +146,7 @@ test('should edit chart name via properties modal', async ({
|
||||
// Modal should close
|
||||
await propertiesModal.waitForHidden();
|
||||
|
||||
// Verify success toast appears
|
||||
// Verify success toast appears.
|
||||
const toast = new Toast(page);
|
||||
await expect(toast.getSuccess()).toBeVisible();
|
||||
|
||||
@@ -164,8 +173,11 @@ test('should export a chart as a zip file', async ({
|
||||
await chartListPage.goto();
|
||||
await chartListPage.waitForTableLoad();
|
||||
|
||||
// Verify chart is visible in list
|
||||
await expect(chartListPage.getChartRow(chartName)).toBeVisible();
|
||||
// The list query is asynchronous; allow extra time on slow CI before the
|
||||
// freshly-created chart appears.
|
||||
await expect(chartListPage.getChartRow(chartName)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Set up API response intercept for export endpoint
|
||||
const exportResponsePromise = waitForGet(page, ENDPOINTS.CHART_EXPORT);
|
||||
@@ -186,7 +198,7 @@ test('should bulk delete multiple charts', async ({
|
||||
chartListPage,
|
||||
testAssets,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
test.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
|
||||
// Create 2 throwaway charts for bulk delete
|
||||
const [chart1, chart2] = await Promise.all([
|
||||
@@ -202,9 +214,14 @@ test('should bulk delete multiple charts', async ({
|
||||
await chartListPage.goto();
|
||||
await chartListPage.waitForTableLoad();
|
||||
|
||||
// Verify both charts are visible in list
|
||||
await expect(chartListPage.getChartRow(chart1.name)).toBeVisible();
|
||||
await expect(chartListPage.getChartRow(chart2.name)).toBeVisible();
|
||||
// The list query is asynchronous; allow extra time on slow CI before the
|
||||
// freshly-created charts appear.
|
||||
await expect(chartListPage.getChartRow(chart1.name)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
await expect(chartListPage.getChartRow(chart2.name)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Enable bulk select mode
|
||||
await chartListPage.clickBulkSelectButton();
|
||||
@@ -214,7 +231,7 @@ test('should bulk delete multiple charts', async ({
|
||||
await chartListPage.selectChartCheckbox(chart2.name);
|
||||
|
||||
// Click bulk delete action
|
||||
await chartListPage.clickBulkAction('Delete');
|
||||
await chartListPage.clickBulkAction('delete');
|
||||
|
||||
// Delete confirmation modal should appear
|
||||
const deleteModal = new DeleteConfirmationModal(page);
|
||||
@@ -229,13 +246,17 @@ test('should bulk delete multiple charts', async ({
|
||||
// Modal should close
|
||||
await deleteModal.waitForHidden();
|
||||
|
||||
// Verify success toast appears
|
||||
// Verify success toast appears.
|
||||
const toast = new Toast(page);
|
||||
await expect(toast.getSuccess()).toBeVisible();
|
||||
|
||||
// Verify both charts are removed from list
|
||||
await expect(chartListPage.getChartRow(chart1.name)).not.toBeVisible();
|
||||
await expect(chartListPage.getChartRow(chart2.name)).not.toBeVisible();
|
||||
// Verify both charts are removed from list (deleted rows are removed from the DOM, so assert count rather than visibility)
|
||||
await expect(chartListPage.getChartRow(chart1.name)).toHaveCount(0, {
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
await expect(chartListPage.getChartRow(chart2.name)).toHaveCount(0, {
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Backend verification: Both return 404
|
||||
for (const chart of [chart1, chart2]) {
|
||||
@@ -259,8 +280,11 @@ test('should edit chart name from card view', async ({ page, testAssets }) => {
|
||||
await cardListPage.gotoCardView();
|
||||
await cardListPage.waitForCardLoad();
|
||||
|
||||
// Verify chart card is visible
|
||||
await expect(cardListPage.getChartCard(chartName)).toBeVisible();
|
||||
// The list query is asynchronous; allow extra time on slow CI before the
|
||||
// freshly-created chart card appears.
|
||||
await expect(cardListPage.getChartCard(chartName)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Open card dropdown and click edit
|
||||
await cardListPage.clickCardEditAction(chartName);
|
||||
@@ -285,13 +309,18 @@ test('should edit chart name from card view', async ({ page, testAssets }) => {
|
||||
// Modal should close
|
||||
await propertiesModal.waitForHidden();
|
||||
|
||||
// Verify success toast appears
|
||||
// Verify success toast appears.
|
||||
const toast = new Toast(page);
|
||||
await expect(toast.getSuccess()).toBeVisible();
|
||||
|
||||
// Verify the renamed card appears in card view and old name is gone
|
||||
await expect(cardListPage.getChartCard(newName)).toBeVisible();
|
||||
await expect(cardListPage.getChartCard(chartName)).not.toBeVisible();
|
||||
// (the old card name is removed from the DOM after the rename re-render).
|
||||
await expect(cardListPage.getChartCard(newName)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
await expect(cardListPage.getChartCard(chartName)).toHaveCount(0, {
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Backend verification: API returns updated name
|
||||
const response = await apiGetChart(page, chartId);
|
||||
@@ -304,6 +333,11 @@ test('should bulk export multiple charts', async ({
|
||||
chartListPage,
|
||||
testAssets,
|
||||
}) => {
|
||||
// Chains create×2 → refresh → bulk select → export. Matches the
|
||||
// sibling bulk-delete test's budget so the export response wait below
|
||||
// can exceed the 30s default without hitting the test timeout.
|
||||
test.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
|
||||
// Create 2 throwaway charts for bulk export
|
||||
const [chart1, chart2] = await Promise.all([
|
||||
createTestChart(page, testAssets, test.info(), {
|
||||
@@ -318,9 +352,14 @@ test('should bulk export multiple charts', async ({
|
||||
await chartListPage.goto();
|
||||
await chartListPage.waitForTableLoad();
|
||||
|
||||
// Verify both charts are visible in list
|
||||
await expect(chartListPage.getChartRow(chart1.name)).toBeVisible();
|
||||
await expect(chartListPage.getChartRow(chart2.name)).toBeVisible();
|
||||
// The list query is asynchronous; allow extra time on slow CI before the
|
||||
// freshly-created charts appear.
|
||||
await expect(chartListPage.getChartRow(chart1.name)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
await expect(chartListPage.getChartRow(chart2.name)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Enable bulk select mode
|
||||
await chartListPage.clickBulkSelectButton();
|
||||
@@ -329,11 +368,15 @@ test('should bulk export multiple charts', async ({
|
||||
await chartListPage.selectChartCheckbox(chart1.name);
|
||||
await chartListPage.selectChartCheckbox(chart2.name);
|
||||
|
||||
// Set up API response intercept for export endpoint
|
||||
const exportResponsePromise = waitForGet(page, ENDPOINTS.CHART_EXPORT);
|
||||
// Set up API response intercept BEFORE the click that triggers it.
|
||||
// Exports of multiple charts can take longer than 30s under load,
|
||||
// so use SLOW_TEST instead of the default test-timeout-bound budget.
|
||||
const exportResponsePromise = waitForGet(page, ENDPOINTS.CHART_EXPORT, {
|
||||
timeout: TIMEOUT.SLOW_TEST,
|
||||
});
|
||||
|
||||
// Click bulk export action
|
||||
await chartListPage.clickBulkAction('Export');
|
||||
await chartListPage.clickBulkAction('export');
|
||||
|
||||
// Wait for export API response and validate zip contains both charts
|
||||
const exportResponse = expectStatusOneOf(await exportResponsePromise, [200]);
|
||||
|
||||
@@ -68,8 +68,11 @@ test('should delete a dashboard with confirmation', async ({
|
||||
await dashboardListPage.goto();
|
||||
await dashboardListPage.waitForTableLoad();
|
||||
|
||||
// Verify dashboard is visible in list
|
||||
await expect(dashboardListPage.getDashboardRow(dashboardName)).toBeVisible();
|
||||
// The list query is asynchronous; allow extra time on slow CI before the
|
||||
// freshly-created dashboard appears.
|
||||
await expect(dashboardListPage.getDashboardRow(dashboardName)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Click delete action button
|
||||
await dashboardListPage.clickDeleteAction(dashboardName);
|
||||
@@ -81,20 +84,25 @@ test('should delete a dashboard with confirmation', async ({
|
||||
// Type "DELETE" to confirm
|
||||
await deleteModal.fillConfirmationInput('DELETE');
|
||||
|
||||
// Click the Delete button
|
||||
// Click the Delete button (waits for it to become enabled)
|
||||
await deleteModal.clickDelete();
|
||||
|
||||
// Modal should close
|
||||
await deleteModal.waitForHidden();
|
||||
|
||||
// Verify success toast appears
|
||||
// Verify success toast appears.
|
||||
const toast = new Toast(page);
|
||||
await expect(toast.getSuccess()).toBeVisible();
|
||||
|
||||
// Verify dashboard is removed from list
|
||||
await expect(
|
||||
dashboardListPage.getDashboardRow(dashboardName),
|
||||
).not.toBeVisible();
|
||||
// Verify dashboard is removed from list (extended timeout for slow CI
|
||||
// post-delete propagation — the default 8s expect.timeout intermittently
|
||||
// expires before the listview re-fetch lands).
|
||||
await expect(dashboardListPage.getDashboardRow(dashboardName)).toHaveCount(
|
||||
0,
|
||||
{
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
},
|
||||
);
|
||||
|
||||
// Backend verification: API returns 404
|
||||
await expectDeleted(page, ENDPOINTS.DASHBOARD, dashboardId, {
|
||||
@@ -119,8 +127,11 @@ test('should export a dashboard as a zip file', async ({
|
||||
await dashboardListPage.goto();
|
||||
await dashboardListPage.waitForTableLoad();
|
||||
|
||||
// Verify dashboard is visible in list
|
||||
await expect(dashboardListPage.getDashboardRow(dashboardName)).toBeVisible();
|
||||
// The list query is asynchronous; allow extra time on slow CI before the
|
||||
// freshly-created dashboard appears.
|
||||
await expect(dashboardListPage.getDashboardRow(dashboardName)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Set up API response intercept for export endpoint
|
||||
const exportResponsePromise = waitForGet(page, ENDPOINTS.DASHBOARD_EXPORT);
|
||||
@@ -141,7 +152,7 @@ test('should bulk delete multiple dashboards', async ({
|
||||
dashboardListPage,
|
||||
testAssets,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
test.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
|
||||
// Create 2 throwaway dashboards for bulk delete
|
||||
const [dashboard1, dashboard2] = await Promise.all([
|
||||
@@ -157,13 +168,14 @@ test('should bulk delete multiple dashboards', async ({
|
||||
await dashboardListPage.goto();
|
||||
await dashboardListPage.waitForTableLoad();
|
||||
|
||||
// Verify both dashboards are visible in list
|
||||
await expect(
|
||||
dashboardListPage.getDashboardRow(dashboard1.name),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
dashboardListPage.getDashboardRow(dashboard2.name),
|
||||
).toBeVisible();
|
||||
// The list query is asynchronous; allow extra time on slow CI before the
|
||||
// freshly-created dashboards appear.
|
||||
await expect(dashboardListPage.getDashboardRow(dashboard1.name)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
await expect(dashboardListPage.getDashboardRow(dashboard2.name)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Enable bulk select mode
|
||||
await dashboardListPage.clickBulkSelectButton();
|
||||
@@ -173,7 +185,7 @@ test('should bulk delete multiple dashboards', async ({
|
||||
await dashboardListPage.selectDashboardCheckbox(dashboard2.name);
|
||||
|
||||
// Click bulk delete action
|
||||
await dashboardListPage.clickBulkAction('Delete');
|
||||
await dashboardListPage.clickBulkAction('delete');
|
||||
|
||||
// Delete confirmation modal should appear
|
||||
const deleteModal = new DeleteConfirmationModal(page);
|
||||
@@ -188,17 +200,19 @@ test('should bulk delete multiple dashboards', async ({
|
||||
// Modal should close
|
||||
await deleteModal.waitForHidden();
|
||||
|
||||
// Verify success toast appears
|
||||
// Verify success toast appears.
|
||||
const toast = new Toast(page);
|
||||
await expect(toast.getSuccess()).toBeVisible();
|
||||
|
||||
// Verify both dashboards are removed from list
|
||||
await expect(
|
||||
dashboardListPage.getDashboardRow(dashboard1.name),
|
||||
).not.toBeVisible();
|
||||
await expect(
|
||||
dashboardListPage.getDashboardRow(dashboard2.name),
|
||||
).not.toBeVisible();
|
||||
// Verify both dashboards are removed from list (deleted rows are removed from the DOM, so assert count rather than visibility)
|
||||
await expect(dashboardListPage.getDashboardRow(dashboard1.name)).toHaveCount(
|
||||
0,
|
||||
{ timeout: TIMEOUT.API_RESPONSE },
|
||||
);
|
||||
await expect(dashboardListPage.getDashboardRow(dashboard2.name)).toHaveCount(
|
||||
0,
|
||||
{ timeout: TIMEOUT.API_RESPONSE },
|
||||
);
|
||||
|
||||
// Backend verification: Both return 404
|
||||
for (const dashboard of [dashboard1, dashboard2]) {
|
||||
@@ -213,6 +227,11 @@ test('should bulk export multiple dashboards', async ({
|
||||
dashboardListPage,
|
||||
testAssets,
|
||||
}) => {
|
||||
// Chains create×2 → refresh → bulk select → export. Matches the
|
||||
// sibling bulk-delete test's budget so the export response wait below
|
||||
// can exceed the 30s default without hitting the test timeout.
|
||||
test.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
|
||||
// Create 2 throwaway dashboards for bulk export
|
||||
const [dashboard1, dashboard2] = await Promise.all([
|
||||
createTestDashboard(page, testAssets, test.info(), {
|
||||
@@ -227,26 +246,31 @@ test('should bulk export multiple dashboards', async ({
|
||||
await dashboardListPage.goto();
|
||||
await dashboardListPage.waitForTableLoad();
|
||||
|
||||
// Verify both dashboards are visible in list
|
||||
await expect(
|
||||
dashboardListPage.getDashboardRow(dashboard1.name),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
dashboardListPage.getDashboardRow(dashboard2.name),
|
||||
).toBeVisible();
|
||||
// The list query is asynchronous; allow extra time on slow CI before the
|
||||
// freshly-created dashboards appear.
|
||||
await expect(dashboardListPage.getDashboardRow(dashboard1.name)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
await expect(dashboardListPage.getDashboardRow(dashboard2.name)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Enable bulk select mode
|
||||
// Enable bulk select mode (waits for the checkbox column to render)
|
||||
await dashboardListPage.clickBulkSelectButton();
|
||||
|
||||
// Select both dashboards
|
||||
// Select both dashboards (each call asserts the checkbox is checked)
|
||||
await dashboardListPage.selectDashboardCheckbox(dashboard1.name);
|
||||
await dashboardListPage.selectDashboardCheckbox(dashboard2.name);
|
||||
|
||||
// Set up API response intercept for export endpoint
|
||||
const exportResponsePromise = waitForGet(page, ENDPOINTS.DASHBOARD_EXPORT);
|
||||
// Set up API response intercept BEFORE the click that triggers it.
|
||||
// Exports of multiple dashboards can take longer than 30s under load,
|
||||
// so use SLOW_TEST instead of the default test-timeout-bound budget.
|
||||
const exportResponsePromise = waitForGet(page, ENDPOINTS.DASHBOARD_EXPORT, {
|
||||
timeout: TIMEOUT.SLOW_TEST,
|
||||
});
|
||||
|
||||
// Click bulk export action
|
||||
await dashboardListPage.clickBulkAction('Export');
|
||||
// Click bulk export action (waits for the action button to render)
|
||||
await dashboardListPage.clickBulkAction('export');
|
||||
|
||||
// Wait for export API response and validate zip contains both dashboards
|
||||
const exportResponse = expectStatusOneOf(await exportResponsePromise, [200]);
|
||||
@@ -262,14 +286,15 @@ test('should bulk export multiple dashboards', async ({
|
||||
// this prevents race conditions when parallel workers import the same dashboard.
|
||||
// (Deviation from "avoid describe" guideline is necessary for functional reasons)
|
||||
test.describe('import dashboard', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
// `timeout` on describe.configure also bounds fixture setup, so the
|
||||
// `dashboardListPage` navigation gets the SLOW_TEST budget too —
|
||||
// inline `test.setTimeout()` only applies once the test body runs.
|
||||
test.describe.configure({ mode: 'serial', timeout: TIMEOUT.SLOW_TEST });
|
||||
test('should import a dashboard from a zip file', async ({
|
||||
page,
|
||||
dashboardListPage,
|
||||
testAssets,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
// Create a dashboard, export it via API, then delete it, then reimport via UI
|
||||
const { id: dashboardId, name: dashboardName } = await createTestDashboard(
|
||||
page,
|
||||
@@ -293,12 +318,13 @@ test.describe('import dashboard', () => {
|
||||
label: `Dashboard ${dashboardId}`,
|
||||
});
|
||||
|
||||
// Refresh to confirm dashboard is no longer in the list
|
||||
// Refresh to confirm dashboard is no longer in the list (deleted rows are removed from the DOM, so assert count rather than visibility)
|
||||
await dashboardListPage.goto();
|
||||
await dashboardListPage.waitForTableLoad();
|
||||
await expect(
|
||||
dashboardListPage.getDashboardRow(dashboardName),
|
||||
).not.toBeVisible();
|
||||
await expect(dashboardListPage.getDashboardRow(dashboardName)).toHaveCount(
|
||||
0,
|
||||
{ timeout: TIMEOUT.API_RESPONSE },
|
||||
);
|
||||
|
||||
// Click the import button
|
||||
await dashboardListPage.clickImportButton();
|
||||
@@ -328,7 +354,7 @@ test.describe('import dashboard', () => {
|
||||
// Handle overwrite confirmation if dashboard already exists
|
||||
const overwriteInput = importModal.getOverwriteInput();
|
||||
await overwriteInput
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
.waitFor({ state: 'visible', timeout: TIMEOUT.CONFIRM_DIALOG })
|
||||
.catch(error => {
|
||||
if (!(error instanceof Error) || error.name !== 'TimeoutError') {
|
||||
throw error;
|
||||
@@ -350,18 +376,21 @@ test.describe('import dashboard', () => {
|
||||
// Modal should close on success
|
||||
await importModal.waitForHidden({ timeout: TIMEOUT.FILE_IMPORT });
|
||||
|
||||
// Verify success toast appears
|
||||
// Verify success toast appears.
|
||||
const toast = new Toast(page);
|
||||
await expect(toast.getSuccess()).toBeVisible({ timeout: 10000 });
|
||||
await expect(toast.getSuccess()).toBeVisible({
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
});
|
||||
|
||||
// Refresh to see the imported dashboard
|
||||
await dashboardListPage.goto();
|
||||
await dashboardListPage.waitForTableLoad();
|
||||
|
||||
// Verify dashboard appears in list
|
||||
await expect(
|
||||
dashboardListPage.getDashboardRow(dashboardName),
|
||||
).toBeVisible();
|
||||
// The list query is asynchronous; allow extra time on slow CI before the
|
||||
// freshly-imported dashboard appears.
|
||||
await expect(dashboardListPage.getDashboardRow(dashboardName)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Track for cleanup: look up the reimported dashboard by title
|
||||
const reimported = await getDashboardByName(page, dashboardName);
|
||||
|
||||
@@ -107,8 +107,11 @@ test('should delete a dataset with confirmation', async ({
|
||||
await datasetListPage.goto();
|
||||
await datasetListPage.waitForTableLoad();
|
||||
|
||||
// Verify dataset is visible in list
|
||||
await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible();
|
||||
// The list query is asynchronous; allow extra time on slow CI before the
|
||||
// freshly-created dataset appears.
|
||||
await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Click delete action button
|
||||
await datasetListPage.clickDeleteAction(datasetName);
|
||||
@@ -126,14 +129,15 @@ test('should delete a dataset with confirmation', async ({
|
||||
// Modal should close
|
||||
await deleteModal.waitForHidden();
|
||||
|
||||
// Verify success toast appears with correct message
|
||||
// Verify success toast appears with correct message.
|
||||
const toast = new Toast(page);
|
||||
const successToast = toast.getSuccess();
|
||||
await expect(successToast).toBeVisible();
|
||||
await expect(toast.getSuccess()).toBeVisible();
|
||||
await expect(toast.getMessage()).toContainText('Deleted');
|
||||
|
||||
// Verify dataset is removed from list
|
||||
await expect(datasetListPage.getDatasetRow(datasetName)).not.toBeVisible();
|
||||
// Verify dataset is removed from list (deleted rows are removed from the DOM, so assert count rather than visibility)
|
||||
await expect(datasetListPage.getDatasetRow(datasetName)).toHaveCount(0, {
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Verify via API that dataset no longer exists (404)
|
||||
await expectDeleted(page, ENDPOINTS.DATASET, datasetId, {
|
||||
@@ -155,10 +159,13 @@ test('should duplicate a dataset with new name', async ({
|
||||
);
|
||||
const duplicateName = `duplicate_${Date.now()}_${test.info().parallelIndex}`;
|
||||
|
||||
// Navigate to list and verify original dataset is visible
|
||||
// Navigate to list and verify original dataset is visible.
|
||||
// The list query is asynchronous; allow extra time on slow CI.
|
||||
await datasetListPage.goto();
|
||||
await datasetListPage.waitForTableLoad();
|
||||
await expect(datasetListPage.getDatasetRow(originalName)).toBeVisible();
|
||||
await expect(datasetListPage.getDatasetRow(originalName)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Set up response intercept to capture duplicate dataset ID
|
||||
const duplicateResponsePromise = waitForPost(
|
||||
@@ -201,9 +208,14 @@ test('should duplicate a dataset with new name', async ({
|
||||
await datasetListPage.goto();
|
||||
await datasetListPage.waitForTableLoad();
|
||||
|
||||
// Verify both datasets exist in list
|
||||
await expect(datasetListPage.getDatasetRow(originalName)).toBeVisible();
|
||||
await expect(datasetListPage.getDatasetRow(duplicateName)).toBeVisible();
|
||||
// The list query is asynchronous; allow extra time on slow CI before the
|
||||
// duplicate appears alongside the original.
|
||||
await expect(datasetListPage.getDatasetRow(originalName)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
await expect(datasetListPage.getDatasetRow(duplicateName)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// API Verification: Fetch both datasets via detail API for consistent comparison
|
||||
// (list API may return undefined for fields that detail API returns as null)
|
||||
@@ -256,6 +268,11 @@ test('should export multiple datasets via bulk select action', async ({
|
||||
datasetListPage,
|
||||
testAssets,
|
||||
}) => {
|
||||
// Chains create×2 → refresh → bulk select → export. Matches the
|
||||
// sibling bulk-delete test's budget so the export response wait below
|
||||
// can exceed the 30s default without hitting the test timeout.
|
||||
test.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
|
||||
// Create 2 throwaway datasets for bulk export
|
||||
const [dataset1, dataset2] = await Promise.all([
|
||||
createTestDataset(page, testAssets, test.info(), {
|
||||
@@ -270,9 +287,14 @@ test('should export multiple datasets via bulk select action', async ({
|
||||
await datasetListPage.goto();
|
||||
await datasetListPage.waitForTableLoad();
|
||||
|
||||
// Verify both datasets are visible in list
|
||||
await expect(datasetListPage.getDatasetRow(dataset1.name)).toBeVisible();
|
||||
await expect(datasetListPage.getDatasetRow(dataset2.name)).toBeVisible();
|
||||
// The list query is asynchronous; allow extra time on slow CI before the
|
||||
// freshly-created datasets appear.
|
||||
await expect(datasetListPage.getDatasetRow(dataset1.name)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
await expect(datasetListPage.getDatasetRow(dataset2.name)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Enable bulk select mode
|
||||
await datasetListPage.clickBulkSelectButton();
|
||||
@@ -281,11 +303,15 @@ test('should export multiple datasets via bulk select action', async ({
|
||||
await datasetListPage.selectDatasetCheckbox(dataset1.name);
|
||||
await datasetListPage.selectDatasetCheckbox(dataset2.name);
|
||||
|
||||
// Set up API response intercept for export endpoint
|
||||
const exportResponsePromise = waitForGet(page, ENDPOINTS.DATASET_EXPORT);
|
||||
// Set up API response intercept BEFORE the click that triggers it.
|
||||
// Exports of multiple datasets can take longer than 30s under load,
|
||||
// so use SLOW_TEST instead of the default test-timeout-bound budget.
|
||||
const exportResponsePromise = waitForGet(page, ENDPOINTS.DATASET_EXPORT, {
|
||||
timeout: TIMEOUT.SLOW_TEST,
|
||||
});
|
||||
|
||||
// Click bulk export action
|
||||
await datasetListPage.clickBulkAction('Export');
|
||||
await datasetListPage.clickBulkAction('export');
|
||||
|
||||
// Wait for export API response and validate zip contains multiple datasets
|
||||
const exportResponse = expectStatusOneOf(await exportResponsePromise, [200]);
|
||||
@@ -312,8 +338,11 @@ test('should edit dataset name via modal', async ({
|
||||
await datasetListPage.goto();
|
||||
await datasetListPage.waitForTableLoad();
|
||||
|
||||
// Verify dataset is visible in list
|
||||
await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible();
|
||||
// The list query is asynchronous; allow extra time on slow CI before the
|
||||
// freshly-created dataset appears.
|
||||
await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Click edit action to open modal
|
||||
await datasetListPage.clickEditAction(datasetName);
|
||||
@@ -348,9 +377,9 @@ test('should edit dataset name via modal', async ({
|
||||
// Modal should close
|
||||
await editModal.waitForHidden();
|
||||
|
||||
// Verify success toast appears
|
||||
// Verify success toast appears.
|
||||
const toast = new Toast(page);
|
||||
await expect(toast.getSuccess()).toBeVisible({ timeout: 10000 });
|
||||
await expect(toast.getSuccess()).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
|
||||
// Verify via API that name was saved
|
||||
const updatedDatasetRes = await apiGetDataset(page, datasetId);
|
||||
@@ -363,6 +392,8 @@ test('should bulk delete multiple datasets', async ({
|
||||
datasetListPage,
|
||||
testAssets,
|
||||
}) => {
|
||||
test.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
|
||||
// Create 2 throwaway datasets for bulk delete
|
||||
const [dataset1, dataset2] = await Promise.all([
|
||||
createTestDataset(page, testAssets, test.info(), {
|
||||
@@ -377,9 +408,14 @@ test('should bulk delete multiple datasets', async ({
|
||||
await datasetListPage.goto();
|
||||
await datasetListPage.waitForTableLoad();
|
||||
|
||||
// Verify both datasets are visible in list
|
||||
await expect(datasetListPage.getDatasetRow(dataset1.name)).toBeVisible();
|
||||
await expect(datasetListPage.getDatasetRow(dataset2.name)).toBeVisible();
|
||||
// The list query is asynchronous; allow extra time on slow CI before the
|
||||
// freshly-created datasets appear.
|
||||
await expect(datasetListPage.getDatasetRow(dataset1.name)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
await expect(datasetListPage.getDatasetRow(dataset2.name)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Enable bulk select mode
|
||||
await datasetListPage.clickBulkSelectButton();
|
||||
@@ -389,7 +425,7 @@ test('should bulk delete multiple datasets', async ({
|
||||
await datasetListPage.selectDatasetCheckbox(dataset2.name);
|
||||
|
||||
// Click bulk delete action
|
||||
await datasetListPage.clickBulkAction('Delete');
|
||||
await datasetListPage.clickBulkAction('delete');
|
||||
|
||||
// Delete confirmation modal should appear
|
||||
const deleteModal = new DeleteConfirmationModal(page);
|
||||
@@ -404,13 +440,17 @@ test('should bulk delete multiple datasets', async ({
|
||||
// Modal should close
|
||||
await deleteModal.waitForHidden();
|
||||
|
||||
// Verify success toast appears
|
||||
// Verify success toast appears.
|
||||
const toast = new Toast(page);
|
||||
await expect(toast.getSuccess()).toBeVisible();
|
||||
|
||||
// Verify both datasets are removed from list
|
||||
await expect(datasetListPage.getDatasetRow(dataset1.name)).not.toBeVisible();
|
||||
await expect(datasetListPage.getDatasetRow(dataset2.name)).not.toBeVisible();
|
||||
// Verify both datasets are removed from list (deleted rows are removed from the DOM, so assert count rather than visibility)
|
||||
await expect(datasetListPage.getDatasetRow(dataset1.name)).toHaveCount(0, {
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
await expect(datasetListPage.getDatasetRow(dataset2.name)).toHaveCount(0, {
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Verify via API that datasets no longer exist (404)
|
||||
await expectDeleted(page, ENDPOINTS.DATASET, dataset1.id, {
|
||||
@@ -426,14 +466,15 @@ test('should bulk delete multiple datasets', async ({
|
||||
// this prevents race conditions when parallel workers import the same dataset.
|
||||
// (Deviation from "avoid describe" guideline is necessary for functional reasons)
|
||||
test.describe('import dataset', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
// `timeout` on describe.configure also bounds fixture setup, so the
|
||||
// `datasetListPage` navigation gets the SLOW_TEST budget too —
|
||||
// inline `test.setTimeout()` only applies once the test body runs.
|
||||
test.describe.configure({ mode: 'serial', timeout: TIMEOUT.SLOW_TEST });
|
||||
test('should import a dataset from a zip file', async ({
|
||||
page,
|
||||
datasetListPage,
|
||||
testAssets,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
// Create a dataset, export it via API, then delete it, then reimport via UI
|
||||
const { id: datasetId, name: datasetName } = await createTestDataset(
|
||||
page,
|
||||
@@ -455,10 +496,12 @@ test.describe('import dataset', () => {
|
||||
label: `Dataset ${datasetId}`,
|
||||
});
|
||||
|
||||
// Refresh to confirm dataset is no longer in the list
|
||||
// Refresh to confirm dataset is no longer in the list (deleted rows are removed from the DOM, so assert count rather than visibility)
|
||||
await datasetListPage.goto();
|
||||
await datasetListPage.waitForTableLoad();
|
||||
await expect(datasetListPage.getDatasetRow(datasetName)).not.toBeVisible();
|
||||
await expect(datasetListPage.getDatasetRow(datasetName)).toHaveCount(0, {
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Click the import button
|
||||
await datasetListPage.clickImportButton();
|
||||
@@ -485,7 +528,7 @@ test.describe('import dataset', () => {
|
||||
// First response may be 409/422 indicating overwrite is required
|
||||
const overwriteInput = importModal.getOverwriteInput();
|
||||
await overwriteInput
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
.waitFor({ state: 'visible', timeout: TIMEOUT.CONFIRM_DIALOG })
|
||||
.catch(error => {
|
||||
if (!(error instanceof Error) || error.name !== 'TimeoutError') {
|
||||
throw error;
|
||||
@@ -507,16 +550,21 @@ test.describe('import dataset', () => {
|
||||
// Modal should close on success
|
||||
await importModal.waitForHidden({ timeout: TIMEOUT.FILE_IMPORT });
|
||||
|
||||
// Verify success toast appears
|
||||
// Verify success toast appears.
|
||||
const toast = new Toast(page);
|
||||
await expect(toast.getSuccess()).toBeVisible({ timeout: 10000 });
|
||||
await expect(toast.getSuccess()).toBeVisible({
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
});
|
||||
|
||||
// Refresh to see the imported dataset
|
||||
await datasetListPage.goto();
|
||||
await datasetListPage.waitForTableLoad();
|
||||
|
||||
// Verify dataset appears in list
|
||||
await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible();
|
||||
// The list query is asynchronous; allow extra time on slow CI before the
|
||||
// freshly-imported dataset appears.
|
||||
await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Track for cleanup: the dataset import API returns {"message": "OK"}
|
||||
// with no ID, so look up the reimported dataset by name.
|
||||
|
||||
@@ -65,9 +65,12 @@ export const TIMEOUT = {
|
||||
UI_TRANSITION: 5000, // 5s ceiling for Ant Design animations (~300-500ms actual)
|
||||
|
||||
/**
|
||||
* SQL query execution (query → backend processing → results)
|
||||
* SQL query execution (query → backend processing → results).
|
||||
* 30s matches Playwright's default test timeout — cold-start CI on the
|
||||
* /app/prefix variant has been observed running trivial SELECTs in
|
||||
* ~25s before results render, which exceeded the previous 15s budget.
|
||||
*/
|
||||
QUERY_EXECUTION: 15000, // 15s for SQL queries that may take longer than default expect timeout
|
||||
QUERY_EXECUTION: 30000, // 30s for SQL queries
|
||||
|
||||
/**
|
||||
* Extended test timeout for multi-step tests (page load + query execution + assertions).
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"fast-safe-stringify": "^2.1.1",
|
||||
"lodash": "^4.18.1",
|
||||
"nvd3-fork": "^2.0.5",
|
||||
"dompurify": "^3.4.5",
|
||||
"dompurify": "^3.4.7",
|
||||
"prop-types": "^15.8.1",
|
||||
"urijs": "^1.19.11"
|
||||
},
|
||||
@@ -42,7 +42,7 @@
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"dayjs": "^1.11.19",
|
||||
"dayjs": "^1.11.21",
|
||||
"react": "^18.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,6 +165,26 @@ function escapeSQLString(value: string): string {
|
||||
return value.replace(/'/g, "''");
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce a range-filter bound to a finite number, or null if it is not a valid
|
||||
* numeric value. Unlike a bare Number() call, empty and whitespace-only strings
|
||||
* are rejected (Number('') === 0), so they never get interpolated into SQL.
|
||||
* @param value - Raw bound value from the AG Grid filter model
|
||||
* @returns The finite number, or null if the value is not numeric
|
||||
*/
|
||||
function toFiniteNumber(value: FilterValue | undefined): number | null {
|
||||
// Number(null) and Number('') both coerce to 0 and pass Number.isFinite,
|
||||
// so reject nullish and empty/whitespace-only strings before coercing.
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (typeof value === 'string' && value.trim() === '') {
|
||||
return null;
|
||||
}
|
||||
const coerced = Number(value);
|
||||
return Number.isFinite(coerced) ? coerced : null;
|
||||
}
|
||||
|
||||
// Maximum column name length - conservative upper bound that exceeds all common
|
||||
// database identifier limits (MySQL: 64, PostgreSQL: 63, SQL Server: 128, Oracle: 128)
|
||||
const MAX_COLUMN_NAME_LENGTH = 255;
|
||||
@@ -378,8 +398,17 @@ function simpleFilterToWhereClause(
|
||||
return '';
|
||||
}
|
||||
|
||||
if (type === FILTER_OPERATORS.IN_RANGE && filterTo !== undefined) {
|
||||
return `${columnName} ${SQL_OPERATORS.BETWEEN} ${value} AND ${filterTo}`;
|
||||
// Handle IN_RANGE unconditionally so a missing/cleared upper bound can never
|
||||
// fall through to the generic clause below and emit an invalid single-operand
|
||||
// BETWEEN. Range bounds are interpolated into the clause without quoting, so
|
||||
// both ends must coerce to finite numbers; otherwise the clause is dropped.
|
||||
if (type === FILTER_OPERATORS.IN_RANGE) {
|
||||
const lowerBound = toFiniteNumber(value);
|
||||
const upperBound = toFiniteNumber(filterTo);
|
||||
if (lowerBound === null || upperBound === null) {
|
||||
return '';
|
||||
}
|
||||
return `${columnName} ${SQL_OPERATORS.BETWEEN} ${lowerBound} AND ${upperBound}`;
|
||||
}
|
||||
|
||||
const formattedValue = formatValueForOperator(type, value!);
|
||||
|
||||
@@ -284,6 +284,67 @@ describe('agGridFilterConverter', () => {
|
||||
val: 18,
|
||||
});
|
||||
});
|
||||
|
||||
test('should emit a numeric BETWEEN clause for a metric range filter', () => {
|
||||
const filterModel: AgGridFilterModel = {
|
||||
revenue: {
|
||||
filterType: 'number',
|
||||
type: 'inRange',
|
||||
filter: 10,
|
||||
filterTo: 20,
|
||||
},
|
||||
};
|
||||
|
||||
// revenue is a metric, so the range filter renders as a HAVING clause
|
||||
const result = convertAgGridFiltersToSQL(filterModel, ['revenue']);
|
||||
|
||||
expect(result.havingClause).toContain('BETWEEN 10 AND 20');
|
||||
});
|
||||
|
||||
test('should drop a metric range filter whose bounds are not numeric', () => {
|
||||
const filterModel = {
|
||||
revenue: {
|
||||
filterType: 'number',
|
||||
type: 'inRange',
|
||||
filter: '0',
|
||||
filterTo: '100 OR 1=1',
|
||||
},
|
||||
} as unknown as AgGridFilterModel;
|
||||
|
||||
const result = convertAgGridFiltersToSQL(filterModel, ['revenue']);
|
||||
|
||||
// a non-numeric bound must never be interpolated into the clause
|
||||
expect(result.havingClause).toBeUndefined();
|
||||
|
||||
const emptyBoundFilterModel = {
|
||||
revenue: {
|
||||
filterType: 'number',
|
||||
type: 'inRange',
|
||||
filter: '0',
|
||||
filterTo: '',
|
||||
},
|
||||
} as unknown as AgGridFilterModel;
|
||||
|
||||
const result2 = convertAgGridFiltersToSQL(emptyBoundFilterModel, [
|
||||
'revenue',
|
||||
]);
|
||||
expect(result2.havingClause).toBeUndefined();
|
||||
|
||||
// A missing upper bound must drop the clause rather than fall through
|
||||
// to a generic single-operand BETWEEN.
|
||||
const missingBoundFilterModel = {
|
||||
revenue: {
|
||||
filterType: 'number',
|
||||
type: 'inRange',
|
||||
filter: '0',
|
||||
},
|
||||
} as unknown as AgGridFilterModel;
|
||||
|
||||
const result3 = convertAgGridFiltersToSQL(missingBoundFilterModel, [
|
||||
'revenue',
|
||||
]);
|
||||
expect(result3.havingClause).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Null/blank filters', () => {
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"dayjs": "^1.11.19",
|
||||
"dayjs": "^1.11.21",
|
||||
"echarts": "*",
|
||||
"memoize-one": "*",
|
||||
"react": "^18.2.0"
|
||||
|
||||
@@ -1016,8 +1016,12 @@ export default function transformProps(
|
||||
trigger: richTooltip ? 'axis' : 'item',
|
||||
formatter: (params: any) => {
|
||||
const [xIndex, yIndex] = isHorizontal ? [1, 0] : [0, 1];
|
||||
// For axis tooltips, prefer axisValue/axisValueLabel which contains the full label
|
||||
// even when the axis label is visually truncated
|
||||
const xValue: number = richTooltip
|
||||
? params[0].value[xIndex]
|
||||
? (params[0].axisValue ??
|
||||
params[0].axisValueLabel ??
|
||||
params[0].value[xIndex])
|
||||
: params.value[xIndex];
|
||||
const forecastValue: CallbackDataParams[] = richTooltip
|
||||
? params
|
||||
|
||||
@@ -1657,3 +1657,100 @@ test('should assign distinct dash patterns for multiple time offsets consistentl
|
||||
// must be different patterns
|
||||
expect(symbol1).not.toEqual(symbol2);
|
||||
});
|
||||
|
||||
describe('Tooltip with long labels', () => {
|
||||
test('should use axisValue for tooltip when available (richTooltip)', () => {
|
||||
const longLabelData: ChartDataResponseResult[] = [
|
||||
createTestQueryData([
|
||||
{
|
||||
'This is a very long category name that would normally be truncated': 100,
|
||||
__timestamp: 599616000000,
|
||||
},
|
||||
{
|
||||
'Another extremely long category name for testing purposes': 200,
|
||||
__timestamp: 599916000000,
|
||||
},
|
||||
]),
|
||||
];
|
||||
|
||||
const chartProps = createTestChartProps({
|
||||
formData: {
|
||||
richTooltip: true,
|
||||
},
|
||||
queriesData: longLabelData,
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(chartProps);
|
||||
|
||||
// Get the tooltip formatter function
|
||||
const tooltipFormatter = (transformedProps.echartOptions as any).tooltip
|
||||
.formatter;
|
||||
|
||||
// Simulate params from ECharts with axisValue containing full label
|
||||
// Use distinct values for axisValue and seriesName to verify axisValue is used
|
||||
const mockParams = [
|
||||
{
|
||||
axisValue:
|
||||
'This is a very long category name that would normally be truncated',
|
||||
value: [599616000000, 100],
|
||||
seriesName: 'Some Series Name',
|
||||
},
|
||||
];
|
||||
|
||||
// Call the formatter and check it uses the full label from axisValue
|
||||
const result = tooltipFormatter(mockParams);
|
||||
expect(result).toContain(
|
||||
'This is a very long category name that would normally be truncated',
|
||||
);
|
||||
});
|
||||
|
||||
test('should fallback to value when axisValue is not available', () => {
|
||||
const chartProps = createTestChartProps({
|
||||
formData: {
|
||||
richTooltip: true,
|
||||
},
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(chartProps);
|
||||
|
||||
const tooltipFormatter = (transformedProps.echartOptions as any).tooltip
|
||||
.formatter;
|
||||
|
||||
// Simulate params without axisValue
|
||||
const mockParams = [
|
||||
{
|
||||
value: [599616000000, 1],
|
||||
seriesName: 'San Francisco',
|
||||
},
|
||||
];
|
||||
|
||||
// Should fall back to the x-value (value[xIndex]) and render it in the title
|
||||
const result = tooltipFormatter(mockParams);
|
||||
expect(typeof result).toBe('string');
|
||||
expect(result).toContain('599616000000');
|
||||
});
|
||||
|
||||
test('should handle item tooltips correctly', () => {
|
||||
const chartProps = createTestChartProps({
|
||||
formData: {
|
||||
richTooltip: false,
|
||||
},
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(chartProps);
|
||||
|
||||
const tooltipFormatter = (transformedProps.echartOptions as any).tooltip
|
||||
.formatter;
|
||||
|
||||
// For item tooltips, params is a single object
|
||||
const mockParams = {
|
||||
value: [599616000000, 1],
|
||||
seriesName: 'San Francisco',
|
||||
};
|
||||
|
||||
// The item-tooltip x-value (value[xIndex]) should appear in the title
|
||||
const result = tooltipFormatter(mockParams);
|
||||
expect(typeof result).toBe('string');
|
||||
expect(result).toContain('599616000000');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"ace-builds": "^1.4.14",
|
||||
"handlebars": "^4.7.8",
|
||||
"lodash": "^4.18.1",
|
||||
"dayjs": "^1.11.19",
|
||||
"dayjs": "^1.11.21",
|
||||
"react": "^18.2.0",
|
||||
"react-ace": "^10.1.0",
|
||||
"react-dom": "^18.2.0"
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"dayjs": "^1.11.19",
|
||||
"dayjs": "^1.11.21",
|
||||
"mapbox-gl": ">=1.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
|
||||
@@ -23,6 +23,10 @@ import React from 'react';
|
||||
import { configure as configureTestingLibrary } from '@testing-library/react';
|
||||
import { matchers } from '@emotion/jest';
|
||||
|
||||
if (typeof Intl.DurationFormat === 'undefined') {
|
||||
require('@formatjs/intl-durationformat/polyfill.js');
|
||||
}
|
||||
|
||||
configureTestingLibrary({
|
||||
testIdAttribute: 'data-test',
|
||||
});
|
||||
|
||||
@@ -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 { ChartProps } from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/theme';
|
||||
import { render, screen, userEvent } from 'spec/helpers/testing-library';
|
||||
import PluginFilterDynamicGroupBy from './DynamicGroupByPlugin';
|
||||
import transformProps from './transformProps';
|
||||
import { PluginFilterGroupByProps } from './types';
|
||||
|
||||
const baseProps = {
|
||||
width: 220,
|
||||
height: 20,
|
||||
hooks: {},
|
||||
filterState: { value: [] },
|
||||
queriesData: [
|
||||
{
|
||||
data: [
|
||||
{ column_name: 'banana' },
|
||||
{ column_name: 'apple' },
|
||||
{ column_name: 'cherry' },
|
||||
],
|
||||
},
|
||||
],
|
||||
formData: {
|
||||
datasource: '1__table',
|
||||
vizType: 'filter_groupby',
|
||||
nativeFilterId: 'test-filter',
|
||||
defaultValue: [],
|
||||
inputRef: { current: null },
|
||||
},
|
||||
};
|
||||
|
||||
const renderPlugin = (sortAscending?: boolean) => {
|
||||
const chartProps = new ChartProps({
|
||||
...baseProps,
|
||||
formData: { ...baseProps.formData, sortAscending },
|
||||
theme: supersetTheme,
|
||||
});
|
||||
return render(
|
||||
<PluginFilterDynamicGroupBy
|
||||
{...(transformProps(chartProps) as unknown as PluginFilterGroupByProps)}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
const getOpenedOptionOrder = async () => {
|
||||
userEvent.click(screen.getAllByRole('combobox')[0]);
|
||||
const options = await screen.findAllByRole('option');
|
||||
return options.map(option => option.textContent);
|
||||
};
|
||||
|
||||
test('sorts display control values A-Z when sortAscending is true', async () => {
|
||||
renderPlugin(true);
|
||||
expect(await getOpenedOptionOrder()).toEqual(['apple', 'banana', 'cherry']);
|
||||
});
|
||||
|
||||
test('sorts display control values Z-A when sortAscending is false', async () => {
|
||||
renderPlugin(false);
|
||||
expect(await getOpenedOptionOrder()).toEqual(['cherry', 'banana', 'apple']);
|
||||
});
|
||||
|
||||
test('preserves source order when sorting is disabled', async () => {
|
||||
renderPlugin(undefined);
|
||||
expect(await getOpenedOptionOrder()).toEqual(['banana', 'apple', 'cherry']);
|
||||
});
|
||||
@@ -18,13 +18,15 @@
|
||||
*/
|
||||
import { t, tn } from '@apache-superset/core/translation';
|
||||
import { ensureIsArray, ExtraFormData } from '@superset-ui/core';
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { useCallback, useEffect, useState, useMemo } from 'react';
|
||||
import {
|
||||
FormItem,
|
||||
type FormItemProps,
|
||||
LabeledValue,
|
||||
Select,
|
||||
type SelectValue,
|
||||
} from '@superset-ui/core/components';
|
||||
import { propertyComparator } from '@superset-ui/core/components/Select/utils';
|
||||
import { FilterPluginStyle, StatusMessage } from '../common';
|
||||
import { PluginFilterGroupByProps, ColumnOption, ColumnData } from './types';
|
||||
|
||||
@@ -116,6 +118,20 @@ export default function PluginFilterDynamicGroupBy(
|
||||
[data],
|
||||
);
|
||||
|
||||
const sortComparator = useCallback(
|
||||
(a: LabeledValue, b: LabeledValue) => {
|
||||
if (formData.sortAscending === undefined) {
|
||||
return 0;
|
||||
}
|
||||
const labelComparator = propertyComparator('label');
|
||||
if (formData.sortAscending) {
|
||||
return labelComparator(a, b);
|
||||
}
|
||||
return labelComparator(b, a);
|
||||
},
|
||||
[formData.sortAscending],
|
||||
);
|
||||
|
||||
return (
|
||||
<FilterPluginStyle height={height} width={width}>
|
||||
<FormItem validateStatus={filterState.validateStatus} {...formItemData}>
|
||||
@@ -132,6 +148,7 @@ export default function PluginFilterDynamicGroupBy(
|
||||
ref={inputRef}
|
||||
options={options}
|
||||
onOpenChange={setFilterActive}
|
||||
sortComparator={sortComparator}
|
||||
/>
|
||||
</div>
|
||||
</FormItem>
|
||||
|
||||
@@ -76,7 +76,6 @@ export const DEFAULT_FORM_DATA: PluginFilterGroupByCustomizeProps = {
|
||||
dataset: null,
|
||||
column: null,
|
||||
sortFilter: false,
|
||||
sortAscending: true,
|
||||
canSelectMultiple: true,
|
||||
defaultValue: null,
|
||||
};
|
||||
|
||||
@@ -16,9 +16,9 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ErrorInfo, PureComponent } from 'react';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import { ErrorInfo, useCallback, useEffect, useRef } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import {
|
||||
ensureIsArray,
|
||||
FeatureFlag,
|
||||
@@ -60,7 +60,7 @@ export interface ChartProps {
|
||||
sharedLabelColors?: string;
|
||||
width: number;
|
||||
height: number;
|
||||
setControlValue: (name: string, value: unknown) => void;
|
||||
setControlValue?: (name: string, value: unknown) => void;
|
||||
timeout?: number;
|
||||
vizType: string;
|
||||
triggerRender?: boolean;
|
||||
@@ -69,7 +69,7 @@ export interface ChartProps {
|
||||
chartAlert?: string;
|
||||
chartStatus?: ChartStatus;
|
||||
chartStackTrace?: string;
|
||||
queriesResponse: ChartState['queriesResponse'];
|
||||
queriesResponse?: ChartState['queriesResponse'];
|
||||
latestQueryFormData?: ChartState['latestQueryFormData'];
|
||||
triggerQuery?: boolean;
|
||||
chartIsStale?: boolean;
|
||||
@@ -126,19 +126,6 @@ const NONEXISTENT_DATASET = t(
|
||||
'The dataset associated with this chart no longer exists',
|
||||
);
|
||||
|
||||
const defaultProps: Partial<ChartProps> = {
|
||||
addFilter: () => BLANK,
|
||||
onFilterMenuOpen: () => BLANK,
|
||||
onFilterMenuClose: () => BLANK,
|
||||
initialValues: BLANK,
|
||||
setControlValue: () => BLANK,
|
||||
triggerRender: false,
|
||||
dashboardId: undefined,
|
||||
chartStackTrace: undefined,
|
||||
force: false,
|
||||
isInView: true,
|
||||
};
|
||||
|
||||
const Styles = styled.div<{ height: number; width?: number }>`
|
||||
min-height: ${p => p.height}px;
|
||||
position: relative;
|
||||
@@ -186,252 +173,321 @@ const MessageSpan = styled.span`
|
||||
color: ${({ theme }) => theme.colorText};
|
||||
`;
|
||||
|
||||
class Chart extends PureComponent<ChartProps, {}> {
|
||||
static defaultProps = defaultProps;
|
||||
function Chart({
|
||||
addFilter = () => BLANK,
|
||||
onFilterMenuOpen = () => BLANK,
|
||||
onFilterMenuClose = () => BLANK,
|
||||
initialValues = BLANK,
|
||||
setControlValue = () => BLANK,
|
||||
triggerRender = false,
|
||||
dashboardId,
|
||||
chartStackTrace,
|
||||
force = false,
|
||||
isInView = true,
|
||||
...restProps
|
||||
}: ChartProps): JSX.Element {
|
||||
const {
|
||||
actions,
|
||||
chartId,
|
||||
datasource,
|
||||
formData,
|
||||
timeout,
|
||||
ownState,
|
||||
chartAlert,
|
||||
chartStatus,
|
||||
queriesResponse = [],
|
||||
errorMessage,
|
||||
chartIsStale,
|
||||
width,
|
||||
height,
|
||||
datasetsStatus,
|
||||
onQuery,
|
||||
annotationData,
|
||||
vizType,
|
||||
latestQueryFormData,
|
||||
triggerQuery,
|
||||
postTransformProps,
|
||||
emitCrossFilters,
|
||||
onChartStateChange,
|
||||
suppressLoadingSpinner,
|
||||
filterState,
|
||||
} = restProps;
|
||||
|
||||
renderStartTime: number;
|
||||
const renderStartTimeRef = useRef<number>(Logger.getTimestamp());
|
||||
// Update on each render to accurately track render duration
|
||||
renderStartTimeRef.current = Logger.getTimestamp();
|
||||
|
||||
constructor(props: ChartProps) {
|
||||
super(props);
|
||||
this.renderStartTime = Logger.getTimestamp();
|
||||
this.handleRenderContainerFailure =
|
||||
this.handleRenderContainerFailure.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.triggerQuery) {
|
||||
this.runQuery();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
if (this.props.triggerQuery) {
|
||||
this.runQuery();
|
||||
}
|
||||
}
|
||||
|
||||
shouldRenderChart() {
|
||||
return (
|
||||
this.props.isInView ||
|
||||
const shouldRenderChart = useCallback(
|
||||
() =>
|
||||
isInView ||
|
||||
!isFeatureEnabled(FeatureFlag.DashboardVirtualization) ||
|
||||
isCurrentUserBot()
|
||||
);
|
||||
}
|
||||
isCurrentUserBot(),
|
||||
[isInView],
|
||||
);
|
||||
|
||||
runQuery() {
|
||||
const runQuery = useCallback(() => {
|
||||
if (
|
||||
isFeatureEnabled(FeatureFlag.DashboardVirtualizationDeferData) &&
|
||||
!this.shouldRenderChart()
|
||||
!shouldRenderChart()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Create chart with POST request
|
||||
this.props.actions.postChartFormData(
|
||||
this.props.formData,
|
||||
Boolean(this.props.force || getUrlParam(URL_PARAMS.force)), // allow override via url params force=true
|
||||
this.props.timeout,
|
||||
this.props.chartId,
|
||||
this.props.dashboardId,
|
||||
this.props.ownState,
|
||||
);
|
||||
}
|
||||
|
||||
handleRenderContainerFailure(error: Error, info: ErrorInfo) {
|
||||
const { actions, chartId } = this.props;
|
||||
logging.warn(error);
|
||||
actions.chartRenderingFailed(
|
||||
error.toString(),
|
||||
actions.postChartFormData(
|
||||
formData,
|
||||
Boolean(force || getUrlParam(URL_PARAMS.force)), // allow override via url params force=true
|
||||
timeout,
|
||||
chartId,
|
||||
info?.componentStack ?? null,
|
||||
);
|
||||
|
||||
actions.logEvent(LOG_ACTIONS_RENDER_CHART, {
|
||||
slice_id: chartId,
|
||||
has_err: true,
|
||||
error_details: error.toString(),
|
||||
start_offset: this.renderStartTime,
|
||||
ts: new Date().getTime(),
|
||||
duration: Logger.getTimestamp() - this.renderStartTime,
|
||||
});
|
||||
}
|
||||
|
||||
renderErrorMessage(queryResponse: ChartErrorType) {
|
||||
const {
|
||||
chartId,
|
||||
chartAlert,
|
||||
chartStackTrace,
|
||||
datasource,
|
||||
dashboardId,
|
||||
height,
|
||||
datasetsStatus,
|
||||
} = this.props;
|
||||
const error = queryResponse?.errors?.[0];
|
||||
const message = chartAlert || queryResponse?.message;
|
||||
ownState,
|
||||
);
|
||||
}, [
|
||||
actions,
|
||||
chartId,
|
||||
dashboardId,
|
||||
formData,
|
||||
force,
|
||||
ownState,
|
||||
shouldRenderChart,
|
||||
timeout,
|
||||
]);
|
||||
|
||||
// if datasource is still loading, don't render JS errors
|
||||
// but always show backend API errors (which have an errors array)
|
||||
// so users can see real issues like auth failures
|
||||
if (
|
||||
!error &&
|
||||
chartAlert !== undefined &&
|
||||
chartAlert !== NONEXISTENT_DATASET &&
|
||||
datasource === PLACEHOLDER_DATASOURCE &&
|
||||
datasetsStatus !== ResourceStatus.Error
|
||||
) {
|
||||
return (
|
||||
<Styles
|
||||
key={chartId}
|
||||
data-ui-anchor="chart"
|
||||
className="chart-container"
|
||||
data-test="chart-container"
|
||||
height={height}
|
||||
>
|
||||
<Loading
|
||||
size={this.props.dashboardId ? 's' : 'm'}
|
||||
muted={!!this.props.dashboardId}
|
||||
/>
|
||||
</Styles>
|
||||
const handleRenderContainerFailure = useCallback(
|
||||
(error: Error, info: ErrorInfo) => {
|
||||
logging.warn(error);
|
||||
actions.chartRenderingFailed(
|
||||
error.toString(),
|
||||
chartId,
|
||||
info?.componentStack ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
actions.logEvent(LOG_ACTIONS_RENDER_CHART, {
|
||||
slice_id: chartId,
|
||||
has_err: true,
|
||||
error_details: error.toString(),
|
||||
start_offset: renderStartTimeRef.current,
|
||||
ts: new Date().getTime(),
|
||||
duration: Logger.getTimestamp() - renderStartTimeRef.current,
|
||||
});
|
||||
},
|
||||
[actions, chartId],
|
||||
);
|
||||
|
||||
// componentDidMount and componentDidUpdate combined
|
||||
useEffect(() => {
|
||||
if (triggerQuery) {
|
||||
runQuery();
|
||||
}
|
||||
}, [triggerQuery, runQuery]);
|
||||
|
||||
const renderErrorMessage = useCallback(
|
||||
(queryResponse: ChartErrorType) => {
|
||||
const error = queryResponse?.errors?.[0];
|
||||
const message = chartAlert || queryResponse?.message;
|
||||
|
||||
// if datasource is still loading, don't render JS errors
|
||||
// but always show backend API errors (which have an errors array)
|
||||
// so users can see real issues like auth failures
|
||||
if (
|
||||
!error &&
|
||||
chartAlert !== undefined &&
|
||||
chartAlert !== NONEXISTENT_DATASET &&
|
||||
datasource === PLACEHOLDER_DATASOURCE &&
|
||||
datasetsStatus !== ResourceStatus.Error
|
||||
) {
|
||||
return (
|
||||
<Styles
|
||||
key={chartId}
|
||||
data-ui-anchor="chart"
|
||||
className="chart-container"
|
||||
data-test="chart-container"
|
||||
height={height}
|
||||
>
|
||||
<Loading size={dashboardId ? 's' : 'm'} muted={!!dashboardId} />
|
||||
</Styles>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChartErrorMessage
|
||||
key={chartId}
|
||||
chartId={chartId}
|
||||
error={error}
|
||||
subtitle={message}
|
||||
link={queryResponse ? queryResponse.link : undefined}
|
||||
source={dashboardId ? ChartSource.Dashboard : ChartSource.Explore}
|
||||
stackTrace={chartStackTrace}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[
|
||||
chartAlert,
|
||||
chartId,
|
||||
chartStackTrace,
|
||||
dashboardId,
|
||||
datasetsStatus,
|
||||
datasource,
|
||||
height,
|
||||
],
|
||||
);
|
||||
|
||||
const renderSpinner = useCallback(
|
||||
(databaseName: string | undefined) => {
|
||||
const message = databaseName
|
||||
? t('Waiting on %s', databaseName)
|
||||
: t('Waiting on database...');
|
||||
|
||||
return (
|
||||
<LoadingDiv>
|
||||
<Loading
|
||||
position="inline-centered"
|
||||
size={dashboardId ? 's' : 'm'}
|
||||
muted={!!dashboardId}
|
||||
/>
|
||||
<MessageSpan>{message}</MessageSpan>
|
||||
</LoadingDiv>
|
||||
);
|
||||
},
|
||||
[dashboardId],
|
||||
);
|
||||
|
||||
const renderChartContainer = useCallback(
|
||||
() => (
|
||||
<div className="slice_container" data-test="slice-container">
|
||||
{shouldRenderChart() ? (
|
||||
<ChartRenderer
|
||||
annotationData={annotationData}
|
||||
actions={actions}
|
||||
chartId={chartId}
|
||||
datasource={datasource}
|
||||
initialValues={initialValues}
|
||||
formData={formData}
|
||||
height={height}
|
||||
width={width}
|
||||
setControlValue={setControlValue}
|
||||
vizType={vizType}
|
||||
triggerRender={triggerRender}
|
||||
chartAlert={chartAlert}
|
||||
chartStatus={chartStatus}
|
||||
queriesResponse={queriesResponse}
|
||||
triggerQuery={triggerQuery}
|
||||
chartIsStale={chartIsStale}
|
||||
addFilter={addFilter}
|
||||
onFilterMenuOpen={onFilterMenuOpen}
|
||||
onFilterMenuClose={onFilterMenuClose}
|
||||
ownState={ownState}
|
||||
postTransformProps={postTransformProps}
|
||||
emitCrossFilters={emitCrossFilters}
|
||||
onChartStateChange={onChartStateChange}
|
||||
latestQueryFormData={latestQueryFormData}
|
||||
filterState={filterState}
|
||||
suppressLoadingSpinner={suppressLoadingSpinner}
|
||||
source={dashboardId ? ChartSource.Dashboard : ChartSource.Explore}
|
||||
/>
|
||||
) : (
|
||||
<Loading size={dashboardId ? 's' : 'm'} muted={!!dashboardId} />
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
[
|
||||
actions,
|
||||
addFilter,
|
||||
annotationData,
|
||||
chartAlert,
|
||||
chartId,
|
||||
chartIsStale,
|
||||
chartStatus,
|
||||
dashboardId,
|
||||
datasource,
|
||||
emitCrossFilters,
|
||||
filterState,
|
||||
formData,
|
||||
height,
|
||||
initialValues,
|
||||
latestQueryFormData,
|
||||
onChartStateChange,
|
||||
onFilterMenuClose,
|
||||
onFilterMenuOpen,
|
||||
ownState,
|
||||
postTransformProps,
|
||||
queriesResponse,
|
||||
setControlValue,
|
||||
shouldRenderChart,
|
||||
suppressLoadingSpinner,
|
||||
triggerQuery,
|
||||
triggerRender,
|
||||
vizType,
|
||||
width,
|
||||
],
|
||||
);
|
||||
|
||||
const databaseName =
|
||||
datasource?.parent?.name ??
|
||||
(datasource?.database?.name as string | undefined);
|
||||
|
||||
const isLoading = chartStatus === 'loading';
|
||||
// Suppress spinner during auto-refresh to avoid visual flicker
|
||||
const showSpinner = isLoading && !suppressLoadingSpinner;
|
||||
|
||||
if (chartStatus === 'failed') {
|
||||
return (
|
||||
<ChartErrorMessage
|
||||
key={chartId}
|
||||
chartId={chartId}
|
||||
error={error}
|
||||
subtitle={message}
|
||||
link={queryResponse ? queryResponse.link : undefined}
|
||||
source={dashboardId ? ChartSource.Dashboard : ChartSource.Explore}
|
||||
stackTrace={chartStackTrace}
|
||||
<ErrorContainer height={height}>
|
||||
{queriesResponse?.map(item =>
|
||||
renderErrorMessage(item as ChartErrorType),
|
||||
)}
|
||||
</ErrorContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (errorMessage && ensureIsArray(queriesResponse).length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
size="large"
|
||||
title={t('Add required control values to preview chart')}
|
||||
description={getChartRequiredFieldsMissingMessage(true)}
|
||||
image="chart.svg"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (
|
||||
!isLoading &&
|
||||
!chartAlert &&
|
||||
!errorMessage &&
|
||||
chartIsStale &&
|
||||
ensureIsArray(queriesResponse).length === 0
|
||||
) {
|
||||
return (
|
||||
<EmptyState
|
||||
size="large"
|
||||
title={t('Your chart is ready to go!')}
|
||||
description={
|
||||
<span>
|
||||
{t(
|
||||
'Click on "Create chart" button in the control panel on the left to preview a visualization or',
|
||||
)}{' '}
|
||||
<span role="button" tabIndex={0} onClick={onQuery}>
|
||||
{t('click here')}
|
||||
</span>
|
||||
.
|
||||
</span>
|
||||
}
|
||||
image="chart.svg"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderSpinner(databaseName: string | undefined) {
|
||||
const message = databaseName
|
||||
? t('Waiting on %s', databaseName)
|
||||
: t('Waiting on database...');
|
||||
|
||||
return (
|
||||
<LoadingDiv>
|
||||
<Loading
|
||||
position="inline-centered"
|
||||
size={this.props.dashboardId ? 's' : 'm'}
|
||||
muted={!!this.props.dashboardId}
|
||||
/>
|
||||
<MessageSpan>{message}</MessageSpan>
|
||||
</LoadingDiv>
|
||||
);
|
||||
}
|
||||
|
||||
renderChartContainer() {
|
||||
return (
|
||||
<div className="slice_container" data-test="slice-container">
|
||||
{this.shouldRenderChart() ? (
|
||||
<ChartRenderer
|
||||
{...this.props}
|
||||
source={
|
||||
this.props.dashboardId
|
||||
? ChartSource.Dashboard
|
||||
: ChartSource.Explore
|
||||
}
|
||||
data-test={this.props.vizType}
|
||||
/>
|
||||
) : (
|
||||
<Loading
|
||||
size={this.props.dashboardId ? 's' : 'm'}
|
||||
muted={!!this.props.dashboardId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
height,
|
||||
chartAlert,
|
||||
chartStatus,
|
||||
datasource,
|
||||
errorMessage,
|
||||
chartIsStale,
|
||||
queriesResponse = [],
|
||||
width,
|
||||
} = this.props;
|
||||
|
||||
const databaseName =
|
||||
datasource?.parent?.name ??
|
||||
(datasource?.database?.name as string | undefined);
|
||||
|
||||
const isLoading = chartStatus === 'loading';
|
||||
// Suppress spinner during auto-refresh to avoid visual flicker
|
||||
const showSpinner = isLoading && !this.props.suppressLoadingSpinner;
|
||||
|
||||
if (chartStatus === 'failed') {
|
||||
return (
|
||||
<ErrorContainer height={height}>
|
||||
{queriesResponse?.map(item =>
|
||||
this.renderErrorMessage(item as ChartErrorType),
|
||||
)}
|
||||
</ErrorContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (errorMessage && ensureIsArray(queriesResponse).length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
size="large"
|
||||
title={t('Add required control values to preview chart')}
|
||||
description={getChartRequiredFieldsMissingMessage(true)}
|
||||
image="chart.svg"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (
|
||||
!isLoading &&
|
||||
!chartAlert &&
|
||||
!errorMessage &&
|
||||
chartIsStale &&
|
||||
ensureIsArray(queriesResponse).length === 0
|
||||
) {
|
||||
return (
|
||||
<EmptyState
|
||||
size="large"
|
||||
title={t('Your chart is ready to go!')}
|
||||
description={
|
||||
<span>
|
||||
{t(
|
||||
'Click on "Create chart" button in the control panel on the left to preview a visualization or',
|
||||
)}{' '}
|
||||
<span role="button" tabIndex={0} onClick={this.props.onQuery}>
|
||||
{t('click here')}
|
||||
</span>
|
||||
.
|
||||
</span>
|
||||
}
|
||||
image="chart.svg"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary
|
||||
onError={this.handleRenderContainerFailure}
|
||||
showMessage={false}
|
||||
return (
|
||||
<ErrorBoundary onError={handleRenderContainerFailure} showMessage={false}>
|
||||
<Styles
|
||||
data-ui-anchor="chart"
|
||||
className="chart-container"
|
||||
data-test="chart-container"
|
||||
height={height}
|
||||
width={width}
|
||||
>
|
||||
<Styles
|
||||
data-ui-anchor="chart"
|
||||
className="chart-container"
|
||||
data-test="chart-container"
|
||||
height={height}
|
||||
width={width}
|
||||
>
|
||||
{showSpinner
|
||||
? this.renderSpinner(databaseName)
|
||||
: this.renderChartContainer()}
|
||||
</Styles>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
{showSpinner ? renderSpinner(databaseName) : renderChartContainer()}
|
||||
</Styles>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
export default Chart;
|
||||
|
||||
@@ -82,7 +82,7 @@ const mockActions: MockActions = {
|
||||
) => Dispatch,
|
||||
};
|
||||
|
||||
const requiredProps: Partial<ChartRendererProps> = {
|
||||
const requiredProps: ChartRendererProps = {
|
||||
chartId: 1,
|
||||
datasource: {} as ChartRendererProps['datasource'],
|
||||
formData: {
|
||||
@@ -111,17 +111,14 @@ afterAll(() => {
|
||||
|
||||
test('should render SuperChart', () => {
|
||||
const { getByTestId } = render(
|
||||
<ChartRenderer
|
||||
{...(requiredProps as ChartRendererProps)}
|
||||
chartIsStale={false}
|
||||
/>,
|
||||
<ChartRenderer {...requiredProps} chartIsStale={false} />,
|
||||
);
|
||||
expect(getByTestId('mock-super-chart')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should use latestQueryFormData instead of formData when chartIsStale is true', () => {
|
||||
const { getByTestId } = render(
|
||||
<ChartRenderer {...(requiredProps as ChartRendererProps)} chartIsStale />,
|
||||
<ChartRenderer {...requiredProps} chartIsStale />,
|
||||
);
|
||||
expect(getByTestId('mock-super-chart')).toHaveTextContent(
|
||||
JSON.stringify({
|
||||
@@ -131,9 +128,7 @@ test('should use latestQueryFormData instead of formData when chartIsStale is tr
|
||||
});
|
||||
|
||||
test('should render chart context menu', () => {
|
||||
const { getByTestId } = render(
|
||||
<ChartRenderer {...(requiredProps as ChartRendererProps)} />,
|
||||
);
|
||||
const { getByTestId } = render(<ChartRenderer {...requiredProps} />);
|
||||
expect(getByTestId('mock-chart-context-menu')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -148,16 +143,13 @@ test('should not render chart context menu if the context menu is suppressed for
|
||||
}),
|
||||
);
|
||||
const { queryByTestId } = render(
|
||||
<ChartRenderer
|
||||
{...(requiredProps as ChartRendererProps)}
|
||||
vizType="chart_without_context_menu"
|
||||
/>,
|
||||
<ChartRenderer {...requiredProps} vizType="chart_without_context_menu" />,
|
||||
);
|
||||
expect(queryByTestId('mock-chart-context-menu')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should detect changes in matrixify properties', () => {
|
||||
const initialProps: Partial<ChartRendererProps> = {
|
||||
const initialProps: ChartRendererProps = {
|
||||
...requiredProps,
|
||||
formData: {
|
||||
...requiredProps.formData,
|
||||
@@ -173,41 +165,34 @@ test('should detect changes in matrixify properties', () => {
|
||||
chartStatus: 'success',
|
||||
};
|
||||
|
||||
render(<ChartRenderer {...(initialProps as ChartRendererProps)} />);
|
||||
const { getByTestId } = render(<ChartRenderer {...initialProps} />);
|
||||
|
||||
// Since we can't directly test shouldComponentUpdate, we verify the component
|
||||
// correctly identifies matrixify-related properties by checking the implementation
|
||||
expect((initialProps.formData as JsonObject).matrixify_mode_rows).toBe(
|
||||
'metrics',
|
||||
// Verify matrixify-related formData is forwarded through to the chart
|
||||
expect(getByTestId('mock-super-chart')).toHaveTextContent(
|
||||
JSON.stringify(initialProps.formData),
|
||||
);
|
||||
expect((initialProps.formData as JsonObject).matrixify_dimension_x).toEqual({
|
||||
dimension: 'country',
|
||||
values: ['USA'],
|
||||
});
|
||||
});
|
||||
|
||||
test('should detect changes in postTransformProps', () => {
|
||||
const postTransformProps = jest.fn((x: JsonObject) => x);
|
||||
const initialProps: Partial<ChartRendererProps> = {
|
||||
const initialProps: ChartRendererProps = {
|
||||
...requiredProps,
|
||||
queriesResponse: [{ data: 'initial' } as unknown as JsonObject],
|
||||
chartStatus: 'success',
|
||||
};
|
||||
const { rerender } = render(
|
||||
<ChartRenderer {...(initialProps as ChartRendererProps)} />,
|
||||
);
|
||||
const updatedProps: Partial<ChartRendererProps> = {
|
||||
const { rerender } = render(<ChartRenderer {...initialProps} />);
|
||||
const updatedProps: ChartRendererProps = {
|
||||
...initialProps,
|
||||
postTransformProps,
|
||||
};
|
||||
expect(postTransformProps).toHaveBeenCalledTimes(0);
|
||||
rerender(<ChartRenderer {...(updatedProps as ChartRendererProps)} />);
|
||||
rerender(<ChartRenderer {...updatedProps} />);
|
||||
expect(postTransformProps).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('should identify matrixify property changes correctly', () => {
|
||||
// Test that formData with different matrixify properties triggers updates
|
||||
const initialProps: Partial<ChartRendererProps> = {
|
||||
const initialProps: ChartRendererProps = {
|
||||
...requiredProps,
|
||||
formData: {
|
||||
datasource: '',
|
||||
@@ -221,16 +206,14 @@ test('should identify matrixify property changes correctly', () => {
|
||||
chartStatus: 'success',
|
||||
};
|
||||
|
||||
const { rerender, getByTestId } = render(
|
||||
<ChartRenderer {...(initialProps as ChartRendererProps)} />,
|
||||
);
|
||||
const { rerender, getByTestId } = render(<ChartRenderer {...initialProps} />);
|
||||
|
||||
expect(getByTestId('mock-super-chart')).toHaveTextContent(
|
||||
JSON.stringify(initialProps.formData),
|
||||
);
|
||||
|
||||
// Update with changed matrixify_dimension_x values
|
||||
const updatedProps: Partial<ChartRendererProps> = {
|
||||
const updatedProps: ChartRendererProps = {
|
||||
...initialProps,
|
||||
formData: {
|
||||
datasource: '',
|
||||
@@ -245,7 +228,7 @@ test('should identify matrixify property changes correctly', () => {
|
||||
},
|
||||
};
|
||||
|
||||
rerender(<ChartRenderer {...(updatedProps as ChartRendererProps)} />);
|
||||
rerender(<ChartRenderer {...updatedProps} />);
|
||||
|
||||
// Verify the component re-rendered with new props
|
||||
expect(getByTestId('mock-super-chart')).toHaveTextContent(
|
||||
@@ -254,7 +237,7 @@ test('should identify matrixify property changes correctly', () => {
|
||||
});
|
||||
|
||||
test('should handle matrixify-related form data changes', () => {
|
||||
const initialProps: Partial<ChartRendererProps> = {
|
||||
const initialProps: ChartRendererProps = {
|
||||
...requiredProps,
|
||||
formData: {
|
||||
datasource: '',
|
||||
@@ -265,16 +248,14 @@ test('should handle matrixify-related form data changes', () => {
|
||||
chartStatus: 'success',
|
||||
};
|
||||
|
||||
const { rerender, getByTestId } = render(
|
||||
<ChartRenderer {...(initialProps as ChartRendererProps)} />,
|
||||
);
|
||||
const { rerender, getByTestId } = render(<ChartRenderer {...initialProps} />);
|
||||
|
||||
expect(getByTestId('mock-super-chart')).toHaveTextContent(
|
||||
JSON.stringify(initialProps.formData),
|
||||
);
|
||||
|
||||
// Enable matrixify
|
||||
const updatedProps: Partial<ChartRendererProps> = {
|
||||
const updatedProps: ChartRendererProps = {
|
||||
...initialProps,
|
||||
formData: {
|
||||
datasource: '',
|
||||
@@ -285,7 +266,7 @@ test('should handle matrixify-related form data changes', () => {
|
||||
},
|
||||
};
|
||||
|
||||
rerender(<ChartRenderer {...(updatedProps as ChartRendererProps)} />);
|
||||
rerender(<ChartRenderer {...updatedProps} />);
|
||||
|
||||
// Verify the component re-rendered with matrixify enabled
|
||||
expect(getByTestId('mock-super-chart')).toHaveTextContent(
|
||||
@@ -294,7 +275,7 @@ test('should handle matrixify-related form data changes', () => {
|
||||
});
|
||||
|
||||
test('should detect matrixify property addition', () => {
|
||||
const initialProps: Partial<ChartRendererProps> = {
|
||||
const initialProps: ChartRendererProps = {
|
||||
...requiredProps,
|
||||
formData: {
|
||||
datasource: '',
|
||||
@@ -307,16 +288,14 @@ test('should detect matrixify property addition', () => {
|
||||
chartStatus: 'success',
|
||||
};
|
||||
|
||||
const { rerender, getByTestId } = render(
|
||||
<ChartRenderer {...(initialProps as ChartRendererProps)} />,
|
||||
);
|
||||
const { rerender, getByTestId } = render(<ChartRenderer {...initialProps} />);
|
||||
|
||||
expect(getByTestId('mock-super-chart')).toHaveTextContent(
|
||||
JSON.stringify(initialProps.formData),
|
||||
);
|
||||
|
||||
// Add matrixify_dimension_x
|
||||
const updatedProps: Partial<ChartRendererProps> = {
|
||||
const updatedProps: ChartRendererProps = {
|
||||
...initialProps,
|
||||
formData: {
|
||||
datasource: '',
|
||||
@@ -327,7 +306,7 @@ test('should detect matrixify property addition', () => {
|
||||
},
|
||||
};
|
||||
|
||||
rerender(<ChartRenderer {...(updatedProps as ChartRendererProps)} />);
|
||||
rerender(<ChartRenderer {...updatedProps} />);
|
||||
|
||||
// Verify the component re-rendered with the new property
|
||||
expect(getByTestId('mock-super-chart')).toHaveTextContent(
|
||||
@@ -336,7 +315,7 @@ test('should detect matrixify property addition', () => {
|
||||
});
|
||||
|
||||
test('should detect nested matrixify property changes', () => {
|
||||
const initialProps: Partial<ChartRendererProps> = {
|
||||
const initialProps: ChartRendererProps = {
|
||||
...requiredProps,
|
||||
formData: {
|
||||
datasource: '',
|
||||
@@ -353,16 +332,14 @@ test('should detect nested matrixify property changes', () => {
|
||||
chartStatus: 'success',
|
||||
};
|
||||
|
||||
const { rerender, getByTestId } = render(
|
||||
<ChartRenderer {...(initialProps as ChartRendererProps)} />,
|
||||
);
|
||||
const { rerender, getByTestId } = render(<ChartRenderer {...initialProps} />);
|
||||
|
||||
expect(getByTestId('mock-super-chart')).toHaveTextContent(
|
||||
JSON.stringify(initialProps.formData),
|
||||
);
|
||||
|
||||
// Change nested topN value
|
||||
const updatedProps: Partial<ChartRendererProps> = {
|
||||
const updatedProps: ChartRendererProps = {
|
||||
...initialProps,
|
||||
formData: {
|
||||
datasource: '',
|
||||
@@ -377,7 +354,7 @@ test('should detect nested matrixify property changes', () => {
|
||||
},
|
||||
};
|
||||
|
||||
rerender(<ChartRenderer {...(updatedProps as ChartRendererProps)} />);
|
||||
rerender(<ChartRenderer {...updatedProps} />);
|
||||
|
||||
// Verify the component re-rendered with the nested change
|
||||
expect(getByTestId('mock-super-chart')).toHaveTextContent(
|
||||
|
||||
@@ -16,8 +16,17 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { snakeCase, isEqual, cloneDeep } from 'lodash';
|
||||
import { createRef, Component, RefObject, MouseEvent, ReactNode } from 'react';
|
||||
import { snakeCase, cloneDeep } from 'lodash';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
useRef,
|
||||
useMemo,
|
||||
MouseEvent,
|
||||
ReactNode,
|
||||
memo,
|
||||
} from 'react';
|
||||
import {
|
||||
SuperChart,
|
||||
Behavior,
|
||||
@@ -28,6 +37,7 @@ import {
|
||||
QueryFormData,
|
||||
AnnotationData,
|
||||
DataMask,
|
||||
FilterState,
|
||||
QueryData,
|
||||
JsonObject,
|
||||
LatestQueryFormData,
|
||||
@@ -37,6 +47,7 @@ import {
|
||||
} from '@superset-ui/core';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { useTheme } from '@apache-superset/core/theme';
|
||||
import { Logger, LOG_ACTIONS_RENDER_CHART } from 'src/logger/LogUtils';
|
||||
import { EmptyState } from '@superset-ui/core/components';
|
||||
import { ChartSource } from 'src/types/ChartSource';
|
||||
@@ -91,12 +102,6 @@ interface OwnState {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// Types for filter state
|
||||
interface FilterState {
|
||||
value?: FilterValue[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// Props interface
|
||||
export interface ChartRendererProps {
|
||||
annotationData?: AnnotationData;
|
||||
@@ -124,7 +129,6 @@ export interface ChartRendererProps {
|
||||
merge?: boolean,
|
||||
refresh?: boolean,
|
||||
) => void;
|
||||
setDataMask?: (dataMask: DataMask) => void;
|
||||
onFilterMenuOpen?: (chartId: number, column: string) => void;
|
||||
onFilterMenuClose?: (chartId: number, column: string) => void;
|
||||
ownState?: OwnState;
|
||||
@@ -137,14 +141,6 @@ export interface ChartRendererProps {
|
||||
suppressLoadingSpinner?: boolean;
|
||||
}
|
||||
|
||||
// State interface
|
||||
interface ChartRendererState {
|
||||
showContextMenu: boolean;
|
||||
inContextMenu: boolean;
|
||||
legendState: LegendState | undefined;
|
||||
legendIndex: number;
|
||||
}
|
||||
|
||||
// Hooks interface
|
||||
interface ChartHooks {
|
||||
onAddFilter: (
|
||||
@@ -175,402 +171,376 @@ const BIG_NO_RESULT_MIN_HEIGHT = 220;
|
||||
|
||||
const behaviors = [Behavior.InteractiveChart];
|
||||
|
||||
const defaultProps: Partial<ChartRendererProps> = {
|
||||
addFilter: () => BLANK,
|
||||
onFilterMenuOpen: () => BLANK,
|
||||
onFilterMenuClose: () => BLANK,
|
||||
initialValues: BLANK,
|
||||
setControlValue: () => {},
|
||||
triggerRender: false,
|
||||
};
|
||||
interface ChartRendererState {
|
||||
inContextMenu: boolean;
|
||||
legendState: LegendState | undefined;
|
||||
legendIndex: number;
|
||||
}
|
||||
|
||||
class ChartRenderer extends Component<ChartRendererProps, ChartRendererState> {
|
||||
static defaultProps = defaultProps;
|
||||
function ChartRendererComponent({
|
||||
addFilter = () => BLANK,
|
||||
onFilterMenuOpen = () => BLANK,
|
||||
onFilterMenuClose = () => BLANK,
|
||||
initialValues = BLANK,
|
||||
setControlValue = () => {},
|
||||
triggerRender = false,
|
||||
...restProps
|
||||
}: ChartRendererProps): JSX.Element | null {
|
||||
const {
|
||||
annotationData,
|
||||
actions,
|
||||
chartId,
|
||||
datasource,
|
||||
formData,
|
||||
latestQueryFormData,
|
||||
height,
|
||||
width,
|
||||
vizType: propVizType,
|
||||
chartAlert,
|
||||
chartStatus,
|
||||
queriesResponse,
|
||||
chartIsStale,
|
||||
ownState,
|
||||
filterState,
|
||||
postTransformProps,
|
||||
source,
|
||||
emitCrossFilters,
|
||||
onChartStateChange,
|
||||
} = restProps;
|
||||
|
||||
private hasQueryResponseChange: boolean;
|
||||
const theme = useTheme();
|
||||
|
||||
private contextMenuRef: RefObject<ChartContextMenuRef>;
|
||||
const suppressContextMenu = getChartMetadataRegistry().get(
|
||||
formData.viz_type ?? propVizType,
|
||||
)?.suppressContextMenu;
|
||||
|
||||
private hooks: ChartHooks;
|
||||
// Derived from props/feature-flags: must NOT live in state, otherwise a
|
||||
// `source` or viz-type change on the same mounted instance would leave
|
||||
// it stale. (Pre-refactor this was a class-instance field recomputed on
|
||||
// every render — preserve that semantic by using a memo here.)
|
||||
const showContextMenu = useMemo(
|
||||
() =>
|
||||
source === ChartSource.Dashboard &&
|
||||
!suppressContextMenu &&
|
||||
isFeatureEnabled(FeatureFlag.DrillToDetail),
|
||||
[source, suppressContextMenu],
|
||||
);
|
||||
|
||||
private mutableQueriesResponse: QueryData[] | null | undefined;
|
||||
const [state, setState] = useState<ChartRendererState>({
|
||||
inContextMenu: false,
|
||||
legendState: undefined,
|
||||
legendIndex: 0,
|
||||
});
|
||||
|
||||
private renderStartTime: number;
|
||||
const hasQueryResponseChangeRef = useRef(false);
|
||||
const renderStartTimeRef = useRef(0);
|
||||
const contextMenuRef = useRef<ChartContextMenuRef>(null);
|
||||
|
||||
constructor(props: ChartRendererProps) {
|
||||
super(props);
|
||||
const suppressContextMenu = getChartMetadataRegistry().get(
|
||||
props.formData.viz_type ?? props.vizType,
|
||||
)?.suppressContextMenu;
|
||||
this.state = {
|
||||
showContextMenu:
|
||||
props.source === ChartSource.Dashboard &&
|
||||
!suppressContextMenu &&
|
||||
isFeatureEnabled(FeatureFlag.DrillToDetail),
|
||||
inContextMenu: false,
|
||||
legendState: undefined,
|
||||
legendIndex: 0,
|
||||
};
|
||||
this.hasQueryResponseChange = false;
|
||||
this.renderStartTime = 0;
|
||||
// Results are "ready" when we have a non-error queriesResponse and the
|
||||
// chartStatus reflects it. This mirrors the gating logic from the former
|
||||
// shouldComponentUpdate implementation.
|
||||
const resultsReady =
|
||||
queriesResponse &&
|
||||
['success', 'rendered'].indexOf(chartStatus as string) > -1 &&
|
||||
!queriesResponse?.[0]?.error;
|
||||
|
||||
this.contextMenuRef = createRef<ChartContextMenuRef>();
|
||||
|
||||
this.handleAddFilter = this.handleAddFilter.bind(this);
|
||||
this.handleRenderSuccess = this.handleRenderSuccess.bind(this);
|
||||
this.handleRenderFailure = this.handleRenderFailure.bind(this);
|
||||
this.handleSetControlValue = this.handleSetControlValue.bind(this);
|
||||
this.handleOnContextMenu = this.handleOnContextMenu.bind(this);
|
||||
this.handleContextMenuSelected = this.handleContextMenuSelected.bind(this);
|
||||
this.handleContextMenuClosed = this.handleContextMenuClosed.bind(this);
|
||||
this.handleLegendStateChanged = this.handleLegendStateChanged.bind(this);
|
||||
this.onContextMenuFallback = this.onContextMenuFallback.bind(this);
|
||||
this.handleLegendScroll = this.handleLegendScroll.bind(this);
|
||||
|
||||
this.hooks = {
|
||||
onAddFilter: this.handleAddFilter,
|
||||
onContextMenu: this.state.showContextMenu
|
||||
? this.handleOnContextMenu
|
||||
: undefined,
|
||||
onError: this.handleRenderFailure,
|
||||
setControlValue: this.handleSetControlValue,
|
||||
onFilterMenuOpen: this.props.onFilterMenuOpen,
|
||||
onFilterMenuClose: this.props.onFilterMenuClose,
|
||||
onLegendStateChanged: this.handleLegendStateChanged,
|
||||
setDataMask: (dataMask: DataMask) => {
|
||||
this.props.actions?.updateDataMask?.(this.props.chartId, dataMask);
|
||||
},
|
||||
onLegendScroll: this.handleLegendScroll,
|
||||
onChartStateChange: this.props.onChartStateChange,
|
||||
};
|
||||
|
||||
// TODO: queriesResponse comes from Redux store but it's being edited by
|
||||
// the plugins, hence we need to clone it to avoid state mutation
|
||||
// until we change the reducers to use Redux Toolkit with Immer
|
||||
this.mutableQueriesResponse = cloneDeep(this.props.queriesResponse);
|
||||
// Track whether queriesResponse changed since the previous render so that
|
||||
// handleRenderSuccess / handleRenderFailure know whether to log render time.
|
||||
// Updating a ref during render is safe when the value doesn't affect the
|
||||
// render output (here it's read asynchronously from SuperChart callbacks).
|
||||
const prevQueriesResponseRef = useRef<QueryData[] | null | undefined>(
|
||||
queriesResponse,
|
||||
);
|
||||
if (resultsReady) {
|
||||
hasQueryResponseChangeRef.current =
|
||||
queriesResponse !== prevQueriesResponseRef.current;
|
||||
}
|
||||
useEffect(() => {
|
||||
prevQueriesResponseRef.current = queriesResponse;
|
||||
}, [queriesResponse]);
|
||||
|
||||
shouldComponentUpdate(
|
||||
nextProps: ChartRendererProps,
|
||||
nextState: ChartRendererState,
|
||||
): boolean {
|
||||
const resultsReady =
|
||||
nextProps.queriesResponse &&
|
||||
['success', 'rendered'].indexOf(nextProps.chartStatus as string) > -1 &&
|
||||
!nextProps.queriesResponse?.[0]?.error;
|
||||
// Clone queriesResponse to protect against plugin mutation of Redux state.
|
||||
// Gate on `resultsReady` so the deep clone doesn't run for every
|
||||
// queriesResponse identity change during loading/idle (only when results
|
||||
// are actually about to render). Matches the pre-refactor gating.
|
||||
// TODO: remove once reducers use Redux Toolkit with Immer.
|
||||
const mutableQueriesResponse = useMemo(
|
||||
() => (resultsReady ? cloneDeep(queriesResponse) : undefined),
|
||||
[queriesResponse, resultsReady],
|
||||
);
|
||||
|
||||
if (resultsReady) {
|
||||
if (!isEqual(this.state, nextState)) {
|
||||
return true;
|
||||
}
|
||||
this.hasQueryResponseChange =
|
||||
nextProps.queriesResponse !== this.props.queriesResponse;
|
||||
// Handler functions
|
||||
const handleAddFilter = useCallback(
|
||||
(col: string, vals: FilterValue[], merge = true, refresh = true): void => {
|
||||
addFilter?.(col, vals, merge, refresh);
|
||||
},
|
||||
[addFilter],
|
||||
);
|
||||
|
||||
if (this.hasQueryResponseChange) {
|
||||
this.mutableQueriesResponse = cloneDeep(nextProps.queriesResponse);
|
||||
}
|
||||
|
||||
// Check if any matrixify-related properties have changed
|
||||
const hasMatrixifyChanges = (): boolean => {
|
||||
const nextFormData = nextProps.formData as JsonObject;
|
||||
const currentFormData = this.props.formData as JsonObject;
|
||||
const isMatrixifyEnabled =
|
||||
nextFormData.matrixify_enable === true &&
|
||||
((nextFormData.matrixify_mode_rows !== undefined &&
|
||||
nextFormData.matrixify_mode_rows !== 'disabled') ||
|
||||
(nextFormData.matrixify_mode_columns !== undefined &&
|
||||
nextFormData.matrixify_mode_columns !== 'disabled'));
|
||||
if (!isMatrixifyEnabled) return false;
|
||||
|
||||
// Check all matrixify-related properties
|
||||
const matrixifyKeys = Object.keys(nextFormData).filter(key =>
|
||||
key.startsWith('matrixify_'),
|
||||
);
|
||||
|
||||
return matrixifyKeys.some(
|
||||
key => !isEqual(nextFormData[key], currentFormData[key]),
|
||||
);
|
||||
};
|
||||
|
||||
const nextFormData = nextProps.formData as JsonObject;
|
||||
const currentFormData = this.props.formData as JsonObject;
|
||||
|
||||
return (
|
||||
this.hasQueryResponseChange ||
|
||||
!isEqual(nextProps.datasource, this.props.datasource) ||
|
||||
nextProps.annotationData !== this.props.annotationData ||
|
||||
nextProps.ownState !== this.props.ownState ||
|
||||
nextProps.filterState !== this.props.filterState ||
|
||||
nextProps.height !== this.props.height ||
|
||||
nextProps.width !== this.props.width ||
|
||||
nextProps.triggerRender === true ||
|
||||
nextProps.labelsColor !== this.props.labelsColor ||
|
||||
nextProps.labelsColorMap !== this.props.labelsColorMap ||
|
||||
nextFormData.color_scheme !== currentFormData.color_scheme ||
|
||||
nextFormData.stack !== currentFormData.stack ||
|
||||
nextFormData.subcategories !== currentFormData.subcategories ||
|
||||
nextProps.cacheBusterProp !== this.props.cacheBusterProp ||
|
||||
nextProps.emitCrossFilters !== this.props.emitCrossFilters ||
|
||||
nextProps.postTransformProps !== this.props.postTransformProps ||
|
||||
hasMatrixifyChanges()
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
handleAddFilter(
|
||||
col: string,
|
||||
vals: FilterValue[],
|
||||
merge = true,
|
||||
refresh = true,
|
||||
): void {
|
||||
this.props.addFilter?.(col, vals, merge, refresh);
|
||||
}
|
||||
|
||||
handleRenderSuccess(): void {
|
||||
const { actions, chartStatus, chartId, vizType } = this.props;
|
||||
const handleRenderSuccess = useCallback((): void => {
|
||||
if (['loading', 'rendered'].indexOf(chartStatus as string) < 0) {
|
||||
actions.chartRenderingSucceeded(chartId);
|
||||
}
|
||||
|
||||
// only log chart render time which is triggered by query results change
|
||||
// currently we don't log chart re-render time, like window resize etc
|
||||
if (this.hasQueryResponseChange) {
|
||||
if (hasQueryResponseChangeRef.current) {
|
||||
actions.logEvent(LOG_ACTIONS_RENDER_CHART, {
|
||||
slice_id: chartId,
|
||||
viz_type: vizType,
|
||||
start_offset: this.renderStartTime,
|
||||
viz_type: propVizType,
|
||||
start_offset: renderStartTimeRef.current,
|
||||
ts: new Date().getTime(),
|
||||
duration: Logger.getTimestamp() - this.renderStartTime,
|
||||
duration: Logger.getTimestamp() - renderStartTimeRef.current,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [actions, chartId, chartStatus, propVizType]);
|
||||
|
||||
handleRenderFailure(
|
||||
error: Error,
|
||||
info: { componentStack: string } | null,
|
||||
): void {
|
||||
const { actions, chartId } = this.props;
|
||||
logging.warn(error);
|
||||
actions.chartRenderingFailed(
|
||||
error.toString(),
|
||||
chartId,
|
||||
info ? info.componentStack : null,
|
||||
);
|
||||
const handleRenderFailure = useCallback(
|
||||
(error: Error, info: { componentStack: string } | null): void => {
|
||||
logging.warn(error);
|
||||
actions.chartRenderingFailed(
|
||||
error.toString(),
|
||||
chartId,
|
||||
info ? info.componentStack : null,
|
||||
);
|
||||
|
||||
// only trigger render log when query is changed
|
||||
if (this.hasQueryResponseChange) {
|
||||
actions.logEvent(LOG_ACTIONS_RENDER_CHART, {
|
||||
slice_id: chartId,
|
||||
has_err: true,
|
||||
error_details: error.toString(),
|
||||
start_offset: this.renderStartTime,
|
||||
ts: new Date().getTime(),
|
||||
duration: Logger.getTimestamp() - this.renderStartTime,
|
||||
});
|
||||
}
|
||||
}
|
||||
// only trigger render log when query is changed
|
||||
if (hasQueryResponseChangeRef.current) {
|
||||
actions.logEvent(LOG_ACTIONS_RENDER_CHART, {
|
||||
slice_id: chartId,
|
||||
has_err: true,
|
||||
error_details: error.toString(),
|
||||
start_offset: renderStartTimeRef.current,
|
||||
ts: new Date().getTime(),
|
||||
duration: Logger.getTimestamp() - renderStartTimeRef.current,
|
||||
});
|
||||
}
|
||||
},
|
||||
[actions, chartId],
|
||||
);
|
||||
|
||||
handleSetControlValue(name: string, value: unknown): void {
|
||||
const { setControlValue } = this.props;
|
||||
if (setControlValue) {
|
||||
setControlValue(name, value);
|
||||
}
|
||||
}
|
||||
const handleSetControlValue = useCallback(
|
||||
(name: string, value: unknown): void => {
|
||||
if (setControlValue) {
|
||||
setControlValue(name, value);
|
||||
}
|
||||
},
|
||||
[setControlValue],
|
||||
);
|
||||
|
||||
handleOnContextMenu(
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
filters?: ContextMenuFilters,
|
||||
): void {
|
||||
this.contextMenuRef.current?.open(offsetX, offsetY, filters);
|
||||
this.setState({ inContextMenu: true });
|
||||
}
|
||||
const handleOnContextMenu = useCallback(
|
||||
(offsetX: number, offsetY: number, filters?: ContextMenuFilters): void => {
|
||||
contextMenuRef.current?.open(offsetX, offsetY, filters);
|
||||
setState(prev => ({ ...prev, inContextMenu: true }));
|
||||
},
|
||||
[contextMenuRef],
|
||||
);
|
||||
|
||||
handleContextMenuSelected(): void {
|
||||
this.setState({ inContextMenu: false });
|
||||
}
|
||||
const handleContextMenuSelected = useCallback((): void => {
|
||||
setState(prev => ({ ...prev, inContextMenu: false }));
|
||||
}, []);
|
||||
|
||||
handleContextMenuClosed(): void {
|
||||
this.setState({ inContextMenu: false });
|
||||
}
|
||||
const handleContextMenuClosed = useCallback((): void => {
|
||||
setState(prev => ({ ...prev, inContextMenu: false }));
|
||||
}, []);
|
||||
|
||||
handleLegendStateChanged(legendState: LegendState): void {
|
||||
this.setState({ legendState });
|
||||
}
|
||||
const handleLegendStateChanged = useCallback(
|
||||
(legendState: LegendState): void => {
|
||||
setState(prev => ({ ...prev, legendState }));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleLegendScroll = useCallback((legendIndex: number): void => {
|
||||
setState(prev => ({ ...prev, legendIndex }));
|
||||
}, []);
|
||||
|
||||
// When viz plugins don't handle `contextmenu` event, fallback handler
|
||||
// calls `handleOnContextMenu` with no `filters` param.
|
||||
onContextMenuFallback(event: MouseEvent<HTMLDivElement>): void {
|
||||
if (!this.state.inContextMenu) {
|
||||
event.preventDefault();
|
||||
this.handleOnContextMenu(event.clientX, event.clientY);
|
||||
}
|
||||
const onContextMenuFallback = useCallback(
|
||||
(event: MouseEvent<HTMLDivElement>): void => {
|
||||
if (!state.inContextMenu) {
|
||||
event.preventDefault();
|
||||
handleOnContextMenu(event.clientX, event.clientY);
|
||||
}
|
||||
},
|
||||
[handleOnContextMenu, state.inContextMenu],
|
||||
);
|
||||
|
||||
const setDataMaskCallback = useCallback(
|
||||
(dataMask: DataMask) => {
|
||||
actions?.updateDataMask?.(chartId, dataMask);
|
||||
},
|
||||
[actions, chartId],
|
||||
);
|
||||
|
||||
// Hooks object - memoized
|
||||
const hooks = useMemo<ChartHooks>(
|
||||
() => ({
|
||||
onAddFilter: handleAddFilter,
|
||||
onContextMenu: showContextMenu ? handleOnContextMenu : undefined,
|
||||
onError: handleRenderFailure,
|
||||
setControlValue: handleSetControlValue,
|
||||
onFilterMenuOpen,
|
||||
onFilterMenuClose,
|
||||
onLegendStateChanged: handleLegendStateChanged,
|
||||
setDataMask: setDataMaskCallback,
|
||||
onLegendScroll: handleLegendScroll,
|
||||
onChartStateChange,
|
||||
}),
|
||||
[
|
||||
handleAddFilter,
|
||||
handleLegendScroll,
|
||||
handleLegendStateChanged,
|
||||
handleOnContextMenu,
|
||||
handleRenderFailure,
|
||||
handleSetControlValue,
|
||||
onChartStateChange,
|
||||
onFilterMenuClose,
|
||||
onFilterMenuOpen,
|
||||
setDataMaskCallback,
|
||||
showContextMenu,
|
||||
],
|
||||
);
|
||||
|
||||
const hasAnyErrors = queriesResponse?.some(item => item?.error);
|
||||
const hasValidPreviousData =
|
||||
(queriesResponse?.length ?? 0) > 0 && !hasAnyErrors;
|
||||
|
||||
if (!!chartAlert || chartStatus === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
handleLegendScroll(legendIndex: number): void {
|
||||
this.setState({ legendIndex });
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
const { chartAlert, chartStatus, chartId, emitCrossFilters } = this.props;
|
||||
|
||||
const hasAnyErrors = this.props.queriesResponse?.some(item => item?.error);
|
||||
const hasValidPreviousData =
|
||||
(this.props.queriesResponse?.length ?? 0) > 0 && !hasAnyErrors;
|
||||
|
||||
if (!!chartAlert || chartStatus === null) {
|
||||
if (chartStatus === 'loading') {
|
||||
if (!restProps.suppressLoadingSpinner || !hasValidPreviousData) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (chartStatus === 'loading') {
|
||||
if (!this.props.suppressLoadingSpinner || !hasValidPreviousData) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
renderStartTimeRef.current = Logger.getTimestamp();
|
||||
|
||||
this.renderStartTime = Logger.getTimestamp();
|
||||
const currentFormData =
|
||||
chartIsStale && latestQueryFormData ? latestQueryFormData : formData;
|
||||
const vizType = currentFormData.viz_type || propVizType;
|
||||
|
||||
const {
|
||||
width,
|
||||
height,
|
||||
datasource,
|
||||
annotationData,
|
||||
initialValues,
|
||||
ownState,
|
||||
filterState,
|
||||
chartIsStale,
|
||||
formData,
|
||||
latestQueryFormData,
|
||||
postTransformProps,
|
||||
} = this.props;
|
||||
// It's bad practice to use unprefixed `vizType` as classnames for chart
|
||||
// container. It may cause css conflicts as in the case of legacy table chart.
|
||||
// When migrating charts, we should gradually add a `superset-chart-` prefix
|
||||
// to each one of them.
|
||||
const snakeCaseVizType = snakeCase(vizType);
|
||||
const chartClassName =
|
||||
vizType === VizType.Table
|
||||
? `superset-chart-${snakeCaseVizType}`
|
||||
: snakeCaseVizType;
|
||||
|
||||
const currentFormData =
|
||||
chartIsStale && latestQueryFormData ? latestQueryFormData : formData;
|
||||
const vizType = currentFormData.viz_type || this.props.vizType;
|
||||
const webpackHash =
|
||||
process.env.WEBPACK_MODE === 'development'
|
||||
? `-${
|
||||
// eslint-disable-next-line camelcase
|
||||
typeof __webpack_require__ !== 'undefined' &&
|
||||
// eslint-disable-next-line camelcase, no-undef
|
||||
typeof __webpack_require__.h === 'function' &&
|
||||
// eslint-disable-next-line no-undef, camelcase
|
||||
__webpack_require__.h()
|
||||
}`
|
||||
: '';
|
||||
|
||||
// It's bad practice to use unprefixed `vizType` as classnames for chart
|
||||
// container. It may cause css conflicts as in the case of legacy table chart.
|
||||
// When migrating charts, we should gradually add a `superset-chart-` prefix
|
||||
// to each one of them.
|
||||
const snakeCaseVizType = snakeCase(vizType);
|
||||
const chartClassName =
|
||||
vizType === VizType.Table
|
||||
? `superset-chart-${snakeCaseVizType}`
|
||||
: snakeCaseVizType;
|
||||
|
||||
const webpackHash =
|
||||
process.env.WEBPACK_MODE === 'development'
|
||||
? `-${
|
||||
// eslint-disable-next-line camelcase
|
||||
typeof __webpack_require__ !== 'undefined' &&
|
||||
// eslint-disable-next-line camelcase, no-undef
|
||||
typeof __webpack_require__.h === 'function' &&
|
||||
// eslint-disable-next-line no-undef, camelcase
|
||||
__webpack_require__.h()
|
||||
}`
|
||||
: '';
|
||||
|
||||
let noResultsComponent: ReactNode;
|
||||
const noResultTitle = t('No results were returned for this query');
|
||||
const noResultDescription =
|
||||
this.props.source === ChartSource.Explore
|
||||
? t(
|
||||
'Make sure that the controls are configured properly and the datasource contains data for the selected time range',
|
||||
)
|
||||
: undefined;
|
||||
const noResultImage = 'chart.svg';
|
||||
if (
|
||||
(width ?? 0) > BIG_NO_RESULT_MIN_WIDTH &&
|
||||
(height ?? 0) > BIG_NO_RESULT_MIN_HEIGHT
|
||||
) {
|
||||
noResultsComponent = (
|
||||
<EmptyState
|
||||
size="large"
|
||||
title={noResultTitle}
|
||||
description={noResultDescription}
|
||||
image={noResultImage}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
noResultsComponent = (
|
||||
<EmptyState title={noResultTitle} image={noResultImage} size="small" />
|
||||
);
|
||||
}
|
||||
|
||||
// Check for Behavior.DRILL_TO_DETAIL to tell if chart can receive Drill to
|
||||
// Detail props or if it'll cause side-effects (e.g. excessive re-renders).
|
||||
const drillToDetailProps = getChartMetadataRegistry()
|
||||
.get(vizType)
|
||||
?.behaviors.find(behavior => behavior === Behavior.DrillToDetail)
|
||||
? { inContextMenu: this.state.inContextMenu }
|
||||
: {};
|
||||
// By pass no result component when server pagination is enabled & the table has:
|
||||
// - a backend search query, OR
|
||||
// - non-empty AG Grid filter model
|
||||
const hasSearchText = (ownState?.searchText?.length || 0) > 0;
|
||||
const hasAgGridFilters =
|
||||
ownState?.agGridFilterModel &&
|
||||
Object.keys(ownState.agGridFilterModel).length > 0;
|
||||
|
||||
const currentFormDataExtended = currentFormData as JsonObject;
|
||||
const bypassNoResult = !(
|
||||
currentFormDataExtended?.server_pagination &&
|
||||
(hasSearchText || hasAgGridFilters)
|
||||
let noResultsComponent: ReactNode;
|
||||
const noResultTitle = t('No results were returned for this query');
|
||||
const noResultDescription =
|
||||
source === ChartSource.Explore
|
||||
? t(
|
||||
'Make sure that the controls are configured properly and the datasource contains data for the selected time range',
|
||||
)
|
||||
: undefined;
|
||||
const noResultImage = 'chart.svg';
|
||||
if (
|
||||
(width ?? 0) > BIG_NO_RESULT_MIN_WIDTH &&
|
||||
(height ?? 0) > BIG_NO_RESULT_MIN_HEIGHT
|
||||
) {
|
||||
noResultsComponent = (
|
||||
<EmptyState
|
||||
size="large"
|
||||
title={noResultTitle}
|
||||
description={noResultDescription}
|
||||
image={noResultImage}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{this.state.showContextMenu && (
|
||||
<ChartContextMenu
|
||||
ref={this.contextMenuRef}
|
||||
id={chartId}
|
||||
formData={currentFormData as QueryFormData}
|
||||
onSelection={this.handleContextMenuSelected}
|
||||
onClose={this.handleContextMenuClosed}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
onContextMenu={
|
||||
this.state.showContextMenu ? this.onContextMenuFallback : undefined
|
||||
}
|
||||
>
|
||||
<SuperChart
|
||||
disableErrorBoundary
|
||||
key={`${chartId}${webpackHash}`}
|
||||
id={`chart-id-${chartId}`}
|
||||
className={chartClassName}
|
||||
chartType={vizType}
|
||||
width={width}
|
||||
height={height}
|
||||
annotationData={annotationData}
|
||||
datasource={datasource}
|
||||
initialValues={initialValues}
|
||||
formData={currentFormData}
|
||||
ownState={ownState}
|
||||
filterState={filterState}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
hooks={this.hooks as any}
|
||||
behaviors={behaviors}
|
||||
queriesData={this.mutableQueriesResponse ?? undefined}
|
||||
onRenderSuccess={this.handleRenderSuccess}
|
||||
onRenderFailure={this.handleRenderFailure}
|
||||
noResults={noResultsComponent}
|
||||
postTransformProps={postTransformProps}
|
||||
emitCrossFilters={emitCrossFilters}
|
||||
legendState={this.state.legendState}
|
||||
enableNoResults={bypassNoResult}
|
||||
legendIndex={this.state.legendIndex}
|
||||
isRefreshing={
|
||||
Boolean(this.props.suppressLoadingSpinner) &&
|
||||
chartStatus === 'loading'
|
||||
}
|
||||
{...drillToDetailProps}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
} else {
|
||||
noResultsComponent = (
|
||||
<EmptyState title={noResultTitle} image={noResultImage} size="small" />
|
||||
);
|
||||
}
|
||||
|
||||
// Check for Behavior.DRILL_TO_DETAIL to tell if chart can receive Drill to
|
||||
// Detail props or if it'll cause side-effects (e.g. excessive re-renders).
|
||||
const drillToDetailProps = getChartMetadataRegistry()
|
||||
.get(vizType)
|
||||
?.behaviors.find(behavior => behavior === Behavior.DrillToDetail)
|
||||
? { inContextMenu: state.inContextMenu }
|
||||
: {};
|
||||
// By pass no result component when server pagination is enabled & the table has:
|
||||
// - a backend search query, OR
|
||||
// - non-empty AG Grid filter model
|
||||
const hasSearchText = (ownState?.searchText?.length || 0) > 0;
|
||||
const hasAgGridFilters =
|
||||
ownState?.agGridFilterModel &&
|
||||
Object.keys(ownState.agGridFilterModel).length > 0;
|
||||
|
||||
const currentFormDataExtended = currentFormData as JsonObject;
|
||||
const bypassNoResult = !(
|
||||
currentFormDataExtended?.server_pagination &&
|
||||
(hasSearchText || hasAgGridFilters)
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showContextMenu && (
|
||||
<ChartContextMenu
|
||||
ref={contextMenuRef}
|
||||
id={chartId}
|
||||
formData={currentFormData as QueryFormData}
|
||||
onSelection={handleContextMenuSelected}
|
||||
onClose={handleContextMenuClosed}
|
||||
/>
|
||||
)}
|
||||
<div onContextMenu={showContextMenu ? onContextMenuFallback : undefined}>
|
||||
<SuperChart
|
||||
disableErrorBoundary
|
||||
key={`${chartId}${webpackHash}`}
|
||||
id={`chart-id-${chartId}`}
|
||||
className={chartClassName}
|
||||
chartType={vizType}
|
||||
width={width}
|
||||
height={height}
|
||||
theme={theme}
|
||||
annotationData={annotationData}
|
||||
datasource={datasource}
|
||||
initialValues={initialValues}
|
||||
formData={currentFormData}
|
||||
ownState={ownState}
|
||||
filterState={filterState}
|
||||
hooks={hooks as unknown as Parameters<typeof SuperChart>[0]['hooks']}
|
||||
behaviors={behaviors}
|
||||
queriesData={mutableQueriesResponse ?? undefined}
|
||||
onRenderSuccess={handleRenderSuccess}
|
||||
onRenderFailure={handleRenderFailure}
|
||||
noResults={noResultsComponent}
|
||||
postTransformProps={postTransformProps}
|
||||
emitCrossFilters={emitCrossFilters}
|
||||
legendState={state.legendState}
|
||||
enableNoResults={bypassNoResult}
|
||||
legendIndex={state.legendIndex}
|
||||
isRefreshing={
|
||||
Boolean(restProps.suppressLoadingSpinner) &&
|
||||
chartStatus === 'loading'
|
||||
}
|
||||
{...drillToDetailProps}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const ChartRenderer = memo(ChartRendererComponent);
|
||||
|
||||
export default ChartRenderer;
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
SuperChart,
|
||||
ContextMenuFilters,
|
||||
} from '@superset-ui/core';
|
||||
import { css } from '@apache-superset/core/theme';
|
||||
import { css, useTheme } from '@apache-superset/core/theme';
|
||||
import { Dataset } from '../types';
|
||||
|
||||
interface DrillByChartProps {
|
||||
@@ -45,6 +45,7 @@ export default function DrillByChart({
|
||||
onContextMenu,
|
||||
inContextMenu,
|
||||
}: DrillByChartProps) {
|
||||
const theme = useTheme();
|
||||
const hooks = useMemo(() => ({ onContextMenu }), [onContextMenu]);
|
||||
|
||||
return (
|
||||
@@ -67,6 +68,7 @@ export default function DrillByChart({
|
||||
inContextMenu={inContextMenu}
|
||||
height="100%"
|
||||
width="100%"
|
||||
theme={theme}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -239,7 +239,10 @@ describe('ListView', () => {
|
||||
});
|
||||
|
||||
test('calls fetchData on sort', async () => {
|
||||
const sortHeader = screen.getAllByTestId('sort-header')[1];
|
||||
// sort-header[0] is the first data column ('id'); the select-all
|
||||
// column header carries `data-test="header-toggle-all"` instead
|
||||
// of `sort-header` (see TableCollection's `header.cell` slot).
|
||||
const sortHeader = screen.getAllByTestId('sort-header')[0];
|
||||
await userEvent.click(sortHeader);
|
||||
|
||||
expect(mockedPropsComprehensive.fetchData).toHaveBeenCalledWith({
|
||||
|
||||
@@ -33,7 +33,6 @@ import BulkTagModal from 'src/features/tags/BulkTagModal';
|
||||
import {
|
||||
Button,
|
||||
Tooltip,
|
||||
Checkbox,
|
||||
Icons,
|
||||
EmptyState,
|
||||
Loading,
|
||||
@@ -179,21 +178,6 @@ const BulkSelectWrapper = styled(Alert)`
|
||||
`}
|
||||
`;
|
||||
|
||||
const bulkSelectColumnConfig = {
|
||||
Cell: ({ row }: any) => (
|
||||
<Checkbox {...row.getToggleRowSelectedProps()} id={row.id} />
|
||||
),
|
||||
Header: ({ getToggleAllRowsSelectedProps }: any) => (
|
||||
<Checkbox
|
||||
{...getToggleAllRowsSelectedProps()}
|
||||
id="header-toggle-all"
|
||||
data-test="header-toggle-all"
|
||||
/>
|
||||
),
|
||||
id: 'selection',
|
||||
size: 'sm',
|
||||
};
|
||||
|
||||
const ViewModeContainer = styled.div`
|
||||
${({ theme }) => `
|
||||
padding-right: ${theme.sizeUnit * 4}px;
|
||||
@@ -375,8 +359,6 @@ export function ListView<T extends object = any>({
|
||||
state: { pageIndex, pageSize, internalFilters, sortBy, viewMode },
|
||||
query,
|
||||
} = useListViewState({
|
||||
bulkSelectColumnConfig,
|
||||
bulkSelectMode: bulkSelectEnabled && Boolean(bulkActions.length),
|
||||
columns,
|
||||
count,
|
||||
data,
|
||||
@@ -527,6 +509,7 @@ export function ListView<T extends object = any>({
|
||||
{bulkActions.map(action => (
|
||||
<Button
|
||||
data-test="bulk-select-action"
|
||||
data-test-action-key={action.key}
|
||||
key={action.key}
|
||||
buttonStyle={action.type}
|
||||
cta
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useMemo, useState, ReactNode } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
useFilters,
|
||||
usePagination,
|
||||
@@ -192,13 +192,7 @@ interface UseListViewConfig {
|
||||
count: number;
|
||||
initialPageSize: number;
|
||||
initialSort?: SortColumn[];
|
||||
bulkSelectMode?: boolean;
|
||||
initialFilters?: Filter[];
|
||||
bulkSelectColumnConfig?: {
|
||||
id: string;
|
||||
Header: (conf: any) => ReactNode;
|
||||
Cell: (conf: any) => ReactNode;
|
||||
};
|
||||
renderCard?: boolean;
|
||||
defaultViewMode?: ViewModeType;
|
||||
}
|
||||
@@ -211,8 +205,6 @@ export function useListViewState({
|
||||
initialPageSize,
|
||||
initialFilters = [],
|
||||
initialSort = [],
|
||||
bulkSelectMode = false,
|
||||
bulkSelectColumnConfig,
|
||||
renderCard = false,
|
||||
defaultViewMode = 'card',
|
||||
}: UseListViewConfig) {
|
||||
@@ -246,13 +238,11 @@ export function useListViewState({
|
||||
(renderCard ? defaultViewMode : 'table'),
|
||||
);
|
||||
|
||||
const columnsWithSelect = useMemo(() => {
|
||||
const columnsWithFilter = useMemo(
|
||||
// add exact filter type so filters with falsy values are not filtered out
|
||||
const columnsWithFilter = columns.map(f => ({ ...f, filter: 'exact' }));
|
||||
return bulkSelectMode
|
||||
? [bulkSelectColumnConfig, ...columnsWithFilter]
|
||||
: columnsWithFilter;
|
||||
}, [bulkSelectMode, columns]);
|
||||
() => columns.map(f => ({ ...f, filter: 'exact' })),
|
||||
[columns],
|
||||
);
|
||||
|
||||
const {
|
||||
getTableProps,
|
||||
@@ -271,7 +261,7 @@ export function useListViewState({
|
||||
state: { pageIndex, pageSize, sortBy, filters },
|
||||
} = useTable(
|
||||
{
|
||||
columns: columnsWithSelect,
|
||||
columns: columnsWithFilter,
|
||||
data,
|
||||
disableFilters: true,
|
||||
disableSortRemove: true,
|
||||
|
||||
@@ -30,6 +30,7 @@ jest.mock('@superset-ui/core', () => ({
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
SupersetClient: {
|
||||
getCSRFToken: jest.fn(() => Promise.resolve('mock-csrf-token')),
|
||||
getGuestToken: jest.fn(() => undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -47,9 +48,12 @@ global.URL.revokeObjectURL = jest.fn();
|
||||
|
||||
global.fetch = jest.fn();
|
||||
|
||||
const { SupersetClient } = jest.requireMock('@superset-ui/core');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
global.fetch = jest.fn();
|
||||
SupersetClient.getGuestToken.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
test('useStreamingExport initializes with default progress state', () => {
|
||||
@@ -238,6 +242,32 @@ const createPrefixTestMockFetch = () =>
|
||||
},
|
||||
});
|
||||
|
||||
test('chart streaming export includes guest token in form body when configured', async () => {
|
||||
SupersetClient.getGuestToken.mockReturnValue('guest-token');
|
||||
const mockFetch = createPrefixTestMockFetch();
|
||||
global.fetch = mockFetch;
|
||||
|
||||
const { result } = renderHook(() => useStreamingExport());
|
||||
|
||||
act(() => {
|
||||
result.current.startExport({
|
||||
url: '/api/v1/chart/data',
|
||||
payload: { datasource: '1__table', viz_type: 'table' },
|
||||
exportType: 'csv',
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const request = mockFetch.mock.calls[0][1];
|
||||
expect(request.body.get('guest_token')).toBe('guest-token');
|
||||
expect(request.body.get('form_data')).toBe(
|
||||
JSON.stringify({ datasource: '1__table', viz_type: 'table' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('URL prefix guard applies prefix to unprefixed relative URL when app root is configured', async () => {
|
||||
const appRoot = '/superset';
|
||||
applicationRoot.mockReturnValue(appRoot);
|
||||
@@ -652,6 +682,8 @@ test('completes XLSX export successfully with correct filename', async () => {
|
||||
expect(result.current.progress.status).toBe(ExportStatus.COMPLETED);
|
||||
});
|
||||
|
||||
const request = mockFetch.mock.calls[0][1];
|
||||
expect(request.body.get('guest_token')).toBeNull();
|
||||
expect(result.current.progress.filename).toBe('report.xlsx');
|
||||
expect(onComplete).toHaveBeenCalledWith('blob:mock-url', 'report.xlsx');
|
||||
});
|
||||
|
||||
@@ -118,6 +118,11 @@ const createFetchRequest = async (
|
||||
formParams.expected_rows = expectedRows.toString();
|
||||
}
|
||||
|
||||
const guestToken = SupersetClient.getGuestToken();
|
||||
if (guestToken) {
|
||||
formParams.guest_token = guestToken;
|
||||
}
|
||||
|
||||
if ('client_id' in payload) {
|
||||
// SQL Lab export - pass client_id directly
|
||||
formParams.client_id = String(payload.client_id);
|
||||
|
||||
@@ -16,7 +16,11 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { SupersetClient, isFeatureEnabled } from '@superset-ui/core';
|
||||
import {
|
||||
JsonResponse,
|
||||
SupersetClient,
|
||||
isFeatureEnabled,
|
||||
} from '@superset-ui/core';
|
||||
import { waitFor } from 'spec/helpers/testing-library';
|
||||
|
||||
import {
|
||||
@@ -28,9 +32,16 @@ import {
|
||||
ON_FILTERS_REFRESH,
|
||||
ON_REFRESH,
|
||||
ON_REFRESH_SUCCESS,
|
||||
TOGGLE_FAVE_STAR,
|
||||
TOGGLE_PUBLISHED,
|
||||
fetchFaveStar,
|
||||
saveFaveStar,
|
||||
savePublished,
|
||||
} from 'src/dashboard/actions/dashboardState';
|
||||
import { refreshChart } from 'src/components/Chart/chartAction';
|
||||
import { UPDATE_COMPONENTS_PARENTS_LIST } from 'src/dashboard/actions/dashboardLayout';
|
||||
import { ADD_TOAST } from 'src/components/MessageToasts/actions';
|
||||
import { ToastType } from 'src/components/MessageToasts/types';
|
||||
import {
|
||||
DASHBOARD_GRID_ID,
|
||||
SAVE_TYPE_OVERWRITE,
|
||||
@@ -400,4 +411,322 @@ describe('dashboardState actions', () => {
|
||||
expect(dispatchedTypes).not.toContain(ON_REFRESH);
|
||||
expect(dispatchedTypes).not.toContain(ON_FILTERS_REFRESH);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('fetchFaveStar race condition', () => {
|
||||
test('dispatches TOGGLE_FAVE_STAR when the dashboard ID still matches', async () => {
|
||||
const id = 123;
|
||||
const { getState, dispatch } = setup({
|
||||
dashboardInfo: {
|
||||
id,
|
||||
metadata: { color_scheme: 'supersetColors' },
|
||||
},
|
||||
});
|
||||
|
||||
getStub.mockRestore();
|
||||
getStub = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
|
||||
json: { result: [{ value: true }] },
|
||||
} as unknown as JsonResponse);
|
||||
|
||||
await fetchFaveStar(id)(dispatch, getState);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: TOGGLE_FAVE_STAR,
|
||||
isStarred: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('does NOT dispatch when the dashboard ID changed before the response resolved', async () => {
|
||||
const requestedId = 123;
|
||||
// User navigated to a different dashboard by the time the response comes back
|
||||
const { getState, dispatch } = setup({
|
||||
dashboardInfo: {
|
||||
id: 456,
|
||||
metadata: { color_scheme: 'supersetColors' },
|
||||
},
|
||||
});
|
||||
|
||||
getStub.mockRestore();
|
||||
getStub = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
|
||||
json: { result: [{ value: true }] },
|
||||
} as unknown as JsonResponse);
|
||||
|
||||
await fetchFaveStar(requestedId)(dispatch, getState);
|
||||
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('dispatches a danger toast on error when the dashboard ID still matches', async () => {
|
||||
const id = 123;
|
||||
const { getState, dispatch } = setup({
|
||||
dashboardInfo: {
|
||||
id,
|
||||
metadata: { color_scheme: 'supersetColors' },
|
||||
},
|
||||
});
|
||||
|
||||
getStub.mockRestore();
|
||||
getStub = jest
|
||||
.spyOn(SupersetClient, 'get')
|
||||
.mockRejectedValue(new Error('network'));
|
||||
|
||||
await fetchFaveStar(id)(dispatch, getState);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch.mock.calls[0][0]).toEqual(
|
||||
expect.objectContaining({
|
||||
type: ADD_TOAST,
|
||||
payload: expect.objectContaining({
|
||||
toastType: ToastType.Danger,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('does NOT dispatch a danger toast on error when the dashboard ID changed', async () => {
|
||||
const requestedId = 123;
|
||||
const { getState, dispatch } = setup({
|
||||
dashboardInfo: {
|
||||
id: 456,
|
||||
metadata: { color_scheme: 'supersetColors' },
|
||||
},
|
||||
});
|
||||
|
||||
getStub.mockRestore();
|
||||
getStub = jest
|
||||
.spyOn(SupersetClient, 'get')
|
||||
.mockRejectedValue(new Error('network'));
|
||||
|
||||
await fetchFaveStar(requestedId)(dispatch, getState);
|
||||
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('saveFaveStar race condition', () => {
|
||||
let deleteStub: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
deleteStub = jest
|
||||
.spyOn(SupersetClient, 'delete')
|
||||
.mockResolvedValue({} as unknown as JsonResponse);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
deleteStub.mockRestore();
|
||||
});
|
||||
|
||||
test('dispatches TOGGLE_FAVE_STAR when the dashboard ID still matches (starring)', async () => {
|
||||
const id = 123;
|
||||
const { getState, dispatch } = setup({
|
||||
dashboardInfo: {
|
||||
id,
|
||||
metadata: { color_scheme: 'supersetColors' },
|
||||
},
|
||||
});
|
||||
|
||||
postStub.mockRestore();
|
||||
postStub = jest
|
||||
.spyOn(SupersetClient, 'post')
|
||||
.mockResolvedValue({} as unknown as JsonResponse);
|
||||
|
||||
await saveFaveStar(id, false)(dispatch, getState);
|
||||
|
||||
expect(postStub).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: TOGGLE_FAVE_STAR,
|
||||
isStarred: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('dispatches TOGGLE_FAVE_STAR when the dashboard ID still matches (unstarring)', async () => {
|
||||
const id = 123;
|
||||
const { getState, dispatch } = setup({
|
||||
dashboardInfo: {
|
||||
id,
|
||||
metadata: { color_scheme: 'supersetColors' },
|
||||
},
|
||||
});
|
||||
|
||||
await saveFaveStar(id, true)(dispatch, getState);
|
||||
|
||||
expect(deleteStub).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: TOGGLE_FAVE_STAR,
|
||||
isStarred: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('does NOT dispatch when the dashboard ID changed before the response resolved', async () => {
|
||||
const requestedId = 123;
|
||||
// User navigated to a different dashboard by the time the response comes back
|
||||
const { getState, dispatch } = setup({
|
||||
dashboardInfo: {
|
||||
id: 456,
|
||||
metadata: { color_scheme: 'supersetColors' },
|
||||
},
|
||||
});
|
||||
|
||||
postStub.mockRestore();
|
||||
postStub = jest
|
||||
.spyOn(SupersetClient, 'post')
|
||||
.mockResolvedValue({} as unknown as JsonResponse);
|
||||
|
||||
await saveFaveStar(requestedId, false)(dispatch, getState);
|
||||
|
||||
expect(postStub).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('dispatches a danger toast on error when the dashboard ID still matches', async () => {
|
||||
const id = 123;
|
||||
const { getState, dispatch } = setup({
|
||||
dashboardInfo: {
|
||||
id,
|
||||
metadata: { color_scheme: 'supersetColors' },
|
||||
},
|
||||
});
|
||||
|
||||
postStub.mockRestore();
|
||||
postStub = jest
|
||||
.spyOn(SupersetClient, 'post')
|
||||
.mockRejectedValue(new Error('network'));
|
||||
|
||||
await saveFaveStar(id, false)(dispatch, getState);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch.mock.calls[0][0]).toEqual(
|
||||
expect.objectContaining({
|
||||
type: ADD_TOAST,
|
||||
payload: expect.objectContaining({
|
||||
toastType: ToastType.Danger,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('does NOT dispatch a danger toast on error when the dashboard ID changed', async () => {
|
||||
const requestedId = 123;
|
||||
const { getState, dispatch } = setup({
|
||||
dashboardInfo: {
|
||||
id: 456,
|
||||
metadata: { color_scheme: 'supersetColors' },
|
||||
},
|
||||
});
|
||||
|
||||
postStub.mockRestore();
|
||||
postStub = jest
|
||||
.spyOn(SupersetClient, 'post')
|
||||
.mockRejectedValue(new Error('network'));
|
||||
|
||||
await saveFaveStar(requestedId, false)(dispatch, getState);
|
||||
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('savePublished race condition', () => {
|
||||
test('dispatches success toast and TOGGLE_PUBLISHED when the dashboard ID still matches', async () => {
|
||||
const id = 123;
|
||||
const { getState, dispatch } = setup({
|
||||
dashboardInfo: {
|
||||
id,
|
||||
metadata: { color_scheme: 'supersetColors' },
|
||||
},
|
||||
});
|
||||
|
||||
putStub.mockRestore();
|
||||
putStub = jest
|
||||
.spyOn(SupersetClient, 'put')
|
||||
.mockResolvedValue({} as unknown as JsonResponse);
|
||||
|
||||
await savePublished(id, true)(dispatch, getState);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledTimes(2);
|
||||
expect(dispatch.mock.calls[0][0]).toEqual(
|
||||
expect.objectContaining({
|
||||
type: ADD_TOAST,
|
||||
payload: expect.objectContaining({
|
||||
toastType: ToastType.Success,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: TOGGLE_PUBLISHED,
|
||||
isPublished: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('does NOT dispatch when the dashboard ID changed before the response resolved', async () => {
|
||||
const requestedId = 123;
|
||||
// User navigated to a different dashboard by the time the response comes back
|
||||
const { getState, dispatch } = setup({
|
||||
dashboardInfo: {
|
||||
id: 456,
|
||||
metadata: { color_scheme: 'supersetColors' },
|
||||
},
|
||||
});
|
||||
|
||||
putStub.mockRestore();
|
||||
putStub = jest
|
||||
.spyOn(SupersetClient, 'put')
|
||||
.mockResolvedValue({} as unknown as JsonResponse);
|
||||
|
||||
await savePublished(requestedId, true)(dispatch, getState);
|
||||
|
||||
expect(putStub).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('dispatches a danger toast on error when the dashboard ID still matches', async () => {
|
||||
const id = 123;
|
||||
const { getState, dispatch } = setup({
|
||||
dashboardInfo: {
|
||||
id,
|
||||
metadata: { color_scheme: 'supersetColors' },
|
||||
},
|
||||
});
|
||||
|
||||
putStub.mockRestore();
|
||||
putStub = jest
|
||||
.spyOn(SupersetClient, 'put')
|
||||
.mockRejectedValue(new Error('forbidden'));
|
||||
|
||||
await savePublished(id, true)(dispatch, getState);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch.mock.calls[0][0]).toEqual(
|
||||
expect.objectContaining({
|
||||
type: ADD_TOAST,
|
||||
payload: expect.objectContaining({
|
||||
toastType: ToastType.Danger,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('does NOT dispatch a danger toast on error when the dashboard ID changed', async () => {
|
||||
const requestedId = 123;
|
||||
const { getState, dispatch } = setup({
|
||||
dashboardInfo: {
|
||||
id: 456,
|
||||
metadata: { color_scheme: 'supersetColors' },
|
||||
},
|
||||
});
|
||||
|
||||
putStub.mockRestore();
|
||||
putStub = jest
|
||||
.spyOn(SupersetClient, 'put')
|
||||
.mockRejectedValue(new Error('forbidden'));
|
||||
|
||||
await savePublished(requestedId, true)(dispatch, getState);
|
||||
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -160,27 +160,43 @@ export function toggleFaveStar(isStarred: boolean): ToggleFaveStarAction {
|
||||
}
|
||||
|
||||
export function fetchFaveStar(id: number) {
|
||||
return function fetchFaveStarThunk(dispatch: AppDispatch) {
|
||||
return function fetchFaveStarThunk(
|
||||
dispatch: AppDispatch,
|
||||
getState: GetState,
|
||||
) {
|
||||
return SupersetClient.get({
|
||||
endpoint: `/api/v1/dashboard/favorite_status/?q=${rison.encode([id])}`,
|
||||
})
|
||||
.then(({ json }: { json: JsonObject }) => {
|
||||
dispatch(toggleFaveStar(!!(json?.result as JsonObject[])?.[0]?.value));
|
||||
// Only update state if this is still the current dashboard
|
||||
// This prevents stale responses from affecting the UI after navigation
|
||||
const currentId = getState().dashboardInfo?.id;
|
||||
if (currentId === id) {
|
||||
dispatch(
|
||||
toggleFaveStar(!!(json?.result as JsonObject[])?.[0]?.value),
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() =>
|
||||
dispatch(
|
||||
addDangerToast(
|
||||
t(
|
||||
'There was an issue fetching the favorite status of this dashboard.',
|
||||
.catch(() => {
|
||||
// Only show error if this is still the current dashboard
|
||||
// This prevents error toasts from appearing for dashboards the user
|
||||
// has already navigated away from (e.g., deleted dashboards)
|
||||
const currentId = getState().dashboardInfo?.id;
|
||||
if (currentId === id) {
|
||||
dispatch(
|
||||
addDangerToast(
|
||||
t(
|
||||
'There was an issue fetching the favorite status of this dashboard.',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function saveFaveStar(id: number, isStarred: boolean) {
|
||||
return function saveFaveStarThunk(dispatch: AppDispatch) {
|
||||
return function saveFaveStarThunk(dispatch: AppDispatch, getState: GetState) {
|
||||
const endpoint = `/api/v1/dashboard/${id}/favorites/`;
|
||||
const apiCall = isStarred
|
||||
? SupersetClient.delete({
|
||||
@@ -190,13 +206,21 @@ export function saveFaveStar(id: number, isStarred: boolean) {
|
||||
|
||||
return apiCall
|
||||
.then(() => {
|
||||
dispatch(toggleFaveStar(!isStarred));
|
||||
// Only update state if this is still the current dashboard
|
||||
const currentId = getState().dashboardInfo?.id;
|
||||
if (currentId === id) {
|
||||
dispatch(toggleFaveStar(!isStarred));
|
||||
}
|
||||
})
|
||||
.catch(() =>
|
||||
dispatch(
|
||||
addDangerToast(t('There was an issue favoriting this dashboard.')),
|
||||
),
|
||||
);
|
||||
.catch(() => {
|
||||
// Only show error if this is still the current dashboard
|
||||
const currentId = getState().dashboardInfo?.id;
|
||||
if (currentId === id) {
|
||||
dispatch(
|
||||
addDangerToast(t('There was an issue favoriting this dashboard.')),
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -214,8 +238,11 @@ export function togglePublished(isPublished: boolean): TogglePublishedAction {
|
||||
export function savePublished(
|
||||
id: number,
|
||||
isPublished: boolean,
|
||||
): (dispatch: AppDispatch) => Promise<void> {
|
||||
return function savePublishedThunk(dispatch: AppDispatch): Promise<void> {
|
||||
): (dispatch: AppDispatch, getState: GetState) => Promise<void> {
|
||||
return function savePublishedThunk(
|
||||
dispatch: AppDispatch,
|
||||
getState: GetState,
|
||||
): Promise<void> {
|
||||
return SupersetClient.put({
|
||||
endpoint: `/api/v1/dashboard/${id}`,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -224,21 +251,30 @@ export function savePublished(
|
||||
}),
|
||||
})
|
||||
.then(() => {
|
||||
dispatch(
|
||||
addSuccessToast(
|
||||
isPublished
|
||||
? t('This dashboard is now published')
|
||||
: t('This dashboard is now hidden'),
|
||||
),
|
||||
);
|
||||
dispatch(togglePublished(isPublished));
|
||||
// Only update state if this is still the current dashboard
|
||||
// This prevents stale responses from affecting the UI after navigation
|
||||
const currentId = getState().dashboardInfo?.id;
|
||||
if (currentId === id) {
|
||||
dispatch(
|
||||
addSuccessToast(
|
||||
isPublished
|
||||
? t('This dashboard is now published')
|
||||
: t('This dashboard is now hidden'),
|
||||
),
|
||||
);
|
||||
dispatch(togglePublished(isPublished));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
dispatch(
|
||||
addDangerToast(
|
||||
t('You do not have permissions to edit this dashboard.'),
|
||||
),
|
||||
);
|
||||
// Only show error if this is still the current dashboard
|
||||
const currentId = getState().dashboardInfo?.id;
|
||||
if (currentId === id) {
|
||||
dispatch(
|
||||
addDangerToast(
|
||||
t('You do not have permissions to edit this dashboard.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { PureComponent, ReactNode } from 'react';
|
||||
import { ReactNode, useCallback, useContext, useEffect, useRef } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { JsonObject } from '@superset-ui/core';
|
||||
|
||||
@@ -90,165 +90,61 @@ interface VisibilityEventData {
|
||||
ts: number;
|
||||
}
|
||||
|
||||
class Dashboard extends PureComponent<DashboardProps> {
|
||||
static contextType = PluginContext;
|
||||
function unload(event: BeforeUnloadEvent): string {
|
||||
const message = t('You have unsaved changes.');
|
||||
// Set returnValue on the actual event object to trigger the browser prompt
|
||||
event.returnValue = message;
|
||||
return message; // Gecko + Webkit, Safari, Chrome etc.
|
||||
}
|
||||
|
||||
// Use type assertion when accessing context instead of declare field
|
||||
// to avoid babel transformation issues in Jest
|
||||
|
||||
static defaultProps = {
|
||||
timeout: 60,
|
||||
userId: '',
|
||||
};
|
||||
|
||||
appliedFilters: ActiveFilters;
|
||||
|
||||
appliedOwnDataCharts: JsonObject;
|
||||
|
||||
visibilityEventData: VisibilityEventData;
|
||||
|
||||
static onBeforeUnload(hasChanged: boolean): void {
|
||||
if (hasChanged) {
|
||||
window.addEventListener('beforeunload', Dashboard.unload);
|
||||
} else {
|
||||
window.removeEventListener('beforeunload', Dashboard.unload);
|
||||
}
|
||||
function onBeforeUnload(hasChanged: boolean): void {
|
||||
if (hasChanged) {
|
||||
window.addEventListener('beforeunload', unload);
|
||||
} else {
|
||||
window.removeEventListener('beforeunload', unload);
|
||||
}
|
||||
}
|
||||
|
||||
static unload(): string {
|
||||
const message = t('You have unsaved changes.');
|
||||
// Gecko + IE: returnValue is typed as boolean but historically accepts string
|
||||
(window.event as BeforeUnloadEvent).returnValue = message;
|
||||
return message; // Gecko + Webkit, Safari, Chrome etc.
|
||||
}
|
||||
function Dashboard({
|
||||
actions,
|
||||
dashboardId,
|
||||
editMode,
|
||||
isPublished,
|
||||
hasUnsavedChanges,
|
||||
slices,
|
||||
activeFilters,
|
||||
chartConfiguration,
|
||||
datasources,
|
||||
ownDataCharts,
|
||||
layout,
|
||||
impressionId,
|
||||
timeout = 60,
|
||||
userId = '',
|
||||
children,
|
||||
}: DashboardProps): JSX.Element {
|
||||
const context = useContext(PluginContext) as PluginContextType;
|
||||
|
||||
constructor(props: DashboardProps) {
|
||||
super(props);
|
||||
this.appliedFilters = props.activeFilters ?? {};
|
||||
this.appliedOwnDataCharts = props.ownDataCharts ?? {};
|
||||
this.visibilityEventData = { start_offset: 0, ts: 0 };
|
||||
this.onVisibilityChange = this.onVisibilityChange.bind(this);
|
||||
}
|
||||
// Use refs to track mutable values that persist across renders
|
||||
const appliedFiltersRef = useRef<ActiveFilters>(activeFilters ?? {});
|
||||
const appliedOwnDataChartsRef = useRef<JsonObject>(ownDataCharts ?? {});
|
||||
const visibilityEventDataRef = useRef<VisibilityEventData>({
|
||||
start_offset: 0,
|
||||
ts: 0,
|
||||
});
|
||||
const prevLayoutRef = useRef<DashboardLayout>(layout);
|
||||
const prevDashboardIdRef = useRef<number>(dashboardId);
|
||||
|
||||
componentDidMount(): void {
|
||||
const bootstrapData = getBootstrapData();
|
||||
const { editMode, isPublished, layout } = this.props;
|
||||
const eventData: Record<string, unknown> = {
|
||||
is_soft_navigation: Logger.timeOriginOffset > 0,
|
||||
is_edit_mode: editMode,
|
||||
mount_duration: Logger.getTimestamp(),
|
||||
is_empty: isDashboardEmpty(layout),
|
||||
is_published: isPublished,
|
||||
bootstrap_data_length: JSON.stringify(bootstrapData).length,
|
||||
};
|
||||
const directLinkComponentId = getLocationHash();
|
||||
if (directLinkComponentId) {
|
||||
eventData.target_id = directLinkComponentId;
|
||||
}
|
||||
this.props.actions.logEvent(LOG_ACTIONS_MOUNT_DASHBOARD, eventData);
|
||||
|
||||
// Handle browser tab visibility change
|
||||
if (document.visibilityState === 'hidden') {
|
||||
this.visibilityEventData = {
|
||||
start_offset: Logger.getTimestamp(),
|
||||
ts: new Date().getTime(),
|
||||
};
|
||||
}
|
||||
window.addEventListener('visibilitychange', this.onVisibilityChange);
|
||||
this.applyCharts();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: DashboardProps): void {
|
||||
this.applyCharts();
|
||||
const currentChartIds = getChartIdsFromLayout(prevProps.layout);
|
||||
const nextChartIds = getChartIdsFromLayout(this.props.layout);
|
||||
|
||||
if (prevProps.dashboardId !== this.props.dashboardId) {
|
||||
// single-page-app navigation check
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentChartIds.length < nextChartIds.length) {
|
||||
const newChartIds = nextChartIds.filter(
|
||||
key => currentChartIds.indexOf(key) === -1,
|
||||
);
|
||||
newChartIds.forEach(newChartId =>
|
||||
this.props.actions.addSliceToDashboard(
|
||||
newChartId,
|
||||
getLayoutComponentFromChartId(this.props.layout, newChartId),
|
||||
),
|
||||
);
|
||||
} else if (currentChartIds.length > nextChartIds.length) {
|
||||
// remove chart
|
||||
const removedChartIds = currentChartIds.filter(
|
||||
key => nextChartIds.indexOf(key) === -1,
|
||||
);
|
||||
removedChartIds.forEach(removedChartId =>
|
||||
this.props.actions.removeSliceFromDashboard(removedChartId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
applyCharts(): void {
|
||||
const {
|
||||
activeFilters,
|
||||
ownDataCharts,
|
||||
chartConfiguration,
|
||||
hasUnsavedChanges,
|
||||
editMode,
|
||||
} = this.props;
|
||||
const { appliedFilters, appliedOwnDataCharts } = this;
|
||||
if (!chartConfiguration) {
|
||||
// For a first loading we need to wait for cross filters charts data loaded to get all active filters
|
||||
// for correct comparing of filters to avoid unnecessary requests
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!editMode &&
|
||||
(!areObjectsEqual(appliedOwnDataCharts, ownDataCharts, {
|
||||
ignoreUndefined: true,
|
||||
}) ||
|
||||
!areObjectsEqual(appliedFilters, activeFilters, {
|
||||
ignoreUndefined: true,
|
||||
}))
|
||||
) {
|
||||
this.applyFilters();
|
||||
}
|
||||
|
||||
if (hasUnsavedChanges) {
|
||||
Dashboard.onBeforeUnload(true);
|
||||
} else {
|
||||
Dashboard.onBeforeUnload(false);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
window.removeEventListener('visibilitychange', this.onVisibilityChange);
|
||||
this.props.actions.clearDataMaskState();
|
||||
this.props.actions.clearAllChartStates();
|
||||
}
|
||||
|
||||
onVisibilityChange(): void {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
// from visible to hidden
|
||||
this.visibilityEventData = {
|
||||
start_offset: Logger.getTimestamp(),
|
||||
ts: new Date().getTime(),
|
||||
};
|
||||
} else if (document.visibilityState === 'visible') {
|
||||
// from hidden to visible
|
||||
const logStart = this.visibilityEventData.start_offset;
|
||||
this.props.actions.logEvent(LOG_ACTIONS_HIDE_BROWSER_TAB, {
|
||||
...this.visibilityEventData,
|
||||
duration: Logger.getTimestamp() - logStart,
|
||||
const refreshCharts = useCallback(
|
||||
(ids: (string | number)[]): void => {
|
||||
ids.forEach(id => {
|
||||
actions.triggerQuery(true, id);
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[actions],
|
||||
);
|
||||
|
||||
applyFilters(): void {
|
||||
const { appliedFilters } = this;
|
||||
const { activeFilters, ownDataCharts, slices } = this.props;
|
||||
const applyFilters = useCallback((): void => {
|
||||
const appliedFilters = appliedFiltersRef.current;
|
||||
|
||||
// refresh charts if a filter was removed, added, or changed
|
||||
|
||||
@@ -258,7 +154,7 @@ class Dashboard extends PureComponent<DashboardProps> {
|
||||
const allKeys = new Set(currFilterKeys.concat(appliedFilterKeys));
|
||||
const affectedChartIds: (string | number)[] = getAffectedOwnDataCharts(
|
||||
ownDataCharts,
|
||||
this.appliedOwnDataCharts,
|
||||
appliedOwnDataChartsRef.current,
|
||||
);
|
||||
|
||||
[...allKeys].forEach(filterKey => {
|
||||
@@ -321,24 +217,145 @@ class Dashboard extends PureComponent<DashboardProps> {
|
||||
});
|
||||
|
||||
// remove dup in affectedChartIds
|
||||
this.refreshCharts([...new Set(affectedChartIds)]);
|
||||
this.appliedFilters = activeFilters;
|
||||
this.appliedOwnDataCharts = ownDataCharts;
|
||||
}
|
||||
refreshCharts([...new Set(affectedChartIds)]);
|
||||
appliedFiltersRef.current = activeFilters;
|
||||
appliedOwnDataChartsRef.current = ownDataCharts;
|
||||
}, [activeFilters, ownDataCharts, slices, refreshCharts]);
|
||||
|
||||
refreshCharts(ids: (string | number)[]): void {
|
||||
ids.forEach(id => {
|
||||
this.props.actions.triggerQuery(true, id);
|
||||
});
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
const context = this.context as PluginContextType;
|
||||
if (context.loading) {
|
||||
return <Loading />;
|
||||
const applyCharts = useCallback((): void => {
|
||||
if (!chartConfiguration) {
|
||||
// For a first loading we need to wait for cross filters charts data loaded to get all active filters
|
||||
// for correct comparing of filters to avoid unnecessary requests
|
||||
return;
|
||||
}
|
||||
return this.props.children;
|
||||
|
||||
if (
|
||||
!editMode &&
|
||||
(!areObjectsEqual(appliedOwnDataChartsRef.current, ownDataCharts, {
|
||||
ignoreUndefined: true,
|
||||
}) ||
|
||||
!areObjectsEqual(appliedFiltersRef.current, activeFilters, {
|
||||
ignoreUndefined: true,
|
||||
}))
|
||||
) {
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
if (hasUnsavedChanges) {
|
||||
onBeforeUnload(true);
|
||||
} else {
|
||||
onBeforeUnload(false);
|
||||
}
|
||||
}, [
|
||||
chartConfiguration,
|
||||
editMode,
|
||||
ownDataCharts,
|
||||
activeFilters,
|
||||
hasUnsavedChanges,
|
||||
applyFilters,
|
||||
]);
|
||||
|
||||
const onVisibilityChange = useCallback((): void => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
// from visible to hidden
|
||||
visibilityEventDataRef.current = {
|
||||
start_offset: Logger.getTimestamp(),
|
||||
ts: new Date().getTime(),
|
||||
};
|
||||
} else if (document.visibilityState === 'visible') {
|
||||
// from hidden to visible
|
||||
const logStart = visibilityEventDataRef.current.start_offset;
|
||||
actions.logEvent(LOG_ACTIONS_HIDE_BROWSER_TAB, {
|
||||
...visibilityEventDataRef.current,
|
||||
duration: Logger.getTimestamp() - logStart,
|
||||
});
|
||||
}
|
||||
}, [actions]);
|
||||
|
||||
// componentDidMount equivalent
|
||||
useEffect(() => {
|
||||
const bootstrapData = getBootstrapData();
|
||||
const eventData: Record<string, unknown> = {
|
||||
is_soft_navigation: Logger.timeOriginOffset > 0,
|
||||
is_edit_mode: editMode,
|
||||
mount_duration: Logger.getTimestamp(),
|
||||
is_empty: isDashboardEmpty(layout),
|
||||
is_published: isPublished,
|
||||
bootstrap_data_length: JSON.stringify(bootstrapData).length,
|
||||
};
|
||||
const directLinkComponentId = getLocationHash();
|
||||
if (directLinkComponentId) {
|
||||
eventData.target_id = directLinkComponentId;
|
||||
}
|
||||
actions.logEvent(LOG_ACTIONS_MOUNT_DASHBOARD, eventData);
|
||||
|
||||
// Handle browser tab visibility change
|
||||
if (document.visibilityState === 'hidden') {
|
||||
visibilityEventDataRef.current = {
|
||||
start_offset: Logger.getTimestamp(),
|
||||
ts: new Date().getTime(),
|
||||
};
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
|
||||
// componentWillUnmount equivalent
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
onBeforeUnload(false); // Remove beforeunload listener on unmount
|
||||
actions.clearDataMaskState();
|
||||
actions.clearAllChartStates();
|
||||
};
|
||||
// Only run on mount/unmount - intentionally excluding deps that would cause re-runs
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Apply charts on every render (like componentDidMount + componentDidUpdate calling applyCharts)
|
||||
useEffect(() => {
|
||||
applyCharts();
|
||||
}, [applyCharts]);
|
||||
|
||||
// componentDidUpdate equivalent for layout changes
|
||||
useEffect(() => {
|
||||
const prevLayout = prevLayoutRef.current;
|
||||
const prevDashboardId = prevDashboardIdRef.current;
|
||||
|
||||
// Update refs for next comparison
|
||||
prevLayoutRef.current = layout;
|
||||
prevDashboardIdRef.current = dashboardId;
|
||||
|
||||
const currentChartIds = getChartIdsFromLayout(prevLayout);
|
||||
const nextChartIds = getChartIdsFromLayout(layout);
|
||||
|
||||
if (prevDashboardId !== dashboardId) {
|
||||
// single-page-app navigation check
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentChartIds.length < nextChartIds.length) {
|
||||
const newChartIds = nextChartIds.filter(
|
||||
key => currentChartIds.indexOf(key) === -1,
|
||||
);
|
||||
newChartIds.forEach(newChartId =>
|
||||
actions.addSliceToDashboard(
|
||||
newChartId,
|
||||
getLayoutComponentFromChartId(layout, newChartId),
|
||||
),
|
||||
);
|
||||
} else if (currentChartIds.length > nextChartIds.length) {
|
||||
// remove chart
|
||||
const removedChartIds = currentChartIds.filter(
|
||||
key => nextChartIds.indexOf(key) === -1,
|
||||
);
|
||||
removedChartIds.forEach(removedChartId =>
|
||||
actions.removeSliceFromDashboard(removedChartId),
|
||||
);
|
||||
}
|
||||
}, [layout, dashboardId, actions]);
|
||||
|
||||
if (context.loading) {
|
||||
return <Loading />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export default Dashboard;
|
||||
|
||||
@@ -31,9 +31,10 @@ export const getRootLevelTabsComponent = (dashboardLayout: DashboardLayout) => {
|
||||
};
|
||||
|
||||
export const shouldFocusTabs = (
|
||||
event: { target: { className: string } },
|
||||
container: { contains: (arg0: any) => any },
|
||||
) =>
|
||||
event: { target: HTMLElement },
|
||||
container: Pick<Node, 'contains'> | null,
|
||||
_menuRef: HTMLDivElement | null,
|
||||
): boolean =>
|
||||
// don't focus the tabs when we click on a tab
|
||||
event.target.className === 'ant-tabs-nav-wrap' ||
|
||||
container.contains(event.target);
|
||||
(container?.contains(event.target) ?? false);
|
||||
|
||||
@@ -16,12 +16,11 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { PureComponent, Fragment } from 'react';
|
||||
import { withTheme } from '@emotion/react';
|
||||
import { Fragment, useCallback, useRef, useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { addAlpha } from '@superset-ui/core';
|
||||
import { css, styled, type SupersetTheme } from '@apache-superset/core/theme';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { css, styled, useTheme } from '@apache-superset/core/theme';
|
||||
import { EmptyState } from '@superset-ui/core/components';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { navigateTo } from 'src/utils/navigationUtils';
|
||||
@@ -48,11 +47,6 @@ export interface DashboardGridProps {
|
||||
setEditMode?: (editMode: boolean) => void;
|
||||
width: number;
|
||||
dashboardId?: number;
|
||||
theme: SupersetTheme;
|
||||
}
|
||||
|
||||
interface DashboardGridState {
|
||||
isResizing: boolean;
|
||||
}
|
||||
|
||||
interface DropProps {
|
||||
@@ -131,261 +125,235 @@ const GridColumnGuide = styled.div`
|
||||
`};
|
||||
`;
|
||||
|
||||
class DashboardGrid extends PureComponent<
|
||||
DashboardGridProps,
|
||||
DashboardGridState
|
||||
> {
|
||||
grid: HTMLDivElement | null;
|
||||
function DashboardGrid({
|
||||
depth,
|
||||
editMode,
|
||||
canEdit,
|
||||
gridComponent,
|
||||
handleComponentDrop,
|
||||
isComponentVisible,
|
||||
resizeComponent,
|
||||
setDirectPathToChild,
|
||||
setEditMode,
|
||||
width,
|
||||
dashboardId,
|
||||
}: DashboardGridProps) {
|
||||
const theme = useTheme();
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const gridRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
constructor(props: DashboardGridProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
isResizing: false,
|
||||
};
|
||||
this.grid = null;
|
||||
this.handleResizeStart = this.handleResizeStart.bind(this);
|
||||
this.handleResize = this.handleResize.bind(this);
|
||||
this.handleResizeStop = this.handleResizeStop.bind(this);
|
||||
this.handleTopDropTargetDrop = this.handleTopDropTargetDrop.bind(this);
|
||||
this.getRowGuidePosition = this.getRowGuidePosition.bind(this);
|
||||
this.setGridRef = this.setGridRef.bind(this);
|
||||
this.handleChangeTab = this.handleChangeTab.bind(this);
|
||||
}
|
||||
const setGridRef = useCallback((ref: HTMLDivElement | null): void => {
|
||||
gridRef.current = ref;
|
||||
}, []);
|
||||
|
||||
getRowGuidePosition(resizeRef: HTMLElement | null): number | null {
|
||||
if (resizeRef && this.grid) {
|
||||
return (
|
||||
resizeRef.getBoundingClientRect().bottom -
|
||||
this.grid.getBoundingClientRect().top -
|
||||
2
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const handleResizeStart = useCallback((): void => {
|
||||
setIsResizing(true);
|
||||
}, []);
|
||||
|
||||
setGridRef(ref: HTMLDivElement | null): void {
|
||||
this.grid = ref;
|
||||
}
|
||||
const handleResize = useCallback(
|
||||
(
|
||||
_event: MouseEvent | TouchEvent,
|
||||
_direction: string,
|
||||
_elementRef: HTMLElement,
|
||||
_delta: { width: number; height: number },
|
||||
): void => {
|
||||
// no-op: resize position tracking not implemented
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
handleResizeStart(): void {
|
||||
this.setState(() => ({
|
||||
isResizing: true,
|
||||
}));
|
||||
}
|
||||
|
||||
handleResize(
|
||||
_event: MouseEvent | TouchEvent,
|
||||
_direction: string,
|
||||
_elementRef: HTMLElement,
|
||||
_delta: { width: number; height: number },
|
||||
): void {
|
||||
// no-op: resize position is tracked via getRowGuidePosition
|
||||
}
|
||||
|
||||
handleResizeStop(
|
||||
_event: MouseEvent | TouchEvent,
|
||||
_direction: string,
|
||||
_elementRef: HTMLElement,
|
||||
delta: { width: number; height: number },
|
||||
id: string,
|
||||
): void {
|
||||
this.props.resizeComponent({
|
||||
id,
|
||||
width: delta.width,
|
||||
height: delta.height,
|
||||
});
|
||||
|
||||
this.setState(() => ({
|
||||
isResizing: false,
|
||||
}));
|
||||
}
|
||||
|
||||
handleTopDropTargetDrop(dropResult: DropResult): void {
|
||||
if (dropResult?.destination) {
|
||||
this.props.handleComponentDrop({
|
||||
...dropResult,
|
||||
destination: {
|
||||
...dropResult.destination,
|
||||
// force appending as the first child if top drop target
|
||||
index: 0,
|
||||
},
|
||||
const handleResizeStop = useCallback(
|
||||
(
|
||||
_event: MouseEvent | TouchEvent,
|
||||
_direction: string,
|
||||
_elementRef: HTMLElement,
|
||||
delta: { width: number; height: number },
|
||||
id: string,
|
||||
): void => {
|
||||
resizeComponent({
|
||||
id,
|
||||
width: delta.width,
|
||||
height: delta.height,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleChangeTab({ pathToTabIndex }: { pathToTabIndex: string[] }): void {
|
||||
this.props.setDirectPathToChild(pathToTabIndex);
|
||||
}
|
||||
setIsResizing(false);
|
||||
},
|
||||
[resizeComponent],
|
||||
);
|
||||
|
||||
render() {
|
||||
const {
|
||||
gridComponent,
|
||||
handleComponentDrop,
|
||||
depth,
|
||||
width,
|
||||
isComponentVisible,
|
||||
editMode,
|
||||
canEdit,
|
||||
setEditMode,
|
||||
dashboardId,
|
||||
theme,
|
||||
} = this.props;
|
||||
const columnPlusGutterWidth =
|
||||
(width + GRID_GUTTER_SIZE) / GRID_COLUMN_COUNT;
|
||||
const handleTopDropTargetDrop = useCallback(
|
||||
(dropResult: DropResult): void => {
|
||||
if (dropResult?.destination) {
|
||||
handleComponentDrop({
|
||||
...dropResult,
|
||||
destination: {
|
||||
...dropResult.destination,
|
||||
// force appending as the first child if top drop target
|
||||
index: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[handleComponentDrop],
|
||||
);
|
||||
|
||||
const columnWidth = columnPlusGutterWidth - GRID_GUTTER_SIZE;
|
||||
const { isResizing } = this.state;
|
||||
const handleChangeTab = useCallback(
|
||||
({ pathToTabIndex }: { pathToTabIndex: string[] }): void => {
|
||||
setDirectPathToChild(pathToTabIndex);
|
||||
},
|
||||
[setDirectPathToChild],
|
||||
);
|
||||
|
||||
const shouldDisplayEmptyState = gridComponent?.children?.length === 0;
|
||||
const shouldDisplayTopLevelTabEmptyState =
|
||||
shouldDisplayEmptyState && gridComponent?.type === TAB_TYPE;
|
||||
const columnPlusGutterWidth = (width + GRID_GUTTER_SIZE) / GRID_COLUMN_COUNT;
|
||||
|
||||
const dashboardEmptyState = editMode && (
|
||||
<EmptyState
|
||||
title={t('Drag and drop components and charts to the dashboard')}
|
||||
description={t(
|
||||
'You can create a new chart or use existing ones from the panel on the right',
|
||||
)}
|
||||
size="large"
|
||||
buttonText={
|
||||
<>
|
||||
<Icons.PlusOutlined iconSize="m" color={theme.colorPrimary} />
|
||||
{t('Create a new chart')}
|
||||
</>
|
||||
}
|
||||
buttonAction={() => {
|
||||
navigateTo(`/chart/add?dashboard_id=${dashboardId}`, {
|
||||
newWindow: true,
|
||||
});
|
||||
}}
|
||||
image="chart.svg"
|
||||
/>
|
||||
);
|
||||
const columnWidth = columnPlusGutterWidth - GRID_GUTTER_SIZE;
|
||||
|
||||
const topLevelTabEmptyState = editMode ? (
|
||||
<EmptyState
|
||||
title={t('Drag and drop components to this tab')}
|
||||
size="large"
|
||||
description={t(
|
||||
`You can create a new chart or use existing ones from the panel on the right`,
|
||||
)}
|
||||
buttonText={
|
||||
<>
|
||||
<Icons.PlusOutlined iconSize="m" color={theme.colorPrimary} />
|
||||
{t('Create a new chart')}
|
||||
</>
|
||||
}
|
||||
buttonAction={() => {
|
||||
navigateTo(`/chart/add?dashboard_id=${dashboardId}`, {
|
||||
newWindow: true,
|
||||
});
|
||||
}}
|
||||
image="chart.svg"
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
title={t('There are no components added to this tab')}
|
||||
size="large"
|
||||
description={
|
||||
canEdit && t('You can add the components in the edit mode.')
|
||||
}
|
||||
buttonText={canEdit ? t('Edit the dashboard') : undefined}
|
||||
buttonAction={
|
||||
canEdit
|
||||
? () => {
|
||||
setEditMode?.(true);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
image="chart.svg"
|
||||
/>
|
||||
);
|
||||
const shouldDisplayEmptyState = gridComponent?.children?.length === 0;
|
||||
const shouldDisplayTopLevelTabEmptyState =
|
||||
shouldDisplayEmptyState && gridComponent?.type === TAB_TYPE;
|
||||
|
||||
return width < 100 ? null : (
|
||||
<>
|
||||
{shouldDisplayEmptyState && (
|
||||
<DashboardEmptyStateContainer>
|
||||
{shouldDisplayTopLevelTabEmptyState
|
||||
? topLevelTabEmptyState
|
||||
: dashboardEmptyState}
|
||||
</DashboardEmptyStateContainer>
|
||||
)}
|
||||
<div className="dashboard-grid" ref={this.setGridRef}>
|
||||
<GridContent
|
||||
className="grid-content"
|
||||
data-test="grid-content"
|
||||
editMode={editMode}
|
||||
>
|
||||
{/* make the area above components droppable */}
|
||||
{editMode && (
|
||||
<Droppable
|
||||
component={gridComponent}
|
||||
depth={depth}
|
||||
parentComponent={null}
|
||||
index={0}
|
||||
orientation="column"
|
||||
onDrop={this.handleTopDropTargetDrop}
|
||||
className={classNames({
|
||||
'empty-droptarget': true,
|
||||
'empty-droptarget--full':
|
||||
gridComponent?.children?.length === 0,
|
||||
})}
|
||||
editMode
|
||||
dropToChild={gridComponent?.children?.length === 0}
|
||||
>
|
||||
{renderDraggableContent}
|
||||
</Droppable>
|
||||
)}
|
||||
{gridComponent?.children?.map((id, index) => (
|
||||
<Fragment key={id}>
|
||||
<DashboardComponent
|
||||
id={id}
|
||||
parentId={gridComponent.id}
|
||||
depth={depth + 1}
|
||||
index={index}
|
||||
availableColumnCount={GRID_COLUMN_COUNT}
|
||||
columnWidth={columnWidth}
|
||||
isComponentVisible={isComponentVisible}
|
||||
onResizeStart={this.handleResizeStart}
|
||||
onResize={this.handleResize}
|
||||
onResizeStop={this.handleResizeStop}
|
||||
onChangeTab={this.handleChangeTab}
|
||||
const dashboardEmptyState = editMode && (
|
||||
<EmptyState
|
||||
title={t('Drag and drop components and charts to the dashboard')}
|
||||
description={t(
|
||||
'You can create a new chart or use existing ones from the panel on the right',
|
||||
)}
|
||||
size="large"
|
||||
buttonText={
|
||||
<>
|
||||
<Icons.PlusOutlined iconSize="m" color={theme.colorPrimary} />
|
||||
{t('Create a new chart')}
|
||||
</>
|
||||
}
|
||||
buttonAction={() => {
|
||||
navigateTo(`/chart/add?dashboard_id=${dashboardId}`, {
|
||||
newWindow: true,
|
||||
});
|
||||
}}
|
||||
image="chart.svg"
|
||||
/>
|
||||
);
|
||||
|
||||
const topLevelTabEmptyState = editMode ? (
|
||||
<EmptyState
|
||||
title={t('Drag and drop components to this tab')}
|
||||
size="large"
|
||||
description={t(
|
||||
`You can create a new chart or use existing ones from the panel on the right`,
|
||||
)}
|
||||
buttonText={
|
||||
<>
|
||||
<Icons.PlusOutlined iconSize="m" color={theme.colorPrimary} />
|
||||
{t('Create a new chart')}
|
||||
</>
|
||||
}
|
||||
buttonAction={() => {
|
||||
navigateTo(`/chart/add?dashboard_id=${dashboardId}`, {
|
||||
newWindow: true,
|
||||
});
|
||||
}}
|
||||
image="chart.svg"
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
title={t('There are no components added to this tab')}
|
||||
size="large"
|
||||
description={canEdit && t('You can add the components in the edit mode.')}
|
||||
buttonText={canEdit ? t('Edit the dashboard') : undefined}
|
||||
buttonAction={
|
||||
canEdit
|
||||
? () => {
|
||||
setEditMode?.(true);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
image="chart.svg"
|
||||
/>
|
||||
);
|
||||
|
||||
return width < 100 ? null : (
|
||||
<>
|
||||
{shouldDisplayEmptyState && (
|
||||
<DashboardEmptyStateContainer>
|
||||
{shouldDisplayTopLevelTabEmptyState
|
||||
? topLevelTabEmptyState
|
||||
: dashboardEmptyState}
|
||||
</DashboardEmptyStateContainer>
|
||||
)}
|
||||
<div className="dashboard-grid" ref={setGridRef}>
|
||||
<GridContent
|
||||
className="grid-content"
|
||||
data-test="grid-content"
|
||||
editMode={editMode}
|
||||
>
|
||||
{/* make the area above components droppable */}
|
||||
{editMode && (
|
||||
<Droppable
|
||||
component={gridComponent}
|
||||
depth={depth}
|
||||
parentComponent={null}
|
||||
index={0}
|
||||
orientation="column"
|
||||
onDrop={handleTopDropTargetDrop}
|
||||
className={classNames({
|
||||
'empty-droptarget': true,
|
||||
'empty-droptarget--full': gridComponent?.children?.length === 0,
|
||||
})}
|
||||
editMode
|
||||
dropToChild={gridComponent?.children?.length === 0}
|
||||
>
|
||||
{renderDraggableContent}
|
||||
</Droppable>
|
||||
)}
|
||||
{gridComponent?.children?.map((id, index) => (
|
||||
<Fragment key={id}>
|
||||
<DashboardComponent
|
||||
id={id}
|
||||
parentId={gridComponent.id}
|
||||
depth={depth + 1}
|
||||
index={index}
|
||||
availableColumnCount={GRID_COLUMN_COUNT}
|
||||
columnWidth={columnWidth}
|
||||
isComponentVisible={isComponentVisible}
|
||||
onResizeStart={handleResizeStart}
|
||||
onResize={handleResize}
|
||||
onResizeStop={handleResizeStop}
|
||||
onChangeTab={handleChangeTab}
|
||||
/>
|
||||
{/* make the area below components droppable */}
|
||||
{editMode && (
|
||||
<Droppable
|
||||
component={gridComponent}
|
||||
depth={depth}
|
||||
parentComponent={null}
|
||||
index={index + 1}
|
||||
orientation="column"
|
||||
onDrop={handleComponentDrop}
|
||||
className="empty-droptarget"
|
||||
editMode
|
||||
>
|
||||
{renderDraggableContent}
|
||||
</Droppable>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
{isResizing &&
|
||||
Array(GRID_COLUMN_COUNT)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<GridColumnGuide
|
||||
key={`grid-column-${i}`}
|
||||
className="grid-column-guide"
|
||||
style={{
|
||||
left: i * GRID_GUTTER_SIZE + i * columnWidth,
|
||||
width: columnWidth,
|
||||
}}
|
||||
/>
|
||||
{/* make the area below components droppable */}
|
||||
{editMode && (
|
||||
<Droppable
|
||||
component={gridComponent}
|
||||
depth={depth}
|
||||
parentComponent={null}
|
||||
index={index + 1}
|
||||
orientation="column"
|
||||
onDrop={handleComponentDrop}
|
||||
className="empty-droptarget"
|
||||
editMode
|
||||
>
|
||||
{renderDraggableContent}
|
||||
</Droppable>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
{isResizing &&
|
||||
Array(GRID_COLUMN_COUNT)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<GridColumnGuide
|
||||
key={`grid-column-${i}`}
|
||||
className="grid-column-guide"
|
||||
style={{
|
||||
left: i * GRID_GUTTER_SIZE + i * columnWidth,
|
||||
width: columnWidth,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</GridContent>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
))}
|
||||
</GridContent>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withTheme(DashboardGrid);
|
||||
export default DashboardGrid;
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { Component } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { Tooltip, PublishedLabel } from '@superset-ui/core/components';
|
||||
import { HeaderProps, HeaderDropdownProps } from '../Header/types';
|
||||
@@ -43,70 +43,64 @@ const publishedTooltip = t(
|
||||
'This dashboard is published. Click to make it a draft.',
|
||||
);
|
||||
|
||||
export default class PublishedStatus extends Component<DashboardPublishedStatusType> {
|
||||
constructor(props: DashboardPublishedStatusType) {
|
||||
super(props);
|
||||
this.togglePublished = this.togglePublished.bind(this);
|
||||
}
|
||||
export default function PublishedStatus({
|
||||
dashboardId,
|
||||
userCanEdit,
|
||||
userCanSave,
|
||||
isPublished,
|
||||
savePublished,
|
||||
}: DashboardPublishedStatusType) {
|
||||
const togglePublished = useCallback(() => {
|
||||
savePublished(dashboardId, !isPublished);
|
||||
}, [dashboardId, isPublished, savePublished]);
|
||||
|
||||
togglePublished() {
|
||||
this.props.savePublished(this.props.dashboardId, !this.props.isPublished);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isPublished, userCanEdit, userCanSave } = this.props;
|
||||
|
||||
// Show everybody the draft badge
|
||||
if (!isPublished) {
|
||||
// if they can edit the dash, make the badge a button
|
||||
if (userCanEdit && userCanSave) {
|
||||
return (
|
||||
<Tooltip
|
||||
id="unpublished-dashboard-tooltip"
|
||||
placement="bottom"
|
||||
title={draftButtonTooltip}
|
||||
>
|
||||
<div>
|
||||
<PublishedLabel
|
||||
isPublished={isPublished}
|
||||
onClick={this.togglePublished}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
// Show everybody the draft badge
|
||||
if (!isPublished) {
|
||||
// if they can edit the dash, make the badge a button
|
||||
if (userCanEdit && userCanSave) {
|
||||
return (
|
||||
<Tooltip
|
||||
id="unpublished-dashboard-tooltip"
|
||||
placement="bottom"
|
||||
title={draftDivTooltip}
|
||||
>
|
||||
<div>
|
||||
<PublishedLabel isPublished={isPublished} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// Show the published badge for the owner of the dashboard to toggle
|
||||
if (userCanEdit && userCanSave) {
|
||||
return (
|
||||
<Tooltip
|
||||
id="published-dashboard-tooltip"
|
||||
placement="bottom"
|
||||
title={publishedTooltip}
|
||||
title={draftButtonTooltip}
|
||||
>
|
||||
<div>
|
||||
<PublishedLabel
|
||||
isPublished={isPublished}
|
||||
onClick={this.togglePublished}
|
||||
onClick={togglePublished}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// Don't show anything if one doesn't own the dashboard and it is published
|
||||
return null;
|
||||
return (
|
||||
<Tooltip
|
||||
id="unpublished-dashboard-tooltip"
|
||||
placement="bottom"
|
||||
title={draftDivTooltip}
|
||||
>
|
||||
<div>
|
||||
<PublishedLabel isPublished={isPublished} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// Show the published badge for the owner of the dashboard to toggle
|
||||
if (userCanEdit && userCanSave) {
|
||||
return (
|
||||
<Tooltip
|
||||
id="published-dashboard-tooltip"
|
||||
placement="bottom"
|
||||
title={publishedTooltip}
|
||||
>
|
||||
<div>
|
||||
<PublishedLabel isPublished={isPublished} onClick={togglePublished} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// Don't show anything if one doesn't own the dashboard and it is published
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -17,13 +17,13 @@
|
||||
* under the License.
|
||||
*/
|
||||
/* eslint-env browser */
|
||||
import { Component } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { FixedSizeList as List } from 'react-window';
|
||||
// @ts-expect-error
|
||||
import { createFilter } from 'react-search-input';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { styled, css } from '@apache-superset/core/theme';
|
||||
import { styled, css, useTheme } from '@apache-superset/core/theme';
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
@@ -49,7 +49,6 @@ import {
|
||||
import { debounce, pickBy } from 'lodash';
|
||||
import { Dispatch } from 'redux';
|
||||
import { Slice } from 'src/dashboard/types';
|
||||
import { withTheme, Theme } from '@emotion/react';
|
||||
import { navigateTo } from 'src/utils/navigationUtils';
|
||||
import type { ConnectDragSource } from 'react-dnd';
|
||||
import AddSliceCard from './AddSliceCard';
|
||||
@@ -58,7 +57,6 @@ import { DragDroppable } from './dnd/DragDroppable';
|
||||
import { datasetLabelLower } from 'src/features/semanticLayers/label';
|
||||
|
||||
export type SliceAdderProps = {
|
||||
theme: Theme;
|
||||
fetchSlices: (
|
||||
userId?: number,
|
||||
filter_value?: string,
|
||||
@@ -77,14 +75,6 @@ export type SliceAdderProps = {
|
||||
dashboardId: number;
|
||||
};
|
||||
|
||||
type SliceAdderState = {
|
||||
filteredSlices: Slice[];
|
||||
searchTerm: string;
|
||||
sortBy: keyof Slice;
|
||||
selectedSliceIdsSet: Set<number>;
|
||||
showOnlyMyCharts: boolean;
|
||||
};
|
||||
|
||||
const KEYS_TO_FILTERS = ['slice_name', 'viz_type', 'datasource_name'];
|
||||
const KEYS_TO_SORT = {
|
||||
slice_name: t('name'),
|
||||
@@ -174,295 +164,284 @@ function getFilteredSortedSlices(
|
||||
.filter(createFilter(searchTerm, KEYS_TO_FILTERS))
|
||||
.sort(sortByComparator(sortBy));
|
||||
}
|
||||
class SliceAdder extends Component<SliceAdderProps, SliceAdderState> {
|
||||
private slicesRequest?: AbortController | Promise<void>;
|
||||
|
||||
static defaultProps = {
|
||||
selectedSliceIds: [],
|
||||
editMode: false,
|
||||
errorMessage: '',
|
||||
};
|
||||
function SliceAdder({
|
||||
fetchSlices,
|
||||
updateSlices,
|
||||
isLoading,
|
||||
slices,
|
||||
errorMessage = '',
|
||||
userId,
|
||||
selectedSliceIds = [],
|
||||
editMode = false,
|
||||
dashboardId,
|
||||
}: SliceAdderProps) {
|
||||
const theme = useTheme();
|
||||
const slicesRequestRef = useRef<AbortController | Promise<void>>();
|
||||
|
||||
constructor(props: SliceAdderProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
filteredSlices: [],
|
||||
searchTerm: '',
|
||||
sortBy: DEFAULT_SORT_KEY,
|
||||
selectedSliceIdsSet: new Set(props.selectedSliceIds),
|
||||
showOnlyMyCharts: getItem(
|
||||
LocalStorageKeys.DashboardEditorShowOnlyMyCharts,
|
||||
true,
|
||||
),
|
||||
};
|
||||
this.rowRenderer = this.rowRenderer.bind(this);
|
||||
this.searchUpdated = this.searchUpdated.bind(this);
|
||||
this.handleSelect = this.handleSelect.bind(this);
|
||||
this.userIdForFetch = this.userIdForFetch.bind(this);
|
||||
this.onShowOnlyMyCharts = this.onShowOnlyMyCharts.bind(this);
|
||||
}
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortBy, setSortBy] = useState<keyof Slice>(DEFAULT_SORT_KEY);
|
||||
const [selectedSliceIdsSet, setSelectedSliceIdsSet] = useState(
|
||||
() => new Set(selectedSliceIds),
|
||||
);
|
||||
|
||||
userIdForFetch() {
|
||||
return this.state.showOnlyMyCharts ? this.props.userId : undefined;
|
||||
}
|
||||
// Refs to track latest values for cleanup effect
|
||||
const latestSlicesRef = useRef(slices);
|
||||
const latestSelectedSliceIdsSetRef = useRef(selectedSliceIdsSet);
|
||||
const [showOnlyMyCharts, setShowOnlyMyCharts] = useState(() =>
|
||||
getItem(LocalStorageKeys.DashboardEditorShowOnlyMyCharts, true),
|
||||
);
|
||||
|
||||
componentDidMount() {
|
||||
this.slicesRequest = this.props.fetchSlices(
|
||||
this.userIdForFetch(),
|
||||
'',
|
||||
this.state.sortBy,
|
||||
);
|
||||
}
|
||||
// Keep refs updated with latest values
|
||||
useEffect(() => {
|
||||
latestSlicesRef.current = slices;
|
||||
}, [slices]);
|
||||
|
||||
componentDidUpdate(prevProps: SliceAdderProps) {
|
||||
const nextState: SliceAdderState = {} as SliceAdderState;
|
||||
if (this.props.lastUpdated !== prevProps.lastUpdated) {
|
||||
nextState.filteredSlices = getFilteredSortedSlices(
|
||||
this.props.slices,
|
||||
this.state.searchTerm,
|
||||
this.state.sortBy,
|
||||
this.state.showOnlyMyCharts,
|
||||
this.props.userId,
|
||||
);
|
||||
}
|
||||
useEffect(() => {
|
||||
latestSelectedSliceIdsSetRef.current = selectedSliceIdsSet;
|
||||
}, [selectedSliceIdsSet]);
|
||||
|
||||
if (prevProps.selectedSliceIds !== this.props.selectedSliceIds) {
|
||||
nextState.selectedSliceIdsSet = new Set(this.props.selectedSliceIds);
|
||||
}
|
||||
|
||||
if (Object.keys(nextState).length) {
|
||||
this.setState(nextState);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// Clears the redux store keeping only selected items
|
||||
const selectedSlices = pickBy(this.props.slices, (value: Slice) =>
|
||||
this.state.selectedSliceIdsSet.has(value.slice_id),
|
||||
);
|
||||
|
||||
this.props.updateSlices(selectedSlices);
|
||||
if (this.slicesRequest instanceof AbortController) {
|
||||
this.slicesRequest.abort();
|
||||
}
|
||||
}
|
||||
|
||||
handleChange = debounce(value => {
|
||||
this.searchUpdated(value);
|
||||
this.slicesRequest = this.props.fetchSlices(
|
||||
this.userIdForFetch(),
|
||||
value,
|
||||
this.state.sortBy,
|
||||
);
|
||||
}, 300);
|
||||
|
||||
searchUpdated(searchTerm: string) {
|
||||
this.setState(prevState => ({
|
||||
searchTerm,
|
||||
filteredSlices: getFilteredSortedSlices(
|
||||
this.props.slices,
|
||||
const filteredSlices = useMemo(
|
||||
() =>
|
||||
getFilteredSortedSlices(
|
||||
slices,
|
||||
searchTerm,
|
||||
prevState.sortBy,
|
||||
prevState.showOnlyMyCharts,
|
||||
this.props.userId,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
handleSelect(sortBy: keyof Slice) {
|
||||
this.setState(prevState => ({
|
||||
sortBy,
|
||||
filteredSlices: getFilteredSortedSlices(
|
||||
this.props.slices,
|
||||
prevState.searchTerm,
|
||||
sortBy,
|
||||
prevState.showOnlyMyCharts,
|
||||
this.props.userId,
|
||||
),
|
||||
}));
|
||||
this.slicesRequest = this.props.fetchSlices(
|
||||
this.userIdForFetch(),
|
||||
this.state.searchTerm,
|
||||
sortBy,
|
||||
);
|
||||
}
|
||||
|
||||
rowRenderer({ index, style }: { index: number; style: React.CSSProperties }) {
|
||||
const { filteredSlices, selectedSliceIdsSet } = this.state;
|
||||
const cellData = filteredSlices[index];
|
||||
|
||||
const isSelected = selectedSliceIdsSet.has(cellData.slice_id);
|
||||
const type = CHART_TYPE;
|
||||
const id = NEW_CHART_ID;
|
||||
|
||||
const meta = {
|
||||
chartId: cellData.slice_id,
|
||||
sliceName: cellData.slice_name,
|
||||
};
|
||||
return (
|
||||
<DragDroppable
|
||||
key={cellData.slice_id}
|
||||
component={{ type, id, meta }}
|
||||
parentComponent={{
|
||||
id: NEW_COMPONENTS_SOURCE_ID,
|
||||
type: NEW_COMPONENT_SOURCE_TYPE,
|
||||
}}
|
||||
index={index}
|
||||
depth={0}
|
||||
disableDragDrop={isSelected}
|
||||
editMode={this.props.editMode}
|
||||
// we must use a custom drag preview within the List because
|
||||
// it does not seem to work within a fixed-position container
|
||||
useEmptyDragPreview
|
||||
// List library expect style props here
|
||||
// actual style should be applied to nested AddSliceCard component
|
||||
style={{}}
|
||||
>
|
||||
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
|
||||
<AddSliceCard
|
||||
innerRef={dragSourceRef}
|
||||
style={style}
|
||||
sliceName={cellData.slice_name}
|
||||
lastModified={cellData.changed_on_humanized}
|
||||
visType={cellData.viz_type}
|
||||
datasourceUrl={cellData.datasource_url}
|
||||
datasourceName={cellData.datasource_name}
|
||||
thumbnailUrl={cellData.thumbnail_url}
|
||||
isSelected={isSelected}
|
||||
/>
|
||||
)}
|
||||
</DragDroppable>
|
||||
);
|
||||
}
|
||||
|
||||
onShowOnlyMyCharts = (showOnlyMyCharts: boolean) => {
|
||||
if (!showOnlyMyCharts) {
|
||||
this.slicesRequest = this.props.fetchSlices(
|
||||
undefined,
|
||||
this.state.searchTerm,
|
||||
this.state.sortBy,
|
||||
);
|
||||
}
|
||||
this.setState(prevState => ({
|
||||
showOnlyMyCharts,
|
||||
filteredSlices: getFilteredSortedSlices(
|
||||
this.props.slices,
|
||||
prevState.searchTerm,
|
||||
prevState.sortBy,
|
||||
showOnlyMyCharts,
|
||||
this.props.userId,
|
||||
userId,
|
||||
),
|
||||
}));
|
||||
setItem(LocalStorageKeys.DashboardEditorShowOnlyMyCharts, showOnlyMyCharts);
|
||||
};
|
||||
[slices, searchTerm, sortBy, showOnlyMyCharts, userId],
|
||||
);
|
||||
|
||||
render() {
|
||||
const { theme } = this.props;
|
||||
return (
|
||||
<div
|
||||
css={css`
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
button > span > :first-of-type {
|
||||
margin-right: 0;
|
||||
const userIdForFetch = useCallback(
|
||||
() => (showOnlyMyCharts ? userId : undefined),
|
||||
[showOnlyMyCharts, userId],
|
||||
);
|
||||
|
||||
// componentDidMount
|
||||
useEffect(() => {
|
||||
slicesRequestRef.current = fetchSlices(userIdForFetch(), '', sortBy);
|
||||
// Only run on mount
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Update selectedSliceIdsSet when selectedSliceIds prop changes
|
||||
useEffect(() => {
|
||||
setSelectedSliceIdsSet(new Set(selectedSliceIds));
|
||||
}, [selectedSliceIds]);
|
||||
|
||||
// componentWillUnmount
|
||||
useEffect(
|
||||
() => () => {
|
||||
// Clears the redux store keeping only selected items
|
||||
// Use refs to get latest values on unmount
|
||||
const selectedSlices = pickBy(latestSlicesRef.current, (value: Slice) =>
|
||||
latestSelectedSliceIdsSetRef.current.has(value.slice_id),
|
||||
);
|
||||
|
||||
updateSlices(selectedSlices);
|
||||
if (slicesRequestRef.current instanceof AbortController) {
|
||||
slicesRequestRef.current.abort();
|
||||
}
|
||||
},
|
||||
[updateSlices],
|
||||
);
|
||||
|
||||
const searchUpdated = useCallback((term: string) => {
|
||||
setSearchTerm(term);
|
||||
}, []);
|
||||
|
||||
const handleChange = useMemo(
|
||||
() =>
|
||||
debounce((value: string) => {
|
||||
searchUpdated(value);
|
||||
slicesRequestRef.current = fetchSlices(userIdForFetch(), value, sortBy);
|
||||
}, 300),
|
||||
[fetchSlices, searchUpdated, sortBy, userIdForFetch],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
handleChange.cancel();
|
||||
},
|
||||
[handleChange],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(newSortBy: keyof Slice) => {
|
||||
setSortBy(newSortBy);
|
||||
slicesRequestRef.current = fetchSlices(
|
||||
userIdForFetch(),
|
||||
searchTerm,
|
||||
newSortBy,
|
||||
);
|
||||
},
|
||||
[fetchSlices, searchTerm, userIdForFetch],
|
||||
);
|
||||
|
||||
const onShowOnlyMyCharts = useCallback(
|
||||
(checked: boolean) => {
|
||||
if (!checked) {
|
||||
slicesRequestRef.current = fetchSlices(undefined, searchTerm, sortBy);
|
||||
}
|
||||
setShowOnlyMyCharts(checked);
|
||||
setItem(LocalStorageKeys.DashboardEditorShowOnlyMyCharts, checked);
|
||||
},
|
||||
[fetchSlices, searchTerm, sortBy],
|
||||
);
|
||||
|
||||
const rowRenderer = useCallback(
|
||||
({ index, style }: { index: number; style: React.CSSProperties }) => {
|
||||
const cellData = filteredSlices[index];
|
||||
|
||||
const isSelected = selectedSliceIdsSet.has(cellData.slice_id);
|
||||
const type = CHART_TYPE;
|
||||
const id = NEW_CHART_ID;
|
||||
|
||||
const meta = {
|
||||
chartId: cellData.slice_id,
|
||||
sliceName: cellData.slice_name,
|
||||
};
|
||||
return (
|
||||
<DragDroppable
|
||||
key={cellData.slice_id}
|
||||
component={{ type, id, meta }}
|
||||
parentComponent={{
|
||||
id: NEW_COMPONENTS_SOURCE_ID,
|
||||
type: NEW_COMPONENT_SOURCE_TYPE,
|
||||
}}
|
||||
index={index}
|
||||
depth={0}
|
||||
disableDragDrop={isSelected}
|
||||
editMode={editMode}
|
||||
// we must use a custom drag preview within the List because
|
||||
// it does not seem to work within a fixed-position container
|
||||
useEmptyDragPreview
|
||||
// List library expect style props here
|
||||
// actual style should be applied to nested AddSliceCard component
|
||||
style={{}}
|
||||
>
|
||||
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
|
||||
<AddSliceCard
|
||||
innerRef={dragSourceRef}
|
||||
style={style}
|
||||
sliceName={cellData.slice_name}
|
||||
lastModified={cellData.changed_on_humanized}
|
||||
visType={cellData.viz_type}
|
||||
datasourceUrl={cellData.datasource_url}
|
||||
datasourceName={cellData.datasource_name}
|
||||
thumbnailUrl={cellData.thumbnail_url}
|
||||
isSelected={isSelected}
|
||||
/>
|
||||
)}
|
||||
</DragDroppable>
|
||||
);
|
||||
},
|
||||
[filteredSlices, selectedSliceIdsSet, editMode],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
css={css`
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
button > span > :first-of-type {
|
||||
margin-right: 0;
|
||||
}
|
||||
`}
|
||||
>
|
||||
<NewChartButtonContainer>
|
||||
<NewChartButton
|
||||
buttonStyle="link"
|
||||
buttonSize="xsmall"
|
||||
icon={
|
||||
<Icons.PlusOutlined iconSize="m" iconColor={theme.colorPrimary} />
|
||||
}
|
||||
onClick={() =>
|
||||
navigateTo(`/chart/add?dashboard_id=${dashboardId}`, {
|
||||
newWindow: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('Create new chart')}
|
||||
</NewChartButton>
|
||||
</NewChartButtonContainer>
|
||||
<Controls>
|
||||
<Input
|
||||
placeholder={
|
||||
showOnlyMyCharts ? t('Filter your charts') : t('Filter charts')
|
||||
}
|
||||
className="search-input"
|
||||
onChange={ev => handleChange(ev.target.value)}
|
||||
data-test="dashboard-charts-filter-search-input"
|
||||
/>
|
||||
<StyledSelect
|
||||
id="slice-adder-sortby"
|
||||
value={sortBy}
|
||||
onChange={handleSelect}
|
||||
options={Object.entries(KEYS_TO_SORT).map(([key, label]) => ({
|
||||
label: t('Sort by %s', label),
|
||||
value: key,
|
||||
}))}
|
||||
placeholder={t('Sort by')}
|
||||
/>
|
||||
</Controls>
|
||||
<div
|
||||
css={themeObj => css`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: ${themeObj.sizeUnit}px;
|
||||
padding: 0 ${themeObj.sizeUnit * 3}px ${themeObj.sizeUnit * 4}px
|
||||
${themeObj.sizeUnit * 3}px;
|
||||
`}
|
||||
>
|
||||
<NewChartButtonContainer>
|
||||
<NewChartButton
|
||||
buttonStyle="link"
|
||||
buttonSize="xsmall"
|
||||
icon={
|
||||
<Icons.PlusOutlined iconSize="m" iconColor={theme.colorPrimary} />
|
||||
}
|
||||
onClick={() =>
|
||||
navigateTo(`/chart/add?dashboard_id=${this.props.dashboardId}`, {
|
||||
newWindow: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('Create new chart')}
|
||||
</NewChartButton>
|
||||
</NewChartButtonContainer>
|
||||
<Controls>
|
||||
<Input
|
||||
placeholder={
|
||||
this.state.showOnlyMyCharts
|
||||
? t('Filter your charts')
|
||||
: t('Filter charts')
|
||||
}
|
||||
className="search-input"
|
||||
onChange={ev => this.handleChange(ev.target.value)}
|
||||
data-test="dashboard-charts-filter-search-input"
|
||||
/>
|
||||
<StyledSelect
|
||||
id="slice-adder-sortby"
|
||||
value={this.state.sortBy}
|
||||
onChange={this.handleSelect}
|
||||
options={Object.entries(KEYS_TO_SORT).map(([key, label]) => ({
|
||||
label: t('Sort by %s', label),
|
||||
value: key,
|
||||
}))}
|
||||
placeholder={t('Sort by')}
|
||||
/>
|
||||
</Controls>
|
||||
<Checkbox
|
||||
onChange={e => onShowOnlyMyCharts(e.target.checked)}
|
||||
checked={showOnlyMyCharts}
|
||||
/>
|
||||
{t('Show only my charts')}
|
||||
<InfoTooltip
|
||||
placement="top"
|
||||
tooltip={t(
|
||||
`You can choose to display all charts that you have access to or only the ones you own.
|
||||
Your filter selection will be saved and remain active until you choose to change it.`,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{isLoading && <Loading />}
|
||||
{!isLoading && filteredSlices.length > 0 && (
|
||||
<ChartList>
|
||||
<AutoSizer>
|
||||
{({ height, width }: { height: number; width: number }) => (
|
||||
<List
|
||||
width={width}
|
||||
height={height}
|
||||
itemCount={filteredSlices.length}
|
||||
itemSize={DEFAULT_CELL_HEIGHT}
|
||||
itemKey={index => filteredSlices[index].slice_id}
|
||||
>
|
||||
{rowRenderer}
|
||||
</List>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</ChartList>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<div
|
||||
css={theme => css`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: ${theme.sizeUnit}px;
|
||||
padding: 0 ${theme.sizeUnit * 3}px ${theme.sizeUnit * 4}px
|
||||
${theme.sizeUnit * 3}px;
|
||||
css={css`
|
||||
padding: 16px;
|
||||
`}
|
||||
>
|
||||
<Checkbox
|
||||
onChange={e => this.onShowOnlyMyCharts(e.target.checked)}
|
||||
checked={this.state.showOnlyMyCharts}
|
||||
/>
|
||||
{t('Show only my charts')}
|
||||
<InfoTooltip
|
||||
placement="top"
|
||||
tooltip={t(
|
||||
`You can choose to display all charts that you have access to or only the ones you own.
|
||||
Your filter selection will be saved and remain active until you choose to change it.`,
|
||||
)}
|
||||
/>
|
||||
{errorMessage}
|
||||
</div>
|
||||
{this.props.isLoading && <Loading />}
|
||||
{!this.props.isLoading && this.state.filteredSlices.length > 0 && (
|
||||
<ChartList>
|
||||
<AutoSizer>
|
||||
{({ height, width }: { height: number; width: number }) => (
|
||||
<List
|
||||
width={width}
|
||||
height={height}
|
||||
itemCount={this.state.filteredSlices.length}
|
||||
itemSize={DEFAULT_CELL_HEIGHT}
|
||||
itemKey={index => this.state.filteredSlices[index].slice_id}
|
||||
>
|
||||
{this.rowRenderer}
|
||||
</List>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</ChartList>
|
||||
)}
|
||||
{this.props.errorMessage && (
|
||||
<div
|
||||
css={css`
|
||||
padding: 16px;
|
||||
`}
|
||||
>
|
||||
{this.props.errorMessage}
|
||||
</div>
|
||||
)}
|
||||
{/* Drag preview is just a single fixed-position element */}
|
||||
<AddSliceDragPreview slices={this.state.filteredSlices} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)}
|
||||
{/* Drag preview is just a single fixed-position element */}
|
||||
<AddSliceDragPreview slices={filteredSlices} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default withTheme(SliceAdder);
|
||||
export default SliceAdder;
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { PureComponent } from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { HeaderProps } from '../Header/types';
|
||||
|
||||
type UndoRedoKeyListenersProps = {
|
||||
@@ -24,43 +24,39 @@ type UndoRedoKeyListenersProps = {
|
||||
onRedo: HeaderProps['onRedo'];
|
||||
};
|
||||
|
||||
class UndoRedoKeyListeners extends PureComponent<UndoRedoKeyListenersProps> {
|
||||
constructor(props: UndoRedoKeyListenersProps) {
|
||||
super(props);
|
||||
this.handleKeydown = this.handleKeydown.bind(this);
|
||||
}
|
||||
function UndoRedoKeyListeners({ onUndo, onRedo }: UndoRedoKeyListenersProps) {
|
||||
const handleKeydown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
const controlOrCommand = event.ctrlKey || event.metaKey;
|
||||
if (controlOrCommand) {
|
||||
const key = event.key.toLowerCase();
|
||||
const isUndo = key === 'z' && !event.shiftKey;
|
||||
const isRedo = key === 'y' || (key === 'z' && event.shiftKey);
|
||||
const isEditingMarkdown = document?.querySelector(
|
||||
'.dashboard-markdown--editing',
|
||||
);
|
||||
const isEditingTitle = document?.querySelector(
|
||||
'.editable-title--editing',
|
||||
);
|
||||
|
||||
componentDidMount() {
|
||||
document.addEventListener('keydown', this.handleKeydown);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('keydown', this.handleKeydown);
|
||||
}
|
||||
|
||||
handleKeydown(event: KeyboardEvent) {
|
||||
const controlOrCommand = event.ctrlKey || event.metaKey;
|
||||
if (controlOrCommand) {
|
||||
const isZChar = event.key === 'z' || event.keyCode === 90;
|
||||
const isYChar = event.key === 'y' || event.keyCode === 89;
|
||||
const isEditingMarkdown = document?.querySelector(
|
||||
'.dashboard-markdown--editing',
|
||||
);
|
||||
const isEditingTitle = document?.querySelector(
|
||||
'.editable-title--editing',
|
||||
);
|
||||
|
||||
if (!isEditingMarkdown && !isEditingTitle && (isZChar || isYChar)) {
|
||||
event.preventDefault();
|
||||
const func = isZChar ? this.props.onUndo : this.props.onRedo;
|
||||
func();
|
||||
if (!isEditingMarkdown && !isEditingTitle && (isUndo || isRedo)) {
|
||||
event.preventDefault();
|
||||
const func = isUndo ? onUndo : onRedo;
|
||||
func();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[onUndo, onRedo],
|
||||
);
|
||||
|
||||
render() {
|
||||
return null;
|
||||
}
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeydown);
|
||||
};
|
||||
}, [handleKeydown]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default UndoRedoKeyListeners;
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
} from 'react-dnd';
|
||||
import cx from 'classnames';
|
||||
import { css, styled } from '@apache-superset/core/theme';
|
||||
|
||||
import { dragConfig, dropConfig } from './dragDroppableConfig';
|
||||
import type { DragDroppableProps as BaseDragDroppableProps } from './dragDroppableConfig';
|
||||
import { DROP_FORBIDDEN } from '../../util/getDropPosition';
|
||||
@@ -122,15 +121,22 @@ const DragDroppableStyles = styled.div`
|
||||
}
|
||||
`};
|
||||
`;
|
||||
|
||||
/**
|
||||
* Note: This component remains a class component because it is tightly integrated
|
||||
* with react-dnd's class-based HOC system (DragSource/DropTarget). The HOCs
|
||||
* access component instance properties directly (mounted, ref, props, setState)
|
||||
* in the hover/drop callbacks defined in dragDroppableConfig.ts.
|
||||
*
|
||||
* Converting to a function component would require migrating to react-dnd's
|
||||
* hooks API (useDrag/useDrop), which would be a more extensive refactor.
|
||||
*/
|
||||
// export unwrapped component for testing
|
||||
// eslint-disable-next-line react-prefer-function-component/react-prefer-function-component -- react-dnd class-based HOC requires class component instance properties
|
||||
export class UnwrappedDragDroppable extends PureComponent<
|
||||
DragDroppableAllProps,
|
||||
DragDroppableState
|
||||
> {
|
||||
mounted: boolean;
|
||||
|
||||
ref: HTMLDivElement | null;
|
||||
|
||||
static defaultProps = {
|
||||
className: null,
|
||||
style: null,
|
||||
@@ -152,6 +158,10 @@ export class UnwrappedDragDroppable extends PureComponent<
|
||||
dragPreviewRef() {},
|
||||
};
|
||||
|
||||
mounted: boolean;
|
||||
|
||||
ref: HTMLDivElement | null;
|
||||
|
||||
constructor(props: DragDroppableAllProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
@@ -283,7 +293,6 @@ export class UnwrappedDragDroppable extends PureComponent<
|
||||
|
||||
// react-dnd's DragSource/DropTarget HOC types don't play well with
|
||||
// class components using spread config tuples, so we use type assertions here
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const DragDroppableAsAny =
|
||||
UnwrappedDragDroppable as unknown as ReactComponentType<
|
||||
Record<string, unknown>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { createRef, PureComponent } from 'react';
|
||||
import { useRef, useCallback } from 'react';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import {
|
||||
ModalTrigger,
|
||||
@@ -33,39 +33,29 @@ const FilterScopeModalBody = styled.div(({ theme: { sizeUnit } }) => ({
|
||||
paddingBottom: sizeUnit * 3,
|
||||
}));
|
||||
|
||||
export default class FilterScopeModal extends PureComponent<
|
||||
FilterScopeModalProps,
|
||||
{}
|
||||
> {
|
||||
modal: ModalTriggerRef;
|
||||
export default function FilterScopeModal({
|
||||
triggerNode,
|
||||
}: FilterScopeModalProps) {
|
||||
const modalRef = useRef<ModalTriggerRef['current']>(null);
|
||||
|
||||
constructor(props: FilterScopeModalProps) {
|
||||
super(props);
|
||||
const handleCloseModal = useCallback((): void => {
|
||||
modalRef.current?.close?.();
|
||||
}, []);
|
||||
|
||||
this.modal = createRef() as ModalTriggerRef;
|
||||
this.handleCloseModal = this.handleCloseModal.bind(this);
|
||||
}
|
||||
const filterScopeProps = {
|
||||
onCloseModal: handleCloseModal,
|
||||
};
|
||||
|
||||
handleCloseModal(): void {
|
||||
this?.modal?.current?.close?.();
|
||||
}
|
||||
|
||||
render() {
|
||||
const filterScopeProps = {
|
||||
onCloseModal: this.handleCloseModal,
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalTrigger
|
||||
ref={this.modal}
|
||||
triggerNode={this.props.triggerNode}
|
||||
modalBody={
|
||||
<FilterScopeModalBody>
|
||||
<FilterScope {...filterScopeProps} />
|
||||
</FilterScopeModalBody>
|
||||
}
|
||||
width="80%"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ModalTrigger
|
||||
ref={modalRef}
|
||||
triggerNode={triggerNode}
|
||||
modalBody={
|
||||
<FilterScopeModalBody>
|
||||
<FilterScope {...filterScopeProps} />
|
||||
</FilterScopeModalBody>
|
||||
}
|
||||
width="80%"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* 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 {
|
||||
cleanup,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import FilterScopeSelector from './FilterScopeSelector';
|
||||
import type { DashboardLayout } from 'src/dashboard/types';
|
||||
|
||||
// --- Mock child components ---
|
||||
|
||||
jest.mock('./FilterFieldTree', () => ({
|
||||
__esModule: true,
|
||||
default: (props: Record<string, unknown>) => (
|
||||
<div data-test="filter-field-tree">
|
||||
FilterFieldTree (checked={String(props.checked)})
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('./FilterScopeTree', () => ({
|
||||
__esModule: true,
|
||||
default: (props: Record<string, unknown>) => (
|
||||
<div data-test="filter-scope-tree">
|
||||
FilterScopeTree (checked={String(props.checked)})
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// --- Mock utility functions ---
|
||||
|
||||
jest.mock('src/dashboard/util/getFilterFieldNodesTree', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => [
|
||||
{
|
||||
value: 'ALL_FILTERS_ROOT',
|
||||
label: 'All filters',
|
||||
children: [
|
||||
{
|
||||
value: 1,
|
||||
label: 'Filter A',
|
||||
children: [
|
||||
{ value: '1_column_b', label: 'Filter B' },
|
||||
{ value: '1_column_c', label: 'Filter C' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
}));
|
||||
|
||||
jest.mock('src/dashboard/util/getFilterScopeNodesTree', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => [
|
||||
{
|
||||
value: 'ROOT_ID',
|
||||
label: 'All charts',
|
||||
children: [{ value: 2, label: 'Chart A' }],
|
||||
},
|
||||
]),
|
||||
}));
|
||||
|
||||
jest.mock('src/dashboard/util/getFilterScopeParentNodes', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => ['ROOT_ID']),
|
||||
}));
|
||||
|
||||
jest.mock('src/dashboard/util/buildFilterScopeTreeEntry', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => ({})),
|
||||
}));
|
||||
|
||||
jest.mock('src/dashboard/util/getKeyForFilterScopeTree', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => '1_column_b'),
|
||||
}));
|
||||
|
||||
jest.mock('src/dashboard/util/getSelectedChartIdForFilterScopeTree', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => 1),
|
||||
}));
|
||||
|
||||
jest.mock('src/dashboard/util/getFilterScopeFromNodesTree', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => ({ scope: ['ROOT_ID'], immune: [] })),
|
||||
}));
|
||||
|
||||
jest.mock('src/dashboard/util/getRevertedFilterScope', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => ({})),
|
||||
}));
|
||||
|
||||
jest.mock('src/dashboard/util/activeDashboardFilters', () => ({
|
||||
getChartIdsInFilterScope: jest.fn(() => [2, 3]),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const mockDashboardFilters = {
|
||||
1: {
|
||||
chartId: 1,
|
||||
componentId: 'component-1',
|
||||
filterName: 'Filter A',
|
||||
datasourceId: 'ds-1',
|
||||
directPathToFilter: ['ROOT_ID', 'GRID', 'CHART_1'],
|
||||
isDateFilter: false,
|
||||
isInstantFilter: false,
|
||||
columns: { column_b: undefined, column_c: undefined },
|
||||
labels: { column_b: 'Filter B', column_c: 'Filter C' },
|
||||
scopes: {
|
||||
column_b: { immune: [], scope: ['ROOT_ID'] },
|
||||
column_c: { immune: [], scope: ['ROOT_ID'] },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockLayout: DashboardLayout = {
|
||||
ROOT_ID: { children: ['GRID'], id: 'ROOT_ID', type: 'ROOT' },
|
||||
GRID: {
|
||||
children: ['CHART_1', 'CHART_2'],
|
||||
id: 'GRID',
|
||||
type: 'GRID',
|
||||
parents: ['ROOT_ID'],
|
||||
},
|
||||
CHART_1: {
|
||||
meta: { chartId: 1, sliceName: 'Chart 1' },
|
||||
children: [],
|
||||
id: 'CHART_1',
|
||||
type: 'CHART',
|
||||
parents: ['ROOT_ID', 'GRID'],
|
||||
},
|
||||
CHART_2: {
|
||||
meta: { chartId: 2, sliceName: 'Chart 2' },
|
||||
children: [],
|
||||
id: 'CHART_2',
|
||||
type: 'CHART',
|
||||
parents: ['ROOT_ID', 'GRID'],
|
||||
},
|
||||
} as unknown as DashboardLayout;
|
||||
|
||||
const defaultProps = {
|
||||
dashboardFilters: mockDashboardFilters,
|
||||
layout: mockLayout,
|
||||
updateDashboardFiltersScope: jest.fn(),
|
||||
setUnsavedChanges: jest.fn(),
|
||||
onCloseModal: jest.fn(),
|
||||
};
|
||||
|
||||
test('renders the header, filter field panel, and scope panel', () => {
|
||||
render(<FilterScopeSelector {...defaultProps} />, { useRedux: true });
|
||||
|
||||
expect(screen.getByText('Configure filter scopes')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('filter-field-tree')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('filter-scope-tree')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders the search input with correct placeholder', () => {
|
||||
render(<FilterScopeSelector {...defaultProps} />, { useRedux: true });
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search...');
|
||||
expect(searchInput).toBeInTheDocument();
|
||||
expect(searchInput).toHaveAttribute('type', 'text');
|
||||
});
|
||||
|
||||
test('renders Close and Save buttons when filters exist', () => {
|
||||
render(<FilterScopeSelector {...defaultProps} />, { useRedux: true });
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders only Close button and a warning when no filters exist', () => {
|
||||
render(<FilterScopeSelector {...defaultProps} dashboardFilters={{}} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText('There are no filters in this dashboard.'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Save' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not render FilterFieldTree or FilterScopeTree when no filters exist', () => {
|
||||
render(<FilterScopeSelector {...defaultProps} dashboardFilters={{}} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('filter-field-tree')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('filter-scope-tree')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('calls onCloseModal when Close button is clicked', () => {
|
||||
const onCloseModal = jest.fn();
|
||||
render(
|
||||
<FilterScopeSelector {...defaultProps} onCloseModal={onCloseModal} />,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
userEvent.click(screen.getByRole('button', { name: 'Close' }));
|
||||
|
||||
expect(onCloseModal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('calls updateDashboardFiltersScope, setUnsavedChanges, and onCloseModal when Save is clicked', () => {
|
||||
const updateDashboardFiltersScope = jest.fn();
|
||||
const setUnsavedChanges = jest.fn();
|
||||
const onCloseModal = jest.fn();
|
||||
|
||||
render(
|
||||
<FilterScopeSelector
|
||||
{...defaultProps}
|
||||
updateDashboardFiltersScope={updateDashboardFiltersScope}
|
||||
setUnsavedChanges={setUnsavedChanges}
|
||||
onCloseModal={onCloseModal}
|
||||
/>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
expect(updateDashboardFiltersScope).toHaveBeenCalledTimes(1);
|
||||
expect(setUnsavedChanges).toHaveBeenCalledWith(true);
|
||||
expect(onCloseModal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('renders the editing filters name section with "Editing 1 filter:" label', () => {
|
||||
render(<FilterScopeSelector {...defaultProps} />, { useRedux: true });
|
||||
|
||||
expect(screen.getByText('Editing 1 filter:')).toBeInTheDocument();
|
||||
// The active filter label should appear (column_b maps to "Filter B")
|
||||
expect(screen.getByText('Filter B')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('updates search text when typing in the search input', () => {
|
||||
render(<FilterScopeSelector {...defaultProps} />, { useRedux: true });
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search...');
|
||||
userEvent.type(searchInput, 'Chart');
|
||||
|
||||
expect(searchInput).toHaveValue('Chart');
|
||||
});
|
||||
@@ -16,12 +16,17 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { PureComponent, ChangeEvent, type ReactElement } from 'react';
|
||||
import {
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
ChangeEvent,
|
||||
type ReactElement,
|
||||
} from 'react';
|
||||
import cx from 'classnames';
|
||||
import { Button, Input } from '@superset-ui/core/components';
|
||||
import { css, styled } from '@apache-superset/core/theme';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
|
||||
import { css, styled } from '@apache-superset/core/theme';
|
||||
import buildFilterScopeTreeEntry from 'src/dashboard/util/buildFilterScopeTreeEntry';
|
||||
import getFilterScopeNodesTree from 'src/dashboard/util/getFilterScopeNodesTree';
|
||||
import getFilterFieldNodesTree from 'src/dashboard/util/getFilterFieldNodesTree';
|
||||
@@ -90,30 +95,6 @@ export interface FilterScopeSelectorProps {
|
||||
onCloseModal: () => void;
|
||||
}
|
||||
|
||||
interface FilterScopeSelectorStateWithSelector {
|
||||
showSelector: true;
|
||||
activeFilterField: string | null;
|
||||
searchText: string;
|
||||
filterScopeMap: FilterScopeMap;
|
||||
filterFieldNodes: FilterFieldNode[];
|
||||
checkedFilterFields: string[];
|
||||
expandedFilterIds: (string | number)[];
|
||||
}
|
||||
|
||||
interface FilterScopeSelectorStateWithoutSelector {
|
||||
showSelector: false;
|
||||
activeFilterField?: undefined;
|
||||
searchText?: undefined;
|
||||
filterScopeMap?: undefined;
|
||||
filterFieldNodes?: undefined;
|
||||
checkedFilterFields?: undefined;
|
||||
expandedFilterIds?: undefined;
|
||||
}
|
||||
|
||||
type FilterScopeSelectorState =
|
||||
| FilterScopeSelectorStateWithSelector
|
||||
| FilterScopeSelectorStateWithoutSelector;
|
||||
|
||||
const ScopeContainer = styled.div`
|
||||
${({ theme }) => css`
|
||||
display: flex;
|
||||
@@ -389,271 +370,358 @@ const ActionsContainer = styled.div`
|
||||
`}
|
||||
`;
|
||||
|
||||
export default class FilterScopeSelector extends PureComponent<
|
||||
FilterScopeSelectorProps,
|
||||
FilterScopeSelectorState
|
||||
> {
|
||||
allfilterFields: string[];
|
||||
function initializeState(
|
||||
dashboardFilters: Record<number, DashboardFilter>,
|
||||
layout: DashboardLayout,
|
||||
) {
|
||||
if (Object.keys(dashboardFilters).length === 0) {
|
||||
return {
|
||||
showSelector: false as const,
|
||||
allFilterFields: [] as string[],
|
||||
defaultFilterKey: '',
|
||||
};
|
||||
}
|
||||
|
||||
defaultFilterKey: string;
|
||||
// display filter fields in tree structure
|
||||
const filterFieldNodes = getFilterFieldNodesTree({
|
||||
dashboardFilters,
|
||||
});
|
||||
// filterFieldNodes root node is dashboard_root component,
|
||||
// so that we can offer a select/deselect all link
|
||||
const filtersNodes = filterFieldNodes[0].children ?? [];
|
||||
const allFilterFields: string[] = [];
|
||||
filtersNodes.forEach(({ children }) => {
|
||||
(children ?? []).forEach(child => {
|
||||
allFilterFields.push(String(child.value));
|
||||
});
|
||||
});
|
||||
const defaultFilterKey = String(filtersNodes[0]?.children?.[0]?.value ?? '');
|
||||
|
||||
constructor(props: FilterScopeSelectorProps) {
|
||||
super(props);
|
||||
|
||||
this.allfilterFields = [];
|
||||
this.defaultFilterKey = '';
|
||||
|
||||
const { dashboardFilters, layout } = props;
|
||||
|
||||
if (Object.keys(dashboardFilters).length > 0) {
|
||||
// display filter fields in tree structure
|
||||
const filterFieldNodes = getFilterFieldNodesTree({
|
||||
dashboardFilters,
|
||||
});
|
||||
// filterFieldNodes root node is dashboard_root component,
|
||||
// so that we can offer a select/deselect all link
|
||||
const filtersNodes = filterFieldNodes[0].children ?? [];
|
||||
this.allfilterFields = [];
|
||||
filtersNodes.forEach(({ children }) => {
|
||||
(children ?? []).forEach(child => {
|
||||
this.allfilterFields.push(String(child.value));
|
||||
// build FilterScopeTree object for each filterKey
|
||||
const filterScopeMap: FilterScopeMap = Object.values(
|
||||
dashboardFilters,
|
||||
).reduce<FilterScopeMap>((map, { chartId: filterId, columns }) => {
|
||||
const filterScopeByChartId = Object.keys(columns).reduce<FilterScopeMap>(
|
||||
(mapByChartId, columnName) => {
|
||||
const filterKey = getDashboardFilterKey({
|
||||
chartId: String(filterId),
|
||||
column: columnName,
|
||||
});
|
||||
});
|
||||
this.defaultFilterKey = String(
|
||||
filtersNodes[0]?.children?.[0]?.value ?? '',
|
||||
);
|
||||
|
||||
// build FilterScopeTree object for each filterKey
|
||||
const filterScopeMap: FilterScopeMap = Object.values(
|
||||
dashboardFilters,
|
||||
).reduce<FilterScopeMap>((map, { chartId: filterId, columns }) => {
|
||||
const filterScopeByChartId = Object.keys(
|
||||
columns,
|
||||
).reduce<FilterScopeMap>((mapByChartId, columnName) => {
|
||||
const filterKey = getDashboardFilterKey({
|
||||
chartId: String(filterId),
|
||||
column: columnName,
|
||||
});
|
||||
const nodes = getFilterScopeNodesTree({
|
||||
components: layout,
|
||||
filterFields: [filterKey],
|
||||
selectedChartId: filterId,
|
||||
});
|
||||
const expanded = getFilterScopeParentNodes(nodes, 1);
|
||||
const chartIdsInFilterScope = (
|
||||
getChartIdsInFilterScope({
|
||||
filterScope: dashboardFilters[filterId].scopes[columnName],
|
||||
}) || []
|
||||
).filter((id: number) => id !== filterId);
|
||||
|
||||
return {
|
||||
...mapByChartId,
|
||||
[filterKey]: {
|
||||
// unfiltered nodes
|
||||
nodes,
|
||||
// filtered nodes in display if searchText is not empty
|
||||
nodesFiltered: [...nodes],
|
||||
checked: chartIdsInFilterScope,
|
||||
expanded,
|
||||
},
|
||||
};
|
||||
}, {});
|
||||
const nodes = getFilterScopeNodesTree({
|
||||
components: layout,
|
||||
filterFields: [filterKey],
|
||||
selectedChartId: filterId,
|
||||
});
|
||||
const expanded = getFilterScopeParentNodes(nodes, 1);
|
||||
const chartIdsInFilterScope = (
|
||||
getChartIdsInFilterScope({
|
||||
filterScope: dashboardFilters[filterId].scopes[columnName],
|
||||
}) || []
|
||||
).filter((id: number) => id !== filterId);
|
||||
|
||||
return {
|
||||
...map,
|
||||
...filterScopeByChartId,
|
||||
...mapByChartId,
|
||||
[filterKey]: {
|
||||
// unfiltered nodes
|
||||
nodes,
|
||||
// filtered nodes in display if searchText is not empty
|
||||
nodesFiltered: [...nodes],
|
||||
checked: chartIdsInFilterScope,
|
||||
expanded,
|
||||
},
|
||||
};
|
||||
}, {});
|
||||
|
||||
// initial state: active defaultFilerKey
|
||||
const { chartId } = getChartIdAndColumnFromFilterKey(
|
||||
this.defaultFilterKey,
|
||||
);
|
||||
const checkedFilterFields: string[] = [];
|
||||
const activeFilterField = this.defaultFilterKey;
|
||||
// expand defaultFilterKey in filter field tree
|
||||
const expandedFilterIds: (string | number)[] = [
|
||||
ALL_FILTERS_ROOT,
|
||||
chartId,
|
||||
];
|
||||
|
||||
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
|
||||
checkedFilterFields,
|
||||
activeFilterField,
|
||||
filterScopeMap,
|
||||
layout,
|
||||
});
|
||||
this.state = {
|
||||
showSelector: true,
|
||||
activeFilterField,
|
||||
searchText: '',
|
||||
filterScopeMap: {
|
||||
...filterScopeMap,
|
||||
...filterScopeTreeEntry,
|
||||
} as FilterScopeMap,
|
||||
filterFieldNodes,
|
||||
checkedFilterFields,
|
||||
expandedFilterIds,
|
||||
};
|
||||
} else {
|
||||
this.state = {
|
||||
showSelector: false,
|
||||
};
|
||||
}
|
||||
|
||||
this.filterNodes = this.filterNodes.bind(this);
|
||||
this.onChangeFilterField = this.onChangeFilterField.bind(this);
|
||||
this.onCheckFilterScope = this.onCheckFilterScope.bind(this);
|
||||
this.onExpandFilterScope = this.onExpandFilterScope.bind(this);
|
||||
this.onSearchInputChange = this.onSearchInputChange.bind(this);
|
||||
this.onCheckFilterField = this.onCheckFilterField.bind(this);
|
||||
this.onExpandFilterField = this.onExpandFilterField.bind(this);
|
||||
this.onClose = this.onClose.bind(this);
|
||||
this.onSave = this.onSave.bind(this);
|
||||
}
|
||||
|
||||
onCheckFilterScope(checked: (string | number)[] = []): void {
|
||||
const state = this.state as FilterScopeSelectorStateWithSelector;
|
||||
const { activeFilterField, filterScopeMap, checkedFilterFields } = state;
|
||||
|
||||
const key = getKeyForFilterScopeTree({
|
||||
activeFilterField: activeFilterField ?? undefined,
|
||||
checkedFilterFields,
|
||||
});
|
||||
const editingList = activeFilterField
|
||||
? [activeFilterField]
|
||||
: checkedFilterFields;
|
||||
const updatedEntry = {
|
||||
...filterScopeMap[key],
|
||||
checked,
|
||||
};
|
||||
|
||||
const updatedFilterScopeMap = getRevertedFilterScope({
|
||||
checked,
|
||||
filterFields: editingList,
|
||||
filterScopeMap,
|
||||
});
|
||||
|
||||
this.setState(() => ({
|
||||
filterScopeMap: {
|
||||
...filterScopeMap,
|
||||
...updatedFilterScopeMap,
|
||||
[key]: updatedEntry,
|
||||
} as FilterScopeMap,
|
||||
}));
|
||||
}
|
||||
|
||||
onExpandFilterScope(expanded: string[] = []): void {
|
||||
const state = this.state as FilterScopeSelectorStateWithSelector;
|
||||
const { activeFilterField, checkedFilterFields, filterScopeMap } = state;
|
||||
const key = getKeyForFilterScopeTree({
|
||||
activeFilterField: activeFilterField ?? undefined,
|
||||
checkedFilterFields,
|
||||
});
|
||||
const updatedEntry = {
|
||||
...filterScopeMap[key],
|
||||
expanded,
|
||||
};
|
||||
this.setState(() => ({
|
||||
filterScopeMap: {
|
||||
...filterScopeMap,
|
||||
[key]: updatedEntry,
|
||||
},
|
||||
}));
|
||||
}
|
||||
{},
|
||||
);
|
||||
|
||||
onCheckFilterField(checkedFilterFields: string[] = []): void {
|
||||
const { layout } = this.props;
|
||||
const state = this.state as FilterScopeSelectorStateWithSelector;
|
||||
const { filterScopeMap } = state;
|
||||
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
|
||||
checkedFilterFields,
|
||||
activeFilterField: undefined,
|
||||
filterScopeMap,
|
||||
layout,
|
||||
});
|
||||
return {
|
||||
...map,
|
||||
...filterScopeByChartId,
|
||||
};
|
||||
}, {});
|
||||
|
||||
this.setState(() => ({
|
||||
activeFilterField: null,
|
||||
checkedFilterFields,
|
||||
// initial state: active defaultFilerKey
|
||||
const { chartId } = getChartIdAndColumnFromFilterKey(defaultFilterKey);
|
||||
const checkedFilterFields: string[] = [];
|
||||
const activeFilterField = defaultFilterKey;
|
||||
// expand defaultFilterKey in filter field tree
|
||||
const expandedFilterIds: (string | number)[] = [ALL_FILTERS_ROOT, chartId];
|
||||
|
||||
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
|
||||
checkedFilterFields,
|
||||
activeFilterField,
|
||||
filterScopeMap,
|
||||
layout,
|
||||
});
|
||||
|
||||
return {
|
||||
showSelector: true as const,
|
||||
allFilterFields,
|
||||
defaultFilterKey,
|
||||
initialState: {
|
||||
activeFilterField,
|
||||
searchText: '',
|
||||
filterScopeMap: {
|
||||
...filterScopeMap,
|
||||
...filterScopeTreeEntry,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
onExpandFilterField(expandedFilterIds: (string | number)[] = []): void {
|
||||
this.setState(() => ({
|
||||
expandedFilterIds,
|
||||
}));
|
||||
}
|
||||
|
||||
onChangeFilterField(filterField: { value?: string } = {}): void {
|
||||
const { layout } = this.props;
|
||||
const nextActiveFilterField = filterField.value;
|
||||
const state = this.state as FilterScopeSelectorStateWithSelector;
|
||||
const {
|
||||
activeFilterField: currentActiveFilterField,
|
||||
} as FilterScopeMap,
|
||||
filterFieldNodes,
|
||||
checkedFilterFields,
|
||||
filterScopeMap,
|
||||
} = state;
|
||||
expandedFilterIds,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// we allow single edit and multiple edit in the same view.
|
||||
// if user click on the single filter field,
|
||||
// will show filter scope for the single field.
|
||||
// if user click on the same filter filed again,
|
||||
// will toggle off the single filter field,
|
||||
// and allow multi-edit all checked filter fields.
|
||||
if (nextActiveFilterField === currentActiveFilterField) {
|
||||
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
|
||||
export default function FilterScopeSelector({
|
||||
dashboardFilters,
|
||||
layout,
|
||||
updateDashboardFiltersScope,
|
||||
setUnsavedChanges,
|
||||
onCloseModal,
|
||||
}: FilterScopeSelectorProps): ReactElement {
|
||||
const initialized = useMemo(
|
||||
() => initializeState(dashboardFilters, layout),
|
||||
// Only initialize once on mount
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
);
|
||||
|
||||
const { showSelector, allFilterFields } = initialized;
|
||||
|
||||
const [activeFilterField, setActiveFilterField] = useState<string | null>(
|
||||
() =>
|
||||
initialized.showSelector
|
||||
? initialized.initialState.activeFilterField
|
||||
: null,
|
||||
);
|
||||
const [searchText, setSearchText] = useState(() =>
|
||||
initialized.showSelector ? initialized.initialState.searchText : '',
|
||||
);
|
||||
const [filterScopeMap, setFilterScopeMap] = useState<FilterScopeMap>(() =>
|
||||
initialized.showSelector ? initialized.initialState.filterScopeMap : {},
|
||||
);
|
||||
const [filterFieldNodes] = useState<FilterFieldNode[]>(() =>
|
||||
initialized.showSelector ? initialized.initialState.filterFieldNodes : [],
|
||||
);
|
||||
const [checkedFilterFields, setCheckedFilterFields] = useState<string[]>(
|
||||
() =>
|
||||
initialized.showSelector
|
||||
? initialized.initialState.checkedFilterFields
|
||||
: [],
|
||||
);
|
||||
const [expandedFilterIds, setExpandedFilterIds] = useState<
|
||||
(string | number)[]
|
||||
>(() =>
|
||||
initialized.showSelector ? initialized.initialState.expandedFilterIds : [],
|
||||
);
|
||||
|
||||
const filterNodes = useCallback(
|
||||
(
|
||||
filtered: FilterScopeTreeNode[] = [],
|
||||
node: FilterScopeTreeNode = { value: '', label: '' },
|
||||
currentSearchText: string,
|
||||
): FilterScopeTreeNode[] => {
|
||||
const filterNodesRecursive = (
|
||||
f: FilterScopeTreeNode[],
|
||||
n: FilterScopeTreeNode,
|
||||
): FilterScopeTreeNode[] => filterNodes(f, n, currentSearchText);
|
||||
|
||||
const children = (node.children || []).reduce<FilterScopeTreeNode[]>(
|
||||
filterNodesRecursive,
|
||||
[],
|
||||
);
|
||||
|
||||
if (
|
||||
// Node's label matches the search string
|
||||
node.label
|
||||
.toLocaleLowerCase()
|
||||
.indexOf((currentSearchText ?? '').toLocaleLowerCase()) > -1 ||
|
||||
// Or a children has a matching node
|
||||
children.length
|
||||
) {
|
||||
filtered.push({ ...node, children });
|
||||
}
|
||||
|
||||
return filtered;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const filterTree = useCallback(
|
||||
(currentSearchText: string) => {
|
||||
const key = getKeyForFilterScopeTree({
|
||||
activeFilterField: activeFilterField ?? undefined,
|
||||
checkedFilterFields,
|
||||
});
|
||||
|
||||
// Reset nodes back to unfiltered state
|
||||
if (!currentSearchText) {
|
||||
setFilterScopeMap(prev => ({
|
||||
...prev,
|
||||
[key]: {
|
||||
...prev[key],
|
||||
nodesFiltered: prev[key].nodes,
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
setFilterScopeMap(prev => {
|
||||
const nodesFiltered = prev[key].nodes.reduce<FilterScopeTreeNode[]>(
|
||||
(filtered, node) => filterNodes(filtered, node, currentSearchText),
|
||||
[],
|
||||
);
|
||||
const expanded = getFilterScopeParentNodes([...nodesFiltered]);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[key]: {
|
||||
...prev[key],
|
||||
nodesFiltered,
|
||||
expanded,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
},
|
||||
[activeFilterField, checkedFilterFields, filterNodes],
|
||||
);
|
||||
|
||||
const onCheckFilterScope = useCallback(
|
||||
(checked: (string | number)[] = []): void => {
|
||||
const key = getKeyForFilterScopeTree({
|
||||
activeFilterField: activeFilterField ?? undefined,
|
||||
checkedFilterFields,
|
||||
});
|
||||
const editingList = activeFilterField
|
||||
? [activeFilterField]
|
||||
: checkedFilterFields;
|
||||
|
||||
const updatedFilterScopeMap = getRevertedFilterScope({
|
||||
checked,
|
||||
filterFields: editingList,
|
||||
filterScopeMap,
|
||||
});
|
||||
|
||||
setFilterScopeMap({
|
||||
...filterScopeMap,
|
||||
...updatedFilterScopeMap,
|
||||
[key]: {
|
||||
...filterScopeMap[key],
|
||||
checked,
|
||||
},
|
||||
} as FilterScopeMap);
|
||||
},
|
||||
[activeFilterField, checkedFilterFields, filterScopeMap],
|
||||
);
|
||||
|
||||
const onExpandFilterScope = useCallback(
|
||||
(expanded: string[] = []): void => {
|
||||
const key = getKeyForFilterScopeTree({
|
||||
activeFilterField: activeFilterField ?? undefined,
|
||||
checkedFilterFields,
|
||||
});
|
||||
|
||||
setFilterScopeMap(prev => ({
|
||||
...prev,
|
||||
[key]: {
|
||||
...prev[key],
|
||||
expanded,
|
||||
},
|
||||
}));
|
||||
},
|
||||
[activeFilterField, checkedFilterFields],
|
||||
);
|
||||
|
||||
const onCheckFilterField = useCallback(
|
||||
(newCheckedFilterFields: string[] = []): void => {
|
||||
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
|
||||
checkedFilterFields: newCheckedFilterFields,
|
||||
activeFilterField: undefined,
|
||||
filterScopeMap,
|
||||
layout,
|
||||
});
|
||||
|
||||
this.setState({
|
||||
activeFilterField: null,
|
||||
filterScopeMap: {
|
||||
setActiveFilterField(null);
|
||||
setCheckedFilterFields(newCheckedFilterFields);
|
||||
setFilterScopeMap({
|
||||
...filterScopeMap,
|
||||
...filterScopeTreeEntry,
|
||||
});
|
||||
},
|
||||
[filterScopeMap, layout],
|
||||
);
|
||||
|
||||
const onExpandFilterField = useCallback(
|
||||
(newExpandedFilterIds: (string | number)[] = []): void => {
|
||||
setExpandedFilterIds(newExpandedFilterIds);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onChangeFilterField = useCallback(
|
||||
(filterField: { value?: string } = {}): void => {
|
||||
const nextActiveFilterField = filterField.value;
|
||||
|
||||
// we allow single edit and multiple edit in the same view.
|
||||
// if user click on the single filter field,
|
||||
// will show filter scope for the single field.
|
||||
// if user click on the same filter filed again,
|
||||
// will toggle off the single filter field,
|
||||
// and allow multi-edit all checked filter fields.
|
||||
if (nextActiveFilterField === activeFilterField) {
|
||||
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
|
||||
checkedFilterFields,
|
||||
activeFilterField: undefined,
|
||||
filterScopeMap,
|
||||
layout,
|
||||
});
|
||||
|
||||
setActiveFilterField(null);
|
||||
setFilterScopeMap({
|
||||
...filterScopeMap,
|
||||
...filterScopeTreeEntry,
|
||||
} as FilterScopeMap,
|
||||
});
|
||||
} else if (
|
||||
nextActiveFilterField &&
|
||||
this.allfilterFields.includes(nextActiveFilterField)
|
||||
) {
|
||||
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
|
||||
checkedFilterFields,
|
||||
activeFilterField: nextActiveFilterField,
|
||||
filterScopeMap,
|
||||
layout,
|
||||
});
|
||||
});
|
||||
} else if (
|
||||
nextActiveFilterField &&
|
||||
allFilterFields.includes(nextActiveFilterField)
|
||||
) {
|
||||
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
|
||||
checkedFilterFields,
|
||||
activeFilterField: nextActiveFilterField,
|
||||
filterScopeMap,
|
||||
layout,
|
||||
});
|
||||
|
||||
this.setState({
|
||||
activeFilterField: nextActiveFilterField,
|
||||
filterScopeMap: {
|
||||
setActiveFilterField(nextActiveFilterField);
|
||||
setFilterScopeMap({
|
||||
...filterScopeMap,
|
||||
...filterScopeTreeEntry,
|
||||
} as FilterScopeMap,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
activeFilterField,
|
||||
allFilterFields,
|
||||
checkedFilterFields,
|
||||
filterScopeMap,
|
||||
layout,
|
||||
],
|
||||
);
|
||||
|
||||
onSearchInputChange(e: ChangeEvent<HTMLInputElement>): void {
|
||||
this.setState({ searchText: e.target.value }, this.filterTree);
|
||||
}
|
||||
const onSearchInputChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>): void => {
|
||||
const newSearchText = e.target.value;
|
||||
setSearchText(newSearchText);
|
||||
filterTree(newSearchText);
|
||||
},
|
||||
[filterTree],
|
||||
);
|
||||
|
||||
onClose(): void {
|
||||
this.props.onCloseModal();
|
||||
}
|
||||
const onClose = useCallback((): void => {
|
||||
onCloseModal();
|
||||
}, [onCloseModal]);
|
||||
|
||||
onSave(): void {
|
||||
const state = this.state as FilterScopeSelectorStateWithSelector;
|
||||
const { filterScopeMap } = state;
|
||||
|
||||
const allFilterFieldScopes = this.allfilterFields.reduce<
|
||||
const onSave = useCallback((): void => {
|
||||
const allFilterFieldScopes = allFilterFields.reduce<
|
||||
Record<string, ReturnType<typeof getFilterScopeFromNodesTree>>
|
||||
>((map, filterKey) => {
|
||||
const { nodes } = filterScopeMap[filterKey];
|
||||
@@ -669,124 +737,32 @@ export default class FilterScopeSelector extends PureComponent<
|
||||
};
|
||||
}, {});
|
||||
|
||||
this.props.updateDashboardFiltersScope(allFilterFieldScopes);
|
||||
this.props.setUnsavedChanges(true);
|
||||
updateDashboardFiltersScope(allFilterFieldScopes);
|
||||
setUnsavedChanges(true);
|
||||
|
||||
// click Save button will do save and close modal
|
||||
this.props.onCloseModal();
|
||||
}
|
||||
onCloseModal();
|
||||
}, [
|
||||
allFilterFields,
|
||||
filterScopeMap,
|
||||
onCloseModal,
|
||||
setUnsavedChanges,
|
||||
updateDashboardFiltersScope,
|
||||
]);
|
||||
|
||||
filterTree(): void {
|
||||
const state = this.state as FilterScopeSelectorStateWithSelector;
|
||||
// Reset nodes back to unfiltered state
|
||||
if (!state.searchText) {
|
||||
this.setState(prevState => {
|
||||
const prev = prevState as FilterScopeSelectorStateWithSelector;
|
||||
const { activeFilterField, checkedFilterFields, filterScopeMap } = prev;
|
||||
const key = getKeyForFilterScopeTree({
|
||||
activeFilterField: activeFilterField ?? undefined,
|
||||
checkedFilterFields,
|
||||
});
|
||||
|
||||
const updatedEntry = {
|
||||
...filterScopeMap[key],
|
||||
nodesFiltered: filterScopeMap[key].nodes,
|
||||
};
|
||||
return {
|
||||
filterScopeMap: {
|
||||
...filterScopeMap,
|
||||
[key]: updatedEntry,
|
||||
},
|
||||
} as Partial<FilterScopeSelectorStateWithSelector> as FilterScopeSelectorState;
|
||||
});
|
||||
} else {
|
||||
const updater = (
|
||||
prevState: FilterScopeSelectorState,
|
||||
): FilterScopeSelectorState => {
|
||||
const prev = prevState as FilterScopeSelectorStateWithSelector;
|
||||
const { activeFilterField, checkedFilterFields, filterScopeMap } = prev;
|
||||
const key = getKeyForFilterScopeTree({
|
||||
activeFilterField: activeFilterField ?? undefined,
|
||||
checkedFilterFields,
|
||||
});
|
||||
|
||||
const nodesFiltered = filterScopeMap[key].nodes.reduce<
|
||||
FilterScopeTreeNode[]
|
||||
>(this.filterNodes, []);
|
||||
const expanded = getFilterScopeParentNodes([...nodesFiltered]);
|
||||
const updatedEntry = {
|
||||
...filterScopeMap[key],
|
||||
nodesFiltered,
|
||||
expanded,
|
||||
};
|
||||
|
||||
return {
|
||||
filterScopeMap: {
|
||||
...filterScopeMap,
|
||||
[key]: updatedEntry,
|
||||
},
|
||||
} as Partial<FilterScopeSelectorStateWithSelector> as FilterScopeSelectorState;
|
||||
};
|
||||
|
||||
this.setState(updater);
|
||||
}
|
||||
}
|
||||
|
||||
filterNodes(
|
||||
filtered: FilterScopeTreeNode[] = [],
|
||||
node: FilterScopeTreeNode = { value: '', label: '' },
|
||||
): FilterScopeTreeNode[] {
|
||||
const state = this.state as FilterScopeSelectorStateWithSelector;
|
||||
const { searchText } = state;
|
||||
const children = (node.children || []).reduce<FilterScopeTreeNode[]>(
|
||||
this.filterNodes,
|
||||
[],
|
||||
);
|
||||
|
||||
if (
|
||||
// Node's label matches the search string
|
||||
node.label
|
||||
.toLocaleLowerCase()
|
||||
.indexOf((searchText ?? '').toLocaleLowerCase()) > -1 ||
|
||||
// Or a children has a matching node
|
||||
children.length
|
||||
) {
|
||||
filtered.push({ ...node, children });
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
renderFilterFieldList(): ReactElement | null {
|
||||
const state = this.state as FilterScopeSelectorStateWithSelector;
|
||||
const {
|
||||
activeFilterField,
|
||||
filterFieldNodes,
|
||||
checkedFilterFields,
|
||||
expandedFilterIds,
|
||||
} = state;
|
||||
return (
|
||||
<FilterFieldTree
|
||||
activeKey={activeFilterField}
|
||||
nodes={filterFieldNodes}
|
||||
checked={checkedFilterFields}
|
||||
expanded={expandedFilterIds}
|
||||
onClick={this.onChangeFilterField}
|
||||
onCheck={this.onCheckFilterField}
|
||||
onExpand={this.onExpandFilterField}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderFilterScopeTree(): ReactElement {
|
||||
const state = this.state as FilterScopeSelectorStateWithSelector;
|
||||
const {
|
||||
filterScopeMap,
|
||||
activeFilterField,
|
||||
checkedFilterFields,
|
||||
searchText,
|
||||
} = state;
|
||||
const renderFilterFieldList = (): ReactElement | null => (
|
||||
<FilterFieldTree
|
||||
activeKey={activeFilterField}
|
||||
nodes={filterFieldNodes}
|
||||
checked={checkedFilterFields}
|
||||
expanded={expandedFilterIds}
|
||||
onClick={onChangeFilterField}
|
||||
onCheck={onCheckFilterField}
|
||||
onExpand={onExpandFilterField}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderFilterScopeTree = (): ReactElement => {
|
||||
const key = getKeyForFilterScopeTree({
|
||||
activeFilterField: activeFilterField ?? undefined,
|
||||
checkedFilterFields,
|
||||
@@ -803,26 +779,23 @@ export default class FilterScopeSelector extends PureComponent<
|
||||
placeholder={t('Search...')}
|
||||
type="text"
|
||||
value={searchText}
|
||||
onChange={this.onSearchInputChange}
|
||||
onChange={onSearchInputChange}
|
||||
/>
|
||||
<FilterScopeTree
|
||||
nodes={filterScopeMap[key].nodesFiltered}
|
||||
checked={filterScopeMap[key].checked}
|
||||
expanded={filterScopeMap[key].expanded}
|
||||
onCheck={this.onCheckFilterScope}
|
||||
onExpand={this.onExpandFilterScope}
|
||||
onCheck={onCheckFilterScope}
|
||||
onExpand={onExpandFilterScope}
|
||||
// pass selectedFilterId prop to FilterScopeTree component,
|
||||
// to hide checkbox for selected filter field itself
|
||||
selectedChartId={selectedChartId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
renderEditingFiltersName(): ReactElement {
|
||||
const { dashboardFilters } = this.props;
|
||||
const state = this.state as FilterScopeSelectorStateWithSelector;
|
||||
const { activeFilterField, checkedFilterFields } = state;
|
||||
const renderEditingFiltersName = (): ReactElement => {
|
||||
const currentFilterLabels = ([] as string[])
|
||||
.concat(activeFilterField || checkedFilterFields)
|
||||
.filter(Boolean)
|
||||
@@ -842,50 +815,42 @@ export default class FilterScopeSelector extends PureComponent<
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
render(): ReactElement {
|
||||
const { showSelector } = this.state;
|
||||
return (
|
||||
<ScopeContainer>
|
||||
<ScopeHeader>
|
||||
<h4>{t('Configure filter scopes')}</h4>
|
||||
{showSelector && renderEditingFiltersName()}
|
||||
</ScopeHeader>
|
||||
|
||||
return (
|
||||
<ScopeContainer>
|
||||
<ScopeHeader>
|
||||
<h4>{t('Configure filter scopes')}</h4>
|
||||
{showSelector && this.renderEditingFiltersName()}
|
||||
</ScopeHeader>
|
||||
|
||||
<ScopeBody className="filter-scope-body">
|
||||
{!showSelector ? (
|
||||
<div className="warning-message">
|
||||
{t('There are no filters in this dashboard.')}
|
||||
<ScopeBody className="filter-scope-body">
|
||||
{!showSelector ? (
|
||||
<div className="warning-message">
|
||||
{t('There are no filters in this dashboard.')}
|
||||
</div>
|
||||
) : (
|
||||
<ScopeSelector className="filters-scope-selector">
|
||||
<div className={cx('filter-field-pane multi-edit-mode')}>
|
||||
{renderFilterFieldList()}
|
||||
</div>
|
||||
) : (
|
||||
<ScopeSelector className="filters-scope-selector">
|
||||
<div className={cx('filter-field-pane multi-edit-mode')}>
|
||||
{this.renderFilterFieldList()}
|
||||
</div>
|
||||
<div className="filter-scope-pane multi-edit-mode">
|
||||
{this.renderFilterScopeTree()}
|
||||
</div>
|
||||
</ScopeSelector>
|
||||
)}
|
||||
</ScopeBody>
|
||||
<div className="filter-scope-pane multi-edit-mode">
|
||||
{renderFilterScopeTree()}
|
||||
</div>
|
||||
</ScopeSelector>
|
||||
)}
|
||||
</ScopeBody>
|
||||
|
||||
<ActionsContainer>
|
||||
<Button buttonSize="small" onClick={this.onClose}>
|
||||
{t('Close')}
|
||||
<ActionsContainer>
|
||||
<Button buttonSize="small" onClick={onClose}>
|
||||
{t('Close')}
|
||||
</Button>
|
||||
{showSelector && (
|
||||
<Button buttonSize="small" buttonStyle="primary" onClick={onSave}>
|
||||
{t('Save')}
|
||||
</Button>
|
||||
{showSelector && (
|
||||
<Button
|
||||
buttonSize="small"
|
||||
buttonStyle="primary"
|
||||
onClick={this.onSave}
|
||||
>
|
||||
{t('Save')}
|
||||
</Button>
|
||||
)}
|
||||
</ActionsContainer>
|
||||
</ScopeContainer>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</ActionsContainer>
|
||||
</ScopeContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -128,6 +128,10 @@ const SliceContainer = styled.div`
|
||||
|
||||
const EMPTY_OBJECT: Record<string, never> = {};
|
||||
|
||||
// Stable no-op fallback for optional callbacks so we don't allocate a new
|
||||
// function on every render (keeps referential equality for memoized children).
|
||||
const NOOP = () => {};
|
||||
|
||||
// Helper function to get chart state with fallback
|
||||
const getChartStateWithFallback = (
|
||||
chartState: { state?: JsonObject } | undefined,
|
||||
@@ -763,11 +767,11 @@ const Chart = (props: ChartProps) => {
|
||||
},
|
||||
slice.viz_type,
|
||||
)}
|
||||
queriesResponse={chart.queriesResponse ?? undefined}
|
||||
queriesResponse={chart.queriesResponse ?? null}
|
||||
timeout={timeout}
|
||||
triggerQuery={chart.triggerQuery}
|
||||
vizType={slice.viz_type}
|
||||
setControlValue={props.setControlValue}
|
||||
setControlValue={props.setControlValue ?? NOOP}
|
||||
datasetsStatus={
|
||||
datasetsStatus as 'loading' | 'error' | 'complete' | undefined
|
||||
}
|
||||
|
||||
@@ -17,9 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { PureComponent } from 'react';
|
||||
import { useCallback, memo } from 'react';
|
||||
import { css, styled } from '@apache-superset/core/theme';
|
||||
|
||||
import { Draggable } from '../../dnd/DragDroppable';
|
||||
import HoverMenu from '../../menu/HoverMenu';
|
||||
import DeleteComponentButton from '../../DeleteComponentButton';
|
||||
@@ -63,50 +62,43 @@ const DividerLine = styled.div`
|
||||
`}
|
||||
`;
|
||||
|
||||
class Divider extends PureComponent<DividerProps> {
|
||||
constructor(props: DividerProps) {
|
||||
super(props);
|
||||
this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
|
||||
}
|
||||
|
||||
handleDeleteComponent() {
|
||||
const { deleteComponent, id, parentId } = this.props;
|
||||
function Divider({
|
||||
id,
|
||||
parentId,
|
||||
component,
|
||||
depth,
|
||||
parentComponent,
|
||||
index,
|
||||
editMode,
|
||||
handleComponentDrop,
|
||||
deleteComponent,
|
||||
}: DividerProps) {
|
||||
const handleDeleteComponent = useCallback(() => {
|
||||
deleteComponent(id, parentId);
|
||||
}
|
||||
}, [deleteComponent, id, parentId]);
|
||||
|
||||
render() {
|
||||
const {
|
||||
component,
|
||||
depth,
|
||||
parentComponent,
|
||||
index,
|
||||
handleComponentDrop,
|
||||
editMode,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<Draggable
|
||||
component={component}
|
||||
parentComponent={parentComponent}
|
||||
orientation="row"
|
||||
index={index}
|
||||
depth={depth}
|
||||
onDrop={handleComponentDrop}
|
||||
editMode={editMode}
|
||||
>
|
||||
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
|
||||
<div ref={dragSourceRef}>
|
||||
{editMode && (
|
||||
<HoverMenu position="left">
|
||||
<DeleteComponentButton onDelete={this.handleDeleteComponent} />
|
||||
</HoverMenu>
|
||||
)}
|
||||
<DividerLine className="dashboard-component dashboard-component-divider" />
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Draggable
|
||||
component={component}
|
||||
parentComponent={parentComponent}
|
||||
orientation="row"
|
||||
index={index}
|
||||
depth={depth}
|
||||
onDrop={handleComponentDrop}
|
||||
editMode={editMode}
|
||||
>
|
||||
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
|
||||
<div ref={dragSourceRef}>
|
||||
{editMode && (
|
||||
<HoverMenu position="left">
|
||||
<DeleteComponentButton onDelete={handleDeleteComponent} />
|
||||
</HoverMenu>
|
||||
)}
|
||||
<DividerLine className="dashboard-component dashboard-component-divider" />
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
}
|
||||
|
||||
export default Divider;
|
||||
export default memo(Divider);
|
||||
|
||||
@@ -16,10 +16,9 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { PureComponent } from 'react';
|
||||
import { useState, useCallback, memo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { css, styled } from '@apache-superset/core/theme';
|
||||
|
||||
import PopoverDropdown from '@superset-ui/core/components/PopoverDropdown';
|
||||
import { EditableTitle } from '@superset-ui/core/components';
|
||||
import { Draggable } from 'src/dashboard/components/dnd/DragDroppable';
|
||||
@@ -85,10 +84,6 @@ interface HeaderProps {
|
||||
updateComponents: (changes: Record<string, ComponentShape>) => void;
|
||||
}
|
||||
|
||||
interface HeaderState {
|
||||
isFocused: boolean;
|
||||
}
|
||||
|
||||
const HeaderStyles = styled.div`
|
||||
${({ theme }) => css`
|
||||
font-weight: ${theme.fontWeightStrong};
|
||||
@@ -159,149 +154,141 @@ const HeaderStyles = styled.div`
|
||||
`}
|
||||
`;
|
||||
|
||||
class Header extends PureComponent<HeaderProps, HeaderState> {
|
||||
handleChangeSize: (nextValue: string) => void;
|
||||
handleChangeBackground: (nextValue: string) => void;
|
||||
handleChangeText: (nextValue: string) => void;
|
||||
function Header({
|
||||
id,
|
||||
dashboardId,
|
||||
parentId,
|
||||
component,
|
||||
depth,
|
||||
parentComponent,
|
||||
index,
|
||||
editMode,
|
||||
embeddedMode,
|
||||
handleComponentDrop,
|
||||
deleteComponent,
|
||||
updateComponents,
|
||||
}: HeaderProps) {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
constructor(props: HeaderProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
isFocused: false,
|
||||
};
|
||||
this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
|
||||
this.handleChangeFocus = this.handleChangeFocus.bind(this);
|
||||
this.handleUpdateMeta = this.handleUpdateMeta.bind(this);
|
||||
const handleChangeFocus = useCallback((nextFocus: boolean): void => {
|
||||
setIsFocused(nextFocus);
|
||||
}, []);
|
||||
|
||||
this.handleChangeSize = (nextValue: string) =>
|
||||
this.handleUpdateMeta('headerSize', nextValue);
|
||||
this.handleChangeBackground = (nextValue: string) =>
|
||||
this.handleUpdateMeta('background', nextValue);
|
||||
this.handleChangeText = (nextValue: string) =>
|
||||
this.handleUpdateMeta('text', nextValue);
|
||||
}
|
||||
|
||||
handleChangeFocus(nextFocus: boolean): void {
|
||||
this.setState(() => ({ isFocused: nextFocus }));
|
||||
}
|
||||
|
||||
handleUpdateMeta(metaKey: keyof ComponentMeta, nextValue: string): void {
|
||||
const { updateComponents, component } = this.props;
|
||||
if (nextValue && component.meta[metaKey] !== nextValue) {
|
||||
updateComponents({
|
||||
[component.id]: {
|
||||
...component,
|
||||
meta: {
|
||||
...component.meta,
|
||||
[metaKey]: nextValue,
|
||||
const handleUpdateMeta = useCallback(
|
||||
(metaKey: keyof ComponentMeta, nextValue: string): void => {
|
||||
if (nextValue && component.meta[metaKey] !== nextValue) {
|
||||
updateComponents({
|
||||
[component.id]: {
|
||||
...component,
|
||||
meta: {
|
||||
...component.meta,
|
||||
[metaKey]: nextValue,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Record<string, ComponentShape>);
|
||||
}
|
||||
}
|
||||
} as Record<string, ComponentShape>);
|
||||
}
|
||||
},
|
||||
[component, updateComponents],
|
||||
);
|
||||
|
||||
handleDeleteComponent(): void {
|
||||
const { deleteComponent, id, parentId } = this.props;
|
||||
const handleChangeSize = useCallback(
|
||||
(nextValue: string) => handleUpdateMeta('headerSize', nextValue),
|
||||
[handleUpdateMeta],
|
||||
);
|
||||
|
||||
const handleChangeBackground = useCallback(
|
||||
(nextValue: string) => handleUpdateMeta('background', nextValue),
|
||||
[handleUpdateMeta],
|
||||
);
|
||||
|
||||
const handleChangeText = useCallback(
|
||||
(nextValue: string) => handleUpdateMeta('text', nextValue),
|
||||
[handleUpdateMeta],
|
||||
);
|
||||
|
||||
const handleDeleteComponent = useCallback((): void => {
|
||||
deleteComponent(id, parentId);
|
||||
}
|
||||
}, [deleteComponent, id, parentId]);
|
||||
|
||||
render() {
|
||||
const { isFocused } = this.state;
|
||||
const headerStyle = headerStyleOptions.find(
|
||||
opt => opt.value === (component.meta.headerSize || SMALL_HEADER),
|
||||
);
|
||||
|
||||
const {
|
||||
dashboardId,
|
||||
component,
|
||||
depth,
|
||||
parentComponent,
|
||||
index,
|
||||
handleComponentDrop,
|
||||
editMode,
|
||||
embeddedMode,
|
||||
} = this.props;
|
||||
const rowStyle = backgroundStyleOptions.find(
|
||||
opt => opt.value === (component.meta.background || BACKGROUND_TRANSPARENT),
|
||||
);
|
||||
|
||||
const headerStyle = headerStyleOptions.find(
|
||||
opt => opt.value === (component.meta.headerSize || SMALL_HEADER),
|
||||
);
|
||||
|
||||
const rowStyle = backgroundStyleOptions.find(
|
||||
opt =>
|
||||
opt.value === (component.meta.background || BACKGROUND_TRANSPARENT),
|
||||
);
|
||||
|
||||
return (
|
||||
<Draggable
|
||||
component={component}
|
||||
parentComponent={parentComponent}
|
||||
orientation="row"
|
||||
index={index}
|
||||
depth={depth}
|
||||
onDrop={handleComponentDrop}
|
||||
disableDragDrop={isFocused}
|
||||
editMode={editMode}
|
||||
>
|
||||
{({
|
||||
dragSourceRef,
|
||||
}: {
|
||||
dragSourceRef: React.Ref<HTMLDivElement> | undefined;
|
||||
}) => (
|
||||
<div ref={dragSourceRef}>
|
||||
{editMode &&
|
||||
depth <= 2 && ( // drag handle looks bad when nested
|
||||
<HoverMenu position="left">
|
||||
<DragHandle position="left" />
|
||||
return (
|
||||
<Draggable
|
||||
component={component}
|
||||
parentComponent={parentComponent}
|
||||
orientation="row"
|
||||
index={index}
|
||||
depth={depth}
|
||||
onDrop={handleComponentDrop}
|
||||
disableDragDrop={isFocused}
|
||||
editMode={editMode}
|
||||
>
|
||||
{({
|
||||
dragSourceRef,
|
||||
}: {
|
||||
dragSourceRef: React.Ref<HTMLDivElement> | undefined;
|
||||
}) => (
|
||||
<div ref={dragSourceRef}>
|
||||
{editMode &&
|
||||
depth <= 2 && ( // drag handle looks bad when nested
|
||||
<HoverMenu position="left">
|
||||
<DragHandle position="left" />
|
||||
</HoverMenu>
|
||||
)}
|
||||
<WithPopoverMenu
|
||||
onChangeFocus={handleChangeFocus}
|
||||
menuItems={[
|
||||
<PopoverDropdown
|
||||
id={`${component.id}-header-style`}
|
||||
options={headerStyleOptions}
|
||||
value={component.meta.headerSize as string}
|
||||
onChange={handleChangeSize}
|
||||
/>,
|
||||
<BackgroundStyleDropdown
|
||||
id={`${component.id}-background`}
|
||||
value={component.meta.background as string}
|
||||
onChange={handleChangeBackground}
|
||||
/>,
|
||||
]}
|
||||
editMode={editMode}
|
||||
>
|
||||
<HeaderStyles
|
||||
className={cx(
|
||||
'dashboard-component',
|
||||
'dashboard-component-header',
|
||||
headerStyle?.className,
|
||||
rowStyle?.className,
|
||||
)}
|
||||
>
|
||||
{editMode && (
|
||||
<HoverMenu position="top">
|
||||
<DeleteComponentButton onDelete={handleDeleteComponent} />
|
||||
</HoverMenu>
|
||||
)}
|
||||
<WithPopoverMenu
|
||||
onChangeFocus={this.handleChangeFocus}
|
||||
menuItems={[
|
||||
<PopoverDropdown
|
||||
id={`${component.id}-header-style`}
|
||||
options={headerStyleOptions}
|
||||
value={component.meta.headerSize as string}
|
||||
onChange={this.handleChangeSize}
|
||||
/>,
|
||||
<BackgroundStyleDropdown
|
||||
id={`${component.id}-background`}
|
||||
value={component.meta.background as string}
|
||||
onChange={this.handleChangeBackground}
|
||||
/>,
|
||||
]}
|
||||
editMode={editMode}
|
||||
>
|
||||
<HeaderStyles
|
||||
className={cx(
|
||||
'dashboard-component',
|
||||
'dashboard-component-header',
|
||||
headerStyle?.className,
|
||||
rowStyle?.className,
|
||||
)}
|
||||
>
|
||||
{editMode && (
|
||||
<HoverMenu position="top">
|
||||
<DeleteComponentButton
|
||||
onDelete={this.handleDeleteComponent}
|
||||
/>
|
||||
</HoverMenu>
|
||||
)}
|
||||
<EditableTitle
|
||||
title={component.meta.text}
|
||||
canEdit={editMode}
|
||||
onSaveTitle={this.handleChangeText}
|
||||
showTooltip={false}
|
||||
<EditableTitle
|
||||
title={component.meta.text}
|
||||
canEdit={editMode}
|
||||
onSaveTitle={handleChangeText}
|
||||
showTooltip={false}
|
||||
/>
|
||||
{!editMode && !embeddedMode && (
|
||||
<AnchorLink
|
||||
id={component.id}
|
||||
dashboardId={Number(dashboardId)}
|
||||
/>
|
||||
{!editMode && !embeddedMode && (
|
||||
<AnchorLink
|
||||
id={component.id}
|
||||
dashboardId={Number(dashboardId)}
|
||||
/>
|
||||
)}
|
||||
</HeaderStyles>
|
||||
</WithPopoverMenu>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</HeaderStyles>
|
||||
</WithPopoverMenu>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
}
|
||||
|
||||
export default Header;
|
||||
export default memo(Header);
|
||||
|
||||
@@ -16,14 +16,16 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { PureComponent } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { ErrorInfo } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import cx from 'classnames';
|
||||
import type { JsonObject } from '@superset-ui/core';
|
||||
import type { ResizeStartCallback, ResizeCallback } from 're-resizable';
|
||||
import { ErrorBoundary } from 'src/components';
|
||||
|
||||
import { css, styled } from '@apache-superset/core/theme';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { css, styled } from '@apache-superset/core/theme';
|
||||
import { SafeMarkdown } from '@superset-ui/core/components';
|
||||
import { EditorHost } from 'src/core/editors';
|
||||
import { Logger, LOG_ACTIONS_RENDER_CHART } from 'src/logger/LogUtils';
|
||||
@@ -82,16 +84,6 @@ export interface MarkdownStateProps {
|
||||
|
||||
export type MarkdownProps = MarkdownOwnProps & MarkdownStateProps;
|
||||
|
||||
export interface MarkdownState {
|
||||
isFocused: boolean;
|
||||
markdownSource: string;
|
||||
editor: EditorInstance | null;
|
||||
editorMode: 'preview' | 'edit';
|
||||
undoLength: number;
|
||||
redoLength: number;
|
||||
hasError?: boolean;
|
||||
}
|
||||
|
||||
// TODO: localize
|
||||
const MARKDOWN_PLACE_HOLDER = `# ✨Header 1
|
||||
## ✨Header 2
|
||||
@@ -140,193 +132,200 @@ interface DragChildProps {
|
||||
dragSourceRef: React.RefCallback<HTMLElement>;
|
||||
}
|
||||
|
||||
class Markdown extends PureComponent<MarkdownProps, MarkdownState> {
|
||||
renderStartTime: number;
|
||||
function Markdown({
|
||||
id,
|
||||
parentId,
|
||||
component,
|
||||
parentComponent,
|
||||
index,
|
||||
depth,
|
||||
editMode,
|
||||
availableColumnCount,
|
||||
columnWidth,
|
||||
onResizeStart,
|
||||
onResize,
|
||||
onResizeStop,
|
||||
deleteComponent,
|
||||
handleComponentDrop,
|
||||
updateComponents,
|
||||
logEvent,
|
||||
addDangerToast,
|
||||
undoLength,
|
||||
redoLength,
|
||||
htmlSanitization,
|
||||
htmlSchemaOverrides,
|
||||
}: MarkdownProps) {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [markdownSource, setMarkdownSource] = useState<string>(
|
||||
component.meta.code as string,
|
||||
);
|
||||
const [editor, setEditorState] = useState<EditorInstance | null>(null);
|
||||
const [editorMode, setEditorMode] = useState<'preview' | 'edit'>('preview');
|
||||
const [hasError, setHasError] = useState(false);
|
||||
|
||||
constructor(props: MarkdownProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
isFocused: false,
|
||||
markdownSource: props.component.meta.code as string,
|
||||
editor: null,
|
||||
editorMode: 'preview',
|
||||
undoLength: props.undoLength,
|
||||
redoLength: props.redoLength,
|
||||
};
|
||||
this.renderStartTime = Logger.getTimestamp();
|
||||
const renderStartTimeRef = useRef(Logger.getTimestamp());
|
||||
const prevUndoLengthRef = useRef(undoLength);
|
||||
const prevRedoLengthRef = useRef(redoLength);
|
||||
const prevComponentWidthRef = useRef(component.meta.width);
|
||||
const prevColumnWidthRef = useRef(columnWidth);
|
||||
|
||||
this.handleChangeFocus = this.handleChangeFocus.bind(this);
|
||||
this.handleChangeEditorMode = this.handleChangeEditorMode.bind(this);
|
||||
this.handleMarkdownChange = this.handleMarkdownChange.bind(this);
|
||||
this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
|
||||
this.handleResizeStart = this.handleResizeStart.bind(this);
|
||||
this.setEditor = this.setEditor.bind(this);
|
||||
this.shouldFocusMarkdown = this.shouldFocusMarkdown.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
this.props.logEvent(LOG_ACTIONS_RENDER_CHART, {
|
||||
viz_type: 'markdown',
|
||||
start_offset: this.renderStartTime,
|
||||
ts: new Date().getTime(),
|
||||
duration: Logger.getTimestamp() - this.renderStartTime,
|
||||
});
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(
|
||||
nextProps: MarkdownProps,
|
||||
state: MarkdownState,
|
||||
): MarkdownState | null {
|
||||
const { hasError, editorMode, markdownSource, undoLength, redoLength } =
|
||||
state;
|
||||
const {
|
||||
component: nextComponent,
|
||||
undoLength: nextUndoLength,
|
||||
redoLength: nextRedoLength,
|
||||
} = nextProps;
|
||||
// user click undo or redo ?
|
||||
if (nextUndoLength !== undoLength || nextRedoLength !== redoLength) {
|
||||
return {
|
||||
...state,
|
||||
undoLength: nextUndoLength,
|
||||
redoLength: nextRedoLength,
|
||||
markdownSource: nextComponent.meta.code as string,
|
||||
hasError: false,
|
||||
};
|
||||
}
|
||||
// getDerivedStateFromProps equivalent: handle undo/redo and external code changes
|
||||
useEffect(() => {
|
||||
// user click undo or redo?
|
||||
if (
|
||||
undoLength !== prevUndoLengthRef.current ||
|
||||
redoLength !== prevRedoLengthRef.current
|
||||
) {
|
||||
setMarkdownSource(component.meta.code as string);
|
||||
setHasError(false);
|
||||
prevUndoLengthRef.current = undoLength;
|
||||
prevRedoLengthRef.current = redoLength;
|
||||
} else if (
|
||||
!hasError &&
|
||||
editorMode === 'preview' &&
|
||||
nextComponent.meta.code !== markdownSource
|
||||
component.meta.code !== markdownSource
|
||||
) {
|
||||
return {
|
||||
...state,
|
||||
markdownSource: nextComponent.meta.code as string,
|
||||
};
|
||||
setMarkdownSource(component.meta.code as string);
|
||||
}
|
||||
}, [
|
||||
undoLength,
|
||||
redoLength,
|
||||
component.meta.code,
|
||||
hasError,
|
||||
editorMode,
|
||||
markdownSource,
|
||||
]);
|
||||
|
||||
return state;
|
||||
}
|
||||
// componentDidMount equivalent: log render event
|
||||
useEffect(() => {
|
||||
logEvent(LOG_ACTIONS_RENDER_CHART, {
|
||||
viz_type: 'markdown',
|
||||
start_offset: renderStartTimeRef.current,
|
||||
ts: new Date().getTime(),
|
||||
duration: Logger.getTimestamp() - renderStartTimeRef.current,
|
||||
});
|
||||
// Only run on mount
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
static getDerivedStateFromError(): { hasError: boolean } {
|
||||
return {
|
||||
hasError: true,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: MarkdownProps): void {
|
||||
// componentDidUpdate equivalent: resize editor when width changes
|
||||
useEffect(() => {
|
||||
if (
|
||||
this.state.editor &&
|
||||
(prevProps.component.meta.width !== this.props.component.meta.width ||
|
||||
prevProps.columnWidth !== this.props.columnWidth)
|
||||
editor &&
|
||||
(prevComponentWidthRef.current !== component.meta.width ||
|
||||
prevColumnWidthRef.current !== columnWidth)
|
||||
) {
|
||||
// Handle both Ace editor (resize method) and EditorHandle (no resize needed)
|
||||
if (typeof this.state.editor.resize === 'function') {
|
||||
this.state.editor.resize(true);
|
||||
if (typeof editor.resize === 'function') {
|
||||
editor.resize(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
prevComponentWidthRef.current = component.meta.width;
|
||||
prevColumnWidthRef.current = columnWidth;
|
||||
}, [editor, component.meta.width, columnWidth]);
|
||||
|
||||
componentDidCatch(): void {
|
||||
if (this.state.editor && this.state.editorMode === 'preview') {
|
||||
this.props.addDangerToast(
|
||||
t(
|
||||
'This markdown component has an error. Please revert your recent changes.',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setEditor(editor: EditorInstance): void {
|
||||
// EditorHandle or Ace editor instance
|
||||
// For Ace: editor.getSession().setUseWrapMode(true)
|
||||
// For EditorHandle: wrapEnabled is handled via options
|
||||
if (editor?.getSession) {
|
||||
editor.getSession!().setUseWrapMode(true);
|
||||
}
|
||||
this.setState({
|
||||
editor,
|
||||
});
|
||||
}
|
||||
|
||||
handleChangeFocus(nextFocus: boolean | number): void {
|
||||
const nextFocused = !!nextFocus;
|
||||
const nextEditMode: 'edit' | 'preview' = nextFocused ? 'edit' : 'preview';
|
||||
this.setState(() => ({ isFocused: nextFocused }));
|
||||
this.handleChangeEditorMode(nextEditMode);
|
||||
}
|
||||
|
||||
handleChangeEditorMode(mode: 'edit' | 'preview'): void {
|
||||
const nextState: MarkdownState = {
|
||||
...this.state,
|
||||
editorMode: mode,
|
||||
};
|
||||
if (mode === 'preview') {
|
||||
this.updateMarkdownContent();
|
||||
nextState.hasError = false;
|
||||
}
|
||||
|
||||
this.setState(nextState);
|
||||
}
|
||||
|
||||
updateMarkdownContent(): void {
|
||||
const { updateComponents, component } = this.props;
|
||||
if (component.meta.code !== this.state.markdownSource) {
|
||||
const updateMarkdownContent = useCallback((): void => {
|
||||
if (component.meta.code !== markdownSource) {
|
||||
updateComponents({
|
||||
[component.id]: {
|
||||
...component,
|
||||
meta: {
|
||||
...component.meta,
|
||||
code: this.state.markdownSource,
|
||||
code: markdownSource,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [component, markdownSource, updateComponents]);
|
||||
|
||||
handleMarkdownChange(nextValue: string): void {
|
||||
this.setState({
|
||||
markdownSource: nextValue,
|
||||
});
|
||||
}
|
||||
|
||||
handleDeleteComponent(): void {
|
||||
const { deleteComponent, id, parentId } = this.props;
|
||||
deleteComponent(id, parentId);
|
||||
}
|
||||
|
||||
handleResizeStart(...args: Parameters<ResizeStartCallback>): void {
|
||||
const { editorMode } = this.state;
|
||||
const { editMode, onResizeStart } = this.props;
|
||||
const isEditing = editorMode === 'edit';
|
||||
onResizeStart(...args);
|
||||
if (editMode && isEditing) {
|
||||
this.updateMarkdownContent();
|
||||
const setEditor = useCallback((editorInstance: EditorInstance): void => {
|
||||
// EditorHandle or Ace editor instance
|
||||
// For Ace: editor.getSession().setUseWrapMode(true)
|
||||
// For EditorHandle: wrapEnabled is handled via options
|
||||
if (editorInstance?.getSession) {
|
||||
editorInstance.getSession!().setUseWrapMode(true);
|
||||
}
|
||||
}
|
||||
setEditorState(editorInstance);
|
||||
}, []);
|
||||
|
||||
shouldFocusMarkdown(
|
||||
event: MouseEvent,
|
||||
container: HTMLElement | null,
|
||||
menuRef: HTMLElement | null,
|
||||
): boolean {
|
||||
if (container?.contains(event.target as Node)) return true;
|
||||
if (menuRef?.contains(event.target as Node)) return true;
|
||||
const handleChangeEditorMode = useCallback(
|
||||
(mode: 'edit' | 'preview'): void => {
|
||||
if (mode === 'preview') {
|
||||
updateMarkdownContent();
|
||||
setHasError(false);
|
||||
}
|
||||
setEditorMode(mode);
|
||||
},
|
||||
[updateMarkdownContent],
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
const handleChangeFocus = useCallback(
|
||||
(nextFocus: boolean | number): void => {
|
||||
const nextFocused = !!nextFocus;
|
||||
const nextEditMode: 'edit' | 'preview' = nextFocused ? 'edit' : 'preview';
|
||||
setIsFocused(nextFocused);
|
||||
handleChangeEditorMode(nextEditMode);
|
||||
},
|
||||
[handleChangeEditorMode],
|
||||
);
|
||||
|
||||
renderEditMode(): JSX.Element {
|
||||
return (
|
||||
const handleMarkdownChange = useCallback((nextValue: string): void => {
|
||||
setMarkdownSource(nextValue);
|
||||
}, []);
|
||||
|
||||
const handleDeleteComponent = useCallback((): void => {
|
||||
deleteComponent(id, parentId);
|
||||
}, [deleteComponent, id, parentId]);
|
||||
|
||||
const handleResizeStart = useCallback(
|
||||
(...args: Parameters<ResizeStartCallback>): void => {
|
||||
const isEditing = editorMode === 'edit';
|
||||
onResizeStart(...args);
|
||||
if (editMode && isEditing) {
|
||||
updateMarkdownContent();
|
||||
}
|
||||
},
|
||||
[editorMode, editMode, onResizeStart, updateMarkdownContent],
|
||||
);
|
||||
|
||||
const shouldFocusMarkdown = useCallback(
|
||||
(
|
||||
event: MouseEvent,
|
||||
container: HTMLElement | null,
|
||||
menuRef: HTMLElement | null,
|
||||
): boolean => {
|
||||
if (container?.contains(event.target as Node)) return true;
|
||||
if (menuRef?.contains(event.target as Node)) return true;
|
||||
return false;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleRenderError = useCallback(
|
||||
(_error: Error, _info: ErrorInfo): void => {
|
||||
setHasError(true);
|
||||
if (editorMode === 'preview') {
|
||||
addDangerToast(
|
||||
t(
|
||||
'This markdown component has an error. Please revert your recent changes.',
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
[addDangerToast, editorMode],
|
||||
);
|
||||
|
||||
const renderEditMode = useMemo(
|
||||
() => (
|
||||
<EditorHost
|
||||
id={`markdown-editor-${this.props.id}`}
|
||||
onChange={this.handleMarkdownChange}
|
||||
id={`markdown-editor-${id}`}
|
||||
onChange={handleMarkdownChange}
|
||||
width="100%"
|
||||
height="100%"
|
||||
value={
|
||||
// this allows "select all => delete" to give an empty editor
|
||||
typeof this.state.markdownSource === 'string'
|
||||
? this.state.markdownSource
|
||||
typeof markdownSource === 'string'
|
||||
? markdownSource
|
||||
: MARKDOWN_PLACE_HOLDER
|
||||
}
|
||||
language="markdown"
|
||||
@@ -336,126 +335,122 @@ class Markdown extends PureComponent<MarkdownProps, MarkdownState> {
|
||||
onReady={(handle: EditorInstance) => {
|
||||
// The handle provides access to the underlying editor for resize
|
||||
if (handle && typeof handle.focus === 'function') {
|
||||
this.setEditor(handle);
|
||||
setEditor(handle);
|
||||
}
|
||||
}}
|
||||
data-test="editor"
|
||||
/>
|
||||
);
|
||||
}
|
||||
),
|
||||
[id, markdownSource, handleMarkdownChange, setEditor],
|
||||
);
|
||||
|
||||
renderPreviewMode(): JSX.Element {
|
||||
const { hasError } = this.state;
|
||||
|
||||
return (
|
||||
<SafeMarkdown
|
||||
source={
|
||||
hasError
|
||||
? MARKDOWN_ERROR_MESSAGE
|
||||
: this.state.markdownSource || MARKDOWN_PLACE_HOLDER
|
||||
}
|
||||
htmlSanitization={this.props.htmlSanitization}
|
||||
htmlSchemaOverrides={this.props.htmlSchemaOverrides}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isFocused, editorMode } = this.state;
|
||||
|
||||
const {
|
||||
component,
|
||||
parentComponent,
|
||||
index,
|
||||
depth,
|
||||
availableColumnCount,
|
||||
columnWidth,
|
||||
onResize,
|
||||
onResizeStop,
|
||||
handleComponentDrop,
|
||||
editMode,
|
||||
} = this.props;
|
||||
|
||||
// inherit the size of parent columns
|
||||
const widthMultiple =
|
||||
parentComponent.type === COLUMN_TYPE
|
||||
? parentComponent.meta.width || GRID_MIN_COLUMN_COUNT
|
||||
: component.meta.width || GRID_MIN_COLUMN_COUNT;
|
||||
|
||||
const isEditing = editorMode === 'edit';
|
||||
|
||||
return (
|
||||
<Draggable
|
||||
component={component}
|
||||
parentComponent={parentComponent}
|
||||
orientation={parentComponent.type === ROW_TYPE ? 'column' : 'row'}
|
||||
index={index}
|
||||
depth={depth}
|
||||
onDrop={handleComponentDrop}
|
||||
disableDragDrop={isFocused}
|
||||
editMode={editMode}
|
||||
const renderPreviewMode = useMemo(
|
||||
() => (
|
||||
<ErrorBoundary
|
||||
key={hasError ? 'markdown-error' : 'markdown-ok'}
|
||||
onError={handleRenderError}
|
||||
showMessage={false}
|
||||
>
|
||||
{({ dragSourceRef }: DragChildProps) => (
|
||||
<WithPopoverMenu
|
||||
onChangeFocus={this.handleChangeFocus}
|
||||
shouldFocus={this.shouldFocusMarkdown}
|
||||
menuItems={[
|
||||
<MarkdownModeDropdown
|
||||
key={`${component.id}-mode`}
|
||||
id={`${component.id}-mode`}
|
||||
value={this.state.editorMode}
|
||||
onChange={this.handleChangeEditorMode}
|
||||
/>,
|
||||
]}
|
||||
editMode={editMode}
|
||||
<SafeMarkdown
|
||||
source={
|
||||
hasError
|
||||
? MARKDOWN_ERROR_MESSAGE
|
||||
: markdownSource || MARKDOWN_PLACE_HOLDER
|
||||
}
|
||||
htmlSanitization={htmlSanitization}
|
||||
htmlSchemaOverrides={htmlSchemaOverrides}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
),
|
||||
[
|
||||
hasError,
|
||||
markdownSource,
|
||||
htmlSanitization,
|
||||
htmlSchemaOverrides,
|
||||
handleRenderError,
|
||||
],
|
||||
);
|
||||
|
||||
// inherit the size of parent columns
|
||||
const widthMultiple =
|
||||
parentComponent.type === COLUMN_TYPE
|
||||
? parentComponent.meta.width || GRID_MIN_COLUMN_COUNT
|
||||
: component.meta.width || GRID_MIN_COLUMN_COUNT;
|
||||
|
||||
const isEditing = editorMode === 'edit';
|
||||
|
||||
const menuItems = useMemo(
|
||||
() => [
|
||||
<MarkdownModeDropdown
|
||||
key={`${component.id}-mode`}
|
||||
id={`${component.id}-mode`}
|
||||
value={editorMode}
|
||||
onChange={handleChangeEditorMode}
|
||||
/>,
|
||||
],
|
||||
[component.id, editorMode, handleChangeEditorMode],
|
||||
);
|
||||
|
||||
return (
|
||||
<Draggable
|
||||
component={component}
|
||||
parentComponent={parentComponent}
|
||||
orientation={parentComponent.type === ROW_TYPE ? 'column' : 'row'}
|
||||
index={index}
|
||||
depth={depth}
|
||||
onDrop={handleComponentDrop}
|
||||
disableDragDrop={isFocused}
|
||||
editMode={editMode}
|
||||
>
|
||||
{({ dragSourceRef }: DragChildProps) => (
|
||||
<WithPopoverMenu
|
||||
onChangeFocus={handleChangeFocus}
|
||||
shouldFocus={shouldFocusMarkdown}
|
||||
menuItems={menuItems}
|
||||
editMode={editMode}
|
||||
>
|
||||
<MarkdownStyles
|
||||
data-test="dashboard-markdown-editor"
|
||||
className={cx(
|
||||
'dashboard-markdown',
|
||||
isEditing && 'dashboard-markdown--editing',
|
||||
)}
|
||||
id={component.id}
|
||||
>
|
||||
<MarkdownStyles
|
||||
data-test="dashboard-markdown-editor"
|
||||
className={cx(
|
||||
'dashboard-markdown',
|
||||
isEditing && 'dashboard-markdown--editing',
|
||||
)}
|
||||
<ResizableContainer
|
||||
id={component.id}
|
||||
adjustableWidth={parentComponent.type === ROW_TYPE}
|
||||
adjustableHeight
|
||||
widthStep={columnWidth}
|
||||
widthMultiple={widthMultiple}
|
||||
heightStep={GRID_BASE_UNIT}
|
||||
heightMultiple={component.meta.height ?? GRID_MIN_ROW_UNITS}
|
||||
minWidthMultiple={GRID_MIN_COLUMN_COUNT}
|
||||
minHeightMultiple={GRID_MIN_ROW_UNITS}
|
||||
maxWidthMultiple={availableColumnCount + widthMultiple}
|
||||
onResizeStart={handleResizeStart}
|
||||
onResize={onResize}
|
||||
onResizeStop={onResizeStop}
|
||||
editMode={isFocused ? false : editMode}
|
||||
>
|
||||
<ResizableContainer
|
||||
id={component.id}
|
||||
adjustableWidth={parentComponent.type === ROW_TYPE}
|
||||
adjustableHeight
|
||||
widthStep={columnWidth}
|
||||
widthMultiple={widthMultiple}
|
||||
heightStep={GRID_BASE_UNIT}
|
||||
heightMultiple={component.meta.height ?? GRID_MIN_ROW_UNITS}
|
||||
minWidthMultiple={GRID_MIN_COLUMN_COUNT}
|
||||
minHeightMultiple={GRID_MIN_ROW_UNITS}
|
||||
maxWidthMultiple={availableColumnCount + widthMultiple}
|
||||
onResizeStart={this.handleResizeStart}
|
||||
onResize={onResize}
|
||||
onResizeStop={onResizeStop}
|
||||
editMode={isFocused ? false : editMode}
|
||||
<div
|
||||
ref={dragSourceRef}
|
||||
className="dashboard-component dashboard-component-chart-holder"
|
||||
data-test="dashboard-component-chart-holder"
|
||||
>
|
||||
<div
|
||||
ref={dragSourceRef}
|
||||
className="dashboard-component dashboard-component-chart-holder"
|
||||
data-test="dashboard-component-chart-holder"
|
||||
>
|
||||
{editMode && (
|
||||
<HoverMenu position="top">
|
||||
<DeleteComponentButton
|
||||
onDelete={this.handleDeleteComponent}
|
||||
/>
|
||||
</HoverMenu>
|
||||
)}
|
||||
{editMode && isEditing
|
||||
? this.renderEditMode()
|
||||
: this.renderPreviewMode()}
|
||||
</div>
|
||||
</ResizableContainer>
|
||||
</MarkdownStyles>
|
||||
</WithPopoverMenu>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
}
|
||||
{editMode && (
|
||||
<HoverMenu position="top">
|
||||
<DeleteComponentButton onDelete={handleDeleteComponent} />
|
||||
</HoverMenu>
|
||||
)}
|
||||
{editMode && isEditing ? renderEditMode : renderPreviewMode}
|
||||
</div>
|
||||
</ResizableContainer>
|
||||
</MarkdownStyles>
|
||||
</WithPopoverMenu>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
}
|
||||
|
||||
interface ReduxState {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { PureComponent } from 'react';
|
||||
import { memo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { css, styled } from '@apache-superset/core/theme';
|
||||
import { DragDroppable } from 'src/dashboard/components/dnd/DragDroppable';
|
||||
@@ -62,37 +62,37 @@ const NewComponentPlaceholder = styled.div`
|
||||
`}
|
||||
`;
|
||||
|
||||
export default class DraggableNewComponent extends PureComponent<DraggableNewComponentProps> {
|
||||
static defaultProps = {
|
||||
className: null,
|
||||
IconComponent: undefined,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { label, id, type, className, meta, IconComponent } = this.props;
|
||||
|
||||
return (
|
||||
<DragDroppable
|
||||
component={{ type, id, meta }}
|
||||
parentComponent={{
|
||||
id: NEW_COMPONENTS_SOURCE_ID,
|
||||
type: NEW_COMPONENT_SOURCE_TYPE,
|
||||
}}
|
||||
index={0}
|
||||
depth={0}
|
||||
editMode
|
||||
>
|
||||
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
|
||||
<NewComponent ref={dragSourceRef} data-test="new-component">
|
||||
<NewComponentPlaceholder
|
||||
className={cx('new-component-placeholder', className)}
|
||||
>
|
||||
{IconComponent && <IconComponent iconSize="xl" />}
|
||||
</NewComponentPlaceholder>
|
||||
{label}
|
||||
</NewComponent>
|
||||
)}
|
||||
</DragDroppable>
|
||||
);
|
||||
}
|
||||
function DraggableNewComponent({
|
||||
label,
|
||||
id,
|
||||
type,
|
||||
className,
|
||||
meta,
|
||||
IconComponent,
|
||||
}: DraggableNewComponentProps) {
|
||||
return (
|
||||
<DragDroppable
|
||||
component={{ type, id, meta }}
|
||||
parentComponent={{
|
||||
id: NEW_COMPONENTS_SOURCE_ID,
|
||||
type: NEW_COMPONENT_SOURCE_TYPE,
|
||||
}}
|
||||
index={0}
|
||||
depth={0}
|
||||
editMode
|
||||
>
|
||||
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
|
||||
<NewComponent ref={dragSourceRef} data-test="new-component">
|
||||
<NewComponentPlaceholder
|
||||
className={cx('new-component-placeholder', className)}
|
||||
>
|
||||
{IconComponent && <IconComponent iconSize="xl" />}
|
||||
</NewComponentPlaceholder>
|
||||
{label}
|
||||
</NewComponent>
|
||||
)}
|
||||
</DragDroppable>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(DraggableNewComponent);
|
||||
|
||||
@@ -16,11 +16,9 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { PureComponent } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { css, styled } from '@apache-superset/core/theme';
|
||||
|
||||
import backgroundStyleOptions from 'src/dashboard/util/backgroundStyleOptions';
|
||||
import PopoverDropdown, {
|
||||
OptionProps,
|
||||
@@ -90,18 +88,19 @@ function renderOption(option: OptionProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export default class BackgroundStyleDropdown extends PureComponent<BackgroundStyleDropdownProps> {
|
||||
render() {
|
||||
const { id, value, onChange } = this.props;
|
||||
return (
|
||||
<PopoverDropdown
|
||||
id={id}
|
||||
options={backgroundStyleOptions}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
renderButton={renderButton}
|
||||
renderOption={renderOption}
|
||||
/>
|
||||
);
|
||||
}
|
||||
export default function BackgroundStyleDropdown({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
}: BackgroundStyleDropdownProps) {
|
||||
return (
|
||||
<PopoverDropdown
|
||||
id={id}
|
||||
options={backgroundStyleOptions}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
renderButton={renderButton}
|
||||
renderOption={renderOption}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable react/no-unused-state */
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
@@ -17,15 +16,15 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { RefObject, ReactNode, PureComponent } from 'react';
|
||||
import { RefObject, ReactNode, useCallback, memo } from 'react';
|
||||
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import cx from 'classnames';
|
||||
|
||||
interface HoverMenuProps {
|
||||
position: 'left' | 'top';
|
||||
innerRef: RefObject<HTMLDivElement>;
|
||||
children: ReactNode;
|
||||
position?: 'left' | 'top';
|
||||
innerRef?: RefObject<HTMLDivElement> | null;
|
||||
children?: ReactNode;
|
||||
onHover?: (data: { isHovered: boolean }) => void;
|
||||
}
|
||||
|
||||
@@ -66,45 +65,41 @@ const HoverStyleOverrides = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
export default class HoverMenu extends PureComponent<HoverMenuProps> {
|
||||
static defaultProps = {
|
||||
position: 'left',
|
||||
innerRef: null,
|
||||
children: null,
|
||||
};
|
||||
|
||||
handleMouseEnter = () => {
|
||||
const { onHover } = this.props;
|
||||
function HoverMenu({
|
||||
position = 'left',
|
||||
innerRef = null,
|
||||
children = null,
|
||||
onHover,
|
||||
}: HoverMenuProps) {
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
if (onHover) {
|
||||
onHover({ isHovered: true });
|
||||
}
|
||||
};
|
||||
}, [onHover]);
|
||||
|
||||
handleMouseLeave = () => {
|
||||
const { onHover } = this.props;
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
if (onHover) {
|
||||
onHover({ isHovered: false });
|
||||
}
|
||||
};
|
||||
}, [onHover]);
|
||||
|
||||
render() {
|
||||
const { innerRef, position, children } = this.props;
|
||||
return (
|
||||
<HoverStyleOverrides className="hover-menu-container">
|
||||
<div
|
||||
ref={innerRef}
|
||||
className={cx(
|
||||
'hover-menu',
|
||||
position === 'left' && 'hover-menu--left',
|
||||
position === 'top' && 'hover-menu--top',
|
||||
)}
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
data-test="hover-menu"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</HoverStyleOverrides>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<HoverStyleOverrides className="hover-menu-container">
|
||||
<div
|
||||
ref={innerRef}
|
||||
className={cx(
|
||||
'hover-menu',
|
||||
position === 'left' && 'hover-menu--left',
|
||||
position === 'top' && 'hover-menu--top',
|
||||
)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
data-test="hover-menu"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</HoverStyleOverrides>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(HoverMenu);
|
||||
|
||||
@@ -16,9 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { PureComponent } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
|
||||
import PopoverDropdown, {
|
||||
OnChangeHandler,
|
||||
} from '@superset-ui/core/components/PopoverDropdown';
|
||||
@@ -40,18 +38,18 @@ const dropdownOptions = [
|
||||
},
|
||||
];
|
||||
|
||||
export default class MarkdownModeDropdown extends PureComponent<MarkdownModeDropdownProps> {
|
||||
render() {
|
||||
const { id, value, onChange } = this.props;
|
||||
|
||||
return (
|
||||
<PopoverDropdown
|
||||
data-test="markdown-mode-dropdown"
|
||||
id={id}
|
||||
options={dropdownOptions}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
export default function MarkdownModeDropdown({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
}: MarkdownModeDropdownProps) {
|
||||
return (
|
||||
<PopoverDropdown
|
||||
data-test="markdown-mode-dropdown"
|
||||
id={id}
|
||||
options={dropdownOptions}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -106,7 +106,9 @@ test('should unfocus when another component is clicked', async () => {
|
||||
<WithPopoverMenu
|
||||
{...props}
|
||||
editMode
|
||||
shouldFocus={(event, container) => container?.contains(event.target)}
|
||||
shouldFocus={(event, container, _menuRef) =>
|
||||
container?.contains(event.target) ?? false
|
||||
}
|
||||
onChangeFocus={onChangeFocusA}
|
||||
>
|
||||
<div id="child-a" />
|
||||
@@ -117,7 +119,9 @@ test('should unfocus when another component is clicked', async () => {
|
||||
<WithPopoverMenu
|
||||
{...props}
|
||||
editMode
|
||||
shouldFocus={(event, container) => container?.contains(event.target)}
|
||||
shouldFocus={(event, container, _menuRef) =>
|
||||
container?.contains(event.target) ?? false
|
||||
}
|
||||
onChangeFocus={onChangeFocusB}
|
||||
>
|
||||
<div id="child-b" />
|
||||
|
||||
@@ -16,7 +16,15 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ReactNode, CSSProperties, PureComponent } from 'react';
|
||||
import {
|
||||
ReactNode,
|
||||
CSSProperties,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
memo,
|
||||
} from 'react';
|
||||
import cx from 'classnames';
|
||||
import { addAlpha } from '@superset-ui/core';
|
||||
import { css, styled } from '@apache-superset/core/theme';
|
||||
@@ -26,26 +34,32 @@ type ShouldFocusContainer = HTMLDivElement & {
|
||||
};
|
||||
|
||||
interface WithPopoverMenuProps {
|
||||
children: ReactNode;
|
||||
disableClick: boolean;
|
||||
menuItems: ReactNode[];
|
||||
onChangeFocus: (focus: boolean) => void;
|
||||
isFocused: boolean;
|
||||
// Event argument is left as "any" because of the clash. In defaultProps it seems
|
||||
children?: ReactNode;
|
||||
disableClick?: boolean;
|
||||
menuItems?: ReactNode[];
|
||||
onChangeFocus?: ((focus: boolean) => void) | null;
|
||||
isFocused?: boolean;
|
||||
// Event argument is left as "any" because of the clash. In props it seems
|
||||
// like it should be React.FocusEvent<>, however from handleClick() we can also
|
||||
// derive that type is EventListenerOrEventListenerObject.
|
||||
shouldFocus: (
|
||||
shouldFocus?: (
|
||||
event: any,
|
||||
container: ShouldFocusContainer,
|
||||
container: ShouldFocusContainer | null,
|
||||
menuRef: HTMLDivElement | null,
|
||||
) => boolean;
|
||||
editMode: boolean;
|
||||
style: CSSProperties;
|
||||
editMode?: boolean;
|
||||
style?: CSSProperties | null;
|
||||
}
|
||||
|
||||
interface WithPopoverMenuState {
|
||||
isFocused: boolean;
|
||||
}
|
||||
const defaultShouldFocus = (
|
||||
event: any,
|
||||
container: ShouldFocusContainer | null,
|
||||
menuRef: HTMLDivElement | null,
|
||||
): boolean => {
|
||||
if (container?.contains(event.target)) return true;
|
||||
if (menuRef?.contains(event.target)) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const WithPopoverMenuStyles = styled.div`
|
||||
${({ theme }) => css`
|
||||
@@ -104,151 +118,103 @@ const PopoverMenuStyles = styled.div`
|
||||
`}
|
||||
`;
|
||||
|
||||
export default class WithPopoverMenu extends PureComponent<
|
||||
WithPopoverMenuProps,
|
||||
WithPopoverMenuState
|
||||
> {
|
||||
container: ShouldFocusContainer;
|
||||
function WithPopoverMenu({
|
||||
children = null,
|
||||
disableClick = false,
|
||||
menuItems = [],
|
||||
onChangeFocus = null,
|
||||
isFocused: isFocusedProp = false,
|
||||
shouldFocus: shouldFocusFunc = defaultShouldFocus,
|
||||
editMode = false,
|
||||
style = null,
|
||||
}: WithPopoverMenuProps) {
|
||||
const [isFocused, setIsFocused] = useState(isFocusedProp);
|
||||
const containerRef = useRef<ShouldFocusContainer | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
// Tracks the native event that just triggered focus via the container's
|
||||
// onClick so the document-level listener (registered once focused) can
|
||||
// skip it. Without this, the same click bubbles to document after a
|
||||
// re-render has detached its event.target, causing shouldFocus to return
|
||||
// false and immediately undoing the focus.
|
||||
const focusEventRef = useRef<Event | null>(null);
|
||||
|
||||
menuRef: HTMLDivElement | null;
|
||||
const handleClick = useCallback(
|
||||
(event: any) => {
|
||||
if (!editMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
focusEvent: Event | null;
|
||||
const nativeEvent = event.nativeEvent || event;
|
||||
if (focusEventRef.current === nativeEvent) {
|
||||
focusEventRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
children: null,
|
||||
disableClick: false,
|
||||
onChangeFocus: null,
|
||||
menuItems: [],
|
||||
isFocused: false,
|
||||
shouldFocus: (
|
||||
event: any,
|
||||
container: ShouldFocusContainer,
|
||||
menuRef: HTMLDivElement | null,
|
||||
) => {
|
||||
if (container?.contains(event.target)) return true;
|
||||
if (menuRef?.contains(event.target)) return true;
|
||||
return false;
|
||||
const shouldFocusResult = shouldFocusFunc(
|
||||
event,
|
||||
containerRef.current,
|
||||
menuRef.current,
|
||||
);
|
||||
|
||||
if (shouldFocusResult === isFocused) return;
|
||||
|
||||
if (!disableClick && shouldFocusResult && !isFocused) {
|
||||
focusEventRef.current = nativeEvent;
|
||||
setIsFocused(true);
|
||||
if (onChangeFocus) onChangeFocus(true);
|
||||
} else if (!shouldFocusResult && isFocused) {
|
||||
setIsFocused(false);
|
||||
if (onChangeFocus) onChangeFocus(false);
|
||||
}
|
||||
},
|
||||
style: null,
|
||||
};
|
||||
[editMode, shouldFocusFunc, isFocused, disableClick, onChangeFocus],
|
||||
);
|
||||
|
||||
constructor(props: WithPopoverMenuProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
isFocused: props.isFocused!,
|
||||
// Handle prop-driven focus changes and add/remove document listeners
|
||||
useEffect(() => {
|
||||
if (editMode && isFocusedProp && !isFocused) {
|
||||
setIsFocused(true);
|
||||
} else if (isFocused && !editMode) {
|
||||
setIsFocused(false);
|
||||
}
|
||||
}, [editMode, isFocusedProp, isFocused]);
|
||||
|
||||
// Add/remove document event listeners based on focus state
|
||||
useEffect(() => {
|
||||
if (isFocused && editMode) {
|
||||
document.addEventListener('click', handleClick);
|
||||
document.addEventListener('drag', handleClick);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClick);
|
||||
document.removeEventListener('drag', handleClick);
|
||||
};
|
||||
this.menuRef = null;
|
||||
this.focusEvent = null;
|
||||
this.setRef = this.setRef.bind(this);
|
||||
this.setMenuRef = this.setMenuRef.bind(this);
|
||||
this.handleClick = this.handleClick.bind(this);
|
||||
}
|
||||
}, [isFocused, editMode, handleClick]);
|
||||
|
||||
componentDidUpdate(prevProps: WithPopoverMenuProps) {
|
||||
if (this.props.editMode && this.props.isFocused && !this.state.isFocused) {
|
||||
document.addEventListener('click', this.handleClick);
|
||||
document.addEventListener('drag', this.handleClick);
|
||||
this.setState({ isFocused: true });
|
||||
} else if (this.state.isFocused && !this.props.editMode) {
|
||||
document.removeEventListener('click', this.handleClick);
|
||||
document.removeEventListener('drag', this.handleClick);
|
||||
this.setState({ isFocused: false });
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('click', this.handleClick);
|
||||
document.removeEventListener('drag', this.handleClick);
|
||||
}
|
||||
|
||||
setRef(ref: ShouldFocusContainer) {
|
||||
this.container = ref;
|
||||
}
|
||||
|
||||
setMenuRef(ref: HTMLDivElement | null) {
|
||||
this.menuRef = ref;
|
||||
}
|
||||
|
||||
shouldHandleFocusChange(shouldFocus: boolean): boolean {
|
||||
const { disableClick } = this.props;
|
||||
const { isFocused } = this.state;
|
||||
|
||||
return (
|
||||
(!disableClick && shouldFocus && !isFocused) ||
|
||||
(!shouldFocus && isFocused)
|
||||
);
|
||||
}
|
||||
|
||||
handleClick(event: any) {
|
||||
if (!this.props.editMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if this is the same event that just triggered focus via onClick.
|
||||
// The document-level listener registered during focus will see the same
|
||||
// event bubble up; by that time a re-render may have detached the
|
||||
// original event.target, causing shouldFocus to return false and
|
||||
// immediately undoing the focus.
|
||||
const nativeEvent = event.nativeEvent || event;
|
||||
if (this.focusEvent === nativeEvent) {
|
||||
this.focusEvent = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
onChangeFocus,
|
||||
shouldFocus: shouldFocusFunc,
|
||||
disableClick,
|
||||
} = this.props;
|
||||
|
||||
const shouldFocus = shouldFocusFunc(event, this.container, this.menuRef);
|
||||
|
||||
if (shouldFocus === this.state.isFocused) return;
|
||||
|
||||
if (!disableClick && shouldFocus && !this.state.isFocused) {
|
||||
document.addEventListener('click', this.handleClick);
|
||||
document.addEventListener('drag', this.handleClick);
|
||||
this.focusEvent = event.nativeEvent || event;
|
||||
|
||||
this.setState(() => ({ isFocused: true }));
|
||||
|
||||
if (onChangeFocus) onChangeFocus(true);
|
||||
} else if (!shouldFocus && this.state.isFocused) {
|
||||
document.removeEventListener('click', this.handleClick);
|
||||
document.removeEventListener('drag', this.handleClick);
|
||||
|
||||
this.setState(() => ({ isFocused: false }));
|
||||
|
||||
if (onChangeFocus) onChangeFocus(false);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { children, menuItems, editMode, style } = this.props;
|
||||
const { isFocused } = this.state;
|
||||
|
||||
return (
|
||||
<WithPopoverMenuStyles
|
||||
ref={this.setRef}
|
||||
onClick={this.handleClick}
|
||||
role="none"
|
||||
className={cx(
|
||||
'with-popover-menu',
|
||||
editMode && isFocused && 'with-popover-menu--focused',
|
||||
)}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
{editMode && isFocused && (menuItems?.length ?? 0) > 0 && (
|
||||
<PopoverMenuStyles ref={this.setMenuRef}>
|
||||
{menuItems.map((node: ReactNode, i: number) => (
|
||||
<div className="menu-item" key={`menu-item-${i}`}>
|
||||
{node}
|
||||
</div>
|
||||
))}
|
||||
</PopoverMenuStyles>
|
||||
)}
|
||||
</WithPopoverMenuStyles>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<WithPopoverMenuStyles
|
||||
ref={containerRef}
|
||||
onClick={handleClick}
|
||||
role="none"
|
||||
className={cx(
|
||||
'with-popover-menu',
|
||||
editMode && isFocused && 'with-popover-menu--focused',
|
||||
)}
|
||||
style={style ?? undefined}
|
||||
>
|
||||
{children}
|
||||
{editMode && isFocused && menuItems?.some(Boolean) && (
|
||||
<PopoverMenuStyles ref={menuRef}>
|
||||
{menuItems.map((node: ReactNode, i: number) => (
|
||||
<div className="menu-item" key={`menu-item-${i}`}>
|
||||
{node}
|
||||
</div>
|
||||
))}
|
||||
</PopoverMenuStyles>
|
||||
)}
|
||||
</WithPopoverMenuStyles>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(WithPopoverMenu);
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen, waitFor } from 'spec/helpers/testing-library';
|
||||
import { Provider } from 'react-redux';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
import { NativeFilterType } from '@superset-ui/core';
|
||||
import type { Filter } from '@superset-ui/core';
|
||||
import FilterValue from './FilterValue';
|
||||
|
||||
const mockGetChartDataRequest = jest.fn();
|
||||
jest.mock('src/components/Chart/chartAction', () => ({
|
||||
getChartDataRequest: (...args: unknown[]) => mockGetChartDataRequest(...args),
|
||||
}));
|
||||
|
||||
jest.mock('src/middleware/asyncEvent', () => ({
|
||||
waitForAsyncData: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@superset-ui/core', () => {
|
||||
const original = jest.requireActual('@superset-ui/core');
|
||||
return {
|
||||
...original,
|
||||
getChartMetadataRegistry: () => ({
|
||||
get: () => ({ enableNoResults: false }),
|
||||
}),
|
||||
SuperChart: (props: Record<string, unknown>) => (
|
||||
<div data-test="mock-super-chart" data-chart-type={props.chartType}>
|
||||
SuperChart
|
||||
</div>
|
||||
),
|
||||
isFeatureEnabled: () => false,
|
||||
getClientErrorObject: (_err: unknown) =>
|
||||
Promise.resolve({
|
||||
message: 'Something went wrong',
|
||||
errors: [
|
||||
{ message: 'Test error', error_type: 'GENERIC_BACKEND_ERROR' },
|
||||
],
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../useFilterOutlined', () => ({
|
||||
useFilterOutlined: () => ({
|
||||
outlinedFilterId: undefined,
|
||||
lastUpdated: 0,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockUseFilterDependencies = jest.fn().mockReturnValue({});
|
||||
const mockUseTransitiveParentIds = jest.fn().mockReturnValue([]);
|
||||
jest.mock('./state', () => ({
|
||||
useFilterDependencies: (...args: unknown[]) =>
|
||||
mockUseFilterDependencies(...args),
|
||||
useTransitiveParentIds: (...args: unknown[]) =>
|
||||
mockUseTransitiveParentIds(...args),
|
||||
}));
|
||||
|
||||
const mockStore = configureStore([thunk]);
|
||||
|
||||
const createMockFilter = (overrides: Partial<Filter> = {}): Filter => ({
|
||||
id: 'NATIVE_FILTER-1',
|
||||
name: 'Test Filter',
|
||||
filterType: 'filter_select',
|
||||
targets: [{ datasetId: 1, column: { name: 'country' } }],
|
||||
defaultDataMask: {},
|
||||
controlValues: {},
|
||||
cascadeParentIds: [],
|
||||
scope: { rootPath: ['ROOT_ID'], excluded: [] },
|
||||
type: NativeFilterType.NativeFilter,
|
||||
description: 'Test filter description',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const getDefaultStoreState = () => ({
|
||||
dashboardInfo: { id: 1 },
|
||||
dashboardState: {
|
||||
isRefreshing: false,
|
||||
isFiltersRefreshing: false,
|
||||
directPathToChild: [],
|
||||
directPathLastUpdated: 0,
|
||||
},
|
||||
nativeFilters: {
|
||||
filters: {
|
||||
'NATIVE_FILTER-1': createMockFilter(),
|
||||
},
|
||||
filterSets: {},
|
||||
},
|
||||
dataMask: {},
|
||||
charts: {},
|
||||
dashboardLayout: { present: {} },
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
filter: createMockFilter(),
|
||||
dataMaskSelected: {},
|
||||
onFilterSelectionChange: jest.fn(),
|
||||
inView: true,
|
||||
};
|
||||
|
||||
function renderFilterValue(
|
||||
propOverrides: Record<string, unknown> = {},
|
||||
stateOverrides: Record<string, unknown> = {},
|
||||
) {
|
||||
const state = { ...getDefaultStoreState(), ...stateOverrides };
|
||||
const store = mockStore(state);
|
||||
const mergedProps = { ...defaultProps, ...propOverrides };
|
||||
return render(
|
||||
<Provider store={store}>
|
||||
<FilterValue {...(mergedProps as typeof defaultProps)} />
|
||||
</Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('renders loading spinner when filter has a data source', () => {
|
||||
mockGetChartDataRequest.mockReturnValue(new Promise(() => {}));
|
||||
|
||||
renderFilterValue();
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('mock-super-chart')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders SuperChart after data loads successfully', async () => {
|
||||
mockGetChartDataRequest.mockResolvedValue({
|
||||
response: { status: 200 },
|
||||
json: { result: [{ data: [{ country: 'US' }] }] },
|
||||
});
|
||||
|
||||
renderFilterValue();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('mock-super-chart')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders error state when API call fails', async () => {
|
||||
mockGetChartDataRequest.mockRejectedValue(
|
||||
new Response(JSON.stringify({ message: 'Server Error' }), { status: 500 }),
|
||||
);
|
||||
|
||||
renderFilterValue();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// No ErrorMessageComponent is registered for GENERIC_BACKEND_ERROR in the
|
||||
// test environment, so FilterValue renders its fallback ErrorAlert.
|
||||
expect(await screen.findByText('Network error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not fetch data when filter has not been in view', () => {
|
||||
renderFilterValue({ inView: false });
|
||||
|
||||
expect(mockGetChartDataRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('does not render loading spinner when filter has no data source', () => {
|
||||
const filterWithoutDataSource = createMockFilter({
|
||||
targets: [{ column: { name: 'country' } }],
|
||||
});
|
||||
mockGetChartDataRequest.mockReturnValue(new Promise(() => {}));
|
||||
|
||||
renderFilterValue({ filter: filterWithoutDataSource });
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('mock-super-chart')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('skips data fetch when cascade parent filters have no values selected', () => {
|
||||
// useFilterDependencies returns dependencies with a filter (from parent defaults),
|
||||
// but dataMaskSelected has no extraFormData for the parent -- counts disagree, so
|
||||
// the component skips the fetch.
|
||||
mockUseFilterDependencies.mockReturnValue({
|
||||
filters: [{ col: 'region', op: 'IN', val: ['US'] }],
|
||||
});
|
||||
mockUseTransitiveParentIds.mockReturnValue(['NATIVE_FILTER-PARENT']);
|
||||
|
||||
const childFilter = createMockFilter({
|
||||
id: 'NATIVE_FILTER-CHILD',
|
||||
cascadeParentIds: ['NATIVE_FILTER-PARENT'],
|
||||
});
|
||||
|
||||
const stateWithParent = {
|
||||
nativeFilters: {
|
||||
filters: {
|
||||
'NATIVE_FILTER-CHILD': childFilter,
|
||||
'NATIVE_FILTER-PARENT': createMockFilter({
|
||||
id: 'NATIVE_FILTER-PARENT',
|
||||
}),
|
||||
},
|
||||
filterSets: {},
|
||||
},
|
||||
};
|
||||
|
||||
renderFilterValue(
|
||||
{
|
||||
filter: childFilter,
|
||||
dataMaskSelected: {},
|
||||
},
|
||||
stateWithParent,
|
||||
);
|
||||
|
||||
expect(mockGetChartDataRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -41,7 +41,8 @@ import {
|
||||
getClientErrorObject,
|
||||
isChartCustomization,
|
||||
} from '@superset-ui/core';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { styled, SupersetTheme } from '@apache-superset/core/theme';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { isEqual, isEqualWith } from 'lodash';
|
||||
import { getChartDataRequest } from 'src/components/Chart/chartAction';
|
||||
@@ -141,6 +142,7 @@ const FilterValue: FC<FilterValueProps> = ({
|
||||
clearAllTrigger,
|
||||
onClearAllComplete,
|
||||
}) => {
|
||||
const theme = useTheme() as SupersetTheme;
|
||||
const { id, targets, filterType } = filter;
|
||||
const isCustomization = isChartCustomization(filter);
|
||||
const allowedTimeGrains = isCustomization
|
||||
@@ -487,6 +489,7 @@ const FilterValue: FC<FilterValueProps> = ({
|
||||
enableNoResults={metadata?.enableNoResults}
|
||||
isRefreshing={isRefreshing}
|
||||
hooks={hooks}
|
||||
theme={theme}
|
||||
/>
|
||||
)}
|
||||
</StyledDiv>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 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 { LabeledValue } from '@superset-ui/core/components';
|
||||
import { createLabelSortComparator } from './GroupByFilterCard';
|
||||
|
||||
const apple: LabeledValue = { value: 'a', label: 'Apple' };
|
||||
const banana: LabeledValue = { value: 'b', label: 'Banana' };
|
||||
|
||||
test('sorts display values A-Z when sortAscending is true', () => {
|
||||
const compare = createLabelSortComparator(true);
|
||||
expect(compare(apple, banana)).toBeLessThan(0);
|
||||
expect(compare(banana, apple)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('sorts display values Z-A when sortAscending is false', () => {
|
||||
const compare = createLabelSortComparator(false);
|
||||
expect(compare(apple, banana)).toBeGreaterThan(0);
|
||||
expect(compare(banana, apple)).toBeLessThan(0);
|
||||
});
|
||||
|
||||
test('preserves source order when sortAscending is unset', () => {
|
||||
const compare = createLabelSortComparator(undefined);
|
||||
expect(compare(apple, banana)).toBe(0);
|
||||
expect(compare(banana, apple)).toBe(0);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user