This is an automated email from the ASF dual-hosted git repository. msyavuz pushed a commit to branch msyavuz/feat/canvas in repository https://gitbox.apache.org/repos/asf/superset.git
commit e397f906faa64ad3438e9fbc527cfa66999761e9 Author: Mehmet Salih Yavuz <[email protected]> AuthorDate: Thu Oct 1 11:48:04 2026 +0300 feat(canvas): list and view pages behind the CANVAS flag Adds read-only /canvas/list/ and /canvas/<id>/ pages and a menu entry, gated on the CANVAS feature flag. The view page renders the server's resolved placements as a CSS grid, reloads on entity.changed (polling /definition/changes when websockets are off), opens the global chat as a panel and publishes the active canvas. Widget renderers register through the new canvas namespace in @apache-superset/core; unregistered or unresolved widgets render a placeholder. The definition response gains gridColumns, the column count of each grid container. --- .../packages/superset-core/package.json | 4 + .../packages/superset-core/src/canvas/index.ts | 114 ++++++++ .../packages/superset-core/src/index.ts | 1 + superset-frontend/src/components/ListView/types.ts | 1 + superset-frontend/src/core/canvas/canvas.test.ts | 63 +++++ superset-frontend/src/core/canvas/index.ts | 92 +++++++ superset-frontend/src/core/index.ts | 1 + .../src/extensions/ExtensionsStartup.tsx | 2 + superset-frontend/src/extensions/Namespaces.ts | 2 + .../src/features/canvas/CanvasGrid.test.tsx | 130 +++++++++ .../src/features/canvas/CanvasGrid.tsx | 199 ++++++++++++++ superset-frontend/src/features/canvas/types.ts | 68 +++++ .../src/features/canvas/useCanvasDefinition.ts | 126 +++++++++ superset-frontend/src/pages/Canvas/Canvas.test.tsx | 114 ++++++++ superset-frontend/src/pages/Canvas/index.tsx | 138 ++++++++++ .../src/pages/CanvasList/CanvasList.test.tsx | 115 ++++++++ superset-frontend/src/pages/CanvasList/index.tsx | 303 +++++++++++++++++++++ superset-frontend/src/views/routePaths.ts | 2 + superset-frontend/src/views/routes.tsx | 15 + superset/canvas/api.py | 3 + superset/canvas/definition/render.py | 12 +- superset/initialization/__init__.py | 10 + superset/views/canvas.py | 47 ++++ tests/unit_tests/canvas/api_test.py | 1 + tests/unit_tests/canvas/render_test.py | 1 + 25 files changed, 1562 insertions(+), 2 deletions(-) diff --git a/superset-frontend/packages/superset-core/package.json b/superset-frontend/packages/superset-core/package.json index 14b36a0a89c..b742be2061b 100644 --- a/superset-frontend/packages/superset-core/package.json +++ b/superset-frontend/packages/superset-core/package.json @@ -23,6 +23,10 @@ "types": "./lib/authentication/index.d.ts", "default": "./lib/authentication/index.js" }, + "./canvas": { + "types": "./lib/canvas/index.d.ts", + "default": "./lib/canvas/index.js" + }, "./chat": { "types": "./lib/chat/index.d.ts", "default": "./lib/chat/index.js" diff --git a/superset-frontend/packages/superset-core/src/canvas/index.ts b/superset-frontend/packages/superset-core/src/canvas/index.ts new file mode 100644 index 00000000000..77d75fb963f --- /dev/null +++ b/superset-frontend/packages/superset-core/src/canvas/index.ts @@ -0,0 +1,114 @@ +/** + * 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 Canvas API for Superset extensions. + * + * A canvas places widgets; widgets are separate entities that bring their own + * renderers. This module lets a widget provider register the React component + * that renders a widget type on a canvas, and lets tools (such as the chat) + * know which canvas the user has open. + * + * @example + * ```typescript + * import { canvas } from '@apache-superset/core'; + * + * canvas.registerWidgetRenderer('my_ext.kpi', KpiTile); + * ``` + */ + +import { ComponentType, ReactNode } from 'react'; +import { Disposable, Event } from '../common'; + +/** A filter value set by a filter widget, as it reaches the widgets it drives. */ +export interface FilterValue { + /** The node id of the filter that set the value. */ + filterNodeId: string; + /** The value, in the filter widget's own format. */ + value: unknown; +} + +/** A child node of a container, for containers that arrange children. */ +export interface CanvasChild { + nodeId: string; + /** The child's widget type, when its widget still resolves. */ + widgetType?: string; + /** The child's stored layout within this container. */ + layout: Record<string, unknown>; + /** The rendered child. */ + element: ReactNode; +} + +/** The props the canvas passes to a widget renderer. */ +export interface CanvasWidgetProps { + canvasId: number; + nodeId: string; + widgetId: string; + widgetType: string; + /** Values of the filters whose scope includes this node. */ + filters: FilterValue[]; + /** + * For filter widgets: publish the filter's value to the widgets it drives. + * Pass `undefined` to clear it. + */ + setFilterValue: (value: unknown) => void; + /** For containers: the child nodes, already rendered. */ + childNodes: CanvasChild[]; + /** + * For grid containers: the children laid out on the container's grid. Other + * containers arrange `childNodes` themselves. + */ + renderGrid: () => ReactNode; +} + +/** The canvas the user has open. */ +export interface ActiveCanvas { + id: number; + title: string; + revision: number; +} + +/** + * Registers the component that renders a widget type on a canvas. + * + * @param widgetType The widget type, as the widget provider names it. + * @param component The renderer. + * @returns A Disposable that unregisters the renderer when disposed. + */ +export declare function registerWidgetRenderer( + widgetType: string, + component: ComponentType<CanvasWidgetProps>, +): Disposable; + +/** + * Returns the renderer registered for a widget type, if any. + */ +export declare function getWidgetRenderer( + widgetType: string, +): ComponentType<CanvasWidgetProps> | undefined; + +/** + * Returns the canvas the user has open, if any. + */ +export declare function getActiveCanvas(): ActiveCanvas | undefined; + +/** + * Fires when the user opens or leaves a canvas, or the open canvas changes. + */ +export declare const onDidChangeActiveCanvas: Event<ActiveCanvas | undefined>; diff --git a/superset-frontend/packages/superset-core/src/index.ts b/superset-frontend/packages/superset-core/src/index.ts index 6d6a0aa4653..c0e88af7dff 100644 --- a/superset-frontend/packages/superset-core/src/index.ts +++ b/superset-frontend/packages/superset-core/src/index.ts @@ -18,6 +18,7 @@ */ export * as common from './common'; export * as authentication from './authentication'; +export * as canvas from './canvas'; export * as chat from './chat'; export * as commands from './commands'; export * as editors from './editors'; diff --git a/superset-frontend/src/components/ListView/types.ts b/superset-frontend/src/components/ListView/types.ts index 571b02306f3..aa788630455 100644 --- a/superset-frontend/src/components/ListView/types.ts +++ b/superset-frontend/src/components/ListView/types.ts @@ -127,6 +127,7 @@ export enum ListViewFilterOperator { NameOrDescription = 'name_or_description', AllText = 'all_text', ChartAllText = 'chart_all_text', + CanvasAllText = 'canvas_all_text', DatasetIsNullOrEmpty = 'dataset_is_null_or_empty', Between = 'between', DashboardIsFav = 'dashboard_is_favorite', diff --git a/superset-frontend/src/core/canvas/canvas.test.ts b/superset-frontend/src/core/canvas/canvas.test.ts new file mode 100644 index 00000000000..a9953c4307f --- /dev/null +++ b/superset-frontend/src/core/canvas/canvas.test.ts @@ -0,0 +1,63 @@ +/** + * 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 { canvas, setActiveCanvas } from 'src/core'; + +const Kpi = () => null; + +afterEach(() => setActiveCanvas(undefined)); + +test('widget renderers register and unregister by type', () => { + const registration = canvas.registerWidgetRenderer('test.kpi', Kpi); + + expect(canvas.getWidgetRenderer('test.kpi')).toBe(Kpi); + + registration.dispose(); + + expect(canvas.getWidgetRenderer('test.kpi')).toBeUndefined(); +}); + +test('disposing a replaced renderer keeps the newer one', () => { + const Other = () => null; + const first = canvas.registerWidgetRenderer('test.kpi', Kpi); + const second = canvas.registerWidgetRenderer('test.kpi', Other); + + first.dispose(); + + expect(canvas.getWidgetRenderer('test.kpi')).toBe(Other); + second.dispose(); +}); + +test('active canvas changes fire only when something changed', () => { + const listener = jest.fn(); + const subscription = canvas.onDidChangeActiveCanvas(listener); + const open = { id: 1, title: 'Exec overview', revision: 2 }; + + setActiveCanvas(open); + setActiveCanvas({ ...open }); + setActiveCanvas({ ...open, revision: 3 }); + setActiveCanvas(undefined); + + expect(listener.mock.calls).toEqual([ + [open], + [{ ...open, revision: 3 }], + [undefined], + ]); + expect(canvas.getActiveCanvas()).toBeUndefined(); + subscription.dispose(); +}); diff --git a/superset-frontend/src/core/canvas/index.ts b/superset-frontend/src/core/canvas/index.ts new file mode 100644 index 00000000000..2050c60c969 --- /dev/null +++ b/superset-frontend/src/core/canvas/index.ts @@ -0,0 +1,92 @@ +/** + * 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 `canvas` extension namespace: + * widget renderers by type, and the canvas the user has open. + */ + +import { ComponentType, useSyncExternalStore } from 'react'; +import type { canvas as canvasApi } from '@apache-superset/core'; +import { Disposable } from '../models'; +import { createValueEventEmitter } from '../utils'; + +type WidgetRenderer = ComponentType<canvasApi.CanvasWidgetProps>; + +const renderers = new Map<string, WidgetRenderer>(); +const listeners = new Set<() => void>(); +let version = 0; + +const notify = () => { + version += 1; + listeners.forEach(listener => listener()); +}; + +const registerWidgetRenderer: typeof canvasApi.registerWidgetRenderer = ( + widgetType, + component, +) => { + renderers.set(widgetType, component); + notify(); + return new Disposable(() => { + if (renderers.get(widgetType) === component) { + renderers.delete(widgetType); + notify(); + } + }); +}; + +const getWidgetRenderer: typeof canvasApi.getWidgetRenderer = widgetType => + renderers.get(widgetType); + +/** Re-renders the caller when widget renderers are registered or removed. */ +export const useWidgetRenderers = (): typeof getWidgetRenderer => { + useSyncExternalStore( + listener => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + () => version, + ); + return getWidgetRenderer; +}; + +const activeCanvas = createValueEventEmitter< + canvasApi.ActiveCanvas | undefined +>(undefined); + +/** Called by the canvas page as the user opens, updates or leaves a canvas. */ +export const setActiveCanvas = (value: canvasApi.ActiveCanvas | undefined) => { + const current = activeCanvas.getCurrent(); + if ( + current?.id === value?.id && + current?.title === value?.title && + current?.revision === value?.revision + ) { + return; + } + activeCanvas.fire(value); +}; + +export const canvas: typeof canvasApi = { + registerWidgetRenderer, + getWidgetRenderer, + getActiveCanvas: activeCanvas.getCurrent, + onDidChangeActiveCanvas: activeCanvas.subscribe, +}; diff --git a/superset-frontend/src/core/index.ts b/superset-frontend/src/core/index.ts index ca8440c68e2..698e2f22b18 100644 --- a/superset-frontend/src/core/index.ts +++ b/superset-frontend/src/core/index.ts @@ -27,6 +27,7 @@ export const core: typeof coreType = { }; export * from './authentication'; +export * from './canvas'; export * from './chat'; export * from './commands'; export * from './editors'; diff --git a/superset-frontend/src/extensions/ExtensionsStartup.tsx b/superset-frontend/src/extensions/ExtensionsStartup.tsx index dd639ec1b18..d1f877504a9 100644 --- a/superset-frontend/src/extensions/ExtensionsStartup.tsx +++ b/superset-frontend/src/extensions/ExtensionsStartup.tsx @@ -22,6 +22,7 @@ import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core'; import * as supersetCore from '@apache-superset/core'; import { authentication, + canvas, chat, core, commands, @@ -56,6 +57,7 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({ window.superset = { ...supersetCore, authentication, + canvas, chat, core, commands, diff --git a/superset-frontend/src/extensions/Namespaces.ts b/superset-frontend/src/extensions/Namespaces.ts index 0e7f6a3063b..370791c2d31 100644 --- a/superset-frontend/src/extensions/Namespaces.ts +++ b/superset-frontend/src/extensions/Namespaces.ts @@ -28,6 +28,7 @@ import type { authentication, + canvas, chat, commands, core, @@ -42,6 +43,7 @@ import type { /** The host namespaces exposed to extensions on `window.superset`. */ export interface Namespaces { authentication: typeof authentication; + canvas: typeof canvas; core: typeof core; chat: typeof chat; commands: typeof commands; diff --git a/superset-frontend/src/features/canvas/CanvasGrid.test.tsx b/superset-frontend/src/features/canvas/CanvasGrid.test.tsx new file mode 100644 index 00000000000..a62a46fde77 --- /dev/null +++ b/superset-frontend/src/features/canvas/CanvasGrid.test.tsx @@ -0,0 +1,130 @@ +/** + * 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 { canvas as canvasApi } from '@apache-superset/core'; +import { render, screen } from 'spec/helpers/testing-library'; +import { canvas } from 'src/core'; +import CanvasGrid from './CanvasGrid'; +import { CanvasDefinitionResult } from './types'; + +const result: CanvasDefinitionResult = { + version: 1, + revision: 3, + definition: { + version: 1, + root: { + layout: { columns: 24, gap: 16, rowUnit: 40 }, + children: ['f', 'g', 'lost', 'odd'], + }, + nodes: { + f: { widget: 'w-filter', layout: {} }, + g: { widget: 'w-group', layout: {}, children: ['c1'] }, + c1: { widget: 'w-chart', layout: { colSpan: 6 } }, + lost: { widget: 'w-gone', layout: {} }, + odd: { widget: 'w-odd', layout: {} }, + }, + interactions: { filters: {} }, + }, + filterScopes: { f: ['c1'] }, + placements: { + f: { col: 1, row: 1, colSpan: 24, rowSpan: 1 }, + g: { col: 1, row: 2, colSpan: 24, rowSpan: 4 }, + lost: { col: 1, row: 6, colSpan: 12, rowSpan: 1 }, + odd: { col: 13, row: 6, colSpan: 12, rowSpan: 1 }, + c1: { col: 1, row: 1, colSpan: 6, rowSpan: 1 }, + }, + widgetTypes: { + f: 'test.filter', + g: 'test.group', + c1: 'test.chart', + odd: 'test.odd', + }, + gridColumns: { g: 12 }, +}; + +const Chart = ({ widgetId, filters }: canvasApi.CanvasWidgetProps) => ( + <div>{`chart ${widgetId} filters=${JSON.stringify(filters)}`}</div> +); +const Filter = ({ setFilterValue }: canvasApi.CanvasWidgetProps) => ( + <button type="button" onClick={() => setFilterValue('EMEA')}> + set filter + </button> +); +const Group = ({ renderGrid }: canvasApi.CanvasWidgetProps) => ( + <section aria-label="group">{renderGrid()}</section> +); + +const registrations = [ + canvas.registerWidgetRenderer('test.chart', Chart), + canvas.registerWidgetRenderer('test.filter', Filter), + canvas.registerWidgetRenderer('test.group', Group), +]; +afterAll(() => registrations.forEach(registration => registration.dispose())); + +test('renders widgets through their renderers, nested in grid containers', () => { + render( + <CanvasGrid + canvasId={7} + result={result} + filterValues={{}} + onFilterChange={jest.fn()} + />, + ); + + expect(screen.getByRole('region', { name: 'group' })).toHaveTextContent( + 'chart w-chart filters=[]', + ); +}); + +test('shows placeholders for unavailable widgets and unknown types', () => { + render( + <CanvasGrid + canvasId={7} + result={result} + filterValues={{}} + onFilterChange={jest.fn()} + />, + ); + + expect(screen.getByText('This widget is unavailable')).toBeInTheDocument(); + expect( + screen.getByText('No renderer for widget type "test.odd"'), + ).toBeInTheDocument(); +}); + +test('filter values reach the nodes in the filter scope', () => { + const onFilterChange = jest.fn(); + render( + <CanvasGrid + canvasId={7} + result={result} + filterValues={{ f: 'EMEA' }} + onFilterChange={onFilterChange} + />, + ); + + expect( + screen.getByText( + 'chart w-chart filters=[{"filterNodeId":"f","value":"EMEA"}]', + ), + ).toBeInTheDocument(); + + screen.getByRole('button', { name: 'set filter' }).click(); + + expect(onFilterChange).toHaveBeenCalledWith('f', 'EMEA'); +}); diff --git a/superset-frontend/src/features/canvas/CanvasGrid.tsx b/superset-frontend/src/features/canvas/CanvasGrid.tsx new file mode 100644 index 00000000000..13f6fb3e4ba --- /dev/null +++ b/superset-frontend/src/features/canvas/CanvasGrid.tsx @@ -0,0 +1,199 @@ +/** + * 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 { ReactNode, useMemo } from 'react'; +import type { canvas as canvasApi } from '@apache-superset/core'; +import { t } from '@apache-superset/core/translation'; +import { css, styled } from '@apache-superset/core/theme'; +import { ErrorBoundary } from 'src/components/ErrorBoundary'; +import { useWidgetRenderers } from 'src/core'; +import { CanvasDefinitionResult, GridPlacement } from './types'; + +interface GridMetrics { + columns: number; + gap: number; + rowUnit: number; +} + +const Grid = styled.div<GridMetrics>` + display: grid; + grid-template-columns: repeat(${({ columns }) => columns}, minmax(0, 1fr)); + grid-auto-rows: ${({ rowUnit }) => rowUnit}px; + gap: ${({ gap }) => gap}px; +`; + +const GridItem = styled.div<{ placement?: GridPlacement }>` + min-width: 0; + min-height: 0; + ${({ placement }) => + placement && + css` + grid-column: ${placement.col} / span ${placement.colSpan}; + grid-row: ${placement.row} / span ${placement.rowSpan}; + `} +`; + +const Stack = styled.div` + ${({ theme }) => css` + display: flex; + flex-direction: column; + gap: ${theme.sizeUnit * 2}px; + `} +`; + +const Placeholder = styled.div` + ${({ theme }) => css` + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: ${theme.sizeUnit}px; + padding: ${theme.sizeUnit * 2}px; + border: 1px dashed ${theme.colorBorder}; + border-radius: ${theme.borderRadius}px; + color: ${theme.colorTextSecondary}; + text-align: center; + `} +`; + +export interface CanvasGridProps { + canvasId: number; + result: CanvasDefinitionResult; + /** Current value of each filter node that has one. */ + filterValues: Record<string, unknown>; + onFilterChange: (filterNodeId: string, value: unknown) => void; +} + +/** + * Renders a canvas definition: nodes on the root grid, each through the + * renderer registered for its widget type. Placement comes resolved from the + * server, so nothing here re-implements auto-placement. + */ +export default function CanvasGrid({ + canvasId, + result, + filterValues, + onFilterChange, +}: CanvasGridProps) { + const getRenderer = useWidgetRenderers(); + const { definition, placements, widgetTypes, gridColumns, filterScopes } = + result; + const { gap, rowUnit, columns } = definition.root.layout; + + const filtersByNode = useMemo(() => { + const byNode: Record<string, canvasApi.FilterValue[]> = {}; + Object.entries(filterScopes).forEach(([filterNodeId, targets]) => { + if (filterValues[filterNodeId] === undefined) return; + targets.forEach(nodeId => { + (byNode[nodeId] ??= []).push({ + filterNodeId, + value: filterValues[filterNodeId], + }); + }); + }); + return byNode; + }, [filterScopes, filterValues]); + + const renderGrid = ( + childIds: string[], + gridColumnCount: number, + render: (nodeId: string) => ReactNode, + ) => ( + <Grid columns={gridColumnCount} gap={gap} rowUnit={rowUnit}> + {childIds.map(childId => ( + <GridItem + key={childId} + placement={placements[childId]} + data-test="canvas-node" + data-node-id={childId} + > + {render(childId)} + </GridItem> + ))} + </Grid> + ); + + function renderNode(nodeId: string): ReactNode { + const node = definition.nodes[nodeId]; + const childIds = node.children ?? []; + const widgetType = widgetTypes[nodeId]; + + if (widgetType === undefined) { + return ( + <Placeholder> + <span>{t('This widget is unavailable')}</span> + {childIds.length > 0 && ( + <Stack> + {childIds.map(childId => ( + <div key={childId}>{renderNode(childId)}</div> + ))} + </Stack> + )} + </Placeholder> + ); + } + + const Renderer = getRenderer(widgetType); + if (Renderer === undefined) { + return ( + <Placeholder> + {t('No renderer for widget type "%s"', widgetType)} + </Placeholder> + ); + } + + const childNodes: canvasApi.CanvasChild[] = childIds.map(childId => ({ + nodeId: childId, + widgetType: widgetTypes[childId], + layout: definition.nodes[childId].layout, + element: renderNode(childId), + })); + const childGridColumns = gridColumns[nodeId]; + return ( + <ErrorBoundary> + <Renderer + canvasId={canvasId} + nodeId={nodeId} + widgetId={node.widget} + widgetType={widgetType} + filters={filtersByNode[nodeId] ?? []} + setFilterValue={value => onFilterChange(nodeId, value)} + childNodes={childNodes} + renderGrid={() => + childGridColumns !== undefined ? ( + renderGrid(childIds, childGridColumns, renderNode) + ) : ( + <Stack> + {childNodes.map(child => ( + <div key={child.nodeId}>{child.element}</div> + ))} + </Stack> + ) + } + /> + </ErrorBoundary> + ); + } + + return ( + <div data-test="canvas-grid"> + {renderGrid(definition.root.children, columns, renderNode)} + </div> + ); +} diff --git a/superset-frontend/src/features/canvas/types.ts b/superset-frontend/src/features/canvas/types.ts new file mode 100644 index 00000000000..cb163cf395c --- /dev/null +++ b/superset-frontend/src/features/canvas/types.ts @@ -0,0 +1,68 @@ +/** + * 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. + */ + +export interface GridPlacement { + col: number; + row: number; + colSpan: number; + rowSpan: number; +} + +export interface CanvasNode { + widget: string; + layout: Record<string, unknown>; + children?: string[]; +} + +export interface CanvasDefinition { + version: number; + root: { + layout: { columns: number; gap: number; rowUnit: number }; + children: string[]; + }; + nodes: Record<string, CanvasNode>; + interactions: { filters: Record<string, unknown> }; +} + +/** `GET /api/v1/canvas/<id>/definition` */ +export interface CanvasDefinitionResult { + version: number; + revision: number; + definition: CanvasDefinition; + /** Filter node id -> node ids it drives. */ + filterScopes: Record<string, string[]>; + /** Resolved grid placement of every node on a grid. */ + placements: Record<string, GridPlacement>; + /** Node id -> widget type, for nodes whose widget resolves. */ + widgetTypes: Record<string, string>; + /** Grid container node id -> its column count. */ + gridColumns: Record<string, number>; +} + +/** A row of `GET /api/v1/canvas/`. */ +export interface CanvasObject { + id: number; + uuid: string; + title: string; + description?: string | null; + url: string; + changed_on_delta_humanized?: string; + changed_by?: { id: number; first_name: string; last_name: string } | null; + editors?: { id: number; label: string; type: number }[]; +} diff --git a/superset-frontend/src/features/canvas/useCanvasDefinition.ts b/superset-frontend/src/features/canvas/useCanvasDefinition.ts new file mode 100644 index 00000000000..8f607293b12 --- /dev/null +++ b/superset-frontend/src/features/canvas/useCanvasDefinition.ts @@ -0,0 +1,126 @@ +/** + * 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 { useCallback, useEffect, useRef, useState } from 'react'; +import { getClientErrorObject, SupersetClient } from '@superset-ui/core'; +import type { ClientErrorObject } from '@superset-ui/core'; +import { + subscribeRealtime, + subscribeRealtimeOpen, +} from 'src/middleware/realtime'; +import getBootstrapData from 'src/utils/getBootstrapData'; +import { CanvasDefinitionResult } from './types'; + +export const REFETCH_DEBOUNCE_MS = 300; +export const POLL_INTERVAL_MS = 5000; + +export type CanvasDefinitionState = + | { status: 'loading' } + | { status: 'error'; error: ClientErrorObject } + | { status: 'complete'; result: CanvasDefinitionResult }; + +const definitionEndpoint = (canvasId: number) => + `/api/v1/canvas/${canvasId}/definition`; + +/** + * Loads a canvas definition and keeps it current. + * + * Every write publishes an `entity.changed` nudge carrying only the canvas id; + * on a nudge (or after a websocket reconnect, since nudges are not replayed) + * the definition is fetched again. Without websockets, a cheap + * `/definition/changes?since=<revision>` poll detects new revisions. + */ +export function useCanvasDefinition(canvasId: number) { + const [state, setState] = useState<CanvasDefinitionState>({ + status: 'loading', + }); + const revision = useRef<number>(); + + const load = useCallback(async () => { + try { + const { json } = await SupersetClient.get({ + endpoint: definitionEndpoint(canvasId), + }); + revision.current = json.result.revision; + setState({ status: 'complete', result: json.result }); + } catch (response) { + const error = await getClientErrorObject(response); + // Keep showing the last good definition if a refresh fails. + setState(previous => + previous.status === 'complete' ? previous : { status: 'error', error }, + ); + } + }, [canvasId]); + + useEffect(() => { + revision.current = undefined; + setState({ status: 'loading' }); + load(); + }, [load]); + + useEffect(() => { + let debounce: ReturnType<typeof setTimeout> | undefined; + const scheduleLoad = () => { + if (debounce !== undefined) return; + debounce = setTimeout(() => { + debounce = undefined; + load(); + }, REFETCH_DEBOUNCE_MS); + }; + + const unsubscribe = subscribeRealtime('entity.changed', payload => { + if (!payload || typeof payload !== 'object') return; + const { entity_type: entityType, id } = payload as { + entity_type?: unknown; + id?: unknown; + }; + if (entityType === 'canvas' && String(id) === String(canvasId)) { + scheduleLoad(); + } + }); + const unsubscribeOpen = subscribeRealtimeOpen(reason => { + if (reason === 'reconnect') scheduleLoad(); + }); + + let poll: ReturnType<typeof setInterval> | undefined; + if (!getBootstrapData()?.common?.conf?.WEBSOCKET_ENABLE) { + poll = setInterval(async () => { + if (revision.current === undefined) return; + try { + const { json } = await SupersetClient.get({ + endpoint: `${definitionEndpoint(canvasId)}/changes?since=${revision.current}`, + }); + if (json.result.changes.length > 0) scheduleLoad(); + } catch { + // A stale revision (409) or a transient error: reload the whole + // definition. + scheduleLoad(); + } + }, POLL_INTERVAL_MS); + } + + return () => { + unsubscribe(); + unsubscribeOpen(); + if (debounce !== undefined) clearTimeout(debounce); + if (poll !== undefined) clearInterval(poll); + }; + }, [canvasId, load]); + + return { state, reload: load }; +} diff --git a/superset-frontend/src/pages/Canvas/Canvas.test.tsx b/superset-frontend/src/pages/Canvas/Canvas.test.tsx new file mode 100644 index 00000000000..db142e65ad3 --- /dev/null +++ b/superset-frontend/src/pages/Canvas/Canvas.test.tsx @@ -0,0 +1,114 @@ +/** + * 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 fetchMock from 'fetch-mock'; +import { render, screen, waitFor } from 'spec/helpers/testing-library'; +import { canvas } from 'src/core'; +import { subscribeRealtime } from 'src/middleware/realtime'; +import CanvasPage from '.'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ canvasId: '7' }), +})); +jest.mock('src/middleware/realtime', () => ({ + subscribeRealtime: jest.fn(() => jest.fn()), + subscribeRealtimeOpen: jest.fn(() => jest.fn()), +})); + +const metadataEndpoint = 'glob:*/api/v1/canvas/7'; +const definitionEndpoint = 'glob:*/api/v1/canvas/7/definition'; + +const definition = (children: string[], revision = 1) => ({ + result: { + version: 1, + revision, + definition: { + version: 1, + root: { layout: { columns: 24, gap: 16, rowUnit: 40 }, children }, + nodes: Object.fromEntries( + children.map(nodeId => [nodeId, { widget: `w-${nodeId}`, layout: {} }]), + ), + interactions: { filters: {} }, + }, + filterScopes: {}, + placements: {}, + widgetTypes: {}, + gridColumns: {}, + }, +}); + +beforeEach(() => { + fetchMock.removeRoutes(); + fetchMock.clearHistory(); + fetchMock.get(metadataEndpoint, { + result: { id: 7, title: 'Exec overview', description: 'KPIs and trend' }, + }); +}); + +test('shows the canvas title and an empty state', async () => { + fetchMock.get(definitionEndpoint, definition([])); + + render(<CanvasPage />, { useRedux: true }); + + expect(await screen.findByText('Exec overview')).toBeInTheDocument(); + expect(screen.getByText('KPIs and trend')).toBeInTheDocument(); + expect(screen.getByText('This canvas is empty')).toBeInTheDocument(); + await waitFor(() => + expect(canvas.getActiveCanvas()).toEqual({ + id: 7, + title: 'Exec overview', + revision: 1, + }), + ); +}); + +test('reloads the definition when this canvas changes', async () => { + let notify: (payload: unknown) => void = () => {}; + jest.mocked(subscribeRealtime).mockImplementation((_topic, handler) => { + notify = handler; + return jest.fn(); + }); + fetchMock.get(definitionEndpoint, definition([])); + + render(<CanvasPage />, { useRedux: true }); + expect(await screen.findByText('This canvas is empty')).toBeInTheDocument(); + + fetchMock.removeRoute(definitionEndpoint); + fetchMock.get(definitionEndpoint, definition(['a'], 2)); + // Another canvas changing is ignored; this one reloads. + notify({ entity_type: 'canvas', id: 8 }); + notify({ entity_type: 'canvas', id: 7 }); + + expect( + await screen.findByText('This widget is unavailable'), + ).toBeInTheDocument(); + expect(fetchMock.callHistory.calls(definitionEndpoint)).toHaveLength(2); +}); + +test('explains when the canvas cannot be opened', async () => { + fetchMock.get(definitionEndpoint, 404); + + render(<CanvasPage />, { useRedux: true }); + + expect( + await screen.findByText( + 'This canvas does not exist, or you do not have access to it', + ), + ).toBeInTheDocument(); +}); diff --git a/superset-frontend/src/pages/Canvas/index.tsx b/superset-frontend/src/pages/Canvas/index.tsx new file mode 100644 index 00000000000..e4385d54278 --- /dev/null +++ b/superset-frontend/src/pages/Canvas/index.tsx @@ -0,0 +1,138 @@ +/** + * 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 { useCallback, useEffect, useRef, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { t } from '@apache-superset/core/translation'; +import { css, styled } from '@apache-superset/core/theme'; +import { EmptyState, Loading, Typography } from '@superset-ui/core/components'; +import { chat, setActiveCanvas, useChat } from 'src/core'; +import { useApiV1Resource } from 'src/hooks/apiResources'; +import { ResourceStatus } from 'src/hooks/apiResources/apiResources'; +import CanvasGrid from 'src/features/canvas/CanvasGrid'; +import { useCanvasDefinition } from 'src/features/canvas/useCanvasDefinition'; + +interface CanvasMetadata { + id: number; + title: string; + description?: string | null; +} + +const Page = styled.div` + ${({ theme }) => css` + padding: ${theme.sizeUnit * 4}px; + display: flex; + flex-direction: column; + gap: ${theme.sizeUnit * 4}px; + `} +`; + +const Header = styled.header` + ${({ theme }) => css` + display: flex; + flex-direction: column; + gap: ${theme.sizeUnit}px; + `} +`; + +/** + * Opens the registered chat as a side panel once, when a canvas is shown and a + * chat is available. Canvas is AI-first: changes come from the chat. + */ +function useOpenChatPanel() { + const { chat: registeredChat } = useChat(); + const opened = useRef(false); + useEffect(() => { + if (!registeredChat || opened.current) return; + opened.current = true; + chat.setDisplayMode('panel'); + chat.open(); + }, [registeredChat]); +} + +export default function CanvasPage() { + const { canvasId } = useParams<{ canvasId: string }>(); + const id = Number(canvasId); + const metadata = useApiV1Resource<CanvasMetadata>(`/api/v1/canvas/${id}`); + const { state } = useCanvasDefinition(id); + const [filterValues, setFilterValues] = useState<Record<string, unknown>>({}); + + const onFilterChange = useCallback((filterNodeId: string, value: unknown) => { + setFilterValues(previous => { + const next = { ...previous }; + if (value === undefined) delete next[filterNodeId]; + else next[filterNodeId] = value; + return next; + }); + }, []); + + useOpenChatPanel(); + + const title = metadata.result?.title; + const revision = + state.status === 'complete' ? state.result.revision : undefined; + useEffect(() => { + if (title !== undefined && revision !== undefined) { + setActiveCanvas({ id, title, revision }); + } + }, [id, title, revision]); + useEffect(() => () => setActiveCanvas(undefined), [id]); + + if (metadata.status === ResourceStatus.Error || state.status === 'error') { + return ( + <EmptyState + size="large" + title={t('This canvas does not exist, or you do not have access to it')} + /> + ); + } + if ( + metadata.status === ResourceStatus.Loading || + state.status === 'loading' + ) { + return <Loading />; + } + + const isEmpty = state.result.definition.root.children.length === 0; + return ( + <Page data-test="canvas-page"> + <Header> + <Typography.Title level={3}>{title}</Typography.Title> + {metadata.result?.description && ( + <Typography.Text type="secondary"> + {metadata.result.description} + </Typography.Text> + )} + </Header> + {isEmpty ? ( + <EmptyState + size="medium" + title={t('This canvas is empty')} + description={t('Ask the chat to add widgets to it.')} + /> + ) : ( + <CanvasGrid + canvasId={id} + result={state.result} + filterValues={filterValues} + onFilterChange={onFilterChange} + /> + )} + </Page> + ); +} diff --git a/superset-frontend/src/pages/CanvasList/CanvasList.test.tsx b/superset-frontend/src/pages/CanvasList/CanvasList.test.tsx new file mode 100644 index 00000000000..8a7468f54bb --- /dev/null +++ b/superset-frontend/src/pages/CanvasList/CanvasList.test.tsx @@ -0,0 +1,115 @@ +/** + * 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 fetchMock from 'fetch-mock'; +import { + fireEvent, + render, + screen, + waitFor, +} from 'spec/helpers/testing-library'; +import { MemoryRouter } from 'react-router-dom'; +import { QueryParamProvider } from 'use-query-params'; +import { ReactRouter5Adapter } from 'use-query-params/adapters/react-router-5'; +import CanvasList from '.'; + +const mockPush = jest.fn(); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useHistory: () => ({ push: mockPush }), +})); +jest.mock('src/middleware/realtime', () => ({ + subscribeRealtime: jest.fn(() => jest.fn()), + subscribeRealtimeOpen: jest.fn(() => jest.fn()), +})); + +const canvases = [0, 1].map(id => ({ + id, + title: `canvas ${id}`, + url: `/canvas/${id}/`, + editors: [], + changed_on_delta_humanized: '1 day ago', + changed_by: { first_name: 'user', last_name: `${id}` }, +})); + +beforeEach(() => { + fetchMock.removeRoutes(); + fetchMock.clearHistory(); + mockPush.mockReset(); + fetchMock.get('glob:*/api/v1/canvas/_info*', { + permissions: ['can_write'], + }); + fetchMock.get('glob:*/api/v1/canvas/related/*', { count: 0, result: [] }); + fetchMock.get('glob:*/api/v1/canvas/?*', { result: canvases, count: 2 }); +}); + +const renderList = () => + render( + <MemoryRouter> + <QueryParamProvider adapter={ReactRouter5Adapter}> + <CanvasList user={{ userId: 1, firstName: 'a', lastName: 'b' }} /> + </QueryParamProvider> + </MemoryRouter>, + { useRedux: true }, + ); + +test('lists canvases linking to their pages', async () => { + renderList(); + + const link = await screen.findByRole('link', { name: 'canvas 1' }); + expect(link).toHaveAttribute('href', '/canvas/1/'); + expect(screen.getByText('canvas 0')).toBeInTheDocument(); +}); + +test('creates a canvas and opens it', async () => { + fetchMock.post('glob:*/api/v1/canvas/', { id: 42, result: {} }); + renderList(); + + fireEvent.click(await screen.findByText('Canvas')); + + await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/canvas/42/')); + const [call] = fetchMock.callHistory.calls('glob:*/api/v1/canvas/', { + method: 'POST', + }); + expect(JSON.parse(call.options.body as string)).toEqual({ + title: 'Untitled canvas', + }); +}); + +test('deletes a canvas after confirmation', async () => { + fetchMock.delete('glob:*/api/v1/canvas/0', { message: 'OK' }); + renderList(); + + const [deleteButton] = await screen.findAllByTestId('delete-action'); + fireEvent.click(deleteButton); + expect(await screen.findByRole('dialog')).toHaveTextContent( + 'This deletes the canvas for everyone', + ); + fireEvent.change(await screen.findByTestId('delete-modal-input'), { + target: { value: 'DELETE' }, + }); + fireEvent.click(await screen.findByTestId('modal-confirm-button')); + + await waitFor(() => + expect( + fetchMock.callHistory.calls('glob:*/api/v1/canvas/0', { + method: 'DELETE', + }), + ).toHaveLength(1), + ); +}); diff --git a/superset-frontend/src/pages/CanvasList/index.tsx b/superset-frontend/src/pages/CanvasList/index.tsx new file mode 100644 index 00000000000..acef0cb8d44 --- /dev/null +++ b/superset-frontend/src/pages/CanvasList/index.tsx @@ -0,0 +1,303 @@ +/** + * 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 { useMemo, useState } from 'react'; +import { Link, useHistory } from 'react-router-dom'; +import { t } from '@apache-superset/core/translation'; +import { SupersetClient } from '@superset-ui/core'; +import rison from 'rison'; +import { useListViewResource } from 'src/views/CRUD/hooks'; +import { createErrorHandler, createFetchRelated } from 'src/views/CRUD/utils'; +import withToasts from 'src/components/MessageToasts/withToasts'; +import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu'; +import { SubjectPile } from 'src/features/subjects/SubjectPile'; +import { ConfirmStatusChange, DeleteModal } from '@superset-ui/core/components'; +import { Icons } from '@superset-ui/core/components/Icons'; +import { + ListView, + ListViewActionsBar, + ListViewFilterOperator as FilterOperator, + ModifiedInfo, + type ListViewActionProps, + type ListViewFilters, + type ListViewProps, +} from 'src/components'; +import { QueryObjectColumns } from 'src/views/CRUD/types'; +import { CanvasObject } from 'src/features/canvas/types'; + +const PAGE_SIZE = 25; + +interface CanvasListProps { + addDangerToast: (msg: string) => void; + addSuccessToast: (msg: string) => void; + user: { + userId: string | number; + firstName: string; + lastName: string; + }; +} + +function CanvasList({ + addDangerToast, + addSuccessToast, + user, +}: CanvasListProps) { + const history = useHistory(); + const { + state: { + loading, + resourceCount: canvasCount, + resourceCollection: canvases, + bulkSelectEnabled, + }, + hasPerm, + fetchData, + refreshData, + toggleBulkSelect, + } = useListViewResource<CanvasObject>( + 'canvas', + t('canvases'), + addDangerToast, + true, + [], + undefined, + true, + undefined, + true, + ); + const [canvasDeleting, setCanvasDeleting] = useState<CanvasObject | null>( + null, + ); + const canWrite = hasPerm('can_write'); + + const createCanvas = () => { + SupersetClient.post({ + endpoint: '/api/v1/canvas/', + jsonPayload: { title: t('Untitled canvas') }, + }).then( + ({ json }) => history.push(`/canvas/${json.id}/`), + createErrorHandler(errMsg => + addDangerToast(t('There was an issue creating the canvas: %s', errMsg)), + ), + ); + }; + + const deleteCanvas = ({ id, title }: CanvasObject) => { + SupersetClient.delete({ endpoint: `/api/v1/canvas/${id}` }).then( + () => { + refreshData(); + setCanvasDeleting(null); + addSuccessToast(t('Deleted: %s', title)); + }, + createErrorHandler(errMsg => + addDangerToast(t('There was an issue deleting %s: %s', title, errMsg)), + ), + ); + }; + + const bulkDeleteCanvases = (toDelete: CanvasObject[]) => { + SupersetClient.delete({ + endpoint: `/api/v1/canvas/?q=${rison.encode(toDelete.map(({ id }) => id))}`, + }).then( + ({ json = {} }) => { + refreshData(); + addSuccessToast(json.message); + }, + createErrorHandler(errMsg => + addDangerToast( + t('There was an issue deleting the selected canvases: %s', errMsg), + ), + ), + ); + }; + + const initialSort = [{ id: 'changed_on', desc: true }]; + const columns = useMemo( + () => [ + { + Cell: ({ + row: { + original: { url, title }, + }, + }: any) => <Link to={url}>{title}</Link>, + Header: t('Title'), + accessor: 'title', + size: 'xxl', + id: 'title', + }, + { + Cell: ({ + row: { + original: { editors = [] }, + }, + }: any) => <SubjectPile subjects={editors} />, + Header: t('Editors'), + accessor: 'editors', + disableSortBy: true, + size: 'lg', + id: 'editors', + }, + { + Cell: ({ + row: { + original: { + changed_on_delta_humanized: changedOn, + changed_by: changedBy, + }, + }, + }: any) => <ModifiedInfo date={changedOn} user={changedBy} />, + Header: t('Last modified'), + accessor: 'changed_on', + size: 'xl', + id: 'changed_on', + }, + { + Cell: ({ row: { original } }: any) => { + const actions: ListViewActionProps[] = [ + { + label: 'delete-action', + tooltip: t('Delete canvas'), + placement: 'bottom', + icon: 'DeleteOutlined', + onClick: () => setCanvasDeleting(original), + }, + ]; + return <ListViewActionsBar actions={actions} />; + }, + Header: t('Actions'), + id: 'actions', + disableSortBy: true, + hidden: !canWrite, + size: 'xl', + }, + { + accessor: QueryObjectColumns.ChangedBy, + hidden: true, + id: QueryObjectColumns.ChangedBy, + }, + ], + [canWrite], + ); + + const menuData: SubMenuProps = { name: t('Canvases') }; + const subMenuButtons: SubMenuProps['buttons'] = []; + if (canWrite) { + subMenuButtons.push( + { + name: t('Bulk select'), + onClick: toggleBulkSelect, + buttonStyle: 'secondary', + }, + { + name: t('Canvas'), + buttonStyle: 'primary', + icon: <Icons.PlusOutlined iconSize="m" />, + onClick: createCanvas, + }, + ); + } + menuData.buttons = subMenuButtons; + + const filters: ListViewFilters = useMemo( + () => [ + { + Header: t('Search'), + key: 'search', + id: 'title', + input: 'search', + operator: FilterOperator.CanvasAllText, + }, + { + Header: t('Modified by'), + key: 'changed_by', + id: 'changed_by', + input: 'select', + operator: FilterOperator.RelationOneMany, + unfilteredLabel: t('All'), + fetchSelects: createFetchRelated( + 'canvas', + 'changed_by', + createErrorHandler(errMsg => + t('An error occurred while fetching modifiers: %s', errMsg), + ), + user, + ), + paginate: true, + }, + ], + [], + ); + + return ( + <> + <SubMenu {...menuData} /> + {canvasDeleting && ( + <DeleteModal + description={t( + 'This deletes the canvas for everyone. The widgets on it are not deleted.', + )} + onConfirm={() => deleteCanvas(canvasDeleting)} + onHide={() => setCanvasDeleting(null)} + open + title={t('Delete canvas?')} + /> + )} + <ConfirmStatusChange + title={t('Please confirm')} + description={t( + 'Are you sure you want to delete the selected canvases?', + )} + onConfirm={bulkDeleteCanvases} + > + {confirmDelete => { + const bulkActions: ListViewProps['bulkActions'] = canWrite + ? [ + { + key: 'delete', + name: t('Delete'), + onSelect: confirmDelete, + type: 'danger', + }, + ] + : []; + return ( + <ListView<CanvasObject> + className="canvas-list-view" + columns={columns} + count={canvasCount} + data={canvases} + fetchData={fetchData} + filters={filters} + initialSort={initialSort} + loading={loading} + pageSize={PAGE_SIZE} + bulkActions={bulkActions} + bulkSelectEnabled={bulkSelectEnabled} + disableBulkSelect={toggleBulkSelect} + addDangerToast={addDangerToast} + addSuccessToast={addSuccessToast} + refreshData={refreshData} + /> + ); + }} + </ConfirmStatusChange> + </> + ); +} + +export default withToasts(CanvasList); diff --git a/superset-frontend/src/views/routePaths.ts b/superset-frontend/src/views/routePaths.ts index 0295e0897e4..915d0989883 100644 --- a/superset-frontend/src/views/routePaths.ts +++ b/superset-frontend/src/views/routePaths.ts @@ -34,6 +34,8 @@ export const RoutePaths = { SAVED_QUERIES: '/savedqueryview/list/', CSS_TEMPLATES: '/csstemplatemodelview/list/', THEMES: '/theme/list/', + CANVAS_LIST: '/canvas/list/', + CANVAS: '/canvas/:canvasId/', ANNOTATION_LAYERS: '/annotationlayer/list/', ANNOTATION_LIST: '/annotationlayer/:annotationLayerId/annotation/', QUERY_HISTORY: '/sqllab/history/', diff --git a/superset-frontend/src/views/routes.tsx b/superset-frontend/src/views/routes.tsx index 94e99e197d9..83fb5fd9e29 100644 --- a/superset-frontend/src/views/routes.tsx +++ b/superset-frontend/src/views/routes.tsx @@ -149,6 +149,14 @@ const TaskList = lazy( () => import(/* webpackChunkName: "TaskList" */ 'src/pages/TaskList'), ); +const CanvasList = lazy( + () => import(/* webpackChunkName: "CanvasList" */ 'src/pages/CanvasList'), +); + +const CanvasPage = lazy( + () => import(/* webpackChunkName: "Canvas" */ 'src/pages/Canvas'), +); + const RolesList = lazy( () => import(/* webpackChunkName: "RolesList" */ 'src/pages/RolesList'), ); @@ -266,6 +274,13 @@ if (isFeatureEnabled(FeatureFlag.TaggingSystem)) { routes.push({ path: RoutePaths.TAGS, Component: Tags }); } +if (isFeatureEnabled(FeatureFlag.Canvas)) { + routes.push( + { path: RoutePaths.CANVAS_LIST, Component: CanvasList }, + { path: RoutePaths.CANVAS, Component: CanvasPage }, + ); +} + // Recently-Archived view — gated by the soft-delete feature (T007). if (isFeatureEnabled(FeatureFlag.SoftDelete)) { routes.push({ diff --git a/superset/canvas/api.py b/superset/canvas/api.py index 3f16b35b0e6..2c73de6e1fd 100644 --- a/superset/canvas/api.py +++ b/superset/canvas/api.py @@ -452,6 +452,9 @@ class CanvasRestApi(BaseSupersetModelRestApi): widgetTypes: type: object description: The widget type of each resolvable node + gridColumns: + type: object + description: The column count of each grid container 401: $ref: '#/components/responses/401' 404: diff --git a/superset/canvas/definition/render.py b/superset/canvas/definition/render.py index 7866b1484d1..49e9497df38 100644 --- a/superset/canvas/definition/render.py +++ b/superset/canvas/definition/render.py @@ -21,7 +21,9 @@ Placement rules live on the server only: ``placements`` gives every node on a grid its resolved position, auto-placed nodes included, so clients render without re-implementing auto-placement and collision push-down. ``widget_types`` maps nodes to their widget's type so clients pick a renderer; -nodes whose widget no longer resolves are left out. +nodes whose widget no longer resolves are left out. ``gridColumns`` gives the +column count of each grid container, whose children's placements are in its +own grid units. """ from __future__ import annotations @@ -56,6 +58,7 @@ def render_context( grids: list[tuple[list[str], int]] = [ (definition["root"]["children"], definition["root"]["layout"]["columns"]) ] + grid_columns: dict[str, int] = {} for node_id, node in nodes.items(): widget_type = widget_types.get(node_id) if widget_type is None: @@ -63,6 +66,7 @@ def render_context( container = rules.get(widget_type) if container.is_container and container.grid_columns is not None: grids.append((node.get("children") or [], container.grid_columns)) + grid_columns[node_id] = container.grid_columns placements: dict[str, dict[str, int]] = {} for children, columns in grids: @@ -74,4 +78,8 @@ def render_context( "colSpan": rect.col_span, "rowSpan": rect.row_span, } - return {"widgetTypes": widget_types, "placements": placements} + return { + "widgetTypes": widget_types, + "placements": placements, + "gridColumns": grid_columns, + } diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py index 1895fa19b2c..706e8ce0ed3 100644 --- a/superset/initialization/__init__.py +++ b/superset/initialization/__init__.py @@ -220,6 +220,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods from superset.views.annotations import AnnotationLayerView from superset.views.api import Api from superset.views.archived_assets import ArchivedAssetsView + from superset.views.canvas import CanvasView from superset.views.chart.views import SliceModelView from superset.views.core import Superset from superset.views.css_templates import CssTemplateModelView @@ -361,6 +362,15 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods category="", category_icon="", ) + appbuilder.add_view( + CanvasView, + "Canvases", + label=_("Canvases"), + icon="fa-object-group", + category="", + category_icon="", + menu_cond=lambda: feature_flag_manager.is_feature_enabled("CANVAS"), + ) appbuilder.add_view( SliceModelView, "Charts", diff --git a/superset/views/canvas.py b/superset/views/canvas.py new file mode 100644 index 00000000000..980f4f7fcd3 --- /dev/null +++ b/superset/views/canvas.py @@ -0,0 +1,47 @@ +# 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. +from flask import abort +from flask_appbuilder import expose, has_access +from flask_appbuilder.security.decorators import permission_name + +from superset.extensions import feature_flag_manager +from superset.superset_typing import FlaskResponse +from superset.views.base import BaseSupersetView + + +class CanvasView(BaseSupersetView): + """Serves the SPA for the canvas list and canvas pages.""" + + route_base = "/canvas" + class_permission_name = "Canvas" + default_view = "list" + + @expose("/list/") + @has_access + @permission_name("read") + def list(self) -> FlaskResponse: + if not feature_flag_manager.is_feature_enabled("CANVAS"): + return abort(404) + return super().render_app_template() + + @expose("/<int:pk>/") + @has_access + @permission_name("read") + def show(self, pk: int) -> FlaskResponse: # pylint: disable=unused-argument + if not feature_flag_manager.is_feature_enabled("CANVAS"): + return abort(404) + return super().render_app_template() diff --git a/tests/unit_tests/canvas/api_test.py b/tests/unit_tests/canvas/api_test.py index bb95e94f17f..0445f8b875f 100644 --- a/tests/unit_tests/canvas/api_test.py +++ b/tests/unit_tests/canvas/api_test.py @@ -116,6 +116,7 @@ def test_get_definition(client: Any, full_api_access: None, canvas: Any) -> None assert response.json["result"]["filterScopes"] == {} assert response.json["result"]["placements"] == {} assert response.json["result"]["widgetTypes"] == {} + assert response.json["result"]["gridColumns"] == {} def test_apply_operations_bumps_revision_logs_and_publishes( diff --git a/tests/unit_tests/canvas/render_test.py b/tests/unit_tests/canvas/render_test.py index 0902694997a..791eaf6de0a 100644 --- a/tests/unit_tests/canvas/render_test.py +++ b/tests/unit_tests/canvas/render_test.py @@ -53,3 +53,4 @@ def test_placements_include_auto_placed_nodes_and_grid_containers() -> None: "g": "group", "c": "chart", } + assert context["gridColumns"] == {"g": 12}
