Compare commits

..

2 Commits

Author SHA1 Message Date
Joe Li
734f608f4d fix(dashboard): guard stale DropdownContainer confirmation frame 2026-06-25 22:58:07 +00:00
sadpandajoe
ca32d9b422 fix(dashboard): keep More filters reachable after applying a cross-filter
When a horizontal filter bar has enough native filters to overflow into the
"More filters" dropdown, applying a cross-filter (which prepends a chip to the
bar's item list) could make the overflowed native filters vanish from BOTH the
bar and the dropdown, while the "More filters" button itself also disappeared,
leaving the hidden filters unreachable. Clearing the cross-filter restored them.

Root cause: when the item set changes, DropdownContainer resets its positional
overflow index and re-measures. If that measurement runs against a transient
mid-reflow layout, it can conclude "nothing overflows" and latch that verdict
(the recalculation effect's dependencies do not change again, so it never
self-corrects). Because the trigger's visibility is derived solely from the
overflow count, that single bad verdict both strands the surplus filters in the
clipped bar and removes the trigger to reach them.

Fix: treat a post-item-change "nothing overflows" read as provisional and run a
single requestAnimationFrame confirmation pass that re-measures once the browser
has reflowed, keeping the trigger mounted across the confirmation window. The
confirmation is armed on every item-set change (so a fit->overflow transition is
covered, not only the already-overflowing case) and is versioned and cancelled
so a superseded frame from a rapid second change cannot clobber the newer state.
This extends the intent of #38193 (which guarded only the transient reset
window) to also cover a settled bad read, and is in the same overflow/button
visibility area as #28060.

Adds DropdownContainer.overflow.test.tsx, which drives the real overflow
recalculation (mocking only the two measurement sources) and covers: a clean
re-measurement after a prepended chip, the transient-latch regression, the
fit->overflow transition, over-correction (genuine fit drops the trigger), and
re-entrant item-set changes.
2026-06-24 02:13:43 +00:00
321 changed files with 10694 additions and 21454 deletions

2
.github/CODEOWNERS vendored
View File

@@ -38,7 +38,7 @@
# Notify translation maintainers of changes to translations
/superset/translations/ @sfirke @rusackas @villebro @sadpandajoe @hainenber
/superset/translations/ @sfirke @rusackas
# Notify PMC members of changes to extension-related files

View File

@@ -42,7 +42,7 @@ runs:
fi
echo "python-version=$RESOLVED_VERSION" >> "$GITHUB_OUTPUT"
- name: Set up Python ${{ steps.set-python-version.outputs.python-version }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: ${{ steps.set-python-version.outputs.python-version }}
cache: ${{ inputs.cache }}

View File

@@ -31,7 +31,7 @@ jobs:
checks: write
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: true
ref: master
@@ -40,7 +40,7 @@ jobs:
uses: ./.github/actions/setup-supersetbot/
- name: Set up Python ${{ inputs.python-version }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.10"

View File

@@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
submodules: recursive

View File

@@ -25,7 +25,7 @@ jobs:
pull-requests: write
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Check and notify

View File

@@ -26,7 +26,7 @@ jobs:
frontend: ${{ steps.check.outputs.frontend }}
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Check for file changes
@@ -58,7 +58,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false

View File

@@ -27,7 +27,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: "Checkout Repository"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: "Dependency Review"
@@ -51,7 +51,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: "Checkout Repository"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false

View File

@@ -30,7 +30,7 @@ jobs:
docker: ${{ steps.check.outputs.docker }}
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Check for file changes
@@ -71,7 +71,7 @@ jobs:
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@@ -177,7 +177,7 @@ jobs:
timeout-minutes: 30
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Free up disk space

View File

@@ -23,7 +23,7 @@ jobs:
run:
working-directory: superset-embedded-sdk
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
# Note: registry-url is intentionally omitted. When set, actions/setup-node

View File

@@ -21,7 +21,7 @@ jobs:
run:
working-directory: superset-embedded-sdk
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6

View File

@@ -32,7 +32,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
submodules: recursive

View File

@@ -27,7 +27,7 @@ jobs:
security-events: write
steps:
- name: Checkout Repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false

View File

@@ -16,7 +16,7 @@ jobs:
issues: write
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false

View File

@@ -12,7 +12,7 @@ jobs:
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
submodules: recursive

View File

@@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
submodules: recursive

View File

@@ -21,7 +21,7 @@ jobs:
pull-requests: write
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
submodules: recursive

View File

@@ -28,7 +28,7 @@ jobs:
python-version: ${{ github.event_name == 'pull_request' && fromJSON('["current"]') || fromJSON('["current", "previous", "next"]') }}
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
submodules: recursive
@@ -63,7 +63,7 @@ jobs:
yarn install --immutable
- name: Cache pre-commit environments
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: ~/.cache/pre-commit
key: pre-commit-v2-${{ runner.os }}-py${{ matrix.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }}

View File

@@ -33,7 +33,7 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
# pulls all commits (needed for lerna / semantic release to correctly version)
@@ -56,7 +56,7 @@ jobs:
- name: Cache npm
if: env.HAS_TAGS
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: ~/.npm # npm cache files are stored in `~/.npm` on Linux/macOS
key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }}
@@ -70,7 +70,7 @@ jobs:
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
- name: Cache npm
if: env.HAS_TAGS
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
id: npm-cache # use this to check for `cache-hit` (`steps.npm-cache.outputs.cache-hit != 'true'`)
with:
path: ${{ steps.npm-cache-dir-path.outputs.dir }}

View File

@@ -1,177 +0,0 @@
name: Scheduled Docker image refresh
# Re-runs the Docker image build against the latest published release on a
# weekly cadence. The code being built doesn't change — but the base image
# layers (python:*-slim-trixie and its OS packages) DO get upstream
# security patches between Superset releases, and those patches don't
# reach our published images unless we rebuild.
#
# Without this workflow, `apache/superset:<latest>` lags behind upstream
# Debian/Python base patches by whatever interval falls between Superset
# releases (typically 36 weeks). With it, the lag drops to at most one
# week regardless of release cadence.
#
# This is a security-hygiene cron, not a release. It overwrites the
# existing tags for the most recent release (e.g. `apache/superset:5.0.0`
# and `apache/superset:latest`) with bit-for-bit-equivalent contents
# layered on a refreshed base. Image digests change; everything users
# actually pin against (image content, code, deps) does not.
on:
schedule:
# Mondays at 06:00 UTC — gives the weekend for upstream patches to
# settle and surfaces failures at the start of the work week so a
# human can react.
- cron: "0 6 * * 1"
# Manual trigger so operators can force a refresh on demand (e.g.
# immediately after a high-severity base-image CVE drops).
workflow_dispatch: {}
permissions:
contents: read
# Serialize with itself and with the release publisher (tag-release.yml) —
# both push to the same Docker Hub tags, so a race could end with stale
# layers winning. Both workflows must declare this group for the lock to work.
concurrency:
group: docker-publish-latest-release
cancel-in-progress: false
jobs:
config:
runs-on: ubuntu-24.04
outputs:
has-secrets: ${{ steps.check.outputs.has-secrets }}
latest-release: ${{ steps.latest.outputs.tag }}
force-latest: ${{ steps.latest.outputs.force-latest }}
steps:
- name: Check for Docker Hub secrets
id: check
shell: bash
run: |
if [ -n "${DOCKERHUB_USER}" ]; then
echo "has-secrets=1" >> "$GITHUB_OUTPUT"
fi
env:
DOCKERHUB_USER: ${{ (secrets.DOCKERHUB_USER != '' && secrets.DOCKERHUB_TOKEN != '') || '' }}
- name: Look up latest published release
id: latest
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPOSITORY: ${{ github.repository }}
run: |
# `releases/latest` returns the latest non-prerelease, non-draft
# release — which is exactly what `apache/superset:latest`
# should reflect.
TAG=$(gh api "repos/${REPOSITORY}/releases/latest" --jq .tag_name)
if [ -z "$TAG" ] || [ "$TAG" = "null" ]; then
echo "::error::Could not determine latest release tag"
exit 1
fi
echo "Latest release: $TAG"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
# Only move `:latest` when the release flagged "latest" is also the
# highest semver release. This guards against a mis-click leaving an
# older maintenance release (e.g. a 5.x patch shipped after 6.0 GA)
# marked latest, which would otherwise roll `:latest` back a major
# version on the next cron run. If it isn't the newest, we still
# refresh that release's own version tag but leave `:latest` alone.
HIGHEST=$(gh api --paginate "repos/${REPOSITORY}/releases" \
--jq '.[] | select(.draft|not) | select(.prerelease|not) | .tag_name' \
| sed 's/^v//' | sort -V | tail -n1)
if [ "${TAG#v}" = "$HIGHEST" ]; then
echo "force-latest=1" >> "$GITHUB_OUTPUT"
else
echo "::warning::Latest-flagged release $TAG is not the highest semver ($HIGHEST); refreshing its version tag but leaving :latest untouched"
fi
docker-rebuild:
needs: config
if: needs.config.outputs.has-secrets == '1'
name: docker-rebuild
runs-on: ubuntu-24.04
strategy:
# Mirror the same matrix the release publisher uses so every variant
# operators consume from Docker Hub gets the refreshed base.
matrix:
build_preset: ["dev", "lean", "py310", "websocket", "dockerize", "py311", "py312"]
fail-fast: false
steps:
- name: "Checkout release tag: ${{ needs.config.outputs.latest-release }}"
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ needs.config.outputs.latest-release }}
fetch-depth: 0
persist-credentials: false
- name: Setup Docker Environment
uses: ./.github/actions/setup-docker
with:
dockerhub-user: ${{ secrets.DOCKERHUB_USER }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
install-docker-compose: "false"
build: "true"
- name: Use Node.js 20
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 20
- name: Setup supersetbot
uses: ./.github/actions/setup-supersetbot/
- name: Rebuild and push
env:
DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USER }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BUILD_PRESET: ${{ matrix.build_preset }}
LATEST_RELEASE: ${{ needs.config.outputs.latest-release }}
FORCE_LATEST_FLAG: ${{ needs.config.outputs.force-latest == '1' && '--force-latest' || '' }}
run: |
# Reuses the same supersetbot invocation as the release
# publisher (`tag-release.yml`), so the resulting tags are
# identical to what a manual release dispatch would produce —
# just with a freshly-pulled base image layer underneath.
# `--force-latest` is only passed when the config job confirmed the
# fetched release is the newest one (see FORCE_LATEST_FLAG above).
supersetbot docker \
--push \
--preset "$BUILD_PRESET" \
--context release \
--context-ref "$LATEST_RELEASE" \
$FORCE_LATEST_FLAG \
--platform "linux/arm64" \
--platform "linux/amd64"
# The whole point of this cron is catching base-image CVEs, so a silent
# failure is the expensive case — a red X in the Actions tab nobody is
# watching on a Monday. File a tracked issue when any rebuild leg fails so
# a missed security refresh surfaces instead of sitting unnoticed.
notify-on-failure:
needs: [config, docker-rebuild]
if: failure() && needs.config.outputs.has-secrets == '1'
runs-on: ubuntu-24.04
permissions:
contents: read
issues: write
steps:
- name: Open a tracking issue
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPOSITORY: ${{ github.repository }}
LATEST_RELEASE: ${{ needs.config.outputs.latest-release }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
gh issue create \
--repo "$REPOSITORY" \
--title "Scheduled Docker image refresh failed for ${LATEST_RELEASE}" \
--label "infra:container" \
--label "bug" \
--body "The weekly Docker base-image refresh failed for release \`${LATEST_RELEASE}\`. Published images may be missing upstream base-layer security patches until this is resolved.
Failed run: ${RUN_URL}"

View File

@@ -152,7 +152,7 @@ jobs:
- name: Checkout PR code (only if build needed)
if: steps.auth.outputs.authorized == 'true' && steps.check.outputs.build_needed == 'true'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ steps.check.outputs.target_sha }}
persist-credentials: false

View File

@@ -41,7 +41,7 @@ jobs:
- 16379:6379
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
submodules: recursive

View File

@@ -60,7 +60,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: "Checkout ${{ github.event.workflow_run.head_sha || github.sha }}"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
persist-credentials: false

View File

@@ -28,7 +28,7 @@ jobs:
name: Link Checking
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
# Do not bump this linkinator-action version without opening
@@ -73,7 +73,7 @@ jobs:
working-directory: docs
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
submodules: recursive
@@ -112,7 +112,7 @@ jobs:
working-directory: docs
steps:
- name: "Checkout PR head: ${{ github.event.workflow_run.head_sha }}"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.workflow_run.head_sha }}
persist-credentials: false

View File

@@ -38,7 +38,7 @@ jobs:
frontend: ${{ steps.check.outputs.frontend }}
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Check for file changes
@@ -97,21 +97,21 @@ jobs:
# Conditional checkout based on context
- name: Checkout for push or pull_request event
if: github.event_name == 'push' || github.event_name == 'pull_request'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
submodules: recursive
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Checkout using ref (workflow_dispatch)
if: github.event_name == 'workflow_dispatch' && github.event.inputs.ref != ''
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
ref: ${{ github.event.inputs.ref }}
submodules: recursive
- name: Checkout using PR ID (workflow_dispatch)
if: github.event_name == 'workflow_dispatch' && github.event.inputs.pr_id != ''
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
ref: refs/pull/${{ github.event.inputs.pr_id }}/merge
@@ -207,21 +207,21 @@ jobs:
# Conditional checkout based on context (same as Cypress workflow)
- name: Checkout for push or pull_request event
if: github.event_name == 'push' || github.event_name == 'pull_request'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
submodules: recursive
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Checkout using ref (workflow_dispatch)
if: github.event_name == 'workflow_dispatch' && github.event.inputs.ref != ''
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
ref: ${{ github.event.inputs.ref }}
submodules: recursive
- name: Checkout using PR ID (workflow_dispatch)
if: github.event_name == 'workflow_dispatch' && github.event.inputs.pr_id != ''
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
ref: refs/pull/${{ github.event.inputs.pr_id }}/merge

View File

@@ -31,7 +31,7 @@ jobs:
working-directory: superset-extensions-cli
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
submodules: recursive

View File

@@ -27,7 +27,7 @@ jobs:
should-run: ${{ steps.check.outputs.frontend }}
steps:
- name: Checkout Code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
fetch-depth: 0
@@ -110,7 +110,7 @@ jobs:
id-token: write
steps:
- name: Checkout Code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
fetch-depth: 0

View File

@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
submodules: recursive

View File

@@ -29,7 +29,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ inputs.ref || github.ref_name }}
persist-credentials: true

View File

@@ -34,7 +34,7 @@ jobs:
frontend: ${{ steps.check.outputs.frontend }}
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Check for file changes
@@ -83,21 +83,21 @@ jobs:
# Conditional checkout based on context (same as Cypress workflow)
- name: Checkout for push or pull_request event
if: github.event_name == 'push' || github.event_name == 'pull_request'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
submodules: recursive
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Checkout using ref (workflow_dispatch)
if: github.event_name == 'workflow_dispatch' && github.event.inputs.ref != ''
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
ref: ${{ github.event.inputs.ref }}
submodules: recursive
- name: Checkout using PR ID (workflow_dispatch)
if: github.event_name == 'workflow_dispatch' && github.event.inputs.pr_id != ''
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
ref: refs/pull/${{ github.event.inputs.pr_id }}/merge

View File

@@ -29,7 +29,7 @@ jobs:
python: ${{ steps.check.outputs.python }}
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Check for file changes
@@ -72,7 +72,7 @@ jobs:
- 16379:6379
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
submodules: recursive
@@ -157,7 +157,7 @@ jobs:
- 16379:6379
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
submodules: recursive
@@ -207,7 +207,7 @@ jobs:
- 16379:6379
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
submodules: recursive

View File

@@ -25,7 +25,7 @@ jobs:
python: ${{ steps.check.outputs.python }}
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Check for file changes
@@ -72,7 +72,7 @@ jobs:
- 16379:6379
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
submodules: recursive
@@ -127,7 +127,7 @@ jobs:
- 16379:6379
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
submodules: recursive

View File

@@ -30,7 +30,7 @@ jobs:
python: ${{ steps.check.outputs.python }}
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Check for file changes
@@ -55,7 +55,7 @@ jobs:
PYTHONPATH: ${{ github.workspace }}
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
submodules: recursive

View File

@@ -25,7 +25,7 @@ jobs:
pull-requests: read
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
submodules: recursive
@@ -61,7 +61,7 @@ jobs:
pull-requests: read
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
submodules: recursive

View File

@@ -25,7 +25,7 @@ jobs:
timeout-minutes: 20
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Install dependencies

View File

@@ -38,7 +38,7 @@ jobs:
});
- name: "Checkout ( ${{ github.sha }} )"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false

View File

@@ -27,7 +27,7 @@ jobs:
# zizmor: ignore[artipacked] - required persisted credentials to push synced requirement changes back to remote
- name: Checkout source code
if: ${{ steps.dependabot-metadata.outputs.package-ecosystem == 'pip' }}
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: true

View File

@@ -24,12 +24,6 @@ on:
permissions:
contents: read
# Serialize with the scheduled Docker image refresh — both workflows push
# to the same Docker Hub tags and must not race on apache/superset:latest.
concurrency:
group: docker-publish-latest-release
cancel-in-progress: false
jobs:
config:
runs-on: ubuntu-24.04
@@ -60,7 +54,7 @@ jobs:
fail-fast: false
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
fetch-depth: 0
@@ -126,7 +120,7 @@ jobs:
pull-requests: write
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
fetch-depth: 0

View File

@@ -32,7 +32,7 @@ jobs:
name: Generate Reports
steps:
- name: Checkout Repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false

View File

@@ -24,22 +24,6 @@ assists people when migrating to a new version.
## Next
### Guest-token RLS rules reject unknown fields
The `rls` rules passed to `POST /api/v1/security/guest_token/` are now validated strictly: a rule may only contain `dataset` and `clause`. Previously unknown fields were silently dropped, so a mistyped or legacy scope key (most commonly `datasource` instead of `dataset`) produced a rule with no `dataset`, which is treated as a *global* rule applied to every dataset the embedded resource can reach. Such a request now returns HTTP 400 identifying the offending field instead of issuing a token with an unintended global rule. Integrators that were sending extra fields in RLS rules must remove them; valid dataset-scoped (`{"dataset": 41, "clause": "..."}`) and global (`{"clause": "..."}`) rules are unaffected.
### MCP service requires `MCP_JWT_AUDIENCE` when JWT auth is enabled
When the MCP service has JWT auth enabled (`MCP_AUTH_ENABLED = True`), an audience must be configured via `MCP_JWT_AUDIENCE` so issued tokens are bound to this service. The service now fails to start with a clear configuration error when the audience is unset, instead of starting with audience validation skipped. Deployments that enable MCP JWT auth must set `MCP_JWT_AUDIENCE` to the audience value their identity provider issues for the MCP service. API-key-only MCP deployments (JWT auth disabled) are unaffected.
### Swagger UI is opt-in (off by default)
`FAB_API_SWAGGER_UI` now defaults to `False` and is driven by the `SUPERSET_ENABLE_SWAGGER_UI` environment variable. The interactive Swagger UI / OpenAPI documentation endpoints (e.g. `/swagger/v1`) are therefore no longer exposed by default. To enable them, set `SUPERSET_ENABLE_SWAGGER_UI=true` (the bundled Docker development environment sets this) or override `FAB_API_SWAGGER_UI = True` in `superset_config.py`.
### Build details (git SHA / build number) are admin-only by default
The git SHA and build number surfaced in the "About" section, the bootstrap payload, and the public `/version` endpoint are now only included for admin users by default; the release version string is still shown to everyone. To expose the build details to all users (the previous behavior), set the `SUPERSET_EXPOSE_BUILD_DETAILS` environment variable (or `EXPOSE_BUILD_DETAILS_TO_USERS = True` in `superset_config.py`).
### Pivot table First/Last aggregations follow data order
The pivot table chart's `First` and `Last` aggregations now return the first and last value in data (query result) order, instead of effectively returning the minimum and maximum. Existing pivot tables that use these aggregations for totals/subtotals may show different values after upgrading. For deterministic results, ensure the underlying query has a stable sort order.

View File

@@ -70,8 +70,6 @@ SUPERSET_LOG_LEVEL=info
SUPERSET_APP_ROOT="/"
SUPERSET_ENV=development
# Swagger UI is opt-in (off by default); enable it for local development.
SUPERSET_ENABLE_SWAGGER_UI=true
SUPERSET_LOAD_EXAMPLES=yes
CYPRESS_CONFIG=false
SUPERSET_PORT=8088

View File

@@ -160,7 +160,7 @@ When enabled, Superset rejects webhook configurations that use `http://` URLs.
#### Retry Behavior
Superset automatically retries webhook deliveries on `429 Too Many Requests` and `5xx` server errors using exponential backoff. Retries are bounded to roughly 120 seconds of cumulative wall-clock time (worst case ~210 seconds, because the bound is checked against the time elapsed before each attempt, so the final request can begin just under the limit and still run its full request timeout), after which the delivery is abandoned.
Superset automatically retries webhook deliveries on `429 Too Many Requests` and `5xx` server errors using exponential backoff.
### Kubernetes-specific

View File

@@ -161,7 +161,6 @@ Here's the documentation section how how to set up Talisman: https://superset.ap
- [ ] Regularly update to the latest major or minor versions of Superset. Those versions receive up-to-date security patches.
- [ ] Rotate the `SUPERSET_SECRET_KEY` periodically (e.g., quarterly) and after any potential security incident.
- [ ] Rotate the other security-critical secrets (guest-token and async-query JWT secrets, SMTP and database credentials) on the cadence in Appendix C, and after any potential security incident.
- [ ] Conduct quarterly access reviews for all users.
- [ ] Assuming logging and monitoring is in place, review security monitoring alerts weekly.
@@ -174,24 +173,6 @@ Rotating the `SUPERSET_SECRET_KEY` is a critical security procedure. It is manda
The procedure for safely rotating the SECRET_KEY must be followed precisely to avoid locking yourself out of your instance. The official Apache Superset documentation maintains the correct, up-to-date procedure. Please follow the official guide here:
https://superset.apache.org/admin-docs/configuration/configuring-superset/#rotating-to-a-newer-secret_key
### **Appendix C: Secrets Register and Rotation Schedule**
`SUPERSET_SECRET_KEY` is not the only security-critical secret in a Superset deployment. Maintain an inventory of all such secrets, store each in a secrets manager (not in `superset_config.py` or version control), assign an owner, and rotate them on a defined cadence as well as after any suspected compromise.
| Secret | Purpose | Risk if leaked | Suggested rotation |
|---|---|---|---|
| `SUPERSET_SECRET_KEY` | Signs session cookies; key material for encrypting stored DB credentials (Fernet/AES) | Forged sessions (auth bypass / privilege escalation); decryption of exfiltrated metadata-DB secrets | Quarterly + post-incident |
| `GUEST_TOKEN_JWT_SECRET` | Signs embedded-dashboard guest tokens | Forged guest tokens → unauthorized dashboard/data access | Quarterly + post-incident |
| `GLOBAL_ASYNC_QUERIES_JWT_SECRET` | Signs the async-query channel JWT | Forged async-query tokens | Quarterly + post-incident |
| SMTP password | Outbound email for alerts & reports | Email relay abuse / spoofing | Per organizational policy + post-incident |
| Database connection passwords | Access to analytical databases and the metadata DB | Direct database access | Per organizational policy + post-incident |
Notes:
- Rotating `GUEST_TOKEN_JWT_SECRET` or `GLOBAL_ASYNC_QUERIES_JWT_SECRET` invalidates outstanding tokens of that type; schedule rotations accordingly.
- After a suspected compromise, rotate **all** of the above, not only `SUPERSET_SECRET_KEY`.
- Keep the register under change control so new secrets introduced by future features are added to the rotation schedule.
:::resources
- [Blog: Running Apache Superset on the Open Internet](https://preset.io/blog/running-apache-superset-on-the-open-internet-a-report-from-the-fireline/)
- [Blog: How Security Vulnerabilities are Reported & Handled in Apache Superset](https://preset.io/blog/how-security-vulnerabilities-are-reported-and-handled-in-apache-superset/)

View File

@@ -34,14 +34,15 @@ Frontend contribution types allow extensions to extend Superset's user interface
Extensions can add new views or panels to the host application, such as custom SQL Lab panels, dashboards, or other UI components. Contribution areas are uniquely identified (e.g., `sqllab.panels` for SQL Lab panels), enabling seamless integration into specific parts of the application.
```typescript
```tsx
import React from 'react';
import { views } from '@apache-superset/core';
import MyPanel from './MyPanel';
views.registerView(
{ id: 'my-extension.main', name: 'My Panel Name' },
'sqllab.panels',
MyPanel,
() => <MyPanel />,
);
```
@@ -111,24 +112,6 @@ editors.registerEditor(
See [Editors Extension Point](./extension-points/editors.md) for implementation details.
### Chat
Extensions can add a chat interface to Superset by registering a trigger component and a panel component. The host owns the layout, open/close state, and display mode — the extension only provides the UI. The panel can be displayed as a floating overlay or docked as a resizable sidebar beside the page content, and the user's preference is persisted across reloads.
```tsx
import { chat } from '@apache-superset/core';
import ChatTrigger from './ChatTrigger';
import ChatPanel from './ChatPanel';
chat.registerChat(
{ id: 'my-org.my-chat', name: 'My Chat' },
ChatTrigger,
ChatPanel,
);
```
See [Chat](./extension-points/chat.md) for implementation details.
## Backend
Backend contribution types allow extensions to extend Superset's server-side capabilities. Backend contributions are registered at startup via classes and functions imported from the auto-discovered `entrypoint.py` file.

View File

@@ -1,141 +0,0 @@
---
title: Chat
sidebar_position: 3
---
<!--
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.
-->
# Chat Contributions
Extensions can add a chat interface to Superset by registering a trigger and a panel. The host owns the layout, open/close state, and display mode — the extension only needs to provide the UI components.
## Overview
A chat registration consists of two React components:
| Component | Role |
|-----------|------|
| **Trigger** | Always-visible entry point (e.g., a floating button). Rendered in the bottom-right corner in floating mode, or as a fixed overlay in panel mode. |
| **Panel** | The chat UI itself (message list, input, etc.). Mounted by the host in the active display mode. |
## Display Modes
The host supports two display modes, switchable by the user or the extension at runtime:
| Mode | Behavior |
|------|----------|
| `floating` | Panel floats above page content, anchored to the bottom-right corner. |
| `panel` | Panel is docked to the right side of the application as a resizable sidebar, sitting beside the page content. |
The user's last selected mode and open/closed state are persisted across page reloads.
## Registering a Chat
Call `chat.registerChat` from your extension's entry point with a descriptor, a trigger factory, and a panel factory:
```tsx
import { chat } from '@apache-superset/core';
import ChatTrigger from './ChatTrigger';
import ChatPanel from './ChatPanel';
chat.registerChat(
{ id: 'my-org.my-chat', name: 'My Chat' },
ChatTrigger,
ChatPanel,
);
```
Only one chat registration is active at a time. If a second extension calls `registerChat`, it replaces the first and a warning is logged.
## Opening and Closing the Chat
The trigger component is responsible for toggling the panel. Use `chat.isOpen()`, `chat.open()`, and `chat.close()` to control visibility:
```tsx
import { chat } from '@apache-superset/core';
export default function ChatTrigger() {
return (
<button onClick={() => (chat.isOpen() ? chat.close() : chat.open())}>
💬
</button>
);
}
```
You can also subscribe to open/close events from any component:
```tsx
useEffect(() => {
const { dispose } = chat.onDidOpen(() => console.log('chat opened'));
return dispose;
}, []);
```
## Changing the Display Mode
Call `chat.setDisplayMode` to switch between `'floating'` and `'panel'` modes. In your panel component, subscribe to `onDidChangeDisplayMode` to react to changes (including those triggered by the user):
```tsx
import { useState, useEffect } from 'react';
import { chat } from '@apache-superset/core';
export default function ChatPanel() {
const [mode, setMode] = useState(chat.getDisplayMode());
useEffect(() => {
const { dispose } = chat.onDidChangeDisplayMode(m => setMode(m));
return dispose;
}, []);
return (
<div style={{ height: mode === 'panel' ? '100%' : '80vh' }}>
<button onClick={() => chat.setDisplayMode(mode === 'panel' ? 'floating' : 'panel')}>
{mode === 'panel' ? 'Float' : 'Dock'}
</button>
{/* message list and input */}
</div>
);
}
```
## Chat API Reference
All methods are available on the `chat` namespace from `@apache-superset/core`:
| Method / Event | Description |
|----------------|-------------|
| `registerChat(descriptor, trigger, panel)` | Register a chat extension. Returns a `Disposable` to unregister. |
| `open()` | Open the chat panel. No-op if already open or no registration. |
| `close()` | Close the chat panel. |
| `isOpen()` | Returns `true` if the panel is currently open. |
| `getDisplayMode()` | Returns the current display mode (`'floating'` or `'panel'`). |
| `setDisplayMode(mode)` | Switch between `'floating'` and `'panel'` mode. |
| `onDidOpen(listener)` | Subscribe to panel open events. Returns a `Disposable`. |
| `onDidClose(listener)` | Subscribe to panel close events. Returns a `Disposable`. |
| `onDidChangeDisplayMode(listener)` | Subscribe to display mode changes. Returns a `Disposable`. |
| `onDidRegisterChat(listener)` | Subscribe to registration events. |
| `onDidUnregisterChat(listener)` | Subscribe to unregistration events. |
| `onDidResizePanel(listener)` | Subscribe to panel resize events (panel mode only). Not all hosts provide a resizer — do not rely on this firing. Returns a `Disposable`. |
## Next Steps
- **[Contribution Types](../contribution-types.md)** — Explore other contribution types
- **[Development](../development.md)** — Set up your development environment

View File

@@ -47,8 +47,6 @@ module.exports = {
collapsed: true,
items: [
'extensions/extension-points/sqllab',
'extensions/extension-points/editors',
'extensions/extension-points/chat',
],
},
'extensions/development',

View File

@@ -72,7 +72,7 @@
"@superset-ui/core": "^0.20.4",
"@swc/core": "^1.15.41",
"antd": "^6.4.4",
"baseline-browser-mapping": "^2.10.38",
"baseline-browser-mapping": "^2.10.37",
"caniuse-lite": "^1.0.30001799",
"docusaurus-plugin-openapi-docs": "^5.0.2",
"docusaurus-theme-openapi-docs": "^5.0.2",
@@ -134,8 +134,7 @@
"yaml": "1.10.3",
"uuid": "11.1.1",
"serialize-javascript": "7.0.5",
"d3-color": "3.1.0",
"ws": "^8.21.0"
"d3-color": "3.1.0"
},
"packageManager": "yarn@1.22.22+sha1.ac34549e6aa8e7ead463a7407e1c7390f61a6610"
}

View File

@@ -5698,10 +5698,10 @@ base64-js@^1.3.1, base64-js@^1.5.1:
resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
baseline-browser-mapping@^2.10.38, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
version "2.10.38"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz#c84d093c4bf7325c5053c279d90f153c66526042"
integrity sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==
baseline-browser-mapping@^2.10.37, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
version "2.10.37"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz#3e636475b6b293244e2b23e2c71a2ab9d9e6ba7d"
integrity sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==
batch@0.6.1:
version "0.6.1"
@@ -15246,10 +15246,15 @@ write-file-atomic@^3.0.3:
signal-exit "^3.0.2"
typedarray-to-buffer "^3.1.5"
ws@^7.3.1, ws@^8.18.0, ws@^8.2.3, ws@^8.21.0:
version "8.21.0"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.21.0.tgz#012e413fc07429945121b0c153158c4343086951"
integrity sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==
ws@^7.3.1:
version "7.5.10"
resolved "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz"
integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==
ws@^8.18.0, ws@^8.2.3:
version "8.20.1"
resolved "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz"
integrity sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==
wsl-utils@^0.1.0:
version "0.1.0"

View File

@@ -29,7 +29,7 @@ maintainers:
- name: craig-rueda
email: craig@craigrueda.com
url: https://github.com/craig-rueda
version: 0.17.3 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
version: 0.17.2 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
dependencies:
- name: postgresql
version: 16.7.27

View File

@@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs
# superset
![Version: 0.17.3](https://img.shields.io/badge/Version-0.17.3-informational?style=flat-square)
![Version: 0.17.2](https://img.shields.io/badge/Version-0.17.2-informational?style=flat-square)
Apache Superset is a modern, enterprise-ready business intelligence web application

View File

@@ -108,6 +108,8 @@ else:
{{ fail (printf "Unsupported database type: %s. Please use 'postgresql' or 'mysql'." .Values.supersetNode.connections.db_type) }}
{{- end }}
SQLALCHEMY_TRACK_MODIFICATIONS = True
class CeleryConfig:
imports = ("superset.sql_lab", )
broker_url = CELERY_REDIS_URL

View File

@@ -315,7 +315,7 @@ pygeohash==3.2.2
# via apache-superset (pyproject.toml)
pygments==2.20.0
# via rich
pyjwt==2.13.0
pyjwt==2.12.0
# via
# apache-superset (pyproject.toml)
# flask-appbuilder

View File

@@ -769,7 +769,7 @@ pyhive==0.7.0
# via apache-superset
pyinstrument==5.1.2
# via apache-superset
pyjwt==2.13.0
pyjwt==2.12.0
# via
# -c requirements/base-constraint.txt
# apache-superset

View File

@@ -0,0 +1,118 @@
/**
* 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 { DATABASE_LIST } from 'cypress/utils/urls';
function closeModal() {
cy.get('body').then($body => {
if ($body.find('[data-test="database-modal"]').length) {
cy.get('[aria-label="Close"]').eq(1).click();
}
});
}
describe('Add database', () => {
before(() => {
cy.visit(DATABASE_LIST);
});
beforeEach(() => {
cy.intercept('POST', '**/api/v1/database/validate_parameters/**').as(
'validateParams',
);
cy.intercept('POST', '**/api/v1/database/').as('createDb');
closeModal();
cy.getBySel('btn-create-database').click();
});
it('should open dynamic form', () => {
cy.get('.preferred > :nth-child(1)').click();
cy.get('input[name="host"]').should('have.value', '');
cy.get('input[name="port"]').should('have.value', '');
cy.get('input[name="database"]').should('have.value', '');
cy.get('input[name="username"]').should('have.value', '');
cy.get('input[name="password"]').should('have.value', '');
cy.get('input[name="database_name"]').should('have.value', '');
});
it('should open sqlalchemy form', () => {
cy.get('.preferred > :nth-child(1)').click();
cy.getBySel('sqla-connect-btn').click();
cy.getBySel('database-name-input').should('be.visible');
cy.getBySel('sqlalchemy-uri-input').should('be.visible');
});
it('show error alerts on dynamic form for bad host', () => {
cy.get('.preferred > :nth-child(1)').click();
cy.get('input[name="host"]').type('badhost', { force: true });
cy.get('input[name="port"]').type('5432', { force: true });
cy.get('input[name="username"]').type('testusername', { force: true });
cy.get('input[name="database"]').type('testdb', { force: true });
cy.get('input[name="password"]').type('testpass', { force: true });
cy.get('body').click(0, 0);
cy.wait('@validateParams', { timeout: 30000 });
cy.getBySel('btn-submit-connection').should('not.be.disabled');
cy.getBySel('btn-submit-connection').click({ force: true });
cy.wait('@validateParams', { timeout: 30000 }).then(() => {
cy.wait('@createDb', { timeout: 60000 }).then(() => {
cy.contains(
'.ant-form-item-explain-error',
"The hostname provided can't be resolved",
).should('exist');
});
});
});
it('show error alerts on dynamic form for bad port', () => {
cy.get('.preferred > :nth-child(1)').click();
cy.get('input[name="host"]').type('localhost', { force: true });
cy.get('body').click(0, 0);
cy.wait('@validateParams', { timeout: 30000 });
cy.get('input[name="port"]').type('5430', { force: true });
cy.get('input[name="database"]').type('testdb', { force: true });
cy.get('input[name="username"]').type('testusername', { force: true });
cy.wait('@validateParams', { timeout: 30000 });
cy.get('input[name="password"]').type('testpass', { force: true });
cy.wait('@validateParams');
cy.getBySel('btn-submit-connection').should('not.be.disabled');
cy.getBySel('btn-submit-connection').click({ force: true });
cy.wait('@validateParams', { timeout: 30000 }).then(() => {
cy.get('body').click(0, 0);
cy.getBySel('btn-submit-connection').click({ force: true });
cy.wait('@createDb', { timeout: 60000 }).then(() => {
cy.contains(
'.ant-form-item-explain-error',
'The port is closed',
).should('exist');
});
});
});
});

View File

@@ -27,6 +27,17 @@
"tscw-config": "^1.1.2"
}
},
"node_modules/@ampproject/remapping": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.1.2.tgz",
"integrity": "sha512-hoyByceqwKirw7w3Z7gnIIZC3Wx3J484Y3L/cMpXFbr7d9ZQj2mODrirNzcJa+SM3UlpWXYvKV4RlRpFXlWgXg==",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.0"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/code-frame": {
"version": "7.12.11",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz",
@@ -37,35 +48,33 @@
}
},
"node_modules/@babel/compat-data": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
"license": "MIT",
"version": "7.21.4",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.21.4.tgz",
"integrity": "sha512-/DYyDpeCfaVinT40FPGdkkb+lYSKvsVuMjDAG7jPOWWiM1ibOaB9CXJAlc4d1QpP/U2q2P9jbrSlClKSErd55g==",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/core": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"license": "MIT",
"version": "7.17.5",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.17.5.tgz",
"integrity": "sha512-/BBMw4EvjmyquN5O+t5eh0+YqB3XXJkYD2cjKpYtWOfFy4lQ4UozNSmxAcWT8r2XtZs0ewG+zrfsqeR15i1ajA==",
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
"@babel/helper-compilation-targets": "^7.29.7",
"@babel/helper-module-transforms": "^7.29.7",
"@babel/helpers": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/template": "^7.29.7",
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"@ampproject/remapping": "^2.1.0",
"@babel/code-frame": "^7.16.7",
"@babel/generator": "^7.17.3",
"@babel/helper-compilation-targets": "^7.16.7",
"@babel/helper-module-transforms": "^7.16.7",
"@babel/helpers": "^7.17.2",
"@babel/parser": "^7.17.3",
"@babel/template": "^7.16.7",
"@babel/traverse": "^7.17.3",
"@babel/types": "^7.17.0",
"convert-source-map": "^1.7.0",
"debug": "^4.1.0",
"gensync": "^1.0.0-beta.2",
"json5": "^2.2.3",
"semver": "^6.3.1"
"json5": "^2.1.2",
"semver": "^6.3.0"
},
"engines": {
"node": ">=6.9.0"
@@ -76,94 +85,23 @@
}
},
"node_modules/@babel/core/node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"license": "MIT",
"version": "7.16.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.16.7.tgz",
"integrity": "sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg==",
"dependencies": {
"@babel/helper-validator-identifier": "^7.29.7",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
"@babel/highlight": "^7.16.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/core/node_modules/@babel/helper-compilation-targets": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
"license": "MIT",
"dependencies": {
"@babel/compat-data": "^7.29.7",
"@babel/helper-validator-option": "^7.29.7",
"browserslist": "^4.24.0",
"lru-cache": "^5.1.1",
"semver": "^6.3.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/core/node_modules/@babel/helper-module-imports": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/core/node_modules/@babel/helper-module-transforms": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7",
"@babel/traverse": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0"
}
},
"node_modules/@babel/core/node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"license": "MIT"
},
"node_modules/@babel/core/node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
"license": "ISC",
"dependencies": {
"yallist": "^3.0.2"
}
},
"node_modules/@babel/core/node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"license": "ISC"
},
"node_modules/@babel/generator": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
"license": "MIT",
"version": "7.29.1",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
"dependencies": {
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7",
"@babel/parser": "^7.29.0",
"@babel/types": "^7.29.0",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
@@ -201,7 +139,6 @@
"version": "7.21.4",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.21.4.tgz",
"integrity": "sha512-Fa0tTuOXZ1iL8IeDFUWCzjZcn+sJGd9RZdH9esYVjEejGmzf+FFYQpMi/kZUk2kPy/q1H3/GPw7np8qar/stfg==",
"peer": true,
"dependencies": {
"@babel/compat-data": "^7.21.4",
"@babel/helper-validator-option": "^7.21.0",
@@ -220,7 +157,6 @@
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
"peer": true,
"dependencies": {
"yallist": "^3.0.2"
}
@@ -228,8 +164,7 @@
"node_modules/@babel/helper-compilation-targets/node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"peer": true
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="
},
"node_modules/@babel/helper-create-class-features-plugin": {
"version": "7.21.4",
@@ -321,10 +256,9 @@
}
},
"node_modules/@babel/helper-globals": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
"license": "MIT",
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
"engines": {
"node": ">=6.9.0"
}
@@ -345,7 +279,6 @@
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
"peer": true,
"dependencies": {
"@babel/traverse": "^7.28.6",
"@babel/types": "^7.28.6"
@@ -358,7 +291,6 @@
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
"peer": true,
"dependencies": {
"@babel/helper-module-imports": "^7.28.6",
"@babel/helper-validator-identifier": "^7.28.5",
@@ -464,28 +396,25 @@
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"license": "MIT",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"license": "MIT",
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-option": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
"license": "MIT",
"version": "7.21.0",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.21.0.tgz",
"integrity": "sha512-rmL/B8/f0mKS2baE9ZpyTcTavvEuWhTTW8amjzXNvYG4AwBsqTLikfXsEofsJEfKHf+HQVQbFOHy6o+4cnC/fQ==",
"engines": {
"node": ">=6.9.0"
}
@@ -506,13 +435,13 @@
}
},
"node_modules/@babel/helpers": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
"version": "7.26.10",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.10.tgz",
"integrity": "sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==",
"license": "MIT",
"dependencies": {
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.7"
"@babel/template": "^7.26.9",
"@babel/types": "^7.26.10"
},
"engines": {
"node": ">=6.9.0"
@@ -522,7 +451,6 @@
"version": "7.22.20",
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz",
"integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==",
"peer": true,
"dependencies": {
"@babel/helper-validator-identifier": "^7.22.20",
"chalk": "^2.4.2",
@@ -533,12 +461,11 @@
}
},
"node_modules/@babel/parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"license": "MIT",
"version": "7.29.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz",
"integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
"dependencies": {
"@babel/types": "^7.29.7"
"@babel/types": "^7.29.0"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -1666,26 +1593,24 @@
}
},
"node_modules/@babel/template": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
"license": "MIT",
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7"
"@babel/code-frame": "^7.28.6",
"@babel/parser": "^7.28.6",
"@babel/types": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template/node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"license": "MIT",
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
"dependencies": {
"@babel/helper-validator-identifier": "^7.29.7",
"@babel/helper-validator-identifier": "^7.28.5",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
@@ -1694,17 +1619,16 @@
}
},
"node_modules/@babel/traverse": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
"integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
"license": "MIT",
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
"@babel/helper-globals": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.7",
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
"@babel/helper-globals": "^7.28.0",
"@babel/parser": "^7.29.0",
"@babel/template": "^7.28.6",
"@babel/types": "^7.29.0",
"debug": "^4.3.1"
},
"engines": {
@@ -1712,12 +1636,11 @@
}
},
"node_modules/@babel/traverse/node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"license": "MIT",
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
"dependencies": {
"@babel/helper-validator-identifier": "^7.29.7",
"@babel/helper-validator-identifier": "^7.28.5",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
@@ -1726,13 +1649,12 @@
}
},
"node_modules/@babel/types": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"license": "MIT",
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"dependencies": {
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
@@ -2171,16 +2093,6 @@
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz",
@@ -3441,7 +3353,6 @@
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
"integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
"peer": true,
"dependencies": {
"ansi-styles": "^3.2.1",
"escape-string-regexp": "^1.0.5",
@@ -3455,7 +3366,6 @@
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
"integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
"peer": true,
"dependencies": {
"color-convert": "^1.9.0"
},
@@ -3581,7 +3491,6 @@
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
"peer": true,
"dependencies": {
"color-name": "1.1.3"
}
@@ -3589,8 +3498,7 @@
"node_modules/color-name": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=",
"peer": true
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU="
},
"node_modules/colorette": {
"version": "1.4.0",
@@ -5076,7 +4984,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
"integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=",
"peer": true,
"engines": {
"node": ">=4"
}
@@ -7971,7 +7878,6 @@
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
"peer": true,
"dependencies": {
"has-flag": "^3.0.0"
},
@@ -8864,6 +8770,14 @@
}
},
"dependencies": {
"@ampproject/remapping": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.1.2.tgz",
"integrity": "sha512-hoyByceqwKirw7w3Z7gnIIZC3Wx3J484Y3L/cMpXFbr7d9ZQj2mODrirNzcJa+SM3UlpWXYvKV4RlRpFXlWgXg==",
"requires": {
"@jridgewell/trace-mapping": "^0.3.0"
}
},
"@babel/code-frame": {
"version": "7.12.11",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz",
@@ -8874,100 +8788,49 @@
}
},
"@babel/compat-data": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="
"version": "7.21.4",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.21.4.tgz",
"integrity": "sha512-/DYyDpeCfaVinT40FPGdkkb+lYSKvsVuMjDAG7jPOWWiM1ibOaB9CXJAlc4d1QpP/U2q2P9jbrSlClKSErd55g=="
},
"@babel/core": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"version": "7.17.5",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.17.5.tgz",
"integrity": "sha512-/BBMw4EvjmyquN5O+t5eh0+YqB3XXJkYD2cjKpYtWOfFy4lQ4UozNSmxAcWT8r2XtZs0ewG+zrfsqeR15i1ajA==",
"requires": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
"@babel/helper-compilation-targets": "^7.29.7",
"@babel/helper-module-transforms": "^7.29.7",
"@babel/helpers": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/template": "^7.29.7",
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"@ampproject/remapping": "^2.1.0",
"@babel/code-frame": "^7.16.7",
"@babel/generator": "^7.17.3",
"@babel/helper-compilation-targets": "^7.16.7",
"@babel/helper-module-transforms": "^7.16.7",
"@babel/helpers": "^7.17.2",
"@babel/parser": "^7.17.3",
"@babel/template": "^7.16.7",
"@babel/traverse": "^7.17.3",
"@babel/types": "^7.17.0",
"convert-source-map": "^1.7.0",
"debug": "^4.1.0",
"gensync": "^1.0.0-beta.2",
"json5": "^2.2.3",
"semver": "^6.3.1"
"json5": "^2.1.2",
"semver": "^6.3.0"
},
"dependencies": {
"@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"version": "7.16.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.16.7.tgz",
"integrity": "sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg==",
"requires": {
"@babel/helper-validator-identifier": "^7.29.7",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
"@babel/highlight": "^7.16.7"
}
},
"@babel/helper-compilation-targets": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
"requires": {
"@babel/compat-data": "^7.29.7",
"@babel/helper-validator-option": "^7.29.7",
"browserslist": "^4.24.0",
"lru-cache": "^5.1.1",
"semver": "^6.3.1"
}
},
"@babel/helper-module-imports": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
"requires": {
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7"
}
},
"@babel/helper-module-transforms": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
"requires": {
"@babel/helper-module-imports": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7",
"@babel/traverse": "^7.29.7"
}
},
"convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="
},
"lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
"requires": {
"yallist": "^3.0.2"
}
},
"yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="
}
}
},
"@babel/generator": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
"version": "7.29.1",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
"requires": {
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7",
"@babel/parser": "^7.29.0",
"@babel/types": "^7.29.0",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
@@ -8996,7 +8859,6 @@
"version": "7.21.4",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.21.4.tgz",
"integrity": "sha512-Fa0tTuOXZ1iL8IeDFUWCzjZcn+sJGd9RZdH9esYVjEejGmzf+FFYQpMi/kZUk2kPy/q1H3/GPw7np8qar/stfg==",
"peer": true,
"requires": {
"@babel/compat-data": "^7.21.4",
"@babel/helper-validator-option": "^7.21.0",
@@ -9009,7 +8871,6 @@
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
"peer": true,
"requires": {
"yallist": "^3.0.2"
}
@@ -9017,8 +8878,7 @@
"yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"peer": true
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="
}
}
},
@@ -9088,9 +8948,9 @@
}
},
"@babel/helper-globals": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="
},
"@babel/helper-member-expression-to-functions": {
"version": "7.21.0",
@@ -9105,7 +8965,6 @@
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
"peer": true,
"requires": {
"@babel/traverse": "^7.28.6",
"@babel/types": "^7.28.6"
@@ -9115,7 +8974,6 @@
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
"peer": true,
"requires": {
"@babel/helper-module-imports": "^7.28.6",
"@babel/helper-validator-identifier": "^7.28.5",
@@ -9191,19 +9049,19 @@
}
},
"@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="
},
"@babel/helper-validator-identifier": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="
},
"@babel/helper-validator-option": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="
"version": "7.21.0",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.21.0.tgz",
"integrity": "sha512-rmL/B8/f0mKS2baE9ZpyTcTavvEuWhTTW8amjzXNvYG4AwBsqTLikfXsEofsJEfKHf+HQVQbFOHy6o+4cnC/fQ=="
},
"@babel/helper-wrap-function": {
"version": "7.20.5",
@@ -9218,19 +9076,18 @@
}
},
"@babel/helpers": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
"version": "7.26.10",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.10.tgz",
"integrity": "sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==",
"requires": {
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.7"
"@babel/template": "^7.26.9",
"@babel/types": "^7.26.10"
}
},
"@babel/highlight": {
"version": "7.22.20",
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz",
"integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==",
"peer": true,
"requires": {
"@babel/helper-validator-identifier": "^7.22.20",
"chalk": "^2.4.2",
@@ -9238,11 +9095,11 @@
}
},
"@babel/parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"version": "7.29.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz",
"integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
"requires": {
"@babel/types": "^7.29.7"
"@babel/types": "^7.29.0"
}
},
"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": {
@@ -9994,21 +9851,21 @@
}
},
"@babel/template": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
"requires": {
"@babel/code-frame": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7"
"@babel/code-frame": "^7.28.6",
"@babel/parser": "^7.28.6",
"@babel/types": "^7.28.6"
},
"dependencies": {
"@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
"requires": {
"@babel/helper-validator-identifier": "^7.29.7",
"@babel/helper-validator-identifier": "^7.28.5",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
}
@@ -10016,25 +9873,25 @@
}
},
"@babel/traverse": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
"integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
"requires": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
"@babel/helper-globals": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.7",
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
"@babel/helper-globals": "^7.28.0",
"@babel/parser": "^7.29.0",
"@babel/template": "^7.28.6",
"@babel/types": "^7.29.0",
"debug": "^4.3.1"
},
"dependencies": {
"@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
"requires": {
"@babel/helper-validator-identifier": "^7.29.7",
"@babel/helper-validator-identifier": "^7.28.5",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
}
@@ -10042,12 +9899,12 @@
}
},
"@babel/types": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"requires": {
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5"
}
},
"@colors/colors": {
@@ -10369,7 +10226,7 @@
"camelcase": "^5.3.1",
"find-up": "^4.1.0",
"get-package-type": "^0.1.0",
"js-yaml": "4.1.1",
"js-yaml": "^3.13.1",
"resolve-from": "^5.0.0"
},
"dependencies": {
@@ -10379,8 +10236,7 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
},
"js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"version": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"requires": {
"argparse": "^2.0.1"
@@ -10402,15 +10258,6 @@
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"requires": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"@jridgewell/resolve-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz",
@@ -11470,7 +11317,6 @@
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
"integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
"peer": true,
"requires": {
"ansi-styles": "^3.2.1",
"escape-string-regexp": "^1.0.5",
@@ -11481,7 +11327,6 @@
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
"integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
"peer": true,
"requires": {
"color-convert": "^1.9.0"
}
@@ -11574,7 +11419,6 @@
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
"peer": true,
"requires": {
"color-name": "1.1.3"
}
@@ -11582,8 +11426,7 @@
"color-name": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=",
"peer": true
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU="
},
"colorette": {
"version": "1.4.0",
@@ -12703,8 +12546,7 @@
"has-flag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
"integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=",
"peer": true
"integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0="
},
"has-symbols": {
"version": "1.1.0",
@@ -13030,7 +12872,7 @@
"resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.3.tgz",
"integrity": "sha512-BXgQl9kf4WTCPCCpmFGoJkz/+uhvm7h7PFKUYxh7qarQd3ER33vHG//qaE8eN25l07YqZPpHXU9I09l/RD5aGQ==",
"requires": {
"@babel/core": "^7.29.6",
"@babel/core": "^7.7.5",
"@istanbuljs/schema": "^0.1.2",
"istanbul-lib-coverage": "^3.0.0",
"semver": "^6.3.0"
@@ -14738,7 +14580,6 @@
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
"peer": true,
"requires": {
"has-flag": "^3.0.0"
}

View File

@@ -34,7 +34,6 @@
"tscw-config": "^1.1.2"
},
"overrides": {
"@babel/core": "^7.29.6",
"cypress": {
"form-data": "^2.3.4"
},

File diff suppressed because it is too large Load Diff

View File

@@ -119,7 +119,7 @@
"@great-expectations/jsonforms-antd-renderers": "^2.2.10",
"@jsonforms/core": "^3.7.0",
"@jsonforms/react": "^3.7.0",
"@jsonforms/vanilla-renderers": "^3.8.0",
"@jsonforms/vanilla-renderers": "^3.7.0",
"@luma.gl/constants": "~9.2.5",
"@luma.gl/core": "~9.2.5",
"@luma.gl/engine": "~9.2.5",
@@ -192,13 +192,13 @@
"json-bigint": "^1.0.0",
"json-stringify-pretty-compact": "^4.0.0",
"lodash": "^4.18.1",
"mapbox-gl": "^3.25.0",
"mapbox-gl": "^3.24.1",
"markdown-to-jsx": "^9.8.2",
"match-sorter": "^8.3.0",
"memoize-one": "^6.0.0",
"mousetrap": "^1.6.5",
"mustache": "^4.2.0",
"nanoid": "^5.1.14",
"nanoid": "^5.1.11",
"ol": "^10.9.0",
"query-string": "9.4.0",
"re-resizable": "^6.11.2",
@@ -270,7 +270,7 @@
"@storybook/test-runner": "0.24.4",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.15.41",
"@swc/plugin-emotion": "^14.13.0",
"@swc/plugin-emotion": "^14.12.0",
"@swc/plugin-transform-imports": "^12.5.0",
"@testing-library/dom": "^9.3.4",
"@testing-library/jest-dom": "^6.9.1",
@@ -303,7 +303,7 @@
"babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"babel-plugin-lodash": "^3.3.4",
"baseline-browser-mapping": "^2.10.38",
"baseline-browser-mapping": "^2.10.37",
"cheerio": "1.2.0",
"concurrently": "^10.0.3",
"copy-webpack-plugin": "^14.0.0",
@@ -350,12 +350,12 @@
"process": "^0.11.10",
"react-dnd-test-backend": "^16.0.1",
"react-refresh": "^0.18.0",
"react-resizable": "^4.0.2",
"react-resizable": "^4.0.1",
"redux-mock-store": "^1.5.4",
"source-map": "^0.7.6",
"source-map-support": "^0.5.21",
"speed-measure-webpack-plugin": "^1.6.0",
"storybook": "10.4.6",
"storybook": "10.4.5",
"style-loader": "^4.0.0",
"swc-loader": "^0.2.7",
"terser-webpack-plugin": "^5.6.1",
@@ -388,10 +388,6 @@
"overrides": {
"uuid": "$uuid",
"core-js": "^3.38.1",
"dompurify": "^3.4.11",
"esbuild": "^0.28.1",
"http-proxy-middleware": "^2.0.10",
"tar": "^7.5.16",
"puppeteer": "^22.4.1",
"remark-gfm": "^3.0.1",
"underscore": "^1.13.7",

View File

@@ -18,14 +18,6 @@
"types": "./lib/authentication/index.d.ts",
"default": "./lib/authentication/index.js"
},
"./chat": {
"types": "./lib/chat/index.d.ts",
"default": "./lib/chat/index.js"
},
"./navigation": {
"types": "./lib/navigation/index.d.ts",
"default": "./lib/navigation/index.js"
},
"./commands": {
"types": "./lib/commands/index.d.ts",
"default": "./lib/commands/index.js"

View File

@@ -1,156 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* @fileoverview Chat contribution API for Superset extensions.
*
* Chat is a dedicated contribution type: an extension registers
* a chat via {@link registerChat} and the host owns where and how it is
* mounted. The host applies singleton resolution — multiple chat extensions
* may register, but exactly one is active at a time.
*
* @example
* ```typescript
* import { chat } from '@apache-superset/core';
*
* chat.registerChat(
* { id: 'acme.chat', name: 'Acme Chat' },
* AcmeTrigger,
* AcmePanel,
* );
* ```
*/
import { ComponentType } from 'react';
import type { Disposable, Event } from '../common';
export interface Chat {
/** The unique identifier for the chat. */
id: string;
/** The display name of the chat. */
name: string;
/** Optional description of the chat. */
description?: string;
}
export type DisplayMode = 'floating' | 'panel';
/**
* Registers a chat provider. Only one chat is active at a time; the most
* recently registered chat wins. Disposing the returned Disposable unregisters
* the chat.
*
* @param chat The chat descriptor (id, name).
* @param trigger The trigger component — the collapsed bubble entry point.
* Owns dynamic state such as unread counts.
* @param panel The panel component, rendered in either display mode. In
* 'floating' mode it appears as an overlay; in 'panel' mode it is docked
* alongside the main content.
* @returns A Disposable that unregisters the chat when disposed.
*
* @example
* ```typescript
* chat.registerChat(
* { id: 'acme.chat', name: 'Acme Chat' },
* AcmeTrigger,
* AcmePanel,
* );
* ```
*/
export declare function registerChat(
chat: Chat,
trigger: ComponentType,
panel: ComponentType,
): Disposable;
/**
* Returns the active chat descriptor, or undefined if none is registered.
*/
export declare function getChat(): Chat | undefined;
/**
* Event fired when a chat is registered.
*/
export declare const onDidRegisterChat: Event<Chat>;
/**
* Event fired when a chat is unregistered.
*/
export declare const onDidUnregisterChat: Event<Chat>;
/**
* Opens the active chat's panel.
*
* Acts on whichever chat is active, regardless of which extension calls it.
* No-op when no chat is registered or the panel is already open.
*/
export declare function open(): void;
/**
* Closes the active chat's panel.
*
* Acts on whichever chat is active, regardless of which extension calls it.
* No-op when the panel is not open.
*/
export declare function close(): void;
/**
* Returns whether the active chat's panel is currently open.
*/
export declare function isOpen(): boolean;
/**
* Event fired when the chat panel opens. Also fired by the host's own
* controls, not only by an extension's open() call.
*/
export declare const onDidOpen: Event<void>;
/**
* Event fired when the chat panel closes, whether triggered by an extension
* or by the host.
*/
export declare const onDidClose: Event<void>;
/**
* Returns the current display mode.
*/
export declare function getDisplayMode(): DisplayMode;
/**
* Sets the display mode. The mode is host-global and applies to whichever
* chat is active. Use {@link onDidChangeDisplayMode} to observe all changes,
* including those triggered by the host.
*/
export declare function setDisplayMode(displayMode: DisplayMode): void;
/**
* Event fired when the display mode changes, whether triggered by an
* extension via setDisplayMode() or by host-provided controls.
*/
export declare const onDidChangeDisplayMode: Event<DisplayMode>;
/**
* Event fired when the panel is resized in panel mode. Not all hosts provide
* a resizer — do not rely on this event firing.
*/
export declare const onDidResizePanel: Event<{ width: number }>;
// TODO: client actions API — tool availability functions will be added here
// once the client_actions SIP is finalized. The chat namespace is the
// intended integration point between the two SIPs.

View File

@@ -223,6 +223,8 @@ export interface Extension {
dependencies: string[];
/** Human-readable description of the extension */
description: string;
/** List of other extensions that this extension depends on */
extensionDependencies: string[];
/** Unique identifier for the extension */
id: string;
/** Human-readable name of the extension */

View File

@@ -23,10 +23,9 @@
* This module defines the aggregate interfaces used by the extension.json
* manifest and the `superset-extensions` build command. Individual metadata
* types are defined in their respective namespace modules (commands, views,
* menus, editors, chat) and re-exported here for the manifest schema.
* menus, editors) and re-exported here for the manifest schema.
*/
import { Chat } from '../chat';
import { Command } from '../commands';
import { View } from '../views';
import { Menu } from '../menus';
@@ -72,8 +71,7 @@ export interface MenuContributions {
}
/**
* Aggregates all contributions (commands, menus, views, editors, and chat)
* provided by an extension or module.
* Aggregates all contributions (commands, menus, views, and editors) provided by an extension or module.
*/
export interface Contributions {
/** List of commands. */
@@ -84,10 +82,4 @@ export interface Contributions {
views: ViewContributions;
/** List of editors. */
editors?: Editor[];
/**
* The chat contributed by the extension — at most one per extension, since
* the host applies singleton resolution and renders exactly one active
* chat at a time.
*/
chat?: Chat;
}

View File

@@ -18,12 +18,10 @@
*/
export * as common from './common';
export * as authentication from './authentication';
export * as chat from './chat';
export * as commands from './commands';
export * as editors from './editors';
export * as extensions from './extensions';
export * as menus from './menus';
export * as navigation from './navigation';
export * as sqlLab from './sqlLab';
export * as views from './views';
export * as contributions from './contributions';

View File

@@ -1,81 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* @fileoverview Navigation namespace for Superset extensions.
*
* Exposes the current application surface so extensions can react to route
* changes without polling. Entity-level context (chart, dashboard, dataset)
* is intentionally not included here — surface-specific namespaces that
* resolve entity payloads are introduced in later phases.
*/
import { Event } from '../common';
/**
* The set of top-level application surfaces.
*
* `'explore'`, `'dashboard'` and `'dataset'` are the single-entity
* editing/viewing surfaces. `'chart_list'`, `'dashboard_list'` and
* `'dataset_list'` are the browse/list surfaces, distinct from those because no
* single entity is active. `'sqllab'` is the SQL editor where
* `sqlLab.getCurrentTab()` resolves; `'query_history'` and `'saved_queries'`
* are the related SQL Lab browse pages, which are not the editor. `'home'` is
* the welcome surface and the fallback for any route not explicitly enumerated.
*/
export type Page =
| 'dashboard'
| 'dashboard_list'
| 'explore'
| 'chart_list'
| 'sqllab'
| 'query_history'
| 'saved_queries'
| 'dataset'
| 'dataset_list'
| 'home';
/**
* Returns the current page surface.
*
* @example
* ```typescript
* const page = navigation.getPage();
* if (page === 'dashboard') {
* // react to being on a dashboard surface
* }
* ```
*/
export declare function getPage(): Page;
/**
* Event fired whenever the user navigates to a different surface.
*
* @example
* ```typescript
* const sub = navigation.onDidChangePage(page => {
* if (page === 'dashboard') {
* // react to navigating onto a dashboard surface
* }
* });
* // later:
* sub.dispose();
* ```
*/
export declare const onDidChangePage: Event<Page>;

View File

@@ -30,12 +30,12 @@
*
* views.registerView(
* { id: 'my_ext.result_stats', name: 'Result Stats', location: 'sqllab.panels' },
* ResultStatsPanel,
* () => <ResultStatsPanel />,
* );
* ```
*/
import { ComponentType } from 'react';
import { ReactElement } from 'react';
import { Disposable, Event } from '../common';
/**
@@ -58,7 +58,7 @@ export interface View {
*
* @param view The view descriptor (id and name).
* @param location The location where this view should appear (e.g. "sqllab.panels").
* @param component The React component to render at that location.
* @param provider A function that returns the React element to render.
* @returns A Disposable that unregisters the view when disposed.
*
* @example
@@ -66,14 +66,14 @@ export interface View {
* views.registerView(
* { id: 'my_ext.result_stats', name: 'Result Stats' },
* 'sqllab.panels',
* ResultStatsPanel,
* () => <ResultStatsPanel />,
* );
* ```
*/
export declare function registerView(
view: View,
location: string,
component: ComponentType,
provider: () => ReactElement,
): Disposable;
/**

View File

@@ -132,26 +132,6 @@ export const advancedAnalyticsControls: ControlPanelSectionConfig = {
},
},
],
[
{
name: 'time_compare_full_range',
config: {
type: 'CheckboxControl',
label: t('Show full range for time shift'),
default: false,
description: t(
'Plot each time-shifted series across its full time range instead ' +
'of truncating it to the main series. Useful for comparing a ' +
'partial current period (e.g. today so far) against complete ' +
'prior periods (e.g. all of yesterday).',
),
visibility: ({ controls }) =>
Boolean(controls?.time_compare?.value) &&
(!Array.isArray(controls?.time_compare?.value) ||
controls.time_compare.value.length > 0),
},
},
],
[
{
name: 'comparison_type',

View File

@@ -60,7 +60,7 @@ export default function RadioButtonControl({
...props
}: RadioButtonControlProps) {
const normalizedOptions = options.map(normalizeOption);
const currentValue = initialValue ?? normalizedOptions[0]?.value;
const currentValue = initialValue || normalizedOptions[0].value;
return (
<div>

View File

@@ -359,51 +359,6 @@ test('handles empty options array gracefully', () => {
expect(container.querySelector('[role="tablist"]')).toBeInTheDocument();
});
test('currentValue is undefined when options are empty and no value is provided', () => {
expect(() => setup({ options: [] })).not.toThrow();
const { container } = setup({ options: [] });
expect(container.querySelectorAll('[id^="tab-"]').length).toBe(0);
});
test('preserves falsy numeric value 0 instead of falling back to first option', () => {
const { container } = setup({
options: [
[0, 'Zero'],
[1, 'One'],
[2, 'Two'],
],
value: 0,
});
expect(container.querySelector('#tab-0')).toHaveAttribute(
'aria-selected',
'true',
);
expect(container.querySelector('#tab-1')).toHaveAttribute(
'aria-selected',
'false',
);
});
test('preserves falsy boolean value false instead of falling back to first option', () => {
const { container } = setup({
options: [
[true, 'True'],
[false, 'False'],
],
value: false,
});
expect(container.querySelector('#tab-true')).toHaveAttribute(
'aria-selected',
'false',
);
expect(container.querySelector('#tab-false')).toHaveAttribute(
'aria-selected',
'true',
);
});
test('renders with hovered prop', () => {
const { container } = setup({
label: 'Test',

View File

@@ -22,50 +22,21 @@ under the License.
[![Version](https://img.shields.io/npm/v/@superset-ui/core.svg?style=flat)](https://www.npmjs.com/package/@superset-ui/core)
[![Libraries.io](https://img.shields.io/librariesio/release/npm/%40superset-ui%2Fcore?style=flat)](https://libraries.io/npm/@superset-ui%2Fcore)
The core package for Apache Superset's frontend. It provides shared utilities,
types, and abstractions used across all Superset chart plugins and UI components.
Key modules include:
- **query** — Utilities for building queries and calling the Superset API
(including `makeApi`)
- **number-format** — Number formatting helpers powered by d3-format
- **time-format** — Time/date formatting helpers powered by d3-time-format
- **connection** — `SupersetClient`, the HTTP client for the Superset REST API
- **chart** — Base classes and types for building chart plugins
> **Note:** i18n utilities (`t`, `tn`, etc.) are no longer part of this package.
> They now live in `@apache-superset/core`, imported from
> `@apache-superset/core/translation`.
Description
#### Example usage
```js
import { getNumberFormatter, makeApi } from '@superset-ui/core';
import { t } from '@apache-superset/core/translation';
// Format a number
const formatter = getNumberFormatter('.2f');
console.log(formatter(1234.5)); // "1234.50"
// Translate a string
console.log(t('Hello %s', 'world'));
// Call a Superset API endpoint
const fetchDashboards = makeApi({
method: 'GET',
endpoint: '/api/v1/dashboard',
});
import { xxx } from '@superset-ui/core';
```
#### API
`fn(args)`
- TBD
### Development
`@data-ui/build-config` is used to manage the build configuration for this package
including babel builds, jest testing, eslint, and prettier.
Run tests:
```bash
cd superset-frontend
npx jest packages/superset-ui-core
```
`@data-ui/build-config` is used to manage the build configuration for this package including babel
builds, jest testing, eslint, and prettier.

View File

@@ -52,7 +52,7 @@
"parse-ms": "^4.0.0",
"re-resizable": "^6.11.2",
"react-ace": "^14.0.1",
"react-draggable": "^4.7.0",
"react-draggable": "^4.6.0",
"react-error-boundary": "6.0.0",
"react-js-cron": "^5.2.0",
"react-markdown": "^8.0.7",

View File

@@ -0,0 +1,363 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* Overflow-engine regression tests for DropdownContainer.
*
* jsdom has no real layout, so these tests drive the component's real overflow
* recalculation by mocking the two measurement sources it reads:
* 1. `useResizeDetector` — supplies the container width.
* 2. `getBoundingClientRect` — supplies per-element geometry. The inner
* `data-test="container"` spans [0, containerRight]; every child is
* ITEM_W wide and laid out left-to-right by its DOM index, so children
* whose right edge exceeds `containerRight` overflow.
*
* This exercises the production code path in DropdownContainer.tsx
* (useLayoutEffect → overflowingIndex → notOverflowedItems/overflowedItems →
* showDropdownButton) rather than mocking the result.
*/
import { screen, render, waitFor, act } from '@superset-ui/core/spec';
import * as resizeDetector from 'react-resize-detector';
import { DropdownContainer } from '..';
const ITEM_W = 100;
// 350px container ⇒ at most 3 items (rights 100/200/300) fit before overflow.
const BAR_WIDTH = 350;
// Mutable so a test can simulate the transient layout window where a freshly
// enlarged item set is momentarily measured as fitting before reflow settles.
let containerRight = BAR_WIDTH;
// Mutable width fed to the component through the mocked resize detector.
let mockWidth = 0;
// Stable ref object React attaches the outer node to (mirrors useResizeDetector).
const fakeRef: { current: HTMLDivElement | null } = { current: null };
const buildRect = (left: number, right: number): DOMRect =>
({
left,
right,
width: right - left,
top: 0,
bottom: 0,
height: 0,
x: left,
y: 0,
toJSON: () => ({}),
}) as DOMRect;
const installLayoutMock = () => {
HTMLElement.prototype.getBoundingClientRect = function mockRect(
this: HTMLElement,
) {
const dataTest = this.getAttribute?.('data-test');
if (dataTest === 'container') {
return buildRect(0, containerRight);
}
const parent = this.parentElement;
if (parent?.getAttribute?.('data-test') === 'container') {
const index = Array.prototype.indexOf.call(parent.children, this);
return buildRect(index * ITEM_W, index * ITEM_W + ITEM_W);
}
// Outer wrapper div (its first child is the inner container).
if (
(this.children[0] as HTMLElement | undefined)?.getAttribute?.(
'data-test',
) === 'container'
) {
return buildRect(0, containerRight);
}
return buildRect(0, 0);
};
};
let resizeSpy: jest.SpyInstance;
let rafSpy: jest.SpyInstance;
let cancelRafSpy: jest.SpyInstance;
// Deterministic requestAnimationFrame: the component schedules a one-shot
// confirmation frame to re-measure after an item-set change. Rather than sleep
// and hope jsdom's timer-backed rAF fires inside the window, we capture the
// callbacks and invoke them explicitly via flushRAF(). cancelAnimationFrame
// removes a queued frame, so the supersession path can be exercised directly.
let rafQueue: Array<{ id: number; cb: FrameRequestCallback }> = [];
let rafSeq = 0;
// Run every currently-queued frame once (frames scheduled during the flush are
// left for the next flush, so a single call models a single browser frame).
const flushRAF = () => {
const pending = rafQueue;
rafQueue = [];
pending.forEach(({ cb }) => cb(0));
};
beforeEach(() => {
containerRight = BAR_WIDTH;
mockWidth = 0;
fakeRef.current = null;
rafQueue = [];
rafSeq = 0;
installLayoutMock();
global.ResizeObserver = jest.fn().mockImplementation(() => ({
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn(),
})) as unknown as typeof ResizeObserver;
rafSpy = jest
.spyOn(window, 'requestAnimationFrame')
.mockImplementation((cb: FrameRequestCallback) => {
rafSeq += 1;
rafQueue.push({ id: rafSeq, cb });
return rafSeq;
});
cancelRafSpy = jest
.spyOn(window, 'cancelAnimationFrame')
.mockImplementation((id: number) => {
rafQueue = rafQueue.filter(frame => frame.id !== id);
});
resizeSpy = jest
.spyOn(resizeDetector, 'useResizeDetector')
.mockImplementation(
() =>
({ ref: fakeRef, width: mockWidth, height: 50 }) as ReturnType<
typeof resizeDetector.useResizeDetector
>,
);
});
afterEach(() => {
resizeSpy?.mockRestore();
rafSpy?.mockRestore();
cancelRafSpy?.mockRestore();
});
const makeItem = (id: string, label: string) => ({
id,
element: <div data-test={`item-${id}`}>{label}</div>,
});
const nativeFilters = (count: number) =>
Array.from({ length: count }, (_, i) =>
makeItem(`native-filter-${i + 1}`, `Filter ${i + 1}`),
);
const barItemCount = () => screen.getByTestId('container').children.length;
// Render, then apply a measured width so the overflow layout effect runs with
// the outer node attached (mirrors the first real resize-detector callback).
const renderOverflowing = async (
items: ReturnType<typeof nativeFilters>,
): Promise<{ rerender: (ui: JSX.Element) => void }> => {
const { rerender } = render(<DropdownContainer items={items} />);
await act(async () => {
mockWidth = BAR_WIDTH;
rerender(<DropdownContainer items={items} />);
});
await waitFor(() => expect(screen.getByText('More')).toBeInTheDocument());
return { rerender };
};
test('control: a clean re-measurement keeps overflowed items reachable after a chip is prepended', async () => {
const filters = nativeFilters(8);
const { rerender } = await renderOverflowing(filters);
// 3 of 8 fit in the bar, the rest are reachable via the More button.
expect(barItemCount()).toBe(3);
// Prepend a cross-filter chip, shifting every native-filter index by one.
const withCrossFilterChip = [
makeItem('cross-filter-chip', 'Region'),
...filters,
];
await act(async () => {
rerender(<DropdownContainer items={withCrossFilterChip} />);
});
await act(async () => {
flushRAF();
});
await waitFor(() => expect(barItemCount()).toBe(3));
// With faithful measurement the engine recovers to the exact split: 3 fit,
// the rest stay accessible behind the trigger.
expect(screen.queryByText('More')).toBeInTheDocument();
expect(barItemCount()).toBe(3);
});
test('overflowed-to-true-fit: when items genuinely fit after a set change, all are in the bar and the trigger is gone', async () => {
// Start from an overflowed steady state: 8 items, 3 in bar, More visible.
const filters = nativeFilters(8);
const { rerender } = await renderOverflowing(filters);
expect(barItemCount()).toBe(3);
expect(screen.queryByText('More')).toBeInTheDocument();
// Reduce to 3 items — they all fit inside the 350 px bar without overflow.
const fewFilters = nativeFilters(3);
await act(async () => {
rerender(<DropdownContainer items={fewFilters} />);
});
await act(async () => {
flushRAF();
});
// After measurement (and confirmation pass if any), the trigger is gone and
// all 3 items are in the bar. This guards the fix against over-correction:
// if the confirmation logic erroneously kept the trigger visible when items
// genuinely fit, this assertion would catch it.
await waitFor(() => {
expect(screen.queryByText('More')).not.toBeInTheDocument();
});
expect(barItemCount()).toBe(3);
});
test('prepending a cross-filter chip must not strand overflowed native filters or hide the More button', async () => {
const filters = nativeFilters(8);
const { rerender } = await renderOverflowing(filters);
expect(barItemCount()).toBe(3);
// Simulate the production race: as the cross-filter chip is added the item
// set grows, overflowingIndex is reset to -1 (all items dumped into the bar)
// and the re-measurement runs against a transient layout that momentarily
// reports everything fits. (More filters ⇒ larger reflow ⇒ wider window,
// matching the report's "depends on filter count".)
containerRight = Number.MAX_SAFE_INTEGER;
const withCrossFilterChip = [
makeItem('cross-filter-chip', 'Region'),
...filters,
];
await act(async () => {
rerender(<DropdownContainer items={withCrossFilterChip} />);
});
// The window closes — the filters genuinely overflow the bar again — but no
// resize/width change occurs, so only the scheduled confirmation frame can
// rescue the verdict. Fire it.
containerRight = BAR_WIDTH;
await act(async () => {
flushRAF();
});
// Invariant: overflowed items must remain accessible AND the split must be
// CORRECT. Asserting the exact count (3 fit, the rest behind the trigger),
// not merely `< total`, so an under-detecting confirmation that strands too
// many items in the clipped bar also fails this guard.
expect(barItemCount()).toBe(3);
expect(screen.queryByText('More')).toBeInTheDocument();
});
test('fit-to-overflow: an item-set change that tips a fitting bar into overflow during a transient must not strand items', async () => {
// Start with a bar that FITS: 3 items, no overflow, no trigger. The overflow
// engine settles overflowingIndex === -1 here.
const fewFilters = nativeFilters(3);
const { rerender } = render(<DropdownContainer items={fewFilters} />);
await act(async () => {
mockWidth = BAR_WIDTH;
rerender(<DropdownContainer items={fewFilters} />);
});
await waitFor(() => expect(barItemCount()).toBe(3));
expect(screen.queryByText('More')).not.toBeInTheDocument();
// Grow the set so it now genuinely overflows, but measure it during a
// transient window where the bar momentarily appears to still fit. Because
// the bar was previously fitting, this takes the "measure" path, not the
// reset path — the case the original fix armed NO confirmation for, so a
// transient "-1" would latch (all items crammed, trigger gone) with no
// rescue. The hardened engine arms a confirmation on every item-set change.
containerRight = Number.MAX_SAFE_INTEGER;
const manyFilters = nativeFilters(8);
await act(async () => {
rerender(<DropdownContainer items={manyFilters} />);
});
// Window closes; the scheduled confirmation frame re-measures and corrects.
containerRight = BAR_WIDTH;
await act(async () => {
flushRAF();
});
expect(barItemCount()).toBe(3);
expect(screen.queryByText('More')).toBeInTheDocument();
});
test('a second item-set change before the confirmation frame fires still settles the correct split (re-entrancy regression)', async () => {
// Regression guard for rapid successive changes: prepend two chips in quick
// succession (each during a transient), then let the frame(s) fire. The
// hardened engine supersedes the stale frame and arms a fresh confirmation
// for the latest set; this locks in the correct end state under re-entrancy.
const filters = nativeFilters(8);
const { rerender } = await renderOverflowing(filters);
expect(barItemCount()).toBe(3);
containerRight = Number.MAX_SAFE_INTEGER;
const withOneChip = [makeItem('cross-filter-chip', 'Region'), ...filters];
await act(async () => {
rerender(<DropdownContainer items={withOneChip} />);
});
const withTwoChips = [
makeItem('cross-filter-chip-2', 'Segment'),
...withOneChip,
];
await act(async () => {
rerender(<DropdownContainer items={withTwoChips} />);
});
containerRight = BAR_WIDTH;
await act(async () => {
flushRAF();
});
expect(barItemCount()).toBe(3);
expect(screen.queryByText('More')).toBeInTheDocument();
});
test('a stale confirmation frame cannot undo a normal overflow settle before it fires', async () => {
const filters = nativeFilters(8);
const { rerender } = await renderOverflowing(filters);
expect(barItemCount()).toBe(3);
// Keep the frame in the queue even when the component cancels it, so this
// test exercises the callback-level stale guard as well as cancellation.
cancelRafSpy.mockImplementation(() => {});
rafSpy.mockImplementationOnce((cb: FrameRequestCallback) => {
rafSeq += 1;
rafQueue.push({ id: rafSeq, cb });
// Model a transient "fits" measurement that closes immediately after the
// confirmation is queued. The setItemsWidth render then re-runs the layout
// effect before the frame fires and settles the correct overflow split.
containerRight = BAR_WIDTH;
return rafSeq;
});
containerRight = Number.MAX_SAFE_INTEGER;
const withCrossFilterChip = [
makeItem('cross-filter-chip', 'Region'),
...filters,
];
await act(async () => {
rerender(<DropdownContainer items={withCrossFilterChip} />);
});
await waitFor(() => expect(barItemCount()).toBe(3));
expect(screen.queryByText('More')).toBeInTheDocument();
await act(async () => {
flushRAF();
});
expect(barItemCount()).toBe(3);
expect(screen.queryByText('More')).toBeInTheDocument();
});

View File

@@ -81,6 +81,53 @@ export const DropdownContainer = forwardRef(
// when nothing actually overflows.
const [recalculating, setRecalculating] = useState(false);
// One-shot confirmation pass: when the layout effect settles on "nothing
// overflows" right after an item-set-change reset, the geometry may still
// be mid-reflow. These refs coordinate a single rAF follow-up measurement
// per item-set change so a transiently-bad "fits" verdict cannot latch.
//
// pendingConfirmForLengthRef: holds the items.length for which a
// confirmation is pending (-1 = none pending). Set in the reset (else)
// branch; cleared by the rAF callback after it settles.
const pendingConfirmForLengthRef = useRef(-1);
// confirmationScheduledRef: true once the rAF has been requested for the
// current pending length, preventing a second rAF on the setItemsWidth
// re-run that follows the first provisional measurement.
const confirmationScheduledRef = useRef(false);
// hadContentAtLastChangeRef: true when the trigger was showing at the
// moment the most recent item-set change was detected. Keeps the trigger
// mounted across the entire confirmation window (not just one render cycle)
// without letting it linger once the rAF callback has settled. Cleared by
// the rAF callback before calling setRecalculating(false).
const hadContentAtLastChangeRef = useRef(false);
// Guards rAF callbacks from firing after the component unmounts.
const mountedRef = useRef(true);
// Stores the pending confirmation rAF handle so it can be cancelled when a
// newer item-set change supersedes it, or on unmount.
const rafIdRef = useRef(0);
// Bumped on every item-set change. A scheduled rAF captures the version at
// schedule time and ignores itself if a newer change has superseded it, so
// a stale frame can never clobber a newer item set's state.
const confirmVersionRef = useRef(0);
// The items.length the layout effect last observed, used to detect a new
// item set (additions/removals) on any measurement path, not just the reset.
const prevItemsLengthRef = useRef(items.length);
useEffect(
() => () => {
mountedRef.current = false;
if (rafIdRef.current) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = 0;
}
},
[],
);
// Persists the inner container element for the rAF confirmation callback.
// Updated each time the layout effect finds a valid container so the rAF
// does not need to re-derive it through ref.current, which may be null by
// the time the callback fires in certain timing / test scenarios.
const containerRef = useRef<Element | null>(null);
// callback to update item widths so that the useLayoutEffect runs whenever
// width of any of the child changes
const recalculateItemWidths = useCallback(() => {
@@ -163,14 +210,66 @@ export const DropdownContainer = forwardRef(
};
}, [items.length, current, recalculateItemWidths]);
const overflowingCount =
overflowingIndex !== -1 ? items.length - overflowingIndex : 0;
const popoverContent = useMemo(
() =>
dropdownContent || overflowingCount ? (
<div
css={css`
display: flex;
flex-direction: column;
gap: ${theme.sizeUnit * 4}px;
`}
data-test="dropdown-content"
style={dropdownStyle}
ref={targetRef}
>
{dropdownContent
? dropdownContent(overflowedItems)
: overflowedItems.map(item => item.element)}
</div>
) : null,
[
dropdownContent,
overflowingCount,
theme.sizeUnit,
dropdownStyle,
overflowedItems,
],
);
useLayoutEffect(() => {
if (popoverVisible) {
return;
}
const container = current?.children.item(0);
if (container) {
containerRef.current = container;
const { children } = container;
const childrenArray = Array.from(children);
// Detect a new item set (additions/removals shift the positional
// measurements the overflow split relies on). Arm a confirmation pass
// for it here so EVERY measurement path below — not just the reset
// branch — gets a follow-up; otherwise a fit->overflow transition (the
// bar was fitting, so the reset branch is skipped) could settle a
// transient "fits" verdict with no rescue. Also supersede any
// confirmation still pending for the previous item set: bump the version
// (so its stale rAF ignores itself) and cancel its frame.
if (prevItemsLengthRef.current !== items.length) {
prevItemsLengthRef.current = items.length;
pendingConfirmForLengthRef.current = items.length;
confirmationScheduledRef.current = false;
hadContentAtLastChangeRef.current = !!popoverContent;
confirmVersionRef.current += 1;
if (rafIdRef.current) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = 0;
}
}
// If items length change, add all items to the container
// and recalculate the widths
if (itemsWidth.length !== items.length) {
@@ -211,6 +310,12 @@ export const DropdownContainer = forwardRef(
// Checks if some elements in the dropdown fits in the remaining space
let sum = 0;
for (let i = childrenArray.length; i < items.length; i += 1) {
// Guard: itemsWidth may be stale when its length doesn't match the
// current item set (its updater bails on a length mismatch). An
// undefined entry would otherwise inject NaN into the sum.
if (itemsWidth[i] === undefined) {
break;
}
sum += itemsWidth[i];
if (sum <= remainingSpace) {
newOverflowingIndex = i + 1;
@@ -220,6 +325,73 @@ export const DropdownContainer = forwardRef(
}
}
// A "nothing overflows" verdict on the pass that consumed an item-set-
// change reset may reflect a transient mid-reflow measurement. When that
// happens, do NOT settle immediately. Instead:
// • If the rAF hasn't been scheduled yet: schedule it (one-shot) and
// return without settling; recalculating stays true so the trigger
// remains mounted throughout the confirmation window.
// • If the rAF is already scheduled (a second layout effect run
// triggered by the setItemsWidth call above): also return without
// settling for the same reason.
// The rAF callback reads the DOM directly at a point where the browser
// has reflowed and calls the setters itself. It also resets the guard
// refs so subsequent effect runs (e.g. from a real resize) behave
// normally.
if (
newOverflowingIndex === -1 &&
pendingConfirmForLengthRef.current === items.length
) {
if (!confirmationScheduledRef.current) {
confirmationScheduledRef.current = true;
const scheduledVersion = confirmVersionRef.current;
rafIdRef.current = requestAnimationFrame(() => {
rafIdRef.current = 0;
if (!mountedRef.current) return;
// A newer item-set change superseded this confirmation while the
// frame was queued; let the newer one's own confirmation settle.
if (confirmVersionRef.current !== scheduledVersion) return;
// The normal layout-effect settle path can run before this
// frame (for example, from the setItemsWidth render) and clear
// the pending confirmation. In that case this queued frame is
// stale and must not overwrite the settled overflow index.
if (
pendingConfirmForLengthRef.current !== items.length ||
!confirmationScheduledRef.current
) {
return;
}
// Reset guard refs so future layout effect runs are unaffected.
pendingConfirmForLengthRef.current = -1;
confirmationScheduledRef.current = false;
hadContentAtLastChangeRef.current = false;
const el = containerRef.current;
if (!el) {
setOverflowingIndex(-1);
setRecalculating(false);
return;
}
const kids = Array.from(el.children);
const confirmIdx = kids.findIndex(
c =>
c.getBoundingClientRect().right >
el.getBoundingClientRect().right + 1,
);
setOverflowingIndex(confirmIdx);
setRecalculating(false);
});
}
// Either way (just scheduled or already pending): hold off settling so
// recalculating stays true and the button guard keeps the trigger mounted.
return;
}
pendingConfirmForLengthRef.current = -1;
confirmationScheduledRef.current = false;
if (rafIdRef.current) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = 0;
}
setOverflowingIndex(newOverflowingIndex);
setRecalculating(false);
}
@@ -242,44 +414,14 @@ export const DropdownContainer = forwardRef(
}
}, [notOverflowedIds, onOverflowingStateChange, overflowedIds]);
const overflowingCount =
overflowingIndex !== -1 ? items.length - overflowingIndex : 0;
const popoverContent = useMemo(
() =>
dropdownContent || overflowingCount ? (
<div
css={css`
display: flex;
flex-direction: column;
gap: ${theme.sizeUnit * 4}px;
`}
data-test="dropdown-content"
style={dropdownStyle}
ref={targetRef}
>
{dropdownContent
? dropdownContent(overflowedItems)
: overflowedItems.map(item => item.element)}
</div>
) : null,
[
dropdownContent,
overflowingCount,
theme.sizeUnit,
dropdownStyle,
overflowedItems,
],
);
// The trigger had content in the previous render if popoverContent was
// truthy then. During the brief mid-recalculation render where
// popoverContent flips to null, this still reflects the prior (non-empty)
// value, letting us keep the trigger mounted across the transient.
const hadPopoverContent = usePrevious(!!popoverContent, false);
// During the rAF confirmation window recalculating stays true (the layout
// effect returns early without settling). hadContentAtLastChangeRef tracks
// whether the trigger was showing when the item-set change was detected; it
// stays true across all renders until the rAF callback clears it. Together
// they keep the trigger mounted for the full confirmation window without
// letting it linger once the rAF has settled.
const showDropdownButton =
!!popoverContent || (recalculating && hadPopoverContent);
!!popoverContent || (recalculating && hadContentAtLastChangeRef.current);
useLayoutEffect(() => {
if (popoverVisible) {

View File

@@ -22,7 +22,7 @@ import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
// remark-gfm v4+ requires react-markdown v9+, which requires React 18.
// Currently pinned to v3.0.1 for compatibility with react-markdown v8 and React 17.
import remarkGfm from 'remark-gfm';
import { cloneDeep, mergeWith } from 'lodash';
import { mergeWith } from 'lodash';
import { FeatureFlag, isFeatureEnabled } from '../../utils';
interface SafeMarkdownProps {
@@ -85,15 +85,8 @@ export function getOverrideHtmlSchema(
originalSchema: typeof defaultSchema,
htmlSchemaOverrides: SafeMarkdownProps['htmlSchemaOverrides'],
) {
// Merge into a fresh clone: mergeWith mutates its first argument, and the
// array customizer concatenates, so merging into the shared defaultSchema
// import would progressively widen the sanitization allowlist for every
// SafeMarkdown instance app-wide.
return mergeWith(
cloneDeep(originalSchema),
htmlSchemaOverrides,
(objValue, srcValue) =>
Array.isArray(objValue) ? objValue.concat(srcValue) : undefined,
return mergeWith(originalSchema, htmlSchemaOverrides, (objValue, srcValue) =>
Array.isArray(objValue) ? objValue.concat(srcValue) : undefined,
);
}

View File

@@ -113,7 +113,7 @@ const AsyncSelect = forwardRef(
allowClear,
allowNewOptions = false,
ariaLabel,
autoClearSearchValue = true,
autoClearSearchValue = false,
fetchOnlyOnSearch,
filterOption = true,
header = null,
@@ -267,12 +267,6 @@ const AsyncSelect = forwardRef(
});
fireOnChange();
}
if (autoClearSearchValue) {
setInputValue('');
if (fetchOnlyOnSearch) {
setSelectOptions([]);
}
}
onSelect?.(selectedItem, option);
};

View File

@@ -404,7 +404,7 @@ AdvancedPlayground.args = {
autoFocus: true,
allowNewOptions: false,
allowClear: false,
autoClearSearchValue: true,
autoClearSearchValue: false,
allowSelectAll: true,
disabled: false,
invertSelection: false,

View File

@@ -1146,127 +1146,6 @@ test('pasting an non-existent option should not add it if allowNewOptions is fal
expect(await findAllSelectOptions()).toHaveLength(0);
});
// Reference for the bug this tests: https://github.com/apache/superset/issues/32645
// Dashboard filters with "Dynamically search all filter values" only load a
// page of options client-side, so a pasted value outside that page used to be
// silently dropped. allowNewOptionsOnPaste keeps such values so the filter can
// still apply them.
test('keeps pasted values outside loaded options when allowNewOptionsOnPaste is true', async () => {
const onChange = jest.fn();
render(
<Select
{...defaultProps}
mode="multiple"
allowNewOptions={false}
allowNewOptionsOnPaste
onChange={onChange}
/>,
);
const input = getElementByClassName('.ant-select-selection-search-input');
const paste = createEvent.paste(input, {
clipboardData: {
// Liam is a loaded option; OutsideValue is not in the loaded page.
getData: () => 'Liam,OutsideValue',
},
});
fireEvent(input, paste);
await waitFor(() => {
const values = [
...getElementsByClassName('.ant-select-selection-item'),
].map(value => value.textContent);
// The paste handler appends, so the loaded option resolves first.
expect(values).toEqual(['Liam', 'OutsideValue']);
});
// Assert the unloaded value actually reaches the change handler (the value
// that gets applied to the filter query), not just the rendered label.
expect(onChange).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ value: 'OutsideValue' }),
]),
expect.anything(),
);
});
test('trims whitespace around pasted comma-separated values', async () => {
const onChange = jest.fn();
render(
<Select
{...defaultProps}
mode="multiple"
allowNewOptions={false}
allowNewOptionsOnPaste
onChange={onChange}
/>,
);
const input = getElementByClassName('.ant-select-selection-search-input');
const paste = createEvent.paste(input, {
clipboardData: {
// Note the space after the comma — it must not leak into the value.
getData: () => 'Liam, OutsideValue',
},
});
fireEvent(input, paste);
await waitFor(() => {
const values = [
...getElementsByClassName('.ant-select-selection-item'),
].map(value => value.textContent);
expect(values).toEqual(['Liam', 'OutsideValue']);
});
expect(onChange).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ value: 'OutsideValue' }),
]),
expect.anything(),
);
});
test('does not create an empty option when pasting blank text', async () => {
const onChange = jest.fn();
render(
<Select
{...defaultProps}
mode="multiple"
allowNewOptions={false}
allowNewOptionsOnPaste
onChange={onChange}
/>,
);
const input = getElementByClassName('.ant-select-selection-search-input');
const paste = createEvent.paste(input, {
clipboardData: {
getData: () => ' ',
},
});
fireEvent(input, paste);
await waitFor(() => {
const values = [
...getElementsByClassName('.ant-select-selection-item'),
].map(value => value.textContent);
expect(values).toEqual([]);
});
// No empty-string value should ever reach the handler.
onChange.mock.calls.forEach(([value]) => {
expect(value).not.toContain('');
});
});
test('drops pasted values outside loaded options when allowNewOptionsOnPaste is false', async () => {
render(<Select {...defaultProps} mode="multiple" allowNewOptions={false} />);
const input = getElementByClassName('.ant-select-selection-search-input');
const paste = createEvent.paste(input, {
clipboardData: {
getData: () => 'Liam,OutsideValue',
},
});
fireEvent(input, paste);
await waitFor(() => {
const values = [
...getElementsByClassName('.ant-select-selection-item'),
].map(value => value.textContent);
expect(values).toEqual(['Liam']);
});
});
test('does not fire onChange if the same value is selected in single mode', async () => {
const onChange = jest.fn();
render(<Select {...defaultProps} onChange={onChange} />);

View File

@@ -91,10 +91,9 @@ const Select = forwardRef(
className,
allowClear,
allowNewOptions = false,
allowNewOptionsOnPaste = false,
allowSelectAll = true,
ariaLabel,
autoClearSearchValue = true,
autoClearSearchValue = false,
filterOption = true,
header = null,
headerPosition = 'top',
@@ -334,11 +333,6 @@ const Select = forwardRef(
});
fireOnChange();
}
if (autoClearSearchValue) {
setInputValue('');
setIsSearching(false);
setVisibleOptions(fullSelectOptions);
}
onSelect?.(selectedItem, option);
};
@@ -698,34 +692,20 @@ const Select = forwardRef(
}
} else {
const token = tokenSeparators.find(token => pastedText.includes(token));
const array = token
? uniq(
pastedText
.split(token)
.map(item => item.trim())
.filter(Boolean),
)
: [pastedText.trim()].filter(Boolean);
const array = token ? uniq(pastedText.split(token)) : [pastedText];
const newOptions: SelectOptionsType = [];
// When `allowNewOptionsOnPaste` is set, accept pasted values that are
// not in the loaded options even if `allowNewOptions` is false. The
// full option set is searched server-side and only partially loaded
// client-side, so a pasted value can legitimately exist in the dataset
// but fall outside the loaded page.
const keepUnknownValues = allowNewOptions || allowNewOptionsOnPaste;
const values = array
.map(item => {
const option = getOption(item, fullSelectOptions, true);
if (!option && keepUnknownValues) {
if (!option && allowNewOptions) {
const newOption = {
label: item,
value: item,
isNewOption: true,
};
newOptions.push(newOption);
return labelInValue ? { label: item, value: item } : item;
}
return getPastedTextValue(item);
})

View File

@@ -88,18 +88,6 @@ export interface BaseSelectProps extends AntdExposedProps {
* False by default.
* */
allowNewOptions?: boolean;
/**
* Accept values pasted into the Select even when they are not part of the
* currently loaded options and `allowNewOptions` is false. Useful for
* selects whose full option set is searched server-side and only partially
* loaded on the client (e.g. dashboard filters with "Dynamically search all
* filter values"), where a pasted value can legitimately exist in the
* dataset but fall outside the loaded page.
* Only applies to multi-select paste; single-select paste resolves through
* `allowNewOptions` and ignores this flag.
* False by default.
* */
allowNewOptionsOnPaste?: boolean;
/**
* It adds the aria-label tag for accessibility standards.
* Must be plain English and localized.

View File

@@ -52,7 +52,6 @@ const SupersetClient: SupersetClientInterface = {
request: request => getInstance().request(request),
getCSRFToken: () => getInstance().getCSRFToken(),
getUrl: (...args) => getInstance().getUrl(...args),
postBlob: (endpoint, payload) => getInstance().postBlob(endpoint, payload),
get guestTokenHeaderName() {
try {
return getInstance().guestTokenHeaderName;

View File

@@ -150,26 +150,6 @@ export default class SupersetClientClass {
}
}
/**
* POST request that returns a blob for file downloads.
* Unlike postForm, this uses AJAX so errors can be caught and handled.
* @param endpoint - API endpoint
* @param payload - Request payload
* @returns Promise resolving to Response with blob
*/
async postBlob(
endpoint: string,
payload: Record<string, any>,
): Promise<Response> {
await this.ensureAuth();
return this.post({
endpoint,
postPayload: payload,
parseMethod: 'raw',
stringify: false,
});
}
async reAuthenticate() {
return this.init(true);
}

View File

@@ -152,7 +152,6 @@ export interface SupersetClientInterface extends Pick<
| 'get'
| 'post'
| 'postForm'
| 'postBlob'
| 'put'
| 'request'
| 'init'

View File

@@ -17,8 +17,6 @@
* under the License.
*/
import { render } from '@testing-library/react';
import { cloneDeep } from 'lodash';
import { defaultSchema } from 'rehype-sanitize';
import {
getOverrideHtmlSchema,
SafeMarkdown,
@@ -53,36 +51,6 @@ describe('getOverrideHtmlSchema', () => {
expect(result.attributes).toEqual({ '*': ['size', 'src'], h1: ['style'] });
expect(result.tagNames).toEqual(['h1', 'h2', 'h3', 'iframe']);
});
test('should not mutate the original schema', () => {
const original = {
attributes: { '*': ['size'] },
tagNames: ['h1'],
};
getOverrideHtmlSchema(original, {
attributes: { '*': ['src'] },
tagNames: ['iframe'],
});
// The original passed in is left untouched.
expect(original.attributes).toEqual({ '*': ['size'] });
expect(original.tagNames).toEqual(['h1']);
});
test('should not mutate the shared defaultSchema import or accumulate across calls', () => {
const snapshot = cloneDeep(defaultSchema);
const overrides = { tagNames: ['iframe'] };
const first = getOverrideHtmlSchema(defaultSchema, overrides);
const second = getOverrideHtmlSchema(defaultSchema, overrides);
// The shared singleton is never modified...
expect(defaultSchema).toEqual(snapshot);
// ...and repeated calls do not accumulate the override (no growing arrays).
expect(first.tagNames).toEqual(second.tagNames);
expect(
(second.tagNames ?? []).filter(name => name === 'iframe'),
).toHaveLength(1);
});
});
describe('transformLinkUri', () => {

View File

@@ -36,13 +36,12 @@ describe('SupersetClient', () => {
getUrl: (...args: unknown[]) => string;
};
test('exposes configure, init, get, post, postForm, postBlob, delete, put, request, reset, getGuestToken, getCSRFToken, getUrl, isAuthenticated, and reAuthenticate methods', () => {
test('exposes configure, init, get, post, postForm, delete, put, request, reset, getGuestToken, getCSRFToken, getUrl, isAuthenticated, and reAuthenticate methods', () => {
expect(typeof SupersetClient.configure).toBe('function');
expect(typeof SupersetClient.init).toBe('function');
expect(typeof SupersetClient.get).toBe('function');
expect(typeof SupersetClient.post).toBe('function');
expect(typeof SupersetClient.postForm).toBe('function');
expect(typeof SupersetClient.postBlob).toBe('function');
expect(typeof SupersetClient.delete).toBe('function');
expect(typeof SupersetClient.put).toBe('function');
expect(typeof SupersetClient.request).toBe('function');
@@ -54,12 +53,11 @@ describe('SupersetClient', () => {
expect(typeof SupersetClient.reAuthenticate).toBe('function');
});
test('throws if you call init, get, post, postForm, postBlob, delete, put, request, getGuestToken, getCSRFToken, getUrl, isAuthenticated, or reAuthenticate before configure', () => {
test('throws if you call init, get, post, postForm, delete, put, request, getGuestToken, getCSRFToken, getUrl, isAuthenticated, or reAuthenticate before configure', () => {
expect(SupersetClient.init).toThrow();
expect(SupersetClient.get).toThrow();
expect(SupersetClient.post).toThrow();
expect(SupersetClient.postForm).toThrow();
expect(SupersetClient.postBlob).toThrow();
expect(SupersetClient.delete).toThrow();
expect(SupersetClient.put).toThrow();
expect(SupersetClient.request).toThrow();

View File

@@ -780,75 +780,4 @@ describe('SupersetClientClass', () => {
expect(authSpy).toHaveBeenCalledTimes(0);
});
});
describe('.postBlob()', () => {
const protocol = 'https:';
const host = 'host';
const mockPostBlobEndpoint = '/api/v1/chart/data';
const mockPostBlobUrl = `${protocol}//${host}${mockPostBlobEndpoint}`;
const postBlobPayload = { form_data: '{"viz_type":"table"}' };
let authSpy: jest.SpyInstance;
let client: SupersetClientClass;
beforeEach(async () => {
fetchMock.removeRoute(LOGIN_GLOB);
fetchMock.get(LOGIN_GLOB, { result: 1234 }, { name: LOGIN_GLOB });
client = new SupersetClientClass({ protocol, host });
await client.init();
authSpy = jest.spyOn(SupersetClientClass.prototype, 'ensureAuth');
});
afterEach(() => {
jest.restoreAllMocks();
});
test('calls ensureAuth and delegates to post with raw parseMethod', async () => {
const mockResponse = new Response('csv data', { status: 200 });
const postSpy = jest
.spyOn(client, 'post')
.mockResolvedValue(mockResponse);
const response = await client.postBlob(
mockPostBlobEndpoint,
postBlobPayload,
);
expect(authSpy).toHaveBeenCalledTimes(1);
expect(postSpy).toHaveBeenCalledWith({
endpoint: mockPostBlobEndpoint,
postPayload: postBlobPayload,
parseMethod: 'raw',
stringify: false,
});
expect(response).toBe(mockResponse);
});
test('passes payload in request body', async () => {
fetchMock.post(mockPostBlobUrl, {
status: 200,
body: 'csv data',
});
await client.postBlob(mockPostBlobEndpoint, postBlobPayload);
const fetchRequest = fetchMock.callHistory.calls(mockPostBlobUrl)[0]
.options as CallApi;
const formData = fetchRequest.body as FormData;
expect(formData.get('form_data')).toBe(postBlobPayload.form_data);
});
test('rejects when response is not ok', async () => {
fetchMock.post(mockPostBlobUrl, {
status: 413,
body: 'Payload Too Large',
});
await expect(
client.postBlob(mockPostBlobEndpoint, postBlobPayload),
).rejects.toMatchObject({ status: 413 });
});
});
});

View File

@@ -21,12 +21,10 @@
import d3 from 'd3';
import { extent as d3Extent } from 'd3-array';
import {
BinaryQueryObjectFilterClause,
CategoricalColorNamespace,
ContextMenuFilters,
DataMask,
ValueFormatter,
getNumberFormatter,
getSequentialSchemeRegistry,
CategoricalColorNamespace,
} from '@superset-ui/core';
import countries, { countryOptions } from './countries';
@@ -67,28 +65,9 @@ interface CountryMapProps {
formatter: ValueFormatter;
colorScheme: string;
sliceId: number;
onContextMenu?: (
clientX: number,
clientY: number,
data: ContextMenuFilters,
) => void;
emitCrossFilters?: boolean;
setDataMask?: (dataMask: DataMask) => void;
filterState?: {
selectedValues?: string[];
extraFormData?: {
filters?: BinaryQueryObjectFilterClause[];
};
};
entity?: string;
}
const maps: Record<string, GeoData> = {};
// Store zoom state per chart instance using element as key to enable garbage collection
const zoomStates = new WeakMap<
HTMLElement,
{ scale: number; translate: [number, number] }
>();
function CountryMap(element: HTMLElement, props: CountryMapProps) {
const {
@@ -96,15 +75,10 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
width,
height,
country,
entity,
linearColorScheme,
formatter,
colorScheme,
sliceId,
filterState,
emitCrossFilters,
onContextMenu,
setDataMask,
} = props;
const container = element;
@@ -125,15 +99,7 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
? colorScale(d.country_id, sliceId)
: (linearColorScale(d.metric) ?? '');
});
const colorFn = (feature: GeoFeature): string => {
if (!feature?.properties) return '#d9d9d9';
const iso = feature.properties.ISO;
return colorMap[iso] || '#d9d9d9';
};
// Check if dashboard is in edit mode
const isEditMode = container.closest('.dashboard--editing') !== null;
const colorFn = (d: GeoFeature) => colorMap[d.properties.ISO] || 'none';
const path = d3.geo.path();
const div = d3.select(container);
@@ -146,11 +112,6 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
.attr('width', width)
.attr('height', height)
.attr('preserveAspectRatio', 'xMidYMid meet');
// Only set grab cursor if not in edit mode
if (!isEditMode) {
svg.style('cursor', 'grab');
}
const backgroundRect = svg
.append('rect')
.attr('class', 'background')
@@ -158,65 +119,40 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
.attr('height', height);
const g = svg.append('g');
const mapLayer = g.append('g').classed('map-layer', true);
// Add hover popup for tooltip
const hoverPopup = div.append('div').attr('class', 'hover-popup');
// Track mouse position to distinguish clicks from drags
let mousedownPos: { x: number; y: number } | null = null;
let centered: GeoFeature | null;
// Cross-filter support
const getCrossFilterDataMask = (
source: GeoFeature,
): { dataMask: DataMask; isCurrentValueSelected: boolean } | undefined => {
if (!entity) return undefined;
const clicked = function clicked(d: GeoFeature) {
const hasCenter = d && centered !== d;
let x: number;
let y: number;
let k: number;
const halfWidth = width / 2;
const halfHeight = height / 2;
const selected = filterState?.selectedValues || [];
const iso = source?.properties?.ISO;
if (!iso) return undefined;
const isSelected = selected.includes(iso);
const values = isSelected ? [] : [iso];
return {
dataMask: {
extraFormData: {
filters: values.length
? [{ col: entity, op: 'IN', val: values }]
: [],
},
filterState: {
value: values.length ? values : null,
selectedValues: values.length ? values : null,
},
},
isCurrentValueSelected: isSelected,
};
};
// Handle right-click context menu
const handleContextMenu = (feature: GeoFeature): void => {
const pointerEvent = d3.event;
if (typeof onContextMenu === 'function') {
pointerEvent?.preventDefault();
if (hasCenter) {
const centroid = path.centroid(d);
[x, y] = centroid;
k = 4;
centered = d;
} else {
x = halfWidth;
y = halfHeight;
k = 1;
centered = null;
}
const iso = feature?.properties?.ISO;
if (!iso || typeof onContextMenu !== 'function' || !entity) return;
const drillVal = iso;
const drillToDetailFilters = [
{ col: entity, op: '==', val: drillVal, formattedVal: drillVal },
];
const drillByFilters = [{ col: entity, op: '==', val: drillVal }];
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, {
drillToDetail: drillToDetailFilters,
crossFilter: getCrossFilterDataMask(feature),
drillBy: { filters: drillByFilters, groupbyFieldName: 'entity' },
});
g.transition()
.duration(750)
.attr(
'transform',
`translate(${halfWidth},${halfHeight})scale(${k})translate(${-x},${-y})`,
);
};
backgroundRect.on('click', clicked);
const getNameOfRegion = function getNameOfRegion(
feature: GeoFeature,
): string {
@@ -229,7 +165,7 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
return '';
};
const updatePopupPosition = (): void => {
const updatePopupPosition = () => {
const svgHeight = svg.node().getBoundingClientRect().height;
const [x, y] = d3.mouse(svg.node());
hoverPopup
@@ -239,135 +175,36 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
.classed('popup-at-bottom', y > (svgHeight * 2) / 3);
};
const mouseenter = function mouseenter(
this: SVGPathElement,
d: GeoFeature,
): void {
const mouseenter = function mouseenter(this: SVGPathElement, d: GeoFeature) {
// Darken color
let c: string = colorFn(d);
if (c) {
if (c !== 'none') {
c = d3.rgb(c).darker().toString();
}
d3.select(this).style('fill', c);
// Display information popup
const result = data.filter(r => r.country_id === d?.properties?.ISO);
const regionName = escapeHtml(getNameOfRegion(d));
const metricValue =
result.length > 0 ? escapeHtml(String(formatter(result[0].metric))) : '';
const result = data.filter(
region => region.country_id === d.properties.ISO,
);
hoverPopup
.style('display', 'block')
.html(`<div><strong>${regionName}</strong><br>${metricValue}</div>`);
.html(
`<div><strong>${getNameOfRegion(d)}</strong><br>${result.length > 0 ? formatter(result[0].metric) : ''}</div>`,
);
updatePopupPosition();
};
// Mouse move handler to update tooltip position
const mousemove = function mousemove(): void {
const mousemove = function mousemove() {
updatePopupPosition();
};
const mouseout = function mouseout(this: SVGPathElement): void {
d3.select(this).style('fill', (d: GeoFeature) => colorFn(d));
const mouseout = function mouseout(this: SVGPathElement) {
d3.select(this).style('fill', colorFn);
hoverPopup.style('display', 'none');
};
// Only enable zoom if not in edit mode
if (!isEditMode) {
// Zoom with panning bounds
const zoom = d3.behavior
.zoom()
.scaleExtent([1, 4])
.on('zoomstart', () => {
svg.style('cursor', 'grabbing');
})
.on('zoom', () => {
const { translate, scale } = d3.event;
let [tx, ty] = translate;
const scaledW = width * scale;
const scaledH = height * scale;
const minX = Math.min(0, width - scaledW);
const maxX = 0;
const minY = Math.min(0, height - scaledH);
const maxY = 0;
tx = Math.max(Math.min(tx, maxX), minX);
ty = Math.max(Math.min(ty, maxY), minY);
// Sync D3's internal translate state with the clamped values so the
// next wheel/zoom event starts from the constrained position rather
// than the unclamped one (otherwise the view jumps).
zoom.translate([tx, ty]);
g.attr('transform', `translate(${tx}, ${ty}) scale(${scale})`);
const prev = zoomStates.get(element);
const changed =
!prev ||
prev.scale !== scale ||
prev.translate[0] !== tx ||
prev.translate[1] !== ty;
if (changed) {
zoomStates.set(element, { scale, translate: [tx, ty] });
}
})
.on('zoomend', () => {
svg.style('cursor', 'grab');
});
d3.select(svg.node()).call(zoom);
// Restore previous zoom state if it exists
const savedZoom = zoomStates.get(element);
if (savedZoom) {
const { scale, translate } = savedZoom;
zoom.scale(scale).translate(translate);
g.attr(
'transform',
`translate(${translate[0]}, ${translate[1]}) scale(${scale})`,
);
}
}
// Visual highlighting for selected regions
function highlightSelectedRegion(
selectedValues: string[] | null = null,
): void {
const selected = selectedValues || filterState?.selectedValues || [];
mapLayer
.selectAll('path.region')
.style('fill-opacity', (d: GeoFeature) => {
const iso = d?.properties?.ISO;
return selected.length === 0 || selected.includes(iso) ? 1 : 0.3;
})
.style('stroke', (d: GeoFeature) => {
const iso = d?.properties?.ISO;
return selected.includes(iso) ? '#222' : null;
})
.style('stroke-width', (d: GeoFeature) => {
const iso = d?.properties?.ISO;
return selected.includes(iso) ? '1.5px' : '0.5px';
});
}
// Click handler for cross-filters
const handleClick = (feature: GeoFeature): void => {
if (!entity || !emitCrossFilters || typeof setDataMask !== 'function') {
return;
}
const result = getCrossFilterDataMask(feature);
if (!result) return;
const { dataMask, isCurrentValueSelected } = result;
setDataMask(dataMask);
const iso = feature?.properties?.ISO;
const newSelection = isCurrentValueSelected || !iso ? [] : [iso];
highlightSelectedRegion(newSelection);
};
function drawMap(mapData: GeoData): void {
function drawMap(mapData: GeoData) {
const { features } = mapData;
const center = d3.geo.centroid(mapData);
const scale = 100;
@@ -378,11 +215,13 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
.translate([width / 2, height / 2]);
path.projection(projection);
// Compute scale that fits container.
const bounds = path.bounds(mapData);
const hscale = (scale * width) / (bounds[1][0] - bounds[0][0]);
const vscale = (scale * height) / (bounds[1][1] - bounds[0][1]);
const newScale = Math.min(hscale, vscale);
const newScale = hscale < vscale ? hscale : vscale;
// Compute bounds and offset using the updated scale.
projection.scale(newScale);
const newBounds = path.bounds(mapData);
projection.translate([
@@ -390,45 +229,20 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
height - (newBounds[0][1] + newBounds[1][1]) / 2,
]);
const sel = mapLayer.selectAll('path.region').data(features);
sel
// Draw each province as a path
mapLayer
.selectAll('path')
.data(features)
.enter()
.append('path')
.attr('class', 'region')
.attr('vector-effect', 'non-scaling-stroke');
// Apply attributes and event handlers to all elements (enter + update)
mapLayer
.selectAll('path.region')
.attr('d', path)
.attr('class', 'region')
.attr('vector-effect', 'non-scaling-stroke')
.style('fill', colorFn)
.on('mouseenter', mouseenter)
.on('mousemove', mousemove)
.on('mouseout', mouseout)
.on('contextmenu', handleContextMenu)
.on('mousedown', function mousedown() {
const pos = d3.mouse(svg.node());
mousedownPos = { x: pos[0], y: pos[1] };
})
.on('click', function click(feature: GeoFeature) {
if (mousedownPos) {
const pos = d3.mouse(svg.node());
const dx = Math.abs(pos[0] - mousedownPos.x);
const dy = Math.abs(pos[1] - mousedownPos.y);
const dragThreshold = 5;
if (dx < dragThreshold && dy < dragThreshold) {
handleClick(feature);
}
mousedownPos = null;
}
});
sel.exit().remove();
highlightSelectedRegion();
.on('click', clicked);
}
const map = maps[country];

File diff suppressed because one or more lines are too long

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import { t } from '@apache-superset/core/translation';
import { ChartMetadata, ChartPlugin, Behavior } from '@superset-ui/core';
import { ChartMetadata, ChartPlugin } from '@superset-ui/core';
import transformProps from './transformProps';
import exampleUsa from './images/exampleUsa.jpg';
import exampleUsaDark from './images/exampleUsa-dark.jpg';
@@ -49,11 +49,6 @@ const metadata = new ChartMetadata({
thumbnail,
thumbnailDark,
useLegacyApi: true,
behaviors: [
Behavior.InteractiveChart,
Behavior.DrillToDetail,
Behavior.DrillBy,
],
});
export default class CountryMapChartPlugin extends ChartPlugin {

View File

@@ -19,18 +19,8 @@
import { ChartProps, getValueFormatter } from '@superset-ui/core';
export default function transformProps(chartProps: ChartProps) {
const { width, height, formData, queriesData, datasource } = chartProps;
const {
width,
height,
formData,
queriesData,
datasource,
hooks = {},
filterState,
emitCrossFilters,
} = chartProps;
const {
entity,
linearColorScheme,
numberFormat,
currencyFormat,
@@ -59,8 +49,6 @@ export default function transformProps(chartProps: ChartProps) {
detectedCurrency,
);
const { onContextMenu, setDataMask } = hooks;
return {
width,
height,
@@ -71,10 +59,5 @@ export default function transformProps(chartProps: ChartProps) {
colorScheme,
sliceId,
formatter,
entity,
onContextMenu,
setDataMask,
emitCrossFilters,
filterState,
};
}

View File

@@ -133,11 +133,10 @@ describe('CountryMap (legacy d3)', () => {
expect(popup!).toHaveStyle({ display: 'none' });
});
test('emits a cross-filter data mask when a region is clicked', () => {
test('shows tooltip on mouseenter/mousemove/mouseout', async () => {
d3Any.json.mockImplementation((_url: string, cb: D3JsonCallback) =>
cb(null, mockMapData),
);
const setDataMask = jest.fn();
render(
<ReactCountryMap
@@ -148,101 +147,19 @@ describe('CountryMap (legacy d3)', () => {
linearColorScheme="bnbColors"
colorScheme=""
formatter={jest.fn().mockReturnValue('100')}
entity="country_code"
emitCrossFilters
setDataMask={setDataMask}
filterState={{ selectedValues: [] }}
/>,
);
const region = document.querySelector('path.region');
expect(region).not.toBeNull();
// A click is only treated as a selection when it follows a mousedown
// without dragging beyond the threshold (d3.mouse is mocked to a fixed
// position, so the down/up positions match).
fireEvent.mouseDown(region!);
fireEvent.click(region!);
const popup = document.querySelector('.hover-popup');
expect(popup).not.toBeNull();
expect(setDataMask).toHaveBeenCalledTimes(1);
expect(setDataMask).toHaveBeenCalledWith(
expect.objectContaining({
extraFormData: {
filters: [{ col: 'country_code', op: 'IN', val: ['CAN'] }],
},
filterState: expect.objectContaining({ value: ['CAN'] }),
}),
);
});
fireEvent.mouseEnter(region!);
expect(popup!).toHaveStyle({ display: 'block' });
test('does not emit a cross-filter when emitCrossFilters is disabled', () => {
d3Any.json.mockImplementation((_url: string, cb: D3JsonCallback) =>
cb(null, mockMapData),
);
const setDataMask = jest.fn();
render(
<ReactCountryMap
width={500}
height={300}
data={[{ country_id: 'CAN', metric: 100 }]}
country="canada"
linearColorScheme="bnbColors"
colorScheme=""
formatter={jest.fn().mockReturnValue('100')}
entity="country_code"
emitCrossFilters={false}
setDataMask={setDataMask}
filterState={{ selectedValues: [] }}
/>,
);
const region = document.querySelector('path.region');
fireEvent.mouseDown(region!);
fireEvent.click(region!);
expect(setDataMask).not.toHaveBeenCalled();
});
test('opens the context menu with drill-by keyed on the entity control', () => {
d3Any.json.mockImplementation((_url: string, cb: D3JsonCallback) =>
cb(null, mockMapData),
);
const onContextMenu = jest.fn();
render(
<ReactCountryMap
width={500}
height={300}
data={[{ country_id: 'CAN', metric: 100 }]}
country="canada"
linearColorScheme="bnbColors"
colorScheme=""
formatter={jest.fn().mockReturnValue('100')}
entity="country_code"
onContextMenu={onContextMenu}
filterState={{ selectedValues: [] }}
/>,
);
const region = document.querySelector('path.region');
expect(region).not.toBeNull();
fireEvent.contextMenu(region!, { clientX: 123, clientY: 45 });
expect(onContextMenu).toHaveBeenCalledTimes(1);
const [[clientX, clientY, payload]] = onContextMenu.mock.calls;
expect(clientX).toBe(123);
expect(clientY).toBe(45);
expect(payload.drillToDetail).toEqual([
{ col: 'country_code', op: '==', val: 'CAN', formattedVal: 'CAN' },
]);
// groupbyFieldName must be the form-data control key ('entity'), not the
// selected column value ('country_code'), so DrillByModal can map the
// selection back to the chart control.
expect(payload.drillBy).toEqual({
filters: [{ col: 'country_code', op: '==', val: 'CAN' }],
groupbyFieldName: 'entity',
});
fireEvent.mouseOut(region!);
expect(popup!).toHaveStyle({ display: 'none' });
});
});

View File

@@ -1,76 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ChartProps } from '@superset-ui/core';
import transformProps from '../src/transformProps';
const onContextMenu = jest.fn();
const setDataMask = jest.fn();
const createProps = (formDataOverrides = {}, chartPropsOverrides = {}) =>
({
width: 800,
height: 600,
formData: {
entity: 'country_code',
linearColorScheme: 'bnbColors',
numberFormat: '.2f',
selectCountry: 'France',
colorScheme: '',
sliceId: 1,
metric: 'count',
...formDataOverrides,
},
queriesData: [{ data: [{ country_id: 'FRA', metric: 10 }] }],
datasource: { currencyFormats: {}, columnFormats: {} },
hooks: { onContextMenu, setDataMask },
filterState: { selectedValues: ['FRA'] },
emitCrossFilters: true,
...chartPropsOverrides,
}) as unknown as ChartProps;
test('forwards cross-filter hooks and state to the chart', () => {
const transformed = transformProps(createProps());
expect(transformed).toMatchObject({
width: 800,
height: 600,
entity: 'country_code',
onContextMenu,
setDataMask,
emitCrossFilters: true,
filterState: { selectedValues: ['FRA'] },
data: [{ country_id: 'FRA', metric: 10 }],
});
});
test('lowercases the selected country for map lookup', () => {
const transformed = transformProps(createProps());
expect(transformed.country).toBe('france');
});
test('passes a null country when none is selected', () => {
const transformed = transformProps(createProps({ selectCountry: undefined }));
expect(transformed.country).toBeNull();
});
test('defaults hooks to an empty object when none are provided', () => {
const transformed = transformProps(createProps({}, { hooks: undefined }));
expect(transformed.onContextMenu).toBeUndefined();
expect(transformed.setDataMask).toBeUndefined();
});

View File

@@ -305,16 +305,36 @@ function WorldMap(element: HTMLElement, props: WorldMapProps): void {
key: JSON.stringify,
},
done: datamap => {
// Hover highlighting and its reset are handled entirely by Datamaps'
// built-in highlightOnHover, which saves each country's original fill on
// mouseover and restores it on mouseout. Adding our own mouseover/mouseout
// fill handlers here creates a second, competing restore path whose
// execution order is browser-timing-dependent, which left the highlight
// stuck on Chrome/Edge (see #37761).
datamap.svg
.selectAll('.datamaps-subunit')
.on('contextmenu', handleContextMenu)
.on('click', handleClick);
.on('click', handleClick)
// Use namespaced events to avoid overriding Datamaps' default tooltip handlers
.on('mouseover.fillPreserve', function onMouseOver() {
if (inContextMenu) {
return;
}
const element = d3.select(this);
const classes = element.attr('class') || '';
const countryId = classes.split(' ')[1];
const countryData = mapData[countryId];
const originalFill =
(countryData && countryData.fillColor) || theme.colorBorder;
// Store original fill color for restoration
element.attr('data-original-fill', originalFill);
})
.on('mouseout.fillPreserve', function onMouseOut() {
if (inContextMenu) {
return;
}
const element = d3.select(this);
const originalFill = element.attr('data-original-fill');
// Restore the original fill color (data-based or default no-data color)
if (originalFill) {
element.style('fill', originalFill);
element.attr('data-original-fill', null);
}
});
},
});

View File

@@ -58,6 +58,15 @@ interface WorldMapProps {
formatter: ValueFormatter;
}
type MouseEventHandler = (this: HTMLElement) => void;
interface MockD3Selection {
attr: jest.Mock;
style: jest.Mock;
classed: jest.Mock;
selectAll: jest.Mock;
}
// Mock Datamap
const mockBubbles = jest.fn();
const mockUpdateChoropleth = jest.fn();
@@ -148,36 +157,244 @@ afterEach(() => {
document.body.removeChild(container);
});
test('relies on Datamaps highlightOnHover without adding conflicting fill handlers', () => {
// Regression test for #37761. The hover highlight got stuck on Chrome/Edge
// because hand-written mouseover/mouseout handlers competed with Datamaps'
// built-in highlightOnHover restore path, and the winning path was
// browser-timing-dependent. The chart should rely on the single built-in
// path and register no custom fill-restoring hover handlers on the countries.
test('sets up mouseover and mouseout handlers on countries', () => {
WorldMap(container, baseProps);
const geographyConfig = lastDatamapConfig?.geographyConfig as Record<
string,
unknown
>;
expect(geographyConfig?.highlightOnHover).toBe(true);
expect(mockSvg.selectAll).toHaveBeenCalledWith('.datamaps-subunit');
const onCalls = mockSvg.on.mock.calls;
const hoverHandlers = mockSvg.on.mock.calls.filter((call: [string]) =>
/^mouse(over|out)/.test(call[0]),
// Find mouseover and mouseout handler registrations (namespaced events)
const hasMouseover = onCalls.some(
call => call[0] === 'mouseover.fillPreserve',
);
expect(hoverHandlers).toEqual([]);
const hasMouseout = onCalls.some(call => call[0] === 'mouseout.fillPreserve');
expect(hasMouseover).toBe(true);
expect(hasMouseout).toBe(true);
});
test('disables Datamaps highlightOnHover while the context menu is open', () => {
// Companion to the regression guard above: when the context menu is open we
// pass highlightOnHover: false so hover highlighting is suppressed at init.
WorldMap(container, { ...baseProps, inContextMenu: true });
test('stores original fill color on mouseover', () => {
// Create a mock DOM element with d3 selection capabilities
const mockElement = document.createElement('path');
mockElement.setAttribute('class', 'datamaps-subunit USA');
mockElement.style.fill = 'rgb(100, 150, 200)';
container.appendChild(mockElement);
const geographyConfig = lastDatamapConfig?.geographyConfig as Record<
string,
unknown
>;
expect(geographyConfig?.highlightOnHover).toBe(false);
let mouseoverHandler: MouseEventHandler | null = null;
// Mock d3.select to return the mock element
const mockD3Selection: MockD3Selection = {
attr: jest.fn((attrName: string, value?: string) => {
if (value !== undefined) {
mockElement.setAttribute(attrName, value);
} else {
return mockElement.getAttribute(attrName);
}
return mockD3Selection;
}),
style: jest.fn((styleName: string, value?: string) => {
if (value !== undefined) {
mockElement.style[styleName as any] = value;
} else {
return mockElement.style[styleName as any];
}
return mockD3Selection;
}),
classed: jest.fn().mockReturnThis(),
selectAll: jest.fn().mockReturnValue({ remove: jest.fn() }),
};
jest.spyOn(d3 as any, 'select').mockReturnValue(mockD3Selection as any);
// Capture the mouseover handler (namespaced event)
mockSvg.on.mockImplementation((event: string, handler: MouseEventHandler) => {
if (event === 'mouseover.fillPreserve') {
mouseoverHandler = handler;
}
return mockSvg;
});
WorldMap(container, baseProps);
// Simulate mouseover
if (mouseoverHandler) {
(mouseoverHandler as MouseEventHandler).call(mockElement);
}
// Verify that data-original-fill attribute was set
expect(mockD3Selection.attr).toHaveBeenCalledWith(
'data-original-fill',
expect.any(String),
);
});
test('restores original fill color on mouseout for country with data', () => {
const mockElement = document.createElement('path');
mockElement.setAttribute('class', 'datamaps-subunit USA');
mockElement.style.fill = 'rgb(100, 150, 200)';
mockElement.setAttribute('data-original-fill', 'rgb(100, 150, 200)');
container.appendChild(mockElement);
let mouseoutHandler: MouseEventHandler | null = null;
const mockD3Selection: MockD3Selection = {
attr: jest.fn((attrName: string, value?: string | null) => {
if (value !== undefined) {
if (value === null) {
mockElement.removeAttribute(attrName);
} else {
mockElement.setAttribute(attrName, value);
}
return mockD3Selection;
}
return mockElement.getAttribute(attrName);
}),
style: jest.fn((styleName: string, value?: string) => {
if (value !== undefined) {
mockElement.style[styleName as any] = value;
}
return mockElement.style[styleName as any] || mockD3Selection;
}),
classed: jest.fn().mockReturnThis(),
selectAll: jest.fn().mockReturnValue({ remove: jest.fn() }),
};
jest.spyOn(d3 as any, 'select').mockReturnValue(mockD3Selection as any);
// Capture the mouseout handler (namespaced event)
mockSvg.on.mockImplementation((event: string, handler: MouseEventHandler) => {
if (event === 'mouseout.fillPreserve') {
mouseoutHandler = handler;
}
return mockSvg;
});
WorldMap(container, baseProps);
// Simulate mouseout
if (mouseoutHandler) {
(mouseoutHandler as MouseEventHandler).call(mockElement);
}
// Verify that original fill was restored
expect(mockD3Selection.style).toHaveBeenCalledWith(
'fill',
'rgb(100, 150, 200)',
);
expect(mockD3Selection.attr).toHaveBeenCalledWith('data-original-fill', null);
});
test('restores default fill color on mouseout for country with no data', () => {
const mockElement = document.createElement('path');
mockElement.setAttribute('class', 'datamaps-subunit XXX');
mockElement.style.fill = '#e0e0e0'; // Default border color
mockElement.setAttribute('data-original-fill', '#e0e0e0');
container.appendChild(mockElement);
let mouseoutHandler: MouseEventHandler | null = null;
const mockD3Selection: MockD3Selection = {
attr: jest.fn((attrName: string, value?: string | null) => {
if (value !== undefined) {
if (value === null) {
mockElement.removeAttribute(attrName);
} else {
mockElement.setAttribute(attrName, value);
}
return mockD3Selection;
}
return mockElement.getAttribute(attrName);
}),
style: jest.fn((styleName: string, value?: string) => {
if (value !== undefined) {
mockElement.style[styleName as any] = value;
}
return mockElement.style[styleName as any] || mockD3Selection;
}),
classed: jest.fn().mockReturnThis(),
selectAll: jest.fn().mockReturnValue({ remove: jest.fn() }),
};
jest.spyOn(d3 as any, 'select').mockReturnValue(mockD3Selection as any);
// Capture the mouseout handler (namespaced event)
mockSvg.on.mockImplementation((event: string, handler: MouseEventHandler) => {
if (event === 'mouseout.fillPreserve') {
mouseoutHandler = handler;
}
return mockSvg;
});
WorldMap(container, baseProps);
// Simulate mouseout
if (mouseoutHandler) {
(mouseoutHandler as MouseEventHandler).call(mockElement);
}
// Verify that default fill was restored (no-data color)
expect(mockD3Selection.style).toHaveBeenCalledWith('fill', '#e0e0e0');
expect(mockD3Selection.attr).toHaveBeenCalledWith('data-original-fill', null);
});
test('does not handle mouse events when inContextMenu is true', () => {
const propsWithContextMenu = {
...baseProps,
inContextMenu: true,
};
const mockElement = document.createElement('path');
mockElement.setAttribute('class', 'datamaps-subunit USA');
mockElement.style.fill = 'rgb(100, 150, 200)';
container.appendChild(mockElement);
let mouseoverHandler: MouseEventHandler | null = null;
let mouseoutHandler: MouseEventHandler | null = null;
const mockD3Selection: MockD3Selection = {
attr: jest.fn(() => mockD3Selection),
style: jest.fn(() => mockD3Selection),
classed: jest.fn().mockReturnThis(),
selectAll: jest.fn().mockReturnValue({ remove: jest.fn() }),
};
jest.spyOn(d3 as any, 'select').mockReturnValue(mockD3Selection as any);
// Capture namespaced event handlers
mockSvg.on.mockImplementation((event: string, handler: MouseEventHandler) => {
if (event === 'mouseover.fillPreserve') {
mouseoverHandler = handler;
}
if (event === 'mouseout.fillPreserve') {
mouseoutHandler = handler;
}
return mockSvg;
});
WorldMap(container, propsWithContextMenu);
// Simulate mouseover and mouseout
if (mouseoverHandler) {
(mouseoverHandler as MouseEventHandler).call(mockElement);
}
if (mouseoutHandler) {
(mouseoutHandler as MouseEventHandler).call(mockElement);
}
// When inContextMenu is true, handlers should exit early without modifying anything
// We verify this by checking that attr and style weren't called to change fill
const attrCalls = mockD3Selection.attr.mock.calls;
const fillChangeCalls = attrCalls.filter(
(call: [string, unknown]) =>
call[0] === 'data-original-fill' && call[1] !== undefined,
);
const styleCalls = mockD3Selection.style.mock.calls;
const fillStyleChangeCalls = styleCalls.filter(
(call: [string, unknown]) => call[0] === 'fill' && call[1] !== undefined,
);
// The handlers should return early, so no state changes
expect(fillChangeCalls.length).toBe(0);
expect(fillStyleChangeCalls.length).toBe(0);
});
test('does not throw error when onContextMenu is undefined', () => {

View File

@@ -90,13 +90,6 @@ const buildQuery: BuildQuery<TableChartFormData> = (
let { metrics, orderby = [], columns = [] } = baseQueryObject;
const { extras = {} } = baseQueryObject;
let postProcessing: PostProcessingRule[] = [];
// Capture the percent-metric `contribution` rule so it can be reused for
// the totals query below. The totals query must rename percent-metric
// columns the same way (`metric` -> `%metric`) so the footer can look them
// up; without it the totals row renders 0.000%. We deliberately reuse only
// this rule and not the full `postProcessing` array, which may also contain
// a time-comparison operator that must not run on the single totals row.
let contributionPostProcessing: PostProcessingRule | undefined;
const nonCustomNorInheritShifts = ensureIsArray(
formData.time_compare,
).filter((shift: string) => shift !== 'custom' && shift !== 'inherit');
@@ -164,14 +157,15 @@ const buildQuery: BuildQuery<TableChartFormData> = (
metrics.concat(percentMetrics),
getMetricLabel,
);
contributionPostProcessing = {
operation: 'contribution',
options: {
columns: percentMetricLabels,
rename_columns: percentMetricLabels.map(x => `%${x}`),
postProcessing = [
{
operation: 'contribution',
options: {
columns: percentMetricLabels,
rename_columns: percentMetricLabels.map(x => `%${x}`),
},
},
};
postProcessing = [contributionPostProcessing];
];
}
// Add the operator for the time comparison if some is selected
if (!isEmpty(timeOffsets)) {
@@ -664,13 +658,7 @@ const buildQuery: BuildQuery<TableChartFormData> = (
extras: totalsExtras, // Use extras with AG Grid WHERE removed
row_limit: 0,
row_offset: 0,
// Reapply only the percent-metric contribution rule so the totals row
// exposes `%metric` keys (value/value = 100% on the single aggregated
// row). The time-comparison operator from the main query is omitted on
// purpose; it must not run against the single-row totals query.
post_processing: contributionPostProcessing
? [contributionPostProcessing]
: [],
post_processing: [],
order_desc: undefined, // we don't need orderby stuff here,
orderby: undefined, // because this query will be used for get total aggregation.
});

View File

@@ -852,75 +852,6 @@ describe('plugin-chart-ag-grid-table', () => {
expect(totalsQuery.columns).toEqual([]);
expect(totalsQuery.row_limit).toBe(0);
});
test('should reapply percent-metric contribution op to totals query', () => {
// Regression test for #37627: when a percent metric is configured and
// Show Summary (show_totals) is enabled, the totals query must rename
// percent-metric columns (`metric` -> `%metric`) so the footer can
// look them up. Otherwise the totals row renders 0.000%.
const { queries } = buildQuery({
...basicFormData,
metrics: ['count'],
percent_metrics: ['count'],
show_totals: true,
query_mode: QueryMode.Aggregate,
});
// No server pagination -> queries[1] is the totals query.
const totalsQuery = queries[1];
const contributionRule = {
operation: 'contribution',
options: {
columns: ['count'],
rename_columns: ['%count'],
},
};
expect(queries[0].post_processing).toContainEqual(contributionRule);
expect(totalsQuery.post_processing).toEqual([contributionRule]);
});
test('should omit time-comparison op from totals post_processing', () => {
// The totals query must reuse ONLY the contribution rule; the
// time-comparison operator from the main query must not run against
// the single-row totals query.
const { queries } = buildQuery({
...basicFormData,
metrics: ['count'],
percent_metrics: ['count'],
show_totals: true,
query_mode: QueryMode.Aggregate,
time_compare: ['1 year ago'],
comparison_type: 'values',
});
const totalsQuery = queries[1];
// Exactly one op (contribution) — the time-comparison operator from the
// main query must not be carried over to the single-row totals query.
expect(totalsQuery.post_processing).toHaveLength(1);
expect(totalsQuery.post_processing?.[0]).toMatchObject({
operation: 'contribution',
});
// The reused rule matches the main query's contribution rule verbatim.
expect(totalsQuery.post_processing?.[0]).toEqual(
queries[0].post_processing?.find(
op => op?.operation === 'contribution',
),
);
});
test('should leave totals post_processing empty without percent metrics', () => {
const { queries } = buildQuery({
...basicFormData,
metrics: ['count'],
show_totals: true,
query_mode: QueryMode.Aggregate,
});
const totalsQuery = queries[1];
expect(totalsQuery.post_processing).toEqual([]);
});
});
describe('Integration - all filter types together', () => {

View File

@@ -231,56 +231,6 @@ describe('BigNumberTotal transformProps', () => {
expect(result.headerFormatter(500)).toBe('$500');
});
test('should pass through non-numeric raw string when parseMetricValue returns null (e.g. VARCHAR MAX)', () => {
const { parseMetricValue } = jest.requireMock('../utils');
parseMetricValue.mockReturnValueOnce(null);
const chartProps = {
width: 400,
height: 300,
queriesData: [
{
data: [{ value: 'some-varchar-result' }],
coltypes: [GenericDataType.String],
},
],
formData: baseFormData,
rawFormData: baseRawFormData,
hooks: baseHooks,
datasource: baseDatasource,
};
const result = transformProps(
chartProps as unknown as BigNumberTotalChartProps,
);
expect(result.bigNumber).toBe('some-varchar-result');
});
test('should pass through numeric-looking VARCHAR string literally (e.g. "123")', () => {
const { parseMetricValue } = jest.requireMock('../utils');
parseMetricValue.mockReturnValueOnce(null);
const chartProps = {
width: 400,
height: 300,
queriesData: [
{
data: [{ value: '123' }],
coltypes: [GenericDataType.String],
},
],
formData: baseFormData,
rawFormData: baseRawFormData,
hooks: baseHooks,
datasource: baseDatasource,
};
const result = transformProps(
chartProps as unknown as BigNumberTotalChartProps,
);
expect(result.bigNumber).toBe('123');
});
test('should propagate colorThresholdFormatters from getColorFormatters', () => {
// Override the getColorFormatters mock to return specific value
const mockFormatters = [{ formatter: 'red' }];

View File

@@ -79,15 +79,8 @@ export default function transformProps(
const formattedSubtitleFontSize = subtitle?.trim()
? (subtitleFontSize ?? PROPORTION.SUBHEADER)
: (subheaderFontSize ?? subtitleFontSize ?? PROPORTION.SUBHEADER);
const rawValue = data.length === 0 ? null : data[0][metricName];
const parsedValue = rawValue == null ? null : parseMetricValue(rawValue);
const bigNumber =
parsedValue === null &&
typeof rawValue === 'string' &&
rawValue.trim() !== ''
? rawValue
: parsedValue;
data.length === 0 ? null : parseMetricValue(data[0][metricName]);
let metricEntry: Metric | undefined;
if (chartProps.datasource?.metrics) {

View File

@@ -189,10 +189,8 @@ function BigNumberVis({
text = t('No data');
} else if (typeof bigNumber === 'number') {
text = headerFormatter(bigNumber);
} else if (typeof bigNumber === 'string') {
text = bigNumber;
} else {
// For boolean/Date values, convert to number if possible, else show as string
// For string/boolean/Date values, convert to number if possible, else show as string
const numValue = Number(bigNumber);
text = Number.isNaN(numValue)
? String(bigNumber)

View File

@@ -160,20 +160,6 @@ const config: ControlPanelConfig = {
},
],
['zoomable'],
[
{
name: 'y_axis_slider',
config: {
type: 'CheckboxControl',
label: t('Y-axis range slider'),
default: false,
renderTrigger: true,
description: t(
'Show a draggable slider to control the visible range of the Y-axis.',
),
},
},
],
],
},
],

View File

@@ -74,7 +74,6 @@ export default function transformProps(
yAxisTitlePosition,
sliceId,
zoomable,
yAxisSlider,
} = formData as BoxPlotQueryFormData;
const refs: Refs = {};
const colorFn = CategoricalColorNamespace.getScale(colorScheme as string);
@@ -258,28 +257,6 @@ export default function transformProps(
convertInteger(yAxisTitleMargin),
convertInteger(xAxisTitleMargin),
);
const dataZoom = [
...(zoomable
? [
{
type: 'inside',
zoomOnMouseWheel: false,
moveOnMouseWheel: true,
},
]
: []),
...(yAxisSlider
? [
{
type: 'slider',
show: true,
yAxisIndex: [0],
// Adjust the axis window without dropping data points outside the range.
filterMode: 'none',
},
]
: []),
];
const echartOptions: EChartsCoreOption = {
grid: {
...defaultGrid,
@@ -321,7 +298,15 @@ export default function transformProps(
},
},
},
dataZoom,
dataZoom: zoomable
? [
{
type: 'inside',
zoomOnMouseWheel: false,
moveOnMouseWheel: true,
},
]
: [],
};
return {

View File

@@ -30,7 +30,6 @@ export type BoxPlotQueryFormData = QueryFormData & {
numberFormat?: string;
whiskerOptions?: BoxPlotFormDataWhiskerOptions;
xTickLayout?: BoxPlotFormXTickLayout;
yAxisSlider?: boolean;
} & TitleFormData;
export type BoxPlotFormDataWhiskerOptions =

Some files were not shown because too many files have changed in this diff Show More