Compare commits
86 Commits
databricks
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fb0e7eca8 | ||
|
|
be0bb512b9 | ||
|
|
d678d6b8ad | ||
|
|
76bb82c293 | ||
|
|
bb1a8e28e1 | ||
|
|
71aa3de941 | ||
|
|
7ceb7ca64c | ||
|
|
5c98700559 | ||
|
|
0501796837 | ||
|
|
181422fc60 | ||
|
|
10ca640e97 | ||
|
|
5aec4342bb | ||
|
|
4bce27f12f | ||
|
|
be0631858c | ||
|
|
241d121015 | ||
|
|
5e8bbc0eb4 | ||
|
|
0034601c06 | ||
|
|
5be35992c5 | ||
|
|
5085570fb4 | ||
|
|
a44ab4d928 | ||
|
|
d1f903c66f | ||
|
|
d65e9fba9c | ||
|
|
a9342fa02a | ||
|
|
83731ce7b3 | ||
|
|
02cb1f1a28 | ||
|
|
1e08d61204 | ||
|
|
3bd6e103c1 | ||
|
|
599026a0e5 | ||
|
|
96d31ee3e3 | ||
|
|
7b716b1838 | ||
|
|
855ca2228c | ||
|
|
f5487f04a3 | ||
|
|
defcd63910 | ||
|
|
1c7d1f049b | ||
|
|
e04d38a85f | ||
|
|
e357014559 | ||
|
|
f5d663c480 | ||
|
|
9ad6917600 | ||
|
|
732248e475 | ||
|
|
25cb6fc02d | ||
|
|
613033b294 | ||
|
|
2ac6e2468d | ||
|
|
45d76f6aba | ||
|
|
92a3b7d7e3 | ||
|
|
ad7fcac11f | ||
|
|
687dcbc63e | ||
|
|
b4cc82b766 | ||
|
|
0ad50dc77e | ||
|
|
d4370cd46e | ||
|
|
8141d666d6 | ||
|
|
390bf299ff | ||
|
|
3b355b6a85 | ||
|
|
082961ba16 | ||
|
|
298aa2f0ba | ||
|
|
775f7c5a1f | ||
|
|
d7a918b814 | ||
|
|
a071d23b41 | ||
|
|
c0c688d3e4 | ||
|
|
7c7b3adf74 | ||
|
|
6323b37f50 | ||
|
|
838376dbc2 | ||
|
|
02dbfd2ab5 | ||
|
|
689ab4a77a | ||
|
|
f8f293d266 | ||
|
|
3e80a53e14 | ||
|
|
4290d94d6c | ||
|
|
4b180bddad | ||
|
|
6c9138a6b7 | ||
|
|
d1f395914f | ||
|
|
6403e16fe5 | ||
|
|
7fe03a536b | ||
|
|
3b4e5f5d0c | ||
|
|
c14ac29f47 | ||
|
|
61fffbe0c0 | ||
|
|
9612437a9c | ||
|
|
7a9fe06762 | ||
|
|
350dd0d60e | ||
|
|
ea1ffcf0ad | ||
|
|
50bbcedf13 | ||
|
|
269b9f99fe | ||
|
|
0f229fda75 | ||
|
|
ca7cdfcd0f | ||
|
|
add97e5d19 | ||
|
|
7dd70eaf2d | ||
|
|
48795f9a2d | ||
|
|
9413294b2b |
92
.github/dependabot.yml
vendored
92
.github/dependabot.yml
vendored
@@ -9,6 +9,9 @@ updates:
|
||||
security:
|
||||
applies-to: "security-updates"
|
||||
patterns: ["*"]
|
||||
codeql:
|
||||
patterns:
|
||||
- github/codeql-action/*
|
||||
cooldown:
|
||||
default-days: 7
|
||||
|
||||
@@ -49,7 +52,11 @@ updates:
|
||||
# hold comments). Remove this once the proxy code is updated to await
|
||||
# the async decompress() API.
|
||||
- dependency-name: "simple-zstd"
|
||||
directory: "/superset-frontend/"
|
||||
directories:
|
||||
- "/superset-frontend"
|
||||
- "/superset-embedded-sdk"
|
||||
- "/superset-websocket"
|
||||
- "/docs"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
labels:
|
||||
@@ -66,6 +73,11 @@ updates:
|
||||
patterns:
|
||||
- "@typescript-eslint/*"
|
||||
- "typescript-eslint"
|
||||
jest:
|
||||
patterns:
|
||||
- "jest"
|
||||
- "babel-jest"
|
||||
- "jest-environment-jsdom"
|
||||
babel:
|
||||
patterns:
|
||||
- "@babel/*"
|
||||
@@ -95,7 +107,22 @@ updates:
|
||||
fontsource:
|
||||
patterns:
|
||||
- "@fontsource/*"
|
||||
open-pull-requests-limit: 30
|
||||
docusaurus:
|
||||
patterns:
|
||||
- "@docusaurus/*"
|
||||
docusaurus-openapi:
|
||||
patterns:
|
||||
- "docusaurus-plugin-openapi-docs"
|
||||
- "docusaurus-theme-openapi-docs"
|
||||
monorepo-dependencies:
|
||||
group-by: dependency-name
|
||||
patterns:
|
||||
- "webpack"
|
||||
- "@types/node"
|
||||
- "oxfmt"
|
||||
- "oxlint"
|
||||
- "oxlint-tsgolint"
|
||||
open-pull-requests-limit: 10
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 7
|
||||
@@ -133,49 +160,6 @@ updates:
|
||||
cooldown:
|
||||
default-days: 7
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/docs/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
groups:
|
||||
security:
|
||||
applies-to: "security-updates"
|
||||
patterns: ["*"]
|
||||
storybook:
|
||||
patterns:
|
||||
- "@storybook/*"
|
||||
- "storybook"
|
||||
docusaurus:
|
||||
patterns:
|
||||
- "@docusaurus/*"
|
||||
docusaurus-openapi:
|
||||
patterns:
|
||||
- "docusaurus-plugin-openapi-docs"
|
||||
- "docusaurus-theme-openapi-docs"
|
||||
typescript-eslint:
|
||||
patterns:
|
||||
- "@typescript-eslint/*"
|
||||
- "typescript-eslint"
|
||||
open-pull-requests-limit: 10
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 7
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-websocket/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
labels:
|
||||
- npm
|
||||
- dependabot
|
||||
groups:
|
||||
security:
|
||||
applies-to: "security-updates"
|
||||
patterns: ["*"]
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 7
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-websocket/utils/client-ws-app/"
|
||||
schedule:
|
||||
@@ -191,21 +175,3 @@ updates:
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 7
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-embedded-sdk/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
labels:
|
||||
- npm
|
||||
- dependabot
|
||||
groups:
|
||||
security:
|
||||
applies-to: "security-updates"
|
||||
patterns: ["*"]
|
||||
babel:
|
||||
patterns:
|
||||
- "@babel/*"
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 7
|
||||
|
||||
2
.github/workflows/asf-allowlist-check.yml
vendored
2
.github/workflows/asf-allowlist-check.yml
vendored
@@ -40,7 +40,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check action refs against the ASF allowlist
|
||||
uses: apache/infrastructure-actions/allowlist-check@60e90f7a665a68248f145b8b2c97ed712132495b # allowlist-check/v1.0.3
|
||||
uses: apache/infrastructure-actions/allowlist-check@47b297bbe90f580139129bee531bd84bd95b7f5d # allowlist-check/v1.0.4
|
||||
with:
|
||||
# Default scan-glob is .github/**/*.yml, which misses .yaml files.
|
||||
scan-glob: ".github/**/*.y*ml"
|
||||
|
||||
6
.github/workflows/bashlib.sh
vendored
6
.github/workflows/bashlib.sh
vendored
@@ -352,14 +352,10 @@ playwright-run() {
|
||||
return 0
|
||||
fi
|
||||
echo "Running tests: ${TEST_PATH}"
|
||||
# Set INCLUDE_EXPERIMENTAL=true to allow experimental tests to run
|
||||
export INCLUDE_EXPERIMENTAL=true
|
||||
npx playwright test "${TEST_PATH}" --output=playwright-results
|
||||
local status=$?
|
||||
# Unset to prevent leaking into subsequent commands
|
||||
unset INCLUDE_EXPERIMENTAL
|
||||
else
|
||||
echo "Running all required tests (experimental/ excluded via playwright.config.ts)"
|
||||
echo "Running all default-project tests"
|
||||
npx playwright test --output=playwright-results
|
||||
local status=$?
|
||||
fi
|
||||
|
||||
4
.github/workflows/codeql-analysis.yml
vendored
4
.github/workflows/codeql-analysis.yml
vendored
@@ -67,7 +67,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
||||
uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -78,6 +78,6 @@ jobs:
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
||||
uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
2
.github/workflows/docker.yml
vendored
2
.github/workflows/docker.yml
vendored
@@ -482,7 +482,7 @@ jobs:
|
||||
contains(fromJson('["superset", "lean"]'), matrix.build_preset) &&
|
||||
steps.grype-scan.outputs.sarif != ''
|
||||
continue-on-error: true
|
||||
uses: github/codeql-action/upload-sarif@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
||||
uses: github/codeql-action/upload-sarif@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
|
||||
with:
|
||||
sarif_file: ${{ steps.grype-scan.outputs.sarif }}
|
||||
# Both `superset` and `lean` are scanned now; without a distinct
|
||||
|
||||
@@ -100,7 +100,7 @@ jobs:
|
||||
# No PR to comment on here, so comment-on-alert is off -- the job
|
||||
# summary (summary-always) is the only surface for this run.
|
||||
- name: Update bundle size baseline
|
||||
uses: benchmark-action/github-action-benchmark@52576c92bccf6ac60c8223ec7eb2565637cae9ba # v1.22.1
|
||||
uses: benchmark-action/github-action-benchmark@4322e5726e6334590d251fc4f92bec0efafc45dc # v1.22.2
|
||||
with:
|
||||
tool: customSmallerIsBetter
|
||||
output-file-path: bundle-size-summary.json
|
||||
|
||||
22
.github/workflows/superset-e2e.yml
vendored
22
.github/workflows/superset-e2e.yml
vendored
@@ -204,9 +204,8 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.python == 'true' || needs.changes.outputs.frontend == 'true'
|
||||
runs-on: ubuntu-26.04
|
||||
# Embedded Tests below adds its own gunicorn boot + spec run on top of
|
||||
# Required and Soft-delete; 30m was tight even for the two-step shadow
|
||||
# job this replaced.
|
||||
# Soft-delete, Embedded and Mobile each add their own gunicorn boot +
|
||||
# spec run on top of the Required step.
|
||||
timeout-minutes: 40
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -381,6 +380,23 @@ jobs:
|
||||
INCLUDE_EMBEDDED: "true"
|
||||
with:
|
||||
run: playwright-run "${{ matrix.app_root }}" embedded
|
||||
- name: Run Playwright (Mobile Tests)
|
||||
# cached-dependencies is a submodule (not a plain directory), and
|
||||
# the $/ self-repository syntax resolves action files directly from
|
||||
# the repository without performing a real (submodule-aware)
|
||||
# checkout, so it can't see into a submodule's link. Keep this one
|
||||
# on the workspace-relative ./ form, consistent with every other
|
||||
# workflow in the repo that references this action.
|
||||
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
|
||||
env:
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
# Scoped to this step for the same reason as Soft-delete and
|
||||
# Embedded above: the mobile consumption mode flag must not alter
|
||||
# Flask's configuration for the other steps' servers.
|
||||
SUPERSET_FEATURE_MOBILE_CONSUMPTION_MODE: "true"
|
||||
INCLUDE_MOBILE: "true"
|
||||
with:
|
||||
run: playwright-run "${{ matrix.app_root }}" mobile/
|
||||
- name: Set safe app root
|
||||
if: failure()
|
||||
id: set-safe-app-root
|
||||
|
||||
2
.github/workflows/superset-frontend.yml
vendored
2
.github/workflows/superset-frontend.yml
vendored
@@ -300,7 +300,7 @@ jobs:
|
||||
# failure) -- it propagates and fails the job outright. Fork PRs
|
||||
# still get the comparison via the job summary (summary-always).
|
||||
- name: Compare bundle size against nightly baseline
|
||||
uses: benchmark-action/github-action-benchmark@52576c92bccf6ac60c8223ec7eb2565637cae9ba # v1.22.1
|
||||
uses: benchmark-action/github-action-benchmark@4322e5726e6334590d251fc4f92bec0efafc45dc # v1.22.2
|
||||
with:
|
||||
tool: customSmallerIsBetter
|
||||
output-file-path: bundle-size-summary.json
|
||||
|
||||
222
.github/workflows/superset-playwright.yml
vendored
222
.github/workflows/superset-playwright.yml
vendored
@@ -1,222 +0,0 @@
|
||||
name: Playwright Experimental Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "master"
|
||||
- "[0-9].[0-9]*"
|
||||
pull_request:
|
||||
types: [synchronize, opened, reopened, ready_for_review]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: "The branch or tag to checkout"
|
||||
required: false
|
||||
default: ""
|
||||
pr_id:
|
||||
description: "The pull request ID to checkout"
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-26.04
|
||||
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: $/.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# NOTE: Required Playwright tests are in superset-e2e.yml (E2E / playwright-tests),
|
||||
# including Embedded — it moved out of this workflow because embedding is a
|
||||
# required feature, not an experimental one. This workflow now contains
|
||||
# only experimental and mobile tests, which run in shadow mode.
|
||||
playwright-tests-experimental:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.python == 'true' || needs.changes.outputs.frontend == 'true'
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 30
|
||||
continue-on-error: true
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
browser: ["chromium"]
|
||||
app_root: ["", "/app/prefix"]
|
||||
env:
|
||||
SUPERSET_ENV: development
|
||||
SUPERSET_CONFIG: tests.integration_tests.superset_test_config
|
||||
SUPERSET__SQLALCHEMY_DATABASE_URI: postgresql+psycopg2://superset:superset@127.0.0.1:15432/superset
|
||||
PYTHONPATH: ${{ github.workspace }}
|
||||
REDIS_PORT: 16379
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: superset
|
||||
POSTGRES_PASSWORD: superset
|
||||
ports:
|
||||
- 15432:5432
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- 16379:6379
|
||||
steps:
|
||||
# -------------------------------------------------------
|
||||
# Conditional checkout based on context (same as Cypress workflow)
|
||||
- name: Checkout for push or pull_request event
|
||||
if: github.event_name == 'push' || github.event_name == 'pull_request'
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
- name: Checkout using ref (workflow_dispatch)
|
||||
if: github.event_name == 'workflow_dispatch' && github.event.inputs.ref != ''
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event.inputs.ref }}
|
||||
submodules: recursive
|
||||
- name: Checkout using PR ID (workflow_dispatch)
|
||||
if: github.event_name == 'workflow_dispatch' && github.event.inputs.pr_id != ''
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: refs/pull/${{ github.event.inputs.pr_id }}/merge
|
||||
submodules: recursive
|
||||
# -------------------------------------------------------
|
||||
- name: Setup Python
|
||||
uses: $/.github/actions/setup-backend/
|
||||
- name: Setup postgres
|
||||
# cached-dependencies is a submodule (not a plain directory), and
|
||||
# the $/ self-repository syntax resolves action files directly from
|
||||
# the repository without performing a real (submodule-aware)
|
||||
# checkout, so it can't see into a submodule's link. Keep this one
|
||||
# on the workspace-relative ./ form, consistent with every other
|
||||
# workflow in the repo that references this action.
|
||||
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
|
||||
with:
|
||||
run: setup-postgres
|
||||
- name: Import test data
|
||||
# cached-dependencies is a submodule (not a plain directory), and
|
||||
# the $/ self-repository syntax resolves action files directly from
|
||||
# the repository without performing a real (submodule-aware)
|
||||
# checkout, so it can't see into a submodule's link. Keep this one
|
||||
# on the workspace-relative ./ form, consistent with every other
|
||||
# workflow in the repo that references this action.
|
||||
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
|
||||
with:
|
||||
run: playwright_testdata
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version-file: "./superset-frontend/.nvmrc"
|
||||
cache: "npm"
|
||||
cache-dependency-path: "superset-frontend/package-lock.json"
|
||||
- name: Install npm dependencies
|
||||
# cached-dependencies is a submodule (not a plain directory), and
|
||||
# the $/ self-repository syntax resolves action files directly from
|
||||
# the repository without performing a real (submodule-aware)
|
||||
# checkout, so it can't see into a submodule's link. Keep this one
|
||||
# on the workspace-relative ./ form, consistent with every other
|
||||
# workflow in the repo that references this action.
|
||||
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
|
||||
with:
|
||||
run: npm-install
|
||||
- name: Build javascript packages
|
||||
# cached-dependencies is a submodule (not a plain directory), and
|
||||
# the $/ self-repository syntax resolves action files directly from
|
||||
# the repository without performing a real (submodule-aware)
|
||||
# checkout, so it can't see into a submodule's link. Keep this one
|
||||
# on the workspace-relative ./ form, consistent with every other
|
||||
# workflow in the repo that references this action.
|
||||
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
|
||||
with:
|
||||
run: build-instrumented-assets
|
||||
- name: Install Playwright
|
||||
# the $/ self-repository syntax resolves action files directly from
|
||||
# the repository without performing a real (submodule-aware)
|
||||
# checkout, so it can't see into a submodule's gitlink. Keep this one
|
||||
# on the workspace-relative ./ form, consistent with every other
|
||||
# workflow in the repo that references this action.
|
||||
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
|
||||
with:
|
||||
run: playwright-install
|
||||
- name: Run Playwright (Experimental Tests)
|
||||
# cached-dependencies is a submodule (not a plain directory), and
|
||||
# the $/ self-repository syntax resolves action files directly from
|
||||
# the repository without performing a real (submodule-aware)
|
||||
# checkout, so it can't see into a submodule's link. Keep this one
|
||||
# on the workspace-relative ./ form, consistent with every other
|
||||
# workflow in the repo that references this action.
|
||||
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
|
||||
env:
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
with:
|
||||
run: playwright-run "${{ matrix.app_root }}" experimental/
|
||||
- name: Run Playwright (Mobile Tests)
|
||||
# cached-dependencies is a submodule (not a plain directory), and
|
||||
# the $/ self-repository syntax resolves action files directly from
|
||||
# the repository without performing a real (submodule-aware)
|
||||
# checkout, so it can't see into a submodule's link. Keep this one
|
||||
# on the workspace-relative ./ form, consistent with every other
|
||||
# workflow in the repo that references this action.
|
||||
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
|
||||
env:
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
# Scoped to this step: setting feature flags at the job level would
|
||||
# alter Flask's configuration for the preceding Experimental step
|
||||
# too — the mobile consumption mode should not do that.
|
||||
SUPERSET_FEATURE_MOBILE_CONSUMPTION_MODE: "true"
|
||||
INCLUDE_MOBILE: "true"
|
||||
with:
|
||||
run: playwright-run "${{ matrix.app_root }}" mobile/
|
||||
- name: Set safe app root
|
||||
if: failure()
|
||||
id: set-safe-app-root
|
||||
run: |
|
||||
APP_ROOT="${{ matrix.app_root }}"
|
||||
SAFE_APP_ROOT=${APP_ROOT//\//_}
|
||||
echo "safe_app_root=$SAFE_APP_ROOT" >> $GITHUB_OUTPUT
|
||||
- name: Upload Playwright Artifacts
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
if: failure()
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/superset-frontend/playwright-results/
|
||||
${{ github.workspace }}/superset-frontend/test-results/
|
||||
name: playwright-experimental-artifact-${{ github.run_id }}-${{ github.job }}-${{ matrix.browser }}--${{ steps.set-safe-app-root.outputs.safe_app_root }}
|
||||
|
||||
actions-timeline:
|
||||
needs: playwright-tests-experimental
|
||||
if: always()
|
||||
runs-on: ubuntu-slim
|
||||
permissions:
|
||||
actions: read
|
||||
steps:
|
||||
- uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0
|
||||
with:
|
||||
expand-composite-actions: true
|
||||
101
UPDATING.md
101
UPDATING.md
@@ -24,6 +24,19 @@ assists people when migrating to a new version.
|
||||
|
||||
## Next
|
||||
|
||||
### Guest token RLS rules without a dataset apply inside sub-queries
|
||||
|
||||
A guest token RLS rule with no `dataset` key applies to every dataset. Such
|
||||
rules are now also injected into sub-queries of custom SQL expressions (with
|
||||
`ALLOW_ADHOC_SUBQUERY` enabled) and into SQL Lab queries, for every table that
|
||||
resolves to a dataset, instead of only into the chart's outer query. A virtual
|
||||
dataset's inner SQL is unchanged, since its outer query already applies them.
|
||||
|
||||
If a sub-query reads a dataset that lacks a column the rule references, the
|
||||
query now fails with a column-not-found error instead of reading rows the rule
|
||||
was meant to exclude. To keep such charts working, set `dataset` on the rule so
|
||||
it only targets the datasets that have the column.
|
||||
|
||||
### MCP response size guard: byte limit instead of estimated token count
|
||||
|
||||
The MCP response-size guard no longer estimates LLM token counts (it
|
||||
@@ -87,6 +100,8 @@ error notice. This does not authorize replay of data-bearing notifications.
|
||||
its Presto dialect under SQLAlchemy 2 because it imports `sqlalchemy.databases`.
|
||||
Upgrade existing installations with `pip install "pyhive[presto]>=0.7.0"`.
|
||||
|
||||
- The Pinot extra requires pinotdb[sqlalchemy]>=8.0.0,<10.0.0. Earlier releases declare SQLAlchemy below 2 in their SQLAlchemy extra.
|
||||
|
||||
- The Databricks extra requires databricks-sqlalchemy 2.x (at least 2.0.1). The 1.x dialect requires SQLAlchemy below 2.
|
||||
|
||||
- `superset deletion-retention force-purge` now exits **1** when the target is
|
||||
@@ -94,6 +109,34 @@ error notice. This does not authorize replay of data-bearing notifications.
|
||||
scripted compliance erasure cannot mistake a refusal for a completed purge.
|
||||
Only a completed purge exits 0; a usage error still exits 2.
|
||||
|
||||
### Deprecated permission cleanup may change custom role grants
|
||||
|
||||
Two migrations now clean up permissions deprecated in past releases that
|
||||
previously stuck around forever after an upgrade (#33272). If a custom role
|
||||
holds one of these permissions, upgrading will either:
|
||||
|
||||
- **Delete it outright**, for permissions whose underlying feature has no
|
||||
live equivalent (e.g. the access-request workflow), or whose only live
|
||||
successor would grant a role a materially broader capability than it ever
|
||||
had -- for example `can_testconn` is deleted rather than resurrected as
|
||||
`Database.can_write`, which would let the role create, edit, or delete any
|
||||
database connection, not just test one; or
|
||||
- **Migrate it to a verified live successor** (e.g. `can_explore_json` ->
|
||||
`can_read` on Chart), preserving the role's effective access.
|
||||
|
||||
`can_copy_dash` is the exception to this rule. Although its live successor,
|
||||
`Dashboard.can_write`, is broader than the historical permission, the current
|
||||
dashboard-copy endpoint is itself authorized by `Dashboard.can_write`. It is
|
||||
therefore migrated rather than deleted so existing access to dashboard
|
||||
copying is preserved.
|
||||
|
||||
If a custom role in your deployment relies on one of the deleted
|
||||
permissions, re-grant the appropriate live permission to it manually after
|
||||
upgrading. See the two migrations' docstrings (`superset/migrations/versions/
|
||||
2026-09-10_00-00_1f5f4fb8bfc1_delete_deprecated_permissions_33272.py` and
|
||||
`..._00-01_3ce9a4572f8a_rename_deprecated_permissions_33272.py`) for the full
|
||||
per-permission mapping and reasoning.
|
||||
|
||||
### MySQL metadata database now actually defaults to READ COMMITTED
|
||||
|
||||
Superset has always *intended* to default the metadata-database isolation
|
||||
@@ -169,6 +212,16 @@ Scheduled report and alert captures require chart readiness to remain stable
|
||||
immediately before Chromium captures the image. A capture that re-enters a loading
|
||||
state during that window fails instead of delivering a screenshot with spinners.
|
||||
|
||||
### Improve Db2 Time Grain Expressions
|
||||
The Db2 engine spec has been streamlined by using the DATE_TRUNC scalar function,
|
||||
which requires Db2 11.1.0 or higher. Per the ISO 8601 standards, the `WEEK` time
|
||||
grain now shifts the first day of the week to Monday as part of this change.
|
||||
|
||||
### Update IBM Db2 for i Time Grain Expressions
|
||||
IBM Db2 for i inherits its engine spec from Db2 but does not support the DATE_TRUNC
|
||||
scalar function, so it will use the previous arithmetic expressions defined for Db2.
|
||||
Its `WEEK` time grain now uses `DAYOFWEEK_ISO` to align with the Db2 change.
|
||||
|
||||
### Scheduled rendered reports fail closed after capture rejection
|
||||
|
||||
Scheduled PDF and PNG delivery requires an accepted report capture context. A
|
||||
@@ -873,19 +926,51 @@ Note that a retried query returns partial data with no truncation indicator
|
||||
(e.g. a filter dropdown may list only a subset of values on tables above the
|
||||
row cap).
|
||||
|
||||
### Dashboard "Export Data to Excel" requires a Celery worker and S3 bucket
|
||||
### Dashboard "Export Data to Excel" moves from `EXCEL_EXPORT_S3_*` to `EXPORT_STORAGE`
|
||||
|
||||
A new dashboard action exports every chart's data to a single multi-sheet
|
||||
`.xlsx` asynchronously. It is disabled by default and turns on only when
|
||||
`EXCEL_EXPORT_S3_BUCKET` is set (the endpoint returns `501` otherwise). It also
|
||||
requires a running Celery worker and a configured SMTP transport, since the task
|
||||
emails the requesting user a pre-signed download link. New config keys:
|
||||
`EXCEL_EXPORT_S3_BUCKET`, `EXCEL_EXPORT_S3_KEY_PREFIX`,
|
||||
`EXCEL_EXPORT_LINK_TTL_SECONDS`, `EXCEL_EXPORT_S3_CLIENT_KWARGS`,
|
||||
`EXPORT_STORAGE` is configured with both a `bucket` and a `backend` (the
|
||||
endpoint returns `501` otherwise) — there is no implicit storage default:
|
||||
|
||||
```python
|
||||
from superset.utils.s3 import S3ExportStorage # or superset.utils.gcs.GCSExportStorage
|
||||
|
||||
EXPORT_STORAGE = {
|
||||
"bucket": "my-export-bucket",
|
||||
"backend": S3ExportStorage(),
|
||||
}
|
||||
```
|
||||
|
||||
**Upgrading from `EXCEL_EXPORT_S3_*`:** the S3-only config keys are removed and
|
||||
replaced by the pluggable `EXPORT_STORAGE` above. They are no longer read, so a
|
||||
deployment that had the export working keeps a valid-looking config while the
|
||||
endpoint starts returning `501`. Port each key:
|
||||
|
||||
| Removed | Replacement |
|
||||
| --- | --- |
|
||||
| `EXCEL_EXPORT_S3_BUCKET = "my-bucket"` | `EXPORT_STORAGE["bucket"] = "my-bucket"` |
|
||||
| `EXCEL_EXPORT_S3_KEY_PREFIX = "prefix/"` | `EXPORT_STORAGE["key_prefix"] = "prefix/"` |
|
||||
| `EXCEL_EXPORT_S3_CLIENT_KWARGS = {...}` | `EXPORT_STORAGE["backend"] = S3ExportStorage(client_kwargs={...})` |
|
||||
|
||||
`EXPORT_STORAGE["backend"]` has no default and must be set explicitly, which is
|
||||
the part an upgrade cannot infer: the previous config implied S3, so keep the
|
||||
same bucket with `S3ExportStorage()`. `EXCEL_EXPORT_LINK_TTL_SECONDS` is
|
||||
unchanged in name, but it now bounds a Superset-issued link rather than a
|
||||
pre-signed S3 URL, so the AWS seven day ceiling no longer applies.
|
||||
|
||||
It also requires a running Celery worker. SMTP is optional and only used to
|
||||
additionally email logged-in users a download link; every session (including
|
||||
guest/Public ones, which have no email) gets the export through status polling
|
||||
and automatic download. Config keys:
|
||||
`EXPORT_STORAGE`, `EXCEL_EXPORT_LINK_TTL_SECONDS`,
|
||||
`EXCEL_EXPORT_TABLE_VIZ_TYPES`, and `EXCEL_EXPORT_QUERY_CONTEXT_BUILDER`.
|
||||
|
||||
The feature depends on `boto3`, which is **not** installed by default; install it
|
||||
with `pip install apache-superset[excel-export]`.
|
||||
The storage backends depend on SDKs that are **not** installed by default:
|
||||
install `pip install apache-superset[excel-export]` (boto3) for
|
||||
`S3ExportStorage`, or `pip install apache-superset[excel-export-gcs]`
|
||||
(google-cloud-storage) for `GCSExportStorage`. A custom backend can be supplied
|
||||
by implementing `superset.utils.export_storage.ExportStorage`.
|
||||
|
||||
Charts store their `query_context` only once they have been (re-)saved in
|
||||
Explore, so older charts may have none. For a fixed, conservative set of viz
|
||||
|
||||
@@ -348,6 +348,13 @@ may need to utilize a healthcheck endpoint so that your load balancer knows if y
|
||||
instance is running. This is provided at `/health` which will return a 200 response containing “OK”
|
||||
if the webserver is running.
|
||||
|
||||
Routes on the health blueprint, including `/health`, `/healthcheck`, `/ping`, and
|
||||
`/version`, are exempt from forced password changes. Even with
|
||||
`ENABLE_FORCE_PASSWORD_CHANGE = True`, a probe carrying a session cookie for a user
|
||||
with a pending password change receives the route's normal response rather than a
|
||||
redirect to the password-reset form. This exemption does not bypass other security
|
||||
checks or change which version details the user may see.
|
||||
|
||||
If the load balancer is inserting `X-Forwarded-For/X-Forwarded-Proto` headers, you should set
|
||||
`ENABLE_PROXY_FIX = True` in the superset config file (`superset_config.py`) to extract and use the
|
||||
headers.
|
||||
|
||||
@@ -762,8 +762,8 @@ MCP_TOOL_SEARCH_CONFIG = {
|
||||
],
|
||||
"search_tool_name": "search_tools",
|
||||
"call_tool_name": "call_tool",
|
||||
"include_schemas": False, # False=summary mode (name + parameters_hint)
|
||||
"compact_schemas": True, # Strip $defs (only applies when include_schemas=True)
|
||||
"include_schemas": True, # False=summary mode (name + parameters_hint)
|
||||
"compact_schemas": True, # Default description limit when include_schemas=True
|
||||
"max_description_length": 300,
|
||||
}
|
||||
```
|
||||
@@ -774,8 +774,8 @@ MCP_TOOL_SEARCH_CONFIG = {
|
||||
| `strategy` | `"bm25"` | Search ranking algorithm. `"bm25"` supports natural language; `"regex"` supports pattern matching |
|
||||
| `max_results` | `5` | Maximum tools returned per search query |
|
||||
| `always_visible` | See above | Tools that always appear in `list_tools`, regardless of search |
|
||||
| `include_schemas` | `False` | When `False` (default, "summary mode"), search results omit `inputSchema` entirely and include a lightweight `parameters_hint` listing top-level parameter names. Set to `True` to include the full `inputSchema` in search results. Full schemas are always used when a tool is actually invoked via `call_tool`. |
|
||||
| `compact_schemas` | `True` | Strip `$defs` / `$ref` and replace with `{"type": "object"}` in search results to reduce token cost. Only takes effect when `include_schemas=True` — ignored in summary mode. |
|
||||
| `include_schemas` | `True` | Include the full `inputSchema` in search results by default. When `False` ("summary mode"), search results omit `inputSchema` entirely and include a lightweight `parameters_hint` listing top-level parameter names. Full schemas are always used when a tool is actually invoked via `call_tool`. |
|
||||
| `compact_schemas` | `True` | Legacy setting selecting the default description limit (300 when `True`, 0 when `False`) if `max_description_length` is omitted. Input schemas preserve `$defs`, `$ref`, nullable unions, and constraints in either mode; titles are omitted. Clients should resolve references within each tool's `inputSchema`. |
|
||||
| `max_description_length` | `300` | Truncate tool descriptions in search results (0 = no truncation). Applies in both summary and full-schema modes. |
|
||||
|
||||
:::tip
|
||||
@@ -1020,6 +1020,23 @@ while True:
|
||||
page += 1
|
||||
```
|
||||
|
||||
## Trash Listing (Soft-Deleted Objects)
|
||||
|
||||
When the `SOFT_DELETE` feature flag is enabled, deleting a chart or dashboard moves it to trash rather than removing it outright. The `list_charts` and `list_dashboards` tools accept a `deleted_state` parameter to surface those trashed objects:
|
||||
|
||||
- `deleted_state="only"` — return only trashed objects
|
||||
- `deleted_state="include"` — return live and trashed objects together
|
||||
- Omitted (default) — live objects only; trashed objects are excluded
|
||||
|
||||
```python
|
||||
# List only trashed charts
|
||||
result = mcp.list_charts(request={"deleted_state": "only"})
|
||||
```
|
||||
|
||||
Trashed rows carry a non-null `deleted_at` timestamp in the response (populated only when `deleted_state` is used). Visibility follows the same restore-audience scoping as the equivalent REST API filters (`chart_deleted_state` / `dashboard_deleted_state`): a non-admin caller sees only the trashed objects they can edit (the same audience that can restore them), not merely the ones they own; admins see all trashed objects. That scoping is narrower than the true restore audience in two cases it can't express in SQL: editorship granted via `EXTRA_EDITORS_RESOLVER` and guest users whose editorship derives from roles. Callers in either case may be able to restore a trashed object via `POST /restore` yet not see it listed here.
|
||||
|
||||
`deleted_state` is only defined on `list_charts` and `list_dashboards`. Other list tools don't declare the field at all, so passing it is silently ignored rather than rejected — it never returns trashed objects.
|
||||
|
||||
## Disabling built-in tools
|
||||
|
||||
If you have deployed a custom tool via a Superset extension that supersedes one of the built-in Superset tools, you can suppress the built-in version so AI clients only discover your replacement. Disabled tools are removed from the server at startup and are never advertised during tool discovery.
|
||||
@@ -1048,7 +1065,7 @@ Specifying a tool name that does not exist logs a warning at startup and is othe
|
||||
|
||||
## Disabling chart type plugins
|
||||
|
||||
The `generate_chart` tool dispatches per chart type (`xy`, `table`, `pie`, `gauge`, `pivot_table`, `interactive_pivot`, `mixed_timeseries`, `handlebars`, `big_number`, `histogram`, `box_plot`, `waterfall`) to a registered chart type plugin. Gauge requests use the public MCP discriminator `chart_type: "gauge"`; Superset stores the corresponding native Explore visualization as `viz_type: "gauge_chart"`. Two settings let operators enable or disable individual chart type plugins at runtime, without a code deploy.
|
||||
The `generate_chart` tool dispatches per chart type (`xy`, `table`, `pie`, `gauge`, `treemap_v2`, `pivot_table`, `interactive_pivot`, `mixed_timeseries`, `handlebars`, `big_number`, `histogram`, `box_plot`, `waterfall`) to a registered chart type plugin. Gauge requests use the public MCP discriminator `chart_type: "gauge"`; Superset stores the corresponding native Explore visualization as `viz_type: "gauge_chart"`. Two settings let operators enable or disable individual chart type plugins at runtime, without a code deploy.
|
||||
|
||||
Gauge data inspection and JSON/CSV/XLSX exports preserve source groups, including NULL and non-finite metric values, using the same row-count and completeness semantics as other chart types. The default XLSX exporter writes non-finite floats as `nan`, `inf`, or `-inf` text, matching CSV and distinguishing them from blank NULL cells. Only previews and compile checks skip NULL, NaN, non-finite, and other nonnumeric dial values while preserving finite groups. In preview/compile paths, a nonempty result containing no finite dial returns an error; genuinely empty results retain the no-data behavior. Compile checks inspect the configured dial limit (at most 10), rather than only two groups. Grouped Vega previews center each needle within its own facet and extend it to the midpoint of the dial band. Automatic all-zero ranges span 0–1. Empty or whitespace-only temporal ranges, like `No filter`, are neutral sentinels; missing or non-string native temporal comparators are invalid.
|
||||
|
||||
@@ -1175,6 +1192,37 @@ answer. Dashboard access checks and row-level security remain independent of thi
|
||||
display-context projection. Integrations must ensure the lookup actually happens
|
||||
and the assistant uses the result; tool guidance alone cannot guarantee this.
|
||||
|
||||
## Tool-call errors
|
||||
|
||||
For exceptions raised by tools, the shared error handler distinguishes permission
|
||||
denials, invalid arguments, datasource failures, invalid SQL, and internal errors.
|
||||
Datasource responses include an enumerated reason rather than raw driver output;
|
||||
internal errors return an error ID rather than implementation details. Tools such
|
||||
as `get_chart_data` that return structured error envelopes instead of raising do
|
||||
not pass through this handler.
|
||||
|
||||
Missing tables, columns, and schemas log at `WARNING` without invoking
|
||||
`MCP_ERROR_HOOK`, even when the exception inherits status 500. Connection failures
|
||||
with status 500 or higher remain `ERROR` and invoke the hook. An explicitly
|
||||
sub-500 exception remains a warning even if it carries a connection error reason:
|
||||
the generic engine reason can also describe a caller's malformed column, and a
|
||||
connection access denial is still a permission failure.
|
||||
|
||||
## Result serialization
|
||||
|
||||
Data-bearing tool responses sanitize row values, column samples, and statistics
|
||||
even when Python producers supply tuples, sets, generators, or read-only mappings.
|
||||
Missing and non-finite values become JSON `null`; non-UTF-8 binary values become
|
||||
base64 strings prefixed with `base64:`. Boolean row counts are treated as
|
||||
unavailable, producing `null` for nullable counts and `0` for non-nullable counts,
|
||||
rather than being interpreted as one or zero rows.
|
||||
|
||||
## Chart data column counts
|
||||
|
||||
For saved and unsaved charts, `get_chart_data` column metadata excludes missing
|
||||
values from `unique_count`. Values serialized as JSON `null`, including NaN,
|
||||
NaT, and non-finite numbers, contribute to `null_count` instead.
|
||||
|
||||
## Dataset query dimensions and nested fields
|
||||
|
||||
The `query_dataset` tool accepts exact column names registered on the selected
|
||||
@@ -1197,6 +1245,65 @@ The tool does not rewrite dots into SQL path separators: quoting and nested-fiel
|
||||
support depend on the dataset's database dialect, and dots can also be literal
|
||||
characters in column names.
|
||||
|
||||
|
||||
### Treemap charts
|
||||
|
||||
Saved and cached previews honor the configured Treemap row limit across ASCII,
|
||||
table, and Vega representations. SQL Decimal metrics are accepted when finite;
|
||||
booleans and nonfinite values are rejected. Categories with identical display
|
||||
names but distinct raw types retain separate geometry groups. Currency settings
|
||||
use the native `symbolPosition` property. Adding filters while omitting the
|
||||
temporal column preserves saved predicates and the existing neutral time binding;
|
||||
explicit empty filters or a cleared temporal column remove those controls.
|
||||
|
||||
|
||||
Use `chart_type: "treemap_v2"` with an ordered `groupby` hierarchy and one
|
||||
`metric`. Hierarchy columns accept typed references (`{"name": "region"}`)
|
||||
or native strings. Metrics accept typed column references with an aggregate,
|
||||
saved metric names, or native SIMPLE/SQL metric objects. SQL metrics still
|
||||
require a label and pass the shared SQL-expression validation. Unknown controls
|
||||
and executable JavaScript hooks are not accepted.
|
||||
|
||||
```json
|
||||
{
|
||||
"dataset_id": 7,
|
||||
"config": {
|
||||
"chart_type": "treemap_v2",
|
||||
"groupby": ["region", "product"],
|
||||
"metric": {"name": "revenue", "aggregate": "SUM"},
|
||||
"row_limit": 100,
|
||||
"sort_by_metric": true,
|
||||
"show_labels": true,
|
||||
"show_upper_labels": true,
|
||||
"label_type": "key_value",
|
||||
"number_format": ",.2f"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Bounded queries sort by the metric descending when requested, then by hierarchy
|
||||
columns ascending to break ties, matching the frontend query contract.
|
||||
`update_chart` and `update_chart_preview` preserve omitted same-dataset Treemap
|
||||
controls, including hierarchy, metrics, formatting, filters, temporal state and
|
||||
`template_params`. Partial configurations are accepted only for updates; a new
|
||||
chart still requires hierarchy and metric roles. Explicit empty filters clear
|
||||
filters, and explicit null clears nullable controls. Rebinding to another dataset
|
||||
requires complete roles and inherits only supported presentation controls.
|
||||
|
||||
ASCII and table previews show hierarchy and values. Vega-Lite previews use
|
||||
bounded slice-and-dice rectangles with metric-proportional areas, hierarchy
|
||||
paths, categorical colors, labels and tooltips. This is not the native ECharts
|
||||
layout: use the Explore URL for native formatting and interactions. The preview
|
||||
supports up to 1,000 nonnegative rows, 20 hierarchy levels, and the built-in
|
||||
`supersetColors` and `lyftColors` schemes. Unsupported color/currency formats,
|
||||
negative values, zero totals and over-limit geometry return
|
||||
`UnsupportedTreemapPreview` rather than a substitute scatter or bar chart.
|
||||
`SMART_NUMBER` is approximated with SI formatting in this preview.
|
||||
|
||||
Queries must return every hierarchy output and the metric's exact output label.
|
||||
Malformed/error envelopes and nonnumeric or nonfinite metric outputs return
|
||||
structured errors instead of apparently successful previews, charts or exports.
|
||||
|
||||
## Dataset discovery and routing
|
||||
|
||||
`list_datasets` searches table name, **description**, schema, and SQL using
|
||||
|
||||
@@ -220,7 +220,7 @@ npm run cypress-debug
|
||||
npm run cypress-run-chrome
|
||||
|
||||
# Run specific file
|
||||
npm run cypress-run-chrome -- --spec "cypress/e2e/dashboard/dashboard.test.ts"
|
||||
npm run cypress-run-chrome -- --spec "cypress/e2e/explore/chart.test.js"
|
||||
```
|
||||
|
||||
### Debugging Server App
|
||||
|
||||
@@ -46,6 +46,10 @@ The host supports two display modes, switchable by the user or the extension at
|
||||
|
||||
The user's last selected mode and open/closed state are persisted across page reloads.
|
||||
|
||||
Switching display modes keeps the same panel mounted, preserving its conversation,
|
||||
draft, expanded tool results, and in-flight UI state. Closing the chat still
|
||||
unmounts the panel; extensions remain responsible for their cleanup on unmount.
|
||||
|
||||
## Registering a Chat
|
||||
|
||||
Call `chat.registerChat` from your extension's entry point with a descriptor, a trigger factory, and a panel factory:
|
||||
|
||||
@@ -234,6 +234,23 @@ The AI agent sees your tool's:
|
||||
|
||||
### Input Validation Errors
|
||||
|
||||
Argument-validation failures return MCP `isError: true`, with schema field paths
|
||||
(such as `request.page_size`) and safe reasons (such as `Expected an integer`).
|
||||
Supply required wrappers such as `request` rather than passing their fields at
|
||||
the top level. An unexpected top-level argument declared directly under `request`
|
||||
gets its expected schema path and a wrapper hint, for example:
|
||||
`Validation error in list_datasets: request.page_size: Unexpected top-level argument (place under request)`.
|
||||
Diagnostics omit received values and custom validator messages. Undeclared fields
|
||||
and dynamic dictionary keys appear as `[field]`; dictionary keys and nested
|
||||
extras remain masked even when they match a field declared elsewhere in the
|
||||
schema. Apart from the top-level wrapper hint, only fields declared at the
|
||||
corresponding schema path are shown. Responses include at most eight validation
|
||||
errors with at most eight path segments each. Failures with more than 128 errors
|
||||
return an input-free summary instead of individual details, avoiding eager
|
||||
materialization of the entire error collection for diagnostics.
|
||||
Already-structured tool errors retain their content and error flag; this
|
||||
validation formatter does not reinterpret domain-error payloads.
|
||||
|
||||
1. **Pydantic models**: Ensure field types match expected inputs
|
||||
2. **Field constraints**: Check min/max values and string lengths are reasonable
|
||||
3. **Required fields**: Verify which parameters are required vs optional
|
||||
|
||||
@@ -139,6 +139,27 @@ The following URL parameters can be passed through the `urlParams` option in `da
|
||||
|
||||
---
|
||||
|
||||
## Embedding a Single Chart
|
||||
|
||||
Individual charts can also be embedded standalone, outside of a dashboard, using a chart permalink. From Explore, generate a permalink for the chart, then append URL parameters to it:
|
||||
|
||||
```
|
||||
/explore/p/<permalink-key>/?standalone=1&show_download=1
|
||||
```
|
||||
|
||||
| Parameter | Values | Effect |
|
||||
| --------------- | -------- | -------------------------------------------------------------------------- |
|
||||
| `standalone` | `0`, `1` | `0`: normal Explore view; `1`: hide the Explore header and controls |
|
||||
| `show_download` | `0`, `1` | Show a compact download control (CSV, JSON, Excel) on the standalone chart |
|
||||
|
||||
`show_download` is opt-in and has no effect outside of `standalone=1`. The download control still respects the viewer's export permissions — it's hidden for users who lack them even when the parameter is set. With the `GRANULAR_EXPORT_CONTROLS` feature flag enabled, this requires the `can_export_data` permission on `Superset`; otherwise it falls back to `can_csv` on `Superset`.
|
||||
|
||||
Explore's **Embed Code** button on a chart also generates one of these permalink URLs wrapped in an `<iframe>`, giving you a session-authenticated iframe embed. That's a separate flow from dashboard embedding above: it doesn't go through `@superset-ui/embedded-sdk`, guest tokens, or the `dashboardUiConfig` options — the viewer needs an existing Superset session with the `can read on Explore` permission, in addition to access to the chart itself. Viewing the same chart on a dashboard doesn't require that permission, so a role scoped only for dashboard viewing gets an access denial on this URL.
|
||||
|
||||
One caveat when the host page lives on a different site than Superset: the default `SESSION_COOKIE_SAMESITE = "Lax"` setting keeps the session cookie out of cross-site iframe requests, so the viewer lands on the login page instead of the chart. Serving both from the same site avoids this; otherwise set `SESSION_COOKIE_SAMESITE = "None"` together with `SESSION_COOKIE_SECURE = True` and allow the host origin in your `TALISMAN_CONFIG` `frame-ancestors`. Even with that configuration, browsers that block third-party cookies by default (including Safari) can still redirect the viewer to the login page. For those cases, host the iframe on the same site as Superset, or use the dashboard embedding SDK's guest-token flow above instead.
|
||||
|
||||
---
|
||||
|
||||
## Security Notes
|
||||
|
||||
- **Guest tokens expire** — their lifetime is controlled by the `GUEST_TOKEN_JWT_EXP_SECONDS` config (default: 5 minutes). Refresh tokens before they expire using a token refresh mechanism in your host app.
|
||||
|
||||
@@ -170,6 +170,20 @@ control can override a simple metric's own aggregation (to Sum or Average) for t
|
||||
only — metrics built from custom SQL keep their own aggregation regardless.
|
||||
:::
|
||||
|
||||
In the **Options** panel, the **Show values as** control displays each cell as a percentage of its
|
||||
row total, column total, or grand total instead of its actual value:
|
||||
|
||||
:::note
|
||||
Totals and subtotals are still computed correctly first, as described above; **Show values as**
|
||||
only changes how the already-correct cell, row-total, column-total, and grand-total values are
|
||||
displayed, not how they're calculated. Switching it requires rerunning the query, though: for
|
||||
non-additive metrics, a percentage option can pull in an additional rollup level for its
|
||||
denominator even when the matching Total row/column isn't shown, so select **Run Query** after
|
||||
changing this control to see the correct percentages. Because the result is always a percentage, a
|
||||
per-metric custom formatter (currency, decimal precision, etc.) doesn't apply while a fraction
|
||||
option is selected.
|
||||
:::
|
||||
|
||||
### Line Chart
|
||||
|
||||
In this section, we are going to create a line chart to understand the average price of a ticket by
|
||||
|
||||
@@ -9,16 +9,18 @@ version: 1
|
||||
|
||||
Superset can export every chart on a dashboard to a single Excel workbook, with
|
||||
each chart's underlying data rendered as its own worksheet. The export reflects
|
||||
the dashboard's currently applied filters and runs asynchronously: when it
|
||||
finishes, the requesting user receives an email with a time-limited download
|
||||
link.
|
||||
the dashboard's currently applied filters and runs asynchronously: the page
|
||||
polls for completion and downloads the workbook automatically, and a logged-in
|
||||
user with an email address also receives a time-limited download link by email.
|
||||
Sessions with no email on file — embedded guest-token sessions and anonymous
|
||||
(Public-role) users — rely on the polling download alone.
|
||||
|
||||
## Using the export
|
||||
|
||||
From a dashboard, open the **... (actions) → Download** submenu and choose
|
||||
**Export Data to Excel**. The action appears for users who have the dashboard
|
||||
`can_export` permission. You'll see a confirmation that the export is being
|
||||
prepared; the workbook arrives by email when it's ready.
|
||||
prepared; the workbook downloads automatically when ready.
|
||||
|
||||
A second option, **Export Images to Excel**, embeds each non-table chart as a
|
||||
rendered image (tables stay tabular) instead of exporting raw data. Because it
|
||||
@@ -36,8 +38,9 @@ Notes on the generated workbook:
|
||||
re-saved in Explore) still exports when it is a `table`, `big_number`,
|
||||
`big_number_total` or `pie`, by rebuilding the query from the chart's saved
|
||||
form data. Charts of other types — and charts relying on post-processing the
|
||||
rebuild can't reproduce — are skipped and listed in the email; open the chart
|
||||
in Explore and re-save it to include it next time, or configure
|
||||
rebuild can't reproduce — are skipped and listed in an **Export Summary**
|
||||
worksheet added to the workbook (and in the email, when one is sent); open
|
||||
the chart in Explore and re-save it to include it next time, or configure
|
||||
`EXCEL_EXPORT_QUERY_CONTEXT_BUILDER`.
|
||||
- Row counts per sheet are capped the same way as the chart-level CSV/Excel
|
||||
export (`ROW_LIMIT`, bounded by `SQL_MAX_ROW`), and never exceed Excel's
|
||||
@@ -47,17 +50,47 @@ Notes on the generated workbook:
|
||||
|
||||
This feature is **disabled by default**. It requires:
|
||||
|
||||
1. **The `boto3` dependency.** It is not installed by default; install it with
|
||||
`pip install apache-superset[excel-export]`. Without it, exports fail and the
|
||||
user receives a failure email.
|
||||
2. **An S3 bucket.** Set `EXCEL_EXPORT_S3_BUCKET`. Until it is set, the export
|
||||
endpoint returns `501` and the menu action surfaces a "not configured"
|
||||
message.
|
||||
1. **A storage bucket and backend.** Configure `EXPORT_STORAGE` with both
|
||||
a `bucket` and a `backend` matching the bucket's provider — there is no
|
||||
implicit default:
|
||||
|
||||
```python
|
||||
from superset.utils.s3 import S3ExportStorage # AWS S3
|
||||
# from superset.utils.gcs import GCSExportStorage # Google Cloud Storage
|
||||
|
||||
EXPORT_STORAGE = {
|
||||
"bucket": "my-export-bucket",
|
||||
"backend": S3ExportStorage(),
|
||||
}
|
||||
```
|
||||
|
||||
Until both are set, the export endpoint returns `501` and the menu action
|
||||
surfaces a "not configured" message.
|
||||
|
||||
:::note Upgrading from `EXCEL_EXPORT_S3_*`
|
||||
`EXCEL_EXPORT_S3_BUCKET`, `EXCEL_EXPORT_S3_KEY_PREFIX` and
|
||||
`EXCEL_EXPORT_S3_CLIENT_KWARGS` were replaced by `EXPORT_STORAGE` and are no
|
||||
longer read. Move the bucket and prefix across, pass the client kwargs to
|
||||
`S3ExportStorage(client_kwargs=...)`, and set `backend` explicitly: it has no
|
||||
default, so an export that worked before returns `501` until it is set.
|
||||
:::
|
||||
|
||||
2. **The backend's SDK dependency, on both the web and worker tiers** (the
|
||||
worker uploads, the web server streams downloads). Not installed by
|
||||
default; install
|
||||
`pip install apache-superset[excel-export]` (boto3) for `S3ExportStorage`,
|
||||
or `pip install apache-superset[excel-export-gcs]` (google-cloud-storage)
|
||||
for `GCSExportStorage`. Without it, exports fail.
|
||||
3. **A running Celery worker.** The export runs as a Celery task. If no worker
|
||||
is running, the request is accepted but nothing is produced.
|
||||
4. **A configured SMTP transport.** The download link is delivered by email
|
||||
using the same settings as alerts & reports (`SMTP_*`,
|
||||
`EMAIL_REPORTS_SUBJECT_PREFIX`).
|
||||
4. **A configured SMTP transport, for email delivery only.** When set (same
|
||||
settings as alerts & reports: `SMTP_*`, `EMAIL_REPORTS_SUBJECT_PREFIX`),
|
||||
logged-in users with an email address also receive the download link by
|
||||
email. The polling auto-download works without it. The emailed link is
|
||||
absolute and built from `WEBDRIVER_BASEURL_USER_FRIENDLY` (default
|
||||
`http://0.0.0.0:8080/`), so set it to your public Superset origin
|
||||
(e.g. `WEBDRIVER_BASEURL_USER_FRIENDLY = "https://superset.example.com/"`)
|
||||
or remote recipients get an unreachable link.
|
||||
|
||||
**Export Images to Excel** additionally requires a working headless webdriver —
|
||||
the same infrastructure scheduled reports and thumbnails use (`WEBDRIVER_*`,
|
||||
@@ -72,33 +105,48 @@ will not register.
|
||||
|
||||
## Configuration keys
|
||||
|
||||
| Key | Default | Description |
|
||||
| ------------------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `EXCEL_EXPORT_S3_BUCKET` | `None` | Destination bucket. Required; `501` if unset. |
|
||||
| `EXCEL_EXPORT_S3_KEY_PREFIX` | `"dashboard-exports/"` | Key prefix: `{prefix}{dashboard_id}/{job_id}.xlsx`. |
|
||||
| `EXCEL_EXPORT_LINK_TTL_SECONDS` | `86400` | Lifetime of the pre-signed download URL (24h). |
|
||||
| `EXCEL_EXPORT_S3_CLIENT_KWARGS` | `{}` | Extra kwargs for `boto3.client("s3", ...)` — e.g. `region_name`, or `endpoint_url` for MinIO/LocalStack. |
|
||||
| `EXCEL_EXPORT_TABLE_VIZ_TYPES` | `None` | Viz types kept tabular in **Export Images to Excel** mode; every other type is embedded as an image. `None` uses the built-in default (`table`, `pivot_table`, `pivot_table_v2`). |
|
||||
| `EXCEL_EXPORT_QUERY_CONTEXT_BUILDER` | `None` | Optional `Callable[[form_data_dict], dict \| None]` to build a query context for a chart missing a saved one, tried before the built-in form-data rebuild. Point it at a service that runs the chart's real frontend `buildQuery` to faithfully export viz types the built-in rebuild can't handle. Must return `None` when it can't build faithfully, so the export falls back. |
|
||||
| Key | Default | Description |
|
||||
| -------------------------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `EXPORT_STORAGE["bucket"]` | unset | Destination bucket. Required; `501` if unset. |
|
||||
| `EXPORT_STORAGE["backend"]` | unset | Storage backend instance: `S3ExportStorage()` (`superset.utils.s3`), `GCSExportStorage()` (`superset.utils.gcs`), or a custom `superset.utils.export_storage.ExportStorage` implementation. Required; `501` if unset. |
|
||||
| `EXPORT_STORAGE["key_prefix"]` | `"dashboard-exports/"` | Object key/blob prefix: `{prefix}{dashboard_id}/{job_id}.xlsx`. A callable (`() -> str`) is invoked per export **inside the Celery worker** (app context only, no request context; reading `flask.request` fails every export) — derive per-tenant prefixes from worker-ambient app config. |
|
||||
| `EXCEL_EXPORT_LINK_TTL_SECONDS` | `86400` | Lifetime of the Superset download link (24h) shared in the email and polling response. Each click streams the file from storage through Superset. Guest-token exports cap the link at one hour regardless of this value, since a guest retrieves the file within the polling window and has no email link to revisit later. |
|
||||
| `EXCEL_EXPORT_TABLE_VIZ_TYPES` | `None` | Viz types kept tabular in **Export Images to Excel** mode; every other type is embedded as an image. `None` uses the built-in default (`table`, `pivot_table`, `pivot_table_v2`). |
|
||||
| `EXCEL_EXPORT_QUERY_CONTEXT_BUILDER` | `None` | Optional `Callable[[form_data_dict], dict \| None]` to build a query context for a chart missing a saved one, tried before the built-in form-data rebuild. Point it at a service that runs the chart's real frontend `buildQuery` to faithfully export viz types the built-in rebuild can't handle. Must return `None` when it can't build faithfully, so the export falls back. |
|
||||
|
||||
Credentials and region resolve through the standard boto3 chain (environment
|
||||
variables, shared config, or instance role) unless overridden via
|
||||
`EXCEL_EXPORT_S3_CLIENT_KWARGS`. The worker needs `s3:PutObject` on the bucket.
|
||||
Credentials resolve through each SDK's standard chain — for S3, environment
|
||||
variables, shared config, or an instance role, with overrides available via
|
||||
`S3ExportStorage(client_kwargs={...})` (e.g. `region_name`, or `endpoint_url`
|
||||
for MinIO/LocalStack); for GCS, Application Default Credentials. Two tiers
|
||||
need bucket access, which matters when they run under separate identities:
|
||||
the **Celery worker** uploads the file (write, e.g. `s3:PutObject`), while
|
||||
the **web server** streams it back at download time (read, e.g.
|
||||
`s3:GetObject`; on S3 also grant `s3:ListBucket`, or a lifecycle-expired
|
||||
object surfaces as AccessDenied instead of a clean "link expired"). No
|
||||
signing permissions are needed on either tier: downloads stream through
|
||||
Superset rather than redirecting to a signed storage URL.
|
||||
|
||||
## Security considerations
|
||||
|
||||
- The emailed link is a **pre-signed S3 URL**: anyone who holds it can download
|
||||
the workbook until it expires. Keep the bucket **private**, enable
|
||||
encryption, and consider a lifecycle rule to delete objects after a few days.
|
||||
Lower `EXCEL_EXPORT_LINK_TTL_SECONDS` if 24 hours is too long for your data.
|
||||
- The download link is an unguessable Superset URL: anyone who holds it can
|
||||
download the workbook until the link expires, and every download streams
|
||||
through Superset (never a transferable signed storage URL). Keep the bucket
|
||||
**private**, enable encryption, and consider a lifecycle rule to delete
|
||||
objects after a few days. Lower `EXCEL_EXPORT_LINK_TTL_SECONDS` if 24 hours
|
||||
is too long for your data.
|
||||
- The export runs with the requesting user's permissions; each chart's query is
|
||||
access-checked, so users only ever receive data they are entitled to.
|
||||
|
||||
## Limitations
|
||||
|
||||
- **Embedded dashboards / guest tokens are not supported** in this version,
|
||||
because guest users have no email address to deliver the link to. Logged-in
|
||||
users viewing an embedded dashboard can still use the export.
|
||||
- **Embedded guest-token sessions get the polling download only.** The export
|
||||
runs under the guest token's RLS rules and resource claims, and the page
|
||||
polls for completion and downloads automatically — but there is no email
|
||||
fallback, so the browser tab must stay open until the export finishes.
|
||||
**Export Images to Excel** is not available to guest or anonymous
|
||||
(Public-role) sessions: the webdriver cannot render without a real user
|
||||
identity, so the menu hides it and the API rejects it for any session
|
||||
without a user id.
|
||||
- The default **Export Data to Excel** mode exports data only (no visual
|
||||
styling). Use **Export Images to Excel** to embed rendered chart images, which
|
||||
requires the webdriver infrastructure described in the prerequisites.
|
||||
|
||||
@@ -281,6 +281,7 @@ Ask your admin for the MCP server URL and any authentication tokens you need.
|
||||
| `update_chart_preview` | Update a cached chart preview without saving |
|
||||
| `generate_explore_link` | Generate an Explore URL for interactive visualization |
|
||||
| `delete_chart` | Delete a chart by ID or UUID (soft-deletes to trash when `SOFT_DELETE` is enabled; fails if alerts/reports are still attached, checked before editorship; otherwise returns `permission_denied` if the caller isn't an editor of the chart — owners, Admins, and explicitly granted editors qualify) |
|
||||
| `restore_chart` | Restore a soft-deleted chart from trash by ID or UUID; not gated on `SOFT_DELETE`, so rows trashed while it was enabled stay recoverable, but only soft-deleted charts can be restored — permanently deleted charts are unrecoverable; returns `permission_denied` if the caller can see the chart but isn't an editor of it, or `NotFound` if the chart is outside the caller's RBAC visibility entirely |
|
||||
|
||||
### Dashboards
|
||||
|
||||
@@ -292,6 +293,7 @@ Ask your admin for the MCP server URL and any authentication tokens you need.
|
||||
| `generate_dashboard` | Create a new dashboard with specified charts |
|
||||
| `add_chart_to_existing_dashboard` | Add a chart to an existing dashboard |
|
||||
| `delete_dashboard` | Delete a dashboard by ID, UUID, or slug; leaves its charts intact (soft-deletes to trash when `SOFT_DELETE` is enabled; fails if alerts/reports are still attached, checked before editorship; otherwise returns `permission_denied` if the caller isn't an editor of the dashboard — owners, Admins, and explicitly granted editors qualify) |
|
||||
| `restore_dashboard` | Restore a soft-deleted dashboard from trash by ID or UUID (slug lookup does not cover trashed dashboards); not gated on `SOFT_DELETE`, so rows trashed while it was enabled stay recoverable, but only soft-deleted dashboards can be restored — permanently deleted dashboards are unrecoverable; returns `permission_denied` if the caller can see the dashboard but isn't an editor of it, or `NotFound` if the dashboard is outside the caller's RBAC visibility entirely |
|
||||
|
||||
### SQL
|
||||
|
||||
|
||||
@@ -65,6 +65,9 @@ type, a first save can carry more of these automatic rewrites. It
|
||||
happens at most once per chart per upgrade, and later saves record only
|
||||
what the user changed.
|
||||
|
||||
The open history panel refreshes after a successful save; you do not need to
|
||||
close and reopen it to see the new version.
|
||||
|
||||
## Previewing a version
|
||||
|
||||
Selecting a version loads it into the page so you can see it before deciding
|
||||
@@ -111,6 +114,10 @@ and would overwrite the restore on the next sync.
|
||||
|
||||
## Opening a version as something new
|
||||
|
||||
Use the **… (actions)** menu on a save-group header to open that version as
|
||||
a new chart or dashboard. Individual field-change rows preview the version;
|
||||
they do not have separate action menus.
|
||||
|
||||
**Open as new** creates a brand-new chart or dashboard from the selected
|
||||
version and leaves the original untouched. This is the safer option when you
|
||||
want an old state back but are not certain you want to give up the current one.
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"@swc/core": "^1.16.2",
|
||||
"antd": "^6.6.4",
|
||||
"baseline-browser-mapping": "^2.11.23",
|
||||
"baseline-browser-mapping": "^2.11.25",
|
||||
"caniuse-lite": "^1.0.30001810",
|
||||
"docusaurus-plugin-openapi-docs": "^5.2.0",
|
||||
"docusaurus-theme-openapi-docs": "^5.2.0",
|
||||
@@ -78,7 +78,7 @@
|
||||
"remark-import-partial": "^0.0.2",
|
||||
"reselect": "^5.3.0",
|
||||
"storybook": "^10.6.0",
|
||||
"swagger-ui-react": "^5.32.15",
|
||||
"swagger-ui-react": "^5.33.0",
|
||||
"swc-loader": "^0.2.7",
|
||||
"tinycolor2": "^1.4.2",
|
||||
"unist-util-visit": "^5.1.0"
|
||||
@@ -89,7 +89,7 @@
|
||||
"@types/react": "^19.1.8",
|
||||
"oxfmt": "^0.68.0",
|
||||
"oxlint": "^1.83.0",
|
||||
"oxlint-tsgolint": "^7.0.2001",
|
||||
"oxlint-tsgolint": "^7.0.2002",
|
||||
"typescript": "7.0.2",
|
||||
"webpack": "^5.111.0"
|
||||
},
|
||||
|
||||
@@ -1457,6 +1457,7 @@
|
||||
"Open Source"
|
||||
],
|
||||
"pypi_packages": ["pinotdb"],
|
||||
"version_requirements": "The Pinot extra requires pinotdb[sqlalchemy]>=8.0.0,<10.0.0. Earlier releases declare SQLAlchemy below 2 in their SQLAlchemy extra.",
|
||||
"connection_string": "pinot+http://{broker_host}:{broker_port}/query?controller=http://{controller_host}:{controller_port}/",
|
||||
"default_port": 8099,
|
||||
"connection_examples": [
|
||||
|
||||
78
docs/static/resources/openapi.json
vendored
78
docs/static/resources/openapi.json
vendored
@@ -18411,6 +18411,9 @@
|
||||
"401": {
|
||||
"$ref": "#/components/responses/401"
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/components/responses/403"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/components/responses/404"
|
||||
},
|
||||
@@ -18435,6 +18438,79 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/dashboard/export_xlsx/download/{job_id}/": {
|
||||
"get": {
|
||||
"description": "Intentionally requires no login: the unguessable job_id, emailed only to the original requester (or handed to their own session via export_xlsx_status), is the credential. The dashboard access check already ran once, when the export was requested -- see security_manager.raise_for_access in export_xlsx. The file streams through Superset with the deployment's own storage credentials instead of redirecting to a signed storage URL, so it works for ambient identities that cannot sign (e.g. workload identity federation) and never mints a bearer URL Superset cannot observe or revoke.",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "The job_id from the export_xlsx response",
|
||||
"in": "path",
|
||||
"name": "job_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The .xlsx file as an attachment"
|
||||
},
|
||||
"410": {
|
||||
"description": "The link is unknown, expired, or the export failed"
|
||||
},
|
||||
"501": {
|
||||
"description": "Excel export is not configured on this server"
|
||||
}
|
||||
},
|
||||
"security": [],
|
||||
"summary": "Download a completed dashboard Excel export",
|
||||
"tags": [
|
||||
"Dashboards"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/dashboard/export_xlsx/status/{job_id}/": {
|
||||
"get": {
|
||||
"description": "For a session with no email address to be notified at (e.g. an embedded/guest session), the frontend polls this endpoint with the job_id from the export_xlsx response instead of waiting for an email. Behind the same @protect() as the export request itself, unlike the login-free download_xlsx stream (which also has to work when clicked from a plain email link, possibly with no active session at all).",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "The job_id from the export_xlsx response",
|
||||
"in": "path",
|
||||
"name": "job_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Job status: {\"status\": \"pending\"} while queued, {\"status\": \"running\"} once a worker has started executing, {\"status\": \"ready\", \"download_url\": \"...\"} once the file is available, or {\"status\": \"error\", \"message\": \"...\"} if the export failed."
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/401"
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/components/responses/403"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"jwt": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "Poll the status of a dashboard Excel export job",
|
||||
"tags": [
|
||||
"Dashboards"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/dashboard/favorite_status/": {
|
||||
"get": {
|
||||
"parameters": [
|
||||
@@ -19693,7 +19769,7 @@
|
||||
},
|
||||
"/api/v1/dashboard/{pk}/export_xlsx/": {
|
||||
"post": {
|
||||
"description": "Enqueues an async task that writes each chart's data to its own worksheet, uploads the .xlsx to S3, and emails the requesting user a pre-signed download link. Returns immediately with a job id.",
|
||||
"description": "Enqueues an async task that writes each chart's data to its own worksheet, uploads the .xlsx to the configured export storage, and records a download link. The requesting user is emailed the link when they have an address on file; either way the returned job id can be polled at export_xlsx/status/<job_id>/ for status and, once ready, the download link.",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "The dashboard id",
|
||||
|
||||
760
docs/yarn.lock
760
docs/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -191,10 +191,18 @@ elasticsearch = ["elasticsearch-dbapi>=0.2.13, <0.3.0"]
|
||||
# SQLAlchemy 2.0 core bump has landed (discussion #40273).
|
||||
exasol = ["sqlalchemy-exasol>=6.0.0, <8.0"]
|
||||
excel = ["xlrd>=2.0.2, <2.1"]
|
||||
# Async dashboard "Export Data/Images to Excel": uploads the workbook to S3 and
|
||||
# emails a pre-signed link. boto3 is imported lazily by superset.utils.s3, so
|
||||
# installing this extra is only required to actually run exports.
|
||||
# S3 storage backend for the async dashboard "Export Data/Images to Excel"
|
||||
# feature (EXPORT_STORAGE["backend"] = S3ExportStorage()). boto3 is
|
||||
# imported lazily by superset.utils.s3, so installing this extra is only
|
||||
# required to actually run exports.
|
||||
excel-export = ["boto3"]
|
||||
# Google Cloud Storage backend for the same feature (EXPORT_STORAGE
|
||||
# ["backend"] = GCSExportStorage()), for a deployment whose export bucket is a
|
||||
# native GCS bucket rather than S3. google-cloud-storage is imported lazily by
|
||||
# superset.utils.gcs, so this extra is an alternative to excel-export, not an
|
||||
# addition to it -- pick whichever matches your bucket's provider.
|
||||
# >=1.37 for ``Blob.open``, which superset.utils.gcs streams downloads through.
|
||||
excel-export-gcs = ["google-cloud-storage>=1.37"]
|
||||
fastmcp = [
|
||||
"fastmcp>=3.4.7,<4.0",
|
||||
# fastmcp 3.x itself requires mcp<2.0; the upper bound here was looser
|
||||
@@ -248,7 +256,7 @@ ocient = [
|
||||
]
|
||||
oracle = ["oracledb>=4.0.2, <5"]
|
||||
parseable = ["sqlalchemy-parseable>=0.1.6,<0.2.0"]
|
||||
pinot = ["pinotdb>=5.0.0, <10.0.0"]
|
||||
pinot = ["pinotdb[sqlalchemy]>=8.0.0, <10.0.0"]
|
||||
playwright = ["playwright>=1.62.0, <2"]
|
||||
postgres = ["psycopg2-binary==2.9.13"]
|
||||
presto = ["pyhive[presto]>=0.7.0"]
|
||||
@@ -390,6 +398,17 @@ module = [
|
||||
]
|
||||
warn_unused_ignores = false
|
||||
|
||||
# The mirror case: superset-core declares SqlaTable's id/uuid/perm as plain
|
||||
# value types, so column expressions like SqlaTable.uuid.in_() only need a
|
||||
# type: ignore when superset-core's sources are part of the same mypy run
|
||||
# (full-repo lint) and not when only changed files are checked.
|
||||
[[tool.mypy.overrides]]
|
||||
module = [
|
||||
"superset.mcp_service.dataset_scope",
|
||||
"tests.unit_tests.mcp_service.dataset.tool.test_dataset_tools",
|
||||
]
|
||||
warn_unused_ignores = false
|
||||
|
||||
[tool.ruff]
|
||||
# Exclude a variety of commonly ignored directories.
|
||||
exclude = [
|
||||
@@ -447,6 +466,9 @@ select = [
|
||||
"F",
|
||||
"G",
|
||||
"I",
|
||||
# f-strings passed to gettext are never translatable, and on Python
|
||||
# 3.12+ babel extracts bogus msgids from their replacement fields.
|
||||
"INT001",
|
||||
"N",
|
||||
"PT",
|
||||
"Q",
|
||||
|
||||
@@ -47,13 +47,13 @@ from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from babel.messages.pofile import read_po
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
BABEL_CFG = ROOT_DIR / "superset" / "translations" / "babel.cfg"
|
||||
DEFAULT_POT = ROOT_DIR / "superset" / "translations" / "messages.pot"
|
||||
|
||||
# Kept in sync with the `pybabel extract` invocation in babel_update.sh.
|
||||
@@ -99,16 +99,77 @@ def _msgid_set(pot_path: Path) -> set[MsgId]:
|
||||
}
|
||||
|
||||
|
||||
def extract_fresh(output_path: Path) -> None:
|
||||
"""Run the project's extraction command into ``output_path``."""
|
||||
subprocess.run( # noqa: S603
|
||||
["pybabel", "extract", "-F", str(BABEL_CFG), "-o", str(output_path)]
|
||||
+ EXTRACT_FLAGS,
|
||||
def _archive_ref() -> str:
|
||||
"""Return a tree-ish for ``git archive`` that matches the working tree.
|
||||
|
||||
``git stash create`` builds a commit object for the current index and
|
||||
tracked-file modifications without touching the working tree, any ref,
|
||||
or the actual stash, so it is safe to call while other processes are
|
||||
using this checkout. It prints nothing when there is nothing to stash,
|
||||
so fall back to ``HEAD``.
|
||||
|
||||
Deliberately uses ``Popen`` rather than ``run``: this module's tests
|
||||
patch ``subprocess.run`` to fake the single "run pybabel" call, and a
|
||||
second real ``run`` call here would be caught by that same patch.
|
||||
"""
|
||||
proc = subprocess.Popen( # noqa: S603
|
||||
["git", "stash", "create"], # noqa: S607
|
||||
cwd=ROOT_DIR,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
stdout=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
stash_sha, _ = proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
raise subprocess.CalledProcessError(proc.returncode, proc.args)
|
||||
return stash_sha.strip() or "HEAD"
|
||||
|
||||
|
||||
def extract_fresh(output_path: Path) -> None:
|
||||
"""Run the project's extraction command into ``output_path``.
|
||||
|
||||
Extracts from a ``git archive`` snapshot rather than the live checkout.
|
||||
The Python-Unit job runs ``pytest -n auto --dist loadfile``, so many
|
||||
worker processes share this checkout while this test runs; ``pybabel
|
||||
extract`` walks everything under ``cwd``, so scanning the working
|
||||
directory directly makes the msgid set depend on whatever transient
|
||||
files another worker's test happens to write into the tree at that
|
||||
instant.
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as snapshot_dir_str:
|
||||
snapshot_dir = Path(snapshot_dir_str)
|
||||
archive = subprocess.Popen( # noqa: S603
|
||||
["git", "archive", _archive_ref()], # noqa: S607
|
||||
cwd=ROOT_DIR,
|
||||
stdout=subprocess.PIPE,
|
||||
)
|
||||
assert archive.stdout is not None
|
||||
with tarfile.open(fileobj=archive.stdout, mode="r|") as tar:
|
||||
# Use the safe extraction filter (PEP 706) where available; older
|
||||
# Python patch releases without the backport still work, just
|
||||
# without that defense-in-depth (this is our own trusted `git
|
||||
# archive` output, not attacker-controlled).
|
||||
data_filter = getattr(tarfile, "data_filter", None)
|
||||
if data_filter is not None:
|
||||
tar.extraction_filter = data_filter
|
||||
tar.extractall(snapshot_dir) # noqa: S202 (own trusted `git archive` output)
|
||||
if archive.wait() != 0:
|
||||
raise subprocess.CalledProcessError(archive.returncode, archive.args)
|
||||
|
||||
subprocess.run( # noqa: S603
|
||||
[
|
||||
"pybabel",
|
||||
"extract",
|
||||
"-F",
|
||||
str(snapshot_dir / "superset" / "translations" / "babel.cfg"),
|
||||
"-o",
|
||||
str(output_path),
|
||||
]
|
||||
+ EXTRACT_FLAGS,
|
||||
cwd=snapshot_dir,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def diff(committed_pot: Path = DEFAULT_POT) -> tuple[set[MsgId], set[MsgId]]:
|
||||
|
||||
288
superset-embedded-sdk/package-lock.json
generated
288
superset-embedded-sdk/package-lock.json
generated
@@ -17,47 +17,47 @@
|
||||
"@babel/core": "^8.0.5",
|
||||
"@babel/preset-env": "^8.0.5",
|
||||
"@babel/preset-typescript": "^8.0.1",
|
||||
"@types/node": "^26.5.1",
|
||||
"@types/node": "^26.6.1",
|
||||
"babel-loader": "^10.1.1",
|
||||
"jsdom": "^30.0.1",
|
||||
"jsdom": "^30.1.0",
|
||||
"tscw-config": "^1.1.2",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^5.0.0",
|
||||
"vitest": "^5.0.1",
|
||||
"webpack": "^5.111.0",
|
||||
"webpack-cli": "^7.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "6.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz",
|
||||
"integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==",
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.0.0.tgz",
|
||||
"integrity": "sha512-IxddtGnnoidVSO6O/aiTueptbTThHvi2qGHu9/AWuD1J7i86Ox+jl+Y5SXcDH1RixzQlP2cjH/PjnlImZiAurQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/css-calc": "^3.3.0",
|
||||
"@csstools/css-color-parser": "^4.1.10",
|
||||
"@csstools/css-color-parser": "^4.2.0",
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0",
|
||||
"lru-cache": "^11.5.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.13.0 || >=24.0.0"
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/dom-selector": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz",
|
||||
"integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==",
|
||||
"version": "9.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.1.4.tgz",
|
||||
"integrity": "sha512-ynDE7RDZM1z+YuSU+iGhRp8WcSXHqK9+G32ZEzsL35TKSoy72fYR7VfrJCQkWe3ciiIWGSTAIBc9XB5jVpqyTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bidi-js": "^1.0.3",
|
||||
"bidi-js": "^1.1.0",
|
||||
"css-tree": "^3.2.1",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"lru-cache": "^11.5.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.13.0 || >=24.0.0"
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/cli": {
|
||||
@@ -1648,9 +1648,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-calc": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz",
|
||||
"integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==",
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.0.tgz",
|
||||
"integrity": "sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1672,9 +1672,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-color-parser": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.1.tgz",
|
||||
"integrity": "sha512-YpAJZhaHplYQkG8ib+/Fx5Y0eF2lVWi3tIvMJA6i39TLyUNp2439cifzW8VMjhlqrBjHzK5hVGugRRm2zTKI/A==",
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.3.tgz",
|
||||
"integrity": "sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1689,7 +1689,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/color-helpers": "^6.1.1",
|
||||
"@csstools/css-calc": "^3.3.0"
|
||||
"@csstools/css-calc": "^3.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
@@ -1723,9 +1723,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||
"version": "1.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.9.tgz",
|
||||
"integrity": "sha512-iGGw4OsAYsS6pD29MdJ2bX/nJx65a04ZZiw6x+VwWlP2DdXf6f++Zmuv/OzALpdyfVhjbduIIF2cXM7HWBIe9A==",
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz",
|
||||
"integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1846,9 +1846,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.148.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz",
|
||||
"integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==",
|
||||
"version": "0.149.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz",
|
||||
"integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
@@ -1857,9 +1857,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm-eabi": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.7.tgz",
|
||||
"integrity": "sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz",
|
||||
"integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1875,9 +1875,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.7.tgz",
|
||||
"integrity": "sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz",
|
||||
"integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1893,9 +1893,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.7.tgz",
|
||||
"integrity": "sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz",
|
||||
"integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1911,9 +1911,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.7.tgz",
|
||||
"integrity": "sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz",
|
||||
"integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1929,9 +1929,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.7.tgz",
|
||||
"integrity": "sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz",
|
||||
"integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1947,9 +1947,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.7.tgz",
|
||||
"integrity": "sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz",
|
||||
"integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1965,9 +1965,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.7.tgz",
|
||||
"integrity": "sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz",
|
||||
"integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1986,9 +1986,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.7.tgz",
|
||||
"integrity": "sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz",
|
||||
"integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2007,9 +2007,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.7.tgz",
|
||||
"integrity": "sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz",
|
||||
"integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -2028,9 +2028,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.7.tgz",
|
||||
"integrity": "sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz",
|
||||
"integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -2049,9 +2049,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.7.tgz",
|
||||
"integrity": "sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz",
|
||||
"integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2070,9 +2070,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.7.tgz",
|
||||
"integrity": "sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz",
|
||||
"integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2091,9 +2091,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.7.tgz",
|
||||
"integrity": "sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz",
|
||||
"integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2109,9 +2109,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.7.tgz",
|
||||
"integrity": "sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz",
|
||||
"integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2127,9 +2127,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.7.tgz",
|
||||
"integrity": "sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz",
|
||||
"integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2205,9 +2205,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.1.tgz",
|
||||
"integrity": "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==",
|
||||
"version": "26.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.1.tgz",
|
||||
"integrity": "sha512-VqGJBMCtdhqkBUCcBLvywI0NJ+KLuVzgNnlBUNFOQjqVxzo2lxLUNg1DSey8+u2u6ktswSAxg+s68QLzWHNOuA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2555,14 +2555,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz",
|
||||
"integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==",
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.1.tgz",
|
||||
"integrity": "sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "0.3.31",
|
||||
"@vitest/spy": "5.0.0",
|
||||
"@vitest/spy": "5.0.1",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^1.2.3"
|
||||
},
|
||||
@@ -2583,9 +2583,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz",
|
||||
"integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==",
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.1.tgz",
|
||||
"integrity": "sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -2905,9 +2905,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
|
||||
"integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3557,16 +3557,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "30.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz",
|
||||
"integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==",
|
||||
"version": "30.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.0.tgz",
|
||||
"integrity": "sha512-h/Q+H+KmlGf49ITw3TrykSVbg04S75S2O7ZtJc9gMGOWBrQRZmx5AJIKLtJ01e6Kszl+/tpFthCVqxNifxhoqg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/css-color": "^6.0.5",
|
||||
"@asamuzakjp/dom-selector": "^8.3.0",
|
||||
"@asamuzakjp/css-color": "^7.0.0",
|
||||
"@asamuzakjp/dom-selector": "^9.1.2",
|
||||
"@bramus/specificity": "^2.4.2",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.1.7",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.1.13",
|
||||
"@exodus/bytes": "^1.15.1",
|
||||
"css-tree": "^3.2.1",
|
||||
"data-urls": "^7.0.0",
|
||||
@@ -3576,13 +3576,12 @@
|
||||
"lru-cache": "^11.5.2",
|
||||
"parse5": "^8.0.1",
|
||||
"saxes": "^6.0.0",
|
||||
"symbol-tree": "^3.2.4",
|
||||
"tough-cookie": "^6.0.2",
|
||||
"undici": "^8.9.0",
|
||||
"undici": "^8.10.2",
|
||||
"w3c-xmlserializer": "^5.0.0",
|
||||
"webidl-conversions": "^8.0.1",
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^17.1.0",
|
||||
"whatwg-url": "^17.1.1",
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -3968,13 +3967,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.3.tgz",
|
||||
"integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==",
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz",
|
||||
"integrity": "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
"@jridgewell/sourcemap-codec": "^1.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mdn-data": {
|
||||
@@ -4089,9 +4088,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||
"version": "3.3.19",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
|
||||
"integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -4501,14 +4500,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz",
|
||||
"integrity": "sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz",
|
||||
"integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.148.0",
|
||||
"@oxc-project/types": "=0.149.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -4518,21 +4517,21 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm-eabi": "1.2.7",
|
||||
"@rolldown/binding-android-arm64": "1.2.7",
|
||||
"@rolldown/binding-darwin-arm64": "1.2.7",
|
||||
"@rolldown/binding-darwin-x64": "1.2.7",
|
||||
"@rolldown/binding-freebsd-x64": "1.2.7",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.2.7",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.2.7",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.2.7",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.2.7",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.2.7",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.2.7",
|
||||
"@rolldown/binding-linux-x64-musl": "1.2.7",
|
||||
"@rolldown/binding-openharmony-arm64": "1.2.7",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.2.7",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.7"
|
||||
"@rolldown/binding-android-arm-eabi": "1.2.8",
|
||||
"@rolldown/binding-android-arm64": "1.2.8",
|
||||
"@rolldown/binding-darwin-arm64": "1.2.8",
|
||||
"@rolldown/binding-darwin-x64": "1.2.8",
|
||||
"@rolldown/binding-freebsd-x64": "1.2.8",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.2.8",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.2.8",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.2.8",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.2.8",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.2.8",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.2.8",
|
||||
"@rolldown/binding-linux-x64-musl": "1.2.8",
|
||||
"@rolldown/binding-openharmony-arm64": "1.2.8",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.2.8",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.8"
|
||||
}
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
@@ -4724,13 +4723,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
|
||||
@@ -4906,9 +4898,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "8.10.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz",
|
||||
"integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==",
|
||||
"version": "8.10.2",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz",
|
||||
"integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -5011,17 +5003,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.2.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz",
|
||||
"integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==",
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz",
|
||||
"integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.33.0",
|
||||
"picomatch": "^4.0.5",
|
||||
"postcss": "^8.5.26",
|
||||
"rolldown": "~1.2.4",
|
||||
"picomatch": "^4.0.7",
|
||||
"postcss": "^8.5.28",
|
||||
"rolldown": "~1.2.6",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
@@ -5038,7 +5030,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"@vitejs/devtools": "^0.4.0 || ^0.5.0",
|
||||
"@vitejs/devtools": "^0.7.1",
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
@@ -5090,14 +5082,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.0.tgz",
|
||||
"integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==",
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.1.tgz",
|
||||
"integrity": "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/mocker": "5.0.0",
|
||||
"@vitest/mocker": "5.0.1",
|
||||
"chai": "^6.2.2",
|
||||
"es-module-lexer": "^2.3.2",
|
||||
"expect-type": "^1.4.0",
|
||||
@@ -5123,12 +5115,12 @@
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "5.0.0",
|
||||
"@vitest/browser-preview": "5.0.0",
|
||||
"@vitest/browser-playwright": "5.0.1",
|
||||
"@vitest/browser-preview": "5.0.1",
|
||||
"@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
|
||||
"@vitest/coverage-istanbul": "5.0.0",
|
||||
"@vitest/coverage-v8": "5.0.0",
|
||||
"@vitest/ui": "5.0.0",
|
||||
"@vitest/coverage-istanbul": "5.0.1",
|
||||
"@vitest/coverage-v8": "5.0.1",
|
||||
"@vitest/ui": "5.0.1",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
|
||||
@@ -5337,9 +5329,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "17.1.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz",
|
||||
"integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==",
|
||||
"version": "17.1.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.1.tgz",
|
||||
"integrity": "sha512-ohjk1mdUebJVadRt3bAhQhx8lSnISq+GDttK79LFl8EHQkAPvzwctoasC4hs8tBt6kLAncBWWyq1N52qEfKvDw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -41,12 +41,12 @@
|
||||
"@babel/core": "^8.0.5",
|
||||
"@babel/preset-env": "^8.0.5",
|
||||
"@babel/preset-typescript": "^8.0.1",
|
||||
"@types/node": "^26.5.1",
|
||||
"@types/node": "^26.6.1",
|
||||
"babel-loader": "^10.1.1",
|
||||
"jsdom": "^30.0.1",
|
||||
"jsdom": "^30.1.0",
|
||||
"tscw-config": "^1.1.2",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^5.0.0",
|
||||
"vitest": "^5.0.1",
|
||||
"webpack": "^5.111.0",
|
||||
"webpack-cli": "^7.2.3"
|
||||
},
|
||||
|
||||
@@ -1,614 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { waitForChartLoad } from 'cypress/utils';
|
||||
import { SUPPORTED_CHARTS_DASHBOARD } from 'cypress/utils/urls';
|
||||
import {
|
||||
openTopLevelTab,
|
||||
SUPPORTED_TIER1_CHARTS,
|
||||
SUPPORTED_TIER2_CHARTS,
|
||||
} from './utils';
|
||||
|
||||
function interceptSamples() {
|
||||
cy.intercept(`**/datasource/samples*`).as('samples');
|
||||
}
|
||||
|
||||
function openModalFromMenu(chartType: string) {
|
||||
interceptSamples();
|
||||
|
||||
cy.get(
|
||||
`[data-test-viz-type='${chartType}'] [aria-label='More Options']`,
|
||||
).click();
|
||||
cy.get('.ant-dropdown')
|
||||
.not('.ant-dropdown-hidden')
|
||||
.find("[role='menu'] [role='menuitem']")
|
||||
.eq(5)
|
||||
.should('contain', 'Drill to detail')
|
||||
.click();
|
||||
cy.wait('@samples');
|
||||
}
|
||||
|
||||
function drillToDetail(targetMenuItem: string) {
|
||||
interceptSamples();
|
||||
|
||||
cy.get('.ant-dropdown')
|
||||
.not('.ant-dropdown-hidden')
|
||||
.first()
|
||||
.find("[role='menu'] [role='menuitem']")
|
||||
.contains(new RegExp(`^${targetMenuItem}$`))
|
||||
.first()
|
||||
.trigger('keydown', { keyCode: 13, which: 13, force: true });
|
||||
|
||||
cy.getBySel('metadata-bar').should('be.visible');
|
||||
cy.wait('@samples');
|
||||
}
|
||||
|
||||
const drillToDetailBy = (targetDrill: string) => {
|
||||
interceptSamples();
|
||||
|
||||
cy.get('.ant-dropdown:not(.ant-dropdown-hidden)')
|
||||
.should('be.visible')
|
||||
.find("[role='menu'] [role='menuitem']")
|
||||
.contains(/^Drill to detail by$/)
|
||||
.trigger('mouseover', { force: true });
|
||||
|
||||
cy.get(
|
||||
'.ant-dropdown-menu-submenu:not(.ant-dropdown-menu-submenu-hidden) [data-test="drill-to-detail-by-submenu"]',
|
||||
)
|
||||
.should('be.visible')
|
||||
.find('[role="menuitem"]')
|
||||
.then($el => {
|
||||
cy.wrap($el)
|
||||
.contains(new RegExp(`^${targetDrill}$`))
|
||||
.trigger('keydown', { keyCode: 13, which: 13, force: true });
|
||||
});
|
||||
|
||||
cy.getBySel('metadata-bar').should('be.visible');
|
||||
return cy.wait('@samples');
|
||||
};
|
||||
|
||||
function closeModal() {
|
||||
cy.get('body').then($body => {
|
||||
if ($body.find('[data-test="close-drilltodetail-modal"]').length) {
|
||||
cy.getBySel('close-drilltodetail-modal').click({ force: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function testTimeChart(vizType: string) {
|
||||
interceptSamples();
|
||||
|
||||
cy.get(`[data-test-viz-type='${vizType}'] canvas`).then($canvas => {
|
||||
cy.wrap($canvas).scrollIntoView();
|
||||
cy.wrap($canvas).trigger('mousemove', 85, 93);
|
||||
cy.wrap($canvas).rightclick(85, 93);
|
||||
|
||||
drillToDetailBy('Drill to detail by 1965');
|
||||
cy.getBySel('filter-val').should('contain', '1965');
|
||||
closeModal();
|
||||
|
||||
cy.wrap($canvas).scrollIntoView();
|
||||
cy.wrap($canvas).trigger('mousemove', 85, 93);
|
||||
cy.wrap($canvas).rightclick(85, 93);
|
||||
|
||||
drillToDetailBy('Drill to detail by boy');
|
||||
cy.getBySel('filter-val').should('contain', 'boy');
|
||||
closeModal();
|
||||
|
||||
cy.wrap($canvas).scrollIntoView();
|
||||
cy.wrap($canvas).trigger('mousemove', 85, 93);
|
||||
cy.wrap($canvas).rightclick(85, 93);
|
||||
|
||||
drillToDetailBy('Drill to detail by all');
|
||||
cy.getBySel('filter-val').first().should('contain', '1965');
|
||||
cy.getBySel('filter-val').eq(1).should('contain', 'boy');
|
||||
closeModal();
|
||||
});
|
||||
}
|
||||
|
||||
// TODO fix this test, it has issues with autoscrolling and the locked title
|
||||
// flakes intricately when the righClick is obstructed by the title.
|
||||
// Tried many option around scrollIntoView, force, etc. but no luck.
|
||||
describe.skip('Drill to detail modal', () => {
|
||||
beforeEach(() => {
|
||||
closeModal();
|
||||
});
|
||||
|
||||
describe('Tier 1 charts', () => {
|
||||
before(() => {
|
||||
cy.visit(SUPPORTED_CHARTS_DASHBOARD);
|
||||
openTopLevelTab('Tier 1');
|
||||
SUPPORTED_TIER1_CHARTS.forEach(waitForChartLoad);
|
||||
});
|
||||
|
||||
describe('Modal actions', () => {
|
||||
it('opens the modal from the context menu', () => {
|
||||
openModalFromMenu('big_number_total');
|
||||
|
||||
cy.get("[role='dialog'] .draggable-trigger").should(
|
||||
'contain',
|
||||
'Drill to detail: Big Number',
|
||||
);
|
||||
});
|
||||
|
||||
it('refreshes the data', () => {
|
||||
openModalFromMenu('big_number_total');
|
||||
// move to the last page
|
||||
cy.get('.ant-pagination-item').eq(5).click();
|
||||
// skips error on pagination
|
||||
cy.on('uncaught:exception', () => false);
|
||||
cy.wait('@samples');
|
||||
// reload
|
||||
cy.get("[aria-label='Reload']").click();
|
||||
cy.wait('@samples');
|
||||
// make sure it started back from first page
|
||||
cy.get('.ant-pagination-item-active').should('contain', '1');
|
||||
});
|
||||
|
||||
it('paginates', () => {
|
||||
openModalFromMenu('big_number_total');
|
||||
// checking the data
|
||||
cy.getBySel('row-count-label').should('contain', '75.7k rows');
|
||||
cy.get('.virtual-table-cell').should($rows => {
|
||||
expect($rows).to.contain('Amy');
|
||||
});
|
||||
// checking the paginated data
|
||||
cy.get('.ant-pagination-item')
|
||||
.should('have.length', 6)
|
||||
.should($pages => {
|
||||
expect($pages).to.contain('1');
|
||||
expect($pages).to.contain('1514');
|
||||
});
|
||||
cy.get('.ant-pagination-item').eq(4).click();
|
||||
// skips error on pagination
|
||||
cy.on('uncaught:exception', () => false);
|
||||
cy.wait('@samples');
|
||||
cy.get('.virtual-table-cell').should($rows => {
|
||||
expect($rows).to.contain('Kimberly');
|
||||
});
|
||||
|
||||
// verify scroll top on pagination
|
||||
cy.getBySelLike('Number-modal').find('.virtual-grid').scrollTo(0, 200);
|
||||
|
||||
cy.get('.virtual-grid').contains('Kim').should('not.be.visible');
|
||||
|
||||
cy.get('.ant-pagination-item').eq(0).click();
|
||||
|
||||
cy.get('.virtual-grid').contains('Aaron').should('be.visible');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Big number total', () => {
|
||||
it('opens the modal with no filters', () => {
|
||||
interceptSamples();
|
||||
|
||||
// opens the modal by clicking on the number on the chart
|
||||
cy.get(
|
||||
"[data-test-viz-type='big_number_total'] .header-line",
|
||||
).scrollIntoView();
|
||||
cy.get(
|
||||
"[data-test-viz-type='big_number_total'] .header-line",
|
||||
).rightclick();
|
||||
|
||||
drillToDetail('Drill to detail');
|
||||
|
||||
cy.getBySel('filter-val').should('not.exist');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Big number with trendline', () => {
|
||||
it('opens the modal with the correct data', () => {
|
||||
interceptSamples();
|
||||
|
||||
// opens the modal by clicking on the number
|
||||
cy.get(
|
||||
"[data-test-viz-type='big_number'] .header-line",
|
||||
).scrollIntoView();
|
||||
cy.get("[data-test-viz-type='big_number'] .header-line").rightclick();
|
||||
|
||||
drillToDetail('Drill to detail');
|
||||
|
||||
cy.getBySel('filter-val').should('not.exist');
|
||||
|
||||
closeModal();
|
||||
|
||||
// opens the modal by clicking on the trendline
|
||||
cy.get("[data-test-viz-type='big_number'] canvas").then($canvas => {
|
||||
cy.wrap($canvas).scrollIntoView();
|
||||
cy.wrap($canvas).trigger('mousemove', 1, 14);
|
||||
cy.wrap($canvas).rightclick(1, 14);
|
||||
|
||||
drillToDetailBy('Drill to detail by 1965');
|
||||
|
||||
// checking the filter
|
||||
cy.getBySel('filter-val').should('contain', '1965');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Table', () => {
|
||||
it('opens the modal with the correct filters', () => {
|
||||
interceptSamples();
|
||||
|
||||
// focus on table first to trigger browser scroll
|
||||
cy.get("[data-test-viz-type='table']").contains('boy').rightclick();
|
||||
|
||||
cy.wait(500);
|
||||
cy.get("[data-test-viz-type='table']").contains('boy').scrollIntoView();
|
||||
cy.get("[data-test-viz-type='table']").contains('boy').rightclick();
|
||||
|
||||
drillToDetailBy('Drill to detail by boy');
|
||||
|
||||
cy.getBySel('filter-val').should('contain', 'boy');
|
||||
|
||||
closeModal();
|
||||
|
||||
// focus on table first to trigger browser scroll
|
||||
cy.get("[data-test-viz-type='table']").contains('girl').rightclick();
|
||||
cy.wait(500);
|
||||
cy.get("[data-test-viz-type='table']").scrollIntoView();
|
||||
cy.get("[data-test-viz-type='table']").contains('girl').rightclick();
|
||||
|
||||
drillToDetailBy('Drill to detail by girl');
|
||||
|
||||
cy.getBySel('filter-val').should('contain', 'girl');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pivot Table V2', () => {
|
||||
it('opens the modal with the correct filters', () => {
|
||||
interceptSamples();
|
||||
|
||||
cy.get("[data-test-viz-type='pivot_table_v2']").scrollIntoView();
|
||||
cy.get("[data-test-viz-type='pivot_table_v2']")
|
||||
.find('[role="gridcell"]')
|
||||
.first()
|
||||
.rightclick();
|
||||
|
||||
drillToDetailBy('Drill to detail by boy');
|
||||
|
||||
cy.getBySel('filter-val').should('contain', 'boy');
|
||||
closeModal();
|
||||
|
||||
cy.get("[data-test-viz-type='pivot_table_v2']").scrollIntoView();
|
||||
cy.get("[data-test-viz-type='pivot_table_v2']")
|
||||
.find('[role="gridcell"]')
|
||||
.first()
|
||||
.rightclick();
|
||||
|
||||
drillToDetailBy('Drill to detail by CA');
|
||||
|
||||
cy.getBySel('filter-val').should('contain', 'CA');
|
||||
closeModal();
|
||||
|
||||
cy.get("[data-test-viz-type='pivot_table_v2']").scrollIntoView();
|
||||
cy.get("[data-test-viz-type='pivot_table_v2']")
|
||||
.find('[role="gridcell"]')
|
||||
.eq(3)
|
||||
.rightclick();
|
||||
|
||||
drillToDetailBy('Drill to detail by girl');
|
||||
|
||||
cy.getBySel('filter-val').should('contain', 'girl');
|
||||
closeModal();
|
||||
|
||||
cy.get("[data-test-viz-type='pivot_table_v2']").scrollIntoView();
|
||||
cy.get("[data-test-viz-type='pivot_table_v2']")
|
||||
.find('[role="gridcell"]')
|
||||
.eq(3)
|
||||
.rightclick();
|
||||
|
||||
drillToDetailBy('Drill to detail by FL');
|
||||
|
||||
cy.getBySel('filter-val').should('contain', 'FL');
|
||||
closeModal();
|
||||
|
||||
cy.get("[data-test-viz-type='pivot_table_v2']").scrollIntoView();
|
||||
cy.get("[data-test-viz-type='pivot_table_v2']")
|
||||
.find('[role="gridcell"]')
|
||||
.eq(3)
|
||||
.rightclick();
|
||||
|
||||
drillToDetailBy('Drill to detail by all');
|
||||
|
||||
cy.getBySel('filter-val').first().should('contain', 'girl');
|
||||
cy.getBySel('filter-val').eq(1).should('contain', 'FL');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Line Chart', () => {
|
||||
it('opens the modal with the correct filters', () => {
|
||||
testTimeChart('echarts_timeseries_line');
|
||||
});
|
||||
});
|
||||
|
||||
describe.skip('Bar Chart', () => {
|
||||
it('opens the modal with the correct filters', () => {
|
||||
interceptSamples();
|
||||
|
||||
cy.get("[data-test-viz-type='echarts_timeseries_bar'] canvas").then(
|
||||
$canvas => {
|
||||
cy.wrap($canvas).scrollIntoView();
|
||||
cy.wrap($canvas).rightclick(70, 100);
|
||||
|
||||
drillToDetailBy('Drill to detail by 1965');
|
||||
cy.getBySel('filter-val').should('contain', '1965');
|
||||
closeModal();
|
||||
|
||||
cy.wrap($canvas).scrollIntoView();
|
||||
cy.wrap($canvas).rightclick(70, 100);
|
||||
|
||||
drillToDetailBy('Drill to detail by boy');
|
||||
cy.getBySel('filter-val').should('contain', 'boy');
|
||||
closeModal();
|
||||
|
||||
cy.wrap($canvas).scrollIntoView();
|
||||
cy.wrap($canvas).rightclick(70, 100);
|
||||
|
||||
drillToDetailBy('Drill to detail by all');
|
||||
cy.getBySel('filter-val').first().should('contain', '1965');
|
||||
cy.getBySel('filter-val').eq(1).should('contain', 'boy');
|
||||
closeModal();
|
||||
|
||||
cy.wrap($canvas).scrollIntoView();
|
||||
cy.wrap($canvas).rightclick(72, 200);
|
||||
|
||||
drillToDetailBy('Drill to detail by girl');
|
||||
cy.getBySel('filter-val').should('contain', 'girl');
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skip('Area Chart', () => {
|
||||
it('opens the modal with the correct filters', () => {
|
||||
testTimeChart('echarts_area');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scatter Chart', () => {
|
||||
it('opens the modal with the correct filters', () => {
|
||||
testTimeChart('echarts_timeseries_scatter');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pie', () => {
|
||||
it('opens the modal with the correct filters', () => {
|
||||
interceptSamples();
|
||||
|
||||
// opens the modal by clicking on the slice of the Pie chart
|
||||
cy.get("[data-test-viz-type='pie'] canvas").then($canvas => {
|
||||
cy.wrap($canvas).scrollIntoView();
|
||||
cy.wrap($canvas).rightclick(130, 150);
|
||||
|
||||
drillToDetailBy('Drill to detail by girl');
|
||||
cy.getBySel('filter-val').should('contain', 'girl');
|
||||
closeModal();
|
||||
|
||||
cy.wrap($canvas).scrollIntoView();
|
||||
cy.wrap($canvas).rightclick(230, 190);
|
||||
|
||||
drillToDetailBy('Drill to detail by boy');
|
||||
cy.getBySel('filter-val').should('contain', 'boy');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe.skip('World Map', () => {
|
||||
it('opens the modal with the correct filters', () => {
|
||||
interceptSamples();
|
||||
|
||||
cy.get("[data-test-viz-type='world_map'] svg").then($canvas => {
|
||||
cy.wrap($canvas).scrollIntoView();
|
||||
cy.wrap($canvas).rightclick(70, 150);
|
||||
drillToDetailBy('Drill to detail by USA');
|
||||
cy.getBySel('filter-val').should('contain', 'USA');
|
||||
closeModal();
|
||||
});
|
||||
cy.get("[data-test-viz-type='world_map'] svg").then($canvas => {
|
||||
cy.wrap($canvas).scrollIntoView();
|
||||
cy.wrap($canvas).rightclick(200, 140);
|
||||
drillToDetailBy('Drill to detail by SRB');
|
||||
cy.getBySel('filter-val').should('contain', 'SRB');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tier 2 charts', () => {
|
||||
before(() => {
|
||||
cy.visit(SUPPORTED_CHARTS_DASHBOARD);
|
||||
openTopLevelTab('Tier 2');
|
||||
SUPPORTED_TIER2_CHARTS.forEach(waitForChartLoad);
|
||||
});
|
||||
|
||||
describe('Modal actions', () => {
|
||||
it('clears filters', () => {
|
||||
interceptSamples();
|
||||
|
||||
// opens the modal by clicking on the box on the chart
|
||||
cy.get("[data-test-viz-type='box_plot'] canvas").then($canvas => {
|
||||
const canvasWidth = $canvas.width() || 0;
|
||||
const canvasHeight = $canvas.height() || 0;
|
||||
const canvasCenterX = canvasWidth / 3 + 15;
|
||||
const canvasCenterY = (canvasHeight * 5) / 6;
|
||||
|
||||
cy.wrap($canvas).scrollIntoView();
|
||||
cy.wrap($canvas).rightclick(canvasCenterX, canvasCenterY, {
|
||||
force: true,
|
||||
});
|
||||
|
||||
drillToDetailBy('Drill to detail by boy');
|
||||
|
||||
// checking the filter
|
||||
cy.getBySel('filter-val').should('contain', 'boy');
|
||||
cy.getBySel('row-count-label').should('contain', '39.2k rows');
|
||||
cy.get('.ant-pagination-item')
|
||||
.should('have.length', 6)
|
||||
.then($pages => {
|
||||
expect($pages).to.contain('1');
|
||||
expect($pages).to.contain('785');
|
||||
});
|
||||
|
||||
// close the filter and test that data was reloaded
|
||||
cy.getBySel('filter-col').find("[aria-label='Close']").click();
|
||||
cy.wait('@samples');
|
||||
cy.getBySel('row-count-label').should('contain', '75.7k rows');
|
||||
cy.get('.ant-pagination-item-active').should('contain', '1');
|
||||
cy.get('.ant-pagination-item')
|
||||
.should('have.length', 6)
|
||||
.then($pages => {
|
||||
expect($pages).to.contain('1');
|
||||
expect($pages).to.contain('1514');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Box plot', () => {
|
||||
it('opens the modal with the correct filters', () => {
|
||||
interceptSamples();
|
||||
|
||||
cy.get("[data-test-viz-type='box_plot'] canvas").then($canvas => {
|
||||
cy.wrap($canvas).scrollIntoView();
|
||||
cy.wrap($canvas).trigger('mousemove', 135, 275);
|
||||
cy.wrap($canvas).rightclick(135, 275);
|
||||
|
||||
drillToDetailBy('Drill to detail by boy');
|
||||
cy.getBySel('filter-val').should('contain', 'boy');
|
||||
closeModal();
|
||||
|
||||
cy.wrap($canvas).scrollIntoView();
|
||||
cy.wrap($canvas).trigger('mousemove', 270, 280);
|
||||
cy.wrap($canvas).rightclick(270, 280);
|
||||
|
||||
drillToDetailBy('Drill to detail by girl');
|
||||
cy.getBySel('filter-val').should('contain', 'girl');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Generic Chart', () => {
|
||||
it('opens the modal with the correct filters', () => {
|
||||
testTimeChart('echarts_timeseries');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Smooth Chart', () => {
|
||||
it('opens the modal with the correct filters', () => {
|
||||
testTimeChart('echarts_timeseries_smooth');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Step Line Chart', () => {
|
||||
it('opens the modal with the correct filters', () => {
|
||||
testTimeChart('echarts_timeseries_step');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Funnel Chart', () => {
|
||||
it('opens the modal with the correct filters', () => {
|
||||
interceptSamples();
|
||||
|
||||
cy.get("[data-test-viz-type='funnel'] canvas").then($canvas => {
|
||||
cy.wrap($canvas).scrollIntoView();
|
||||
cy.wrap($canvas).rightclick(170, 90);
|
||||
|
||||
drillToDetailBy('Drill to detail by boy');
|
||||
cy.getBySel('filter-val').should('contain', 'boy');
|
||||
closeModal();
|
||||
|
||||
cy.wrap($canvas).scrollIntoView();
|
||||
cy.wrap($canvas).rightclick(190, 250);
|
||||
|
||||
drillToDetailBy('Drill to detail by girl');
|
||||
cy.getBySel('filter-val').should('contain', 'girl');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Gauge Chart', () => {
|
||||
it('opens the modal with the correct filters', () => {
|
||||
interceptSamples();
|
||||
|
||||
cy.get("[data-test-viz-type='gauge_chart'] canvas").then($canvas => {
|
||||
cy.wrap($canvas).scrollIntoView();
|
||||
cy.wrap($canvas).rightclick(135, 95);
|
||||
|
||||
drillToDetailBy('Drill to detail by boy');
|
||||
cy.getBySel('filter-val').should('contain', 'boy');
|
||||
closeModal();
|
||||
|
||||
cy.wrap($canvas).scrollIntoView();
|
||||
cy.wrap($canvas).rightclick(95, 135);
|
||||
|
||||
drillToDetailBy('Drill to detail by girl');
|
||||
cy.getBySel('filter-val').should('contain', 'girl');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mixed Chart', () => {
|
||||
it('opens the modal with the correct filters', () => {
|
||||
testTimeChart('mixed_timeseries');
|
||||
});
|
||||
});
|
||||
|
||||
describe.skip('Radar Chart', () => {
|
||||
it('opens the modal with the correct filters', () => {
|
||||
interceptSamples();
|
||||
|
||||
cy.get("[data-test-viz-type='radar'] canvas").then($canvas => {
|
||||
cy.wrap($canvas).scrollIntoView();
|
||||
cy.wrap($canvas).rightclick(180, 45);
|
||||
|
||||
drillToDetailBy('Drill to detail by boy');
|
||||
cy.getBySel('filter-val').should('contain', 'boy');
|
||||
closeModal();
|
||||
|
||||
cy.wrap($canvas).scrollIntoView();
|
||||
cy.wrap($canvas).rightclick(180, 85);
|
||||
|
||||
drillToDetailBy('Drill to detail by girl');
|
||||
cy.getBySel('filter-val').should('contain', 'girl');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Treemap', () => {
|
||||
it('opens the modal with the correct filters', () => {
|
||||
interceptSamples();
|
||||
|
||||
cy.get("[data-test-viz-type='treemap_v2'] canvas").then($canvas => {
|
||||
cy.wrap($canvas).scrollIntoView();
|
||||
cy.wrap($canvas).rightclick(100, 30);
|
||||
|
||||
drillToDetailBy('Drill to detail by boy');
|
||||
cy.getBySel('filter-val').should('contain', 'boy');
|
||||
closeModal();
|
||||
|
||||
cy.wrap($canvas).scrollIntoView();
|
||||
cy.wrap($canvas).rightclick(150, 250);
|
||||
|
||||
drillToDetailBy('Drill to detail by girl');
|
||||
cy.getBySel('filter-val').should('contain', 'girl');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,201 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { SAMPLE_DASHBOARD_1 } from 'cypress/utils/urls';
|
||||
import { interceptCharts, interceptDatasets, interceptGet } from './utils';
|
||||
|
||||
export const SAMPLE_CHART = { name: 'Most Populated Countries', viz: 'table' };
|
||||
|
||||
/**
|
||||
* Look up a dataset ID by table name
|
||||
*/
|
||||
export function getDatasetId(tableName: string): Cypress.Chainable<number> {
|
||||
return cy
|
||||
.request({
|
||||
method: 'GET',
|
||||
url: `/api/v1/dataset/?q=${encodeURIComponent(
|
||||
JSON.stringify({
|
||||
filters: [{ col: 'table_name', opr: 'eq', value: tableName }],
|
||||
}),
|
||||
)}`,
|
||||
})
|
||||
.then(response => {
|
||||
const datasets = response.body.result;
|
||||
if (datasets && datasets.length > 0) {
|
||||
return datasets[0].id;
|
||||
}
|
||||
throw new Error(`Dataset with table name "${tableName}" not found`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a chart ID by slice name
|
||||
*/
|
||||
export function getChartId(sliceName: string): Cypress.Chainable<number> {
|
||||
return cy
|
||||
.request({
|
||||
method: 'GET',
|
||||
url: `/api/v1/chart/?q=${encodeURIComponent(
|
||||
JSON.stringify({
|
||||
filters: [{ col: 'slice_name', opr: 'eq', value: sliceName }],
|
||||
}),
|
||||
)}`,
|
||||
})
|
||||
.then(response => {
|
||||
const charts = response.body.result;
|
||||
if (charts && charts.length > 0) {
|
||||
return charts[0].id;
|
||||
}
|
||||
throw new Error(`Chart with slice name "${sliceName}" not found`);
|
||||
});
|
||||
}
|
||||
|
||||
export function visitDashboard(createSample = true) {
|
||||
interceptCharts();
|
||||
interceptGet();
|
||||
interceptDatasets();
|
||||
|
||||
if (createSample) {
|
||||
cy.createSampleDashboards([0]);
|
||||
}
|
||||
|
||||
cy.visit(SAMPLE_DASHBOARD_1);
|
||||
cy.wait('@get');
|
||||
cy.wait('@getCharts');
|
||||
cy.wait('@getDatasets');
|
||||
cy.url().should('contain', 'native_filters_key');
|
||||
}
|
||||
|
||||
export function prepareDashboardFilters(
|
||||
filters: { name: string; column: string; datasetId?: number }[],
|
||||
) {
|
||||
cy.createSampleDashboards([0]);
|
||||
|
||||
// First, look up the dataset ID and chart ID dynamically
|
||||
getDatasetId('wb_health_population').then(datasetId => {
|
||||
getChartId('Most Populated Countries').then(chartId => {
|
||||
cy.request({
|
||||
method: 'GET',
|
||||
url: `api/v1/dashboard/1-sample-dashboard`,
|
||||
}).then(res => {
|
||||
const { body } = res;
|
||||
const dashboardId = body.result.id;
|
||||
const allFilters: Record<string, unknown>[] = [];
|
||||
filters.forEach((f, i) => {
|
||||
allFilters.push({
|
||||
id: `NATIVE_FILTER-fLH0pxFQ${i}`,
|
||||
controlValues: {
|
||||
enableEmptyFilter: false,
|
||||
defaultToFirstItem: false,
|
||||
creatable: true,
|
||||
multiSelect: true,
|
||||
searchAllOptions: false,
|
||||
inverseSelection: false,
|
||||
},
|
||||
name: f.name,
|
||||
filterType: 'filter_select',
|
||||
targets: [
|
||||
{
|
||||
datasetId: f.datasetId ?? datasetId,
|
||||
column: { name: f.column },
|
||||
},
|
||||
],
|
||||
defaultDataMask: {
|
||||
extraFormData: {},
|
||||
filterState: {},
|
||||
ownState: {},
|
||||
},
|
||||
cascadeParentIds: [],
|
||||
scope: {
|
||||
rootPath: ['ROOT_ID'],
|
||||
excluded: [],
|
||||
},
|
||||
type: 'NATIVE_FILTER',
|
||||
description: '',
|
||||
chartsInScope: [chartId],
|
||||
tabsInScope: [],
|
||||
});
|
||||
});
|
||||
if (dashboardId) {
|
||||
const jsonMetadata = {
|
||||
native_filter_configuration: allFilters,
|
||||
chart_customization_config: [],
|
||||
timed_refresh_immune_slices: [],
|
||||
expanded_slices: {},
|
||||
expand_all_slices: false,
|
||||
refresh_frequency: 0,
|
||||
color_scheme: '',
|
||||
label_colors: {},
|
||||
shared_label_colors: [],
|
||||
color_scheme_domain: [],
|
||||
cross_filters_enabled: false,
|
||||
positions: {
|
||||
DASHBOARD_VERSION_KEY: 'v2',
|
||||
ROOT_ID: { type: 'ROOT', id: 'ROOT_ID', children: ['GRID_ID'] },
|
||||
GRID_ID: {
|
||||
type: 'GRID',
|
||||
id: 'GRID_ID',
|
||||
children: ['ROW-0rHnUz4nMA'],
|
||||
parents: ['ROOT_ID'],
|
||||
},
|
||||
HEADER_ID: {
|
||||
id: 'HEADER_ID',
|
||||
type: 'HEADER',
|
||||
meta: { text: '1 - Sample dashboard' },
|
||||
},
|
||||
'CHART-DF6EfI55F-': {
|
||||
type: 'CHART',
|
||||
id: 'CHART-DF6EfI55F-',
|
||||
children: [],
|
||||
parents: ['ROOT_ID', 'GRID_ID', 'ROW-0rHnUz4nMA'],
|
||||
meta: {
|
||||
width: 4,
|
||||
height: 50,
|
||||
chartId,
|
||||
sliceName: 'Most Populated Countries',
|
||||
},
|
||||
},
|
||||
'ROW-0rHnUz4nMA': {
|
||||
type: 'ROW',
|
||||
id: 'ROW-0rHnUz4nMA',
|
||||
children: ['CHART-DF6EfI55F-'],
|
||||
parents: ['ROOT_ID', 'GRID_ID'],
|
||||
meta: { background: 'BACKGROUND_TRANSPARENT' },
|
||||
},
|
||||
},
|
||||
default_filters: '{}',
|
||||
filter_scopes: {},
|
||||
chart_configuration: {},
|
||||
};
|
||||
|
||||
return cy
|
||||
.request({
|
||||
method: 'PUT',
|
||||
url: `api/v1/dashboard/${dashboardId}`,
|
||||
body: {
|
||||
json_metadata: JSON.stringify(jsonMetadata),
|
||||
},
|
||||
})
|
||||
.then(() => visitDashboard(false));
|
||||
}
|
||||
return cy;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,559 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { dashboardView, nativeFilters } from 'cypress/support/directories';
|
||||
import {
|
||||
ChartSpec,
|
||||
setSelectSearchInput,
|
||||
waitForChartLoad,
|
||||
} from 'cypress/utils';
|
||||
|
||||
export const WORLD_HEALTH_CHARTS = [
|
||||
{ name: '% Rural', viz: 'world_map' },
|
||||
{ name: 'Most Populated Countries', viz: 'table' },
|
||||
{ name: "World's Population", viz: 'big_number' },
|
||||
{ name: 'Growth Rate', viz: 'echarts_timeseries_line' },
|
||||
{ name: 'Rural Breakdown', viz: 'sunburst_v2' },
|
||||
{ name: "World's Pop Growth", viz: 'echarts_area' },
|
||||
{ name: 'Life Expectancy VS Rural %', viz: 'bubble_v2' },
|
||||
{ name: 'Treemap', viz: 'treemap_v2' },
|
||||
{ name: 'Box plot', viz: 'box_plot' },
|
||||
] as ChartSpec[];
|
||||
|
||||
export const SUPPORTED_TIER1_CHARTS = [
|
||||
{ name: 'Big Number', viz: 'big_number_total' },
|
||||
{ name: 'Big Number with Trendline', viz: 'big_number' },
|
||||
{ name: 'Pie Chart', viz: 'pie' },
|
||||
{ name: 'Table', viz: 'table' },
|
||||
{ name: 'Pivot Table', viz: 'pivot_table_v2' },
|
||||
{ name: 'Line Chart', viz: 'echarts_timeseries_line' },
|
||||
{ name: 'Area Chart', viz: 'echarts_area' },
|
||||
{ name: 'Scatter Chart', viz: 'echarts_timeseries_scatter' },
|
||||
{ name: 'Bar Chart', viz: 'echarts_timeseries_bar' },
|
||||
] as ChartSpec[];
|
||||
|
||||
export const SUPPORTED_TIER2_CHARTS = [
|
||||
{ name: 'Box Plot Chart', viz: 'box_plot' },
|
||||
{ name: 'Generic Chart', viz: 'echarts_timeseries' },
|
||||
{ name: 'Smooth Line Chart', viz: 'echarts_timeseries_smooth' },
|
||||
{ name: 'Step Line Chart', viz: 'echarts_timeseries_step' },
|
||||
{ name: 'Funnel Chart', viz: 'funnel' },
|
||||
{ name: 'Gauge Chart', viz: 'gauge_chart' },
|
||||
{ name: 'Radar Chart', viz: 'radar' },
|
||||
{ name: 'Treemap V2 Chart', viz: 'treemap_v2' },
|
||||
{ name: 'Mixed Chart', viz: 'mixed_timeseries' },
|
||||
] as ChartSpec[];
|
||||
|
||||
export const testItems = {
|
||||
dashboard: 'Cypress test Dashboard',
|
||||
dataset: 'Vehicle Sales',
|
||||
datasetForNativeFilter: 'wb_health_population',
|
||||
chart: 'Cypress chart',
|
||||
newChart: 'New Cypress Chart',
|
||||
createdDashboard: 'New Dashboard',
|
||||
defaultNameDashboard: '[ untitled dashboard ]',
|
||||
newDashboardTitle: `Test dashboard [NEW TEST]`,
|
||||
bulkFirstNameDashboard: 'First Dash',
|
||||
bulkSecondNameDashboard: 'Second Dash',
|
||||
worldBanksDataCopy: `World Bank's Data [copy]`,
|
||||
filterType: {
|
||||
value: 'Value',
|
||||
numerical: 'Numerical range',
|
||||
timeColumn: 'Time column',
|
||||
timeGrain: 'Time grain',
|
||||
timeRange: 'Time range',
|
||||
},
|
||||
topTenChart: {
|
||||
name: 'Most Populated Countries',
|
||||
filterColumn: 'country_name',
|
||||
filterColumnYear: 'year',
|
||||
filterColumnRegion: 'region',
|
||||
filterColumnCountryCode: 'country_code',
|
||||
},
|
||||
filterDefaultValue: 'United States',
|
||||
filterOtherCountry: 'China',
|
||||
filterTimeGrain: 'Month',
|
||||
filterTimeColumn: 'created',
|
||||
filterNumericalColumn: 'SP_RUR_TOTL_ZS',
|
||||
};
|
||||
|
||||
export const nativeFilterTooltips = {
|
||||
searchAllFilterOptions:
|
||||
'By default, each filter loads at most 1000 choices at the initial page load. Check this box if you have more than 1000 filter values and want to enable dynamically searching that loads filter values as users type (may add stress to your database).',
|
||||
defaultToFirstItem: 'When using this option, default value can’t be set',
|
||||
inverseSelection: 'Exclude selected values',
|
||||
required: 'User must select a value before applying the filter',
|
||||
multipleSelect: 'Allow selecting multiple values',
|
||||
defaultValue:
|
||||
'Default value must be set when "Filter value is required" is checked',
|
||||
preFilter: `Add filter clauses to control the filter's source query, though only in the context of the autocomplete i.e., these conditions do not impact how the filter is applied to the dashboard. This is useful when you want to improve the query's performance by only scanning a subset of the underlying data or limit the available values displayed in the filter.`,
|
||||
};
|
||||
|
||||
export const nativeFilterOptions = [
|
||||
'Filter has default value',
|
||||
'Multiple select',
|
||||
'Filter value is required',
|
||||
'Filter is hierarchical',
|
||||
'Default to first item',
|
||||
'Inverse selection',
|
||||
'Search all filter options',
|
||||
'Pre-filter available values',
|
||||
'Sort filter values',
|
||||
];
|
||||
|
||||
export const valueNativeFilterOptions = [
|
||||
'Pre-filter available values',
|
||||
'Sort filter values',
|
||||
'Filter has default value',
|
||||
'Select first filter value by default',
|
||||
'Can select multiple values',
|
||||
'Dynamically search all filter values',
|
||||
'Inverse selection',
|
||||
'Filter value is required',
|
||||
];
|
||||
|
||||
export function interceptGet() {
|
||||
cy.intercept('GET', '**/api/v1/dashboard/*').as('get');
|
||||
}
|
||||
|
||||
export function interceptFiltering() {
|
||||
cy.intercept('GET', `**/api/v1/dashboard/?q=*`).as('filtering');
|
||||
}
|
||||
|
||||
export function interceptBulkDelete() {
|
||||
cy.intercept('DELETE', `**/api/v1/dashboard/?q=*`).as('bulkDelete');
|
||||
}
|
||||
|
||||
export function interceptDelete() {
|
||||
cy.intercept('DELETE', `**/api/v1/dashboard/*`).as('delete');
|
||||
}
|
||||
|
||||
export function interceptUpdate() {
|
||||
cy.intercept('PUT', `**/api/v1/dashboard/*`).as('update');
|
||||
}
|
||||
|
||||
export function interceptExploreUpdate() {
|
||||
cy.intercept('PUT', `**/api/v1/chart/*`).as('chartUpdate');
|
||||
}
|
||||
|
||||
export function interceptPost() {
|
||||
cy.intercept('POST', `**/api/v1/dashboard/`).as('post');
|
||||
}
|
||||
|
||||
export function interceptLog() {
|
||||
cy.intercept('**/superset/log/?explode=events&dashboard_id=*').as('logs');
|
||||
}
|
||||
|
||||
export function interceptDataset() {
|
||||
cy.intercept('GET', `**/api/v1/dataset/*`).as('getDataset');
|
||||
}
|
||||
|
||||
export function interceptCharts() {
|
||||
cy.intercept('GET', `**/api/v1/dashboard/*/charts`).as('getCharts');
|
||||
}
|
||||
|
||||
export function interceptDatasets() {
|
||||
cy.intercept('GET', `**/api/v1/dashboard/*/datasets`).as('getDatasets');
|
||||
}
|
||||
|
||||
export function interceptFilterState() {
|
||||
cy.intercept('POST', `**/api/v1/dashboard/*/filter_state*`).as(
|
||||
'postFilterState',
|
||||
);
|
||||
}
|
||||
|
||||
export function setFilter(filter: string, option: string) {
|
||||
interceptFiltering();
|
||||
|
||||
cy.get(`[aria-label^="${filter}"]`).first().click();
|
||||
cy.get(`.ant-select-item-option[title="${option}"]`).first().click({
|
||||
force: true,
|
||||
});
|
||||
|
||||
cy.wait('@filtering');
|
||||
}
|
||||
|
||||
/** ************************************************************************
|
||||
* Expend Native filter from the left panel on dashboard
|
||||
* @returns {None}
|
||||
* @summary helper for expend native filter
|
||||
************************************************************************* */
|
||||
export function expandFilterOnLeftPanel() {
|
||||
return cy
|
||||
.get(nativeFilters.filterFromDashboardView.expand)
|
||||
.should('be.visible')
|
||||
.click({ force: true });
|
||||
}
|
||||
|
||||
/** ************************************************************************
|
||||
* Collapses Native Filter from the left panel on dashboard
|
||||
* @returns {None}
|
||||
* @summary helper for collapse native filter
|
||||
************************************************************************* */
|
||||
export function collapseFilterOnLeftPanel() {
|
||||
cy.get(nativeFilters.filterFromDashboardView.collapse)
|
||||
.should('be.visible')
|
||||
.click();
|
||||
cy.get(nativeFilters.filterFromDashboardView.collapse).should(
|
||||
'not.be.visible',
|
||||
);
|
||||
}
|
||||
|
||||
/** ************************************************************************
|
||||
* Enter Native Filter edit modal from the left panel on dashboard
|
||||
* @returns {None}
|
||||
* @summary helper for enter native filter edit modal
|
||||
************************************************************************* */
|
||||
export function enterNativeFilterEditModal(waitForDataset = true) {
|
||||
interceptDataset();
|
||||
cy.get(nativeFilters.filtersPanel.filterGear).click({
|
||||
force: true,
|
||||
});
|
||||
cy.get('.ant-dropdown-menu').should('be.visible');
|
||||
cy.get(nativeFilters.filterFromDashboardView.createFilterButton).click({
|
||||
force: true,
|
||||
});
|
||||
cy.get(nativeFilters.modal.container).should('be.visible');
|
||||
if (waitForDataset) {
|
||||
cy.wait('@getDataset');
|
||||
}
|
||||
}
|
||||
|
||||
/** ************************************************************************
|
||||
* Clicks on new filter button
|
||||
* @returns {None}
|
||||
* @summary helper for adding new filter
|
||||
************************************************************************* */
|
||||
export function clickOnAddFilterInModal() {
|
||||
cy.get('[data-test="new-item-dropdown-button"]').trigger('mouseover');
|
||||
cy.get('.ant-dropdown-menu').should('be.visible');
|
||||
cy.contains('.ant-dropdown-menu-item', 'Add filter').click();
|
||||
}
|
||||
|
||||
/** ************************************************************************
|
||||
* Fills value native filter form with basic information
|
||||
* @param {string} type type for filter: Value, Numerical range,Time column,Time grain,Time range
|
||||
* @param {string} name name for filter
|
||||
* @param {string} dataset which dataset should be used
|
||||
* @param {string} filterColumn which column should be used
|
||||
* @returns {None}
|
||||
* @summary helper for filling value native filter form
|
||||
************************************************************************* */
|
||||
export function fillNativeFilterForm(
|
||||
type: string,
|
||||
name: string,
|
||||
dataset?: string,
|
||||
filterColumn?: string,
|
||||
) {
|
||||
cy.get(nativeFilters.filtersPanel.filterTypeInput).within(() => {
|
||||
cy.get('input').then($input => {
|
||||
setSelectSearchInput($input, type);
|
||||
});
|
||||
});
|
||||
cy.get(nativeFilters.modal.container)
|
||||
.find(nativeFilters.filtersPanel.filterName)
|
||||
.last()
|
||||
.click({ scrollBehavior: false });
|
||||
cy.get(nativeFilters.modal.container)
|
||||
.find(nativeFilters.filtersPanel.filterName)
|
||||
.last()
|
||||
.clear({ force: true });
|
||||
cy.get(nativeFilters.modal.container)
|
||||
.find(nativeFilters.filtersPanel.filterName)
|
||||
.last()
|
||||
.type(name, { scrollBehavior: false, force: true });
|
||||
|
||||
if (dataset) {
|
||||
cy.get('div[aria-label="Dataset"]').within(() => {
|
||||
cy.get('input').then($input => {
|
||||
setSelectSearchInput($input, dataset, true);
|
||||
});
|
||||
});
|
||||
cy.get(nativeFilters.silentLoading).should('not.exist');
|
||||
}
|
||||
|
||||
if (filterColumn) {
|
||||
cy.get('div[aria-label="Column select"]').within(() => {
|
||||
cy.get('input').then($input => {
|
||||
setSelectSearchInput($input, filterColumn, true);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** ************************************************************************
|
||||
* Get native filter placeholder e.g 9 options
|
||||
* @param {number} index which input it fills
|
||||
* @returns cy object for assertions
|
||||
* @summary helper for getting placeholder value
|
||||
************************************************************************* */
|
||||
export function getNativeFilterPlaceholderWithIndex(index: number) {
|
||||
return cy.get(nativeFilters.filtersPanel.columnEmptyInput).eq(index);
|
||||
}
|
||||
|
||||
/** ************************************************************************
|
||||
* Apply native filter value from dashboard view
|
||||
* @param {number} index which input it fills
|
||||
* @param {string} value what is filter value
|
||||
* @returns {null}
|
||||
* @summary put value to nth native filter input in view
|
||||
************************************************************************* */
|
||||
export function applyNativeFilterValueWithIndex(index: number, value: string) {
|
||||
cy.get(nativeFilters.filterFromDashboardView.filterValueInput)
|
||||
.eq(index)
|
||||
.should('exist', { timeout: 10000 })
|
||||
.type(`${value}{enter}`, { force: true });
|
||||
// click the title to dismiss shown options
|
||||
cy.get(nativeFilters.filterFromDashboardView.filterName)
|
||||
.eq(index)
|
||||
.click({ force: true });
|
||||
}
|
||||
|
||||
/** ************************************************************************
|
||||
* Fills parent filter input
|
||||
* @param {number} index which input it fills
|
||||
* @param {string} value on which filter it depends on
|
||||
* @returns {null}
|
||||
* @summary takes first or second input and modify the depends on filter value
|
||||
************************************************************************* */
|
||||
export function addParentFilterWithValue(index: number, value: string) {
|
||||
return cy
|
||||
.get(nativeFilters.filterConfigurationSections.displayedSection)
|
||||
.within(() => {
|
||||
cy.get('input[aria-label^="Limit type"]')
|
||||
.eq(index)
|
||||
.click({ force: true });
|
||||
cy.get('input[aria-label^="Limit type"]')
|
||||
.eq(index)
|
||||
.type(`${value}{enter}`, { delay: 30, force: true });
|
||||
});
|
||||
}
|
||||
|
||||
/** ************************************************************************
|
||||
* Save Native Filter Settings
|
||||
* @returns {None}
|
||||
* @summary helper for save native filters settings
|
||||
************************************************************************* */
|
||||
export function saveNativeFilterSettings(charts: ChartSpec[]) {
|
||||
cy.get(nativeFilters.modal.footer)
|
||||
.contains('Save')
|
||||
.should('be.visible')
|
||||
.click({ force: true });
|
||||
|
||||
// Wait for modal to either close or remain open
|
||||
cy.get('body').should($body => {
|
||||
const modalExists = $body.find(nativeFilters.modal.container).length > 0;
|
||||
if (modalExists) {
|
||||
cy.get(nativeFilters.modal.footer)
|
||||
.contains('Save')
|
||||
.should('be.visible')
|
||||
.click({ force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure modal is closed
|
||||
cy.get(nativeFilters.modal.container).should('not.exist');
|
||||
|
||||
// Wait for all charts to load
|
||||
charts.forEach(chart => {
|
||||
waitForChartLoad(chart);
|
||||
});
|
||||
}
|
||||
|
||||
/** ************************************************************************
|
||||
* Cancel Native filter settings
|
||||
* @returns {None}
|
||||
* @summary helper for cancel native filters settings
|
||||
************************************************************************* */
|
||||
export function cancelNativeFilterSettings() {
|
||||
cy.get(nativeFilters.modal.footer)
|
||||
.find(nativeFilters.modal.cancelButton)
|
||||
.should('be.visible')
|
||||
.click();
|
||||
cy.get(nativeFilters.modal.alertXUnsavedFilters)
|
||||
.should('be.visible')
|
||||
.should('have.text', 'There are unsaved changes.');
|
||||
cy.get(nativeFilters.modal.footer)
|
||||
.find(nativeFilters.modal.confirmCancelButton)
|
||||
.contains('cancel')
|
||||
.click({ force: true });
|
||||
cy.get(nativeFilters.modal.container).should('not.exist');
|
||||
}
|
||||
|
||||
/** ************************************************************************
|
||||
* Validate filter name on dashboard
|
||||
* @param name: filter name to validate
|
||||
* @return {null}
|
||||
* @summary helper for validate filter name on dashboard
|
||||
************************************************************************* */
|
||||
export function validateFilterNameOnDashboard(name: string) {
|
||||
cy.get(nativeFilters.filterFromDashboardView.filterName)
|
||||
.should('be.visible', { timeout: 40000 })
|
||||
.contains(`${name}`);
|
||||
}
|
||||
|
||||
/** ************************************************************************
|
||||
* Validate filter content on dashboard
|
||||
* @param filterContent: filter content to validate
|
||||
* @return {null}
|
||||
* @summary helper for validate filter content on dashboard
|
||||
************************************************************************* */
|
||||
export function validateFilterContentOnDashboard(filterContent: string) {
|
||||
cy.get(nativeFilters.filterFromDashboardView.filterContent)
|
||||
.contains(`${filterContent}`)
|
||||
.should('be.visible');
|
||||
}
|
||||
|
||||
/** ************************************************************************
|
||||
* Delete Native filter
|
||||
* @return {null}
|
||||
* @summary helper for delete native filter
|
||||
************************************************************************* */
|
||||
export function deleteNativeFilter() {
|
||||
cy.get(nativeFilters.filtersList.removeIcon).first().click();
|
||||
}
|
||||
|
||||
/** ************************************************************************
|
||||
* Undo delete Native filter
|
||||
* @return {null}
|
||||
* @summary helper for undo delete native filter
|
||||
************************************************************************ */
|
||||
export function undoDeleteNativeFilter() {
|
||||
deleteNativeFilter();
|
||||
cy.contains('Undo?').click();
|
||||
}
|
||||
|
||||
/** ************************************************************************
|
||||
* Check Native Filter tooltip content
|
||||
* @param index: tooltip index to check
|
||||
* @param value: tooltip value to check
|
||||
* @return {null}
|
||||
* @summary helper for checking native filter tooltip content by index
|
||||
************************************************************************* */
|
||||
export function checkNativeFilterTooltip(index: number, value: string) {
|
||||
cy.get(nativeFilters.filterConfigurationSections.infoTooltip)
|
||||
.eq(index)
|
||||
.trigger('mouseover');
|
||||
cy.contains(`${value}`).should('be.visible');
|
||||
cy.wait(100);
|
||||
cy.get(nativeFilters.filterConfigurationSections.infoTooltip)
|
||||
.eq(index)
|
||||
.trigger('mouseout');
|
||||
cy.get('body').trigger('mousemove', { clientX: 0, clientY: 0 });
|
||||
cy.wait(500);
|
||||
}
|
||||
|
||||
/** ************************************************************************
|
||||
* Apply advanced time range filter on dashboard
|
||||
* @param startRange: starting time range
|
||||
* @param endRange: ending time range
|
||||
* @return {null}
|
||||
* @summary helper for applying advanced time range filter on dashboard with customize time range
|
||||
************************************************************************* */
|
||||
export function applyAdvancedTimeRangeFilterOnDashboard(
|
||||
startRange?: string,
|
||||
endRange?: string,
|
||||
) {
|
||||
cy.get('.control-label').contains('Range type').should('be.visible');
|
||||
cy.get('.ant-popover-content .ant-select-content')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
cy.get(`[label="Advanced"]`).should('be.visible').click();
|
||||
cy.get('.section-title').contains('Advanced Time Range').should('be.visible');
|
||||
if (startRange) {
|
||||
cy.get('.ant-popover-content')
|
||||
.find('[class^=ant-input]')
|
||||
.first()
|
||||
.type(`${startRange}`);
|
||||
}
|
||||
if (endRange) {
|
||||
cy.get('.ant-popover-content')
|
||||
.find('[class^=ant-input]')
|
||||
.last()
|
||||
.type(`${endRange}`);
|
||||
}
|
||||
cy.get(dashboardView.timeRangeModal.applyButton).click();
|
||||
cy.get(nativeFilters.applyFilter).click();
|
||||
}
|
||||
|
||||
/** ************************************************************************
|
||||
* Input default value in Native filter in filter settings
|
||||
* @param defaultValue: default value for native filter
|
||||
* @return {null}
|
||||
* @summary helper for input default value in Native filter in filter settings
|
||||
************************************************************************* */
|
||||
export function inputNativeFilterDefaultValue(
|
||||
defaultValue: string,
|
||||
multiple = false,
|
||||
) {
|
||||
if (!multiple) {
|
||||
cy.contains('Filter has default value').click();
|
||||
cy.contains('Please choose a valid value').should('be.visible');
|
||||
cy.get(nativeFilters.modal.container).within(() => {
|
||||
cy.get(
|
||||
nativeFilters.filterConfigurationSections.filterPlaceholder,
|
||||
).contains('options');
|
||||
cy.get(
|
||||
nativeFilters.filterConfigurationSections.collapsedSectionContainer,
|
||||
)
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('.ant-select-input').type(`${defaultValue}{enter}`, {
|
||||
force: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
cy.getBySel('default-input').within(() => {
|
||||
cy.get('.ant-select-input').click();
|
||||
cy.get('.ant-select-item-option-content').contains(defaultValue).click();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** ************************************************************************
|
||||
* add filter for test column 'Country name'
|
||||
* @return {null}
|
||||
* @summary helper for add filter for test column 'Country name'
|
||||
************************************************************************* */
|
||||
export function addCountryNameFilter() {
|
||||
fillNativeFilterForm(
|
||||
testItems.filterType.value,
|
||||
testItems.topTenChart.filterColumn,
|
||||
testItems.datasetForNativeFilter,
|
||||
testItems.topTenChart.filterColumn,
|
||||
);
|
||||
}
|
||||
|
||||
export function openTab(
|
||||
tabComponentIndex: number,
|
||||
tabIndex: number,
|
||||
target = 'dashboard-component-tabs',
|
||||
) {
|
||||
cy.getBySel(target)
|
||||
.eq(tabComponentIndex)
|
||||
.find('[role="tab"]')
|
||||
.eq(tabIndex)
|
||||
.click();
|
||||
cy.wait(500);
|
||||
}
|
||||
|
||||
export const openTopLevelTab = (tabName: string) => {
|
||||
cy.get("div#TABS-TOP div[role='tab']").contains(tabName).click();
|
||||
};
|
||||
@@ -17,9 +17,9 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { CHART_LIST } from 'cypress/utils/urls';
|
||||
import { interceptGet as interceptDashboardGet } from 'cypress/e2e/dashboard/utils';
|
||||
import { FORM_DATA_DEFAULTS, NUM_METRIC } from './visualizations/shared.helper';
|
||||
import {
|
||||
interceptDashboardGet,
|
||||
interceptFiltering,
|
||||
interceptV1ChartData,
|
||||
saveChartToDashboard,
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { interceptGet as interceptDashboardGet } from '../dashboard/utils';
|
||||
export function interceptDashboardGet() {
|
||||
cy.intercept('GET', '**/api/v1/dashboard/*').as('get');
|
||||
}
|
||||
|
||||
export function interceptFiltering() {
|
||||
cy.intercept('GET', `**/api/v1/chart/?q=*`).as('filtering');
|
||||
|
||||
@@ -1,678 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
const dataTestLocator = (value: string) => `[data-test='${value}']`;
|
||||
|
||||
export function dataTestChartName(chartName: string): string {
|
||||
return `[data-test-chart-name='${chartName}']`;
|
||||
}
|
||||
|
||||
export const pageHeader = {
|
||||
logo: '.navbar-brand > img',
|
||||
headerNavigationItem: '.ant-menu-submenu-title',
|
||||
headerNavigationDropdown: "[aria-label='triangle-down']",
|
||||
headerNavigationItemMenu: '.ant-menu-item-group-list',
|
||||
plusIcon: ':nth-child(2) > .ant-menu-submenu-title',
|
||||
plusIconMenuOptions: {
|
||||
sqlQueryOption: dataTestLocator('menu-item-SQL query'),
|
||||
chartOption: dataTestLocator('menu-item-Chart'),
|
||||
dashboardOption: dataTestLocator('menu-item-Dashboard'),
|
||||
},
|
||||
plusMenu: '.ant-menu-submenu-popup',
|
||||
barButtons: '[role="presentation"]',
|
||||
sqlLabMenu: '[id="item_3$Menu"]',
|
||||
dataMenu: '[id="item_4$Menu"]',
|
||||
};
|
||||
|
||||
export const profile = {
|
||||
activeTab: '.ant-tabs-tab-active',
|
||||
inactiveTab: '.ant-tabs-tab',
|
||||
emptyFavoritedPlaceholder: '.ant-empty-normal',
|
||||
tableRow: '.table-row',
|
||||
favoritesSpace: '#rc-tabs-0-panel-2',
|
||||
};
|
||||
export const securityAccess = {
|
||||
rolesBubble: '.ant-badge-count',
|
||||
};
|
||||
export const homePage = {
|
||||
homeSection: {
|
||||
sectionArea: '.ant-collapse-body',
|
||||
sectionElement: '.ant-card-meta-title',
|
||||
},
|
||||
sections: {
|
||||
expandedSection: '.ant-collapse-item-active',
|
||||
expandedSectionHeader: '[aria-expanded="true"]',
|
||||
collapseExpandButton: '.ant-collapse-arrow',
|
||||
collapsedSection: '[class="ant-collapse-item"]',
|
||||
collapsedSectionHeader: '[aria-expanded="false"]',
|
||||
section: '[class^="ant-collapse-item"]',
|
||||
sectionsMenuContainer: "[role='navigation']",
|
||||
sectionsMenuItem: "[role='menuitem']",
|
||||
card: dataTestLocator('styled-card'),
|
||||
},
|
||||
thumbnailsSection: {
|
||||
container: "[class='switch']",
|
||||
toggle: "[role ='switch']",
|
||||
},
|
||||
};
|
||||
export const databasesPage = {
|
||||
addDatabaseButton: dataTestLocator('btn-create-database'),
|
||||
searchInput: dataTestLocator('search-input'),
|
||||
table: {
|
||||
tableRow: dataTestLocator('table-row'),
|
||||
tableCell: dataTestLocator('table-row-cell'),
|
||||
editButton: dataTestLocator('edit-alt'),
|
||||
},
|
||||
modal: {
|
||||
header: '#rcDialogTitle0 > h4',
|
||||
step: '.helper-top',
|
||||
selectDbStepTitle: '.select-db > h4',
|
||||
preferredBlockBigQuery: '.preferred > :nth-child(1)',
|
||||
preferredBlockPostgreSQL: '.preferred > :nth-child(2)',
|
||||
preferredBlockSnowflake: '.preferred > :nth-child(3)',
|
||||
preferredBlockMySQL: '.preferred > :nth-child(4)',
|
||||
preferredBlockAthena: '.preferred > :nth-child(5)',
|
||||
preferredBlockSheets: '.preferred > :nth-child(6)',
|
||||
supportedDatabasesText: '.control-label',
|
||||
orChoose: '.available-label',
|
||||
dbDropdown: '.ant-select-input',
|
||||
dbDropdownMenu: '.rc-virtual-list-holder-inner',
|
||||
dbDropdownMenuItem: '[class="ant-select-item-option-content"]',
|
||||
infoAlert: '.ant-alert',
|
||||
serviceAccountInput: '[name="credentials_info"]',
|
||||
connectionStep: {
|
||||
modal: '.ant-modal-container',
|
||||
modalBody: '.ant-modal-body',
|
||||
stepTitle: '.css-7x6kk > h4',
|
||||
helperBottom: '.helper-bottom',
|
||||
postgresDatabase: '[name="database"]',
|
||||
dbInput: '[name="database_name"]',
|
||||
alertMessage: '.ant-alert-message',
|
||||
errorField: '[role="alert"]',
|
||||
uploadJson: '[title="Upload JSON file"]',
|
||||
chooseFile: '[class="ant-btn input-upload-btn"]',
|
||||
additionalParameters: '[name="query_input"]',
|
||||
sqlAlchemyUriInput: dataTestLocator('sqlalchemy-uri-input'),
|
||||
advancedTab: '#rc-tabs-0-tab-2',
|
||||
activeTab: '.ant-tabs-tab-active',
|
||||
securitySubMenu:
|
||||
':nth-child(3) > .ant-collapse-header > .anticon > svg > path',
|
||||
aceTextInput: '.ace_text-input',
|
||||
aceContent: '.ace_content',
|
||||
connectButton: '.css-16i3wh7',
|
||||
hostInput: '[name="host"]',
|
||||
portInput: '[name="port"]',
|
||||
usernameInput: '[name="username"]',
|
||||
passwordInput: '[name="password"]',
|
||||
switch: '.ant-switch',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const sqlLabView = {
|
||||
sqlEditorLeftBar: {
|
||||
sqlEditorLeftBar: '[data-test="sql-editor-left-bar"]',
|
||||
databaseSchemaTableSection:
|
||||
'[data-test="sql-editor-left-bar"] > :nth-child(1)',
|
||||
tableSchemaSection:
|
||||
'[data-test="sql-editor-left-bar"] > :nth-child(1) > :nth-child(3) > :nth-child(1)',
|
||||
tableSchemaInputEmpty: '[aria-label="Select table or type table name"]',
|
||||
},
|
||||
databaseInput: '[data-test=DatabaseSelector] > :nth-child(1)',
|
||||
emptyMenuOptionsPlaceholder: '[class="ant-empty-img-simple"]',
|
||||
removeTabButton: '.ant-tabs-tab-remove > .anticon > svg',
|
||||
tabsNavList: "[class='ant-tabs-nav-list']",
|
||||
tab: "[class='ant-tabs-tab-btn']",
|
||||
addTabButton: dataTestLocator('add-tab-icon'),
|
||||
tooltip: '.ant-tooltip-content',
|
||||
tabName: '.css-1suejie',
|
||||
schemaInput: '[data-test=DatabaseSelector] > :nth-child(2)',
|
||||
loadingIndicator: '.Select__loading-indicator',
|
||||
menuItem: '[class="ant-select-item-option-content"]',
|
||||
examplesMenuItem: '[title="examples"]',
|
||||
tableInput: ':nth-child(4) > .select > :nth-child(1)',
|
||||
sqlEditor: '#brace-editor textarea',
|
||||
saveAsButton: '.SaveQuery > .ant-btn',
|
||||
saveAsModal: {
|
||||
footer: '.ant-modal-footer',
|
||||
queryNameInput: 'input[class^="ant-input"]',
|
||||
},
|
||||
sqlToolbar: {
|
||||
toolbar: '#js-sql-toolbar',
|
||||
runButton: '.css-d3dxop',
|
||||
},
|
||||
rowsLimit: {
|
||||
dropdown: '.ant-dropdown-menu',
|
||||
limitButton: '.ant-dropdown-menu-item',
|
||||
limitButtonText: '.css-151uxnz',
|
||||
limitTextWithValue: '[class="ant-dropdown-trigger"]',
|
||||
},
|
||||
renderedTableHeader: '.ReactVirtualized__Table__headerRow',
|
||||
renderedTableRow: '.ReactVirtualized__Table__row',
|
||||
errorBody: '.error-body',
|
||||
alertMessage: '.ant-alert-message',
|
||||
historyTable: {
|
||||
header: '[role=columnheader]',
|
||||
table: '.QueryTable',
|
||||
row: dataTestLocator('table-row'),
|
||||
failureMarkIcon: '[aria-label=close]',
|
||||
successMarkIcon: '[aria-label=check]',
|
||||
},
|
||||
};
|
||||
export const queryHistory = {
|
||||
tableHeader: dataTestLocator('sort-header'),
|
||||
row: dataTestLocator('table-row'),
|
||||
cell: dataTestLocator('table-row-cell'),
|
||||
action: '[aria-label=full]',
|
||||
};
|
||||
export const savedQuery = {
|
||||
tableHeader: dataTestLocator('sort-header'),
|
||||
row: dataTestLocator('table-row'),
|
||||
cell: dataTestLocator('table-row-cell'),
|
||||
trash: dataTestLocator('delete-action'),
|
||||
deleteQueryIcon: '[data-test="delete-action"]',
|
||||
deleteModal: {
|
||||
deleteInput: dataTestLocator('delete-modal-input'),
|
||||
deleteButton: dataTestLocator('modal-confirm-button'),
|
||||
},
|
||||
};
|
||||
export const annotationLayersView = {
|
||||
emptyDescription: {
|
||||
description: '.ant-empty-description',
|
||||
addAnnotationLayerButton: '.ant-empty-footer > .ant-btn',
|
||||
},
|
||||
modal: {
|
||||
content: {
|
||||
content: '.ant-modal-body',
|
||||
title: '.ant-modal-body > :nth-child(2) > input',
|
||||
description: "[name='descr']",
|
||||
},
|
||||
footer: {
|
||||
footer: '.ant-modal-footer',
|
||||
addButton: dataTestLocator('modal-confirm-button'),
|
||||
cancelButton: dataTestLocator('modal-cancel-button'),
|
||||
},
|
||||
},
|
||||
};
|
||||
export const datasetsList = {
|
||||
newDatasetButton: '.css-yff34v',
|
||||
searchInput: dataTestLocator('filters-search'),
|
||||
newDatasetModal: {
|
||||
inputField: '[class="section"]',
|
||||
addButton: dataTestLocator('modal-confirm-button'),
|
||||
body: '.ant-modal-body',
|
||||
},
|
||||
table: {
|
||||
tableRow: {
|
||||
row: dataTestLocator('table-row'),
|
||||
rowText: dataTestLocator('cell-text'),
|
||||
rowCell: dataTestLocator('table-row-cell'),
|
||||
},
|
||||
tableRowHeader: {
|
||||
header: dataTestLocator('sort-header'),
|
||||
unsorted: '[aria-label="sort"]',
|
||||
sortAsc: '[aria-label="sort-asc"]',
|
||||
sortDesc: '[aria-label="sort-desc"]',
|
||||
},
|
||||
rowActionItems: dataTestLocator('table-row-cell'),
|
||||
actionButton: '.action-button',
|
||||
editButton: '[aria-label="edit-alt"]',
|
||||
},
|
||||
editDatasetModal: {
|
||||
modal: dataTestLocator('edit-dataset-tabs'),
|
||||
sourceNavigationTab: '#table-tabs-tab-0',
|
||||
metricsNavigationTab: dataTestLocator('collection-tab-Metrics'),
|
||||
columnsNavigationTab: dataTestLocator('collection-tab-Columns'),
|
||||
calculatedColumnsNavigationTab: dataTestLocator(
|
||||
'collection-tab-Calculated columns',
|
||||
),
|
||||
settingsNavigationTab: '#table-tabs-tab-4',
|
||||
saveButton: dataTestLocator('datasource-modal-save'),
|
||||
sourceContent: '#table-tabs-panel-0',
|
||||
metricsContent: '#table-tabs-panel-1',
|
||||
columnsContent: '#table-tabs-panel-2',
|
||||
calculatedColumnsContent: '#table-tabs-panel-3',
|
||||
settingsContent: '#table-tabs-panel-4',
|
||||
aceTextInput: '.ace_text-input',
|
||||
sourceSQLInput: '.ace_content',
|
||||
sourceVirtualSQLRadio: ':nth-child(2) > .ant-radio > .ant-radio-inner',
|
||||
sourcePadlock: '[aria-label=lock]',
|
||||
legacy: {
|
||||
panel: '.panel-body',
|
||||
sqlInput: '#sql',
|
||||
save: '.well > .btn-primary',
|
||||
description: '#description',
|
||||
},
|
||||
},
|
||||
deleteDatasetModal: {
|
||||
modal: '.ant-modal-container',
|
||||
deleteInput: dataTestLocator('delete-modal-input'),
|
||||
deleteButton: dataTestLocator('modal-confirm-button'),
|
||||
text: '.css-kxmt87',
|
||||
},
|
||||
};
|
||||
export const chartListView = {
|
||||
filtersBar: {
|
||||
searchField: dataTestLocator('filters-search'),
|
||||
},
|
||||
top: {
|
||||
bulkSelect: dataTestLocator('bulk-select'),
|
||||
},
|
||||
header: {
|
||||
cardView: '[aria-label="card-view"]',
|
||||
listView: '[aria-label="list-view"]',
|
||||
sort: '.ant-select-input[aria-label="Sort"]',
|
||||
sortRecentlyModifiedMenuOption: '[label="Recently modified"]',
|
||||
sortAlphabeticalMenuOption: '[label="Alphabetical"]',
|
||||
sortDropdown: '.Select__menu',
|
||||
},
|
||||
card: {
|
||||
card: dataTestLocator('styled-card'),
|
||||
cardCover: '[class="ant-card-cover"]',
|
||||
cardImage: '[class="gradient-container"]',
|
||||
starIcon: dataTestLocator('fave-unfave-icon'),
|
||||
},
|
||||
deleteModal: {
|
||||
deleteInput: dataTestLocator('delete-modal-input'),
|
||||
deleteButton: dataTestLocator('modal-confirm-button'),
|
||||
},
|
||||
table: {
|
||||
bulkSelect: {
|
||||
checkboxOff: 'input[type="checkbox"]:checked',
|
||||
checkboxOn: 'input[type="checkbox"]:not(:checked)',
|
||||
action: dataTestLocator('bulk-select-action'),
|
||||
},
|
||||
tableList: dataTestLocator('listview-table'),
|
||||
table: '[role="rowgroup"]',
|
||||
tableRowHeader: {
|
||||
header: dataTestLocator('sort-header'),
|
||||
unsorted: '[aria-label="sort"]',
|
||||
sortAsc: '[aria-label="sort-asc"]',
|
||||
sortDesc: '[aria-label="sort-desc"]',
|
||||
},
|
||||
tableRow: {
|
||||
row: dataTestLocator('table-row'),
|
||||
rowText: dataTestLocator('cell-text'),
|
||||
rowCell: dataTestLocator('table-row-cell'),
|
||||
},
|
||||
},
|
||||
addChartButton: '.css-yff34v',
|
||||
};
|
||||
export const nativeFilters = {
|
||||
modal: {
|
||||
container: '.ant-modal',
|
||||
footer: '.ant-modal-footer',
|
||||
saveButton: dataTestLocator('native-filter-modal-save-button'),
|
||||
cancelButton: dataTestLocator('native-filter-modal-cancel-button'),
|
||||
confirmCancelButton: dataTestLocator(
|
||||
'native-filter-modal-confirm-cancel-button',
|
||||
),
|
||||
alertXUnsavedFilters: '.ant-alert-message',
|
||||
tabsList: {
|
||||
filterItemsContainer: dataTestLocator('filter-title-container'),
|
||||
tabsContainer: '[class="ant-tabs-nav-list"]',
|
||||
tab: '.ant-tabs-tab',
|
||||
removeTab: '[aria-label="delete"]',
|
||||
},
|
||||
addFilter: dataTestLocator('add-filter-button'),
|
||||
defaultValueCheck: '.ant-checkbox-checked',
|
||||
addNewFilterButton: dataTestLocator('add-new-filter-button'),
|
||||
addNewDividerButton: dataTestLocator('add-new-divider-button'),
|
||||
},
|
||||
filtersPanel: {
|
||||
filterName: dataTestLocator('filters-config-modal__name-input'),
|
||||
datasetName: dataTestLocator('filters-config-modal__datasource-input'),
|
||||
filterInfoInput: '.ant-select-input',
|
||||
inputDropdown: '.ant-select-item-option-content',
|
||||
columnEmptyInput: '.ant-select-placeholder',
|
||||
filterTypeInput: dataTestLocator('filters-config-modal__filter-type'),
|
||||
fieldInput: dataTestLocator('field-input'),
|
||||
filterTypeItem: '.ant-select-content-has-value, .ant-select-selection-item',
|
||||
filterGear: dataTestLocator('filterbar-orientation-icon'),
|
||||
},
|
||||
filterFromDashboardView: {
|
||||
filterValueInput: '.ant-select-input',
|
||||
expand: dataTestLocator('filter-bar__expand-button'),
|
||||
collapse: dataTestLocator('filter-bar__collapse-button'),
|
||||
filterName: dataTestLocator('filter-control-name'),
|
||||
filterContent: '.ant-select-content-has-value, .ant-select-selection-item',
|
||||
createFilterButton: dataTestLocator('filter-bar__create-filter'),
|
||||
timeRangeFilterContent: dataTestLocator('time-range-trigger'),
|
||||
},
|
||||
createFilterButton: dataTestLocator('filter-bar__create-filter'),
|
||||
removeFilter: '[aria-label="remove"]',
|
||||
silentLoading: '.loading inline-centered css-101mkpk',
|
||||
filterConfigurationSections: {
|
||||
sectionHeader: '.ant-collapse-header',
|
||||
displayedSection: 'div[style="height: 100%; overflow-y: auto;"]',
|
||||
collapseExpandButton: '.ant-collapse-arrow',
|
||||
checkedCheckbox: '.ant-checkbox-wrapper-checked',
|
||||
infoTooltip: '[aria-label="Show info tooltip"]',
|
||||
parentFilterInput: dataTestLocator('parent-filter-input'),
|
||||
filterPlaceholder: '.ant-select-placeholder',
|
||||
collapsedSectionContainer: '[class="ant-collapse-body"]',
|
||||
},
|
||||
filtersList: {
|
||||
list: '.ant-tabs-nav-list',
|
||||
listItemNotActive: '[class="ant-tabs-tab ant-tabs-tab-with-remove"]',
|
||||
listItemActive:
|
||||
'[class="ant-tabs-tab ant-tabs-tab-with-remove ant-tabs-tab-active"]',
|
||||
removeIcon: '[aria-label="delete"]',
|
||||
},
|
||||
filterItem: dataTestLocator('form-item-value'),
|
||||
filterItemDropdown: '.ant-select-input',
|
||||
applyFilter: dataTestLocator('filter-bar__apply-button'),
|
||||
defaultInput: dataTestLocator('default-input'),
|
||||
filterIcon: dataTestLocator('filter-icon'),
|
||||
slider: {
|
||||
slider: '[class="ant-slider"]',
|
||||
startHandle: '[class="ant-slider-handle ant-slider-handle-1"]',
|
||||
endHandle: '[class="ant-slider-handle ant-slider-handle-2"]',
|
||||
sliderText: '[class="ant-slider-mark-text ant-slider-mark-text-active"]',
|
||||
},
|
||||
};
|
||||
export const dashboardListView = {
|
||||
dashboardListView: dataTestLocator('dashboard-list-view'),
|
||||
editItemAction: dataTestLocator('edit-alt'),
|
||||
filtersBar: {
|
||||
searchField: dataTestLocator('filters-search'),
|
||||
},
|
||||
top: {
|
||||
bulkSelect: dataTestLocator('bulk-select'),
|
||||
},
|
||||
card: {
|
||||
card: dataTestLocator('styled-card'),
|
||||
cardCover: '[class="ant-card-cover"]',
|
||||
cardImage: '[class="gradient-container"]',
|
||||
selectedStarIcon: "[aria-label='star']",
|
||||
unselectedStarIcon: "[aria-label='star']",
|
||||
starIcon: dataTestLocator('fave-unfave-icon'),
|
||||
},
|
||||
deleteModal: {
|
||||
deleteInput: dataTestLocator('delete-modal-input'),
|
||||
deleteButton: dataTestLocator('modal-confirm-button'),
|
||||
},
|
||||
table: {
|
||||
starIcon: dataTestLocator('fave-unfave-icon'),
|
||||
selectedStarIcon: "[aria-label='star']",
|
||||
unselectedStarIcon: "[aria-label='star']",
|
||||
bulkSelect: {
|
||||
checkboxOff: '[aria-label="checkbox-off"]',
|
||||
checkboxOn: '[aria-label="checkbox-on"]',
|
||||
action: dataTestLocator('bulk-select-action'),
|
||||
},
|
||||
tableRow: {
|
||||
sortHeader: dataTestLocator('sort-header'),
|
||||
row: dataTestLocator('table-row'),
|
||||
rowText: dataTestLocator('cell-text'),
|
||||
rowCell: dataTestLocator('table-row-cell'),
|
||||
},
|
||||
tableRowList: '[role="rowgroup"]',
|
||||
tableList: dataTestLocator('listview-table'),
|
||||
trashIcon: dataTestLocator('dashboard-list-trash-icon'),
|
||||
},
|
||||
actions: {
|
||||
importButton: dataTestLocator('import-button'),
|
||||
newDashboardButton: '.css-yff34v',
|
||||
},
|
||||
importModal: {
|
||||
selectFileButton: '.ant-upload > .ant-btn > span',
|
||||
importButton: dataTestLocator('modal-confirm-button'),
|
||||
},
|
||||
header: {
|
||||
cardView: '[aria-label="appstore"]',
|
||||
listView: '[aria-label="unordered-list"]',
|
||||
sort: dataTestLocator('sort-header'),
|
||||
sortDropdown: '.Select__menu',
|
||||
statusFilterInput: `${dataTestLocator(
|
||||
'filters-select',
|
||||
)}[aria-label='Status']`,
|
||||
ownerFilterInput: `${dataTestLocator(
|
||||
'filters-select',
|
||||
)}[aria-label='Owner']`,
|
||||
createdByFilterInput: `${dataTestLocator(
|
||||
'filters-select',
|
||||
)}[aria-label='Created by']`,
|
||||
FavoriteFilterInput: `${dataTestLocator(
|
||||
'filters-select',
|
||||
)}[aria-label='Favorite']`,
|
||||
searchFilterInput: `${dataTestLocator(
|
||||
'filters-search',
|
||||
)}[name='dashboard_title']`,
|
||||
draftStatusFilterOption: "[title='Draft']",
|
||||
},
|
||||
};
|
||||
export const exploreView = {
|
||||
openDatasourceMenu: dataTestLocator('open-datasource-tab'),
|
||||
sectionsHeader: '.ant-collapse-header',
|
||||
datasourceMenuThreeDots: dataTestLocator('datasource-menu-trigger'),
|
||||
threeDotsMenuDropdown: {
|
||||
editDataset: dataTestLocator('edit-dataset'),
|
||||
},
|
||||
chartContainer: dataTestLocator('chart-container'),
|
||||
chartHeading: {
|
||||
queryMenu: '#query',
|
||||
viewQueryButton: dataTestLocator('view-query-menu-item'),
|
||||
shortLinkButton: dataTestLocator('short-link-button'),
|
||||
embedCodeButton: dataTestLocator('embed-code-button'),
|
||||
},
|
||||
chartAreaItem: '.nv-legend-text',
|
||||
viewQueryModal: {
|
||||
container: '.ant-modal-container',
|
||||
closeButton: 'button.ant-modal-close',
|
||||
},
|
||||
embedCodeModal: {
|
||||
container: dataTestLocator('embed-code-popover'),
|
||||
textfield: dataTestLocator('embed-code-textarea'),
|
||||
},
|
||||
saveModal: {
|
||||
modal: '.ant-modal-container',
|
||||
chartNameInput: dataTestLocator('new-chart-name'),
|
||||
dashboardNameInput: '.ant-select-input',
|
||||
addToDashboardInput: dataTestLocator(
|
||||
'save-chart-modal-select-dashboard-form',
|
||||
),
|
||||
saveButton: dataTestLocator('btn-modal-save'),
|
||||
saveAsCircle: dataTestLocator('saveas-radio'),
|
||||
overwriteCircle: dataTestLocator('save-overwrite-radio'),
|
||||
saveAndGoToDashboard: '#btn_modal_save_goto_dash',
|
||||
},
|
||||
controlPanel: {
|
||||
panel: dataTestLocator('control-tabs'),
|
||||
categoryArea: '.ant-collapse-body',
|
||||
dragField: dataTestLocator('datasource'),
|
||||
metricsField: dataTestLocator('metrics'),
|
||||
optionField: dataTestLocator('option-label'),
|
||||
fieldInput: '.Select__control input',
|
||||
removeFieldValue: dataTestLocator('remove-control-button'),
|
||||
addFieldValue: '[aria-label="plus"]',
|
||||
vizType: dataTestLocator('visualization-type'),
|
||||
runButton: dataTestLocator('run-query-button'),
|
||||
saveQuery: dataTestLocator('query-save-button'),
|
||||
fieldValue: dataTestLocator('control-label'),
|
||||
addMetricModal: {
|
||||
container: dataTestLocator('metrics-edit-popover'),
|
||||
metricNameEdit: {
|
||||
editButton: dataTestLocator('AdhocMetricEditTitle#trigger'),
|
||||
input: dataTestLocator('AdhocMetricEditTitle#input'),
|
||||
},
|
||||
simpleTab: {
|
||||
tab: '#adhoc-metric-edit-tabs-tab-SIMPLE',
|
||||
columnInput: '[name="select-column"]',
|
||||
aggregateInput: '[name="select-aggregate"]',
|
||||
},
|
||||
customSQLTab: {
|
||||
tab: '#adhoc-metric-edit-tabs-tab-SQL',
|
||||
inputContainerField: '.ace_content',
|
||||
input: '.ace_text-input',
|
||||
},
|
||||
saveButton: dataTestLocator('AdhocMetricEdit#save'),
|
||||
},
|
||||
querySection: {
|
||||
groupByField: dataTestLocator('groupby'),
|
||||
filtersField: dataTestLocator('adhoc_filters'),
|
||||
filterValue: dataTestLocator('adhoc-filter-simple-value'),
|
||||
filterModal: {
|
||||
customSQL: {
|
||||
tab: '#adhoc-filter-edit-tabs-tab-SQL',
|
||||
inputArea: '.ace_content',
|
||||
input: '.ace_text-input',
|
||||
},
|
||||
simple: {
|
||||
tab: '#adhoc-filter-edit-tabs-tab-SIMPLE',
|
||||
},
|
||||
container: dataTestLocator('filter-edit-popover'),
|
||||
saveButton: dataTestLocator('adhoc-filter-edit-popover-save-button'),
|
||||
},
|
||||
},
|
||||
annotationLayerSection: {
|
||||
addAnnotationLayer: dataTestLocator('annotation_layers'),
|
||||
addAnnotationLayerModal: {
|
||||
container: dataTestLocator('popover-content'),
|
||||
name: dataTestLocator('annotation-layer-name-header'),
|
||||
formula: dataTestLocator('annotation-layer-value-header'),
|
||||
input: dataTestLocator(''),
|
||||
},
|
||||
},
|
||||
timeSection: {
|
||||
timeRangeFilter: dataTestLocator('time-range-trigger'),
|
||||
timeRangeFilterModal: {
|
||||
container: '.ant-popover-content',
|
||||
footer: '.footer',
|
||||
cancelButton: dataTestLocator('cancel-button'),
|
||||
configureLastTimeRange: {
|
||||
container: '.ant-radio-group',
|
||||
checkedItem: '.ant-radio-checked + span',
|
||||
},
|
||||
configureCustomTimeRange: {
|
||||
container: dataTestLocator('custom-frame'),
|
||||
timeInput: '.ant-input-number-input-wrap > input',
|
||||
},
|
||||
configureNoFilterTimeRange: {
|
||||
container: dataTestLocator('no-filter'),
|
||||
},
|
||||
},
|
||||
},
|
||||
advancedAnalyticsSection: {
|
||||
timeShift: {
|
||||
container: dataTestLocator('time_compare'),
|
||||
input: 'input[type=text]',
|
||||
selectedValues: '.Select__multi-value__label',
|
||||
},
|
||||
},
|
||||
},
|
||||
editDatasetModal: {
|
||||
container: '.ant-modal-container',
|
||||
datasetTabsContainer: dataTestLocator('edit-dataset-tabs'),
|
||||
saveButton: dataTestLocator('datasource-modal-save'),
|
||||
metricsTab: {
|
||||
addItem: dataTestLocator('crud-add-table-item'),
|
||||
rowsContainer: '.ant-table-body',
|
||||
},
|
||||
confirmModal: {
|
||||
okButton: '.ant-modal-confirm-btns .ant-btn-primary',
|
||||
},
|
||||
},
|
||||
visualizationTypeModal: {
|
||||
vizTypeButton: dataTestLocator('viztype-selector-container'),
|
||||
},
|
||||
};
|
||||
export const createChartView = {
|
||||
chooseDatasetInput: '.ant-select-input',
|
||||
chooseDatasetOption: '.ant-select-item-option-content',
|
||||
chooseDatasetList: '.rc-virtual-list-holder-inner',
|
||||
tableVizType: "[alt='Table']",
|
||||
};
|
||||
export const editDashboardView = {
|
||||
dashboardName: dataTestLocator('dashboard-title-input'),
|
||||
dashboardEditingContainer: dataTestLocator('dashboard-content'),
|
||||
editComponent: dataTestLocator('new-component'),
|
||||
discardChanges: dataTestLocator('discard-changes-button'),
|
||||
chartBox: dataTestLocator('chart-grid-component'),
|
||||
tabsList: {
|
||||
tabsContainer: '[class="ant-tabs-nav-list"]',
|
||||
tab: '.ant-tabs-tab',
|
||||
},
|
||||
};
|
||||
export const dashboardView = {
|
||||
dashboardContainer: dataTestLocator('grid-container'),
|
||||
dashboardAlert: {
|
||||
modal: dataTestLocator('toast-container'),
|
||||
closeButton: dataTestLocator('close-button'),
|
||||
},
|
||||
saveModal: {
|
||||
modal: '.ant-modal-container',
|
||||
dashboardNameInput: '.ant-input',
|
||||
saveButton: dataTestLocator('modal-save-dashboard-button'),
|
||||
},
|
||||
dashboardProperties: {
|
||||
modal: '.ant-modal-container',
|
||||
dashboardTitleInput: dataTestLocator('dashboard-title-input'),
|
||||
modalButton: '[type="button"]',
|
||||
},
|
||||
chart: {
|
||||
trashIcon: dataTestLocator('dashboard-delete-component-button'),
|
||||
refreshChart: dataTestLocator('refresh-chart-menu-item'),
|
||||
},
|
||||
threeDotsMenuIcon:
|
||||
'.header-with-actions .right-button-panel .ant-dropdown-trigger',
|
||||
threeDotsMenuDropdown: dataTestLocator('header-actions-menu'),
|
||||
refreshDashboard: dataTestLocator('refresh-dashboard-menu-item'),
|
||||
saveAsMenuOption: dataTestLocator('save-as-menu-item'),
|
||||
dashboardTab: dataTestLocator('dragdroppable-object'),
|
||||
salesDashboardSpecific: {
|
||||
vehicleSalesFilterTimeRange: dataTestLocator('time-range-trigger'),
|
||||
secondTabSalesDashboard: dataTestLocator('dragdroppable-object'),
|
||||
},
|
||||
timeRangeModal: {
|
||||
rangeTypeField: '.ant-select-content-has-value, .ant-select-selection-item',
|
||||
startTimeInputNumber: '.ant-input-number-input',
|
||||
datePicker: '.ant-picker-input',
|
||||
applyButton: dataTestLocator('date-filter-control__apply-button'),
|
||||
radioGroup: '.ant-radio-group',
|
||||
radioButton: '.ant-radio',
|
||||
finalFilterRangePreviewCustom:
|
||||
'.css-c2bup7 > :nth-child(6) > :nth-child(2)',
|
||||
finalFilterRangePreviewLast: '.css-c2bup7 > :nth-child(7) > :nth-child(2)',
|
||||
finalFilterRangePreviewPrevious:
|
||||
'.css-c2bup7 > :nth-child(7) > :nth-child(2)',
|
||||
},
|
||||
treeMapChartModal: {
|
||||
selectItem: '.Select_control',
|
||||
selectItemInput: '.Select__control input[type=text]',
|
||||
applyButton: '.filter button:not(:disabled)',
|
||||
clearItemIcon: '.Select__clear-indicator',
|
||||
},
|
||||
sliceThreeDots: '[aria-label="More Options"]',
|
||||
sliceThreeDotsDropdown: '[role="menu"]',
|
||||
editDashboardButton: '[aria-label="Edit dashboard"]',
|
||||
starIcon: dataTestLocator('fave-unfave-icon'),
|
||||
dashboardHeader: dataTestLocator('dashboard-header'),
|
||||
dashboardSectionContainer: dataTestLocator(
|
||||
'grid-row-background--transparent',
|
||||
),
|
||||
markdownEditor: dataTestLocator('dashboard-markdown-editor'),
|
||||
dashboardComponent: dataTestLocator('dashboard-component-chart-holder'),
|
||||
tabsList: {
|
||||
tabsContainer: dataTestLocator('dashboard-component-tabs'),
|
||||
tabsNavList: dataTestLocator('nav-list'),
|
||||
tabs: '.ant-tabs-nav-list',
|
||||
tab: '.ant-tabs-tab',
|
||||
},
|
||||
};
|
||||
@@ -20,7 +20,5 @@
|
||||
export const DASHBOARD_LIST = '/dashboard/list/';
|
||||
export const CHART_LIST = '/chart/list/';
|
||||
export const WORLD_HEALTH_DASHBOARD = '/dashboard/world_health/';
|
||||
export const SAMPLE_DASHBOARD_1 = '/dashboard/1-sample-dashboard/';
|
||||
export const SUPPORTED_CHARTS_DASHBOARD = '/dashboard/supported_charts_dash/';
|
||||
export const TABBED_DASHBOARD = '/dashboard/tabbed_dash/';
|
||||
export const DATABASE_LIST = '/databaseview/list';
|
||||
|
||||
418
superset-frontend/package-lock.json
generated
418
superset-frontend/package-lock.json
generated
@@ -45,9 +45,9 @@
|
||||
"@luma.gl/shadertools": "~9.2.5",
|
||||
"@luma.gl/webgl": "~9.2.5",
|
||||
"@reduxjs/toolkit": "^1.9.3",
|
||||
"@rjsf/core": "^6.10.0",
|
||||
"@rjsf/core": "^6.10.1",
|
||||
"@rjsf/utils": "^6.6.2",
|
||||
"@rjsf/validator-ajv8": "^6.10.0",
|
||||
"@rjsf/validator-ajv8": "^6.10.1",
|
||||
"@scarf/scarf": "^1.4.0",
|
||||
"@superset-ui/chart-controls": "file:./packages/superset-ui-chart-controls",
|
||||
"@superset-ui/core": "file:./packages/superset-ui-core",
|
||||
@@ -78,8 +78,8 @@
|
||||
"@visx/scale": "^4.0.0",
|
||||
"@visx/tooltip": "^4.0.0",
|
||||
"@visx/xychart": "^4.0.0",
|
||||
"ag-grid-community": "36.1.0",
|
||||
"ag-grid-react": "36.1.0",
|
||||
"ag-grid-community": "36.2.0",
|
||||
"ag-grid-react": "36.2.0",
|
||||
"antd": "^6.6.4",
|
||||
"chrono-node": "^2.10.1",
|
||||
"classnames": "^2.2.5",
|
||||
@@ -89,8 +89,6 @@
|
||||
"dom-to-image-more": "^3.10.2",
|
||||
"dom-to-pdf": "^0.3.2",
|
||||
"echarts": "^6.1.0",
|
||||
"fast-glob": "^3.3.2",
|
||||
"fs-extra": "^11.4.0",
|
||||
"fuse.js": "^7.5.0",
|
||||
"geolib": "^3.3.14",
|
||||
"geostyler": "^18.6.0",
|
||||
@@ -98,7 +96,7 @@
|
||||
"geostyler-openlayers-parser": "^5.7.1",
|
||||
"geostyler-style": "11.0.2",
|
||||
"geostyler-wfs-parser": "^3.0.1",
|
||||
"google-auth-library": "^11.0.2",
|
||||
"google-auth-library": "^11.1.0",
|
||||
"immer": "^11.1.18",
|
||||
"interweave": "^13.1.1",
|
||||
"jquery": "^4.0.0",
|
||||
@@ -107,8 +105,8 @@
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.18.1",
|
||||
"mapbox-gl": "^3.30.0",
|
||||
"markdown-to-jsx": "^9.10.2",
|
||||
"mapbox-gl": "^3.31.0",
|
||||
"markdown-to-jsx": "^9.10.3",
|
||||
"match-sorter": "^8.3.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
"mousetrap": "^1.6.5",
|
||||
@@ -203,7 +201,7 @@
|
||||
"@types/json-bigint": "^1.0.4",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/mousetrap": "^1.6.15",
|
||||
"@types/node": "^26.5.1",
|
||||
"@types/node": "^26.6.1",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-loadable": "^5.5.11",
|
||||
@@ -221,7 +219,7 @@
|
||||
"babel-loader": "^10.1.1",
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"baseline-browser-mapping": "^2.11.23",
|
||||
"baseline-browser-mapping": "^2.11.25",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^10.0.5",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
@@ -248,14 +246,14 @@
|
||||
"jest-html-reporter": "^4.4.2",
|
||||
"jest-websocket-mock": "^2.5.0",
|
||||
"js-yaml-loader": "^1.2.2",
|
||||
"jsdom": "^30.0.1",
|
||||
"jsdom": "^30.1.0",
|
||||
"lerna": "^10.0.1",
|
||||
"lightningcss": "^1.33.0",
|
||||
"mini-css-extract-plugin": "^2.10.2",
|
||||
"minimizer-webpack-plugin": "^5.10.1",
|
||||
"open-cli": "^9.0.0",
|
||||
"oxfmt": "^0.68.0",
|
||||
"oxlint": "^1.82.0",
|
||||
"oxlint": "^1.83.0",
|
||||
"po2json": "^0.4.5",
|
||||
"postcss-styled-syntax": "^0.7.2",
|
||||
"process": "^0.11.10",
|
||||
@@ -276,7 +274,7 @@
|
||||
"unzipper": "^0.12.5",
|
||||
"wait-on": "^9.1.0",
|
||||
"webpack": "^5.111.0",
|
||||
"webpack-bundle-analyzer": "^5.3.3",
|
||||
"webpack-bundle-analyzer": "^5.4.0",
|
||||
"webpack-cli": "^7.2.3",
|
||||
"webpack-dev-server": "^6.0.0",
|
||||
"webpack-manifest-plugin": "^6.0.1",
|
||||
@@ -445,20 +443,20 @@
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "6.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.5.tgz",
|
||||
"integrity": "sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==",
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.0.0.tgz",
|
||||
"integrity": "sha512-IxddtGnnoidVSO6O/aiTueptbTThHvi2qGHu9/AWuD1J7i86Ox+jl+Y5SXcDH1RixzQlP2cjH/PjnlImZiAurQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/css-calc": "^3.2.1",
|
||||
"@csstools/css-color-parser": "^4.1.9",
|
||||
"@csstools/css-calc": "^3.3.0",
|
||||
"@csstools/css-color-parser": "^4.2.0",
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0",
|
||||
"lru-cache": "^11.5.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.13.0 || >=24.0.0"
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
|
||||
@@ -472,19 +470,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/dom-selector": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz",
|
||||
"integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==",
|
||||
"version": "9.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.1.4.tgz",
|
||||
"integrity": "sha512-ynDE7RDZM1z+YuSU+iGhRp8WcSXHqK9+G32ZEzsL35TKSoy72fYR7VfrJCQkWe3ciiIWGSTAIBc9XB5jVpqyTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bidi-js": "^1.0.3",
|
||||
"bidi-js": "^1.1.0",
|
||||
"css-tree": "^3.2.1",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"lru-cache": "^11.5.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.13.0 || >=24.0.0"
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/dom-selector/node_modules/css-tree": {
|
||||
@@ -2794,9 +2792,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz",
|
||||
"integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==",
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz",
|
||||
"integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2814,9 +2812,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-calc": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz",
|
||||
"integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==",
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.0.tgz",
|
||||
"integrity": "sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2838,9 +2836,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-color-parser": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz",
|
||||
"integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==",
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.3.tgz",
|
||||
"integrity": "sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2854,8 +2852,8 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/color-helpers": "^6.1.0",
|
||||
"@csstools/css-calc": "^3.3.0"
|
||||
"@csstools/color-helpers": "^6.1.1",
|
||||
"@csstools/css-calc": "^3.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
@@ -8284,6 +8282,7 @@
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.stat": "2.0.5",
|
||||
@@ -8297,6 +8296,7 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
|
||||
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
@@ -8306,6 +8306,7 @@
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
|
||||
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.scandir": "2.1.5",
|
||||
@@ -10275,9 +10276,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-android-arm-eabi": {
|
||||
"version": "1.82.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.82.0.tgz",
|
||||
"integrity": "sha512-a3LB+C5Dsj5b/qtmG/mv5WrzuiXEpg1KF5nXWcEvaoN5TYAqkIvxPOwTPp3Jy/FoGpRo8zsTFhMElMXfeoOEzA==",
|
||||
"version": "1.83.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.83.0.tgz",
|
||||
"integrity": "sha512-0yGY24EwsLk5YDe6F+VkmZyRHSwJDALa3nIrPpq7FXmp2lV2d0TzvBCGeZk+wgiULRGr5blhyr4QMp5KCXJUqA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -10292,9 +10293,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-android-arm64": {
|
||||
"version": "1.82.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.82.0.tgz",
|
||||
"integrity": "sha512-OBlhRgNqFblGpGenno/aqOfJLOkQ2B8Ig3iDAalfn0H8hJGZKXPeexCRTDm6uwv6YUjSA9Xnwt1y/Bgj5ZH8uw==",
|
||||
"version": "1.83.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.83.0.tgz",
|
||||
"integrity": "sha512-hHfJ0vc17A4iUjH5p9BsTUPYbYRNxGpvD2lbu1aBRk54bzNIx9o5TtYF39QPZcV95DagZd+4DEAw2RH3G2ZsMg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -10309,9 +10310,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-darwin-arm64": {
|
||||
"version": "1.82.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.82.0.tgz",
|
||||
"integrity": "sha512-dsopxqtY5ZdyT9uLHyGt1SyiLop6hi7hWI3PKpePodkRQOkLaCm+OE4fR9CAz9qdfjiFO8531tX/QDyP/psjFg==",
|
||||
"version": "1.83.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.83.0.tgz",
|
||||
"integrity": "sha512-hsOjYjszLb/3zym/TkzUMPAoQlTJcuzSyEPOAyA+skXJIX9M0o+4JfOtqopX/Vf4hSLrJ98j0nvFo23gzk8auQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -10326,9 +10327,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-darwin-x64": {
|
||||
"version": "1.82.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.82.0.tgz",
|
||||
"integrity": "sha512-94Lu0SgTClKColU66g1VDuigV3HkcbkJBnTtZjGYfE8UPugaWDgKrm2icjC6HJVUYler2OXaHP/X0TBy8+CowQ==",
|
||||
"version": "1.83.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.83.0.tgz",
|
||||
"integrity": "sha512-mjh5oH2EA+wl5yRJYT9K9G61O2zFlpuv+yf2JwZOi0+dq2FnTUtm1h8i+5Ik0fXPWIu/k84I1psZR9aQsLAnyA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -10343,9 +10344,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-freebsd-x64": {
|
||||
"version": "1.82.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.82.0.tgz",
|
||||
"integrity": "sha512-hne/V06ewhh1i0w8+l7GDNROAGCGPmyFuOwiP7YTRu0JycyStJ4785dmF8xU5p0uUwt2emvIF9vc7Xjis+cJ0g==",
|
||||
"version": "1.83.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.83.0.tgz",
|
||||
"integrity": "sha512-fNHr64/YaO8YssuoDVC8+F4Uk5enR86q5uxfHkQrjAPs1dbAILOrD2uaud+J7MO8Fx774g44ERLD0IGIvZE48w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -10360,9 +10361,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.82.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.82.0.tgz",
|
||||
"integrity": "sha512-aWY2xtbZf1LneW9Qsv/n2Sp8gOu74JrlQzEtj4coHX2SHFrCfhmAumaU+sI/A5nr+yoTRTSmI/pL2s6ADlNSkw==",
|
||||
"version": "1.83.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.83.0.tgz",
|
||||
"integrity": "sha512-Qpwy3zzAwMj+8/lyYItHmkSMwbkprFNWTK7jPYDOxSyxEhaSLOWYUTCMkjF334J8/WD0nznCCsoBbIH6hpsuIw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -10377,9 +10378,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-arm-musleabihf": {
|
||||
"version": "1.82.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.82.0.tgz",
|
||||
"integrity": "sha512-Fe+TtXCXMh/5f7kWlZ2VAwsMumZWtraFlKVk1NJlL52/beGwfDE7ov+/8gVirHzWokzGu7X65hSPq0ucPDskWQ==",
|
||||
"version": "1.83.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.83.0.tgz",
|
||||
"integrity": "sha512-s+BirYLFq7JL2k9sP0XI3ZXJ9dYvJ8sX3jLCLoag7tt+zrSHpZxP0jqznfL+Gdgwu7ay0dYgGYJXrQvq3iWloA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -10394,9 +10395,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-arm64-gnu": {
|
||||
"version": "1.82.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.82.0.tgz",
|
||||
"integrity": "sha512-6azCZ6OJudlvipNttXCCQcyeFfcJ/NvUZdSN1z8elo73kCHtyQC7WTiUcSjWYvJ1jaq9KDUyMAoAS/vNzhBomA==",
|
||||
"version": "1.83.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.83.0.tgz",
|
||||
"integrity": "sha512-7lihXt3vKr+GIyapNbHrnFHm/biiW30le6Zv/DExbAFPF6YwCQXVFlONPFehxs0CpGO4CBfYPM9rdDT+XMoIlg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -10414,9 +10415,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-arm64-musl": {
|
||||
"version": "1.82.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.82.0.tgz",
|
||||
"integrity": "sha512-PLEaSD8IAIIlwW4dwOd9YaxuxeOpwiXL4J24rcnE4iNtyM5j9Q9/3+gti08oXpx0u2ygNjRDx9xjWWpQonuJEw==",
|
||||
"version": "1.83.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.83.0.tgz",
|
||||
"integrity": "sha512-q63JalLYVkZiZvls1z3PPUnpmQluOMXp0khqQMznCeAPLGydfNY8JhvuA4WlK57JfrvikU8wB5lPVveqpIXvew==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -10434,9 +10435,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-ppc64-gnu": {
|
||||
"version": "1.82.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.82.0.tgz",
|
||||
"integrity": "sha512-D94em/BwknNTn4vqxjHh5wb2oL566eFhArabqKIr0cNZMHOJuiraFp1A8tXpH05bbE5tqwEfLXTI0MWEGtn3Dw==",
|
||||
"version": "1.83.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.83.0.tgz",
|
||||
"integrity": "sha512-krQmDF+dRbxvdqVPV88ZuOoPPu8X5BuqDA8Hd+qcS4YMRQCb+nexA57DazgGsc/rGdKBe3QmV0mnv0bdpW/p5g==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -10454,9 +10455,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-riscv64-gnu": {
|
||||
"version": "1.82.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.82.0.tgz",
|
||||
"integrity": "sha512-MOprxBaoYU2D4VgxXCl3ghydThWtx7Um1lL51kGYNeQ5Al7WzsH7/tqGdNtbLrIWnjq3bsm13+nz/gRIxjrOXw==",
|
||||
"version": "1.83.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.83.0.tgz",
|
||||
"integrity": "sha512-MmOl8Y6txEAXZU1RG8Rr264jQ6D7VPmqFsU/45x/FeWsGe32hklTqGrLE6UxHzp5Rjt0wP+20tY8YXKgSFB3mw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -10474,9 +10475,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-riscv64-musl": {
|
||||
"version": "1.82.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.82.0.tgz",
|
||||
"integrity": "sha512-5h55QsfJ/luDXZzC20k6SNOY1Az+dCP9WvntKtcUWh2JhckAdwApY2ZusaBTwLENnReXU+A2fJtSrYvZJNKNPg==",
|
||||
"version": "1.83.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.83.0.tgz",
|
||||
"integrity": "sha512-u1rMymh0W3JZkq370kzQsYPULGWqhE09pZRqnZvUSoYaI9pVO5yVX+iYIslmWuEgwuzH9YAaOsScJiobWCHoOw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -10494,9 +10495,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-s390x-gnu": {
|
||||
"version": "1.82.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.82.0.tgz",
|
||||
"integrity": "sha512-IE8NJNLlHr0CaXyGJPGVn0eTkUyoj1I2UfA8x7I4PSOYKsQ/6btVC7Pywrj5onk0cMH25r6Z38SoN3AvE5Zuog==",
|
||||
"version": "1.83.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.83.0.tgz",
|
||||
"integrity": "sha512-y0zK3HNwGysu7rqtE+BQG/d0bx5gh/KwlOtghN8oWeK1KcWzeaLqtZrbm8owqdma1lFyrce/hTO5ismuNu+INQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -10514,9 +10515,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-x64-gnu": {
|
||||
"version": "1.82.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.82.0.tgz",
|
||||
"integrity": "sha512-XUUUxaBo9XKl+J1B9EmP1cTGQPddzeURvoGkfwh/94PGnbW+hBprDljneoI2M1jzC1bzrIV3ihc7iM9UXl8+tg==",
|
||||
"version": "1.83.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.83.0.tgz",
|
||||
"integrity": "sha512-rS5gM0NgD7ngmuJmbIehsidtrOwKkLFwCQbKEeb9KuyQrrWNq5Zkn0uV6AYdXOMJ0grrWEiLwBuvMxt8w5vsNw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -10534,9 +10535,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-x64-musl": {
|
||||
"version": "1.82.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.82.0.tgz",
|
||||
"integrity": "sha512-SWLSFulX9TDuH6yvbPYp4+VNn6jkkIvvI+KiujDM5rWBRHEfkesCC/pCneIIUr6ovkxZ5fRtpi2v5Cz5FrMJZg==",
|
||||
"version": "1.83.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.83.0.tgz",
|
||||
"integrity": "sha512-W2IH4EtpcPaWcvNGCA95YoDg4vxqE/ZiPCi3arrxEEpsK7+JQN9WYwrlYFx9pcdP6KPXqRqkv3zdQPHcx7b6YQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -10554,9 +10555,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-openharmony-arm64": {
|
||||
"version": "1.82.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.82.0.tgz",
|
||||
"integrity": "sha512-BQy35f6ZUdNr9a6c7B7orxQTcLjByGT2z3WAgmRovpRwmPYAaJ+NTplmMzhdjdJ4qSchfMNZy/Ukg+qRg6zseQ==",
|
||||
"version": "1.83.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.83.0.tgz",
|
||||
"integrity": "sha512-6LyKkUyoajssTPLlZmDbZIbu4IZ5B4bGuRUnBgCGpEvHP3FQMaYITncHA/unPUo7q+Z+pIu2HhdkQ+8d1SG7iA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -10571,9 +10572,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-win32-arm64-msvc": {
|
||||
"version": "1.82.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.82.0.tgz",
|
||||
"integrity": "sha512-V4QhSTg5gctZue8RJjsGi7NpQPThr/p1/HfmiMC5kfe1KFEup9SQRVub4A6kijQjdHfxj7bLL1KO3QO7/5bwMQ==",
|
||||
"version": "1.83.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.83.0.tgz",
|
||||
"integrity": "sha512-Uz/fObEtF0jmNJQJ8CGRBKfefYstS0/wjD3s6IGzP8nUwsJykHQJBiN3npHwKiGRGn/vvBEgNr4B3cCzmmatvg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -10588,9 +10589,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-win32-ia32-msvc": {
|
||||
"version": "1.82.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.82.0.tgz",
|
||||
"integrity": "sha512-TUSCLaKB2yktpFAJ/r3HAUYsaV/3DT7JS4iNKyoh3a9YNwD0UG7Ezh4D8m23654vQcU6P/RQrCAjRPKe4peP/A==",
|
||||
"version": "1.83.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.83.0.tgz",
|
||||
"integrity": "sha512-u7XcvPW6Bk58tY5iWs2ESb0vJjoE/kuSpHxopbwp/p3ZtWVQXZ6wor5w3ssVTHOqd/v8b+QdhSFWQ4grEUNWpA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -10605,9 +10606,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-win32-x64-msvc": {
|
||||
"version": "1.82.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.82.0.tgz",
|
||||
"integrity": "sha512-VTVoRIWJTb+wvUX8EYoPArfFH02whuR10goFXE/LHRRX33ajRrFgqbcONXZMiF4C5rnattfkm87HqYn8jb8hmQ==",
|
||||
"version": "1.83.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.83.0.tgz",
|
||||
"integrity": "sha512-LZRubd7ph13QmAg4fFecTYVZkiYbROR2Htaxh/ufWRkDhPOm2wrwaEYR89e0YpPFD3dqBrPoxS7myBw5hmYA7Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -12151,9 +12152,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rjsf/core": {
|
||||
"version": "6.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@rjsf/core/-/core-6.10.0.tgz",
|
||||
"integrity": "sha512-fdyaPnhIe+NzTFZcYcYcp/QqVcAJhhloIsU6vN+hp/IhiGJrzgB9REMVVUlKYNzP37xZ/BsEq7u7RC24KKdLAQ==",
|
||||
"version": "6.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@rjsf/core/-/core-6.10.1.tgz",
|
||||
"integrity": "sha512-LHLbXYEq1+c2CpynBlf5JBlUhPNxOkVV+txf1BvamgJSt4QOisXFw5Uv+mBdLRi4BI+FfT0ofMVOgGWnuwG7gw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"markdown-to-jsx": "^9.8.2"
|
||||
@@ -12162,17 +12163,17 @@
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@rjsf/utils": "^6.10.0",
|
||||
"@rjsf/utils": "^6.10.1",
|
||||
"react": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@rjsf/utils": {
|
||||
"version": "6.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.10.0.tgz",
|
||||
"integrity": "sha512-GQa+dr28FgGOaRRhIOX3QEv1GkzoklrZYf4f1bTEDNuAWWWyO1oznw62ryZBj4FI/XKPaE/VyJQlssOSbD5KiQ==",
|
||||
"version": "6.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.10.1.tgz",
|
||||
"integrity": "sha512-dlbQUCoTl/+/TenyYs1zAKXf9sLTebtY+6pB+bUFsaN4xkdAtR8YBLwmpa1N5yNiRzf3fbxveISNuZmDrZE/yQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@x0k/json-schema-merge": "^1.0.3",
|
||||
"@x0k/json-schema-merge": "^1.0.6",
|
||||
"fast-equals": "^6.0.0",
|
||||
"fast-uri": "^4.1.4",
|
||||
"jsonpointer": "^5.0.1",
|
||||
@@ -12186,9 +12187,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rjsf/validator-ajv8": {
|
||||
"version": "6.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-6.10.0.tgz",
|
||||
"integrity": "sha512-B/cNuPIQNBiJo4ByoJUXwGylu51JA5Jhkj4gsZ794dl85xxfcQ43HKfR1Lg55SoCUOzHc0Sf7zI/0q3R3sKEEQ==",
|
||||
"version": "6.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-6.10.1.tgz",
|
||||
"integrity": "sha512-Jyim45UKd39SgWn4ryaydTigZ0dddNEeqZUfTlLgsy2v23uMDLyeKjNCItC09dy00dtKzwM3Q4WA0FtQ5IjOgA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"ajv": "^8.20.0",
|
||||
@@ -12198,7 +12199,7 @@
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@rjsf/utils": "^6.10.0"
|
||||
"@rjsf/utils": "^6.10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@rtsao/scc": {
|
||||
@@ -14498,9 +14499,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.1.tgz",
|
||||
"integrity": "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==",
|
||||
"version": "26.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.1.tgz",
|
||||
"integrity": "sha512-VqGJBMCtdhqkBUCcBLvywI0NJ+KLuVzgNnlBUNFOQjqVxzo2lxLUNg1DSey8+u2u6ktswSAxg+s68QLzWHNOuA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~8.9.0"
|
||||
@@ -16325,9 +16326,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@x0k/json-schema-merge": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@x0k/json-schema-merge/-/json-schema-merge-1.0.4.tgz",
|
||||
"integrity": "sha512-KvmMgAftbVzATq4IRnkno/SKSu+gjaR2ZUPJG5JUlY4W3twRJo03sk2914u8scmosibBZ0m7s6euZlJuqpv8Ww==",
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@x0k/json-schema-merge/-/json-schema-merge-1.0.6.tgz",
|
||||
"integrity": "sha512-SSdWAhirRj9UGTSjmgMnchC3Nytpbde46qPEXVQLm/S3opteW6E/VX4UzLlfmA9L2zdkmG3c69sfLfHbT0jl0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/json-schema": "^7.0.15"
|
||||
@@ -16529,28 +16530,28 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ag-charts-types": {
|
||||
"version": "14.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-14.1.0.tgz",
|
||||
"integrity": "sha512-mmzkng88c0l+Z9PvCMowMilhVeNgDU/iMuMemcOQD4BG/qO4vbkxWvyQO0iqju8Dx7YOY81PNzC/RmElQNiVkA==",
|
||||
"version": "14.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-14.2.0.tgz",
|
||||
"integrity": "sha512-4pDV6/4zbzPN7t607Gr1o6DO9RhmPafQGr+hIpTJBaerbaMoC+WOO3v/wkxFbvCNrHKOexEKSfNTH7PaAR4UzA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ag-grid-community": {
|
||||
"version": "36.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-36.1.0.tgz",
|
||||
"integrity": "sha512-WnvSQ4csRs8gv/b1B0lPHykQXvUJVgi2u5mpY0Aa6dv3pvhDVVqCT8dwUyOeCog4JNPptaXGrOrZ8qP2XoJzVA==",
|
||||
"version": "36.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-36.2.0.tgz",
|
||||
"integrity": "sha512-tK0Toj7t0fV/OMLPnDc0xlYKAqDbnCT3iJodYSn2TJ541caU8jPdYwWhMdW+AP2+pUNwFiJVgc04eJNfvBPsWw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ag-charts-types": "14.1.0",
|
||||
"ag-stack": "36.1.0"
|
||||
"ag-charts-types": "14.2.0",
|
||||
"ag-stack": "36.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ag-grid-react": {
|
||||
"version": "36.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-36.1.0.tgz",
|
||||
"integrity": "sha512-FNsmrOmr+taZY11sCA+6Ow0mKs2JqnmrjBrMjkKC2rVTXPARtks+tv+Ne4ZkLGGtvIMDlBFCDbSzFXVP0CdwXQ==",
|
||||
"version": "36.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-36.2.0.tgz",
|
||||
"integrity": "sha512-7dsaR4Ga8/bJAOov7XJqxsDc1JAjUU5Bmh9+s4U9+/TKIdSsaVo53SuZb2yEFSB1elv3eh6aZP8a1fqGZehXwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ag-grid-community": "36.1.0",
|
||||
"ag-grid-community": "36.2.0",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -16559,9 +16560,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ag-stack": {
|
||||
"version": "36.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ag-stack/-/ag-stack-36.1.0.tgz",
|
||||
"integrity": "sha512-Kmkf5iRZmyduNx2KtW450j6GbCdJ4ejacSpb1AWl0yrdquRmScLRc1SlLolx6lyCbTBSJGo4tRraKbBzre+GlQ==",
|
||||
"version": "36.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ag-stack/-/ag-stack-36.2.0.tgz",
|
||||
"integrity": "sha512-f3XT2htFs9dUPcHni2z9ak69QNta5ODEpooHuuNvbjN98JkokpEqRQ9YLQHQJFdCcgi9Ybs/j3zkkvmNRamIPg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
@@ -17720,9 +17721,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.11.23",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.23.tgz",
|
||||
"integrity": "sha512-le521dGVfxM7yRX0EikCoSz+rOK+hHzdDt/E7mG1jOJB/6WAAUuwVroLwaB7ApaUsz5Q0kFlDXLSA9MheUIfRQ==",
|
||||
"version": "2.11.25",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.25.tgz",
|
||||
"integrity": "sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -17740,9 +17741,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
|
||||
"integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -17991,6 +17992,7 @@
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fill-range": "^7.1.1"
|
||||
@@ -22648,6 +22650,7 @@
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.stat": "^2.0.2",
|
||||
@@ -22771,6 +22774,7 @@
|
||||
"version": "1.18.0",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz",
|
||||
"integrity": "sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"reusify": "^1.0.4"
|
||||
@@ -22937,6 +22941,7 @@
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"to-regex-range": "^5.0.1"
|
||||
@@ -23496,6 +23501,7 @@
|
||||
"version": "11.4.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz",
|
||||
"integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
@@ -24459,6 +24465,7 @@
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.1"
|
||||
@@ -24630,9 +24637,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/google-auth-library": {
|
||||
"version": "11.0.2",
|
||||
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-11.0.2.tgz",
|
||||
"integrity": "sha512-vzpgPutxrghPsnjrjpzLX2bdv8IOL719Rh0oEjGnQu8YCIbnbMuTTQ5zU9LcKvLdOPgCxBwppbvnhgW90Qna5Q==",
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-11.1.0.tgz",
|
||||
"integrity": "sha512-YIBq5KNoHQrZ9PM4PeodDBqqBGlSTIxh1zibN8u3zNIO9UJg8gvKuCpdKV7fWXhsZ2zRkU6FhGrgKNgLJ2IRgw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.0",
|
||||
@@ -24702,6 +24709,7 @@
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/h3-js": {
|
||||
@@ -26107,6 +26115,7 @@
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -26169,6 +26178,7 @@
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-extglob": "^2.1.1"
|
||||
@@ -26276,6 +26286,7 @@
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.12.0"
|
||||
@@ -30774,16 +30785,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "30.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz",
|
||||
"integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==",
|
||||
"version": "30.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.0.tgz",
|
||||
"integrity": "sha512-h/Q+H+KmlGf49ITw3TrykSVbg04S75S2O7ZtJc9gMGOWBrQRZmx5AJIKLtJ01e6Kszl+/tpFthCVqxNifxhoqg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/css-color": "^6.0.5",
|
||||
"@asamuzakjp/dom-selector": "^8.3.0",
|
||||
"@asamuzakjp/css-color": "^7.0.0",
|
||||
"@asamuzakjp/dom-selector": "^9.1.2",
|
||||
"@bramus/specificity": "^2.4.2",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.1.7",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.1.13",
|
||||
"@exodus/bytes": "^1.15.1",
|
||||
"css-tree": "^3.2.1",
|
||||
"data-urls": "^7.0.0",
|
||||
@@ -30793,13 +30804,12 @@
|
||||
"lru-cache": "^11.5.2",
|
||||
"parse5": "^8.0.1",
|
||||
"saxes": "^6.0.0",
|
||||
"symbol-tree": "^3.2.4",
|
||||
"tough-cookie": "^6.0.2",
|
||||
"undici": "^8.9.0",
|
||||
"undici": "^8.10.2",
|
||||
"w3c-xmlserializer": "^5.0.0",
|
||||
"webidl-conversions": "^8.0.1",
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^17.1.0",
|
||||
"whatwg-url": "^17.1.1",
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -30815,9 +30825,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz",
|
||||
"integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==",
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz",
|
||||
"integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -30915,9 +30925,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/undici": {
|
||||
"version": "8.10.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz",
|
||||
"integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==",
|
||||
"version": "8.10.2",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz",
|
||||
"integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -30945,9 +30955,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/whatwg-url": {
|
||||
"version": "17.1.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz",
|
||||
"integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==",
|
||||
"version": "17.1.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.1.tgz",
|
||||
"integrity": "sha512-ohjk1mdUebJVadRt3bAhQhx8lSnISq+GDttK79LFl8EHQkAPvzwctoasC4hs8tBt6kLAncBWWyq1N52qEfKvDw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -31059,6 +31069,7 @@
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
|
||||
"integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
@@ -32455,9 +32466,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mapbox-gl": {
|
||||
"version": "3.30.0",
|
||||
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.30.0.tgz",
|
||||
"integrity": "sha512-26m0CSBszLkvbdVWf2oIZ3SjqCuResxJKEbupwwSyNwbwJAcgnMhDy2Yl5cSgtUxeamUJ1UeXRqpBmGEUVaAeQ==",
|
||||
"version": "3.31.0",
|
||||
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.31.0.tgz",
|
||||
"integrity": "sha512-7i25NyCPW5jnqsqJq3irgBBEoVTJgfZqTJJS446Q3ZuxyAb5Om75UIcxYIo38oz9eDswnBXr0riz+M/C8V7g3Q==",
|
||||
"license": "SEE LICENSE IN LICENSE.txt",
|
||||
"workspaces": [
|
||||
"src/style-spec",
|
||||
@@ -32550,9 +32561,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-to-jsx": {
|
||||
"version": "9.10.2",
|
||||
"resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-9.10.2.tgz",
|
||||
"integrity": "sha512-iR9GadlIox0q1uXnpqdxpF02Vb1WDmZ/QIXWjBR5htzjUEEhyIWbM65LuVKavdwJaVo4q95C/F2OOyNjWe91ig==",
|
||||
"version": "9.10.3",
|
||||
"resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-9.10.3.tgz",
|
||||
"integrity": "sha512-J5/NCmNYQ0ExsnThzvF3F1fSzNYLgROcW2diUqRqoIoiyhoy8DSI06jLuMYk0c86SQd/FiVYJrV6tIgci9S2tQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
@@ -33089,6 +33100,7 @@
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
@@ -33667,6 +33679,7 @@
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"braces": "^3.0.3",
|
||||
@@ -35889,9 +35902,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/oxlint": {
|
||||
"version": "1.82.0",
|
||||
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.82.0.tgz",
|
||||
"integrity": "sha512-+iFM1BGw1ntYJt3QngbJmjbrGxPaKMUADOXOijpWGnYcBPq8YZnQftSS1C+pVcDYy9YxqDVJKQqQkTazTQMboQ==",
|
||||
"version": "1.83.0",
|
||||
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.83.0.tgz",
|
||||
"integrity": "sha512-cyDzSzaw3uzP0TeCeq3lLRPPoaUxkbB4ZOXj+kn+5r+BX9V+4bNVGk9lxer+WrgcpebH4JxLlJ3KQjveVztOLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -35904,25 +35917,25 @@
|
||||
"url": "https://github.com/sponsors/oxc-project"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@oxlint/binding-android-arm-eabi": "1.82.0",
|
||||
"@oxlint/binding-android-arm64": "1.82.0",
|
||||
"@oxlint/binding-darwin-arm64": "1.82.0",
|
||||
"@oxlint/binding-darwin-x64": "1.82.0",
|
||||
"@oxlint/binding-freebsd-x64": "1.82.0",
|
||||
"@oxlint/binding-linux-arm-gnueabihf": "1.82.0",
|
||||
"@oxlint/binding-linux-arm-musleabihf": "1.82.0",
|
||||
"@oxlint/binding-linux-arm64-gnu": "1.82.0",
|
||||
"@oxlint/binding-linux-arm64-musl": "1.82.0",
|
||||
"@oxlint/binding-linux-ppc64-gnu": "1.82.0",
|
||||
"@oxlint/binding-linux-riscv64-gnu": "1.82.0",
|
||||
"@oxlint/binding-linux-riscv64-musl": "1.82.0",
|
||||
"@oxlint/binding-linux-s390x-gnu": "1.82.0",
|
||||
"@oxlint/binding-linux-x64-gnu": "1.82.0",
|
||||
"@oxlint/binding-linux-x64-musl": "1.82.0",
|
||||
"@oxlint/binding-openharmony-arm64": "1.82.0",
|
||||
"@oxlint/binding-win32-arm64-msvc": "1.82.0",
|
||||
"@oxlint/binding-win32-ia32-msvc": "1.82.0",
|
||||
"@oxlint/binding-win32-x64-msvc": "1.82.0"
|
||||
"@oxlint/binding-android-arm-eabi": "1.83.0",
|
||||
"@oxlint/binding-android-arm64": "1.83.0",
|
||||
"@oxlint/binding-darwin-arm64": "1.83.0",
|
||||
"@oxlint/binding-darwin-x64": "1.83.0",
|
||||
"@oxlint/binding-freebsd-x64": "1.83.0",
|
||||
"@oxlint/binding-linux-arm-gnueabihf": "1.83.0",
|
||||
"@oxlint/binding-linux-arm-musleabihf": "1.83.0",
|
||||
"@oxlint/binding-linux-arm64-gnu": "1.83.0",
|
||||
"@oxlint/binding-linux-arm64-musl": "1.83.0",
|
||||
"@oxlint/binding-linux-ppc64-gnu": "1.83.0",
|
||||
"@oxlint/binding-linux-riscv64-gnu": "1.83.0",
|
||||
"@oxlint/binding-linux-riscv64-musl": "1.83.0",
|
||||
"@oxlint/binding-linux-s390x-gnu": "1.83.0",
|
||||
"@oxlint/binding-linux-x64-gnu": "1.83.0",
|
||||
"@oxlint/binding-linux-x64-musl": "1.83.0",
|
||||
"@oxlint/binding-openharmony-arm64": "1.83.0",
|
||||
"@oxlint/binding-win32-arm64-msvc": "1.83.0",
|
||||
"@oxlint/binding-win32-ia32-msvc": "1.83.0",
|
||||
"@oxlint/binding-win32-x64-msvc": "1.83.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"oxlint-tsgolint": ">=7.0.2001",
|
||||
@@ -37430,6 +37443,7 @@
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -38853,10 +38867,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-draggable": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.5.0.tgz",
|
||||
"integrity": "sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==",
|
||||
"dev": true,
|
||||
"version": "4.7.2",
|
||||
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.7.2.tgz",
|
||||
"integrity": "sha512-pmqDx72EqhMGoJe/x42ZO2wJ9TIz7qJ5OPnFu6lVWWznEuJumlSjR+J0V4tNyDFhUo6dVvv56FeiTqN+arTgnw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
@@ -40160,6 +40173,7 @@
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
|
||||
"integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"iojs": ">=1.0.0",
|
||||
@@ -40246,6 +40260,7 @@
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -43256,6 +43271,7 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-number": "^7.0.0"
|
||||
@@ -44265,6 +44281,7 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
||||
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
@@ -44973,9 +44990,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-bundle-analyzer": {
|
||||
"version": "5.3.3",
|
||||
"resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-5.3.3.tgz",
|
||||
"integrity": "sha512-kr/bzg3sORg1xAnxpg1uaIPGDDNwaRIsClOv22HzCIMX9X64FbvIBNjP2ulPdfCgZw7ekrv7JGAPc30W+0b7rA==",
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-5.4.0.tgz",
|
||||
"integrity": "sha512-6OjsEFQIwcRT1nId/O7OyHjDvrE0JSFAef/XCwGarrf9MF5/CaD0lhDvoySbus57dYEdJ3vrRVj5F3i30c4ozw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -46774,7 +46791,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "^10.1.0",
|
||||
"fs-extra": "^11.4.0",
|
||||
"jest": "^30.5.1",
|
||||
"yeoman-test": "^11.8.2"
|
||||
},
|
||||
@@ -46875,8 +46891,8 @@
|
||||
"@types/json-bigint": "^1.0.4",
|
||||
"@visx/responsive": "^4.0.0",
|
||||
"ace-builds": "^1.44.0",
|
||||
"ag-grid-community": "36.1.0",
|
||||
"ag-grid-react": "36.1.0",
|
||||
"ag-grid-community": "36.2.0",
|
||||
"ag-grid-react": "36.2.0",
|
||||
"brace": "^0.11.1",
|
||||
"classnames": "^2.5.1",
|
||||
"core-js": "^3.50.0",
|
||||
@@ -46897,7 +46913,7 @@
|
||||
"parse-ms": "^4.0.0",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react-ace": "^15.0.0",
|
||||
"react-draggable": "^4.7.1",
|
||||
"react-draggable": "^4.7.2",
|
||||
"react-error-boundary": "^6.1.5",
|
||||
"react-js-cron": "^6.0.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
@@ -46921,7 +46937,7 @@
|
||||
"@types/d3-time-format": "^4.0.3",
|
||||
"@types/jquery": "^4.0.1",
|
||||
"@types/lodash": "^4.17.25",
|
||||
"@types/node": "^26.5.1",
|
||||
"@types/node": "^26.6.1",
|
||||
"@types/prop-types": "^15.7.15",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/react-table": "^7.7.20",
|
||||
@@ -47012,20 +47028,6 @@
|
||||
"react-dom": "^0.13.0 || ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"packages/superset-ui-core/node_modules/react-draggable": {
|
||||
"version": "4.7.1",
|
||||
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.7.1.tgz",
|
||||
"integrity": "sha512-wa3tzfFnYt3yaZLuyU58fl1TNunfWfBekDgWhZA1+gb2jnp42wZ0ymuopR6M5kqDYmm4hKmzGlcKWjZf3Zb6RQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.3.0",
|
||||
"react-dom": ">= 16.3.0"
|
||||
}
|
||||
},
|
||||
"packages/superset-ui-core/node_modules/react-resize-detector": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/react-resize-detector/-/react-resize-detector-7.1.2.tgz",
|
||||
@@ -47388,7 +47390,7 @@
|
||||
"@math.gl/web-mercator": "^4.1.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"@types/supercluster": "^7.1.3",
|
||||
"mapbox-gl": "^3.30.0",
|
||||
"mapbox-gl": "^3.31.0",
|
||||
"maplibre-gl": "^6.8.0",
|
||||
"react-map-gl": "^8.1.3",
|
||||
"supercluster": "^9.1.0"
|
||||
|
||||
@@ -52,8 +52,8 @@
|
||||
"cover": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=4096\" jest --coverage",
|
||||
"dev": "webpack --mode=development --color --watch",
|
||||
"dev-server": "cross-env NODE_ENV=development BABEL_ENV=development node --max_old_space_size=4096 ./node_modules/webpack-dev-server/bin/webpack-dev-server.js --mode=development",
|
||||
"format": "npm run _format --write",
|
||||
"format-check": "npm run _format --check",
|
||||
"format": "npm run _format -- --write",
|
||||
"format-check": "npm run _format -- --check",
|
||||
"lint": "npx oxlint --config oxlint.json --quiet",
|
||||
"lint:all": "npx oxlint --config oxlint.json && npm run type",
|
||||
"lint-fix": "npx oxlint --config oxlint.json --fix --quiet",
|
||||
@@ -66,7 +66,6 @@
|
||||
"ensure-oxc": "echo 'OXC linter is ready' && npx oxlint --version",
|
||||
"lint-stats": "node ./scripts/oxlint-metrics-uploader.js",
|
||||
"plugins:build": "node ./scripts/build.js",
|
||||
"plugins:build-assets": "node ./scripts/copyAssets.js",
|
||||
"plugins:create-conventional-version": "npm run prune && lerna version --conventional-commits --create-release github --no-private --yes --tag-version-prefix=\"plugins-and-packages-v\"",
|
||||
"plugins:create-minor-version": "npm run prune && lerna version minor --no-private --yes --tag-version-prefix=\"plugins-and-packages-v\"",
|
||||
"plugins:create-patch-version": "npm run prune && lerna version patch --no-private --yes --tag-version-prefix=\"plugins-and-packages-v\"",
|
||||
@@ -123,9 +122,9 @@
|
||||
"@luma.gl/shadertools": "~9.2.5",
|
||||
"@luma.gl/webgl": "~9.2.5",
|
||||
"@reduxjs/toolkit": "^1.9.3",
|
||||
"@rjsf/core": "^6.10.0",
|
||||
"@rjsf/core": "^6.10.1",
|
||||
"@rjsf/utils": "^6.6.2",
|
||||
"@rjsf/validator-ajv8": "^6.10.0",
|
||||
"@rjsf/validator-ajv8": "^6.10.1",
|
||||
"@scarf/scarf": "^1.4.0",
|
||||
"@superset-ui/chart-controls": "file:./packages/superset-ui-chart-controls",
|
||||
"@superset-ui/core": "file:./packages/superset-ui-core",
|
||||
@@ -156,8 +155,8 @@
|
||||
"@visx/scale": "^4.0.0",
|
||||
"@visx/tooltip": "^4.0.0",
|
||||
"@visx/xychart": "^4.0.0",
|
||||
"ag-grid-community": "36.1.0",
|
||||
"ag-grid-react": "36.1.0",
|
||||
"ag-grid-community": "36.2.0",
|
||||
"ag-grid-react": "36.2.0",
|
||||
"antd": "^6.6.4",
|
||||
"chrono-node": "^2.10.1",
|
||||
"classnames": "^2.2.5",
|
||||
@@ -167,8 +166,6 @@
|
||||
"dom-to-image-more": "^3.10.2",
|
||||
"dom-to-pdf": "^0.3.2",
|
||||
"echarts": "^6.1.0",
|
||||
"fast-glob": "^3.3.2",
|
||||
"fs-extra": "^11.4.0",
|
||||
"fuse.js": "^7.5.0",
|
||||
"geolib": "^3.3.14",
|
||||
"geostyler": "^18.6.0",
|
||||
@@ -176,7 +173,7 @@
|
||||
"geostyler-openlayers-parser": "^5.7.1",
|
||||
"geostyler-style": "11.0.2",
|
||||
"geostyler-wfs-parser": "^3.0.1",
|
||||
"google-auth-library": "^11.0.2",
|
||||
"google-auth-library": "^11.1.0",
|
||||
"immer": "^11.1.18",
|
||||
"interweave": "^13.1.1",
|
||||
"jquery": "^4.0.0",
|
||||
@@ -185,8 +182,8 @@
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.18.1",
|
||||
"mapbox-gl": "^3.30.0",
|
||||
"markdown-to-jsx": "^9.10.2",
|
||||
"mapbox-gl": "^3.31.0",
|
||||
"markdown-to-jsx": "^9.10.3",
|
||||
"match-sorter": "^8.3.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
"mousetrap": "^1.6.5",
|
||||
@@ -281,7 +278,7 @@
|
||||
"@types/json-bigint": "^1.0.4",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/mousetrap": "^1.6.15",
|
||||
"@types/node": "^26.5.1",
|
||||
"@types/node": "^26.6.1",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-loadable": "^5.5.11",
|
||||
@@ -299,7 +296,7 @@
|
||||
"babel-loader": "^10.1.1",
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"baseline-browser-mapping": "^2.11.23",
|
||||
"baseline-browser-mapping": "^2.11.25",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^10.0.5",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
@@ -326,14 +323,14 @@
|
||||
"jest-html-reporter": "^4.4.2",
|
||||
"jest-websocket-mock": "^2.5.0",
|
||||
"js-yaml-loader": "^1.2.2",
|
||||
"jsdom": "^30.0.1",
|
||||
"jsdom": "^30.1.0",
|
||||
"lerna": "^10.0.1",
|
||||
"lightningcss": "^1.33.0",
|
||||
"mini-css-extract-plugin": "^2.10.2",
|
||||
"minimizer-webpack-plugin": "^5.10.1",
|
||||
"open-cli": "^9.0.0",
|
||||
"oxfmt": "^0.68.0",
|
||||
"oxlint": "^1.82.0",
|
||||
"oxlint": "^1.83.0",
|
||||
"po2json": "^0.4.5",
|
||||
"postcss-styled-syntax": "^0.7.2",
|
||||
"process": "^0.11.10",
|
||||
@@ -354,7 +351,7 @@
|
||||
"unzipper": "^0.12.5",
|
||||
"wait-on": "^9.1.0",
|
||||
"webpack": "^5.111.0",
|
||||
"webpack-bundle-analyzer": "^5.3.3",
|
||||
"webpack-bundle-analyzer": "^5.4.0",
|
||||
"webpack-cli": "^7.2.3",
|
||||
"webpack-dev-server": "^6.0.0",
|
||||
"webpack-manifest-plugin": "^6.0.1",
|
||||
|
||||
@@ -38,7 +38,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "^10.1.0",
|
||||
"fs-extra": "^11.4.0",
|
||||
"jest": "^30.5.1",
|
||||
"yeoman-test": "^11.8.2"
|
||||
},
|
||||
|
||||
@@ -20,8 +20,7 @@
|
||||
import { dirname, join } from 'path';
|
||||
// @ts-ignore -- yeoman-test type resolution differs between local and Docker environments
|
||||
import helpers from 'yeoman-test';
|
||||
// @ts-ignore -- fs-extra/esm has no type declarations
|
||||
import { copySync } from 'fs-extra/esm';
|
||||
import { cpSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import pluginChartModule from '../generators/plugin-chart';
|
||||
|
||||
@@ -31,9 +30,10 @@ test('generator-superset:plugin-chart:creates files', async () => {
|
||||
.onTargetDirectory((dir: string) => {
|
||||
// `dir` is the path to the new temporary directory
|
||||
const generatorDirname = dirname(fileURLToPath(import.meta.url));
|
||||
copySync(
|
||||
cpSync(
|
||||
join(generatorDirname, '../generators/plugin-chart/templates'),
|
||||
join(dir, 'unknown/templates'),
|
||||
{ recursive: true },
|
||||
);
|
||||
})
|
||||
.withPrompts({
|
||||
|
||||
@@ -57,8 +57,8 @@
|
||||
"@types/json-bigint": "^1.0.4",
|
||||
"@visx/responsive": "^4.0.0",
|
||||
"ace-builds": "^1.44.0",
|
||||
"ag-grid-community": "36.1.0",
|
||||
"ag-grid-react": "36.1.0",
|
||||
"ag-grid-community": "36.2.0",
|
||||
"ag-grid-react": "36.2.0",
|
||||
"brace": "^0.11.1",
|
||||
"classnames": "^2.5.1",
|
||||
"core-js": "^3.50.0",
|
||||
@@ -79,7 +79,7 @@
|
||||
"parse-ms": "^4.0.0",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react-ace": "^15.0.0",
|
||||
"react-draggable": "^4.7.1",
|
||||
"react-draggable": "^4.7.2",
|
||||
"react-error-boundary": "^6.1.5",
|
||||
"react-js-cron": "^6.0.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
@@ -103,7 +103,7 @@
|
||||
"@types/d3-time-format": "^4.0.3",
|
||||
"@types/jquery": "^4.0.1",
|
||||
"@types/lodash": "^4.17.25",
|
||||
"@types/node": "^26.5.1",
|
||||
"@types/node": "^26.6.1",
|
||||
"@types/prop-types": "^15.7.15",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/react-table": "^7.7.20",
|
||||
|
||||
@@ -75,6 +75,28 @@ export const resolveAutoCurrency = (
|
||||
return null; // Mixed currencies
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves an AUTO currency to the backend-detected one when the currency
|
||||
* code column isn't part of the query results, since CurrencyFormatter can
|
||||
* only detect AUTO per row from that column.
|
||||
*/
|
||||
export const resolveDetectedCurrency = (
|
||||
currency: Currency | undefined,
|
||||
detectedCurrency: string | null | undefined,
|
||||
currencyCodeColumn: string | undefined,
|
||||
colnames: string[] | undefined,
|
||||
): Currency | undefined => {
|
||||
if (
|
||||
currency?.symbol !== AUTO_CURRENCY_SYMBOL ||
|
||||
!detectedCurrency ||
|
||||
(currencyCodeColumn && colnames?.includes(currencyCodeColumn))
|
||||
) {
|
||||
return currency;
|
||||
}
|
||||
const symbol = normalizeCurrency(detectedCurrency);
|
||||
return symbol ? { ...currency, symbol } : currency;
|
||||
};
|
||||
|
||||
const getEffectiveCurrencyFormat = (
|
||||
resolvedCurrencyFormat: Currency | undefined | null,
|
||||
savedFormat: Currency | undefined,
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import {
|
||||
analyzeCurrencyInData,
|
||||
resolveAutoCurrency,
|
||||
resolveDetectedCurrency,
|
||||
} from '../../src/currency-format/utils';
|
||||
|
||||
test('buildCustomFormatters without saved metrics returns empty object', () => {
|
||||
@@ -412,3 +413,35 @@ test('getValueFormatter returns NumberFormatter via line 205 when AUTO resolves
|
||||
);
|
||||
expect(formatter).toBeInstanceOf(NumberFormatter);
|
||||
});
|
||||
|
||||
test('resolveDetectedCurrency uses the detected currency when the currency column is not in the results', () => {
|
||||
expect(
|
||||
resolveDetectedCurrency(
|
||||
{ symbol: 'AUTO', symbolPosition: 'prefix' },
|
||||
'gbp',
|
||||
'currency_code',
|
||||
['metric'],
|
||||
),
|
||||
).toEqual({ symbol: 'GBP', symbolPosition: 'prefix' });
|
||||
});
|
||||
|
||||
test('resolveDetectedCurrency keeps AUTO when the currency column is in the results', () => {
|
||||
const currency: Currency = { symbol: 'AUTO', symbolPosition: 'prefix' };
|
||||
expect(
|
||||
resolveDetectedCurrency(currency, 'GBP', 'currency_code', [
|
||||
'metric',
|
||||
'currency_code',
|
||||
]),
|
||||
).toBe(currency);
|
||||
});
|
||||
|
||||
test('resolveDetectedCurrency leaves non-AUTO and unresolvable currencies untouched', () => {
|
||||
const usd: Currency = { symbol: 'USD', symbolPosition: 'prefix' };
|
||||
const auto: Currency = { symbol: 'AUTO', symbolPosition: 'prefix' };
|
||||
expect(resolveDetectedCurrency(usd, 'GBP', 'currency_code', [])).toBe(usd);
|
||||
expect(resolveDetectedCurrency(auto, null, 'currency_code', [])).toBe(auto);
|
||||
expect(resolveDetectedCurrency(auto, 'nope', 'currency_code', [])).toBe(auto);
|
||||
expect(resolveDetectedCurrency(undefined, 'GBP', undefined, [])).toBe(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -26,13 +26,6 @@ export default defineConfig({
|
||||
// Test directory
|
||||
testDir: './playwright/tests',
|
||||
|
||||
// Conditionally ignore experimental tests based on env var
|
||||
// When INCLUDE_EXPERIMENTAL=true, experimental tests are included
|
||||
// Otherwise, they are excluded (default for required tests)
|
||||
testIgnore: process.env.INCLUDE_EXPERIMENTAL
|
||||
? undefined
|
||||
: '**/experimental/**',
|
||||
|
||||
// Global setup - authenticate once before all tests
|
||||
globalSetup: './playwright/global-setup.ts',
|
||||
|
||||
@@ -94,14 +87,12 @@ export default defineConfig({
|
||||
// Default project - uses global authentication for speed
|
||||
// E2E tests login once via global-setup.ts and reuse auth state
|
||||
// Explicitly ignore auth tests (they run in chromium-unauth project)
|
||||
// Also respect the global experimental testIgnore setting
|
||||
name: 'chromium',
|
||||
testIgnore: [
|
||||
'**/tests/auth/**/*.spec.ts',
|
||||
'**/tests/sqllab/**/*.spec.ts',
|
||||
'**/tests/embedded/**/*.spec.ts',
|
||||
'**/tests/mobile/**/*.spec.ts',
|
||||
...(process.env.INCLUDE_EXPERIMENTAL ? [] : ['**/experimental/**']),
|
||||
],
|
||||
use: {
|
||||
browserName: 'chromium',
|
||||
|
||||
@@ -41,7 +41,7 @@ import { TIMEOUT } from '../../utils/constants';
|
||||
const DATASET_NAME = 'birth_names';
|
||||
|
||||
testWithAssets(
|
||||
'Handlebars formatDate helper renders a formatted date (#32960)',
|
||||
'Handlebars formatDate helper renders a formatted date',
|
||||
async ({ page, testAssets }) => {
|
||||
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
|
||||
|
||||
@@ -18,9 +18,6 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* E2E migration of the Cypress "Drill to detail modal" suite
|
||||
* (dashboard/drilltodetail.test.ts).
|
||||
*
|
||||
* Drill to detail lets a viewer open a modal of the underlying sample rows for a
|
||||
* chart — optionally filtered to a single data point — by either the chart's
|
||||
* "More Options" header menu or a right-click context menu on the chart body.
|
||||
|
||||
@@ -53,7 +53,7 @@ const COLOR_INTERVAL_2: [number, number, number] = [69, 78, 124];
|
||||
const COLOR_UNUSED_3: [number, number, number] = [90, 193, 137];
|
||||
|
||||
testWithAssets(
|
||||
'Gauge renders configured interval colors on a dashboard (#28766)',
|
||||
'Gauge renders configured interval colors on a dashboard',
|
||||
async ({ page, testAssets }) => {
|
||||
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ const FILTER_COLUMN = 'gender';
|
||||
const FILTER_VALUE = 'boy';
|
||||
|
||||
testWithAssets(
|
||||
'Mixed chart applies dashboard filter to both queries (#29519)',
|
||||
'Mixed chart applies dashboard filter to both queries',
|
||||
async ({ page, testAssets }) => {
|
||||
// Four API round-trips of setup precede a full dashboard load with a
|
||||
// preselected native filter, matching the other dashboard specs that build
|
||||
|
||||
@@ -82,7 +82,7 @@ async function findDatasetIdByName(page: Page, name: string): Promise<number> {
|
||||
return body.result[0].id;
|
||||
}
|
||||
|
||||
test.describe('Embedded Pivot Table collapse state (#33406)', () => {
|
||||
test.describe('Embedded Pivot Table collapse state', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
test.setTimeout(90000);
|
||||
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
|
||||
# Experimental Playwright Tests
|
||||
|
||||
## Purpose
|
||||
|
||||
This directory contains **experimental** Playwright E2E tests that are being developed and stabilized before becoming part of the required test suite.
|
||||
|
||||
## How Experimental Tests Work
|
||||
|
||||
### Running Tests
|
||||
|
||||
**By default (CI and local), experimental tests are EXCLUDED:**
|
||||
|
||||
```bash
|
||||
npm run playwright:test
|
||||
# Only runs stable tests (tests/auth/*)
|
||||
```
|
||||
|
||||
**To include experimental tests, set the environment variable:**
|
||||
|
||||
```bash
|
||||
INCLUDE_EXPERIMENTAL=true npm run playwright:test
|
||||
# Runs all tests including experimental/
|
||||
```
|
||||
|
||||
### CI Behavior
|
||||
|
||||
- **Required CI jobs**: Experimental tests are excluded by default
|
||||
- Tests in `experimental/` do NOT block merges
|
||||
- Failures in `experimental/` do NOT fail the build
|
||||
|
||||
- **Experimental CI jobs** (optional): Use `TEST_PATH=experimental/`
|
||||
- Set `INCLUDE_EXPERIMENTAL=true` in the job environment to include experimental tests
|
||||
- These jobs can use `continue-on-error: true` for shadow mode
|
||||
|
||||
### Configuration
|
||||
|
||||
The experimental pattern is configured in `playwright.config.ts`:
|
||||
|
||||
```typescript
|
||||
testIgnore: process.env.INCLUDE_EXPERIMENTAL
|
||||
? undefined
|
||||
: '**/experimental/**',
|
||||
```
|
||||
|
||||
This ensures:
|
||||
|
||||
- Without `INCLUDE_EXPERIMENTAL`: Tests in `experimental/` are ignored
|
||||
- With `INCLUDE_EXPERIMENTAL=true`: All tests run, including experimental
|
||||
|
||||
## When to Use Experimental
|
||||
|
||||
Add tests to `experimental/` when:
|
||||
|
||||
1. **Testing new infrastructure** - New page objects, components, or patterns that need real-world validation
|
||||
2. **Flaky tests** - Tests that pass locally but have intermittent CI failures that need investigation
|
||||
3. **New test types** - E2E tests for new features that need to prove stability before becoming required
|
||||
4. **Prototyping** - Experimental approaches that may or may not become standard patterns
|
||||
|
||||
## Moving Tests to Stable
|
||||
|
||||
Once an experimental test has proven stable (consistent CI passes over time):
|
||||
|
||||
1. **Move the test file** from `experimental/` to the appropriate stable directory:
|
||||
|
||||
```bash
|
||||
git mv tests/experimental/dataset/my-test.spec.ts tests/dataset/my-test.spec.ts
|
||||
```
|
||||
|
||||
2. **Commit the move** with a clear message:
|
||||
|
||||
```bash
|
||||
git commit -m "test(playwright): promote my-test from experimental to stable"
|
||||
```
|
||||
|
||||
3. **Test will now be required** - It will run by default and block merges on failure
|
||||
|
||||
## Current Experimental Tests
|
||||
|
||||
### Dataset Tests
|
||||
|
||||
- **`dataset/dataset-list.spec.ts`** - Dataset list E2E tests
|
||||
- Status: Infrastructure complete, validating stability
|
||||
- Includes: Delete dataset test with API-based test data
|
||||
- Supporting infrastructure: API helpers, Modal components, page objects
|
||||
|
||||
## Infrastructure Location
|
||||
|
||||
**Important**: Supporting infrastructure (components, page objects, API helpers) should live in **stable locations**, NOT under `experimental/`:
|
||||
|
||||
✅ **Correct locations:**
|
||||
|
||||
- `playwright/components/` - Components used by any tests
|
||||
- `playwright/pages/` - Page objects for any features
|
||||
- `playwright/helpers/api/` - API helpers for test data setup
|
||||
|
||||
❌ **Avoid:**
|
||||
|
||||
- `playwright/tests/experimental/components/` - Makes it hard to share infrastructure
|
||||
|
||||
This keeps infrastructure reusable and avoids duplication when tests graduate from experimental to stable.
|
||||
|
||||
## Questions?
|
||||
|
||||
See [Superset Testing Documentation](https://superset.apache.org/docs/contributing/development#testing) or ask in the `#testing` Slack channel.
|
||||
@@ -24,22 +24,18 @@
|
||||
* backend round-trip; the reuse-vs-fallback and row-limit slicing logic is
|
||||
* unit-tested in useResultsPane.test.tsx, so this E2E asserts only the
|
||||
* end-to-end win: opening Results issues no extra chart/data request.
|
||||
*
|
||||
* Lives under tests/experimental/ until proven stable in CI; run with:
|
||||
* INCLUDE_EXPERIMENTAL=true npm run playwright:test \
|
||||
* tests/experimental/explore/results-tab-reuse.spec.ts -- --headed
|
||||
*/
|
||||
import { testWithAssets, expect } from '../../../helpers/fixtures';
|
||||
import { apiPostChart } from '../../../helpers/api/chart';
|
||||
import { getDatasetByName } from '../../../helpers/api/dataset';
|
||||
import { ExplorePage } from '../../../pages/ExplorePage';
|
||||
import { TIMEOUT } from '../../../utils/constants';
|
||||
import { testWithAssets, expect } from '../../helpers/fixtures';
|
||||
import { apiPostChart } from '../../helpers/api/chart';
|
||||
import { getDatasetByName } from '../../helpers/api/dataset';
|
||||
import { ExplorePage } from '../../pages/ExplorePage';
|
||||
import { TIMEOUT } from '../../utils/constants';
|
||||
|
||||
const DATASET_NAME = 'birth_names';
|
||||
const CHART_DATA_PATH = '/api/v1/chart/data';
|
||||
|
||||
testWithAssets(
|
||||
'Results tab reuses chart data without a duplicate query (#38165)',
|
||||
'Results tab reuses chart data without a duplicate query',
|
||||
async ({ page, testAssets }) => {
|
||||
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
getNumberFormatter,
|
||||
getTimeFormatter,
|
||||
getTimeFormatterForGranularity,
|
||||
normalizeCurrency,
|
||||
resolveDetectedCurrency,
|
||||
NumberFormats,
|
||||
QueryMode,
|
||||
SMART_DATE_ID,
|
||||
@@ -147,6 +147,7 @@ const getComparisonColFormatter = (
|
||||
columnConfig: Record<string, TableColumnConfig>,
|
||||
savedFormat: string | undefined,
|
||||
savedCurrency: Currency | undefined,
|
||||
resolveCurrency: (currency: Currency | undefined) => Currency | undefined,
|
||||
) => {
|
||||
const currentColConfig = getComparisonColConfig(
|
||||
label,
|
||||
@@ -161,7 +162,9 @@ const getComparisonColFormatter = (
|
||||
if (label === '%') {
|
||||
formatter = getNumberFormatter(currentColNumberFormat || PERCENT_3_POINT);
|
||||
} else if (currentColNumberFormat || hasCurrency) {
|
||||
const currency = currentColConfig.currencyFormat || savedCurrency;
|
||||
const currency = resolveCurrency(
|
||||
currentColConfig.currencyFormat || savedCurrency,
|
||||
);
|
||||
const numberFormat = currentColNumberFormat || savedFormat;
|
||||
formatter = currency
|
||||
? new CurrencyFormatter({
|
||||
@@ -254,9 +257,19 @@ const processComparisonColumns = (
|
||||
) =>
|
||||
columns.flatMap(col => {
|
||||
const {
|
||||
datasource: { columnFormats, currencyFormats },
|
||||
datasource: { columnFormats, currencyFormats, currencyCodeColumn },
|
||||
rawFormData: { column_config: columnConfig = {} },
|
||||
queriesData,
|
||||
} = props;
|
||||
const { detected_currency: detectedCurrency, colnames } =
|
||||
queriesData[0] || {};
|
||||
const resolveCurrency = (currency: Currency | undefined) =>
|
||||
resolveDetectedCurrency(
|
||||
currency,
|
||||
detectedCurrency,
|
||||
currencyCodeColumn,
|
||||
colnames,
|
||||
);
|
||||
const savedFormat = columnFormats?.[col.key];
|
||||
const savedCurrency = currencyFormats?.[col.key];
|
||||
const originalLabel = col.label;
|
||||
@@ -279,6 +292,7 @@ const processComparisonColumns = (
|
||||
columnConfig,
|
||||
savedFormat,
|
||||
savedCurrency,
|
||||
resolveCurrency,
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -294,6 +308,7 @@ const processComparisonColumns = (
|
||||
columnConfig,
|
||||
savedFormat,
|
||||
savedCurrency,
|
||||
resolveCurrency,
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -309,6 +324,7 @@ const processComparisonColumns = (
|
||||
columnConfig,
|
||||
savedFormat,
|
||||
savedCurrency,
|
||||
resolveCurrency,
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -324,6 +340,7 @@ const processComparisonColumns = (
|
||||
columnConfig,
|
||||
savedFormat,
|
||||
savedCurrency,
|
||||
resolveCurrency,
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -477,21 +494,12 @@ const processColumns = memoizePerChart(function processColumns(
|
||||
// percent metrics have a default format
|
||||
formatter = getNumberFormatter(numberFormat || PERCENT_3_POINT);
|
||||
} else if (isMetric || (isNumber && (numberFormat || currency))) {
|
||||
// Resolve AUTO currency when currency column isn't in query results
|
||||
let resolvedCurrency = currency;
|
||||
if (
|
||||
currency?.symbol === 'AUTO' &&
|
||||
detectedCurrency &&
|
||||
(!currencyCodeColumn || !colnames?.includes(currencyCodeColumn))
|
||||
) {
|
||||
const normalizedCurrency = normalizeCurrency(detectedCurrency);
|
||||
if (normalizedCurrency) {
|
||||
resolvedCurrency = {
|
||||
...currency,
|
||||
symbol: normalizedCurrency,
|
||||
};
|
||||
}
|
||||
}
|
||||
const resolvedCurrency = resolveDetectedCurrency(
|
||||
currency,
|
||||
detectedCurrency,
|
||||
currencyCodeColumn,
|
||||
colnames,
|
||||
);
|
||||
formatter = resolvedCurrency?.symbol
|
||||
? new CurrencyFormatter({
|
||||
d3Format: numberFormat,
|
||||
|
||||
@@ -329,6 +329,51 @@ test('does not mistake the all_records percent-metric query for the totals query
|
||||
expect(result.totals).toEqual({ sum__num: 42 });
|
||||
});
|
||||
|
||||
test('resolves AUTO currency on comparison columns from detected_currency', () => {
|
||||
const autoCurrency = { symbol: 'AUTO', symbolPosition: 'prefix' };
|
||||
const comparisonKeys = ['Main metric_1', '# metric_1', '△ metric_1'];
|
||||
const props = createMockChartProps({
|
||||
rawFormData: {
|
||||
viz_type: 'table',
|
||||
datasource: '1__table',
|
||||
slice_id: 1,
|
||||
query_mode: QueryMode.Aggregate,
|
||||
metrics: ['metric_1'],
|
||||
percent_metrics: [],
|
||||
column_config: Object.fromEntries(
|
||||
comparisonKeys.map(key => [key, { currencyFormat: autoCurrency }]),
|
||||
),
|
||||
table_timestamp_format: '',
|
||||
time_compare: ['1 year ago'],
|
||||
comparison_type: 'values',
|
||||
},
|
||||
datasource: {
|
||||
columns: [],
|
||||
metrics: [],
|
||||
columnFormats: {},
|
||||
currencyFormats: {},
|
||||
verboseMap: {},
|
||||
currencyCodeColumn: 'currency_code',
|
||||
},
|
||||
queriesData: [
|
||||
{
|
||||
data: [{ metric_1: 100, 'metric_1__1 year ago': 80 }],
|
||||
colnames: ['metric_1', 'metric_1__1 year ago'],
|
||||
coltypes: [GenericDataType.Numeric, GenericDataType.Numeric],
|
||||
rowcount: 1,
|
||||
detected_currency: 'GBP',
|
||||
},
|
||||
{ data: [{ rowcount: 1 }] },
|
||||
] as unknown as TableChartProps['queriesData'],
|
||||
} as unknown as Partial<TableChartProps>);
|
||||
|
||||
const { columns } = transformProps(props);
|
||||
comparisonKeys.forEach(key => {
|
||||
const formatted = columns.find(col => col.key === key)?.formatter?.(100);
|
||||
expect(formatted).toContain('£');
|
||||
});
|
||||
});
|
||||
|
||||
test('excludes Green/Red color-scheme rules from columnColorFormatters', () => {
|
||||
// Green/Red rules are rendered via the increase/decrease path, so they must
|
||||
// not reach getColorFormatters, which would treat the scheme name as a hex
|
||||
|
||||
@@ -241,7 +241,8 @@ test('replaces stale query event handlers without clearing regular event handler
|
||||
});
|
||||
|
||||
test.each([
|
||||
// Report/thumbnail screenshots render in standalone "true" (charts) or 3 (reports)
|
||||
// Report/thumbnail captures use standalone=3; "true" is the legacy capture
|
||||
// value and stays supported for links created before numeric modes existed
|
||||
['true', true],
|
||||
['3', true],
|
||||
// Live embeds use 1/2 and must keep animation
|
||||
|
||||
@@ -125,8 +125,9 @@ use([
|
||||
LegacyGridContainLabel,
|
||||
]);
|
||||
|
||||
// Report/thumbnail screenshots use standalone="true" (charts) or 3 (reports);
|
||||
// live embeds use 1/2 and keep animation. See superset/utils/screenshots.py.
|
||||
// Report/thumbnail screenshots use standalone=3. Legacy chart capture URLs may
|
||||
// still carry standalone="true"; live embeds use 1/2 and keep animation.
|
||||
// See superset/utils/screenshots.py.
|
||||
export function isReportScreenshotMode(): boolean {
|
||||
try {
|
||||
const standalone = new URLSearchParams(window.location.search).get(
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"@math.gl/web-mercator": "^4.1.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"@types/supercluster": "^7.1.3",
|
||||
"mapbox-gl": "^3.30.0",
|
||||
"mapbox-gl": "^3.31.0",
|
||||
"maplibre-gl": "^6.8.0",
|
||||
"react-map-gl": "^8.1.3",
|
||||
"supercluster": "^9.1.0"
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
getTimeFormatter,
|
||||
getTimeFormatterForGranularity,
|
||||
isAdhocColumn,
|
||||
normalizeCurrency,
|
||||
resolveDetectedCurrency,
|
||||
NumberFormats,
|
||||
QueryMode,
|
||||
SMART_DATE_ID,
|
||||
@@ -299,21 +299,12 @@ const processColumns = memoizeOne(function processColumns(
|
||||
// percent metrics have a default format
|
||||
formatter = getNumberFormatter(numberFormat || PERCENT_3_POINT);
|
||||
} else if (isMetric || (isNumber && (numberFormat || currency))) {
|
||||
// Resolve AUTO currency when currency column isn't in query results
|
||||
let resolvedCurrency = currency;
|
||||
if (
|
||||
currency?.symbol === 'AUTO' &&
|
||||
detectedCurrency &&
|
||||
(!currencyCodeColumn || !colnames?.includes(currencyCodeColumn))
|
||||
) {
|
||||
const normalizedCurrency = normalizeCurrency(detectedCurrency);
|
||||
if (normalizedCurrency) {
|
||||
resolvedCurrency = {
|
||||
...currency,
|
||||
symbol: normalizedCurrency,
|
||||
};
|
||||
}
|
||||
}
|
||||
const resolvedCurrency = resolveDetectedCurrency(
|
||||
currency,
|
||||
detectedCurrency,
|
||||
currencyCodeColumn,
|
||||
colnames,
|
||||
);
|
||||
formatter = resolvedCurrency?.symbol
|
||||
? new CurrencyFormatter({
|
||||
d3Format: numberFormat,
|
||||
@@ -358,6 +349,7 @@ const getComparisonColFormatter = (
|
||||
columnConfig: Record<string, TableColumnConfig>,
|
||||
savedFormat: string | undefined,
|
||||
savedCurrency: Currency | undefined,
|
||||
resolveCurrency: (currency: Currency | undefined) => Currency | undefined,
|
||||
) => {
|
||||
const currentColConfig = getComparisonColConfig(
|
||||
label,
|
||||
@@ -372,7 +364,9 @@ const getComparisonColFormatter = (
|
||||
if (label === '%') {
|
||||
formatter = getNumberFormatter(currentColNumberFormat || PERCENT_3_POINT);
|
||||
} else if (currentColNumberFormat || hasCurrency) {
|
||||
const currency = currentColConfig.currencyFormat || savedCurrency;
|
||||
const currency = resolveCurrency(
|
||||
currentColConfig.currencyFormat || savedCurrency,
|
||||
);
|
||||
const numberFormat = currentColNumberFormat || savedFormat;
|
||||
formatter = currency
|
||||
? new CurrencyFormatter({
|
||||
@@ -391,9 +385,19 @@ const processComparisonColumns = (
|
||||
) =>
|
||||
columns.flatMap(col => {
|
||||
const {
|
||||
datasource: { columnFormats, currencyFormats },
|
||||
datasource: { columnFormats, currencyFormats, currencyCodeColumn },
|
||||
rawFormData: { column_config: columnConfig = {} },
|
||||
queriesData,
|
||||
} = props;
|
||||
const { detected_currency: detectedCurrency, colnames } =
|
||||
queriesData[0] || {};
|
||||
const resolveCurrency = (currency: Currency | undefined) =>
|
||||
resolveDetectedCurrency(
|
||||
currency,
|
||||
detectedCurrency,
|
||||
currencyCodeColumn,
|
||||
colnames,
|
||||
);
|
||||
const savedFormat = columnFormats?.[col.key];
|
||||
const savedCurrency = currencyFormats?.[col.key];
|
||||
const originalLabel = col.label;
|
||||
@@ -415,6 +419,7 @@ const processComparisonColumns = (
|
||||
columnConfig,
|
||||
savedFormat,
|
||||
savedCurrency,
|
||||
resolveCurrency,
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -429,6 +434,7 @@ const processComparisonColumns = (
|
||||
columnConfig,
|
||||
savedFormat,
|
||||
savedCurrency,
|
||||
resolveCurrency,
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -443,6 +449,7 @@ const processComparisonColumns = (
|
||||
columnConfig,
|
||||
savedFormat,
|
||||
savedCurrency,
|
||||
resolveCurrency,
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -457,6 +464,7 @@ const processComparisonColumns = (
|
||||
columnConfig,
|
||||
savedFormat,
|
||||
savedCurrency,
|
||||
resolveCurrency,
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -335,6 +335,38 @@ describe('plugin-chart-table', () => {
|
||||
expect(formattedPercentMetric).toBe('0.123');
|
||||
});
|
||||
|
||||
test('resolves AUTO currency on comparison columns from detected_currency', () => {
|
||||
const autoCurrency = { symbol: 'AUTO', symbolPosition: 'prefix' };
|
||||
const comparisonKeys = ['Main metric_1', '# metric_1', '△ metric_1'];
|
||||
const transformedProps = transformProps({
|
||||
...testData.comparisonWithConfig,
|
||||
rawFormData: {
|
||||
...testData.comparisonWithConfig.rawFormData,
|
||||
column_config: Object.fromEntries(
|
||||
comparisonKeys.map(key => [key, { currencyFormat: autoCurrency }]),
|
||||
),
|
||||
},
|
||||
datasource: {
|
||||
...testData.comparisonWithConfig.datasource,
|
||||
currencyCodeColumn: 'currency_code',
|
||||
},
|
||||
queriesData: [
|
||||
{
|
||||
...testData.comparisonWithConfig.queriesData[0],
|
||||
detected_currency: 'GBP',
|
||||
},
|
||||
testData.comparisonWithConfig.queriesData[1],
|
||||
],
|
||||
});
|
||||
|
||||
comparisonKeys.forEach(key => {
|
||||
const formatted = transformedProps.columns
|
||||
.find(col => col.key === key)
|
||||
?.formatter?.(100);
|
||||
expect(formatted).toContain('£');
|
||||
});
|
||||
});
|
||||
|
||||
test('should set originalLabel for comparison columns when time_compare and comparison_type are set', () => {
|
||||
const transformedProps = transformProps(testData.comparison);
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fastGlob from 'fast-glob';
|
||||
import { globSync } from 'node:fs';
|
||||
import yargs from 'yargs';
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
|
||||
@@ -61,26 +61,29 @@ function getPackages(packagePattern, tsOnly = false) {
|
||||
// Find packages in both @superset-ui and @apache-superset scopes
|
||||
const supersetUiPackages = [
|
||||
...new Set(
|
||||
fastGlob
|
||||
.sync([
|
||||
globSync(
|
||||
[
|
||||
`./node_modules/@superset-ui/${pattern}/src/**/*.${
|
||||
tsOnly ? '{ts,tsx}' : '{ts,tsx,js,jsx}'
|
||||
}`,
|
||||
])
|
||||
.map(x => x.split('/')[3])
|
||||
],
|
||||
{ followSymlinks: true },
|
||||
)
|
||||
.map(x => x.split('/')[2])
|
||||
.filter(x => !META_PACKAGES.has(x)),
|
||||
),
|
||||
];
|
||||
|
||||
const apachePackages = [
|
||||
...new Set(
|
||||
fastGlob
|
||||
.sync([
|
||||
globSync(
|
||||
[
|
||||
`./node_modules/@apache-superset/${pattern}/src/**/*.${
|
||||
tsOnly ? '{ts,tsx}' : '{ts,tsx,js,jsx}'
|
||||
}`,
|
||||
])
|
||||
.map(x => x.split('/')[3]),
|
||||
],
|
||||
{ followSymlinks: true },
|
||||
).map(x => x.split('/')[2]),
|
||||
),
|
||||
];
|
||||
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/* eslint-disable no-console */
|
||||
import { sync } from 'fast-glob';
|
||||
import { copy } from 'fs-extra';
|
||||
|
||||
const pkgGlob = process.argv[2] || '*';
|
||||
|
||||
const packages = sync([`{packages,plugins}/${pkgGlob}`], {
|
||||
onlyDirectories: true,
|
||||
});
|
||||
|
||||
console.log('Copying asset files from package {src} to {lib,esm}...');
|
||||
packages.forEach(pkg => {
|
||||
const assets = sync([`${pkg}/src/**/*.{png,gif,jpg,css,geojson}`]);
|
||||
assets.forEach(filePath => {
|
||||
['lib', 'esm']
|
||||
.map(dir => filePath.replace(`${pkg}/src`, `${pkg}/${dir}`))
|
||||
.forEach(newFilePath => {
|
||||
copy(filePath, newFilePath, err => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if (assets.length > 0) {
|
||||
console.log(
|
||||
` Copied ${assets.length
|
||||
.toString()
|
||||
.padStart(2)} asset files for ${pkg.replace(
|
||||
'packages/superset-ui-',
|
||||
'',
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { type ComponentType, useRef } from 'react';
|
||||
import { type ComponentType, type ReactNode, useRef } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import { css, useTheme } from '@apache-superset/core/theme';
|
||||
@@ -88,7 +88,7 @@ export const ChatPanelHost = () => {
|
||||
* floating panel overlay. The trigger is always visible when a chat is
|
||||
* registered; the panel overlay is suppressed in panel mode.
|
||||
*/
|
||||
export const ChatFloatingHost = () => {
|
||||
export const ChatFloatingHost = ({ children }: { children?: ReactNode }) => {
|
||||
const theme = useTheme();
|
||||
const { open: panelOpen, mode, chat, trigger, panel } = useChat();
|
||||
const onError = useCrashNotifier(chat?.id);
|
||||
@@ -116,13 +116,15 @@ export const ChatFloatingHost = () => {
|
||||
Separate boundaries so a crashing panel cannot take the trigger down
|
||||
with it — the trigger is the user's only way back.
|
||||
*/}
|
||||
{panelOpen && mode !== 'panel' && (
|
||||
<ChatBoundary
|
||||
key={`panel-${chat.id}`}
|
||||
component={panel}
|
||||
onError={onError}
|
||||
/>
|
||||
)}
|
||||
{panelOpen &&
|
||||
mode !== 'panel' &&
|
||||
(children ?? (
|
||||
<ChatBoundary
|
||||
key={`panel-${chat.id}`}
|
||||
component={panel}
|
||||
onError={onError}
|
||||
/>
|
||||
))}
|
||||
<ChatBoundary
|
||||
key={`trigger-${chat.id}`}
|
||||
component={trigger}
|
||||
|
||||
@@ -23,7 +23,10 @@ import {
|
||||
saveChartCustomization,
|
||||
} from './chartCustomizationActions';
|
||||
import { SET_IN_SCOPE_STATUS_OF_FILTERS } from './nativeFilters';
|
||||
import { DASHBOARD_INFO_UPDATED } from './dashboardInfo';
|
||||
import {
|
||||
DASHBOARD_INFO_UPDATED,
|
||||
dashboardSaveSucceeded,
|
||||
} from './dashboardInfo';
|
||||
|
||||
beforeAll(() => fetchMock.mockGlobal());
|
||||
afterAll(() => fetchMock.hardReset());
|
||||
@@ -225,6 +228,8 @@ test('saveChartCustomization filters null entries from currentConfig before merg
|
||||
const thunk = saveChartCustomization([customization], [], [], false);
|
||||
await thunk(dispatch, getState, null);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(dashboardSaveSucceeded(1));
|
||||
|
||||
// DASHBOARD_INFO_UPDATED should have merged config without nulls
|
||||
const infoUpdateCall = dispatch.mock.calls.find(
|
||||
([action]: [{ type: string }]) => action.type === DASHBOARD_INFO_UPDATED,
|
||||
@@ -271,3 +276,16 @@ test('saveChartCustomization filters null entries from oldConfig when resetDataM
|
||||
// Should not throw when building oldCustomizationsById from null-containing config
|
||||
await expect(thunk(dispatch, getState, null)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
test('failed customization persistence does not signal a successful dashboard save', async () => {
|
||||
fetchMock.put('glob:*/api/v1/dashboard/1/chart_customizations', 500);
|
||||
const { getState, dispatch } = setup({
|
||||
dashboardInfo: { id: 1, metadata: {} },
|
||||
});
|
||||
|
||||
await expect(
|
||||
saveChartCustomization([], [])(dispatch, getState, null),
|
||||
).rejects.toBeDefined();
|
||||
|
||||
expect(dispatch).not.toHaveBeenCalledWith(dashboardSaveSucceeded(1));
|
||||
});
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
removeDataMask,
|
||||
setDataMaskForFilterChangesComplete,
|
||||
} from 'src/dataMask/actions';
|
||||
import { dashboardInfoChanged } from './dashboardInfo';
|
||||
import { dashboardInfoChanged, dashboardSaveSucceeded } from './dashboardInfo';
|
||||
import {
|
||||
SET_NATIVE_FILTERS_CONFIG_COMPLETE,
|
||||
SET_IN_SCOPE_STATUS_OF_FILTERS,
|
||||
@@ -106,6 +106,8 @@ export function saveChartCustomization(
|
||||
reordered: reorderedIds,
|
||||
});
|
||||
|
||||
dispatch(dashboardSaveSucceeded(id));
|
||||
|
||||
const currentMetadata = getState().dashboardInfo.metadata;
|
||||
const currentConfig =
|
||||
currentMetadata?.chart_customization_config?.filter(Boolean) || [];
|
||||
|
||||
@@ -38,6 +38,12 @@ const createUpdateDashboardApi = (id: number) =>
|
||||
endpoint: `/api/v1/dashboard/${id}`,
|
||||
});
|
||||
|
||||
export const DASHBOARD_SAVE_SUCCEEDED = 'DASHBOARD_SAVE_SUCCEEDED';
|
||||
|
||||
export function dashboardSaveSucceeded(dashboardId: number) {
|
||||
return { type: DASHBOARD_SAVE_SUCCEEDED, dashboardId };
|
||||
}
|
||||
|
||||
export const DASHBOARD_INFO_UPDATED = 'DASHBOARD_INFO_UPDATED';
|
||||
export const DASHBOARD_INFO_FILTERS_CHANGED = 'DASHBOARD_INFO_FILTERS_CHANGED';
|
||||
|
||||
|
||||
@@ -18,11 +18,15 @@
|
||||
*/
|
||||
import { HYDRATE_DASHBOARD, hydrateDashboard } from './hydrate';
|
||||
import {
|
||||
CHART_TYPE,
|
||||
COLUMN_TYPE,
|
||||
DASHBOARD_GRID_TYPE,
|
||||
DASHBOARD_ROOT_TYPE,
|
||||
ROW_TYPE,
|
||||
TABS_TYPE,
|
||||
TAB_TYPE,
|
||||
} from '../util/componentTypes';
|
||||
import { DASHBOARD_ROOT_ID } from '../util/constants';
|
||||
import { DASHBOARD_GRID_ID, DASHBOARD_ROOT_ID } from '../util/constants';
|
||||
|
||||
/**
|
||||
* Regression guard for the follow-up to PR #39417 / PR #41832: the default
|
||||
@@ -274,3 +278,82 @@ test('a permalink activeTabs: [] (empty but present) wins and seeds []', () => {
|
||||
|
||||
expect(action.data.dashboardState.activeTabs).toEqual([]);
|
||||
});
|
||||
|
||||
test('keeps a trapped chart missing from the charts payload in the layout', () => {
|
||||
// e.g. an archived chart with SOFT_DELETE: the charts endpoint omits it, so
|
||||
// hydration cannot re-add it and only its layout entry keeps its membership
|
||||
const positionData = {
|
||||
[DASHBOARD_ROOT_ID]: layoutItem(
|
||||
DASHBOARD_ROOT_ID,
|
||||
DASHBOARD_ROOT_TYPE,
|
||||
[DASHBOARD_GRID_ID],
|
||||
[],
|
||||
),
|
||||
[DASHBOARD_GRID_ID]: layoutItem(
|
||||
DASHBOARD_GRID_ID,
|
||||
DASHBOARD_GRID_TYPE,
|
||||
[],
|
||||
[DASHBOARD_ROOT_ID],
|
||||
),
|
||||
'COLUMN-orphan': layoutItem(
|
||||
'COLUMN-orphan',
|
||||
COLUMN_TYPE,
|
||||
['CHART-archived', 'ROW-orphan'],
|
||||
[DASHBOARD_ROOT_ID, DASHBOARD_GRID_ID],
|
||||
),
|
||||
'ROW-orphan': layoutItem(
|
||||
'ROW-orphan',
|
||||
ROW_TYPE,
|
||||
['COLUMN-orphan'],
|
||||
[DASHBOARD_ROOT_ID, DASHBOARD_GRID_ID, 'COLUMN-orphan'],
|
||||
),
|
||||
'CHART-archived': {
|
||||
...layoutItem('CHART-archived', CHART_TYPE, [], []),
|
||||
meta: { chartId: 42, width: 4, height: 50 },
|
||||
},
|
||||
};
|
||||
|
||||
const layout = hydrate(positionData).data.dashboardLayout.present;
|
||||
|
||||
expect(layout['COLUMN-orphan']).toBeUndefined();
|
||||
expect(layout['ROW-orphan']).toBeUndefined();
|
||||
const [rowId] = layout[DASHBOARD_GRID_ID].children;
|
||||
expect(layout[rowId].children).toEqual(['CHART-archived']);
|
||||
expect(layout['CHART-archived'].meta.chartId).toBe(42);
|
||||
});
|
||||
|
||||
test('rebuilds stale parents from the layout children', () => {
|
||||
const positionData = {
|
||||
[DASHBOARD_ROOT_ID]: layoutItem(
|
||||
DASHBOARD_ROOT_ID,
|
||||
DASHBOARD_ROOT_TYPE,
|
||||
[DASHBOARD_GRID_ID],
|
||||
[],
|
||||
),
|
||||
[DASHBOARD_GRID_ID]: layoutItem(
|
||||
DASHBOARD_GRID_ID,
|
||||
DASHBOARD_GRID_TYPE,
|
||||
['ROW-a'],
|
||||
[DASHBOARD_ROOT_ID],
|
||||
),
|
||||
'ROW-a': layoutItem('ROW-a', ROW_TYPE, ['COLUMN-a'], [DASHBOARD_ROOT_ID]),
|
||||
'COLUMN-a': layoutItem(
|
||||
'COLUMN-a',
|
||||
COLUMN_TYPE,
|
||||
[],
|
||||
[DASHBOARD_ROOT_ID, DASHBOARD_GRID_ID, 'ROW-stale'],
|
||||
),
|
||||
};
|
||||
|
||||
const layout = hydrate(positionData).data.dashboardLayout.present;
|
||||
|
||||
expect(layout['ROW-a'].parents).toEqual([
|
||||
DASHBOARD_ROOT_ID,
|
||||
DASHBOARD_GRID_ID,
|
||||
]);
|
||||
expect(layout['COLUMN-a'].parents).toEqual([
|
||||
DASHBOARD_ROOT_ID,
|
||||
DASHBOARD_GRID_ID,
|
||||
'ROW-a',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -55,6 +55,7 @@ import getLocationHash from 'src/dashboard/util/getLocationHash';
|
||||
import newComponentFactory, {
|
||||
DashboardEntity,
|
||||
} from 'src/dashboard/util/newComponentFactory';
|
||||
import removeUnreachableComponents from 'src/dashboard/util/removeUnreachableComponents';
|
||||
import { URL_PARAMS } from 'src/constants';
|
||||
import { getUrlParam } from 'src/utils/urlUtils';
|
||||
import { ResourceStatus } from 'src/hooks/apiResources/apiResources';
|
||||
@@ -133,11 +134,13 @@ export const hydrateDashboard =
|
||||
// new dash: position_json could be {} or null
|
||||
// getEmptyLayout() includes a version string entry plus BasicLayoutItem entries
|
||||
// which lack the `meta` field; layout is mutated below to add full LayoutItem entries
|
||||
const layout = (
|
||||
positionData && Object.keys(positionData).length > 0
|
||||
// Repaired before anything indexes the layout: a detached cycle crashes the
|
||||
// filter scope modal, and a chart trapped in one would never render.
|
||||
const layout = removeUnreachableComponents(
|
||||
(positionData && Object.keys(positionData).length > 0
|
||||
? positionData
|
||||
: getEmptyLayout()
|
||||
) as Record<string, LayoutItem | DashboardEntity>;
|
||||
: getEmptyLayout()) as Record<string, LayoutItem | DashboardEntity>,
|
||||
);
|
||||
|
||||
// create a lookup to sync layout names with slice names
|
||||
const chartIdToLayoutId: Record<number, string> = {};
|
||||
@@ -241,17 +244,12 @@ export const hydrateDashboard =
|
||||
}
|
||||
});
|
||||
|
||||
// make sure that parents tree is built
|
||||
if (
|
||||
Object.values(layout).some(
|
||||
element => element.id !== DASHBOARD_ROOT_ID && !element.parents,
|
||||
)
|
||||
) {
|
||||
updateComponentParentsList({
|
||||
currentComponent: layout[DASHBOARD_ROOT_ID] as LayoutItem,
|
||||
layout: layout as Record<string, LayoutItem>,
|
||||
});
|
||||
}
|
||||
// buildActiveFilters reads `parents` for filter scopes before the layout
|
||||
// reducer rebuilds them, and the repair above may have moved components
|
||||
updateComponentParentsList({
|
||||
currentComponent: layout[DASHBOARD_ROOT_ID] as LayoutItem,
|
||||
layout: layout as Record<string, LayoutItem>,
|
||||
});
|
||||
|
||||
buildActiveFilters({
|
||||
dashboardFilters: dashboardFilters as Parameters<
|
||||
|
||||
@@ -16,12 +16,20 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen, waitFor } from 'spec/helpers/testing-library';
|
||||
import { StrictMode, useState } from 'react';
|
||||
import { act, render, screen, waitFor } from 'spec/helpers/testing-library';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { storeWithState } from 'spec/fixtures/mockStore';
|
||||
import mockState from 'spec/fixtures/mockState';
|
||||
import { sliceId } from 'spec/fixtures/mockChartQueries';
|
||||
import { ChartCustomizationType, NativeFilterType } from '@superset-ui/core';
|
||||
import {
|
||||
ChartCustomizationType,
|
||||
NativeFilterType,
|
||||
CategoricalColorNamespace,
|
||||
} from '@superset-ui/core';
|
||||
import DashboardGrid from 'src/dashboard/containers/DashboardGrid';
|
||||
import { applyDashboardLabelsColorOnLoad } from 'src/dashboard/actions/dashboardState';
|
||||
import { dashboardInfoChanged } from 'src/dashboard/actions/dashboardInfo';
|
||||
import { CHART_TYPE } from '../../util/componentTypes';
|
||||
import DashboardContainer from './DashboardContainer';
|
||||
import * as nativeFiltersActions from '../../actions/nativeFilters';
|
||||
@@ -37,7 +45,7 @@ jest.mock('@visx/responsive', () => ({
|
||||
|
||||
jest.mock('src/dashboard/containers/DashboardGrid', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-test="mock-dashboard-grid" />,
|
||||
default: jest.fn(() => <div data-test="mock-dashboard-grid" />),
|
||||
}));
|
||||
|
||||
// DashboardContainer dispatches these on mount, so unit tests stub them.
|
||||
@@ -154,6 +162,12 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
setInScopeStatusMock.mockRestore();
|
||||
jest
|
||||
.mocked(applyDashboardLabelsColorOnLoad)
|
||||
.mockImplementation(() => async () => {});
|
||||
jest
|
||||
.mocked(DashboardGrid)
|
||||
.mockImplementation(() => <div data-test="mock-dashboard-grid" />);
|
||||
});
|
||||
|
||||
test('calculates chartsInScope correctly for filters', async () => {
|
||||
@@ -853,3 +867,128 @@ test('does not dispatch setInScopeStatusOfCustomizations when chart_customizatio
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test.each([false, true])(
|
||||
'applies custom label colors before a cached chart consumes its first color scale (StrictMode=%s)',
|
||||
strict => {
|
||||
const namespace = 'initial-label-colors';
|
||||
CategoricalColorNamespace.getNamespace(namespace).resetColors();
|
||||
jest
|
||||
.mocked(applyDashboardLabelsColorOnLoad)
|
||||
.mockImplementation(
|
||||
jest.requireActual('src/dashboard/actions/dashboardState')
|
||||
.applyDashboardLabelsColorOnLoad,
|
||||
);
|
||||
jest.mocked(DashboardGrid).mockImplementation(function CachedChart() {
|
||||
const [color] = useState(() =>
|
||||
CategoricalColorNamespace.getScale(undefined, namespace).getColor(
|
||||
'20_Passed',
|
||||
),
|
||||
);
|
||||
return <div data-test="cached-chart" data-color={color} />;
|
||||
});
|
||||
|
||||
const initialState = createTestState({
|
||||
dashboardInfo: {
|
||||
...mockState.dashboardInfo,
|
||||
metadata: {
|
||||
...mockState.dashboardInfo.metadata,
|
||||
color_namespace: namespace,
|
||||
color_scheme: '',
|
||||
label_colors: { '20_Passed': '#008000' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { unmount } = render(
|
||||
strict ? (
|
||||
<StrictMode>
|
||||
<DashboardContainer />
|
||||
</StrictMode>
|
||||
) : (
|
||||
<DashboardContainer />
|
||||
),
|
||||
{ useRedux: true, store: storeWithState(initialState) },
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('cached-chart')).toHaveAttribute(
|
||||
'data-color',
|
||||
'#008000',
|
||||
);
|
||||
unmount();
|
||||
CategoricalColorNamespace.getNamespace(namespace).resetColors();
|
||||
},
|
||||
);
|
||||
|
||||
test.each([false, true])(
|
||||
'initializes the next dashboard colors before mounting its cached charts (new namespace=%s)',
|
||||
newNamespace => {
|
||||
const namespace = 'navigation-label-colors';
|
||||
let chartNamespace = namespace;
|
||||
CategoricalColorNamespace.getNamespace(namespace).resetColors();
|
||||
jest
|
||||
.mocked(applyDashboardLabelsColorOnLoad)
|
||||
.mockImplementation(
|
||||
jest.requireActual('src/dashboard/actions/dashboardState')
|
||||
.applyDashboardLabelsColorOnLoad,
|
||||
);
|
||||
jest.mocked(DashboardGrid).mockImplementation(function CachedChart() {
|
||||
const [color] = useState(() =>
|
||||
CategoricalColorNamespace.getScale(undefined, chartNamespace).getColor(
|
||||
'20_Passed',
|
||||
),
|
||||
);
|
||||
return <div data-test="cached-chart" data-color={color} />;
|
||||
});
|
||||
const metadata = {
|
||||
...mockState.dashboardInfo.metadata,
|
||||
color_namespace: namespace,
|
||||
color_scheme: '',
|
||||
label_colors: { '20_Passed': '#008000' },
|
||||
};
|
||||
const { store, unmount } = setupWithStore({
|
||||
dashboardInfo: { ...mockState.dashboardInfo, metadata },
|
||||
});
|
||||
expect(screen.getByTestId('cached-chart')).toHaveAttribute(
|
||||
'data-color',
|
||||
'#008000',
|
||||
);
|
||||
|
||||
chartNamespace = newNamespace ? 'next-dashboard-colors' : namespace;
|
||||
act(() => {
|
||||
store.dispatch(
|
||||
dashboardInfoChanged({
|
||||
id: mockState.dashboardInfo.id + 1,
|
||||
metadata: {
|
||||
...store.getState().dashboardInfo.metadata,
|
||||
...metadata,
|
||||
color_namespace: chartNamespace,
|
||||
label_colors: { '20_Passed': '#ff0000' },
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('cached-chart')).toHaveAttribute(
|
||||
'data-color',
|
||||
'#ff0000',
|
||||
);
|
||||
unmount();
|
||||
CategoricalColorNamespace.getNamespace(namespace).resetColors();
|
||||
CategoricalColorNamespace.getNamespace(chartNamespace).resetColors();
|
||||
},
|
||||
);
|
||||
|
||||
test('waits for dashboard hydration before mounting cached charts', () => {
|
||||
jest.mocked(applyDashboardLabelsColorOnLoad).mockClear();
|
||||
const { store } = setupWithStore({ dashboardInfo: {} });
|
||||
expect(screen.queryByTestId('mock-dashboard-grid')).not.toBeInTheDocument();
|
||||
expect(applyDashboardLabelsColorOnLoad).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
store.dispatch(dashboardInfoChanged({ id: mockState.dashboardInfo.id }));
|
||||
});
|
||||
|
||||
expect(applyDashboardLabelsColorOnLoad).toHaveBeenCalled();
|
||||
expect(screen.getByTestId('mock-dashboard-grid')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -164,8 +164,10 @@ const DashboardContainer: FC<DashboardContainerProps> = ({ topLevelTabs }) => {
|
||||
|
||||
const renderedChartIds = useRenderedChartIds();
|
||||
|
||||
const [dashboardLabelsColorInitiated, setDashboardLabelsColorInitiated] =
|
||||
useState(false);
|
||||
const [colorInitializedDashboardId, setColorInitializedDashboardId] =
|
||||
useState<number | null>(null);
|
||||
const dashboardLabelsColorInitiated =
|
||||
colorInitializedDashboardId === dashboardInfo?.id;
|
||||
const prevRenderedChartIds = useRef<number[]>([]);
|
||||
const prevTabIndexRef = useRef<number>();
|
||||
const prevFilterScopesRef = useRef<FilterScopeData[]>([]);
|
||||
@@ -304,7 +306,7 @@ const DashboardContainer: FC<DashboardContainerProps> = ({ topLevelTabs }) => {
|
||||
if (dashboardInfo?.id && !dashboardLabelsColorInitiated) {
|
||||
dispatch(applyDashboardLabelsColorOnLoad(dashboardInfo.metadata));
|
||||
// apply labels color as dictated by stored metadata (if any)
|
||||
setDashboardLabelsColorInitiated(true);
|
||||
setColorInitializedDashboardId(dashboardInfo.id);
|
||||
}
|
||||
|
||||
return () => {
|
||||
@@ -379,7 +381,9 @@ const DashboardContainer: FC<DashboardContainerProps> = ({ topLevelTabs }) => {
|
||||
|
||||
return (
|
||||
<div className="grid-container" data-test="grid-container" ref={parentRef}>
|
||||
{renderParentSizeChildren({ width })}
|
||||
{/* Defer the grid until hydration and color initialization complete,
|
||||
before cached charts consume their color scales on first render. */}
|
||||
{dashboardLabelsColorInitiated && renderParentSizeChildren({ width })}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -36,6 +36,11 @@ import { UPDATE_COMPONENTS } from '../../actions/dashboardLayout';
|
||||
import { AutoRefreshStatus } from '../../types/autoRefresh';
|
||||
|
||||
const mockHistoryReplace = jest.fn();
|
||||
// Dashboards render top-level (not iframed) unless a test says otherwise.
|
||||
const mockIsInIframe = jest.fn(() => false);
|
||||
jest.mock('src/dashboard/util/isEmbedded', () => ({
|
||||
isEmbedded: () => mockIsInIframe(),
|
||||
}));
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useHistory: () => ({
|
||||
@@ -244,6 +249,7 @@ beforeAll(() => {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockIsInIframe.mockReturnValue(false);
|
||||
const { useLocation } = jest.requireMock('react-router-dom');
|
||||
useLocation.mockReturnValue({
|
||||
pathname: '/dashboard',
|
||||
@@ -863,21 +869,44 @@ test('should hide edit button and navbar, and show Exit fullscreen when in fulls
|
||||
expect(screen.queryByTestId('main-navigation')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should show Exit fullscreen when in fullscreen mode', async () => {
|
||||
setup();
|
||||
|
||||
await userEvent.click(screen.getByTestId('actions-trigger'));
|
||||
|
||||
expect(await screen.findByText('Exit fullscreen')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should have fullscreen option in dropdown', async () => {
|
||||
test('should show Exit fullscreen when in standalone mode at top level', async () => {
|
||||
// Default setup URL carries standalone=1. A user who clicked "Enter fullscreen"
|
||||
// must be able to get back out, so this must not be hidden outside an iframe.
|
||||
setup();
|
||||
await openActionsDropdown();
|
||||
expect(screen.getByText('Exit fullscreen')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Enter fullscreen')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should show Enter fullscreen when not in standalone mode', async () => {
|
||||
// Keep the router location and window.location in agreement. The shared mock
|
||||
// reports `?standalone=1`; leaving it would let this test pass on a stale
|
||||
// premise if the component ever read `location.search` instead of
|
||||
// `window.location.search`.
|
||||
const { useLocation } = jest.requireMock('react-router-dom');
|
||||
useLocation.mockReturnValue({
|
||||
pathname: '/dashboard',
|
||||
search: '',
|
||||
hash: '',
|
||||
state: undefined,
|
||||
});
|
||||
window.history.pushState({}, 'Test page', '/dashboard');
|
||||
setup();
|
||||
await openActionsDropdown();
|
||||
expect(screen.getByText('Enter fullscreen')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Exit fullscreen')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should hide the fullscreen toggle entirely inside an iframe', async () => {
|
||||
// Exiting inside an iframe reloads without the standalone param and restores the
|
||||
// full Superset nav, breaking the embed — so neither direction is offered.
|
||||
mockIsInIframe.mockReturnValue(true);
|
||||
setup();
|
||||
await openActionsDropdown();
|
||||
expect(screen.queryByText('Exit fullscreen')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Enter fullscreen')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render MetadataBar when not in edit mode and not embedded', () => {
|
||||
const state = {
|
||||
dashboardInfo: {
|
||||
|
||||
@@ -34,6 +34,7 @@ import { SAVE_TYPE_NEWDASHBOARD } from 'src/dashboard/util/constants';
|
||||
import FilterScopeModal from 'src/dashboard/components/filterscope/FilterScopeModal';
|
||||
import getDashboardUrl from 'src/dashboard/util/getDashboardUrl';
|
||||
import { getActiveFilters } from 'src/dashboard/util/activeDashboardFilters';
|
||||
import { isEmbedded as isInIframe } from 'src/dashboard/util/isEmbedded';
|
||||
import { getUrlParam } from 'src/utils/urlUtils';
|
||||
import { MenuKeys, RootState } from 'src/dashboard/types';
|
||||
import { HeaderDropdownProps } from 'src/dashboard/components/Header/types';
|
||||
@@ -290,8 +291,13 @@ export const useHeaderActionsMenu = ({
|
||||
});
|
||||
}
|
||||
|
||||
// Toggle fullscreen (hide on mobile)
|
||||
if (!editMode && !isEmbedded && !isMobile) {
|
||||
// Toggle fullscreen (hide on mobile). Hidden entirely inside an iframe: there,
|
||||
// "Exit fullscreen" reloads without the standalone param and brings back the full
|
||||
// Superset nav, breaking the embed. Note `isEmbedded` above is
|
||||
// `!dashboardInfo.userId` (anonymous guest via the embedded SDK), which does not
|
||||
// cover an authenticated user whose dashboard is iframed, hence the separate check.
|
||||
// Top-level users keep both directions so fullscreen is not a one-way door.
|
||||
if (!editMode && !isEmbedded && !isMobile && !isInIframe()) {
|
||||
menuItems.push({
|
||||
key: MenuKeys.ToggleFullscreen,
|
||||
label: getUrlParam(URL_PARAMS.standalone)
|
||||
|
||||
@@ -23,11 +23,15 @@ import { ReactNode } from 'react';
|
||||
import { LOG_ACTIONS_FORCE_REFRESH_DASHBOARD } from 'src/logger/LogUtils';
|
||||
import { useHeaderAutoRefresh } from './useHeaderAutoRefresh';
|
||||
|
||||
const mockStartAutoRefresh = jest.fn();
|
||||
const mockEndAutoRefresh = jest.fn();
|
||||
const mockSetRefreshInFlight = jest.fn();
|
||||
|
||||
jest.mock('src/dashboard/contexts/AutoRefreshContext', () => ({
|
||||
useAutoRefreshContext: () => ({
|
||||
startAutoRefresh: jest.fn(),
|
||||
endAutoRefresh: jest.fn(),
|
||||
setRefreshInFlight: jest.fn(),
|
||||
startAutoRefresh: mockStartAutoRefresh,
|
||||
endAutoRefresh: mockEndAutoRefresh,
|
||||
setRefreshInFlight: mockSetRefreshInFlight,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -45,8 +49,10 @@ jest.mock('src/dashboard/hooks/useRealTimeDashboard', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockUseAutoRefreshTabPause = jest.fn();
|
||||
jest.mock('src/dashboard/hooks/useAutoRefreshTabPause', () => ({
|
||||
useAutoRefreshTabPause: jest.fn(),
|
||||
useAutoRefreshTabPause: (...args: unknown[]) =>
|
||||
mockUseAutoRefreshTabPause(...args),
|
||||
}));
|
||||
|
||||
const createWrapper = (conf: Record<string, unknown> = {}) => {
|
||||
@@ -54,6 +60,8 @@ const createWrapper = (conf: Record<string, unknown> = {}) => {
|
||||
charts: {
|
||||
1: { latestQueryFormData: { datasource: '1__table' } },
|
||||
2: { latestQueryFormData: { datasource: '2__table' } },
|
||||
// A chart on a tab that has never been visited has no query data yet.
|
||||
3: { latestQueryFormData: {} },
|
||||
},
|
||||
dashboardInfo: {
|
||||
common: { conf },
|
||||
@@ -152,3 +160,45 @@ test('forceRefresh normalizes a negative config value to 0 (unstaggered)', async
|
||||
expect.objectContaining({ interval: 0 }),
|
||||
);
|
||||
});
|
||||
|
||||
test('a silent refresh reports only the affected chart ids to startAutoRefresh, not the whole dashboard', async () => {
|
||||
mockStartAutoRefresh.mockClear();
|
||||
mockUseAutoRefreshTabPause.mockClear();
|
||||
const { props } = renderHeaderAutoRefresh(
|
||||
{},
|
||||
{ chartIds: [1, 2], timedRefreshImmuneSlices: [2] },
|
||||
);
|
||||
|
||||
const { onRefresh: handleTabVisibilityRefresh } =
|
||||
mockUseAutoRefreshTabPause.mock.calls[0][0];
|
||||
|
||||
await act(async () => {
|
||||
await handleTabVisibilityRefresh();
|
||||
});
|
||||
|
||||
expect(props.onRefresh).toHaveBeenCalledTimes(1);
|
||||
expect(mockStartAutoRefresh).toHaveBeenCalledWith([1]);
|
||||
expect(mockStartAutoRefresh).not.toHaveBeenCalledWith([1, 2]);
|
||||
});
|
||||
|
||||
test('a silent refresh excludes charts with no previous query data from both startAutoRefresh and onRefresh', async () => {
|
||||
mockStartAutoRefresh.mockClear();
|
||||
mockUseAutoRefreshTabPause.mockClear();
|
||||
const { props } = renderHeaderAutoRefresh(
|
||||
{},
|
||||
{ chartIds: [1, 2, 3], timedRefreshImmuneSlices: [2] },
|
||||
);
|
||||
|
||||
const { onRefresh: handleTabVisibilityRefresh } =
|
||||
mockUseAutoRefreshTabPause.mock.calls[0][0];
|
||||
|
||||
await act(async () => {
|
||||
await handleTabVisibilityRefresh();
|
||||
});
|
||||
|
||||
expect(mockStartAutoRefresh).toHaveBeenCalledTimes(1);
|
||||
expect(mockStartAutoRefresh).toHaveBeenCalledWith([1]);
|
||||
expect(props.onRefresh).toHaveBeenCalledTimes(1);
|
||||
const [refreshedChartIds] = props.onRefresh.mock.calls[0];
|
||||
expect(refreshedChartIds).toEqual([1]);
|
||||
});
|
||||
|
||||
@@ -158,7 +158,7 @@ export const useHeaderAutoRefresh = ({
|
||||
}
|
||||
|
||||
if (suppressSpinners) {
|
||||
startAutoRefresh();
|
||||
startAutoRefresh(chartsToRefresh);
|
||||
setStatus(AutoRefreshStatus.Fetching);
|
||||
setFetchStartTime(Date.now());
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import * as ColorSchemeSelect from 'src/dashboard/components/ColorSchemeSelect';
|
||||
import * as SupersetCore from '@superset-ui/core';
|
||||
import { isFeatureEnabled, FeatureFlag } from '@superset-ui/core';
|
||||
import PropertiesModal from '.';
|
||||
import * as dashboardInfoActions from 'src/dashboard/actions/dashboardInfo';
|
||||
|
||||
// Increase timeout for CI environment
|
||||
jest.setTimeout(60000);
|
||||
@@ -529,6 +530,7 @@ describe('PropertiesModal', () => {
|
||||
});
|
||||
|
||||
test('preserves certification fields on save without opening Certification section', async () => {
|
||||
const saved = jest.spyOn(dashboardInfoActions, 'dashboardSaveSucceeded');
|
||||
// Accordion Collapse only mounts the active panel, so certifiedBy /
|
||||
// certificationDetails FormItems are unregistered until Certification is
|
||||
// opened. onFinish must use getFieldsValue(true) to read store values for
|
||||
@@ -571,6 +573,8 @@ describe('PropertiesModal', () => {
|
||||
expect(submitCall.certifiedBy).toBe('John Doe');
|
||||
expect(submitCall.certificationDetails).toBe('Sample certification');
|
||||
|
||||
expect(saved).toHaveBeenCalledTimes(1);
|
||||
expect(saved).toHaveBeenCalledWith(props.dashboardId);
|
||||
expect(put).toHaveBeenCalled();
|
||||
const putRequest = put.mock.calls[0][0];
|
||||
expect(typeof putRequest.body).toBe('string');
|
||||
@@ -580,6 +584,7 @@ describe('PropertiesModal', () => {
|
||||
});
|
||||
|
||||
test('submitting with onlyApply:true', async () => {
|
||||
const saved = jest.spyOn(dashboardInfoActions, 'dashboardSaveSucceeded');
|
||||
mockedIsFeatureEnabled.mockReturnValue(false);
|
||||
const props = createProps();
|
||||
props.onlyApply = true;
|
||||
@@ -607,6 +612,7 @@ describe('PropertiesModal', () => {
|
||||
await waitFor(() => {
|
||||
expect(props.onSubmit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(saved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('passes full theme object with json_data to onSubmit when theme is selected', async () => {
|
||||
|
||||
@@ -55,7 +55,10 @@ import {
|
||||
setColorScheme,
|
||||
setDashboardMetadata,
|
||||
} from 'src/dashboard/actions/dashboardState';
|
||||
import { dashboardInfoChanged } from 'src/dashboard/actions/dashboardInfo';
|
||||
import {
|
||||
dashboardInfoChanged,
|
||||
dashboardSaveSucceeded,
|
||||
} from 'src/dashboard/actions/dashboardInfo';
|
||||
import { areObjectsEqual } from 'src/reduxUtils';
|
||||
import { AsyncModeOverride } from 'src/utils/asyncMode';
|
||||
import { StandardModal, useModalValidation } from 'src/components/Modal';
|
||||
@@ -477,6 +480,7 @@ const PropertiesModal = ({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(saveData),
|
||||
}).then(() => {
|
||||
dispatch(dashboardSaveSucceeded(dashboardId));
|
||||
onSubmit(onSubmitProps);
|
||||
onHide();
|
||||
addSuccessToast(t('The dashboard has been saved'));
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect } from 'react';
|
||||
import { act, fireEvent, render } from 'spec/helpers/testing-library';
|
||||
import { FeatureFlag, VizType } from '@superset-ui/core';
|
||||
import * as redux from 'redux';
|
||||
@@ -27,6 +28,10 @@ import mockDatasource from 'spec/fixtures/mockDatasource';
|
||||
import chartQueries, {
|
||||
sliceId as queryId,
|
||||
} from 'spec/fixtures/mockChartQueries';
|
||||
import {
|
||||
AutoRefreshProvider,
|
||||
useAutoRefreshContext,
|
||||
} from 'src/dashboard/contexts/AutoRefreshContext';
|
||||
import Chart from './Chart';
|
||||
|
||||
let capturedChartContainerProps: Record<string, unknown> = {};
|
||||
@@ -109,6 +114,32 @@ function setup(
|
||||
});
|
||||
}
|
||||
|
||||
function StartAutoRefreshFor({ chartIds }: { chartIds: number[] }) {
|
||||
const { startAutoRefresh } = useAutoRefreshContext();
|
||||
useEffect(() => {
|
||||
startAutoRefresh(chartIds);
|
||||
}, [chartIds, startAutoRefresh]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function setupDuringUnrelatedAutoRefresh(
|
||||
refreshingChartIds: number[],
|
||||
overrideProps: Record<string, unknown> = {},
|
||||
overrideState: Record<string, unknown> = {},
|
||||
) {
|
||||
return render(
|
||||
<AutoRefreshProvider>
|
||||
<StartAutoRefreshFor chartIds={refreshingChartIds} />
|
||||
<Chart {...props} {...overrideProps} />
|
||||
</AutoRefreshProvider>,
|
||||
{
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: { ...defaultState, ...overrideState },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const refreshChart = jest.fn();
|
||||
const logEvent = jest.fn();
|
||||
const changeFilter = jest.fn();
|
||||
@@ -134,6 +165,34 @@ afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('shows the loading spinner for a chart that starts loading outside the in-flight auto-refresh batch', () => {
|
||||
setupDuringUnrelatedAutoRefresh([queryId + 1], undefined, {
|
||||
charts: {
|
||||
...defaultState.charts,
|
||||
[queryId]: {
|
||||
...defaultState.charts[queryId],
|
||||
chartStatus: 'loading',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedChartContainerProps.suppressLoadingSpinner).toBe(false);
|
||||
});
|
||||
|
||||
test('suppresses the loading spinner for a chart included in the in-flight auto-refresh batch', () => {
|
||||
setupDuringUnrelatedAutoRefresh([queryId], undefined, {
|
||||
charts: {
|
||||
...defaultState.charts,
|
||||
[queryId]: {
|
||||
...defaultState.charts[queryId],
|
||||
chartStatus: 'loading',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedChartContainerProps.suppressLoadingSpinner).toBe(true);
|
||||
});
|
||||
|
||||
test('should render a SliceHeader', () => {
|
||||
const { getByTestId, container } = setup();
|
||||
expect(getByTestId('slice-header')).toBeInTheDocument();
|
||||
|
||||
@@ -57,7 +57,7 @@ import {
|
||||
convertChartStateToOwnState,
|
||||
hasChartStateConverter,
|
||||
} from '../../../util/chartStateConverter';
|
||||
import { useIsAutoRefreshing } from 'src/dashboard/contexts/AutoRefreshContext';
|
||||
import { useIsChartAutoRefreshing } from 'src/dashboard/contexts/AutoRefreshContext';
|
||||
|
||||
import SliceHeader from '../../SliceHeader';
|
||||
import MissingChart from '../../MissingChart';
|
||||
@@ -254,7 +254,7 @@ const Chart = (props: ChartProps) => {
|
||||
(state.dashboardInfo?.metadata as JsonObject)?.show_chart_timestamps ??
|
||||
false,
|
||||
);
|
||||
const suppressLoadingSpinner = useIsAutoRefreshing();
|
||||
const suppressLoadingSpinner = useIsChartAutoRefreshing(props.id);
|
||||
|
||||
const isCached: boolean[] = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -65,11 +65,20 @@ jest.mock('src/dashboard/components/dnd/DragDroppable', () => ({
|
||||
Droppable: ({
|
||||
children,
|
||||
depth,
|
||||
style,
|
||||
className,
|
||||
}: {
|
||||
children: (args: object) => React.ReactNode;
|
||||
depth: number;
|
||||
style?: React.CSSProperties;
|
||||
className?: string;
|
||||
}) => (
|
||||
<div data-test="mock-droppable" data-depth={depth}>
|
||||
<div
|
||||
data-test="mock-droppable"
|
||||
data-depth={depth}
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
{children({})}
|
||||
</div>
|
||||
),
|
||||
@@ -269,6 +278,38 @@ test('should increment the depth of its children', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('row droptargets size via CSS instead of a measured pixel height that can only grow (regression for #37644)', () => {
|
||||
const { container } = setup({ editMode: true });
|
||||
const getDroptargetHeights = () =>
|
||||
Array.from(
|
||||
container.querySelectorAll<HTMLElement>('.empty-droptarget--vertical'),
|
||||
).map(el => el.style.height);
|
||||
|
||||
// The leading droptarget (index 0) is absolutely positioned, so a
|
||||
// percentage height resolves fine and stretches it to the row via CSS
|
||||
// rather than a pixel value measured from the row's tallest chart -- it
|
||||
// can never be left pinned to a chart's prior (larger) height after a
|
||||
// resize. The droptarget after the chart is an in-flow flex item under
|
||||
// GridRow's indefinite `height: fit-content`, where a percentage height
|
||||
// resolves to `auto`/is ignored per the flexbox spec (defeating the
|
||||
// `align-self: stretch` CSS already declares for it) -- it gets an
|
||||
// explicit `auto` instead, letting that stretch actually apply.
|
||||
expect(getDroptargetHeights()).toEqual(['100%', 'auto']);
|
||||
});
|
||||
|
||||
test('trailing droptarget also gets a CSS-driven height when the row is full (regression for #37644)', () => {
|
||||
const { container } = setup({ editMode: true, occupiedColumnCount: 12 });
|
||||
const getDroptargetHeights = () =>
|
||||
Array.from(
|
||||
container.querySelectorAll<HTMLElement>('.empty-droptarget--vertical'),
|
||||
).map(el => el.style.height);
|
||||
|
||||
// With no remaining columns, the droptarget after the last chart is
|
||||
// treated as a side target too (isTrailingSideTarget), so it takes the
|
||||
// same '100%' CSS height as the leading one instead of 'auto'.
|
||||
expect(getDroptargetHeights()).toEqual(['100%', '100%']);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('visibility handling for intersection observers', () => {
|
||||
const mockIntersectionObserver = jest.fn();
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
useCallback,
|
||||
useRef,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
memo,
|
||||
RefObject,
|
||||
@@ -102,6 +101,7 @@ const GridRow = styled.div<{ editMode: boolean }>`
|
||||
align-self: center;
|
||||
&.empty-droptarget--vertical {
|
||||
min-width: ${theme.sizeUnit * 4}px;
|
||||
align-self: stretch;
|
||||
&:not(:last-child) {
|
||||
width: ${theme.sizeUnit * 4}px;
|
||||
}
|
||||
@@ -183,7 +183,6 @@ const Row = memo((props: RowProps) => {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [isInView, setIsInView] = useState(false);
|
||||
const [hoverMenuHovered, setHoverMenuHovered] = useState(false);
|
||||
const [containerHeight, setContainerHeight] = useState<number | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const isComponentVisibleRef = useRef(isComponentVisible);
|
||||
|
||||
@@ -284,14 +283,6 @@ const Row = memo((props: RowProps) => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!editMode) return;
|
||||
const updatedHeight = containerRef.current?.clientHeight;
|
||||
if (updatedHeight !== undefined && updatedHeight !== containerHeight) {
|
||||
setContainerHeight(updatedHeight);
|
||||
}
|
||||
});
|
||||
|
||||
const handleChangeFocus = useCallback((nextFocus: boolean) => {
|
||||
setIsFocused(Boolean(nextFocus));
|
||||
}, []);
|
||||
@@ -403,7 +394,7 @@ const Row = memo((props: RowProps) => {
|
||||
)}
|
||||
editMode
|
||||
style={{
|
||||
height: rowItems.length > 0 ? containerHeight : '100%',
|
||||
height: '100%',
|
||||
...(rowItems.length > 0 && { width: 16 }),
|
||||
}}
|
||||
>
|
||||
@@ -416,54 +407,68 @@ const Row = memo((props: RowProps) => {
|
||||
<div css={emptyRowContentStyles as any}>{t('Empty row')}</div>
|
||||
)}
|
||||
{rowItems.length > 0 &&
|
||||
rowItems.map((componentId, itemIndex) => (
|
||||
<Fragment key={componentId}>
|
||||
<DashboardComponent
|
||||
key={componentId}
|
||||
id={componentId}
|
||||
parentId={rowComponent.id as string}
|
||||
depth={depth + 1}
|
||||
index={itemIndex}
|
||||
availableColumnCount={remainColumnCount}
|
||||
columnWidth={columnWidth}
|
||||
onResizeStart={onResizeStart}
|
||||
onResize={onResize}
|
||||
onResizeStop={onResizeStop}
|
||||
isComponentVisible={isComponentVisible}
|
||||
onChangeTab={onChangeTab}
|
||||
isInView={isInView}
|
||||
/>
|
||||
{editMode && (
|
||||
<Droppable
|
||||
component={rowItems}
|
||||
parentComponent={rowComponent}
|
||||
depth={depth}
|
||||
index={itemIndex + 1}
|
||||
orientation="row"
|
||||
onDrop={handleComponentDrop}
|
||||
className={cx(
|
||||
'empty-droptarget',
|
||||
'empty-droptarget--vertical',
|
||||
remainColumnCount === 0 &&
|
||||
itemIndex === rowItems.length - 1 &&
|
||||
'droptarget-side',
|
||||
)}
|
||||
editMode
|
||||
style={{
|
||||
height: containerHeight,
|
||||
...(remainColumnCount === 0 &&
|
||||
itemIndex === rowItems.length - 1 && { width: 16 }),
|
||||
}}
|
||||
>
|
||||
{({
|
||||
dropIndicatorProps,
|
||||
}: {
|
||||
dropIndicatorProps: JsonObject;
|
||||
}) => dropIndicatorProps && <div {...dropIndicatorProps} />}
|
||||
</Droppable>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
rowItems.map((componentId, itemIndex) => {
|
||||
const isTrailingSideTarget =
|
||||
remainColumnCount === 0 && itemIndex === rowItems.length - 1;
|
||||
return (
|
||||
<Fragment key={componentId}>
|
||||
<DashboardComponent
|
||||
key={componentId}
|
||||
id={componentId}
|
||||
parentId={rowComponent.id as string}
|
||||
depth={depth + 1}
|
||||
index={itemIndex}
|
||||
availableColumnCount={remainColumnCount}
|
||||
columnWidth={columnWidth}
|
||||
onResizeStart={onResizeStart}
|
||||
onResize={onResize}
|
||||
onResizeStop={onResizeStop}
|
||||
isComponentVisible={isComponentVisible}
|
||||
onChangeTab={onChangeTab}
|
||||
isInView={isInView}
|
||||
/>
|
||||
{editMode && (
|
||||
<Droppable
|
||||
component={rowItems}
|
||||
parentComponent={rowComponent}
|
||||
depth={depth}
|
||||
index={itemIndex + 1}
|
||||
orientation="row"
|
||||
onDrop={handleComponentDrop}
|
||||
className={cx(
|
||||
'empty-droptarget',
|
||||
'empty-droptarget--vertical',
|
||||
isTrailingSideTarget && 'droptarget-side',
|
||||
)}
|
||||
editMode
|
||||
style={{
|
||||
// Only the last target in a full row (the absolutely
|
||||
// positioned "side" target) actually resolves a
|
||||
// percentage height -- for every other, in-flow
|
||||
// target, GridRow's height is `fit-content`
|
||||
// (indefinite), and a percentage height on a flex
|
||||
// item under an indefinite-height parent resolves to
|
||||
// `auto`/is ignored per the CSS flexbox spec, which
|
||||
// defeats the `align-self: stretch` the CSS above
|
||||
// already declares for it. `height: 'auto'` lets that
|
||||
// stretch actually apply instead of collapsing the
|
||||
// target to its content size.
|
||||
height: isTrailingSideTarget ? '100%' : 'auto',
|
||||
...(isTrailingSideTarget && { width: 16 }),
|
||||
}}
|
||||
>
|
||||
{({
|
||||
dropIndicatorProps,
|
||||
}: {
|
||||
dropIndicatorProps: JsonObject;
|
||||
}) =>
|
||||
dropIndicatorProps && <div {...dropIndicatorProps} />
|
||||
}
|
||||
</Droppable>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</GridRow>
|
||||
</WithPopoverMenu>
|
||||
),
|
||||
@@ -471,7 +476,6 @@ const Row = memo((props: RowProps) => {
|
||||
backgroundStyle.className,
|
||||
backgroundStyle.value,
|
||||
columnWidth,
|
||||
containerHeight,
|
||||
depth,
|
||||
editMode,
|
||||
handleChangeBackground,
|
||||
|
||||
@@ -265,6 +265,101 @@ test('Switching tabs', async () => {
|
||||
expect(props.onChangeTab).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each([false, true])(
|
||||
'A childless TABS component does not register an active tab (editMode=%s)',
|
||||
editMode => {
|
||||
// An unresolved id reaches activeTabs as `undefined`, which
|
||||
// `JSON.stringify` coerces to `null` in the permalink request body.
|
||||
const props = createProps();
|
||||
props.editMode = editMode;
|
||||
props.component.children = [];
|
||||
|
||||
render(<Tabs {...props} />, {
|
||||
useRedux: true,
|
||||
useDnd: true,
|
||||
});
|
||||
|
||||
expect(props.setActiveTab).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
test.each([false, true])(
|
||||
'The first child added to an empty TABS component is activated (editMode=%s)',
|
||||
editMode => {
|
||||
const props = createProps();
|
||||
const [tabId] = props.component.children;
|
||||
props.editMode = editMode;
|
||||
props.component.children = [];
|
||||
const { rerender } = render(<Tabs {...props} />, {
|
||||
useRedux: true,
|
||||
useDnd: true,
|
||||
});
|
||||
|
||||
expect(props.setActiveTab).not.toHaveBeenCalled();
|
||||
rerender(
|
||||
<Tabs {...props} component={{ ...props.component, children: [tabId] }} />,
|
||||
);
|
||||
|
||||
expect(props.setActiveTab.mock.calls).toEqual([[tabId]]);
|
||||
expect(screen.getByRole('tab')).toHaveAttribute('aria-selected', 'true');
|
||||
},
|
||||
);
|
||||
|
||||
test('A tab added after deleting the last tab is selected and registered', async () => {
|
||||
const props = createProps();
|
||||
const [deletedTabId, newTabId] = props.component.children;
|
||||
props.component.children = [deletedTabId];
|
||||
const { rerender } = render(<Tabs {...props} />, {
|
||||
useRedux: true,
|
||||
useDnd: true,
|
||||
});
|
||||
|
||||
expect(props.setActiveTab.mock.calls).toEqual([[deletedTabId]]);
|
||||
expect(screen.getByRole('tab')).toHaveAttribute('aria-selected', 'true');
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'remove' }));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Delete' }));
|
||||
expect(props.deleteComponent).toHaveBeenCalledWith(
|
||||
deletedTabId,
|
||||
props.component.id,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<Tabs {...props} component={{ ...props.component, children: [] }} />,
|
||||
);
|
||||
expect(screen.queryByRole('tab')).not.toBeInTheDocument();
|
||||
props.setActiveTab.mockClear();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Add tab' }));
|
||||
expect(props.createComponent).toHaveBeenCalled();
|
||||
expect(props.setActiveTab).not.toHaveBeenCalled();
|
||||
|
||||
rerender(
|
||||
<Tabs
|
||||
{...props}
|
||||
component={{ ...props.component, children: [newTabId] }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(props.setActiveTab.mock.calls).toEqual([[newTabId, deletedTabId]]);
|
||||
expect(screen.getByRole('tab')).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
test.each([false, true])(
|
||||
'A populated TABS component registers its active tab (editMode=%s)',
|
||||
editMode => {
|
||||
const props = createProps();
|
||||
props.editMode = editMode;
|
||||
|
||||
render(<Tabs {...props} />, {
|
||||
useRedux: true,
|
||||
useDnd: true,
|
||||
});
|
||||
|
||||
expect(props.setActiveTab.mock.calls).toEqual([['TAB-AsMaxdYL_t']]);
|
||||
},
|
||||
);
|
||||
|
||||
test('activeTabs hydrated from a permalink selects the matching tab content', () => {
|
||||
// Regression guard for #36132: when a dashboard is opened via a
|
||||
// permalink/anchor (including embedded dashboards), the permalink state is
|
||||
|
||||
@@ -153,7 +153,7 @@ const Tabs = (props: TabsProps): ReactElement => {
|
||||
};
|
||||
}, [activeTabs, props.component, directPathToChild]);
|
||||
|
||||
const [activeKey, setActiveKey] = useState<string>(initActiveKey);
|
||||
const [activeKey, setActiveKey] = useState<string | undefined>(initActiveKey);
|
||||
const [selectedTabIndex, setSelectedTabIndex] =
|
||||
useState<number>(initTabIndex);
|
||||
const [dropPosition, setDropPosition] = useState<string | null>(null);
|
||||
@@ -166,6 +166,26 @@ const Tabs = (props: TabsProps): ReactElement => {
|
||||
const prevTabIds = usePrevious(props.component.children);
|
||||
|
||||
useEffect(() => {
|
||||
// Resolve missing or deleted active keys when children become available
|
||||
// so a tab added to an empty container is selected and registered.
|
||||
const tabId = props.component.children[selectedTabIndex];
|
||||
if (
|
||||
tabId &&
|
||||
(!activeKey || !props.component.children.includes(activeKey))
|
||||
) {
|
||||
setActiveKey(tabId);
|
||||
}
|
||||
}, [activeKey, props.component.children, selectedTabIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
// A TABS component with no children resolves no tab id, so there is
|
||||
// nothing to activate. Dispatching the unresolved id would register an
|
||||
// `undefined` entry in dashboardState.activeTabs, which JSON.stringify
|
||||
// coerces to `null` when the dashboard state is posted to the permalink
|
||||
// endpoint.
|
||||
if (!activeKey) {
|
||||
return;
|
||||
}
|
||||
if (prevActiveKey) {
|
||||
props.setActiveTab(activeKey, prevActiveKey);
|
||||
} else {
|
||||
|
||||
@@ -184,7 +184,7 @@ export interface TabsRendererProps {
|
||||
tabsDragSourceRef?: RefObject<HTMLDivElement>;
|
||||
handleDeleteComponent: () => void;
|
||||
tabsComponent: TabsComponent;
|
||||
activeKey: string;
|
||||
activeKey: string | undefined;
|
||||
tabIds: string[];
|
||||
handleClickTab: (index: number) => void;
|
||||
handleEdit: AntdTabsProps['onEdit'];
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
* under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import {
|
||||
act,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
@@ -34,6 +36,7 @@ import { useDownloadMenuItems } from '.';
|
||||
|
||||
const mockAddSuccessToast = jest.fn();
|
||||
const mockAddDangerToast = jest.fn();
|
||||
const mockAddInfoToast = jest.fn();
|
||||
|
||||
jest.mock('src/components/MessageToasts/withToasts', () => ({
|
||||
__esModule: true,
|
||||
@@ -41,6 +44,7 @@ jest.mock('src/components/MessageToasts/withToasts', () => ({
|
||||
useToasts: () => ({
|
||||
addSuccessToast: mockAddSuccessToast,
|
||||
addDangerToast: mockAddDangerToast,
|
||||
addInfoToast: mockAddInfoToast,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -68,6 +72,11 @@ const createProps = () => ({
|
||||
userCanExport: true,
|
||||
});
|
||||
|
||||
// The default test store has an empty user; most tests exercise a logged-in
|
||||
// session with an email on file.
|
||||
const loggedInState = { user: { userId: 1, email: 'admin@example.com' } };
|
||||
const guestState = { user: {} };
|
||||
|
||||
const MenuWrapper = () => {
|
||||
const downloadMenuItem = useDownloadMenuItems(createProps());
|
||||
const menuItems: MenuItem[] = [downloadMenuItem];
|
||||
@@ -87,14 +96,46 @@ const MenuWrapperWithProps = (
|
||||
return <Menu forceSubMenuRender items={menuItems} />;
|
||||
};
|
||||
|
||||
// jsdom cannot follow an <a download> click, so record what was clicked.
|
||||
let clickedDownloads: { href: string | null; download: string | null }[] = [];
|
||||
let anchorClickSpy: jest.SpyInstance;
|
||||
const lastDownloadHref = () =>
|
||||
clickedDownloads.length
|
||||
? clickedDownloads[clickedDownloads.length - 1].href
|
||||
: null;
|
||||
|
||||
// forceSubMenuRender keeps the items mounted while the submenu is visually
|
||||
// closed, so they sit under `pointer-events: none`; and user-event's own delay
|
||||
// is a setTimeout that never fires once a test installs fake timers.
|
||||
const clickMenuItem = (label: string) =>
|
||||
userEvent.click(screen.getByText(label), {
|
||||
pointerEventsCheck: 0,
|
||||
delay: null,
|
||||
});
|
||||
|
||||
const originalCreateObjectURL = window.URL.createObjectURL;
|
||||
const originalRevokeObjectURL = window.URL.revokeObjectURL;
|
||||
const originalLocation = window.location;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// The ready link is confirmed with HEAD before the browser is sent to it.
|
||||
fetchMock.head('glob:*/api/v1/dashboard/export_xlsx/download/*', 200);
|
||||
clickedDownloads = [];
|
||||
anchorClickSpy = jest
|
||||
.spyOn(HTMLAnchorElement.prototype, 'click')
|
||||
.mockImplementation(function recordClick(this: HTMLAnchorElement) {
|
||||
clickedDownloads.push({
|
||||
href: this.getAttribute('href'),
|
||||
download: this.getAttribute('download'),
|
||||
});
|
||||
});
|
||||
// Reset the implementation each test: clearAllMocks resets call history but
|
||||
// not mockReturnValue, so an override in one test would otherwise leak.
|
||||
(isFeatureEnabled as jest.Mock).mockReturnValue(false);
|
||||
// @ts-ignore
|
||||
delete window.location;
|
||||
window.location = { href: '' } as Location;
|
||||
});
|
||||
|
||||
// "Export Images to Excel" is gated on the webdriver screenshot feature flags.
|
||||
@@ -102,14 +143,20 @@ const enableWebDriverScreenshot = () =>
|
||||
(isFeatureEnabled as jest.Mock).mockReturnValue(true);
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.removeRoutes();
|
||||
fetchMock.clearHistory();
|
||||
anchorClickSpy.mockRestore();
|
||||
window.URL.createObjectURL = originalCreateObjectURL;
|
||||
window.URL.revokeObjectURL = originalRevokeObjectURL;
|
||||
window.location = originalLocation;
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('Should render all menu items', () => {
|
||||
enableWebDriverScreenshot();
|
||||
render(<MenuWrapper />, {
|
||||
useRedux: true,
|
||||
initialState: loggedInState,
|
||||
});
|
||||
|
||||
// Screenshot options
|
||||
@@ -126,42 +173,50 @@ test('Should render all menu items', () => {
|
||||
test('Export Images to Excel is hidden when the webdriver is not enabled', () => {
|
||||
// Default: webdriver screenshot flags off. Image export needs the webdriver,
|
||||
// so only the data export is offered.
|
||||
render(<MenuWrapper />, { useRedux: true });
|
||||
render(<MenuWrapper />, { useRedux: true, initialState: loggedInState });
|
||||
|
||||
expect(screen.getByText('Export Data to Excel')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Export Images to Excel')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Excel export items are hidden when userCanExport is false', () => {
|
||||
render(<MenuWrapperWithProps userCanExport={false} />, { useRedux: true });
|
||||
render(<MenuWrapperWithProps userCanExport={false} />, {
|
||||
useRedux: true,
|
||||
initialState: loggedInState,
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Export Data to Excel')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Export Images to Excel')).not.toBeInTheDocument();
|
||||
// YAML export is not gated and remains visible
|
||||
// YAML export is not gated on userCanExport and remains visible
|
||||
expect(screen.getByText('Export YAML')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('sessions without a user id get the data export but no bundle exports', () => {
|
||||
// The API refuses bundle exports for them, so offering them only yields errors.
|
||||
render(<MenuWrapper />, { useRedux: true, initialState: guestState });
|
||||
|
||||
expect(screen.getByText('Export Data to Excel')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Export YAML')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Export as Example')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Export Data to Excel posts mode "data" and shows a pending toast', async () => {
|
||||
mockSupersetClient.post.mockResolvedValue({
|
||||
json: { job_id: 'abc' },
|
||||
} as never);
|
||||
|
||||
render(<MenuWrapper />, { useRedux: true });
|
||||
render(<MenuWrapper />, { useRedux: true, initialState: loggedInState });
|
||||
|
||||
// forceSubMenuRender keeps the submenu item in the DOM for querying
|
||||
// even while visually closed (pointer-events: none); bypass user-event's
|
||||
// pointer-events check rather than simulating the hover-to-open flow.
|
||||
await userEvent.click(screen.getByText('Export Data to Excel'), {
|
||||
pointerEventsCheck: 0,
|
||||
});
|
||||
await clickMenuItem('Export Data to Excel');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSupersetClient.post).toHaveBeenCalledWith({
|
||||
endpoint: '/api/v1/dashboard/123/export_xlsx/',
|
||||
jsonPayload: { active_data_mask: {}, mode: 'data' },
|
||||
});
|
||||
expect(mockAddSuccessToast).toHaveBeenCalledWith(
|
||||
"Your export is being prepared. You'll receive an email when it's ready.",
|
||||
expect(mockAddInfoToast).toHaveBeenCalledWith(
|
||||
"Your export is being generated and will download automatically when ready. We'll also email you a download link.",
|
||||
{ noDuplicate: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -172,24 +227,174 @@ test('Export Images to Excel posts mode "images" and shows a pending toast', asy
|
||||
json: { job_id: 'abc' },
|
||||
} as never);
|
||||
|
||||
render(<MenuWrapper />, { useRedux: true });
|
||||
render(<MenuWrapper />, { useRedux: true, initialState: loggedInState });
|
||||
|
||||
// See forceSubMenuRender note above.
|
||||
await userEvent.click(screen.getByText('Export Images to Excel'), {
|
||||
pointerEventsCheck: 0,
|
||||
});
|
||||
await clickMenuItem('Export Images to Excel');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSupersetClient.post).toHaveBeenCalledWith({
|
||||
endpoint: '/api/v1/dashboard/123/export_xlsx/',
|
||||
jsonPayload: { active_data_mask: {}, mode: 'images' },
|
||||
});
|
||||
expect(mockAddSuccessToast).toHaveBeenCalledWith(
|
||||
"Your export is being prepared. You'll receive an email when it's ready.",
|
||||
expect(mockAddInfoToast).toHaveBeenCalledWith(
|
||||
"Your export is being generated and will download automatically when ready. We'll also email you a download link.",
|
||||
{ noDuplicate: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('Export Data to Excel polls status and auto-downloads once ready', async () => {
|
||||
// A guest/embedded session has no email to be notified at, so completion is
|
||||
// discovered by polling export_xlsx/status/<job_id>/ instead -- exercised
|
||||
// here regardless of session type, since the same polling drives the
|
||||
// auto-download for a regular session too.
|
||||
jest.useFakeTimers();
|
||||
mockSupersetClient.post.mockResolvedValue({
|
||||
json: { job_id: 'abc' },
|
||||
} as never);
|
||||
mockSupersetClient.get.mockResolvedValueOnce({
|
||||
json: {
|
||||
status: 'ready',
|
||||
download_url: '/api/v1/dashboard/export_xlsx/download/abc/',
|
||||
},
|
||||
} as never);
|
||||
|
||||
render(<MenuWrapper />, { useRedux: true, initialState: loggedInState });
|
||||
|
||||
await clickMenuItem('Export Data to Excel');
|
||||
await waitFor(() =>
|
||||
expect(mockAddInfoToast).toHaveBeenCalledWith(
|
||||
"Your export is being generated and will download automatically when ready. We'll also email you a download link.",
|
||||
{ noDuplicate: true },
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
});
|
||||
|
||||
// The download is handed to the browser via an anchor (no second
|
||||
// SupersetClient.get, so the whole workbook is never buffered in page memory).
|
||||
await waitFor(() => {
|
||||
expect(mockSupersetClient.get).toHaveBeenCalledWith({
|
||||
endpoint: '/api/v1/dashboard/export_xlsx/status/abc/',
|
||||
});
|
||||
expect(lastDownloadHref()).toBe(
|
||||
'/api/v1/dashboard/export_xlsx/download/abc/',
|
||||
);
|
||||
expect(mockAddSuccessToast).toHaveBeenCalledWith(
|
||||
'Your export is ready and downloading.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('the ready download goes through an anchor and never navigates the page', async () => {
|
||||
jest.useFakeTimers();
|
||||
mockSupersetClient.post.mockResolvedValue({
|
||||
json: { job_id: 'abc' },
|
||||
} as never);
|
||||
mockSupersetClient.get.mockResolvedValueOnce({
|
||||
json: {
|
||||
status: 'ready',
|
||||
download_url: '/api/v1/dashboard/export_xlsx/download/abc/',
|
||||
},
|
||||
} as never);
|
||||
const hrefBefore = window.location.href;
|
||||
|
||||
render(<MenuWrapper />, { useRedux: true, initialState: loggedInState });
|
||||
|
||||
await clickMenuItem('Export Data to Excel');
|
||||
await waitFor(() => expect(mockSupersetClient.post).toHaveBeenCalled());
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
});
|
||||
|
||||
// A plain anchor: no frame (so no frame-src CSP exception), no `download`
|
||||
// attribute (Chrome drops those without user activation), and the dashboard
|
||||
// (or an embedding parent) is never navigated away.
|
||||
await waitFor(() =>
|
||||
expect(lastDownloadHref()).toBe(
|
||||
'/api/v1/dashboard/export_xlsx/download/abc/',
|
||||
),
|
||||
);
|
||||
expect(clickedDownloads[0].download).toBeNull();
|
||||
expect(window.location.href).toBe(hrefBefore);
|
||||
expect(document.body.querySelectorAll('iframe')).toHaveLength(0);
|
||||
expect(
|
||||
fetchMock.callHistory.calls('glob:*/export_xlsx/download/abc/'),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('Export Data to Excel keeps polling while status is pending', async () => {
|
||||
jest.useFakeTimers();
|
||||
mockSupersetClient.post.mockResolvedValue({
|
||||
json: { job_id: 'abc' },
|
||||
} as never);
|
||||
mockSupersetClient.get.mockResolvedValue({
|
||||
json: { status: 'pending' },
|
||||
} as never);
|
||||
|
||||
render(<MenuWrapper />, { useRedux: true, initialState: loggedInState });
|
||||
|
||||
await clickMenuItem('Export Data to Excel');
|
||||
await waitFor(() =>
|
||||
expect(mockAddInfoToast).toHaveBeenCalledWith(
|
||||
"Your export is being generated and will download automatically when ready. We'll also email you a download link.",
|
||||
{ noDuplicate: true },
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
});
|
||||
await waitFor(() => expect(mockSupersetClient.get).toHaveBeenCalledTimes(1));
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
});
|
||||
await waitFor(() => expect(mockSupersetClient.get).toHaveBeenCalledTimes(2));
|
||||
|
||||
// Still pending -- no terminal toast, and the browser never navigated.
|
||||
expect(mockAddDangerToast).not.toHaveBeenCalled();
|
||||
expect(mockAddSuccessToast).not.toHaveBeenCalledWith(
|
||||
'Your export is ready and downloading.',
|
||||
);
|
||||
});
|
||||
|
||||
test('Export Data to Excel shows an error toast when the export job fails', async () => {
|
||||
jest.useFakeTimers();
|
||||
mockSupersetClient.post.mockResolvedValue({
|
||||
json: { job_id: 'abc' },
|
||||
} as never);
|
||||
mockSupersetClient.get.mockResolvedValue({
|
||||
json: { status: 'error', message: 'The export could not be built.' },
|
||||
} as never);
|
||||
|
||||
render(<MenuWrapper />, { useRedux: true, initialState: loggedInState });
|
||||
|
||||
await clickMenuItem('Export Data to Excel');
|
||||
await waitFor(() =>
|
||||
expect(mockAddInfoToast).toHaveBeenCalledWith(
|
||||
"Your export is being generated and will download automatically when ready. We'll also email you a download link.",
|
||||
{ noDuplicate: true },
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddDangerToast).toHaveBeenCalledWith(
|
||||
'The export could not be built.',
|
||||
);
|
||||
});
|
||||
expect(mockAddSuccessToast).not.toHaveBeenCalledWith(
|
||||
'Your export is ready and downloading.',
|
||||
);
|
||||
});
|
||||
|
||||
test('Export Data to Excel shows an "already in progress" toast when throttled', async () => {
|
||||
// The throttle response is 202 with a message but no job_id.
|
||||
mockSupersetClient.post.mockResolvedValue({
|
||||
@@ -198,15 +403,12 @@ test('Export Data to Excel shows an "already in progress" toast when throttled',
|
||||
},
|
||||
} as never);
|
||||
|
||||
render(<MenuWrapper />, { useRedux: true });
|
||||
render(<MenuWrapper />, { useRedux: true, initialState: loggedInState });
|
||||
|
||||
// See forceSubMenuRender note above.
|
||||
await userEvent.click(screen.getByText('Export Data to Excel'), {
|
||||
pointerEventsCheck: 0,
|
||||
});
|
||||
await clickMenuItem('Export Data to Excel');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddSuccessToast).toHaveBeenCalledWith(
|
||||
expect(mockAddInfoToast).toHaveBeenCalledWith(
|
||||
'An export for this dashboard is already in progress.',
|
||||
);
|
||||
});
|
||||
@@ -216,12 +418,9 @@ test('Export Data to Excel shows a config error toast on 501', async () => {
|
||||
mockSupersetClient.post.mockRejectedValue(new Error('not configured'));
|
||||
mockGetClientErrorObject.mockResolvedValue({ status: 501 });
|
||||
|
||||
render(<MenuWrapper />, { useRedux: true });
|
||||
render(<MenuWrapper />, { useRedux: true, initialState: loggedInState });
|
||||
|
||||
// See forceSubMenuRender note above.
|
||||
await userEvent.click(screen.getByText('Export Data to Excel'), {
|
||||
pointerEventsCheck: 0,
|
||||
});
|
||||
await clickMenuItem('Export Data to Excel');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddDangerToast).toHaveBeenCalledWith(
|
||||
@@ -234,12 +433,9 @@ test('Export Data to Excel shows a generic error toast on other failures', async
|
||||
mockSupersetClient.post.mockRejectedValue(new Error('boom'));
|
||||
mockGetClientErrorObject.mockResolvedValue({ status: 500 });
|
||||
|
||||
render(<MenuWrapper />, { useRedux: true });
|
||||
render(<MenuWrapper />, { useRedux: true, initialState: loggedInState });
|
||||
|
||||
// See forceSubMenuRender note above.
|
||||
await userEvent.click(screen.getByText('Export Data to Excel'), {
|
||||
pointerEventsCheck: 0,
|
||||
});
|
||||
await clickMenuItem('Export Data to Excel');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddDangerToast).toHaveBeenCalledWith(
|
||||
@@ -264,12 +460,9 @@ test('Export as Example calls SupersetClient.get with correct endpoint', async (
|
||||
window.URL.createObjectURL = createObjectURL;
|
||||
window.URL.revokeObjectURL = revokeObjectURL;
|
||||
|
||||
render(<MenuWrapper />, { useRedux: true });
|
||||
render(<MenuWrapper />, { useRedux: true, initialState: loggedInState });
|
||||
|
||||
// See forceSubMenuRender note above.
|
||||
await userEvent.click(screen.getByText('Export as Example'), {
|
||||
pointerEventsCheck: 0,
|
||||
});
|
||||
await clickMenuItem('Export as Example');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSupersetClient.get).toHaveBeenCalledWith({
|
||||
@@ -286,12 +479,9 @@ test('Export as Example calls SupersetClient.get with correct endpoint', async (
|
||||
test('Export as Example shows error toast on failure', async () => {
|
||||
mockSupersetClient.get.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
render(<MenuWrapper />, { useRedux: true });
|
||||
render(<MenuWrapper />, { useRedux: true, initialState: loggedInState });
|
||||
|
||||
// See forceSubMenuRender note above.
|
||||
await userEvent.click(screen.getByText('Export as Example'), {
|
||||
pointerEventsCheck: 0,
|
||||
});
|
||||
await clickMenuItem('Export as Example');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddDangerToast).toHaveBeenCalledWith(
|
||||
@@ -391,3 +581,341 @@ test('Enabled screenshot items should not show tooltip icon', () => {
|
||||
|
||||
mockIsFeatureEnabled.mockReset();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Delivery follows the requester identity, not iframe presence: a guest or
|
||||
// anonymous session (no userId) has no email channel, so the toast must not
|
||||
// promise one, and the image export (webdriver-rendered, guests cannot open
|
||||
// Explore) is hidden.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('guest session: export toast promises auto-download, not an email', async () => {
|
||||
mockSupersetClient.post.mockResolvedValue({
|
||||
json: { job_id: 'abc' },
|
||||
} as never);
|
||||
|
||||
render(<MenuWrapper />, { useRedux: true, initialState: guestState });
|
||||
|
||||
await clickMenuItem('Export Data to Excel');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockAddInfoToast).toHaveBeenCalledWith(
|
||||
'Your export is being generated. Please, do not leave the page.',
|
||||
{ noDuplicate: true },
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('guest session: Export Images to Excel is hidden even with the webdriver enabled', () => {
|
||||
enableWebDriverScreenshot();
|
||||
|
||||
render(<MenuWrapper />, { useRedux: true, initialState: guestState });
|
||||
|
||||
expect(screen.getByText('Export Data to Excel')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Export Images to Excel')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('logged-in user without an email gets the delivery-neutral toast', async () => {
|
||||
mockSupersetClient.post.mockResolvedValue({
|
||||
json: { job_id: 'abc' },
|
||||
} as never);
|
||||
|
||||
render(<MenuWrapper />, {
|
||||
useRedux: true,
|
||||
initialState: { user: { userId: 1 } },
|
||||
});
|
||||
|
||||
await clickMenuItem('Export Data to Excel');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockAddInfoToast).toHaveBeenCalledWith(
|
||||
'Your export is being generated. Please, do not leave the page.',
|
||||
{ noDuplicate: true },
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('a "running" status restarts the wait window, so queue delay is not counted', async () => {
|
||||
jest.useFakeTimers();
|
||||
mockSupersetClient.post.mockResolvedValue({
|
||||
json: { job_id: 'abc' },
|
||||
} as never);
|
||||
// Worker picks the job up on the first poll; still running just past the
|
||||
// original 12 minute deadline; done on the poll after that.
|
||||
mockSupersetClient.get
|
||||
.mockResolvedValueOnce({ json: { status: 'running' } } as never)
|
||||
.mockResolvedValueOnce({ json: { status: 'running' } } as never)
|
||||
.mockResolvedValueOnce({
|
||||
json: {
|
||||
status: 'ready',
|
||||
download_url: '/api/v1/dashboard/export_xlsx/download/abc/',
|
||||
},
|
||||
} as never);
|
||||
|
||||
render(<MenuWrapper />, { useRedux: true, initialState: loggedInState });
|
||||
|
||||
await clickMenuItem('Export Data to Excel');
|
||||
await waitFor(() => expect(mockSupersetClient.post).toHaveBeenCalled());
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
});
|
||||
await waitFor(() => expect(mockSupersetClient.get).toHaveBeenCalledTimes(1));
|
||||
|
||||
// t ~= 12m01s: past the enqueue-based deadline, within the restarted one
|
||||
// (running was observed at t=3s). Without the restart this poll would give
|
||||
// up with a danger toast instead of continuing.
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(12 * 60 * 1000 - 2000);
|
||||
});
|
||||
await waitFor(() => expect(mockSupersetClient.get).toHaveBeenCalledTimes(2));
|
||||
expect(mockAddDangerToast).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(lastDownloadHref()).toBe(
|
||||
'/api/v1/dashboard/export_xlsx/download/abc/',
|
||||
);
|
||||
expect(mockAddSuccessToast).toHaveBeenCalledWith(
|
||||
'Your export is ready and downloading.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('a transient poll failure keeps polling and still downloads', async () => {
|
||||
jest.useFakeTimers();
|
||||
mockSupersetClient.post.mockResolvedValue({
|
||||
json: { job_id: 'abc' },
|
||||
} as never);
|
||||
mockSupersetClient.get
|
||||
.mockRejectedValueOnce(new Error('network blip'))
|
||||
.mockResolvedValueOnce({
|
||||
json: {
|
||||
status: 'ready',
|
||||
download_url: '/api/v1/dashboard/export_xlsx/download/abc/',
|
||||
},
|
||||
} as never);
|
||||
|
||||
render(<MenuWrapper />, { useRedux: true, initialState: loggedInState });
|
||||
|
||||
await clickMenuItem('Export Data to Excel');
|
||||
await waitFor(() => expect(mockSupersetClient.post).toHaveBeenCalled());
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
});
|
||||
await waitFor(() => expect(mockSupersetClient.get).toHaveBeenCalledTimes(1));
|
||||
expect(mockAddDangerToast).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(lastDownloadHref()).toBe(
|
||||
'/api/v1/dashboard/export_xlsx/download/abc/',
|
||||
);
|
||||
expect(mockAddSuccessToast).toHaveBeenCalledWith(
|
||||
'Your export is ready and downloading.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('poll failures past the deadline give up with an error toast', async () => {
|
||||
jest.useFakeTimers();
|
||||
mockSupersetClient.post.mockResolvedValue({
|
||||
json: { job_id: 'abc' },
|
||||
} as never);
|
||||
mockSupersetClient.get.mockRejectedValue(new Error('server down'));
|
||||
|
||||
render(<MenuWrapper />, { useRedux: true, initialState: loggedInState });
|
||||
|
||||
await clickMenuItem('Export Data to Excel');
|
||||
await waitFor(() => expect(mockSupersetClient.post).toHaveBeenCalled());
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
});
|
||||
await waitFor(() => expect(mockSupersetClient.get).toHaveBeenCalledTimes(1));
|
||||
|
||||
// Jump past the 12 minute deadline; the next failing poll must give up.
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(13 * 60 * 1000);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockAddDangerToast).toHaveBeenCalledWith(
|
||||
'Sorry, something went wrong. Try again later.',
|
||||
);
|
||||
});
|
||||
expect(mockAddSuccessToast).not.toHaveBeenCalledWith(
|
||||
'Your export is ready and downloading.',
|
||||
);
|
||||
});
|
||||
|
||||
test('a "ready" status with no download_url is an error, not a fake success', async () => {
|
||||
jest.useFakeTimers();
|
||||
mockSupersetClient.post.mockResolvedValue({
|
||||
json: { job_id: 'abc' },
|
||||
} as never);
|
||||
mockSupersetClient.get.mockResolvedValue({
|
||||
json: { status: 'ready' },
|
||||
} as never);
|
||||
|
||||
render(<MenuWrapper />, { useRedux: true, initialState: loggedInState });
|
||||
|
||||
await clickMenuItem('Export Data to Excel');
|
||||
await waitFor(() => expect(mockSupersetClient.post).toHaveBeenCalled());
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddDangerToast).toHaveBeenCalledWith(
|
||||
'Sorry, something went wrong. Try again later.',
|
||||
);
|
||||
});
|
||||
expect(mockAddSuccessToast).not.toHaveBeenCalled();
|
||||
expect(mockAddSuccessToast).not.toHaveBeenCalledWith(
|
||||
'Your export is ready and downloading.',
|
||||
);
|
||||
});
|
||||
|
||||
test('unmounting stops the polling loop', async () => {
|
||||
jest.useFakeTimers();
|
||||
mockSupersetClient.post.mockResolvedValue({
|
||||
json: { job_id: 'abc' },
|
||||
} as never);
|
||||
mockSupersetClient.get.mockResolvedValue({
|
||||
json: { status: 'pending' },
|
||||
} as never);
|
||||
|
||||
const { unmount } = render(<MenuWrapper />, {
|
||||
useRedux: true,
|
||||
initialState: loggedInState,
|
||||
});
|
||||
|
||||
await clickMenuItem('Export Data to Excel');
|
||||
await waitFor(() => expect(mockSupersetClient.post).toHaveBeenCalled());
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
});
|
||||
await waitFor(() => expect(mockSupersetClient.get).toHaveBeenCalledTimes(1));
|
||||
|
||||
unmount();
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(30000);
|
||||
});
|
||||
expect(mockSupersetClient.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('the pending toast is announced once, not re-emitted on every poll', async () => {
|
||||
jest.useFakeTimers();
|
||||
mockSupersetClient.post.mockResolvedValue({
|
||||
json: { job_id: 'abc' },
|
||||
} as never);
|
||||
mockSupersetClient.get.mockResolvedValue({
|
||||
json: { status: 'pending' },
|
||||
} as never);
|
||||
|
||||
render(<MenuWrapper />, { useRedux: true, initialState: loggedInState });
|
||||
|
||||
await clickMenuItem('Export Data to Excel');
|
||||
await waitFor(() => expect(mockAddInfoToast).toHaveBeenCalledTimes(1));
|
||||
|
||||
// Several poll cycles later, no additional pending toast has been emitted:
|
||||
// re-emitting would spawn a fresh role="alert" every poll for a slow export.
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
});
|
||||
}
|
||||
await waitFor(() =>
|
||||
expect(mockSupersetClient.get.mock.calls.length).toBeGreaterThan(3),
|
||||
);
|
||||
expect(mockAddInfoToast).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const READY_URL = '/api/v1/dashboard/export_xlsx/download/abc/';
|
||||
|
||||
test('a link that fails at download time is reported, not navigated to', async () => {
|
||||
// The narrow race where the object vanishes between the status check and
|
||||
// the click: without the HEAD probe the page would navigate to a 410.
|
||||
jest.useFakeTimers();
|
||||
fetchMock.removeRoutes();
|
||||
fetchMock.head('glob:*/api/v1/dashboard/export_xlsx/download/*', 410);
|
||||
mockSupersetClient.post.mockResolvedValue({
|
||||
json: { job_id: 'abc' },
|
||||
} as never);
|
||||
mockSupersetClient.get.mockResolvedValueOnce({
|
||||
json: { status: 'ready', download_url: READY_URL },
|
||||
} as never);
|
||||
render(<MenuWrapper />, { useRedux: true, initialState: loggedInState });
|
||||
await clickMenuItem('Export Data to Excel');
|
||||
await waitFor(() => expect(mockSupersetClient.post).toHaveBeenCalled());
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockAddDangerToast).toHaveBeenCalledWith(
|
||||
'Sorry, something went wrong. Try again later.',
|
||||
),
|
||||
);
|
||||
expect(clickedDownloads).toHaveLength(0);
|
||||
expect(mockAddSuccessToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('the download anchor is detached again once clicked', async () => {
|
||||
// Nothing is left in document.body to accumulate across repeated exports.
|
||||
jest.useFakeTimers();
|
||||
mockSupersetClient.post.mockResolvedValue({
|
||||
json: { job_id: 'abc' },
|
||||
} as never);
|
||||
mockSupersetClient.get.mockResolvedValueOnce({
|
||||
json: { status: 'ready', download_url: READY_URL },
|
||||
} as never);
|
||||
render(<MenuWrapper />, { useRedux: true, initialState: loggedInState });
|
||||
await clickMenuItem('Export Data to Excel');
|
||||
await waitFor(() => expect(mockSupersetClient.post).toHaveBeenCalled());
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
});
|
||||
await waitFor(() => expect(lastDownloadHref()).toBe(READY_URL));
|
||||
|
||||
expect(document.body.querySelector(`a[href="${READY_URL}"]`)).toBeNull();
|
||||
});
|
||||
|
||||
test('unmounting while the export POST is in flight suppresses its follow-up', async () => {
|
||||
// Cleanup has already run by the time the POST settles, so a toast would
|
||||
// land on another page and the poll timer would be untracked.
|
||||
jest.useFakeTimers();
|
||||
let settlePost: (value: unknown) => void = () => {};
|
||||
mockSupersetClient.post.mockReturnValue(
|
||||
new Promise(resolve => {
|
||||
settlePost = resolve;
|
||||
}) as never,
|
||||
);
|
||||
|
||||
const { unmount } = render(<MenuWrapper />, {
|
||||
useRedux: true,
|
||||
initialState: loggedInState,
|
||||
});
|
||||
await clickMenuItem('Export Data to Excel');
|
||||
await waitFor(() => expect(mockSupersetClient.post).toHaveBeenCalled());
|
||||
|
||||
unmount();
|
||||
await act(async () => {
|
||||
settlePost({ json: { job_id: 'abc' } });
|
||||
});
|
||||
|
||||
expect(mockAddInfoToast).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(30000);
|
||||
});
|
||||
expect(mockSupersetClient.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { SyntheticEvent } from 'react';
|
||||
import { SyntheticEvent, useEffect, useRef } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
@@ -43,6 +43,22 @@ import { useToasts } from 'src/components/MessageToasts/withToasts';
|
||||
import { MenuItemTooltip } from 'src/components/Chart/DisabledMenuItemTooltip';
|
||||
import { DownloadScreenshotFormat } from './types';
|
||||
|
||||
// Polling always outlives the server's hard task budget (11 minutes): the
|
||||
// export email is best-effort, so the client cannot rely on it as a fallback.
|
||||
const EXPORT_STATUS_POLL_INTERVAL_MS = 3000;
|
||||
const EXPORT_STATUS_POLL_TIMEOUT_MS = 12 * 60 * 1000;
|
||||
|
||||
interface ExportStatusResponse {
|
||||
status?: 'pending' | 'running' | 'ready' | 'error';
|
||||
download_url?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface ExportPollState {
|
||||
deadline: number;
|
||||
sawRunning: boolean;
|
||||
}
|
||||
|
||||
export interface UseDownloadMenuItemsProps {
|
||||
pdfMenuItemTitle: string;
|
||||
imageMenuItemTitle: string;
|
||||
@@ -70,8 +86,35 @@ export const useDownloadMenuItems = (
|
||||
canExportImage,
|
||||
} = props;
|
||||
|
||||
const { addDangerToast, addSuccessToast } = useToasts();
|
||||
const { addDangerToast, addSuccessToast, addInfoToast } = useToasts();
|
||||
// Track the in-flight poll timer so navigating away stops the polling
|
||||
// (and the full-page navigation it would eventually trigger).
|
||||
const pollTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const unmountedRef = useRef(false);
|
||||
useEffect(
|
||||
() => () => {
|
||||
unmountedRef.current = true;
|
||||
if (pollTimerRef.current) {
|
||||
clearTimeout(pollTimerRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
const dataMask = useSelector((state: RootState) => state.dataMask);
|
||||
const user = useSelector((state: RootState) => state.user);
|
||||
// Guests and anonymous sessions have no userId and cannot be emailed.
|
||||
const isGuestSession = !user?.userId;
|
||||
const canReceiveEmail = Boolean(user?.userId && user?.email);
|
||||
|
||||
const addExportPendingToast = () =>
|
||||
addInfoToast(
|
||||
canReceiveEmail
|
||||
? t(
|
||||
"Your export is being generated and will download automatically when ready. We'll also email you a download link.",
|
||||
)
|
||||
: t('Your export is being generated. Please, do not leave the page.'),
|
||||
{ noDuplicate: true },
|
||||
);
|
||||
const SCREENSHOT_NODE_SELECTOR = '.dashboard';
|
||||
|
||||
const buildActiveDataMask = (): Record<string, { extraFormData: object }> =>
|
||||
@@ -167,24 +210,142 @@ export const useDownloadMenuItems = (
|
||||
}
|
||||
};
|
||||
|
||||
const triggerExportDownload = async (downloadUrl: string) => {
|
||||
// A plain anchor to the attachment streams it to disk without leaving the
|
||||
// page or needing a frame (so no frame-src CSP exception). No `download`
|
||||
// attribute: by now polling has consumed any user activation, and Chrome
|
||||
// drops such clicks silently. An error response would navigate instead,
|
||||
// so confirm the exact link first and report a failure as a toast.
|
||||
try {
|
||||
const probe = await fetch(downloadUrl, {
|
||||
method: 'HEAD',
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (!probe.ok) {
|
||||
throw new Error(`download link answered ${probe.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logging.error(error);
|
||||
if (!unmountedRef.current) {
|
||||
addDangerToast(t('Sorry, something went wrong. Try again later.'));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (unmountedRef.current) {
|
||||
return false;
|
||||
}
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = downloadUrl;
|
||||
anchor.style.display = 'none';
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
return true;
|
||||
};
|
||||
|
||||
const pollExportStatus = (jobId: string, pollState: ExportPollState) => {
|
||||
if (unmountedRef.current) {
|
||||
return;
|
||||
}
|
||||
SupersetClient.get({
|
||||
endpoint: `/api/v1/dashboard/export_xlsx/status/${jobId}/`,
|
||||
})
|
||||
.then(({ json }) => {
|
||||
// A response in flight when the component unmounts must not navigate
|
||||
// (redirect) or toast on whatever page the user moved to.
|
||||
if (unmountedRef.current) {
|
||||
return;
|
||||
}
|
||||
const {
|
||||
status,
|
||||
download_url: downloadUrl,
|
||||
message,
|
||||
} = json as ExportStatusResponse;
|
||||
if (status === 'ready') {
|
||||
if (downloadUrl) {
|
||||
triggerExportDownload(downloadUrl).then(started => {
|
||||
if (started) {
|
||||
addSuccessToast(t('Your export is ready and downloading.'));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
addDangerToast(t('Sorry, something went wrong. Try again later.'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (status === 'error') {
|
||||
addDangerToast(
|
||||
message || t('Sorry, something went wrong. Try again later.'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (status === 'running' && !pollState.sawRunning) {
|
||||
// The task's execution budget only starts when a worker picks it
|
||||
// up; restart the wait window then, so queue delay doesn't eat it.
|
||||
pollState.sawRunning = true;
|
||||
pollState.deadline = Date.now() + EXPORT_STATUS_POLL_TIMEOUT_MS;
|
||||
}
|
||||
if (Date.now() > pollState.deadline) {
|
||||
addDangerToast(
|
||||
t('Your export is taking longer than expected. Try again later.'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// The pending toast is announced once at enqueue, not re-emitted on
|
||||
// every poll: an info toast expires after a few seconds and noDuplicate
|
||||
// only dedupes live toasts, so re-emitting would create a fresh
|
||||
// role="alert" every poll for the whole export -- noisy, and hostile to
|
||||
// screen readers.
|
||||
pollTimerRef.current = setTimeout(
|
||||
() => pollExportStatus(jobId, pollState),
|
||||
EXPORT_STATUS_POLL_INTERVAL_MS,
|
||||
);
|
||||
})
|
||||
.catch(error => {
|
||||
if (unmountedRef.current) {
|
||||
return;
|
||||
}
|
||||
// A transient polling failure shouldn't give up the wait -- the export
|
||||
// itself may still succeed -- so keep polling until the timeout.
|
||||
logging.error(error);
|
||||
if (Date.now() > pollState.deadline) {
|
||||
addDangerToast(t('Sorry, something went wrong. Try again later.'));
|
||||
return;
|
||||
}
|
||||
pollTimerRef.current = setTimeout(
|
||||
() => pollExportStatus(jobId, pollState),
|
||||
EXPORT_STATUS_POLL_INTERVAL_MS,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const onExportXlsx = async (mode: 'data' | 'images') => {
|
||||
try {
|
||||
const { json } = await SupersetClient.post({
|
||||
endpoint: `/api/v1/dashboard/${dashboardId}/export_xlsx/`,
|
||||
jsonPayload: { active_data_mask: buildActiveDataMask(), mode },
|
||||
});
|
||||
// Settled after unmount: the timer would be untracked, the toast stray.
|
||||
if (unmountedRef.current) {
|
||||
return;
|
||||
}
|
||||
// The throttle response (an export is already running) returns 202 with a
|
||||
// message but no job_id; only a freshly enqueued job carries a job_id.
|
||||
if ((json as { job_id?: string })?.job_id) {
|
||||
addSuccessToast(
|
||||
t(
|
||||
"Your export is being prepared. You'll receive an email when it's ready.",
|
||||
),
|
||||
const jobId = (json as { job_id?: string })?.job_id;
|
||||
if (jobId) {
|
||||
addExportPendingToast();
|
||||
pollTimerRef.current = setTimeout(
|
||||
() =>
|
||||
pollExportStatus(jobId, {
|
||||
deadline: Date.now() + EXPORT_STATUS_POLL_TIMEOUT_MS,
|
||||
sawRunning: false,
|
||||
}),
|
||||
EXPORT_STATUS_POLL_INTERVAL_MS,
|
||||
);
|
||||
} else {
|
||||
addSuccessToast(
|
||||
t('An export for this dashboard is already in progress.'),
|
||||
);
|
||||
// The throttle 202 carries no job_id, so there is nothing to poll and
|
||||
// nothing is downloading here: an info toast, not a success one.
|
||||
addInfoToast(t('An export for this dashboard is already in progress.'));
|
||||
}
|
||||
} catch (error) {
|
||||
// status comes from the response (Partial<SupersetClientResponse>), which
|
||||
@@ -192,6 +353,9 @@ export const useDownloadMenuItems = (
|
||||
const { status } = (await getClientErrorObject(error)) as {
|
||||
status?: number;
|
||||
};
|
||||
if (unmountedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (status === 501) {
|
||||
addDangerToast(t('Excel export is not configured on this server.'));
|
||||
} else {
|
||||
@@ -252,11 +416,10 @@ export const useDownloadMenuItems = (
|
||||
label: t('Export Data to Excel'),
|
||||
onClick: () => onExportXlsx('data'),
|
||||
},
|
||||
// Image export renders charts through the headless webdriver, so only
|
||||
// offer it where that infrastructure is available (same signal as the
|
||||
// PDF/PNG image downloads above); otherwise non-table charts would
|
||||
// silently come back empty.
|
||||
...(isWebDriverScreenshotEnabled
|
||||
// Needs the webdriver infrastructure and a real session: the
|
||||
// webdriver cannot render under a guest identity (the API rejects
|
||||
// guest image exports too).
|
||||
...(isWebDriverScreenshotEnabled && !isGuestSession
|
||||
? [
|
||||
{
|
||||
key: 'export-xlsx-images',
|
||||
@@ -267,12 +430,17 @@ export const useDownloadMenuItems = (
|
||||
: []),
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: 'export-yaml',
|
||||
label: t('Export YAML'),
|
||||
onClick: onExportZip,
|
||||
},
|
||||
...(userCanExport
|
||||
// Bundle exports are refused for sessions without a user id.
|
||||
...(!isGuestSession
|
||||
? [
|
||||
{
|
||||
key: 'export-yaml',
|
||||
label: t('Export YAML'),
|
||||
onClick: onExportZip,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(userCanExport && !isGuestSession
|
||||
? [
|
||||
{
|
||||
key: 'export-as-example',
|
||||
|
||||
@@ -21,6 +21,8 @@ import { RefObject, ReactNode, useCallback, memo } from 'react';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { HOVER_MENU_Z_INDEX } from 'src/dashboard/constants';
|
||||
|
||||
interface HoverMenuProps {
|
||||
position?: 'left' | 'top';
|
||||
innerRef?: RefObject<HTMLDivElement> | null;
|
||||
@@ -32,7 +34,7 @@ const HoverStyleOverrides = styled.div`
|
||||
.hover-menu {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
z-index: 11; /* one more than DragDroppable */
|
||||
z-index: ${HOVER_MENU_Z_INDEX};
|
||||
font-size: ${({ theme }) => theme.fontSize};
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
BottomRightResizeHandle,
|
||||
} from './ResizableHandle';
|
||||
import { isMobileConsumptionEnabled } from 'src/hooks/useIsMobile';
|
||||
import { RESIZE_HANDLE_Z_INDEX } from 'src/dashboard/constants';
|
||||
import resizableConfig from '../../util/resizableConfig';
|
||||
import {
|
||||
GRID_BASE_UNIT,
|
||||
@@ -130,7 +131,7 @@ const StyledResizable = styled(Resizable)`
|
||||
|
||||
.resize-handle {
|
||||
opacity: 0;
|
||||
z-index: 10;
|
||||
z-index: ${RESIZE_HANDLE_Z_INDEX};
|
||||
|
||||
&--bottom-right {
|
||||
position: absolute;
|
||||
|
||||
@@ -43,6 +43,12 @@ export const FILTER_BAR_TABS_HEIGHT = 46;
|
||||
export const BUILDER_SIDEPANEL_WIDTH = 374;
|
||||
export const OVERWRITE_INSPECT_FIELDS = ['css', 'json_metadata.filter_scopes'];
|
||||
export const EMPTY_CONTAINER_Z_INDEX = 10;
|
||||
// A grid component's hover menu floats outside the component's own box, so it
|
||||
// must paint above the drop targets it overlaps.
|
||||
export const HOVER_MENU_Z_INDEX = EMPTY_CONTAINER_Z_INDEX + 1;
|
||||
// A hover menu rendered above a column overlaps the bottom edge of whatever
|
||||
// sits above it, so resize handles have to win hit testing against it.
|
||||
export const RESIZE_HANDLE_Z_INDEX = HOVER_MENU_Z_INDEX + 1;
|
||||
|
||||
export const DEFAULT_CROSS_FILTER_SCOPING: NativeFilterScope = {
|
||||
rootPath: [DASHBOARD_ROOT_ID],
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
AutoRefreshProvider,
|
||||
useAutoRefreshContext,
|
||||
useIsAutoRefreshing,
|
||||
useIsChartAutoRefreshing,
|
||||
useIsRefreshInFlight,
|
||||
} from './AutoRefreshContext';
|
||||
|
||||
@@ -135,3 +136,71 @@ test('useIsRefreshInFlight hook returns correct value inside provider', () => {
|
||||
|
||||
expect(result.current.isRefreshInFlight).toBe(true);
|
||||
});
|
||||
|
||||
test('autoRefreshingChartIds starts empty', () => {
|
||||
const { result } = renderHook(() => useAutoRefreshContext(), { wrapper });
|
||||
expect(result.current.autoRefreshingChartIds).toEqual([]);
|
||||
});
|
||||
|
||||
test('startAutoRefresh(chartIds) records only the affected chart ids', () => {
|
||||
const { result } = renderHook(() => useAutoRefreshContext(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.startAutoRefresh([1, 2, 3]);
|
||||
});
|
||||
|
||||
expect(result.current.isAutoRefreshing).toBe(true);
|
||||
expect(result.current.autoRefreshingChartIds).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
test('startAutoRefresh() with no chart ids records an empty batch', () => {
|
||||
const { result } = renderHook(() => useAutoRefreshContext(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.startAutoRefresh();
|
||||
});
|
||||
|
||||
expect(result.current.isAutoRefreshing).toBe(true);
|
||||
expect(result.current.autoRefreshingChartIds).toEqual([]);
|
||||
});
|
||||
|
||||
test('endAutoRefresh clears autoRefreshingChartIds', () => {
|
||||
const { result } = renderHook(() => useAutoRefreshContext(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.startAutoRefresh([1, 2, 3]);
|
||||
});
|
||||
expect(result.current.autoRefreshingChartIds).toEqual([1, 2, 3]);
|
||||
|
||||
act(() => {
|
||||
result.current.endAutoRefresh();
|
||||
});
|
||||
expect(result.current.autoRefreshingChartIds).toEqual([]);
|
||||
});
|
||||
|
||||
test('useIsChartAutoRefreshing only reports true for charts in the in-flight batch', () => {
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
context: useAutoRefreshContext(),
|
||||
isChart1AutoRefreshing: useIsChartAutoRefreshing(1),
|
||||
isChart2AutoRefreshing: useIsChartAutoRefreshing(2),
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.isChart1AutoRefreshing).toBe(false);
|
||||
expect(result.current.isChart2AutoRefreshing).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current.context.startAutoRefresh([1]);
|
||||
});
|
||||
|
||||
expect(result.current.isChart1AutoRefreshing).toBe(true);
|
||||
expect(result.current.isChart2AutoRefreshing).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current.context.endAutoRefresh();
|
||||
});
|
||||
|
||||
expect(result.current.isChart1AutoRefreshing).toBe(false);
|
||||
});
|
||||
|
||||
@@ -29,15 +29,17 @@ import {
|
||||
export interface AutoRefreshContextValue {
|
||||
isAutoRefreshing: boolean;
|
||||
isRefreshInFlight: boolean;
|
||||
autoRefreshingChartIds: number[];
|
||||
setIsAutoRefreshing: (value: boolean) => void;
|
||||
setRefreshInFlight: (value: boolean) => void;
|
||||
startAutoRefresh: () => void;
|
||||
startAutoRefresh: (chartIds?: number[]) => void;
|
||||
endAutoRefresh: () => void;
|
||||
}
|
||||
|
||||
const AutoRefreshContext = createContext<AutoRefreshContextValue>({
|
||||
isAutoRefreshing: false,
|
||||
isRefreshInFlight: false,
|
||||
autoRefreshingChartIds: [],
|
||||
setIsAutoRefreshing: () => {},
|
||||
setRefreshInFlight: () => {},
|
||||
startAutoRefresh: () => {},
|
||||
@@ -57,25 +59,37 @@ export const AutoRefreshProvider: FC<AutoRefreshProviderProps> = ({
|
||||
}) => {
|
||||
const [isAutoRefreshing, setIsAutoRefreshing] = useState(false);
|
||||
const [isRefreshInFlight, setRefreshInFlight] = useState(false);
|
||||
const [autoRefreshingChartIds, setAutoRefreshingChartIds] = useState<
|
||||
number[]
|
||||
>([]);
|
||||
|
||||
const startAutoRefresh = useCallback(() => {
|
||||
const startAutoRefresh = useCallback((chartIds: number[] = []) => {
|
||||
setAutoRefreshingChartIds(chartIds);
|
||||
setIsAutoRefreshing(true);
|
||||
}, []);
|
||||
|
||||
const endAutoRefresh = useCallback(() => {
|
||||
setIsAutoRefreshing(false);
|
||||
setAutoRefreshingChartIds([]);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
isAutoRefreshing,
|
||||
isRefreshInFlight,
|
||||
autoRefreshingChartIds,
|
||||
setIsAutoRefreshing,
|
||||
setRefreshInFlight,
|
||||
startAutoRefresh,
|
||||
endAutoRefresh,
|
||||
}),
|
||||
[isAutoRefreshing, isRefreshInFlight, startAutoRefresh, endAutoRefresh],
|
||||
[
|
||||
isAutoRefreshing,
|
||||
isRefreshInFlight,
|
||||
autoRefreshingChartIds,
|
||||
startAutoRefresh,
|
||||
endAutoRefresh,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -98,4 +112,15 @@ export const useIsRefreshInFlight = (): boolean => {
|
||||
return isRefreshInFlight;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the given chart is part of the currently in-flight silent
|
||||
* auto-refresh batch. Unlike useIsAutoRefreshing, this is scoped to a single
|
||||
* chart so charts loading for unrelated reasons (e.g. mounting into a
|
||||
* freshly-selected tab) still show their own spinner.
|
||||
*/
|
||||
export const useIsChartAutoRefreshing = (chartId: number): boolean => {
|
||||
const { autoRefreshingChartIds } = useContext(AutoRefreshContext);
|
||||
return autoRefreshingChartIds.includes(chartId);
|
||||
};
|
||||
|
||||
export default AutoRefreshContext;
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from '@superset-ui/core';
|
||||
import {
|
||||
DASHBOARD_INFO_UPDATED,
|
||||
DASHBOARD_SAVE_SUCCEEDED,
|
||||
SET_FILTER_BAR_ORIENTATION,
|
||||
SET_CROSS_FILTERS_ENABLED,
|
||||
DASHBOARD_INFO_FILTERS_CHANGED,
|
||||
@@ -71,6 +72,7 @@ type DashboardInfoReducerAction =
|
||||
|
||||
type DashboardInfoState = Partial<DashboardInfo> & {
|
||||
last_modified_time?: number;
|
||||
versionHistoryRevision?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
@@ -124,6 +126,13 @@ export default function dashboardInfoReducer(
|
||||
action: DashboardInfoReducerAction,
|
||||
): DashboardInfoState {
|
||||
switch (action.type) {
|
||||
case DASHBOARD_SAVE_SUCCEEDED:
|
||||
return (action as DashboardInfoAction).dashboardId === state.id
|
||||
? {
|
||||
...state,
|
||||
versionHistoryRevision: (state.versionHistoryRevision ?? 0) + 1,
|
||||
}
|
||||
: state;
|
||||
case DASHBOARD_INFO_UPDATED: {
|
||||
const dashAction = action as DashboardInfoAction;
|
||||
const newInfo = dashAction.newInfo || {};
|
||||
@@ -189,6 +198,7 @@ export default function dashboardInfoReducer(
|
||||
native_filter_configuration: newConfigWithScopes,
|
||||
} as DashboardInfo['metadata'],
|
||||
last_modified_time: Math.round(new Date().getTime() / 1000),
|
||||
versionHistoryRevision: (state.versionHistoryRevision ?? 0) + 1,
|
||||
};
|
||||
}
|
||||
case HYDRATE_DASHBOARD: {
|
||||
|
||||
@@ -33,6 +33,7 @@ import type { DashboardLayout } from 'src/dashboard/types';
|
||||
|
||||
import {
|
||||
CHART_TYPE,
|
||||
COLUMN_TYPE,
|
||||
DASHBOARD_GRID_TYPE,
|
||||
DASHBOARD_ROOT_TYPE,
|
||||
ROW_TYPE,
|
||||
@@ -231,6 +232,67 @@ describe('dashboardLayout reducer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('should not move a component into its own descendant', () => {
|
||||
const layout = {
|
||||
parent: {
|
||||
id: 'parent',
|
||||
type: ROW_TYPE,
|
||||
children: ['column'],
|
||||
},
|
||||
column: {
|
||||
id: 'column',
|
||||
type: COLUMN_TYPE,
|
||||
children: ['nestedRow'],
|
||||
parents: ['parent'],
|
||||
},
|
||||
nestedRow: {
|
||||
id: 'nestedRow',
|
||||
type: ROW_TYPE,
|
||||
children: [],
|
||||
parents: ['parent', 'column'],
|
||||
},
|
||||
};
|
||||
|
||||
const dropResult = {
|
||||
source: { id: 'parent', type: ROW_TYPE, index: 0 },
|
||||
destination: { id: 'nestedRow', type: ROW_TYPE, index: 0 },
|
||||
dragging: { id: 'column', type: COLUMN_TYPE },
|
||||
};
|
||||
|
||||
expect(
|
||||
testReducer(layout, {
|
||||
type: MOVE_COMPONENT,
|
||||
payload: { dropResult },
|
||||
}),
|
||||
).toBe(layout);
|
||||
});
|
||||
|
||||
test('should not move a component into a descendant with stale parents', () => {
|
||||
const layout = {
|
||||
parent: { id: 'parent', type: ROW_TYPE, children: ['column'] },
|
||||
column: { id: 'column', type: COLUMN_TYPE, children: ['nestedRow'] },
|
||||
nestedRow: {
|
||||
id: 'nestedRow',
|
||||
type: ROW_TYPE,
|
||||
children: [],
|
||||
parents: ['somewhere-else'],
|
||||
},
|
||||
};
|
||||
|
||||
const dropResult = {
|
||||
source: { id: 'parent', type: ROW_TYPE, index: 0 },
|
||||
destination: { id: 'nestedRow', type: ROW_TYPE, index: 0 },
|
||||
dragging: { id: 'column', type: COLUMN_TYPE },
|
||||
};
|
||||
|
||||
expect(
|
||||
testReducer(layout, {
|
||||
type: MOVE_COMPONENT,
|
||||
payload: { dropResult },
|
||||
}),
|
||||
).toBe(layout);
|
||||
});
|
||||
|
||||
test('should wrap a moved component in a row if need be', () => {
|
||||
const layout = {
|
||||
source: {
|
||||
|
||||
@@ -47,6 +47,25 @@ import { HYDRATE_DASHBOARD } from '../actions/hydrate';
|
||||
import { DashboardLayout } from '../types';
|
||||
import { DropResult } from '../components/dnd/dragDroppableConfig';
|
||||
|
||||
// Walks `children` rather than trusting `parents`, which can be stale.
|
||||
function isSelfOrDescendant(
|
||||
layout: DashboardLayout,
|
||||
ancestorId: string,
|
||||
targetId: string,
|
||||
): boolean {
|
||||
const seen = new Set<string>();
|
||||
const stack = [ancestorId];
|
||||
while (stack.length) {
|
||||
const id = stack.pop() as string;
|
||||
if (id === targetId) return true;
|
||||
if (!seen.has(id)) {
|
||||
seen.add(id);
|
||||
stack.push(...(layout[id]?.children ?? []));
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
interface DashboardLayoutAction {
|
||||
type: string;
|
||||
payload?: {
|
||||
@@ -177,6 +196,13 @@ const actionHandlers: Record<
|
||||
|
||||
if (!source || !destination || !dragging) return state;
|
||||
|
||||
// Defense in depth against the drop guard in getDropPosition: moving a
|
||||
// component into itself or into one of its own descendants detaches the
|
||||
// subtree and leaves a cycle that no root walk can reach or repair.
|
||||
if (isSelfOrDescendant(state, dragging.id, destination.id)) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const nextEntities = reorderItem({
|
||||
entitiesMap: state,
|
||||
source,
|
||||
|
||||
@@ -274,6 +274,29 @@ describe('DashboardState reducer', () => {
|
||||
expect.arrayContaining(['TAB-Outer1', 'TAB-Inner1']),
|
||||
);
|
||||
});
|
||||
|
||||
// The component tests assert on a `setActiveTab` mock, so they stop short
|
||||
// of the state that actually gets serialized into the permalink body.
|
||||
test('stores a resolved tab id so activeTabs serializes without null', () => {
|
||||
const store = mockStore({
|
||||
dashboardState: { activeTabs: [] },
|
||||
dashboardLayout: { present: { 'TAB-1': { parents: [] } } },
|
||||
});
|
||||
const thunkAction = setActiveTab('TAB-1')(
|
||||
store.dispatch,
|
||||
store.getState as () => RootState,
|
||||
);
|
||||
|
||||
const result = typedDashboardStateReducer(
|
||||
createMockDashboardState({ activeTabs: [] }),
|
||||
thunkAction,
|
||||
);
|
||||
|
||||
expect(result.activeTabs).toEqual(['TAB-1']);
|
||||
expect(JSON.stringify({ activeTabs: result.activeTabs })).toBe(
|
||||
'{"activeTabs":["TAB-1"]}',
|
||||
);
|
||||
});
|
||||
});
|
||||
// Pins a side effect of seeding activeTabs at hydration (see
|
||||
// actions/hydrate.ts / util/getDefaultActiveTabs.ts): a non-empty seeded
|
||||
|
||||
@@ -90,6 +90,7 @@ interface DashboardStateShape {
|
||||
hasUnsavedChanges?: boolean;
|
||||
dashboardIsSaving?: boolean;
|
||||
lastModifiedTime?: number;
|
||||
versionHistoryRevision?: number;
|
||||
refreshFrequency?: number;
|
||||
shouldPersistRefreshFrequency?: boolean;
|
||||
isRefreshing?: boolean;
|
||||
@@ -294,6 +295,7 @@ export default function dashboardStateReducer(
|
||||
updatedColorScheme: false,
|
||||
// server-side returns last_modified_time for latest change
|
||||
lastModifiedTime: action.lastModifiedTime,
|
||||
versionHistoryRevision: (state.versionHistoryRevision ?? 0) + 1,
|
||||
};
|
||||
},
|
||||
[SET_UNSAVED_CHANGES](): DashboardStateShape {
|
||||
|
||||
@@ -26,6 +26,7 @@ import getDropPositionOriginal, {
|
||||
|
||||
import {
|
||||
CHART_TYPE,
|
||||
COLUMN_TYPE,
|
||||
DASHBOARD_GRID_TYPE,
|
||||
DASHBOARD_ROOT_TYPE,
|
||||
HEADER_TYPE,
|
||||
@@ -457,3 +458,85 @@ describe('getDropPosition', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// The self-nesting guard needs ids and a parents chain, which getMocks above
|
||||
// intentionally omits, so these build their own minimal mocks.
|
||||
const selfNestingMocks = ({
|
||||
componentId,
|
||||
componentParents = [],
|
||||
parentComponentId,
|
||||
parentComponentParents = [],
|
||||
}: {
|
||||
componentId: string;
|
||||
componentParents?: string[];
|
||||
parentComponentId?: string;
|
||||
parentComponentParents?: string[];
|
||||
}) => [
|
||||
{
|
||||
getItem: () => ({ id: 'COLUMN-dragged', type: COLUMN_TYPE }),
|
||||
getClientOffset: () => ({ x: 0, y: 0 }),
|
||||
},
|
||||
{
|
||||
props: {
|
||||
depth: 2,
|
||||
component: {
|
||||
id: componentId,
|
||||
type: ROW_TYPE,
|
||||
children: [],
|
||||
parents: componentParents,
|
||||
},
|
||||
parentComponent: parentComponentId
|
||||
? {
|
||||
id: parentComponentId,
|
||||
type: TAB_TYPE,
|
||||
children: [],
|
||||
parents: parentComponentParents,
|
||||
}
|
||||
: undefined,
|
||||
orientation: 'column',
|
||||
isDraggingOverShallow: true,
|
||||
},
|
||||
ref: {
|
||||
getBoundingClientRect: () => ({
|
||||
top: 0,
|
||||
right: 100,
|
||||
bottom: 100,
|
||||
left: 0,
|
||||
}),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
test('returns DROP_FORBIDDEN when dropping a component into its own descendant', () => {
|
||||
expect(
|
||||
getDropPosition(
|
||||
...selfNestingMocks({
|
||||
componentId: 'ROW-nested-in-dragged',
|
||||
componentParents: ['ROOT_ID', 'GRID_ID', 'COLUMN-dragged'],
|
||||
}),
|
||||
),
|
||||
).toBe(DROP_FORBIDDEN);
|
||||
});
|
||||
|
||||
test('returns DROP_FORBIDDEN when dropping a component as a sibling inside itself', () => {
|
||||
expect(
|
||||
getDropPosition(
|
||||
...selfNestingMocks({
|
||||
componentId: 'ROW-nested-in-dragged-sibling',
|
||||
componentParents: ['ROOT_ID', 'GRID_ID', 'COLUMN-dragged'],
|
||||
parentComponentId: 'COLUMN-dragged',
|
||||
}),
|
||||
),
|
||||
).toBe(DROP_FORBIDDEN);
|
||||
});
|
||||
|
||||
test('allows a drop on an unrelated component', () => {
|
||||
expect(
|
||||
getDropPosition(
|
||||
...selfNestingMocks({
|
||||
componentId: 'ROW-unrelated',
|
||||
componentParents: ['ROOT_ID', 'GRID_ID'],
|
||||
}),
|
||||
),
|
||||
).not.toBe(DROP_FORBIDDEN);
|
||||
});
|
||||
|
||||
@@ -88,6 +88,19 @@ export default function getDropPosition(
|
||||
return null;
|
||||
}
|
||||
|
||||
// A container cannot receive itself. Dropping it into one of its own
|
||||
// descendants (as a child, or as a sibling of one) makes reorderItem detach
|
||||
// the subtree from its real parent and close a cycle between the two, which
|
||||
// is unreachable from the root and so never repaired downstream.
|
||||
const isSelfOrDescendant = (target?: LayoutItem) =>
|
||||
!!target &&
|
||||
(target.id === draggingItem.id ||
|
||||
(target.parents || []).includes(draggingItem.id));
|
||||
|
||||
if (isSelfOrDescendant(component) || isSelfOrDescendant(parentComponent)) {
|
||||
return DROP_FORBIDDEN;
|
||||
}
|
||||
|
||||
const validChild = isValidChild({
|
||||
parentType: component.type,
|
||||
parentDepth: componentDepth,
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* 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 removeUnreachableComponents from 'src/dashboard/util/removeUnreachableComponents';
|
||||
import type { DashboardComponent } from 'src/dashboard/types';
|
||||
|
||||
type Layout = Record<string, DashboardComponent>;
|
||||
|
||||
const component = (
|
||||
id: string,
|
||||
type: string,
|
||||
children: string[] = [],
|
||||
extra: Partial<DashboardComponent> = {},
|
||||
): DashboardComponent => ({ id, type, children, meta: {}, ...extra });
|
||||
|
||||
// DASHBOARD_VERSION_KEY is a plain string in real position data
|
||||
const withVersionKey = (layout: Layout): Layout =>
|
||||
({ DASHBOARD_VERSION_KEY: 'v2', ...layout }) as unknown as Layout;
|
||||
|
||||
const reachableLayout = (): Layout =>
|
||||
withVersionKey({
|
||||
ROOT_ID: component('ROOT_ID', 'ROOT', ['GRID_ID']),
|
||||
GRID_ID: component('GRID_ID', 'GRID', ['ROW-a']),
|
||||
'ROW-a': component('ROW-a', 'ROW', ['CHART-a']),
|
||||
'CHART-a': component('CHART-a', 'CHART', [], { meta: { chartId: 1 } }),
|
||||
HEADER_ID: component('HEADER_ID', 'HEADER'),
|
||||
});
|
||||
|
||||
// the shape of a real corrupted dashboard: a column moved into a row nested
|
||||
// inside itself, leaving the pair pointing at each other and detached
|
||||
const trappedComponents = (chartMeta: DashboardComponent['meta']): Layout => ({
|
||||
'COLUMN-orphan': component(
|
||||
'COLUMN-orphan',
|
||||
'COLUMN',
|
||||
['CHART-trapped', 'ROW-orphan'],
|
||||
{ parents: ['ROOT_ID', 'GRID_ID', 'ROW-a'] },
|
||||
),
|
||||
'ROW-orphan': component('ROW-orphan', 'ROW', ['COLUMN-orphan'], {
|
||||
parents: ['ROOT_ID', 'GRID_ID', 'ROW-a', 'COLUMN-orphan'],
|
||||
}),
|
||||
'CHART-trapped': component('CHART-trapped', 'CHART', [], {
|
||||
parents: ['ROOT_ID', 'GRID_ID', 'ROW-a', 'COLUMN-orphan'],
|
||||
meta: chartMeta,
|
||||
}),
|
||||
});
|
||||
|
||||
test('returns the same layout when everything is reachable', () => {
|
||||
const layout = reachableLayout();
|
||||
expect(removeUnreachableComponents(layout)).toBe(layout);
|
||||
});
|
||||
|
||||
test('drops a detached subtree, including one that contains a cycle', () => {
|
||||
const layout = { ...reachableLayout(), ...trappedComponents({}) };
|
||||
|
||||
expect(Object.keys(removeUnreachableComponents(layout)).sort()).toEqual(
|
||||
Object.keys(reachableLayout()).sort(),
|
||||
);
|
||||
});
|
||||
|
||||
test('reattaches a detached chart to the grid, keeping its id and meta', () => {
|
||||
const layout = {
|
||||
...reachableLayout(),
|
||||
...trappedComponents({ chartId: 2, width: 4 }),
|
||||
};
|
||||
|
||||
const repaired = removeUnreachableComponents(layout);
|
||||
|
||||
expect(repaired['COLUMN-orphan']).toBeUndefined();
|
||||
expect(repaired['ROW-orphan']).toBeUndefined();
|
||||
const [, newRowId] = repaired.GRID_ID.children;
|
||||
expect(repaired[newRowId]).toMatchObject({
|
||||
type: 'ROW',
|
||||
children: ['CHART-trapped'],
|
||||
parents: ['ROOT_ID', 'GRID_ID'],
|
||||
});
|
||||
expect(repaired['CHART-trapped']).toMatchObject({
|
||||
parents: ['ROOT_ID', 'GRID_ID', newRowId],
|
||||
meta: { chartId: 2, width: 4 },
|
||||
});
|
||||
expect(layout.GRID_ID.children).toEqual(['ROW-a']);
|
||||
});
|
||||
|
||||
test('reattaches detached markdown to a row and a header to the grid', () => {
|
||||
const layout = {
|
||||
...reachableLayout(),
|
||||
'COLUMN-orphan': component('COLUMN-orphan', 'COLUMN', [
|
||||
'HEADER-trapped',
|
||||
'MARKDOWN-trapped',
|
||||
'ROW-orphan',
|
||||
]),
|
||||
'ROW-orphan': component('ROW-orphan', 'ROW', ['COLUMN-orphan']),
|
||||
'HEADER-trapped': component('HEADER-trapped', 'HEADER', [], {
|
||||
meta: { text: 'Section title' },
|
||||
}),
|
||||
'MARKDOWN-trapped': component('MARKDOWN-trapped', 'MARKDOWN', [], {
|
||||
meta: { code: '# Notes', width: 4 },
|
||||
}),
|
||||
};
|
||||
|
||||
const repaired = removeUnreachableComponents(layout);
|
||||
|
||||
expect(repaired['COLUMN-orphan']).toBeUndefined();
|
||||
expect(repaired['ROW-orphan']).toBeUndefined();
|
||||
const newRowId = repaired.GRID_ID.children[2];
|
||||
// a header is not a valid row child, so it sits directly in the grid
|
||||
expect(repaired.GRID_ID.children).toEqual([
|
||||
'ROW-a',
|
||||
'HEADER-trapped',
|
||||
newRowId,
|
||||
]);
|
||||
expect(repaired['HEADER-trapped']).toMatchObject({
|
||||
parents: ['ROOT_ID', 'GRID_ID'],
|
||||
meta: { text: 'Section title' },
|
||||
});
|
||||
expect(repaired[newRowId]).toMatchObject({
|
||||
type: 'ROW',
|
||||
children: ['MARKDOWN-trapped'],
|
||||
});
|
||||
expect(repaired['MARKDOWN-trapped']).toMatchObject({
|
||||
parents: ['ROOT_ID', 'GRID_ID', newRowId],
|
||||
meta: { code: '# Notes', width: 4 },
|
||||
});
|
||||
});
|
||||
|
||||
test('reattaches a detached chart to the first tab of top-level tabs', () => {
|
||||
const layout = withVersionKey({
|
||||
ROOT_ID: component('ROOT_ID', 'ROOT', ['TABS-t']),
|
||||
GRID_ID: component('GRID_ID', 'GRID'),
|
||||
'TABS-t': component('TABS-t', 'TABS', ['TAB-1', 'TAB-2']),
|
||||
'TAB-1': component('TAB-1', 'TAB'),
|
||||
'TAB-2': component('TAB-2', 'TAB'),
|
||||
'CHART-trapped': component('CHART-trapped', 'CHART', [], {
|
||||
meta: { chartId: 2 },
|
||||
}),
|
||||
});
|
||||
|
||||
const repaired = removeUnreachableComponents(layout);
|
||||
|
||||
const [newRowId] = repaired['TAB-1'].children;
|
||||
expect(repaired['CHART-trapped'].parents).toEqual([
|
||||
'ROOT_ID',
|
||||
'TABS-t',
|
||||
'TAB-1',
|
||||
newRowId,
|
||||
]);
|
||||
});
|
||||
|
||||
test('reattaches a detached chart to the tab recorded in its stale parents', () => {
|
||||
const layout = withVersionKey({
|
||||
ROOT_ID: component('ROOT_ID', 'ROOT', ['TABS-t']),
|
||||
GRID_ID: component('GRID_ID', 'GRID'),
|
||||
'TABS-t': component('TABS-t', 'TABS', ['TAB-1', 'TAB-2']),
|
||||
'TAB-1': component('TAB-1', 'TAB'),
|
||||
'TAB-2': component('TAB-2', 'TAB'),
|
||||
'CHART-trapped': component('CHART-trapped', 'CHART', [], {
|
||||
parents: ['ROOT_ID', 'TABS-t', 'TAB-2', 'ROW-gone', 'COLUMN-gone'],
|
||||
meta: { chartId: 2 },
|
||||
}),
|
||||
});
|
||||
|
||||
const repaired = removeUnreachableComponents(layout);
|
||||
|
||||
expect(repaired['TAB-1'].children).toEqual([]);
|
||||
const [newRowId] = repaired['TAB-2'].children;
|
||||
expect(repaired['CHART-trapped'].parents).toEqual([
|
||||
'ROOT_ID',
|
||||
'TABS-t',
|
||||
'TAB-2',
|
||||
newRowId,
|
||||
]);
|
||||
});
|
||||
|
||||
test('returns detached headers and markdown to their tab in order', () => {
|
||||
const staleParents = ['ROOT_ID', 'TABS-t', 'TAB-2', 'ROW-gone'];
|
||||
const layout = withVersionKey({
|
||||
ROOT_ID: component('ROOT_ID', 'ROOT', ['TABS-t']),
|
||||
GRID_ID: component('GRID_ID', 'GRID'),
|
||||
'TABS-t': component('TABS-t', 'TABS', ['TAB-1', 'TAB-2']),
|
||||
'TAB-1': component('TAB-1', 'TAB'),
|
||||
'TAB-2': component('TAB-2', 'TAB'),
|
||||
'CHART-trapped': component('CHART-trapped', 'CHART', [], {
|
||||
parents: staleParents,
|
||||
meta: { chartId: 2 },
|
||||
}),
|
||||
'HEADER-trapped': component('HEADER-trapped', 'HEADER', [], {
|
||||
parents: ['ROOT_ID', 'TABS-t', 'TAB-2'],
|
||||
}),
|
||||
'MARKDOWN-trapped': component('MARKDOWN-trapped', 'MARKDOWN', [], {
|
||||
parents: staleParents,
|
||||
}),
|
||||
});
|
||||
|
||||
const repaired = removeUnreachableComponents(layout);
|
||||
|
||||
expect(repaired['TAB-1'].children).toEqual([]);
|
||||
const [chartRow, headerId, markdownRow] = repaired['TAB-2'].children;
|
||||
expect(headerId).toBe('HEADER-trapped');
|
||||
expect(repaired['HEADER-trapped'].parents).toEqual([
|
||||
'ROOT_ID',
|
||||
'TABS-t',
|
||||
'TAB-2',
|
||||
]);
|
||||
expect(repaired[chartRow].children).toEqual(['CHART-trapped']);
|
||||
expect(repaired[markdownRow].children).toEqual(['MARKDOWN-trapped']);
|
||||
});
|
||||
|
||||
test('does not duplicate a detached chart that is also placed reachably', () => {
|
||||
const layout = { ...reachableLayout(), ...trappedComponents({ chartId: 1 }) };
|
||||
|
||||
const repaired = removeUnreachableComponents(layout);
|
||||
|
||||
expect(repaired['CHART-trapped']).toBeUndefined();
|
||||
expect(repaired.GRID_ID.children).toEqual(['ROW-a']);
|
||||
});
|
||||
|
||||
test('keeps the detached empty grid of a dashboard with top-level tabs', () => {
|
||||
const layout = withVersionKey({
|
||||
ROOT_ID: component('ROOT_ID', 'ROOT', ['TABS-t']),
|
||||
GRID_ID: component('GRID_ID', 'GRID'),
|
||||
'TABS-t': component('TABS-t', 'TABS', ['TAB-1']),
|
||||
'TAB-1': component('TAB-1', 'TAB'),
|
||||
HEADER_ID: component('HEADER_ID', 'HEADER'),
|
||||
});
|
||||
|
||||
expect(removeUnreachableComponents(layout)).toBe(layout);
|
||||
});
|
||||
|
||||
test('clears the children of a detached grid when dropping them', () => {
|
||||
const layout = withVersionKey({
|
||||
ROOT_ID: component('ROOT_ID', 'ROOT', ['TABS-t']),
|
||||
GRID_ID: component('GRID_ID', 'GRID', ['ROW-stale']),
|
||||
'ROW-stale': component('ROW-stale', 'ROW'),
|
||||
'TABS-t': component('TABS-t', 'TABS', ['TAB-1']),
|
||||
'TAB-1': component('TAB-1', 'TAB'),
|
||||
});
|
||||
|
||||
const repaired = removeUnreachableComponents(layout);
|
||||
|
||||
expect(repaired['ROW-stale']).toBeUndefined();
|
||||
expect(repaired.GRID_ID.children).toEqual([]);
|
||||
expect(layout.GRID_ID.children).toEqual(['ROW-stale']);
|
||||
});
|
||||
|
||||
test('returns the layout untouched when there is no root to walk from', () => {
|
||||
const layout: Layout = { 'ROW-a': component('ROW-a', 'ROW') };
|
||||
|
||||
expect(removeUnreachableComponents(layout)).toBe(layout);
|
||||
});
|
||||
|
||||
test('leaves the layout untouched when root children is not an array', () => {
|
||||
const layout = reachableLayout();
|
||||
layout.ROOT_ID = {
|
||||
...layout.ROOT_ID,
|
||||
children: { 0: 'GRID_ID' } as unknown as string[],
|
||||
};
|
||||
|
||||
expect(removeUnreachableComponents(layout)).toBe(layout);
|
||||
});
|
||||
|
||||
test('tolerates non-array children below the root', () => {
|
||||
const layout = reachableLayout();
|
||||
layout['ROW-a'] = {
|
||||
...layout['ROW-a'],
|
||||
children: 'CHART-a' as unknown as string[],
|
||||
};
|
||||
|
||||
expect(() => removeUnreachableComponents(layout)).not.toThrow();
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* 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 { DashboardComponent } from '../types';
|
||||
import {
|
||||
CHART_TYPE,
|
||||
DASHBOARD_GRID_TYPE,
|
||||
HEADER_TYPE,
|
||||
MARKDOWN_TYPE,
|
||||
ROW_TYPE,
|
||||
TAB_TYPE,
|
||||
TABS_TYPE,
|
||||
} from './componentTypes';
|
||||
import {
|
||||
DASHBOARD_GRID_ID,
|
||||
DASHBOARD_HEADER_ID,
|
||||
DASHBOARD_ROOT_ID,
|
||||
DASHBOARD_VERSION_KEY,
|
||||
GRID_COLUMN_COUNT,
|
||||
GRID_DEFAULT_CHART_WIDTH,
|
||||
} from './constants';
|
||||
import newComponentFactory, { DashboardEntity } from './newComponentFactory';
|
||||
|
||||
// HEADER_ID is dashboard metadata rather than a rendered child, GRID_ID is
|
||||
// retained (emptied) and detached when a dashboard uses top-level tabs, and
|
||||
// DASHBOARD_VERSION_KEY is a version string rather than a component.
|
||||
const RESERVED_IDS = new Set<string>([
|
||||
DASHBOARD_GRID_ID,
|
||||
DASHBOARD_HEADER_ID,
|
||||
DASHBOARD_ROOT_ID,
|
||||
DASHBOARD_VERSION_KEY,
|
||||
]);
|
||||
|
||||
// containers that accept both new rows and headers
|
||||
const REATTACH_CONTAINER_TYPES = new Set<string>([
|
||||
DASHBOARD_GRID_TYPE,
|
||||
TAB_TYPE,
|
||||
]);
|
||||
|
||||
const childrenOf = (component: unknown): string[] => {
|
||||
const children = (component as DashboardComponent | undefined)?.children;
|
||||
return Array.isArray(children)
|
||||
? children.filter((id): id is string => typeof id === 'string')
|
||||
: [];
|
||||
};
|
||||
|
||||
const isComponent = (value: unknown): value is DashboardComponent =>
|
||||
typeof value === 'object' && value !== null;
|
||||
|
||||
/**
|
||||
* Drop components that cannot be reached from ROOT_ID, reattaching detached
|
||||
* charts, markdown and headers instead of dropping them.
|
||||
*
|
||||
* Detached components never render, but they survive in position_json and stay
|
||||
* visible to code that walks every layout entry or trusts the stale `parents`
|
||||
* of a node. A detached subtree containing a cycle is what crashes the filter
|
||||
* scope modal with "Maximum call stack size exceeded".
|
||||
*
|
||||
* A detached chart keeps its layout id and is moved into new rows in the deepest
|
||||
* container of its stored `parents` that is still reachable, falling back to
|
||||
* the first top-level container. Only the tail of a stale chain is gone, so its
|
||||
* reachable prefix still says e.g. which tab the chart came from. Dropping it would lose the chart's cross-filter
|
||||
* configuration, and a chart missing from the charts payload (e.g. archived
|
||||
* with SOFT_DELETE) could not be re-added, so the next save would drop its
|
||||
* dashboard membership. A chart that is also placed reachably is not duplicated.
|
||||
*
|
||||
* Detached markdown and headers are reattached the same way, since their text
|
||||
* lives nowhere but position_json. Markdown goes into the new rows; a header,
|
||||
* which cannot be a row child, goes directly into the container.
|
||||
* Mirrors `superset/dashboards/layout.py`.
|
||||
*/
|
||||
export default function removeUnreachableComponents<
|
||||
T extends DashboardComponent,
|
||||
>(layout: Record<string, T>): Record<string, T | DashboardEntity> {
|
||||
const root = layout[DASHBOARD_ROOT_ID];
|
||||
if (!isComponent(root) || !Array.isArray(root.children)) {
|
||||
return layout;
|
||||
}
|
||||
|
||||
// path from ROOT_ID to each reachable id
|
||||
const paths = new Map<string, string[]>();
|
||||
const stack: [string, string[]][] = [[DASHBOARD_ROOT_ID, []]];
|
||||
|
||||
while (stack.length) {
|
||||
const [id, parentPath] = stack.pop() as [string, string[]];
|
||||
// doubles as the cycle guard: an id already seen is never expanded twice
|
||||
if (!paths.has(id)) {
|
||||
const path = [...parentPath, id];
|
||||
paths.set(id, path);
|
||||
childrenOf(layout[id]).forEach(childId => {
|
||||
if (isComponent(layout[childId])) {
|
||||
stack.push([childId, path]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
const reachable = new Set(paths.keys());
|
||||
|
||||
const unreachable = Object.keys(layout).filter(
|
||||
id =>
|
||||
isComponent(layout[id]) && !reachable.has(id) && !RESERVED_IDS.has(id),
|
||||
);
|
||||
|
||||
if (!unreachable.length) {
|
||||
return layout;
|
||||
}
|
||||
|
||||
const placedChartIds = new Set<number | undefined>(
|
||||
[...reachable]
|
||||
.filter(id => layout[id].type === CHART_TYPE)
|
||||
.map(id => layout[id].meta?.chartId),
|
||||
);
|
||||
const rescued: [string, T][] = [];
|
||||
unreachable.forEach(id => {
|
||||
const component = layout[id];
|
||||
if (component.type === MARKDOWN_TYPE || component.type === HEADER_TYPE) {
|
||||
rescued.push([id, component]);
|
||||
return;
|
||||
}
|
||||
const chartId = component.meta?.chartId;
|
||||
if (
|
||||
component.type === CHART_TYPE &&
|
||||
chartId !== undefined &&
|
||||
!placedChartIds.has(chartId)
|
||||
) {
|
||||
placedChartIds.add(chartId);
|
||||
rescued.push([id, component]);
|
||||
}
|
||||
});
|
||||
|
||||
const next: Record<string, T | DashboardEntity> = { ...layout };
|
||||
unreachable.forEach(id => {
|
||||
delete next[id];
|
||||
});
|
||||
// a detached reserved id is kept, but must not reference the dropped nodes
|
||||
RESERVED_IDS.forEach(id => {
|
||||
const component = next[id];
|
||||
if (
|
||||
!reachable.has(id) &&
|
||||
isComponent(component) &&
|
||||
component.children?.length
|
||||
) {
|
||||
next[id] = { ...component, children: [] };
|
||||
}
|
||||
});
|
||||
|
||||
// mirrors findFirstParentContainerId; the path is built here rather than
|
||||
// read from `parents`, which may be stale or missing
|
||||
const [firstId] = childrenOf(layout[DASHBOARD_ROOT_ID]);
|
||||
const fallbackPath =
|
||||
layout[firstId]?.type === TABS_TYPE
|
||||
? [DASHBOARD_ROOT_ID, firstId, childrenOf(layout[firstId])[0]]
|
||||
: [DASHBOARD_ROOT_ID, firstId];
|
||||
|
||||
const homePath = (component: T): string[] => {
|
||||
const parents = Array.isArray(component.parents) ? component.parents : [];
|
||||
for (let i = parents.length - 1; i >= 0; i -= 1) {
|
||||
const path = paths.get(parents[i]);
|
||||
if (path && REATTACH_CONTAINER_TYPES.has(layout[parents[i]].type)) {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
return fallbackPath;
|
||||
};
|
||||
|
||||
const groups = new Map<string, [string[], [string, T][]]>();
|
||||
rescued.forEach(([componentKey, component]) => {
|
||||
const path = homePath(component);
|
||||
const key = path.join('/');
|
||||
const group = groups.get(key) ?? [path, []];
|
||||
group[1].push([componentKey, component]);
|
||||
groups.set(key, group);
|
||||
});
|
||||
|
||||
groups.forEach(([rowParents, components]) => {
|
||||
const containerId = rowParents[rowParents.length - 1];
|
||||
const container = containerId ? next[containerId] : undefined;
|
||||
if (!isComponent(container)) {
|
||||
return;
|
||||
}
|
||||
const containerChildren = childrenOf(container);
|
||||
let row: DashboardEntity | undefined;
|
||||
let rowWidth = 0;
|
||||
components.forEach(([componentKey, component]) => {
|
||||
if (component.type === HEADER_TYPE) {
|
||||
containerChildren.push(componentKey);
|
||||
next[componentKey] = { ...component, parents: rowParents.slice() };
|
||||
row = undefined;
|
||||
return;
|
||||
}
|
||||
const { width: rawWidth } = component.meta ?? {};
|
||||
const width =
|
||||
typeof rawWidth === 'number' && rawWidth > 0
|
||||
? rawWidth
|
||||
: GRID_DEFAULT_CHART_WIDTH;
|
||||
if (!row || rowWidth + width > GRID_COLUMN_COUNT) {
|
||||
row = newComponentFactory(ROW_TYPE, undefined, rowParents.slice());
|
||||
next[row.id] = row;
|
||||
containerChildren.push(row.id);
|
||||
rowWidth = 0;
|
||||
}
|
||||
row.children.push(componentKey);
|
||||
next[componentKey] = { ...component, parents: [...rowParents, row.id] };
|
||||
rowWidth += width;
|
||||
});
|
||||
next[containerId] = { ...container, children: containerChildren };
|
||||
});
|
||||
|
||||
return next;
|
||||
}
|
||||
@@ -168,3 +168,20 @@ describe('updateComponentParentsList with bad inputs', () => {
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
test('skips a back-edge to an ancestor instead of recursing forever', () => {
|
||||
const layout: Record<
|
||||
string,
|
||||
{ id: string; children?: string[]; parents?: string[] }
|
||||
> = {
|
||||
root: { id: 'root', children: ['row'] },
|
||||
row: { id: 'row', children: ['col'] },
|
||||
col: { id: 'col', children: ['row'] },
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
updateComponentParentsList({ currentComponent: layout.root, layout }),
|
||||
).not.toThrow();
|
||||
expect(layout.row.parents).toEqual(['root']);
|
||||
expect(layout.col.parents).toEqual(['root', 'row']);
|
||||
});
|
||||
|
||||
@@ -44,7 +44,12 @@ export default function updateComponentParentsList({
|
||||
|
||||
if (Array.isArray(currentComponent.children)) {
|
||||
currentComponent.children.forEach(childId => {
|
||||
if (layout[childId]) {
|
||||
if (parentsList.includes(childId)) {
|
||||
// A back-edge to an ancestor would recurse forever.
|
||||
logging.warn(
|
||||
`The component ${childId} is its own ancestor in the current layout. Skipping this component`,
|
||||
);
|
||||
} else if (layout[childId]) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
layout[childId] = {
|
||||
...layout[childId],
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
VizType,
|
||||
} from '@superset-ui/core';
|
||||
import ChartContainerComponent from 'src/explore/components/ExploreChartPanel';
|
||||
import { ExploreStandaloneMode } from 'src/explore/constants';
|
||||
import { setItem, LocalStorageKeys } from 'src/utils/localStorageHelpers';
|
||||
|
||||
jest.mock('./StandaloneDownloadControl', () => ({
|
||||
@@ -203,7 +204,7 @@ describe('ChartContainer', () => {
|
||||
|
||||
test('does not render standalone download control when show_download is absent', () => {
|
||||
const props = createProps({
|
||||
standalone: true,
|
||||
standalone: ExploreStandaloneMode.HideNav,
|
||||
can_download: true,
|
||||
});
|
||||
|
||||
@@ -218,7 +219,7 @@ describe('ChartContainer', () => {
|
||||
window.history.replaceState({}, '', '?show_download=0');
|
||||
|
||||
const props = createProps({
|
||||
standalone: true,
|
||||
standalone: ExploreStandaloneMode.HideNav,
|
||||
can_download: true,
|
||||
});
|
||||
|
||||
@@ -233,7 +234,7 @@ describe('ChartContainer', () => {
|
||||
window.history.replaceState({}, '', '?show_download=1');
|
||||
|
||||
const props = createProps({
|
||||
standalone: true,
|
||||
standalone: ExploreStandaloneMode.HideNav,
|
||||
can_download: true,
|
||||
});
|
||||
|
||||
@@ -248,7 +249,7 @@ describe('ChartContainer', () => {
|
||||
window.history.replaceState({}, '', '?show_download=1');
|
||||
|
||||
const props = createProps({
|
||||
standalone: true,
|
||||
standalone: ExploreStandaloneMode.HideNav,
|
||||
can_download: false,
|
||||
});
|
||||
|
||||
@@ -263,7 +264,7 @@ describe('ChartContainer', () => {
|
||||
window.history.replaceState({}, '', '?show_download=1');
|
||||
|
||||
const props = createProps({
|
||||
standalone: false,
|
||||
standalone: ExploreStandaloneMode.None,
|
||||
can_download: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -29,8 +29,10 @@ import {
|
||||
JsonObject,
|
||||
getExtensionsRegistry,
|
||||
} from '@superset-ui/core';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import { URL_PARAMS } from 'src/constants';
|
||||
import { getUrlParam } from 'src/utils/urlUtils';
|
||||
import { ExploreStandaloneMode } from 'src/explore/constants';
|
||||
import { css, styled, useTheme } from '@apache-superset/core/theme';
|
||||
import ChartContainer from 'src/components/Chart/ChartContainer';
|
||||
import { updateExploreChartState } from 'src/explore/actions/exploreActions';
|
||||
@@ -88,7 +90,7 @@ export interface ExploreChartPanelProps {
|
||||
vizType: string;
|
||||
form_data: QueryFormData;
|
||||
ownState?: JsonObject;
|
||||
standalone?: boolean;
|
||||
standalone?: number;
|
||||
force?: boolean;
|
||||
timeout?: number;
|
||||
chartIsStale?: boolean;
|
||||
@@ -255,7 +257,16 @@ const ExploreChartPanel = ({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
updateQueryContext();
|
||||
updateQueryContext().catch(error => {
|
||||
// Best-effort backfill; the chart renders either way. A 403 is expected
|
||||
// (no write access, or a managed chart), so it stays at debug.
|
||||
const message = 'Skipped background query context backfill';
|
||||
if (error?.status === 403) {
|
||||
logging.debug(message, error);
|
||||
} else {
|
||||
logging.warn(message, error);
|
||||
}
|
||||
});
|
||||
}, [updateQueryContext]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -503,7 +514,7 @@ const ExploreChartPanel = ({
|
||||
[gutterMargin],
|
||||
);
|
||||
|
||||
if (standalone) {
|
||||
if (standalone === ExploreStandaloneMode.HideNav) {
|
||||
// dom manipulation hack to get rid of the bootstrap theme's body background
|
||||
const standaloneClass = 'background-transparent';
|
||||
const bodyClasses = document.body.className.split(' ');
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user