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}

Reply via email to