diff --git a/docs/developer_docs/extensions/contribution-types.md b/docs/developer_docs/extensions/contribution-types.md index f339fc01949..3590650c19f 100644 --- a/docs/developer_docs/extensions/contribution-types.md +++ b/docs/developer_docs/extensions/contribution-types.md @@ -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. diff --git a/docs/developer_docs/extensions/extension-points/dashboards.md b/docs/developer_docs/extensions/extension-points/dashboards.md new file mode 100644 index 00000000000..e4c54e83bb5 --- /dev/null +++ b/docs/developer_docs/extensions/extension-points/dashboards.md @@ -0,0 +1,111 @@ +--- +title: Dashboards +sidebar_position: 4 +--- + + + +# 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: + +- At most one custom renderer is active at a time. The most recently registered renderer wins; a previously registered renderer is displaced and unregistered with a console warning. +- When no custom renderer is registered, the host renders the built-in dashboard renderer. +- 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 }) => ( +
+

{dashboard.title}

+ {/* render charts from `charts` + `dashboard.layout` */} +
+); + +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()`. + +## 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. diff --git a/superset-frontend/packages/superset-core/package.json b/superset-frontend/packages/superset-core/package.json index ddea4ffb3d5..b7fc47b63ed 100644 --- a/superset-frontend/packages/superset-core/package.json +++ b/superset-frontend/packages/superset-core/package.json @@ -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" diff --git a/superset-frontend/packages/superset-core/src/contributions/index.ts b/superset-frontend/packages/superset-core/src/contributions/index.ts index f24ae36dba0..7719bf49537 100644 --- a/superset-frontend/packages/superset-core/src/contributions/index.ts +++ b/superset-frontend/packages/superset-core/src/contributions/index.ts @@ -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; } diff --git a/superset-frontend/packages/superset-core/src/dashboards/index.ts b/superset-frontend/packages/superset-core/src/dashboards/index.ts new file mode 100644 index 00000000000..7a4d4388a7d --- /dev/null +++ b/superset-frontend/packages/superset-core/src/dashboards/index.ts @@ -0,0 +1,285 @@ +/** + * 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. When no custom renderer is registered, the host renders + * the built-in dashboard 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; + /** Query-facing form data merged into affected charts' queries */ + extraFormData?: Record; + /** Renderer/chart-private state (e.g. table pagination, search) */ + ownState?: Record; +} + +/** + * 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; + +/** + * 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; + /** + * 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; + /** 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; + [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[]; + /** Metric definitions */ + metrics?: Record[]; + [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; + +/** + * 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; 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 currently registered dashboard renderer provider. + * + * @returns The active provider, or undefined if no extension has + * registered a renderer (the host uses its built-in renderer). + */ +export declare function getDashboardRenderer(): + | DashboardRendererProvider + | undefined; + +/** + * Event fired when a dashboard renderer is registered. + */ +export declare const onDidRegisterDashboardRenderer: Event; + +/** + * Event fired when a dashboard renderer is unregistered. + */ +export declare const onDidUnregisterDashboardRenderer: Event; diff --git a/superset-frontend/packages/superset-core/src/index.ts b/superset-frontend/packages/superset-core/src/index.ts index be41ac88a19..3bff58a46c1 100644 --- a/superset-frontend/packages/superset-core/src/index.ts +++ b/superset-frontend/packages/superset-core/src/index.ts @@ -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'; diff --git a/superset-frontend/src/core/dashboards/DashboardRendererHost.test.tsx b/superset-frontend/src/core/dashboards/DashboardRendererHost.test.tsx new file mode 100644 index 00000000000..1591b33f3a6 --- /dev/null +++ b/superset-frontend/src/core/dashboards/DashboardRendererHost.test.tsx @@ -0,0 +1,182 @@ +/** + * 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) => ( +
+ {dashboard.id} +
+ ), + }), +); + +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', () => { + render(); + + expect(screen.getByTestId('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) => ( +
+ {dashboard.title} + {Object.keys(initialDataMask).join(',')} +
+ ), + ); + DashboardRendererProviders.getInstance().registerProvider( + { id: 'test.custom', name: 'Custom Renderer' }, + customRenderer, + ); + + render(); + + 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', () => { + DashboardRendererProviders.getInstance().registerProvider( + { id: 'test.custom', name: 'Custom Renderer' }, + () =>
, + ); + + render(); + + expect(screen.getByTestId('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', () => { + mockIsFeatureEnabled.mockReturnValue(false); + DashboardRendererProviders.getInstance().registerProvider( + { id: 'test.custom', name: 'Custom Renderer' }, + () =>
, + ); + + render(); + + expect(screen.getByTestId('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(); + + 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', () => { + render(); + + expect(screen.getByTestId('default-dashboard-renderer')).toBeInTheDocument(); + + act(() => { + DashboardRendererProviders.getInstance().registerProvider( + { id: 'test.late', name: 'Late Renderer' }, + () =>
, + ); + }); + + expect(screen.getByTestId('custom-dashboard-renderer')).toBeInTheDocument(); + expect( + screen.queryByTestId('default-dashboard-renderer'), + ).not.toBeInTheDocument(); +}); diff --git a/superset-frontend/src/core/dashboards/DashboardRendererHost.tsx b/superset-frontend/src/core/dashboards/DashboardRendererHost.tsx new file mode 100644 index 00000000000..9b26a6afddb --- /dev/null +++ b/superset-frontend/src/core/dashboards/DashboardRendererHost.tsx @@ -0,0 +1,88 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * @fileoverview DashboardRendererHost component for dynamic dashboard + * renderer resolution. + * + * This component resolves and renders the appropriate dashboard renderer + * implementation. If an extension has registered a custom renderer (and the + * dashboard is not in edit mode), it uses that; otherwise, it falls back to + * the built-in dashboard renderer. + */ + +import { useSyncExternalStore } from 'react'; +import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core'; +import type { dashboards } from '@apache-superset/core'; +import { ErrorBoundary } from 'src/components/ErrorBoundary'; +import DefaultDashboardRenderer from 'src/dashboard/components/DashboardRenderer/DefaultDashboardRenderer'; +import DashboardRendererProviders from './DashboardRendererProviders'; + +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 falls back to the built-in + * renderer. 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 provider = useSyncExternalStore( + manager.subscribe, + () => manager.getProvider(), + () => 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 useCustomRenderer = + !editMode && + provider !== undefined && + isFeatureEnabled(FeatureFlag.EnableExtensions); + + if (useCustomRenderer) { + const RendererComponent = provider.component; + return ( + + + + ); + } + + return ; +}; + +export default DashboardRendererHost; + +export { DashboardRendererHost }; diff --git a/superset-frontend/src/core/dashboards/DashboardRendererProviders.test.ts b/superset-frontend/src/core/dashboards/DashboardRendererProviders.test.ts new file mode 100644 index 00000000000..d809cb91ee8 --- /dev/null +++ b/superset-frontend/src/core/dashboards/DashboardRendererProviders.test.ts @@ -0,0 +1,224 @@ +/** + * 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 { + 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 + const manager = DashboardRendererProviders.getInstance(); + manager.reset(); +}); + +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.getProvider()).toBeUndefined(); +}); diff --git a/superset-frontend/src/core/dashboards/DashboardRendererProviders.ts b/superset-frontend/src/core/dashboards/DashboardRendererProviders.ts new file mode 100644 index 00000000000..c5562e754f5 --- /dev/null +++ b/superset-frontend/src/core/dashboards/DashboardRendererProviders.ts @@ -0,0 +1,170 @@ +/** + * 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 = (e: T) => void; + +/** + * Singleton manager for the dashboard renderer slot. + * + * The dashboard renderer is a single-slot contribution point: at most one + * provider is active at a time. Registering while the slot is occupied + * displaces (and unregisters) the previous provider with a console warning — + * the most recent registration wins. + */ +class DashboardRendererProviders { + private static instance: DashboardRendererProviders; + + /** + * The single active provider slot. + */ + private provider: DashboardRendererProvider | undefined; + + private registerEmitter = + createEventEmitter(); + + private unregisterEmitter = + createEventEmitter(); + + 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()); + }); + } + + /** + * Get the active dashboard renderer provider. + * @returns The provider or undefined if none is registered. + */ + public getProvider(): DashboardRendererProvider | undefined { + return this.provider; + } + + /** + * Subscribe to provider registration events. + * @param listener The listener function. + * @returns A Disposable to unsubscribe. + */ + public onDidRegister( + listener: Listener, + 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, + thisArgs?: unknown, + ): Disposable { + return this.unregisterEmitter.subscribe(listener, thisArgs); + } + + /** + * Reset the manager state (for testing purposes). + */ + public reset(): void { + this.provider = undefined; + this.syncListeners.clear(); + this.registerEmitter = + createEventEmitter(); + this.unregisterEmitter = + createEventEmitter(); + } +} + +export default DashboardRendererProviders; diff --git a/superset-frontend/src/core/dashboards/index.ts b/superset-frontend/src/core/dashboards/index.ts new file mode 100644 index 00000000000..cf6d1369d92 --- /dev/null +++ b/superset-frontend/src/core/dashboards/index.ts @@ -0,0 +1,54 @@ +/** + * 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'; + +export type { DashboardRendererHostProps } from './DashboardRendererHost'; +export { default as DashboardRendererHost } from './DashboardRendererHost'; + +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), + onDidRegisterDashboardRenderer: provider.onDidRegister.bind(provider), + onDidUnregisterDashboardRenderer: provider.onDidUnregister.bind(provider), +}; diff --git a/superset-frontend/src/core/index.ts b/superset-frontend/src/core/index.ts index ce5b7f4d505..0eaa7368760 100644 --- a/superset-frontend/src/core/index.ts +++ b/superset-frontend/src/core/index.ts @@ -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'; diff --git a/superset-frontend/src/dashboard/components/DashboardRenderer/DefaultDashboardRenderer.tsx b/superset-frontend/src/dashboard/components/DashboardRenderer/DefaultDashboardRenderer.tsx new file mode 100644 index 00000000000..5d78665823b --- /dev/null +++ b/superset-frontend/src/dashboard/components/DashboardRenderer/DefaultDashboardRenderer.tsx @@ -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 = () => { + const relevantDataMask = useSelector(selectRelevantDatamask); + const activeFilters = useSelector(selectActiveFilters); + const dashboardBuilderComponent = useMemo(() => , []); + + return ( + + + {dashboardBuilderComponent} + + + ); +}; + +export default DefaultDashboardRenderer; diff --git a/superset-frontend/src/dashboard/containers/DashboardPage.test.tsx b/superset-frontend/src/dashboard/containers/DashboardPage.test.tsx index e66d271d0a2..7b0e3103478 100644 --- a/superset-frontend/src/dashboard/containers/DashboardPage.test.tsx +++ b/superset-frontend/src/dashboard/containers/DashboardPage.test.tsx @@ -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,89 @@ 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( + + + , + { + 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) => ( +
{dashboard.title}
+ ), + ); + DashboardRendererProviders.getInstance().registerProvider( + { id: 'test.custom', name: 'Custom Renderer' }, + customRenderer, + ); + + render( + + + , + { + 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('clears undo history after hydrating the dashboard', async () => { render( diff --git a/superset-frontend/src/dashboard/containers/DashboardPage.tsx b/superset-frontend/src/dashboard/containers/DashboardPage.tsx index 516b04ba786..e4663318a28 100644 --- a/superset-frontend/src/dashboard/containers/DashboardPage.tsx +++ b/superset-frontend/src/dashboard/containers/DashboardPage.tsx @@ -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 & { 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 = ({ idOrSlug }: PageProps) => { const theme = useTheme(); @@ -150,6 +114,12 @@ export const DashboardPage: FC = ({ idOrSlug }: PageProps) => { status, } = useDashboardDatasets(idOrSlug); const isDashboardHydrated = useRef(false); + const uiConfig = useUiConfig(); + const editMode = useSelector( + state => !!state.dashboardState?.editMode, + ); + const [initialRendererState, setInitialRendererState] = + useState(); 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 = ({ idOrSlug }: PageProps) => { } as unknown as Parameters[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 = ({ 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 = ({ idOrSlug }: PageProps) => { if (error && !isNotFoundError) throw error; // caught in error boundary - const DashboardBuilderComponent = useMemo(() => , []); - if (isNotFoundError) { return ( = ({ idOrSlug }: PageProps) => { return ( <> - {readyToRender && hasDashboardInfoInitiated ? ( + {readyToRender && hasDashboardInfoInitiated && rendererProps ? ( <> - - - {DashboardBuilderComponent} - - + diff --git a/superset-frontend/src/extensions/ExtensionsStartup.tsx b/superset-frontend/src/extensions/ExtensionsStartup.tsx index dd639ec1b18..c891d9f47fd 100644 --- a/superset-frontend/src/extensions/ExtensionsStartup.tsx +++ b/superset-frontend/src/extensions/ExtensionsStartup.tsx @@ -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, diff --git a/superset-frontend/src/extensions/Namespaces.ts b/superset-frontend/src/extensions/Namespaces.ts index 0e7f6a3063b..7e43f4a3508 100644 --- a/superset-frontend/src/extensions/Namespaces.ts +++ b/superset-frontend/src/extensions/Namespaces.ts @@ -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; diff --git a/superset-frontend/src/types/Dashboard.ts b/superset-frontend/src/types/Dashboard.ts index 99c16f3fc8e..3ac7de07e57 100644 --- a/superset-frontend/src/types/Dashboard.ts +++ b/superset-frontend/src/types/Dashboard.ts @@ -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[];