mirror of
https://github.com/apache/superset.git
synced 2026-06-25 17:39:18 +00:00
Compare commits
10 Commits
fix-filter
...
chore/ci/s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c32284d56a | ||
|
|
6bc77fecc2 | ||
|
|
420a74b01e | ||
|
|
7ba59c2d79 | ||
|
|
b77c525d4b | ||
|
|
41ce9ca7d3 | ||
|
|
c2fb94cedf | ||
|
|
1d0866556f | ||
|
|
b4dfeef2fd | ||
|
|
0ec6cae45d |
4
.github/workflows/bump-python-package.yml
vendored
4
.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
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-supersetbot/
|
||||
|
||||
- name: Set up Python ${{ inputs.python-version }}
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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",
|
||||
|
||||
@@ -1,325 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Overflow-engine regression tests for DropdownContainer.
|
||||
*
|
||||
* jsdom has no real layout, so these tests drive the component's real overflow
|
||||
* recalculation by mocking the two measurement sources it reads:
|
||||
* 1. `useResizeDetector` — supplies the container width.
|
||||
* 2. `getBoundingClientRect` — supplies per-element geometry. The inner
|
||||
* `data-test="container"` spans [0, containerRight]; every child is
|
||||
* ITEM_W wide and laid out left-to-right by its DOM index, so children
|
||||
* whose right edge exceeds `containerRight` overflow.
|
||||
*
|
||||
* This exercises the production code path in DropdownContainer.tsx
|
||||
* (useLayoutEffect → overflowingIndex → notOverflowedItems/overflowedItems →
|
||||
* showDropdownButton) rather than mocking the result.
|
||||
*/
|
||||
import { screen, render, waitFor, act } from '@superset-ui/core/spec';
|
||||
import * as resizeDetector from 'react-resize-detector';
|
||||
import { DropdownContainer } from '..';
|
||||
|
||||
const ITEM_W = 100;
|
||||
// 350px container ⇒ at most 3 items (rights 100/200/300) fit before overflow.
|
||||
const BAR_WIDTH = 350;
|
||||
|
||||
// Mutable so a test can simulate the transient layout window where a freshly
|
||||
// enlarged item set is momentarily measured as fitting before reflow settles.
|
||||
let containerRight = BAR_WIDTH;
|
||||
// Mutable width fed to the component through the mocked resize detector.
|
||||
let mockWidth = 0;
|
||||
// Stable ref object React attaches the outer node to (mirrors useResizeDetector).
|
||||
const fakeRef: { current: HTMLDivElement | null } = { current: null };
|
||||
|
||||
const buildRect = (left: number, right: number): DOMRect =>
|
||||
({
|
||||
left,
|
||||
right,
|
||||
width: right - left,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
height: 0,
|
||||
x: left,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect;
|
||||
|
||||
const installLayoutMock = () => {
|
||||
HTMLElement.prototype.getBoundingClientRect = function mockRect(
|
||||
this: HTMLElement,
|
||||
) {
|
||||
const dataTest = this.getAttribute?.('data-test');
|
||||
if (dataTest === 'container') {
|
||||
return buildRect(0, containerRight);
|
||||
}
|
||||
const parent = this.parentElement;
|
||||
if (parent?.getAttribute?.('data-test') === 'container') {
|
||||
const index = Array.prototype.indexOf.call(parent.children, this);
|
||||
return buildRect(index * ITEM_W, index * ITEM_W + ITEM_W);
|
||||
}
|
||||
// Outer wrapper div (its first child is the inner container).
|
||||
if (
|
||||
(this.children[0] as HTMLElement | undefined)?.getAttribute?.(
|
||||
'data-test',
|
||||
) === 'container'
|
||||
) {
|
||||
return buildRect(0, containerRight);
|
||||
}
|
||||
return buildRect(0, 0);
|
||||
};
|
||||
};
|
||||
|
||||
let resizeSpy: jest.SpyInstance;
|
||||
let rafSpy: jest.SpyInstance;
|
||||
let cancelRafSpy: jest.SpyInstance;
|
||||
|
||||
// Deterministic requestAnimationFrame: the component schedules a one-shot
|
||||
// confirmation frame to re-measure after an item-set change. Rather than sleep
|
||||
// and hope jsdom's timer-backed rAF fires inside the window, we capture the
|
||||
// callbacks and invoke them explicitly via flushRAF(). cancelAnimationFrame
|
||||
// removes a queued frame, so the supersession path can be exercised directly.
|
||||
let rafQueue: Array<{ id: number; cb: FrameRequestCallback }> = [];
|
||||
let rafSeq = 0;
|
||||
|
||||
// Run every currently-queued frame once (frames scheduled during the flush are
|
||||
// left for the next flush, so a single call models a single browser frame).
|
||||
const flushRAF = () => {
|
||||
const pending = rafQueue;
|
||||
rafQueue = [];
|
||||
pending.forEach(({ cb }) => cb(0));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
containerRight = BAR_WIDTH;
|
||||
mockWidth = 0;
|
||||
fakeRef.current = null;
|
||||
rafQueue = [];
|
||||
rafSeq = 0;
|
||||
installLayoutMock();
|
||||
global.ResizeObserver = jest.fn().mockImplementation(() => ({
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
disconnect: jest.fn(),
|
||||
})) as unknown as typeof ResizeObserver;
|
||||
rafSpy = jest
|
||||
.spyOn(window, 'requestAnimationFrame')
|
||||
.mockImplementation((cb: FrameRequestCallback) => {
|
||||
rafSeq += 1;
|
||||
rafQueue.push({ id: rafSeq, cb });
|
||||
return rafSeq;
|
||||
});
|
||||
cancelRafSpy = jest
|
||||
.spyOn(window, 'cancelAnimationFrame')
|
||||
.mockImplementation((id: number) => {
|
||||
rafQueue = rafQueue.filter(frame => frame.id !== id);
|
||||
});
|
||||
resizeSpy = jest
|
||||
.spyOn(resizeDetector, 'useResizeDetector')
|
||||
.mockImplementation(
|
||||
() =>
|
||||
({ ref: fakeRef, width: mockWidth, height: 50 }) as ReturnType<
|
||||
typeof resizeDetector.useResizeDetector
|
||||
>,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resizeSpy?.mockRestore();
|
||||
rafSpy?.mockRestore();
|
||||
cancelRafSpy?.mockRestore();
|
||||
});
|
||||
|
||||
const makeItem = (id: string, label: string) => ({
|
||||
id,
|
||||
element: <div data-test={`item-${id}`}>{label}</div>,
|
||||
});
|
||||
|
||||
const nativeFilters = (count: number) =>
|
||||
Array.from({ length: count }, (_, i) =>
|
||||
makeItem(`native-filter-${i + 1}`, `Filter ${i + 1}`),
|
||||
);
|
||||
|
||||
const barItemCount = () => screen.getByTestId('container').children.length;
|
||||
|
||||
// Render, then apply a measured width so the overflow layout effect runs with
|
||||
// the outer node attached (mirrors the first real resize-detector callback).
|
||||
const renderOverflowing = async (
|
||||
items: ReturnType<typeof nativeFilters>,
|
||||
): Promise<{ rerender: (ui: JSX.Element) => void }> => {
|
||||
const { rerender } = render(<DropdownContainer items={items} />);
|
||||
await act(async () => {
|
||||
mockWidth = BAR_WIDTH;
|
||||
rerender(<DropdownContainer items={items} />);
|
||||
});
|
||||
await waitFor(() => expect(screen.getByText('More')).toBeInTheDocument());
|
||||
return { rerender };
|
||||
};
|
||||
|
||||
test('control: a clean re-measurement keeps overflowed items reachable after a chip is prepended', async () => {
|
||||
const filters = nativeFilters(8);
|
||||
const { rerender } = await renderOverflowing(filters);
|
||||
|
||||
// 3 of 8 fit in the bar, the rest are reachable via the More button.
|
||||
expect(barItemCount()).toBe(3);
|
||||
|
||||
// Prepend a cross-filter chip, shifting every native-filter index by one.
|
||||
const withCrossFilterChip = [
|
||||
makeItem('cross-filter-chip', 'Region'),
|
||||
...filters,
|
||||
];
|
||||
await act(async () => {
|
||||
rerender(<DropdownContainer items={withCrossFilterChip} />);
|
||||
});
|
||||
await act(async () => {
|
||||
flushRAF();
|
||||
});
|
||||
await waitFor(() => expect(barItemCount()).toBe(3));
|
||||
|
||||
// With faithful measurement the engine recovers to the exact split: 3 fit,
|
||||
// the rest stay accessible behind the trigger.
|
||||
expect(screen.queryByText('More')).toBeInTheDocument();
|
||||
expect(barItemCount()).toBe(3);
|
||||
});
|
||||
|
||||
test('overflowed-to-true-fit: when items genuinely fit after a set change, all are in the bar and the trigger is gone', async () => {
|
||||
// Start from an overflowed steady state: 8 items, 3 in bar, More visible.
|
||||
const filters = nativeFilters(8);
|
||||
const { rerender } = await renderOverflowing(filters);
|
||||
expect(barItemCount()).toBe(3);
|
||||
expect(screen.queryByText('More')).toBeInTheDocument();
|
||||
|
||||
// Reduce to 3 items — they all fit inside the 350 px bar without overflow.
|
||||
const fewFilters = nativeFilters(3);
|
||||
await act(async () => {
|
||||
rerender(<DropdownContainer items={fewFilters} />);
|
||||
});
|
||||
await act(async () => {
|
||||
flushRAF();
|
||||
});
|
||||
|
||||
// After measurement (and confirmation pass if any), the trigger is gone and
|
||||
// all 3 items are in the bar. This guards the fix against over-correction:
|
||||
// if the confirmation logic erroneously kept the trigger visible when items
|
||||
// genuinely fit, this assertion would catch it.
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('More')).not.toBeInTheDocument();
|
||||
});
|
||||
expect(barItemCount()).toBe(3);
|
||||
});
|
||||
|
||||
test('prepending a cross-filter chip must not strand overflowed native filters or hide the More button', async () => {
|
||||
const filters = nativeFilters(8);
|
||||
const { rerender } = await renderOverflowing(filters);
|
||||
expect(barItemCount()).toBe(3);
|
||||
|
||||
// Simulate the production race: as the cross-filter chip is added the item
|
||||
// set grows, overflowingIndex is reset to -1 (all items dumped into the bar)
|
||||
// and the re-measurement runs against a transient layout that momentarily
|
||||
// reports everything fits. (More filters ⇒ larger reflow ⇒ wider window,
|
||||
// matching the report's "depends on filter count".)
|
||||
containerRight = Number.MAX_SAFE_INTEGER;
|
||||
const withCrossFilterChip = [
|
||||
makeItem('cross-filter-chip', 'Region'),
|
||||
...filters,
|
||||
];
|
||||
await act(async () => {
|
||||
rerender(<DropdownContainer items={withCrossFilterChip} />);
|
||||
});
|
||||
|
||||
// The window closes — the filters genuinely overflow the bar again — but no
|
||||
// resize/width change occurs, so only the scheduled confirmation frame can
|
||||
// rescue the verdict. Fire it.
|
||||
containerRight = BAR_WIDTH;
|
||||
await act(async () => {
|
||||
flushRAF();
|
||||
});
|
||||
|
||||
// Invariant: overflowed items must remain accessible AND the split must be
|
||||
// CORRECT. Asserting the exact count (3 fit, the rest behind the trigger),
|
||||
// not merely `< total`, so an under-detecting confirmation that strands too
|
||||
// many items in the clipped bar also fails this guard.
|
||||
expect(barItemCount()).toBe(3);
|
||||
expect(screen.queryByText('More')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('fit-to-overflow: an item-set change that tips a fitting bar into overflow during a transient must not strand items', async () => {
|
||||
// Start with a bar that FITS: 3 items, no overflow, no trigger. The overflow
|
||||
// engine settles overflowingIndex === -1 here.
|
||||
const fewFilters = nativeFilters(3);
|
||||
const { rerender } = render(<DropdownContainer items={fewFilters} />);
|
||||
await act(async () => {
|
||||
mockWidth = BAR_WIDTH;
|
||||
rerender(<DropdownContainer items={fewFilters} />);
|
||||
});
|
||||
await waitFor(() => expect(barItemCount()).toBe(3));
|
||||
expect(screen.queryByText('More')).not.toBeInTheDocument();
|
||||
|
||||
// Grow the set so it now genuinely overflows, but measure it during a
|
||||
// transient window where the bar momentarily appears to still fit. Because
|
||||
// the bar was previously fitting, this takes the "measure" path, not the
|
||||
// reset path — the case the original fix armed NO confirmation for, so a
|
||||
// transient "-1" would latch (all items crammed, trigger gone) with no
|
||||
// rescue. The hardened engine arms a confirmation on every item-set change.
|
||||
containerRight = Number.MAX_SAFE_INTEGER;
|
||||
const manyFilters = nativeFilters(8);
|
||||
await act(async () => {
|
||||
rerender(<DropdownContainer items={manyFilters} />);
|
||||
});
|
||||
|
||||
// Window closes; the scheduled confirmation frame re-measures and corrects.
|
||||
containerRight = BAR_WIDTH;
|
||||
await act(async () => {
|
||||
flushRAF();
|
||||
});
|
||||
|
||||
expect(barItemCount()).toBe(3);
|
||||
expect(screen.queryByText('More')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('a second item-set change before the confirmation frame fires still settles the correct split (re-entrancy regression)', async () => {
|
||||
// Regression guard for rapid successive changes: prepend two chips in quick
|
||||
// succession (each during a transient), then let the frame(s) fire. The
|
||||
// hardened engine supersedes the stale frame and arms a fresh confirmation
|
||||
// for the latest set; this locks in the correct end state under re-entrancy.
|
||||
const filters = nativeFilters(8);
|
||||
const { rerender } = await renderOverflowing(filters);
|
||||
expect(barItemCount()).toBe(3);
|
||||
|
||||
containerRight = Number.MAX_SAFE_INTEGER;
|
||||
const withOneChip = [makeItem('cross-filter-chip', 'Region'), ...filters];
|
||||
await act(async () => {
|
||||
rerender(<DropdownContainer items={withOneChip} />);
|
||||
});
|
||||
const withTwoChips = [
|
||||
makeItem('cross-filter-chip-2', 'Segment'),
|
||||
...withOneChip,
|
||||
];
|
||||
await act(async () => {
|
||||
rerender(<DropdownContainer items={withTwoChips} />);
|
||||
});
|
||||
|
||||
containerRight = BAR_WIDTH;
|
||||
await act(async () => {
|
||||
flushRAF();
|
||||
});
|
||||
|
||||
expect(barItemCount()).toBe(3);
|
||||
expect(screen.queryByText('More')).toBeInTheDocument();
|
||||
});
|
||||
@@ -81,53 +81,6 @@ export const DropdownContainer = forwardRef(
|
||||
// when nothing actually overflows.
|
||||
const [recalculating, setRecalculating] = useState(false);
|
||||
|
||||
// One-shot confirmation pass: when the layout effect settles on "nothing
|
||||
// overflows" right after an item-set-change reset, the geometry may still
|
||||
// be mid-reflow. These refs coordinate a single rAF follow-up measurement
|
||||
// per item-set change so a transiently-bad "fits" verdict cannot latch.
|
||||
//
|
||||
// pendingConfirmForLengthRef: holds the items.length for which a
|
||||
// confirmation is pending (-1 = none pending). Set in the reset (else)
|
||||
// branch; cleared by the rAF callback after it settles.
|
||||
const pendingConfirmForLengthRef = useRef(-1);
|
||||
// confirmationScheduledRef: true once the rAF has been requested for the
|
||||
// current pending length, preventing a second rAF on the setItemsWidth
|
||||
// re-run that follows the first provisional measurement.
|
||||
const confirmationScheduledRef = useRef(false);
|
||||
// hadContentAtLastChangeRef: true when the trigger was showing at the
|
||||
// moment the most recent item-set change was detected. Keeps the trigger
|
||||
// mounted across the entire confirmation window (not just one render cycle)
|
||||
// without letting it linger once the rAF callback has settled. Cleared by
|
||||
// the rAF callback before calling setRecalculating(false).
|
||||
const hadContentAtLastChangeRef = useRef(false);
|
||||
// Guards rAF callbacks from firing after the component unmounts.
|
||||
const mountedRef = useRef(true);
|
||||
// Stores the pending confirmation rAF handle so it can be cancelled when a
|
||||
// newer item-set change supersedes it, or on unmount.
|
||||
const rafIdRef = useRef(0);
|
||||
// Bumped on every item-set change. A scheduled rAF captures the version at
|
||||
// schedule time and ignores itself if a newer change has superseded it, so
|
||||
// a stale frame can never clobber a newer item set's state.
|
||||
const confirmVersionRef = useRef(0);
|
||||
// The items.length the layout effect last observed, used to detect a new
|
||||
// item set (additions/removals) on any measurement path, not just the reset.
|
||||
const prevItemsLengthRef = useRef(items.length);
|
||||
useEffect(
|
||||
() => () => {
|
||||
mountedRef.current = false;
|
||||
if (rafIdRef.current) {
|
||||
cancelAnimationFrame(rafIdRef.current);
|
||||
rafIdRef.current = 0;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
// Persists the inner container element for the rAF confirmation callback.
|
||||
// Updated each time the layout effect finds a valid container so the rAF
|
||||
// does not need to re-derive it through ref.current, which may be null by
|
||||
// the time the callback fires in certain timing / test scenarios.
|
||||
const containerRef = useRef<Element | null>(null);
|
||||
|
||||
// callback to update item widths so that the useLayoutEffect runs whenever
|
||||
// width of any of the child changes
|
||||
const recalculateItemWidths = useCallback(() => {
|
||||
@@ -210,66 +163,14 @@ export const DropdownContainer = forwardRef(
|
||||
};
|
||||
}, [items.length, current, recalculateItemWidths]);
|
||||
|
||||
const overflowingCount =
|
||||
overflowingIndex !== -1 ? items.length - overflowingIndex : 0;
|
||||
|
||||
const popoverContent = useMemo(
|
||||
() =>
|
||||
dropdownContent || overflowingCount ? (
|
||||
<div
|
||||
css={css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.sizeUnit * 4}px;
|
||||
`}
|
||||
data-test="dropdown-content"
|
||||
style={dropdownStyle}
|
||||
ref={targetRef}
|
||||
>
|
||||
{dropdownContent
|
||||
? dropdownContent(overflowedItems)
|
||||
: overflowedItems.map(item => item.element)}
|
||||
</div>
|
||||
) : null,
|
||||
[
|
||||
dropdownContent,
|
||||
overflowingCount,
|
||||
theme.sizeUnit,
|
||||
dropdownStyle,
|
||||
overflowedItems,
|
||||
],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (popoverVisible) {
|
||||
return;
|
||||
}
|
||||
const container = current?.children.item(0);
|
||||
if (container) {
|
||||
containerRef.current = container;
|
||||
const { children } = container;
|
||||
const childrenArray = Array.from(children);
|
||||
|
||||
// Detect a new item set (additions/removals shift the positional
|
||||
// measurements the overflow split relies on). Arm a confirmation pass
|
||||
// for it here so EVERY measurement path below — not just the reset
|
||||
// branch — gets a follow-up; otherwise a fit->overflow transition (the
|
||||
// bar was fitting, so the reset branch is skipped) could settle a
|
||||
// transient "fits" verdict with no rescue. Also supersede any
|
||||
// confirmation still pending for the previous item set: bump the version
|
||||
// (so its stale rAF ignores itself) and cancel its frame.
|
||||
if (prevItemsLengthRef.current !== items.length) {
|
||||
prevItemsLengthRef.current = items.length;
|
||||
pendingConfirmForLengthRef.current = items.length;
|
||||
confirmationScheduledRef.current = false;
|
||||
hadContentAtLastChangeRef.current = !!popoverContent;
|
||||
confirmVersionRef.current += 1;
|
||||
if (rafIdRef.current) {
|
||||
cancelAnimationFrame(rafIdRef.current);
|
||||
rafIdRef.current = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// If items length change, add all items to the container
|
||||
// and recalculate the widths
|
||||
if (itemsWidth.length !== items.length) {
|
||||
@@ -310,12 +211,6 @@ export const DropdownContainer = forwardRef(
|
||||
// Checks if some elements in the dropdown fits in the remaining space
|
||||
let sum = 0;
|
||||
for (let i = childrenArray.length; i < items.length; i += 1) {
|
||||
// Guard: itemsWidth may be stale when its length doesn't match the
|
||||
// current item set (its updater bails on a length mismatch). An
|
||||
// undefined entry would otherwise inject NaN into the sum.
|
||||
if (itemsWidth[i] === undefined) {
|
||||
break;
|
||||
}
|
||||
sum += itemsWidth[i];
|
||||
if (sum <= remainingSpace) {
|
||||
newOverflowingIndex = i + 1;
|
||||
@@ -325,59 +220,6 @@ export const DropdownContainer = forwardRef(
|
||||
}
|
||||
}
|
||||
|
||||
// A "nothing overflows" verdict on the pass that consumed an item-set-
|
||||
// change reset may reflect a transient mid-reflow measurement. When that
|
||||
// happens, do NOT settle immediately. Instead:
|
||||
// • If the rAF hasn't been scheduled yet: schedule it (one-shot) and
|
||||
// return without settling; recalculating stays true so the trigger
|
||||
// remains mounted throughout the confirmation window.
|
||||
// • If the rAF is already scheduled (a second layout effect run
|
||||
// triggered by the setItemsWidth call above): also return without
|
||||
// settling for the same reason.
|
||||
// The rAF callback reads the DOM directly at a point where the browser
|
||||
// has reflowed and calls the setters itself. It also resets the guard
|
||||
// refs so subsequent effect runs (e.g. from a real resize) behave
|
||||
// normally.
|
||||
if (
|
||||
newOverflowingIndex === -1 &&
|
||||
pendingConfirmForLengthRef.current === items.length
|
||||
) {
|
||||
if (!confirmationScheduledRef.current) {
|
||||
confirmationScheduledRef.current = true;
|
||||
const scheduledVersion = confirmVersionRef.current;
|
||||
rafIdRef.current = requestAnimationFrame(() => {
|
||||
rafIdRef.current = 0;
|
||||
if (!mountedRef.current) return;
|
||||
// A newer item-set change superseded this confirmation while the
|
||||
// frame was queued; let the newer one's own confirmation settle.
|
||||
if (confirmVersionRef.current !== scheduledVersion) return;
|
||||
// Reset guard refs so future layout effect runs are unaffected.
|
||||
pendingConfirmForLengthRef.current = -1;
|
||||
confirmationScheduledRef.current = false;
|
||||
hadContentAtLastChangeRef.current = false;
|
||||
const el = containerRef.current;
|
||||
if (!el) {
|
||||
setOverflowingIndex(-1);
|
||||
setRecalculating(false);
|
||||
return;
|
||||
}
|
||||
const kids = Array.from(el.children);
|
||||
const confirmIdx = kids.findIndex(
|
||||
c =>
|
||||
c.getBoundingClientRect().right >
|
||||
el.getBoundingClientRect().right + 1,
|
||||
);
|
||||
setOverflowingIndex(confirmIdx);
|
||||
setRecalculating(false);
|
||||
});
|
||||
}
|
||||
// Either way (just scheduled or already pending): hold off settling so
|
||||
// recalculating stays true and the button guard keeps the trigger mounted.
|
||||
return;
|
||||
}
|
||||
|
||||
pendingConfirmForLengthRef.current = -1;
|
||||
confirmationScheduledRef.current = false;
|
||||
setOverflowingIndex(newOverflowingIndex);
|
||||
setRecalculating(false);
|
||||
}
|
||||
@@ -400,14 +242,44 @@ export const DropdownContainer = forwardRef(
|
||||
}
|
||||
}, [notOverflowedIds, onOverflowingStateChange, overflowedIds]);
|
||||
|
||||
// During the rAF confirmation window recalculating stays true (the layout
|
||||
// effect returns early without settling). hadContentAtLastChangeRef tracks
|
||||
// whether the trigger was showing when the item-set change was detected; it
|
||||
// stays true across all renders until the rAF callback clears it. Together
|
||||
// they keep the trigger mounted for the full confirmation window without
|
||||
// letting it linger once the rAF has settled.
|
||||
const overflowingCount =
|
||||
overflowingIndex !== -1 ? items.length - overflowingIndex : 0;
|
||||
|
||||
const popoverContent = useMemo(
|
||||
() =>
|
||||
dropdownContent || overflowingCount ? (
|
||||
<div
|
||||
css={css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.sizeUnit * 4}px;
|
||||
`}
|
||||
data-test="dropdown-content"
|
||||
style={dropdownStyle}
|
||||
ref={targetRef}
|
||||
>
|
||||
{dropdownContent
|
||||
? dropdownContent(overflowedItems)
|
||||
: overflowedItems.map(item => item.element)}
|
||||
</div>
|
||||
) : null,
|
||||
[
|
||||
dropdownContent,
|
||||
overflowingCount,
|
||||
theme.sizeUnit,
|
||||
dropdownStyle,
|
||||
overflowedItems,
|
||||
],
|
||||
);
|
||||
|
||||
// The trigger had content in the previous render if popoverContent was
|
||||
// truthy then. During the brief mid-recalculation render where
|
||||
// popoverContent flips to null, this still reflects the prior (non-empty)
|
||||
// value, letting us keep the trigger mounted across the transient.
|
||||
const hadPopoverContent = usePrevious(!!popoverContent, false);
|
||||
|
||||
const showDropdownButton =
|
||||
!!popoverContent || (recalculating && hadContentAtLastChangeRef.current);
|
||||
!!popoverContent || (recalculating && hadPopoverContent);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (popoverVisible) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -160,6 +160,20 @@ const config: ControlPanelConfig = {
|
||||
},
|
||||
],
|
||||
['zoomable'],
|
||||
[
|
||||
{
|
||||
name: 'y_axis_slider',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Y-axis range slider'),
|
||||
default: false,
|
||||
renderTrigger: true,
|
||||
description: t(
|
||||
'Show a draggable slider to control the visible range of the Y-axis.',
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -74,6 +74,7 @@ export default function transformProps(
|
||||
yAxisTitlePosition,
|
||||
sliceId,
|
||||
zoomable,
|
||||
yAxisSlider,
|
||||
} = formData as BoxPlotQueryFormData;
|
||||
const refs: Refs = {};
|
||||
const colorFn = CategoricalColorNamespace.getScale(colorScheme as string);
|
||||
@@ -257,6 +258,28 @@ export default function transformProps(
|
||||
convertInteger(yAxisTitleMargin),
|
||||
convertInteger(xAxisTitleMargin),
|
||||
);
|
||||
const dataZoom = [
|
||||
...(zoomable
|
||||
? [
|
||||
{
|
||||
type: 'inside',
|
||||
zoomOnMouseWheel: false,
|
||||
moveOnMouseWheel: true,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(yAxisSlider
|
||||
? [
|
||||
{
|
||||
type: 'slider',
|
||||
show: true,
|
||||
yAxisIndex: [0],
|
||||
// Adjust the axis window without dropping data points outside the range.
|
||||
filterMode: 'none',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
const echartOptions: EChartsCoreOption = {
|
||||
grid: {
|
||||
...defaultGrid,
|
||||
@@ -298,15 +321,7 @@ export default function transformProps(
|
||||
},
|
||||
},
|
||||
},
|
||||
dataZoom: zoomable
|
||||
? [
|
||||
{
|
||||
type: 'inside',
|
||||
zoomOnMouseWheel: false,
|
||||
moveOnMouseWheel: true,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
dataZoom,
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -30,6 +30,7 @@ export type BoxPlotQueryFormData = QueryFormData & {
|
||||
numberFormat?: string;
|
||||
whiskerOptions?: BoxPlotFormDataWhiskerOptions;
|
||||
xTickLayout?: BoxPlotFormXTickLayout;
|
||||
yAxisSlider?: boolean;
|
||||
} & TitleFormData;
|
||||
|
||||
export type BoxPlotFormDataWhiskerOptions =
|
||||
|
||||
@@ -71,6 +71,15 @@ describe('BoxPlot transformProps', () => {
|
||||
theme: supersetTheme,
|
||||
});
|
||||
|
||||
const buildChartProps = (formDataOverrides: Partial<SqlaFormData> = {}) =>
|
||||
new ChartProps({
|
||||
formData: { ...formData, ...formDataOverrides },
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData: chartProps.queriesData,
|
||||
theme: supersetTheme,
|
||||
}) as EchartsBoxPlotChartProps;
|
||||
|
||||
test('should transform chart props for viz', () => {
|
||||
expect(transformProps(chartProps as EchartsBoxPlotChartProps)).toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -125,4 +134,41 @@ describe('BoxPlot transformProps', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('should add a vertical Y-axis slider to dataZoom when yAxisSlider is enabled', () => {
|
||||
const { echartOptions } = transformProps(
|
||||
buildChartProps({ yAxisSlider: true }),
|
||||
);
|
||||
expect((echartOptions as any).dataZoom).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: 'slider',
|
||||
show: true,
|
||||
yAxisIndex: [0],
|
||||
filterMode: 'none',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('should not add a Y-axis slider when yAxisSlider is disabled', () => {
|
||||
const { echartOptions } = transformProps(
|
||||
buildChartProps({ yAxisSlider: false }),
|
||||
);
|
||||
expect((echartOptions as any).dataZoom).not.toContainEqual(
|
||||
expect.objectContaining({ type: 'slider' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('should combine zoomable and yAxisSlider dataZoom entries', () => {
|
||||
const { echartOptions } = transformProps(
|
||||
buildChartProps({ zoomable: true, yAxisSlider: true }),
|
||||
);
|
||||
expect((echartOptions as any).dataZoom).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ type: 'inside' }),
|
||||
expect.objectContaining({ type: 'slider', yAxisIndex: [0] }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -524,7 +524,11 @@ class BaseReportState:
|
||||
self._update_query_context()
|
||||
|
||||
try:
|
||||
csv_data = get_chart_csv_data(chart_url=url, auth_cookies=auth_cookies)
|
||||
csv_data = get_chart_csv_data(
|
||||
chart_url=url,
|
||||
auth_cookies=auth_cookies,
|
||||
timeout=app.config["ALERT_REPORTS_CSV_REQUEST_TIMEOUT"],
|
||||
)
|
||||
elapsed_seconds = (datetime.utcnow() - start_time).total_seconds()
|
||||
logger.info(
|
||||
"CSV data generation from %s as user %s took %.2fs - execution_id: %s",
|
||||
@@ -574,7 +578,11 @@ class BaseReportState:
|
||||
self._update_query_context()
|
||||
|
||||
try:
|
||||
dataframe = get_chart_dataframe(url, auth_cookies)
|
||||
dataframe = get_chart_dataframe(
|
||||
url,
|
||||
auth_cookies,
|
||||
timeout=app.config["ALERT_REPORTS_CSV_REQUEST_TIMEOUT"],
|
||||
)
|
||||
elapsed_seconds = (datetime.utcnow() - start_time).total_seconds()
|
||||
logger.info(
|
||||
"DataFrame generation from %s as user %s took %.2fs - execution_id: %s",
|
||||
|
||||
@@ -1153,6 +1153,12 @@ SCREENSHOT_LOCATE_WAIT = int(timedelta(seconds=10).total_seconds())
|
||||
# Time before selenium times out after waiting for all DOM class elements named
|
||||
# "loading" are gone.
|
||||
SCREENSHOT_LOAD_WAIT = int(timedelta(minutes=1).total_seconds())
|
||||
# Maximum time (in seconds) selenium waits for an initial page navigation
|
||||
# (driver.get) to complete. Without it the navigation blocks indefinitely when
|
||||
# the target page never finishes loading (e.g. an unreachable WEBDRIVER_BASEURL),
|
||||
# which leaves the report schedule stuck in the WORKING state. Set to None to
|
||||
# disable (not recommended).
|
||||
SCREENSHOT_PAGE_LOAD_WAIT = int(timedelta(minutes=2).total_seconds())
|
||||
# Selenium destroy retries
|
||||
SCREENSHOT_SELENIUM_RETRIES = 5
|
||||
# Give selenium an headstart, in seconds
|
||||
@@ -1736,6 +1742,11 @@ SMTP_MAIL_FROM = "superset@superset.com"
|
||||
# If True creates a default SSL context with ssl.Purpose.CLIENT_AUTH using the
|
||||
# default system root CA certificates.
|
||||
SMTP_SSL_SERVER_AUTH = False
|
||||
# Socket timeout (in seconds) for the SMTP connection used when sending
|
||||
# alert/report emails. Without a timeout the underlying socket blocks
|
||||
# indefinitely if the SMTP server becomes unreachable, which leaves report
|
||||
# schedules stuck in the WORKING state. Set to None to disable (not recommended).
|
||||
SMTP_TIMEOUT = 30
|
||||
ENABLE_CHUNK_ENCODING = False
|
||||
|
||||
# Whether to bump the logging level to ERROR on the flask_appbuilder package
|
||||
@@ -2084,6 +2095,12 @@ ALERT_REPORTS_NOTIFICATION_DRY_RUN = False
|
||||
# Max tries to run queries to prevent false errors caused by transient errors
|
||||
# being returned to users. Set to a value >1 to enable retries.
|
||||
ALERT_REPORTS_QUERY_EXECUTION_MAX_TRIES = 1
|
||||
# Socket timeout (in seconds) for the HTTP request that fetches chart data when
|
||||
# generating CSV/dataframe report attachments. Without a timeout the request
|
||||
# blocks indefinitely if the Superset webserver is unreachable from the worker,
|
||||
# which leaves the report schedule stuck in the WORKING state. Set to None to
|
||||
# disable (not recommended).
|
||||
ALERT_REPORTS_CSV_REQUEST_TIMEOUT = 60
|
||||
# Custom width for screenshots
|
||||
ALERT_REPORTS_MIN_CUSTOM_SCREENSHOT_WIDTH = 600
|
||||
ALERT_REPORTS_MAX_CUSTOM_SCREENSHOT_WIDTH = 2400
|
||||
@@ -2128,6 +2145,12 @@ SLACK_CACHE_TIMEOUT = int(timedelta(days=1).total_seconds())
|
||||
# For workspaces with 10k+ channels, consider increasing to 10
|
||||
SLACK_API_RATE_LIMIT_RETRY_COUNT = 2
|
||||
|
||||
# Timeout (in seconds) for outbound Slack API calls. The Slack SDK defaults to 30s;
|
||||
# exposing it here lets operators grant more time for large file uploads (multi-MB
|
||||
# CSVs, PDFs, screenshot sets) to congested or rate-limited Slack endpoints without
|
||||
# patching code, consistent with the SMTP/CSV/screenshot timeouts.
|
||||
SLACK_API_TIMEOUT = 30
|
||||
|
||||
# The webdriver to use for generating reports when using Selenium (not Playwright).
|
||||
# This setting is ignored when PLAYWRIGHT_REPORTS_AND_THUMBNAILS is enabled, as
|
||||
# Playwright always uses Chromium regardless of this value.
|
||||
|
||||
@@ -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",),
|
||||
|
||||
@@ -73,9 +73,21 @@ def stringify_values(array: NDArray[Any]) -> NDArray[Any]:
|
||||
obj[na_obj] = None
|
||||
else:
|
||||
try:
|
||||
# for simple string conversions
|
||||
# this handles odd character types better
|
||||
obj[...] = obj.astype(str)
|
||||
val = obj.item()
|
||||
if isinstance(val, (dict, list)):
|
||||
try:
|
||||
# Use json.dumps for valid double-quoted JSON.
|
||||
# str() gives single-quoted repr like {'a': 1}
|
||||
# which breaks the frontend cell viewer.
|
||||
obj[...] = stringify(val)
|
||||
except TypeError:
|
||||
# Non-JSON-serializable value (e.g. bytes, custom
|
||||
# objects): fall back to str() to avoid crashing.
|
||||
obj[...] = str(val)
|
||||
else:
|
||||
# for simple string conversions
|
||||
# this handles odd character types better
|
||||
obj[...] = obj.astype(str)
|
||||
except ValueError:
|
||||
obj[...] = stringify(obj)
|
||||
|
||||
|
||||
@@ -938,6 +938,11 @@ def send_mime_email(
|
||||
smtp_starttls = config["SMTP_STARTTLS"]
|
||||
smtp_ssl = config["SMTP_SSL"]
|
||||
smtp_ssl_server_auth = config["SMTP_SSL_SERVER_AUTH"]
|
||||
# A missing timeout means the socket blocks forever when the SMTP server is
|
||||
# unreachable, wedging the report schedule in the WORKING state. Fall back to
|
||||
# the key being absent for backwards compatibility with custom configs.
|
||||
# Keep this fallback in sync with the SMTP_TIMEOUT default in config.py.
|
||||
smtp_timeout = config.get("SMTP_TIMEOUT", 30)
|
||||
|
||||
if dryrun:
|
||||
logger.info("Dryrun enabled, email notification content is below:")
|
||||
@@ -948,17 +953,27 @@ def send_mime_email(
|
||||
# root CA certificates
|
||||
ssl_context = ssl.create_default_context() if smtp_ssl_server_auth else None
|
||||
smtp = (
|
||||
smtplib.SMTP_SSL(smtp_host, smtp_port, context=ssl_context)
|
||||
smtplib.SMTP_SSL(
|
||||
smtp_host, smtp_port, context=ssl_context, timeout=smtp_timeout
|
||||
)
|
||||
if smtp_ssl
|
||||
else smtplib.SMTP(smtp_host, smtp_port)
|
||||
else smtplib.SMTP(smtp_host, smtp_port, timeout=smtp_timeout)
|
||||
)
|
||||
if smtp_starttls:
|
||||
smtp.starttls(context=ssl_context)
|
||||
if smtp_user and smtp_password:
|
||||
smtp.login(smtp_user, smtp_password)
|
||||
logger.debug("Sent an email to %s", str(e_to))
|
||||
smtp.sendmail(e_from, e_to, mime_msg.as_string())
|
||||
smtp.quit()
|
||||
try:
|
||||
if smtp_starttls:
|
||||
smtp.starttls(context=ssl_context)
|
||||
if smtp_user and smtp_password:
|
||||
smtp.login(smtp_user, smtp_password)
|
||||
logger.debug("Sent an email to %s", str(e_to))
|
||||
smtp.sendmail(e_from, e_to, mime_msg.as_string())
|
||||
finally:
|
||||
# Always release the socket; the new timeout means starttls/login/
|
||||
# sendmail can raise, and a skipped quit() would leak connections in
|
||||
# the long-lived worker process.
|
||||
try:
|
||||
smtp.quit()
|
||||
except smtplib.SMTPException:
|
||||
pass
|
||||
|
||||
|
||||
def recipients_string_to_list(address_string: str | None) -> list[str]:
|
||||
|
||||
@@ -90,14 +90,18 @@ def df_to_escaped_csv(df: pd.DataFrame, **kwargs: Any) -> Any:
|
||||
|
||||
|
||||
def get_chart_csv_data(
|
||||
chart_url: str, auth_cookies: Optional[dict[str, str]] = None
|
||||
chart_url: str,
|
||||
auth_cookies: Optional[dict[str, str]] = None,
|
||||
timeout: Optional[float] = None,
|
||||
) -> Optional[bytes]:
|
||||
content = None
|
||||
if auth_cookies:
|
||||
opener = urllib.request.build_opener()
|
||||
cookie_str = ";".join([f"{key}={val}" for key, val in auth_cookies.items()])
|
||||
opener.addheaders.append(("Cookie", cookie_str))
|
||||
response = opener.open(chart_url)
|
||||
# A missing timeout means the socket blocks forever when the Superset
|
||||
# webserver is unreachable, wedging the report schedule in WORKING.
|
||||
response = opener.open(chart_url, timeout=timeout)
|
||||
content = response.read()
|
||||
if response.getcode() != 200:
|
||||
raise URLError(response.getcode())
|
||||
@@ -107,11 +111,13 @@ def get_chart_csv_data(
|
||||
|
||||
|
||||
def get_chart_dataframe(
|
||||
chart_url: str, auth_cookies: Optional[dict[str, str]] = None
|
||||
chart_url: str,
|
||||
auth_cookies: Optional[dict[str, str]] = None,
|
||||
timeout: Optional[float] = None,
|
||||
) -> Optional[pd.DataFrame]:
|
||||
# Disable all the unnecessary-lambda violations in this function
|
||||
# pylint: disable=unnecessary-lambda
|
||||
content = get_chart_csv_data(chart_url, auth_cookies)
|
||||
content = get_chart_csv_data(chart_url, auth_cookies, timeout)
|
||||
if content is None:
|
||||
return None
|
||||
|
||||
|
||||
@@ -48,7 +48,11 @@ def get_slack_client() -> WebClient:
|
||||
token: str = app.config["SLACK_API_TOKEN"]
|
||||
if callable(token):
|
||||
token = token()
|
||||
client = WebClient(token=token, proxy=app.config["SLACK_PROXY"])
|
||||
client = WebClient(
|
||||
token=token,
|
||||
proxy=app.config["SLACK_PROXY"],
|
||||
timeout=app.config["SLACK_API_TIMEOUT"],
|
||||
)
|
||||
|
||||
max_retry_count = app.config.get("SLACK_API_RATE_LIMIT_RETRY_COUNT", 2)
|
||||
rate_limit_handler = RateLimitErrorRetryHandler(max_retry_count=max_retry_count)
|
||||
|
||||
@@ -502,9 +502,23 @@ class WebDriverSelenium(WebDriverProxy):
|
||||
self._driver = self._create()
|
||||
if not self._driver:
|
||||
raise RuntimeError("WebDriver creation failed")
|
||||
self._driver.set_window_size(*self._window)
|
||||
if self._user:
|
||||
self._auth(self._user)
|
||||
try:
|
||||
self._driver.set_window_size(*self._window)
|
||||
# Bound driver.get() so an unreachable page raises a
|
||||
# TimeoutException instead of blocking the worker (and the
|
||||
# report schedule) forever.
|
||||
page_load_wait = app.config["SCREENSHOT_PAGE_LOAD_WAIT"]
|
||||
if page_load_wait is not None:
|
||||
self._driver.set_page_load_timeout(page_load_wait)
|
||||
if self._user:
|
||||
self._auth(self._user)
|
||||
except Exception:
|
||||
# A failure mid-setup (e.g. the new page-load timeout or auth
|
||||
# raising) would otherwise leave a partially initialized,
|
||||
# unauthenticated driver cached for reuse. Tear it down so the
|
||||
# next access recreates it cleanly.
|
||||
self._destroy()
|
||||
raise
|
||||
return self._driver
|
||||
|
||||
def _create_firefox_driver(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -193,7 +193,9 @@ class TestEmailSmtp(SupersetTestCase):
|
||||
msg = MIMEMultipart()
|
||||
utils.send_mime_email("from", "to", msg, current_app.config, dryrun=False)
|
||||
mock_smtp.assert_called_with(
|
||||
current_app.config["SMTP_HOST"], current_app.config["SMTP_PORT"]
|
||||
current_app.config["SMTP_HOST"],
|
||||
current_app.config["SMTP_PORT"],
|
||||
timeout=current_app.config["SMTP_TIMEOUT"],
|
||||
)
|
||||
assert mock_smtp.return_value.starttls.called
|
||||
mock_smtp.return_value.login.assert_called_with(
|
||||
@@ -218,6 +220,7 @@ class TestEmailSmtp(SupersetTestCase):
|
||||
current_app.config["SMTP_HOST"],
|
||||
current_app.config["SMTP_PORT"],
|
||||
context=None,
|
||||
timeout=current_app.config["SMTP_TIMEOUT"],
|
||||
)
|
||||
|
||||
@mock.patch("smtplib.SMTP_SSL")
|
||||
@@ -235,6 +238,7 @@ class TestEmailSmtp(SupersetTestCase):
|
||||
current_app.config["SMTP_HOST"],
|
||||
current_app.config["SMTP_PORT"],
|
||||
context=mock.ANY,
|
||||
timeout=current_app.config["SMTP_TIMEOUT"],
|
||||
)
|
||||
called_context = mock_smtp_ssl.call_args.kwargs["context"]
|
||||
assert called_context.verify_mode == ssl.CERT_REQUIRED
|
||||
@@ -266,7 +270,9 @@ class TestEmailSmtp(SupersetTestCase):
|
||||
)
|
||||
assert not mock_smtp_ssl.called
|
||||
mock_smtp.assert_called_with(
|
||||
current_app.config["SMTP_HOST"], current_app.config["SMTP_PORT"]
|
||||
current_app.config["SMTP_HOST"],
|
||||
current_app.config["SMTP_PORT"],
|
||||
timeout=current_app.config["SMTP_TIMEOUT"],
|
||||
)
|
||||
assert not mock_smtp.login.called
|
||||
current_app.config["SMTP_USER"] = smtp_user
|
||||
@@ -281,6 +287,36 @@ class TestEmailSmtp(SupersetTestCase):
|
||||
assert not mock_smtp.called
|
||||
assert not mock_smtp_ssl.called
|
||||
|
||||
@mock.patch("smtplib.SMTP_SSL")
|
||||
@mock.patch("smtplib.SMTP")
|
||||
def test_send_mime_respects_custom_timeout(
|
||||
self, mock_smtp: mock.Mock, mock_smtp_ssl: mock.Mock
|
||||
) -> None:
|
||||
"""A configured SMTP_TIMEOUT must reach the smtplib client.
|
||||
|
||||
A missing timeout would block the worker forever when the SMTP server
|
||||
is unreachable, wedging the report schedule in WORKING (issue #40047).
|
||||
"""
|
||||
config = {**current_app.config, "SMTP_TIMEOUT": 7, "SMTP_SSL": False}
|
||||
mock_smtp.return_value = mock.Mock()
|
||||
utils.send_mime_email("from", ["to"], MIMEMultipart(), config, dryrun=False)
|
||||
assert mock_smtp.call_args.kwargs["timeout"] == 7
|
||||
|
||||
@mock.patch("smtplib.SMTP_SSL")
|
||||
@mock.patch("smtplib.SMTP")
|
||||
def test_send_mime_timeout_defaults_when_unset(
|
||||
self, mock_smtp: mock.Mock, mock_smtp_ssl: mock.Mock
|
||||
) -> None:
|
||||
"""An absent SMTP_TIMEOUT key falls back to the 30s default.
|
||||
|
||||
Custom configs predating SMTP_TIMEOUT must still get a finite timeout.
|
||||
"""
|
||||
config = {k: v for k, v in current_app.config.items() if k != "SMTP_TIMEOUT"}
|
||||
config["SMTP_SSL"] = False
|
||||
mock_smtp.return_value = mock.Mock()
|
||||
utils.send_mime_email("from", ["to"], MIMEMultipart(), config, dryrun=False)
|
||||
assert mock_smtp.call_args.kwargs["timeout"] == 30
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -2007,7 +2007,11 @@ def test_slack_token_callable_chart_report(
|
||||
TEST_ID, create_report_slack_chart.id, datetime.utcnow()
|
||||
).run()
|
||||
slack_token_mock.assert_called()
|
||||
slack_client_mock_class.assert_called_with(token="cool_code", proxy=None) # noqa: S106
|
||||
slack_client_mock_class.assert_called_with(
|
||||
token="cool_code", # noqa: S106
|
||||
proxy=None,
|
||||
timeout=30,
|
||||
)
|
||||
assert_log(ReportState.SUCCESS)
|
||||
|
||||
|
||||
|
||||
@@ -450,3 +450,93 @@ def test_stringify_extension_columns() -> None:
|
||||
# plain binary BLOBs and other types are left untouched
|
||||
assert pa.types.is_binary(result.schema.field("blob").type)
|
||||
assert pa.types.is_integer(result.schema.field("n").type)
|
||||
|
||||
|
||||
def test_stringify_values_dict_and_list_produce_valid_json() -> None:
|
||||
"""
|
||||
ClickHouse native JSON and Map types return Python dicts. When stringified for
|
||||
Arrow array storage they must produce valid double-quoted JSON strings, not
|
||||
Python's single-quoted repr. Single-quoted strings pass the cheap '{' prefix
|
||||
check in the frontend's safeJsonObjectParse but then fail JSONbig.parse(),
|
||||
so the SQL Lab cell viewer never activates.
|
||||
"""
|
||||
data = np.array(
|
||||
[
|
||||
{"key": "value", "nested": {"a": 1}},
|
||||
# str() gives ['a', 'b'] (single-quoted, invalid JSON);
|
||||
# json.dumps gives ["a", "b"] (double-quoted, valid JSON).
|
||||
["a", "b"],
|
||||
{"items": [1, 2, 3], "d": "Hello, World!"},
|
||||
None,
|
||||
],
|
||||
dtype=object,
|
||||
)
|
||||
result = stringify_values(data)
|
||||
|
||||
# Must be valid JSON strings (double-quoted), not Python repr (single-quoted)
|
||||
assert result[0] == '{"key": "value", "nested": {"a": 1}}'
|
||||
assert result[1] == '["a", "b"]'
|
||||
assert result[2] == '{"items": [1, 2, 3], "d": "Hello, World!"}'
|
||||
assert result[3] is None
|
||||
|
||||
# Parseable by a JSON parser — confirms the frontend's JSON.parse would succeed
|
||||
parsed = superset_json.loads(result[0])
|
||||
assert parsed == {"key": "value", "nested": {"a": 1}}
|
||||
parsed = superset_json.loads(result[1])
|
||||
assert parsed == ["a", "b"]
|
||||
|
||||
|
||||
def test_clickhouse_json_column_in_pa_table_is_valid_json() -> None:
|
||||
"""
|
||||
Verify that ClickHouse-style heterogeneous dict columns produce valid JSON
|
||||
strings in the Arrow table used by the msgpack serialization path.
|
||||
|
||||
When clickhouse-connect returns Python dicts for JSON/Map type columns,
|
||||
SupersetResultSet must serialize them with json.dumps (not str()) so that
|
||||
the SQL Lab grid's cell viewer can call JSON.parse on the value.
|
||||
"""
|
||||
data = [
|
||||
(1, {"a": {"b": 42}, "c": [1, 2, 3], "d": "Hello, World!"}),
|
||||
(2, {"e": 5}),
|
||||
(3, None),
|
||||
]
|
||||
description = [
|
||||
("id", 3, None, None, None, None, None),
|
||||
("json_col", None, None, None, None, None, None),
|
||||
]
|
||||
result_set = SupersetResultSet(data, description, BaseEngineSpec) # type: ignore
|
||||
|
||||
df_from_pa = SupersetResultSet.convert_table_to_df(result_set.pa_table)
|
||||
|
||||
val0 = df_from_pa["json_col"].iloc[0]
|
||||
val1 = df_from_pa["json_col"].iloc[1]
|
||||
|
||||
# Values in pa_table must be valid JSON strings (parseable by JSON.parse)
|
||||
assert isinstance(val0, str)
|
||||
assert isinstance(val1, str)
|
||||
|
||||
# Double-quoted JSON, not single-quoted Python repr
|
||||
parsed0 = superset_json.loads(val0)
|
||||
assert parsed0 == {"a": {"b": 42}, "c": [1, 2, 3], "d": "Hello, World!"}
|
||||
parsed1 = superset_json.loads(val1)
|
||||
assert parsed1 == {"e": 5}
|
||||
|
||||
|
||||
def test_stringify_values_non_serializable_dict_falls_back_to_str() -> None:
|
||||
"""
|
||||
When a dict/list contains a value that json.dumps cannot serialize (e.g. bytes),
|
||||
stringify_values must fall back to str() rather than raising TypeError and crashing
|
||||
the result-set construction path.
|
||||
"""
|
||||
|
||||
class _Unserializable:
|
||||
def __repr__(self) -> str:
|
||||
return "unserializable"
|
||||
|
||||
data = np.array(
|
||||
[{"key": _Unserializable()}],
|
||||
dtype=object,
|
||||
)
|
||||
# Must not raise — falls back to str()
|
||||
result = stringify_values(data)
|
||||
assert result[0] == str({"key": _Unserializable()})
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
# under the License.
|
||||
|
||||
|
||||
from typing import Any
|
||||
from unittest import mock
|
||||
|
||||
import pandas as pd
|
||||
import pyarrow as pa
|
||||
import pytest # noqa: F401
|
||||
@@ -25,6 +28,7 @@ from superset.utils import csv, json
|
||||
from superset.utils.core import GenericDataType
|
||||
from superset.utils.csv import (
|
||||
df_to_escaped_csv,
|
||||
get_chart_csv_data,
|
||||
get_chart_dataframe,
|
||||
)
|
||||
|
||||
@@ -75,20 +79,33 @@ def test_escape_value():
|
||||
assert result == "'\rfoo"
|
||||
|
||||
|
||||
def fake_get_chart_csv_data_none(chart_url, auth_cookies=None):
|
||||
def fake_get_chart_csv_data_none(
|
||||
chart_url: str,
|
||||
auth_cookies: dict[str, str] | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> bytes | None:
|
||||
"""Return ``None`` to mock a fetch that yields no payload."""
|
||||
return None
|
||||
|
||||
|
||||
def fake_get_chart_csv_data_empty(chart_url, auth_cookies=None):
|
||||
# Return JSON with empty data so that the resulting DataFrame is empty
|
||||
fake_result = {
|
||||
def fake_get_chart_csv_data_empty(
|
||||
chart_url: str,
|
||||
auth_cookies: dict[str, str] | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> bytes | None:
|
||||
"""Return an encoded empty-result payload for dataframe-empty scenarios."""
|
||||
fake_result: dict[str, Any] = {
|
||||
"result": [{"data": {}, "coltypes": [], "colnames": [], "indexnames": []}]
|
||||
}
|
||||
return json.dumps(fake_result).encode("utf-8")
|
||||
|
||||
|
||||
def fake_get_chart_csv_data_valid(chart_url, auth_cookies=None):
|
||||
# Return JSON with non-temporal data and valid indexnames so that they are used.
|
||||
def fake_get_chart_csv_data_valid(
|
||||
chart_url: str,
|
||||
auth_cookies: dict[str, str] | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> bytes | None:
|
||||
"""Return a non-temporal payload used to verify dataframe construction."""
|
||||
fake_result = {
|
||||
"result": [
|
||||
{
|
||||
@@ -103,7 +120,11 @@ def fake_get_chart_csv_data_valid(chart_url, auth_cookies=None):
|
||||
return json.dumps(fake_result).encode("utf-8")
|
||||
|
||||
|
||||
def fake_get_chart_csv_data_temporal(chart_url, auth_cookies=None):
|
||||
def fake_get_chart_csv_data_temporal(
|
||||
chart_url: str,
|
||||
auth_cookies: dict[str, str] | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> bytes | None:
|
||||
"""
|
||||
Return JSON with a temporal column and valid indexnames
|
||||
so that a MultiIndex is built.
|
||||
@@ -122,8 +143,12 @@ def fake_get_chart_csv_data_temporal(chart_url, auth_cookies=None):
|
||||
return json.dumps(fake_result).encode("utf-8")
|
||||
|
||||
|
||||
def fake_get_chart_csv_data_hierarchical(chart_url, auth_cookies=None):
|
||||
# Return JSON with hierarchical column (list-based) and matching index names.
|
||||
def fake_get_chart_csv_data_hierarchical(
|
||||
chart_url: str,
|
||||
auth_cookies: dict[str, str] | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> bytes | None:
|
||||
"""Return hierarchical-column mock data for MultiIndex assertions."""
|
||||
fake_result = {
|
||||
"result": [
|
||||
{
|
||||
@@ -138,7 +163,11 @@ def fake_get_chart_csv_data_hierarchical(chart_url, auth_cookies=None):
|
||||
return json.dumps(fake_result).encode("utf-8")
|
||||
|
||||
|
||||
def fake_get_chart_csv_data_with_na_values(chart_url, auth_cookies=None):
|
||||
def fake_get_chart_csv_data_with_na_values(
|
||||
chart_url: str,
|
||||
auth_cookies: dict[str, str] | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> bytes | None:
|
||||
# Return JSON with data containing "NA" string value that will be treated as null
|
||||
fake_result = {
|
||||
"result": [
|
||||
@@ -380,3 +409,41 @@ def test_get_chart_dataframe_preserves_na_string_values(
|
||||
last_name_values = df[("last_name",)].values
|
||||
assert last_name_values[0] == "Smith"
|
||||
assert last_name_values[1] == "NA"
|
||||
|
||||
|
||||
def test_get_chart_csv_data_passes_timeout_to_opener(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The timeout argument must reach the urllib opener's open() call."""
|
||||
# Without a timeout the request blocks forever when the webserver is
|
||||
# unreachable, wedging the report schedule in WORKING (issue #40047).
|
||||
mock_response = mock.Mock()
|
||||
mock_response.read.return_value = b"data"
|
||||
mock_response.getcode.return_value = 200
|
||||
mock_opener = mock.Mock()
|
||||
mock_opener.open.return_value = mock_response
|
||||
mock_opener.addheaders = []
|
||||
monkeypatch.setattr(
|
||||
"urllib.request.build_opener", mock.Mock(return_value=mock_opener)
|
||||
)
|
||||
|
||||
get_chart_csv_data("http://dummy-url", auth_cookies={"session": "x"}, timeout=42)
|
||||
|
||||
mock_opener.open.assert_called_once_with("http://dummy-url", timeout=42)
|
||||
|
||||
|
||||
def test_get_chart_dataframe_forwards_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""get_chart_dataframe must forward its timeout down to get_chart_csv_data."""
|
||||
captured: dict[str, float | None] = {}
|
||||
|
||||
def fake(
|
||||
chart_url: str,
|
||||
auth_cookies: dict[str, str] | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> bytes | None:
|
||||
captured["timeout"] = timeout
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(csv, "get_chart_csv_data", fake)
|
||||
get_chart_dataframe("http://dummy-url", timeout=99)
|
||||
assert captured["timeout"] == 99
|
||||
|
||||
@@ -273,6 +273,36 @@ class TestWebDriverSelenium:
|
||||
# Should create driver without errors
|
||||
mock_driver_class.assert_called_once()
|
||||
|
||||
@patch("superset.utils.webdriver.app")
|
||||
def test_driver_sets_page_load_timeout(self, mock_app_patch: MagicMock) -> None:
|
||||
"""driver.get() must be bounded so it can't block forever (#40047)."""
|
||||
mock_app_patch.config = {
|
||||
"SCREENSHOT_LOCATE_WAIT": 10,
|
||||
"SCREENSHOT_LOAD_WAIT": 10,
|
||||
"SCREENSHOT_PAGE_LOAD_WAIT": 120,
|
||||
}
|
||||
mock_driver = MagicMock()
|
||||
driver = WebDriverSelenium(driver_type="chrome", window=(800, 600))
|
||||
with patch.object(driver, "_create", return_value=mock_driver):
|
||||
assert driver.driver is mock_driver
|
||||
mock_driver.set_page_load_timeout.assert_called_once_with(120)
|
||||
|
||||
@patch("superset.utils.webdriver.app")
|
||||
def test_driver_skips_page_load_timeout_when_none(
|
||||
self, mock_app_patch: MagicMock
|
||||
) -> None:
|
||||
"""Setting SCREENSHOT_PAGE_LOAD_WAIT to None disables the bound."""
|
||||
mock_app_patch.config = {
|
||||
"SCREENSHOT_LOCATE_WAIT": 10,
|
||||
"SCREENSHOT_LOAD_WAIT": 10,
|
||||
"SCREENSHOT_PAGE_LOAD_WAIT": None,
|
||||
}
|
||||
mock_driver = MagicMock()
|
||||
driver = WebDriverSelenium(driver_type="chrome", window=(800, 600))
|
||||
with patch.object(driver, "_create", return_value=mock_driver):
|
||||
assert driver.driver is mock_driver
|
||||
mock_driver.set_page_load_timeout.assert_not_called()
|
||||
|
||||
|
||||
class TestPlaywrightAvailabilityCheck:
|
||||
"""Test comprehensive Playwright availability checking."""
|
||||
|
||||
Reference in New Issue
Block a user