mirror of
https://github.com/apache/superset.git
synced 2026-08-22 08:01:16 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95b0ba8043 | ||
|
|
83990e0e64 | ||
|
|
2f15c32572 | ||
|
|
377116e5ab | ||
|
|
ad3c8b6dac | ||
|
|
f9cedf84e2 | ||
|
|
c2d653b4b8 | ||
|
|
5a96c3f538 | ||
|
|
faf7c34c0a | ||
|
|
b8fca2145d | ||
|
|
c10054f521 | ||
|
|
8c500ccee1 | ||
|
|
6d77efad29 | ||
|
|
8222db3340 | ||
|
|
01ce8358a6 | ||
|
|
53a8a0e140 | ||
|
|
eafbff9f8d | ||
|
|
1339bcd9da | ||
|
|
334e280489 | ||
|
|
c07f3ebf2d | ||
|
|
1569915096 | ||
|
|
fde0ba26d1 |
@@ -48,7 +48,7 @@ jobs:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
enable-cache: true
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
<!--
|
||||
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)
|
||||
@@ -0,0 +1,232 @@
|
||||
<!--
|
||||
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
|
||||
@@ -129,6 +129,27 @@ 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.
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
---
|
||||
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,6 +49,7 @@ module.exports = {
|
||||
'extensions/extension-points/sqllab',
|
||||
'extensions/extension-points/editors',
|
||||
'extensions/extension-points/chat',
|
||||
'extensions/extension-points/dashboard-components',
|
||||
],
|
||||
},
|
||||
'extensions/development',
|
||||
|
||||
+4
-4
@@ -64,8 +64,8 @@
|
||||
"antd": "^6.6.0",
|
||||
"baseline-browser-mapping": "^2.11.13",
|
||||
"caniuse-lite": "^1.0.30001809",
|
||||
"docusaurus-plugin-openapi-docs": "^5.1.3",
|
||||
"docusaurus-theme-openapi-docs": "^5.1.3",
|
||||
"docusaurus-plugin-openapi-docs": "^5.2.0",
|
||||
"docusaurus-theme-openapi-docs": "^5.2.0",
|
||||
"js-yaml": "^5.2.3",
|
||||
"json-bigint": "^1.0.0",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
@@ -78,7 +78,7 @@
|
||||
"remark-import-partial": "^0.0.2",
|
||||
"reselect": "^5.2.0",
|
||||
"storybook": "^10.5.7",
|
||||
"swagger-ui-react": "^5.32.12",
|
||||
"swagger-ui-react": "^5.32.13",
|
||||
"swc-loader": "^0.2.7",
|
||||
"tinycolor2": "^1.4.2",
|
||||
"unist-util-visit": "^5.1.0"
|
||||
@@ -93,7 +93,7 @@
|
||||
"@typescript-eslint/parser": "^8.67.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"globals": "^17.9.0",
|
||||
"globals": "^17.10.0",
|
||||
"oxfmt": "^0.63.0",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.67.0",
|
||||
|
||||
Vendored
+6
@@ -21,6 +21,12 @@
|
||||
"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,
|
||||
|
||||
+23
-23
@@ -8014,10 +8014,10 @@ doctrine@^2.1.0:
|
||||
dependencies:
|
||||
esutils "^2.0.2"
|
||||
|
||||
docusaurus-plugin-openapi-docs@^5.1.3:
|
||||
version "5.1.3"
|
||||
resolved "https://registry.yarnpkg.com/docusaurus-plugin-openapi-docs/-/docusaurus-plugin-openapi-docs-5.1.3.tgz#b8cd5f8451aaf881deb1a744a8295685f1681865"
|
||||
integrity sha512-HnpblSBdXoR39VNTIW9zWERUsMJxXOpvdQoBKyaTkUBPwCM48Z76+ndo2yO2vADq+EhWjJlfxL1DUzCrgNjThQ==
|
||||
docusaurus-plugin-openapi-docs@^5.2.0:
|
||||
version "5.2.0"
|
||||
resolved "https://registry.yarnpkg.com/docusaurus-plugin-openapi-docs/-/docusaurus-plugin-openapi-docs-5.2.0.tgz#8318ec90cd21fed023be57696211af7d72fd81db"
|
||||
integrity sha512-MjrfRAMB64uvdxRVz6L9AXWe4QFjCdoBAzYs306yyI3nnXHsFj2lv2FnLA90JV9CAUZaGiYMvvkzBo2Nrkq/9w==
|
||||
dependencies:
|
||||
"@apidevtools/json-schema-ref-parser" "^15.3.3"
|
||||
"@redocly/openapi-core" "^2.25.2"
|
||||
@@ -8035,10 +8035,10 @@ docusaurus-plugin-openapi-docs@^5.1.3:
|
||||
swagger2openapi "^7.0.8"
|
||||
xml-formatter "^3.6.6"
|
||||
|
||||
docusaurus-theme-openapi-docs@^5.1.3:
|
||||
version "5.1.3"
|
||||
resolved "https://registry.yarnpkg.com/docusaurus-theme-openapi-docs/-/docusaurus-theme-openapi-docs-5.1.3.tgz#e23644a63785352abbc76e42760c0dfdff3669e1"
|
||||
integrity sha512-npbD1QahtjAEmrOet/86i5fTmcJX4/rPhVT+c0qKjm7StUNbyqjwchSVBQuU1rB69T51JOA9TpT/y6QcB9Xjvw==
|
||||
docusaurus-theme-openapi-docs@^5.2.0:
|
||||
version "5.2.0"
|
||||
resolved "https://registry.yarnpkg.com/docusaurus-theme-openapi-docs/-/docusaurus-theme-openapi-docs-5.2.0.tgz#6d93a74e2e3cf0ae77d24e1c4144bd2e74a52115"
|
||||
integrity sha512-L0b80LzaMUfr76a9EQXRPCf8nxkEz8Xo6Aknnke1UeE2oXsgoiVki6U+RTE7GmJRjO8zSNKXyckGmGmqqWuHeA==
|
||||
dependencies:
|
||||
"@hookform/error-message" "^2.0.1"
|
||||
"@reduxjs/toolkit" "^2.8.2"
|
||||
@@ -8123,7 +8123,7 @@ domhandler@^5.0.2, domhandler@^5.0.3:
|
||||
dependencies:
|
||||
domelementtype "^2.3.0"
|
||||
|
||||
dompurify@^3.3.3, dompurify@^3.4.12:
|
||||
dompurify@^3.3.3, dompurify@^3.4.13:
|
||||
version "3.4.13"
|
||||
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.4.13.tgz#fc28949d59f92d62e28a3a764bcbeee35897a1be"
|
||||
integrity sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==
|
||||
@@ -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.9.0:
|
||||
version "17.9.0"
|
||||
resolved "https://registry.yarnpkg.com/globals/-/globals-17.9.0.tgz#e43f252d6bbe71508da43902a1709c8895a59f70"
|
||||
integrity sha512-m/MvAW61QVU5VDNF1Vj8axt016h8w7L5TU1e9zlab7XIttAT2YAlCwl75K1fOqvMM9apmD7lbCIRhpfkhmxhCg==
|
||||
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==
|
||||
|
||||
globalthis@^1.0.4:
|
||||
version "1.0.4"
|
||||
@@ -10284,10 +10284,10 @@ js-levenshtein@^1.1.6:
|
||||
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
|
||||
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
|
||||
|
||||
js-yaml@4.1.0, js-yaml@=4.3.0, js-yaml@^4.1.0, js-yaml@^4.1.1, js-yaml@^4.2.0, js-yaml@^4.3.0:
|
||||
version "4.3.0"
|
||||
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.3.0.tgz#d1900572a7f7cf0b5f540c83673e60bad3436592"
|
||||
integrity sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==
|
||||
js-yaml@4.1.0, js-yaml@=4.3.1, js-yaml@^4.1.0, js-yaml@^4.1.1, js-yaml@^4.2.0, js-yaml@^4.3.0:
|
||||
version "4.3.1"
|
||||
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.3.1.tgz#01216c001d67f48e2cd560d708c7af21090a3848"
|
||||
integrity sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==
|
||||
dependencies:
|
||||
argparse "^2.0.1"
|
||||
|
||||
@@ -15103,10 +15103,10 @@ swagger-client@^3.37.8:
|
||||
"@swagger-api/apidom-parser-adapter-openapi-yaml-3-2" "^1.12.0"
|
||||
"@swagger-api/apidom-parser-adapter-yaml-1-2" "^1.12.0"
|
||||
|
||||
swagger-ui-react@^5.32.12:
|
||||
version "5.32.12"
|
||||
resolved "https://registry.yarnpkg.com/swagger-ui-react/-/swagger-ui-react-5.32.12.tgz#47525a26774eb02db0e6203af72f5b32fa6205cc"
|
||||
integrity sha512-WCdkNOQyMTZDu+z356FpwVWHf1dwZgQPUjdQPh1L4r7jULaJTKKlIItXq6WsZdYeXvsHndMdxxccEQXOAroUHQ==
|
||||
swagger-ui-react@^5.32.13:
|
||||
version "5.32.13"
|
||||
resolved "https://registry.yarnpkg.com/swagger-ui-react/-/swagger-ui-react-5.32.13.tgz#04c96140b0a2d4ea01ebec4d4cfc655d5ed9a500"
|
||||
integrity sha512-XIDl+Ny6kE1N8wpSPiOFrjPfAevs4GR4XmV6BT6NLMikkMFIbIVocWbA8pnKYyYXQe8Rccfli5o2zDfySw0FnQ==
|
||||
dependencies:
|
||||
"@babel/runtime-corejs3" "^7.27.1"
|
||||
"@scarf/scarf" "=1.4.0"
|
||||
@@ -15115,11 +15115,11 @@ swagger-ui-react@^5.32.12:
|
||||
classnames "^2.5.1"
|
||||
css.escape "1.5.1"
|
||||
deep-extend "0.6.0"
|
||||
dompurify "^3.4.12"
|
||||
dompurify "^3.4.13"
|
||||
ieee754 "^1.2.1"
|
||||
immutable "^4.3.9"
|
||||
js-file-download "^0.4.12"
|
||||
js-yaml "=4.3.0"
|
||||
js-yaml "=4.3.1"
|
||||
lodash "^4.18.1"
|
||||
prop-types "^15.8.1"
|
||||
randexp "^0.5.3"
|
||||
|
||||
Generated
+18
-18
@@ -186,7 +186,7 @@
|
||||
"@storybook/test-runner": "0.24.4",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.47",
|
||||
"@swc/plugin-emotion": "^14.15.0",
|
||||
"@swc/plugin-emotion": "^14.19.0",
|
||||
"@swc/plugin-transform-imports": "^12.5.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
@@ -11808,9 +11808,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/plugin-emotion": {
|
||||
"version": "14.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@swc/plugin-emotion/-/plugin-emotion-14.15.0.tgz",
|
||||
"integrity": "sha512-nCsTO7mOOPz2UnT3N6YWb014uI0CVxeKg53A/KM/CvuSIE6H3KPkhaziJQ3q2jI3u3LfFuDKEnU5ZmB1330Dqg==",
|
||||
"version": "14.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@swc/plugin-emotion/-/plugin-emotion-14.19.0.tgz",
|
||||
"integrity": "sha512-0/q84ro0a7kdjpYpn9Wmi5/RLHYuSwYjO638lE5ZBQfIvYpSLJxbEgLsObCmdH4KPe2stoN8plVKUpCsKPggaw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -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": "⚠️ 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": "\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.",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -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": "⚠️ 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": "\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.",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -43073,6 +43073,15 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"packages/superset-ui-core/node_modules/dompurify": {
|
||||
"version": "3.4.13",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
|
||||
"integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"packages/superset-ui-core/node_modules/react-ace": {
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-ace/-/react-ace-14.0.1.tgz",
|
||||
@@ -43420,22 +43429,13 @@
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"d3": "^3.5.17",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^19.2.7"
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*"
|
||||
}
|
||||
},
|
||||
"plugins/plugin-chart-chord/node_modules/react": {
|
||||
"version": "19.2.8",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
||||
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
"@superset-ui/core": "*",
|
||||
"react": "^18.3.0"
|
||||
}
|
||||
},
|
||||
"plugins/plugin-chart-country-map": {
|
||||
|
||||
@@ -263,7 +263,7 @@
|
||||
"@storybook/test-runner": "0.24.4",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.47",
|
||||
"@swc/plugin-emotion": "^14.15.0",
|
||||
"@swc/plugin-emotion": "^14.19.0",
|
||||
"@swc/plugin-transform-imports": "^12.5.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
|
||||
@@ -35,6 +35,10 @@
|
||||
"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"
|
||||
|
||||
@@ -130,6 +130,7 @@ export enum GenericDataType {
|
||||
String = 1,
|
||||
Temporal = 2,
|
||||
Boolean = 3,
|
||||
MultiValue = 4,
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
|
||||
import { Chat } from '../chat';
|
||||
import { Command } from '../commands';
|
||||
import { DashboardComponentDefinition } from '../dashboardComponents';
|
||||
import { View } from '../views';
|
||||
import { Menu } from '../menus';
|
||||
import { Editor } from '../editors';
|
||||
@@ -90,4 +91,9 @@ 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[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* 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,6 +20,7 @@ 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';
|
||||
|
||||
+5
@@ -28,6 +28,7 @@ import {
|
||||
FieldBinaryOutlined,
|
||||
FieldStringOutlined,
|
||||
NumberOutlined,
|
||||
UnorderedListOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Icons } from '@superset-ui/core/components';
|
||||
|
||||
@@ -72,6 +73,10 @@ export function ColumnTypeLabel({ type }: ColumnTypeLabelProps) {
|
||||
typeIcon = <FieldBinaryOutlined aria-label={t('boolean type icon')} />;
|
||||
} else if (type === GenericDataType.Temporal) {
|
||||
typeIcon = <ClockCircleOutlined aria-label={t('temporal type icon')} />;
|
||||
} else if (type === GenericDataType.MultiValue) {
|
||||
typeIcon = (
|
||||
<UnorderedListOutlined aria-label={t('multi-value type icon')} />
|
||||
);
|
||||
}
|
||||
|
||||
return <TypeIconWrapper>{typeIcon}</TypeIconWrapper>;
|
||||
|
||||
+17
@@ -64,4 +64,21 @@ describe('ColumnOption', () => {
|
||||
renderColumnTypeLabel({ type: GenericDataType.Temporal });
|
||||
expect(screen.getByLabelText('temporal type icon')).toBeVisible();
|
||||
});
|
||||
test('multi-value (array) type shows list icon', () => {
|
||||
renderColumnTypeLabel({ type: GenericDataType.MultiValue });
|
||||
expect(screen.getByLabelText('multi-value type icon')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GenericDataType enum parity', () => {
|
||||
// These numeric values are shared with the backend enum in
|
||||
// superset/utils/core.py (GenericDataType). They must stay in sync because
|
||||
// the backend serializes columns using these integers.
|
||||
test('values match the backend contract', () => {
|
||||
expect(GenericDataType.Numeric).toBe(0);
|
||||
expect(GenericDataType.String).toBe(1);
|
||||
expect(GenericDataType.Temporal).toBe(2);
|
||||
expect(GenericDataType.Boolean).toBe(3);
|
||||
expect(GenericDataType.MultiValue).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ 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',
|
||||
|
||||
@@ -47,6 +47,10 @@ export default defineConfig({
|
||||
// Retry logic - 2 retries in CI, 0 locally
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
|
||||
// Disable capturing Git commit info as the project's history is increasingly dense
|
||||
// and breach Playwright's default 3-seconds `git` command timeout limit
|
||||
captureGitInfo: { commit: false, diff: false },
|
||||
|
||||
// Reporter configuration - multiple reporters for better visibility
|
||||
reporter: process.env.CI
|
||||
? [
|
||||
|
||||
@@ -30,12 +30,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"d3": "^3.5.17",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^19.2.7"
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*"
|
||||
"@superset-ui/core": "*",
|
||||
"react": "^18.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { getNumberFormatter } from '@superset-ui/core';
|
||||
import { render, fireEvent } from '../../../../spec/helpers/testing-library';
|
||||
import BigNumberVis from './BigNumberViz';
|
||||
|
||||
/**
|
||||
* Tests for the color threshold formatter logic in BigNumberViz.
|
||||
*
|
||||
@@ -83,3 +87,33 @@ describe('BigNumberViz color formatters', () => {
|
||||
expect(getColorFromValue).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('BigNumberViz context menu', () => {
|
||||
test('invokes onContextMenu and stops the event bubbling to ancestor handlers', () => {
|
||||
const onContextMenu = jest.fn();
|
||||
const ancestorHandler = jest.fn();
|
||||
|
||||
const { container } = render(
|
||||
<div onContextMenu={ancestorHandler}>
|
||||
<BigNumberVis
|
||||
width={200}
|
||||
height={100}
|
||||
bigNumber={42}
|
||||
headerFormatter={getNumberFormatter()}
|
||||
headerFontSize={0.3}
|
||||
subheaderFontSize={0.125}
|
||||
subtitleFontSize={0.125}
|
||||
subtitle=""
|
||||
refs={{}}
|
||||
onContextMenu={onContextMenu}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
|
||||
const headerLine = container.querySelector('.header-line');
|
||||
fireEvent.contextMenu(headerLine!, { clientX: 10, clientY: 20 });
|
||||
|
||||
expect(onContextMenu).toHaveBeenCalledWith(10, 20);
|
||||
expect(ancestorHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -224,6 +224,7 @@ function BigNumberVis({
|
||||
const handleContextMenu = (e: MouseEvent<HTMLDivElement>) => {
|
||||
if (onContextMenu) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onContextMenu(e.nativeEvent.clientX, e.nativeEvent.clientY);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -390,6 +390,7 @@ export default function transformProps(chartProps: EchartsGanttChartProps) {
|
||||
[GenericDataType.String]: undefined,
|
||||
[GenericDataType.Temporal]: tooltipTimeFormatter,
|
||||
[GenericDataType.Boolean]: undefined,
|
||||
[GenericDataType.MultiValue]: undefined,
|
||||
};
|
||||
|
||||
const echartOptions: EChartsCoreOption = {
|
||||
|
||||
+1
-3
@@ -1627,9 +1627,7 @@ function DatasourceEditor({
|
||||
{t(
|
||||
'Default URL to redirect to when accessing from the dataset list page. Accepts relative URLs such as',
|
||||
)}{' '}
|
||||
<Typography.Text code>
|
||||
/superset/dashboard/{'{id}'}/
|
||||
</Typography.Text>
|
||||
<Typography.Text code>/dashboard/{'{id}'}/</Typography.Text>
|
||||
</>
|
||||
}
|
||||
control={<TextControl controlId="default_endpoint" />}
|
||||
|
||||
+11
@@ -71,6 +71,17 @@ test('renders Tabs', async () => {
|
||||
expect(screen.getByTestId('edit-dataset-tabs')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('recommends a registered client route for the default URL', async () => {
|
||||
await asyncRender(createProps());
|
||||
|
||||
userEvent.click(screen.getByRole('tab', { name: 'Settings' }));
|
||||
|
||||
expect(await screen.findByText('/dashboard/{id}/')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('/superset/dashboard/{id}/'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('can sync columns from source', async () => {
|
||||
const testProps = createProps();
|
||||
await asyncRender({
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 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,6 +29,7 @@ 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,6 +21,7 @@ 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';
|
||||
@@ -29,6 +30,7 @@ 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;
|
||||
@@ -38,83 +40,94 @@ const TABS_KEYS = {
|
||||
LAYOUT_ELEMENTS: 'LAYOUT_ELEMENTS',
|
||||
};
|
||||
|
||||
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;
|
||||
`}
|
||||
>
|
||||
const BuilderComponentPane = ({ topOffset = 0 }) => {
|
||||
const extensionComponents = useDashboardComponents();
|
||||
return (
|
||||
<div
|
||||
css={(theme: SupersetTheme) => css`
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
data-test="dashboard-builder-sidepane"
|
||||
css={css`
|
||||
position: sticky;
|
||||
right: 0;
|
||||
top: ${topOffset}px;
|
||||
height: calc(100vh - ${topOffset}px);
|
||||
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"
|
||||
<div
|
||||
css={(theme: SupersetTheme) => css`
|
||||
line-height: inherit;
|
||||
margin-top: ${theme.sizeUnit * 2}px;
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
|
||||
& .ant-tabs-body-holder {
|
||||
height: 100%;
|
||||
& .ant-tabs-body {
|
||||
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};
|
||||
`}
|
||||
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}
|
||||
>
|
||||
<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 {
|
||||
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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{/* @deprecated legacy DashboardComponentsRegistry path */}
|
||||
{dashboardComponents
|
||||
.getAll()
|
||||
.map(({ key: componentKey, metadata }) => (
|
||||
<NewDynamicComponent
|
||||
key={componentKey}
|
||||
metadata={metadata}
|
||||
componentKey={componentKey}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
export default BuilderComponentPane;
|
||||
|
||||
@@ -468,7 +468,7 @@ function SliceAdder({
|
||||
<AutoSizer>
|
||||
{({ height, width }: { height: number; width: number }) => (
|
||||
<List
|
||||
style={{ width, height }}
|
||||
style={{ width, height, maxHeight: height }}
|
||||
rowCount={filteredSlices.length}
|
||||
rowHeight={DEFAULT_CELL_HEIGHT}
|
||||
rowProps={listRowProps}
|
||||
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 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
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* 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,6 +22,7 @@ import {
|
||||
COLUMN_TYPE,
|
||||
DIVIDER_TYPE,
|
||||
HEADER_TYPE,
|
||||
EXTENSION_TYPE,
|
||||
ROW_TYPE,
|
||||
TAB_TYPE,
|
||||
TABS_TYPE,
|
||||
@@ -33,6 +34,7 @@ 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';
|
||||
@@ -44,6 +46,7 @@ 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
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import 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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { dashboardComponents } from 'src/core';
|
||||
import IframeContent from './IframeContent';
|
||||
|
||||
export const IFRAME_COMPONENT_ID = 'superset.iframe';
|
||||
|
||||
/**
|
||||
* Registers the built-in iframe as a first-class dashboard component through the
|
||||
* Extensions `dashboardComponents` contribution point. Core registers it the
|
||||
* same way a third-party extension would, demonstrating the contract end to end.
|
||||
*/
|
||||
export default function registerIframeComponent() {
|
||||
dashboardComponents.registerDashboardComponent(
|
||||
{
|
||||
id: IFRAME_COMPONENT_ID,
|
||||
name: t('Embed / Iframe'),
|
||||
description: t('Embed external content via a URL'),
|
||||
icon: 'LinkOutlined',
|
||||
resizable: true,
|
||||
defaultMeta: { width: 4, height: 50, url: '' },
|
||||
},
|
||||
IframeContent,
|
||||
);
|
||||
}
|
||||
@@ -210,6 +210,7 @@ const actionHandlers: Record<
|
||||
const wrapInRow = shouldWrapChildInRow({
|
||||
parentType: destination.type,
|
||||
childType: dragging.type,
|
||||
childMeta: dragging.meta,
|
||||
});
|
||||
|
||||
if (wrapInRow) {
|
||||
|
||||
@@ -286,8 +286,14 @@ 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,6 +24,7 @@ import {
|
||||
DASHBOARD_ROOT_TYPE,
|
||||
DIVIDER_TYPE,
|
||||
HEADER_TYPE,
|
||||
EXTENSION_TYPE,
|
||||
MARKDOWN_TYPE,
|
||||
ROW_TYPE,
|
||||
TABS_TYPE,
|
||||
@@ -40,7 +41,7 @@ const notResizable = [
|
||||
TAB_TYPE,
|
||||
];
|
||||
|
||||
const resizable = [COLUMN_TYPE, CHART_TYPE, MARKDOWN_TYPE];
|
||||
const resizable = [COLUMN_TYPE, CHART_TYPE, EXTENSION_TYPE, MARKDOWN_TYPE];
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('componentIsResizable', () => {
|
||||
|
||||
@@ -19,11 +19,20 @@
|
||||
import {
|
||||
COLUMN_TYPE,
|
||||
CHART_TYPE,
|
||||
EXTENSION_TYPE,
|
||||
MARKDOWN_TYPE,
|
||||
DYNAMIC_TYPE,
|
||||
} from './componentTypes';
|
||||
|
||||
export default function componentIsResizable(entity: { type: string }) {
|
||||
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;
|
||||
}
|
||||
return (
|
||||
[COLUMN_TYPE, CHART_TYPE, MARKDOWN_TYPE, DYNAMIC_TYPE].indexOf(
|
||||
entity.type,
|
||||
|
||||
@@ -23,12 +23,17 @@ 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';
|
||||
// Dynamic type proposes lazy loading of custom dashboard components that can be added in separate repository
|
||||
// @deprecated Legacy lazy-loaded custom component registry (DashboardComponentsRegistry).
|
||||
// Superseded by EXTENSION_TYPE + the `dashboardComponents` Extensions contribution point.
|
||||
export const DYNAMIC_TYPE = 'DYNAMIC';
|
||||
|
||||
export default {
|
||||
@@ -39,6 +44,7 @@ export default {
|
||||
DASHBOARD_ROOT_TYPE,
|
||||
DIVIDER_TYPE,
|
||||
HEADER_TYPE,
|
||||
EXTENSION_TYPE,
|
||||
MARKDOWN_TYPE,
|
||||
NEW_COMPONENT_SOURCE_TYPE,
|
||||
ROW_TYPE,
|
||||
|
||||
@@ -27,6 +27,7 @@ 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';
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
export const CSP_ALLOWLIST_ENDPOINT = '/api/v1/csp_allowlist/';
|
||||
export const DEFAULT_EMBED_DIRECTIVE = 'frame-src';
|
||||
|
||||
/** FAB requires `can write on CSPAllowlist`; Admins hold it by default. */
|
||||
export const CSP_ALLOWLIST_PERMISSION = 'can_write';
|
||||
export const CSP_ALLOWLIST_VIEW = 'CSPAllowlist';
|
||||
|
||||
interface CSPAllowlistResult {
|
||||
result?: { domain: string; directive: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the bare origin (scheme://host[:port]) of a URL, or null if the URL is
|
||||
* empty or cannot be parsed. Mirrors the server-side `is_valid_csp_origin`
|
||||
* canonicalization so the UX check matches what the backend will accept.
|
||||
*/
|
||||
export function getOrigin(url?: string | null): string | null {
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const { origin } = new URL(url);
|
||||
// URL parses things like "mailto:" to an opaque origin of "null"
|
||||
return origin && origin !== 'null' ? origin : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** True only for absolute http(s) URLs that resolve to a concrete origin. */
|
||||
export function isEmbeddableUrl(url?: string | null): boolean {
|
||||
const origin = getOrigin(url);
|
||||
return !!origin && /^https?:\/\//.test(origin);
|
||||
}
|
||||
|
||||
/** Fetch the set of origins currently allowed for the given CSP directive. */
|
||||
export async function fetchCspAllowlist(
|
||||
directive: string = DEFAULT_EMBED_DIRECTIVE,
|
||||
): Promise<Set<string>> {
|
||||
const response = await SupersetClient.get({
|
||||
endpoint: CSP_ALLOWLIST_ENDPOINT,
|
||||
});
|
||||
const json = response.json as CSPAllowlistResult;
|
||||
const origins = (json.result ?? [])
|
||||
.filter(entry => entry.directive === directive)
|
||||
.map(entry => entry.domain);
|
||||
return new Set(origins);
|
||||
}
|
||||
|
||||
/** Add a new allowlist entry (punch a hole in the CSP) for the given origin. */
|
||||
export async function addCspAllowlistEntry(
|
||||
domain: string,
|
||||
directive: string = DEFAULT_EMBED_DIRECTIVE,
|
||||
): Promise<void> {
|
||||
await SupersetClient.post({
|
||||
endpoint: CSP_ALLOWLIST_ENDPOINT,
|
||||
jsonPayload: { domain, directive },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* 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 componentIsResizable from './componentIsResizable';
|
||||
import isValidChild from './isValidChild';
|
||||
import shouldWrapChildInRow from './shouldWrapChildInRow';
|
||||
import isDashboardEmpty from './isDashboardEmpty';
|
||||
import getDetailedComponentWidth from './getDetailedComponentWidth';
|
||||
import {
|
||||
EXTENSION_TYPE,
|
||||
DASHBOARD_GRID_TYPE,
|
||||
TAB_TYPE,
|
||||
COLUMN_TYPE,
|
||||
} from './componentTypes';
|
||||
|
||||
test('componentIsResizable honors meta.resizable for extension components', () => {
|
||||
expect(componentIsResizable({ type: EXTENSION_TYPE })).toBe(true);
|
||||
expect(
|
||||
componentIsResizable({ type: EXTENSION_TYPE, meta: { resizable: true } }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
componentIsResizable({ type: EXTENSION_TYPE, meta: { resizable: false } }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('isValidChild restricts extension parents via meta.validParents', () => {
|
||||
// Allowed in GRID when GRID is listed
|
||||
expect(
|
||||
isValidChild({
|
||||
parentType: DASHBOARD_GRID_TYPE,
|
||||
childType: EXTENSION_TYPE,
|
||||
parentDepth: 1,
|
||||
childMeta: { validParents: [DASHBOARD_GRID_TYPE] },
|
||||
}),
|
||||
).toBe(true);
|
||||
// Forbidden in COLUMN when only GRID is listed
|
||||
expect(
|
||||
isValidChild({
|
||||
parentType: COLUMN_TYPE,
|
||||
childType: EXTENSION_TYPE,
|
||||
parentDepth: 4,
|
||||
childMeta: { validParents: [DASHBOARD_GRID_TYPE] },
|
||||
}),
|
||||
).toBe(false);
|
||||
// No restriction → standard leaf behavior (allowed in GRID at depth 1)
|
||||
expect(
|
||||
isValidChild({
|
||||
parentType: DASHBOARD_GRID_TYPE,
|
||||
childType: EXTENSION_TYPE,
|
||||
parentDepth: 1,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('shouldWrapChildInRow honors meta.wrapInRow for extension components', () => {
|
||||
expect(
|
||||
shouldWrapChildInRow({
|
||||
parentType: DASHBOARD_GRID_TYPE,
|
||||
childType: EXTENSION_TYPE,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldWrapChildInRow({
|
||||
parentType: DASHBOARD_GRID_TYPE,
|
||||
childType: EXTENSION_TYPE,
|
||||
childMeta: { wrapInRow: false },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('isDashboardEmpty respects meta.isUserContent for extension components', () => {
|
||||
const userContent = {
|
||||
a: { type: EXTENSION_TYPE, meta: { isUserContent: true } },
|
||||
};
|
||||
const nonUserContent = {
|
||||
a: { type: EXTENSION_TYPE, meta: { isUserContent: false } },
|
||||
};
|
||||
expect(isDashboardEmpty(userContent)).toBe(false);
|
||||
// An extension that opts out of being user content leaves the dashboard empty
|
||||
expect(isDashboardEmpty(nonUserContent)).toBe(true);
|
||||
// Default (no flag) counts as user content
|
||||
expect(isDashboardEmpty({ a: { type: EXTENSION_TYPE, meta: {} } })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('getDetailedComponentWidth honors meta.minWidth for extension components', () => {
|
||||
const withMin = getDetailedComponentWidth({
|
||||
component: { type: EXTENSION_TYPE, meta: { minWidth: 3 } } as any,
|
||||
});
|
||||
expect(withMin.minimumWidth).toBe(3);
|
||||
const withoutMin = getDetailedComponentWidth({
|
||||
component: { type: EXTENSION_TYPE, meta: {} } as any,
|
||||
});
|
||||
expect(withoutMin.minimumWidth).toBe(1);
|
||||
});
|
||||
|
||||
test('extension nesting still respects container depth limits', () => {
|
||||
// Even when validParents allows TAB, the depth gate still applies.
|
||||
expect(
|
||||
isValidChild({
|
||||
parentType: TAB_TYPE,
|
||||
childType: EXTENSION_TYPE,
|
||||
parentDepth: 999,
|
||||
childMeta: { validParents: [TAB_TYPE] },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import { GRID_MIN_COLUMN_COUNT, GRID_COLUMN_COUNT } from './constants';
|
||||
import {
|
||||
ROW_TYPE,
|
||||
COLUMN_TYPE,
|
||||
EXTENSION_TYPE,
|
||||
MARKDOWN_TYPE,
|
||||
CHART_TYPE,
|
||||
DYNAMIC_TYPE,
|
||||
@@ -105,6 +106,11 @@ export default function getDetailedComponentWidth({
|
||||
);
|
||||
}
|
||||
});
|
||||
} else if (component.type === EXTENSION_TYPE) {
|
||||
// Extension-contributed components may declare a minimum width (grid
|
||||
// columns) in their definition, seeded onto meta at creation.
|
||||
const minWidth = component.meta?.minWidth as number | undefined;
|
||||
result.minimumWidth = minWidth ?? GRID_MIN_COLUMN_COUNT;
|
||||
} else if (
|
||||
component.type === DYNAMIC_TYPE ||
|
||||
component.type === MARKDOWN_TYPE ||
|
||||
|
||||
@@ -81,6 +81,7 @@ export default function getDropPosition(
|
||||
const draggingItem = monitor.getItem() as {
|
||||
id: string;
|
||||
type: string;
|
||||
meta?: { validParents?: string[] };
|
||||
} | null;
|
||||
|
||||
// if dropped self on self, do nothing
|
||||
@@ -92,6 +93,7 @@ export default function getDropPosition(
|
||||
parentType: component.type,
|
||||
parentDepth: componentDepth,
|
||||
childType: draggingItem.type,
|
||||
childMeta: draggingItem.meta,
|
||||
});
|
||||
|
||||
const parentType = parentComponent?.type;
|
||||
@@ -103,6 +105,7 @@ export default function getDropPosition(
|
||||
parentType,
|
||||
parentDepth,
|
||||
childType: draggingItem.type,
|
||||
childMeta: draggingItem.meta,
|
||||
});
|
||||
|
||||
if (!validChild && !validSibling) {
|
||||
|
||||
@@ -16,17 +16,31 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { CHART_TYPE, MARKDOWN_TYPE, DYNAMIC_TYPE } from './componentTypes';
|
||||
import {
|
||||
CHART_TYPE,
|
||||
EXTENSION_TYPE,
|
||||
MARKDOWN_TYPE,
|
||||
DYNAMIC_TYPE,
|
||||
} from './componentTypes';
|
||||
|
||||
const USER_CONTENT_COMPONENT_TYPE: string[] = [
|
||||
CHART_TYPE,
|
||||
EXTENSION_TYPE,
|
||||
MARKDOWN_TYPE,
|
||||
DYNAMIC_TYPE,
|
||||
];
|
||||
export default function isDashboardEmpty(layout: any): boolean {
|
||||
// has at least one chart or markdown component
|
||||
// has at least one chart, markdown, or contributed user-content component
|
||||
return !Object.values(layout).some(
|
||||
({ type }: { type?: string }) =>
|
||||
type && USER_CONTENT_COMPONENT_TYPE.includes(type),
|
||||
({ type, meta }: { type?: string; meta?: { isUserContent?: boolean } }) => {
|
||||
if (!type || !USER_CONTENT_COMPONENT_TYPE.includes(type)) {
|
||||
return false;
|
||||
}
|
||||
// Extension components may opt out of counting as user content.
|
||||
if (type === EXTENSION_TYPE && meta?.isUserContent === false) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
DASHBOARD_ROOT_TYPE,
|
||||
DIVIDER_TYPE,
|
||||
HEADER_TYPE,
|
||||
EXTENSION_TYPE,
|
||||
MARKDOWN_TYPE,
|
||||
ROW_TYPE,
|
||||
TABS_TYPE,
|
||||
@@ -64,6 +65,7 @@ const parentMaxDepthLookup: Record<string, Record<string, number>> = {
|
||||
[DASHBOARD_GRID_TYPE]: {
|
||||
[CHART_TYPE]: depthOne,
|
||||
[DYNAMIC_TYPE]: depthOne,
|
||||
[EXTENSION_TYPE]: depthOne,
|
||||
[MARKDOWN_TYPE]: depthOne,
|
||||
[COLUMN_TYPE]: depthOne,
|
||||
[DIVIDER_TYPE]: depthOne,
|
||||
@@ -75,6 +77,7 @@ const parentMaxDepthLookup: Record<string, Record<string, number>> = {
|
||||
[ROW_TYPE]: {
|
||||
[CHART_TYPE]: depthFour,
|
||||
[DYNAMIC_TYPE]: depthFour,
|
||||
[EXTENSION_TYPE]: depthFour,
|
||||
[MARKDOWN_TYPE]: depthFour,
|
||||
[COLUMN_TYPE]: depthFour,
|
||||
},
|
||||
@@ -86,6 +89,7 @@ const parentMaxDepthLookup: Record<string, Record<string, number>> = {
|
||||
[TAB_TYPE]: {
|
||||
[CHART_TYPE]: depthFive,
|
||||
[DYNAMIC_TYPE]: depthFive,
|
||||
[EXTENSION_TYPE]: depthFive,
|
||||
[MARKDOWN_TYPE]: depthFive,
|
||||
[COLUMN_TYPE]: depthThree,
|
||||
[DIVIDER_TYPE]: depthFive,
|
||||
@@ -97,6 +101,7 @@ const parentMaxDepthLookup: Record<string, Record<string, number>> = {
|
||||
[COLUMN_TYPE]: {
|
||||
[CHART_TYPE]: depthFive,
|
||||
[HEADER_TYPE]: depthFive,
|
||||
[EXTENSION_TYPE]: depthFive,
|
||||
[MARKDOWN_TYPE]: depthFive,
|
||||
[ROW_TYPE]: depthThree,
|
||||
[DIVIDER_TYPE]: depthThree,
|
||||
@@ -108,6 +113,7 @@ const parentMaxDepthLookup: Record<string, Record<string, number>> = {
|
||||
[DYNAMIC_TYPE]: {},
|
||||
[DIVIDER_TYPE]: {},
|
||||
[HEADER_TYPE]: {},
|
||||
[EXTENSION_TYPE]: {},
|
||||
[MARKDOWN_TYPE]: {},
|
||||
};
|
||||
|
||||
@@ -115,14 +121,29 @@ interface IsValidChildProps {
|
||||
parentType?: string;
|
||||
childType?: string;
|
||||
parentDepth?: unknown;
|
||||
/**
|
||||
* The child's meta, when available (e.g. during a drag). Extension-contributed
|
||||
* components may declare `validParents` to restrict which container types may
|
||||
* hold them; the restriction is seeded onto meta at creation.
|
||||
*/
|
||||
childMeta?: { validParents?: string[] };
|
||||
}
|
||||
|
||||
export default function isValidChild(child: IsValidChildProps): boolean {
|
||||
const { parentType, childType, parentDepth } = child;
|
||||
const { parentType, childType, parentDepth, childMeta } = child;
|
||||
if (!parentType || !childType || typeof parentDepth !== 'number') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Per-component parent restriction for extension components.
|
||||
if (
|
||||
childType === EXTENSION_TYPE &&
|
||||
Array.isArray(childMeta?.validParents) &&
|
||||
!childMeta.validParents.includes(parentType)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const maxParentDepth: number | undefined =
|
||||
parentMaxDepthLookup[parentType]?.[childType];
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
COLUMN_TYPE,
|
||||
DIVIDER_TYPE,
|
||||
HEADER_TYPE,
|
||||
EXTENSION_TYPE,
|
||||
MARKDOWN_TYPE,
|
||||
ROW_TYPE,
|
||||
TABS_TYPE,
|
||||
@@ -56,6 +57,7 @@ const typeToDefaultMetaData: Record<string, LayoutItemMeta | null> = {
|
||||
headerSize: MEDIUM_HEADER,
|
||||
background: BACKGROUND_TRANSPARENT,
|
||||
},
|
||||
[EXTENSION_TYPE]: { width: GRID_DEFAULT_CHART_WIDTH, height: 50 },
|
||||
[MARKDOWN_TYPE]: { width: GRID_DEFAULT_CHART_WIDTH, height: 50 },
|
||||
[ROW_TYPE]: { background: BACKGROUND_TRANSPARENT },
|
||||
[TABS_TYPE]: null,
|
||||
|
||||
@@ -51,6 +51,7 @@ export default function newEntitiesFromDrop({
|
||||
const wrapChildInRow = shouldWrapChildInRow({
|
||||
parentType: dropType,
|
||||
childType: dragType,
|
||||
childMeta: dragging.meta,
|
||||
});
|
||||
|
||||
const newEntities: Record<string, DashboardComponent> = {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
DASHBOARD_GRID_TYPE,
|
||||
CHART_TYPE,
|
||||
COLUMN_TYPE,
|
||||
EXTENSION_TYPE,
|
||||
MARKDOWN_TYPE,
|
||||
TAB_TYPE,
|
||||
} from './componentTypes';
|
||||
@@ -28,10 +29,20 @@ import { ComponentType } from '../types';
|
||||
interface WrapChildParams {
|
||||
parentType: ComponentType | undefined | null;
|
||||
childType: ComponentType | undefined | null;
|
||||
/**
|
||||
* The child's meta, when available. Extension-contributed components may set
|
||||
* `wrapInRow: false` in their definition (seeded onto meta) to opt out of the
|
||||
* default auto-wrapping.
|
||||
*/
|
||||
childMeta?: { wrapInRow?: boolean };
|
||||
}
|
||||
|
||||
type ParentTypes = typeof DASHBOARD_GRID_TYPE | typeof TAB_TYPE;
|
||||
type ChildTypes = typeof CHART_TYPE | typeof COLUMN_TYPE | typeof MARKDOWN_TYPE;
|
||||
type ChildTypes =
|
||||
| typeof CHART_TYPE
|
||||
| typeof COLUMN_TYPE
|
||||
| typeof EXTENSION_TYPE
|
||||
| typeof MARKDOWN_TYPE;
|
||||
|
||||
const typeToWrapChildLookup: Record<
|
||||
ParentTypes,
|
||||
@@ -40,12 +51,14 @@ const typeToWrapChildLookup: Record<
|
||||
[DASHBOARD_GRID_TYPE]: {
|
||||
[CHART_TYPE]: true,
|
||||
[COLUMN_TYPE]: true,
|
||||
[EXTENSION_TYPE]: true,
|
||||
[MARKDOWN_TYPE]: true,
|
||||
},
|
||||
|
||||
[TAB_TYPE]: {
|
||||
[CHART_TYPE]: true,
|
||||
[COLUMN_TYPE]: true,
|
||||
[EXTENSION_TYPE]: true,
|
||||
[MARKDOWN_TYPE]: true,
|
||||
},
|
||||
};
|
||||
@@ -53,9 +66,15 @@ const typeToWrapChildLookup: Record<
|
||||
export default function shouldWrapChildInRow({
|
||||
parentType,
|
||||
childType,
|
||||
childMeta,
|
||||
}: WrapChildParams): boolean {
|
||||
if (!parentType || !childType) return false;
|
||||
|
||||
// Extension components may opt out of auto-wrapping.
|
||||
if (childType === EXTENSION_TYPE && childMeta?.wrapInRow === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const wrapChildLookup = typeToWrapChildLookup[parentType as ParentTypes];
|
||||
if (!wrapChildLookup) return false;
|
||||
|
||||
|
||||
@@ -148,7 +148,7 @@ export const DatasourceItems = ({
|
||||
|
||||
return (
|
||||
<List
|
||||
style={{ width: width - BORDER_WIDTH, height }}
|
||||
style={{ width: width - BORDER_WIDTH, height, maxHeight: height }}
|
||||
rowHeight={rowHeight}
|
||||
rowCount={flattenedItems.length}
|
||||
rowProps={rowProps}
|
||||
|
||||
@@ -251,4 +251,11 @@ export const DEFAULT_CONFIG_FORM_LAYOUT: ColumnConfigFormLayout = {
|
||||
{ name: 'horizontalAlign', override: { defaultValue: 'left' } },
|
||||
],
|
||||
],
|
||||
[GenericDataType.MultiValue]: [
|
||||
[
|
||||
'columnWidth',
|
||||
{ name: 'horizontalAlign', override: { defaultValue: 'left' } },
|
||||
],
|
||||
['truncateLongCells'],
|
||||
],
|
||||
};
|
||||
|
||||
+68
@@ -270,6 +270,74 @@ describe('AdhocFilter', () => {
|
||||
});
|
||||
expect(adhocFilter.comparator).toBe(undefined);
|
||||
});
|
||||
// Charts saved before #32701 persisted `==` as the operation for IS_TRUE and
|
||||
// IS_FALSE, alongside a boolean comparator. `translateToSql` and the backend
|
||||
// both key off `operator`, so dropping the comparator would render such a
|
||||
// filter as `col =` and query it as `col IS NULL`.
|
||||
test('keeps the legacy boolean comparator for IS_TRUE', () => {
|
||||
const adhocFilter = new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'col',
|
||||
operator: '==',
|
||||
operatorId: Operators.IsTrue,
|
||||
comparator: true,
|
||||
clause: Clauses.Where,
|
||||
});
|
||||
expect(adhocFilter.operator).toBe('==');
|
||||
expect(adhocFilter.comparator).toBe(true);
|
||||
expect(adhocFilter.translateToSql()).toBe("col = 'TRUE'");
|
||||
});
|
||||
test('keeps the legacy boolean comparator for IS_FALSE', () => {
|
||||
const adhocFilter = new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'col',
|
||||
operator: '==',
|
||||
operatorId: Operators.IsFalse,
|
||||
comparator: false,
|
||||
clause: Clauses.Where,
|
||||
});
|
||||
expect(adhocFilter.operator).toBe('==');
|
||||
expect(adhocFilter.comparator).toBe(false);
|
||||
expect(adhocFilter.translateToSql()).toBe("col = 'FALSE'");
|
||||
});
|
||||
test('restores the boolean even when the stored comparator is missing', () => {
|
||||
const adhocFilter = new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'col',
|
||||
operator: '==',
|
||||
operatorId: Operators.IsTrue,
|
||||
clause: Clauses.Where,
|
||||
});
|
||||
expect(adhocFilter.comparator).toBe(true);
|
||||
});
|
||||
test('keeps a legacy boolean filter intact when the control re-posts it', () => {
|
||||
const stored = {
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'col',
|
||||
operator: '==',
|
||||
operatorId: Operators.IsTrue,
|
||||
comparator: true,
|
||||
clause: Clauses.Where,
|
||||
};
|
||||
// DndFilterSelect wraps props.value and hands those instances to onChange
|
||||
const posted = JSON.parse(JSON.stringify(new AdhocFilter(stored)));
|
||||
expect(posted.operator).toBe('==');
|
||||
expect(posted.comparator).toBe(true);
|
||||
expect(posted.operatorId).toBe(Operators.IsTrue);
|
||||
});
|
||||
test('leaves a genuine equality filter on a boolean value alone', () => {
|
||||
const adhocFilter = new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'col',
|
||||
operator: '==',
|
||||
operatorId: Operators.Equals,
|
||||
comparator: true,
|
||||
clause: Clauses.Where,
|
||||
});
|
||||
expect(adhocFilter.operator).toBe('==');
|
||||
expect(adhocFilter.comparator).toBe(true);
|
||||
expect(adhocFilter.translateToSql()).toBe("col = 'TRUE'");
|
||||
});
|
||||
test('sets the label properly if subject is a string', () => {
|
||||
const adhocFilter = new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
|
||||
@@ -30,6 +30,15 @@ const CUSTOM_OPERATIONS = [...CUSTOM_OPERATORS].map(
|
||||
op => OPERATOR_ENUM_TO_OPERATOR_TYPE[op].operation,
|
||||
);
|
||||
|
||||
// Charts saved before #32701 store `==` for IS_TRUE/IS_FALSE with the boolean
|
||||
// in the comparator; blanking it makes them query `col IS NULL`. Restoring it
|
||||
// leaves the emitted SQL untouched -- reconciling `operator` to `IS TRUE`
|
||||
// would not, and Druid rejects that predicate on VARCHAR columns.
|
||||
const LEGACY_BOOLEAN_COMPARATORS = new Map<string, boolean>([
|
||||
[Operators.IsTrue, true],
|
||||
[Operators.IsFalse, false],
|
||||
]);
|
||||
|
||||
interface AdhocFilterInput {
|
||||
expressionType?: string;
|
||||
subject?: string | { column_name?: string; [key: string]: unknown } | null;
|
||||
@@ -77,6 +86,16 @@ export default class AdhocFilter {
|
||||
) {
|
||||
this.comparator = undefined;
|
||||
}
|
||||
if (
|
||||
this.operator ===
|
||||
OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.Equals].operation &&
|
||||
adhocFilter.operatorId &&
|
||||
LEGACY_BOOLEAN_COMPARATORS.has(adhocFilter.operatorId)
|
||||
) {
|
||||
this.comparator = LEGACY_BOOLEAN_COMPARATORS.get(
|
||||
adhocFilter.operatorId,
|
||||
);
|
||||
}
|
||||
this.clause = adhocFilter.clause || Clauses.Where;
|
||||
this.sqlExpression = null;
|
||||
} else if (this.expressionType === ExpressionTypes.Sql) {
|
||||
|
||||
+16
-2
@@ -367,8 +367,22 @@ function AdhocFilterEditPopover({
|
||||
</ErrorBoundary>
|
||||
),
|
||||
},
|
||||
...(datasource?.type === 'semantic_view'
|
||||
? []
|
||||
...(datasource?.type === 'semantic_view' ||
|
||||
[
|
||||
Operators.ContainsAny,
|
||||
Operators.ContainsAll,
|
||||
Operators.IsEmpty,
|
||||
Operators.IsNotEmpty,
|
||||
Operators.LengthEquals,
|
||||
Operators.LengthGreaterThan,
|
||||
Operators.LengthLessThan,
|
||||
Operators.LengthGreaterThanOrEqual,
|
||||
Operators.LengthLessThanOrEqual,
|
||||
].includes(adhocFilter.operatorId as Operators)
|
||||
? // Hide the Custom SQL tab for element-level array operators: they
|
||||
// have no portable SQL representation, and converting one would
|
||||
// silently turn the filter into invalid raw SQL.
|
||||
[]
|
||||
: [
|
||||
{
|
||||
key: ExpressionTypes.Sql,
|
||||
|
||||
+138
@@ -35,6 +35,7 @@ import {
|
||||
} from 'src/explore/constants';
|
||||
import AdhocMetric from 'src/explore/components/controls/MetricControl/AdhocMetric';
|
||||
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import fetchMock from 'fetch-mock';
|
||||
|
||||
import { TestDataset, Dataset } from '@superset-ui/chart-controls';
|
||||
@@ -252,6 +253,78 @@ test('shows boolean only operators when subject is number', () => {
|
||||
].map(operator => expect(isOperatorRelevant(operator, 'value')).toBe(true));
|
||||
});
|
||||
|
||||
test('shows array operators (tier 1 + tier 2) when subject is multi-value', () => {
|
||||
const props = setup({
|
||||
adhocFilter: new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'skills',
|
||||
operatorId: undefined,
|
||||
operator: undefined,
|
||||
comparator: undefined,
|
||||
clause: undefined,
|
||||
}),
|
||||
datasource: {
|
||||
columns: [
|
||||
{
|
||||
id: 3,
|
||||
column_name: 'skills',
|
||||
type: 'Array(String)',
|
||||
type_generic: GenericDataType.MultiValue,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const { isOperatorRelevant } = useSimpleTabFilterProps(
|
||||
props as unknown as Props,
|
||||
);
|
||||
// Tier 1 (whole-array) + Tier 2 (element-level) are all relevant.
|
||||
[
|
||||
Operators.Equals,
|
||||
Operators.NotEquals,
|
||||
Operators.In,
|
||||
Operators.NotIn,
|
||||
Operators.IsNull,
|
||||
Operators.IsNotNull,
|
||||
Operators.ContainsAny,
|
||||
Operators.ContainsAll,
|
||||
Operators.IsEmpty,
|
||||
Operators.IsNotEmpty,
|
||||
].forEach(operator =>
|
||||
expect(isOperatorRelevant(operator, 'skills')).toBe(true),
|
||||
);
|
||||
// scalar-only operators are hidden for array columns
|
||||
[Operators.GreaterThan, Operators.LessThan, Operators.Like].forEach(
|
||||
operator => expect(isOperatorRelevant(operator, 'skills')).toBe(false),
|
||||
);
|
||||
});
|
||||
|
||||
test('hides element-level array operators for non multi-value columns', () => {
|
||||
const props = setup({
|
||||
adhocFilter: new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'value',
|
||||
operatorId: undefined,
|
||||
operator: undefined,
|
||||
comparator: undefined,
|
||||
clause: undefined,
|
||||
}),
|
||||
datasource: {
|
||||
columns: [{ id: 3, column_name: 'value', type: 'STRING' }],
|
||||
},
|
||||
});
|
||||
const { isOperatorRelevant } = useSimpleTabFilterProps(
|
||||
props as unknown as Props,
|
||||
);
|
||||
[
|
||||
Operators.ContainsAny,
|
||||
Operators.ContainsAll,
|
||||
Operators.IsEmpty,
|
||||
Operators.IsNotEmpty,
|
||||
].forEach(operator =>
|
||||
expect(isOperatorRelevant(operator, 'value')).toBe(false),
|
||||
);
|
||||
});
|
||||
|
||||
test('will convert from individual comparator to array if the operator changes to multi', () => {
|
||||
const props = setup();
|
||||
const { onOperatorChange } = useSimpleTabFilterProps(
|
||||
@@ -309,6 +382,49 @@ test('will convert from array to individual comparators if the operator changes
|
||||
);
|
||||
});
|
||||
|
||||
test('resets the comparator when switching between array value families', () => {
|
||||
// Equal to (whole-array literal) -> Contains all (individual elements):
|
||||
// the value spaces are incompatible, so the stale value must be cleared.
|
||||
const wholeArrayFilter = new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'scores',
|
||||
operatorId: Operators.Equals,
|
||||
operator: OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.Equals].operation,
|
||||
comparator: '[5,6,7]',
|
||||
clause: Clauses.Where,
|
||||
});
|
||||
const props = setup({ adhocFilter: wholeArrayFilter });
|
||||
const { onOperatorChange } = useSimpleTabFilterProps(
|
||||
props as unknown as Props,
|
||||
);
|
||||
onOperatorChange(Operators.ContainsAll);
|
||||
const lastCall =
|
||||
props.onChange.mock.calls[props.onChange.mock.calls.length - 1][0];
|
||||
expect(lastCall.operatorId).toEqual(Operators.ContainsAll);
|
||||
expect(lastCall.comparator).toBeUndefined();
|
||||
});
|
||||
|
||||
test('keeps the value when switching within the element family', () => {
|
||||
// Contains any <-> Contains all both take individual elements, so the
|
||||
// selected elements should carry over.
|
||||
const elementFilter = new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'scores',
|
||||
operatorId: Operators.ContainsAny,
|
||||
operator: OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.ContainsAny].operation,
|
||||
comparator: ['5', '6'],
|
||||
clause: Clauses.Where,
|
||||
});
|
||||
const props = setup({ adhocFilter: elementFilter });
|
||||
const { onOperatorChange } = useSimpleTabFilterProps(
|
||||
props as unknown as Props,
|
||||
);
|
||||
onOperatorChange(Operators.ContainsAll);
|
||||
const lastCall =
|
||||
props.onChange.mock.calls[props.onChange.mock.calls.length - 1][0];
|
||||
expect(lastCall.comparator).toEqual(['5', '6']);
|
||||
});
|
||||
|
||||
test('passes the new adhocFilter to onChange after onComparatorChange', () => {
|
||||
const props = setup();
|
||||
const { onComparatorChange } = useSimpleTabFilterProps(
|
||||
@@ -399,6 +515,28 @@ test('will not display boolean operators when column type is string', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.each(['STRING', 'DATE'])(
|
||||
'will not display boolean operators when an expression column declares type %s',
|
||||
type => {
|
||||
const props = setup({
|
||||
datasource: {
|
||||
type: 'table' as const,
|
||||
datasource_name: 'table1',
|
||||
schema: 'schema',
|
||||
columns: [{ column_name: 'value', type, expression: '"value"' }],
|
||||
},
|
||||
adhocFilter: simpleAdhocFilter,
|
||||
});
|
||||
const { isOperatorRelevant } = useSimpleTabFilterProps(
|
||||
props as unknown as Props,
|
||||
);
|
||||
const booleanOnlyOperators = [Operators.IsTrue, Operators.IsFalse];
|
||||
booleanOnlyOperators.forEach(operator => {
|
||||
expect(isOperatorRelevant(operator, 'value')).toBe(false);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test('will display boolean operators when column is an expression', () => {
|
||||
const props = setup({
|
||||
datasource: {
|
||||
|
||||
+112
-14
@@ -32,6 +32,7 @@ import {
|
||||
isDefined,
|
||||
SupersetClient,
|
||||
} from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import { styled, useTheme, css } from '@apache-superset/core/theme';
|
||||
import {
|
||||
Operators,
|
||||
@@ -118,6 +119,8 @@ export const useSimpleTabFilterProps = (props: Props) => {
|
||||
const isColumnNumber =
|
||||
!!column && (column.type === 'INT' || column.type === 'INTEGER');
|
||||
const isColumnFunction = !!column && !!column.expression;
|
||||
const isColumnMultiValue =
|
||||
!!column && column.type_generic === GenericDataType.MultiValue;
|
||||
|
||||
if (operator && operator === Operators.LatestPartition) {
|
||||
const { partitionColumn } = props;
|
||||
@@ -127,8 +130,41 @@ export const useSimpleTabFilterProps = (props: Props) => {
|
||||
// hide the TEMPORAL_RANGE operator
|
||||
return false;
|
||||
}
|
||||
// Element-level array operators only apply to multi-value columns.
|
||||
const arrayElementOperators = [
|
||||
Operators.ContainsAny,
|
||||
Operators.ContainsAll,
|
||||
Operators.IsEmpty,
|
||||
Operators.IsNotEmpty,
|
||||
Operators.LengthEquals,
|
||||
Operators.LengthGreaterThan,
|
||||
Operators.LengthLessThan,
|
||||
Operators.LengthGreaterThanOrEqual,
|
||||
Operators.LengthLessThanOrEqual,
|
||||
];
|
||||
if (arrayElementOperators.includes(operator)) {
|
||||
return isColumnMultiValue;
|
||||
}
|
||||
if (isColumnMultiValue) {
|
||||
// Array columns support whole-array operators (=, !=, In, Not in, null
|
||||
// checks) plus the element-level operators above. Scalar-only operators
|
||||
// (Like, <, >, <=, >=) are hidden because they aren't valid on an array.
|
||||
return [
|
||||
Operators.Equals,
|
||||
Operators.NotEquals,
|
||||
Operators.In,
|
||||
Operators.NotIn,
|
||||
Operators.IsNull,
|
||||
Operators.IsNotNull,
|
||||
...arrayElementOperators,
|
||||
].includes(operator);
|
||||
}
|
||||
if (operator === Operators.IsTrue || operator === Operators.IsFalse) {
|
||||
return isColumnBoolean || isColumnNumber || isColumnFunction;
|
||||
// An expression column may evaluate to a boolean, but that is only a
|
||||
// safe assumption while its type is unknown; a declared type wins.
|
||||
return (
|
||||
isColumnBoolean || isColumnNumber || (isColumnFunction && !column?.type)
|
||||
);
|
||||
}
|
||||
if (isColumnBoolean) {
|
||||
return operator === Operators.IsNull || operator === Operators.IsNotNull;
|
||||
@@ -167,9 +203,19 @@ export const useSimpleTabFilterProps = (props: Props) => {
|
||||
].operation
|
||||
: null;
|
||||
if (!isDefined(operator)) {
|
||||
// if operator is `null`, use the `IN` and reset the comparator.
|
||||
operator = Operators.In;
|
||||
operatorId = Operators.In;
|
||||
// The previous operator is not relevant for the new subject; pick a
|
||||
// sensible default and reset the comparator. Multi-value (array) columns
|
||||
// default to "Contains any" (element membership) rather than the
|
||||
// scalar-only IN.
|
||||
const newColumn = props.datasource.columns?.find(
|
||||
col => col.column_name === subject,
|
||||
);
|
||||
const defaultOperator =
|
||||
newColumn?.type_generic === GenericDataType.MultiValue
|
||||
? Operators.ContainsAny
|
||||
: Operators.In;
|
||||
operator = defaultOperator;
|
||||
operatorId = defaultOperator;
|
||||
comparator = undefined;
|
||||
}
|
||||
|
||||
@@ -193,10 +239,38 @@ export const useSimpleTabFilterProps = (props: Props) => {
|
||||
};
|
||||
const onOperatorChange = (operatorId: Operators) => {
|
||||
const currentComparator = props.adhocFilter.comparator;
|
||||
// The value space differs between operator families: element-level array
|
||||
// ops (Contains any/all) take individual elements, whole-array/scalar ops
|
||||
// (=, In, …) take whole arrays or scalars, Length ops take a count, and the
|
||||
// unary ops take nothing. A value from one family is meaningless in another,
|
||||
// so reset the value when the family changes (e.g. Equal to -> Contains all).
|
||||
const comparatorKind = (op?: Operators): string => {
|
||||
if (!op) return 'none';
|
||||
if (op === Operators.ContainsAny || op === Operators.ContainsAll) {
|
||||
return 'element';
|
||||
}
|
||||
if (
|
||||
op === Operators.LengthEquals ||
|
||||
op === Operators.LengthGreaterThan ||
|
||||
op === Operators.LengthLessThan ||
|
||||
op === Operators.LengthGreaterThanOrEqual ||
|
||||
op === Operators.LengthLessThanOrEqual
|
||||
) {
|
||||
return 'length';
|
||||
}
|
||||
if (DISABLE_INPUT_OPERATORS.includes(op)) return 'none';
|
||||
return 'value';
|
||||
};
|
||||
const valueFamilyChanged =
|
||||
comparatorKind(props.adhocFilter.operatorId as Operators | undefined) !==
|
||||
comparatorKind(operatorId);
|
||||
|
||||
let newComparator;
|
||||
// convert between list of comparators and individual comparators
|
||||
// (e.g. `in ('North America', 'Africa')` to `== 'North America'`)
|
||||
if (MULTI_OPERATORS.has(operatorId)) {
|
||||
if (valueFamilyChanged) {
|
||||
newComparator = undefined;
|
||||
} else if (MULTI_OPERATORS.has(operatorId)) {
|
||||
// convert between list of comparators and individual comparators
|
||||
// (e.g. `in ('North America', 'Africa')` to `== 'North America'`)
|
||||
newComparator = Array.isArray(currentComparator)
|
||||
? currentComparator
|
||||
: [currentComparator].filter(element => element != null);
|
||||
@@ -433,19 +507,42 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
|
||||
if (loadingComparatorSuggestions) {
|
||||
controller.abort();
|
||||
}
|
||||
// Element-level array operators (Contains any / Contains all) search
|
||||
// inside the array, so suggest individual elements; whole-array
|
||||
// operators (=, In, …) keep the default distinct-array suggestions.
|
||||
const { operatorId } = props.adhocFilter;
|
||||
const arrayElements =
|
||||
operatorId === Operators.ContainsAny ||
|
||||
operatorId === Operators.ContainsAll;
|
||||
setLoadingComparatorSuggestions(true);
|
||||
SupersetClient.get({
|
||||
signal,
|
||||
endpoint: `/api/v1/datasource/${datasource.type}/${datasource.id}/column/${col}/values/`,
|
||||
endpoint: `/api/v1/datasource/${datasource.type}/${datasource.id}/column/${col}/values/${
|
||||
arrayElements ? '?array_elements=true' : ''
|
||||
}`,
|
||||
})
|
||||
.then(({ json }) => {
|
||||
setSuggestions(
|
||||
json.result.map(
|
||||
(suggestion: null | number | boolean | string) => ({
|
||||
value: suggestion,
|
||||
label: optionLabel(suggestion),
|
||||
}),
|
||||
),
|
||||
json.result.map((suggestion: unknown) => {
|
||||
// Complex column values arrive as JS arrays or objects: whole
|
||||
// arrays for MULTI_VALUE columns (e.g. [5, 6, 7]) and Map/Tuple
|
||||
// objects for nested-container columns (e.g. {"a": ["x","y"]}).
|
||||
// A raw array/object is neither a valid single-select value
|
||||
// (antd collapses an array to its first element) nor renderable
|
||||
// as a React child (an object throws). Render it as its literal
|
||||
// string, which is also exactly what the backend's
|
||||
// parse_array_literal expects for the whole-array operators.
|
||||
if (suggestion !== null && typeof suggestion === 'object') {
|
||||
const literal = JSON.stringify(suggestion);
|
||||
return { value: literal, label: literal };
|
||||
}
|
||||
return {
|
||||
value: suggestion as null | number | boolean | string,
|
||||
label: optionLabel(
|
||||
suggestion as null | number | boolean | string,
|
||||
),
|
||||
};
|
||||
}),
|
||||
);
|
||||
setLoadingComparatorSuggestions(false);
|
||||
})
|
||||
@@ -464,6 +561,7 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
|
||||
}, [
|
||||
props.adhocFilter.subject,
|
||||
props.adhocFilter.clause,
|
||||
props.adhocFilter.operatorId,
|
||||
props.datasource,
|
||||
datePicker,
|
||||
]);
|
||||
|
||||
+11
@@ -44,6 +44,17 @@ export const OPERATORS_TO_SQL = {
|
||||
'IS NULL': 'IS NULL',
|
||||
'IS TRUE': 'IS TRUE',
|
||||
'IS FALSE': 'IS FALSE',
|
||||
// Element-level array operators (shown as filter labels; not executable SQL —
|
||||
// the Custom SQL tab is hidden for these).
|
||||
CONTAINS_ANY: 'CONTAINS ANY',
|
||||
CONTAINS_ALL: 'CONTAINS ALL',
|
||||
IS_EMPTY: 'IS EMPTY',
|
||||
IS_NOT_EMPTY: 'IS NOT EMPTY',
|
||||
LENGTH_EQUALS: 'LENGTH =',
|
||||
LENGTH_GREATER_THAN: 'LENGTH >',
|
||||
LENGTH_LESS_THAN: 'LENGTH <',
|
||||
LENGTH_GREATER_THAN_OR_EQUALS: 'LENGTH >=',
|
||||
LENGTH_LESS_THAN_OR_EQUALS: 'LENGTH <=',
|
||||
'LATEST PARTITION': ({
|
||||
datasource,
|
||||
}: {
|
||||
|
||||
@@ -45,6 +45,17 @@ export enum Operators {
|
||||
IsTrue = 'IS_TRUE',
|
||||
IsFalse = 'IS_FALSE',
|
||||
TemporalRange = 'TEMPORAL_RANGE',
|
||||
// Element-level operators for multi-value (array) columns
|
||||
ContainsAny = 'CONTAINS_ANY',
|
||||
ContainsAll = 'CONTAINS_ALL',
|
||||
IsEmpty = 'IS_EMPTY',
|
||||
IsNotEmpty = 'IS_NOT_EMPTY',
|
||||
// Length (element-count) comparison operators for array columns
|
||||
LengthEquals = 'LENGTH_EQUALS',
|
||||
LengthGreaterThan = 'LENGTH_GREATER_THAN',
|
||||
LengthLessThan = 'LENGTH_LESS_THAN',
|
||||
LengthGreaterThanOrEqual = 'LENGTH_GREATER_THAN_OR_EQUALS',
|
||||
LengthLessThanOrEqual = 'LENGTH_LESS_THAN_OR_EQUALS',
|
||||
}
|
||||
|
||||
export interface OperatorType {
|
||||
@@ -89,6 +100,39 @@ export const OPERATOR_ENUM_TO_OPERATOR_TYPE: {
|
||||
display: t('TEMPORAL_RANGE'),
|
||||
operation: 'TEMPORAL_RANGE',
|
||||
},
|
||||
[Operators.ContainsAny]: {
|
||||
display: t('Contains any'),
|
||||
operation: 'CONTAINS_ANY',
|
||||
},
|
||||
[Operators.ContainsAll]: {
|
||||
display: t('Contains all'),
|
||||
operation: 'CONTAINS_ALL',
|
||||
},
|
||||
[Operators.IsEmpty]: { display: t('Is empty'), operation: 'IS_EMPTY' },
|
||||
[Operators.IsNotEmpty]: {
|
||||
display: t('Is not empty'),
|
||||
operation: 'IS_NOT_EMPTY',
|
||||
},
|
||||
[Operators.LengthEquals]: {
|
||||
display: t('Length equals (=)'),
|
||||
operation: 'LENGTH_EQUALS',
|
||||
},
|
||||
[Operators.LengthGreaterThan]: {
|
||||
display: t('Length greater than (>)'),
|
||||
operation: 'LENGTH_GREATER_THAN',
|
||||
},
|
||||
[Operators.LengthLessThan]: {
|
||||
display: t('Length less than (<)'),
|
||||
operation: 'LENGTH_LESS_THAN',
|
||||
},
|
||||
[Operators.LengthGreaterThanOrEqual]: {
|
||||
display: t('Length greater or equal (>=)'),
|
||||
operation: 'LENGTH_GREATER_THAN_OR_EQUALS',
|
||||
},
|
||||
[Operators.LengthLessThanOrEqual]: {
|
||||
display: t('Length less or equal (<=)'),
|
||||
operation: 'LENGTH_LESS_THAN_OR_EQUALS',
|
||||
},
|
||||
};
|
||||
|
||||
export const OPERATORS_OPTIONS = Object.values(Operators) as Operators[];
|
||||
@@ -105,7 +149,12 @@ export const HAVING_OPERATORS = [
|
||||
Operators.GreaterThan,
|
||||
Operators.GreaterThanOrEqual,
|
||||
];
|
||||
export const MULTI_OPERATORS = new Set([Operators.In, Operators.NotIn]);
|
||||
export const MULTI_OPERATORS = new Set([
|
||||
Operators.In,
|
||||
Operators.NotIn,
|
||||
Operators.ContainsAny,
|
||||
Operators.ContainsAll,
|
||||
]);
|
||||
// CUSTOM_OPERATORS will show operator in simple mode,
|
||||
// but will generate customized sqlExpression
|
||||
export const CUSTOM_OPERATORS = new Set([
|
||||
@@ -120,6 +169,8 @@ export const DISABLE_INPUT_OPERATORS = [
|
||||
Operators.LatestPartition,
|
||||
Operators.IsTrue,
|
||||
Operators.IsFalse,
|
||||
Operators.IsEmpty,
|
||||
Operators.IsNotEmpty,
|
||||
];
|
||||
|
||||
export const sqlaAutoGeneratedMetricNameRegex =
|
||||
|
||||
@@ -82,3 +82,14 @@ test('Should handle boolean true comparator as a string value', () => {
|
||||
"subject operator 'TRUE'",
|
||||
);
|
||||
});
|
||||
|
||||
test('Should render array-literal comparators as-is (not quoted)', () => {
|
||||
// Whole-array = filter: the pasted array literal is shown unquoted.
|
||||
expect(getSimpleSQLExpression('ingredients', '=', "['1 large egg']")).toBe(
|
||||
"ingredients = ['1 large egg']",
|
||||
);
|
||||
// IN with multiple array literals.
|
||||
expect(
|
||||
getSimpleSQLExpression('ingredients', Operators.In, ["['a']", "['b']"]),
|
||||
).toBe(`ingredients ${Operators.In} (['a'], ['b'])`);
|
||||
});
|
||||
|
||||
@@ -461,10 +461,15 @@ export const getSimpleSQLExpression = (
|
||||
if (comparatorArray.length > 0 && showComparator) {
|
||||
const formattedComparators = comparatorArray
|
||||
.map(val => optionLabel(val))
|
||||
.map(
|
||||
val =>
|
||||
`${quote}${isString ? String(val).replace(/'/g, "''") : val}${quote}`,
|
||||
);
|
||||
.map(val => {
|
||||
// Array-literal values (e.g. ['a', 'b']) are shown as-is rather than
|
||||
// quoted/escaped as a string, so array-column filters read naturally.
|
||||
const asString = String(val);
|
||||
if (asString.startsWith('[') && asString.endsWith(']')) {
|
||||
return asString;
|
||||
}
|
||||
return `${quote}${isString ? asString.replace(/'/g, "''") : val}${quote}`;
|
||||
});
|
||||
expression += ` ${prefix}${formattedComparators.join(', ')}${suffix}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
chat,
|
||||
core,
|
||||
commands,
|
||||
dashboardComponents,
|
||||
editors,
|
||||
extensions,
|
||||
menus,
|
||||
@@ -59,6 +60,7 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
|
||||
chat,
|
||||
core,
|
||||
commands,
|
||||
dashboardComponents,
|
||||
editors,
|
||||
extensions,
|
||||
menus,
|
||||
|
||||
@@ -31,6 +31,7 @@ import type {
|
||||
chat,
|
||||
commands,
|
||||
core,
|
||||
dashboardComponents,
|
||||
editors,
|
||||
extensions,
|
||||
menus,
|
||||
@@ -45,6 +46,7 @@ export interface Namespaces {
|
||||
core: typeof core;
|
||||
chat: typeof chat;
|
||||
commands: typeof commands;
|
||||
dashboardComponents: typeof dashboardComponents;
|
||||
editors: typeof editors;
|
||||
extensions: typeof extensions;
|
||||
menus: typeof menus;
|
||||
|
||||
@@ -31,7 +31,12 @@ import {
|
||||
import { Group, Role, UserObject } from 'src/pages/UsersList/types';
|
||||
import { Actions } from 'src/constants';
|
||||
import { BaseUserListModalProps, FormValues } from './types';
|
||||
import { createUser, updateUser, atLeastOneRoleOrGroup } from './utils';
|
||||
import {
|
||||
createUser,
|
||||
updateUser,
|
||||
atLeastOneRoleOrGroup,
|
||||
handleUserError,
|
||||
} from './utils';
|
||||
|
||||
export interface UserModalProps extends BaseUserListModalProps {
|
||||
roles: Role[];
|
||||
@@ -51,36 +56,6 @@ function UserListModal({
|
||||
}: UserModalProps) {
|
||||
const { addDangerToast, addSuccessToast } = useToasts();
|
||||
const handleFormSubmit = async (values: FormValues) => {
|
||||
const handleError = async (
|
||||
err: any,
|
||||
action: Actions.CREATE | Actions.UPDATE,
|
||||
) => {
|
||||
let errorMessage =
|
||||
action === Actions.CREATE
|
||||
? t('There was an error creating the user. Please, try again.')
|
||||
: t('There was an error updating the user. Please, try again.');
|
||||
|
||||
if (err.status === 422) {
|
||||
const errorData = await err.json();
|
||||
const detail = errorData?.message || '';
|
||||
|
||||
if (detail.includes('duplicate key value')) {
|
||||
if (detail.includes('ab_user_username_key')) {
|
||||
errorMessage = t(
|
||||
'This username is already taken. Please choose another one.',
|
||||
);
|
||||
} else if (detail.includes('ab_user_email_key')) {
|
||||
errorMessage = t(
|
||||
'This email is already associated with an account. Please choose another one.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addDangerToast(errorMessage);
|
||||
throw err;
|
||||
};
|
||||
|
||||
if (isEditMode) {
|
||||
if (!user) {
|
||||
throw new Error('User is required in edit mode');
|
||||
@@ -89,14 +64,14 @@ function UserListModal({
|
||||
await updateUser(user.id, values);
|
||||
addSuccessToast(t('The user has been updated successfully.'));
|
||||
} catch (err) {
|
||||
await handleError(err, Actions.UPDATE);
|
||||
await handleUserError(err as Response, Actions.UPDATE, addDangerToast);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await createUser(values);
|
||||
addSuccessToast(t('The user has been created successfully.'));
|
||||
} catch (err) {
|
||||
await handleError(err, Actions.CREATE);
|
||||
await handleUserError(err as Response, Actions.CREATE, addDangerToast);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 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 { Actions } from 'src/constants';
|
||||
import { handleUserError } from './utils';
|
||||
|
||||
test('shows the password validation message from a 400 response', async () => {
|
||||
const error = new Response(
|
||||
JSON.stringify({
|
||||
message: {
|
||||
password: ['Password must be at least 8 characters long.'],
|
||||
},
|
||||
}),
|
||||
{ status: 400 },
|
||||
);
|
||||
const addDangerToast = jest.fn();
|
||||
|
||||
await expect(
|
||||
handleUserError(error, Actions.CREATE, addDangerToast),
|
||||
).rejects.toBe(error);
|
||||
expect(addDangerToast).toHaveBeenCalledWith(
|
||||
'Password must be at least 8 characters long.',
|
||||
);
|
||||
});
|
||||
|
||||
test('shows a plain string message from a 400 response', async () => {
|
||||
const error = new Response(
|
||||
JSON.stringify({ message: 'User must have at least one role or group!' }),
|
||||
{ status: 400 },
|
||||
);
|
||||
const addDangerToast = jest.fn();
|
||||
|
||||
await expect(
|
||||
handleUserError(error, Actions.UPDATE, addDangerToast),
|
||||
).rejects.toBe(error);
|
||||
expect(addDangerToast).toHaveBeenCalledWith(
|
||||
'User must have at least one role or group!',
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps the duplicate username message for a 422 response', async () => {
|
||||
const error = new Response(
|
||||
JSON.stringify({
|
||||
message:
|
||||
'duplicate key value violates unique constraint "ab_user_username_key"',
|
||||
}),
|
||||
{ status: 422 },
|
||||
);
|
||||
const addDangerToast = jest.fn();
|
||||
|
||||
await expect(
|
||||
handleUserError(error, Actions.CREATE, addDangerToast),
|
||||
).rejects.toBe(error);
|
||||
expect(addDangerToast).toHaveBeenCalledWith(
|
||||
'This username is already taken. Please choose another one.',
|
||||
);
|
||||
});
|
||||
|
||||
test('shows the generic message when a 422 response has no message', async () => {
|
||||
const error = new Response(JSON.stringify({ foo: 'bar' }), { status: 422 });
|
||||
const addDangerToast = jest.fn();
|
||||
|
||||
await expect(
|
||||
handleUserError(error, Actions.CREATE, addDangerToast),
|
||||
).rejects.toBe(error);
|
||||
expect(addDangerToast).toHaveBeenCalledWith(
|
||||
'There was an error creating the user. Please, try again.',
|
||||
);
|
||||
});
|
||||
|
||||
test('shows the generic message when a 400 response is not JSON', async () => {
|
||||
const error = new Response('<html>Bad request</html>', {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
});
|
||||
const addDangerToast = jest.fn();
|
||||
|
||||
await expect(
|
||||
handleUserError(error, Actions.CREATE, addDangerToast),
|
||||
).rejects.toBe(error);
|
||||
expect(addDangerToast).toHaveBeenCalledWith(
|
||||
'There was an error creating the user. Please, try again.',
|
||||
);
|
||||
});
|
||||
@@ -17,10 +17,49 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { getClientErrorObject, SupersetClient } from '@superset-ui/core';
|
||||
import { SelectOption } from 'src/components/ListView';
|
||||
import { Actions } from 'src/constants';
|
||||
import { FormValues } from './types';
|
||||
|
||||
type AddDangerToast = (message: string) => void;
|
||||
|
||||
export const handleUserError = async (
|
||||
err: Response,
|
||||
action: Actions.CREATE | Actions.UPDATE,
|
||||
addDangerToast: AddDangerToast,
|
||||
): Promise<never> => {
|
||||
let errorMessage =
|
||||
action === Actions.CREATE
|
||||
? t('There was an error creating the user. Please, try again.')
|
||||
: t('There was an error updating the user. Please, try again.');
|
||||
|
||||
if (err.status === 400 || err.status === 422) {
|
||||
const errorData = await getClientErrorObject(err);
|
||||
const message: unknown = errorData.message;
|
||||
|
||||
if (err.status === 400 && message && errorData.error) {
|
||||
errorMessage = errorData.error;
|
||||
} else if (
|
||||
err.status === 422 &&
|
||||
errorData.error?.includes('duplicate key value')
|
||||
) {
|
||||
if (errorData.error.includes('ab_user_username_key')) {
|
||||
errorMessage = t(
|
||||
'This username is already taken. Please choose another one.',
|
||||
);
|
||||
} else if (errorData.error.includes('ab_user_email_key')) {
|
||||
errorMessage = t(
|
||||
'This email is already associated with an account. Please choose another one.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addDangerToast(errorMessage);
|
||||
throw err;
|
||||
};
|
||||
|
||||
export const createUser = async (values: FormValues) => {
|
||||
const { confirmPassword: _confirmPassword, ...payload } = values;
|
||||
if (payload.active == null) {
|
||||
|
||||
@@ -1157,6 +1157,34 @@ test('dataset links use internal routing when PREVENT_UNSAFE_DEFAULT_URLS_ON_DAT
|
||||
});
|
||||
});
|
||||
|
||||
test('legacy dashboard default URLs use the registered client route', async () => {
|
||||
const dataset = {
|
||||
...mockDatasets[0],
|
||||
explore_url: '/superset/dashboard/123/?standalone=1#section',
|
||||
};
|
||||
mockDatasetListEndpoints({ result: [dataset], count: 1 });
|
||||
|
||||
renderDatasetList(
|
||||
mockAdminUser,
|
||||
{},
|
||||
{
|
||||
common: {
|
||||
conf: {
|
||||
PREVENT_UNSAFE_DEFAULT_URLS_ON_DATASET: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const datasetLink = await screen.findByRole('link', {
|
||||
name: dataset.table_name,
|
||||
});
|
||||
expect(datasetLink).toHaveAttribute(
|
||||
'href',
|
||||
'/dashboard/123/?standalone=1#section',
|
||||
);
|
||||
});
|
||||
|
||||
// Note: These delete error tests verify that the modal doesn't open when fetching
|
||||
// related_objects fails. The component's openDatasetDeleteModal error handler
|
||||
// (index.tsx:262-268) returns a string but doesn't call addDangerToast(), so no
|
||||
|
||||
@@ -54,10 +54,18 @@ import {
|
||||
|
||||
const APP_ROOT = '/superset';
|
||||
|
||||
const renderUnderSubdirectory = () => {
|
||||
const renderUnderSubdirectory = (preventUnsafeDefaultUrls = false) => {
|
||||
const defaultState = createDefaultStoreState(mockAdminUser);
|
||||
const store = createMockStore({
|
||||
...createDefaultStoreState(mockAdminUser),
|
||||
...defaultState,
|
||||
user: mockAdminUser,
|
||||
common: {
|
||||
...defaultState.common,
|
||||
conf: {
|
||||
...defaultState.common?.conf,
|
||||
PREVENT_UNSAFE_DEFAULT_URLS_ON_DATASET: preventUnsafeDefaultUrls,
|
||||
},
|
||||
},
|
||||
});
|
||||
return render(
|
||||
<Provider store={store}>
|
||||
@@ -115,6 +123,31 @@ test('explore link is single-prefixed under a subdirectory deployment', async ()
|
||||
expect(exploreLink.getAttribute('href')).not.toContain('/superset/superset');
|
||||
});
|
||||
|
||||
test('legacy dashboard default URL uses the router basename once', async () => {
|
||||
// A subdirectory user pastes the full browser path, so the saved value
|
||||
// carries both the application root and the legacy `/superset` prefix.
|
||||
// stripAppRoot removes the root and the legacy normalization removes the
|
||||
// prefix, leaving the basename to re-add the root exactly once.
|
||||
const dataset = {
|
||||
...mockDatasets[0],
|
||||
explore_url: `${APP_ROOT}/superset/dashboard/123/?standalone=1#section`,
|
||||
};
|
||||
mockDatasetListEndpoints({ result: [dataset], count: 1 });
|
||||
|
||||
renderUnderSubdirectory(true);
|
||||
|
||||
const dashboardLink = await screen.findByRole('link', {
|
||||
name: dataset.table_name,
|
||||
});
|
||||
expect(dashboardLink).toHaveAttribute(
|
||||
'href',
|
||||
`${APP_ROOT}/dashboard/123/?standalone=1#section`,
|
||||
);
|
||||
expect(dashboardLink.getAttribute('href')).not.toContain(
|
||||
'/superset/superset',
|
||||
);
|
||||
});
|
||||
|
||||
test('external default_endpoint passes through unprefixed', async () => {
|
||||
const dataset = {
|
||||
...mockDatasets[0],
|
||||
|
||||
@@ -87,7 +87,6 @@ import withToasts from 'src/components/MessageToasts/withToasts';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import WarningIconWithTooltip from '@superset-ui/core/components/WarningIconWithTooltip';
|
||||
import { isUserEditorOrAdmin } from 'src/dashboard/util/permissionUtils';
|
||||
|
||||
import {
|
||||
PAGE_SIZE,
|
||||
SORT_BY,
|
||||
@@ -114,6 +113,10 @@ import type {
|
||||
} from 'src/types/bootstrapTypes';
|
||||
import type User from 'src/types/User';
|
||||
|
||||
// Keep saved Default URLs compatible with the prefix-free SPA route.
|
||||
const normalizeLegacyDashboardUrl = (url: string) =>
|
||||
url.replace(/^\/superset(?=\/dashboard(?:\/|$))/, '');
|
||||
|
||||
const SEMANTIC_LAYERS_FLAG = 'SEMANTIC_LAYERS' as FeatureFlag;
|
||||
type DatasetExtra = {
|
||||
certification?: {
|
||||
@@ -722,7 +725,9 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
// Router basename, which re-prefixes the root — so strip it here to
|
||||
// avoid a doubled `/superset/superset/...`. External
|
||||
// `default_endpoint` URLs pass through unchanged.
|
||||
const exploreTo = stripAppRoot(exploreURL);
|
||||
const exploreTo = normalizeLegacyDashboardUrl(
|
||||
stripAppRoot(exploreURL),
|
||||
);
|
||||
let titleLink: JSX.Element;
|
||||
if (PREVENT_UNSAFE_DEFAULT_URLS_ON_DATASET) {
|
||||
titleLink = (
|
||||
|
||||
@@ -18,14 +18,17 @@
|
||||
*/
|
||||
|
||||
/*
|
||||
This file can be overridden from outside by custom config, it will add/delete new components to existing config in
|
||||
superset-frontend/src/visualizations/presets/dashboardComponents.ts file
|
||||
Registers built-in dashboard components through the `dashboardComponents`
|
||||
Extensions contribution point. This file can be overridden from outside by
|
||||
custom config to add or remove components.
|
||||
|
||||
The legacy DashboardComponentsRegistry
|
||||
(visualizations/presets/dashboardComponents) is deprecated in favor of this
|
||||
path.
|
||||
*/
|
||||
|
||||
// import dashboardComponentsRegistry from '../visualizations/presets/dashboardComponents';
|
||||
// import example from '../visualizations/dashboardComponents/ExampleComponent';
|
||||
import registerIframeComponent from '../dashboard/extensions/iframe';
|
||||
|
||||
export default function setupDashboardComponents() {
|
||||
// Add custom dashboard components here. Example:
|
||||
// dashboardComponentsRegistry.set('example', example);
|
||||
registerIframeComponent();
|
||||
}
|
||||
|
||||
+9
@@ -16,6 +16,15 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @deprecated Superseded by the `dashboardComponents` Extensions contribution
|
||||
* point (`@apache-superset/core` -> `src/core/dashboardComponents`) and the
|
||||
* EXTENSION_TYPE dashboard component. New custom dashboard components should be
|
||||
* registered via `dashboardComponents.registerDashboardComponent(...)`. This
|
||||
* registry and the DYNAMIC_TYPE path remain only for backwards compatibility
|
||||
* and will be removed in a future major release.
|
||||
*/
|
||||
import {
|
||||
ComponentItem,
|
||||
ComponentRegistry,
|
||||
|
||||
Generated
+4
-4
@@ -28,7 +28,7 @@
|
||||
"@typescript-eslint/parser": "^8.67.0",
|
||||
"eslint": "^10.8.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"globals": "^17.9.0",
|
||||
"globals": "^17.10.0",
|
||||
"oxfmt": "^0.63.0",
|
||||
"tscw-config": "^1.1.2",
|
||||
"typescript": "^6.0.3",
|
||||
@@ -2053,9 +2053,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "17.9.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.9.0.tgz",
|
||||
"integrity": "sha512-m/MvAW61QVU5VDNF1Vj8axt016h8w7L5TU1e9zlab7XIttAT2YAlCwl75K1fOqvMM9apmD7lbCIRhpfkhmxhCg==",
|
||||
"version": "17.10.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.10.0.tgz",
|
||||
"integrity": "sha512-V0kztuWST2k8A/VbxAY8+L+7+Rgo3fyA24IHRLrZp7HOzJjV0gHSaZUjK9lpP/IrBSNite2tZ1prhRkinRu1CA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"@typescript-eslint/parser": "^8.67.0",
|
||||
"eslint": "^10.8.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"globals": "^17.9.0",
|
||||
"globals": "^17.10.0",
|
||||
"oxfmt": "^0.63.0",
|
||||
"tscw-config": "^1.1.2",
|
||||
"typescript": "^6.0.3",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
# pylint: disable=invalid-name
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pprint import pformat
|
||||
@@ -205,8 +206,86 @@ class QueryObject: # pylint: disable=too-many-instance-attributes
|
||||
def _set_post_processing(
|
||||
self, post_processing: list[dict[str, Any] | None] | None
|
||||
) -> None:
|
||||
post_processing = post_processing or []
|
||||
self.post_processing = [post_proc for post_proc in post_processing if post_proc]
|
||||
self.post_processing = [
|
||||
self._drop_unsupported_options(post_proc)
|
||||
for post_proc in post_processing or []
|
||||
if post_proc
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _drop_unsupported_options(post_proc: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Drop options that the post-processing operation no longer accepts.
|
||||
|
||||
A chart's ``query_context`` is written when the chart is saved and is
|
||||
never rewritten afterwards, while Explore rebuilds the query from
|
||||
``form_data`` at every render. A chart saved by an older version of
|
||||
Superset can therefore reference an option that has since been removed
|
||||
from the operation. ``exec_post_processing`` passes the stored options
|
||||
as keyword arguments, so that option raises a bare ``TypeError`` on
|
||||
every path that replays the stored ``query_context`` -- the chart data
|
||||
endpoint, alerts and reports, thumbnails, CSV export -- while the same
|
||||
chart still renders correctly in Explore.
|
||||
|
||||
Comparing against the signature avoids a hard-coded list of removed
|
||||
option names, which would need extending at each release.
|
||||
"""
|
||||
operation = post_proc.get("operation")
|
||||
function = (
|
||||
getattr(pandas_postprocessing, operation, None)
|
||||
if isinstance(operation, str)
|
||||
else None
|
||||
)
|
||||
if function is None:
|
||||
# A missing or unknown operation is left untouched, so that
|
||||
# exec_post_processing reports it as InvalidPostProcessingError.
|
||||
return post_proc
|
||||
|
||||
parameters = inspect.signature(function).parameters
|
||||
if any(
|
||||
parameter.kind is inspect.Parameter.VAR_KEYWORD
|
||||
for parameter in parameters.values()
|
||||
):
|
||||
return post_proc
|
||||
|
||||
# `exec_post_processing` calls the operation as `operation(df, **options)`,
|
||||
# so an option can only reach a parameter that a caller may fill by
|
||||
# keyword. That excludes the first parameter, which receives the
|
||||
# DataFrame positionally, and any positional-only or `*args` parameter.
|
||||
keyword_parameters = {
|
||||
name
|
||||
for position, (name, parameter) in enumerate(parameters.items())
|
||||
if position > 0
|
||||
and parameter.kind
|
||||
in (
|
||||
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
||||
inspect.Parameter.KEYWORD_ONLY,
|
||||
)
|
||||
}
|
||||
|
||||
options = post_proc.get("options") or {}
|
||||
unsupported = {key for key in options if key not in keyword_parameters}
|
||||
if not unsupported:
|
||||
return post_proc
|
||||
|
||||
# Logged at info: a chart saved before the option was removed hits this
|
||||
# on every render, so a warning would repeat for as long as the chart
|
||||
# is not resaved, without anything new to report.
|
||||
logger.info(
|
||||
"Dropping unsupported option(s) %s of post-processing operation "
|
||||
"`%s`. The chart's stored query_context predates the current "
|
||||
"signature of that operation.",
|
||||
sorted(unsupported),
|
||||
operation,
|
||||
)
|
||||
return {
|
||||
**post_proc,
|
||||
"options": {
|
||||
key: value
|
||||
for key, value in options.items()
|
||||
if key in keyword_parameters
|
||||
},
|
||||
}
|
||||
|
||||
def _init_series_columns(
|
||||
self,
|
||||
|
||||
@@ -673,6 +673,12 @@ DEFAULT_FEATURE_FLAGS: dict[str, bool] = {
|
||||
# Enables experimental chart plugins
|
||||
# @lifecycle: development
|
||||
"CHART_PLUGINS_EXPERIMENTAL": False,
|
||||
# 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.
|
||||
# @lifecycle: development
|
||||
"CSP_RUNTIME_ALLOWLIST": False,
|
||||
# Experimental PyArrow engine for CSV parsing (may have issues with dates/nulls)
|
||||
# @lifecycle: development
|
||||
"CSV_UPLOAD_PYARROW_ENGINE": False,
|
||||
@@ -2720,6 +2726,16 @@ CONTENT_SECURITY_POLICY_WARNING = True
|
||||
# images where the webpack build-time flag of the same name cannot be changed.
|
||||
SCARF_ANALYTICS = utils.cast_to_boolean(os.environ.get("SCARF_ANALYTICS", True))
|
||||
|
||||
# When the CSP_RUNTIME_ALLOWLIST feature flag is enabled, runtime allowlist
|
||||
# entries are merged into the response CSP header. To avoid a metadata DB hit on
|
||||
# every response, the allowlist is cached in-process for this many seconds. A
|
||||
# write through the REST API invalidates the cache in the worker that handled the
|
||||
# write; other workers pick up the change once their cached copy expires. Lower
|
||||
# this for faster cross-worker propagation, raise it to reduce DB load.
|
||||
CSP_RUNTIME_ALLOWLIST_CACHE_TTL = int(
|
||||
os.environ.get("CSP_RUNTIME_ALLOWLIST_CACHE_TTL", 30)
|
||||
)
|
||||
|
||||
# Do you want Talisman enabled?
|
||||
TALISMAN_ENABLED = utils.cast_to_boolean(os.environ.get("TALISMAN_ENABLED", True))
|
||||
|
||||
|
||||
@@ -957,7 +957,13 @@ class AnnotationDatasource(BaseDatasource):
|
||||
def get_query_str(self, query_obj: QueryObjectDict) -> str:
|
||||
raise NotImplementedError()
|
||||
|
||||
def values_for_column(self, column_name: str, limit: int = 10000) -> list[Any]:
|
||||
def values_for_column(
|
||||
self,
|
||||
column_name: str,
|
||||
limit: int = 10000,
|
||||
denormalize_column: bool = False,
|
||||
array_elements: bool = False,
|
||||
) -> list[Any]:
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
@@ -0,0 +1,312 @@
|
||||
# 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 logging
|
||||
from typing import Any
|
||||
|
||||
from flask import request, Response
|
||||
from flask_appbuilder.api import expose, protect, rison as parse_rison, safe
|
||||
from flask_appbuilder.models.sqla.interface import SQLAInterface
|
||||
from flask_babel import ngettext
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod
|
||||
from superset.csp_allowlist.schemas import (
|
||||
CSPAllowlistEntryPostSchema,
|
||||
CSPAllowlistEntryPutSchema,
|
||||
get_delete_ids_schema,
|
||||
openapi_spec_methods_override,
|
||||
)
|
||||
from superset.daos.csp import CSPAllowlistDAO
|
||||
from superset.extensions import db, event_logger
|
||||
from superset.models.csp import CSPAllowlistEntry
|
||||
from superset.security.csp import invalidate_csp_allowlist_cache
|
||||
from superset.utils.decorators import transaction
|
||||
from superset.views.base_api import (
|
||||
BaseSupersetModelRestApi,
|
||||
RelatedFieldFilter,
|
||||
statsd_metrics,
|
||||
)
|
||||
from superset.views.filters import BaseFilterRelatedUsers, FilterRelatedOwners
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CSPAllowlistRestApi(BaseSupersetModelRestApi):
|
||||
"""CRUD API for runtime Content Security Policy allowlist entries.
|
||||
|
||||
The ``CSPAllowlist`` view-menu is registered as admin-only (see
|
||||
``SupersetSecurityManager.ADMIN_ONLY_VIEW_MENUS``), so only Admins (or a
|
||||
custom role explicitly granted ``can_write on CSPAllowlist``) may mutate the
|
||||
allowlist. Mutations invalidate the per-worker CSP cache so the new policy
|
||||
takes effect without a server restart.
|
||||
"""
|
||||
|
||||
datamodel = SQLAInterface(CSPAllowlistEntry)
|
||||
|
||||
include_route_methods = RouteMethod.REST_MODEL_VIEW_CRUD_SET | {
|
||||
RouteMethod.RELATED,
|
||||
"bulk_delete", # not using RouteMethod since locally defined
|
||||
}
|
||||
class_permission_name = "CSPAllowlist"
|
||||
method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
|
||||
|
||||
resource_name = "csp_allowlist"
|
||||
allow_browser_login = True
|
||||
|
||||
show_columns = [
|
||||
"id",
|
||||
"domain",
|
||||
"directive",
|
||||
"description",
|
||||
"changed_on_delta_humanized",
|
||||
"changed_by.first_name",
|
||||
"changed_by.id",
|
||||
"changed_by.last_name",
|
||||
"created_by.first_name",
|
||||
"created_by.id",
|
||||
"created_by.last_name",
|
||||
]
|
||||
list_columns = [
|
||||
"id",
|
||||
"domain",
|
||||
"directive",
|
||||
"description",
|
||||
"created_on",
|
||||
"changed_on_delta_humanized",
|
||||
"changed_by.first_name",
|
||||
"changed_by.id",
|
||||
"changed_by.last_name",
|
||||
"created_by.first_name",
|
||||
"created_by.id",
|
||||
"created_by.last_name",
|
||||
]
|
||||
add_columns = ["domain", "directive", "description"]
|
||||
edit_columns = add_columns
|
||||
order_columns = ["domain", "directive", "changed_on", "created_on"]
|
||||
|
||||
add_model_schema = CSPAllowlistEntryPostSchema()
|
||||
edit_model_schema = CSPAllowlistEntryPutSchema()
|
||||
|
||||
allowed_rel_fields = {"created_by", "changed_by"}
|
||||
|
||||
apispec_parameter_schemas = {
|
||||
"get_delete_ids_schema": get_delete_ids_schema,
|
||||
}
|
||||
openapi_spec_tag = "CSP Allowlist"
|
||||
openapi_spec_methods = openapi_spec_methods_override
|
||||
|
||||
related_field_filters = {
|
||||
"changed_by": RelatedFieldFilter("first_name", FilterRelatedOwners),
|
||||
}
|
||||
base_related_field_filters = {
|
||||
"changed_by": [["id", BaseFilterRelatedUsers, lambda: []]],
|
||||
}
|
||||
|
||||
def post_add(self, item: CSPAllowlistEntry) -> None:
|
||||
invalidate_csp_allowlist_cache()
|
||||
|
||||
def post_update(self, item: CSPAllowlistEntry) -> None:
|
||||
invalidate_csp_allowlist_cache()
|
||||
|
||||
def post_delete(self, item: CSPAllowlistEntry) -> None:
|
||||
invalidate_csp_allowlist_cache()
|
||||
|
||||
@expose("/", methods=("POST",))
|
||||
@protect()
|
||||
@safe
|
||||
@statsd_metrics
|
||||
@event_logger.log_this_with_context(
|
||||
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.post",
|
||||
log_to_statsd=False,
|
||||
)
|
||||
def post(self) -> Response:
|
||||
"""Create a CSP allowlist entry.
|
||||
---
|
||||
post:
|
||||
summary: Create a CSP allowlist entry
|
||||
requestBody:
|
||||
description: CSP allowlist entry schema
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CSPAllowlistRestApi.post'
|
||||
responses:
|
||||
201:
|
||||
description: CSP allowlist entry created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: number
|
||||
result:
|
||||
$ref: '#/components/schemas/CSPAllowlistRestApi.post'
|
||||
400:
|
||||
$ref: '#/components/responses/400'
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
422:
|
||||
$ref: '#/components/responses/422'
|
||||
500:
|
||||
$ref: '#/components/responses/500'
|
||||
"""
|
||||
if not request.is_json:
|
||||
return self.response_400(message="Request is not JSON")
|
||||
try:
|
||||
item = self.add_model_schema.load(request.json)
|
||||
except ValidationError as error:
|
||||
return self.response_400(message=error.messages)
|
||||
entry = CSPAllowlistEntry(**item)
|
||||
self.pre_add(entry)
|
||||
entry = self._save_entry(entry)
|
||||
self.post_add(entry)
|
||||
return self.response(
|
||||
201,
|
||||
id=entry.id,
|
||||
result=self.add_model_schema.dump(entry, many=False),
|
||||
)
|
||||
|
||||
@transaction()
|
||||
def _save_entry(self, entry: CSPAllowlistEntry) -> CSPAllowlistEntry:
|
||||
db.session.add(entry)
|
||||
db.session.flush()
|
||||
return entry
|
||||
|
||||
@expose("/<int:pk>", methods=("PUT",))
|
||||
@protect()
|
||||
@safe
|
||||
@statsd_metrics
|
||||
@event_logger.log_this_with_context(
|
||||
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.put",
|
||||
log_to_statsd=False,
|
||||
)
|
||||
def put(self, pk: int) -> Response:
|
||||
"""Update a CSP allowlist entry.
|
||||
---
|
||||
put:
|
||||
summary: Update a CSP allowlist entry
|
||||
parameters:
|
||||
- in: path
|
||||
schema:
|
||||
type: integer
|
||||
name: pk
|
||||
requestBody:
|
||||
description: CSP allowlist entry schema
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CSPAllowlistRestApi.put'
|
||||
responses:
|
||||
200:
|
||||
description: CSP allowlist entry updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: number
|
||||
result:
|
||||
$ref: '#/components/schemas/CSPAllowlistRestApi.put'
|
||||
400:
|
||||
$ref: '#/components/responses/400'
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
422:
|
||||
$ref: '#/components/responses/422'
|
||||
500:
|
||||
$ref: '#/components/responses/500'
|
||||
"""
|
||||
entry = self.datamodel.get(pk, self._base_filters)
|
||||
if not entry:
|
||||
return self.response_404()
|
||||
if not request.is_json:
|
||||
return self.response_400(message="Request is not JSON")
|
||||
try:
|
||||
item = self.edit_model_schema.load(request.json)
|
||||
except ValidationError as error:
|
||||
return self.response_400(message=error.messages)
|
||||
for key, value in item.items():
|
||||
setattr(entry, key, value)
|
||||
self.pre_update(entry)
|
||||
entry = self._save_entry(entry)
|
||||
self.post_update(entry)
|
||||
return self.response(
|
||||
200,
|
||||
id=entry.id,
|
||||
result=self.edit_model_schema.dump(entry, many=False),
|
||||
)
|
||||
|
||||
@expose("/", methods=("DELETE",))
|
||||
@protect()
|
||||
@safe
|
||||
@statsd_metrics
|
||||
@event_logger.log_this_with_context(
|
||||
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.bulk_delete",
|
||||
log_to_statsd=False,
|
||||
)
|
||||
@parse_rison(get_delete_ids_schema)
|
||||
def bulk_delete(self, **kwargs: Any) -> Response:
|
||||
"""Bulk delete CSP allowlist entries.
|
||||
---
|
||||
delete:
|
||||
summary: Bulk delete CSP allowlist entries
|
||||
parameters:
|
||||
- in: query
|
||||
name: q
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/get_delete_ids_schema'
|
||||
responses:
|
||||
200:
|
||||
description: CSP allowlist entries bulk delete
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
422:
|
||||
$ref: '#/components/responses/422'
|
||||
500:
|
||||
$ref: '#/components/responses/500'
|
||||
"""
|
||||
item_ids = kwargs["rison"]
|
||||
entries = CSPAllowlistDAO.find_by_ids(item_ids)
|
||||
if not entries:
|
||||
return self.response_404()
|
||||
CSPAllowlistDAO.delete(entries)
|
||||
invalidate_csp_allowlist_cache()
|
||||
return self.response(
|
||||
200,
|
||||
message=ngettext(
|
||||
"Deleted %(num)d CSP allowlist entry",
|
||||
"Deleted %(num)d CSP allowlist entries",
|
||||
num=len(entries),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,113 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
from typing import Any
|
||||
|
||||
from flask_babel import gettext as _
|
||||
from marshmallow import fields, Schema, validates
|
||||
from marshmallow.exceptions import ValidationError
|
||||
|
||||
from superset.security.csp import (
|
||||
ALLOWED_DIRECTIVES,
|
||||
is_valid_csp_directive,
|
||||
is_valid_csp_origin,
|
||||
)
|
||||
|
||||
domain_description = (
|
||||
"A bare origin to allow, e.g. 'https://example.com' or "
|
||||
"'https://example.com:8443'. Wildcards, paths, query strings and fragments "
|
||||
"are rejected."
|
||||
)
|
||||
directive_description = (
|
||||
"The CSP directive to widen. Defaults to 'frame-src'. One of: "
|
||||
f"{', '.join(sorted(ALLOWED_DIRECTIVES))}."
|
||||
)
|
||||
|
||||
openapi_spec_methods_override = {
|
||||
"get": {"get": {"summary": "Get a CSP allowlist entry"}},
|
||||
"get_list": {
|
||||
"get": {
|
||||
"summary": "Get a list of CSP allowlist entries",
|
||||
"description": "Gets a list of runtime Content Security Policy "
|
||||
"allowlist entries, use Rison or JSON query parameters for "
|
||||
"filtering, sorting, pagination and for selecting specific "
|
||||
"columns and metadata.",
|
||||
}
|
||||
},
|
||||
"post": {"post": {"summary": "Create a CSP allowlist entry"}},
|
||||
"put": {"put": {"summary": "Update a CSP allowlist entry"}},
|
||||
"delete": {"delete": {"summary": "Delete a CSP allowlist entry"}},
|
||||
"info": {"get": {"summary": "Get metadata information about this API resource"}},
|
||||
}
|
||||
|
||||
get_delete_ids_schema = {"type": "array", "items": {"type": "integer"}}
|
||||
|
||||
|
||||
def validate_origin(value: str) -> None:
|
||||
if not is_valid_csp_origin(value):
|
||||
raise ValidationError(
|
||||
_(
|
||||
"'%(value)s' is not a valid origin. Provide a bare "
|
||||
"scheme://host[:port] value with no wildcard, path, query or "
|
||||
"fragment.",
|
||||
value=value,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def validate_directive(value: str) -> None:
|
||||
if not is_valid_csp_directive(value):
|
||||
raise ValidationError(
|
||||
_(
|
||||
"'%(value)s' is not an allowed CSP directive. Allowed: %(allowed)s.",
|
||||
value=value,
|
||||
allowed=", ".join(sorted(ALLOWED_DIRECTIVES)),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class CSPAllowlistEntryPostSchema(Schema):
|
||||
domain = fields.String(required=True, metadata={"description": domain_description})
|
||||
directive = fields.String(
|
||||
required=False,
|
||||
load_default="frame-src",
|
||||
metadata={"description": directive_description},
|
||||
)
|
||||
description = fields.String(required=False, allow_none=True)
|
||||
|
||||
@validates("domain")
|
||||
def validate_domain(self, value: str, **kwargs: Any) -> None:
|
||||
validate_origin(value)
|
||||
|
||||
@validates("directive")
|
||||
def validate_directive_field(self, value: str, **kwargs: Any) -> None:
|
||||
validate_directive(value)
|
||||
|
||||
|
||||
class CSPAllowlistEntryPutSchema(Schema):
|
||||
domain = fields.String(required=False, metadata={"description": domain_description})
|
||||
directive = fields.String(
|
||||
required=False, metadata={"description": directive_description}
|
||||
)
|
||||
description = fields.String(required=False, allow_none=True)
|
||||
|
||||
@validates("domain")
|
||||
def validate_domain(self, value: str, **kwargs: Any) -> None:
|
||||
validate_origin(value)
|
||||
|
||||
@validates("directive")
|
||||
def validate_directive_field(self, value: str, **kwargs: Any) -> None:
|
||||
validate_directive(value)
|
||||
@@ -0,0 +1,22 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
from superset.daos.base import BaseDAO
|
||||
from superset.models.csp import CSPAllowlistEntry
|
||||
|
||||
|
||||
class CSPAllowlistDAO(BaseDAO[CSPAllowlistEntry]):
|
||||
pass
|
||||
@@ -133,6 +133,9 @@ class DatasourceRestApi(BaseSupersetApi):
|
||||
|
||||
row_limit = apply_max_row_limit(app.config["FILTER_SELECT_ROW_LIMIT"])
|
||||
denormalize_column = not datasource.normalize_columns
|
||||
# Element-level operators (Contains any / Contains all) request the
|
||||
# distinct array *elements* rather than distinct whole arrays.
|
||||
array_elements = parse_boolean_string(request.args.get("array_elements"))
|
||||
|
||||
# Cache distinct column-value results so a dashboard with many filters
|
||||
# backed by the same (often heavy) virtual dataset doesn't re-execute
|
||||
@@ -165,6 +168,7 @@ class DatasourceRestApi(BaseSupersetApi):
|
||||
"col": column_name,
|
||||
"limit": row_limit,
|
||||
"denorm": denormalize_column,
|
||||
"elements": array_elements,
|
||||
"rls": security_manager.get_rls_cache_key(datasource),
|
||||
"changed_on": str(getattr(datasource, "changed_on", "")),
|
||||
},
|
||||
@@ -189,6 +193,7 @@ class DatasourceRestApi(BaseSupersetApi):
|
||||
column_name=column_name,
|
||||
limit=row_limit,
|
||||
denormalize_column=denormalize_column,
|
||||
array_elements=array_elements,
|
||||
)
|
||||
except KeyError:
|
||||
return self.response(
|
||||
|
||||
@@ -55,7 +55,13 @@ from sqlalchemy.engine.reflection import Inspector
|
||||
from sqlalchemy.engine.url import URL
|
||||
from sqlalchemy.ext.compiler import compiles
|
||||
from sqlalchemy.sql import literal_column, quoted_name, text
|
||||
from sqlalchemy.sql.expression import BinaryExpression, ColumnClause, Select, TextClause
|
||||
from sqlalchemy.sql.expression import (
|
||||
BinaryExpression,
|
||||
ColumnClause,
|
||||
ColumnElement,
|
||||
Select,
|
||||
TextClause,
|
||||
)
|
||||
from sqlalchemy.types import TypeEngine
|
||||
|
||||
from superset import db
|
||||
@@ -528,6 +534,11 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
|
||||
time_groupby_inline = False
|
||||
limit_method = LimitMethod.FORCE_LIMIT
|
||||
supports_multivalues_insert = False
|
||||
# Whether this engine supports first-class multi-value (array-typed) columns.
|
||||
# When True, array columns are classified as ``GenericDataType.MULTI_VALUE`` and
|
||||
# the ``array_*`` capability methods below must be implemented. Defaults to
|
||||
# False so engines that have not opted in keep treating arrays as strings.
|
||||
supports_multivalue_columns = False
|
||||
allows_joins = True
|
||||
allows_subqueries = True
|
||||
allows_alias_in_select = True
|
||||
@@ -2571,6 +2582,105 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
|
||||
logger.error(ex, exc_info=True)
|
||||
raise
|
||||
|
||||
@classmethod
|
||||
def array_contains_any(cls, col: ColumnElement, values: list[Any]) -> ColumnElement:
|
||||
"""
|
||||
Build a boolean expression testing whether array column ``col`` contains
|
||||
**any** of ``values`` (element-level membership, like ``IN``). Engines
|
||||
that set ``supports_multivalue_columns = True`` must override this with
|
||||
their native function (e.g. ClickHouse ``hasAny``).
|
||||
|
||||
:param col: SQLAlchemy column element for the array column
|
||||
:param values: element values to look for inside the array
|
||||
:return: a SQLAlchemy boolean expression
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{cls.engine} does not support multi-value (array) columns"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def array_contains_all(cls, col: ColumnElement, values: list[Any]) -> ColumnElement:
|
||||
"""
|
||||
Build a boolean expression testing whether array column ``col`` contains
|
||||
**all** of ``values``. Engines that set
|
||||
``supports_multivalue_columns = True`` must override this with their
|
||||
native function (e.g. ClickHouse ``hasAll``).
|
||||
|
||||
:param col: SQLAlchemy column element for the array column
|
||||
:param values: element values that must all be present
|
||||
:return: a SQLAlchemy boolean expression
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{cls.engine} does not support multi-value (array) columns"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def array_length(cls, col: ColumnElement) -> ColumnElement:
|
||||
"""
|
||||
Build a numeric expression returning the number of elements in array
|
||||
column ``col``. Engines that set ``supports_multivalue_columns = True``
|
||||
must override this with their native array-length function. Used both for
|
||||
the ``Length`` filter and the ``Is empty`` / ``Is not empty`` operators.
|
||||
|
||||
:param col: SQLAlchemy column element for the array column
|
||||
:return: a SQLAlchemy numeric expression
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{cls.engine} does not support multi-value (array) columns"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def array_literal(cls, values: list[Any]) -> ColumnElement:
|
||||
"""
|
||||
Build an array-literal expression from ``values`` (e.g. ClickHouse
|
||||
``array(v1, v2)`` == ``[v1, v2]``). Used for the whole-array (column-
|
||||
level) operators ``=`` / ``!=`` / ``IN`` / ``NOT IN`` where the array is
|
||||
compared as a single value. Engines that set
|
||||
``supports_multivalue_columns = True`` must override this.
|
||||
|
||||
:param values: element values that make up the array
|
||||
:return: a SQLAlchemy array-literal expression
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{cls.engine} does not support multi-value (array) columns"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def array_explode(cls, col: ColumnElement) -> ColumnElement:
|
||||
"""
|
||||
Build an expression that expands array column ``col`` into one row per
|
||||
element (e.g. ClickHouse ``arrayJoin``). Used to source **element-level**
|
||||
value suggestions (``SELECT DISTINCT array_explode(col)``) for the
|
||||
``Contains any`` / ``Contains all`` filter operators, so the picker offers
|
||||
individual elements rather than whole arrays. Engines that set
|
||||
``supports_multivalue_columns = True`` must override this.
|
||||
|
||||
:param col: SQLAlchemy column element for the array column
|
||||
:return: a SQLAlchemy expression yielding one element per row
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{cls.engine} does not support multi-value (array) columns"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_array_element_type( # pylint: disable=unused-argument
|
||||
cls, native_type: str | None
|
||||
) -> GenericDataType | None:
|
||||
"""
|
||||
Return the generic type of an array column's **element** type, derived
|
||||
from its native type string (e.g. ClickHouse ``Array(Int32)`` ->
|
||||
``NUMERIC``), or ``None`` when the engine has no array support or the
|
||||
element type cannot be resolved.
|
||||
|
||||
Callers use this to coerce filter values to the element type before
|
||||
building array expressions, so, for example, a ``Contains any`` filter on
|
||||
a numeric array compares against numbers rather than quoted strings.
|
||||
|
||||
:param native_type: native column type string of the array column
|
||||
:return: the element's :class:`GenericDataType`, or ``None``
|
||||
"""
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_column_spec( # pylint: disable=unused-argument
|
||||
cls,
|
||||
|
||||
@@ -26,8 +26,9 @@ from flask import current_app as app
|
||||
from flask_babel import gettext as __
|
||||
from marshmallow import fields, Schema
|
||||
from marshmallow.validate import Range
|
||||
from sqlalchemy import types
|
||||
from sqlalchemy import func, types
|
||||
from sqlalchemy.engine.url import URL
|
||||
from sqlalchemy.sql.expression import ColumnElement
|
||||
from urllib3.exceptions import NewConnectionError
|
||||
|
||||
from superset.databases.utils import make_url_safe
|
||||
@@ -55,6 +56,7 @@ class ClickHouseBaseEngineSpec(BaseEngineSpec):
|
||||
|
||||
time_groupby_inline = True
|
||||
supports_multivalues_insert = True
|
||||
supports_multivalue_columns = True
|
||||
|
||||
# ClickHouse doesn't support IS true/false syntax, use = true/false instead
|
||||
use_equality_for_boolean_filters = True
|
||||
@@ -128,12 +130,18 @@ class ClickHouseBaseEngineSpec(BaseEngineSpec):
|
||||
|
||||
column_type_mappings = (
|
||||
(
|
||||
re.compile(r".*Enum.*", re.IGNORECASE),
|
||||
# Anchor to the start so only top-level arrays match. This must be
|
||||
# ordered before the ``Enum`` entry below: ``Array(Enum8(...))`` is a
|
||||
# real array and should classify as MULTI_VALUE, not STRING. The
|
||||
# anchor also prevents over-matching nested arrays such as
|
||||
# ``Map(String, Array(String))`` or ``Tuple(Array(String))``, which
|
||||
# are not themselves array columns and must keep their own type.
|
||||
re.compile(r"^Array\(", re.IGNORECASE),
|
||||
types.String(),
|
||||
GenericDataType.STRING,
|
||||
GenericDataType.MULTI_VALUE,
|
||||
),
|
||||
(
|
||||
re.compile(r".*Array.*", re.IGNORECASE),
|
||||
re.compile(r".*Enum.*", re.IGNORECASE),
|
||||
types.String(),
|
||||
GenericDataType.STRING,
|
||||
),
|
||||
@@ -174,6 +182,56 @@ class ClickHouseBaseEngineSpec(BaseEngineSpec):
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def array_contains_any(cls, col: ColumnElement, values: list[Any]) -> ColumnElement:
|
||||
# ClickHouse: hasAny(arr, [v1, v2]) -> 1 if arr shares any element.
|
||||
# func.array(*values) renders as array(v1, v2) == [v1, v2].
|
||||
return func.hasAny(col, func.array(*values))
|
||||
|
||||
@classmethod
|
||||
def array_contains_all(cls, col: ColumnElement, values: list[Any]) -> ColumnElement:
|
||||
# ClickHouse: hasAll(arr, [v1, v2]) -> 1 if arr contains all elements.
|
||||
return func.hasAll(col, func.array(*values))
|
||||
|
||||
@classmethod
|
||||
def array_length(cls, col: ColumnElement) -> ColumnElement:
|
||||
# ClickHouse: length(arr) -> number of elements
|
||||
return func.length(col)
|
||||
|
||||
@classmethod
|
||||
def array_literal(cls, values: list[Any]) -> ColumnElement:
|
||||
# ClickHouse: array(v1, v2) is equivalent to the literal [v1, v2].
|
||||
return func.array(*values)
|
||||
|
||||
@classmethod
|
||||
def array_explode(cls, col: ColumnElement) -> ColumnElement:
|
||||
# ClickHouse: arrayJoin(arr) yields one row per element, so
|
||||
# SELECT DISTINCT arrayJoin(arr) returns the distinct elements.
|
||||
return func.arrayJoin(col)
|
||||
|
||||
# Matches the element type inside a top-level ``Array(...)`` column, e.g.
|
||||
# ``Array(Int32)`` -> ``Int32``, ``Array(Nullable(String))`` -> ``String``.
|
||||
_ARRAY_ELEMENT_RE = re.compile(r"^Array\((?P<inner>.+)\)$", re.IGNORECASE)
|
||||
# Element-type wrappers that don't change the underlying generic type.
|
||||
_ELEMENT_WRAPPER_RE = re.compile(
|
||||
r"^(?:Nullable|LowCardinality)\((?P<inner>.+)\)$", re.IGNORECASE
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_array_element_type(cls, native_type: str | None) -> GenericDataType | None:
|
||||
if not native_type:
|
||||
return None
|
||||
match = cls._ARRAY_ELEMENT_RE.match(native_type.strip())
|
||||
if not match:
|
||||
return None
|
||||
inner = match.group("inner").strip()
|
||||
# Peel wrappers (Nullable/LowCardinality) that don't alter the generic
|
||||
# type so the inner scalar type drives classification.
|
||||
while wrapper := cls._ELEMENT_WRAPPER_RE.match(inner):
|
||||
inner = wrapper.group("inner").strip()
|
||||
spec = cls.get_column_spec(inner)
|
||||
return spec.generic_type if spec else None
|
||||
|
||||
@classmethod
|
||||
def epoch_to_dttm(cls) -> str:
|
||||
return "{col}"
|
||||
|
||||
@@ -186,6 +186,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
||||
from superset.cachekeys.api import CacheRestApi
|
||||
from superset.charts.api import ChartRestApi
|
||||
from superset.charts.data.api import ChartDataRestApi
|
||||
from superset.csp_allowlist.api import CSPAllowlistRestApi
|
||||
from superset.css_templates.api import CssTemplateRestApi
|
||||
from superset.dashboards.api import DashboardRestApi
|
||||
from superset.dashboards.filter_state.api import DashboardFilterStateRestApi
|
||||
@@ -277,6 +278,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
||||
appbuilder.add_api(CacheRestApi)
|
||||
appbuilder.add_api(ChartRestApi)
|
||||
appbuilder.add_api(ChartDataRestApi)
|
||||
appbuilder.add_api(CSPAllowlistRestApi)
|
||||
appbuilder.add_api(CssTemplateRestApi)
|
||||
appbuilder.add_api(ThemeRestApi)
|
||||
appbuilder.add_api(CurrentUserRestApi)
|
||||
@@ -1523,6 +1525,18 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
||||
# Flask-Compress
|
||||
Compress(self.superset_app)
|
||||
|
||||
# Runtime CSP allowlist merge. Registered BEFORE Talisman so that, since
|
||||
# Flask runs after_request callbacks in reverse registration order, this
|
||||
# runs AFTER Talisman has set the CSP header and can widen it with the
|
||||
# operator-curated allowlist. Inert unless CSP_RUNTIME_ALLOWLIST is on.
|
||||
from flask import Response
|
||||
|
||||
@self.superset_app.after_request
|
||||
def merge_runtime_csp_allowlist(response: Response) -> Response:
|
||||
from superset.security.csp import apply_runtime_csp_allowlist
|
||||
|
||||
return apply_runtime_csp_allowlist(response)
|
||||
|
||||
# Talisman
|
||||
talisman_enabled = self.config["TALISMAN_ENABLED"]
|
||||
talisman_config = (
|
||||
|
||||
@@ -63,6 +63,7 @@ from superset.mcp_service.utils import (
|
||||
sanitize_for_llm_context,
|
||||
)
|
||||
from superset.mcp_service.utils.response_utils import humanize_timestamp
|
||||
from superset.sql.parse import has_aggregate
|
||||
from superset.utils import json
|
||||
|
||||
|
||||
@@ -386,13 +387,27 @@ class CreateDatasetMetric(BaseModel):
|
||||
"""Metric definition for dataset creation."""
|
||||
|
||||
metric_name: str = Field(..., description="Name of the metric")
|
||||
expression: str = Field(..., description="SQL expression for the metric")
|
||||
expression: str = Field(
|
||||
...,
|
||||
description="Aggregate SQL expression for the metric, e.g. SUM(amount)",
|
||||
)
|
||||
verbose_name: str | None = None
|
||||
description: str | None = None
|
||||
metric_type: str | None = None
|
||||
d3format: str | None = None
|
||||
warning_text: str | None = None
|
||||
|
||||
@field_validator("expression")
|
||||
@classmethod
|
||||
def expression_must_aggregate(cls, value: str) -> str:
|
||||
if not has_aggregate(value):
|
||||
raise ValueError(
|
||||
"saved metrics must aggregate rows; wrap a row-level column in "
|
||||
"an aggregate such as MAX(column), or omit the saved metric and "
|
||||
"use the dataset column directly"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
class CreateDatasetCalculatedColumn(BaseModel):
|
||||
"""Calculated column definition for dataset creation."""
|
||||
|
||||
@@ -21,6 +21,7 @@ from typing import Any
|
||||
from fastmcp import Context
|
||||
from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
|
||||
from superset.exceptions import SupersetGenericDBErrorException
|
||||
from superset.extensions import event_logger
|
||||
from superset.mcp_service.dataset.schemas import (
|
||||
CreateVirtualDatasetRequest,
|
||||
@@ -67,14 +68,17 @@ def _cleanup_failed_dataset(dataset_id: int) -> None:
|
||||
|
||||
|
||||
def _update_virtual_dataset(dataset_id: int, update_props: dict[str, Any]) -> Any:
|
||||
from superset.commands.dataset.exceptions import DatasetUpdateFailedError
|
||||
from superset.commands.dataset.exceptions import (
|
||||
DatasetInvalidError,
|
||||
DatasetUpdateFailedError,
|
||||
)
|
||||
from superset.commands.dataset.update import UpdateDatasetCommand
|
||||
|
||||
try:
|
||||
return UpdateDatasetCommand(dataset_id, update_props).run()
|
||||
except Exception as exc:
|
||||
_cleanup_failed_dataset(dataset_id)
|
||||
if not isinstance(exc, DatasetUpdateFailedError):
|
||||
if not isinstance(exc, (DatasetInvalidError, DatasetUpdateFailedError)):
|
||||
raise DatasetUpdateFailedError() from exc
|
||||
raise
|
||||
|
||||
@@ -89,7 +93,7 @@ def _update_virtual_dataset(dataset_id: int, update_props: dict[str, Any]) -> An
|
||||
destructiveHint=False,
|
||||
),
|
||||
)
|
||||
async def create_virtual_dataset(
|
||||
async def create_virtual_dataset( # noqa: C901
|
||||
request: CreateVirtualDatasetRequest, ctx: Context
|
||||
) -> CreateVirtualDatasetResponse:
|
||||
"""Save a SQL query as a virtual dataset so it can be charted.
|
||||
@@ -213,6 +217,18 @@ async def create_virtual_dataset(
|
||||
url=None,
|
||||
error=f"Failed to update dataset metadata (creation rolled back): {exc}",
|
||||
)
|
||||
except SupersetGenericDBErrorException as exc:
|
||||
logger.warning("Virtual dataset SQL validation failed", exc_info=True)
|
||||
await ctx.warning(f"Virtual dataset SQL failed validation: {exc}")
|
||||
return CreateVirtualDatasetResponse(
|
||||
id=None,
|
||||
dataset_name=request.dataset_name,
|
||||
sql=request.sql,
|
||||
database_id=request.database_id,
|
||||
columns=[],
|
||||
url=None,
|
||||
error=f"Dataset SQL could not be executed: {exc}",
|
||||
)
|
||||
except Exception as exc:
|
||||
await ctx.error(
|
||||
f"Unexpected error creating virtual dataset: {type(exc).__name__}: {exc}"
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# 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.
|
||||
"""add csp_allowlist table
|
||||
|
||||
Creates the ``csp_allowlist`` table backing runtime Content Security Policy
|
||||
"punched holes". Each row widens a single CSP directive (``frame-src`` by
|
||||
default) to allow one additional origin. The table is only consulted when the
|
||||
``CSP_RUNTIME_ALLOWLIST`` feature flag is enabled.
|
||||
|
||||
Revision ID: 4a50792bd265
|
||||
Revises: 3a8e6f2c1b95
|
||||
Create Date: 2026-06-26 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy_utils import UUIDType
|
||||
|
||||
from superset.migrations.shared.utils import (
|
||||
create_fks_for_table,
|
||||
create_table,
|
||||
drop_table,
|
||||
)
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "4a50792bd265"
|
||||
down_revision = "3a8e6f2c1b95"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
create_table(
|
||||
"csp_allowlist",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("uuid", UUIDType(binary=True), nullable=True, unique=True),
|
||||
sa.Column("domain", sa.String(length=255), nullable=False),
|
||||
sa.Column(
|
||||
"directive",
|
||||
sa.String(length=64),
|
||||
nullable=False,
|
||||
server_default="frame-src",
|
||||
),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
# AuditMixinNullable columns
|
||||
sa.Column("created_on", sa.DateTime(), nullable=True),
|
||||
sa.Column("changed_on", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_by_fk", sa.Integer(), nullable=True),
|
||||
sa.Column("changed_by_fk", sa.Integer(), nullable=True),
|
||||
sa.UniqueConstraint(
|
||||
"domain", "directive", name="uq_csp_allowlist_domain_directive"
|
||||
),
|
||||
)
|
||||
create_fks_for_table(
|
||||
"fk_csp_allowlist_created_by_fk_ab_user",
|
||||
"csp_allowlist",
|
||||
"ab_user",
|
||||
["created_by_fk"],
|
||||
["id"],
|
||||
)
|
||||
create_fks_for_table(
|
||||
"fk_csp_allowlist_changed_by_fk_ab_user",
|
||||
"csp_allowlist",
|
||||
"ab_user",
|
||||
["changed_by_fk"],
|
||||
["id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
drop_table("csp_allowlist")
|
||||
@@ -0,0 +1,55 @@
|
||||
# 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.
|
||||
"""Models backing the runtime Content Security Policy (CSP) allowlist."""
|
||||
|
||||
from flask_appbuilder import Model
|
||||
from sqlalchemy import Column, Integer, String, Text, UniqueConstraint
|
||||
|
||||
from superset.models.helpers import AuditMixinNullable, UUIDMixin
|
||||
|
||||
# Default CSP directive a hole is punched into. ``frame-src`` governs which
|
||||
# origins may be embedded in an <iframe>, which is the primary use case for the
|
||||
# allowlist (the first-class dashboard iframe component).
|
||||
DEFAULT_CSP_DIRECTIVE = "frame-src"
|
||||
|
||||
|
||||
class CSPAllowlistEntry(AuditMixinNullable, UUIDMixin, Model):
|
||||
"""A runtime "punched hole" in the Content Security Policy.
|
||||
|
||||
Each row widens a single CSP directive (``frame-src`` by default) to allow a
|
||||
single additional origin. Entries are merged into the response CSP header at
|
||||
request time, but only when the ``CSP_RUNTIME_ALLOWLIST`` feature flag is
|
||||
enabled, so operators retain full control over whether the static, deploy-time
|
||||
policy can be overridden at runtime at all.
|
||||
"""
|
||||
|
||||
__tablename__ = "csp_allowlist"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"domain", "directive", name="uq_csp_allowlist_domain_directive"
|
||||
),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
# A bare origin, e.g. ``https://example.com`` or ``https://example.com:8443``.
|
||||
# Never a wildcard, path, query or fragment — see ``is_valid_csp_origin``.
|
||||
domain = Column(String(255), nullable=False)
|
||||
directive = Column(String(64), nullable=False, default=DEFAULT_CSP_DIRECTIVE)
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<CSPAllowlistEntry {self.directive} {self.domain}>"
|
||||
+207
-3
@@ -19,6 +19,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import builtins
|
||||
import copy
|
||||
import dataclasses
|
||||
@@ -417,6 +418,52 @@ UUID_NATIVE_TYPE_RE: re.Pattern[str] = re.compile(
|
||||
)
|
||||
|
||||
|
||||
def parse_array_literal(value: Any) -> list[Any]:
|
||||
"""
|
||||
Parse a user-entered array literal (e.g. ``['a', 'b']`` or ``[1, 2]``) into a
|
||||
list of elements, for the whole-array (column-level) array operators.
|
||||
|
||||
Accepts either an actual list/tuple, a bracketed literal string (parsed with
|
||||
``ast.literal_eval``), or a plain scalar (wrapped into a single-element list).
|
||||
Falls back to a single-element list when the string is not a valid literal.
|
||||
"""
|
||||
if isinstance(value, (list, tuple)):
|
||||
return list(value)
|
||||
if isinstance(value, str):
|
||||
stripped = value.strip()
|
||||
if stripped.startswith("[") and stripped.endswith("]"):
|
||||
try:
|
||||
parsed = ast.literal_eval(stripped)
|
||||
except (ValueError, SyntaxError):
|
||||
parsed = None
|
||||
if isinstance(parsed, (list, tuple)):
|
||||
return list(parsed)
|
||||
return [value]
|
||||
|
||||
|
||||
def coerce_array_values(
|
||||
values: list[Any], element_type: Optional[utils.GenericDataType]
|
||||
) -> list[Any]:
|
||||
"""
|
||||
Coerce array-element ``values`` to the array column's element type so the
|
||||
emitted literal matches the column. Array columns map to a SQLAlchemy
|
||||
``String`` type, so values arrive as strings and would otherwise build
|
||||
string literals (e.g. ``array('5')``) that fail against a numeric array on
|
||||
the server. Numeric elements are cast to numbers and boolean elements to
|
||||
booleans; every other element type (string, temporal, enum, unknown) is left
|
||||
untouched.
|
||||
|
||||
:param values: element values entered for an array filter
|
||||
:param element_type: the array's element :class:`GenericDataType`, or None
|
||||
:return: the coerced values
|
||||
"""
|
||||
if element_type == utils.GenericDataType.NUMERIC:
|
||||
return [utils.cast_to_num(v) if isinstance(v, str) else v for v in values]
|
||||
if element_type == utils.GenericDataType.BOOLEAN:
|
||||
return [utils.cast_to_boolean(v) if isinstance(v, str) else v for v in values]
|
||||
return values
|
||||
|
||||
|
||||
def is_uuid_native_type(native_type: Optional[str]) -> bool:
|
||||
"""
|
||||
Return True if a native column type represents a UUID.
|
||||
@@ -3652,6 +3699,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
column_name: str,
|
||||
limit: int = 10000,
|
||||
denormalize_column: bool = False,
|
||||
array_elements: bool = False,
|
||||
) -> list[Any]:
|
||||
# denormalize column name before querying for values
|
||||
# unless disabled in the dataset configuration
|
||||
@@ -3666,13 +3714,25 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
tp = self.get_template_processor()
|
||||
tbl, cte = self.get_from_clause(tp)
|
||||
|
||||
db_engine_spec = self.database.db_engine_spec
|
||||
value_expr = target_col.get_sqla_col(template_processor=tp)
|
||||
# For element-level operators (Contains any / Contains all) on a
|
||||
# multi-value (array) column, suggest the distinct **elements** rather
|
||||
# than distinct whole arrays by expanding the array first (e.g. ClickHouse
|
||||
# arrayJoin). Only when the engine supports arrays and the column is
|
||||
# actually an array column; otherwise fall back to whole-value suggestions.
|
||||
if array_elements and db_engine_spec.supports_multivalue_columns:
|
||||
col_spec = db_engine_spec.get_column_spec(native_type=target_col.type)
|
||||
if col_spec and col_spec.generic_type == GenericDataType.MULTI_VALUE:
|
||||
value_expr = db_engine_spec.array_explode(value_expr)
|
||||
|
||||
qry = (
|
||||
sa.select(
|
||||
# The alias (label) here is important because some dialects will
|
||||
# automatically add a random alias to the projection because of the
|
||||
# call to DISTINCT; others will uppercase the column names. This
|
||||
# gives us a deterministic column name in the dataframe.
|
||||
target_col.get_sqla_col(template_processor=tp).label("column_values")
|
||||
value_expr.label("column_values")
|
||||
)
|
||||
.select_from(tbl)
|
||||
.distinct()
|
||||
@@ -4359,7 +4419,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
elif is_adhoc_column(flt_col):
|
||||
try:
|
||||
sqla_col, adhoc_generic_type = self.adhoc_column_to_sqla(
|
||||
flt_col,
|
||||
cast("AdhocColumn", flt_col),
|
||||
force_type_check=True,
|
||||
template_processor=template_processor,
|
||||
)
|
||||
@@ -4433,9 +4493,21 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
sqla_col = Grouping(sqla_col)
|
||||
col_type = col_obj.type if col_obj else None
|
||||
col_spec = db_engine_spec.get_column_spec(native_type=col_type)
|
||||
is_multivalue_col = bool(
|
||||
col_spec and col_spec.generic_type == GenericDataType.MULTI_VALUE
|
||||
)
|
||||
# Element type of an array column (e.g. Array(Int32) -> NUMERIC),
|
||||
# used to coerce filter values before building array expressions.
|
||||
array_element_type = (
|
||||
db_engine_spec.get_array_element_type(col_type)
|
||||
if is_multivalue_col
|
||||
else None
|
||||
)
|
||||
is_list_target = op in (
|
||||
utils.FilterOperator.IN,
|
||||
utils.FilterOperator.NOT_IN,
|
||||
utils.FilterOperator.CONTAINS_ANY,
|
||||
utils.FilterOperator.CONTAINS_ALL,
|
||||
)
|
||||
|
||||
col_advanced_data_type = col_obj.advanced_data_type if col_obj else ""
|
||||
@@ -4490,7 +4562,56 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
sqla_col, op, bus_resp["values"]
|
||||
)
|
||||
)
|
||||
elif is_list_target:
|
||||
elif is_multivalue_col and op in {
|
||||
utils.FilterOperator.EQUALS,
|
||||
utils.FilterOperator.NOT_EQUALS,
|
||||
utils.FilterOperator.IN,
|
||||
utils.FilterOperator.NOT_IN,
|
||||
}:
|
||||
# Whole-array (column-level) comparison against array
|
||||
# literal(s). The value is a pasted array literal like
|
||||
# ``['a', 'b']`` (parsed into elements): ``col = ['a', 'b']``
|
||||
# for = / !=; for IN / NOT IN each entered value is one such
|
||||
# array literal (``col IN (['a'], ['b'])``).
|
||||
if op in {
|
||||
utils.FilterOperator.EQUALS,
|
||||
utils.FilterOperator.NOT_EQUALS,
|
||||
}:
|
||||
literal = db_engine_spec.array_literal(
|
||||
coerce_array_values(
|
||||
parse_array_literal(val), array_element_type
|
||||
)
|
||||
)
|
||||
cond = (
|
||||
sqla_col != literal
|
||||
if op == utils.FilterOperator.NOT_EQUALS
|
||||
else sqla_col == literal
|
||||
)
|
||||
else:
|
||||
candidates: list[Any] = (
|
||||
list(val) if isinstance(val, (list, tuple)) else [val]
|
||||
)
|
||||
cond = sqla_col.in_(
|
||||
[
|
||||
db_engine_spec.array_literal(
|
||||
coerce_array_values(
|
||||
parse_array_literal(candidate),
|
||||
array_element_type,
|
||||
)
|
||||
)
|
||||
for candidate in candidates
|
||||
]
|
||||
)
|
||||
if op == utils.FilterOperator.NOT_IN:
|
||||
cond = ~cond
|
||||
target_clause_list.append(cond)
|
||||
elif op in {
|
||||
utils.FilterOperator.IN,
|
||||
utils.FilterOperator.NOT_IN,
|
||||
}:
|
||||
# CONTAINS_ANY/CONTAINS_ALL also produce a list ``eq`` (they
|
||||
# are in ``is_list_target``), but are element-level array ops
|
||||
# handled by their own branch below — not IN.
|
||||
assert isinstance(eq, (tuple, list))
|
||||
if len(eq) == 0:
|
||||
raise QueryObjectValidationError(
|
||||
@@ -4529,6 +4650,57 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
target_clause_list.append(
|
||||
db_engine_spec.handle_null_filter(sqla_col, op)
|
||||
)
|
||||
elif op in {
|
||||
utils.FilterOperator.IS_EMPTY,
|
||||
utils.FilterOperator.IS_NOT_EMPTY,
|
||||
}:
|
||||
# Element-level array operators: length(col) == 0 / > 0.
|
||||
if target_generic_type != GenericDataType.MULTI_VALUE:
|
||||
raise QueryObjectValidationError(
|
||||
_(
|
||||
"The %(op)s operator is only supported for "
|
||||
"multi-value (array) columns.",
|
||||
op=op,
|
||||
)
|
||||
)
|
||||
length_expr = db_engine_spec.array_length(sqla_col)
|
||||
if op == utils.FilterOperator.IS_EMPTY:
|
||||
target_clause_list.append(length_expr == 0)
|
||||
else:
|
||||
target_clause_list.append(length_expr > 0)
|
||||
elif op in {
|
||||
utils.FilterOperator.LENGTH_EQUALS,
|
||||
utils.FilterOperator.LENGTH_GREATER_THAN,
|
||||
utils.FilterOperator.LENGTH_LESS_THAN,
|
||||
utils.FilterOperator.LENGTH_GREATER_THAN_OR_EQUALS,
|
||||
utils.FilterOperator.LENGTH_LESS_THAN_OR_EQUALS,
|
||||
}:
|
||||
# Length filter: compare the array's element count to a
|
||||
# number, e.g. length(col) > 2.
|
||||
if target_generic_type != GenericDataType.MULTI_VALUE:
|
||||
raise QueryObjectValidationError(
|
||||
_(
|
||||
"The %(op)s operator is only supported for "
|
||||
"multi-value (array) columns.",
|
||||
op=op,
|
||||
)
|
||||
)
|
||||
number = utils.cast_to_num(eq) # type: ignore[arg-type]
|
||||
if number is None:
|
||||
raise QueryObjectValidationError(
|
||||
_("The Length filter requires a numeric value.")
|
||||
)
|
||||
length_expr = db_engine_spec.array_length(sqla_col)
|
||||
length_comparisons = {
|
||||
utils.FilterOperator.LENGTH_EQUALS: length_expr == number,
|
||||
utils.FilterOperator.LENGTH_GREATER_THAN: length_expr > number,
|
||||
utils.FilterOperator.LENGTH_LESS_THAN: length_expr < number,
|
||||
utils.FilterOperator.LENGTH_GREATER_THAN_OR_EQUALS: length_expr
|
||||
>= number,
|
||||
utils.FilterOperator.LENGTH_LESS_THAN_OR_EQUALS: length_expr
|
||||
<= number,
|
||||
}
|
||||
target_clause_list.append(length_comparisons[op])
|
||||
elif op == utils.FilterOperator.IS_TRUE:
|
||||
target_clause_list.append(
|
||||
db_engine_spec.handle_boolean_filter(sqla_col, op, True)
|
||||
@@ -4586,6 +4758,38 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
target_clause_list.append(sqla_col.not_like(eq))
|
||||
else:
|
||||
target_clause_list.append(sqla_col.not_ilike(eq))
|
||||
elif op in {
|
||||
utils.FilterOperator.CONTAINS_ANY,
|
||||
utils.FilterOperator.CONTAINS_ALL,
|
||||
}:
|
||||
# Element-level array membership. Enforce the target is
|
||||
# actually a multi-value (array) column (only classified
|
||||
# MULTI_VALUE on an array-capable engine), guarding against
|
||||
# payloads that bypass the UI gating.
|
||||
if target_generic_type != GenericDataType.MULTI_VALUE:
|
||||
raise QueryObjectValidationError(
|
||||
_(
|
||||
"The %(op)s operator is only supported for "
|
||||
"multi-value (array) columns.",
|
||||
op=op,
|
||||
)
|
||||
)
|
||||
array_values: list[Any] = coerce_array_values(
|
||||
list(eq) if isinstance(eq, (list, tuple)) else [eq],
|
||||
array_element_type,
|
||||
)
|
||||
if op == utils.FilterOperator.CONTAINS_ANY:
|
||||
target_clause_list.append(
|
||||
db_engine_spec.array_contains_any(
|
||||
sqla_col, array_values
|
||||
)
|
||||
)
|
||||
else:
|
||||
target_clause_list.append(
|
||||
db_engine_spec.array_contains_all(
|
||||
sqla_col, array_values
|
||||
)
|
||||
)
|
||||
elif (
|
||||
op == utils.FilterOperator.TEMPORAL_RANGE
|
||||
and isinstance(eq, str)
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
# 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.
|
||||
"""Runtime Content Security Policy (CSP) allowlist.
|
||||
|
||||
This module merges operator-curated *runtime* CSP allowlist entries (the
|
||||
``csp_allowlist`` table) into the response CSP header that flask-talisman sets at
|
||||
request time. It is intentionally inert unless the ``CSP_RUNTIME_ALLOWLIST``
|
||||
feature flag is enabled, so the static deploy-time policy remains the default and
|
||||
operators opt in to runtime overrides explicitly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from flask import current_app, Response
|
||||
|
||||
from superset.extensions import feature_flag_manager
|
||||
|
||||
#: CSP directives an allowlist entry is permitted to widen. Restricting the set
|
||||
#: keeps an entry from, say, loosening ``script-src`` in a way that would defeat
|
||||
#: the nonce/strict-dynamic protections.
|
||||
ALLOWED_DIRECTIVES = frozenset(
|
||||
{
|
||||
"frame-src",
|
||||
"child-src",
|
||||
"img-src",
|
||||
"connect-src",
|
||||
"media-src",
|
||||
"font-src",
|
||||
}
|
||||
)
|
||||
|
||||
CSP_HEADER = "Content-Security-Policy"
|
||||
CSP_REPORT_ONLY_HEADER = "Content-Security-Policy-Report-Only"
|
||||
|
||||
|
||||
def is_valid_csp_origin(origin: str) -> bool:
|
||||
"""Return ``True`` if ``origin`` is a bare ``scheme://host[:port]`` source.
|
||||
|
||||
The check is deliberately strict: it rejects wildcards, paths, query strings,
|
||||
fragments and embedded credentials so that an allowlist entry can only ever
|
||||
widen the policy to one specific, fully-qualified origin. This is the
|
||||
server-side enforcement point — the frontend performs the same check for UX,
|
||||
but must not be relied upon for security.
|
||||
"""
|
||||
if not origin or any(ch.isspace() for ch in origin):
|
||||
return False
|
||||
if "*" in origin:
|
||||
return False
|
||||
try:
|
||||
parsed = urlparse(origin)
|
||||
except ValueError:
|
||||
return False
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
return False
|
||||
if not parsed.hostname:
|
||||
return False
|
||||
if parsed.username or parsed.password:
|
||||
return False
|
||||
if parsed.path or parsed.query or parsed.fragment:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def is_valid_csp_directive(directive: str) -> bool:
|
||||
"""Return ``True`` if ``directive`` is one an entry may widen."""
|
||||
return directive in ALLOWED_DIRECTIVES
|
||||
|
||||
|
||||
def _parse_csp(value: str) -> "OrderedDict[str, list[str]]":
|
||||
"""Parse a CSP header string into an ordered ``directive -> sources`` map."""
|
||||
directives: OrderedDict[str, list[str]] = OrderedDict()
|
||||
for part in value.split(";"):
|
||||
tokens = part.split()
|
||||
if not tokens:
|
||||
continue
|
||||
directives[tokens[0]] = tokens[1:]
|
||||
return directives
|
||||
|
||||
|
||||
def _serialize_csp(directives: "OrderedDict[str, list[str]]") -> str:
|
||||
"""Serialize a ``directive -> sources`` map back into a CSP header string."""
|
||||
return "; ".join(
|
||||
" ".join([name, *sources]).strip() for name, sources in directives.items()
|
||||
)
|
||||
|
||||
|
||||
def merge_allowlist_into_csp(header_value: str, additions: dict[str, list[str]]) -> str:
|
||||
"""Merge ``additions`` into an existing CSP header value.
|
||||
|
||||
For a directive that already exists, missing origins are appended. For a
|
||||
directive that does not exist yet (e.g. ``frame-src`` when the base policy
|
||||
only declares ``default-src``), the directive is seeded with ``'self'`` so the
|
||||
addition widens rather than unexpectedly narrows the effective policy.
|
||||
"""
|
||||
directives = _parse_csp(header_value)
|
||||
for directive, domains in additions.items():
|
||||
sources = directives.get(directive)
|
||||
if sources is None:
|
||||
directives[directive] = ["'self'", *domains]
|
||||
else:
|
||||
for domain in domains:
|
||||
if domain not in sources:
|
||||
sources.append(domain)
|
||||
return _serialize_csp(directives)
|
||||
|
||||
|
||||
class _CSPAllowlistCache:
|
||||
"""In-process, time-bounded cache of the runtime CSP allowlist.
|
||||
|
||||
The metadata DB is the source of truth; this cache only exists to avoid a
|
||||
query on every response. A write through the REST API invalidates the cache
|
||||
in the worker that handled it; other workers converge once their copy
|
||||
expires (``CSP_RUNTIME_ALLOWLIST_CACHE_TTL`` seconds).
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._directive_map: dict[str, list[str]] | None = None
|
||||
self._loaded_at: float = 0.0
|
||||
|
||||
def get(self) -> dict[str, list[str]]:
|
||||
ttl = current_app.config.get("CSP_RUNTIME_ALLOWLIST_CACHE_TTL", 30)
|
||||
now = time.monotonic()
|
||||
if self._directive_map is None or (now - self._loaded_at) > ttl:
|
||||
self._directive_map = self._load()
|
||||
self._loaded_at = now
|
||||
return self._directive_map
|
||||
|
||||
@staticmethod
|
||||
def _load() -> dict[str, list[str]]:
|
||||
# Imported lazily to avoid a circular import at module load time.
|
||||
from superset.daos.csp import CSPAllowlistDAO
|
||||
|
||||
directive_map: dict[str, list[str]] = {}
|
||||
for entry in CSPAllowlistDAO.find_all():
|
||||
if not is_valid_csp_directive(entry.directive):
|
||||
# Defensive: never trust a stale/legacy row to widen an
|
||||
# unexpected directive.
|
||||
continue
|
||||
directive_map.setdefault(entry.directive, []).append(entry.domain)
|
||||
return directive_map
|
||||
|
||||
def invalidate(self) -> None:
|
||||
self._directive_map = None
|
||||
self._loaded_at = 0.0
|
||||
|
||||
|
||||
csp_allowlist_cache = _CSPAllowlistCache()
|
||||
|
||||
|
||||
def invalidate_csp_allowlist_cache() -> None:
|
||||
"""Drop this worker's cached copy of the allowlist (call after a write)."""
|
||||
csp_allowlist_cache.invalidate()
|
||||
|
||||
|
||||
def apply_runtime_csp_allowlist(response: Response) -> Response:
|
||||
"""Merge runtime allowlist entries into the response CSP header(s).
|
||||
|
||||
Registered as an ``after_request`` handler *before* flask-talisman so that it
|
||||
runs *after* Talisman has set the header (Flask invokes ``after_request``
|
||||
callbacks in reverse registration order). A no-op unless the
|
||||
``CSP_RUNTIME_ALLOWLIST`` feature flag is enabled and the allowlist is
|
||||
non-empty.
|
||||
"""
|
||||
if not feature_flag_manager.is_feature_enabled("CSP_RUNTIME_ALLOWLIST"):
|
||||
return response
|
||||
additions = csp_allowlist_cache.get()
|
||||
if not additions:
|
||||
return response
|
||||
for header in (CSP_HEADER, CSP_REPORT_ONLY_HEADER):
|
||||
value = response.headers.get(header)
|
||||
if value:
|
||||
response.headers[header] = merge_allowlist_into_csp(value, additions)
|
||||
return response
|
||||
@@ -1418,6 +1418,9 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||
ADMIN_ONLY_VIEW_MENUS = {
|
||||
"Access Requests",
|
||||
"Action Logs",
|
||||
# Runtime CSP allowlist: punching holes in the Content Security Policy is
|
||||
# a trusted, security-sensitive operation reserved for Admins.
|
||||
"CSPAllowlist",
|
||||
"Extensions",
|
||||
"Log",
|
||||
"List Users",
|
||||
|
||||
+21
-1
@@ -209,7 +209,7 @@ class GenericDataType(IntEnum):
|
||||
STRING = 1
|
||||
TEMPORAL = 2
|
||||
BOOLEAN = 3
|
||||
# ARRAY = 4 # Mapping all the complex data types to STRING for now
|
||||
MULTI_VALUE = 4 # array-typed columns (e.g. ClickHouse Array, Postgres ARRAY)
|
||||
# JSON = 5 # and leaving these as a reminder.
|
||||
# MAP = 6
|
||||
# ROW = 7
|
||||
@@ -299,6 +299,17 @@ class FilterOperator(StrEnum):
|
||||
IS_TRUE = "IS TRUE"
|
||||
IS_FALSE = "IS FALSE"
|
||||
TEMPORAL_RANGE = "TEMPORAL_RANGE"
|
||||
# Element-level operators for MULTI_VALUE (array) columns
|
||||
CONTAINS_ANY = "CONTAINS_ANY"
|
||||
CONTAINS_ALL = "CONTAINS_ALL"
|
||||
IS_EMPTY = "IS_EMPTY"
|
||||
IS_NOT_EMPTY = "IS_NOT_EMPTY"
|
||||
# Length (element-count) comparison operators for array columns
|
||||
LENGTH_EQUALS = "LENGTH_EQUALS"
|
||||
LENGTH_GREATER_THAN = "LENGTH_GREATER_THAN"
|
||||
LENGTH_LESS_THAN = "LENGTH_LESS_THAN"
|
||||
LENGTH_GREATER_THAN_OR_EQUALS = "LENGTH_GREATER_THAN_OR_EQUALS"
|
||||
LENGTH_LESS_THAN_OR_EQUALS = "LENGTH_LESS_THAN_OR_EQUALS"
|
||||
|
||||
|
||||
class FilterStringOperators(StrEnum):
|
||||
@@ -317,6 +328,15 @@ class FilterStringOperators(StrEnum):
|
||||
LATEST_PARTITION = ("LATEST_PARTITION",)
|
||||
IS_TRUE = ("IS_TRUE",)
|
||||
IS_FALSE = ("IS_FALSE",)
|
||||
CONTAINS_ANY = ("CONTAINS_ANY",)
|
||||
CONTAINS_ALL = ("CONTAINS_ALL",)
|
||||
IS_EMPTY = ("IS_EMPTY",)
|
||||
IS_NOT_EMPTY = ("IS_NOT_EMPTY",)
|
||||
LENGTH_EQUALS = ("LENGTH_EQUALS",)
|
||||
LENGTH_GREATER_THAN = ("LENGTH_GREATER_THAN",)
|
||||
LENGTH_LESS_THAN = ("LENGTH_LESS_THAN",)
|
||||
LENGTH_GREATER_THAN_OR_EQUALS = ("LENGTH_GREATER_THAN_OR_EQUALS",)
|
||||
LENGTH_LESS_THAN_OR_EQUALS = ("LENGTH_LESS_THAN_OR_EQUALS",)
|
||||
|
||||
|
||||
class PostProcessingBoxplotWhiskerType(StrEnum):
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
from collections.abc import Sequence
|
||||
from functools import partial
|
||||
from functools import partial, wraps
|
||||
from typing import Any, Callable
|
||||
|
||||
import numpy as np
|
||||
@@ -122,6 +122,10 @@ def scalar_to_sequence(val: Any) -> Sequence[str]:
|
||||
|
||||
def validate_column_args(*argnames: str) -> Callable[..., Any]:
|
||||
def wrapper(func: Callable[..., Any]) -> Callable[..., Any]:
|
||||
# `wraps` keeps `func` reachable through `__wrapped__`, so that
|
||||
# `inspect.signature` reports the parameters of the decorated operation
|
||||
# rather than the `(df, **options)` of this wrapper.
|
||||
@wraps(func)
|
||||
def wrapped(df: DataFrame, **options: Any) -> Any:
|
||||
if _is_multi_index_on_columns(df):
|
||||
# MultiIndex column validate first level
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
@@ -0,0 +1,124 @@
|
||||
# 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.
|
||||
"""Integration tests for the CSP allowlist REST API."""
|
||||
|
||||
import pytest
|
||||
|
||||
import tests.integration_tests.test_app # noqa: F401
|
||||
from superset import db
|
||||
from superset.models.csp import CSPAllowlistEntry
|
||||
from superset.utils import json
|
||||
from tests.integration_tests.base_tests import SupersetTestCase
|
||||
from tests.integration_tests.constants import ADMIN_USERNAME, GAMMA_USERNAME
|
||||
|
||||
|
||||
class TestCSPAllowlistApi(SupersetTestCase):
|
||||
def insert_entry(
|
||||
self,
|
||||
domain: str,
|
||||
directive: str = "frame-src",
|
||||
) -> CSPAllowlistEntry:
|
||||
admin = self.get_user("admin")
|
||||
entry = CSPAllowlistEntry(
|
||||
domain=domain,
|
||||
directive=directive,
|
||||
created_by=admin,
|
||||
changed_by=admin,
|
||||
)
|
||||
db.session.add(entry)
|
||||
db.session.commit()
|
||||
return entry
|
||||
|
||||
@pytest.fixture
|
||||
def create_entries(self):
|
||||
with self.create_app().app_context():
|
||||
entries = [
|
||||
self.insert_entry("https://a.example.com"),
|
||||
self.insert_entry("https://b.example.com"),
|
||||
]
|
||||
yield entries
|
||||
for entry in entries:
|
||||
db.session.delete(entry)
|
||||
db.session.commit()
|
||||
|
||||
@pytest.mark.usefixtures("create_entries")
|
||||
def test_get_list_as_admin(self):
|
||||
self.login(ADMIN_USERNAME)
|
||||
rv = self.client.get("/api/v1/csp_allowlist/")
|
||||
assert rv.status_code == 200
|
||||
data = json.loads(rv.data.decode("utf-8"))
|
||||
assert data["count"] >= 2
|
||||
|
||||
def test_gamma_cannot_list(self):
|
||||
"""The CSPAllowlist view-menu is admin-only."""
|
||||
self.login(GAMMA_USERNAME)
|
||||
rv = self.client.get("/api/v1/csp_allowlist/")
|
||||
assert rv.status_code in (401, 403, 404)
|
||||
|
||||
def test_admin_can_create_valid_entry(self):
|
||||
self.login(ADMIN_USERNAME)
|
||||
rv = self.client.post(
|
||||
"/api/v1/csp_allowlist/",
|
||||
json={"domain": "https://new.example.com", "directive": "frame-src"},
|
||||
)
|
||||
assert rv.status_code == 201
|
||||
data = json.loads(rv.data.decode("utf-8"))
|
||||
created = db.session.query(CSPAllowlistEntry).get(data["id"])
|
||||
assert created.domain == "https://new.example.com"
|
||||
db.session.delete(created)
|
||||
db.session.commit()
|
||||
|
||||
def test_create_rejects_invalid_origin(self):
|
||||
self.login(ADMIN_USERNAME)
|
||||
rv = self.client.post(
|
||||
"/api/v1/csp_allowlist/",
|
||||
json={"domain": "https://*.evil.com/path"},
|
||||
)
|
||||
assert rv.status_code == 400
|
||||
assert (
|
||||
db.session.query(CSPAllowlistEntry)
|
||||
.filter_by(domain="https://*.evil.com/path")
|
||||
.first()
|
||||
is None
|
||||
)
|
||||
|
||||
def test_create_rejects_disallowed_directive(self):
|
||||
self.login(ADMIN_USERNAME)
|
||||
rv = self.client.post(
|
||||
"/api/v1/csp_allowlist/",
|
||||
json={"domain": "https://ok.example.com", "directive": "script-src"},
|
||||
)
|
||||
assert rv.status_code == 400
|
||||
|
||||
def test_gamma_cannot_create(self):
|
||||
self.login(GAMMA_USERNAME)
|
||||
rv = self.client.post(
|
||||
"/api/v1/csp_allowlist/",
|
||||
json={"domain": "https://nope.example.com"},
|
||||
)
|
||||
assert rv.status_code in (401, 403, 404)
|
||||
|
||||
@pytest.mark.usefixtures("create_entries")
|
||||
def test_admin_can_delete(self):
|
||||
self.login(ADMIN_USERNAME)
|
||||
entry = (
|
||||
db.session.query(CSPAllowlistEntry)
|
||||
.filter_by(domain="https://a.example.com")
|
||||
.one()
|
||||
)
|
||||
rv = self.client.delete(f"/api/v1/csp_allowlist/{entry.id}")
|
||||
assert rv.status_code == 200
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user