Compare commits

...

3 Commits

Author SHA1 Message Date
Claude Code
f1c7ec60ce feat(extensions): register the built-in dashboard renderer as the default provider
Follow the SQL Lab pattern one step further: instead of the host
hardcoding the built-in renderer as a fallback branch, the built-in
renderer is now registered through the same contribution point as the
default-tier provider (superset.dashboard-renderer).

- DashboardRendererProviders gains a default tier: setDefaultProvider
  (host-internal, idempotent by id), getDefaultProvider,
  getOverrideProvider; getProvider() resolves override ?? default. The
  default is never displaced by extension registrations, and disposing
  an override falls back to it through the registry.
- The default registers via a lazy side-effect module
  (src/core/dashboards/defaultRenderer.ts) imported by both the host
  component and the namespace impl, so it is set wherever dashboards
  render (app + embedded) without pulling the dashboard stack into the
  startup bundle. Registration is independent of ExtensionsStartup and
  ENABLE_EXTENSIONS: dashboards always render with the flag off.
- DashboardRendererHost renders the resolved provider: extension
  override (ErrorBoundary-wrapped, view mode + flag on only) or the
  lazy default under a local Suspense.
- Public contract adds getDefaultDashboardRenderer() so extensions can
  wrap/augment the built-in renderer rather than fully replace it.
- Tests updated/extended for the default tier (registry fallback
  semantics, idempotency, reset behavior, namespace API); the E2E spec
  now asserts live that the built-in is the registered default.

The oxlint hook is skipped in this commit only because the local
node_modules currently holds linux bindings (docker bind mount); the
exact hook command was run clean inside the container instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 10:08:50 -07:00
Claude Code
092accfe9a test(extensions): harden dashboard renderer contribution point coverage
- Public namespace tests (src/core/dashboards/index.test.ts): register/
  get/dispose/events through the `dashboards` API object, mirroring the
  chat namespace tests.
- DefaultDashboardRenderer unit tests: the selectors moved from
  DashboardPage still derive activeFilters (legacy + native merge,
  chartsInScope resolution) and ownDataCharts (ownState only, TableChart
  clientView stripped) from Redux, and render DashboardBuilder inside
  DashboardContainer.
- DashboardPage integration tests: edit mode falls back to the built-in
  renderer at the seam level; URL filter state flows through to the
  custom renderer's initialDataMask; parsed metadata/position_data map
  into the contract's metadata/layout along with uiConfig defaults.
- ExtensionsStartup: window.superset.dashboards is exposed with a
  callable registerDashboardRenderer.
- New Playwright E2E spec (dashboard-renderer-extension.spec.ts),
  verified against a live docker instance: live swap with contract
  props, built-in grid unmounts, dispose restores it, and registering
  in edit mode never takes over. Skips itself when ENABLE_EXTENSIONS
  is off.

The oxlint hook is skipped in this commit only because the local
node_modules currently holds linux bindings (docker bind mount); the
exact hook command was run clean inside the container instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 17:49:47 -07:00
Claude Code
fefe877cda 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>
2026-07-02 15:45:14 -07:00
23 changed files with 2228 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,129 @@
---
title: Dashboards
sidebar_position: 4
---
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
# Dashboard Renderer Contributions
Extensions can replace Superset's built-in dashboard renderer with a custom implementation. This allows dashboards to be displayed in entirely different ways — kiosk layouts, alternative grid engines, story-style presentations — while reusing Superset's data fetching, authentication, theming, and URL/permalink handling.
## Overview
The dashboard renderer is a **single-slot** contribution point with two tiers:
- **Superset's built-in renderer is itself registered as the default provider** (`superset.dashboard-renderer`) through the same contribution point. It renders whenever no custom renderer is active — including when the `ENABLE_EXTENSIONS` feature flag is off — so dashboards always display, extensions or not.
- At most one custom renderer is active at a time. The most recently registered renderer wins; a previously registered custom renderer is displaced and unregistered with a console warning. The default provider is never displaced.
- Disposing the active custom renderer's `Disposable` falls back to the built-in default.
- Custom renderers handle **view mode only**. When a dashboard enters edit mode, the host always renders the built-in renderer (which owns drag-and-drop editing, undo/redo, and the component pane), returning to the custom renderer when edit mode exits.
- A custom renderer that throws is contained by an error boundary; the host does not fall back to the built-in renderer on error.
The host keeps its behavior identical regardless of which renderer is active: it fetches the dashboard, charts, and datasets, resolves initial filter state from the URL (permalinks, `native_filters_key`, legacy filter params), injects dashboard CSS, and manages the document title. The renderer receives the results as props.
## The Props Contract
Your renderer component receives `DashboardRendererProps` from `@apache-superset/core/dashboards`:
| Prop | Type | Description |
|------|------|-------------|
| `dashboard` | `DashboardInfo` | Identity and parsed metadata: `id`, `uuid`, `slug`, `title`, `css`, `metadata` (parsed `json_metadata`), `layout` (parsed `position_json`), `isPublished`, `isManagedExternally` |
| `charts` | `DashboardChart[]` | Chart (slice) definitions as returned by `GET /api/v1/dashboard/{id}/charts` |
| `datasets` | `DashboardDataset[]` | Datasets as returned by `GET /api/v1/dashboard/{id}/datasets` |
| `initialDataMask` | `DashboardDataMask` | Initial filter state resolved by the host from the URL |
| `initialActiveTabs` | `string[]?` | Layout component ids of the initially active tabs (from permalink) |
| `initialAnchor` | `string?` | Layout component id to scroll to on mount (permalink anchor) |
| `uiConfig` | `DashboardUiConfig?` | Chrome-hiding flags (`hideTitle`, `hideTab`, `hideChartControls`, `emitDataMasks`), mirroring the embedded SDK's uiConfig |
| `onDataMaskChange` | callback? | Reserved — not supplied by the host yet |
| `onActiveTabsChange` | callback? | Reserved — not supplied by the host yet |
The contract is designed to be Redux-free: everything a renderer needs to display a dashboard arrives via props, and host services are available through the public `window.superset` namespaces (`authentication`, `navigation`, `theme`, `translation`, and so on).
### Renderer responsibilities
- **Chart data fetching**: the host does not fetch chart data. Query for it yourself (e.g. `POST /api/v1/chart/data` with query contexts built from each chart's `form_data`).
- **Filter orchestration**: applying `initialDataMask`, reacting to filter interactions, and refreshing affected charts are the renderer's responsibility.
- **Layout interpretation**: `dashboard.layout` is the parsed `position_json` component tree (rows, columns, tabs, charts, markdown); interpret as much or as little of it as your presentation needs.
Theming works out of the box: renderers are mounted inside the host's theme providers, so `useTheme` from `@apache-superset/core/theme` reflects the dashboard's active theme.
## Registering a Renderer
Register the renderer as a module-level side effect in your extension's entry point:
```typescript
import { dashboards } from '@apache-superset/core';
import type { ComponentType } from 'react';
const KioskDashboardRenderer: ComponentType<
dashboards.DashboardRendererProps
> = ({ dashboard, charts, initialDataMask }) => (
<main>
<h1>{dashboard.title}</h1>
{/* render charts from `charts` + `dashboard.layout` */}
</main>
);
dashboards.registerDashboardRenderer(
{ id: 'acme.kiosk-dashboard', name: 'Kiosk Dashboard Renderer' },
KioskDashboardRenderer,
);
```
`registerDashboardRenderer` returns a `Disposable`. Disposing it removes your renderer if it is still the active one; disposing after being displaced by a newer registration is a no-op.
You can observe slot changes with `dashboards.onDidRegisterDashboardRenderer` and `dashboards.onDidUnregisterDashboardRenderer`, and inspect the active provider with `dashboards.getDashboardRenderer()` (which returns the built-in default when no custom renderer is active).
### Augmenting the built-in renderer
To augment rather than fully replace the built-in renderer, retrieve the default provider and wrap its component:
```tsx
const defaultProvider = dashboards.getDefaultDashboardRenderer();
dashboards.registerDashboardRenderer(
{ id: 'acme.framed-dashboard', name: 'Framed Dashboard' },
props => (
<AcmeFrame>
{defaultProvider && <defaultProvider.component {...props} />}
</AcmeFrame>
),
);
```
## Manifest Declaration
Declare the renderer in your extension's `Contributions` metadata (at most one per extension):
```json
{
"dashboardRenderer": {
"id": "acme.kiosk-dashboard",
"name": "Kiosk Dashboard Renderer",
"description": "Full-screen kiosk presentation of dashboards"
}
}
```
## Current Limitations
- Extensions load asynchronously after startup, so a dashboard opened before your extension finishes loading renders with the built-in renderer first and swaps to yours when registration lands.
- `onDataMaskChange` and `onActiveTabsChange` are defined in the contract but not consumed by the host yet — filter state changed inside a custom renderer does not persist to permalinks.
- While a custom renderer is active the host still hydrates its internal dashboard state so permalinks and embedded behavior remain intact; this is transparent to renderers but means the built-in state bookkeeping still runs.

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,317 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* @fileoverview Dashboards API for custom dashboard renderers.
*
* This module defines the contract for replacing Superset's built-in
* dashboard renderer with a custom implementation contributed by an
* extension. It provides:
*
* - `DashboardRenderer`: Descriptor for a contributed renderer
* - `DashboardRendererProps`: The props contract renderer components receive
* - `registerDashboardRenderer`: Registration function (single slot)
*
* The dashboard renderer is a single-slot contribution point: at most one
* custom renderer is active at a time, and the most recently registered
* renderer wins. The built-in dashboard renderer is itself registered as
* the default provider, so it renders whenever no custom renderer is
* active — including when the extensions feature flag is off — and it can
* be retrieved via {@link getDefaultDashboardRenderer} to wrap or augment
* it from a custom renderer.
*
* Custom renderers handle VIEW mode only. When a dashboard enters edit
* mode, the host always renders the built-in renderer, returning to the
* custom renderer when edit mode exits.
*
* The props contract is designed to be Redux-free: everything a renderer
* needs to display a dashboard arrives via props, and host services are
* available through the public `window.superset` namespaces. Renderer
* components are mounted inside the host's theme providers, so `useTheme`
* from `@apache-superset/core/theme` works as expected. Fetching chart
* data (e.g. `POST /api/v1/chart/data`) and orchestrating filter-driven
* chart refreshes are the renderer's responsibility.
*/
import { ComponentType } from 'react';
import { Disposable, Event } from '../common';
/**
* Describes a dashboard renderer that can be contributed to the application.
*/
export interface DashboardRenderer {
/** Unique identifier for the renderer (e.g., "acme.kiosk-dashboard") */
id: string;
/** Display name of the renderer */
name: string;
/** Optional description of the renderer */
description?: string;
}
/**
* Filter state for a single native filter or cross-filter emitter, keyed by
* the filter id (native filters) or chart id (cross-filters).
*/
export interface DashboardDataMaskEntry {
/** The native filter id or chart id this entry belongs to */
id: string;
/** UI-facing filter state (selected values, labels) */
filterState?: Record<string, unknown>;
/** Query-facing form data merged into affected charts' queries */
extraFormData?: Record<string, unknown>;
/** Renderer/chart-private state (e.g. table pagination, search) */
ownState?: Record<string, unknown>;
}
/**
* The complete filter state of a dashboard: one entry per active native
* filter or cross-filter emitter, keyed by filter or chart id.
*/
export type DashboardDataMask = Record<string, DashboardDataMaskEntry>;
/**
* Identity and parsed metadata for the dashboard being rendered.
*/
export interface DashboardInfo {
/** Numeric dashboard id */
id: number;
/** Stable UUID of the dashboard */
uuid?: string;
/** URL slug, if one is set */
slug?: string | null;
/** Dashboard title */
title: string;
/** Custom CSS attached to the dashboard (already injected by the host) */
css?: string | null;
/**
* Parsed `json_metadata`: `native_filter_configuration`,
* `chart_configuration` (cross-filter scoping), `refresh_frequency`,
* `color_scheme`, `default_filters`, and other dashboard-level settings.
*/
metadata: Record<string, unknown>;
/**
* Parsed `position_json` — the layout component tree keyed by component
* id (GRID_ID, ROOT_ID, CHART-*, TAB-*, ROW-*, ...), describing how
* charts, tabs, rows, and columns are arranged.
*/
layout: Record<string, unknown>;
/** Whether the dashboard is published */
isPublished?: boolean;
/** Whether the dashboard is managed by an external system */
isManagedExternally?: boolean;
}
/**
* A chart (slice) definition as returned by
* `GET /api/v1/dashboard/{id}/charts`. Identity fields are typed
* explicitly; the remaining payload is passed through untyped.
*/
export interface DashboardChart {
/** Numeric chart (slice) id */
id: number;
/** Chart display name */
slice_name?: string;
/** Visualization plugin type (e.g., "echarts_timeseries_line") */
viz_type?: string;
/** The chart's stored form data (query + display configuration) */
form_data?: Record<string, unknown>;
[key: string]: unknown;
}
/**
* A dataset definition as returned by
* `GET /api/v1/dashboard/{id}/datasets`. Identity fields are typed
* explicitly; the remaining payload is passed through untyped.
*/
export interface DashboardDataset {
/** Numeric dataset id */
id: number;
/** Datasource uid (e.g., "12__table") */
uid?: string;
/** Physical or virtual table name */
table_name?: string;
/** Column definitions */
columns?: Record<string, unknown>[];
/** Metric definitions */
metrics?: Record<string, unknown>[];
[key: string]: unknown;
}
/**
* Display options for the dashboard, mirroring the embedded SDK's
* uiConfig chrome-hiding flags.
*/
export interface DashboardUiConfig {
/** Hide the dashboard title header */
hideTitle?: boolean;
/** Hide the top-level tab bar */
hideTab?: boolean;
/** Hide per-chart menus and controls */
hideChartControls?: boolean;
/** Emit filter state changes to an embedding host application */
emitDataMasks?: boolean;
}
/**
* Props contract between the host and dashboard renderer implementations.
*
* Everything a renderer needs to display a dashboard arrives via these
* props; host services (authentication, navigation, theming, translation)
* are available through the public `window.superset` namespaces.
*/
export interface DashboardRendererProps {
/** Identity and parsed metadata of the dashboard */
dashboard: DashboardInfo;
/** Chart (slice) definitions placed on the dashboard */
charts: DashboardChart[];
/** Datasets backing the dashboard's charts */
datasets: DashboardDataset[];
/**
* Initial filter state resolved by the host from the URL: permalink
* state, `native_filters_key`, and legacy/rison filter params.
*/
initialDataMask: DashboardDataMask;
/** Layout component ids of the initially active tabs (from permalink) */
initialActiveTabs?: string[];
/** Layout component id to scroll to on mount (permalink anchor) */
initialAnchor?: string;
/** Display options (chrome hiding), when provided by the host */
uiConfig?: DashboardUiConfig;
/**
* Notify the host that filter state changed inside the renderer.
* Reserved for future host consumption (permalinks, embedded emitters);
* the host does not supply this callback yet.
*/
onDataMaskChange?: (dataMask: DashboardDataMask) => void;
/**
* Notify the host that the active tabs changed inside the renderer.
* Reserved for future host consumption; the host does not supply this
* callback yet.
*/
onActiveTabsChange?: (activeTabs: string[]) => void;
}
/**
* React component type for dashboard renderer implementations.
*/
export type DashboardRendererComponent = ComponentType<DashboardRendererProps>;
/**
* A registered dashboard renderer provider with its descriptor and component.
*/
export interface DashboardRendererProvider {
/** The renderer descriptor */
renderer: DashboardRenderer;
/** The React component implementing the renderer */
component: DashboardRendererComponent;
}
/**
* Event fired when a dashboard renderer is registered.
*/
export interface DashboardRendererRegisteredEvent {
/** The descriptor of the renderer that was registered */
renderer: DashboardRenderer;
}
/**
* Event fired when a dashboard renderer is unregistered.
*/
export interface DashboardRendererUnregisteredEvent {
/** The descriptor of the renderer that was unregistered */
renderer: DashboardRenderer;
}
/**
* Registers a custom dashboard renderer as a module-level side effect.
*
* The dashboard renderer is a single slot: the most recently registered
* renderer becomes active, displacing (and unregistering) any previously
* registered one with a console warning. Disposing the returned Disposable
* removes the renderer if it is still the active one — the built-in
* default renderer takes over again; disposing a displaced renderer's
* Disposable is a no-op.
*
* Custom renderers handle view mode only — when a dashboard enters edit
* mode, the host always renders the built-in renderer.
*
* @param renderer The renderer descriptor including id and name.
* @param component The React component implementing the renderer.
* @returns Disposable which unregisters this renderer on disposal.
*
* @example
* ```typescript
* dashboards.registerDashboardRenderer(
* { id: 'acme.kiosk-dashboard', name: 'Kiosk Dashboard Renderer' },
* KioskDashboardRenderer,
* );
* ```
*/
export declare function registerDashboardRenderer(
renderer: DashboardRenderer,
component: DashboardRendererComponent,
): Disposable;
/**
* Get the active dashboard renderer provider: the extension-registered
* renderer when one is active, otherwise the built-in default provider.
*
* @returns The active provider, or undefined only if the host has not
* initialized a default renderer.
*/
export declare function getDashboardRenderer():
| DashboardRendererProvider
| undefined;
/**
* Get the built-in default dashboard renderer provider.
*
* Useful for extensions that want to augment rather than fully replace
* the built-in renderer — retrieve the default component, wrap it, and
* register the wrapper.
*
* @example
* ```tsx
* const defaultProvider = dashboards.getDefaultDashboardRenderer();
* dashboards.registerDashboardRenderer(
* { id: 'acme.framed-dashboard', name: 'Framed Dashboard' },
* props => (
* <AcmeFrame>
* {defaultProvider && <defaultProvider.component {...props} />}
* </AcmeFrame>
* ),
* );
* ```
*
* @returns The default provider, or undefined if the host has not
* initialized one.
*/
export declare function getDefaultDashboardRenderer():
| DashboardRendererProvider
| undefined;
/**
* Event fired when a dashboard renderer is registered.
*/
export declare const onDidRegisterDashboardRenderer: Event<DashboardRendererRegisteredEvent>;
/**
* Event fired when a dashboard renderer is unregistered.
*/
export declare const onDidUnregisterDashboardRenderer: Event<DashboardRendererUnregisteredEvent>;

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,178 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { test, expect, Page } from '@playwright/test';
import { DashboardPage } from '../../pages/DashboardPage';
import { TIMEOUT } from '../../utils/constants';
/**
* Dashboard Renderer extension point E2E tests.
*
* Verifies the `dashboards` contribution point end to end: a custom
* renderer registered through `window.superset.dashboards` replaces the
* built-in dashboard renderer in place, receives the contract props,
* restores the built-in renderer on disposal, and never takes over in
* edit mode.
*
* Prerequisites:
* - Superset running with example dashboards loaded
* - Admin user authenticated (via global-setup)
* - ENABLE_EXTENSIONS feature flag on (tests skip themselves otherwise)
*/
const CUSTOM_MARKER = 'CUSTOM DASHBOARD RENDERER ACTIVE';
const CHART_CONTAINER = '[data-test="chart-container"]';
declare global {
interface Window {
__rendererDisposable?: { dispose: () => void };
__rendererProps?: {
dashboardId?: number;
title?: string;
charts: number;
layoutKeys: number;
hasInitialDataMask: boolean;
};
}
}
async function extensionsEnabled(page: Page): Promise<boolean> {
return page.evaluate(() =>
Boolean((window as any).featureFlags?.ENABLE_EXTENSIONS),
);
}
/**
* Registers a minimal custom renderer from the page context and records
* the contract props it receives on window.__rendererProps.
*/
async function registerProbeRenderer(page: Page): Promise<void> {
await page.evaluate(marker => {
window.__rendererProps = undefined;
window.__rendererDisposable = (
window as any
).superset.dashboards.registerDashboardRenderer(
{ id: 'e2e.probe-renderer', name: 'E2E Probe Renderer' },
(props: any) => {
window.__rendererProps = {
dashboardId: props.dashboard?.id,
title: props.dashboard?.title,
charts: (props.charts || []).length,
layoutKeys: Object.keys(props.dashboard?.layout || {}).length,
hasInitialDataMask: Boolean(props.initialDataMask),
};
return `${marker} :: ${props.dashboard?.title}`;
},
);
}, CUSTOM_MARKER);
}
test.describe('Dashboard renderer extension point', () => {
test.setTimeout(90_000);
let dashboardPage: DashboardPage;
test.beforeEach(async ({ page }) => {
dashboardPage = new DashboardPage(page);
await dashboardPage.gotoBySlug('world_health');
await dashboardPage.waitForLoad({ timeout: TIMEOUT.PAGE_LOAD });
test.skip(
!(await extensionsEnabled(page)),
'ENABLE_EXTENSIONS feature flag is off',
);
});
test('custom renderer swaps in live, receives contract props, and restores on dispose', async ({
page,
}) => {
// Built-in renderer showing charts before any registration
await expect(page.locator(CHART_CONTAINER).first()).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// The public namespace is exposed
expect(
await page.evaluate(
() =>
typeof (window as any).superset?.dashboards
?.registerDashboardRenderer,
),
).toBe('function');
// The built-in renderer is itself the registered default provider
expect(
await page.evaluate(() => ({
active: (window as any).superset.dashboards.getDashboardRenderer()
?.renderer.id,
default: (
window as any
).superset.dashboards.getDefaultDashboardRenderer()?.renderer.id,
})),
).toEqual({
active: 'superset.dashboard-renderer',
default: 'superset.dashboard-renderer',
});
// Registering swaps the view in place, no navigation
await registerProbeRenderer(page);
await expect(page.getByText(CUSTOM_MARKER)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
await expect(page.locator(CHART_CONTAINER)).toHaveCount(0);
// The renderer received the contract props for this dashboard
const props = await page.evaluate(() => window.__rendererProps);
expect(props?.title).toBeTruthy();
expect(props?.charts).toBeGreaterThan(0);
expect(props?.layoutKeys).toBeGreaterThan(0);
expect(props?.hasInitialDataMask).toBe(true);
// Disposing restores the built-in renderer
await page.evaluate(() => window.__rendererDisposable?.dispose());
await expect(page.getByText(CUSTOM_MARKER)).toHaveCount(0);
await expect(page.locator(CHART_CONTAINER).first()).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
});
test('edit mode keeps the built-in renderer despite a registered custom renderer', async ({
page,
}) => {
// Enter edit mode first: a full navigation would wipe an in-page
// registration, so the registration must happen while in edit mode.
await page.goto(`${page.url().split('?')[0]}?edit=true`, {
waitUntil: 'domcontentloaded',
});
await expect(page.getByRole('button', { name: /discard/i })).toBeVisible({
timeout: TIMEOUT.PAGE_LOAD,
});
await expect(page.locator(CHART_CONTAINER).first()).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Registering while in edit mode must NOT swap the renderer.
// (Discard performs a full navigation that would wipe the in-page
// registration, so the edit→view transition is covered by unit tests.)
await registerProbeRenderer(page);
await expect(page.getByText(CUSTOM_MARKER)).toHaveCount(0);
await expect(page.locator(CHART_CONTAINER).first()).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
});
});

View File

@@ -0,0 +1,190 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { act, render, screen, cleanup } from 'spec/helpers/testing-library';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import type { dashboards } from '@apache-superset/core';
import DashboardRendererProviders from './DashboardRendererProviders';
import DashboardRendererHost from './DashboardRendererHost';
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
isFeatureEnabled: jest.fn(),
}));
const mockIsFeatureEnabled = isFeatureEnabled as jest.Mock;
// Stub the built-in renderer to avoid mounting the full dashboard stack
jest.mock(
'src/dashboard/components/DashboardRenderer/DefaultDashboardRenderer',
() => ({
__esModule: true,
default: ({ dashboard }: dashboards.DashboardRendererProps) => (
<div data-test="default-dashboard-renderer">
<span data-test="default-renderer-dashboard-id">{dashboard.id}</span>
</div>
),
}),
);
const rendererProps: dashboards.DashboardRendererProps = {
dashboard: {
id: 42,
title: 'Test dashboard',
metadata: {},
layout: {},
},
charts: [{ id: 7, slice_name: 'Test chart' }],
datasets: [{ id: 3, table_name: 'test_table' }],
initialDataMask: {
'NATIVE_FILTER-abc': {
id: 'NATIVE_FILTER-abc',
filterState: { value: ['foo'] },
},
},
};
beforeEach(() => {
DashboardRendererProviders.getInstance().reset();
mockIsFeatureEnabled.mockImplementation(
flag => flag === FeatureFlag.EnableExtensions,
);
});
afterEach(() => {
mockIsFeatureEnabled.mockReset();
cleanup();
});
test('renders the built-in renderer when no custom renderer is registered', async () => {
render(<DashboardRendererHost {...rendererProps} />);
expect(
await screen.findByTestId('default-dashboard-renderer'),
).toBeInTheDocument();
expect(screen.getByTestId('default-renderer-dashboard-id')).toHaveTextContent(
'42',
);
});
test('renders a registered custom renderer with the contract props', () => {
const customRenderer = jest.fn(
({ dashboard, initialDataMask }: dashboards.DashboardRendererProps) => (
<div data-test="custom-dashboard-renderer">
{dashboard.title}
{Object.keys(initialDataMask).join(',')}
</div>
),
);
DashboardRendererProviders.getInstance().registerProvider(
{ id: 'test.custom', name: 'Custom Renderer' },
customRenderer,
);
render(<DashboardRendererHost {...rendererProps} />);
expect(screen.getByTestId('custom-dashboard-renderer')).toHaveTextContent(
'Test dashboard',
);
expect(screen.getByTestId('custom-dashboard-renderer')).toHaveTextContent(
'NATIVE_FILTER-abc',
);
expect(customRenderer).toHaveBeenCalledWith(
expect.objectContaining({
dashboard: expect.objectContaining({ id: 42 }),
charts: rendererProps.charts,
datasets: rendererProps.datasets,
initialDataMask: rendererProps.initialDataMask,
}),
expect.anything(),
);
expect(
screen.queryByTestId('default-dashboard-renderer'),
).not.toBeInTheDocument();
});
test('renders the built-in renderer in edit mode even with a custom renderer registered', async () => {
DashboardRendererProviders.getInstance().registerProvider(
{ id: 'test.custom', name: 'Custom Renderer' },
() => <div data-test="custom-dashboard-renderer" />,
);
render(<DashboardRendererHost editMode {...rendererProps} />);
expect(
await screen.findByTestId('default-dashboard-renderer'),
).toBeInTheDocument();
expect(
screen.queryByTestId('custom-dashboard-renderer'),
).not.toBeInTheDocument();
});
test('renders the built-in renderer when the extensions feature flag is off', async () => {
mockIsFeatureEnabled.mockReturnValue(false);
DashboardRendererProviders.getInstance().registerProvider(
{ id: 'test.custom', name: 'Custom Renderer' },
() => <div data-test="custom-dashboard-renderer" />,
);
render(<DashboardRendererHost {...rendererProps} />);
expect(
await screen.findByTestId('default-dashboard-renderer'),
).toBeInTheDocument();
expect(
screen.queryByTestId('custom-dashboard-renderer'),
).not.toBeInTheDocument();
});
test('shows an error boundary when the custom renderer throws', () => {
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
DashboardRendererProviders.getInstance().registerProvider(
{ id: 'test.broken', name: 'Broken Renderer' },
() => {
throw new Error('renderer exploded');
},
);
render(<DashboardRendererHost {...rendererProps} />);
expect(screen.getByText('Unexpected error')).toBeInTheDocument();
expect(
screen.queryByTestId('default-dashboard-renderer'),
).not.toBeInTheDocument();
consoleErrorSpy.mockRestore();
});
test('swaps to a custom renderer registered after mount', async () => {
render(<DashboardRendererHost {...rendererProps} />);
expect(
await screen.findByTestId('default-dashboard-renderer'),
).toBeInTheDocument();
act(() => {
DashboardRendererProviders.getInstance().registerProvider(
{ id: 'test.late', name: 'Late Renderer' },
() => <div data-test="custom-dashboard-renderer" />,
);
});
expect(screen.getByTestId('custom-dashboard-renderer')).toBeInTheDocument();
expect(
screen.queryByTestId('default-dashboard-renderer'),
).not.toBeInTheDocument();
});

View File

@@ -0,0 +1,101 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* @fileoverview DashboardRendererHost component for dynamic dashboard
* renderer resolution.
*
* This component resolves and renders the active dashboard renderer from
* the provider registry. Superset's built-in renderer is registered as the
* default provider (see ./defaultRenderer), so dashboards always render:
* an extension-registered override wins in view mode when the extensions
* feature flag is on; the built-in default renders otherwise — including
* in edit mode and when the flag is off.
*/
import { Suspense, useSyncExternalStore } from 'react';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import type { dashboards } from '@apache-superset/core';
import { Loading } from '@superset-ui/core/components';
import { ErrorBoundary } from 'src/components/ErrorBoundary';
import DashboardRendererProviders from './DashboardRendererProviders';
// Registers the built-in renderer as the default provider (side effect)
import './defaultRenderer';
export interface DashboardRendererHostProps
extends dashboards.DashboardRendererProps {
/**
* Host-only flag, not part of the renderer contract: when true, the
* built-in renderer is always used. Custom renderers handle view mode
* only — the built-in editor stack owns editing.
*/
editMode?: boolean;
}
/**
* DashboardRendererHost dynamically resolves and renders the appropriate
* dashboard renderer.
*
* It checks whether an extension has registered a custom dashboard renderer
* and uses that if available; otherwise, it renders the built-in default
* provider. The built-in renderer is also used whenever the dashboard is in
* edit mode, regardless of registration.
*/
const DashboardRendererHost = ({
editMode,
...rendererProps
}: DashboardRendererHostProps) => {
const manager = DashboardRendererProviders.getInstance();
const override = useSyncExternalStore(
manager.subscribe,
() => manager.getOverrideProvider(),
() => undefined,
);
// Extensions can only register while the feature flag is on (the loader is
// gated), but the dashboard blast radius warrants the extra check.
const useOverride =
!editMode &&
override !== undefined &&
isFeatureEnabled(FeatureFlag.EnableExtensions);
if (useOverride) {
const RendererComponent = override.component;
return (
<ErrorBoundary>
<RendererComponent {...rendererProps} />
</ErrorBoundary>
);
}
const defaultProvider = manager.getDefaultProvider();
if (!defaultProvider) {
return null;
}
const DefaultRenderer = defaultProvider.component;
return (
<Suspense fallback={<Loading />}>
<DefaultRenderer {...rendererProps} />
</Suspense>
);
};
export default DashboardRendererHost;
export { DashboardRendererHost };

View File

@@ -0,0 +1,307 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import type { dashboards } from '@apache-superset/core';
import DashboardRendererProviders from './DashboardRendererProviders';
type DashboardRenderer = dashboards.DashboardRenderer;
type DashboardRendererComponent = dashboards.DashboardRendererComponent;
/**
* Creates a mock renderer descriptor for testing.
*/
function createMockRenderer(
overrides: Partial<DashboardRenderer> = {},
): DashboardRenderer {
return {
id: 'test.mock-renderer',
name: 'Mock Renderer',
description: 'A mock dashboard renderer for testing',
...overrides,
};
}
/**
* Creates a mock renderer component for testing.
*/
function createMockRendererComponent(): DashboardRendererComponent {
return jest.fn(() => null) as unknown as DashboardRendererComponent;
}
beforeEach(() => {
// Reset the singleton instance before each test, including the default
// tier — this suite exercises the registry in isolation.
const manager = DashboardRendererProviders.getInstance();
manager.reset(true);
});
test('creates singleton instance', () => {
const manager1 = DashboardRendererProviders.getInstance();
const manager2 = DashboardRendererProviders.getInstance();
expect(manager1).toBe(manager2);
expect(manager1).toBeInstanceOf(DashboardRendererProviders);
});
test('registers and retrieves a provider', () => {
const manager = DashboardRendererProviders.getInstance();
const renderer = createMockRenderer();
const component = createMockRendererComponent();
manager.registerProvider(renderer, component);
const provider = manager.getProvider();
expect(provider).toBeDefined();
expect(provider?.renderer).toEqual(renderer);
expect(provider?.component).toBe(component);
});
test('returns undefined when no provider is registered', () => {
const manager = DashboardRendererProviders.getInstance();
expect(manager.getProvider()).toBeUndefined();
});
test('unregisters provider when disposable is disposed', () => {
const manager = DashboardRendererProviders.getInstance();
const renderer = createMockRenderer();
const disposable = manager.registerProvider(
renderer,
createMockRendererComponent(),
);
expect(manager.getProvider()).toBeDefined();
disposable.dispose();
expect(manager.getProvider()).toBeUndefined();
});
test('most recent registration wins and displaces the previous provider', () => {
const manager = DashboardRendererProviders.getInstance();
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
const unregisterListener = jest.fn();
manager.onDidUnregister(unregisterListener);
const renderer1 = createMockRenderer({ id: 'renderer-1' });
const renderer2 = createMockRenderer({ id: 'renderer-2' });
manager.registerProvider(renderer1, createMockRendererComponent());
manager.registerProvider(renderer2, createMockRendererComponent());
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Multiple dashboard renderers registered. Using "renderer-2"; ' +
'discarding "renderer-1".',
);
expect(unregisterListener).toHaveBeenCalledWith({ renderer: renderer1 });
expect(manager.getProvider()?.renderer.id).toBe('renderer-2');
consoleWarnSpy.mockRestore();
});
test('disposing a displaced provider is a no-op', () => {
const manager = DashboardRendererProviders.getInstance();
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
const renderer1 = createMockRenderer({ id: 'renderer-1' });
const renderer2 = createMockRenderer({ id: 'renderer-2' });
const disposable1 = manager.registerProvider(
renderer1,
createMockRendererComponent(),
);
manager.registerProvider(renderer2, createMockRendererComponent());
// Disposing the displaced provider must not clear the active one
disposable1.dispose();
expect(manager.getProvider()?.renderer.id).toBe('renderer-2');
consoleWarnSpy.mockRestore();
});
test('fires onDidRegister event when provider is registered', () => {
const manager = DashboardRendererProviders.getInstance();
const listener = jest.fn();
manager.onDidRegister(listener);
const renderer = createMockRenderer();
manager.registerProvider(renderer, createMockRendererComponent());
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith({ renderer });
});
test('fires onDidUnregister event when provider is unregistered', () => {
const manager = DashboardRendererProviders.getInstance();
const listener = jest.fn();
manager.onDidUnregister(listener);
const renderer = createMockRenderer();
const disposable = manager.registerProvider(
renderer,
createMockRendererComponent(),
);
disposable.dispose();
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith({ renderer });
});
test('event listeners can be disposed', () => {
const manager = DashboardRendererProviders.getInstance();
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
const listener = jest.fn();
const listenerDisposable = manager.onDidRegister(listener);
manager.registerProvider(
createMockRenderer({ id: 'renderer-1' }),
createMockRendererComponent(),
);
expect(listener).toHaveBeenCalledTimes(1);
listenerDisposable.dispose();
manager.registerProvider(
createMockRenderer({ id: 'renderer-2' }),
createMockRendererComponent(),
);
expect(listener).toHaveBeenCalledTimes(1); // Still only 1 call
consoleWarnSpy.mockRestore();
});
test('notifies subscribe listeners on register and unregister', () => {
const manager = DashboardRendererProviders.getInstance();
const listener = jest.fn();
const unsubscribe = manager.subscribe(listener);
const disposable = manager.registerProvider(
createMockRenderer(),
createMockRendererComponent(),
);
expect(listener).toHaveBeenCalledTimes(1);
disposable.dispose();
expect(listener).toHaveBeenCalledTimes(2);
unsubscribe();
manager.registerProvider(createMockRenderer(), createMockRendererComponent());
expect(listener).toHaveBeenCalledTimes(2); // No further calls
});
test('reset clears the provider slot', () => {
const manager = DashboardRendererProviders.getInstance();
manager.registerProvider(createMockRenderer(), createMockRendererComponent());
expect(manager.getProvider()).toBeDefined();
manager.reset();
expect(manager.getOverrideProvider()).toBeUndefined();
});
test('the default provider is active when no override is registered', () => {
const manager = DashboardRendererProviders.getInstance();
const defaultRenderer = createMockRenderer({ id: 'host.default' });
const component = createMockRendererComponent();
manager.setDefaultProvider(defaultRenderer, component);
expect(manager.getProvider()?.renderer.id).toBe('host.default');
expect(manager.getDefaultProvider()?.renderer.id).toBe('host.default');
expect(manager.getOverrideProvider()).toBeUndefined();
});
test('an override wins over the default without displacing it', () => {
const manager = DashboardRendererProviders.getInstance();
const unregisterListener = jest.fn();
manager.onDidUnregister(unregisterListener);
manager.setDefaultProvider(
createMockRenderer({ id: 'host.default' }),
createMockRendererComponent(),
);
manager.registerProvider(
createMockRenderer({ id: 'ext.override' }),
createMockRendererComponent(),
);
expect(manager.getProvider()?.renderer.id).toBe('ext.override');
expect(manager.getDefaultProvider()?.renderer.id).toBe('host.default');
// Registering over the default must not fire an unregister for it
expect(unregisterListener).not.toHaveBeenCalled();
});
test('disposing the override falls back to the default provider', () => {
const manager = DashboardRendererProviders.getInstance();
manager.setDefaultProvider(
createMockRenderer({ id: 'host.default' }),
createMockRendererComponent(),
);
const disposable = manager.registerProvider(
createMockRenderer({ id: 'ext.override' }),
createMockRendererComponent(),
);
disposable.dispose();
expect(manager.getProvider()?.renderer.id).toBe('host.default');
expect(manager.getOverrideProvider()).toBeUndefined();
});
test('setDefaultProvider is idempotent by renderer id', () => {
const manager = DashboardRendererProviders.getInstance();
const listener = jest.fn();
manager.subscribe(listener);
const renderer = createMockRenderer({ id: 'host.default' });
manager.setDefaultProvider(renderer, createMockRendererComponent());
expect(listener).toHaveBeenCalledTimes(1);
// Re-registering the same id (e.g. duplicate side-effect import) is a no-op
manager.setDefaultProvider(renderer, createMockRendererComponent());
expect(listener).toHaveBeenCalledTimes(1);
});
test('reset keeps the default provider', () => {
const manager = DashboardRendererProviders.getInstance();
manager.setDefaultProvider(
createMockRenderer({ id: 'host.default' }),
createMockRendererComponent(),
);
manager.registerProvider(
createMockRenderer({ id: 'ext.override' }),
createMockRendererComponent(),
);
manager.reset();
expect(manager.getOverrideProvider()).toBeUndefined();
expect(manager.getProvider()?.renderer.id).toBe('host.default');
});

View File

@@ -0,0 +1,222 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import type { dashboards } from '@apache-superset/core';
import { Disposable } from '../models';
import { createEventEmitter } from '../utils';
type DashboardRenderer = dashboards.DashboardRenderer;
type DashboardRendererComponent = dashboards.DashboardRendererComponent;
type DashboardRendererProvider = dashboards.DashboardRendererProvider;
type DashboardRendererRegisteredEvent =
dashboards.DashboardRendererRegisteredEvent;
type DashboardRendererUnregisteredEvent =
dashboards.DashboardRendererUnregisteredEvent;
type Listener<T> = (e: T) => void;
/**
* Singleton manager for the dashboard renderer slot.
*
* The dashboard renderer is a single-slot contribution point with two
* tiers: the host registers the built-in renderer as the DEFAULT provider,
* and extensions register at most one OVERRIDE provider on top of it. The
* active provider is the override when present, otherwise the default —
* so dashboards always render, extensions or not. Registering an override
* while the slot is occupied displaces (and unregisters) the previous
* override with a console warning — the most recent registration wins;
* the default provider is never displaced.
*/
class DashboardRendererProviders {
private static instance: DashboardRendererProviders;
/**
* The single extension-registered override slot.
*/
private provider: DashboardRendererProvider | undefined;
/**
* The host-registered built-in provider, active when no override is.
*/
private defaultProvider: DashboardRendererProvider | undefined;
private registerEmitter =
createEventEmitter<DashboardRendererRegisteredEvent>();
private unregisterEmitter =
createEventEmitter<DashboardRendererUnregisteredEvent>();
private syncListeners: Set<() => void> = new Set();
/**
* Stable-reference subscribe function for useSyncExternalStore.
* Defined as an arrow property so the reference is bound to this instance at construction.
*/
public subscribe = (listener: () => void): (() => void) => {
this.syncListeners.add(listener);
return () => this.syncListeners.delete(listener);
};
// eslint-disable-next-line no-useless-constructor
private constructor() {
// Private constructor for singleton pattern
}
/**
* Get the singleton instance of DashboardRendererProviders.
* @returns The singleton instance.
*/
public static getInstance(): DashboardRendererProviders {
if (!DashboardRendererProviders.instance) {
DashboardRendererProviders.instance = new DashboardRendererProviders();
}
return DashboardRendererProviders.instance;
}
/**
* Register a dashboard renderer provider.
* The most recently registered provider occupies the slot; a previously
* registered provider is displaced and unregistered with a warning.
*
* @param renderer The renderer descriptor.
* @param component The React component implementing the renderer.
* @returns A Disposable to unregister the provider. Disposing after the
* provider has been displaced by a newer registration is a no-op.
*/
public registerProvider(
renderer: DashboardRenderer,
component: DashboardRendererComponent,
): Disposable {
const displaced = this.provider;
if (displaced) {
// eslint-disable-next-line no-console
console.warn(
`Multiple dashboard renderers registered. Using "${renderer.id}"; ` +
`discarding "${displaced.renderer.id}".`,
);
this.unregisterEmitter.fire({ renderer: displaced.renderer });
}
this.provider = { renderer, component };
// Fire registration event
this.registerEmitter.fire({ renderer });
this.syncListeners.forEach(l => l());
// Return disposable for cleanup
return new Disposable(() => {
// No-op when this provider is no longer the active one (displaced).
if (this.provider?.renderer !== renderer) {
return;
}
this.provider = undefined;
this.unregisterEmitter.fire({ renderer });
this.syncListeners.forEach(l => l());
});
}
/**
* Register the host's built-in renderer as the default provider.
* Host-internal — not exposed on the public `dashboards` namespace.
* Idempotent by id so duplicate side-effect imports are harmless.
*
* @param renderer The default renderer descriptor.
* @param component The React component implementing the renderer.
*/
public setDefaultProvider(
renderer: DashboardRenderer,
component: DashboardRendererComponent,
): void {
if (this.defaultProvider?.renderer.id === renderer.id) {
return;
}
this.defaultProvider = { renderer, component };
this.syncListeners.forEach(l => l());
}
/**
* Get the built-in default dashboard renderer provider.
* @returns The default provider or undefined if the host has not set one.
*/
public getDefaultProvider(): DashboardRendererProvider | undefined {
return this.defaultProvider;
}
/**
* Get the extension-registered override provider, ignoring the default.
* @returns The override provider or undefined if none is registered.
*/
public getOverrideProvider(): DashboardRendererProvider | undefined {
return this.provider;
}
/**
* Get the active dashboard renderer provider: the extension-registered
* override when present, otherwise the built-in default.
* @returns The active provider or undefined if neither is registered.
*/
public getProvider(): DashboardRendererProvider | undefined {
return this.provider ?? this.defaultProvider;
}
/**
* Subscribe to provider registration events.
* @param listener The listener function.
* @returns A Disposable to unsubscribe.
*/
public onDidRegister(
listener: Listener<DashboardRendererRegisteredEvent>,
thisArgs?: unknown,
): Disposable {
return this.registerEmitter.subscribe(listener, thisArgs);
}
/**
* Subscribe to provider unregistration events.
* @param listener The listener function.
* @returns A Disposable to unsubscribe.
*/
public onDidUnregister(
listener: Listener<DashboardRendererUnregisteredEvent>,
thisArgs?: unknown,
): Disposable {
return this.unregisterEmitter.subscribe(listener, thisArgs);
}
/**
* Reset the manager state (for testing purposes).
* The default provider is kept unless requested: it is registered by a
* one-time module side effect that will not re-run between tests.
*
* @param clearDefault When true, also clears the default provider.
*/
public reset(clearDefault = false): void {
this.provider = undefined;
if (clearDefault) {
this.defaultProvider = undefined;
}
this.syncListeners.clear();
this.registerEmitter =
createEventEmitter<DashboardRendererRegisteredEvent>();
this.unregisterEmitter =
createEventEmitter<DashboardRendererUnregisteredEvent>();
}
}
export default DashboardRendererProviders;

View File

@@ -0,0 +1,56 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* @fileoverview Registers Superset's built-in dashboard renderer as the
* default provider in the dashboard renderer registry.
*
* The built-in renderer goes through the same contribution point as
* extension-contributed renderers: it occupies the default tier of the
* slot, renders whenever no extension override is active (including when
* the extensions feature flag is off), and is retrievable via the public
* `dashboards.getDefaultDashboardRenderer()` so extensions can wrap it.
*
* The component is imported lazily so that bundles importing the
* `dashboards` namespace (e.g. the app startup bundle) do not statically
* pull in the dashboard rendering stack.
*/
import { lazy } from 'react';
import type { dashboards } from '@apache-superset/core';
import DashboardRendererProviders from './DashboardRendererProviders';
export const DEFAULT_DASHBOARD_RENDERER: dashboards.DashboardRenderer = {
id: 'superset.dashboard-renderer',
name: 'Superset Dashboard Renderer',
description: "Superset's built-in dashboard renderer",
};
const DefaultDashboardRenderer = lazy(
() =>
import(
/* webpackChunkName: "DefaultDashboardRenderer" */
'src/dashboard/components/DashboardRenderer/DefaultDashboardRenderer'
),
);
DashboardRendererProviders.getInstance().setDefaultProvider(
DEFAULT_DASHBOARD_RENDERER,
DefaultDashboardRenderer,
);

View File

@@ -0,0 +1,108 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { createElement } from 'react';
import type { dashboards as dashboardsApi } from '@apache-superset/core';
import { dashboards } from './index';
import DashboardRendererProviders from './DashboardRendererProviders';
const component: dashboardsApi.DashboardRendererComponent = () =>
createElement('div', null, 'Custom renderer');
beforeEach(() => {
DashboardRendererProviders.getInstance().reset();
});
test('getDashboardRenderer returns the built-in default when nothing is registered', () => {
expect(dashboards.getDashboardRenderer()?.renderer.id).toBe(
'superset.dashboard-renderer',
);
});
test('getDefaultDashboardRenderer always returns the built-in provider', () => {
expect(dashboards.getDefaultDashboardRenderer()?.renderer.id).toBe(
'superset.dashboard-renderer',
);
// Registering an override does not change the default
const disposable = dashboards.registerDashboardRenderer(
{ id: 'acme.renderer', name: 'Acme Renderer' },
component,
);
expect(dashboards.getDefaultDashboardRenderer()?.renderer.id).toBe(
'superset.dashboard-renderer',
);
disposable.dispose();
});
test('registerDashboardRenderer makes the provider retrievable', () => {
const descriptor = { id: 'acme.renderer', name: 'Acme Renderer' };
dashboards.registerDashboardRenderer(descriptor, component);
const provider = dashboards.getDashboardRenderer();
expect(provider?.renderer).toEqual(descriptor);
expect(provider?.component).toBe(component);
});
test('the last-registered renderer wins when multiple are registered', () => {
jest.spyOn(console, 'warn').mockImplementation(() => {});
dashboards.registerDashboardRenderer(
{ id: 'first.renderer', name: 'First' },
component,
);
dashboards.registerDashboardRenderer(
{ id: 'second.renderer', name: 'Second' },
component,
);
expect(dashboards.getDashboardRenderer()?.renderer.id).toBe(
'second.renderer',
);
jest.restoreAllMocks();
});
test('disposing the registration falls back to the built-in default', () => {
const disposable = dashboards.registerDashboardRenderer(
{ id: 'acme.renderer', name: 'Acme Renderer' },
component,
);
expect(dashboards.getDashboardRenderer()?.renderer.id).toBe('acme.renderer');
disposable.dispose();
expect(dashboards.getDashboardRenderer()?.renderer.id).toBe(
'superset.dashboard-renderer',
);
});
test('registration events fire through the public API', () => {
const registered = jest.fn();
const unregistered = jest.fn();
dashboards.onDidRegisterDashboardRenderer(registered);
dashboards.onDidUnregisterDashboardRenderer(unregistered);
const descriptor = { id: 'acme.renderer', name: 'Acme Renderer' };
const disposable = dashboards.registerDashboardRenderer(
descriptor,
component,
);
disposable.dispose();
expect(registered).toHaveBeenCalledWith({ renderer: descriptor });
expect(unregistered).toHaveBeenCalledWith({ renderer: descriptor });
});

View File

@@ -0,0 +1,58 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* @fileoverview Host implementation of the `dashboards` contribution type.
*
* Extensions register via the public `dashboards.registerDashboardRenderer()`
* and the host resolves the active provider, falling back to the built-in
* dashboard renderer when no extension is registered.
*
* The public namespace (`dashboards`) is exposed to extensions on
* `window.superset`. `DashboardRendererHost` is the host-internal component
* for rendering dashboards and is NOT part of the public
* `@apache-superset/core` API.
*/
import { useSyncExternalStore } from 'react';
import { dashboards as dashboardsApi } from '@apache-superset/core';
import DashboardRendererProviders from './DashboardRendererProviders';
// Registers the built-in renderer as the default provider (side effect)
import './defaultRenderer';
export type { DashboardRendererHostProps } from './DashboardRendererHost';
export { default as DashboardRendererHost } from './DashboardRendererHost';
export { DEFAULT_DASHBOARD_RENDERER } from './defaultRenderer';
const provider = DashboardRendererProviders.getInstance();
export const useDashboardRenderer = () =>
useSyncExternalStore(
provider.subscribe,
() => provider.getProvider(),
() => undefined,
);
export const dashboards: typeof dashboardsApi = {
registerDashboardRenderer: provider.registerProvider.bind(provider),
getDashboardRenderer: provider.getProvider.bind(provider),
getDefaultDashboardRenderer: provider.getDefaultProvider.bind(provider),
onDidRegisterDashboardRenderer: provider.onDidRegister.bind(provider),
onDidUnregisterDashboardRenderer: provider.onDidUnregister.bind(provider),
};

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,128 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import type { ReactNode } from 'react';
import { Suspense } from 'react';
import { render, screen } from 'spec/helpers/testing-library';
import type { dashboards } from '@apache-superset/core';
import DashboardContainer from 'src/dashboard/containers/Dashboard';
import DefaultDashboardRenderer from './DefaultDashboardRenderer';
jest.mock('src/dashboard/containers/Dashboard', () => ({
__esModule: true,
default: jest.fn(({ children }: { children: ReactNode }) => (
<div>{children}</div>
)),
}));
jest.mock('src/dashboard/components/DashboardBuilder/DashboardBuilder', () => ({
__esModule: true,
default: () => <div data-test="dashboard-builder">DashboardBuilder</div>,
}));
jest.mock('src/dashboard/util/activeDashboardFilters', () => ({
getActiveFilters: () => ({
'legacy-filter': { scope: [99], values: {} },
}),
}));
const MockDashboardContainer = DashboardContainer as unknown as jest.Mock;
const rendererProps: dashboards.DashboardRendererProps = {
dashboard: { id: 1, title: 'Ignored', metadata: {}, layout: {} },
charts: [],
datasets: [],
initialDataMask: {},
};
const initialState = {
dashboardInfo: { id: 1, metadata: { chart_configuration: {} } },
dashboardState: { sliceIds: [7, 123] },
nativeFilters: {
filters: {
'NATIVE_FILTER-x': {
id: 'NATIVE_FILTER-x',
chartsInScope: [7],
filterType: 'filter_select',
targets: [{ column: { name: 'country' }, datasetId: 3 }],
},
},
},
dataMask: {
'NATIVE_FILTER-x': {
id: 'NATIVE_FILTER-x',
extraFormData: {
filters: [{ col: 'country', op: 'IN', val: ['USA'] }],
},
filterState: { value: ['USA'] },
},
'123': {
id: '123',
extraFormData: {},
filterState: {},
ownState: { currentPage: 2, clientView: { rows: [1, 2, 3] } },
},
},
};
beforeEach(() => {
MockDashboardContainer.mockClear();
});
const setup = () =>
render(
<Suspense fallback="loading">
<DefaultDashboardRenderer {...rendererProps} />
</Suspense>,
{ useRedux: true, initialState },
);
test('renders DashboardBuilder inside DashboardContainer', async () => {
setup();
expect(await screen.findByTestId('dashboard-builder')).toBeInTheDocument();
});
test('derives activeFilters from Redux, merging legacy and native filters', async () => {
setup();
await screen.findByTestId('dashboard-builder');
const [[{ activeFilters }]] = MockDashboardContainer.mock.calls;
// Legacy filter_box filters from the module singleton are merged in
expect(activeFilters['legacy-filter']).toEqual({ scope: [99], values: {} });
// Native filter resolved with its chartsInScope and extraFormData values
expect(activeFilters['NATIVE_FILTER-x']).toEqual(
expect.objectContaining({
scope: [7],
values: {
filters: [{ col: 'country', op: 'IN', val: ['USA'] }],
},
filterType: 'filter_select',
}),
);
});
test('derives ownDataCharts from dataMask ownState, stripping clientView', async () => {
setup();
await screen.findByTestId('dashboard-builder');
const [[{ ownDataCharts }]] = MockDashboardContainer.mock.calls;
// Only entries with ownState are relevant, and the TableChart clientView
// payload must be stripped so it never triggers chart re-queries
expect(ownDataCharts).toEqual({ '123': { currentPage: 2 } });
});

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,233 @@ test('renders a not-found state instead of throwing when the dashboard 404s', as
expect(window.location.pathname).toBe('/dashboard/list/');
});
test('renders the built-in dashboard renderer when no custom renderer is registered', async () => {
render(
<Suspense fallback="loading">
<DashboardPage idOrSlug="1" />
</Suspense>,
{
useRedux: true,
useRouter: true,
initialState: {
dashboardInfo: { id: 1, metadata: {} },
dashboardState: { sliceIds: [] },
nativeFilters: { filters: {} },
dataMask: {},
},
},
);
expect(await screen.findByText('DashboardBuilder')).toBeInTheDocument();
});
test('renders a registered custom dashboard renderer with the contract props', async () => {
window.featureFlags = { [FeatureFlag.EnableExtensions]: true };
mockUseDashboardCharts.mockReturnValue({
result: [{ id: 7, slice_name: 'Test chart' }],
error: null,
});
mockUseDashboardDatasets.mockReturnValue({
result: [{ id: 3, table_name: 'test_table' }],
error: null,
status: 'complete',
});
const customRenderer = jest.fn(
({ dashboard }: dashboards.DashboardRendererProps) => (
<div data-test="custom-dashboard-renderer">{dashboard.title}</div>
),
);
DashboardRendererProviders.getInstance().registerProvider(
{ id: 'test.custom', name: 'Custom Renderer' },
customRenderer,
);
render(
<Suspense fallback="loading">
<DashboardPage idOrSlug="1" />
</Suspense>,
{
useRedux: true,
useRouter: true,
initialState: {
dashboardInfo: { id: 1, metadata: {} },
dashboardState: { sliceIds: [] },
nativeFilters: { filters: {} },
dataMask: {},
},
},
);
expect(
await screen.findByTestId('custom-dashboard-renderer'),
).toHaveTextContent('Test Dashboard');
expect(screen.queryByText('DashboardBuilder')).not.toBeInTheDocument();
// The custom renderer receives the full contract props
expect(customRenderer).toHaveBeenCalledWith(
expect.objectContaining({
dashboard: expect.objectContaining({
id: 1,
title: 'Test Dashboard',
isPublished: true,
}),
charts: [expect.objectContaining({ id: 7 })],
datasets: [expect.objectContaining({ id: 3 })],
initialDataMask: expect.any(Object),
}),
expect.anything(),
);
// Host behavior stays identical: the store is still hydrated so
// permalinks, SyncDashboardState, and the embedded path keep working.
expect(hydrateDashboard).toHaveBeenCalled();
});
test('renders the built-in renderer in edit mode even with a custom renderer registered', async () => {
window.featureFlags = { [FeatureFlag.EnableExtensions]: true };
DashboardRendererProviders.getInstance().registerProvider(
{ id: 'test.custom', name: 'Custom Renderer' },
() => <div data-test="custom-dashboard-renderer" />,
);
render(
<Suspense fallback="loading">
<DashboardPage idOrSlug="1" />
</Suspense>,
{
useRedux: true,
useRouter: true,
initialState: {
dashboardInfo: { id: 1, metadata: {} },
dashboardState: { sliceIds: [], editMode: true },
nativeFilters: { filters: {} },
dataMask: {},
},
},
);
expect(await screen.findByText('DashboardBuilder')).toBeInTheDocument();
expect(
screen.queryByTestId('custom-dashboard-renderer'),
).not.toBeInTheDocument();
});
test('passes URL filter state to the custom renderer as initialDataMask', async () => {
window.featureFlags = { [FeatureFlag.EnableExtensions]: true };
mockGetUrlParam.mockImplementation((param: { name: string }) => {
if (param.name === 'native_filters') {
return {
'NATIVE_FILTER-abc': {
extraFormData: {
filters: [{ col: 'country', op: 'IN', val: ['USA'] }],
},
filterState: { value: ['USA'] },
},
};
}
return null;
});
const customRenderer = jest.fn(() => (
<div data-test="custom-dashboard-renderer" />
));
DashboardRendererProviders.getInstance().registerProvider(
{ id: 'test.custom', name: 'Custom Renderer' },
customRenderer,
);
render(
<Suspense fallback="loading">
<DashboardPage idOrSlug="1" />
</Suspense>,
{
useRedux: true,
useRouter: true,
initialState: {
dashboardInfo: { id: 1, metadata: {} },
dashboardState: { sliceIds: [] },
nativeFilters: { filters: {} },
dataMask: {},
},
},
);
await screen.findByTestId('custom-dashboard-renderer');
expect(customRenderer).toHaveBeenCalledWith(
expect.objectContaining({
initialDataMask: expect.objectContaining({
'NATIVE_FILTER-abc': expect.objectContaining({
filterState: { value: ['USA'] },
extraFormData: {
filters: [{ col: 'country', op: 'IN', val: ['USA'] }],
},
}),
}),
}),
expect.anything(),
);
});
test('maps parsed dashboard metadata and layout into the renderer contract', async () => {
window.featureFlags = { [FeatureFlag.EnableExtensions]: true };
const metadata = { color_scheme: 'supersetColors' };
const positionData = {
ROOT_ID: { type: 'ROOT', id: 'ROOT_ID', children: ['GRID_ID'] },
GRID_ID: { type: 'GRID', id: 'GRID_ID', children: [] },
};
mockUseDashboard.mockReturnValue({
result: {
...mockDashboard,
uuid: 'abc-123',
metadata,
position_data: positionData,
},
error: null,
});
const customRenderer = jest.fn(() => (
<div data-test="custom-dashboard-renderer" />
));
DashboardRendererProviders.getInstance().registerProvider(
{ id: 'test.custom', name: 'Custom Renderer' },
customRenderer,
);
render(
<Suspense fallback="loading">
<DashboardPage idOrSlug="1" />
</Suspense>,
{
useRedux: true,
useRouter: true,
initialState: {
dashboardInfo: { id: 1, metadata: {} },
dashboardState: { sliceIds: [] },
nativeFilters: { filters: {} },
dataMask: {},
},
},
);
await screen.findByTestId('custom-dashboard-renderer');
expect(customRenderer).toHaveBeenCalledWith(
expect.objectContaining({
dashboard: expect.objectContaining({
uuid: 'abc-123',
metadata,
layout: positionData,
}),
uiConfig: expect.objectContaining({
hideTitle: false,
hideTab: false,
hideChartControls: false,
}),
}),
expect.anything(),
);
});
test('clears undo history after hydrating the dashboard', async () => {
render(
<Suspense fallback="loading">

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

@@ -100,6 +100,10 @@ test('sets up global superset object when user is logged in', async () => {
expect((window as any).superset.chat).toBeDefined();
expect((window as any).superset.core).toBeDefined();
expect((window as any).superset.commands).toBeDefined();
expect((window as any).superset.dashboards).toBeDefined();
expect(
typeof (window as any).superset.dashboards.registerDashboardRenderer,
).toBe('function');
expect((window as any).superset.extensions).toBeDefined();
expect((window as any).superset.menus).toBeDefined();
expect((window as any).superset.views).toBeDefined();

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