mirror of
https://github.com/apache/superset.git
synced 2026-07-04 05:45:32 +00:00
Compare commits
3 Commits
fix/105973
...
feat/dashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1c7ec60ce | ||
|
|
092accfe9a | ||
|
|
fefe877cda |
@@ -129,6 +129,22 @@ chat.registerChat(
|
||||
|
||||
See [Chat](./extension-points/chat.md) for implementation details.
|
||||
|
||||
### Dashboard Renderers
|
||||
|
||||
Extensions can replace Superset's built-in dashboard renderer with a custom implementation, changing how dashboards are displayed while reusing the host's data fetching, theming, and URL handling. The dashboard renderer is a single slot: the most recently registered renderer is active, and the built-in renderer is used when none is registered or when the dashboard enters edit mode.
|
||||
|
||||
```tsx
|
||||
import { dashboards } from '@apache-superset/core';
|
||||
import KioskDashboardRenderer from './KioskDashboardRenderer';
|
||||
|
||||
dashboards.registerDashboardRenderer(
|
||||
{ id: 'my-org.kiosk-dashboard', name: 'Kiosk Dashboard Renderer' },
|
||||
KioskDashboardRenderer,
|
||||
);
|
||||
```
|
||||
|
||||
See [Dashboards](./extension-points/dashboards.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.
|
||||
|
||||
129
docs/developer_docs/extensions/extension-points/dashboards.md
Normal file
129
docs/developer_docs/extensions/extension-points/dashboards.md
Normal file
@@ -0,0 +1,129 @@
|
||||
---
|
||||
title: Dashboards
|
||||
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 Renderer Contributions
|
||||
|
||||
Extensions can replace Superset's built-in dashboard renderer with a custom implementation. This allows dashboards to be displayed in entirely different ways — kiosk layouts, alternative grid engines, story-style presentations — while reusing Superset's data fetching, authentication, theming, and URL/permalink handling.
|
||||
|
||||
## Overview
|
||||
|
||||
The dashboard renderer is a **single-slot** contribution point with two tiers:
|
||||
|
||||
- **Superset's built-in renderer is itself registered as the default provider** (`superset.dashboard-renderer`) through the same contribution point. It renders whenever no custom renderer is active — including when the `ENABLE_EXTENSIONS` feature flag is off — so dashboards always display, extensions or not.
|
||||
- At most one custom renderer is active at a time. The most recently registered renderer wins; a previously registered custom renderer is displaced and unregistered with a console warning. The default provider is never displaced.
|
||||
- Disposing the active custom renderer's `Disposable` falls back to the built-in default.
|
||||
- Custom renderers handle **view mode only**. When a dashboard enters edit mode, the host always renders the built-in renderer (which owns drag-and-drop editing, undo/redo, and the component pane), returning to the custom renderer when edit mode exits.
|
||||
- A custom renderer that throws is contained by an error boundary; the host does not fall back to the built-in renderer on error.
|
||||
|
||||
The host keeps its behavior identical regardless of which renderer is active: it fetches the dashboard, charts, and datasets, resolves initial filter state from the URL (permalinks, `native_filters_key`, legacy filter params), injects dashboard CSS, and manages the document title. The renderer receives the results as props.
|
||||
|
||||
## The Props Contract
|
||||
|
||||
Your renderer component receives `DashboardRendererProps` from `@apache-superset/core/dashboards`:
|
||||
|
||||
| Prop | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `dashboard` | `DashboardInfo` | Identity and parsed metadata: `id`, `uuid`, `slug`, `title`, `css`, `metadata` (parsed `json_metadata`), `layout` (parsed `position_json`), `isPublished`, `isManagedExternally` |
|
||||
| `charts` | `DashboardChart[]` | Chart (slice) definitions as returned by `GET /api/v1/dashboard/{id}/charts` |
|
||||
| `datasets` | `DashboardDataset[]` | Datasets as returned by `GET /api/v1/dashboard/{id}/datasets` |
|
||||
| `initialDataMask` | `DashboardDataMask` | Initial filter state resolved by the host from the URL |
|
||||
| `initialActiveTabs` | `string[]?` | Layout component ids of the initially active tabs (from permalink) |
|
||||
| `initialAnchor` | `string?` | Layout component id to scroll to on mount (permalink anchor) |
|
||||
| `uiConfig` | `DashboardUiConfig?` | Chrome-hiding flags (`hideTitle`, `hideTab`, `hideChartControls`, `emitDataMasks`), mirroring the embedded SDK's uiConfig |
|
||||
| `onDataMaskChange` | callback? | Reserved — not supplied by the host yet |
|
||||
| `onActiveTabsChange` | callback? | Reserved — not supplied by the host yet |
|
||||
|
||||
The contract is designed to be Redux-free: everything a renderer needs to display a dashboard arrives via props, and host services are available through the public `window.superset` namespaces (`authentication`, `navigation`, `theme`, `translation`, and so on).
|
||||
|
||||
### Renderer responsibilities
|
||||
|
||||
- **Chart data fetching**: the host does not fetch chart data. Query for it yourself (e.g. `POST /api/v1/chart/data` with query contexts built from each chart's `form_data`).
|
||||
- **Filter orchestration**: applying `initialDataMask`, reacting to filter interactions, and refreshing affected charts are the renderer's responsibility.
|
||||
- **Layout interpretation**: `dashboard.layout` is the parsed `position_json` component tree (rows, columns, tabs, charts, markdown); interpret as much or as little of it as your presentation needs.
|
||||
|
||||
Theming works out of the box: renderers are mounted inside the host's theme providers, so `useTheme` from `@apache-superset/core/theme` reflects the dashboard's active theme.
|
||||
|
||||
## Registering a Renderer
|
||||
|
||||
Register the renderer as a module-level side effect in your extension's entry point:
|
||||
|
||||
```typescript
|
||||
import { dashboards } from '@apache-superset/core';
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
const KioskDashboardRenderer: ComponentType<
|
||||
dashboards.DashboardRendererProps
|
||||
> = ({ dashboard, charts, initialDataMask }) => (
|
||||
<main>
|
||||
<h1>{dashboard.title}</h1>
|
||||
{/* render charts from `charts` + `dashboard.layout` */}
|
||||
</main>
|
||||
);
|
||||
|
||||
dashboards.registerDashboardRenderer(
|
||||
{ id: 'acme.kiosk-dashboard', name: 'Kiosk Dashboard Renderer' },
|
||||
KioskDashboardRenderer,
|
||||
);
|
||||
```
|
||||
|
||||
`registerDashboardRenderer` returns a `Disposable`. Disposing it removes your renderer if it is still the active one; disposing after being displaced by a newer registration is a no-op.
|
||||
|
||||
You can observe slot changes with `dashboards.onDidRegisterDashboardRenderer` and `dashboards.onDidUnregisterDashboardRenderer`, and inspect the active provider with `dashboards.getDashboardRenderer()` (which returns the built-in default when no custom renderer is active).
|
||||
|
||||
### Augmenting the built-in renderer
|
||||
|
||||
To augment rather than fully replace the built-in renderer, retrieve the default provider and wrap its component:
|
||||
|
||||
```tsx
|
||||
const defaultProvider = dashboards.getDefaultDashboardRenderer();
|
||||
|
||||
dashboards.registerDashboardRenderer(
|
||||
{ id: 'acme.framed-dashboard', name: 'Framed Dashboard' },
|
||||
props => (
|
||||
<AcmeFrame>
|
||||
{defaultProvider && <defaultProvider.component {...props} />}
|
||||
</AcmeFrame>
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
## Manifest Declaration
|
||||
|
||||
Declare the renderer in your extension's `Contributions` metadata (at most one per extension):
|
||||
|
||||
```json
|
||||
{
|
||||
"dashboardRenderer": {
|
||||
"id": "acme.kiosk-dashboard",
|
||||
"name": "Kiosk Dashboard Renderer",
|
||||
"description": "Full-screen kiosk presentation of dashboards"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Current Limitations
|
||||
|
||||
- Extensions load asynchronously after startup, so a dashboard opened before your extension finishes loading renders with the built-in renderer first and swaps to yours when registration lands.
|
||||
- `onDataMaskChange` and `onActiveTabsChange` are defined in the contract but not consumed by the host yet — filter state changed inside a custom renderer does not persist to permalinks.
|
||||
- While a custom renderer is active the host still hydrates its internal dashboard state so permalinks and embedded behavior remain intact; this is transparent to renderers but means the built-in state bookkeeping still runs.
|
||||
@@ -30,6 +30,10 @@
|
||||
"types": "./lib/commands/index.d.ts",
|
||||
"default": "./lib/commands/index.js"
|
||||
},
|
||||
"./dashboards": {
|
||||
"types": "./lib/dashboards/index.d.ts",
|
||||
"default": "./lib/dashboards/index.js"
|
||||
},
|
||||
"./editors": {
|
||||
"types": "./lib/editors/index.d.ts",
|
||||
"default": "./lib/editors/index.js"
|
||||
|
||||
@@ -23,11 +23,13 @@
|
||||
* This module defines the aggregate interfaces used by the extension.json
|
||||
* manifest and the `superset-extensions` build command. Individual metadata
|
||||
* types are defined in their respective namespace modules (commands, views,
|
||||
* menus, editors, chat) and re-exported here for the manifest schema.
|
||||
* menus, editors, chat, dashboards) and re-exported here for the manifest
|
||||
* schema.
|
||||
*/
|
||||
|
||||
import { Chat } from '../chat';
|
||||
import { Command } from '../commands';
|
||||
import { DashboardRenderer } from '../dashboards';
|
||||
import { View } from '../views';
|
||||
import { Menu } from '../menus';
|
||||
import { Editor } from '../editors';
|
||||
@@ -90,4 +92,10 @@ export interface Contributions {
|
||||
* chat at a time.
|
||||
*/
|
||||
chat?: Chat;
|
||||
/**
|
||||
* The dashboard renderer contributed by the extension — at most one per
|
||||
* extension, since the host applies singleton resolution and renders
|
||||
* exactly one active dashboard renderer at a time.
|
||||
*/
|
||||
dashboardRenderer?: DashboardRenderer;
|
||||
}
|
||||
|
||||
317
superset-frontend/packages/superset-core/src/dashboards/index.ts
Normal file
317
superset-frontend/packages/superset-core/src/dashboards/index.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* 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 Dashboards API for custom dashboard renderers.
|
||||
*
|
||||
* This module defines the contract for replacing Superset's built-in
|
||||
* dashboard renderer with a custom implementation contributed by an
|
||||
* extension. It provides:
|
||||
*
|
||||
* - `DashboardRenderer`: Descriptor for a contributed renderer
|
||||
* - `DashboardRendererProps`: The props contract renderer components receive
|
||||
* - `registerDashboardRenderer`: Registration function (single slot)
|
||||
*
|
||||
* The dashboard renderer is a single-slot contribution point: at most one
|
||||
* custom renderer is active at a time, and the most recently registered
|
||||
* renderer wins. The built-in dashboard renderer is itself registered as
|
||||
* the default provider, so it renders whenever no custom renderer is
|
||||
* active — including when the extensions feature flag is off — and it can
|
||||
* be retrieved via {@link getDefaultDashboardRenderer} to wrap or augment
|
||||
* it from a custom renderer.
|
||||
*
|
||||
* Custom renderers handle VIEW mode only. When a dashboard enters edit
|
||||
* mode, the host always renders the built-in renderer, returning to the
|
||||
* custom renderer when edit mode exits.
|
||||
*
|
||||
* The props contract is designed to be Redux-free: everything a renderer
|
||||
* needs to display a dashboard arrives via props, and host services are
|
||||
* available through the public `window.superset` namespaces. Renderer
|
||||
* components are mounted inside the host's theme providers, so `useTheme`
|
||||
* from `@apache-superset/core/theme` works as expected. Fetching chart
|
||||
* data (e.g. `POST /api/v1/chart/data`) and orchestrating filter-driven
|
||||
* chart refreshes are the renderer's responsibility.
|
||||
*/
|
||||
|
||||
import { ComponentType } from 'react';
|
||||
import { Disposable, Event } from '../common';
|
||||
|
||||
/**
|
||||
* Describes a dashboard renderer that can be contributed to the application.
|
||||
*/
|
||||
export interface DashboardRenderer {
|
||||
/** Unique identifier for the renderer (e.g., "acme.kiosk-dashboard") */
|
||||
id: string;
|
||||
/** Display name of the renderer */
|
||||
name: string;
|
||||
/** Optional description of the renderer */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter state for a single native filter or cross-filter emitter, keyed by
|
||||
* the filter id (native filters) or chart id (cross-filters).
|
||||
*/
|
||||
export interface DashboardDataMaskEntry {
|
||||
/** The native filter id or chart id this entry belongs to */
|
||||
id: string;
|
||||
/** UI-facing filter state (selected values, labels) */
|
||||
filterState?: Record<string, unknown>;
|
||||
/** Query-facing form data merged into affected charts' queries */
|
||||
extraFormData?: Record<string, unknown>;
|
||||
/** Renderer/chart-private state (e.g. table pagination, search) */
|
||||
ownState?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The complete filter state of a dashboard: one entry per active native
|
||||
* filter or cross-filter emitter, keyed by filter or chart id.
|
||||
*/
|
||||
export type DashboardDataMask = Record<string, DashboardDataMaskEntry>;
|
||||
|
||||
/**
|
||||
* Identity and parsed metadata for the dashboard being rendered.
|
||||
*/
|
||||
export interface DashboardInfo {
|
||||
/** Numeric dashboard id */
|
||||
id: number;
|
||||
/** Stable UUID of the dashboard */
|
||||
uuid?: string;
|
||||
/** URL slug, if one is set */
|
||||
slug?: string | null;
|
||||
/** Dashboard title */
|
||||
title: string;
|
||||
/** Custom CSS attached to the dashboard (already injected by the host) */
|
||||
css?: string | null;
|
||||
/**
|
||||
* Parsed `json_metadata`: `native_filter_configuration`,
|
||||
* `chart_configuration` (cross-filter scoping), `refresh_frequency`,
|
||||
* `color_scheme`, `default_filters`, and other dashboard-level settings.
|
||||
*/
|
||||
metadata: Record<string, unknown>;
|
||||
/**
|
||||
* Parsed `position_json` — the layout component tree keyed by component
|
||||
* id (GRID_ID, ROOT_ID, CHART-*, TAB-*, ROW-*, ...), describing how
|
||||
* charts, tabs, rows, and columns are arranged.
|
||||
*/
|
||||
layout: Record<string, unknown>;
|
||||
/** Whether the dashboard is published */
|
||||
isPublished?: boolean;
|
||||
/** Whether the dashboard is managed by an external system */
|
||||
isManagedExternally?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A chart (slice) definition as returned by
|
||||
* `GET /api/v1/dashboard/{id}/charts`. Identity fields are typed
|
||||
* explicitly; the remaining payload is passed through untyped.
|
||||
*/
|
||||
export interface DashboardChart {
|
||||
/** Numeric chart (slice) id */
|
||||
id: number;
|
||||
/** Chart display name */
|
||||
slice_name?: string;
|
||||
/** Visualization plugin type (e.g., "echarts_timeseries_line") */
|
||||
viz_type?: string;
|
||||
/** The chart's stored form data (query + display configuration) */
|
||||
form_data?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* A dataset definition as returned by
|
||||
* `GET /api/v1/dashboard/{id}/datasets`. Identity fields are typed
|
||||
* explicitly; the remaining payload is passed through untyped.
|
||||
*/
|
||||
export interface DashboardDataset {
|
||||
/** Numeric dataset id */
|
||||
id: number;
|
||||
/** Datasource uid (e.g., "12__table") */
|
||||
uid?: string;
|
||||
/** Physical or virtual table name */
|
||||
table_name?: string;
|
||||
/** Column definitions */
|
||||
columns?: Record<string, unknown>[];
|
||||
/** Metric definitions */
|
||||
metrics?: Record<string, unknown>[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display options for the dashboard, mirroring the embedded SDK's
|
||||
* uiConfig chrome-hiding flags.
|
||||
*/
|
||||
export interface DashboardUiConfig {
|
||||
/** Hide the dashboard title header */
|
||||
hideTitle?: boolean;
|
||||
/** Hide the top-level tab bar */
|
||||
hideTab?: boolean;
|
||||
/** Hide per-chart menus and controls */
|
||||
hideChartControls?: boolean;
|
||||
/** Emit filter state changes to an embedding host application */
|
||||
emitDataMasks?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props contract between the host and dashboard renderer implementations.
|
||||
*
|
||||
* Everything a renderer needs to display a dashboard arrives via these
|
||||
* props; host services (authentication, navigation, theming, translation)
|
||||
* are available through the public `window.superset` namespaces.
|
||||
*/
|
||||
export interface DashboardRendererProps {
|
||||
/** Identity and parsed metadata of the dashboard */
|
||||
dashboard: DashboardInfo;
|
||||
/** Chart (slice) definitions placed on the dashboard */
|
||||
charts: DashboardChart[];
|
||||
/** Datasets backing the dashboard's charts */
|
||||
datasets: DashboardDataset[];
|
||||
/**
|
||||
* Initial filter state resolved by the host from the URL: permalink
|
||||
* state, `native_filters_key`, and legacy/rison filter params.
|
||||
*/
|
||||
initialDataMask: DashboardDataMask;
|
||||
/** Layout component ids of the initially active tabs (from permalink) */
|
||||
initialActiveTabs?: string[];
|
||||
/** Layout component id to scroll to on mount (permalink anchor) */
|
||||
initialAnchor?: string;
|
||||
/** Display options (chrome hiding), when provided by the host */
|
||||
uiConfig?: DashboardUiConfig;
|
||||
/**
|
||||
* Notify the host that filter state changed inside the renderer.
|
||||
* Reserved for future host consumption (permalinks, embedded emitters);
|
||||
* the host does not supply this callback yet.
|
||||
*/
|
||||
onDataMaskChange?: (dataMask: DashboardDataMask) => void;
|
||||
/**
|
||||
* Notify the host that the active tabs changed inside the renderer.
|
||||
* Reserved for future host consumption; the host does not supply this
|
||||
* callback yet.
|
||||
*/
|
||||
onActiveTabsChange?: (activeTabs: string[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* React component type for dashboard renderer implementations.
|
||||
*/
|
||||
export type DashboardRendererComponent = ComponentType<DashboardRendererProps>;
|
||||
|
||||
/**
|
||||
* A registered dashboard renderer provider with its descriptor and component.
|
||||
*/
|
||||
export interface DashboardRendererProvider {
|
||||
/** The renderer descriptor */
|
||||
renderer: DashboardRenderer;
|
||||
/** The React component implementing the renderer */
|
||||
component: DashboardRendererComponent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event fired when a dashboard renderer is registered.
|
||||
*/
|
||||
export interface DashboardRendererRegisteredEvent {
|
||||
/** The descriptor of the renderer that was registered */
|
||||
renderer: DashboardRenderer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event fired when a dashboard renderer is unregistered.
|
||||
*/
|
||||
export interface DashboardRendererUnregisteredEvent {
|
||||
/** The descriptor of the renderer that was unregistered */
|
||||
renderer: DashboardRenderer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a custom dashboard renderer as a module-level side effect.
|
||||
*
|
||||
* The dashboard renderer is a single slot: the most recently registered
|
||||
* renderer becomes active, displacing (and unregistering) any previously
|
||||
* registered one with a console warning. Disposing the returned Disposable
|
||||
* removes the renderer if it is still the active one — the built-in
|
||||
* default renderer takes over again; disposing a displaced renderer's
|
||||
* Disposable is a no-op.
|
||||
*
|
||||
* Custom renderers handle view mode only — when a dashboard enters edit
|
||||
* mode, the host always renders the built-in renderer.
|
||||
*
|
||||
* @param renderer The renderer descriptor including id and name.
|
||||
* @param component The React component implementing the renderer.
|
||||
* @returns Disposable which unregisters this renderer on disposal.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* dashboards.registerDashboardRenderer(
|
||||
* { id: 'acme.kiosk-dashboard', name: 'Kiosk Dashboard Renderer' },
|
||||
* KioskDashboardRenderer,
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export declare function registerDashboardRenderer(
|
||||
renderer: DashboardRenderer,
|
||||
component: DashboardRendererComponent,
|
||||
): Disposable;
|
||||
|
||||
/**
|
||||
* Get the active dashboard renderer provider: the extension-registered
|
||||
* renderer when one is active, otherwise the built-in default provider.
|
||||
*
|
||||
* @returns The active provider, or undefined only if the host has not
|
||||
* initialized a default renderer.
|
||||
*/
|
||||
export declare function getDashboardRenderer():
|
||||
| DashboardRendererProvider
|
||||
| undefined;
|
||||
|
||||
/**
|
||||
* Get the built-in default dashboard renderer provider.
|
||||
*
|
||||
* Useful for extensions that want to augment rather than fully replace
|
||||
* the built-in renderer — retrieve the default component, wrap it, and
|
||||
* register the wrapper.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const defaultProvider = dashboards.getDefaultDashboardRenderer();
|
||||
* dashboards.registerDashboardRenderer(
|
||||
* { id: 'acme.framed-dashboard', name: 'Framed Dashboard' },
|
||||
* props => (
|
||||
* <AcmeFrame>
|
||||
* {defaultProvider && <defaultProvider.component {...props} />}
|
||||
* </AcmeFrame>
|
||||
* ),
|
||||
* );
|
||||
* ```
|
||||
*
|
||||
* @returns The default provider, or undefined if the host has not
|
||||
* initialized one.
|
||||
*/
|
||||
export declare function getDefaultDashboardRenderer():
|
||||
| DashboardRendererProvider
|
||||
| undefined;
|
||||
|
||||
/**
|
||||
* Event fired when a dashboard renderer is registered.
|
||||
*/
|
||||
export declare const onDidRegisterDashboardRenderer: Event<DashboardRendererRegisteredEvent>;
|
||||
|
||||
/**
|
||||
* Event fired when a dashboard renderer is unregistered.
|
||||
*/
|
||||
export declare const onDidUnregisterDashboardRenderer: Event<DashboardRendererUnregisteredEvent>;
|
||||
@@ -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 dashboards from './dashboards';
|
||||
export * as editors from './editors';
|
||||
export * as extensions from './extensions';
|
||||
export * as menus from './menus';
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* 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 { test, expect, Page } from '@playwright/test';
|
||||
import { DashboardPage } from '../../pages/DashboardPage';
|
||||
import { TIMEOUT } from '../../utils/constants';
|
||||
|
||||
/**
|
||||
* Dashboard Renderer extension point E2E tests.
|
||||
*
|
||||
* Verifies the `dashboards` contribution point end to end: a custom
|
||||
* renderer registered through `window.superset.dashboards` replaces the
|
||||
* built-in dashboard renderer in place, receives the contract props,
|
||||
* restores the built-in renderer on disposal, and never takes over in
|
||||
* edit mode.
|
||||
*
|
||||
* Prerequisites:
|
||||
* - Superset running with example dashboards loaded
|
||||
* - Admin user authenticated (via global-setup)
|
||||
* - ENABLE_EXTENSIONS feature flag on (tests skip themselves otherwise)
|
||||
*/
|
||||
|
||||
const CUSTOM_MARKER = 'CUSTOM DASHBOARD RENDERER ACTIVE';
|
||||
const CHART_CONTAINER = '[data-test="chart-container"]';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__rendererDisposable?: { dispose: () => void };
|
||||
__rendererProps?: {
|
||||
dashboardId?: number;
|
||||
title?: string;
|
||||
charts: number;
|
||||
layoutKeys: number;
|
||||
hasInitialDataMask: boolean;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function extensionsEnabled(page: Page): Promise<boolean> {
|
||||
return page.evaluate(() =>
|
||||
Boolean((window as any).featureFlags?.ENABLE_EXTENSIONS),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a minimal custom renderer from the page context and records
|
||||
* the contract props it receives on window.__rendererProps.
|
||||
*/
|
||||
async function registerProbeRenderer(page: Page): Promise<void> {
|
||||
await page.evaluate(marker => {
|
||||
window.__rendererProps = undefined;
|
||||
window.__rendererDisposable = (
|
||||
window as any
|
||||
).superset.dashboards.registerDashboardRenderer(
|
||||
{ id: 'e2e.probe-renderer', name: 'E2E Probe Renderer' },
|
||||
(props: any) => {
|
||||
window.__rendererProps = {
|
||||
dashboardId: props.dashboard?.id,
|
||||
title: props.dashboard?.title,
|
||||
charts: (props.charts || []).length,
|
||||
layoutKeys: Object.keys(props.dashboard?.layout || {}).length,
|
||||
hasInitialDataMask: Boolean(props.initialDataMask),
|
||||
};
|
||||
return `${marker} :: ${props.dashboard?.title}`;
|
||||
},
|
||||
);
|
||||
}, CUSTOM_MARKER);
|
||||
}
|
||||
|
||||
test.describe('Dashboard renderer extension point', () => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
let dashboardPage: DashboardPage;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
dashboardPage = new DashboardPage(page);
|
||||
await dashboardPage.gotoBySlug('world_health');
|
||||
await dashboardPage.waitForLoad({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
test.skip(
|
||||
!(await extensionsEnabled(page)),
|
||||
'ENABLE_EXTENSIONS feature flag is off',
|
||||
);
|
||||
});
|
||||
|
||||
test('custom renderer swaps in live, receives contract props, and restores on dispose', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Built-in renderer showing charts before any registration
|
||||
await expect(page.locator(CHART_CONTAINER).first()).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// The public namespace is exposed
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
typeof (window as any).superset?.dashboards
|
||||
?.registerDashboardRenderer,
|
||||
),
|
||||
).toBe('function');
|
||||
|
||||
// The built-in renderer is itself the registered default provider
|
||||
expect(
|
||||
await page.evaluate(() => ({
|
||||
active: (window as any).superset.dashboards.getDashboardRenderer()
|
||||
?.renderer.id,
|
||||
default: (
|
||||
window as any
|
||||
).superset.dashboards.getDefaultDashboardRenderer()?.renderer.id,
|
||||
})),
|
||||
).toEqual({
|
||||
active: 'superset.dashboard-renderer',
|
||||
default: 'superset.dashboard-renderer',
|
||||
});
|
||||
|
||||
// Registering swaps the view in place, no navigation
|
||||
await registerProbeRenderer(page);
|
||||
await expect(page.getByText(CUSTOM_MARKER)).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
await expect(page.locator(CHART_CONTAINER)).toHaveCount(0);
|
||||
|
||||
// The renderer received the contract props for this dashboard
|
||||
const props = await page.evaluate(() => window.__rendererProps);
|
||||
expect(props?.title).toBeTruthy();
|
||||
expect(props?.charts).toBeGreaterThan(0);
|
||||
expect(props?.layoutKeys).toBeGreaterThan(0);
|
||||
expect(props?.hasInitialDataMask).toBe(true);
|
||||
|
||||
// Disposing restores the built-in renderer
|
||||
await page.evaluate(() => window.__rendererDisposable?.dispose());
|
||||
await expect(page.getByText(CUSTOM_MARKER)).toHaveCount(0);
|
||||
await expect(page.locator(CHART_CONTAINER).first()).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
});
|
||||
|
||||
test('edit mode keeps the built-in renderer despite a registered custom renderer', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Enter edit mode first: a full navigation would wipe an in-page
|
||||
// registration, so the registration must happen while in edit mode.
|
||||
await page.goto(`${page.url().split('?')[0]}?edit=true`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
await expect(page.getByRole('button', { name: /discard/i })).toBeVisible({
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
});
|
||||
await expect(page.locator(CHART_CONTAINER).first()).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// Registering while in edit mode must NOT swap the renderer.
|
||||
// (Discard performs a full navigation that would wipe the in-page
|
||||
// registration, so the edit→view transition is covered by unit tests.)
|
||||
await registerProbeRenderer(page);
|
||||
await expect(page.getByText(CUSTOM_MARKER)).toHaveCount(0);
|
||||
await expect(page.locator(CHART_CONTAINER).first()).toBeVisible({
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* 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 { act, render, screen, cleanup } from 'spec/helpers/testing-library';
|
||||
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
||||
import type { dashboards } from '@apache-superset/core';
|
||||
import DashboardRendererProviders from './DashboardRendererProviders';
|
||||
import DashboardRendererHost from './DashboardRendererHost';
|
||||
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
isFeatureEnabled: jest.fn(),
|
||||
}));
|
||||
const mockIsFeatureEnabled = isFeatureEnabled as jest.Mock;
|
||||
|
||||
// Stub the built-in renderer to avoid mounting the full dashboard stack
|
||||
jest.mock(
|
||||
'src/dashboard/components/DashboardRenderer/DefaultDashboardRenderer',
|
||||
() => ({
|
||||
__esModule: true,
|
||||
default: ({ dashboard }: dashboards.DashboardRendererProps) => (
|
||||
<div data-test="default-dashboard-renderer">
|
||||
<span data-test="default-renderer-dashboard-id">{dashboard.id}</span>
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const rendererProps: dashboards.DashboardRendererProps = {
|
||||
dashboard: {
|
||||
id: 42,
|
||||
title: 'Test dashboard',
|
||||
metadata: {},
|
||||
layout: {},
|
||||
},
|
||||
charts: [{ id: 7, slice_name: 'Test chart' }],
|
||||
datasets: [{ id: 3, table_name: 'test_table' }],
|
||||
initialDataMask: {
|
||||
'NATIVE_FILTER-abc': {
|
||||
id: 'NATIVE_FILTER-abc',
|
||||
filterState: { value: ['foo'] },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
DashboardRendererProviders.getInstance().reset();
|
||||
mockIsFeatureEnabled.mockImplementation(
|
||||
flag => flag === FeatureFlag.EnableExtensions,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockIsFeatureEnabled.mockReset();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test('renders the built-in renderer when no custom renderer is registered', async () => {
|
||||
render(<DashboardRendererHost {...rendererProps} />);
|
||||
|
||||
expect(
|
||||
await screen.findByTestId('default-dashboard-renderer'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('default-renderer-dashboard-id')).toHaveTextContent(
|
||||
'42',
|
||||
);
|
||||
});
|
||||
|
||||
test('renders a registered custom renderer with the contract props', () => {
|
||||
const customRenderer = jest.fn(
|
||||
({ dashboard, initialDataMask }: dashboards.DashboardRendererProps) => (
|
||||
<div data-test="custom-dashboard-renderer">
|
||||
{dashboard.title}
|
||||
{Object.keys(initialDataMask).join(',')}
|
||||
</div>
|
||||
),
|
||||
);
|
||||
DashboardRendererProviders.getInstance().registerProvider(
|
||||
{ id: 'test.custom', name: 'Custom Renderer' },
|
||||
customRenderer,
|
||||
);
|
||||
|
||||
render(<DashboardRendererHost {...rendererProps} />);
|
||||
|
||||
expect(screen.getByTestId('custom-dashboard-renderer')).toHaveTextContent(
|
||||
'Test dashboard',
|
||||
);
|
||||
expect(screen.getByTestId('custom-dashboard-renderer')).toHaveTextContent(
|
||||
'NATIVE_FILTER-abc',
|
||||
);
|
||||
expect(customRenderer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
dashboard: expect.objectContaining({ id: 42 }),
|
||||
charts: rendererProps.charts,
|
||||
datasets: rendererProps.datasets,
|
||||
initialDataMask: rendererProps.initialDataMask,
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(
|
||||
screen.queryByTestId('default-dashboard-renderer'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders the built-in renderer in edit mode even with a custom renderer registered', async () => {
|
||||
DashboardRendererProviders.getInstance().registerProvider(
|
||||
{ id: 'test.custom', name: 'Custom Renderer' },
|
||||
() => <div data-test="custom-dashboard-renderer" />,
|
||||
);
|
||||
|
||||
render(<DashboardRendererHost editMode {...rendererProps} />);
|
||||
|
||||
expect(
|
||||
await screen.findByTestId('default-dashboard-renderer'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('custom-dashboard-renderer'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders the built-in renderer when the extensions feature flag is off', async () => {
|
||||
mockIsFeatureEnabled.mockReturnValue(false);
|
||||
DashboardRendererProviders.getInstance().registerProvider(
|
||||
{ id: 'test.custom', name: 'Custom Renderer' },
|
||||
() => <div data-test="custom-dashboard-renderer" />,
|
||||
);
|
||||
|
||||
render(<DashboardRendererHost {...rendererProps} />);
|
||||
|
||||
expect(
|
||||
await screen.findByTestId('default-dashboard-renderer'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('custom-dashboard-renderer'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows an error boundary when the custom renderer throws', () => {
|
||||
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
DashboardRendererProviders.getInstance().registerProvider(
|
||||
{ id: 'test.broken', name: 'Broken Renderer' },
|
||||
() => {
|
||||
throw new Error('renderer exploded');
|
||||
},
|
||||
);
|
||||
|
||||
render(<DashboardRendererHost {...rendererProps} />);
|
||||
|
||||
expect(screen.getByText('Unexpected error')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('default-dashboard-renderer'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('swaps to a custom renderer registered after mount', async () => {
|
||||
render(<DashboardRendererHost {...rendererProps} />);
|
||||
|
||||
expect(
|
||||
await screen.findByTestId('default-dashboard-renderer'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
DashboardRendererProviders.getInstance().registerProvider(
|
||||
{ id: 'test.late', name: 'Late Renderer' },
|
||||
() => <div data-test="custom-dashboard-renderer" />,
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('custom-dashboard-renderer')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('default-dashboard-renderer'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
101
superset-frontend/src/core/dashboards/DashboardRendererHost.tsx
Normal file
101
superset-frontend/src/core/dashboards/DashboardRendererHost.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview DashboardRendererHost component for dynamic dashboard
|
||||
* renderer resolution.
|
||||
*
|
||||
* This component resolves and renders the active dashboard renderer from
|
||||
* the provider registry. Superset's built-in renderer is registered as the
|
||||
* default provider (see ./defaultRenderer), so dashboards always render:
|
||||
* an extension-registered override wins in view mode when the extensions
|
||||
* feature flag is on; the built-in default renders otherwise — including
|
||||
* in edit mode and when the flag is off.
|
||||
*/
|
||||
|
||||
import { Suspense, useSyncExternalStore } from 'react';
|
||||
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
||||
import type { dashboards } from '@apache-superset/core';
|
||||
import { Loading } from '@superset-ui/core/components';
|
||||
import { ErrorBoundary } from 'src/components/ErrorBoundary';
|
||||
import DashboardRendererProviders from './DashboardRendererProviders';
|
||||
// Registers the built-in renderer as the default provider (side effect)
|
||||
import './defaultRenderer';
|
||||
|
||||
export interface DashboardRendererHostProps
|
||||
extends dashboards.DashboardRendererProps {
|
||||
/**
|
||||
* Host-only flag, not part of the renderer contract: when true, the
|
||||
* built-in renderer is always used. Custom renderers handle view mode
|
||||
* only — the built-in editor stack owns editing.
|
||||
*/
|
||||
editMode?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* DashboardRendererHost dynamically resolves and renders the appropriate
|
||||
* dashboard renderer.
|
||||
*
|
||||
* It checks whether an extension has registered a custom dashboard renderer
|
||||
* and uses that if available; otherwise, it renders the built-in default
|
||||
* provider. The built-in renderer is also used whenever the dashboard is in
|
||||
* edit mode, regardless of registration.
|
||||
*/
|
||||
const DashboardRendererHost = ({
|
||||
editMode,
|
||||
...rendererProps
|
||||
}: DashboardRendererHostProps) => {
|
||||
const manager = DashboardRendererProviders.getInstance();
|
||||
const override = useSyncExternalStore(
|
||||
manager.subscribe,
|
||||
() => manager.getOverrideProvider(),
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
// Extensions can only register while the feature flag is on (the loader is
|
||||
// gated), but the dashboard blast radius warrants the extra check.
|
||||
const useOverride =
|
||||
!editMode &&
|
||||
override !== undefined &&
|
||||
isFeatureEnabled(FeatureFlag.EnableExtensions);
|
||||
|
||||
if (useOverride) {
|
||||
const RendererComponent = override.component;
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<RendererComponent {...rendererProps} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
const defaultProvider = manager.getDefaultProvider();
|
||||
if (!defaultProvider) {
|
||||
return null;
|
||||
}
|
||||
const DefaultRenderer = defaultProvider.component;
|
||||
return (
|
||||
<Suspense fallback={<Loading />}>
|
||||
<DefaultRenderer {...rendererProps} />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardRendererHost;
|
||||
|
||||
export { DashboardRendererHost };
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* 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 { dashboards } from '@apache-superset/core';
|
||||
import DashboardRendererProviders from './DashboardRendererProviders';
|
||||
|
||||
type DashboardRenderer = dashboards.DashboardRenderer;
|
||||
type DashboardRendererComponent = dashboards.DashboardRendererComponent;
|
||||
|
||||
/**
|
||||
* Creates a mock renderer descriptor for testing.
|
||||
*/
|
||||
function createMockRenderer(
|
||||
overrides: Partial<DashboardRenderer> = {},
|
||||
): DashboardRenderer {
|
||||
return {
|
||||
id: 'test.mock-renderer',
|
||||
name: 'Mock Renderer',
|
||||
description: 'A mock dashboard renderer for testing',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock renderer component for testing.
|
||||
*/
|
||||
function createMockRendererComponent(): DashboardRendererComponent {
|
||||
return jest.fn(() => null) as unknown as DashboardRendererComponent;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset the singleton instance before each test, including the default
|
||||
// tier — this suite exercises the registry in isolation.
|
||||
const manager = DashboardRendererProviders.getInstance();
|
||||
manager.reset(true);
|
||||
});
|
||||
|
||||
test('creates singleton instance', () => {
|
||||
const manager1 = DashboardRendererProviders.getInstance();
|
||||
const manager2 = DashboardRendererProviders.getInstance();
|
||||
|
||||
expect(manager1).toBe(manager2);
|
||||
expect(manager1).toBeInstanceOf(DashboardRendererProviders);
|
||||
});
|
||||
|
||||
test('registers and retrieves a provider', () => {
|
||||
const manager = DashboardRendererProviders.getInstance();
|
||||
const renderer = createMockRenderer();
|
||||
const component = createMockRendererComponent();
|
||||
|
||||
manager.registerProvider(renderer, component);
|
||||
|
||||
const provider = manager.getProvider();
|
||||
expect(provider).toBeDefined();
|
||||
expect(provider?.renderer).toEqual(renderer);
|
||||
expect(provider?.component).toBe(component);
|
||||
});
|
||||
|
||||
test('returns undefined when no provider is registered', () => {
|
||||
const manager = DashboardRendererProviders.getInstance();
|
||||
|
||||
expect(manager.getProvider()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('unregisters provider when disposable is disposed', () => {
|
||||
const manager = DashboardRendererProviders.getInstance();
|
||||
const renderer = createMockRenderer();
|
||||
|
||||
const disposable = manager.registerProvider(
|
||||
renderer,
|
||||
createMockRendererComponent(),
|
||||
);
|
||||
|
||||
expect(manager.getProvider()).toBeDefined();
|
||||
|
||||
disposable.dispose();
|
||||
|
||||
expect(manager.getProvider()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('most recent registration wins and displaces the previous provider', () => {
|
||||
const manager = DashboardRendererProviders.getInstance();
|
||||
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
||||
const unregisterListener = jest.fn();
|
||||
manager.onDidUnregister(unregisterListener);
|
||||
|
||||
const renderer1 = createMockRenderer({ id: 'renderer-1' });
|
||||
const renderer2 = createMockRenderer({ id: 'renderer-2' });
|
||||
|
||||
manager.registerProvider(renderer1, createMockRendererComponent());
|
||||
manager.registerProvider(renderer2, createMockRendererComponent());
|
||||
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'Multiple dashboard renderers registered. Using "renderer-2"; ' +
|
||||
'discarding "renderer-1".',
|
||||
);
|
||||
expect(unregisterListener).toHaveBeenCalledWith({ renderer: renderer1 });
|
||||
expect(manager.getProvider()?.renderer.id).toBe('renderer-2');
|
||||
|
||||
consoleWarnSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('disposing a displaced provider is a no-op', () => {
|
||||
const manager = DashboardRendererProviders.getInstance();
|
||||
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
||||
|
||||
const renderer1 = createMockRenderer({ id: 'renderer-1' });
|
||||
const renderer2 = createMockRenderer({ id: 'renderer-2' });
|
||||
|
||||
const disposable1 = manager.registerProvider(
|
||||
renderer1,
|
||||
createMockRendererComponent(),
|
||||
);
|
||||
manager.registerProvider(renderer2, createMockRendererComponent());
|
||||
|
||||
// Disposing the displaced provider must not clear the active one
|
||||
disposable1.dispose();
|
||||
|
||||
expect(manager.getProvider()?.renderer.id).toBe('renderer-2');
|
||||
|
||||
consoleWarnSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('fires onDidRegister event when provider is registered', () => {
|
||||
const manager = DashboardRendererProviders.getInstance();
|
||||
const listener = jest.fn();
|
||||
|
||||
manager.onDidRegister(listener);
|
||||
|
||||
const renderer = createMockRenderer();
|
||||
manager.registerProvider(renderer, createMockRendererComponent());
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(listener).toHaveBeenCalledWith({ renderer });
|
||||
});
|
||||
|
||||
test('fires onDidUnregister event when provider is unregistered', () => {
|
||||
const manager = DashboardRendererProviders.getInstance();
|
||||
const listener = jest.fn();
|
||||
|
||||
manager.onDidUnregister(listener);
|
||||
|
||||
const renderer = createMockRenderer();
|
||||
const disposable = manager.registerProvider(
|
||||
renderer,
|
||||
createMockRendererComponent(),
|
||||
);
|
||||
|
||||
disposable.dispose();
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(listener).toHaveBeenCalledWith({ renderer });
|
||||
});
|
||||
|
||||
test('event listeners can be disposed', () => {
|
||||
const manager = DashboardRendererProviders.getInstance();
|
||||
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
||||
const listener = jest.fn();
|
||||
|
||||
const listenerDisposable = manager.onDidRegister(listener);
|
||||
|
||||
manager.registerProvider(
|
||||
createMockRenderer({ id: 'renderer-1' }),
|
||||
createMockRendererComponent(),
|
||||
);
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
|
||||
listenerDisposable.dispose();
|
||||
|
||||
manager.registerProvider(
|
||||
createMockRenderer({ id: 'renderer-2' }),
|
||||
createMockRendererComponent(),
|
||||
);
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1); // Still only 1 call
|
||||
|
||||
consoleWarnSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('notifies subscribe listeners on register and unregister', () => {
|
||||
const manager = DashboardRendererProviders.getInstance();
|
||||
const listener = jest.fn();
|
||||
|
||||
const unsubscribe = manager.subscribe(listener);
|
||||
|
||||
const disposable = manager.registerProvider(
|
||||
createMockRenderer(),
|
||||
createMockRendererComponent(),
|
||||
);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
|
||||
disposable.dispose();
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
|
||||
unsubscribe();
|
||||
manager.registerProvider(createMockRenderer(), createMockRendererComponent());
|
||||
expect(listener).toHaveBeenCalledTimes(2); // No further calls
|
||||
});
|
||||
|
||||
test('reset clears the provider slot', () => {
|
||||
const manager = DashboardRendererProviders.getInstance();
|
||||
|
||||
manager.registerProvider(createMockRenderer(), createMockRendererComponent());
|
||||
expect(manager.getProvider()).toBeDefined();
|
||||
|
||||
manager.reset();
|
||||
|
||||
expect(manager.getOverrideProvider()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('the default provider is active when no override is registered', () => {
|
||||
const manager = DashboardRendererProviders.getInstance();
|
||||
const defaultRenderer = createMockRenderer({ id: 'host.default' });
|
||||
const component = createMockRendererComponent();
|
||||
|
||||
manager.setDefaultProvider(defaultRenderer, component);
|
||||
|
||||
expect(manager.getProvider()?.renderer.id).toBe('host.default');
|
||||
expect(manager.getDefaultProvider()?.renderer.id).toBe('host.default');
|
||||
expect(manager.getOverrideProvider()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('an override wins over the default without displacing it', () => {
|
||||
const manager = DashboardRendererProviders.getInstance();
|
||||
const unregisterListener = jest.fn();
|
||||
manager.onDidUnregister(unregisterListener);
|
||||
|
||||
manager.setDefaultProvider(
|
||||
createMockRenderer({ id: 'host.default' }),
|
||||
createMockRendererComponent(),
|
||||
);
|
||||
manager.registerProvider(
|
||||
createMockRenderer({ id: 'ext.override' }),
|
||||
createMockRendererComponent(),
|
||||
);
|
||||
|
||||
expect(manager.getProvider()?.renderer.id).toBe('ext.override');
|
||||
expect(manager.getDefaultProvider()?.renderer.id).toBe('host.default');
|
||||
// Registering over the default must not fire an unregister for it
|
||||
expect(unregisterListener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('disposing the override falls back to the default provider', () => {
|
||||
const manager = DashboardRendererProviders.getInstance();
|
||||
|
||||
manager.setDefaultProvider(
|
||||
createMockRenderer({ id: 'host.default' }),
|
||||
createMockRendererComponent(),
|
||||
);
|
||||
const disposable = manager.registerProvider(
|
||||
createMockRenderer({ id: 'ext.override' }),
|
||||
createMockRendererComponent(),
|
||||
);
|
||||
|
||||
disposable.dispose();
|
||||
|
||||
expect(manager.getProvider()?.renderer.id).toBe('host.default');
|
||||
expect(manager.getOverrideProvider()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('setDefaultProvider is idempotent by renderer id', () => {
|
||||
const manager = DashboardRendererProviders.getInstance();
|
||||
const listener = jest.fn();
|
||||
manager.subscribe(listener);
|
||||
|
||||
const renderer = createMockRenderer({ id: 'host.default' });
|
||||
manager.setDefaultProvider(renderer, createMockRendererComponent());
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Re-registering the same id (e.g. duplicate side-effect import) is a no-op
|
||||
manager.setDefaultProvider(renderer, createMockRendererComponent());
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('reset keeps the default provider', () => {
|
||||
const manager = DashboardRendererProviders.getInstance();
|
||||
|
||||
manager.setDefaultProvider(
|
||||
createMockRenderer({ id: 'host.default' }),
|
||||
createMockRendererComponent(),
|
||||
);
|
||||
manager.registerProvider(
|
||||
createMockRenderer({ id: 'ext.override' }),
|
||||
createMockRendererComponent(),
|
||||
);
|
||||
|
||||
manager.reset();
|
||||
|
||||
expect(manager.getOverrideProvider()).toBeUndefined();
|
||||
expect(manager.getProvider()?.renderer.id).toBe('host.default');
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* 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 { dashboards } from '@apache-superset/core';
|
||||
import { Disposable } from '../models';
|
||||
import { createEventEmitter } from '../utils';
|
||||
|
||||
type DashboardRenderer = dashboards.DashboardRenderer;
|
||||
type DashboardRendererComponent = dashboards.DashboardRendererComponent;
|
||||
type DashboardRendererProvider = dashboards.DashboardRendererProvider;
|
||||
type DashboardRendererRegisteredEvent =
|
||||
dashboards.DashboardRendererRegisteredEvent;
|
||||
type DashboardRendererUnregisteredEvent =
|
||||
dashboards.DashboardRendererUnregisteredEvent;
|
||||
|
||||
type Listener<T> = (e: T) => void;
|
||||
|
||||
/**
|
||||
* Singleton manager for the dashboard renderer slot.
|
||||
*
|
||||
* The dashboard renderer is a single-slot contribution point with two
|
||||
* tiers: the host registers the built-in renderer as the DEFAULT provider,
|
||||
* and extensions register at most one OVERRIDE provider on top of it. The
|
||||
* active provider is the override when present, otherwise the default —
|
||||
* so dashboards always render, extensions or not. Registering an override
|
||||
* while the slot is occupied displaces (and unregisters) the previous
|
||||
* override with a console warning — the most recent registration wins;
|
||||
* the default provider is never displaced.
|
||||
*/
|
||||
class DashboardRendererProviders {
|
||||
private static instance: DashboardRendererProviders;
|
||||
|
||||
/**
|
||||
* The single extension-registered override slot.
|
||||
*/
|
||||
private provider: DashboardRendererProvider | undefined;
|
||||
|
||||
/**
|
||||
* The host-registered built-in provider, active when no override is.
|
||||
*/
|
||||
private defaultProvider: DashboardRendererProvider | undefined;
|
||||
|
||||
private registerEmitter =
|
||||
createEventEmitter<DashboardRendererRegisteredEvent>();
|
||||
|
||||
private unregisterEmitter =
|
||||
createEventEmitter<DashboardRendererUnregisteredEvent>();
|
||||
|
||||
private syncListeners: Set<() => void> = new Set();
|
||||
|
||||
/**
|
||||
* Stable-reference subscribe function for useSyncExternalStore.
|
||||
* Defined as an arrow property so the reference is bound to this instance at construction.
|
||||
*/
|
||||
public subscribe = (listener: () => void): (() => void) => {
|
||||
this.syncListeners.add(listener);
|
||||
return () => this.syncListeners.delete(listener);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-useless-constructor
|
||||
private constructor() {
|
||||
// Private constructor for singleton pattern
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singleton instance of DashboardRendererProviders.
|
||||
* @returns The singleton instance.
|
||||
*/
|
||||
public static getInstance(): DashboardRendererProviders {
|
||||
if (!DashboardRendererProviders.instance) {
|
||||
DashboardRendererProviders.instance = new DashboardRendererProviders();
|
||||
}
|
||||
return DashboardRendererProviders.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a dashboard renderer provider.
|
||||
* The most recently registered provider occupies the slot; a previously
|
||||
* registered provider is displaced and unregistered with a warning.
|
||||
*
|
||||
* @param renderer The renderer descriptor.
|
||||
* @param component The React component implementing the renderer.
|
||||
* @returns A Disposable to unregister the provider. Disposing after the
|
||||
* provider has been displaced by a newer registration is a no-op.
|
||||
*/
|
||||
public registerProvider(
|
||||
renderer: DashboardRenderer,
|
||||
component: DashboardRendererComponent,
|
||||
): Disposable {
|
||||
const displaced = this.provider;
|
||||
if (displaced) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`Multiple dashboard renderers registered. Using "${renderer.id}"; ` +
|
||||
`discarding "${displaced.renderer.id}".`,
|
||||
);
|
||||
this.unregisterEmitter.fire({ renderer: displaced.renderer });
|
||||
}
|
||||
|
||||
this.provider = { renderer, component };
|
||||
|
||||
// Fire registration event
|
||||
this.registerEmitter.fire({ renderer });
|
||||
this.syncListeners.forEach(l => l());
|
||||
|
||||
// Return disposable for cleanup
|
||||
return new Disposable(() => {
|
||||
// No-op when this provider is no longer the active one (displaced).
|
||||
if (this.provider?.renderer !== renderer) {
|
||||
return;
|
||||
}
|
||||
this.provider = undefined;
|
||||
this.unregisterEmitter.fire({ renderer });
|
||||
this.syncListeners.forEach(l => l());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the host's built-in renderer as the default provider.
|
||||
* Host-internal — not exposed on the public `dashboards` namespace.
|
||||
* Idempotent by id so duplicate side-effect imports are harmless.
|
||||
*
|
||||
* @param renderer The default renderer descriptor.
|
||||
* @param component The React component implementing the renderer.
|
||||
*/
|
||||
public setDefaultProvider(
|
||||
renderer: DashboardRenderer,
|
||||
component: DashboardRendererComponent,
|
||||
): void {
|
||||
if (this.defaultProvider?.renderer.id === renderer.id) {
|
||||
return;
|
||||
}
|
||||
this.defaultProvider = { renderer, component };
|
||||
this.syncListeners.forEach(l => l());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the built-in default dashboard renderer provider.
|
||||
* @returns The default provider or undefined if the host has not set one.
|
||||
*/
|
||||
public getDefaultProvider(): DashboardRendererProvider | undefined {
|
||||
return this.defaultProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the extension-registered override provider, ignoring the default.
|
||||
* @returns The override provider or undefined if none is registered.
|
||||
*/
|
||||
public getOverrideProvider(): DashboardRendererProvider | undefined {
|
||||
return this.provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active dashboard renderer provider: the extension-registered
|
||||
* override when present, otherwise the built-in default.
|
||||
* @returns The active provider or undefined if neither is registered.
|
||||
*/
|
||||
public getProvider(): DashboardRendererProvider | undefined {
|
||||
return this.provider ?? this.defaultProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to provider registration events.
|
||||
* @param listener The listener function.
|
||||
* @returns A Disposable to unsubscribe.
|
||||
*/
|
||||
public onDidRegister(
|
||||
listener: Listener<DashboardRendererRegisteredEvent>,
|
||||
thisArgs?: unknown,
|
||||
): Disposable {
|
||||
return this.registerEmitter.subscribe(listener, thisArgs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to provider unregistration events.
|
||||
* @param listener The listener function.
|
||||
* @returns A Disposable to unsubscribe.
|
||||
*/
|
||||
public onDidUnregister(
|
||||
listener: Listener<DashboardRendererUnregisteredEvent>,
|
||||
thisArgs?: unknown,
|
||||
): Disposable {
|
||||
return this.unregisterEmitter.subscribe(listener, thisArgs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the manager state (for testing purposes).
|
||||
* The default provider is kept unless requested: it is registered by a
|
||||
* one-time module side effect that will not re-run between tests.
|
||||
*
|
||||
* @param clearDefault When true, also clears the default provider.
|
||||
*/
|
||||
public reset(clearDefault = false): void {
|
||||
this.provider = undefined;
|
||||
if (clearDefault) {
|
||||
this.defaultProvider = undefined;
|
||||
}
|
||||
this.syncListeners.clear();
|
||||
this.registerEmitter =
|
||||
createEventEmitter<DashboardRendererRegisteredEvent>();
|
||||
this.unregisterEmitter =
|
||||
createEventEmitter<DashboardRendererUnregisteredEvent>();
|
||||
}
|
||||
}
|
||||
|
||||
export default DashboardRendererProviders;
|
||||
56
superset-frontend/src/core/dashboards/defaultRenderer.ts
Normal file
56
superset-frontend/src/core/dashboards/defaultRenderer.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 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 Registers Superset's built-in dashboard renderer as the
|
||||
* default provider in the dashboard renderer registry.
|
||||
*
|
||||
* The built-in renderer goes through the same contribution point as
|
||||
* extension-contributed renderers: it occupies the default tier of the
|
||||
* slot, renders whenever no extension override is active (including when
|
||||
* the extensions feature flag is off), and is retrievable via the public
|
||||
* `dashboards.getDefaultDashboardRenderer()` so extensions can wrap it.
|
||||
*
|
||||
* The component is imported lazily so that bundles importing the
|
||||
* `dashboards` namespace (e.g. the app startup bundle) do not statically
|
||||
* pull in the dashboard rendering stack.
|
||||
*/
|
||||
|
||||
import { lazy } from 'react';
|
||||
import type { dashboards } from '@apache-superset/core';
|
||||
import DashboardRendererProviders from './DashboardRendererProviders';
|
||||
|
||||
export const DEFAULT_DASHBOARD_RENDERER: dashboards.DashboardRenderer = {
|
||||
id: 'superset.dashboard-renderer',
|
||||
name: 'Superset Dashboard Renderer',
|
||||
description: "Superset's built-in dashboard renderer",
|
||||
};
|
||||
|
||||
const DefaultDashboardRenderer = lazy(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "DefaultDashboardRenderer" */
|
||||
'src/dashboard/components/DashboardRenderer/DefaultDashboardRenderer'
|
||||
),
|
||||
);
|
||||
|
||||
DashboardRendererProviders.getInstance().setDefaultProvider(
|
||||
DEFAULT_DASHBOARD_RENDERER,
|
||||
DefaultDashboardRenderer,
|
||||
);
|
||||
108
superset-frontend/src/core/dashboards/index.test.ts
Normal file
108
superset-frontend/src/core/dashboards/index.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 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 { createElement } from 'react';
|
||||
import type { dashboards as dashboardsApi } from '@apache-superset/core';
|
||||
import { dashboards } from './index';
|
||||
import DashboardRendererProviders from './DashboardRendererProviders';
|
||||
|
||||
const component: dashboardsApi.DashboardRendererComponent = () =>
|
||||
createElement('div', null, 'Custom renderer');
|
||||
|
||||
beforeEach(() => {
|
||||
DashboardRendererProviders.getInstance().reset();
|
||||
});
|
||||
|
||||
test('getDashboardRenderer returns the built-in default when nothing is registered', () => {
|
||||
expect(dashboards.getDashboardRenderer()?.renderer.id).toBe(
|
||||
'superset.dashboard-renderer',
|
||||
);
|
||||
});
|
||||
|
||||
test('getDefaultDashboardRenderer always returns the built-in provider', () => {
|
||||
expect(dashboards.getDefaultDashboardRenderer()?.renderer.id).toBe(
|
||||
'superset.dashboard-renderer',
|
||||
);
|
||||
|
||||
// Registering an override does not change the default
|
||||
const disposable = dashboards.registerDashboardRenderer(
|
||||
{ id: 'acme.renderer', name: 'Acme Renderer' },
|
||||
component,
|
||||
);
|
||||
expect(dashboards.getDefaultDashboardRenderer()?.renderer.id).toBe(
|
||||
'superset.dashboard-renderer',
|
||||
);
|
||||
disposable.dispose();
|
||||
});
|
||||
|
||||
test('registerDashboardRenderer makes the provider retrievable', () => {
|
||||
const descriptor = { id: 'acme.renderer', name: 'Acme Renderer' };
|
||||
dashboards.registerDashboardRenderer(descriptor, component);
|
||||
|
||||
const provider = dashboards.getDashboardRenderer();
|
||||
expect(provider?.renderer).toEqual(descriptor);
|
||||
expect(provider?.component).toBe(component);
|
||||
});
|
||||
|
||||
test('the last-registered renderer wins when multiple are registered', () => {
|
||||
jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
dashboards.registerDashboardRenderer(
|
||||
{ id: 'first.renderer', name: 'First' },
|
||||
component,
|
||||
);
|
||||
dashboards.registerDashboardRenderer(
|
||||
{ id: 'second.renderer', name: 'Second' },
|
||||
component,
|
||||
);
|
||||
|
||||
expect(dashboards.getDashboardRenderer()?.renderer.id).toBe(
|
||||
'second.renderer',
|
||||
);
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('disposing the registration falls back to the built-in default', () => {
|
||||
const disposable = dashboards.registerDashboardRenderer(
|
||||
{ id: 'acme.renderer', name: 'Acme Renderer' },
|
||||
component,
|
||||
);
|
||||
|
||||
expect(dashboards.getDashboardRenderer()?.renderer.id).toBe('acme.renderer');
|
||||
disposable.dispose();
|
||||
expect(dashboards.getDashboardRenderer()?.renderer.id).toBe(
|
||||
'superset.dashboard-renderer',
|
||||
);
|
||||
});
|
||||
|
||||
test('registration events fire through the public API', () => {
|
||||
const registered = jest.fn();
|
||||
const unregistered = jest.fn();
|
||||
dashboards.onDidRegisterDashboardRenderer(registered);
|
||||
dashboards.onDidUnregisterDashboardRenderer(unregistered);
|
||||
|
||||
const descriptor = { id: 'acme.renderer', name: 'Acme Renderer' };
|
||||
const disposable = dashboards.registerDashboardRenderer(
|
||||
descriptor,
|
||||
component,
|
||||
);
|
||||
disposable.dispose();
|
||||
|
||||
expect(registered).toHaveBeenCalledWith({ renderer: descriptor });
|
||||
expect(unregistered).toHaveBeenCalledWith({ renderer: descriptor });
|
||||
});
|
||||
58
superset-frontend/src/core/dashboards/index.ts
Normal file
58
superset-frontend/src/core/dashboards/index.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* 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 `dashboards` contribution type.
|
||||
*
|
||||
* Extensions register via the public `dashboards.registerDashboardRenderer()`
|
||||
* and the host resolves the active provider, falling back to the built-in
|
||||
* dashboard renderer when no extension is registered.
|
||||
*
|
||||
* The public namespace (`dashboards`) is exposed to extensions on
|
||||
* `window.superset`. `DashboardRendererHost` is the host-internal component
|
||||
* for rendering dashboards and is NOT part of the public
|
||||
* `@apache-superset/core` API.
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { dashboards as dashboardsApi } from '@apache-superset/core';
|
||||
import DashboardRendererProviders from './DashboardRendererProviders';
|
||||
// Registers the built-in renderer as the default provider (side effect)
|
||||
import './defaultRenderer';
|
||||
|
||||
export type { DashboardRendererHostProps } from './DashboardRendererHost';
|
||||
export { default as DashboardRendererHost } from './DashboardRendererHost';
|
||||
export { DEFAULT_DASHBOARD_RENDERER } from './defaultRenderer';
|
||||
|
||||
const provider = DashboardRendererProviders.getInstance();
|
||||
|
||||
export const useDashboardRenderer = () =>
|
||||
useSyncExternalStore(
|
||||
provider.subscribe,
|
||||
() => provider.getProvider(),
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
export const dashboards: typeof dashboardsApi = {
|
||||
registerDashboardRenderer: provider.registerProvider.bind(provider),
|
||||
getDashboardRenderer: provider.getProvider.bind(provider),
|
||||
getDefaultDashboardRenderer: provider.getDefaultProvider.bind(provider),
|
||||
onDidRegisterDashboardRenderer: provider.onDidRegister.bind(provider),
|
||||
onDidUnregisterDashboardRenderer: provider.onDidUnregister.bind(provider),
|
||||
};
|
||||
@@ -29,6 +29,7 @@ export const core: typeof coreType = {
|
||||
export * from './authentication';
|
||||
export * from './chat';
|
||||
export * from './commands';
|
||||
export * from './dashboards';
|
||||
export * from './editors';
|
||||
export * from './extensions';
|
||||
export * from './menus';
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 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 { ReactNode } from 'react';
|
||||
import { Suspense } from 'react';
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import type { dashboards } from '@apache-superset/core';
|
||||
import DashboardContainer from 'src/dashboard/containers/Dashboard';
|
||||
import DefaultDashboardRenderer from './DefaultDashboardRenderer';
|
||||
|
||||
jest.mock('src/dashboard/containers/Dashboard', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
jest.mock('src/dashboard/components/DashboardBuilder/DashboardBuilder', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-test="dashboard-builder">DashboardBuilder</div>,
|
||||
}));
|
||||
|
||||
jest.mock('src/dashboard/util/activeDashboardFilters', () => ({
|
||||
getActiveFilters: () => ({
|
||||
'legacy-filter': { scope: [99], values: {} },
|
||||
}),
|
||||
}));
|
||||
|
||||
const MockDashboardContainer = DashboardContainer as unknown as jest.Mock;
|
||||
|
||||
const rendererProps: dashboards.DashboardRendererProps = {
|
||||
dashboard: { id: 1, title: 'Ignored', metadata: {}, layout: {} },
|
||||
charts: [],
|
||||
datasets: [],
|
||||
initialDataMask: {},
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
dashboardInfo: { id: 1, metadata: { chart_configuration: {} } },
|
||||
dashboardState: { sliceIds: [7, 123] },
|
||||
nativeFilters: {
|
||||
filters: {
|
||||
'NATIVE_FILTER-x': {
|
||||
id: 'NATIVE_FILTER-x',
|
||||
chartsInScope: [7],
|
||||
filterType: 'filter_select',
|
||||
targets: [{ column: { name: 'country' }, datasetId: 3 }],
|
||||
},
|
||||
},
|
||||
},
|
||||
dataMask: {
|
||||
'NATIVE_FILTER-x': {
|
||||
id: 'NATIVE_FILTER-x',
|
||||
extraFormData: {
|
||||
filters: [{ col: 'country', op: 'IN', val: ['USA'] }],
|
||||
},
|
||||
filterState: { value: ['USA'] },
|
||||
},
|
||||
'123': {
|
||||
id: '123',
|
||||
extraFormData: {},
|
||||
filterState: {},
|
||||
ownState: { currentPage: 2, clientView: { rows: [1, 2, 3] } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
MockDashboardContainer.mockClear();
|
||||
});
|
||||
|
||||
const setup = () =>
|
||||
render(
|
||||
<Suspense fallback="loading">
|
||||
<DefaultDashboardRenderer {...rendererProps} />
|
||||
</Suspense>,
|
||||
{ useRedux: true, initialState },
|
||||
);
|
||||
|
||||
test('renders DashboardBuilder inside DashboardContainer', async () => {
|
||||
setup();
|
||||
|
||||
expect(await screen.findByTestId('dashboard-builder')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('derives activeFilters from Redux, merging legacy and native filters', async () => {
|
||||
setup();
|
||||
await screen.findByTestId('dashboard-builder');
|
||||
|
||||
const [[{ activeFilters }]] = MockDashboardContainer.mock.calls;
|
||||
// Legacy filter_box filters from the module singleton are merged in
|
||||
expect(activeFilters['legacy-filter']).toEqual({ scope: [99], values: {} });
|
||||
// Native filter resolved with its chartsInScope and extraFormData values
|
||||
expect(activeFilters['NATIVE_FILTER-x']).toEqual(
|
||||
expect.objectContaining({
|
||||
scope: [7],
|
||||
values: {
|
||||
filters: [{ col: 'country', op: 'IN', val: ['USA'] }],
|
||||
},
|
||||
filterType: 'filter_select',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('derives ownDataCharts from dataMask ownState, stripping clientView', async () => {
|
||||
setup();
|
||||
await screen.findByTestId('dashboard-builder');
|
||||
|
||||
const [[{ ownDataCharts }]] = MockDashboardContainer.mock.calls;
|
||||
// Only entries with ownState are relevant, and the TableChart clientView
|
||||
// payload must be stripped so it never triggers chart re-queries
|
||||
expect(ownDataCharts).toEqual({ '123': { currentPage: 2 } });
|
||||
});
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview The built-in dashboard renderer.
|
||||
*
|
||||
* This component conforms to the `DashboardRendererProps` contract from
|
||||
* `@apache-superset/core/dashboards` but intentionally ignores those props:
|
||||
* it reads everything from the Redux store the host hydrates. The contract
|
||||
* props exist so custom renderers contributed by extensions can be
|
||||
* Redux-free; migrating this built-in renderer to consume the props instead
|
||||
* of the store is incremental follow-up work behind the stable contract.
|
||||
*/
|
||||
|
||||
import { FC, lazy, useMemo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import type { dashboards } from '@apache-superset/core';
|
||||
import DashboardContainer from 'src/dashboard/containers/Dashboard';
|
||||
import {
|
||||
getAllActiveFilters,
|
||||
getRelevantDataMask,
|
||||
} from 'src/dashboard/util/activeAllDashboardFilters';
|
||||
import { getActiveFilters } from 'src/dashboard/util/activeDashboardFilters';
|
||||
import { AutoRefreshProvider } from 'src/dashboard/contexts/AutoRefreshContext';
|
||||
import type { ActiveFilters, RootState } from 'src/dashboard/types';
|
||||
|
||||
const DashboardBuilder = lazy(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "DashboardContainer" */
|
||||
/* webpackPreload: true */
|
||||
'src/dashboard/components/DashboardBuilder/DashboardBuilder'
|
||||
),
|
||||
);
|
||||
|
||||
const selectRelevantDatamask = createSelector(
|
||||
(state: RootState) => state.dataMask, // the first argument accesses relevant data from global state
|
||||
dataMask => getRelevantDataMask(dataMask, 'ownState'), // the second parameter conducts the transformation
|
||||
);
|
||||
|
||||
const selectChartConfiguration = (state: RootState) =>
|
||||
state.dashboardInfo.metadata?.chart_configuration;
|
||||
const selectNativeFilters = (state: RootState) => state.nativeFilters.filters;
|
||||
const selectDataMask = (state: RootState) => state.dataMask;
|
||||
const selectAllSliceIds = (state: RootState) => state.dashboardState.sliceIds;
|
||||
const selectActiveFilters = createSelector(
|
||||
[
|
||||
selectChartConfiguration,
|
||||
selectNativeFilters,
|
||||
selectDataMask,
|
||||
selectAllSliceIds,
|
||||
],
|
||||
(chartConfiguration, nativeFilters, dataMask, allSliceIds) => ({
|
||||
...getActiveFilters(),
|
||||
...getAllActiveFilters({
|
||||
// eslint-disable-next-line camelcase
|
||||
chartConfiguration,
|
||||
nativeFilters,
|
||||
dataMask,
|
||||
allSliceIds,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const DefaultDashboardRenderer: FC<dashboards.DashboardRendererProps> = () => {
|
||||
const relevantDataMask = useSelector(selectRelevantDatamask);
|
||||
const activeFilters = useSelector(selectActiveFilters);
|
||||
const dashboardBuilderComponent = useMemo(() => <DashboardBuilder />, []);
|
||||
|
||||
return (
|
||||
<AutoRefreshProvider>
|
||||
<DashboardContainer
|
||||
activeFilters={activeFilters as ActiveFilters}
|
||||
ownDataCharts={relevantDataMask}
|
||||
>
|
||||
{dashboardBuilderComponent}
|
||||
</DashboardContainer>
|
||||
</AutoRefreshProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default DefaultDashboardRenderer;
|
||||
@@ -31,8 +31,14 @@ import {
|
||||
useDashboardCharts,
|
||||
useDashboardDatasets,
|
||||
} from 'src/hooks/apiResources';
|
||||
import { SupersetApiError, SupersetClient } from '@superset-ui/core';
|
||||
import {
|
||||
FeatureFlag,
|
||||
SupersetApiError,
|
||||
SupersetClient,
|
||||
} from '@superset-ui/core';
|
||||
import type { dashboards } from '@apache-superset/core';
|
||||
import CrudThemeProvider from 'src/components/CrudThemeProvider';
|
||||
import DashboardRendererProviders from 'src/core/dashboards/DashboardRendererProviders';
|
||||
import { hydrateDashboard } from 'src/dashboard/actions/hydrate';
|
||||
import {
|
||||
clearDashboardHistory,
|
||||
@@ -158,6 +164,8 @@ const MockCrudThemeProvider = CrudThemeProvider as unknown as jest.Mock;
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
DashboardRendererProviders.getInstance().reset();
|
||||
window.featureFlags = {};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -602,6 +610,233 @@ test('renders a not-found state instead of throwing when the dashboard 404s', as
|
||||
expect(window.location.pathname).toBe('/dashboard/list/');
|
||||
});
|
||||
|
||||
test('renders the built-in dashboard renderer when no custom renderer is registered', async () => {
|
||||
render(
|
||||
<Suspense fallback="loading">
|
||||
<DashboardPage idOrSlug="1" />
|
||||
</Suspense>,
|
||||
{
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: {
|
||||
dashboardInfo: { id: 1, metadata: {} },
|
||||
dashboardState: { sliceIds: [] },
|
||||
nativeFilters: { filters: {} },
|
||||
dataMask: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(await screen.findByText('DashboardBuilder')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders a registered custom dashboard renderer with the contract props', async () => {
|
||||
window.featureFlags = { [FeatureFlag.EnableExtensions]: true };
|
||||
mockUseDashboardCharts.mockReturnValue({
|
||||
result: [{ id: 7, slice_name: 'Test chart' }],
|
||||
error: null,
|
||||
});
|
||||
mockUseDashboardDatasets.mockReturnValue({
|
||||
result: [{ id: 3, table_name: 'test_table' }],
|
||||
error: null,
|
||||
status: 'complete',
|
||||
});
|
||||
|
||||
const customRenderer = jest.fn(
|
||||
({ dashboard }: dashboards.DashboardRendererProps) => (
|
||||
<div data-test="custom-dashboard-renderer">{dashboard.title}</div>
|
||||
),
|
||||
);
|
||||
DashboardRendererProviders.getInstance().registerProvider(
|
||||
{ id: 'test.custom', name: 'Custom Renderer' },
|
||||
customRenderer,
|
||||
);
|
||||
|
||||
render(
|
||||
<Suspense fallback="loading">
|
||||
<DashboardPage idOrSlug="1" />
|
||||
</Suspense>,
|
||||
{
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: {
|
||||
dashboardInfo: { id: 1, metadata: {} },
|
||||
dashboardState: { sliceIds: [] },
|
||||
nativeFilters: { filters: {} },
|
||||
dataMask: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByTestId('custom-dashboard-renderer'),
|
||||
).toHaveTextContent('Test Dashboard');
|
||||
expect(screen.queryByText('DashboardBuilder')).not.toBeInTheDocument();
|
||||
|
||||
// The custom renderer receives the full contract props
|
||||
expect(customRenderer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
dashboard: expect.objectContaining({
|
||||
id: 1,
|
||||
title: 'Test Dashboard',
|
||||
isPublished: true,
|
||||
}),
|
||||
charts: [expect.objectContaining({ id: 7 })],
|
||||
datasets: [expect.objectContaining({ id: 3 })],
|
||||
initialDataMask: expect.any(Object),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
// Host behavior stays identical: the store is still hydrated so
|
||||
// permalinks, SyncDashboardState, and the embedded path keep working.
|
||||
expect(hydrateDashboard).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('renders the built-in renderer in edit mode even with a custom renderer registered', async () => {
|
||||
window.featureFlags = { [FeatureFlag.EnableExtensions]: true };
|
||||
DashboardRendererProviders.getInstance().registerProvider(
|
||||
{ id: 'test.custom', name: 'Custom Renderer' },
|
||||
() => <div data-test="custom-dashboard-renderer" />,
|
||||
);
|
||||
|
||||
render(
|
||||
<Suspense fallback="loading">
|
||||
<DashboardPage idOrSlug="1" />
|
||||
</Suspense>,
|
||||
{
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: {
|
||||
dashboardInfo: { id: 1, metadata: {} },
|
||||
dashboardState: { sliceIds: [], editMode: true },
|
||||
nativeFilters: { filters: {} },
|
||||
dataMask: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(await screen.findByText('DashboardBuilder')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('custom-dashboard-renderer'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('passes URL filter state to the custom renderer as initialDataMask', async () => {
|
||||
window.featureFlags = { [FeatureFlag.EnableExtensions]: true };
|
||||
mockGetUrlParam.mockImplementation((param: { name: string }) => {
|
||||
if (param.name === 'native_filters') {
|
||||
return {
|
||||
'NATIVE_FILTER-abc': {
|
||||
extraFormData: {
|
||||
filters: [{ col: 'country', op: 'IN', val: ['USA'] }],
|
||||
},
|
||||
filterState: { value: ['USA'] },
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const customRenderer = jest.fn(() => (
|
||||
<div data-test="custom-dashboard-renderer" />
|
||||
));
|
||||
DashboardRendererProviders.getInstance().registerProvider(
|
||||
{ id: 'test.custom', name: 'Custom Renderer' },
|
||||
customRenderer,
|
||||
);
|
||||
|
||||
render(
|
||||
<Suspense fallback="loading">
|
||||
<DashboardPage idOrSlug="1" />
|
||||
</Suspense>,
|
||||
{
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: {
|
||||
dashboardInfo: { id: 1, metadata: {} },
|
||||
dashboardState: { sliceIds: [] },
|
||||
nativeFilters: { filters: {} },
|
||||
dataMask: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('custom-dashboard-renderer');
|
||||
expect(customRenderer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
initialDataMask: expect.objectContaining({
|
||||
'NATIVE_FILTER-abc': expect.objectContaining({
|
||||
filterState: { value: ['USA'] },
|
||||
extraFormData: {
|
||||
filters: [{ col: 'country', op: 'IN', val: ['USA'] }],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
test('maps parsed dashboard metadata and layout into the renderer contract', async () => {
|
||||
window.featureFlags = { [FeatureFlag.EnableExtensions]: true };
|
||||
const metadata = { color_scheme: 'supersetColors' };
|
||||
const positionData = {
|
||||
ROOT_ID: { type: 'ROOT', id: 'ROOT_ID', children: ['GRID_ID'] },
|
||||
GRID_ID: { type: 'GRID', id: 'GRID_ID', children: [] },
|
||||
};
|
||||
mockUseDashboard.mockReturnValue({
|
||||
result: {
|
||||
...mockDashboard,
|
||||
uuid: 'abc-123',
|
||||
metadata,
|
||||
position_data: positionData,
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
|
||||
const customRenderer = jest.fn(() => (
|
||||
<div data-test="custom-dashboard-renderer" />
|
||||
));
|
||||
DashboardRendererProviders.getInstance().registerProvider(
|
||||
{ id: 'test.custom', name: 'Custom Renderer' },
|
||||
customRenderer,
|
||||
);
|
||||
|
||||
render(
|
||||
<Suspense fallback="loading">
|
||||
<DashboardPage idOrSlug="1" />
|
||||
</Suspense>,
|
||||
{
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: {
|
||||
dashboardInfo: { id: 1, metadata: {} },
|
||||
dashboardState: { sliceIds: [] },
|
||||
nativeFilters: { filters: {} },
|
||||
dataMask: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('custom-dashboard-renderer');
|
||||
expect(customRenderer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
dashboard: expect.objectContaining({
|
||||
uuid: 'abc-123',
|
||||
metadata,
|
||||
layout: positionData,
|
||||
}),
|
||||
uiConfig: expect.objectContaining({
|
||||
hideTitle: false,
|
||||
hideTab: false,
|
||||
hideChartControls: false,
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
test('clears undo history after hydrating the dashboard', async () => {
|
||||
render(
|
||||
<Suspense fallback="loading">
|
||||
|
||||
@@ -16,13 +16,13 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { createContext, lazy, FC, useEffect, useMemo, useRef } from 'react';
|
||||
import { createContext, FC, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Global } from '@emotion/react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import type { dashboards } from '@apache-superset/core';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { useTheme } from '@apache-superset/core/theme';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { useToasts } from 'src/components/MessageToasts/withToasts';
|
||||
import { EmptyState, Loading } from '@superset-ui/core/components';
|
||||
import {
|
||||
@@ -34,11 +34,6 @@ import { hydrateDashboard } from 'src/dashboard/actions/hydrate';
|
||||
import { clearDashboardHistory } from 'src/dashboard/actions/dashboardLayout';
|
||||
import { setDatasources } from 'src/dashboard/actions/datasources';
|
||||
import injectCustomCss from 'src/dashboard/util/injectCustomCss';
|
||||
import {
|
||||
getAllActiveFilters,
|
||||
getRelevantDataMask,
|
||||
} from 'src/dashboard/util/activeAllDashboardFilters';
|
||||
import { getActiveFilters } from 'src/dashboard/util/activeDashboardFilters';
|
||||
import { LocalStorageKeys, setItem } from 'src/utils/localStorageHelpers';
|
||||
import { URL_PARAMS } from 'src/constants';
|
||||
import { getUrlParam } from 'src/utils/urlUtils';
|
||||
@@ -49,12 +44,12 @@ import {
|
||||
getFilterValue,
|
||||
getPermalinkValue,
|
||||
} from 'src/dashboard/components/nativeFilters/FilterBar/keyValue';
|
||||
import DashboardContainer from 'src/dashboard/containers/Dashboard';
|
||||
import CrudThemeProvider from 'src/components/CrudThemeProvider';
|
||||
import { useUiConfig } from 'src/components/UiConfigContext';
|
||||
import DashboardRendererHost from 'src/core/dashboards/DashboardRendererHost';
|
||||
import type { DashboardChartStates } from 'src/dashboard/types/chartState';
|
||||
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { ActiveFilters } from '../types';
|
||||
import { RootState } from '../types';
|
||||
import {
|
||||
chartContextMenuStyles,
|
||||
@@ -66,7 +61,6 @@ import {
|
||||
import SyncDashboardState, {
|
||||
getDashboardContextLocalStorage,
|
||||
} from '../components/SyncDashboardState';
|
||||
import { AutoRefreshProvider } from '../contexts/AutoRefreshContext';
|
||||
import { Filter, PartialFilters, SupersetApiError } from '@superset-ui/core';
|
||||
import { RoutePaths } from 'src/views/routePaths';
|
||||
import {
|
||||
@@ -83,49 +77,19 @@ type NativeFilterConfigEntry = Partial<Filter> & { id: string };
|
||||
|
||||
export const DashboardPageIdContext = createContext('');
|
||||
|
||||
const DashboardBuilder = lazy(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "DashboardContainer" */
|
||||
/* webpackPreload: true */
|
||||
'src/dashboard/components/DashboardBuilder/DashboardBuilder'
|
||||
),
|
||||
);
|
||||
|
||||
type PageProps = {
|
||||
idOrSlug: string;
|
||||
};
|
||||
|
||||
// TODO: move to Dashboard.jsx when it's refactored to functional component
|
||||
const selectRelevantDatamask = createSelector(
|
||||
(state: RootState) => state.dataMask, // the first argument accesses relevant data from global state
|
||||
dataMask => getRelevantDataMask(dataMask, 'ownState'), // the second parameter conducts the transformation
|
||||
);
|
||||
|
||||
const selectChartConfiguration = (state: RootState) =>
|
||||
state.dashboardInfo.metadata?.chart_configuration;
|
||||
const selectNativeFilters = (state: RootState) => state.nativeFilters.filters;
|
||||
const selectDataMask = (state: RootState) => state.dataMask;
|
||||
const selectAllSliceIds = (state: RootState) => state.dashboardState.sliceIds;
|
||||
// TODO: move to Dashboard.jsx when it's refactored to functional component
|
||||
const selectActiveFilters = createSelector(
|
||||
[
|
||||
selectChartConfiguration,
|
||||
selectNativeFilters,
|
||||
selectDataMask,
|
||||
selectAllSliceIds,
|
||||
],
|
||||
(chartConfiguration, nativeFilters, dataMask, allSliceIds) => ({
|
||||
...getActiveFilters(),
|
||||
...getAllActiveFilters({
|
||||
// eslint-disable-next-line camelcase
|
||||
chartConfiguration,
|
||||
nativeFilters,
|
||||
dataMask,
|
||||
allSliceIds,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
/**
|
||||
* Initial dashboard state resolved from the URL (permalink, filter key, or
|
||||
* legacy rison params) before any renderer mounts.
|
||||
*/
|
||||
type InitialRendererState = {
|
||||
dataMask: dashboards.DashboardDataMask;
|
||||
activeTabs?: string[];
|
||||
anchor?: string;
|
||||
};
|
||||
|
||||
export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
|
||||
const theme = useTheme();
|
||||
@@ -150,6 +114,12 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
|
||||
status,
|
||||
} = useDashboardDatasets(idOrSlug);
|
||||
const isDashboardHydrated = useRef(false);
|
||||
const uiConfig = useUiConfig();
|
||||
const editMode = useSelector<RootState, boolean>(
|
||||
state => !!state.dashboardState?.editMode,
|
||||
);
|
||||
const [initialRendererState, setInitialRendererState] =
|
||||
useState<InitialRendererState>();
|
||||
|
||||
const error = dashboardApiError || chartsApiError;
|
||||
// Only 404 gets a graceful not-found state; a 403 (access denied) still
|
||||
@@ -318,6 +288,11 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
|
||||
} as unknown as Parameters<typeof hydrateDashboard>[0]),
|
||||
);
|
||||
dispatch(clearDashboardHistory());
|
||||
setInitialRendererState({
|
||||
dataMask: dataMask as dashboards.DashboardDataMask,
|
||||
activeTabs: activeTabs ?? undefined,
|
||||
anchor,
|
||||
});
|
||||
|
||||
// Scroll to anchor element if specified in permalink state
|
||||
if (anchor) {
|
||||
@@ -380,8 +355,38 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
|
||||
}
|
||||
}, [addDangerToast, datasets, datasetsApiError, dispatch, isNotFoundError]);
|
||||
|
||||
const relevantDataMask = useSelector(selectRelevantDatamask);
|
||||
const activeFilters = useSelector(selectActiveFilters);
|
||||
const rendererProps = useMemo<
|
||||
dashboards.DashboardRendererProps | undefined
|
||||
>(() => {
|
||||
if (!dashboard || !charts || !initialRendererState) return undefined;
|
||||
return {
|
||||
dashboard: {
|
||||
id: dashboard.id,
|
||||
uuid: dashboard.uuid,
|
||||
slug: dashboard.slug,
|
||||
title: dashboard.dashboard_title,
|
||||
css: dashboard.css,
|
||||
metadata: dashboard.metadata ?? {},
|
||||
layout: dashboard.position_data ?? {},
|
||||
isPublished: dashboard.published,
|
||||
isManagedExternally: dashboard.is_managed_externally,
|
||||
},
|
||||
// The API payloads satisfy the contract shapes structurally, but the
|
||||
// host types (Chart, Datasource) lack the contract's index signatures,
|
||||
// so the conversion has to go through `unknown`.
|
||||
charts: charts as unknown as dashboards.DashboardChart[],
|
||||
datasets: (datasets ?? []) as unknown as dashboards.DashboardDataset[],
|
||||
initialDataMask: initialRendererState.dataMask,
|
||||
initialActiveTabs: initialRendererState.activeTabs,
|
||||
initialAnchor: initialRendererState.anchor,
|
||||
uiConfig: {
|
||||
hideTitle: uiConfig.hideTitle,
|
||||
hideTab: uiConfig.hideTab,
|
||||
hideChartControls: uiConfig.hideChartControls,
|
||||
emitDataMasks: uiConfig.emitDataMasks,
|
||||
},
|
||||
};
|
||||
}, [dashboard, charts, datasets, initialRendererState, uiConfig]);
|
||||
|
||||
if (error && !isNotFoundError) throw error; // caught in error boundary
|
||||
|
||||
@@ -398,8 +403,6 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
|
||||
|
||||
if (error && !isNotFoundError) throw error; // caught in error boundary
|
||||
|
||||
const DashboardBuilderComponent = useMemo(() => <DashboardBuilder />, []);
|
||||
|
||||
if (isNotFoundError) {
|
||||
return (
|
||||
<EmptyState
|
||||
@@ -418,21 +421,14 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
|
||||
return (
|
||||
<>
|
||||
<Global styles={globalStyles} />
|
||||
{readyToRender && hasDashboardInfoInitiated ? (
|
||||
{readyToRender && hasDashboardInfoInitiated && rendererProps ? (
|
||||
<>
|
||||
<SyncDashboardState dashboardPageId={dashboardPageId} />
|
||||
<DashboardPageIdContext.Provider value={dashboardPageId}>
|
||||
<CrudThemeProvider
|
||||
theme={reduxTheme !== undefined ? reduxTheme : dashboard?.theme}
|
||||
>
|
||||
<AutoRefreshProvider>
|
||||
<DashboardContainer
|
||||
activeFilters={activeFilters as ActiveFilters}
|
||||
ownDataCharts={relevantDataMask}
|
||||
>
|
||||
{DashboardBuilderComponent}
|
||||
</DashboardContainer>
|
||||
</AutoRefreshProvider>
|
||||
<DashboardRendererHost editMode={editMode} {...rendererProps} />
|
||||
</CrudThemeProvider>
|
||||
</DashboardPageIdContext.Provider>
|
||||
</>
|
||||
|
||||
@@ -100,6 +100,10 @@ test('sets up global superset object when user is logged in', async () => {
|
||||
expect((window as any).superset.chat).toBeDefined();
|
||||
expect((window as any).superset.core).toBeDefined();
|
||||
expect((window as any).superset.commands).toBeDefined();
|
||||
expect((window as any).superset.dashboards).toBeDefined();
|
||||
expect(
|
||||
typeof (window as any).superset.dashboards.registerDashboardRenderer,
|
||||
).toBe('function');
|
||||
expect((window as any).superset.extensions).toBeDefined();
|
||||
expect((window as any).superset.menus).toBeDefined();
|
||||
expect((window as any).superset.views).toBeDefined();
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
chat,
|
||||
core,
|
||||
commands,
|
||||
dashboards,
|
||||
editors,
|
||||
extensions,
|
||||
menus,
|
||||
@@ -59,6 +60,7 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
|
||||
chat,
|
||||
core,
|
||||
commands,
|
||||
dashboards,
|
||||
editors,
|
||||
extensions,
|
||||
menus,
|
||||
|
||||
@@ -31,6 +31,7 @@ import type {
|
||||
chat,
|
||||
commands,
|
||||
core,
|
||||
dashboards,
|
||||
editors,
|
||||
extensions,
|
||||
menus,
|
||||
@@ -45,6 +46,7 @@ export interface Namespaces {
|
||||
core: typeof core;
|
||||
chat: typeof chat;
|
||||
commands: typeof commands;
|
||||
dashboards: typeof dashboards;
|
||||
editors: typeof editors;
|
||||
extensions: typeof extensions;
|
||||
menus: typeof menus;
|
||||
|
||||
@@ -21,6 +21,7 @@ import Role from './Role';
|
||||
|
||||
export interface Dashboard {
|
||||
id: number;
|
||||
uuid?: string;
|
||||
slug?: string | null;
|
||||
url: string;
|
||||
dashboard_title: string;
|
||||
@@ -32,6 +33,7 @@ export interface Dashboard {
|
||||
changed_by_name: string;
|
||||
changed_by: Owner;
|
||||
changed_on: string;
|
||||
is_managed_externally?: boolean;
|
||||
charts: string[]; // just chart names, unfortunately...
|
||||
owners: Owner[];
|
||||
extra_owners?: Owner[];
|
||||
|
||||
Reference in New Issue
Block a user