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 9779b070e03615ec9b23c88d9b6a47c3170b850d Author: Mehmet Salih Yavuz <[email protected]> AuthorDate: Thu Oct 1 12:55:27 2026 +0300 feat(canvas): pass settings and every scope kind to widget renderers Widget renderers receive cross-filter and customization values for the nodes whose scope includes them, setters for each kind (cross-filters are ignored while turned off), the canvas colors, whether to show data timestamps, and a refresh key the page bumps on every automatic refresh, spread over the configured stagger and skipping exempt nodes. The canvas page resolves /canvas/<slug>/ as well as ids, applies the canvas theme and custom CSS, and shows certification on the page and in the list. --- .../packages/superset-core/src/canvas/index.ts | 42 ++++++- .../src/features/canvas/CanvasGrid.test.tsx | 119 +++++++++++++----- .../src/features/canvas/CanvasGrid.tsx | 104 +++++++++++---- superset-frontend/src/features/canvas/types.ts | 33 ++++- .../src/features/canvas/useCanvasId.test.ts | 56 +++++++++ .../src/features/canvas/useCanvasId.ts | 63 ++++++++++ .../src/features/canvas/useCanvasRefresh.test.ts | 77 ++++++++++++ .../src/features/canvas/useCanvasRefresh.ts | 72 +++++++++++ superset-frontend/src/pages/Canvas/Canvas.test.tsx | 12 +- superset-frontend/src/pages/Canvas/index.tsx | 140 +++++++++++++-------- superset-frontend/src/pages/CanvasList/index.tsx | 27 +++- superset-frontend/src/views/routePaths.ts | 2 +- 12 files changed, 635 insertions(+), 112 deletions(-) diff --git a/superset-frontend/packages/superset-core/src/canvas/index.ts b/superset-frontend/packages/superset-core/src/canvas/index.ts index 77d75fb963f..dc895e06b20 100644 --- a/superset-frontend/packages/superset-core/src/canvas/index.ts +++ b/superset-frontend/packages/superset-core/src/canvas/index.ts @@ -36,14 +36,28 @@ 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. */ +/** + * What can drive other widgets: filters, cross-filter sources (charts that + * filter others when clicked) and customizations (e.g. dynamic group-by). + */ +export type ScopeKind = 'filter' | 'crossFilter' | 'customization'; + +/** A value set by a node, as it reaches the widgets its scope includes. */ export interface FilterValue { - /** The node id of the filter that set the value. */ + /** The node id of the filter, cross-filter source or customization. */ filterNodeId: string; - /** The value, in the filter widget's own format. */ + /** The value, in the setting widget's own format. */ value: unknown; } +/** Colors shared by every widget on a canvas. */ +export interface CanvasColors { + /** Categorical color scheme; the default scheme when unset. */ + scheme?: string; + /** Fixed colors for series labels, e.g. `{ France: '#1f77b4' }`. */ + labelColors: Record<string, string>; +} + /** A child node of a container, for containers that arrange children. */ export interface CanvasChild { nodeId: string; @@ -63,11 +77,33 @@ export interface CanvasWidgetProps { widgetType: string; /** Values of the filters whose scope includes this node. */ filters: FilterValue[]; + /** Values of the cross-filter sources whose scope includes this node. */ + crossFilters: FilterValue[]; + /** Values of the customizations whose scope includes this node. */ + customizations: FilterValue[]; /** * For filter widgets: publish the filter's value to the widgets it drives. * Pass `undefined` to clear it. */ setFilterValue: (value: unknown) => void; + /** + * For cross-filter sources: publish a cross-filter, or clear it with + * `undefined`. Ignored while cross-filters are turned off. + */ + setCrossFilter: (value: unknown) => void; + /** Whether cross-filters are turned on for this canvas. */ + crossFiltersEnabled: boolean; + /** For customization widgets: publish the value, or clear it. */ + setCustomizationValue: (value: unknown) => void; + /** Colors shared across the canvas. */ + colors: CanvasColors; + /** Whether to show when the widget's data was last refreshed. */ + showTimestamp: boolean; + /** + * Increments on every automatic refresh that reaches this node; refetch + * data when it changes. Stays 0 for nodes exempt from refresh. + */ + refreshKey: number; /** For containers: the child nodes, already rendered. */ childNodes: CanvasChild[]; /** diff --git a/superset-frontend/src/features/canvas/CanvasGrid.test.tsx b/superset-frontend/src/features/canvas/CanvasGrid.test.tsx index a62a46fde77..eda68f9b056 100644 --- a/superset-frontend/src/features/canvas/CanvasGrid.test.tsx +++ b/superset-frontend/src/features/canvas/CanvasGrid.test.tsx @@ -19,8 +19,15 @@ 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'; +import CanvasGrid, { emptyScopeValues, ScopeValues } from './CanvasGrid'; +import { CanvasDefinitionResult, CanvasSettings } from './types'; + +const settings: CanvasSettings = { + refresh: { interval: 0, stagger: 0, exempt: [] }, + colors: { scheme: 'supersetColors', labelColors: { EMEA: '#123456' } }, + display: { showTimestamps: true }, + crossFilters: { enabled: true }, +}; const result: CanvasDefinitionResult = { version: 1, @@ -29,7 +36,7 @@ const result: CanvasDefinitionResult = { version: 1, root: { layout: { columns: 24, gap: 16, rowUnit: 40 }, - children: ['f', 'g', 'lost', 'odd'], + children: ['f', 'g', 'lost', 'odd', 'unknown'], }, nodes: { f: { widget: 'w-filter', layout: {} }, @@ -37,15 +44,20 @@ const result: CanvasDefinitionResult = { c1: { widget: 'w-chart', layout: { colSpan: 6 } }, lost: { widget: 'w-gone', layout: {} }, odd: { widget: 'w-odd', layout: {} }, + unknown: { widget: 'w-unknown', layout: {} }, }, - interactions: { filters: {} }, + interactions: { filters: {}, crossFilters: {}, customizations: {} }, + settings, }, filterScopes: { f: ['c1'] }, + crossFilterScopes: { odd: ['c1'] }, + customizationScopes: {}, 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 }, + unknown: { col: 1, row: 7, colSpan: 24, rowSpan: 1 }, c1: { col: 1, row: 1, colSpan: 6, rowSpan: 1 }, }, widgetTypes: { @@ -53,12 +65,29 @@ const result: CanvasDefinitionResult = { g: 'test.group', c1: 'test.chart', odd: 'test.odd', + unknown: 'test.unknown', }, gridColumns: { g: 12 }, }; -const Chart = ({ widgetId, filters }: canvasApi.CanvasWidgetProps) => ( - <div>{`chart ${widgetId} filters=${JSON.stringify(filters)}`}</div> +const Chart = ({ + widgetId, + filters, + crossFilters, + colors, + showTimestamp, + refreshKey, +}: canvasApi.CanvasWidgetProps) => ( + <div> + <span>{`chart ${widgetId} filters=${JSON.stringify(filters)}`}</span> + <span>{`cross=${JSON.stringify(crossFilters)}`}</span> + <span>{`colors=${colors.scheme} timestamp=${showTimestamp} refresh=${refreshKey}`}</span> + </div> +); +const CrossSource = ({ setCrossFilter }: canvasApi.CanvasWidgetProps) => ( + <button type="button" onClick={() => setCrossFilter('France')}> + cross filter + </button> ); const Filter = ({ setFilterValue }: canvasApi.CanvasWidgetProps) => ( <button type="button" onClick={() => setFilterValue('EMEA')}> @@ -73,50 +102,48 @@ const registrations = [ canvas.registerWidgetRenderer('test.chart', Chart), canvas.registerWidgetRenderer('test.filter', Filter), canvas.registerWidgetRenderer('test.group', Group), + canvas.registerWidgetRenderer('test.odd', CrossSource), ]; -afterAll(() => registrations.forEach(registration => registration.dispose())); -test('renders widgets through their renderers, nested in grid containers', () => { +const renderGrid = ( + values: ScopeValues = emptyScopeValues(), + onValueChange = jest.fn(), + gridResult: CanvasDefinitionResult = result, +) => render( <CanvasGrid canvasId={7} - result={result} - filterValues={{}} - onFilterChange={jest.fn()} + result={gridResult} + values={values} + onValueChange={onValueChange} + refreshKeys={{ c1: 2 }} />, ); +afterAll(() => registrations.forEach(registration => registration.dispose())); + +test('renders widgets through their renderers, nested in grid containers', () => { + renderGrid(); expect(screen.getByRole('region', { name: 'group' })).toHaveTextContent( 'chart w-chart filters=[]', ); + expect( + screen.getByText('colors=supersetColors timestamp=true refresh=2'), + ).toBeInTheDocument(); }); -test('shows placeholders for unavailable widgets and unknown types', () => { - render( - <CanvasGrid - canvasId={7} - result={result} - filterValues={{}} - onFilterChange={jest.fn()} - />, - ); +test('shows placeholders for unavailable widgets and unregistered types', () => { + renderGrid(); expect(screen.getByText('This widget is unavailable')).toBeInTheDocument(); expect( - screen.getByText('No renderer for widget type "test.odd"'), + screen.getByText('No renderer for widget type "test.unknown"'), ).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} - />, - ); + const onValueChange = jest.fn(); + renderGrid({ ...emptyScopeValues(), filter: { f: 'EMEA' } }, onValueChange); expect( screen.getByText( @@ -126,5 +153,35 @@ test('filter values reach the nodes in the filter scope', () => { screen.getByRole('button', { name: 'set filter' }).click(); - expect(onFilterChange).toHaveBeenCalledWith('f', 'EMEA'); + expect(onValueChange).toHaveBeenCalledWith('filter', 'f', 'EMEA'); +}); + +test('cross-filter values reach the nodes in the source scope', () => { + const onValueChange = jest.fn(); + renderGrid( + { ...emptyScopeValues(), crossFilter: { odd: 'France' } }, + onValueChange, + ); + + expect( + screen.getByText('cross=[{"filterNodeId":"odd","value":"France"}]'), + ).toBeInTheDocument(); + screen.getByRole('button', { name: 'cross filter' }).click(); + expect(onValueChange).toHaveBeenCalledWith('crossFilter', 'odd', 'France'); +}); + +test('setting a cross-filter does nothing while cross-filters are off', () => { + const onValueChange = jest.fn(); + const off: CanvasDefinitionResult = { + ...result, + definition: { + ...result.definition, + settings: { ...settings, crossFilters: { enabled: false } }, + }, + }; + renderGrid(emptyScopeValues(), onValueChange, off); + + screen.getByRole('button', { name: 'cross filter' }).click(); + + expect(onValueChange).not.toHaveBeenCalled(); }); diff --git a/superset-frontend/src/features/canvas/CanvasGrid.tsx b/superset-frontend/src/features/canvas/CanvasGrid.tsx index 13f6fb3e4ba..a4a977bd561 100644 --- a/superset-frontend/src/features/canvas/CanvasGrid.tsx +++ b/superset-frontend/src/features/canvas/CanvasGrid.tsx @@ -24,6 +24,35 @@ import { ErrorBoundary } from 'src/components/ErrorBoundary'; import { useWidgetRenderers } from 'src/core'; import { CanvasDefinitionResult, GridPlacement } from './types'; +/** Current values set by nodes, per scope kind, by node id. */ +export type ScopeValues = Record<canvasApi.ScopeKind, Record<string, unknown>>; + +export const emptyScopeValues = (): ScopeValues => ({ + filter: {}, + crossFilter: {}, + customization: {}, +}); + +const NO_VALUES: canvasApi.FilterValue[] = []; + +/** For each node, the values that reach it from the nodes whose scope includes it. */ +function valuesByNode( + scopes: Record<string, string[]>, + values: Record<string, unknown>, +): Record<string, canvasApi.FilterValue[]> { + const byNode: Record<string, canvasApi.FilterValue[]> = {}; + Object.entries(scopes).forEach(([filterNodeId, targets]) => { + if (values[filterNodeId] === undefined) return; + targets.forEach(nodeId => { + (byNode[nodeId] ??= []).push({ + filterNodeId, + value: values[filterNodeId], + }); + }); + }); + return byNode; +} + interface GridMetrics { columns: number; gap: number; @@ -75,9 +104,14 @@ const Placeholder = styled.div` 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; + values: ScopeValues; + onValueChange: ( + kind: canvasApi.ScopeKind, + nodeId: string, + value: unknown, + ) => void; + /** Per node, how many automatic refreshes have reached it. */ + refreshKeys?: Record<string, number>; } /** @@ -88,27 +122,39 @@ export interface CanvasGridProps { export default function CanvasGrid({ canvasId, result, - filterValues, - onFilterChange, + values, + onValueChange, + refreshKeys = {}, }: CanvasGridProps) { const getRenderer = useWidgetRenderers(); - const { definition, placements, widgetTypes, gridColumns, filterScopes } = - result; + const { + definition, + placements, + widgetTypes, + gridColumns, + filterScopes, + crossFilterScopes, + customizationScopes, + } = result; const { gap, rowUnit, columns } = definition.root.layout; + const { colors, display, crossFilters } = definition.settings; + const sharedColors = useMemo<canvasApi.CanvasColors>( + () => ({ scheme: colors.scheme, labelColors: colors.labelColors }), + [colors.scheme, colors.labelColors], + ); - 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 filtersByNode = useMemo( + () => valuesByNode(filterScopes, values.filter), + [filterScopes, values.filter], + ); + const crossFiltersByNode = useMemo( + () => valuesByNode(crossFilterScopes, values.crossFilter), + [crossFilterScopes, values.crossFilter], + ); + const customizationsByNode = useMemo( + () => valuesByNode(customizationScopes, values.customization), + [customizationScopes, values.customization], + ); const renderGrid = ( childIds: string[], @@ -172,8 +218,22 @@ export default function CanvasGrid({ nodeId={nodeId} widgetId={node.widget} widgetType={widgetType} - filters={filtersByNode[nodeId] ?? []} - setFilterValue={value => onFilterChange(nodeId, value)} + filters={filtersByNode[nodeId] ?? NO_VALUES} + crossFilters={crossFiltersByNode[nodeId] ?? NO_VALUES} + customizations={customizationsByNode[nodeId] ?? NO_VALUES} + setFilterValue={value => onValueChange('filter', nodeId, value)} + setCrossFilter={value => { + if (crossFilters.enabled) { + onValueChange('crossFilter', nodeId, value); + } + }} + crossFiltersEnabled={crossFilters.enabled} + setCustomizationValue={value => + onValueChange('customization', nodeId, value) + } + colors={sharedColors} + showTimestamp={display.showTimestamps} + refreshKey={refreshKeys[nodeId] ?? 0} childNodes={childNodes} renderGrid={() => childGridColumns !== undefined ? ( diff --git a/superset-frontend/src/features/canvas/types.ts b/superset-frontend/src/features/canvas/types.ts index cb163cf395c..6bea3b65310 100644 --- a/superset-frontend/src/features/canvas/types.ts +++ b/superset-frontend/src/features/canvas/types.ts @@ -37,7 +37,19 @@ export interface CanvasDefinition { children: string[]; }; nodes: Record<string, CanvasNode>; - interactions: { filters: Record<string, unknown> }; + interactions: { + filters: Record<string, unknown>; + crossFilters: Record<string, unknown>; + customizations: Record<string, unknown>; + }; + settings: CanvasSettings; +} + +export interface CanvasSettings { + refresh: { interval: number; stagger: number; exempt: string[] }; + colors: { scheme?: string; labelColors: Record<string, string> }; + display: { showTimestamps: boolean }; + crossFilters: { enabled: boolean }; } /** `GET /api/v1/canvas/<id>/definition` */ @@ -47,6 +59,10 @@ export interface CanvasDefinitionResult { definition: CanvasDefinition; /** Filter node id -> node ids it drives. */ filterScopes: Record<string, string[]>; + /** Cross-filter source node id -> node ids it drives; empty when off. */ + crossFilterScopes: Record<string, string[]>; + /** Customization node id -> node ids it drives. */ + customizationScopes: 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. */ @@ -62,7 +78,22 @@ export interface CanvasObject { title: string; description?: string | null; url: string; + slug?: string | null; + certified_by?: string | null; + certification_details?: string | null; changed_on_delta_humanized?: string; changed_by?: { id: number; first_name: string; last_name: string } | null; editors?: { id: number; label: string; type: number }[]; } + +/** `GET /api/v1/canvas/<id>`. */ +export interface CanvasMetadata { + id: number; + title: string; + description?: string | null; + slug?: string | null; + css?: string | null; + certified_by?: string | null; + certification_details?: string | null; + theme?: { id: number; theme_name: string; json_data: string } | null; +} diff --git a/superset-frontend/src/features/canvas/useCanvasId.test.ts b/superset-frontend/src/features/canvas/useCanvasId.test.ts new file mode 100644 index 00000000000..5f1f689127a --- /dev/null +++ b/superset-frontend/src/features/canvas/useCanvasId.test.ts @@ -0,0 +1,56 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { renderHook, waitFor } from '@testing-library/react'; +import fetchMock from 'fetch-mock'; +import { useCanvasId } from './useCanvasId'; + +const listEndpoint = 'glob:*/api/v1/canvas/?q=*'; + +beforeEach(() => { + fetchMock.removeRoutes(); + fetchMock.clearHistory(); +}); + +test('a numeric parameter is the id, with no request', () => { + const { result } = renderHook(() => useCanvasId('42')); + + expect(result.current).toEqual({ status: 'complete', id: 42 }); + expect(fetchMock.callHistory.calls()).toHaveLength(0); +}); + +test('a slug is resolved through the list endpoint', async () => { + fetchMock.get(listEndpoint, { result: [{ id: 7 }] }); + + const { result } = renderHook(() => useCanvasId('sales-overview')); + + await waitFor(() => + expect(result.current).toEqual({ status: 'complete', id: 7 }), + ); + expect(fetchMock.callHistory.calls()[0].url).toContain( + 'value:sales-overview', + ); +}); + +test('an unknown slug is an error', async () => { + fetchMock.get(listEndpoint, { result: [] }); + + const { result } = renderHook(() => useCanvasId('nope')); + + await waitFor(() => expect(result.current).toEqual({ status: 'error' })); +}); diff --git a/superset-frontend/src/features/canvas/useCanvasId.ts b/superset-frontend/src/features/canvas/useCanvasId.ts new file mode 100644 index 00000000000..d0ed0cad3aa --- /dev/null +++ b/superset-frontend/src/features/canvas/useCanvasId.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 { useEffect, useState } from 'react'; +import rison from 'rison'; +import { SupersetClient } from '@superset-ui/core'; + +export type CanvasIdState = + | { status: 'loading' } + | { status: 'error' } + | { status: 'complete'; id: number }; + +const isId = (idOrSlug: string) => /^\d+$/.test(idOrSlug); + +/** Resolves the `/canvas/<id or slug>/` route parameter to a canvas id. */ +export function useCanvasId(idOrSlug: string): CanvasIdState { + const [slugState, setSlugState] = useState<CanvasIdState>({ + status: 'loading', + }); + + useEffect(() => { + if (isId(idOrSlug)) return undefined; + let active = true; + setSlugState({ status: 'loading' }); + const query = rison.encode({ + filters: [{ col: 'slug', opr: 'eq', value: idOrSlug }], + columns: ['id'], + }); + SupersetClient.get({ endpoint: `/api/v1/canvas/?q=${query}` }) + .then(({ json }) => { + if (!active) return; + const [match] = (json?.result ?? []) as { id: number }[]; + setSlugState( + match ? { status: 'complete', id: match.id } : { status: 'error' }, + ); + }) + .catch(() => { + if (active) setSlugState({ status: 'error' }); + }); + return () => { + active = false; + }; + }, [idOrSlug]); + + return isId(idOrSlug) + ? { status: 'complete', id: Number(idOrSlug) } + : slugState; +} diff --git a/superset-frontend/src/features/canvas/useCanvasRefresh.test.ts b/superset-frontend/src/features/canvas/useCanvasRefresh.test.ts new file mode 100644 index 00000000000..d5bc94774ac --- /dev/null +++ b/superset-frontend/src/features/canvas/useCanvasRefresh.test.ts @@ -0,0 +1,77 @@ +/** + * 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, renderHook } from '@testing-library/react'; +import { CanvasDefinition } from './types'; +import { useCanvasRefresh } from './useCanvasRefresh'; + +const definition = ( + refresh: CanvasDefinition['settings']['refresh'], +): CanvasDefinition => ({ + version: 1, + root: { layout: { columns: 24, gap: 16, rowUnit: 40 }, children: [] }, + nodes: { + a: { widget: 'w-a', layout: {} }, + b: { widget: 'w-b', layout: {} }, + c: { widget: 'w-c', layout: {} }, + }, + interactions: { filters: {}, crossFilters: {}, customizations: {} }, + settings: { + refresh, + colors: { labelColors: {} }, + display: { showTimestamps: false }, + crossFilters: { enabled: true }, + }, +}); + +beforeEach(() => jest.useFakeTimers()); +afterEach(() => jest.useRealTimers()); + +test('bumps every node not exempt on each interval', () => { + const { result } = renderHook(() => + useCanvasRefresh(definition({ interval: 60, stagger: 0, exempt: ['c'] })), + ); + + expect(result.current).toEqual({}); + act(() => jest.advanceTimersByTime(60_000)); + expect(result.current).toEqual({ a: 1, b: 1 }); + act(() => jest.advanceTimersByTime(60_000)); + expect(result.current).toEqual({ a: 2, b: 2 }); +}); + +test('spreads a refresh over the stagger window', () => { + const { result } = renderHook(() => + useCanvasRefresh(definition({ interval: 60, stagger: 3000, exempt: [] })), + ); + + act(() => jest.advanceTimersByTime(60_000)); + expect(result.current).toEqual({ a: 1 }); + act(() => jest.advanceTimersByTime(1000)); + expect(result.current).toEqual({ a: 1, b: 1 }); + act(() => jest.advanceTimersByTime(1000)); + expect(result.current).toEqual({ a: 1, b: 1, c: 1 }); +}); + +test('does nothing when refresh is off', () => { + const { result } = renderHook(() => + useCanvasRefresh(definition({ interval: 0, stagger: 0, exempt: [] })), + ); + + act(() => jest.advanceTimersByTime(600_000)); + expect(result.current).toEqual({}); +}); diff --git a/superset-frontend/src/features/canvas/useCanvasRefresh.ts b/superset-frontend/src/features/canvas/useCanvasRefresh.ts new file mode 100644 index 00000000000..837f13a1234 --- /dev/null +++ b/superset-frontend/src/features/canvas/useCanvasRefresh.ts @@ -0,0 +1,72 @@ +/** + * 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 { useEffect, useState } from 'react'; +import { CanvasDefinition } from './types'; + +/** + * Drives the canvas' automatic refresh: every `settings.refresh.interval` + * seconds, each node not exempt gets its refresh key bumped, spread over + * `stagger` milliseconds in reading order. Widgets refetch when their key + * changes. + */ +export function useCanvasRefresh( + definition: CanvasDefinition | undefined, +): Record<string, number> { + const [keys, setKeys] = useState<Record<string, number>>({}); + const refresh = definition?.settings.refresh; + const interval = refresh?.interval ?? 0; + const stagger = refresh?.stagger ?? 0; + const exempt = new Set(refresh?.exempt ?? []); + const targetsKey = definition + ? Object.keys(definition.nodes) + .filter(nodeId => !exempt.has(nodeId)) + .join(',') + : ''; + + useEffect(() => { + if (interval <= 0 || !targetsKey) return undefined; + const targets = targetsKey.split(','); + const pending = new Set<ReturnType<typeof setTimeout>>(); + const bump = (nodeId: string) => + setKeys(previous => ({ + ...previous, + [nodeId]: (previous[nodeId] ?? 0) + 1, + })); + const timer = setInterval(() => { + targets.forEach((nodeId, index) => { + const delay = Math.round((stagger * index) / targets.length); + if (delay === 0) { + bump(nodeId); + return; + } + const timeout = setTimeout(() => { + pending.delete(timeout); + bump(nodeId); + }, delay); + pending.add(timeout); + }); + }, interval * 1000); + return () => { + clearInterval(timer); + pending.forEach(clearTimeout); + }; + }, [interval, stagger, targetsKey]); + + return keys; +} diff --git a/superset-frontend/src/pages/Canvas/Canvas.test.tsx b/superset-frontend/src/pages/Canvas/Canvas.test.tsx index db142e65ad3..fb8e453a9e0 100644 --- a/superset-frontend/src/pages/Canvas/Canvas.test.tsx +++ b/superset-frontend/src/pages/Canvas/Canvas.test.tsx @@ -24,7 +24,7 @@ import CanvasPage from '.'; jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), - useParams: () => ({ canvasId: '7' }), + useParams: () => ({ idOrSlug: '7' }), })); jest.mock('src/middleware/realtime', () => ({ subscribeRealtime: jest.fn(() => jest.fn()), @@ -44,9 +44,17 @@ const definition = (children: string[], revision = 1) => ({ nodes: Object.fromEntries( children.map(nodeId => [nodeId, { widget: `w-${nodeId}`, layout: {} }]), ), - interactions: { filters: {} }, + interactions: { filters: {}, crossFilters: {}, customizations: {} }, + settings: { + refresh: { interval: 0, stagger: 0, exempt: [] }, + colors: { labelColors: {} }, + display: { showTimestamps: false }, + crossFilters: { enabled: true }, + }, }, filterScopes: {}, + crossFilterScopes: {}, + customizationScopes: {}, placements: {}, widgetTypes: {}, gridColumns: {}, diff --git a/superset-frontend/src/pages/Canvas/index.tsx b/superset-frontend/src/pages/Canvas/index.tsx index e4385d54278..c87f52a5041 100644 --- a/superset-frontend/src/pages/Canvas/index.tsx +++ b/superset-frontend/src/pages/Canvas/index.tsx @@ -20,18 +20,26 @@ 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 type { canvas as canvasApi } from '@apache-superset/core'; +import { + CertifiedBadge, + EmptyState, + Loading, + Typography, +} from '@superset-ui/core/components'; import { chat, setActiveCanvas, useChat } from 'src/core'; +import CrudThemeProvider from 'src/components/CrudThemeProvider'; +import injectCustomCss from 'src/dashboard/util/injectCustomCss'; import { useApiV1Resource } from 'src/hooks/apiResources'; import { ResourceStatus } from 'src/hooks/apiResources/apiResources'; -import CanvasGrid from 'src/features/canvas/CanvasGrid'; +import CanvasGrid, { + emptyScopeValues, + ScopeValues, +} from 'src/features/canvas/CanvasGrid'; +import { CanvasMetadata } from 'src/features/canvas/types'; import { useCanvasDefinition } from 'src/features/canvas/useCanvasDefinition'; - -interface CanvasMetadata { - id: number; - title: string; - description?: string | null; -} +import { useCanvasId } from 'src/features/canvas/useCanvasId'; +import { useCanvasRefresh } from 'src/features/canvas/useCanvasRefresh'; const Page = styled.div` ${({ theme }) => css` @@ -65,24 +73,41 @@ function useOpenChatPanel() { }, [registeredChat]); } -export default function CanvasPage() { - const { canvasId } = useParams<{ canvasId: string }>(); - const id = Number(canvasId); +const notFound = ( + <EmptyState + size="large" + title={t('This canvas does not exist, or you do not have access to it')} + /> +); + +function CanvasContent({ id }: { id: number }) { const metadata = useApiV1Resource<CanvasMetadata>(`/api/v1/canvas/${id}`); const { state } = useCanvasDefinition(id); - const [filterValues, setFilterValues] = useState<Record<string, unknown>>({}); + const [values, setValues] = useState<ScopeValues>(emptyScopeValues); + const refreshKeys = useCanvasRefresh( + state.status === 'complete' ? state.result.definition : undefined, + ); - const onFilterChange = useCallback((filterNodeId: string, value: unknown) => { - setFilterValues(previous => { - const next = { ...previous }; - if (value === undefined) delete next[filterNodeId]; - else next[filterNodeId] = value; - return next; - }); - }, []); + const onValueChange = useCallback( + (kind: canvasApi.ScopeKind, nodeId: string, value: unknown) => { + setValues(previous => { + const next = { ...previous[kind] }; + if (value === undefined) delete next[nodeId]; + else next[nodeId] = value; + return { ...previous, [kind]: next }; + }); + }, + [], + ); useOpenChatPanel(); + const customCss = metadata.result?.css; + useEffect( + () => (customCss ? injectCustomCss(customCss) : undefined), + [customCss], + ); + const title = metadata.result?.title; const revision = state.status === 'complete' ? state.result.revision : undefined; @@ -94,12 +119,7 @@ export default function CanvasPage() { 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')} - /> - ); + return notFound; } if ( metadata.status === ResourceStatus.Loading || @@ -109,30 +129,52 @@ export default function CanvasPage() { } const isEmpty = state.result.definition.root.children.length === 0; + const certifiedBy = metadata.result?.certified_by; 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> + <CrudThemeProvider theme={metadata.result?.theme}> + <Page data-test="canvas-page"> + <Header> + <Typography.Title level={3}> + {certifiedBy && ( + <> + <CertifiedBadge + certifiedBy={certifiedBy} + details={metadata.result?.certification_details ?? undefined} + />{' '} + </> + )} + {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} + values={values} + onValueChange={onValueChange} + refreshKeys={refreshKeys} + /> )} - </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> + </Page> + </CrudThemeProvider> ); } + +export default function CanvasPage() { + const { idOrSlug } = useParams<{ idOrSlug: string }>(); + const canvasId = useCanvasId(idOrSlug); + if (canvasId.status === 'error') return notFound; + if (canvasId.status === 'loading') return <Loading />; + return <CanvasContent id={canvasId.id} />; +} diff --git a/superset-frontend/src/pages/CanvasList/index.tsx b/superset-frontend/src/pages/CanvasList/index.tsx index acef0cb8d44..d2c10875f7d 100644 --- a/superset-frontend/src/pages/CanvasList/index.tsx +++ b/superset-frontend/src/pages/CanvasList/index.tsx @@ -26,7 +26,11 @@ 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 { + CertifiedBadge, + ConfirmStatusChange, + DeleteModal, +} from '@superset-ui/core/components'; import { Icons } from '@superset-ui/core/components/Icons'; import { ListView, @@ -132,9 +136,26 @@ function CanvasList({ { Cell: ({ row: { - original: { url, title }, + original: { + url, + title, + certified_by: certifiedBy, + certification_details: certificationDetails, + }, }, - }: any) => <Link to={url}>{title}</Link>, + }: any) => ( + <Link to={url}> + {certifiedBy && ( + <> + <CertifiedBadge + certifiedBy={certifiedBy} + details={certificationDetails} + />{' '} + </> + )} + {title} + </Link> + ), Header: t('Title'), accessor: 'title', size: 'xxl', diff --git a/superset-frontend/src/views/routePaths.ts b/superset-frontend/src/views/routePaths.ts index 915d0989883..8f993d9728b 100644 --- a/superset-frontend/src/views/routePaths.ts +++ b/superset-frontend/src/views/routePaths.ts @@ -35,7 +35,7 @@ export const RoutePaths = { CSS_TEMPLATES: '/csstemplatemodelview/list/', THEMES: '/theme/list/', CANVAS_LIST: '/canvas/list/', - CANVAS: '/canvas/:canvasId/', + CANVAS: '/canvas/:idOrSlug/', ANNOTATION_LAYERS: '/annotationlayer/list/', ANNOTATION_LIST: '/annotationlayer/:annotationLayerId/annotation/', QUERY_HISTORY: '/sqllab/history/',
