mirror of
https://github.com/apache/superset.git
synced 2026-06-28 10:55:36 +00:00
Compare commits
21 Commits
fix/issue-
...
fix/105973
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
348d924c92 | ||
|
|
a57b5f6078 | ||
|
|
d1b523b97f | ||
|
|
91188a0302 | ||
|
|
ac234d0fb2 | ||
|
|
8eb753eab2 | ||
|
|
779fa13679 | ||
|
|
caf81e71d2 | ||
|
|
1b8c6d109d | ||
|
|
eb60e5477b | ||
|
|
7b9bcdd951 | ||
|
|
d9d395bde1 | ||
|
|
584d41759b | ||
|
|
8f22b71898 | ||
|
|
1ea3584dcb | ||
|
|
6bc77fecc2 | ||
|
|
420a74b01e | ||
|
|
7ba59c2d79 | ||
|
|
b77c525d4b | ||
|
|
41ce9ca7d3 | ||
|
|
c2fb94cedf |
2
.github/workflows/bump-python-package.yml
vendored
2
.github/workflows/bump-python-package.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
||||
checks: write
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: true
|
||||
ref: master
|
||||
|
||||
2
.github/workflows/check-python-deps.yml
vendored
2
.github/workflows/check-python-deps.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check and notify
|
||||
|
||||
4
.github/workflows/codeql-analysis.yml
vendored
4
.github/workflows/codeql-analysis.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
frontend: ${{ steps.check.outputs.frontend }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check for file changes
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
4
.github/workflows/dependency-review.yml
vendored
4
.github/workflows/dependency-review.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: "Checkout Repository"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: "Dependency Review"
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: "Checkout Repository"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
6
.github/workflows/docker.yml
vendored
6
.github/workflows/docker.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
docker: ${{ steps.check.outputs.docker }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check for file changes
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -177,7 +177,7 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Free up disk space
|
||||
|
||||
2
.github/workflows/embedded-sdk-release.yml
vendored
2
.github/workflows/embedded-sdk-release.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
run:
|
||||
working-directory: superset-embedded-sdk
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
# Note: registry-url is intentionally omitted. When set, actions/setup-node
|
||||
|
||||
2
.github/workflows/embedded-sdk-test.yml
vendored
2
.github/workflows/embedded-sdk-test.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
run:
|
||||
working-directory: superset-embedded-sdk
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
|
||||
2
.github/workflows/generate-FOSSA-report.yml
vendored
2
.github/workflows/generate-FOSSA-report.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
security-events: write
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
2
.github/workflows/issue_creation.yml
vendored
2
.github/workflows/issue_creation.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
2
.github/workflows/latest-release-tag.yml
vendored
2
.github/workflows/latest-release-tag.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
2
.github/workflows/license-check.yml
vendored
2
.github/workflows/license-check.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
2
.github/workflows/pr-lint.yml
vendored
2
.github/workflows/pr-lint.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
2
.github/workflows/pre-commit.yml
vendored
2
.github/workflows/pre-commit.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
python-version: ${{ github.event_name == 'pull_request' && fromJSON('["current"]') || fromJSON('["current", "previous", "next"]') }}
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
# pulls all commits (needed for lerna / semantic release to correctly version)
|
||||
|
||||
2
.github/workflows/showtime-trigger.yml
vendored
2
.github/workflows/showtime-trigger.yml
vendored
@@ -152,7 +152,7 @@ jobs:
|
||||
|
||||
- name: Checkout PR code (only if build needed)
|
||||
if: steps.auth.outputs.authorized == 'true' && steps.check.outputs.build_needed == 'true'
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ steps.check.outputs.target_sha }}
|
||||
persist-credentials: false
|
||||
|
||||
2
.github/workflows/superset-app-cli.yml
vendored
2
.github/workflows/superset-app-cli.yml
vendored
@@ -41,7 +41,7 @@ jobs:
|
||||
- 16379:6379
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
2
.github/workflows/superset-docs-deploy.yml
vendored
2
.github/workflows/superset-docs-deploy.yml
vendored
@@ -60,7 +60,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: "Checkout ${{ github.event.workflow_run.head_sha || github.sha }}"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
6
.github/workflows/superset-docs-verify.yml
vendored
6
.github/workflows/superset-docs-verify.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
name: Link Checking
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
# Do not bump this linkinator-action version without opening
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
working-directory: docs
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
@@ -112,7 +112,7 @@ jobs:
|
||||
working-directory: docs
|
||||
steps:
|
||||
- name: "Checkout PR head: ${{ github.event.workflow_run.head_sha }}"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
persist-credentials: false
|
||||
|
||||
14
.github/workflows/superset-e2e.yml
vendored
14
.github/workflows/superset-e2e.yml
vendored
@@ -38,7 +38,7 @@ jobs:
|
||||
frontend: ${{ steps.check.outputs.frontend }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check for file changes
|
||||
@@ -97,21 +97,21 @@ jobs:
|
||||
# Conditional checkout based on context
|
||||
- name: Checkout for push or pull_request event
|
||||
if: github.event_name == 'push' || github.event_name == 'pull_request'
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: refs/pull/${{ github.event.inputs.pr_id }}/merge
|
||||
@@ -207,21 +207,21 @@ jobs:
|
||||
# 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: refs/pull/${{ github.event.inputs.pr_id }}/merge
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
working-directory: superset-extensions-cli
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
4
.github/workflows/superset-frontend.yml
vendored
4
.github/workflows/superset-frontend.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
should-run: ${{ steps.check.outputs.frontend }}
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
@@ -110,7 +110,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
2
.github/workflows/superset-helm-lint.yml
vendored
2
.github/workflows/superset-helm-lint.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
2
.github/workflows/superset-helm-release.yml
vendored
2
.github/workflows/superset-helm-release.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref_name }}
|
||||
persist-credentials: true
|
||||
|
||||
8
.github/workflows/superset-playwright.yml
vendored
8
.github/workflows/superset-playwright.yml
vendored
@@ -34,7 +34,7 @@ jobs:
|
||||
frontend: ${{ steps.check.outputs.frontend }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check for file changes
|
||||
@@ -83,21 +83,21 @@ jobs:
|
||||
# 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: refs/pull/${{ github.event.inputs.pr_id }}/merge
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
python: ${{ steps.check.outputs.python }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check for file changes
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
- 16379:6379
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
@@ -157,7 +157,7 @@ jobs:
|
||||
- 16379:6379
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
@@ -207,7 +207,7 @@ jobs:
|
||||
- 16379:6379
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
python: ${{ steps.check.outputs.python }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check for file changes
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
- 16379:6379
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
@@ -127,7 +127,7 @@ jobs:
|
||||
- 16379:6379
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
python: ${{ steps.check.outputs.python }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check for file changes
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
PYTHONPATH: ${{ github.workspace }}
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
4
.github/workflows/superset-translations.yml
vendored
4
.github/workflows/superset-translations.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
2
.github/workflows/superset-websocket.yml
vendored
2
.github/workflows/superset-websocket.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install dependencies
|
||||
|
||||
2
.github/workflows/supersetbot.yml
vendored
2
.github/workflows/supersetbot.yml
vendored
@@ -38,7 +38,7 @@ jobs:
|
||||
});
|
||||
|
||||
- name: "Checkout ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
# zizmor: ignore[artipacked] - required persisted credentials to push synced requirement changes back to remote
|
||||
- name: Checkout source code
|
||||
if: ${{ steps.dependabot-metadata.outputs.package-ecosystem == 'pip' }}
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
persist-credentials: true
|
||||
|
||||
4
.github/workflows/tag-release.yml
vendored
4
.github/workflows/tag-release.yml
vendored
@@ -54,7 +54,7 @@ jobs:
|
||||
fail-fast: false
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
@@ -120,7 +120,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
2
.github/workflows/tech-debt.yml
vendored
2
.github/workflows/tech-debt.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
||||
name: Generate Reports
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ maintainers:
|
||||
- name: craig-rueda
|
||||
email: craig@craigrueda.com
|
||||
url: https://github.com/craig-rueda
|
||||
version: 0.17.2 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
|
||||
version: 0.17.3 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
|
||||
dependencies:
|
||||
- name: postgresql
|
||||
version: 16.7.27
|
||||
|
||||
@@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs
|
||||
|
||||
# superset
|
||||
|
||||

|
||||

|
||||
|
||||
Apache Superset is a modern, enterprise-ready business intelligence web application
|
||||
|
||||
|
||||
@@ -108,8 +108,6 @@ else:
|
||||
{{ fail (printf "Unsupported database type: %s. Please use 'postgresql' or 'mysql'." .Values.supersetNode.connections.db_type) }}
|
||||
{{- end }}
|
||||
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = True
|
||||
|
||||
class CeleryConfig:
|
||||
imports = ("superset.sql_lab", )
|
||||
broker_url = CELERY_REDIS_URL
|
||||
|
||||
46
superset-frontend/package-lock.json
generated
46
superset-frontend/package-lock.json
generated
@@ -36,7 +36,7 @@
|
||||
"@great-expectations/jsonforms-antd-renderers": "^2.2.10",
|
||||
"@jsonforms/core": "^3.7.0",
|
||||
"@jsonforms/react": "^3.7.0",
|
||||
"@jsonforms/vanilla-renderers": "^3.7.0",
|
||||
"@jsonforms/vanilla-renderers": "^3.8.0",
|
||||
"@luma.gl/constants": "~9.2.5",
|
||||
"@luma.gl/core": "~9.2.5",
|
||||
"@luma.gl/engine": "~9.2.5",
|
||||
@@ -187,7 +187,7 @@
|
||||
"@storybook/test-runner": "0.24.4",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.41",
|
||||
"@swc/plugin-emotion": "^14.12.0",
|
||||
"@swc/plugin-emotion": "^14.13.0",
|
||||
"@swc/plugin-transform-imports": "^12.5.0",
|
||||
"@testing-library/dom": "^9.3.4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
@@ -272,7 +272,7 @@
|
||||
"source-map": "^0.7.6",
|
||||
"source-map-support": "^0.5.21",
|
||||
"speed-measure-webpack-plugin": "^1.6.0",
|
||||
"storybook": "10.4.5",
|
||||
"storybook": "10.4.6",
|
||||
"style-loader": "^4.0.0",
|
||||
"swc-loader": "^0.2.7",
|
||||
"terser-webpack-plugin": "^5.6.1",
|
||||
@@ -5387,41 +5387,41 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@jsonforms/core": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@jsonforms/core/-/core-3.7.0.tgz",
|
||||
"integrity": "sha512-CE9viWtwi9QWLqlWLeOul1/R1GRAyOA9y6OoUpsCc0FhyR+g5p29F3k0fUExHWxL0Sf4KHcXYkfhtqfRBPS8ww==",
|
||||
"version": "3.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@jsonforms/core/-/core-3.8.0.tgz",
|
||||
"integrity": "sha512-XSvaZuQSs/MceG5nDDcrE879onPHkGBy0xEuLeZMUkSM/M8wc1dEUrJtMOZVNSITocm9YXFY1qQ5gnsPP38zAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/json-schema": "^7.0.3",
|
||||
"ajv": "^8.6.1",
|
||||
"ajv": "^8.18.0",
|
||||
"ajv-formats": "^2.1.0",
|
||||
"lodash": "^4.17.21"
|
||||
}
|
||||
},
|
||||
"node_modules/@jsonforms/react": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@jsonforms/react/-/react-3.7.0.tgz",
|
||||
"integrity": "sha512-HkY7qAx8vW97wPEgZ7GxCB3iiXG1c95GuObxtcDHGPBJWMwnxWBnVYJmv5h7nthrInKsQKHZL5OusnC/sj/1GQ==",
|
||||
"version": "3.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@jsonforms/react/-/react-3.8.0.tgz",
|
||||
"integrity": "sha512-k81+yWLpCQl+3XizS1bLjXoBwYhW1OAkjSXFA8W5qNtfPZjSOXDgtiuMOGYDv4b60tu2e9RB8h2P2O7QhfkhiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.21"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@jsonforms/core": "3.7.0",
|
||||
"@jsonforms/core": "3.8.0",
|
||||
"react": "^16.12.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jsonforms/vanilla-renderers": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@jsonforms/vanilla-renderers/-/vanilla-renderers-3.7.0.tgz",
|
||||
"integrity": "sha512-RdXQGsheARUJVbaTe6SqGw9W4/yrm0BgUok6OKUj8krp1NF4fqXc5UbYGHFksMR/p7LCuoYHCtQzKLXEfxJbDw==",
|
||||
"version": "3.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@jsonforms/vanilla-renderers/-/vanilla-renderers-3.8.0.tgz",
|
||||
"integrity": "sha512-s75TG4hSYgYLN9IRVhYtGjijqyhVXijgDhb2WnMqY+Ki7MQkLn9U7yg/l89NEpwzWS1sv0DxKUxriqVUq382og==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.21"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@jsonforms/core": "3.7.0",
|
||||
"@jsonforms/react": "3.7.0",
|
||||
"@jsonforms/core": "3.8.0",
|
||||
"@jsonforms/react": "3.8.0",
|
||||
"react": "^16.12.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
@@ -10819,9 +10819,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/plugin-emotion": {
|
||||
"version": "14.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@swc/plugin-emotion/-/plugin-emotion-14.12.0.tgz",
|
||||
"integrity": "sha512-lyAQgTeDkowq/4+8JYaviVOL4jXSdObz+uuk84DjM0z4qoiMpI6xoDVp7/tjWeVjmLc2U6Qp3hDuwWMZ5xe88Q==",
|
||||
"version": "14.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@swc/plugin-emotion/-/plugin-emotion-14.13.0.tgz",
|
||||
"integrity": "sha512-UT1l9tr934HjnktUiMGbw1rWrIXUhAByTB0DwZJwHmS8KWox+wNBIK4ZkJ2tKVU/PnQZRni+R9e6xklFkmgSYg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -39527,9 +39527,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/storybook": {
|
||||
"version": "10.4.5",
|
||||
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.4.5.tgz",
|
||||
"integrity": "sha512-QZuv1gS9Tf9RMCjDw5JOfv1XSB5IhU0uhSKQNS7l/N9zDpmSydirCspkCNT9e0zkFfPkZ9vmQUTzHY/BA07saA==",
|
||||
"version": "10.4.6",
|
||||
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.4.6.tgz",
|
||||
"integrity": "sha512-6wkA6LxfDSSilloITsrFOJfsnw0mDUP2h8Ls+lRt8oRsudtz2RWFhLv+Toiwg6NW7hUpdTDc2hzR7DztJid6+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -39540,7 +39540,7 @@
|
||||
"@vitest/expect": "3.2.4",
|
||||
"@vitest/spy": "3.2.4",
|
||||
"@webcontainer/env": "^1.1.1",
|
||||
"esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0",
|
||||
"esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0 || ^0.28.0",
|
||||
"open": "^10.2.0",
|
||||
"oxc-parser": "^0.127.0",
|
||||
"oxc-resolver": "^11.19.1",
|
||||
|
||||
@@ -119,7 +119,7 @@
|
||||
"@great-expectations/jsonforms-antd-renderers": "^2.2.10",
|
||||
"@jsonforms/core": "^3.7.0",
|
||||
"@jsonforms/react": "^3.7.0",
|
||||
"@jsonforms/vanilla-renderers": "^3.7.0",
|
||||
"@jsonforms/vanilla-renderers": "^3.8.0",
|
||||
"@luma.gl/constants": "~9.2.5",
|
||||
"@luma.gl/core": "~9.2.5",
|
||||
"@luma.gl/engine": "~9.2.5",
|
||||
@@ -270,7 +270,7 @@
|
||||
"@storybook/test-runner": "0.24.4",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.41",
|
||||
"@swc/plugin-emotion": "^14.12.0",
|
||||
"@swc/plugin-emotion": "^14.13.0",
|
||||
"@swc/plugin-transform-imports": "^12.5.0",
|
||||
"@testing-library/dom": "^9.3.4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
@@ -355,7 +355,7 @@
|
||||
"source-map": "^0.7.6",
|
||||
"source-map-support": "^0.5.21",
|
||||
"speed-measure-webpack-plugin": "^1.6.0",
|
||||
"storybook": "10.4.5",
|
||||
"storybook": "10.4.6",
|
||||
"style-loader": "^4.0.0",
|
||||
"swc-loader": "^0.2.7",
|
||||
"terser-webpack-plugin": "^5.6.1",
|
||||
|
||||
@@ -22,21 +22,50 @@ under the License.
|
||||
[](https://www.npmjs.com/package/@superset-ui/core)
|
||||
[](https://libraries.io/npm/@superset-ui%2Fcore)
|
||||
|
||||
Description
|
||||
The core package for Apache Superset's frontend. It provides shared utilities,
|
||||
types, and abstractions used across all Superset chart plugins and UI components.
|
||||
|
||||
Key modules include:
|
||||
|
||||
- **query** — Utilities for building queries and calling the Superset API
|
||||
(including `makeApi`)
|
||||
- **number-format** — Number formatting helpers powered by d3-format
|
||||
- **time-format** — Time/date formatting helpers powered by d3-time-format
|
||||
- **connection** — `SupersetClient`, the HTTP client for the Superset REST API
|
||||
- **chart** — Base classes and types for building chart plugins
|
||||
|
||||
> **Note:** i18n utilities (`t`, `tn`, etc.) are no longer part of this package.
|
||||
> They now live in `@apache-superset/core`, imported from
|
||||
> `@apache-superset/core/translation`.
|
||||
|
||||
#### Example usage
|
||||
|
||||
```js
|
||||
import { xxx } from '@superset-ui/core';
|
||||
import { getNumberFormatter, makeApi } from '@superset-ui/core';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
|
||||
// Format a number
|
||||
const formatter = getNumberFormatter('.2f');
|
||||
console.log(formatter(1234.5)); // "1234.50"
|
||||
|
||||
// Translate a string
|
||||
console.log(t('Hello %s', 'world'));
|
||||
|
||||
// Call a Superset API endpoint
|
||||
const fetchDashboards = makeApi({
|
||||
method: 'GET',
|
||||
endpoint: '/api/v1/dashboard',
|
||||
});
|
||||
```
|
||||
|
||||
#### API
|
||||
|
||||
`fn(args)`
|
||||
|
||||
- TBD
|
||||
|
||||
### Development
|
||||
|
||||
`@data-ui/build-config` is used to manage the build configuration for this package including babel
|
||||
builds, jest testing, eslint, and prettier.
|
||||
`@data-ui/build-config` is used to manage the build configuration for this package
|
||||
including babel builds, jest testing, eslint, and prettier.
|
||||
|
||||
Run tests:
|
||||
|
||||
```bash
|
||||
cd superset-frontend
|
||||
npx jest packages/superset-ui-core
|
||||
```
|
||||
|
||||
@@ -21,10 +21,12 @@
|
||||
import d3 from 'd3';
|
||||
import { extent as d3Extent } from 'd3-array';
|
||||
import {
|
||||
ValueFormatter,
|
||||
getNumberFormatter,
|
||||
getSequentialSchemeRegistry,
|
||||
BinaryQueryObjectFilterClause,
|
||||
CategoricalColorNamespace,
|
||||
ContextMenuFilters,
|
||||
DataMask,
|
||||
ValueFormatter,
|
||||
getSequentialSchemeRegistry,
|
||||
} from '@superset-ui/core';
|
||||
import countries, { countryOptions } from './countries';
|
||||
|
||||
@@ -65,9 +67,28 @@ interface CountryMapProps {
|
||||
formatter: ValueFormatter;
|
||||
colorScheme: string;
|
||||
sliceId: number;
|
||||
onContextMenu?: (
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
data: ContextMenuFilters,
|
||||
) => void;
|
||||
emitCrossFilters?: boolean;
|
||||
setDataMask?: (dataMask: DataMask) => void;
|
||||
filterState?: {
|
||||
selectedValues?: string[];
|
||||
extraFormData?: {
|
||||
filters?: BinaryQueryObjectFilterClause[];
|
||||
};
|
||||
};
|
||||
entity?: string;
|
||||
}
|
||||
|
||||
const maps: Record<string, GeoData> = {};
|
||||
// Store zoom state per chart instance using element as key to enable garbage collection
|
||||
const zoomStates = new WeakMap<
|
||||
HTMLElement,
|
||||
{ scale: number; translate: [number, number] }
|
||||
>();
|
||||
|
||||
function CountryMap(element: HTMLElement, props: CountryMapProps) {
|
||||
const {
|
||||
@@ -75,10 +96,15 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
|
||||
width,
|
||||
height,
|
||||
country,
|
||||
entity,
|
||||
linearColorScheme,
|
||||
formatter,
|
||||
colorScheme,
|
||||
sliceId,
|
||||
filterState,
|
||||
emitCrossFilters,
|
||||
onContextMenu,
|
||||
setDataMask,
|
||||
} = props;
|
||||
|
||||
const container = element;
|
||||
@@ -99,7 +125,15 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
|
||||
? colorScale(d.country_id, sliceId)
|
||||
: (linearColorScale(d.metric) ?? '');
|
||||
});
|
||||
const colorFn = (d: GeoFeature) => colorMap[d.properties.ISO] || 'none';
|
||||
|
||||
const colorFn = (feature: GeoFeature): string => {
|
||||
if (!feature?.properties) return '#d9d9d9';
|
||||
const iso = feature.properties.ISO;
|
||||
return colorMap[iso] || '#d9d9d9';
|
||||
};
|
||||
|
||||
// Check if dashboard is in edit mode
|
||||
const isEditMode = container.closest('.dashboard--editing') !== null;
|
||||
|
||||
const path = d3.geo.path();
|
||||
const div = d3.select(container);
|
||||
@@ -112,6 +146,11 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
|
||||
.attr('width', width)
|
||||
.attr('height', height)
|
||||
.attr('preserveAspectRatio', 'xMidYMid meet');
|
||||
|
||||
// Only set grab cursor if not in edit mode
|
||||
if (!isEditMode) {
|
||||
svg.style('cursor', 'grab');
|
||||
}
|
||||
const backgroundRect = svg
|
||||
.append('rect')
|
||||
.attr('class', 'background')
|
||||
@@ -119,39 +158,64 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
|
||||
.attr('height', height);
|
||||
const g = svg.append('g');
|
||||
const mapLayer = g.append('g').classed('map-layer', true);
|
||||
// Add hover popup for tooltip
|
||||
const hoverPopup = div.append('div').attr('class', 'hover-popup');
|
||||
|
||||
let centered: GeoFeature | null;
|
||||
// Track mouse position to distinguish clicks from drags
|
||||
let mousedownPos: { x: number; y: number } | null = null;
|
||||
|
||||
const clicked = function clicked(d: GeoFeature) {
|
||||
const hasCenter = d && centered !== d;
|
||||
let x: number;
|
||||
let y: number;
|
||||
let k: number;
|
||||
const halfWidth = width / 2;
|
||||
const halfHeight = height / 2;
|
||||
// Cross-filter support
|
||||
const getCrossFilterDataMask = (
|
||||
source: GeoFeature,
|
||||
): { dataMask: DataMask; isCurrentValueSelected: boolean } | undefined => {
|
||||
if (!entity) return undefined;
|
||||
|
||||
if (hasCenter) {
|
||||
const centroid = path.centroid(d);
|
||||
[x, y] = centroid;
|
||||
k = 4;
|
||||
centered = d;
|
||||
} else {
|
||||
x = halfWidth;
|
||||
y = halfHeight;
|
||||
k = 1;
|
||||
centered = null;
|
||||
}
|
||||
const selected = filterState?.selectedValues || [];
|
||||
const iso = source?.properties?.ISO;
|
||||
if (!iso) return undefined;
|
||||
|
||||
g.transition()
|
||||
.duration(750)
|
||||
.attr(
|
||||
'transform',
|
||||
`translate(${halfWidth},${halfHeight})scale(${k})translate(${-x},${-y})`,
|
||||
);
|
||||
const isSelected = selected.includes(iso);
|
||||
const values = isSelected ? [] : [iso];
|
||||
|
||||
return {
|
||||
dataMask: {
|
||||
extraFormData: {
|
||||
filters: values.length
|
||||
? [{ col: entity, op: 'IN', val: values }]
|
||||
: [],
|
||||
},
|
||||
filterState: {
|
||||
value: values.length ? values : null,
|
||||
selectedValues: values.length ? values : null,
|
||||
},
|
||||
},
|
||||
isCurrentValueSelected: isSelected,
|
||||
};
|
||||
};
|
||||
|
||||
backgroundRect.on('click', clicked);
|
||||
// Handle right-click context menu
|
||||
const handleContextMenu = (feature: GeoFeature): void => {
|
||||
const pointerEvent = d3.event;
|
||||
|
||||
if (typeof onContextMenu === 'function') {
|
||||
pointerEvent?.preventDefault();
|
||||
}
|
||||
|
||||
const iso = feature?.properties?.ISO;
|
||||
if (!iso || typeof onContextMenu !== 'function' || !entity) return;
|
||||
|
||||
const drillVal = iso;
|
||||
const drillToDetailFilters = [
|
||||
{ col: entity, op: '==', val: drillVal, formattedVal: drillVal },
|
||||
];
|
||||
const drillByFilters = [{ col: entity, op: '==', val: drillVal }];
|
||||
|
||||
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, {
|
||||
drillToDetail: drillToDetailFilters,
|
||||
crossFilter: getCrossFilterDataMask(feature),
|
||||
drillBy: { filters: drillByFilters, groupbyFieldName: 'entity' },
|
||||
});
|
||||
};
|
||||
|
||||
const getNameOfRegion = function getNameOfRegion(
|
||||
feature: GeoFeature,
|
||||
@@ -165,7 +229,7 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
|
||||
return '';
|
||||
};
|
||||
|
||||
const updatePopupPosition = () => {
|
||||
const updatePopupPosition = (): void => {
|
||||
const svgHeight = svg.node().getBoundingClientRect().height;
|
||||
const [x, y] = d3.mouse(svg.node());
|
||||
hoverPopup
|
||||
@@ -175,36 +239,135 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
|
||||
.classed('popup-at-bottom', y > (svgHeight * 2) / 3);
|
||||
};
|
||||
|
||||
const mouseenter = function mouseenter(this: SVGPathElement, d: GeoFeature) {
|
||||
const mouseenter = function mouseenter(
|
||||
this: SVGPathElement,
|
||||
d: GeoFeature,
|
||||
): void {
|
||||
// Darken color
|
||||
let c: string = colorFn(d);
|
||||
if (c !== 'none') {
|
||||
if (c) {
|
||||
c = d3.rgb(c).darker().toString();
|
||||
}
|
||||
d3.select(this).style('fill', c);
|
||||
// Display information popup
|
||||
const result = data.filter(
|
||||
region => region.country_id === d.properties.ISO,
|
||||
);
|
||||
|
||||
// Display information popup
|
||||
const result = data.filter(r => r.country_id === d?.properties?.ISO);
|
||||
const regionName = escapeHtml(getNameOfRegion(d));
|
||||
const metricValue =
|
||||
result.length > 0 ? escapeHtml(String(formatter(result[0].metric))) : '';
|
||||
hoverPopup
|
||||
.style('display', 'block')
|
||||
.html(
|
||||
`<div><strong>${getNameOfRegion(d)}</strong><br>${result.length > 0 ? formatter(result[0].metric) : ''}</div>`,
|
||||
);
|
||||
.html(`<div><strong>${regionName}</strong><br>${metricValue}</div>`);
|
||||
updatePopupPosition();
|
||||
};
|
||||
|
||||
const mousemove = function mousemove() {
|
||||
// Mouse move handler to update tooltip position
|
||||
const mousemove = function mousemove(): void {
|
||||
updatePopupPosition();
|
||||
};
|
||||
|
||||
const mouseout = function mouseout(this: SVGPathElement) {
|
||||
d3.select(this).style('fill', colorFn);
|
||||
const mouseout = function mouseout(this: SVGPathElement): void {
|
||||
d3.select(this).style('fill', (d: GeoFeature) => colorFn(d));
|
||||
hoverPopup.style('display', 'none');
|
||||
};
|
||||
|
||||
function drawMap(mapData: GeoData) {
|
||||
// Only enable zoom if not in edit mode
|
||||
if (!isEditMode) {
|
||||
// Zoom with panning bounds
|
||||
const zoom = d3.behavior
|
||||
.zoom()
|
||||
.scaleExtent([1, 4])
|
||||
.on('zoomstart', () => {
|
||||
svg.style('cursor', 'grabbing');
|
||||
})
|
||||
.on('zoom', () => {
|
||||
const { translate, scale } = d3.event;
|
||||
let [tx, ty] = translate;
|
||||
|
||||
const scaledW = width * scale;
|
||||
const scaledH = height * scale;
|
||||
const minX = Math.min(0, width - scaledW);
|
||||
const maxX = 0;
|
||||
const minY = Math.min(0, height - scaledH);
|
||||
const maxY = 0;
|
||||
|
||||
tx = Math.max(Math.min(tx, maxX), minX);
|
||||
ty = Math.max(Math.min(ty, maxY), minY);
|
||||
|
||||
// Sync D3's internal translate state with the clamped values so the
|
||||
// next wheel/zoom event starts from the constrained position rather
|
||||
// than the unclamped one (otherwise the view jumps).
|
||||
zoom.translate([tx, ty]);
|
||||
|
||||
g.attr('transform', `translate(${tx}, ${ty}) scale(${scale})`);
|
||||
const prev = zoomStates.get(element);
|
||||
const changed =
|
||||
!prev ||
|
||||
prev.scale !== scale ||
|
||||
prev.translate[0] !== tx ||
|
||||
prev.translate[1] !== ty;
|
||||
if (changed) {
|
||||
zoomStates.set(element, { scale, translate: [tx, ty] });
|
||||
}
|
||||
})
|
||||
.on('zoomend', () => {
|
||||
svg.style('cursor', 'grab');
|
||||
});
|
||||
|
||||
d3.select(svg.node()).call(zoom);
|
||||
|
||||
// Restore previous zoom state if it exists
|
||||
const savedZoom = zoomStates.get(element);
|
||||
if (savedZoom) {
|
||||
const { scale, translate } = savedZoom;
|
||||
zoom.scale(scale).translate(translate);
|
||||
g.attr(
|
||||
'transform',
|
||||
`translate(${translate[0]}, ${translate[1]}) scale(${scale})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Visual highlighting for selected regions
|
||||
function highlightSelectedRegion(
|
||||
selectedValues: string[] | null = null,
|
||||
): void {
|
||||
const selected = selectedValues || filterState?.selectedValues || [];
|
||||
|
||||
mapLayer
|
||||
.selectAll('path.region')
|
||||
.style('fill-opacity', (d: GeoFeature) => {
|
||||
const iso = d?.properties?.ISO;
|
||||
return selected.length === 0 || selected.includes(iso) ? 1 : 0.3;
|
||||
})
|
||||
.style('stroke', (d: GeoFeature) => {
|
||||
const iso = d?.properties?.ISO;
|
||||
return selected.includes(iso) ? '#222' : null;
|
||||
})
|
||||
.style('stroke-width', (d: GeoFeature) => {
|
||||
const iso = d?.properties?.ISO;
|
||||
return selected.includes(iso) ? '1.5px' : '0.5px';
|
||||
});
|
||||
}
|
||||
|
||||
// Click handler for cross-filters
|
||||
const handleClick = (feature: GeoFeature): void => {
|
||||
if (!entity || !emitCrossFilters || typeof setDataMask !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = getCrossFilterDataMask(feature);
|
||||
if (!result) return;
|
||||
|
||||
const { dataMask, isCurrentValueSelected } = result;
|
||||
setDataMask(dataMask);
|
||||
|
||||
const iso = feature?.properties?.ISO;
|
||||
const newSelection = isCurrentValueSelected || !iso ? [] : [iso];
|
||||
highlightSelectedRegion(newSelection);
|
||||
};
|
||||
|
||||
function drawMap(mapData: GeoData): void {
|
||||
const { features } = mapData;
|
||||
const center = d3.geo.centroid(mapData);
|
||||
const scale = 100;
|
||||
@@ -215,13 +378,11 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
|
||||
.translate([width / 2, height / 2]);
|
||||
path.projection(projection);
|
||||
|
||||
// Compute scale that fits container.
|
||||
const bounds = path.bounds(mapData);
|
||||
const hscale = (scale * width) / (bounds[1][0] - bounds[0][0]);
|
||||
const vscale = (scale * height) / (bounds[1][1] - bounds[0][1]);
|
||||
const newScale = hscale < vscale ? hscale : vscale;
|
||||
const newScale = Math.min(hscale, vscale);
|
||||
|
||||
// Compute bounds and offset using the updated scale.
|
||||
projection.scale(newScale);
|
||||
const newBounds = path.bounds(mapData);
|
||||
projection.translate([
|
||||
@@ -229,20 +390,45 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
|
||||
height - (newBounds[0][1] + newBounds[1][1]) / 2,
|
||||
]);
|
||||
|
||||
// Draw each province as a path
|
||||
mapLayer
|
||||
.selectAll('path')
|
||||
.data(features)
|
||||
const sel = mapLayer.selectAll('path.region').data(features);
|
||||
|
||||
sel
|
||||
.enter()
|
||||
.append('path')
|
||||
.attr('d', path)
|
||||
.attr('class', 'region')
|
||||
.attr('vector-effect', 'non-scaling-stroke')
|
||||
.attr('vector-effect', 'non-scaling-stroke');
|
||||
|
||||
// Apply attributes and event handlers to all elements (enter + update)
|
||||
mapLayer
|
||||
.selectAll('path.region')
|
||||
.attr('d', path)
|
||||
.style('fill', colorFn)
|
||||
.on('mouseenter', mouseenter)
|
||||
.on('mousemove', mousemove)
|
||||
.on('mouseout', mouseout)
|
||||
.on('click', clicked);
|
||||
.on('contextmenu', handleContextMenu)
|
||||
.on('mousedown', function mousedown() {
|
||||
const pos = d3.mouse(svg.node());
|
||||
mousedownPos = { x: pos[0], y: pos[1] };
|
||||
})
|
||||
.on('click', function click(feature: GeoFeature) {
|
||||
if (mousedownPos) {
|
||||
const pos = d3.mouse(svg.node());
|
||||
const dx = Math.abs(pos[0] - mousedownPos.x);
|
||||
const dy = Math.abs(pos[1] - mousedownPos.y);
|
||||
const dragThreshold = 5;
|
||||
|
||||
if (dx < dragThreshold && dy < dragThreshold) {
|
||||
handleClick(feature);
|
||||
}
|
||||
|
||||
mousedownPos = null;
|
||||
}
|
||||
});
|
||||
|
||||
sel.exit().remove();
|
||||
|
||||
highlightSelectedRegion();
|
||||
}
|
||||
|
||||
const map = maps[country];
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { ChartMetadata, ChartPlugin } from '@superset-ui/core';
|
||||
import { ChartMetadata, ChartPlugin, Behavior } from '@superset-ui/core';
|
||||
import transformProps from './transformProps';
|
||||
import exampleUsa from './images/exampleUsa.jpg';
|
||||
import exampleUsaDark from './images/exampleUsa-dark.jpg';
|
||||
@@ -49,6 +49,11 @@ const metadata = new ChartMetadata({
|
||||
thumbnail,
|
||||
thumbnailDark,
|
||||
useLegacyApi: true,
|
||||
behaviors: [
|
||||
Behavior.InteractiveChart,
|
||||
Behavior.DrillToDetail,
|
||||
Behavior.DrillBy,
|
||||
],
|
||||
});
|
||||
|
||||
export default class CountryMapChartPlugin extends ChartPlugin {
|
||||
|
||||
@@ -19,8 +19,18 @@
|
||||
import { ChartProps, getValueFormatter } from '@superset-ui/core';
|
||||
|
||||
export default function transformProps(chartProps: ChartProps) {
|
||||
const { width, height, formData, queriesData, datasource } = chartProps;
|
||||
const {
|
||||
width,
|
||||
height,
|
||||
formData,
|
||||
queriesData,
|
||||
datasource,
|
||||
hooks = {},
|
||||
filterState,
|
||||
emitCrossFilters,
|
||||
} = chartProps;
|
||||
const {
|
||||
entity,
|
||||
linearColorScheme,
|
||||
numberFormat,
|
||||
currencyFormat,
|
||||
@@ -49,6 +59,8 @@ export default function transformProps(chartProps: ChartProps) {
|
||||
detectedCurrency,
|
||||
);
|
||||
|
||||
const { onContextMenu, setDataMask } = hooks;
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
@@ -59,5 +71,10 @@ export default function transformProps(chartProps: ChartProps) {
|
||||
colorScheme,
|
||||
sliceId,
|
||||
formatter,
|
||||
entity,
|
||||
onContextMenu,
|
||||
setDataMask,
|
||||
emitCrossFilters,
|
||||
filterState,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -133,10 +133,11 @@ describe('CountryMap (legacy d3)', () => {
|
||||
expect(popup!).toHaveStyle({ display: 'none' });
|
||||
});
|
||||
|
||||
test('shows tooltip on mouseenter/mousemove/mouseout', async () => {
|
||||
test('emits a cross-filter data mask when a region is clicked', () => {
|
||||
d3Any.json.mockImplementation((_url: string, cb: D3JsonCallback) =>
|
||||
cb(null, mockMapData),
|
||||
);
|
||||
const setDataMask = jest.fn();
|
||||
|
||||
render(
|
||||
<ReactCountryMap
|
||||
@@ -147,19 +148,101 @@ describe('CountryMap (legacy d3)', () => {
|
||||
linearColorScheme="bnbColors"
|
||||
colorScheme=""
|
||||
formatter={jest.fn().mockReturnValue('100')}
|
||||
entity="country_code"
|
||||
emitCrossFilters
|
||||
setDataMask={setDataMask}
|
||||
filterState={{ selectedValues: [] }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const region = document.querySelector('path.region');
|
||||
expect(region).not.toBeNull();
|
||||
|
||||
const popup = document.querySelector('.hover-popup');
|
||||
expect(popup).not.toBeNull();
|
||||
// A click is only treated as a selection when it follows a mousedown
|
||||
// without dragging beyond the threshold (d3.mouse is mocked to a fixed
|
||||
// position, so the down/up positions match).
|
||||
fireEvent.mouseDown(region!);
|
||||
fireEvent.click(region!);
|
||||
|
||||
fireEvent.mouseEnter(region!);
|
||||
expect(popup!).toHaveStyle({ display: 'block' });
|
||||
expect(setDataMask).toHaveBeenCalledTimes(1);
|
||||
expect(setDataMask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
extraFormData: {
|
||||
filters: [{ col: 'country_code', op: 'IN', val: ['CAN'] }],
|
||||
},
|
||||
filterState: expect.objectContaining({ value: ['CAN'] }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
fireEvent.mouseOut(region!);
|
||||
expect(popup!).toHaveStyle({ display: 'none' });
|
||||
test('does not emit a cross-filter when emitCrossFilters is disabled', () => {
|
||||
d3Any.json.mockImplementation((_url: string, cb: D3JsonCallback) =>
|
||||
cb(null, mockMapData),
|
||||
);
|
||||
const setDataMask = jest.fn();
|
||||
|
||||
render(
|
||||
<ReactCountryMap
|
||||
width={500}
|
||||
height={300}
|
||||
data={[{ country_id: 'CAN', metric: 100 }]}
|
||||
country="canada"
|
||||
linearColorScheme="bnbColors"
|
||||
colorScheme=""
|
||||
formatter={jest.fn().mockReturnValue('100')}
|
||||
entity="country_code"
|
||||
emitCrossFilters={false}
|
||||
setDataMask={setDataMask}
|
||||
filterState={{ selectedValues: [] }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const region = document.querySelector('path.region');
|
||||
fireEvent.mouseDown(region!);
|
||||
fireEvent.click(region!);
|
||||
|
||||
expect(setDataMask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('opens the context menu with drill-by keyed on the entity control', () => {
|
||||
d3Any.json.mockImplementation((_url: string, cb: D3JsonCallback) =>
|
||||
cb(null, mockMapData),
|
||||
);
|
||||
const onContextMenu = jest.fn();
|
||||
|
||||
render(
|
||||
<ReactCountryMap
|
||||
width={500}
|
||||
height={300}
|
||||
data={[{ country_id: 'CAN', metric: 100 }]}
|
||||
country="canada"
|
||||
linearColorScheme="bnbColors"
|
||||
colorScheme=""
|
||||
formatter={jest.fn().mockReturnValue('100')}
|
||||
entity="country_code"
|
||||
onContextMenu={onContextMenu}
|
||||
filterState={{ selectedValues: [] }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const region = document.querySelector('path.region');
|
||||
expect(region).not.toBeNull();
|
||||
|
||||
fireEvent.contextMenu(region!, { clientX: 123, clientY: 45 });
|
||||
|
||||
expect(onContextMenu).toHaveBeenCalledTimes(1);
|
||||
const [[clientX, clientY, payload]] = onContextMenu.mock.calls;
|
||||
expect(clientX).toBe(123);
|
||||
expect(clientY).toBe(45);
|
||||
expect(payload.drillToDetail).toEqual([
|
||||
{ col: 'country_code', op: '==', val: 'CAN', formattedVal: 'CAN' },
|
||||
]);
|
||||
// groupbyFieldName must be the form-data control key ('entity'), not the
|
||||
// selected column value ('country_code'), so DrillByModal can map the
|
||||
// selection back to the chart control.
|
||||
expect(payload.drillBy).toEqual({
|
||||
filters: [{ col: 'country_code', op: '==', val: 'CAN' }],
|
||||
groupbyFieldName: 'entity',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ChartProps } from '@superset-ui/core';
|
||||
import transformProps from '../src/transformProps';
|
||||
|
||||
const onContextMenu = jest.fn();
|
||||
const setDataMask = jest.fn();
|
||||
|
||||
const createProps = (formDataOverrides = {}, chartPropsOverrides = {}) =>
|
||||
({
|
||||
width: 800,
|
||||
height: 600,
|
||||
formData: {
|
||||
entity: 'country_code',
|
||||
linearColorScheme: 'bnbColors',
|
||||
numberFormat: '.2f',
|
||||
selectCountry: 'France',
|
||||
colorScheme: '',
|
||||
sliceId: 1,
|
||||
metric: 'count',
|
||||
...formDataOverrides,
|
||||
},
|
||||
queriesData: [{ data: [{ country_id: 'FRA', metric: 10 }] }],
|
||||
datasource: { currencyFormats: {}, columnFormats: {} },
|
||||
hooks: { onContextMenu, setDataMask },
|
||||
filterState: { selectedValues: ['FRA'] },
|
||||
emitCrossFilters: true,
|
||||
...chartPropsOverrides,
|
||||
}) as unknown as ChartProps;
|
||||
|
||||
test('forwards cross-filter hooks and state to the chart', () => {
|
||||
const transformed = transformProps(createProps());
|
||||
|
||||
expect(transformed).toMatchObject({
|
||||
width: 800,
|
||||
height: 600,
|
||||
entity: 'country_code',
|
||||
onContextMenu,
|
||||
setDataMask,
|
||||
emitCrossFilters: true,
|
||||
filterState: { selectedValues: ['FRA'] },
|
||||
data: [{ country_id: 'FRA', metric: 10 }],
|
||||
});
|
||||
});
|
||||
|
||||
test('lowercases the selected country for map lookup', () => {
|
||||
const transformed = transformProps(createProps());
|
||||
expect(transformed.country).toBe('france');
|
||||
});
|
||||
|
||||
test('passes a null country when none is selected', () => {
|
||||
const transformed = transformProps(createProps({ selectCountry: undefined }));
|
||||
expect(transformed.country).toBeNull();
|
||||
});
|
||||
|
||||
test('defaults hooks to an empty object when none are provided', () => {
|
||||
const transformed = transformProps(createProps({}, { hooks: undefined }));
|
||||
expect(transformed.onContextMenu).toBeUndefined();
|
||||
expect(transformed.setDataMask).toBeUndefined();
|
||||
});
|
||||
@@ -44,3 +44,8 @@ export const FILTER_CONDITION_BODY_INDEX = {
|
||||
} as const;
|
||||
|
||||
export const ROW_NUMBER_COL_ID = '__row_number__';
|
||||
|
||||
// Non-enumerable key used to attach a row's basic (increase/decrease) color
|
||||
// formatter to the row data object so it travels with the row through AG Grid
|
||||
// client-side sorting (#105973).
|
||||
export const BASIC_COLOR_FORMATTERS_ROW_KEY = '__basicColorFormatters__';
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { CustomCellRendererProps } from '@superset-ui/core/components/ThemedAgGridReact';
|
||||
import { BasicColorFormatterType, InputColumn, ValueRange } from '../types';
|
||||
import { useIsDark } from '../utils/useTableTheme';
|
||||
import getRowBasicColorFormatter from '../utils/getRowBasicColorFormatter';
|
||||
|
||||
const StyledTotalCell = styled.div`
|
||||
${() => `
|
||||
@@ -163,13 +164,13 @@ export const NumericCellRenderer = (
|
||||
let arrow = '';
|
||||
let arrowColor = '';
|
||||
if (hasBasicColorFormatters && col?.metricName) {
|
||||
arrow =
|
||||
basicColorFormatters?.[node?.rowIndex as number]?.[col.metricName]
|
||||
?.mainArrow;
|
||||
arrowColor =
|
||||
basicColorFormatters?.[node?.rowIndex as number]?.[
|
||||
col.metricName
|
||||
]?.arrowColor?.toLowerCase();
|
||||
const rowFormatter = getRowBasicColorFormatter(
|
||||
node,
|
||||
node?.rowIndex,
|
||||
basicColorFormatters,
|
||||
)?.[col.metricName];
|
||||
arrow = rowFormatter?.mainArrow;
|
||||
arrowColor = rowFormatter?.arrowColor?.toLowerCase();
|
||||
}
|
||||
|
||||
const alignment =
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
} from '@superset-ui/chart-controls';
|
||||
import isEqualColumns from './utils/isEqualColumns';
|
||||
import DateWithFormatter from './utils/DateWithFormatter';
|
||||
import { BASIC_COLOR_FORMATTERS_ROW_KEY } from './consts';
|
||||
import {
|
||||
DataColumnMeta,
|
||||
TableChartProps,
|
||||
@@ -703,6 +704,23 @@ const transformProps = (
|
||||
|
||||
const basicColorFormatters =
|
||||
comparisonColorEnabled && getBasicColorFormatter(baseQuery?.data, columns);
|
||||
|
||||
// Attach each row's basic (increase/decrease) color formatter to the row data
|
||||
// object so it travels with the row through AG Grid client-side sorting.
|
||||
// basicColorFormatters is built in the original query order and was previously
|
||||
// read positionally by the displayed rowIndex, which applied colors to the
|
||||
// wrong rows once the table was sorted (#105973). The property is
|
||||
// non-enumerable so it never leaks into exports, cross-filters or spreads.
|
||||
if (basicColorFormatters) {
|
||||
passedData.forEach((row, index) => {
|
||||
Object.defineProperty(row, BASIC_COLOR_FORMATTERS_ROW_KEY, {
|
||||
value: basicColorFormatters[index],
|
||||
enumerable: false,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
const columnColorFormatters =
|
||||
getColorFormatters(conditionalFormatting, passedData, theme) ?? [];
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from '@superset-ui/chart-controls';
|
||||
import { CellClassParams } from '@superset-ui/core/components/ThemedAgGridReact';
|
||||
import { BasicColorFormatterType, InputColumn } from '../types';
|
||||
import getRowBasicColorFormatter from './getRowBasicColorFormatter';
|
||||
|
||||
type CellStyleParams = CellClassParams & {
|
||||
hasColumnColorFormatters: boolean | undefined;
|
||||
@@ -84,8 +85,11 @@ const getCellStyle = (params: CellStyleParams) => {
|
||||
col?.metricName &&
|
||||
node?.rowPinned !== 'bottom'
|
||||
) {
|
||||
backgroundColor =
|
||||
basicColorFormatters?.[rowIndex]?.[col.metricName]?.backgroundColor;
|
||||
backgroundColor = getRowBasicColorFormatter(
|
||||
node,
|
||||
rowIndex,
|
||||
basicColorFormatters,
|
||||
)?.[col.metricName]?.backgroundColor;
|
||||
}
|
||||
|
||||
const textAlign =
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 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 { BASIC_COLOR_FORMATTERS_ROW_KEY } from '../consts';
|
||||
import { BasicColorFormatterType } from '../types';
|
||||
|
||||
type RowFormatters = { [key: string]: BasicColorFormatterType };
|
||||
|
||||
/**
|
||||
* Resolves the basic (increase/decrease) color formatters for a given AG Grid
|
||||
* row node.
|
||||
*
|
||||
* The formatter is attached to the row data object itself (see transformProps),
|
||||
* so it follows the row through client-side sorting. Looking it up positionally
|
||||
* by the displayed `rowIndex` was wrong once the user sorted the table, because
|
||||
* the displayed index no longer matched the original data order (#105973).
|
||||
*
|
||||
* Falls back to the positional array for safety when no attached formatter is
|
||||
* present.
|
||||
*/
|
||||
export default function getRowBasicColorFormatter(
|
||||
node: { data?: Record<string, unknown> } | undefined,
|
||||
rowIndex: number | null | undefined,
|
||||
basicColorFormatters: RowFormatters[] | undefined,
|
||||
): RowFormatters | undefined {
|
||||
const attached = node?.data?.[BASIC_COLOR_FORMATTERS_ROW_KEY] as
|
||||
| RowFormatters
|
||||
| undefined;
|
||||
if (attached) {
|
||||
return attached;
|
||||
}
|
||||
if (rowIndex == null) {
|
||||
return undefined;
|
||||
}
|
||||
return basicColorFormatters?.[rowIndex];
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 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 getRowBasicColorFormatter from '../../src/utils/getRowBasicColorFormatter';
|
||||
import { BASIC_COLOR_FORMATTERS_ROW_KEY } from '../../src/consts';
|
||||
|
||||
const red = { sales: { backgroundColor: 'red', mainArrow: '↓', arrowColor: 'red' } };
|
||||
const green = {
|
||||
sales: { backgroundColor: 'green', mainArrow: '↑', arrowColor: 'green' },
|
||||
};
|
||||
|
||||
// Positional array in the original (unsorted) query order: row 0 -> green, row 1 -> red.
|
||||
const positional = [green, red] as any;
|
||||
|
||||
test('uses the formatter attached to the row, not the displayed rowIndex (#105973)', () => {
|
||||
// After sorting, the row whose original formatter is `red` is displayed first
|
||||
// (rowIndex 0). The positional lookup would wrongly return `green`.
|
||||
const rowData: Record<string, unknown> = { sales: 5 };
|
||||
Object.defineProperty(rowData, BASIC_COLOR_FORMATTERS_ROW_KEY, {
|
||||
value: red,
|
||||
enumerable: false,
|
||||
});
|
||||
const node = { data: rowData };
|
||||
|
||||
expect(getRowBasicColorFormatter(node, 0, positional)).toBe(red);
|
||||
expect(
|
||||
getRowBasicColorFormatter(node, 0, positional)?.sales.backgroundColor,
|
||||
).toBe('red');
|
||||
});
|
||||
|
||||
test('falls back to positional lookup when no formatter is attached', () => {
|
||||
const node = { data: { sales: 5 } };
|
||||
expect(getRowBasicColorFormatter(node, 1, positional)).toBe(red);
|
||||
});
|
||||
|
||||
test('returns undefined when nothing matches', () => {
|
||||
expect(getRowBasicColorFormatter(undefined, null, positional)).toBeUndefined();
|
||||
expect(
|
||||
getRowBasicColorFormatter({ data: {} }, null, positional),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test('attached formatter is non-enumerable so it does not leak into the row', () => {
|
||||
const rowData: Record<string, unknown> = { sales: 5 };
|
||||
Object.defineProperty(rowData, BASIC_COLOR_FORMATTERS_ROW_KEY, {
|
||||
value: green,
|
||||
enumerable: false,
|
||||
});
|
||||
expect(Object.keys(rowData)).toEqual(['sales']);
|
||||
});
|
||||
@@ -231,6 +231,56 @@ describe('BigNumberTotal transformProps', () => {
|
||||
expect(result.headerFormatter(500)).toBe('$500');
|
||||
});
|
||||
|
||||
test('should pass through non-numeric raw string when parseMetricValue returns null (e.g. VARCHAR MAX)', () => {
|
||||
const { parseMetricValue } = jest.requireMock('../utils');
|
||||
parseMetricValue.mockReturnValueOnce(null);
|
||||
|
||||
const chartProps = {
|
||||
width: 400,
|
||||
height: 300,
|
||||
queriesData: [
|
||||
{
|
||||
data: [{ value: 'some-varchar-result' }],
|
||||
coltypes: [GenericDataType.String],
|
||||
},
|
||||
],
|
||||
formData: baseFormData,
|
||||
rawFormData: baseRawFormData,
|
||||
hooks: baseHooks,
|
||||
datasource: baseDatasource,
|
||||
};
|
||||
|
||||
const result = transformProps(
|
||||
chartProps as unknown as BigNumberTotalChartProps,
|
||||
);
|
||||
expect(result.bigNumber).toBe('some-varchar-result');
|
||||
});
|
||||
|
||||
test('should pass through numeric-looking VARCHAR string literally (e.g. "123")', () => {
|
||||
const { parseMetricValue } = jest.requireMock('../utils');
|
||||
parseMetricValue.mockReturnValueOnce(null);
|
||||
|
||||
const chartProps = {
|
||||
width: 400,
|
||||
height: 300,
|
||||
queriesData: [
|
||||
{
|
||||
data: [{ value: '123' }],
|
||||
coltypes: [GenericDataType.String],
|
||||
},
|
||||
],
|
||||
formData: baseFormData,
|
||||
rawFormData: baseRawFormData,
|
||||
hooks: baseHooks,
|
||||
datasource: baseDatasource,
|
||||
};
|
||||
|
||||
const result = transformProps(
|
||||
chartProps as unknown as BigNumberTotalChartProps,
|
||||
);
|
||||
expect(result.bigNumber).toBe('123');
|
||||
});
|
||||
|
||||
test('should propagate colorThresholdFormatters from getColorFormatters', () => {
|
||||
// Override the getColorFormatters mock to return specific value
|
||||
const mockFormatters = [{ formatter: 'red' }];
|
||||
|
||||
@@ -79,8 +79,15 @@ export default function transformProps(
|
||||
const formattedSubtitleFontSize = subtitle?.trim()
|
||||
? (subtitleFontSize ?? PROPORTION.SUBHEADER)
|
||||
: (subheaderFontSize ?? subtitleFontSize ?? PROPORTION.SUBHEADER);
|
||||
const rawValue = data.length === 0 ? null : data[0][metricName];
|
||||
const parsedValue = rawValue == null ? null : parseMetricValue(rawValue);
|
||||
|
||||
const bigNumber =
|
||||
data.length === 0 ? null : parseMetricValue(data[0][metricName]);
|
||||
parsedValue === null &&
|
||||
typeof rawValue === 'string' &&
|
||||
rawValue.trim() !== ''
|
||||
? rawValue
|
||||
: parsedValue;
|
||||
|
||||
let metricEntry: Metric | undefined;
|
||||
if (chartProps.datasource?.metrics) {
|
||||
|
||||
@@ -189,8 +189,10 @@ function BigNumberVis({
|
||||
text = t('No data');
|
||||
} else if (typeof bigNumber === 'number') {
|
||||
text = headerFormatter(bigNumber);
|
||||
} else if (typeof bigNumber === 'string') {
|
||||
text = bigNumber;
|
||||
} else {
|
||||
// For string/boolean/Date values, convert to number if possible, else show as string
|
||||
// For boolean/Date values, convert to number if possible, else show as string
|
||||
const numValue = Number(bigNumber);
|
||||
text = Number.isNaN(numValue)
|
||||
? String(bigNumber)
|
||||
|
||||
@@ -331,10 +331,16 @@ export default function transformProps(
|
||||
type: legendType,
|
||||
});
|
||||
|
||||
const chartPadding = getChartPadding(
|
||||
showLegend,
|
||||
legendOrientation,
|
||||
effectiveLegendMargin,
|
||||
);
|
||||
|
||||
const series: RadarSeriesOption[] = [
|
||||
{
|
||||
type: 'radar',
|
||||
...getChartPadding(showLegend, legendOrientation, effectiveLegendMargin),
|
||||
...chartPadding,
|
||||
animation: false,
|
||||
emphasis: {
|
||||
label: {
|
||||
@@ -361,6 +367,15 @@ export default function transformProps(
|
||||
numberFormatter,
|
||||
);
|
||||
|
||||
const centerX = width
|
||||
? ((width + chartPadding.left - chartPadding.right) / 2 / width) * 100
|
||||
: 50;
|
||||
const centerY = height
|
||||
? ((height + chartPadding.top - chartPadding.bottom) / 2 / height) * 100
|
||||
: 50;
|
||||
|
||||
const radarCenter: [string, string] = [`${centerX}%`, `${centerY}%`];
|
||||
|
||||
const echartOptions: EChartsCoreOption = {
|
||||
grid: {
|
||||
...defaultGrid,
|
||||
@@ -390,6 +405,7 @@ export default function transformProps(
|
||||
color: theme.colorSplit,
|
||||
},
|
||||
},
|
||||
center: radarCenter,
|
||||
splitArea: {
|
||||
show: true,
|
||||
areaStyle: {
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from '../../../../spec/helpers/testing-library';
|
||||
import { AxisType } from '@superset-ui/core';
|
||||
import type { EChartsCoreOption } from 'echarts/core';
|
||||
import type { ECElementEvent } from 'echarts/types/src/util/types';
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
LegendOrientation,
|
||||
@@ -202,11 +203,15 @@ const defaultProps: TimeseriesChartTransformedProps = {
|
||||
onFocusedSeries: jest.fn(),
|
||||
};
|
||||
|
||||
function getLatestHeight() {
|
||||
function getLatestEchartProps() {
|
||||
const lastCall = mockEchart.mock.calls.at(-1);
|
||||
expect(lastCall).toBeDefined();
|
||||
const [props] = lastCall as [EchartsProps];
|
||||
return props.height;
|
||||
return props;
|
||||
}
|
||||
|
||||
function getLatestHeight() {
|
||||
return getLatestEchartProps().height;
|
||||
}
|
||||
|
||||
test('observes extra control height changes when ResizeObserver is available', async () => {
|
||||
@@ -335,6 +340,7 @@ test('emits cross-filter on X-axis value when no dimensions and categorical X-ax
|
||||
const clickHandler = props.eventHandlers?.click;
|
||||
if (clickHandler) {
|
||||
clickHandler({
|
||||
componentType: 'series',
|
||||
seriesName: 'Sales', // This is the metric name
|
||||
data: ['Product A', 100], // X-axis value is 'Product A'
|
||||
name: 'Product A',
|
||||
@@ -361,6 +367,149 @@ test('emits cross-filter on X-axis value when no dimensions and categorical X-ax
|
||||
}
|
||||
});
|
||||
|
||||
test('emits cross-filter on category value for horizontal bar clicks', async () => {
|
||||
const setDataMaskMock = jest.fn();
|
||||
|
||||
render(
|
||||
<EchartsTimeseries
|
||||
{...defaultProps}
|
||||
emitCrossFilters
|
||||
setDataMask={setDataMaskMock}
|
||||
formData={{
|
||||
...defaultFormData,
|
||||
orientation: OrientationType.Horizontal,
|
||||
}}
|
||||
xAxis={{
|
||||
label: 'category_column',
|
||||
type: AxisType.Category,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const clickHandler = getLatestEchartProps().eventHandlers?.click;
|
||||
expect(clickHandler).toBeDefined();
|
||||
clickHandler?.({
|
||||
componentType: 'series',
|
||||
seriesName: 'Sales',
|
||||
data: [100, 'Product A'],
|
||||
name: 'Product A',
|
||||
dataIndex: 0,
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(setDataMaskMock).toHaveBeenCalled();
|
||||
},
|
||||
{ timeout: 500 },
|
||||
);
|
||||
|
||||
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
|
||||
{
|
||||
col: 'category_column',
|
||||
op: 'IN',
|
||||
val: ['Product A'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('uses rendered categorical axis for query event handlers', () => {
|
||||
render(
|
||||
<EchartsTimeseries
|
||||
{...defaultProps}
|
||||
xAxis={{
|
||||
label: 'category_column',
|
||||
type: AxisType.Category,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getLatestEchartProps().queryEventHandlers?.[0].query).toBe(
|
||||
'xAxis.category',
|
||||
);
|
||||
|
||||
cleanup();
|
||||
mockEchart.mockReset();
|
||||
|
||||
render(
|
||||
<EchartsTimeseries
|
||||
{...defaultProps}
|
||||
formData={{
|
||||
...defaultFormData,
|
||||
orientation: OrientationType.Horizontal,
|
||||
}}
|
||||
xAxis={{
|
||||
label: 'category_column',
|
||||
type: AxisType.Category,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getLatestEchartProps().queryEventHandlers?.[0].query).toBe(
|
||||
'yAxis.category',
|
||||
);
|
||||
});
|
||||
|
||||
test('emits cross-filter from horizontal categorical axis label clicks', () => {
|
||||
const setDataMaskMock = jest.fn();
|
||||
|
||||
render(
|
||||
<EchartsTimeseries
|
||||
{...defaultProps}
|
||||
emitCrossFilters
|
||||
setDataMask={setDataMaskMock}
|
||||
formData={{
|
||||
...defaultFormData,
|
||||
orientation: OrientationType.Horizontal,
|
||||
}}
|
||||
xAxis={{
|
||||
label: 'category_column',
|
||||
type: AxisType.Category,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const labelClickHandler =
|
||||
getLatestEchartProps().queryEventHandlers?.[0].handler;
|
||||
expect(labelClickHandler).toBeDefined();
|
||||
labelClickHandler?.({
|
||||
value: 'Product A',
|
||||
} as ECElementEvent);
|
||||
|
||||
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
|
||||
{
|
||||
col: 'category_column',
|
||||
op: 'IN',
|
||||
val: ['Product A'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('does not emit duplicate cross-filter for generic axis label clicks', async () => {
|
||||
const setDataMaskMock = jest.fn();
|
||||
|
||||
render(
|
||||
<EchartsTimeseries
|
||||
{...defaultProps}
|
||||
emitCrossFilters
|
||||
setDataMask={setDataMaskMock}
|
||||
xAxis={{
|
||||
label: 'category_column',
|
||||
type: AxisType.Category,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const clickHandler = getLatestEchartProps().eventHandlers?.click;
|
||||
expect(clickHandler).toBeDefined();
|
||||
clickHandler?.({
|
||||
componentType: 'xAxis',
|
||||
name: 'Product A',
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 400));
|
||||
expect(setDataMaskMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('does not emit cross-filter when no dimensions and time-based X-axis', async () => {
|
||||
const setDataMaskMock = jest.fn();
|
||||
|
||||
@@ -385,6 +534,7 @@ test('does not emit cross-filter when no dimensions and time-based X-axis', asyn
|
||||
const clickHandler = props.eventHandlers?.click;
|
||||
if (clickHandler) {
|
||||
clickHandler({
|
||||
componentType: 'series',
|
||||
seriesName: 'Sales',
|
||||
data: [1609459200000, 100], // Timestamp
|
||||
name: '2021-01-01',
|
||||
@@ -407,6 +557,10 @@ test('emits cross-filter on the category value for a horizontal categorical bar'
|
||||
...defaultProps,
|
||||
emitCrossFilters: true,
|
||||
setDataMask: setDataMaskMock,
|
||||
formData: {
|
||||
...defaultFormData,
|
||||
orientation: OrientationType.Horizontal,
|
||||
},
|
||||
groupby: [], // No dimensions
|
||||
xAxis: {
|
||||
label: 'category_column',
|
||||
@@ -423,6 +577,7 @@ test('emits cross-filter on the category value for a horizontal categorical bar'
|
||||
const clickHandler = props.eventHandlers?.click;
|
||||
if (clickHandler) {
|
||||
clickHandler({
|
||||
componentType: 'series',
|
||||
seriesName: 'Sales', // This is the metric name
|
||||
data: [100, 'Product A'], // Horizontal: value first, category second
|
||||
name: 'Product A',
|
||||
@@ -457,6 +612,10 @@ test('context menu cross-filter uses the category value for a horizontal categor
|
||||
...defaultProps,
|
||||
emitCrossFilters: true,
|
||||
onContextMenu: onContextMenuMock,
|
||||
formData: {
|
||||
...defaultFormData,
|
||||
orientation: OrientationType.Horizontal,
|
||||
},
|
||||
groupby: [], // No dimensions
|
||||
xAxis: {
|
||||
label: 'category_column',
|
||||
@@ -474,6 +633,7 @@ test('context menu cross-filter uses the category value for a horizontal categor
|
||||
expect(contextMenuHandler).toBeDefined();
|
||||
if (contextMenuHandler) {
|
||||
await contextMenuHandler({
|
||||
componentType: 'series',
|
||||
seriesName: 'Sales', // This is the metric name
|
||||
data: [100, 'Product A'], // Horizontal: value first, category second
|
||||
name: 'Product A',
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
DTTM_ALIAS,
|
||||
BinaryQueryObjectFilterClause,
|
||||
@@ -27,12 +27,15 @@ import {
|
||||
LegendState,
|
||||
ensureIsArray,
|
||||
} from '@superset-ui/core';
|
||||
import type { ViewRootGroup } from 'echarts/types/src/util/types';
|
||||
import type {
|
||||
ECElementEvent,
|
||||
ViewRootGroup,
|
||||
} from 'echarts/types/src/util/types';
|
||||
import type GlobalModel from 'echarts/types/src/model/Global';
|
||||
import type ComponentModel from 'echarts/types/src/model/Component';
|
||||
import { EchartsHandler, EventHandlers } from '../types';
|
||||
import Echart from '../components/Echart';
|
||||
import { TimeseriesChartTransformedProps } from './types';
|
||||
import { OrientationType, TimeseriesChartTransformedProps } from './types';
|
||||
import { formatSeriesName } from '../utils/series';
|
||||
import { ExtraControls } from '../components/ExtraControls';
|
||||
|
||||
@@ -218,6 +221,26 @@ export default function EchartsTimeseries({
|
||||
// Determine if X-axis can be used for cross-filtering (categorical axis without dimensions)
|
||||
const canCrossFilterByXAxis =
|
||||
!hasDimensions && xAxis.type === AxisType.Category;
|
||||
const categoryAxisValueIndex =
|
||||
formData.orientation === OrientationType.Horizontal ? 1 : 0;
|
||||
const getCategoryAxisValue = useCallback(
|
||||
(data: unknown, name: unknown) => {
|
||||
if (Array.isArray(data)) {
|
||||
const categoryAxisValue = data[categoryAxisValueIndex];
|
||||
if (
|
||||
typeof categoryAxisValue === 'string' ||
|
||||
typeof categoryAxisValue === 'number'
|
||||
) {
|
||||
return categoryAxisValue;
|
||||
}
|
||||
}
|
||||
if (typeof name === 'string' || typeof name === 'number') {
|
||||
return name;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
[categoryAxisValueIndex],
|
||||
);
|
||||
|
||||
const eventHandlers: EventHandlers = {
|
||||
click: props => {
|
||||
@@ -234,12 +257,15 @@ export default function EchartsTimeseries({
|
||||
// Cross-filter by dimension (original behavior)
|
||||
const { seriesName: name } = props;
|
||||
handleChange(name);
|
||||
} else if (canCrossFilterByXAxis && props.name != null) {
|
||||
// Cross-filter by X-axis value when no dimensions (issue #25334).
|
||||
// Use `name` (the category-axis value) instead of `data[0]`: for
|
||||
// horizontal bars the data tuple is value-first, so `data[0]` would
|
||||
// be the metric value rather than the category (issue #41102).
|
||||
handleXAxisChange(props.name);
|
||||
} else if (canCrossFilterByXAxis && props.componentType === 'series') {
|
||||
// Cross-filter by X-axis value when no dimensions (issue #25334)
|
||||
const categoryAxisValue = getCategoryAxisValue(
|
||||
props.data,
|
||||
props.name,
|
||||
);
|
||||
if (categoryAxisValue !== undefined) {
|
||||
handleXAxisChange(categoryAxisValue);
|
||||
}
|
||||
}
|
||||
}, TIMER_DURATION);
|
||||
},
|
||||
@@ -321,10 +347,17 @@ export default function EchartsTimeseries({
|
||||
let crossFilter;
|
||||
if (hasDimensions) {
|
||||
crossFilter = getCrossFilterDataMask(seriesName);
|
||||
} else if (canCrossFilterByXAxis && eventParams.name != null) {
|
||||
// Use `name` (the category-axis value), not `data[0]`, so horizontal
|
||||
// bars cross-filter on the category and not the metric (issue #41102).
|
||||
crossFilter = getXAxisCrossFilterDataMask(eventParams.name);
|
||||
} else if (
|
||||
canCrossFilterByXAxis &&
|
||||
eventParams.componentType === 'series'
|
||||
) {
|
||||
const categoryAxisValue = getCategoryAxisValue(
|
||||
data,
|
||||
eventParams.name,
|
||||
);
|
||||
if (categoryAxisValue !== undefined) {
|
||||
crossFilter = getXAxisCrossFilterDataMask(categoryAxisValue);
|
||||
}
|
||||
}
|
||||
|
||||
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, {
|
||||
@@ -336,6 +369,33 @@ export default function EchartsTimeseries({
|
||||
},
|
||||
};
|
||||
|
||||
const handleXAxisLabelClick = useCallback(
|
||||
(event: ECElementEvent) => {
|
||||
const { value } = event;
|
||||
if (
|
||||
canCrossFilterByXAxis &&
|
||||
(typeof value === 'string' || typeof value === 'number')
|
||||
) {
|
||||
handleXAxisChange(value);
|
||||
}
|
||||
},
|
||||
[canCrossFilterByXAxis, handleXAxisChange],
|
||||
);
|
||||
|
||||
const categoryAxis =
|
||||
formData.orientation === OrientationType.Horizontal ? 'yAxis' : 'xAxis';
|
||||
|
||||
const queryEventHandlers = useMemo(
|
||||
() => [
|
||||
{
|
||||
name: 'click',
|
||||
query: `${categoryAxis}.category`,
|
||||
handler: handleXAxisLabelClick,
|
||||
},
|
||||
],
|
||||
[categoryAxis, handleXAxisLabelClick],
|
||||
);
|
||||
|
||||
const zrEventHandlers: EventHandlers = {
|
||||
dblclick: params => {
|
||||
// clear single click timer
|
||||
@@ -377,6 +437,7 @@ export default function EchartsTimeseries({
|
||||
width={width}
|
||||
echartOptions={echartOptions}
|
||||
eventHandlers={eventHandlers}
|
||||
queryEventHandlers={queryEventHandlers}
|
||||
zrEventHandlers={zrEventHandlers}
|
||||
selectedValues={selectedValues}
|
||||
vizType={formData.vizType}
|
||||
|
||||
@@ -889,6 +889,10 @@ export default function transformProps(
|
||||
name: xAxisTitle,
|
||||
nameGap: convertInteger(xAxisTitleMargin),
|
||||
nameLocation: 'middle',
|
||||
...(xAxisType === AxisType.Category &&
|
||||
groupBy.length === 0 && {
|
||||
triggerEvent: true,
|
||||
}),
|
||||
axisLabel: {
|
||||
// When rotation is applied on time axes, hideOverlap can
|
||||
// aggressively hide the last label. Rotated labels already
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, waitFor } from '../../../../spec/helpers/testing-library';
|
||||
import type { EChartsCoreOption } from 'echarts/core';
|
||||
import Echart from './Echart';
|
||||
import type { EchartsProps } from '../types';
|
||||
|
||||
type Handler = (params: unknown) => void;
|
||||
type Listener = {
|
||||
query?: string;
|
||||
handler: Handler;
|
||||
};
|
||||
|
||||
const listeners: Record<string, Listener[]> = {};
|
||||
|
||||
const mockChart = {
|
||||
dispatchAction: jest.fn(),
|
||||
dispose: jest.fn(),
|
||||
getOption: jest.fn(() => ({})),
|
||||
getZr: jest.fn(() => ({
|
||||
off: jest.fn(),
|
||||
on: jest.fn(),
|
||||
})),
|
||||
off: jest.fn((name: string, handler?: Handler) => {
|
||||
if (!handler) {
|
||||
delete listeners[name];
|
||||
return;
|
||||
}
|
||||
listeners[name] = (listeners[name] || []).filter(
|
||||
listener => listener.handler !== handler,
|
||||
);
|
||||
}),
|
||||
on: jest.fn(
|
||||
(name: string, queryOrHandler: string | Handler, handler?: Handler) => {
|
||||
listeners[name] = listeners[name] || [];
|
||||
listeners[name].push(
|
||||
handler
|
||||
? { query: queryOrHandler as string, handler }
|
||||
: { handler: queryOrHandler as Handler },
|
||||
);
|
||||
},
|
||||
),
|
||||
resize: jest.fn(),
|
||||
setOption: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock('echarts/core', () => ({
|
||||
init: jest.fn(() => mockChart),
|
||||
registerLocale: jest.fn(),
|
||||
use: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('echarts/charts', () => ({
|
||||
BarChart: 'BarChart',
|
||||
BoxplotChart: 'BoxplotChart',
|
||||
CustomChart: 'CustomChart',
|
||||
FunnelChart: 'FunnelChart',
|
||||
GaugeChart: 'GaugeChart',
|
||||
GraphChart: 'GraphChart',
|
||||
HeatmapChart: 'HeatmapChart',
|
||||
LineChart: 'LineChart',
|
||||
PieChart: 'PieChart',
|
||||
RadarChart: 'RadarChart',
|
||||
SankeyChart: 'SankeyChart',
|
||||
ScatterChart: 'ScatterChart',
|
||||
SunburstChart: 'SunburstChart',
|
||||
TreeChart: 'TreeChart',
|
||||
TreemapChart: 'TreemapChart',
|
||||
}));
|
||||
|
||||
jest.mock('echarts/components', () => ({
|
||||
AriaComponent: 'AriaComponent',
|
||||
DataZoomComponent: 'DataZoomComponent',
|
||||
GraphicComponent: 'GraphicComponent',
|
||||
GridComponent: 'GridComponent',
|
||||
LegendComponent: 'LegendComponent',
|
||||
MarkAreaComponent: 'MarkAreaComponent',
|
||||
MarkLineComponent: 'MarkLineComponent',
|
||||
TitleComponent: 'TitleComponent',
|
||||
ToolboxComponent: 'ToolboxComponent',
|
||||
TooltipComponent: 'TooltipComponent',
|
||||
VisualMapComponent: 'VisualMapComponent',
|
||||
}));
|
||||
|
||||
jest.mock('echarts/features', () => ({
|
||||
LabelLayout: 'LabelLayout',
|
||||
}));
|
||||
|
||||
jest.mock('echarts/renderers', () => ({
|
||||
CanvasRenderer: 'CanvasRenderer',
|
||||
}));
|
||||
|
||||
const initialState = {
|
||||
common: {
|
||||
locale: 'en',
|
||||
},
|
||||
dashboardState: {
|
||||
isRefreshing: false,
|
||||
},
|
||||
};
|
||||
|
||||
const defaultProps: EchartsProps = {
|
||||
echartOptions: { series: [] } as EChartsCoreOption,
|
||||
height: 100,
|
||||
refs: {},
|
||||
width: 100,
|
||||
};
|
||||
|
||||
const renderEchart = (props: Partial<EchartsProps> = {}) => (
|
||||
<Echart {...defaultProps} {...props} />
|
||||
);
|
||||
|
||||
const trigger = (name: string) => {
|
||||
(listeners[name] || []).forEach(listener => listener.handler({}));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
Object.keys(listeners).forEach(name => {
|
||||
delete listeners[name];
|
||||
});
|
||||
Object.values(mockChart).forEach(value => {
|
||||
if (jest.isMockFunction(value)) {
|
||||
value.mockClear();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('replaces stale query event handlers without clearing regular event handlers', async () => {
|
||||
const regularClickHandler = jest.fn();
|
||||
const firstQueryHandler = jest.fn();
|
||||
const secondQueryHandler = jest.fn();
|
||||
|
||||
const { rerender } = render(
|
||||
renderEchart({
|
||||
eventHandlers: {
|
||||
click: regularClickHandler,
|
||||
},
|
||||
queryEventHandlers: [
|
||||
{
|
||||
handler: firstQueryHandler,
|
||||
name: 'click',
|
||||
query: 'xAxis.category',
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ initialState, useRedux: true },
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockChart.on).toHaveBeenCalledWith(
|
||||
'click',
|
||||
'xAxis.category',
|
||||
firstQueryHandler,
|
||||
),
|
||||
);
|
||||
|
||||
rerender(
|
||||
renderEchart({
|
||||
eventHandlers: {
|
||||
click: regularClickHandler,
|
||||
},
|
||||
queryEventHandlers: [
|
||||
{
|
||||
handler: secondQueryHandler,
|
||||
name: 'click',
|
||||
query: 'xAxis.category',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockChart.on).toHaveBeenCalledWith(
|
||||
'click',
|
||||
'xAxis.category',
|
||||
secondQueryHandler,
|
||||
),
|
||||
);
|
||||
|
||||
trigger('click');
|
||||
|
||||
expect(regularClickHandler).toHaveBeenCalledTimes(1);
|
||||
expect(firstQueryHandler).not.toHaveBeenCalled();
|
||||
expect(secondQueryHandler).toHaveBeenCalledTimes(1);
|
||||
|
||||
regularClickHandler.mockClear();
|
||||
secondQueryHandler.mockClear();
|
||||
|
||||
rerender(
|
||||
renderEchart({
|
||||
eventHandlers: {
|
||||
click: regularClickHandler,
|
||||
},
|
||||
queryEventHandlers: [],
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockChart.off).toHaveBeenCalledWith('click', secondQueryHandler),
|
||||
);
|
||||
|
||||
trigger('click');
|
||||
|
||||
expect(regularClickHandler).toHaveBeenCalledTimes(1);
|
||||
expect(firstQueryHandler).not.toHaveBeenCalled();
|
||||
expect(secondQueryHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -64,7 +64,12 @@ import {
|
||||
MarkLineComponent,
|
||||
} from 'echarts/components';
|
||||
import { LabelLayout } from 'echarts/features';
|
||||
import { EchartsHandler, EchartsProps, EchartsStylesProps } from '../types';
|
||||
import {
|
||||
EchartsHandler,
|
||||
EchartsProps,
|
||||
EchartsStylesProps,
|
||||
QueryEventHandlers,
|
||||
} from '../types';
|
||||
import { DEFAULT_LOCALE } from '../constants';
|
||||
import { mergeEchartsThemeOverrides } from '../utils/themeOverrides';
|
||||
|
||||
@@ -132,6 +137,7 @@ function Echart(
|
||||
height,
|
||||
echartOptions,
|
||||
eventHandlers,
|
||||
queryEventHandlers,
|
||||
zrEventHandlers,
|
||||
selectedValues = {},
|
||||
refs,
|
||||
@@ -147,6 +153,7 @@ function Echart(
|
||||
}
|
||||
const [didMount, setDidMount] = useState(false);
|
||||
const chartRef = useRef<EChartsType>();
|
||||
const previousQueryEventHandlers = useRef<QueryEventHandlers>([]);
|
||||
const currentSelection = useMemo(
|
||||
() => Object.keys(selectedValues) || [],
|
||||
[selectedValues],
|
||||
@@ -196,11 +203,19 @@ function Echart(
|
||||
|
||||
useEffect(() => {
|
||||
if (didMount) {
|
||||
previousQueryEventHandlers.current.forEach(({ name, handler }) => {
|
||||
chartRef.current?.off(name, handler);
|
||||
});
|
||||
Object.entries(eventHandlers || {}).forEach(([name, handler]) => {
|
||||
chartRef.current?.off(name);
|
||||
chartRef.current?.on(name, handler);
|
||||
});
|
||||
|
||||
(queryEventHandlers || []).forEach(({ name, query, handler }) => {
|
||||
chartRef.current?.on(name, query, handler);
|
||||
});
|
||||
previousQueryEventHandlers.current = queryEventHandlers || [];
|
||||
|
||||
Object.entries(zrEventHandlers || {}).forEach(([name, handler]) => {
|
||||
chartRef.current?.getZr().off(name);
|
||||
chartRef.current?.getZr().on(name, handler);
|
||||
@@ -336,7 +351,15 @@ function Echart(
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- isDashboardRefreshing intentionally excluded to prevent extra setOption calls
|
||||
}, [didMount, echartOptions, eventHandlers, zrEventHandlers, theme, vizType]);
|
||||
}, [
|
||||
didMount,
|
||||
echartOptions,
|
||||
eventHandlers,
|
||||
queryEventHandlers,
|
||||
zrEventHandlers,
|
||||
theme,
|
||||
vizType,
|
||||
]);
|
||||
|
||||
// Clear tooltip on refresh start to avoid stale content (#39247)
|
||||
useEffect(() => {
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
} from '@superset-ui/core';
|
||||
import type { EChartsCoreOption, EChartsType } from 'echarts/core';
|
||||
import type { TooltipMarker } from 'echarts/types/src/util/format';
|
||||
import type { ECElementEvent } from 'echarts/types/src/util/types';
|
||||
import { StackControlsValue } from './constants';
|
||||
|
||||
export type EchartsStylesProps = {
|
||||
@@ -51,6 +52,7 @@ export interface EchartsProps {
|
||||
width: number;
|
||||
echartOptions: EChartsCoreOption;
|
||||
eventHandlers?: EventHandlers;
|
||||
queryEventHandlers?: QueryEventHandlers;
|
||||
zrEventHandlers?: EventHandlers;
|
||||
selectedValues?: Record<number, string>;
|
||||
forceClear?: boolean;
|
||||
@@ -105,6 +107,12 @@ export type LegendFormData = {
|
||||
|
||||
export type EventHandlers = Record<string, { (props: any): void }>;
|
||||
|
||||
export type QueryEventHandlers = {
|
||||
name: string;
|
||||
query: string;
|
||||
handler: (props: ECElementEvent) => void;
|
||||
}[];
|
||||
|
||||
export enum LabelPositionEnum {
|
||||
Top = 'top',
|
||||
Left = 'left',
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
EchartsRadarChartProps,
|
||||
EchartsRadarFormData,
|
||||
} from '../../src/Radar/types';
|
||||
import { LegendOrientation } from '../../src/types';
|
||||
|
||||
interface RadarIndicator {
|
||||
name: string;
|
||||
@@ -202,3 +203,58 @@ describe('legend sorting', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('radar center positioning', () => {
|
||||
const getCenter = (overrides: Partial<EchartsRadarFormData> = {}) => {
|
||||
const props = new ChartProps({
|
||||
formData: {
|
||||
...formData,
|
||||
showLegend: true,
|
||||
legendMargin: 100,
|
||||
...overrides,
|
||||
},
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData,
|
||||
theme: supersetTheme,
|
||||
});
|
||||
const result = transformProps(props as EchartsRadarChartProps);
|
||||
const { center } = result.echartOptions.radar as {
|
||||
center: [string, string];
|
||||
};
|
||||
return {
|
||||
x: parseFloat(center[0]),
|
||||
y: parseFloat(center[1]),
|
||||
};
|
||||
};
|
||||
|
||||
test('keeps the center when the legend is hidden', () => {
|
||||
const { x, y } = getCenter({ showLegend: false });
|
||||
expect(x).toBe(50);
|
||||
expect(y).toBe(50);
|
||||
});
|
||||
|
||||
test('shifts the center right (away from the legend) when legend is on the left', () => {
|
||||
const { x, y } = getCenter({ legendOrientation: LegendOrientation.Left });
|
||||
expect(x).toBeGreaterThan(50);
|
||||
expect(y).toBe(50);
|
||||
});
|
||||
|
||||
test('shifts the center left (away from the legend) when legend is on the right', () => {
|
||||
const { x, y } = getCenter({ legendOrientation: LegendOrientation.Right });
|
||||
expect(x).toBeLessThan(50);
|
||||
expect(y).toBe(50);
|
||||
});
|
||||
|
||||
test('shifts the center down (away from the legend) when legend is on the top', () => {
|
||||
const { x, y } = getCenter({ legendOrientation: LegendOrientation.Top });
|
||||
expect(x).toBe(50);
|
||||
expect(y).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
test('shifts the center up (away from the legend) when legend is on the bottom', () => {
|
||||
const { x, y } = getCenter({ legendOrientation: LegendOrientation.Bottom });
|
||||
expect(x).toBe(50);
|
||||
expect(y).toBeLessThan(50);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1564,9 +1564,13 @@ test('xAxisForceCategorical forces Category axis regardless of Numeric coltype',
|
||||
});
|
||||
|
||||
const { echartOptions } = transformProps(chartProps);
|
||||
const xAxis = echartOptions.xAxis as { type: string };
|
||||
const xAxis = echartOptions.xAxis as {
|
||||
triggerEvent?: boolean;
|
||||
type: string;
|
||||
};
|
||||
|
||||
expect(xAxis.type).toBe(AxisType.Category);
|
||||
expect(xAxis.triggerEvent).toBe(true);
|
||||
});
|
||||
|
||||
test('temporal x coltype wires the time formatter and Time axis', () => {
|
||||
|
||||
@@ -379,6 +379,79 @@ test('should fallback to formData state when runtime state not available', () =>
|
||||
expect(getByTestId('chart-container')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('chart height is reduced on first render in expanded state (guards against useEffect regression)', () => {
|
||||
const DESCRIPTION_HEIGHT = 60;
|
||||
const CHART_HEIGHT = 300;
|
||||
// Matches the DEFAULT_HEADER_HEIGHT constant in Chart.tsx.
|
||||
const DEFAULT_HEADER_HEIGHT = 22;
|
||||
|
||||
// Stabilise getHeaderHeight(): emotion injects margin-bottom CSS during
|
||||
// React's commit phase, so getComputedStyle returns different values in
|
||||
// initial renders vs re-renders. Mock it to always return empty so
|
||||
// getHeaderHeight() consistently falls back to DEFAULT_HEADER_HEIGHT.
|
||||
const getComputedStyleSpy = jest
|
||||
.spyOn(window, 'getComputedStyle')
|
||||
.mockReturnValue({
|
||||
getPropertyValue: () => '',
|
||||
} as unknown as CSSStyleDeclaration);
|
||||
|
||||
// JSDOM doesn't compute layout, so mock offsetHeight to simulate a real
|
||||
// description element with height.
|
||||
const offsetHeightSpy = jest
|
||||
.spyOn(HTMLElement.prototype, 'offsetHeight', 'get')
|
||||
.mockImplementation(function (this: HTMLElement) {
|
||||
return this.classList.contains('slice_description')
|
||||
? DESCRIPTION_HEIGHT
|
||||
: 0;
|
||||
});
|
||||
|
||||
// Suppress all passive effects to simulate the first-paint moment — the
|
||||
// point at which the original useEffect bug caused clipping. useLayoutEffect
|
||||
// (the fix) runs synchronously before paint and is intentionally NOT mocked
|
||||
// here. If the implementation were reverted to useEffect, this spy would
|
||||
// prevent the height measurement and the assertion below would fail.
|
||||
const useEffectSpy = jest
|
||||
.spyOn(global.React, 'useEffect')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const { container } = setup(
|
||||
{ height: CHART_HEIGHT },
|
||||
{
|
||||
charts: {
|
||||
...defaultState.charts,
|
||||
[queryId]: {
|
||||
...defaultState.charts[queryId],
|
||||
// ChartOverlay renders with an inline height style when loading —
|
||||
// this is the observable proxy for getChartHeight() without real layout.
|
||||
chartStatus: 'loading',
|
||||
},
|
||||
},
|
||||
dashboardState: {
|
||||
...defaultState.dashboardState,
|
||||
expandedSlices: { [queryId]: true },
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const chartHeight = parseInt(
|
||||
container.querySelector<HTMLDivElement>('.dashboard-chart > div[style]')!
|
||||
.style.height,
|
||||
10,
|
||||
);
|
||||
|
||||
// useLayoutEffect must have measured and applied descriptionHeight
|
||||
// synchronously. If useEffect were used instead, descriptionHeight would
|
||||
// still be 0 here (suppressed by useEffectSpy) and chartHeight would equal
|
||||
// CHART_HEIGHT - DEFAULT_HEADER_HEIGHT rather than the value below.
|
||||
expect(chartHeight).toBe(
|
||||
CHART_HEIGHT - DEFAULT_HEADER_HEIGHT - DESCRIPTION_HEIGHT,
|
||||
);
|
||||
|
||||
useEffectSpy.mockRestore();
|
||||
getComputedStyleSpy.mockRestore();
|
||||
offsetHeightSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('should not show a close button on chart error banners', () => {
|
||||
const { queryByRole } = setup(
|
||||
{},
|
||||
|
||||
@@ -20,6 +20,7 @@ import cx from 'classnames';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useMemo,
|
||||
useState,
|
||||
@@ -318,13 +319,9 @@ const Chart = (props: ChartProps) => {
|
||||
[dispatch, props.id, sliceVizType],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded) {
|
||||
const descHeight =
|
||||
isExpanded && descriptionRef.current
|
||||
? descriptionRef.current?.offsetHeight
|
||||
: 0;
|
||||
setDescriptionHeight(descHeight);
|
||||
useLayoutEffect(() => {
|
||||
if (isExpanded && descriptionRef.current) {
|
||||
setDescriptionHeight(descriptionRef.current.offsetHeight);
|
||||
} else {
|
||||
setDescriptionHeight(0);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
extractLabel,
|
||||
getAppliedColumnsWithFallback,
|
||||
getCrossFilterIndicator,
|
||||
IndicatorStatus,
|
||||
selectNativeIndicatorsForChart,
|
||||
} from './selectors';
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
@@ -207,6 +209,21 @@ test('getAppliedColumnsWithFallback returns columns from query response when ava
|
||||
expect(result).toEqual(new Set(['age', 'name']));
|
||||
});
|
||||
|
||||
test('getAppliedColumnsWithFallback returns columns from all query responses', () => {
|
||||
const chart = {
|
||||
queriesResponse: [
|
||||
{
|
||||
applied_filters: [],
|
||||
},
|
||||
{
|
||||
applied_filters: [{ column: 'age' }, { column: 'name' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = getAppliedColumnsWithFallback(chart);
|
||||
expect(result).toEqual(new Set(['age', 'name']));
|
||||
});
|
||||
|
||||
test('getAppliedColumnsWithFallback returns empty set when query response has no applied_filters and no fallback params', () => {
|
||||
const chart = {
|
||||
queriesResponse: [{ applied_filters: [] }],
|
||||
@@ -565,3 +582,47 @@ test('getAppliedColumnsWithFallback prioritizes query response over fallback', (
|
||||
);
|
||||
expect(result).toEqual(new Set(['query_column']));
|
||||
});
|
||||
|
||||
test('selectNativeIndicatorsForChart marks rejected filters from later query responses incompatible', () => {
|
||||
const chartId = 987;
|
||||
const nativeFilters = {
|
||||
filter1: {
|
||||
id: 'filter1',
|
||||
name: 'Age',
|
||||
type: NativeFilterType.NativeFilter,
|
||||
chartsInScope: [chartId],
|
||||
targets: [{ column: { name: 'age' } }],
|
||||
},
|
||||
} as any;
|
||||
const dataMask = {
|
||||
filter1: {
|
||||
id: 'filter1',
|
||||
filterState: { value: '25' },
|
||||
extraFormData: {},
|
||||
},
|
||||
} as any;
|
||||
const chart = {
|
||||
queriesResponse: [
|
||||
{ rejected_filters: [] },
|
||||
{ rejected_filters: [{ column: 'age' }] },
|
||||
],
|
||||
};
|
||||
|
||||
const result = selectNativeIndicatorsForChart(
|
||||
nativeFilters,
|
||||
dataMask,
|
||||
chartId,
|
||||
chart,
|
||||
[],
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
column: 'age',
|
||||
name: 'Age',
|
||||
path: ['filter1'],
|
||||
status: IndicatorStatus.Incompatible,
|
||||
value: '25',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -141,9 +141,20 @@ const selectIndicatorsForChartFromFilter = (
|
||||
}));
|
||||
};
|
||||
|
||||
const getQueryFilterMetadata = (
|
||||
chart: any,
|
||||
metadataKey: 'applied_filters' | 'rejected_filters',
|
||||
) =>
|
||||
ensureIsArray(chart?.queriesResponse).flatMap(
|
||||
queryResponse =>
|
||||
(metadataKey === 'applied_filters'
|
||||
? queryResponse?.applied_filters
|
||||
: queryResponse?.rejected_filters) || [],
|
||||
);
|
||||
|
||||
const getAppliedColumns = (chart: any): Set<string> =>
|
||||
new Set(
|
||||
(chart?.queriesResponse?.[0]?.applied_filters || []).map(
|
||||
getQueryFilterMetadata(chart, 'applied_filters').map(
|
||||
(filter: any) => filter.column,
|
||||
),
|
||||
);
|
||||
@@ -161,8 +172,7 @@ export const getAppliedColumnsWithFallback = (
|
||||
chartId?: number,
|
||||
): Set<string> => {
|
||||
// First try to get from query response (preferred source of truth)
|
||||
const queryAppliedFilters =
|
||||
chart?.queriesResponse?.[0]?.applied_filters || [];
|
||||
const queryAppliedFilters = getQueryFilterMetadata(chart, 'applied_filters');
|
||||
if (queryAppliedFilters.length > 0) {
|
||||
return new Set(queryAppliedFilters.map((filter: any) => filter.column));
|
||||
}
|
||||
@@ -191,7 +201,7 @@ export const getAppliedColumnsWithFallback = (
|
||||
|
||||
const getRejectedColumns = (chart: any): Set<string> =>
|
||||
new Set(
|
||||
(chart?.queriesResponse?.[0]?.rejected_filters || []).map((filter: any) =>
|
||||
getQueryFilterMetadata(chart, 'rejected_filters').map((filter: any) =>
|
||||
getColumnLabel(filter.column),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -335,27 +335,6 @@ test('enables overwrite option for admin non-owner', () => {
|
||||
expect(getByRole('radio', { name: 'Save (Overwrite)' })).toBeEnabled();
|
||||
});
|
||||
|
||||
test('enables overwrite option for owner when owners are objects', () => {
|
||||
// The Slice type declares `owners: { id: number }[]`, and the explore
|
||||
// bootstrap can deliver owners in object form. A chart owner must still be
|
||||
// able to overwrite their own chart in that case.
|
||||
const { getByRole } = setup(
|
||||
{},
|
||||
mockStore({
|
||||
...initialState,
|
||||
explore: {
|
||||
...initialState.explore,
|
||||
slice: {
|
||||
...initialState.explore.slice,
|
||||
owners: [{ id: 1 }],
|
||||
},
|
||||
},
|
||||
user: { userId: 1 },
|
||||
}),
|
||||
);
|
||||
expect(getByRole('radio', { name: 'Save (Overwrite)' })).toBeEnabled();
|
||||
});
|
||||
|
||||
test('updates slice name and selected dashboard', async () => {
|
||||
const dashboardId = mockEvent.value;
|
||||
const saveDataset = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
@@ -132,27 +132,13 @@ class SaveModal extends Component<SaveModalProps, SaveModalState> {
|
||||
return typeof dashboard?.value === 'string';
|
||||
}
|
||||
|
||||
isCurrentUserOwner(): boolean {
|
||||
const userId = this.props.user?.userId;
|
||||
if (userId === undefined) {
|
||||
return false;
|
||||
}
|
||||
// Owners can arrive either as plain ids (number) or as objects depending on
|
||||
// where the slice was hydrated from (bootstrap vs. SLICE_UPDATED reducer),
|
||||
// so normalize to the numeric id before comparing.
|
||||
return Boolean(
|
||||
this.props.slice?.owners?.some((owner: number | { id?: number }) =>
|
||||
typeof owner === 'number' ? owner === userId : owner?.id === userId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
canOverwriteSlice(): boolean {
|
||||
const canEdit =
|
||||
this.props.can_overwrite ||
|
||||
isUserAdmin(this.props.user) ||
|
||||
this.isCurrentUserOwner();
|
||||
return canEdit && !this.props.slice?.is_managed_externally;
|
||||
return (
|
||||
(this.props.can_overwrite ||
|
||||
isUserAdmin(this.props.user) ||
|
||||
this.props.slice?.owners?.includes(this.props.user.userId)) &&
|
||||
!this.props.slice?.is_managed_externally
|
||||
);
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
|
||||
66
superset-frontend/src/features/roles/RoleFormItems.test.tsx
Normal file
66
superset-frontend/src/features/roles/RoleFormItems.test.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import {
|
||||
RoleNameField,
|
||||
PermissionsField,
|
||||
UsersField,
|
||||
GroupsField,
|
||||
} from './RoleFormItems';
|
||||
|
||||
jest.mock('./utils', () => ({
|
||||
fetchPermissionOptions: jest.fn(),
|
||||
fetchGroupOptions: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../groups/utils', () => ({
|
||||
fetchUserOptions: jest.fn(),
|
||||
}));
|
||||
|
||||
const addDangerToast = jest.fn();
|
||||
|
||||
test('RoleNameField renders label and input', () => {
|
||||
render(<RoleNameField />);
|
||||
expect(screen.getByText('Role Name')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('role-name-input')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('PermissionsField renders label and select', () => {
|
||||
render(<PermissionsField addDangerToast={addDangerToast} />);
|
||||
expect(screen.getByText('Permissions')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('permissions-select')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('PermissionsField renders loading state', () => {
|
||||
render(<PermissionsField addDangerToast={addDangerToast} loading />);
|
||||
expect(screen.getByText('Permissions')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('permissions-select')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('UsersField renders label and select', () => {
|
||||
render(<UsersField addDangerToast={addDangerToast} loading={false} />);
|
||||
expect(screen.getByText('Users')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('roles-select')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('GroupsField renders label and select', () => {
|
||||
render(<GroupsField addDangerToast={addDangerToast} />);
|
||||
expect(screen.getByText('Groups')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('groups-select')).toBeInTheDocument();
|
||||
});
|
||||
@@ -16,6 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useCallback } from 'react';
|
||||
import { FormItem, Input, AsyncSelect } from '@superset-ui/core/components';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { fetchUserOptions } from '../groups/utils';
|
||||
@@ -44,51 +45,69 @@ export const RoleNameField = () => (
|
||||
export const PermissionsField = ({
|
||||
addDangerToast,
|
||||
loading = false,
|
||||
}: AsyncOptionsFieldProps) => (
|
||||
<FormItem name="rolePermissions" label={t('Permissions')}>
|
||||
<AsyncSelect
|
||||
mode="multiple"
|
||||
name="rolePermissions"
|
||||
placeholder={t('Select permissions')}
|
||||
options={(filterValue, page, pageSize) =>
|
||||
fetchPermissionOptions(filterValue, page, pageSize, addDangerToast)
|
||||
}
|
||||
loading={loading}
|
||||
getPopupContainer={trigger => trigger.closest('.ant-modal-content')}
|
||||
data-test="permissions-select"
|
||||
/>
|
||||
</FormItem>
|
||||
);
|
||||
}: AsyncOptionsFieldProps) => {
|
||||
const options = useCallback(
|
||||
(filterValue: string, page: number, pageSize: number) =>
|
||||
fetchPermissionOptions(filterValue, page, pageSize, addDangerToast),
|
||||
[addDangerToast],
|
||||
);
|
||||
|
||||
export const UsersField = ({ addDangerToast, loading }: UsersFieldProps) => (
|
||||
<FormItem name="roleUsers" label={t('Users')}>
|
||||
<AsyncSelect
|
||||
name="roleUsers"
|
||||
mode="multiple"
|
||||
placeholder={t('Select users')}
|
||||
options={(filterValue, page, pageSize) =>
|
||||
fetchUserOptions(filterValue, page, pageSize, addDangerToast)
|
||||
}
|
||||
loading={loading}
|
||||
data-test="roles-select"
|
||||
/>
|
||||
</FormItem>
|
||||
);
|
||||
return (
|
||||
<FormItem name="rolePermissions" label={t('Permissions')}>
|
||||
<AsyncSelect
|
||||
mode="multiple"
|
||||
name="rolePermissions"
|
||||
placeholder={t('Select permissions')}
|
||||
options={options}
|
||||
loading={loading}
|
||||
getPopupContainer={trigger => trigger.closest('.ant-modal-content')}
|
||||
data-test="permissions-select"
|
||||
/>
|
||||
</FormItem>
|
||||
);
|
||||
};
|
||||
|
||||
export const UsersField = ({ addDangerToast, loading }: UsersFieldProps) => {
|
||||
const options = useCallback(
|
||||
(filterValue: string, page: number, pageSize: number) =>
|
||||
fetchUserOptions(filterValue, page, pageSize, addDangerToast),
|
||||
[addDangerToast],
|
||||
);
|
||||
|
||||
return (
|
||||
<FormItem name="roleUsers" label={t('Users')}>
|
||||
<AsyncSelect
|
||||
name="roleUsers"
|
||||
mode="multiple"
|
||||
placeholder={t('Select users')}
|
||||
options={options}
|
||||
loading={loading}
|
||||
data-test="roles-select"
|
||||
/>
|
||||
</FormItem>
|
||||
);
|
||||
};
|
||||
|
||||
export const GroupsField = ({
|
||||
addDangerToast,
|
||||
loading = false,
|
||||
}: AsyncOptionsFieldProps) => (
|
||||
<FormItem name="roleGroups" label={t('Groups')}>
|
||||
<AsyncSelect
|
||||
mode="multiple"
|
||||
name="roleGroups"
|
||||
placeholder={t('Select groups')}
|
||||
options={(filterValue, page, pageSize) =>
|
||||
fetchGroupOptions(filterValue, page, pageSize, addDangerToast)
|
||||
}
|
||||
loading={loading}
|
||||
data-test="groups-select"
|
||||
/>
|
||||
</FormItem>
|
||||
);
|
||||
}: AsyncOptionsFieldProps) => {
|
||||
const options = useCallback(
|
||||
(filterValue: string, page: number, pageSize: number) =>
|
||||
fetchGroupOptions(filterValue, page, pageSize, addDangerToast),
|
||||
[addDangerToast],
|
||||
);
|
||||
|
||||
return (
|
||||
<FormItem name="roleGroups" label={t('Groups')}>
|
||||
<AsyncSelect
|
||||
mode="multiple"
|
||||
name="roleGroups"
|
||||
placeholder={t('Select groups')}
|
||||
options={options}
|
||||
loading={loading}
|
||||
data-test="groups-select"
|
||||
/>
|
||||
</FormItem>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -59,11 +59,15 @@ test('fetchPermissionOptions fetches all results on page 0 with large page_size'
|
||||
expect(queries).toContainEqual({
|
||||
page: 0,
|
||||
page_size: 1000,
|
||||
order_column: 'id',
|
||||
order_direction: 'asc',
|
||||
filters: [{ col: 'view_menu.name', opr: 'ct', value: 'dataset' }],
|
||||
});
|
||||
expect(queries).toContainEqual({
|
||||
page: 0,
|
||||
page_size: 1000,
|
||||
order_column: 'id',
|
||||
order_direction: 'asc',
|
||||
filters: [{ col: 'permission.name', opr: 'ct', value: 'dataset' }],
|
||||
});
|
||||
|
||||
@@ -125,6 +129,8 @@ test('fetchPermissionOptions makes single request when search term is empty', as
|
||||
expect(rison.decode(queryString)).toEqual({
|
||||
page: 0,
|
||||
page_size: 100,
|
||||
order_column: 'id',
|
||||
order_direction: 'asc',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -236,6 +242,8 @@ test('fetchGroupOptions sends filters array with search term', async () => {
|
||||
expect(rison.decode(queryString)).toEqual({
|
||||
page: 1,
|
||||
page_size: 25,
|
||||
order_column: 'name',
|
||||
order_direction: 'asc',
|
||||
filters: [{ col: 'name', opr: 'ct', value: 'eng' }],
|
||||
});
|
||||
expect(result).toEqual({
|
||||
@@ -261,6 +269,8 @@ test('fetchGroupOptions omits filters when search term is empty', async () => {
|
||||
expect(rison.decode(queryString)).toEqual({
|
||||
page: 0,
|
||||
page_size: 100,
|
||||
order_column: 'name',
|
||||
order_direction: 'asc',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -94,11 +94,14 @@ const fetchPermissionPageRaw = async (queryParams: Record<string, unknown>) => {
|
||||
const fetchAllPermissionPages = async (
|
||||
filters: Record<string, unknown>[],
|
||||
): Promise<SelectOption[]> => {
|
||||
const page0 = await fetchPermissionPageRaw({
|
||||
page: 0,
|
||||
const baseQuery = {
|
||||
page_size: PAGE_SIZE,
|
||||
order_column: 'id',
|
||||
order_direction: 'asc',
|
||||
filters,
|
||||
});
|
||||
};
|
||||
|
||||
const page0 = await fetchPermissionPageRaw({ ...baseQuery, page: 0 });
|
||||
if (page0.data.length === 0 || page0.data.length >= page0.totalCount) {
|
||||
return page0.data;
|
||||
}
|
||||
@@ -113,11 +116,7 @@ const fetchAllPermissionPages = async (
|
||||
const batchEnd = Math.min(batch + CONCURRENCY_LIMIT, totalPages);
|
||||
const batchResults = await Promise.all(
|
||||
Array.from({ length: batchEnd - batch }, (_, i) =>
|
||||
fetchPermissionPageRaw({
|
||||
page: batch + i,
|
||||
page_size: PAGE_SIZE,
|
||||
filters,
|
||||
}),
|
||||
fetchPermissionPageRaw({ ...baseQuery, page: batch + i }),
|
||||
),
|
||||
);
|
||||
for (const r of batchResults) {
|
||||
@@ -138,7 +137,12 @@ export const fetchPermissionOptions = async (
|
||||
) => {
|
||||
if (!filterValue) {
|
||||
try {
|
||||
return await fetchPermissionPageRaw({ page, page_size: pageSize });
|
||||
return await fetchPermissionPageRaw({
|
||||
page,
|
||||
page_size: pageSize,
|
||||
order_column: 'id',
|
||||
order_direction: 'asc',
|
||||
});
|
||||
} catch {
|
||||
addDangerToast(t('There was an error while fetching permissions'));
|
||||
return { data: [], totalCount: 0 };
|
||||
@@ -193,6 +197,8 @@ export const fetchGroupOptions = async (
|
||||
const query = rison.encode({
|
||||
page,
|
||||
page_size: pageSize,
|
||||
order_column: 'name',
|
||||
order_direction: 'asc',
|
||||
...(filterValue
|
||||
? { filters: [{ col: 'name', opr: 'ct', value: filterValue }] }
|
||||
: {}),
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* 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
|
||||
* 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
|
||||
@@ -36,80 +36,77 @@ const mockedProps = {
|
||||
resourceName: 'dashboard',
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('BulkTagModal', () => {
|
||||
afterEach(() => {
|
||||
fetchMock.clearHistory().removeRoutes();
|
||||
jest.clearAllMocks();
|
||||
afterEach(() => {
|
||||
fetchMock.clearHistory().removeRoutes();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should render', () => {
|
||||
const { container } = render(<BulkTagModal {...mockedProps} />);
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders the correct title and message', () => {
|
||||
render(<BulkTagModal {...mockedProps} />);
|
||||
expect(
|
||||
screen.getByText(/you are adding tags to 2 dashboards/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Bulk tag')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders tags input field', async () => {
|
||||
render(<BulkTagModal {...mockedProps} />);
|
||||
const tagsInput = await screen.findByRole('combobox', { name: /tags/i });
|
||||
expect(tagsInput).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('calls onHide when the Cancel button is clicked', () => {
|
||||
render(<BulkTagModal {...mockedProps} />);
|
||||
const cancelButton = screen.getByText('Cancel');
|
||||
fireEvent.click(cancelButton);
|
||||
expect(mockedProps.onHide).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('submits the selected tags and shows success toast', async () => {
|
||||
fetchMock.post('glob:*/api/v1/tag/bulk_create', {
|
||||
result: {
|
||||
objects_tagged: [1, 2],
|
||||
objects_skipped: [],
|
||||
},
|
||||
});
|
||||
|
||||
test('should render', () => {
|
||||
const { container } = render(<BulkTagModal {...mockedProps} />);
|
||||
expect(container).toBeInTheDocument();
|
||||
render(<BulkTagModal {...mockedProps} />);
|
||||
|
||||
const tagsInput = await screen.findByRole('combobox', { name: /tags/i });
|
||||
fireEvent.change(tagsInput, { target: { value: 'Test Tag' } });
|
||||
fireEvent.keyDown(tagsInput, { key: 'Enter', code: 'Enter' });
|
||||
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedProps.addSuccessToast).toHaveBeenCalledWith(
|
||||
'Tagged 2 dashboards',
|
||||
);
|
||||
});
|
||||
|
||||
test('renders the correct title and message', () => {
|
||||
render(<BulkTagModal {...mockedProps} />);
|
||||
expect(
|
||||
screen.getByText(/you are adding tags to 2 dashboards/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Bulk tag')).toBeInTheDocument();
|
||||
});
|
||||
expect(mockedProps.refreshData).toHaveBeenCalled();
|
||||
expect(mockedProps.onHide).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('renders tags input field', async () => {
|
||||
render(<BulkTagModal {...mockedProps} />);
|
||||
const tagsInput = await screen.findByRole('combobox', { name: /tags/i });
|
||||
expect(tagsInput).toBeInTheDocument();
|
||||
});
|
||||
test('handles API errors gracefully', async () => {
|
||||
fetchMock.post('glob:*/api/v1/tag/bulk_create', 500);
|
||||
|
||||
test('calls onHide when the Cancel button is clicked', () => {
|
||||
render(<BulkTagModal {...mockedProps} />);
|
||||
const cancelButton = screen.getByText('Cancel');
|
||||
fireEvent.click(cancelButton);
|
||||
expect(mockedProps.onHide).toHaveBeenCalled();
|
||||
});
|
||||
render(<BulkTagModal {...mockedProps} />);
|
||||
|
||||
test('submits the selected tags and shows success toast', async () => {
|
||||
fetchMock.post('glob:*/api/v1/tag/bulk_create', {
|
||||
result: {
|
||||
objects_tagged: [1, 2],
|
||||
objects_skipped: [],
|
||||
},
|
||||
});
|
||||
const tagsInput = await screen.findByRole('combobox', { name: /tags/i });
|
||||
fireEvent.change(tagsInput, { target: { value: 'Test Tag' } });
|
||||
fireEvent.keyDown(tagsInput, { key: 'Enter', code: 'Enter' });
|
||||
|
||||
render(<BulkTagModal {...mockedProps} />);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
const tagsInput = await screen.findByRole('combobox', { name: /tags/i });
|
||||
fireEvent.change(tagsInput, { target: { value: 'Test Tag' } });
|
||||
fireEvent.keyDown(tagsInput, { key: 'Enter', code: 'Enter' });
|
||||
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedProps.addSuccessToast).toHaveBeenCalledWith(
|
||||
'Tagged 2 dashboards',
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockedProps.refreshData).toHaveBeenCalled();
|
||||
expect(mockedProps.onHide).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('handles API errors gracefully', async () => {
|
||||
fetchMock.post('glob:*/api/v1/tag/bulk_create', 500);
|
||||
|
||||
render(<BulkTagModal {...mockedProps} />);
|
||||
|
||||
const tagsInput = await screen.findByRole('combobox', { name: /tags/i });
|
||||
fireEvent.change(tagsInput, { target: { value: 'Test Tag' } });
|
||||
fireEvent.keyDown(tagsInput, { key: 'Enter', code: 'Enter' });
|
||||
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedProps.addDangerToast).toHaveBeenCalledWith(
|
||||
'Failed to tag items',
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockedProps.addDangerToast).toHaveBeenCalledWith(
|
||||
'Failed to tag items',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1658,3 +1658,42 @@ test('renders standard Select dropdown when operatorType is Exact', () => {
|
||||
|
||||
expect(screen.getAllByRole('combobox').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('renders dashboard select dropdown popup under document body', async () => {
|
||||
jest.useFakeTimers({ advanceTimers: true });
|
||||
render(<SelectFilterPlugin {...buildSelectFilterProps()} />, {
|
||||
useRedux: true,
|
||||
initialState: {
|
||||
nativeFilters: {
|
||||
filters: { 'test-filter': { name: 'Test Filter' } },
|
||||
},
|
||||
dataMask: {
|
||||
'test-filter': {
|
||||
extraFormData: {
|
||||
filters: [{ col: 'gender', op: 'IN', val: ['boy'] }],
|
||||
},
|
||||
filterState: {
|
||||
value: ['boy'],
|
||||
label: 'boy',
|
||||
excludeFilterValues: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const [filterSelect] = screen.getAllByRole('combobox');
|
||||
userEvent.click(filterSelect);
|
||||
|
||||
let dropdown: Element | undefined;
|
||||
await waitFor(() => {
|
||||
dropdown = Array.from(
|
||||
document.querySelectorAll('.ant-select-dropdown'),
|
||||
).find(
|
||||
element => !element.classList.contains('ant-select-dropdown-hidden'),
|
||||
);
|
||||
expect(dropdown).toBeDefined();
|
||||
});
|
||||
|
||||
expect(dropdown?.parentElement).toBe(document.body);
|
||||
});
|
||||
|
||||
@@ -539,6 +539,19 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
|
||||
[debouncedLikeChange],
|
||||
);
|
||||
|
||||
const getSelectPopupContainer = useCallback(
|
||||
(trigger: HTMLElement) => {
|
||||
if (showOverflow) {
|
||||
return (parentRef?.current as HTMLElement) || document.body;
|
||||
}
|
||||
if (appSection === AppSection.FilterConfigModal) {
|
||||
return (trigger?.parentNode as HTMLElement) || document.body;
|
||||
}
|
||||
return document.body;
|
||||
},
|
||||
[appSection, parentRef, showOverflow],
|
||||
);
|
||||
|
||||
const likeInputPlaceholder = useMemo(() => {
|
||||
switch (operatorType) {
|
||||
case SelectFilterOperatorType.Contains:
|
||||
@@ -571,6 +584,7 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
|
||||
{ value: 'false', label: t('is') },
|
||||
]}
|
||||
onChange={handleExclusionToggle}
|
||||
getPopupContainer={getSelectPopupContainer}
|
||||
/>
|
||||
)}
|
||||
{isLikeOperator ? (
|
||||
@@ -595,12 +609,7 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
|
||||
allowSelectAll={!searchAllOptions}
|
||||
value={multiSelect ? filterState.value || [] : filterState.value}
|
||||
disabled={isDisabled}
|
||||
getPopupContainer={
|
||||
showOverflow
|
||||
? () => (parentRef?.current as HTMLElement) || document.body
|
||||
: (trigger: HTMLElement) =>
|
||||
(trigger?.parentNode as HTMLElement) || document.body
|
||||
}
|
||||
getPopupContainer={getSelectPopupContainer}
|
||||
showSearch={showSearch}
|
||||
mode={multiSelect ? 'multiple' : 'single'}
|
||||
placeholder={placeholderText}
|
||||
|
||||
@@ -38,7 +38,6 @@ const TestComponent = (props: ThemeSubMenuProps) => {
|
||||
return <Menu items={[menuItem]} />;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('useThemeMenuItems', () => {
|
||||
const defaultProps = {
|
||||
allowOSPreference: true,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* "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
|
||||
* 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
|
||||
@@ -27,133 +27,127 @@ import {
|
||||
ColumnDefinition,
|
||||
} from 'src/utils/common';
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('utils/common', () => {
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('optionFromValue', () => {
|
||||
test('converts values as expected', () => {
|
||||
expect(optionFromValue(false)).toEqual({
|
||||
value: false,
|
||||
label: FALSE_STRING,
|
||||
});
|
||||
expect(optionFromValue(true)).toEqual({
|
||||
value: true,
|
||||
label: TRUE_STRING,
|
||||
});
|
||||
expect(optionFromValue(null)).toEqual({
|
||||
value: NULL_STRING,
|
||||
label: NULL_STRING,
|
||||
});
|
||||
expect(optionFromValue('')).toEqual({
|
||||
value: '',
|
||||
label: '<empty string>',
|
||||
});
|
||||
expect(optionFromValue('foo')).toEqual({ value: 'foo', label: 'foo' });
|
||||
expect(optionFromValue(5)).toEqual({ value: 5, label: '5' });
|
||||
});
|
||||
test('converts values as expected', () => {
|
||||
expect(optionFromValue(false)).toEqual({
|
||||
value: false,
|
||||
label: FALSE_STRING,
|
||||
});
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('prepareCopyToClipboardTabularData', () => {
|
||||
test('converts empty array', () => {
|
||||
const data: TabularDataRow[] = [];
|
||||
const columns: string[] = [];
|
||||
expect(prepareCopyToClipboardTabularData(data, columns)).toEqual('');
|
||||
});
|
||||
test('converts non empty array', () => {
|
||||
const data: TabularDataRow[] = [
|
||||
{ column1: 'lorem', column2: 'ipsum' },
|
||||
{ column1: 'dolor', column2: 'sit', column3: 'amet' },
|
||||
];
|
||||
const columns: string[] = ['column1', 'column2', 'column3'];
|
||||
expect(prepareCopyToClipboardTabularData(data, columns)).toEqual(
|
||||
'column1\tcolumn2\tcolumn3\nlorem\tipsum\t\ndolor\tsit\tamet\n',
|
||||
);
|
||||
});
|
||||
test('includes 0 values and handle column objects', () => {
|
||||
const data: TabularDataRow[] = [
|
||||
{ column1: 0, column2: 0 },
|
||||
{ column1: 1, column2: -1, 0: 0 },
|
||||
];
|
||||
const columns: ColumnDefinition[] = [
|
||||
{ name: 'column1' },
|
||||
{ name: 'column2' },
|
||||
{ name: '0' },
|
||||
];
|
||||
expect(prepareCopyToClipboardTabularData(data, columns)).toEqual(
|
||||
'column1\tcolumn2\t0\n0\t0\t\n1\t-1\t0\n',
|
||||
);
|
||||
});
|
||||
expect(optionFromValue(true)).toEqual({
|
||||
value: true,
|
||||
label: TRUE_STRING,
|
||||
});
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('applyFormattingToTabularData', () => {
|
||||
test('does not mutate empty array', () => {
|
||||
const data: TabularDataRow[] = [];
|
||||
expect(applyFormattingToTabularData(data, [])).toEqual(data);
|
||||
});
|
||||
test('does not mutate array without temporal column', () => {
|
||||
const data: TabularDataRow[] = [
|
||||
{ column1: 'lorem', column2: 'ipsum' },
|
||||
{ column1: 'dolor', column2: 'sit', column3: 'amet' },
|
||||
];
|
||||
expect(applyFormattingToTabularData(data, [])).toEqual(data);
|
||||
});
|
||||
test('changes formatting of columns selected for formatting', () => {
|
||||
const originalData: TabularDataRow[] = [
|
||||
{
|
||||
__timestamp: null,
|
||||
column1: 'lorem',
|
||||
column2: 1590014060000,
|
||||
column3: 1507680000000,
|
||||
},
|
||||
{
|
||||
__timestamp: 0,
|
||||
column1: 'ipsum',
|
||||
column2: 1590075817000,
|
||||
column3: 1513641600000,
|
||||
},
|
||||
{
|
||||
__timestamp: 1594285437771,
|
||||
column1: 'dolor',
|
||||
column2: 1591062977000,
|
||||
column3: 1516924800000,
|
||||
},
|
||||
{
|
||||
__timestamp: 1594285441675,
|
||||
column1: 'sit',
|
||||
column2: 1591397351000,
|
||||
column3: 1518566400000,
|
||||
},
|
||||
];
|
||||
const timeFormattedColumns: string[] = ['__timestamp', 'column3'];
|
||||
const expectedData: TabularDataRow[] = [
|
||||
{
|
||||
__timestamp: null,
|
||||
column1: 'lorem',
|
||||
column2: 1590014060000,
|
||||
column3: '2017-10-11 00:00:00',
|
||||
},
|
||||
{
|
||||
__timestamp: '1970-01-01 00:00:00',
|
||||
column1: 'ipsum',
|
||||
column2: 1590075817000,
|
||||
column3: '2017-12-19 00:00:00',
|
||||
},
|
||||
{
|
||||
__timestamp: '2020-07-09 09:03:57',
|
||||
column1: 'dolor',
|
||||
column2: 1591062977000,
|
||||
column3: '2018-01-26 00:00:00',
|
||||
},
|
||||
{
|
||||
__timestamp: '2020-07-09 09:04:01',
|
||||
column1: 'sit',
|
||||
column2: 1591397351000,
|
||||
column3: '2018-02-14 00:00:00',
|
||||
},
|
||||
];
|
||||
expect(
|
||||
applyFormattingToTabularData(originalData, timeFormattedColumns),
|
||||
).toEqual(expectedData);
|
||||
});
|
||||
expect(optionFromValue(null)).toEqual({
|
||||
value: NULL_STRING,
|
||||
label: NULL_STRING,
|
||||
});
|
||||
expect(optionFromValue('')).toEqual({
|
||||
value: '',
|
||||
label: '<empty string>',
|
||||
});
|
||||
expect(optionFromValue('foo')).toEqual({ value: 'foo', label: 'foo' });
|
||||
expect(optionFromValue(5)).toEqual({ value: 5, label: '5' });
|
||||
});
|
||||
|
||||
test('converts empty array', () => {
|
||||
const data: TabularDataRow[] = [];
|
||||
const columns: string[] = [];
|
||||
expect(prepareCopyToClipboardTabularData(data, columns)).toEqual('');
|
||||
});
|
||||
|
||||
test('converts non empty array', () => {
|
||||
const data: TabularDataRow[] = [
|
||||
{ column1: 'lorem', column2: 'ipsum' },
|
||||
{ column1: 'dolor', column2: 'sit', column3: 'amet' },
|
||||
];
|
||||
const columns: string[] = ['column1', 'column2', 'column3'];
|
||||
expect(prepareCopyToClipboardTabularData(data, columns)).toEqual(
|
||||
'column1\tcolumn2\tcolumn3\nlorem\tipsum\t\ndolor\tsit\tamet\n',
|
||||
);
|
||||
});
|
||||
|
||||
test('includes 0 values and handle column objects', () => {
|
||||
const data: TabularDataRow[] = [
|
||||
{ column1: 0, column2: 0 },
|
||||
{ column1: 1, column2: -1, 0: 0 },
|
||||
];
|
||||
const columns: ColumnDefinition[] = [
|
||||
{ name: 'column1' },
|
||||
{ name: 'column2' },
|
||||
{ name: '0' },
|
||||
];
|
||||
expect(prepareCopyToClipboardTabularData(data, columns)).toEqual(
|
||||
'column1\tcolumn2\t0\n0\t0\t\n1\t-1\t0\n',
|
||||
);
|
||||
});
|
||||
|
||||
test('does not mutate empty array', () => {
|
||||
const data: TabularDataRow[] = [];
|
||||
expect(applyFormattingToTabularData(data, [])).toEqual(data);
|
||||
});
|
||||
|
||||
test('does not mutate array without temporal column', () => {
|
||||
const data: TabularDataRow[] = [
|
||||
{ column1: 'lorem', column2: 'ipsum' },
|
||||
{ column1: 'dolor', column2: 'sit', column3: 'amet' },
|
||||
];
|
||||
expect(applyFormattingToTabularData(data, [])).toEqual(data);
|
||||
});
|
||||
|
||||
test('changes formatting of columns selected for formatting', () => {
|
||||
const originalData: TabularDataRow[] = [
|
||||
{
|
||||
__timestamp: null,
|
||||
column1: 'lorem',
|
||||
column2: 1590014060000,
|
||||
column3: 1507680000000,
|
||||
},
|
||||
{
|
||||
__timestamp: 0,
|
||||
column1: 'ipsum',
|
||||
column2: 1590075817000,
|
||||
column3: 1513641600000,
|
||||
},
|
||||
{
|
||||
__timestamp: 1594285437771,
|
||||
column1: 'dolor',
|
||||
column2: 1591062977000,
|
||||
column3: 1516924800000,
|
||||
},
|
||||
{
|
||||
__timestamp: 1594285441675,
|
||||
column1: 'sit',
|
||||
column2: 1591397351000,
|
||||
column3: 1518566400000,
|
||||
},
|
||||
];
|
||||
const timeFormattedColumns: string[] = ['__timestamp', 'column3'];
|
||||
const expectedData: TabularDataRow[] = [
|
||||
{
|
||||
__timestamp: null,
|
||||
column1: 'lorem',
|
||||
column2: 1590014060000,
|
||||
column3: '2017-10-11 00:00:00',
|
||||
},
|
||||
{
|
||||
__timestamp: '1970-01-01 00:00:00',
|
||||
column1: 'ipsum',
|
||||
column2: 1590075817000,
|
||||
column3: '2017-12-19 00:00:00',
|
||||
},
|
||||
{
|
||||
__timestamp: '2020-07-09 09:03:57',
|
||||
column1: 'dolor',
|
||||
column2: 1591062977000,
|
||||
column3: '2018-01-26 00:00:00',
|
||||
},
|
||||
{
|
||||
__timestamp: '2020-07-09 09:04:01',
|
||||
column1: 'sit',
|
||||
column2: 1591397351000,
|
||||
column3: '2018-02-14 00:00:00',
|
||||
},
|
||||
];
|
||||
expect(
|
||||
applyFormattingToTabularData(originalData, timeFormattedColumns),
|
||||
).toEqual(expectedData);
|
||||
});
|
||||
|
||||
@@ -17,8 +17,6 @@
|
||||
# isort:skip_file
|
||||
|
||||
import logging
|
||||
import random
|
||||
import string
|
||||
import uuid as uuid_module
|
||||
from typing import Any, Optional, Callable
|
||||
from collections.abc import Iterator
|
||||
@@ -50,13 +48,6 @@ DEFAULT_CHART_HEIGHT = 50
|
||||
DEFAULT_CHART_WIDTH = 4
|
||||
|
||||
|
||||
def suffix(length: int = 8) -> str:
|
||||
return "".join(
|
||||
random.SystemRandom().choice(string.ascii_uppercase + string.digits)
|
||||
for _ in range(length)
|
||||
)
|
||||
|
||||
|
||||
def get_default_position(title: str) -> dict[str, Any]:
|
||||
return {
|
||||
"DASHBOARD_VERSION_KEY": "v2",
|
||||
@@ -72,12 +63,12 @@ def get_default_position(title: str) -> dict[str, Any]:
|
||||
|
||||
|
||||
def append_charts(position: dict[str, Any], charts: set[Slice]) -> dict[str, Any]:
|
||||
chart_hashes = [f"CHART-{suffix()}" for _ in charts]
|
||||
chart_hashes = [f"CHART-{str(chart.uuid)}" for chart in charts]
|
||||
|
||||
# if we have ROOT_ID/GRID_ID, append orphan charts to a new row inside the grid
|
||||
row_hash = None
|
||||
if "ROOT_ID" in position and "GRID_ID" in position["ROOT_ID"]["children"]:
|
||||
row_hash = f"ROW-N-{suffix()}"
|
||||
row_hash = f"ROW-N-{len(position['GRID_ID']['children'])}"
|
||||
position["GRID_ID"]["children"].append(row_hash)
|
||||
position[row_hash] = {
|
||||
"children": chart_hashes,
|
||||
|
||||
@@ -408,20 +408,30 @@ AUTH_PASSWORD_COMMON_BLOCKLIST: list[str] = []
|
||||
APP_NAME = "Superset"
|
||||
|
||||
# Specify the App icon
|
||||
# NOTE: This variable is used to populate THEME_DEFAULT. If you override this in
|
||||
# superset_config.py, you must also override THEME_DEFAULT to see the change,
|
||||
# or set THEME_DEFAULT["token"]["brandLogoUrl"] directly.
|
||||
APP_ICON = "/static/assets/images/superset-logo-horiz.png"
|
||||
|
||||
# Specify where clicking the logo would take the user'
|
||||
# Specify where clicking the logo would take the user
|
||||
# Default value of None will take you to '/superset/welcome'
|
||||
# You can also specify a relative URL e.g. '/superset/welcome' or '/dashboards/list'
|
||||
# or you can specify a full URL e.g. 'https://foo.bar'
|
||||
# NOTE: Overriding this in superset_config.py automatically updates the logo link
|
||||
# (THEME_DEFAULT["token"]["brandLogoHref"]); see sync_theme_logo_href below.
|
||||
LOGO_TARGET_PATH = None
|
||||
|
||||
# Specify tooltip that should appear when hovering over the App Icon/Logo
|
||||
# NOTE: This variable is deprecated and not used in the new theme system.
|
||||
LOGO_TOOLTIP = ""
|
||||
|
||||
# Specify any text that should appear to the right of the logo
|
||||
# NOTE: This variable is deprecated and not used in the new theme system.
|
||||
LOGO_RIGHT_TEXT: Callable[[], str] | str = ""
|
||||
|
||||
# APP_ICON_WIDTH is deprecated.
|
||||
# Use THEME_DEFAULT["token"]["brandLogoHeight"] instead (default: "24px").
|
||||
|
||||
# Enables SWAGGER UI for superset openapi spec
|
||||
# ex: http://localhost:8080/swagger/v1
|
||||
FAB_API_SWAGGER_UI = True
|
||||
@@ -1008,9 +1018,10 @@ THEME_DEFAULT: Theme = {
|
||||
"brandLogoAlt": "Apache Superset",
|
||||
"brandLogoUrl": APP_ICON,
|
||||
"brandLogoMargin": "18px 0",
|
||||
"brandLogoHref": "/",
|
||||
"brandLogoHref": LOGO_TARGET_PATH or "/",
|
||||
"brandLogoHeight": "24px",
|
||||
# Spinner
|
||||
# Spinner - Set this to use a custom GIF/image loader
|
||||
# "brandSpinnerUrl": "/static/assets/images/loading.gif",
|
||||
"brandSpinnerUrl": None,
|
||||
"brandSpinnerSvg": None,
|
||||
# Default colors
|
||||
@@ -1052,6 +1063,22 @@ THEME_DARK: Optional[Theme] = {
|
||||
"algorithm": "dark",
|
||||
}
|
||||
|
||||
|
||||
def sync_theme_logo_href(
|
||||
theme: Optional[Theme], logo_target_path: Optional[str]
|
||||
) -> None:
|
||||
"""
|
||||
Apply ``LOGO_TARGET_PATH`` to a theme's ``brandLogoHref`` token.
|
||||
|
||||
``THEME_DEFAULT`` / ``THEME_DARK`` are built above, before ``superset_config.py``
|
||||
and environment overrides are applied at the bottom of this module. This is
|
||||
re-run after those overrides so that setting only ``LOGO_TARGET_PATH`` updates
|
||||
the logo link without also having to override the whole theme object.
|
||||
"""
|
||||
if theme and logo_target_path and isinstance(theme.get("token"), dict):
|
||||
theme["token"]["brandLogoHref"] = logo_target_path
|
||||
|
||||
|
||||
# Theme behavior and user preference settings
|
||||
# To force a single theme on all users, set THEME_DARK = None
|
||||
# When both THEME_DEFAULT and THEME_DARK are defined:
|
||||
@@ -2831,3 +2858,9 @@ for env_var in ENV_VAR_KEYS:
|
||||
if env_var in os.environ:
|
||||
config_var = env_var.replace("SUPERSET__", "")
|
||||
globals()[config_var] = os.environ[env_var]
|
||||
|
||||
# THEME_DEFAULT / THEME_DARK are defined before the overrides above are applied,
|
||||
# so re-sync the logo link from the final LOGO_TARGET_PATH value here. This lets
|
||||
# users set just LOGO_TARGET_PATH without also overriding the whole theme.
|
||||
sync_theme_logo_href(THEME_DEFAULT, LOGO_TARGET_PATH)
|
||||
sync_theme_logo_href(THEME_DARK, LOGO_TARGET_PATH)
|
||||
|
||||
@@ -31,7 +31,12 @@ from superset.exceptions import SupersetSecurityException
|
||||
from superset.extensions import cache_manager
|
||||
from superset.superset_typing import FlaskResponse
|
||||
from superset.utils import json
|
||||
from superset.utils.core import apply_max_row_limit, DatasourceType, SqlExpressionType
|
||||
from superset.utils.core import (
|
||||
apply_max_row_limit,
|
||||
DatasourceType,
|
||||
parse_boolean_string,
|
||||
SqlExpressionType,
|
||||
)
|
||||
from superset.views.base_api import BaseSupersetApi, statsd_metrics
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -125,13 +130,63 @@ class DatasourceRestApi(BaseSupersetApi):
|
||||
|
||||
row_limit = apply_max_row_limit(app.config["FILTER_SELECT_ROW_LIMIT"])
|
||||
denormalize_column = not datasource.normalize_columns
|
||||
|
||||
# Cache distinct column-value results so a dashboard with many filters
|
||||
# backed by the same (often heavy) virtual dataset doesn't re-execute
|
||||
# the wrapping query per filter (#39342).
|
||||
#
|
||||
# Key fields:
|
||||
# - ``rls`` — full RLS fingerprint via
|
||||
# ``security_manager.get_rls_cache_key`` (the canonical helper used
|
||||
# by viz.py and query_context_processor.py). This is the sole
|
||||
# security-isolation field — two users with identical effective
|
||||
# RLS share a cache entry (intentional: they would see identical
|
||||
# filtered values anyway), while users with different RLS, guest
|
||||
# sessions with different guest-token RLS, and anonymous sessions
|
||||
# with no RLS each get their own partition. We deliberately do
|
||||
# NOT include the raw user id; doing so would defeat the
|
||||
# intended cross-user cache sharing without adding any real
|
||||
# security boundary beyond what the RLS fingerprint already
|
||||
# provides.
|
||||
# - ``changed_on`` — auto-busts cached entries when the dataset's
|
||||
# underlying SQL is edited.
|
||||
# - ``uid`` / ``col`` / ``limit`` / ``denorm`` — basic query-shape
|
||||
# isolation so different inputs never collide.
|
||||
force = parse_boolean_string(request.args.get("force"))
|
||||
cache_key = (
|
||||
"col_values:"
|
||||
+ hashlib.sha256(
|
||||
json.dumps(
|
||||
{
|
||||
"uid": datasource.uid,
|
||||
"col": column_name,
|
||||
"limit": row_limit,
|
||||
"denorm": denormalize_column,
|
||||
"rls": security_manager.get_rls_cache_key(datasource),
|
||||
"changed_on": str(getattr(datasource, "changed_on", "")),
|
||||
},
|
||||
sort_keys=True,
|
||||
).encode()
|
||||
).hexdigest()
|
||||
)
|
||||
|
||||
if (
|
||||
not force
|
||||
and (cached := cache_manager.data_cache.get(cache_key)) is not None
|
||||
):
|
||||
logger.debug(
|
||||
"column-values cache HIT: uid=%s col=%s", datasource.uid, column_name
|
||||
)
|
||||
response = self.response(200, result=cached)
|
||||
response.headers["X-Cache-Status"] = "HIT"
|
||||
return response
|
||||
|
||||
try:
|
||||
payload = datasource.values_for_column(
|
||||
column_name=column_name,
|
||||
limit=row_limit,
|
||||
denormalize_column=denormalize_column,
|
||||
)
|
||||
return self.response(200, result=payload)
|
||||
except KeyError:
|
||||
return self.response(
|
||||
400, message=f"Column name {column_name} does not exist"
|
||||
@@ -145,6 +200,31 @@ class DatasourceRestApi(BaseSupersetApi):
|
||||
),
|
||||
)
|
||||
|
||||
# Warn before caching very large payloads (high-cardinality columns)
|
||||
# so operators can spot cache-memory pressure before Redis OOMs.
|
||||
# Threshold is operator-tunable; defaults to 100k rows.
|
||||
warn_threshold = app.config.get("FILTER_VALUES_CACHE_WARN_THRESHOLD", 100_000)
|
||||
if (payload_size := len(payload)) > warn_threshold:
|
||||
logger.warning(
|
||||
"column-values payload exceeds cache-warn threshold: "
|
||||
"uid=%s col=%s rows=%d threshold=%d",
|
||||
datasource.uid,
|
||||
column_name,
|
||||
payload_size,
|
||||
warn_threshold,
|
||||
)
|
||||
|
||||
timeout = datasource.cache_timeout or app.config.get(
|
||||
"CACHE_DEFAULT_TIMEOUT", 300
|
||||
)
|
||||
cache_manager.data_cache.set(cache_key, payload, timeout=timeout)
|
||||
logger.debug(
|
||||
"column-values cache MISS: uid=%s col=%s", datasource.uid, column_name
|
||||
)
|
||||
response = self.response(200, result=payload)
|
||||
response.headers["X-Cache-Status"] = "MISS"
|
||||
return response
|
||||
|
||||
@expose(
|
||||
"/<datasource_type>/<int:datasource_id>/validate_expression/",
|
||||
methods=("POST",),
|
||||
|
||||
@@ -512,7 +512,10 @@ class BigQueryEngineSpec(BaseEngineSpec): # pylint: disable=too-many-public-met
|
||||
database, catalog=table.catalog, schema=table.schema
|
||||
) as engine:
|
||||
client = cls._get_client(engine, database)
|
||||
bq_table = client.get_table(f"{table.schema}.{table.table}")
|
||||
table_ref = f"{table.schema}.{table.table}"
|
||||
if table.catalog:
|
||||
table_ref = f"{table.catalog}.{table_ref}"
|
||||
bq_table = client.get_table(table_ref)
|
||||
|
||||
if bq_table.time_partitioning:
|
||||
return bq_table.time_partitioning.field
|
||||
|
||||
92
superset/mcp_service/explore/schemas.py
Normal file
92
superset/mcp_service/explore/schemas.py
Normal file
@@ -0,0 +1,92 @@
|
||||
# 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.
|
||||
|
||||
"""
|
||||
Pydantic schemas for explore-related MCP tool outputs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from superset.mcp_service.common.error_schemas import ChartGenerationError
|
||||
|
||||
|
||||
class GenerateExploreLinkResponse(BaseModel):
|
||||
"""
|
||||
Output schema for the generate_explore_link tool.
|
||||
|
||||
On success, ``url`` is a fully-qualified Superset Explore URL that the
|
||||
user can open immediately, and ``form_data_key`` can be used to
|
||||
reconstruct or share the same configuration. On failure, ``url`` is
|
||||
empty and ``error`` is a ``ChartGenerationError``; its ``error_type``
|
||||
distinguishes ``dataset_not_found``, ``permission_denied``,
|
||||
``validation_error``, and ``generation_failed`` so callers can branch
|
||||
on failure mode without parsing free-text messages.
|
||||
"""
|
||||
|
||||
url: str = Field(
|
||||
...,
|
||||
description=(
|
||||
"Explore URL — open in a browser to view the interactive chart. "
|
||||
"Empty string on failure."
|
||||
),
|
||||
)
|
||||
form_data: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Raw Superset form_data dict that was encoded into the URL.",
|
||||
)
|
||||
permalink_key: str | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Durable permalink key for the generated Explore URL, when one "
|
||||
"was created. Prefer this over ``form_data_key`` for sharing; it "
|
||||
"survives cache eviction. Null on failure or when only an "
|
||||
"ephemeral form_data key is available."
|
||||
),
|
||||
)
|
||||
form_data_key: str | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Short, ephemeral cache key that represents this form_data "
|
||||
"configuration. Populated only when no ``permalink_key`` is "
|
||||
"available. Can be passed to the Explore UI as ?form_data_key=<key>."
|
||||
),
|
||||
)
|
||||
chart_type_label: str | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Human-readable label for the resulting chart type "
|
||||
"(e.g. 'table chart', 'interactive table chart'). "
|
||||
"Null on failure or when the viz_type has no specific label."
|
||||
),
|
||||
)
|
||||
error: ChartGenerationError | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Structured ChartGenerationError when generation fails, else "
|
||||
"null. Branch on error.error_type to handle specific failure "
|
||||
"modes (dataset_not_found, permission_denied, validation_error, "
|
||||
"generation_failed)."
|
||||
),
|
||||
)
|
||||
success: bool = Field(
|
||||
True,
|
||||
description="True when a valid URL was produced, False on any error.",
|
||||
)
|
||||
@@ -23,16 +23,14 @@ chart configuration.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastmcp import Context
|
||||
from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
|
||||
from superset.daos.dataset import DatasetDAO
|
||||
from superset.extensions import event_logger
|
||||
from superset.mcp_service.auth import has_dataset_access
|
||||
from superset.mcp_service.chart.chart_helpers import (
|
||||
extract_form_data_key_from_url,
|
||||
)
|
||||
from superset.mcp_service.chart.chart_helpers import extract_form_data_key_from_url
|
||||
from superset.mcp_service.chart.chart_utils import (
|
||||
generate_explore_link as generate_url,
|
||||
get_table_chart_type_label,
|
||||
@@ -42,8 +40,12 @@ from superset.mcp_service.chart.compile import validate_and_compile
|
||||
from superset.mcp_service.chart.schemas import (
|
||||
GenerateExploreLinkRequest,
|
||||
)
|
||||
from superset.mcp_service.chart.validation.dataset_validator import DatasetValidator
|
||||
from superset.mcp_service.common.error_schemas import ChartGenerationError
|
||||
from superset.mcp_service.explore.schemas import GenerateExploreLinkResponse
|
||||
from superset.mcp_service.utils.url_utils import (
|
||||
extract_permalink_key_from_url,
|
||||
get_superset_base_url,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -60,7 +62,7 @@ logger = logging.getLogger(__name__)
|
||||
)
|
||||
async def generate_explore_link(
|
||||
request: GenerateExploreLinkRequest, ctx: Context
|
||||
) -> Dict[str, Any]:
|
||||
) -> GenerateExploreLinkResponse:
|
||||
"""Generate explore URL for interactive visualization.
|
||||
|
||||
PREFERRED TOOL for most visualization requests.
|
||||
@@ -118,8 +120,6 @@ async def generate_explore_link(
|
||||
try:
|
||||
await ctx.report_progress(1, 4, "Validating dataset exists")
|
||||
with event_logger.log_context(action="mcp.generate_explore_link.dataset_check"):
|
||||
from superset.daos.dataset import DatasetDAO
|
||||
|
||||
dataset = None
|
||||
if isinstance(request.dataset_id, int) or (
|
||||
isinstance(request.dataset_id, str) and request.dataset_id.isdigit()
|
||||
@@ -137,17 +137,28 @@ async def generate_explore_link(
|
||||
await ctx.warning(
|
||||
"Dataset not found: dataset_id=%s" % (request.dataset_id,)
|
||||
)
|
||||
return {
|
||||
"url": "",
|
||||
"form_data": {},
|
||||
"permalink_key": None,
|
||||
"form_data_key": None,
|
||||
"chart_type_label": None,
|
||||
"error": (
|
||||
f"Dataset not found: {request.dataset_id}. "
|
||||
"Use list_datasets to find valid dataset IDs."
|
||||
return GenerateExploreLinkResponse(
|
||||
url="",
|
||||
form_data={},
|
||||
permalink_key=None,
|
||||
form_data_key=None,
|
||||
chart_type_label=None,
|
||||
error=ChartGenerationError(
|
||||
error_type="dataset_not_found",
|
||||
error_code="MCP_EXPLORE_DATASET_NOT_FOUND",
|
||||
message=f"Dataset not found: {request.dataset_id}.",
|
||||
details=(
|
||||
f"No dataset found with identifier "
|
||||
f"'{request.dataset_id}'. Use list_datasets to "
|
||||
"find valid dataset IDs."
|
||||
),
|
||||
suggestions=[
|
||||
"Verify the dataset ID or UUID is correct",
|
||||
"Use the list_datasets tool to find available datasets",
|
||||
],
|
||||
),
|
||||
}
|
||||
success=False,
|
||||
)
|
||||
|
||||
if not has_dataset_access(dataset):
|
||||
logger.warning(
|
||||
@@ -157,24 +168,39 @@ async def generate_explore_link(
|
||||
await ctx.warning(
|
||||
"Dataset access denied: dataset_id=%s" % (request.dataset_id,)
|
||||
)
|
||||
return {
|
||||
"url": "",
|
||||
"form_data": {},
|
||||
"permalink_key": None,
|
||||
"form_data_key": None,
|
||||
"chart_type_label": None,
|
||||
"error": (
|
||||
f"Dataset not found: {request.dataset_id}. "
|
||||
"Use list_datasets to find valid dataset IDs."
|
||||
# User-facing message stays generic to avoid leaking dataset
|
||||
# existence; error_type lets programmatic callers distinguish.
|
||||
return GenerateExploreLinkResponse(
|
||||
url="",
|
||||
form_data={},
|
||||
permalink_key=None,
|
||||
form_data_key=None,
|
||||
chart_type_label=None,
|
||||
error=ChartGenerationError(
|
||||
error_type="permission_denied",
|
||||
# Same code as the not-found path: the user-visible
|
||||
# message is intentionally indistinguishable so
|
||||
# access policy isn't disclosed; ``error_type`` is
|
||||
# the programmatic distinguisher.
|
||||
error_code="MCP_EXPLORE_DATASET_NOT_FOUND",
|
||||
message=f"Dataset not found: {request.dataset_id}.",
|
||||
details=(
|
||||
f"No dataset found with identifier "
|
||||
f"'{request.dataset_id}'. Use list_datasets to "
|
||||
"find valid dataset IDs."
|
||||
),
|
||||
suggestions=[
|
||||
"Check that you have access to this dataset",
|
||||
"Use the list_datasets tool to find available datasets",
|
||||
],
|
||||
),
|
||||
}
|
||||
success=False,
|
||||
)
|
||||
|
||||
# When no config is provided, return a default explore URL that opens
|
||||
# the dataset in Superset without a preconfigured chart.
|
||||
if request.config is None:
|
||||
await ctx.report_progress(4, 4, "URL generation complete")
|
||||
from superset.mcp_service.utils.url_utils import get_superset_base_url
|
||||
|
||||
base_url = get_superset_base_url()
|
||||
default_url = (
|
||||
f"{base_url}/explore/?datasource_type=table&datasource_id={dataset.id}"
|
||||
@@ -182,14 +208,15 @@ async def generate_explore_link(
|
||||
await ctx.info(
|
||||
"Default explore link generated: dataset_id=%s" % (request.dataset_id,)
|
||||
)
|
||||
return {
|
||||
"url": default_url,
|
||||
"form_data": {},
|
||||
"permalink_key": None,
|
||||
"form_data_key": None,
|
||||
"chart_type_label": None,
|
||||
"error": None,
|
||||
}
|
||||
return GenerateExploreLinkResponse(
|
||||
url=default_url,
|
||||
form_data={},
|
||||
permalink_key=None,
|
||||
form_data_key=None,
|
||||
chart_type_label=None,
|
||||
error=None,
|
||||
success=True,
|
||||
)
|
||||
|
||||
await ctx.report_progress(2, 4, "Converting configuration to form data")
|
||||
with event_logger.log_context(action="mcp.generate_explore_link.form_data"):
|
||||
@@ -199,14 +226,28 @@ async def generate_explore_link(
|
||||
# Normalize column names to match canonical dataset column names
|
||||
# This fixes case sensitivity issues (e.g., 'order_date' vs 'OrderDate')
|
||||
try:
|
||||
from superset.mcp_service.chart.validation.dataset_validator import (
|
||||
DatasetValidator,
|
||||
)
|
||||
|
||||
normalized_config = DatasetValidator.normalize_column_names(
|
||||
config, request.dataset_id
|
||||
)
|
||||
except (ImportError, AttributeError, KeyError, ValueError, TypeError):
|
||||
except (
|
||||
ImportError,
|
||||
AttributeError,
|
||||
KeyError,
|
||||
ValueError,
|
||||
TypeError,
|
||||
) as norm_err:
|
||||
logger.warning(
|
||||
"Column normalization failed for dataset_id=%s; falling back "
|
||||
"to caller-supplied config. %s: %s",
|
||||
request.dataset_id,
|
||||
type(norm_err).__name__,
|
||||
norm_err,
|
||||
)
|
||||
await ctx.warning(
|
||||
"Column normalization failed for dataset_id=%s; using config "
|
||||
"as-supplied. Chart may behave unexpectedly if column names "
|
||||
"differ in case." % (request.dataset_id,)
|
||||
)
|
||||
normalized_config = config
|
||||
|
||||
# Map config to form_data using shared utilities
|
||||
@@ -242,25 +283,24 @@ async def generate_explore_link(
|
||||
await ctx.warning(
|
||||
"Explore link validation failed: error=%s" % (compile_result.error,)
|
||||
)
|
||||
error_payload: Dict[str, Any]
|
||||
if compile_result.error_obj is not None:
|
||||
error_payload = compile_result.error_obj.model_dump()
|
||||
error_payload = compile_result.error_obj
|
||||
else:
|
||||
error_payload = {
|
||||
"error_type": "validation_error",
|
||||
"message": "Explore link validation failed",
|
||||
"details": compile_result.error or "",
|
||||
"error_code": compile_result.error_code,
|
||||
"suggestions": [],
|
||||
}
|
||||
return {
|
||||
"url": "",
|
||||
"form_data": form_data,
|
||||
"permalink_key": None,
|
||||
"form_data_key": None,
|
||||
"chart_type_label": None,
|
||||
"error": error_payload,
|
||||
}
|
||||
error_payload = ChartGenerationError(
|
||||
error_type="validation_error",
|
||||
message="Explore link validation failed",
|
||||
details=compile_result.error or "",
|
||||
error_code=compile_result.error_code,
|
||||
)
|
||||
return GenerateExploreLinkResponse(
|
||||
url="",
|
||||
form_data=form_data,
|
||||
permalink_key=None,
|
||||
form_data_key=None,
|
||||
chart_type_label=None,
|
||||
error=error_payload,
|
||||
success=False,
|
||||
)
|
||||
|
||||
await ctx.report_progress(3, 4, "Generating explore URL")
|
||||
with event_logger.log_context(
|
||||
@@ -284,14 +324,15 @@ async def generate_explore_link(
|
||||
% (len(explore_url or ""), request.dataset_id, permalink_key, form_data_key)
|
||||
)
|
||||
|
||||
return {
|
||||
"url": explore_url,
|
||||
"form_data": form_data,
|
||||
"permalink_key": permalink_key,
|
||||
"form_data_key": form_data_key,
|
||||
"chart_type_label": get_table_chart_type_label(form_data.get("viz_type")),
|
||||
"error": None,
|
||||
}
|
||||
return GenerateExploreLinkResponse(
|
||||
url=explore_url,
|
||||
form_data=form_data,
|
||||
permalink_key=permalink_key,
|
||||
form_data_key=form_data_key,
|
||||
chart_type_label=get_table_chart_type_label(form_data.get("viz_type")),
|
||||
error=None,
|
||||
success=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await ctx.error(
|
||||
@@ -303,11 +344,23 @@ async def generate_explore_link(
|
||||
str(e),
|
||||
)
|
||||
)
|
||||
return {
|
||||
"url": "",
|
||||
"form_data": {},
|
||||
"permalink_key": None,
|
||||
"form_data_key": None,
|
||||
"chart_type_label": None,
|
||||
"error": f"Failed to generate explore link: {str(e)}",
|
||||
}
|
||||
# ``details`` intentionally omits ``str(e)`` so internal info
|
||||
# (file paths, schema names) isn't echoed to the MCP response.
|
||||
# The raw exception is already captured in the server-side log
|
||||
# above via ``ctx.error``.
|
||||
return GenerateExploreLinkResponse(
|
||||
url="",
|
||||
form_data={},
|
||||
permalink_key=None,
|
||||
form_data_key=None,
|
||||
chart_type_label=None,
|
||||
error=ChartGenerationError(
|
||||
error_type="generation_failed",
|
||||
error_code="MCP_EXPLORE_GENERATION_FAILED",
|
||||
message="Failed to generate explore link",
|
||||
details=(
|
||||
"An unexpected error occurred; check server logs for details."
|
||||
),
|
||||
),
|
||||
success=False,
|
||||
)
|
||||
|
||||
@@ -1626,6 +1626,32 @@ class DeckGLMultiLayer(BaseViz):
|
||||
is_timeseries = False
|
||||
credits = '<a href="https://uber.github.io/deck.gl/">deck.gl</a>'
|
||||
|
||||
@staticmethod
|
||||
def _merge_filter_metadata(
|
||||
*filter_groups: list[dict[str, Any]] | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Merge multiple filter metadata lists, de-duplicating identical entries.
|
||||
|
||||
Used to combine the applied/rejected filter metadata reported by each
|
||||
child layer into a single list for the multi-layer chart payload.
|
||||
"""
|
||||
merged_filters: list[dict[str, Any]] = []
|
||||
seen_filters: set[str] = set()
|
||||
|
||||
for filters in filter_groups:
|
||||
for filter_metadata in filters or []:
|
||||
if not isinstance(filter_metadata, dict):
|
||||
continue
|
||||
|
||||
cache_key = json.dumps(filter_metadata, sort_keys=True)
|
||||
if cache_key in seen_filters:
|
||||
continue
|
||||
|
||||
merged_filters.append(filter_metadata)
|
||||
seen_filters.add(cache_key)
|
||||
|
||||
return merged_filters
|
||||
|
||||
@deprecated(deprecated_in="3.0")
|
||||
def query_obj(self) -> QueryObjectDict:
|
||||
return {}
|
||||
@@ -1726,6 +1752,8 @@ class DeckGLMultiLayer(BaseViz):
|
||||
slices = db.session.query(Slice).filter(Slice.id.in_(slice_ids)).all()
|
||||
|
||||
features: dict[str, list[Any]] = {}
|
||||
self.applied_filters = []
|
||||
self.rejected_filters = []
|
||||
|
||||
for layer_index, slc in enumerate(slices):
|
||||
form_data = slc.form_data
|
||||
@@ -1738,6 +1766,15 @@ class DeckGLMultiLayer(BaseViz):
|
||||
|
||||
viz_instance = viz_class(datasource=slc.datasource, form_data=form_data)
|
||||
payload = viz_instance.get_payload()
|
||||
if payload:
|
||||
self.applied_filters = self._merge_filter_metadata(
|
||||
self.applied_filters,
|
||||
payload.get("applied_filters"),
|
||||
)
|
||||
self.rejected_filters = self._merge_filter_metadata(
|
||||
self.rejected_filters,
|
||||
payload.get("rejected_filters"),
|
||||
)
|
||||
|
||||
if (
|
||||
payload
|
||||
@@ -1755,6 +1792,25 @@ class DeckGLMultiLayer(BaseViz):
|
||||
"slices": [slc.data for slc in slices if slc.data is not None],
|
||||
}
|
||||
|
||||
@deprecated(deprecated_in="3.0")
|
||||
def get_payload(self, query_obj: QueryObjectDict | None = None) -> VizPayload:
|
||||
"""Extend the base payload with merged child-layer filter metadata.
|
||||
|
||||
The applied/rejected filter metadata collected from each sub-slice in
|
||||
``get_data`` is merged into the base payload so dashboard filter badges
|
||||
reflect the filters applied across all layers.
|
||||
"""
|
||||
payload = super().get_payload(query_obj)
|
||||
payload["applied_filters"] = self._merge_filter_metadata(
|
||||
payload.get("applied_filters"),
|
||||
self.applied_filters,
|
||||
)
|
||||
payload["rejected_filters"] = self._merge_filter_metadata(
|
||||
payload.get("rejected_filters"),
|
||||
self.rejected_filters,
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
class BaseDeckGLViz(BaseViz):
|
||||
"""Base class for deck.gl visualizations"""
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
import itertools
|
||||
from unittest.mock import MagicMock, patch # noqa: F401
|
||||
|
||||
import pytest
|
||||
@@ -348,22 +347,21 @@ class TestExportDashboardsCommand(SupersetTestCase):
|
||||
}
|
||||
|
||||
@pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
|
||||
@patch("superset.commands.dashboard.export.suffix")
|
||||
def test_append_charts(self, mock_suffix):
|
||||
def test_append_charts(self):
|
||||
"""Test that orphaned charts are added to the dashboard position"""
|
||||
# return deterministic IDs
|
||||
mock_suffix.side_effect = (str(i) for i in itertools.count(1))
|
||||
|
||||
# IDs are deterministic: charts are keyed by their UUID and rows are
|
||||
# numbered by their position within the grid.
|
||||
position = get_default_position("example")
|
||||
chart_1 = (
|
||||
db.session.query(Slice).filter_by(slice_name="World's Population").one()
|
||||
)
|
||||
chart_1_key = f"CHART-{chart_1.uuid}"
|
||||
new_position = append_charts(position, {chart_1})
|
||||
assert new_position == {
|
||||
"DASHBOARD_VERSION_KEY": "v2",
|
||||
"ROOT_ID": {"children": ["GRID_ID"], "id": "ROOT_ID", "type": "ROOT"},
|
||||
"GRID_ID": {
|
||||
"children": ["ROW-N-2"],
|
||||
"children": ["ROW-N-0"],
|
||||
"id": "GRID_ID",
|
||||
"parents": ["ROOT_ID"],
|
||||
"type": "GRID",
|
||||
@@ -373,16 +371,16 @@ class TestExportDashboardsCommand(SupersetTestCase):
|
||||
"meta": {"text": "example"},
|
||||
"type": "HEADER",
|
||||
},
|
||||
"ROW-N-2": {
|
||||
"children": ["CHART-1"],
|
||||
"id": "ROW-N-2",
|
||||
"ROW-N-0": {
|
||||
"children": [chart_1_key],
|
||||
"id": "ROW-N-0",
|
||||
"meta": {"0": "ROOT_ID", "background": "BACKGROUND_TRANSPARENT"},
|
||||
"type": "ROW",
|
||||
"parents": ["ROOT_ID", "GRID_ID"],
|
||||
},
|
||||
"CHART-1": {
|
||||
chart_1_key: {
|
||||
"children": [],
|
||||
"id": "CHART-1",
|
||||
"id": chart_1_key,
|
||||
"meta": {
|
||||
"chartId": chart_1.id,
|
||||
"height": 50,
|
||||
@@ -391,19 +389,18 @@ class TestExportDashboardsCommand(SupersetTestCase):
|
||||
"width": 4,
|
||||
},
|
||||
"type": "CHART",
|
||||
"parents": ["ROOT_ID", "GRID_ID", "ROW-N-2"],
|
||||
"parents": ["ROOT_ID", "GRID_ID", "ROW-N-0"],
|
||||
},
|
||||
}
|
||||
|
||||
chart_2 = (
|
||||
db.session.query(Slice).filter_by(slice_name="World's Population").one()
|
||||
)
|
||||
chart_2 = db.session.query(Slice).filter_by(slice_name="Growth Rate").one()
|
||||
chart_2_key = f"CHART-{chart_2.uuid}"
|
||||
new_position = append_charts(new_position, {chart_2})
|
||||
assert new_position == {
|
||||
"DASHBOARD_VERSION_KEY": "v2",
|
||||
"ROOT_ID": {"children": ["GRID_ID"], "id": "ROOT_ID", "type": "ROOT"},
|
||||
"GRID_ID": {
|
||||
"children": ["ROW-N-2", "ROW-N-4"],
|
||||
"children": ["ROW-N-0", "ROW-N-1"],
|
||||
"id": "GRID_ID",
|
||||
"parents": ["ROOT_ID"],
|
||||
"type": "GRID",
|
||||
@@ -413,23 +410,23 @@ class TestExportDashboardsCommand(SupersetTestCase):
|
||||
"meta": {"text": "example"},
|
||||
"type": "HEADER",
|
||||
},
|
||||
"ROW-N-2": {
|
||||
"children": ["CHART-1"],
|
||||
"id": "ROW-N-2",
|
||||
"ROW-N-0": {
|
||||
"children": [chart_1_key],
|
||||
"id": "ROW-N-0",
|
||||
"meta": {"0": "ROOT_ID", "background": "BACKGROUND_TRANSPARENT"},
|
||||
"type": "ROW",
|
||||
"parents": ["ROOT_ID", "GRID_ID"],
|
||||
},
|
||||
"ROW-N-4": {
|
||||
"children": ["CHART-3"],
|
||||
"id": "ROW-N-4",
|
||||
"ROW-N-1": {
|
||||
"children": [chart_2_key],
|
||||
"id": "ROW-N-1",
|
||||
"meta": {"0": "ROOT_ID", "background": "BACKGROUND_TRANSPARENT"},
|
||||
"type": "ROW",
|
||||
"parents": ["ROOT_ID", "GRID_ID"],
|
||||
},
|
||||
"CHART-1": {
|
||||
chart_1_key: {
|
||||
"children": [],
|
||||
"id": "CHART-1",
|
||||
"id": chart_1_key,
|
||||
"meta": {
|
||||
"chartId": chart_1.id,
|
||||
"height": 50,
|
||||
@@ -438,29 +435,29 @@ class TestExportDashboardsCommand(SupersetTestCase):
|
||||
"width": 4,
|
||||
},
|
||||
"type": "CHART",
|
||||
"parents": ["ROOT_ID", "GRID_ID", "ROW-N-2"],
|
||||
"parents": ["ROOT_ID", "GRID_ID", "ROW-N-0"],
|
||||
},
|
||||
"CHART-3": {
|
||||
chart_2_key: {
|
||||
"children": [],
|
||||
"id": "CHART-3",
|
||||
"id": chart_2_key,
|
||||
"meta": {
|
||||
"chartId": chart_2.id,
|
||||
"height": 50,
|
||||
"sliceName": "World's Population",
|
||||
"sliceName": "Growth Rate",
|
||||
"uuid": str(chart_2.uuid),
|
||||
"width": 4,
|
||||
},
|
||||
"type": "CHART",
|
||||
"parents": ["ROOT_ID", "GRID_ID", "ROW-N-4"],
|
||||
"parents": ["ROOT_ID", "GRID_ID", "ROW-N-1"],
|
||||
},
|
||||
}
|
||||
|
||||
position = {"DASHBOARD_VERSION_KEY": "v2"}
|
||||
new_position = append_charts(position, [chart_1, chart_2])
|
||||
assert new_position == {
|
||||
"CHART-5": {
|
||||
chart_1_key: {
|
||||
"children": [],
|
||||
"id": "CHART-5",
|
||||
"id": chart_1_key,
|
||||
"meta": {
|
||||
"chartId": chart_1.id,
|
||||
"height": 50,
|
||||
@@ -470,13 +467,13 @@ class TestExportDashboardsCommand(SupersetTestCase):
|
||||
},
|
||||
"type": "CHART",
|
||||
},
|
||||
"CHART-6": {
|
||||
chart_2_key: {
|
||||
"children": [],
|
||||
"id": "CHART-6",
|
||||
"id": chart_2_key,
|
||||
"meta": {
|
||||
"chartId": chart_2.id,
|
||||
"height": 50,
|
||||
"sliceName": "World's Population",
|
||||
"sliceName": "Growth Rate",
|
||||
"uuid": str(chart_2.uuid),
|
||||
"width": 4,
|
||||
},
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import ANY, patch
|
||||
|
||||
import pytest
|
||||
@@ -23,12 +24,21 @@ from sqlalchemy.sql.elements import TextClause
|
||||
from superset import db, security_manager
|
||||
from superset.connectors.sqla.models import SqlaTable
|
||||
from superset.daos.exceptions import DatasourceTypeNotSupportedError
|
||||
from superset.extensions import cache_manager
|
||||
from superset.utils import json
|
||||
from tests.integration_tests.base_tests import SupersetTestCase
|
||||
from tests.integration_tests.constants import ADMIN_USERNAME, GAMMA_USERNAME
|
||||
|
||||
|
||||
class TestDatasourceApi(SupersetTestCase):
|
||||
def setUp(self):
|
||||
# Clear the column-values cache before every test so that
|
||||
# ``get_column_values`` always re-runs ``values_for_column`` rather
|
||||
# than returning a payload populated by a previous test. Prevents
|
||||
# order-dependent flakes now that the endpoint caches its result.
|
||||
super().setUp()
|
||||
cache_manager.data_cache.clear()
|
||||
|
||||
def get_virtual_dataset(self):
|
||||
return (
|
||||
db.session.query(SqlaTable)
|
||||
@@ -205,6 +215,123 @@ class TestDatasourceApi(SupersetTestCase):
|
||||
response = json.loads(rv.data.decode("utf-8"))
|
||||
assert response["result"] == []
|
||||
|
||||
@pytest.mark.usefixtures("app_context", "virtual_dataset")
|
||||
@patch("superset.models.helpers.ExploreMixin.values_for_column")
|
||||
def test_get_column_values_cache_hit_skips_query(self, values_for_column_mock):
|
||||
"""Regression test for #39342.
|
||||
|
||||
Two identical requests for the same column on the same datasource
|
||||
should hit ``values_for_column`` exactly once — the second request
|
||||
returns the cached payload.
|
||||
"""
|
||||
cache_manager.data_cache.clear()
|
||||
values_for_column_mock.return_value = ["a", "b", "c"]
|
||||
self.login(ADMIN_USERNAME)
|
||||
table = self.get_virtual_dataset()
|
||||
url = f"api/v1/datasource/table/{table.id}/column/col2/values/"
|
||||
|
||||
rv1 = self.client.get(url)
|
||||
rv2 = self.client.get(url)
|
||||
|
||||
assert rv1.status_code == 200
|
||||
assert rv2.status_code == 200
|
||||
assert json.loads(rv2.data.decode("utf-8"))["result"] == ["a", "b", "c"]
|
||||
assert values_for_column_mock.call_count == 1
|
||||
|
||||
@pytest.mark.usefixtures("app_context", "virtual_dataset")
|
||||
@patch("superset.models.helpers.ExploreMixin.values_for_column")
|
||||
def test_get_column_values_force_bypasses_cache(self, values_for_column_mock):
|
||||
"""``?force=true`` should bypass the cache and re-query the source."""
|
||||
cache_manager.data_cache.clear()
|
||||
values_for_column_mock.return_value = ["a", "b"]
|
||||
self.login(ADMIN_USERNAME)
|
||||
table = self.get_virtual_dataset()
|
||||
url = f"api/v1/datasource/table/{table.id}/column/col2/values/"
|
||||
|
||||
self.client.get(url)
|
||||
self.client.get(f"{url}?force=true")
|
||||
|
||||
assert values_for_column_mock.call_count == 2
|
||||
|
||||
@pytest.mark.usefixtures("app_context", "virtual_dataset")
|
||||
@patch("superset.models.helpers.ExploreMixin.values_for_column")
|
||||
def test_get_column_values_cache_isolated_per_column(self, values_for_column_mock):
|
||||
"""Different columns on the same datasource must not share a cache
|
||||
entry — otherwise filter values would be silently swapped."""
|
||||
cache_manager.data_cache.clear()
|
||||
values_for_column_mock.return_value = ["x"]
|
||||
self.login(ADMIN_USERNAME)
|
||||
table = self.get_virtual_dataset()
|
||||
|
||||
self.client.get(f"api/v1/datasource/table/{table.id}/column/col1/values/")
|
||||
self.client.get(f"api/v1/datasource/table/{table.id}/column/col2/values/")
|
||||
|
||||
assert values_for_column_mock.call_count == 2
|
||||
|
||||
@pytest.mark.usefixtures("app_context", "virtual_dataset")
|
||||
@patch("superset.models.helpers.ExploreMixin.values_for_column")
|
||||
def test_get_column_values_cache_busts_on_changed_on(self, values_for_column_mock):
|
||||
"""Editing the underlying virtual dataset SQL bumps ``changed_on``,
|
||||
which is part of the cache key — so the next request must miss the
|
||||
cache and re-run the query."""
|
||||
cache_manager.data_cache.clear()
|
||||
values_for_column_mock.return_value = ["v"]
|
||||
self.login(ADMIN_USERNAME)
|
||||
table = self.get_virtual_dataset()
|
||||
url = f"api/v1/datasource/table/{table.id}/column/col2/values/"
|
||||
|
||||
self.client.get(url)
|
||||
# Simulate an edit to the dataset; ``changed_on`` is what the cache
|
||||
# key reads, so any new value forces a miss.
|
||||
table.changed_on = datetime(2030, 1, 1)
|
||||
db.session.flush()
|
||||
self.client.get(url)
|
||||
|
||||
assert values_for_column_mock.call_count == 2
|
||||
|
||||
@pytest.mark.usefixtures("app_context", "virtual_dataset")
|
||||
@patch("superset.datasource.api.security_manager.get_rls_cache_key")
|
||||
@patch("superset.models.helpers.ExploreMixin.values_for_column")
|
||||
def test_get_column_values_cache_isolated_per_rls_context(
|
||||
self, values_for_column_mock, get_rls_cache_key_mock
|
||||
):
|
||||
"""RLS safety for guest/embedded sessions. ``get_user_id()`` returns
|
||||
``None`` for guest users, so two embedded dashboards with different
|
||||
guest-token RLS would otherwise collide on ``user=None``. Including
|
||||
the RLS fingerprint in the cache key keeps them separate."""
|
||||
cache_manager.data_cache.clear()
|
||||
values_for_column_mock.return_value = ["v"]
|
||||
self.login(ADMIN_USERNAME)
|
||||
table = self.get_virtual_dataset()
|
||||
url = f"api/v1/datasource/table/{table.id}/column/col2/values/"
|
||||
|
||||
get_rls_cache_key_mock.return_value = ["dept='A'"]
|
||||
self.client.get(url)
|
||||
get_rls_cache_key_mock.return_value = ["dept='B'"]
|
||||
self.client.get(url)
|
||||
|
||||
assert values_for_column_mock.call_count == 2
|
||||
|
||||
@pytest.mark.usefixtures("app_context", "virtual_dataset")
|
||||
@patch("superset.models.helpers.ExploreMixin.values_for_column")
|
||||
def test_get_column_values_response_advertises_cache_status(
|
||||
self, values_for_column_mock
|
||||
):
|
||||
"""The ``X-Cache-Status`` response header should advertise MISS on
|
||||
the populating request and HIT on the next identical request, so
|
||||
operators can debug cache behavior from logs or browser devtools."""
|
||||
cache_manager.data_cache.clear()
|
||||
values_for_column_mock.return_value = ["v"]
|
||||
self.login(ADMIN_USERNAME)
|
||||
table = self.get_virtual_dataset()
|
||||
url = f"api/v1/datasource/table/{table.id}/column/col2/values/"
|
||||
|
||||
rv_miss = self.client.get(url)
|
||||
rv_hit = self.client.get(url)
|
||||
|
||||
assert rv_miss.headers.get("X-Cache-Status") == "MISS"
|
||||
assert rv_hit.headers.get("X-Cache-Status") == "HIT"
|
||||
|
||||
@patch("superset.datasource.api.security_manager.can_access")
|
||||
@patch("superset.datasource.api.GetCombinedDatasourceListCommand.run")
|
||||
def test_combined_list_invalid_order_column(
|
||||
|
||||
@@ -27,7 +27,7 @@ import tests.integration_tests.test_app # noqa: F401
|
||||
import superset.viz as viz
|
||||
from flask import current_app
|
||||
from superset.exceptions import QueryObjectValidationError, SpatialException
|
||||
from superset.utils.core import DTTM_ALIAS
|
||||
from superset.utils.core import DTTM_ALIAS, ExtraFiltersReasonType
|
||||
from superset.utils.pandas_postprocessing.utils import FLAT_COLUMN_SEPARATOR
|
||||
from tests.conftest import with_config
|
||||
|
||||
@@ -1849,6 +1849,79 @@ class TestDeckGLMultiLayer(SupersetTestCase):
|
||||
assert len(result["slices"]) == 1
|
||||
assert result["slices"][0] == slice_1.data
|
||||
|
||||
@with_config({"MAPBOX_API_KEY": "test_key"})
|
||||
@patch("superset.viz.viz_types")
|
||||
@patch("superset.db.session")
|
||||
def test_get_payload_includes_subslice_filter_metadata(
|
||||
self,
|
||||
mock_db_session,
|
||||
mock_viz_types,
|
||||
):
|
||||
"""Test deck.gl multi-layer payload includes child filter metadata."""
|
||||
datasource = self.get_datasource_mock()
|
||||
|
||||
slice_1 = Mock()
|
||||
slice_1.form_data = {"viz_type": "deck_scatter"}
|
||||
slice_1.data = {"features": [{"type": "Feature"}]}
|
||||
slice_1.datasource = datasource
|
||||
|
||||
slice_2 = Mock()
|
||||
slice_2.form_data = {"viz_type": "deck_path"}
|
||||
slice_2.data = {"features": [{"type": "Feature"}]}
|
||||
slice_2.datasource = datasource
|
||||
|
||||
mock_db_session.query.return_value.filter.return_value.all.return_value = [
|
||||
slice_1,
|
||||
slice_2,
|
||||
]
|
||||
|
||||
mock_scatter_viz_class = Mock()
|
||||
mock_scatter_viz_instance = Mock()
|
||||
mock_scatter_viz_instance.get_payload.return_value = {
|
||||
"data": {"features": [{"id": 1}]},
|
||||
"applied_filters": [{"column": "Latitude"}],
|
||||
"rejected_filters": [],
|
||||
}
|
||||
mock_scatter_viz_class.return_value = mock_scatter_viz_instance
|
||||
|
||||
mock_path_viz_class = Mock()
|
||||
mock_path_viz_instance = Mock()
|
||||
mock_path_viz_instance.get_payload.return_value = {
|
||||
"data": {"features": [{"id": 2}]},
|
||||
"applied_filters": [
|
||||
{"column": "Latitude"},
|
||||
{"column": "Longitude"},
|
||||
],
|
||||
"rejected_filters": [
|
||||
{
|
||||
"column": "Country",
|
||||
"reason": ExtraFiltersReasonType.COL_NOT_IN_DATASOURCE,
|
||||
},
|
||||
],
|
||||
}
|
||||
mock_path_viz_class.return_value = mock_path_viz_instance
|
||||
|
||||
mock_viz_types.get.side_effect = lambda viz_type: {
|
||||
"deck_scatter": mock_scatter_viz_class,
|
||||
"deck_path": mock_path_viz_class,
|
||||
}.get(viz_type)
|
||||
|
||||
test_viz = viz.DeckGLMultiLayer(datasource, {"deck_slices": [1, 2]})
|
||||
test_viz.get_df_payload = Mock(return_value={"df": pd.DataFrame()})
|
||||
|
||||
result = test_viz.get_payload()
|
||||
|
||||
assert result["applied_filters"] == [
|
||||
{"column": "Latitude"},
|
||||
{"column": "Longitude"},
|
||||
]
|
||||
assert result["rejected_filters"] == [
|
||||
{
|
||||
"column": "Country",
|
||||
"reason": ExtraFiltersReasonType.COL_NOT_IN_DATASOURCE,
|
||||
},
|
||||
]
|
||||
|
||||
@with_config({"MAPBOX_API_KEY": "test_key"})
|
||||
def test_get_data_empty_deck_slices(self):
|
||||
"""Test get_data method with empty deck_slices."""
|
||||
|
||||
@@ -312,3 +312,40 @@ def test_full_setting(
|
||||
assert dttm_col.is_dttm
|
||||
assert dttm_col.python_date_format == "epoch_s"
|
||||
assert dttm_col.expression == "CAST(dttm as INTEGER)"
|
||||
|
||||
|
||||
def test_sync_theme_logo_href() -> None:
|
||||
"""
|
||||
Verify LOGO_TARGET_PATH is wired into a theme's brandLogoHref.
|
||||
|
||||
THEME_DEFAULT is built before superset_config.py overrides load, so the link
|
||||
is re-synced afterwards via sync_theme_logo_href. A provided LOGO_TARGET_PATH
|
||||
must update brandLogoHref; None must leave the existing value untouched.
|
||||
"""
|
||||
from copy import deepcopy
|
||||
|
||||
from superset.config import sync_theme_logo_href, THEME_DEFAULT
|
||||
|
||||
# A user-provided LOGO_TARGET_PATH propagates to the logo link.
|
||||
theme = deepcopy(THEME_DEFAULT)
|
||||
theme["token"]["brandLogoHref"] = "/"
|
||||
sync_theme_logo_href(theme, "https://custom.url")
|
||||
assert theme["token"]["brandLogoHref"] == "https://custom.url"
|
||||
|
||||
# The default (None) leaves the existing link untouched.
|
||||
default_theme = deepcopy(THEME_DEFAULT)
|
||||
default_theme["token"]["brandLogoHref"] = "/"
|
||||
sync_theme_logo_href(default_theme, None)
|
||||
assert default_theme["token"]["brandLogoHref"] == "/"
|
||||
|
||||
# A disabled theme (None) is a no-op rather than an error.
|
||||
sync_theme_logo_href(None, "https://custom.url")
|
||||
|
||||
|
||||
def test_theme_default_logo_defaults() -> None:
|
||||
"""With the shipped defaults, brandLogoHref is "/" and brandLogoUrl is APP_ICON."""
|
||||
from superset import config
|
||||
|
||||
assert config.LOGO_TARGET_PATH is None
|
||||
assert config.THEME_DEFAULT["token"]["brandLogoHref"] == "/"
|
||||
assert config.THEME_DEFAULT["token"]["brandLogoUrl"] == config.APP_ICON
|
||||
|
||||
60
tests/unit_tests/dashboards/export_append_charts_tests.py
Normal file
60
tests/unit_tests/dashboards/export_append_charts_tests.py
Normal file
@@ -0,0 +1,60 @@
|
||||
# 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 uuid
|
||||
|
||||
from superset.commands.dashboard import export as export_module
|
||||
|
||||
|
||||
class DummySlice:
|
||||
def __init__(self, id_: int, slice_uuid: uuid.UUID, slice_name: str = "chart"):
|
||||
self.id = id_
|
||||
self.uuid = slice_uuid
|
||||
self.slice_name = slice_name
|
||||
|
||||
|
||||
def test_append_deterministic_fields():
|
||||
# start with a default position (has ROOT_ID and GRID_ID)
|
||||
position = export_module.get_default_position("test")
|
||||
|
||||
# create two dummy slices with known UUIDs
|
||||
u1 = uuid.UUID("11111111-1111-1111-1111-111111111111")
|
||||
u2 = uuid.UUID("22222222-2222-2222-2222-222222222222")
|
||||
s1 = DummySlice(101, u1, "Chart One")
|
||||
s2 = DummySlice(102, u2, "Chart Two")
|
||||
charts = {s1, s2}
|
||||
|
||||
new_pos = export_module.append_charts(position, charts)
|
||||
|
||||
# chart keys should be CHART-<uuid>
|
||||
expected_keys = {f"CHART-{str(u1)}", f"CHART-{str(u2)}"}
|
||||
|
||||
# row key should be ROW-N-<row number>
|
||||
# expected row number is 0 since we started with only ROOT_ID and GRID_ID
|
||||
expected_row = "ROW-N-0"
|
||||
|
||||
assert expected_row in new_pos, "expected row key in position"
|
||||
for k in expected_keys:
|
||||
assert k in new_pos, f"expected chart key {k} in position"
|
||||
|
||||
# Ensure meta.uuid equals the chart uuid and chartId equals id
|
||||
for chart in (s1, s2):
|
||||
key = f"CHART-{str(chart.uuid)}"
|
||||
meta = new_pos[key]["meta"]
|
||||
assert meta["uuid"] == str(chart.uuid)
|
||||
assert meta["chartId"] == chart.id
|
||||
assert meta["sliceName"] == chart.slice_name
|
||||
@@ -430,6 +430,30 @@ def test_get_default_catalog(mocker: MockerFixture) -> None:
|
||||
assert BigQueryEngineSpec.get_default_catalog(database) == "project"
|
||||
|
||||
|
||||
def test_get_time_partition_column_uses_catalog_in_table_reference(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
Test that partition metadata lookup preserves the BigQuery project.
|
||||
"""
|
||||
from superset.db_engine_specs.bigquery import BigQueryEngineSpec
|
||||
|
||||
database = mock.Mock()
|
||||
engine = mock.MagicMock()
|
||||
get_engine = mocker.patch.object(BigQueryEngineSpec, "get_engine")
|
||||
get_engine.return_value.__enter__.return_value = engine
|
||||
client = mocker.patch.object(BigQueryEngineSpec, "_get_client").return_value
|
||||
client.get_table.return_value.time_partitioning.field = "ds"
|
||||
|
||||
result = BigQueryEngineSpec.get_time_partition_column(
|
||||
database,
|
||||
Table("my_table", "my_dataset", "other_project"),
|
||||
)
|
||||
|
||||
assert result == "ds"
|
||||
client.get_table.assert_called_once_with("other_project.my_dataset.my_table")
|
||||
|
||||
|
||||
def test_adjust_engine_params_catalog_as_host() -> None:
|
||||
"""
|
||||
Test passing a custom catalog.
|
||||
|
||||
@@ -73,6 +73,14 @@ def mock_auth():
|
||||
yield mock_get_user
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_event_logger():
|
||||
"""Skip event-logger DB writes so a bad logs FK doesn't poison the
|
||||
session for FastMCP's response serialization on the success path."""
|
||||
with patch("superset.utils.log.DBEventLogger.log", return_value=None):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_dataset_access_granted():
|
||||
"""Grant dataset access by default; tests that need a denial override this."""
|
||||
@@ -167,14 +175,16 @@ class TestGenerateExploreLink:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["error"] is None
|
||||
assert result.structured_content["error"] is None
|
||||
|
||||
assert result.structured_content["success"] is True
|
||||
assert (
|
||||
result.data["url"]
|
||||
result.structured_content["url"]
|
||||
== "http://localhost:9001/explore/p/test_permalink_key/"
|
||||
)
|
||||
assert result.data["permalink_key"] == "test_permalink_key"
|
||||
assert result.data["form_data_key"] is None
|
||||
assert result.data["chart_type_label"] == "table chart"
|
||||
assert result.structured_content["permalink_key"] == "test_permalink_key"
|
||||
assert result.structured_content["form_data_key"] is None
|
||||
assert result.structured_content["chart_type_label"] == "table chart"
|
||||
|
||||
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
|
||||
@pytest.mark.asyncio
|
||||
@@ -204,14 +214,16 @@ class TestGenerateExploreLink:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["error"] is None
|
||||
assert result.structured_content["error"] is None
|
||||
|
||||
assert result.structured_content["success"] is True
|
||||
assert (
|
||||
result.data["url"]
|
||||
result.structured_content["url"]
|
||||
== "http://localhost:9001/explore/p/test_permalink_key/"
|
||||
)
|
||||
assert result.data["permalink_key"] == "test_permalink_key"
|
||||
assert result.data["form_data_key"] is None
|
||||
assert result.data["chart_type_label"] == "table chart"
|
||||
assert result.structured_content["permalink_key"] == "test_permalink_key"
|
||||
assert result.structured_content["form_data_key"] is None
|
||||
assert result.structured_content["chart_type_label"] == "table chart"
|
||||
|
||||
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
|
||||
@pytest.mark.asyncio
|
||||
@@ -233,8 +245,13 @@ class TestGenerateExploreLink:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["error"] is None
|
||||
assert result.data["chart_type_label"] == "interactive table chart"
|
||||
assert result.structured_content["error"] is None
|
||||
|
||||
assert result.structured_content["success"] is True
|
||||
assert (
|
||||
result.structured_content["chart_type_label"]
|
||||
== "interactive table chart"
|
||||
)
|
||||
|
||||
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
|
||||
@pytest.mark.asyncio
|
||||
@@ -264,12 +281,14 @@ class TestGenerateExploreLink:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["error"] is None
|
||||
assert result.structured_content["error"] is None
|
||||
|
||||
assert result.structured_content["success"] is True
|
||||
assert (
|
||||
result.data["url"]
|
||||
result.structured_content["url"]
|
||||
== "http://localhost:9001/explore/p/test_permalink_key/"
|
||||
)
|
||||
assert result.data["chart_type_label"] is None
|
||||
assert result.structured_content["chart_type_label"] is None
|
||||
|
||||
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
|
||||
@pytest.mark.asyncio
|
||||
@@ -292,9 +311,11 @@ class TestGenerateExploreLink:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["error"] is None
|
||||
assert result.structured_content["error"] is None
|
||||
|
||||
assert result.structured_content["success"] is True
|
||||
assert (
|
||||
result.data["url"]
|
||||
result.structured_content["url"]
|
||||
== "http://localhost:9001/explore/p/test_permalink_key/"
|
||||
)
|
||||
|
||||
@@ -324,9 +345,11 @@ class TestGenerateExploreLink:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["error"] is None
|
||||
assert result.structured_content["error"] is None
|
||||
|
||||
assert result.structured_content["success"] is True
|
||||
assert (
|
||||
result.data["url"]
|
||||
result.structured_content["url"]
|
||||
== "http://localhost:9001/explore/p/test_permalink_key/"
|
||||
)
|
||||
|
||||
@@ -354,9 +377,11 @@ class TestGenerateExploreLink:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["error"] is None
|
||||
assert result.structured_content["error"] is None
|
||||
|
||||
assert result.structured_content["success"] is True
|
||||
assert (
|
||||
result.data["url"]
|
||||
result.structured_content["url"]
|
||||
== "http://localhost:9001/explore/p/test_permalink_key/"
|
||||
)
|
||||
|
||||
@@ -392,13 +417,15 @@ class TestGenerateExploreLink:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["error"] is None
|
||||
assert result.structured_content["error"] is None
|
||||
assert (
|
||||
result.data["url"]
|
||||
result.structured_content["url"]
|
||||
== "http://localhost:9001/explore/?form_data_key=fallback_form_data_key"
|
||||
)
|
||||
assert result.data["form_data_key"] == "fallback_form_data_key"
|
||||
assert result.data["permalink_key"] is None
|
||||
assert (
|
||||
result.structured_content["form_data_key"] == "fallback_form_data_key"
|
||||
)
|
||||
assert result.structured_content["permalink_key"] is None
|
||||
mock_create_form_data.assert_called_once()
|
||||
|
||||
@patch(_PERMALINK_PATCH)
|
||||
@@ -434,9 +461,10 @@ class TestGenerateExploreLink:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["error"] is None
|
||||
assert result.structured_content["error"] is None
|
||||
assert result.structured_content["success"] is True
|
||||
assert (
|
||||
result.data["url"]
|
||||
result.structured_content["url"]
|
||||
== "http://localhost:9001/explore/?datasource_type=table&datasource_id=1"
|
||||
)
|
||||
|
||||
@@ -473,9 +501,10 @@ class TestGenerateExploreLink:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["error"] is None
|
||||
assert result.structured_content["error"] is None
|
||||
assert result.structured_content["success"] is True
|
||||
assert (
|
||||
result.data["url"]
|
||||
result.structured_content["url"]
|
||||
== "http://localhost:9001/explore/?form_data_key=lock_fallback_key"
|
||||
)
|
||||
|
||||
@@ -505,9 +534,11 @@ class TestGenerateExploreLink:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["error"] is None
|
||||
assert result.structured_content["error"] is None
|
||||
|
||||
assert result.structured_content["success"] is True
|
||||
assert (
|
||||
result.data["url"]
|
||||
result.structured_content["url"]
|
||||
== "http://localhost:9001/explore/p/test_permalink_key/"
|
||||
)
|
||||
|
||||
@@ -543,9 +574,11 @@ class TestGenerateExploreLink:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["error"] is None
|
||||
assert result.structured_content["error"] is None
|
||||
|
||||
assert result.structured_content["success"] is True
|
||||
assert (
|
||||
result.data["url"]
|
||||
result.structured_content["url"]
|
||||
== "http://localhost:9001/explore/p/test_permalink_key/"
|
||||
)
|
||||
|
||||
@@ -595,10 +628,11 @@ class TestGenerateExploreLink:
|
||||
|
||||
# All URLs should follow the same permalink format
|
||||
assert (
|
||||
result.data["url"]
|
||||
result.structured_content["url"]
|
||||
== "http://localhost:9001/explore/p/test_permalink_key/"
|
||||
)
|
||||
assert result.data["error"] is None
|
||||
assert result.structured_content["error"] is None
|
||||
assert result.structured_content["success"] is True
|
||||
|
||||
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
|
||||
@pytest.mark.asyncio
|
||||
@@ -621,9 +655,10 @@ class TestGenerateExploreLink:
|
||||
result = await client.call_tool(
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
assert result.data["error"] is None
|
||||
assert result.structured_content["error"] is None
|
||||
assert result.structured_content["success"] is True
|
||||
assert (
|
||||
result.data["url"]
|
||||
result.structured_content["url"]
|
||||
== "http://localhost:9001/explore/p/test_permalink_key/"
|
||||
)
|
||||
|
||||
@@ -661,9 +696,11 @@ class TestGenerateExploreLink:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["error"] is None
|
||||
assert result.structured_content["error"] is None
|
||||
|
||||
assert result.structured_content["success"] is True
|
||||
assert (
|
||||
result.data["url"]
|
||||
result.structured_content["url"]
|
||||
== "http://localhost:9001/explore/p/test_permalink_key/"
|
||||
)
|
||||
|
||||
@@ -708,8 +745,9 @@ class TestGenerateExploreLink:
|
||||
f"http://localhost:9001/explore/?datasource_type=table"
|
||||
f"&datasource_id={dataset_id}"
|
||||
)
|
||||
assert result.data["error"] is None
|
||||
assert result.data["url"] == expected_url
|
||||
assert result.structured_content["error"] is None
|
||||
assert result.structured_content["success"] is True
|
||||
assert result.structured_content["url"] == expected_url
|
||||
|
||||
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
|
||||
@pytest.mark.asyncio
|
||||
@@ -744,12 +782,19 @@ class TestGenerateExploreLink:
|
||||
)
|
||||
|
||||
# Should return error response with empty URL
|
||||
assert result.data["url"] == ""
|
||||
assert result.data["form_data"] == {}
|
||||
assert result.data["form_data_key"] is None
|
||||
assert result.data["permalink_key"] is None
|
||||
assert result.data["chart_type_label"] is None
|
||||
assert "Invalid config structure" in result.data["error"]
|
||||
assert result.structured_content["url"] == ""
|
||||
assert result.structured_content["form_data"] == {}
|
||||
assert result.structured_content["form_data_key"] is None
|
||||
assert result.structured_content["permalink_key"] is None
|
||||
assert result.structured_content["chart_type_label"] is None
|
||||
assert result.structured_content["success"] is False
|
||||
error = result.structured_content["error"]
|
||||
assert error["error_type"] == "generation_failed"
|
||||
# ``details`` is the static, sanitized message; the raw
|
||||
# exception text ("Invalid config structure") is kept
|
||||
# only in the server-side log, not echoed to the client.
|
||||
assert "check server logs" in error["details"]
|
||||
assert "Invalid config structure" not in error["details"]
|
||||
finally:
|
||||
# Restore original function
|
||||
explore_module.map_config_to_form_data = original_func
|
||||
@@ -774,11 +819,14 @@ class TestGenerateExploreLink:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["error"] is None
|
||||
assert result.data["permalink_key"] == "extracted_permalink_xyz"
|
||||
assert result.data["form_data_key"] is None
|
||||
assert "extracted_permalink_xyz" in result.data["url"]
|
||||
assert result.data["url"] == (
|
||||
assert result.structured_content["error"] is None
|
||||
assert result.structured_content["success"] is True
|
||||
assert (
|
||||
result.structured_content["permalink_key"] == "extracted_permalink_xyz"
|
||||
)
|
||||
assert result.structured_content["form_data_key"] is None
|
||||
assert "extracted_permalink_xyz" in result.structured_content["url"]
|
||||
assert result.structured_content["url"] == (
|
||||
"http://localhost:9001/explore/p/extracted_permalink_xyz/"
|
||||
)
|
||||
|
||||
@@ -803,13 +851,20 @@ class TestGenerateExploreLink:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["error"] is None
|
||||
assert "form_data" in result.data
|
||||
assert isinstance(result.data["form_data"], dict)
|
||||
assert result.data["form_data"].get("viz_type") == "echarts_timeseries_line"
|
||||
assert result.data["form_data"].get("x_axis") == "date"
|
||||
assert result.structured_content["error"] is None
|
||||
|
||||
assert result.structured_content["success"] is True
|
||||
assert "form_data" in result.structured_content
|
||||
assert isinstance(result.structured_content["form_data"], dict)
|
||||
assert (
|
||||
result.structured_content["form_data"].get("viz_type")
|
||||
== "echarts_timeseries_line"
|
||||
)
|
||||
assert result.structured_content["form_data"].get("x_axis") == "date"
|
||||
# Verify datasource field format: "{dataset_id}__table"
|
||||
assert result.data["form_data"].get("datasource") == "1__table"
|
||||
assert (
|
||||
result.structured_content["form_data"].get("datasource") == "1__table"
|
||||
)
|
||||
|
||||
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
|
||||
@pytest.mark.asyncio
|
||||
@@ -829,20 +884,26 @@ class TestGenerateExploreLink:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["url"] == ""
|
||||
assert result.data["form_data"] == {}
|
||||
assert result.data["form_data_key"] is None
|
||||
assert result.data["permalink_key"] is None
|
||||
assert result.data["chart_type_label"] is None
|
||||
assert "Dataset not found: 99999" in result.data["error"]
|
||||
assert "list_datasets" in result.data["error"]
|
||||
assert result.structured_content["url"] == ""
|
||||
assert result.structured_content["form_data"] == {}
|
||||
assert result.structured_content["form_data_key"] is None
|
||||
assert result.structured_content["permalink_key"] is None
|
||||
assert result.structured_content["chart_type_label"] is None
|
||||
assert result.structured_content["success"] is False
|
||||
error = result.structured_content["error"]
|
||||
assert error["error_type"] == "dataset_not_found"
|
||||
assert "Dataset not found: 99999" in error["message"]
|
||||
assert "list_datasets" in error["details"]
|
||||
|
||||
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_explore_link_without_config(
|
||||
self, mock_find_dataset, mcp_server
|
||||
):
|
||||
"""Omitting config returns a default dataset explore URL."""
|
||||
"""Omitting config returns a default dataset explore URL through
|
||||
the same typed ``GenerateExploreLinkResponse`` shape as every
|
||||
other code path. ``success=True`` and ``error=None`` so callers
|
||||
cannot mistake a no-config response for a failure."""
|
||||
mock_find_dataset.return_value = _mock_dataset(id=42)
|
||||
|
||||
request = GenerateExploreLinkRequest(dataset_id="42")
|
||||
@@ -852,23 +913,26 @@ class TestGenerateExploreLink:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["error"] is None
|
||||
assert result.structured_content["error"] is None
|
||||
assert result.structured_content["success"] is True
|
||||
assert (
|
||||
result.data["url"]
|
||||
result.structured_content["url"]
|
||||
== "http://localhost:9001/explore/?datasource_type=table"
|
||||
"&datasource_id=42"
|
||||
)
|
||||
assert result.data["form_data"] == {}
|
||||
assert result.data["form_data_key"] is None
|
||||
assert result.data["permalink_key"] is None
|
||||
assert result.data["chart_type_label"] is None
|
||||
assert result.structured_content["form_data"] == {}
|
||||
assert result.structured_content["form_data_key"] is None
|
||||
assert result.structured_content["permalink_key"] is None
|
||||
assert result.structured_content["chart_type_label"] is None
|
||||
|
||||
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_explore_link_without_config_missing_dataset(
|
||||
self, mock_find_dataset, mcp_server
|
||||
):
|
||||
"""Omitting config still surfaces a dataset-not-found error."""
|
||||
"""Omitting config still surfaces a dataset-not-found error
|
||||
through the structured error object — not as a substring on a
|
||||
dict, which is the bug this test originally hid."""
|
||||
mock_find_dataset.return_value = None
|
||||
|
||||
request = GenerateExploreLinkRequest(dataset_id="99999")
|
||||
@@ -878,12 +942,15 @@ class TestGenerateExploreLink:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["url"] == ""
|
||||
assert result.data["form_data"] == {}
|
||||
assert result.data["form_data_key"] is None
|
||||
assert result.data["permalink_key"] is None
|
||||
assert result.data["chart_type_label"] is None
|
||||
assert "Dataset not found: 99999" in result.data["error"]
|
||||
assert result.structured_content["url"] == ""
|
||||
assert result.structured_content["form_data"] == {}
|
||||
assert result.structured_content["form_data_key"] is None
|
||||
assert result.structured_content["permalink_key"] is None
|
||||
assert result.structured_content["chart_type_label"] is None
|
||||
assert result.structured_content["success"] is False
|
||||
error = result.structured_content["error"]
|
||||
assert error["error_type"] == "dataset_not_found"
|
||||
assert "Dataset not found: 99999" in error["message"]
|
||||
|
||||
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
|
||||
@pytest.mark.asyncio
|
||||
@@ -905,12 +972,15 @@ class TestGenerateExploreLink:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["url"] == ""
|
||||
assert result.data["form_data"] == {}
|
||||
assert result.data["form_data_key"] is None
|
||||
assert result.data["permalink_key"] is None
|
||||
assert result.data["chart_type_label"] is None
|
||||
assert "Dataset not found" in result.data["error"]
|
||||
assert result.structured_content["url"] == ""
|
||||
assert result.structured_content["form_data"] == {}
|
||||
assert result.structured_content["form_data_key"] is None
|
||||
assert result.structured_content["permalink_key"] is None
|
||||
assert result.structured_content["chart_type_label"] is None
|
||||
assert result.structured_content["success"] is False
|
||||
error = result.structured_content["error"]
|
||||
assert error["error_type"] == "dataset_not_found"
|
||||
assert "Dataset not found" in error["message"]
|
||||
|
||||
|
||||
class TestGenerateExploreLinkColumnNormalization:
|
||||
@@ -959,9 +1029,11 @@ class TestGenerateExploreLinkColumnNormalization:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["error"] is None
|
||||
assert result.structured_content["error"] is None
|
||||
|
||||
assert result.structured_content["success"] is True
|
||||
# x-axis should be normalized from 'orderdate' to 'OrderDate'
|
||||
assert result.data["form_data"]["x_axis"] == "OrderDate"
|
||||
assert result.structured_content["form_data"]["x_axis"] == "OrderDate"
|
||||
|
||||
@patch(
|
||||
"superset.mcp_service.chart.validation.dataset_validator.DatasetValidator._get_dataset_context"
|
||||
@@ -1004,8 +1076,10 @@ class TestGenerateExploreLinkColumnNormalization:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["error"] is None
|
||||
form_data = result.data["form_data"]
|
||||
assert result.structured_content["error"] is None
|
||||
|
||||
assert result.structured_content["success"] is True
|
||||
form_data = result.structured_content["form_data"]
|
||||
# x-axis normalized
|
||||
assert form_data["x_axis"] == "OrderDate"
|
||||
# filter subject normalized to match x-axis
|
||||
@@ -1045,9 +1119,11 @@ class TestGenerateExploreLinkColumnNormalization:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["error"] is None
|
||||
assert result.structured_content["error"] is None
|
||||
|
||||
assert result.structured_content["success"] is True
|
||||
# original names should pass through unchanged
|
||||
assert result.data["form_data"]["x_axis"] == "orderdate"
|
||||
assert result.structured_content["form_data"]["x_axis"] == "orderdate"
|
||||
|
||||
|
||||
class TestGenerateExploreLinkValidation:
|
||||
@@ -1105,11 +1181,12 @@ class TestGenerateExploreLinkValidation:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["url"] == ""
|
||||
assert result.data["form_data_key"] is None
|
||||
assert result.data["permalink_key"] is None
|
||||
assert result.data["chart_type_label"] is None
|
||||
error = result.data["error"]
|
||||
assert result.structured_content["url"] == ""
|
||||
assert result.structured_content["form_data_key"] is None
|
||||
assert result.structured_content["permalink_key"] is None
|
||||
assert result.structured_content["chart_type_label"] is None
|
||||
assert result.structured_content["success"] is False
|
||||
error = result.structured_content["error"]
|
||||
assert isinstance(error, dict)
|
||||
assert error["error_code"] == "CHART_VALIDATION_FAILED"
|
||||
assert "sum_boys" in error["suggestions"]
|
||||
@@ -1141,8 +1218,12 @@ class TestGenerateExploreLinkValidation:
|
||||
"generate_explore_link", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
assert result.data["url"] == ""
|
||||
assert result.data["chart_type_label"] is None
|
||||
# Surface as "not found" rather than leaking that the dataset exists.
|
||||
assert "Dataset not found" in result.data["error"]
|
||||
assert result.structured_content["url"] == ""
|
||||
assert result.structured_content["chart_type_label"] is None
|
||||
assert result.structured_content["success"] is False
|
||||
error = result.structured_content["error"]
|
||||
# error_type lets programmatic callers distinguish, while the
|
||||
# user-facing message still avoids leaking dataset existence.
|
||||
assert error["error_type"] == "permission_denied"
|
||||
assert "Dataset not found" in error["message"]
|
||||
mock_create_permalink.assert_not_called()
|
||||
|
||||
Reference in New Issue
Block a user