mirror of
https://github.com/apache/superset.git
synced 2026-08-25 17:41:14 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50f4802bbf | ||
|
|
fd07663dc1 | ||
|
|
c3ed8b312d | ||
|
|
9f505eb0cb | ||
|
|
f2610e9dca | ||
|
|
09bd8c98de | ||
|
|
5812c0eab5 | ||
|
|
5f3e34908c | ||
|
|
9781254363 | ||
|
|
65893113fe | ||
|
|
04017f3956 | ||
|
|
2b6044b624 | ||
|
|
aee98b527d | ||
|
|
edda9d1bbb | ||
|
|
03eac279e5 | ||
|
|
fd7095df9a | ||
|
|
d422f5b4b6 | ||
|
|
a05a099987 | ||
|
|
05842a6350 | ||
|
|
e45dd2d3f1 | ||
|
|
65910abf21 | ||
|
|
6e22050b50 | ||
|
|
132340b652 | ||
|
|
14408eb3db | ||
|
|
87743ef3f8 | ||
|
|
fb53761ec8 | ||
|
|
f7d505e1fd | ||
|
|
22396d504a | ||
|
|
27ea5de44a | ||
|
|
18fc2c6228 | ||
|
|
18a36d04c7 | ||
|
|
01b1d58ac9 | ||
|
|
7441ce90ae | ||
|
|
42ba2a4433 | ||
|
|
271564cb0d | ||
|
|
bcfb4346f6 | ||
|
|
b5fe00b407 | ||
|
|
148ffaff50 | ||
|
|
83c6ea4e03 | ||
|
|
1ca5e61f54 | ||
|
|
1ef12580a7 | ||
|
|
c0884c0f0c | ||
|
|
2ff79bd495 | ||
|
|
e7dccd44a7 | ||
|
|
fdd3945dde | ||
|
|
8b67fb4d30 | ||
|
|
b288db91f9 | ||
|
|
80bba12d0f | ||
|
|
5d4358a149 |
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}}"
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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.
|
||||
Vendored
-6
@@ -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
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
Generated
+74
-74
@@ -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": {
|
||||
|
||||
@@ -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';
|
||||
|
||||
+15
-1
@@ -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' : ''}
|
||||
|
||||
+23
@@ -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(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
+11
-1
@@ -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 }>`
|
||||
|
||||
+59
@@ -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('<img');
|
||||
expect(html).toContain('<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('<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('<img');
|
||||
});
|
||||
|
||||
test('escapeHtml stringifies non-string formatter output safely', () => {
|
||||
expect(escapeHtml(1234)).toEqual('1234');
|
||||
expect(escapeHtml('a & b < c')).toEqual('a & b < 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
/**
|
||||
* 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 <img src=x onerror=alert(1)>',
|
||||
);
|
||||
expect(escapeAttribution('a & "b" \'c\'')).toBe(
|
||||
'a & "b" 'c'',
|
||||
);
|
||||
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 <img src=x onerror=alert(1)>',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
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 <img src=x onerror=alert(1)>',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createWfsLayer', () => {
|
||||
|
||||
+12
-7
@@ -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}
|
||||
`;
|
||||
};
|
||||
|
||||
+684
-52
@@ -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 () => {
|
||||
|
||||
+168
-37
@@ -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,
|
||||
|
||||
+70
@@ -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 } });
|
||||
});
|
||||
|
||||
+104
@@ -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('<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('<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('" onmouseover="alert(1)');
|
||||
});
|
||||
});
|
||||
|
||||
+422
-4
@@ -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<');
|
||||
});
|
||||
});
|
||||
|
||||
+73
-5
@@ -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('<img src=x onerror=alert(1)>');
|
||||
expect(popup).toContain('<b>United States</b>');
|
||||
});
|
||||
});
|
||||
|
||||
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 = {
|
||||
|
||||
+25
-1
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+4
-4
@@ -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()}
|
||||
|
||||
-1
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
-106
@@ -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();
|
||||
});
|
||||
-193
@@ -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,
|
||||
|
||||
-71
@@ -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) {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user