mirror of
https://github.com/apache/superset.git
synced 2026-09-09 16:54:29 +00:00
Compare commits
74
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c3c92e96b | ||
|
|
986b648f1c | ||
|
|
acb29937ae | ||
|
|
2875c76d55 | ||
|
|
d0f139c210 | ||
|
|
9de50b85e5 | ||
|
|
c510946df5 | ||
|
|
91f4d01ca2 | ||
|
|
3828dcb922 | ||
|
|
362d3509cb | ||
|
|
fa31e9c1c3 | ||
|
|
335a2a78ea | ||
|
|
3564baabf6 | ||
|
|
380675662d | ||
|
|
0e6170aaa3 | ||
|
|
9d89dbddc7 | ||
|
|
02ea5775c1 | ||
|
|
83d5d1ccda | ||
|
|
7b65d22205 | ||
|
|
1339bdc6ce | ||
|
|
10866337c5 | ||
|
|
e6d088d6b1 | ||
|
|
5986fe92c2 | ||
|
|
04ab089962 | ||
|
|
bc35151350 | ||
|
|
7029c468c4 | ||
|
|
60553b4326 | ||
|
|
85ea6e900a | ||
|
|
a114b4cc46 | ||
|
|
3ccab2ea7a | ||
|
|
067a356b6f | ||
|
|
57eafd75e0 | ||
|
|
c45fc6a31a | ||
|
|
33c6df77c9 | ||
|
|
204ccc7ce7 | ||
|
|
08694d64cb | ||
|
|
056fd218d4 | ||
|
|
ad319bd8a3 | ||
|
|
7382ee0bfe | ||
|
|
7d0cf1acfc | ||
|
|
c4e145b071 | ||
|
|
cdb6c8e0c9 | ||
|
|
89786160f9 | ||
|
|
bd3ab30907 | ||
|
|
1b3758d2a4 | ||
|
|
1fb6b98941 | ||
|
|
8d6044a147 | ||
|
|
574d121cd8 | ||
|
|
d22069a7af | ||
|
|
233f6379ae | ||
|
|
4440887dc2 | ||
|
|
53070fa4c0 | ||
|
|
92bd2ba057 | ||
|
|
4eff13b81e | ||
|
|
06913244de | ||
|
|
a5377541c9 | ||
|
|
e5e6d74819 | ||
|
|
b8fb5a268c | ||
|
|
07052752b6 | ||
|
|
7c9c32651c | ||
|
|
0db9ca7401 | ||
|
|
7d09490bb8 | ||
|
|
df3abe474c | ||
|
|
87f138cc80 | ||
|
|
c03f3441bd | ||
|
|
f3896ccf20 | ||
|
|
18d277c096 | ||
|
|
fe1b368bcd | ||
|
|
1358543827 | ||
|
|
1315f5ff6b | ||
|
|
974f36f94f | ||
|
|
9e9a3d06ad | ||
|
|
5c4c8b831f | ||
|
|
1e76fb6098 |
@@ -1,23 +0,0 @@
|
||||
name: Label Draft PRs
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- converted_to_draft
|
||||
jobs:
|
||||
label-draft:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check if the PR is a draft
|
||||
id: check-draft
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const isDraft = context.payload.pull_request.draft;
|
||||
core.setOutput('isDraft', isDraft);
|
||||
- name: Add `review:draft` Label
|
||||
if: steps.check-draft.outputs.isDraft == 'true'
|
||||
uses: actions-ecosystem/action-add-labels@v1
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
labels: "review:draft"
|
||||
@@ -26,7 +26,7 @@ runs:
|
||||
|
||||
- name: Set up QEMU
|
||||
if: ${{ inputs.build == 'true' }}
|
||||
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
|
||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
|
||||
with:
|
||||
# Pin the binfmt image to a specific QEMU release. The default
|
||||
# (`tonistiigi/binfmt:latest`) is a moving target, and drift across
|
||||
@@ -39,12 +39,12 @@ runs:
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: ${{ inputs.build == 'true' }}
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Try to login to DockerHub
|
||||
if: ${{ inputs.login-to-dockerhub == 'true' }}
|
||||
continue-on-error: true
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
username: ${{ inputs.dockerhub-user }}
|
||||
password: ${{ inputs.dockerhub-token }}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Verifies that every `uses:` ref under .github/ is on the ASF Infrastructure
|
||||
# GitHub Actions allowlist (apache/infrastructure-actions). An action that is
|
||||
# not allowlisted fails at "Set up job" with no logs and no notification, so
|
||||
# this check surfaces the problem at PR time instead. It also warns (without
|
||||
# failing) when a pinned SHA's allowlist entry is about to expire.
|
||||
name: ASF Allowlist Check
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
paths:
|
||||
- ".github/**"
|
||||
push:
|
||||
branches:
|
||||
- "master"
|
||||
- "[0-9].[0-9]*"
|
||||
paths:
|
||||
- ".github/**"
|
||||
schedule:
|
||||
# Weekly, so allowlist expirations are surfaced even when nothing under
|
||||
# .github/ has changed.
|
||||
- cron: "0 6 * * 1"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# cancel previous workflow jobs for PRs
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
asf-allowlist-check:
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check action refs against the ASF allowlist
|
||||
uses: apache/infrastructure-actions/allowlist-check@df54e48ff76152790f317934c691cfa7fd7a1a46 # allowlist-check/v1.0.1
|
||||
with:
|
||||
# Default scan-glob is .github/**/*.yml, which misses .yaml files.
|
||||
scan-glob: ".github/**/*.y*ml"
|
||||
@@ -49,4 +49,4 @@ jobs:
|
||||
run: bash .github/workflows/scripts/check-docs-deploy-freshness.test.sh
|
||||
|
||||
- name: Check for security issues on GHA workflows
|
||||
uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
|
||||
uses: zizmorcore/zizmor-action@70fb788f84895a7701f5643d103d587e460b5c99 # v0.6.3
|
||||
|
||||
@@ -168,7 +168,10 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.python == 'true' || needs.changes.outputs.frontend == 'true'
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 30
|
||||
# Embedded Tests below adds its own gunicorn boot + spec run on top of
|
||||
# Required and Soft-delete; 30m was tight even for the two-step shadow
|
||||
# job this replaced.
|
||||
timeout-minutes: 40
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
@@ -176,7 +179,10 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
browser: ["chromium"]
|
||||
app_root: ${{ github.event_name == 'push' && fromJSON('["", "/app/prefix"]') || fromJSON('[""]') }}
|
||||
# Subdirectory deployment (APPLICATION_ROOT) is a required-to-pass
|
||||
# dimension, not an optional one, so it runs on every event —
|
||||
# unlike cypress-matrix above, which only widens on push.
|
||||
app_root: ["", "/app/prefix"]
|
||||
env:
|
||||
SUPERSET_ENV: development
|
||||
SUPERSET_CONFIG: tests.integration_tests.superset_test_config
|
||||
@@ -266,14 +272,25 @@ jobs:
|
||||
# Scoped to this step: each playwright-run boots its own gunicorn
|
||||
# with the step's env, so the Required Tests server above keeps
|
||||
# master's Flask configuration while this one runs with SOFT_DELETE
|
||||
# on — the same isolation pattern as the Embedded step in
|
||||
# superset-playwright.yml. Without a flag-on server the
|
||||
# recently-archived specs skip themselves everywhere and ship zero
|
||||
# executed E2E coverage; in the Required run above they are
|
||||
# collected and skipped, which is expected.
|
||||
# on — the same isolation pattern as the Embedded Tests step below.
|
||||
# Without a flag-on server the recently-archived specs skip
|
||||
# themselves everywhere and ship zero executed E2E coverage; in the
|
||||
# Required run above they are collected and skipped, which is
|
||||
# expected.
|
||||
SUPERSET_FEATURE_SOFT_DELETE: "true"
|
||||
with:
|
||||
run: playwright-run "${{ matrix.app_root }}" recently-archived/
|
||||
- name: Run Playwright (Embedded Tests)
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
env:
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
# Scoped to this step for the same reason as Soft-delete above:
|
||||
# embedding is a real, required feature, so its Playwright coverage
|
||||
# now gates merges instead of running only in shadow mode.
|
||||
SUPERSET_FEATURE_EMBEDDED_SUPERSET: "true"
|
||||
INCLUDE_EMBEDDED: "true"
|
||||
with:
|
||||
run: playwright-run "${{ matrix.app_root }}" embedded
|
||||
- name: Set safe app root
|
||||
if: failure()
|
||||
id: set-safe-app-root
|
||||
|
||||
@@ -46,8 +46,10 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# NOTE: Required Playwright tests are in superset-e2e.yml (E2E / playwright-tests)
|
||||
# This workflow contains only experimental tests that run in shadow mode
|
||||
# NOTE: Required Playwright tests are in superset-e2e.yml (E2E / playwright-tests),
|
||||
# including Embedded — it moved out of this workflow because embedding is a
|
||||
# required feature, not an experimental one. This workflow now contains
|
||||
# only experimental and mobile tests, which run in shadow mode.
|
||||
playwright-tests-experimental:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.python == 'true' || needs.changes.outputs.frontend == 'true'
|
||||
@@ -130,10 +132,6 @@ jobs:
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: build-instrumented-assets
|
||||
- name: Build embedded SDK
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: build-embedded-sdk
|
||||
- name: Install Playwright
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
@@ -144,27 +142,13 @@ jobs:
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
with:
|
||||
run: playwright-run "${{ matrix.app_root }}" experimental/
|
||||
- name: Run Playwright (Embedded Tests)
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
env:
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
# Scope embedded-only env vars to this step. Setting them at the job
|
||||
# level enabled the EMBEDDED_SUPERSET feature flag inside Flask for
|
||||
# the preceding "Required Tests" and "Experimental Tests" steps too,
|
||||
# which loads extra handlers and destabilizes the werkzeug dev
|
||||
# server under the 2-worker Playwright load. Required Tests should
|
||||
# match master's Flask configuration.
|
||||
SUPERSET_FEATURE_EMBEDDED_SUPERSET: "true"
|
||||
INCLUDE_EMBEDDED: "true"
|
||||
with:
|
||||
run: playwright-run "${{ matrix.app_root }}" embedded
|
||||
- name: Run Playwright (Mobile Tests)
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
env:
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
# Scoped to this step for the same reason as the embedded flags
|
||||
# above: the mobile consumption mode should not alter Flask's
|
||||
# configuration for the required desktop test steps.
|
||||
# Scoped to this step: setting feature flags at the job level would
|
||||
# alter Flask's configuration for the preceding Experimental step
|
||||
# too — the mobile consumption mode should not do that.
|
||||
SUPERSET_FEATURE_MOBILE_CONSUMPTION_MODE: "true"
|
||||
INCLUDE_MOBILE: "true"
|
||||
with:
|
||||
|
||||
@@ -160,7 +160,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-backend/
|
||||
uses: $/.github/actions/setup-backend/
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Setup Postgres
|
||||
@@ -210,7 +210,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-backend/
|
||||
uses: $/.github/actions/setup-backend/
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
uses: $/.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -80,9 +80,15 @@ jobs:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-backend/
|
||||
uses: $/.github/actions/setup-backend/
|
||||
- name: Setup Postgres
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
# cached-dependencies is a submodule (not a plain directory), and
|
||||
# the $/ self-repository syntax resolves action files directly from
|
||||
# the repository without performing a real (submodule-aware)
|
||||
# checkout, so it can't see into a submodule's link. Keep this one
|
||||
# on the workspace-relative ./ form, consistent with every other
|
||||
# workflow in the repo that references this action.
|
||||
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
|
||||
with:
|
||||
run: setup-postgres
|
||||
- name: Start Celery worker
|
||||
@@ -141,13 +147,19 @@ jobs:
|
||||
- name: Start hadoop and hive
|
||||
run: docker compose -f scripts/databases/hive/docker-compose.yml up -d
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-backend/
|
||||
uses: $/.github/actions/setup-backend/
|
||||
- name: Setup Postgres
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: setup-postgres
|
||||
- name: Start Celery worker
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
# cached-dependencies is a submodule (not a plain directory), and
|
||||
# the $/ self-repository syntax resolves action files directly from
|
||||
# the repository without performing a real (submodule-aware)
|
||||
# checkout, so it can't see into a submodule's link. Keep this one
|
||||
# on the workspace-relative ./ form, consistent with every other
|
||||
# workflow in the repo that references this action.
|
||||
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
|
||||
with:
|
||||
run: celery-worker
|
||||
- name: Python unit tests (PostgreSQL)
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
uses: $/.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-backend/
|
||||
uses: $/.github/actions/setup-backend/
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Python unit tests
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
uses: $/.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -45,7 +45,13 @@ jobs:
|
||||
cache-dependency-path: "superset-frontend/package-lock.json"
|
||||
- name: Install dependencies
|
||||
if: steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
# cached-dependencies is a git submodule (not a plain directory), and
|
||||
# the $/ self-repository syntax resolves action files directly from
|
||||
# the repository without performing a real (submodule-aware)
|
||||
# checkout, so it can't see into a submodule's gitlink. Keep this one
|
||||
# on the workspace-relative ./ form, consistent with every other
|
||||
# workflow in the repo that references this action.
|
||||
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
|
||||
with:
|
||||
run: npm-install
|
||||
- name: lint
|
||||
@@ -68,13 +74,13 @@ jobs:
|
||||
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
uses: $/.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Python
|
||||
if: steps.check.outputs.python == 'true' || steps.check.outputs.frontend == 'true'
|
||||
uses: ./.github/actions/setup-backend/
|
||||
uses: $/.github/actions/setup-backend/
|
||||
|
||||
- name: Install gettext tools
|
||||
if: steps.check.outputs.python == 'true' || steps.check.outputs.frontend == 'true'
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup supersetbot
|
||||
uses: ./.github/actions/setup-supersetbot/
|
||||
uses: $/.github/actions/setup-supersetbot/
|
||||
|
||||
- name: Execute custom Node.js script
|
||||
env:
|
||||
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Docker Environment
|
||||
uses: ./.github/actions/setup-docker
|
||||
uses: $/.github/actions/setup-docker
|
||||
with:
|
||||
dockerhub-user: ${{ secrets.DOCKERHUB_USER }}
|
||||
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Setup supersetbot
|
||||
uses: ./.github/actions/setup-supersetbot/
|
||||
uses: $/.github/actions/setup-supersetbot
|
||||
|
||||
- name: Execute custom Node.js script
|
||||
env:
|
||||
@@ -139,7 +139,7 @@ jobs:
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Setup supersetbot
|
||||
uses: ./.github/actions/setup-supersetbot/
|
||||
uses: $/.github/actions/setup-supersetbot/
|
||||
|
||||
- name: Label the PRs with the right release-related labels
|
||||
env:
|
||||
|
||||
@@ -178,7 +178,7 @@ repos:
|
||||
name: zizmor (GHA security audit)
|
||||
entry: zizmor
|
||||
language: python
|
||||
additional_dependencies: [zizmor==1.25.2]
|
||||
additional_dependencies: [zizmor==1.30.0]
|
||||
files: ^\.github/
|
||||
types: [yaml]
|
||||
pass_filenames: false
|
||||
|
||||
+28
@@ -24,6 +24,34 @@ assists people when migrating to a new version.
|
||||
|
||||
## Next
|
||||
|
||||
### Tagging is on by default
|
||||
|
||||
`TAGGING_SYSTEM` now ships **on**. The Tags menu entry, the tag columns and
|
||||
filters on the chart, dashboard and saved-query lists, and the Tags field in the
|
||||
chart and dashboard property modals are all visible without configuration, and
|
||||
tags are included in asset export and import.
|
||||
|
||||
**What operators should expect:**
|
||||
|
||||
- **Implicit tags accrue.** Saving a chart, dashboard, dataset or saved query,
|
||||
and favoriting an asset, write rows to `tag` and `tagged_object` (`type:chart`,
|
||||
`editor:<user id>`, `favorited_by:<user id>`). These have always been created
|
||||
when the flag was on; they are simply no longer opt-in.
|
||||
- **Exports gain a `tags` key and a `tags.yaml` file.** Chart and dashboard
|
||||
export bundles carry custom tags. Importers on 6.0 and later understand both;
|
||||
older importers skip the unrecognized `tags.yaml` file but reject chart and
|
||||
dashboard YAML that contains a `tags` key, so strip that key before importing
|
||||
a bundle into Superset 5.x or earlier.
|
||||
- **The flag is honored at write time.** The tagging SQLA event listeners are
|
||||
always attached at startup; the ones that create tags check `TAGGING_SYSTEM`
|
||||
when they fire, so the flag, including a runtime override through
|
||||
`GET_FEATURE_FLAGS_FUNC` or `IS_FEATURE_ENABLED_FUNC`, takes effect without a
|
||||
restart. The cleanup listeners run regardless of the flag, so deleting an
|
||||
asset never leaves orphaned `tagged_object` rows behind.
|
||||
|
||||
Set `FEATURE_FLAGS = {"TAGGING_SYSTEM": False}` to restore the previous
|
||||
behavior. Existing tag rows are left untouched.
|
||||
|
||||
### Global Async Queries re-platformed onto the Global Task Framework (breaking)
|
||||
|
||||
Global Async Queries (GAQ) no longer runs on its own bespoke async-events
|
||||
|
||||
@@ -486,6 +486,39 @@ Log in as an admin user to ensure you have adequate permissions.
|
||||
|
||||
This is the best source of information about the problem. In a docker compose deployment, you can do this with a command like `docker logs superset_worker --since 1h`.
|
||||
|
||||
### CSV and Excel chart-data request failures
|
||||
|
||||
The worker uses the saved query context to POST to the chart-data export endpoint,
|
||||
falling back to the legacy GET export when a query context cannot be generated.
|
||||
`ALERT_REPORTS_CSV_REQUEST_TIMEOUT` (60 seconds by default) limits socket operations;
|
||||
the report execution budget and its delivery/cleanup reserves also cap the request.
|
||||
Connection and read timeouts are reported as CSV/Excel generation timeouts.
|
||||
These attachment timeouts are logged at error level and explicitly mark the report
|
||||
task as failed, while the report execution retains its ERROR state and separate
|
||||
error-notification history. Other HTTP 408 exception handling is unchanged.
|
||||
|
||||
To tolerate short-lived transport failures, operators can opt in with
|
||||
`ALERT_REPORTS_CSV_REQUEST_RETRY = True` (default: `False`). This permits **one** retry
|
||||
for transient connection/read failures and HTTP 429, 500, 502, 503, or 504. Other
|
||||
HTTP statuses are not retried. Backoff is 0.5 seconds, extended to at most 2 seconds
|
||||
for a numeric `Retry-After`; longer, invalid, or date-based delays are not retried
|
||||
inline. Both attempts and backoff share the initial request timeout allowance and
|
||||
respect the remaining execution budget. Unbounded requests are not retried.
|
||||
A request that consumes its entire timeout does **not** get another full timeout.
|
||||
Socket timeouts are not wall-clock cancellation: existing report task limits still
|
||||
interrupt in-flight work. A timed-out server query can continue running, so enabling
|
||||
retries can increase database load. Leave retries disabled unless appropriate for
|
||||
your deployment; disable the setting to roll back retry behavior.
|
||||
|
||||
Worker diagnostics include schedule/chart identifiers, a fixed endpoint path (no
|
||||
query string), error category, HTTP status, timeout, elapsed duration, and attempt.
|
||||
For HTTP errors, at most 4097 response bytes are read to enforce a 4096-byte limit.
|
||||
Only recognized Superset error types from up to four JSON errors are retained;
|
||||
free-form messages, extra fields, and non-JSON or oversized bodies are redacted or
|
||||
omitted. Cookies, authentication headers, URLs, SQL, and query payloads are not
|
||||
included in these transport diagnostics. HTTP 400 therefore remains a failure to
|
||||
investigate, not a reason to repeat the same request.
|
||||
|
||||
### Check web browser and webdriver installation
|
||||
|
||||
To take a screenshot, the worker visits the dashboard or chart using a headless browser, then takes a screenshot. If you are able to send a chart as CSV, XLSX, or text but can't send as PNG, your problem may lie with the browser.
|
||||
|
||||
@@ -540,6 +540,8 @@ MCP_STORE_CONFIG = {
|
||||
|
||||
When `CACHE_REDIS_URL` is set, the MCP server uses a Redis-backed EventStore for session management, allowing replicas to share state. Without Redis, each pod manages its own in-memory sessions and stateful MCP interactions may fail when requests hit different replicas.
|
||||
|
||||
`MCP_STATELESS_HTTP` (default `True`) controls whether requests get a fresh, ephemeral transport per HTTP round trip or a transport that stays alive for the session's lifetime. The default suits multi-pod deployments because it doesn't require session affinity -- any pod can handle any request. Its tradeoff: a client disconnecting mid-tool-call can crash not just its own session but other concurrent sessions on the same worker. Setting it to `False` avoids that, but it requires session-affinity (sticky session) routing on `Mcp-Session-Id` at the mesh/ingress layer, since a session's follow-up requests must land on the same pod that created it. See [`MCP_STATELESS_HTTP`](#core) below.
|
||||
|
||||
---
|
||||
|
||||
## Configuration Reference
|
||||
@@ -555,6 +557,7 @@ All MCP settings go in `superset_config.py`. Defaults are defined in `superset/m
|
||||
| `MCP_SERVICE_URL` | `None` | Public base URL for MCP-generated links (set this when behind a reverse proxy) |
|
||||
| `MCP_DEBUG` | `False` | Enable debug logging |
|
||||
| `MCP_DEV_USERNAME` | -- | Superset username for development mode (no auth) |
|
||||
| `MCP_STATELESS_HTTP` | `True` | Streamable-HTTP session mode. `True` gives each request a fresh, ephemeral transport, torn down as soon as that request completes; a client disconnecting mid-tool-call can crash not just its own session but other concurrent sessions on the same worker. `False` keeps the transport alive for the session's lifetime, avoiding that crash, but requires session-affinity routing on `Mcp-Session-Id` for multi-pod deployments (see [Multi-Pod (Kubernetes)](#multi-pod-kubernetes)). |
|
||||
| `MCP_RBAC_ENABLED` | `True` | Enforce Superset's role-based access control on MCP tool calls. When `True`, each tool checks that the authenticated user has the required FAB permission before executing. Disable only for testing or trusted-network deployments. |
|
||||
| `MCP_DISABLED_TOOLS` | `set()` | Set of tool names to remove from the MCP server at startup. Disabled tools are never advertised to AI clients during tool discovery. Useful when a custom extension tool should replace a built-in Superset tool. See [Disabling built-in tools](#disabling-built-in-tools). |
|
||||
| `MCP_DISABLED_CHART_PLUGINS` | `frozenset()` | Set of chart type plugin names (e.g. `"handlebars"`) to hide from `generate_chart`. Does not affect `get_chart_type_schema`. See [Disabling chart type plugins](#disabling-chart-type-plugins). |
|
||||
|
||||
@@ -138,6 +138,18 @@ The existing `APP_NAME` Python config key continues to work for backward compati
|
||||
Email and alert/report notification subjects are driven by backend settings such as
|
||||
`EMAIL_REPORTS_SUBJECT_PREFIX` and `APP_NAME`, not by this theme token.
|
||||
|
||||
To hide the entire brand area in the navbar (both the logo image and the
|
||||
brand text), set `HIDE_NAVBAR_LOGO` in `superset_config.py`:
|
||||
|
||||
```python
|
||||
# Hide the entire brand area in the navbar, including the logo image and the
|
||||
# brand text (brandAppName / APP_NAME). Defaults to False.
|
||||
HIDE_NAVBAR_LOGO = True
|
||||
```
|
||||
|
||||
`HIDE_NAVBAR_LOGO` is a Python config flag rather than a theme token, so it
|
||||
cannot be set through the theme CRUD UI or `THEME_DEFAULT`/`THEME_DARK`.
|
||||
|
||||
### Migration from Configuration to UI
|
||||
|
||||
When `ENABLE_UI_THEME_ADMINISTRATION = True`:
|
||||
@@ -487,6 +499,118 @@ THEME_DEFAULT = {
|
||||
|
||||
This feature provides powerful theming capabilities while maintaining the flexibility of ECharts' extensive configuration options.
|
||||
|
||||
## Component Sizing & Style Tokens
|
||||
|
||||
:::note
|
||||
These tokens landed after the Superset 6.1 release and are only available on
|
||||
`master`; they are not present in any tagged release yet.
|
||||
:::
|
||||
|
||||
Beyond colors and fonts, a handful of Superset-specific tokens let you tune the
|
||||
sizing, radius, and outline behavior of individual UI components. All of these
|
||||
tokens are optional — omit them and components fall back to their existing
|
||||
defaults, so applying them is a zero-visual-change operation until you opt in.
|
||||
|
||||
### Button & DropdownButton Sizing
|
||||
|
||||
```python
|
||||
THEME_DEFAULT = {
|
||||
"token": {
|
||||
# ... other tokens
|
||||
"buttonControlHeight": 32, # default button height, in px
|
||||
"buttonControlHeightSM": 30, # small/dropdown button height, in px
|
||||
"buttonControlHeightXS": 22, # xsmall button height, in px
|
||||
"buttonPaddingInline": 18, # horizontal padding, in px
|
||||
"buttonPaddingInlineSM": 10, # horizontal padding for small buttons, in px
|
||||
"buttonFontSize": 14,
|
||||
"buttonBorderRadius": 4,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`buttonControlHeight` and `buttonBorderRadius` also drive the sizing of the
|
||||
menu-trigger button used by `PageHeaderWithActions`, so a single pair of tokens
|
||||
keeps page-header icon buttons visually consistent with regular buttons.
|
||||
|
||||
For one-off overrides that shouldn't apply to every button in the app, pass a
|
||||
`styleConfig` prop directly to `Button` or `DropdownButton` instead of setting
|
||||
a theme token:
|
||||
|
||||
```tsx
|
||||
<Button
|
||||
styleConfig={{
|
||||
controlHeight: 40,
|
||||
paddingInline: 20,
|
||||
fontSize: 16,
|
||||
fontWeight: 700,
|
||||
borderRadius: 8,
|
||||
ctaMinWidth: 120,
|
||||
ctaMinHeight: 40,
|
||||
iconGap: 8,
|
||||
}}
|
||||
>
|
||||
Click me
|
||||
</Button>
|
||||
|
||||
<DropdownButton
|
||||
styleConfig={{
|
||||
controlHeight: 32,
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
}}
|
||||
menu={menuProps}
|
||||
>
|
||||
Options
|
||||
</DropdownButton>
|
||||
```
|
||||
|
||||
`styleConfig` values take precedence over the equivalent theme tokens, which in
|
||||
turn take precedence over the built-in defaults.
|
||||
|
||||
### Label Border Radius
|
||||
|
||||
```python
|
||||
THEME_DEFAULT = {
|
||||
"token": {
|
||||
"labelBorderRadius": 4, # defaults to 8px
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Select Option Outline
|
||||
|
||||
By default, hovering or navigating to an option in a `Select` dropdown draws a
|
||||
2px outline in `colorPrimary`. Set `selectOptionActiveOutline` to `False` for a
|
||||
more subtle hover style with no outline:
|
||||
|
||||
```python
|
||||
THEME_DEFAULT = {
|
||||
"token": {
|
||||
"selectOptionActiveOutline": False,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dashboard Tile Appearance
|
||||
|
||||
Chart tiles on a dashboard (not text/markdown tiles) can be restyled via
|
||||
`dashboardTile*` tokens. All fall back to the existing look — a
|
||||
`colorBgContainer` background, a `1px solid colorBorder` border, and a
|
||||
hairline `box-shadow` while the tile is fading out (e.g. when a filter
|
||||
makes it irrelevant):
|
||||
|
||||
```python
|
||||
THEME_DEFAULT = {
|
||||
"token": {
|
||||
"dashboardTileBg": "#ffffff",
|
||||
"dashboardTileBorder": "1px solid #e0e0e0",
|
||||
"dashboardTileBorderRadius": 8,
|
||||
"dashboardTileBoxShadow": "0 1px 2px rgba(0, 0, 0, 0.08)",
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced Features
|
||||
|
||||
- **System Themes**: Manage system-wide default and dark themes via UI or configuration
|
||||
|
||||
@@ -215,7 +215,7 @@ If you have a good solution for this, let us know!
|
||||
:::
|
||||
|
||||
:::note
|
||||
Superset uses [Scarf Gateway](https://about.scarf.sh/scarf-gateway) to collect telemetry
|
||||
Superset uses [Scarf Gateway](https://about.scarf.sh/) to collect telemetry
|
||||
data. Knowing the installation counts for different Superset versions informs the project's
|
||||
decisions about patching and long-term support. Scarf purges personally identifiable information
|
||||
(PII) and provides only aggregated statistics.
|
||||
|
||||
@@ -87,6 +87,7 @@ The chart will publish appropriate services to expose the Superset UI internally
|
||||
|
||||
- Configure the Service as a `LoadBalancer` or `NodePort`
|
||||
- Set up an `Ingress` for it - the chart includes a definition, but will need to be tuned to your needs (hostname, tls, annotations etc...)
|
||||
- Set up a Gateway API `HTTPRoute` for it - see [Exposing Superset via Gateway API (HTTPRoute)](#exposing-superset-via-gateway-api-httproute) below
|
||||
- Run `kubectl port-forward superset-xxxx-yyyy :8088` to directly tunnel one pod's port into your localhost
|
||||
|
||||
Depending how you configured external access, the URL will vary. Once you've identified the appropriate URL you can log in with:
|
||||
@@ -135,7 +136,7 @@ init:
|
||||
```
|
||||
|
||||
:::note
|
||||
Superset uses [Scarf Gateway](https://about.scarf.sh/scarf-gateway) to collect telemetry data. Knowing the installation counts for different Superset versions informs the project's decisions about patching and long-term support. Scarf purges personally identifiable information (PII) and provides only aggregated statistics.
|
||||
Superset uses [Scarf Gateway](https://about.scarf.sh/) to collect telemetry data. Knowing the installation counts for different Superset versions informs the project's decisions about patching and long-term support. Scarf purges personally identifiable information (PII) and provides only aggregated statistics.
|
||||
|
||||
There are two independent telemetry channels:
|
||||
|
||||
@@ -319,6 +320,53 @@ configOverrides:
|
||||
AUTH_USER_REGISTRATION_ROLE = "Admin"
|
||||
```
|
||||
|
||||
### Exposing Superset via Gateway API (HTTPRoute)
|
||||
|
||||
As an alternative to `Ingress`, the chart can create a [Gateway API](https://gateway-api.sigs.k8s.io/)
|
||||
`HTTPRoute` that attaches to a Gateway already running in your cluster. This requires the Gateway
|
||||
API CRDs serving the configured `httproute.apiVersion` (`gateway.networking.k8s.io/v1` by default)
|
||||
to be installed, along with a Gateway resource for the route to attach to. If the Gateway lives in
|
||||
a different namespace than the `HTTPRoute` (as in the
|
||||
example below), its listener's `allowedRoutes` must explicitly permit routes from this release's
|
||||
namespace, or the `HTTPRoute` will install successfully but never attach.
|
||||
|
||||
```yaml
|
||||
httproute:
|
||||
enabled: true
|
||||
parentRefs:
|
||||
- name: my-gateway
|
||||
namespace: gateway-system
|
||||
hostnames:
|
||||
- superset.example.com
|
||||
rules:
|
||||
- matches:
|
||||
- path:
|
||||
type: PathPrefix
|
||||
value: /
|
||||
```
|
||||
|
||||
- `httproute.parentRefs` lists the Gateway(s) the route attaches to.
|
||||
- `httproute.hostnames` matches against the HTTP `Host` header; it's templated, so values like
|
||||
`{{ .Release.Name }}` can be used.
|
||||
- `httproute.rules` are routing rules backed by the Superset service; each rule accepts standard
|
||||
`matches`, `filters`, and `timeouts` fields, and an optional `weight` (defaults to `1`) applied to
|
||||
its single backend reference. Since each rule maps to one backend, `weight` has no traffic-splitting
|
||||
effect here; it only matters if you fork the template to add multiple `backendRefs` to a rule.
|
||||
`timeouts` only joined the Gateway API Standard channel in v1.2, so it requires both v1.2+ CRDs
|
||||
and a supporting controller; drop it if either predates that.
|
||||
- If `supersetWebsockets.enabled` is set, an extra rule routing `supersetWebsockets.ingress.path`
|
||||
(default `/ws`) to the `-ws` service is appended automatically, mirroring the `Ingress` behavior.
|
||||
WebSocket upgrade support is controller-dependent under Gateway API; check your Gateway
|
||||
implementation's docs in case it needs an explicit protocol opt-in for global async queries to
|
||||
keep working behind a Gateway.
|
||||
- If `supersetMcp.enabled` and `supersetMcp.httproute.enabled` are both set, an extra rule routing
|
||||
`supersetMcp.httproute.path` to the `-mcp` service is appended as well. Don't expose this route
|
||||
without first enabling MCP authentication — see the
|
||||
[MCP Server Deployment & Authentication](/admin-docs/configuration/mcp-server#authentication) doc;
|
||||
by default the MCP server runs in dev mode with auth disabled.
|
||||
- Set `httproute.apiVersion` to `gateway.networking.k8s.io/v1beta1` if your cluster's Gateway API
|
||||
installation hasn't promoted `HTTPRoute` to `v1` yet.
|
||||
|
||||
### Enable Alerts and Reports
|
||||
|
||||
For this, as per the [Alerts and Reports doc](/admin-docs/configuration/alerts-reports), you will need to:
|
||||
|
||||
@@ -215,7 +215,7 @@ If you have a good solution for this, let us know!
|
||||
:::
|
||||
|
||||
:::note
|
||||
Superset uses [Scarf Gateway](https://about.scarf.sh/scarf-gateway) to collect telemetry
|
||||
Superset uses [Scarf Gateway](https://about.scarf.sh/) to collect telemetry
|
||||
data. Knowing the installation counts for different Superset versions informs the project's
|
||||
decisions about patching and long-term support. Scarf purges personally identifiable information
|
||||
(PII) and provides only aggregated statistics.
|
||||
|
||||
@@ -135,7 +135,7 @@ init:
|
||||
```
|
||||
|
||||
:::note
|
||||
Superset uses [Scarf Gateway](https://about.scarf.sh/scarf-gateway) to collect telemetry data. Knowing the installation counts for different Superset versions informs the project's decisions about patching and long-term support. Scarf purges personally identifiable information (PII) and provides only aggregated statistics.
|
||||
Superset uses [Scarf Gateway](https://about.scarf.sh/) to collect telemetry data. Knowing the installation counts for different Superset versions informs the project's decisions about patching and long-term support. Scarf purges personally identifiable information (PII) and provides only aggregated statistics.
|
||||
|
||||
To opt-out of this data collection in your Helm-based installation, edit the `repository:` line in your `helm/superset/values.yaml` file, replacing `apachesuperset.docker.scarf.sh/apache/superset` with `apache/superset` to pull the image directly from Docker Hub.
|
||||
:::
|
||||
|
||||
@@ -379,6 +379,15 @@ AG Grid supports server-side column filters that query the full dataset — not
|
||||
|
||||
AG Grid Interactive Table supports **Time Shift** (time comparison), matching the behavior of the standard Table chart. In the **Advanced Analytics** → **Time Comparison** section of the chart configuration, enter a shift expression (e.g., `1 year ago`, `minus 7 days`) to add comparison columns showing values from the offset period. Dashboard-level time range overrides apply to both the base and comparison periods.
|
||||
|
||||
#### Show Summary
|
||||
|
||||
The **Show summary** checkbox lives at the top of the **Visual formatting** section in the **Customize** tab, for both **Aggregate** and **Raw Records** query modes. Enabling it pins a summary row to the bottom of the grid whenever there is something to summarize: at least one metric in **Aggregate** mode, or at least one eligible numeric column in **Raw Records** mode. Otherwise no summary row is added.
|
||||
|
||||
- In **Aggregate** mode, the summary row applies each metric's own aggregation (or the **Summary aggregation** override, where available) across the full filtered dataset.
|
||||
- In **Raw Records** mode, the summary row defaults to a server-side `SUM` for each numeric column that's backed by a physical or calculated dataset column; the **Summary aggregation** control can override this to `AVG` as well. Non-numeric cells and columns built from free-form SQL expressions stay blank.
|
||||
|
||||
In both modes, the summary is computed across the full result set, independent of the chart's row limit and pagination, and it reflects dashboard and chart-level filters. It does not reflect AG Grid's own server-side column filters (the per-column filter UI in the grid header), which are excluded from the summary query.
|
||||
|
||||
### Dynamic Currency Formatting
|
||||
|
||||
Chart metric values can display currencies dynamically rather than using a fixed currency code. To enable:
|
||||
|
||||
@@ -145,3 +145,51 @@ The following URL parameters can be passed through the `urlParams` option in `da
|
||||
- **Row-level security** — pass `rls` rules in the guest token request to restrict which rows are visible to the embedded user.
|
||||
- **Allowed domains** — restrict which host origins can embed a dashboard by setting **Allowed Domains** per-dashboard in the _Embed_ settings modal. Superset checks the request's `Referer` header against this list before serving the embedded view; an empty list allows any origin, so configure this explicitly for production.
|
||||
- **Redacted errors** — API responses to a guest token report a generic `An error occurred while fetching the data.` instead of the underlying error, since engine errors quote catalog, schema, table and column names. Errors Superset raises itself — access denials, timeouts, payload validation — keep their message, and the full error is always available in the server logs.
|
||||
|
||||
|
||||
## Guest-token request-header size diagnostics
|
||||
|
||||
A successful guest-token mint does not guarantee the token can pass through your
|
||||
deployment's proxies. Limits apply to the **encoded JWT bytes plus header
|
||||
overhead**, not the number of RLS rules or identifiers. A proxy can reject the
|
||||
subsequent authentication request before it reaches Superset, including an HTTP
|
||||
400 HTML response instead of JSON. A 400 alone does not establish a size problem.
|
||||
|
||||
Operators can set a deployment-specific diagnostic budget in `superset_config.py`:
|
||||
|
||||
```python
|
||||
# Example only: choose a budget for your complete proxy path.
|
||||
GUEST_TOKEN_HEADER_MAX_BYTES = 16 * 1024
|
||||
```
|
||||
|
||||
The default is `None` (no budget warnings). Positive integer budgets count UTF-8
|
||||
bytes of `GUEST_TOKEN_HEADER_NAME`, `: `, the encoded token, and `\r\n`
|
||||
(four framing bytes). Only sizes **strictly greater** than the budget warn;
|
||||
equality does not. This is consistent diagnostic accounting, not a prediction of
|
||||
every proxy's wire-level accounting, HTTP/2 compression, or total-header limits.
|
||||
Leave a safety margin and validate your actual deployment, including custom
|
||||
header names. Zero, negative, non-integral, or non-numeric values (including strings and
|
||||
booleans) disable budget warnings, as do values above JavaScript's maximum safe
|
||||
integer (2^53 − 1). Whole-number floats are accepted. Convert environment-variable
|
||||
strings to integers in deployment configuration to enable the budget.
|
||||
|
||||
Issuance audit metadata includes `token_bytes`, `header_bytes`,
|
||||
`header_budget_bytes`, and `header_budget_exceeded`. Issuance remains HTTP 200
|
||||
with the same token and response shape. The embedded bootstrap exposes the budget
|
||||
and configured header name; reload the iframe after changing deployment config.
|
||||
The embedded client measures initial and refreshed tokens and warns in the
|
||||
developer console with sizes only. Initial authentication failures get a targeted
|
||||
suggestion only when the request's token exceeds the budget and the failure has
|
||||
no status or HTTP 400/431/494; other statuses and ambiguous in-flight
|
||||
refreshes use the generic error. Refresh warnings do not restart authentication.
|
||||
These diagnostics do not record JWTs, decoded claims, RLS SQL, or request headers.
|
||||
|
||||
[AWS Application Load Balancer quotas](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-limits.html)
|
||||
list a non-adjustable 16 K single-header limit. Increasing a Superset diagnostic
|
||||
budget does not increase that limit or add large-token support.
|
||||
|
||||
To reduce payload size, replace large inline RLS ID lists with a compact
|
||||
entitlements-table subquery where supported by your database. Keep the same
|
||||
tenant/user restrictions, derive identity from your trusted token-issuing
|
||||
backend, and verify equivalent row access and query performance before rollout.
|
||||
Do not remove RLS or broaden entitlements to make a token smaller.
|
||||
|
||||
@@ -71,17 +71,17 @@ Parses a JSON string into an object that can be used in your template.
|
||||
|
||||
---
|
||||
|
||||
#### `groupBy`
|
||||
#### `group`
|
||||
|
||||
Groups an array of objects by a key, powered by [handlebars-group-by](https://github.com/nicktindall/handlebars-group-by).
|
||||
Groups an array of objects by a key, powered by [handlebars-group-by](https://github.com/nicktindall/handlebars-group-by). The key is passed as a `by` hash argument.
|
||||
|
||||
```handlebars
|
||||
{{#groupBy data 'department'}}
|
||||
{{#group data by="department"}}
|
||||
<h3>{{value}}</h3>
|
||||
{{#each items}}
|
||||
<p>{{this.name}}</p>
|
||||
{{/each}}
|
||||
{{/groupBy}}
|
||||
{{/group}}
|
||||
```
|
||||
|
||||
---
|
||||
@@ -90,6 +90,14 @@ Groups an array of objects by a key, powered by [handlebars-group-by](https://gi
|
||||
|
||||
Superset also registers all helpers from the [just-handlebars-helpers](https://github.com/leapfrogtechnology/just-handlebars-helpers) library. These include a wide range of comparison, math, string, and conditional helpers. Commonly used ones include:
|
||||
|
||||
:::note
|
||||
These names are specific to `just-handlebars-helpers` and differ from other
|
||||
Handlebars helper libraries — notably `handlebars-helpers`, which spells the
|
||||
math helpers `add`, `subtract`, `multiply` and `divide`. Calling a helper that
|
||||
is not registered raises `Missing helper: "..."`, which renders the chart blank,
|
||||
so it is worth checking a name against the tables below before using it.
|
||||
:::
|
||||
|
||||
#### Comparison
|
||||
|
||||
| Helper | Description | Example |
|
||||
@@ -97,6 +105,7 @@ Superset also registers all helpers from the [just-handlebars-helpers](https://g
|
||||
| `eq` | Strict equality | `{{#if (eq status "active")}}` |
|
||||
| `eqw` | Weak equality | `{{#if (eqw count "5")}}` |
|
||||
| `neq` | Strict inequality | `{{#if (neq role "admin")}}` |
|
||||
| `neqw` | Weak inequality | `{{#if (neqw count "5")}}` |
|
||||
| `lt` | Less than | `{{#if (lt score 50)}}` |
|
||||
| `lte` | Less than or equal | `{{#if (lte score 100)}}` |
|
||||
| `gt` | Greater than | `{{#if (gt price 0)}}` |
|
||||
@@ -114,25 +123,52 @@ Superset also registers all helpers from the [just-handlebars-helpers](https://g
|
||||
|
||||
#### String
|
||||
|
||||
| Helper | Description | Example |
|
||||
| ------------ | ----------------------------------- | --------------------------------- |
|
||||
| `capitalize` | Capitalizes first letter | `{{capitalize name}}` |
|
||||
| `uppercase` | Converts to uppercase | `{{uppercase status}}` |
|
||||
| `lowercase` | Converts to lowercase | `{{lowercase email}}` |
|
||||
| `truncate` | Truncates a string | `{{truncate description 100}}` |
|
||||
| `contains` | Checks if string contains substring | `{{#if (contains tag "urgent")}}` |
|
||||
| Helper | Description | Example |
|
||||
| ----------------- | ----------------------------------------------- | ------------------------------ |
|
||||
| `capitalizeFirst` | Capitalizes the first letter | `{{capitalizeFirst name}}` |
|
||||
| `capitalizeEach` | Capitalizes the first letter of each word | `{{capitalizeEach title}}` |
|
||||
| `uppercase` | Converts to uppercase | `{{uppercase status}}` |
|
||||
| `lowercase` | Converts to lowercase | `{{lowercase email}}` |
|
||||
| `excerpt` | Truncates to a length and appends an ellipsis | `{{excerpt description 100}}` |
|
||||
| `sprintf` | printf-style formatting | `{{sprintf "%.1f" score}}` |
|
||||
| `concat` | Concatenates values | `{{concat first " " last}}` |
|
||||
| `join` | Joins an array with a separator | `{{join tags ", "}}` |
|
||||
| `first` / `last` | First or last element of an array | `{{first items}}` |
|
||||
| `newLineToBr` | Converts newlines to `<br>` (needs `{{{ }}}`) | `{{{newLineToBr notes}}}` |
|
||||
|
||||
#### Math
|
||||
|
||||
| Helper | Description | Example |
|
||||
| ---------- | -------------- | ----------------------------- |
|
||||
| `add` | Addition | `{{add a b}}` |
|
||||
| `subtract` | Subtraction | `{{subtract total discount}}` |
|
||||
| `multiply` | Multiplication | `{{multiply price quantity}}` |
|
||||
| `divide` | Division | `{{divide total count}}` |
|
||||
| `ceil` | Ceiling | `{{ceil value}}` |
|
||||
| `floor` | Floor | `{{floor value}}` |
|
||||
| `round` | Round | `{{round value}}` |
|
||||
| Helper | Description | Example |
|
||||
| ---------------- | ----------------------- | ------------------------------------ |
|
||||
| `sum` | Addition | `{{sum a b}}` |
|
||||
| `difference` | Subtraction | `{{difference total discount}}` |
|
||||
| `multiplication` | Multiplication | `{{multiplication price quantity}}` |
|
||||
| `division` | Division | `{{division total count}}` |
|
||||
| `remainder` | Modulo | `{{remainder index 2}}` |
|
||||
| `abs` | Absolute value | `{{abs delta}}` |
|
||||
| `ceil` | Ceiling | `{{ceil value}}` |
|
||||
| `floor` | Floor | `{{floor value}}` |
|
||||
|
||||
`sum` takes exactly two arguments — it adds a pair of numbers and does not total
|
||||
an array. There is no `round` helper; use `{{sprintf "%.0f" value}}` to round to
|
||||
a given number of decimal places.
|
||||
|
||||
#### Arrays
|
||||
|
||||
| Helper | Description | Example |
|
||||
| ---------- | ---------------------------------- | --------------------------------- |
|
||||
| `includes` | Whether an array contains a value | `{{#if (includes tags "urgent")}}` |
|
||||
| `empty` | Whether an array is empty | `{{#if (empty rows)}}` |
|
||||
| `count` | Number of items in an array | `{{count rows}}` |
|
||||
|
||||
`includes` tests array membership. It returns `false` for a string, so it cannot
|
||||
be used to check for a substring.
|
||||
|
||||
#### Formatting
|
||||
|
||||
| Helper | Description | Example |
|
||||
| ---------------- | ---------------------------- | -------------------------------- |
|
||||
| `formatCurrency` | Formats a number as currency | `{{formatCurrency revenue "$"}}` |
|
||||
|
||||
For the full list of available helpers, see the [just-handlebars-helpers documentation](https://github.com/leapfrogtechnology/just-handlebars-helpers).
|
||||
|
||||
|
||||
@@ -305,6 +305,14 @@ Ask your admin for the MCP server URL and any authentication tokens you need.
|
||||
| `list_databases` | List configured database connections |
|
||||
| `get_database_info` | Get details about a specific database connection |
|
||||
|
||||
### Themes
|
||||
|
||||
| Tool | Description |
|
||||
| ---------------- | ------------------------------------------------------------------------- |
|
||||
| `list_themes` | Discover themes (antd design-token configurations) with filters |
|
||||
| `get_theme_info` | Get a theme's tokens (`json_data`) by ID or UUID |
|
||||
| `create_theme` | Create a reusable theme from antd design tokens (requires write access) |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
+5
-2
@@ -43,8 +43,11 @@ publish = "build"
|
||||
ignore = 'if [ -n "$CACHED_COMMIT_REF" ]; then git diff --quiet "$CACHED_COMMIT_REF" HEAD -- . ../README.md; else git fetch --no-tags origin master >/dev/null 2>&1 || true; i=0; while [ "$i" -lt 10 ] && ! git merge-base origin/master HEAD >/dev/null 2>&1; do git fetch --deepen=200 origin master >/dev/null 2>&1 || break; i=$((i+1)); done; BASE="$(git merge-base origin/master HEAD 2>/dev/null || true)"; if [ -z "$BASE" ]; then exit 1; fi; git diff --quiet "$BASE" HEAD -- . ../README.md; fi'
|
||||
|
||||
[build.environment]
|
||||
# Node version matching docs/.nvmrc
|
||||
NODE_VERSION = "20"
|
||||
# Node version is intentionally not pinned here: Netlify auto-detects it
|
||||
# from docs/.nvmrc, which is a symlink to the repo's single source of truth
|
||||
# at superset-frontend/.nvmrc. Duplicating the version here previously let
|
||||
# it drift out of sync (stuck on Node 20 after the repo moved to Node 24),
|
||||
# breaking installs once a dependency required a newer Node engine.
|
||||
# Yarn version
|
||||
YARN_VERSION = "1.22.22"
|
||||
# Increase heap size for webpack bundling of Superset UI components
|
||||
|
||||
+2
-2
@@ -43,7 +43,7 @@
|
||||
"version:remove:components": "node scripts/manage-versions.mjs remove components"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.2.5",
|
||||
"@ant-design/icons": "^6.3.4",
|
||||
"@docusaurus/core": "^3.10.2",
|
||||
"@docusaurus/faster": "^3.10.2",
|
||||
"@docusaurus/plugin-client-redirects": "^3.10.2",
|
||||
@@ -91,7 +91,7 @@
|
||||
"oxlint": "^1.80.0",
|
||||
"oxlint-tsgolint": "^7.0.2001",
|
||||
"typescript": "7.0.2",
|
||||
"webpack": "^5.110.1"
|
||||
"webpack": "^5.110.2"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
||||
Vendored
+6
-6
@@ -98,12 +98,6 @@
|
||||
"default": false,
|
||||
"lifecycle": "development",
|
||||
"description": "Enable Table V2 time comparison feature"
|
||||
},
|
||||
{
|
||||
"name": "TAGGING_SYSTEM",
|
||||
"default": false,
|
||||
"lifecycle": "development",
|
||||
"description": "Enables the tagging system for organizing assets"
|
||||
}
|
||||
],
|
||||
"testing": [
|
||||
@@ -240,6 +234,12 @@
|
||||
"description": "Allow users to enable SSH tunneling when creating a DB connection. DB engine must support SSH Tunnels.",
|
||||
"docs": "https://superset.apache.org/docs/configuration/setup-ssh-tunneling"
|
||||
},
|
||||
{
|
||||
"name": "TAGGING_SYSTEM",
|
||||
"default": true,
|
||||
"lifecycle": "testing",
|
||||
"description": "Enables the tagging system for organizing assets"
|
||||
},
|
||||
{
|
||||
"name": "USE_ANALOGOUS_COLORS",
|
||||
"default": false,
|
||||
|
||||
@@ -215,7 +215,7 @@ If you have a good solution for this, let us know!
|
||||
:::
|
||||
|
||||
:::note
|
||||
Superset uses [Scarf Gateway](https://about.scarf.sh/scarf-gateway) to collect telemetry
|
||||
Superset uses [Scarf Gateway](https://about.scarf.sh/) to collect telemetry
|
||||
data. Knowing the installation counts for different Superset versions informs the project's
|
||||
decisions about patching and long-term support. Scarf purges personally identifiable information
|
||||
(PII) and provides only aggregated statistics.
|
||||
|
||||
@@ -135,7 +135,7 @@ init:
|
||||
```
|
||||
|
||||
:::note
|
||||
Superset uses [Scarf Gateway](https://about.scarf.sh/scarf-gateway) to collect telemetry data. Knowing the installation counts for different Superset versions informs the project's decisions about patching and long-term support. Scarf purges personally identifiable information (PII) and provides only aggregated statistics.
|
||||
Superset uses [Scarf Gateway](https://about.scarf.sh/) to collect telemetry data. Knowing the installation counts for different Superset versions informs the project's decisions about patching and long-term support. Scarf purges personally identifiable information (PII) and provides only aggregated statistics.
|
||||
|
||||
To opt-out of this data collection in your Helm-based installation, edit the `repository:` line in your `helm/superset/values.yaml` file, replacing `apachesuperset.docker.scarf.sh/apache/superset` with `apache/superset` to pull the image directly from Docker Hub.
|
||||
:::
|
||||
|
||||
@@ -71,17 +71,17 @@ Parses a JSON string into an object that can be used in your template.
|
||||
|
||||
---
|
||||
|
||||
#### `groupBy`
|
||||
#### `group`
|
||||
|
||||
Groups an array of objects by a key, powered by [handlebars-group-by](https://github.com/nicktindall/handlebars-group-by).
|
||||
Groups an array of objects by a key, powered by [handlebars-group-by](https://github.com/nicktindall/handlebars-group-by). The key is passed as a `by` hash argument.
|
||||
|
||||
```handlebars
|
||||
{{#groupBy data 'department'}}
|
||||
{{#group data by="department"}}
|
||||
<h3>{{value}}</h3>
|
||||
{{#each items}}
|
||||
<p>{{this.name}}</p>
|
||||
{{/each}}
|
||||
{{/groupBy}}
|
||||
{{/group}}
|
||||
```
|
||||
|
||||
---
|
||||
@@ -90,6 +90,14 @@ Groups an array of objects by a key, powered by [handlebars-group-by](https://gi
|
||||
|
||||
Superset also registers all helpers from the [just-handlebars-helpers](https://github.com/leapfrogtechnology/just-handlebars-helpers) library. These include a wide range of comparison, math, string, and conditional helpers. Commonly used ones include:
|
||||
|
||||
:::note
|
||||
These names are specific to `just-handlebars-helpers` and differ from other
|
||||
Handlebars helper libraries — notably `handlebars-helpers`, which spells the
|
||||
math helpers `add`, `subtract`, `multiply` and `divide`. Calling a helper that
|
||||
is not registered raises `Missing helper: "..."`, which renders the chart blank,
|
||||
so it is worth checking a name against the tables below before using it.
|
||||
:::
|
||||
|
||||
#### Comparison
|
||||
|
||||
| Helper | Description | Example |
|
||||
@@ -97,6 +105,7 @@ Superset also registers all helpers from the [just-handlebars-helpers](https://g
|
||||
| `eq` | Strict equality | `{{#if (eq status "active")}}` |
|
||||
| `eqw` | Weak equality | `{{#if (eqw count "5")}}` |
|
||||
| `neq` | Strict inequality | `{{#if (neq role "admin")}}` |
|
||||
| `neqw` | Weak inequality | `{{#if (neqw count "5")}}` |
|
||||
| `lt` | Less than | `{{#if (lt score 50)}}` |
|
||||
| `lte` | Less than or equal | `{{#if (lte score 100)}}` |
|
||||
| `gt` | Greater than | `{{#if (gt price 0)}}` |
|
||||
@@ -114,25 +123,52 @@ Superset also registers all helpers from the [just-handlebars-helpers](https://g
|
||||
|
||||
#### String
|
||||
|
||||
| Helper | Description | Example |
|
||||
| ------------ | ----------------------------------- | --------------------------------- |
|
||||
| `capitalize` | Capitalizes first letter | `{{capitalize name}}` |
|
||||
| `uppercase` | Converts to uppercase | `{{uppercase status}}` |
|
||||
| `lowercase` | Converts to lowercase | `{{lowercase email}}` |
|
||||
| `truncate` | Truncates a string | `{{truncate description 100}}` |
|
||||
| `contains` | Checks if string contains substring | `{{#if (contains tag "urgent")}}` |
|
||||
| Helper | Description | Example |
|
||||
| ----------------- | ----------------------------------------------- | ------------------------------ |
|
||||
| `capitalizeFirst` | Capitalizes the first letter | `{{capitalizeFirst name}}` |
|
||||
| `capitalizeEach` | Capitalizes the first letter of each word | `{{capitalizeEach title}}` |
|
||||
| `uppercase` | Converts to uppercase | `{{uppercase status}}` |
|
||||
| `lowercase` | Converts to lowercase | `{{lowercase email}}` |
|
||||
| `excerpt` | Truncates to a length and appends an ellipsis | `{{excerpt description 100}}` |
|
||||
| `sprintf` | printf-style formatting | `{{sprintf "%.1f" score}}` |
|
||||
| `concat` | Concatenates values | `{{concat first " " last}}` |
|
||||
| `join` | Joins an array with a separator | `{{join tags ", "}}` |
|
||||
| `first` / `last` | First or last element of an array | `{{first items}}` |
|
||||
| `newLineToBr` | Converts newlines to `<br>` (needs `{{{ }}}`) | `{{{newLineToBr notes}}}` |
|
||||
|
||||
#### Math
|
||||
|
||||
| Helper | Description | Example |
|
||||
| ---------- | -------------- | ----------------------------- |
|
||||
| `add` | Addition | `{{add a b}}` |
|
||||
| `subtract` | Subtraction | `{{subtract total discount}}` |
|
||||
| `multiply` | Multiplication | `{{multiply price quantity}}` |
|
||||
| `divide` | Division | `{{divide total count}}` |
|
||||
| `ceil` | Ceiling | `{{ceil value}}` |
|
||||
| `floor` | Floor | `{{floor value}}` |
|
||||
| `round` | Round | `{{round value}}` |
|
||||
| Helper | Description | Example |
|
||||
| ---------------- | ----------------------- | ------------------------------------ |
|
||||
| `sum` | Addition | `{{sum a b}}` |
|
||||
| `difference` | Subtraction | `{{difference total discount}}` |
|
||||
| `multiplication` | Multiplication | `{{multiplication price quantity}}` |
|
||||
| `division` | Division | `{{division total count}}` |
|
||||
| `remainder` | Modulo | `{{remainder index 2}}` |
|
||||
| `abs` | Absolute value | `{{abs delta}}` |
|
||||
| `ceil` | Ceiling | `{{ceil value}}` |
|
||||
| `floor` | Floor | `{{floor value}}` |
|
||||
|
||||
`sum` takes exactly two arguments — it adds a pair of numbers and does not total
|
||||
an array. There is no `round` helper; use `{{sprintf "%.0f" value}}` to round to
|
||||
a given number of decimal places.
|
||||
|
||||
#### Arrays
|
||||
|
||||
| Helper | Description | Example |
|
||||
| ---------- | ---------------------------------- | --------------------------------- |
|
||||
| `includes` | Whether an array contains a value | `{{#if (includes tags "urgent")}}` |
|
||||
| `empty` | Whether an array is empty | `{{#if (empty rows)}}` |
|
||||
| `count` | Number of items in an array | `{{count rows}}` |
|
||||
|
||||
`includes` tests array membership. It returns `false` for a string, so it cannot
|
||||
be used to check for a substring.
|
||||
|
||||
#### Formatting
|
||||
|
||||
| Helper | Description | Example |
|
||||
| ---------------- | ---------------------------- | -------------------------------- |
|
||||
| `formatCurrency` | Formats a number as currency | `{{formatCurrency revenue "$"}}` |
|
||||
|
||||
For the full list of available helpers, see the [just-handlebars-helpers documentation](https://github.com/leapfrogtechnology/just-handlebars-helpers).
|
||||
|
||||
|
||||
+85
-146
@@ -222,18 +222,18 @@
|
||||
resolved "https://registry.yarnpkg.com/@ant-design/fast-color/-/fast-color-3.0.1.tgz#fee56b95427c0b55b216c93d9a7f3473f31615b5"
|
||||
integrity sha512-esKJegpW4nckh0o6kV3Tkb7NPIZYbPnnFxmQDUmL08ukXZAvV85TZBr70eGuke/CIArLaP6aw8lt9KILjnWuOw==
|
||||
|
||||
"@ant-design/icons-svg@^4.5.0":
|
||||
version "4.5.0"
|
||||
resolved "https://registry.yarnpkg.com/@ant-design/icons-svg/-/icons-svg-4.5.0.tgz#7b1c567e489840d747f211d3688949bcba363ad2"
|
||||
integrity sha512-1BTUFyKPTBZ53MuTP8s0k5SFEXL7o3VHEOwLgzaoWKwnBeqIcqUtVshc4SKzhI6uACfqhJqBwBUE9FsWR3uULA==
|
||||
"@ant-design/icons-svg@^4.6.0":
|
||||
version "4.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@ant-design/icons-svg/-/icons-svg-4.6.0.tgz#5f7ebfe2a6b7c871920f73db095bd4ab50d7764d"
|
||||
integrity sha512-PRomU725ABMf/lnQp5HiB7my1kjEbFY0D10N4lXYxK6TIB1gKjIVD5MRThpDaezLgw1D774J8eOeeDB0M6wHrQ==
|
||||
|
||||
"@ant-design/icons@^6.2.5", "@ant-design/icons@^6.3.2":
|
||||
version "6.3.2"
|
||||
resolved "https://registry.yarnpkg.com/@ant-design/icons/-/icons-6.3.2.tgz#8291dffc53003db9a5df59f80ed758473cd5c8df"
|
||||
integrity sha512-B6O5a5XJ4wjtNOfZejXYwHW5zvKV5gYkjGf11dHGLEbKn0ABDGndo41+gfIiXyTFhvESj4XTotuud33mUFid0g==
|
||||
"@ant-design/icons@^6.3.2", "@ant-design/icons@^6.3.4":
|
||||
version "6.3.4"
|
||||
resolved "https://registry.yarnpkg.com/@ant-design/icons/-/icons-6.3.4.tgz#9a76f7b6b4554a65f25e3e5c439405b96c4ebd71"
|
||||
integrity sha512-kDoUlppczVyCUTFthF6cHmPzMBYqNZQhu9j6EyIX/YIdM9j9CZNZoJUjzVw7/1v1VO8WwVMmPp15r/uwIAS0AQ==
|
||||
dependencies:
|
||||
"@ant-design/colors" "^8.0.1"
|
||||
"@ant-design/icons-svg" "^4.5.0"
|
||||
"@ant-design/icons-svg" "^4.6.0"
|
||||
"@rc-component/util" "^1.11.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
@@ -4969,85 +4969,85 @@
|
||||
resolved "https://registry.yarnpkg.com/@swc/counter/-/counter-0.1.3.tgz#cc7463bd02949611c6329596fccd2b0ec782b0e9"
|
||||
integrity sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==
|
||||
|
||||
"@swc/html-darwin-arm64@1.15.43":
|
||||
version "1.15.43"
|
||||
resolved "https://registry.yarnpkg.com/@swc/html-darwin-arm64/-/html-darwin-arm64-1.15.43.tgz#c88069f140ead901724018f96ad709779526a368"
|
||||
integrity sha512-+PFbHbeeN+zB0zfvR1V1NmvPriuWPI+sijQXpI+wq/nLIujxvtENWjOKVHgouC9TIN/uKmL2zu9HAq6L6YxnPA==
|
||||
"@swc/html-darwin-arm64@1.16.2":
|
||||
version "1.16.2"
|
||||
resolved "https://registry.yarnpkg.com/@swc/html-darwin-arm64/-/html-darwin-arm64-1.16.2.tgz#dfe45d70266262a59aaaa0d93740b6161803f192"
|
||||
integrity sha512-SNBUxkxLBXD0ATwnOG1rF8mpSrRtFDfqWnEUmbm/g4KwmCt7NuHHv9YYqA3lqfq90Ucc+Xlk7afx8KAW/utz4A==
|
||||
|
||||
"@swc/html-darwin-x64@1.15.43":
|
||||
version "1.15.43"
|
||||
resolved "https://registry.yarnpkg.com/@swc/html-darwin-x64/-/html-darwin-x64-1.15.43.tgz#9bc8171494d3b98eac017b5b73f0f63054558626"
|
||||
integrity sha512-LQJ2U8Oxcx4T1rRF25y4h+/p05nn58FugTe/uGxC5OT3K83c2MftcSZLYaahOu4GVHRZeS1NI94CkSvQV++TVw==
|
||||
"@swc/html-darwin-x64@1.16.2":
|
||||
version "1.16.2"
|
||||
resolved "https://registry.yarnpkg.com/@swc/html-darwin-x64/-/html-darwin-x64-1.16.2.tgz#72066bda0d843c024dd5230aa013445d10037cde"
|
||||
integrity sha512-WVBgn6yrBPMZu+DL95/XGAXYcgd1nhd67Ml1UjMtFoFMVKY+VRpCq8JpTZTMXhWbVoRENUHk+3PHu0nNjlE/Fg==
|
||||
|
||||
"@swc/html-linux-arm-gnueabihf@1.15.43":
|
||||
version "1.15.43"
|
||||
resolved "https://registry.yarnpkg.com/@swc/html-linux-arm-gnueabihf/-/html-linux-arm-gnueabihf-1.15.43.tgz#271a5d345719fa2c381df45355f70870faaaf35c"
|
||||
integrity sha512-DKIen6DuIRO7Xc5gAbgBT5QyRHJGEGXreIdM1VBosYWTGnnrQ//Hwd7bLD6UbT8X8eU1vqvpXwQ1E24QRqRaBQ==
|
||||
"@swc/html-linux-arm-gnueabihf@1.16.2":
|
||||
version "1.16.2"
|
||||
resolved "https://registry.yarnpkg.com/@swc/html-linux-arm-gnueabihf/-/html-linux-arm-gnueabihf-1.16.2.tgz#efa7dd85b03b941ac173ae2870355f9721993905"
|
||||
integrity sha512-V9F/Akd2TXrf5nUhdLgdy3FoVFxQbw8pA2AOyqnEOa2Mbm1R7DZJJ0GdShEMcoyMyMDB9r/4pWuWfxNtP4mFHA==
|
||||
|
||||
"@swc/html-linux-arm64-gnu@1.15.43":
|
||||
version "1.15.43"
|
||||
resolved "https://registry.yarnpkg.com/@swc/html-linux-arm64-gnu/-/html-linux-arm64-gnu-1.15.43.tgz#93cd3202f204351e7279efd07abc280fe0b2d193"
|
||||
integrity sha512-0AuHiyfcE86CZ/CajFIszLzZVzbM2wn5p01oet8Q9RikflCGwyH79Nv9TrAKD1Cx7juUrONzDk+f2b/x73wLTg==
|
||||
"@swc/html-linux-arm64-gnu@1.16.2":
|
||||
version "1.16.2"
|
||||
resolved "https://registry.yarnpkg.com/@swc/html-linux-arm64-gnu/-/html-linux-arm64-gnu-1.16.2.tgz#4b6a02b42a40463e9ed9a96e0c42b7095b244f48"
|
||||
integrity sha512-jonZVtHc6BesMjC/muUEJGzE1L2kVdgiPVuHc7CL79MrUm0Hjf8LS4Wmtjqe2bLTfRcaMfaYl/60ZcRXHCaYSQ==
|
||||
|
||||
"@swc/html-linux-arm64-musl@1.15.43":
|
||||
version "1.15.43"
|
||||
resolved "https://registry.yarnpkg.com/@swc/html-linux-arm64-musl/-/html-linux-arm64-musl-1.15.43.tgz#a6c0c2a1646755b1ee53a6bddbee68674f8edaea"
|
||||
integrity sha512-TweIdl/g9ugkoiYvcL/qbu+gbglDY3TqNxfXH84WXc4rSqEP20owVlxLya2NjVct8LIP2wDrtutpOwAXWC+Eew==
|
||||
"@swc/html-linux-arm64-musl@1.16.2":
|
||||
version "1.16.2"
|
||||
resolved "https://registry.yarnpkg.com/@swc/html-linux-arm64-musl/-/html-linux-arm64-musl-1.16.2.tgz#0334e071cb8a01e0423fe7da27204b71f1afed66"
|
||||
integrity sha512-dvki9/sgacHk9ouORmnIok5FbpeE9zUE8yqGGhL1kitNJi6/TKzfnMOpRxSxeDk1/ccvJTAdjRGDIGkT45+b3Q==
|
||||
|
||||
"@swc/html-linux-ppc64-gnu@1.15.43":
|
||||
version "1.15.43"
|
||||
resolved "https://registry.yarnpkg.com/@swc/html-linux-ppc64-gnu/-/html-linux-ppc64-gnu-1.15.43.tgz#51319cc1a4184b788613e0e556fc33ff77af0c52"
|
||||
integrity sha512-4oue1pB38/W6mbudp+w0q1jbwxuwdbdbaOj85ay0pisCs213WkgP+MPN8Zqa5VVPjQnVk2CTY9kmEc74XQI/sA==
|
||||
"@swc/html-linux-ppc64-gnu@1.16.2":
|
||||
version "1.16.2"
|
||||
resolved "https://registry.yarnpkg.com/@swc/html-linux-ppc64-gnu/-/html-linux-ppc64-gnu-1.16.2.tgz#6c0d7293a1f7f7631e975c767755eb5236769193"
|
||||
integrity sha512-6m0vVWHl9MW7cmWKVgKlFW6yhRv0uahMEaDxNIvXrPC3LdbbiiYZui+ryhyQGIYeVps3OMujzUjc0GihNz/afQ==
|
||||
|
||||
"@swc/html-linux-s390x-gnu@1.15.43":
|
||||
version "1.15.43"
|
||||
resolved "https://registry.yarnpkg.com/@swc/html-linux-s390x-gnu/-/html-linux-s390x-gnu-1.15.43.tgz#a612115a5f2f6c9df438a52ab2cf8bc4fd3865b1"
|
||||
integrity sha512-/tceMNvAxK70SKUZtcn3X+K0vcElMGk3i8Sz0CmPdtooso8MZ7WfAvVP1qi3TWgh1rpQ3cC+Al3433AHlET6+w==
|
||||
"@swc/html-linux-s390x-gnu@1.16.2":
|
||||
version "1.16.2"
|
||||
resolved "https://registry.yarnpkg.com/@swc/html-linux-s390x-gnu/-/html-linux-s390x-gnu-1.16.2.tgz#853018d7b57377e2f3b23d369a1571862c34d2b9"
|
||||
integrity sha512-TOlz6wgKyZjg4THJsNZfDz/rAMO+rBa0s2eewTeHEfuJhI+jGu7H6Co6bdbMpN3oyDvTMG7N1f1ktSbkE0erAg==
|
||||
|
||||
"@swc/html-linux-x64-gnu@1.15.43":
|
||||
version "1.15.43"
|
||||
resolved "https://registry.yarnpkg.com/@swc/html-linux-x64-gnu/-/html-linux-x64-gnu-1.15.43.tgz#028980da812e1797316f0a06a758a49aca699318"
|
||||
integrity sha512-YE7ltlTt5ZFl59GsoHTDrIHnCBY8EDBio66CVj4bqkElFXbE/28xmpVE5ksdGoI5c5aQ/8byUCfHxqzCzQQSVg==
|
||||
"@swc/html-linux-x64-gnu@1.16.2":
|
||||
version "1.16.2"
|
||||
resolved "https://registry.yarnpkg.com/@swc/html-linux-x64-gnu/-/html-linux-x64-gnu-1.16.2.tgz#a347921e256f6ecb8847956bbacd4a23e1476adb"
|
||||
integrity sha512-5EduoVpsnuAAkG9BW8COxcIKAe5swgNAEo+BVkAJCOy1ZMZm0krQYBdvlaDCsGGE9yLDKVPm7rpYIi7vTTZTbA==
|
||||
|
||||
"@swc/html-linux-x64-musl@1.15.43":
|
||||
version "1.15.43"
|
||||
resolved "https://registry.yarnpkg.com/@swc/html-linux-x64-musl/-/html-linux-x64-musl-1.15.43.tgz#363ac7ce3866b664db0c35d78a6a90636f280139"
|
||||
integrity sha512-nS20HmbOk+dEEzdosJqqxAeyjMIiS5yrCAti8LUf0+dgr4eRmjkH4MlkjfPjf49aayR8o+eMJ1jsDZ7whx4zog==
|
||||
"@swc/html-linux-x64-musl@1.16.2":
|
||||
version "1.16.2"
|
||||
resolved "https://registry.yarnpkg.com/@swc/html-linux-x64-musl/-/html-linux-x64-musl-1.16.2.tgz#ebfbbdb05f2f991a34bc500dce0ca10bfbcdfc3b"
|
||||
integrity sha512-c0Z84dvBd0oh1ZcBHnM18itmvJFLbCZBKFF2lEDHsGBSLQ/1sPbggEKsVO4KgWkkhwQV2l9AB4jnsw1HrwZJCg==
|
||||
|
||||
"@swc/html-win32-arm64-msvc@1.15.43":
|
||||
version "1.15.43"
|
||||
resolved "https://registry.yarnpkg.com/@swc/html-win32-arm64-msvc/-/html-win32-arm64-msvc-1.15.43.tgz#d0aa3f99c091577aaaa1aaf51a3695c98278564d"
|
||||
integrity sha512-Yz7aQQhXT/Yc6QcuMDQDZP9jqf2phkVyU+qSu8ZRWEcJgIorrPL6q7YLqMk+MB5PpZyu5XJEODvc1/UVDE1Kyg==
|
||||
"@swc/html-win32-arm64-msvc@1.16.2":
|
||||
version "1.16.2"
|
||||
resolved "https://registry.yarnpkg.com/@swc/html-win32-arm64-msvc/-/html-win32-arm64-msvc-1.16.2.tgz#69207d99cd80e55fe1ea722c8c8d5174b3d3cd2a"
|
||||
integrity sha512-Aq7V2B5gS23X59DzV2z892c4NBHYtJbwhvsCjJN1MBMx723htjgNE9KVIJp9dQaJBr2PrNfb/u3QFwnWV2tAoQ==
|
||||
|
||||
"@swc/html-win32-ia32-msvc@1.15.43":
|
||||
version "1.15.43"
|
||||
resolved "https://registry.yarnpkg.com/@swc/html-win32-ia32-msvc/-/html-win32-ia32-msvc-1.15.43.tgz#e2cfe4dd26ce8b8c5787ca1fdd69ef6b5dc94822"
|
||||
integrity sha512-muUgfsSQRZk6YBRuhaGKSLvXy0bV9BW6/mHLI0N/06btWuf0hekoHhIzR7dUmS98NXKCA7Hv+buBPE/0vXUwyA==
|
||||
"@swc/html-win32-ia32-msvc@1.16.2":
|
||||
version "1.16.2"
|
||||
resolved "https://registry.yarnpkg.com/@swc/html-win32-ia32-msvc/-/html-win32-ia32-msvc-1.16.2.tgz#9473a17f22c65ec20059533bd8cc78b32c82a711"
|
||||
integrity sha512-9gslPcsfXxKvAZtOvDkxGuEbM7lqBrONzLAyRsyUtw8KxFcSYkGIO48RDTstGWOkgTgKjjAq/WWqt9qr/NcE3A==
|
||||
|
||||
"@swc/html-win32-x64-msvc@1.15.43":
|
||||
version "1.15.43"
|
||||
resolved "https://registry.yarnpkg.com/@swc/html-win32-x64-msvc/-/html-win32-x64-msvc-1.15.43.tgz#ee1a8a7fe4d928595268c214cf17c72867ee8f0f"
|
||||
integrity sha512-tuLDy4MxPXsLi6jW+ozCdFWO61AoMMnlhePWJxMafefC2Ojm+iILxP2zI2Hgfu6F16y1q7ITdXdpEuqptu5fHw==
|
||||
"@swc/html-win32-x64-msvc@1.16.2":
|
||||
version "1.16.2"
|
||||
resolved "https://registry.yarnpkg.com/@swc/html-win32-x64-msvc/-/html-win32-x64-msvc-1.16.2.tgz#7c7aeaa21b8848a765be2d4f7ee2ee3fecfd0f52"
|
||||
integrity sha512-Kdb4VdC8FyF5s1MQaFUNeASLckHECrb/oYy/6OCtU+hbgxQ/o/JCgE4uCe8YAg0LCWSOjhx73PCZDGwPf1TpKw==
|
||||
|
||||
"@swc/html@^1.15.40":
|
||||
version "1.15.43"
|
||||
resolved "https://registry.yarnpkg.com/@swc/html/-/html-1.15.43.tgz#421da1ffc3226d149fd57c73f73755c6e6148427"
|
||||
integrity sha512-SKbkbdGi9SDO9cTdV+6H0/AYifnb2nDOlz5BlWxlWMXACV3kmX6WwZDo0bBdyGlO/G4jCVWdR5r84qfotU2now==
|
||||
version "1.16.2"
|
||||
resolved "https://registry.yarnpkg.com/@swc/html/-/html-1.16.2.tgz#91ee34374e4c926c6c8a8f43561688192b8a1725"
|
||||
integrity sha512-RmWH8m5dePWDFpHpmFKquZCRe5SyD/Sb0FBPxWcWv/tsjtlJl6oHeaxBsTL2edvaHuW385Fy5nPuTjDD/a+GEA==
|
||||
dependencies:
|
||||
"@swc/counter" "^0.1.3"
|
||||
optionalDependencies:
|
||||
"@swc/html-darwin-arm64" "1.15.43"
|
||||
"@swc/html-darwin-x64" "1.15.43"
|
||||
"@swc/html-linux-arm-gnueabihf" "1.15.43"
|
||||
"@swc/html-linux-arm64-gnu" "1.15.43"
|
||||
"@swc/html-linux-arm64-musl" "1.15.43"
|
||||
"@swc/html-linux-ppc64-gnu" "1.15.43"
|
||||
"@swc/html-linux-s390x-gnu" "1.15.43"
|
||||
"@swc/html-linux-x64-gnu" "1.15.43"
|
||||
"@swc/html-linux-x64-musl" "1.15.43"
|
||||
"@swc/html-win32-arm64-msvc" "1.15.43"
|
||||
"@swc/html-win32-ia32-msvc" "1.15.43"
|
||||
"@swc/html-win32-x64-msvc" "1.15.43"
|
||||
"@swc/html-darwin-arm64" "1.16.2"
|
||||
"@swc/html-darwin-x64" "1.16.2"
|
||||
"@swc/html-linux-arm-gnueabihf" "1.16.2"
|
||||
"@swc/html-linux-arm64-gnu" "1.16.2"
|
||||
"@swc/html-linux-arm64-musl" "1.16.2"
|
||||
"@swc/html-linux-ppc64-gnu" "1.16.2"
|
||||
"@swc/html-linux-s390x-gnu" "1.16.2"
|
||||
"@swc/html-linux-x64-gnu" "1.16.2"
|
||||
"@swc/html-linux-x64-musl" "1.16.2"
|
||||
"@swc/html-win32-arm64-msvc" "1.16.2"
|
||||
"@swc/html-win32-ia32-msvc" "1.16.2"
|
||||
"@swc/html-win32-x64-msvc" "1.16.2"
|
||||
|
||||
"@swc/types@^0.1.28":
|
||||
version "0.1.28"
|
||||
@@ -6924,9 +6924,9 @@ color-name@~1.1.4:
|
||||
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
|
||||
|
||||
colord@^2.9.3:
|
||||
version "2.9.3"
|
||||
resolved "https://registry.yarnpkg.com/colord/-/colord-2.9.3.tgz#4f8ce919de456f1d5c1c368c307fe20f3e59fb43"
|
||||
integrity sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==
|
||||
version "2.10.0"
|
||||
resolved "https://registry.yarnpkg.com/colord/-/colord-2.10.0.tgz#56c9050e6b06b4b6c62ddec366a48d65ef57e860"
|
||||
integrity sha512-AidJptpBJmjTclAp9BkLwJi0T93fo5epJnbaZslpg6QVzpHjAiveF55mE9AcUJiGMqRHgMDY8soMsQtuNYMHfw==
|
||||
|
||||
colorette@^1.2.0:
|
||||
version "1.4.0"
|
||||
@@ -8284,36 +8284,11 @@ escape-string-regexp@^5.0.0:
|
||||
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz#4683126b500b61762f2dbebace1806e8be31b1c8"
|
||||
integrity sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==
|
||||
|
||||
eslint-scope@5.1.1:
|
||||
version "5.1.1"
|
||||
resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c"
|
||||
integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==
|
||||
dependencies:
|
||||
esrecurse "^4.3.0"
|
||||
estraverse "^4.1.1"
|
||||
|
||||
esprima@~4.0.0:
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71"
|
||||
integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==
|
||||
|
||||
esrecurse@^4.3.0:
|
||||
version "4.3.0"
|
||||
resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921"
|
||||
integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==
|
||||
dependencies:
|
||||
estraverse "^5.2.0"
|
||||
|
||||
estraverse@^4.1.1:
|
||||
version "4.3.0"
|
||||
resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d"
|
||||
integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==
|
||||
|
||||
estraverse@^5.2.0:
|
||||
version "5.3.0"
|
||||
resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123"
|
||||
integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==
|
||||
|
||||
estree-util-attach-comments@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/estree-util-attach-comments/-/estree-util-attach-comments-3.0.0.tgz#344bde6a64c8a31d15231e5ee9e297566a691c2d"
|
||||
@@ -9671,9 +9646,9 @@ jiti@^1.20.0:
|
||||
integrity sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==
|
||||
|
||||
joi@^17.9.2:
|
||||
version "17.13.4"
|
||||
resolved "https://registry.yarnpkg.com/joi/-/joi-17.13.4.tgz#ad6153d97ce558eb3a3b593e0d43eab51df1c474"
|
||||
integrity sha512-1RuuER6kmt8K8I3nIWvPZKi5RQCb568ZPyY4Pwjlua+yo+63ZTmIwxLZH0heBmiKN4uxjvCiarDrjaeH84xicQ==
|
||||
version "17.13.7"
|
||||
resolved "https://registry.yarnpkg.com/joi/-/joi-17.13.7.tgz#92e212c50dbbbcb1a1592424f84083eb265cc778"
|
||||
integrity sha512-MF80Dm5Y2veNy8QWVx9Bj3ui4mo7+VPSPsR1M+oaHXV0Gx6zGX9a2F+OZG3Blby9tOlzU9Rs5FUimlEhbKtfnQ==
|
||||
dependencies:
|
||||
"@hapi/hoek" "^9.3.0"
|
||||
"@hapi/topo" "^5.1.0"
|
||||
@@ -11137,16 +11112,6 @@ minimist@^1.2.0:
|
||||
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c"
|
||||
integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
|
||||
|
||||
minimizer-webpack-plugin@^5.6.1:
|
||||
version "5.6.1"
|
||||
resolved "https://registry.yarnpkg.com/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.6.1.tgz#289922a4c96c4ed1ddb76b8a00bd8074e89a2f7f"
|
||||
integrity sha512-DoeAZz8Q1C1znwsUzej1fdoi4jCf7/+Em27ouLqfK/+3m8G+D7yDhUwrc3CNhjSzGUN1kn7Iv4sWmjflQHenpw==
|
||||
dependencies:
|
||||
"@jridgewell/trace-mapping" "^0.3.25"
|
||||
jest-worker "^27.4.5"
|
||||
schema-utils "^4.3.0"
|
||||
terser "^5.31.1"
|
||||
|
||||
minimizer-webpack-plugin@^5.7.0:
|
||||
version "5.8.0"
|
||||
resolved "https://registry.yarnpkg.com/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.8.0.tgz#744f0e28da888aa1708e2be32b4ddcf2eeb09e58"
|
||||
@@ -14219,9 +14184,9 @@ svg-parser@^2.0.4:
|
||||
integrity sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==
|
||||
|
||||
svgo@^3.0.2, svgo@^3.2.0:
|
||||
version "3.3.4"
|
||||
resolved "https://registry.yarnpkg.com/svgo/-/svgo-3.3.4.tgz#fd2aa10ff585b3bd2b83ce3602f5582bc0718bb5"
|
||||
integrity sha512-GsNRis4e8jxn2Y9ENz/8lbJ93CstG8svtMnuRaHbiF2LTJ5tK0/q3t/URPq9Zc7zVWBJnNnJMIp6bevK7bSmNg==
|
||||
version "3.3.5"
|
||||
resolved "https://registry.yarnpkg.com/svgo/-/svgo-3.3.5.tgz#8a3d9557ab2f386eca7e24760385849554985a1c"
|
||||
integrity sha512-8SQMzdrvWaD8deUmrnYB+ASyxBVgWUOilg+A75nE/76WdLpj6LopCwiAVvkzkcqy/9b7t2Mg7faFLjg0ZRcZ3w==
|
||||
dependencies:
|
||||
commander "^7.2.0"
|
||||
css-select "^5.1.0"
|
||||
@@ -15163,10 +15128,10 @@ webpack-virtual-modules@^0.6.2:
|
||||
resolved "https://registry.yarnpkg.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz#057faa9065c8acf48f24cb57ac0e77739ab9a7e8"
|
||||
integrity sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==
|
||||
|
||||
webpack@^5.110.1:
|
||||
version "5.110.1"
|
||||
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.110.1.tgz#d662d8ff1866fcb58a6b8af09c86bc6f1b9c5c1d"
|
||||
integrity sha512-gInQB+jxXxgnZyvPwuzT5NGQmECDqeu85oxcrjinrYHqPoBex0hCAN2SFTJVyPVrK0Pq9E44VFP+e89fAc10/w==
|
||||
webpack@^5.110.2, webpack@^5.88.1, webpack@^5.95.0:
|
||||
version "5.110.2"
|
||||
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.110.2.tgz#ef23a0e62fe5e1ba71b033e3505b7f005d8d5c6e"
|
||||
integrity sha512-TciLrfM7zgEjqGdY851HkirDsSPQgTFsWQpl9oHqMAMYsHhEC0bKjscvjpnz+pzx10hLC8qISApGrsnrCP4UtQ==
|
||||
dependencies:
|
||||
"@types/estree" "^1.0.8"
|
||||
"@types/json-schema" "^7.0.15"
|
||||
@@ -15188,32 +15153,6 @@ webpack@^5.110.1:
|
||||
watchpack "^2.5.2"
|
||||
webpack-sources "^3.5.1"
|
||||
|
||||
webpack@^5.88.1, webpack@^5.95.0:
|
||||
version "5.109.2"
|
||||
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.109.2.tgz#b58dc289561c3282db35c210a99379a836a4c28d"
|
||||
integrity sha512-U9/cvLzxObKNEZ9+TtdqrHM5/9z3lgl2c+c4BzbqGxFQvQvBAq87yql5A8pQ+rrMbS496MZJeF5enVBndIy2hw==
|
||||
dependencies:
|
||||
"@types/estree" "^1.0.8"
|
||||
"@types/json-schema" "^7.0.15"
|
||||
"@webassemblyjs/ast" "^1.14.1"
|
||||
"@webassemblyjs/wasm-edit" "^1.14.1"
|
||||
"@webassemblyjs/wasm-parser" "^1.14.1"
|
||||
acorn "^8.16.0"
|
||||
browserslist "^4.28.1"
|
||||
chrome-trace-event "^1.0.2"
|
||||
enhanced-resolve "^5.24.4"
|
||||
es-module-lexer "^2.1.0"
|
||||
eslint-scope "5.1.1"
|
||||
events "^3.2.0"
|
||||
graceful-fs "^4.2.11"
|
||||
mime-db "^1.54.0"
|
||||
minimizer-webpack-plugin "^5.6.1"
|
||||
neo-async "^2.6.2"
|
||||
schema-utils "^4.3.3"
|
||||
tapable "^2.3.0"
|
||||
watchpack "^2.5.2"
|
||||
webpack-sources "^3.5.1"
|
||||
|
||||
webpackbar@^7.0.0:
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/webpackbar/-/webpackbar-7.0.0.tgz#7228d32881af2392381b6514499ddea73cdf218a"
|
||||
|
||||
+6
-6
@@ -50,11 +50,11 @@ dependencies = [
|
||||
"flask-cors>=6.0.5, <7.0",
|
||||
"croniter>=6.2.4",
|
||||
"cron-descriptor",
|
||||
"cryptography>=50.0.0, <51.0.0",
|
||||
"cryptography>=50.0.1, <51.0.0",
|
||||
"deprecation>=2.1.0, <2.2.0",
|
||||
"flask>=2.2.5, <4.0.0",
|
||||
"flask-appbuilder>=5.2.2, <6.0.0",
|
||||
"flask-caching>=2.4.1, <3",
|
||||
"flask-caching>=2.5.0, <3",
|
||||
"flask-compress>=1.13, <2.0",
|
||||
"flask-talisman>=1.0.0, <2.0",
|
||||
"flask-login>=0.6.0, < 1.0",
|
||||
@@ -96,7 +96,7 @@ dependencies = [
|
||||
"pgsanity",
|
||||
"Pillow>=12.3.0, <13", # raise floor to match resolved pin; closes SCA false-positive on 11.x-range CVEs already fixed in 12.3.0
|
||||
"polyline>=2.0.4, <3.0",
|
||||
"pydantic>=2.8.0",
|
||||
"pydantic>=2.13.5",
|
||||
"pyparsing>=3.3.2, <4",
|
||||
"python-dateutil",
|
||||
"python-dotenv", # optional dependencies for Flask but required for Superset, see https://flask.palletsprojects.com/en/stable/installation/#optional-dependencies
|
||||
@@ -142,7 +142,7 @@ bigquery = [
|
||||
# 1.17.1 is likely the final release: googleapis/python-bigquery-sqlalchemy
|
||||
# was archived 2026-05-16. Both 1.17.0 and 1.17.1 support SQLAlchemy 1.4/2.0.
|
||||
"sqlalchemy-bigquery>=1.17.2",
|
||||
"google-cloud-bigquery>=3.42.3",
|
||||
"google-cloud-bigquery>=3.44.0",
|
||||
]
|
||||
clickhouse = ["clickhouse-connect>=1.7.2, <2.0"]
|
||||
# The `cockroachdb` PyPI package (last released 2021) is abandoned and its
|
||||
@@ -273,7 +273,7 @@ tdengine = [
|
||||
"taospy>=2.8.10",
|
||||
"taos-ws-py>=0.7.0"
|
||||
]
|
||||
teradata = ["teradatasql>=20.0.0.66"]
|
||||
teradata = ["teradatasql>=20.0.0.67"]
|
||||
thumbnails = [] # deprecated, will be removed in 7.0
|
||||
vertica = ["sqlalchemy-vertica-python>= 0.6.3, < 0.7"]
|
||||
netezza = ["nzalchemy>= 11.1.2, < 11.2"]
|
||||
@@ -288,7 +288,7 @@ development = [
|
||||
"docker",
|
||||
"flask-testing",
|
||||
"freezegun",
|
||||
"grpcio>=1.82.1",
|
||||
"grpcio>=1.83.1",
|
||||
"openapi-spec-validator",
|
||||
"parameterized",
|
||||
"pip",
|
||||
|
||||
@@ -26,7 +26,7 @@ filelock>=3.20.3,<4.0.0
|
||||
brotli>=1.2.0,<2.0.0
|
||||
numexpr>=2.9.0
|
||||
# Security: CVE-2026-34073 (MEDIUM) - Improper Certificate Validation
|
||||
cryptography>=50.0.0,<51.0.0
|
||||
cryptography>=50.0.1,<51.0.0
|
||||
# Security: Snyk - XSS vulnerability in Mako templates
|
||||
mako>=1.4.1,<2.0.0
|
||||
# Security: CVE-2024-52338 (CRITICAL) - Deserialization of untrusted data in IPC/Parquet readers
|
||||
|
||||
@@ -40,7 +40,7 @@ brotli==1.2.0
|
||||
# via
|
||||
# -r requirements/base.in
|
||||
# flask-compress
|
||||
cachelib==0.13.0
|
||||
cachelib==0.17.0
|
||||
# via
|
||||
# flask-caching
|
||||
# flask-session
|
||||
@@ -84,7 +84,7 @@ cron-descriptor==1.4.5
|
||||
# via apache-superset (pyproject.toml)
|
||||
croniter==6.2.4
|
||||
# via apache-superset (pyproject.toml)
|
||||
cryptography==50.0.0
|
||||
cryptography==50.0.1
|
||||
# via
|
||||
# -r requirements/base.in
|
||||
# apache-superset (pyproject.toml)
|
||||
@@ -105,7 +105,7 @@ et-xmlfile==2.0.0
|
||||
# via openpyxl
|
||||
filelock==3.20.3
|
||||
# via -r requirements/base.in
|
||||
flask==2.3.3
|
||||
flask==3.1.3
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# flask-appbuilder
|
||||
@@ -124,9 +124,9 @@ flask-appbuilder==5.2.2
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# apache-superset-core
|
||||
flask-babel==3.1.0
|
||||
flask-babel==4.0.0
|
||||
# via flask-appbuilder
|
||||
flask-caching==2.4.1
|
||||
flask-caching==2.5.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
flask-compress==1.24
|
||||
# via apache-superset (pyproject.toml)
|
||||
@@ -218,6 +218,7 @@ markdown-it-py==3.0.0
|
||||
# via rich
|
||||
markupsafe==3.0.2
|
||||
# via
|
||||
# flask
|
||||
# jinja2
|
||||
# mako
|
||||
# werkzeug
|
||||
@@ -297,11 +298,11 @@ pyasn1-modules==0.4.2
|
||||
# via google-auth
|
||||
pycparser==2.22
|
||||
# via cffi
|
||||
pydantic==2.13.4
|
||||
pydantic==2.13.5
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# apache-superset-core
|
||||
pydantic-core==2.46.4
|
||||
pydantic-core==2.46.5
|
||||
# via pydantic
|
||||
pygeohash==3.2.2
|
||||
# via apache-superset (pyproject.toml)
|
||||
|
||||
@@ -94,7 +94,7 @@ brotli==1.2.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# flask-compress
|
||||
cachelib==0.13.0
|
||||
cachelib==0.17.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# flask-caching
|
||||
@@ -179,7 +179,7 @@ croniter==6.2.4
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
cryptography==50.0.0
|
||||
cryptography==50.0.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -247,7 +247,7 @@ filelock==3.20.3
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# virtualenv
|
||||
flask==2.3.3
|
||||
flask==3.1.3
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -269,11 +269,11 @@ flask-appbuilder==5.2.2
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
# apache-superset-core
|
||||
flask-babel==3.1.0
|
||||
flask-babel==4.0.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# flask-appbuilder
|
||||
flask-caching==2.4.1
|
||||
flask-caching==2.5.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -360,7 +360,7 @@ google-auth-oauthlib==1.2.1
|
||||
# via
|
||||
# pandas-gbq
|
||||
# pydata-google-auth
|
||||
google-cloud-bigquery==3.43.0
|
||||
google-cloud-bigquery==3.45.0
|
||||
# via
|
||||
# apache-superset
|
||||
# pandas-gbq
|
||||
@@ -384,7 +384,7 @@ greenlet==3.5.5
|
||||
# sqlalchemy
|
||||
griffelib==2.0.2
|
||||
# via fastmcp-slim
|
||||
grpcio==1.83.0
|
||||
grpcio==1.83.1
|
||||
# via
|
||||
# apache-superset
|
||||
# google-api-core
|
||||
@@ -524,6 +524,7 @@ markdown-it-py==3.0.0
|
||||
markupsafe==3.0.2
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# flask
|
||||
# jinja2
|
||||
# mako
|
||||
# werkzeug
|
||||
@@ -731,7 +732,7 @@ pycparser==2.22
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# cffi
|
||||
pydantic==2.13.4
|
||||
pydantic==2.13.5
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -740,7 +741,7 @@ pydantic==2.13.4
|
||||
# mcp
|
||||
# openapi-pydantic
|
||||
# pydantic-settings
|
||||
pydantic-core==2.46.4
|
||||
pydantic-core==2.46.5
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# pydantic
|
||||
|
||||
Generated
+4
-4
@@ -23,7 +23,7 @@
|
||||
"tscw-config": "^1.1.2",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^4.0.18",
|
||||
"webpack": "^5.110.0",
|
||||
"webpack": "^5.110.2",
|
||||
"webpack-cli": "^7.2.3"
|
||||
}
|
||||
},
|
||||
@@ -5260,9 +5260,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/webpack": {
|
||||
"version": "5.110.1",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.110.1.tgz",
|
||||
"integrity": "sha512-gInQB+jxXxgnZyvPwuzT5NGQmECDqeu85oxcrjinrYHqPoBex0hCAN2SFTJVyPVrK0Pq9E44VFP+e89fAc10/w==",
|
||||
"version": "5.110.2",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.110.2.tgz",
|
||||
"integrity": "sha512-TciLrfM7zgEjqGdY851HkirDsSPQgTFsWQpl9oHqMAMYsHhEC0bKjscvjpnz+pzx10hLC8qISApGrsnrCP4UtQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
"tscw-config": "^1.1.2",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^4.0.18",
|
||||
"webpack": "^5.110.0",
|
||||
"webpack": "^5.110.2",
|
||||
"webpack-cli": "^7.2.3"
|
||||
},
|
||||
"repository": {
|
||||
|
||||
Generated
+169
-194
@@ -215,7 +215,7 @@
|
||||
"@types/rison": "0.1.0",
|
||||
"@types/tinycolor2": "^1.4.3",
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"@typescript-eslint/eslint-plugin": "^8.68.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.69.0",
|
||||
"@typescript-eslint/parser": "^8.63.0",
|
||||
"babel-jest": "^30.5.0",
|
||||
"babel-loader": "^10.1.1",
|
||||
@@ -272,11 +272,11 @@
|
||||
"swc-loader": "^0.2.7",
|
||||
"ts-jest": "^29.4.12",
|
||||
"tscw-config": "^1.1.2",
|
||||
"tsx": "^4.23.12",
|
||||
"tsx": "^4.23.13",
|
||||
"typescript": "5.4.5",
|
||||
"unzipper": "^0.12.5",
|
||||
"wait-on": "^9.1.0",
|
||||
"webpack": "^5.110.1",
|
||||
"webpack": "^5.110.2",
|
||||
"webpack-bundle-analyzer": "^5.3.2",
|
||||
"webpack-cli": "^7.2.3",
|
||||
"webpack-dev-server": "^6.0.0",
|
||||
@@ -420,9 +420,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@ant-design/icons-svg": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.5.0.tgz",
|
||||
"integrity": "sha512-1BTUFyKPTBZ53MuTP8s0k5SFEXL7o3VHEOwLgzaoWKwnBeqIcqUtVshc4SKzhI6uACfqhJqBwBUE9FsWR3uULA==",
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.6.0.tgz",
|
||||
"integrity": "sha512-PRomU725ABMf/lnQp5HiB7my1kjEbFY0D10N4lXYxK6TIB1gKjIVD5MRThpDaezLgw1D774J8eOeeDB0M6wHrQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@ant-design/react-slick": {
|
||||
@@ -8007,11 +8007,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/jsonlint-lines-primitives": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz",
|
||||
"integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==",
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz",
|
||||
"integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
"node": ">= 22"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/martini": {
|
||||
@@ -8027,9 +8028,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@mapbox/tiny-sdf": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.1.0.tgz",
|
||||
"integrity": "sha512-uFJhNh36BR4OCuWIEiWaEix9CA2WzT6CAIcqVjWYpnx8+QDtS+oC4QehRrx5cX4mgWs37MmKnwUejeHxVymzNg==",
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz",
|
||||
"integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@mapbox/unitbezier": {
|
||||
@@ -8047,29 +8048,23 @@
|
||||
"@mapbox/point-geometry": "~0.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/whoots-js": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
|
||||
"integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/geojson-vt": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz",
|
||||
"integrity": "sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@maplibre/maplibre-gl-style-spec": {
|
||||
"version": "24.8.5",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.8.5.tgz",
|
||||
"integrity": "sha512-EzEJmMt6thioRH7GI9LWS7ahXTcAhAPGWCe6oTP2Ps4YnsXOOAfeqx854lZaiDnwURfHmcCKV1mr6oo0i23x6w==",
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz",
|
||||
"integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
|
||||
"@mapbox/unitbezier": "^0.0.1",
|
||||
"kdbush": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/maplibre-gl-style-spec": {
|
||||
"version": "26.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.4.2.tgz",
|
||||
"integrity": "sha512-6J0vZqMZvRAJKtdWJdDGHEh1YJ2ZHG08/GOur8gCArYhO8ZkM//OXqtI2AAEz4jc2G+Wq4MfcePg8qNK5TZ4Kg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@mapbox/jsonlint-lines-primitives": "^2.0.3",
|
||||
"@mapbox/unitbezier": "^1.0.0",
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"minimist": "^1.2.8",
|
||||
"quickselect": "^3.0.0",
|
||||
@@ -8081,10 +8076,16 @@
|
||||
"gl-style-validate": "dist/gl-style-validate.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/maplibre-gl-style-spec/node_modules/@mapbox/unitbezier": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
|
||||
"integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@maplibre/mlt": {
|
||||
"version": "1.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.8.tgz",
|
||||
"integrity": "sha512-8vtfYGidr1rNkv5IwIoU2lfe3Oy+Wa8HluzQYcQi9cveU9K3pweAal/poQj4GJ0K/EW4bTQp2wVAs09g2yDRZg==",
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.2.1.tgz",
|
||||
"integrity": "sha512-5n5dgolE2EYxwCKgx8vlwURCB8A+kyfJJyThlYimjlGgOcOe2Bhw9VxxnnHC91OC9PgHg+nVdgVPTYPkIo62vg==",
|
||||
"license": "(MIT OR Apache-2.0)",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0"
|
||||
@@ -8097,18 +8098,14 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@maplibre/vt-pbf": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.0.tgz",
|
||||
"integrity": "sha512-jIvp8F5hQCcreqOOpEt42TJMUlsrEcpf/kI1T2v85YrQRV6PPXUcEXUg5karKtH6oh47XJZ4kHu56pUkOuqA7w==",
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz",
|
||||
"integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@mapbox/vector-tile": "^2.0.4",
|
||||
"@maplibre/geojson-vt": "^5.0.4",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"@types/supercluster": "^7.1.3",
|
||||
"pbf": "^4.0.1",
|
||||
"supercluster": "^8.0.1"
|
||||
"pbf": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/vt-pbf/node_modules/@mapbox/point-geometry": {
|
||||
@@ -8117,21 +8114,10 @@
|
||||
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@maplibre/vt-pbf/node_modules/@mapbox/vector-tile": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.5.tgz",
|
||||
"integrity": "sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "~1.1.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"pbf": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/vt-pbf/node_modules/pbf": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.2.tgz",
|
||||
"integrity": "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg==",
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz",
|
||||
"integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"resolve-protobuf-schema": "^2.1.0"
|
||||
@@ -14995,17 +14981,17 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.68.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.68.0.tgz",
|
||||
"integrity": "sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q==",
|
||||
"version": "8.69.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.69.0.tgz",
|
||||
"integrity": "sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.68.0",
|
||||
"@typescript-eslint/type-utils": "8.68.0",
|
||||
"@typescript-eslint/utils": "8.68.0",
|
||||
"@typescript-eslint/visitor-keys": "8.68.0",
|
||||
"@typescript-eslint/scope-manager": "8.69.0",
|
||||
"@typescript-eslint/type-utils": "8.69.0",
|
||||
"@typescript-eslint/utils": "8.69.0",
|
||||
"@typescript-eslint/visitor-keys": "8.69.0",
|
||||
"ignore": "^7.0.5",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
@@ -15018,7 +15004,7 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.68.0",
|
||||
"@typescript-eslint/parser": "^8.69.0",
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
@@ -15034,16 +15020,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.68.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.68.0.tgz",
|
||||
"integrity": "sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==",
|
||||
"version": "8.69.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.69.0.tgz",
|
||||
"integrity": "sha512-l4b0DhWioGg6Gt2ebGlvfkFMOjRsauxtsnDRwUSRX1qHq3HdTfQHV8wW9zEXeciai6HfeaKOedQn2Zoofx3WBw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.68.0",
|
||||
"@typescript-eslint/types": "8.68.0",
|
||||
"@typescript-eslint/typescript-estree": "8.68.0",
|
||||
"@typescript-eslint/visitor-keys": "8.68.0",
|
||||
"@typescript-eslint/scope-manager": "8.69.0",
|
||||
"@typescript-eslint/types": "8.69.0",
|
||||
"@typescript-eslint/typescript-estree": "8.69.0",
|
||||
"@typescript-eslint/visitor-keys": "8.69.0",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -15059,14 +15045,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.68.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.68.0.tgz",
|
||||
"integrity": "sha512-5GQtWZCXFcFYux955pvoS02WLc49pXNlvIxocKjS0clvwo3in1RdlzVKyiqQH9vE5AKWFLTaUgeQkOrTS+0Qxw==",
|
||||
"version": "8.69.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.69.0.tgz",
|
||||
"integrity": "sha512-yi4obFrHMmnsesWehHbkg9zMA7Jt8cXT+mKM08G999pH1yT6nqgsHx7MYm0uY1wAj8CqiBXYRJ7WAT0QdQHQXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.68.0",
|
||||
"@typescript-eslint/types": "^8.68.0",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.69.0",
|
||||
"@typescript-eslint/types": "^8.69.0",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -15081,14 +15067,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.68.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.68.0.tgz",
|
||||
"integrity": "sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA==",
|
||||
"version": "8.69.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.69.0.tgz",
|
||||
"integrity": "sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.68.0",
|
||||
"@typescript-eslint/visitor-keys": "8.68.0"
|
||||
"@typescript-eslint/types": "8.69.0",
|
||||
"@typescript-eslint/visitor-keys": "8.69.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -15099,9 +15085,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.68.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.68.0.tgz",
|
||||
"integrity": "sha512-F7zrGQfiJHojPwi8vhxZQC1tWtJzvL74cK/nqri2lk8YUXvYaYwl263xOJ69jDWPUk1hmcdoayFwk9lX09npVw==",
|
||||
"version": "8.69.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.69.0.tgz",
|
||||
"integrity": "sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -15116,15 +15102,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.68.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.68.0.tgz",
|
||||
"integrity": "sha512-X77zqoY1EjeWGs/0JNxeaMfp5C5lIz4Tw8y66F1Ne8Faq6g424sBNYM6xBAqElfGZPLpWS+CZAp0DXyKDzWiHg==",
|
||||
"version": "8.69.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.69.0.tgz",
|
||||
"integrity": "sha512-ZfoJAVg3JZndQEpEl9petVlxau3lRuElc4HRMuAlLCf8to04/iHz692RUSNmXKDjEuJmIL+KZ2/BsOcBc16dsA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.68.0",
|
||||
"@typescript-eslint/typescript-estree": "8.68.0",
|
||||
"@typescript-eslint/utils": "8.68.0",
|
||||
"@typescript-eslint/types": "8.69.0",
|
||||
"@typescript-eslint/typescript-estree": "8.69.0",
|
||||
"@typescript-eslint/utils": "8.69.0",
|
||||
"debug": "^4.4.3",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
},
|
||||
@@ -15141,9 +15127,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.68.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.68.0.tgz",
|
||||
"integrity": "sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg==",
|
||||
"version": "8.69.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.69.0.tgz",
|
||||
"integrity": "sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -15155,16 +15141,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.68.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.68.0.tgz",
|
||||
"integrity": "sha512-OKKsD0tYmoNiU5PW2zehO1yO56jYOm1ShYlxon/Z0SJNidAkdVg86eg9ruRuoXf8xfnuWZGbwDsStkoXbZtIIA==",
|
||||
"version": "8.69.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.69.0.tgz",
|
||||
"integrity": "sha512-AdFkgqck3Vudb/kWnxlyafU/4aBhHrbQ9locP2N4psXTy5mOBg0SHJumnLvx7r6g1gV4DKvUFwV2nJZBoqOD8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.68.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.68.0",
|
||||
"@typescript-eslint/types": "8.68.0",
|
||||
"@typescript-eslint/visitor-keys": "8.68.0",
|
||||
"@typescript-eslint/project-service": "8.69.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.69.0",
|
||||
"@typescript-eslint/types": "8.69.0",
|
||||
"@typescript-eslint/visitor-keys": "8.69.0",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
@@ -15199,16 +15185,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.68.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.68.0.tgz",
|
||||
"integrity": "sha512-PB5gJMMOg0Q5P1tsgWtEAqQacJXq0qEqRHDX/YJ4FaTMLfZPpHB3gjl2EJuiZyPABxmj4ZQYiY9m1bdAJ5y7tQ==",
|
||||
"version": "8.69.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.69.0.tgz",
|
||||
"integrity": "sha512-tUbx60BBqQa31kXF5MCsOOLL5E/WzUuxIn7YpAvq+eaUlqvk8/NXnXMBNAdLCr0icjkzem7iUA5QqWHe/hJ1aw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/scope-manager": "8.68.0",
|
||||
"@typescript-eslint/types": "8.68.0",
|
||||
"@typescript-eslint/typescript-estree": "8.68.0"
|
||||
"@typescript-eslint/scope-manager": "8.69.0",
|
||||
"@typescript-eslint/types": "8.69.0",
|
||||
"@typescript-eslint/typescript-estree": "8.69.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -15223,13 +15209,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.68.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.68.0.tgz",
|
||||
"integrity": "sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A==",
|
||||
"version": "8.69.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.69.0.tgz",
|
||||
"integrity": "sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.68.0",
|
||||
"@typescript-eslint/types": "8.69.0",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -28781,9 +28767,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jest-process-manager/node_modules/joi": {
|
||||
"version": "17.13.4",
|
||||
"resolved": "https://registry.npmjs.org/joi/-/joi-17.13.4.tgz",
|
||||
"integrity": "sha512-1RuuER6kmt8K8I3nIWvPZKi5RQCb568ZPyY4Pwjlua+yo+63ZTmIwxLZH0heBmiKN4uxjvCiarDrjaeH84xicQ==",
|
||||
"version": "17.13.7",
|
||||
"resolved": "https://registry.npmjs.org/joi/-/joi-17.13.7.tgz",
|
||||
"integrity": "sha512-MF80Dm5Y2veNy8QWVx9Bj3ui4mo7+VPSPsR1M+oaHXV0Gx6zGX9a2F+OZG3Blby9tOlzU9Rs5FUimlEhbKtfnQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
@@ -30783,9 +30769,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/joi": {
|
||||
"version": "18.2.3",
|
||||
"resolved": "https://registry.npmjs.org/joi/-/joi-18.2.3.tgz",
|
||||
"integrity": "sha512-N5A3KTWQpPWT4ExxxPlUx7WmykGXRzhNidWhV41d6Abu9YfI2NyWCJuxdPnslJCPWtbRpSVOWSnSS6GakLM/Rg==",
|
||||
"version": "18.2.8",
|
||||
"resolved": "https://registry.npmjs.org/joi/-/joi-18.2.8.tgz",
|
||||
"integrity": "sha512-G2TX62h58ZHuwqetJgP2F4ualakqAmZtBYe3jWen7gxQRw5xApX6crnFtuB91WC0c3ESBnva+kGSnb3+6pIQDQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
@@ -32288,7 +32274,8 @@
|
||||
"version": "4.4.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
|
||||
"integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lodash.isempty": {
|
||||
"version": "4.4.0",
|
||||
@@ -32300,7 +32287,8 @@
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
|
||||
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lodash.isplainobject": {
|
||||
"version": "4.0.6",
|
||||
@@ -32575,27 +32563,25 @@
|
||||
]
|
||||
},
|
||||
"node_modules/maplibre-gl": {
|
||||
"version": "5.24.0",
|
||||
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.24.0.tgz",
|
||||
"integrity": "sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==",
|
||||
"version": "6.8.0",
|
||||
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.8.0.tgz",
|
||||
"integrity": "sha512-+ZkjKTodsVLY0ewQThvXRxXQsclcsSgOm5LlnrBM3G8AloMJKt6Haw8mY4xrOl8T0WMqH6DTYjktZ9zGQXZd4w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@mapbox/tiny-sdf": "^2.1.0",
|
||||
"@mapbox/unitbezier": "^0.0.1",
|
||||
"@mapbox/vector-tile": "^2.0.4",
|
||||
"@mapbox/whoots-js": "^3.1.0",
|
||||
"@maplibre/geojson-vt": "^6.1.0",
|
||||
"@maplibre/maplibre-gl-style-spec": "^24.8.1",
|
||||
"@maplibre/mlt": "^1.1.8",
|
||||
"@maplibre/vt-pbf": "^4.3.0",
|
||||
"@mapbox/tiny-sdf": "^2.2.0",
|
||||
"@mapbox/unitbezier": "^1.0.0",
|
||||
"@mapbox/vector-tile": "^3.0.0",
|
||||
"@maplibre/geojson-vt": "^6.1.1",
|
||||
"@maplibre/maplibre-gl-style-spec": "^26.4.1",
|
||||
"@maplibre/mlt": "^1.2.1",
|
||||
"@maplibre/vt-pbf": "^4.3.2",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"earcut": "^3.0.2",
|
||||
"earcut": "^3.2.3",
|
||||
"gl-matrix": "^3.4.4",
|
||||
"kdbush": "^4.0.2",
|
||||
"kdbush": "^4.1.0",
|
||||
"murmurhash-js": "^1.0.0",
|
||||
"pbf": "^4.0.1",
|
||||
"pbf": "^5.1.2",
|
||||
"potpack": "^2.1.0",
|
||||
"quickselect": "^3.0.0",
|
||||
"tinyqueue": "^3.0.0"
|
||||
@@ -32614,36 +32600,33 @@
|
||||
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/maplibre-gl/node_modules/@mapbox/unitbezier": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
|
||||
"integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/maplibre-gl/node_modules/@mapbox/vector-tile": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.5.tgz",
|
||||
"integrity": "sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw==",
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-3.0.0.tgz",
|
||||
"integrity": "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "~1.1.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"pbf": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/maplibre-gl/node_modules/@maplibre/geojson-vt": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.0.tgz",
|
||||
"integrity": "sha512-2eIY4gZxeKIVOZVNkAMb+5NgXhgsMQpOveTQAvnp53LYqHGJZDidk7Ew0Tged9PThidpbS+NFTh0g4zivhPDzQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"kdbush": "^4.0.2"
|
||||
"pbf": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/maplibre-gl/node_modules/earcut": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
|
||||
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz",
|
||||
"integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/maplibre-gl/node_modules/pbf": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.2.tgz",
|
||||
"integrity": "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg==",
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz",
|
||||
"integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"resolve-protobuf-schema": "^2.1.0"
|
||||
@@ -38965,9 +38948,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-error-boundary": {
|
||||
"version": "6.1.3",
|
||||
"resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.1.3.tgz",
|
||||
"integrity": "sha512-GnSKpCohFi2nQmJCWwP8O8wub7zexlePvpsejvQr35vS5RTouS1+utTNOmyc540yw5vyOXnSL1rBWsCQDmkyUA==",
|
||||
"version": "6.1.4",
|
||||
"resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.1.4.tgz",
|
||||
"integrity": "sha512-7FgvbyjCFhu4dtKSZu807cW1MWG2nWAM4bwGqUByAtHVJXtlzoCJX0SSyZRyFrb+aBd7wtJkcOR4DsxXVEuDsA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0 || ^19.0.0",
|
||||
@@ -42614,15 +42597,6 @@
|
||||
"integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/supercluster": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
|
||||
"integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"kdbush": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
@@ -42715,9 +42689,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/svgo": {
|
||||
"version": "3.3.4",
|
||||
"resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.4.tgz",
|
||||
"integrity": "sha512-GsNRis4e8jxn2Y9ENz/8lbJ93CstG8svtMnuRaHbiF2LTJ5tK0/q3t/URPq9Zc7zVWBJnNnJMIp6bevK7bSmNg==",
|
||||
"version": "3.3.5",
|
||||
"resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.5.tgz",
|
||||
"integrity": "sha512-8SQMzdrvWaD8deUmrnYB+ASyxBVgWUOilg+A75nE/76WdLpj6LopCwiAVvkzkcqy/9b7t2Mg7faFLjg0ZRcZ3w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -43761,9 +43735,9 @@
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.23.12",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz",
|
||||
"integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==",
|
||||
"version": "4.23.13",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz",
|
||||
"integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -45127,9 +45101,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/webpack": {
|
||||
"version": "5.110.1",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.110.1.tgz",
|
||||
"integrity": "sha512-gInQB+jxXxgnZyvPwuzT5NGQmECDqeu85oxcrjinrYHqPoBex0hCAN2SFTJVyPVrK0Pq9E44VFP+e89fAc10/w==",
|
||||
"version": "5.110.2",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.110.2.tgz",
|
||||
"integrity": "sha512-TciLrfM7zgEjqGdY851HkirDsSPQgTFsWQpl9oHqMAMYsHhEC0bKjscvjpnz+pzx10hLC8qISApGrsnrCP4UtQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -46922,9 +46896,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz",
|
||||
"integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
@@ -47053,7 +47027,7 @@
|
||||
"tinycolor2": "*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ant-design/icons": "^5.6.1 || ^6.0.0",
|
||||
"@ant-design/icons": "^5.6.1",
|
||||
"@emotion/react": "^11.4.1",
|
||||
"@superset-ui/core": "*",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
@@ -47073,7 +47047,7 @@
|
||||
"version": "0.20.4",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.3.2",
|
||||
"@ant-design/icons": "^6.3.4",
|
||||
"@apache-superset/core": "*",
|
||||
"@babel/runtime": "^7.29.7",
|
||||
"@braintree/sanitize-url": "^7.1.2",
|
||||
@@ -47102,9 +47076,9 @@
|
||||
"math-expression-evaluator": "^2.0.7",
|
||||
"parse-ms": "^4.0.0",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react-ace": "^14.0.1",
|
||||
"react-ace": "^15.0.0",
|
||||
"react-draggable": "^4.7.1",
|
||||
"react-error-boundary": "^6.1.3",
|
||||
"react-error-boundary": "^6.1.4",
|
||||
"react-js-cron": "^6.0.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resize-detector": "^7.1.2",
|
||||
@@ -47175,13 +47149,13 @@
|
||||
}
|
||||
},
|
||||
"packages/superset-ui-core/node_modules/@ant-design/icons": {
|
||||
"version": "6.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-6.3.2.tgz",
|
||||
"integrity": "sha512-B6O5a5XJ4wjtNOfZejXYwHW5zvKV5gYkjGf11dHGLEbKn0ABDGndo41+gfIiXyTFhvESj4XTotuud33mUFid0g==",
|
||||
"version": "6.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-6.3.4.tgz",
|
||||
"integrity": "sha512-kDoUlppczVyCUTFthF6cHmPzMBYqNZQhu9j6EyIX/YIdM9j9CZNZoJUjzVw7/1v1VO8WwVMmPp15r/uwIAS0AQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ant-design/colors": "^8.0.1",
|
||||
"@ant-design/icons-svg": "^4.5.0",
|
||||
"@ant-design/icons-svg": "^4.6.0",
|
||||
"@rc-component/util": "^1.11.0",
|
||||
"clsx": "^2.1.1"
|
||||
},
|
||||
@@ -47203,15 +47177,14 @@
|
||||
}
|
||||
},
|
||||
"packages/superset-ui-core/node_modules/react-ace": {
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-ace/-/react-ace-14.0.1.tgz",
|
||||
"integrity": "sha512-z6YAZ20PNf/FqmYEic//G/UK6uw0rn21g58ASgHJHl9rfE4nITQLqthr9rHMVQK4ezwohJbp2dGrZpkq979PYQ==",
|
||||
"version": "15.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-ace/-/react-ace-15.0.0.tgz",
|
||||
"integrity": "sha512-gdmS5ftF0hsbkcrAjZQqYbXwFg5JrHuxjki8mP6Bn9kfa6lHKfZk9sU9EfS0ifQ1CpGCmRxF/VC7GRvlJMBuZw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ace-builds": "^1.36.3",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
"lodash.get": "^4.4.2",
|
||||
"lodash.isequal": "^4.5.0",
|
||||
"fast-equals": "^5.3.3",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -47414,7 +47387,7 @@
|
||||
"d3-array": "^3.2.4",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.18.1",
|
||||
"zod": "^4.4.3"
|
||||
"zod": "^4.5.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
@@ -47593,8 +47566,10 @@
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@math.gl/web-mercator": "^4.1.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"@types/supercluster": "^7.1.3",
|
||||
"mapbox-gl": "^3.29.0",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"maplibre-gl": "^6.8.0",
|
||||
"react-map-gl": "^8.1.2",
|
||||
"supercluster": "^9.0.0"
|
||||
},
|
||||
@@ -47737,7 +47712,7 @@
|
||||
"handlebars": "^4.7.9",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.18.1",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"maplibre-gl": "^6.8.0",
|
||||
"mousetrap": "^1.6.5",
|
||||
"ngeohash": "^0.6.4",
|
||||
"prop-types": "^15.8.1",
|
||||
|
||||
@@ -292,7 +292,7 @@
|
||||
"@types/rison": "0.1.0",
|
||||
"@types/tinycolor2": "^1.4.3",
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"@typescript-eslint/eslint-plugin": "^8.68.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.69.0",
|
||||
"@typescript-eslint/parser": "^8.63.0",
|
||||
"babel-jest": "^30.5.0",
|
||||
"babel-loader": "^10.1.1",
|
||||
@@ -349,11 +349,11 @@
|
||||
"swc-loader": "^0.2.7",
|
||||
"ts-jest": "^29.4.12",
|
||||
"tscw-config": "^1.1.2",
|
||||
"tsx": "^4.23.12",
|
||||
"tsx": "^4.23.13",
|
||||
"typescript": "5.4.5",
|
||||
"unzipper": "^0.12.5",
|
||||
"wait-on": "^9.1.0",
|
||||
"webpack": "^5.110.1",
|
||||
"webpack": "^5.110.2",
|
||||
"webpack-bundle-analyzer": "^5.3.2",
|
||||
"webpack-cli": "^7.2.3",
|
||||
"webpack-dev-server": "^6.0.0",
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"tinycolor2": "*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ant-design/icons": "^5.6.1 || ^6.0.0",
|
||||
"@ant-design/icons": "^5.6.1",
|
||||
"@emotion/react": "^11.4.1",
|
||||
"@superset-ui/core": "*",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
"access": "public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.3.2",
|
||||
"@ant-design/icons": "^6.3.4",
|
||||
"@apache-superset/core": "*",
|
||||
"@babel/runtime": "^7.29.7",
|
||||
"@braintree/sanitize-url": "^7.1.2",
|
||||
@@ -78,9 +78,9 @@
|
||||
"math-expression-evaluator": "^2.0.7",
|
||||
"parse-ms": "^4.0.0",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react-ace": "^14.0.1",
|
||||
"react-ace": "^15.0.0",
|
||||
"react-draggable": "^4.7.1",
|
||||
"react-error-boundary": "^6.1.3",
|
||||
"react-error-boundary": "^6.1.4",
|
||||
"react-js-cron": "^6.0.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resize-detector": "^7.1.2",
|
||||
|
||||
+98
@@ -566,6 +566,104 @@ test('should NOT refetch data when other string-based renderTrigger controls cha
|
||||
});
|
||||
});
|
||||
|
||||
test('should NOT refetch data when echart_options (string-based renderTrigger control) changes', async () => {
|
||||
// Matches how the Timeseries/MixedTimeseries control panels reference this
|
||||
// shared control: a bare string, e.g. ['echart_options'].
|
||||
const controlPanelConfig = {
|
||||
controlPanelSections: [
|
||||
{
|
||||
controlSetRows: [['echart_options']],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
jest.mocked(getChartControlPanelRegistry).mockReturnValue({
|
||||
get: jest.fn().mockReturnValue(controlPanelConfig),
|
||||
} as unknown as ReturnType<typeof getChartControlPanelRegistry>);
|
||||
|
||||
const formDataWithEchartOptions = {
|
||||
...mockFormData,
|
||||
echart_options: '{}',
|
||||
};
|
||||
|
||||
const { rerender, getByTestId } = render(
|
||||
<StatefulChart
|
||||
formData={formDataWithEchartOptions}
|
||||
chartType="test_chart"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockChartClient.client.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Edit the ECharts Options field (e.g. from the Customize tab while the
|
||||
// chart is part of a Matrixify grid cell).
|
||||
const updatedFormData = {
|
||||
...formDataWithEchartOptions,
|
||||
echart_options: '{"title": {"text": "My Chart"}}',
|
||||
};
|
||||
|
||||
rerender(<StatefulChart formData={updatedFormData} chartType="test_chart" />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should NOT refetch data - echart_options is a renderTrigger control
|
||||
expect(mockChartClient.client.post).toHaveBeenCalledTimes(1);
|
||||
// But should re-render with the new formData
|
||||
expect(getByTestId('super-chart')).toHaveTextContent(
|
||||
JSON.stringify(updatedFormData),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('should refetch when a chart overrides a shared renderTrigger control to renderTrigger: false', async () => {
|
||||
// Matches Country Map's controlPanel.controlOverrides, which sets
|
||||
// linear_color_scheme to renderTrigger: false because it drives the
|
||||
// choropleth data query rather than just styling.
|
||||
const controlPanelConfig = {
|
||||
controlPanelSections: [
|
||||
{
|
||||
controlSetRows: [['linear_color_scheme']],
|
||||
},
|
||||
],
|
||||
controlOverrides: {
|
||||
linear_color_scheme: {
|
||||
renderTrigger: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
jest.mocked(getChartControlPanelRegistry).mockReturnValue({
|
||||
get: jest.fn().mockReturnValue(controlPanelConfig),
|
||||
} as unknown as ReturnType<typeof getChartControlPanelRegistry>);
|
||||
|
||||
const formDataWithColorScheme = {
|
||||
...mockFormData,
|
||||
linear_color_scheme: 'schemeA',
|
||||
};
|
||||
|
||||
const { rerender } = render(
|
||||
<StatefulChart formData={formDataWithColorScheme} chartType="test_chart" />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockChartClient.client.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const updatedFormData = {
|
||||
...formDataWithColorScheme,
|
||||
linear_color_scheme: 'schemeB',
|
||||
};
|
||||
|
||||
rerender(<StatefulChart formData={updatedFormData} chartType="test_chart" />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should refetch because this chart's controlOverrides mark the control
|
||||
// as data-affecting, overriding the shared-control fallback.
|
||||
expect(mockChartClient.client.post).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
test('should refetch when string control is NOT in RENDER_TRIGGER_SHARED_CONTROLS', async () => {
|
||||
// Control panel with a string control that is NOT in the renderTrigger set
|
||||
const controlPanelConfig = {
|
||||
|
||||
@@ -50,6 +50,9 @@ type LoadingState = 'uninitialized' | 'loading' | 'loaded' | 'error';
|
||||
* This list is needed because string-based control references (e.g., ['zoomable'])
|
||||
* cannot be introspected for their renderTrigger property without importing
|
||||
* sharedControls, which would create a circular dependency.
|
||||
*
|
||||
* Keep this list in sync with the `renderTrigger: true` entries in
|
||||
* @superset-ui/chart-controls's sharedControls.tsx.
|
||||
*/
|
||||
const RENDER_TRIGGER_SHARED_CONTROLS = new Set([
|
||||
'zoomable',
|
||||
@@ -57,6 +60,11 @@ const RENDER_TRIGGER_SHARED_CONTROLS = new Set([
|
||||
'time_shift_color',
|
||||
'y_axis_format',
|
||||
'currency_format',
|
||||
'color_picker',
|
||||
'linear_color_scheme',
|
||||
'x_axis_time_format',
|
||||
'x_axis_number_format',
|
||||
'echart_options',
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -111,6 +119,28 @@ function shouldRefetchData(
|
||||
}
|
||||
});
|
||||
|
||||
// Individual chart types can override a shared control's renderTrigger
|
||||
// behavior (e.g., Country Map sets `linear_color_scheme` to
|
||||
// renderTrigger: false because it drives the choropleth query, not just
|
||||
// styling). Apply those overrides on top of the shared-control fallback
|
||||
// so such controls still trigger a refetch for that chart type.
|
||||
const { controlOverrides } = controlPanel;
|
||||
if (controlOverrides) {
|
||||
Object.entries(controlOverrides).forEach(([controlName, override]) => {
|
||||
if (
|
||||
override &&
|
||||
typeof override === 'object' &&
|
||||
'renderTrigger' in override
|
||||
) {
|
||||
if ((override as { renderTrigger?: boolean }).renderTrigger) {
|
||||
renderTriggerControls.add(controlName);
|
||||
} else {
|
||||
renderTriggerControls.delete(controlName);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Check which fields changed
|
||||
const changedFields = Object.keys(nextFormData).filter(
|
||||
key =>
|
||||
|
||||
+55
@@ -604,6 +604,61 @@ test('cleans up event listeners on unmount', async () => {
|
||||
offSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('re-applies annotations only when their content actually changes across renders (react-ace 15 fast-equals regression guard)', async () => {
|
||||
// react-ace's componentDidUpdate decides whether to call
|
||||
// session.setAnnotations() by deep-comparing the new/old `annotations`
|
||||
// prop (lib/ace.js, using an internal deep-equality helper -- lodash's
|
||||
// isEqual through react-ace 14.x, fast-equals's deepEqual from 15.0.0
|
||||
// onward). Superset's own AceEditorProvider/EditorWrapper always pass a
|
||||
// freshly `.map()`-derived annotations array on every render, so this
|
||||
// guards the actual behavior Superset relies on: a same-content-but-
|
||||
// different-reference array must NOT re-trigger setAnnotations (or the
|
||||
// editor would thrash on every keystroke-driven re-render), while a
|
||||
// genuinely different array must still update the editor.
|
||||
const ref = createRef<AceEditor>();
|
||||
const annotationsV1 = [{ row: 0, column: 0, type: 'error', text: 'oops' }];
|
||||
|
||||
const { rerender, container } = render(
|
||||
<SQLEditor ref={ref as React.Ref<never>} annotations={annotationsV1} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector(selector)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const session = ref.current?.editor?.getSession();
|
||||
expect(session).toBeDefined();
|
||||
if (!session) return;
|
||||
|
||||
// The initial mount already applies annotations via componentDidMount,
|
||||
// not componentDidUpdate, so start observing only from the first update.
|
||||
const setAnnotationsSpy = jest.spyOn(session, 'setAnnotations');
|
||||
|
||||
// Same content, new array/object references -- must be a no-op.
|
||||
const annotationsV1SameContent = [
|
||||
{ row: 0, column: 0, type: 'error', text: 'oops' },
|
||||
];
|
||||
rerender(
|
||||
<SQLEditor
|
||||
ref={ref as React.Ref<never>}
|
||||
annotations={annotationsV1SameContent}
|
||||
/>,
|
||||
);
|
||||
expect(setAnnotationsSpy).not.toHaveBeenCalled();
|
||||
|
||||
// Genuinely different content -- must update, with the new value.
|
||||
const annotationsV2 = [
|
||||
{ row: 1, column: 2, type: 'warning', text: 'different' },
|
||||
];
|
||||
rerender(
|
||||
<SQLEditor ref={ref as React.Ref<never>} annotations={annotationsV2} />,
|
||||
);
|
||||
expect(setAnnotationsSpy).toHaveBeenCalledTimes(1);
|
||||
expect(setAnnotationsSpy).toHaveBeenCalledWith(annotationsV2);
|
||||
|
||||
setAnnotationsSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('does not move autocomplete popup if target container is document.body', async () => {
|
||||
const ref = createRef<AceEditor>();
|
||||
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
|
||||
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* 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 { Icons } from '../Icons';
|
||||
import { Button } from '../Button';
|
||||
import MetadataBar, { MetadataType } from '../MetadataBar';
|
||||
import { Menu } from '../Menu';
|
||||
import { PageHeaderWithActions, PageHeaderWithActionsProps } from '.';
|
||||
|
||||
export default {
|
||||
title: 'Design System/Components/PageHeaderWithActions',
|
||||
component: PageHeaderWithActions,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Header used on entity pages (e.g. the dashboard page) combining an editable title with badges, a metadata bar, and page-level actions.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Mirrors src/dashboard/components/Header's real composition: an editable
|
||||
// title, the certified badge, and a titlePanelAdditionalItems cluster of a
|
||||
// refresh button, an auto-refresh indicator, a published-status toggle, and
|
||||
// a MetadataBar (Last Modified + Editor) -- the same items the real
|
||||
// dashboard header packs into that space -- so this story reproduces the
|
||||
// header's real narrow-viewport layout behavior, not just the isolated
|
||||
// MetadataBar. The real RefreshButton/AutoRefreshIndicator/PublishedStatus
|
||||
// components live in src/dashboard and depend on this package, so they
|
||||
// can't be imported here without inverting that dependency; these are
|
||||
// same-sized stand-ins built from core components instead.
|
||||
export const DashboardHeader = (args: PageHeaderWithActionsProps) => (
|
||||
<PageHeaderWithActions {...args} />
|
||||
);
|
||||
|
||||
DashboardHeader.args = {
|
||||
editableTitleProps: {
|
||||
title: 'Q3 Executive Revenue and Growth Overview Dashboard',
|
||||
placeholder: 'Add the name of the dashboard',
|
||||
onSave: () => {},
|
||||
canEdit: true,
|
||||
label: 'Dashboard title',
|
||||
},
|
||||
showTitlePanelItems: true,
|
||||
certificatiedBadgeProps: {
|
||||
certifiedBy: 'Jane Doe',
|
||||
details: 'Certified by the BI team',
|
||||
},
|
||||
showFaveStar: true,
|
||||
faveStarProps: { itemId: 1, saveFaveStar: () => {}, isStarred: false },
|
||||
titlePanelAdditionalItems: [
|
||||
<Button key="refresh-button" buttonStyle="link" tooltip="Refresh dashboard">
|
||||
<Icons.ReloadOutlined iconSize="l" />
|
||||
</Button>,
|
||||
<Icons.SyncOutlined key="auto-refresh-indicator" iconSize="l" />,
|
||||
<Button key="published-status" buttonStyle="link">
|
||||
Published
|
||||
</Button>,
|
||||
<MetadataBar
|
||||
key="metadata-bar"
|
||||
tooltipPlacement="bottom"
|
||||
items={[
|
||||
{
|
||||
type: MetadataType.LastModified,
|
||||
value: '2 hours ago',
|
||||
modifiedBy: 'Jane Doe',
|
||||
},
|
||||
{
|
||||
type: MetadataType.Editor,
|
||||
createdBy: 'Jane Doe',
|
||||
editors: ['Jane Doe', 'John Smith'],
|
||||
createdOn: 'a week ago',
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
],
|
||||
rightPanelAdditionalItems: <button type="button">Edit dashboard</button>,
|
||||
additionalActionsMenu: (
|
||||
<Menu
|
||||
items={[{ label: 'Edit properties', key: '1' }]}
|
||||
data-test="additional-actions-menu"
|
||||
/>
|
||||
),
|
||||
menuDropdownProps: {},
|
||||
};
|
||||
+18
-1
@@ -18,7 +18,12 @@
|
||||
*/
|
||||
|
||||
import { render, screen, userEvent } from '@superset-ui/core/spec';
|
||||
import { PageHeaderWithActions, PageHeaderWithActionsProps } from './index';
|
||||
import { supersetTheme } from '@apache-superset/core/theme';
|
||||
import {
|
||||
buttonsStyles,
|
||||
PageHeaderWithActions,
|
||||
PageHeaderWithActionsProps,
|
||||
} from './index';
|
||||
import { Menu } from '../Menu';
|
||||
|
||||
const defaultProps: PageHeaderWithActionsProps = {
|
||||
@@ -54,3 +59,15 @@ test('Renders', async () => {
|
||||
await userEvent.click(screen.getByLabelText('Menu actions trigger'));
|
||||
expect(defaultProps.menuDropdownProps.onOpenChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('clips the title panel buttons/metadata cluster instead of letting it overflow into the actions menu', () => {
|
||||
// jsdom doesn't compute real flexbox layout, so it can't verify the
|
||||
// overlap itself is fixed; this guards the underlying CSS from
|
||||
// regressing instead. Without `overflow: hidden`, this wrapper's
|
||||
// automatic flex minimum size is based on its content rather than 0, so
|
||||
// it refuses to shrink -- forcing the title to absorb all the space
|
||||
// pressure until the cluster's content renders outside its box and
|
||||
// overlaps the actions menu once the title has fully collapsed.
|
||||
const { styles } = buttonsStyles(supersetTheme);
|
||||
expect(styles).toMatch(/overflow:\s*hidden/);
|
||||
});
|
||||
|
||||
+5
-1
@@ -99,9 +99,13 @@ const headerStyles = (theme: SupersetTheme) => css`
|
||||
}
|
||||
`;
|
||||
|
||||
const buttonsStyles = (theme: SupersetTheme) => css`
|
||||
// Exported only so PageHeaderWithActions.test.tsx can assert on the
|
||||
// `overflow: hidden` declaration directly; not part of the component's
|
||||
// public API.
|
||||
export const buttonsStyles = (theme: SupersetTheme) => css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
padding-left: ${theme.sizeUnit * 2}px;
|
||||
|
||||
& .anticon-star {
|
||||
|
||||
+8
-5
@@ -16,15 +16,18 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
DataRecordValue,
|
||||
normalizeTimestamp,
|
||||
TimeFormatFunction,
|
||||
} from '@superset-ui/core';
|
||||
import type { DataRecordValue } from '../query/types/QueryResponse';
|
||||
import type { TimeFormatFunction } from './types';
|
||||
import normalizeTimestamp from './utils/normalizeTimestamp';
|
||||
|
||||
/**
|
||||
* Extended Date object with a custom formatter, and retains the original input
|
||||
* when the formatter is simple `String(..)`.
|
||||
*
|
||||
* `toString()` never formats an Invalid Date: it returns the original input
|
||||
* instead. `stringifyTimeInput` relies on that when it falls back to
|
||||
* `${value}` for an unparseable input, otherwise the two would call each other
|
||||
* forever.
|
||||
*/
|
||||
export default class DateWithFormatter extends Date {
|
||||
formatter: TimeFormatFunction;
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
export { default as TimeFormats, LOCAL_PREFIX } from './TimeFormats';
|
||||
export { default as TimeFormatter, PREVIEW_TIME } from './TimeFormatter';
|
||||
export { default as DateWithFormatter } from './DateWithFormatter';
|
||||
export { DEFAULT_D3_TIME_FORMAT } from './D3FormatConfig';
|
||||
|
||||
export {
|
||||
|
||||
+13
-2
@@ -28,8 +28,17 @@ export default function stringifyTimeInput(
|
||||
let time: Date;
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
// A bare four-digit string is the ISO 8601 year-only form ("2017"), which
|
||||
// every engine parses as January 1st of that year. Any other integer
|
||||
// string is an epoch timestamp in milliseconds that was stringified on
|
||||
// its way here, e.g. by the pivot table, and is not a valid Date input.
|
||||
const isYear = /^\d{4}$/.test(trimmed);
|
||||
const isIntegerString = /^-?\d+$/.test(trimmed);
|
||||
time = new Date(isIntegerString ? Number(trimmed) : value);
|
||||
if (isYear) {
|
||||
time = new Date(trimmed);
|
||||
} else {
|
||||
time = new Date(isIntegerString ? Number(trimmed) : value);
|
||||
}
|
||||
} else {
|
||||
time = value instanceof Date ? value : new Date(value);
|
||||
}
|
||||
@@ -37,7 +46,9 @@ export default function stringifyTimeInput(
|
||||
// An input that does not resolve to a valid date - a duration such as
|
||||
// "00:01:54", for instance - would otherwise be formatted from an Invalid
|
||||
// Date and render as "NaN:NaN:NaN". Fall back to its own representation,
|
||||
// as is already done for null and undefined above.
|
||||
// as is already done for null and undefined above. For a `DateWithFormatter`
|
||||
// this calls its `toString()`, which returns the original input rather than
|
||||
// re-entering the formatter; that guard is what keeps the fallback finite.
|
||||
if (Number.isNaN(time.getTime())) {
|
||||
return `${value}`;
|
||||
}
|
||||
|
||||
+2
-21
@@ -1,4 +1,4 @@
|
||||
/*
|
||||
/**
|
||||
* 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
|
||||
@@ -16,11 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { getTimeFormatter } from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import DateWithFormatter from '../../src/utils/DateWithFormatter';
|
||||
import { formatColumnValue } from '../../src/utils/formatValue';
|
||||
import { DataColumnMeta } from '../../src/types';
|
||||
import { DateWithFormatter, getTimeFormatter } from '@superset-ui/core';
|
||||
|
||||
const formatter = getTimeFormatter('%H:%M:%S');
|
||||
|
||||
@@ -43,18 +39,3 @@ test('retains the original input when the formatter is String', () => {
|
||||
const value = new DateWithFormatter('00:01:54');
|
||||
expect(String(value)).toBe('00:01:54');
|
||||
});
|
||||
|
||||
test('renders a duration cell through the column formatter without producing NaN', () => {
|
||||
// The cell text is produced by formatColumnValue, which hands the wrapped
|
||||
// value straight to the formatter rather than going through toString().
|
||||
const column: DataColumnMeta = {
|
||||
key: 'call_period',
|
||||
label: 'call_period',
|
||||
dataType: GenericDataType.Temporal,
|
||||
formatter,
|
||||
isNumeric: false,
|
||||
};
|
||||
const value = new DateWithFormatter('00:01:54', { formatter });
|
||||
|
||||
expect(formatColumnValue(column, value)).toEqual([false, '00:01:54']);
|
||||
});
|
||||
+23
@@ -16,6 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { DateWithFormatter, getTimeFormatter } from '@superset-ui/core';
|
||||
import stringifyTimeInput from '../../../src/time-format/utils/stringifyTimeInput';
|
||||
|
||||
const format = (time: Date) => time.toISOString();
|
||||
@@ -56,3 +57,25 @@ test('returns unparseable strings unchanged instead of formatting an Invalid Dat
|
||||
test('returns the representation of a Date that could not be resolved', () => {
|
||||
expect(stringifyTimeInput(new Date('00:01:54'), format)).toBe('Invalid Date');
|
||||
});
|
||||
|
||||
test('treats a four-digit integer string as a year, not as milliseconds', () => {
|
||||
// "2017" is the ISO 8601 year-only form. Reading it as an epoch offset
|
||||
// would silently turn it into two seconds past 1970.
|
||||
expect(stringifyTimeInput('2017', format)).toBe('2017-01-01T00:00:00.000Z');
|
||||
expect(stringifyTimeInput(' 1987 ', format)).toBe('1987-01-01T00:00:00.000Z');
|
||||
// Longer digit strings stay epoch milliseconds.
|
||||
expect(stringifyTimeInput('1704067200000', format)).toBe(
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
);
|
||||
});
|
||||
|
||||
test('returns the original input of an unparseable DateWithFormatter without re-entering the formatter', () => {
|
||||
// The `${value}` fallback calls `DateWithFormatter.toString()`, which must
|
||||
// return the input rather than call the formatter again, or the two would
|
||||
// recurse until the stack overflows.
|
||||
const formatter = getTimeFormatter('%H:%M:%S');
|
||||
const value = new DateWithFormatter('00:01:54', { formatter });
|
||||
|
||||
expect(stringifyTimeInput(value, time => formatter(time))).toBe('00:01:54');
|
||||
expect(formatter(value)).toBe('00:01:54');
|
||||
});
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
TimeFormatter,
|
||||
AgGridChartState,
|
||||
AgGridFilterModel,
|
||||
DateWithFormatter,
|
||||
} from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import { isEmpty, isEqual, merge } from 'lodash-es';
|
||||
@@ -45,7 +46,6 @@ import {
|
||||
ColorSchemeEnum,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import isEqualColumns from './utils/isEqualColumns';
|
||||
import DateWithFormatter from './utils/DateWithFormatter';
|
||||
import { BASIC_COLOR_FORMATTERS_ROW_KEY } from './consts';
|
||||
import {
|
||||
DataColumnMeta,
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
isDefined,
|
||||
isProbablyHTML,
|
||||
sanitizeHtml,
|
||||
DateWithFormatter,
|
||||
} from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import {
|
||||
@@ -29,7 +30,6 @@ import {
|
||||
ValueGetterParams,
|
||||
} from '@superset-ui/core/components/ThemedAgGridReact';
|
||||
import { DataColumnMeta, InputColumn } from '../types';
|
||||
import DateWithFormatter from './DateWithFormatter';
|
||||
|
||||
/**
|
||||
* Format text for cell value.
|
||||
|
||||
@@ -25,7 +25,11 @@ import {
|
||||
CellClassParams,
|
||||
} from '@superset-ui/core/components/ThemedAgGridReact';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { DataRecordValue, JsonObject } from '@superset-ui/core';
|
||||
import {
|
||||
DataRecordValue,
|
||||
DateWithFormatter,
|
||||
JsonObject,
|
||||
} from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import { useTheme } from '@apache-superset/core/theme';
|
||||
import { ColorFormatters } from '@superset-ui/chart-controls';
|
||||
@@ -42,7 +46,6 @@ import htmlTextFilterValueGetter, {
|
||||
htmlTextComparator,
|
||||
} from './htmlTextFilterValueGetter';
|
||||
import dateFilterComparator from './dateFilterComparator';
|
||||
import DateWithFormatter from './DateWithFormatter';
|
||||
import { getAggFunc } from './getAggFunc';
|
||||
import { TextCellRenderer } from '../renderers/TextCellRenderer';
|
||||
import { NumericCellRenderer } from '../renderers/NumericCellRenderer';
|
||||
|
||||
+1
-22
@@ -16,34 +16,13 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { getTimeFormatter } from '@superset-ui/core';
|
||||
import { DateWithFormatter, getTimeFormatter } from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import DateWithFormatter from '../../src/utils/DateWithFormatter';
|
||||
import { formatColumnValue } from '../../src/utils/formatValue';
|
||||
import { DataColumnMeta } from '../../src/types';
|
||||
|
||||
const formatter = getTimeFormatter('%H:%M:%S');
|
||||
|
||||
test('formats a parseable timestamp with the configured formatter', () => {
|
||||
const value = new DateWithFormatter('2017-02-14T11:22:33Z', { formatter });
|
||||
expect(String(value)).toBe('11:22:33');
|
||||
});
|
||||
|
||||
test('renders the original value when it is not a parseable timestamp', () => {
|
||||
// Duration columns hold values like these. They produce an Invalid Date,
|
||||
// which used to be formatted and rendered as "NaN:NaN:NaN".
|
||||
['00:01:54', '0 days 00:01:54'].forEach(input => {
|
||||
const value = new DateWithFormatter(input, { formatter });
|
||||
expect(Number.isNaN(value.getTime())).toBe(true);
|
||||
expect(String(value)).toBe(input);
|
||||
});
|
||||
});
|
||||
|
||||
test('retains the original input when the formatter is String', () => {
|
||||
const value = new DateWithFormatter('00:01:54');
|
||||
expect(String(value)).toBe('00:01:54');
|
||||
});
|
||||
|
||||
test('renders a duration cell through the column formatter without producing NaN', () => {
|
||||
// The cell text is produced by formatColumnValue, which hands the wrapped
|
||||
// value straight to the formatter rather than going through toString().
|
||||
@@ -33,7 +33,7 @@
|
||||
"d3-array": "^3.2.4",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.18.1",
|
||||
"zod": "^4.4.3"
|
||||
"zod": "^4.5.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
|
||||
+50
-2
@@ -44,9 +44,14 @@ import {
|
||||
ValueFormatter,
|
||||
} from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import { getOriginalSeries } from '@superset-ui/chart-controls';
|
||||
import {
|
||||
getOriginalSeries,
|
||||
getTimeOffset,
|
||||
isDerivedSeries,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import type { EChartsCoreOption } from 'echarts/core';
|
||||
import type { SeriesOption } from 'echarts';
|
||||
import type { LineStyleOption } from 'echarts/types/src/util/types';
|
||||
import {
|
||||
DEFAULT_FORM_DATA,
|
||||
EchartsMixedTimeseriesChartTransformedProps,
|
||||
@@ -100,7 +105,11 @@ import {
|
||||
transformSeries,
|
||||
transformTimeseriesAnnotation,
|
||||
} from '../Timeseries/transformers';
|
||||
import { TIMEGRAIN_TO_TIMESTAMP, TIMESERIES_CONSTANTS } from '../constants';
|
||||
import {
|
||||
TIMEGRAIN_TO_TIMESTAMP,
|
||||
TIMESERIES_CONSTANTS,
|
||||
OpacityEnum,
|
||||
} from '../constants';
|
||||
import { getDefaultTooltip } from '../utils/tooltip';
|
||||
import {
|
||||
createSpacedXAxisFormatter,
|
||||
@@ -453,6 +462,10 @@ export default function transformProps(
|
||||
|
||||
const array = ensureIsArray(chartProps.rawFormData?.time_compare);
|
||||
const inverted = invert(verboseMap);
|
||||
// Tracks a stable pattern index per time offset so that derived series
|
||||
// sharing the same comparison window (across both queries A and B) get
|
||||
// the same dash pattern, mirroring the regular Timeseries transform.
|
||||
const offsetPatterns: { [key: string]: number } = {};
|
||||
|
||||
// The rendered ECharts series names are display names that can diverge from
|
||||
// the backend `label_map` keys: the metric display name is prepended when
|
||||
@@ -467,6 +480,22 @@ export default function transformProps(
|
||||
rawSeriesA.forEach(entry => {
|
||||
const entryName = String(entry.name || '');
|
||||
const seriesName = inverted[entryName] || entryName;
|
||||
const derivedSeries = isDerivedSeries(
|
||||
entry,
|
||||
chartProps.rawFormData,
|
||||
seriesName,
|
||||
);
|
||||
const lineStyle: LineStyleOption = {};
|
||||
if (derivedSeries && timeShiftColor) {
|
||||
const offset = getTimeOffset(entry, array) || seriesName;
|
||||
if (!offsetPatterns[offset]) {
|
||||
offsetPatterns[offset] = Object.keys(offsetPatterns).length + 1;
|
||||
}
|
||||
const patternIndex = offsetPatterns[offset];
|
||||
// use a combination of dash and dot for the line style
|
||||
lineStyle.type = [(patternIndex % 5) + 1, (patternIndex % 3) + 1];
|
||||
lineStyle.opacity = OpacityEnum.DerivedSeries;
|
||||
}
|
||||
const colorScaleKey = getOriginalSeries(seriesName, array);
|
||||
|
||||
const labelMapValues = rawLabelMap?.[seriesName];
|
||||
@@ -544,6 +573,7 @@ export default function transformProps(
|
||||
timeShiftColor,
|
||||
theme,
|
||||
labelPosition,
|
||||
lineStyle,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -556,6 +586,23 @@ export default function transformProps(
|
||||
rawSeriesB.forEach(entry => {
|
||||
const entryName = String(entry.name || '');
|
||||
const seriesEntry = inverted[entryName] || entryName;
|
||||
const derivedSeries = isDerivedSeries(
|
||||
entry,
|
||||
chartProps.rawFormData,
|
||||
seriesEntry,
|
||||
);
|
||||
const lineStyle: LineStyleOption = {};
|
||||
if (derivedSeries && timeShiftColor) {
|
||||
const offset = getTimeOffset(entry, array) || seriesEntry;
|
||||
if (!offsetPatterns[offset]) {
|
||||
offsetPatterns[offset] = Object.keys(offsetPatterns).length + 1;
|
||||
}
|
||||
const patternIndex = offsetPatterns[offset];
|
||||
// use a combination of dash and dot for the line style
|
||||
lineStyle.type = [(patternIndex % 5) + 1, (patternIndex % 3) + 1];
|
||||
lineStyle.opacity = OpacityEnum.DerivedSeries;
|
||||
}
|
||||
|
||||
const colorScaleKey = getOriginalSeries(seriesEntry, array);
|
||||
|
||||
const labelMapValuesB = rawLabelMapB?.[seriesEntry];
|
||||
@@ -634,6 +681,7 @@ export default function transformProps(
|
||||
timeShiftColor,
|
||||
theme,
|
||||
labelPosition: labelPositionB,
|
||||
lineStyle,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
+161
@@ -21,6 +21,7 @@ import {
|
||||
AnnotationType,
|
||||
AnnotationSourceType,
|
||||
AxisType,
|
||||
ComparisonType,
|
||||
DataRecord,
|
||||
FormulaAnnotationLayer,
|
||||
IntervalAnnotationLayer,
|
||||
@@ -1562,6 +1563,7 @@ test('y-axis title position: non-Left sets nameLocation to end', () => {
|
||||
expect(yAxis[1].nameGap).toEqual(30);
|
||||
expect(yAxis[1].nameLocation).toEqual('end');
|
||||
});
|
||||
|
||||
describe('EchartsMixedTimeseries tooltip truncation', () => {
|
||||
const longSeriesName = 'prod-us-east-1-service-checkout-latency-p99';
|
||||
const marker = '<span style="background-color:#1f77b4;"></span>';
|
||||
@@ -1763,3 +1765,162 @@ test('hides the ticks on the x axis and both y axes', () => {
|
||||
expect(yAxis[0].axisTick.show).toBe(false);
|
||||
expect(yAxis[1].axisTick.show).toBe(false);
|
||||
});
|
||||
|
||||
test('should apply a dashed lineStyle to derived (time comparison) series only', () => {
|
||||
const queryAData = createTestQueryData(
|
||||
[
|
||||
{
|
||||
sum__num: 100,
|
||||
'sum__num__1 week ago': 80,
|
||||
ds: 599616000000,
|
||||
},
|
||||
{
|
||||
sum__num: 150,
|
||||
'sum__num__1 week ago': 120,
|
||||
ds: 599916000000,
|
||||
},
|
||||
],
|
||||
{
|
||||
label_map: {
|
||||
ds: ['ds'],
|
||||
sum__num: ['sum__num'],
|
||||
'sum__num__1 week ago': ['sum__num__1 week ago'],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const chartProps = createEchartsTimeseriesTestChartProps<
|
||||
EchartsMixedTimeseriesFormData,
|
||||
EchartsMixedTimeseriesProps
|
||||
>({
|
||||
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
|
||||
defaultQueriesData: [queryAData, queriesData[1]],
|
||||
formData: {
|
||||
...formData,
|
||||
metrics: ['sum__num'],
|
||||
groupby: [],
|
||||
time_compare: ['1 week ago'],
|
||||
comparison_type: ComparisonType.Values,
|
||||
timeShiftColor: true,
|
||||
},
|
||||
queriesData: [queryAData, queriesData[1]],
|
||||
});
|
||||
|
||||
const transformed = transformProps(chartProps);
|
||||
const series = (transformed.echartOptions.series as SeriesOption[]) || [];
|
||||
|
||||
const mainSeries = series.find(s => s.name === 'sum__num') as
|
||||
| (SeriesOption & { lineStyle?: { type?: number[] | string } })
|
||||
| undefined;
|
||||
const derivedSeries = series.find(s => s.name === 'sum__num__1 week ago') as
|
||||
| (SeriesOption & { lineStyle?: { type?: number[] | string } })
|
||||
| undefined;
|
||||
|
||||
expect(mainSeries).toBeDefined();
|
||||
expect(derivedSeries).toBeDefined();
|
||||
// The primary (non-derived) series should not receive a dash pattern
|
||||
expect(mainSeries?.lineStyle?.type).toBeUndefined();
|
||||
// The derived (time comparison) series should receive a dash pattern array
|
||||
expect(Array.isArray(derivedSeries?.lineStyle?.type)).toBe(true);
|
||||
});
|
||||
|
||||
test('should not apply a dashed lineStyle when comparison_type is not Values', () => {
|
||||
const queryAData = createTestQueryData(
|
||||
[
|
||||
{
|
||||
sum__num: 100,
|
||||
'sum__num__1 week ago': 80,
|
||||
ds: 599616000000,
|
||||
},
|
||||
{
|
||||
sum__num: 150,
|
||||
'sum__num__1 week ago': 120,
|
||||
ds: 599916000000,
|
||||
},
|
||||
],
|
||||
{
|
||||
label_map: {
|
||||
ds: ['ds'],
|
||||
sum__num: ['sum__num'],
|
||||
'sum__num__1 week ago': ['sum__num__1 week ago'],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const chartProps = createEchartsTimeseriesTestChartProps<
|
||||
EchartsMixedTimeseriesFormData,
|
||||
EchartsMixedTimeseriesProps
|
||||
>({
|
||||
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
|
||||
defaultQueriesData: [queryAData, queriesData[1]],
|
||||
formData: {
|
||||
...formData,
|
||||
metrics: ['sum__num'],
|
||||
groupby: [],
|
||||
time_compare: ['1 week ago'],
|
||||
comparison_type: ComparisonType.Difference,
|
||||
},
|
||||
queriesData: [queryAData, queriesData[1]],
|
||||
});
|
||||
|
||||
const transformed = transformProps(chartProps);
|
||||
const series = (transformed.echartOptions.series as SeriesOption[]) || [];
|
||||
|
||||
const derivedSeries = series.find(s => s.name === 'sum__num__1 week ago') as
|
||||
| (SeriesOption & { lineStyle?: { type?: number[] | string } })
|
||||
| undefined;
|
||||
|
||||
expect(derivedSeries).toBeDefined();
|
||||
expect(derivedSeries?.lineStyle?.type).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should not apply a dashed lineStyle when timeShiftColor is disabled', () => {
|
||||
const queryAData = createTestQueryData(
|
||||
[
|
||||
{
|
||||
sum__num: 100,
|
||||
'sum__num__1 week ago': 80,
|
||||
ds: 599616000000,
|
||||
},
|
||||
{
|
||||
sum__num: 150,
|
||||
'sum__num__1 week ago': 120,
|
||||
ds: 599916000000,
|
||||
},
|
||||
],
|
||||
{
|
||||
label_map: {
|
||||
ds: ['ds'],
|
||||
sum__num: ['sum__num'],
|
||||
'sum__num__1 week ago': ['sum__num__1 week ago'],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const chartProps = createEchartsTimeseriesTestChartProps<
|
||||
EchartsMixedTimeseriesFormData,
|
||||
EchartsMixedTimeseriesProps
|
||||
>({
|
||||
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
|
||||
defaultQueriesData: [queryAData, queriesData[1]],
|
||||
formData: {
|
||||
...formData,
|
||||
metrics: ['sum__num'],
|
||||
groupby: [],
|
||||
time_compare: ['1 week ago'],
|
||||
comparison_type: ComparisonType.Values,
|
||||
timeShiftColor: false,
|
||||
},
|
||||
queriesData: [queryAData, queriesData[1]],
|
||||
});
|
||||
|
||||
const transformed = transformProps(chartProps);
|
||||
const series = (transformed.echartOptions.series as SeriesOption[]) || [];
|
||||
|
||||
const derivedSeries = series.find(s => s.name === 'sum__num__1 week ago') as
|
||||
| (SeriesOption & { lineStyle?: { type?: number[] | string } })
|
||||
| undefined;
|
||||
|
||||
expect(derivedSeries).toBeDefined();
|
||||
expect(derivedSeries?.lineStyle?.type).toBeUndefined();
|
||||
});
|
||||
|
||||
+19
-15
@@ -44,6 +44,23 @@ import {
|
||||
import { PivotData, flatKey } from './utilities';
|
||||
import { Styles } from './Styles';
|
||||
|
||||
/**
|
||||
* Pivot keys are stringified on their way through `PivotData`, so a temporal
|
||||
* header holding an epoch timestamp arrives as e.g. "1700000000000". Coerce
|
||||
* such numeric strings back to numbers so temporal formatters (which expect
|
||||
* an epoch) render correctly. A bare four-digit string is the ISO 8601
|
||||
* year-only form ("2017"), which the shared `stringifyTimeInput` in core
|
||||
* reads as that calendar year; coercing it would turn the year into two
|
||||
* seconds past 1970, so it is passed through untouched.
|
||||
*/
|
||||
const toDateFormatterInput = (value: unknown): unknown =>
|
||||
typeof value === 'string' &&
|
||||
value.trim() !== '' &&
|
||||
!/^\d{4}$/.test(value.trim()) &&
|
||||
Number.isFinite(Number(value))
|
||||
? Number(value)
|
||||
: value;
|
||||
|
||||
type ClickCallback = (
|
||||
e: MouseEvent,
|
||||
value: unknown,
|
||||
@@ -989,15 +1006,9 @@ export function TableRenderer(props: TableRendererProps) {
|
||||
/>
|
||||
);
|
||||
};
|
||||
// Coerce numeric timestamp strings to numbers so temporal formatters
|
||||
// (which typically expect an epoch) render correctly.
|
||||
const rawHeaderCellValue = colKey[attrIdx];
|
||||
const headerCellFormatterValue =
|
||||
typeof rawHeaderCellValue === 'string' &&
|
||||
rawHeaderCellValue.trim() !== '' &&
|
||||
Number.isFinite(Number(rawHeaderCellValue))
|
||||
? Number(rawHeaderCellValue)
|
||||
: rawHeaderCellValue;
|
||||
toDateFormatterInput(rawHeaderCellValue);
|
||||
const headerCellFormattedValue =
|
||||
dateFormatters?.[attrName]?.(headerCellFormatterValue) ??
|
||||
rawHeaderCellValue;
|
||||
@@ -1263,14 +1274,7 @@ export function TableRenderer(props: TableRendererProps) {
|
||||
? toggleRowKey(flatRowKeySlice)
|
||||
: null;
|
||||
|
||||
// Coerce numeric timestamp strings to numbers so temporal formatters
|
||||
// (which typically expect an epoch) render correctly.
|
||||
const headerFormatterValue =
|
||||
typeof r === 'string' &&
|
||||
r.trim() !== '' &&
|
||||
Number.isFinite(Number(r))
|
||||
? Number(r)
|
||||
: r;
|
||||
const headerFormatterValue = toDateFormatterInput(r);
|
||||
const headerCellFormattedValue =
|
||||
dateFormatters?.[settingsRowAttrs[i]]?.(headerFormatterValue) ?? r;
|
||||
const isActiveHeader = valueCellClassName.includes('active');
|
||||
|
||||
+39
@@ -21,6 +21,7 @@ import type { ReactElement } from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { supersetTheme, ThemeProvider } from '@apache-superset/core/theme';
|
||||
import { getTimeFormatter } from '@superset-ui/core';
|
||||
import { TableRenderer } from '../../src/react-pivottable/TableRenderers';
|
||||
import {
|
||||
aggregatorTemplates,
|
||||
@@ -689,6 +690,44 @@ test('TableRenderer coerces numeric timestamp strings to numbers for row header
|
||||
expect(screen.getByText('row:red')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('TableRenderer passes four-digit year strings through to column header date formatters uncoerced', () => {
|
||||
const data = [
|
||||
{ shape: '2017', color: 'blue', value: 1 },
|
||||
{ shape: '1700000000000', color: 'blue', value: 2 },
|
||||
];
|
||||
const props = buildDefaultProps({
|
||||
data,
|
||||
rows: ['color'],
|
||||
cols: ['shape'],
|
||||
tableOptions: { dateFormatters: { shape: getTimeFormatter('%Y') } },
|
||||
});
|
||||
renderWithTheme(<TableRenderer {...props} />);
|
||||
|
||||
// "2017" is the ISO year-only form and must render as that year, not as
|
||||
// 2.017 seconds past the epoch; a stringified epoch still coerces.
|
||||
expect(screen.getByText('2017')).toBeInTheDocument();
|
||||
expect(screen.getByText('2023')).toBeInTheDocument();
|
||||
expect(screen.queryByText('1970')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('TableRenderer passes four-digit year strings through to row header date formatters uncoerced', () => {
|
||||
const data = [
|
||||
{ color: '2017', shape: 'circle', value: 1 },
|
||||
{ color: '1700000000000', shape: 'circle', value: 2 },
|
||||
];
|
||||
const props = buildDefaultProps({
|
||||
data,
|
||||
rows: ['color'],
|
||||
cols: ['shape'],
|
||||
tableOptions: { dateFormatters: { color: getTimeFormatter('%Y') } },
|
||||
});
|
||||
renderWithTheme(<TableRenderer {...props} />);
|
||||
|
||||
expect(screen.getByText('2017')).toBeInTheDocument();
|
||||
expect(screen.getByText('2023')).toBeInTheDocument();
|
||||
expect(screen.queryByText('1970')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('TableRenderer applies cellColorFormatters background and contrast color to column headers', () => {
|
||||
const cellColorFormatters = {
|
||||
shape: [
|
||||
|
||||
@@ -30,8 +30,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@math.gl/web-mercator": "^4.1.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"@types/supercluster": "^7.1.3",
|
||||
"mapbox-gl": "^3.29.0",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"maplibre-gl": "^6.8.0",
|
||||
"react-map-gl": "^8.1.2",
|
||||
"supercluster": "^9.0.0"
|
||||
},
|
||||
|
||||
@@ -103,6 +103,42 @@ const sortTypes = {
|
||||
alphanumeric: sortAlphanumericCaseInsensitive,
|
||||
};
|
||||
|
||||
// Prefer a stable identifier from original row data; otherwise use a deterministic
|
||||
// concatenation of visible values (keys sorted so the result does not depend on
|
||||
// column order).
|
||||
function stableRowKey<D extends object>(r: Row<D>): string {
|
||||
const orig = r.original as Record<string, unknown> | undefined;
|
||||
if (orig) {
|
||||
const idLike = orig.id ?? orig.ID ?? orig.key ?? orig.uuid;
|
||||
if (idLike != null) return String(idLike);
|
||||
}
|
||||
|
||||
// Fallback: derive from row.values, sorting the keys so that reordering the
|
||||
// columns does not change the key.
|
||||
const v = r.values as Record<string, unknown>;
|
||||
const keys = Object.keys(v).sort();
|
||||
return keys.map(k => String(v[k] ?? '')).join('|');
|
||||
}
|
||||
|
||||
// Very small, fast hash for strings (no crypto dependency).
|
||||
function hashString(s: string): string {
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i += 1) {
|
||||
// oxlint-disable-next-line unicorn/prefer-math-trunc -- | 0 is intentional for 32-bit integer wrapping in hash
|
||||
h = (h * 31 + s.charCodeAt(i)) | 0;
|
||||
}
|
||||
return String(h);
|
||||
}
|
||||
|
||||
function signatureOfRows<D extends object>(rs: Row<D>[]): string {
|
||||
const keys = rs.map(stableRowKey);
|
||||
const len = keys.length;
|
||||
const first = keys[0] ?? '';
|
||||
const last = keys[len - 1] ?? '';
|
||||
const digest = hashString(keys.join('\u0001')); // non-printable separator to avoid collisions
|
||||
return `${len}|${first}|${last}|${digest}`;
|
||||
}
|
||||
|
||||
// Be sure to pass our updateMyData and the skipReset option
|
||||
export default typedMemo(function DataTable<D extends object>({
|
||||
tableClassName,
|
||||
@@ -289,6 +325,46 @@ export default typedMemo(function DataTable<D extends object>({
|
||||
onFilteredDataChange(rowsRef.current, searchText);
|
||||
}, [filterValue, onFilteredDataChange, rowSignature]);
|
||||
|
||||
// Emit filtered rows to parent in client-side mode (debounced via RAF)
|
||||
const isMountedRef = useRef(true);
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const lastSigRef = useRef<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
if (serverPagination || typeof onFilteredRowsChange !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
const sig = signatureOfRows(rows);
|
||||
|
||||
if (sig !== lastSigRef.current) {
|
||||
lastSigRef.current = sig;
|
||||
if (rafRef.current != null) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
if (isMountedRef.current) {
|
||||
// Only emit originals when the signature truly changed
|
||||
onFilteredRowsChange(rows.map(r => r.original as D));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (rafRef.current != null) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [rows, serverPagination, onFilteredRowsChange]);
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(query: string) => {
|
||||
if (manualSearch && onSearchChange) {
|
||||
@@ -472,84 +548,6 @@ export default typedMemo(function DataTable<D extends object>({
|
||||
onServerPaginationChange(pageNumber, serverPageSize);
|
||||
}
|
||||
|
||||
// Emit filtered rows to parent in client-side mode (debounced via RAF)
|
||||
const isMountedRef = useRef(true);
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const lastSigRef = useRef<string>('');
|
||||
|
||||
// Prefer a stable identifier from original row data; otherwise use a deterministic
|
||||
// concatenation of visible values (keys sorted so column order changes are detected).
|
||||
function stableRowKey<D extends object>(r: Row<D>): string {
|
||||
const orig = r.original as Record<string, unknown> | undefined;
|
||||
if (orig) {
|
||||
const idLike =
|
||||
(orig as any).id ??
|
||||
(orig as any).ID ??
|
||||
(orig as any).key ??
|
||||
(orig as any).uuid;
|
||||
if (idLike != null) return String(idLike);
|
||||
}
|
||||
|
||||
// Fallback: derive from row.values, but make it stable against column order changes.
|
||||
const v = r.values as Record<string, unknown>;
|
||||
const keys = Object.keys(v).sort(); // detect column order changes
|
||||
return keys.map(k => String(v[k] ?? '')).join('|');
|
||||
}
|
||||
|
||||
// Very small, fast hash for strings (no crypto dependency).
|
||||
function hashString(s: string): string {
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i += 1) {
|
||||
// oxlint-disable-next-line unicorn/prefer-math-trunc -- | 0 is intentional for 32-bit integer wrapping in hash
|
||||
h = (h * 31 + s.charCodeAt(i)) | 0;
|
||||
}
|
||||
return String(h);
|
||||
}
|
||||
|
||||
function signatureOfRows<D extends object>(rs: Row<D>[]): string {
|
||||
const keys = rs.map(stableRowKey);
|
||||
const len = keys.length;
|
||||
const first = keys[0] ?? '';
|
||||
const last = keys[len - 1] ?? '';
|
||||
const digest = hashString(keys.join('\u0001')); // non-printable separator to avoid collisions
|
||||
return `${len}|${first}|${last}|${digest}`;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (serverPagination || typeof onFilteredRowsChange !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
const sig = signatureOfRows(rows);
|
||||
|
||||
if (sig !== lastSigRef.current) {
|
||||
lastSigRef.current = sig;
|
||||
if (rafRef.current != null) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
if (isMountedRef.current) {
|
||||
// Only emit originals when the signature truly changed
|
||||
onFilteredRowsChange(rows.map(r => r.original as D));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (rafRef.current != null) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [rows, serverPagination, onFilteredRowsChange]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
extractTextFromHTML,
|
||||
TimeGranularity,
|
||||
forceHexAlpha,
|
||||
DateWithFormatter,
|
||||
} from '@superset-ui/core';
|
||||
import {
|
||||
styled,
|
||||
@@ -98,7 +99,6 @@ import { formatColumnValue } from './utils/formatValue';
|
||||
import { PAGE_SIZE_OPTIONS, SERVER_PAGE_SIZE_OPTIONS } from './consts';
|
||||
import { updateTableOwnState } from './DataTable/utils/externalAPIs';
|
||||
import getScrollBarSize from './DataTable/utils/getScrollBarSize';
|
||||
import DateWithFormatter from './utils/DateWithFormatter';
|
||||
|
||||
type ValueRange = [number, number];
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
SMART_DATE_ID,
|
||||
TimeFormats,
|
||||
TimeFormatter,
|
||||
DateWithFormatter,
|
||||
} from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import {
|
||||
@@ -47,7 +48,6 @@ import {
|
||||
|
||||
import { isEmpty, merge } from 'lodash-es';
|
||||
import isEqualColumns from './utils/isEqualColumns';
|
||||
import DateWithFormatter from './utils/DateWithFormatter';
|
||||
import {
|
||||
BasicColorFormatterType,
|
||||
DataColumnMeta,
|
||||
|
||||
@@ -1,62 +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 {
|
||||
DataRecordValue,
|
||||
normalizeTimestamp,
|
||||
TimeFormatFunction,
|
||||
} from '@superset-ui/core';
|
||||
|
||||
/**
|
||||
* Extended Date object with a custom formatter, and retains the original input
|
||||
* when the formatter is simple `String(..)`.
|
||||
*/
|
||||
export default class DateWithFormatter extends Date {
|
||||
formatter: TimeFormatFunction;
|
||||
|
||||
input: DataRecordValue;
|
||||
|
||||
constructor(
|
||||
input: DataRecordValue,
|
||||
{ formatter = String }: { formatter?: TimeFormatFunction } = {},
|
||||
) {
|
||||
let value = input;
|
||||
// assuming timestamps without a timezone is in UTC time
|
||||
if (typeof value === 'string') {
|
||||
value = normalizeTimestamp(value);
|
||||
}
|
||||
|
||||
super(value as string);
|
||||
|
||||
this.input = input;
|
||||
this.formatter = formatter;
|
||||
this.toString = (): string => {
|
||||
if (this.formatter === String) {
|
||||
return String(this.input);
|
||||
}
|
||||
// Values that are not parseable timestamps - durations such as
|
||||
// "00:01:54" or "0 days 00:01:54", for instance - produce an Invalid
|
||||
// Date, and formatting one renders as "NaN:NaN:NaN". Fall back to the
|
||||
// original value instead.
|
||||
if (Number.isNaN(this.getTime())) {
|
||||
return String(this.input);
|
||||
}
|
||||
return this.formatter ? this.formatter(this) : Date.toString.call(this);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -22,10 +22,10 @@ import {
|
||||
getSmallNumberFormatter,
|
||||
isProbablyHTML,
|
||||
sanitizeHtml,
|
||||
DateWithFormatter,
|
||||
} from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import { DataColumnMeta } from '../types';
|
||||
import DateWithFormatter from './DateWithFormatter';
|
||||
|
||||
/**
|
||||
* Format text for cell value.
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* 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 '@testing-library/jest-dom';
|
||||
import { Component, type ReactNode } from 'react';
|
||||
import { render, screen } from '@superset-ui/core/spec';
|
||||
import { CellProps, Column, HeaderProps } from 'react-table';
|
||||
import DataTable from '../../src/DataTable/DataTable';
|
||||
import { ProviderWrapper } from '../testHelpers';
|
||||
|
||||
type DataRow = {
|
||||
city: string;
|
||||
firstName: string;
|
||||
};
|
||||
|
||||
interface RenderErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface RenderErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
const columns: Column<DataRow>[] = [
|
||||
{
|
||||
Header: ({ column }: HeaderProps<DataRow>) => (
|
||||
<th data-column-name={column.id}>First name</th>
|
||||
),
|
||||
Cell: ({ value }: CellProps<DataRow>) => <td>{value}</td>,
|
||||
id: 'firstName',
|
||||
accessor: 'firstName' as never,
|
||||
},
|
||||
{
|
||||
Header: ({ column }: HeaderProps<DataRow>) => (
|
||||
<th data-column-name={column.id}>City</th>
|
||||
),
|
||||
Cell: ({ value }: CellProps<DataRow>) => <td>{value}</td>,
|
||||
id: 'city',
|
||||
accessor: 'city' as never,
|
||||
},
|
||||
];
|
||||
|
||||
const data: DataRow[] = [
|
||||
{ firstName: 'Michael', city: 'Paris' },
|
||||
{ firstName: 'Jordan', city: 'London' },
|
||||
];
|
||||
|
||||
// Turns a render-phase throw, such as a Rules of Hooks violation, into a
|
||||
// readable assertion instead of an unhandled error.
|
||||
class RenderErrorBoundary extends Component<
|
||||
RenderErrorBoundaryProps,
|
||||
RenderErrorBoundaryState
|
||||
> {
|
||||
state: RenderErrorBoundaryState = {
|
||||
hasError: false,
|
||||
};
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
// data-test is the configured testIdAttribute (see spec/helpers/setup.ts),
|
||||
// so *ByTestId('render-error') resolves this node.
|
||||
return <div data-test="render-error">Render error</div>;
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
const renderDataTable = (tableColumns: Column<DataRow>[]) => (
|
||||
<ProviderWrapper>
|
||||
<RenderErrorBoundary>
|
||||
<DataTable<DataRow>
|
||||
columns={tableColumns}
|
||||
data={data}
|
||||
rowCount={data.length}
|
||||
serverPagination={false}
|
||||
serverPaginationData={{}}
|
||||
onServerPaginationChange={jest.fn()}
|
||||
handleSortByChange={jest.fn()}
|
||||
sortByFromParent={[]}
|
||||
onSearchColChange={jest.fn()}
|
||||
searchOptions={[]}
|
||||
onFilteredRowsChange={jest.fn()}
|
||||
sticky={false}
|
||||
/>
|
||||
</RenderErrorBoundary>
|
||||
</ProviderWrapper>
|
||||
);
|
||||
|
||||
test('keeps the hook order stable when the columns disappear', () => {
|
||||
const { rerender } = render(renderDataTable(columns));
|
||||
|
||||
expect(screen.getByText('Michael')).toBeInTheDocument();
|
||||
|
||||
rerender(renderDataTable([]));
|
||||
|
||||
expect(screen.queryByTestId('render-error')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('No data found')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('keeps the hook order stable when the columns appear', () => {
|
||||
const { rerender } = render(renderDataTable([]));
|
||||
|
||||
expect(screen.getByText('No data found')).toBeInTheDocument();
|
||||
|
||||
rerender(renderDataTable(columns));
|
||||
|
||||
expect(screen.queryByTestId('render-error')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Michael')).toBeInTheDocument();
|
||||
});
|
||||
@@ -40,13 +40,13 @@ import {
|
||||
TimeGranularity,
|
||||
SMART_DATE_ID,
|
||||
getTimeFormatterForGranularity,
|
||||
DateWithFormatter,
|
||||
} from '@superset-ui/core';
|
||||
import { CellProps, Column, HeaderProps } from 'react-table';
|
||||
import DataTable from '../src/DataTable/DataTable';
|
||||
import TableChart, { sanitizeHeaderId } from '../src/TableChart';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import transformProps from '../src/transformProps';
|
||||
import DateWithFormatter from '../src/utils/DateWithFormatter';
|
||||
import testData from './testData';
|
||||
import { ProviderWrapper } from './testHelpers';
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* 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 { DateWithFormatter, getTimeFormatter } from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import { formatColumnValue } from '../../src/utils/formatValue';
|
||||
import { DataColumnMeta } from '../../src/types';
|
||||
|
||||
const formatter = getTimeFormatter('%H:%M:%S');
|
||||
|
||||
test('renders a duration cell through the column formatter without producing NaN', () => {
|
||||
// The cell text is produced by formatColumnValue, which hands the wrapped
|
||||
// value straight to the formatter rather than going through toString().
|
||||
const column: DataColumnMeta = {
|
||||
key: 'call_period',
|
||||
label: 'call_period',
|
||||
dataType: GenericDataType.Temporal,
|
||||
formatter,
|
||||
isNumeric: false,
|
||||
};
|
||||
const value = new DateWithFormatter('00:01:54', { formatter });
|
||||
|
||||
expect(formatColumnValue(column, value)).toEqual([false, '00:01:54']);
|
||||
});
|
||||
@@ -49,7 +49,7 @@
|
||||
"handlebars": "^4.7.9",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.18.1",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"maplibre-gl": "^6.8.0",
|
||||
"mousetrap": "^1.6.5",
|
||||
"ngeohash": "^0.6.4",
|
||||
"prop-types": "^15.8.1",
|
||||
|
||||
@@ -35,20 +35,30 @@ jest.mock('src/utils/cachedSupersetGet');
|
||||
// only need a stand-in that lets us trigger onDrillBy with a distinguishable
|
||||
// config, so we can assert ChartContextMenu wires it into the modal.
|
||||
jest.mock('../DrillBy/DrillBySubmenu', () => ({
|
||||
DrillBySubmenu: ({ onDrillBy }: any) => (
|
||||
<button
|
||||
type="button"
|
||||
data-test="fake-drill-by-submenu"
|
||||
onClick={() =>
|
||||
onDrillBy(
|
||||
{ column_name: 'city', groupby: true },
|
||||
{ id: 1, columns: [], metrics: [] },
|
||||
{ filters: [{ col: 'selected_scope' }], groupbyFieldName: 'groupby' },
|
||||
)
|
||||
}
|
||||
>
|
||||
Fake Drill By
|
||||
</button>
|
||||
DrillBySubmenu: ({ onDrillBy, dataset }: any) => (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
data-test="fake-drill-by-submenu"
|
||||
onClick={() =>
|
||||
onDrillBy(
|
||||
{ column_name: 'city', groupby: true },
|
||||
{ id: 1, columns: [], metrics: [] },
|
||||
{
|
||||
filters: [{ col: 'selected_scope' }],
|
||||
groupbyFieldName: 'groupby',
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
Fake Drill By
|
||||
</button>
|
||||
<div data-test="drillable-columns">
|
||||
{(dataset?.drillable_columns ?? [])
|
||||
.map((col: any) => col.column_name)
|
||||
.join(',')}
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -210,3 +220,35 @@ test('drill by modal uses the scope selected in the submenu over the raw context
|
||||
);
|
||||
expect(modalConfig.filters).toEqual([{ col: 'selected_scope' }]);
|
||||
});
|
||||
|
||||
test('drill by only offers dimension columns', async () => {
|
||||
// drill_info returns every column so the results grid can label non-dimension
|
||||
// ones; narrowing to dimensions is this component's job, not the API's.
|
||||
mockCachedSupersetGet.mockResolvedValue({
|
||||
response: {} as Response,
|
||||
json: {
|
||||
result: {
|
||||
columns: [
|
||||
{ column_name: 'city', verbose_name: 'City', groupby: true },
|
||||
{ column_name: 'revenue', verbose_name: 'Revenue', groupby: false },
|
||||
],
|
||||
metrics: [],
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
setup({
|
||||
drillBy: {
|
||||
filters: [{ col: 'raw_scope', op: '==', val: 'raw' }],
|
||||
groupbyFieldName: 'groupby',
|
||||
},
|
||||
});
|
||||
|
||||
userEvent.click(screen.getByTestId('open-context-menu'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('drillable-columns')).toHaveTextContent('city');
|
||||
});
|
||||
expect(screen.getByTestId('drillable-columns')).not.toHaveTextContent(
|
||||
'revenue',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -37,7 +37,6 @@ import {
|
||||
ensureIsArray,
|
||||
FeatureFlag,
|
||||
getChartMetadataRegistry,
|
||||
getExtensionsRegistry,
|
||||
isFeatureEnabled,
|
||||
QueryFormData,
|
||||
} from '@superset-ui/core';
|
||||
@@ -180,10 +179,6 @@ const ChartContextMenu = (
|
||||
[],
|
||||
);
|
||||
|
||||
const loadDrillByOptionsExtension = getExtensionsRegistry().get(
|
||||
'load.drillby.options',
|
||||
);
|
||||
|
||||
const handleCloseDrillByModal = useCallback(() => {
|
||||
setShowDrillByModal(false);
|
||||
}, []);
|
||||
@@ -234,8 +229,9 @@ const ChartContextMenu = (
|
||||
|
||||
const filteredColumns = ensureIsArray(dataset.columns).filter(
|
||||
column =>
|
||||
// If using an extension, also filter by column.groupby since the extension might not do this
|
||||
(!loadDrillByOptionsExtension || column.groupby) &&
|
||||
// Both the API and the extension return every column, since the same
|
||||
// payload resolves display labels elsewhere. Only dimensions are drillable.
|
||||
column.groupby &&
|
||||
!ensureIsArray(
|
||||
formData[filters?.drillBy?.groupbyFieldName ?? ''],
|
||||
).includes(column.column_name) &&
|
||||
@@ -257,7 +253,6 @@ const ChartContextMenu = (
|
||||
formData.x_axis,
|
||||
formData[enhancedFilters?.drillBy?.groupbyFieldName ?? ''],
|
||||
additionalConfig?.drillBy?.excludedColumns,
|
||||
loadDrillByOptionsExtension,
|
||||
]);
|
||||
|
||||
const showCrossFilters = isDisplayed(ContextMenuItem.CrossFilter);
|
||||
|
||||
+114
@@ -31,6 +31,17 @@ import downloadAsPdf from 'src/utils/downloadAsPdf';
|
||||
import SliceHeaderControls, { SliceHeaderControlsProps } from '.';
|
||||
|
||||
jest.mock('src/utils/cachedSupersetGet');
|
||||
jest.mock('src/explore/components/DataTablesPane', () => ({
|
||||
ResultsPaneOnDashboard: ({
|
||||
columnDisplayNames,
|
||||
}: {
|
||||
columnDisplayNames?: Record<string, string>;
|
||||
}) => (
|
||||
<div data-test="results-pane">
|
||||
{JSON.stringify(columnDisplayNames ?? {})}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
jest.mock('src/utils/downloadAsImage', () =>
|
||||
jest.fn(() => jest.fn().mockResolvedValue(undefined)),
|
||||
);
|
||||
@@ -709,6 +720,109 @@ test('Dataset drill info API call is not made when user lacks drill permissions'
|
||||
expect(mockCachedSupersetGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('Dataset drill info API call is made when user can only view chart as table', async () => {
|
||||
(global as any).featureFlags = {
|
||||
[FeatureFlag.DrillToDetail]: false,
|
||||
};
|
||||
const props = {
|
||||
...createProps(),
|
||||
supersetCanExplore: false,
|
||||
};
|
||||
// "View as table" has its own permission, so label resolution must not be
|
||||
// gated behind Drill to detail.
|
||||
renderWrapper(props, {
|
||||
Gamma: [
|
||||
['can_view_chart_as_table', 'Dashboard'],
|
||||
['can_get_drill_info', 'Dataset'],
|
||||
],
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockCachedSupersetGet).toHaveBeenCalledWith({
|
||||
endpoint: expect.stringContaining(
|
||||
'/api/v1/dataset/58/drill_info/?q=(dashboard_id:26)',
|
||||
),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('Dataset drill info API call is made for an explore-only user', async () => {
|
||||
(global as any).featureFlags = {
|
||||
[FeatureFlag.DrillToDetail]: false,
|
||||
};
|
||||
// "View as table" is offered to `canExplore || canViewTable`, so the fetch that
|
||||
// feeds its column headers has to cover the same set -- an explore user with
|
||||
// neither `can_samples` nor `can_view_chart_as_table` opens the same modal.
|
||||
renderWrapper(createProps(), {
|
||||
Gamma: [['can_get_drill_info', 'Dataset']],
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockCachedSupersetGet).toHaveBeenCalledWith({
|
||||
endpoint: expect.stringContaining(
|
||||
'/api/v1/dataset/58/drill_info/?q=(dashboard_id:26)',
|
||||
),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('Dataset drill info API call is not made without `can_get_drill_info`', async () => {
|
||||
(global as any).featureFlags = {
|
||||
[FeatureFlag.DrillToDetail]: false,
|
||||
};
|
||||
const props = {
|
||||
...createProps(),
|
||||
supersetCanExplore: false,
|
||||
};
|
||||
// The endpoint is guarded by `can_get_drill_info` on Dataset, so requesting
|
||||
// it without that permission would only ever produce a 403.
|
||||
renderWrapper(props, {
|
||||
Gamma: [['can_view_chart_as_table', 'Dashboard']],
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
expect(mockCachedSupersetGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('Results grid receives verbose names for a view-as-table-only user', async () => {
|
||||
(global as any).featureFlags = {
|
||||
[FeatureFlag.DrillToDetail]: false,
|
||||
};
|
||||
mockCachedSupersetGet.mockResolvedValue({
|
||||
response: {} as Response,
|
||||
json: {
|
||||
result: {
|
||||
columns: [{ column_name: 'region', verbose_name: 'Region' }],
|
||||
metrics: [{ metric_name: 'sum__num', verbose_name: 'Yearly Total' }],
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
const props = {
|
||||
...createProps(),
|
||||
supersetCanExplore: false,
|
||||
};
|
||||
renderWrapper(props, {
|
||||
Gamma: [
|
||||
['can_view_chart_as_table', 'Dashboard'],
|
||||
['can_get_drill_info', 'Dataset'],
|
||||
],
|
||||
});
|
||||
// Let the drill_info request settle the way it would while the dashboard loads.
|
||||
await waitFor(() => expect(mockCachedSupersetGet).toHaveBeenCalled());
|
||||
openMenu();
|
||||
userEvent.click(screen.getByTestId('view-query-menu-item'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
JSON.parse(screen.getByTestId('results-pane').textContent as string),
|
||||
).toEqual({
|
||||
region: 'Region',
|
||||
sum__num: 'Yearly Total',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('Should show "Embed code" in Share menu when feature flag is enabled and chart has data', async () => {
|
||||
window.featureFlags = {
|
||||
EMBEDDABLE_CHARTS: true,
|
||||
|
||||
@@ -196,13 +196,22 @@ const SliceHeaderControls = (
|
||||
.get(props.slice.viz_type)
|
||||
?.behaviors?.includes(Behavior.InteractiveChart);
|
||||
const canExplore = props.supersetCanExplore;
|
||||
const { canDrillToDetail, canViewQuery, canViewTable } = usePermissions();
|
||||
const { canDrillToDetail, canGetDrillInfo, canViewQuery, canViewTable } =
|
||||
usePermissions();
|
||||
|
||||
// Single predicate for the "View as table" entry, so the fetch that feeds its
|
||||
// column headers cannot drift from the set of users who can open it.
|
||||
const canViewResultsTable = canExplore || canViewTable;
|
||||
|
||||
// The dataset's verbose map resolves friendly Labels for both the drill-to-detail
|
||||
// pane and the results grid, and those are separate permissions — so fetch it for
|
||||
// either one, as long as the drill_info endpoint itself is readable (it is gated
|
||||
// by `can_get_drill_info` on Dataset).
|
||||
const datasetResource = useDatasetDrillInfo(
|
||||
props.slice.datasource,
|
||||
props.dashboardId,
|
||||
props.formData,
|
||||
!canDrillToDetail,
|
||||
!canGetDrillInfo || !(canDrillToDetail || canViewResultsTable),
|
||||
);
|
||||
|
||||
const datasetWithVerboseMap =
|
||||
@@ -594,7 +603,7 @@ const SliceHeaderControls = (
|
||||
});
|
||||
}
|
||||
|
||||
if (canExplore || canViewTable) {
|
||||
if (canViewResultsTable) {
|
||||
newMenuItems.push({
|
||||
key: MenuKeys.ViewResults,
|
||||
label: (
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* 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 { TextEncoder } from 'util';
|
||||
import {
|
||||
measureGuestToken,
|
||||
guestAuthenticationMessage,
|
||||
} from './guestTokenDiagnostics';
|
||||
|
||||
beforeAll(() => {
|
||||
Object.assign(global, { TextEncoder });
|
||||
});
|
||||
|
||||
test.each([
|
||||
[20, true],
|
||||
[21, false],
|
||||
[22, false],
|
||||
[null, false],
|
||||
[0, false],
|
||||
[-1, false],
|
||||
])('header budget %s: exceeded=%s', (budget, exceeded) => {
|
||||
expect(measureGuestToken('é'.repeat(3), 'X-Custom-É', budget)).toEqual({
|
||||
tokenBytes: 6,
|
||||
headerBytes: 21,
|
||||
headerBudgetBytes: budget && budget > 0 ? budget : null,
|
||||
headerBudgetExceeded: exceeded,
|
||||
});
|
||||
});
|
||||
|
||||
test('default header accounting and safe metadata only', () => {
|
||||
const size = measureGuestToken('secret-token', undefined, 1);
|
||||
expect(size.headerBytes).toBe(28);
|
||||
expect(JSON.stringify(size)).not.toContain('secret-token');
|
||||
expect(guestAuthenticationMessage(size)).toContain('may exceed');
|
||||
});
|
||||
|
||||
test('no size evidence uses generic authentication message', () => {
|
||||
expect(guestAuthenticationMessage()).not.toContain('may exceed');
|
||||
expect(guestAuthenticationMessage(measureGuestToken('t'))).not.toContain(
|
||||
'may exceed',
|
||||
);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['16384', null],
|
||||
['invalid', null],
|
||||
[true, null],
|
||||
[false, null],
|
||||
[[], null],
|
||||
[{}, null],
|
||||
[20.5, null],
|
||||
[NaN, null],
|
||||
[Infinity, null],
|
||||
[-Infinity, null],
|
||||
[2 ** 53, null],
|
||||
[Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER],
|
||||
[16384.0, 16384],
|
||||
])('normalizes configured budget %p to %p', (configured, expected) => {
|
||||
const size = measureGuestToken('t', undefined, configured);
|
||||
expect(size.headerBudgetBytes).toBe(expected);
|
||||
expect(size.headerBudgetExceeded).toBe(false);
|
||||
});
|
||||
|
||||
test.each([undefined, 400, 431, 494])(
|
||||
'uses header-size evidence for status %p',
|
||||
status => {
|
||||
expect(
|
||||
guestAuthenticationMessage(measureGuestToken('t', undefined, 1), {
|
||||
status,
|
||||
}),
|
||||
).toContain('may exceed');
|
||||
expect(
|
||||
guestAuthenticationMessage(measureGuestToken('t', undefined, 100), {
|
||||
status,
|
||||
}),
|
||||
).not.toContain('may exceed');
|
||||
},
|
||||
);
|
||||
|
||||
test.each([401, 403, 413, 500])('keeps status %p generic', status => {
|
||||
expect(
|
||||
guestAuthenticationMessage(measureGuestToken('t', undefined, 1), {
|
||||
status,
|
||||
}),
|
||||
).not.toContain('may exceed');
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 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 { t } from '@apache-superset/core/translation';
|
||||
|
||||
export type GuestTokenSize = {
|
||||
tokenBytes: number;
|
||||
headerBytes: number;
|
||||
headerBudgetBytes: number | null;
|
||||
headerBudgetExceeded: boolean;
|
||||
};
|
||||
|
||||
/** Measure the encoded token without decoding or retaining credentials. */
|
||||
export function measureGuestToken(
|
||||
token: string,
|
||||
headerName = 'X-GuestToken',
|
||||
budget?: unknown,
|
||||
): GuestTokenSize {
|
||||
const encoder = new TextEncoder();
|
||||
const tokenBytes = encoder.encode(token).length;
|
||||
// HTTP/1-style accounting: name + ": " + value + CRLF, not wire compression.
|
||||
const headerBytes = tokenBytes + encoder.encode(headerName).length + 4;
|
||||
const headerBudgetBytes =
|
||||
typeof budget === 'number' && Number.isSafeInteger(budget) && budget > 0
|
||||
? budget
|
||||
: null;
|
||||
return {
|
||||
tokenBytes,
|
||||
headerBytes,
|
||||
headerBudgetBytes,
|
||||
headerBudgetExceeded:
|
||||
headerBudgetBytes !== null && headerBytes > headerBudgetBytes,
|
||||
};
|
||||
}
|
||||
|
||||
/** Diagnose from size evidence only; never inspect or log proxy response bodies. */
|
||||
export function guestAuthenticationMessage(
|
||||
size?: GuestTokenSize,
|
||||
error?: unknown,
|
||||
): string {
|
||||
const status =
|
||||
typeof error === 'object' && error !== null && 'status' in error
|
||||
? error.status
|
||||
: undefined;
|
||||
// Explicit auth/server failures have other causes. Some non-JSON proxy
|
||||
// failures lose their status during parsing, so size remains the evidence.
|
||||
const possibleHeaderFailure =
|
||||
status === undefined || status === 400 || status === 431 || status === 494;
|
||||
return size?.headerBudgetExceeded && possibleHeaderFailure
|
||||
? t(
|
||||
'Embedded authentication failed. The guest token may exceed the request-header size limit. Reduce the token payload; large inline RLS lists can be replaced with an entitlements-table lookup.',
|
||||
)
|
||||
: t(
|
||||
'Something went wrong with embedded authentication. Check the dev console for details.',
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,14 @@
|
||||
*/
|
||||
// Mark this file as a module so its top-level declarations stay file-scoped
|
||||
// (the file has no imports; modules are loaded via require() inside tests).
|
||||
export {};
|
||||
import { TextEncoder } from 'util';
|
||||
|
||||
Object.assign(global, { TextEncoder });
|
||||
|
||||
const mockConfig = {
|
||||
GUEST_TOKEN_HEADER_NAME: 'X-Custom-Guest',
|
||||
GUEST_TOKEN_HEADER_MAX_BYTES: 100,
|
||||
};
|
||||
|
||||
// Stable mock references so they survive jest.resetModules() between tests
|
||||
// (a factory-created jest.fn() would otherwise be replaced on each reset,
|
||||
@@ -59,13 +66,14 @@ jest.mock('src/components/UiConfigContext', () => ({
|
||||
|
||||
// Capture the guestToken handler that start() is wired to, so tests can
|
||||
// re-trigger the handshake and assert the retry behavior.
|
||||
const mockSwitchboardInit = jest.fn();
|
||||
const mockSwitchboard = {
|
||||
handler: undefined as ((arg: { guestToken: string }) => void) | undefined,
|
||||
};
|
||||
jest.mock('@superset-ui/switchboard', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
init: jest.fn(),
|
||||
init: mockSwitchboardInit,
|
||||
start: jest.fn(),
|
||||
defineMethod: (name: string, fn: (arg: { guestToken: string }) => void) => {
|
||||
if (name === 'guestToken') {
|
||||
@@ -76,7 +84,8 @@ jest.mock('@superset-ui/switchboard', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('src/setup/setupClient', () => jest.fn(), { virtual: true });
|
||||
const mockSetupClient = jest.fn();
|
||||
jest.mock('src/setup/setupClient', () => mockSetupClient, { virtual: true });
|
||||
|
||||
jest.mock('src/views/store', () => ({
|
||||
store: {
|
||||
@@ -113,6 +122,7 @@ jest.mock('react-dom/client', () => ({
|
||||
jest.mock('src/utils/getBootstrapData', () => ({
|
||||
__esModule: true,
|
||||
default: () => ({
|
||||
config: mockConfig,
|
||||
embedded: { dashboard_id: '123', allowed_domains: [] },
|
||||
common: {
|
||||
application_root: '/',
|
||||
@@ -147,55 +157,148 @@ function sendHandshake() {
|
||||
);
|
||||
}
|
||||
|
||||
describe('embedded/index.tsx', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
mockSwitchboard.handler = undefined;
|
||||
mockSetupPlugins.mockReset();
|
||||
mockSetupAGGridModules.mockReset();
|
||||
mockLogging.error.mockClear();
|
||||
mockGetMeWithRole.mockReset();
|
||||
mockGetMeWithRole.mockResolvedValue({ result: { roles: {} } });
|
||||
document.body.innerHTML = '<div id="app"></div>';
|
||||
});
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
mockSwitchboard.handler = undefined;
|
||||
mockSetupPlugins.mockReset();
|
||||
mockSetupAGGridModules.mockReset();
|
||||
mockLogging.error.mockClear();
|
||||
mockGetMeWithRole.mockReset();
|
||||
mockGetMeWithRole.mockResolvedValue({ result: { roles: {} } });
|
||||
document.body.innerHTML = '<div id="app"></div>';
|
||||
});
|
||||
|
||||
test('initializes AG Grid modules on bootstrap', async () => {
|
||||
mockSetupPlugins.mockImplementation(() => undefined);
|
||||
require('./index');
|
||||
await flush();
|
||||
|
||||
expect(mockSetupAGGridModules).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('retries plugin setup after setupPlugins rejects, then bootstraps the user', async () => {
|
||||
// First plugin setup throws; the second attempt (after a re-handshake) succeeds.
|
||||
mockSetupPlugins
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('setupPlugins failed');
|
||||
})
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
require('./index');
|
||||
await flush();
|
||||
|
||||
test('initializes AG Grid modules on bootstrap', async () => {
|
||||
mockSetupPlugins.mockImplementation(() => undefined);
|
||||
require('./index');
|
||||
await flush();
|
||||
sendHandshake();
|
||||
expect(mockSwitchboard.handler).toBeDefined();
|
||||
|
||||
expect(mockSetupAGGridModules).toHaveBeenCalled();
|
||||
});
|
||||
// First guest token: plugin setup rejects, start() resets the guard and
|
||||
// recreates pluginsReady so a retry can re-run setup.
|
||||
mockSwitchboard.handler!({ guestToken: 'token-1' });
|
||||
await flush();
|
||||
expect(mockLogging.error).toHaveBeenCalled();
|
||||
expect(mockGetMeWithRole).not.toHaveBeenCalled();
|
||||
// The user gets a visible failure message rather than a blank #app.
|
||||
expect(document.getElementById('app')!.innerHTML).toContain(
|
||||
'Something went wrong loading the dashboard',
|
||||
);
|
||||
|
||||
test('retries plugin setup after setupPlugins rejects, then bootstraps the user', async () => {
|
||||
// First plugin setup throws; the second attempt (after a re-handshake) succeeds.
|
||||
mockSetupPlugins
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('setupPlugins failed');
|
||||
})
|
||||
.mockImplementation(() => undefined);
|
||||
// Second guest token retries: plugin setup now succeeds and the user loads.
|
||||
mockSwitchboard.handler!({ guestToken: 'token-2' });
|
||||
await flush();
|
||||
expect(mockSetupPlugins).toHaveBeenCalledTimes(2);
|
||||
expect(mockGetMeWithRole).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each([
|
||||
['short', { status: 400, text: '<html>proxy error</html>' }, false],
|
||||
['short', { status: 401 }, false],
|
||||
['x'.repeat(100), { status: 401 }, false],
|
||||
['x'.repeat(100), { status: 500 }, false],
|
||||
['x'.repeat(100), new SyntaxError('private response body'), true],
|
||||
])(
|
||||
'authentication failure uses size evidence, not response content',
|
||||
async (token, error, targeted) => {
|
||||
mockGetMeWithRole.mockRejectedValue(error);
|
||||
require('./index');
|
||||
await flush();
|
||||
|
||||
sendHandshake();
|
||||
expect(mockSwitchboard.handler).toBeDefined();
|
||||
|
||||
// First guest token: plugin setup rejects, start() resets the guard and
|
||||
// recreates pluginsReady so a retry can re-run setup.
|
||||
mockSwitchboard.handler!({ guestToken: 'token-1' });
|
||||
mockSwitchboard.handler!({ guestToken: token });
|
||||
await flush();
|
||||
expect(mockLogging.error).toHaveBeenCalled();
|
||||
expect(mockGetMeWithRole).not.toHaveBeenCalled();
|
||||
// The user gets a visible failure message rather than a blank #app.
|
||||
expect(document.getElementById('app')!.innerHTML).toContain(
|
||||
'Something went wrong loading the dashboard',
|
||||
expect(
|
||||
document.getElementById('app')!.textContent?.includes('may exceed'),
|
||||
).toBe(targeted);
|
||||
expect(JSON.stringify(mockLogging.error.mock.calls)).not.toContain(
|
||||
'private response body',
|
||||
);
|
||||
expect(JSON.stringify(mockLogging.error.mock.calls)).not.toContain(
|
||||
'<html>',
|
||||
);
|
||||
// Failed authentication still permits the existing retry.
|
||||
mockGetMeWithRole.mockResolvedValue({ result: { roles: {} } });
|
||||
mockSwitchboard.handler!({ guestToken: 'replacement' });
|
||||
await flush();
|
||||
expect(mockGetMeWithRole).toHaveBeenCalledTimes(2);
|
||||
},
|
||||
);
|
||||
|
||||
// Second guest token retries: plugin setup now succeeds and the user loads.
|
||||
mockSwitchboard.handler!({ guestToken: 'token-2' });
|
||||
await flush();
|
||||
expect(mockSetupPlugins).toHaveBeenCalledTimes(2);
|
||||
expect(mockGetMeWithRole).toHaveBeenCalled();
|
||||
});
|
||||
test('oversized refresh updates diagnostics without restarting successful auth', async () => {
|
||||
mockLogging.warn.mockClear();
|
||||
require('./index');
|
||||
await flush();
|
||||
sendHandshake();
|
||||
mockSwitchboard.handler!({ guestToken: 'short' });
|
||||
await flush();
|
||||
mockSwitchboard.handler!({ guestToken: 'x'.repeat(100) });
|
||||
await flush();
|
||||
expect(mockGetMeWithRole).toHaveBeenCalledTimes(1);
|
||||
expect(mockLogging.warn).toHaveBeenLastCalledWith(
|
||||
'Guest token exceeds configured request-header budget',
|
||||
{
|
||||
tokenBytes: 100,
|
||||
headerBytes: 118,
|
||||
headerBudgetBytes: 100,
|
||||
headerBudgetExceeded: true,
|
||||
},
|
||||
);
|
||||
expect(mockSetupClient).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
guestToken: 'x'.repeat(100),
|
||||
guestTokenHeaderName: 'X-Custom-Guest',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('refresh during pending authentication does not misattribute size evidence', async () => {
|
||||
let rejectRequest: (error: unknown) => void = () => {};
|
||||
mockGetMeWithRole.mockReturnValue(
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectRequest = reject;
|
||||
}),
|
||||
);
|
||||
require('./index');
|
||||
await flush();
|
||||
sendHandshake();
|
||||
mockSwitchboard.handler!({ guestToken: 'x'.repeat(100) });
|
||||
await flush();
|
||||
mockSwitchboard.handler!({ guestToken: 'short' });
|
||||
rejectRequest({ status: 400 });
|
||||
await flush();
|
||||
expect(document.getElementById('app')!.textContent).not.toContain(
|
||||
'may exceed',
|
||||
);
|
||||
expect(mockGetMeWithRole).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Clearing the guard after failure lets a subsequent token retry authentication.
|
||||
mockGetMeWithRole.mockResolvedValue({ result: { roles: {} } });
|
||||
mockSwitchboard.handler!({ guestToken: 'retry' });
|
||||
await flush();
|
||||
expect(mockGetMeWithRole).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('Switchboard does not log credential-bearing message bodies', async () => {
|
||||
require('./index');
|
||||
await flush();
|
||||
sendHandshake();
|
||||
expect(mockSwitchboardInit).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ debug: false }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -49,6 +49,11 @@ import {
|
||||
import { embeddedApi } from './api';
|
||||
import { getDataMaskChangeTrigger } from './utils';
|
||||
import { validateMessageEvent } from './originValidation';
|
||||
import {
|
||||
measureGuestToken,
|
||||
guestAuthenticationMessage,
|
||||
GuestTokenSize,
|
||||
} from './guestTokenDiagnostics';
|
||||
|
||||
// Defer plugin setup until after the language pack loads to prevent t() calls in
|
||||
// plugin control panel configs from being cached in English before translations are ready.
|
||||
@@ -177,6 +182,7 @@ if (!window.parent || window.parent === window) {
|
||||
let displayedUnauthorizedToast = false;
|
||||
let root: Root | null = null;
|
||||
let started = false;
|
||||
let guestTokenSize: GuestTokenSize | undefined;
|
||||
|
||||
/**
|
||||
* If there is a problem with the guest token, we will start getting
|
||||
@@ -209,8 +215,10 @@ function start() {
|
||||
endpoint: '/api/v1/me/roles/',
|
||||
});
|
||||
return pluginsReady.then(
|
||||
() =>
|
||||
getMeWithRole().then(
|
||||
() => {
|
||||
// Snapshot at dispatch, not at handshake: plugin loading can overlap refresh.
|
||||
const requestTokenSize = guestTokenSize;
|
||||
return getMeWithRole().then(
|
||||
({ result }) => {
|
||||
// fill in some missing bootstrap data
|
||||
// (because at pageload, we don't have any auth yet)
|
||||
@@ -225,18 +233,18 @@ function start() {
|
||||
}
|
||||
root.render(<EmbeddedApp />);
|
||||
},
|
||||
err => {
|
||||
(error: unknown) => {
|
||||
// something is most likely wrong with the guest token; reset the guard
|
||||
// so a rehandshake with a valid token can retry.
|
||||
logging.error(err);
|
||||
showFailureMessage(
|
||||
t(
|
||||
'Something went wrong with embedded authentication. Check the dev console for details.',
|
||||
),
|
||||
);
|
||||
// A refresh while the request is in flight makes attribution ambiguous.
|
||||
const size =
|
||||
requestTokenSize === guestTokenSize ? requestTokenSize : undefined;
|
||||
logging.error('Embedded authentication failed', size);
|
||||
showFailureMessage(guestAuthenticationMessage(size, error));
|
||||
started = false;
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
err => {
|
||||
// setupPlugins() or setupCodeOverrides() threw while preparing plugins;
|
||||
// reset the guard and recreate pluginsReady so a retry actually re-runs
|
||||
@@ -258,6 +266,17 @@ function start() {
|
||||
* Configures SupersetClient with the correct settings for the embedded dashboard page.
|
||||
*/
|
||||
function setupGuestClient(guestToken: string) {
|
||||
guestTokenSize = measureGuestToken(
|
||||
guestToken,
|
||||
bootstrapData.config?.GUEST_TOKEN_HEADER_NAME,
|
||||
bootstrapData.config?.GUEST_TOKEN_HEADER_MAX_BYTES,
|
||||
);
|
||||
if (guestTokenSize.headerBudgetExceeded) {
|
||||
logging.warn(
|
||||
'Guest token exceeds configured request-header budget',
|
||||
guestTokenSize,
|
||||
);
|
||||
}
|
||||
setupClient({
|
||||
appRoot: applicationRoot(),
|
||||
guestToken,
|
||||
@@ -268,18 +287,19 @@ function setupGuestClient(guestToken: string) {
|
||||
|
||||
window.addEventListener('message', function embeddedPageInitializer(event) {
|
||||
if (!validateMessageEvent(event, bootstrapData.embedded?.allowed_domains)) {
|
||||
log('ignoring message unrelated to embedded comms', event);
|
||||
log('ignoring message unrelated to embedded comms');
|
||||
return;
|
||||
}
|
||||
|
||||
const port = event.ports?.[0];
|
||||
if (event.data.handshake === 'port transfer' && port) {
|
||||
log('message port received', event);
|
||||
log('message port received');
|
||||
|
||||
Switchboard.init({
|
||||
port,
|
||||
name: 'superset',
|
||||
debug: debugMode,
|
||||
// Switchboard debug logs message bodies, including guest-token credentials.
|
||||
debug: false,
|
||||
});
|
||||
|
||||
Switchboard.defineMethod(
|
||||
|
||||
+189
-11
@@ -35,7 +35,11 @@ import {
|
||||
OPERATOR_ENUM_TO_OPERATOR_TYPE,
|
||||
} from 'src/explore/constants';
|
||||
import AdhocMetric from 'src/explore/components/controls/MetricControl/AdhocMetric';
|
||||
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
||||
import {
|
||||
FeatureFlag,
|
||||
isFeatureEnabled,
|
||||
SupersetClient,
|
||||
} from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import fetchMock from 'fetch-mock';
|
||||
|
||||
@@ -172,10 +176,22 @@ fetchMock.get(ADVANCED_DATA_TYPE_ENDPOINT_INVALID, {
|
||||
const mockStore = configureStore([thunk]);
|
||||
const store = mockStore({});
|
||||
|
||||
// Either a JSON body ({ result, limit }), a fetch-mock response config
|
||||
// ({ status, body } / { throws }) so a test can make the server fail, or a
|
||||
// per-call function for stateful routes. fetch-mock reads this lazily at
|
||||
// request-match time -- reassigning the variable changes what EARLIER,
|
||||
// still-unmatched requests resolve with -- so a test whose earlier request
|
||||
// must still be IN FLIGHT at reassignment time has to use the function
|
||||
// form. Once the earlier response has settled, plain reassignment is safe.
|
||||
let columnValuesResponse: unknown = { result: [], limit: 10000 };
|
||||
|
||||
let isFeatureEnabledMock: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.clearHistory();
|
||||
// Reset the shared route: a prior test's stateful function (with its
|
||||
// closed-over call counter) must not serve the next test's requests.
|
||||
columnValuesResponse = { result: [], limit: 10000 };
|
||||
isFeatureEnabledMock = mockedIsFeatureEnabled.mockImplementation(
|
||||
(featureFlag: FeatureFlag) =>
|
||||
featureFlag === FeatureFlag.EnableAdvancedDataTypes,
|
||||
@@ -955,14 +971,16 @@ test('filters the subject select by column verbose_name as well as column_name',
|
||||
const COLUMN_VALUES_ENDPOINT =
|
||||
'glob:*/api/v1/datasource/*/column/value/values/*';
|
||||
|
||||
let columnValues: { result: unknown[]; limit: number } = {
|
||||
result: [],
|
||||
limit: 10000,
|
||||
};
|
||||
fetchMock.get(COLUMN_VALUES_ENDPOINT, () => columnValues);
|
||||
// Route for COLUMN_VALUES_ENDPOINT; the response contract and its
|
||||
// lazy-read trap are documented at columnValuesResponse's declaration.
|
||||
fetchMock.get(COLUMN_VALUES_ENDPOINT, (...args: unknown[]) =>
|
||||
typeof columnValuesResponse === 'function'
|
||||
? columnValuesResponse(...args)
|
||||
: columnValuesResponse,
|
||||
);
|
||||
|
||||
const setupWithFilterValues = (result: unknown[], limit = 10000) => {
|
||||
columnValues = { result, limit };
|
||||
const setupWithFilterValuesResponse = (response: unknown) => {
|
||||
columnValuesResponse = response;
|
||||
const onChange = jest.fn();
|
||||
const validHandler = jest.fn();
|
||||
const spy = jest.spyOn(redux, 'useSelector');
|
||||
@@ -986,12 +1004,17 @@ const setupWithFilterValues = (result: unknown[], limit = 10000) => {
|
||||
partitionColumn: 'test',
|
||||
validHandler,
|
||||
};
|
||||
render(
|
||||
const { rerender } = render(
|
||||
<AdhocFilterEditPopoverSimpleTabContent {...(props as unknown as Props)} />,
|
||||
);
|
||||
return props;
|
||||
return { ...props, rerender };
|
||||
};
|
||||
|
||||
const setupWithFilterValues = (result: unknown[], limit = 10000) =>
|
||||
setupWithFilterValuesResponse({ result, limit });
|
||||
|
||||
const SUGGESTIONS_UNAVAILABLE = /Suggestions could not be loaded/;
|
||||
|
||||
const openComparator = async () => {
|
||||
const comparator = screen.getByRole('combobox', {
|
||||
name: 'Comparator option',
|
||||
@@ -1065,7 +1088,7 @@ test('stores the picked value, not the option object', async () => {
|
||||
test('can remove a value that was saved earlier', async () => {
|
||||
// Reopening the popover restores the comparator from the saved filter, and
|
||||
// the value is not in the freshly loaded page. Removing it has to still work.
|
||||
columnValues = { result: [], limit: 10000 };
|
||||
columnValuesResponse = { result: [], limit: 10000 };
|
||||
const onChange = jest.fn();
|
||||
const validHandler = jest.fn();
|
||||
jest.spyOn(redux, 'useSelector').mockReturnValue({});
|
||||
@@ -1115,3 +1138,158 @@ test('does not say the list is partial when it is complete', async () => {
|
||||
expect(await screen.findByTitle('alpha')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Only the first/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('says suggestions could not be loaded when the server fails', async () => {
|
||||
// A failed request used to render exactly like a column with no values,
|
||||
// which is how a 500 on every semantic view went unreported for months.
|
||||
const props = setupWithFilterValuesResponse({
|
||||
status: 500,
|
||||
body: { message: 'Fatal error' },
|
||||
});
|
||||
await openComparator();
|
||||
expect(await screen.findByText(SUGGESTIONS_UNAVAILABLE)).toBeInTheDocument();
|
||||
|
||||
// The note must not cost the user the way through: typing still works.
|
||||
const comparator = screen.getByRole('combobox', {
|
||||
name: 'Comparator option',
|
||||
});
|
||||
userEvent.type(comparator, 'typed-by-hand');
|
||||
userEvent.click(await screen.findByTitle('typed-by-hand'));
|
||||
await waitFor(() => expect(props.onChange).toHaveBeenCalled());
|
||||
const [filter] = props.onChange.mock.calls.at(-1);
|
||||
expect(filter.comparator).toEqual(['typed-by-hand']);
|
||||
});
|
||||
|
||||
test('says suggestions could not be loaded when the request gets no answer', async () => {
|
||||
// A network failure rejects with no response at all (the client retries
|
||||
// those itself, so it is stubbed above the transport).
|
||||
jest
|
||||
.spyOn(SupersetClient, 'get')
|
||||
.mockRejectedValueOnce(new TypeError('Failed to fetch'));
|
||||
setupWithFilterValues([]);
|
||||
await openComparator();
|
||||
expect(await screen.findByText(SUGGESTIONS_UNAVAILABLE)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('stays quiet when the server refuses the request', async () => {
|
||||
// A 4xx is the caller's problem, not an outage; the picker behaves as before.
|
||||
fetchMock.clearHistory();
|
||||
setupWithFilterValuesResponse({
|
||||
status: 400,
|
||||
body: { message: 'Column name value does not exist' },
|
||||
});
|
||||
await openComparator();
|
||||
await waitFor(() =>
|
||||
expect(fetchMock.callHistory.calls(COLUMN_VALUES_ENDPOINT)).toHaveLength(1),
|
||||
);
|
||||
expect(await screen.findByText('Type a value here')).toBeInTheDocument();
|
||||
expect(screen.queryByText(SUGGESTIONS_UNAVAILABLE)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows a plain empty list when the server has no values', async () => {
|
||||
fetchMock.clearHistory();
|
||||
setupWithFilterValues([]);
|
||||
await openComparator();
|
||||
await waitFor(() =>
|
||||
expect(fetchMock.callHistory.calls(COLUMN_VALUES_ENDPOINT)).toHaveLength(1),
|
||||
);
|
||||
expect(await screen.findByText('Type a value here')).toBeInTheDocument();
|
||||
expect(screen.queryByText(SUGGESTIONS_UNAVAILABLE)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('ignores a stale failing response that loses the race to a newer success', async () => {
|
||||
// The outage the note exists for is a slow endpoint -- which is exactly
|
||||
// when a failing response can resolve AFTER a newer search already
|
||||
// succeeded. The loser must not stamp its note over the winner.
|
||||
//
|
||||
// The route is STATEFUL (routed by call count): only the function form
|
||||
// (see the route comment above) makes the base request genuinely pend
|
||||
// while the newer one succeeds -- with a plain reassignment only one
|
||||
// request would ever exist and this test would pass with the staleness
|
||||
// guard deleted.
|
||||
let resolveSlowFailure: (value: unknown) => void = () => {};
|
||||
const firstPending = new Promise(resolve => {
|
||||
resolveSlowFailure = resolve;
|
||||
});
|
||||
let landedCalls = 0;
|
||||
setupWithFilterValuesResponse(() => {
|
||||
landedCalls += 1;
|
||||
return landedCalls === 1
|
||||
? firstPending
|
||||
: { result: ['alpha'], limit: 10000 };
|
||||
});
|
||||
const comparator = await openComparator();
|
||||
await waitFor(() => expect(landedCalls).toBe(1));
|
||||
|
||||
// A newer request succeeds while the first is still pending.
|
||||
userEvent.type(comparator, 'al');
|
||||
expect(
|
||||
await screen.findByTitle('alpha', {}, { timeout: 3000 }),
|
||||
).toBeInTheDocument();
|
||||
expect(landedCalls).toBe(2);
|
||||
|
||||
// Now the original request fails -- too late to matter. Flush it all the
|
||||
// way through explicitly: a waitFor on a negative assertion would pass
|
||||
// on the first tick, before the late rejection could land. (The single
|
||||
// macrotask assumes the rejection pipeline is microtask-only; after a
|
||||
// fetch-mock upgrade, re-run the guard-deleted control to reverify.)
|
||||
resolveSlowFailure({ status: 500, body: { message: 'Fatal error' } });
|
||||
await act(async () => {
|
||||
await firstPending;
|
||||
await new Promise(resolve => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
});
|
||||
expect(screen.queryByText(SUGGESTIONS_UNAVAILABLE)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not carry the note to a different column', async () => {
|
||||
const props = setupWithFilterValuesResponse({
|
||||
status: 500,
|
||||
body: { message: 'Fatal error' },
|
||||
});
|
||||
await openComparator();
|
||||
expect(await screen.findByText(SUGGESTIONS_UNAVAILABLE)).toBeInTheDocument();
|
||||
|
||||
// The parent applies a subject change by re-rendering with a new filter;
|
||||
// the note must reset with it -- the new column's own request decides what
|
||||
// is shown next.
|
||||
columnValuesResponse = { result: [], limit: 10000 };
|
||||
props.rerender(
|
||||
<AdhocFilterEditPopoverSimpleTabContent
|
||||
{...({
|
||||
...props,
|
||||
adhocFilter: new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'source',
|
||||
operatorId: Operators.In,
|
||||
operator: OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.In].operation,
|
||||
comparator: [],
|
||||
clause: Clauses.Where,
|
||||
}),
|
||||
} as unknown as Props)}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText(SUGGESTIONS_UNAVAILABLE)).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
test('drops the note once suggestions load again', async () => {
|
||||
setupWithFilterValuesResponse({
|
||||
status: 500,
|
||||
body: { message: 'Fatal error' },
|
||||
});
|
||||
const comparator = await openComparator();
|
||||
expect(await screen.findByText(SUGGESTIONS_UNAVAILABLE)).toBeInTheDocument();
|
||||
|
||||
// A new search term is a new request; the server is back. Safe as a
|
||||
// plain reassignment: the first response has fully settled (the note is
|
||||
// already on screen), so the lazy read cannot hand it this value.
|
||||
columnValuesResponse = { result: ['alpha'], limit: 10000 };
|
||||
userEvent.type(comparator, 'al');
|
||||
expect(
|
||||
await screen.findByTitle('alpha', {}, { timeout: 3000 }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(SUGGESTIONS_UNAVAILABLE)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
+63
-18
@@ -85,6 +85,16 @@ const SelectWithLabel = styled(AsyncSelect)<{ labelText: string }>`
|
||||
// AsyncSelect from asking for a second page.
|
||||
const COMPARATOR_PAGE_SIZE = 1_000_000;
|
||||
|
||||
// SupersetClient rejects with the raw Response, so a refused request carries
|
||||
// its status. A 4xx is the caller's problem (an unknown column, no access) and
|
||||
// is not an outage; a 5xx is the server's own failure, and no status at all
|
||||
// means the request got no answer (network failure, timeout). Only the last
|
||||
// two are "suggestions unavailable".
|
||||
const isSuggestionsOutage = (error: unknown): boolean => {
|
||||
const status = (error as { status?: unknown } | null)?.status;
|
||||
return typeof status !== 'number' || status >= 500;
|
||||
};
|
||||
|
||||
const toLabeledValue = (value: unknown): LabeledValue => ({
|
||||
value: value as LabeledValue['value'],
|
||||
label: optionLabel(value as null | number | boolean | string),
|
||||
@@ -386,6 +396,12 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
|
||||
const comparatorSelectRef = useRef<AsyncSelectRef>(null);
|
||||
const [loadedOptionCount, setLoadedOptionCount] = useState(0);
|
||||
const [optionsTruncated, setOptionsTruncated] = useState(false);
|
||||
const [suggestionsUnavailable, setSuggestionsUnavailable] = useState(false);
|
||||
// Identity of the newest suggestions request. A slow response that loses
|
||||
// the race -- a failing fetch resolving after a newer search succeeded, or
|
||||
// after the column changed -- must not stamp its outcome over the current
|
||||
// one, so every state write below is guarded on still being the latest.
|
||||
const comparatorRequestRef = useRef(0);
|
||||
const [hasFocusedComparator, setHasFocusedComparator] =
|
||||
useState<boolean>(false);
|
||||
|
||||
@@ -520,16 +536,20 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
|
||||
allowNewOptions: true,
|
||||
ariaLabel: t('Comparator option'),
|
||||
pageSize: COMPARATOR_PAGE_SIZE,
|
||||
// A capped list reads as the whole set unless it says otherwise, so an
|
||||
// absent value looks like a value that does not exist. Only shown when the
|
||||
// list is actually cut short.
|
||||
helperText: optionsTruncated
|
||||
? t(
|
||||
'Only the first %s values are listed. Type to search all of them, ' +
|
||||
'or enter a value that is not listed.',
|
||||
loadedOptionCount,
|
||||
)
|
||||
: undefined,
|
||||
// An empty list reads as "this column has no values" unless it says
|
||||
// otherwise, so a failed request has to say so -- that silence is how a
|
||||
// months-long 500 on every semantic view went unreported. Likewise a capped
|
||||
// list reads as the whole set, so an absent value looks like a value that
|
||||
// does not exist. Each note is only shown when it applies.
|
||||
helperText: suggestionsUnavailable
|
||||
? t('Suggestions could not be loaded. You can still type a value.')
|
||||
: optionsTruncated
|
||||
? t(
|
||||
'Only the first %s values are listed. Type to search all of them, ' +
|
||||
'or enter a value that is not listed.',
|
||||
loadedOptionCount,
|
||||
)
|
||||
: undefined,
|
||||
mode:
|
||||
operatorId && MULTI_OPERATORS.has(operatorId as Operators)
|
||||
? ('multiple' as const)
|
||||
@@ -581,6 +601,10 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
|
||||
return { data: [], totalCount: 0 };
|
||||
}
|
||||
|
||||
const requestId = comparatorRequestRef.current + 1;
|
||||
comparatorRequestRef.current = requestId;
|
||||
const isCurrent = () => comparatorRequestRef.current === requestId;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (arrayElements) {
|
||||
params.set('array_elements', 'true');
|
||||
@@ -615,8 +639,13 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
|
||||
};
|
||||
});
|
||||
|
||||
setLoadedOptionCount(data.length);
|
||||
setOptionsTruncated(isDefined(json.limit) && data.length >= json.limit);
|
||||
if (isCurrent()) {
|
||||
setLoadedOptionCount(data.length);
|
||||
setOptionsTruncated(
|
||||
isDefined(json.limit) && data.length >= json.limit,
|
||||
);
|
||||
setSuggestionsUnavailable(false);
|
||||
}
|
||||
|
||||
// The count has to exceed what was returned. AsyncSelect treats
|
||||
// `loaded >= totalCount` as "that is every value", sets allValuesLoaded
|
||||
@@ -624,10 +653,18 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
|
||||
// client-side -- which is the behaviour this whole change exists to
|
||||
// replace. Pagination is held off by COMPARATOR_PAGE_SIZE instead.
|
||||
return { data, totalCount: data.length + 1 };
|
||||
} catch {
|
||||
setLoadedOptionCount(0);
|
||||
setOptionsTruncated(false);
|
||||
return { data: [], totalCount: 0 };
|
||||
} catch (error) {
|
||||
if (isCurrent()) {
|
||||
setLoadedOptionCount(0);
|
||||
setOptionsTruncated(false);
|
||||
// The empty page keeps the dropdown, and with it the value the
|
||||
// user types, in place; the note says why the page is empty.
|
||||
setSuggestionsUnavailable(isSuggestionsOutage(error));
|
||||
}
|
||||
// The count has to exceed the page: an empty page reported as
|
||||
// complete (0 >= 0) makes AsyncSelect serve every later search from
|
||||
// it, so the server would never be asked again for this column.
|
||||
return { data: [], totalCount: 1 };
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -640,9 +677,17 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
|
||||
);
|
||||
|
||||
// Options are cached per search term inside AsyncSelect; a different column
|
||||
// or a switch to element-level suggestions invalidates all of them.
|
||||
// or a switch to element-level suggestions invalidates all of them, and a
|
||||
// note about the previous column's request with them.
|
||||
useEffect(() => {
|
||||
comparatorSelectRef.current?.clearCache();
|
||||
// The ref only carries the AsyncSelect handle while the suggestions
|
||||
// branch is mounted; a column without comparator options renders the
|
||||
// plain input, so the method itself is optional too.
|
||||
comparatorSelectRef.current?.clearCache?.();
|
||||
// Invalidate any in-flight request as well: a slow response for the
|
||||
// previous column must not resurface the note after this reset.
|
||||
comparatorRequestRef.current += 1;
|
||||
setSuggestionsUnavailable(false);
|
||||
}, [subjectString, arrayElements]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
+45
-1
@@ -16,7 +16,13 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { Preset, VizType } from '@superset-ui/core';
|
||||
import {
|
||||
ChartLabel,
|
||||
ChartMetadata,
|
||||
ChartPlugin,
|
||||
Preset,
|
||||
VizType,
|
||||
} from '@superset-ui/core';
|
||||
import {
|
||||
render,
|
||||
cleanup,
|
||||
@@ -47,12 +53,31 @@ jest.mock('scroll-into-view-if-needed', () => jest.fn());
|
||||
|
||||
jest.useFakeTimers({ advanceTimers: true });
|
||||
|
||||
// A minimal plugin carrying a "Featured" label, so tests can assert on the
|
||||
// badge that VizTypeGallery overlays on its thumbnail.
|
||||
class FeaturedTestChartPlugin extends ChartPlugin {
|
||||
constructor() {
|
||||
super({
|
||||
metadata: new ChartMetadata({
|
||||
name: 'Featured Test Chart',
|
||||
thumbnail: '',
|
||||
label: ChartLabel.Featured,
|
||||
tags: ['Featured'],
|
||||
}),
|
||||
Chart: () => null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class MainPreset extends Preset {
|
||||
constructor() {
|
||||
super({
|
||||
name: 'Legacy charts',
|
||||
plugins: [
|
||||
new TableChartPlugin().configure({ key: VizType.Table }),
|
||||
new FeaturedTestChartPlugin().configure({
|
||||
key: 'featured_test_chart',
|
||||
}),
|
||||
new BigNumberTotalChartPlugin().configure({
|
||||
key: VizType.BigNumberTotal,
|
||||
}),
|
||||
@@ -278,6 +303,25 @@ describe('VizTypeControl', () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('anchors the Featured badge to the bottom-right of the thumbnail image', async () => {
|
||||
// The badge is positioned relative to the thumbnail image only (not the
|
||||
// whole tile), so it must hang off the image's bottom-right corner
|
||||
// rather than its top edge.
|
||||
await waitForRenderWrapper();
|
||||
userEvent.click(screen.getByRole('tab', { name: 'All charts' }));
|
||||
|
||||
const visualizations = screen.getByTestId(getTestId('viz-row'));
|
||||
const image = await within(visualizations).findByAltText(
|
||||
'Featured Test Chart',
|
||||
);
|
||||
const badgeWrapper = image.nextElementSibling as HTMLElement;
|
||||
|
||||
expect(badgeWrapper).toHaveStyleRule('bottom', '4px');
|
||||
expect(badgeWrapper).toHaveStyleRule('right', '4px');
|
||||
expect(badgeWrapper).not.toHaveStyleRule('top', expect.anything());
|
||||
expect(within(badgeWrapper).getByText('FEATURED')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Thumbnail labels expose the full chart name via a title tooltip', async () => {
|
||||
// Labels are clamped to a fixed two-line block so every tile is the same
|
||||
// height; the full (possibly truncated) name must stay discoverable through
|
||||
|
||||
@@ -344,7 +344,7 @@ const ThumbnailImageWrapper = styled.div`
|
||||
const ThumbnailLabelWrapper = styled.div`
|
||||
position: absolute;
|
||||
right: ${({ theme }) => theme.sizeUnit}px;
|
||||
top: ${({ theme }) => theme.sizeUnit}px;
|
||||
bottom: ${({ theme }) => theme.sizeUnit}px;
|
||||
`;
|
||||
|
||||
const TitleLabelWrapper = styled.div`
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import { Dataset } from 'src/components/Chart/types';
|
||||
import {
|
||||
cachedSupersetGet,
|
||||
@@ -35,6 +36,11 @@ jest.mock('src/utils/cachedSupersetGet', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@apache-superset/core/utils', () => ({
|
||||
...jest.requireActual('@apache-superset/core/utils'),
|
||||
logging: { error: jest.fn(), warn: jest.fn() },
|
||||
}));
|
||||
|
||||
// Mock getExtensionsRegistry at module level - returns undefined by default
|
||||
const mockGetExtensionsRegistry = jest.fn(() => ({ get: () => undefined }));
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
@@ -43,6 +49,7 @@ jest.mock('@superset-ui/core', () => ({
|
||||
}));
|
||||
|
||||
const mockedCachedSupersetGet = jest.mocked(cachedSupersetGet);
|
||||
const mockedLoggingError = jest.mocked(logging.error);
|
||||
const mockedSupersetGetCacheDelete = jest.mocked(supersetGetCache.delete);
|
||||
const mockExtension = jest.fn();
|
||||
|
||||
@@ -141,6 +148,22 @@ test('createVerboseMap creates verbose_map from both columns and metrics', () =>
|
||||
});
|
||||
});
|
||||
|
||||
test('createVerboseMap lets a column win a name collision with a metric', () => {
|
||||
// A dataset can hold a column and a metric with the same name -- uniqueness is
|
||||
// only enforced within each list. Both end up in one flat map, so one label has
|
||||
// to win. Columns win, matching `SqlaTable.data_for_slices`, which builds the
|
||||
// verbose_map the dashboard's own charts already render with. Otherwise an
|
||||
// unused metric could relabel a column the chart actually selected.
|
||||
const dataset = {
|
||||
columns: [{ column_name: 'revenue', verbose_name: 'Revenue' }],
|
||||
metrics: [{ metric_name: 'revenue', verbose_name: 'Revenue %' }],
|
||||
} as Dataset;
|
||||
|
||||
const verboseMap = createVerboseMap(dataset);
|
||||
|
||||
expect(verboseMap).toEqual({ revenue: 'Revenue' });
|
||||
});
|
||||
|
||||
test('createVerboseMap handles undefined dataset', () => {
|
||||
const verboseMap = createVerboseMap(undefined);
|
||||
expect(verboseMap).toEqual({});
|
||||
@@ -317,6 +340,9 @@ test('useDatasetDrillInfo fetches dataset via extension when extension and formD
|
||||
mockExtension.mockResolvedValue({
|
||||
json: { result: mockDataset },
|
||||
} as any);
|
||||
mockedCachedSupersetGet.mockResolvedValue({
|
||||
json: { result: mockDataset },
|
||||
} as any);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetDrillInfo(123, 456, mockFormData),
|
||||
@@ -341,9 +367,94 @@ test('useDatasetDrillInfo fetches dataset via extension when extension and formD
|
||||
},
|
||||
});
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
// Verify cachedSupersetGet was NOT called (extension path bypasses REST API)
|
||||
expect(mockedCachedSupersetGet).not.toHaveBeenCalled();
|
||||
test('useDatasetDrillInfo labels come from the API even when the extension supplies drill-by options', async () => {
|
||||
setupExtensionMock();
|
||||
|
||||
const mockFormData = { viz_type: 'table', datasource: '123__table' };
|
||||
// The extension contract only covers drill-by options, so a conforming
|
||||
// implementation may return dimensions alone.
|
||||
const extensionResult = {
|
||||
id: 123,
|
||||
columns: [{ column_name: 'city', verbose_name: 'City', groupby: true }],
|
||||
};
|
||||
// The API is the only source that promises the whole dataset.
|
||||
const apiResult = {
|
||||
id: 123,
|
||||
columns: [
|
||||
{ column_name: 'city', verbose_name: 'City', groupby: true },
|
||||
{ column_name: 'revenue', verbose_name: 'Revenue', groupby: false },
|
||||
],
|
||||
metrics: [{ metric_name: 'sum__num', verbose_name: 'Yearly Total' }],
|
||||
};
|
||||
|
||||
mockExtension.mockResolvedValue({ json: { result: extensionResult } } as any);
|
||||
mockedCachedSupersetGet.mockResolvedValue({
|
||||
json: { result: apiResult },
|
||||
} as any);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetDrillInfo(123, 456, mockFormData),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toBe('complete');
|
||||
});
|
||||
|
||||
// Labels cover everything the chart may select...
|
||||
expect(result.current.result?.verbose_map).toEqual({
|
||||
city: 'City',
|
||||
revenue: 'Revenue',
|
||||
sum__num: 'Yearly Total',
|
||||
});
|
||||
// ...while drill-by options still come from the extension.
|
||||
expect(result.current.result?.columns).toEqual(extensionResult.columns);
|
||||
});
|
||||
|
||||
test('useDatasetDrillInfo keeps extension drill-by options when the label fetch fails', async () => {
|
||||
setupExtensionMock();
|
||||
|
||||
const mockFormData = { viz_type: 'table', datasource: '123__table' };
|
||||
const extensionResult = {
|
||||
id: 123,
|
||||
columns: [{ column_name: 'city', verbose_name: 'City', groupby: true }],
|
||||
};
|
||||
|
||||
mockExtension.mockResolvedValue({ json: { result: extensionResult } } as any);
|
||||
// A deployment may register the extension precisely because the REST endpoint
|
||||
// is unavailable to it. Drill-by works there today and must keep working.
|
||||
mockedCachedSupersetGet.mockRejectedValue(new Error('403'));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetDrillInfo(123, 456, mockFormData),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toBe('complete');
|
||||
});
|
||||
|
||||
expect(mockedCachedSupersetGet).toHaveBeenCalled();
|
||||
expect(result.current.result?.columns).toEqual(extensionResult.columns);
|
||||
expect(result.current.result?.verbose_map).toEqual({ city: 'City' });
|
||||
expect(result.current.error).toBeNull();
|
||||
// The failure is expected and handled in these deployments, so it must not
|
||||
// log an error on every dashboard load.
|
||||
expect(mockedLoggingError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('useDatasetDrillInfo logs when the API is the only source and it fails', async () => {
|
||||
// Without an extension the failure is fatal rather than best-effort, so it
|
||||
// still surfaces in the console.
|
||||
mockedCachedSupersetGet.mockRejectedValue(new Error('500'));
|
||||
|
||||
const { result } = renderHook(() => useDatasetDrillInfo(123, 456));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toBe('error');
|
||||
});
|
||||
|
||||
expect(mockedLoggingError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('useDatasetDrillInfo handles extension throwing error', async () => {
|
||||
|
||||
@@ -46,12 +46,17 @@ export const getDatasetId = (datasetId: string | number): number =>
|
||||
*/
|
||||
export const createVerboseMap = (dataset?: Dataset): Record<string, string> => {
|
||||
const verbose_map: Record<string, string> = {};
|
||||
ensureIsArray(dataset?.columns).forEach((column: Column) => {
|
||||
verbose_map[column.column_name] = column.verbose_name || column.column_name;
|
||||
});
|
||||
// A name can appear in both lists -- uniqueness is only enforced within each
|
||||
// one -- so metrics are written first and columns overwrite them. That matches
|
||||
// `SqlaTable.data_for_slices`, which builds the verbose map the dashboard's own
|
||||
// charts already render with, and keeps an unused metric from relabelling a
|
||||
// column the chart actually selected.
|
||||
ensureIsArray(dataset?.metrics).forEach((metric: Metric) => {
|
||||
verbose_map[metric.metric_name] = metric.verbose_name || metric.metric_name;
|
||||
});
|
||||
ensureIsArray(dataset?.columns).forEach((column: Column) => {
|
||||
verbose_map[column.column_name] = column.verbose_name || column.column_name;
|
||||
});
|
||||
return verbose_map;
|
||||
};
|
||||
|
||||
@@ -81,6 +86,23 @@ export const useDatasetDrillInfo = (
|
||||
});
|
||||
return;
|
||||
}
|
||||
// `bestEffort` callers recover from a failure themselves, so it is not worth
|
||||
// logging: a deployment that registers the drill-by extension because this
|
||||
// endpoint is unreachable would otherwise log on every dashboard load.
|
||||
const fetchDrillInfo = async ({ bestEffort = false } = {}) => {
|
||||
const endpoint = `/api/v1/dataset/${getDatasetId(datasetId)}/drill_info/?q=(dashboard_id:${dashboardId})`;
|
||||
try {
|
||||
const { json } = await cachedSupersetGet({ endpoint });
|
||||
return json.result;
|
||||
} catch (error) {
|
||||
if (!bestEffort) {
|
||||
logging.error('Failed to load dataset: ', error);
|
||||
}
|
||||
supersetGetCache.delete(endpoint);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchDataset = async () => {
|
||||
try {
|
||||
const numericDatasetId = getDatasetId(datasetId);
|
||||
@@ -88,6 +110,7 @@ export const useDatasetDrillInfo = (
|
||||
'load.drillby.options',
|
||||
);
|
||||
let result;
|
||||
let labelSource;
|
||||
|
||||
if (loadDrillByOptionsExtension && formData) {
|
||||
const response = await loadDrillByOptionsExtension(
|
||||
@@ -95,20 +118,23 @@ export const useDatasetDrillInfo = (
|
||||
formData,
|
||||
);
|
||||
result = response?.json?.result;
|
||||
} else {
|
||||
const endpoint = `/api/v1/dataset/${numericDatasetId}/drill_info/?q=(dashboard_id:${dashboardId})`;
|
||||
// The extension contract only covers drill-by options, so a conforming
|
||||
// implementation may omit metrics and non-dimension columns. Labels
|
||||
// come from the API, which is the only source that promises the whole
|
||||
// dataset. If it is unreachable -- a deployment may register the
|
||||
// extension precisely because it is -- fall back to what the extension
|
||||
// returned rather than breaking drill-by, which works there today.
|
||||
try {
|
||||
const { json } = await cachedSupersetGet({ endpoint });
|
||||
const { result: datasetResult } = json;
|
||||
result = datasetResult;
|
||||
} catch (error) {
|
||||
logging.error('Failed to load dataset: ', error);
|
||||
supersetGetCache.delete(endpoint);
|
||||
throw error;
|
||||
labelSource = await fetchDrillInfo({ bestEffort: true });
|
||||
} catch {
|
||||
labelSource = result;
|
||||
}
|
||||
} else {
|
||||
result = await fetchDrillInfo();
|
||||
labelSource = result;
|
||||
}
|
||||
|
||||
const verbose_map = createVerboseMap(result);
|
||||
const verbose_map = createVerboseMap(labelSource);
|
||||
|
||||
setResource({
|
||||
status: ResourceStatus.Complete,
|
||||
|
||||
@@ -90,6 +90,7 @@ export const usePermissions = () => {
|
||||
canDrill,
|
||||
canDrillBy,
|
||||
canDrillToDetail,
|
||||
canGetDrillInfo,
|
||||
canViewQuery,
|
||||
canViewTable,
|
||||
};
|
||||
|
||||
@@ -153,6 +153,20 @@ test('renders a select and a VizTypeGallery', async () => {
|
||||
expect(screen.getByText(/choose chart type/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not double up the vertical Steps icon-to-content gap', async () => {
|
||||
// antd 6 added its own icon->content gap on `.ant-steps-item-wrapper`
|
||||
// (column-gap), stacking on top of the pre-existing `margin-right` on
|
||||
// `.ant-steps-item-icon` and shifting every step's content to the right.
|
||||
const { container } = await renderComponent();
|
||||
const styledContainer = container.firstChild;
|
||||
expect(styledContainer).toHaveStyleRule('column-gap', '0', {
|
||||
target: '.ant-steps-item-wrapper',
|
||||
});
|
||||
expect(styledContainer).toHaveStyleRule('margin-right', '8px', {
|
||||
target: '.ant-steps-item-icon',
|
||||
});
|
||||
});
|
||||
|
||||
test('renders dataset help text when user lacks dataset write permissions', async () => {
|
||||
await renderComponent();
|
||||
expect(screen.queryByText('Add a dataset')).not.toBeInTheDocument();
|
||||
|
||||
@@ -141,6 +141,13 @@ const StyledContainer = styled.div`
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* antd 6 added its own icon->content gap on this flex wrapper
|
||||
(column-gap), on top of the .ant-steps-item-icon margin-right below,
|
||||
doubling the gap. Zero it out so the icon's margin is the only gap. */
|
||||
&&&& .ant-steps-item-wrapper {
|
||||
column-gap: 0;
|
||||
}
|
||||
|
||||
&&&& .ant-steps-item-icon {
|
||||
margin-right: ${theme.marginXS}px;
|
||||
width: ${theme.sizeUnit * 5}px;
|
||||
|
||||
@@ -181,7 +181,10 @@ export interface CommonBootstrapData {
|
||||
export interface BootstrapData {
|
||||
user?: BootstrapUser;
|
||||
common: CommonBootstrapData;
|
||||
config?: any;
|
||||
config?: {
|
||||
GUEST_TOKEN_HEADER_NAME?: string;
|
||||
GUEST_TOKEN_HEADER_MAX_BYTES?: number | null;
|
||||
};
|
||||
embedded?: {
|
||||
dashboard_id: string;
|
||||
// Domains allowed to embed this dashboard. An empty/undefined list means
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
import type { WorkBook } from 'xlsx';
|
||||
import { getNumberFormatterRegistry } from '@superset-ui/core';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import exportPivotExcel from './downloadAsPivotExcel';
|
||||
|
||||
const mockWriteFile = jest.fn();
|
||||
@@ -30,6 +31,14 @@ jest.mock('xlsx', () => {
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@apache-superset/core/utils', () => ({
|
||||
logging: { error: jest.fn() },
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
// Renders a single-row pivot table with the given cell values, runs the
|
||||
// export, and returns the resulting sheet so each test only has to state
|
||||
// its input cells and assertions.
|
||||
@@ -114,3 +123,13 @@ test('leaves date-shaped strings as text rather than reinterpreting them as date
|
||||
expect(sheet.B1).toMatchObject({ t: 's', v: '2024-01-01 13:45:30' });
|
||||
expect(sheet.C1).toMatchObject({ t: 's', v: 'not-a-date' });
|
||||
});
|
||||
|
||||
test('should log an error and return early when table element is not found', () => {
|
||||
jest.spyOn(document, 'querySelector').mockReturnValue(null);
|
||||
|
||||
exportPivotExcel('.non-existent-selector', 'test-file');
|
||||
|
||||
expect(logging.error as jest.Mock).toHaveBeenCalledWith(
|
||||
'[exportPivotExcel] No element found for selector: ".non-existent-selector"',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { getNumberFormatterRegistry } from '@superset-ui/core';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import { utils, writeFile } from 'xlsx';
|
||||
import type { WorkSheet } from 'xlsx';
|
||||
|
||||
@@ -65,6 +66,12 @@ export default function exportPivotExcel(
|
||||
fileName: string,
|
||||
) {
|
||||
const table = document.querySelector(tableSelector);
|
||||
if (!table) {
|
||||
logging.error(
|
||||
`[exportPivotExcel] No element found for selector: "${tableSelector}"`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// `raw: true` keeps every cell as the literal text rendered in the DOM.
|
||||
// Without it, SheetJS tries to infer numbers/dates from the displayed
|
||||
// string, which mangles values that were formatted using a non-US
|
||||
|
||||
+6
-7
@@ -184,9 +184,6 @@ class SupersetApp(Flask):
|
||||
app startup.
|
||||
"""
|
||||
try:
|
||||
# Import here to avoid circular import issues
|
||||
from superset.extensions import feature_flag_manager
|
||||
|
||||
# Check if database is up-to-date with migrations
|
||||
if not self._is_database_up_to_date():
|
||||
logger.info("Pending database migrations: run 'superset db upgrade'")
|
||||
@@ -194,11 +191,13 @@ class SupersetApp(Flask):
|
||||
|
||||
logger.info("Syncing configuration to database...")
|
||||
|
||||
# Register SQLA event listeners for tagging system
|
||||
if feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"):
|
||||
from superset.tags.core import register_sqla_event_listeners
|
||||
# Register SQLA event listeners for the tagging system. The
|
||||
# listeners that create tags check TAGGING_SYSTEM when they fire,
|
||||
# and the cleanup listeners must run regardless of the flag so a
|
||||
# deleted object never leaves orphaned `tagged_object` rows behind.
|
||||
from superset.tags.core import register_sqla_event_listeners
|
||||
|
||||
register_sqla_event_listeners()
|
||||
register_sqla_event_listeners()
|
||||
|
||||
# Seed system themes from configuration
|
||||
from superset.commands.theme.seed import SeedSystemThemesCommand
|
||||
|
||||
@@ -44,6 +44,26 @@ class DatabaseExistsValidationError(ValidationError):
|
||||
)
|
||||
|
||||
|
||||
class DatabaseUpdateUnsafeRebindError(ValidationError):
|
||||
"""
|
||||
Marshmallow validation error for an update that would change a
|
||||
database's effective connection destination while leaving the stored
|
||||
password/encrypted_extra/SSH tunnel credential masked.
|
||||
"""
|
||||
|
||||
def __init__(self, field_name: str = "sqlalchemy_uri") -> None:
|
||||
super().__init__(
|
||||
_(
|
||||
"This update would change the connection's effective "
|
||||
"destination (host/port, engine parameters, or SSH tunnel "
|
||||
"endpoint) while reusing the stored credential. Provide "
|
||||
"the real password (or SSH tunnel credential) to confirm "
|
||||
"a connection move."
|
||||
),
|
||||
field_name=field_name,
|
||||
)
|
||||
|
||||
|
||||
class DatabaseRequiredFieldValidationError(ValidationError):
|
||||
def __init__(self, field_name: str) -> None:
|
||||
super().__init__(
|
||||
@@ -174,6 +194,15 @@ class DatabaseSecurityUnsafeError(CommandInvalidError):
|
||||
message = _("Stopped an unsafe database connection")
|
||||
|
||||
|
||||
class DatabaseTestConnectionUnsafeRebindError(CommandInvalidError):
|
||||
message = _(
|
||||
"Testing this connection would change its effective destination "
|
||||
"(engine parameters or SSH tunnel endpoint) while reusing the stored "
|
||||
"password. Provide the real password to test a connection whose "
|
||||
"destination has changed."
|
||||
)
|
||||
|
||||
|
||||
class DatabaseTestConnectionDriverError(CommandInvalidError):
|
||||
message = _("Could not load database driver")
|
||||
|
||||
|
||||
@@ -22,7 +22,12 @@ from flask import current_app as app
|
||||
|
||||
from superset import db, security_manager
|
||||
from superset.commands.database.exceptions import DatabaseInvalidError
|
||||
from superset.commands.database.utils import add_permissions
|
||||
from superset.commands.database.utils import (
|
||||
add_permissions,
|
||||
engine_params_changed,
|
||||
ssh_tunnel_rebind_unsafe,
|
||||
uri_identity_changed,
|
||||
)
|
||||
from superset.commands.exceptions import ImportFailedError
|
||||
from superset.constants import PASSWORD_MASK
|
||||
from superset.databases.ssh_tunnel.models import SSHTunnel
|
||||
@@ -41,14 +46,19 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
def _connection_identity_changed(existing: Database, config: dict[str, Any]) -> bool:
|
||||
"""Whether the import points the database at a different endpoint."""
|
||||
try:
|
||||
stored = make_url_safe(existing.sqlalchemy_uri)._replace(password=None)
|
||||
incoming = make_url_safe(config["sqlalchemy_uri"])._replace(password=None)
|
||||
except DatabaseInvalidError:
|
||||
# An unparseable URI cannot be compared: treat it as a change so
|
||||
# stored secrets never survive onto it.
|
||||
if uri_identity_changed(existing.sqlalchemy_uri, config.get("sqlalchemy_uri")):
|
||||
return True
|
||||
return stored != incoming
|
||||
|
||||
# The URI's host/port aren't the whole story: `extra.engine_params`
|
||||
# (e.g. `connect_args.host`/`port`) is merged into the actual DBAPI
|
||||
# connect kwargs and can override them. An import that opens a live
|
||||
# connection (`add_permissions` -> `get_all_catalog_names`) with a
|
||||
# rehydrated stored password must not do so against a destination this
|
||||
# field silently redirected.
|
||||
submitted_extra = config.get("extra")
|
||||
if isinstance(submitted_extra, dict):
|
||||
submitted_extra = json.dumps(submitted_extra)
|
||||
return engine_params_changed(existing.extra, submitted_extra)
|
||||
|
||||
|
||||
def _refuse_stored_secret_reuse(existing: Database, config: dict[str, Any]) -> None:
|
||||
@@ -77,33 +87,13 @@ def _refuse_stored_secret_reuse(existing: Database, config: dict[str, Any]) -> N
|
||||
"connection to confirm the change."
|
||||
)
|
||||
|
||||
if ssh_tunnel := config.get("ssh_tunnel"):
|
||||
existing_tunnel = existing.ssh_tunnel
|
||||
if existing_tunnel and (
|
||||
ssh_tunnel.get("server_address") != existing_tunnel.server_address
|
||||
or ssh_tunnel.get("server_port") != existing_tunnel.server_port
|
||||
):
|
||||
has_fresh_credential = any(
|
||||
ssh_tunnel.get(field) not in (None, PASSWORD_MASK)
|
||||
for field in ("password", "private_key")
|
||||
)
|
||||
# A passphrase-protected private key's stored passphrase is a
|
||||
# secret in its own right: if the existing tunnel had one, a
|
||||
# repoint that supplies a fresh private_key but leaves
|
||||
# private_key_password masked/absent would keep the old
|
||||
# passphrase attached to the new key rather than requiring the
|
||||
# importer to confirm it too.
|
||||
stale_private_key_password = (
|
||||
existing_tunnel.private_key_password is not None
|
||||
and ssh_tunnel.get("private_key_password") in (None, PASSWORD_MASK)
|
||||
)
|
||||
if not has_fresh_credential or stale_private_key_password:
|
||||
raise ImportFailedError(
|
||||
f"Import would change the SSH tunnel endpoint of database "
|
||||
f"'{existing.database_name}' without providing new tunnel "
|
||||
"credentials. Re-enter the SSH tunnel credentials to "
|
||||
"confirm the change."
|
||||
)
|
||||
if ssh_tunnel_rebind_unsafe(existing.ssh_tunnel, config.get("ssh_tunnel")):
|
||||
raise ImportFailedError(
|
||||
f"Import would change the SSH tunnel endpoint of database "
|
||||
f"'{existing.database_name}' without providing new tunnel "
|
||||
"credentials. Re-enter the SSH tunnel credentials to "
|
||||
"confirm the change."
|
||||
)
|
||||
|
||||
|
||||
def import_database( # noqa: C901
|
||||
|
||||
@@ -26,13 +26,18 @@ from superset.commands.database.exceptions import (
|
||||
DatabaseSecurityUnsafeError,
|
||||
DatabaseTestConnectionDriverError,
|
||||
DatabaseTestConnectionUnexpectedError,
|
||||
DatabaseTestConnectionUnsafeRebindError,
|
||||
)
|
||||
from superset.commands.database.ssh_tunnel.exceptions import (
|
||||
SSHTunnelDatabasePortError,
|
||||
SSHTunnelHostKeyVerificationError,
|
||||
SSHTunnelingNotEnabledError,
|
||||
)
|
||||
from superset.commands.database.utils import ping
|
||||
from superset.commands.database.utils import (
|
||||
engine_params_changed,
|
||||
ping,
|
||||
ssh_tunnel_endpoint_changed,
|
||||
)
|
||||
from superset.daos.database import DatabaseDAO
|
||||
from superset.databases.utils import make_url_safe
|
||||
from superset.errors import ErrorLevel, SupersetErrorType
|
||||
@@ -65,6 +70,8 @@ class TestConnectionDatabaseCommand(BaseCommand):
|
||||
_model: Optional[Database] = None
|
||||
_context: dict[str, Any]
|
||||
_uri: str
|
||||
_identity_changed: bool
|
||||
_ssh_tunnel_endpoint_changed: bool
|
||||
|
||||
def __init__(self, data: dict[str, Any]):
|
||||
self._properties = data.copy()
|
||||
@@ -73,8 +80,27 @@ class TestConnectionDatabaseCommand(BaseCommand):
|
||||
self._model = DatabaseDAO.get_database_by_name(database_name)
|
||||
|
||||
uri = self._properties.get("sqlalchemy_uri", "")
|
||||
if self._model and uri == self._model.safe_sqlalchemy_uri():
|
||||
uri = self._model.sqlalchemy_uri_decrypted
|
||||
self._identity_changed = False
|
||||
self._ssh_tunnel_endpoint_changed = False
|
||||
if (model := self._model) is not None:
|
||||
# A stored password (and, below, encrypted_extra / SSH tunnel
|
||||
# credentials) must never be rehydrated onto a connection whose
|
||||
# final effective destination the requester can change. The
|
||||
# visible `sqlalchemy_uri` is only one part of that destination:
|
||||
# `extra.engine_params` (merged into the DBAPI connect kwargs,
|
||||
# e.g. `connect_args.host`/`port`) and the SSH tunnel endpoint
|
||||
# can both override it after this decision is made.
|
||||
self._ssh_tunnel_endpoint_changed = ssh_tunnel_endpoint_changed(
|
||||
model.ssh_tunnel, self._properties.get("ssh_tunnel")
|
||||
)
|
||||
self._identity_changed = (
|
||||
engine_params_changed(model.extra, self._properties.get("extra", "{}"))
|
||||
or self._ssh_tunnel_endpoint_changed
|
||||
)
|
||||
if uri == model.safe_sqlalchemy_uri():
|
||||
if self._identity_changed:
|
||||
raise DatabaseTestConnectionUnsafeRebindError()
|
||||
uri = model.sqlalchemy_uri_decrypted
|
||||
|
||||
url = make_url_safe(uri)
|
||||
|
||||
@@ -102,7 +128,7 @@ class TestConnectionDatabaseCommand(BaseCommand):
|
||||
"masked_encrypted_extra",
|
||||
"{}",
|
||||
)
|
||||
if self._model:
|
||||
if self._model and not self._identity_changed:
|
||||
serialized_encrypted_extra = (
|
||||
self._model.db_engine_spec.unmask_encrypted_extra(
|
||||
self._model.encrypted_extra,
|
||||
@@ -112,7 +138,12 @@ class TestConnectionDatabaseCommand(BaseCommand):
|
||||
|
||||
# collect SSH tunnel info
|
||||
ssh_tunnel_properties = self._properties.get("ssh_tunnel")
|
||||
if ssh_tunnel_properties and self._model and self._model.ssh_tunnel:
|
||||
if (
|
||||
ssh_tunnel_properties
|
||||
and self._model
|
||||
and self._model.ssh_tunnel
|
||||
and not self._ssh_tunnel_endpoint_changed
|
||||
):
|
||||
# unmask password while allowing for updated values
|
||||
ssh_tunnel_properties = unmask_password_info(
|
||||
ssh_tunnel_properties,
|
||||
|
||||
@@ -30,10 +30,18 @@ from superset.commands.database.exceptions import (
|
||||
DatabaseInvalidError,
|
||||
DatabaseNotFoundError,
|
||||
DatabaseUpdateFailedError,
|
||||
DatabaseUpdateUnsafeRebindError,
|
||||
MissingOAuth2TokenError,
|
||||
)
|
||||
from superset.commands.database.sync_permissions import SyncPermissionsCommand
|
||||
from superset.commands.database.utils import (
|
||||
engine_params_changed,
|
||||
ssh_tunnel_rebind_unsafe,
|
||||
uri_identity_changed,
|
||||
)
|
||||
from superset.constants import PASSWORD_MASK
|
||||
from superset.daos.database import DatabaseDAO
|
||||
from superset.databases.utils import make_url_safe
|
||||
from superset.exceptions import OAuth2RedirectError
|
||||
from superset.models.core import Database
|
||||
from superset.utils import json
|
||||
@@ -180,3 +188,82 @@ class UpdateDatabaseCommand(BaseCommand):
|
||||
database_name,
|
||||
):
|
||||
raise DatabaseInvalidError(exceptions=[DatabaseExistsValidationError()])
|
||||
|
||||
if self._model:
|
||||
self._check_no_unsafe_secret_rebind()
|
||||
|
||||
def _check_no_unsafe_secret_rebind(self) -> None:
|
||||
"""
|
||||
Refuse an update that changes the connection's effective destination
|
||||
(URI host/port, `extra.engine_params`, or the SSH tunnel endpoint)
|
||||
while leaving the corresponding stored secret masked.
|
||||
|
||||
Without this, an editor could silently redirect the real stored
|
||||
password/encrypted_extra/SSH tunnel credential to a different
|
||||
destination -- and since an update persists, every subsequent use of
|
||||
the database (by any user) would send the real secret there, not
|
||||
just the editor's own request.
|
||||
"""
|
||||
model = self._model
|
||||
assert model is not None
|
||||
|
||||
connection_identity_changed = False
|
||||
submitted_password: str | None = None
|
||||
|
||||
if "sqlalchemy_uri" in self._properties:
|
||||
submitted_uri = self._properties["sqlalchemy_uri"] or ""
|
||||
connection_identity_changed = uri_identity_changed(
|
||||
model.sqlalchemy_uri, submitted_uri
|
||||
)
|
||||
try:
|
||||
submitted_password = make_url_safe(submitted_uri).password
|
||||
except DatabaseInvalidError:
|
||||
submitted_password = None
|
||||
|
||||
if "extra" in self._properties and engine_params_changed(
|
||||
model.extra, self._properties["extra"]
|
||||
):
|
||||
connection_identity_changed = True
|
||||
|
||||
if connection_identity_changed:
|
||||
# The URI password is only one of the secrets that can silently
|
||||
# carry over onto a changed destination. `encrypted_extra` (e.g.
|
||||
# a service-account key or OAuth2 client secret) is reattached
|
||||
# unconditionally in `run()` via `unmask_encrypted_extra` unless
|
||||
# we catch it here -- gating on the URI password alone would
|
||||
# both miss that reuse when a fresh URI password is supplied,
|
||||
# and wrongly block engines that keep credentials entirely in
|
||||
# `encrypted_extra` and carry no URI password at all (BigQuery,
|
||||
# GSheets), since those never have a "fresh" URI password to
|
||||
# give.
|
||||
uri_password_reused = model.password is not None and submitted_password in (
|
||||
None,
|
||||
PASSWORD_MASK,
|
||||
)
|
||||
# encrypted_extra is a blob with per-field masks, so "reused"
|
||||
# means unmasking the submission against the stored value
|
||||
# changes nothing -- including not submitting it at all, which
|
||||
# leaves the old (real) value attached unchanged.
|
||||
encrypted_extra_reused = model.encrypted_extra not in (
|
||||
None,
|
||||
"",
|
||||
"{}",
|
||||
) and (
|
||||
"masked_encrypted_extra" not in self._properties
|
||||
or model.db_engine_spec.unmask_encrypted_extra(
|
||||
model.encrypted_extra,
|
||||
self._properties["masked_encrypted_extra"],
|
||||
)
|
||||
== model.encrypted_extra
|
||||
)
|
||||
if uri_password_reused or encrypted_extra_reused:
|
||||
raise DatabaseInvalidError(
|
||||
exceptions=[DatabaseUpdateUnsafeRebindError()]
|
||||
)
|
||||
|
||||
if "ssh_tunnel" in self._properties and ssh_tunnel_rebind_unsafe(
|
||||
model.ssh_tunnel, self._properties["ssh_tunnel"]
|
||||
):
|
||||
raise DatabaseInvalidError(
|
||||
exceptions=[DatabaseUpdateUnsafeRebindError(field_name="ssh_tunnel")]
|
||||
)
|
||||
|
||||
@@ -19,6 +19,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import sqlite3
|
||||
from contextlib import closing
|
||||
from typing import Any
|
||||
|
||||
from flask import current_app as app
|
||||
from flask_appbuilder.security.sqla.models import (
|
||||
@@ -30,14 +31,109 @@ from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from superset import security_manager
|
||||
from superset.commands.database.exceptions import DatabaseInvalidError
|
||||
from superset.constants import PASSWORD_MASK
|
||||
from superset.databases.ssh_tunnel.models import SSHTunnel
|
||||
from superset.databases.utils import make_url_safe
|
||||
from superset.db_engine_specs.base import GenericDBException
|
||||
from superset.models.core import Database
|
||||
from superset.security.manager import SupersetSecurityManager
|
||||
from superset.utils import json
|
||||
from superset.utils.core import timeout
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def uri_identity_changed(existing_uri: str | None, submitted_uri: str | None) -> bool:
|
||||
"""
|
||||
Whether two SQLAlchemy URIs differ once their password is stripped --
|
||||
i.e. whether the effective connection destination (driver, host, port,
|
||||
database, username, query params) changed.
|
||||
"""
|
||||
try:
|
||||
stored = make_url_safe(existing_uri or "")._replace(password=None)
|
||||
incoming = make_url_safe(submitted_uri or "")._replace(password=None)
|
||||
except DatabaseInvalidError:
|
||||
# An unparseable URI cannot be compared: treat it as a change so a
|
||||
# stored secret never survives onto it.
|
||||
return True
|
||||
return stored != incoming
|
||||
|
||||
|
||||
def engine_params_changed(
|
||||
existing_extra: str | None, submitted_extra: str | None
|
||||
) -> bool:
|
||||
"""
|
||||
Whether ``submitted_extra`` carries different ``engine_params`` than
|
||||
``existing_extra``.
|
||||
|
||||
``engine_params`` (in particular ``engine_params.connect_args``) is
|
||||
merged into the actual DBAPI connect kwargs, so it can override the
|
||||
host/port/etc. carried in the SQLAlchemy URI itself. Any caller that
|
||||
conditionally reattaches a stored secret (password, encrypted_extra, SSH
|
||||
tunnel credentials) based on the URI being unchanged must also check
|
||||
this, or the destination can be silently redirected while the real
|
||||
secret rides along.
|
||||
"""
|
||||
|
||||
def _engine_params(serialized_extra: str | None) -> dict[str, Any]:
|
||||
try:
|
||||
return json.loads(serialized_extra or "{}").get("engine_params", {})
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
# Unparseable/non-dict `extra` cannot be compared: treat it as a
|
||||
# change so a stored secret never rides along with input that
|
||||
# can't be verified to leave the connection identity untouched.
|
||||
return {"__unparseable__": True}
|
||||
|
||||
return _engine_params(submitted_extra) != _engine_params(existing_extra)
|
||||
|
||||
|
||||
def ssh_tunnel_endpoint_changed(
|
||||
existing_tunnel: SSHTunnel | None, submitted_tunnel: dict[str, Any] | None
|
||||
) -> bool:
|
||||
"""
|
||||
Whether a submitted SSH tunnel config points at a different endpoint
|
||||
than the stored tunnel it would otherwise inherit credentials from.
|
||||
"""
|
||||
if not submitted_tunnel or not existing_tunnel:
|
||||
return False
|
||||
return bool(
|
||||
submitted_tunnel.get("server_address") != existing_tunnel.server_address
|
||||
or submitted_tunnel.get("server_port") != existing_tunnel.server_port
|
||||
)
|
||||
|
||||
|
||||
def ssh_tunnel_rebind_unsafe(
|
||||
existing_tunnel: SSHTunnel | None, submitted_tunnel: dict[str, Any] | None
|
||||
) -> bool:
|
||||
"""
|
||||
Whether a submitted SSH tunnel config repoints the tunnel at a
|
||||
different endpoint without supplying credentials fresh enough to
|
||||
justify it -- i.e. whether carrying the stored tunnel secrets over
|
||||
onto this submission would be unsafe.
|
||||
"""
|
||||
if not ssh_tunnel_endpoint_changed(existing_tunnel, submitted_tunnel):
|
||||
return False
|
||||
|
||||
assert submitted_tunnel is not None
|
||||
assert existing_tunnel is not None
|
||||
|
||||
has_fresh_credential = any(
|
||||
submitted_tunnel.get(field) not in (None, PASSWORD_MASK)
|
||||
for field in ("password", "private_key")
|
||||
)
|
||||
# A passphrase-protected private key's stored passphrase is a secret in
|
||||
# its own right: if the existing tunnel had one, a repoint that
|
||||
# supplies a fresh private_key but leaves private_key_password
|
||||
# masked/absent would keep the old passphrase attached to the new key
|
||||
# rather than requiring the caller to confirm it too.
|
||||
stale_private_key_password = (
|
||||
existing_tunnel.private_key_password is not None
|
||||
and submitted_tunnel.get("private_key_password") in (None, PASSWORD_MASK)
|
||||
)
|
||||
return not has_fresh_credential or stale_private_key_password
|
||||
|
||||
|
||||
def ping(engine: Engine) -> bool:
|
||||
try:
|
||||
time_delta = app.config["TEST_DATABASE_CONNECTION_TIMEOUT"]
|
||||
|
||||
@@ -27,6 +27,10 @@ from superset.commands.database.exceptions import (
|
||||
InvalidEngineError,
|
||||
InvalidParametersError,
|
||||
)
|
||||
from superset.commands.database.utils import (
|
||||
engine_params_changed,
|
||||
ssh_tunnel_endpoint_changed,
|
||||
)
|
||||
from superset.daos.database import DatabaseDAO
|
||||
from superset.databases.utils import make_url_safe
|
||||
from superset.db_engine_specs import get_engine_spec
|
||||
@@ -90,11 +94,28 @@ class ValidateDatabaseParametersCommand(BaseCommand):
|
||||
event_logger.log_with_context(action="validation_error", engine=engine)
|
||||
raise InvalidParametersError(errors)
|
||||
|
||||
# A stored password/encrypted_extra/SSH tunnel credential must never
|
||||
# be rehydrated onto a connection whose final effective destination
|
||||
# the caller can change. `parameters` only covers what feeds into
|
||||
# `sqlalchemy_uri` here -- `extra.engine_params` (merged into the
|
||||
# actual DBAPI connect kwargs, e.g. `connect_args.host`/`port`) and
|
||||
# the SSH tunnel endpoint can both override it independently.
|
||||
identity_changed = False
|
||||
ssh_tunnel_changed = False
|
||||
if (model := self._model) is not None:
|
||||
ssh_tunnel_changed = ssh_tunnel_endpoint_changed(
|
||||
model.ssh_tunnel, self._properties.get("ssh_tunnel")
|
||||
)
|
||||
identity_changed = (
|
||||
engine_params_changed(model.extra, self._properties.get("extra", "{}"))
|
||||
or ssh_tunnel_changed
|
||||
)
|
||||
|
||||
serialized_encrypted_extra = self._properties.get(
|
||||
"masked_encrypted_extra",
|
||||
"{}",
|
||||
)
|
||||
if self._model:
|
||||
if self._model and not identity_changed:
|
||||
serialized_encrypted_extra = engine_spec.unmask_encrypted_extra(
|
||||
self._model.encrypted_extra,
|
||||
serialized_encrypted_extra,
|
||||
@@ -110,13 +131,35 @@ class ValidateDatabaseParametersCommand(BaseCommand):
|
||||
encrypted_extra,
|
||||
)
|
||||
if self._model and sqlalchemy_uri == self._model.safe_sqlalchemy_uri():
|
||||
if identity_changed:
|
||||
raise InvalidParametersError(
|
||||
[
|
||||
SupersetError(
|
||||
message=__(
|
||||
"Testing this connection would change its "
|
||||
"effective destination (engine parameters "
|
||||
"or SSH tunnel endpoint) while reusing the "
|
||||
"stored password. Provide the real "
|
||||
"password to test a connection whose "
|
||||
"destination has changed."
|
||||
),
|
||||
error_type=SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
|
||||
level=ErrorLevel.ERROR,
|
||||
)
|
||||
]
|
||||
)
|
||||
sqlalchemy_uri = self._model.sqlalchemy_uri_decrypted
|
||||
|
||||
# Forward the SSH tunnel into the connection test so that
|
||||
# tunnel-only databases are reached through the tunnel rather
|
||||
# than directly, mirroring the existing test_connection flow.
|
||||
ssh_tunnel_properties = self._properties.get("ssh_tunnel")
|
||||
if ssh_tunnel_properties and self._model and self._model.ssh_tunnel:
|
||||
if (
|
||||
ssh_tunnel_properties
|
||||
and self._model
|
||||
and self._model.ssh_tunnel
|
||||
and not ssh_tunnel_changed
|
||||
):
|
||||
ssh_tunnel_properties = unmask_password_info(
|
||||
ssh_tunnel_properties,
|
||||
self._model.ssh_tunnel,
|
||||
|
||||
@@ -555,19 +555,29 @@ def import_dataset( # noqa: C901
|
||||
except SupersetSecurityException as ex:
|
||||
raise DatasetAccessDeniedError() from ex
|
||||
|
||||
try:
|
||||
table_exists = dataset.database.has_table(
|
||||
Table(dataset.table_name, dataset.schema, dataset.catalog),
|
||||
)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
# MySQL doesn't play nice with GSheets table names
|
||||
logger.warning(
|
||||
"Couldn't check if table %s exists, assuming it does", dataset.table_name
|
||||
)
|
||||
table_exists = True
|
||||
# `has_table` opens a live connection to the target database to run a
|
||||
# schema-introspection query. Its result is only ever consulted below to
|
||||
# decide whether to call `load_data`, which itself is a no-op unless
|
||||
# `data_uri` is set - so for imports that don't carry inline data (the
|
||||
# common case when bulk-importing dataset *metadata*, e.g. hundreds of
|
||||
# datasets at once), this was an unconditional, unnecessary round trip
|
||||
# to every target database on every single dataset, and a major
|
||||
# contributor to bulk imports timing out.
|
||||
if data_uri:
|
||||
try:
|
||||
table_exists = dataset.database.has_table(
|
||||
Table(dataset.table_name, dataset.schema, dataset.catalog),
|
||||
)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
# MySQL doesn't play nice with GSheets table names
|
||||
logger.warning(
|
||||
"Couldn't check if table %s exists, assuming it does",
|
||||
dataset.table_name,
|
||||
)
|
||||
table_exists = True
|
||||
|
||||
if data_uri and (not table_exists or force_data):
|
||||
load_data(data_uri, dataset, dataset.database)
|
||||
if not table_exists or force_data:
|
||||
load_data(data_uri, dataset, dataset.database)
|
||||
|
||||
if user:
|
||||
from superset.subjects.utils import get_user_subject
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user