diff --git a/.github/actions/chart-releaser-action b/.github/actions/chart-releaser-action deleted file mode 160000 index a917fd15b20..00000000000 --- a/.github/actions/chart-releaser-action +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a917fd15b20e8b64b94d9158ad54cd6345335584 diff --git a/.github/workflows/bump-python-package.yml b/.github/workflows/bump-python-package.yml index 17a620e5570..70681804dd6 100644 --- a/.github/workflows/bump-python-package.yml +++ b/.github/workflows/bump-python-package.yml @@ -23,7 +23,7 @@ on: jobs: bump-python-package: - runs-on: ubuntu-26.04 + runs-on: ubuntu-slim permissions: actions: write contents: write diff --git a/.github/workflows/check_db_migration_confict.yml b/.github/workflows/check-db-migration-confict.yml similarity index 99% rename from .github/workflows/check_db_migration_confict.yml rename to .github/workflows/check-db-migration-confict.yml index df05b32b1db..4208d3cc8ee 100644 --- a/.github/workflows/check_db_migration_confict.yml +++ b/.github/workflows/check-db-migration-confict.yml @@ -19,7 +19,7 @@ concurrency: jobs: check_db_migration_conflict: name: Check DB migration conflict - runs-on: ubuntu-26.04 + runs-on: ubuntu-slim permissions: contents: read pull-requests: write diff --git a/.github/workflows/check-python-deps.yml b/.github/workflows/check-python-deps.yml index da1a79c6950..77f6764c9d9 100644 --- a/.github/workflows/check-python-deps.yml +++ b/.github/workflows/check-python-deps.yml @@ -11,6 +11,7 @@ on: permissions: contents: read pull-requests: read + actions: read # cancel previous workflow jobs for PRs concurrency: @@ -21,6 +22,10 @@ jobs: check-python-deps: runs-on: ubuntu-26.04 steps: + - uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1 + with: + expand-composite-actions: true + - name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )" uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -46,7 +51,7 @@ jobs: - name: Login to Docker Hub if: steps.check.outputs.python continue-on-error: true - uses: docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c # v4.5.0 + uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2 with: username: ${{ secrets.DOCKERHUB_USER }} password: ${{ secrets.DOCKERHUB_TOKEN }} diff --git a/.github/workflows/dependabot-auto-approve.yml b/.github/workflows/dependabot-auto-approve.yml new file mode 100644 index 00000000000..721cf6321d8 --- /dev/null +++ b/.github/workflows/dependabot-auto-approve.yml @@ -0,0 +1,63 @@ +name: Auto-approve Dependabot patch bumps + +# Posts an approving review on Dependabot PRs that only bump a patch +# version, using the same trigger/guard convention already proven to work +# for Dependabot PRs in sync-requirements-for-python-dep-upgrade-pr.yml +# (plain `pull_request` gets a working, write-capable GITHUB_TOKEN here +# because Dependabot pushes branches directly to this repo, not a fork). +# +# This does NOT auto-merge anything - repo-wide auto-merge is disabled +# (Settings > General > Pull Requests > "Allow auto-merge" is off), and +# flipping that is a separate, repo-wide decision this workflow doesn't +# make on its own. Branch protection also still requires 1 approving +# review; this just means that review can already exist by the time a +# human looks at the PR, for the (large majority of) ecosystems whose +# files aren't matched by any CODEOWNERS pattern. One ecosystem - the npm +# bump under .github/actions - matches the /.github/ CODEOWNERS entry, so +# those PRs will still need a human owner's approval regardless of this +# workflow; it posts a review there too, but that alone won't satisfy the +# code-owner requirement. +on: + pull_request: + types: [opened, synchronize] + +# Cancel a superseded run if Dependabot pushes to the same PR again before +# the previous run finished (matches the pattern used elsewhere in +# superset-docs-verify.yml). +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: {} + +jobs: + approve-patch-bump: + name: Approve patch-level bump + # Mirrors the guard in sync-requirements-for-python-dep-upgrade-pr.yml: + # limited to (1) PRs authored by Dependabot and (2) the upstream repo, + # since forked PRs don't get a write-capable token here anyway. + if: > + github.repository == 'apache/superset' && + github.event.pull_request.user.login == 'dependabot[bot]' && + github.event.pull_request.head.repo.fork == false + runs-on: ubuntu-latest + permissions: + pull-requests: write # to post the approving review via `gh pr review` + steps: + - name: Fetch Dependabot metadata + id: metadata + # This exact SHA is on ASF Infra's action allowlist + # (apache/infrastructure-actions approved_patterns.yml) as of this + # writing. Do not bump without opening an Infra ticket to allow + # the new SHA first! + uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0 + + - name: Approve patch-level bump + if: steps.metadata.outputs.update-type == 'version-update:semver-patch' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_URL: ${{ github.event.pull_request.html_url }} + DEPENDENCY_NAMES: ${{ steps.metadata.outputs.dependency-names }} + run: | + gh pr review --approve "$PR_URL" \ + --body "Auto-approved: patch-level bump only ($DEPENDENCY_NAMES)." diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 23889b41be1..e4d9b4a323c 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -20,11 +20,12 @@ concurrency: permissions: contents: read + actions: read jobs: dependency-review: if: github.event_name == 'pull_request' - runs-on: ubuntu-26.04 + runs-on: ubuntu-slim steps: - name: "Checkout Repository" uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -47,8 +48,12 @@ jobs: python-dependency-liccheck: # NOTE: Configuration for liccheck lives in our pyproject.yml. # You cannot use a liccheck.ini file in this workflow. - runs-on: ubuntu-26.04 + runs-on: ubuntu-slim steps: + - uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1 + with: + expand-composite-actions: true + - name: "Checkout Repository" uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index db9bb93bdae..aa1f3ad4097 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -40,7 +40,7 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} setup_matrix: - runs-on: ubuntu-26.04 + runs-on: ubuntu-slim timeout-minutes: 5 outputs: matrix_config: ${{ steps.set_matrix.outputs.matrix_config }} @@ -213,3 +213,14 @@ jobs: shell: bash run: | docker compose -f docker-compose-image-tag.yml up superset-init --exit-code-from superset-init + + actions-timeline: + needs: [docker-build, docker-compose-image-tag] + if: always() + runs-on: ubuntu-26.04 + permissions: + actions: read + steps: + - uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1 + with: + expand-composite-actions: true diff --git a/.github/workflows/embedded-sdk-release.yml b/.github/workflows/embedded-sdk-release.yml index 873858ace10..22b87a25b09 100644 --- a/.github/workflows/embedded-sdk-release.yml +++ b/.github/workflows/embedded-sdk-release.yml @@ -15,7 +15,7 @@ jobs: # gate on. Restrict to the canonical repo: forks cannot mint a valid OIDC # token for this package and must not publish. if: github.repository == 'apache/superset' - runs-on: ubuntu-26.04 + runs-on: ubuntu-slim permissions: contents: read id-token: write # required for npm trusted publishing (OIDC) diff --git a/.github/workflows/embedded-sdk-test.yml b/.github/workflows/embedded-sdk-test.yml index d8a6ce8adfe..9dfdca83511 100644 --- a/.github/workflows/embedded-sdk-test.yml +++ b/.github/workflows/embedded-sdk-test.yml @@ -16,7 +16,7 @@ concurrency: jobs: embedded-sdk-test: - runs-on: ubuntu-26.04 + runs-on: ubuntu-slim defaults: run: working-directory: superset-embedded-sdk diff --git a/.github/workflows/generate-FOSSA-report.yml b/.github/workflows/generate-FOSSA-report.yml index cc8de152575..afd57875c91 100644 --- a/.github/workflows/generate-FOSSA-report.yml +++ b/.github/workflows/generate-FOSSA-report.yml @@ -11,7 +11,7 @@ permissions: jobs: config: - runs-on: ubuntu-26.04 + runs-on: ubuntu-slim outputs: has-secrets: ${{ steps.check.outputs.has-secrets }} steps: @@ -29,7 +29,7 @@ jobs: needs: config if: needs.config.outputs.has-secrets name: Generate Report - runs-on: ubuntu-26.04 + runs-on: ubuntu-slim steps: - name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )" uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/issue_creation.yml b/.github/workflows/issue-creation.yml similarity index 97% rename from .github/workflows/issue_creation.yml rename to .github/workflows/issue-creation.yml index fb3871d0161..840600a31d8 100644 --- a/.github/workflows/issue_creation.yml +++ b/.github/workflows/issue-creation.yml @@ -9,7 +9,7 @@ on: jobs: superbot-orglabel: - runs-on: ubuntu-26.04 + runs-on: ubuntu-slim permissions: contents: read pull-requests: write diff --git a/.github/workflows/label-merge-conflicts.yml b/.github/workflows/label-merge-conflicts.yml new file mode 100644 index 00000000000..b764ec6b964 --- /dev/null +++ b/.github/workflows/label-merge-conflicts.yml @@ -0,0 +1,54 @@ +name: Label Merge Conflicts + +# Sweeps every open PR and labels the ones GitHub reports as CONFLICTING with +# `requires:rebase` (removing it once a rebase makes the PR mergeable again), +# so the label can be used to filter the PR backlog for the ones that need a +# rebase before they can be reviewed/merged. +# +# The action itself always re-checks *every* open PR via GraphQL on each run +# regardless of what triggered it (see eps1lon/actions-label-merge-conflict's +# sources/main.ts) - there's no way to scope it to "just this PR". The +# project's own README suggests triggering on `push` (to the default branch) +# plus `pull_request_target: [synchronize]`, but on a repo with Superset's PR +# volume that combination would re-sweep the entire open-PR list on every +# merge to master *and* every push to *any* open PR - many times an hour. +# A schedule bounds that to a fixed, predictable cadence instead; adjust it +# if 2 hours turns out to be too slow or too chatty in practice. +on: + schedule: + - cron: "0 */2 * * *" + workflow_dispatch: + +# Avoid two full backlog sweeps racing (a manual workflow_dispatch landing +# mid-schedule-tick, say); queue rather than cancel so an in-progress +# paginated sweep always runs to completion. +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +permissions: {} + +jobs: + label-merge-conflicts: + # Scheduled/dispatch workflows still run on forks that carry this file; + # skip anywhere but the canonical repo. + if: github.repository == 'apache/superset' + name: Label Merge Conflicts + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write # to add/remove requires:rebase and need:merge + steps: + # ASF Infra allowlists this whole action via a wildcard + # (eps1lon/actions-label-merge-conflict@*), so any pinned SHA/version + # is already fine here - no Infra ticket needed for future bumps. + - uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0 + with: + dirtyLabel: "requires:rebase" + # A conflicting PR isn't actually ready to merge; strip that signal + # if it was previously set so reviewers don't act on a stale one. + removeOnDirtyLabel: "need:merge" + repoToken: ${{ secrets.GITHUB_TOKEN }} + # Intentionally no commentOnDirty/commentOnClean: the label alone is + # the signal (matches the label's existing description, and avoids + # a one-time comment storm across the whole backlog on first run). diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index edeae632e7f..8890f4d6db5 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -12,7 +12,7 @@ jobs: permissions: contents: read pull-requests: write - runs-on: ubuntu-26.04 + runs-on: ubuntu-slim steps: - uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0 with: diff --git a/.github/workflows/latest-release-tag.yml b/.github/workflows/latest-release-tag.yml index 793101b2df5..00aa67df28f 100644 --- a/.github/workflows/latest-release-tag.yml +++ b/.github/workflows/latest-release-tag.yml @@ -6,7 +6,7 @@ on: jobs: latest-release: name: Add/update tag to new release - runs-on: ubuntu-26.04 + runs-on: ubuntu-slim permissions: contents: write diff --git a/.github/workflows/license-check.yml b/.github/workflows/license-check.yml index eeed678aaae..31056d01d45 100644 --- a/.github/workflows/license-check.yml +++ b/.github/workflows/license-check.yml @@ -15,7 +15,7 @@ concurrency: jobs: license_check: name: License Check - runs-on: ubuntu-26.04 + runs-on: ubuntu-slim steps: - name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )" uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/mirror-service-images.yml b/.github/workflows/mirror-service-images.yml new file mode 100644 index 00000000000..97cf692c90c --- /dev/null +++ b/.github/workflows/mirror-service-images.yml @@ -0,0 +1,113 @@ +# 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. +# +# Mirror the Docker Hub service-container images that CI relies on into the +# repository's GitHub Container Registry (GHCR) namespace. +# +# WHY: CI jobs declare Postgres/MySQL/Redis/Presto as `services:` containers +# pulled anonymously from Docker Hub. Anonymous pulls share the runner's IP +# rate limit, which causes intermittent timeouts / 429s / 502s on `master` +# and same-repo PRs. The obvious fix — adding `credentials:` to the service +# blocks — breaks fork PRs hard: forks can't read secrets, so the templated +# username/password resolve to '' and GitHub rejects the workflow at parse +# time ("Unexpected value ''"), failing every fork job at "Set up job". +# +# Mirroring to GHCR sidesteps both problems: public GHCR images are pulled +# without Docker Hub's anonymous rate limit AND without any credentials, so +# the consuming workflows need no `credentials:` block and forks work +# unchanged. +# +# ONE-TIME BOOTSTRAP (maintainer, after this lands on the default branch): +# 1. Run this workflow once (Actions tab → "Mirror service images to GHCR" +# → Run workflow), or wait for the weekly schedule. +# 2. In the org's Packages settings, set each mirrored package's visibility +# to **public** (apache/superset → ci/postgres, ci/mysql, ci/redis, +# ci/presto). Public visibility is what lets fork CI pull without auth. +# 3. Only then merge the follow-up that repoints the `services.*.image` +# refs at these GHCR copies and drops the `credentials:` blocks. +# +# NOTE: this mirrors only the images declared as `services:` containers (the +# ones that broke forks). The `bde2020` hive-metastore image pulled via +# `docker compose` in the Presto/Hive job is a separate path and is left for +# a follow-up. + +name: Mirror service images to GHCR + +on: + schedule: + # Weekly, Monday 06:00 UTC — keeps the mirror fresh as upstream tags move. + - cron: "0 6 * * 1" + workflow_dispatch: {} + +concurrency: + group: mirror-service-images + cancel-in-progress: false + +permissions: + contents: read + packages: write + +jobs: + mirror: + # Never run on forks: they lack both the secrets and write access to the + # apache GHCR namespace, so a scheduled run there would only ever fail. + if: github.repository == 'apache/superset' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # Keep this list in sync with the `services.*.image` refs in + # superset-e2e.yml, superset-python-integrationtest.yml, and + # superset-python-presto-hive.yml. + image: + - postgres:17-alpine + - redis:7-alpine + - mysql:8.0 + - starburstdata/presto:350-e.6 + steps: + - name: Log in to Docker Hub (authenticated source pulls) + uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2 + with: + username: ${{ secrets.DOCKERHUB_USER }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Log in to GHCR (push target) + uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + + - name: Copy image to GHCR + env: + # Pass the matrix value through the environment rather than + # interpolating it into the shell, to avoid template injection. + SRC_IMAGE: ${{ matrix.image }} + run: | + set -euo pipefail + # Destination keeps the image's short name (drop any namespace), + # under a `ci/` prefix in this repo's GHCR namespace. + name="${SRC_IMAGE##*/}" + dst="ghcr.io/${GITHUB_REPOSITORY}/ci/${name}" + echo "Mirroring docker.io/${SRC_IMAGE} -> ${dst}" + # imagetools copies the full (multi-arch) manifest registry-to- + # registry without a local pull/retag/push round trip. + docker buildx imagetools create --tag "${dst}" "docker.io/${SRC_IMAGE}" + echo "- \`docker.io/${SRC_IMAGE}\` → \`${dst}\`" >> "${GITHUB_STEP_SUMMARY}" diff --git a/.github/workflows/no-hold-label.yml b/.github/workflows/no-hold-label.yml index 00856cd8e2d..3a525b3bc4f 100644 --- a/.github/workflows/no-hold-label.yml +++ b/.github/workflows/no-hold-label.yml @@ -17,7 +17,7 @@ concurrency: jobs: check-hold-label: - runs-on: ubuntu-26.04 + runs-on: ubuntu-slim steps: - name: Check for 'hold' label uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 diff --git a/.github/workflows/pr-lint.yml b/.github/workflows/pr-lint.yml index 1a00594f5a7..79a8a0b6b27 100644 --- a/.github/workflows/pr-lint.yml +++ b/.github/workflows/pr-lint.yml @@ -21,7 +21,7 @@ concurrency: jobs: lint-check: - runs-on: ubuntu-26.04 + runs-on: ubuntu-slim permissions: contents: read pull-requests: write diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 7b93d78f04b..ce6ec11f258 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -15,6 +15,7 @@ on: permissions: contents: read + actions: read # cancel previous workflow jobs for PRs concurrency: @@ -188,3 +189,12 @@ jobs: echo "📖 More details here: https://superset.apache.org/docs/contributing/development#git-hooks" exit 1 fi + + actions-timeline: + needs: pre-commit + if: always() + runs-on: ubuntu-26.04 + steps: + - uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1 + with: + expand-composite-actions: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index de68d7b820b..351a3f47a43 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,7 @@ permissions: jobs: config: - runs-on: ubuntu-26.04 + runs-on: ubuntu-slim outputs: has-secrets: ${{ steps.check.outputs.has-secrets }} steps: diff --git a/.github/workflows/superset-app-cli.yml b/.github/workflows/superset-app-cli.yml index 70d012760ce..298cef8a2ba 100644 --- a/.github/workflows/superset-app-cli.yml +++ b/.github/workflows/superset-app-cli.yml @@ -11,6 +11,7 @@ on: permissions: contents: read pull-requests: read + actions: read # cancel previous workflow jobs for PRs concurrency: @@ -40,6 +41,10 @@ jobs: ports: - 16379:6379 steps: + - uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1 + with: + expand-composite-actions: true + - name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )" uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.github/workflows/superset-docs-deploy.yml b/.github/workflows/superset-docs-deploy.yml index 8b5f4287cfb..b55f3bd66cc 100644 --- a/.github/workflows/superset-docs-deploy.yml +++ b/.github/workflows/superset-docs-deploy.yml @@ -30,6 +30,7 @@ concurrency: permissions: contents: read + actions: read jobs: config: @@ -59,6 +60,10 @@ jobs: name: Build & Deploy runs-on: ubuntu-26.04 steps: + - uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1 + with: + expand-composite-actions: true + - name: "Checkout ${{ github.event.workflow_run.head_sha || github.sha }}" uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.github/workflows/superset-e2e.yml b/.github/workflows/superset-e2e.yml index ca94f2c68e5..4df02fd0984 100644 --- a/.github/workflows/superset-e2e.yml +++ b/.github/workflows/superset-e2e.yml @@ -340,3 +340,14 @@ jobs: exit 1 fi echo "playwright-tests result: $RESULT (changes: $CHANGES)" + + actions-timeline: + needs: [cypress-matrix, playwright-tests, cypress-matrix-required, playwright-tests-required] + if: always() + runs-on: ubuntu-26.04 + permissions: + actions: read + steps: + - uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1 + with: + expand-composite-actions: true diff --git a/.github/workflows/superset-extensions-cli.yml b/.github/workflows/superset-extensions-cli.yml index 0ee6eb3505c..60df7c0a3bc 100644 --- a/.github/workflows/superset-extensions-cli.yml +++ b/.github/workflows/superset-extensions-cli.yml @@ -11,6 +11,7 @@ on: permissions: contents: read pull-requests: read + actions: read # cancel previous workflow jobs for PRs concurrency: @@ -69,3 +70,12 @@ jobs: with: name: superset-extensions-cli-coverage-html path: htmlcov/ + + actions-timeline: + needs: test-superset-extensions-cli-package + if: always() + runs-on: ubuntu-26.04 + steps: + - uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1 + with: + expand-composite-actions: true diff --git a/.github/workflows/superset-frontend.yml b/.github/workflows/superset-frontend.yml index a7e20ab8235..923ec8ed8f5 100644 --- a/.github/workflows/superset-frontend.yml +++ b/.github/workflows/superset-frontend.yml @@ -201,3 +201,14 @@ jobs: run: | docker run --rm $TAG bash -c \ "npm run build-storybook && npx playwright install-deps && npx playwright install chromium && npm run test-storybook:ci" + + actions-timeline: + needs: [report-coverage, lint-frontend, validate-frontend, test-storybook] + if: always() + runs-on: ubuntu-26.04 + permissions: + actions: read + steps: + - uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1 + with: + expand-composite-actions: true diff --git a/.github/workflows/superset-helm-lint-test.yml b/.github/workflows/superset-helm-lint-test.yml index db59101bfa9..64914ba175a 100644 --- a/.github/workflows/superset-helm-lint-test.yml +++ b/.github/workflows/superset-helm-lint-test.yml @@ -8,6 +8,7 @@ on: permissions: contents: read + actions: read # Serialize runs per PR without cancelling: when a first-time contributor's # queued runs are approved together, cancel-in-progress lets an older run @@ -19,8 +20,12 @@ concurrency: jobs: lint-test: - runs-on: ubuntu-26.04 + runs-on: ubuntu-slim steps: + - uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1 + with: + expand-composite-actions: true + - name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )" uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -38,6 +43,11 @@ jobs: with: install-superset: "false" + # Still vendored (not de-vendored like chart-releaser-action below): the + # allowlisted helm/chart-testing-action@v2.8.0 depends internally on + # astral-sh/setup-uv@v7.0.0, which isn't itself on the ASF Actions + # allowlist (only v8.1.0+ are, at apache/infrastructure-actions' + # actions.yml). Needs an INFRA request before this can de-vendor too. - name: Set up chart-testing uses: ./.github/actions/chart-testing-action diff --git a/.github/workflows/superset-helm-release.yml b/.github/workflows/superset-helm-release.yml index ae6511d49da..53483c908c6 100644 --- a/.github/workflows/superset-helm-release.yml +++ b/.github/workflows/superset-helm-release.yml @@ -95,13 +95,8 @@ jobs: # Return to the original branch git checkout local_gha_temp - - name: Fetch/list all tags - run: | - git submodule update - cat .github/actions/chart-releaser-action/action.yml - - name: Run chart-releaser - uses: ./.github/actions/chart-releaser-action + uses: helm/chart-releaser-action@cae68fefc6b5f367a0275617c9f83181ba54714f # v1.7.0 with: version: v1.6.0 charts_dir: helm diff --git a/.github/workflows/superset-playwright.yml b/.github/workflows/superset-playwright.yml index 944a3eabcb0..fcdeff265a4 100644 --- a/.github/workflows/superset-playwright.yml +++ b/.github/workflows/superset-playwright.yml @@ -170,3 +170,14 @@ jobs: ${{ github.workspace }}/superset-frontend/playwright-results/ ${{ github.workspace }}/superset-frontend/test-results/ name: playwright-experimental-artifact-${{ github.run_id }}-${{ github.job }}-${{ matrix.browser }}--${{ steps.set-safe-app-root.outputs.safe_app_root }} + + actions-timeline: + needs: playwright-tests-experimental + if: always() + runs-on: ubuntu-26.04 + permissions: + actions: read + steps: + - uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1 + with: + expand-composite-actions: true diff --git a/.github/workflows/superset-python-integrationtest.yml b/.github/workflows/superset-python-integrationtest.yml index fd8c4a39b6d..1291ae9c7d8 100644 --- a/.github/workflows/superset-python-integrationtest.yml +++ b/.github/workflows/superset-python-integrationtest.yml @@ -255,3 +255,14 @@ jobs: exit 1 fi echo "test-postgres result: $RESULT" + + actions-timeline: + needs: [test-mysql, test-postgres, test-sqlite, test-postgres-required] + if: always() + runs-on: ubuntu-26.04 + permissions: + actions: read + steps: + - uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1 + with: + expand-composite-actions: true diff --git a/.github/workflows/superset-python-presto-hive.yml b/.github/workflows/superset-python-presto-hive.yml index 0eb264e83f1..60dea43d0e9 100644 --- a/.github/workflows/superset-python-presto-hive.yml +++ b/.github/workflows/superset-python-presto-hive.yml @@ -158,3 +158,14 @@ jobs: verbose: true use_oidc: true slug: apache/superset + + actions-timeline: + needs: [test-postgres-presto, test-postgres-hive] + if: always() + runs-on: ubuntu-26.04 + permissions: + actions: read + steps: + - uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1 + with: + expand-composite-actions: true diff --git a/.github/workflows/superset-python-unittest.yml b/.github/workflows/superset-python-unittest.yml index 64f3c683ae6..7e5d8833547 100644 --- a/.github/workflows/superset-python-unittest.yml +++ b/.github/workflows/superset-python-unittest.yml @@ -101,7 +101,14 @@ jobs: if: always() runs-on: ubuntu-26.04 timeout-minutes: 5 + permissions: + contents: read + actions: read steps: + - uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1 + with: + expand-composite-actions: true + - name: Check unit-tests result env: RESULT: ${{ needs.unit-tests.result }} diff --git a/.github/workflows/superset-translations.yml b/.github/workflows/superset-translations.yml index d0a3eb69006..8ccc6030331 100644 --- a/.github/workflows/superset-translations.yml +++ b/.github/workflows/superset-translations.yml @@ -153,3 +153,14 @@ jobs: - name: Fail if regression detected if: steps.regression.outcome == 'failure' run: exit 1 + + actions-timeline: + needs: [frontend-check-translations, babel-extract] + if: always() + runs-on: ubuntu-26.04 + permissions: + actions: read + steps: + - uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1 + with: + expand-composite-actions: true diff --git a/.github/workflows/superset-websocket.yml b/.github/workflows/superset-websocket.yml index e9bd5b1abd5..123f0dd6c1c 100644 --- a/.github/workflows/superset-websocket.yml +++ b/.github/workflows/superset-websocket.yml @@ -21,7 +21,7 @@ concurrency: jobs: app-checks: - runs-on: ubuntu-26.04 + runs-on: ubuntu-slim timeout-minutes: 20 steps: - name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )" diff --git a/.github/workflows/sync-requirements-for-python-dep-upgrade-pr.yml b/.github/workflows/sync-requirements-for-python-dep-upgrade-pr.yml index 98c9d36d910..90199a7997a 100644 --- a/.github/workflows/sync-requirements-for-python-dep-upgrade-pr.yml +++ b/.github/workflows/sync-requirements-for-python-dep-upgrade-pr.yml @@ -38,7 +38,7 @@ jobs: - name: Login to Docker Hub if: ${{ steps.dependabot-metadata.outputs.package-ecosystem == 'pip' }} continue-on-error: true - uses: docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c # v4.5.0 + uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2 with: username: ${{ secrets.DOCKERHUB_USER }} password: ${{ secrets.DOCKERHUB_TOKEN }} diff --git a/.github/workflows/tag-release.yml b/.github/workflows/tag-release.yml index faee116937d..8708f3f189e 100644 --- a/.github/workflows/tag-release.yml +++ b/.github/workflows/tag-release.yml @@ -32,7 +32,7 @@ concurrency: jobs: config: - runs-on: ubuntu-26.04 + runs-on: ubuntu-slim outputs: has-secrets: ${{ steps.check.outputs.has-secrets }} steps: diff --git a/.github/workflows/tech-debt.yml b/.github/workflows/tech-debt.yml index 8a668920cc4..0b0c7cdcebf 100644 --- a/.github/workflows/tech-debt.yml +++ b/.github/workflows/tech-debt.yml @@ -11,7 +11,7 @@ permissions: jobs: config: - runs-on: ubuntu-26.04 + runs-on: ubuntu-slim outputs: has-secrets: ${{ steps.check.outputs.has-secrets }} steps: @@ -28,7 +28,7 @@ jobs: process-and-upload: needs: config if: needs.config.outputs.has-secrets - runs-on: ubuntu-26.04 + runs-on: ubuntu-slim name: Generate Reports steps: - name: Checkout Repository diff --git a/.github/workflows/welcome-new-users.yml b/.github/workflows/welcome-new-users.yml index 68b78a397b8..20971dad370 100644 --- a/.github/workflows/welcome-new-users.yml +++ b/.github/workflows/welcome-new-users.yml @@ -7,7 +7,7 @@ on: jobs: welcome: - runs-on: ubuntu-26.04 + runs-on: ubuntu-slim if: github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' permissions: pull-requests: write diff --git a/.gitmodules b/.gitmodules index 54a0fb6b8f4..39e51236da9 100644 --- a/.gitmodules +++ b/.gitmodules @@ -30,9 +30,6 @@ [submodule ".github/actions/chart-testing-action"] path = .github/actions/chart-testing-action url = https://github.com/helm/chart-testing-action -[submodule ".github/actions/chart-releaser-action"] - path = .github/actions/chart-releaser-action - url = https://github.com/helm/chart-releaser-action [submodule ".github/actions/github-action-push-to-another-repository"] path = .github/actions/github-action-push-to-another-repository url = https://github.com/cpina/github-action-push-to-another-repository diff --git a/RELEASING/README.md b/RELEASING/README.md index d49671c598c..23f3f116aca 100644 --- a/RELEASING/README.md +++ b/RELEASING/README.md @@ -423,7 +423,7 @@ git push origin ${SUPERSET_VERSION} ### Publishing a Convenience Release to PyPI -Extract the release to the `/tmp` folder to build the PiPY release. Files in the `/tmp` folder will be automatically deleted by the OS. +Extract the release to the `/tmp` folder to build the PyPI release. Files in the `/tmp` folder will be automatically deleted by the OS. ```bash mkdir -p /tmp/superset && cd /tmp/superset diff --git a/RELEASING/validate_this_release.sh b/RELEASING/validate_this_release.sh index 4942803702c..53673ffc287 100755 --- a/RELEASING/validate_this_release.sh +++ b/RELEASING/validate_this_release.sh @@ -1,3 +1,5 @@ +#!/usr/bin/env bash + # 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 @@ -15,8 +17,6 @@ # specific language governing permissions and limitations # under the License. -#!/bin/bash - # Function to determine Python command get_python_command() { if command -v python3 &>/dev/null; then diff --git a/docker/apt-install.sh b/docker/apt-install.sh index 1c36353199e..c1423364c38 100755 --- a/docker/apt-install.sh +++ b/docker/apt-install.sh @@ -38,7 +38,7 @@ RESET='\033[0m' echo -e "${GREEN}Updating package lists...${RESET}" apt-get update -qq -echo -e "${GREEN}Installing packages: $@${RESET}" +echo -e "${GREEN}Installing packages: $*${RESET}" apt-get install -yqq --no-install-recommends "$@" echo -e "${GREEN}Autoremoving unnecessary packages...${RESET}" diff --git a/docker/tag_latest_release.sh b/docker/tag_latest_release.sh index b57c67e25f6..362ce4f47ee 100755 --- a/docker/tag_latest_release.sh +++ b/docker/tag_latest_release.sh @@ -163,10 +163,10 @@ do # Iterate through the components of the version strings for (( j=0; j<${#THIS_TAG_NAME_ARRAY[@]}; j++ )); do echo "Comparing ${THIS_TAG_NAME_ARRAY[$j]} to ${LATEST_RELEASE_TAG_ARRAY[$j]}" - if [[ $((THIS_TAG_NAME_ARRAY[$j])) > $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then + if [[ $((THIS_TAG_NAME_ARRAY[$j])) -gt $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then compare_result="greater" break - elif [[ $((THIS_TAG_NAME_ARRAY[$j])) < $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then + elif [[ $((THIS_TAG_NAME_ARRAY[$j])) -lt $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then compare_result="lesser" break fi diff --git a/docs/admin_docs/installation/docker-builds.mdx b/docs/admin_docs/installation/docker-builds.mdx index ffd523b906d..da7965291f4 100644 --- a/docs/admin_docs/installation/docker-builds.mdx +++ b/docs/admin_docs/installation/docker-builds.mdx @@ -112,12 +112,61 @@ USER superset CMD ["/app/docker/entrypoints/run-server.sh"] ``` +### Adding translations to a custom image + +The pattern above, a small Dockerfile that just extends `FROM apache/superset:...`, can't add +translations after the fact. By the time an official tag is published, its frontend and backend +layers have already had non-English translation files stripped out unless `BUILD_TRANSLATIONS` +was set at build time (see below), and there's no `superset/translations` source tree left in the +final image to compile from. + +To get translations into your own image, you need to build from the full Superset source (a +clone or fork of this repo) rather than extend a published tag. The most efficient way to do this +is to append your customizations as one more stage at the end of the repo's own `Dockerfile`, so +Docker can reuse the cached upstream layers and only rebuild what your stage adds: + +```Dockerfile +# Append this to the end of the repo's Dockerfile +# Keep this tag in sync with the branch/tag of the repo you cloned, so the +# translation files built from source match the keys the runtime expects: +FROM apache/superset:5.0.0 AS my-custom-image +USER root + +# Pull the translation files out of the earlier build stages (frontend +# .json in `superset-node`, backend .mo in `python-translation-compiler`). +# Those stages' own cleanup only matches single-character extensions, so +# the source `.po` files can still be present here; strip them explicitly +# so this stage only keeps the compiled translations. +COPY --from=superset-node /app/superset/translations superset/translations +COPY --from=python-translation-compiler /app/translations_mo superset/translations +RUN find superset/translations -name '*.po' -delete + +USER superset +``` + +Then build with: + +```bash +docker build --target=my-custom-image --build-arg=BUILD_TRANSLATIONS=true -t mysuperset:5.0.0 . +``` + +You can combine this with the database-driver/dependency pattern above by adding your own +`RUN uv pip install ...` step before switching back to `USER superset`. See +[issue #35959](https://github.com/apache/superset/issues/35959) for the discussion this pattern +came out of, credit to the community for working it out. + ## Key ARGs in Dockerfile -- `BUILD_TRANSLATIONS`: whether to build the translations into the image. For the - frontend build this tells webpack to strip out all locales other than `en` from - the `moment-timezone` library. For the backendthis skips compiling the - `*.po` translation files +- `BUILD_TRANSLATIONS`: whether to compile non-English translations into the image. + When `true`, the frontend build converts the `*.po` files to locale JSON and the + backend runs `pybabel compile` to produce `*.mo` files; both source `*.po` files + are stripped afterward either way. When `false` (the default), those compile + steps are skipped and only `en` ships. This only takes effect when building the image from source + (`docker build` against this repo's own `Dockerfile`); it has no effect on a downstream + Dockerfile that just extends an already-published tag, see + "Adding translations to a custom image" above. Note that the backend `pybabel compile` + step ignores its exit code, so a `.po` file with a compile error won't fail the build; + check the build logs for `pybabel` warnings if a locale's backend strings aren't showing up. - `DEV_MODE`: whether to skip the frontend build, this is used by our `docker-compose` dev setup where we mount the local volume and build using `webpack` in `--watch` mode, meaning as you alter the code in the local file system, webpack, from within a docker image used for this diff --git a/docs/package.json b/docs/package.json index a46d19d7557..07a367afbc9 100644 --- a/docs/package.json +++ b/docs/package.json @@ -58,11 +58,11 @@ "@fontsource/inter": "^5.3.0", "@mdx-js/react": "^3.1.1", "@saucelabs/theme-github-codeblock": "^0.3.0", - "@storybook/addon-docs": "^10.5.3", + "@storybook/addon-docs": "^10.5.4", "@superset-ui/core": "^0.20.4", "@swc/core": "^1.15.46", - "antd": "^6.5.1", - "baseline-browser-mapping": "^2.11.1", + "antd": "^6.5.2", + "baseline-browser-mapping": "^2.11.4", "caniuse-lite": "^1.0.30001806", "docusaurus-plugin-openapi-docs": "^5.1.2", "docusaurus-theme-openapi-docs": "^5.1.2", @@ -77,7 +77,7 @@ "react-table": "^7.8.0", "remark-import-partial": "^0.0.2", "reselect": "^5.2.0", - "storybook": "^10.5.3", + "storybook": "^10.5.4", "swagger-ui-react": "^5.32.11", "swc-loader": "^0.2.7", "tinycolor2": "^1.4.2", @@ -95,11 +95,11 @@ "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.6", "eslint-plugin-react": "^7.37.5", - "globals": "^17.7.0", + "globals": "^17.8.0", "prettier": "^3.9.6", "typescript": "~6.0.3", "typescript-eslint": "^8.65.0", - "webpack": "^5.108.2" + "webpack": "^5.109.0" }, "browserslist": { "production": [ diff --git a/docs/yarn.lock b/docs/yarn.lock index 2e9e644e498..95a9be4753a 100644 --- a/docs/yarn.lock +++ b/docs/yarn.lock @@ -3779,7 +3779,7 @@ "@rc-component/virtual-list" "^1.2.0" clsx "^2.1.1" -"@rc-component/trigger@^3.0.0", "@rc-component/trigger@^3.10.0", "@rc-component/trigger@^3.6.15", "@rc-component/trigger@^3.7.1": +"@rc-component/trigger@^3.0.0", "@rc-component/trigger@^3.10.1", "@rc-component/trigger@^3.6.15", "@rc-component/trigger@^3.7.1": version "3.10.1" resolved "https://registry.yarnpkg.com/@rc-component/trigger/-/trigger-3.10.1.tgz#cb28e1bc0745a2af6897dd7ec774f9b56dc88f86" integrity sha512-mXlDN0IXdtV8Yqqm8195ECCyrbmfvvfKvwVvSlH0+qvKD6BUF8gRhEjSy0FOcD1+CcDRHgTiX99LoxfQrmh3Cw== @@ -3798,13 +3798,13 @@ "@rc-component/util" "^1.11.1" clsx "^2.1.1" -"@rc-component/util@^1.10.1", "@rc-component/util@^1.11.0", "@rc-component/util@^1.11.1", "@rc-component/util@^1.2.0", "@rc-component/util@^1.2.1", "@rc-component/util@^1.3.0", "@rc-component/util@^1.4.0", "@rc-component/util@^1.7.0", "@rc-component/util@^1.9.0": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@rc-component/util/-/util-1.11.1.tgz#07d698908339c55648e4f974afa739345e65b483" - integrity sha512-awVlI3ub2vqfqkYxOBc/uQ0efm3jw0wcrhtO/YWLyZfxiKXczKwNbVuhlnyxytDt7H9pbbVQiqr+O6MLATtRYg== +"@rc-component/util@^1.10.1", "@rc-component/util@^1.11.0", "@rc-component/util@^1.11.1", "@rc-component/util@^1.12.0", "@rc-component/util@^1.2.0", "@rc-component/util@^1.2.1", "@rc-component/util@^1.3.0", "@rc-component/util@^1.4.0", "@rc-component/util@^1.7.0", "@rc-component/util@^1.9.0": + version "1.12.0" + resolved "https://registry.yarnpkg.com/@rc-component/util/-/util-1.12.0.tgz#58e453585810bcb8a35ff1aafd5e01187457b86f" + integrity sha512-AEjPL8JVdohIITaiXokyjL9WQ6tKWWjAYK9QU16tGNE9JaQABBQy+hA4H2Lup5MgXy9yY3iLrbZJheuU13hTdQ== dependencies: is-mobile "^5.0.0" - react-is "^18.2.0" + react-is "^19.2.7" "@rc-component/virtual-list@^1.0.1", "@rc-component/virtual-list@^1.2.0": version "1.2.0" @@ -4005,23 +4005,23 @@ resolved "https://registry.yarnpkg.com/@standard-schema/utils/-/utils-0.3.0.tgz#3d5e608f16c2390c10528e98e59aef6bf73cae7b" integrity sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g== -"@storybook/addon-docs@^10.5.3": - version "10.5.3" - resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-10.5.3.tgz#b5666cf20361d85c98cc836b2bfcdbbb72af3e97" - integrity sha512-MI1VDMSMQk78YxjIdt7WlrVOiA3TzTP00lRed1LeXh0fCvA9jxz9YXJI2+XigsLaxCSuOAEf/l35/GTLDMHD8A== +"@storybook/addon-docs@^10.5.4": + version "10.5.4" + resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-10.5.4.tgz#1906605d3dee2d86ff91bb79b444edb2961536fb" + integrity sha512-2Z/x2pKEmXOCQjmttYzPuQBu9aWeMly8uEs3msrCTBLiHs/F7IlBFnMu0Z+T2Qvk0LEy8O93AlcPSP76aCcKjw== dependencies: "@mdx-js/react" "^3.0.0" - "@storybook/csf-plugin" "10.5.3" + "@storybook/csf-plugin" "10.5.4" "@storybook/icons" "^2.0.2" - "@storybook/react-dom-shim" "10.5.3" + "@storybook/react-dom-shim" "10.5.4" react "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" react-dom "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" ts-dedent "^2.0.0" -"@storybook/csf-plugin@10.5.3": - version "10.5.3" - resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-10.5.3.tgz#0818709c2761910f1e42710f6231f773eb97fb41" - integrity sha512-mkPq6zru8fN5+46uC1cZEbKW2ws1hh9KvF4g4/Gu8pNbKnvqULPhk0/Bf0ZCtlr7zI7DvcFhyCy3dbvN+2n4Gw== +"@storybook/csf-plugin@10.5.4": + version "10.5.4" + resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-10.5.4.tgz#750cb3cb74693e482e48c945f1bd29252ab1dc52" + integrity sha512-DSp5Z/eZlRnKq0KrKLJE6uoYf/Ysc+FP0Z5DVTGnOrie+z3tC0lNi9I4RB++EXkJeUDS9/4dxvJZVSWjhLlXxw== dependencies: unplugin "^2.3.5" @@ -4035,10 +4035,10 @@ resolved "https://registry.yarnpkg.com/@storybook/icons/-/icons-2.1.0.tgz#edfc2450a39c5e780f28c6cbc49acd7bff59b41a" integrity sha512-Fxh9vYpX9bQqFeHRiY8h2ApeRGDzRSMLwJwNZ/AIRqnyOKHxRKL+yFe+ctEkVJmuptRE9u1Hrn8ZZNHyfDKKNg== -"@storybook/react-dom-shim@10.5.3": - version "10.5.3" - resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-10.5.3.tgz#e176a6549aa02002e375cb66467a00c5403745f8" - integrity sha512-eUWBsRRax5R3MDJVFs/CrFDF1bYS58AMB9tX02lLRuiZe6xy1cKh3CRFS+2xH571l0fNaXQ+7j69TOJ0fk2tmA== +"@storybook/react-dom-shim@10.5.4": + version "10.5.4" + resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-10.5.4.tgz#c92bb6d8b26aaa4523efa5a8b99178a0a76a3d52" + integrity sha512-YdlppEOReg8MvTECRNuf79gu2zL83JqKDHIR/65eS0M6y+ue9pkpfjYo7hZVIcyOcRd9npBDXMdt2kC92bCuaA== "@superset-ui/core@^0.20.4": version "0.20.4" @@ -5835,11 +5835,6 @@ acorn-dynamic-import@^4.0.0: resolved "https://registry.yarnpkg.com/acorn-dynamic-import/-/acorn-dynamic-import-4.0.0.tgz#482210140582a36b83c3e342e1cfebcaa9240948" integrity sha512-d3OEjQV4ROpoflsnUA8HozoIR504TFxNivYEUi6uwz0IYhBkTDXGuWlNdMtybRt3nqVx/L6XqMt0FxkXuWKZhw== -acorn-import-phases@^1.0.3: - version "1.0.4" - resolved "https://registry.yarnpkg.com/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz#16eb850ba99a056cb7cbfe872ffb8972e18c8bd7" - integrity sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ== - acorn-jsx@^5.0.0, acorn-jsx@^5.0.1, acorn-jsx@^5.3.2: version "5.3.2" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" @@ -6028,10 +6023,10 @@ ansis@^3.2.0: resolved "https://registry.yarnpkg.com/ansis/-/ansis-3.17.0.tgz#fa8d9c2a93fe7d1177e0c17f9eeb562a58a832d7" integrity sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg== -antd@^6.5.1: - version "6.5.1" - resolved "https://registry.yarnpkg.com/antd/-/antd-6.5.1.tgz#2623db1f3b0ae32a2e311ef20f2f1413934d0094" - integrity sha512-VZVVF9zYI6S0NHqboVhCoY9Iiqj6dphW1NPB+sEaAf2HuIQ0haXWXj7ZvAXTRDzusktV6+cvvrSZEdRi4twATg== +antd@^6.5.2: + version "6.5.2" + resolved "https://registry.yarnpkg.com/antd/-/antd-6.5.2.tgz#d771211beddf539f37303f862df24e8aaf32b7c4" + integrity sha512-ntYx0lr4Jq192QnBkDWkDqEeoberXZ34vSE9SgiP/0J6DY8O0pzR3bVZLBsdpCSguVkwjtEAP+QNeMN7LNAvgw== dependencies: "@ant-design/colors" "^8.0.1" "@ant-design/cssinjs" "^2.1.2" @@ -6073,9 +6068,9 @@ antd@^6.5.1: "@rc-component/tour" "~2.4.0" "@rc-component/tree" "~1.3.2" "@rc-component/tree-select" "~1.11.0" - "@rc-component/trigger" "^3.10.0" + "@rc-component/trigger" "^3.10.1" "@rc-component/upload" "~1.1.1" - "@rc-component/util" "^1.11.1" + "@rc-component/util" "^1.12.0" clsx "^2.1.1" dayjs "^1.11.11" scroll-into-view-if-needed "^3.1.0" @@ -6368,10 +6363,10 @@ base64-js@^1.3.1, base64-js@^1.5.1: resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== -baseline-browser-mapping@^2.10.38, baseline-browser-mapping@^2.11.1, baseline-browser-mapping@^2.9.19: - version "2.11.1" - resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.1.tgz#390b4714558634093df77add4acca0c5c0c1605e" - integrity sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A== +baseline-browser-mapping@^2.10.38, baseline-browser-mapping@^2.11.4, baseline-browser-mapping@^2.9.19: + version "2.11.4" + resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.4.tgz#3da1a877a8be2ec06495123ce994b43af58cc55e" + integrity sha512-s4+sLr9mZ/CyqeRritFeYV/Zx73OAtmaHn6kkBS1XRoJn1hrg3xIDUcpicAEX68tkcIN0iBCgti31C8zxtkhsQ== batch@0.6.1: version "0.6.1" @@ -8066,10 +8061,10 @@ encodeurl@~2.0.0: resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58" integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== -enhanced-resolve@^5.22.2: - version "5.24.1" - resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.24.1.tgz#b2439adf5d31d7e4764de1f9ecf942d6cd3fc874" - integrity sha512-7DdUaTjmNwMcH2gLr1qycesKII3BK4RLy/mdAb7x10Lq7bR4aNKHt1BR1ZALSv0rPM/hF5wYF0PhGop/rJm8vw== +enhanced-resolve@^5.24.2: + version "5.24.5" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz#b4dad3255b7545f07ba5535189868e9f85f47573" + integrity sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A== dependencies: graceful-fs "^4.2.4" tapable "^2.3.3" @@ -9031,10 +9026,10 @@ globals@^14.0.0: resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e" integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ== -globals@^17.7.0: - version "17.7.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-17.7.0.tgz#553d55090b4dde8209ec2da42580d6e7e7d8b10d" - integrity sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg== +globals@^17.8.0: + version "17.8.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-17.8.0.tgz#a1f213a06adcd0eec38004c5cd39fef7af7e0830" + integrity sha512-Zz/LMDZScFmkakeL2cTHzf+PbWKdpU3uclqkZT7TjDG58j5WPt0PpA+n9uPI24fZtlw07q0OtEi84K+umsRzqQ== globalthis@^1.0.4: version "1.0.4" @@ -10431,11 +10426,6 @@ liquid-json@0.3.1: resolved "https://registry.yarnpkg.com/liquid-json/-/liquid-json-0.3.1.tgz#9155a18136d8a6b2615e5f16f9a2448ab6b50eea" integrity sha512-wUayTU8MS827Dam6MxgD72Ui+KOSF+u/eIqpatOtjnvgJ0+mnDq33uC2M7J0tPK+upe/DpUAuK4JUU89iBoNKQ== -loader-runner@^4.3.2: - version "4.3.2" - resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.3.2.tgz#9913d3a15971f8f635915e601fb5c9d495d918e9" - integrity sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w== - loader-utils@^2.0.0: version "2.0.4" resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.4.tgz#8b5cb38b5c34a9a018ee1fc0e6a066d1dfcc528c" @@ -13327,11 +13317,16 @@ react-is@^17.0.1: resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== -react-is@^18.0.0, react-is@^18.2.0: +react-is@^18.0.0: version "18.3.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== +react-is@^19.2.7: + version "19.2.8" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-19.2.8.tgz#09826f9fbc187bc668e3e5c62edc001f804d5018" + integrity sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ== + react-json-view-lite@^2.3.0: version "2.5.0" resolved "https://registry.yarnpkg.com/react-json-view-lite/-/react-json-view-lite-2.5.0.tgz#c7ff011c7cc80e9900abc7aa4916c6a5c6d6c1c6" @@ -14620,10 +14615,10 @@ stop-iteration-iterator@^1.1.0: es-errors "^1.3.0" internal-slot "^1.1.0" -storybook@^10.5.3: - version "10.5.3" - resolved "https://registry.yarnpkg.com/storybook/-/storybook-10.5.3.tgz#72ee7cc02e3b6353eeec9633c2367437cf2ac86e" - integrity sha512-c8Wumu5qz0N2fnzWBxcPzUsY+8BpKBKChNyl4BEh9qhMV6KW587gL8il8emRB+4Hay+zMjDHA7cIeTkl4FKYuw== +storybook@^10.5.4: + version "10.5.4" + resolved "https://registry.yarnpkg.com/storybook/-/storybook-10.5.4.tgz#3190fc10568053bd32f198ef164c045904cd2582" + integrity sha512-bmLxPsxVSPnbeiZqYQpozyNOiJXfk+pf7WfHZflvPkwT6Y+rvYz3Cj/D6H4Kf2jHpuDNiMXBKO3yawLN2OWirg== dependencies: "@storybook/global" "^5.0.0" "@storybook/icons" "^2.0.2" @@ -15855,20 +15850,20 @@ webpack-merge@^6.0.1: flat "^5.0.2" wildcard "^2.0.1" -webpack-sources@^3.5.0: - version "3.5.0" - resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.5.0.tgz#87bf7f5801a4e985b1f1c92b64b9620a02f76d08" - integrity sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ== +webpack-sources@^3.5.1: + version "3.5.1" + resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.5.1.tgz#76c2418486dcc02b2aa0694c104176c2858fe84a" + integrity sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw== webpack-virtual-modules@^0.6.2: version "0.6.2" resolved "https://registry.yarnpkg.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz#057faa9065c8acf48f24cb57ac0e77739ab9a7e8" integrity sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ== -webpack@^5.108.2, webpack@^5.88.1, webpack@^5.95.0: - version "5.108.4" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.108.4.tgz#141818a411662773a0bb32dc5536acc5409943b7" - integrity sha512-yur8LyJoeiWh47dErD+Ok7vlbmDsJ3UbbRPAoxbGJ54WpE2y5yVo5G/inUzujnYgw3tPmBRdn+G7PoxXaYC33w== +webpack@^5.109.0, webpack@^5.88.1, webpack@^5.95.0: + version "5.109.0" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.109.0.tgz#871d8eee5e2d5e6eaf5ec8d1a6db74ea65491030" + integrity sha512-vomrngskVVXEZF9sMZfYAd4pXZUnfaWdJGlF+BTNF+gJBCKYCQBnOeVPlrh39Ewl7nlCsirDplMy6o5g9xJHBg== dependencies: "@types/estree" "^1.0.8" "@types/json-schema" "^7.0.15" @@ -15876,22 +15871,20 @@ webpack@^5.108.2, webpack@^5.88.1, webpack@^5.95.0: "@webassemblyjs/wasm-edit" "^1.14.1" "@webassemblyjs/wasm-parser" "^1.14.1" acorn "^8.16.0" - acorn-import-phases "^1.0.3" browserslist "^4.28.1" chrome-trace-event "^1.0.2" - enhanced-resolve "^5.22.2" + enhanced-resolve "^5.24.2" es-module-lexer "^2.1.0" eslint-scope "5.1.1" events "^3.2.0" graceful-fs "^4.2.11" - loader-runner "^4.3.2" mime-db "^1.54.0" minimizer-webpack-plugin "^5.6.1" neo-async "^2.6.2" schema-utils "^4.3.3" tapable "^2.3.0" watchpack "^2.5.2" - webpack-sources "^3.5.0" + webpack-sources "^3.5.1" webpackbar@^7.0.0: version "7.0.0" diff --git a/pyproject.toml b/pyproject.toml index 5b0d2320723..118a1fe2dcc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,13 +42,13 @@ dependencies = [ # ``google-auth`` 2.53+ dropped it, so Superset must declare it # explicitly to keep fresh ``pip install apache-superset`` working # without the ``base.txt`` lock file (#40962). - "cachetools>=7.1.4, <8", + "cachetools>=7.1.6, <8", "celery>=5.6.3, <6.0.0", "click>=8.4.2", "click-option-group", "colorama", "flask-cors>=6.0.5, <7.0", - "croniter>=6.2.2", + "croniter>=6.2.4", "cron-descriptor", "cryptography>=49.0.0, <50.0.0", "deprecation>=2.1.0, <2.2.0", @@ -62,7 +62,7 @@ dependencies = [ "flask-session>=0.4.0, <1.0", "flask-wtf>=1.3.0, <2.0", "geopy", - "greenlet<=3.5.3, >=3.5.3", + "greenlet<=3.5.4, >=3.5.4", "gunicorn>=26.0.0, <27; sys_platform != 'win32'", "hashids>=1.3.1, <2", # holidays>=0.45 required for security fix @@ -90,7 +90,7 @@ dependencies = [ "paramiko>=3.4.0, <4.0", # 4.0 removed DSSKey, still referenced by sshtunnel "pgsanity", "Pillow>=11.0.0, <13", - "polyline>=2.0.0, <3.0", + "polyline>=2.0.4, <3.0", "pydantic>=2.8.0", "pyparsing>=3.3.2, <4", "python-dateutil", @@ -122,14 +122,14 @@ dependencies = [ [project.optional-dependencies] -athena = ["pyathena[pandas]>=2, <4"] +athena = ["pyathena[pandas]>=3.35.2, <4"] aurora-data-api = ["preset-sqlalchemy-aurora-data-api>=0.2.8,<0.3"] bigquery = [ "pandas-gbq>=0.35.0", "sqlalchemy-bigquery>=1.17.0", "google-cloud-bigquery>=3.42.2", ] -clickhouse = ["clickhouse-connect>=1.4.2, <2.0"] +clickhouse = ["clickhouse-connect>=1.6.0, <2.0"] cockroachdb = ["cockroachdb>=0.3.5, <0.4"] crate = ["sqlalchemy-cratedb>=0.41.0, <1"] d1 = [ @@ -139,7 +139,7 @@ d1 = [ ] databend = ["databend-sqlalchemy>=0.5.5, <1.0"] databricks = [ - "databricks-sql-connector>=4.2.6, <4.4.0", + "databricks-sql-connector>=4.4.0, <4.5.0", "databricks-sqlalchemy==1.0.5", ] datafusion = ["flightsql-dbapi>=0.2.2, <0.3"] @@ -173,7 +173,7 @@ hana = ["hdbcli==2.29.25", "sqlalchemy_hana==3.0.3"] hive = [ "pyhive[hive_pure_sasl]>=0.7.0", "tableschema", - "thrift>=0.23.0, <1.0.0", + "thrift>=0.24.0, <1.0.0", "thrift_sasl>=0.4.3, < 1.0.0", ] impala = ["impyla>=0.24.0, <0.25"] @@ -196,7 +196,7 @@ playwright = ["playwright>=1.61.0, <2"] postgres = ["psycopg2-binary==2.9.12"] presto = ["pyhive[presto]>=0.6.5"] trino = ["trino>=0.338.0"] -prophet = ["prophet>=1.1.6, <2"] +prophet = ["prophet>=1.3.0, <2"] redshift = ["sqlalchemy-redshift>=0.8.1, <0.9"] risingwave = ["sqlalchemy-risingwave"] shillelagh = ["shillelagh[all]>=1.4.4, <2"] @@ -206,7 +206,7 @@ sqlite = ["syntaqlite>=0.7.0,<0.8.0"] spark = [ "pyhive[hive_pure_sasl]>=0.7", "tableschema", - "thrift>=0.23.0, <1", + "thrift>=0.24.0, <1", ] tdengine = [ "taospy>=2.8.9", diff --git a/requirements/base.txt b/requirements/base.txt index 94b95d1effb..e22e3c7d864 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -46,7 +46,7 @@ cachelib==0.13.0 # via # flask-caching # flask-session -cachetools==7.1.4 +cachetools==7.1.6 # via apache-superset (pyproject.toml) cattrs==25.1.1 # via requests-cache @@ -86,7 +86,7 @@ colorama==0.4.6 # flask-appbuilder cron-descriptor==1.4.5 # via apache-superset (pyproject.toml) -croniter==6.2.2 +croniter==6.2.4 # via apache-superset (pyproject.toml) cryptography==49.0.0 # via @@ -166,7 +166,7 @@ google-auth==2.53.0 # via # -r requirements/base.in # shillelagh -greenlet==3.5.3 +greenlet==3.5.4 # via # apache-superset (pyproject.toml) # shillelagh @@ -291,7 +291,7 @@ pillow==12.3.0 # via apache-superset (pyproject.toml) platformdirs==4.3.8 # via requests-cache -polyline==2.0.2 +polyline==2.0.4 # via apache-superset (pyproject.toml) prison==0.2.1 # via flask-appbuilder diff --git a/requirements/development.txt b/requirements/development.txt index 3cf52d0d88b..54caccc08c6 100644 --- a/requirements/development.txt +++ b/requirements/development.txt @@ -101,7 +101,7 @@ cachelib==0.13.0 # -c requirements/base-constraint.txt # flask-caching # flask-session -cachetools==7.1.4 +cachetools==7.1.6 # via # -c requirements/base-constraint.txt # apache-superset @@ -178,7 +178,7 @@ cron-descriptor==1.4.5 # via # -c requirements/base-constraint.txt # apache-superset -croniter==6.2.2 +croniter==6.2.4 # via # -c requirements/base-constraint.txt # apache-superset @@ -377,7 +377,7 @@ googleapis-common-protos==1.66.0 # via # google-api-core # grpcio-status -greenlet==3.5.3 +greenlet==3.5.4 # via # -c requirements/base-constraint.txt # apache-superset @@ -687,7 +687,7 @@ pluggy==1.5.0 # via pytest polib==1.2.0 # via apache-superset -polyline==2.0.2 +polyline==2.0.4 # via # -c requirements/base-constraint.txt # apache-superset @@ -703,7 +703,7 @@ prompt-toolkit==3.0.51 # via # -c requirements/base-constraint.txt # click-repl -prophet==1.2.0 +prophet==1.3.0 # via apache-superset proto-plus==1.25.0 # via google-api-core diff --git a/scripts/check_license.sh b/scripts/check_license.sh index 28f2ddec319..9a00b07dec4 100755 --- a/scripts/check_license.sh +++ b/scripts/check_license.sh @@ -35,7 +35,7 @@ acquire_rat_jar () { wget --quiet ${URL} -O "$JAR_DL" && mv "$JAR_DL" "$JAR" else printf "You do not have curl or wget installed, please install rat manually.\n" - exit -1 + exit 255 fi fi @@ -44,7 +44,7 @@ acquire_rat_jar () { # We failed to download rm "$JAR" printf "Our attempt to download rat locally to ${JAR} failed. Please install rat manually.\n" - exit -1 + exit 255 fi printf "Done downloading.\n" } diff --git a/scripts/tag_latest_release.sh b/scripts/tag_latest_release.sh index b57c67e25f6..362ce4f47ee 100755 --- a/scripts/tag_latest_release.sh +++ b/scripts/tag_latest_release.sh @@ -163,10 +163,10 @@ do # Iterate through the components of the version strings for (( j=0; j<${#THIS_TAG_NAME_ARRAY[@]}; j++ )); do echo "Comparing ${THIS_TAG_NAME_ARRAY[$j]} to ${LATEST_RELEASE_TAG_ARRAY[$j]}" - if [[ $((THIS_TAG_NAME_ARRAY[$j])) > $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then + if [[ $((THIS_TAG_NAME_ARRAY[$j])) -gt $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then compare_result="greater" break - elif [[ $((THIS_TAG_NAME_ARRAY[$j])) < $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then + elif [[ $((THIS_TAG_NAME_ARRAY[$j])) -lt $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then compare_result="lesser" break fi diff --git a/superset-embedded-sdk/README.md b/superset-embedded-sdk/README.md index e26d2109be7..fc23cc173c6 100644 --- a/superset-embedded-sdk/README.md +++ b/superset-embedded-sdk/README.md @@ -215,7 +215,7 @@ Common permissions you might need: By default, the Embedded SDK creates an `iframe` element without a `referrerPolicy` value enforced. This means that a policy defined for `iframe` elements at the host app level would reflect to it. -This can be an issue as during the embedded enablement for a dashboard it's possible to specify which domain(s) are allowed to embed the dashboard, and this validation happens throuth the `Referrer` header. That said, in case the hosting app has a more restrictive policy that would omit this header, this validation would fail. +This can be an issue as during the embedded enablement for a dashboard it's possible to specify which domain(s) are allowed to embed the dashboard, and this validation happens through the `Referrer` header. That said, in case the hosting app has a more restrictive policy that would omit this header, this validation would fail. Use the `referrerPolicy` parameter in the `embedDashboard` method to specify [a particular policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Referrer-Policy) that works for your implementation. diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 3cef32f0ad7..c034d789f95 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -45,9 +45,9 @@ "@luma.gl/shadertools": "~9.2.5", "@luma.gl/webgl": "~9.2.5", "@reduxjs/toolkit": "^1.9.3", - "@rjsf/core": "^6.7.0", + "@rjsf/core": "^6.7.1", "@rjsf/utils": "^6.6.2", - "@rjsf/validator-ajv8": "^6.7.0", + "@rjsf/validator-ajv8": "^6.7.1", "@scarf/scarf": "^1.4.0", "@superset-ui/chart-controls": "file:./packages/superset-ui-chart-controls", "@superset-ui/core": "file:./packages/superset-ui-core", @@ -91,7 +91,7 @@ "dom-to-pdf": "^0.3.2", "echarts": "^6.1.0", "fast-glob": "^3.3.2", - "fs-extra": "^11.3.6", + "fs-extra": "^11.4.0", "fuse.js": "^7.5.0", "geolib": "^3.3.14", "geostyler": "^18.6.0", @@ -99,7 +99,7 @@ "geostyler-openlayers-parser": "^5.7.1", "geostyler-style": "11.0.2", "geostyler-wfs-parser": "^3.0.1", - "google-auth-library": "^10.9.0", + "google-auth-library": "^10.9.1", "immer": "^11.1.15", "interweave": "^13.1.1", "jquery": "^4.0.0", @@ -108,7 +108,7 @@ "json-stringify-pretty-compact": "^4.0.0", "lodash": "^4.18.1", "lodash-es": "^4.18.1", - "mapbox-gl": "^3.26.0", + "mapbox-gl": "^3.27.0", "markdown-to-jsx": "^9.9.0", "match-sorter": "^8.3.0", "memoize-one": "^6.0.0", @@ -180,17 +180,17 @@ "@istanbuljs/nyc-config-typescript": "^1.0.1", "@playwright/test": "^1.61.1", "@pmmmwh/react-refresh-webpack-plugin": "^0.6.2", - "@storybook/addon-docs": "10.5.3", - "@storybook/addon-links": "10.5.3", - "@storybook/react-webpack5": "10.5.3", + "@storybook/addon-docs": "10.5.4", + "@storybook/addon-links": "10.5.4", + "@storybook/react-webpack5": "10.5.4", "@storybook/test-runner": "0.24.4", "@svgr/webpack": "^8.1.0", "@swc/core": "^1.15.46", "@swc/plugin-emotion": "^14.15.0", "@swc/plugin-transform-imports": "^12.5.0", - "@testing-library/dom": "^9.3.4", - "@testing-library/jest-dom": "^6.9.1", - "@testing-library/react": "^14.0.0", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^7.0.0", + "@testing-library/react": "^15.0.0", "@testing-library/user-event": "^12.8.3", "@types/content-disposition": "^0.5.9", "@types/dom-to-image": "^2.6.7", @@ -221,7 +221,7 @@ "babel-plugin-jsx-remove-data-test-id": "^3.0.0", "baseline-browser-mapping": "^2.11.1", "cheerio": "1.2.0", - "concurrently": "^10.0.3", + "concurrently": "^10.0.4", "copy-webpack-plugin": "^14.0.0", "cross-env": "^10.1.0", "css-loader": "^7.1.4", @@ -238,13 +238,13 @@ "eslint-plugin-prettier": "^5.5.6", "eslint-plugin-react-prefer-function-component": "^5.0.0", "eslint-plugin-react-you-might-not-need-an-effect": "^1.0.1", - "eslint-plugin-storybook": "10.5.3", + "eslint-plugin-storybook": "10.5.4", "eslint-plugin-testing-library": "^7.16.2", "eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors", "fetch-mock": "^12.6.0", "fork-ts-checker-webpack-plugin": "^9.1.0", "history": "^5.3.0", - "html-webpack-plugin": "^5.6.7", + "html-webpack-plugin": "^5.6.8", "imports-loader": "^5.0.0", "jest": "^30.4.2", "jest-environment-jsdom": "^30.4.1", @@ -270,7 +270,7 @@ "source-map": "^0.8.0", "source-map-support": "^0.5.21", "speed-measure-webpack-plugin": "^1.6.0", - "storybook": "10.5.3", + "storybook": "10.5.4", "style-loader": "^4.0.0", "stylelint": "^17.14.1", "swc-loader": "^0.2.7", @@ -280,7 +280,7 @@ "typescript": "5.4.5", "unzipper": "^0.12.5", "wait-on": "^9.1.0", - "webpack": "^5.108.4", + "webpack": "^5.109.0", "webpack-bundle-analyzer": "^5.3.1", "webpack-cli": "^7.0.3", "webpack-dev-server": "^5.2.5", @@ -325,6 +325,7 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.1.tgz", "integrity": "sha512-12WGKBQzjUAI4ayyF4IAtfw2QR/IDoqk6jTddXDhtYTJF9ASmoE1zst7cVtP0aL/F1jUJL5r+JxKXKEgHNbEUQ==", + "dev": true, "license": "MIT" }, "node_modules/@ant-design/colors": { @@ -3115,20 +3116,6 @@ "react-dom": ">=16.3.0" } }, - "node_modules/@deck.gl/widgets": { - "version": "9.2.11", - "resolved": "https://registry.npmjs.org/@deck.gl/widgets/-/widgets-9.2.11.tgz", - "integrity": "sha512-90HWlQPsiRyTPWR4aYfLwnYDrJdHG2mqCzRcyMUKewWBNQLu4upB//l4ewIkUeXXCzAprjjVeRnNb7wdYj2CXQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "preact": "^10.17.0" - }, - "peerDependencies": { - "@deck.gl/core": "~9.2.0", - "@luma.gl/core": "~9.2.6" - } - }, "node_modules/@discoveryjs/json-ext": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.6.3.tgz", @@ -8565,9 +8552,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -8585,9 +8569,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -8605,9 +8586,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -8625,9 +8603,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -8645,9 +8620,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -8665,9 +8637,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -8685,9 +8654,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -8705,9 +8671,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -9841,85 +9804,6 @@ "integrity": "sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==", "license": "MIT" }, - "node_modules/@react-spring/animated": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-10.1.1.tgz", - "integrity": "sha512-kqdtIzr1GfBmbojnYhNdEhgu23f1rQ9H98K6KGmWHpNBP0gGZWN8KEIzGYwCAon7Q8ktGmsR1dCyy6QTI4egug==", - "license": "MIT", - "peer": true, - "dependencies": { - "@react-spring/shared": "~10.1.1", - "@react-spring/types": "~10.1.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/@react-spring/core": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-10.1.1.tgz", - "integrity": "sha512-eCxEJuJzNxbfFXEr+BuMqp+LYv0fdvIO/lk6B+A/aUDxD4M4EBKizPqhCkMz/B+U12d99KTAUSmWaGul/Lly+w==", - "license": "MIT", - "peer": true, - "dependencies": { - "@react-spring/animated": "~10.1.1", - "@react-spring/shared": "~10.1.1", - "@react-spring/types": "~10.1.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/react-spring/donate" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/@react-spring/rafz": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-10.1.1.tgz", - "integrity": "sha512-F7n2fU8EO8OokBkUHivU/tJ9HnL/QTIgd8V/2BhHFSrszt+OaLUoqEinomNeedLPIs24skiXQtki4APJHxghDQ==", - "license": "MIT", - "peer": true - }, - "node_modules/@react-spring/shared": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-10.1.1.tgz", - "integrity": "sha512-pOoQPoOa+EPWrRndqIYlIms71fNB1p0IQVJcCi5xHtErqL8N0+jS1V9pExHfryqn9aUhHv0+7kMiVrcb6U+IBQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@react-spring/rafz": "~10.1.1", - "@react-spring/types": "~10.1.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/@react-spring/types": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-10.1.1.tgz", - "integrity": "sha512-C35b9XigBnwv1fZsUGR/lWzAKBskGhdAmf0LIBvqhwwHTYMtat4DXHZEnFyJQvfCAmpVc/d9Yug7j2Lbv2GDEA==", - "license": "MIT", - "peer": true - }, - "node_modules/@react-spring/web": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@react-spring/web/-/web-10.1.1.tgz", - "integrity": "sha512-G8jVDH8YPAo9ALWio0YcpwvZjXIt6LFwSKZtS4XeTNWIMNUH+sTm2Uy8dxEobTqyzQ5kvKwnMfS3LsOpKrVOag==", - "license": "MIT", - "peer": true, - "dependencies": { - "@react-spring/animated": "~10.1.1", - "@react-spring/core": "~10.1.1", - "@react-spring/shared": "~10.1.1", - "@react-spring/types": "~10.1.1", - "csstype": "^3.2.3" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/@reduxjs/toolkit": { "version": "1.9.7", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-1.9.7.tgz", @@ -9961,9 +9845,9 @@ "license": "MIT" }, "node_modules/@rjsf/core": { - "version": "6.7.0", - "resolved": "https://registry.npmjs.org/@rjsf/core/-/core-6.7.0.tgz", - "integrity": "sha512-TdiiRf9H6R7mYXyRq1TQHhTSdX9lpvYHsfn1obWksICbZEg4jBFBh7BuyrcHhzGos6976GsLO4TTYw11fQUbpQ==", + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@rjsf/core/-/core-6.7.1.tgz", + "integrity": "sha512-/CQfIGUzcXceBNRhEH3wsTvxcT8dMrjPLXhYSfcJUTftKxOCdisqi2wFwt7LOyIvFvzHUxag0r0xXs/MXS9jmA==", "license": "Apache-2.0", "dependencies": { "lodash": "^4.18.1", @@ -9975,7 +9859,7 @@ "node": ">=20" }, "peerDependencies": { - "@rjsf/utils": "^6.7.0", + "@rjsf/utils": "^6.7.1", "react": ">=18" } }, @@ -10001,9 +9885,9 @@ } }, "node_modules/@rjsf/validator-ajv8": { - "version": "6.7.0", - "resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-6.7.0.tgz", - "integrity": "sha512-GAo1BknPXVncMwCsnAg/UpLPvdzVuyB73FbdPe5p3VjefrdVFjbbtaYMsFUN5iGMKe5fIQOZD9ke5ajvTZjJPA==", + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-6.7.1.tgz", + "integrity": "sha512-oG9reR8VgUUTxfsO8WybZWTjKs6SLUdhmUCp55SXmJvwVbeKZ+Mz4SI+y+T1Mdpbm1kLZWQPRyF2Md97soWXkw==", "license": "Apache-2.0", "dependencies": { "ajv": "^8.20.0", @@ -10015,7 +9899,7 @@ "node": ">=20" }, "peerDependencies": { - "@rjsf/utils": "^6.7.0" + "@rjsf/utils": "^6.7.1" } }, "node_modules/@rtsao/scc": { @@ -10327,16 +10211,16 @@ "license": "MIT" }, "node_modules/@storybook/addon-docs": { - "version": "10.5.3", - "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.3.tgz", - "integrity": "sha512-MI1VDMSMQk78YxjIdt7WlrVOiA3TzTP00lRed1LeXh0fCvA9jxz9YXJI2+XigsLaxCSuOAEf/l35/GTLDMHD8A==", + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.4.tgz", + "integrity": "sha512-2Z/x2pKEmXOCQjmttYzPuQBu9aWeMly8uEs3msrCTBLiHs/F7IlBFnMu0Z+T2Qvk0LEy8O93AlcPSP76aCcKjw==", "dev": true, "license": "MIT", "dependencies": { "@mdx-js/react": "^3.0.0", - "@storybook/csf-plugin": "10.5.3", + "@storybook/csf-plugin": "10.5.4", "@storybook/icons": "^2.0.2", - "@storybook/react-dom-shim": "10.5.3", + "@storybook/react-dom-shim": "10.5.4", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "ts-dedent": "^2.0.0" @@ -10347,7 +10231,7 @@ }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "10.5.3" + "storybook": "^10.5.4" }, "peerDependenciesMeta": { "@types/react": { @@ -10355,10 +10239,91 @@ } } }, - "node_modules/@storybook/addon-docs/node_modules/@storybook/csf-plugin": { - "version": "10.5.3", - "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.3.tgz", - "integrity": "sha512-mkPq6zru8fN5+46uC1cZEbKW2ws1hh9KvF4g4/Gu8pNbKnvqULPhk0/Bf0ZCtlr7zI7DvcFhyCy3dbvN+2n4Gw==", + "node_modules/@storybook/addon-links": { + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-10.5.4.tgz", + "integrity": "sha512-XH/DiL2XjZ8uR30gYGumNGrFCHWOOHXQQiWMTGf0JFrB7XhyNsN5zfRHN/nST9GMxXvwbZvFOdbty1SMx3jafA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "storybook": "^10.5.4" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@storybook/builder-webpack5": { + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-10.5.4.tgz", + "integrity": "sha512-6giShVJZss3uT2HQoBXQnZTguWTBAexylagVgx2BTw6hxDumrq2CUC7RBYPhsXSiF8lsKwaJJScgwxaUVNfz0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/core-webpack": "10.5.4", + "case-sensitive-paths-webpack-plugin": "^2.4.0", + "cjs-module-lexer": "^1.2.3", + "css-loader": "^7.1.2", + "es-module-lexer": "^1.5.0", + "fork-ts-checker-webpack-plugin": "^9.1.0", + "html-webpack-plugin": "^5.5.0", + "magic-string": "^0.30.5", + "semver": "^7.7.3", + "style-loader": "^4.0.0", + "terser-webpack-plugin": "^5.3.17", + "ts-dedent": "^2.0.0", + "webpack": "5", + "webpack-dev-middleware": "^6.1.2", + "webpack-hot-middleware": "^2.25.1", + "webpack-virtual-modules": "^0.6.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^10.5.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@storybook/core-webpack": { + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.4.tgz", + "integrity": "sha512-aCpPpltarwzB7ve4UEFwjGtvr6qBkpgLPjZRA9wTrxx8tyfUj7THaZUhgZyMOI7NsCVE2eP2lXRC1sSbFqOxIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^10.5.4" + } + }, + "node_modules/@storybook/csf-plugin": { + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.4.tgz", + "integrity": "sha512-DSp5Z/eZlRnKq0KrKLJE6uoYf/Ysc+FP0Z5DVTGnOrie+z3tC0lNi9I4RB++EXkJeUDS9/4dxvJZVSWjhLlXxw==", "dev": true, "license": "MIT", "dependencies": { @@ -10371,7 +10336,7 @@ "peerDependencies": { "esbuild": "*", "rollup": "*", - "storybook": "10.5.3", + "storybook": "^10.5.4", "vite": "*", "webpack": "*" }, @@ -10390,59 +10355,6 @@ } } }, - "node_modules/@storybook/addon-docs/node_modules/@storybook/react-dom-shim": { - "version": "10.5.3", - "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.3.tgz", - "integrity": "sha512-eUWBsRRax5R3MDJVFs/CrFDF1bYS58AMB9tX02lLRuiZe6xy1cKh3CRFS+2xH571l0fNaXQ+7j69TOJ0fk2tmA==", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "10.5.3" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@storybook/addon-links": { - "version": "10.5.3", - "resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-10.5.3.tgz", - "integrity": "sha512-awu6nBV/MRFv+zu9hIqFrqnKa37LnWfZ3/UHeS68il0QxWfy6uSU1dfpxqiYbCjs9Ct0+bsaO6ZaPamA+jlFLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/global": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "10.5.3" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "react": { - "optional": true - } - } - }, "node_modules/@storybook/global": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@storybook/global/-/global-5.0.0.tgz", @@ -10461,6 +10373,74 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@storybook/preset-react-webpack": { + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/@storybook/preset-react-webpack/-/preset-react-webpack-10.5.4.tgz", + "integrity": "sha512-STPKSpK5vpp9jJVnV125YfRElNXBiSkWXstmJe2jxmRYMA4f/d9m0XM4rZZV6kBqtEnIJY1qmzAMWSMibEzX6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/core-webpack": "10.5.4", + "@storybook/react-docgen-typescript-plugin": "1.0.6--canary.9.0c3f3b7.0", + "@types/semver": "^7.7.1", + "magic-string": "^0.30.5", + "react-docgen": "^8.0.2", + "resolve": "^1.22.8", + "semver": "^7.7.3", + "tsconfig-paths": "^4.2.0", + "webpack": "5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "storybook": "^10.5.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@storybook/react": { + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.4.tgz", + "integrity": "sha512-tOxfVgbYcaVsArN8XTDkJfdsnsnHh1LxjRHVpJ/N+VEkz4FveK/XH3jOLV0YqgrG8yXza7+CteDP4FfPVQY/mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "@storybook/react-dom-shim": "10.5.4", + "react-docgen": "^8.0.2", + "react-docgen-typescript": "^2.2.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "storybook": "^10.5.4", + "typescript": ">= 4.9.x" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/@storybook/react-docgen-typescript-plugin": { "version": "1.0.6--canary.9.0c3f3b7.0", "resolved": "https://registry.npmjs.org/@storybook/react-docgen-typescript-plugin/-/react-docgen-typescript-plugin-1.0.6--canary.9.0c3f3b7.0.tgz", @@ -10525,16 +10505,42 @@ "semver": "bin/semver.js" } }, + "node_modules/@storybook/react-dom-shim": { + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.4.tgz", + "integrity": "sha512-YdlppEOReg8MvTECRNuf79gu2zL83JqKDHIR/65eS0M6y+ue9pkpfjYo7hZVIcyOcRd9npBDXMdt2kC92bCuaA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "storybook": "^10.5.4" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@storybook/react-webpack5": { - "version": "10.5.3", - "resolved": "https://registry.npmjs.org/@storybook/react-webpack5/-/react-webpack5-10.5.3.tgz", - "integrity": "sha512-5uGVxywkT+/Bge4JwaReIj3TMKeceS6trkFwRc70RJnTWUQtlAbUyodI1OMQifRAs/1190+ptKBl0qOAbDyINw==", + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/@storybook/react-webpack5/-/react-webpack5-10.5.4.tgz", + "integrity": "sha512-nuK+yhTJXFsd3sEnv47TpcVwPUus5mq7KH8IMysvG/LR+ZUmoxe+5N4KnZtZoBxc8e9lENiiKAU0eKssggFRIQ==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/builder-webpack5": "10.5.3", - "@storybook/preset-react-webpack": "10.5.3", - "@storybook/react": "10.5.3" + "@storybook/builder-webpack5": "10.5.4", + "@storybook/preset-react-webpack": "10.5.4", + "@storybook/react": "10.5.4" }, "funding": { "type": "opencollective", @@ -10543,7 +10549,7 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "10.5.3", + "storybook": "^10.5.4", "typescript": ">= 4.9.x" }, "peerDependenciesMeta": { @@ -10552,171 +10558,6 @@ } } }, - "node_modules/@storybook/react-webpack5/node_modules/@storybook/builder-webpack5": { - "version": "10.5.3", - "resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-10.5.3.tgz", - "integrity": "sha512-IY8OlaOFRgsOCA4K+fBmTfEbVArvpqhjc/TYKC7Tq9hgY449stMmj89XNDeviL+ZXc7fL+S80EMiZnCPoF2Acw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/core-webpack": "10.5.3", - "case-sensitive-paths-webpack-plugin": "^2.4.0", - "cjs-module-lexer": "^1.2.3", - "css-loader": "^7.1.2", - "es-module-lexer": "^1.5.0", - "fork-ts-checker-webpack-plugin": "^9.1.0", - "html-webpack-plugin": "^5.5.0", - "magic-string": "^0.30.5", - "semver": "^7.7.3", - "style-loader": "^4.0.0", - "terser-webpack-plugin": "^5.3.17", - "ts-dedent": "^2.0.0", - "webpack": "5", - "webpack-dev-middleware": "^6.1.2", - "webpack-hot-middleware": "^2.25.1", - "webpack-virtual-modules": "^0.6.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "storybook": "10.5.3" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@storybook/react-webpack5/node_modules/@storybook/builder-webpack5/node_modules/@storybook/core-webpack": { - "version": "10.5.3", - "resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.3.tgz", - "integrity": "sha512-A7DIGq4XcOXkL7g4Dc/XyntlfStvYvBp+iIkblA5mVOJ/I0n7MxI0jgrLoG+thP5jA72HLdSpjedXa3GUQOPrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ts-dedent": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "storybook": "10.5.3" - } - }, - "node_modules/@storybook/react-webpack5/node_modules/@storybook/preset-react-webpack": { - "version": "10.5.3", - "resolved": "https://registry.npmjs.org/@storybook/preset-react-webpack/-/preset-react-webpack-10.5.3.tgz", - "integrity": "sha512-i93PdwMFAO0hqFhqdrNEhcwBnPb416zELssZTnxjjClJq4qRIGrGxch1NZ33IN4LGaF3fEY68byGuX6AHcHo6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/core-webpack": "10.5.3", - "@storybook/react-docgen-typescript-plugin": "1.0.6--canary.9.0c3f3b7.0", - "@types/semver": "^7.7.1", - "magic-string": "^0.30.5", - "react-docgen": "^8.0.2", - "resolve": "^1.22.8", - "semver": "^7.7.3", - "tsconfig-paths": "^4.2.0", - "webpack": "5" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "10.5.3" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@storybook/react-webpack5/node_modules/@storybook/preset-react-webpack/node_modules/@storybook/core-webpack": { - "version": "10.5.3", - "resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.3.tgz", - "integrity": "sha512-A7DIGq4XcOXkL7g4Dc/XyntlfStvYvBp+iIkblA5mVOJ/I0n7MxI0jgrLoG+thP5jA72HLdSpjedXa3GUQOPrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ts-dedent": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "storybook": "10.5.3" - } - }, - "node_modules/@storybook/react-webpack5/node_modules/@storybook/react": { - "version": "10.5.3", - "resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.3.tgz", - "integrity": "sha512-d/CK78xgA7DDvqnxkqcYmiTjomE4ch2TWvk0O8/xHQWW6y0nMjKtsZbmUBfZ0QcdYdWq7dErzfbG7YAzxDi7Ig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/global": "^5.0.0", - "@storybook/react-dom-shim": "10.5.3", - "react-docgen": "^8.0.2", - "react-docgen-typescript": "^2.2.2" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "10.5.3", - "typescript": ">= 4.9.x" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - }, - "typescript": { - "optional": true - } - } - }, - "node_modules/@storybook/react-webpack5/node_modules/@storybook/react/node_modules/@storybook/react-dom-shim": { - "version": "10.5.3", - "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.3.tgz", - "integrity": "sha512-eUWBsRRax5R3MDJVFs/CrFDF1bYS58AMB9tX02lLRuiZe6xy1cKh3CRFS+2xH571l0fNaXQ+7j69TOJ0fk2tmA==", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "10.5.3" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@storybook/test-runner": { "version": "0.24.4", "resolved": "https://registry.npmjs.org/@storybook/test-runner/-/test-runner-0.24.4.tgz", @@ -11115,7 +10956,7 @@ "version": "1.15.46", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.46.tgz", "integrity": "sha512-Ri3em2mBpq3h2zSPliCYl63otDGqek8PPEfv2nWgRQEbZ/VBCNyypVTVQ6cEbTCXBhy+WE2T3fQb08moIyuYaw==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -11159,6 +11000,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -11175,6 +11017,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -11191,6 +11034,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -11207,9 +11051,7 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -11226,9 +11068,7 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -11245,9 +11085,7 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -11264,9 +11102,7 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -11283,9 +11119,7 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -11302,9 +11136,7 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -11321,6 +11153,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -11337,6 +11170,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -11353,6 +11187,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -11366,7 +11201,7 @@ "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0" }, "node_modules/@swc/jest": { @@ -11411,35 +11246,37 @@ "version": "0.1.27", "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.27.tgz", "integrity": "sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@swc/counter": "^0.1.3" } }, "node_modules/@testing-library/dom": { - "version": "9.3.4", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz", - "integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==", + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", - "aria-query": "5.1.3", - "chalk": "^4.1.0", + "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", + "picocolors": "1.1.1", "pretty-format": "^27.0.2" }, "engines": { - "node": ">=14" + "node": ">=18" } }, "node_modules/@testing-library/jest-dom": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", - "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.0.tgz", + "integrity": "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==", + "dev": true, "license": "MIT", "dependencies": { "@adobe/css-tools": "^4.4.0", @@ -11450,39 +11287,51 @@ "redent": "^3.0.0" }, "engines": { - "node": ">=14", + "node": ">=22", "npm": ">=6", "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11" } }, "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, "license": "MIT" }, "node_modules/@testing-library/react": { - "version": "14.3.1", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.3.1.tgz", - "integrity": "sha512-H99XjUhWQw0lTgyMN05W3xQG1Nh4lq574D8keFf1dDoNTJgp66VbJozRaczoF+wsiaPJNt/TcnfpLGufGxSrZQ==", + "version": "15.0.7", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-15.0.7.tgz", + "integrity": "sha512-cg0RvEdD1TIhhkm1IeYMQxrzy0MtUNfa3minv4MjbgcYzJAZ7yD0i0lwoPOTPr+INtiXFezt2o8xMSnyHhEn2Q==", + "dev": true, "license": "MIT", "dependencies": { "@babel/runtime": "^7.12.5", - "@testing-library/dom": "^9.0.0", + "@testing-library/dom": "^10.0.0", "@types/react-dom": "^18.0.0" }, "engines": { - "node": ">=14" + "node": ">=18" }, "peerDependencies": { + "@types/react": "^18.0.0", "react": "^18.0.0", "react-dom": "^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, "node_modules/@testing-library/user-event": { "version": "12.8.3", "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-12.8.3.tgz", "integrity": "sha512-IR0iWbFkgd56Bu5ZI/ej8yQwrkCv8Qydx6RzwbKz9faXazR/+5tvYKsZQgyXJiwgpcva127YO6JcWy7YlCfofQ==", + "dev": true, "license": "MIT", "dependencies": { "@babel/runtime": "^7.12.5" @@ -11677,6 +11526,7 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, "license": "MIT" }, "node_modules/@types/babel__core": { @@ -11994,13 +11844,6 @@ "@types/estree": "*" } }, - "node_modules/@types/expect": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/@types/expect/-/expect-1.20.4.tgz", - "integrity": "sha512-Q5Vn3yjTDyCMV50TB6VRIbQNxSE4OmZR86VSbGaNpfUolm0iePBB4KdEEHmxoY5sT2+2DIvXW0rvMDP2nHZ4Mg==", - "license": "MIT", - "peer": true - }, "node_modules/@types/express": { "version": "4.17.25", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", @@ -12395,6 +12238,7 @@ "version": "18.3.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^18.0.0" @@ -12413,6 +12257,7 @@ "version": "5.5.11", "resolved": "https://registry.npmjs.org/@types/react-loadable/-/react-loadable-5.5.11.tgz", "integrity": "sha512-/tq2IJ853MoIFRBmqVOxnGsRRjER5TmEKzsZtaAkiXAWoDeKgR/QNOT1vd9k0p9h/F616X21cpNh3hu4RutzRQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/react": "*", @@ -12423,6 +12268,7 @@ "version": "4.41.40", "resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-4.41.40.tgz", "integrity": "sha512-u6kMFSBM9HcoTpUXnL6mt2HSzftqb3JgYV6oxIgL2dl6sX6aCa5k6SOkzv5DuZjBTPUE/dJltKtwwuqrkZHpfw==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -12437,6 +12283,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -12509,6 +12356,7 @@ "version": "1.8.8", "resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz", "integrity": "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==", + "dev": true, "license": "MIT", "dependencies": { "@types/react": "*" @@ -12569,9 +12417,9 @@ "license": "MIT" }, "node_modules/@types/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==", "dev": true, "license": "MIT" }, @@ -12622,6 +12470,7 @@ "version": "0.1.6", "resolved": "https://registry.npmjs.org/@types/source-list-map/-/source-list-map-0.1.6.tgz", "integrity": "sha512-5JcVt1u5HDmlXkwOD2nslZVllBBc7HDuOICfiZah2Z0is8M8g+ddAEawbmd3VjedfDHBzxCaXLs07QEmb7y54g==", + "dev": true, "license": "MIT" }, "node_modules/@types/stack-utils": { @@ -12644,12 +12493,14 @@ "version": "1.0.12", "resolved": "https://registry.npmjs.org/@types/tapable/-/tapable-1.0.12.tgz", "integrity": "sha512-bTHG8fcxEqv1M9+TD14P8ok8hjxoOCkfKc8XXLaaD05kI7ohpeI956jtDOD3XHKBQrlyPughUtzm1jtVhHpA5Q==", + "dev": true, "license": "MIT" }, "node_modules/@types/tinycolor2": { "version": "1.4.6", "resolved": "https://registry.npmjs.org/@types/tinycolor2/-/tinycolor2-1.4.6.tgz", "integrity": "sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==", + "dev": true, "license": "MIT" }, "node_modules/@types/tough-cookie": { @@ -12670,6 +12521,7 @@ "version": "3.17.5", "resolved": "https://registry.npmjs.org/@types/uglify-js/-/uglify-js-3.17.5.tgz", "integrity": "sha512-TU+fZFBTBcXj/GpDpDaBmgWk/gn96kMZ+uocaFUlV2f8a6WdMzzI44QBCmGcCiYR0Y6ZlNRiyUyKKt5nl/lbzQ==", + "dev": true, "license": "MIT", "dependencies": { "source-map": "^0.6.1" @@ -12679,6 +12531,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -12707,17 +12560,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/vinyl": { - "version": "2.0.12", - "resolved": "https://registry.npmjs.org/@types/vinyl/-/vinyl-2.0.12.tgz", - "integrity": "sha512-Sr2fYMBUVGYq8kj3UthXFAu5UN6ZW+rYr4NACjZQJvHvj+c8lYv0CahmZ2P/r7iUkN44gGUBwqxZkrKXYPb7cw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/expect": "^1.20.4", - "@types/node": "*" - } - }, "node_modules/@types/wait-on": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/@types/wait-on/-/wait-on-5.3.4.tgz", @@ -12732,6 +12574,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/@types/webpack-sources/-/webpack-sources-3.2.3.tgz", "integrity": "sha512-4nZOdMwSPHZ4pTEZzSp0AsTM4K7Qmu40UKW4tJDiOVs20UzYF9l+qUe4s0ftfN0pin06n+5cWWDJXH+sbhAiDw==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -12743,6 +12586,7 @@ "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">= 12" @@ -14141,29 +13985,6 @@ "node": "^16.13.0 || >=18.12.0" } }, - "node_modules/@yeoman/types": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@yeoman/types/-/types-1.11.1.tgz", - "integrity": "sha512-27CI5hHQAHfq8ohYILmLNzClbdzBJzu+ny9AzUVV6naJO0l4/+t+67QDKlwQvt+TW3oE5j74I/Mh4Kn14rsVXA==", - "license": "MIT", - "peer": true, - "engines": { - "node": "^16.13.0 || >=18.12.0" - }, - "peerDependencies": { - "@types/node": ">=16.18.26", - "@yeoman/adapter": "^1.6.0 || ^2.0.0-beta.0 || ^3.0.0 || ^4.0.0", - "mem-fs": "^3.0.0 || ^4.0.0-beta.1" - }, - "peerDependenciesMeta": { - "@yeoman/adapter": { - "optional": true - }, - "mem-fs": { - "optional": true - } - } - }, "node_modules/@zarrita/storage": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@zarrita/storage/-/storage-0.2.0.tgz", @@ -14454,6 +14275,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -14463,6 +14285,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -14578,6 +14401,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -14630,12 +14454,13 @@ } }, "node_modules/aria-query": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", - "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, "license": "Apache-2.0", "dependencies": { - "deep-equal": "^2.0.5" + "dequal": "^2.0.3" } }, "node_modules/arr-union": { @@ -16210,6 +16035,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -16856,15 +16682,15 @@ } }, "node_modules/concurrently": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.3.tgz", - "integrity": "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==", + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.4.tgz", + "integrity": "sha512-trZql+7l/0+WRAsAnEdctr4+iiOS6ZrViI6H8QWcCF9MFS/LT0dKpe8vluB1to6it+OxSI4VospFTIFMW8DJRw==", "dev": true, "license": "MIT", "dependencies": { "chalk": "5.6.2", "rxjs": "7.8.2", - "shell-quote": "1.8.4", + "shell-quote": "1.9.0", "supports-color": "10.2.2", "tree-kill": "1.2.2", "yargs": "18.0.0" @@ -17523,6 +17349,7 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, "license": "MIT" }, "node_modules/cssesc": { @@ -18205,38 +18032,6 @@ "node": ">=6" } }, - "node_modules/deep-equal": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", - "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "call-bind": "^1.0.5", - "es-get-iterator": "^1.1.3", - "get-intrinsic": "^1.2.2", - "is-arguments": "^1.1.1", - "is-array-buffer": "^3.0.2", - "is-date-object": "^1.0.5", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.2", - "isarray": "^2.0.5", - "object-is": "^1.1.5", - "object-keys": "^1.1.1", - "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.5.1", - "side-channel": "^1.0.4", - "which-boxed-primitive": "^1.0.2", - "which-collection": "^1.0.1", - "which-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -18647,6 +18442,7 @@ "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, "license": "MIT" }, "node_modules/dom-converter": { @@ -19045,9 +18841,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.22.2", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.2.tgz", - "integrity": "sha512-0rxICaFZ7NQho/sHely2bvOPRP0Eu2B0NZ9zM54YvRvWMn7jfz3DmnOZDR9LlXDdDcqntAVc6Hfy4gr/tdH/Ag==", + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", "dev": true, "license": "MIT", "dependencies": { @@ -19239,26 +19035,6 @@ "node": ">= 0.4" } }, - "node_modules/es-get-iterator": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", - "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.3", - "has-symbols": "^1.0.3", - "is-arguments": "^1.1.1", - "is-map": "^2.0.2", - "is-set": "^2.0.2", - "is-string": "^1.0.7", - "isarray": "^2.0.5", - "stop-iteration-iterator": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -19861,9 +19637,9 @@ } }, "node_modules/eslint-plugin-storybook": { - "version": "10.5.3", - "resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.5.3.tgz", - "integrity": "sha512-dreVGgQvTTOvPTrnn71uogs3E7skbODW3Ya1cHxupznzuIofLCzy+7zWJ4ObsEZ1MsOcofQlSaQuV9oJBszLoA==", + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.5.4.tgz", + "integrity": "sha512-IEG6GK9iMKH45WxiBUoDu3AajUyz+AcRXsLRkwnsNz9TwzZI6tbyHgdZ1Tkvs2RXOovYUYYksNpbtnmY746LAw==", "dev": true, "license": "MIT", "dependencies": { @@ -19872,7 +19648,7 @@ }, "peerDependencies": { "eslint": ">=8", - "storybook": "10.5.3" + "storybook": "^10.5.4" } }, "node_modules/eslint-plugin-testing-library": { @@ -20972,19 +20748,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/first-chunk-stream": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/first-chunk-stream/-/first-chunk-stream-5.0.0.tgz", - "integrity": "sha512-WdHo4ejd2cG2Dl+sLkW79SctU7mUQDfr4s1i26ffOZRs5mgv+BRttIM9gwcq0rDbemo0KlpVPaa3LBVLqPXzcQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/flat": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", @@ -21358,9 +21121,9 @@ } }, "node_modules/fs-extra": { - "version": "11.3.6", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.6.tgz", - "integrity": "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==", + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -22687,9 +22450,9 @@ "license": "MIT" }, "node_modules/google-auth-library": { - "version": "10.9.0", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.0.tgz", - "integrity": "sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg==", + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz", + "integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==", "license": "Apache-2.0", "dependencies": { "base64-js": "^1.3.0", @@ -22848,6 +22611,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -23400,9 +23164,9 @@ } }, "node_modules/html-webpack-plugin": { - "version": "5.6.7", - "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.7.tgz", - "integrity": "sha512-md+vXtdCAe60s1k6AU3dUyMJnDxUyQAwfwPKoLisvgUF1IXjtlLsk2se54+qfL9Mdm26bbwvjJybpNx48NKRLw==", + "version": "5.6.8", + "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.8.tgz", + "integrity": "sha512-MZmKQcTnhEh1SPSyMiEytIeDZDUoBZVorNHivQGXMASHf/BSGGOrKa2xQ5bGx3TCe1n109ecCt+cpww7wwWhKA==", "dev": true, "license": "MIT", "dependencies": { @@ -23420,7 +23184,7 @@ "url": "https://opencollective.com/html-webpack-plugin" }, "peerDependencies": { - "@rspack/core": "0.x || 1.x", + "@rspack/core": "0.x || 1.x || 2.x", "webpack": "^5.20.0" }, "peerDependenciesMeta": { @@ -23887,6 +23651,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -24056,22 +23821,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/is-arguments": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", - "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -24708,13 +24457,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-utf8": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz", - "integrity": "sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==", - "license": "MIT", - "peer": true - }, "node_modules/is-weakmap": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", @@ -28118,9 +27860,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -28142,9 +27881,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -28166,9 +27902,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -28190,9 +27923,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -28284,20 +28014,6 @@ "node": ">=8" } }, - "node_modules/loader-runner": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", - "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.11.5" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -28494,6 +28210,7 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, "license": "MIT", "bin": { "lz-string": "bin/bin.js" @@ -28619,9 +28336,9 @@ } }, "node_modules/mapbox-gl": { - "version": "3.26.0", - "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.26.0.tgz", - "integrity": "sha512-N+Y8VmvpD6xDeP0hAbegPZeEMY5TtnLcV0gEUBZ/KHq3s/yUKQm0PVG9JEkId0QFMMe5/W+FEjoyV4NI3B/N7A==", + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.27.0.tgz", + "integrity": "sha512-K8W9LTTjFEJsg9qsnJbKk+zbXrmSqa+nU1EiFXez5gQ0T0RMtylZUelgg1/RE6vCUMvHX0gaYfWU9g2mTWuA0g==", "license": "SEE LICENSE IN LICENSE.txt", "workspaces": [ "src/style-spec", @@ -29117,22 +28834,6 @@ "node": ">= 0.6" } }, - "node_modules/mem-fs": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/mem-fs/-/mem-fs-4.1.4.tgz", - "integrity": "sha512-NlRHmUiEcxDHI7FeDlrrTZP5YFvnoS74wEf5OrQ7NAg83B2Rv3oF+FWr961I0rVdxkKbZMjq2BcV7VFWGFPkog==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/node": ">=18", - "@types/vinyl": "^2.0.12", - "vinyl": "^3.0.1", - "vinyl-file": "^5.0.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/mem-fs-editor": { "version": "12.0.4", "resolved": "https://registry.npmjs.org/mem-fs-editor/-/mem-fs-editor-12.0.4.tgz", @@ -30174,6 +29875,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -30513,16 +30215,6 @@ "node": ">=0.10.0" } }, - "node_modules/moment": { - "version": "2.30.1", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", - "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", - "license": "MIT", - "peer": true, - "engines": { - "node": "*" - } - }, "node_modules/monaco-editor": { "version": "0.52.2", "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz", @@ -30700,15 +30392,6 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "license": "MIT" }, - "node_modules/ngeohash": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/ngeohash/-/ngeohash-0.6.3.tgz", - "integrity": "sha512-kltF0cOxgx1AbmVzKxYZaoB0aj7mOxZeHaerEtQV0YaqnkXNq26WWqMmJ6lTqShYxVRWZ/mwvvTrNeOwdslWiw==", - "license": "MIT", - "engines": { - "node": ">=v0.2.0" - } - }, "node_modules/no-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", @@ -31633,22 +31316,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/object-is": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", - "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", @@ -33034,19 +32701,6 @@ "node": ">= 0.8.0" } }, - "node_modules/polished": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz", - "integrity": "sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/runtime": "^7.17.8" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/possible-typed-array-names": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", @@ -33264,17 +32918,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/preact": { - "version": "10.29.1", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.1.tgz", - "integrity": "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==", - "license": "MIT", - "peer": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -33347,6 +32990,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1", @@ -33361,6 +33005,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -33373,6 +33018,7 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, "license": "MIT" }, "node_modules/pretty-ms": { @@ -34806,24 +34452,6 @@ "node": ">=0.10.0" } }, - "node_modules/react-ace": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/react-ace/-/react-ace-10.1.0.tgz", - "integrity": "sha512-VkvUjZNhdYTuKOKQpMIZi7uzZZVgzCjM7cLYu6F64V0mejY8a2XTyPUIMszC6A4trbeMIHbK5fYFcT/wkP/8VA==", - "license": "MIT", - "peer": true, - "dependencies": { - "ace-builds": "^1.4.14", - "diff-match-patch": "^1.0.5", - "lodash.get": "^4.4.2", - "lodash.isequal": "^4.5.0", - "prop-types": "^15.7.2" - }, - "peerDependencies": { - "react": "^0.13.0 || ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0", - "react-dom": "^0.13.0 || ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0" - } - }, "node_modules/react-arborist": { "version": "3.15.1", "resolved": "https://registry.npmjs.org/react-arborist/-/react-arborist-3.15.1.tgz", @@ -36045,6 +35673,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, "license": "MIT", "dependencies": { "indent-string": "^4.0.0", @@ -36058,6 +35687,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, "license": "MIT", "dependencies": { "min-indent": "^1.0.0" @@ -37327,9 +36957,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", - "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", + "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", "dev": true, "license": "MIT", "engines": { @@ -38315,9 +37945,9 @@ } }, "node_modules/storybook": { - "version": "10.5.3", - "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.3.tgz", - "integrity": "sha512-c8Wumu5qz0N2fnzWBxcPzUsY+8BpKBKChNyl4BEh9qhMV6KW587gL8il8emRB+4Hay+zMjDHA7cIeTkl4FKYuw==", + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.4.tgz", + "integrity": "sha512-bmLxPsxVSPnbeiZqYQpozyNOiJXfk+pf7WfHZflvPkwT6Y+rvYz3Cj/D6H4Kf2jHpuDNiMXBKO3yawLN2OWirg==", "dev": true, "license": "MIT", "dependencies": { @@ -38363,24 +37993,24 @@ } } }, - "node_modules/storybook/node_modules/@testing-library/dom": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "node_modules/storybook/node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "picocolors": "1.1.1", - "pretty-format": "^27.0.2" + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" }, "engines": { - "node": ">=18" + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" } }, "node_modules/storybook/node_modules/@testing-library/user-event": { @@ -38397,16 +38027,6 @@ "@testing-library/dom": ">=7.21.4" } }, - "node_modules/storybook/node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "dequal": "^2.0.3" - } - }, "node_modules/storybook/node_modules/define-lazy-prop": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", @@ -38420,6 +38040,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/storybook/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, "node_modules/storybook/node_modules/is-wsl": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", @@ -38683,39 +38310,6 @@ "node": ">=8" } }, - "node_modules/strip-bom-buf": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/strip-bom-buf/-/strip-bom-buf-3.0.1.tgz", - "integrity": "sha512-iJaWw2WroigLHzQysdc5WWeUc99p7ea7AEgB6JkY8CMyiO1yTVAA1gIlJJgORElUIR+lcZJkNl1OGChMhvc2Cw==", - "license": "MIT", - "peer": true, - "dependencies": { - "is-utf8": "^0.2.1" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/strip-bom-stream": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/strip-bom-stream/-/strip-bom-stream-5.0.0.tgz", - "integrity": "sha512-Yo472mU+3smhzqeKlIxClre4s4pwtYZEvDNQvY/sJpnChdaxmKuwU28UVx/v1ORKNMxkmj1GBuvxJQyBk6wYMQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "first-chunk-stream": "^5.0.0", - "strip-bom-buf": "^3.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/strip-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz", @@ -39173,6 +38767,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -40550,6 +40145,7 @@ "version": "5.4.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -41453,25 +41049,6 @@ "node": ">=10.13.0" } }, - "node_modules/vinyl-file": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/vinyl-file/-/vinyl-file-5.0.0.tgz", - "integrity": "sha512-MvkPF/yA1EX7c6p+juVIvp9+Lxp70YUfNKzEWeHMKpUNVSnTZh2coaOqLxI0pmOe2V9nB+OkgFaMDkodaJUyGw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/vinyl": "^2.0.7", - "strip-bom-buf": "^3.0.1", - "strip-bom-stream": "^5.0.0", - "vinyl": "^3.0.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/vlq": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/vlq/-/vlq-0.2.3.tgz", @@ -41703,9 +41280,9 @@ } }, "node_modules/webpack": { - "version": "5.108.4", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.108.4.tgz", - "integrity": "sha512-yur8LyJoeiWh47dErD+Ok7vlbmDsJ3UbbRPAoxbGJ54WpE2y5yVo5G/inUzujnYgw3tPmBRdn+G7PoxXaYC33w==", + "version": "5.109.2", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.109.2.tgz", + "integrity": "sha512-U9/cvLzxObKNEZ9+TtdqrHM5/9z3lgl2c+c4BzbqGxFQvQvBAq87yql5A8pQ+rrMbS496MZJeF5enVBndIy2hw==", "dev": true, "license": "MIT", "dependencies": { @@ -41715,22 +41292,20 @@ "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.16.0", - "acorn-import-phases": "^1.0.3", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.22.2", + "enhanced-resolve": "^5.24.4", "es-module-lexer": "^2.1.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "graceful-fs": "^4.2.11", - "loader-runner": "^4.3.2", "mime-db": "^1.54.0", "minimizer-webpack-plugin": "^5.6.1", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", "watchpack": "^2.5.2", - "webpack-sources": "^3.5.0" + "webpack-sources": "^3.5.1" }, "bin": { "webpack": "bin/webpack.js" @@ -42264,9 +41839,9 @@ } }, "node_modules/webpack/node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", "bin": { @@ -42276,23 +41851,10 @@ "node": ">=0.4.0" } }, - "node_modules/webpack/node_modules/acorn-import-phases": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", - "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.13.0" - }, - "peerDependencies": { - "acorn": "^8.14.0" - } - }, "node_modules/webpack/node_modules/es-module-lexer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", - "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", "dev": true, "license": "MIT" }, @@ -43506,14 +43068,14 @@ "version": "0.20.3", "license": "Apache-2.0", "dependencies": { - "chalk": "^5.6.2", + "chalk": "^6.0.0", "lodash-es": "^4.18.1", "yeoman-generator": "^8.2.2", "yosay": "^3.0.0" }, "devDependencies": { "cross-env": "^10.1.0", - "fs-extra": "^11.3.6", + "fs-extra": "^11.4.0", "jest": "^30.4.2", "yeoman-test": "^11.6.0" }, @@ -43523,12 +43085,12 @@ } }, "packages/generator-superset/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-6.0.0.tgz", + "integrity": "sha512-2uNTXIuTTxk7ciZgAU1BQcgnchcG0xXnrs6jzkQfj9SsRa9M2s5zE8WT96hS6KmG4MzWHSrvH43DF1m4XRkrFg==", "license": "MIT", "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" + "node": ">=22" }, "funding": { "url": "https://github.com/chalk/chalk?sponsor=1" @@ -43563,9 +43125,9 @@ "@babel/preset-react": "^7.29.7", "@babel/preset-typescript": "^7.29.7", "@emotion/styled": "^11.14.1", - "@testing-library/dom": "^9.3.4", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "*", - "@testing-library/react": "^14.0.0", + "@testing-library/react": "^15.0.0", "@testing-library/user-event": "*", "@types/lodash": "^4.17.24", "@types/react": "*", @@ -43605,9 +43167,9 @@ "@ant-design/icons": "^5.6.1 || ^6.0.0", "@emotion/react": "^11.4.1", "@superset-ui/core": "*", - "@testing-library/dom": "^9.3.4", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "*", - "@testing-library/react": "^14.0.0", + "@testing-library/react": "^15.0.0", "@testing-library/user-event": "*", "ace-builds": "^1.4.14", "brace": "^0.11.1", @@ -43690,9 +43252,9 @@ "@emotion/cache": "^11.4.0", "@emotion/react": "^11.4.1", "@emotion/styled": "^11.14.1", - "@testing-library/dom": "^9.3.4", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "*", - "@testing-library/react": "^14.0.0", + "@testing-library/react": "^15.0.0", "@testing-library/user-event": "*", "@types/react": "*", "@types/react-loadable": "*", @@ -43822,9 +43384,9 @@ "@apache-superset/core": "*", "@superset-ui/chart-controls": "*", "@superset-ui/core": "*", - "@testing-library/dom": "^9.3.4", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "*", - "@testing-library/react": "^14.0.0", + "@testing-library/react": "^15.0.0", "@testing-library/user-event": "*", "@types/react": "*", "react": "^18.3.0", @@ -44117,7 +43679,7 @@ "@superset-ui/chart-controls": "*", "@superset-ui/core": "*", "@testing-library/jest-dom": "*", - "@testing-library/react": "^14.0.0", + "@testing-library/react": "^15.0.0", "react": "^18.3.0", "react-dom": "^18.3.0" } @@ -44128,9 +43690,9 @@ "license": "Apache-2.0", "devDependencies": { "@babel/types": "^7.29.7", - "@testing-library/dom": "^9.3.4", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "*", - "@testing-library/react": "^14.0.0", + "@testing-library/react": "^15.0.0", "@testing-library/user-event": "*", "@types/jest": "^30.0.0", "jest": "^30.4.2" @@ -44152,7 +43714,7 @@ "license": "Apache-2.0", "dependencies": { "@math.gl/web-mercator": "^4.1.0", - "mapbox-gl": "^3.26.0", + "mapbox-gl": "^3.27.0", "maplibre-gl": "^5.24.0", "react-map-gl": "^8.1.1", "supercluster": "^8.0.1" @@ -44186,9 +43748,9 @@ "@apache-superset/core": "*", "@superset-ui/chart-controls": "*", "@superset-ui/core": "*", - "@testing-library/dom": "^9.3.4", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "*", - "@testing-library/react": "^14.0.0", + "@testing-library/react": "^15.0.0", "@testing-library/user-event": "*", "@types/react": "*", "match-sorter": "^8.2.0", @@ -44289,7 +43851,7 @@ "lodash-es": "^4.18.1", "maplibre-gl": "^5.24.0", "mousetrap": "^1.6.5", - "ngeohash": "^0.6.3", + "ngeohash": "^0.6.4", "prop-types": "^15.8.1", "react-map-gl": "^8.1.1", "tinycolor2": "^1.6.0", @@ -44341,6 +43903,15 @@ "engines": { "node": ">=12" } + }, + "plugins/preset-chart-deckgl/node_modules/ngeohash": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/ngeohash/-/ngeohash-0.6.4.tgz", + "integrity": "sha512-+dEKu+Be7xkK6gVQ2wZSGLbr+iWO+0ZGaLi98iE7F0/1WCSqQDRmS8LAlZBNLD2z/3zFKE9v9BvUfCrhyDhXbg==", + "license": "MIT", + "engines": { + "node": ">=v0.2.0" + } } } } diff --git a/superset-frontend/package.json b/superset-frontend/package.json index 65f86eced0f..b39309675e4 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -130,9 +130,9 @@ "@luma.gl/shadertools": "~9.2.5", "@luma.gl/webgl": "~9.2.5", "@reduxjs/toolkit": "^1.9.3", - "@rjsf/core": "^6.7.0", + "@rjsf/core": "^6.7.1", "@rjsf/utils": "^6.6.2", - "@rjsf/validator-ajv8": "^6.7.0", + "@rjsf/validator-ajv8": "^6.7.1", "@scarf/scarf": "^1.4.0", "@superset-ui/chart-controls": "file:./packages/superset-ui-chart-controls", "@superset-ui/core": "file:./packages/superset-ui-core", @@ -176,7 +176,7 @@ "dom-to-pdf": "^0.3.2", "echarts": "^6.1.0", "fast-glob": "^3.3.2", - "fs-extra": "^11.3.6", + "fs-extra": "^11.4.0", "fuse.js": "^7.5.0", "geolib": "^3.3.14", "geostyler": "^18.6.0", @@ -184,7 +184,7 @@ "geostyler-openlayers-parser": "^5.7.1", "geostyler-style": "11.0.2", "geostyler-wfs-parser": "^3.0.1", - "google-auth-library": "^10.9.0", + "google-auth-library": "^10.9.1", "immer": "^11.1.15", "interweave": "^13.1.1", "jquery": "^4.0.0", @@ -193,7 +193,7 @@ "json-stringify-pretty-compact": "^4.0.0", "lodash": "^4.18.1", "lodash-es": "^4.18.1", - "mapbox-gl": "^3.26.0", + "mapbox-gl": "^3.27.0", "markdown-to-jsx": "^9.9.0", "match-sorter": "^8.3.0", "memoize-one": "^6.0.0", @@ -265,17 +265,17 @@ "@istanbuljs/nyc-config-typescript": "^1.0.1", "@playwright/test": "^1.61.1", "@pmmmwh/react-refresh-webpack-plugin": "^0.6.2", - "@storybook/addon-docs": "10.5.3", - "@storybook/addon-links": "10.5.3", - "@storybook/react-webpack5": "10.5.3", + "@storybook/addon-docs": "10.5.4", + "@storybook/addon-links": "10.5.4", + "@storybook/react-webpack5": "10.5.4", "@storybook/test-runner": "0.24.4", "@svgr/webpack": "^8.1.0", "@swc/core": "^1.15.46", "@swc/plugin-emotion": "^14.15.0", "@swc/plugin-transform-imports": "^12.5.0", - "@testing-library/dom": "^9.3.4", - "@testing-library/jest-dom": "^6.9.1", - "@testing-library/react": "^14.0.0", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^7.0.0", + "@testing-library/react": "^15.0.0", "@testing-library/user-event": "^12.8.3", "@types/content-disposition": "^0.5.9", "@types/dom-to-image": "^2.6.7", @@ -306,7 +306,7 @@ "babel-plugin-jsx-remove-data-test-id": "^3.0.0", "baseline-browser-mapping": "^2.11.1", "cheerio": "1.2.0", - "concurrently": "^10.0.3", + "concurrently": "^10.0.4", "copy-webpack-plugin": "^14.0.0", "cross-env": "^10.1.0", "css-loader": "^7.1.4", @@ -323,13 +323,13 @@ "eslint-plugin-prettier": "^5.5.6", "eslint-plugin-react-prefer-function-component": "^5.0.0", "eslint-plugin-react-you-might-not-need-an-effect": "^1.0.1", - "eslint-plugin-storybook": "10.5.3", + "eslint-plugin-storybook": "10.5.4", "eslint-plugin-testing-library": "^7.16.2", "eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors", "fetch-mock": "^12.6.0", "fork-ts-checker-webpack-plugin": "^9.1.0", "history": "^5.3.0", - "html-webpack-plugin": "^5.6.7", + "html-webpack-plugin": "^5.6.8", "imports-loader": "^5.0.0", "jest": "^30.4.2", "jest-environment-jsdom": "^30.4.1", @@ -355,7 +355,7 @@ "source-map": "^0.8.0", "source-map-support": "^0.5.21", "speed-measure-webpack-plugin": "^1.6.0", - "storybook": "10.5.3", + "storybook": "10.5.4", "style-loader": "^4.0.0", "stylelint": "^17.14.1", "swc-loader": "^0.2.7", @@ -365,7 +365,7 @@ "typescript": "5.4.5", "unzipper": "^0.12.5", "wait-on": "^9.1.0", - "webpack": "^5.108.4", + "webpack": "^5.109.0", "webpack-bundle-analyzer": "^5.3.1", "webpack-cli": "^7.0.3", "webpack-dev-server": "^5.2.5", diff --git a/superset-frontend/packages/generator-superset/package.json b/superset-frontend/packages/generator-superset/package.json index 716b9a139ae..052e5b2b746 100644 --- a/superset-frontend/packages/generator-superset/package.json +++ b/superset-frontend/packages/generator-superset/package.json @@ -28,14 +28,14 @@ "test": "cross-env NODE_OPTIONS=--experimental-vm-modules jest" }, "dependencies": { - "chalk": "^5.6.2", + "chalk": "^6.0.0", "lodash-es": "^4.18.1", "yeoman-generator": "^8.2.2", "yosay": "^3.0.0" }, "devDependencies": { "cross-env": "^10.1.0", - "fs-extra": "^11.3.6", + "fs-extra": "^11.4.0", "jest": "^30.4.2", "yeoman-test": "^11.6.0" }, diff --git a/superset-frontend/packages/superset-core/package.json b/superset-frontend/packages/superset-core/package.json index 41da6db9b1b..fd1759a52da 100644 --- a/superset-frontend/packages/superset-core/package.json +++ b/superset-frontend/packages/superset-core/package.json @@ -93,9 +93,9 @@ "typescript": "^5.0.0", "@emotion/styled": "^11.14.1", "@types/lodash": "^4.17.24", - "@testing-library/dom": "^9.3.4", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "*", - "@testing-library/react": "^14.0.0", + "@testing-library/react": "^15.0.0", "@testing-library/user-event": "*", "@types/react": "*", "@types/react-loadable": "*", diff --git a/superset-frontend/packages/superset-ui-chart-controls/package.json b/superset-frontend/packages/superset-ui-chart-controls/package.json index 855281d9c6f..559adf3adef 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/package.json +++ b/superset-frontend/packages/superset-ui-chart-controls/package.json @@ -34,9 +34,9 @@ "@ant-design/icons": "^5.6.1 || ^6.0.0", "@emotion/react": "^11.4.1", "@superset-ui/core": "*", - "@testing-library/dom": "^9.3.4", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "*", - "@testing-library/react": "^14.0.0", + "@testing-library/react": "^15.0.0", "@testing-library/user-event": "*", "ace-builds": "^1.4.14", "brace": "^0.11.1", diff --git a/superset-frontend/packages/superset-ui-core/package.json b/superset-frontend/packages/superset-ui-core/package.json index b472607bb23..783b400351f 100644 --- a/superset-frontend/packages/superset-ui-core/package.json +++ b/superset-frontend/packages/superset-ui-core/package.json @@ -92,9 +92,9 @@ "@emotion/cache": "^11.4.0", "@emotion/react": "^11.4.1", "@emotion/styled": "^11.14.1", - "@testing-library/dom": "^9.3.4", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "*", - "@testing-library/react": "^14.0.0", + "@testing-library/react": "^15.0.0", "@testing-library/user-event": "*", "@types/react": "*", "@types/react-loadable": "*", diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/package.json b/superset-frontend/plugins/plugin-chart-ag-grid-table/package.json index 1424e87008e..b64160601b1 100644 --- a/superset-frontend/plugins/plugin-chart-ag-grid-table/package.json +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/package.json @@ -40,9 +40,9 @@ "@apache-superset/core": "*", "@superset-ui/chart-controls": "*", "@superset-ui/core": "*", - "@testing-library/dom": "^9.3.4", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "*", - "@testing-library/react": "^14.0.0", + "@testing-library/react": "^15.0.0", "@testing-library/user-event": "*", "@types/react": "*", "react": "^18.3.0", diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/controlPanel.tsx index 0826b9e1472..d5c1fd35e78 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/controlPanel.tsx @@ -17,12 +17,14 @@ * under the License. */ import { t } from '@apache-superset/core/translation'; +import { ensureIsArray } from '@superset-ui/core'; import { ControlPanelConfig, ControlSubSectionHeader, D3_TIME_FORMAT_DOCS, DEFAULT_TIME_FORMAT, formatSelectOptions, + getStandardizedControls, sharedControls, } from '@superset-ui/chart-controls'; import { showValueControl } from '../controls'; @@ -245,6 +247,17 @@ const config: ControlPanelConfig = { multi: false, }, }, + formDataOverrides: formData => ({ + ...formData, + metric: getStandardizedControls().shiftMetric(), + // Waterfall's `groupby` is a single-value control (multi: false; + // buildQuery groups by the whole array but transformProps only reads + // groupby[0]), so only one column should be taken off the queue here. + // Using popAllColumns() would let a memorized multi-column breakdown + // (e.g. from Table/Pivot) push extra dimensions into the SQL query + // that never surface in the rendered chart. + groupby: ensureIsArray(getStandardizedControls().shiftColumn()), + }), }; export default config; diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Waterfall/controlPanel.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Waterfall/controlPanel.test.ts new file mode 100644 index 00000000000..80590746c14 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Waterfall/controlPanel.test.ts @@ -0,0 +1,65 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { SqlaFormData } from '@superset-ui/core'; + +// Mock getStandardizedControls so we can assert the Waterfall control panel +// actually consumes (shifts) the queued metric and column instead of +// leaving them for the next viz-type switch to pick up again. Regression +// test for https://github.com/apache/superset/issues/32835, where switching +// away from and back to another chart type (e.g. Line) produced duplicate +// metrics because Waterfall never drained the shared standardized-controls +// queue. +const mockShiftMetric = jest.fn(() => 'shiftedMetric'); +const mockShiftColumn = jest.fn(() => 'shiftedColumn'); + +jest.mock('@superset-ui/chart-controls', () => { + const actual = jest.requireActual('@superset-ui/chart-controls'); + return { + ...actual, + getStandardizedControls: jest.fn(() => ({ + shiftMetric: mockShiftMetric, + shiftColumn: mockShiftColumn, + })), + }; +}); + +// eslint-disable-next-line import/first +import controlPanel from '../../src/Waterfall/controlPanel'; + +test('formDataOverrides consumes a single metric and a single column from getStandardizedControls', () => { + expect(controlPanel.formDataOverrides).toBeDefined(); + + const dummyFormData = { someProp: 'test' } as unknown as SqlaFormData; + const newFormData = controlPanel.formDataOverrides!(dummyFormData); + + // original properties are preserved + expect(newFormData.someProp).toBe('test'); + + // only a single metric is taken (Waterfall only supports one metric), + // leaving any remaining queued metrics for the next viz-type switch + expect(newFormData.metric).toBe('shiftedMetric'); + expect(mockShiftMetric).toHaveBeenCalled(); + + // only a single column is taken for the (single-value) groupby control, + // leaving any remaining queued columns for the next viz-type switch; + // popping the whole queue here would let buildQuery group by columns + // that transformProps (which only reads groupby[0]) never renders. + expect(newFormData.groupby).toEqual(['shiftedColumn']); + expect(mockShiftColumn).toHaveBeenCalled(); +}); diff --git a/superset-frontend/plugins/plugin-chart-partition/package.json b/superset-frontend/plugins/plugin-chart-partition/package.json index c1a5d12ac2c..1055a00176a 100644 --- a/superset-frontend/plugins/plugin-chart-partition/package.json +++ b/superset-frontend/plugins/plugin-chart-partition/package.json @@ -32,7 +32,7 @@ "@superset-ui/core": "*", "@apache-superset/core": "*", "@testing-library/jest-dom": "*", - "@testing-library/react": "^14.0.0", + "@testing-library/react": "^15.0.0", "react": "^18.3.0", "react-dom": "^18.3.0" }, diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/package.json b/superset-frontend/plugins/plugin-chart-pivot-table/package.json index 45a69a4cde5..37b53f6387f 100644 --- a/superset-frontend/plugins/plugin-chart-pivot-table/package.json +++ b/superset-frontend/plugins/plugin-chart-pivot-table/package.json @@ -38,9 +38,9 @@ }, "devDependencies": { "@babel/types": "^7.29.7", - "@testing-library/dom": "^9.3.4", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "*", - "@testing-library/react": "^14.0.0", + "@testing-library/react": "^15.0.0", "@testing-library/user-event": "*", "@types/jest": "^30.0.0", "jest": "^30.4.2" diff --git a/superset-frontend/plugins/plugin-chart-point-cluster-map/package.json b/superset-frontend/plugins/plugin-chart-point-cluster-map/package.json index 846fc752088..d7c297330a8 100644 --- a/superset-frontend/plugins/plugin-chart-point-cluster-map/package.json +++ b/superset-frontend/plugins/plugin-chart-point-cluster-map/package.json @@ -27,7 +27,7 @@ ], "dependencies": { "@math.gl/web-mercator": "^4.1.0", - "mapbox-gl": "^3.26.0", + "mapbox-gl": "^3.27.0", "maplibre-gl": "^5.24.0", "react-map-gl": "^8.1.1", "supercluster": "^8.0.1" diff --git a/superset-frontend/plugins/plugin-chart-table/package.json b/superset-frontend/plugins/plugin-chart-table/package.json index 031d1498f52..e192caaa64b 100644 --- a/superset-frontend/plugins/plugin-chart-table/package.json +++ b/superset-frontend/plugins/plugin-chart-table/package.json @@ -40,9 +40,9 @@ "@apache-superset/core": "*", "@superset-ui/chart-controls": "*", "@superset-ui/core": "*", - "@testing-library/dom": "^9.3.4", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "*", - "@testing-library/react": "^14.0.0", + "@testing-library/react": "^15.0.0", "@testing-library/user-event": "*", "@types/react": "*", "match-sorter": "^8.2.0", diff --git a/superset-frontend/plugins/preset-chart-deckgl/package.json b/superset-frontend/plugins/preset-chart-deckgl/package.json index 1915c4d04a6..ce2ec13b73b 100644 --- a/superset-frontend/plugins/preset-chart-deckgl/package.json +++ b/superset-frontend/plugins/preset-chart-deckgl/package.json @@ -47,7 +47,7 @@ "lodash": "^4.18.1", "maplibre-gl": "^5.24.0", "mousetrap": "^1.6.5", - "ngeohash": "^0.6.3", + "ngeohash": "^0.6.4", "prop-types": "^15.8.1", "react-map-gl": "^8.1.1", "tinycolor2": "^1.6.0", diff --git a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.test.tsx b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.test.tsx index 72637c71084..3bf8fb6b36b 100644 --- a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.test.tsx +++ b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.test.tsx @@ -216,6 +216,12 @@ test('should render the error', async () => { .spyOn(SupersetClient, 'post') .mockRejectedValue(new Error('Something went wrong')); await waitForRender(); + // The error is wrapped in an Alert component with a stable headline and the + // raw error text in the description — no more bare ``
`` elements.
+  expect(await screen.findByRole('alert')).toBeVisible();
+  expect(
+    await screen.findByText('Failed to load drill-to-detail rows'),
+  ).toBeVisible();
   expect(screen.getByText('Error: Something went wrong')).toBeInTheDocument();
 });
 
diff --git a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.tsx b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.tsx
index 98e3b46d9ad..d7378fc3904 100644
--- a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.tsx
+++ b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.tsx
@@ -42,6 +42,7 @@ import BooleanCell from '@superset-ui/core/components/Table/cell-renderers/Boole
 import NullCell from '@superset-ui/core/components/Table/cell-renderers/NullCell';
 import TimeCell from '@superset-ui/core/components/Table/cell-renderers/TimeCell';
 import { EmptyState, Loading } from '@superset-ui/core/components';
+import { Alert } from '@apache-superset/core/components';
 import { getDatasourceSamples } from 'src/components/Chart/chartAction';
 import Table, {
   ColumnsType,
@@ -362,13 +363,18 @@ export default function DrillDetailPane({
   if (responseError) {
     // Render error if page download failed
     tableContent = (
-      
-        {responseError}
-      
+ + ); } else if (bootstrapping) { // Render loading if first page hasn't loaded diff --git a/superset-frontend/src/components/Chart/useDrillDetailMenuItems/index.tsx b/superset-frontend/src/components/Chart/useDrillDetailMenuItems/index.tsx index 48aeef24dda..d99bc5358ff 100644 --- a/superset-frontend/src/components/Chart/useDrillDetailMenuItems/index.tsx +++ b/superset-frontend/src/components/Chart/useDrillDetailMenuItems/index.tsx @@ -50,6 +50,7 @@ const DISABLED_REASONS = { DATABASE: t( 'Drill to detail is disabled for this database. Change the database settings to enable it.', ), + DATASOURCE: t('Drill to detail is not available for this datasource type.'), NO_AGGREGATIONS: t( 'Drill to detail is disabled because this chart does not group data by dimension value.', ), @@ -116,6 +117,17 @@ export const useDrillDetailMenuItems = ({ datasources[formData.datasource]?.database?.disable_drill_to_detail, ); + // Capability flag on the datasource itself. Datasources that don't model + // raw rows (e.g. semantic views) opt out via ``supports_drill_to_detail`` + // in the explore data payload. + const datasourceSupportsDrillToDetail = useSelector< + RootState, + boolean | undefined + >( + ({ datasources }) => + datasources[formData.datasource]?.supports_drill_to_detail, + ); + const openModal = useCallback( (filters: BinaryQueryObjectFilterClause[], event: MouseEvent) => { onClick(event); @@ -158,7 +170,10 @@ export const useDrillDetailMenuItems = ({ let drillDisabled; let drillByDisabled; - if (drillToDetailDisabled) { + if (datasourceSupportsDrillToDetail === false) { + drillDisabled = DISABLED_REASONS.DATASOURCE; + drillByDisabled = DISABLED_REASONS.DATASOURCE; + } else if (drillToDetailDisabled) { drillDisabled = DISABLED_REASONS.DATABASE; drillByDisabled = DISABLED_REASONS.DATABASE; } else if (handlesDimensionContextMenu) { diff --git a/superset-frontend/src/components/Chart/useDrillDetailMenuItems/useDrillDetailMenuItems.test.tsx b/superset-frontend/src/components/Chart/useDrillDetailMenuItems/useDrillDetailMenuItems.test.tsx index 0e7e2a7c5df..8338d3e6bd3 100644 --- a/superset-frontend/src/components/Chart/useDrillDetailMenuItems/useDrillDetailMenuItems.test.tsx +++ b/superset-frontend/src/components/Chart/useDrillDetailMenuItems/useDrillDetailMenuItems.test.tsx @@ -444,3 +444,45 @@ test('context menu renders for null dimension values', async () => { await expectDrillToDetailByEnabled(); await expectDrillToDetailByDimension(filterNull); }); + +const buildStateWithUnsupportedDatasource = () => { + const baseState = getMockStoreWithNativeFilters().getState(); + const datasourceKey = defaultFormData.datasource as string; + return { + ...baseState, + datasources: { + ...baseState.datasources, + [datasourceKey]: { + ...baseState.datasources[datasourceKey], + supports_drill_to_detail: false, + }, + }, + }; +}; + +test('dropdown menu when datasource opts out via supports_drill_to_detail=false', async () => { + cleanup(); + render(, { + useRouter: true, + useRedux: true, + initialState: buildStateWithUnsupportedDatasource(), + }); + + await expectDrillToDetailDisabled( + 'Drill to detail is not available for this datasource type.', + ); + await expectNoDrillToDetailBy(); +}); + +test('context menu when datasource opts out via supports_drill_to_detail=false', async () => { + cleanup(); + render(, { + useRouter: true, + useRedux: true, + initialState: buildStateWithUnsupportedDatasource(), + }); + + const message = 'Drill to detail is not available for this datasource type.'; + await expectDrillToDetailDisabled(message); + await expectDrillToDetailByDisabled(message); +}); diff --git a/superset-frontend/src/components/ErrorMessage/ErrorMessageWithStackTrace.tsx b/superset-frontend/src/components/ErrorMessage/ErrorMessageWithStackTrace.tsx index 890374012a5..ad8b15c730b 100644 --- a/superset-frontend/src/components/ErrorMessage/ErrorMessageWithStackTrace.tsx +++ b/superset-frontend/src/components/ErrorMessage/ErrorMessageWithStackTrace.tsx @@ -62,6 +62,7 @@ export function ErrorMessageWithStackTrace({ fallback, compact, closable = true, + errorMitigationFunction, }: Props) { // Check if a custom error message component was registered for this message if (error) { @@ -77,6 +78,7 @@ export function ErrorMessageWithStackTrace({ error={error} source={source} subtitle={subtitle} + errorMitigationFunction={errorMitigationFunction} /> ); } diff --git a/superset-frontend/src/components/ErrorMessage/OAuth2RedirectMessage.test.tsx b/superset-frontend/src/components/ErrorMessage/OAuth2RedirectMessage.test.tsx index 37001745440..89726c8af9d 100644 --- a/superset-frontend/src/components/ErrorMessage/OAuth2RedirectMessage.test.tsx +++ b/superset-frontend/src/components/ErrorMessage/OAuth2RedirectMessage.test.tsx @@ -20,7 +20,7 @@ import * as reduxHooks from 'react-redux'; import { Provider } from 'react-redux'; import { createStore, Store } from 'redux'; -import { render, waitFor } from 'spec/helpers/testing-library'; +import { act, render, waitFor } from 'spec/helpers/testing-library'; import { ErrorLevel, ErrorSource, ErrorTypeEnum } from '@superset-ui/core'; import { reRunQuery } from 'src/SqlLab/actions/sqlLab'; import { triggerQuery } from 'src/components/Chart/chartAction'; @@ -166,15 +166,55 @@ describe('OAuth2RedirectMessage Component', () => { render(setup()); simulateBroadcastMessage({ tabId: 'tabId' }); + simulateStorageMessage({ tabId: 'tabId' }); + + await waitFor(() => { + expect(reRunQuery).toHaveBeenCalledWith({ sql: 'SELECT * FROM table' }); + }); + expect(reRunQuery).toHaveBeenCalledTimes(1); + }); + + test('dispatches reRunQuery action when storage event has matching tab ID', async () => { + render(setup()); + + simulateStorageMessage({ tabId: 'tabId' }); await waitFor(() => { expect(reRunQuery).toHaveBeenCalledWith({ sql: 'SELECT * FROM table' }); }); }); - test('dispatches reRunQuery action when storage event has matching tab ID', async () => { - render(setup()); + test('waits for the SQL Lab query before consuming the completion', async () => { + const initialState = { + sqlLab: { + queries: {}, + queryEditors: [{ id: 'editor-id', latestQueryId: 'query-id' }], + tabHistory: ['editor-id'], + }, + explore: { slice: null }, + charts: {}, + dashboardInfo: {}, + }; + const delayedQueryStore = createStore( + (state: typeof initialState = initialState, action) => + action.type === 'load-query' + ? { + ...state, + sqlLab: { + ...state.sqlLab, + queries: { 'query-id': { sql: 'SELECT * FROM table' } }, + }, + } + : state, + ); + render(setup({}, delayedQueryStore)); + simulateBroadcastMessage({ tabId: 'tabId' }); + expect(reRunQuery).not.toHaveBeenCalled(); + + act(() => { + delayedQueryStore.dispatch({ type: 'load-query' }); + }); simulateStorageMessage({ tabId: 'tabId' }); await waitFor(() => { @@ -234,4 +274,22 @@ describe('OAuth2RedirectMessage Component', () => { ]); }); }); + + test('runs scoped mitigation once instead of CRUD invalidation', async () => { + const errorMitigationFunction = jest.fn(); + render( + setup({ + source: 'crud' as ErrorSource, + errorMitigationFunction, + }), + ); + + simulateBroadcastMessage({ tabId: 'tabId' }); + simulateStorageMessage({ tabId: 'tabId' }); + + await waitFor(() => { + expect(errorMitigationFunction).toHaveBeenCalledTimes(1); + }); + expect(api.util.invalidateTags).not.toHaveBeenCalled(); + }); }); diff --git a/superset-frontend/src/components/ErrorMessage/OAuth2RedirectMessage.tsx b/superset-frontend/src/components/ErrorMessage/OAuth2RedirectMessage.tsx index 3383d7f3a41..5466c2adfe7 100644 --- a/superset-frontend/src/components/ErrorMessage/OAuth2RedirectMessage.tsx +++ b/superset-frontend/src/components/ErrorMessage/OAuth2RedirectMessage.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { QueryEditor, SqlLabRootState } from 'src/SqlLab/types'; @@ -58,15 +58,16 @@ interface OAuth2RedirectExtra { * * After the token has been stored, the opened tab will broadcast a message to the * original tab and close itself. This component, running on the original tab, listens - * on a same-origin BroadcastChannel and re-runs the query for the user once it - * receives the success message — be it in SQL Lab, Explore, or a dashboard. Both tabs - * share a "tab ID" (a UUID generated by the backend) which is echoed back through the - * channel so the original tab only reacts to its own OAuth2 flow. + * for same-origin BroadcastChannel and storage notifications and re-runs the query + * for the user once it receives the success message — be it in SQL Lab, Explore, or + * a dashboard. Both tabs share a "tab ID" (a UUID generated by the backend) which is + * echoed back so the original tab only reacts to its own OAuth2 flow. */ export function OAuth2RedirectMessage({ error, source, closable, + errorMitigationFunction, }: ErrorMessageComponentProps) { const { extra, level } = error; @@ -103,13 +104,17 @@ export function OAuth2RedirectMessage({ ); const dispatch = useDispatch(); + const lastHandledTabIdRef = useRef(); useEffect(() => { const handleOAuthComplete = (tabId?: string) => { - if (tabId !== extra.tab_id) { + if (tabId !== extra.tab_id || tabId === lastHandledTabIdRef.current) { return; } - if (source === 'sqllab' && query) { + + if (errorMitigationFunction) { + errorMitigationFunction(); + } else if (source === 'sqllab' && query) { dispatch(reRunQuery(query)); } else if (source === 'explore') { dispatch(triggerQuery(true, chartId)); @@ -123,7 +128,11 @@ export function OAuth2RedirectMessage({ 'Tables', ]), ); + } else { + return; } + + lastHandledTabIdRef.current = tabId; }; const channel = @@ -156,7 +165,16 @@ export function OAuth2RedirectMessage({ window.removeEventListener('storage', handleStorage); channel?.close(); }; - }, [source, extra.tab_id, dispatch, query, chartId, chartList, dashboardId]); + }, [ + source, + extra.tab_id, + dispatch, + query, + chartId, + chartList, + dashboardId, + errorMitigationFunction, + ]); const body = (

diff --git a/superset-frontend/src/components/ErrorMessage/types.ts b/superset-frontend/src/components/ErrorMessage/types.ts index c48b3dfae4c..fb9c6b1756b 100644 --- a/superset-frontend/src/components/ErrorMessage/types.ts +++ b/superset-frontend/src/components/ErrorMessage/types.ts @@ -27,6 +27,7 @@ export type ErrorMessageComponentProps | null> = subtitle?: ReactNode; compact?: boolean; closable?: boolean; + errorMitigationFunction?: () => void; }; export type ErrorMessageComponent = ComponentType; diff --git a/superset-frontend/src/dashboard/components/DeleteComponentButton.test.tsx b/superset-frontend/src/dashboard/components/DeleteComponentButton.test.tsx new file mode 100644 index 00000000000..519e31cdba7 --- /dev/null +++ b/superset-frontend/src/dashboard/components/DeleteComponentButton.test.tsx @@ -0,0 +1,38 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { render, screen, fireEvent } from 'spec/helpers/testing-library'; +import DeleteComponentButton from './DeleteComponentButton'; + +test('exposes an accessible name without rendering visible label text', () => { + render(); + + expect( + screen.getByRole('button', { name: 'Delete component' }), + ).toBeInTheDocument(); + expect(screen.queryByText('Delete component')).not.toBeInTheDocument(); +}); + +test('calls onDelete when clicked', () => { + const onDelete = jest.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Delete component' })); + + expect(onDelete).toHaveBeenCalledTimes(1); +}); diff --git a/superset-frontend/src/dashboard/components/DeleteComponentButton.tsx b/superset-frontend/src/dashboard/components/DeleteComponentButton.tsx index 37c24a180f3..65a440b10d3 100644 --- a/superset-frontend/src/dashboard/components/DeleteComponentButton.tsx +++ b/superset-frontend/src/dashboard/components/DeleteComponentButton.tsx @@ -18,6 +18,7 @@ */ import { MouseEventHandler, FC } from 'react'; +import { t } from '@apache-superset/core/translation'; import { Icons } from '@superset-ui/core/components/Icons'; import type { IconType } from '@superset-ui/core/components/Icons/types'; import IconButton from './IconButton'; @@ -33,6 +34,8 @@ const DeleteComponentButton: FC = ({ }) => ( } /> ); diff --git a/superset-frontend/src/dashboard/components/IconButton.test.tsx b/superset-frontend/src/dashboard/components/IconButton.test.tsx index 289cafd4a74..a021e63a275 100644 --- a/superset-frontend/src/dashboard/components/IconButton.test.tsx +++ b/superset-frontend/src/dashboard/components/IconButton.test.tsx @@ -73,3 +73,17 @@ test('renders the provided label', () => { expect(screen.getByText('My Label')).toBeInTheDocument(); }); + +test('hideVisibleLabel suppresses visible text but keeps the accessible name', () => { + render( + , + ); + + expect(screen.queryByText('My Label')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'My Label' })).toBeInTheDocument(); +}); diff --git a/superset-frontend/src/dashboard/components/IconButton.tsx b/superset-frontend/src/dashboard/components/IconButton.tsx index 3e48730cff1..8e5b842508d 100644 --- a/superset-frontend/src/dashboard/components/IconButton.tsx +++ b/superset-frontend/src/dashboard/components/IconButton.tsx @@ -22,6 +22,7 @@ import { styled, SupersetTheme } from '@apache-superset/core/theme'; interface IconButtonProps extends HTMLAttributes { icon: JSX.Element; label?: string; + hideVisibleLabel?: boolean; onClick: MouseEventHandler; disabled?: boolean; 'data-test'?: string; @@ -63,6 +64,7 @@ const IconButton = forwardRef( { icon, label, + hideVisibleLabel, onClick, onKeyDown, disabled, @@ -75,6 +77,7 @@ const IconButton = forwardRef( {...rest} ref={ref} type="button" + aria-label={label} isDisabled={disabled} aria-disabled={disabled} data-test={dataTest} @@ -91,7 +94,7 @@ const IconButton = forwardRef( }} > {icon} - {label && {label}} + {label && !hideVisibleLabel && {label}} ), ); diff --git a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.test.tsx b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.test.tsx index cff80c41c29..ab6b36ef67c 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.test.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.test.tsx @@ -566,3 +566,31 @@ test('should pass filterState from dataMask to ChartContainer', () => { mockFilterState, ); }); + +test('should pass chartStackTrace to ChartContainer so dashboard chart errors stay expandable', () => { + // Regression guard for #31858: the dashboard chart wrapper stopped forwarding + // the stack trace, so failed charts rendered a flat error with no "See more" + // affordance while the same error in Explore stayed expandable. + const stackTrace = 'Traceback (most recent call last): ValueError: boom'; + + setup( + {}, + { + ...defaultState, + charts: { + ...defaultState.charts, + [queryId]: { + ...defaultState.charts[queryId], + chartStatus: 'failed', + chartAlert: 'Something went wrong', + chartStackTrace: stackTrace, + }, + }, + }, + ); + + expect(capturedChartContainerProps).toHaveProperty( + 'chartStackTrace', + stackTrace, + ); +}); diff --git a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx index 122e29ffce3..516c02f4b7c 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx @@ -789,6 +789,7 @@ const Chart = (props: ChartProps) => { chartAlert={chart.chartAlert ?? undefined} chartId={props.id} chartStatus={chartStatus ?? undefined} + chartStackTrace={chart.chartStackTrace ?? undefined} datasource={datasource} dashboardId={props.dashboardId} initialValues={EMPTY_OBJECT} diff --git a/superset-frontend/src/dashboard/components/gridComponents/Column/Column.test.tsx b/superset-frontend/src/dashboard/components/gridComponents/Column/Column.test.tsx index c402942fe63..278548d213c 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Column/Column.test.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Column/Column.test.tsx @@ -17,7 +17,7 @@ * under the License. */ import React from 'react'; -import { fireEvent, render } from 'spec/helpers/testing-library'; +import { fireEvent, render, screen } from 'spec/helpers/testing-library'; import BackgroundStyleDropdown from 'src/dashboard/components/menu/BackgroundStyleDropdown'; import IconButton from 'src/dashboard/components/IconButton'; @@ -200,6 +200,15 @@ test('should call deleteComponent when deleted', () => { expect(deleteComponent).toHaveBeenCalledTimes(1); }); +test('settings IconButton exposes an accessible name without visible label text', () => { + setup({ component: columnWithoutChildren, editMode: true }); + + expect( + screen.getByRole('button', { name: 'Column settings' }), + ).toBeInTheDocument(); + expect(screen.queryByText('Column settings')).not.toBeInTheDocument(); +}); + test('should pass its own width as availableColumnCount to children', () => { const { getByTestId } = setup(); expect(getByTestId('mock-dashboard-component')).toHaveTextContent( diff --git a/superset-frontend/src/dashboard/components/gridComponents/Column/Column.tsx b/superset-frontend/src/dashboard/components/gridComponents/Column/Column.tsx index 72be4b59a67..edb36d1da85 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Column/Column.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Column/Column.tsx @@ -247,6 +247,8 @@ const Column = (props: ColumnProps) => { /> handleChangeFocus(true)} + label={t('Column settings')} + hideVisibleLabel icon={} /> diff --git a/superset-frontend/src/dashboard/components/gridComponents/Header/Header.test.tsx b/superset-frontend/src/dashboard/components/gridComponents/Header/Header.test.tsx index 5c13f038f03..f51d74d14c0 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Header/Header.test.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Header/Header.test.tsx @@ -145,7 +145,9 @@ describe('Header', () => { const deleteComponent = jest.fn(); setup({ editMode: true, deleteComponent }); - const trashButton = screen.getByRole('button', { name: 'delete' }); + const trashButton = screen.getByRole('button', { + name: 'Delete component', + }); fireEvent.click(trashButton); expect(deleteComponent).toHaveBeenCalledTimes(1); diff --git a/superset-frontend/src/dashboard/components/gridComponents/Row/Row.test.tsx b/superset-frontend/src/dashboard/components/gridComponents/Row/Row.test.tsx index 69635012d20..39b2b718ec6 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Row/Row.test.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Row/Row.test.tsx @@ -240,6 +240,15 @@ test('should call deleteComponent when deleted', () => { expect(deleteComponent).toHaveBeenCalledTimes(1); }); +test('settings IconButton exposes an accessible name without visible label text', () => { + setup({ component: rowWithoutChildren, editMode: true }); + + expect( + screen.getByRole('button', { name: 'Row settings' }), + ).toBeInTheDocument(); + expect(screen.queryByText('Row settings')).not.toBeInTheDocument(); +}); + test('should pass appropriate availableColumnCount to children', () => { const { getByTestId } = setup(); expect(getByTestId('mock-dashboard-component')).toHaveTextContent( diff --git a/superset-frontend/src/dashboard/components/gridComponents/Row/Row.tsx b/superset-frontend/src/dashboard/components/gridComponents/Row/Row.tsx index c5ec0c497f5..b644a65fef8 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Row/Row.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Row/Row.tsx @@ -293,6 +293,8 @@ const Row = memo((props: RowProps) => { handleChangeFocus(true)} + label={t('Row settings')} + hideVisibleLabel icon={} /> diff --git a/superset-frontend/src/dashboard/types.ts b/superset-frontend/src/dashboard/types.ts index 67b7704f8ca..6889d265228 100644 --- a/superset-frontend/src/dashboard/types.ts +++ b/superset-frontend/src/dashboard/types.ts @@ -232,6 +232,10 @@ export type Datasource = Dataset & { // Populated by the dashboard datasets API alongside ``type``; declared here // so callers can rely on structural typing instead of casting. datasource_type?: DatasourceType; + /** False when the datasource can't return row samples (e.g. semantic views). */ + supports_samples?: boolean; + /** False when the datasource can't answer drill-to-detail requests. */ + supports_drill_to_detail?: boolean; }; export type DatasourcesState = { [key: string]: Datasource; diff --git a/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.tsx b/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.tsx index 4d7cb5c28a7..cb8878e3bf9 100644 --- a/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.tsx +++ b/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.tsx @@ -241,25 +241,44 @@ export const DataTablesPane = ({ } }, [resultsTabFallback]); + // Hide the Samples tab for datasources that don't expose raw rows + // (e.g. semantic views). The check is intentionally ``=== false`` so that + // datasources from older backends that don't send the flag still show the + // tab and preserve current behavior. + const showSamplesTab = datasource?.supports_samples !== false; + + // If the datasource swaps to one that doesn't support samples while the + // Samples tab is active (e.g. the user picks a semantic view), the tab + // disappears from ``tabItems`` and ``activeTabKey`` is orphaned. Fall back + // to Results so the panel keeps rendering content. + useEffect(() => { + if (!showSamplesTab && activeTabKey === ResultTypes.Samples) { + setActiveTabKey(ResultTypes.Results); + } + }, [showSamplesTab, activeTabKey]); const tabItems = [ ...queryResultsPanes, - { - key: ResultTypes.Samples, - label: t('Samples'), - children: ( - - - - ), - }, + ...(showSamplesTab + ? [ + { + key: ResultTypes.Samples, + label: t('Samples'), + children: ( + + + + ), + }, + ] + : []), ]; return ( diff --git a/superset-frontend/src/explore/components/DataTablesPane/components/SamplesPane.tsx b/superset-frontend/src/explore/components/DataTablesPane/components/SamplesPane.tsx index 9e2fa986164..3aa8a16fa71 100644 --- a/superset-frontend/src/explore/components/DataTablesPane/components/SamplesPane.tsx +++ b/superset-frontend/src/explore/components/DataTablesPane/components/SamplesPane.tsx @@ -21,6 +21,7 @@ import { t } from '@apache-superset/core/translation'; import { ensureIsArray } from '@superset-ui/core'; import { datasetLabelLower } from 'src/features/semanticLayers/label'; import { styled } from '@apache-superset/core/theme'; +import { Alert } from '@apache-superset/core/components'; import { EmptyState, Loading } from '@superset-ui/core/components'; import { GenericDataType } from '@apache-superset/core/common'; import { GridTable } from 'src/components/GridTable'; @@ -35,7 +36,7 @@ import { import { TableControls, ROW_LIMIT_OPTIONS } from './DataTableControls'; import { SamplesPaneProps } from '../types'; -const Error = styled.pre` +const ErrorAlertWrapper = styled.div` margin-top: ${({ theme }) => `${theme.sizeUnit * 4}px`}; `; @@ -155,7 +156,14 @@ export const SamplesPane = ({ rowLimitOptions={ROW_LIMIT_OPTIONS} onRowLimitChange={handleRowLimitChange} /> - {responseError} + + + ); } diff --git a/superset-frontend/src/explore/components/DataTablesPane/components/useResultsPane.tsx b/superset-frontend/src/explore/components/DataTablesPane/components/useResultsPane.tsx index 4dcf1b5324a..ede48d3bf25 100644 --- a/superset-frontend/src/explore/components/DataTablesPane/components/useResultsPane.tsx +++ b/superset-frontend/src/explore/components/DataTablesPane/components/useResultsPane.tsx @@ -27,13 +27,14 @@ import { QueryData, } from '@superset-ui/core'; import { styled } from '@apache-superset/core/theme'; +import { Alert } from '@apache-superset/core/components'; import { EmptyState, Loading } from '@superset-ui/core/components'; import { getChartDataRequest } from 'src/components/Chart/chartAction'; import { ResultsPaneProps, QueryResultInterface } from '../types'; import { SingleQueryResultPane } from './SingleQueryResultPane'; import { TableControls, ROW_LIMIT_OPTIONS } from './DataTableControls'; -const Error = styled.pre` +const ErrorAlertWrapper = styled.div` margin-top: ${({ theme }) => `${theme.sizeUnit * 4}px`}; `; @@ -199,7 +200,14 @@ export const useResultsPane = ({ isLoading={false} canDownload={canDownload} /> - {responseError} + + + ); return Array(queryCount).fill(err); diff --git a/superset-frontend/src/explore/components/DataTablesPane/test/DataTablesPane.test.tsx b/superset-frontend/src/explore/components/DataTablesPane/test/DataTablesPane.test.tsx index 024602f2126..65b3b8423c2 100644 --- a/superset-frontend/src/explore/components/DataTablesPane/test/DataTablesPane.test.tsx +++ b/superset-frontend/src/explore/components/DataTablesPane/test/DataTablesPane.test.tsx @@ -19,7 +19,12 @@ import fetchMock from 'fetch-mock'; import { FeatureFlag } from '@superset-ui/core'; import * as copyUtils from 'src/utils/copy'; -import { render, screen, userEvent } from 'spec/helpers/testing-library'; +import { + render, + screen, + userEvent, + waitFor, +} from 'spec/helpers/testing-library'; import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact'; import { setItem, LocalStorageKeys } from 'src/utils/localStorageHelpers'; import { DataTablesPane } from '..'; @@ -89,6 +94,48 @@ describe('DataTablesPane', () => { expect(await screen.findByLabelText('Collapse data panel')).toBeVisible(); }); + test('Hides Samples tab when datasource opts out via supports_samples=false', async () => { + const props = createDataTablesPaneProps(0); + const propsWithoutSamples = { + ...props, + datasource: { ...props.datasource, supports_samples: false }, + }; + render(, { useRedux: true }); + expect(await screen.findByText('Results')).toBeVisible(); + expect(screen.queryByText('Samples')).not.toBeInTheDocument(); + }); + + test('Falls back to Results when active Samples tab disappears mid-session', async () => { + // Regression for codeant Major finding on PR #41509: a datasource swap + // that hides the Samples tab while it was the active tab used to leave + // ``activeTabKey === 'samples'`` orphaned, rendering a blank panel. + const props = createDataTablesPaneProps(0); + const { rerender } = render(, { + useRedux: true, + }); + + // Open the panel and pick the Samples tab. + userEvent.click(screen.getByLabelText('Expand data panel')); + userEvent.click(await screen.findByText('Samples')); + expect(await screen.findByLabelText('Collapse data panel')).toBeVisible(); + + // Swap to a datasource that doesn't support samples (e.g. a semantic + // view). The Samples tab should disappear and the panel should land on + // Results with content still rendered. + rerender( + , + ); + await waitFor(() => { + expect(screen.queryByText('Samples')).not.toBeInTheDocument(); + }); + expect(screen.getByText('Results')).toBeVisible(); + // Panel stays expanded and renders Results content rather than going blank. + expect(screen.getByLabelText('Collapse data panel')).toBeVisible(); + }); + test('Should copy data table content correctly', async () => { fetchMock.post( 'glob:*/api/v1/chart/data?form_data=%7B%22slice_id%22%3A456%7D', diff --git a/superset-frontend/src/explore/components/DataTablesPane/test/SamplesPane.test.tsx b/superset-frontend/src/explore/components/DataTablesPane/test/SamplesPane.test.tsx index 26cff940c26..02b5fed7290 100644 --- a/superset-frontend/src/explore/components/DataTablesPane/test/SamplesPane.test.tsx +++ b/superset-frontend/src/explore/components/DataTablesPane/test/SamplesPane.test.tsx @@ -84,10 +84,14 @@ describe('SamplesPane', () => { const props = createSamplesPaneProps({ datasourceId: 36, }); - const { findByText } = render(, { + const { findByText, findByRole } = render(, { useRedux: true, }); + // The error is now rendered inside an Alert component, with a clear + // headline message and the raw error text as the description. + expect(await findByRole('alert')).toBeVisible(); + expect(await findByText('Failed to load samples')).toBeVisible(); expect(await findByText('Error: Bad request')).toBeVisible(); }); diff --git a/superset-frontend/src/explore/components/controls/DateFilterControl/components/CurrentCalendarFrame.tsx b/superset-frontend/src/explore/components/controls/DateFilterControl/components/CurrentCalendarFrame.tsx index effc52860f9..25fe2fcc723 100644 --- a/superset-frontend/src/explore/components/controls/DateFilterControl/components/CurrentCalendarFrame.tsx +++ b/superset-frontend/src/explore/components/controls/DateFilterControl/components/CurrentCalendarFrame.tsx @@ -49,6 +49,7 @@ export function CurrentCalendarFrame({ onChange, value }: FrameComponentProps) { wrap: true, }} size="large" + value={value} onChange={(e: any) => { let newValue = e.target.value; newValue = newValue.trim(); diff --git a/superset-frontend/src/explore/components/controls/DateFilterControl/tests/CurrentCalendarFrame.test.tsx b/superset-frontend/src/explore/components/controls/DateFilterControl/tests/CurrentCalendarFrame.test.tsx index 7038879269b..df6c140a105 100644 --- a/superset-frontend/src/explore/components/controls/DateFilterControl/tests/CurrentCalendarFrame.test.tsx +++ b/superset-frontend/src/explore/components/controls/DateFilterControl/tests/CurrentCalendarFrame.test.tsx @@ -16,9 +16,9 @@ * specific language governing permissions and limitations * under the License. */ -import { render } from 'spec/helpers/testing-library'; +import { render, screen } from 'spec/helpers/testing-library'; import { CurrentCalendarFrame } from '../components/CurrentCalendarFrame'; -import { CurrentWeek } from '../types'; +import { CurrentDay, CurrentWeek } from '../types'; const mockOnChange = jest.fn(); @@ -33,3 +33,9 @@ test('returns null if value is not a valid CurrentRangeType', () => { ); expect(container.childNodes.length).toBe(0); }); + +test('selects the radio button matching the current value', () => { + render(); + + expect(screen.getByRole('radio', { name: CurrentDay })).toBeChecked(); +}); diff --git a/superset-frontend/src/explore/types.ts b/superset-frontend/src/explore/types.ts index cb0ce75c80c..a9c499adcf6 100644 --- a/superset-frontend/src/explore/types.ts +++ b/superset-frontend/src/explore/types.ts @@ -74,6 +74,18 @@ export type Datasource = Dataset & { schema?: string; is_sqllab_view?: boolean; extra?: string | object; + /** + * False when the datasource (e.g. a semantic view) doesn't model raw rows + * and therefore can't return a row sample. Defaults to true on the server + * side; missing here means the explore UI keeps current behavior. + */ + supports_samples?: boolean; + /** + * False when the datasource doesn't model raw rows and therefore can't + * answer a drill-to-detail query. Tracked separately from + * ``supports_samples`` so the two capabilities can diverge. + */ + supports_drill_to_detail?: boolean; }; export interface ExplorePageInitialData { diff --git a/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanel.stories.tsx b/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanel.stories.tsx index 6da60e561b9..581ce2195a1 100644 --- a/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanel.stories.tsx +++ b/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanel.stories.tsx @@ -35,6 +35,5 @@ export const Basic: StoryFn = args => ( Basic.args = { tableName: 'example_table', loading: false, - hasError: false, columnList: exampleColumns, }; diff --git a/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanel.subdirectory.test.tsx b/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanel.subdirectory.test.tsx index cf5b9d816cd..04158c05755 100644 --- a/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanel.subdirectory.test.tsx +++ b/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanel.subdirectory.test.tsx @@ -77,7 +77,6 @@ test('View Dataset opens a single-prefixed URL under a subdirectory deployment', render( { test('renders a blank state DatasetPanel', () => { - render(, { + render(, { useRouter: true, }); @@ -70,17 +70,9 @@ describe('DatasetPanel', () => { }); test('renders a no columns screen', () => { - render( - , - { - useRouter: true, - }, - ); + render(, { + useRouter: true, + }); const blankDatasetImg = screen.getByRole('img', { name: /empty/i }); expect(blankDatasetImg).toBeVisible(); @@ -91,17 +83,9 @@ describe('DatasetPanel', () => { }); test('renders a loading screen', () => { - render( - , - { - useRouter: true, - }, - ); + render(, { + useRouter: true, + }); const loadingIndicator = screen.getByTestId('loading-indicator'); expect(loadingIndicator).toBeVisible(); @@ -113,7 +97,12 @@ describe('DatasetPanel', () => { render( , @@ -124,8 +113,9 @@ describe('DatasetPanel', () => { const errorTitle = screen.getByText(ERROR_TITLE); expect(errorTitle).toBeVisible(); - const errorDescription = screen.getByText(ERROR_DESCRIPTION); + const errorDescription = screen.getByText('Structured backend failure'); expect(errorDescription).toBeVisible(); + expect(screen.getByTitle('Name')).toHaveStyle({ position: 'relative' }); }); test('renders a table with columns displayed', async () => { @@ -133,7 +123,6 @@ describe('DatasetPanel', () => { render( , @@ -159,7 +148,6 @@ describe('DatasetPanel', () => { render( theme.sizeUnit * 6}px; +`; + const StyledAlert = styled(Alert)` ${({ theme }) => ` border: 1px solid ${theme.colorInfoText}; @@ -167,6 +173,7 @@ const StyledAlert = styled(Alert)` export const REFRESHING = t('Refreshing columns'); export const COLUMN_TITLE = t('Table columns'); +export const ERROR_TITLE = t('An Error Occurred'); const pageSizeOptions = ['5', '10', '15', '25']; const DEFAULT_PAGE_SIZE = 25; @@ -201,9 +208,13 @@ export interface IDatasetPanelProps { */ columnList: ITableColumn[]; /** - * Boolean indicating if there is an error state + * Error returned while loading the table metadata */ - hasError: boolean; + error?: SupersetError; + /** + * Function used to retry loading the table metadata after error mitigation + */ + errorMitigationFunction?: () => void; /** * Boolean indicating if the component is in a loading state */ @@ -256,11 +267,11 @@ const DatasetPanel = ({ tableName, columnList, loading, - hasError, + error, + errorMitigationFunction, datasets, }: IDatasetPanelProps) => { - const hasColumns = Boolean(columnList?.length > 0); - const datasetNames = datasets?.map(dataset => dataset.table_name); + const hasColumns = columnList.length > 0; const tableWithDataset = datasets?.find( dataset => dataset.table_name === tableName, ); @@ -278,7 +289,19 @@ const DatasetPanel = ({ ); } if (!loading) { - if (!loading && tableName && hasColumns && !hasError) { + if (error) { + component = ( + + + + ); + } else if (tableName && hasColumns) { component = ( <> {COLUMN_TITLE} @@ -312,13 +335,7 @@ const DatasetPanel = ({ ); } else { - component = ( - - ); + component = ; } } @@ -326,11 +343,12 @@ const DatasetPanel = ({ <> {tableName && ( <> - {datasetNames?.includes(tableName) && - renderExistingDatasetAlert(tableWithDataset)} + {tableWithDataset && renderExistingDatasetAlert(tableWithDataset)} diff --git a/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanelWrapper.test.tsx b/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanelWrapper.test.tsx index 9ade2f88786..f3786210d40 100644 --- a/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanelWrapper.test.tsx +++ b/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanelWrapper.test.tsx @@ -16,8 +16,14 @@ * specific language governing permissions and limitations * under the License. */ -import { render, waitFor } from 'spec/helpers/testing-library'; -import { SupersetClient } from '@superset-ui/core'; +import { act, render, screen, waitFor } from 'spec/helpers/testing-library'; +import { ErrorTypeEnum, SupersetClient } from '@superset-ui/core'; +import type { SupersetClientResponse } from '@superset-ui/core'; +import { + DatabaseErrorMessage, + getErrorMessageComponentRegistry, + OAuth2RedirectMessage, +} from 'src/components/ErrorMessage'; import DatasetPanelWrapper from 'src/features/datasets/AddDataset/DatasetPanel'; jest.mock( @@ -29,17 +35,29 @@ jest.mock( ), ); +const errorMessageRegistry = getErrorMessageComponentRegistry(); + afterEach(() => { + errorMessageRegistry.remove(ErrorTypeEnum.GENERIC_BACKEND_ERROR); + errorMessageRegistry.remove(ErrorTypeEnum.OAUTH2_REDIRECT); jest.restoreAllMocks(); }); +const tableMetadataResponse = ( + name: string, + columnName: string, +): SupersetClientResponse => ({ + response: new Response(), + json: { + name, + columns: [{ name: columnName, type: 'INTEGER', longType: 'INTEGER' }], + }, +}); + test('fetches table metadata for schema-less database without schema', async () => { - const getSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({ - json: { - name: 'my_table', - columns: [{ name: 'id', type: 'INTEGER', longType: 'INTEGER' }], - }, - } as any); + const getSpy = jest + .spyOn(SupersetClient, 'get') + .mockResolvedValue(tableMetadataResponse('my_table', 'id')); render( { + jest.spyOn(SupersetClient, 'get').mockRejectedValue({ + response: new Response('{}', { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }), + }); + errorMessageRegistry.registerValue( + ErrorTypeEnum.GENERIC_BACKEND_ERROR, + DatabaseErrorMessage, + ); + + render( + , + { useRouter: true }, + ); + + expect( + await screen.findByText('Unable to load columns for the selected table.'), + ).toBeVisible(); +}); + +test('retries only table metadata after matching OAuth completion', async () => { + const oauthError = { + error_type: ErrorTypeEnum.OAUTH2_REDIRECT, + message: 'OAuth authorization is required.', + extra: { + url: 'https://example.com/authorize', + tab_id: 'dataset-oauth-tab', + }, + level: 'warning', + }; + const getSpy = jest + .spyOn(SupersetClient, 'get') + .mockRejectedValueOnce({ + response: new Response(JSON.stringify({ errors: [oauthError] }), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }), + }) + .mockResolvedValueOnce(tableMetadataResponse('oauth_table', 'oauth_id')); + + errorMessageRegistry.registerValue( + ErrorTypeEnum.OAUTH2_REDIRECT, + OAuth2RedirectMessage, + ); + + render( + , + { + initialState: { + charts: {}, + dashboardInfo: {}, + explore: {}, + sqlLab: { + queries: {}, + queryEditors: [], + tabHistory: [], + }, + }, + useRedux: true, + useRouter: true, + }, + ); + + const authorizationLink = await screen.findByRole('link', { + name: /provide authorization/i, + }); + expect(authorizationLink).toHaveAttribute( + 'href', + 'https://example.com/authorize', + ); + expect(getSpy).toHaveBeenCalledTimes(1); + + act(() => { + window.dispatchEvent( + new StorageEvent('storage', { + key: 'oauth2_auth_complete', + newValue: JSON.stringify({ tabId: 'dataset-oauth-tab' }), + }), + ); + }); + + expect(await screen.findByText('oauth_id')).toBeVisible(); + expect(getSpy).toHaveBeenCalledTimes(2); + expect(getSpy.mock.calls[1]).toEqual(getSpy.mock.calls[0]); +}); diff --git a/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/MessageContent.tsx b/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/MessageContent.tsx index 8b584296d55..5aba5032869 100644 --- a/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/MessageContent.tsx +++ b/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/MessageContent.tsx @@ -65,27 +65,17 @@ export const NO_COLUMNS_TITLE = t('No table columns'); export const NO_COLUMNS_DESCRIPTION = t( 'This database table does not contain any data. Please select a different table.', ); -export const ERROR_TITLE = t('An Error Occurred'); -export const ERROR_DESCRIPTION = t( - 'Unable to load columns for the selected table. Please select a different table.', -); interface MessageContentProps { - hasError: boolean; tableName?: string | null; - hasColumns: boolean; } export const MessageContent = (props: MessageContentProps) => { - const { hasError, tableName, hasColumns } = props; - let currentImage: string | undefined = 'empty-dataset.svg'; + const { tableName } = props; + let currentImage = 'empty-dataset.svg'; let currentTitle = SELECT_TABLE_TITLE; let currentDescription = renderEmptyDescription(); - if (hasError) { - currentTitle = ERROR_TITLE; - currentDescription = <>{ERROR_DESCRIPTION}; - currentImage = undefined; - } else if (tableName && !hasColumns) { + if (tableName) { currentImage = 'no-columns.svg'; currentTitle = NO_COLUMNS_TITLE; currentDescription = <>{NO_COLUMNS_DESCRIPTION}; diff --git a/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/index.tsx b/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/index.tsx index f41ee9020fc..37fc8172c91 100644 --- a/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/index.tsx +++ b/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/index.tsx @@ -16,9 +16,14 @@ * specific language governing permissions and limitations * under the License. */ -import { useEffect, useState, useRef } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { t } from '@apache-superset/core/translation'; -import { SupersetClient } from '@superset-ui/core'; +import { + ErrorTypeEnum, + getClientErrorObject, + SupersetClient, +} from '@superset-ui/core'; +import type { SupersetError } from '@superset-ui/core'; import { logging } from '@apache-superset/core/utils'; import { DatasetObject } from 'src/features/datasets/AddDataset/types'; import { addDangerToast } from 'src/components/MessageToasts/actions'; @@ -30,7 +35,7 @@ import { ITableColumn, IDatabaseTable, isIDatabaseTable } from './types'; /** * Interface for the getTableMetadata API call */ -interface IColumnProps { +interface TableMetadataRequest { /** * Unique id of the database */ @@ -43,6 +48,10 @@ interface IColumnProps { * Name of the schema (optional for databases that don't support schemas) */ schema?: string | null; + /** + * Name of the catalog (optional for databases that don't support catalogs) + */ + catalog?: string | null; } export interface IDatasetPanelWrapperProps { @@ -63,7 +72,7 @@ export interface IDatasetPanelWrapperProps { * The selected database object (used to check engine capabilities) */ database?: Partial | null; - setHasColumns?: Function; + setHasColumns?: (hasColumns: boolean) => void; datasets?: DatasetObject[] | undefined; } @@ -78,74 +87,131 @@ const DatasetPanelWrapper = ({ }: IDatasetPanelWrapperProps) => { const [columnList, setColumnList] = useState([]); const [loading, setLoading] = useState(false); - const [hasError, setHasError] = useState(false); - const tableNameRef = useRef(tableName); + const [error, setError] = useState(); + const requestIdRef = useRef(0); + const currentRequestRef = useRef(); + const supportsSchemas = database?.supports_schemas; - const getTableMetadata = async (props: IColumnProps) => { - const { dbId, tableName, schema } = props; - setLoading(true); - setHasColumns?.(false); - const path = `/api/v1/database/${dbId}/table_metadata/${toQueryString({ - name: tableName, - catalog, - schema, - })}`; - try { - const response = await SupersetClient.get({ - endpoint: path, - }); + const getTableMetadata = useCallback( + async (props: TableMetadataRequest) => { + const { dbId, tableName, catalog, schema } = props; + requestIdRef.current += 1; + const requestId = requestIdRef.current; + setLoading(true); + setColumnList([]); + setError(undefined); + setHasColumns?.(false); + const path = `/api/v1/database/${dbId}/table_metadata/${toQueryString({ + name: tableName, + catalog, + schema, + })}`; + try { + const response = await SupersetClient.get({ + endpoint: path, + }); - if (isIDatabaseTable(response?.json)) { - const table: IDatabaseTable = response.json as IDatabaseTable; - /** - * The user is able to click other table columns while the http call for last selected table column is made - * This check ensures we process the response that matches the last selected table name and ignore the others - */ - if (table.name === tableNameRef.current) { + if (requestId !== requestIdRef.current) { + return; + } + + const table = isIDatabaseTable(response?.json) + ? (response.json as IDatabaseTable) + : undefined; + if (table?.name === tableName) { setColumnList(table.columns); setHasColumns?.(table.columns.length > 0); - setHasError(false); + setError(undefined); + } else { + const message = t( + 'The API response from %s does not match the IDatabaseTable interface.', + path, + ); + setColumnList([]); + setHasColumns?.(false); + setError({ + error_type: ErrorTypeEnum.GENERIC_BACKEND_ERROR, + extra: null, + level: 'error', + message, + }); + addDangerToast(message); + logging.error(message); } - } else { - setColumnList([]); - setHasColumns?.(false); - setHasError(true); - addDangerToast( - t( - 'The API response from %s does not match the IDatabaseTable interface.', - path, - ), - ); - logging.error( - t( - 'The API response from %s does not match the IDatabaseTable interface.', - path, - ), + } catch (caughtError) { + const clientError = await getClientErrorObject( + caughtError as Parameters[0], ); + + if (requestId === requestIdRef.current) { + const parsedError = clientError.errors?.[0] ?? { + error_type: ErrorTypeEnum.GENERIC_BACKEND_ERROR, + extra: null, + level: 'error' as const, + message: + clientError.error || + clientError.message || + clientError.statusText || + t('Unable to load columns for the selected table.'), + }; + + setColumnList([]); + setHasColumns?.(false); + setError(parsedError); + } + } finally { + if (requestId === requestIdRef.current) { + setLoading(false); + } } - } catch (error) { - setColumnList([]); - setHasColumns?.(false); - setHasError(true); - } finally { - setLoading(false); + }, + [setHasColumns], + ); + + const retryGetTableMetadata = useCallback(() => { + if (currentRequestRef.current) { + getTableMetadata(currentRequestRef.current); } - }; + }, [getTableMetadata]); useEffect(() => { - tableNameRef.current = tableName; - const schemaRequired = database?.supports_schemas !== false; + const schemaRequired = supportsSchemas !== false; if (tableName && dbId && (schema || !schemaRequired)) { - getTableMetadata({ tableName, dbId, schema: schema || undefined }); + const request = { + tableName, + dbId, + catalog, + schema: schema || undefined, + }; + currentRequestRef.current = request; + getTableMetadata(request); + } else if (currentRequestRef.current) { + currentRequestRef.current = undefined; + requestIdRef.current += 1; + setColumnList([]); + setError(undefined); + setHasColumns?.(false); + setLoading(false); } - // getTableMetadata is a const and should not be in dependency array - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tableName, dbId, schema, database]); + + return () => { + requestIdRef.current += 1; + }; + }, [ + tableName, + dbId, + catalog, + schema, + supportsSchemas, + getTableMetadata, + setHasColumns, + ]); return ( { } diff --git a/superset-frontend/src/features/home/Menu.test.tsx b/superset-frontend/src/features/home/Menu.test.tsx index 7fdf67dd0ef..ea1a44178ad 100644 --- a/superset-frontend/src/features/home/Menu.test.tsx +++ b/superset-frontend/src/features/home/Menu.test.tsx @@ -1052,6 +1052,59 @@ describe('active tab highlighting (regression #36403)', () => { expect(getMenuItemByText('Дашборды')).toHaveClass('ant-menu-item-selected'); }); + test.each([ + ['/tablemodelview/list/', 'the legacy FAB dataset list route'], + ['/dataset/add/', 'the modern React dataset create route'], + ['/dataset/42', 'a dataset detail route'], + ])( + 'highlights the Datasets tab on %s (%s) — regression #42467', + async (route, _label) => { + // ``/tablemodelview/list/`` is the pre-existing legacy path (kept as + // a coverage anchor so future prefix-matching changes cannot silently + // regress it); the ``/dataset/*`` routes are the modern React ones + // added by #42467. + useSelectorMock.mockReturnValue({ roles: user.roles }); + window.history.pushState({}, '', route); + + render(

, { + useRedux: true, + useQueryParams: true, + useRouter: true, + useTheme: true, + }); + + // Datasets is a child under the Sources submenu — expand it first so + // the item is in the DOM (same pattern as the existing "render the top + // navbar child menu items" test). + const sources = await screen.findByText('Sources'); + userEvent.hover(sources); + + const datasets = await screen.findByText('Datasets'); + expect(datasets.closest('li')).toHaveClass('ant-menu-item-selected'); + }, + ); + + test('does not highlight the Datasets tab on lookalike prefixes (e.g. /datasetXyz)', async () => { + // The active-tab matcher must use a boundary-aware startsWith so that + // an unrelated future route beginning with ``/dataset`` (e.g. a + // hypothetical ``/datasetXyz``) does not falsely trigger the highlight. + useSelectorMock.mockReturnValue({ roles: user.roles }); + window.history.pushState({}, '', '/datasetXyz'); + + render(, { + useRedux: true, + useQueryParams: true, + useRouter: true, + useTheme: true, + }); + + const sources = await screen.findByText('Sources'); + userEvent.hover(sources); + + const datasets = await screen.findByText('Datasets'); + expect(datasets.closest('li')).not.toHaveClass('ant-menu-item-selected'); + }); + test('highlights the active SQL tab when the label is localized', async () => { // The SQL Lab top-level entry is a FAB category: its stable `name` is // "SQL Lab" while its label ("SQL") is localized. diff --git a/superset-frontend/src/features/home/Menu.tsx b/superset-frontend/src/features/home/Menu.tsx index 07cc228db6c..b78a0dff5a4 100644 --- a/superset-frontend/src/features/home/Menu.tsx +++ b/superset-frontend/src/features/home/Menu.tsx @@ -212,6 +212,12 @@ export function Menu({ Dashboard = '/dashboard', Chart = '/chart', Datasets = '/tablemodelview', + // The legacy FAB dataset list still lives at ``/tablemodelview/list/``, + // but the modern React-managed dataset add + detail routes are under + // ``/dataset/*`` (``/dataset/add/``, ``/dataset/:datasetId``). Both + // prefixes must map to the Datasets tab so the top-nav highlight + // survives navigation into the create/edit flow. See #42467. + Dataset = '/dataset', SqlLab = '/sqllab', SavedQueries = '/savedqueryview', } @@ -238,7 +244,9 @@ export function Menu({ case path.startsWith(Paths.Chart) || path.startsWith(Paths.Explore): setActiveTabs([MenuKeys.Charts]); break; - case path.startsWith(Paths.Datasets): + case path.startsWith(Paths.Datasets) || + path === Paths.Dataset || + path.startsWith(`${Paths.Dataset}/`): setActiveTabs([MenuKeys.Datasets]); break; case path.startsWith(Paths.SqlLab) || path.startsWith(Paths.SavedQueries): diff --git a/superset-frontend/src/preamble.test.ts b/superset-frontend/src/preamble.test.ts index 579b0c97f71..dfbe1e7fd6b 100644 --- a/superset-frontend/src/preamble.test.ts +++ b/superset-frontend/src/preamble.test.ts @@ -17,6 +17,8 @@ * under the License. */ +import type { LanguagePack } from '@apache-superset/core/translation'; + const mockConfigure = jest.fn(); const mockInitFeatureFlags = jest.fn(); const mockMakeApi = jest.fn(() => jest.fn()); @@ -111,3 +113,83 @@ test('falls back to en when passing locale to setupFormatters', async () => { expect(mockSetupFormatters).toHaveBeenCalledWith({}, {}, 'en'); }); + +// --- language pack loading semantics (issues #35330, PR #41780) ----------- +// English: nothing is loaded at all. Non-English: the pack is expected to +// already be on window (set by the versioned classic + {% endif %} + {# + Operators can still supply a pack directly in the bootstrap payload + via COMMON_BOOTSTRAP_OVERRIDES_FUNC (the historical #35330 + workaround). When one is present, stash it on window here instead of + emitting the versioned script tag, so early chunks see the override. + #} + {% if language_pack_inline %} + + })(); + + {% endif %} {% if entry %} {{ js_bundle(assets_prefix, entry) }} {% endif %} diff --git a/superset/translations/fr/LC_MESSAGES/messages.po b/superset/translations/fr/LC_MESSAGES/messages.po index 0418ab2dcb0..2bbbb8bbb99 100644 --- a/superset/translations/fr/LC_MESSAGES/messages.po +++ b/superset/translations/fr/LC_MESSAGES/messages.po @@ -223,7 +223,7 @@ msgstr "% du total" #, python-format msgid "%(alertType)s \"%(alertName)s\" triggered successfully" -msgstr "" +msgstr "%(alertType)s « %(alertName)s » déclenché avec succès" #, python-format msgid "%(dialect)s cannot be used as a data source for security reasons." @@ -1390,10 +1390,10 @@ msgstr "" " ou négative par rapport à la valeur de comparaison." msgid "Adhoc metric SQL expression is invalid" -msgstr "" +msgstr "L'expression SQL de la mesure ad hoc est invalide" msgid "Adhoc metric aggregate is invalid" -msgstr "" +msgstr "L'agrégat de la mesure ad hoc est invalide" msgid "Adjust how this database will interact with SQL Lab." msgstr "Ajuster la façon dont cette base de données interagira avec SQL Lab." @@ -2018,6 +2018,8 @@ msgid "" "Angle at which the first slice begins, in degrees. 90° starts at the top," " 0°/360° at the right, 270° at the bottom, and 180° at the left." msgstr "" +"Angle auquel commence la première part, en degrés. 90° démarre en haut, " +"0°/360° à droite, 270° en bas et 180° à gauche." msgid "Angle at which to end progress axis" msgstr "Angle de fin de l'axe de progression" @@ -2278,7 +2280,7 @@ msgid "Are you sure you want to delete the selected layers?" msgstr "Voulez-vous vraiment supprimer les couches sélectionnées?" msgid "Are you sure you want to delete the selected queries?" -msgstr "" +msgstr "Voulez-vous vraiment supprimer les requêtes sélectionnées ?" msgid "Are you sure you want to delete the selected roles?" msgstr "Voulez-vous vraiment supprimer les rôles sélectionnés ?" @@ -3527,7 +3529,7 @@ msgid "Clear local theme" msgstr "Supprimer le thème local" msgid "Clear search" -msgstr "" +msgstr "Effacer la recherche" msgid "Clear the selection to revert to the system default theme" msgstr "Effacer la sélection pour revenir au thème par défaut du système" @@ -4056,7 +4058,7 @@ msgid "Connection failed, please check your connection settings." msgstr "La connexion a échoué, veuillez vérifier vos paramètres de connexion" msgid "Connection looks good!" -msgstr "" +msgstr "La connexion fonctionne !" msgid "Contains" msgstr "Contient" @@ -4144,7 +4146,7 @@ msgid "Copy query" msgstr "Copier la requête" msgid "Copy query URL" -msgstr "" +msgstr "Copier l'URL de la requête" msgid "Copy query link to your clipboard" msgstr "Copier le lien de la requête vers le presse-papier" @@ -4274,6 +4276,8 @@ msgid "" "Create a new tag and assign it to existing entities like charts or " "dashboards" msgstr "" +"Créer une balise et l'affecter à des entités existantes comme des " +"graphiques ou des tableaux de bord" msgid "Create and explore dataset" msgstr "Créer et explorer un jeu de données" @@ -4302,7 +4306,7 @@ msgstr "Créé par" msgid "Created by me" msgstr "Créé par moi" -, python-format +#, python-format msgid "Created by: %s" msgstr "Créé par : %s" @@ -4597,6 +4601,9 @@ msgid "" "Dashboard cannot be restored because its slug is now used by another " "active dashboard. Rename one of the dashboards and retry." msgstr "" +"Le tableau de bord ne peut pas être restauré car son slug est désormais " +"utilisé par un autre tableau de bord actif. Renommez l'un des deux " +"tableaux de bord et réessayez." msgid "Dashboard cannot be unfavorited." msgstr "Le tableau de bord n'a pas pu être retiré des favoris." @@ -5283,7 +5290,7 @@ msgid "Delete item" msgstr "Supprimer l'élément" msgid "Delete query" -msgstr "" +msgstr "Supprimer la requête" msgid "Delete role" msgstr "Supprimer le rôle" @@ -5595,6 +5602,9 @@ msgid "" "Display charts on a map. For using this plugin, users first have to " "create any other chart that can then be placed on the map." msgstr "" +"Affiche des graphiques sur une carte. Pour utiliser ce module, il faut " +"d'abord créer un autre graphique, qui pourra ensuite être placé sur la " +"carte." msgid "Display column in the chart" msgstr "Afficher la colonne dans le graphique" @@ -5990,7 +6000,7 @@ msgstr "ERREUR" #, python-format msgid "ERROR: %s" -msgstr "" +msgstr "ERREUR : %s" msgid "Edge length" msgstr "Longueur du bord" @@ -6082,7 +6092,7 @@ msgid "Edit properties" msgstr "Modifier les propriétés" msgid "Edit query" -msgstr "" +msgstr "Modifier la requête" msgid "Edit report" msgstr "Modifier le rapport" @@ -6172,7 +6182,7 @@ msgid "Email link" msgstr "Lien par courriel" msgid "Email recipients" -msgstr "" +msgstr "Destinataires du courriel" msgid "Email reports active" msgstr "Rapports par courriel actifs" @@ -6425,7 +6435,7 @@ msgid "Entity" msgstr "Entité" msgid "Entries per page" -msgstr "" +msgstr "Entrées par page" msgid "Equal Date Sizes" msgstr "Taille des dates égales" @@ -6693,7 +6703,7 @@ msgid "Export as Example" msgstr "Exporter comme exemple" msgid "Export as PDF" -msgstr "" +msgstr "Exporter en PDF" msgid "Export cancelled" msgstr "Export annulé" @@ -6712,7 +6722,7 @@ msgid "Export failed: %s" msgstr "Export échoué : %s" msgid "Export query" -msgstr "" +msgstr "Exporter la requête" # Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: cs, de, ja, # lv, ro, ru, sk, sr, sr_Latn, tr, uk] @@ -6720,7 +6730,7 @@ msgid "Export screenshot (jpeg)" msgstr "Exporter la capture d'écran (jpeg)" msgid "Export screenshot (png)" -msgstr "" +msgstr "Exporter la capture d'écran (png)" #, python-format msgid "Export successful: %s" @@ -6764,11 +6774,15 @@ msgid "" "Exporting semantic views is not supported yet — %s semantic-view row(s) " "were skipped." msgstr "" +"L'export des vues sémantiques n'est pas encore pris en charge — %s " +"ligne(s) de vue sémantique ont été ignorées." msgid "" "Exporting semantic views is not supported yet. Deselect the semantic-view" " rows and try again." msgstr "" +"L'export des vues sémantiques n'est pas encore pris en charge. " +"Désélectionnez les lignes de vue sémantique et réessayez." msgid "Expose database in SQL Lab" msgstr "Exposer la base de données dans SQL Lab" @@ -6881,6 +6895,8 @@ msgid "" "Failed to export chart data. Please try again or contact your " "administrator." msgstr "" +"Échec de l'export des données du graphique. Réessayez ou contactez votre " +"administrateur." msgid "Failed to fetch API keys" msgstr "Tout Dé-Sélectionner" @@ -6956,7 +6972,7 @@ msgstr "Échec du marquage des éléments" #, python-format msgid "Failed to trigger %(alertType)s \"%(alertName)s\": %(error)s" -msgstr "" +msgstr "Échec du déclenchement de %(alertType)s « %(alertName)s » : %(error)s" msgid "Failed to update report" msgstr "Échec de la mise à jour du rapport" @@ -7315,7 +7331,7 @@ msgid "Forecast periods" msgstr "Périodes de prévision" msgid "Forecast requires at least 2 data points" -msgstr "" +msgstr "La prévision nécessite au moins 2 points de données" msgid "Foreign key" msgstr "Clé étrangère" @@ -7371,10 +7387,10 @@ msgstr "" " sont présentes, le formatage revient aux nombres neutres." msgid "Formatted CSV attached in email" -msgstr "CSV formatté attaché dans le courriel" +msgstr "Fichier CSV mis en forme joint au courriel" msgid "Formatted Excel attached in email" -msgstr "" +msgstr "Fichier Excel mis en forme joint au courriel" msgid "Formatted date" msgstr "Date formatée" @@ -8318,7 +8334,7 @@ msgid "Label for your query" msgstr "Label pour votre requête" msgid "Label must not be empty." -msgstr "" +msgstr "L'étiquette ne doit pas être vide." msgid "Label position" msgstr "Position de l'étiquette" @@ -8619,7 +8635,7 @@ msgid "Lines encoding" msgstr "Codage des lignes" msgid "Link Copied!" -msgstr "" +msgstr "Lien copié !" msgid "List" msgstr "Liste" @@ -9556,7 +9572,7 @@ msgstr "" "enregistrement temporel" msgid "No data found" -msgstr "" +msgstr "Aucune donnée trouvée" msgid "No data in file" msgstr "Pas de données dans le fichier" @@ -9972,7 +9988,7 @@ msgstr "Une ou plusieurs mesures n'existent pas" #, python-format msgid "One or more parameters are missing: %(missing)s" -msgstr "" +msgstr "Un ou plusieurs paramètres sont manquants : %(missing)s" msgid "One or more parameters needed to configure a database are missing." msgstr "" @@ -10675,6 +10691,11 @@ msgid "" "period (e.g. today so far) against complete prior periods (e.g. all of " "yesterday)." msgstr "" +"Tracer chaque série décalée dans le temps sur toute sa plage temporelle " +"au lieu de la tronquer à celle de la série principale. Utile pour " +"comparer une période en cours partielle (par exemple aujourd'hui jusqu'à " +"maintenant) à des périodes antérieures complètes (par exemple la journée " +"d'hier entière)." msgid "Plot the distance (like flight paths) between origin and destination." msgstr "" @@ -11135,6 +11156,12 @@ msgid "" "except the subjects defined in the filter, and can be used to define what" " users can see if no RLS filters within a filter group apply to them." msgstr "" +"Les filtres classiques ajoutent des clauses WHERE aux requêtes lorsqu'un " +"utilisateur correspond à un sujet référencé par le filtre. Les filtres de" +" base appliquent des filtres à toutes les requêtes sauf pour les sujets " +"définis dans le filtre ; ils permettent de définir ce que voient les " +"utilisateurs auxquels aucun filtre RLS d'un groupe de filtres ne " +"s'applique." msgid "Relational" msgstr "Relationnel" @@ -11175,7 +11202,7 @@ msgid "Remove customization" msgstr "Supprimer la personnalisation" msgid "Remove dependency" -msgstr "" +msgstr "Supprimer la dépendance" msgid "Remove filter" msgstr "Supprimer le filtre" @@ -11184,13 +11211,13 @@ msgid "Remove item" msgstr "Supprimer l’élément" msgid "Remove notification method" -msgstr "" +msgstr "Supprimer le mode de notification" msgid "Remove query from log" msgstr "Supprimer la requête des journaux" msgid "Remove sheet" -msgstr "" +msgstr "Supprimer la feuille" #, python-format msgid "Removed 1 column from the virtual dataset" @@ -11234,7 +11261,7 @@ msgid "Report Schedule delete failed." msgstr "La planification de rapport n'a pas être supprimée." msgid "Report Schedule execute now failed." -msgstr "" +msgstr "L'exécution immédiate de la planification de rapport a échoué." msgid "Report Schedule execution failed when generating a csv." msgstr "" @@ -11258,6 +11285,8 @@ msgstr "" msgid "Report Schedule execution failed when generating an Excel file." msgstr "" +"L'exécution de la planification de rapport a échoué lors de la génération" +" du fichier Excel." msgid "Report Schedule execution got an unexpected error." msgstr "" @@ -11269,10 +11298,15 @@ msgid "" "Please configure a Celery broker (Redis or RabbitMQ) and worker " "processes." msgstr "" +"L'exécution de la planification de rapport nécessite un backend Celery " +"configuré. Configurez un broker Celery (Redis ou RabbitMQ) et des " +"processus worker." #, python-format msgid "Report Schedule executor user %(username)s was not found." msgstr "" +"L'utilisateur %(username)s exécutant la planification de rapport est " +"introuvable." msgid "Report Schedule is still working, refusing to re-compute." msgstr "" @@ -11981,7 +12015,7 @@ msgid "Search Metrics & Columns" msgstr "Rechercher les mesures et les colonnes" msgid "Search a channel by name, or paste a channel ID" -msgstr "" +msgstr "Rechercher un canal par son nom, ou coller un identifiant de canal" msgid "Search all charts" msgstr "Rechercher tous les graphiques" @@ -12026,7 +12060,7 @@ msgid "Search owners" msgstr "Rechercher des propriétaires" msgid "Search records" -msgstr "" +msgstr "Rechercher des enregistrements" msgid "Search roles" msgstr "Recherche de rôles" @@ -12042,6 +12076,8 @@ msgstr "Rechercher…" msgid "Searches all text fields: Name, Description, Database & Schema" msgstr "" +"Recherche dans tous les champs texte : nom, description, base de données " +"et schéma" msgid "Second" msgstr "Seconde" @@ -12084,7 +12120,7 @@ msgid "See all %(tableName)s" msgstr "Voir tout %(tableName)s" msgid "See all dashboards" -msgstr "" +msgstr "Voir tous les tableaux de bord" msgid "See less" msgstr "Voir moins" @@ -12375,10 +12411,10 @@ msgid "Select operator" msgstr "Sélectionner l'opérateur" msgid "Select or type BCC recipients" -msgstr "" +msgstr "Sélectionner ou saisir les destinataires en Cci" msgid "Select or type CC recipients" -msgstr "" +msgstr "Sélectionner ou saisir les destinataires en Cc" msgid "Select or type a custom value..." msgstr "Sélectionner ou renseigner une valeur personnalisé..." @@ -12390,7 +12426,7 @@ msgid "Select or type dataset name" msgstr "Sélectionner la base de données ou taper le nom du jeu de données" msgid "Select or type email recipients" -msgstr "" +msgstr "Sélectionner ou saisir les destinataires du courriel" msgid "Select page size" msgstr "Sélectionner la taille de la page" @@ -12639,7 +12675,7 @@ msgid "Send as CSV" msgstr "Envoyer comme CSV" msgid "Send as Excel" -msgstr "" +msgstr "Envoyer comme Excel" msgid "Send as PDF" msgstr "Envoyer comme PDF" @@ -12871,7 +12907,7 @@ msgid "Show Metric Names" msgstr "Afficher les noms de mesure" msgid "Show Null Values" -msgstr "" +msgstr "Afficher les valeurs nulles" msgid "Show Range Filter" msgstr "Afficher l'intervalle de filtre" @@ -12912,13 +12948,17 @@ msgstr "" "autrement min/max dans les données." msgid "Show a draggable slider to control the visible range of the Y-axis." -msgstr "" +msgstr "Afficher un curseur déplaçable pour contrôler la plage visible de l'axe Y." msgid "" "Show a summary row of total aggregations: the selected metrics in " "aggregate mode, or the sum of numeric columns in raw records mode. Note " "that row limit does not apply to the result." msgstr "" +"Afficher une ligne de synthèse des agrégats totaux : les mesures " +"sélectionnées en mode agrégé, ou la somme des colonnes numériques en mode" +" enregistrements bruts. Notez que la limite de lignes ne s'applique pas " +"au résultat." msgid "Show all columns" msgstr "Afficher toutes les colonnes" @@ -12959,7 +12999,7 @@ msgid "Show entries per page" msgstr "Afficher le nombre d'éléments par page" msgid "Show full range for time shift" -msgstr "" +msgstr "Afficher la plage complète pour le décalage temporel" msgid "" "Show hierarchical relationships of data, with the value represented by " @@ -13046,6 +13086,8 @@ msgid "" "Showcases a metric along with a comparison of value, change, and percent " "change for a selected time period." msgstr "" +"Met en avant une mesure avec une comparaison de la valeur, de l'écart et " +"de l'écart en pourcentage sur une période sélectionnée." msgid "" "Showcases a single metric front-and-center. Big number is best used to " @@ -13189,7 +13231,7 @@ msgid "Solid" msgstr "Solide" msgid "Solid background" -msgstr "" +msgstr "Fond uni" # Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr, # sr_Latn] @@ -13213,12 +13255,14 @@ msgstr "" "seront pas effacés" msgid "Some tables are not shown. Refine your search." -msgstr "" +msgstr "Certaines tables ne sont pas affichées. Affinez votre recherche." msgid "" "Something went wrong loading the dashboard. Check the dev console for " "details." msgstr "" +"Un problème est survenu lors du chargement du tableau de bord. Consultez " +"la console développeur pour plus de détails." msgid "Something went wrong while saving the user info" msgstr "Une erreur s'est produite. Réessayez plus tard." @@ -13628,7 +13672,7 @@ msgid "Success" msgstr "Réussite" msgid "Success message" -msgstr "" +msgstr "Message de succès" #, python-format msgid "Successfully changed %s!" @@ -13702,7 +13746,7 @@ msgid "Swap rows and columns" msgstr "Échanger les rangées et les colonnes" msgid "Sweep angle" -msgstr "" +msgstr "Angle de balayage" msgid "" "Swiss army knife for visualizing data. Choose between step, line, " @@ -13870,7 +13914,7 @@ msgid "Tag created" msgstr "Balise créée" msgid "Tag description" -msgstr "" +msgstr "Description de la balise" msgid "Tag name" msgstr "Nom de la balise" @@ -14000,11 +14044,13 @@ msgid "Text align" msgstr "Alignement du texte" msgid "Text embedded in email" -msgstr "Text encapsulé dans le courriel" +msgstr "Texte encapsulé dans le courriel" #, python-format msgid "The %(key)s in metadata_cache_timeout must be a non-negative integer." msgstr "" +"La valeur de %(key)s dans metadata_cache_timeout doit être un entier " +"positif ou nul." #, python-format msgid "The %s" @@ -14124,6 +14170,9 @@ msgid "" "The chart data is too large to download. Please try reducing the date " "range, limiting rows, or using fewer columns." msgstr "" +"Les données du graphique sont trop volumineuses pour être téléchargées. " +"Réduisez la plage de dates, limitez le nombre de lignes ou utilisez moins" +" de colonnes." # Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr, # sr_Latn] @@ -14141,7 +14190,7 @@ msgstr "" " ou mettez à jour le rapport pour pointer vers un graphique actif." msgid "The chat failed to load." -msgstr "" +msgstr "La conversation n'a pas pu être chargée." msgid "" "The classic. Great for showing how much of a company each investor gets, " @@ -14239,6 +14288,8 @@ msgstr "" msgid "The dashboard you are looking for may have been deleted or moved." msgstr "" +"Le tableau de bord que vous recherchez a peut-être été supprimé ou " +"déplacé." msgid "The data source seems to have been deleted" msgstr "La source de données semble avoir été effacée" @@ -14523,6 +14574,8 @@ msgid "" "The metadata_cache_timeout must be a mapping from string keys to non-" "negative integer values." msgstr "" +"metadata_cache_timeout doit être une correspondance entre des clés de " +"type chaîne et des entiers positifs ou nuls." #, python-format msgid "" @@ -14759,6 +14812,8 @@ msgstr "Cette requête contient un ou plusieurs paramètres de modèle malformé msgid "The query context datasource does not match the chart datasource" msgstr "" +"La source de données du contexte de requête ne correspond pas à celle du " +"graphique" msgid "The query couldn't be loaded" msgstr "La requête ne peut pas être chargée" @@ -15200,7 +15255,7 @@ msgid "There was an error fetching the filtered charts and dashboards:" msgstr "Une erreur s’est produite lors de la récupération des graphiques et tableaux de bord filtrés :" msgid "There was an error generating the permalink." -msgstr "" +msgstr "Une erreur s'est produite lors de la génération du lien permanent." msgid "There was an error loading groups." msgstr "Une erreur s'est produite lors du chargement des groupes." @@ -15305,6 +15360,8 @@ msgstr "" #, python-format msgid "There was an issue deleting the selected queries: %s" msgstr "" +"Un problème est survenu lors de la suppression des requêtes sélectionnées" +" : %s" #, python-format msgid "There was an issue deleting the selected templates: %s" @@ -15351,7 +15408,7 @@ msgid "There was an issue exporting the selected dashboards" msgstr "Il y a eu un problème lors de l'export des tableaux de bord sélectionnés" msgid "There was an issue exporting the selected queries" -msgstr "" +msgstr "Un problème est survenu lors de l'export des requêtes sélectionnées" msgid "There was an issue exporting the selected themes" msgstr "Il y a eu un problème lors de l'export des thèmes sélectionnés" @@ -15518,7 +15575,7 @@ msgstr "" "être transmis au graphique contenant les données d'annotation." msgid "This dashboard does not exist" -msgstr "" +msgstr "Ce tableau de bord n'existe pas" msgid "This dashboard is managed externally, and can't be edited in Superset" msgstr "" @@ -15906,6 +15963,8 @@ msgstr "Fragment de temps" msgid "Time Grain must be specified when using Time Comparison." msgstr "" +"Le fragment de temps doit être précisé lors de l'utilisation de la " +"comparaison de temps." msgid "Time Granularity" msgstr "Fragmentation de Temps" @@ -16195,6 +16254,10 @@ msgid "" " angle is a multiple of 90°, the chart is automatically re-centered to " "make use of the empty space." msgstr "" +"Angle total couvert par le graphique, en degrés. 360° dessine un cercle " +"complet et 180° un demi-anneau. Lorsque le balayage est inférieur ou égal" +" à 180° et que l'angle de départ est un multiple de 90°, le graphique est" +" automatiquement recentré pour exploiter l'espace vide." msgid "Total color" msgstr "Couleur du total" @@ -16219,7 +16282,7 @@ msgid "Transparent" msgstr "Transparent" msgid "Transparent background" -msgstr "" +msgstr "Fond transparent" msgid "Transpose pivot" msgstr "Pivot de transposition" @@ -16249,7 +16312,7 @@ msgid "Trigger Alert If..." msgstr "Déclencher une alerte si…" msgid "Trigger now" -msgstr "" +msgstr "Déclencher maintenant" msgid "True" msgstr "Est vrai" @@ -16368,7 +16431,7 @@ msgid "URL parameters" msgstr "Paramètres URL" msgid "UUID to track the execution status" -msgstr "" +msgstr "UUID permettant de suivre l'état de l'exécution" msgid "Unable to calculate such a date delta" msgstr "Impossible de calculer un delta de date comme celui-ci" @@ -16435,7 +16498,7 @@ msgstr "Impossible de générer les données de téléchargement" #, python-format msgid "Unable to generate forecast: %(error)s" -msgstr "" +msgstr "Impossible de générer la prévision : %(error)s" msgid "" "Unable to identify temporal column for date range time comparison.Please " @@ -16450,6 +16513,8 @@ msgid "" "Unable to interpret the time offset: %(offset)s. Use a relative time such" " as \"1 month ago\"." msgstr "" +"Impossible d'interpréter le décalage temporel : %(offset)s. Utilisez une " +"expression relative telle que « 1 month ago »." msgid "" "Unable to load columns for the selected table. Please select a different " @@ -17497,6 +17562,8 @@ msgstr "Affichage ou non des bulles au-dessus des pays" msgid "Whether to display entries with null values in the hierarchy" msgstr "" +"Afficher ou non les entrées dont les valeurs sont nulles dans la " +"hiérarchie" msgid "Whether to display in the chart" msgstr "Afficher ou non dans le graphique" @@ -17632,6 +17699,9 @@ msgid "" "Whether to sort tooltip by the selected metric in descending order. On " "stacked charts, values are shown in ascending order." msgstr "" +"Trier ou non l'infobulle par la mesure sélectionnée dans l'ordre " +"décroissant. Sur les graphiques empilés, les valeurs sont affichées dans " +"l'ordre croissant." msgid "Whether to truncate metrics" msgstr "Tronquer ou non les mesures" @@ -17791,7 +17861,7 @@ msgid "Y-axis bounds" msgstr "Limites de l’axe des ordonnées" msgid "Y-axis range slider" -msgstr "" +msgstr "Curseur de plage de l'axe Y" msgid "Y-scale interval" msgstr "Intervalle d'échelle Y" @@ -18066,26 +18136,41 @@ msgid "" "You must be a chart editor in order to delete. Please reach out to a " "chart editor to request modifications or edit access." msgstr "" +"Vous devez être éditeur du graphique pour pouvoir supprimer. Contactez un" +" éditeur du graphique pour demander des modifications ou un accès en " +"modification." msgid "" "You must be a chart editor in order to edit. Please reach out to a chart " "editor to request modifications or edit access." msgstr "" +"Vous devez être éditeur du graphique pour pouvoir modifier. Contactez un " +"éditeur du graphique pour demander des modifications ou un accès en " +"modification." msgid "" "You must be a dashboard editor in order to delete. Please reach out to a " "dashboard editor to request modifications or edit access." msgstr "" +"Vous devez être éditeur du tableau de bord pour pouvoir supprimer. " +"Contactez un éditeur du tableau de bord pour demander des modifications " +"ou un accès en modification." msgid "" "You must be a dashboard editor in order to edit. Please reach out to a " "dashboard editor to request modifications or edit access." msgstr "" +"Vous devez être éditeur du tableau de bord pour pouvoir modifier. " +"Contactez un éditeur du tableau de bord pour demander des modifications " +"ou un accès en modification." msgid "" "You must be a dataset editor in order to delete. Please reach out to a " "dataset editor to request modifications or edit access." msgstr "" +"Vous devez être éditeur de l'ensemble de données pour pouvoir supprimer. " +"Contactez un éditeur de l'ensemble de données pour demander des " +"modifications ou un accès en modification." msgid "" "You must be a dataset editor in order to edit. Please reach out to a " @@ -18172,6 +18257,11 @@ msgid "" "into multiple dashboards) or raise the " "SUPERSET_DASHBOARD_POSITION_DATA_LIMIT config setting." msgstr "" +"Votre tableau de bord est trop volumineux pour être enregistré : la " +"longueur sérialisée de la disposition est de %s alors que la limite est " +"de %s. Réduisez la taille du tableau de bord (par exemple en le scindant " +"en plusieurs tableaux de bord) ou augmentez le paramètre de configuration" +" SUPERSET_DASHBOARD_POSITION_DATA_LIMIT." msgid "Your query could not be saved" msgstr "Votre requête n'a pas pu être enregistrée" @@ -19026,7 +19116,7 @@ msgid "quarter" msgstr "trimestre" msgid "queries" -msgstr "" +msgstr "requêtes" msgid "query" msgstr "requête" @@ -19072,7 +19162,7 @@ msgid "seconds" msgstr "secondes" msgid "semantic layer" -msgstr "" +msgstr "couche sémantique" msgid "series" msgstr "série" diff --git a/superset/translations/utils.py b/superset/translations/utils.py index 36be0331a4d..9f47abf670e 100644 --- a/superset/translations/utils.py +++ b/superset/translations/utils.py @@ -14,6 +14,7 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. +import hashlib import json import logging import os @@ -24,31 +25,63 @@ logger = logging.getLogger(__name__) # Global caching for JSON language packs ALL_LANGUAGE_PACKS: dict[str, dict[str, Any]] = {"en": {}} +# Global caching for language pack content hashes, used to build +# content-addressed (cache-busting) asset URLs. A locale with no pack file +# is cached as None so repeated requests don't re-stat the filesystem or +# re-log the same warning. +ALL_LANGUAGE_PACK_VERSIONS: dict[str, Optional[str]] = {} + DIR = os.path.dirname(os.path.abspath(__file__)) +def get_language_pack_filename(locale: str) -> str: + """Resolve the on-disk JSON pack for a locale (empty pack for English)""" + if not locale or locale == "en": + # Forcing a dummy, quasi-empty language pack for English since the file + # in the en directory contains data with empty mappings + return DIR + "/empty_language_pack.json" + return DIR + f"/{locale}/LC_MESSAGES/messages.json" + + +def get_language_pack_version(locale: str) -> Optional[str]: + """Get/cache a short content hash of the language pack file + + The hash is embedded in the pack's asset URL so browsers can cache it + as immutable and pick up a fresh copy whenever translations change. + Returns None when the pack file cannot be read. + """ + if locale in ALL_LANGUAGE_PACK_VERSIONS: + return ALL_LANGUAGE_PACK_VERSIONS[locale] + try: + with open(get_language_pack_filename(locale), "rb") as f: + version = hashlib.sha256(f.read()).hexdigest()[:12] + except OSError: + logger.warning("No language pack file to version for locale %s", locale) + version = None + ALL_LANGUAGE_PACK_VERSIONS[locale] = version + return version + + def get_language_pack(locale: str) -> Optional[dict[str, Any]]: """Get/cache a language pack - Returns the language pack from cache if it exists, caches otherwise + Returns the language pack from cache if it exists, caches otherwise. + Returns None when the catalog is missing or fails to parse, so callers + can tell a genuine pack apart from a masked failure instead of silently + serving another locale's content under the requested locale's identity + (and, worse, caching it there forever behind a content-addressed URL). >>> get_language_pack('fr')['Dashboards'] "Tableaux de bords" """ pack = ALL_LANGUAGE_PACKS.get(locale) if not pack: - filename = DIR + f"/{locale}/LC_MESSAGES/messages.json" - if not locale or locale == "en": - # Forcing a dummy, quasy-empty language pack for English since the file - # in the en directory is contains data with empty mappings - filename = DIR + "/empty_language_pack.json" + filename = get_language_pack_filename(locale) try: with open(filename, encoding="utf8") as f: pack = json.load(f) ALL_LANGUAGE_PACKS[locale] = pack or {} except Exception: # pylint: disable=broad-except - logger.error( - "Error loading language pack for, falling back on en %s", locale - ) - pack = get_language_pack("en") + logger.error("Error loading language pack for locale %s", locale) + return None return pack diff --git a/superset/utils/screenshot_utils.py b/superset/utils/screenshot_utils.py index 868b8e731d5..82a06fd66c0 100644 --- a/superset/utils/screenshot_utils.py +++ b/superset/utils/screenshot_utils.py @@ -87,6 +87,29 @@ def resolve_screenshot_task_budget_seconds( return None +# Fallback wall-clock budget, in seconds, for the entire tiled-screenshot +# operation (element lookup plus all per-tile readiness/animation waits +# combined), used when resolve_screenshot_task_budget_seconds() returns None +# (no Celery task context -- e.g. synchronous thumbnail generation -- or no +# usable task limit). The non-tiled readiness path treats None as "keep the +# configured SCREENSHOT_LOAD_WAIT" because it makes exactly one bounded wait; +# the tiled path cannot, because its per-tile waits accumulate: with N tiles, +# an uncapped load_wait allows N * load_wait of total wall-clock time, so the +# operation still needs one fixed total ceiling. Sized against the longest +# Celery hard task_time_limit observed in production for report execution +# (1740s), minus the same 300s cleanup margin the runtime derivation reserves +# for combining tiles, building the PDF, and delivering the notification. +TILED_SCREENSHOT_TOTAL_WAIT_BUDGET_SECONDS = 1440 # 1740s limit - 300s margin + + +class ScreenshotTaskBudgetExceededError(RuntimeError): + """Raised when no safe task budget remains before screenshot capture.""" + + +class TiledScreenshotBudgetExceededError(ScreenshotTaskBudgetExceededError): + """Raised when the tiled-screenshot time budget runs out mid-capture.""" + + try: from playwright.sync_api import TimeoutError as PlaywrightTimeout except ImportError: @@ -251,7 +274,7 @@ def combine_screenshot_tiles(screenshot_tiles: list[bytes]) -> bytes: return screenshot_tiles[0] -def take_tiled_screenshot( +def take_tiled_screenshot( # noqa: C901 page: "Page", element_name: str, tile_height: int, @@ -274,6 +297,12 @@ def take_tiled_screenshot( Returns: Combined screenshot bytes or None if failed + + Raises: + TiledScreenshotBudgetExceededError: If the total time budget for the + tiled-screenshot operation runs out before every tile has been + verifiably captured. Callers must treat this as a hard failure + rather than fall back to an unchecked/partial screenshot. """ context_suffix = f" [{log_context}]" if log_context else "" # Set right before re-raising the per-tile readiness timeout below, and @@ -286,6 +315,15 @@ def take_tiled_screenshot( # match `except PlaywrightTimeout` and incorrectly propagate instead of # degrading to `None` like every other unexpected error in this function. readiness_timeout = False + # Cap the whole tiled operation against the running Celery task's own + # time limit, using the same runtime derivation as the non-tiled + # readiness wait (#42253/#42427). Unlike that path, a None budget does + # not mean "keep the configured timeout": per-tile waits accumulate, so + # the operation falls back to a fixed total ceiling instead. + wait_budget_seconds = resolve_screenshot_task_budget_seconds(log_context) + if wait_budget_seconds is None: + wait_budget_seconds = float(TILED_SCREENSHOT_TOTAL_WAIT_BUDGET_SECONDS) + start_time = time.monotonic() try: # Get the target element element = page.locator(f".{element_name}") @@ -320,9 +358,44 @@ def take_tiled_screenshot( num_tiles = max(1, (dashboard_height + tile_height - 1) // tile_height) logger.info("Taking %s screenshot tiles", num_tiles) - screenshot_tiles = [] + screenshot_tiles: list[bytes] = [] + + def _raise_if_budget_exhausted(elapsed: float, remaining_budget: float) -> None: + if remaining_budget > 0: + return + # A customer-side chart-loading issue (a slow/hung dashboard), + # not a Superset system fault, so this is a WARNING rather + # than an ERROR -- consistent with #38130/#38441, which + # deliberately downgraded screenshot timeout logs the same way. + logger.warning( + "Tiled screenshot time budget exhausted on tile %s/%s: " + "%s/%s tiles captured so far, %.1fs elapsed of a %.1fs " + "budget. Aborting instead of capturing remaining tiles " + "unchecked.%s", + i + 1, + num_tiles, + len(screenshot_tiles), + num_tiles, + elapsed, + wait_budget_seconds, + context_suffix, + ) + raise TiledScreenshotBudgetExceededError( + f"Tiled screenshot budget of " + f"{wait_budget_seconds:.1f}s exhausted " + f"after {len(screenshot_tiles)}/{num_tiles} tiles" + ) for i in range(num_tiles): + # Check the time budget before starting this tile's readiness wait. + # If it's already exhausted, we can no longer verify this (or any + # later) tile is actually ready to capture -- fail loudly instead + # of silently snapshotting a spinner or blank chart, or running + # past the Celery task time limit and getting SIGKILLed. + elapsed = time.monotonic() - start_time + remaining_budget = wait_budget_seconds - elapsed + _raise_if_budget_exhausted(elapsed, remaining_budget) + # Calculate scroll position to show this tile's content scroll_y = dashboard_top + (i * tile_height) @@ -332,17 +405,31 @@ def take_tiled_screenshot( ) # Wait for scroll to settle and content to load page.wait_for_timeout(SCROLL_SETTLE_TIMEOUT_MS) + + # Recompute the remaining budget after the scroll-settle sleep -- + # which itself consumes real wall-clock time -- rather than + # reusing the value from before it, so the readiness-check + # timeout below is capped against a fresh number instead of a + # stale one that would let each tile overrun the budget by up + # to one settle interval. + tile_wait_start = time.monotonic() + elapsed = tile_wait_start - start_time + remaining_budget = wait_budget_seconds - elapsed + _raise_if_budget_exhausted(elapsed, remaining_budget) + # Wait for every chart holder visible in the current viewport to reach - # a terminal state (rendered chart or error/empty state). Only check + # a terminal state (rendered chart or error/empty state), capped at + # whatever remains of the total time budget so a slow dashboard + # degrades gracefully instead of exceeding it. Only check # viewport-visible chart holders to avoid blocking on virtualization # placeholders rendered for off-screen charts. A holder that hasn't # mounted anything yet does not satisfy this check -- unlike checking # for the absence of `.loading`, which passes vacuously in that case. - tile_wait_start = time.monotonic() + tile_load_wait = min(load_wait, remaining_budget) try: page.wait_for_function( CHART_HOLDERS_READY_JS, - timeout=load_wait * 1000, + timeout=tile_load_wait * 1000, ) except PlaywrightTimeout: elapsed = time.monotonic() - tile_wait_start @@ -354,14 +441,21 @@ def take_tiled_screenshot( # made the same call for the other screenshot timeout paths. logger.warning( "Timed out after %.2fs waiting for %s chart container(s) to " - "become ready on tile %s/%s (load_wait=%ss)%s; unready chart " - "holders (chart id, state): %s. Aborting tiled screenshot " - "rather than capturing a blank or partially-loaded tile.", + "become ready on tile %s/%s (waited %.1fs of a %ss requested " + "load_wait; %.1fs elapsed of a %.1fs total budget; %s/%s " + "tiles captured so far)%s; unready chart holders (chart id, " + "state): %s. Aborting tiled screenshot rather than capturing " + "a blank or partially-loaded tile.", elapsed, len(unready_chart_holders), i + 1, num_tiles, + tile_load_wait, load_wait, + time.monotonic() - start_time, + wait_budget_seconds, + len(screenshot_tiles), + num_tiles, context_suffix, unready_chart_holders, ) @@ -377,12 +471,36 @@ def take_tiled_screenshot( load_wait, context_suffix, ) + readiness_wait_elapsed = time.monotonic() - tile_wait_start # Wait for chart animations (e.g. ECharts) to finish after spinner clears. # The global animation wait before tiling only covers the first tile; - # subsequent tiles need their own wait after data loads. + # subsequent tiles need their own wait after data loads. Capped at + # whatever remains of the budget; unlike the readiness wait above this + # is cosmetic settling, not a readiness check, so we simply skip it + # (rather than raise) once the budget runs out. + animation_wait_elapsed = 0.0 if animation_wait > 0: - page.wait_for_timeout(animation_wait * 1000) + elapsed = time.monotonic() - start_time + remaining_budget = wait_budget_seconds - elapsed + tile_animation_wait = max(0, min(animation_wait, remaining_budget)) + if tile_animation_wait > 0: + animation_wait_start = time.monotonic() + page.wait_for_timeout(tile_animation_wait * 1000) + animation_wait_elapsed = time.monotonic() - animation_wait_start + + # Per-tile timing breakdown so slow dashboards can be profiled from + # logs alone. DEBUG rather than INFO: this fires once per tile, and + # large dashboards can have dozens of tiles per report run. + logger.debug( + "Tile %s/%s timing: %.2fs waiting for chart readiness, " + "%.2fs waiting for animations.%s", + i + 1, + num_tiles, + readiness_wait_elapsed, + animation_wait_elapsed, + context_suffix, + ) # Calculate what portion of the element we want to capture for this tile tile_start_in_element = i * tile_height @@ -431,6 +549,12 @@ def take_tiled_screenshot( return combined_screenshot + except TiledScreenshotBudgetExceededError: + # Budget exhaustion must fail cleanly, not be swallowed into the + # generic `return None` degradation below -- the raise carries the + # budget diagnostics to the caller, which fails the capture loudly + # (#42273) instead of receiving an anonymous empty result. + raise except Exception as e: if readiness_timeout: # Let the per-tile readiness timeout propagate so the caller diff --git a/superset/utils/screenshots.py b/superset/utils/screenshots.py index 750ec54b597..32b7f2bda23 100644 --- a/superset/utils/screenshots.py +++ b/superset/utils/screenshots.py @@ -85,6 +85,26 @@ class ScreenshotCachePayloadType(TypedDict): status: str +# Magic bytes for a cheap image sanity check. This is intentionally not a full +# decode: it's meant to catch 0-byte/corrupt/blank payloads before they're +# cached or served, not to validate the image is renderable. +PNG_MAGIC_BYTES = b"\x89PNG\r\n\x1a\n" +JPEG_MAGIC_BYTES = b"\xff\xd8\xff" + + +def validate_screenshot_image(image: bytes | None) -> str | None: + """Cheaply validate screenshot bytes before they're cached or served. + + :return: None if the bytes look like a usable image, otherwise a short + reason ("empty" or "undecodable") suitable for logging. + """ + if not image: + return "empty" + if not image.startswith((PNG_MAGIC_BYTES, JPEG_MAGIC_BYTES)): + return "undecodable" + return None + + class ScreenshotCachePayload: def __init__( self, @@ -147,6 +167,13 @@ class ScreenshotCachePayload: def get_status(self) -> str: return self.status.value + def get_invalid_image_reason(self) -> str | None: + """Reason this payload's image should not be served/cached, or None if + it passes validation (or it isn't claiming a successful screenshot).""" + if self.status != StatusValues.UPDATED: + return None + return validate_screenshot_image(self._image) + def is_error_cache_ttl_expired(self) -> bool: error_cache_ttl = app.config["THUMBNAIL_ERROR_CACHE_TTL"] return ( @@ -263,6 +290,14 @@ class BaseScreenshot: elif isinstance(payload, dict): payload = cast(ScreenshotCachePayloadType, payload) payload = ScreenshotCachePayload.from_dict(payload) + if invalid_reason := payload.get_invalid_image_reason(): + logger.warning( + "Rejecting cached screenshot for %s: %s image payload; " + "treating as a cache miss", + cache_key, + invalid_reason, + ) + return None return payload logger.info("Failed at getting from cache: %s", cache_key) return None @@ -331,15 +366,28 @@ class BaseScreenshot: image = None # Cache the result (success or error) to avoid immediate retries - if image: + invalid_reason = validate_screenshot_image(image) + # `image and` is redundant at runtime (validate_screenshot_image + # only returns None for truthy, well-formed bytes) but mypy can't + # infer that image is non-None from invalid_reason being None + # across the function-call boundary, so it's kept for narrowing. + if image and invalid_reason is None: with event_logger.log_context( f"screenshot.cache.{self.thumbnail_type}" ): cache_payload.update(image) - elif cache_payload.status != StatusValues.ERROR: - # Only call error() if not already set — avoids overwriting - # the timestamp recorded when the actual failure occurred above. - cache_payload.error() + else: + if invalid_reason: + logger.warning( + "Not caching screenshot result for %s: %s image payload", + cache_key, + invalid_reason, + ) + if cache_payload.status != StatusValues.ERROR: + # Only call error() if not already set — avoids overwriting + # the timestamp recorded when the actual failure occurred + # above. + cache_payload.error() logger.info("Caching thumbnail: %s", cache_key) self.cache.set(cache_key, cache_payload.to_dict()) diff --git a/superset/utils/webdriver.py b/superset/utils/webdriver.py index 5fd7d94418a..4b8c1837275 100644 --- a/superset/utils/webdriver.py +++ b/superset/utils/webdriver.py @@ -47,6 +47,7 @@ from superset.utils.screenshot_utils import ( CHART_HOLDERS_READY_JS, FIND_CHART_HOLDER_STATES_JS, resolve_screenshot_task_budget_seconds, + ScreenshotTaskBudgetExceededError, take_tiled_screenshot, ) @@ -61,10 +62,6 @@ PLAYWRIGHT_INSTALL_MESSAGE = ( ) -class ScreenshotTaskBudgetExceededError(RuntimeError): - """Raised when no safe task budget remains before screenshot capture.""" - - if TYPE_CHECKING: from typing import Any @@ -482,16 +479,31 @@ class WebDriverPlaywright(WebDriverProxy): logger.exception("Timed out requesting url %s", url) raise + slice_container_elems: list[Locator] = [] + rendered_chart_count = 0 try: # chart containers didn't render logger.debug("Wait for chart containers to draw at url: %s", url) slice_container_locator = page.locator(".chart-container") - for slice_container_elem in slice_container_locator.all(): + # One-time snapshot: containers mounting after this point + # are neither waited on nor counted, so the progress + # numbers below describe the snapshot, not the final DOM. + slice_container_elems = slice_container_locator.all() + for slice_container_elem in slice_container_elems: slice_container_elem.wait_for() + rendered_chart_count += 1 except PlaywrightTimeout: - logger.exception( - "Timed out waiting for chart containers to draw at url %s", + # Customer-side chart loading is often just slow, not a + # Superset bug, so this is a WARNING (matching the other + # locate-wait timeouts below) rather than an ERROR -- but + # it still fails the screenshot; see the `raise` below. + logger.warning( + "Timed out waiting for chart containers to draw at url %s " + "(%s of %s chart containers rendered before the timeout)", url, + rendered_chart_count, + len(slice_container_elems), + exc_info=True, ) raise selenium_animation_wait = app.config[ @@ -529,19 +541,38 @@ class WebDriverPlaywright(WebDriverProxy): "SCREENSHOT_TILED_VIEWPORT_HEIGHT", viewport_height ) - if dashboard_height == 0: - logger.warning( + # A height of 0 means the DOM query above found no matching + # element (or it hadn't laid out yet), not that the + # dashboard is actually empty. Treat it as "unknown" rather + # than "fits in a single tile": chart_count alone already + # tells us whether this looks like a large dashboard, and + # that signal must not be silently vetoed just because we + # couldn't measure height, or a large dashboard could skip + # tiling and ship with unrendered below-the-fold charts. + height_unknown = dashboard_height == 0 + likely_large_dashboard = ( + chart_count >= chart_threshold + or dashboard_height > height_threshold + ) + if height_unknown: + log_fn = ( + logger.warning if likely_large_dashboard else logger.debug + ) + log_fn( "Could not determine dashboard height for element %s " - "at url %s; falling back to standard screenshot behavior", + "at url %s (%s chart containers found); %s", element_name, url, + chart_count, + "attempting tiled screenshot anyway" + if likely_large_dashboard + else "falling back to standard screenshot behavior", ) # Use tiled screenshots for large dashboards - use_tiled = ( - chart_count >= chart_threshold - or dashboard_height > height_threshold - ) and dashboard_height > tile_height + use_tiled = likely_large_dashboard and ( + height_unknown or dashboard_height > tile_height + ) if use_tiled: logger.info( @@ -563,18 +594,23 @@ class WebDriverPlaywright(WebDriverProxy): log_context=log_context, ) if not img: + # _get_screenshot() has no wait/readiness logic at + # all, so falling back to it here would risk + # silently delivering a screenshot of spinners or + # a blank dashboard. Fail the capture loudly + # (report error, thumbnail cache ERROR) instead of + # guessing at a "safer" fallback. logger.warning( - ( - "Tiled screenshot failed, " - "falling back to standard screenshot" - ) + "Tiled screenshot failed for url %s and no " + "safe fallback exists; failing the capture", + url, ) - img = WebDriverPlaywright._get_screenshot( - page, element, element_name + raise PlaywrightTimeout( + f"Tiled screenshot failed for url {url}" ) logger.debug( "Tiled screenshot result: %d bytes for url: %s", - len(img) if img else 0, + len(img), url, ) else: diff --git a/superset/views/base.py b/superset/views/base.py index 3bc7b92cc3a..d08a14d34d1 100644 --- a/superset/views/base.py +++ b/superset/views/base.py @@ -66,7 +66,7 @@ from superset.themes.types import Theme, ThemeMode from superset.themes.utils import ( is_valid_theme, ) -from superset.translations.utils import get_language_pack +from superset.translations.utils import get_language_pack_version from superset.utils import core as utils, json from superset.utils.filters import get_dataset_access_filters from superset.utils.version import get_version_metadata, visible_version_metadata @@ -611,25 +611,54 @@ def common_bootstrap_payload() -> dict[str, Any]: # Convert locale to string for proper cache key hashing locale_str = str(locale) if locale else None payload = dict(cached_common_bootstrap_data(utils.get_user_id(), locale_str)) - # Inject the Jed language pack outside the per-user memoize so the cached - # payload stays small and the pack is shared across users for the same - # locale. The frontend uses it to configure the translator synchronously, - # before any code-split chunk evaluates a module-level `const X = t('...')` - # (upstream issue #35330). - language = payload.get("locale") - if language and language != "en": - # Respect a pack already provided via COMMON_BOOTSTRAP_OVERRIDES_FUNC - # (the workaround in #35330 does exactly that), otherwise load the - # shared one. `get_language_pack` returns the empty English pack on a - # miss, which is the right result (English) when no translation file - # exists. - pack = payload.get("language_pack") or get_language_pack(language) - else: - pack = None - payload["language_pack"] = pack + # The language pack itself is NOT embedded in the payload: spa.html loads + # it through the content-addressed /language_pack///script.js + # tag before the entry bundle, keeping HTML small while still configuring + # the translator synchronously (upstream issue #35330). A pack provided via + # COMMON_BOOTSTRAP_OVERRIDES_FUNC (the historical workaround) is respected + # and takes precedence over the script tag. + payload.setdefault("language_pack", None) return payload +def get_language_pack_template_context(common: dict[str, Any]) -> dict[str, Any]: + """Template vars controlling how spa.html delivers the language pack. + + ``common`` is the already-built common bootstrap payload for the request + (passed in rather than re-derived, so callers that assembled or mocked + their own payload stay consistent with what the template sees). + + Three mutually exclusive outcomes: + - English (or no locale): no script tag, nothing to stash. + - Operator supplied a pack via COMMON_BOOTSTRAP_OVERRIDES_FUNC: no script + tag; spa.html stashes the bootstrap pack on window instead. + - Otherwise: emit the content-addressed script URL so the browser can + cache the pack as immutable and cache-bust on translation changes. + """ + language = common.get("locale") + if not language or language == "en": + return {"language_pack_src": None, "language_pack_inline": False} + # Truthiness (not `is not None`) is intentional: an empty `{}` override + # is not a usable pack (the JS-side stash treats `{}` as truthy and would + # crash the translator), so it falls through to the versioned script tag + # like "no override" would. + if common.get("language_pack"): + return {"language_pack_src": None, "language_pack_inline": True} + version = get_language_pack_version(language) + return { + "language_pack_src": ( + url_for( + "Superset.language_pack_script", + lang=language, + version=version, + ) + if version + else None + ), + "language_pack_inline": False, + } + + def get_spa_payload(extra_data: dict[str, Any] | None = None) -> dict[str, Any]: """Generate standardized payload for spa.html template rendering. @@ -755,6 +784,7 @@ def get_spa_template_context( "dark_theme_bg": dark_theme_bg, "spinner_svg": spinner_svg, "default_title": default_title, + **get_language_pack_template_context(payload.get("common") or {}), **template_kwargs, } diff --git a/superset/views/core.py b/superset/views/core.py index eccfd8b98e1..029513ce790 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -22,7 +22,7 @@ import logging import os import re from datetime import datetime -from typing import Any, Callable, cast +from typing import Any, Callable, cast, Optional from urllib import parse from flask import ( @@ -76,6 +76,7 @@ from superset.superset_typing import ( FlaskResponse, ) from superset.tasks.utils import get_current_user +from superset.translations.utils import get_language_pack, get_language_pack_version from superset.utils import core as utils, json from superset.utils.core import ( DatasourceType, @@ -112,6 +113,13 @@ PARAMETER_MISSING_ERR = __( SqlResults = dict[str, Any] +# Matches the locale codes Superset actually ships: a 2-3 letter language +# subtag, optionally followed by a 2-letter region subtag ("pt_BR") or a +# 4-letter script subtag ("sr_Latn"). +LANGUAGE_CODE_RE: re.Pattern[str] = re.compile( + r"^[a-z]{2,3}(_[A-Z]{2}|_[A-Z][a-z]{3})?$" +) + class Superset(BaseSupersetView): """The base views for Superset!""" @@ -678,7 +686,7 @@ class Superset(BaseSupersetView): @expose("/language_pack//") def language_pack(self, lang: str) -> FlaskResponse: # Only allow expected language formats like "en", "pt_BR", etc. - if not re.match(r"^[a-z]{2,3}(_[A-Z]{2})?$", lang): + if not LANGUAGE_CODE_RE.match(lang): abort(400, "Invalid language code") base_dir = os.path.join(os.path.dirname(__file__), "..", "translations") @@ -691,6 +699,52 @@ class Superset(BaseSupersetView): "Language pack doesn't exist on the server", status=404 ) + @expose("/language_pack///script.js") + def language_pack_script(self, lang: str, version: str) -> FlaskResponse: + """Serve the language pack as a content-addressed classic script. + + spa.html loads this BEFORE the entry bundle so translations are + configured synchronously (no race with code-split chunks), while the + versioned URL lets browsers cache the pack as immutable and pick up a + fresh copy whenever translations change. + + Deliberately unauthenticated: translation catalogs are static, public + content shipped in the Superset repo, contain no user or tenant data, + and must load for anonymous principals (login page, embedded). + """ + # Only allow expected language formats like "en", "pt_BR", etc. + if not LANGUAGE_CODE_RE.match(lang): + abort(400, "Invalid language code") + if not re.match(r"^[0-9a-f]{12}$", version): + abort(400, "Invalid language pack version") + + current_version: Optional[str] = get_language_pack_version(lang) + if current_version is None: + return json_error_response( + "Language pack doesn't exist on the server", status=404 + ) + pack: Optional[dict[str, Any]] = get_language_pack(lang) + if pack is None: + return json_error_response( + "Language pack doesn't exist on the server", status=404 + ) + + response: Response = Response( + f"window.__SUPERSET_LANGUAGE_PACK__ = {json.dumps(pack)};", + mimetype="application/javascript; charset=utf-8", + ) + if version == current_version: + # Content-addressed URL: safe to cache forever. + response.cache_control.public = True + response.cache_control.max_age = 31536000 + response.cache_control.immutable = True + else: + # Stale or unknown version (e.g. HTML rendered before an upgrade): + # serve the current pack but keep caches from pinning it under the + # wrong address. + response.cache_control.no_cache = True + return response + @event_logger.log_this @expose("/welcome/") def welcome(self) -> FlaskResponse: diff --git a/superset/views/datasource/views.py b/superset/views/datasource/views.py index 9d983265de9..7cb96074f3e 100644 --- a/superset/views/datasource/views.py +++ b/superset/views/datasource/views.py @@ -202,6 +202,23 @@ class Datasource(BaseSupersetView): payload = SamplesPayloadSchema().load(request.json) except ValidationError as err: return json_error_response(err.messages, status=400) + + # Refuse early for datasource types that don't model raw rows + # (e.g. semantic views, which only expose pre-defined metrics and + # dimensions). Without this gate the request would still go through + # the standard query pipeline and fail with an opaque 500. + # ``supports_samples`` defaults to True for any datasource class that + # doesn't explicitly opt out, so SqlaTable/Query/SavedQuery continue + # to work without needing the attribute declared on each class. + ds_class = DatasourceDAO.sources.get( + DatasourceType(params["datasource_type"]), + ) + if ds_class is not None and not getattr(ds_class, "supports_samples", True): + return json_error_response( + _("Samples are not available for this datasource type."), + status=400, + ) + dashboard_id = None if security_manager.is_guest_user(): if not params["dashboard_id"]: diff --git a/tests/integration_tests/charts/version_restore_tests.py b/tests/integration_tests/charts/version_restore_tests.py index 3c60bfbb173..46a6772c3ea 100644 --- a/tests/integration_tests/charts/version_restore_tests.py +++ b/tests/integration_tests/charts/version_restore_tests.py @@ -224,6 +224,7 @@ class TestChartRestoreApi(SupersetTestCase): chart_id = chart.id chart_uuid = str(chart.uuid) entity_uuid = chart.uuid + assert entity_uuid is not None original_name = chart.slice_name original_created_by = chart.created_by_fk before_changed_on = chart.changed_on @@ -329,6 +330,8 @@ class TestChartRestoreApi(SupersetTestCase): assert alpha not in chart.editors ver_cls = version_class(Slice) + entity_uuid = chart.uuid + assert entity_uuid is not None first_tx = ( db.session.query(ver_cls.transaction_id) .filter(ver_cls.id == chart.id) @@ -337,7 +340,7 @@ class TestChartRestoreApi(SupersetTestCase): .scalar() ) assert first_tx is not None - target_uuid = str(derive_version_uuid(chart.uuid, first_tx)) + target_uuid = str(derive_version_uuid(entity_uuid, first_tx)) self.login(ALPHA_USERNAME) rv = self._restore(str(chart.uuid), target_uuid) @@ -387,6 +390,8 @@ class TestChartRestoreApi(SupersetTestCase): assert boys is not None ver_cls = version_class(Slice) + boys_uuid = boys.uuid + assert boys_uuid is not None boys_tx = ( db.session.query(ver_cls.transaction_id) .filter(ver_cls.id == boys.id) @@ -395,7 +400,7 @@ class TestChartRestoreApi(SupersetTestCase): .scalar() ) assert boys_tx is not None - boys_version_uuid = str(derive_version_uuid(boys.uuid, boys_tx)) + boys_version_uuid = str(derive_version_uuid(boys_uuid, boys_tx)) self.login(ADMIN_USERNAME) rv = self._restore(str(girls.uuid), boys_version_uuid) @@ -421,6 +426,8 @@ class TestChartRestoreApi(SupersetTestCase): db.session.commit() ver_cls = version_class(Slice) + entity_uuid = chart.uuid + assert entity_uuid is not None first_tx = ( db.session.query(ver_cls.transaction_id) .filter(ver_cls.id == chart_id) @@ -428,7 +435,7 @@ class TestChartRestoreApi(SupersetTestCase): .limit(1) .scalar() ) - target_uuid = str(derive_version_uuid(chart.uuid, first_tx)) + target_uuid = str(derive_version_uuid(entity_uuid, first_tx)) self.login(ADMIN_USERNAME) rv = self._restore(str(chart.uuid), target_uuid) diff --git a/tests/integration_tests/dashboards/version_restore_tests.py b/tests/integration_tests/dashboards/version_restore_tests.py index 93bc71fdef6..78c57e87f9d 100644 --- a/tests/integration_tests/dashboards/version_restore_tests.py +++ b/tests/integration_tests/dashboards/version_restore_tests.py @@ -90,6 +90,7 @@ class TestDashboardRestoreApi(SupersetTestCase): original_title = dashboard.dashboard_title dashboard_id = dashboard.id entity_uuid = dashboard.uuid + assert entity_uuid is not None # Make two more edits so we have a known non-trivial history to # navigate: [initial, v1, v2]. @@ -151,6 +152,7 @@ class TestDashboardRestoreApi(SupersetTestCase): dashboard_uuid = str(dashboard.uuid) dashboard_id = dashboard.id entity_uuid = dashboard.uuid + assert entity_uuid is not None original_slice_ids = sorted(s.id for s in dashboard.slices) assert len(original_slice_ids) >= 2, ( @@ -225,6 +227,8 @@ class TestDashboardRestoreApi(SupersetTestCase): db.session.commit() ver_cls = version_class(Dashboard) + entity_uuid = dashboard.uuid + assert entity_uuid is not None target_tx = ( db.session.query(ver_cls.transaction_id) .filter(ver_cls.id == dashboard_id) @@ -232,7 +236,7 @@ class TestDashboardRestoreApi(SupersetTestCase): .limit(1) .scalar() ) - target_uuid = str(derive_version_uuid(dashboard.uuid, target_tx)) + target_uuid = str(derive_version_uuid(entity_uuid, target_tx)) # Edit the member chart AFTER the snapshot. member = db.session.query(Slice).filter(Slice.id == member_id).one() @@ -282,6 +286,8 @@ class TestDashboardRestoreApi(SupersetTestCase): db.session.commit() ver_cls = version_class(Dashboard) + entity_uuid = dashboard.uuid + assert entity_uuid is not None target_tx = ( db.session.query(ver_cls.transaction_id) .filter(ver_cls.id == dashboard_id) @@ -289,7 +295,7 @@ class TestDashboardRestoreApi(SupersetTestCase): .limit(1) .scalar() ) - target_uuid = str(derive_version_uuid(dashboard.uuid, target_tx)) + target_uuid = str(derive_version_uuid(entity_uuid, target_tx)) # Detach, then hard-delete the victim via raw SQL so no live row # remains (bypasses the soft-delete listener deliberately — the @@ -340,6 +346,8 @@ class TestDashboardRestoreApi(SupersetTestCase): assert alpha not in dashboard.editors ver_cls = version_class(Dashboard) + entity_uuid = dashboard.uuid + assert entity_uuid is not None first_tx = ( db.session.query(ver_cls.transaction_id) .filter(ver_cls.id == dashboard.id) @@ -348,7 +356,7 @@ class TestDashboardRestoreApi(SupersetTestCase): .scalar() ) assert first_tx is not None - target_uuid = str(derive_version_uuid(dashboard.uuid, first_tx)) + target_uuid = str(derive_version_uuid(entity_uuid, first_tx)) self.login(ALPHA_USERNAME) rv = self._restore(str(dashboard.uuid), target_uuid) diff --git a/tests/integration_tests/datasets/version_restore_tests.py b/tests/integration_tests/datasets/version_restore_tests.py index f8e82baddae..236de2f5098 100644 --- a/tests/integration_tests/datasets/version_restore_tests.py +++ b/tests/integration_tests/datasets/version_restore_tests.py @@ -109,6 +109,7 @@ class TestDatasetRestoreApi(SupersetTestCase): assert table is not None table_uuid = str(table.uuid) entity_uuid = table.uuid + assert entity_uuid is not None table_id = table.id original_description = table.description @@ -164,6 +165,7 @@ class TestDatasetRestoreApi(SupersetTestCase): assert table is not None table_uuid = str(table.uuid) entity_uuid = table.uuid + assert entity_uuid is not None table_id = table.id col = table.columns[0] @@ -219,6 +221,7 @@ class TestDatasetRestoreApi(SupersetTestCase): table_id = table.id table_uuid = str(table.uuid) entity_uuid = table.uuid + assert entity_uuid is not None original_col_names = sorted(c.column_name for c in table.columns) removed_name = table.columns[0].column_name @@ -285,6 +288,7 @@ class TestDatasetRestoreApi(SupersetTestCase): table_id = table.id table_uuid = str(table.uuid) entity_uuid = table.uuid + assert entity_uuid is not None removed_name = table.columns[0].column_name added_name = "__restore_full_diff_test__" @@ -399,6 +403,7 @@ class TestDatasetRestoreApi(SupersetTestCase): table_id = table.id table_uuid = str(table.uuid) entity_uuid = table.uuid + assert entity_uuid is not None original_description = table.description original_col_names = sorted(c.column_name for c in table.columns) diff --git a/tests/integration_tests/security_tests.py b/tests/integration_tests/security_tests.py index a9211bb2460..75b7e90582c 100644 --- a/tests/integration_tests/security_tests.py +++ b/tests/integration_tests/security_tests.py @@ -1733,6 +1733,10 @@ class TestRolePermission(SupersetTestCase): # Serves the PWA web app manifest unauthenticated (PWA install # fetches have no session); mirrors the RedirectView precedent. ["PwaManifestView", "manifest"], + # Serves translation catalogs (static public repo content, no + # user/tenant data) as content-addressed scripts; must load for + # anonymous principals (login page, embedded dashboards). + ["Superset", "language_pack_script"], ] unsecured_views = [] for view_class in appbuilder.baseviews: diff --git a/tests/unit_tests/commands/report/base_test.py b/tests/unit_tests/commands/report/base_test.py index ec8c6973406..93055e38a32 100644 --- a/tests/unit_tests/commands/report/base_test.py +++ b/tests/unit_tests/commands/report/base_test.py @@ -26,7 +26,10 @@ from unittest.mock import patch import pytest from superset.commands.report.base import BaseReportScheduleCommand -from superset.commands.report.exceptions import ReportScheduleFrequencyNotAllowed +from superset.commands.report.exceptions import ( + ReportScheduleCrontabNotValidError, + ReportScheduleFrequencyNotAllowed, +) from superset.reports.models import ReportScheduleType REPORT_TYPES = { @@ -174,6 +177,28 @@ def test_validate_report_frequency_report_only(schedule: str) -> None: ) +@pytest.mark.parametrize("report_type", REPORT_TYPES) +@app_custom_config( + alert_minimum_interval=int(timedelta(minutes=5).total_seconds()), + report_minimum_interval=int(timedelta(minutes=5).total_seconds()), +) +def test_validate_report_frequency_never_matching_crontab(report_type: str) -> None: + """ + Test the ``validate_report_frequency`` method with a crontab that is + syntactically valid but never matches a real calendar date (Feb 30th). + + Such schedules pass ``croniter.is_valid()`` (purely syntactic) and thus + marshmallow schema validation, but raise ``CroniterBadDateError`` when + iterated. This should surface as a ``ValidationError`` rather than + propagating the raw croniter exception. + """ + with pytest.raises(ReportScheduleCrontabNotValidError): + BaseReportScheduleCommand().validate_report_frequency( + "0 0 30 2 *", + report_type, + ) + + @pytest.mark.parametrize("report_type", REPORT_TYPES) @pytest.mark.parametrize("schedule", TEST_SCHEDULES) @app_custom_config( diff --git a/tests/unit_tests/common/test_query_actions_drill_detail.py b/tests/unit_tests/common/test_query_actions_drill_detail.py new file mode 100644 index 00000000000..0bd95aa8225 --- /dev/null +++ b/tests/unit_tests/common/test_query_actions_drill_detail.py @@ -0,0 +1,70 @@ +# 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. +from unittest.mock import MagicMock, patch + +import pytest + +from superset.common.query_actions import _get_drill_detail +from superset.exceptions import QueryObjectValidationError + + +def test_get_drill_detail_refuses_datasource_that_opts_out() -> None: + """ + A datasource with ``supports_drill_to_detail = False`` (e.g. semantic + views) must be hard-blocked on the server. Without this gate the request + would fall through to ``_get_full`` and fail with an opaque error, and + the flag would only be enforced by the frontend menu — leaving the + chart-data API endpoint accepting drill-detail requests it shouldn't. + """ + datasource = MagicMock() + datasource.supports_drill_to_detail = False + + query_obj = MagicMock() + query_obj.datasource = datasource + + query_context = MagicMock() + + with pytest.raises( + QueryObjectValidationError, + match="Drill to detail is not available", + ): + _get_drill_detail(query_context, query_obj) + + +def test_get_drill_detail_allows_datasource_without_flag() -> None: + """ + Datasources that don't declare the flag (e.g. legacy ``SqlaTable`` + subclasses via ``getattr`` default) must continue to work — the gate + only fires when the flag is explicitly ``False``. + """ + datasource = MagicMock(spec=["columns"]) + column = MagicMock() + column.column_name = "id" + datasource.columns = [column] + + query_obj = MagicMock() + query_obj.datasource = datasource + query_obj.columns = [] + + query_context = MagicMock() + + expected_payload: dict[str, list[dict[str, str]]] = {"data": []} + with patch( + "superset.common.query_actions._get_full", return_value=expected_payload + ) as mock_get_full: + assert _get_drill_detail(query_context, query_obj) is expected_payload + mock_get_full.assert_called_once() diff --git a/tests/unit_tests/common/test_query_context_processor.py b/tests/unit_tests/common/test_query_context_processor.py index a2f259dd627..88c786ed1da 100644 --- a/tests/unit_tests/common/test_query_context_processor.py +++ b/tests/unit_tests/common/test_query_context_processor.py @@ -2256,3 +2256,106 @@ def test_grouping_sets_fallback_applies_row_offset_once_globally() -> None: # ...and the requested offset is applied exactly once, to the combined # result: 2 + 1 = 3 total rows in, minus an offset of 1 = 2 rows out. assert len(result.df) == 2 + + +def test_relative_offset_preserves_inner_bounds( + processor: QueryContextProcessor, +) -> None: + """ + Regression test for #40501: Relative time comparison offset should + preserve inner bounds as the original (unshifted) period, not the shifted one. + + When comparing 2026-05-01 : 2026-05-28 with offset "365 days ago": + - inner_from_dttm should be 2026-05-01 (original), NOT 2025-05-01 (shifted) + - inner_to_dttm should be 2026-05-28 (original), NOT 2025-05-28 (shifted) + """ + from superset.common.query_object import QueryObject + from superset.models.helpers import ExploreMixin + + datasource: Any = processor._qc_datasource + + for method in ( + "processing_time_offsets", + "_align_offset_without_time_grain", + "_coalesce_offset_index", + ): + setattr( + datasource, + method, + getattr(ExploreMixin, method).__get__(datasource), + ) + + df = pd.DataFrame( + { + "__timestamp": pd.to_datetime(["2026-05-01", "2026-05-15", "2026-05-28"]), + "sum__num": [100, 200, 300], + } + ) + + query_object = QueryObject( + datasource=MagicMock(), + granularity="ds", + columns=[], + metrics=["sum__num"], + is_timeseries=True, + time_offsets=["365 days ago"], + filters=[ + { + "col": "ds", + "op": "TEMPORAL_RANGE", + "val": "2026-05-01 : 2026-05-28", + } + ], + ) + + captured: list[dict[str, Any]] = [] + + def fake_query(dct: dict[str, Any]) -> MagicMock: + captured.append(dct) + result = MagicMock() + result.df = pd.DataFrame( + { + "__timestamp": pd.date_range( + start=dct["from_dttm"], periods=3, freq="14D" + ), + "sum__num": [1.0, 2.0, 3.0], + } + ) + result.query = "SELECT 1" + return result + + datasource.query = fake_query + datasource.normalize_df = MagicMock( + side_effect=lambda offset_df, _query_object: offset_df + ) + + with ( + patch( + "superset.models.helpers.get_since_until_from_query_object", + return_value=(pd.Timestamp("2026-05-01"), pd.Timestamp("2026-05-28")), + ), + patch( + "superset.common.utils.query_cache_manager.QueryCacheManager" + ) as mock_cache_manager, + patch.object( + datasource, + "get_time_grain", + return_value=None, + ), + ): + mock_cache = MagicMock() + mock_cache.is_loaded = False + mock_cache_manager.get.return_value = mock_cache + + datasource.processing_time_offsets(df, query_object, None, None, False) + + # The offset query should use shifted dates for the main window + assert len(captured) == 1 + assert captured[0]["from_dttm"] == pd.Timestamp("2025-05-01") + assert captured[0]["to_dttm"] == pd.Timestamp("2025-05-28") + + # The inner bounds (used for series-limit subquery) should be the + # ORIGINAL unshifted dates, not the shifted ones — this is the fix + # for #40501. Without the fix, inner_from/to_dttm == shifted dates. + assert captured[0]["inner_from_dttm"] == pd.Timestamp("2026-05-01") + assert captured[0]["inner_to_dttm"] == pd.Timestamp("2026-05-28") diff --git a/tests/unit_tests/db_engine_specs/test_db2.py b/tests/unit_tests/db_engine_specs/test_db2.py index 6f469fb7a20..2d2421449b1 100644 --- a/tests/unit_tests/db_engine_specs/test_db2.py +++ b/tests/unit_tests/db_engine_specs/test_db2.py @@ -39,13 +39,16 @@ def test_epoch_to_dttm() -> None: def test_get_table_comment(mocker: MockerFixture): """ Test the `get_table_comment` method. + + ibm_db_sa >= 0.4.1 returns the comment as a plain string (fixed in + https://github.com/ibmdb/python-ibmdbsa/pull/135), not a tuple as it + used to. Indexing into that string with `comment[0]` truncates every + DB2 table comment to its first character; this guards against that. """ from superset.db_engine_specs.db2 import Db2EngineSpec mock_inspector = mocker.MagicMock() - mock_inspector.get_table_comment.return_value = { - "text": ("This is a table comment",) - } + mock_inspector.get_table_comment.return_value = {"text": "This is a table comment"} assert ( Db2EngineSpec.get_table_comment(mock_inspector, Table("my_table", "my_schema")) @@ -69,6 +72,22 @@ def test_get_table_comment_empty(mocker: MockerFixture): ) +def test_get_table_comment_unexpected_error(mocker: MockerFixture): + """ + Test that `get_table_comment` returns `None` instead of raising + when the inspector call fails unexpectedly. + """ + from superset.db_engine_specs.db2 import Db2EngineSpec + + mock_inspector = mocker.MagicMock() + mock_inspector.get_table_comment.side_effect = Exception("boom") + + assert ( + Db2EngineSpec.get_table_comment(mock_inspector, Table("my_table", "my_schema")) + is None + ) + + def test_get_prequeries(mocker: MockerFixture) -> None: """ Test the ``get_prequeries`` method. diff --git a/tests/unit_tests/db_engine_specs/test_postgres.py b/tests/unit_tests/db_engine_specs/test_postgres.py index 287fcec38a3..c1840d9fd71 100644 --- a/tests/unit_tests/db_engine_specs/test_postgres.py +++ b/tests/unit_tests/db_engine_specs/test_postgres.py @@ -450,3 +450,33 @@ def test_interval_type_mutator() -> None: assert mutator(True) is None assert mutator([1, 2, 3]) is None assert mutator({"days": 1}) is None + + +def test_get_schema_names_excludes_only_actual_system_schemas( + mocker: MockerFixture, +) -> None: + """ + DB Eng Specs (postgres): Test ``get_schema_names`` + + User-defined schemas that merely start with ``pg`` (but are not + actual Postgres system schemas, which always start with the literal + ``pg_``) must not be filtered out. See issue #30678. + """ + inspector = mocker.MagicMock() + inspector.engine.connect().__enter__().execute.return_value = [ + ("public",), + ("pgsql",), + ("pgstats",), + ("pg_catalog",), + ("pg_toast",), + ("information_schema",), + ] + + schemas = spec.get_schema_names(inspector) + + assert schemas == { + "public", + "pgsql", + "pgstats", + "information_schema", + } diff --git a/tests/unit_tests/mcp_service/chart/test_big_number_chart.py b/tests/unit_tests/mcp_service/chart/test_big_number_chart.py index cbf255ac784..88a11b9e0b3 100644 --- a/tests/unit_tests/mcp_service/chart/test_big_number_chart.py +++ b/tests/unit_tests/mcp_service/chart/test_big_number_chart.py @@ -21,6 +21,7 @@ from unittest.mock import MagicMock, patch import pytest from pydantic import ValidationError +from sqlalchemy.exc import SQLAlchemyError from superset.mcp_service.chart.chart_utils import ( _resolve_viz_type, @@ -388,13 +389,13 @@ class TestMapBigNumberConfig: mock_find_by_id_or_uuid.assert_called_once_with("42") @patch("superset.daos.dataset.DatasetDAO.find_by_id_or_uuid") - def test_total_no_dataset_main_dttm_col_skips_temporal_filter( + def test_total_without_temporal_columns_skips_temporal_filter( self, mock_find_by_id_or_uuid: MagicMock ) -> None: - """When the dataset has no temporal column, no TEMPORAL_RANGE filter - can be added — there's nothing for a dashboard filter to bind to.""" + """A dataset without temporal columns has nothing to bind to.""" mock_dataset = MagicMock() mock_dataset.main_dttm_col = None + mock_dataset.columns = [] mock_find_by_id_or_uuid.return_value = mock_dataset config = BigNumberChartConfig( @@ -405,6 +406,84 @@ class TestMapBigNumberConfig: assert "adhoc_filters" not in form_data + @patch("superset.daos.dataset.DatasetDAO.find_by_id_or_uuid") + def test_total_falls_back_to_first_temporal_column_without_main_dttm_col( + self, mock_find_by_id_or_uuid: MagicMock + ) -> None: + """Match Explore when temporal columns exist but no main one is set.""" + non_temporal_column = MagicMock( + column_name="revenue", + is_dttm=False, + ) + temporal_column = MagicMock( + column_name="order_date", + is_dttm=True, + type=None, + ) + mock_dataset = MagicMock( + main_dttm_col=None, + columns=[non_temporal_column, temporal_column], + ) + mock_find_by_id_or_uuid.return_value = mock_dataset + + config = BigNumberChartConfig( + chart_type="big_number", + metric=ColumnRef(name="revenue", aggregate="SUM"), + ) + form_data = map_big_number_config(config, dataset_id=42) + + assert form_data["adhoc_filters"][0]["subject"] == "order_date" + assert form_data["adhoc_filters"][0]["operator"] == "TEMPORAL_RANGE" + mock_find_by_id_or_uuid.assert_called_once_with("42") + + @patch("superset.mcp_service.chart.chart_utils.is_column_truly_temporal") + @patch("superset.daos.dataset.DatasetDAO.find_by_id_or_uuid") + def test_total_accepts_native_temporal_column_without_is_dttm( + self, + mock_find_by_id_or_uuid: MagicMock, + mock_is_temporal: MagicMock, + ) -> None: + """Use backend type classification instead of relying only on is_dttm.""" + mock_dataset = MagicMock( + main_dttm_col=None, + columns=[ + MagicMock(column_name="revenue", is_dttm=False), + MagicMock(column_name="order_date", is_dttm=False), + ], + ) + mock_find_by_id_or_uuid.return_value = mock_dataset + mock_is_temporal.side_effect = lambda column, *_args, **_kwargs: ( + column == "order_date" + ) + + config = BigNumberChartConfig( + chart_type="big_number", + metric=ColumnRef(name="revenue", aggregate="SUM"), + ) + form_data = map_big_number_config(config, dataset_id=42) + + assert form_data["adhoc_filters"][0]["subject"] == "order_date" + assert [call.args[0] for call in mock_is_temporal.call_args_list] == [ + "revenue", + "order_date", + ] + + @patch("superset.daos.dataset.DatasetDAO.find_by_id_or_uuid") + def test_total_ignores_optional_temporal_binding_on_dataset_lookup_failure( + self, mock_find_by_id_or_uuid: MagicMock + ) -> None: + """A metadata failure must not make optional time binding abort mapping.""" + mock_find_by_id_or_uuid.side_effect = SQLAlchemyError("metadata unavailable") + config = BigNumberChartConfig( + chart_type="big_number", + metric=ColumnRef(name="revenue", aggregate="SUM"), + ) + + form_data = map_big_number_config(config, dataset_id=42) + + assert form_data["viz_type"] == "big_number_total" + assert "adhoc_filters" not in form_data + @patch("superset.mcp_service.chart.chart_utils.is_column_truly_temporal") def test_total_non_temporal_column_skips_temporal_filter( self, mock_is_temporal: MagicMock diff --git a/tests/unit_tests/mcp_service/chart/test_chart_utils.py b/tests/unit_tests/mcp_service/chart/test_chart_utils.py index 256ee7032c4..b9113296143 100644 --- a/tests/unit_tests/mcp_service/chart/test_chart_utils.py +++ b/tests/unit_tests/mcp_service/chart/test_chart_utils.py @@ -1945,9 +1945,8 @@ class TestValidateChartDataset: def test_validate_chart_dataset_no_datasource_id( self, mock_find: MagicMock, mock_access: MagicMock ) -> None: - """Chart with no datasource_id returns invalid result.""" - chart = MagicMock(spec=[]) # no datasource_id attribute - result = validate_chart_dataset(chart) + """A chart with no datasource_id returns invalid result.""" + result = validate_chart_dataset(None) assert not result.is_valid assert result.dataset_id is None assert "no dataset reference" in (result.error or "").lower() @@ -1959,9 +1958,7 @@ class TestValidateChartDataset: self, mock_find: MagicMock, mock_access: MagicMock ) -> None: """Chart whose dataset was deleted returns invalid result.""" - chart = MagicMock() - chart.datasource_id = 42 - result = validate_chart_dataset(chart) + result = validate_chart_dataset(42) assert not result.is_valid assert result.dataset_id == 42 assert "deleted" in (result.error or "").lower() @@ -1976,9 +1973,7 @@ class TestValidateChartDataset: dataset.table_name = "my_table" dataset.sql = None mock_find.return_value = dataset - chart = MagicMock() - chart.datasource_id = 7 - result = validate_chart_dataset(chart) + result = validate_chart_dataset(7) assert result.is_valid assert result.dataset_id == 7 assert result.dataset_name == "my_table" @@ -1994,9 +1989,7 @@ class TestValidateChartDataset: dataset.table_name = "virt_ds" dataset.sql = "SELECT 1" mock_find.return_value = dataset - chart = MagicMock() - chart.datasource_id = 10 - result = validate_chart_dataset(chart) + result = validate_chart_dataset(10) assert result.is_valid assert len(result.warnings) == 1 assert "virtual" in result.warnings[0].lower() @@ -2010,9 +2003,7 @@ class TestValidateChartDataset: from sqlalchemy.exc import SQLAlchemyError mock_find.side_effect = SQLAlchemyError("connection lost") - chart = MagicMock() - chart.datasource_id = 99 - result = validate_chart_dataset(chart) + result = validate_chart_dataset(99) assert not result.is_valid assert result.dataset_id == 99 assert "error" in (result.error or "").lower() diff --git a/tests/unit_tests/mcp_service/chart/tool/test_generate_chart.py b/tests/unit_tests/mcp_service/chart/tool/test_generate_chart.py index 52e59d62597..bc23ae9b5a5 100644 --- a/tests/unit_tests/mcp_service/chart/tool/test_generate_chart.py +++ b/tests/unit_tests/mcp_service/chart/tool/test_generate_chart.py @@ -19,9 +19,11 @@ Unit tests for MCP generate_chart tool """ +from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest +from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm.exc import DetachedInstanceError from superset.mcp_service.chart.schemas import ( @@ -438,6 +440,154 @@ def _make_mock_chart(chart_id: int = 42) -> Mock: return chart +class _DetachableSlice: + """A Slice stand-in that starts attached and can be detached at will. + + Once detached, every attribute read raises ``DetachedInstanceError``, which + is what SQLAlchemy does when a concurrent request tears down the session + this instance was loaded in. + """ + + def __init__(self, chart_id: int = 42) -> None: + self._values = { + "id": chart_id, + "slice_name": "Concurrent chart", + "viz_type": "table", + "uuid": "2a0e0e0e-0000-4000-8000-000000000042", + "datasource_id": 1, + } + self._detached = False + + def detach(self) -> None: + self._detached = True + + def __getattr__(self, name: str) -> Any: + if self._detached: + raise DetachedInstanceError( + f"Instance is not bound to a Session; " + f"attribute refresh operation cannot proceed ({name})" + ) + try: + return self._values[name] + except KeyError as ex: + raise AttributeError(name) from ex + + +async def _generate_saved_chart( + refetch: Any, +) -> tuple[Any, _DetachableSlice]: + """Run generate_chart(save_chart=True) with a chart that detaches on commit. + + ``refetch`` is used as the ``ChartDAO.find_by_id`` behaviour of the + serialization path. + """ + request = GenerateChartRequest( + dataset_id="1", + config=TableChartConfig(chart_type="table", columns=[ColumnRef(name="region")]), + save_chart=True, + generate_preview=False, + ) + ctx = MagicMock() + ctx.info = AsyncMock() + ctx.debug = AsyncMock() + ctx.warning = AsyncMock() + ctx.error = AsyncMock() + ctx.report_progress = AsyncMock() + + chart = _DetachableSlice() + dataset = Mock( + id=1, datasource_name="test_table", table_name="test_table", sql=None + ) + validation_result = Mock(is_valid=True, request=request, warnings={}, error=None) + session = MagicMock() + # The instance is detached right after the commit, before any of the reads + # that build the response. + session.refresh.side_effect = lambda _chart: chart.detach() + + with ( + patch( + "superset.mcp_service.auth.get_user_from_request", + return_value=Mock(id=1, username="admin", roles=[], groups=[]), + ), + patch( + "superset.mcp_service.chart.validation.ValidationPipeline." + "validate_request_with_warnings", + return_value=validation_result, + ), + patch("superset.daos.dataset.DatasetDAO.find_by_id", return_value=dataset), + patch( + "superset.mcp_service.chart.tool.generate_chart.has_dataset_access", + return_value=True, + ), + # validate_chart_dataset is deliberately not mocked: it runs for real + # against the detached instance, which is where it used to raise. + patch("superset.mcp_service.auth.has_dataset_access", return_value=True), + patch( + "superset.commands.chart.create.CreateChartCommand", + return_value=Mock(run=Mock(return_value=chart)), + ), + patch("superset.db.session", session), + patch( + "superset.mcp_service.chart.tool.generate_chart._compile_chart", + return_value=CompileResult(success=True, warnings=[]), + ), + patch("superset.daos.chart.ChartDAO", Mock(find_by_id=refetch)), + patch( + "superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand", + return_value=Mock(run=Mock(return_value="form-data-key")), + ), + patch( + "superset.mcp_service.chart.tool.generate_chart.get_superset_base_url", + return_value="http://localhost:8088", + ), + ): + result = await generate_chart(request, ctx=ctx) + + return result, chart + + +class TestGenerateChartDetachedInstance: + """The committed chart must be reported even if its instance is detached. + + Regression tests for https://github.com/apache/superset/issues/42567: under + concurrency the chart was written to the database and the tool still + returned an error, because the response was built by reading attributes off + an instance another request had detached. + """ + + @pytest.mark.asyncio + async def test_detached_chart_is_reported_as_created(self) -> None: + """A detached instance no longer turns a committed chart into an error.""" + refetched = _make_mock_chart() + + result, chart = await _generate_saved_chart( + refetch=Mock(return_value=refetched) + ) + + assert chart._detached is True + assert result.success is True + assert result.error is None + assert result.chart is not None + assert result.chart.id == 42 + assert result.explore_url == "http://localhost:8088/explore/?slice_id=42" + assert result.api_endpoints["data"].endswith("/api/v1/chart/42/data/") + + @pytest.mark.asyncio + async def test_detached_chart_falls_back_to_captured_scalars(self) -> None: + """The minimal fallback response never reads the detached instance.""" + result, chart = await _generate_saved_chart( + refetch=Mock(side_effect=SQLAlchemyError("session is gone")) + ) + + assert chart._detached is True + assert result.success is True + assert result.error is None + assert result.chart is not None + assert result.chart.id == 42 + assert result.chart.slice_name == "Concurrent chart" + assert result.chart.viz_type == "table" + + class TestChartSerializationEagerLoading: """Tests for eager loading fix in generate_chart serialization path.""" diff --git a/tests/unit_tests/mcp_service/test_middleware.py b/tests/unit_tests/mcp_service/test_middleware.py index 62248710911..3f0957fd9b8 100644 --- a/tests/unit_tests/mcp_service/test_middleware.py +++ b/tests/unit_tests/mcp_service/test_middleware.py @@ -95,7 +95,7 @@ class TestResponseSizeGuardMiddleware: # Create mock context context = MagicMock() context.message.name = "list_charts" - context.message.params = {} + context.message.arguments = {} # Create mock call_next that returns small response small_response = {"charts": [{"id": 1, "name": "test"}]} @@ -118,7 +118,7 @@ class TestResponseSizeGuardMiddleware: # Create mock context context = MagicMock() context.message.name = "list_charts" - context.message.params = {"page_size": 100} + context.message.arguments = {"page_size": 100} # Create large response large_response = { @@ -148,7 +148,7 @@ class TestResponseSizeGuardMiddleware: # Create mock context for excluded tool context = MagicMock() context.message.name = "health_check" - context.message.params = {} + context.message.arguments = {} # Create response that would exceed limit large_response = {"data": "x" * 10000} @@ -173,7 +173,7 @@ class TestResponseSizeGuardMiddleware: context = MagicMock() context.message.name = "list_charts" - context.message.params = {} + context.message.arguments = {} response = {"data": "approaching the limit"} call_next = AsyncMock(return_value=response) @@ -201,7 +201,7 @@ class TestResponseSizeGuardMiddleware: context = MagicMock() context.message.name = "list_charts" - context.message.params = {"page_size": 100} + context.message.arguments = {"page_size": 100} large_response = {"charts": [{"id": i} for i in range(1000)]} call_next = AsyncMock(return_value=large_response) @@ -226,7 +226,7 @@ class TestResponseSizeGuardMiddleware: context = MagicMock() context.message.name = "list_charts" - context.message.params = {} + context.message.arguments = {} large_response = {"data": "x" * 10000} call_next = AsyncMock(return_value=large_response) @@ -250,7 +250,7 @@ class TestResponseSizeGuardMiddleware: context = MagicMock() context.message.name = "get_dataset_info" - context.message.params = {} + context.message.arguments = {} # Large info tool response with a big description large_response = { @@ -279,7 +279,7 @@ class TestResponseSizeGuardMiddleware: context = MagicMock() context.message.name = "get_chart_info" - context.message.params = {} + context.message.arguments = {} large_response = { "id": 1, @@ -305,7 +305,7 @@ class TestResponseSizeGuardMiddleware: context = MagicMock() context.message.name = "list_charts" # Not an info tool - context.message.params = {} + context.message.arguments = {} large_response = {"data": "x" * 10000} call_next = AsyncMock(return_value=large_response) @@ -324,7 +324,7 @@ class TestResponseSizeGuardMiddleware: context = MagicMock() context.message.name = "get_dashboard_info" - context.message.params = {} + context.message.arguments = {} large_response = { "id": 1, @@ -357,7 +357,7 @@ class TestResponseSizeGuardMiddleware: context = MagicMock() context.message.name = "get_dashboard_info" - context.message.params = {} + context.message.arguments = {} large_response = { "id": 1, @@ -387,7 +387,7 @@ class TestResponseSizeGuardMiddleware: context = MagicMock() context.message.name = "execute_sql" - context.message.params = {} + context.message.arguments = {} row = {f"col_{i}": f"value_{i}" for i in range(10)} large_response = { @@ -417,7 +417,7 @@ class TestResponseSizeGuardMiddleware: context = MagicMock() context.message.name = "query_dataset" - context.message.params = {} + context.message.arguments = {} row = {f"col_{i}": f"value_{i}" for i in range(10)} large_response = { @@ -446,7 +446,7 @@ class TestResponseSizeGuardMiddleware: context = MagicMock() context.message.name = "get_chart_data" - context.message.params = {} + context.message.arguments = {} row = {f"col_{i}": f"value_{i}" for i in range(10)} large_response = { @@ -476,7 +476,7 @@ class TestResponseSizeGuardMiddleware: context = MagicMock() context.message.name = "execute_sql" - context.message.params = {} + context.message.arguments = {} row = {f"col_{i}": f"value_{i}" for i in range(10)} large_response = { @@ -502,7 +502,7 @@ class TestResponseSizeGuardMiddleware: context = MagicMock() context.message.name = "execute_sql" - context.message.params = {} + context.message.arguments = {} row = {f"col_{i}": f"value_{i}" for i in range(10)} large_response = { @@ -529,7 +529,7 @@ class TestResponseSizeGuardMiddleware: context = MagicMock() context.message.name = "execute_sql" - context.message.params = {} + context.message.arguments = {} row = {f"col_{i}": f"value_{i}" for i in range(10)} large_response = { @@ -557,7 +557,7 @@ class TestResponseSizeGuardMiddleware: context = MagicMock() context.message.name = "get_chart_data" - context.message.params = {} + context.message.arguments = {} large_response: dict[str, Any] = { "chart_id": 1, @@ -595,7 +595,7 @@ class TestResponseSizeGuardMiddleware: context = MagicMock() context.message.name = "execute_sql" - context.message.params = {} + context.message.arguments = {} huge_row = {"col": "x" * 5000} large_response = { @@ -619,7 +619,7 @@ class TestResponseSizeGuardMiddleware: context = MagicMock() context.message.name = "execute_sql" - context.message.params = {} + context.message.arguments = {} small_response = { "status": "success", @@ -949,7 +949,7 @@ class TestToolResultWrapping: middleware = ResponseSizeGuardMiddleware(token_limit=500) context = MagicMock() context.message.name = "get_dataset_info" - context.message.params = {} + context.message.arguments = {} large_payload = {"id": 1, "table_name": "test", "description": "x" * 50000} tool_result = self._make_tool_result(large_payload) @@ -975,7 +975,7 @@ class TestToolResultWrapping: middleware = ResponseSizeGuardMiddleware(token_limit=25000) context = MagicMock() context.message.name = "get_chart_info" - context.message.params = {} + context.message.arguments = {} small_payload = {"id": 1, "name": "My Chart"} tool_result = self._make_tool_result(small_payload) @@ -995,7 +995,7 @@ class TestToolResultWrapping: middleware = ResponseSizeGuardMiddleware(token_limit=100) context = MagicMock() context.message.name = "list_charts" - context.message.params = {} + context.message.arguments = {} large_payload = { "charts": [{"id": i, "name": f"chart_{i}"} for i in range(500)] @@ -1026,7 +1026,7 @@ class TestToolResultWrapping: middleware = ResponseSizeGuardMiddleware(token_limit=500) context = MagicMock() context.message.name = "execute_sql" - context.message.params = {} + context.message.arguments = {} row = {f"col_{i}": f"value_{i}" for i in range(10)} large_payload = { @@ -1058,7 +1058,7 @@ class TestToolResultWrapping: middleware = ResponseSizeGuardMiddleware(token_limit=500) context = MagicMock() context.message.name = "get_dashboard_info" - context.message.params = {} + context.message.arguments = {} meta = {"request_id": "abc-123"} large_payload = {"id": 1, "title": "My Dashboard", "description": "x" * 50000} @@ -1093,7 +1093,7 @@ class TestMiddlewareIntegration: context = MagicMock() context.message.name = "get_chart_info" - context.message.params = {} + context.message.arguments = {} response = ChartInfo(id=1, name="Test Chart") call_next = AsyncMock(return_value=response) @@ -1113,7 +1113,7 @@ class TestMiddlewareIntegration: context = MagicMock() context.message.name = "list_charts" - context.message.params = {} + context.message.arguments = {} response = [{"id": 1}, {"id": 2}, {"id": 3}] call_next = AsyncMock(return_value=response) @@ -1133,7 +1133,7 @@ class TestMiddlewareIntegration: context = MagicMock() context.message.name = "health_check" - context.message.params = {} + context.message.arguments = {} response = "OK" call_next = AsyncMock(return_value=response) diff --git a/tests/unit_tests/mcp_service/test_middleware_logging.py b/tests/unit_tests/mcp_service/test_middleware_logging.py index d838a478a1d..eed0334648e 100644 --- a/tests/unit_tests/mcp_service/test_middleware_logging.py +++ b/tests/unit_tests/mcp_service/test_middleware_logging.py @@ -49,7 +49,7 @@ def _make_context( ctx.method = method message = MagicMock() message.name = name - message.params = params or {} + message.arguments = params or {} ctx.message = message if metadata is not None: ctx.metadata = metadata @@ -503,6 +503,33 @@ class TestExtractContextInfo: assert slice_id == 66 + @patch("superset.mcp_service.middleware.get_user_id", return_value=1) + def test_extract_reads_arguments_on_real_call_tool_request_params( + self, mock_get_user_id + ) -> None: + """Regression test: the real MCP ``CallToolRequestParams`` object + exposes tool arguments as ``.arguments``, not ``.params`` -- a + ``MagicMock``-based context would auto-vivify a ``.params`` + attribute and hide a mismatch. Using the real SDK type here + ensures params/dashboard_id/etc. are actually populated instead + of silently logging as empty.""" + middleware = LoggingMiddleware() + message = mt.CallToolRequestParams( + name="get_dashboard_info", + arguments={"dashboard_id": 7}, + ) + ctx = MagicMock() + ctx.message = message + ctx.metadata = None + ctx.session = None + + agent_id, user_id, dashboard_id, slice_id, dataset_id, params = ( + middleware._extract_context_info(ctx) + ) + + assert params == {"dashboard_id": 7} + assert dashboard_id == 7 + class TestIsErrorResponse: """Tests for LoggingMiddleware._is_error_response().""" diff --git a/tests/unit_tests/models/helpers_test.py b/tests/unit_tests/models/helpers_test.py index 579dd1beb0c..747527536f7 100644 --- a/tests/unit_tests/models/helpers_test.py +++ b/tests/unit_tests/models/helpers_test.py @@ -2405,6 +2405,76 @@ def test_get_sqla_query_allows_jinja_templated_custom_sql_metric_with_columns( assert "{{" not in sql +def test_get_sqla_query_virtual_dataset_filter_values_drill_to_detail( + database: Database, +) -> None: + """ + Regression for #35263: a Jinja-templated virtual dataset that calls + ``filter_values()`` in its own SQL must see filters sent in the native + ``{col, op, val}`` format that Drill to Detail/Drill by use, not just + the ``adhoc_filters`` format used by ordinary chart/explore requests. + Without this, Jinja-based datasets return zero rows when drilled into, + even though the parent chart shows data for the selected value. + """ + from superset.connectors.sqla.models import SqlaTable, TableColumn + + table = SqlaTable( + database=database, + schema=None, + table_name="t", + sql=( + "SELECT a, b FROM t WHERE 1=1 " + "{% if filter_values('b') %} " + "AND b IN {{ filter_values('b') | where_in }} " + "{% endif %}" + ), + columns=[ + TableColumn(column_name="a", type="INTEGER"), + TableColumn(column_name="b", type="TEXT"), + ], + ) + + result = table.get_sqla_query( + columns=["a", "b"], + metrics=[], + extras={}, + filter=[{"col": "b", "op": "IN", "val": ["Alice"]}], + granularity=None, + is_timeseries=False, + ) + assert result is not None + + with database.get_sqla_engine() as engine: + sql = str( + result.sqla_query.compile( + dialect=engine.dialect, + compile_kwargs={"literal_binds": True}, + ) + ) + + assert "'Alice'" in sql, ( + "filter_values() should resolve native drill-to-detail-style " + f"filters inside a virtual dataset's own SQL. Generated SQL: {sql}" + ) + + # The assertion above can pass even when filter_values() itself is + # broken, because get_sqla_query() independently applies the native + # filter as an outer WHERE predicate on top of whatever the virtual + # dataset's own SQL renders to. Pull the virtual dataset's own rendered + # SQL directly out of the compiled query (rather than re-rendering it + # via a separately constructed template processor, which would not + # catch get_sqla_query() failing to forward the filter to the template + # processor it builds internally) to confirm filter_values() actually + # resolved the native filter *inside* the templated subquery. + virtual_table_from = result.sqla_query.get_final_froms()[0] + rendered_inner_sql = virtual_table_from.element.element.text + assert "'Alice'" in rendered_inner_sql, ( + "filter_values() should render the native drill-to-detail-style " + "filter directly into the virtual dataset's own templated SQL, " + f"not just the outer query. Rendered SQL: {rendered_inner_sql}" + ) + + def test_extras_where_is_parenthesized( database: Database, ) -> None: diff --git a/tests/unit_tests/semantic_layers/models_test.py b/tests/unit_tests/semantic_layers/models_test.py index af13d6d11e7..6400676f8b1 100644 --- a/tests/unit_tests/semantic_layers/models_test.py +++ b/tests/unit_tests/semantic_layers/models_test.py @@ -653,6 +653,15 @@ def test_semantic_view_data( assert data["table_name"] == "Orders View" assert data["datasource_name"] == "Orders View" assert data["offset"] == 0 + # Semantic views don't model raw rows, so neither samples nor + # drill-to-detail are available. + assert data["supports_samples"] is False + assert data["supports_drill_to_detail"] is False + + +def test_semantic_view_supports_samples_is_false() -> None: + """The class-level flag opts SemanticView out of the Samples affordance.""" + assert SemanticView.supports_samples is False @pytest.fixture @@ -767,6 +776,11 @@ def test_semantic_view_data_populates_time_grain_sqla( assert grain_durations == sorted(["PT1H", "P1D", "P1M"]) +def test_semantic_view_supports_drill_to_detail_is_false() -> None: + """The class-level flag opts SemanticView out of Drill to detail.""" + assert SemanticView.supports_drill_to_detail is False + + def test_semantic_view_get_query_result( mock_implementation: MagicMock, ) -> None: diff --git a/tests/unit_tests/tasks/test_utils.py b/tests/unit_tests/tasks/test_utils.py index 1abab022293..2c423e4f3fd 100644 --- a/tests/unit_tests/tasks/test_utils.py +++ b/tests/unit_tests/tasks/test_utils.py @@ -42,11 +42,11 @@ FIXED_USER_ID = 1234 FIXED_USERNAME = "admin" -def _make_user_subject(user_id: int) -> MagicMock: +def _make_user_subject(user_id: int, active: bool = True) -> MagicMock: """Create a mock user-type Subject with an underlying User.""" from superset.subjects.types import SubjectType - user = User(id=user_id, username=str(user_id)) + user = User(id=user_id, username=str(user_id), active=active) subject = MagicMock() subject.id = user_id # deterministic subject ID subject.type = SubjectType.USER @@ -83,12 +83,13 @@ def _make_group_subject(group_id: int) -> MagicMock: def _get_users( params: Optional[Union[int, list[int]]], + active: bool = True, ) -> Optional[Union[User, list[User]]]: if params is None: return None if isinstance(params, int): - return User(id=params, username=str(params)) - return [User(id=user, username=str(user)) for user in params] + return User(id=params, username=str(params), active=active) + return [User(id=user, username=str(user), active=active) for user in params] @dataclass @@ -98,10 +99,14 @@ class EditorSpec: user_ids: list[int] role_ids: list[int] | None = None group_ids: list[int] | None = None + # Direct user-type editors that should be built as inactive users + inactive_user_ids: list[int] | None = None def build(self) -> list[MagicMock]: editors: list[MagicMock] = [] editors.extend(_make_user_subject(uid) for uid in self.user_ids) + for uid in self.inactive_user_ids or []: + editors.append(_make_user_subject(uid, active=False)) for rid in self.role_ids or []: editors.append(_make_role_subject(rid)) for gid in self.group_ids or []: @@ -114,6 +119,8 @@ class ModelConfig: editors: EditorSpec creator: Optional[int] = None modifier: Optional[int] = None + creator_active: bool = True + modifier_active: bool = True # Maps user_id → role_ids the user belongs to (for indirect editor resolution) user_roles: dict[int, list[int]] = field(default_factory=dict) # Maps user_id → group_ids the user belongs to (for indirect editor resolution) @@ -530,6 +537,105 @@ class ModelType(int, Enum): None, ExecutorNotFoundError(), ), + # CREATOR: an inactive creator is skipped (no other executor configured) + ( + ModelType.REPORT_SCHEDULE, + [ExecutorType.CREATOR], + ModelConfig( + editors=EditorSpec(user_ids=[]), + creator=3, + creator_active=False, + ), + None, + ExecutorNotFoundError(), + ), + # CREATOR: an inactive creator is skipped, falling through to an active + # MODIFIER later in the executor chain. + ( + ModelType.REPORT_SCHEDULE, + [ExecutorType.CREATOR, ExecutorType.MODIFIER], + ModelConfig( + editors=EditorSpec(user_ids=[]), + creator=3, + creator_active=False, + modifier=4, + ), + None, + (ExecutorType.MODIFIER, 4), + ), + # CREATOR_EDITOR: creator is an editor but inactive → not resolved + ( + ModelType.REPORT_SCHEDULE, + [ExecutorType.CREATOR_EDITOR], + ModelConfig( + editors=EditorSpec(user_ids=[4]), + creator=4, + creator_active=False, + ), + None, + ExecutorNotFoundError(), + ), + # MODIFIER_EDITOR: modifier is an editor but inactive → not resolved + ( + ModelType.REPORT_SCHEDULE, + [ExecutorType.MODIFIER_EDITOR], + ModelConfig( + editors=EditorSpec(user_ids=[4]), + modifier=4, + modifier_active=False, + ), + None, + ExecutorNotFoundError(), + ), + # EDITOR: creator (the only editor) is inactive, so scheduling falls + # through to the still-active direct-user editor instead of failing. + # This is the scenario from #33584: the original report creator has + # gone inactive but a currently-active owner/editor should still be + # usable as the executor. + ( + ModelType.REPORT_SCHEDULE, + [ExecutorType.EDITOR], + ModelConfig( + editors=EditorSpec(user_ids=[2], inactive_user_ids=[3]), + creator=3, + creator_active=False, + ), + None, + (ExecutorType.EDITOR, 2), + ), + # EDITOR: both modifier and creator are inactive editors, and the only + # direct-user editor is also inactive → falls through to the indirect + # (role/group) editor resolution. + ( + ModelType.REPORT_SCHEDULE, + [ExecutorType.EDITOR], + ModelConfig( + editors=EditorSpec( + user_ids=[], inactive_user_ids=[3, 4], role_ids=[10] + ), + creator=3, + creator_active=False, + modifier=4, + modifier_active=False, + user_roles={6: [10]}, + ), + None, + (ExecutorType.EDITOR, 6), + ), + # EDITOR: modifier is an inactive editor, creator is an active editor → + # resolves to the creator instead of failing outright. + ( + ModelType.REPORT_SCHEDULE, + [ExecutorType.EDITOR], + ModelConfig( + editors=EditorSpec(user_ids=[3], inactive_user_ids=[4]), + creator=3, + modifier=4, + modifier_active=False, + ), + None, + (ExecutorType.EDITOR, 3), + ), ], ) def test_get_executor( @@ -561,8 +667,10 @@ def test_get_executor( obj = model( id=1, - created_by=_get_users(model_config.creator), - changed_by=_get_users(model_config.modifier), + created_by=_get_users(model_config.creator, active=model_config.creator_active), + changed_by=_get_users( + model_config.modifier, active=model_config.modifier_active + ), **model_kwargs, ) obj.editors = model_config.editors.build() diff --git a/tests/unit_tests/translations/__init__.py b/tests/unit_tests/translations/__init__.py new file mode 100644 index 00000000000..13a83393a91 --- /dev/null +++ b/tests/unit_tests/translations/__init__.py @@ -0,0 +1,16 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. diff --git a/tests/unit_tests/translations/utils_test.py b/tests/unit_tests/translations/utils_test.py new file mode 100644 index 00000000000..9cc84c5e369 --- /dev/null +++ b/tests/unit_tests/translations/utils_test.py @@ -0,0 +1,93 @@ +# 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 hashlib +from collections.abc import Iterator + +import pytest + +from superset.translations import utils as translations_utils +from superset.translations.utils import ( + get_language_pack, + get_language_pack_filename, + get_language_pack_version, +) + + +@pytest.fixture(autouse=True) +def _clear_version_cache() -> Iterator[None]: + translations_utils.ALL_LANGUAGE_PACK_VERSIONS.clear() + yield + translations_utils.ALL_LANGUAGE_PACK_VERSIONS.clear() + + +@pytest.fixture(autouse=True) +def _clear_pack_cache() -> Iterator[None]: + saved = dict(translations_utils.ALL_LANGUAGE_PACKS) + yield + translations_utils.ALL_LANGUAGE_PACKS.clear() + translations_utils.ALL_LANGUAGE_PACKS.update(saved) + + +def test_language_pack_filename_resolution() -> None: + assert get_language_pack_filename("fr").endswith("/fr/LC_MESSAGES/messages.json") + assert get_language_pack_filename("en").endswith("/empty_language_pack.json") + assert get_language_pack_filename("").endswith("/empty_language_pack.json") + + +def test_version_is_short_content_hash(tmp_path, monkeypatch) -> None: + pack_file = tmp_path / "fr" / "LC_MESSAGES" / "messages.json" + pack_file.parent.mkdir(parents=True) + pack_file.write_bytes(b'{"domain": "superset"}') + monkeypatch.setattr(translations_utils, "DIR", str(tmp_path)) + + version = get_language_pack_version("fr") + + expected = hashlib.sha256(b'{"domain": "superset"}').hexdigest()[:12] + assert version == expected + + +def test_version_is_cached_and_changes_with_content(tmp_path, monkeypatch) -> None: + pack_file = tmp_path / "fr" / "LC_MESSAGES" / "messages.json" + pack_file.parent.mkdir(parents=True) + pack_file.write_bytes(b"{}") + monkeypatch.setattr(translations_utils, "DIR", str(tmp_path)) + + first = get_language_pack_version("fr") + pack_file.write_bytes(b'{"changed": true}') + # Cached within the process lifetime: same version until cache cleared. + assert get_language_pack_version("fr") == first + + translations_utils.ALL_LANGUAGE_PACK_VERSIONS.clear() + assert get_language_pack_version("fr") != first + + +def test_version_none_when_pack_missing(tmp_path, monkeypatch) -> None: + monkeypatch.setattr(translations_utils, "DIR", str(tmp_path)) + assert get_language_pack_version("xx") is None + + +def test_pack_none_when_catalog_fails_to_parse(tmp_path, monkeypatch) -> None: + """A corrupt catalog must surface as None, not be silently masked as + the English fallback under the broken locale's cache key (which would + let a caller cache the wrong content as if it were correct).""" + pack_file = tmp_path / "fr" / "LC_MESSAGES" / "messages.json" + pack_file.parent.mkdir(parents=True) + pack_file.write_bytes(b"not valid json") + monkeypatch.setattr(translations_utils, "DIR", str(tmp_path)) + + assert get_language_pack("fr") is None + assert "fr" not in translations_utils.ALL_LANGUAGE_PACKS diff --git a/tests/unit_tests/utils/screenshot_test.py b/tests/unit_tests/utils/screenshot_test.py index b7f7de6032e..c57df50484c 100644 --- a/tests/unit_tests/utils/screenshot_test.py +++ b/tests/unit_tests/utils/screenshot_test.py @@ -33,6 +33,10 @@ from superset.utils.screenshots import ( BASE_SCREENSHOT_PATH = "superset.utils.screenshots.BaseScreenshot" +# A minimal valid PNG header, used wherever a test needs bytes that pass +# ScreenshotCachePayload's image validation. +FAKE_PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"fake-png-body" + class MockCache: """A class to manage screenshot cache.""" @@ -92,7 +96,7 @@ def test_get_cache_key(app_context, screenshot_obj): def test_get_from_cache_key(mocker: MockerFixture, screenshot_obj): """get_from_cache_key should always return a ScreenshotCachePayload Object""" # backwards compatibility test for retrieving plain bytes - fake_bytes = b"fake_screenshot_data" + fake_bytes = FAKE_PNG_BYTES BaseScreenshot.cache = MockCache() BaseScreenshot.cache.set("key", fake_bytes) cache_payload = screenshot_obj.get_from_cache_key("key") @@ -108,10 +112,10 @@ class TestComputeAndCache: BASE_SCREENSHOT_PATH + ".get_from_cache_key", return_value=None ) get_screenshot = mocker.patch( - BASE_SCREENSHOT_PATH + ".get_screenshot", return_value=b"new_image_data" + BASE_SCREENSHOT_PATH + ".get_screenshot", return_value=FAKE_PNG_BYTES ) resize_image = mocker.patch( - BASE_SCREENSHOT_PATH + ".resize_image", return_value=b"resized_image_data" + BASE_SCREENSHOT_PATH + ".resize_image", return_value=FAKE_PNG_BYTES ) BaseScreenshot.cache = MockCache() return { diff --git a/tests/unit_tests/utils/test_screenshot_cache_fix.py b/tests/unit_tests/utils/test_screenshot_cache_fix.py index 4ffe07711c8..133c8c7e4d8 100644 --- a/tests/unit_tests/utils/test_screenshot_cache_fix.py +++ b/tests/unit_tests/utils/test_screenshot_cache_fix.py @@ -37,6 +37,10 @@ from superset.utils.screenshots import ( BASE_SCREENSHOT_PATH = "superset.utils.screenshots.BaseScreenshot" DISTRIBUTED_LOCK_PATH = "superset.utils.screenshots.DistributedLock" +# A minimal valid PNG header, used wherever a test needs bytes that pass +# ScreenshotCachePayload's image validation. +FAKE_PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"fake-png-body" + class MockCache: """A class to manage screenshot cache for testing.""" @@ -83,11 +87,11 @@ class TestCacheOnlyOnSuccess: mocker.patch(DISTRIBUTED_LOCK_PATH) mocker.patch(BASE_SCREENSHOT_PATH + ".get_from_cache_key", return_value=None) get_screenshot = mocker.patch( - BASE_SCREENSHOT_PATH + ".get_screenshot", return_value=b"image_data" + BASE_SCREENSHOT_PATH + ".get_screenshot", return_value=FAKE_PNG_BYTES ) # Mock resize_image to avoid PIL errors with fake image data mocker.patch( - BASE_SCREENSHOT_PATH + ".resize_image", return_value=b"resized_image_data" + BASE_SCREENSHOT_PATH + ".resize_image", return_value=FAKE_PNG_BYTES ) BaseScreenshot.cache = MockCache() return get_screenshot @@ -161,13 +165,15 @@ class TestCacheOnlyOnSuccess: screenshot_obj: BaseScreenshot, mock_user: MagicMock, ) -> None: - """Empty bytes from get_screenshot must set ERROR, not leave COMPUTING.""" + """Empty bytes from get_screenshot must set ERROR, not leave COMPUTING, + and must log a WARNING that includes the cache key.""" mocker.patch(DISTRIBUTED_LOCK_PATH) mocker.patch(BASE_SCREENSHOT_PATH + ".get_from_cache_key", return_value=None) mocker.patch( BASE_SCREENSHOT_PATH + ".get_screenshot", return_value=b"", ) + mock_logger = mocker.patch("superset.utils.screenshots.logger") BaseScreenshot.cache = MockCache() screenshot_obj.compute_and_cache(user=mock_user, force=True) @@ -177,6 +183,43 @@ class TestCacheOnlyOnSuccess: assert cached_value is not None assert cached_value["status"] == "Error" assert cached_value.get("image") is None + assert any( + cache_key in call.args and "empty" in call.args + for call in mock_logger.warning.call_args_list + ) + + def test_cache_error_status_when_screenshot_returns_garbage_bytes( + self, + mocker: MockerFixture, + screenshot_obj: BaseScreenshot, + mock_user: MagicMock, + ) -> None: + """Non-empty bytes without a valid image header must set ERROR, not be + cached as a success, and must log a WARNING that includes the cache key.""" + mocker.patch(DISTRIBUTED_LOCK_PATH) + mocker.patch(BASE_SCREENSHOT_PATH + ".get_from_cache_key", return_value=None) + mocker.patch( + BASE_SCREENSHOT_PATH + ".get_screenshot", + return_value=b"this-is-not-a-real-image", + ) + mocker.patch( + BASE_SCREENSHOT_PATH + ".resize_image", + return_value=b"this-is-not-a-real-image", + ) + mock_logger = mocker.patch("superset.utils.screenshots.logger") + BaseScreenshot.cache = MockCache() + + screenshot_obj.compute_and_cache(user=mock_user, force=True) + + cache_key = screenshot_obj.get_cache_key() + cached_value = BaseScreenshot.cache.get(cache_key) + assert cached_value is not None + assert cached_value["status"] == "Error" + assert cached_value.get("image") is None + assert any( + cache_key in call.args and "undecodable" in call.args + for call in mock_logger.warning.call_args_list + ) def test_computing_status_written_to_cache_early( self, @@ -197,14 +240,14 @@ class TestCacheOnlyOnSuccess: "Cache should be set to COMPUTING before screenshot starts" ) assert cached_value["status"] == "Computing" - return b"image_data" + return FAKE_PNG_BYTES mocker.patch( BASE_SCREENSHOT_PATH + ".get_screenshot", side_effect=check_cache_during_screenshot, ) mocker.patch( - BASE_SCREENSHOT_PATH + ".resize_image", return_value=b"resized_image_data" + BASE_SCREENSHOT_PATH + ".resize_image", return_value=FAKE_PNG_BYTES ) screenshot_obj.compute_and_cache(user=mock_user, force=True) @@ -429,11 +472,11 @@ class TestIntegrationCacheBugFix: BaseScreenshot.cache.set(cache_key, stale_payload.to_dict()) mocker.patch( - BASE_SCREENSHOT_PATH + ".get_screenshot", return_value=b"recovered_image" + BASE_SCREENSHOT_PATH + ".get_screenshot", return_value=FAKE_PNG_BYTES ) # Mock resize to avoid PIL errors mocker.patch( - BASE_SCREENSHOT_PATH + ".resize_image", return_value=b"resized_image" + BASE_SCREENSHOT_PATH + ".resize_image", return_value=FAKE_PNG_BYTES ) # Should trigger task because COMPUTING is stale @@ -482,3 +525,72 @@ class TestIntegrationCacheBugFix: assert payload._image == old_image assert payload.status == StatusValues.COMPUTING + + +class TestReadSideImageValidation: + """A cached payload that claims a successful screenshot (status UPDATED) + but carries invalid image bytes must be served as a cache miss, not + returned to the caller — this is what the dashboard/chart screenshot + endpoints call to fetch bytes to serve.""" + + def test_zero_byte_image_is_treated_as_cache_miss( + self, mocker: MockerFixture, screenshot_obj: BaseScreenshot + ) -> None: + mock_logger = mocker.patch("superset.utils.screenshots.logger") + BaseScreenshot.cache = MockCache() + cache_key = screenshot_obj.get_cache_key() + stale_payload = ScreenshotCachePayload(image=b"", status=StatusValues.UPDATED) + BaseScreenshot.cache.set(cache_key, stale_payload.to_dict()) + + result = screenshot_obj.get_from_cache_key(cache_key) + + assert result is None + assert any( + cache_key in call.args and "empty" in call.args + for call in mock_logger.warning.call_args_list + ) + + def test_garbage_bytes_image_is_treated_as_cache_miss( + self, mocker: MockerFixture, screenshot_obj: BaseScreenshot + ) -> None: + mock_logger = mocker.patch("superset.utils.screenshots.logger") + BaseScreenshot.cache = MockCache() + cache_key = screenshot_obj.get_cache_key() + garbage_payload = ScreenshotCachePayload(image=b"not-an-image-at-all") + BaseScreenshot.cache.set(cache_key, garbage_payload.to_dict()) + + result = screenshot_obj.get_from_cache_key(cache_key) + + assert result is None + assert any( + cache_key in call.args and "undecodable" in call.args + for call in mock_logger.warning.call_args_list + ) + + def test_valid_image_is_served_normally( + self, screenshot_obj: BaseScreenshot + ) -> None: + BaseScreenshot.cache = MockCache() + cache_key = screenshot_obj.get_cache_key() + valid_payload = ScreenshotCachePayload(image=FAKE_PNG_BYTES) + BaseScreenshot.cache.set(cache_key, valid_payload.to_dict()) + + result = screenshot_obj.get_from_cache_key(cache_key) + + assert result is not None + assert result.get_image().read() == FAKE_PNG_BYTES + + def test_pending_status_with_no_image_is_not_rejected( + self, screenshot_obj: BaseScreenshot + ) -> None: + """Non-UPDATED statuses (e.g. PENDING/COMPUTING) aren't claiming a + successful screenshot, so they should be returned as-is.""" + BaseScreenshot.cache = MockCache() + cache_key = screenshot_obj.get_cache_key() + pending_payload = ScreenshotCachePayload(status=StatusValues.PENDING) + BaseScreenshot.cache.set(cache_key, pending_payload.to_dict()) + + result = screenshot_obj.get_from_cache_key(cache_key) + + assert result is not None + assert result.status == StatusValues.PENDING diff --git a/tests/unit_tests/utils/test_screenshot_utils.py b/tests/unit_tests/utils/test_screenshot_utils.py index 43829001596..f5569e8a741 100644 --- a/tests/unit_tests/utils/test_screenshot_utils.py +++ b/tests/unit_tests/utils/test_screenshot_utils.py @@ -25,8 +25,11 @@ from superset.utils.screenshot_utils import ( combine_screenshot_tiles, resolve_screenshot_task_budget_seconds, SCREENSHOT_TASK_BUDGET_MAX_MARGIN_SECONDS, + ScreenshotTaskBudgetExceededError, SCROLL_SETTLE_TIMEOUT_MS, take_tiled_screenshot, + TILED_SCREENSHOT_TOTAL_WAIT_BUDGET_SECONDS, + TiledScreenshotBudgetExceededError, ) @@ -453,12 +456,17 @@ class TestTakeTiledScreenshot: assert warning_args[2] == 1 # count of unready chart containers assert warning_args[3] == 1 # tile index assert warning_args[4] == 3 # total tiles - assert warning_args[5] == 30 # load_wait - assert warning_args[6] == "" # no log_context passed + assert warning_args[5] == 30 # tile_load_wait (uncapped: budget remains) + assert warning_args[6] == 30 # requested load_wait + assert isinstance(warning_args[7], float) # total elapsed vs budget + assert warning_args[8] == 1440 # total budget (fixed fallback) + assert warning_args[9] == 0 # tiles captured so far + assert warning_args[10] == 3 # total tiles + assert warning_args[11] == "" # no log_context passed # Diagnostic payload identifies chart id AND the state it's stuck in # (spinner mounted vs nothing mounted vs waiting-on-database) so a # slow query can be told apart from the virtualization race. - assert warning_args[7] == [{"chartId": "42", "state": "waiting_on_database"}] + assert warning_args[12] == [{"chartId": "42", "state": "waiting_on_database"}] def test_timeout_warning_includes_log_context(self, mock_page): """The log context (e.g. report execution id) is threaded through for @@ -484,7 +492,7 @@ class TestTakeTiledScreenshot: ) warning_args = mock_logger.warning.call_args[0] - assert warning_args[6] == " [execution_id=abc-123]" + assert warning_args[11] == " [execution_id=abc-123]" def test_chart_holder_with_nothing_mounted_blocks_wait(self, mock_page): """Regression test for the vacuous-pass race (PR #39895). @@ -646,3 +654,311 @@ class TestTakeTiledScreenshot: sig = inspect.signature(take_tiled_screenshot) assert sig.parameters["animation_wait"].default == 0 + + +class TestTileWaitBudget: + """The tiled operation's cumulative per-tile waits are capped by one + wall-clock budget derived from the running Celery task's own time limit + (resolve_screenshot_task_budget_seconds), falling back to a fixed total + ceiling outside Celery because per-tile waits accumulate.""" + + @pytest.fixture + def mock_page(self): + """Create a mock Playwright page object for a 3-tile (5000px) dashboard.""" + page = MagicMock() + element = MagicMock() + page.locator.return_value = element + page.evaluate.return_value = { + "height": 5000, + "top": 100, + "left": 50, + "width": 800, + } + page.screenshot.return_value = b"fake_screenshot_data" + return page + + class _FakeClock: + """Stateful monotonic() stand-in the test advances explicitly. + + Robust to how many times the code under test samples the clock per + tile (budget check, per-tile wait timing, animation budget) -- only + explicit advances move time forward. + """ + + def __init__(self) -> None: + self.now = 0.0 + + def __call__(self) -> float: + return self.now + + def test_budget_error_is_task_budget_error_subclass(self): + """Callers can catch the whole budget-error family with the base + ScreenshotTaskBudgetExceededError type.""" + assert issubclass( + TiledScreenshotBudgetExceededError, ScreenshotTaskBudgetExceededError + ) + + def test_per_tile_wait_shrinks_as_budget_depletes(self, mock_page, monkeypatch): + """Each tile's readiness-wait timeout is capped at the remaining budget.""" + monkeypatch.setattr( + "superset.utils.screenshot_utils.TILED_SCREENSHOT_TOTAL_WAIT_BUDGET_SECONDS", # noqa: E501 + 1000, + ) + clock = self._FakeClock() + # Simulate slow tiles: the readiness wait itself consumes wall time, + # so each subsequent tile sees less remaining budget. + wait_durations = iter([950, 40, 5]) + + def slow_wait(*args, **kwargs): + clock.now += next(wait_durations) + + mock_page.wait_for_function.side_effect = slow_wait + + with patch("superset.utils.screenshot_utils.current_task", None): + with patch("superset.utils.screenshot_utils.time.monotonic", new=clock): + with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"): + result = take_tiled_screenshot( + mock_page, "dashboard", tile_height=2000, load_wait=100 + ) + + assert result is not None + timeouts = [ + call[1]["timeout"] for call in mock_page.wait_for_function.call_args_list + ] + # remaining budget at each tile's wait: 1000, 50, 10 seconds + # -> capped timeouts shrink + assert timeouts == [100 * 1000, 50 * 1000, 10 * 1000] + assert timeouts == sorted(timeouts, reverse=True) + + def test_readiness_wait_uses_budget_recomputed_after_scroll_settle( + self, mock_page, monkeypatch + ): + """The readiness-wait timeout must be capped using the budget + recomputed *after* the scroll-settle sleep, not the stale value from + before it -- otherwise each tile could overrun the total budget by up + to one settle interval.""" + monkeypatch.setattr( + "superset.utils.screenshot_utils.TILED_SCREENSHOT_TOTAL_WAIT_BUDGET_SECONDS", # noqa: E501 + 1000, + ) + # A single-tile dashboard to keep the scenario simple. + mock_page.evaluate.return_value = { + "height": 1000, + "top": 100, + "left": 50, + "width": 800, + } + clock = self._FakeClock() + # The scroll-settle sleep itself consumes 950s of wall-clock time, + # leaving only 50s of the 1000s budget by the time the readiness + # wait is capped. + mock_page.wait_for_timeout.side_effect = lambda *args, **kwargs: setattr( + clock, "now", clock.now + 950 + ) + + with patch("superset.utils.screenshot_utils.current_task", None): + with patch("superset.utils.screenshot_utils.time.monotonic", new=clock): + with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"): + take_tiled_screenshot( + mock_page, "dashboard", tile_height=2000, load_wait=999 + ) + + timeout = mock_page.wait_for_function.call_args_list[0][1]["timeout"] + # Must reflect the post-settle remaining budget (50s), not the + # stale pre-settle value (1000s, which would have let load_wait's + # full 999s through uncapped). + assert timeout == 50 * 1000 + + def test_budget_exhausted_raises_and_stops_capturing(self, mock_page, monkeypatch): + """Exhausting the budget aborts cleanly instead of capturing unchecked.""" + monkeypatch.setattr( + "superset.utils.screenshot_utils.TILED_SCREENSHOT_TOTAL_WAIT_BUDGET_SECONDS", # noqa: E501 + 1000, + ) + clock = self._FakeClock() + # Tile 0's readiness wait consumes the whole budget; tile 1's budget + # check then sees remaining <= 0 and raises before capturing. + mock_page.wait_for_function.side_effect = lambda *args, **kwargs: setattr( + clock, "now", 1000.0 + ) + + with patch("superset.utils.screenshot_utils.current_task", None): + with patch("superset.utils.screenshot_utils.time.monotonic", new=clock): + with patch( + "superset.utils.screenshot_utils.combine_screenshot_tiles" + ) as mock_combine: + with patch("superset.utils.screenshot_utils.logger") as mock_logger: + with pytest.raises(TiledScreenshotBudgetExceededError): + take_tiled_screenshot( + mock_page, "dashboard", tile_height=2000, load_wait=100 + ) + + # Only the first tile was captured before the budget ran out. + assert mock_page.screenshot.call_count == 1 + # Tiles were never combined -- the function raised before that point. + mock_combine.assert_not_called() + + # Budget exhaustion is a customer chart-loading issue, not a Superset + # system fault, so it must log at WARNING (not ERROR) -- consistent + # with the #38130/#38441 precedent for screenshot timeout logging. + assert mock_logger.error.call_count == 0 + mock_logger.warning.assert_called_once() + warning_args = mock_logger.warning.call_args[0] + assert "budget exhausted" in warning_args[0] + # tile index, tiles total, tiles captured, tiles total, + # elapsed seconds, budget seconds, log-context suffix + assert warning_args[1] == 2 + assert warning_args[2] == 3 + assert warning_args[3] == 1 + assert warning_args[4] == 3 + assert warning_args[5] == 1000 + assert warning_args[6] == 1000 + assert warning_args[7] == "" + + def test_budget_exhausted_warning_includes_log_context( + self, mock_page, monkeypatch + ): + """log_context (e.g. report execution id) is appended to the warning.""" + monkeypatch.setattr( + "superset.utils.screenshot_utils.TILED_SCREENSHOT_TOTAL_WAIT_BUDGET_SECONDS", # noqa: E501 + 1000, + ) + clock = self._FakeClock() + # Tile 0's readiness wait consumes the whole budget; tile 1's budget + # check then sees remaining <= 0 and raises. + mock_page.wait_for_function.side_effect = lambda *args, **kwargs: setattr( + clock, "now", 1000.0 + ) + with patch("superset.utils.screenshot_utils.current_task", None): + with patch("superset.utils.screenshot_utils.time.monotonic", new=clock): + with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"): + with patch("superset.utils.screenshot_utils.logger") as mock_logger: + with pytest.raises(TiledScreenshotBudgetExceededError): + take_tiled_screenshot( + mock_page, + "dashboard", + tile_height=2000, + load_wait=100, + log_context="execution_id=abc-123", + ) + + warning_args = mock_logger.warning.call_args[0] + assert warning_args[-1] == " [execution_id=abc-123]" + + def test_budget_exhausted_before_first_tile_raises_without_capture( + self, mock_page, monkeypatch + ): + """No budget floor: a budget already exhausted by setup (element + lookup/dimension probing) raises before the first tile is captured, + matching the non-tiled path's raise-before-capture semantics.""" + monkeypatch.setattr( + "superset.utils.screenshot_utils.TILED_SCREENSHOT_TOTAL_WAIT_BUDGET_SECONDS", # noqa: E501 + 1000, + ) + clock = self._FakeClock() + # The dashboard-dimension evaluate() itself consumes the whole budget. + original_return = {"height": 5000, "top": 100, "left": 50, "width": 800} + + def slow_evaluate(*args, **kwargs): + clock.now = 1000.0 + return original_return + + mock_page.evaluate.side_effect = slow_evaluate + + with patch("superset.utils.screenshot_utils.current_task", None): + with patch("superset.utils.screenshot_utils.time.monotonic", new=clock): + with patch( + "superset.utils.screenshot_utils.combine_screenshot_tiles" + ) as mock_combine: + with pytest.raises(TiledScreenshotBudgetExceededError): + take_tiled_screenshot( + mock_page, "dashboard", tile_height=2000, load_wait=100 + ) + + mock_page.screenshot.assert_not_called() + mock_combine.assert_not_called() + + def test_no_celery_context_uses_fixed_total_fallback(self, mock_page): + """Outside Celery the helper returns None; the tiled path must fall + back to the fixed total ceiling rather than running uncapped, because + per-tile waits accumulate across tiles.""" + clock = self._FakeClock() + with patch("superset.utils.screenshot_utils.current_task", None): + with patch("superset.utils.screenshot_utils.time.monotonic", new=clock): + with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"): + take_tiled_screenshot( + mock_page, + "dashboard", + tile_height=2000, + load_wait=10_000, # deliberately above the fallback + ) + + first_timeout = mock_page.wait_for_function.call_args_list[0][1]["timeout"] + assert first_timeout == TILED_SCREENSHOT_TOTAL_WAIT_BUDGET_SECONDS * 1000 + + def test_derived_task_budget_caps_tile_wait(self, mock_page): + """Inside Celery, the tiled path caps waits using the same + task-derived budget as the non-tiled path (helper reuse, #42427).""" + task = MagicMock() + task.request.timelimit = (120, None) # (hard, soft): 120s hard limit + + clock = self._FakeClock() + with patch("superset.utils.screenshot_utils.current_task", task): + with patch("superset.utils.screenshot_utils.time.monotonic", new=clock): + with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"): + take_tiled_screenshot( + mock_page, "dashboard", tile_height=2000, load_wait=200 + ) + + # margin = min(300, 120 * 0.2) = 24; budget = 120 - 24 = 96 + first_timeout = mock_page.wait_for_function.call_args_list[0][1]["timeout"] + assert first_timeout == 96 * 1000 + assert first_timeout < 200 * 1000 + + def test_fast_dashboard_matches_default_behavior(self, mock_page): + """Well under budget, waits are not capped and behavior is unchanged.""" + with patch("superset.utils.screenshot_utils.current_task", None): + with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"): + result = take_tiled_screenshot( + mock_page, + "dashboard", + tile_height=2000, + load_wait=30, + animation_wait=5, + ) + + assert result is not None + assert mock_page.screenshot.call_count == 3 + + for call in mock_page.wait_for_function.call_args_list: + assert call[1]["timeout"] == 30 * 1000 + + animation_calls = [ + call + for call in mock_page.wait_for_timeout.call_args_list + if call[0][0] == 5 * 1000 + ] + assert len(animation_calls) == 3 + + def test_per_tile_timing_debug_line_logged(self, mock_page): + """Each tile logs a DEBUG timing breakdown (readiness wait, animation + wait) so slow dashboards can be profiled from logs alone.""" + with patch("superset.utils.screenshot_utils.current_task", None): + with patch("superset.utils.screenshot_utils.logger") as mock_logger: + with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"): + take_tiled_screenshot( + mock_page, + "dashboard", + tile_height=2000, + log_context="cache_key=xyz", + ) + + timing_calls = [ + call for call in mock_logger.debug.call_args_list if "timing" in call[0][0] + ] + assert len(timing_calls) == 3 + for i, call in enumerate(timing_calls): + args = call[0] + assert args[1] == i + 1 # tile index + assert args[2] == 3 # total tiles + assert args[-1] == " [cache_key=xyz]" diff --git a/tests/unit_tests/utils/webdriver_test.py b/tests/unit_tests/utils/webdriver_test.py index a7dea405b8e..8b0ab64c364 100644 --- a/tests/unit_tests/utils/webdriver_test.py +++ b/tests/unit_tests/utils/webdriver_test.py @@ -919,21 +919,197 @@ class TestWebDriverPlaywrightErrorHandling: ) assert result == b"fake_screenshot" - mock_logger.warning.assert_any_call( - "Could not determine dashboard height for element %s at url %s; " - "falling back to standard screenshot behavior", + # chart_count (1) is well below the tiling threshold (20), so this is + # the benign/expected case and must not be logged as a WARNING. + mock_logger.debug.assert_any_call( + "Could not determine dashboard height for element %s " + "at url %s (%s chart containers found); %s", "dashboard", "http://example.com", + 1, + "falling back to standard screenshot behavior", + ) + assert not any( + call.args and "Could not determine dashboard height" in call.args[0] + for call in mock_logger.warning.call_args_list ) @patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True) @patch("superset.utils.webdriver._browser_manager") @patch("superset.utils.webdriver.logger") @patch("superset.utils.webdriver.take_tiled_screenshot") - def test_tiled_screenshot_failure_falls_back_to_standard_screenshot( + def test_unknown_height_does_not_veto_tiling_for_large_dashboard( + self, mock_take_tiled, mock_logger, mock_browser_manager + ): + """ + A large dashboard (by chart_count) whose height can't be measured + must still attempt tiling instead of being silently downgraded to a + standard screenshot, since below-the-fold charts may not have + rendered without the scroll-driven tiling pass. + """ + mock_user = MagicMock() + mock_user.username = "test_user" + + mock_browser = MagicMock() + mock_context = MagicMock() + mock_page = MagicMock() + mock_element = MagicMock() + mock_chart_container = MagicMock() + + mock_browser_manager.get_browser.return_value = mock_browser + mock_browser.new_context.return_value = mock_context + mock_context.new_page.return_value = mock_page + + def locator_side_effect(selector): + if selector == ".chart-container": + locator = MagicMock() + locator.all.return_value = [mock_chart_container] + return locator + return mock_element + + mock_page.locator.side_effect = locator_side_effect + mock_element.wait_for.return_value = None + mock_chart_container.wait_for.return_value = None + mock_page.wait_for_timeout.return_value = None + mock_take_tiled.return_value = b"tiled_screenshot" + + def evaluate_side_effect(script): + if script == 'document.querySelectorAll(".chart-container").length': + return 25 # chart_count >= threshold + if "const target = document.querySelector" in script: + return 0 # height could not be determined + return None + + mock_page.evaluate.side_effect = evaluate_side_effect + + with patch("superset.utils.webdriver.app") as mock_app: + mock_app.config = { + "WEBDRIVER_OPTION_ARGS": [], + "WEBDRIVER_WINDOW": {"pixel_density": 1}, + "SCREENSHOT_PLAYWRIGHT_DEFAULT_TIMEOUT": 30000, + "SCREENSHOT_PLAYWRIGHT_WAIT_EVENT": "networkidle", + "SCREENSHOT_SELENIUM_HEADSTART": 5, + "SCREENSHOT_SELENIUM_ANIMATION_WAIT": 1, + "SCREENSHOT_LOCATE_WAIT": 10, + "SCREENSHOT_LOAD_WAIT": 10, + "SCREENSHOT_WAIT_FOR_ERROR_MODAL_VISIBLE": 10, + "SCREENSHOT_WAIT_FOR_ERROR_MODAL_INVISIBLE": 10, + "SCREENSHOT_REPLACE_UNEXPECTED_ERRORS": False, + "SCREENSHOT_TILED_ENABLED": True, + "SCREENSHOT_TILED_CHART_THRESHOLD": 20, + "SCREENSHOT_TILED_HEIGHT_THRESHOLD": 5000, + "SCREENSHOT_TILED_VIEWPORT_HEIGHT": 600, + } + + with patch.object(WebDriverPlaywright, "auth") as mock_auth: + mock_auth.return_value = mock_context + + driver = WebDriverPlaywright("chrome") + result = driver.get_screenshot( + "http://example.com", "dashboard", mock_user + ) + + assert result == b"tiled_screenshot" + mock_take_tiled.assert_called_once() + mock_logger.warning.assert_any_call( + "Could not determine dashboard height for element %s " + "at url %s (%s chart containers found); %s", + "dashboard", + "http://example.com", + 25, + "attempting tiled screenshot anyway", + ) + + @patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True) + @patch("superset.utils.webdriver._browser_manager") + @patch("superset.utils.webdriver.logger") + def test_chart_container_timeout_logs_warning_with_progress_and_raises( + self, mock_logger, mock_browser_manager + ): + """ + Timing out while waiting for `.chart-container` elements to draw must + be logged as a WARNING (matching the other locate-wait timeouts in + this method, and the customer-side-slowness convention established + for these Playwright timeouts) with rendered/total progress, and must + still fail the screenshot by re-raising. + """ + from superset.utils.webdriver import PlaywrightTimeout + + mock_user = MagicMock() + mock_user.username = "test_user" + + mock_browser = MagicMock() + mock_context = MagicMock() + mock_page = MagicMock() + mock_element = MagicMock() + + mock_browser_manager.get_browser.return_value = mock_browser + mock_browser.new_context.return_value = mock_context + mock_context.new_page.return_value = mock_page + + timeout = PlaywrightTimeout() + rendered_ok = MagicMock() + rendered_ok.wait_for.return_value = None + never_renders = MagicMock() + never_renders.wait_for.side_effect = timeout + + def locator_side_effect(selector): + if selector == ".chart-container": + locator = MagicMock() + locator.all.return_value = [rendered_ok, never_renders] + return locator + return mock_element + + mock_page.locator.side_effect = locator_side_effect + mock_element.wait_for.return_value = None + + with patch("superset.utils.webdriver.app") as mock_app: + mock_app.config = { + "WEBDRIVER_OPTION_ARGS": [], + "WEBDRIVER_WINDOW": {"pixel_density": 1}, + "SCREENSHOT_PLAYWRIGHT_DEFAULT_TIMEOUT": 30000, + "SCREENSHOT_PLAYWRIGHT_WAIT_EVENT": "networkidle", + "SCREENSHOT_SELENIUM_HEADSTART": 5, + "SCREENSHOT_SELENIUM_ANIMATION_WAIT": 1, + "SCREENSHOT_LOCATE_WAIT": 10, + "SCREENSHOT_LOAD_WAIT": 10, + "SCREENSHOT_WAIT_FOR_ERROR_MODAL_VISIBLE": 10, + "SCREENSHOT_WAIT_FOR_ERROR_MODAL_INVISIBLE": 10, + "SCREENSHOT_REPLACE_UNEXPECTED_ERRORS": False, + "SCREENSHOT_TILED_ENABLED": False, + } + + with patch.object(WebDriverPlaywright, "auth") as mock_auth: + mock_auth.return_value = mock_context + + driver = WebDriverPlaywright("chrome") + with pytest.raises(PlaywrightTimeout) as exc_info: + driver.get_screenshot( + "http://example.com", "test-element", mock_user + ) + + assert exc_info.value is timeout + mock_logger.warning.assert_any_call( + "Timed out waiting for chart containers to draw at url %s " + "(%s of %s chart containers rendered before the timeout)", + "http://example.com", + 1, + 2, + exc_info=True, + ) + mock_logger.exception.assert_not_called() + + @patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True) + @patch("superset.utils.webdriver._browser_manager") + @patch("superset.utils.webdriver.logger") + @patch("superset.utils.webdriver.take_tiled_screenshot") + def test_tiled_screenshot_failure_raises_without_fallback( self, mock_take_tiled, mock_logger, mock_browser_manager ) -> None: - """When take_tiled_screenshot returns None, fall back to standard screenshot.""" + """When take_tiled_screenshot returns None, fail loudly instead of + falling back to an unguarded standard screenshot.""" + from superset.utils.webdriver import PlaywrightTimeout + mock_user = MagicMock() mock_user.username = "test_user" @@ -947,7 +1123,8 @@ class TestWebDriverPlaywrightErrorHandling: mock_context.new_page.return_value = mock_page mock_page.locator.return_value = mock_element mock_element.wait_for.return_value = None - # page.screenshot is used by _get_screenshot for the "standalone" element + # page.screenshot is used by _get_screenshot for the "standalone" element; + # it must never be reached by the failure path under test. mock_page.screenshot.return_value = b"fallback_screenshot" def evaluate_side_effect(script): @@ -983,14 +1160,20 @@ class TestWebDriverPlaywrightErrorHandling: mock_auth.return_value = mock_context driver = WebDriverPlaywright("chrome") - result = driver.get_screenshot( - "http://example.com", "standalone", mock_user - ) + # match= keeps this assertion meaningful even when playwright + # is not installed and PlaywrightTimeout aliases bare Exception. + with pytest.raises( + PlaywrightTimeout, match="Tiled screenshot failed for url" + ): + driver.get_screenshot("http://example.com", "standalone", mock_user) - assert result == b"fallback_screenshot" mock_take_tiled.assert_called_once() + mock_page.screenshot.assert_not_called() + mock_element.screenshot.assert_not_called() mock_logger.warning.assert_any_call( - ("Tiled screenshot failed, falling back to standard screenshot"), + "Tiled screenshot failed for url %s and no safe fallback " + "exists; failing the capture", + "http://example.com", ) @@ -1514,10 +1697,13 @@ class TestWebDriverPlaywrightAnimationWaitOrder: @patch("superset.utils.webdriver._browser_manager") @patch("superset.utils.webdriver.take_tiled_screenshot") @patch("superset.utils.webdriver.app") - def test_tiled_fallback_triggered_on_empty_bytes( + def test_tiled_empty_bytes_raises_without_fallback( self, mock_app, mock_take_tiled, mock_browser_manager ): - """Tiled fallback fires when take_tiled_screenshot returns b"" (not None).""" + """Tiled failure raises when take_tiled_screenshot returns b"" (not None), + instead of silently falling through to an unguarded raw capture.""" + from superset.utils.webdriver import PlaywrightTimeout + mock_user = MagicMock() mock_user.username = "test_user" mock_app.config = { @@ -1532,20 +1718,24 @@ class TestWebDriverPlaywrightAnimationWaitOrder: mock_page.evaluate.side_effect = [25, 6000] # Empty bytes — falsy but not None; was silently passed through before the fix mock_take_tiled.return_value = b"" - # _get_screenshot("standalone") calls page.screenshot(full_page=True); - # configure that return value so we can assert the fallback was reached + # _get_screenshot("standalone") calls page.screenshot(full_page=True); it + # must never be reached by the failure path under test. mock_page.screenshot.return_value = b"fallback" with patch.object(WebDriverPlaywright, "auth", return_value=mock_context): - result = WebDriverPlaywright("chrome").get_screenshot( - "http://example.com", "standalone", mock_user - ) + # match= keeps this assertion meaningful even when playwright + # is not installed and PlaywrightTimeout aliases bare Exception. + with pytest.raises( + PlaywrightTimeout, match="Tiled screenshot failed for url" + ): + WebDriverPlaywright("chrome").get_screenshot( + "http://example.com", "standalone", mock_user + ) - assert result == b"fallback" # Tiled path was taken (take_tiled_screenshot was called) mock_take_tiled.assert_called_once() - # Standard screenshot was called as fallback (full_page=True for "standalone") - mock_page.screenshot.assert_called_with(full_page=True) + # Standard screenshot must never be called as a fallback + mock_page.screenshot.assert_not_called() @patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True) @patch("superset.utils.webdriver._browser_manager") diff --git a/tests/unit_tests/views/datasource/views_test.py b/tests/unit_tests/views/datasource/views_test.py index 0ca11ed3fff..c1a771035a3 100644 --- a/tests/unit_tests/views/datasource/views_test.py +++ b/tests/unit_tests/views/datasource/views_test.py @@ -310,3 +310,66 @@ def test_save_non_editor_with_editors_field_is_rejected( raw_save(_view_self()) mock_security_manager.raise_for_editorship.assert_called_once_with(mock_orm) + + +# --------------------------------------------------------------------------- +# Datasource.samples +# --------------------------------------------------------------------------- + + +@patch("superset.views.datasource.views._", lambda s: s) +@patch("superset.views.datasource.views.get_samples") +@patch("superset.views.datasource.views.json_error_response") +@patch("superset.views.datasource.views.security_manager", new_callable=MagicMock) +def test_samples_returns_400_for_unsupported_datasource_type( + mock_security_manager: MagicMock, + mock_json_error_response: MagicMock, + mock_get_samples: MagicMock, +) -> None: + """Semantic views can't return raw samples — endpoint should refuse with 400.""" + from flask import Flask + + mock_security_manager.is_guest_user.return_value = False + mock_json_error_response.return_value = "error-response" + + raw_samples = _get_view_func("samples") + app = Flask(__name__) + with app.test_request_context( + "/datasource/samples?datasource_type=semantic_view&datasource_id=1", + method="POST", + json={}, + ): + result = raw_samples(_view_self()) + + assert result == "error-response" + mock_json_error_response.assert_called_once() + _, kwargs = mock_json_error_response.call_args + assert kwargs.get("status") == 400 + # The bail-out must happen before any sample fetching is attempted. + mock_get_samples.assert_not_called() + + +@patch("superset.views.datasource.views.get_samples") +@patch("superset.views.datasource.views.security_manager", new_callable=MagicMock) +def test_samples_proceeds_for_supported_datasource_type( + mock_security_manager: MagicMock, + mock_get_samples: MagicMock, +) -> None: + """A `query` datasource (supports_samples=True) bypasses the 400 short-circuit.""" + from flask import Flask + + mock_security_manager.is_guest_user.return_value = False + mock_get_samples.return_value = {"rows": []} + + view = _view_self() + raw_samples = _get_view_func("samples") + app = Flask(__name__) + with app.test_request_context( + "/datasource/samples?datasource_type=query&datasource_id=1", + method="POST", + json={}, + ): + raw_samples(view) + + mock_get_samples.assert_called_once() + view.json_response.assert_called_once_with({"result": {"rows": []}}) diff --git a/tests/unit_tests/views/test_bootstrap_auth.py b/tests/unit_tests/views/test_bootstrap_auth.py index c49c3eddc0a..af92031537f 100644 --- a/tests/unit_tests/views/test_bootstrap_auth.py +++ b/tests/unit_tests/views/test_bootstrap_auth.py @@ -191,71 +191,108 @@ def test_bootstrap_does_not_crash_without_recaptcha_key( assert "RECAPTCHA_PUBLIC_KEY" not in payload["conf"] -# --- language_pack injection -------------------------------------------- +# --- language pack delivery ---------------------------------------------- # -# The Jed pack is injected by `common_bootstrap_payload` (outside the -# memoized `cached_common_bootstrap_data`) using the shared -# `superset.translations.utils.get_language_pack`. Tests here cover the -# wrapper to confirm the pack lands on the payload for non-English -# locales and is None for English. +# The pack is NOT embedded in the bootstrap payload; spa.html loads it via a +# content-addressed script tag whose URL comes from +# `get_language_pack_template_context`. A pack supplied through +# COMMON_BOOTSTRAP_OVERRIDES_FUNC still rides the payload and suppresses the +# script tag. -def test_common_bootstrap_payload_includes_language_pack_for_non_english( +def test_common_bootstrap_payload_does_not_embed_language_pack( app_context: None, ) -> None: - """common.language_pack carries the shared utility's pack for non-en.""" - fake_pack = {"domain": "superset", "locale_data": {"superset": {}}} + """The payload stays small: no full pack even for non-English locales.""" with ( patch( "superset.views.base.cached_common_bootstrap_data", return_value={"locale": "fr"}, ), - patch( - "superset.views.base.get_language_pack", - return_value=fake_pack, - ) as mock_get, patch("superset.views.base.utils.get_user_id", return_value=1), patch("superset.views.base.get_locale", return_value="fr"), ): - payload = common_bootstrap_payload() + payload: dict[str, Any] = common_bootstrap_payload() - assert payload["language_pack"] == fake_pack - mock_get.assert_called_once_with("fr") + assert payload["language_pack"] is None -def test_common_bootstrap_payload_skips_pack_for_english( +def test_common_bootstrap_payload_preserves_override_pack( app_context: None, ) -> None: - """English short-circuits: pack is None and the utility is not called.""" + """A pack from COMMON_BOOTSTRAP_OVERRIDES_FUNC is passed through.""" + fake_pack: dict[str, Any] = {"domain": "superset", "locale_data": {"superset": {}}} with ( patch( "superset.views.base.cached_common_bootstrap_data", - return_value={"locale": "en"}, + return_value={"locale": "fr", "language_pack": fake_pack}, ), - patch("superset.views.base.get_language_pack") as mock_get, patch("superset.views.base.utils.get_user_id", return_value=1), - patch("superset.views.base.get_locale", return_value="en"), + patch("superset.views.base.get_locale", return_value="fr"), ): - payload = common_bootstrap_payload() + payload: dict[str, Any] = common_bootstrap_payload() - assert payload["language_pack"] is None - mock_get.assert_not_called() + assert payload["language_pack"] == fake_pack def test_common_bootstrap_payload_does_not_mutate_memoized_dict( app_context: None, ) -> None: - """Injecting language_pack must not write back into the memoize cache.""" + """Defaulting language_pack must not write back into the memoize cache.""" cached: dict[str, Any] = {"locale": "fr"} with ( patch( "superset.views.base.cached_common_bootstrap_data", return_value=cached, ), - patch("superset.views.base.get_language_pack", return_value={"x": 1}), patch("superset.views.base.utils.get_user_id", return_value=1), patch("superset.views.base.get_locale", return_value="fr"), ): common_bootstrap_payload() assert "language_pack" not in cached + + +def _language_pack_context( + locale: str, payload_extra: dict[str, Any] +) -> dict[str, Any]: + from superset.views.base import get_language_pack_template_context + + with ( + patch( + "superset.views.base.get_language_pack_version", + return_value="abc123def456", + ), + patch( + "superset.views.base.url_for", + return_value="/language_pack/fr/abc123def456/script.js", + ), + ): + return get_language_pack_template_context({"locale": locale, **payload_extra}) + + +def test_language_pack_template_context_versioned_src_for_non_english( + app_context: None, +) -> None: + context: dict[str, Any] = _language_pack_context("fr", {}) + assert context == { + "language_pack_src": "/language_pack/fr/abc123def456/script.js", + "language_pack_inline": False, + } + + +def test_language_pack_template_context_none_for_english( + app_context: None, +) -> None: + context: dict[str, Any] = _language_pack_context("en", {}) + assert context == {"language_pack_src": None, "language_pack_inline": False} + + +def test_language_pack_template_context_inline_for_override_pack( + app_context: None, +) -> None: + """An operator-supplied pack suppresses the script tag; spa.html inlines.""" + context: dict[str, Any] = _language_pack_context( + "fr", {"language_pack": {"domain": "superset"}} + ) + assert context == {"language_pack_src": None, "language_pack_inline": True} diff --git a/tests/unit_tests/views/test_language_pack_script.py b/tests/unit_tests/views/test_language_pack_script.py new file mode 100644 index 00000000000..53eeb36ebd3 --- /dev/null +++ b/tests/unit_tests/views/test_language_pack_script.py @@ -0,0 +1,147 @@ +# 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. +from pathlib import Path +from typing import Any +from unittest.mock import patch + +FAKE_PACK = {"domain": "superset", "locale_data": {"superset": {"": {}}}} +FAKE_VERSION = "abc123def456" + + +def test_script_served_immutable_when_version_matches(client: Any) -> None: + with ( + patch( + "superset.views.core.get_language_pack_version", + return_value=FAKE_VERSION, + ), + patch("superset.views.core.get_language_pack", return_value=FAKE_PACK), + ): + response = client.get(f"/language_pack/fr/{FAKE_VERSION}/script.js") + + assert response.status_code == 200 + assert response.mimetype == "application/javascript" + body = response.get_data(as_text=True) + assert body.startswith("window.__SUPERSET_LANGUAGE_PACK__ = ") + assert '"domain": "superset"' in body + cache_control = response.headers["Cache-Control"] + assert "immutable" in cache_control + assert "max-age=31536000" in cache_control + assert "public" in cache_control + + +def test_script_not_cacheable_when_version_stale(client: Any) -> None: + """A pre-upgrade HTML page may reference an old version: serve fresh + content, but do not let caches pin it under the stale address.""" + with ( + patch( + "superset.views.core.get_language_pack_version", + return_value=FAKE_VERSION, + ), + patch("superset.views.core.get_language_pack", return_value=FAKE_PACK), + ): + response = client.get("/language_pack/fr/000000000000/script.js") + + assert response.status_code == 200 + assert "no-cache" in response.headers["Cache-Control"] + assert "immutable" not in response.headers["Cache-Control"] + + +def test_script_404_when_pack_missing(client: Any) -> None: + with patch( + "superset.views.core.get_language_pack_version", + return_value=None, + ): + response = client.get(f"/language_pack/xx/{FAKE_VERSION}/script.js") + + assert response.status_code == 404 + + +def test_script_404_when_pack_fails_to_parse(client: Any) -> None: + """A malformed catalog must not be masked as a success: silently + serving the internal English fallback under the broken locale's + content-addressed URL would cache the wrong translations as + immutable for a year.""" + with ( + patch( + "superset.views.core.get_language_pack_version", + return_value=FAKE_VERSION, + ), + patch("superset.views.core.get_language_pack", return_value=None), + ): + response = client.get(f"/language_pack/fr/{FAKE_VERSION}/script.js") + + assert response.status_code == 404 + + +def test_script_rejects_malformed_lang_and_version(client: Any) -> None: + assert client.get(f"/language_pack/../{FAKE_VERSION}/script.js").status_code in ( + 400, + 404, + ) + assert client.get("/language_pack/fr/not-a-hash!/script.js").status_code == 400 + + +def test_script_accepts_script_subtag_locale(client: Any) -> None: + """Script-subtag locales Superset ships (e.g. sr_Latn) must not be + rejected by the language-code validation.""" + with ( + patch( + "superset.views.core.get_language_pack_version", + return_value=FAKE_VERSION, + ), + patch("superset.views.core.get_language_pack", return_value=FAKE_PACK), + ): + response = client.get(f"/language_pack/sr_Latn/{FAKE_VERSION}/script.js") + + assert response.status_code == 200 + + +def test_script_serves_only_the_requested_locale(client: Any) -> None: + """The endpoint resolves exactly one pack: the locale in the URL.""" + with ( + patch( + "superset.views.core.get_language_pack_version", + return_value=FAKE_VERSION, + ) as mock_version, + patch( + "superset.views.core.get_language_pack", return_value=FAKE_PACK + ) as mock_pack, + ): + client.get(f"/language_pack/pt_BR/{FAKE_VERSION}/script.js") + + mock_version.assert_called_once_with("pt_BR") + mock_pack.assert_called_once_with("pt_BR") + + +def test_spa_template_loads_pack_before_entry_bundle() -> None: + """Static guard on spa.html: the language pack script tag must precede + the entry bundle and stay a classic script (no async/defer). A deferred + or reordered tag would let code-split chunks evaluate module-level + `t('...')` calls before the translator is configured (issue #35330).""" + import superset + + template: str = ( + Path(superset.__file__).parent / "templates" / "superset" / "spa.html" + ).read_text() + + tag_start: int = template.index('