feat(extensions): add dashboard renderer contribution point

Introduces a single-slot `dashboards` contribution point (SIP-151
architecture) that lets an extension replace Superset's built-in
dashboard renderer while the host keeps owning data fetching,
hydration, URL/permalink resolution, CSS injection, and theming.

- New `dashboards` namespace in @apache-superset/core defining the
  DashboardRenderer descriptor and the Redux-free DashboardRendererProps
  contract (dashboard identity/metadata/layout, charts, datasets,
  initial dataMask/tabs/anchor, uiConfig, reserved change callbacks).
- Host registry (DashboardRendererProviders) with chat-style singleton
  semantics: most recent registration wins, displaced providers are
  unregistered with a warning, disposal of a displaced provider is a
  no-op.
- DashboardRendererHost resolves the active provider via
  useSyncExternalStore (late registration swaps live), wraps custom
  renderers in an ErrorBoundary, and always falls back to the built-in
  renderer in edit mode or when the EnableExtensions flag is off.
- The built-in stack (DashboardContainer/DashboardBuilder + filter
  selectors) moves behind the same contract as DefaultDashboardRenderer,
  preserving the lazy DashboardBuilder chunk.
- DashboardPage now builds the contract props from data it already
  fetches and renders DashboardRendererHost; hydration and all other
  host behavior are unchanged, so the embedded path inherits the seam.

This is the first step toward a fully Redux-decoupled dashboard
renderer and an Embedded SDK that is a thin wrapper over this
extension point.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Claude Code
2026-07-02 15:45:14 -07:00
parent 8bf3933972
commit fefe877cda
18 changed files with 1400 additions and 64 deletions

View File

@@ -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.

View File

@@ -0,0 +1,111 @@
---
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:
- 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 }) => (
<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()`.
## 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.

View File

@@ -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"

View File

@@ -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;
}

View File

@@ -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<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; 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<DashboardRendererRegisteredEvent>;
/**
* Event fired when a dashboard renderer is unregistered.
*/
export declare const onDidUnregisterDashboardRenderer: Event<DashboardRendererUnregisteredEvent>;

View File

@@ -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';

View File

@@ -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) => (
<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', () => {
render(<DashboardRendererHost {...rendererProps} />);
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) => (
<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', () => {
DashboardRendererProviders.getInstance().registerProvider(
{ id: 'test.custom', name: 'Custom Renderer' },
() => <div data-test="custom-dashboard-renderer" />,
);
render(<DashboardRendererHost editMode {...rendererProps} />);
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' },
() => <div data-test="custom-dashboard-renderer" />,
);
render(<DashboardRendererHost {...rendererProps} />);
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(<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', () => {
render(<DashboardRendererHost {...rendererProps} />);
expect(screen.getByTestId('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();
});

View File

@@ -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 (
<ErrorBoundary>
<RendererComponent {...rendererProps} />
</ErrorBoundary>
);
}
return <DefaultDashboardRenderer {...rendererProps} />;
};
export default DashboardRendererHost;
export { DashboardRendererHost };

View File

@@ -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> = {},
): 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();
});

View File

@@ -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<T> = (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<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());
});
}
/**
* 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<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).
*/
public reset(): void {
this.provider = undefined;
this.syncListeners.clear();
this.registerEmitter =
createEventEmitter<DashboardRendererRegisteredEvent>();
this.unregisterEmitter =
createEventEmitter<DashboardRendererUnregisteredEvent>();
}
}
export default DashboardRendererProviders;

View File

@@ -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),
};

View File

@@ -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';

View File

@@ -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;

View File

@@ -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(
<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('clears undo history after hydrating the dashboard', async () => {
render(
<Suspense fallback="loading">

View File

@@ -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>
</>

View File

@@ -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,

View File

@@ -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;

View File

@@ -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[];