mirror of
https://github.com/apache/superset.git
synced 2026-07-19 21:25:38 +00:00
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:
@@ -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.
|
||||
|
||||
111
docs/developer_docs/extensions/extension-points/dashboards.md
Normal file
111
docs/developer_docs/extensions/extension-points/dashboards.md
Normal 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.
|
||||
@@ -30,6 +30,10 @@
|
||||
"types": "./lib/commands/index.d.ts",
|
||||
"default": "./lib/commands/index.js"
|
||||
},
|
||||
"./dashboards": {
|
||||
"types": "./lib/dashboards/index.d.ts",
|
||||
"default": "./lib/dashboards/index.js"
|
||||
},
|
||||
"./editors": {
|
||||
"types": "./lib/editors/index.d.ts",
|
||||
"default": "./lib/editors/index.js"
|
||||
|
||||
@@ -23,11 +23,13 @@
|
||||
* This module defines the aggregate interfaces used by the extension.json
|
||||
* manifest and the `superset-extensions` build command. Individual metadata
|
||||
* types are defined in their respective namespace modules (commands, views,
|
||||
* menus, editors, chat) and re-exported here for the manifest schema.
|
||||
* menus, editors, chat, dashboards) and re-exported here for the manifest
|
||||
* schema.
|
||||
*/
|
||||
|
||||
import { Chat } from '../chat';
|
||||
import { Command } from '../commands';
|
||||
import { DashboardRenderer } from '../dashboards';
|
||||
import { View } from '../views';
|
||||
import { Menu } from '../menus';
|
||||
import { Editor } from '../editors';
|
||||
@@ -90,4 +92,10 @@ export interface Contributions {
|
||||
* chat at a time.
|
||||
*/
|
||||
chat?: Chat;
|
||||
/**
|
||||
* The dashboard renderer contributed by the extension — at most one per
|
||||
* extension, since the host applies singleton resolution and renders
|
||||
* exactly one active dashboard renderer at a time.
|
||||
*/
|
||||
dashboardRenderer?: DashboardRenderer;
|
||||
}
|
||||
|
||||
285
superset-frontend/packages/superset-core/src/dashboards/index.ts
Normal file
285
superset-frontend/packages/superset-core/src/dashboards/index.ts
Normal 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>;
|
||||
@@ -20,6 +20,7 @@ export * as common from './common';
|
||||
export * as authentication from './authentication';
|
||||
export * as chat from './chat';
|
||||
export * as commands from './commands';
|
||||
export * as dashboards from './dashboards';
|
||||
export * as editors from './editors';
|
||||
export * as extensions from './extensions';
|
||||
export * as menus from './menus';
|
||||
|
||||
@@ -0,0 +1,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();
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
54
superset-frontend/src/core/dashboards/index.ts
Normal file
54
superset-frontend/src/core/dashboards/index.ts
Normal 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),
|
||||
};
|
||||
@@ -29,6 +29,7 @@ export const core: typeof coreType = {
|
||||
export * from './authentication';
|
||||
export * from './chat';
|
||||
export * from './commands';
|
||||
export * from './dashboards';
|
||||
export * from './editors';
|
||||
export * from './extensions';
|
||||
export * from './menus';
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview The built-in dashboard renderer.
|
||||
*
|
||||
* This component conforms to the `DashboardRendererProps` contract from
|
||||
* `@apache-superset/core/dashboards` but intentionally ignores those props:
|
||||
* it reads everything from the Redux store the host hydrates. The contract
|
||||
* props exist so custom renderers contributed by extensions can be
|
||||
* Redux-free; migrating this built-in renderer to consume the props instead
|
||||
* of the store is incremental follow-up work behind the stable contract.
|
||||
*/
|
||||
|
||||
import { FC, lazy, useMemo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import type { dashboards } from '@apache-superset/core';
|
||||
import DashboardContainer from 'src/dashboard/containers/Dashboard';
|
||||
import {
|
||||
getAllActiveFilters,
|
||||
getRelevantDataMask,
|
||||
} from 'src/dashboard/util/activeAllDashboardFilters';
|
||||
import { getActiveFilters } from 'src/dashboard/util/activeDashboardFilters';
|
||||
import { AutoRefreshProvider } from 'src/dashboard/contexts/AutoRefreshContext';
|
||||
import type { ActiveFilters, RootState } from 'src/dashboard/types';
|
||||
|
||||
const DashboardBuilder = lazy(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "DashboardContainer" */
|
||||
/* webpackPreload: true */
|
||||
'src/dashboard/components/DashboardBuilder/DashboardBuilder'
|
||||
),
|
||||
);
|
||||
|
||||
const selectRelevantDatamask = createSelector(
|
||||
(state: RootState) => state.dataMask, // the first argument accesses relevant data from global state
|
||||
dataMask => getRelevantDataMask(dataMask, 'ownState'), // the second parameter conducts the transformation
|
||||
);
|
||||
|
||||
const selectChartConfiguration = (state: RootState) =>
|
||||
state.dashboardInfo.metadata?.chart_configuration;
|
||||
const selectNativeFilters = (state: RootState) => state.nativeFilters.filters;
|
||||
const selectDataMask = (state: RootState) => state.dataMask;
|
||||
const selectAllSliceIds = (state: RootState) => state.dashboardState.sliceIds;
|
||||
const selectActiveFilters = createSelector(
|
||||
[
|
||||
selectChartConfiguration,
|
||||
selectNativeFilters,
|
||||
selectDataMask,
|
||||
selectAllSliceIds,
|
||||
],
|
||||
(chartConfiguration, nativeFilters, dataMask, allSliceIds) => ({
|
||||
...getActiveFilters(),
|
||||
...getAllActiveFilters({
|
||||
// eslint-disable-next-line camelcase
|
||||
chartConfiguration,
|
||||
nativeFilters,
|
||||
dataMask,
|
||||
allSliceIds,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const DefaultDashboardRenderer: FC<dashboards.DashboardRendererProps> = () => {
|
||||
const relevantDataMask = useSelector(selectRelevantDatamask);
|
||||
const activeFilters = useSelector(selectActiveFilters);
|
||||
const dashboardBuilderComponent = useMemo(() => <DashboardBuilder />, []);
|
||||
|
||||
return (
|
||||
<AutoRefreshProvider>
|
||||
<DashboardContainer
|
||||
activeFilters={activeFilters as ActiveFilters}
|
||||
ownDataCharts={relevantDataMask}
|
||||
>
|
||||
{dashboardBuilderComponent}
|
||||
</DashboardContainer>
|
||||
</AutoRefreshProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default DefaultDashboardRenderer;
|
||||
@@ -31,8 +31,14 @@ import {
|
||||
useDashboardCharts,
|
||||
useDashboardDatasets,
|
||||
} from 'src/hooks/apiResources';
|
||||
import { SupersetApiError, SupersetClient } from '@superset-ui/core';
|
||||
import {
|
||||
FeatureFlag,
|
||||
SupersetApiError,
|
||||
SupersetClient,
|
||||
} from '@superset-ui/core';
|
||||
import type { dashboards } from '@apache-superset/core';
|
||||
import CrudThemeProvider from 'src/components/CrudThemeProvider';
|
||||
import DashboardRendererProviders from 'src/core/dashboards/DashboardRendererProviders';
|
||||
import { hydrateDashboard } from 'src/dashboard/actions/hydrate';
|
||||
import {
|
||||
clearDashboardHistory,
|
||||
@@ -158,6 +164,8 @@ const MockCrudThemeProvider = CrudThemeProvider as unknown as jest.Mock;
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
DashboardRendererProviders.getInstance().reset();
|
||||
window.featureFlags = {};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -602,6 +610,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">
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
chat,
|
||||
core,
|
||||
commands,
|
||||
dashboards,
|
||||
editors,
|
||||
extensions,
|
||||
menus,
|
||||
@@ -59,6 +60,7 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
|
||||
chat,
|
||||
core,
|
||||
commands,
|
||||
dashboards,
|
||||
editors,
|
||||
extensions,
|
||||
menus,
|
||||
|
||||
@@ -31,6 +31,7 @@ import type {
|
||||
chat,
|
||||
commands,
|
||||
core,
|
||||
dashboards,
|
||||
editors,
|
||||
extensions,
|
||||
menus,
|
||||
@@ -45,6 +46,7 @@ export interface Namespaces {
|
||||
core: typeof core;
|
||||
chat: typeof chat;
|
||||
commands: typeof commands;
|
||||
dashboards: typeof dashboards;
|
||||
editors: typeof editors;
|
||||
extensions: typeof extensions;
|
||||
menus: typeof menus;
|
||||
|
||||
@@ -21,6 +21,7 @@ import Role from './Role';
|
||||
|
||||
export interface Dashboard {
|
||||
id: number;
|
||||
uuid?: string;
|
||||
slug?: string | null;
|
||||
url: string;
|
||||
dashboard_title: string;
|
||||
@@ -32,6 +33,7 @@ export interface Dashboard {
|
||||
changed_by_name: string;
|
||||
changed_by: Owner;
|
||||
changed_on: string;
|
||||
is_managed_externally?: boolean;
|
||||
charts: string[]; // just chart names, unfortunately...
|
||||
owners: Owner[];
|
||||
extra_owners?: Owner[];
|
||||
|
||||
Reference in New Issue
Block a user