diff --git a/SIP-DASHBOARD-COMPONENT-CONTRIBUTION-POINT.md b/SIP-DASHBOARD-COMPONENT-CONTRIBUTION-POINT.md index 6325948cad8..4f4d1aafb43 100644 --- a/SIP-DASHBOARD-COMPONENT-CONTRIBUTION-POINT.md +++ b/SIP-DASHBOARD-COMPONENT-CONTRIBUTION-POINT.md @@ -245,12 +245,18 @@ contribution-point pattern from #41000/#41205): - [x] Tests: registry lifecycle (register/get/replace/dispose), host-wrapper resolution + fallback + `updateMeta`, iframe content + CSP UX +- [x] Per-component behavior policy honored by the layout engine: `resizable`, + `minWidth`, `isUserContent`, `validParents`, and `wrapInRow` are seeded onto + instance `meta` at creation and read by `componentIsResizable`, + `getDetailedComponentWidth`, `isDashboardEmpty`, `isValidChild`, and + `shouldWrapChildInRow` (the pure layout utils stay registry-free; behavior + round-trips in the saved layout) +- [x] Developer docs: `extension-points/dashboard-components.md` + a + `contribution-types.md` section + sidebar entry, with an example extension + Remaining (follow-up, not POC-blocking): -- [ ] Per-component nesting policy in the global behavior maps (currently - `EXTENSION_TYPE` uses uniform leaf behavior; `validParents`/`wrapInRow`/ - `minWidth` from the definition are not yet consulted by the global maps) - [ ] Manifest `contributions.dashboardComponents` declarative validation in the - Python/TS manifest schema (runtime side-effect registration works today) + Python/TS manifest schema (runtime side-effect registration works today, + matching how `chat` does it) - [ ] Remove the legacy `DashboardComponentsRegistry`/`DYNAMIC_TYPE` (major) -- [ ] Developer docs + example extension diff --git a/docs/developer_docs/extensions/contribution-types.md b/docs/developer_docs/extensions/contribution-types.md index f339fc01949..3eae9f73b68 100644 --- a/docs/developer_docs/extensions/contribution-types.md +++ b/docs/developer_docs/extensions/contribution-types.md @@ -129,6 +129,27 @@ chat.registerChat( See [Chat](./extension-points/chat.md) for implementation details. +### Dashboard Components + +Extensions can add first-class layout components to the dashboard builder — elements that live in the grid alongside charts, Markdown, and tabs. The host owns the drag/resize/delete chrome, so the extension only provides the component that renders the element's content. The built-in iframe component is implemented through this contribution point. + +```tsx +import { dashboardComponents } from '@apache-superset/core'; +import WeatherWidget from './WeatherWidget'; + +dashboardComponents.registerDashboardComponent( + { + id: 'my-org.weather', + name: 'Weather widget', + icon: 'CloudOutlined', + defaultMeta: { width: 4, height: 50 }, + }, + WeatherWidget, +); +``` + +See [Dashboard Components](./extension-points/dashboard-components.md) for implementation details. + ## Backend Backend contribution types allow extensions to extend Superset's server-side capabilities. Backend contributions are registered at startup via classes and functions imported from the auto-discovered `entrypoint.py` file. diff --git a/docs/developer_docs/extensions/extension-points/dashboard-components.md b/docs/developer_docs/extensions/extension-points/dashboard-components.md new file mode 100644 index 00000000000..aa6b9ab48a7 --- /dev/null +++ b/docs/developer_docs/extensions/extension-points/dashboard-components.md @@ -0,0 +1,155 @@ +--- +title: Dashboard Components +sidebar_position: 4 +--- + + + +# Dashboard Component Contributions + +Extensions can add first-class **layout components** to the dashboard builder — +elements that sit in the grid alongside charts, Markdown, and tabs. The built-in +iframe component is itself implemented through this contribution point. + +The host owns the surrounding **chrome** (the drag handle, the resize container, +and the delete affordance), so your component only renders its content and, in +edit mode, its own editor affordances. This keeps the contract small and stable. + +> This supersedes the legacy `DashboardComponentsRegistry` / `DYNAMIC_TYPE` +> mechanism, which is deprecated. + +## Overview + +A dashboard component contribution is: + +| Part | Role | +|------|------| +| **Definition** | A descriptor declaring the component's id, palette label, icon, and layout behavior (resizable, default size, nesting). | +| **Component** | A React component that renders the element's content and receives the [`DashboardComponentProps`](#component-contract) contract. | + +## The Component Contract + +Your component receives a small, stable set of props. It never deals with drag, +resize, or delete — the host renders it inside that chrome. + +```ts +interface DashboardComponentProps { + /** The layout item id of this instance. */ + id: string; + /** This instance's persisted meta (round-trips in the saved layout). */ + meta: Record; + /** Whether the dashboard is in edit mode. */ + editMode: boolean; + /** Shallow-merge a patch into this instance's persisted meta. */ + updateMeta: (patch: Record) => void; +} +``` + +Persist any per-instance state in `meta` via `updateMeta`. It is saved with the +dashboard and rehydrated on load. + +## Registering a Dashboard Component + +Call `dashboardComponents.registerDashboardComponent` from your extension's entry +point with a definition and your component: + +```tsx +import { dashboardComponents } from '@apache-superset/core'; +import WeatherWidget from './WeatherWidget'; + +dashboardComponents.registerDashboardComponent( + { + id: 'my-org.weather', + name: 'Weather widget', + description: 'Shows the current weather for a city', + icon: 'CloudOutlined', + resizable: true, + defaultMeta: { width: 4, height: 50, city: 'Lisbon' }, + }, + WeatherWidget, +); +``` + +```tsx +// WeatherWidget.tsx +import type { dashboardComponents } from '@apache-superset/core'; + +type Props = dashboardComponents.DashboardComponentProps; + +export default function WeatherWidget({ meta, editMode, updateMeta }: Props) { + const city = (meta.city as string) ?? ''; + return editMode ? ( + updateMeta({ city: e.target.value })} + placeholder="City" + /> + ) : ( + + ); +} +``` + +The component appears in the dashboard builder's **Layout elements** palette and +can be dragged onto the grid like any built-in element. + +## Definition Reference + +| Field | Type | Description | +|-------|------|-------------| +| `id` | `string` | Namespaced unique id, e.g. `my-org.weather`. Selects the component for each instance. | +| `name` | `string` | Label shown in the builder palette. | +| `description` | `string` | Optional longer description. | +| `icon` | `string` | A known Superset icon name (e.g. `CloudOutlined`). Falls back to a generic icon. | +| `resizable` | `boolean` | Whether instances can be resized. Defaults to `true`. | +| `defaultMeta` | `object` | `meta` seeded onto a new instance (e.g. `width`, `height`, and your own keys). | +| `isUserContent` | `boolean` | Whether an instance counts as content for "is this dashboard empty?" detection. Defaults to `true`. | +| `minWidth` | `number` | Minimum width in grid columns. Defaults to `1`. | +| `validParents` | `string[]` | Restrict which container types may hold the component (e.g. `['GRID', 'TAB']`). Defaults to standard content-leaf placement (grid, row, column, tab). | +| `wrapInRow` | `boolean` | Whether a drop into the grid or a tab auto-wraps the component in a row. Defaults to `true`. | + +The layout-relevant behavior fields are seeded onto each instance's `meta` at +creation, so the dashboard honors them — and they round-trip in the saved layout +even if the extension later becomes unavailable. + +## Graceful Degradation + +If a saved dashboard references a component whose extension is disabled or not +yet loaded, the host renders a non-destructive placeholder in its place and +preserves the instance's `meta` on save. Re-enabling the extension restores the +component. + +## Dashboard Components API Reference + +All methods are available on the `dashboardComponents` namespace from +`@apache-superset/core`: + +| Method / Event | Description | +|----------------|-------------| +| `registerDashboardComponent(definition, component)` | Register a component. Returns a `Disposable` to unregister. Registering the same id again replaces the previous registration. | +| `getDashboardComponent(id)` | Returns the registered component for `id`, or `undefined`. | +| `getDashboardComponents()` | Returns all registered components. | +| `onDidRegisterDashboardComponent(listener)` | Subscribe to registration events. Returns a `Disposable`. | +| `onDidUnregisterDashboardComponent(listener)` | Subscribe to unregistration events. Returns a `Disposable`. | + +## Next Steps + +- **[Contribution Types](../contribution-types.md)** — Explore other contribution types +- **[Development](../development.md)** — Set up your development environment diff --git a/docs/developer_docs/sidebars.js b/docs/developer_docs/sidebars.js index fe03dac7b8f..723aa3f503d 100644 --- a/docs/developer_docs/sidebars.js +++ b/docs/developer_docs/sidebars.js @@ -49,6 +49,7 @@ module.exports = { 'extensions/extension-points/sqllab', 'extensions/extension-points/editors', 'extensions/extension-points/chat', + 'extensions/extension-points/dashboard-components', ], }, 'extensions/development', diff --git a/superset-frontend/packages/superset-core/src/dashboardComponents/index.ts b/superset-frontend/packages/superset-core/src/dashboardComponents/index.ts index 3fdbee67826..a548fb5ef12 100644 --- a/superset-frontend/packages/superset-core/src/dashboardComponents/index.ts +++ b/superset-frontend/packages/superset-core/src/dashboardComponents/index.ts @@ -89,6 +89,35 @@ export interface DashboardComponentDefinition { * detection. Defaults to true. */ isUserContent?: boolean; + /** Minimum width in grid columns. Defaults to 1. */ + minWidth?: number; + /** + * Restrict which container types may hold this component (e.g. + * `['GRID', 'TAB']`). When omitted, the component is allowed wherever a + * standard content leaf is allowed (grid, row, column, tab). + */ + validParents?: string[]; + /** + * Whether a drop into the grid or a tab auto-wraps the component in a row. + * Defaults to true (matching built-in content components). + */ + wrapInRow?: boolean; +} + +/** + * The subset of a definition's behavior that is seeded onto each instance's + * `meta` at creation, so the dashboard layout engine can honor it (and so it + * round-trips in the saved layout even if the extension later becomes + * unavailable). Read by the dashboard util maps; not part of the rendered + * component's concern. + */ +export interface DashboardComponentBehaviorMeta { + extensionComponentId: string; + resizable?: boolean; + isUserContent?: boolean; + minWidth?: number; + validParents?: string[]; + wrapInRow?: boolean; } /** diff --git a/superset-frontend/src/dashboard/components/gridComponents/new/NewExtensionComponent.tsx b/superset-frontend/src/dashboard/components/gridComponents/new/NewExtensionComponent.tsx index f80e48ac808..ad324150501 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/new/NewExtensionComponent.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/new/NewExtensionComponent.tsx @@ -41,13 +41,31 @@ export default function NewExtensionComponent({ ]) || Icons.AppstoreOutlined; + // Seed the layout-relevant behavior onto the instance meta so the (pure) + // dashboard util maps can honor it without coupling to the component registry, + // and so it round-trips in the saved layout even if the extension is later + // unavailable. Only defined fields are seeded to keep meta tidy. + const behaviorMeta: Record = { + extensionComponentId: definition.id, + }; + if (definition.resizable !== undefined) + behaviorMeta.resizable = definition.resizable; + if (definition.isUserContent !== undefined) + behaviorMeta.isUserContent = definition.isUserContent; + if (definition.minWidth !== undefined) + behaviorMeta.minWidth = definition.minWidth; + if (definition.validParents !== undefined) + behaviorMeta.validParents = definition.validParents; + if (definition.wrapInRow !== undefined) + behaviorMeta.wrapInRow = definition.wrapInRow; + return ( ); } diff --git a/superset-frontend/src/dashboard/reducers/dashboardLayout.ts b/superset-frontend/src/dashboard/reducers/dashboardLayout.ts index aab6cd25a32..98f40cbc951 100644 --- a/superset-frontend/src/dashboard/reducers/dashboardLayout.ts +++ b/superset-frontend/src/dashboard/reducers/dashboardLayout.ts @@ -210,6 +210,7 @@ const actionHandlers: Record< const wrapInRow = shouldWrapChildInRow({ parentType: destination.type, childType: dragging.type, + childMeta: dragging.meta, }); if (wrapInRow) { diff --git a/superset-frontend/src/dashboard/types.ts b/superset-frontend/src/dashboard/types.ts index 00553860f31..bfefc196ac1 100644 --- a/superset-frontend/src/dashboard/types.ts +++ b/superset-frontend/src/dashboard/types.ts @@ -280,6 +280,10 @@ export type LayoutItemMeta = { url?: string; /** Background style value for columns and rows */ background?: string; + /** Extension-contributed components opt out of resizing via this flag */ + resizable?: boolean; + /** Extension-contributed components opt out of default row-wrapping via this flag */ + wrapInRow?: boolean; /** Allow additional meta properties used by different component types */ [key: string]: unknown; }; diff --git a/superset-frontend/src/dashboard/util/componentIsResizable.ts b/superset-frontend/src/dashboard/util/componentIsResizable.ts index 6bfe1ea61c4..f7ec4d8b170 100644 --- a/superset-frontend/src/dashboard/util/componentIsResizable.ts +++ b/superset-frontend/src/dashboard/util/componentIsResizable.ts @@ -24,9 +24,17 @@ import { DYNAMIC_TYPE, } from './componentTypes'; -export default function componentIsResizable(entity: { type: string }) { +export default function componentIsResizable(entity: { + type: string; + meta?: { resizable?: boolean }; +}) { + // Extension-contributed components opt out of resizing via their definition, + // seeded onto meta at creation. + if (entity.type === EXTENSION_TYPE) { + return entity.meta?.resizable !== false; + } return ( - [COLUMN_TYPE, CHART_TYPE, EXTENSION_TYPE, MARKDOWN_TYPE, DYNAMIC_TYPE].indexOf( + [COLUMN_TYPE, CHART_TYPE, MARKDOWN_TYPE, DYNAMIC_TYPE].indexOf( entity.type, ) > -1 ); diff --git a/superset-frontend/src/dashboard/util/extensionComponentBehavior.test.ts b/superset-frontend/src/dashboard/util/extensionComponentBehavior.test.ts new file mode 100644 index 00000000000..9eae2e9f468 --- /dev/null +++ b/superset-frontend/src/dashboard/util/extensionComponentBehavior.test.ts @@ -0,0 +1,123 @@ +/** + * 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 componentIsResizable from './componentIsResizable'; +import isValidChild from './isValidChild'; +import shouldWrapChildInRow from './shouldWrapChildInRow'; +import isDashboardEmpty from './isDashboardEmpty'; +import getDetailedComponentWidth from './getDetailedComponentWidth'; +import { + EXTENSION_TYPE, + DASHBOARD_GRID_TYPE, + TAB_TYPE, + COLUMN_TYPE, +} from './componentTypes'; + +test('componentIsResizable honors meta.resizable for extension components', () => { + expect(componentIsResizable({ type: EXTENSION_TYPE })).toBe(true); + expect( + componentIsResizable({ type: EXTENSION_TYPE, meta: { resizable: true } }), + ).toBe(true); + expect( + componentIsResizable({ type: EXTENSION_TYPE, meta: { resizable: false } }), + ).toBe(false); +}); + +test('isValidChild restricts extension parents via meta.validParents', () => { + // Allowed in GRID when GRID is listed + expect( + isValidChild({ + parentType: DASHBOARD_GRID_TYPE, + childType: EXTENSION_TYPE, + parentDepth: 1, + childMeta: { validParents: [DASHBOARD_GRID_TYPE] }, + }), + ).toBe(true); + // Forbidden in COLUMN when only GRID is listed + expect( + isValidChild({ + parentType: COLUMN_TYPE, + childType: EXTENSION_TYPE, + parentDepth: 4, + childMeta: { validParents: [DASHBOARD_GRID_TYPE] }, + }), + ).toBe(false); + // No restriction → standard leaf behavior (allowed in GRID at depth 1) + expect( + isValidChild({ + parentType: DASHBOARD_GRID_TYPE, + childType: EXTENSION_TYPE, + parentDepth: 1, + }), + ).toBe(true); +}); + +test('shouldWrapChildInRow honors meta.wrapInRow for extension components', () => { + expect( + shouldWrapChildInRow({ + parentType: DASHBOARD_GRID_TYPE, + childType: EXTENSION_TYPE, + }), + ).toBe(true); + expect( + shouldWrapChildInRow({ + parentType: DASHBOARD_GRID_TYPE, + childType: EXTENSION_TYPE, + childMeta: { wrapInRow: false }, + }), + ).toBe(false); +}); + +test('isDashboardEmpty respects meta.isUserContent for extension components', () => { + const userContent = { + a: { type: EXTENSION_TYPE, meta: { isUserContent: true } }, + }; + const nonUserContent = { + a: { type: EXTENSION_TYPE, meta: { isUserContent: false } }, + }; + expect(isDashboardEmpty(userContent)).toBe(false); + // An extension that opts out of being user content leaves the dashboard empty + expect(isDashboardEmpty(nonUserContent)).toBe(true); + // Default (no flag) counts as user content + expect(isDashboardEmpty({ a: { type: EXTENSION_TYPE, meta: {} } })).toBe( + false, + ); +}); + +test('getDetailedComponentWidth honors meta.minWidth for extension components', () => { + const withMin = getDetailedComponentWidth({ + component: { type: EXTENSION_TYPE, meta: { minWidth: 3 } } as any, + }); + expect(withMin.minimumWidth).toBe(3); + const withoutMin = getDetailedComponentWidth({ + component: { type: EXTENSION_TYPE, meta: {} } as any, + }); + expect(withoutMin.minimumWidth).toBe(1); +}); + +test('extension nesting still respects container depth limits', () => { + // Even when validParents allows TAB, the depth gate still applies. + expect( + isValidChild({ + parentType: TAB_TYPE, + childType: EXTENSION_TYPE, + parentDepth: 999, + childMeta: { validParents: [TAB_TYPE] }, + }), + ).toBe(false); +}); diff --git a/superset-frontend/src/dashboard/util/getDetailedComponentWidth.ts b/superset-frontend/src/dashboard/util/getDetailedComponentWidth.ts index 8c454466b80..55744609758 100644 --- a/superset-frontend/src/dashboard/util/getDetailedComponentWidth.ts +++ b/superset-frontend/src/dashboard/util/getDetailedComponentWidth.ts @@ -106,9 +106,13 @@ export default function getDetailedComponentWidth({ ); } }); + } else if (component.type === EXTENSION_TYPE) { + // Extension-contributed components may declare a minimum width (grid + // columns) in their definition, seeded onto meta at creation. + const minWidth = component.meta?.minWidth as number | undefined; + result.minimumWidth = minWidth ?? GRID_MIN_COLUMN_COUNT; } else if ( component.type === DYNAMIC_TYPE || - component.type === EXTENSION_TYPE || component.type === MARKDOWN_TYPE || component.type === CHART_TYPE ) { diff --git a/superset-frontend/src/dashboard/util/getDropPosition.ts b/superset-frontend/src/dashboard/util/getDropPosition.ts index 341aa1edde8..017bc3c36c5 100644 --- a/superset-frontend/src/dashboard/util/getDropPosition.ts +++ b/superset-frontend/src/dashboard/util/getDropPosition.ts @@ -81,6 +81,7 @@ export default function getDropPosition( const draggingItem = monitor.getItem() as { id: string; type: string; + meta?: { validParents?: string[] }; } | null; // if dropped self on self, do nothing @@ -92,6 +93,7 @@ export default function getDropPosition( parentType: component.type, parentDepth: componentDepth, childType: draggingItem.type, + childMeta: draggingItem.meta, }); const parentType = parentComponent?.type; @@ -103,6 +105,7 @@ export default function getDropPosition( parentType, parentDepth, childType: draggingItem.type, + childMeta: draggingItem.meta, }); if (!validChild && !validSibling) { diff --git a/superset-frontend/src/dashboard/util/isDashboardEmpty.ts b/superset-frontend/src/dashboard/util/isDashboardEmpty.ts index 1660d3e120d..70c3057bf0c 100644 --- a/superset-frontend/src/dashboard/util/isDashboardEmpty.ts +++ b/superset-frontend/src/dashboard/util/isDashboardEmpty.ts @@ -30,9 +30,17 @@ const USER_CONTENT_COMPONENT_TYPE: string[] = [ DYNAMIC_TYPE, ]; export default function isDashboardEmpty(layout: any): boolean { - // has at least one chart or markdown component + // has at least one chart, markdown, or contributed user-content component return !Object.values(layout).some( - ({ type }: { type?: string }) => - type && USER_CONTENT_COMPONENT_TYPE.includes(type), + ({ type, meta }: { type?: string; meta?: { isUserContent?: boolean } }) => { + if (!type || !USER_CONTENT_COMPONENT_TYPE.includes(type)) { + return false; + } + // Extension components may opt out of counting as user content. + if (type === EXTENSION_TYPE && meta?.isUserContent === false) { + return false; + } + return true; + }, ); } diff --git a/superset-frontend/src/dashboard/util/isValidChild.ts b/superset-frontend/src/dashboard/util/isValidChild.ts index 5361f81fd4d..89be2f6866b 100644 --- a/superset-frontend/src/dashboard/util/isValidChild.ts +++ b/superset-frontend/src/dashboard/util/isValidChild.ts @@ -121,14 +121,29 @@ interface IsValidChildProps { parentType?: string; childType?: string; parentDepth?: unknown; + /** + * The child's meta, when available (e.g. during a drag). Extension-contributed + * components may declare `validParents` to restrict which container types may + * hold them; the restriction is seeded onto meta at creation. + */ + childMeta?: { validParents?: string[] }; } export default function isValidChild(child: IsValidChildProps): boolean { - const { parentType, childType, parentDepth } = child; + const { parentType, childType, parentDepth, childMeta } = child; if (!parentType || !childType || typeof parentDepth !== 'number') { return false; } + // Per-component parent restriction for extension components. + if ( + childType === EXTENSION_TYPE && + Array.isArray(childMeta?.validParents) && + !childMeta.validParents.includes(parentType) + ) { + return false; + } + const maxParentDepth: number | undefined = parentMaxDepthLookup[parentType]?.[childType]; diff --git a/superset-frontend/src/dashboard/util/newEntitiesFromDrop.ts b/superset-frontend/src/dashboard/util/newEntitiesFromDrop.ts index 5307abdf209..75616584ddf 100644 --- a/superset-frontend/src/dashboard/util/newEntitiesFromDrop.ts +++ b/superset-frontend/src/dashboard/util/newEntitiesFromDrop.ts @@ -51,6 +51,7 @@ export default function newEntitiesFromDrop({ const wrapChildInRow = shouldWrapChildInRow({ parentType: dropType, childType: dragType, + childMeta: dragging.meta, }); const newEntities: Record = { diff --git a/superset-frontend/src/dashboard/util/shouldWrapChildInRow.ts b/superset-frontend/src/dashboard/util/shouldWrapChildInRow.ts index 649e2267bac..f8acb64e546 100644 --- a/superset-frontend/src/dashboard/util/shouldWrapChildInRow.ts +++ b/superset-frontend/src/dashboard/util/shouldWrapChildInRow.ts @@ -29,6 +29,12 @@ import { ComponentType } from '../types'; interface WrapChildParams { parentType: ComponentType | undefined | null; childType: ComponentType | undefined | null; + /** + * The child's meta, when available. Extension-contributed components may set + * `wrapInRow: false` in their definition (seeded onto meta) to opt out of the + * default auto-wrapping. + */ + childMeta?: { wrapInRow?: boolean }; } type ParentTypes = typeof DASHBOARD_GRID_TYPE | typeof TAB_TYPE; @@ -60,9 +66,15 @@ const typeToWrapChildLookup: Record< export default function shouldWrapChildInRow({ parentType, childType, + childMeta, }: WrapChildParams): boolean { if (!parentType || !childType) return false; + // Extension components may opt out of auto-wrapping. + if (childType === EXTENSION_TYPE && childMeta?.wrapInRow === false) { + return false; + } + const wrapChildLookup = typeToWrapChildLookup[parentType as ParentTypes]; if (!wrapChildLookup) return false;