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/',

Reply via email to