Compare commits

..
Author SHA1 Message Date
Enzo MartellucciandClaude Sonnet 5 50f4802bbf fix(dashboard): reconcile stale results tab in Chart Data modal
Address PR #43454 review feedback: reuse DataTablesPane's stale-tab
fallback so ResultsPaneOnDashboard doesn't render blank when a mixed
chart's active results tab disappears, and drop CSS rules now
redundant with the fullHeight prop.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 12:16:21 +02:00
Enzo Martellucci fd07663dc1 fix(dashboard): stretch Chart Data modal results grid to fill available height 2026-08-24 13:32:15 +02:00
Enzo Martellucci c3ed8b312d fix(security): surface extra_editors in dashboard/chart lists (#43392) 2026-08-24 11:49:29 +02:00
Elizabeth Thompson 9f505eb0cb fix(examples): replace deprecated timeseries_limit_metric with series_limit_metric (#43422) 2026-08-22 15:02:20 -07:00
Joe LiandClaude Sonnet 5 f2610e9dca fix(sqllab): default PostgreSQL port to 5432 in the dynamic connection form (#43351)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 15:59:33 -07:00
abdullah revehaandAbdullah Sahin 09bd8c98de feat(chart): enable cross-filter on temporal x-axis (bar/label click) (#42792)
Co-authored-by: Abdullah Sahin <you@example.comclear>
2026-08-21 15:45:06 -07:00
Aurimas Navardauskas 5812c0eab5 fix(mcp): fail closed when MCP_AUTH_FACTORY raises (#43329) 2026-08-21 15:43:51 -07:00
Mike BridgeandClaude Fable 5 5f3e34908c fix(charts): surface blocking alerts/reports when archiving a chart (#43402)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 19:33:32 -03:00
Evan RusackasandSuperset Dev 9781254363 fix(export): escape formula-triggering values consistently and bound post-processing inputs (#43396)
Co-authored-by: Superset Dev <dev@superset.apache.org>
2026-08-21 12:37:33 -07:00
65893113fe fix(import): tighten ownership and validation checks across asset importers (#43393)
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 12:35:33 -07:00
04017f3956 fix(sqllab): re-validate access against rendered SQL and tighten cache/permalink scoping (#43394)
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 12:35:06 -07:00
2b6044b624 fix: tighten SSRF validation, executor resolution, and cache scoping across reports/thumbnails (#43395)
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 12:34:37 -07:00
Evan RusackasandSuperset Dev aee98b527d fix(charts): escape untrusted strings before rendering into chart tooltips and popups (#43397)
Co-authored-by: Superset Dev <dev@superset.apache.org>
2026-08-21 12:31:31 -07:00
edda9d1bbb fix(frontend): tighten SQL Lab autorun scoping and HTML-rendering defaults (#43398)
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 12:31:08 -07:00
Joe LiandClaude Sonnet 5 03eac279e5 fix(explore): exclude permalink_key from chart URL params (#43354)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 11:43:18 -07:00
yousophandClaude Opus 4.8 fd7095df9a fix(explore): align viz type gallery thumbnails and Featured tag (#43373)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-21 10:40:37 -07:00
d422f5b4b6 fix: last date label hidden on time series x-axis (#39899) (#42299)
Co-authored-by: Prathamesh Hukkeri <prathamesh04@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-08-21 09:59:06 -07:00
Alexandru Soare a05a099987 fix(embedded): block custom SQL injection in guest user chart payloads (#43111) 2026-08-21 17:05:40 +03:00
Evan RusackasandClaude Code 05842a6350 feat(metrics): add MEDIAN/STDDEV_SAMP/VAR_SAMP as system-wide aggregates (#42895)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-08-21 04:15:17 -07:00
Evan RusackasandClaude Opus 4.8 e45dd2d3f1 ci: pull CI service images from GHCR mirror (fork-safe) [depends on #40880] (#40882)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 04:11:37 -07:00
Mehmet Salih Yavuz 65910abf21 fix(sqllab): disable Save dataset until the query runs successfully (#43330) 2026-08-21 12:56:42 +03:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 6e22050b50 chore(deps): bump the storybook group in /docs with 2 updates (#43377)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-21 01:10:41 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 132340b652 chore(deps): bump astral-sh/setup-uv from 10.0.0 to 10.0.1 (#43378)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-21 01:10:37 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 14408eb3db chore(deps-dev): bump the storybook group in /superset-frontend with 5 updates (#43379)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-21 01:10:33 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 87743ef3f8 chore(deps): bump dompurify from 3.4.12 to 3.4.13 in /superset-frontend (#43380)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-21 01:10:29 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> fb53761ec8 chore(deps-dev): bump baseline-browser-mapping from 2.11.13 to 2.11.14 in /superset-frontend (#43381)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-21 01:10:25 -07:00
Amin Ghadersohi f7d505e1fd fix(listview): stop card clicks creating a duplicate history entry (#43310) 2026-08-20 23:23:46 -04:00
22396d504a fix(ux): use title case for button labels (#40048)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-08-20 20:14:33 -07:00
Gaston LaterzaandClaude Opus 5 27ea5de44a fix(i18n-es): correct case/number collapse in the semantic-layer labels (#43311)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 20:03:57 -07:00
Joe LiandClaude Sonnet 5 18fc2c6228 fix(sqllab): stop copying a permalink when opening a saved query (#43147)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 15:44:20 -07:00
Amin Ghadersohi 18a36d04c7 fix(mcp): preserve user-authored result values (#43202) 2026-08-20 18:29:39 -04:00
ʈᵃᵢ 01b1d58ac9 fix(plugin-chart-echarts): restore tooltips for metrics labelled like… (#43369) 2026-08-20 14:58:11 -07:00
Joe Li 7441ce90ae fix(charts): align grain-less time comparisons safely (#43315) 2026-08-20 14:55:01 -07:00
Amin Ghadersohi 42ba2a4433 fix(reports): humanize day-of-month + day-of-week crontabs as OR (#43307) 2026-08-20 16:46:08 -04:00
madhushreeagandmadhushree agarwal 271564cb0d feat(config): add EXTRA_PANDAS_POSTPROCESSING_OPS extension point (#43337)
Co-authored-by: madhushree agarwal <madhushree_agarwal@apple.com>
2026-08-20 13:15:41 -07:00
JUST.in DO ITandClaude Sonnet 5 bcfb4346f6 fix(mcp): honor use_cache and cache_timeout in get_chart_data (#43349)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 13:01:41 -07:00
Joe Li b5fe00b407 test(frontend): shrink flaky/misplaced recently-archived e2e coverage to Jest unit tests (#43264) 2026-08-20 11:48:02 -07:00
Parman MohammadalizadehandEvan Rusackas 148ffaff50 fix(plugin-chart-echarts): omit stacked value labels on zero-height segments (#42756)
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-08-20 11:04:19 -07:00
Ankit 83c6ea4e03 fix(explore): show the empty state when Samples returns no result payload (#43115) 2026-08-20 11:00:54 -07:00
Hans Yu 1ca5e61f54 chore: Unset future flag in engines and sessions. (#43273) 2026-08-20 10:58:37 -07:00
rlei 1ef12580a7 fix(chart): ignore chart actions for a chart no longer in state (#43228) 2026-08-20 10:57:21 -07:00
c0884c0f0c fix(explore): keep x-axis label when overriding Time Column with time comparison (#42875)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Joe Li <joe@preset.io>
2026-08-20 10:52:22 -07:00
Sepuri Sai KrishnaandClaude Opus 5 2ff79bd495 fix(github): point the issue templates at labels that exist (#43357)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 10:13:36 -07:00
e7dccd44a7 fix(reports): prevent blank/partial report PDFs from virtualized charts (#43348)
Co-authored-by: Matt Fitzgerald <matt.fitzgerald@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
2026-08-20 09:10:30 -04:00
dependabot[bot] fdd3945dde chore(deps): bump github/codeql-action/analyze from 4.37.6 to 4.37.7 (#43361)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-20 13:58:31 +03:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 8b67fb4d30 chore(deps-dev): bump globals from 17.10.0 to 17.11.0 in /superset-websocket (#43360)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-20 00:41:49 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> b288db91f9 chore(deps): bump github/codeql-action/init from 4.37.6 to 4.37.7 (#43362)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-20 00:41:43 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 80bba12d0f chore(deps-dev): bump globals from 17.10.0 to 17.11.0 in /docs (#43363)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-20 00:41:38 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 5d4358a149 chore(deps): bump google-auth-library from 11.0.1 to 11.0.2 in /superset-frontend (#43364)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-20 00:41:25 -07:00
517 changed files with 12940 additions and 7235 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
name: Bug report
description: Report a bug to improve Superset's stability
labels: ["bug"]
labels: ["#bug"]
body:
- type: markdown
attributes:
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: Cosmetic Issue
about: Describe a cosmetic issue with CSS, positioning, layout, labeling, or similar
labels: "cosmetic-issue"
labels: "#bug:cosmetic"
---
## Screenshot
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
python-version: "3.11"
- name: Install uv
uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
python-version: "3.11"
enable-cache: true
+2 -2
View File
@@ -67,7 +67,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -78,6 +78,6 @@ jobs:
# queries: security-extended,security-and-quality
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
category: "/language:${{matrix.language}}"
+4 -4
View File
@@ -78,14 +78,14 @@ jobs:
USE_DASHBOARD: ${{ github.event.inputs.use_dashboard == 'true' || 'false' }}
services:
postgres:
image: postgres:17-alpine
image: ghcr.io/apache/superset/ci/postgres:17-alpine
env:
POSTGRES_USER: superset
POSTGRES_PASSWORD: superset
ports:
- 15432:5432
redis:
image: redis:7-alpine
image: ghcr.io/apache/superset/ci/redis:7-alpine
ports:
- 16379:6379
steps:
@@ -186,14 +186,14 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
services:
postgres:
image: postgres:17-alpine
image: ghcr.io/apache/superset/ci/postgres:17-alpine
env:
POSTGRES_USER: superset
POSTGRES_PASSWORD: superset
ports:
- 15432:5432
redis:
image: redis:7-alpine
image: ghcr.io/apache/superset/ci/redis:7-alpine
ports:
- 16379:6379
steps:
@@ -53,9 +53,7 @@ jobs:
mysql+mysqldb://superset:superset@127.0.0.1:13306/superset?charset=utf8mb4&binary_prefix=true
services:
mysql:
image: mysql:8.0
# Authenticated pulls use our higher Docker Hub rate limit. Empty on
# fork PRs (secrets unavailable) -> runner falls back to anonymous.
image: ghcr.io/apache/superset/ci/mysql:8.0
env:
MYSQL_ROOT_PASSWORD: root
ports:
@@ -66,7 +64,7 @@ jobs:
--health-timeout=5s
--health-retries=5
redis:
image: redis:7-alpine
image: ghcr.io/apache/superset/ci/redis:7-alpine
options: --entrypoint redis-server
ports:
- 16379:6379
@@ -143,7 +141,7 @@ jobs:
SUPERSET__SQLALCHEMY_DATABASE_URI: postgresql+psycopg2://superset:superset@127.0.0.1:15432/superset
services:
postgres:
image: postgres:17-alpine
image: ghcr.io/apache/superset/ci/postgres:17-alpine
env:
POSTGRES_USER: superset
POSTGRES_PASSWORD: superset
@@ -152,7 +150,7 @@ jobs:
# GitHub action runner's default installations
- 15432:5432
redis:
image: redis:7-alpine
image: ghcr.io/apache/superset/ci/redis:7-alpine
ports:
- 16379:6379
steps:
@@ -202,7 +200,7 @@ jobs:
sqlite:///${{ github.workspace }}/.temp/examples.db?check_same_thread=true
services:
redis:
image: redis:7-alpine
image: ghcr.io/apache/superset/ci/redis:7-alpine
ports:
- 16379:6379
steps:
@@ -52,7 +52,7 @@ jobs:
SUPERSET__SQLALCHEMY_EXAMPLES_URI: presto://localhost:15433/memory/default
services:
postgres:
image: postgres:17-alpine
image: ghcr.io/apache/superset/ci/postgres:17-alpine
env:
POSTGRES_USER: superset
POSTGRES_PASSWORD: superset
@@ -61,7 +61,7 @@ jobs:
# GitHub action runner's default installations
- 15432:5432
presto:
image: starburstdata/presto:350-e.6
image: ghcr.io/apache/superset/ci/presto:350-e.6
env:
POSTGRES_USER: superset
POSTGRES_PASSWORD: superset
@@ -70,7 +70,7 @@ jobs:
# GitHub action runner's default installations
- 15433:8080
redis:
image: redis:7-alpine
image: ghcr.io/apache/superset/ci/redis:7-alpine
ports:
- 16379:6379
steps:
@@ -116,7 +116,7 @@ jobs:
UPLOAD_FOLDER: /tmp/.superset/uploads/
services:
postgres:
image: postgres:17-alpine
image: ghcr.io/apache/superset/ci/postgres:17-alpine
env:
POSTGRES_USER: superset
POSTGRES_PASSWORD: superset
@@ -125,7 +125,7 @@ jobs:
# GitHub action runner's default installations
- 15432:5432
redis:
image: redis:7-alpine
image: ghcr.io/apache/superset/ci/redis:7-alpine
ports:
- 16379:6379
steps:
@@ -75,13 +75,18 @@ jobs:
SUPERSET_SECRET_KEY: not-a-secret
run: |
pytest --durations-min=0.5 --cov-report= --cov=superset ./tests/common ./tests/unit_tests --cache-clear --maxfail=50 --junit-xml=test-results/junit-unit.xml
# COVERAGE_FILE keeps these scoped gates off the default .coverage that
# the step above wrote. pytest-cov starts a fresh data file per run, so
# without it the last gate replaces the full-suite data and the report
# uploaded below contains only that gate's subtree -- every other file
# then reads as uncovered, and patch coverage on an unrelated PR is 0%.
- name: Python 100% coverage unit tests
env:
SUPERSET_TESTENV: true
SUPERSET_SECRET_KEY: not-a-secret
run: |
pytest --durations-min=0.5 --cov=superset/sql/ ./tests/unit_tests/sql/ --cache-clear --cov-fail-under=100 --junit-xml=test-results/junit-sql-coverage.xml
pytest --durations-min=0.5 --cov=superset/semantic_layers/ ./tests/unit_tests/semantic_layers/ --cache-clear --cov-fail-under=100 --junit-xml=test-results/junit-semantic-layers-coverage.xml
COVERAGE_FILE="${RUNNER_TEMP}/.coverage.sql" pytest --durations-min=0.5 --cov=superset/sql/ ./tests/unit_tests/sql/ --cache-clear --cov-fail-under=100 --junit-xml=test-results/junit-sql-coverage.xml
COVERAGE_FILE="${RUNNER_TEMP}/.coverage.semantic_layers" pytest --durations-min=0.5 --cov=superset/semantic_layers/ ./tests/unit_tests/semantic_layers/ --cache-clear --cov-fail-under=100 --junit-xml=test-results/junit-semantic-layers-coverage.xml
- name: Upload code coverage
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
@@ -1,262 +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.
-->
# [SIP] Proposal for a dashboard component Extensions contribution point
> **Companion SIP:** Pairs with [`SIP.md`](SIP.md) (first-class iframe component +
> runtime CSP allowlist). That SIP is the **reference implementation** that proves
> this contribution point: the iframe's UI becomes an extension-contributed
> dashboard component, while its security-sensitive CSP backend stays in core.
>
> **Status:** Draft — POC tracked in `feat/csp-runtime-allowlist-iframe`.
## Motivation
Adding a new dashboard layout component to Superset today is a **core-only,
high-friction** operation. The iframe component in the companion SIP had to touch
~12 files: a type constant, the `componentLookup` map, the builder palette, and
**seven hardcoded behavior maps** keyed by component-type string
(`isValidChild`, `componentIsResizable`, `newComponentFactory`,
`shouldWrapChildInRow`, `getDetailedComponentWidth`, `isDashboardEmpty`, plus the
prop bundle injected by `DashboardComponent.tsx`). Component types are a **closed
enum** baked into core.
There is a legacy escape hatch — the `DashboardComponentsRegistry` /
`DYNAMIC_TYPE` path (`src/visualizations/dashboardComponents/`) — but it is an
**antique that should be deprecated**:
- It is disconnected from the modern VS Code-style Extensions framework
(`@apache-superset/core`, `ENABLE_EXTENSIONS`), which already has contribution
points for `commands`, `menus`, `views`, `editors`, and `chat`.
- Components registered through it are **second-class**: `DynamicComponent`
renders them in a generic wrapper that only passes `dashboardData`. They do not
receive the first-class layout lifecycle (edit mode, meta editing, resize, DnD)
and cannot declare their own layout behavior.
We want a **single, modern way** to contribute a first-class dashboard layout
component — via the Extensions framework — and to deprecate the legacy registry.
The iframe component is the ideal pilot because it is self-contained.
## Proposed Change
### 1. A `dashboardComponents` contribution point
Add `dashboardComponents` to the Extensions `Contributions` interface
(`packages/superset-core/src/contributions/index.ts`), alongside `views`,
`commands`, etc., with a public registration API mirroring the existing ones
(`registerDashboardComponent` returning a `Disposable`), exposed on
`window.superset.dashboardComponents` and wired into `ExtensionsLoader`.
### 2. The Dashboard Component Contract (the heart of this SIP)
The contract has two halves. Getting this right is the real work — it becomes a
**public API Superset must support indefinitely**.
**(a) Declarative behavior metadata** — replaces the seven hardcoded util maps:
```ts
interface DashboardComponentContribution {
id: string; // unique type key, namespaced, e.g. "my-org.iframe"
name: string; // palette label
description?: string;
icon: string; // contributed icon id or known icon name
resizable?: boolean; // -> componentIsResizable
defaultMeta?: { // -> newComponentFactory
width?: number;
height?: number;
[key: string]: unknown;
};
nesting?: { // -> isValidChild / shouldWrapChildInRow
validParents?: string[]; // e.g. [GRID, ROW, COLUMN, TAB]
wrapInRow?: boolean;
minWidth?: number; // -> getDetailedComponentWidth
};
isUserContent?: boolean; // -> isDashboardEmpty
loadComponent: () => Promise<{ default: ComponentType<DashboardComponentProps> }>;
}
```
**(b) Runtime props contract** — a small, stable surface. Crucially, **the host
owns the chrome** (the `Draggable` + `ResizableContainer` + `HoverMenu`/delete
wrapper that every current `componentLookup` component re-implements today). The
extension component renders only its *content* and, optionally, an *editor*:
```ts
interface DashboardComponentProps {
id: string;
meta: Record<string, unknown>;
editMode: boolean;
updateMeta: (patch: Record<string, unknown>) => void; // wraps updateComponents
// resize/drag/delete handled by the host wrapper, NOT the component
}
```
This is a strict improvement over the status quo: the iframe component in the
companion PR hand-rolls the Draggable/Resizable/HoverMenu wrapper; under this
contract that boilerplate moves into the host once, and contributed components
shrink to "render content + edit meta."
### 3. Registry-driven core
Refactor `componentLookup` and the seven behavior maps to consult a registry,
with the **built-in leaf components seeded into it** at startup. Structural
container components (Chart, Tabs, Row, Column, Header) *are* the layout engine
and stay bespoke; the contribution point targets **leaf/content components**
(today: Markdown, Divider, Iframe; tomorrow: anything). `DashboardComponent.tsx`
resolves contributed types through the registry and renders them inside the
shared host chrome.
### 4. Deprecate `DashboardComponentsRegistry` / `DYNAMIC_TYPE`
Mark the legacy registry and `DYNAMIC_TYPE` deprecated. Provide a shim so existing
dynamic components keep working, with a migration note pointing at the new
contribution point. Removal happens in a later major per Superset's deprecation
policy.
### 5. Graceful fallback for unknown types
A saved dashboard layout stores component **type strings** in its position JSON.
If a dashboard references a type whose extension is disabled/uninstalled, the host
must render a non-destructive placeholder ("This component requires the *X*
extension") and **preserve the meta on save** so re-enabling the extension
restores it. The layout engine already tolerates unknown types defensively
(`componentLookup[type]` → null; `isValidChild` → false); this SIP makes that an
intentional, user-visible contract rather than silent breakage.
### 6. Backend: APIs yes, security policy no
The Extensions framework **already** lets a component contribute a backend REST
API: the `@api` decorator (`superset-core/.../rest_api/decorators.py`) detects
extension context and registers the route via `appbuilder.add_api()` at entrypoint
import, serving it under `/extensions/{publisher}/{name}/...` and auto-creating
the endpoint's FAB permission. **No new work is required for an extension to ship
an API.**
What an extension **cannot** do today, and what this SIP explicitly leaves to
core:
- **Role policy for a permission.** Endpoint permissions are auto-created, but
whether a permission is *Admin-only* (e.g. via
`SupersetSecurityManager.ADMIN_ONLY_VIEW_MENUS`) is decided in core at
`sync_role_definitions` time. The manifest's `permissions: list[str]` field is
currently **dormant** (never read), and the `ContributionProcessorRegistry` that
would process it is scaffolding that is not wired into the load pipeline.
- **Security-sensitive request hooks** (e.g. rewriting CSP/Talisman headers).
This is exactly why the companion CSP feature keeps its backend in core: the
component *UI* is extension-shaped, but punching holes in the CSP and gating it
admin-only are core security responsibilities.
A **future, optional** extension of this SIP could finish wiring
`ContributionProcessorRegistry` + a manifest permission-policy schema so
extensions can declare role policy — but that is itself a security-review-worthy
change and is out of scope here.
## New or Changed Public Interfaces
- **New contribution point** `dashboardComponents` on the `Contributions`
interface; new `registerDashboardComponent(...) -> Disposable` API; new
`window.superset.dashboardComponents` namespace.
- **New public types** `DashboardComponentContribution` and
`DashboardComponentProps` (the contract) — these become long-term public API.
- **Changed (internal → registry-driven)** `componentLookup` and the seven
behavior util maps; `DashboardComponent.tsx` resolution path; the host gains a
shared component-chrome wrapper.
- **Deprecated** `DashboardComponentsRegistry`, `DYNAMIC_TYPE`,
`NewDynamicComponent`, `setupDashboardComponents`.
## New dependencies
None. Reuses the existing Extensions framework (module federation, manifest
schema, `@api` decorator) and the existing functional-registry utilities.
## Migration Plan and Compatibility
- **No DB migration.** This is a frontend/framework change plus the (already
supported) extension API path.
- **Layout JSON is unchanged** — component types remain type strings. The new
fallback behavior makes *unknown* types degrade gracefully instead of rendering
nothing.
- **Backwards compatible:** built-in components are seeded into the registry, so
existing dashboards render identically. Legacy `DYNAMIC_TYPE` components keep
working via a deprecation shim.
- **Rollout:** the contribution point is only active under `ENABLE_EXTENSIONS`;
with it off, behavior is identical to today.
## Rejected Alternatives
- **Keep / extend `DashboardComponentsRegistry`.** It is disconnected from the
modern Extensions framework and produces second-class components. Deprecating it
in favor of one contribution model is the goal, not a side effect.
- **Require all built-in components to become extensions.** Chart/Tabs/Row/Column
are the layout engine; extracting them is high-risk and low-value. The
contribution point *adds* leaf components; it does not mandate extraction.
- **Let the extension component own its own DnD/resize chrome** (as
`componentLookup` components do today). Rejected: it bloats the contract,
duplicates host logic, and makes the public API fragile. The host owns chrome.
- **One combined SIP with the CSP feature.** Rejected: the framework change and
the security-sensitive feature are distinct discussions with different
reviewers and risk profiles, even though they share a POC branch.
- **Move the CSP permission/role policy into the extension.** Not supported today
(dormant manifest `permissions`, unwired contribution processor) and
undesirable: admin-only gating and CSP-header rewriting are core security
responsibilities.
## Implementation Status (POC)
Implemented on the POC branch (`@apache-superset/core` mirrors the `chat`
contribution-point pattern from #41000/#41205):
- [x] `DashboardComponentDefinition` + `DashboardComponentProps` contract types
(`packages/superset-core/src/dashboardComponents`), added to the
`Contributions` interface and the package's subpath exports
- [x] `dashboardComponents` contribution point: host `DashboardComponentsProvider`
registry + public `registerDashboardComponent`/`getDashboardComponents` API
(`src/core/dashboardComponents`), exposed on `window.superset` via
`ExtensionsStartup` + `Namespaces`
- [x] Shared host component-chrome wrapper `DashboardExtensionComponent`
(owns Draggable/Resizable/HoverMenu/Delete; reads `resizable` from the
definition) behind the new `EXTENSION_TYPE`
- [x] `componentLookup` + builder palette resolve the registry; the seven
behavior maps carry `EXTENSION_TYPE` leaf behavior
- [x] Unknown-type graceful fallback (placeholder + meta preserved on save)
- [x] Deprecation notices on `DashboardComponentsRegistry` / `DYNAMIC_TYPE`
(legacy path still functions)
- [x] Reference component: the built-in iframe is now delivered **through** the
contribution point (`src/dashboard/extensions/iframe`), registered at
startup exactly as a third-party extension would; its CSP backend remains
in core per the companion SIP
- [x] Tests: registry lifecycle (register/get/replace/dispose), host-wrapper
resolution + fallback + `updateMeta`, iframe content + CSP UX
- [x] Per-component behavior policy honored by the layout engine: `resizable`,
`minWidth`, `isUserContent`, `validParents`, and `wrapInRow` are seeded onto
instance `meta` at creation and read by `componentIsResizable`,
`getDetailedComponentWidth`, `isDashboardEmpty`, `isValidChild`, and
`shouldWrapChildInRow` (the pure layout utils stay registry-free; behavior
round-trips in the saved layout)
- [x] Developer docs: `extension-points/dashboard-components.md` + a
`contribution-types.md` section + sidebar entry, with an example extension
Remaining (follow-up, not POC-blocking):
- [ ] Manifest `contributions.dashboardComponents` declarative validation in the
Python/TS manifest schema (runtime side-effect registration works today,
matching how `chat` does it)
- [ ] Remove the legacy `DashboardComponentsRegistry`/`DYNAMIC_TYPE` (major)
-232
View File
@@ -1,232 +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.
-->
# [SIP] Proposal for a first-class iframe dashboard component with a runtime CSP allowlist
> **Companion SIP:** This proposal pairs with
> [`SIP-DASHBOARD-COMPONENT-CONTRIBUTION-POINT.md`](SIP-DASHBOARD-COMPONENT-CONTRIBUTION-POINT.md),
> which proposes the Extensions contribution point that would let this iframe
> component (and others) be shipped as an extension. The two are deliberately
> separate discussions: **this** SIP covers the security-sensitive feature
> (runtime CSP override + permissions); the companion covers the framework
> change. They share one POC branch so the end-to-end story is demonstrable.
> **Status:** Draft — tracking the implementation in `feat/csp-runtime-allowlist-iframe`.
> This document follows the SIP issue template and is kept in sync with the branch
> as the implementation evolves. See SIP-0
> (<https://github.com/apache/superset/issues/5602>) for the SIP process.
## Motivation
Superset ships a Talisman/Content-Security-Policy (CSP) configuration that, by
design, prevents users from embedding arbitrary external content in a dashboard.
The default policy declares `default-src 'self'` and **no** `frame-src`
directive, so an `<iframe>` pointing at any third-party origin is blocked by the
browser.
This is correct and secure default behavior, but it creates real friction:
- There is **no first-class "iframe" dashboard component**. Users historically
smuggled iframes through Markdown, which is both a footgun and blocked by CSP.
- When an embed *is* legitimately needed (an internal tool, a status page, a
partner widget), the only way to allow it is to **edit `TALISMAN_CONFIG` and
restart every Superset process**. That is a deploy-time, ops-team operation —
far too heavyweight for "let me embed this one dashboard from our other
internal app."
- There is no in-product signal telling a user *why* their embed is blank, and
no path to fix it.
We want to (a) make embedding a real, supported component, and (b) give trusted
Admins a controlled, audited way to widen the CSP at runtime — without
abandoning the secure-by-default posture that operators rely on.
## Proposed Change
The change has five parts.
### 1. A first-class `IFRAME` dashboard layout component
A new grid component (`IFRAME_TYPE`) modeled on the existing Markdown/Divider
components. In edit mode the user pastes a URL; in view mode the component
renders a sandboxed `<iframe>`. The component is registered through the same
surface as every other layout element (type constant, `componentLookup`, drag
palette, nesting/resize/width/wrap util maps).
The iframe is rendered with a restrictive `sandbox` attribute
(`allow-scripts allow-same-origin allow-popups allow-forms`).
### 2. Domain flagging
When the runtime-allowlist feature is enabled, the component compares the
embedded URL's **origin** against the current allowlist (fetched from the new
API). If the origin is not yet allowed, it shows an inline warning explaining
that the domain is blocked by the CSP.
### 3. "Enable domain in CSP" button
If the current user holds the new permission (Admins by default), the warning
includes an **Enable domain in CSP** button. Clicking it `POST`s the origin to
the allowlist API and re-checks. Users without the permission instead see "ask
an administrator."
### 4. Permission gating
Mutating the allowlist requires `can write on CSPAllowlist`. The `CSPAllowlist`
view-menu is registered in `SupersetSecurityManager.ADMIN_ONLY_VIEW_MENUS`, so
the capability is reserved for Admins (or a custom role explicitly granted it),
consistent with how other trusted, security-sensitive operations are scoped.
### 5. Runtime CSP override ("punched holes")
A new `csp_allowlist` metadata table stores allowlist entries. An `after_request`
hook — registered **before** flask-talisman so that, because Flask runs
`after_request` callbacks in reverse registration order, it runs **after**
Talisman has set the header — merges the operator-curated entries into the
response CSP header. Entries are cached in-process with a short TTL to avoid a DB
hit per response; a write through the API invalidates the cache in the handling
worker, and other workers converge when their cached copy expires.
The entire runtime-override path is inert unless the `CSP_RUNTIME_ALLOWLIST`
feature flag is enabled, so the static, deploy-time policy remains the default
and operators opt in explicitly.
```
Browser ──> Flask request
Talisman after_request (sets "Content-Security-Policy: default-src 'self'; …")
merge_runtime_csp_allowlist (if flag on: appends allowlist origins to frame-src, …)
Response ──> Browser ("…; frame-src 'self' https://embed.example")
```
#### Design decisions (resolved)
- **Scope: global.** Allowlist entries apply server-wide. CSP is a single
per-response header; a global allowlist keeps the merge context-free and
avoids per-dashboard request plumbing. (Per-dashboard scoping is a possible
future extension.)
- **Operator control: feature-flagged kill-switch.** The runtime override only
functions when `CSP_RUNTIME_ALLOWLIST` is on (default **off**). Operators who
want a purely static policy simply leave it off and the table is never
consulted.
## New or Changed Public Interfaces
### REST API
- `GET /api/v1/csp_allowlist/` — list entries
- `GET /api/v1/csp_allowlist/<id>` — get one
- `POST /api/v1/csp_allowlist/` — create (validates origin + directive)
- `PUT /api/v1/csp_allowlist/<id>` — update
- `DELETE /api/v1/csp_allowlist/<id>` — delete
- `DELETE /api/v1/csp_allowlist/?q=!(...)` — bulk delete
All write methods require `can write on CSPAllowlist` (Admin-only by default).
Origins are validated server-side: bare `scheme://host[:port]` only — no
wildcards, paths, query strings, fragments, or credentials. Only a fixed set of
directives may be widened (`frame-src`, `child-src`, `img-src`, `connect-src`,
`media-src`, `font-src`); notably **not** `script-src`.
### Model
- `CSPAllowlistEntry` (`superset/models/csp.py`, table `csp_allowlist`):
`id`, `uuid`, `domain`, `directive` (default `frame-src`), `description`,
audit columns. Unique on `(domain, directive)`.
### Feature flag
- `CSP_RUNTIME_ALLOWLIST` (default `False`) — gates the entire runtime-override
path, backend and frontend.
### Config
- `CSP_RUNTIME_ALLOWLIST_CACHE_TTL` (default `30` seconds) — in-process cache TTL
for the allowlist; also settable via env var.
### Frontend
- New `IFRAME` dashboard layout component and its registration across the
dashboard util maps.
- New `FeatureFlag.CspRuntimeAllowlist` enum member.
### Security model
- New `CSPAllowlist` view-menu added to `ADMIN_ONLY_VIEW_MENUS`.
## New dependencies
None. The implementation uses existing libraries (flask-talisman,
Flask-AppBuilder, marshmallow, SQLAlchemy on the backend; existing
`@superset-ui/core` components on the frontend).
## Migration Plan and Compatibility
- One Alembic migration adds the `csp_allowlist` table
(`4a50792bd265`, down-revision `3a8e6f2c1b95`). The table is empty on creation.
- Fully backward compatible: with the feature flag off (the default), behavior is
identical to today — the static CSP is authoritative and the new table is never
read. No existing dashboards, URLs, or policies change.
- Rollback: dropping the table and disabling the flag fully reverts the feature.
### Security review notes
This feature deliberately relocates a *capability* (widening the CSP) from a
purely deploy-time operator control into a runtime, permission-gated, audited
operation. The mitigations that keep it within Superset's trust model:
- **Off by default** behind a feature flag the operator owns.
- **Admin-only** write permission (a fully trusted principal per `SECURITY.md`).
- **Strict origin validation** server-side — no wildcards, no `script-src`.
- **Audit trail** via the audit mixin (`created_by` / `changed_by`).
- The iframe is **sandboxed** and the merge can only *widen* a directive to a
specific origin, never relax nonce/`strict-dynamic` protections on
`script-src`.
## Rejected Alternatives
- **Dynamically reconfiguring flask-talisman at runtime.** Talisman is configured
once at app init. Rather than mutate its internals, we add our own
`after_request` hook that post-processes the header it already sets. This is
simpler, avoids depending on Talisman internals, and rides the same per-request
header machinery Talisman already uses for its nonce.
- **Per-dashboard allowlist scoping.** More precise, but CSP is a per-response
header; per-dashboard scoping adds request-context complexity for marginal
benefit in the common case. Left as a possible future extension.
- **"Always on" runtime override (no kill-switch).** Simpler, but moves a
security control fully into the app with no operator opt-out. Rejected in favor
of the feature-flag kill-switch.
- **Shared/Redis-backed allowlist cache with cross-worker invalidation.**
Correct but heavier. A short-TTL in-process cache is good enough: writes take
effect immediately in the handling worker and within the TTL elsewhere, with no
new infrastructure dependency.
## Implementation Status
- [x] Feature flag `CSP_RUNTIME_ALLOWLIST` + `CSP_RUNTIME_ALLOWLIST_CACHE_TTL`
- [x] `CSPAllowlistEntry` model + Alembic migration
- [x] DAO, marshmallow schemas (with origin/directive validation), REST API
- [x] Admin-only permission (`CSPAllowlist` view-menu)
- [x] `after_request` CSP merge hook + in-process TTL cache + invalidation
- [x] `IFRAME` dashboard component + registration across util maps
- [x] Domain flagging + permission-gated "Enable domain in CSP" button
- [x] Tests: backend unit (validation + merge + hook), backend integration (API),
frontend unit (util + component)
- [ ] Docs (`docs/`) + `UPDATING.md` entry
- [ ] Community/security review feedback
+40
View File
@@ -26,6 +26,29 @@ assists people when migrating to a new version.
- `SAMPLES_ROW_LIMIT` is now the default for `/datasource/samples` requests without a valid explicit `per_page`, rather than a hard per-request ceiling; explicit limits are honored up to the existing global row-limit ceiling, matching `/chart/data` SAMPLES requests.
### MCP tool results preserve stored string values
Structured MCP tool results no longer add `<UNTRUSTED-CONTENT>` wrappers or
rewrite delimiter-looking text inside string fields. Tool-result content remains
user-controlled data, but clients must convey that trust boundary outside domain
values instead of recognizing or removing marker strings.
Clients that handled the former delimiter convention should stop stripping marker
text: the same text can be legitimate stored content. Response models and content
types are unchanged, and no metadata-database migration is required. Automated
read-modify-write workflows should be paused or pinned away from older instances
until every serving instance is upgraded; a mixed-version response has no reliable
signal that tells a client whether its text is decorated. Redis-backed MCP response
caches use a new internal namespace after the upgrade, so upgraded instances do not
reuse older cached results.
Values that a client already wrote back with presentation wrappers cannot be
distinguished safely from intentional content. Operators should review possible
`<UNTRUSTED-CONTENT>` / `</UNTRUSTED-CONTENT>` wrappers and
`[ESCAPED-UNTRUSTED-CONTENT-OPEN]` /
`[ESCAPED-UNTRUSTED-CONTENT-CLOSE]` substitutions rather than applying an automatic
marker-removal migration.
### OAuth2 database callback metrics include their outcome
The unqualified `DatabaseRestApi.oauth2` StatsD counter has been replaced with
@@ -102,6 +125,23 @@ dialect; each package's constraint in `pyproject.toml` documents why.
No application-level configuration changes are required for deployments
that don't touch SQLAlchemy directly.
### New metric aggregates: MEDIAN, Sample Standard Deviation, Sample Variance
`MEDIAN`, `STDDEV_SAMP`, and `VAR_SAMP` are now available anywhere a metric
aggregate is chosen (every chart type, SQL Lab, MCP), not only in Pivot
Table's controls. Support is opt-in per database engine *spec class*,
verified against a live instance before being enabled: Postgres, MySQL
(`STDDEV_SAMP`/`VAR_SAMP` only, no `MEDIAN`), DuckDB, and Redshift (inherits
Postgres's support, not yet separately verified) ship enabled in this
release. Engine specs that subclass one of those (e.g. MariaDB, Aurora
MySQL/Postgres, TimescaleDB) inherit the same support, on the same
not-yet-independently-verified basis. Picking one of these aggregates on a
database that has not opted in returns a clear "not supported on this
database" error rather than a failed query. See
`docs/sip/median-stddev-variance-aggregates.md` for the full design
rationale, including why this is safe to add without reintroducing the
totals/subtotals correctness bug fixed by #41184 (SIP-216).
### Soft delete is on by default, and purging is live
`SOFT_DELETE` now ships **on** (`DEFAULT_FEATURE_FLAGS`), so deleting a
+34 -2
View File
@@ -576,7 +576,7 @@ MCP_CACHE_CONFIG = {
| Key | Default | Description |
| -------------------- | --------- | ----------------------------------------------------------- |
| `enabled` | `False` | Enable response caching |
| `CACHE_KEY_PREFIX` | `None` | Optional prefix for cache keys (useful for shared Redis) |
| `CACHE_KEY_PREFIX` | `None` | Base prefix for shared Redis; Superset appends an internal response-contract namespace |
| `list_tools_ttl` | `300` | Cache TTL in seconds for `tools/list` |
| `list_resources_ttl` | `300` | Cache TTL for `resources/list` |
| `list_prompts_ttl` | `300` | Cache TTL for `prompts/list` |
@@ -718,6 +718,34 @@ Every MCP request passes through a middleware stack before reaching the tool fun
Additional middleware classes (`RateLimitMiddleware`, `FieldPermissionsMiddleware`, `PrivateToolMiddleware`) are implemented in `superset/mcp_service/middleware.py` but are not added to the default pipeline. They are available for operators who want to layer them in via a custom startup path.
### Tool Result Value Contract
Structured tool results preserve Superset domain values exactly. In particular,
string fields are not wrapped in trust delimiters, and text that resembles a
delimiter is returned as literal application data. This lets clients safely use a
read result as the basis for an update without persisting presentation markup.
All tool-result content should still be treated as user-controlled data with no
instruction authority. MCP clients should communicate that trust boundary through
their model instructions or presentation layer, outside the returned field values;
fixed or generated marker strings inside a value are ambiguous and must not be used
as a trust signal.
For compatibility, clients that supported the former
`<UNTRUSTED-CONTENT>` convention should stop recognizing or stripping those strings.
The response schemas and content types have not changed. Because marker-looking text
can be legitimate application data, a client cannot reliably distinguish a legacy
decorated response from a clean one. Pause automated read-modify-write workflows, or
route them only to upgraded instances, until every serving instance is upgraded.
Redis-backed MCP response caches include an internal response-contract namespace, so
an upgraded instance does not reuse responses cached by an older release. Older
instances can still return legacy values while they remain in service. After the
upgrade, review previously written values for wrapper text and both
`[ESCAPED-UNTRUSTED-CONTENT-OPEN]` and
`[ESCAPED-UNTRUSTED-CONTENT-CLOSE]`; do not remove these strings automatically,
because they may be intentional content.
### Error Sanitization
The `GlobalErrorHandlerMiddleware` automatically redacts sensitive information from all error messages before they reach the LLM client. The following are replaced with generic messages:
@@ -752,7 +780,11 @@ For a 3-pod Kubernetes deployment with the defaults above, expect up to 3 × (5
Enable response caching for read-heavy workloads (dashboards/datasets that don't change frequently). With the in-memory backend (default when `MCP_STORE_CONFIG` is disabled), caching is per-process. Use Redis-backed caching for consistent cache hits across multiple pods:
```python
MCP_CACHE_CONFIG = {"enabled": True, "call_tool_ttl": 3600}
MCP_CACHE_CONFIG = {
"enabled": True,
"CACHE_KEY_PREFIX": "mcp_cache_",
"call_tool_ttl": 3600,
}
MCP_STORE_CONFIG = {"enabled": True, "CACHE_REDIS_URL": "redis://redis:6379/0"}
```
@@ -129,27 +129,6 @@ chat.registerChat(
See [Chat](./extension-points/chat.md) for implementation details.
### Dashboard Components
Extensions can add first-class layout components to the dashboard builder — elements that live in the grid alongside charts, Markdown, and tabs. The host owns the drag/resize/delete chrome, so the extension only provides the component that renders the element's content. The built-in iframe component is implemented through this contribution point.
```tsx
import { dashboardComponents } from '@apache-superset/core';
import WeatherWidget from './WeatherWidget';
dashboardComponents.registerDashboardComponent(
{
id: 'my-org.weather',
name: 'Weather widget',
icon: 'CloudOutlined',
defaultMeta: { width: 4, height: 50 },
},
WeatherWidget,
);
```
See [Dashboard Components](./extension-points/dashboard-components.md) for implementation details.
## Backend
Backend contribution types allow extensions to extend Superset's server-side capabilities. Backend contributions are registered at startup via classes and functions imported from the auto-discovered `entrypoint.py` file.
@@ -1,155 +0,0 @@
---
title: Dashboard Components
sidebar_position: 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
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.
-->
# Dashboard Component Contributions
Extensions can add first-class **layout components** to the dashboard builder —
elements that sit in the grid alongside charts, Markdown, and tabs. The built-in
iframe component is itself implemented through this contribution point.
The host owns the surrounding **chrome** (the drag handle, the resize container,
and the delete affordance), so your component only renders its content and, in
edit mode, its own editor affordances. This keeps the contract small and stable.
> This supersedes the legacy `DashboardComponentsRegistry` / `DYNAMIC_TYPE`
> mechanism, which is deprecated.
## Overview
A dashboard component contribution is:
| Part | Role |
|------|------|
| **Definition** | A descriptor declaring the component's id, palette label, icon, and layout behavior (resizable, default size, nesting). |
| **Component** | A React component that renders the element's content and receives the [`DashboardComponentProps`](#component-contract) contract. |
## The Component Contract
Your component receives a small, stable set of props. It never deals with drag,
resize, or delete — the host renders it inside that chrome.
```ts
interface DashboardComponentProps {
/** The layout item id of this instance. */
id: string;
/** This instance's persisted meta (round-trips in the saved layout). */
meta: Record<string, unknown>;
/** Whether the dashboard is in edit mode. */
editMode: boolean;
/** Shallow-merge a patch into this instance's persisted meta. */
updateMeta: (patch: Record<string, unknown>) => void;
}
```
Persist any per-instance state in `meta` via `updateMeta`. It is saved with the
dashboard and rehydrated on load.
## Registering a Dashboard Component
Call `dashboardComponents.registerDashboardComponent` from your extension's entry
point with a definition and your component:
```tsx
import { dashboardComponents } from '@apache-superset/core';
import WeatherWidget from './WeatherWidget';
dashboardComponents.registerDashboardComponent(
{
id: 'my-org.weather',
name: 'Weather widget',
description: 'Shows the current weather for a city',
icon: 'CloudOutlined',
resizable: true,
defaultMeta: { width: 4, height: 50, city: 'Lisbon' },
},
WeatherWidget,
);
```
```tsx
// WeatherWidget.tsx
import type { dashboardComponents } from '@apache-superset/core';
type Props = dashboardComponents.DashboardComponentProps;
export default function WeatherWidget({ meta, editMode, updateMeta }: Props) {
const city = (meta.city as string) ?? '';
return editMode ? (
<input
value={city}
onChange={e => updateMeta({ city: e.target.value })}
placeholder="City"
/>
) : (
<Forecast city={city} />
);
}
```
The component appears in the dashboard builder's **Layout elements** palette and
can be dragged onto the grid like any built-in element.
## Definition Reference
| Field | Type | Description |
|-------|------|-------------|
| `id` | `string` | Namespaced unique id, e.g. `my-org.weather`. Selects the component for each instance. |
| `name` | `string` | Label shown in the builder palette. |
| `description` | `string` | Optional longer description. |
| `icon` | `string` | A known Superset icon name (e.g. `CloudOutlined`). Falls back to a generic icon. |
| `resizable` | `boolean` | Whether instances can be resized. Defaults to `true`. |
| `defaultMeta` | `object` | `meta` seeded onto a new instance (e.g. `width`, `height`, and your own keys). |
| `isUserContent` | `boolean` | Whether an instance counts as content for "is this dashboard empty?" detection. Defaults to `true`. |
| `minWidth` | `number` | Minimum width in grid columns. Defaults to `1`. |
| `validParents` | `string[]` | Restrict which container types may hold the component (e.g. `['GRID', 'TAB']`). Defaults to standard content-leaf placement (grid, row, column, tab). |
| `wrapInRow` | `boolean` | Whether a drop into the grid or a tab auto-wraps the component in a row. Defaults to `true`. |
The layout-relevant behavior fields are seeded onto each instance's `meta` at
creation, so the dashboard honors them — and they round-trip in the saved layout
even if the extension later becomes unavailable.
## Graceful Degradation
If a saved dashboard references a component whose extension is disabled or not
yet loaded, the host renders a non-destructive placeholder in its place and
preserves the instance's `meta` on save. Re-enabling the extension restores the
component.
## Dashboard Components API Reference
All methods are available on the `dashboardComponents` namespace from
`@apache-superset/core`:
| Method / Event | Description |
|----------------|-------------|
| `registerDashboardComponent(definition, component)` | Register a component. Returns a `Disposable` to unregister. Registering the same id again replaces the previous registration. |
| `getDashboardComponent(id)` | Returns the registered component for `id`, or `undefined`. |
| `getDashboardComponents()` | Returns all registered components. |
| `onDidRegisterDashboardComponent(listener)` | Subscribe to registration events. Returns a `Disposable`. |
| `onDidUnregisterDashboardComponent(listener)` | Subscribe to unregistration events. Returns a `Disposable`. |
## Next Steps
- **[Contribution Types](../contribution-types.md)** — Explore other contribution types
- **[Development](../development.md)** — Set up your development environment
-1
View File
@@ -49,7 +49,6 @@ module.exports = {
'extensions/extension-points/sqllab',
'extensions/extension-points/editors',
'extensions/extension-points/chat',
'extensions/extension-points/dashboard-components',
],
},
'extensions/development',
@@ -11,6 +11,11 @@ When soft-delete is enabled, deleting a chart, dashboard, or dataset archives it
instead of removing it permanently. The **Recently Archived** view lets owners
and admins find archived objects and restore them.
A chart used by an alert or report cannot be archived while that dependency
exists. In the chart list view, the archive confirmation lists the alerts and
reports that use the chart; a blocked attempt names them and asks you to
detach or delete them first.
:::note
This view is gated by the `SOFT_DELETE` feature flag. When the flag is off the
+3 -3
View File
@@ -58,7 +58,7 @@
"@fontsource/inter": "^5.3.0",
"@mdx-js/react": "^3.1.1",
"@saucelabs/theme-github-codeblock": "^0.3.0",
"@storybook/addon-docs": "^10.5.7",
"@storybook/addon-docs": "^10.5.8",
"@superset-ui/core": "^0.20.4",
"@swc/core": "^1.15.47",
"antd": "^6.6.0",
@@ -77,7 +77,7 @@
"react-table": "^7.8.0",
"remark-import-partial": "^0.0.2",
"reselect": "^5.2.0",
"storybook": "^10.5.7",
"storybook": "^10.5.8",
"swagger-ui-react": "^5.32.13",
"swc-loader": "^0.2.7",
"tinycolor2": "^1.4.2",
@@ -93,7 +93,7 @@
"@typescript-eslint/parser": "^8.67.0",
"eslint": "^9.39.2",
"eslint-plugin-react": "^7.37.5",
"globals": "^17.10.0",
"globals": "^17.11.0",
"oxfmt": "^0.63.0",
"typescript": "~6.0.3",
"typescript-eslint": "^8.67.0",
@@ -0,0 +1,236 @@
<!--
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.
-->
# SIP: System-wide MEDIAN, Sample Standard Deviation, and Sample Variance metric aggregates
## [DRAFT — proposal for discussion]
This document now has an accompanying implementation in this PR, for the
proposed mechanism plus a first, empirically-verified engine set (Postgres,
MySQL, DuckDB, Redshift by inheritance). It is intended to seed discussion on
whether this is the right shape and scope before it goes up for a formal SIP
vote, not to pre-empt that discussion, the code exists so reviewers have a
concrete design to react to rather than a description of one.
## Motivation
Before #41184 (SIP-216, the non-additive-totals fix), the Pivot Table chart
exposed an "Aggregation function" control with 18 choices, including
`Median`, `Sample Standard Deviation`, `Sample Variance`, `First`, `Last`,
`Count Unique Values`, and `List Unique Values`. #41184 deleted that control
wholesale, and deliberately so: it re-aggregated already-aggregated cell
values to compute totals/subtotals, which is exactly the class of bug
SIP-216 fixed (summing per-group averages, averaging per-group medians, etc.
produces silently wrong totals). #42761 subsequently restored the one piece
of that control's functionality that was cleanly separable from the
correctness bug, the "show as % of row/column/total" display option,
redesigned as a decoupled, post-hoc-only `showValuesAs` control.
A user has since noticed that several of the other pre-#41184 options never
came back. Checking today's metric aggregate list (`AVG, COUNT,
COUNT_DISTINCT, MAX, MIN, SUM`, see
`superset-frontend/packages/superset-ui-core/src/query/types/Metric.ts`),
most of these have a reasonable equivalent already: `Count Unique Values`
maps to `COUNT_DISTINCT`; `Count`/`Average`/`Max`/`Min` are already standard
aggregates; the two "fraction of" variants are already covered by
`showValuesAs`. But `Median`, `Sample Standard Deviation`, and `Sample
Variance` have no equivalent today anywhere in Superset, not just in Pivot
Table, in any chart type, since the aggregate list is shared across the
whole app.
This is a real, currently-live gap, not a hypothetical one:
`superset/mcp_service/chart/chart_utils.py`, `schemas.py`, and
`prompts/create_chart_guided.py` already treat `STDDEV`, `VAR`, `MEDIAN`,
and `PERCENTILE` as valid aggregate values in their own validation and
documentation, but those values are never recognized by
`superset/connectors/sqla/models.py`'s `sqla_aggregations` dict (the actual
mapping from aggregate name to SQL), so an AI agent using the MCP tool to
build a chart with `"aggregate": "STDDEV"` today creates a chart that
**errors at query time** with "Adhoc metric aggregate is invalid." This SIP
proposes closing that gap for real, at the source, rather than patching
around it in MCP.
## Proposed change
Add `MEDIAN`, `STDDEV_SAMP`, and `VAR_SAMP` as first-class, system-wide
metric aggregates, available anywhere a metric aggregate is chosen (every
chart type, SQL Lab metric picker, MCP), not as a Pivot-Table-specific
control.
**Why this is safe with respect to SIP-216, and needs no Pivot-Table-specific
code at all:** Pivot Table's non-additive-totals machinery
(`superset-frontend/plugins/plugin-chart-pivot-table/src/plugin/utilities.ts`)
already classifies any metric aggregate not in `ADDITIVE_AGGREGATES = {SUM,
COUNT, MIN, MAX}` as non-additive, which routes totals/subtotals through the
correct DB-`GROUPING SETS`-rollup path rather than client-side
re-aggregation (`AVG` and `COUNT_DISTINCT` already go through this path
today). `MEDIAN`/`STDDEV_SAMP`/`VAR_SAMP` fall into that bucket
automatically, with zero changes needed to the additivity logic. So once
these are valid, buildable SQL aggregates, Pivot Table (and every other
chart) gets correct behavior for free. This is the version of "restore the
control" that does not reopen the bug that was just fixed.
**Where the actual change needs to land, and what this PR does:**
1. **Done.** `superset-frontend/packages/superset-ui-core/src/query/types/Metric.ts`,
extended the `Aggregate` type.
2. **Done.** `superset-frontend/src/explore/constants.ts`, added to `AGGREGATES`
(drives `AGGREGATES_OPTIONS`, the dropdown in `AdhocMetricEditPopover`).
3. **Done**, but not consolidated. `superset/connectors/sqla/models.py`
(`sqla_aggregations`) and `superset/models/helpers.py`
(`ExploreMixin.sqla_aggregations`) are both wired to consult the new
`BaseEngineSpec.get_extended_aggregation_func`, in addition to their
existing 6-aggregate dict, so neither's original, already-tested behavior
changed. They remain two separate dicts, consolidating them into one
source of truth is left as a follow-up (see Open questions).
4. **Done**, and it surfaced a second, smaller bug on top of the one this SIP
opened with: MCP's own aggregate names (`STDDEV`, `VAR`) never matched any
real Superset aggregate, before or after this PR, they were always going
to error regardless of what this SIP does. `superset/mcp_service/chart/*`
now accepts the old shorthand as an alias, normalized to the real,
unambiguous names (`STDDEV_SAMP`, `VAR_SAMP`) this PR ships, and the guided
prompt text points at the correct names going forward. `MEDIAN`/
`PERCENTILE` were already spelled correctly in MCP; `PERCENTILE` remains
unimplemented (it needs a parameter this schema has no field for) and is
unchanged by this PR, out of scope here.
**The part that needs real engineering care, this must not be a blind
`sa.func.MEDIAN` / `sa.func.STDDEV_SAMP` / `sa.func.VAR_SAMP`:**
`sqla_aggregations` today is a flat, engine-unaware dict (`sa.func.AVG`,
etc., SQLAlchemy emits whatever function name it is given, with zero
validation that the target dialect actually has it). Superset already has
precedent for exactly this class of per-engine capability difference:
`BaseEngineSpec.supports_grouping_sets` and `_time_grain_expressions`, both
introduced by #41184 itself. This SIP proposes the same shape, a new
per-engine-overridable mechanism (for example
`BaseEngineSpec.get_aggregate_sql(aggregate, column)` with a sensible
default, overridden per engine spec where the default does not hold),
rather than a single hardcoded dict.
Verified findings so far (via `sqlglot.transpile`, cross-checked against
known engine docs; **not** exhaustively tested against live databases, that
is necessary follow-up work this SIP alone cannot complete):
| Engine | `MEDIAN(x)` | `STDDEV_SAMP(x)` | `VAR_SAMP(x)` |
|---|---|---|---|
| Postgres | `PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY x)` | native | native |
| MySQL | no native equivalent, needs explicit "unsupported" handling, not a blind emit | native | MySQL's `VARIANCE()` is an alias for `VAR_POP` (population), not `VAR_SAMP` (sample); a naive dialect-name substitution would silently compute the wrong statistic and needs an explicit, verified expression instead |
| SQLite | only if the specific build was compiled with the (non-default) `SQLITE_ENABLE_PERCENTILE` extension (added in SQLite 3.43, 2023), cannot be assumed available | not available in core SQLite | not available in core SQLite |
| BigQuery / Snowflake / DuckDB / Redshift / Oracle / T-SQL / Databricks / Spark | native `MEDIAN(x)` | native | native on BigQuery/Snowflake/Databricks/Spark, where `VARIANCE` is correctly sample variance; T-SQL has no function named `VARIANCE` at all and needs `VAR(x)` instead |
| Trino / Presto / Hive | `PERCENTILE_CONT` / `approx_percentile` (dialect- and exactness-dependent) | native | `variance` is correctly sample variance per Trino/Presto docs |
This table is deliberately not exhaustive, Superset has roughly 75
`db_engine_specs` files. The proposed default (`BaseEngineSpec`) should be
the safe choice (mark unsupported, surface a clear user-facing error) rather
than an optimistic one, with individual engine specs opting in once
verified. Ship for the handful of engines above first, extend
opportunistically.
**`Count Unique Values`, `First`, `Last`, `List Unique Values`, explicitly
out of scope for this SIP:**
- `Count Unique Values` needs no work, it is already `COUNT_DISTINCT`.
- `First`/`Last` have no well-defined, unambiguous meaning as a plain
`GROUP BY` aggregate without an explicit ordering; most engines only
support this via window functions (`FIRST_VALUE`/`LAST_VALUE` `OVER
(ORDER BY ...)`) or do not support it as a simple aggregate at all
(Postgres has neither built in). Restoring this properly would mean
designing an "order by" sub-control on the metric, a real, separate
feature, not a one-line aggregate addition. Proposed as a follow-up SIP if
there is demand.
- `List Unique Values` maps to the `STRING_AGG`/`GROUP_CONCAT`/`LISTAGG`/
`ARRAY_AGG(DISTINCT ...)` family, real dialect differences, plus an open
UX question (unbounded cell content for high-cardinality columns).
Proposed as a follow-up SIP.
## New or changed public interfaces
- New `Aggregate` values (`MEDIAN`, `STDDEV_SAMP`, `VAR_SAMP`) selectable
anywhere the standard metric control appears, every chart type, not just
Pivot Table.
- New `BaseEngineSpec` extensibility point for per-engine aggregate SQL
generation (exact shape TBD in implementation, likely mirrors
`_time_grain_expressions`).
- No REST API surface changes beyond the existing metric aggregate field
accepting new values.
## Migration plan and compatibility
No new tables/columns needed for the aggregate addition itself.
Restoring prior chart settings, the way #42761 restored `show_values_as` for
charts that had it before #41184, is murkier here than it was for that PR
and needs its own design pass: the old `aggregate_function` was a single
Pivot-Table-level setting applied uniformly to every metric on the chart,
not a per-metric property. A chart that had `aggregate_function: Median`
before #41184, with a metric of `SUM(sales)`, was already silently wrong
under the old architecture (that is the bug that was fixed); mechanically
rewriting its metric to `MEDIAN(sales)` on upgrade would change what the
chart's leaf cells display, not just its totals, which may not match user
intent. This SIP proposes a best-effort, flagged-for-review migration
(surface affected charts to admins rather than silently rewriting them)
rather than a fully automatic one-to-one restoration.
## Rejected alternatives
- **Restoring the old `aggregateFunction` Pivot-Table control as-is.**
Rejected: this is the literal mechanism SIP-216 removed because it
reintroduces incorrect totals for non-additive metrics. Any fix has to go
through the metric's own aggregate, not a separate pivot-level override.
- **Routing all metric SQL generation through `sqlglot` expression-building
instead of SQLAlchemy's `sa.func`.** More architecturally thorough (would
give correct dialect syntax for free across more of the roughly 75 engine
specs), but a much larger, more invasive change to a hot path used by
every chart query. Noted as a possible future direction, not this SIP's
scope; this SIP proposes the smaller, `supports_grouping_sets`-shaped
extensibility point instead.
## Open questions
- **Resolved for this PR, worth confirming as the community's preferred
shape:** implemented as `BaseEngineSpec._extended_aggregations` (a
`{aggregate_name: sqla_column -> sqla_column}` dict) plus a
`get_extended_aggregation_func` accessor, set on the concrete or shared
base engine spec class per engine (e.g. on `PostgresBaseEngineSpec` so
Redshift inherits it, but *not* on `PrestoBaseEngineSpec` so Hive/Spark/
Databricks don't silently inherit unverified behavior, mirroring how
`supports_grouping_sets` is opted into per-concrete-engine there today).
Did not route through the `superset/sql/dialects/` sqlglot-based layer;
that layer is for SQL Lab parsing, wiring it into chart-metric query
building felt like a separate, larger change from this SIP's scope.
- **Still open, not addressed in this PR:** how aggressively should
`MEDIAN` degrade on engines without a native or exact equivalent?
Trino/Presto/Hive were left unimplemented (unsupported) specifically to
avoid silently answering this with an approximate function
(`approx_percentile`) that changes the semantics of what a user asked
for. If someone wants `MEDIAN` on those engines, this needs a real
decision: require explicit opt-in, show a UI warning, or keep it
disallowed.
- **Resolved for this PR:** left the two `sqla_aggregations` dicts
(`connectors/sqla/models.py` and `models/helpers.py`) unconsolidated,
both now separately wired to the same new `get_extended_aggregation_func`
hook. Consolidating them into one source of truth is real but unrelated
cleanup, not bundled here to keep the diff reviewable.
- **New, from implementation:** only Postgres, MySQL (partial), DuckDB, and
Redshift (by inheritance, unverified) ship enabled. BigQuery, Snowflake,
Trino, Presto, Hive, Spark, Databricks, Oracle, and T-SQL all have
documented (not live-verified) support per the table above but are not
yet wired up, each needs the same live-instance verification treatment
before being enabled, this PR intentionally didn't guess.
-6
View File
@@ -21,12 +21,6 @@
"lifecycle": "development",
"description": "Enables experimental chart plugins"
},
{
"name": "CSP_RUNTIME_ALLOWLIST",
"default": false,
"lifecycle": "development",
"description": "Allow users with the \"can write on CSPAllowlist\" permission (Admins by default) to punch holes in the Content Security Policy at runtime, e.g. to allow a new domain to be embedded in a dashboard iframe component. When disabled, the CSP is purely static/deploy-time and the allowlist is ignored."
},
{
"name": "CSV_UPLOAD_PYARROW_ENGINE",
"default": false,
+22 -22
View File
@@ -4122,23 +4122,23 @@
resolved "https://registry.yarnpkg.com/@standard-schema/utils/-/utils-0.3.0.tgz#3d5e608f16c2390c10528e98e59aef6bf73cae7b"
integrity sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==
"@storybook/addon-docs@^10.5.7":
version "10.5.7"
resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-10.5.7.tgz#6d599c94fc871c248ce06a5c081f57655c83f40a"
integrity sha512-KNARJfjICaizinsR3INMEiipZm1ObYo+xw+E26gteu50Bcy2dIZUtk5uHY5XdtardU3AXX6yRXoBZ2HCY3lbHA==
"@storybook/addon-docs@^10.5.8":
version "10.5.8"
resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-10.5.8.tgz#767c10c7a4cc1b625b93f869b2a2b09fc8514f2e"
integrity sha512-NlHiMKW/UvW/uL8HXFDCEVwoH3qZeGYZ/qlWax4d7H471b/T54MBq2KcB4ZrdA785FfIH3numAJdBb5jwn00Mg==
dependencies:
"@mdx-js/react" "^3.0.0"
"@storybook/csf-plugin" "10.5.7"
"@storybook/csf-plugin" "10.5.8"
"@storybook/icons" "^2.0.2"
"@storybook/react-dom-shim" "10.5.7"
"@storybook/react-dom-shim" "10.5.8"
react "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
react-dom "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
ts-dedent "^2.0.0"
"@storybook/csf-plugin@10.5.7":
version "10.5.7"
resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-10.5.7.tgz#bc73f164d1b5f8e2931b2774f4b389a06453cf6e"
integrity sha512-IaX8FlM0H36HNFhJ2+4L9bCldqfvHGqcLg841SJNyK/DhfMlM7JsvY/GDH2ZFuWrUf8FSOx96GRRnHq6XfRKag==
"@storybook/csf-plugin@10.5.8":
version "10.5.8"
resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-10.5.8.tgz#c626c5bfe55d0e279b2457e5cf150a788d1fc637"
integrity sha512-/FHiMyOWWEXfwK/lM0WxmkP9GLzbSJJuzGtfeuNWSOVDnvAMbjavitxfHb5wSbWKIQo0XYC1EJ2Y7x91XNYP4w==
dependencies:
unplugin "^2.3.5"
@@ -4152,10 +4152,10 @@
resolved "https://registry.yarnpkg.com/@storybook/icons/-/icons-2.1.0.tgz#edfc2450a39c5e780f28c6cbc49acd7bff59b41a"
integrity sha512-Fxh9vYpX9bQqFeHRiY8h2ApeRGDzRSMLwJwNZ/AIRqnyOKHxRKL+yFe+ctEkVJmuptRE9u1Hrn8ZZNHyfDKKNg==
"@storybook/react-dom-shim@10.5.7":
version "10.5.7"
resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-10.5.7.tgz#9a5aa0e0f89c09e71c6cbfc6bb1abeb537e5aabf"
integrity sha512-lxOkyh+wu/MiBXvYQHjZfD+DRKOa4bHBzbuGuiHXnHXmdOcTRdcrQTsoeN2FPtfugmmOG66cZUEgDwNX+k5eRA==
"@storybook/react-dom-shim@10.5.8":
version "10.5.8"
resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-10.5.8.tgz#40cc3e32af424baa2e4109a325dae2ede29999e2"
integrity sha512-N8D13/Xny+V3kfe1KBgsAHS0nKWXLLdgOOXS9poKdYzVwVCN+CGEGBxWX0zMMtdCptqa6/57em9coPlZMoO+bg==
"@superset-ui/core@^0.20.4":
version "0.20.4"
@@ -9174,10 +9174,10 @@ globals@^14.0.0:
resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e"
integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==
globals@^17.10.0:
version "17.10.0"
resolved "https://registry.yarnpkg.com/globals/-/globals-17.10.0.tgz#f9dbd847ae99e236f98b13095e2426ac3b25a45c"
integrity sha512-V0kztuWST2k8A/VbxAY8+L+7+Rgo3fyA24IHRLrZp7HOzJjV0gHSaZUjK9lpP/IrBSNite2tZ1prhRkinRu1CA==
globals@^17.11.0:
version "17.11.0"
resolved "https://registry.yarnpkg.com/globals/-/globals-17.11.0.tgz#d643485bb30220d7751e511cf4f68c73d3870d87"
integrity sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==
globalthis@^1.0.4:
version "1.0.4"
@@ -14783,10 +14783,10 @@ stop-iteration-iterator@^1.1.0:
es-errors "^1.3.0"
internal-slot "^1.1.0"
storybook@^10.5.7:
version "10.5.7"
resolved "https://registry.yarnpkg.com/storybook/-/storybook-10.5.7.tgz#adfc465e51f337291c095278c23f1b8024ef2da7"
integrity sha512-oiKvWIwIoOhFP1i6dASYyMXwPHKEtVZMshqSB7EvIVYjWRh0l9H7gHEt1z4Gh2rLGFMekWdsm4s94rvwpR7gkg==
storybook@^10.5.8:
version "10.5.8"
resolved "https://registry.yarnpkg.com/storybook/-/storybook-10.5.8.tgz#d5f051983e6232c0a73ea02149a72c7bafb43275"
integrity sha512-rR4oFMSiWBSqI0lvsJPtcQUPj8+hzj3TkLu+Mw61Wo6YxPSb5FsLSHai0jZnuaIdKIlmu25KCfwlSQl4e1uvnA==
dependencies:
"@storybook/global" "^5.0.0"
"@storybook/icons" "^2.0.2"
+1 -1
View File
@@ -93,7 +93,7 @@ def find_models(module: ModuleType) -> list[type[Model]]: # noqa: C901
# where the current model is out-of-sync with the existing table after a
# downgrade
sqlalchemy_uri = current_app.config["SQLALCHEMY_DATABASE_URI"]
engine = create_engine(sqlalchemy_uri, future=True)
engine = create_engine(sqlalchemy_uri)
Base = automap_base() # noqa: N806
Base.prepare(engine, reflect=True)
seen = set()
+74 -74
View File
@@ -99,7 +99,7 @@
"geostyler-openlayers-parser": "^5.7.1",
"geostyler-style": "11.0.2",
"geostyler-wfs-parser": "^3.0.1",
"google-auth-library": "^11.0.1",
"google-auth-library": "^11.0.2",
"immer": "^11.1.16",
"interweave": "^13.1.1",
"jquery": "^4.0.0",
@@ -180,9 +180,9 @@
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@playwright/test": "^1.62.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
"@storybook/addon-docs": "10.5.7",
"@storybook/addon-links": "10.5.7",
"@storybook/react-webpack5": "10.5.7",
"@storybook/addon-docs": "10.5.8",
"@storybook/addon-links": "10.5.8",
"@storybook/react-webpack5": "10.5.8",
"@storybook/test-runner": "0.24.4",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.15.47",
@@ -218,7 +218,7 @@
"babel-loader": "^10.1.1",
"babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"baseline-browser-mapping": "^2.11.13",
"baseline-browser-mapping": "^2.11.14",
"cheerio": "1.2.0",
"concurrently": "^10.0.4",
"copy-webpack-plugin": "^14.0.0",
@@ -235,7 +235,7 @@
"eslint-plugin-no-only-tests": "^3.4.0",
"eslint-plugin-react-prefer-function-component": "^5.0.0",
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.1",
"eslint-plugin-storybook": "10.5.7",
"eslint-plugin-storybook": "10.5.8",
"eslint-plugin-testing-library": "^7.16.2",
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
"fetch-mock": "^12.6.0",
@@ -266,7 +266,7 @@
"source-map": "^0.8.0",
"source-map-support": "^0.5.21",
"speed-measure-webpack-plugin": "^1.6.0",
"storybook": "10.5.7",
"storybook": "10.5.8",
"style-loader": "^4.0.0",
"stylelint": "^17.14.1",
"swc-loader": "^0.2.7",
@@ -10765,16 +10765,16 @@
"license": "MIT"
},
"node_modules/@storybook/addon-docs": {
"version": "10.5.7",
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.7.tgz",
"integrity": "sha512-KNARJfjICaizinsR3INMEiipZm1ObYo+xw+E26gteu50Bcy2dIZUtk5uHY5XdtardU3AXX6yRXoBZ2HCY3lbHA==",
"version": "10.5.8",
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.8.tgz",
"integrity": "sha512-NlHiMKW/UvW/uL8HXFDCEVwoH3qZeGYZ/qlWax4d7H471b/T54MBq2KcB4ZrdA785FfIH3numAJdBb5jwn00Mg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@mdx-js/react": "^3.0.0",
"@storybook/csf-plugin": "10.5.7",
"@storybook/csf-plugin": "10.5.8",
"@storybook/icons": "^2.0.2",
"@storybook/react-dom-shim": "10.5.7",
"@storybook/react-dom-shim": "10.5.8",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"ts-dedent": "^2.0.0"
@@ -10785,7 +10785,7 @@
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "10.5.7"
"storybook": "10.5.8"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -10794,9 +10794,9 @@
}
},
"node_modules/@storybook/addon-docs/node_modules/@storybook/csf-plugin": {
"version": "10.5.7",
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.7.tgz",
"integrity": "sha512-IaX8FlM0H36HNFhJ2+4L9bCldqfvHGqcLg841SJNyK/DhfMlM7JsvY/GDH2ZFuWrUf8FSOx96GRRnHq6XfRKag==",
"version": "10.5.8",
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.8.tgz",
"integrity": "sha512-/FHiMyOWWEXfwK/lM0WxmkP9GLzbSJJuzGtfeuNWSOVDnvAMbjavitxfHb5wSbWKIQo0XYC1EJ2Y7x91XNYP4w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10809,7 +10809,7 @@
"peerDependencies": {
"esbuild": "*",
"rollup": "*",
"storybook": "10.5.7",
"storybook": "10.5.8",
"vite": "*",
"webpack": "*"
},
@@ -10829,9 +10829,9 @@
}
},
"node_modules/@storybook/addon-docs/node_modules/@storybook/react-dom-shim": {
"version": "10.5.7",
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.7.tgz",
"integrity": "sha512-lxOkyh+wu/MiBXvYQHjZfD+DRKOa4bHBzbuGuiHXnHXmdOcTRdcrQTsoeN2FPtfugmmOG66cZUEgDwNX+k5eRA==",
"version": "10.5.8",
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.8.tgz",
"integrity": "sha512-N8D13/Xny+V3kfe1KBgsAHS0nKWXLLdgOOXS9poKdYzVwVCN+CGEGBxWX0zMMtdCptqa6/57em9coPlZMoO+bg==",
"dev": true,
"license": "MIT",
"funding": {
@@ -10843,7 +10843,7 @@
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "10.5.7"
"storybook": "10.5.8"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -10855,9 +10855,9 @@
}
},
"node_modules/@storybook/addon-links": {
"version": "10.5.7",
"resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-10.5.7.tgz",
"integrity": "sha512-17PxEOocLhAEaPeQ4q+8yul/LF9YEIePS1arknCAS7U1pQXTe0uj+R0pB6uPLVflM5gECQMiP4WzIj4tEiL6+A==",
"version": "10.5.8",
"resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-10.5.8.tgz",
"integrity": "sha512-mpWw4alBJVGqgVh897LZ2keN/xnMHcH93wKJG+oGg4+cdEUA+06hCs5T4k+AS5Aa+EZ6LvdOoi2VPHssyQlCCA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10870,7 +10870,7 @@
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "10.5.7"
"storybook": "10.5.8"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -10964,15 +10964,15 @@
}
},
"node_modules/@storybook/react-webpack5": {
"version": "10.5.7",
"resolved": "https://registry.npmjs.org/@storybook/react-webpack5/-/react-webpack5-10.5.7.tgz",
"integrity": "sha512-vvl07oXp2qfmHJHZ77Aw1F3LFOo7XubOta+lC8UmlEw3rDDJhQxJN3erJJVHavNhdA2jBTK6VUXQKdqQh7X7nQ==",
"version": "10.5.8",
"resolved": "https://registry.npmjs.org/@storybook/react-webpack5/-/react-webpack5-10.5.8.tgz",
"integrity": "sha512-HkPi42WaoNSHC0DAERsJEF7Vhnluzsp/aiuhnH65GGYG5TmdLL9G8KDiYvXHGDCyb4RfoAPYrtzxaLMFfPPFvQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@storybook/builder-webpack5": "10.5.7",
"@storybook/preset-react-webpack": "10.5.7",
"@storybook/react": "10.5.7"
"@storybook/builder-webpack5": "10.5.8",
"@storybook/preset-react-webpack": "10.5.8",
"@storybook/react": "10.5.8"
},
"funding": {
"type": "opencollective",
@@ -10981,7 +10981,7 @@
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "10.5.7",
"storybook": "10.5.8",
"typescript": ">= 4.9.x"
},
"peerDependenciesMeta": {
@@ -10991,13 +10991,13 @@
}
},
"node_modules/@storybook/react-webpack5/node_modules/@storybook/builder-webpack5": {
"version": "10.5.7",
"resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-10.5.7.tgz",
"integrity": "sha512-4n4c60LihFivZnjAcXGO5+XbgZthoUtKb/nPKVgypj3MpEetzjq6XR83A4UNnRsXYmjqfn6bsDWNgEJ/RvQg5A==",
"version": "10.5.8",
"resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-10.5.8.tgz",
"integrity": "sha512-ke5x27gtWQ4gpXCLWxdGkr8ZlJwBykV/KjbBTAlC04dmS9OkI9MBzGj+TteUlgrcaN7LwoNTR5zRmxKStOZYzQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@storybook/core-webpack": "10.5.7",
"@storybook/core-webpack": "10.5.8",
"case-sensitive-paths-webpack-plugin": "^2.4.0",
"cjs-module-lexer": "^1.2.3",
"css-loader": "^7.1.2",
@@ -11019,7 +11019,7 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
"storybook": "10.5.7"
"storybook": "10.5.8"
},
"peerDependenciesMeta": {
"typescript": {
@@ -11028,9 +11028,9 @@
}
},
"node_modules/@storybook/react-webpack5/node_modules/@storybook/builder-webpack5/node_modules/@storybook/core-webpack": {
"version": "10.5.7",
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.7.tgz",
"integrity": "sha512-0dtDw/FNPREoeCHX2RgZz0OecxaAGol1R7bCobFevArxyFIPJisTfjDMUFHKr+3B7BilTd3vnatl7Nlvgs0EiA==",
"version": "10.5.8",
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.8.tgz",
"integrity": "sha512-HccINB0UbTtnyJtKpaX+C35BRTSnAwnreIMwwI+LpeUd4x9mQg0G9orB7lfBBZwd5LQf8YhM2Vkjiawzo41GLg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -11041,17 +11041,17 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
"storybook": "10.5.7"
"storybook": "10.5.8"
}
},
"node_modules/@storybook/react-webpack5/node_modules/@storybook/preset-react-webpack": {
"version": "10.5.7",
"resolved": "https://registry.npmjs.org/@storybook/preset-react-webpack/-/preset-react-webpack-10.5.7.tgz",
"integrity": "sha512-xwNRcoVlIDx1/YYCFBAxfh/91vFiOgrVI+0Ir4u9eO87SH2leehRnJh619QEOrlQEU5px487y2BmL2ZVtmTpYA==",
"version": "10.5.8",
"resolved": "https://registry.npmjs.org/@storybook/preset-react-webpack/-/preset-react-webpack-10.5.8.tgz",
"integrity": "sha512-0JjgVoX5t9Wb+gwddYHx/Ej7KFqwd65lpHXEhBoT4pFWRqVI0pvfHu42M+DRGjsgOye3uE+3pH4yHR3+0/fCHA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@storybook/core-webpack": "10.5.7",
"@storybook/core-webpack": "10.5.8",
"@storybook/react-docgen-typescript-plugin": "1.0.6--canary.9.0c3f3b7.0",
"@types/semver": "^7.7.1",
"magic-string": "^0.30.5",
@@ -11068,7 +11068,7 @@
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "10.5.7"
"storybook": "10.5.8"
},
"peerDependenciesMeta": {
"typescript": {
@@ -11077,9 +11077,9 @@
}
},
"node_modules/@storybook/react-webpack5/node_modules/@storybook/preset-react-webpack/node_modules/@storybook/core-webpack": {
"version": "10.5.7",
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.7.tgz",
"integrity": "sha512-0dtDw/FNPREoeCHX2RgZz0OecxaAGol1R7bCobFevArxyFIPJisTfjDMUFHKr+3B7BilTd3vnatl7Nlvgs0EiA==",
"version": "10.5.8",
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.8.tgz",
"integrity": "sha512-HccINB0UbTtnyJtKpaX+C35BRTSnAwnreIMwwI+LpeUd4x9mQg0G9orB7lfBBZwd5LQf8YhM2Vkjiawzo41GLg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -11090,18 +11090,18 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
"storybook": "10.5.7"
"storybook": "10.5.8"
}
},
"node_modules/@storybook/react-webpack5/node_modules/@storybook/react": {
"version": "10.5.7",
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.7.tgz",
"integrity": "sha512-uFvty2MMdFXzW5PcQe1JqDAZkz6cQq7q/9G/cbGVnBEvP6zsOVeL+bmrQ0/WBlFQN0Ko9+ZoCTvaQ9s65zBa5g==",
"version": "10.5.8",
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.8.tgz",
"integrity": "sha512-6qqkmqX6imtL+0Z9Uan2tIfYivOI0FiVmWr0zpqqQR15AkJ18JfNcNTQoyjeAlCO0Kei56SWqnu2qLq52TYplg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@storybook/global": "^5.0.0",
"@storybook/react-dom-shim": "10.5.7",
"@storybook/react-dom-shim": "10.5.8",
"react-docgen": "^8.0.2",
"react-docgen-typescript": "^2.2.2"
},
@@ -11114,7 +11114,7 @@
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "10.5.7",
"storybook": "10.5.8",
"typescript": ">= 4.9.x"
},
"peerDependenciesMeta": {
@@ -11130,9 +11130,9 @@
}
},
"node_modules/@storybook/react-webpack5/node_modules/@storybook/react/node_modules/@storybook/react-dom-shim": {
"version": "10.5.7",
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.7.tgz",
"integrity": "sha512-lxOkyh+wu/MiBXvYQHjZfD+DRKOa4bHBzbuGuiHXnHXmdOcTRdcrQTsoeN2FPtfugmmOG66cZUEgDwNX+k5eRA==",
"version": "10.5.8",
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.8.tgz",
"integrity": "sha512-N8D13/Xny+V3kfe1KBgsAHS0nKWXLLdgOOXS9poKdYzVwVCN+CGEGBxWX0zMMtdCptqa6/57em9coPlZMoO+bg==",
"dev": true,
"license": "MIT",
"funding": {
@@ -11144,7 +11144,7 @@
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "10.5.7"
"storybook": "10.5.8"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -15697,9 +15697,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.11.13",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz",
"integrity": "sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==",
"version": "2.11.14",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.14.tgz",
"integrity": "sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -20219,9 +20219,9 @@
}
},
"node_modules/eslint-plugin-storybook": {
"version": "10.5.7",
"resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.5.7.tgz",
"integrity": "sha512-mLpamG1Rsica2jYbUzIZOEuy7Fm1IMtVLMvvxGTpjTVKUMxTXJsANx3MBpH2VSbGQB8Yzlt5399WL/O07K97Ig==",
"version": "10.5.8",
"resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.5.8.tgz",
"integrity": "sha512-bf9W5nZyWdIaCUZf4aEZnEeD1mn+csNYX8dYUQjAo6L7/DkSLtr65R4zFZ1xeS4m6dOXO6UtUySesCSw4e8w1g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -20230,7 +20230,7 @@
},
"peerDependencies": {
"eslint": ">=8",
"storybook": "10.5.7"
"storybook": "10.5.8"
}
},
"node_modules/eslint-plugin-testing-library": {
@@ -20612,7 +20612,7 @@
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/expect-playwright/-/expect-playwright-0.8.0.tgz",
"integrity": "sha512-+kn8561vHAY+dt+0gMqqj1oY+g5xWrsuGMk4QGxotT2WS545nVqqjs37z6hrYfIuucwqthzwJfCJUEYqixyljg==",
"deprecated": "\u26a0\ufe0f The 'expect-playwright' package is deprecated. The Playwright core assertions (via @playwright/test) now cover the same functionality. Please migrate to built-in expect. See https://playwright.dev/docs/test-assertions for migration.",
"deprecated": "⚠️ The 'expect-playwright' package is deprecated. The Playwright core assertions (via @playwright/test) now cover the same functionality. Please migrate to built-in expect. See https://playwright.dev/docs/test-assertions for migration.",
"dev": true,
"license": "MIT"
},
@@ -22783,9 +22783,9 @@
"license": "MIT"
},
"node_modules/google-auth-library": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-11.0.1.tgz",
"integrity": "sha512-ZqfaYduu9ASUaFuUk5dF9g9QvufdhhSj7jFiEnCrTQcH57sFPKYetM0iU4dcKkQk6CqC1xpSrVr5uQ9NhqjNOg==",
"version": "11.0.2",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-11.0.2.tgz",
"integrity": "sha512-vzpgPutxrghPsnjrjpzLX2bdv8IOL719Rh0oEjGnQu8YCIbnbMuTTQ5zU9LcKvLdOPgCxBwppbvnhgW90Qna5Q==",
"license": "Apache-2.0",
"dependencies": {
"base64-js": "^1.3.0",
@@ -26023,7 +26023,7 @@
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/jest-process-manager/-/jest-process-manager-0.4.0.tgz",
"integrity": "sha512-80Y6snDyb0p8GG83pDxGI/kQzwVTkCxc7ep5FPe/F6JYdvRDhwr6RzRmPSP7SEwuLhxo80lBS/NqOdUIbHIfhw==",
"deprecated": "\u26a0\ufe0f The 'jest-process-manager' package is deprecated. Please migrate to Playwright's built-in test runner (@playwright/test) which now includes full Jest-style features and parallel testing. See https://playwright.dev/docs/intro for details.",
"deprecated": "⚠️ The 'jest-process-manager' package is deprecated. Please migrate to Playwright's built-in test runner (@playwright/test) which now includes full Jest-style features and parallel testing. See https://playwright.dev/docs/intro for details.",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -37711,9 +37711,9 @@
}
},
"node_modules/storybook": {
"version": "10.5.7",
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.7.tgz",
"integrity": "sha512-oiKvWIwIoOhFP1i6dASYyMXwPHKEtVZMshqSB7EvIVYjWRh0l9H7gHEt1z4Gh2rLGFMekWdsm4s94rvwpR7gkg==",
"version": "10.5.8",
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.8.tgz",
"integrity": "sha512-rR4oFMSiWBSqI0lvsJPtcQUPj8+hzj3TkLu+Mw61Wo6YxPSb5FsLSHai0jZnuaIdKIlmu25KCfwlSQl4e1uvnA==",
"dev": true,
"license": "MIT",
"dependencies": {
+7 -7
View File
@@ -176,7 +176,7 @@
"geostyler-openlayers-parser": "^5.7.1",
"geostyler-style": "11.0.2",
"geostyler-wfs-parser": "^3.0.1",
"google-auth-library": "^11.0.1",
"google-auth-library": "^11.0.2",
"immer": "^11.1.16",
"interweave": "^13.1.1",
"jquery": "^4.0.0",
@@ -257,9 +257,9 @@
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@playwright/test": "^1.62.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
"@storybook/addon-docs": "10.5.7",
"@storybook/addon-links": "10.5.7",
"@storybook/react-webpack5": "10.5.7",
"@storybook/addon-docs": "10.5.8",
"@storybook/addon-links": "10.5.8",
"@storybook/react-webpack5": "10.5.8",
"@storybook/test-runner": "0.24.4",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.15.47",
@@ -295,7 +295,7 @@
"babel-loader": "^10.1.1",
"babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"baseline-browser-mapping": "^2.11.13",
"baseline-browser-mapping": "^2.11.14",
"cheerio": "1.2.0",
"concurrently": "^10.0.4",
"copy-webpack-plugin": "^14.0.0",
@@ -312,7 +312,7 @@
"eslint-plugin-no-only-tests": "^3.4.0",
"eslint-plugin-react-prefer-function-component": "^5.0.0",
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.1",
"eslint-plugin-storybook": "10.5.7",
"eslint-plugin-storybook": "10.5.8",
"eslint-plugin-testing-library": "^7.16.2",
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
"fetch-mock": "^12.6.0",
@@ -343,7 +343,7 @@
"source-map": "^0.8.0",
"source-map-support": "^0.5.21",
"speed-measure-webpack-plugin": "^1.6.0",
"storybook": "10.5.7",
"storybook": "10.5.8",
"style-loader": "^4.0.0",
"stylelint": "^17.14.1",
"swc-loader": "^0.2.7",
@@ -35,10 +35,6 @@
"types": "./lib/commands/index.d.ts",
"default": "./lib/commands/index.js"
},
"./dashboardComponents": {
"types": "./lib/dashboardComponents/index.d.ts",
"default": "./lib/dashboardComponents/index.js"
},
"./editors": {
"types": "./lib/editors/index.d.ts",
"default": "./lib/editors/index.js"
@@ -28,7 +28,6 @@
import { Chat } from '../chat';
import { Command } from '../commands';
import { DashboardComponentDefinition } from '../dashboardComponents';
import { View } from '../views';
import { Menu } from '../menus';
import { Editor } from '../editors';
@@ -91,9 +90,4 @@ export interface Contributions {
* chat at a time.
*/
chat?: Chat;
/**
* Dashboard layout components contributed by the extension. Each becomes a
* first-class, draggable element in the dashboard builder palette.
*/
dashboardComponents?: DashboardComponentDefinition[];
}
@@ -1,158 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* @fileoverview Dashboard component contribution API for Superset extensions.
*
* A dashboard component is a first-class dashboard layout element (like the
* built-in Markdown or iframe) contributed by an extension. The extension
* provides a single React component that renders the element's *content*; the
* host owns the surrounding chrome (drag handle, resize, delete) so the
* contributed component stays small and the contract stable.
*
* This replaces the legacy `DashboardComponentsRegistry` / `DYNAMIC_TYPE`
* mechanism, which is deprecated.
*
* @example
* ```typescript
* import { dashboardComponents } from '@apache-superset/core';
*
* dashboardComponents.registerDashboardComponent(
* {
* id: 'acme.weather',
* name: 'Weather widget',
* icon: 'CloudOutlined',
* resizable: true,
* defaultMeta: { width: 4, height: 50 },
* },
* WeatherWidget,
* );
* ```
*/
import { ComponentType } from 'react';
import type { Disposable, Event } from '../common';
/**
* Props passed by the host to a contributed dashboard component. The host
* renders this component inside its own drag/resize/delete chrome, so the
* component only needs to render content (and, in edit mode, its own editor
* affordances). Persisted state lives in `meta`; mutate it via `updateMeta`.
*/
export interface DashboardComponentProps {
/** The layout item id of this component instance. */
id: string;
/** The component instance's persisted meta (round-trips in the layout). */
meta: Record<string, unknown>;
/** Whether the dashboard is in edit mode. */
editMode: boolean;
/** Shallow-merge a patch into this component's persisted meta. */
updateMeta: (patch: Record<string, unknown>) => void;
}
/**
* Declarative descriptor for a contributed dashboard component. The behavior
* fields replace what was historically hardcoded in the dashboard util maps
* (resizability, default sizing, nesting, etc.).
*/
export interface DashboardComponentDefinition {
/** Namespaced unique id, e.g. "acme.weather" or "superset.iframe". */
id: string;
/** Human-readable label shown in the builder palette. */
name: string;
/** Optional longer description. */
description?: string;
/** Icon id (a known Superset icon name) shown in the palette. */
icon?: string;
/** Whether instances can be resized. Defaults to true. */
resizable?: boolean;
/** Default `meta` seeded onto a newly created instance (e.g. width/height). */
defaultMeta?: Record<string, unknown>;
/**
* Whether an instance counts as user content for "is this dashboard empty?"
* detection. Defaults to true.
*/
isUserContent?: boolean;
/** Minimum width in grid columns. Defaults to 1. */
minWidth?: number;
/**
* Restrict which container types may hold this component (e.g.
* `['GRID', 'TAB']`). When omitted, the component is allowed wherever a
* standard content leaf is allowed (grid, row, column, tab).
*/
validParents?: string[];
/**
* Whether a drop into the grid or a tab auto-wraps the component in a row.
* Defaults to true (matching built-in content components).
*/
wrapInRow?: boolean;
}
/**
* The subset of a definition's behavior that is seeded onto each instance's
* `meta` at creation, so the dashboard layout engine can honor it (and so it
* round-trips in the saved layout even if the extension later becomes
* unavailable). Read by the dashboard util maps; not part of the rendered
* component's concern.
*/
export interface DashboardComponentBehaviorMeta {
extensionComponentId: string;
resizable?: boolean;
isUserContent?: boolean;
minWidth?: number;
validParents?: string[];
wrapInRow?: boolean;
}
/**
* A registered dashboard component: its definition plus the React component
* the host renders.
*/
export interface RegisteredDashboardComponent {
definition: DashboardComponentDefinition;
Component: ComponentType<DashboardComponentProps>;
}
/**
* Registers a dashboard component. Disposing the returned Disposable
* unregisters it. Registering a second component with the same id replaces the
* first.
*
* @param definition The component descriptor (id, name, behavior).
* @param component The React component rendering the element's content.
* @returns A Disposable that unregisters the component when disposed.
*/
export declare function registerDashboardComponent(
definition: DashboardComponentDefinition,
component: ComponentType<DashboardComponentProps>,
): Disposable;
/** Returns the registered component for `id`, or undefined. */
export declare function getDashboardComponent(
id: string,
): RegisteredDashboardComponent | undefined;
/** Returns all registered dashboard components. */
export declare function getDashboardComponents(): RegisteredDashboardComponent[];
/** Event fired when a dashboard component is registered. */
export declare const onDidRegisterDashboardComponent: Event<DashboardComponentDefinition>;
/** Event fired when a dashboard component is unregistered. */
export declare const onDidUnregisterDashboardComponent: Event<DashboardComponentDefinition>;
@@ -20,7 +20,6 @@ export * as common from './common';
export * as authentication from './authentication';
export * as chat from './chat';
export * as commands from './commands';
export * as dashboardComponents from './dashboardComponents';
export * as editors from './editors';
export * as extensions from './extensions';
export * as menus from './menus';
@@ -51,6 +51,20 @@ export interface MetricOptionProps {
shouldShowTooltip?: boolean;
}
/**
* `url` is an arbitrary caller-supplied string rendered as an href. Only
* http(s) and relative URLs become links; other schemes degrade to plain
* text.
*/
function isSafeHref(url: string): boolean {
try {
const { protocol } = new URL(url, window.location.origin);
return protocol === 'http:' || protocol === 'https:';
} catch {
return false;
}
}
export function MetricOption({
metric,
labelRef,
@@ -70,7 +84,7 @@ export function MetricOption({
`}
ref={labelRef}
>
{url ? (
{url && isSafeHref(url) ? (
<Typography.Link
href={url}
target={openInNewWindow ? '_blank' : ''}
@@ -112,3 +112,26 @@ test('shows a Tooltip for the verbose metric name', () => {
const { getByTestId } = setup();
expect(getByTestId('mock-tooltip')).toBeInTheDocument();
});
test('does not render javascript: URLs as links', () => {
// Regression test: the url prop can be creator-authored and must
// never become a script-bearing href for other viewers.
const { queryByRole, getByText } = setup({
url: 'javascript:alert(document.domain)', // eslint-disable-line no-script-url
});
expect(queryByRole('link')).not.toBeInTheDocument();
expect(getByText(defaultProps.metric.verbose_name)).toBeInTheDocument();
});
test('does not render data: URLs as links', () => {
const { queryByRole } = setup({
url: 'data:text/html,<script>alert(1)</script>',
});
expect(queryByRole('link')).not.toBeInTheDocument();
});
test('renders relative URLs as links', () => {
const { getByRole } = setup({
url: '/superset/dashboard/1/',
});
expect(
getByRole('link', { name: defaultProps.metric.verbose_name }),
).toHaveAttribute('href', '/superset/dashboard/1/');
});
@@ -25,8 +25,11 @@ export type Aggregate =
| 'COUNT'
| 'COUNT_DISTINCT'
| 'MAX'
| 'MEDIAN'
| 'MIN'
| 'SUM';
| 'STDDEV_SAMP'
| 'SUM'
| 'VAR_SAMP';
export interface AdhocMetricBase {
hasCustomLabel?: boolean;
@@ -49,5 +49,6 @@ export { default as finestTemporalGrainFormatter } from './formatters/finestTemp
export { default as normalizeTimestamp } from './utils/normalizeTimestamp';
export { default as denormalizeTimestamp } from './utils/denormalizeTimestamp';
export { default as createTimeRangeFromGranularity } from './utils/createTimeRangeFromGranularity';
export * from './types';
@@ -31,7 +31,6 @@ export enum FeatureFlag {
AllowFullCsvExport = 'ALLOW_FULL_CSV_EXPORT',
ChartPluginsExperimental = 'CHART_PLUGINS_EXPERIMENTAL',
ConfirmDashboardDiff = 'CONFIRM_DASHBOARD_DIFF',
CspRuntimeAllowlist = 'CSP_RUNTIME_ALLOWLIST',
CssTemplates = 'CSS_TEMPLATES',
DashboardVirtualization = 'DASHBOARD_VIRTUALIZATION',
DashboardVirtualizationDeferData = 'DASHBOARD_VIRTUALIZATION_DEFER_DATA',
@@ -123,6 +123,25 @@ describe('isProbablyHTML', () => {
expect(isProbablyHTML('<canvas></canvas>')).toBe(true);
expect(isProbablyHTML('<iframe src="page.html"></iframe>')).toBe(true);
});
test('should return true for script-capable and foreign-content tags', () => {
expect(isProbablyHTML('<svg onload="alert(1)"></svg>')).toBe(true);
expect(isProbablyHTML('<math><mi>x</mi></math>')).toBe(true);
expect(
isProbablyHTML('<details open ontoggle="alert(1)">x</details>'),
).toBe(true);
expect(isProbablyHTML('<summary>x</summary>')).toBe(true);
expect(isProbablyHTML('<object data="x"></object>')).toBe(true);
expect(isProbablyHTML('<embed src="x">')).toBe(true);
expect(isProbablyHTML('<marquee>x</marquee>')).toBe(true);
expect(isProbablyHTML('<template>x</template>')).toBe(true);
expect(isProbablyHTML('<dialog open>x</dialog>')).toBe(true);
});
test('should return true for elements that parse into document.head', () => {
expect(isProbablyHTML('<style>body { display: none; }</style>')).toBe(true);
expect(isProbablyHTML('<title>injected</title>')).toBe(true);
});
});
describe('sanitizeHtmlIfNeeded', () => {
@@ -137,6 +156,24 @@ describe('sanitizeHtmlIfNeeded', () => {
const sanitizedString = sanitizeHtmlIfNeeded(plainText);
expect(sanitizedString).toEqual(plainText);
});
test('should sanitize svg/details/style payloads instead of passing them through', () => {
const svgPayload = '<svg onload="alert(document.cookie)"></svg>';
const sanitizedSvg = sanitizeHtmlIfNeeded(svgPayload);
expect(sanitizedSvg).not.toContain('<svg');
expect(sanitizedSvg).not.toContain('onload');
// `details` (with its `open` attribute) is in js-xss's default
// whitelist, so the tag itself survives sanitization; the fix is that
// the payload is now routed through FilterXSS at all, which strips the
// non-whitelisted `ontoggle` handler instead of returning it verbatim.
const detailsPayload = '<details open ontoggle="alert(1)">x</details>';
const sanitizedDetails = sanitizeHtmlIfNeeded(detailsPayload);
expect(sanitizedDetails).toEqual('<details open>x</details>');
const stylePayload = '<style>body { display: none; }</style>';
expect(sanitizeHtmlIfNeeded(stylePayload)).not.toContain('<style');
});
});
describe('safeHtmlSpan', () => {
@@ -154,6 +154,20 @@ const KNOWN_HTML_TAGS = new Set([
'html',
'head',
'body',
// Script-capable elements and foreign-content roots (SVG/MathML). These
// must be classified as HTML so that downstream sanitization is applied;
// omitting them makes the heuristic fail open — payloads such as
// `<svg onload=...>` or `<details open ontoggle=...>` would be classified
// "not HTML" and returned verbatim by sanitizeHtmlIfNeeded.
'svg',
'math',
'details',
'summary',
'object',
'embed',
'marquee',
'template',
'dialog',
]);
const HTML_TAG_PATTERN = new RegExp(
@@ -183,10 +197,15 @@ export function isProbablyHTML(text: string) {
const parser = new DOMParser();
const doc = parser.parseFromString(cleanedStr, 'text/html');
// Check if parsing created actual HTML elements (not just text nodes)
const elements = Array.from(doc.body.childNodes).filter(
node => node.nodeType === 1,
) as Element[];
// Check if parsing created actual HTML elements (not just text nodes).
// Some elements (e.g. <style>, <title>, <meta>, <link>) parse into
// document.head rather than document.body, so both must be inspected —
// otherwise a bare <style> payload is classified "not HTML" and skips
// sanitization.
const elements = [
...Array.from(doc.head.childNodes),
...Array.from(doc.body.childNodes),
].filter(node => node.nodeType === 1) as Element[];
// If no elements were created, it's not HTML
if (elements.length === 0) {
@@ -22,7 +22,7 @@ import { Modal } from '../core/Modal';
/**
* Confirm Dialog component for Ant Design Modal.confirm dialogs.
* These are the "OK" / "Cancel" confirmation dialogs used throughout Superset.
* These are the "Confirm" / "Cancel" confirmation dialogs used throughout Superset.
* Uses getByRole with name to target specific confirm dialogs when multiple are open.
*/
export class ConfirmDialog extends Modal {
@@ -43,7 +43,7 @@ export class ConfirmDialog extends Modal {
}
/**
* Clicks the OK button to confirm.
* Clicks the Confirm button to confirm.
* @param options.timeout - If provided, silently returns if dialog doesn't appear
* within timeout. If not provided, waits indefinitely (strict mode).
*/
@@ -53,7 +53,7 @@ export class ConfirmDialog extends Modal {
state: 'visible',
timeout: options?.timeout,
});
await this.clickFooterButton('OK');
await this.clickFooterButton('Confirm');
await this.waitForHidden();
} catch (error) {
// Only swallow TimeoutError when timeout was explicitly provided
@@ -1,58 +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.
*/
/**
* With SOFT_DELETE enabled the delete-confirmation modal becomes recoverable:
* it explains the object is moved to the archive (and for how long), and drops
* the "type DELETE to confirm" friction. Non-destructive the modal is opened
* and dismissed without deleting anything.
*/
import { test, expect } from '@playwright/test';
import { skipUnlessFeatureEnabled } from '../../helpers/featureFlags';
test.beforeEach(async ({ page }) => {
await skipUnlessFeatureEnabled(page, 'SOFT_DELETE');
});
test('chart delete confirmation reflects soft-delete (archive) semantics', async ({
page,
}) => {
await page.goto('chart/list/');
await page.locator('[data-test="chart-row-delete"]').first().waitFor();
await page.locator('[data-test="chart-row-delete"]').first().click();
// The action reads as "Archive", not "Delete". Scope to the dialog: with
// the flag on, every list row's delete action is also named "Archive", so
// an unscoped button query is a strict-mode violation (25 rows + modal).
const dialog = page.getByRole('dialog');
await expect(dialog.getByText(/^Archive .+\?$/)).toBeVisible();
await expect(dialog.getByRole('button', { name: 'Archive' })).toBeVisible();
// Recoverable copy instead of "Are you sure … permanently".
await expect(page.getByText(/moved to Recently Archived/i)).toBeVisible();
await expect(
page.getByText(/recover it there within \d+ days/i),
).toBeVisible();
// No "type DELETE to confirm" input in recoverable mode.
await expect(page.getByTestId('delete-modal-input')).toHaveCount(0);
// Dismiss without deleting.
await page.getByTestId('close-modal-btn').click();
});
@@ -29,7 +29,7 @@
* restore it and asserts via the API that it is live again.
*/
import { test, expect, Page } from '@playwright/test';
import { apiGet, apiPost } from '../../helpers/api/requests';
import { apiGet } from '../../helpers/api/requests';
import { extractIdFromResponse } from '../../helpers/api/assertions';
import {
apiPostChart,
@@ -188,58 +188,3 @@ test('permanently deletes an archived item from the view', async ({ page }) => {
await TYPES[0].softDelete(page, id).catch(() => {});
}
});
test('shows an empty message and no rows when the search matches nothing', async ({
page,
}) => {
await page.goto('archived/');
await expect(page.getByTestId('archived-list-view')).toBeVisible();
const search = page.getByPlaceholder(/type a value/i);
await search.click();
await search.fill(`e2e_nonexistent_${Date.now()}`);
await search.press('Enter');
await expect(
page.getByText('No results match your filter criteria'),
).toBeVisible();
await expect(page.getByTestId('archived-row-restore')).toHaveCount(0);
});
test('restoring an already-restored row surfaces an error without crashing', async ({
page,
}) => {
const name = `e2e_stale_${Date.now()}`;
const id = await TYPES[0].create(page, name);
// Capture the uuid before soft-delete (a soft-deleted GET returns 404).
const { uuid } = (await (await apiGetDashboard(page, id)).json()).result;
try {
expect((await apiDeleteDashboard(page, id)).ok()).toBeTruthy();
await openArchive(page, 'Dashboard', name);
await expect(page.getByText(name, { exact: false })).toBeVisible();
// Simulate another actor restoring the object out from under this view.
const restored = await apiPost(
page,
`api/v1/dashboard/${uuid}/restore`,
{},
);
expect(restored.ok()).toBeTruthy();
// Clicking the now-stale row's Restore yields a 404 → danger toast, no crash.
await page
.getByRole('row')
.filter({ hasText: name })
.getByTestId('archived-row-restore')
.click();
await expect(
page.getByText(`Failed to restore ${name}`, { exact: false }),
).toBeVisible({ timeout: 15000 });
// The page is still functional (the list view did not crash).
await expect(page.getByTestId('archived-list-view')).toBeVisible();
} finally {
// Re-archive the (possibly) restored dashboard, whatever happened above.
await apiDeleteDashboard(page, id).catch(() => {});
}
});
@@ -27,11 +27,21 @@ const StyledTotalCell = styled.div`
`}
`;
// `align` originates from the chart's stored column_config
// (col.config.horizontalAlign), which can be set to an arbitrary string via
// a direct chart-params API write. Emotion compiles interpolated strings as
// CSS source, so the value must be clamped to a closed set of keywords
// before it reaches the stylesheet — never interpolated raw.
const ALLOWED_ALIGN_VALUES = new Set(['left', 'right', 'center']);
const safeAlign = (align?: string) =>
align && ALLOWED_ALIGN_VALUES.has(align) ? align : 'left';
const CellContainer = styled.div<{ backgroundColor?: string; align?: string }>`
display: flex;
background-color: ${({ backgroundColor }) =>
backgroundColor || 'transparent'};
justify-content: ${({ align }) => align || 'left'};
justify-content: ${({ align }) => safeAlign(align)};
`;
const ArrowContainer = styled.div<{ arrowColor?: string }>`
@@ -0,0 +1,59 @@
/**
* 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 { render } from '@superset-ui/core/spec';
import { NumericCellRenderer } from '../src/renderers/NumericCellRenderer';
const renderCell = (horizontalAlign?: string) => {
const params = {
value: 42,
valueFormatted: '42',
node: { rowPinned: undefined, rowIndex: 0 },
hasBasicColorFormatters: false,
basicColorFormatters: [],
col: {
isNumeric: true,
config: horizontalAlign ? { horizontalAlign } : {},
},
valueRange: undefined,
alignPositiveNegative: false,
colorPositiveNegative: false,
} as unknown as Parameters<typeof NumericCellRenderer>[0];
return render(<NumericCellRenderer {...params} />);
};
const collectInjectedCss = () =>
Array.from(document.querySelectorAll('style'))
.map(style => style.textContent ?? '')
.join('\n');
test('applies an allowed horizontalAlign value from column config', () => {
const { container } = renderCell('center');
expect(container.firstChild).toHaveStyle({ justifyContent: 'center' });
});
test('does not compile a malicious horizontalAlign into the stylesheet', () => {
const payload =
'right;} & { position:fixed; top:0; left:0; width:100vw; height:100vh; z-index:99999; background:#fff url(https://attacker.example/beacon) }';
const { container } = renderCell(payload);
const css = collectInjectedCss();
expect(css).not.toContain('position:fixed');
expect(css).not.toContain('attacker.example');
expect(container.firstChild).toHaveStyle({ justifyContent: 'left' });
});
@@ -38,3 +38,11 @@ export const convertUTCTimestampToLocal = (utcTimestamp: number): number => {
const offsetMs = date.getTimezoneOffset() * 60 * 1000;
return utcTimestamp + offsetMs;
};
// Escapes HTML special characters before formatter output reaches an
// innerHTML sink. Mirrors plugin-chart-country-map's escapeHtml.
export const escapeHtml = (text: unknown): string => {
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML;
};
@@ -13,6 +13,7 @@ import d3tip from 'd3-tip';
import { t } from '@apache-superset/core/translation';
import { getContrastingColor } from '@superset-ui/core';
import { CALENDAR_TOOLTIP_CLASS } from '../tooltip';
import { escapeHtml } from '../utils';
var d3 = typeof require === 'function' ? require('d3') : window.d3;
@@ -22,14 +23,16 @@ var CalHeatMap = function () {
'use strict';
var self = this;
// d3-tip assigns the .html() return value to the tip node via
// innerHTML, so formatter output is HTML-escaped first.
self.tip = d3tip()
.attr('class', `d3-tip ${CALENDAR_TOOLTIP_CLASS}`)
.direction('n')
.offset([-5, 0])
.html(
d => `
${self.options.timeFormatter(d.t)}: <strong>${self.options.valueFormatter(
d.v,
${escapeHtml(self.options.timeFormatter(d.t))}: <strong>${escapeHtml(
self.options.valueFormatter(d.v),
)}</strong>
`,
);
@@ -37,7 +40,7 @@ var CalHeatMap = function () {
.attr('class', `d3-tip ${CALENDAR_TOOLTIP_CLASS}`)
.direction('n')
.offset([-5, 0])
.html(d => self.options.valueFormatter(d));
.html(d => escapeHtml(self.options.valueFormatter(d)));
this.allowedDataType = ['json', 'csv', 'tsv', 'txt'];
@@ -25,8 +25,12 @@ type FunctionalDateFormat = (date: Date) => string;
interface CalHeatMapInstance {
options: {
dateFormatter: DateFormatter | null;
timeFormatter: (t: number) => string;
valueFormatter: (v: number) => string;
};
formatDate(date: Date, format: string | FunctionalDateFormat): string;
tip: { html(): (d: { t: number; v: number }) => string };
legendTip: { html(): (d: number) => string };
}
const CalHeatMap = CalHeatMapImport as unknown as new () => CalHeatMapInstance;
@@ -59,3 +63,29 @@ test('CalHeatMap keeps the D3 formatter fallback', () => {
expect(calendar.formatDate(date, '%B')).toBe('January');
});
test('cell tooltip HTML escapes creator-controlled formatter output', () => {
// Regression test: the tip's .html() callback is assigned to the
// tooltip node via innerHTML (d3-tip), so formatter output must be
// escaped before it's returned.
const calendar = new CalHeatMap();
calendar.options.timeFormatter = () => '<img src=x onerror=alert(1)>';
calendar.options.valueFormatter = () => '<svg onload=alert(2)>';
const html = calendar.tip.html()({ t: 0, v: 1 });
expect(html).not.toContain('<img');
expect(html).not.toContain('<svg');
expect(html).toContain('&lt;img');
expect(html).toContain('&lt;svg');
});
test('legend tooltip HTML escapes creator-controlled formatter output', () => {
const calendar = new CalHeatMap();
calendar.options.valueFormatter = () => '<img src=x onerror=alert(1)>';
const html = calendar.legendTip.html()(1);
expect(html).not.toContain('<img');
expect(html).toContain('&lt;img');
});
@@ -17,7 +17,11 @@
* under the License.
*/
import { getFormattedUTCTime, convertUTCTimestampToLocal } from '../src/utils';
import {
getFormattedUTCTime,
convertUTCTimestampToLocal,
escapeHtml,
} from '../src/utils';
test('getFormattedUTCTime formats local timestamp for display as UTC date', () => {
const utcTimestamp = 1420070400000; // 2015-01-01 00:00:00 UTC
@@ -87,3 +91,22 @@ test('convertUTCTimestampToLocal and getFormattedUTCTime work together to displa
const formattedTime = getFormattedUTCTime(localTimestamp, '%Y-%m-%d');
expect(formattedTime).toContain('2024-01-01');
});
test('escapeHtml neutralizes markup smuggled through a time format string', () => {
// Regression test: d3-time-format passes non-% characters through
// verbatim, so escaping must happen before the innerHTML sink.
const formatted = getFormattedUTCTime(
1704067200000,
'%Y <img src=x onerror=alert(1)>',
);
const escaped = escapeHtml(formatted);
expect(formatted).toContain('<img');
expect(escaped).not.toContain('<img');
expect(escaped).toContain('&lt;img');
});
test('escapeHtml stringifies non-string formatter output safely', () => {
expect(escapeHtml(1234)).toEqual('1234');
expect(escapeHtml('a & b < c')).toEqual('a &amp; b &lt; c');
});
@@ -33,6 +33,27 @@ import { WmsLayerConf, WfsLayerConf, LayerConf, XyzLayerConf } from '../types';
import { isWfsLayerConf, isWmsLayerConf, isXyzLayerConf } from '../typeguards';
import { isVersionBelow } from './serviceUtil';
/**
* Escape HTML special characters in a layer attribution string.
*
* OpenLayers' Attribution control renders attribution strings via innerHTML,
* and the attribution here comes from creator-supplied chart form data, so it
* must be treated as untrusted text rather than markup to prevent stored XSS.
*
* @param attribution The attribution string from the layer configuration
*
* @returns The attribution with HTML special characters escaped
*/
export const escapeAttribution = (attribution?: string): string | undefined =>
attribution === undefined
? undefined
: attribution
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
/**
* Create a WMS layer.
*
@@ -49,7 +70,7 @@ export const createWmsLayer = (wmsLayerConf: WmsLayerConf) => {
LAYERS: layersParam,
VERSION: version,
},
attributions: attribution,
attributions: escapeAttribution(attribution),
}),
});
};
@@ -66,7 +87,7 @@ export const createXyzLayer = (xyzLayerConf: XyzLayerConf) => {
return new TileLayer({
source: new XyzSource({
url,
attributions: attribution,
attributions: escapeAttribution(attribution),
}),
});
};
@@ -90,7 +111,7 @@ export const createWfsLayer = async (wfsLayerConf: WfsLayerConf) => {
const wfsSource = new VectorSource({
format: new GeoJSON(),
attributions: attribution,
attributions: escapeAttribution(attribution),
url: extent => {
const requestUrl = new URL(url);
const params = requestUrl.searchParams;
@@ -17,20 +17,65 @@
* under the License.
*/
import { WfsLayerConf } from '../../src/types';
import { WfsLayerConf, WmsLayerConf, XyzLayerConf } from '../../src/types';
import {
createLayer,
createWfsLayer,
createWmsLayer,
createXyzLayer,
escapeAttribution,
} from '../../src/util/layerUtil';
describe('layerUtil', () => {
describe('escapeAttribution', () => {
test('escapes HTML markup in attribution strings', () => {
expect(escapeAttribution('(c) OSM <img src=x onerror=alert(1)>')).toBe(
'(c) OSM &lt;img src=x onerror=alert(1)&gt;',
);
expect(escapeAttribution('a & "b" \'c\'')).toBe(
'a &amp; &quot;b&quot; &#039;c&#039;',
);
expect(escapeAttribution(undefined)).toBeUndefined();
});
});
describe('createWmsLayer', () => {
test('exists', () => {
// function is trivial
expect(createWmsLayer).toBeDefined();
});
test('escapes HTML in the layer attribution', () => {
const wmsLayerConf: WmsLayerConf = {
title: 'wms',
type: 'WMS',
url: 'https://ows-demo.terrestris.de/geoserver/osm/wms',
version: '1.3.0',
layersParam: 'osm:osm-fuel',
attribution: '(c) OSM <img src=x onerror=alert(1)>',
};
const layer = createWmsLayer(wmsLayerConf);
const attributions = layer.getSource()?.getAttributions();
expect(attributions?.(undefined as never)).toEqual([
'(c) OSM &lt;img src=x onerror=alert(1)&gt;',
]);
});
});
describe('createXyzLayer', () => {
test('escapes HTML in the layer attribution', () => {
const xyzLayerConf: XyzLayerConf = {
title: 'osm',
type: 'XYZ',
url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
attribution: '(c) OSM <img src=x onerror=alert(1)>',
};
const layer = createXyzLayer(xyzLayerConf);
const attributions = layer.getSource()?.getAttributions();
expect(attributions?.(undefined as never)).toEqual([
'(c) OSM &lt;img src=x onerror=alert(1)&gt;',
]);
});
});
describe('createWfsLayer', () => {
@@ -80,6 +80,7 @@ import {
getAnnotationData,
} from '../utils/annotation';
import {
collapseForecastKeys,
extractForecastSeriesContext,
extractForecastValuesFromTooltipParams,
formatForecastTooltipSeries,
@@ -769,7 +770,9 @@ export default function transformProps(
nameGap: xAxisTitleMarginPx,
nameLocation: 'middle',
axisLabel: {
hideOverlap: !(xAxisType === AxisType.Time && xAxisLabelRotation !== 0),
hideOverlap: showMaxLabel
? false
: !(xAxisType === AxisType.Time && xAxisLabelRotation !== 0),
formatter: deduplicatedFormatter,
rotate: xAxisLabelRotation,
interval: xAxisLabelInterval,
@@ -861,12 +864,14 @@ export default function transformProps(
: params.value[0];
const forecastValue: any[] = richTooltip ? params : [params];
const sortedKeys = extractTooltipKeys(
forecastValue,
// horizontal mode is not supported in mixed series chart
1,
richTooltip,
tooltipSortByMetric,
const sortedKeys = collapseForecastKeys(
extractTooltipKeys(
forecastValue,
// horizontal mode is not supported in mixed series chart
1,
richTooltip,
tooltipSortByMetric,
),
);
const rows: string[][] = [];
@@ -18,6 +18,7 @@
*/
import { t } from '@apache-superset/core/translation';
import { NumberFormatter } from '@superset-ui/core';
import { sanitizeHtml } from '../utils/series';
/*
function for finding the max metric values among all series data for Radar Chart
@@ -63,7 +64,7 @@ export const renderNormalizedTooltip = (
const { color, name = '', value: values } = params;
const seriesName = name || 'series0';
const colorDot = `<span style="display:inline-block;margin-right:5px;border-radius:50%;width:5px;height:5px;background-color:${color}"></span>`;
const colorDot = `<span style="display:inline-block;margin-right:5px;border-radius:50%;width:5px;height:5px;background-color:${sanitizeHtml(color)}"></span>`;
// Get metric values with denormalization if needed
const metricValues: TooltipMetricValue[] = metrics.map((metric, index) => {
@@ -85,19 +86,26 @@ export const renderNormalizedTooltip = (
};
});
// Tooltip is rendered via innerHTML (ECharts default renderMode
// 'html'), so seriesName/metric/value/color are HTML-escaped, matching
// the treatment every other echarts tooltip path applies.
const tooltipRows = metricValues
.map(
({ metric, value }) => `
<div style="display:flex;">
<div>${colorDot}${metric}:</div>
<div style="font-weight:bold;margin-left:auto;">${value}</div>
<div>${colorDot}${sanitizeHtml(metric)}:</div>
<div style="font-weight:bold;margin-left:auto;">${sanitizeHtml(
String(value),
)}</div>
</div>
`,
)
.join('');
return `
<div style="font-weight:bold;margin-bottom:5px;">${seriesName}</div>
<div style="font-weight:bold;margin-bottom:5px;">${sanitizeHtml(
seriesName,
)}</div>
${tooltipRows}
`;
};
@@ -21,8 +21,14 @@ import {
waitFor,
cleanup,
} from '../../../../spec/helpers/testing-library';
import { AxisType, TimeGranularity } from '@superset-ui/core';
import {
AxisType,
createTimeRangeFromGranularity,
DTTM_ALIAS,
TimeGranularity,
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import { logging } from '@apache-superset/core/utils';
import type { EChartsCoreOption } from 'echarts/core';
import type { ECElementEvent } from 'echarts/types/src/util/types';
import type { ReactNode } from 'react';
@@ -93,12 +99,17 @@ afterAll(() => {
});
afterEach(() => {
jest.useRealTimers();
cleanup();
mockEchart.mockReset();
(globalThis as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver =
originalResizeObserver;
});
const advanceClickTimer = () => {
jest.advanceTimersByTime(300);
};
const defaultFormData: EchartsTimeseriesFormData & {
vizType: string;
dateFormat: string;
@@ -314,7 +325,8 @@ test('falls back to window resize listener when ResizeObserver is unavailable',
});
// Test for issue #25334: Bar chart cross-filter without dimensions
test('emits cross-filter on X-axis value when no dimensions and categorical X-axis', async () => {
test('emits cross-filter on X-axis value when no dimensions and categorical X-axis', () => {
jest.useFakeTimers();
const setDataMaskMock = jest.fn();
const propsWithCategoricalXAxis: TimeseriesChartTransformedProps = {
@@ -348,13 +360,7 @@ test('emits cross-filter on X-axis value when no dimensions and categorical X-ax
dataIndex: 0,
});
// Wait for the timer (TIMER_DURATION = 300ms)
await waitFor(
() => {
expect(setDataMaskMock).toHaveBeenCalled();
},
{ timeout: 500 },
);
advanceClickTimer();
// Verify the cross-filter uses the X-axis column and value, not the metric
const dataMaskCall = setDataMaskMock.mock.calls[0][0];
@@ -368,7 +374,8 @@ test('emits cross-filter on X-axis value when no dimensions and categorical X-ax
}
});
test('emits cross-filter on category value for horizontal bar clicks', async () => {
test('emits cross-filter on category value for horizontal bar clicks', () => {
jest.useFakeTimers();
const setDataMaskMock = jest.fn();
render(
@@ -397,12 +404,7 @@ test('emits cross-filter on category value for horizontal bar clicks', async ()
dataIndex: 0,
});
await waitFor(
() => {
expect(setDataMaskMock).toHaveBeenCalled();
},
{ timeout: 500 },
);
advanceClickTimer();
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
{
@@ -424,9 +426,7 @@ test('uses rendered categorical axis for query event handlers', () => {
/>,
);
expect(getLatestEchartProps().queryEventHandlers?.[0].query).toBe(
'xAxis.category',
);
expect(getLatestEchartProps().queryEventHandlers?.[0].query).toBe('xAxis');
cleanup();
mockEchart.mockReset();
@@ -445,9 +445,7 @@ test('uses rendered categorical axis for query event handlers', () => {
/>,
);
expect(getLatestEchartProps().queryEventHandlers?.[0].query).toBe(
'yAxis.category',
);
expect(getLatestEchartProps().queryEventHandlers?.[0].query).toBe('yAxis');
});
test('emits cross-filter from horizontal categorical axis label clicks', () => {
@@ -473,8 +471,9 @@ test('emits cross-filter from horizontal categorical axis label clicks', () => {
getLatestEchartProps().queryEventHandlers?.[0].handler;
expect(labelClickHandler).toBeDefined();
labelClickHandler?.({
targetType: 'axisLabel',
value: 'Product A',
} as ECElementEvent);
} as unknown as ECElementEvent);
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
{
@@ -485,7 +484,8 @@ test('emits cross-filter from horizontal categorical axis label clicks', () => {
]);
});
test('does not emit duplicate cross-filter for generic axis label clicks', async () => {
test('does not emit duplicate cross-filter for generic axis label clicks', () => {
jest.useFakeTimers();
const setDataMaskMock = jest.fn();
render(
@@ -507,11 +507,28 @@ test('does not emit duplicate cross-filter for generic axis label clicks', async
name: 'Product A',
});
await new Promise(resolve => setTimeout(resolve, 400));
jest.advanceTimersByTime(400);
expect(setDataMaskMock).not.toHaveBeenCalled();
});
test('does not emit cross-filter when no dimensions and time-based X-axis', async () => {
test('keeps temporal range exclusive ends on whole-second boundaries', () => {
const clickedTimestamp = new Date(Date.UTC(2021, 0, 15, 12, 34, 56, 789));
[TimeGranularity.DAY, TimeGranularity.MONTH, TimeGranularity.YEAR].forEach(
grain => {
const [, inclusiveEnd] = createTimeRangeFromGranularity(
clickedTimestamp,
grain,
false,
);
const exclusiveEnd = new Date(inclusiveEnd.getTime() + 1);
expect(exclusiveEnd.getUTCMilliseconds()).toBe(0);
},
);
});
test('emits TEMPORAL_RANGE cross-filter from time axis label click on day bucket', () => {
const setDataMaskMock = jest.fn();
const propsWithTimeXAxis: TimeseriesChartTransformedProps = {
@@ -519,39 +536,610 @@ test('does not emit cross-filter when no dimensions and time-based X-axis', asyn
emitCrossFilters: true,
setDataMask: setDataMaskMock,
groupby: [], // No dimensions
resolvedTimeGrain: TimeGranularity.DAY,
formData: {
...defaultFormData,
granularitySqla: 'ds',
timeGrainSqla: TimeGranularity.DAY,
},
xAxis: {
label: '__timestamp',
type: AxisType.Time, // Time-based X-axis (not categorical)
label: DTTM_ALIAS,
type: AxisType.Time,
},
};
render(<EchartsTimeseries {...propsWithTimeXAxis} />);
const lastCall = mockEchart.mock.calls.at(-1);
expect(lastCall).toBeDefined();
const [props] = lastCall as [EchartsProps];
const labelClickHandler = getLatestEchartProps().queryEventHandlers?.find(
({ query }) => query === 'xAxis',
)?.handler;
expect(labelClickHandler).toBeDefined();
labelClickHandler?.({
targetType: 'axisLabel',
value: '2021-01-01',
} as unknown as ECElementEvent);
// Simulate a click event
const clickHandler = props.eventHandlers?.click;
if (clickHandler) {
clickHandler({
componentType: 'series',
seriesName: 'Sales',
data: [1609459200000, 100], // Timestamp
name: '2021-01-01',
dataIndex: 0,
});
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
{
col: 'ds',
op: 'TEMPORAL_RANGE',
val: '2021-01-01T00:00:00 : 2021-01-02T00:00:00',
},
]);
});
// Wait a bit and verify setDataMask was NOT called
await new Promise(resolve => setTimeout(resolve, 400));
expect(setDataMaskMock).not.toHaveBeenCalled();
}
test('emits TEMPORAL_RANGE cross-filter from time axis label click on month bucket', () => {
const setDataMaskMock = jest.fn();
render(
<EchartsTimeseries
{...defaultProps}
emitCrossFilters
setDataMask={setDataMaskMock}
groupby={[]}
resolvedTimeGrain={TimeGranularity.MONTH}
formData={{
...defaultFormData,
granularitySqla: 'ds',
timeGrainSqla: TimeGranularity.MONTH,
}}
xAxis={{
label: DTTM_ALIAS,
type: AxisType.Time,
}}
/>,
);
const labelClickHandler = getLatestEchartProps().queryEventHandlers?.find(
({ query }) => query === 'xAxis',
)?.handler;
expect(labelClickHandler).toBeDefined();
labelClickHandler?.({
targetType: 'axisLabel',
value: '2021-01-01',
} as unknown as ECElementEvent);
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
{
col: 'ds',
op: 'TEMPORAL_RANGE',
val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
},
]);
});
test('emits TEMPORAL_RANGE cross-filter from time axis label click on year bucket', () => {
const setDataMaskMock = jest.fn();
render(
<EchartsTimeseries
{...defaultProps}
emitCrossFilters
setDataMask={setDataMaskMock}
groupby={[]}
resolvedTimeGrain={TimeGranularity.YEAR}
formData={{
...defaultFormData,
granularitySqla: 'ds',
timeGrainSqla: TimeGranularity.YEAR,
}}
xAxis={{
label: DTTM_ALIAS,
type: AxisType.Time,
}}
/>,
);
const labelClickHandler = getLatestEchartProps().queryEventHandlers?.find(
({ query }) => query === 'xAxis',
)?.handler;
expect(labelClickHandler).toBeDefined();
labelClickHandler?.({
targetType: 'axisLabel',
value: '2021-01-01',
} as unknown as ECElementEvent);
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
{
col: 'ds',
op: 'TEMPORAL_RANGE',
val: '2021-01-01T00:00:00 : 2022-01-01T00:00:00',
},
]);
});
test('emits upper-exclusive TEMPORAL_RANGE from time point click on month bucket', () => {
jest.useFakeTimers();
const setDataMaskMock = jest.fn();
render(
<EchartsTimeseries
{...defaultProps}
emitCrossFilters
setDataMask={setDataMaskMock}
groupby={[]}
resolvedTimeGrain={TimeGranularity.MONTH}
formData={{
...defaultFormData,
granularitySqla: 'ds',
timeGrainSqla: TimeGranularity.MONTH,
}}
xAxis={{
label: DTTM_ALIAS,
type: AxisType.Time,
}}
/>,
);
const clickHandler = getLatestEchartProps().eventHandlers?.click;
expect(clickHandler).toBeDefined();
clickHandler?.({
componentType: 'series',
seriesName: 'Sales',
data: [Date.UTC(2021, 0, 1), 100],
name: '2021-01-01',
dataIndex: 0,
});
advanceClickTimer();
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
{
col: 'ds',
op: 'TEMPORAL_RANGE',
val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
},
]);
});
test('emits TEMPORAL_RANGE from string-typed time point click value', () => {
jest.useFakeTimers();
const setDataMaskMock = jest.fn();
render(
<EchartsTimeseries
{...defaultProps}
emitCrossFilters
setDataMask={setDataMaskMock}
groupby={[]}
resolvedTimeGrain={TimeGranularity.MONTH}
formData={{
...defaultFormData,
granularitySqla: 'ds',
timeGrainSqla: TimeGranularity.MONTH,
}}
xAxis={{
label: DTTM_ALIAS,
type: AxisType.Time,
}}
/>,
);
const clickHandler = getLatestEchartProps().eventHandlers?.click;
expect(clickHandler).toBeDefined();
clickHandler?.({
componentType: 'series',
seriesName: 'Sales',
data: ['2021-01-01T00:00:00Z', 100],
name: '2021-01-01',
dataIndex: 0,
});
advanceClickTimer();
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
{
col: 'ds',
op: 'TEMPORAL_RANGE',
val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
},
]);
});
test('uses resolved time grain for temporal point-click cross-filter', () => {
jest.useFakeTimers();
const setDataMaskMock = jest.fn();
render(
<EchartsTimeseries
{...defaultProps}
emitCrossFilters
setDataMask={setDataMaskMock}
groupby={[]}
resolvedTimeGrain={TimeGranularity.MONTH}
formData={{
...defaultFormData,
granularitySqla: 'ds',
timeGrainSqla: TimeGranularity.DAY,
extraFormData: {
time_grain_sqla: TimeGranularity.MONTH,
},
}}
xAxis={{
label: DTTM_ALIAS,
type: AxisType.Time,
}}
/>,
);
const clickHandler = getLatestEchartProps().eventHandlers?.click;
expect(clickHandler).toBeDefined();
clickHandler?.({
componentType: 'series',
seriesName: 'Sales',
data: [Date.UTC(2021, 0, 1), 100],
name: '2021-01-01',
dataIndex: 0,
});
advanceClickTimer();
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
{
col: 'ds',
op: 'TEMPORAL_RANGE',
val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
},
]);
});
test('emits TEMPORAL_RANGE from horizontal time point click using timestamp, not metric', () => {
jest.useFakeTimers();
const setDataMaskMock = jest.fn();
render(
<EchartsTimeseries
{...defaultProps}
emitCrossFilters
setDataMask={setDataMaskMock}
groupby={[]}
resolvedTimeGrain={TimeGranularity.MONTH}
formData={{
...defaultFormData,
orientation: OrientationType.Horizontal,
granularitySqla: 'ds',
timeGrainSqla: TimeGranularity.MONTH,
}}
xAxis={{
label: DTTM_ALIAS,
type: AxisType.Time,
}}
/>,
);
const clickHandler = getLatestEchartProps().eventHandlers?.click;
expect(clickHandler).toBeDefined();
clickHandler?.({
componentType: 'series',
seriesName: 'Sales',
data: [129, Date.UTC(2021, 0, 1)],
name: '2021-01-01',
dataIndex: 0,
});
advanceClickTimer();
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
{
col: 'ds',
op: 'TEMPORAL_RANGE',
val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
},
]);
});
test('emits TEMPORAL_RANGE cross-filter from horizontal time axis label click', () => {
const setDataMaskMock = jest.fn();
render(
<EchartsTimeseries
{...defaultProps}
emitCrossFilters
setDataMask={setDataMaskMock}
groupby={[]}
resolvedTimeGrain={TimeGranularity.MONTH}
formData={{
...defaultFormData,
orientation: OrientationType.Horizontal,
granularitySqla: 'ds',
timeGrainSqla: TimeGranularity.MONTH,
}}
xAxis={{
label: DTTM_ALIAS,
type: AxisType.Time,
}}
/>,
);
const labelClickHandler = getLatestEchartProps().queryEventHandlers?.find(
({ query }) => query === 'yAxis',
)?.handler;
expect(labelClickHandler).toBeDefined();
labelClickHandler?.({
targetType: 'axisLabel',
value: '2021-01-01',
} as unknown as ECElementEvent);
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
{
col: 'ds',
op: 'TEMPORAL_RANGE',
val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
},
]);
});
test('warns and skips temporal axis label cross-filter when label value cannot be parsed', () => {
const setDataMaskMock = jest.fn();
const warn = jest.spyOn(logging, 'warn').mockImplementation();
render(
<EchartsTimeseries
{...defaultProps}
emitCrossFilters
setDataMask={setDataMaskMock}
groupby={[]}
formData={{
...defaultFormData,
granularitySqla: 'ds',
timeGrainSqla: TimeGranularity.MONTH,
}}
xAxis={{
label: DTTM_ALIAS,
type: AxisType.Time,
}}
/>,
);
const labelClickHandler = getLatestEchartProps().queryEventHandlers?.find(
({ query }) => query === 'xAxis',
)?.handler;
expect(labelClickHandler).toBeDefined();
labelClickHandler?.({
targetType: 'axisLabel',
value: 'not-a-date',
} as unknown as ECElementEvent);
expect(setDataMaskMock).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledWith(
'Unable to parse time axis value for cross-filtering',
'not-a-date',
);
warn.mockRestore();
});
test('logs and skips temporal point-click cross-filter when string value cannot be parsed', () => {
jest.useFakeTimers();
const setDataMaskMock = jest.fn();
const warn = jest.spyOn(logging, 'warn').mockImplementation();
render(
<EchartsTimeseries
{...defaultProps}
emitCrossFilters
setDataMask={setDataMaskMock}
groupby={[]}
resolvedTimeGrain={TimeGranularity.MONTH}
formData={{
...defaultFormData,
granularitySqla: 'ds',
timeGrainSqla: TimeGranularity.MONTH,
}}
xAxis={{
label: DTTM_ALIAS,
type: AxisType.Time,
}}
/>,
);
const clickHandler = getLatestEchartProps().eventHandlers?.click;
expect(clickHandler).toBeDefined();
clickHandler?.({
componentType: 'series',
seriesName: 'Sales',
data: ['not-a-date', 100],
name: 'not-a-date',
dataIndex: 0,
});
advanceClickTimer();
expect(setDataMaskMock).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledWith(
'Unable to parse time axis value for cross-filtering',
'not-a-date',
);
warn.mockRestore();
});
test('emits empty temporal X-axis data mask when filter grain is missing', () => {
jest.useFakeTimers();
const setDataMaskMock = jest.fn();
render(
<EchartsTimeseries
{...defaultProps}
emitCrossFilters
setDataMask={setDataMaskMock}
groupby={[]}
formData={{
...defaultFormData,
granularitySqla: 'ds',
timeGrainSqla: undefined,
}}
xAxis={{
label: DTTM_ALIAS,
type: AxisType.Time,
}}
/>,
);
const clickHandler = getLatestEchartProps().eventHandlers?.click;
expect(clickHandler).toBeDefined();
clickHandler?.({
componentType: 'series',
seriesName: 'Sales',
data: [Date.UTC(2021, 0, 1), 100],
name: '2021-01-01',
dataIndex: 0,
});
advanceClickTimer();
expect(setDataMaskMock.mock.calls[0][0]).toEqual({
extraFormData: {
filters: [],
},
filterState: {
label: undefined,
value: null,
selectedValues: null,
},
});
});
test('clears temporal X-axis cross-filter when clicking selected bucket again', () => {
jest.useFakeTimers();
const setDataMaskMock = jest.fn();
const selectedRange = '2021-01-01T00:00:00 : 2021-02-01T00:00:00';
render(
<EchartsTimeseries
{...defaultProps}
emitCrossFilters
setDataMask={setDataMaskMock}
groupby={[]}
selectedValues={{ 0: selectedRange }}
resolvedTimeGrain={TimeGranularity.MONTH}
formData={{
...defaultFormData,
granularitySqla: 'ds',
timeGrainSqla: TimeGranularity.MONTH,
}}
xAxis={{
label: DTTM_ALIAS,
type: AxisType.Time,
}}
/>,
);
const clickHandler = getLatestEchartProps().eventHandlers?.click;
expect(clickHandler).toBeDefined();
clickHandler?.({
componentType: 'series',
seriesName: 'Sales',
data: [Date.UTC(2021, 0, 1), 100],
name: '2021-01-01',
dataIndex: 0,
});
advanceClickTimer();
expect(setDataMaskMock.mock.calls[0][0]).toEqual({
extraFormData: {
filters: [],
},
filterState: {
label: undefined,
value: null,
selectedValues: null,
},
});
});
test('does not emit temporal X-axis label cross-filter when dimensions are set', () => {
const setDataMaskMock = jest.fn();
render(
<EchartsTimeseries
{...defaultProps}
emitCrossFilters
setDataMask={setDataMaskMock}
groupby={['country']}
formData={{
...defaultFormData,
groupby: ['country'],
granularitySqla: 'ds',
timeGrainSqla: TimeGranularity.MONTH,
}}
xAxis={{
label: DTTM_ALIAS,
type: AxisType.Time,
}}
/>,
);
const labelClickHandler = getLatestEchartProps().queryEventHandlers?.find(
({ query }) => query === 'xAxis',
)?.handler;
expect(labelClickHandler).toBeDefined();
labelClickHandler?.({
targetType: 'axisLabel',
value: '2021-01-01',
} as unknown as ECElementEvent);
expect(setDataMaskMock).not.toHaveBeenCalled();
});
test('does not emit temporal X-axis cross-filter when dimensions are set', () => {
jest.useFakeTimers();
const setDataMaskMock = jest.fn();
render(
<EchartsTimeseries
{...defaultProps}
emitCrossFilters
setDataMask={setDataMaskMock}
groupby={['country']}
labelMap={{
Sales: ['US'],
}}
formData={{
...defaultFormData,
groupby: ['country'],
granularitySqla: 'ds',
timeGrainSqla: TimeGranularity.MONTH,
}}
xAxis={{
label: DTTM_ALIAS,
type: AxisType.Time,
}}
/>,
);
const clickHandler = getLatestEchartProps().eventHandlers?.click;
expect(clickHandler).toBeDefined();
clickHandler?.({
componentType: 'series',
seriesName: 'Sales',
data: [Date.UTC(2021, 0, 1), 100],
name: '2021-01-01',
dataIndex: 0,
});
advanceClickTimer();
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
{
col: 'country',
op: 'IN',
val: ['US'],
},
]);
expect(
setDataMaskMock.mock.calls[0][0].extraFormData.filters.some(
(filter: { op: string }) => filter.op === 'TEMPORAL_RANGE',
),
).toBe(false);
});
// Test for issue #41102: horizontal bar cross-filter must use the category
// value, not the metric. For horizontal bars the data tuple is value-first
// (e.g. [100, 'Product A']), so relying on data[0] emitted the metric value.
test('emits cross-filter on the category value for a horizontal categorical bar', async () => {
test('emits cross-filter on the category value for a horizontal categorical bar', () => {
jest.useFakeTimers();
const setDataMaskMock = jest.fn();
const propsWithHorizontalXAxis: TimeseriesChartTransformedProps = {
@@ -585,12 +1173,7 @@ test('emits cross-filter on the category value for a horizontal categorical bar'
dataIndex: 0,
});
await waitFor(
() => {
expect(setDataMaskMock).toHaveBeenCalled();
},
{ timeout: 500 },
);
advanceClickTimer();
// Must filter on the category ('Product A'), not the metric value (100)
const dataMaskCall = setDataMaskMock.mock.calls[0][0];
@@ -604,6 +1187,55 @@ test('emits cross-filter on the category value for a horizontal categorical bar'
}
});
test('context menu cross-filter is available for a temporal bar point', async () => {
const onContextMenuMock = jest.fn();
render(
<EchartsTimeseries
{...defaultProps}
emitCrossFilters
onContextMenu={onContextMenuMock}
groupby={[]}
resolvedTimeGrain={TimeGranularity.MONTH}
formData={{
...defaultFormData,
granularitySqla: 'ds',
timeGrainSqla: TimeGranularity.DAY,
extraFormData: {
time_grain_sqla: TimeGranularity.MONTH,
},
}}
xAxis={{
label: DTTM_ALIAS,
type: AxisType.Time,
}}
/>,
);
const contextMenuHandler = getLatestEchartProps().eventHandlers?.contextmenu;
expect(contextMenuHandler).toBeDefined();
await contextMenuHandler?.({
componentType: 'series',
seriesName: 'Sales',
data: [Date.UTC(2021, 0, 1), 100],
name: '2021-01-01',
event: { stop: jest.fn(), event: { clientX: 10, clientY: 20 } },
});
await waitFor(() => {
expect(onContextMenuMock).toHaveBeenCalled();
});
const { crossFilter } = onContextMenuMock.mock.calls[0][2];
expect(crossFilter.dataMask.extraFormData.filters).toEqual([
{
col: 'ds',
op: 'TEMPORAL_RANGE',
val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
},
]);
});
// Test for issue #41102: the context-menu ("Add cross-filter") path must also
// use the category value, not the metric, for a horizontal categorical bar.
test('context menu cross-filter uses the category value for a horizontal categorical bar', async () => {
@@ -21,14 +21,17 @@ import {
DTTM_ALIAS,
BinaryQueryObjectFilterClause,
AxisType,
type TimeGranularity,
getTimeFormatter,
getColumnLabel,
getNumberFormatter,
LegendState,
ensureIsArray,
createTimeRangeFromGranularity,
} from '@superset-ui/core';
import { useTheme } from '@apache-superset/core/theme';
import { GenericDataType } from '@apache-superset/core/common';
import { logging } from '@apache-superset/core/utils';
import type {
ECElementEvent,
ViewRootGroup,
@@ -48,6 +51,29 @@ import { getTemporalXAxisDrillByFilter } from '../utils/xAxisDrillByFilter';
import { ExtraControls } from '../components/ExtraControls';
const TIMER_DURATION = 300;
const getTimestampFromTimeAxisValue = (value: string | number) => {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : undefined;
}
const timestamp = Date.parse(value);
if (Number.isNaN(timestamp)) {
logging.warn('Unable to parse time axis value for cross-filtering', value);
}
return Number.isNaN(timestamp) ? undefined : timestamp;
};
// Day, month, and year ranges end at 23:59:59.999, so adding 1ms lands on a
// whole-second next bucket boundary. The formatter intentionally emits seconds.
const formatDateTime = (date: Date) =>
`${[
date.getUTCFullYear(),
String(date.getUTCMonth() + 1).padStart(2, '0'),
String(date.getUTCDate()).padStart(2, '0'),
].join('-')}T${[
String(date.getUTCHours()).padStart(2, '0'),
String(date.getUTCMinutes()).padStart(2, '0'),
String(date.getUTCSeconds()).padStart(2, '0'),
].join(':')}`;
// Percent-change draggable baseline handle geometry, in pixels.
const BASELINE_HANDLE_WIDTH = 8;
@@ -71,6 +97,7 @@ export default function EchartsTimeseries({
onFocusedSeries,
xValueFormatter,
xAxis,
resolvedTimeGrain,
refs,
emitCrossFilters,
coltypeMapping,
@@ -363,6 +390,65 @@ export default function EchartsTimeseries({
[selectedValues, xAxis.label],
);
const getTimeAxisCrossFilterDataMask = useCallback(
(clickedTimestamp: number) => {
const filterColumn =
xAxis.label === DTTM_ALIAS ? formData.granularitySqla : xAxis.label;
const grain = resolvedTimeGrain as TimeGranularity | undefined;
if (!filterColumn || !grain) {
return {
dataMask: {
extraFormData: {
filters: [],
},
filterState: {
label: undefined,
value: null,
selectedValues: null,
},
},
isCurrentValueSelected: false,
};
}
const [start, inclusiveEnd] = createTimeRangeFromGranularity(
new Date(clickedTimestamp),
grain,
false,
);
const exclusiveEnd = new Date(inclusiveEnd.getTime() + 1);
const timeRange = `${formatDateTime(start)} : ${formatDateTime(exclusiveEnd)}`;
const selected: string[] = Object.values(selectedValues);
const isCurrentValueSelected = selected.includes(timeRange);
const values = isCurrentValueSelected ? [] : [timeRange];
return {
dataMask: {
extraFormData: {
filters:
values.length === 0
? []
: [
{
col: filterColumn,
op: 'TEMPORAL_RANGE' as const,
val: timeRange,
},
],
},
filterState: {
label: values.length ? values : undefined,
value: values.length ? values : null,
selectedValues: values.length ? values : null,
},
},
isCurrentValueSelected,
};
},
[formData.granularitySqla, resolvedTimeGrain, selectedValues, xAxis.label],
);
const handleChange = useCallback(
(value: string) => {
if (!emitCrossFilters) {
@@ -384,15 +470,26 @@ export default function EchartsTimeseries({
[emitCrossFilters, setDataMask, getXAxisCrossFilterDataMask],
);
const handleTimeAxisChange = useCallback(
(clickedTimestamp: number) => {
if (!emitCrossFilters) {
return;
}
setDataMask(getTimeAxisCrossFilterDataMask(clickedTimestamp).dataMask);
},
[emitCrossFilters, setDataMask, getTimeAxisCrossFilterDataMask],
);
// Determine if X-axis can be used for cross-filtering (categorical axis without dimensions)
const canCrossFilterByXAxis =
!hasDimensions && xAxis.type === AxisType.Category;
const categoryAxisValueIndex =
!hasDimensions &&
(xAxis.type === AxisType.Category || xAxis.type === AxisType.Time);
const xAxisValueIndex =
formData.orientation === OrientationType.Horizontal ? 1 : 0;
const getCategoryAxisValue = useCallback(
const getXAxisValue = useCallback(
(data: unknown, name: unknown) => {
if (Array.isArray(data)) {
const categoryAxisValue = data[categoryAxisValueIndex];
const categoryAxisValue = data[xAxisValueIndex];
if (
typeof categoryAxisValue === 'string' ||
typeof categoryAxisValue === 'number'
@@ -405,7 +502,7 @@ export default function EchartsTimeseries({
}
return undefined;
},
[categoryAxisValueIndex],
[xAxisValueIndex],
);
const eventHandlers: EventHandlers = {
@@ -423,15 +520,28 @@ export default function EchartsTimeseries({
// Cross-filter by dimension (original behavior)
const { seriesName: name } = props;
handleChange(name);
} else if (canCrossFilterByXAxis && props.componentType === 'series') {
} else if (
canCrossFilterByXAxis &&
xAxis.type === AxisType.Category &&
props.componentType === 'series'
) {
// Cross-filter by X-axis value when no dimensions (issue #25334)
const categoryAxisValue = getCategoryAxisValue(
props.data,
props.name,
);
const categoryAxisValue = getXAxisValue(props.data, props.name);
if (categoryAxisValue !== undefined) {
handleXAxisChange(categoryAxisValue);
}
} else if (
canCrossFilterByXAxis &&
xAxis.type === AxisType.Time &&
props.componentType === 'series'
) {
const timeAxisValue = getXAxisValue(props.data, props.name);
if (timeAxisValue !== undefined) {
const timestamp = getTimestampFromTimeAxisValue(timeAxisValue);
if (timestamp !== undefined) {
handleTimeAxisChange(timestamp);
}
}
}
}, TIMER_DURATION);
},
@@ -466,17 +576,20 @@ export default function EchartsTimeseries({
];
const groupBy = ensureIsArray(formData.groupby);
if (data && xAxis.type === AxisType.Time) {
drillToDetailFilters.push({
col:
// if the xAxis is '__timestamp', granularity_sqla will be the column of filter
xAxis.label === DTTM_ALIAS
? formData.granularitySqla
: xAxis.label,
grain: formData.timeGrainSqla,
op: '==',
val: data[0],
formattedVal: xValueFormatter(data[0]),
});
const timeAxisValue = getXAxisValue(data, eventParams.name);
if (timeAxisValue !== undefined) {
drillToDetailFilters.push({
col:
// if the xAxis is '__timestamp', granularity_sqla will be the column of filter
xAxis.label === DTTM_ALIAS
? formData.granularitySqla
: xAxis.label,
grain: resolvedTimeGrain,
op: '==',
val: timeAxisValue,
formattedVal: xValueFormatter(timeAxisValue),
});
}
}
[
...(xAxis.type === AxisType.Category && data ? [xAxis.label] : []),
@@ -517,9 +630,7 @@ export default function EchartsTimeseries({
xAxis.label === DTTM_ALIAS ? formData.granularitySqla : xAxis.label;
if (data && xAxis.type === AxisType.Time && xAxisCol) {
// For horizontal orientation the [x, value] pair is swapped
const xValue = Array.isArray(data)
? data[categoryAxisValueIndex]
: data;
const xValue = Array.isArray(data) ? data[xAxisValueIndex] : data;
const xAxisFilter = getTemporalXAxisDrillByFilter(
xAxisCol,
xValue,
@@ -530,10 +641,7 @@ export default function EchartsTimeseries({
xAxisFilters.push(xAxisFilter);
}
} else if (xAxis.type === AxisType.Category && xAxisCol) {
const categoryAxisValue = getCategoryAxisValue(
data,
eventParams.name,
);
const categoryAxisValue = getXAxisValue(data, eventParams.name);
if (categoryAxisValue !== undefined) {
// A category axis can still sit on a temporal column when the
// axis is forced categorical; filter by time bucket in that case
@@ -564,15 +672,25 @@ export default function EchartsTimeseries({
crossFilter = getCrossFilterDataMask(seriesName);
} else if (
canCrossFilterByXAxis &&
xAxis.type === AxisType.Category &&
eventParams.componentType === 'series'
) {
const categoryAxisValue = getCategoryAxisValue(
data,
eventParams.name,
);
const categoryAxisValue = getXAxisValue(data, eventParams.name);
if (categoryAxisValue !== undefined) {
crossFilter = getXAxisCrossFilterDataMask(categoryAxisValue);
}
} else if (
canCrossFilterByXAxis &&
xAxis.type === AxisType.Time &&
eventParams.componentType === 'series'
) {
const timeAxisValue = getXAxisValue(data, eventParams.name);
if (timeAxisValue !== undefined) {
const timestamp = getTimestampFromTimeAxisValue(timeAxisValue);
if (timestamp !== undefined) {
crossFilter = getTimeAxisCrossFilterDataMask(timestamp);
}
}
}
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, {
@@ -593,26 +711,39 @@ export default function EchartsTimeseries({
const { value } = event;
if (
canCrossFilterByXAxis &&
event.targetType === 'axisLabel' &&
(typeof value === 'string' || typeof value === 'number')
) {
handleXAxisChange(value);
if (xAxis.type === AxisType.Time) {
const timestamp = getTimestampFromTimeAxisValue(value);
if (timestamp !== undefined) {
handleTimeAxisChange(timestamp);
}
} else {
handleXAxisChange(value);
}
}
},
[canCrossFilterByXAxis, handleXAxisChange],
[
canCrossFilterByXAxis,
handleTimeAxisChange,
handleXAxisChange,
xAxis.type,
],
);
const categoryAxis =
const renderedXAxis =
formData.orientation === OrientationType.Horizontal ? 'yAxis' : 'xAxis';
const queryEventHandlers = useMemo(
() => [
{
name: 'click',
query: `${categoryAxis}.category`,
query: renderedXAxis,
handler: handleXAxisLabelClick,
},
],
[categoryAxis, handleXAxisLabelClick],
[renderedXAxis, handleXAxisLabelClick],
);
const zrEventHandlers: EventHandlers = {
@@ -95,6 +95,7 @@ import {
getAnnotationData,
} from '../utils/annotation';
import {
collapseForecastKeys,
extractForecastSeriesContext,
extractForecastSeriesContexts,
extractForecastValuesFromTooltipParams,
@@ -1247,7 +1248,7 @@ export default function transformProps(
name: xAxisTitle,
nameGap: convertInteger(xAxisTitleMargin),
nameLocation: 'middle',
...(xAxisType === AxisType.Category &&
...((xAxisType === AxisType.Category || xAxisType === AxisType.Time) &&
groupBy.length === 0 && {
triggerEvent: true,
}),
@@ -1255,10 +1256,12 @@ export default function transformProps(
// When rotation is applied on time axes, hideOverlap can
// aggressively hide the last label. Rotated labels already
// have less overlap, so disabling hideOverlap is safe.
// At 0° rotation, keep hideOverlap to prevent long labels
// from overlapping each other, with showMaxLabel to ensure
// the last data point label stays visible (#37181).
hideOverlap: !(xAxisType === AxisType.Time && xAxisLabelRotation !== 0),
// At 0° rotation, also disable hideOverlap when showMaxLabel
// is active so the forced boundary label is never suppressed
// by ECharts' overlap detection (#39899).
hideOverlap: showMaxLabel
? false
: !(xAxisType === AxisType.Time && xAxisLabelRotation !== 0),
formatter: deduplicatedFormatter,
rotate: xAxisLabelRotation,
interval: xAxisLabelInterval,
@@ -1392,11 +1395,13 @@ export default function transformProps(
const forecastValue: CallbackDataParams[] = richTooltip
? params
: [params];
const sortedKeys = extractTooltipKeys(
forecastValue,
yIndex,
richTooltip,
tooltipSortByMetric,
const sortedKeys = collapseForecastKeys(
extractTooltipKeys(
forecastValue,
yIndex,
richTooltip,
tooltipSortByMetric,
),
);
const filteredForecastValue = forecastValue.filter(
(item: CallbackDataParams) =>
@@ -1595,6 +1600,7 @@ export default function transformProps(
label: xAxisLabel,
type: xAxisType,
},
resolvedTimeGrain,
refs,
coltypeMapping: dataTypes,
onLegendScroll,
@@ -467,6 +467,14 @@ export function transformSeries(
return formatter(numericValue);
}
if (!onlyTotal) {
// A stacked segment with no height begins and ends at the same
// coordinate as the top of the segment beneath it, so its label is
// drawn over that segment's label. Zero and null have no height, so
// they carry no label. The rich tooltip omits zero observations from
// a stacked series for the same reason.
if (stack && !numericValue) {
return '';
}
if (
numericValue >=
(thresholdValues[dataIndex] || Number.MIN_SAFE_INTEGER)
@@ -122,5 +122,6 @@ export type TimeseriesChartTransformedProps =
label: string;
type: AxisType;
};
resolvedTimeGrain?: TimeGranularity;
onFocusedSeries: (series: string | null) => void;
};
@@ -28,6 +28,7 @@
*/
import { z } from 'zod';
import { sanitizeHtml } from '@superset-ui/core';
// =============================================================================
// Common Schemas
@@ -57,6 +58,33 @@ const fontStyleSchema = z.enum(['normal', 'italic', 'oblique']);
/** Symbol type */
const symbolTypeSchema = z.string();
/**
* With the ECharts default renderMode 'html', a string tooltip formatter is
* assigned to the tooltip DOM element via innerHTML. ECharts formatter
* strings commonly rely on inline markup (e.g. '{b}<br/>{c}') for layout, so
* rejecting every '<' would break that supported usage; instead the value is
* run through the same allowlist sanitizer used for other tooltip HTML,
* which keeps presentational tags and strips anything else.
*/
const sanitizedFormatterSchema = z
.string()
.transform(value => sanitizeHtml(value));
/**
* ECharts navigates to title.link/sublink on click, so restrict them to
* http(s) and same-origin relative paths.
*/
const safeLinkSchema = z
.string()
.refine(
value =>
/^https?:\/\//i.test(value) ||
(value.startsWith('/') && !value.startsWith('//')),
{
message: 'Only http(s) or same-origin relative URLs are allowed',
},
);
// =============================================================================
// Text Style Schema
// =============================================================================
@@ -168,11 +196,11 @@ export const titleSchema = z.object({
id: z.string().optional(),
show: z.boolean().optional(),
text: z.string().optional(),
link: z.string().optional(),
link: safeLinkSchema.optional(),
target: z.enum(['self', 'blank']).optional(),
textStyle: textStyleSchema.optional(),
subtext: z.string().optional(),
sublink: z.string().optional(),
sublink: safeLinkSchema.optional(),
subtarget: z.enum(['self', 'blank']).optional(),
subtextStyle: textStyleSchema.optional(),
textAlign: z.enum(['left', 'center', 'right']).optional(),
@@ -386,7 +414,9 @@ export const tooltipSchema = z.object({
z.array(z.union([z.number(), z.string()])),
])
.optional(),
formatter: z.string().optional(), // Only string formatters
// Only string formatters: a string tooltip formatter is rendered via
// innerHTML (default renderMode 'html'), so it is sanitized above.
formatter: sanitizedFormatterSchema.optional(),
padding: z.union([z.number(), z.array(z.number())]).optional(),
backgroundColor: colorSchema.optional(),
borderColor: colorSchema.optional(),
@@ -397,7 +427,9 @@ export const tooltipSchema = z.object({
shadowOffsetX: z.number().optional(),
shadowOffsetY: z.number().optional(),
textStyle: textStyleSchema.optional(),
extraCssText: z.string().optional(),
// `extraCssText` is intentionally not accepted; unknown keys are
// stripped by the schema, so configs that still carry it keep working
// minus the raw CSS.
order: z
.enum(['seriesAsc', 'seriesDesc', 'valueAsc', 'valueDesc'])
.optional(),
@@ -575,6 +607,9 @@ export const seriesSchema = z.object({
polarIndex: z.number().optional(),
geoIndex: z.number().optional(),
calendarIndex: z.number().optional(),
// Per-series `tooltip` is intentionally not admitted; the schema
// strips unknown keys. If per-series tooltips are ever admitted, reuse
// tooltipSchema so the formatter sanitization applies.
label: labelSchema.optional(),
labelLine: z
.object({
@@ -60,6 +60,21 @@ export const extractForecastSeriesContexts = (
{} as { [key: string]: ForecastSeriesEnum[] },
);
/**
* Collapses raw ECharts series ids onto the names used to key tooltip rows.
*
* Tooltip values are grouped by forecast-stripped name, so any ordering derived
* from the raw series ids has to be expressed in the same terms before it can be
* matched against them. This matters beyond real Prophet output: a metric simply
* labelled `ci__yhat_lower` collapses to `ci` exactly like a forecast bound
* does, and a chart whose every series carries such a suffix has no id that
* survives the comparison untouched.
*/
export const collapseForecastKeys = (seriesIds: string[]): string[] =>
Array.from(
new Set(seriesIds.map(id => extractForecastSeriesContext(id).name)),
);
export const extractForecastValuesFromTooltipParams = (
params: any[],
isHorizontal = false,
@@ -523,3 +523,73 @@ test('EChartOptionsParseError contains validation error details', () => {
);
}
});
// =============================================================================
// Creator-authored options must not reach the tooltip's innerHTML/
// navigation sinks unsanitized.
// =============================================================================
test('sanitizes tooltip string formatters instead of rejecting all markup', () => {
const input = `{ tooltip: { formatter: '<img src=x onerror=alert(1)>' } }`;
const result = parseEChartOptions(input);
expect(result.success).toBe(true);
expect(result.data?.tooltip).toEqual({ formatter: '<img src>' });
});
test('keeps presentational tags in tooltip string formatters', () => {
const input = `{ tooltip: { formatter: '{b}<br/>{c}' } }`;
const result = parseEChartOptions(input);
expect(result.success).toBe(true);
expect(result.data?.tooltip).toEqual({ formatter: '{b}<br />{c}' });
});
test('strips per-series tooltip config so its formatter never reaches the merge', () => {
const result = parseEChartOptions(
`{ series: [{ type: 'line', tooltip: { formatter: '<b onpointerover=alert(1)>x</b>' } }] }`,
);
expect(result.success).toBe(true);
expect(result.data).toEqual({ series: [{ type: 'line' }] });
});
test('accepts markup-free tooltip placeholder formatters', () => {
const input = `{ tooltip: { formatter: '{b}: {c}' } }`;
const result = parseEChartOptions(input);
expect(result.success).toBe(true);
expect(result.data).toEqual({ tooltip: { formatter: '{b}: {c}' } });
});
test('rejects javascript: URLs in title link and sublink', () => {
expect(() =>
parseEChartOptions(`{ title: { link: 'javascript:alert(1)' } }`),
).toThrow(EChartOptionsParseError);
expect(() =>
parseEChartOptions(`{ title: { sublink: 'javascript:alert(1)' } }`),
).toThrow(EChartOptionsParseError);
expect(() =>
parseEChartOptions(`{ title: { link: '//evil.example/x' } }`),
).toThrow(EChartOptionsParseError);
});
test('accepts http(s) and same-origin relative title links', () => {
const result = parseEChartOptions(
`{ title: { link: 'https://superset.apache.org', sublink: '/dashboard/1/' } }`,
);
expect(result.success).toBe(true);
expect(result.data).toEqual({
title: { link: 'https://superset.apache.org', sublink: '/dashboard/1/' },
});
});
test('strips tooltip extraCssText instead of passing raw CSS through', () => {
const result = parseEChartOptions(
`{ tooltip: { show: true, extraCssText: 'background:url(//evil.example/x)' } }`,
);
expect(result.success).toBe(true);
expect(result.data).toEqual({ tooltip: { show: true } });
});
@@ -1165,6 +1165,110 @@ test('x-axis dedup keeps the forced min label when the endpoints format identica
expect(formatter(min)).toBe('May');
});
test('#39899 - x-axis dates do not overlap and last label stays visible at 0° rotation (mixed)', () => {
// When showMaxLabel is active on a time axis with 0° rotation,
// hideOverlap must be off so ECharts cannot suppress the forced
// max label (the end-of-axis date).
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsMixedTimeseriesFormData,
EchartsMixedTimeseriesProps
>({
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
defaultQueriesData: [
createTestQueryData(
[
{
__timestamp: Date.UTC(2026, 0, 1),
sum__num: 100,
},
{
__timestamp: Date.UTC(2026, 6, 1),
sum__num: 200,
},
],
{
colnames: ['__timestamp', 'sum__num'],
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
label_map: { __timestamp: ['__timestamp'], sum__num: ['sum__num'] },
},
),
createTestQueryData(
[
{
__timestamp: Date.UTC(2026, 0, 1),
sum__num: 100,
},
{
__timestamp: Date.UTC(2026, 6, 1),
sum__num: 200,
},
],
{
colnames: ['__timestamp', 'sum__num'],
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
label_map: { __timestamp: ['__timestamp'], sum__num: ['sum__num'] },
},
),
],
formData: {
...formData,
x_axis: '__timestamp',
metrics: ['sum__num'],
metricsB: ['sum__num'],
groupby: [],
groupbyB: [],
xAxisLabelRotation: 0,
// showMaxLabel (and therefore hideOverlap: false) only activates when
// a time grain resolves, so this needs one set to actually exercise
// the #39899 fix rather than silently no-op.
timeGrainSqla: TimeGranularity.MONTH,
},
queriesData: [
createTestQueryData(
[
{
__timestamp: Date.UTC(2026, 0, 1),
sum__num: 100,
},
{
__timestamp: Date.UTC(2026, 6, 1),
sum__num: 200,
},
],
{
colnames: ['__timestamp', 'sum__num'],
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
label_map: { __timestamp: ['__timestamp'], sum__num: ['sum__num'] },
},
),
createTestQueryData(
[
{
__timestamp: Date.UTC(2026, 0, 1),
sum__num: 100,
},
{
__timestamp: Date.UTC(2026, 6, 1),
sum__num: 200,
},
],
{
colnames: ['__timestamp', 'sum__num'],
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
label_map: { __timestamp: ['__timestamp'], sum__num: ['sum__num'] },
},
),
],
});
const { echartOptions } = transformProps(chartProps);
const { axisLabel } = echartOptions.xAxis as Record<string, any>;
expect(axisLabel.showMaxLabel).toBe(true);
expect(axisLabel.alignMaxLabel).toBe('right');
expect(axisLabel.hideOverlap).toBe(false);
});
test('regression #37921: multi-metric Query A with groupby does not duplicate first metric in series names', () => {
// Regression test for https://github.com/apache/superset/issues/37921
// ("Residual" follow-up to #37055).
@@ -70,4 +70,42 @@ describe('renderNormalizedTooltip', () => {
expect(tooltip).toContain('N/A');
expect(tooltip).not.toContain('NaN');
});
test('should HTML-escape series names from query data', () => {
// Regression test: the tooltip is rendered via innerHTML, so markup
// in query-result values must not become live DOM.
const tooltip = renderNormalizedTooltip(
{ ...params, name: '<img src=x onerror=alert(1)>' },
metrics,
mockGetDenormalizedValue,
metricsWithCustomBounds,
);
expect(tooltip).not.toContain('<img');
expect(tooltip).toContain('&lt;img');
});
test('should HTML-escape metric labels', () => {
const tooltip = renderNormalizedTooltip(
params,
['<svg onload=alert(1)>', 'metric2'],
mockGetDenormalizedValue,
metricsWithCustomBounds,
);
expect(tooltip).not.toContain('<svg');
expect(tooltip).toContain('&lt;svg');
});
test('should HTML-escape the series color used for the tooltip color dot', () => {
// Regression test: `color` is interpolated into a style attribute
// unquoted, so an unescaped quote could break out of the attribute
// and inject markup.
const tooltip = renderNormalizedTooltip(
{ ...params, color: 'red" onmouseover="alert(1)' },
metrics,
mockGetDenormalizedValue,
metricsWithCustomBounds,
);
expect(tooltip).not.toContain('" onmouseover="alert(1)"');
expect(tooltip).toContain('&quot; onmouseover=&quot;alert(1)');
});
});
@@ -16,11 +16,17 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render } from '@testing-library/react';
import { AxisType } from '@superset-ui/core';
import { render, waitFor } from '@testing-library/react';
import { AxisType, DTTM_ALIAS, TimeGranularity } from '@superset-ui/core';
import { supersetTheme, ThemeProvider } from '@apache-superset/core/theme';
import { logging } from '@apache-superset/core/utils';
import type { ECElementEvent } from 'echarts/types/src/util/types';
import EchartsTimeseries from '../../src/Timeseries/EchartsTimeseries';
import { TimeseriesChartTransformedProps } from '../../src/Timeseries/types';
import {
OrientationType,
TimeseriesChartTransformedProps,
} from '../../src/Timeseries/types';
import type { EchartsProps } from '../../src/types';
// Percent-change draggable baseline: this is the one piece of the ECharts
// rebuilds with zero prior test coverage despite six separate production
@@ -40,12 +46,14 @@ let mockChart: {
convertFromPixel: jest.Mock;
getModel: jest.Mock;
};
const mockEchart = jest.fn();
jest.mock('../../src/components/Echart', () => {
const { forwardRef, useImperativeHandle } = jest.requireActual('react');
return {
__esModule: true,
default: forwardRef((_props: unknown, ref: unknown) => {
default: forwardRef((props: unknown, ref: unknown) => {
mockEchart(props);
useImperativeHandle(ref, () => ({
getEchartInstance: () => mockChart,
}));
@@ -115,6 +123,17 @@ function renderTimeseries(
);
}
function getLatestEchartProps() {
const lastCall = mockEchart.mock.calls.at(-1);
expect(lastCall).toBeDefined();
const [props] = lastCall as [EchartsProps];
return props;
}
function advanceClickTimer() {
jest.advanceTimersByTime(300);
}
// Pulls the graphic descriptor for the draggable baseline handle out of the
// most recent setOption call, mirroring how ECharts itself would read it.
function getBaselineGraphic() {
@@ -126,6 +145,7 @@ function getBaselineGraphic() {
beforeEach(() => {
jest.clearAllMocks();
mockEchart.mockReset();
setupChartMock();
jest.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => {
cb(0);
@@ -134,6 +154,7 @@ beforeEach(() => {
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
@@ -245,3 +266,400 @@ test('does not touch the chart instance when rebase is disabled', () => {
expect(mockChart.setOption).not.toHaveBeenCalled();
});
test('emits TEMPORAL_RANGE cross-filter from time axis label click on day bucket', () => {
const setDataMask = jest.fn();
renderTimeseries({
emitCrossFilters: true,
setDataMask,
groupby: [],
resolvedTimeGrain: TimeGranularity.DAY,
formData: {
granularitySqla: 'ds',
timeGrainSqla: TimeGranularity.DAY,
vizType: 'echarts_timeseries_line',
} as any,
xAxis: {
label: DTTM_ALIAS,
type: AxisType.Time,
},
});
const labelClickHandler = getLatestEchartProps().queryEventHandlers?.find(
({ query }) => query === 'xAxis',
)?.handler;
expect(labelClickHandler).toBeDefined();
labelClickHandler?.({
targetType: 'axisLabel',
value: '2021-01-01',
} as unknown as ECElementEvent);
expect(setDataMask.mock.calls[0][0]).toEqual({
extraFormData: {
filters: [
{
col: 'ds',
op: 'TEMPORAL_RANGE',
val: '2021-01-01T00:00:00 : 2021-01-02T00:00:00',
},
],
},
filterState: {
label: ['2021-01-01T00:00:00 : 2021-01-02T00:00:00'],
value: ['2021-01-01T00:00:00 : 2021-01-02T00:00:00'],
selectedValues: ['2021-01-01T00:00:00 : 2021-01-02T00:00:00'],
},
});
});
test('emits upper-exclusive TEMPORAL_RANGE from time point click on month bucket', () => {
jest.useFakeTimers();
const setDataMask = jest.fn();
renderTimeseries({
emitCrossFilters: true,
setDataMask,
groupby: [],
resolvedTimeGrain: TimeGranularity.MONTH,
formData: {
granularitySqla: 'ds',
timeGrainSqla: TimeGranularity.MONTH,
vizType: 'echarts_timeseries_line',
} as any,
xAxis: {
label: DTTM_ALIAS,
type: AxisType.Time,
},
});
getLatestEchartProps().eventHandlers?.click?.({
componentType: 'series',
seriesName: 'Sales',
data: [Date.UTC(2021, 0, 1), 100],
name: '2021-01-01',
dataIndex: 0,
});
advanceClickTimer();
expect(setDataMask.mock.calls[0][0].extraFormData.filters).toEqual([
{
col: 'ds',
op: 'TEMPORAL_RANGE',
val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
},
]);
});
test('uses resolved time grain for temporal point-click cross-filter', () => {
jest.useFakeTimers();
const setDataMask = jest.fn();
renderTimeseries({
emitCrossFilters: true,
setDataMask,
groupby: [],
resolvedTimeGrain: TimeGranularity.MONTH,
formData: {
granularitySqla: 'ds',
timeGrainSqla: TimeGranularity.DAY,
extraFormData: {
time_grain_sqla: TimeGranularity.MONTH,
},
vizType: 'echarts_timeseries_line',
} as any,
xAxis: {
label: DTTM_ALIAS,
type: AxisType.Time,
},
});
getLatestEchartProps().eventHandlers?.click?.({
componentType: 'series',
seriesName: 'Sales',
data: [Date.UTC(2021, 0, 1), 100],
name: '2021-01-01',
dataIndex: 0,
});
advanceClickTimer();
expect(setDataMask.mock.calls[0][0].extraFormData.filters).toEqual([
{
col: 'ds',
op: 'TEMPORAL_RANGE',
val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
},
]);
});
test('emits TEMPORAL_RANGE from string-typed time point click value', () => {
jest.useFakeTimers();
const setDataMask = jest.fn();
renderTimeseries({
emitCrossFilters: true,
setDataMask,
groupby: [],
resolvedTimeGrain: TimeGranularity.MONTH,
formData: {
granularitySqla: 'ds',
timeGrainSqla: TimeGranularity.MONTH,
vizType: 'echarts_timeseries_line',
} as any,
xAxis: {
label: DTTM_ALIAS,
type: AxisType.Time,
},
});
getLatestEchartProps().eventHandlers?.click?.({
componentType: 'series',
seriesName: 'Sales',
data: ['2021-01-01T00:00:00Z', 100],
name: '2021-01-01',
dataIndex: 0,
});
advanceClickTimer();
expect(setDataMask.mock.calls[0][0].extraFormData.filters).toEqual([
{
col: 'ds',
op: 'TEMPORAL_RANGE',
val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
},
]);
});
test('emits TEMPORAL_RANGE from horizontal time point click using timestamp, not metric', () => {
jest.useFakeTimers();
const setDataMask = jest.fn();
renderTimeseries({
emitCrossFilters: true,
setDataMask,
groupby: [],
resolvedTimeGrain: TimeGranularity.MONTH,
formData: {
orientation: OrientationType.Horizontal,
granularitySqla: 'ds',
timeGrainSqla: TimeGranularity.MONTH,
vizType: 'echarts_timeseries_line',
} as any,
xAxis: {
label: DTTM_ALIAS,
type: AxisType.Time,
},
});
getLatestEchartProps().eventHandlers?.click?.({
componentType: 'series',
seriesName: 'Sales',
data: [129, Date.UTC(2021, 0, 1)],
name: '2021-01-01',
dataIndex: 0,
});
advanceClickTimer();
expect(setDataMask.mock.calls[0][0].extraFormData.filters).toEqual([
{
col: 'ds',
op: 'TEMPORAL_RANGE',
val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
},
]);
});
test('clears temporal X-axis cross-filter when clicking selected bucket again', () => {
jest.useFakeTimers();
const setDataMask = jest.fn();
const selectedRange = '2021-01-01T00:00:00 : 2021-02-01T00:00:00';
renderTimeseries({
emitCrossFilters: true,
setDataMask,
groupby: [],
selectedValues: { 0: selectedRange },
resolvedTimeGrain: TimeGranularity.MONTH,
formData: {
granularitySqla: 'ds',
timeGrainSqla: TimeGranularity.MONTH,
vizType: 'echarts_timeseries_line',
} as any,
xAxis: {
label: DTTM_ALIAS,
type: AxisType.Time,
},
});
getLatestEchartProps().eventHandlers?.click?.({
componentType: 'series',
seriesName: 'Sales',
data: [Date.UTC(2021, 0, 1), 100],
name: '2021-01-01',
dataIndex: 0,
});
advanceClickTimer();
expect(setDataMask.mock.calls[0][0]).toEqual({
extraFormData: {
filters: [],
},
filterState: {
label: undefined,
value: null,
selectedValues: null,
},
});
});
test('emits empty temporal X-axis data mask when filter grain is missing', () => {
jest.useFakeTimers();
const setDataMask = jest.fn();
renderTimeseries({
emitCrossFilters: true,
setDataMask,
groupby: [],
formData: {
granularitySqla: 'ds',
timeGrainSqla: undefined,
vizType: 'echarts_timeseries_line',
} as any,
xAxis: {
label: DTTM_ALIAS,
type: AxisType.Time,
},
});
getLatestEchartProps().eventHandlers?.click?.({
componentType: 'series',
seriesName: 'Sales',
data: [Date.UTC(2021, 0, 1), 100],
name: '2021-01-01',
dataIndex: 0,
});
advanceClickTimer();
expect(setDataMask.mock.calls[0][0]).toEqual({
extraFormData: {
filters: [],
},
filterState: {
label: undefined,
value: null,
selectedValues: null,
},
});
});
test('warns and skips temporal cross-filter when string value cannot be parsed', () => {
jest.useFakeTimers();
const setDataMask = jest.fn();
const warn = jest.spyOn(logging, 'warn').mockImplementation();
renderTimeseries({
emitCrossFilters: true,
setDataMask,
groupby: [],
resolvedTimeGrain: TimeGranularity.MONTH,
formData: {
granularitySqla: 'ds',
timeGrainSqla: TimeGranularity.MONTH,
vizType: 'echarts_timeseries_line',
} as any,
xAxis: {
label: DTTM_ALIAS,
type: AxisType.Time,
},
});
getLatestEchartProps().eventHandlers?.click?.({
componentType: 'series',
seriesName: 'Sales',
data: ['not-a-date', 100],
name: 'not-a-date',
dataIndex: 0,
});
advanceClickTimer();
expect(setDataMask).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledWith(
'Unable to parse time axis value for cross-filtering',
'not-a-date',
);
});
test('does not emit temporal X-axis label cross-filter when dimensions are set', () => {
const setDataMask = jest.fn();
renderTimeseries({
emitCrossFilters: true,
setDataMask,
groupby: ['country'],
formData: {
groupby: ['country'],
granularitySqla: 'ds',
timeGrainSqla: TimeGranularity.MONTH,
vizType: 'echarts_timeseries_line',
} as any,
xAxis: {
label: DTTM_ALIAS,
type: AxisType.Time,
},
});
const labelClickHandler = getLatestEchartProps().queryEventHandlers?.find(
({ query }) => query === 'xAxis',
)?.handler;
expect(labelClickHandler).toBeDefined();
labelClickHandler?.({
targetType: 'axisLabel',
value: '2021-01-01',
} as unknown as ECElementEvent);
expect(setDataMask).not.toHaveBeenCalled();
});
test('context menu cross-filter is available for a temporal bar point', async () => {
const onContextMenu = jest.fn();
renderTimeseries({
emitCrossFilters: true,
onContextMenu,
groupby: [],
resolvedTimeGrain: TimeGranularity.MONTH,
formData: {
granularitySqla: 'ds',
timeGrainSqla: TimeGranularity.DAY,
extraFormData: {
time_grain_sqla: TimeGranularity.MONTH,
},
vizType: 'echarts_timeseries_line',
} as any,
xAxis: {
label: DTTM_ALIAS,
type: AxisType.Time,
},
});
await getLatestEchartProps().eventHandlers?.contextmenu?.({
componentType: 'series',
seriesName: 'Sales',
data: [Date.UTC(2021, 0, 1), 100],
name: '2021-01-01',
event: { stop: jest.fn(), event: { clientX: 10, clientY: 20 } },
});
await waitFor(() => {
expect(onContextMenu).toHaveBeenCalled();
});
const { crossFilter } = onContextMenu.mock.calls[0][2];
expect(crossFilter.dataMask.extraFormData.filters).toEqual([
{
col: 'ds',
op: 'TEMPORAL_RANGE',
val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
},
]);
});
@@ -2083,6 +2083,39 @@ test('xAxisForceCategorical forces Category axis regardless of Numeric coltype',
expect(xAxis.triggerEvent).toBe(true);
});
test('temporal x-axis enables trigger events when no dimensions are set', () => {
const ts1 = 1745784000000;
const ts2 = 1745870400000;
const chartProps = createTestChartProps({
formData: {
metrics: ['metric'],
granularity_sqla: 'ds',
x_axis: '__timestamp',
},
queriesData: [
createTestQueryData(
[
{ __timestamp: ts1, metric: 10 },
{ __timestamp: ts2, metric: 20 },
],
{
colnames: ['__timestamp', 'metric'],
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
},
),
],
});
const { echartOptions } = transformProps(chartProps);
const xAxis = echartOptions.xAxis as {
triggerEvent?: boolean;
type: string;
};
expect(xAxis.type).toBe(AxisType.Time);
expect(xAxis.triggerEvent).toBe(true);
});
test('temporal x coltype forced categorical yields a Category axis with date labels', () => {
// Issue #28204: with a temporal x-axis (e.g. weekly grain) the default Time
// scale places ticks at "nice" intervals that don't line up with the buckets.
@@ -2330,6 +2363,7 @@ test('tooltip time grain wiring: dashboard-level extraFormData time grain overri
});
const transformedProps = transformProps(chartProps);
expect(transformedProps.resolvedTimeGrain).toBe(TimeGranularity.MONTH);
const tooltipFormatter = (
transformedProps.echartOptions as unknown as TooltipFormatterOptions
).tooltip.formatter;
@@ -2363,6 +2397,7 @@ test('tooltip time grain wiring: chart-level time grain drives the tooltip when
});
const transformedProps = transformProps(chartProps);
expect(transformedProps.resolvedTimeGrain).toBe(TimeGranularity.YEAR);
const tooltipFormatter = (
transformedProps.echartOptions as unknown as TooltipFormatterOptions
).tooltip.formatter;
@@ -2529,3 +2564,64 @@ describe('EchartsTimeseries tooltip truncation', () => {
expect(buildTooltip(undefined, longCategory)).toContain(longCategory);
});
});
describe('tooltip for metrics whose labels end in forecast suffixes', () => {
const marker = '<span style="background-color:#1f77b4;"></span>';
const seriesIds = ['ci__yhat', 'ci__yhat_lower', 'ci__yhat_upper'];
const values = [1.5, 0.5, 2.0];
// Metrics can be labelled `ci__yhat*` with no forecast enabled and no plain
// observation series. Every series then collapses onto the same
// forecast-stripped tooltip key, so no raw series id matches itself.
const buildTooltip = (tooltipSortByMetric = false) => {
const chartProps = createTestChartProps({
formData: {
x_axis: 'dt',
metrics: seriesIds,
groupby: [],
richTooltip: true,
tooltipSortByMetric,
} as Partial<EchartsTimeseriesFormData>,
queriesData: [
createTestQueryData([
{
dt: 599616000000,
ci__yhat: 1.5,
ci__yhat_lower: 0.5,
ci__yhat_upper: 2.5,
},
]),
],
});
const tooltipFormatter = (transformProps(chartProps).echartOptions as any)
.tooltip.formatter;
return tooltipFormatter(
seriesIds.map((id, i) => ({
seriesId: id,
seriesName: id,
value: [599616000000, values[i]],
data: [599616000000, values[i]],
marker,
})),
);
};
test('renders the collapsed series rather than falling back to "No data"', () => {
const html = buildTooltip();
expect(html).not.toContain('No data');
expect(html).toContain('>ci<');
expect(html).toContain('ŷ = 1.5 (0.5, 2.5)');
});
test('renders a single row rather than one per forecast suffix', () => {
const html = buildTooltip();
expect(html.match(/<tr/g)).toHaveLength(1);
expect(html).toContain('>ci<');
});
test('still renders the row when the tooltip is sorted by metric', () => {
const html = buildTooltip(true);
expect(html).not.toContain('No data');
expect(html).toContain('>ci<');
});
});
@@ -20,13 +20,14 @@ import {
CategoricalColorScale,
ChartProps,
TimeGranularity,
getNumberFormatter,
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import { supersetTheme } from '@apache-superset/core/theme';
import type { SeriesOption } from 'echarts';
import type { ScatterSeriesOption } from 'echarts/charts';
import { EchartsTimeseriesSeriesType } from '../../src';
import { TIMESERIES_CONSTANTS } from '../../src/constants';
import { StackControlsValue, TIMESERIES_CONSTANTS } from '../../src/constants';
import {
LegendOrientation,
EchartsTimeseriesChartProps,
@@ -340,15 +341,15 @@ test('should configure time axis labels to show max label for last month visibil
);
});
test('x-axis dates do not overlap and last label stays visible at 0° rotation', () => {
test('#39899 - x-axis dates do not overlap and last label stays visible at 0° rotation', () => {
const result = transformProps(buildTimeseriesChartProps());
const { axisLabel } = result.echartOptions.xAxis as Record<string, any>;
expect(axisLabel.hideOverlap).toBe(true);
// showMaxLabel forces the last data point label to render even
// when hideOverlap is active, preventing the #37181 regression.
// showMaxLabel forces the last data point label to render
expect(axisLabel.showMaxLabel).toBe(true);
expect(axisLabel.alignMaxLabel).toBe('right');
// hideOverlap must be OFF so ECharts cannot suppress the forced max label
expect(axisLabel.hideOverlap).toBe(false);
});
test('last x-axis date is visible and not cut off when rotated -45°', () => {
@@ -566,3 +567,70 @@ test('getPadding should handle Left position with zero margin correctly', () =>
getChartPaddingSpy.mockRestore();
}
});
/**
* #42702: a stacked segment with no height starts and ends at the same
* coordinate as the top of the segment beneath it, so a value label on it is
* drawn over that segment's label. `percentage_threshold` does not filter these
* out: it defaults to 0, and `thresholdValues[dataIndex] || MIN_SAFE_INTEGER`
* turns a 0 threshold into "no filtering", which is intentional.
*/
const stackedLabel = (
numericValue: number | null,
opts: Record<string, unknown> = {},
) => {
const series = transformSeries(
{ id: 'B', name: 'B', data: [[1, numericValue]] } as SeriesOption,
mockColorScale,
'B',
{
seriesType: EchartsTimeseriesSeriesType.Bar,
stack: StackControlsValue.Stack,
showValue: true,
onlyTotal: false,
formatter: getNumberFormatter(),
thresholdValues: [0],
...opts,
},
) as SeriesOption & {
label: { formatter: (params: unknown) => string };
};
return series.label.formatter({
value: [1, numericValue],
dataIndex: 0,
seriesIndex: 1,
seriesName: 'B',
});
};
test('stacked value labels are omitted for a zero-height segment', () => {
expect(stackedLabel(0)).toBe('');
expect(stackedLabel(null)).toBe('');
});
test('stacked value labels are kept for segments that have height', () => {
expect(stackedLabel(32)).toBe('32');
expect(stackedLabel(-5)).toBe('-5');
});
test('a zero value keeps its label when the series is not stacked', () => {
// Without a stack the label sits on the bar itself, so there is nothing for
// it to collide with.
expect(stackedLabel(0, { stack: undefined })).toBe('0');
});
test('percentage_threshold still filters values below the threshold', () => {
// 10% of a 100 total. The zero-height guard must not swallow this rule.
expect(stackedLabel(5, { thresholdValues: [10] })).toBe('');
expect(stackedLabel(50, { thresholdValues: [10] })).toBe('50');
});
test('only-total labels are unaffected by the zero-height guard', () => {
expect(
stackedLabel(0, {
onlyTotal: true,
showValueIndexes: [1],
totalStackedValues: [32],
}),
).toBe('32');
});
@@ -23,6 +23,7 @@ import {
} from '@superset-ui/core';
import { SeriesOption } from 'echarts';
import {
collapseForecastKeys,
extractForecastSeriesContext,
extractForecastValuesFromTooltipParams,
formatForecastTooltipSeries,
@@ -464,3 +465,35 @@ describe('formatForecastTooltipSeries truncation', () => {
expect(cell).toBe(`${marker}cpu`);
});
});
describe('collapseForecastKeys', () => {
test('leaves plain observation series untouched and in order', () => {
expect(collapseForecastKeys(['foo', 'bar'])).toEqual(['foo', 'bar']);
});
test('folds a forecast bundle down to a single key', () => {
expect(
collapseForecastKeys([
'foo',
'foo__yhat',
'foo__yhat_lower',
'foo__yhat_upper',
]),
).toEqual(['foo']);
});
test('keeps a key for metrics whose labels are entirely forecast suffixes', () => {
// Charts can carry metrics literally labelled `ci__yhat*` with no plain
// observation series. Callers match these against forecast-stripped keys,
// so an uncollapsed id here would match nothing and drop every row.
expect(
collapseForecastKeys(['ci__yhat', 'ci__yhat_lower', 'ci__yhat_upper']),
).toEqual(['ci']);
});
test('preserves the incoming order of distinct series', () => {
expect(
collapseForecastKeys(['b__yhat_lower', 'a__yhat', 'b__yhat']),
).toEqual(['b', 'a']);
});
});
@@ -290,6 +290,25 @@ test('isAdditiveMetric: non-additive aggregates, SQL, and saved metrics are not
expect(isAdditiveMetric('count')).toBe(false);
});
test('isAdditiveMetric: MEDIAN/STDDEV_SAMP/VAR_SAMP are non-additive, with no dedicated code needed', () => {
// Regression guard: MEDIAN/STDDEV_SAMP/VAR_SAMP are new system-wide metric
// aggregates (not pivot-table-specific). They must fall outside
// ADDITIVE_AGGREGATES so totals/subtotals route through the correct
// DB-rollup path automatically, same as AVG/COUNT_DISTINCT already do --
// averaging per-group medians (or variances) is exactly the class of bug
// SIP-216 fixed for AVG, and would be equally wrong here.
(['MEDIAN', 'STDDEV_SAMP', 'VAR_SAMP'] as const).forEach(aggregate => {
expect(
isAdditiveMetric({
expressionType: 'SIMPLE',
aggregate,
column: { column_name: 'num' },
label: `${aggregate.toLowerCase()}_num`,
} as QueryFormMetric),
).toBe(false);
});
});
test('allMetricsAdditive: all additive vs any non-additive vs empty', () => {
const sum = {
expressionType: 'SIMPLE',
@@ -80,6 +80,19 @@ interface DatamapSource {
country?: string;
}
/**
* Escape HTML special characters to prevent XSS attacks. Popup templates are
* assigned to the hover element via innerHTML by the datamaps library, and
* formatter output can echo a creator-controlled format string verbatim
* (see createD3NumberFormatter's invalid-format fallback), so both the name
* and the formatted value must be treated as untrusted text.
*/
function escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
const propTypes = {
data: PropTypes.arrayOf(
PropTypes.shape({
@@ -279,9 +292,9 @@ function WorldMap(element: HTMLElement, props: WorldMapProps): void {
highlightBorderWidth: 1,
popupTemplate: (geo, d) =>
d &&
`<div class="hoverinfo"><strong>${d.name}</strong><br>${formatter(
d.m1,
)}</div>`,
`<div class="hoverinfo"><strong>${escapeHtml(
d.name,
)}</strong><br>${escapeHtml(String(formatter(d.m1)))}</div>`,
},
bubblesConfig: {
borderWidth: 1,
@@ -290,9 +303,9 @@ function WorldMap(element: HTMLElement, props: WorldMapProps): void {
popupOnHover: !inContextMenu,
radius: null,
popupTemplate: (geo, d) =>
`<div class="hoverinfo"><strong>${d.name}</strong><br>${formatter(
d.m2,
)}</div>`,
`<div class="hoverinfo"><strong>${escapeHtml(
d.name,
)}</strong><br>${escapeHtml(String(formatter(d.m2)))}</div>`,
fillOpacity: 0.5,
animate: true,
highlightOnHover: !inContextMenu,
@@ -180,6 +180,33 @@ test('disables Datamaps highlightOnHover while the context menu is open', () =>
expect(geographyConfig?.highlightOnHover).toBe(false);
});
test('escapes markup in hover popup templates', () => {
// Regression test for stored XSS via the number-formatter fallback: an
// invalid Y Axis Format string is echoed verbatim by the formatter
// (createD3NumberFormatter's catch branch), so the popup templates must
// HTML-escape formatter output before datamaps assigns it via innerHTML.
const maliciousFormatter = getNumberFormatter('<img src=x onerror=alert(1)>');
WorldMap(container, { ...baseProps, formatter: maliciousFormatter });
const geographyConfig = lastDatamapConfig?.geographyConfig as {
popupTemplate: (geo: unknown, d: unknown) => string;
};
const bubblesConfig = lastDatamapConfig?.bubblesConfig as {
popupTemplate: (geo: unknown, d: unknown) => string;
};
const entry = { name: '<b>United States</b>', m1: 100, m2: 200 };
const geoPopup = geographyConfig.popupTemplate({}, entry);
const bubblePopup = bubblesConfig.popupTemplate({}, entry);
[geoPopup, bubblePopup].forEach(popup => {
expect(popup).not.toContain('<img');
expect(popup).not.toContain('<b>');
expect(popup).toContain('&lt;img src=x onerror=alert(1)&gt;');
expect(popup).toContain('&lt;b&gt;United States&lt;/b&gt;');
});
});
test('does not throw error when onContextMenu is undefined', () => {
const propsWithoutContextMenu = {
...baseProps,
@@ -1524,7 +1524,9 @@ export function popPermalink(key: string): SqlLabThunkAction<Promise<unknown>> {
dbId: json.dbId ? parseInt(json.dbId, 10) : undefined,
catalog: json.catalog ?? null,
schema: json.schema ?? undefined,
autorun: json.autorun ? json.autorun : false,
// The recipient must review the prefilled query and press
// Run; a permalink payload never auto-runs.
autorun: false,
sql: json.sql ? json.sql : 'SELECT ...',
templateParams: json.templateParams,
}),
@@ -1548,7 +1550,9 @@ export function popStoredQuery(
dbId: json.dbId ? parseInt(json.dbId, 10) : undefined,
catalog: json.catalog ?? null,
schema: json.schema ?? undefined,
autorun: json.autorun ? json.autorun : false,
// Same rule as popPermalink above — stored payloads never
// auto-run.
autorun: false,
sql: json.sql ? json.sql : 'SELECT ...',
templateParams: json.templateParams,
}),
@@ -1627,7 +1631,9 @@ export function popDatasourceQuery(
name: `${QUERY_TEXT} ${json.result.name}`,
dbId: json.result.database.id,
schema: json.result.schema,
autorun: sql !== undefined,
// `sql` here can come straight from the URL, so its mere
// presence must never imply auto-execution.
autorun: false,
sql: sql || json.result.select_star,
}),
),
@@ -390,7 +390,7 @@ const ResultSet = ({
// provides.
redirect(getExportCsvUrl(query.id));
},
confirmText: t('OK'),
confirmText: t('Confirm'),
cancelText: t('Close'),
});
}
@@ -719,7 +719,7 @@ const ResultSet = ({
if (data && data.length > 0) {
const allowHTML = getItem(
LocalStorageKeys.SqllabIsRenderHtmlEnabled,
true,
false,
);
const tableProps = {
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen } from 'spec/helpers/testing-library';
import { render, screen, userEvent } from 'spec/helpers/testing-library';
import SaveDatasetActionButton from 'src/SqlLab/components/SaveDatasetActionButton';
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
@@ -27,6 +27,7 @@ describe('SaveDatasetActionButton', () => {
<SaveDatasetActionButton
setShowSave={() => true}
onSaveAsExplore={onSaveAsExplore}
canSaveDataset
/>,
);
@@ -41,4 +42,27 @@ describe('SaveDatasetActionButton', () => {
expect(saveBtn).toBeVisible();
expect(saveDatasetBtn).toBeVisible();
});
test('disables the save dataset button when the query did not run successfully', async () => {
render(
<SaveDatasetActionButton
setShowSave={() => true}
onSaveAsExplore={jest.fn()}
canSaveDataset={false}
/>,
);
const saveDatasetBtn = screen.getByRole('button', {
name: /save dataset/i,
});
expect(saveDatasetBtn).toBeDisabled();
// the disabled button is wrapped in a span so the tooltip still triggers
userEvent.hover(saveDatasetBtn.parentElement as HTMLElement);
expect(
await screen.findByRole('tooltip', {
name: 'You must run the query successfully first',
}),
).toBeInTheDocument();
});
});
@@ -23,11 +23,13 @@ import { Button } from '@superset-ui/core/components';
interface SaveDatasetActionButtonProps {
setShowSave: (arg0: boolean) => void;
onSaveAsExplore?: () => void;
canSaveDataset: boolean;
}
const SaveDatasetActionButton = ({
setShowSave,
onSaveAsExplore,
canSaveDataset,
}: SaveDatasetActionButtonProps) => (
<>
<Button
@@ -43,8 +45,13 @@ const SaveDatasetActionButton = ({
color="default"
variant="text"
onClick={() => onSaveAsExplore?.()}
disabled={!canSaveDataset}
icon={<Icons.TableOutlined />}
tooltip={t('Save or Overwrite Dataset')}
tooltip={
canSaveDataset
? t('Save or Overwrite Dataset')
: t('You must run the query successfully first')
}
aria-label={t('Save dataset')}
/>
)}
@@ -35,6 +35,7 @@ const mockedProps = {
onSave: () => {},
saveQueryWarning: null,
columns: [],
canSaveDataset: true,
};
const mockState = {
@@ -52,6 +52,7 @@ interface SaveQueryProps {
onUpdate: (arg0: QueryPayload, id: string) => void;
saveQueryWarning: string | null;
database: Partial<DatabaseObject> | undefined;
canSaveDataset: boolean;
}
export type QueryPayload = {
@@ -81,6 +82,7 @@ const SaveQuery = ({
saveQueryWarning,
database,
columns,
canSaveDataset,
}: SaveQueryProps) => {
const queryEditor = useQueryEditor(queryEditorId, [
'autorun',
@@ -207,6 +209,7 @@ const SaveQuery = ({
<SaveDatasetActionButton
setShowSave={setShowSave}
onSaveAsExplore={canExploreDatabase ? onSaveAsExplore : undefined}
canSaveDataset={canSaveDataset}
/>
)}
<SaveDatasetModal
@@ -21,6 +21,7 @@ import {
isFeatureEnabled,
getExtensionsRegistry,
FeatureFlag,
QueryState,
} from '@superset-ui/core';
import {
act,
@@ -334,6 +335,47 @@ describe('SqlEditor', () => {
expect(await findByText('10 000')).toBeInTheDocument();
});
const setupWithLatestQuery = (overrides: Partial<typeof latestQuery>) =>
setup(
mockedProps,
createStore({
...mockInitialState,
sqlLab: {
...mockInitialState.sqlLab,
queries: {
[latestQuery.id]: { ...latestQuery, ...overrides },
},
databases: {
1991: {
...mockInitialState.sqlLab.databases[1991],
allows_virtual_table_explore: true,
},
},
},
}),
);
test('enables the save dataset button when the latest query succeeded', async () => {
const { findByRole } = setupWithLatestQuery({ state: QueryState.Success });
expect(await findByRole('button', { name: 'Save dataset' })).toBeEnabled();
});
test('disables the save dataset button when the latest query failed', async () => {
const { findByRole } = setupWithLatestQuery({
state: QueryState.Failed,
results: undefined,
});
expect(await findByRole('button', { name: 'Save dataset' })).toBeDisabled();
});
test('disables the save dataset button when the results are not loaded', async () => {
const { findByRole } = setupWithLatestQuery({
state: QueryState.Success,
results: undefined,
});
expect(await findByRole('button', { name: 'Save dataset' })).toBeDisabled();
});
test('renders an Extension if provided', async () => {
const extensionsRegistry = getExtensionsRegistry();
@@ -40,6 +40,7 @@ import {
getExtensionsRegistry,
QueryResponse,
Query,
QueryState,
} from '@superset-ui/core';
import { Alert } from '@apache-superset/core/components';
import { css, styled, useTheme } from '@apache-superset/core/theme';
@@ -283,7 +284,7 @@ const SqlEditor: FC<Props> = ({
getItem(LocalStorageKeys.SqllabIsAutocompleteEnabled, true),
);
const [renderHTMLEnabled, setRenderHTMLEnabled] = useState(
getItem(LocalStorageKeys.SqllabIsRenderHtmlEnabled, true),
getItem(LocalStorageKeys.SqllabIsRenderHtmlEnabled, false),
);
const [showCreateAsModal, setShowCreateAsModal] = useState(false);
const [createAs, setCreateAs] = useState('');
@@ -295,6 +296,9 @@ const SqlEditor: FC<Props> = ({
const SqlFormExtension = extensionsRegistry.get('sqleditor.extension.form');
const successful = latestQuery?.state === QueryState.Success;
const resultColumns = latestQuery?.results?.columns || [];
const startQuery = useCallback(
(
ctasArg = false,
@@ -712,7 +716,6 @@ const SqlEditor: FC<Props> = ({
const getSecondaryMenuItems = () => {
const qe = queryEditor;
const successful = latestQuery?.state === 'success';
const scheduleToolTip = successful
? t('Schedule the query periodically')
: t('You must run the query successfully first');
@@ -858,13 +861,14 @@ const SqlEditor: FC<Props> = ({
)}
<SaveQuery
queryEditorId={queryEditor.id}
columns={latestQuery?.results?.columns || []}
columns={resultColumns}
onSave={onSaveQuery}
onUpdate={(query, remoteId) =>
dispatch(updateSavedQuery(query, remoteId))
}
saveQueryWarning={saveQueryWarning}
database={database}
canSaveDataset={successful && resultColumns.length > 0}
/>
<ShareSqlLabQuery queryEditorId={queryEditor.id} />
</>
@@ -223,6 +223,11 @@ export default function chartReducer(
}
if (action.type in actionHandlers) {
// ADD_CHART creates the entry, so it runs without prior state; every other
// handler reads state that is absent once the chart has been removed
if (action.type !== actions.ADD_CHART && !charts[action.key]) {
return charts;
}
return {
...charts,
[action.key]: actionHandlers[action.type](charts[action.key]),
@@ -91,4 +91,20 @@ describe('chart reducers', () => {
expect(newState[chartKey].chartUpdateEndTime).toBeGreaterThan(0);
expect(newState[chartKey].chartStatus).toEqual('failed');
});
test('ignores an action for a chart that is no longer in state', () => {
const action = actions.chartUpdateStopped(999, new AbortController());
expect(() => chartReducer(charts, action)).not.toThrow();
expect(chartReducer(charts, action)).toEqual(charts);
});
test('still adds a chart that is not yet in state', () => {
const newChartKey = 2;
const newState = chartReducer(
charts,
actions.addChart({ ...chart, id: newChartKey }, newChartKey),
);
expect(newState[newChartKey].id).toEqual(newChartKey);
expect(newState[chartKey]).toEqual(testChart);
});
});
@@ -120,7 +120,7 @@ describe('DatasourceModal', () => {
});
const saveButton = screen.getByTestId('datasource-modal-save');
fireEvent.click(saveButton);
const okButton = await screen.findByRole('button', { name: 'OK' });
const okButton = await screen.findByRole('button', { name: 'Confirm' });
fireEvent.click(okButton);
await waitFor(() => {
expect(onDatasourceSave).toHaveBeenCalled();
@@ -142,7 +142,7 @@ describe('DatasourceModal', () => {
const saveButton = screen.getByTestId('datasource-modal-save');
fireEvent.click(saveButton);
const okButton = await screen.findByRole('button', { name: 'OK' });
const okButton = await screen.findByRole('button', { name: 'Confirm' });
fireEvent.click(okButton);
const errorElements = await screen.findAllByText('Error saving dataset');
@@ -230,7 +230,7 @@ describe('DatasourceModal', () => {
expect(checkbox).toBeChecked();
// Click OK to submit
const okButton = screen.getByRole('button', { name: 'OK' });
const okButton = screen.getByRole('button', { name: 'Confirm' });
fireEvent.click(okButton);
// Verify the PUT request was made with override_columns=true
@@ -297,7 +297,7 @@ describe('DatasourceModal', () => {
expect(checkbox).not.toBeChecked();
// Click OK to submit
const okButton = screen.getByRole('button', { name: 'OK' });
const okButton = screen.getByRole('button', { name: 'Confirm' });
fireEvent.click(okButton);
// Verify the PUT request was made with override_columns=false
@@ -395,7 +395,7 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
show={confirmModalOpen}
onHide={handleConfirmModalClose}
onHandledPrimaryAction={handleConfirmSave}
primaryButtonName={t('OK')}
primaryButtonName={t('Confirm')}
primaryButtonLoading={isSaving}
>
{getSaveDialog()}
@@ -1993,7 +1993,6 @@ function DatasourceEditor({
col => col.column_name,
)}
height={300}
allowHTML
/>
</>
)}
@@ -63,7 +63,7 @@ export const FilterableTable = ({
height,
filterText = '',
expandedColumns = [],
allowHTML = true,
allowHTML = false,
striped,
themeOverrides,
}: FilterableTableProps) => {
@@ -64,6 +64,35 @@ test('should render cellData value for default cell data', () => {
expect(container).toHaveTextContent('regular_text');
});
test('should render HTML cell data as inert text by default', () => {
const { container } = render(
<>
{renderResultCell({
cellData: '<img src="https://attacker.example/beacon.gif" />link',
columnKey: 'a',
})}
</>,
);
expect(container.querySelector('img')).not.toBeInTheDocument();
expect(container).toHaveTextContent(
'<img src="https://attacker.example/beacon.gif" />link',
);
});
test('should render sanitized HTML only when allowHTML is explicitly enabled', () => {
const { container } = render(
<>
{renderResultCell({
cellData: '<b>bold</b>',
columnKey: 'a',
allowHTML: true,
})}
</>,
);
expect(container.querySelector('b')).toBeInTheDocument();
expect(container).toHaveTextContent('bold');
});
test('should transform cell data by getCellContent for the regular text', () => {
const { container } = render(
<>
@@ -32,11 +32,15 @@ type Params = CellParams & {
getCellContent?: (args: CellParams) => string;
};
// Result cells carry untrusted warehouse data, so HTML rendering is opt-in:
// even sanitized markup keeps active capabilities (img/video fetch beacons,
// phishing anchors), which must not activate by default for data the viewer
// did not author.
export const renderResultCell = ({
cellData,
getCellContent,
columnKey,
allowHTML = true,
allowHTML = false,
}: Params) => {
const cellNode =
getCellContent?.({ cellData, columnKey }) ?? String(cellData);
+31
View File
@@ -0,0 +1,31 @@
/**
* 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 {
URL_PARAMS,
RESERVED_CHART_URL_PARAMS,
RESERVED_DASHBOARD_URL_PARAMS,
} from 'src/constants';
test('permalinkKey is reserved on both the chart and dashboard URL param lists', () => {
// Dashboard and explore permalinks resolve against different backend
// KV resources/salts, so a key from one must never leak into the other's
// URL via the reserved-params passthrough logic.
expect(RESERVED_DASHBOARD_URL_PARAMS).toContain(URL_PARAMS.permalinkKey.name);
expect(RESERVED_CHART_URL_PARAMS).toContain(URL_PARAMS.permalinkKey.name);
});
+1
View File
@@ -123,6 +123,7 @@ export const RESERVED_CHART_URL_PARAMS: string[] = [
URL_PARAMS.datasourceId.name,
URL_PARAMS.datasourceType.name,
URL_PARAMS.datasetId.name,
URL_PARAMS.permalinkKey.name,
URL_PARAMS.versionHistory.name,
];
export const RESERVED_DASHBOARD_URL_PARAMS: string[] = [
@@ -1,106 +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 { ComponentType } from 'react';
import type { dashboardComponents as api } from '@apache-superset/core';
import { Disposable } from '../models';
import { createEventEmitter } from '../utils';
type Definition = api.DashboardComponentDefinition;
type Props = api.DashboardComponentProps;
type Registered = api.RegisteredDashboardComponent;
/**
* Singleton registry for contributed dashboard components. Unlike the chat
* provider (one active chat), this holds many components keyed by id. Built-in
* components register here at startup; extensions register at module-load time.
*/
class DashboardComponentsProvider {
private static instance: DashboardComponentsProvider;
private components = new Map<string, Registered>();
// Cached, referentially-stable snapshot for useSyncExternalStore; rebuilt
// only when the set of components changes.
private snapshot: Registered[] = [];
private stateSubscribers = new Set<() => void>();
private registerEmitter = createEventEmitter<Definition>();
private unregisterEmitter = createEventEmitter<Definition>();
public static getInstance(): DashboardComponentsProvider {
if (!DashboardComponentsProvider.instance) {
DashboardComponentsProvider.instance = new DashboardComponentsProvider();
}
return DashboardComponentsProvider.instance;
}
public subscribe = (listener: () => void): (() => void) => {
this.stateSubscribers.add(listener);
return () => this.stateSubscribers.delete(listener);
};
private notifyState(): void {
this.snapshot = Array.from(this.components.values());
this.stateSubscribers.forEach(fn => fn());
}
public registerDashboardComponent = (
definition: Definition,
component: ComponentType<Props>,
): Disposable => {
if (this.components.has(definition.id)) {
// eslint-disable-next-line no-console
console.warn(
`[Superset] A dashboard component "${definition.id}" is already ` +
`registered; replacing it.`,
);
}
const entry: Registered = { definition, Component: component };
this.components.set(definition.id, entry);
this.registerEmitter.fire(definition);
this.notifyState();
return new Disposable(() => {
// Only remove if this exact registration is still the active one.
if (this.components.get(definition.id) === entry) {
this.components.delete(definition.id);
this.unregisterEmitter.fire(definition);
this.notifyState();
}
});
};
public getDashboardComponent = (id: string): Registered | undefined =>
this.components.get(id);
public getDashboardComponents = (): Registered[] => this.snapshot;
public get onDidRegisterDashboardComponent() {
return this.registerEmitter.subscribe;
}
public get onDidUnregisterDashboardComponent() {
return this.unregisterEmitter.subscribe;
}
}
export default DashboardComponentsProvider;
@@ -1,88 +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 { dashboardComponents } from './index';
const Noop = () => null;
const def = (id: string) => ({ id, name: id });
test('registerDashboardComponent makes a component retrievable', () => {
const disposable = dashboardComponents.registerDashboardComponent(
def('acme.widget'),
Noop,
);
expect(dashboardComponents.getDashboardComponent('acme.widget')).toEqual({
definition: def('acme.widget'),
Component: Noop,
});
expect(
dashboardComponents
.getDashboardComponents()
.some(r => r.definition.id === 'acme.widget'),
).toBe(true);
disposable.dispose();
});
test('disposing the registration unregisters the component', () => {
const disposable = dashboardComponents.registerDashboardComponent(
def('acme.temp'),
Noop,
);
expect(dashboardComponents.getDashboardComponent('acme.temp')).toBeDefined();
disposable.dispose();
expect(
dashboardComponents.getDashboardComponent('acme.temp'),
).toBeUndefined();
});
test('registering the same id twice replaces the first', () => {
jest.spyOn(console, 'warn').mockImplementation(() => {});
const A = () => null;
const B = () => null;
dashboardComponents.registerDashboardComponent(def('acme.dup'), A);
const second = dashboardComponents.registerDashboardComponent(
def('acme.dup'),
B,
);
expect(dashboardComponents.getDashboardComponent('acme.dup')?.Component).toBe(
B,
);
jest.restoreAllMocks();
second.dispose();
});
test('disposing a stale registration does not remove the active one', () => {
jest.spyOn(console, 'warn').mockImplementation(() => {});
const A = () => null;
const B = () => null;
const first = dashboardComponents.registerDashboardComponent(
def('acme.stale'),
A,
);
const second = dashboardComponents.registerDashboardComponent(
def('acme.stale'),
B,
);
// Disposing the superseded registration is a no-op.
first.dispose();
expect(
dashboardComponents.getDashboardComponent('acme.stale')?.Component,
).toBe(B);
jest.restoreAllMocks();
second.dispose();
});
@@ -1,49 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* @fileoverview Host implementation of the `dashboardComponents` contribution
* type. Extensions register via `dashboardComponents.registerDashboardComponent()`
* and the host renders contributed components inside its own dashboard chrome.
*
* The public namespace (`dashboardComponents`) is exposed to extensions on
* `window.superset`. `useDashboardComponents` is host-internal and NOT part of
* the public `@apache-superset/core` API.
*/
import { useSyncExternalStore } from 'react';
import type { dashboardComponents as api } from '@apache-superset/core';
import DashboardComponentsProvider from './DashboardComponentsProvider';
const provider = DashboardComponentsProvider.getInstance();
/**
* Host-internal hook returning all registered dashboard components, re-rendering
* when the set changes.
*/
export const useDashboardComponents = () =>
useSyncExternalStore(provider.subscribe, provider.getDashboardComponents);
export const dashboardComponents: typeof api = {
registerDashboardComponent: provider.registerDashboardComponent,
getDashboardComponent: provider.getDashboardComponent,
getDashboardComponents: provider.getDashboardComponents,
onDidRegisterDashboardComponent: provider.onDidRegisterDashboardComponent,
onDidUnregisterDashboardComponent: provider.onDidUnregisterDashboardComponent,
};
-1
View File
@@ -29,7 +29,6 @@ export const core: typeof coreType = {
export * from './authentication';
export * from './chat';
export * from './commands';
export * from './dashboardComponents';
export * from './editors';
export * from './extensions';
export * from './menus';
@@ -21,7 +21,6 @@ import tinycolor from 'tinycolor2';
import Tabs from '@superset-ui/core/components/Tabs';
import { t } from '@apache-superset/core/translation';
import { css, SupersetTheme } from '@apache-superset/core/theme';
import { useDashboardComponents } from 'src/core';
import SliceAdder from 'src/dashboard/containers/SliceAdder';
import dashboardComponents from 'src/visualizations/presets/dashboardComponents';
import NewColumn from '../gridComponents/new/NewColumn';
@@ -30,7 +29,6 @@ import NewHeader from '../gridComponents/new/NewHeader';
import NewRow from '../gridComponents/new/NewRow';
import NewTabs from '../gridComponents/new/NewTabs';
import NewMarkdown from '../gridComponents/new/NewMarkdown';
import NewExtensionComponent from '../gridComponents/new/NewExtensionComponent';
import NewDynamicComponent from '../gridComponents/new/NewDynamicComponent';
const BUILDER_PANE_WIDTH = 374;
@@ -40,94 +38,83 @@ const TABS_KEYS = {
LAYOUT_ELEMENTS: 'LAYOUT_ELEMENTS',
};
const BuilderComponentPane = ({ topOffset = 0 }) => {
const extensionComponents = useDashboardComponents();
return (
const BuilderComponentPane = ({ topOffset = 0 }) => (
<div
data-test="dashboard-builder-sidepane"
css={css`
position: sticky;
right: 0;
top: ${topOffset}px;
height: calc(100vh - ${topOffset}px);
width: ${BUILDER_PANE_WIDTH}px;
`}
>
<div
data-test="dashboard-builder-sidepane"
css={css`
position: sticky;
right: 0;
top: ${topOffset}px;
height: calc(100vh - ${topOffset}px);
css={(theme: SupersetTheme) => css`
position: absolute;
height: 100%;
width: ${BUILDER_PANE_WIDTH}px;
box-shadow: -${theme.sizeUnit}px 0 ${theme.sizeUnit}px 0
${tinycolor(theme.colorBorder).setAlpha(0.1).toRgbString()};
background-color: ${theme.colorBgBase};
`}
>
<div
<Tabs
data-test="dashboard-builder-component-pane-tabs-navigation"
id="tabs"
css={(theme: SupersetTheme) => css`
position: absolute;
line-height: inherit;
margin-top: ${theme.sizeUnit * 2}px;
height: 100%;
width: ${BUILDER_PANE_WIDTH}px;
box-shadow: -${theme.sizeUnit}px 0 ${theme.sizeUnit}px 0
${tinycolor(theme.colorBorder).setAlpha(0.1).toRgbString()};
background-color: ${theme.colorBgBase};
`}
>
<Tabs
data-test="dashboard-builder-component-pane-tabs-navigation"
id="tabs"
css={(theme: SupersetTheme) => css`
line-height: inherit;
margin-top: ${theme.sizeUnit * 2}px;
height: 100%;
& .ant-tabs-body-holder {
& .ant-tabs-body-holder {
height: 100%;
& .ant-tabs-body {
height: 100%;
& .ant-tabs-body {
height: 100%;
}
}
`}
items={[
{
key: TABS_KEYS.CHARTS,
label: t('Charts'),
children: (
<div
css={css`
height: calc(100vh - ${topOffset * 2}px);
`}
>
<SliceAdder />
</div>
),
},
{
key: TABS_KEYS.LAYOUT_ELEMENTS,
label: t('Layout elements'),
children: (
<>
<NewTabs />
<NewRow />
<NewColumn />
<NewHeader />
<NewMarkdown />
<NewDivider />
{/* Extensions-contributed dashboard components */}
{extensionComponents.map(({ definition }) => (
<NewExtensionComponent
key={definition.id}
definition={definition}
}
`}
items={[
{
key: TABS_KEYS.CHARTS,
label: t('Charts'),
children: (
<div
css={css`
height: calc(100vh - ${topOffset * 2}px);
`}
>
<SliceAdder />
</div>
),
},
{
key: TABS_KEYS.LAYOUT_ELEMENTS,
label: t('Layout elements'),
children: (
<>
<NewTabs />
<NewRow />
<NewColumn />
<NewHeader />
<NewMarkdown />
<NewDivider />
{dashboardComponents
.getAll()
.map(({ key: componentKey, metadata }) => (
<NewDynamicComponent
key={componentKey}
metadata={metadata}
componentKey={componentKey}
/>
))}
{/* @deprecated legacy DashboardComponentsRegistry path */}
{dashboardComponents
.getAll()
.map(({ key: componentKey, metadata }) => (
<NewDynamicComponent
key={componentKey}
metadata={metadata}
componentKey={componentKey}
/>
))}
</>
),
},
]}
/>
</div>
</>
),
},
]}
/>
</div>
);
};
</div>
);
export default BuilderComponentPane;
@@ -1,106 +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 { screen, render } from 'spec/helpers/testing-library';
import { dashboardComponents } from 'src/core';
import newComponentFactory from 'src/dashboard/util/newComponentFactory';
import {
EXTENSION_TYPE,
DASHBOARD_GRID_TYPE,
} from 'src/dashboard/util/componentTypes';
import DashboardExtensionComponent, {
DashboardExtensionComponentProps,
} from './DashboardExtensionComponent';
const makeComponent = (extensionComponentId?: string) => {
const component = newComponentFactory(EXTENSION_TYPE);
component.meta.extensionComponentId = extensionComponentId;
return component;
};
const baseProps = (
overrides: Partial<DashboardExtensionComponentProps> = {},
): DashboardExtensionComponentProps => ({
id: 'ext-id',
parentId: 'parentId',
component: makeComponent('acme.demo'),
parentComponent: newComponentFactory(DASHBOARD_GRID_TYPE),
index: 0,
depth: 1,
editMode: false,
availableColumnCount: 12,
columnWidth: 50,
onResizeStart: jest.fn(),
onResize: jest.fn(),
onResizeStop: jest.fn(),
deleteComponent: jest.fn(),
handleComponentDrop: jest.fn(),
updateComponents: jest.fn(),
...overrides,
});
const setup = (props: Partial<DashboardExtensionComponentProps> = {}) =>
render(<DashboardExtensionComponent {...baseProps(props)} />, {
useRedux: true,
useDnd: true,
});
test('renders the registered contributed component', () => {
const disposable = dashboardComponents.registerDashboardComponent(
{ id: 'acme.demo', name: 'Acme Demo' },
() => <div data-test="acme-demo-content">Acme content</div>,
);
setup();
expect(screen.getByTestId('acme-demo-content')).toBeInTheDocument();
disposable.dispose();
});
test('renders a graceful placeholder when the component is not registered', () => {
setup({ component: makeComponent('not.installed') });
expect(
screen.getByTestId('dashboard-component-extension-missing'),
).toBeInTheDocument();
expect(
screen.getByText(/requires the "not.installed" extension/),
).toBeInTheDocument();
});
test('passes editMode and an updateMeta that patches the instance meta', () => {
const updateComponents = jest.fn();
let captured: ((patch: Record<string, unknown>) => void) | undefined;
const disposable = dashboardComponents.registerDashboardComponent(
{ id: 'acme.meta', name: 'Acme Meta' },
({ editMode, updateMeta }) => {
captured = updateMeta;
return <div>{editMode ? 'editing' : 'viewing'}</div>;
},
);
setup({
component: makeComponent('acme.meta'),
editMode: true,
updateComponents,
});
expect(screen.getByText('editing')).toBeInTheDocument();
captured?.({ url: 'https://x.com' });
expect(updateComponents).toHaveBeenCalledTimes(1);
const updated = Object.values(updateComponents.mock.calls[0][0])[0] as {
meta: { url: string };
};
expect(updated.meta.url).toBe('https://x.com');
disposable.dispose();
});
@@ -1,193 +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.
*/
/**
* Host wrapper for Extensions-contributed dashboard components (EXTENSION_TYPE).
*
* This component owns the shared dashboard "chrome" the drag handle, resize
* container, and delete affordance and renders the contributed component
* resolved from the `dashboardComponents` registry, passing it the stable
* `DashboardComponentProps` contract. Contributed components therefore only
* render content; they never re-implement layout chrome.
*
* If the referenced component is not registered (e.g. its extension is disabled
* or not yet loaded), a non-destructive placeholder is rendered and the
* instance's `meta` is preserved on save.
*/
import { useCallback } from 'react';
import type { ResizeStartCallback, ResizeCallback } from 're-resizable';
import { t } from '@apache-superset/core/translation';
import { styled } from '@apache-superset/core/theme';
import { useDashboardComponents } from 'src/core';
import DeleteComponentButton from 'src/dashboard/components/DeleteComponentButton';
import { Draggable } from 'src/dashboard/components/dnd/DragDroppable';
import HoverMenu from 'src/dashboard/components/menu/HoverMenu';
import ResizableContainer from 'src/dashboard/components/resizable/ResizableContainer';
import type { LayoutItem } from 'src/dashboard/types';
import type { DropResult } from 'src/dashboard/components/dnd/dragDroppableConfig';
import { ROW_TYPE } from 'src/dashboard/util/componentTypes';
import {
GRID_MIN_COLUMN_COUNT,
GRID_MIN_ROW_UNITS,
GRID_BASE_UNIT,
} from 'src/dashboard/util/constants';
export interface DashboardExtensionComponentProps {
id: string;
parentId: string;
component: LayoutItem;
parentComponent: LayoutItem;
index: number;
depth: number;
editMode: boolean;
availableColumnCount: number;
columnWidth: number;
onResizeStart: ResizeStartCallback;
onResize: ResizeCallback;
onResizeStop: ResizeCallback;
deleteComponent: (id: string, parentId: string) => void;
handleComponentDrop: (dropResult: DropResult) => void;
updateComponents: (components: Record<string, LayoutItem>) => void;
}
const Placeholder = styled.div`
${({ theme }) => `
width: 100%;
height: 100%;
min-height: ${theme.sizeUnit * 25}px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: ${theme.sizeUnit * 4}px;
color: ${theme.colorTextTertiary};
border: 1px dashed ${theme.colorBorder};
`}
`;
export default function DashboardExtensionComponent(
props: DashboardExtensionComponentProps,
) {
const {
component,
parentComponent,
index,
depth,
editMode,
availableColumnCount,
columnWidth,
onResizeStart,
onResize,
onResizeStop,
deleteComponent,
handleComponentDrop,
updateComponents,
} = props;
// Subscribe to the registry so a component that registers after this renders
// (e.g. a lazily-loaded extension) replaces the placeholder once available.
const registered = useDashboardComponents();
const extensionComponentId = component.meta.extensionComponentId as
| string
| undefined;
const entry = registered.find(r => r.definition.id === extensionComponentId);
const handleDeleteComponent = useCallback(() => {
deleteComponent(component.id, parentComponent.id);
}, [component.id, deleteComponent, parentComponent.id]);
const updateMeta = useCallback(
(patch: Record<string, unknown>) => {
updateComponents({
[component.id]: {
...component,
meta: { ...component.meta, ...patch },
},
});
},
[component, updateComponents],
);
const resizable = entry?.definition.resizable ?? true;
const widthMultiple = component.meta.width ?? GRID_MIN_COLUMN_COUNT;
const ContributedComponent = entry?.Component;
return (
<Draggable
component={component}
parentComponent={parentComponent}
orientation={parentComponent.type === ROW_TYPE ? 'column' : 'row'}
index={index}
depth={depth}
onDrop={handleComponentDrop}
editMode={editMode}
>
{({ dragSourceRef }: { dragSourceRef: React.Ref<HTMLDivElement> }) => (
<ResizableContainer
id={component.id}
adjustableWidth={resizable && parentComponent.type === ROW_TYPE}
adjustableHeight={resizable}
widthStep={columnWidth}
widthMultiple={widthMultiple}
heightStep={GRID_BASE_UNIT}
heightMultiple={component.meta.height ?? GRID_MIN_ROW_UNITS}
minWidthMultiple={GRID_MIN_COLUMN_COUNT}
minHeightMultiple={GRID_MIN_ROW_UNITS}
maxWidthMultiple={availableColumnCount + widthMultiple}
onResizeStart={onResizeStart}
onResize={onResize}
onResizeStop={onResizeStop}
editMode={editMode}
>
<div
ref={dragSourceRef}
className="dashboard-component dashboard-component-extension"
data-test="dashboard-component-extension"
id={component.id}
>
{editMode && (
<HoverMenu position="top">
<DeleteComponentButton onDelete={handleDeleteComponent} />
</HoverMenu>
)}
{ContributedComponent ? (
<ContributedComponent
id={component.id}
meta={component.meta}
editMode={editMode}
updateMeta={updateMeta}
/>
) : (
<Placeholder data-test="dashboard-component-extension-missing">
{t(
'This component requires the "%(id)s" extension, which is not available.',
{ id: extensionComponentId ?? t('unknown') },
)}
</Placeholder>
)}
</div>
</ResizableContainer>
)}
</Draggable>
);
}
@@ -22,7 +22,6 @@ import {
COLUMN_TYPE,
DIVIDER_TYPE,
HEADER_TYPE,
EXTENSION_TYPE,
ROW_TYPE,
TAB_TYPE,
TABS_TYPE,
@@ -34,7 +33,6 @@ import Markdown from './Markdown';
import Column from './Column';
import Divider from './Divider';
import Header from './Header';
import DashboardExtensionComponent from './DashboardExtensionComponent';
import Row from './Row';
import Tab from './Tab';
import Tabs from './Tabs';
@@ -46,7 +44,6 @@ export const componentLookup = {
[COLUMN_TYPE]: Column,
[DIVIDER_TYPE]: Divider,
[HEADER_TYPE]: Header,
[EXTENSION_TYPE]: DashboardExtensionComponent,
[ROW_TYPE]: Row,
[TAB_TYPE]: Tab,
[TABS_TYPE]: Tabs,
@@ -1,71 +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 type { dashboardComponents as dashboardComponentsApi } from '@apache-superset/core';
import { Icons } from '@superset-ui/core/components';
import { EXTENSION_TYPE } from '../../../util/componentTypes';
import { NEW_EXTENSION_ID } from '../../../util/constants';
import DraggableNewComponent from './DraggableNewComponent';
type Definition = dashboardComponentsApi.DashboardComponentDefinition;
/**
* Palette drag source for an Extensions-contributed dashboard component. Drops a
* new EXTENSION_TYPE instance whose `meta.extensionComponentId` selects the
* contributed component, seeded with the definition's `defaultMeta`.
*/
export default function NewExtensionComponent({
definition,
}: {
definition: Definition;
}) {
const IconComponent =
(definition.icon &&
(Icons as Record<string, (typeof Icons)[keyof typeof Icons]>)[
definition.icon
]) ||
Icons.AppstoreOutlined;
// Seed the layout-relevant behavior onto the instance meta so the (pure)
// dashboard util maps can honor it without coupling to the component registry,
// and so it round-trips in the saved layout even if the extension is later
// unavailable. Only defined fields are seeded to keep meta tidy.
const behaviorMeta: Record<string, unknown> = {
extensionComponentId: definition.id,
};
if (definition.resizable !== undefined)
behaviorMeta.resizable = definition.resizable;
if (definition.isUserContent !== undefined)
behaviorMeta.isUserContent = definition.isUserContent;
if (definition.minWidth !== undefined)
behaviorMeta.minWidth = definition.minWidth;
if (definition.validParents !== undefined)
behaviorMeta.validParents = definition.validParents;
if (definition.wrapInRow !== undefined)
behaviorMeta.wrapInRow = definition.wrapInRow;
return (
<DraggableNewComponent
id={`${NEW_EXTENSION_ID}-${definition.id}`}
type={EXTENSION_TYPE}
label={definition.name}
IconComponent={IconComponent}
meta={{ ...behaviorMeta, ...definition.defaultMeta }}
/>
);
}
@@ -1,150 +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 {
screen,
render,
userEvent,
fireEvent,
waitFor,
} from 'spec/helpers/testing-library';
import { isFeatureEnabled } from '@superset-ui/core';
import {
addCspAllowlistEntry,
fetchCspAllowlist,
} from 'src/dashboard/util/cspAllowlist';
import IframeContent from './IframeContent';
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
isFeatureEnabled: jest.fn(() => true),
}));
jest.mock('src/dashboard/util/cspAllowlist', () => ({
...jest.requireActual('src/dashboard/util/cspAllowlist'),
fetchCspAllowlist: jest.fn(),
addCspAllowlistEntry: jest.fn(),
}));
const mockedFetch = fetchCspAllowlist as jest.Mock;
const mockedAdd = addCspAllowlistEntry as jest.Mock;
const mockedFeature = isFeatureEnabled as jest.Mock;
const adminState = {
user: { roles: { Admin: [['can_write', 'CSPAllowlist']] } },
};
const gammaState = {
user: { roles: { Gamma: [['can_read', 'Dashboard']] } },
};
const setup = (
{
url = 'https://example.com',
editMode = false,
updateMeta = jest.fn(),
}: { url?: string; editMode?: boolean; updateMeta?: jest.Mock } = {},
initialState: object = adminState,
) =>
render(
<IframeContent
id="iframe-id"
meta={{ url }}
editMode={editMode}
updateMeta={updateMeta}
/>,
{ useRedux: true, initialState },
);
beforeEach(() => {
mockedFeature.mockReturnValue(true);
mockedFetch.mockResolvedValue(new Set<string>());
mockedAdd.mockResolvedValue(undefined);
});
afterEach(() => jest.clearAllMocks());
test('renders an iframe with the configured URL', () => {
setup();
expect(screen.getByTitle('Embedded content')).toHaveAttribute(
'src',
'https://example.com',
);
});
test('sandboxes the iframe without allow-same-origin', () => {
setup();
const sandbox = screen.getByTitle('Embedded content').getAttribute('sandbox');
expect(sandbox).toContain('allow-scripts');
expect(sandbox).not.toContain('allow-same-origin');
});
test('renders an empty placeholder when no URL is configured', () => {
setup({ url: '' });
expect(screen.queryByTitle('Embedded content')).not.toBeInTheDocument();
expect(screen.getByText('No URL configured')).toBeInTheDocument();
});
test('saves the URL via updateMeta on blur in edit mode', async () => {
const updateMeta = jest.fn();
setup({ editMode: true, updateMeta });
const input = screen.getByTestId('dashboard-iframe-url-input');
await userEvent.clear(input);
await userEvent.type(input, 'https://new.example.com');
fireEvent.blur(input);
await waitFor(() =>
expect(updateMeta).toHaveBeenCalledWith({ url: 'https://new.example.com' }),
);
});
test('flags a non-allowlisted domain and offers Enable for admins', async () => {
mockedFetch.mockResolvedValue(new Set<string>());
setup({ editMode: true });
expect(
await screen.findByText('This domain is not allowed to be embedded'),
).toBeInTheDocument();
await userEvent.click(screen.getByTestId('dashboard-iframe-enable-csp'));
expect(mockedAdd).toHaveBeenCalledWith('https://example.com');
});
test('does not flag an already-allowlisted domain', async () => {
mockedFetch.mockResolvedValue(new Set<string>(['https://example.com']));
setup({ editMode: true });
await waitFor(() => expect(mockedFetch).toHaveBeenCalled());
expect(
screen.queryByText('This domain is not allowed to be embedded'),
).not.toBeInTheDocument();
});
test('hides Enable for users without the CSP permission', async () => {
setup({ editMode: true }, gammaState);
expect(
await screen.findByText('This domain is not allowed to be embedded'),
).toBeInTheDocument();
expect(
screen.queryByTestId('dashboard-iframe-enable-csp'),
).not.toBeInTheDocument();
});
test('never flags domains when the feature flag is disabled', async () => {
mockedFeature.mockReturnValue(false);
setup({ editMode: true });
await waitFor(() => expect(mockedFetch).not.toHaveBeenCalled());
expect(
screen.queryByText('This domain is not allowed to be embedded'),
).not.toBeInTheDocument();
});
@@ -1,215 +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.
*/
/**
* Built-in "iframe" dashboard component, delivered through the
* `dashboardComponents` Extensions contribution point. This renders only the
* element's *content* and editor the host (DashboardExtensionComponent) owns
* the drag/resize/delete chrome.
*
* The component also surfaces the runtime CSP allowlist UX (companion SIP):
* when the embedded origin is not allowed, it flags it and offers permitted
* admins an "Enable domain in CSP" action.
*/
import { useCallback, useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import { t } from '@apache-superset/core/translation';
import { styled } from '@apache-superset/core/theme';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import type { dashboardComponents as dashboardComponentsApi } from '@apache-superset/core';
import { Alert } from '@apache-superset/core/components';
import { Button, Input } from '@superset-ui/core/components';
import { useToasts } from 'src/components/MessageToasts/withToasts';
import { findPermission } from 'src/utils/findPermission';
import type { RootState } from 'src/dashboard/types';
import {
addCspAllowlistEntry,
CSP_ALLOWLIST_PERMISSION,
CSP_ALLOWLIST_VIEW,
fetchCspAllowlist,
getOrigin,
isEmbeddableUrl,
} from 'src/dashboard/util/cspAllowlist';
type DashboardComponentProps = dashboardComponentsApi.DashboardComponentProps;
const IframeStyles = styled.div`
${({ theme }) => `
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
gap: ${theme.sizeUnit * 2}px;
padding: ${theme.sizeUnit * 2}px;
.dashboard-iframe-frame {
flex: 1;
width: 100%;
border: 0;
min-height: ${theme.sizeUnit * 25}px;
}
.dashboard-iframe-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: ${theme.colorTextTertiary};
border: 1px dashed ${theme.colorBorder};
}
`}
`;
export default function IframeContent({
meta,
editMode,
updateMeta,
}: DashboardComponentProps) {
const { addSuccessToast, addDangerToast } = useToasts();
const roles = useSelector((state: RootState) => state.user?.roles);
const cspFeatureEnabled = isFeatureEnabled(FeatureFlag.CspRuntimeAllowlist);
const canManageCsp =
cspFeatureEnabled &&
findPermission(CSP_ALLOWLIST_PERMISSION, CSP_ALLOWLIST_VIEW, roles);
const url = (meta.url as string) ?? '';
const [draftUrl, setDraftUrl] = useState(url);
const [allowlist, setAllowlist] = useState<Set<string> | null>(null);
const [enabling, setEnabling] = useState(false);
const origin = getOrigin(url);
const refreshAllowlist = useCallback(() => {
if (!cspFeatureEnabled) {
return;
}
fetchCspAllowlist()
.then(setAllowlist)
.catch(() => setAllowlist(new Set()));
}, [cspFeatureEnabled]);
useEffect(() => {
refreshAllowlist();
}, [refreshAllowlist]);
useEffect(() => {
setDraftUrl(url);
}, [url]);
const handleSaveUrl = useCallback(() => {
const trimmed = draftUrl.trim();
if (trimmed === url) {
return;
}
updateMeta({ url: trimmed });
}, [draftUrl, updateMeta, url]);
const handleEnableDomain = useCallback(() => {
if (!origin) {
return;
}
setEnabling(true);
addCspAllowlistEntry(origin)
.then(() => {
addSuccessToast(
t('%(origin)s is now allowed to be embedded.', { origin }),
);
refreshAllowlist();
})
.catch(() =>
addDangerToast(t('Failed to allow %(origin)s in the CSP.', { origin })),
)
.finally(() => setEnabling(false));
}, [addDangerToast, addSuccessToast, origin, refreshAllowlist]);
const domainFlagged =
cspFeatureEnabled &&
!!origin &&
allowlist !== null &&
!allowlist.has(origin);
return (
<IframeStyles data-test="dashboard-iframe">
{editMode && (
<Input
aria-label={t('Embed URL')}
data-test="dashboard-iframe-url-input"
placeholder={t('Paste a URL to embed, e.g. https://example.com')}
value={draftUrl}
onChange={e => setDraftUrl(e.target.value)}
onBlur={handleSaveUrl}
onPressEnter={handleSaveUrl}
/>
)}
{domainFlagged && (
<Alert
type="warning"
showIcon
closable={false}
message={t('This domain is not allowed to be embedded')}
description={
canManageCsp
? t(
'%(origin)s is blocked by the Content Security Policy. ' +
'Enable it to allow this content to load.',
{ origin },
)
: t(
'%(origin)s is blocked by the Content Security Policy. ' +
'Ask an administrator to allow this domain.',
{ origin },
)
}
action={
canManageCsp ? (
<Button
buttonStyle="primary"
buttonSize="small"
loading={enabling}
onClick={handleEnableDomain}
data-test="dashboard-iframe-enable-csp"
>
{t('Enable domain in CSP')}
</Button>
) : undefined
}
/>
)}
{isEmbeddableUrl(url) ? (
<iframe
className="dashboard-iframe-frame"
src={url}
title={t('Embedded content')}
// No `allow-same-origin`: combined with `allow-scripts` it would let an
// allowlisted-then-compromised origin script its way out of its own
// sandbox (same-origin DOM/storage access) for third-party embeds.
sandbox="allow-scripts allow-popups allow-forms"
/>
) : (
<div className="dashboard-iframe-empty">
{editMode
? t('Enter a URL above to embed content')
: t('No URL configured')}
</div>
)}
</IframeStyles>
);
}
@@ -210,7 +210,6 @@ const actionHandlers: Record<
const wrapInRow = shouldWrapChildInRow({
parentType: destination.type,
childType: dragging.type,
childMeta: dragging.meta,
});
if (wrapInRow) {
-6
View File
@@ -286,14 +286,8 @@ export type LayoutItemMeta = {
headerSize?: string;
/** Markdown source code for markdown components */
code?: string;
/** Embedded URL for iframe components */
url?: string;
/** Background style value for columns and rows */
background?: string;
/** Extension-contributed components opt out of resizing via this flag */
resizable?: boolean;
/** Extension-contributed components opt out of default row-wrapping via this flag */
wrapInRow?: boolean;
/** Allow additional meta properties used by different component types */
[key: string]: unknown;
};
@@ -24,7 +24,6 @@ import {
DASHBOARD_ROOT_TYPE,
DIVIDER_TYPE,
HEADER_TYPE,
EXTENSION_TYPE,
MARKDOWN_TYPE,
ROW_TYPE,
TABS_TYPE,
@@ -41,7 +40,7 @@ const notResizable = [
TAB_TYPE,
];
const resizable = [COLUMN_TYPE, CHART_TYPE, EXTENSION_TYPE, MARKDOWN_TYPE];
const resizable = [COLUMN_TYPE, CHART_TYPE, MARKDOWN_TYPE];
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('componentIsResizable', () => {
@@ -19,20 +19,11 @@
import {
COLUMN_TYPE,
CHART_TYPE,
EXTENSION_TYPE,
MARKDOWN_TYPE,
DYNAMIC_TYPE,
} from './componentTypes';
export default function componentIsResizable(entity: {
type: string;
meta?: { resizable?: boolean };
}) {
// Extension-contributed components opt out of resizing via their definition,
// seeded onto meta at creation.
if (entity.type === EXTENSION_TYPE) {
return entity.meta?.resizable !== false;
}
export default function componentIsResizable(entity: { type: string }) {
return (
[COLUMN_TYPE, CHART_TYPE, MARKDOWN_TYPE, DYNAMIC_TYPE].indexOf(
entity.type,
@@ -23,17 +23,12 @@ export const DASHBOARD_GRID_TYPE = 'GRID';
export const DASHBOARD_ROOT_TYPE = 'ROOT';
export const DIVIDER_TYPE = 'DIVIDER';
export const HEADER_TYPE = 'HEADER';
// First-class Extensions-contributed dashboard component (see the
// `dashboardComponents` contribution point in @apache-superset/core). The
// concrete component is selected by `meta.extensionComponentId`.
export const EXTENSION_TYPE = 'EXTENSION';
export const MARKDOWN_TYPE = 'MARKDOWN';
export const NEW_COMPONENT_SOURCE_TYPE = 'NEW_COMPONENT_SOURCE';
export const ROW_TYPE = 'ROW';
export const TABS_TYPE = 'TABS';
export const TAB_TYPE = 'TAB';
// @deprecated Legacy lazy-loaded custom component registry (DashboardComponentsRegistry).
// Superseded by EXTENSION_TYPE + the `dashboardComponents` Extensions contribution point.
// Dynamic type proposes lazy loading of custom dashboard components that can be added in separate repository
export const DYNAMIC_TYPE = 'DYNAMIC';
export default {
@@ -44,7 +39,6 @@ export default {
DASHBOARD_ROOT_TYPE,
DIVIDER_TYPE,
HEADER_TYPE,
EXTENSION_TYPE,
MARKDOWN_TYPE,
NEW_COMPONENT_SOURCE_TYPE,
ROW_TYPE,
@@ -27,7 +27,6 @@ export const NEW_CHART_ID = 'NEW_CHART_ID';
export const NEW_COLUMN_ID = 'NEW_COLUMN_ID';
export const NEW_DIVIDER_ID = 'NEW_DIVIDER_ID';
export const NEW_HEADER_ID = 'NEW_HEADER_ID';
export const NEW_EXTENSION_ID = 'NEW_EXTENSION_ID';
export const NEW_MARKDOWN_ID = 'NEW_MARKDOWN_ID';
export const NEW_ROW_ID = 'NEW_ROW_ID';
export const NEW_TAB_ID = 'NEW_TAB_ID';
@@ -1,88 +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 { SupersetClient } from '@superset-ui/core';
import {
addCspAllowlistEntry,
CSP_ALLOWLIST_ENDPOINT,
fetchCspAllowlist,
getOrigin,
isEmbeddableUrl,
} from './cspAllowlist';
test('getOrigin extracts the bare origin from a URL', () => {
expect(getOrigin('https://example.com/path?q=1#frag')).toBe(
'https://example.com',
);
expect(getOrigin('https://example.com:8443/x')).toBe(
'https://example.com:8443',
);
expect(getOrigin('http://localhost:9000')).toBe('http://localhost:9000');
});
test('getOrigin returns null for empty or unparseable URLs', () => {
expect(getOrigin('')).toBeNull();
expect(getOrigin(undefined)).toBeNull();
expect(getOrigin('not a url')).toBeNull();
expect(getOrigin('example.com')).toBeNull();
});
test('isEmbeddableUrl is true only for absolute http(s) URLs', () => {
expect(isEmbeddableUrl('https://example.com')).toBe(true);
expect(isEmbeddableUrl('http://example.com/embed')).toBe(true);
expect(isEmbeddableUrl('ftp://example.com')).toBe(false);
// built via concatenation to avoid a literal javascript: URL in source
expect(isEmbeddableUrl(`${'java'}${'script'}:alert(1)`)).toBe(false);
expect(isEmbeddableUrl('')).toBe(false);
expect(isEmbeddableUrl(undefined)).toBe(false);
});
test('fetchCspAllowlist returns the set of origins for frame-src', async () => {
const getSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: {
result: [
{ domain: 'https://a.com', directive: 'frame-src' },
{ domain: 'https://b.com', directive: 'frame-src' },
{ domain: 'https://c.com', directive: 'img-src' },
],
},
} as any);
const allowlist = await fetchCspAllowlist();
expect(getSpy).toHaveBeenCalledWith({ endpoint: CSP_ALLOWLIST_ENDPOINT });
expect(allowlist.has('https://a.com')).toBe(true);
expect(allowlist.has('https://b.com')).toBe(true);
// img-src entries are excluded from the frame-src set
expect(allowlist.has('https://c.com')).toBe(false);
getSpy.mockRestore();
});
test('addCspAllowlistEntry posts the origin with the frame-src directive', async () => {
const postSpy = jest
.spyOn(SupersetClient, 'post')
.mockResolvedValue({} as any);
await addCspAllowlistEntry('https://example.com');
expect(postSpy).toHaveBeenCalledWith({
endpoint: CSP_ALLOWLIST_ENDPOINT,
jsonPayload: { domain: 'https://example.com', directive: 'frame-src' },
});
postSpy.mockRestore();
});

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