You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ms...@apache.org on 2026/10/05 11:27:49 UTC
(superset) branch msyavuz/feat/canvas created (now ed5a6810e12)
This is an automated email from the ASF dual-hosted git repository.
msyavuz pushed a change to branch msyavuz/feat/canvas
in repository https://gitbox.apache.org/repos/asf/superset.git
at ed5a6810e12 feat(canvas): consolidate with the SIP-231 widget framework
This branch includes the following new commits:
new e10532c7e8d feat(canvas): add Canvas, an AI-first dashboard entity
new e397f906faa feat(canvas): list and view pages behind the CANVAS flag
new 7dc9fcbaa72 feat(canvas): let callers choose node ids on add
new 1cd3a9c3cc5 feat(canvas): upgrade stored definitions and tolerate widget rule changes
new a5bcbe3a57f feat(canvas): canvas-wide settings for refresh, colors and display
new 842ee05899c feat(canvas): scope cross-filters and customizations like filters
new 5c1c3db9db2 feat(canvas): slug, theme, CSS and certification metadata
new 9779b070e03 feat(canvas): pass settings and every scope kind to widget renderers
new 247d90166e4 docs(canvas): placement is resolved server-side only
new bf7d0fe3f60 feat(canvas): drop the arbitrary definition limits
new 146af62670d feat(canvas): allow a widget to be placed more than once
new ed5a6810e12 feat(canvas): consolidate with the SIP-231 widget framework
The 12 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(superset) 08/12: feat(canvas): pass settings and every scope kind to widget renderers
Posted by ms...@apache.org.
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 <sa...@proton.me>
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/',
(superset) 05/12: feat(canvas): canvas-wide settings for refresh, colors and display
Posted by ms...@apache.org.
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 a5bcbe3a57f2a20c2a66f494ece88527eb5ce059
Author: Mehmet Salih Yavuz <sa...@proton.me>
AuthorDate: Thu Oct 1 12:41:27 2026 +0300
feat(canvas): canvas-wide settings for refresh, colors and display
The definition gains a settings section: automatic refresh (interval,
stagger, exempt nodes), shared colors (scheme and fixed label colors)
and display (data timestamps). A set_settings operation replaces one
section or resets it. Each section is its own concurrency group, so
concurrent changes to different sections, or to settings and layout,
merge. Removing a node drops it from the refresh exemptions.
---
superset/canvas/definition/ops.py | 35 +++++++++++++++++++++++-
superset/canvas/definition/schemas.py | 44 +++++++++++++++++++++++++++++-
superset/canvas/definition/validation.py | 10 +++++++
tests/unit_tests/canvas/ops_test.py | 42 ++++++++++++++++++++++++++++
tests/unit_tests/canvas/validation_test.py | 30 ++++++++++++++++++++
5 files changed, 159 insertions(+), 2 deletions(-)
diff --git a/superset/canvas/definition/ops.py b/superset/canvas/definition/ops.py
index e17e964cb19..d8edf954a8f 100644
--- a/superset/canvas/definition/ops.py
+++ b/superset/canvas/definition/ops.py
@@ -47,6 +47,7 @@ from superset.canvas.definition.schemas import (
RemoveOp,
ROOT_ID,
SetFilterScopeOp,
+ SetSettingsOp,
)
from superset.canvas.definition.validation import normalize_definition
@@ -57,6 +58,14 @@ class FieldGroup(str, Enum):
LAYOUT = "layout"
# A filter's scope override.
SCOPE = "scope"
+ # Sections of the canvas settings, touched on ``SETTINGS_ID``.
+ REFRESH = "refresh"
+ COLORS = "colors"
+ DISPLAY = "display"
+
+
+# Stands in for the settings in touches; never a node id, which is a UUID.
+SETTINGS_ID = "settings"
@dataclass(frozen=True)
@@ -112,6 +121,8 @@ def named_touches(ops: Iterable[Operation]) -> list[Touch]:
touches.append(Touch(op.id, FieldGroup.LAYOUT))
elif isinstance(op, SetFilterScopeOp):
touches.append(Touch(op.id, FieldGroup.SCOPE))
+ elif isinstance(op, SetSettingsOp):
+ touches.append(Touch(SETTINGS_ID, FieldGroup(op.key)))
return touches
@@ -194,7 +205,12 @@ class _Applier:
for node_id in removed:
del self.nodes[node_id]
touched = [Touch(node_id, FieldGroup.TREE) for node_id in removed]
- return AppliedOperation(logged, touched + self._prune_scopes(set(removed)))
+ return AppliedOperation(
+ logged,
+ touched
+ + self._prune_scopes(set(removed))
+ + self._prune_settings(set(removed)),
+ )
if isinstance(op, MoveOp):
return self.move(op, logged)
if isinstance(op, SetFilterScopeOp):
@@ -207,6 +223,13 @@ class _Applier:
mode="json", by_alias=True, exclude_none=True
)
return AppliedOperation(logged, [Touch(op.id, FieldGroup.SCOPE)])
+ if isinstance(op, SetSettingsOp):
+ settings = self.canvas.setdefault("settings", {})
+ if op.value is None:
+ settings.pop(op.key, None)
+ else:
+ settings[op.key] = op.value
+ return AppliedOperation(logged, [Touch(SETTINGS_ID, FieldGroup(op.key))])
assert isinstance(op, PlaceOp) # noqa: S101
self.node(op.id)["layout"] = op.layout
return AppliedOperation(logged, [Touch(op.id, FieldGroup.LAYOUT)])
@@ -230,6 +253,16 @@ class _Applier:
touched.append(Touch(filter_id, FieldGroup.SCOPE))
return touched
+ def _prune_settings(self, removed: set[str]) -> list[Touch]:
+ """Drop removed nodes from the refresh exemptions."""
+ refresh = self.canvas.get("settings", {}).get("refresh", {})
+ exempt = refresh.get("exempt", [])
+ kept = [node_id for node_id in exempt if node_id not in removed]
+ if len(kept) == len(exempt):
+ return []
+ refresh["exempt"] = kept
+ return [Touch(SETTINGS_ID, FieldGroup.REFRESH)]
+
def move(self, op: MoveOp, logged: dict[str, Any]) -> AppliedOperation:
node = self.node(op.id)
if op.parent != ROOT_ID and op.parent in self.subtree(op.id):
diff --git a/superset/canvas/definition/schemas.py b/superset/canvas/definition/schemas.py
index feac87dd612..726627e8bf1 100644
--- a/superset/canvas/definition/schemas.py
+++ b/superset/canvas/definition/schemas.py
@@ -98,11 +98,45 @@ class Interactions(_Model):
filters: dict[str, FilterScope] = Field(default_factory=dict)
+class RefreshSettings(_Model):
+ # Seconds between automatic refreshes of every widget's data; 0 is off.
+ interval: int = Field(default=0, ge=0, le=7 * 24 * 60 * 60)
+ # Milliseconds a refresh is spread over across widgets; 0 refreshes all at
+ # once.
+ stagger: int = Field(default=0, ge=0, le=10 * 60 * 1000)
+ # Nodes left out of automatic refresh.
+ exempt: list[str] = Field(default_factory=list)
+
+
+class ColorSettings(_Model):
+ # Categorical color scheme shared by every widget; the default when unset.
+ scheme: str | None = Field(default=None, min_length=1, max_length=255)
+ # Fixed colors for series labels across widgets, e.g. {"France": "#1f77b4"}.
+ label_colors: dict[str, Annotated[str, Field(max_length=64)]] = Field(
+ default_factory=dict, max_length=1000
+ )
+
+
+class DisplaySettings(_Model):
+ # Show when each widget's data was last refreshed.
+ show_timestamps: bool = False
+
+
+class Settings(_Model):
+ refresh: RefreshSettings = Field(default_factory=RefreshSettings)
+ colors: ColorSettings = Field(default_factory=ColorSettings)
+ display: DisplaySettings = Field(default_factory=DisplaySettings)
+
+
+SettingsKey = Literal["refresh", "colors", "display"]
+
+
class CanvasDefinition(_Model):
version: Literal[1] = 1
root: Root = Field(default_factory=Root)
nodes: dict[str, Node] = Field(default_factory=dict)
interactions: Interactions = Field(default_factory=Interactions)
+ settings: Settings = Field(default_factory=Settings)
def empty_definition() -> dict[str, Any]:
@@ -160,8 +194,16 @@ class SetFilterScopeOp(_Model):
scope: FilterScope | None
+class SetSettingsOp(_Model):
+ """Replace one section of the canvas settings; ``value: null`` resets it."""
+
+ op: Literal["set_settings"]
+ key: SettingsKey
+ value: dict[str, Any] | None
+
+
Operation = Annotated[
- Union[AddOp, RemoveOp, MoveOp, PlaceOp, SetFilterScopeOp],
+ Union[AddOp, RemoveOp, MoveOp, PlaceOp, SetFilterScopeOp, SetSettingsOp],
Field(discriminator="op"),
]
diff --git a/superset/canvas/definition/validation.py b/superset/canvas/definition/validation.py
index ec4b342a940..0999ec6492b 100644
--- a/superset/canvas/definition/validation.py
+++ b/superset/canvas/definition/validation.py
@@ -119,6 +119,7 @@ class _Validator:
self._check_nesting()
self._check_depth(doc)
self._check_interactions(doc)
+ self._check_settings(doc)
if not self.issues:
self._resolve_layouts(doc)
if self.issues:
@@ -238,6 +239,15 @@ class _Validator:
f"node {target!r} cannot be filtered",
)
+ def _check_settings(self, doc: dict[str, Any]) -> None:
+ exempt = doc["settings"]["refresh"]["exempt"]
+ for index, node_id in enumerate(exempt):
+ if node_id not in doc["nodes"]:
+ self.fail(
+ pointer("settings", "refresh", "exempt", index),
+ f"unknown node {node_id!r}",
+ )
+
def _check_depth(self, doc: dict[str, Any]) -> None:
for node_id in doc["nodes"]:
depth, current = 1, node_id
diff --git a/tests/unit_tests/canvas/ops_test.py b/tests/unit_tests/canvas/ops_test.py
index 5449adb2733..0607bd88f31 100644
--- a/tests/unit_tests/canvas/ops_test.py
+++ b/tests/unit_tests/canvas/ops_test.py
@@ -22,8 +22,10 @@ from superset.canvas.definition.ops import (
AppliedOperation,
apply_operations,
FieldGroup,
+ named_touches,
OperationError,
overlapping,
+ SETTINGS_ID,
Touch,
)
from superset.canvas.definition.registry import LayoutRulesRegistry
@@ -257,3 +259,43 @@ def test_overlapping(
earlier: list[Touch], later: list[Touch], expected: set[str]
) -> None:
assert overlapping(earlier, later) == expected
+
+
+def test_set_settings_replaces_one_section_and_null_resets_it() -> None:
+ doc = build(
+ add("chart-1"),
+ {"op": "set_settings", "key": "refresh", "value": {"interval": 300}},
+ {"op": "set_settings", "key": "colors", "value": {"scheme": "supersetColors"}},
+ )
+
+ assert doc["settings"]["refresh"] == {"interval": 300, "stagger": 0, "exempt": []}
+ assert doc["settings"]["colors"]["scheme"] == "supersetColors"
+
+ reset, applied = apply(doc, {"op": "set_settings", "key": "refresh", "value": None})
+
+ assert reset["settings"]["refresh"]["interval"] == 0
+ assert reset["settings"]["colors"]["scheme"] == "supersetColors"
+ assert applied[0].touched == [Touch(SETTINGS_ID, FieldGroup.REFRESH)]
+
+
+def test_settings_sections_do_not_overlap_each_other() -> None:
+ refresh = named_touches(
+ ops({"op": "set_settings", "key": "refresh", "value": {"interval": 60}})
+ )
+ colors = named_touches(ops({"op": "set_settings", "key": "colors", "value": {}}))
+
+ assert overlapping(refresh, colors) == set()
+ assert overlapping(refresh, refresh) == {SETTINGS_ID}
+
+
+def test_removing_a_node_drops_its_refresh_exemption() -> None:
+ doc = build(
+ add("chart-1"),
+ add("chart-2"),
+ {"op": "set_settings", "key": "refresh", "value": {"exempt": ["n0", "n1"]}},
+ )
+
+ result, applied = apply(doc, {"op": "remove", "id": "n0"})
+
+ assert result["settings"]["refresh"]["exempt"] == ["n1"]
+ assert Touch(SETTINGS_ID, FieldGroup.REFRESH) in applied[0].touched
diff --git a/tests/unit_tests/canvas/validation_test.py b/tests/unit_tests/canvas/validation_test.py
index 3c5096639d1..63eab034496 100644
--- a/tests/unit_tests/canvas/validation_test.py
+++ b/tests/unit_tests/canvas/validation_test.py
@@ -47,6 +47,11 @@ def test_empty_canvas_is_valid() -> None:
"root": {"layout": {"columns": 24, "gap": 16, "rowUnit": 40}, "children": []},
"nodes": {},
"interactions": {"filters": {}},
+ "settings": {
+ "refresh": {"interval": 0, "stagger": 0, "exempt": []},
+ "colors": {"labelColors": {}},
+ "display": {"showTimestamps": False},
+ },
}
@@ -213,3 +218,28 @@ def test_containers_get_children_and_leaves_do_not() -> None:
assert result["nodes"]["g"]["children"] == []
assert "children" not in result["nodes"]["a"]
+
+
+def test_refresh_exemptions_must_be_nodes() -> None:
+ found = issues(
+ {
+ **canvas({"a": node("chart")}),
+ "settings": {"refresh": {"exempt": ["a", "b"]}},
+ }
+ )
+
+ assert found == [("/settings/refresh/exempt/1", "unknown node 'b'")]
+
+
+def test_settings_are_validated() -> None:
+ found = issues(
+ {
+ **empty_definition(),
+ "settings": {"refresh": {"interval": -1}, "colors": {"theme": "dark"}},
+ }
+ )
+
+ assert {path for path, _ in found} == {
+ "/settings/refresh/interval",
+ "/settings/colors/theme",
+ }
(superset) 04/12: feat(canvas): upgrade stored definitions and tolerate widget rule changes
Posted by ms...@apache.org.
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 1cd3a9c3cc513937e981d854baf26c50e5e64c83
Author: Mehmet Salih Yavuz <sa...@proton.me>
AuthorDate: Thu Oct 1 12:38:18 2026 +0300
feat(canvas): upgrade stored definitions and tolerate widget rule changes
Stored definitions are upgraded to the current schema version on load
through a chain of per-version upgrades; the next write stores the
upgraded form and restarts the operation log, so clients on an older
base reload. A definition newer than the server supports is refused
with a 422 that says Superset needs upgrading.
Widget rules (nesting, sizes, child layouts, filter roles) are enforced
only on the nodes a write touches. Nodes saved under rules that have
since tightened keep their stored placement and no longer block edits
elsewhere on the canvas.
---
superset/canvas/api.py | 3 ++
superset/canvas/definition/ops.py | 5 ++-
superset/canvas/definition/render.py | 17 +++++++-
superset/canvas/definition/upgrades.py | 74 ++++++++++++++++++++++++++++++++
superset/canvas/definition/validation.py | 45 +++++++++++++++----
superset/commands/canvas/apply_ops.py | 9 +++-
superset/commands/canvas/create.py | 11 ++++-
superset/daos/canvas.py | 16 ++++---
tests/unit_tests/canvas/api_test.py | 30 +++++++++++++
tests/unit_tests/canvas/ops_test.py | 50 +++++++++++++++++++++
tests/unit_tests/canvas/upgrades_test.py | 62 ++++++++++++++++++++++++++
11 files changed, 301 insertions(+), 21 deletions(-)
diff --git a/superset/canvas/api.py b/superset/canvas/api.py
index 2c73de6e1fd..e365154663c 100644
--- a/superset/canvas/api.py
+++ b/superset/canvas/api.py
@@ -40,6 +40,7 @@ from superset.canvas.definition.schemas import (
Operation,
)
from superset.canvas.definition.scopes import resolve_filter_scopes
+from superset.canvas.definition.upgrades import DefinitionVersionError
from superset.canvas.filters import (
CanvasAccessFilter,
CanvasAllTextFilter,
@@ -94,6 +95,8 @@ def _handle_canvas_errors(
return self.response_422(message=ex.normalized_messages())
except (DefinitionConflictError, DefinitionInvalidError) as ex:
return self.response(ex.status, **ex.to_payload())
+ except DefinitionVersionError as ex:
+ return self.response_422(message=str(ex))
except (
CanvasCreateFailedError,
CanvasUpdateFailedError,
diff --git a/superset/canvas/definition/ops.py b/superset/canvas/definition/ops.py
index 64fb3b10df0..e17e964cb19 100644
--- a/superset/canvas/definition/ops.py
+++ b/superset/canvas/definition/ops.py
@@ -277,4 +277,7 @@ def apply_operations(
applied.append(applier.apply(op))
except ValueError as ex:
raise OperationError(index, str(ex)) from ex
- return normalize_definition(applier.canvas, rules, resolver), applied
+ # Widget rules are enforced on what these operations touched; the rest of
+ # the canvas keeps its stored placement even if a rule changed since.
+ touched = {touch.node_id for op in applied for touch in op.touched}
+ return normalize_definition(applier.canvas, rules, resolver, touched), applied
diff --git a/superset/canvas/definition/render.py b/superset/canvas/definition/render.py
index 49e9497df38..069ac2cc751 100644
--- a/superset/canvas/definition/render.py
+++ b/superset/canvas/definition/render.py
@@ -30,7 +30,8 @@ from __future__ import annotations
from typing import Any
-from superset_core.canvas import WidgetResolver
+from pydantic import ValidationError
+from superset_core.canvas import GridPlacement, WidgetResolver
from superset.canvas.definition.grid import resolve_grid
from superset.canvas.definition.registry import (
@@ -70,7 +71,8 @@ def render_context(
placements: dict[str, dict[str, int]] = {}
for children, columns in grids:
- rects, _ = resolve_grid([nodes[c]["layout"] for c in children], columns)
+ layouts = [_grid_layout(nodes[c]["layout"]) for c in children]
+ rects, _ = resolve_grid(layouts, columns)
for child, rect in zip(children, rects, strict=True):
placements[child] = {
"col": rect.col,
@@ -83,3 +85,14 @@ def render_context(
"placements": placements,
"gridColumns": grid_columns,
}
+
+
+def _grid_layout(layout: dict[str, Any]) -> dict[str, Any]:
+ """A stored layout as grid placement; one kept from another layout model
+ after a container's rules changed is auto-placed instead."""
+ try:
+ return GridPlacement.model_validate(layout).model_dump(
+ mode="json", by_alias=True, exclude_none=True
+ )
+ except ValidationError:
+ return {}
diff --git a/superset/canvas/definition/upgrades.py b/superset/canvas/definition/upgrades.py
new file mode 100644
index 00000000000..1f63cc8b020
--- /dev/null
+++ b/superset/canvas/definition/upgrades.py
@@ -0,0 +1,74 @@
+# 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.
+"""
+Upgrading stored definitions to the current schema version.
+
+Every change to the definition schema bumps ``DEFINITION_VERSION`` and adds an
+upgrade from the previous version to ``UPGRADES``. Definitions are upgraded
+when loaded; the next write stores the upgraded form. A definition newer than
+this server understands is refused rather than read with a partial schema.
+"""
+
+from __future__ import annotations
+
+import copy
+from collections.abc import Callable, Mapping
+from typing import Any
+
+from superset.canvas.definition.schemas import DEFINITION_VERSION
+
+Upgrade = Callable[[dict[str, Any]], dict[str, Any]]
+
+# Upgrades keyed by the version they upgrade from; each returns the definition
+# at the next version.
+UPGRADES: dict[int, Upgrade] = {}
+
+
+class DefinitionVersionError(ValueError):
+ def __init__(self, version: object, current: int) -> None:
+ self.version = version
+ self.current = current
+ if isinstance(version, int) and version > current:
+ message = (
+ f"This canvas was saved with definition version {version}; this "
+ f"Superset supports up to {current}. Upgrade Superset to open it."
+ )
+ else:
+ message = f"Unsupported canvas definition version {version!r}"
+ super().__init__(message)
+
+
+def upgrade_definition(
+ definition: dict[str, Any],
+ current: int = DEFINITION_VERSION,
+ upgrades: Mapping[int, Upgrade] | None = None,
+) -> dict[str, Any]:
+ """Return ``definition`` at version ``current``, upgrading it if older."""
+ upgrades = UPGRADES if upgrades is None else upgrades
+ version = definition.get("version", 1)
+ if not isinstance(version, int) or isinstance(version, bool):
+ raise DefinitionVersionError(version, current)
+ if version > current or version < 1:
+ raise DefinitionVersionError(version, current)
+ if version == current:
+ return definition
+ upgraded = copy.deepcopy(definition)
+ while version < current:
+ upgraded = upgrades[version](upgraded)
+ version += 1
+ upgraded["version"] = version
+ return upgraded
diff --git a/superset/canvas/definition/validation.py b/superset/canvas/definition/validation.py
index d4f70617ea7..ec4b342a940 100644
--- a/superset/canvas/definition/validation.py
+++ b/superset/canvas/definition/validation.py
@@ -25,10 +25,16 @@ A node whose widget no longer exists, or whose container type is no longer
registered (e.g. its extension was removed), is kept as an unresolved
placeholder: it and its children stay as stored, so the rest of the canvas can
still be edited. Whether a new widget may be placed is checked by the caller.
+
+Widget types' rules (nesting, sizes, child layouts, filter roles) can change
+after a canvas is saved. With ``strict_nodes`` given, only those nodes are
+checked against the rules; the others keep their stored placement, so a
+tightened rule never blocks edits elsewhere. Tree integrity is always checked.
"""
from __future__ import annotations
+from collections.abc import Iterable
from dataclasses import dataclass
from typing import Any
@@ -81,10 +87,12 @@ class _Validator:
raw: dict[str, Any],
rules: LayoutRulesRegistry,
resolver: WidgetResolver,
+ strict_nodes: Iterable[str] | None,
) -> None:
self.raw = raw
self.rules = rules
self.resolver = resolver
+ self.strict = None if strict_nodes is None else set(strict_nodes)
self.issues: list[Issue] = []
self.parents: dict[str, str] = {}
self.node_rules: dict[str, type[CanvasLayoutRules]] = {}
@@ -94,6 +102,9 @@ class _Validator:
def fail(self, path: str, message: str) -> None:
self.issues.append(Issue(path, message))
+ def is_strict(self, node_id: str) -> bool:
+ return self.strict is None or node_id in self.strict
+
def run(self) -> dict[str, Any]:
try:
canvas = CanvasDefinition.model_validate(self.raw)
@@ -179,7 +190,7 @@ class _Validator:
def _check_nesting(self) -> None:
for node_id, parent_id in self.parents.items():
rules = self.node_rules.get(node_id)
- if rules is None:
+ if rules is None or not self.is_strict(node_id):
continue
accepted: frozenset[str] | None = None
if parent_id == ROOT_ID:
@@ -206,8 +217,9 @@ class _Validator:
if filter_id not in nodes:
self.fail(pointer(*path), f"unknown node {filter_id!r}")
continue
+ strict = self.is_strict(filter_id)
rules = self.node_rules.get(filter_id)
- if rules is not None and not rules.is_filter:
+ if strict and rules is not None and not rules.is_filter:
self.fail(pointer(*path), f"node {filter_id!r} is not a filter")
for field in ("targets", "exclude"):
for index, target in enumerate(scope[field]):
@@ -216,7 +228,11 @@ class _Validator:
self.fail(
pointer(*path, field, index), f"unknown node {target!r}"
)
- elif target_rules is not None and not target_rules.is_filterable:
+ elif (
+ strict
+ and target_rules is not None
+ and not target_rules.is_filterable
+ ):
self.fail(
pointer(*path, field, index),
f"node {target!r} cannot be filtered",
@@ -255,16 +271,24 @@ class _Validator:
model = GridPlacement if columns is not None else child_model
assert model is not None # noqa: S101
layouts: list[dict[str, Any]] = []
+ # Children whose stored layout no longer fits a changed rule, kept as
+ # stored and auto-placed when resolving the grid.
+ kept: set[str] = set()
for child_id in children:
layout_path = ("nodes", child_id, "layout")
+ strict = self.is_strict(child_id)
try:
layout = model.model_validate(
doc["nodes"][child_id]["layout"]
).model_dump(mode="json", by_alias=True, exclude_none=True)
except ValidationError as ex:
- self.issues.extend(_pydantic_issues(layout_path, ex))
+ if strict:
+ self.issues.extend(_pydantic_issues(layout_path, ex))
+ continue
+ kept.add(child_id)
+ layouts.append({})
continue
- if columns is not None:
+ if columns is not None and strict:
for message in [
*span_errors(layout, columns),
*self._size_errors(child_id, layout, columns),
@@ -276,7 +300,8 @@ class _Validator:
if columns is not None and len(layouts) == len(children):
_, stored = resolve_grid(layouts, columns)
for child_id, layout in zip(children, stored, strict=True):
- doc["nodes"][child_id]["layout"] = layout
+ if child_id not in kept:
+ doc["nodes"][child_id]["layout"] = layout
def _size_errors(
self, node_id: str, layout: dict[str, Any], columns: int
@@ -309,13 +334,15 @@ def normalize_definition(
raw: dict[str, Any],
rules: LayoutRulesRegistry | None = None,
resolver: WidgetResolver | None = None,
+ strict_nodes: Iterable[str] | None = None,
) -> dict[str, Any]:
"""
Validate ``raw`` and return the normalized canvas to store.
- Raises ``DefinitionValidationError`` listing every problem found, each with a
- JSON-pointer path into the canvas.
+ ``strict_nodes`` limits the widget rule checks to those nodes; ``None``
+ checks every node. Raises ``DefinitionValidationError`` listing every
+ problem found, each with a JSON-pointer path into the canvas.
"""
return _Validator(
- raw, rules or layout_rules, resolver or get_widget_resolver()
+ raw, rules or layout_rules, resolver or get_widget_resolver(), strict_nodes
).run()
diff --git a/superset/commands/canvas/apply_ops.py b/superset/commands/canvas/apply_ops.py
index 9e4d1e7268b..790af1883b6 100644
--- a/superset/commands/canvas/apply_ops.py
+++ b/superset/commands/canvas/apply_ops.py
@@ -35,7 +35,7 @@ from superset.canvas.definition.ops import (
)
from superset.canvas.definition.registry import get_widget_resolver
from superset.canvas.definition.render import render_context
-from superset.canvas.definition.schemas import AddOp, Operation
+from superset.canvas.definition.schemas import AddOp, DEFINITION_VERSION, Operation
from superset.canvas.definition.scopes import resolve_filter_scopes
from superset.canvas.definition.validation import (
DefinitionValidationError,
@@ -111,7 +111,12 @@ class ApplyCanvasOperationsCommand(BaseCommand):
self.validate()
canvas = CanvasDAO.lock(self._canvas_id)
current = canvas.revision
+ # Logged operations predate the stored definition's upgrade, so they
+ # can't be checked for overlap; the log restarts with this write.
+ upgrading = canvas.definition_version != DEFINITION_VERSION
if self._base_revision != current:
+ if upgrading:
+ raise DefinitionConflictError(current, [], stale=True)
self._check_overlap(current, named_touches(self._ops))
try:
@@ -130,6 +135,8 @@ class ApplyCanvasOperationsCommand(BaseCommand):
canvas.definition = json.dumps(definition)
canvas.definition_version = definition["version"]
canvas.revision = revision
+ if upgrading:
+ CanvasDAO.prune(canvas.id)
CanvasDAO.log(canvas.id, revision, applied, get_user_id())
CanvasDAO.prune(canvas.id, revision - OP_LOG_RETAINED_REVISIONS)
return ApplyResult(
diff --git a/superset/commands/canvas/create.py b/superset/commands/canvas/create.py
index 6b5b856c8ab..d02af52e913 100644
--- a/superset/commands/canvas/create.py
+++ b/superset/commands/canvas/create.py
@@ -25,6 +25,10 @@ from marshmallow import ValidationError
from superset.canvas.definition.registry import get_widget_resolver
from superset.canvas.definition.schemas import empty_definition
+from superset.canvas.definition.upgrades import (
+ DefinitionVersionError,
+ upgrade_definition,
+)
from superset.canvas.definition.validation import (
DefinitionValidationError,
normalize_definition,
@@ -61,7 +65,12 @@ class CreateCanvasCommand(CreateMixin, BaseCommand):
def _validate_definition(self, exceptions: list[ValidationError]) -> None:
raw = self._properties.pop("definition", None) or empty_definition()
try:
- definition = normalize_definition(raw)
+ definition = normalize_definition(upgrade_definition(raw))
+ except DefinitionVersionError as ex:
+ exceptions.append(
+ ValidationError({"/version": [str(ex)]}, field_name="definition")
+ )
+ return
except DefinitionValidationError as ex:
exceptions.append(
ValidationError(
diff --git a/superset/daos/canvas.py b/superset/daos/canvas.py
index 069f66f3bbc..f4c6a810900 100644
--- a/superset/daos/canvas.py
+++ b/superset/daos/canvas.py
@@ -24,6 +24,7 @@ from sqlalchemy import func
from superset import db
from superset.canvas.definition.ops import AppliedOperation
+from superset.canvas.definition.upgrades import upgrade_definition
from superset.canvas.filters import CanvasAccessFilter
from superset.daos.base import BaseDAO
from superset.models.canvas import Canvas, CanvasOp
@@ -46,7 +47,8 @@ class CanvasDAO(BaseDAO[Canvas]):
@staticmethod
def load(canvas: Canvas) -> dict[str, Any]:
- return json.loads(canvas.definition)
+ """The stored definition, upgraded to the current schema version."""
+ return upgrade_definition(json.loads(canvas.definition))
@staticmethod
def ops_since(canvas_id: int, revision: int) -> list[CanvasOp]:
@@ -88,9 +90,9 @@ class CanvasDAO(BaseDAO[Canvas]):
)
@staticmethod
- def prune(canvas_id: int, keep_after_revision: int) -> None:
- """Drop logged operations at or before ``keep_after_revision``."""
- db.session.query(CanvasOp).filter(
- CanvasOp.canvas_id == canvas_id,
- CanvasOp.revision <= keep_after_revision,
- ).delete(synchronize_session=False)
+ def prune(canvas_id: int, keep_after_revision: int | None = None) -> None:
+ """Drop logged operations at or before ``keep_after_revision``, or all."""
+ query = db.session.query(CanvasOp).filter(CanvasOp.canvas_id == canvas_id)
+ if keep_after_revision is not None:
+ query = query.filter(CanvasOp.revision <= keep_after_revision)
+ query.delete(synchronize_session=False)
diff --git a/tests/unit_tests/canvas/api_test.py b/tests/unit_tests/canvas/api_test.py
index 4a09f277abd..18185df60d4 100644
--- a/tests/unit_tests/canvas/api_test.py
+++ b/tests/unit_tests/canvas/api_test.py
@@ -308,6 +308,36 @@ def test_changes_since_a_pruned_revision_are_stale(
assert client.get(f"{URL}/changes?since=3").json["result"]["changes"] == []
+def test_definition_from_a_newer_superset_is_refused(
+ client: Any, full_api_access: None, canvas: Any, published: MagicMock
+) -> None:
+ canvas.definition = json.dumps({**json.loads(canvas.definition), "version": 99})
+
+ for response in (client.get(URL), patch_ops(client, 1, add("chart-1"))):
+ assert response.status_code == 422
+ assert "Upgrade Superset" in response.json["message"]
+ assert canvas.revision == 1
+
+
+def test_write_that_upgrades_the_definition_restarts_the_op_log(
+ client: Any,
+ full_api_access: None,
+ canvas: Any,
+ published: MagicMock,
+ session: Session,
+) -> None:
+ patch_ops(client, 1, add("chart-1"))
+ # Stored by an older schema version.
+ canvas.definition_version = 0
+ session.commit()
+
+ assert patch_ops(client, 1, add("chart-2")).json["stale"] is True
+ assert patch_ops(client, 2, add("chart-2")).status_code == 200
+ assert canvas.definition_version == 1
+ assert client.get(f"{URL}/changes?since=1").status_code == 409
+ assert client.get(f"{URL}/changes?since=2").status_code == 200
+
+
def test_schema_is_published(
client: Any, full_api_access: None, widgets: FakeResolver
) -> None:
diff --git a/tests/unit_tests/canvas/ops_test.py b/tests/unit_tests/canvas/ops_test.py
index 85a0521609e..5449adb2733 100644
--- a/tests/unit_tests/canvas/ops_test.py
+++ b/tests/unit_tests/canvas/ops_test.py
@@ -114,6 +114,56 @@ def test_add_into_a_leaf_is_rejected() -> None:
build(add("chart-1"), add("chart-2", parent="n0"))
+def changed_rules(widget_type: str, **changes: Any) -> LayoutRulesRegistry:
+ """The test rules, with ``widget_type``'s rules changed after saving."""
+ rules = canvas_rules()
+ base = rules.get(widget_type)
+ rules.unregister(widget_type)
+ rules.register(type(f"Changed{base.__name__}", (base,), changes))
+ return rules
+
+
+def apply_with(
+ rules: LayoutRulesRegistry, doc: dict[str, Any], *raw: dict[str, Any]
+) -> dict[str, Any]:
+ return apply_operations(
+ doc, ops(*raw), rules=rules, resolver=FakeResolver(), new_id=sequential_ids()
+ )[0]
+
+
+def test_a_tightened_size_rule_only_applies_to_touched_nodes() -> None:
+ doc = build(add("chart-1", layout={"colSpan": 24}), add("chart-2"))
+ rules = changed_rules("chart", max_col_span=12)
+
+ result = apply_with(
+ rules, doc, {"op": "place", "id": "n1", "layout": {"colSpan": 6}}
+ )
+
+ assert result["nodes"]["n0"]["layout"]["colSpan"] == 24
+ with pytest.raises(DefinitionValidationError, match="above the maximum of 12"):
+ apply_with(rules, doc, {"op": "place", "id": "n0", "layout": {"colSpan": 24}})
+
+
+def test_a_tightened_nesting_rule_only_applies_to_touched_nodes() -> None:
+ doc = build(add("group"), add("chart-1", parent="n0"))
+ rules = changed_rules("group", accepted_children=frozenset({"filter"}))
+
+ result = apply_with(rules, doc, add("chart-2", id=GROUP_ID))
+
+ assert result["nodes"]["n0"]["children"] == ["n1"]
+ with pytest.raises(DefinitionValidationError, match="cannot be placed in group"):
+ apply_with(rules, doc, {"op": "move", "id": "n1", "parent": "n0", "index": 0})
+
+
+def test_a_layout_from_a_replaced_child_model_is_kept_as_stored() -> None:
+ doc = build(add("board"), add("chart-1", parent="n0", layout={"lane": "todo"}))
+ rules = changed_rules("board", child_layout_model=None, grid_columns=12)
+
+ result = apply_with(rules, doc, add("chart-2", id=GROUP_ID))
+
+ assert result["nodes"]["n1"]["layout"] == {"lane": "todo"}
+
+
def test_unresolved_container_keeps_its_children_but_takes_no_new_ones() -> None:
doc = build(add("group"), add("chart-1", parent="n0"), add("chart-2"))
# The group's widget type is no longer registered.
diff --git a/tests/unit_tests/canvas/upgrades_test.py b/tests/unit_tests/canvas/upgrades_test.py
new file mode 100644
index 00000000000..04d40d9c621
--- /dev/null
+++ b/tests/unit_tests/canvas/upgrades_test.py
@@ -0,0 +1,62 @@
+# 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 typing import Any
+
+import pytest
+
+from superset.canvas.definition.upgrades import (
+ DefinitionVersionError,
+ upgrade_definition,
+)
+
+
+def add_title(definition: dict[str, Any]) -> dict[str, Any]:
+ return {**definition, "title": "untitled"}
+
+
+def rename_title(definition: dict[str, Any]) -> dict[str, Any]:
+ definition["name"] = definition.pop("title")
+ return definition
+
+
+UPGRADES = {1: add_title, 2: rename_title}
+
+
+def test_current_definition_is_returned_as_is() -> None:
+ definition = {"version": 3}
+
+ assert upgrade_definition(definition, current=3, upgrades=UPGRADES) is definition
+
+
+def test_older_definition_runs_every_upgrade_in_order() -> None:
+ stored = {"version": 1, "nodes": {}}
+
+ upgraded = upgrade_definition(stored, current=3, upgrades=UPGRADES)
+
+ assert upgraded == {"version": 3, "nodes": {}, "name": "untitled"}
+ assert stored == {"version": 1, "nodes": {}}
+
+
+def test_newer_definition_is_refused() -> None:
+ with pytest.raises(DefinitionVersionError, match="Upgrade Superset"):
+ upgrade_definition({"version": 4}, current=3, upgrades=UPGRADES)
+
+
+@pytest.mark.parametrize("version", [0, "1", True, None])
+def test_malformed_version_is_refused(version: Any) -> None:
+ with pytest.raises(DefinitionVersionError, match="Unsupported"):
+ upgrade_definition({"version": version}, current=3, upgrades=UPGRADES)
(superset) 06/12: feat(canvas): scope cross-filters and customizations like filters
Posted by ms...@apache.org.
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 842ee05899c9a4e588585096b5ed1cdca5412dd6
Author: Mehmet Salih Yavuz <sa...@proton.me>
AuthorDate: Thu Oct 1 12:46:03 2026 +0300
feat(canvas): scope cross-filters and customizations like filters
Widget types can declare themselves cross-filter sources or
customizations (e.g. dynamic group-by). Each kind gets its own scope
overrides under interactions and resolves like filters: auto drives the
filterable widgets under the nearest scope-bounding container, never the
node itself, with global and custom overrides. The definition response
returns crossFilterScopes and customizationScopes alongside filterScopes;
cross-filter scopes are empty while settings.crossFilters is off.
set_filter_scope becomes set_scope with a kind. Each kind is its own
concurrency group, and removing a node prunes it from every kind.
---
superset-core/src/superset_core/canvas/base.py | 13 +--
superset/canvas/api.py | 22 +++--
superset/canvas/definition/ops.py | 62 ++++++++------
superset/canvas/definition/schemas.py | 34 ++++++--
superset/canvas/definition/scopes.py | 97 ++++++++++++++--------
superset/canvas/definition/validation.py | 66 +++++++++------
superset/commands/canvas/apply_ops.py | 9 ++-
tests/unit_tests/canvas/api_test.py | 2 +
tests/unit_tests/canvas/fixtures.py | 17 ++++
tests/unit_tests/canvas/scopes_test.py | 107 ++++++++++++++++++++++---
tests/unit_tests/canvas/validation_test.py | 3 +-
11 files changed, 317 insertions(+), 115 deletions(-)
diff --git a/superset-core/src/superset_core/canvas/base.py b/superset-core/src/superset_core/canvas/base.py
index 0e1fd95b0ce..23b4a81bd80 100644
--- a/superset-core/src/superset_core/canvas/base.py
+++ b/superset-core/src/superset_core/canvas/base.py
@@ -83,12 +83,15 @@ class CanvasLayoutRules:
grid_columns: ClassVar[int | None] = None
child_layout_model: ClassVar[type[BaseModel] | None] = None
- # Filters drive filterable widgets. By default a filter drives every
- # filterable widget under its nearest container that bounds filter scope
- # (or the whole canvas at the root); the canvas can override that per
- # filter. A container that does not bound scope, e.g. a filter bar, lets
- # its filters reach further up.
+ # Filters, cross-filter sources and customizations (e.g. dynamic group-by)
+ # drive filterable widgets. By default each drives every filterable widget
+ # under its nearest container that bounds filter scope (or the whole canvas
+ # at the root), never itself; the canvas can override that per node. A
+ # container that does not bound scope, e.g. a filter bar, lets its
+ # children reach further up.
is_filter: ClassVar[bool] = False
+ is_cross_filter_source: ClassVar[bool] = False
+ is_customization: ClassVar[bool] = False
is_filterable: ClassVar[bool] = False
bounds_filter_scope: ClassVar[bool] = True
diff --git a/superset/canvas/api.py b/superset/canvas/api.py
index e365154663c..f1c460ea316 100644
--- a/superset/canvas/api.py
+++ b/superset/canvas/api.py
@@ -39,7 +39,7 @@ from superset.canvas.definition.schemas import (
DEFINITION_VERSION,
Operation,
)
-from superset.canvas.definition.scopes import resolve_filter_scopes
+from superset.canvas.definition.scopes import resolve_scopes
from superset.canvas.definition.upgrades import DefinitionVersionError
from superset.canvas.filters import (
CanvasAccessFilter,
@@ -128,6 +128,8 @@ def describe_rules(rules: type[CanvasLayoutRules]) -> dict[str, Any]:
if rules.child_layout_model is not None
else None,
"isFilter": rules.is_filter,
+ "isCrossFilterSource": rules.is_cross_filter_source,
+ "isCustomization": rules.is_customization,
"isFilterable": rules.is_filterable,
"boundsFilterScope": rules.bounds_filter_scope,
"colSpan": {"min": rules.min_col_span, "max": rules.max_col_span},
@@ -447,6 +449,14 @@ class CanvasRestApi(BaseSupersetModelRestApi):
description: >-
The node ids each filter drives, resolved from
the tree and the definition's scope overrides
+ crossFilterScopes:
+ type: object
+ description: >-
+ The node ids each cross-filter source drives;
+ empty while cross-filters are turned off
+ customizationScopes:
+ type: object
+ description: The node ids each customization drives
placements:
type: object
description: >-
@@ -471,7 +481,7 @@ class CanvasRestApi(BaseSupersetModelRestApi):
"version": canvas.definition_version,
"revision": canvas.revision,
"definition": definition,
- "filterScopes": resolve_filter_scopes(definition),
+ **resolve_scopes(definition),
**render_context(definition),
},
)
@@ -565,9 +575,9 @@ class CanvasRestApi(BaseSupersetModelRestApi):
description: >-
Applies `ops` in order to the latest definition, atomically. Changes
made since `base_revision` are merged unless they touched the same
- node and field group (layout or filter scope), or moved or removed
- an edited node; then the whole request is rejected with 409 and the
- conflicting node ids.
+ node and field group (layout, a scope kind, or a settings
+ section), or moved or removed an edited node; then the whole
+ request is rejected with 409 and the conflicting node ids.
parameters:
- in: path
schema:
@@ -613,7 +623,7 @@ class CanvasRestApi(BaseSupersetModelRestApi):
result={
"revision": result.revision,
"ops": result.ops,
- "filterScopes": result.filter_scopes,
+ **result.scopes,
**result.render_context,
},
)
diff --git a/superset/canvas/definition/ops.py b/superset/canvas/definition/ops.py
index d8edf954a8f..c65e94f4925 100644
--- a/superset/canvas/definition/ops.py
+++ b/superset/canvas/definition/ops.py
@@ -46,7 +46,8 @@ from superset.canvas.definition.schemas import (
PlaceOp,
RemoveOp,
ROOT_ID,
- SetFilterScopeOp,
+ SCOPE_FIELDS,
+ SetScopeOp,
SetSettingsOp,
)
from superset.canvas.definition.validation import normalize_definition
@@ -56,12 +57,22 @@ class FieldGroup(str, Enum):
# Existence and position in the tree; overlaps with every other group.
TREE = "tree"
LAYOUT = "layout"
- # A filter's scope override.
- SCOPE = "scope"
+ # A node's scope override, per kind.
+ FILTER_SCOPE = "filterScope"
+ CROSS_FILTER_SCOPE = "crossFilterScope"
+ CUSTOMIZATION_SCOPE = "customizationScope"
# Sections of the canvas settings, touched on ``SETTINGS_ID``.
REFRESH = "refresh"
COLORS = "colors"
DISPLAY = "display"
+ CROSS_FILTERS = "crossFilters"
+
+
+SCOPE_GROUPS = {
+ "filter": FieldGroup.FILTER_SCOPE,
+ "crossFilter": FieldGroup.CROSS_FILTER_SCOPE,
+ "customization": FieldGroup.CUSTOMIZATION_SCOPE,
+}
# Stands in for the settings in touches; never a node id, which is a UUID.
@@ -119,8 +130,8 @@ def named_touches(ops: Iterable[Operation]) -> list[Touch]:
touches += [Touch(op.id, FieldGroup.TREE), Touch(op.id, FieldGroup.LAYOUT)]
elif isinstance(op, PlaceOp):
touches.append(Touch(op.id, FieldGroup.LAYOUT))
- elif isinstance(op, SetFilterScopeOp):
- touches.append(Touch(op.id, FieldGroup.SCOPE))
+ elif isinstance(op, SetScopeOp):
+ touches.append(Touch(op.id, SCOPE_GROUPS[op.kind]))
elif isinstance(op, SetSettingsOp):
touches.append(Touch(SETTINGS_ID, FieldGroup(op.key)))
return touches
@@ -213,16 +224,16 @@ class _Applier:
)
if isinstance(op, MoveOp):
return self.move(op, logged)
- if isinstance(op, SetFilterScopeOp):
+ if isinstance(op, SetScopeOp):
self.node(op.id)
- filters = self.scopes()
+ overrides = self.scopes(op.kind)
if op.scope is None:
- filters.pop(op.id, None)
+ overrides.pop(op.id, None)
else:
- filters[op.id] = op.scope.model_dump(
+ overrides[op.id] = op.scope.model_dump(
mode="json", by_alias=True, exclude_none=True
)
- return AppliedOperation(logged, [Touch(op.id, FieldGroup.SCOPE)])
+ return AppliedOperation(logged, [Touch(op.id, SCOPE_GROUPS[op.kind])])
if isinstance(op, SetSettingsOp):
settings = self.canvas.setdefault("settings", {})
if op.value is None:
@@ -234,23 +245,26 @@ class _Applier:
self.node(op.id)["layout"] = op.layout
return AppliedOperation(logged, [Touch(op.id, FieldGroup.LAYOUT)])
- def scopes(self) -> dict[str, Any]:
- return self.canvas.setdefault("interactions", {}).setdefault("filters", {})
+ def scopes(self, kind: str) -> dict[str, Any]:
+ interactions = self.canvas.setdefault("interactions", {})
+ return interactions.setdefault(SCOPE_FIELDS[kind], {})
def _prune_scopes(self, removed: set[str]) -> list[Touch]:
- """Drop removed nodes from filter scope overrides."""
- filters = self.scopes()
+ """Drop removed nodes from every kind of scope override."""
touched = []
- for filter_id in list(filters):
- if filter_id in removed:
- del filters[filter_id]
- continue
- scope = filters[filter_id]
- for field_name in ("targets", "exclude"):
- kept = [n for n in scope.get(field_name, []) if n not in removed]
- if len(kept) != len(scope.get(field_name, [])):
- scope[field_name] = kept
- touched.append(Touch(filter_id, FieldGroup.SCOPE))
+ for kind, group in SCOPE_GROUPS.items():
+ overrides = self.scopes(kind)
+ for owner_id in list(overrides):
+ if owner_id in removed:
+ del overrides[owner_id]
+ continue
+ scope = overrides[owner_id]
+ for field_name in ("targets", "exclude"):
+ listed = scope.get(field_name, [])
+ kept = [n for n in listed if n not in removed]
+ if len(kept) != len(listed):
+ scope[field_name] = kept
+ touched.append(Touch(owner_id, group))
return touched
def _prune_settings(self, removed: set[str]) -> list[Touch]:
diff --git a/superset/canvas/definition/schemas.py b/superset/canvas/definition/schemas.py
index 726627e8bf1..1bc55ba31ba 100644
--- a/superset/canvas/definition/schemas.py
+++ b/superset/canvas/definition/schemas.py
@@ -93,9 +93,21 @@ class FilterScope(_Model):
return self
+ScopeKind = Literal["filter", "crossFilter", "customization"]
+
+# Where each kind's scope overrides live in ``interactions``.
+SCOPE_FIELDS: dict[str, str] = {
+ "filter": "filters",
+ "crossFilter": "crossFilters",
+ "customization": "customizations",
+}
+
+
class Interactions(_Model):
- # Scope overrides, by filter node id. Filters without one use auto scope.
+ # Scope overrides by node id, per kind. Nodes without one use auto scope.
filters: dict[str, FilterScope] = Field(default_factory=dict)
+ cross_filters: dict[str, FilterScope] = Field(default_factory=dict)
+ customizations: dict[str, FilterScope] = Field(default_factory=dict)
class RefreshSettings(_Model):
@@ -122,13 +134,19 @@ class DisplaySettings(_Model):
show_timestamps: bool = False
+class CrossFilterSettings(_Model):
+ # Whether clicking a cross-filter source filters other widgets.
+ enabled: bool = True
+
+
class Settings(_Model):
refresh: RefreshSettings = Field(default_factory=RefreshSettings)
colors: ColorSettings = Field(default_factory=ColorSettings)
display: DisplaySettings = Field(default_factory=DisplaySettings)
+ cross_filters: CrossFilterSettings = Field(default_factory=CrossFilterSettings)
-SettingsKey = Literal["refresh", "colors", "display"]
+SettingsKey = Literal["refresh", "colors", "display", "crossFilters"]
class CanvasDefinition(_Model):
@@ -186,10 +204,14 @@ class PlaceOp(_Model):
layout: dict[str, Any]
-class SetFilterScopeOp(_Model):
- """Override which widgets a filter drives; ``scope: null`` restores auto."""
+class SetScopeOp(_Model):
+ """
+ Override which widgets a filter, cross-filter source or customization
+ drives; ``scope: null`` restores auto.
+ """
- op: Literal["set_filter_scope"]
+ op: Literal["set_scope"]
+ kind: ScopeKind
id: str
scope: FilterScope | None
@@ -203,7 +225,7 @@ class SetSettingsOp(_Model):
Operation = Annotated[
- Union[AddOp, RemoveOp, MoveOp, PlaceOp, SetFilterScopeOp, SetSettingsOp],
+ Union[AddOp, RemoveOp, MoveOp, PlaceOp, SetScopeOp, SetSettingsOp],
Field(discriminator="op"),
]
diff --git a/superset/canvas/definition/scopes.py b/superset/canvas/definition/scopes.py
index 5556bdca4d8..2fe94c996de 100644
--- a/superset/canvas/definition/scopes.py
+++ b/superset/canvas/definition/scopes.py
@@ -15,17 +15,18 @@
# specific language governing permissions and limitations
# under the License.
"""
-Which widgets each filter on a canvas drives.
+Which widgets each filter, cross-filter source and customization drives.
-By default a filter drives every filterable widget under its nearest ancestor
-container that bounds filter scope, or the whole canvas when there is none. A
-filter in a tab drives that tab; a filter in a filter bar that does not bound
-scope drives the whole canvas. A per-filter override in
-``interactions.filters`` can make the scope global or name the targets, and
-can exclude nodes from an auto or global scope.
+By default a node drives every filterable widget under its nearest ancestor
+container that bounds filter scope, or the whole canvas when there is none,
+never itself. A filter in a tab drives that tab; a filter in a filter bar that
+does not bound scope drives the whole canvas. A per-node override in
+``interactions`` can make the scope global or name the targets, and can
+exclude nodes from an auto or global scope.
Only filterable widgets are ever driven, and nodes whose widget or type no
-longer resolves are skipped.
+longer resolves are skipped. Cross-filter scopes are empty while cross-filters
+are turned off in the canvas settings.
"""
from __future__ import annotations
@@ -39,7 +40,24 @@ from superset.canvas.definition.registry import (
layout_rules,
LayoutRulesRegistry,
)
-from superset.canvas.definition.schemas import FilterScopeMode, ROOT_ID
+from superset.canvas.definition.schemas import (
+ FilterScopeMode,
+ ROOT_ID,
+ SCOPE_FIELDS,
+)
+
+# The layout rule that makes a node own each kind of scope.
+_ROLES = {
+ "filter": "is_filter",
+ "crossFilter": "is_cross_filter_source",
+ "customization": "is_customization",
+}
+# Response keys for each kind's resolved scopes.
+SCOPE_RESULT_KEYS = {
+ "filter": "filterScopes",
+ "crossFilter": "crossFilterScopes",
+ "customization": "customizationScopes",
+}
def _reading_order(canvas: dict[str, Any], start: str) -> list[str]:
@@ -56,12 +74,15 @@ def _reading_order(canvas: dict[str, Any], start: str) -> list[str]:
return ordered
-def resolve_filter_scopes(
+def resolve_scopes(
canvas: dict[str, Any],
rules: LayoutRulesRegistry | None = None,
resolver: WidgetResolver | None = None,
-) -> dict[str, list[str]]:
- """Map each filter node id to the node ids it drives, in reading order."""
+) -> dict[str, dict[str, list[str]]]:
+ """
+ Map each kind's response key (``filterScopes``, ``crossFilterScopes``,
+ ``customizationScopes``) to ``{node id: driven node ids}``, in reading order.
+ """
rules = rules or layout_rules
resolver = resolver or get_widget_resolver()
nodes = canvas["nodes"]
@@ -77,29 +98,39 @@ def resolve_filter_scopes(
for child in _children(canvas, parent)
}
filterable = {n for n, r in node_rules.items() if r.is_filterable}
- overrides = canvas.get("interactions", {}).get("filters", {})
+ interactions = canvas.get("interactions", {})
+ cross_filters_on = (
+ canvas.get("settings", {}).get("crossFilters", {}).get("enabled", True)
+ )
- scopes: dict[str, list[str]] = {}
- for node_id, node_rule in node_rules.items():
- if not node_rule.is_filter:
- continue
- override = overrides.get(node_id, {})
- mode = FilterScopeMode(override.get("mode", FilterScopeMode.AUTO))
- if mode == FilterScopeMode.CUSTOM:
- scopes[node_id] = [t for t in override["targets"] if t in filterable]
+ resolved: dict[str, dict[str, list[str]]] = {}
+ for kind, role in _ROLES.items():
+ scopes: dict[str, list[str]] = {}
+ resolved[SCOPE_RESULT_KEYS[kind]] = scopes
+ if kind == "crossFilter" and not cross_filters_on:
continue
- boundary = (
- ROOT_ID
- if mode == FilterScopeMode.GLOBAL
- else _scope_boundary(node_id, parents, node_rules)
- )
- excluded = set(override.get("exclude", []))
- scopes[node_id] = [
- n
- for n in _reading_order(canvas, boundary)
- if n in filterable and n not in excluded
- ]
- return scopes
+ overrides = interactions.get(SCOPE_FIELDS[kind], {})
+ for node_id, node_rule in node_rules.items():
+ if not getattr(node_rule, role):
+ continue
+ override = overrides.get(node_id, {})
+ mode = FilterScopeMode(override.get("mode", FilterScopeMode.AUTO))
+ if mode == FilterScopeMode.CUSTOM:
+ candidates = override["targets"]
+ else:
+ boundary = (
+ ROOT_ID
+ if mode == FilterScopeMode.GLOBAL
+ else _scope_boundary(node_id, parents, node_rules)
+ )
+ excluded = set(override.get("exclude", []))
+ candidates = [
+ n for n in _reading_order(canvas, boundary) if n not in excluded
+ ]
+ scopes[node_id] = [
+ n for n in candidates if n in filterable and n != node_id
+ ]
+ return resolved
def _children(canvas: dict[str, Any], parent: str) -> list[str]:
diff --git a/superset/canvas/definition/validation.py b/superset/canvas/definition/validation.py
index 0999ec6492b..03ec67dda41 100644
--- a/superset/canvas/definition/validation.py
+++ b/superset/canvas/definition/validation.py
@@ -47,11 +47,22 @@ from superset.canvas.definition.registry import (
layout_rules,
LayoutRulesRegistry,
)
-from superset.canvas.definition.schemas import CanvasDefinition, ROOT_ID
+from superset.canvas.definition.schemas import (
+ CanvasDefinition,
+ ROOT_ID,
+ SCOPE_FIELDS,
+)
MAX_NODES = 500
MAX_DEPTH = 8
+# The layout rule a node needs to own each kind of scope override.
+SCOPE_ROLES = {
+ "filter": ("is_filter", "a filter"),
+ "crossFilter": ("is_cross_filter_source", "a cross-filter source"),
+ "customization": ("is_customization", "a customization"),
+}
+
@dataclass(frozen=True)
class Issue:
@@ -213,31 +224,34 @@ class _Validator:
def _check_interactions(self, doc: dict[str, Any]) -> None:
nodes = doc["nodes"]
- for filter_id, scope in doc["interactions"]["filters"].items():
- path = ("interactions", "filters", filter_id)
- if filter_id not in nodes:
- self.fail(pointer(*path), f"unknown node {filter_id!r}")
- continue
- strict = self.is_strict(filter_id)
- rules = self.node_rules.get(filter_id)
- if strict and rules is not None and not rules.is_filter:
- self.fail(pointer(*path), f"node {filter_id!r} is not a filter")
- for field in ("targets", "exclude"):
- for index, target in enumerate(scope[field]):
- target_rules = self.node_rules.get(target)
- if target not in nodes:
- self.fail(
- pointer(*path, field, index), f"unknown node {target!r}"
- )
- elif (
- strict
- and target_rules is not None
- and not target_rules.is_filterable
- ):
- self.fail(
- pointer(*path, field, index),
- f"node {target!r} cannot be filtered",
- )
+ for kind, field_name in SCOPE_FIELDS.items():
+ role, noun = SCOPE_ROLES[kind]
+ for owner_id, scope in doc["interactions"][field_name].items():
+ path = ("interactions", field_name, owner_id)
+ if owner_id not in nodes:
+ self.fail(pointer(*path), f"unknown node {owner_id!r}")
+ continue
+ strict = self.is_strict(owner_id)
+ rules = self.node_rules.get(owner_id)
+ if strict and rules is not None and not getattr(rules, role):
+ self.fail(pointer(*path), f"node {owner_id!r} is not {noun}")
+ for field in ("targets", "exclude"):
+ for index, target in enumerate(scope[field]):
+ target_rules = self.node_rules.get(target)
+ if target not in nodes:
+ self.fail(
+ pointer(*path, field, index),
+ f"unknown node {target!r}",
+ )
+ elif (
+ strict
+ and target_rules is not None
+ and not target_rules.is_filterable
+ ):
+ self.fail(
+ pointer(*path, field, index),
+ f"node {target!r} cannot be filtered",
+ )
def _check_settings(self, doc: dict[str, Any]) -> None:
exempt = doc["settings"]["refresh"]["exempt"]
diff --git a/superset/commands/canvas/apply_ops.py b/superset/commands/canvas/apply_ops.py
index 790af1883b6..9f49215e419 100644
--- a/superset/commands/canvas/apply_ops.py
+++ b/superset/commands/canvas/apply_ops.py
@@ -36,7 +36,7 @@ from superset.canvas.definition.ops import (
from superset.canvas.definition.registry import get_widget_resolver
from superset.canvas.definition.render import render_context
from superset.canvas.definition.schemas import AddOp, DEFINITION_VERSION, Operation
-from superset.canvas.definition.scopes import resolve_filter_scopes
+from superset.canvas.definition.scopes import resolve_scopes
from superset.canvas.definition.validation import (
DefinitionValidationError,
Issue,
@@ -69,8 +69,9 @@ class ApplyResult:
revision: int
# Operations as applied; ``add`` entries carry the new node's id.
ops: list[dict[str, Any]]
- # Node ids each filter drives after the write.
- filter_scopes: dict[str, list[str]]
+ # Node ids each filter, cross-filter source and customization drives
+ # after the write, keyed like the API response.
+ scopes: dict[str, dict[str, list[str]]]
# Resolved placements and widget types after the write.
render_context: dict[str, Any]
@@ -142,7 +143,7 @@ class ApplyCanvasOperationsCommand(BaseCommand):
return ApplyResult(
revision=revision,
ops=[a.op for a in applied],
- filter_scopes=resolve_filter_scopes(definition),
+ scopes=resolve_scopes(definition),
render_context=render_context(definition),
)
diff --git a/tests/unit_tests/canvas/api_test.py b/tests/unit_tests/canvas/api_test.py
index 18185df60d4..086958d4cda 100644
--- a/tests/unit_tests/canvas/api_test.py
+++ b/tests/unit_tests/canvas/api_test.py
@@ -114,6 +114,8 @@ def test_get_definition(client: Any, full_api_access: None, canvas: Any) -> None
assert response.json["result"]["revision"] == 1
assert response.json["result"]["definition"]["nodes"] == {}
assert response.json["result"]["filterScopes"] == {}
+ assert response.json["result"]["crossFilterScopes"] == {}
+ assert response.json["result"]["customizationScopes"] == {}
assert response.json["result"]["placements"] == {}
assert response.json["result"]["widgetTypes"] == {}
assert response.json["result"]["gridColumns"] == {}
diff --git a/tests/unit_tests/canvas/fixtures.py b/tests/unit_tests/canvas/fixtures.py
index d098d2da28a..0a18e5aa3c0 100644
--- a/tests/unit_tests/canvas/fixtures.py
+++ b/tests/unit_tests/canvas/fixtures.py
@@ -64,6 +64,21 @@ class ChartRules(CanvasLayoutRules):
is_filterable = True
+class CrossFilterChartRules(CanvasLayoutRules):
+ """A chart that can also cross-filter others."""
+
+ widget_type = "xchart"
+ is_filterable = True
+ is_cross_filter_source = True
+
+
+class GroupByRules(CanvasLayoutRules):
+ """A customization, e.g. a dynamic group-by control."""
+
+ widget_type = "groupby"
+ is_customization = True
+
+
class FilterRules(CanvasLayoutRules):
widget_type = "filter"
is_filter = True
@@ -87,6 +102,8 @@ def canvas_rules() -> LayoutRulesRegistry:
TabRules,
BoardRules,
ChartRules,
+ CrossFilterChartRules,
+ GroupByRules,
FilterRules,
FilterBarRules,
):
diff --git a/tests/unit_tests/canvas/scopes_test.py b/tests/unit_tests/canvas/scopes_test.py
index d5d808f4a89..4aba73ada68 100644
--- a/tests/unit_tests/canvas/scopes_test.py
+++ b/tests/unit_tests/canvas/scopes_test.py
@@ -24,7 +24,7 @@ from superset.canvas.definition.ops import (
overlapping,
Touch,
)
-from superset.canvas.definition.scopes import resolve_filter_scopes
+from superset.canvas.definition.scopes import resolve_scopes
from superset.canvas.definition.validation import (
DefinitionValidationError,
normalize_definition,
@@ -38,11 +38,13 @@ from tests.unit_tests.canvas.fixtures import (
)
-def scopes(raw: dict[str, Any]) -> dict[str, list[str]]:
+def all_scopes(raw: dict[str, Any]) -> dict[str, dict[str, list[str]]]:
rules, resolver = canvas_rules(), FakeResolver()
- return resolve_filter_scopes(
- normalize_definition(raw, rules, resolver), rules, resolver
- )
+ return resolve_scopes(normalize_definition(raw, rules, resolver), rules, resolver)
+
+
+def scopes(raw: dict[str, Any]) -> dict[str, list[str]]:
+ return all_scopes(raw)["filterScopes"]
def analyst_view(**interactions: Any) -> dict[str, Any]:
@@ -148,7 +150,8 @@ def test_set_filter_scope_and_restore_auto() -> None:
doc, applied = apply(
analyst_view(),
{
- "op": "set_filter_scope",
+ "op": "set_scope",
+ "kind": "filter",
"id": "f2",
"scope": {"mode": "custom", "targets": ["c3"]},
},
@@ -159,9 +162,11 @@ def test_set_filter_scope_and_restore_auto() -> None:
"targets": ["c3"],
"exclude": [],
}
- assert applied[0].touched == [Touch("f2", FieldGroup.SCOPE)]
+ assert applied[0].touched == [Touch("f2", FieldGroup.FILTER_SCOPE)]
- doc, _ = apply(doc, {"op": "set_filter_scope", "id": "f2", "scope": None})
+ doc, _ = apply(
+ doc, {"op": "set_scope", "kind": "filter", "id": "f2", "scope": None}
+ )
assert doc["interactions"]["filters"] == {}
@@ -177,7 +182,7 @@ def test_removing_nodes_prunes_scope_overrides() -> None:
assert doc["interactions"]["filters"]["f1"]["targets"] == ["c1"]
assert doc["interactions"]["filters"]["f2"]["exclude"] == []
- assert Touch("f1", FieldGroup.SCOPE) in applied[0].touched
+ assert Touch("f1", FieldGroup.FILTER_SCOPE) in applied[0].touched
doc, _ = apply(doc, {"op": "remove", "id": "section"})
@@ -185,7 +190,89 @@ def test_removing_nodes_prunes_scope_overrides() -> None:
def test_scope_edits_conflict_only_with_scope_edits() -> None:
- scope = [Touch("f1", FieldGroup.SCOPE)]
+ scope = [Touch("f1", FieldGroup.FILTER_SCOPE)]
assert overlapping(scope, [Touch("f1", FieldGroup.LAYOUT)]) == set()
assert overlapping(scope, scope) == {"f1"}
+
+
+def sales_view(**settings: Any) -> dict[str, Any]:
+ """Two cross-filtering charts and a group-by in a tab, plus a chart outside."""
+ raw = canvas(
+ {
+ "tabs": node("tabs", children=["tab"]),
+ "tab": node("tab", children=["x1", "x2", "g1"]),
+ "x1": node("xchart-1"),
+ "x2": node("xchart-2"),
+ "g1": node("groupby-1"),
+ "c1": node("chart-1"),
+ },
+ children=["tabs", "c1"],
+ )
+ return {**raw, "settings": settings}
+
+
+def test_cross_filters_and_customizations_scope_like_filters() -> None:
+ resolved = all_scopes(sales_view())
+
+ assert resolved["crossFilterScopes"] == {"x1": ["x2"], "x2": ["x1"]}
+ assert resolved["customizationScopes"] == {"g1": ["x1", "x2"]}
+ assert resolved["filterScopes"] == {}
+
+
+def test_cross_filter_scopes_are_empty_while_turned_off() -> None:
+ resolved = all_scopes(sales_view(crossFilters={"enabled": False}))
+
+ assert resolved["crossFilterScopes"] == {}
+ assert resolved["customizationScopes"] == {"g1": ["x1", "x2"]}
+
+
+def test_scope_kinds_are_set_and_merged_independently() -> None:
+ doc, applied = apply(
+ sales_view(),
+ {
+ "op": "set_scope",
+ "kind": "crossFilter",
+ "id": "x1",
+ "scope": {"mode": "global"},
+ },
+ )
+
+ assert doc["interactions"]["crossFilters"]["x1"]["mode"] == "global"
+ assert applied[0].touched == [Touch("x1", FieldGroup.CROSS_FILTER_SCOPE)]
+ assert (
+ overlapping(applied[0].touched, [Touch("x1", FieldGroup.FILTER_SCOPE)]) == set()
+ )
+ rules, resolver = canvas_rules(), FakeResolver()
+ assert resolve_scopes(doc, rules, resolver)["crossFilterScopes"]["x1"] == [
+ "x2",
+ "c1",
+ ]
+
+
+def test_scope_override_needs_the_matching_role() -> None:
+ with pytest.raises(DefinitionValidationError, match="is not a customization"):
+ apply(
+ sales_view(),
+ {
+ "op": "set_scope",
+ "kind": "customization",
+ "id": "x1",
+ "scope": {"mode": "global"},
+ },
+ )
+
+
+def test_removing_nodes_prunes_every_scope_kind() -> None:
+ raw = sales_view()
+ raw["interactions"] = {
+ "crossFilters": {"x1": {"mode": "custom", "targets": ["x2", "c1"]}},
+ "customizations": {"g1": {"mode": "auto", "exclude": ["x2"]}},
+ }
+
+ doc, applied = apply(raw, {"op": "remove", "id": "x2"})
+
+ assert doc["interactions"]["crossFilters"]["x1"]["targets"] == ["c1"]
+ assert doc["interactions"]["customizations"]["g1"]["exclude"] == []
+ assert Touch("x1", FieldGroup.CROSS_FILTER_SCOPE) in applied[0].touched
+ assert Touch("g1", FieldGroup.CUSTOMIZATION_SCOPE) in applied[0].touched
diff --git a/tests/unit_tests/canvas/validation_test.py b/tests/unit_tests/canvas/validation_test.py
index 63eab034496..a9ec1e1035f 100644
--- a/tests/unit_tests/canvas/validation_test.py
+++ b/tests/unit_tests/canvas/validation_test.py
@@ -46,11 +46,12 @@ def test_empty_canvas_is_valid() -> None:
"version": 1,
"root": {"layout": {"columns": 24, "gap": 16, "rowUnit": 40}, "children": []},
"nodes": {},
- "interactions": {"filters": {}},
+ "interactions": {"filters": {}, "crossFilters": {}, "customizations": {}},
"settings": {
"refresh": {"interval": 0, "stagger": 0, "exempt": []},
"colors": {"labelColors": {}},
"display": {"showTimestamps": False},
+ "crossFilters": {"enabled": True},
},
}
(superset) 11/12: feat(canvas): allow a widget to be placed more than once
Posted by ms...@apache.org.
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 146af62670d38e739e8261f63fa67e92a32cd721
Author: Mehmet Salih Yavuz <sa...@proton.me>
AuthorDate: Thu Oct 1 17:10:52 2026 +0300
feat(canvas): allow a widget to be placed more than once
Placement, scopes, filter values and refresh exemptions are all keyed by
node, so two nodes can reference the same widget without sharing any
canvas state.
---
superset/canvas/definition/validation.py | 13 ++++---------
tests/unit_tests/canvas/validation_test.py | 11 +++++++----
2 files changed, 11 insertions(+), 13 deletions(-)
diff --git a/superset/canvas/definition/validation.py b/superset/canvas/definition/validation.py
index d04a5440e71..b485e27547d 100644
--- a/superset/canvas/definition/validation.py
+++ b/superset/canvas/definition/validation.py
@@ -17,9 +17,8 @@
"""
Whole-canvas validation and normalization.
-Checks the envelope, tree integrity, that every widget is placed only once, the
-nesting rules of widget types, each child's layout under its parent, and
-resolves grid collisions.
+Checks the envelope, tree integrity, the nesting rules of widget types and each
+child's layout under its parent, and resolves grid collisions.
A node whose widget no longer exists, or whose container type is no longer
registered (e.g. its extension was removed), is kept as an unresolved
@@ -170,14 +169,10 @@ class _Validator:
def _check_widgets(self, doc: dict[str, Any]) -> None:
nodes = doc["nodes"]
types = self.resolver.widget_types({node["widget"] for node in nodes.values()})
- placed: dict[str, str] = {}
+ # A widget may be placed more than once; everything the canvas tracks
+ # (placement, scopes, values) is keyed by node.
for node_id, node in nodes.items():
widget_id = node["widget"]
- path = pointer("nodes", node_id, "widget")
- if widget_id in placed:
- self.fail(path, f"widget {widget_id!r} is already placed")
- continue
- placed[widget_id] = node_id
widget_type = types.get(widget_id)
if widget_type is None:
self.unresolved.add(node_id)
diff --git a/tests/unit_tests/canvas/validation_test.py b/tests/unit_tests/canvas/validation_test.py
index 681b0e5ef60..c42d4c9da8e 100644
--- a/tests/unit_tests/canvas/validation_test.py
+++ b/tests/unit_tests/canvas/validation_test.py
@@ -84,10 +84,13 @@ def test_missing_widgets_stay_as_placeholders() -> None:
assert result["nodes"]["b"]["layout"] == {"anything": "kept"}
-def test_a_widget_is_placed_once() -> None:
- assert issues(canvas({"a": node("chart-1"), "b": node("chart-1")})) == [
- ("/nodes/b/widget", "widget 'chart-1' is already placed")
- ]
+def test_a_widget_can_be_placed_more_than_once() -> None:
+ raw = canvas({"a": node("chart-1", layout={"colSpan": 8}), "b": node("chart-1")})
+
+ result = normalize(raw)
+
+ assert result["nodes"]["a"]["widget"] == result["nodes"]["b"]["widget"]
+ assert result["nodes"]["a"]["layout"] == {"colSpan": 8}
def test_unknown_child_and_orphan() -> None:
(superset) 09/12: docs(canvas): placement is resolved server-side only
Posted by ms...@apache.org.
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 247d90166e4ffead4abd09033d77fe330cf96f25
Author: Mehmet Salih Yavuz <sa...@proton.me>
AuthorDate: Thu Oct 1 12:55:37 2026 +0300
docs(canvas): placement is resolved server-side only
---
superset/canvas/definition/grid.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/superset/canvas/definition/grid.py b/superset/canvas/definition/grid.py
index d360249753c..f9d915af05a 100644
--- a/superset/canvas/definition/grid.py
+++ b/superset/canvas/definition/grid.py
@@ -26,7 +26,8 @@ Children are resolved in reading order (their order in ``children``):
fits its span, scanning row by row from a cursor that only moves forward.
Its layout keeps ``col``/``row`` omitted.
-The frontend renderer must resolve placement with the same rules.
+Clients don't re-implement these rules: the definition response carries every
+node's resolved placement (see ``render``).
"""
from __future__ import annotations
(superset) 10/12: feat(canvas): drop the arbitrary definition limits
Posted by ms...@apache.org.
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 bf7d0fe3f608e2d3ca78713b49442fffcfda6f3d
Author: Mehmet Salih Yavuz <sa...@proton.me>
AuthorDate: Thu Oct 1 15:42:17 2026 +0300
feat(canvas): drop the arbitrary definition limits
The caps on node count, nesting depth, operations per request, widget
id length and the upper bounds on grid and settings values were never
calibrated against real dashboards, so they're removed. Only structural
minimums remain. Reading order is computed iteratively so deep trees
can't exhaust the recursion limit.
---
superset/canvas/definition/schemas.py | 20 +++++++++-----------
superset/canvas/definition/scopes.py | 13 +++++--------
superset/canvas/definition/validation.py | 17 -----------------
tests/unit_tests/canvas/validation_test.py | 13 +++++++++++++
4 files changed, 27 insertions(+), 36 deletions(-)
diff --git a/superset/canvas/definition/schemas.py b/superset/canvas/definition/schemas.py
index 1bc55ba31ba..0522f2a865d 100644
--- a/superset/canvas/definition/schemas.py
+++ b/superset/canvas/definition/schemas.py
@@ -46,9 +46,9 @@ class _Model(BaseModel):
class RootLayout(_Model):
- columns: int = Field(default=24, ge=1, le=48)
- gap: int = Field(default=16, ge=0, le=64)
- row_unit: int = Field(default=40, ge=8, le=400)
+ columns: int = Field(default=24, ge=1)
+ gap: int = Field(default=16, ge=0)
+ row_unit: int = Field(default=40, ge=1)
class Root(_Model):
@@ -58,7 +58,7 @@ class Root(_Model):
class Node(_Model):
# Id of the placed widget.
- widget: str = Field(min_length=1, max_length=64)
+ widget: str = Field(min_length=1)
# Placement within the parent, validated against the parent's rules.
layout: dict[str, Any] = Field(default_factory=dict)
# Present only when the widget is a container.
@@ -112,10 +112,10 @@ class Interactions(_Model):
class RefreshSettings(_Model):
# Seconds between automatic refreshes of every widget's data; 0 is off.
- interval: int = Field(default=0, ge=0, le=7 * 24 * 60 * 60)
+ interval: int = Field(default=0, ge=0)
# Milliseconds a refresh is spread over across widgets; 0 refreshes all at
# once.
- stagger: int = Field(default=0, ge=0, le=10 * 60 * 1000)
+ stagger: int = Field(default=0, ge=0)
# Nodes left out of automatic refresh.
exempt: list[str] = Field(default_factory=list)
@@ -124,9 +124,7 @@ class ColorSettings(_Model):
# Categorical color scheme shared by every widget; the default when unset.
scheme: str | None = Field(default=None, min_length=1, max_length=255)
# Fixed colors for series labels across widgets, e.g. {"France": "#1f77b4"}.
- label_colors: dict[str, Annotated[str, Field(max_length=64)]] = Field(
- default_factory=dict, max_length=1000
- )
+ label_colors: dict[str, str] = Field(default_factory=dict)
class DisplaySettings(_Model):
@@ -171,7 +169,7 @@ class AddOp(_Model):
op: Literal["add"]
id: str | None = Field(default=None, pattern=NODE_ID_PATTERN)
- widget: str = Field(min_length=1, max_length=64)
+ widget: str = Field(min_length=1)
layout: dict[str, Any] = Field(default_factory=dict)
parent: str = ROOT_ID
# Position in the parent's children (reading order); appended when omitted.
@@ -235,4 +233,4 @@ class ApplyOperationsRequest(BaseModel):
# The revision the caller's view of the canvas is based on.
base_revision: int = Field(ge=0)
- ops: list[Operation] = Field(min_length=1, max_length=200)
+ ops: list[Operation] = Field(min_length=1)
diff --git a/superset/canvas/definition/scopes.py b/superset/canvas/definition/scopes.py
index 2fe94c996de..1ad677451f4 100644
--- a/superset/canvas/definition/scopes.py
+++ b/superset/canvas/definition/scopes.py
@@ -62,15 +62,12 @@ SCOPE_RESULT_KEYS = {
def _reading_order(canvas: dict[str, Any], start: str) -> list[str]:
"""Node ids under ``start`` (exclusive), depth first in children order."""
- children = (
- canvas["root"]["children"]
- if start == ROOT_ID
- else canvas["nodes"][start].get("children") or []
- )
ordered: list[str] = []
- for child in children:
- ordered.append(child)
- ordered.extend(_reading_order(canvas, child))
+ stack = list(reversed(_children(canvas, start)))
+ while stack:
+ node_id = stack.pop()
+ ordered.append(node_id)
+ stack.extend(reversed(_children(canvas, node_id)))
return ordered
diff --git a/superset/canvas/definition/validation.py b/superset/canvas/definition/validation.py
index 03ec67dda41..d04a5440e71 100644
--- a/superset/canvas/definition/validation.py
+++ b/superset/canvas/definition/validation.py
@@ -53,9 +53,6 @@ from superset.canvas.definition.schemas import (
SCOPE_FIELDS,
)
-MAX_NODES = 500
-MAX_DEPTH = 8
-
# The layout rule a node needs to own each kind of scope override.
SCOPE_ROLES = {
"filter": ("is_filter", "a filter"),
@@ -123,12 +120,9 @@ class _Validator:
raise DefinitionValidationError(_pydantic_issues((), ex)) from ex
doc = canvas.model_dump(mode="json", by_alias=True, exclude_none=True)
- if len(doc["nodes"]) > MAX_NODES:
- self.fail("/nodes", f"more than {MAX_NODES} nodes")
self._check_tree(doc)
self._check_widgets(doc)
self._check_nesting()
- self._check_depth(doc)
self._check_interactions(doc)
self._check_settings(doc)
if not self.issues:
@@ -262,17 +256,6 @@ class _Validator:
f"unknown node {node_id!r}",
)
- def _check_depth(self, doc: dict[str, Any]) -> None:
- for node_id in doc["nodes"]:
- depth, current = 1, node_id
- while self.parents.get(current, ROOT_ID) != ROOT_ID and depth <= MAX_DEPTH:
- current = self.parents[current]
- depth += 1
- if depth > MAX_DEPTH:
- self.fail(
- pointer("nodes", node_id), f"nested deeper than {MAX_DEPTH} levels"
- )
-
def _resolve_layouts(self, doc: dict[str, Any]) -> None:
self._resolve_children(doc, ROOT_ID, doc["root"]["layout"]["columns"], None)
for node_id, rules in self.node_rules.items():
diff --git a/tests/unit_tests/canvas/validation_test.py b/tests/unit_tests/canvas/validation_test.py
index a9ec1e1035f..681b0e5ef60 100644
--- a/tests/unit_tests/canvas/validation_test.py
+++ b/tests/unit_tests/canvas/validation_test.py
@@ -244,3 +244,16 @@ def test_settings_are_validated() -> None:
"/settings/refresh/interval",
"/settings/colors/theme",
}
+
+
+def test_nesting_depth_is_not_capped() -> None:
+ nodes = {
+ f"g{level}": node(
+ f"group-{level}", children=[f"g{level + 1}"] if level < 29 else []
+ )
+ for level in range(30)
+ }
+
+ result = normalize(canvas(nodes, children=["g0"]))
+
+ assert len(result["nodes"]) == 30
(superset) 07/12: feat(canvas): slug, theme, CSS and certification metadata
Posted by ms...@apache.org.
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 5c1c3db9db2f5cdb7080fe785c1b6cb3303846b7
Author: Mehmet Salih Yavuz <sa...@proton.me>
AuthorDate: Thu Oct 1 12:51:03 2026 +0300
feat(canvas): slug, theme, CSS and certification metadata
Canvases gain the row metadata dashboards have: a unique slug used in
the canvas URL (never all digits, and not "list"), a theme, custom CSS,
certification and external-management fields. Create and update reject
a slug already taken by any canvas and a theme that doesn't exist. The
canvas page is served by id or slug.
---
superset/canvas/api.py | 29 ++++++++-
superset/canvas/schemas.py | 68 +++++++++++++++++++++-
superset/commands/canvas/create.py | 2 +
superset/commands/canvas/update.py | 4 +-
superset/commands/canvas/utils.py | 43 ++++++++++++++
superset/daos/canvas.py | 8 +++
.../2026-09-30_12-00_6d55f8a0851e_add_canvases.py | 19 ++++++
superset/models/canvas.py | 14 ++++-
superset/views/canvas.py | 4 +-
tests/unit_tests/canvas/api_test.py | 35 +++++++++++
10 files changed, 218 insertions(+), 8 deletions(-)
diff --git a/superset/canvas/api.py b/superset/canvas/api.py
index f1c460ea316..87f26ec05ff 100644
--- a/superset/canvas/api.py
+++ b/superset/canvas/api.py
@@ -180,6 +180,15 @@ class CanvasRestApi(BaseSupersetModelRestApi):
"created_by.id",
"created_by.last_name",
]
+ _metadata_columns = [
+ "slug",
+ "css",
+ "theme_id",
+ "certified_by",
+ "certification_details",
+ "is_managed_externally",
+ "external_url",
+ ]
show_columns = [
"id",
"uuid",
@@ -187,6 +196,10 @@ class CanvasRestApi(BaseSupersetModelRestApi):
"description",
"revision",
"url",
+ *_metadata_columns,
+ "theme.id",
+ "theme.theme_name",
+ "theme.json_data",
*_subject_columns,
*_viewer_columns,
*_audit_columns,
@@ -197,15 +210,25 @@ class CanvasRestApi(BaseSupersetModelRestApi):
"title",
"description",
"url",
+ "slug",
+ "certified_by",
+ "certification_details",
"changed_on",
*_subject_columns,
*_audit_columns,
]
list_select_columns = list_columns + ["changed_by_fk", "created_on"]
- add_columns = ["title", "description", "editors", "viewers", "definition"]
- edit_columns = ["title", "description", "editors", "viewers"]
+ add_columns = [
+ "title",
+ "description",
+ "editors",
+ "viewers",
+ *_metadata_columns,
+ "definition",
+ ]
+ edit_columns = ["title", "description", "editors", "viewers", *_metadata_columns]
order_columns = ["title", "changed_on"]
- search_columns = ["title", "id"]
+ search_columns = ["title", "id", "slug"]
search_filters = {"title": [CanvasAllTextFilter], "id": [CanvasEditableFilter]}
add_model_schema = CanvasPostSchema()
diff --git a/superset/canvas/schemas.py b/superset/canvas/schemas.py
index 633efb80e11..5056e4bfbc3 100644
--- a/superset/canvas/schemas.py
+++ b/superset/canvas/schemas.py
@@ -17,7 +17,7 @@
"""Request schemas for the canvas REST API."""
from marshmallow import fields, Schema
-from marshmallow.validate import Length
+from marshmallow.validate import Length, Regexp
get_delete_ids_schema = {"type": "array", "items": {"type": "integer"}}
@@ -28,6 +28,18 @@ editors_description = (
"to the creating user."
)
viewers_description = "Subject ids (users, roles or groups) that can view the canvas."
+slug_description = (
+ "Readable key for the canvas URL, e.g. /canvas/sales-overview/. Unique, not "
+ "all digits, and not 'list'."
+)
+css_description = "Custom CSS applied to the canvas page."
+theme_id_description = "Theme applied to the canvas; the default theme when unset."
+certified_by_description = "Person or group that has certified this canvas."
+certification_details_description = "Details of the certification."
+is_managed_externally_description = (
+ "Whether the canvas is managed by an external system, e.g. as code."
+)
+external_url_description = "Where the canvas is managed when managed externally."
definition_description = (
"Initial canvas definition. Changed afterwards only through the "
"definition operations endpoint."
@@ -45,6 +57,33 @@ class CanvasPostSchema(Schema):
)
editors = fields.List(fields.Integer(metadata={"description": editors_description}))
viewers = fields.List(fields.Integer(metadata={"description": viewers_description}))
+ slug = fields.String(
+ allow_none=True,
+ validate=[
+ Length(1, 255),
+ Regexp(
+ r"^(?!\d+$)(?!list$)[\w-]+$",
+ error="Use letters, digits, - and _; not all digits, not 'list'.",
+ ),
+ ],
+ metadata={"description": slug_description},
+ )
+ css = fields.String(allow_none=True, metadata={"description": css_description})
+ theme_id = fields.Integer(
+ allow_none=True, metadata={"description": theme_id_description}
+ )
+ certified_by = fields.String(
+ allow_none=True, metadata={"description": certified_by_description}
+ )
+ certification_details = fields.String(
+ allow_none=True, metadata={"description": certification_details_description}
+ )
+ is_managed_externally = fields.Boolean(
+ allow_none=True, metadata={"description": is_managed_externally_description}
+ )
+ external_url = fields.String(
+ allow_none=True, metadata={"description": external_url_description}
+ )
definition = fields.Dict(
allow_none=True, metadata={"description": definition_description}
)
@@ -59,3 +98,30 @@ class CanvasPutSchema(Schema):
)
editors = fields.List(fields.Integer(metadata={"description": editors_description}))
viewers = fields.List(fields.Integer(metadata={"description": viewers_description}))
+ slug = fields.String(
+ allow_none=True,
+ validate=[
+ Length(1, 255),
+ Regexp(
+ r"^(?!\d+$)(?!list$)[\w-]+$",
+ error="Use letters, digits, - and _; not all digits, not 'list'.",
+ ),
+ ],
+ metadata={"description": slug_description},
+ )
+ css = fields.String(allow_none=True, metadata={"description": css_description})
+ theme_id = fields.Integer(
+ allow_none=True, metadata={"description": theme_id_description}
+ )
+ certified_by = fields.String(
+ allow_none=True, metadata={"description": certified_by_description}
+ )
+ certification_details = fields.String(
+ allow_none=True, metadata={"description": certification_details_description}
+ )
+ is_managed_externally = fields.Boolean(
+ allow_none=True, metadata={"description": is_managed_externally_description}
+ )
+ external_url = fields.String(
+ allow_none=True, metadata={"description": external_url_description}
+ )
diff --git a/superset/commands/canvas/create.py b/superset/commands/canvas/create.py
index d02af52e913..78abc083c9f 100644
--- a/superset/commands/canvas/create.py
+++ b/superset/commands/canvas/create.py
@@ -38,6 +38,7 @@ from superset.commands.canvas.exceptions import (
CanvasCreateFailedError,
CanvasInvalidError,
)
+from superset.commands.canvas.utils import validate_metadata
from superset.commands.utils import populate_subjects
from superset.daos.canvas import CanvasDAO
from superset.models.canvas import Canvas
@@ -58,6 +59,7 @@ class CreateCanvasCommand(CreateMixin, BaseCommand):
exceptions: list[ValidationError] = []
# Defaults the editors to the requesting user, and resolves viewers.
populate_subjects(self._properties, exceptions)
+ validate_metadata(self._properties, exceptions)
self._validate_definition(exceptions)
if exceptions:
raise CanvasInvalidError(exceptions=exceptions)
diff --git a/superset/commands/canvas/update.py b/superset/commands/canvas/update.py
index 421c0fa11f1..7a3d8811119 100644
--- a/superset/commands/canvas/update.py
+++ b/superset/commands/canvas/update.py
@@ -31,6 +31,7 @@ from superset.commands.canvas.exceptions import (
CanvasNotFoundError,
CanvasUpdateFailedError,
)
+from superset.commands.canvas.utils import validate_metadata
from superset.commands.utils import compute_subjects
from superset.daos.canvas import CanvasDAO
from superset.exceptions import SupersetSecurityException
@@ -40,7 +41,7 @@ from superset.utils.decorators import on_error, transaction
class UpdateCanvasCommand(UpdateMixin, BaseCommand):
"""
- Update title, description, editors and viewers. The definition changes
+ Update the canvas' properties, metadata and sharing. The definition changes
only through operations (``ApplyCanvasOperationsCommand``).
"""
@@ -67,5 +68,6 @@ class UpdateCanvasCommand(UpdateMixin, BaseCommand):
raise CanvasForbiddenError() from ex
exceptions: list[ValidationError] = []
compute_subjects(self._model, self._properties, exceptions)
+ validate_metadata(self._properties, exceptions, self._model.id)
if exceptions:
raise CanvasInvalidError(exceptions=exceptions)
diff --git a/superset/commands/canvas/utils.py b/superset/commands/canvas/utils.py
new file mode 100644
index 00000000000..952fe4d7961
--- /dev/null
+++ b/superset/commands/canvas/utils.py
@@ -0,0 +1,43 @@
+# 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.
+"""Validation shared by the canvas create and update commands."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from flask_babel import gettext as _
+from marshmallow import ValidationError
+
+from superset import db
+from superset.daos.canvas import CanvasDAO
+from superset.models.core import Theme
+
+
+def validate_metadata(
+ properties: dict[str, Any],
+ exceptions: list[ValidationError],
+ canvas_id: int | None = None,
+) -> None:
+ """Check the slug is free and the theme exists."""
+ if (slug := properties.get("slug")) and CanvasDAO.slug_in_use(slug, canvas_id):
+ exceptions.append(ValidationError(_("Must be unique"), field_name="slug"))
+ theme_id = properties.get("theme_id")
+ if theme_id is not None and db.session.get(Theme, theme_id) is None:
+ exceptions.append(
+ ValidationError(_("Theme does not exist"), field_name="theme_id")
+ )
diff --git a/superset/daos/canvas.py b/superset/daos/canvas.py
index f4c6a810900..11d343a425b 100644
--- a/superset/daos/canvas.py
+++ b/superset/daos/canvas.py
@@ -45,6 +45,14 @@ class CanvasDAO(BaseDAO[Canvas]):
.one()
)
+ @staticmethod
+ def slug_in_use(slug: str, exclude_id: int | None = None) -> bool:
+ """Whether another canvas, visible to the caller or not, has ``slug``."""
+ query = db.session.query(Canvas).filter(Canvas.slug == slug)
+ if exclude_id is not None:
+ query = query.filter(Canvas.id != exclude_id)
+ return db.session.query(query.exists()).scalar()
+
@staticmethod
def load(canvas: Canvas) -> dict[str, Any]:
"""The stored definition, upgraded to the current schema version."""
diff --git a/superset/migrations/versions/2026-09-30_12-00_6d55f8a0851e_add_canvases.py b/superset/migrations/versions/2026-09-30_12-00_6d55f8a0851e_add_canvases.py
index d4676e42f6e..e9393917d6a 100644
--- a/superset/migrations/versions/2026-09-30_12-00_6d55f8a0851e_add_canvases.py
+++ b/superset/migrations/versions/2026-09-30_12-00_6d55f8a0851e_add_canvases.py
@@ -72,13 +72,32 @@ def upgrade() -> None:
sa.Column("definition", MediumText(), nullable=False),
sa.Column("definition_version", sa.Integer(), nullable=False),
sa.Column("revision", sa.BigInteger(), nullable=False),
+ sa.Column("slug", sa.String(255), nullable=True),
+ sa.Column("css", MediumText(), nullable=True),
+ sa.Column("theme_id", sa.Integer(), nullable=True),
+ sa.Column("certified_by", sa.Text(), nullable=True),
+ sa.Column("certification_details", sa.Text(), nullable=True),
+ sa.Column(
+ "is_managed_externally",
+ sa.Boolean(),
+ nullable=False,
+ server_default=sa.false(),
+ ),
+ sa.Column("external_url", sa.Text(), nullable=True),
sa.ForeignKeyConstraint(
["created_by_fk"], ["ab_user.id"], name="fk_canvases_created_by_fk_ab_user"
),
sa.ForeignKeyConstraint(
["changed_by_fk"], ["ab_user.id"], name="fk_canvases_changed_by_fk_ab_user"
),
+ sa.ForeignKeyConstraint(
+ ["theme_id"],
+ ["themes.id"],
+ name="fk_canvases_theme_id_themes",
+ ondelete="SET NULL",
+ ),
sa.UniqueConstraint("uuid", name="uq_canvases_uuid"),
+ sa.UniqueConstraint("slug", name="uq_canvases_slug"),
)
_subject_table("canvas_editors")
_subject_table("canvas_viewers")
diff --git a/superset/models/canvas.py b/superset/models/canvas.py
index 9e8ac71b154..d327910133a 100644
--- a/superset/models/canvas.py
+++ b/superset/models/canvas.py
@@ -24,6 +24,7 @@ from typing import Any
from flask_appbuilder import Model
from sqlalchemy import (
BigInteger,
+ Boolean,
Column,
DateTime,
ForeignKey,
@@ -60,6 +61,17 @@ class Canvas(AuditMixinNullable, UUIDMixin, Model):
# Bumped by every accepted definition write; the base for stale-edit
# detection.
revision = Column(BigInteger, nullable=False, default=1)
+ # Readable URL key; never all digits, so it can't be mistaken for an id.
+ slug = Column(String(255), unique=True)
+ css = Column(utils.MediumText())
+ theme_id = Column(
+ Integer, ForeignKey("themes.id", ondelete="SET NULL"), nullable=True
+ )
+ theme = relationship("Theme", foreign_keys=[theme_id])
+ certified_by = Column(Text)
+ certification_details = Column(Text)
+ is_managed_externally = Column(Boolean, nullable=False, default=False)
+ external_url = Column(Text, nullable=True)
editors = relationship(Subject, secondary=canvas_editors, passive_deletes=True)
viewers = relationship(Subject, secondary=canvas_viewers, passive_deletes=True)
@@ -75,7 +87,7 @@ class Canvas(AuditMixinNullable, UUIDMixin, Model):
@property
def url(self) -> str:
- return f"/canvas/{self.id}/"
+ return f"/canvas/{self.slug or self.id}/"
class CanvasOp(Model):
diff --git a/superset/views/canvas.py b/superset/views/canvas.py
index 980f4f7fcd3..cd1251ae149 100644
--- a/superset/views/canvas.py
+++ b/superset/views/canvas.py
@@ -38,10 +38,10 @@ class CanvasView(BaseSupersetView):
return abort(404)
return super().render_app_template()
- @expose("/<int:pk>/")
+ @expose("/<id_or_slug>/")
@has_access
@permission_name("read")
- def show(self, pk: int) -> FlaskResponse: # pylint: disable=unused-argument
+ def show(self, id_or_slug: str) -> 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 086958d4cda..06bb5885e4c 100644
--- a/tests/unit_tests/canvas/api_test.py
+++ b/tests/unit_tests/canvas/api_test.py
@@ -92,6 +92,41 @@ def test_create_and_read_a_canvas(
assert definition["definition"]["nodes"] == {}
+def test_metadata_and_slug(
+ client: Any,
+ full_api_access: None,
+ canvas: Any,
+ mocker: MockerFixture,
+ session: Session,
+) -> None:
+ from superset.models.canvas import Canvas
+
+ mocker.patch("superset.commands.canvas.create.populate_subjects")
+ metadata = {
+ "slug": "sales-overview",
+ "css": ".x { color: red; }",
+ "certified_by": "Data team",
+ "certification_details": "Reviewed weekly",
+ "external_url": "https://example.com/canvas.yaml",
+ "is_managed_externally": True,
+ }
+
+ response = client.post(BASE + "/", json={"title": "Sales", **metadata})
+
+ assert response.status_code == 201
+ created = session.get(Canvas, response.json["id"])
+ assert {key: getattr(created, key) for key in metadata} == metadata
+ assert created.url == "/canvas/sales-overview/"
+
+ taken = client.put(f"{BASE}/100", json={"slug": "sales-overview"})
+ assert taken.status_code == 422
+ assert taken.json["message"] == {"slug": ["Must be unique"]}
+ for slug in ("123", "list", "has space"):
+ assert client.put(f"{BASE}/100", json={"slug": slug}).status_code == 400
+ missing_theme = client.put(f"{BASE}/100", json={"theme_id": 999})
+ assert missing_theme.json["message"] == {"theme_id": ["Theme does not exist"]}
+
+
def test_create_validates_the_body(
client: Any, full_api_access: None, canvas: Any
) -> None:
(superset) 12/12: feat(canvas): consolidate with the SIP-231 widget framework
Posted by ms...@apache.org.
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 ed5a6810e12b45e2b8a1cb47793b3b9868f068e7
Author: Mehmet Salih Yavuz <sa...@proton.me>
AuthorDate: Mon Oct 5 14:26:58 2026 +0300
feat(canvas): consolidate with the SIP-231 widget framework
Brings the widget framework prototype (superset_core.widgets, the widget
registry and built-ins, the control schema API and widget MCP tools) onto
the canvas branch and rebuilds the canvas on it:
- Placements hold a persisted instance by UUID or an inline instance
(widget, schemaVersion, props); placement ids are readable slugs.
- Widgets declare schema versions, props migrators, WidgetBehavior and
WidgetUi, replacing the canvas layout rules registry; core adds tabs,
tab and group containers.
- Inline props are validated where written, migrated on read, and
replaced with the new set_props op.
- get_canvas and apply_canvas_ops MCP tools; the canvas and widget APIs
and MCP tools are all gated by CANVAS.
- Definition versions are checked, not upgraded on read; format changes
ship as Alembic migrations.
- Built-in renderers for the core containers and markdown.
---
superset-core/src/superset_core/canvas/__init__.py | 8 +-
superset-core/src/superset_core/canvas/base.py | 75 +--
.../src/superset_core/semantic_layers/config.py | 106 +++-
.../superset_core/{canvas => widgets}/__init__.py | 27 +-
superset-core/src/superset_core/widgets/base.py | 218 ++++++++
.../src/superset_core/widgets/behavior.py | 92 +++
.../src/superset_core/widgets/composites.py | 111 ++++
.../src/superset_core/widgets/decorators.py | 92 +++
.../src/superset_core/widgets/enrichment.py | 160 ++++++
.../packages/superset-core/src/canvas/index.ts | 18 +-
.../src/features/canvas/CanvasGrid.test.tsx | 25 +-
.../src/features/canvas/CanvasGrid.tsx | 5 +-
.../src/features/canvas/builtinRenderers.test.tsx | 83 +++
.../src/features/canvas/builtinRenderers.tsx | 73 +++
superset-frontend/src/features/canvas/types.ts | 21 +-
.../src/features/canvas/useCanvasRefresh.test.ts | 6 +-
superset-frontend/src/pages/Canvas/Canvas.test.tsx | 5 +-
superset-frontend/src/pages/Canvas/index.tsx | 3 +
superset/canvas/api.py | 62 ++-
superset/canvas/definition/ops.py | 156 ++++--
superset/canvas/definition/placements.py | 103 ++++
superset/canvas/definition/registry.py | 45 +-
superset/canvas/definition/render.py | 48 +-
superset/canvas/definition/schemas.py | 93 +++-
superset/canvas/definition/scopes.py | 49 +-
superset/canvas/definition/validation.py | 191 ++++---
.../canvas/definition/{upgrades.py => versions.py} | 49 +-
superset/commands/canvas/apply_ops.py | 36 +-
superset/commands/canvas/create.py | 26 +-
superset/commands/canvas/exceptions.py | 2 +-
superset/core/api/core_api_injection.py | 87 +++
superset/daos/canvas.py | 12 +-
superset/initialization/__init__.py | 2 +
superset/mcp_service/app.py | 24 +
.../mcp_service}/canvas/__init__.py | 10 -
superset/mcp_service/canvas/schemas.py | 67 +++
.../mcp_service/canvas/tool}/__init__.py | 11 +-
.../mcp_service/canvas/tool/apply_canvas_ops.py | 116 ++++
superset/mcp_service/canvas/tool/get_canvas.py | 78 +++
superset/mcp_service/utils/response_size_utils.py | 1 +
.../mcp_service/widgets}/__init__.py | 10 +-
.../mcp_service/widgets/schemas.py | 34 +-
.../mcp_service/widgets/tool}/__init__.py | 14 +-
.../widgets/tool/get_widget_control_schema.py | 121 ++++
.../mcp_service/widgets/tool/list_widget_types.py | 66 +++
superset/mcp_service/widgets/utils.py | 69 +++
superset/models/canvas.py | 7 +-
superset/widgets/__init__.py | 35 ++
superset/widgets/api.py | 310 +++++++++++
superset/widgets/builtin.py | 381 +++++++++++++
superset/widgets/controls.py | 615 +++++++++++++++++++++
.../__init__.py => superset/widgets/registry.py | 20 +-
superset/widgets/schema_tools.py | 248 +++++++++
tests/unit_tests/canvas/api_test.py | 50 +-
tests/unit_tests/canvas/commands_test.py | 4 +-
tests/unit_tests/canvas/conftest.py | 20 +-
tests/unit_tests/canvas/fixtures.py | 170 +++---
tests/unit_tests/canvas/ops_test.py | 171 +++++-
tests/unit_tests/canvas/render_test.py | 4 +-
tests/unit_tests/canvas/scopes_test.py | 12 +-
tests/unit_tests/canvas/upgrades_test.py | 62 ---
tests/unit_tests/canvas/validation_test.py | 83 ++-
tests/unit_tests/canvas/versions_test.py | 43 ++
.../unit_tests/mcp_service}/canvas/__init__.py | 10 -
.../mcp_service/canvas/tool}/__init__.py | 10 -
.../mcp_service/canvas/tool/test_canvas_tools.py | 101 ++++
.../mcp_service/test_mcp_tool_registration.py | 28 +
.../unit_tests/mcp_service/widgets}/__init__.py | 10 -
.../mcp_service/widgets/tool}/__init__.py | 10 -
.../mcp_service/widgets/tool/test_widget_tools.py | 119 ++++
tests/unit_tests/semantic_layers/config_test.py | 143 +++++
.../unit_tests/widgets}/__init__.py | 10 -
.../unit_tests/widgets/conftest.py | 18 +-
tests/unit_tests/widgets/test_api.py | 103 ++++
tests/unit_tests/widgets/test_builtin.py | 202 +++++++
tests/unit_tests/widgets/test_composites.py | 75 +++
tests/unit_tests/widgets/test_contract.py | 100 ++++
.../widgets/test_echarts_structured_chrome.py | 166 ++++++
.../widgets/test_echarts_structured_series.py | 209 +++++++
tests/unit_tests/widgets/test_enrichment.py | 165 ++++++
tests/unit_tests/widgets/test_registry.py | 302 ++++++++++
tests/unit_tests/widgets/test_schema_tools.py | 125 +++++
82 files changed, 6121 insertions(+), 730 deletions(-)
diff --git a/superset-core/src/superset_core/canvas/__init__.py b/superset-core/src/superset_core/canvas/__init__.py
index eee560bcf4b..07bc7de4adc 100644
--- a/superset-core/src/superset_core/canvas/__init__.py
+++ b/superset-core/src/superset_core/canvas/__init__.py
@@ -17,10 +17,6 @@
"""Dashboard canvas contracts for widget providers."""
-from superset_core.canvas.base import (
- CanvasLayoutRules,
- GridPlacement,
- WidgetResolver,
-)
+from superset_core.canvas.base import GridPlacement, InstanceResolver
-__all__ = ["CanvasLayoutRules", "GridPlacement", "WidgetResolver"]
+__all__ = ["GridPlacement", "InstanceResolver"]
diff --git a/superset-core/src/superset_core/canvas/base.py b/superset-core/src/superset_core/canvas/base.py
index 23b4a81bd80..13c50d3a21e 100644
--- a/superset-core/src/superset_core/canvas/base.py
+++ b/superset-core/src/superset_core/canvas/base.py
@@ -16,23 +16,19 @@
# under the License.
"""
-What the dashboard canvas needs to know about widgets.
+What a canvas needs from the widget side beyond the widget registry.
-The canvas places widgets; it never stores or reads their configuration.
-Widgets are their own entities, referenced from canvas nodes by id. The canvas
-asks two things of the widget side:
-
-- ``CanvasLayoutRules``, declared per widget type: whether the type holds
- child nodes, which types may nest where, and how its children are laid out.
- Types without registered rules are leaves.
-- ``WidgetResolver``: the type of each referenced widget, so nesting and
- layout can be validated, and which widgets the current user may place.
+A canvas places widget instances. An inline instance is stored on its
+placement, as a widget id, schema version and props; a persisted instance is
+its own entity, referenced by UUID. The registry (``superset_core.widgets``)
+describes each widget's behavior; ``InstanceResolver`` looks up persisted
+instances.
"""
from __future__ import annotations
from collections.abc import Iterable
-from typing import ClassVar, Protocol
+from typing import Protocol
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic.alias_generators import to_camel
@@ -63,58 +59,19 @@ class GridPlacement(BaseModel):
return self
-class CanvasLayoutRules:
- """
- How a widget type behaves on the canvas.
-
- A container sets ``is_container`` and either ``grid_columns`` (children
- are placed with ``GridPlacement`` on a grid of that many columns) or
- ``child_layout_model`` (children's layout is validated against that model
- and arranged by the container's renderer).
- """
+class InstanceResolver(Protocol):
+ """Looks up the persisted widget instances a canvas references."""
- widget_type: ClassVar[str]
- is_container: ClassVar[bool] = False
- # Child widget types this container accepts; ``None`` accepts any.
- accepted_children: ClassVar[frozenset[str] | None] = None
- # Container types this widget may live under; ``None`` allows any
- # container that accepts it, including the root.
- allowed_parents: ClassVar[frozenset[str] | None] = None
- grid_columns: ClassVar[int | None] = None
- child_layout_model: ClassVar[type[BaseModel] | None] = None
-
- # Filters, cross-filter sources and customizations (e.g. dynamic group-by)
- # drive filterable widgets. By default each drives every filterable widget
- # under its nearest container that bounds filter scope (or the whole canvas
- # at the root), never itself; the canvas can override that per node. A
- # container that does not bound scope, e.g. a filter bar, lets its
- # children reach further up.
- is_filter: ClassVar[bool] = False
- is_cross_filter_source: ClassVar[bool] = False
- is_customization: ClassVar[bool] = False
- is_filterable: ClassVar[bool] = False
- bounds_filter_scope: ClassVar[bool] = True
-
- # Size limits, in grid units, for this widget when its parent is a grid.
- min_col_span: ClassVar[int | None] = None
- max_col_span: ClassVar[int | None] = None
- min_row_span: ClassVar[int | None] = None
- max_row_span: ClassVar[int | None] = None
-
-
-class WidgetResolver(Protocol):
- """Looks up the widgets a canvas references."""
-
- def widget_types(self, widget_ids: Iterable[str]) -> dict[str, str]:
+ def widget_types(self, instance_ids: Iterable[str]) -> dict[str, str]:
"""
- Return ``{widget id: widget type}`` for the widgets that exist.
+ Return ``{instance UUID: widget id}`` for the instances that exist.
- Types are needed to validate nesting and layout for every node,
- including widgets the current user cannot see, so this is not
- filtered by access.
+ Needed to validate nesting and layout for every placement, including
+ instances the current user cannot see, so this is not filtered by
+ access.
"""
...
- def placeable(self, widget_ids: Iterable[str]) -> set[str]:
- """The widgets the current user may place on a canvas."""
+ def placeable(self, instance_ids: Iterable[str]) -> set[str]:
+ """The instances the current user may place on a canvas."""
...
diff --git a/superset-core/src/superset_core/semantic_layers/config.py b/superset-core/src/superset_core/semantic_layers/config.py
index 7c9aa520b11..615249e2863 100644
--- a/superset-core/src/superset_core/semantic_layers/config.py
+++ b/superset-core/src/superset_core/semantic_layers/config.py
@@ -17,11 +17,71 @@
from __future__ import annotations
-from typing import Any
+from typing import Any, get_args, get_origin, Iterator
from pydantic import BaseModel
+def _iter_nested_models(
+ annotation: Any, seen: set[type[BaseModel]]
+) -> Iterator[type[BaseModel]]:
+ """Yield every ``BaseModel`` subclass reachable from ``annotation``
+ (through generics like ``list[...]``/``... | None``, and recursively
+ through each found model's own fields), each at most once."""
+ origin = get_origin(annotation)
+ if origin is not None:
+ for arg in get_args(annotation):
+ yield from _iter_nested_models(arg, seen)
+ return
+ if isinstance(annotation, type) and issubclass(annotation, BaseModel):
+ model_cls: type[BaseModel] = annotation
+ if model_cls not in seen:
+ seen.add(model_cls)
+ yield model_cls
+ for field in model_cls.model_fields.values():
+ yield from _iter_nested_models(field.annotation, seen)
+
+
+def _resolve_field_order(
+ model_cls: type[BaseModel], schema_node: dict[str, Any]
+) -> list[str]:
+ """The order ``schema_node["properties"]`` should render in: an explicit
+ ``field_order: ClassVar[list[str]]`` on ``model_cls`` when declared
+ (validated as an exact permutation of its own properties), else the
+ model's field declaration order (by alias)."""
+ declared_order = getattr(model_cls, "field_order", None)
+ if declared_order is None:
+ return [field.alias or name for name, field in model_cls.model_fields.items()]
+ declared = set(declared_order)
+ actual = set(schema_node.get("properties", {}))
+ # A `set()` comparison alone can't catch a duplicate: `["a", "a", "b"]`
+ # collapses to the same set as `["a", "b"]`, so a repeated entry would
+ # otherwise pass this check and then be silently absorbed by `_reorder`'s
+ # dict comprehension (a later duplicate key just overwrites the same
+ # slot) rather than surfacing the invalid declaration.
+ if len(declared_order) != len(declared) or declared != actual:
+ raise ValueError(
+ f"{model_cls.__name__}.field_order must be a permutation of its "
+ f"schema properties; declared={sorted(declared_order)} "
+ f"actual={sorted(actual)}"
+ )
+ return declared_order
+
+
+def _reorder(schema_node: dict[str, Any], field_order: list[str]) -> None:
+ """Reorder ``schema_node``'s ``properties`` (and, for determinism,
+ ``required``) to match ``field_order``. Mutates in place."""
+ if (properties := schema_node.get("properties")) is not None:
+ schema_node["properties"] = {
+ key: properties[key] for key in field_order if key in properties
+ }
+ if (required := schema_node.get("required")) is not None:
+ index = {key: position for position, key in enumerate(field_order)}
+ schema_node["required"] = sorted(
+ required, key=lambda key: index.get(key, len(field_order))
+ )
+
+
def build_configuration_schema(
config_class: type[BaseModel],
configuration: BaseModel | None = None,
@@ -31,7 +91,15 @@ def build_configuration_schema(
Handles generic boilerplate that any semantic layer with dynamic fields needs:
- - Reorders properties to match model field order (Pydantic sorts alphabetically)
+ - Reorders properties to match model field order (Pydantic sorts alphabetically),
+ or an explicit ``field_order: ClassVar[list[str]]`` on a model when declared —
+ needed because Pydantic always places an inherited field ahead of a
+ subclass's own fields in ``model_fields``, regardless of where the subclass
+ redeclares it, so composed models can't rely on declaration order alone.
+ Applied to ``config_class`` itself *and* to every nested model that lands in
+ the schema's ``$defs`` — a model's declared/inherited field order isn't only
+ relevant when it's the top-level schema, and Pydantic emits ``$defs`` entries
+ in each nested model's own (potentially inheritance-skewed) field order too.
- When ``configuration`` is None, sets ``enum: []`` on all ``x-dynamic`` properties
so the frontend renders them as empty dropdowns
@@ -41,15 +109,16 @@ def build_configuration_schema(
"""
schema = config_class.model_json_schema()
- # Pydantic sorts properties alphabetically; restore model field order
- field_order = [
- field.alias or name for name, field in config_class.model_fields.items()
- ]
- schema["properties"] = {
- key: schema["properties"][key]
- for key in field_order
- if key in schema["properties"]
- }
+ _reorder(schema, _resolve_field_order(config_class, schema))
+
+ defs = schema.get("$defs", {})
+ for nested_cls in _iter_nested_models(config_class, seen=set()):
+ if nested_cls is config_class:
+ continue
+ def_entry = defs.get(nested_cls.__name__)
+ if def_entry is None:
+ continue
+ _reorder(def_entry, _resolve_field_order(nested_cls, def_entry))
if configuration is None:
for prop_schema in schema["properties"].values():
@@ -67,7 +136,18 @@ def check_dependencies(
Check whether a dynamic property's dependencies are satisfied.
Reads the ``x-dependsOn`` list from the property schema and returns ``True``
- when every referenced attribute on ``configuration`` is truthy.
+ when every referenced attribute on ``configuration`` is truthy. Entries are
+ written using the schema-facing alias (e.g. ``"dataBinding"``), so each is
+ resolved to its Pydantic field name before ``getattr`` -- Pydantic attribute
+ access always uses the field name, never the alias, even under
+ ``populate_by_name=True``.
"""
dependencies = prop_schema.get("x-dependsOn", [])
- return all(getattr(configuration, dep, None) for dep in dependencies)
+ alias_to_name = {
+ (field.alias or name): name
+ for name, field in type(configuration).model_fields.items()
+ }
+ return all(
+ getattr(configuration, alias_to_name.get(dep, dep), None)
+ for dep in dependencies
+ )
diff --git a/superset-core/src/superset_core/canvas/__init__.py b/superset-core/src/superset_core/widgets/__init__.py
similarity index 50%
copy from superset-core/src/superset_core/canvas/__init__.py
copy to superset-core/src/superset_core/widgets/__init__.py
index eee560bcf4b..420754fb3e0 100644
--- a/superset-core/src/superset_core/canvas/__init__.py
+++ b/superset-core/src/superset_core/widgets/__init__.py
@@ -15,12 +15,25 @@
# specific language governing permissions and limitations
# under the License.
-"""Dashboard canvas contracts for widget providers."""
+"""Public contract for registering Dashboard V2 widgets.
-from superset_core.canvas.base import (
- CanvasLayoutRules,
- GridPlacement,
- WidgetResolver,
-)
+Re-exported with the redundant-alias form so these are recognized as
+intentional re-exports (this codebase does not use ``__all__``).
+"""
-__all__ = ["CanvasLayoutRules", "GridPlacement", "WidgetResolver"]
+from superset_core.widgets.base import (
+ PropsMigrator as PropsMigrator,
+ PropsVersionError as PropsVersionError,
+ Widget as Widget,
+)
+from superset_core.widgets.behavior import (
+ WidgetBehavior as WidgetBehavior,
+ WidgetUi as WidgetUi,
+)
+from superset_core.widgets.composites import (
+ composite_control as composite_control,
+ list_composite_controls as list_composite_controls,
+ MetricControl as MetricControl,
+)
+from superset_core.widgets.decorators import widget as widget
+from superset_core.widgets.enrichment import EnricherFn as EnricherFn
diff --git a/superset-core/src/superset_core/widgets/base.py b/superset-core/src/superset_core/widgets/base.py
new file mode 100644
index 00000000000..a805611d783
--- /dev/null
+++ b/superset-core/src/superset_core/widgets/base.py
@@ -0,0 +1,218 @@
+# 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.
+
+"""
+Base class for a Dashboard V2 widget's backend behavior.
+
+Lives in ``superset_core`` so extensions can subclass it exactly the way the
+host's built-in widgets do — the same contract, no app-internal imports. A
+concrete widget registers itself as a whole with the ``@widget`` decorator (see
+``superset_core.widgets.decorators``).
+
+``Widget`` is the single registered unit for a widget type. Today it exposes the
+**control panel** (the JSON Schema that drives the Inspector and MCP, plus
+commit-time validation). It is deliberately the wrapper — not a bare
+"controls" object — because a widget type's other backend concerns will hang off
+the same class and register together: e.g. building a ``SemanticQuery`` from the
+control values, and post-processing the query results before they reach the
+viz. Those aren't relevant to the frontend, but they belong in the same registry
+entry, so they live here rather than in a parallel registry.
+"""
+
+from __future__ import annotations
+
+import logging
+from collections.abc import Callable, Mapping
+from typing import Any, ClassVar
+
+from pydantic import BaseModel, ValidationError
+
+# Reuse the Semantic Layer's schema helper: it turns a Pydantic model into a
+# JSON Schema, preserves field order, and blanks ``x-dynamic`` enums when no
+# configuration is supplied — the exact machinery the SIP proposes to share.
+from superset_core.semantic_layers.config import build_configuration_schema
+from superset_core.widgets.behavior import WidgetBehavior, WidgetUi
+from superset_core.widgets.enrichment import (
+ build_dependency_graph,
+ dynamic_field_paths,
+ EnricherFn,
+ run_enrichers,
+ toposort_or_raise,
+)
+
+logger = logging.getLogger(__name__)
+
+# Upgrades stored props by one schema version.
+PropsMigrator = Callable[[dict[str, Any]], dict[str, Any]]
+
+
+class PropsVersionError(ValueError):
+ """Stored props this server can't bring to the widget's schema version."""
+
+
+class Widget:
+ """
+ Base class for a Dashboard V2 widget's backend behavior (the registered
+ unit for a widget type).
+
+ A concrete widget type is declared with the ``@widget`` decorator (which
+ sets ``widget_type``/``name``/``description``) and sets a ``controls_class``
+ (a Pydantic model). The model's JSON Schema *is* the control panel; it
+ describes the widget's ``node.props`` and is served to both the frontend and
+ MCP. Future backend concerns for a widget type (e.g. ``build_semantic_query``
+ from the control values, or a query-result handler) will be added here as
+ sibling methods so a widget is always registered as a whole.
+ """
+
+ widget_type: str
+ name: str
+ description: str = ""
+ controls_class: type[BaseModel]
+ # Only increases; any change to what stored props mean bumps it and adds a
+ # migrator from the previous version.
+ schema_version: ClassVar[int] = 1
+ # Keyed by the version each one upgrades from.
+ migrators: ClassVar[Mapping[int, PropsMigrator]] = {}
+ behavior: ClassVar[WidgetBehavior] = WidgetBehavior()
+ ui: ClassVar[WidgetUi] = WidgetUi()
+
+ @classmethod
+ def validate_contract(cls) -> None:
+ """Check the declaration beyond its schema; raises ``ValueError``."""
+ errors = [*cls.behavior.errors()]
+ if cls.schema_version < 1:
+ errors.append("schema_version must be at least 1")
+ missing = [v for v in range(1, cls.schema_version) if v not in cls.migrators]
+ if missing:
+ errors.append(f"no migrators from schema versions {missing}")
+ if errors:
+ raise ValueError(f"widget {cls.widget_type!r}: " + "; ".join(errors))
+
+ @classmethod
+ def upgrade_props(cls, props: dict[str, Any], version: int) -> dict[str, Any]:
+ """
+ ``props`` stored at ``version``, upgraded to ``schema_version``.
+
+ Raises ``PropsVersionError`` for a version newer than this widget, or
+ when a migrator fails; callers keep such props as stored.
+ """
+ if version > cls.schema_version or version < 1:
+ raise PropsVersionError(
+ f"props at schema version {version}; {cls.widget_type!r} "
+ f"supports up to {cls.schema_version}"
+ )
+ while version < cls.schema_version:
+ try:
+ props = cls.migrators[version](props)
+ except Exception as ex:
+ raise PropsVersionError(
+ f"migrating {cls.widget_type!r} props from version {version} "
+ f"failed: {ex}"
+ ) from ex
+ version += 1
+ return props
+
+ enrichers: ClassVar[dict[str, EnricherFn]] = {}
+
+ @classmethod
+ def validate_control_schema(cls) -> None:
+ """Validate the static control schema without running enrichers.
+
+ Widget registration happens during application initialization, before
+ there is a request user. Enrichers may perform permission-scoped data
+ lookups, so registration must only verify that the schema can be built
+ and that its dynamic-field dependency graph is acyclic.
+ """
+ schema = build_configuration_schema(cls.controls_class)
+ fields = dynamic_field_paths(schema)
+ toposort_or_raise(build_dependency_graph(fields), cls.widget_type)
+
+ @classmethod
+ def get_control_schema(
+ cls,
+ control_values: dict[str, Any] | None = None,
+ series: list[str] | None = None,
+ ) -> dict[str, Any]:
+ """
+ Return the JSON Schema for this widget's controls.
+
+ ``control_values`` (the full current ``node.props``) is accepted so
+ dynamic fields can be enriched from it, mirroring the Semantic Layer.
+ ``series`` carries the distinct dimension values the frontend
+ discovered from the query results (they cannot come from
+ ``control_values`` alone, which hold the dimension *name*, not its
+ values). Partial or invalid values during editing are tolerated and
+ fall back to the base schema; enrichment errors propagate so the caller
+ can degrade gracefully.
+
+ Every ``x-dynamic`` field found in the built schema is enriched (if
+ this widget has a registered enricher for its path, see ``enrichers``)
+ in dependency order — derived from each field's own ``x-dependsOn``,
+ see ``superset_core.widgets.enrichment``. A cyclic dependency raises
+ ``ValueError``; for a built-in or registered widget this is caught
+ once at registration time (``inject_widget_implementations``), not on
+ every request.
+ """
+ parsed: BaseModel | None = None
+ if control_values:
+ try:
+ parsed = cls.controls_class.model_validate(control_values)
+ except Exception: # pylint: disable=broad-except
+ # Partial control values during editing are expected; fall back
+ # to the base schema.
+ logger.debug(
+ "Could not validate control values for %s; using base schema",
+ cls.widget_type,
+ exc_info=True,
+ )
+ schema = build_configuration_schema(cls.controls_class, parsed)
+ fields = dynamic_field_paths(schema)
+ order = toposort_or_raise(build_dependency_graph(fields), cls.widget_type)
+ run_enrichers(schema, fields, order, cls.enrichers, parsed, series or [])
+ return schema
+
+ @classmethod
+ def validate_control_values(
+ cls,
+ control_values: dict[str, Any] | None,
+ ) -> list[dict[str, Any]]:
+ """
+ Strictly validate ``control_values`` against ``controls_class``,
+ returning a list of ``{"loc", "message"}`` errors (empty when valid).
+
+ This is the **commit-time** gate, distinct from ``get_control_schema``
+ (which tolerates partial/invalid values so a form can be edited without
+ erroring). It runs the model's full validation — including cross-field
+ rules declared as Pydantic ``@model_validator`` / ``@field_validator``
+ on ``controls_class`` — so a caller (or an AI) gets an actionable
+ message instead of a silently-broken widget. It is widget-agnostic:
+ every rule lives declaratively on the model; this method just surfaces
+ whatever the model enforces.
+ """
+ if control_values is None:
+ return []
+ try:
+ cls.controls_class.model_validate(control_values)
+ except ValidationError as ex:
+ return [
+ {
+ "loc": [str(part) for part in error.get("loc", ())],
+ "message": error.get("msg", ""),
+ }
+ for error in ex.errors()
+ ]
+ return []
diff --git a/superset-core/src/superset_core/widgets/behavior.py b/superset-core/src/superset_core/widgets/behavior.py
new file mode 100644
index 00000000000..5ac714c7f45
--- /dev/null
+++ b/superset-core/src/superset_core/widgets/behavior.py
@@ -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.
+
+"""
+How a widget takes part in a canvas, and how the UI presents it.
+
+Declared once per widget as ``behavior`` and ``ui`` class attributes; the
+canvas reads them to validate nesting and child layout, and to resolve which
+widgets each filter, cross-filter source and customization drives.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from pydantic import BaseModel
+
+
+@dataclass(frozen=True)
+class WidgetBehavior:
+ """
+ A container sets ``container`` and either ``grid_columns`` (children are
+ placed on a grid of that many columns) or ``child_layout_model`` (each
+ child's layout is validated against that model and arranged by the
+ container's renderer).
+
+ By default a filter, cross-filter source or customization drives every
+ filterable widget under its nearest container that bounds filter scope (or
+ the whole canvas), never itself; a canvas can override that per placement.
+ A container that doesn't bound scope, such as a filter bar, lets its
+ children reach further up.
+ """
+
+ container: bool = False
+ # Child widget ids this container accepts; ``None`` accepts any.
+ accepted_children: frozenset[str] | None = None
+ # Container widget ids this widget may live under; ``None`` allows any
+ # container that accepts it, including the canvas root.
+ allowed_parents: frozenset[str] | None = None
+ grid_columns: int | None = None
+ child_layout_model: type[BaseModel] | None = None
+ bounds_filter_scope: bool = True
+
+ # Sets runtime filters for the widgets in its scope.
+ filter: bool = False
+ # Emits cross-filters when clicked (a cross-filter source).
+ emits_filters: bool = False
+ # Changes how the widgets in its scope query, e.g. a dynamic group-by.
+ customization: bool = False
+ # Receives runtime filters.
+ filterable: bool = False
+ drill: bool = False
+
+ def errors(self) -> list[str]:
+ """Inconsistencies in this declaration, checked at registration."""
+ errors = []
+ if not self.container:
+ if self.accepted_children is not None:
+ errors.append("only a container can declare accepted_children")
+ if self.grid_columns is not None or self.child_layout_model is not None:
+ errors.append("only a container can declare a child layout")
+ elif self.grid_columns is not None and self.child_layout_model is not None:
+ errors.append("declare grid_columns or child_layout_model, not both")
+ if self.grid_columns is not None and self.grid_columns < 1:
+ errors.append("grid_columns must be at least 1")
+ return errors
+
+
+@dataclass(frozen=True)
+class WidgetUi:
+ """Presentation metadata; sizes are in grid units of the parent grid."""
+
+ # (colSpan, rowSpan) for a new placement.
+ default_size: tuple[int, int] | None = None
+ min_col_span: int | None = None
+ max_col_span: int | None = None
+ min_row_span: int | None = None
+ max_row_span: int | None = None
diff --git a/superset-core/src/superset_core/widgets/composites.py b/superset-core/src/superset_core/widgets/composites.py
new file mode 100644
index 00000000000..7b76c4917ac
--- /dev/null
+++ b/superset-core/src/superset_core/widgets/composites.py
@@ -0,0 +1,111 @@
+# 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.
+
+"""
+Reusable, discoverable building blocks for Dashboard V2 widget control models.
+
+A composite control is a ``BaseModel`` mixin carrying one or more fields
+(with their ``x-control`` schema extras already set) that a widget's
+``controls_class`` composes in via plain single inheritance — no nesting, so
+the composed field renders exactly where a directly-declared field would.
+The ``@composite_control`` decorator only registers the class for discovery
+(docs generation, MCP tooling); composing one into a widget never touches the
+registry.
+
+Composing more than one composite control into the same model (multiple
+inheritance across two or more registered mixins) is unsupported for now —
+see the design spec's Scope section.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from types import MappingProxyType
+from typing import Any, Callable, Mapping
+
+from pydantic import BaseModel, ConfigDict, Field
+
+
+@dataclass(frozen=True)
+class CompositeControlInfo:
+ name: str
+ title: str
+ description: str
+ model: type[BaseModel]
+
+
+_registry: dict[str, CompositeControlInfo] = {}
+
+
+def composite_control(
+ name: str, title: str, description: str
+) -> Callable[[type[BaseModel]], type[BaseModel]]:
+ """Register a reusable Pydantic mixin as a discoverable composite control.
+
+ Composing one into a widget's ``controls_class`` is plain inheritance —
+ this decorator only makes the class discoverable via
+ ``list_composite_controls()``.
+ """
+
+ def decorator(cls: type[BaseModel]) -> type[BaseModel]:
+ if name in _registry:
+ raise ValueError(f"composite control {name!r} already registered")
+ _registry[name] = CompositeControlInfo(name, title, description, cls)
+ return cls
+
+ return decorator
+
+
+def list_composite_controls() -> Mapping[str, CompositeControlInfo]:
+ """Read-only view of registered composite controls, for docs/MCP
+ discovery. An extension-defined composite appears only once its defining
+ module has been imported (decorator side effect, same as ``@widget``)."""
+ return MappingProxyType(_registry)
+
+
+@composite_control(
+ name="metric",
+ title="Metrics",
+ description=(
+ "Reusable metric-list field (saved-metric names or ad-hoc SIMPLE aggregates)."
+ ),
+)
+class MetricControl(BaseModel):
+ """Mixin providing a ``metrics`` field, extracted verbatim from
+ ``DataBinding`` for reuse outside it."""
+
+ model_config = ConfigDict(populate_by_name=True)
+
+ metrics: list[Any] = Field(
+ title="Metrics",
+ description=(
+ "Metrics to fetch. Each entry is a string naming a saved metric "
+ 'on the dataset (e.g. "count"), OR an ad-hoc aggregate object, '
+ "either "
+ '{"expressionType": "SIMPLE", "column": {"column_name": "<col>"}, '
+ '"aggregate": "SUM"|"AVG"|"COUNT"|"COUNT_DISTINCT"|"MIN"|"MAX", '
+ '"label": "<optional display label>"} '
+ 'or {"expressionType": "SQL", "sqlExpression": "<raw SQL '
+ 'expression, e.g. \\"SUM(sales)\\">", '
+ '"label": "<optional display label>"}. Do not pass a raw SQL '
+ "string directly in place of an entry — a plain string is always "
+ "looked up as a saved-metric name, not evaluated as an "
+ "expression; a SQL expression must be wrapped in the "
+ '{"expressionType": "SQL", ...} object above.'
+ ),
+ json_schema_extra={"x-control": "metric-multi", "x-language": "json"},
+ )
diff --git a/superset-core/src/superset_core/widgets/decorators.py b/superset-core/src/superset_core/widgets/decorators.py
new file mode 100644
index 00000000000..527b2ba629e
--- /dev/null
+++ b/superset-core/src/superset_core/widgets/decorators.py
@@ -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.
+
+"""
+Widget registration decorator for Superset.
+
+This module provides the decorator an extension (or the host) uses to register
+a Dashboard V2 widget with the host application, enabling automatic discovery —
+the same contract built-in widgets use.
+
+Usage:
+ from superset_core.widgets.base import Widget
+ from superset_core.widgets.decorators import widget
+
+ @widget(
+ widget_type="my-widget",
+ name="My Widget",
+ description="...",
+ )
+ class MyWidget(Widget):
+ controls_class = MyWidgetControlsModel
+"""
+
+from __future__ import annotations
+
+from typing import Callable, TypeVar
+
+# Type variable for decorated widget classes
+T = TypeVar("T")
+
+
+def widget(
+ widget_type: str,
+ name: str,
+ description: str | None = None,
+) -> Callable[[T], T]:
+ """
+ Decorator to register a widget.
+
+ Automatically detects extension context and applies appropriate
+ namespacing to prevent ``widget_type`` conflicts between the host and
+ extension widgets, and raises on a genuine collision so one widget cannot
+ silently replace another's schema.
+
+ Host implementations replace this function during initialization with a
+ concrete implementation providing actual functionality.
+
+ Args:
+ widget_type: Unique widget type identifier (e.g., "metric-tile",
+ "balloons"), matching the dashboard node's ``type``. Used as the key
+ in the widget registry.
+ name: Human-readable display name (e.g., "Metric Tile"). Shown in the UI
+ when listing available widget types.
+ description: Optional description for documentation and UI tooltips.
+
+ Returns:
+ The decorated widget class, registered with the host application.
+
+ Raises:
+ NotImplementedError: If called before the host implementation is
+ initialized.
+
+ Example:
+ from superset_core.widgets.base import Widget
+ from superset_core.widgets.decorators import widget
+
+ @widget(
+ widget_type="my-widget",
+ name="My Widget",
+ description="A custom widget",
+ )
+ class MyWidget(Widget):
+ controls_class = MyWidgetControlsModel
+ """
+ raise NotImplementedError(
+ "Widget decorator not initialized. "
+ "This decorator should be replaced during Superset startup."
+ )
diff --git a/superset-core/src/superset_core/widgets/enrichment.py b/superset-core/src/superset_core/widgets/enrichment.py
new file mode 100644
index 00000000000..b7e8354f40f
--- /dev/null
+++ b/superset-core/src/superset_core/widgets/enrichment.py
@@ -0,0 +1,160 @@
+# 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.
+
+"""
+Dependency graph and execution engine for a widget's dynamic (``x-dynamic``)
+control-schema fields.
+
+A widget registers one enricher callable per dynamic field path (see
+``Widget.enrichers``). Each field's existing ``x-dependsOn`` list (already
+used to gate enrichment via ``check_dependencies``) does double duty here:
+an entry naming another dynamic field's path becomes an ordering edge (that
+field's enricher must run first, and this one receives its result); any
+other entry stays a plain truthiness gate against the parsed control values.
+Field paths use ``a/b`` dot-path notation, the same convention
+``schema_tools.py`` uses for drill-in paths.
+
+This module only computes the graph and runs enrichers in order — it has no
+opinion on where the schema or enrichers come from; ``Widget.get_control_schema``
+wires it up.
+"""
+
+from __future__ import annotations
+
+from typing import Any, Callable
+
+from pydantic import BaseModel
+
+from superset_core.semantic_layers.config import check_dependencies
+
+# (schema, node, parsed, series, upstream_results) -> Any.
+# `schema` is the full document (for cross-$defs lookups, e.g. a sibling
+# style definition); `node` is this field's own schema fragment, mutated in
+# place. The return value is threaded to enrichers ordered after this one, as
+# `upstream_results[path]`.
+EnricherFn = Callable[
+ [dict[str, Any], dict[str, Any], "BaseModel | None", list[str], dict[str, Any]],
+ Any,
+]
+
+
+def _defs(schema: dict[str, Any]) -> dict[str, Any]:
+ return schema.get("$defs", {}) or {}
+
+
+def _deref(schema: dict[str, Any], node: dict[str, Any]) -> dict[str, Any]:
+ if "$ref" not in node:
+ return node
+ return _defs(schema).get(node["$ref"].split("/")[-1], {})
+
+
+def dynamic_field_paths(schema: dict[str, Any]) -> dict[str, dict[str, Any]]:
+ """Walk a built (pre-enrichment) control schema and return
+ ``{path: schema_node}`` for every field carrying ``x-dynamic: true``,
+ using ``a/b`` dot-path notation. Descends into ``properties`` and
+ resolves ``$ref`` against ``$defs`` along the way; does not descend into
+ a discovered dynamic field itself (a dynamic field's own internals are
+ the enricher's concern, not the graph's)."""
+ fields: dict[str, dict[str, Any]] = {}
+
+ def _walk(node: dict[str, Any], prefix: str) -> None:
+ resolved = _deref(schema, node)
+ for name, prop in resolved.get("properties", {}).items():
+ path = f"{prefix}/{name}" if prefix else name
+ prop_resolved = _deref(schema, prop)
+ if prop_resolved.get("x-dynamic"):
+ fields[path] = prop_resolved
+ else:
+ _walk(prop, path)
+
+ _walk(schema, "")
+ return fields
+
+
+def build_dependency_graph(fields: dict[str, dict[str, Any]]) -> dict[str, list[str]]:
+ """``{path: [ordering-edge paths]}`` for every dynamic field in
+ ``fields``. Only ``x-dependsOn`` entries that name another key of
+ ``fields`` become edges; every other entry is left as a gate for
+ ``check_dependencies`` to evaluate at run time, not an edge here."""
+ return {
+ path: [dep for dep in node.get("x-dependsOn", []) if dep in fields]
+ for path, node in fields.items()
+ }
+
+
+def toposort_or_raise(graph: dict[str, list[str]], widget_type: str) -> list[str]:
+ """Kahn's algorithm over ``graph`` (``path -> [paths it depends on]``).
+ Raises ``ValueError`` naming every field on a cycle when the graph isn't
+ a DAG."""
+ in_degree = dict.fromkeys(graph, 0)
+ dependents: dict[str, list[str]] = {path: [] for path in graph}
+ for path, deps in graph.items():
+ in_degree[path] = len(deps)
+ for dep in deps:
+ dependents[dep].append(path)
+
+ ready = sorted(path for path, degree in in_degree.items() if degree == 0)
+ order: list[str] = []
+ while ready:
+ path = ready.pop(0)
+ order.append(path)
+ for dependent in sorted(dependents[path]):
+ in_degree[dependent] -= 1
+ if in_degree[dependent] == 0:
+ ready.append(dependent)
+
+ if len(order) != len(graph):
+ remaining = sorted(set(graph) - set(order))
+ raise ValueError(
+ f"Cyclic control dependency in widget {widget_type!r} among: "
+ f"{', '.join(remaining)}"
+ )
+ return order
+
+
+def run_enrichers(
+ schema: dict[str, Any],
+ fields: dict[str, dict[str, Any]],
+ order: list[str],
+ enrichers: dict[str, EnricherFn],
+ parsed: BaseModel | None,
+ series: list[str],
+) -> None:
+ """Run each path's registered enricher (if any) in ``order``, skipping
+ one whose non-edge ``x-dependsOn`` gate(s) aren't satisfied, and
+ threading each enricher's return value forward as
+ ``upstream_results[path]`` for anything ordered after it."""
+ upstream_results: dict[str, Any] = {}
+ for path in order:
+ enricher = enrichers.get(path)
+ if enricher is None:
+ continue
+ node = fields[path]
+ if parsed is not None:
+ # `check_dependencies` is a plain attribute-truthiness gate — it
+ # has no notion of `fields`, so an ordering-edge entry (naming
+ # another dynamic field's path, e.g. "group/options") would
+ # never resolve via `getattr` and would wrongly fail the gate
+ # for every dependent every time, regardless of whether the
+ # upstream enricher it names already ran. Only the non-edge
+ # subset of `x-dependsOn` is a value gate at all — the edges
+ # are already enforced by `order` itself.
+ gates = [dep for dep in node.get("x-dependsOn", []) if dep not in fields]
+ if not check_dependencies({"x-dependsOn": gates}, parsed):
+ continue
+ result = enricher(schema, node, parsed, series, upstream_results)
+ upstream_results[path] = result
diff --git a/superset-frontend/packages/superset-core/src/canvas/index.ts b/superset-frontend/packages/superset-core/src/canvas/index.ts
index dc895e06b20..e722d1aefa4 100644
--- a/superset-frontend/packages/superset-core/src/canvas/index.ts
+++ b/superset-frontend/packages/superset-core/src/canvas/index.ts
@@ -20,8 +20,9 @@
/**
* @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
+ * A canvas places widget instances: inline ones, stored on the placement as a
+ * widget id and props, or persisted ones, referenced by UUID. Widgets 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.
*
@@ -63,6 +64,8 @@ export interface CanvasChild {
nodeId: string;
/** The child's widget type, when its widget still resolves. */
widgetType?: string;
+ /** The child's props, when it is an inline instance. */
+ props?: Record<string, unknown>;
/** The child's stored layout within this container. */
layout: Record<string, unknown>;
/** The rendered child. */
@@ -72,9 +75,18 @@ export interface CanvasChild {
/** The props the canvas passes to a widget renderer. */
export interface CanvasWidgetProps {
canvasId: number;
+ /** The placement's id, unique within the canvas, e.g. `revenue-trend`. */
nodeId: string;
- widgetId: string;
+ /** The widget id, e.g. `echarts` or `extensions.acme.kpi.kpi`. */
widgetType: string;
+ /** For a persisted instance: its UUID. */
+ instanceId?: string;
+ /**
+ * For an inline instance: its props, valid against the widget's schema at
+ * `schemaVersion`. Only explicitly set values; defaults come from the schema.
+ */
+ props?: Record<string, unknown>;
+ schemaVersion?: number;
/** Values of the filters whose scope includes this node. */
filters: FilterValue[];
/** Values of the cross-filter sources whose scope includes this node. */
diff --git a/superset-frontend/src/features/canvas/CanvasGrid.test.tsx b/superset-frontend/src/features/canvas/CanvasGrid.test.tsx
index eda68f9b056..dfa1cf0f3ce 100644
--- a/superset-frontend/src/features/canvas/CanvasGrid.test.tsx
+++ b/superset-frontend/src/features/canvas/CanvasGrid.test.tsx
@@ -39,12 +39,17 @@ const result: CanvasDefinitionResult = {
children: ['f', 'g', 'lost', 'odd', 'unknown'],
},
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: {} },
- unknown: { widget: 'w-unknown', layout: {} },
+ f: { instance: 'w-filter', layout: {} },
+ g: { instance: 'w-group', layout: {}, children: ['c1'] },
+ c1: {
+ widget: 'test.chart',
+ schemaVersion: 1,
+ props: { title: 'Revenue' },
+ layout: { colSpan: 6 },
+ },
+ lost: { instance: 'w-gone', layout: {} },
+ odd: { instance: 'w-odd', layout: {} },
+ unknown: { instance: 'w-unknown', layout: {} },
},
interactions: { filters: {}, crossFilters: {}, customizations: {} },
settings,
@@ -71,7 +76,7 @@ const result: CanvasDefinitionResult = {
};
const Chart = ({
- widgetId,
+ props,
filters,
crossFilters,
colors,
@@ -79,7 +84,7 @@ const Chart = ({
refreshKey,
}: canvasApi.CanvasWidgetProps) => (
<div>
- <span>{`chart ${widgetId} filters=${JSON.stringify(filters)}`}</span>
+ <span>{`chart ${props?.title} filters=${JSON.stringify(filters)}`}</span>
<span>{`cross=${JSON.stringify(crossFilters)}`}</span>
<span>{`colors=${colors.scheme} timestamp=${showTimestamp} refresh=${refreshKey}`}</span>
</div>
@@ -125,7 +130,7 @@ test('renders widgets through their renderers, nested in grid containers', () =>
renderGrid();
expect(screen.getByRole('region', { name: 'group' })).toHaveTextContent(
- 'chart w-chart filters=[]',
+ 'chart Revenue filters=[]',
);
expect(
screen.getByText('colors=supersetColors timestamp=true refresh=2'),
@@ -147,7 +152,7 @@ test('filter values reach the nodes in the filter scope', () => {
expect(
screen.getByText(
- 'chart w-chart filters=[{"filterNodeId":"f","value":"EMEA"}]',
+ 'chart Revenue filters=[{"filterNodeId":"f","value":"EMEA"}]',
),
).toBeInTheDocument();
diff --git a/superset-frontend/src/features/canvas/CanvasGrid.tsx b/superset-frontend/src/features/canvas/CanvasGrid.tsx
index a4a977bd561..fdd8a64e0b9 100644
--- a/superset-frontend/src/features/canvas/CanvasGrid.tsx
+++ b/superset-frontend/src/features/canvas/CanvasGrid.tsx
@@ -207,6 +207,7 @@ export default function CanvasGrid({
const childNodes: canvasApi.CanvasChild[] = childIds.map(childId => ({
nodeId: childId,
widgetType: widgetTypes[childId],
+ props: definition.nodes[childId].props,
layout: definition.nodes[childId].layout,
element: renderNode(childId),
}));
@@ -216,8 +217,10 @@ export default function CanvasGrid({
<Renderer
canvasId={canvasId}
nodeId={nodeId}
- widgetId={node.widget}
widgetType={widgetType}
+ instanceId={node.instance}
+ props={node.props}
+ schemaVersion={node.schemaVersion}
filters={filtersByNode[nodeId] ?? NO_VALUES}
crossFilters={crossFiltersByNode[nodeId] ?? NO_VALUES}
customizations={customizationsByNode[nodeId] ?? NO_VALUES}
diff --git a/superset-frontend/src/features/canvas/builtinRenderers.test.tsx b/superset-frontend/src/features/canvas/builtinRenderers.test.tsx
new file mode 100644
index 00000000000..92abab52c23
--- /dev/null
+++ b/superset-frontend/src/features/canvas/builtinRenderers.test.tsx
@@ -0,0 +1,83 @@
+/**
+ * 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 { render, screen } from 'spec/helpers/testing-library';
+import CanvasGrid, { emptyScopeValues } from './CanvasGrid';
+import { registerBuiltinRenderers } from './builtinRenderers';
+import { CanvasDefinitionResult } from './types';
+
+const inline = (
+ widget: string,
+ props: Record<string, unknown> = {},
+ children?: string[],
+) => ({ widget, schemaVersion: 1, props, layout: {}, children });
+
+const result: CanvasDefinitionResult = {
+ version: 1,
+ revision: 1,
+ definition: {
+ version: 1,
+ root: {
+ layout: { columns: 24, gap: 16, rowUnit: 40 },
+ children: ['overview', 'kpis'],
+ },
+ nodes: {
+ overview: inline('tabs', {}, ['emea', 'apac']),
+ emea: inline('tab', { title: 'EMEA' }, ['notes']),
+ apac: inline('tab', {}, []),
+ notes: inline('markdown', { content: '**Quarterly** notes' }),
+ kpis: inline('group', { title: 'Key numbers' }, []),
+ },
+ interactions: { filters: {}, crossFilters: {}, customizations: {} },
+ settings: {
+ refresh: { interval: 0, stagger: 0, exempt: [] },
+ colors: { labelColors: {} },
+ display: { showTimestamps: false },
+ crossFilters: { enabled: true },
+ },
+ },
+ filterScopes: {},
+ crossFilterScopes: {},
+ customizationScopes: {},
+ placements: {},
+ widgetTypes: {
+ overview: 'tabs',
+ emea: 'tab',
+ apac: 'tab',
+ notes: 'markdown',
+ kpis: 'group',
+ },
+ gridColumns: { emea: 24, apac: 24, kpis: 12 },
+};
+
+test('renders core containers and markdown without extensions', () => {
+ registerBuiltinRenderers();
+ render(
+ <CanvasGrid
+ canvasId={1}
+ result={result}
+ values={emptyScopeValues()}
+ onValueChange={jest.fn()}
+ />,
+ );
+
+ expect(screen.getByRole('tab', { name: 'EMEA' })).toBeInTheDocument();
+ expect(screen.getByRole('tab', { name: 'Untitled tab' })).toBeInTheDocument();
+ expect(screen.getByText('Quarterly')).toBeInTheDocument();
+ expect(screen.getByText('Key numbers')).toBeInTheDocument();
+});
diff --git a/superset-frontend/src/features/canvas/builtinRenderers.tsx b/superset-frontend/src/features/canvas/builtinRenderers.tsx
new file mode 100644
index 00000000000..d239c02ec2a
--- /dev/null
+++ b/superset-frontend/src/features/canvas/builtinRenderers.tsx
@@ -0,0 +1,73 @@
+/**
+ * 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 { ComponentType } from 'react';
+import type { canvas as canvasApi } from '@apache-superset/core';
+import { t } from '@apache-superset/core/translation';
+import { SafeMarkdown, Tabs, Typography } from '@superset-ui/core/components';
+import { canvas } from 'src/core';
+
+type Props = canvasApi.CanvasWidgetProps;
+
+const text = (value: unknown): string =>
+ typeof value === 'string' ? value : '';
+
+const TabsRenderer = ({ childNodes }: Props) => (
+ <Tabs
+ items={childNodes.map(child => ({
+ key: child.nodeId,
+ label: text(child.props?.title) || t('Untitled tab'),
+ children: child.element,
+ }))}
+ />
+);
+
+const GridRenderer = ({ renderGrid }: Props) => <>{renderGrid()}</>;
+
+const GroupRenderer = ({ props, renderGrid }: Props) => (
+ <section>
+ {text(props?.title) && (
+ <Typography.Title level={5}>{text(props?.title)}</Typography.Title>
+ )}
+ {renderGrid()}
+ </section>
+);
+
+const MarkdownRenderer = ({ props }: Props) => (
+ <SafeMarkdown source={text(props?.content)} />
+);
+
+/** Renderers for the core widgets that need no data: containers and text. */
+export const BUILTIN_RENDERERS: Record<string, ComponentType<Props>> = {
+ tabs: TabsRenderer,
+ tab: GridRenderer,
+ group: GroupRenderer,
+ 'filter.bar': GridRenderer,
+ markdown: MarkdownRenderer,
+};
+
+let registered = false;
+
+/** Registers the built-in renderers once, before the first canvas renders. */
+export function registerBuiltinRenderers(): void {
+ if (registered) return;
+ registered = true;
+ Object.entries(BUILTIN_RENDERERS).forEach(([widgetType, component]) =>
+ canvas.registerWidgetRenderer(widgetType, component),
+ );
+}
diff --git a/superset-frontend/src/features/canvas/types.ts b/superset-frontend/src/features/canvas/types.ts
index 6bea3b65310..7eab53cbf90 100644
--- a/superset-frontend/src/features/canvas/types.ts
+++ b/superset-frontend/src/features/canvas/types.ts
@@ -24,12 +24,29 @@ export interface GridPlacement {
rowSpan: number;
}
-export interface CanvasNode {
- widget: string;
+interface CanvasNodeBase {
layout: Record<string, unknown>;
children?: string[];
}
+/** A placement of a persisted widget instance. */
+export interface PersistedCanvasNode extends CanvasNodeBase {
+ instance: string;
+ widget?: never;
+ schemaVersion?: never;
+ props?: never;
+}
+
+/** A placement holding an inline widget instance. */
+export interface InlineCanvasNode extends CanvasNodeBase {
+ widget: string;
+ schemaVersion: number;
+ props: Record<string, unknown>;
+ instance?: never;
+}
+
+export type CanvasNode = PersistedCanvasNode | InlineCanvasNode;
+
export interface CanvasDefinition {
version: number;
root: {
diff --git a/superset-frontend/src/features/canvas/useCanvasRefresh.test.ts b/superset-frontend/src/features/canvas/useCanvasRefresh.test.ts
index d5bc94774ac..6bf9458184c 100644
--- a/superset-frontend/src/features/canvas/useCanvasRefresh.test.ts
+++ b/superset-frontend/src/features/canvas/useCanvasRefresh.test.ts
@@ -26,9 +26,9 @@ const definition = (
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: {} },
+ a: { instance: 'w-a', layout: {} },
+ b: { instance: 'w-b', layout: {} },
+ c: { instance: 'w-c', layout: {} },
},
interactions: { filters: {}, crossFilters: {}, customizations: {} },
settings: {
diff --git a/superset-frontend/src/pages/Canvas/Canvas.test.tsx b/superset-frontend/src/pages/Canvas/Canvas.test.tsx
index fb8e453a9e0..0a1aa5e3648 100644
--- a/superset-frontend/src/pages/Canvas/Canvas.test.tsx
+++ b/superset-frontend/src/pages/Canvas/Canvas.test.tsx
@@ -42,7 +42,10 @@ const definition = (children: string[], revision = 1) => ({
version: 1,
root: { layout: { columns: 24, gap: 16, rowUnit: 40 }, children },
nodes: Object.fromEntries(
- children.map(nodeId => [nodeId, { widget: `w-${nodeId}`, layout: {} }]),
+ children.map(nodeId => [
+ nodeId,
+ { instance: `w-${nodeId}`, layout: {} },
+ ]),
),
interactions: { filters: {}, crossFilters: {}, customizations: {} },
settings: {
diff --git a/superset-frontend/src/pages/Canvas/index.tsx b/superset-frontend/src/pages/Canvas/index.tsx
index c87f52a5041..06e923f07b5 100644
--- a/superset-frontend/src/pages/Canvas/index.tsx
+++ b/superset-frontend/src/pages/Canvas/index.tsx
@@ -38,9 +38,12 @@ import CanvasGrid, {
} from 'src/features/canvas/CanvasGrid';
import { CanvasMetadata } from 'src/features/canvas/types';
import { useCanvasDefinition } from 'src/features/canvas/useCanvasDefinition';
+import { registerBuiltinRenderers } from 'src/features/canvas/builtinRenderers';
import { useCanvasId } from 'src/features/canvas/useCanvasId';
import { useCanvasRefresh } from 'src/features/canvas/useCanvasRefresh';
+registerBuiltinRenderers();
+
const Page = styled.div`
${({ theme }) => css`
padding: ${theme.sizeUnit * 4}px;
diff --git a/superset/canvas/api.py b/superset/canvas/api.py
index 87f26ec05ff..ebd07ddb2f1 100644
--- a/superset/canvas/api.py
+++ b/superset/canvas/api.py
@@ -29,9 +29,10 @@ from flask_appbuilder.models.sqla.interface import SQLAInterface
from flask_babel import ngettext
from marshmallow import ValidationError
from pydantic import TypeAdapter, ValidationError as PydanticValidationError
-from superset_core.canvas import CanvasLayoutRules, GridPlacement
+from superset_core.canvas import GridPlacement
+from superset_core.widgets import Widget
-from superset.canvas.definition.registry import layout_rules
+from superset.canvas.definition.registry import get_widgets
from superset.canvas.definition.render import render_context
from superset.canvas.definition.schemas import (
ApplyOperationsRequest,
@@ -40,7 +41,8 @@ from superset.canvas.definition.schemas import (
Operation,
)
from superset.canvas.definition.scopes import resolve_scopes
-from superset.canvas.definition.upgrades import DefinitionVersionError
+from superset.canvas.definition.validation import request_pointer
+from superset.canvas.definition.versions import DefinitionVersionError
from superset.canvas.filters import (
CanvasAccessFilter,
CanvasAllTextFilter,
@@ -109,31 +111,39 @@ def _handle_canvas_errors(
def _pydantic_errors(ex: PydanticValidationError) -> list[dict[str, Any]]:
+ """Request errors as JSON-pointer paths into the request body."""
return [
- {"path": list(error["loc"]), "message": error["msg"]} for error in ex.errors()
+ {"path": request_pointer(error["loc"]), "message": error["msg"]}
+ for error in ex.errors()
]
-def describe_rules(rules: type[CanvasLayoutRules]) -> dict[str, Any]:
+def describe_widget(widget: type[Widget]) -> dict[str, Any]:
+ """How a widget takes part in a canvas: nesting, child layout, roles."""
+
def names(types: frozenset[str] | None) -> list[str] | None:
return sorted(types) if types is not None else None
+ behavior, ui = widget.behavior, widget.ui
return {
- "type": rules.widget_type,
- "isContainer": rules.is_container,
- "acceptedChildren": names(rules.accepted_children),
- "allowedParents": names(rules.allowed_parents),
- "gridColumns": rules.grid_columns,
- "childLayout": rules.child_layout_model.model_json_schema(by_alias=True)
- if rules.child_layout_model is not None
+ "id": widget.widget_type,
+ "name": widget.name,
+ "schemaVersion": widget.schema_version,
+ "container": behavior.container,
+ "acceptedChildren": names(behavior.accepted_children),
+ "allowedParents": names(behavior.allowed_parents),
+ "gridColumns": behavior.grid_columns,
+ "childLayout": behavior.child_layout_model.model_json_schema(by_alias=True)
+ if behavior.child_layout_model is not None
else None,
- "isFilter": rules.is_filter,
- "isCrossFilterSource": rules.is_cross_filter_source,
- "isCustomization": rules.is_customization,
- "isFilterable": rules.is_filterable,
- "boundsFilterScope": rules.bounds_filter_scope,
- "colSpan": {"min": rules.min_col_span, "max": rules.max_col_span},
- "rowSpan": {"min": rules.min_row_span, "max": rules.max_row_span},
+ "filter": behavior.filter,
+ "emitsFilters": behavior.emits_filters,
+ "customization": behavior.customization,
+ "filterable": behavior.filterable,
+ "boundsFilterScope": behavior.bounds_filter_scope,
+ "defaultSize": list(ui.default_size) if ui.default_size else None,
+ "colSpan": {"min": ui.min_col_span, "max": ui.max_col_span},
+ "rowSpan": {"min": ui.min_row_span, "max": ui.max_row_span},
}
@@ -364,8 +374,9 @@ class CanvasRestApi(BaseSupersetModelRestApi):
delete:
summary: Delete a canvas
description: >-
- Deletes the canvas and its operation log. The widgets it placed are
- separate entities and are not deleted.
+ Deletes the canvas, its inline widget instances and its operation
+ log. Persisted widget instances it placed are separate entities and
+ are not deleted.
parameters:
- in: path
schema:
@@ -663,11 +674,12 @@ class CanvasRestApi(BaseSupersetModelRestApi):
"""Get the canvas definition contract.
---
get:
- summary: Get the canvas definition schema and layout rules
+ summary: Get the canvas definition schema and widget behavior
description: >-
JSON Schemas for the definition, its operations and grid placement,
- plus the layout rules registered for container, filter and
- filterable widget types. Widget types without rules are leaves.
+ plus how each registered widget takes part in a canvas: nesting,
+ child layout and filter roles. A widget's props schema is served
+ by the widget API.
responses:
200:
description: The canvas contract
@@ -681,6 +693,6 @@ class CanvasRestApi(BaseSupersetModelRestApi):
"definition": CanvasDefinition.model_json_schema(by_alias=True),
"operation": TypeAdapter(Operation).json_schema(by_alias=True),
"gridPlacement": GridPlacement.model_json_schema(by_alias=True),
- "widgetTypes": [describe_rules(rules) for rules in layout_rules],
+ "widgets": [describe_widget(w) for w in get_widgets().values()],
},
)
diff --git a/superset/canvas/definition/ops.py b/superset/canvas/definition/ops.py
index c65e94f4925..07cd4d27f15 100644
--- a/superset/canvas/definition/ops.py
+++ b/superset/canvas/definition/ops.py
@@ -26,18 +26,19 @@ since the revision the caller last saw (see ``overlapping``).
from __future__ import annotations
import copy
-from collections.abc import Callable, Iterable
+from collections.abc import Callable, Container, Iterable
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
-from uuid import uuid4
-from superset_core.canvas import WidgetResolver
+from superset_core.canvas import InstanceResolver
+from superset_core.widgets import Widget
+from superset.canvas.definition.placements import new_placement_id, placement_widgets
from superset.canvas.definition.registry import (
- get_widget_resolver,
- layout_rules,
- LayoutRulesRegistry,
+ get_instance_resolver,
+ get_widgets,
+ WidgetRegistry,
)
from superset.canvas.definition.schemas import (
AddOp,
@@ -47,8 +48,10 @@ from superset.canvas.definition.schemas import (
RemoveOp,
ROOT_ID,
SCOPE_FIELDS,
+ SetPropsOp,
SetScopeOp,
SetSettingsOp,
+ SETTINGS_ID,
)
from superset.canvas.definition.validation import normalize_definition
@@ -57,6 +60,8 @@ class FieldGroup(str, Enum):
# Existence and position in the tree; overlaps with every other group.
TREE = "tree"
LAYOUT = "layout"
+ # An inline instance's props.
+ PROPS = "props"
# A node's scope override, per kind.
FILTER_SCOPE = "filterScope"
CROSS_FILTER_SCOPE = "crossFilterScope"
@@ -75,10 +80,6 @@ SCOPE_GROUPS = {
}
-# Stands in for the settings in touches; never a node id, which is a UUID.
-SETTINGS_ID = "settings"
-
-
@dataclass(frozen=True)
class Touch:
node_id: str
@@ -95,6 +96,7 @@ class AppliedOperation:
class OperationError(ValueError):
def __init__(self, index: int, message: str) -> None:
self.index = index
+ self.message = message
super().__init__(f"operation {index}: {message}")
@@ -130,6 +132,8 @@ def named_touches(ops: Iterable[Operation]) -> list[Touch]:
touches += [Touch(op.id, FieldGroup.TREE), Touch(op.id, FieldGroup.LAYOUT)]
elif isinstance(op, PlaceOp):
touches.append(Touch(op.id, FieldGroup.LAYOUT))
+ elif isinstance(op, SetPropsOp):
+ touches.append(Touch(op.id, FieldGroup.PROPS))
elif isinstance(op, SetScopeOp):
touches.append(Touch(op.id, SCOPE_GROUPS[op.kind]))
elif isinstance(op, SetSettingsOp):
@@ -141,14 +145,22 @@ class _Applier:
def __init__(
self,
canvas: dict[str, Any],
- new_id: Callable[[], str],
- is_container: Callable[[str], bool],
+ new_id: Callable[[str, Container[str]], str],
+ widgets: WidgetRegistry,
+ resolver: InstanceResolver,
) -> None:
self.canvas = copy.deepcopy(canvas)
self.new_id = new_id
- # Takes a widget id. Nodes that are no longer resolvable keep their
- # stored children but accept no new ones.
- self.is_container = is_container
+ self.widgets = widgets
+ self.resolver = resolver
+
+ def is_container(self, node_id: str) -> bool:
+ # Placements that no longer resolve keep their stored children but
+ # accept no new ones.
+ resolved = placement_widgets(
+ {node_id: self.nodes[node_id]}, self.widgets, self.resolver
+ )
+ return node_id in resolved and resolved[node_id].behavior.container
@property
def nodes(self) -> dict[str, Any]:
@@ -163,7 +175,7 @@ class _Applier:
if parent_id == ROOT_ID:
return self.canvas["root"]["children"]
parent = self.node(parent_id)
- if not self.is_container(parent["widget"]):
+ if not self.is_container(parent_id):
raise ValueError(f"{parent_id!r} cannot hold children")
return parent.setdefault("children", [])
@@ -201,14 +213,9 @@ class _Applier:
def apply(self, op: Operation) -> AppliedOperation:
logged = op.model_dump(mode="json", by_alias=True, exclude_none=True)
if isinstance(op, AddOp):
- node_id = op.id or self.new_id()
- if node_id in self.nodes:
- raise ValueError(f"node {node_id!r} already exists")
- self.insert(self.children(op.parent), node_id, op.index)
- self.nodes[node_id] = {"widget": op.widget, "layout": op.layout}
- return AppliedOperation(
- {**logged, "id": node_id}, [Touch(node_id, FieldGroup.TREE)]
- )
+ return self.add(op, logged)
+ if isinstance(op, SetPropsOp):
+ return self.set_props(op, logged)
if isinstance(op, RemoveOp):
self.node(op.id)
self._siblings(self.parent_of(op.id)).remove(op.id)
@@ -245,6 +252,72 @@ class _Applier:
self.node(op.id)["layout"] = op.layout
return AppliedOperation(logged, [Touch(op.id, FieldGroup.LAYOUT)])
+ def set_props(self, op: SetPropsOp, logged: dict[str, Any]) -> AppliedOperation:
+ node = self.node(op.id)
+ widget = self.widgets.get(node.get("widget") or "")
+ if widget is None:
+ raise ValueError(
+ f"{op.id!r} is not an inline instance of a registered widget"
+ )
+ node["props"] = op.props
+ node["schemaVersion"] = widget.schema_version
+ return AppliedOperation(logged, [Touch(op.id, FieldGroup.PROPS)])
+
+ def grid_columns(self, parent_id: str) -> int | None:
+ """Columns of ``parent_id``'s grid, or ``None`` if it isn't a grid."""
+ if parent_id == ROOT_ID:
+ return self.canvas["root"].get("layout", {}).get("columns", 24)
+ resolved = placement_widgets(
+ {parent_id: self.node(parent_id)}, self.widgets, self.resolver
+ )
+ widget = resolved.get(parent_id)
+ return widget.behavior.grid_columns if widget is not None else None
+
+ def default_layout(
+ self, widget: type[Widget] | None, parent_id: str, layout: dict[str, Any]
+ ) -> dict[str, Any]:
+ """``layout`` with the widget's default size on a grid, where unset."""
+ columns = self.grid_columns(parent_id)
+ if widget is None or widget.ui.default_size is None or columns is None:
+ return layout
+ col_span, row_span = widget.ui.default_size
+ return {
+ "colSpan": min(col_span, columns),
+ "rowSpan": row_span,
+ **layout,
+ }
+
+ def add(self, op: AddOp, logged: dict[str, Any]) -> AppliedOperation:
+ widget: type[Widget] | None
+ if op.widget is not None:
+ widget = self.widgets.get(op.widget)
+ if widget is None:
+ raise ValueError(f"unknown widget {op.widget!r}")
+ props = op.props or {}
+ title = props.get("title")
+ name = title if isinstance(title, str) and title.strip() else widget.name
+ node: dict[str, Any] = {
+ "widget": op.widget,
+ "schemaVersion": widget.schema_version,
+ "props": props,
+ }
+ else:
+ assert op.instance is not None # noqa: S101
+ widget_type = self.resolver.widget_types({op.instance}).get(op.instance)
+ widget = self.widgets.get(widget_type or "")
+ name = widget.name if widget is not None else "widget"
+ node = {"instance": op.instance}
+ node_id = op.id or self.new_id(name, self.nodes)
+ if node_id in self.nodes:
+ raise ValueError(f"node {node_id!r} already exists")
+ self.insert(self.children(op.parent), node_id, op.index)
+ layout = self.default_layout(widget, op.parent, op.layout)
+ self.nodes[node_id] = {**node, "layout": layout}
+ touched = [Touch(node_id, FieldGroup.TREE)]
+ if op.widget is not None:
+ touched.append(Touch(node_id, FieldGroup.PROPS))
+ return AppliedOperation({**logged, "id": node_id}, touched)
+
def scopes(self, kind: str) -> dict[str, Any]:
interactions = self.canvas.setdefault("interactions", {})
return interactions.setdefault(SCOPE_FIELDS[kind], {})
@@ -299,9 +372,9 @@ def apply_operations(
canvas: dict[str, Any],
ops: list[Operation],
*,
- rules: LayoutRulesRegistry | None = None,
- resolver: WidgetResolver | None = None,
- new_id: Callable[[], str] = lambda: str(uuid4()),
+ widgets: WidgetRegistry | None = None,
+ resolver: InstanceResolver | None = None,
+ new_id: Callable[[str, Container[str]], str] = new_placement_id,
) -> tuple[dict[str, Any], list[AppliedOperation]]:
"""
Apply ``ops`` in order to a copy of ``canvas``.
@@ -310,21 +383,26 @@ def apply_operations(
and an invalid result raises ``DefinitionValidationError``. Returns the
normalized canvas and what each operation touched.
"""
- rules = rules or layout_rules
- resolver = resolver or get_widget_resolver()
-
- def is_container(widget_id: str) -> bool:
- widget_type = resolver.widget_types({widget_id}).get(widget_id)
- return widget_type is not None and rules.get(widget_type).is_container
-
- applier = _Applier(canvas, new_id, is_container)
+ widgets = get_widgets() if widgets is None else widgets
+ resolver = resolver or get_instance_resolver()
+ applier = _Applier(canvas, new_id, widgets, resolver)
applied: list[AppliedOperation] = []
for index, op in enumerate(ops):
try:
applied.append(applier.apply(op))
except ValueError as ex:
raise OperationError(index, str(ex)) from ex
- # Widget rules are enforced on what these operations touched; the rest of
- # the canvas keeps its stored placement even if a rule changed since.
- touched = {touch.node_id for op in applied for touch in op.touched}
- return normalize_definition(applier.canvas, rules, resolver, touched), applied
+ # Widget behavior is enforced on what these operations touched, and props
+ # validated where they were written; the rest of the canvas keeps its
+ # stored form even if a widget changed since.
+ touches = [touch for op in applied for touch in op.touched]
+ return (
+ normalize_definition(
+ applier.canvas,
+ widgets,
+ resolver,
+ strict_nodes={touch.node_id for touch in touches},
+ props_nodes={t.node_id for t in touches if t.group == FieldGroup.PROPS},
+ ),
+ applied,
+ )
diff --git a/superset/canvas/definition/placements.py b/superset/canvas/definition/placements.py
new file mode 100644
index 00000000000..0081a42d325
--- /dev/null
+++ b/superset/canvas/definition/placements.py
@@ -0,0 +1,103 @@
+# 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.
+"""
+The widget behind each placement, inline props upgrades and placement ids.
+
+A placement resolves when its widget is registered and, for an inline
+instance, its props are at the widget's schema version. Anything else (a
+deleted instance, an uninstalled extension, props saved by a newer widget or
+whose migration fails) is an unresolved placeholder, kept exactly as stored.
+"""
+
+from __future__ import annotations
+
+import re
+from collections.abc import Container
+from typing import Any
+
+from superset_core.canvas import InstanceResolver
+from superset_core.widgets import PropsVersionError, Widget
+
+from superset.canvas.definition.registry import WidgetRegistry
+from superset.canvas.definition.schemas import NODE_ID_MAX_LENGTH, RESERVED_IDS
+
+
+def placement_widgets(
+ nodes: dict[str, Any],
+ widgets: WidgetRegistry,
+ resolver: InstanceResolver,
+) -> dict[str, type[Widget]]:
+ """``{placement id: widget}`` for every placement that resolves."""
+ instance_types = resolver.widget_types(
+ {node["instance"] for node in nodes.values() if node.get("instance")}
+ )
+ resolved: dict[str, type[Widget]] = {}
+ for node_id, node in nodes.items():
+ if node.get("instance"):
+ widget = widgets.get(instance_types.get(node["instance"], ""))
+ else:
+ widget = widgets.get(node.get("widget") or "")
+ if widget is not None and node.get("schemaVersion") != (
+ widget.schema_version
+ ):
+ widget = None
+ if widget is not None:
+ resolved[node_id] = widget
+ return resolved
+
+
+def upgrade_inline_props(
+ definition: dict[str, Any], widgets: WidgetRegistry
+) -> dict[str, Any]:
+ """
+ Bring inline props to their widget's schema version, in place.
+
+ Props a widget can't upgrade are left as stored, so the placement stays
+ an unresolved placeholder until the widget can read them.
+ """
+ for node in definition.get("nodes", {}).values():
+ widget = widgets.get(node.get("widget") or "")
+ version = node.get("schemaVersion")
+ if widget is None or not isinstance(version, int):
+ continue
+ if version == widget.schema_version:
+ continue
+ try:
+ node["props"] = widget.upgrade_props(node.get("props") or {}, version)
+ except PropsVersionError:
+ continue
+ node["schemaVersion"] = widget.schema_version
+ return definition
+
+
+def slugify(text: str) -> str:
+ slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
+ return slug[:NODE_ID_MAX_LENGTH].rstrip("-") or "widget"
+
+
+def new_placement_id(base: str, taken: Container[str]) -> str:
+ """A readable id from ``base``, e.g. ``revenue-trend`` or ``revenue-trend-2``."""
+ slug = slugify(base)
+ if slug not in taken and slug not in RESERVED_IDS:
+ return slug
+ counter = 2
+ while True:
+ suffix = f"-{counter}"
+ candidate = slug[: NODE_ID_MAX_LENGTH - len(suffix)].rstrip("-") + suffix
+ if candidate not in taken:
+ return candidate
+ counter += 1
diff --git a/superset/canvas/definition/registry.py b/superset/canvas/definition/registry.py
index 36c2381c8bd..f82efb3c503 100644
--- a/superset/canvas/definition/registry.py
+++ b/superset/canvas/definition/registry.py
@@ -15,56 +15,43 @@
# specific language governing permissions and limitations
# under the License.
"""
-Where the widget side plugs into the canvas.
+Where the canvas looks up widgets and persisted widget instances.
-Widget providers register layout rules for their container types and one
-resolver for widget references. Until a resolver is set, no widget exists,
-so a canvas can hold no nodes.
+Widgets come from the widget registry, which built-ins and extensions register
+into with ``@widget``; persisted instances through an ``InstanceResolver``.
"""
from __future__ import annotations
-from collections.abc import Iterable, Iterator
+from collections.abc import Iterable, Mapping
-from superset_core.canvas import CanvasLayoutRules, WidgetResolver
+from superset_core.canvas import InstanceResolver
+from superset_core.widgets import Widget
+WidgetRegistry = Mapping[str, type[Widget]]
-class LayoutRulesRegistry:
- def __init__(self) -> None:
- self._rules: dict[str, type[CanvasLayoutRules]] = {}
- def register(self, rules: type[CanvasLayoutRules]) -> None:
- if rules.widget_type in self._rules:
- raise ValueError(f"widget type {rules.widget_type!r} already registered")
- self._rules[rules.widget_type] = rules
+def get_widgets() -> WidgetRegistry:
+ from superset.widgets.registry import registry
- def unregister(self, widget_type: str) -> None:
- self._rules.pop(widget_type, None)
+ return registry
- def __iter__(self) -> Iterator[type[CanvasLayoutRules]]:
- return iter(self._rules.values())
- def get(self, widget_type: str) -> type[CanvasLayoutRules]:
- """Rules for ``widget_type``; types without rules are leaves."""
- return self._rules.get(widget_type, CanvasLayoutRules)
-
-
-class _NoWidgets:
- def widget_types(self, widget_ids: Iterable[str]) -> dict[str, str]:
+class _NoInstances:
+ def widget_types(self, instance_ids: Iterable[str]) -> dict[str, str]:
return {}
- def placeable(self, widget_ids: Iterable[str]) -> set[str]:
+ def placeable(self, instance_ids: Iterable[str]) -> set[str]:
return set()
-layout_rules = LayoutRulesRegistry()
-_resolver: WidgetResolver = _NoWidgets()
+_resolver: InstanceResolver = _NoInstances()
-def set_widget_resolver(resolver: WidgetResolver) -> None:
+def set_instance_resolver(resolver: InstanceResolver) -> None:
global _resolver
_resolver = resolver
-def get_widget_resolver() -> WidgetResolver:
+def get_instance_resolver() -> InstanceResolver:
return _resolver
diff --git a/superset/canvas/definition/render.py b/superset/canvas/definition/render.py
index 069ac2cc751..dd092ad3910 100644
--- a/superset/canvas/definition/render.py
+++ b/superset/canvas/definition/render.py
@@ -20,10 +20,10 @@ What a renderer needs besides the stored definition.
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. ``gridColumns`` gives the
-column count of each grid container, whose children's placements are in its
-own grid units.
+``widgetTypes`` maps placements to their widget so clients pick a renderer;
+placements that don't resolve are left out and render as placeholders.
+``gridColumns`` gives the column count of each grid container, whose
+children's placements are in its own grid units.
"""
from __future__ import annotations
@@ -31,43 +31,39 @@ from __future__ import annotations
from typing import Any
from pydantic import ValidationError
-from superset_core.canvas import GridPlacement, WidgetResolver
+from superset_core.canvas import GridPlacement, InstanceResolver
from superset.canvas.definition.grid import resolve_grid
+from superset.canvas.definition.placements import placement_widgets
from superset.canvas.definition.registry import (
- get_widget_resolver,
- layout_rules,
- LayoutRulesRegistry,
+ get_instance_resolver,
+ get_widgets,
+ WidgetRegistry,
)
def render_context(
definition: dict[str, Any],
- rules: LayoutRulesRegistry | None = None,
- resolver: WidgetResolver | None = None,
+ widgets: WidgetRegistry | None = None,
+ resolver: InstanceResolver | None = None,
) -> dict[str, Any]:
- rules = rules or layout_rules
- resolver = resolver or get_widget_resolver()
nodes = definition["nodes"]
- types = resolver.widget_types({node["widget"] for node in nodes.values()})
- widget_types = {
- node_id: types[node["widget"]]
- for node_id, node in nodes.items()
- if node["widget"] in types
- }
+ node_widgets = placement_widgets(
+ nodes,
+ get_widgets() if widgets is None else widgets,
+ resolver or get_instance_resolver(),
+ )
+ widget_types = {node_id: w.widget_type for node_id, w in node_widgets.items()}
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:
- continue
- 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
+ for node_id, widget in node_widgets.items():
+ columns = widget.behavior.grid_columns
+ if widget.behavior.container and columns is not None:
+ grids.append((nodes[node_id].get("children") or [], columns))
+ grid_columns[node_id] = columns
placements: dict[str, dict[str, int]] = {}
for children, columns in grids:
diff --git a/superset/canvas/definition/schemas.py b/superset/canvas/definition/schemas.py
index 0522f2a865d..cc7cf397eff 100644
--- a/superset/canvas/definition/schemas.py
+++ b/superset/canvas/definition/schemas.py
@@ -17,10 +17,11 @@
"""
The dashboard canvas and the operations that change it.
-A canvas is where a dashboard's widgets sit: a flat ``nodes`` map keyed by
-UUID node ids, with the tree expressed through ``children`` id lists.
-A node references a widget by id and holds only its placement; widget
-configuration lives with the widget. The root is not a node: it always
+A canvas is a tree of placements: a flat ``nodes`` map keyed by placement id,
+with the tree expressed through ``children`` id lists. A placement shows one
+widget instance, either persisted (referenced by UUID) or inline (its widget,
+schema version and props stored on the placement), and holds its layout; its
+filter scope lives in ``interactions``. The root is not a node: it always
exists, is always a grid, and is addressed as ``"root"`` in operations.
"""
@@ -34,9 +35,15 @@ from pydantic.alias_generators import to_camel
DEFINITION_VERSION = 1
ROOT_ID = "root"
-# Caller-chosen node ids must be canonical lowercase UUIDs, like the ones the
-# server generates, so every reference to a node spells it the same way.
-NODE_ID_PATTERN = r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
+# Stands in for the settings in the operation log; never a placement id.
+SETTINGS_ID = "settings"
+# Placement ids are readable slugs, unique within a canvas, e.g.
+# ``revenue-trend``; agents read and write them far more cheaply than UUIDs.
+NODE_ID_PATTERN = r"^[a-z0-9]+(?:-[a-z0-9]+)*$"
+NODE_ID_MAX_LENGTH = 64
+RESERVED_IDS = frozenset({ROOT_ID, SETTINGS_ID})
+
+NodeId = Annotated[str, Field(pattern=NODE_ID_PATTERN, max_length=NODE_ID_MAX_LENGTH)]
class _Model(BaseModel):
@@ -57,13 +64,35 @@ class Root(_Model):
class Node(_Model):
- # Id of the placed widget.
- widget: str = Field(min_length=1)
+ """A placement: a persisted instance by UUID, or an inline instance."""
+
+ instance: str | None = Field(default=None, min_length=1)
+ widget: str | None = Field(default=None, min_length=1)
+ # The widget schema version ``props`` conform to.
+ schema_version: int | None = Field(default=None, ge=1)
+ # Explicitly set values only; defaults come from the widget's schema.
+ props: dict[str, Any] | None = None
# Placement within the parent, validated against the parent's rules.
layout: dict[str, Any] = Field(default_factory=dict)
# Present only when the widget is a container.
children: list[str] | None = None
+ @model_validator(mode="after")
+ def _one_instance(self) -> Node:
+ if (self.instance is None) == (self.widget is None):
+ raise ValueError("a placement takes either instance or widget")
+ if self.instance is not None:
+ if self.schema_version is not None or self.props is not None:
+ raise ValueError(
+ "a persisted instance's schemaVersion and props live with "
+ "the instance"
+ )
+ elif self.schema_version is None:
+ raise ValueError("an inline instance needs its schemaVersion")
+ elif self.props is None:
+ self.props = {}
+ return self
+
class FilterScopeMode(str, Enum):
# Filterable widgets under the filter's nearest scoping container.
@@ -150,10 +179,16 @@ SettingsKey = Literal["refresh", "colors", "display", "crossFilters"]
class CanvasDefinition(_Model):
version: Literal[1] = 1
root: Root = Field(default_factory=Root)
- nodes: dict[str, Node] = Field(default_factory=dict)
+ nodes: dict[NodeId, Node] = Field(default_factory=dict)
interactions: Interactions = Field(default_factory=Interactions)
settings: Settings = Field(default_factory=Settings)
+ @model_validator(mode="after")
+ def _no_reserved_ids(self) -> CanvasDefinition:
+ if reserved := RESERVED_IDS.intersection(self.nodes):
+ raise ValueError(f"reserved placement ids: {sorted(reserved)}")
+ return self
+
def empty_definition() -> dict[str, Any]:
return CanvasDefinition().model_dump(mode="json", by_alias=True, exclude_none=True)
@@ -161,20 +196,35 @@ def empty_definition() -> dict[str, Any]:
class AddOp(_Model):
"""
- Place a widget under ``parent``.
+ Place a persisted instance (``instance``) or a new inline one (``widget``
+ and ``props``) under ``parent``.
- The node id is the caller's ``id`` when given, so later operations in the
- same request can reference the new node; otherwise the server assigns one.
+ The placement id is the caller's ``id`` when given, so later operations in
+ the same request can reference it; otherwise the server derives one from
+ the widget's name. Inline props are stored at the widget's current schema
+ version.
"""
op: Literal["add"]
- id: str | None = Field(default=None, pattern=NODE_ID_PATTERN)
- widget: str = Field(min_length=1)
+ id: NodeId | None = None
+ instance: str | None = Field(default=None, min_length=1)
+ widget: str | None = Field(default=None, min_length=1)
+ props: dict[str, Any] | None = None
layout: dict[str, Any] = Field(default_factory=dict)
parent: str = ROOT_ID
# Position in the parent's children (reading order); appended when omitted.
index: int | None = Field(default=None, ge=0)
+ @model_validator(mode="after")
+ def _one_instance(self) -> AddOp:
+ if (self.instance is None) == (self.widget is None):
+ raise ValueError("add takes either instance or widget")
+ if self.instance is not None and self.props is not None:
+ raise ValueError("props are only for an inline widget")
+ if self.id in RESERVED_IDS:
+ raise ValueError(f"{self.id!r} is reserved")
+ return self
+
class RemoveOp(_Model):
"""Remove a node and its whole subtree from the canvas."""
@@ -202,6 +252,17 @@ class PlaceOp(_Model):
layout: dict[str, Any]
+class SetPropsOp(_Model):
+ """
+ Replace an inline instance's props, stored at its widget's current schema
+ version; how a committed draft of an inline instance is written back.
+ """
+
+ op: Literal["set_props"]
+ id: str
+ props: dict[str, Any]
+
+
class SetScopeOp(_Model):
"""
Override which widgets a filter, cross-filter source or customization
@@ -223,7 +284,7 @@ class SetSettingsOp(_Model):
Operation = Annotated[
- Union[AddOp, RemoveOp, MoveOp, PlaceOp, SetScopeOp, SetSettingsOp],
+ Union[AddOp, RemoveOp, MoveOp, PlaceOp, SetPropsOp, SetScopeOp, SetSettingsOp],
Field(discriminator="op"),
]
diff --git a/superset/canvas/definition/scopes.py b/superset/canvas/definition/scopes.py
index 1ad677451f4..bc2c8c22e86 100644
--- a/superset/canvas/definition/scopes.py
+++ b/superset/canvas/definition/scopes.py
@@ -33,12 +33,14 @@ from __future__ import annotations
from typing import Any
-from superset_core.canvas import CanvasLayoutRules, WidgetResolver
+from superset_core.canvas import InstanceResolver
+from superset_core.widgets import Widget
+from superset.canvas.definition.placements import placement_widgets
from superset.canvas.definition.registry import (
- get_widget_resolver,
- layout_rules,
- LayoutRulesRegistry,
+ get_instance_resolver,
+ get_widgets,
+ WidgetRegistry,
)
from superset.canvas.definition.schemas import (
FilterScopeMode,
@@ -46,11 +48,11 @@ from superset.canvas.definition.schemas import (
SCOPE_FIELDS,
)
-# The layout rule that makes a node own each kind of scope.
+# The behavior that makes a placement own each kind of scope.
_ROLES = {
- "filter": "is_filter",
- "crossFilter": "is_cross_filter_source",
- "customization": "is_customization",
+ "filter": "filter",
+ "crossFilter": "emits_filters",
+ "customization": "customization",
}
# Response keys for each kind's resolved scopes.
SCOPE_RESULT_KEYS = {
@@ -73,28 +75,25 @@ def _reading_order(canvas: dict[str, Any], start: str) -> list[str]:
def resolve_scopes(
canvas: dict[str, Any],
- rules: LayoutRulesRegistry | None = None,
- resolver: WidgetResolver | None = None,
+ widgets: WidgetRegistry | None = None,
+ resolver: InstanceResolver | None = None,
) -> dict[str, dict[str, list[str]]]:
"""
Map each kind's response key (``filterScopes``, ``crossFilterScopes``,
``customizationScopes``) to ``{node id: driven node ids}``, in reading order.
"""
- rules = rules or layout_rules
- resolver = resolver or get_widget_resolver()
nodes = canvas["nodes"]
- types = resolver.widget_types({node["widget"] for node in nodes.values()})
- node_rules: dict[str, type[CanvasLayoutRules]] = {
- node_id: rules.get(types[node["widget"]])
- for node_id, node in nodes.items()
- if node["widget"] in types
- }
+ node_widgets = placement_widgets(
+ nodes,
+ get_widgets() if widgets is None else widgets,
+ resolver or get_instance_resolver(),
+ )
parents = {
child: parent
for parent in [ROOT_ID, *nodes]
for child in _children(canvas, parent)
}
- filterable = {n for n, r in node_rules.items() if r.is_filterable}
+ filterable = {n for n, w in node_widgets.items() if w.behavior.filterable}
interactions = canvas.get("interactions", {})
cross_filters_on = (
canvas.get("settings", {}).get("crossFilters", {}).get("enabled", True)
@@ -107,8 +106,8 @@ def resolve_scopes(
if kind == "crossFilter" and not cross_filters_on:
continue
overrides = interactions.get(SCOPE_FIELDS[kind], {})
- for node_id, node_rule in node_rules.items():
- if not getattr(node_rule, role):
+ for node_id, widget in node_widgets.items():
+ if not getattr(widget.behavior, role):
continue
override = overrides.get(node_id, {})
mode = FilterScopeMode(override.get("mode", FilterScopeMode.AUTO))
@@ -118,7 +117,7 @@ def resolve_scopes(
boundary = (
ROOT_ID
if mode == FilterScopeMode.GLOBAL
- else _scope_boundary(node_id, parents, node_rules)
+ else _scope_boundary(node_id, parents, node_widgets)
)
excluded = set(override.get("exclude", []))
candidates = [
@@ -139,13 +138,13 @@ def _children(canvas: dict[str, Any], parent: str) -> list[str]:
def _scope_boundary(
node_id: str,
parents: dict[str, str],
- node_rules: dict[str, type[CanvasLayoutRules]],
+ node_widgets: dict[str, type[Widget]],
) -> str:
current = parents.get(node_id, ROOT_ID)
while current != ROOT_ID:
- container = node_rules.get(current)
+ container = node_widgets.get(current)
# An unresolved container keeps its filters contained.
- if container is None or container.bounds_filter_scope:
+ if container is None or container.behavior.bounds_filter_scope:
return current
current = parents.get(current, ROOT_ID)
return ROOT_ID
diff --git a/superset/canvas/definition/validation.py b/superset/canvas/definition/validation.py
index b485e27547d..399037a84fe 100644
--- a/superset/canvas/definition/validation.py
+++ b/superset/canvas/definition/validation.py
@@ -17,18 +17,21 @@
"""
Whole-canvas validation and normalization.
-Checks the envelope, tree integrity, the nesting rules of widget types and each
-child's layout under its parent, and resolves grid collisions.
-
-A node whose widget no longer exists, or whose container type is no longer
-registered (e.g. its extension was removed), is kept as an unresolved
-placeholder: it and its children stay as stored, so the rest of the canvas can
-still be edited. Whether a new widget may be placed is checked by the caller.
-
-Widget types' rules (nesting, sizes, child layouts, filter roles) can change
-after a canvas is saved. With ``strict_nodes`` given, only those nodes are
-checked against the rules; the others keep their stored placement, so a
-tightened rule never blocks edits elsewhere. Tree integrity is always checked.
+Checks the envelope, tree integrity, each widget's nesting rules, each
+child's layout under its parent and inline props, and resolves grid
+collisions.
+
+A placement whose widget doesn't resolve (see ``placements``) is kept as an
+unresolved placeholder: it and its children stay as stored, so the rest of the
+canvas can still be edited. Whether a persisted instance may be placed is
+checked by the caller.
+
+Widgets' behavior (nesting, sizes, child layouts, filter roles) can change
+after a canvas is saved. With ``strict_nodes`` given, only those placements
+are checked against it; the others keep their stored placement, so a
+tightened rule never blocks edits elsewhere. Inline props are validated where
+they were written (``props_nodes``), so a write can't introduce an error but
+never fails over props it didn't touch. Tree integrity is always checked.
"""
from __future__ import annotations
@@ -38,13 +41,18 @@ from dataclasses import dataclass
from typing import Any
from pydantic import BaseModel, ValidationError
-from superset_core.canvas import CanvasLayoutRules, GridPlacement, WidgetResolver
+from superset_core.canvas import GridPlacement, InstanceResolver
+from superset_core.widgets import Widget
from superset.canvas.definition.grid import resolve_grid, span_errors
+from superset.canvas.definition.placements import (
+ placement_widgets,
+ upgrade_inline_props,
+)
from superset.canvas.definition.registry import (
- get_widget_resolver,
- layout_rules,
- LayoutRulesRegistry,
+ get_instance_resolver,
+ get_widgets,
+ WidgetRegistry,
)
from superset.canvas.definition.schemas import (
CanvasDefinition,
@@ -52,11 +60,11 @@ from superset.canvas.definition.schemas import (
SCOPE_FIELDS,
)
-# The layout rule a node needs to own each kind of scope override.
+# The behavior a placement needs to own each kind of scope override.
SCOPE_ROLES = {
- "filter": ("is_filter", "a filter"),
- "crossFilter": ("is_cross_filter_source", "a cross-filter source"),
- "customization": ("is_customization", "a customization"),
+ "filter": ("filter", "a filter"),
+ "crossFilter": ("emits_filters", "a cross-filter source"),
+ "customization": ("customization", "a customization"),
}
@@ -78,6 +86,19 @@ def pointer(*parts: object) -> str:
)
+def request_pointer(loc: tuple[int | str, ...]) -> str:
+ """
+ A request validation error's location as a JSON pointer into the body.
+
+ Pydantic puts the matched op tag after an op's index (``ops/0/add/layout``);
+ the body has no such level.
+ """
+ parts = list(loc)
+ if len(parts) > 2 and parts[0] == "ops" and isinstance(parts[1], int):
+ del parts[2]
+ return pointer(*parts)
+
+
def _pydantic_issues(prefix: tuple[object, ...], ex: ValidationError) -> list[Issue]:
return [
Issue(pointer(*prefix, *error["loc"]), error["msg"]) for error in ex.errors()
@@ -92,18 +113,19 @@ class _Validator:
def __init__(
self,
raw: dict[str, Any],
- rules: LayoutRulesRegistry,
- resolver: WidgetResolver,
+ widgets: WidgetRegistry,
+ resolver: InstanceResolver,
strict_nodes: Iterable[str] | None,
+ props_nodes: Iterable[str] | None,
) -> None:
self.raw = raw
- self.rules = rules
+ self.widgets = widgets
self.resolver = resolver
self.strict = None if strict_nodes is None else set(strict_nodes)
+ self.props_nodes = None if props_nodes is None else set(props_nodes)
self.issues: list[Issue] = []
self.parents: dict[str, str] = {}
- self.node_rules: dict[str, type[CanvasLayoutRules]] = {}
- self.node_types: dict[str, str] = {}
+ self.node_widgets: dict[str, type[Widget]] = {}
self.unresolved: set[str] = set()
def fail(self, path: str, message: str) -> None:
@@ -112,12 +134,19 @@ class _Validator:
def is_strict(self, node_id: str) -> bool:
return self.strict is None or node_id in self.strict
+ def wrote_props(self, node_id: str) -> bool:
+ return self.props_nodes is None or node_id in self.props_nodes
+
+ def widget_id(self, node_id: str) -> str:
+ return self.node_widgets[node_id].widget_type
+
def run(self) -> dict[str, Any]:
try:
canvas = CanvasDefinition.model_validate(self.raw)
except ValidationError as ex:
raise DefinitionValidationError(_pydantic_issues((), ex)) from ex
doc = canvas.model_dump(mode="json", by_alias=True, exclude_none=True)
+ upgrade_inline_props(doc, self.widgets)
self._check_tree(doc)
self._check_widgets(doc)
@@ -168,46 +197,59 @@ class _Validator:
def _check_widgets(self, doc: dict[str, Any]) -> None:
nodes = doc["nodes"]
- types = self.resolver.widget_types({node["widget"] for node in nodes.values()})
- # A widget may be placed more than once; everything the canvas tracks
- # (placement, scopes, values) is keyed by node.
+ resolved = placement_widgets(nodes, self.widgets, self.resolver)
+ # An instance may be placed more than once; everything the canvas
+ # tracks (placement, scopes, values) is keyed by placement.
for node_id, node in nodes.items():
- widget_id = node["widget"]
- widget_type = types.get(widget_id)
- if widget_type is None:
- self.unresolved.add(node_id)
- continue
- rules = self.rules.get(widget_type)
- if not rules.is_container and node.get("children"):
- self.unresolved.add(node_id)
+ widget = resolved.get(node_id)
+ if widget is not None and (
+ widget.behavior.container or not node.get("children")
+ ):
+ self.node_widgets[node_id] = widget
+ if widget.behavior.container:
+ node.setdefault("children", [])
+ else:
+ node.pop("children", None)
+ if "widget" in node and self.wrote_props(node_id):
+ self._check_props(node_id, widget, node["props"])
continue
- self.node_rules[node_id] = rules
- self.node_types[node_id] = widget_type
- if rules.is_container:
- node.setdefault("children", [])
- else:
- node.pop("children", None)
+ self.unresolved.add(node_id)
+ if "widget" in node and self.wrote_props(node_id):
+ self.fail(
+ pointer("nodes", node_id, "widget"),
+ f"unknown widget {node['widget']!r}, or props at a schema "
+ "version it doesn't support",
+ )
+
+ def _check_props(
+ self, node_id: str, widget: type[Widget], props: dict[str, Any]
+ ) -> None:
+ for error in widget.validate_control_values(props):
+ self.fail(
+ pointer("nodes", node_id, "props", *error["loc"]), error["message"]
+ )
def _check_nesting(self) -> None:
for node_id, parent_id in self.parents.items():
- rules = self.node_rules.get(node_id)
- if rules is None or not self.is_strict(node_id):
+ widget = self.node_widgets.get(node_id)
+ if widget is None or not self.is_strict(node_id):
continue
accepted: frozenset[str] | None = None
if parent_id == ROOT_ID:
parent_type = ROOT_ID
- elif parent_id in self.node_rules:
- parent_type = self.node_types[parent_id]
- accepted = self.node_rules[parent_id].accepted_children
+ elif parent_id in self.node_widgets:
+ parent_type = self.widget_id(parent_id)
+ accepted = self.node_widgets[parent_id].behavior.accepted_children
else:
continue
- widget_type = self.node_types[node_id]
+ widget_type = widget.widget_type
+ allowed_parents = widget.behavior.allowed_parents
allowed = (accepted is None or widget_type in accepted) and (
- rules.allowed_parents is None or parent_type in rules.allowed_parents
+ allowed_parents is None or parent_type in allowed_parents
)
if not allowed:
self.fail(
- pointer("nodes", node_id, "widget"),
+ pointer("nodes", node_id),
f"a {widget_type} widget cannot be placed in {parent_type}",
)
@@ -221,12 +263,12 @@ class _Validator:
self.fail(pointer(*path), f"unknown node {owner_id!r}")
continue
strict = self.is_strict(owner_id)
- rules = self.node_rules.get(owner_id)
- if strict and rules is not None and not getattr(rules, role):
+ owner = self.node_widgets.get(owner_id)
+ if strict and owner is not None and not getattr(owner.behavior, role):
self.fail(pointer(*path), f"node {owner_id!r} is not {noun}")
for field in ("targets", "exclude"):
for index, target in enumerate(scope[field]):
- target_rules = self.node_rules.get(target)
+ target_widget = self.node_widgets.get(target)
if target not in nodes:
self.fail(
pointer(*path, field, index),
@@ -234,8 +276,8 @@ class _Validator:
)
elif (
strict
- and target_rules is not None
- and not target_rules.is_filterable
+ and target_widget is not None
+ and not target_widget.behavior.filterable
):
self.fail(
pointer(*path, field, index),
@@ -253,13 +295,13 @@ class _Validator:
def _resolve_layouts(self, doc: dict[str, Any]) -> None:
self._resolve_children(doc, ROOT_ID, doc["root"]["layout"]["columns"], None)
- for node_id, rules in self.node_rules.items():
- if rules.is_container:
+ for node_id, widget in self.node_widgets.items():
+ if widget.behavior.container:
self._resolve_children(
doc,
node_id,
- rules.grid_columns,
- rules.child_layout_model or _EmptyLayout,
+ widget.behavior.grid_columns,
+ widget.behavior.child_layout_model or _EmptyLayout,
)
def _resolve_children(
@@ -308,20 +350,21 @@ class _Validator:
def _size_errors(
self, node_id: str, layout: dict[str, Any], columns: int
) -> list[str]:
- rules = self.node_rules.get(node_id)
- if rules is None:
+ widget = self.node_widgets.get(node_id)
+ if widget is None:
return []
+ ui = widget.ui
errors = []
spans = {
"colSpan": (
layout.get("colSpan") or columns,
- rules.min_col_span,
- rules.max_col_span,
+ ui.min_col_span,
+ ui.max_col_span,
),
"rowSpan": (
layout.get("rowSpan") or 1,
- rules.min_row_span,
- rules.max_row_span,
+ ui.min_row_span,
+ ui.max_row_span,
),
}
for name, (span, minimum, maximum) in spans.items():
@@ -334,17 +377,23 @@ class _Validator:
def normalize_definition(
raw: dict[str, Any],
- rules: LayoutRulesRegistry | None = None,
- resolver: WidgetResolver | None = None,
+ widgets: WidgetRegistry | None = None,
+ resolver: InstanceResolver | None = None,
strict_nodes: Iterable[str] | None = None,
+ props_nodes: Iterable[str] | None = None,
) -> dict[str, Any]:
"""
Validate ``raw`` and return the normalized canvas to store.
- ``strict_nodes`` limits the widget rule checks to those nodes; ``None``
- checks every node. Raises ``DefinitionValidationError`` listing every
- problem found, each with a JSON-pointer path into the canvas.
+ ``strict_nodes`` limits the widget behavior checks to those placements, and
+ ``props_nodes`` the inline props checks; ``None`` checks every placement.
+ Raises ``DefinitionValidationError`` listing every problem found, each
+ with a JSON-pointer path into the canvas.
"""
return _Validator(
- raw, rules or layout_rules, resolver or get_widget_resolver(), strict_nodes
+ raw,
+ get_widgets() if widgets is None else widgets,
+ resolver or get_instance_resolver(),
+ strict_nodes,
+ props_nodes,
).run()
diff --git a/superset/canvas/definition/upgrades.py b/superset/canvas/definition/versions.py
similarity index 53%
rename from superset/canvas/definition/upgrades.py
rename to superset/canvas/definition/versions.py
index 1f63cc8b020..d8e175b5bb6 100644
--- a/superset/canvas/definition/upgrades.py
+++ b/superset/canvas/definition/versions.py
@@ -15,28 +15,25 @@
# specific language governing permissions and limitations
# under the License.
"""
-Upgrading stored definitions to the current schema version.
-
-Every change to the definition schema bumps ``DEFINITION_VERSION`` and adds an
-upgrade from the previous version to ``UPGRADES``. Definitions are upgraded
-when loaded; the next write stores the upgraded form. A definition newer than
-this server understands is refused rather than read with a partial schema.
+The definition format version.
+
+Every change to the definition format bumps ``DEFINITION_VERSION`` and ships
+an Alembic migration that rewrites stored definitions, with a downgrade that
+converts them back, so stored definitions are always at the current version.
+The migration also clears ``canvas_ops``: logged operations are in the old
+format and can't be checked for overlap, so open clients get ``stale`` and
+reload.
+Additive changes ship expand/contract across two releases. Anything at another
+version, such as a definition from a newer server or an old export, is refused
+rather than read with the wrong schema.
"""
from __future__ import annotations
-import copy
-from collections.abc import Callable, Mapping
from typing import Any
from superset.canvas.definition.schemas import DEFINITION_VERSION
-Upgrade = Callable[[dict[str, Any]], dict[str, Any]]
-
-# Upgrades keyed by the version they upgrade from; each returns the definition
-# at the next version.
-UPGRADES: dict[int, Upgrade] = {}
-
class DefinitionVersionError(ValueError):
def __init__(self, version: object, current: int) -> None:
@@ -52,23 +49,11 @@ class DefinitionVersionError(ValueError):
super().__init__(message)
-def upgrade_definition(
- definition: dict[str, Any],
- current: int = DEFINITION_VERSION,
- upgrades: Mapping[int, Upgrade] | None = None,
+def check_definition_version(
+ definition: dict[str, Any], current: int = DEFINITION_VERSION
) -> dict[str, Any]:
- """Return ``definition`` at version ``current``, upgrading it if older."""
- upgrades = UPGRADES if upgrades is None else upgrades
- version = definition.get("version", 1)
- if not isinstance(version, int) or isinstance(version, bool):
- raise DefinitionVersionError(version, current)
- if version > current or version < 1:
+ """Return ``definition`` if it is at version ``current``; refuse it otherwise."""
+ version = definition.get("version", current)
+ if isinstance(version, bool) or version != current:
raise DefinitionVersionError(version, current)
- if version == current:
- return definition
- upgraded = copy.deepcopy(definition)
- while version < current:
- upgraded = upgrades[version](upgraded)
- version += 1
- upgraded["version"] = version
- return upgraded
+ return definition
diff --git a/superset/commands/canvas/apply_ops.py b/superset/commands/canvas/apply_ops.py
index 9f49215e419..ad2d1b13c42 100644
--- a/superset/commands/canvas/apply_ops.py
+++ b/superset/commands/canvas/apply_ops.py
@@ -33,9 +33,9 @@ from superset.canvas.definition.ops import (
overlapping,
Touch,
)
-from superset.canvas.definition.registry import get_widget_resolver
+from superset.canvas.definition.registry import get_instance_resolver
from superset.canvas.definition.render import render_context
-from superset.canvas.definition.schemas import AddOp, DEFINITION_VERSION, Operation
+from superset.canvas.definition.schemas import AddOp, Operation
from superset.canvas.definition.scopes import resolve_scopes
from superset.canvas.definition.validation import (
DefinitionValidationError,
@@ -77,15 +77,22 @@ class ApplyResult:
def placement_issues(ops: list[Operation]) -> list[Issue]:
- """Widgets being added that the current user may not place."""
- added = [(index, op) for index, op in enumerate(ops) if isinstance(op, AddOp)]
+ """Persisted instances being added that the current user may not place."""
+ added = [
+ (index, op.instance)
+ for index, op in enumerate(ops)
+ if isinstance(op, AddOp) and op.instance is not None
+ ]
if not added:
return []
- allowed = get_widget_resolver().placeable({op.widget for _, op in added})
+ allowed = get_instance_resolver().placeable({instance for _, instance in added})
return [
- Issue(pointer("ops", index, "widget"), "unknown widget, or no access to it")
- for index, op in added
- if op.widget not in allowed
+ Issue(
+ pointer("ops", index, "instance"),
+ "unknown widget instance, or no access to it",
+ )
+ for index, instance in added
+ if instance not in allowed
]
@@ -112,18 +119,17 @@ class ApplyCanvasOperationsCommand(BaseCommand):
self.validate()
canvas = CanvasDAO.lock(self._canvas_id)
current = canvas.revision
- # Logged operations predate the stored definition's upgrade, so they
- # can't be checked for overlap; the log restarts with this write.
- upgrading = canvas.definition_version != DEFINITION_VERSION
if self._base_revision != current:
- if upgrading:
- raise DefinitionConflictError(current, [], stale=True)
self._check_overlap(current, named_touches(self._ops))
try:
definition, applied = apply_operations(CanvasDAO.load(canvas), self._ops)
except OperationError as ex:
- raise DefinitionInvalidError(str(ex), operation_index=ex.index) from ex
+ raise DefinitionInvalidError(
+ str(ex),
+ issues=[Issue(pointer("ops", ex.index), ex.message)],
+ operation_index=ex.index,
+ ) from ex
except DefinitionValidationError as ex:
raise DefinitionInvalidError(
"The operations produce an invalid definition", issues=ex.issues
@@ -136,8 +142,6 @@ class ApplyCanvasOperationsCommand(BaseCommand):
canvas.definition = json.dumps(definition)
canvas.definition_version = definition["version"]
canvas.revision = revision
- if upgrading:
- CanvasDAO.prune(canvas.id)
CanvasDAO.log(canvas.id, revision, applied, get_user_id())
CanvasDAO.prune(canvas.id, revision - OP_LOG_RETAINED_REVISIONS)
return ApplyResult(
diff --git a/superset/commands/canvas/create.py b/superset/commands/canvas/create.py
index 78abc083c9f..763b20d3b5b 100644
--- a/superset/commands/canvas/create.py
+++ b/superset/commands/canvas/create.py
@@ -23,16 +23,16 @@ from typing import Any
from marshmallow import ValidationError
-from superset.canvas.definition.registry import get_widget_resolver
+from superset.canvas.definition.registry import get_instance_resolver
from superset.canvas.definition.schemas import empty_definition
-from superset.canvas.definition.upgrades import (
- DefinitionVersionError,
- upgrade_definition,
-)
from superset.canvas.definition.validation import (
DefinitionValidationError,
normalize_definition,
)
+from superset.canvas.definition.versions import (
+ check_definition_version,
+ DefinitionVersionError,
+)
from superset.commands.base import BaseCommand, CreateMixin
from superset.commands.canvas.exceptions import (
CanvasCreateFailedError,
@@ -67,7 +67,7 @@ class CreateCanvasCommand(CreateMixin, BaseCommand):
def _validate_definition(self, exceptions: list[ValidationError]) -> None:
raw = self._properties.pop("definition", None) or empty_definition()
try:
- definition = normalize_definition(upgrade_definition(raw))
+ definition = normalize_definition(check_definition_version(raw))
except DefinitionVersionError as ex:
exceptions.append(
ValidationError({"/version": [str(ex)]}, field_name="definition")
@@ -81,16 +81,20 @@ class CreateCanvasCommand(CreateMixin, BaseCommand):
)
)
return
- widgets = {node["widget"] for node in definition["nodes"].values()}
- if hidden := widgets - get_widget_resolver().placeable(widgets):
+ instances = {
+ node["instance"]
+ for node in definition["nodes"].values()
+ if "instance" in node
+ }
+ if hidden := instances - get_instance_resolver().placeable(instances):
exceptions.append(
ValidationError(
{
- f"/nodes/{node_id}/widget": [
- "unknown widget, or no access to it"
+ f"/nodes/{node_id}/instance": [
+ "unknown widget instance, or no access to it"
]
for node_id, node in definition["nodes"].items()
- if node["widget"] in hidden
+ if node.get("instance") in hidden
},
field_name="definition",
)
diff --git a/superset/commands/canvas/exceptions.py b/superset/commands/canvas/exceptions.py
index b64f95a3810..4703f512f78 100644
--- a/superset/commands/canvas/exceptions.py
+++ b/superset/commands/canvas/exceptions.py
@@ -69,7 +69,7 @@ class DefinitionConflictError(CommandException):
message = (
_("The canvas changed too much since your revision; reload it.")
if stale
- else _("Nodes you edited were changed since your revision.")
+ else _("Placements you edited were changed since your revision.")
)
super().__init__(message)
diff --git a/superset/core/api/core_api_injection.py b/superset/core/api/core_api_injection.py
index f2d48f31853..82c86d86ce4 100644
--- a/superset/core/api/core_api_injection.py
+++ b/superset/core/api/core_api_injection.py
@@ -281,6 +281,92 @@ def inject_semantic_layer_implementations() -> None:
core_sl_module.semantic_layer = semantic_layer_impl # type: ignore[assignment]
+def inject_widget_implementations() -> None:
+ """
+ Replace the abstract widget decorator in ``superset_core.widgets.decorators``
+ with a concrete implementation that registers widget control sets in the
+ host registry, then register the built-in widgets.
+
+ Registration raises on a genuine ``widget_type`` collision (naming both the
+ existing and the new class) so a careless or malicious extension cannot
+ silently replace another widget's schema; extension registrations are
+ namespaced by publisher/name, mirroring semantic layers.
+
+ A widget whose static schema fails to build (see ``validate_control_schema``
+ below) raises the same way — deliberately: this is meant to be a loud,
+ fail-at-import check, not one an author can silently ship past. For a
+ built-in widget that failure aborts application startup (as any other
+ error importing ``superset.widgets.builtin`` would). For an
+ extension-registered widget, the raise unwinds through that extension's own
+ backend entrypoint import — `init_extensions`'s per-extension `try/except`
+ (in `superset/initialization/__init__.py`) catches it there, so one
+ broken widget's schema disables that whole extension's backend for this
+ process (any other widget or route the same entrypoint module would have
+ registered after it never runs), not just Superset overall or just the
+ one widget — the same containment boundary a collision in an extension's
+ own widget already has.
+
+ Built-ins are imported here — *after* the decorator is concrete — because
+ ``superset.widgets.api`` is imported during ``init_views()``, which runs
+ before this injection. This mirrors how the MCP layer imports its host tool
+ modules only after injecting ``@tool``.
+ """
+ import superset_core.widgets as core_widgets_pkg
+ import superset_core.widgets.decorators as core_widgets_decorators
+
+ from superset.widgets.registry import registry
+
+ def widget_impl(
+ widget_type: str,
+ name: str,
+ description: str | None = None,
+ ) -> Callable[[Any], Any]:
+ def decorator(cls: Any) -> Any:
+ if context := get_current_extension_context():
+ key = (
+ f"extensions.{context.extension.publisher}."
+ f"{context.extension.name}.{widget_type}"
+ )
+ else:
+ key = widget_type
+
+ existing = registry.get(key)
+ if existing is not None and existing is not cls:
+ raise ValueError(
+ f"Widget type {key!r} is already registered by "
+ f"{existing.__module__}.{existing.__qualname__}; "
+ f"{cls.__module__}.{cls.__qualname__} cannot replace it."
+ )
+
+ cls.widget_type = key
+ cls.name = name
+ cls.description = description or ""
+ registry[key] = cls
+ try:
+ # Validate the static schema and dependency graph at import
+ # time. Runtime enrichers may perform permission-scoped data
+ # lookups, so they must wait until a request user exists.
+ cls.validate_control_schema()
+ cls.validate_contract()
+ except Exception:
+ registry.pop(key, None)
+ raise
+ return cls
+
+ return decorator
+
+ # Rebind on both the decorators module and the package re-export, so a
+ # consumer using either `from superset_core.widgets import widget` or
+ # `from superset_core.widgets.decorators import widget` resolves to the
+ # concrete implementation after injection.
+ core_widgets_decorators.widget = widget_impl # type: ignore[assignment]
+ core_widgets_pkg.widget = widget_impl # type: ignore[assignment]
+
+ # Register the built-in widgets now that the decorator is concrete (their
+ # module applies @widget at import time).
+ import superset.widgets.builtin # noqa: F401 pylint: disable=unused-import
+
+
def inject_storage_implementations() -> None:
"""
Replace abstract storage classes in superset_core.extensions.storage with concrete
@@ -329,5 +415,6 @@ def initialize_core_api_dependencies() -> None:
inject_task_implementations()
inject_rest_api_implementations()
inject_semantic_layer_implementations()
+ inject_widget_implementations()
inject_storage_implementations()
inject_extension_context()
diff --git a/superset/daos/canvas.py b/superset/daos/canvas.py
index 11d343a425b..7cc857080ea 100644
--- a/superset/daos/canvas.py
+++ b/superset/daos/canvas.py
@@ -24,7 +24,9 @@ from sqlalchemy import func
from superset import db
from superset.canvas.definition.ops import AppliedOperation
-from superset.canvas.definition.upgrades import upgrade_definition
+from superset.canvas.definition.placements import upgrade_inline_props
+from superset.canvas.definition.registry import get_widgets
+from superset.canvas.definition.versions import check_definition_version
from superset.canvas.filters import CanvasAccessFilter
from superset.daos.base import BaseDAO
from superset.models.canvas import Canvas, CanvasOp
@@ -55,8 +57,12 @@ class CanvasDAO(BaseDAO[Canvas]):
@staticmethod
def load(canvas: Canvas) -> dict[str, Any]:
- """The stored definition, upgraded to the current schema version."""
- return upgrade_definition(json.loads(canvas.definition))
+ """
+ The stored definition, with inline props upgraded to their widgets'
+ schema versions in memory; the next write stores them.
+ """
+ definition = check_definition_version(json.loads(canvas.definition))
+ return upgrade_inline_props(definition, get_widgets())
@staticmethod
def ops_since(canvas_id: int, revision: int) -> list[CanvasOp]:
diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py
index 706e8ce0ed3..3e3ae2154ae 100644
--- a/superset/initialization/__init__.py
+++ b/superset/initialization/__init__.py
@@ -299,8 +299,10 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
appbuilder.add_api(SavedQueryRestApi)
if feature_flag_manager.is_feature_enabled("CANVAS"):
from superset.canvas.api import CanvasRestApi
+ from superset.widgets.api import WidgetControlsRestApi
appbuilder.add_api(CanvasRestApi)
+ appbuilder.add_api(WidgetControlsRestApi)
if feature_flag_manager.is_feature_enabled("SEMANTIC_LAYERS"):
from superset.semantic_layers.api import (
SemanticLayerRestApi,
diff --git a/superset/mcp_service/app.py b/superset/mcp_service/app.py
index 236aec2b418..0a1bc777a6f 100644
--- a/superset/mcp_service/app.py
+++ b/superset/mcp_service/app.py
@@ -796,6 +796,10 @@ from superset.mcp_service.annotation_layer.tool import ( # noqa: F401, E402
list_annotation_layers,
list_layer_annotations,
)
+from superset.mcp_service.canvas.tool import ( # noqa: F401, E402
+ apply_canvas_ops,
+ get_canvas,
+)
from superset.mcp_service.chart import ( # noqa: F401, E402
prompts as chart_prompts,
resources as chart_resources,
@@ -906,6 +910,10 @@ from superset.mcp_service.user.tool import ( # noqa: F401, E402
get_user_info,
list_users,
)
+from superset.mcp_service.widgets.tool import ( # noqa: F401, E402
+ get_widget_control_schema,
+ list_widget_types,
+)
#: Tool names exempt from the mcp_auth_hook protection check. Adding a tool
#: here is a security-significant choice — review carefully. Entries are tools
@@ -999,6 +1007,15 @@ def _remove_tool_quietly(tool_name: str, reason: str) -> None:
pass
+# Registered only with the CANVAS feature flag, like the canvas and widget APIs.
+CANVAS_TOOLS = (
+ "get_canvas",
+ "apply_canvas_ops",
+ "list_widget_types",
+ "get_widget_control_schema",
+)
+
+
def _apply_config_guards(flask_app: Any) -> set[str]:
"""Remove tools whose backing features are administratively disabled.
@@ -1009,6 +1026,8 @@ def _apply_config_guards(flask_app: Any) -> set[str]:
the GLOBAL_TASK_FRAMEWORK feature flag via feature_flag_manager so that
all Superset enablement paths (DEFAULT_FEATURE_FLAGS, GET_FEATURE_FLAGS_FUNC,
IS_FEATURE_ENABLED_FUNC, etc.) are respected.
+ - Canvas and widget tools: mirror the canvas and widget APIs, which are
+ registered only with the CANVAS feature flag.
"""
removed: set[str] = set()
@@ -1019,6 +1038,11 @@ def _apply_config_guards(flask_app: Any) -> set[str]:
_remove_tool_quietly(tool_name, "GLOBAL_TASK_FRAMEWORK not enabled")
removed.add(tool_name)
+ if not feature_flag_manager.is_feature_enabled("CANVAS"):
+ for tool_name in CANVAS_TOOLS:
+ _remove_tool_quietly(tool_name, "CANVAS not enabled")
+ removed.add(tool_name)
+
return removed
diff --git a/superset-core/src/superset_core/canvas/__init__.py b/superset/mcp_service/canvas/__init__.py
similarity index 77%
copy from superset-core/src/superset_core/canvas/__init__.py
copy to superset/mcp_service/canvas/__init__.py
index eee560bcf4b..13a83393a91 100644
--- a/superset-core/src/superset_core/canvas/__init__.py
+++ b/superset/mcp_service/canvas/__init__.py
@@ -14,13 +14,3 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
-
-"""Dashboard canvas contracts for widget providers."""
-
-from superset_core.canvas.base import (
- CanvasLayoutRules,
- GridPlacement,
- WidgetResolver,
-)
-
-__all__ = ["CanvasLayoutRules", "GridPlacement", "WidgetResolver"]
diff --git a/superset/mcp_service/canvas/schemas.py b/superset/mcp_service/canvas/schemas.py
new file mode 100644
index 00000000000..2d6b0d9a88e
--- /dev/null
+++ b/superset/mcp_service/canvas/schemas.py
@@ -0,0 +1,67 @@
+# 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.
+
+"""Response models for the canvas MCP tools."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from pydantic import BaseModel, ConfigDict, Field
+
+
+class GetCanvasResponse(BaseModel):
+ model_config = ConfigDict(populate_by_name=True)
+
+ id: int | None = None
+ title: str | None = None
+ url: str | None = None
+ revision: int | None = None
+ definition: dict[str, Any] | None = None
+ filter_scopes: dict[str, list[str]] | None = Field(
+ default=None, alias="filterScopes"
+ )
+ cross_filter_scopes: dict[str, list[str]] | None = Field(
+ default=None, alias="crossFilterScopes"
+ )
+ customization_scopes: dict[str, list[str]] | None = Field(
+ default=None, alias="customizationScopes"
+ )
+ placements: dict[str, dict[str, int]] | None = None
+ widget_types: dict[str, str] | None = Field(default=None, alias="widgetTypes")
+ grid_columns: dict[str, int] | None = Field(default=None, alias="gridColumns")
+ error: str | None = None
+
+
+class ApplyCanvasOpsResponse(BaseModel):
+ revision: int | None = Field(
+ default=None, description="The canvas revision after the write."
+ )
+ ops: list[dict[str, Any]] | None = Field(
+ default=None, description="The applied operations; adds carry their ids."
+ )
+ error: str | None = None
+ errors: list[dict[str, Any]] | None = None
+ operation: int | None = Field(
+ default=None, description="Index of the operation that failed."
+ )
+ conflicts: list[str] | None = Field(
+ default=None, description="Placements changed by others since base_revision."
+ )
+ stale: bool | None = Field(
+ default=None, description="Reload the canvas: base_revision is too old."
+ )
diff --git a/superset-core/src/superset_core/canvas/__init__.py b/superset/mcp_service/canvas/tool/__init__.py
similarity index 77%
copy from superset-core/src/superset_core/canvas/__init__.py
copy to superset/mcp_service/canvas/tool/__init__.py
index eee560bcf4b..9f95365a5e9 100644
--- a/superset-core/src/superset_core/canvas/__init__.py
+++ b/superset/mcp_service/canvas/tool/__init__.py
@@ -15,12 +15,7 @@
# specific language governing permissions and limitations
# under the License.
-"""Dashboard canvas contracts for widget providers."""
+from .apply_canvas_ops import apply_canvas_ops
+from .get_canvas import get_canvas
-from superset_core.canvas.base import (
- CanvasLayoutRules,
- GridPlacement,
- WidgetResolver,
-)
-
-__all__ = ["CanvasLayoutRules", "GridPlacement", "WidgetResolver"]
+__all__ = ["apply_canvas_ops", "get_canvas"]
diff --git a/superset/mcp_service/canvas/tool/apply_canvas_ops.py b/superset/mcp_service/canvas/tool/apply_canvas_ops.py
new file mode 100644
index 00000000000..eb22c1a6025
--- /dev/null
+++ b/superset/mcp_service/canvas/tool/apply_canvas_ops.py
@@ -0,0 +1,116 @@
+# 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.
+
+"""MCP tool: apply_canvas_ops"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from pydantic import ValidationError
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset.canvas.definition.schemas import ApplyOperationsRequest
+from superset.canvas.definition.validation import request_pointer
+from superset.canvas.definition.versions import DefinitionVersionError
+from superset.commands.canvas.apply_ops import ApplyCanvasOperationsCommand
+from superset.commands.canvas.exceptions import (
+ CanvasForbiddenError,
+ CanvasNotFoundError,
+ DefinitionConflictError,
+ DefinitionInvalidError,
+)
+from superset.mcp_service.canvas.schemas import ApplyCanvasOpsResponse
+
+
+def _apply_canvas_ops_impl(
+ canvas_id: int, base_revision: int, ops: list[dict[str, Any]]
+) -> dict[str, Any]:
+ try:
+ request = ApplyOperationsRequest.model_validate(
+ {"base_revision": base_revision, "ops": ops}
+ )
+ except ValidationError as ex:
+ return {
+ "error": "Invalid operations",
+ "errors": [
+ {"path": request_pointer(error["loc"]), "message": error["msg"]}
+ for error in ex.errors()
+ ],
+ }
+ try:
+ result = ApplyCanvasOperationsCommand(
+ canvas_id, request.base_revision, request.ops
+ ).run()
+ except CanvasNotFoundError:
+ return {"error": f"Canvas {canvas_id} not found, or no access to it"}
+ except CanvasForbiddenError:
+ return {"error": "Only the canvas' editors can change it"}
+ except DefinitionVersionError as ex:
+ return {"error": str(ex)}
+ except (DefinitionConflictError, DefinitionInvalidError) as ex:
+ payload = ex.to_payload()
+ return {"error": payload.pop("message"), **payload}
+ return {"revision": result.revision, "ops": result.ops}
+
+
+@tool(
+ tags=["mutate"],
+ class_permission_name="Canvas",
+ method_permission_name="write",
+ annotations=ToolAnnotations(
+ title="Change a canvas",
+ readOnlyHint=False,
+ destructiveHint=True,
+ idempotentHint=False,
+ openWorldHint=False,
+ ),
+)
+def apply_canvas_ops(
+ canvas_id: int, base_revision: int, ops: list[dict[str, Any]]
+) -> ApplyCanvasOpsResponse:
+ """Apply operations to a canvas, in order and atomically: on any error
+ nothing changes and the error names the operation and the path at fault.
+
+ Operations (placement ids are readable slugs; ``parent`` defaults to
+ ``"root"``):
+
+ - ``{"op": "add", "widget": "<widget id>", "props": {...}, "layout": {...},
+ "parent": "<id>", "index": n, "id": "<optional id>"}`` adds an inline
+ widget; get its props schema with ``get_widget_control_schema``. Use
+ ``"instance": "<uuid>"`` instead of ``widget``/``props`` to place a
+ saved widget instance.
+ - ``{"op": "set_props", "id": "<id>", "props": {...}}`` replaces an inline
+ widget's props.
+ - ``{"op": "place", "id": "<id>", "layout": {"col", "row", "colSpan",
+ "rowSpan"}}`` moves or resizes within the parent's grid.
+ - ``{"op": "move", "id": "<id>", "parent": "<id>", "index": n}``
+ reparents or reorders.
+ - ``{"op": "remove", "id": "<id>"}`` removes a placement and its children.
+ - ``{"op": "set_scope", "kind": "filter" | "crossFilter" |
+ "customization", "id": "<id>", "scope": {"mode": "auto" | "global" |
+ "custom", "targets": [...], "exclude": [...]}}``; ``scope: null``
+ restores auto (the filter's nearest scoping container).
+ - ``{"op": "set_settings", "key": "refresh" | "colors" | "display" |
+ "crossFilters", "value": {...}}``.
+
+ ``base_revision`` is the revision you last read; edits by others since
+ then are merged unless they touched the same placement and aspect.
+ """
+ return ApplyCanvasOpsResponse(
+ **_apply_canvas_ops_impl(canvas_id, base_revision, ops)
+ )
diff --git a/superset/mcp_service/canvas/tool/get_canvas.py b/superset/mcp_service/canvas/tool/get_canvas.py
new file mode 100644
index 00000000000..5213c64004a
--- /dev/null
+++ b/superset/mcp_service/canvas/tool/get_canvas.py
@@ -0,0 +1,78 @@
+# 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.
+
+"""MCP tool: get_canvas"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset.canvas.definition.render import render_context
+from superset.canvas.definition.scopes import resolve_scopes
+from superset.canvas.definition.versions import DefinitionVersionError
+from superset.daos.canvas import CanvasDAO
+from superset.mcp_service.canvas.schemas import GetCanvasResponse
+
+
+def _get_canvas_impl(canvas_id: int, include_resolved: bool) -> dict[str, Any]:
+ canvas = CanvasDAO.find_by_id(canvas_id)
+ if canvas is None:
+ return {"error": f"Canvas {canvas_id} not found, or no access to it"}
+ try:
+ definition = CanvasDAO.load(canvas)
+ except DefinitionVersionError as ex:
+ return {"error": str(ex)}
+ result: dict[str, Any] = {
+ "id": canvas.id,
+ "title": canvas.title,
+ "url": canvas.url,
+ "revision": canvas.revision,
+ "definition": definition,
+ }
+ if include_resolved:
+ result.update(resolve_scopes(definition))
+ result.update(render_context(definition))
+ return result
+
+
+@tool(
+ tags=["discovery"],
+ class_permission_name="Canvas",
+ annotations=ToolAnnotations(
+ title="Get a canvas",
+ readOnlyHint=True,
+ destructiveHint=False,
+ openWorldHint=False,
+ ),
+)
+def get_canvas(canvas_id: int, include_resolved: bool = False) -> GetCanvasResponse:
+ """Get a canvas: its placements, filter scope overrides, settings and
+ revision.
+
+ Placements are keyed by readable ids (``revenue-trend``); use them in
+ ``apply_canvas_ops``. A placement holds an inline widget (``widget``,
+ ``props``) or a persisted instance (``instance``), plus its ``layout``.
+ Pass ``include_resolved=true`` only when you need the server-resolved grid
+ positions (``placements``) or which placements each filter drives
+ (``filterScopes`` and friends); they're derived from the tree and cost
+ context.
+ """
+ return GetCanvasResponse.model_validate(
+ _get_canvas_impl(canvas_id, include_resolved)
+ )
diff --git a/superset/mcp_service/utils/response_size_utils.py b/superset/mcp_service/utils/response_size_utils.py
index 96907b8ef7f..0b50b11f1b5 100644
--- a/superset/mcp_service/utils/response_size_utils.py
+++ b/superset/mcp_service/utils/response_size_utils.py
@@ -432,6 +432,7 @@ def _spec(
# a second one.
COMMITTED_WRITE_SPECS: Dict[str, CommittedWriteSpec] = {
"add_chart_to_existing_dashboard": _spec("dashboard", "dashboard"),
+ "apply_canvas_ops": _spec("canvas", "ops"),
"apply_dashboard_filters": _spec("dashboard"),
"create_dataset": _spec("dataset"),
"create_theme": _spec("theme", reports_success=True),
diff --git a/superset-core/src/superset_core/canvas/__init__.py b/superset/mcp_service/widgets/__init__.py
similarity index 77%
copy from superset-core/src/superset_core/canvas/__init__.py
copy to superset/mcp_service/widgets/__init__.py
index eee560bcf4b..f73ab474f2a 100644
--- a/superset-core/src/superset_core/canvas/__init__.py
+++ b/superset/mcp_service/widgets/__init__.py
@@ -15,12 +15,4 @@
# specific language governing permissions and limitations
# under the License.
-"""Dashboard canvas contracts for widget providers."""
-
-from superset_core.canvas.base import (
- CanvasLayoutRules,
- GridPlacement,
- WidgetResolver,
-)
-
-__all__ = ["CanvasLayoutRules", "GridPlacement", "WidgetResolver"]
+"""MCP tools for Dashboard V2 schema-driven widget controls (experimental)."""
diff --git a/superset-core/src/superset_core/canvas/__init__.py b/superset/mcp_service/widgets/schemas.py
similarity index 51%
copy from superset-core/src/superset_core/canvas/__init__.py
copy to superset/mcp_service/widgets/schemas.py
index eee560bcf4b..ec55c68560d 100644
--- a/superset-core/src/superset_core/canvas/__init__.py
+++ b/superset/mcp_service/widgets/schemas.py
@@ -15,12 +15,32 @@
# specific language governing permissions and limitations
# under the License.
-"""Dashboard canvas contracts for widget providers."""
+"""Response models for the widget MCP tools."""
-from superset_core.canvas.base import (
- CanvasLayoutRules,
- GridPlacement,
- WidgetResolver,
-)
+from __future__ import annotations
-__all__ = ["CanvasLayoutRules", "GridPlacement", "WidgetResolver"]
+from typing import Any
+
+from pydantic import BaseModel, Field
+
+
+class WidgetTypeInfo(BaseModel):
+ id: str
+ name: str
+ description: str
+
+
+class ListWidgetTypesResponse(BaseModel):
+ widget_types: list[WidgetTypeInfo]
+
+
+class WidgetControlSchemaResponse(BaseModel):
+ control_schema: dict[str, Any] | None = Field(
+ default=None,
+ description="The minimum viable root schema, when no paths were given.",
+ )
+ subtrees: dict[str, Any] | None = Field(
+ default=None, description="The requested branches, keyed by path."
+ )
+ error: dict[str, Any] | None = None
+ valid_widget_types: list[str] | None = None
diff --git a/superset-core/src/superset_core/canvas/__init__.py b/superset/mcp_service/widgets/tool/__init__.py
similarity index 77%
copy from superset-core/src/superset_core/canvas/__init__.py
copy to superset/mcp_service/widgets/tool/__init__.py
index eee560bcf4b..bfe8a57ba93 100644
--- a/superset-core/src/superset_core/canvas/__init__.py
+++ b/superset/mcp_service/widgets/tool/__init__.py
@@ -15,12 +15,10 @@
# specific language governing permissions and limitations
# under the License.
-"""Dashboard canvas contracts for widget providers."""
+from .get_widget_control_schema import get_widget_control_schema
+from .list_widget_types import list_widget_types
-from superset_core.canvas.base import (
- CanvasLayoutRules,
- GridPlacement,
- WidgetResolver,
-)
-
-__all__ = ["CanvasLayoutRules", "GridPlacement", "WidgetResolver"]
+__all__ = [
+ "list_widget_types",
+ "get_widget_control_schema",
+]
diff --git a/superset/mcp_service/widgets/tool/get_widget_control_schema.py b/superset/mcp_service/widgets/tool/get_widget_control_schema.py
new file mode 100644
index 00000000000..69f227713a7
--- /dev/null
+++ b/superset/mcp_service/widgets/tool/get_widget_control_schema.py
@@ -0,0 +1,121 @@
+# 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.
+
+"""MCP tool: get_widget_control_schema"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any, Dict, List
+
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset.mcp_service.widgets.schemas import WidgetControlSchemaResponse
+from superset.mcp_service.widgets.utils import (
+ resolve_widget,
+ unknown_widget_type_error,
+)
+from superset.widgets.schema_tools import (
+ get_subtrees,
+ prune_to_minimal_viable,
+ SchemaPathError,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def _get_widget_control_schema_impl(
+ widget_type: str,
+ paths: List[str] | None = None,
+ control_values: Dict[str, Any] | None = None,
+ series: List[str] | None = None,
+) -> Dict[str, Any]:
+ """Pure logic: the minimum-viable root schema, or the requested subtrees."""
+ widget = resolve_widget(widget_type)
+ if widget is None:
+ return unknown_widget_type_error(widget_type)
+ schema = widget.get_control_schema(control_values, series)
+ if not paths:
+ return prune_to_minimal_viable(schema)
+ try:
+ return {"subtrees": get_subtrees(schema, paths)}
+ except SchemaPathError as ex:
+ return {
+ "error": {
+ "error_type": "invalid_path",
+ "message": str(ex),
+ "suggestions": [
+ "Call this tool without `paths` first, then drill into the "
+ "x-path values of its x-collapsed nodes.",
+ ],
+ }
+ }
+
+
+@tool(
+ tags=["discovery"],
+ class_permission_name="Chart",
+ annotations=ToolAnnotations(
+ title="Get widget control schema",
+ readOnlyHint=True,
+ destructiveHint=False,
+ openWorldHint=False,
+ ),
+)
+def get_widget_control_schema(
+ widget_type: str,
+ paths: List[str] | None = None,
+ control_values: Dict[str, Any] | None = None,
+ series: List[str] | None = None,
+) -> WidgetControlSchemaResponse:
+ """Get the control schema for a Dashboard V2 widget type (progressive
+ disclosure).
+
+ Called WITHOUT ``paths``, returns the **minimum viable** root as
+ ``control_schema``: every
+ mandatory field expanded to its leaves (descending into mandatory nested
+ objects), plus cheap optional leaves inline. Optional nested branches are
+ returned as opaque drill-in markers carrying ``x-collapsed: true`` and an
+ ``x-path``; the root is tagged ``x-disclosure: "minimal"``.
+
+ Called WITH ``paths`` (each an ``x-path`` from a marker, e.g.
+ ``["a", "a/b"]``), returns ``{"subtrees": {<path>: <schema>}}`` — one
+ self-contained JSON Schema per requested path, ``$ref`` inlined. Expand
+ several at once to avoid a round trip per branch.
+
+ The root's mandatory fields alone are enough to build a valid widget. Expand
+ a collapsed branch ONLY when the user's request calls for that optional
+ capability (judge from the marker's ``description``) — do NOT pre-fetch
+ speculatively.
+
+ A marker flagged ``x-dynamic: true`` has a shape that depends on the current
+ query (e.g. a map keyed by a dimension's distinct values). To get its
+ ENRICHED shape you must pass ``control_values`` (the current widget ``props``,
+ so the branch's dependency is satisfied) — and any known ``series`` values.
+ ``series`` on its own does nothing: without a satisfying ``control_values``
+ you get the branch's GENERIC shape instead (e.g. a map of
+ ``<value> -> {...}``), which is usually enough to fill it in anyway. Re-fetch
+ a dynamic branch after the query changes. A marker WITHOUT ``x-dynamic`` is
+ static — expand it once and it won't change.
+
+ Returns a structured error for an unknown ``widget_type`` or an unresolvable
+ ``path``.
+ """
+ result = _get_widget_control_schema_impl(widget_type, paths, control_values, series)
+ if "error" in result or "subtrees" in result:
+ return WidgetControlSchemaResponse.model_validate(result)
+ return WidgetControlSchemaResponse(control_schema=result)
diff --git a/superset/mcp_service/widgets/tool/list_widget_types.py b/superset/mcp_service/widgets/tool/list_widget_types.py
new file mode 100644
index 00000000000..bbb6e6cdc26
--- /dev/null
+++ b/superset/mcp_service/widgets/tool/list_widget_types.py
@@ -0,0 +1,66 @@
+# 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.
+
+"""MCP tool: list_widget_types"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any, Dict, List
+
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset.mcp_service.widgets.schemas import ListWidgetTypesResponse
+from superset.mcp_service.widgets.utils import registry
+
+logger = logging.getLogger(__name__)
+
+
+def _list_widget_types_impl() -> List[Dict[str, Any]]:
+ """Pure logic: the registered schema-driven widget types."""
+ return [
+ {
+ "id": cls.widget_type,
+ "name": cls.name,
+ "description": cls.description,
+ }
+ for cls in registry.values()
+ ]
+
+
+@tool(
+ tags=["discovery"],
+ class_permission_name="Chart",
+ annotations=ToolAnnotations(
+ title="List dashboard widget types",
+ readOnlyHint=True,
+ destructiveHint=False,
+ openWorldHint=False,
+ ),
+)
+def list_widget_types() -> ListWidgetTypesResponse:
+ """List the Dashboard V2 widget types that have a schema-driven control
+ panel.
+
+ This is the entry point for progressive disclosure: pick a type's ``id``,
+ then call ``get_widget_control_schema`` for its minimum-viable control schema
+ (call it again with ``paths`` to drill into optional/nested branches).
+ Returns each type's id, human name, and description.
+ """
+ return ListWidgetTypesResponse.model_validate(
+ {"widget_types": _list_widget_types_impl()}
+ )
diff --git a/superset/mcp_service/widgets/utils.py b/superset/mcp_service/widgets/utils.py
new file mode 100644
index 00000000000..f30c46037c3
--- /dev/null
+++ b/superset/mcp_service/widgets/utils.py
@@ -0,0 +1,69 @@
+# 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.
+
+"""
+Shared helpers for the Dashboard V2 widget-control MCP tools.
+
+Kept separate from the decorated tool modules so the pure logic is importable
+and unit-testable without going through the MCP layer (mirrors the
+``get_chart_type_schema`` ``_impl`` pattern).
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from superset_core.widgets import Widget
+
+from superset.widgets.registry import registry
+
+
+def valid_widget_types() -> list[str]:
+ return sorted(cls.widget_type for cls in registry.values())
+
+
+def unknown_widget_type_error(widget_type: str) -> dict[str, Any]:
+ """Structured error for an unknown widget type (mirrors the chart tools)."""
+ return {
+ "error": {
+ "error_type": "invalid_widget_type",
+ "message": f"Unknown widget type: {widget_type!r}",
+ "suggestions": [
+ "Call list_widget_types to see the available types.",
+ ],
+ },
+ "valid_widget_types": valid_widget_types(),
+ }
+
+
+def resolve_widget(widget_type: str) -> type[Widget] | None:
+ return registry.get(widget_type)
+
+
+def unknown_node_error(node_id: str) -> dict[str, Any]:
+ """Structured error for a node id absent from the widget-node store
+ (mirrors ``unknown_widget_type_error``)."""
+ return {
+ "error": {
+ "error_type": "unknown_node",
+ "message": f"Unknown widget node: {node_id!r}",
+ "suggestions": [
+ "The node id must already exist in the widget-node store; "
+ "this tool does not create nodes.",
+ ],
+ }
+ }
diff --git a/superset/models/canvas.py b/superset/models/canvas.py
index d327910133a..52a1abe2617 100644
--- a/superset/models/canvas.py
+++ b/superset/models/canvas.py
@@ -42,10 +42,11 @@ from superset.utils import core as utils, json
class Canvas(AuditMixinNullable, UUIDMixin, Model):
"""
- A canvas and where its widgets are placed.
+ A canvas and its placements of widget instances.
- Widgets are separate entities; ``definition`` only references them by id
- and holds their placement and filter scopes (see ``superset.canvas``).
+ ``definition`` holds the placements, their layout and filter scopes, and
+ inline instances; persisted instances are referenced by UUID (see
+ ``superset.canvas``).
The integer id plus ``uuid`` shape matches the versioned asset models, so
version history can be enabled later by adding ``__versioned__``.
"""
diff --git a/superset/widgets/__init__.py b/superset/widgets/__init__.py
new file mode 100644
index 00000000000..b353f53642f
--- /dev/null
+++ b/superset/widgets/__init__.py
@@ -0,0 +1,35 @@
+# 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.
+"""
+Schema-driven controls for Dashboard V2 widgets (experimental).
+
+Each data-backed widget type declares its control panel as a Pydantic
+model whose JSON Schema *is* the control panel. That single backend-owned schema
+drives both the human control panel (rendered generically with JSONForms in the
+dashboard Inspector) and read-only MCP progressive disclosure, so an agent and a
+human edit the same ``node.props`` by different routes.
+
+This reuses the Semantic Layer's schema machinery verbatim
+(``superset_core.semantic_layers.config.build_configuration_schema`` /
+``check_dependencies``) and the ``x-control`` / ``x-dynamic`` / ``x-dependsOn`` /
+``x-propertyOrder`` conventions.
+
+See ``WIDGET_FRAMEWORK.md`` for the broader proposal. This POC implements only
+the control-schema half of that contract; widget data is still fetched on the
+frontend via the v1 ``/api/v1/chart/data`` path. In the eventual design this
+package lives in ``superset-core``; it is kept in the app here to move quickly.
+"""
diff --git a/superset/widgets/api.py b/superset/widgets/api.py
new file mode 100644
index 00000000000..7669425e200
--- /dev/null
+++ b/superset/widgets/api.py
@@ -0,0 +1,310 @@
+# 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 __future__ import annotations
+
+import logging
+from typing import Any
+
+from flask import make_response, request, Response
+from flask_appbuilder.api import expose, protect, safe
+from flask_babel import gettext as _
+
+from superset.extensions import event_logger
+from superset.utils import json
+from superset.views.base_api import BaseSupersetApi, statsd_metrics
+from superset.widgets.registry import registry
+from superset.widgets.schema_tools import get_subtrees, SchemaPathError
+
+logger = logging.getLogger(__name__)
+
+
+class WidgetControlsRestApi(BaseSupersetApi):
+ """
+ Schema-driven controls for Dashboard V2 widgets (experimental).
+
+ Serves the backend-owned control JSON Schema that drives both the dashboard
+ Inspector's control panel and read-only MCP progressive disclosure. Widget
+ data is fetched separately via the v1 chart-data path on the frontend, so
+ there is no data endpoint here. The resource name ``widgets`` is
+ a placeholder (see ``WIDGET_FRAMEWORK.md``).
+ """
+
+ resource_name = "widgets"
+ allow_browser_login = True
+ # Read-only schema serving; reuse the existing Chart permission so no new
+ # permission is introduced.
+ class_permission_name = "Chart"
+ method_permission_name = {
+ "types": "read",
+ "control_schema": "read",
+ "validate": "read",
+ }
+ openapi_spec_tag = "Dashboard Controls (experimental)"
+
+ @expose("/types", methods=("GET",))
+ @protect()
+ @safe
+ @statsd_metrics
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.types",
+ log_to_statsd=False,
+ )
+ def types(self) -> Response:
+ """List registered building-widget control sets.
+ ---
+ get:
+ summary: List widget types that have a schema-driven control panel
+ responses:
+ 200:
+ description: A list of widget types
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ result:
+ type: array
+ items:
+ type: object
+ 401:
+ $ref: '#/components/responses/401'
+ """
+ result = [
+ {"id": cls.widget_type, "name": cls.name, "description": cls.description}
+ for cls in registry.values()
+ ]
+ return self.response(200, result=result)
+
+ @expose("/type/<widget_type>/control-schema", methods=("GET", "POST"))
+ @protect()
+ @safe
+ @statsd_metrics
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs: (
+ f"{self.__class__.__name__}.control_schema"
+ ),
+ log_to_statsd=False,
+ )
+ def control_schema(self, widget_type: str) -> Response:
+ """Return the control JSON Schema for a widget type (progressive
+ disclosure).
+
+ ``GET`` returns the base schema (no enrichment) — enough to discover a
+ type's fields and required props without a CSRF-bearing request. ``POST``
+ additionally accepts ``control_values``/``series`` to enrich x-dynamic
+ fields, and an optional ``paths`` array to return just those subtrees
+ (a ``{path: schema}`` map) instead of the whole schema.
+ ---
+ get:
+ summary: Get the base control schema for a widget type
+ parameters:
+ - in: path
+ name: widget_type
+ required: true
+ schema:
+ type: string
+ responses:
+ 200:
+ description: Control JSON Schema
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ result:
+ type: object
+ 404:
+ $ref: '#/components/responses/404'
+ post:
+ summary: Get the enriched control schema (or requested subtrees)
+ parameters:
+ - in: path
+ name: widget_type
+ required: true
+ schema:
+ type: string
+ requestBody:
+ required: false
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ control_values:
+ type: object
+ series:
+ type: array
+ items:
+ type: string
+ paths:
+ type: array
+ items:
+ type: string
+ description: >-
+ When given, return a `{path: schema}` map of just these
+ drill-in subtrees instead of the whole schema.
+ responses:
+ 200:
+ description: Control JSON Schema, or a `{path: schema}` map
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ result:
+ type: object
+ warning:
+ type: string
+ 400:
+ $ref: '#/components/responses/400'
+ 404:
+ $ref: '#/components/responses/404'
+ """
+ widget = registry.get(widget_type)
+ if widget is None:
+ return self.response_404()
+ # GET carries no body (and no CSRF); tolerate a missing/non-JSON body so
+ # the base schema is returned for a plain read. A body that IS present
+ # must be a JSON object.
+ body = request.get_json(silent=True) or {}
+ if not isinstance(body, dict):
+ return self.response_400(message="Request body must be a JSON object.")
+ control_values = body.get("control_values")
+ series = body.get("series")
+ paths = body.get("paths")
+ if paths is not None and (
+ not isinstance(paths, list)
+ or not all(isinstance(path, str) for path in paths)
+ ):
+ return self.response_400(message="'paths' must be an array of strings.")
+
+ warning: str | None = None
+ try:
+ schema = widget.get_control_schema(control_values, series)
+ except Exception: # pylint: disable=broad-except
+ # Enrichment can fail (e.g. malformed dynamic values); showing the
+ # base form is better than a hard error — mirror the Semantic Layer.
+ warning = str(
+ _(
+ "Could not enrich the controls for this widget; showing the "
+ "default form. See the server logs for details."
+ )
+ )
+ logger.exception(
+ "Error enriching control schema for widget type %s", widget_type
+ )
+ schema = widget.get_control_schema(None, None)
+
+ # `paths` narrows the response to just those drill-in subtrees; without
+ # it the whole (enriched) schema is returned.
+ if paths:
+ try:
+ result: dict[str, Any] = get_subtrees(schema, paths)
+ except SchemaPathError as ex:
+ return self.response_400(message=str(ex))
+ else:
+ result = schema
+
+ payload: dict[str, Any] = {"result": result}
+ if warning:
+ payload["warning"] = warning
+ resp = make_response(json.dumps(payload, sort_keys=False), 200)
+ resp.headers["Content-Type"] = "application/json; charset=utf-8"
+ return resp
+
+ @expose("/type/<widget_type>/validate", methods=("POST",))
+ @protect()
+ @safe
+ @statsd_metrics
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.validate",
+ log_to_statsd=False,
+ )
+ def validate(self, widget_type: str) -> Response:
+ """Validate control values against a widget type's model.
+
+ The commit-time gate: runs the widget's full model validation (including
+ cross-field rules declared on the control model) and returns any errors
+ as ``{"errors": [{"loc", "message"}]}`` (empty list when valid), so a
+ caller can reject a bad edit with an actionable message rather than
+ writing a silently-broken widget. Widget-agnostic — the rules live on
+ each widget's control model, not here.
+
+ On success also returns ``values``: the candidate as Pydantic actually
+ parsed it (coerced, alias-keyed) rather than the raw request body — the
+ same normalization the ``set_widget_control_values`` MCP tool commits,
+ so a REST caller that commits its own raw candidate instead can end up
+ storing something subtly different from what this endpoint validated
+ (e.g. a numeric field coerced from a string).
+ ---
+ post:
+ summary: Validate control values for a widget type
+ parameters:
+ - in: path
+ name: widget_type
+ required: true
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ control_values:
+ type: object
+ responses:
+ 200:
+ description: Validation result
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ result:
+ type: object
+ properties:
+ errors:
+ type: array
+ items:
+ type: object
+ values:
+ type: object
+ nullable: true
+ 400:
+ $ref: '#/components/responses/400'
+ 404:
+ $ref: '#/components/responses/404'
+ """
+ widget = registry.get(widget_type)
+ if widget is None:
+ return self.response_404()
+ body = request.get_json(silent=True) or {}
+ if not isinstance(body, dict):
+ return self.response_400(message="Request body must be a JSON object.")
+ control_values = body.get("control_values")
+ if errors := widget.validate_control_values(control_values):
+ return self.response(200, result={"errors": errors, "values": None})
+ values = (
+ widget.controls_class.model_validate(control_values).model_dump(
+ by_alias=True
+ )
+ if control_values is not None
+ else None
+ )
+ return self.response(200, result={"errors": [], "values": values})
diff --git a/superset/widgets/builtin.py b/superset/widgets/builtin.py
new file mode 100644
index 00000000000..8a822617f7a
--- /dev/null
+++ b/superset/widgets/builtin.py
@@ -0,0 +1,381 @@
+# 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.
+"""Built-in Dashboard V2 widgets. Importing this registers them.
+
+Built-ins use the exact same public contract an extension does — ``@widget`` +
+``Widget`` from ``superset_core.widgets`` — so there is no parallel registration
+path. This module is imported (for its decorator side-effects) by
+``inject_widget_implementations`` once the decorator is concrete.
+"""
+
+from __future__ import annotations
+
+import logging
+from copy import deepcopy
+from typing import Any, ClassVar
+
+from pydantic import BaseModel
+from superset_core.widgets import (
+ EnricherFn,
+ Widget,
+ widget,
+ WidgetBehavior,
+ WidgetUi,
+)
+
+from superset.daos.dataset import DatasetDAO
+from superset.exceptions import SupersetSecurityException
+from superset.widgets.controls import (
+ AgGridTableControls,
+ BalloonsControls,
+ EchartsControls,
+ FilterBarControls,
+ FilterSelectControls,
+ GroupControls,
+ MarkdownControls,
+ MetricTileControls,
+ TabControls,
+ TabsControls,
+)
+
+# A widget that queries data takes runtime filters; charts also cross-filter.
+QUERYING = WidgetBehavior(filterable=True)
+CHART = WidgetBehavior(filterable=True, emits_filters=True)
+
+logger = logging.getLogger(__name__)
+
+
+def _metric_key(metric: Any) -> str:
+ """The stable label a `dataBinding` metric renders/queries under —
+ mirrors the frontend's `getMetricLabel` (`@superset-ui/core`) exactly,
+ since the two must agree: this is also the query result column name a
+ structured series reads its data from."""
+ if isinstance(metric, str):
+ return metric
+ if isinstance(metric, dict):
+ label = metric.get("label")
+ if label:
+ return str(label)
+ if metric.get("expressionType") != "SQL":
+ column = metric.get("column") or {}
+ column_name = column.get("columnName") or column.get("column_name") or ""
+ aggregate = metric.get("aggregate") or ""
+ return f"{aggregate}({column_name})"
+ return str(metric.get("sqlExpression", ""))
+ return str(metric)
+
+
+@widget(
+ widget_type="markdown",
+ name="Markdown",
+ description="Rich text authored in Markdown.",
+)
+class Markdown(Widget):
+ controls_class = MarkdownControls
+ ui = WidgetUi(default_size=(6, 4))
+
+
+@widget(
+ widget_type="echarts",
+ name="ECharts",
+ description="A chart from a raw ECharts option with $bind data markers.",
+)
+class Echarts(Widget):
+ """
+ A raw-ECharts-option chart, plus an optional structured layer: when
+ ``chartType`` is set, ``customize.series`` offers one entry per
+ ``dataBinding`` metric (the SIP's ``x-dynamic`` pattern, as ``Balloons``
+ uses for its per-series styling) so a series can be colored, hidden, or
+ relabeled without hand-editing ``echartsOptions``.
+ """
+
+ controls_class = EchartsControls
+ behavior = CHART
+ ui = WidgetUi(default_size=(12, 8))
+
+ # Default color per series index, matching the frontend's structured
+ # series builder so a series' color is stable before the author touches
+ # customize (same convention as Balloons.PALETTE).
+ PALETTE = ["#e74c3c", "#3498db", "#2ecc71", "#f1c40f", "#9b59b6", "#1abc9c"]
+
+ # Upper bound on distinct series inlined into the schema (see Balloons.MAX_SERIES).
+ MAX_SERIES = 100
+
+ @staticmethod
+ def _populate_chart_series(
+ schema: dict[str, Any],
+ node: dict[str, Any],
+ parsed: BaseModel | None,
+ series: list[str],
+ upstream: dict[str, Any],
+ ) -> None:
+ # Unlike Balloons (where dimension *values* are only known once the
+ # frontend reports them, so an extra runtime guard is needed beyond
+ # the x-dependsOn gate), a structured series' identity comes entirely
+ # from `dataBinding.metrics` — a field already on `parsed` once the
+ # `dataBinding`/`chartType` gate above has passed.
+ style_def = schema.get("$defs", {}).get("SeriesOverride")
+ if style_def is None:
+ return
+ data_binding = getattr(parsed, "data_binding", None) if parsed else None
+ metrics = getattr(data_binding, "metrics", None) or []
+ discovered_keys = [_metric_key(metric) for metric in metrics]
+ customize = getattr(parsed, "customize", None) if parsed else None
+ stored = getattr(customize, "series", None) or {}
+ # Union with already-stored override keys, so an override for a
+ # metric temporarily removed from dataBinding.metrics stays visible
+ # (and thus editable/removable) instead of silently disappearing.
+ combined_keys = list(dict.fromkeys([*discovered_keys, *stored.keys()]))
+ if not combined_keys:
+ # Also sidesteps a forward reference to `Echarts.MAX_SERIES`
+ # below: this enricher runs once during the class's own
+ # `@widget` registration check (control_values=None), before the
+ # `Echarts` name is bound in this module.
+ return
+ all_keys = combined_keys[: Echarts.MAX_SERIES]
+ node.pop("additionalProperties", None)
+ properties: dict[str, Any] = {}
+ for index, key in enumerate(all_keys):
+ style = deepcopy(style_def)
+ style["properties"]["color"]["default"] = Echarts.PALETTE[
+ index % len(Echarts.PALETTE)
+ ]
+ style["title"] = key
+ properties[key] = style
+ node["properties"] = properties
+
+ enrichers: ClassVar[dict[str, EnricherFn]] = {
+ "customize/series": _populate_chart_series
+ }
+
+
+@widget(
+ widget_type="metric-tile",
+ name="Metric Tile",
+ description="A single live metric value rendered as a big number.",
+)
+class MetricTile(Widget):
+ controls_class = MetricTileControls
+ behavior = QUERYING
+ ui = WidgetUi(default_size=(6, 4))
+
+
+@widget(
+ widget_type="ag-grid-table",
+ name="Table",
+ description="Query results rendered as an AG Grid table.",
+)
+class AgGridTable(Widget):
+ controls_class = AgGridTableControls
+ behavior = CHART
+ ui = WidgetUi(default_size=(12, 8))
+
+
+@widget(
+ widget_type="balloons",
+ name="Balloons",
+ description=(
+ "Rising colored balloons, one per query row — colored and sized by the "
+ "query (Chart Framework v2 POC)."
+ ),
+)
+class Balloons(Widget):
+ """
+ Explicit/typed chart: renders one balloon per query row, colored and sized
+ per series. The per-series ``customize`` section is populated dynamically
+ once a grouping dimension is chosen and the frontend reports the distinct
+ series values (the SIP's ``x-dynamic`` pattern).
+ """
+
+ controls_class = BalloonsControls
+ behavior = QUERYING
+ ui = WidgetUi(default_size=(12, 8))
+
+ # Default color per series index. Must match the frontend widget's palette
+ # so a series' color is stable before the author touches the customize
+ # control.
+ PALETTE = ["#e74c3c", "#3498db", "#2ecc71", "#f1c40f", "#9b59b6", "#1abc9c"]
+
+ # Upper bound on distinct series inlined into the schema, so a caller can't
+ # force unbounded schema construction / serialization by submitting a huge
+ # (or duplicate-heavy) series list.
+ MAX_SERIES = 100
+
+ @staticmethod
+ def _populate_series(
+ schema: dict[str, Any],
+ node: dict[str, Any],
+ parsed: BaseModel | None,
+ series: list[str],
+ upstream: dict[str, Any],
+ ) -> None:
+ # `node` is Customization.series's own fragment; `SeriesStyle` is a
+ # sibling $defs entry, only reachable via the full `schema`.
+ style_def = schema.get("$defs", {}).get("SeriesStyle")
+ if style_def is None:
+ return
+ # The x-dependsOn: ["dataBinding"] gate (run by run_enrichers before
+ # this is ever called) only confirms a dataBinding was parsed at all
+ # -- it can't express "dimensions is non-empty" (a nested attribute)
+ # or "series is non-empty" (a runtime parameter, not a field on
+ # parsed), so both stay checked here.
+ dimensions = None
+ if parsed is not None:
+ data_binding = getattr(parsed, "data_binding", None)
+ dimensions = getattr(data_binding, "dimensions", None)
+ if not dimensions or not series:
+ return
+ # Dedupe (preserving order) and cap before doing per-series work, so an
+ # oversized/duplicate list can't blow up CPU, memory, or response size.
+ unique_series = list(dict.fromkeys(series))[: Balloons.MAX_SERIES]
+ # Replace the open-ended map with one inlined, pre-colored style per series.
+ node.pop("additionalProperties", None)
+ properties: dict[str, Any] = {}
+ for index, value in enumerate(unique_series):
+ style = deepcopy(style_def)
+ style["properties"]["color"]["default"] = Balloons.PALETTE[
+ index % len(Balloons.PALETTE)
+ ]
+ # Title each group with the series value so the control panel labels
+ # it by series rather than by the shared model name ("SeriesStyle").
+ style["title"] = value
+ properties[value] = style
+ node["properties"] = properties
+
+ enrichers: ClassVar[dict[str, EnricherFn]] = {"customize/series": _populate_series}
+
+
+@widget(
+ widget_type="filter.select",
+ name="Filter",
+ description="A value/multi-select dashboard filter.",
+)
+class FilterSelect(Widget):
+ """
+ A leaf ``filter.*`` widget: reads ``datasetId``/``column`` and, at
+ render time, either the author's static ``options`` or the column's own
+ distinct values. Its live selection travels over the widget event bus
+ (``dashboard.emit``/``getValue``), not ``props`` — see the frontend
+ ``FilterSelectWidget`` and ``collectActiveFilters`` — so nothing about
+ that selection lives in this schema.
+ """
+
+ controls_class = FilterSelectControls
+ behavior = WidgetBehavior(filter=True)
+ ui = WidgetUi(default_size=(6, 2))
+
+ @staticmethod
+ def _populate_datasets(
+ _schema: dict[str, Any],
+ node: dict[str, Any],
+ _parsed: BaseModel | None,
+ _series: list[str],
+ _upstream: dict[str, Any],
+ ) -> None:
+ """Populate the dataset picker with datasets the caller can view."""
+ datasets = DatasetDAO.find_all()
+ node["enum"] = [dataset.id for dataset in datasets]
+ node["x-enumNames"] = [dataset.name for dataset in datasets]
+
+ @staticmethod
+ def _populate_columns(
+ _schema: dict[str, Any],
+ node: dict[str, Any],
+ parsed: BaseModel | None,
+ _series: list[str],
+ _upstream: dict[str, Any],
+ ) -> None:
+ # Every early-return below leaves `enum` explicitly blank (rather than
+ # merely absent) so the field degrades to a plain text input instead
+ # of an enum-typed control with no choices. `build_configuration_schema`
+ # already does this when `parsed` is None; these branches cover the
+ # cases it doesn't (a dataset that's unset, missing, or inaccessible).
+ node["enum"] = []
+ dataset_id = getattr(parsed, "dataset_id", None) if parsed else None
+ if not dataset_id:
+ return
+ dataset = DatasetDAO.find_by_id(dataset_id)
+ if dataset is None:
+ return
+ try:
+ dataset.raise_for_access()
+ except SupersetSecurityException:
+ # Same fail-open as an unset dataset: the field just falls back to
+ # a plain text input rather than a 500 or a schema that leaks
+ # column names for a dataset this caller cannot read.
+ logger.info(
+ "Access denied enriching filter.select column enum for dataset %s",
+ dataset_id,
+ )
+ return
+ node["enum"] = dataset.filterable_column_names
+
+ enrichers: ClassVar[dict[str, EnricherFn]] = {
+ "datasetId": _populate_datasets,
+ "column": _populate_columns,
+ }
+
+
+@widget(
+ widget_type="filter.bar",
+ name="Filter Bar",
+ description="A plain arranging container for filter.* children.",
+)
+class FilterBar(Widget):
+ controls_class = FilterBarControls
+ behavior = WidgetBehavior(
+ container=True,
+ grid_columns=24,
+ accepted_children=frozenset({"filter.select"}),
+ # Filters in the bar drive the canvas, not just the bar.
+ bounds_filter_scope=False,
+ )
+ ui = WidgetUi(default_size=(24, 2))
+
+
+@widget(
+ widget_type="tabs",
+ name="Tabs",
+ description="Shows one of its tabs at a time.",
+)
+class Tabs(Widget):
+ controls_class = TabsControls
+ behavior = WidgetBehavior(container=True, accepted_children=frozenset({"tab"}))
+ ui = WidgetUi(default_size=(24, 12))
+
+
+@widget(
+ widget_type="tab",
+ name="Tab",
+ description="One tab of a tabs container; lays its children out on a grid.",
+)
+class Tab(Widget):
+ controls_class = TabControls
+ behavior = WidgetBehavior(
+ container=True, allowed_parents=frozenset({"tabs"}), grid_columns=24
+ )
+
+
+@widget(
+ widget_type="group",
+ name="Group",
+ description="Groups widgets on a grid of their own, e.g. a KPI row.",
+)
+class Group(Widget):
+ controls_class = GroupControls
+ behavior = WidgetBehavior(container=True, grid_columns=12)
+ ui = WidgetUi(default_size=(24, 6))
diff --git a/superset/widgets/controls.py b/superset/widgets/controls.py
new file mode 100644
index 00000000000..f756c71594b
--- /dev/null
+++ b/superset/widgets/controls.py
@@ -0,0 +1,615 @@
+# 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.
+"""
+Pydantic control models for built-in Dashboard V2 widgets.
+
+Field aliases are the camelCase names the frontend reads from ``node.props``
+(e.g. ``datasetId``); Pydantic emits JSON Schema by alias by default, so the
+served schema's property names match what widgets and ``fetchQueryData`` use.
+
+Genuinely-required controls (``datasetId``, ``metrics``, ``dataBinding``) carry
+no default so they land in the schema's ``required`` array. That is what makes
+the MCP "minimum viable object" view meaningful: it surfaces exactly those
+mandatory leaves, leaving optional/styling fields behind a drill-in.
+"""
+
+from __future__ import annotations
+
+from typing import Any, ClassVar, Literal
+
+from pydantic import BaseModel, ConfigDict, Field, model_validator
+from pydantic.json_schema import SkipJsonSchema
+from superset_core.widgets import MetricControl
+
+
+class DataBinding(MetricControl):
+ """Query binding for a data-backed widget (mirrors the frontend
+ ``DataBindingSpec``). ``datasetId`` and ``metrics`` are mandatory; the rest
+ are optional."""
+
+ model_config = ConfigDict(populate_by_name=True)
+
+ # Pydantic places an inherited field (``metrics``, from ``MetricControl``)
+ # ahead of this class's own fields in ``model_fields`` regardless of
+ # declaration order, so the rendered field order needs to be pinned
+ # explicitly to match what this class served before composing MetricControl.
+ field_order: ClassVar[list[str]] = [
+ "datasetId",
+ "metrics",
+ "dimensions",
+ "rowLimit",
+ ]
+
+ dataset_id: int = Field(
+ alias="datasetId",
+ title="Dataset ID",
+ description="Numeric id of the dataset to query.",
+ )
+ dimensions: list[str] = Field(
+ default_factory=list,
+ title="Dimensions",
+ description="Columns to group by (the categories / series).",
+ json_schema_extra={"x-control": "column-multi"},
+ )
+ # No `x-control` yet authors it through the control panel or MCP — it's
+ # populated by other means (e.g. a dashboard filter binding a chart).
+ # `SkipJsonSchema` keeps it out of the control schema (so `field_order`
+ # doesn't need it) while still round-tripping through `model_dump`;
+ # without it, committing the backend-normalized dump (see
+ # `commitWidgetProps`) would silently discard any authored filters, since
+ # Pydantic's default `extra="ignore"` drops fields the model doesn't
+ # declare.
+ filters: SkipJsonSchema[list[dict[str, Any]]] = Field(default_factory=list)
+ row_limit: int = Field(
+ default=1000,
+ ge=1,
+ alias="rowLimit",
+ title="Row limit",
+ description="Maximum number of rows to fetch.",
+ )
+
+
+class MetricTileControls(BaseModel):
+ """Controls for the ``metric-tile`` widget (a single "big number")."""
+
+ model_config = ConfigDict(populate_by_name=True)
+
+ data_binding: DataBinding = Field(
+ alias="dataBinding",
+ title="Data",
+ description=(
+ "The query behind the tile. Resolve one metric; the first result "
+ "row's value is shown."
+ ),
+ )
+ decimals: int = Field(
+ default=0,
+ ge=0,
+ le=10,
+ title="Decimal places",
+ description="How many decimal places to show.",
+ )
+ prefix: str = Field(
+ default="",
+ title="Prefix",
+ description='Text shown before the value (e.g. "$").',
+ )
+ suffix: str = Field(
+ default="",
+ title="Suffix",
+ description='Text shown after the value (e.g. "%").',
+ )
+ label: str = Field(
+ default="",
+ title="Label",
+ description="Caption under the number. Defaults to the column name.",
+ )
+
+
+class AgGridTableControls(BaseModel):
+ """Controls for the ``ag-grid-table`` widget (query results as a table)."""
+
+ model_config = ConfigDict(populate_by_name=True)
+
+ data_binding: DataBinding = Field(
+ alias="dataBinding",
+ title="Data",
+ description="The query whose rows fill the table.",
+ )
+ column_defs: list[dict[str, Any]] = Field(
+ default_factory=list,
+ alias="columnDefs",
+ title="Column definitions",
+ description=(
+ "Optional AG Grid column definitions. When empty, columns are "
+ "derived one-to-one from the query result columns."
+ ),
+ json_schema_extra={"x-control": "code", "x-language": "json"},
+ )
+
+
+class SeriesStyle(BaseModel):
+ """Per-series balloon styling, populated dynamically once the grouping
+ dimension is set and its distinct values are known."""
+
+ color: str = Field(
+ default="#e74c3c",
+ title="Color",
+ json_schema_extra={"x-control": "color"},
+ )
+ size_scale: float = Field(
+ default=1.0,
+ ge=0.25,
+ le=4.0,
+ alias="sizeScale",
+ title="Size scale (×)",
+ description=(
+ "Multiplier on the metric-derived balloon size for this series "
+ "(1 = as-is, 2 = twice as big)."
+ ),
+ json_schema_extra={"x-step": 0.25},
+ )
+
+
+class Customization(BaseModel):
+ """Per-series customization for the balloons widget.
+
+ ``series`` is ``x-dynamic``: empty until the ``dataBinding`` grouping
+ dimension is set, then the backend fills in one styled entry per distinct
+ dimension value the frontend discovered from the query results.
+ """
+
+ series: dict[str, SeriesStyle] = Field(
+ default_factory=dict,
+ title="Per-series styling",
+ json_schema_extra={
+ "x-dynamic": True,
+ "x-dependsOn": ["dataBinding"],
+ # The valid keys of this map are the distinct values of the widget's
+ # color dimension — named by the ``colorDimension`` prop, or the last
+ # ``dataBinding`` dimension when that's unset. Declaring the source
+ # lets a generic client enumerate/validate the keys (rather than a
+ # consumer guessing values like "F" for "girl").
+ "x-key-source": {
+ "dimensionFromProp": "colorDimension",
+ "fallback": "lastDimension",
+ },
+ },
+ )
+
+
+class BalloonsControls(BaseModel):
+ """Controls for the ``balloons`` chart widget (Chart Framework v2 POC).
+
+ One balloon per query row, colored and sized per series (the distinct
+ values of the first grouping dimension). ``customize`` is optional and
+ populated on demand.
+ """
+
+ model_config = ConfigDict(populate_by_name=True)
+
+ data_binding: DataBinding = Field(
+ alias="dataBinding",
+ title="Data",
+ description=(
+ "The query behind the balloons. Group by one or more dimensions "
+ "(one balloon per resulting row); the metric sizes each balloon."
+ ),
+ )
+ color_dimension: str = Field(
+ default="",
+ alias="colorDimension",
+ title="Color dimension",
+ description=(
+ "Which grouping dimension colors the balloons — its distinct values "
+ "become the customizable series. The value MUST be one of "
+ "`dataBinding.dimensions`; if the dimension you want to color by "
+ "isn't grouped yet, add it to `dimensions` as well (it is not enough "
+ "to name it here). Leave empty to color by the last dimension. "
+ 'E.g. to color by gender: set this to "gender" AND include "gender" '
+ 'in dimensions (e.g. dimensions ["name", "gender"]).'
+ ),
+ json_schema_extra={"x-control": "column"},
+ )
+ customize: Customization = Field(
+ default_factory=Customization,
+ title="Customize",
+ description="Per-series color and size overrides.",
+ )
+
+ @model_validator(mode="after")
+ def _color_dimension_must_be_grouped(self) -> "BalloonsControls":
+ """``colorDimension`` colors balloons by a dimension's distinct values,
+ so that dimension must actually be grouped. Naming it here without
+ adding it to ``dataBinding.dimensions`` would silently color by nothing;
+ surface it as a validation error instead (the message tells the caller
+ exactly what to fix)."""
+ dimensions = self.data_binding.dimensions
+ if self.color_dimension and self.color_dimension not in dimensions:
+ raise ValueError(
+ f'colorDimension "{self.color_dimension}" must be one of the '
+ f"dataBinding dimensions {dimensions}; add it to `dimensions` "
+ f"as well."
+ )
+ return self
+
+
+class MarkdownControls(BaseModel):
+ """Controls for the ``markdown`` widget (rich text)."""
+
+ model_config = ConfigDict(populate_by_name=True)
+
+ content: str = Field(
+ title="Text",
+ description="Markdown content rendered by the widget.",
+ json_schema_extra={"x-control": "markdown"},
+ )
+
+
+class SeriesOverride(BaseModel):
+ """One structured chart series' visual override, matched to a
+ ``dataBinding`` metric by its stable label (the same label ECharts'
+ `getMetricLabel`-equivalent computes and the query result column is
+ named after), not by array position."""
+
+ color: str = Field(
+ default="",
+ title="Color",
+ description="Empty keeps the palette-assigned default.",
+ json_schema_extra={"x-control": "color"},
+ )
+ visible: bool = Field(
+ default=True,
+ title="Visible",
+ description="Unchecking omits this series from the rendered chart.",
+ )
+ display_name: str = Field(
+ default="",
+ alias="displayName",
+ title="Display name",
+ description=(
+ "Overrides the series' legend/tooltip label. Empty keeps the "
+ "metric's own label."
+ ),
+ )
+
+
+class EchartsCustomization(BaseModel):
+ """Per-series overrides for a structured (``chartType`` set) echarts
+ chart.
+
+ ``series`` is ``x-dynamic``: inlined with one entry per ``dataBinding``
+ metric once ``chartType`` is set, plus any already-stored override whose
+ metric was since removed from ``dataBinding.metrics`` — so switching
+ metrics around doesn't silently discard configuration (see
+ ``Echarts._populate_chart_series``).
+ """
+
+ series: dict[str, SeriesOverride] = Field(
+ default_factory=dict,
+ title="Per-series overrides",
+ json_schema_extra={
+ "x-dynamic": True,
+ "x-dependsOn": ["dataBinding", "chartType"],
+ },
+ )
+
+
+class EchartsChrome(BaseModel):
+ """Structured chart chrome — title, legend, tooltip, axis labels — each
+ field independently optional and layered on top of the matching
+ `echartsOptions` section. A field left at its default doesn't touch
+ `echartsOptions` at all; only the specific key(s) a set field manages are
+ merged onto that section, so an unmanaged sibling property there (e.g. a
+ hand-authored `legend.orient`) survives.
+
+ Deliberately flat (not grouped into `title`/`legend`/`tooltip`/`xAxis`/
+ `yAxis` sub-objects — the natural modeling) rather than nested two levels
+ under `chrome`: JsonForms' generated control panel only renders one level
+ of nested-object properties (the same depth `DataBinding`/`Customization`
+ already rely on), so a `chrome.title.text`-style double nesting would
+ render an empty group with no fields inside it.
+ """
+
+ model_config = ConfigDict(populate_by_name=True)
+
+ title_text: str = Field(
+ default="",
+ alias="titleText",
+ title="Title",
+ description="Empty leaves echartsOptions.title exactly as authored.",
+ )
+ legend_show: bool = Field(
+ default=True,
+ alias="legendShow",
+ title="Show legend",
+ description="Unchecking hides the legend, regardless of echartsOptions.",
+ )
+ legend_position: Literal["top", "bottom", "left", "right"] | None = Field(
+ default=None,
+ alias="legendPosition",
+ title="Legend position",
+ description=(
+ "Unset leaves echartsOptions.legend's own position (or "
+ "ECharts' default) untouched."
+ ),
+ json_schema_extra={
+ "x-control": "select",
+ "x-options": ["top", "bottom", "left", "right"],
+ },
+ )
+ tooltip_trigger: Literal["item", "axis"] | None = Field(
+ default=None,
+ alias="tooltipTrigger",
+ title="Tooltip trigger",
+ description=(
+ "Unset leaves echartsOptions.tooltip's own trigger (or "
+ "ECharts' default) untouched."
+ ),
+ json_schema_extra={
+ "x-control": "select",
+ "x-options": ["item", "axis"],
+ },
+ )
+ x_axis_name: str = Field(
+ default="",
+ alias="xAxisName",
+ title="X axis name",
+ description="Empty leaves the axis's own `name` (or none) untouched.",
+ )
+ x_axis_rotate: int = Field(
+ default=0,
+ ge=-90,
+ le=90,
+ alias="xAxisRotate",
+ title="X axis label rotation (°)",
+ description="0 leaves the axis's own `axisLabel.rotate` (or none) untouched.",
+ )
+ x_axis_format: str = Field(
+ default="",
+ alias="xAxisFormat",
+ title="X axis label format",
+ description=(
+ 'An ECharts axisLabel formatter template, e.g. "{value} kg". Empty '
+ "leaves the axis's own `axisLabel.formatter` (or none) untouched."
+ ),
+ )
+ y_axis_name: str = Field(
+ default="",
+ alias="yAxisName",
+ title="Y axis name",
+ description="Empty leaves the axis's own `name` (or none) untouched.",
+ )
+ y_axis_rotate: int = Field(
+ default=0,
+ ge=-90,
+ le=90,
+ alias="yAxisRotate",
+ title="Y axis label rotation (°)",
+ description="0 leaves the axis's own `axisLabel.rotate` (or none) untouched.",
+ )
+ y_axis_format: str = Field(
+ default="",
+ alias="yAxisFormat",
+ title="Y axis label format",
+ description=(
+ 'An ECharts axisLabel formatter template, e.g. "{value} kg". Empty '
+ "leaves the axis's own `axisLabel.formatter` (or none) untouched."
+ ),
+ )
+
+
+class EchartsControls(BaseModel):
+ """Controls for the ``echarts`` widget (a chart from a raw ECharts option).
+
+ Not modeled field-by-field: ``echartsOptions`` is a near-raw ECharts
+ ``option`` edited as JSON, with ``$bind`` markers splicing in the queried
+ data. The query itself is the standard ``dataBinding``.
+
+ ``chartType``/``customize`` are an optional structured layer on top:
+ when ``chartType`` is set, the frontend replaces `option.series` with one
+ generated series per ``dataBinding`` metric (styled per ``customize``).
+ Leaving ``chartType`` unset ("Custom") keeps ``echartsOptions.series``
+ fully authoritative, including mixed-series or non-Cartesian (e.g. pie)
+ shapes the structured layer doesn't cover.
+
+ ``chrome`` is a second, independent structured layer — title, legend,
+ tooltip, and axis labels — each leaf optional and applying (or not)
+ regardless of ``chartType``. See ``EchartsChrome`` for its own
+ leaf-by-leaf precedence.
+ """
+
+ model_config = ConfigDict(populate_by_name=True)
+
+ data_binding: DataBinding = Field(
+ alias="dataBinding",
+ title="Data",
+ description="The query whose rows the ECharts option's $bind markers read.",
+ )
+ echarts_options: dict[str, Any] = Field(
+ default_factory=dict,
+ alias="echartsOptions",
+ title="ECharts option",
+ description=(
+ "A near-raw ECharts `option` object "
+ "(https://echarts.apache.org/en/option.html). Anywhere a literal "
+ "value would normally go, use a $bind marker to splice in the "
+ 'queried data or a theme token: {"$bind": {"source": "metric", '
+ '"alias": "<metric name>"}} or {"source": "dimension", "alias": '
+ '"<column name>"}} yields one array of that column\'s values (add '
+ '"single": true to unwrap a single-row value); {"$bind": {"source": '
+ '"records", "fields": {"name": "<col>", "value": "<col>"}}} yields '
+ "an array of {name, value} objects (e.g. for pie series data); and "
+ '{"$bind": {"source": "theme", "token": "<token>"}} yields a Superset '
+ "theme value. The $bind wrapper is required."
+ ),
+ json_schema_extra={
+ "x-control": "code",
+ "x-language": "json",
+ "x-spec-dialect": "echarts",
+ # The Inspector's JSON tab already edits the whole `node.props`
+ # record as text — including this field — so a second, redundant
+ # raw-JSON box in the Form tab just duplicates it. Hidden there;
+ # still fully editable via the JSON tab (or `chartType`/
+ # `customize` for what those manage).
+ "x-hidden-in-form": True,
+ },
+ )
+ chart_type: Literal["bar", "line", "scatter"] | None = Field(
+ default=None,
+ alias="chartType",
+ title="Chart type",
+ description=(
+ "Renders a structured chart — one series per dataBinding metric — "
+ "layered on top of echartsOptions. Leave unset ('Custom') to use "
+ "echartsOptions exactly as authored, including mixed-series or "
+ "non-Cartesian (e.g. pie) configurations. Pie isn't offered here: "
+ "its data shape (one series, categories as data points) doesn't "
+ "match the one-series-per-metric model this picker drives."
+ ),
+ json_schema_extra={
+ "x-control": "select",
+ "x-options": ["bar", "line", "scatter"],
+ },
+ )
+ customize: EchartsCustomization = Field(
+ default_factory=EchartsCustomization,
+ title="Customize",
+ description="Per-series color, visibility, and display-name overrides.",
+ )
+ chrome: EchartsChrome = Field(
+ default_factory=EchartsChrome,
+ title="Chrome",
+ description="Structured title, legend, tooltip, and axis label overrides.",
+ )
+ cross_filter: bool = Field(
+ default=False,
+ alias="crossFilter",
+ title="Cross-filter on click",
+ description=(
+ "Emit a cross-filter from the first grouping dimension when a "
+ "point is clicked (requires dataBinding.dimensions to have at "
+ "least one entry). Clicking the same point again clears it."
+ ),
+ )
+
+
+class FilterSelectControls(BaseModel):
+ """Controls for the ``filter.select`` widget (a value/multi-select
+ dashboard filter).
+
+ Deliberately not a ``dataBinding`` — a filter has no metrics, just a
+ target column. ``datasetId``'s enum (every dataset the caller can view)
+ and ``column``'s enum (that dataset's own columns, populated once
+ ``datasetId`` is set) are both populated dynamically — see
+ ``FilterSelect.enrichers``.
+ """
+
+ model_config = ConfigDict(populate_by_name=True)
+
+ dataset_id: int = Field(
+ alias="datasetId",
+ title="Dataset ID",
+ description="Numeric id of the dataset this filter targets.",
+ json_schema_extra={"x-dynamic": True},
+ )
+ # Defaulted (unlike `dataset_id`) even though a real filter needs one:
+ # the control panel posts control_values as the author fills fields in,
+ # and until a dataset is chosen there's no `column` value to send at
+ # all. Without a default, validating that partial payload would raise
+ # on the *missing* field, `Widget.get_control_schema` would swallow that
+ # and fall back to `parsed=None`, and the column enricher would never
+ # see the `dataset_id` it needs to populate this very field's `enum` —
+ # i.e. picking a dataset would never turn `column` into a working
+ # dropdown. `min_length=1` keeps an explicit empty value invalid at
+ # commit time (`validate_control_values`), since Pydantic only checks
+ # defaults against a field's validators when asked to, not on every
+ # validate() (see `validate_default` if that ever needs to change).
+ column: str = Field(
+ default="",
+ min_length=1,
+ title="Column",
+ description="The column this filter constrains.",
+ json_schema_extra={"x-dynamic": True, "x-dependsOn": ["datasetId"]},
+ )
+ # Both hidden from the generated Form (see `x-hidden` in `buildUiSchema`):
+ # a raw string-list "Add one at a time" control is more clutter than help
+ # for what's meant to be an edge case, not the primary way to configure a
+ # filter. Still real fields — present in the schema/JSON tab and fully
+ # functional — just not offered as a first-class Form control.
+ options: list[str] = Field(
+ default_factory=list,
+ title="Options",
+ description=(
+ "Optional static list of selectable values. Leave empty to query "
+ "the target column's own distinct values at render time instead."
+ ),
+ json_schema_extra={"x-hidden": True},
+ )
+ default_selection: list[str] = Field(
+ default_factory=list,
+ alias="defaultSelection",
+ title="Default selection",
+ description=(
+ "Starting selection, applied once the first time this filter "
+ "renders with no live viewer selection yet."
+ ),
+ json_schema_extra={"x-hidden": True},
+ )
+
+
+class FilterBarControls(BaseModel):
+ """Controls for the ``filter.bar`` widget — a plain arranging container
+ for ``filter.*`` children; it holds no data of its own."""
+
+ model_config = ConfigDict(populate_by_name=True)
+
+ orientation: Literal["horizontal", "vertical"] = Field(
+ default="horizontal",
+ title="Orientation",
+ description="Whether filters lay out side by side or stacked.",
+ )
+
+
+class TabsControls(BaseModel):
+ """Controls for the ``tabs`` container; its tabs are its children."""
+
+ model_config = ConfigDict(populate_by_name=True)
+
+
+class TabControls(BaseModel):
+ """Controls for a ``tab`` inside ``tabs``."""
+
+ model_config = ConfigDict(populate_by_name=True)
+
+ title: str = Field(
+ default="",
+ title="Title",
+ description="The tab's label.",
+ )
+
+
+class GroupControls(BaseModel):
+ """Controls for a ``group``: a container with its own grid."""
+
+ model_config = ConfigDict(populate_by_name=True)
+
+ title: str = Field(
+ default="",
+ title="Title",
+ description="Optional heading shown above the group.",
+ )
diff --git a/superset-core/src/superset_core/canvas/__init__.py b/superset/widgets/registry.py
similarity index 58%
copy from superset-core/src/superset_core/canvas/__init__.py
copy to superset/widgets/registry.py
index eee560bcf4b..23381b7dc0f 100644
--- a/superset-core/src/superset_core/canvas/__init__.py
+++ b/superset/widgets/registry.py
@@ -14,13 +14,19 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
+"""
+In-memory registry of Dashboard V2 widgets, keyed by widget type.
-"""Dashboard canvas contracts for widget providers."""
+The public contract (``Widget`` base class + ``@widget`` decorator) lives in
+``superset_core.widgets`` so extensions can register widgets the same way
+built-ins do. This module holds only the host-side registry the concrete
+decorator writes into (see
+``superset.core.api.core_api_injection.inject_widget_implementations``),
+mirroring ``superset.semantic_layers.registry``.
+"""
-from superset_core.canvas.base import (
- CanvasLayoutRules,
- GridPlacement,
- WidgetResolver,
-)
+from __future__ import annotations
-__all__ = ["CanvasLayoutRules", "GridPlacement", "WidgetResolver"]
+from superset_core.widgets.base import Widget
+
+registry: dict[str, type[Widget]] = {}
diff --git a/superset/widgets/schema_tools.py b/superset/widgets/schema_tools.py
new file mode 100644
index 00000000000..1e677991cd4
--- /dev/null
+++ b/superset/widgets/schema_tools.py
@@ -0,0 +1,248 @@
+# 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.
+"""
+Progressive-disclosure helpers over a control JSON Schema.
+
+Shared by the REST API and the MCP tools so the two never diverge. Pure
+functions over the dict a ``Widget.get_control_schema`` returns (which
+may carry ``x-dynamic`` enrichment and ``$defs``).
+
+- ``prune_to_minimal_viable`` — the **minimum viable object** an agent can fill
+ in one shot: mandatory properties expanded down to their leaves (descending
+ into mandatory nested objects), every optional/nested branch replaced by an
+ opaque ``x-collapsed`` drill-in marker (carrying an ``x-path``). The root is
+ tagged ``x-disclosure: "minimal"`` so the consumer knows this is partial —
+ only mandatory nested leaves are present; optional branches must be fetched
+ with ``get_subtree``.
+- ``get_subtree`` — resolve a ``a/b`` path against the fully-enriched schema and
+ return that node re-rooted as a self-contained schema (all ``$ref`` inlined).
+"""
+
+from __future__ import annotations
+
+from copy import deepcopy
+from typing import Any
+
+
+class SchemaPathError(KeyError):
+ """Raised when a drill-in ``path`` cannot be resolved against a schema."""
+
+
+def _defs(root: dict[str, Any]) -> dict[str, Any]:
+ return root.get("$defs", {}) or {}
+
+
+def _deref(root: dict[str, Any], node: dict[str, Any]) -> dict[str, Any]:
+ """Resolve a node's single ``$ref`` (optionally wrapped in a one-element
+ ``allOf``) against ``root['$defs']``, merging any sibling keys. Non-ref
+ nodes are returned unchanged. Not recursive — resolves one level."""
+ ref: str | None = None
+ extras: dict[str, Any] = {}
+ if "$ref" in node:
+ ref = node["$ref"]
+ extras = {k: v for k, v in node.items() if k != "$ref"}
+ elif (
+ isinstance(node.get("allOf"), list)
+ and len(node["allOf"]) == 1
+ and isinstance(node["allOf"][0], dict)
+ and "$ref" in node["allOf"][0]
+ ):
+ ref = node["allOf"][0]["$ref"]
+ extras = {k: v for k, v in node.items() if k != "allOf"}
+ if ref is None:
+ return node
+ target = deepcopy(_defs(root).get(ref.split("/")[-1], {}))
+ target.update(extras)
+ return target
+
+
+def _is_object(node: dict[str, Any]) -> bool:
+ return (
+ node.get("type") == "object"
+ or "properties" in node
+ or "additionalProperties" in node
+ )
+
+
+def _is_collapsible(root: dict[str, Any], node: dict[str, Any]) -> bool:
+ """Whether an optional branch is worth hiding behind a drill-in marker.
+
+ Only nested objects — and arrays of objects — are: their sub-fields can be
+ many or large, so inlining them would bloat the first fetch. Cheap leaves
+ (scalars, enums, arrays of scalars) are small and have nothing to drill
+ into, so they're inlined instead of costing an extra round trip.
+ """
+ if _is_object(node):
+ return True
+ if node.get("type") == "array":
+ items = node.get("items")
+ if isinstance(items, dict):
+ return _is_object(_deref(root, items))
+ return False
+
+
+def _inline(root: dict[str, Any], node: Any) -> Any:
+ """Recursively resolve every ``$ref`` against ``root['$defs']`` and drop
+ ``$defs``, returning a self-contained schema fragment."""
+ if isinstance(node, dict):
+ if "$ref" in node:
+ target = _defs(root).get(node["$ref"].split("/")[-1], {})
+ merged = {**target, **{k: v for k, v in node.items() if k != "$ref"}}
+ return _inline(root, merged)
+ return {k: _inline(root, v) for k, v in node.items() if k != "$defs"}
+ if isinstance(node, list):
+ return [_inline(root, item) for item in node]
+ return node
+
+
+def _contains_dynamic(node: Any) -> bool:
+ """Whether a schema fragment has any ``x-dynamic`` content anywhere inside.
+
+ ``x-dynamic`` marks a field whose shape/enum is derived from the current
+ query or control values (e.g. per-series styling keyed by the discovered
+ dimension values). A collapsed branch containing such a field expands
+ differently as the query changes; a branch without one is stable.
+ """
+ if isinstance(node, dict):
+ if node.get("x-dynamic"):
+ return True
+ return any(_contains_dynamic(value) for value in node.values())
+ if isinstance(node, list):
+ return any(_contains_dynamic(item) for item in node)
+ return False
+
+
+def _collapse(root: dict[str, Any], node: dict[str, Any], path: str) -> dict[str, Any]:
+ """An opaque drill-in marker for an optional/nested branch.
+
+ Keeps the field's own (terse) description/title as a human summary, but
+ never inlines its child ``properties`` — those are what a consumer must
+ fetch on drill-in via ``get_subtree``. Only the summary, type, and
+ ``x-path`` are exposed here, so the shape of the sub-fields stays hidden
+ until explicitly requested.
+
+ Marks the branch ``x-dynamic: true`` when its subtree's shape depends on the
+ current query/values (so the consumer knows to pass ``control_values`` /
+ ``series`` when expanding it, and to re-fetch it when the query changes).
+ A branch without that flag is static: expand it once and it won't change.
+ """
+ resolved = _deref(root, node)
+ description = (
+ node.get("description")
+ or resolved.get("description")
+ or resolved.get("title")
+ or ""
+ )
+ marker: dict[str, Any] = {
+ "type": resolved.get("type", "object"),
+ "x-collapsed": True,
+ "x-path": path,
+ }
+ if description:
+ marker["description"] = description
+ if _contains_dynamic(_inline(root, resolved)):
+ marker["x-dynamic"] = True
+ return marker
+
+
+def _prune_object(
+ root: dict[str, Any], node: dict[str, Any], prefix: str
+) -> dict[str, Any]:
+ node = _deref(root, node)
+ properties: dict[str, Any] = node.get("properties", {})
+ required = set(node.get("required", []))
+
+ out_properties: dict[str, Any] = {}
+ collapsed_any = False
+ for name, prop in properties.items():
+ path = f"{prefix}/{name}" if prefix else name
+ if name in required:
+ resolved = _deref(root, prop)
+ if _is_object(resolved):
+ # Mandatory nested object: descend to surface its own mandatory
+ # leaves (recursive minimum-viable).
+ out_properties[name] = _prune_object(root, resolved, path)
+ else:
+ # Mandatory leaf (scalar / array / enum): inline it whole.
+ out_properties[name] = _inline(root, prop)
+ else:
+ resolved = _deref(root, prop)
+ if _is_collapsible(root, resolved):
+ # Optional object / array-of-objects: hide behind a drill-in.
+ out_properties[name] = _collapse(root, prop, path)
+ collapsed_any = True
+ else:
+ # Optional cheap leaf: inline it — small, and nothing to drill.
+ out_properties[name] = _inline(root, prop)
+
+ result: dict[str, Any] = {"type": "object", "properties": out_properties}
+ for key in ("title", "description", "required"):
+ if key in node:
+ result[key] = node[key]
+ # Signal that some optional children were withheld (collapsed) from this
+ # object, so the consumer knows it is a partial view.
+ if collapsed_any:
+ result["x-partial"] = True
+ return result
+
+
+def prune_to_minimal_viable(schema: dict[str, Any]) -> dict[str, Any]:
+ """Return the minimum-viable-object view of a control schema (see module
+ docstring). The input is not mutated."""
+ result = _prune_object(schema, schema, prefix="")
+ result["x-disclosure"] = "minimal"
+ return result
+
+
+def _navigate(root: dict[str, Any], path: str) -> dict[str, Any]:
+ """Walk a ``a/b`` (or ``a.b``) path through ``properties`` /
+ ``additionalProperties``, dereferencing along the way."""
+ node: dict[str, Any] = root
+ segments = [seg for seg in path.replace(".", "/").split("/") if seg]
+ for segment in segments:
+ node = _deref(root, node)
+ properties = node.get("properties", {})
+ if segment in properties:
+ node = properties[segment]
+ elif isinstance(node.get("additionalProperties"), dict):
+ # A dynamic map (e.g. `series`): any key resolves to the item shape.
+ node = node["additionalProperties"]
+ else:
+ raise SchemaPathError(
+ f"Path segment {segment!r} not found in schema path {path!r}"
+ )
+ return node
+
+
+def get_subtree(schema: dict[str, Any], path: str) -> dict[str, Any]:
+ """Resolve ``path`` against ``schema`` and return that node as a
+ self-contained schema (``$ref`` inlined). Raises ``SchemaPathError`` for an
+ unresolvable path. The input is not mutated."""
+ if not path or not path.strip("/. "):
+ return _inline(schema, dict(schema))
+ node = _navigate(schema, path)
+ return _inline(schema, node)
+
+
+def get_subtrees(schema: dict[str, Any], paths: list[str]) -> dict[str, Any]:
+ """Resolve several drill-in ``paths`` in one pass, returning a
+ ``{path: subtree}`` map (each subtree self-contained, ``$ref`` inlined).
+
+ Lets a consumer expand multiple collapsed branches in a single round trip
+ instead of one call per branch. Raises ``SchemaPathError`` naming the first
+ unresolvable path. The input is not mutated."""
+ return {path: get_subtree(schema, path) for path in paths}
diff --git a/tests/unit_tests/canvas/api_test.py b/tests/unit_tests/canvas/api_test.py
index 06bb5885e4c..515aa5897c1 100644
--- a/tests/unit_tests/canvas/api_test.py
+++ b/tests/unit_tests/canvas/api_test.py
@@ -72,7 +72,7 @@ def patch_ops(client: Any, base_revision: int, *ops: dict[str, Any]) -> Any:
def add(widget: str, **extra: Any) -> dict[str, Any]:
- return {"op": "add", "widget": widget, **extra}
+ return {"op": "add", "instance": widget, **extra}
def test_create_and_read_a_canvas(
@@ -214,7 +214,7 @@ def test_overlapping_stale_write_is_rejected(
assert response.status_code == 409
assert response.json == {
- "message": "Nodes you edited were changed since your revision.",
+ "message": "Placements you edited were changed since your revision.",
"revision": 3,
"conflicts": [node_id],
"stale": False,
@@ -261,6 +261,9 @@ def test_invalid_operation_reports_its_index(
assert response.status_code == 422
assert response.json["operation"] == 1
+ assert response.json["errors"] == [
+ {"path": "/ops/1", "message": "unknown node 'ghost'"}
+ ]
assert canvas.revision == 1
published.assert_not_called()
@@ -283,7 +286,10 @@ def test_widgets_the_author_cannot_place_are_rejected(
assert response.status_code == 422
assert response.json["errors"] == [
- {"path": "/ops/1/widget", "message": "unknown widget, or no access to it"}
+ {
+ "path": "/ops/1/instance",
+ "message": "unknown widget instance, or no access to it",
+ }
]
@@ -291,6 +297,17 @@ def test_malformed_body(client: Any, full_api_access: None, canvas: Any) -> None
response = client.patch(URL, json={"ops": [{"op": "explode"}]})
assert response.status_code == 400
+ paths = {error["path"] for error in response.json["message"]}
+ assert "/base_revision" in paths
+
+
+def test_malformed_op_paths_point_into_the_body(
+ client: Any, full_api_access: None, canvas: Any
+) -> None:
+ response = patch_ops(client, 1, add("chart-1", layout=[]))
+
+ assert response.status_code == 400
+ assert response.json["message"][0]["path"] == "/ops/0/layout"
def test_non_editors_cannot_write(
@@ -356,23 +373,23 @@ def test_definition_from_a_newer_superset_is_refused(
assert canvas.revision == 1
-def test_write_that_upgrades_the_definition_restarts_the_op_log(
+def test_a_definition_from_a_newer_server_is_refused(
client: Any,
full_api_access: None,
canvas: Any,
published: MagicMock,
session: Session,
) -> None:
- patch_ops(client, 1, add("chart-1"))
- # Stored by an older schema version.
- canvas.definition_version = 0
+ canvas.definition = json.dumps({**json.loads(canvas.definition), "version": 2})
session.commit()
- assert patch_ops(client, 1, add("chart-2")).json["stale"] is True
- assert patch_ops(client, 2, add("chart-2")).status_code == 200
- assert canvas.definition_version == 1
- assert client.get(f"{URL}/changes?since=1").status_code == 409
- assert client.get(f"{URL}/changes?since=2").status_code == 200
+ read = client.get(URL)
+ write = patch_ops(client, 1, add("chart-1"))
+
+ assert read.status_code == 422
+ assert "Upgrade Superset" in read.json["message"]
+ assert write.status_code == 422
+ assert canvas.revision == 1
def test_schema_is_published(
@@ -384,7 +401,8 @@ def test_schema_is_published(
assert "interactions" in result["definition"]["properties"]
assert result["operation"]["discriminator"]["propertyName"] == "op"
assert "colSpan" in result["gridPlacement"]["properties"]
- by_type = {rules["type"]: rules for rules in result["widgetTypes"]}
- assert by_type["tabs"]["acceptedChildren"] == ["tab"]
- assert by_type["filterbar"]["boundsFilterScope"] is False
- assert by_type["chart"]["isFilterable"] is True
+ by_id = {widget["id"]: widget for widget in result["widgets"]}
+ assert by_id["tabs"]["acceptedChildren"] == ["tab"]
+ assert by_id["filterbar"]["boundsFilterScope"] is False
+ assert by_id["chart"]["filterable"] is True
+ assert by_id["chart"]["schemaVersion"] == 1
diff --git a/tests/unit_tests/canvas/commands_test.py b/tests/unit_tests/canvas/commands_test.py
index 0ab5e624955..f6fa357cfe5 100644
--- a/tests/unit_tests/canvas/commands_test.py
+++ b/tests/unit_tests/canvas/commands_test.py
@@ -70,7 +70,9 @@ def test_create_rejects_widgets_the_author_cannot_place(
command.validate()
assert excinfo.value.normalized_messages() == {
- "definition": {"/nodes/a/widget": ["unknown widget, or no access to it"]}
+ "definition": {
+ "/nodes/a/instance": ["unknown widget instance, or no access to it"]
+ }
}
diff --git a/tests/unit_tests/canvas/conftest.py b/tests/unit_tests/canvas/conftest.py
index a0357988a2f..e84ac6b2069 100644
--- a/tests/unit_tests/canvas/conftest.py
+++ b/tests/unit_tests/canvas/conftest.py
@@ -15,24 +15,22 @@
# specific language governing permissions and limitations
# under the License.
from collections.abc import Iterator
+from unittest.mock import patch
import pytest
-from tests.unit_tests.canvas.fixtures import canvas_rules, FakeResolver
+from tests.unit_tests.canvas.fixtures import canvas_widgets, FakeResolver
@pytest.fixture
def widgets() -> Iterator[FakeResolver]:
- """Register the test widget types and resolver with the canvas registry."""
+ """Swap in the test widgets and a persisted-instance resolver."""
from superset.canvas.definition import registry
+ from superset.widgets.registry import registry as widget_registry
resolver = FakeResolver(hidden={"chart-secret"})
- previous_resolver = registry.get_widget_resolver()
- test_rules = list(canvas_rules())
- for rules in test_rules:
- registry.layout_rules.register(rules)
- registry.set_widget_resolver(resolver)
- yield resolver
- registry.set_widget_resolver(previous_resolver)
- for rules in test_rules:
- registry.layout_rules.unregister(rules.widget_type)
+ previous_resolver = registry.get_instance_resolver()
+ registry.set_instance_resolver(resolver)
+ with patch.dict(widget_registry, canvas_widgets(), clear=True):
+ yield resolver
+ registry.set_instance_resolver(previous_resolver)
diff --git a/tests/unit_tests/canvas/fixtures.py b/tests/unit_tests/canvas/fixtures.py
index 0a18e5aa3c0..a7016e544cb 100644
--- a/tests/unit_tests/canvas/fixtures.py
+++ b/tests/unit_tests/canvas/fixtures.py
@@ -16,33 +16,42 @@
# under the License.
from __future__ import annotations
-from collections.abc import Callable, Iterable
+from collections.abc import Callable, Container, Iterable
from typing import Any
from pydantic import BaseModel, ConfigDict, TypeAdapter
-from superset_core.canvas import CanvasLayoutRules
+from superset_core.widgets import Widget, WidgetBehavior
-from superset.canvas.definition.registry import LayoutRulesRegistry
from superset.canvas.definition.schemas import Operation
-class GroupRules(CanvasLayoutRules):
- widget_type = "group"
- is_container = True
- grid_columns = 12
+class Props(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ title: str = ""
-class TabsRules(CanvasLayoutRules):
- widget_type = "tabs"
- is_container = True
- accepted_children = frozenset({"tab"})
+class ChartProps(Props):
+ metric: str = "count"
-class TabRules(CanvasLayoutRules):
- widget_type = "tab"
- is_container = True
- allowed_parents = frozenset({"tabs"})
- grid_columns = 24
+def _widget(
+ widget_type: str,
+ behavior: WidgetBehavior | None = None,
+ controls: type[BaseModel] = Props,
+ **attrs: Any,
+) -> type[Widget]:
+ return type(
+ f"{widget_type.title()}Widget",
+ (Widget,),
+ {
+ "widget_type": widget_type,
+ "name": widget_type.title(),
+ "controls_class": controls,
+ "behavior": behavior or WidgetBehavior(),
+ **attrs,
+ },
+ )
class BoardColumn(BaseModel):
@@ -51,85 +60,64 @@ class BoardColumn(BaseModel):
lane: str
-class BoardRules(CanvasLayoutRules):
- """A container with its own, non-grid child layout."""
-
- widget_type = "board"
- is_container = True
- child_layout_model = BoardColumn
-
-
-class ChartRules(CanvasLayoutRules):
- widget_type = "chart"
- is_filterable = True
-
-
-class CrossFilterChartRules(CanvasLayoutRules):
- """A chart that can also cross-filter others."""
-
- widget_type = "xchart"
- is_filterable = True
- is_cross_filter_source = True
-
-
-class GroupByRules(CanvasLayoutRules):
- """A customization, e.g. a dynamic group-by control."""
-
- widget_type = "groupby"
- is_customization = True
-
-
-class FilterRules(CanvasLayoutRules):
- widget_type = "filter"
- is_filter = True
-
-
-class FilterBarRules(CanvasLayoutRules):
- """Holds filters without containing their scope."""
-
- widget_type = "filterbar"
- is_container = True
- grid_columns = 24
- accepted_children = frozenset({"filter"})
- bounds_filter_scope = False
-
-
-def canvas_rules() -> LayoutRulesRegistry:
- rules = LayoutRulesRegistry()
- for registered in (
- GroupRules,
- TabsRules,
- TabRules,
- BoardRules,
- ChartRules,
- CrossFilterChartRules,
- GroupByRules,
- FilterRules,
- FilterBarRules,
- ):
- rules.register(registered)
- return rules
+def canvas_widgets() -> dict[str, type[Widget]]:
+ """Test widgets by id, standing in for the widget registry."""
+ widgets = [
+ _widget("group", WidgetBehavior(container=True, grid_columns=12)),
+ _widget(
+ "tabs",
+ WidgetBehavior(container=True, accepted_children=frozenset({"tab"})),
+ ),
+ _widget(
+ "tab",
+ WidgetBehavior(
+ container=True, allowed_parents=frozenset({"tabs"}), grid_columns=24
+ ),
+ ),
+ # A container with its own, non-grid child layout.
+ _widget(
+ "board", WidgetBehavior(container=True, child_layout_model=BoardColumn)
+ ),
+ _widget("markdown"),
+ _widget("chart", WidgetBehavior(filterable=True), ChartProps),
+ # A chart that can also cross-filter others.
+ _widget("xchart", WidgetBehavior(filterable=True, emits_filters=True)),
+ # A customization, e.g. a dynamic group-by control.
+ _widget("groupby", WidgetBehavior(customization=True)),
+ _widget("filter", WidgetBehavior(filter=True)),
+ # Holds filters without containing their scope.
+ _widget(
+ "filterbar",
+ WidgetBehavior(
+ container=True,
+ grid_columns=24,
+ accepted_children=frozenset({"filter"}),
+ bounds_filter_scope=False,
+ ),
+ ),
+ ]
+ return {widget.widget_type: widget for widget in widgets}
class FakeResolver:
"""
- Widgets are named ``<type>`` or ``<type>-<n>``, so a widget's type is
- readable from its id. Ids in ``hidden`` exist but cannot be placed, and
- ids starting with ``missing`` do not exist.
+ Persisted instances are named ``<widget>`` or ``<widget>-<n>``, so an
+ instance's widget is readable from its id. Ids in ``hidden`` exist but
+ cannot be placed, and ids starting with ``missing`` do not exist.
"""
def __init__(self, hidden: Iterable[str] = ()) -> None:
self.hidden = set(hidden)
- def widget_types(self, widget_ids: Iterable[str]) -> dict[str, str]:
+ def widget_types(self, instance_ids: Iterable[str]) -> dict[str, str]:
return {
- widget_id: widget_id.split("-")[0]
- for widget_id in widget_ids
- if not widget_id.startswith("missing")
+ instance_id: instance_id.split("-")[0]
+ for instance_id in instance_ids
+ if not instance_id.startswith("missing")
}
- def placeable(self, widget_ids: Iterable[str]) -> set[str]:
- return set(self.widget_types(widget_ids)) - self.hidden
+ def placeable(self, instance_ids: Iterable[str]) -> set[str]:
+ return set(self.widget_types(instance_ids)) - self.hidden
_ops_adapter = TypeAdapter(list[Operation])
@@ -139,13 +127,21 @@ def ops(*raw: dict[str, Any]) -> list[Operation]:
return _ops_adapter.validate_python(list(raw))
-def sequential_ids() -> Callable[[], str]:
+def sequential_ids() -> Callable[[str, Container[str]], str]:
counter = iter(range(10_000))
- return lambda: f"n{next(counter)}"
+ return lambda _base, _taken: f"n{next(counter)}"
+
+def node(instance: str, **extra: Any) -> dict[str, Any]:
+ """A placement of a persisted instance."""
+ return {"instance": instance, **extra}
-def node(widget: str, **extra: Any) -> dict[str, Any]:
- return {"widget": widget, **extra}
+
+def inline(
+ widget: str, props: dict[str, Any] | None = None, **extra: Any
+) -> dict[str, Any]:
+ """A placement holding an inline instance of ``widget``."""
+ return {"widget": widget, "schemaVersion": 1, "props": props or {}, **extra}
def canvas(
diff --git a/tests/unit_tests/canvas/ops_test.py b/tests/unit_tests/canvas/ops_test.py
index 0607bd88f31..88e51daa47e 100644
--- a/tests/unit_tests/canvas/ops_test.py
+++ b/tests/unit_tests/canvas/ops_test.py
@@ -14,9 +14,11 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
+import dataclasses
from typing import Any
import pytest
+from superset_core.widgets import Widget, WidgetUi
from superset.canvas.definition.ops import (
AppliedOperation,
@@ -28,11 +30,10 @@ from superset.canvas.definition.ops import (
SETTINGS_ID,
Touch,
)
-from superset.canvas.definition.registry import LayoutRulesRegistry
from superset.canvas.definition.schemas import empty_definition
from superset.canvas.definition.validation import DefinitionValidationError
from tests.unit_tests.canvas.fixtures import (
- canvas_rules,
+ canvas_widgets,
FakeResolver,
ops,
sequential_ids,
@@ -45,7 +46,7 @@ def apply(
return apply_operations(
canvas,
ops(*raw),
- rules=canvas_rules(),
+ widgets=canvas_widgets(),
resolver=FakeResolver(),
new_id=sequential_ids(),
)
@@ -56,14 +57,14 @@ def build(*raw: dict[str, Any]) -> dict[str, Any]:
def add(widget: str, **extra: Any) -> dict[str, Any]:
- return {"op": "add", "widget": widget, **extra}
+ return {"op": "add", "instance": widget, **extra}
def test_add_assigns_node_ids_and_reports_them() -> None:
result, applied = apply(empty_definition(), add("chart-1"), add("group"))
assert result["root"]["children"] == ["n0", "n1"]
- assert result["nodes"]["n0"] == {"widget": "chart-1", "layout": {}}
+ assert result["nodes"]["n0"] == {"instance": "chart-1", "layout": {}}
assert result["nodes"]["n1"]["children"] == []
assert applied[0].op["id"] == "n0"
assert applied[0].touched == [Touch("n0", FieldGroup.TREE)]
@@ -79,7 +80,7 @@ def test_add_into_a_container_added_in_the_same_batch() -> None:
assert result["nodes"]["n0"]["children"] == ["n2", "n1"]
-GROUP_ID = "6f1c2b7e-2d4a-4c1e-9a53-0f3b8d2e7a10"
+GROUP_ID = "kpi-row"
def test_add_takes_a_caller_id_that_later_ops_can_reference() -> None:
@@ -104,32 +105,162 @@ def test_add_rejects_an_id_already_in_use() -> None:
@pytest.mark.parametrize(
- "node_id", ["root", "n0", GROUP_ID.upper(), GROUP_ID.replace("-", "")]
+ "node_id", ["Kpi-Row", "kpi_row", "kpi row", "-kpi", "kpi-", "kpi--row", "x" * 65]
)
-def test_add_rejects_ids_that_are_not_canonical_uuids(node_id: str) -> None:
- with pytest.raises(ValueError, match="should match pattern"):
+def test_add_rejects_ids_that_are_not_slugs(node_id: str) -> None:
+ with pytest.raises(ValueError, match="should match pattern|at most 64"):
ops(add("chart-1", id=node_id))
+@pytest.mark.parametrize("node_id", ["root", "settings"])
+def test_add_rejects_reserved_ids(node_id: str) -> None:
+ with pytest.raises(ValueError, match="reserved"):
+ ops(add("chart-1", id=node_id))
+
+
+def test_add_derives_readable_ids_from_titles_and_widget_names() -> None:
+ result, applied = apply_operations(
+ empty_definition(),
+ ops(
+ {"op": "add", "widget": "chart", "props": {"title": "Revenue trend"}},
+ {"op": "add", "widget": "chart", "props": {"title": "Revenue trend"}},
+ {"op": "add", "widget": "chart"},
+ add("group-1"),
+ ),
+ widgets=canvas_widgets(),
+ resolver=FakeResolver(),
+ )
+
+ assert [a.op["id"] for a in applied] == [
+ "revenue-trend",
+ "revenue-trend-2",
+ "chart",
+ "group",
+ ]
+ assert result["root"]["children"] == [a.op["id"] for a in applied]
+
+
+def test_add_inline_stores_props_at_the_widget_schema_version() -> None:
+ result, applied = apply(
+ empty_definition(), {"op": "add", "widget": "chart", "props": {"metric": "sum"}}
+ )
+
+ assert result["nodes"]["n0"] == {
+ "widget": "chart",
+ "schemaVersion": 1,
+ "props": {"metric": "sum"},
+ "layout": {},
+ }
+ assert set(applied[0].touched) == {
+ Touch("n0", FieldGroup.TREE),
+ Touch("n0", FieldGroup.PROPS),
+ }
+
+
+def test_add_inline_rejects_unknown_widgets_and_invalid_props() -> None:
+ with pytest.raises(OperationError, match="unknown widget 'nope'"):
+ apply(empty_definition(), {"op": "add", "widget": "nope"})
+ with pytest.raises(DefinitionValidationError) as excinfo:
+ apply(empty_definition(), {"op": "add", "widget": "chart", "props": {"x": 1}})
+ assert excinfo.value.issues[0].path == "/nodes/n0/props/x"
+
+
+@pytest.mark.parametrize(
+ "raw",
+ [
+ {"op": "add"},
+ {"op": "add", "instance": "chart-1", "widget": "chart"},
+ {"op": "add", "instance": "chart-1", "props": {}},
+ ],
+)
+def test_add_takes_exactly_one_kind_of_instance(raw: dict[str, Any]) -> None:
+ with pytest.raises(ValueError, match="either instance or widget|props are only"):
+ ops(raw)
+
+
+def test_add_uses_the_widget_default_size_on_grids() -> None:
+ widgets = changed_rules("chart", default_size=(30, 4))
+ widgets["group"] = changed_rules("group")["group"]
+
+ result, _ = apply_operations(
+ empty_definition(),
+ ops(
+ {"op": "add", "id": "a", "widget": "chart"},
+ {"op": "add", "id": "b", "widget": "chart", "layout": {"rowSpan": 2}},
+ {"op": "add", "id": "g", "widget": "group"},
+ {"op": "add", "id": "c", "widget": "chart", "parent": "g"},
+ ),
+ widgets=widgets,
+ resolver=FakeResolver(),
+ )
+
+ # Clamped to the grid's width; explicit values win.
+ assert result["nodes"]["a"]["layout"] == {"colSpan": 24, "rowSpan": 4}
+ assert result["nodes"]["b"]["layout"] == {"colSpan": 24, "rowSpan": 2}
+ assert result["nodes"]["c"]["layout"] == {"colSpan": 12, "rowSpan": 4}
+
+
+def test_set_props_replaces_inline_props() -> None:
+ doc = build({"op": "add", "widget": "chart", "props": {"title": "A"}})
+
+ result, applied = apply(
+ doc, {"op": "set_props", "id": "n0", "props": {"metric": "avg"}}
+ )
+
+ assert result["nodes"]["n0"]["props"] == {"metric": "avg"}
+ assert applied[0].touched == [Touch("n0", FieldGroup.PROPS)]
+
+
+def test_set_props_needs_an_inline_instance() -> None:
+ doc = build(add("chart-1"))
+
+ with pytest.raises(OperationError, match="not an inline instance"):
+ apply(doc, {"op": "set_props", "id": "n0", "props": {}})
+
+
+def test_props_are_only_validated_where_written() -> None:
+ doc = build(
+ {"op": "add", "widget": "chart", "props": {"title": "A"}},
+ add("chart-1"),
+ )
+ # Stored props a newer widget no longer accepts don't block other edits.
+ doc["nodes"]["n0"]["props"] = {"retired": True}
+
+ result, _ = apply(doc, {"op": "place", "id": "n1", "layout": {"colSpan": 6}})
+
+ assert result["nodes"]["n0"]["props"] == {"retired": True}
+ with pytest.raises(DefinitionValidationError, match="retired"):
+ apply(doc, {"op": "set_props", "id": "n0", "props": {"retired": True}})
+
+
def test_add_into_a_leaf_is_rejected() -> None:
with pytest.raises(OperationError, match="'n0' cannot hold children"):
build(add("chart-1"), add("chart-2", parent="n0"))
-def changed_rules(widget_type: str, **changes: Any) -> LayoutRulesRegistry:
- """The test rules, with ``widget_type``'s rules changed after saving."""
- rules = canvas_rules()
- base = rules.get(widget_type)
- rules.unregister(widget_type)
- rules.register(type(f"Changed{base.__name__}", (base,), changes))
- return rules
+def changed_rules(widget_type: str, **changes: Any) -> dict[str, type[Widget]]:
+ """The test widgets, with ``widget_type``'s behavior or UI changed after saving."""
+ widgets = canvas_widgets()
+ base = widgets[widget_type]
+ ui_fields = {f.name for f in dataclasses.fields(WidgetUi)}
+ ui = {k: v for k, v in changes.items() if k in ui_fields}
+ behavior = {k: v for k, v in changes.items() if k not in ui_fields}
+ widgets[widget_type] = type(
+ f"Changed{base.__name__}",
+ (base,),
+ {
+ "behavior": dataclasses.replace(base.behavior, **behavior),
+ "ui": dataclasses.replace(base.ui, **ui),
+ },
+ )
+ return widgets
def apply_with(
- rules: LayoutRulesRegistry, doc: dict[str, Any], *raw: dict[str, Any]
+ rules: dict[str, type[Widget]], doc: dict[str, Any], *raw: dict[str, Any]
) -> dict[str, Any]:
return apply_operations(
- doc, ops(*raw), rules=rules, resolver=FakeResolver(), new_id=sequential_ids()
+ doc, ops(*raw), widgets=rules, resolver=FakeResolver(), new_id=sequential_ids()
)[0]
@@ -172,7 +303,7 @@ def test_unresolved_container_keeps_its_children_but_takes_no_new_ones() -> None
unregistered, _ = apply_operations(
doc,
ops({"op": "place", "id": "n2", "layout": {"colSpan": 6}}),
- rules=LayoutRulesRegistry(),
+ widgets={},
resolver=FakeResolver(),
)
@@ -181,7 +312,7 @@ def test_unresolved_container_keeps_its_children_but_takes_no_new_ones() -> None
apply_operations(
unregistered,
ops({"op": "move", "id": "n2", "parent": "n0"}),
- rules=LayoutRulesRegistry(),
+ widgets={},
resolver=FakeResolver(),
)
diff --git a/tests/unit_tests/canvas/render_test.py b/tests/unit_tests/canvas/render_test.py
index 791eaf6de0a..77587d3646a 100644
--- a/tests/unit_tests/canvas/render_test.py
+++ b/tests/unit_tests/canvas/render_test.py
@@ -16,11 +16,11 @@
# under the License.
from superset.canvas.definition.render import render_context
from superset.canvas.definition.validation import normalize_definition
-from tests.unit_tests.canvas.fixtures import canvas, canvas_rules, FakeResolver, node
+from tests.unit_tests.canvas.fixtures import canvas, canvas_widgets, FakeResolver, node
def test_placements_include_auto_placed_nodes_and_grid_containers() -> None:
- rules, resolver = canvas_rules(), FakeResolver()
+ rules, resolver = canvas_widgets(), FakeResolver()
definition = normalize_definition(
canvas(
{
diff --git a/tests/unit_tests/canvas/scopes_test.py b/tests/unit_tests/canvas/scopes_test.py
index 4aba73ada68..76f9bc1bfd3 100644
--- a/tests/unit_tests/canvas/scopes_test.py
+++ b/tests/unit_tests/canvas/scopes_test.py
@@ -31,7 +31,7 @@ from superset.canvas.definition.validation import (
)
from tests.unit_tests.canvas.fixtures import (
canvas,
- canvas_rules,
+ canvas_widgets,
FakeResolver,
node,
ops,
@@ -39,7 +39,7 @@ from tests.unit_tests.canvas.fixtures import (
def all_scopes(raw: dict[str, Any]) -> dict[str, dict[str, list[str]]]:
- rules, resolver = canvas_rules(), FakeResolver()
+ rules, resolver = canvas_widgets(), FakeResolver()
return resolve_scopes(normalize_definition(raw, rules, resolver), rules, resolver)
@@ -131,17 +131,17 @@ def test_filter_bars_do_not_contain_scope() -> None:
)
def test_invalid_overrides(override: dict[str, Any], message: str) -> None:
with pytest.raises(DefinitionValidationError) as excinfo:
- normalize_definition(analyst_view(**override), canvas_rules(), FakeResolver())
+ normalize_definition(analyst_view(**override), canvas_widgets(), FakeResolver())
assert any(message in issue.message for issue in excinfo.value.issues)
def apply(raw: dict[str, Any], *raw_ops: dict[str, Any]) -> Any:
- rules, resolver = canvas_rules(), FakeResolver()
+ rules, resolver = canvas_widgets(), FakeResolver()
return apply_operations(
normalize_definition(raw, rules, resolver),
ops(*raw_ops),
- rules=rules,
+ widgets=rules,
resolver=resolver,
)
@@ -243,7 +243,7 @@ def test_scope_kinds_are_set_and_merged_independently() -> None:
assert (
overlapping(applied[0].touched, [Touch("x1", FieldGroup.FILTER_SCOPE)]) == set()
)
- rules, resolver = canvas_rules(), FakeResolver()
+ rules, resolver = canvas_widgets(), FakeResolver()
assert resolve_scopes(doc, rules, resolver)["crossFilterScopes"]["x1"] == [
"x2",
"c1",
diff --git a/tests/unit_tests/canvas/upgrades_test.py b/tests/unit_tests/canvas/upgrades_test.py
deleted file mode 100644
index 04d40d9c621..00000000000
--- a/tests/unit_tests/canvas/upgrades_test.py
+++ /dev/null
@@ -1,62 +0,0 @@
-# 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 typing import Any
-
-import pytest
-
-from superset.canvas.definition.upgrades import (
- DefinitionVersionError,
- upgrade_definition,
-)
-
-
-def add_title(definition: dict[str, Any]) -> dict[str, Any]:
- return {**definition, "title": "untitled"}
-
-
-def rename_title(definition: dict[str, Any]) -> dict[str, Any]:
- definition["name"] = definition.pop("title")
- return definition
-
-
-UPGRADES = {1: add_title, 2: rename_title}
-
-
-def test_current_definition_is_returned_as_is() -> None:
- definition = {"version": 3}
-
- assert upgrade_definition(definition, current=3, upgrades=UPGRADES) is definition
-
-
-def test_older_definition_runs_every_upgrade_in_order() -> None:
- stored = {"version": 1, "nodes": {}}
-
- upgraded = upgrade_definition(stored, current=3, upgrades=UPGRADES)
-
- assert upgraded == {"version": 3, "nodes": {}, "name": "untitled"}
- assert stored == {"version": 1, "nodes": {}}
-
-
-def test_newer_definition_is_refused() -> None:
- with pytest.raises(DefinitionVersionError, match="Upgrade Superset"):
- upgrade_definition({"version": 4}, current=3, upgrades=UPGRADES)
-
-
-@pytest.mark.parametrize("version", [0, "1", True, None])
-def test_malformed_version_is_refused(version: Any) -> None:
- with pytest.raises(DefinitionVersionError, match="Unsupported"):
- upgrade_definition({"version": version}, current=3, upgrades=UPGRADES)
diff --git a/tests/unit_tests/canvas/validation_test.py b/tests/unit_tests/canvas/validation_test.py
index c42d4c9da8e..2a6867e5a69 100644
--- a/tests/unit_tests/canvas/validation_test.py
+++ b/tests/unit_tests/canvas/validation_test.py
@@ -25,14 +25,15 @@ from superset.canvas.definition.validation import (
)
from tests.unit_tests.canvas.fixtures import (
canvas,
- canvas_rules,
+ canvas_widgets,
FakeResolver,
+ inline,
node,
)
def normalize(raw: dict[str, Any]) -> dict[str, Any]:
- return normalize_definition(raw, canvas_rules(), FakeResolver())
+ return normalize_definition(raw, canvas_widgets(), FakeResolver())
def issues(raw: dict[str, Any]) -> list[tuple[str, str]]:
@@ -56,10 +57,58 @@ def test_empty_canvas_is_valid() -> None:
}
-def test_nodes_hold_only_a_widget_reference_and_placement() -> None:
- found = issues(canvas({"a": node("chart", props={"title": "x"})}))
+def test_a_persisted_instance_placement_holds_no_props() -> None:
+ found = issues(canvas({"a": node("chart-1", props={"title": "x"})}))
- assert found[0][0] == "/nodes/a/props"
+ assert found[0][0] == "/nodes/a"
+
+
+def test_inline_props_are_validated_against_the_widget() -> None:
+ found = issues(canvas({"a": inline("chart", {"metric": 3})}))
+
+ assert found == [("/nodes/a/props/metric", "Input should be a valid string")]
+
+
+def test_unknown_inline_widget_is_rejected_on_write() -> None:
+ found = issues(canvas({"a": inline("nope")}))
+
+ assert found[0][0] == "/nodes/a/widget"
+
+
+def test_props_from_a_newer_widget_stay_as_a_placeholder() -> None:
+ raw = canvas(
+ {"a": inline("chart", {"future": 1}, schemaVersion=2), "b": node("chart-1")}
+ )
+
+ result = normalize_definition(
+ raw, canvas_widgets(), FakeResolver(), strict_nodes=["b"], props_nodes=[]
+ )
+
+ assert result["nodes"]["a"]["props"] == {"future": 1}
+ assert result["nodes"]["a"]["schemaVersion"] == 2
+
+
+def test_inline_props_are_migrated_to_the_widget_version() -> None:
+ rules = canvas_widgets()
+ chart = rules["chart"]
+ rules["chart"] = type(
+ "ChartV2",
+ (chart,),
+ {
+ "schema_version": 2,
+ "migrators": {1: lambda props: {"metric": props["measure"]}},
+ },
+ )
+ raw = canvas({"a": inline("chart", {"measure": "sum"})})
+
+ result = normalize_definition(raw, rules, FakeResolver())
+
+ assert result["nodes"]["a"]["schemaVersion"] == 2
+ assert result["nodes"]["a"]["props"] == {"metric": "sum"}
+
+
+def test_reserved_placement_ids_are_rejected() -> None:
+ assert "reserved" in issues(canvas({"settings": node("chart-1")}))[0][1]
def test_unknown_version_is_rejected() -> None:
@@ -89,7 +138,7 @@ def test_a_widget_can_be_placed_more_than_once() -> None:
result = normalize(raw)
- assert result["nodes"]["a"]["widget"] == result["nodes"]["b"]["widget"]
+ assert result["nodes"]["a"]["instance"] == result["nodes"]["b"]["instance"]
assert result["nodes"]["a"]["layout"] == {"colSpan": 8}
@@ -135,17 +184,17 @@ def test_unregistered_type_with_children_is_kept() -> None:
def test_size_limits_apply_on_grids() -> None:
- from superset_core.canvas import CanvasLayoutRules
-
- from tests.unit_tests.canvas.fixtures import canvas_rules
+ from superset_core.widgets import WidgetUi
- class KpiRules(CanvasLayoutRules):
- widget_type = "kpi"
- min_col_span = 4
- max_row_span = 6
+ from tests.unit_tests.canvas.fixtures import canvas_widgets
- rules = canvas_rules()
- rules.register(KpiRules)
+ rules = canvas_widgets()
+ chart = rules["chart"]
+ rules["kpi"] = type(
+ "KpiWidget",
+ (chart,),
+ {"widget_type": "kpi", "ui": WidgetUi(min_col_span=4, max_row_span=6)},
+ )
raw = canvas({"k": node("kpi", layout={"colSpan": 2, "rowSpan": 8})})
with pytest.raises(DefinitionValidationError) as excinfo:
@@ -159,11 +208,11 @@ def test_size_limits_apply_on_grids() -> None:
def test_nesting_rules() -> None:
assert issues(canvas({"t": node("tab")})) == [
- ("/nodes/t/widget", "a tab widget cannot be placed in root")
+ ("/nodes/t", "a tab widget cannot be placed in root")
]
assert issues(
canvas({"s": node("tabs", children=["m"]), "m": node("markdown")}, ["s"])
- ) == [("/nodes/m/widget", "a markdown widget cannot be placed in tabs")]
+ ) == [("/nodes/m", "a markdown widget cannot be placed in tabs")]
def test_containers_own_their_children_layout() -> None:
diff --git a/tests/unit_tests/canvas/versions_test.py b/tests/unit_tests/canvas/versions_test.py
new file mode 100644
index 00000000000..ec6dbd0c866
--- /dev/null
+++ b/tests/unit_tests/canvas/versions_test.py
@@ -0,0 +1,43 @@
+# 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 pytest
+
+from superset.canvas.definition.versions import (
+ check_definition_version,
+ DefinitionVersionError,
+)
+
+
+def test_current_version_passes_through() -> None:
+ definition = {"version": 3}
+
+ assert check_definition_version(definition, current=3) is definition
+
+
+def test_missing_version_is_the_current_one() -> None:
+ assert check_definition_version({}, current=3) == {}
+
+
+def test_newer_version_asks_for_an_upgrade() -> None:
+ with pytest.raises(DefinitionVersionError, match="Upgrade Superset"):
+ check_definition_version({"version": 4}, current=3)
+
+
+@pytest.mark.parametrize("version", [2, 0, "1", True, None])
+def test_other_versions_are_refused(version: object) -> None:
+ with pytest.raises(DefinitionVersionError, match="Unsupported"):
+ check_definition_version({"version": version}, current=3)
diff --git a/superset-core/src/superset_core/canvas/__init__.py b/tests/unit_tests/mcp_service/canvas/__init__.py
similarity index 77%
copy from superset-core/src/superset_core/canvas/__init__.py
copy to tests/unit_tests/mcp_service/canvas/__init__.py
index eee560bcf4b..13a83393a91 100644
--- a/superset-core/src/superset_core/canvas/__init__.py
+++ b/tests/unit_tests/mcp_service/canvas/__init__.py
@@ -14,13 +14,3 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
-
-"""Dashboard canvas contracts for widget providers."""
-
-from superset_core.canvas.base import (
- CanvasLayoutRules,
- GridPlacement,
- WidgetResolver,
-)
-
-__all__ = ["CanvasLayoutRules", "GridPlacement", "WidgetResolver"]
diff --git a/superset-core/src/superset_core/canvas/__init__.py b/tests/unit_tests/mcp_service/canvas/tool/__init__.py
similarity index 77%
copy from superset-core/src/superset_core/canvas/__init__.py
copy to tests/unit_tests/mcp_service/canvas/tool/__init__.py
index eee560bcf4b..13a83393a91 100644
--- a/superset-core/src/superset_core/canvas/__init__.py
+++ b/tests/unit_tests/mcp_service/canvas/tool/__init__.py
@@ -14,13 +14,3 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
-
-"""Dashboard canvas contracts for widget providers."""
-
-from superset_core.canvas.base import (
- CanvasLayoutRules,
- GridPlacement,
- WidgetResolver,
-)
-
-__all__ = ["CanvasLayoutRules", "GridPlacement", "WidgetResolver"]
diff --git a/tests/unit_tests/mcp_service/canvas/tool/test_canvas_tools.py b/tests/unit_tests/mcp_service/canvas/tool/test_canvas_tools.py
new file mode 100644
index 00000000000..aada043547d
--- /dev/null
+++ b/tests/unit_tests/mcp_service/canvas/tool/test_canvas_tools.py
@@ -0,0 +1,101 @@
+# 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.
+
+"""Tests for the canvas MCP tools."""
+
+from __future__ import annotations
+
+from unittest.mock import MagicMock, patch
+
+from superset.commands.canvas.apply_ops import ApplyResult
+from superset.commands.canvas.exceptions import DefinitionConflictError
+from superset.mcp_service.canvas.tool.apply_canvas_ops import _apply_canvas_ops_impl
+from superset.mcp_service.canvas.tool.get_canvas import _get_canvas_impl
+
+COMMAND = (
+ "superset.mcp_service.canvas.tool.apply_canvas_ops.ApplyCanvasOperationsCommand"
+)
+DAO = "superset.mcp_service.canvas.tool.get_canvas.CanvasDAO"
+
+
+def test_apply_reports_malformed_ops_without_running_the_command() -> None:
+ with patch(COMMAND) as command:
+ result = _apply_canvas_ops_impl(1, 0, [{"op": "add", "id": "Not A Slug"}])
+
+ command.assert_not_called()
+ assert result["error"] == "Invalid operations"
+ assert result["errors"]
+
+
+def test_apply_returns_the_new_revision_and_assigned_ids() -> None:
+ applied = ApplyResult(
+ revision=4,
+ ops=[{"op": "add", "widget": "markdown", "id": "markdown"}],
+ scopes={},
+ render_context={},
+ )
+ with patch(COMMAND) as command:
+ command.return_value.run.return_value = applied
+ result = _apply_canvas_ops_impl(
+ 1, 3, [{"op": "add", "widget": "markdown", "props": {"content": "Hi"}}]
+ )
+
+ assert result == {"revision": 4, "ops": applied.ops}
+
+
+def test_apply_returns_conflicts_as_data() -> None:
+ with patch(COMMAND) as command:
+ command.return_value.run.side_effect = DefinitionConflictError(
+ 7, ["revenue-trend"], stale=False
+ )
+ result = _apply_canvas_ops_impl(1, 3, [{"op": "remove", "id": "revenue-trend"}])
+
+ assert result["revision"] == 7
+ assert "error" in result
+
+
+def test_get_canvas_is_lean_unless_resolution_is_asked_for() -> None:
+ canvas = MagicMock(id=1, title="Sales", url="/canvas/1/", revision=2)
+ definition = {
+ "version": 1,
+ "root": {"layout": {"columns": 24}, "children": []},
+ "nodes": {},
+ }
+ with patch(DAO) as dao:
+ dao.find_by_id.return_value = canvas
+ dao.load.return_value = definition
+ lean = _get_canvas_impl(1, include_resolved=False)
+ full = _get_canvas_impl(1, include_resolved=True)
+
+ assert set(lean) == {"id", "title", "url", "revision", "definition"}
+ assert {"placements", "filterScopes", "widgetTypes"} <= set(full)
+
+
+def test_get_canvas_hides_missing_or_inaccessible_canvases() -> None:
+ with patch(DAO) as dao:
+ dao.find_by_id.return_value = None
+ assert "error" in _get_canvas_impl(9, include_resolved=False)
+
+
+def test_apply_reports_unsupported_definition_versions() -> None:
+ from superset.canvas.definition.versions import DefinitionVersionError
+
+ with patch(COMMAND) as command:
+ command.return_value.run.side_effect = DefinitionVersionError(2, 1)
+ result = _apply_canvas_ops_impl(1, 3, [{"op": "remove", "id": "a"}])
+
+ assert "Upgrade Superset" in result["error"]
diff --git a/tests/unit_tests/mcp_service/test_mcp_tool_registration.py b/tests/unit_tests/mcp_service/test_mcp_tool_registration.py
index 76f08979740..b5be5438605 100644
--- a/tests/unit_tests/mcp_service/test_mcp_tool_registration.py
+++ b/tests/unit_tests/mcp_service/test_mcp_tool_registration.py
@@ -36,6 +36,10 @@ _FFM_PATH = "superset.extensions.feature_flag_manager"
# field assertion rejects missing or fully unconstrained output schemas.
EXPECTED_TOOL_OUTPUT_FIELDS = {
"add_chart_to_existing_dashboard": "dashboard_url",
+ "apply_canvas_ops": "revision",
+ "get_canvas": "definition",
+ "get_widget_control_schema": "control_schema",
+ "list_widget_types": "widget_types",
"apply_dashboard_filters": "permalink_key",
"create_dataset": "table_name",
"create_theme": "theme_name",
@@ -121,6 +125,7 @@ EXPECTED_TOP_LEVEL_OUTPUT_FIELDS = {
MUTATING_TOOLS = {
"add_chart_to_existing_dashboard",
+ "apply_canvas_ops",
"apply_dashboard_filters",
"create_dataset",
"create_theme",
@@ -160,6 +165,7 @@ NON_COMMITTING_MUTATING_TOOLS = {
DESTRUCTIVE_TOOLS = {
+ "apply_canvas_ops",
"delete_chart",
"delete_dashboard",
"execute_sql",
@@ -597,6 +603,28 @@ def test_task_tools_removed_when_global_task_framework_disabled(
assert "get_task_info" in removed
+def test_canvas_tools_removed_when_canvas_disabled(gtf_ffm: MagicMock) -> None:
+ """Canvas and widget tools follow the CANVAS flag, like their APIs."""
+ gtf_ffm.is_feature_enabled.side_effect = lambda flag: flag != "CANVAS"
+
+ flask_app = _make_flask_app_mock(set())
+
+ with (
+ patch("superset.mcp_service.flask_singleton.app", flask_app),
+ patch.object(mcp.local_provider, "remove_tool") as mock_remove,
+ ):
+ init_fastmcp_server()
+
+ removed = {call.args[0] for call in mock_remove.call_args_list}
+ assert {
+ "get_canvas",
+ "apply_canvas_ops",
+ "list_widget_types",
+ "get_widget_control_schema",
+ } <= removed
+ assert "list_tasks" not in removed
+
+
def test_config_guard_tools_excluded_from_instructions(
gtf_ffm: MagicMock,
) -> None:
diff --git a/superset-core/src/superset_core/canvas/__init__.py b/tests/unit_tests/mcp_service/widgets/__init__.py
similarity index 77%
copy from superset-core/src/superset_core/canvas/__init__.py
copy to tests/unit_tests/mcp_service/widgets/__init__.py
index eee560bcf4b..13a83393a91 100644
--- a/superset-core/src/superset_core/canvas/__init__.py
+++ b/tests/unit_tests/mcp_service/widgets/__init__.py
@@ -14,13 +14,3 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
-
-"""Dashboard canvas contracts for widget providers."""
-
-from superset_core.canvas.base import (
- CanvasLayoutRules,
- GridPlacement,
- WidgetResolver,
-)
-
-__all__ = ["CanvasLayoutRules", "GridPlacement", "WidgetResolver"]
diff --git a/superset-core/src/superset_core/canvas/__init__.py b/tests/unit_tests/mcp_service/widgets/tool/__init__.py
similarity index 77%
copy from superset-core/src/superset_core/canvas/__init__.py
copy to tests/unit_tests/mcp_service/widgets/tool/__init__.py
index eee560bcf4b..13a83393a91 100644
--- a/superset-core/src/superset_core/canvas/__init__.py
+++ b/tests/unit_tests/mcp_service/widgets/tool/__init__.py
@@ -14,13 +14,3 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
-
-"""Dashboard canvas contracts for widget providers."""
-
-from superset_core.canvas.base import (
- CanvasLayoutRules,
- GridPlacement,
- WidgetResolver,
-)
-
-__all__ = ["CanvasLayoutRules", "GridPlacement", "WidgetResolver"]
diff --git a/tests/unit_tests/mcp_service/widgets/tool/test_widget_tools.py b/tests/unit_tests/mcp_service/widgets/tool/test_widget_tools.py
new file mode 100644
index 00000000000..f267d1da161
--- /dev/null
+++ b/tests/unit_tests/mcp_service/widgets/tool/test_widget_tools.py
@@ -0,0 +1,119 @@
+# 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.
+
+"""Tests for the Dashboard V2 widget-control MCP tools."""
+
+from __future__ import annotations
+
+from unittest.mock import Mock, patch
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+from superset.mcp_service.widgets.tool.get_widget_control_schema import (
+ _get_widget_control_schema_impl,
+)
+from superset.mcp_service.widgets.tool.list_widget_types import (
+ _list_widget_types_impl,
+)
+from superset.utils import json
+
+
+def test_list_widget_types_impl() -> None:
+ ids = {b["id"] for b in _list_widget_types_impl()}
+ assert {"metric-tile", "ag-grid-table", "balloons"} <= ids
+
+
+def test_get_widget_control_schema_is_minimal() -> None:
+ result = _get_widget_control_schema_impl("balloons")
+ assert result["x-disclosure"] == "minimal"
+ assert result["properties"]["customize"]["x-collapsed"] is True
+ # Mandatory leaves are surfaced inline.
+ assert (
+ result["properties"]["dataBinding"]["properties"]["datasetId"]["type"]
+ == "integer"
+ )
+
+
+def test_get_widget_control_schema_unknown_type_error() -> None:
+ result = _get_widget_control_schema_impl("does-not-exist")
+ assert result["error"]["error_type"] == "invalid_widget_type"
+ assert "balloons" in result["valid_widget_types"]
+
+
+def test_get_widget_control_schema_drills_into_enriched_series() -> None:
+ result = _get_widget_control_schema_impl(
+ "balloons",
+ paths=["customize/series"],
+ control_values={
+ "dataBinding": {"datasetId": 1, "metrics": ["count"], "dimensions": ["g"]}
+ },
+ series=["boy", "girl"],
+ )
+ series_schema = result["subtrees"]["customize/series"]
+ assert set(series_schema["properties"]) == {"boy", "girl"}
+
+
+def test_get_widget_control_schema_expands_multiple_paths_at_once() -> None:
+ result = _get_widget_control_schema_impl(
+ "balloons", paths=["dataBinding", "customize"]
+ )
+ # Both requested branches come back in one call, keyed by path.
+ assert set(result["subtrees"]) == {"dataBinding", "customize"}
+ assert "datasetId" in result["subtrees"]["dataBinding"]["properties"]
+ assert "series" in result["subtrees"]["customize"]["properties"]
+
+
+def test_get_widget_control_schema_bad_path_error() -> None:
+ result = _get_widget_control_schema_impl("balloons", paths=["nope/nope"])
+ assert result["error"]["error_type"] == "invalid_path"
+
+
+@pytest.fixture(autouse=True)
+def mock_auth():
+ """Mock authentication for client-based tool tests."""
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ mock_user = Mock()
+ mock_user.id = 1
+ mock_user.username = "admin"
+ mock_get_user.return_value = mock_user
+ yield mock_get_user
+
+
+@pytest.mark.asyncio
+async def test_tools_registered_and_callable() -> None:
+ async with Client(mcp) as client:
+ names = {t.name for t in await client.list_tools()}
+ assert {"list_widget_types", "get_widget_control_schema"} <= names
+ # The separate field-schema tool was folded into get_widget_control_schema.
+ assert "get_widget_control_field_schema" not in names
+
+ result = await client.call_tool(
+ "get_widget_control_schema", {"widget_type": "metric-tile"}
+ )
+ data = json.loads(result.content[0].text)["control_schema"]
+ assert data["x-disclosure"] == "minimal"
+ assert "dataBinding" in data["properties"]
+
+ # Passing paths returns the requested subtrees instead of the root.
+ drilled = await client.call_tool(
+ "get_widget_control_schema",
+ {"widget_type": "balloons", "paths": ["dataBinding"]},
+ )
+ drilled_data = json.loads(drilled.content[0].text)
+ assert "datasetId" in drilled_data["subtrees"]["dataBinding"]["properties"]
diff --git a/tests/unit_tests/semantic_layers/config_test.py b/tests/unit_tests/semantic_layers/config_test.py
new file mode 100644
index 00000000000..52160fac210
--- /dev/null
+++ b/tests/unit_tests/semantic_layers/config_test.py
@@ -0,0 +1,143 @@
+# 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 __future__ import annotations
+
+from typing import ClassVar
+
+import pytest
+from pydantic import BaseModel, ConfigDict, Field
+from superset_core.semantic_layers.config import (
+ build_configuration_schema,
+ check_dependencies,
+)
+
+
+class _NoOverride(BaseModel):
+ b: int = 0
+ a: int = 0
+
+
+class _WithOverride(BaseModel):
+ field_order: ClassVar[list[str]] = ["a", "b"]
+
+ b: int = 0
+ a: int = 0
+
+
+class _WithBadOverride(BaseModel):
+ field_order: ClassVar[list[str]] = ["a", "c"]
+
+ b: int = 0
+ a: int = 0
+
+
+class _WithDuplicateOverride(BaseModel):
+ # Same set of names as its own properties, but "a" is repeated instead of
+ # naming "b" -- a set() comparison alone can't tell this apart from a
+ # valid permutation.
+ field_order: ClassVar[list[str]] = ["a", "a"]
+
+ b: int = 0
+ a: int = 0
+
+
+class _Nested(BaseModel):
+ field_order: ClassVar[list[str]] = ["y", "x"]
+
+ x: int = 0
+ y: int = 0
+
+
+class _NestedBase(BaseModel):
+ y: int
+
+
+class _NestedComposed(_NestedBase):
+ field_order: ClassVar[list[str]] = ["y", "x"]
+
+ x: int
+
+
+class _Outer(BaseModel):
+ nested: _Nested
+
+
+class _OuterComposed(BaseModel):
+ nested: _NestedComposed
+
+
+def test_no_field_order_behaves_as_today() -> None:
+ # Unchanged behavior: model-field declaration order (b, a), not alphabetical.
+ schema = build_configuration_schema(_NoOverride)
+ assert list(schema["properties"]) == ["b", "a"]
+
+
+def test_field_order_override_reorders_properties() -> None:
+ schema = build_configuration_schema(_WithOverride)
+ assert list(schema["properties"]) == ["a", "b"]
+
+
+def test_field_order_override_must_be_exact_permutation() -> None:
+ with pytest.raises(ValueError, match="field_order"):
+ build_configuration_schema(_WithBadOverride)
+
+
+def test_field_order_override_rejects_a_duplicate_with_the_right_set_of_names() -> None:
+ with pytest.raises(ValueError, match="field_order"):
+ build_configuration_schema(_WithDuplicateOverride)
+
+
+def test_field_order_applies_to_nested_defs_models() -> None:
+ # `_Nested` only ever appears inside `$defs`, never as the top-level
+ # `config_class` -- this is the DataBinding-inside-MetricTileControls shape.
+ schema = build_configuration_schema(_Outer)
+ assert list(schema["$defs"]["_Nested"]["properties"]) == ["y", "x"]
+
+
+def test_field_order_on_nested_model_with_inherited_field() -> None:
+ # `x` is inherited from `_NestedBase` (like DataBinding inheriting
+ # `metrics` from MetricControl), so Pydantic's natural field order would
+ # put `x` first; the override pins it back.
+ schema = build_configuration_schema(_OuterComposed)
+ assert list(schema["$defs"]["_NestedComposed"]["properties"]) == ["y", "x"]
+ assert schema["$defs"]["_NestedComposed"]["required"] == ["y", "x"]
+
+
+class _Aliased(BaseModel):
+ model_config = ConfigDict(populate_by_name=True)
+
+ data_binding: int = Field(alias="dataBinding")
+
+
+def test_check_dependencies_resolves_alias() -> None:
+ # x-dependsOn is written using the schema-facing alias ("dataBinding"),
+ # but Pydantic attribute access always uses the Python field name
+ # ("data_binding") -- confirmed directly that getattr(parsed, "dataBinding")
+ # misses even under populate_by_name=True.
+ configuration = _Aliased(dataBinding=1)
+ assert getattr(configuration, "dataBinding", "MISSING") == "MISSING"
+ assert check_dependencies({"x-dependsOn": ["dataBinding"]}, configuration)
+
+
+def test_check_dependencies_false_when_dependency_falsy() -> None:
+ configuration = _Aliased(dataBinding=0)
+ assert not check_dependencies({"x-dependsOn": ["dataBinding"]}, configuration)
+
+
+def test_check_dependencies_true_when_no_dependencies_declared() -> None:
+ configuration = _Aliased(dataBinding=0)
+ assert check_dependencies({}, configuration)
diff --git a/superset-core/src/superset_core/canvas/__init__.py b/tests/unit_tests/widgets/__init__.py
similarity index 77%
copy from superset-core/src/superset_core/canvas/__init__.py
copy to tests/unit_tests/widgets/__init__.py
index eee560bcf4b..13a83393a91 100644
--- a/superset-core/src/superset_core/canvas/__init__.py
+++ b/tests/unit_tests/widgets/__init__.py
@@ -14,13 +14,3 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
-
-"""Dashboard canvas contracts for widget providers."""
-
-from superset_core.canvas.base import (
- CanvasLayoutRules,
- GridPlacement,
- WidgetResolver,
-)
-
-__all__ = ["CanvasLayoutRules", "GridPlacement", "WidgetResolver"]
diff --git a/superset-core/src/superset_core/canvas/__init__.py b/tests/unit_tests/widgets/conftest.py
similarity index 55%
copy from superset-core/src/superset_core/canvas/__init__.py
copy to tests/unit_tests/widgets/conftest.py
index eee560bcf4b..21c221bc17c 100644
--- a/superset-core/src/superset_core/canvas/__init__.py
+++ b/tests/unit_tests/widgets/conftest.py
@@ -14,13 +14,17 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
+"""
+Make the ``@widget`` decorator concrete and register the built-in widgets
+before any test module in this directory is collected.
-"""Dashboard canvas contracts for widget providers."""
+These are lightweight unit tests that don't spin up the full app fixture, but
+``superset.widgets.builtin`` applies ``@widget`` at import time — which is a
+stub until ``inject_widget_implementations`` runs. conftest.py is imported
+before its sibling test modules, so injecting here guarantees the decorator is
+live (and the registry populated) first. The injection needs no app context.
+"""
-from superset_core.canvas.base import (
- CanvasLayoutRules,
- GridPlacement,
- WidgetResolver,
-)
+from superset.core.api.core_api_injection import inject_widget_implementations
-__all__ = ["CanvasLayoutRules", "GridPlacement", "WidgetResolver"]
+inject_widget_implementations()
diff --git a/tests/unit_tests/widgets/test_api.py b/tests/unit_tests/widgets/test_api.py
new file mode 100644
index 00000000000..a60a9b65cc8
--- /dev/null
+++ b/tests/unit_tests/widgets/test_api.py
@@ -0,0 +1,103 @@
+# 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.
+
+"""Tests for ``WidgetControlsRestApi.validate`` — the REST boundary the
+Dashboard V2 Inspector's form and JSON editor commit through (see
+``controlValueValidation.ts``). It is a thin wrapper over
+``Widget.validate_control_values``, the same call ``set_widget_control_values``
+(the MCP write tool) commits through, so a frontend edit and an MCP edit are
+validated by one implementation reached by two routes, not two rule sets that
+can drift apart."""
+
+from __future__ import annotations
+
+import pytest
+
+from superset.utils import json
+
+# The widget API is registered with the canvas, behind its feature flag.
+pytestmark = pytest.mark.parametrize(
+ "app", [{"FEATURE_FLAGS": {"CANVAS": True}}], indirect=True
+)
+
+
+def test_validate_accepts_valid_control_values(client, full_api_access) -> None:
+ resp = client.post(
+ "/api/v1/widgets/type/metric-tile/validate",
+ data=json.dumps(
+ {
+ "control_values": {
+ "dataBinding": {"datasetId": 1, "metrics": ["count"]},
+ }
+ }
+ ),
+ content_type="application/json",
+ )
+
+ assert resp.status_code == 200
+ assert resp.get_json()["result"]["errors"] == []
+
+
+def test_validate_rejects_invalid_control_values(client, full_api_access) -> None:
+ resp = client.post(
+ "/api/v1/widgets/type/metric-tile/validate",
+ data=json.dumps({"control_values": {"decimals": "not-an-int"}}),
+ content_type="application/json",
+ )
+
+ assert resp.status_code == 200
+ errors = resp.get_json()["result"]["errors"]
+ locs = [tuple(error["loc"]) for error in errors]
+ # Missing the required dataBinding, and decimals is the wrong type: both
+ # of the widget's own model rules, not something this route invents.
+ assert ("dataBinding",) in locs
+ assert ("decimals",) in locs
+
+
+def test_validate_unknown_widget_type_is_404(client, full_api_access) -> None:
+ resp = client.post(
+ "/api/v1/widgets/type/not-a-real-widget-type/validate",
+ data=json.dumps({"control_values": {}}),
+ content_type="application/json",
+ )
+
+ assert resp.status_code == 404
+
+
+def test_validate_is_a_thin_wrapper_over_the_widget_s_own_validator(
+ client, full_api_access
+) -> None:
+ """The route's result must equal calling the widget's
+ ``validate_control_values`` directly with the same input — proving the
+ REST boundary and the MCP ``set_widget_control_values`` tool (which also
+ calls ``validate_control_values`` directly) run through the identical
+ implementation rather than parallel copies of it."""
+ from superset.widgets.registry import registry
+
+ control_values = {"decimals": "not-an-int"}
+ widget = registry.get("metric-tile")
+ assert widget is not None
+ direct_errors = widget.validate_control_values(control_values)
+
+ resp = client.post(
+ "/api/v1/widgets/type/metric-tile/validate",
+ data=json.dumps({"control_values": control_values}),
+ content_type="application/json",
+ )
+
+ assert resp.status_code == 200
+ assert resp.get_json()["result"]["errors"] == direct_errors
diff --git a/tests/unit_tests/widgets/test_builtin.py b/tests/unit_tests/widgets/test_builtin.py
new file mode 100644
index 00000000000..5faaefa3229
--- /dev/null
+++ b/tests/unit_tests/widgets/test_builtin.py
@@ -0,0 +1,202 @@
+# 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 __future__ import annotations
+
+from unittest.mock import MagicMock
+
+from superset.exceptions import SupersetSecurityException
+from superset.widgets.builtin import Balloons, FilterSelect
+from superset.widgets.registry import registry
+
+
+def _series_properties(control_values, series):
+ widget = registry.get("balloons")
+ assert widget is not None
+ schema = widget.get_control_schema(control_values, series)
+ return schema["$defs"]["Customization"]["properties"]["series"]
+
+
+def test_series_stays_open_ended_without_dimension_or_series() -> None:
+ # No control values at all.
+ series = _series_properties(None, None)
+ assert "properties" not in series
+ # A grouping dimension but no discovered series values yet.
+ series = _series_properties(
+ {
+ "dataBinding": {
+ "datasetId": 1,
+ "metrics": ["count"],
+ "dimensions": ["gender"],
+ }
+ },
+ [],
+ )
+ assert "properties" not in series
+ # Discovered values but no grouping dimension chosen.
+ series = _series_properties(
+ {"dataBinding": {"datasetId": 1, "metrics": ["count"]}},
+ ["boy", "girl"],
+ )
+ assert "properties" not in series
+
+
+def test_series_populated_per_value_with_palette_colors() -> None:
+ series = _series_properties(
+ {
+ "dataBinding": {
+ "datasetId": 1,
+ "metrics": ["count"],
+ "dimensions": ["gender"],
+ }
+ },
+ ["boy", "girl", "other"],
+ )
+ assert set(series["properties"]) == {"boy", "girl", "other"}
+ # Open-ended map is replaced by concrete per-series styles.
+ assert "additionalProperties" not in series
+ # Each entry is titled by its series value and pre-colored from the palette.
+ assert series["properties"]["boy"]["title"] == "boy"
+ assert (
+ series["properties"]["boy"]["properties"]["color"]["default"]
+ == Balloons.PALETTE[0]
+ )
+ assert (
+ series["properties"]["girl"]["properties"]["color"]["default"]
+ == Balloons.PALETTE[1]
+ )
+
+
+def test_series_deduped_and_capped() -> None:
+ binding = {
+ "dataBinding": {
+ "datasetId": 1,
+ "metrics": ["count"],
+ "dimensions": ["gender"],
+ }
+ }
+ # Duplicates collapse (order preserved), so no repeated per-series work.
+ series = _series_properties(binding, ["boy", "girl", "boy", "girl"])
+ assert list(series["properties"]) == ["boy", "girl"]
+
+ # An oversized list is capped, bounding schema construction / response size.
+ many = [f"s{i}" for i in range(Balloons.MAX_SERIES + 50)]
+ series = _series_properties(binding, many)
+ assert len(series["properties"]) == Balloons.MAX_SERIES
+
+
+def test_filter_select_registered() -> None:
+ assert registry.get("filter.select") is FilterSelect
+
+
+def test_filter_bar_registered() -> None:
+ assert registry.get("filter.bar") is not None
+
+
+def test_filter_select_dataset_enum_lists_viewable_datasets(mocker) -> None:
+ # `name` is a reserved `MagicMock.__init__` kwarg (it names the mock
+ # itself for repr), so it has to be set as an attribute afterward rather
+ # than passed in the constructor like `id`.
+ dataset_a = MagicMock(id=1)
+ dataset_a.name = "main.sales"
+ dataset_b = MagicMock(id=2)
+ dataset_b.name = "birth_names"
+ mocker.patch(
+ "superset.widgets.builtin.DatasetDAO.find_all",
+ return_value=[dataset_a, dataset_b],
+ )
+
+ widget = registry.get("filter.select")
+ assert widget is not None
+ schema = widget.get_control_schema(None, None)
+
+ assert schema["properties"]["datasetId"]["enum"] == [1, 2]
+ assert schema["properties"]["datasetId"]["x-enumNames"] == [
+ "main.sales",
+ "birth_names",
+ ]
+
+
+def test_filter_select_dataset_enum_blank_without_viewable_datasets(mocker) -> None:
+ mocker.patch("superset.widgets.builtin.DatasetDAO.find_all", return_value=[])
+
+ widget = registry.get("filter.select")
+ assert widget is not None
+ schema = widget.get_control_schema(None, None)
+
+ assert schema["properties"]["datasetId"]["enum"] == []
+ assert schema["properties"]["datasetId"]["x-enumNames"] == []
+
+
+def test_filter_select_column_enum_blank_without_dataset(mocker) -> None:
+ mocker.patch("superset.widgets.builtin.DatasetDAO.find_all", return_value=[])
+
+ widget = registry.get("filter.select")
+ assert widget is not None
+ schema = widget.get_control_schema(None, None)
+ assert schema["properties"]["column"]["enum"] == []
+
+
+def test_filter_select_column_enum_populated_from_dataset(mocker) -> None:
+ mocker.patch("superset.widgets.builtin.DatasetDAO.find_all", return_value=[])
+ dataset = MagicMock()
+ dataset.filterable_column_names = ["region", "product_line"]
+ mocker.patch("superset.widgets.builtin.DatasetDAO.find_by_id", return_value=dataset)
+
+ widget = registry.get("filter.select")
+ assert widget is not None
+ # A column already chosen (unlike the payload-shape test above, which
+ # covers the moment right after picking a dataset, before `column` has
+ # ever been set) — re-fetching still enriches the enum, and still
+ # includes the already-selected value alongside the rest.
+ schema = widget.get_control_schema({"datasetId": 1, "column": "region"}, None)
+
+ assert schema["properties"]["column"]["enum"] == ["region", "product_line"]
+ dataset.raise_for_access.assert_called_once()
+
+
+def test_filter_select_column_enum_populated_before_column_is_ever_set(
+ mocker,
+) -> None:
+ # The control panel only posts `column` once the author has actually
+ # touched it — right after picking a dataset, `control_values` is just
+ # `{"datasetId": 1}`, with no `column` key at all (unlike the fixture
+ # above, which always includes one). `column` needs a default for this
+ # payload to validate at all; without one, `parsed` silently falls back
+ # to `None` and `dataset_id` below is never seen.
+ mocker.patch("superset.widgets.builtin.DatasetDAO.find_all", return_value=[])
+ dataset = MagicMock()
+ dataset.filterable_column_names = ["region", "product_line"]
+ mocker.patch("superset.widgets.builtin.DatasetDAO.find_by_id", return_value=dataset)
+
+ widget = registry.get("filter.select")
+ assert widget is not None
+ schema = widget.get_control_schema({"datasetId": 1}, None)
+
+ assert schema["properties"]["column"]["enum"] == ["region", "product_line"]
+
+
+def test_filter_select_column_enum_blank_on_access_denied(mocker) -> None:
+ mocker.patch("superset.widgets.builtin.DatasetDAO.find_all", return_value=[])
+ dataset = MagicMock()
+ dataset.raise_for_access.side_effect = SupersetSecurityException(mocker.MagicMock())
+ mocker.patch("superset.widgets.builtin.DatasetDAO.find_by_id", return_value=dataset)
+
+ widget = registry.get("filter.select")
+ assert widget is not None
+ schema = widget.get_control_schema({"datasetId": 1}, None)
+
+ assert schema["properties"]["column"]["enum"] == []
diff --git a/tests/unit_tests/widgets/test_composites.py b/tests/unit_tests/widgets/test_composites.py
new file mode 100644
index 00000000000..3d0674d39ef
--- /dev/null
+++ b/tests/unit_tests/widgets/test_composites.py
@@ -0,0 +1,75 @@
+# 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 __future__ import annotations
+
+from types import MappingProxyType
+
+import pytest
+from pydantic import BaseModel
+from superset_core.widgets import (
+ composite_control,
+ list_composite_controls,
+ MetricControl,
+)
+from superset_core.widgets.composites import _registry
+
+
+def test_metric_control_is_registered() -> None:
+ info = list_composite_controls()["metric"]
+ assert info.name == "metric"
+ assert info.model is MetricControl
+
+
+def test_metric_control_declares_metric_multi_field() -> None:
+ schema = MetricControl.model_json_schema()
+ assert schema["properties"]["metrics"]["x-control"] == "metric-multi"
+
+
+def test_list_composite_controls_is_read_only() -> None:
+ result = list_composite_controls()
+ assert isinstance(result, MappingProxyType)
+ with pytest.raises(TypeError):
+ result["metric"] = None # type: ignore[index]
+
+
+def test_composite_control_registers_new_entry() -> None:
+ @composite_control(name="test-only", title="Test Only", description="...")
+ class _TestOnly(BaseModel):
+ value: int = 0
+
+ try:
+ info = list_composite_controls()["test-only"]
+ assert info.title == "Test Only"
+ assert info.model is _TestOnly
+ finally:
+ _registry.pop("test-only", None)
+
+
+def test_composite_control_duplicate_name_raises() -> None:
+ @composite_control(name="dup-test", title="Dup", description="...")
+ class _First(BaseModel):
+ value: int = 0
+
+ try:
+ with pytest.raises(ValueError, match="already registered"):
+
+ @composite_control(name="dup-test", title="Dup2", description="...")
+ class _Second(BaseModel):
+ value: int = 0
+
+ finally:
+ _registry.pop("dup-test", None)
diff --git a/tests/unit_tests/widgets/test_contract.py b/tests/unit_tests/widgets/test_contract.py
new file mode 100644
index 00000000000..9d574f2231c
--- /dev/null
+++ b/tests/unit_tests/widgets/test_contract.py
@@ -0,0 +1,100 @@
+# 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.
+
+"""Tests for the widget contract beyond schemas: behavior, versions, migrators."""
+
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+from pydantic import BaseModel
+from superset_core.widgets import PropsVersionError, Widget, WidgetBehavior
+
+from superset.widgets.registry import registry
+
+
+class _Props(BaseModel):
+ title: str = ""
+
+
+def _widget(**attrs: Any) -> type[Widget]:
+ return type(
+ "TestWidget",
+ (Widget,),
+ {"widget_type": "test", "name": "Test", "controls_class": _Props, **attrs},
+ )
+
+
+def test_built_in_widgets_declare_a_consistent_contract() -> None:
+ for widget in registry.values():
+ widget.validate_contract()
+ assert registry["tabs"].behavior.accepted_children == frozenset({"tab"})
+ assert registry["filter.bar"].behavior.bounds_filter_scope is False
+ assert registry["filter.select"].behavior.filter is True
+ assert registry["echarts"].behavior.emits_filters is True
+
+
+@pytest.mark.parametrize(
+ "behavior, message",
+ [
+ (WidgetBehavior(grid_columns=12), "only a container"),
+ (WidgetBehavior(accepted_children=frozenset({"tab"})), "only a container"),
+ (
+ WidgetBehavior(container=True, grid_columns=12, child_layout_model=_Props),
+ "not both",
+ ),
+ ],
+)
+def test_inconsistent_behavior_is_rejected(
+ behavior: WidgetBehavior, message: str
+) -> None:
+ with pytest.raises(ValueError, match=message):
+ _widget(behavior=behavior).validate_contract()
+
+
+def test_a_broken_migrator_chain_is_rejected() -> None:
+ with pytest.raises(ValueError, match=r"no migrators from schema versions \[2\]"):
+ _widget(schema_version=3, migrators={1: dict}).validate_contract()
+
+
+def test_props_are_upgraded_through_the_chain() -> None:
+ widget = _widget(
+ schema_version=3,
+ migrators={
+ 1: lambda props: {"name": props["label"]},
+ 2: lambda props: {"title": props["name"]},
+ },
+ )
+
+ assert widget.upgrade_props({"label": "Revenue"}, 1) == {"title": "Revenue"}
+ assert widget.upgrade_props({"title": "Revenue"}, 3) == {"title": "Revenue"}
+
+
+@pytest.mark.parametrize("version", [0, 4])
+def test_props_from_unknown_versions_are_refused(version: int) -> None:
+ with pytest.raises(PropsVersionError):
+ _widget(schema_version=3, migrators={1: dict, 2: dict}).upgrade_props(
+ {}, version
+ )
+
+
+def test_a_failing_migrator_is_reported_as_a_version_error() -> None:
+ widget = _widget(schema_version=2, migrators={1: lambda props: props["gone"]})
+
+ with pytest.raises(PropsVersionError, match="failed"):
+ widget.upgrade_props({}, 1)
diff --git a/tests/unit_tests/widgets/test_echarts_structured_chrome.py b/tests/unit_tests/widgets/test_echarts_structured_chrome.py
new file mode 100644
index 00000000000..a38534df71d
--- /dev/null
+++ b/tests/unit_tests/widgets/test_echarts_structured_chrome.py
@@ -0,0 +1,166 @@
+# 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.
+"""Tests for the `echarts` widget's `chrome` structured layer — title,
+legend, tooltip, and axis labels, each independently optional and applying
+regardless of `chartType` (see `EchartsChrome` in `superset/widgets/controls.py`).
+`EchartsChrome`'s fields are deliberately flat (`titleText`, `legendShow`,
+`xAxisName`, ...) rather than nested into `title`/`legend`/`xAxis` sub-objects
+— JsonForms only renders one level of nested-object properties, so a
+two-level-deep `chrome.title.text` would render as an empty group with
+nothing inside it (verified against the actual control panel). The actual
+merge onto `echartsOptions` happens on the frontend
+(`echartsStructuredChrome.ts`); this covers the backend's half — the schema
+shape served, and strict validation of the control values it accepts."""
+
+from __future__ import annotations
+
+from superset.utils import json
+from superset.widgets.registry import registry
+
+
+def _echarts_schema(control_values=None):
+ widget = registry.get("echarts")
+ assert widget is not None
+ return widget.get_control_schema(control_values, None)
+
+
+def test_chrome_schema_is_flat_one_level_under_chrome() -> None:
+ schema = _echarts_schema()
+ chrome_ref = schema["properties"]["chrome"]
+ assert chrome_ref["$ref"] == "#/$defs/EchartsChrome"
+ chrome_def = schema["$defs"]["EchartsChrome"]
+ assert set(chrome_def["properties"]) == {
+ "titleText",
+ "legendShow",
+ "legendPosition",
+ "tooltipTrigger",
+ "xAxisName",
+ "xAxisRotate",
+ "xAxisFormat",
+ "yAxisName",
+ "yAxisRotate",
+ "yAxisFormat",
+ }
+ # Flat, not nested: every field's own schema is a plain scalar (string,
+ # boolean, integer, or a nullable enum) — never another $ref to a
+ # sub-object.
+ for name, prop in chrome_def["properties"].items():
+ assert "$ref" not in prop, f"{name} must not be a nested object"
+
+
+def test_legend_position_and_tooltip_trigger_offer_their_option_lists() -> None:
+ schema = _echarts_schema()
+ chrome_def = schema["$defs"]["EchartsChrome"]
+ assert chrome_def["properties"]["legendPosition"]["x-options"] == [
+ "top",
+ "bottom",
+ "left",
+ "right",
+ ]
+ assert chrome_def["properties"]["tooltipTrigger"]["x-options"] == [
+ "item",
+ "axis",
+ ]
+
+
+def test_axis_rotate_is_bounded() -> None:
+ schema = _echarts_schema()
+ chrome_def = schema["$defs"]["EchartsChrome"]
+ assert chrome_def["properties"]["xAxisRotate"]["minimum"] == -90
+ assert chrome_def["properties"]["xAxisRotate"]["maximum"] == 90
+ assert chrome_def["properties"]["yAxisRotate"]["minimum"] == -90
+ assert chrome_def["properties"]["yAxisRotate"]["maximum"] == 90
+
+
+def test_valid_chrome_control_values_pass_strict_validation() -> None:
+ widget = registry.get("echarts")
+ assert widget is not None
+ errors = widget.validate_control_values(
+ {
+ "dataBinding": {"datasetId": 1, "metrics": ["count"]},
+ "chrome": {
+ "titleText": "Sales",
+ "legendShow": False,
+ "legendPosition": "right",
+ "tooltipTrigger": "axis",
+ "xAxisName": "Product",
+ "xAxisRotate": 45,
+ "xAxisFormat": "{value} kg",
+ "yAxisName": "Sales",
+ },
+ }
+ )
+ assert errors == []
+
+
+def test_out_of_range_rotate_is_rejected() -> None:
+ widget = registry.get("echarts")
+ assert widget is not None
+ errors = widget.validate_control_values(
+ {
+ "dataBinding": {"datasetId": 1, "metrics": ["count"]},
+ "chrome": {"xAxisRotate": 200},
+ }
+ )
+ locs = [tuple(error["loc"]) for error in errors]
+ assert ("chrome", "xAxisRotate") in locs
+
+
+def test_invalid_legend_position_is_rejected() -> None:
+ widget = registry.get("echarts")
+ assert widget is not None
+ errors = widget.validate_control_values(
+ {
+ "dataBinding": {"datasetId": 1, "metrics": ["count"]},
+ "chrome": {"legendPosition": "center"},
+ }
+ )
+ assert errors != []
+
+
+def test_a_widget_without_any_chrome_still_validates_cleanly() -> None:
+ # Pre-existing raw-only widgets carry no `chrome` at all.
+ widget = registry.get("echarts")
+ assert widget is not None
+ errors = widget.validate_control_values(
+ {"dataBinding": {"datasetId": 1, "metrics": ["count"]}}
+ )
+ assert errors == []
+
+
+def test_control_schema_rest_endpoint_exposes_chrome(client, full_api_access) -> None:
+ resp = client.get("/api/v1/widgets/type/echarts/control-schema")
+ assert resp.status_code == 200
+ schema = resp.get_json()["result"]
+ assert "chrome" in schema["properties"]
+
+
+def test_validate_rest_endpoint_accepts_chrome_fields(client, full_api_access) -> None:
+ resp = client.post(
+ "/api/v1/widgets/type/echarts/validate",
+ data=json.dumps(
+ {
+ "control_values": {
+ "dataBinding": {"datasetId": 1, "metrics": ["count"]},
+ "chrome": {"titleText": "Sales", "legendShow": False},
+ }
+ }
+ ),
+ content_type="application/json",
+ )
+ assert resp.status_code == 200
+ assert resp.get_json()["result"]["errors"] == []
diff --git a/tests/unit_tests/widgets/test_echarts_structured_series.py b/tests/unit_tests/widgets/test_echarts_structured_series.py
new file mode 100644
index 00000000000..31323dd2e56
--- /dev/null
+++ b/tests/unit_tests/widgets/test_echarts_structured_series.py
@@ -0,0 +1,209 @@
+# 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.
+"""Tests for the `echarts` widget's structured chartType/customize layer —
+the backend half of the merge described in `EchartsControls`'s docstring:
+`chartType` unset keeps `echartsOptions` fully authoritative; set, it drives
+one series per `dataBinding` metric, styled by `customize.series` (matched by
+stable metric label, not position)."""
+
+from __future__ import annotations
+
+from superset.utils import json
+from superset.widgets.builtin import _metric_key, Echarts
+from superset.widgets.registry import registry
+
+
+def _series_properties(control_values, series=None):
+ widget = registry.get("echarts")
+ assert widget is not None
+ schema = widget.get_control_schema(control_values, series)
+ return schema["$defs"]["EchartsCustomization"]["properties"]["series"]
+
+
+def test_metric_key_matches_frontend_getmetriclabel_precedence() -> None:
+ # A saved-metric string is used verbatim.
+ assert _metric_key("count") == "count"
+ # An explicit label always wins.
+ assert _metric_key({"label": "Total sales"}) == "Total sales"
+ # A SIMPLE ad-hoc metric without a label: aggregate(column).
+ assert (
+ _metric_key(
+ {
+ "expressionType": "SIMPLE",
+ "aggregate": "SUM",
+ "column": {"column_name": "sales"},
+ }
+ )
+ == "SUM(sales)"
+ )
+ # A SQL ad-hoc metric without a label: the raw expression.
+ assert (
+ _metric_key({"expressionType": "SQL", "sqlExpression": "COUNT(*)"})
+ == "COUNT(*)"
+ )
+
+
+def test_chart_type_unset_leaves_series_schema_unenriched() -> None:
+ # The x-dependsOn: ["dataBinding", "chartType"] gate blocks enrichment
+ # entirely when chartType is unset — schema stays the open-ended map.
+ series = _series_properties({"dataBinding": {"datasetId": 1, "metrics": ["count"]}})
+ assert "properties" not in series
+ assert series.get("additionalProperties")
+
+
+def test_chart_type_set_populates_one_entry_per_metric() -> None:
+ series = _series_properties(
+ {
+ "dataBinding": {"datasetId": 1, "metrics": ["count", "sum__sales"]},
+ "chartType": "bar",
+ }
+ )
+ assert set(series["properties"]) == {"count", "sum__sales"}
+ assert "additionalProperties" not in series
+ assert series["properties"]["count"]["title"] == "count"
+ assert (
+ series["properties"]["count"]["properties"]["color"]["default"]
+ == Echarts.PALETTE[0]
+ )
+ assert (
+ series["properties"]["sum__sales"]["properties"]["color"]["default"]
+ == Echarts.PALETTE[1]
+ )
+
+
+def test_ad_hoc_metric_keyed_by_its_stable_label() -> None:
+ series = _series_properties(
+ {
+ "dataBinding": {
+ "datasetId": 1,
+ "metrics": [
+ {
+ "expressionType": "SIMPLE",
+ "aggregate": "AVG",
+ "column": {"column_name": "price"},
+ }
+ ],
+ },
+ "chartType": "line",
+ }
+ )
+ assert set(series["properties"]) == {"AVG(price)"}
+
+
+def test_stored_override_for_a_removed_metric_stays_visible() -> None:
+ # A metric that was customized, then dropped from dataBinding.metrics,
+ # must not silently lose its stored override from the schema's known keys.
+ series = _series_properties(
+ {
+ "dataBinding": {"datasetId": 1, "metrics": ["count"]},
+ "chartType": "bar",
+ "customize": {
+ "series": {"sum__sales": {"color": "#123456", "visible": False}}
+ },
+ }
+ )
+ assert set(series["properties"]) == {"count", "sum__sales"}
+
+
+def test_series_deduped_and_capped() -> None:
+ many_metrics = [f"m{i}" for i in range(Echarts.MAX_SERIES + 50)]
+ series = _series_properties(
+ {
+ "dataBinding": {"datasetId": 1, "metrics": many_metrics},
+ "chartType": "bar",
+ }
+ )
+ assert len(series["properties"]) == Echarts.MAX_SERIES
+
+
+def test_valid_control_values_pass_strict_validation() -> None:
+ widget = registry.get("echarts")
+ assert widget is not None
+ errors = widget.validate_control_values(
+ {
+ "dataBinding": {"datasetId": 1, "metrics": ["count"]},
+ "chartType": "bar",
+ "customize": {
+ "series": {
+ "count": {
+ "color": "#e74c3c",
+ "visible": True,
+ "displayName": "Total",
+ }
+ }
+ },
+ }
+ )
+ assert errors == []
+
+
+def test_raw_only_widget_without_chart_type_still_validates() -> None:
+ # Pre-existing raw-only echarts widgets carry neither chartType nor
+ # customize — must still validate cleanly (no forced migration).
+ widget = registry.get("echarts")
+ assert widget is not None
+ errors = widget.validate_control_values(
+ {
+ "dataBinding": {"datasetId": 1, "metrics": ["count"]},
+ "echartsOptions": {"series": [{"type": "pie", "data": []}]},
+ }
+ )
+ assert errors == []
+
+
+def test_control_schema_rest_endpoint_exposes_chart_type_and_customize(
+ client, full_api_access
+) -> None:
+ # The same schema a browser Inspector renders is what a headless/MCP
+ # caller sees through this REST route — no separate structured-field
+ # surface for the two.
+ resp = client.get("/api/v1/widgets/type/echarts/control-schema")
+ assert resp.status_code == 200
+ schema = resp.get_json()["result"]
+ assert "chartType" in schema["properties"]
+ assert schema["properties"]["chartType"]["x-options"] == [
+ "bar",
+ "line",
+ "scatter",
+ ]
+ assert "customize" in schema["properties"]
+ # Redundant with the Inspector's JSON tab (which edits the whole props
+ # record as text) — flagged so the Form tab doesn't also render its own
+ # raw-JSON box for it.
+ assert schema["properties"]["echartsOptions"]["x-hidden-in-form"] is True
+
+
+def test_validate_rest_endpoint_accepts_structured_fields(
+ client, full_api_access
+) -> None:
+ resp = client.post(
+ "/api/v1/widgets/type/echarts/validate",
+ data=json.dumps(
+ {
+ "control_values": {
+ "dataBinding": {"datasetId": 1, "metrics": ["count"]},
+ "chartType": "line",
+ "customize": {
+ "series": {"count": {"color": "#3498db", "visible": True}}
+ },
+ }
+ }
+ ),
+ content_type="application/json",
+ )
+ assert resp.status_code == 200
+ assert resp.get_json()["result"]["errors"] == []
diff --git a/tests/unit_tests/widgets/test_enrichment.py b/tests/unit_tests/widgets/test_enrichment.py
new file mode 100644
index 00000000000..65d42a2366e
--- /dev/null
+++ b/tests/unit_tests/widgets/test_enrichment.py
@@ -0,0 +1,165 @@
+# 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 __future__ import annotations
+
+from typing import Any
+
+import pytest
+from pydantic import BaseModel
+from superset_core.widgets.enrichment import (
+ build_dependency_graph,
+ dynamic_field_paths,
+ run_enrichers,
+ toposort_or_raise,
+)
+
+
+def test_dynamic_field_paths_finds_top_level_field() -> None:
+ schema = {
+ "properties": {
+ "a": {"x-dynamic": True, "x-dependsOn": ["b"]},
+ "b": {"type": "string"},
+ }
+ }
+ fields = dynamic_field_paths(schema)
+ assert list(fields) == ["a"]
+ assert fields["a"] is schema["properties"]["a"]
+
+
+def test_dynamic_field_paths_finds_nested_defs_field() -> None:
+ # Mirrors Customization.series's actual shape: the dynamic field lives
+ # inside a $defs entry referenced by $ref, not directly in properties.
+ schema = {
+ "properties": {"customize": {"$ref": "#/$defs/Customization"}},
+ "$defs": {
+ "Customization": {
+ "properties": {
+ "series": {"x-dynamic": True, "x-dependsOn": ["dataBinding"]}
+ }
+ }
+ },
+ }
+ fields = dynamic_field_paths(schema)
+ assert list(fields) == ["customize/series"]
+
+
+def test_build_dependency_graph_edge_vs_gate() -> None:
+ fields: dict[str, dict[str, Any]] = {
+ "a": {"x-dynamic": True, "x-dependsOn": ["b", "staticField"]},
+ "b": {"x-dynamic": True},
+ }
+ graph = build_dependency_graph(fields)
+ # "b" is a known dynamic path -> ordering edge; "staticField" isn't -> not an edge.
+ assert graph == {"a": ["b"], "b": []}
+
+
+def test_toposort_orders_a_chain() -> None:
+ graph = {"c": ["b"], "b": ["a"], "a": []}
+ order = toposort_or_raise(graph, "test-widget")
+ assert order.index("a") < order.index("b") < order.index("c")
+
+
+def test_toposort_raises_on_cycle() -> None:
+ graph = {"a": ["b"], "b": ["a"]}
+ with pytest.raises(ValueError, match="a") as exc_info:
+ toposort_or_raise(graph, "test-widget")
+ assert "b" in str(exc_info.value)
+
+
+def test_run_enrichers_threads_upstream_result_forward() -> None:
+ schema: dict[str, Any] = {
+ "properties": {
+ "a": {"x-dynamic": True},
+ "b": {"x-dynamic": True, "x-dependsOn": ["a"]},
+ }
+ }
+ fields = dynamic_field_paths(schema)
+ order = toposort_or_raise(build_dependency_graph(fields), "test-widget")
+
+ seen_upstream = {}
+
+ def enrich_a(schema_arg, node, parsed, series, upstream):
+ node["computed"] = "from-a"
+ return "a-result"
+
+ def enrich_b(schema_arg, node, parsed, series, upstream):
+ seen_upstream.update(upstream)
+
+ run_enrichers(schema, fields, order, {"a": enrich_a, "b": enrich_b}, None, [])
+
+ assert schema["properties"]["a"]["computed"] == "from-a"
+ assert seen_upstream == {"a": "a-result"}
+
+
+def test_run_enrichers_skips_ungated_field_without_error() -> None:
+ # No enricher registered for a discovered dynamic field: no-op, not an error.
+ schema = {"properties": {"a": {"x-dynamic": True}}}
+ fields = dynamic_field_paths(schema)
+ order = toposort_or_raise(build_dependency_graph(fields), "test-widget")
+ run_enrichers(schema, fields, order, {}, None, []) # must not raise
+
+
+def test_run_enrichers_does_not_gate_on_its_own_ordering_edge() -> None:
+ # "b" depends on "a" only as an ordering edge (both are x-dynamic paths in
+ # `fields`) — that's enforced by `order`, not a plain-attribute gate. A
+ # naive check_dependencies(node, parsed) call would look up an attribute
+ # literally named "a", find nothing, and skip "b" forever.
+ schema: dict[str, Any] = {
+ "properties": {
+ "a": {"x-dynamic": True},
+ "b": {"x-dynamic": True, "x-dependsOn": ["a"]},
+ }
+ }
+ fields = dynamic_field_paths(schema)
+ order = toposort_or_raise(build_dependency_graph(fields), "test-widget")
+
+ class Config(BaseModel):
+ pass
+
+ ran = []
+
+ def enrich_a(schema_arg, node, parsed, series, upstream):
+ ran.append("a")
+
+ def enrich_b(schema_arg, node, parsed, series, upstream):
+ ran.append("b")
+
+ run_enrichers(schema, fields, order, {"a": enrich_a, "b": enrich_b}, Config(), [])
+
+ assert ran == ["a", "b"]
+
+
+def test_run_enrichers_still_gates_on_a_plain_non_edge_dependency() -> None:
+ schema: dict[str, Any] = {
+ "properties": {"a": {"x-dynamic": True, "x-dependsOn": ["flag"]}}
+ }
+ fields = dynamic_field_paths(schema)
+ order = toposort_or_raise(build_dependency_graph(fields), "test-widget")
+
+ class Config(BaseModel):
+ flag: bool = False
+
+ ran = []
+
+ def enrich_a(schema_arg, node, parsed, series, upstream):
+ ran.append("a")
+
+ run_enrichers(schema, fields, order, {"a": enrich_a}, Config(flag=False), [])
+ assert ran == []
+
+ run_enrichers(schema, fields, order, {"a": enrich_a}, Config(flag=True), [])
+ assert ran == ["a"]
diff --git a/tests/unit_tests/widgets/test_registry.py b/tests/unit_tests/widgets/test_registry.py
new file mode 100644
index 00000000000..fe3406c5bec
--- /dev/null
+++ b/tests/unit_tests/widgets/test_registry.py
@@ -0,0 +1,302 @@
+# 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 __future__ import annotations
+
+from typing import Any
+
+import pytest
+from pydantic import BaseModel, Field
+from superset_core.widgets import Widget, widget
+
+from superset.widgets.controls import BalloonsControls
+from superset.widgets.registry import registry
+
+
+def _block(widget_type: str) -> type[Widget]:
+ widget_cls = registry.get(widget_type)
+ assert widget_cls is not None
+ return widget_cls
+
+
+def test_registry_lists_built_in_widget_types() -> None:
+ ids = {cls.widget_type for cls in registry.values()}
+ assert {"metric-tile", "ag-grid-table", "balloons"} <= ids
+
+
+def test_core_contract_is_importable() -> None:
+ # Extensions register widgets via exactly these two public symbols.
+ assert Widget is not None
+ assert callable(widget)
+
+
+def test_duplicate_widget_type_raises_naming_both() -> None:
+ @widget(widget_type="dup-test-widget", name="First")
+ class First(Widget):
+ controls_class = BalloonsControls
+
+ try:
+ with pytest.raises(ValueError, match="already registered"):
+
+ @widget(widget_type="dup-test-widget", name="Second")
+ class Second(Widget):
+ controls_class = BalloonsControls
+ finally:
+ registry.pop("dup-test-widget", None)
+
+
+def test_get_control_schema_base_shape() -> None:
+ schema = _block("balloons").get_control_schema(None, None)
+ # Field order preserved (dataBinding before customize), $defs present.
+ assert list(schema["properties"]) == [
+ "dataBinding",
+ "colorDimension",
+ "customize",
+ ]
+ assert schema["required"] == ["dataBinding"]
+ assert {"DataBinding", "Customization", "SeriesStyle"} <= set(schema["$defs"])
+
+
+def test_get_control_schema_tolerates_invalid_values() -> None:
+ # Partial / malformed control values during editing must not raise; the base
+ # schema is returned instead.
+ schema = _block("balloons").get_control_schema(
+ {"dataBinding": "not-an-object"}, None
+ )
+ assert "properties" in schema
+
+
+def test_get_control_schema_accepts_camel_case_props() -> None:
+ # node.props uses camelCase aliases; validation must accept them.
+ schema = _block("metric-tile").get_control_schema(
+ {"dataBinding": {"datasetId": 1, "metrics": ["count"]}, "decimals": 2}, None
+ )
+ assert "dataBinding" in schema["properties"]
+
+
+def test_minimal_object_validates_against_model() -> None:
+ # datasetId + metrics are the only mandatory leaves; everything else is
+ # optional, so this minimal object is a valid instance.
+ BalloonsControls.model_validate(
+ {"dataBinding": {"datasetId": 1, "metrics": ["count"]}}
+ )
+
+
+def test_validate_control_values_passes_for_valid_props() -> None:
+ errors = _block("balloons").validate_control_values(
+ {
+ "dataBinding": {
+ "datasetId": 1,
+ "metrics": ["count"],
+ "dimensions": ["gender"],
+ },
+ "colorDimension": "gender",
+ }
+ )
+ assert errors == []
+
+
+def test_validate_control_values_flags_color_dimension_not_grouped() -> None:
+ # colorDimension names a dimension that isn't in dataBinding.dimensions —
+ # the declarative cross-field rule must surface an actionable error.
+ errors = _block("balloons").validate_control_values(
+ {
+ "dataBinding": {
+ "datasetId": 1,
+ "metrics": ["count"],
+ "dimensions": ["name"],
+ },
+ "colorDimension": "gender",
+ }
+ )
+ assert errors
+ assert any("colorDimension" in error["message"] for error in errors)
+
+
+def test_validate_control_values_empty_when_no_values() -> None:
+ # Nothing to validate (required-field checks live elsewhere).
+ assert _block("balloons").validate_control_values(None) == []
+
+
+def test_data_binding_declares_column_and_metric_controls() -> None:
+ schema = _block("balloons").get_control_schema(None, None)
+ data_binding_props = schema["$defs"]["DataBinding"]["properties"]
+ assert data_binding_props["dimensions"]["x-control"] == "column-multi"
+ assert data_binding_props["metrics"]["x-control"] == "metric-multi"
+
+
+def test_data_binding_round_trip_preserves_filters() -> None:
+ # `filters` isn't a schema-visible DataBinding field (no control panel or
+ # MCP editor authors it yet — see `superset/widgets/controls.py`), but a
+ # dashboard filter binding can still populate it on `node.props`. The
+ # `validate` endpoint's `model_validate(...).model_dump(by_alias=True)`
+ # round trip (mirrored here) is exactly what the frontend commits back to
+ # `node.props` on every edit, so it must not silently drop the field.
+ widget = _block("balloons")
+ control_values = {
+ "dataBinding": {
+ "datasetId": 1,
+ "metrics": ["count"],
+ "filters": [{"clause": "WHERE", "expressionType": "SIMPLE"}],
+ },
+ }
+ assert widget.validate_control_values(control_values) == []
+ values = widget.controls_class.model_validate(control_values).model_dump(
+ by_alias=True
+ )
+ assert values["dataBinding"]["filters"] == [
+ {"clause": "WHERE", "expressionType": "SIMPLE"}
+ ]
+
+
+def test_color_dimension_declares_column_control() -> None:
+ schema = _block("balloons").get_control_schema(None, None)
+ assert schema["properties"]["colorDimension"]["x-control"] == "column"
+
+
+def test_data_binding_schema_is_unchanged_after_metric_control_extraction() -> None:
+ # Golden fixture captured before DataBinding composed MetricControl, via
+ # registry.get("metric-tile").get_control_schema(None, None). Guards
+ # against both the MetricControl extraction and the field_order fix
+ # regressing the served schema — checked at the same boundary the
+ # Inspector consumes, not just raw model_json_schema().
+ schema = _block("metric-tile").get_control_schema(None, None)
+ data_binding = schema["$defs"]["DataBinding"]
+
+ assert list(data_binding["properties"]) == [
+ "datasetId",
+ "metrics",
+ "dimensions",
+ "rowLimit",
+ ]
+ assert data_binding["required"] == ["datasetId", "metrics"]
+ assert data_binding["properties"]["metrics"] == {
+ "description": (
+ "Metrics to fetch. Each entry is a string naming a saved metric "
+ 'on the dataset (e.g. "count"), OR an ad-hoc aggregate object, '
+ "either "
+ '{"expressionType": "SIMPLE", "column": {"column_name": "<col>"}, '
+ '"aggregate": "SUM"|"AVG"|"COUNT"|"COUNT_DISTINCT"|"MIN"|"MAX", '
+ '"label": "<optional display label>"} '
+ 'or {"expressionType": "SQL", "sqlExpression": "<raw SQL '
+ 'expression, e.g. \\"SUM(sales)\\">", '
+ '"label": "<optional display label>"}. Do not pass a raw SQL '
+ "string directly in place of an entry — a plain string is always "
+ "looked up as a saved-metric name, not evaluated as an "
+ "expression; a SQL expression must be wrapped in the "
+ '{"expressionType": "SQL", ...} object above.'
+ ),
+ "items": {},
+ "title": "Metrics",
+ "type": "array",
+ "x-control": "metric-multi",
+ "x-language": "json",
+ }
+ assert data_binding["properties"]["datasetId"] == {
+ "description": "Numeric id of the dataset to query.",
+ "title": "Dataset ID",
+ "type": "integer",
+ }
+ assert data_binding["properties"]["dimensions"] == {
+ "description": "Columns to group by (the categories / series).",
+ "items": {"type": "string"},
+ "title": "Dimensions",
+ "type": "array",
+ "x-control": "column-multi",
+ }
+ assert data_binding["properties"]["rowLimit"] == {
+ "default": 1000,
+ "description": "Maximum number of rows to fetch.",
+ "minimum": 1,
+ "title": "Row limit",
+ "type": "integer",
+ }
+
+
+def test_data_binding_schema_unchanged_via_mcp_boundary() -> None:
+ from superset.mcp_service.widgets.tool.get_widget_control_schema import (
+ _get_widget_control_schema_impl,
+ )
+
+ # dataBinding is mandatory, so the minimal-viable pruning inlines it
+ # (recursing into its own mandatory leaves) rather than leaving a $ref
+ # into $defs -- a different code path through schema_tools.py than the
+ # REST/get_control_schema boundary above, so this exercises the field
+ # order fix against progressive disclosure too.
+ result = _get_widget_control_schema_impl("metric-tile")
+ data_binding = result["properties"]["dataBinding"]
+ assert list(data_binding["properties"]) == [
+ "datasetId",
+ "metrics",
+ "dimensions",
+ "rowLimit",
+ ]
+
+
+def test_widget_registration_raises_on_cyclic_dependency() -> None:
+ class _CyclicControls(BaseModel):
+ a: dict[str, Any] = Field(
+ default_factory=dict,
+ json_schema_extra={"x-dynamic": True, "x-dependsOn": ["b"]},
+ )
+ b: dict[str, Any] = Field(
+ default_factory=dict,
+ json_schema_extra={"x-dynamic": True, "x-dependsOn": ["a"]},
+ )
+
+ with pytest.raises(ValueError, match="Cyclic control dependency"):
+
+ @widget(widget_type="cyclic-test-widget", name="Cyclic")
+ class _Cyclic(Widget):
+ controls_class = _CyclicControls
+
+ # The widget must not remain half-registered after the failure.
+ assert registry.get("cyclic-test-widget") is None
+
+
+def test_widget_registration_does_not_run_enrichers() -> None:
+ class _RuntimeControls(BaseModel):
+ options: list[str] = Field(
+ default_factory=list,
+ json_schema_extra={"x-dynamic": True},
+ )
+
+ calls: list[bool] = []
+
+ def _populate_options(
+ _schema: dict[str, Any],
+ node: dict[str, Any],
+ _parsed: BaseModel | None,
+ _series: list[str],
+ _upstream: dict[str, Any],
+ ) -> None:
+ calls.append(True)
+ node["enum"] = ["one"]
+
+ @widget(widget_type="runtime-enricher-test-widget", name="Runtime enricher")
+ class _RuntimeEnricher(Widget):
+ controls_class = _RuntimeControls
+ enrichers = {"options": _populate_options}
+
+ try:
+ assert calls == []
+
+ schema = _RuntimeEnricher.get_control_schema(None, None)
+
+ assert calls == [True]
+ assert schema["properties"]["options"]["enum"] == ["one"]
+ finally:
+ registry.pop("runtime-enricher-test-widget", None)
diff --git a/tests/unit_tests/widgets/test_schema_tools.py b/tests/unit_tests/widgets/test_schema_tools.py
new file mode 100644
index 00000000000..b07da5daf8c
--- /dev/null
+++ b/tests/unit_tests/widgets/test_schema_tools.py
@@ -0,0 +1,125 @@
+# 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 __future__ import annotations
+
+import pytest
+
+from superset.widgets.registry import registry
+from superset.widgets.schema_tools import (
+ get_subtree,
+ get_subtrees,
+ prune_to_minimal_viable,
+ SchemaPathError,
+)
+
+
+def _balloons_schema(control_values=None, series=None):
+ widget = registry.get("balloons")
+ assert widget is not None
+ return widget.get_control_schema(control_values, series)
+
+
+def test_prune_surfaces_mandatory_leaves_and_collapses_the_rest() -> None:
+ minimal = prune_to_minimal_viable(_balloons_schema())
+
+ assert minimal["x-disclosure"] == "minimal"
+ # No $defs / $ref leak into the minimal view.
+ assert "$defs" not in minimal
+ # dataBinding is mandatory → expanded; its mandatory leaves are inline.
+ data_binding = minimal["properties"]["dataBinding"]
+ assert data_binding["properties"]["datasetId"]["type"] == "integer"
+ assert data_binding["properties"]["metrics"]["type"] == "array"
+ # Its optional but CHEAP leaves (array-of-strings, integer) are inlined too
+ # — small, and nothing to drill into, so no wasted round trip.
+ dimensions = data_binding["properties"]["dimensions"]
+ assert dimensions["type"] == "array"
+ assert "x-collapsed" not in dimensions
+ assert data_binding["properties"]["rowLimit"]["type"] == "integer"
+ # dataBinding has no collapsed (object) children → not partial.
+ assert "x-partial" not in data_binding
+ # colorDimension is an optional scalar at the root → inlined, not collapsed.
+ assert minimal["properties"]["colorDimension"]["type"] == "string"
+ assert "x-collapsed" not in minimal["properties"]["colorDimension"]
+ # customize is an optional OBJECT → collapsed at the root.
+ customize = minimal["properties"]["customize"]
+ assert customize["x-collapsed"] is True
+ assert customize["x-path"] == "customize"
+ # A terse description is KEPT — it's the breadcrumb telling a consumer what
+ # lives under this branch so it knows to drill in. But the child props
+ # themselves are withheld (that's what keeps the first fetch small); they're
+ # fetched on demand via get_subtree.
+ assert customize["description"] == "Per-series color and size overrides."
+ assert "properties" not in customize
+ # customize contains an x-dynamic branch (per-series styling keyed by the
+ # query's values), so the marker is flagged dynamic — the consumer knows it
+ # expands from the current query and must be re-fetched when the query
+ # changes.
+ assert customize["x-dynamic"] is True
+ # The root has a collapsed object child → flagged partial.
+ assert minimal["x-partial"] is True
+
+
+def test_collapsed_marker_is_static_when_no_dynamic_content() -> None:
+ widget = registry.get("ag-grid-table")
+ assert widget is not None
+ minimal = prune_to_minimal_viable(widget.get_control_schema(None, None))
+ # columnDefs is an optional array-of-objects → collapsed, but its shape does
+ # not depend on the query, so it carries no x-dynamic flag (it's static).
+ column_defs = minimal["properties"]["columnDefs"]
+ assert column_defs["x-collapsed"] is True
+ assert "x-dynamic" not in column_defs
+
+
+def test_get_subtree_inlines_a_branch() -> None:
+ subtree = get_subtree(_balloons_schema(), "dataBinding")
+ props = subtree["properties"]
+ assert {"datasetId", "metrics", "dimensions", "rowLimit"} <= set(props)
+ # Fully inlined — no dangling refs.
+ assert "$ref" not in str(subtree)
+
+
+def test_get_subtree_reaches_dynamic_series_after_enrichment() -> None:
+ enriched = _balloons_schema(
+ {
+ "dataBinding": {
+ "datasetId": 1,
+ "metrics": ["count"],
+ "dimensions": ["gender"],
+ }
+ },
+ ["boy", "girl"],
+ )
+ series = get_subtree(enriched, "customize/series")
+ assert set(series["properties"]) == {"boy", "girl"}
+ assert series["properties"]["boy"]["properties"]["color"]["x-control"] == "color"
+
+
+def test_get_subtree_raises_on_bad_path() -> None:
+ with pytest.raises(SchemaPathError):
+ get_subtree(_balloons_schema(), "customize/nope")
+
+
+def test_get_subtrees_expands_several_paths_in_one_call() -> None:
+ subtrees = get_subtrees(_balloons_schema(), ["dataBinding", "customize"])
+ assert set(subtrees) == {"dataBinding", "customize"}
+ assert "datasetId" in subtrees["dataBinding"]["properties"]
+ assert "series" in subtrees["customize"]["properties"]
+
+
+def test_get_subtrees_raises_on_any_bad_path() -> None:
+ with pytest.raises(SchemaPathError):
+ get_subtrees(_balloons_schema(), ["dataBinding", "customize/nope"])
(superset) 01/12: feat(canvas): add Canvas, an AI-first dashboard entity
Posted by ms...@apache.org.
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 e10532c7e8d2a97ca3ecf70524d529d3fa4b580e
Author: Mehmet Salih Yavuz <sa...@proton.me>
AuthorDate: Wed Sep 30 18:56:49 2026 +0300
feat(canvas): add Canvas, an AI-first dashboard entity
Canvas is a new entity, separate from dashboards, that places widgets
on a canvas. Widgets are their own entities; a canvas definition only
references them by id and holds their placement and filter scopes.
- Model: `canvases` (integer id + uuid, audit columns, title,
description, definition, definition_version, revision) with
editors/viewers, and a `canvas_ops` log of applied operations. The id
+ uuid shape keeps entity versioning possible later.
- Definition: a root grid (24 columns) and a flat node map
`{widget, layout, children?}` with server-generated node ids.
Containers are widget types that declare nesting, a child grid or
child layout model, size limits and filter roles through
`CanvasLayoutRules` in superset-core; a `WidgetResolver` reports
widget types and which widgets the user may place. Unresolved widgets
stay as placeholders.
- Operations: add, remove, move, place and set_filter_scope, applied
atomically. A write based on an older revision is merged unless it
overlaps a later change (409 otherwise).
- Filter scope: a filter drives the filterable widgets under its
nearest scoping container, or the whole canvas at the root, with
per-filter overrides.
- API under /api/v1/canvas/: CRUD with editors/viewers sharing, GET and
PATCH /<id>/definition (with resolved filter scopes, placements and
widget types), GET /<id>/definition/changes, and GET /schema for the
published contract. Writes need editorship; admins, editors and
viewers can read.
- CANVAS feature flag (off by default) gates the API.
---
docs/static/feature-flags.json | 6 +
.../src/superset_core/canvas}/__init__.py | 15 +-
superset-core/src/superset_core/canvas/base.py | 117 ++++
.../superset-ui-core/src/utils/featureFlags.ts | 1 +
superset/{models => canvas}/__init__.py | 8 +-
superset/canvas/api.py | 647 +++++++++++++++++++++
superset/{models => canvas/definition}/__init__.py | 8 +-
superset/canvas/definition/grid.py | 103 ++++
superset/canvas/definition/ops.py | 278 +++++++++
superset/canvas/definition/registry.py | 70 +++
superset/canvas/definition/render.py | 77 +++
superset/canvas/definition/schemas.py | 165 ++++++
superset/canvas/definition/scopes.py | 123 ++++
superset/canvas/definition/validation.py | 321 ++++++++++
superset/canvas/filters.py | 83 +++
superset/canvas/schemas.py | 61 ++
superset/{models => commands/canvas}/__init__.py | 7 -
superset/commands/canvas/apply_ops.py | 189 ++++++
superset/commands/canvas/create.py | 90 +++
superset/commands/canvas/delete.py | 56 ++
superset/commands/canvas/exceptions.py | 109 ++++
superset/commands/canvas/update.py | 71 +++
superset/config.py | 5 +
superset/daos/canvas.py | 96 +++
superset/initialization/__init__.py | 4 +
.../2026-09-30_12-00_6d55f8a0851e_add_canvases.py | 125 ++++
superset/models/__init__.py | 1 +
superset/models/canvas.py | 127 ++++
superset/subjects/models.py | 38 ++
.../models => tests/unit_tests/canvas}/__init__.py | 7 -
tests/unit_tests/canvas/api_test.py | 298 ++++++++++
tests/unit_tests/canvas/commands_test.py | 122 ++++
.../unit_tests/canvas/conftest.py | 29 +-
tests/unit_tests/canvas/filters_test.py | 102 ++++
tests/unit_tests/canvas/fixtures.py | 145 +++++
tests/unit_tests/canvas/grid_test.py | 76 +++
tests/unit_tests/canvas/models_test.py | 102 ++++
tests/unit_tests/canvas/ops_test.py | 177 ++++++
tests/unit_tests/canvas/render_test.py | 55 ++
tests/unit_tests/canvas/scopes_test.py | 191 ++++++
tests/unit_tests/canvas/validation_test.py | 215 +++++++
41 files changed, 4479 insertions(+), 41 deletions(-)
diff --git a/docs/static/feature-flags.json b/docs/static/feature-flags.json
index 7a679395464..e128b9237c0 100644
--- a/docs/static/feature-flags.json
+++ b/docs/static/feature-flags.json
@@ -15,6 +15,12 @@
"lifecycle": "development",
"description": "Enables experimental tabs UI for Alerts and Reports"
},
+ {
+ "name": "CANVAS",
+ "default": false,
+ "lifecycle": "development",
+ "description": "Canvas: the AI-first successor to dashboards, whose widgets are placed on a canvas. When off, its API is not registered; existing canvases are kept."
+ },
{
"name": "CHART_PLUGINS_EXPERIMENTAL",
"default": false,
diff --git a/superset/models/__init__.py b/superset-core/src/superset_core/canvas/__init__.py
similarity index 77%
copy from superset/models/__init__.py
copy to superset-core/src/superset_core/canvas/__init__.py
index 1db3711227c..eee560bcf4b 100644
--- a/superset/models/__init__.py
+++ b/superset-core/src/superset_core/canvas/__init__.py
@@ -14,10 +14,13 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
-from . import ( # noqa: F401
- core,
- dynamic_plugins,
- purge_audit_log,
- sql_lab,
- user_attributes,
+
+"""Dashboard canvas contracts for widget providers."""
+
+from superset_core.canvas.base import (
+ CanvasLayoutRules,
+ GridPlacement,
+ WidgetResolver,
)
+
+__all__ = ["CanvasLayoutRules", "GridPlacement", "WidgetResolver"]
diff --git a/superset-core/src/superset_core/canvas/base.py b/superset-core/src/superset_core/canvas/base.py
new file mode 100644
index 00000000000..0e1fd95b0ce
--- /dev/null
+++ b/superset-core/src/superset_core/canvas/base.py
@@ -0,0 +1,117 @@
+# 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.
+
+"""
+What the dashboard canvas needs to know about widgets.
+
+The canvas places widgets; it never stores or reads their configuration.
+Widgets are their own entities, referenced from canvas nodes by id. The canvas
+asks two things of the widget side:
+
+- ``CanvasLayoutRules``, declared per widget type: whether the type holds
+ child nodes, which types may nest where, and how its children are laid out.
+ Types without registered rules are leaves.
+- ``WidgetResolver``: the type of each referenced widget, so nesting and
+ layout can be validated, and which widgets the current user may place.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Iterable
+from typing import ClassVar, Protocol
+
+from pydantic import BaseModel, ConfigDict, Field, model_validator
+from pydantic.alias_generators import to_camel
+
+
+class GridPlacement(BaseModel):
+ """
+ A child's placement on a grid, in 1-based grid units.
+
+ ``col``/``row`` are set together for an explicit position, or both omitted
+ to auto-place the child in reading order. Spans default to the full grid
+ width and one row.
+ """
+
+ model_config = ConfigDict(
+ extra="forbid", alias_generator=to_camel, populate_by_name=True
+ )
+
+ col: int | None = Field(default=None, ge=1)
+ row: int | None = Field(default=None, ge=1)
+ col_span: int | None = Field(default=None, ge=1)
+ row_span: int | None = Field(default=None, ge=1)
+
+ @model_validator(mode="after")
+ def _col_and_row_together(self) -> GridPlacement:
+ if (self.col is None) != (self.row is None):
+ raise ValueError("col and row must be set together or both omitted")
+ return self
+
+
+class CanvasLayoutRules:
+ """
+ How a widget type behaves on the canvas.
+
+ A container sets ``is_container`` and either ``grid_columns`` (children
+ are placed with ``GridPlacement`` on a grid of that many columns) or
+ ``child_layout_model`` (children's layout is validated against that model
+ and arranged by the container's renderer).
+ """
+
+ widget_type: ClassVar[str]
+ is_container: ClassVar[bool] = False
+ # Child widget types this container accepts; ``None`` accepts any.
+ accepted_children: ClassVar[frozenset[str] | None] = None
+ # Container types this widget may live under; ``None`` allows any
+ # container that accepts it, including the root.
+ allowed_parents: ClassVar[frozenset[str] | None] = None
+ grid_columns: ClassVar[int | None] = None
+ child_layout_model: ClassVar[type[BaseModel] | None] = None
+
+ # Filters drive filterable widgets. By default a filter drives every
+ # filterable widget under its nearest container that bounds filter scope
+ # (or the whole canvas at the root); the canvas can override that per
+ # filter. A container that does not bound scope, e.g. a filter bar, lets
+ # its filters reach further up.
+ is_filter: ClassVar[bool] = False
+ is_filterable: ClassVar[bool] = False
+ bounds_filter_scope: ClassVar[bool] = True
+
+ # Size limits, in grid units, for this widget when its parent is a grid.
+ min_col_span: ClassVar[int | None] = None
+ max_col_span: ClassVar[int | None] = None
+ min_row_span: ClassVar[int | None] = None
+ max_row_span: ClassVar[int | None] = None
+
+
+class WidgetResolver(Protocol):
+ """Looks up the widgets a canvas references."""
+
+ def widget_types(self, widget_ids: Iterable[str]) -> dict[str, str]:
+ """
+ Return ``{widget id: widget type}`` for the widgets that exist.
+
+ Types are needed to validate nesting and layout for every node,
+ including widgets the current user cannot see, so this is not
+ filtered by access.
+ """
+ ...
+
+ def placeable(self, widget_ids: Iterable[str]) -> set[str]:
+ """The widgets the current user may place on a canvas."""
+ ...
diff --git a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts
index d8292db827d..96efc85d56a 100644
--- a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts
+++ b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts
@@ -30,6 +30,7 @@ export enum FeatureFlag {
AlertReportsFilter = 'ALERT_REPORTS_FILTER',
AlertReportsRetry = 'ALERT_REPORTS_RETRY',
AllowFullCsvExport = 'ALLOW_FULL_CSV_EXPORT',
+ Canvas = 'CANVAS',
ChartPluginsExperimental = 'CHART_PLUGINS_EXPERIMENTAL',
ConfirmDashboardDiff = 'CONFIRM_DASHBOARD_DIFF',
CssTemplates = 'CSS_TEMPLATES',
diff --git a/superset/models/__init__.py b/superset/canvas/__init__.py
similarity index 86%
copy from superset/models/__init__.py
copy to superset/canvas/__init__.py
index 1db3711227c..5624b8120fe 100644
--- a/superset/models/__init__.py
+++ b/superset/canvas/__init__.py
@@ -14,10 +14,4 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
-from . import ( # noqa: F401
- core,
- dynamic_plugins,
- purge_audit_log,
- sql_lab,
- user_attributes,
-)
+"""Canvas: an AI-first dashboard whose widgets are placed on a canvas."""
diff --git a/superset/canvas/api.py b/superset/canvas/api.py
new file mode 100644
index 00000000000..3f16b35b0e6
--- /dev/null
+++ b/superset/canvas/api.py
@@ -0,0 +1,647 @@
+# 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.
+"""REST API for canvases."""
+
+from __future__ import annotations
+
+import logging
+from collections.abc import Callable
+from functools import wraps
+from typing import Any
+
+from flask import request, Response
+from flask_appbuilder.api import expose, protect, rison as parse_rison, safe
+from flask_appbuilder.models.sqla.interface import SQLAInterface
+from flask_babel import ngettext
+from marshmallow import ValidationError
+from pydantic import TypeAdapter, ValidationError as PydanticValidationError
+from superset_core.canvas import CanvasLayoutRules, GridPlacement
+
+from superset.canvas.definition.registry import layout_rules
+from superset.canvas.definition.render import render_context
+from superset.canvas.definition.schemas import (
+ ApplyOperationsRequest,
+ CanvasDefinition,
+ DEFINITION_VERSION,
+ Operation,
+)
+from superset.canvas.definition.scopes import resolve_filter_scopes
+from superset.canvas.filters import (
+ CanvasAccessFilter,
+ CanvasAllTextFilter,
+ CanvasEditableFilter,
+)
+from superset.canvas.schemas import (
+ CanvasPostSchema,
+ CanvasPutSchema,
+ get_delete_ids_schema,
+)
+from superset.commands.canvas.apply_ops import ApplyCanvasOperationsCommand
+from superset.commands.canvas.create import CreateCanvasCommand
+from superset.commands.canvas.delete import DeleteCanvasCommand
+from superset.commands.canvas.exceptions import (
+ CanvasCreateFailedError,
+ CanvasDeleteFailedError,
+ CanvasForbiddenError,
+ CanvasInvalidError,
+ CanvasNotFoundError,
+ CanvasUpdateFailedError,
+ DefinitionConflictError,
+ DefinitionInvalidError,
+)
+from superset.commands.canvas.update import UpdateCanvasCommand
+from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod
+from superset.daos.canvas import CanvasDAO
+from superset.extensions import event_logger
+from superset.models.canvas import Canvas
+from superset.subjects.filters import FilterRelatedSubjects, subject_type_filter
+from superset.views.base_api import (
+ BaseSupersetModelRestApi,
+ RelatedFieldFilter,
+ statsd_metrics,
+)
+from superset.views.filters import BaseFilterRelatedUsers, FilterRelatedUsers
+
+logger = logging.getLogger(__name__)
+
+
+def _handle_canvas_errors(
+ f: Callable[..., Response],
+) -> Callable[..., Response]:
+ @wraps(f)
+ def wrapped(self: CanvasRestApi, *args: Any, **kwargs: Any) -> Response:
+ try:
+ return f(self, *args, **kwargs)
+ except CanvasNotFoundError:
+ return self.response_404()
+ except CanvasForbiddenError:
+ return self.response_403()
+ except CanvasInvalidError as ex:
+ return self.response_422(message=ex.normalized_messages())
+ except (DefinitionConflictError, DefinitionInvalidError) as ex:
+ return self.response(ex.status, **ex.to_payload())
+ except (
+ CanvasCreateFailedError,
+ CanvasUpdateFailedError,
+ CanvasDeleteFailedError,
+ ) as ex:
+ logger.exception("Canvas write failed")
+ return self.response_422(message=str(ex))
+
+ return wrapped
+
+
+def _pydantic_errors(ex: PydanticValidationError) -> list[dict[str, Any]]:
+ return [
+ {"path": list(error["loc"]), "message": error["msg"]} for error in ex.errors()
+ ]
+
+
+def describe_rules(rules: type[CanvasLayoutRules]) -> dict[str, Any]:
+ def names(types: frozenset[str] | None) -> list[str] | None:
+ return sorted(types) if types is not None else None
+
+ return {
+ "type": rules.widget_type,
+ "isContainer": rules.is_container,
+ "acceptedChildren": names(rules.accepted_children),
+ "allowedParents": names(rules.allowed_parents),
+ "gridColumns": rules.grid_columns,
+ "childLayout": rules.child_layout_model.model_json_schema(by_alias=True)
+ if rules.child_layout_model is not None
+ else None,
+ "isFilter": rules.is_filter,
+ "isFilterable": rules.is_filterable,
+ "boundsFilterScope": rules.bounds_filter_scope,
+ "colSpan": {"min": rules.min_col_span, "max": rules.max_col_span},
+ "rowSpan": {"min": rules.min_row_span, "max": rules.max_row_span},
+ }
+
+
+def _get_canvas(pk: int) -> Canvas:
+ canvas = CanvasDAO.find_by_id(pk)
+ if canvas is None:
+ raise CanvasNotFoundError()
+ return canvas
+
+
+class CanvasRestApi(BaseSupersetModelRestApi):
+ datamodel = SQLAInterface(Canvas)
+
+ include_route_methods = RouteMethod.REST_MODEL_VIEW_CRUD_SET | {
+ RouteMethod.RELATED,
+ "bulk_delete",
+ "get_definition",
+ "get_definition_changes",
+ "apply_definition_operations",
+ "get_schema",
+ }
+ resource_name = "canvas"
+ allow_browser_login = True
+ class_permission_name = "Canvas"
+ method_permission_name = {
+ **MODEL_API_RW_METHOD_PERMISSION_MAP,
+ "get_definition": "read",
+ "get_definition_changes": "read",
+ "apply_definition_operations": "write",
+ "get_schema": "read",
+ }
+ openapi_spec_tag = "Canvases"
+
+ base_filters = [["id", CanvasAccessFilter, lambda: []]]
+
+ _subject_columns = ["editors.id", "editors.label", "editors.type"]
+ _viewer_columns = ["viewers.id", "viewers.label", "viewers.type"]
+ _audit_columns = [
+ "changed_by.first_name",
+ "changed_by.id",
+ "changed_by.last_name",
+ "changed_on_delta_humanized",
+ "created_by.first_name",
+ "created_by.id",
+ "created_by.last_name",
+ ]
+ show_columns = [
+ "id",
+ "uuid",
+ "title",
+ "description",
+ "revision",
+ "url",
+ *_subject_columns,
+ *_viewer_columns,
+ *_audit_columns,
+ ]
+ list_columns = [
+ "id",
+ "uuid",
+ "title",
+ "description",
+ "url",
+ "changed_on",
+ *_subject_columns,
+ *_audit_columns,
+ ]
+ list_select_columns = list_columns + ["changed_by_fk", "created_on"]
+ add_columns = ["title", "description", "editors", "viewers", "definition"]
+ edit_columns = ["title", "description", "editors", "viewers"]
+ order_columns = ["title", "changed_on"]
+ search_columns = ["title", "id"]
+ search_filters = {"title": [CanvasAllTextFilter], "id": [CanvasEditableFilter]}
+
+ add_model_schema = CanvasPostSchema()
+ edit_model_schema = CanvasPutSchema()
+ apispec_parameter_schemas = {"get_delete_ids_schema": get_delete_ids_schema}
+
+ allowed_rel_fields = {"created_by", "changed_by", "editors", "viewers"}
+ order_rel_fields = {"editors": ("label", "asc"), "viewers": ("label", "asc")}
+ text_field_rel_fields = {"editors": "label", "viewers": "label"}
+ extra_fields_rel_fields = {
+ "editors": ["type", "active", "secondary_label", "img"],
+ "viewers": ["type", "active", "secondary_label", "img"],
+ }
+ related_field_filters = {
+ "changed_by": RelatedFieldFilter("first_name", FilterRelatedUsers),
+ "editors": RelatedFieldFilter("label", FilterRelatedSubjects),
+ "viewers": RelatedFieldFilter("label", FilterRelatedSubjects),
+ }
+ base_related_field_filters = {
+ "changed_by": [["id", BaseFilterRelatedUsers, lambda: []]],
+ "editors": [
+ ["type", subject_type_filter("SUBJECTS_RELATED_TYPES_CANVASES"), lambda: []]
+ ],
+ "viewers": [
+ ["type", subject_type_filter("SUBJECTS_RELATED_TYPES_CANVASES"), lambda: []]
+ ],
+ }
+
+ @expose("/", methods=("POST",))
+ @protect()
+ @safe
+ @statsd_metrics
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.post",
+ log_to_statsd=False,
+ )
+ @_handle_canvas_errors
+ def post(self) -> Response:
+ """Create a canvas.
+ ---
+ post:
+ summary: Create a canvas
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/{{self.__class__.__name__}}.post'
+ responses:
+ 201:
+ description: Canvas created
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ id:
+ type: number
+ uuid:
+ type: string
+ 400:
+ $ref: '#/components/responses/400'
+ 401:
+ $ref: '#/components/responses/401'
+ 422:
+ $ref: '#/components/responses/422'
+ """
+ try:
+ item = self.add_model_schema.load(request.json or {})
+ except ValidationError as error:
+ return self.response_400(message=error.messages)
+ canvas = CreateCanvasCommand(item).run()
+ return self.response(201, id=canvas.id, uuid=str(canvas.uuid))
+
+ @expose("/<int:pk>", methods=("PUT",))
+ @protect()
+ @safe
+ @statsd_metrics
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.put",
+ log_to_statsd=False,
+ )
+ @_handle_canvas_errors
+ def put(self, pk: int) -> Response:
+ """Update a canvas' title, description and sharing.
+ ---
+ put:
+ summary: Update a canvas' title, description and sharing
+ parameters:
+ - in: path
+ schema:
+ type: integer
+ name: pk
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/{{self.__class__.__name__}}.put'
+ responses:
+ 200:
+ description: Canvas updated
+ 400:
+ $ref: '#/components/responses/400'
+ 401:
+ $ref: '#/components/responses/401'
+ 403:
+ $ref: '#/components/responses/403'
+ 404:
+ $ref: '#/components/responses/404'
+ 422:
+ $ref: '#/components/responses/422'
+ """
+ try:
+ item = self.edit_model_schema.load(request.json or {})
+ except ValidationError as error:
+ return self.response_400(message=error.messages)
+ canvas = UpdateCanvasCommand(pk, item).run()
+ return self.response(200, id=canvas.id)
+
+ @expose("/<int:pk>", methods=("DELETE",))
+ @protect()
+ @safe
+ @statsd_metrics
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.delete",
+ log_to_statsd=False,
+ )
+ @_handle_canvas_errors
+ def delete(self, pk: int) -> Response:
+ """Delete a canvas.
+ ---
+ delete:
+ summary: Delete a canvas
+ description: >-
+ Deletes the canvas and its operation log. The widgets it placed are
+ separate entities and are not deleted.
+ parameters:
+ - in: path
+ schema:
+ type: integer
+ name: pk
+ responses:
+ 200:
+ description: Canvas deleted
+ 401:
+ $ref: '#/components/responses/401'
+ 403:
+ $ref: '#/components/responses/403'
+ 404:
+ $ref: '#/components/responses/404'
+ 422:
+ $ref: '#/components/responses/422'
+ """
+ DeleteCanvasCommand([pk]).run()
+ return self.response(200, message="OK")
+
+ @expose("/", methods=("DELETE",))
+ @protect()
+ @safe
+ @statsd_metrics
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.bulk_delete",
+ log_to_statsd=False,
+ )
+ @parse_rison(get_delete_ids_schema)
+ @_handle_canvas_errors
+ def bulk_delete(self, **kwargs: Any) -> Response:
+ """Delete canvases.
+ ---
+ delete:
+ summary: Delete canvases
+ parameters:
+ - in: query
+ name: q
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/get_delete_ids_schema'
+ responses:
+ 200:
+ description: Canvases deleted
+ 401:
+ $ref: '#/components/responses/401'
+ 403:
+ $ref: '#/components/responses/403'
+ 404:
+ $ref: '#/components/responses/404'
+ 422:
+ $ref: '#/components/responses/422'
+ """
+ ids = kwargs["rison"]
+ DeleteCanvasCommand(ids).run()
+ return self.response(
+ 200,
+ message=ngettext(
+ "Deleted %(num)d canvas", "Deleted %(num)d canvases", num=len(ids)
+ ),
+ )
+
+ @expose("/<int:pk>/definition", methods=("GET",))
+ @protect()
+ @safe
+ @statsd_metrics
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs: (
+ f"{self.__class__.__name__}.get_definition"
+ ),
+ log_to_statsd=False,
+ )
+ @_handle_canvas_errors
+ def get_definition(self, pk: int) -> Response:
+ """Get a canvas' definition.
+ ---
+ get:
+ summary: Get a canvas' definition
+ parameters:
+ - in: path
+ schema:
+ type: integer
+ name: pk
+ responses:
+ 200:
+ description: The definition, its revision and resolved filter scopes
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ result:
+ type: object
+ properties:
+ version:
+ type: integer
+ revision:
+ type: integer
+ definition:
+ type: object
+ filterScopes:
+ type: object
+ description: >-
+ The node ids each filter drives, resolved from
+ the tree and the definition's scope overrides
+ placements:
+ type: object
+ description: >-
+ Resolved grid position of every node on a grid,
+ auto-placed nodes included
+ widgetTypes:
+ type: object
+ description: The widget type of each resolvable node
+ 401:
+ $ref: '#/components/responses/401'
+ 404:
+ $ref: '#/components/responses/404'
+ """
+ canvas = _get_canvas(pk)
+ definition = CanvasDAO.load(canvas)
+ return self.response(
+ 200,
+ result={
+ "version": canvas.definition_version,
+ "revision": canvas.revision,
+ "definition": definition,
+ "filterScopes": resolve_filter_scopes(definition),
+ **render_context(definition),
+ },
+ )
+
+ @expose("/<int:pk>/definition/changes", methods=("GET",))
+ @protect()
+ @safe
+ @statsd_metrics
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs: (
+ f"{self.__class__.__name__}.get_definition_changes"
+ ),
+ log_to_statsd=False,
+ )
+ @_handle_canvas_errors
+ def get_definition_changes(self, pk: int) -> Response:
+ """Get the operations applied to a canvas' definition since a revision.
+ ---
+ get:
+ summary: Get a canvas' definition changes since a revision
+ description: >-
+ Returns every operation applied after `since`, grouped by revision.
+ Responds 409 with `stale` when `since` predates the retained
+ operation log; the client should reload the whole definition.
+ parameters:
+ - in: path
+ schema:
+ type: integer
+ name: pk
+ - in: query
+ schema:
+ type: integer
+ minimum: 0
+ name: since
+ required: true
+ responses:
+ 200:
+ description: Operations applied since the given revision
+ 400:
+ $ref: '#/components/responses/400'
+ 401:
+ $ref: '#/components/responses/401'
+ 404:
+ $ref: '#/components/responses/404'
+ 409:
+ description: The operation log no longer covers `since`
+ """
+ since = request.args.get("since", type=int)
+ if since is None or since < 0:
+ return self.response_400(message="`since` must be a revision number")
+
+ canvas = _get_canvas(pk)
+ current = canvas.revision
+ if since >= current:
+ return self.response(200, result={"revision": current, "changes": []})
+
+ oldest = CanvasDAO.oldest_logged_revision(canvas.id)
+ if oldest is None or oldest > since + 1:
+ raise DefinitionConflictError(current, [], stale=True)
+
+ changes: dict[int, list[dict[str, Any]]] = {}
+ for row in CanvasDAO.ops_since(canvas.id, since):
+ changes.setdefault(row.revision, []).append(row.op_dict)
+ return self.response(
+ 200,
+ result={
+ "revision": current,
+ "changes": [
+ {"revision": revision, "ops": ops}
+ for revision, ops in changes.items()
+ ],
+ },
+ )
+
+ @expose("/<int:pk>/definition", methods=("PATCH",))
+ @protect()
+ @safe
+ @statsd_metrics
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs: (
+ f"{self.__class__.__name__}.apply_definition_operations"
+ ),
+ log_to_statsd=False,
+ )
+ @_handle_canvas_errors
+ def apply_definition_operations(self, pk: int) -> Response:
+ """Apply operations to a canvas' definition.
+ ---
+ patch:
+ summary: Apply operations to a canvas' definition
+ description: >-
+ Applies `ops` in order to the latest definition, atomically. Changes
+ made since `base_revision` are merged unless they touched the same
+ node and field group (layout or filter scope), or moved or removed
+ an edited node; then the whole request is rejected with 409 and the
+ conflicting node ids.
+ parameters:
+ - in: path
+ schema:
+ type: integer
+ name: pk
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [base_revision, ops]
+ properties:
+ base_revision:
+ type: integer
+ ops:
+ type: array
+ items:
+ type: object
+ responses:
+ 200:
+ description: The new revision, the applied operations and scopes
+ 400:
+ $ref: '#/components/responses/400'
+ 401:
+ $ref: '#/components/responses/401'
+ 403:
+ $ref: '#/components/responses/403'
+ 404:
+ $ref: '#/components/responses/404'
+ 409:
+ description: Conflicting or stale write
+ 422:
+ description: An operation failed or produced an invalid definition
+ """
+ try:
+ body = ApplyOperationsRequest.model_validate(request.get_json(silent=True))
+ except PydanticValidationError as ex:
+ return self.response(400, message=_pydantic_errors(ex))
+ result = ApplyCanvasOperationsCommand(pk, body.base_revision, body.ops).run()
+ return self.response(
+ 200,
+ result={
+ "revision": result.revision,
+ "ops": result.ops,
+ "filterScopes": result.filter_scopes,
+ **result.render_context,
+ },
+ )
+
+ @expose("/schema", methods=("GET",))
+ @protect()
+ @safe
+ @statsd_metrics
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.get_schema",
+ log_to_statsd=False,
+ )
+ def get_schema(self) -> Response:
+ """Get the canvas definition contract.
+ ---
+ get:
+ summary: Get the canvas definition schema and layout rules
+ description: >-
+ JSON Schemas for the definition, its operations and grid placement,
+ plus the layout rules registered for container, filter and
+ filterable widget types. Widget types without rules are leaves.
+ responses:
+ 200:
+ description: The canvas contract
+ 401:
+ $ref: '#/components/responses/401'
+ """
+ return self.response(
+ 200,
+ result={
+ "version": DEFINITION_VERSION,
+ "definition": CanvasDefinition.model_json_schema(by_alias=True),
+ "operation": TypeAdapter(Operation).json_schema(by_alias=True),
+ "gridPlacement": GridPlacement.model_json_schema(by_alias=True),
+ "widgetTypes": [describe_rules(rules) for rules in layout_rules],
+ },
+ )
diff --git a/superset/models/__init__.py b/superset/canvas/definition/__init__.py
similarity index 86%
copy from superset/models/__init__.py
copy to superset/canvas/definition/__init__.py
index 1db3711227c..25272ebbe05 100644
--- a/superset/models/__init__.py
+++ b/superset/canvas/definition/__init__.py
@@ -14,10 +14,4 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
-from . import ( # noqa: F401
- core,
- dynamic_plugins,
- purge_audit_log,
- sql_lab,
- user_attributes,
-)
+"""A canvas definition: where widgets are placed, and how filters scope."""
diff --git a/superset/canvas/definition/grid.py b/superset/canvas/definition/grid.py
new file mode 100644
index 00000000000..d360249753c
--- /dev/null
+++ b/superset/canvas/definition/grid.py
@@ -0,0 +1,103 @@
+# 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.
+"""
+Grid placement shared by the root and grid containers.
+
+Children are resolved in reading order (their order in ``children``):
+
+- An explicitly placed child (``col``/``row`` set) keeps its column. If it
+ overlaps a child resolved before it, it is pushed straight down until clear,
+ and the pushed ``row`` is written back to its layout.
+- An auto-placed child (``col``/``row`` omitted) takes the first free slot that
+ fits its span, scanning row by row from a cursor that only moves forward.
+ Its layout keeps ``col``/``row`` omitted.
+
+The frontend renderer must resolve placement with the same rules.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+
+
+@dataclass(frozen=True)
+class Rect:
+ col: int
+ row: int
+ col_span: int
+ row_span: int
+
+ def cells(self) -> set[tuple[int, int]]:
+ return {
+ (row, col)
+ for row in range(self.row, self.row + self.row_span)
+ for col in range(self.col, self.col + self.col_span)
+ }
+
+
+def span_errors(layout: dict[str, Any], columns: int) -> list[str]:
+ """Reasons ``layout`` does not fit a grid of ``columns`` columns."""
+ col_span = layout.get("colSpan") or columns
+ col = layout.get("col") or 1
+ if col_span > columns:
+ return [f"colSpan {col_span} exceeds the grid's {columns} columns"]
+ if col + col_span - 1 > columns:
+ return [f"col {col} with colSpan {col_span} overflows {columns} columns"]
+ return []
+
+
+def resolve_grid(
+ layouts: list[dict[str, Any]], columns: int
+) -> tuple[list[Rect], list[dict[str, Any]]]:
+ """
+ Resolve placement for children in reading order.
+
+ Returns the resolved rectangles and the layouts to store, where explicitly
+ placed children that collided carry their pushed-down ``row``.
+ """
+ occupied: set[tuple[int, int]] = set()
+ cursor_row, cursor_col = 1, 1
+ rects: list[Rect] = []
+ stored: list[dict[str, Any]] = []
+
+ for layout in layouts:
+ col_span = min(layout.get("colSpan") or columns, columns)
+ row_span = layout.get("rowSpan") or 1
+
+ if layout.get("col") is not None:
+ rect = Rect(layout["col"], layout["row"], col_span, row_span)
+ while rect.cells() & occupied:
+ rect = Rect(rect.col, rect.row + 1, col_span, row_span)
+ stored.append({**layout, "row": rect.row})
+ else:
+ row, col = cursor_row, cursor_col
+ while True:
+ if col + col_span - 1 > columns:
+ row, col = row + 1, 1
+ continue
+ rect = Rect(col, row, col_span, row_span)
+ if not rect.cells() & occupied:
+ break
+ col += 1
+ cursor_row, cursor_col = rect.row, rect.col + col_span
+ stored.append(dict(layout))
+
+ occupied |= rect.cells()
+ rects.append(rect)
+
+ return rects, stored
diff --git a/superset/canvas/definition/ops.py b/superset/canvas/definition/ops.py
new file mode 100644
index 00000000000..d3e37baa170
--- /dev/null
+++ b/superset/canvas/definition/ops.py
@@ -0,0 +1,278 @@
+# 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.
+"""
+Applying operations to a canvas.
+
+Operations are the only way a stored canvas changes. Each applied operation
+reports what it touched as ``(node id, field group)`` pairs; the command layer
+logs them per revision and rejects a write whose touches overlap a change made
+since the revision the caller last saw (see ``overlapping``).
+"""
+
+from __future__ import annotations
+
+import copy
+from collections.abc import Callable, Iterable
+from dataclasses import dataclass, field
+from enum import Enum
+from typing import Any
+from uuid import uuid4
+
+from superset_core.canvas import WidgetResolver
+
+from superset.canvas.definition.registry import (
+ get_widget_resolver,
+ layout_rules,
+ LayoutRulesRegistry,
+)
+from superset.canvas.definition.schemas import (
+ AddOp,
+ MoveOp,
+ Operation,
+ PlaceOp,
+ RemoveOp,
+ ROOT_ID,
+ SetFilterScopeOp,
+)
+from superset.canvas.definition.validation import normalize_definition
+
+
+class FieldGroup(str, Enum):
+ # Existence and position in the tree; overlaps with every other group.
+ TREE = "tree"
+ LAYOUT = "layout"
+ # A filter's scope override.
+ SCOPE = "scope"
+
+
+@dataclass(frozen=True)
+class Touch:
+ node_id: str
+ group: FieldGroup
+
+
+@dataclass
+class AppliedOperation:
+ # The operation as logged; ``add`` carries the node id the server assigned.
+ op: dict[str, Any]
+ touched: list[Touch] = field(default_factory=list)
+
+
+class OperationError(ValueError):
+ def __init__(self, index: int, message: str) -> None:
+ self.index = index
+ super().__init__(f"operation {index}: {message}")
+
+
+def overlapping(earlier: Iterable[Touch], later: Iterable[Touch]) -> set[str]:
+ """Node ids where ``later`` touches collide with ``earlier`` ones."""
+ groups: dict[str, set[FieldGroup]] = {}
+ for touch in earlier:
+ groups.setdefault(touch.node_id, set()).add(touch.group)
+ conflicts: set[str] = set()
+ for touch in later:
+ seen = groups.get(touch.node_id)
+ if seen and (
+ touch.group in seen
+ or touch.group == FieldGroup.TREE
+ or FieldGroup.TREE in seen
+ ):
+ conflicts.add(touch.node_id)
+ return conflicts
+
+
+def named_touches(ops: Iterable[Operation]) -> list[Touch]:
+ """
+ What ``ops`` touch on nodes that already exist, known before applying them.
+
+ Lets a stale write be checked for overlap even when an operation could no
+ longer apply, e.g. because another writer removed its node.
+ """
+ touches: list[Touch] = []
+ for op in ops:
+ if isinstance(op, RemoveOp):
+ touches.append(Touch(op.id, FieldGroup.TREE))
+ elif isinstance(op, MoveOp):
+ touches += [Touch(op.id, FieldGroup.TREE), Touch(op.id, FieldGroup.LAYOUT)]
+ elif isinstance(op, PlaceOp):
+ touches.append(Touch(op.id, FieldGroup.LAYOUT))
+ elif isinstance(op, SetFilterScopeOp):
+ touches.append(Touch(op.id, FieldGroup.SCOPE))
+ return touches
+
+
+class _Applier:
+ def __init__(
+ self,
+ canvas: dict[str, Any],
+ new_id: Callable[[], str],
+ is_container: Callable[[str], bool],
+ ) -> None:
+ self.canvas = copy.deepcopy(canvas)
+ self.new_id = new_id
+ # Takes a widget id. Nodes that are no longer resolvable keep their
+ # stored children but accept no new ones.
+ self.is_container = is_container
+
+ @property
+ def nodes(self) -> dict[str, Any]:
+ return self.canvas["nodes"]
+
+ def node(self, node_id: str) -> dict[str, Any]:
+ if node_id not in self.nodes:
+ raise ValueError(f"unknown node {node_id!r}")
+ return self.nodes[node_id]
+
+ def children(self, parent_id: str) -> list[str]:
+ if parent_id == ROOT_ID:
+ return self.canvas["root"]["children"]
+ parent = self.node(parent_id)
+ if not self.is_container(parent["widget"]):
+ raise ValueError(f"{parent_id!r} cannot hold children")
+ return parent.setdefault("children", [])
+
+ def _siblings(self, parent_id: str) -> list[str]:
+ """A parent's existing children, which may be taken from any parent."""
+ if parent_id == ROOT_ID:
+ return self.canvas["root"]["children"]
+ return self.node(parent_id)["children"]
+
+ def parent_of(self, node_id: str) -> str:
+ if node_id in self.canvas["root"]["children"]:
+ return ROOT_ID
+ for parent_id, node in self.nodes.items():
+ if node_id in (node.get("children") or []):
+ return parent_id
+ raise ValueError(f"node {node_id!r} has no parent")
+
+ def subtree(self, node_id: str) -> list[str]:
+ ids, stack = [], [node_id]
+ while stack:
+ current = stack.pop()
+ ids.append(current)
+ stack.extend(self.nodes[current].get("children") or [])
+ return ids
+
+ @staticmethod
+ def insert(children: list[str], node_id: str, index: int | None) -> None:
+ if index is None:
+ children.append(node_id)
+ elif index > len(children):
+ raise ValueError(f"index {index} is past the end of {len(children)}")
+ else:
+ children.insert(index, node_id)
+
+ def apply(self, op: Operation) -> AppliedOperation:
+ logged = op.model_dump(mode="json", by_alias=True, exclude_none=True)
+ if isinstance(op, AddOp):
+ node_id = self.new_id()
+ self.insert(self.children(op.parent), node_id, op.index)
+ self.nodes[node_id] = {"widget": op.widget, "layout": op.layout}
+ return AppliedOperation(
+ {**logged, "id": node_id}, [Touch(node_id, FieldGroup.TREE)]
+ )
+ if isinstance(op, RemoveOp):
+ self.node(op.id)
+ self._siblings(self.parent_of(op.id)).remove(op.id)
+ removed = self.subtree(op.id)
+ for node_id in removed:
+ del self.nodes[node_id]
+ touched = [Touch(node_id, FieldGroup.TREE) for node_id in removed]
+ return AppliedOperation(logged, touched + self._prune_scopes(set(removed)))
+ if isinstance(op, MoveOp):
+ return self.move(op, logged)
+ if isinstance(op, SetFilterScopeOp):
+ self.node(op.id)
+ filters = self.scopes()
+ if op.scope is None:
+ filters.pop(op.id, None)
+ else:
+ filters[op.id] = op.scope.model_dump(
+ mode="json", by_alias=True, exclude_none=True
+ )
+ return AppliedOperation(logged, [Touch(op.id, FieldGroup.SCOPE)])
+ assert isinstance(op, PlaceOp) # noqa: S101
+ self.node(op.id)["layout"] = op.layout
+ return AppliedOperation(logged, [Touch(op.id, FieldGroup.LAYOUT)])
+
+ def scopes(self) -> dict[str, Any]:
+ return self.canvas.setdefault("interactions", {}).setdefault("filters", {})
+
+ def _prune_scopes(self, removed: set[str]) -> list[Touch]:
+ """Drop removed nodes from filter scope overrides."""
+ filters = self.scopes()
+ touched = []
+ for filter_id in list(filters):
+ if filter_id in removed:
+ del filters[filter_id]
+ continue
+ scope = filters[filter_id]
+ for field_name in ("targets", "exclude"):
+ kept = [n for n in scope.get(field_name, []) if n not in removed]
+ if len(kept) != len(scope.get(field_name, [])):
+ scope[field_name] = kept
+ touched.append(Touch(filter_id, FieldGroup.SCOPE))
+ return touched
+
+ def move(self, op: MoveOp, logged: dict[str, Any]) -> AppliedOperation:
+ node = self.node(op.id)
+ if op.parent != ROOT_ID and op.parent in self.subtree(op.id):
+ raise ValueError("cannot move a node into itself or its descendants")
+ old_parent = self.parent_of(op.id)
+ new_children = self.children(op.parent)
+ self._siblings(old_parent).remove(op.id)
+ self.insert(new_children, op.id, op.index)
+ if op.layout is not None:
+ node["layout"] = op.layout
+ elif op.parent != old_parent:
+ # Placement is relative to the parent; re-flow in the new one.
+ node["layout"] = {}
+ return AppliedOperation(
+ logged, [Touch(op.id, FieldGroup.TREE), Touch(op.id, FieldGroup.LAYOUT)]
+ )
+
+
+def apply_operations(
+ canvas: dict[str, Any],
+ ops: list[Operation],
+ *,
+ rules: LayoutRulesRegistry | None = None,
+ resolver: WidgetResolver | None = None,
+ new_id: Callable[[], str] = lambda: str(uuid4()),
+) -> tuple[dict[str, Any], list[AppliedOperation]]:
+ """
+ Apply ``ops`` in order to a copy of ``canvas``.
+
+ All operations apply or none do: a bad operation raises ``OperationError``
+ and an invalid result raises ``DefinitionValidationError``. Returns the
+ normalized canvas and what each operation touched.
+ """
+ rules = rules or layout_rules
+ resolver = resolver or get_widget_resolver()
+
+ def is_container(widget_id: str) -> bool:
+ widget_type = resolver.widget_types({widget_id}).get(widget_id)
+ return widget_type is not None and rules.get(widget_type).is_container
+
+ applier = _Applier(canvas, new_id, is_container)
+ applied: list[AppliedOperation] = []
+ for index, op in enumerate(ops):
+ try:
+ applied.append(applier.apply(op))
+ except ValueError as ex:
+ raise OperationError(index, str(ex)) from ex
+ return normalize_definition(applier.canvas, rules, resolver), applied
diff --git a/superset/canvas/definition/registry.py b/superset/canvas/definition/registry.py
new file mode 100644
index 00000000000..36c2381c8bd
--- /dev/null
+++ b/superset/canvas/definition/registry.py
@@ -0,0 +1,70 @@
+# 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.
+"""
+Where the widget side plugs into the canvas.
+
+Widget providers register layout rules for their container types and one
+resolver for widget references. Until a resolver is set, no widget exists,
+so a canvas can hold no nodes.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Iterable, Iterator
+
+from superset_core.canvas import CanvasLayoutRules, WidgetResolver
+
+
+class LayoutRulesRegistry:
+ def __init__(self) -> None:
+ self._rules: dict[str, type[CanvasLayoutRules]] = {}
+
+ def register(self, rules: type[CanvasLayoutRules]) -> None:
+ if rules.widget_type in self._rules:
+ raise ValueError(f"widget type {rules.widget_type!r} already registered")
+ self._rules[rules.widget_type] = rules
+
+ def unregister(self, widget_type: str) -> None:
+ self._rules.pop(widget_type, None)
+
+ def __iter__(self) -> Iterator[type[CanvasLayoutRules]]:
+ return iter(self._rules.values())
+
+ def get(self, widget_type: str) -> type[CanvasLayoutRules]:
+ """Rules for ``widget_type``; types without rules are leaves."""
+ return self._rules.get(widget_type, CanvasLayoutRules)
+
+
+class _NoWidgets:
+ def widget_types(self, widget_ids: Iterable[str]) -> dict[str, str]:
+ return {}
+
+ def placeable(self, widget_ids: Iterable[str]) -> set[str]:
+ return set()
+
+
+layout_rules = LayoutRulesRegistry()
+_resolver: WidgetResolver = _NoWidgets()
+
+
+def set_widget_resolver(resolver: WidgetResolver) -> None:
+ global _resolver
+ _resolver = resolver
+
+
+def get_widget_resolver() -> WidgetResolver:
+ return _resolver
diff --git a/superset/canvas/definition/render.py b/superset/canvas/definition/render.py
new file mode 100644
index 00000000000..7866b1484d1
--- /dev/null
+++ b/superset/canvas/definition/render.py
@@ -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.
+"""
+What a renderer needs besides the stored definition.
+
+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.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from superset_core.canvas import WidgetResolver
+
+from superset.canvas.definition.grid import resolve_grid
+from superset.canvas.definition.registry import (
+ get_widget_resolver,
+ layout_rules,
+ LayoutRulesRegistry,
+)
+
+
+def render_context(
+ definition: dict[str, Any],
+ rules: LayoutRulesRegistry | None = None,
+ resolver: WidgetResolver | None = None,
+) -> dict[str, Any]:
+ rules = rules or layout_rules
+ resolver = resolver or get_widget_resolver()
+ nodes = definition["nodes"]
+ types = resolver.widget_types({node["widget"] for node in nodes.values()})
+ widget_types = {
+ node_id: types[node["widget"]]
+ for node_id, node in nodes.items()
+ if node["widget"] in types
+ }
+
+ grids: list[tuple[list[str], int]] = [
+ (definition["root"]["children"], definition["root"]["layout"]["columns"])
+ ]
+ for node_id, node in nodes.items():
+ widget_type = widget_types.get(node_id)
+ if widget_type is None:
+ continue
+ 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))
+
+ placements: dict[str, dict[str, int]] = {}
+ for children, columns in grids:
+ rects, _ = resolve_grid([nodes[c]["layout"] for c in children], columns)
+ for child, rect in zip(children, rects, strict=True):
+ placements[child] = {
+ "col": rect.col,
+ "row": rect.row,
+ "colSpan": rect.col_span,
+ "rowSpan": rect.row_span,
+ }
+ return {"widgetTypes": widget_types, "placements": placements}
diff --git a/superset/canvas/definition/schemas.py b/superset/canvas/definition/schemas.py
new file mode 100644
index 00000000000..b5f9b87df8e
--- /dev/null
+++ b/superset/canvas/definition/schemas.py
@@ -0,0 +1,165 @@
+# 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.
+"""
+The dashboard canvas and the operations that change it.
+
+A canvas is where a dashboard's widgets sit: a flat ``nodes`` map keyed by
+server-generated ids, with the tree expressed through ``children`` id lists.
+A node references a widget by id and holds only its placement; widget
+configuration lives with the widget. The root is not a node: it always
+exists, is always a grid, and is addressed as ``"root"`` in operations.
+"""
+
+from __future__ import annotations
+
+from enum import Enum
+from typing import Annotated, Any, Literal, Union
+
+from pydantic import BaseModel, ConfigDict, Field, model_validator
+from pydantic.alias_generators import to_camel
+
+DEFINITION_VERSION = 1
+ROOT_ID = "root"
+
+
+class _Model(BaseModel):
+ model_config = ConfigDict(
+ extra="forbid", alias_generator=to_camel, populate_by_name=True
+ )
+
+
+class RootLayout(_Model):
+ columns: int = Field(default=24, ge=1, le=48)
+ gap: int = Field(default=16, ge=0, le=64)
+ row_unit: int = Field(default=40, ge=8, le=400)
+
+
+class Root(_Model):
+ layout: RootLayout = Field(default_factory=RootLayout)
+ children: list[str] = Field(default_factory=list)
+
+
+class Node(_Model):
+ # Id of the placed widget.
+ widget: str = Field(min_length=1, max_length=64)
+ # Placement within the parent, validated against the parent's rules.
+ layout: dict[str, Any] = Field(default_factory=dict)
+ # Present only when the widget is a container.
+ children: list[str] | None = None
+
+
+class FilterScopeMode(str, Enum):
+ # Filterable widgets under the filter's nearest scoping container.
+ AUTO = "auto"
+ # Every filterable widget on the canvas.
+ GLOBAL = "global"
+ # Exactly the listed nodes.
+ CUSTOM = "custom"
+
+
+class FilterScope(_Model):
+ mode: FilterScopeMode = FilterScopeMode.AUTO
+ # Nodes a custom scope drives.
+ targets: list[str] = Field(default_factory=list)
+ # Nodes left out of an auto or global scope.
+ exclude: list[str] = Field(default_factory=list)
+
+ @model_validator(mode="after")
+ def _targets_match_mode(self) -> FilterScope:
+ # A custom scope may end up with no targets once they are removed; the
+ # filter then drives nothing rather than silently falling back to auto.
+ if self.mode == FilterScopeMode.CUSTOM:
+ if self.exclude:
+ raise ValueError("a custom scope takes targets, not exclude")
+ elif self.targets:
+ raise ValueError(f"an {self.mode.value} scope takes exclude, not targets")
+ return self
+
+
+class Interactions(_Model):
+ # Scope overrides, by filter node id. Filters without one use auto scope.
+ filters: dict[str, FilterScope] = Field(default_factory=dict)
+
+
+class CanvasDefinition(_Model):
+ version: Literal[1] = 1
+ root: Root = Field(default_factory=Root)
+ nodes: dict[str, Node] = Field(default_factory=dict)
+ interactions: Interactions = Field(default_factory=Interactions)
+
+
+def empty_definition() -> dict[str, Any]:
+ return CanvasDefinition().model_dump(mode="json", by_alias=True, exclude_none=True)
+
+
+class AddOp(_Model):
+ """Place a widget under ``parent``; the server assigns the node id."""
+
+ op: Literal["add"]
+ widget: str = Field(min_length=1, max_length=64)
+ layout: dict[str, Any] = Field(default_factory=dict)
+ parent: str = ROOT_ID
+ # Position in the parent's children (reading order); appended when omitted.
+ index: int | None = Field(default=None, ge=0)
+
+
+class RemoveOp(_Model):
+ """Remove a node and its whole subtree from the canvas."""
+
+ op: Literal["remove"]
+ id: str
+
+
+class MoveOp(_Model):
+ """Reparent or reorder a node, optionally with a new layout."""
+
+ op: Literal["move"]
+ id: str
+ parent: str
+ index: int | None = Field(default=None, ge=0)
+ # Required when the new parent lays children out differently.
+ layout: dict[str, Any] | None = None
+
+
+class PlaceOp(_Model):
+ """Replace a node's layout within its current parent (drag/resize)."""
+
+ op: Literal["place"]
+ id: str
+ layout: dict[str, Any]
+
+
+class SetFilterScopeOp(_Model):
+ """Override which widgets a filter drives; ``scope: null`` restores auto."""
+
+ op: Literal["set_filter_scope"]
+ id: str
+ scope: FilterScope | None
+
+
+Operation = Annotated[
+ Union[AddOp, RemoveOp, MoveOp, PlaceOp, SetFilterScopeOp],
+ Field(discriminator="op"),
+]
+
+
+class ApplyOperationsRequest(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ # The revision the caller's view of the canvas is based on.
+ base_revision: int = Field(ge=0)
+ ops: list[Operation] = Field(min_length=1, max_length=200)
diff --git a/superset/canvas/definition/scopes.py b/superset/canvas/definition/scopes.py
new file mode 100644
index 00000000000..5556bdca4d8
--- /dev/null
+++ b/superset/canvas/definition/scopes.py
@@ -0,0 +1,123 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""
+Which widgets each filter on a canvas drives.
+
+By default a filter drives every filterable widget under its nearest ancestor
+container that bounds filter scope, or the whole canvas when there is none. A
+filter in a tab drives that tab; a filter in a filter bar that does not bound
+scope drives the whole canvas. A per-filter override in
+``interactions.filters`` can make the scope global or name the targets, and
+can exclude nodes from an auto or global scope.
+
+Only filterable widgets are ever driven, and nodes whose widget or type no
+longer resolves are skipped.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from superset_core.canvas import CanvasLayoutRules, WidgetResolver
+
+from superset.canvas.definition.registry import (
+ get_widget_resolver,
+ layout_rules,
+ LayoutRulesRegistry,
+)
+from superset.canvas.definition.schemas import FilterScopeMode, ROOT_ID
+
+
+def _reading_order(canvas: dict[str, Any], start: str) -> list[str]:
+ """Node ids under ``start`` (exclusive), depth first in children order."""
+ children = (
+ canvas["root"]["children"]
+ if start == ROOT_ID
+ else canvas["nodes"][start].get("children") or []
+ )
+ ordered: list[str] = []
+ for child in children:
+ ordered.append(child)
+ ordered.extend(_reading_order(canvas, child))
+ return ordered
+
+
+def resolve_filter_scopes(
+ canvas: dict[str, Any],
+ rules: LayoutRulesRegistry | None = None,
+ resolver: WidgetResolver | None = None,
+) -> dict[str, list[str]]:
+ """Map each filter node id to the node ids it drives, in reading order."""
+ rules = rules or layout_rules
+ resolver = resolver or get_widget_resolver()
+ nodes = canvas["nodes"]
+ types = resolver.widget_types({node["widget"] for node in nodes.values()})
+ node_rules: dict[str, type[CanvasLayoutRules]] = {
+ node_id: rules.get(types[node["widget"]])
+ for node_id, node in nodes.items()
+ if node["widget"] in types
+ }
+ parents = {
+ child: parent
+ for parent in [ROOT_ID, *nodes]
+ for child in _children(canvas, parent)
+ }
+ filterable = {n for n, r in node_rules.items() if r.is_filterable}
+ overrides = canvas.get("interactions", {}).get("filters", {})
+
+ scopes: dict[str, list[str]] = {}
+ for node_id, node_rule in node_rules.items():
+ if not node_rule.is_filter:
+ continue
+ override = overrides.get(node_id, {})
+ mode = FilterScopeMode(override.get("mode", FilterScopeMode.AUTO))
+ if mode == FilterScopeMode.CUSTOM:
+ scopes[node_id] = [t for t in override["targets"] if t in filterable]
+ continue
+ boundary = (
+ ROOT_ID
+ if mode == FilterScopeMode.GLOBAL
+ else _scope_boundary(node_id, parents, node_rules)
+ )
+ excluded = set(override.get("exclude", []))
+ scopes[node_id] = [
+ n
+ for n in _reading_order(canvas, boundary)
+ if n in filterable and n not in excluded
+ ]
+ return scopes
+
+
+def _children(canvas: dict[str, Any], parent: str) -> list[str]:
+ if parent == ROOT_ID:
+ return canvas["root"]["children"]
+ return canvas["nodes"][parent].get("children") or []
+
+
+def _scope_boundary(
+ node_id: str,
+ parents: dict[str, str],
+ node_rules: dict[str, type[CanvasLayoutRules]],
+) -> str:
+ current = parents.get(node_id, ROOT_ID)
+ while current != ROOT_ID:
+ container = node_rules.get(current)
+ # An unresolved container keeps its filters contained.
+ if container is None or container.bounds_filter_scope:
+ return current
+ current = parents.get(current, ROOT_ID)
+ return ROOT_ID
diff --git a/superset/canvas/definition/validation.py b/superset/canvas/definition/validation.py
new file mode 100644
index 00000000000..d4f70617ea7
--- /dev/null
+++ b/superset/canvas/definition/validation.py
@@ -0,0 +1,321 @@
+# 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.
+"""
+Whole-canvas validation and normalization.
+
+Checks the envelope, tree integrity, that every widget is placed only once, the
+nesting rules of widget types, each child's layout under its parent, and
+resolves grid collisions.
+
+A node whose widget no longer exists, or whose container type is no longer
+registered (e.g. its extension was removed), is kept as an unresolved
+placeholder: it and its children stay as stored, so the rest of the canvas can
+still be edited. Whether a new widget may be placed is checked by the caller.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+
+from pydantic import BaseModel, ValidationError
+from superset_core.canvas import CanvasLayoutRules, GridPlacement, WidgetResolver
+
+from superset.canvas.definition.grid import resolve_grid, span_errors
+from superset.canvas.definition.registry import (
+ get_widget_resolver,
+ layout_rules,
+ LayoutRulesRegistry,
+)
+from superset.canvas.definition.schemas import CanvasDefinition, ROOT_ID
+
+MAX_NODES = 500
+MAX_DEPTH = 8
+
+
+@dataclass(frozen=True)
+class Issue:
+ path: str
+ message: str
+
+
+class DefinitionValidationError(ValueError):
+ def __init__(self, issues: list[Issue]) -> None:
+ self.issues = issues
+ super().__init__("; ".join(f"{i.path}: {i.message}" for i in issues))
+
+
+def pointer(*parts: object) -> str:
+ return "/" + "/".join(
+ str(part).replace("~", "~0").replace("/", "~1") for part in parts
+ )
+
+
+def _pydantic_issues(prefix: tuple[object, ...], ex: ValidationError) -> list[Issue]:
+ return [
+ Issue(pointer(*prefix, *error["loc"]), error["msg"]) for error in ex.errors()
+ ]
+
+
+class _EmptyLayout(BaseModel):
+ model_config = {"extra": "forbid"}
+
+
+class _Validator:
+ def __init__(
+ self,
+ raw: dict[str, Any],
+ rules: LayoutRulesRegistry,
+ resolver: WidgetResolver,
+ ) -> None:
+ self.raw = raw
+ self.rules = rules
+ self.resolver = resolver
+ self.issues: list[Issue] = []
+ self.parents: dict[str, str] = {}
+ self.node_rules: dict[str, type[CanvasLayoutRules]] = {}
+ self.node_types: dict[str, str] = {}
+ self.unresolved: set[str] = set()
+
+ def fail(self, path: str, message: str) -> None:
+ self.issues.append(Issue(path, message))
+
+ def run(self) -> dict[str, Any]:
+ try:
+ canvas = CanvasDefinition.model_validate(self.raw)
+ except ValidationError as ex:
+ raise DefinitionValidationError(_pydantic_issues((), ex)) from ex
+ doc = canvas.model_dump(mode="json", by_alias=True, exclude_none=True)
+
+ if len(doc["nodes"]) > MAX_NODES:
+ self.fail("/nodes", f"more than {MAX_NODES} nodes")
+ self._check_tree(doc)
+ self._check_widgets(doc)
+ self._check_nesting()
+ self._check_depth(doc)
+ self._check_interactions(doc)
+ if not self.issues:
+ self._resolve_layouts(doc)
+ if self.issues:
+ raise DefinitionValidationError(self.issues)
+ return doc
+
+ def _children_of(self, doc: dict[str, Any], parent_id: str) -> list[str]:
+ if parent_id == ROOT_ID:
+ return doc["root"]["children"]
+ return doc["nodes"][parent_id].get("children") or []
+
+ def _check_tree(self, doc: dict[str, Any]) -> None:
+ nodes = doc["nodes"]
+ containers = [ROOT_ID, *(i for i, n in nodes.items() if "children" in n)]
+ for parent_id in containers:
+ for index, child_id in enumerate(self._children_of(doc, parent_id)):
+ path = (
+ pointer("root", "children", index)
+ if parent_id == ROOT_ID
+ else pointer("nodes", parent_id, "children", index)
+ )
+ if child_id not in nodes:
+ self.fail(path, f"unknown node {child_id!r}")
+ elif child_id in self.parents:
+ self.fail(path, f"node {child_id!r} has more than one parent")
+ else:
+ self.parents[child_id] = parent_id
+
+ # Every node has one parent, so any node the root cannot reach sits on
+ # a cycle or is an orphan.
+ reachable: set[str] = set()
+ stack = list(doc["root"]["children"])
+ while stack:
+ node_id = stack.pop()
+ if node_id in reachable or node_id not in nodes:
+ continue
+ reachable.add(node_id)
+ stack.extend(nodes[node_id].get("children") or [])
+ for node_id in nodes:
+ if node_id not in reachable:
+ self.fail(pointer("nodes", node_id), "not reachable from the root")
+
+ def _check_widgets(self, doc: dict[str, Any]) -> None:
+ nodes = doc["nodes"]
+ types = self.resolver.widget_types({node["widget"] for node in nodes.values()})
+ placed: dict[str, str] = {}
+ for node_id, node in nodes.items():
+ widget_id = node["widget"]
+ path = pointer("nodes", node_id, "widget")
+ if widget_id in placed:
+ self.fail(path, f"widget {widget_id!r} is already placed")
+ continue
+ placed[widget_id] = node_id
+ widget_type = types.get(widget_id)
+ if widget_type is None:
+ self.unresolved.add(node_id)
+ continue
+ rules = self.rules.get(widget_type)
+ if not rules.is_container and node.get("children"):
+ self.unresolved.add(node_id)
+ continue
+ self.node_rules[node_id] = rules
+ self.node_types[node_id] = widget_type
+ if rules.is_container:
+ node.setdefault("children", [])
+ else:
+ node.pop("children", None)
+
+ def _check_nesting(self) -> None:
+ for node_id, parent_id in self.parents.items():
+ rules = self.node_rules.get(node_id)
+ if rules is None:
+ continue
+ accepted: frozenset[str] | None = None
+ if parent_id == ROOT_ID:
+ parent_type = ROOT_ID
+ elif parent_id in self.node_rules:
+ parent_type = self.node_types[parent_id]
+ accepted = self.node_rules[parent_id].accepted_children
+ else:
+ continue
+ widget_type = self.node_types[node_id]
+ allowed = (accepted is None or widget_type in accepted) and (
+ rules.allowed_parents is None or parent_type in rules.allowed_parents
+ )
+ if not allowed:
+ self.fail(
+ pointer("nodes", node_id, "widget"),
+ f"a {widget_type} widget cannot be placed in {parent_type}",
+ )
+
+ def _check_interactions(self, doc: dict[str, Any]) -> None:
+ nodes = doc["nodes"]
+ for filter_id, scope in doc["interactions"]["filters"].items():
+ path = ("interactions", "filters", filter_id)
+ if filter_id not in nodes:
+ self.fail(pointer(*path), f"unknown node {filter_id!r}")
+ continue
+ rules = self.node_rules.get(filter_id)
+ if rules is not None and not rules.is_filter:
+ self.fail(pointer(*path), f"node {filter_id!r} is not a filter")
+ for field in ("targets", "exclude"):
+ for index, target in enumerate(scope[field]):
+ target_rules = self.node_rules.get(target)
+ if target not in nodes:
+ self.fail(
+ pointer(*path, field, index), f"unknown node {target!r}"
+ )
+ elif target_rules is not None and not target_rules.is_filterable:
+ self.fail(
+ pointer(*path, field, index),
+ f"node {target!r} cannot be filtered",
+ )
+
+ def _check_depth(self, doc: dict[str, Any]) -> None:
+ for node_id in doc["nodes"]:
+ depth, current = 1, node_id
+ while self.parents.get(current, ROOT_ID) != ROOT_ID and depth <= MAX_DEPTH:
+ current = self.parents[current]
+ depth += 1
+ if depth > MAX_DEPTH:
+ self.fail(
+ pointer("nodes", node_id), f"nested deeper than {MAX_DEPTH} levels"
+ )
+
+ def _resolve_layouts(self, doc: dict[str, Any]) -> None:
+ self._resolve_children(doc, ROOT_ID, doc["root"]["layout"]["columns"], None)
+ for node_id, rules in self.node_rules.items():
+ if rules.is_container:
+ self._resolve_children(
+ doc,
+ node_id,
+ rules.grid_columns,
+ rules.child_layout_model or _EmptyLayout,
+ )
+
+ def _resolve_children(
+ self,
+ doc: dict[str, Any],
+ parent_id: str,
+ columns: int | None,
+ child_model: type[BaseModel] | None,
+ ) -> None:
+ children = self._children_of(doc, parent_id)
+ model = GridPlacement if columns is not None else child_model
+ assert model is not None # noqa: S101
+ layouts: list[dict[str, Any]] = []
+ for child_id in children:
+ layout_path = ("nodes", child_id, "layout")
+ try:
+ layout = model.model_validate(
+ doc["nodes"][child_id]["layout"]
+ ).model_dump(mode="json", by_alias=True, exclude_none=True)
+ except ValidationError as ex:
+ self.issues.extend(_pydantic_issues(layout_path, ex))
+ continue
+ if columns is not None:
+ for message in [
+ *span_errors(layout, columns),
+ *self._size_errors(child_id, layout, columns),
+ ]:
+ self.fail(pointer(*layout_path), message)
+ layouts.append(layout)
+ doc["nodes"][child_id]["layout"] = layout
+
+ if columns is not None and len(layouts) == len(children):
+ _, stored = resolve_grid(layouts, columns)
+ for child_id, layout in zip(children, stored, strict=True):
+ doc["nodes"][child_id]["layout"] = layout
+
+ def _size_errors(
+ self, node_id: str, layout: dict[str, Any], columns: int
+ ) -> list[str]:
+ rules = self.node_rules.get(node_id)
+ if rules is None:
+ return []
+ errors = []
+ spans = {
+ "colSpan": (
+ layout.get("colSpan") or columns,
+ rules.min_col_span,
+ rules.max_col_span,
+ ),
+ "rowSpan": (
+ layout.get("rowSpan") or 1,
+ rules.min_row_span,
+ rules.max_row_span,
+ ),
+ }
+ for name, (span, minimum, maximum) in spans.items():
+ if minimum is not None and span < minimum:
+ errors.append(f"{name} {span} is below the minimum of {minimum}")
+ if maximum is not None and span > maximum:
+ errors.append(f"{name} {span} is above the maximum of {maximum}")
+ return errors
+
+
+def normalize_definition(
+ raw: dict[str, Any],
+ rules: LayoutRulesRegistry | None = None,
+ resolver: WidgetResolver | None = None,
+) -> dict[str, Any]:
+ """
+ Validate ``raw`` and return the normalized canvas to store.
+
+ Raises ``DefinitionValidationError`` listing every problem found, each with a
+ JSON-pointer path into the canvas.
+ """
+ return _Validator(
+ raw, rules or layout_rules, resolver or get_widget_resolver()
+ ).run()
diff --git a/superset/canvas/filters.py b/superset/canvas/filters.py
new file mode 100644
index 00000000000..76cd607bf89
--- /dev/null
+++ b/superset/canvas/filters.py
@@ -0,0 +1,83 @@
+# 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.
+"""List and access filters for canvases."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from flask import current_app
+from flask_babel import lazy_gettext as _
+from sqlalchemy import or_
+from sqlalchemy.orm.query import Query
+
+from superset import db, security_manager
+from superset.models.canvas import Canvas
+from superset.subjects.filters import (
+ EditableFilter,
+ subject_relation_exists_for_current_user,
+)
+from superset.subjects.models import canvas_editors, canvas_viewers
+from superset.utils.core import get_user_id
+from superset.views.base import BaseFilter
+
+
+class CanvasAccessFilter(BaseFilter): # pylint: disable=too-few-public-methods
+ """
+ The canvases the current user can see: admins see all; everyone else sees
+ the canvases they edit or view. Embedded guests see none.
+ """
+
+ def apply(self, query: Query, value: Any) -> Query:
+ if security_manager.is_guest_user():
+ return query.filter(Canvas.id < 0)
+ if security_manager.is_admin():
+ return query
+
+ filters = [
+ Canvas.id.in_(
+ db.session.query(table.c.canvas_id).filter(
+ subject_relation_exists_for_current_user(table)
+ )
+ )
+ for table in (canvas_editors, canvas_viewers)
+ ]
+ extra_filters = current_app.config.get("EXTRA_ACCESS_QUERY_FILTERS", {})
+ if (extra_canvases_filter := extra_filters.get("canvases")) and (
+ user_id := get_user_id()
+ ):
+ filters.append(Canvas.id.in_(extra_canvases_filter(user_id)))
+ return query.filter(or_(*filters))
+
+
+class CanvasEditableFilter(EditableFilter): # pylint: disable=too-few-public-methods
+ model = Canvas
+ editors_table = canvas_editors
+ editors_fk_column = "canvas_id"
+
+
+class CanvasAllTextFilter(BaseFilter): # pylint: disable=too-few-public-methods
+ name = _("All Text")
+ arg_name = "canvas_all_text"
+
+ def apply(self, query: Query, value: Any) -> Query:
+ if not value:
+ return query
+ ilike_value = f"%{value}%"
+ return query.filter(
+ or_(Canvas.title.ilike(ilike_value), Canvas.description.ilike(ilike_value))
+ )
diff --git a/superset/canvas/schemas.py b/superset/canvas/schemas.py
new file mode 100644
index 00000000000..633efb80e11
--- /dev/null
+++ b/superset/canvas/schemas.py
@@ -0,0 +1,61 @@
+# 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.
+"""Request schemas for the canvas REST API."""
+
+from marshmallow import fields, Schema
+from marshmallow.validate import Length
+
+get_delete_ids_schema = {"type": "array", "items": {"type": "integer"}}
+
+title_description = "The canvas' title."
+description_description = "What the canvas shows, for readers and agents."
+editors_description = (
+ "Subject ids (users, roles or groups) that can edit the canvas. Defaults "
+ "to the creating user."
+)
+viewers_description = "Subject ids (users, roles or groups) that can view the canvas."
+definition_description = (
+ "Initial canvas definition. Changed afterwards only through the "
+ "definition operations endpoint."
+)
+
+
+class CanvasPostSchema(Schema):
+ title = fields.String(
+ required=True,
+ validate=Length(1, 500),
+ metadata={"description": title_description},
+ )
+ description = fields.String(
+ allow_none=True, metadata={"description": description_description}
+ )
+ editors = fields.List(fields.Integer(metadata={"description": editors_description}))
+ viewers = fields.List(fields.Integer(metadata={"description": viewers_description}))
+ definition = fields.Dict(
+ allow_none=True, metadata={"description": definition_description}
+ )
+
+
+class CanvasPutSchema(Schema):
+ title = fields.String(
+ validate=Length(1, 500), metadata={"description": title_description}
+ )
+ description = fields.String(
+ allow_none=True, metadata={"description": description_description}
+ )
+ editors = fields.List(fields.Integer(metadata={"description": editors_description}))
+ viewers = fields.List(fields.Integer(metadata={"description": viewers_description}))
diff --git a/superset/models/__init__.py b/superset/commands/canvas/__init__.py
similarity index 86%
copy from superset/models/__init__.py
copy to superset/commands/canvas/__init__.py
index 1db3711227c..13a83393a91 100644
--- a/superset/models/__init__.py
+++ b/superset/commands/canvas/__init__.py
@@ -14,10 +14,3 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
-from . import ( # noqa: F401
- core,
- dynamic_plugins,
- purge_audit_log,
- sql_lab,
- user_attributes,
-)
diff --git a/superset/commands/canvas/apply_ops.py b/superset/commands/canvas/apply_ops.py
new file mode 100644
index 00000000000..9e4d1e7268b
--- /dev/null
+++ b/superset/commands/canvas/apply_ops.py
@@ -0,0 +1,189 @@
+# 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.
+"""Apply a batch of operations to a canvas definition."""
+
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass
+from functools import partial
+from typing import Any
+
+from superset import security_manager
+from superset.canvas.definition.ops import (
+ AppliedOperation,
+ apply_operations,
+ FieldGroup,
+ named_touches,
+ OperationError,
+ overlapping,
+ Touch,
+)
+from superset.canvas.definition.registry import get_widget_resolver
+from superset.canvas.definition.render import render_context
+from superset.canvas.definition.schemas import AddOp, Operation
+from superset.canvas.definition.scopes import resolve_filter_scopes
+from superset.canvas.definition.validation import (
+ DefinitionValidationError,
+ Issue,
+ pointer,
+)
+from superset.commands.base import BaseCommand
+from superset.commands.canvas.exceptions import (
+ CanvasForbiddenError,
+ CanvasNotFoundError,
+ CanvasUpdateFailedError,
+ DefinitionConflictError,
+ DefinitionInvalidError,
+)
+from superset.daos.canvas import CanvasDAO
+from superset.exceptions import SupersetSecurityException
+from superset.realtime.publish import publish_realtime
+from superset.utils import json
+from superset.utils.core import get_user_id
+from superset.utils.decorators import on_error, transaction
+
+logger = logging.getLogger(__name__)
+
+# Revisions of operation log kept per canvas. A write based on an older
+# revision cannot be checked for overlap and is rejected as stale.
+OP_LOG_RETAINED_REVISIONS = 500
+
+
+@dataclass
+class ApplyResult:
+ revision: int
+ # Operations as applied; ``add`` entries carry the new node's id.
+ ops: list[dict[str, Any]]
+ # Node ids each filter drives after the write.
+ filter_scopes: dict[str, list[str]]
+ # Resolved placements and widget types after the write.
+ render_context: dict[str, Any]
+
+
+def placement_issues(ops: list[Operation]) -> list[Issue]:
+ """Widgets being added that the current user may not place."""
+ added = [(index, op) for index, op in enumerate(ops) if isinstance(op, AddOp)]
+ if not added:
+ return []
+ allowed = get_widget_resolver().placeable({op.widget for _, op in added})
+ return [
+ Issue(pointer("ops", index, "widget"), "unknown widget, or no access to it")
+ for index, op in added
+ if op.widget not in allowed
+ ]
+
+
+class ApplyCanvasOperationsCommand(BaseCommand):
+ """
+ Apply operations to the latest definition, as long as no change made since
+ ``base_revision`` touched the same nodes and field groups.
+ """
+
+ def __init__(
+ self, canvas_id: int, base_revision: int, ops: list[Operation]
+ ) -> None:
+ self._canvas_id = canvas_id
+ self._base_revision = base_revision
+ self._ops = ops
+
+ def run(self) -> ApplyResult:
+ result = self._apply()
+ publish_canvas_changed(self._canvas_id)
+ return result
+
+ @transaction(on_error=partial(on_error, reraise=CanvasUpdateFailedError))
+ def _apply(self) -> ApplyResult:
+ self.validate()
+ canvas = CanvasDAO.lock(self._canvas_id)
+ current = canvas.revision
+ if self._base_revision != current:
+ self._check_overlap(current, named_touches(self._ops))
+
+ try:
+ definition, applied = apply_operations(CanvasDAO.load(canvas), self._ops)
+ except OperationError as ex:
+ raise DefinitionInvalidError(str(ex), operation_index=ex.index) from ex
+ except DefinitionValidationError as ex:
+ raise DefinitionInvalidError(
+ "The operations produce an invalid definition", issues=ex.issues
+ ) from ex
+
+ if self._base_revision != current:
+ self._check_overlap(current, applied_touches(applied))
+
+ revision = current + 1
+ canvas.definition = json.dumps(definition)
+ canvas.definition_version = definition["version"]
+ canvas.revision = revision
+ CanvasDAO.log(canvas.id, revision, applied, get_user_id())
+ CanvasDAO.prune(canvas.id, revision - OP_LOG_RETAINED_REVISIONS)
+ return ApplyResult(
+ revision=revision,
+ ops=[a.op for a in applied],
+ filter_scopes=resolve_filter_scopes(definition),
+ render_context=render_context(definition),
+ )
+
+ def _check_overlap(self, current: int, touches: list[Touch]) -> None:
+ if self._base_revision > current:
+ raise DefinitionConflictError(current, [], stale=True)
+ oldest = CanvasDAO.oldest_logged_revision(self._canvas_id)
+ if oldest is None or oldest > self._base_revision + 1:
+ raise DefinitionConflictError(current, [], stale=True)
+ earlier = [
+ Touch(node_id, FieldGroup(group))
+ for row in CanvasDAO.ops_since(self._canvas_id, self._base_revision)
+ for node_id, group in row.touched_pairs
+ ]
+ if conflicts := overlapping(earlier, touches):
+ raise DefinitionConflictError(current, sorted(conflicts), stale=False)
+
+ def validate(self) -> None:
+ canvas = CanvasDAO.find_by_id(self._canvas_id)
+ if canvas is None:
+ raise CanvasNotFoundError()
+ try:
+ security_manager.raise_for_editorship(canvas)
+ except SupersetSecurityException as ex:
+ raise CanvasForbiddenError() from ex
+ if issues := placement_issues(self._ops):
+ raise DefinitionInvalidError(
+ "The operations add widgets that cannot be placed", issues=issues
+ )
+
+
+def applied_touches(applied: list[AppliedOperation]) -> list[Touch]:
+ return [touch for operation in applied for touch in operation.touched]
+
+
+def publish_canvas_changed(canvas_id: int) -> None:
+ """
+ Best-effort nudge that a canvas changed.
+
+ Like other ``entity.changed`` messages it carries only the entity id; open
+ viewers fetch the changes through the authorized REST API, which is also
+ the fallback when no realtime backend is configured.
+ """
+ try:
+ publish_realtime(
+ topic="entity.changed",
+ scope="authenticated_global",
+ payload={"entity_type": "canvas", "id": canvas_id},
+ )
+ except Exception: # noqa: BLE001 pylint: disable=broad-except
+ logger.warning("Failed to publish change for canvas %s", canvas_id)
diff --git a/superset/commands/canvas/create.py b/superset/commands/canvas/create.py
new file mode 100644
index 00000000000..6b5b856c8ab
--- /dev/null
+++ b/superset/commands/canvas/create.py
@@ -0,0 +1,90 @@
+# 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.
+"""Create a canvas."""
+
+from __future__ import annotations
+
+from functools import partial
+from typing import Any
+
+from marshmallow import ValidationError
+
+from superset.canvas.definition.registry import get_widget_resolver
+from superset.canvas.definition.schemas import empty_definition
+from superset.canvas.definition.validation import (
+ DefinitionValidationError,
+ normalize_definition,
+)
+from superset.commands.base import BaseCommand, CreateMixin
+from superset.commands.canvas.exceptions import (
+ CanvasCreateFailedError,
+ CanvasInvalidError,
+)
+from superset.commands.utils import populate_subjects
+from superset.daos.canvas import CanvasDAO
+from superset.models.canvas import Canvas
+from superset.utils import json
+from superset.utils.decorators import on_error, transaction
+
+
+class CreateCanvasCommand(CreateMixin, BaseCommand):
+ def __init__(self, data: dict[str, Any]) -> None:
+ self._properties = data.copy()
+
+ @transaction(on_error=partial(on_error, reraise=CanvasCreateFailedError))
+ def run(self) -> Canvas:
+ self.validate()
+ return CanvasDAO.create(attributes=self._properties)
+
+ def validate(self) -> None:
+ exceptions: list[ValidationError] = []
+ # Defaults the editors to the requesting user, and resolves viewers.
+ populate_subjects(self._properties, exceptions)
+ self._validate_definition(exceptions)
+ if exceptions:
+ raise CanvasInvalidError(exceptions=exceptions)
+
+ def _validate_definition(self, exceptions: list[ValidationError]) -> None:
+ raw = self._properties.pop("definition", None) or empty_definition()
+ try:
+ definition = normalize_definition(raw)
+ except DefinitionValidationError as ex:
+ exceptions.append(
+ ValidationError(
+ {issue.path: [issue.message] for issue in ex.issues},
+ field_name="definition",
+ )
+ )
+ return
+ widgets = {node["widget"] for node in definition["nodes"].values()}
+ if hidden := widgets - get_widget_resolver().placeable(widgets):
+ exceptions.append(
+ ValidationError(
+ {
+ f"/nodes/{node_id}/widget": [
+ "unknown widget, or no access to it"
+ ]
+ for node_id, node in definition["nodes"].items()
+ if node["widget"] in hidden
+ },
+ field_name="definition",
+ )
+ )
+ return
+ self._properties["definition"] = json.dumps(definition)
+ self._properties["definition_version"] = definition["version"]
+ self._properties["revision"] = 1
diff --git a/superset/commands/canvas/delete.py b/superset/commands/canvas/delete.py
new file mode 100644
index 00000000000..2f9c96659f7
--- /dev/null
+++ b/superset/commands/canvas/delete.py
@@ -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.
+"""Delete canvases."""
+
+from __future__ import annotations
+
+from functools import partial
+
+from superset import security_manager
+from superset.commands.base import BaseCommand
+from superset.commands.canvas.exceptions import (
+ CanvasDeleteFailedError,
+ CanvasForbiddenError,
+ CanvasNotFoundError,
+)
+from superset.daos.canvas import CanvasDAO
+from superset.exceptions import SupersetSecurityException
+from superset.models.canvas import Canvas
+from superset.utils.decorators import on_error, transaction
+
+
+class DeleteCanvasCommand(BaseCommand):
+ """Delete canvases and their operation logs. Their widgets are not deleted."""
+
+ def __init__(self, model_ids: list[int]) -> None:
+ self._model_ids = model_ids
+ self._models: list[Canvas] = []
+
+ @transaction(on_error=partial(on_error, reraise=CanvasDeleteFailedError))
+ def run(self) -> None:
+ self.validate()
+ CanvasDAO.delete(self._models)
+
+ def validate(self) -> None:
+ self._models = CanvasDAO.find_by_ids(self._model_ids)
+ if len(self._models) != len(set(self._model_ids)):
+ raise CanvasNotFoundError()
+ for canvas in self._models:
+ try:
+ security_manager.raise_for_editorship(canvas)
+ except SupersetSecurityException as ex:
+ raise CanvasForbiddenError() from ex
diff --git a/superset/commands/canvas/exceptions.py b/superset/commands/canvas/exceptions.py
new file mode 100644
index 00000000000..b64f95a3810
--- /dev/null
+++ b/superset/commands/canvas/exceptions.py
@@ -0,0 +1,109 @@
+# 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 __future__ import annotations
+
+from typing import Any
+
+from flask_babel import lazy_gettext as _
+
+from superset.canvas.definition.validation import Issue
+from superset.commands.exceptions import (
+ CommandException,
+ CommandInvalidError,
+ CreateFailedError,
+ DeleteFailedError,
+ ForbiddenError,
+ ObjectNotFoundError,
+ UpdateFailedError,
+)
+
+
+class CanvasNotFoundError(ObjectNotFoundError):
+ def __init__(self) -> None:
+ super().__init__("Canvas")
+
+
+class CanvasForbiddenError(ForbiddenError):
+ message = _("Changing this canvas is forbidden")
+
+
+class CanvasInvalidError(CommandInvalidError):
+ message = _("Canvas parameters are invalid.")
+
+
+class CanvasCreateFailedError(CreateFailedError):
+ message = _("Canvas could not be created.")
+
+
+class CanvasUpdateFailedError(UpdateFailedError):
+ message = _("Canvas could not be updated.")
+
+
+class CanvasDeleteFailedError(DeleteFailedError):
+ message = _("Canvas could not be deleted.")
+
+
+class DefinitionConflictError(CommandException):
+ """The write is based on a revision that later changes overlap."""
+
+ status = 409
+
+ def __init__(self, revision: int, conflicts: list[str], stale: bool) -> None:
+ self.revision = revision
+ self.conflicts = conflicts
+ self.stale = stale
+ message = (
+ _("The canvas changed too much since your revision; reload it.")
+ if stale
+ else _("Nodes you edited were changed since your revision.")
+ )
+ super().__init__(message)
+
+ def to_payload(self) -> dict[str, Any]:
+ return {
+ "message": str(self.message),
+ "revision": self.revision,
+ "conflicts": self.conflicts,
+ "stale": self.stale,
+ }
+
+
+class DefinitionInvalidError(CommandException):
+ """An operation could not apply, or its result is not a valid definition."""
+
+ status = 422
+
+ def __init__(
+ self,
+ message: str,
+ issues: list[Issue] | None = None,
+ operation_index: int | None = None,
+ ) -> None:
+ self.issues = issues or []
+ self.operation_index = operation_index
+ super().__init__(message)
+
+ def to_payload(self) -> dict[str, Any]:
+ payload: dict[str, Any] = {
+ "message": str(self.message),
+ "errors": [
+ {"path": issue.path, "message": issue.message} for issue in self.issues
+ ],
+ }
+ if self.operation_index is not None:
+ payload["operation"] = self.operation_index
+ return payload
diff --git a/superset/commands/canvas/update.py b/superset/commands/canvas/update.py
new file mode 100644
index 00000000000..421c0fa11f1
--- /dev/null
+++ b/superset/commands/canvas/update.py
@@ -0,0 +1,71 @@
+# 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.
+"""Update a canvas' properties and sharing."""
+
+from __future__ import annotations
+
+from functools import partial
+from typing import Any
+
+from marshmallow import ValidationError
+
+from superset import security_manager
+from superset.commands.base import BaseCommand, UpdateMixin
+from superset.commands.canvas.exceptions import (
+ CanvasForbiddenError,
+ CanvasInvalidError,
+ CanvasNotFoundError,
+ CanvasUpdateFailedError,
+)
+from superset.commands.utils import compute_subjects
+from superset.daos.canvas import CanvasDAO
+from superset.exceptions import SupersetSecurityException
+from superset.models.canvas import Canvas
+from superset.utils.decorators import on_error, transaction
+
+
+class UpdateCanvasCommand(UpdateMixin, BaseCommand):
+ """
+ Update title, description, editors and viewers. The definition changes
+ only through operations (``ApplyCanvasOperationsCommand``).
+ """
+
+ def __init__(self, model_id: int, data: dict[str, Any]) -> None:
+ self._model_id = model_id
+ self._properties = data.copy()
+ self._model: Canvas | None = None
+
+ @transaction(on_error=partial(on_error, reraise=CanvasUpdateFailedError))
+ def run(self) -> Canvas:
+ self.validate()
+ assert self._model is not None # noqa: S101
+ return CanvasDAO.update(self._model, self._properties)
+
+ def validate(self) -> None:
+ self._model = CanvasDAO.find_by_id(self._model_id)
+ if self._model is None:
+ raise CanvasNotFoundError()
+ # Editorship is checked on the stored canvas first, so a non-editor
+ # cannot add themselves to the editors.
+ try:
+ security_manager.raise_for_editorship(self._model)
+ except SupersetSecurityException as ex:
+ raise CanvasForbiddenError() from ex
+ exceptions: list[ValidationError] = []
+ compute_subjects(self._model, self._properties, exceptions)
+ if exceptions:
+ raise CanvasInvalidError(exceptions=exceptions)
diff --git a/superset/config.py b/superset/config.py
index bd2fb106f1a..a59f0cc3b56 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -691,6 +691,10 @@ DEFAULT_FEATURE_FLAGS: dict[str, bool] = {
# Enables experimental tabs UI for Alerts and Reports
# @lifecycle: development
"ALERT_REPORT_TABS": False,
+ # Canvas: the AI-first successor to dashboards, whose widgets are placed on
+ # a canvas. When off, its API is not registered; existing canvases are kept.
+ # @lifecycle: development
+ "CANVAS": False,
# Enables experimental chart plugins
# @lifecycle: development
"CHART_PLUGINS_EXPERIMENTAL": False,
@@ -3280,6 +3284,7 @@ SUBJECTS_RELATED_TYPES_RLS: list[SubjectType] | None = [
]
SUBJECTS_RELATED_TYPES_ALERT_REPORTS: list[SubjectType] | None = None
SUBJECTS_RELATED_TYPES_THEMES: list[SubjectType] | None = None
+SUBJECTS_RELATED_TYPES_CANVASES: list[SubjectType] | None = None
# Extra dynamic query filters make it possible to limit which objects are shown
diff --git a/superset/daos/canvas.py b/superset/daos/canvas.py
new file mode 100644
index 00000000000..069f66f3bbc
--- /dev/null
+++ b/superset/daos/canvas.py
@@ -0,0 +1,96 @@
+# 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.
+"""Data access for canvases and their operation log."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from sqlalchemy import func
+
+from superset import db
+from superset.canvas.definition.ops import AppliedOperation
+from superset.canvas.filters import CanvasAccessFilter
+from superset.daos.base import BaseDAO
+from superset.models.canvas import Canvas, CanvasOp
+from superset.utils import json
+
+
+class CanvasDAO(BaseDAO[Canvas]):
+ base_filter = CanvasAccessFilter
+
+ @staticmethod
+ def lock(canvas_id: int) -> Canvas:
+ """Reload the canvas row under a write lock for the transaction."""
+ return (
+ db.session.query(Canvas)
+ .filter(Canvas.id == canvas_id)
+ .with_for_update()
+ .populate_existing()
+ .one()
+ )
+
+ @staticmethod
+ def load(canvas: Canvas) -> dict[str, Any]:
+ return json.loads(canvas.definition)
+
+ @staticmethod
+ def ops_since(canvas_id: int, revision: int) -> list[CanvasOp]:
+ """Logged operations after ``revision``, in application order."""
+ return (
+ db.session.query(CanvasOp)
+ .filter(CanvasOp.canvas_id == canvas_id, CanvasOp.revision > revision)
+ .order_by(CanvasOp.revision, CanvasOp.sequence)
+ .all()
+ )
+
+ @staticmethod
+ def oldest_logged_revision(canvas_id: int) -> int | None:
+ return (
+ db.session.query(func.min(CanvasOp.revision))
+ .filter(CanvasOp.canvas_id == canvas_id)
+ .scalar()
+ )
+
+ @staticmethod
+ def log(
+ canvas_id: int,
+ revision: int,
+ applied: list[AppliedOperation],
+ user_id: int | None,
+ ) -> None:
+ db.session.add_all(
+ CanvasOp(
+ canvas_id=canvas_id,
+ revision=revision,
+ sequence=sequence,
+ op=json.dumps(operation.op),
+ touched=json.dumps(
+ [[touch.node_id, touch.group.value] for touch in operation.touched]
+ ),
+ created_by_fk=user_id,
+ )
+ for sequence, operation in enumerate(applied)
+ )
+
+ @staticmethod
+ def prune(canvas_id: int, keep_after_revision: int) -> None:
+ """Drop logged operations at or before ``keep_after_revision``."""
+ db.session.query(CanvasOp).filter(
+ CanvasOp.canvas_id == canvas_id,
+ CanvasOp.revision <= keep_after_revision,
+ ).delete(synchronize_session=False)
diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py
index f790f9049ad..1895fa19b2c 100644
--- a/superset/initialization/__init__.py
+++ b/superset/initialization/__init__.py
@@ -296,6 +296,10 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
appbuilder.add_api(ReportExecutionLogRestApi)
appbuilder.add_api(RLSRestApi)
appbuilder.add_api(SavedQueryRestApi)
+ if feature_flag_manager.is_feature_enabled("CANVAS"):
+ from superset.canvas.api import CanvasRestApi
+
+ appbuilder.add_api(CanvasRestApi)
if feature_flag_manager.is_feature_enabled("SEMANTIC_LAYERS"):
from superset.semantic_layers.api import (
SemanticLayerRestApi,
diff --git a/superset/migrations/versions/2026-09-30_12-00_6d55f8a0851e_add_canvases.py b/superset/migrations/versions/2026-09-30_12-00_6d55f8a0851e_add_canvases.py
new file mode 100644
index 00000000000..d4676e42f6e
--- /dev/null
+++ b/superset/migrations/versions/2026-09-30_12-00_6d55f8a0851e_add_canvases.py
@@ -0,0 +1,125 @@
+# 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.
+"""Add canvases.
+
+Creates ``canvases`` (a canvas and its definition), ``canvas_editors`` and
+``canvas_viewers`` (sharing, like dashboards), and ``canvas_ops`` (the log of
+applied definition operations per revision).
+
+Revision ID: 6d55f8a0851e
+Revises: 95d8a99c822e
+Create Date: 2026-09-30 12:00:00.000000
+
+"""
+
+import sqlalchemy as sa
+from sqlalchemy_utils import UUIDType
+
+from superset.migrations.shared.utils import create_table, drop_table
+from superset.utils.core import MediumText
+
+revision = "6d55f8a0851e"
+down_revision = "95d8a99c822e"
+
+
+def _subject_table(name: str) -> None:
+ create_table(
+ name,
+ sa.Column("id", sa.Integer(), primary_key=True),
+ sa.Column("subject_id", sa.Integer(), nullable=False),
+ sa.Column("canvas_id", sa.Integer(), nullable=False),
+ sa.ForeignKeyConstraint(
+ ["subject_id"],
+ ["subjects.id"],
+ name=f"fk_{name}_subject_id_subjects",
+ ondelete="CASCADE",
+ ),
+ sa.ForeignKeyConstraint(
+ ["canvas_id"],
+ ["canvases.id"],
+ name=f"fk_{name}_canvas_id_canvases",
+ ondelete="CASCADE",
+ ),
+ sa.UniqueConstraint("subject_id", "canvas_id", name=f"uq_{name}"),
+ )
+
+
+def upgrade() -> None:
+ create_table(
+ "canvases",
+ sa.Column("id", sa.Integer(), primary_key=True),
+ sa.Column("uuid", UUIDType(binary=True), nullable=True),
+ sa.Column("created_on", sa.DateTime(), nullable=True),
+ sa.Column("changed_on", sa.DateTime(), nullable=True),
+ sa.Column("created_by_fk", sa.Integer(), nullable=True),
+ sa.Column("changed_by_fk", sa.Integer(), nullable=True),
+ sa.Column("title", sa.String(500), nullable=False),
+ sa.Column("description", sa.Text(), nullable=True),
+ sa.Column("definition", MediumText(), nullable=False),
+ sa.Column("definition_version", sa.Integer(), nullable=False),
+ sa.Column("revision", sa.BigInteger(), nullable=False),
+ sa.ForeignKeyConstraint(
+ ["created_by_fk"], ["ab_user.id"], name="fk_canvases_created_by_fk_ab_user"
+ ),
+ sa.ForeignKeyConstraint(
+ ["changed_by_fk"], ["ab_user.id"], name="fk_canvases_changed_by_fk_ab_user"
+ ),
+ sa.UniqueConstraint("uuid", name="uq_canvases_uuid"),
+ )
+ _subject_table("canvas_editors")
+ _subject_table("canvas_viewers")
+ create_table(
+ "canvas_ops",
+ sa.Column(
+ "id",
+ sa.BigInteger().with_variant(sa.Integer(), "sqlite"),
+ primary_key=True,
+ autoincrement=True,
+ ),
+ sa.Column("canvas_id", sa.Integer(), nullable=False),
+ sa.Column("revision", sa.BigInteger(), nullable=False),
+ sa.Column("sequence", sa.Integer(), nullable=False),
+ sa.Column("op", sa.Text(), nullable=False),
+ sa.Column("touched", sa.Text(), nullable=False),
+ sa.Column("created_on", sa.DateTime(), nullable=False),
+ sa.Column("created_by_fk", sa.Integer(), nullable=True),
+ sa.ForeignKeyConstraint(
+ ["canvas_id"],
+ ["canvases.id"],
+ name="fk_canvas_ops_canvas_id_canvases",
+ ondelete="CASCADE",
+ ),
+ sa.ForeignKeyConstraint(
+ ["created_by_fk"],
+ ["ab_user.id"],
+ name="fk_canvas_ops_created_by_fk_ab_user",
+ ondelete="SET NULL",
+ ),
+ sa.UniqueConstraint(
+ "canvas_id",
+ "revision",
+ "sequence",
+ name="uq_canvas_ops_revision_sequence",
+ ),
+ )
+
+
+def downgrade() -> None:
+ drop_table("canvas_ops")
+ drop_table("canvas_viewers")
+ drop_table("canvas_editors")
+ drop_table("canvases")
diff --git a/superset/models/__init__.py b/superset/models/__init__.py
index 1db3711227c..c9047a9d6e1 100644
--- a/superset/models/__init__.py
+++ b/superset/models/__init__.py
@@ -15,6 +15,7 @@
# specific language governing permissions and limitations
# under the License.
from . import ( # noqa: F401
+ canvas,
core,
dynamic_plugins,
purge_audit_log,
diff --git a/superset/models/canvas.py b/superset/models/canvas.py
new file mode 100644
index 00000000000..9e8ac71b154
--- /dev/null
+++ b/superset/models/canvas.py
@@ -0,0 +1,127 @@
+# 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.
+"""Canvas: an AI-first dashboard whose widgets are placed on a canvas."""
+
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Any
+
+from flask_appbuilder import Model
+from sqlalchemy import (
+ BigInteger,
+ Column,
+ DateTime,
+ ForeignKey,
+ Integer,
+ String,
+ Text,
+ UniqueConstraint,
+)
+from sqlalchemy.orm import relationship
+
+from superset.models.helpers import AuditMixinNullable, UUIDMixin
+from superset.subjects.models import canvas_editors, canvas_viewers, Subject
+from superset.utils import core as utils, json
+
+
+class Canvas(AuditMixinNullable, UUIDMixin, Model):
+ """
+ A canvas and where its widgets are placed.
+
+ Widgets are separate entities; ``definition`` only references them by id
+ and holds their placement and filter scopes (see ``superset.canvas``).
+ The integer id plus ``uuid`` shape matches the versioned asset models, so
+ version history can be enabled later by adding ``__versioned__``.
+ """
+
+ __tablename__ = "canvases"
+
+ id = Column(Integer, primary_key=True)
+ title = Column(String(500), nullable=False)
+ description = Column(Text)
+ # The canvas definition as JSON.
+ definition = Column(utils.MediumText(), nullable=False)
+ definition_version = Column(Integer, nullable=False)
+ # Bumped by every accepted definition write; the base for stale-edit
+ # detection.
+ revision = Column(BigInteger, nullable=False, default=1)
+
+ editors = relationship(Subject, secondary=canvas_editors, passive_deletes=True)
+ viewers = relationship(Subject, secondary=canvas_viewers, passive_deletes=True)
+ ops = relationship(
+ "CanvasOp",
+ back_populates="canvas",
+ cascade="all, delete-orphan",
+ passive_deletes=True,
+ )
+
+ def __repr__(self) -> str:
+ return f"Canvas<{self.id}>"
+
+ @property
+ def url(self) -> str:
+ return f"/canvas/{self.id}/"
+
+
+class CanvasOp(Model):
+ """
+ One applied operation on a canvas definition.
+
+ A write bumps the canvas' ``revision`` once and logs each of its
+ operations here under that revision, in order. ``touched`` lists the
+ ``[node_id, field_group]`` pairs the operation changed; later writes based
+ on an older revision are checked against them. Rows are also the feed that
+ lets open viewers catch up on changes since a revision.
+ """
+
+ __tablename__ = "canvas_ops"
+ __table_args__ = (
+ UniqueConstraint(
+ "canvas_id",
+ "revision",
+ "sequence",
+ name="uq_canvas_ops_revision_sequence",
+ ),
+ )
+
+ # An append-only log keyed by (canvas, revision, sequence), never exposed
+ # by id, so it keeps an integer key like the versioning tables.
+ id = Column(BigInteger().with_variant(Integer, "sqlite"), primary_key=True)
+ canvas_id = Column(
+ Integer,
+ ForeignKey("canvases.id", ondelete="CASCADE"),
+ nullable=False,
+ )
+ revision = Column(BigInteger, nullable=False)
+ sequence = Column(Integer, nullable=False)
+ op = Column(Text, nullable=False)
+ touched = Column(Text, nullable=False)
+ created_on = Column(DateTime, default=datetime.now, nullable=False)
+ created_by_fk = Column(
+ Integer, ForeignKey("ab_user.id", ondelete="SET NULL"), nullable=True
+ )
+
+ canvas = relationship(Canvas, back_populates="ops")
+
+ @property
+ def op_dict(self) -> dict[str, Any]:
+ return json.loads(self.op)
+
+ @property
+ def touched_pairs(self) -> list[tuple[str, str]]:
+ return [(node_id, group) for node_id, group in json.loads(self.touched)]
diff --git a/superset/subjects/models.py b/superset/subjects/models.py
index 59fe4d64293..a8adda32d29 100644
--- a/superset/subjects/models.py
+++ b/superset/subjects/models.py
@@ -220,3 +220,41 @@ theme_editors = Table(
),
UniqueConstraint("subject_id", "theme_id"),
)
+
+canvas_editors = Table(
+ "canvas_editors",
+ metadata,
+ Column("id", Integer, primary_key=True),
+ Column(
+ "subject_id",
+ Integer,
+ ForeignKey("subjects.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ Column(
+ "canvas_id",
+ Integer,
+ ForeignKey("canvases.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ UniqueConstraint("subject_id", "canvas_id"),
+)
+
+canvas_viewers = Table(
+ "canvas_viewers",
+ metadata,
+ Column("id", Integer, primary_key=True),
+ Column(
+ "subject_id",
+ Integer,
+ ForeignKey("subjects.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ Column(
+ "canvas_id",
+ Integer,
+ ForeignKey("canvases.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ UniqueConstraint("subject_id", "canvas_id"),
+)
diff --git a/superset/models/__init__.py b/tests/unit_tests/canvas/__init__.py
similarity index 86%
copy from superset/models/__init__.py
copy to tests/unit_tests/canvas/__init__.py
index 1db3711227c..13a83393a91 100644
--- a/superset/models/__init__.py
+++ b/tests/unit_tests/canvas/__init__.py
@@ -14,10 +14,3 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
-from . import ( # noqa: F401
- core,
- dynamic_plugins,
- purge_audit_log,
- sql_lab,
- user_attributes,
-)
diff --git a/tests/unit_tests/canvas/api_test.py b/tests/unit_tests/canvas/api_test.py
new file mode 100644
index 00000000000..bb95e94f17f
--- /dev/null
+++ b/tests/unit_tests/canvas/api_test.py
@@ -0,0 +1,298 @@
+# 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 __future__ import annotations
+
+from collections.abc import Iterator
+from typing import Any
+from unittest.mock import MagicMock
+
+import pytest
+from pytest_mock import MockerFixture
+from sqlalchemy.orm.session import Session
+
+from superset.exceptions import SupersetSecurityException
+from superset.utils import json
+from tests.unit_tests.canvas.fixtures import FakeResolver
+
+# The canvas API only exists while its feature flag is on.
+pytestmark = pytest.mark.parametrize(
+ "app", [{"FEATURE_FLAGS": {"CANVAS": True}}], indirect=True
+)
+
+BASE = "/api/v1/canvas"
+URL = f"{BASE}/100/definition"
+
+
+@pytest.fixture
+def canvas(
+ session: Session, mocker: MockerFixture, widgets: FakeResolver
+) -> Iterator[Any]:
+ from superset.canvas.definition.schemas import empty_definition
+ from superset.models.canvas import Canvas
+
+ Canvas.metadata.create_all(session.get_bind()) # pylint: disable=no-member
+ canvas = Canvas(
+ id=100,
+ title="Exec overview",
+ definition=json.dumps(empty_definition()),
+ definition_version=1,
+ revision=1,
+ )
+ session.add(canvas)
+ session.commit()
+
+ # Admins see every canvas; access rules have their own test below.
+ mocker.patch("superset.canvas.filters.security_manager.is_admin", return_value=True)
+ mocker.patch("superset.security_manager.raise_for_editorship")
+ yield canvas
+ session.rollback()
+
+
+@pytest.fixture
+def published(mocker: MockerFixture) -> MagicMock:
+ return mocker.patch("superset.commands.canvas.apply_ops.publish_realtime")
+
+
+def patch_ops(client: Any, base_revision: int, *ops: dict[str, Any]) -> Any:
+ return client.patch(URL, json={"base_revision": base_revision, "ops": list(ops)})
+
+
+def add(widget: str, **extra: Any) -> dict[str, Any]:
+ return {"op": "add", "widget": widget, **extra}
+
+
+def test_create_and_read_a_canvas(
+ client: Any,
+ full_api_access: None,
+ canvas: Any,
+ mocker: MockerFixture,
+) -> None:
+ mocker.patch("superset.commands.canvas.create.populate_subjects")
+
+ response = client.post(BASE + "/", json={"title": "Ops breakdown"})
+
+ assert response.status_code == 201
+ new_id = response.json["id"]
+ definition = client.get(f"{BASE}/{new_id}/definition").json["result"]
+ assert definition["revision"] == 1
+ assert definition["definition"]["nodes"] == {}
+
+
+def test_create_validates_the_body(
+ client: Any, full_api_access: None, canvas: Any
+) -> None:
+ assert client.post(BASE + "/", json={}).status_code == 400
+
+
+def test_update_and_delete(client: Any, full_api_access: None, canvas: Any) -> None:
+ response = client.put(f"{BASE}/100", json={"title": "Renamed"})
+
+ assert response.status_code == 200
+ assert canvas.title == "Renamed"
+ assert client.delete(f"{BASE}/100").status_code == 200
+ assert client.get(URL).status_code == 404
+
+
+def test_get_definition(client: Any, full_api_access: None, canvas: Any) -> None:
+ response = client.get(URL)
+
+ assert response.status_code == 200
+ assert response.json["result"]["revision"] == 1
+ assert response.json["result"]["definition"]["nodes"] == {}
+ assert response.json["result"]["filterScopes"] == {}
+ assert response.json["result"]["placements"] == {}
+ assert response.json["result"]["widgetTypes"] == {}
+
+
+def test_apply_operations_bumps_revision_logs_and_publishes(
+ client: Any, full_api_access: None, canvas: Any, published: MagicMock
+) -> None:
+ response = patch_ops(client, 1, add("filter"), add("chart-1"))
+
+ assert response.status_code == 200
+ result = response.json["result"]
+ assert result["revision"] == 2
+ filter_id, chart_id = (op["id"] for op in result["ops"])
+ assert result["filterScopes"] == {filter_id: [chart_id]}
+ assert json.loads(canvas.definition)["root"]["children"] == [filter_id, chart_id]
+ assert canvas.revision == 2
+ published.assert_called_once_with(
+ topic="entity.changed",
+ scope="authenticated_global",
+ payload={"entity_type": "canvas", "id": 100},
+ )
+
+ changes = client.get(f"{URL}/changes?since=1").json["result"]
+ assert changes["revision"] == 2
+ assert [op["id"] for op in changes["changes"][0]["ops"]] == [filter_id, chart_id]
+
+
+def test_non_overlapping_stale_write_is_merged(
+ client: Any, full_api_access: None, canvas: Any, published: MagicMock
+) -> None:
+ first, second = (
+ op["id"]
+ for op in patch_ops(client, 1, add("chart-1"), add("chart-2")).json["result"][
+ "ops"
+ ]
+ )
+ place_first = {"op": "place", "id": first, "layout": {"colSpan": 8}}
+ assert patch_ops(client, 2, place_first).status_code == 200
+
+ # A second writer still at revision 2 resizes the other node.
+ response = patch_ops(
+ client, 2, {"op": "place", "id": second, "layout": {"colSpan": 16}}
+ )
+
+ assert response.status_code == 200
+ nodes = json.loads(canvas.definition)["nodes"]
+ assert nodes[first]["layout"] == {"colSpan": 8}
+ assert nodes[second]["layout"] == {"colSpan": 16}
+
+
+def test_overlapping_stale_write_is_rejected(
+ client: Any, full_api_access: None, canvas: Any, published: MagicMock
+) -> None:
+ node_id = patch_ops(client, 1, add("chart-1")).json["result"]["ops"][0]["id"]
+ assert patch_ops(client, 2, {"op": "remove", "id": node_id}).status_code == 200
+
+ response = patch_ops(
+ client, 2, {"op": "place", "id": node_id, "layout": {"colSpan": 8}}
+ )
+
+ assert response.status_code == 409
+ assert response.json == {
+ "message": "Nodes you edited were changed since your revision.",
+ "revision": 3,
+ "conflicts": [node_id],
+ "stale": False,
+ }
+
+
+def test_write_from_the_future_is_stale(
+ client: Any, full_api_access: None, canvas: Any, published: MagicMock
+) -> None:
+ response = patch_ops(client, 7, add("chart-1"))
+
+ assert response.status_code == 409
+ assert response.json["stale"] is True
+
+
+def test_invalid_operation_reports_its_index(
+ client: Any, full_api_access: None, canvas: Any, published: MagicMock
+) -> None:
+ response = patch_ops(client, 1, add("chart-1"), {"op": "remove", "id": "ghost"})
+
+ assert response.status_code == 422
+ assert response.json["operation"] == 1
+ assert canvas.revision == 1
+ published.assert_not_called()
+
+
+def test_invalid_result_reports_paths(
+ client: Any, full_api_access: None, canvas: Any, published: MagicMock
+) -> None:
+ response = patch_ops(client, 1, add("tab"))
+
+ assert response.status_code == 422
+ assert response.json["errors"][0]["message"] == (
+ "a tab widget cannot be placed in root"
+ )
+
+
+def test_widgets_the_author_cannot_place_are_rejected(
+ client: Any, full_api_access: None, canvas: Any, published: MagicMock
+) -> None:
+ response = patch_ops(client, 1, add("chart-1"), add("chart-secret"))
+
+ assert response.status_code == 422
+ assert response.json["errors"] == [
+ {"path": "/ops/1/widget", "message": "unknown widget, or no access to it"}
+ ]
+
+
+def test_malformed_body(client: Any, full_api_access: None, canvas: Any) -> None:
+ response = client.patch(URL, json={"ops": [{"op": "explode"}]})
+
+ assert response.status_code == 400
+
+
+def test_non_editors_cannot_write(
+ client: Any,
+ full_api_access: None,
+ canvas: Any,
+ mocker: MockerFixture,
+ published: MagicMock,
+) -> None:
+ mocker.patch(
+ "superset.security_manager.raise_for_editorship",
+ side_effect=SupersetSecurityException(MagicMock()),
+ )
+
+ assert patch_ops(client, 1, add("chart-1")).status_code == 403
+ assert client.put(f"{BASE}/100", json={"title": "x"}).status_code == 403
+ assert client.delete(f"{BASE}/100").status_code == 403
+ assert canvas.revision == 1
+
+
+def test_users_without_access_see_nothing(
+ client: Any,
+ full_api_access: None,
+ canvas: Any,
+ mocker: MockerFixture,
+ published: MagicMock,
+) -> None:
+ patch_ops(client, 1, add("chart-1"))
+ # Not an admin, and not an editor or viewer of this canvas.
+ mocker.patch(
+ "superset.canvas.filters.security_manager.is_admin", return_value=False
+ )
+
+ assert client.get(URL).status_code == 404
+ assert client.get(f"{URL}/changes?since=1").status_code == 404
+ assert patch_ops(client, 2, add("chart-2")).status_code == 404
+
+
+def test_changes_since_a_pruned_revision_are_stale(
+ client: Any,
+ full_api_access: None,
+ canvas: Any,
+ mocker: MockerFixture,
+ published: MagicMock,
+) -> None:
+ mocker.patch("superset.commands.canvas.apply_ops.OP_LOG_RETAINED_REVISIONS", 1)
+ patch_ops(client, 1, add("chart-1"))
+ patch_ops(client, 2, add("chart-2"))
+
+ assert client.get(f"{URL}/changes?since=1").status_code == 409
+ assert client.get(f"{URL}/changes?since=2").status_code == 200
+ assert client.get(f"{URL}/changes?since=3").json["result"]["changes"] == []
+
+
+def test_schema_is_published(
+ client: Any, full_api_access: None, widgets: FakeResolver
+) -> None:
+ result = client.get(f"{BASE}/schema").json["result"]
+
+ assert result["version"] == 1
+ assert "interactions" in result["definition"]["properties"]
+ assert result["operation"]["discriminator"]["propertyName"] == "op"
+ assert "colSpan" in result["gridPlacement"]["properties"]
+ by_type = {rules["type"]: rules for rules in result["widgetTypes"]}
+ assert by_type["tabs"]["acceptedChildren"] == ["tab"]
+ assert by_type["filterbar"]["boundsFilterScope"] is False
+ assert by_type["chart"]["isFilterable"] is True
diff --git a/tests/unit_tests/canvas/commands_test.py b/tests/unit_tests/canvas/commands_test.py
new file mode 100644
index 00000000000..0ab5e624955
--- /dev/null
+++ b/tests/unit_tests/canvas/commands_test.py
@@ -0,0 +1,122 @@
+# 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 typing import Any
+from unittest.mock import MagicMock
+
+import pytest
+from pytest_mock import MockerFixture
+
+from superset.commands.canvas.exceptions import (
+ CanvasForbiddenError,
+ CanvasInvalidError,
+ CanvasNotFoundError,
+)
+from superset.exceptions import SupersetSecurityException
+from superset.utils import json
+from tests.unit_tests.canvas.fixtures import canvas, FakeResolver, node
+
+
+@pytest.fixture
+def create_deps(mocker: MockerFixture, widgets: FakeResolver) -> None:
+ mocker.patch("superset.commands.canvas.create.populate_subjects")
+
+
+def create_command(data: dict[str, Any]) -> Any:
+ from superset.commands.canvas.create import CreateCanvasCommand
+
+ return CreateCanvasCommand(data)
+
+
+def test_create_defaults_to_an_empty_definition(create_deps: None) -> None:
+ command = create_command({"title": "Exec overview"})
+
+ command.validate()
+
+ stored = json.loads(command._properties["definition"])
+ assert stored["nodes"] == {}
+ assert command._properties["definition_version"] == 1
+ assert command._properties["revision"] == 1
+
+
+def test_create_normalizes_a_given_definition(create_deps: None) -> None:
+ command = create_command({"title": "x", "definition": canvas({"g": node("group")})})
+
+ command.validate()
+
+ assert json.loads(command._properties["definition"])["nodes"]["g"]["children"] == []
+
+
+@pytest.mark.parametrize("widget", ["missing-1", "chart-secret"])
+def test_create_rejects_widgets_the_author_cannot_place(
+ create_deps: None, widget: str
+) -> None:
+ command = create_command({"title": "x", "definition": canvas({"a": node(widget)})})
+
+ with pytest.raises(CanvasInvalidError) as excinfo:
+ command.validate()
+
+ assert excinfo.value.normalized_messages() == {
+ "definition": {"/nodes/a/widget": ["unknown widget, or no access to it"]}
+ }
+
+
+def test_create_reports_definition_issues(create_deps: None) -> None:
+ command = create_command({"title": "x", "definition": {"version": 2}})
+
+ with pytest.raises(CanvasInvalidError) as excinfo:
+ command.validate()
+
+ assert "/version" in excinfo.value.normalized_messages()["definition"]
+
+
+def test_update_needs_editorship(mocker: MockerFixture) -> None:
+ from superset.commands.canvas.update import UpdateCanvasCommand
+
+ mocker.patch(
+ "superset.commands.canvas.update.CanvasDAO.find_by_id",
+ return_value=MagicMock(),
+ )
+ mocker.patch(
+ "superset.commands.canvas.update.security_manager.raise_for_editorship",
+ side_effect=SupersetSecurityException(MagicMock()),
+ )
+
+ with pytest.raises(CanvasForbiddenError):
+ UpdateCanvasCommand(1, {"title": "x"}).validate()
+
+
+def test_update_hidden_canvas_is_not_found(mocker: MockerFixture) -> None:
+ from superset.commands.canvas.update import UpdateCanvasCommand
+
+ mocker.patch(
+ "superset.commands.canvas.update.CanvasDAO.find_by_id", return_value=None
+ )
+
+ with pytest.raises(CanvasNotFoundError):
+ UpdateCanvasCommand(1, {"title": "x"}).validate()
+
+
+def test_delete_needs_every_canvas(mocker: MockerFixture) -> None:
+ from superset.commands.canvas.delete import DeleteCanvasCommand
+
+ mocker.patch(
+ "superset.commands.canvas.delete.CanvasDAO.find_by_ids",
+ return_value=[MagicMock()],
+ )
+
+ with pytest.raises(CanvasNotFoundError):
+ DeleteCanvasCommand([1, 2]).validate()
diff --git a/superset/models/__init__.py b/tests/unit_tests/canvas/conftest.py
similarity index 51%
copy from superset/models/__init__.py
copy to tests/unit_tests/canvas/conftest.py
index 1db3711227c..a0357988a2f 100644
--- a/superset/models/__init__.py
+++ b/tests/unit_tests/canvas/conftest.py
@@ -14,10 +14,25 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
-from . import ( # noqa: F401
- core,
- dynamic_plugins,
- purge_audit_log,
- sql_lab,
- user_attributes,
-)
+from collections.abc import Iterator
+
+import pytest
+
+from tests.unit_tests.canvas.fixtures import canvas_rules, FakeResolver
+
+
+@pytest.fixture
+def widgets() -> Iterator[FakeResolver]:
+ """Register the test widget types and resolver with the canvas registry."""
+ from superset.canvas.definition import registry
+
+ resolver = FakeResolver(hidden={"chart-secret"})
+ previous_resolver = registry.get_widget_resolver()
+ test_rules = list(canvas_rules())
+ for rules in test_rules:
+ registry.layout_rules.register(rules)
+ registry.set_widget_resolver(resolver)
+ yield resolver
+ registry.set_widget_resolver(previous_resolver)
+ for rules in test_rules:
+ registry.layout_rules.unregister(rules.widget_type)
diff --git a/tests/unit_tests/canvas/filters_test.py b/tests/unit_tests/canvas/filters_test.py
new file mode 100644
index 00000000000..33dacc5bb47
--- /dev/null
+++ b/tests/unit_tests/canvas/filters_test.py
@@ -0,0 +1,102 @@
+# 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 collections.abc import Iterator
+from typing import Any
+from unittest.mock import MagicMock
+
+import pytest
+from pytest_mock import MockerFixture
+from sqlalchemy.orm.session import Session
+
+from superset.utils import json
+
+
+@pytest.fixture
+def shared(session: Session) -> Iterator[dict[str, Any]]:
+ """Three canvases: one the user edits, one they view, one they can't see."""
+ from superset.canvas.definition.schemas import empty_definition
+ from superset.models.canvas import Canvas
+ from superset.subjects.models import Subject
+
+ Canvas.metadata.create_all(session.get_bind()) # pylint: disable=no-member
+ me = Subject(id=1, label="me", type=1)
+ other = Subject(id=2, label="other", type=1)
+
+ def new(title: str, editors: list[Any], viewers: list[Any]) -> Any:
+ return Canvas(
+ title=title,
+ definition=json.dumps(empty_definition()),
+ definition_version=1,
+ editors=editors,
+ viewers=viewers,
+ )
+
+ canvases = {
+ "edited": new("edited", [me], []),
+ "viewed": new("viewed", [other], [me]),
+ "hidden": new("hidden", [other], []),
+ }
+ session.add_all([me, other, *canvases.values()])
+ session.flush()
+ yield canvases
+ session.rollback()
+
+
+def visible(session: Session) -> list[str]:
+ from superset.canvas.filters import CanvasAccessFilter
+ from superset.models.canvas import Canvas
+
+ query = CanvasAccessFilter("id", MagicMock()).apply(session.query(Canvas), None)
+ return sorted(canvas.title for canvas in query)
+
+
+def as_user(mocker: MockerFixture, admin: bool = False, guest: bool = False) -> None:
+ mocker.patch(
+ "superset.canvas.filters.security_manager.is_admin", return_value=admin
+ )
+ mocker.patch(
+ "superset.canvas.filters.security_manager.is_guest_user", return_value=guest
+ )
+ mocker.patch("superset.subjects.filters.get_user_id", return_value=7)
+ mocker.patch(
+ "superset.subjects.utils.get_user_subject_ids_subquery",
+ return_value=[1],
+ )
+
+
+def test_users_see_canvases_they_edit_or_view(
+ session: Session, shared: dict[str, Any], mocker: MockerFixture
+) -> None:
+ as_user(mocker)
+
+ assert visible(session) == ["edited", "viewed"]
+
+
+def test_admins_see_every_canvas(
+ session: Session, shared: dict[str, Any], mocker: MockerFixture
+) -> None:
+ as_user(mocker, admin=True)
+
+ assert visible(session) == ["edited", "hidden", "viewed"]
+
+
+def test_embedded_guests_see_none(
+ session: Session, shared: dict[str, Any], mocker: MockerFixture
+) -> None:
+ as_user(mocker, guest=True)
+
+ assert visible(session) == []
diff --git a/tests/unit_tests/canvas/fixtures.py b/tests/unit_tests/canvas/fixtures.py
new file mode 100644
index 00000000000..d098d2da28a
--- /dev/null
+++ b/tests/unit_tests/canvas/fixtures.py
@@ -0,0 +1,145 @@
+# 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 __future__ import annotations
+
+from collections.abc import Callable, Iterable
+from typing import Any
+
+from pydantic import BaseModel, ConfigDict, TypeAdapter
+from superset_core.canvas import CanvasLayoutRules
+
+from superset.canvas.definition.registry import LayoutRulesRegistry
+from superset.canvas.definition.schemas import Operation
+
+
+class GroupRules(CanvasLayoutRules):
+ widget_type = "group"
+ is_container = True
+ grid_columns = 12
+
+
+class TabsRules(CanvasLayoutRules):
+ widget_type = "tabs"
+ is_container = True
+ accepted_children = frozenset({"tab"})
+
+
+class TabRules(CanvasLayoutRules):
+ widget_type = "tab"
+ is_container = True
+ allowed_parents = frozenset({"tabs"})
+ grid_columns = 24
+
+
+class BoardColumn(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ lane: str
+
+
+class BoardRules(CanvasLayoutRules):
+ """A container with its own, non-grid child layout."""
+
+ widget_type = "board"
+ is_container = True
+ child_layout_model = BoardColumn
+
+
+class ChartRules(CanvasLayoutRules):
+ widget_type = "chart"
+ is_filterable = True
+
+
+class FilterRules(CanvasLayoutRules):
+ widget_type = "filter"
+ is_filter = True
+
+
+class FilterBarRules(CanvasLayoutRules):
+ """Holds filters without containing their scope."""
+
+ widget_type = "filterbar"
+ is_container = True
+ grid_columns = 24
+ accepted_children = frozenset({"filter"})
+ bounds_filter_scope = False
+
+
+def canvas_rules() -> LayoutRulesRegistry:
+ rules = LayoutRulesRegistry()
+ for registered in (
+ GroupRules,
+ TabsRules,
+ TabRules,
+ BoardRules,
+ ChartRules,
+ FilterRules,
+ FilterBarRules,
+ ):
+ rules.register(registered)
+ return rules
+
+
+class FakeResolver:
+ """
+ Widgets are named ``<type>`` or ``<type>-<n>``, so a widget's type is
+ readable from its id. Ids in ``hidden`` exist but cannot be placed, and
+ ids starting with ``missing`` do not exist.
+ """
+
+ def __init__(self, hidden: Iterable[str] = ()) -> None:
+ self.hidden = set(hidden)
+
+ def widget_types(self, widget_ids: Iterable[str]) -> dict[str, str]:
+ return {
+ widget_id: widget_id.split("-")[0]
+ for widget_id in widget_ids
+ if not widget_id.startswith("missing")
+ }
+
+ def placeable(self, widget_ids: Iterable[str]) -> set[str]:
+ return set(self.widget_types(widget_ids)) - self.hidden
+
+
+_ops_adapter = TypeAdapter(list[Operation])
+
+
+def ops(*raw: dict[str, Any]) -> list[Operation]:
+ return _ops_adapter.validate_python(list(raw))
+
+
+def sequential_ids() -> Callable[[], str]:
+ counter = iter(range(10_000))
+ return lambda: f"n{next(counter)}"
+
+
+def node(widget: str, **extra: Any) -> dict[str, Any]:
+ return {"widget": widget, **extra}
+
+
+def canvas(
+ nodes: dict[str, dict[str, Any]], children: list[str] | None = None
+) -> dict[str, Any]:
+ """A canvas whose root holds ``children`` (default: every node)."""
+ return {
+ "version": 1,
+ "root": {
+ "layout": {"columns": 24},
+ "children": list(nodes) if children is None else children,
+ },
+ "nodes": nodes,
+ }
diff --git a/tests/unit_tests/canvas/grid_test.py b/tests/unit_tests/canvas/grid_test.py
new file mode 100644
index 00000000000..57adbfee53a
--- /dev/null
+++ b/tests/unit_tests/canvas/grid_test.py
@@ -0,0 +1,76 @@
+# 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 superset.canvas.definition.grid import Rect, resolve_grid, span_errors
+
+
+def test_auto_placement_fills_rows_in_reading_order() -> None:
+ rects, stored = resolve_grid(
+ [{"colSpan": 8}, {"colSpan": 8}, {"colSpan": 8}, {"colSpan": 24}], 24
+ )
+
+ assert rects == [
+ Rect(1, 1, 8, 1),
+ Rect(9, 1, 8, 1),
+ Rect(17, 1, 8, 1),
+ Rect(1, 2, 24, 1),
+ ]
+ assert stored == [{"colSpan": 8}, {"colSpan": 8}, {"colSpan": 8}, {"colSpan": 24}]
+
+
+def test_missing_spans_default_to_full_width_and_one_row() -> None:
+ rects, _ = resolve_grid([{}, {}], 12)
+
+ assert rects == [Rect(1, 1, 12, 1), Rect(1, 2, 12, 1)]
+
+
+def test_explicit_collision_is_pushed_down_and_stored() -> None:
+ rects, stored = resolve_grid(
+ [
+ {"col": 1, "row": 1, "colSpan": 12, "rowSpan": 3},
+ {"col": 7, "row": 2, "colSpan": 12, "rowSpan": 2},
+ ],
+ 24,
+ )
+
+ assert rects[1] == Rect(7, 4, 12, 2)
+ assert stored[1] == {"col": 7, "row": 4, "colSpan": 12, "rowSpan": 2}
+
+
+def test_auto_placement_skips_explicitly_occupied_cells() -> None:
+ rects, _ = resolve_grid(
+ [{"col": 1, "row": 1, "colSpan": 12, "rowSpan": 2}, {"colSpan": 12}], 24
+ )
+
+ assert rects[1] == Rect(13, 1, 12, 1)
+
+
+def test_auto_placement_cursor_never_moves_backwards() -> None:
+ rects, _ = resolve_grid([{"colSpan": 20}, {"colSpan": 20}, {"colSpan": 4}], 24)
+
+ # The 4-wide child would fit beside the first one, but the cursor has
+ # already moved past it.
+ assert rects[2] == Rect(21, 2, 4, 1)
+
+
+def test_span_errors() -> None:
+ assert span_errors({"colSpan": 30}, 24) == [
+ "colSpan 30 exceeds the grid's 24 columns"
+ ]
+ assert span_errors({"col": 20, "row": 1, "colSpan": 8}, 24) == [
+ "col 20 with colSpan 8 overflows 24 columns"
+ ]
+ assert span_errors({"col": 17, "row": 1, "colSpan": 8}, 24) == []
diff --git a/tests/unit_tests/canvas/models_test.py b/tests/unit_tests/canvas/models_test.py
new file mode 100644
index 00000000000..2624305ea52
--- /dev/null
+++ b/tests/unit_tests/canvas/models_test.py
@@ -0,0 +1,102 @@
+# 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 __future__ import annotations
+
+from collections.abc import Iterator
+from typing import Any
+from uuid import UUID
+
+import pytest
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.orm.session import Session
+
+from superset.utils import json
+
+
+@pytest.fixture
+def canvas_session(session: Session) -> Iterator[Session]:
+ from superset.models.canvas import Canvas
+
+ Canvas.metadata.create_all(session.get_bind()) # pylint: disable=no-member
+ yield session
+ session.rollback()
+
+
+def new_canvas(title: str = "Exec overview") -> Any:
+ from superset.canvas.definition.schemas import empty_definition
+ from superset.models.canvas import Canvas
+
+ return Canvas(
+ title=title,
+ definition=json.dumps(empty_definition()),
+ definition_version=1,
+ )
+
+
+def test_canvas_has_an_integer_id_and_a_uuid(canvas_session: Session) -> None:
+ canvas = new_canvas()
+ canvas_session.add(canvas)
+ canvas_session.flush()
+
+ # The integer id plus uuid shape is what entity versioning expects.
+ assert isinstance(canvas.id, int)
+ assert isinstance(canvas.uuid, UUID)
+ assert canvas.revision == 1
+ assert canvas.url == f"/canvas/{canvas.id}/"
+
+
+def test_op_log_is_unique_per_revision_and_sequence(canvas_session: Session) -> None:
+ from superset.models.canvas import CanvasOp
+
+ canvas = new_canvas()
+ canvas_session.add(canvas)
+ canvas_session.flush()
+
+ def log(sequence: int) -> CanvasOp:
+ return CanvasOp(
+ canvas_id=canvas.id,
+ revision=2,
+ sequence=sequence,
+ op=json.dumps({"op": "remove", "id": "n0"}),
+ touched=json.dumps([["n0", "tree"]]),
+ )
+
+ canvas_session.add_all([log(0), log(1)])
+ canvas_session.flush()
+ stored = canvas_session.query(CanvasOp).order_by("sequence").all()
+
+ assert [row.sequence for row in stored] == [0, 1]
+ assert stored[0].op_dict == {"op": "remove", "id": "n0"}
+ assert stored[0].touched_pairs == [("n0", "tree")]
+
+ canvas_session.add(log(1))
+ with pytest.raises(IntegrityError):
+ canvas_session.flush()
+
+
+def test_deleting_a_canvas_deletes_its_op_log(canvas_session: Session) -> None:
+ from superset.models.canvas import CanvasOp
+
+ canvas = new_canvas()
+ canvas.ops.append(CanvasOp(revision=2, sequence=0, op="{}", touched="[]"))
+ canvas_session.add(canvas)
+ canvas_session.flush()
+
+ canvas_session.delete(canvas)
+ canvas_session.flush()
+
+ assert canvas_session.query(CanvasOp).count() == 0
diff --git a/tests/unit_tests/canvas/ops_test.py b/tests/unit_tests/canvas/ops_test.py
new file mode 100644
index 00000000000..c8eb6bf189e
--- /dev/null
+++ b/tests/unit_tests/canvas/ops_test.py
@@ -0,0 +1,177 @@
+# 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 typing import Any
+
+import pytest
+
+from superset.canvas.definition.ops import (
+ AppliedOperation,
+ apply_operations,
+ FieldGroup,
+ OperationError,
+ overlapping,
+ Touch,
+)
+from superset.canvas.definition.registry import LayoutRulesRegistry
+from superset.canvas.definition.schemas import empty_definition
+from superset.canvas.definition.validation import DefinitionValidationError
+from tests.unit_tests.canvas.fixtures import (
+ canvas_rules,
+ FakeResolver,
+ ops,
+ sequential_ids,
+)
+
+
+def apply(
+ canvas: dict[str, Any], *raw: dict[str, Any]
+) -> tuple[dict[str, Any], list[AppliedOperation]]:
+ return apply_operations(
+ canvas,
+ ops(*raw),
+ rules=canvas_rules(),
+ resolver=FakeResolver(),
+ new_id=sequential_ids(),
+ )
+
+
+def build(*raw: dict[str, Any]) -> dict[str, Any]:
+ return apply(empty_definition(), *raw)[0]
+
+
+def add(widget: str, **extra: Any) -> dict[str, Any]:
+ return {"op": "add", "widget": widget, **extra}
+
+
+def test_add_assigns_node_ids_and_reports_them() -> None:
+ result, applied = apply(empty_definition(), add("chart-1"), add("group"))
+
+ assert result["root"]["children"] == ["n0", "n1"]
+ assert result["nodes"]["n0"] == {"widget": "chart-1", "layout": {}}
+ assert result["nodes"]["n1"]["children"] == []
+ assert applied[0].op["id"] == "n0"
+ assert applied[0].touched == [Touch("n0", FieldGroup.TREE)]
+
+
+def test_add_into_a_container_added_in_the_same_batch() -> None:
+ result = build(
+ add("group"),
+ add("chart-1", parent="n0"),
+ add("chart-2", parent="n0", index=0),
+ )
+
+ assert result["nodes"]["n0"]["children"] == ["n2", "n1"]
+
+
+def test_add_into_a_leaf_is_rejected() -> None:
+ with pytest.raises(OperationError, match="'n0' cannot hold children"):
+ build(add("chart-1"), add("chart-2", parent="n0"))
+
+
+def test_unresolved_container_keeps_its_children_but_takes_no_new_ones() -> None:
+ doc = build(add("group"), add("chart-1", parent="n0"), add("chart-2"))
+ # The group's widget type is no longer registered.
+ unregistered, _ = apply_operations(
+ doc,
+ ops({"op": "place", "id": "n2", "layout": {"colSpan": 6}}),
+ rules=LayoutRulesRegistry(),
+ resolver=FakeResolver(),
+ )
+
+ assert unregistered["nodes"]["n0"]["children"] == ["n1"]
+ with pytest.raises(OperationError, match="cannot hold children"):
+ apply_operations(
+ unregistered,
+ ops({"op": "move", "id": "n2", "parent": "n0"}),
+ rules=LayoutRulesRegistry(),
+ resolver=FakeResolver(),
+ )
+
+
+def test_remove_drops_the_subtree() -> None:
+ doc = build(add("group"), add("chart-1", parent="n0"), add("chart-2"))
+
+ result, applied = apply(doc, {"op": "remove", "id": "n0"})
+
+ assert set(result["nodes"]) == {"n2"}
+ assert result["root"]["children"] == ["n2"]
+ assert set(applied[0].touched) == {
+ Touch("n0", FieldGroup.TREE),
+ Touch("n1", FieldGroup.TREE),
+ }
+
+
+def test_move_reparents_and_resets_layout() -> None:
+ doc = build(add("chart-1", layout={"col": 1, "row": 1, "colSpan": 6}), add("group"))
+
+ result, _ = apply(doc, {"op": "move", "id": "n0", "parent": "n1"})
+
+ assert result["root"]["children"] == ["n1"]
+ assert result["nodes"]["n1"]["children"] == ["n0"]
+ assert result["nodes"]["n0"]["layout"] == {}
+
+
+def test_move_into_own_descendant_fails() -> None:
+ doc = build(add("group-1"), add("group-2", parent="n0"))
+
+ with pytest.raises(OperationError, match="into itself or its descendants"):
+ apply(doc, {"op": "move", "id": "n0", "parent": "n1"})
+
+
+def test_place_updates_layout_and_resolves_collisions() -> None:
+ doc = build(
+ add("chart-1", layout={"col": 1, "row": 1, "colSpan": 12, "rowSpan": 2}),
+ add("chart-2", layout={"col": 13, "row": 1, "colSpan": 12}),
+ )
+
+ result, applied = apply(
+ doc, {"op": "place", "id": "n1", "layout": {"col": 1, "row": 1, "colSpan": 24}}
+ )
+
+ assert result["nodes"]["n1"]["layout"] == {"col": 1, "row": 3, "colSpan": 24}
+ assert applied[0].touched == [Touch("n1", FieldGroup.LAYOUT)]
+
+
+def test_all_operations_apply_or_none_do() -> None:
+ doc = build(add("chart-1"))
+
+ with pytest.raises(DefinitionValidationError):
+ apply(doc, add("chart-2"), add("missing-1"))
+ assert list(doc["nodes"]) == ["n0"]
+
+
+def test_unknown_node() -> None:
+ with pytest.raises(OperationError, match="unknown node 'nope'"):
+ build({"op": "place", "id": "nope", "layout": {}})
+
+
+@pytest.mark.parametrize(
+ "earlier,later,expected",
+ [
+ # Drags of different nodes never collide.
+ ([Touch("a", FieldGroup.LAYOUT)], [Touch("b", FieldGroup.LAYOUT)], set()),
+ # Two drags of the same node do.
+ ([Touch("a", FieldGroup.LAYOUT)], [Touch("a", FieldGroup.LAYOUT)], {"a"}),
+ # Removing or moving a node collides with any edit to it.
+ ([Touch("a", FieldGroup.TREE)], [Touch("a", FieldGroup.LAYOUT)], {"a"}),
+ ([Touch("a", FieldGroup.LAYOUT)], [Touch("a", FieldGroup.TREE)], {"a"}),
+ ],
+)
+def test_overlapping(
+ earlier: list[Touch], later: list[Touch], expected: set[str]
+) -> None:
+ assert overlapping(earlier, later) == expected
diff --git a/tests/unit_tests/canvas/render_test.py b/tests/unit_tests/canvas/render_test.py
new file mode 100644
index 00000000000..0902694997a
--- /dev/null
+++ b/tests/unit_tests/canvas/render_test.py
@@ -0,0 +1,55 @@
+# 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 superset.canvas.definition.render import render_context
+from superset.canvas.definition.validation import normalize_definition
+from tests.unit_tests.canvas.fixtures import canvas, canvas_rules, FakeResolver, node
+
+
+def test_placements_include_auto_placed_nodes_and_grid_containers() -> None:
+ rules, resolver = canvas_rules(), FakeResolver()
+ definition = normalize_definition(
+ canvas(
+ {
+ "k1": node("chart-1", layout={"colSpan": 8}),
+ "k2": node("chart-2", layout={"colSpan": 8}),
+ "g": node("group", layout={"colSpan": 24}, children=["c"]),
+ "c": node("chart-3", layout={"colSpan": 6}),
+ "gone": node("missing-1", layout={"colSpan": 4}),
+ },
+ children=["k1", "k2", "g", "gone"],
+ ),
+ rules,
+ resolver,
+ )
+
+ context = render_context(definition, rules, resolver)
+
+ assert context["placements"] == {
+ "k1": {"col": 1, "row": 1, "colSpan": 8, "rowSpan": 1},
+ "k2": {"col": 9, "row": 1, "colSpan": 8, "rowSpan": 1},
+ "g": {"col": 1, "row": 2, "colSpan": 24, "rowSpan": 1},
+ # A placeholder still takes its place on the grid.
+ "gone": {"col": 1, "row": 3, "colSpan": 4, "rowSpan": 1},
+ # Children of a grid container are placed on its own 12 columns.
+ "c": {"col": 1, "row": 1, "colSpan": 6, "rowSpan": 1},
+ }
+ assert context["widgetTypes"] == {
+ "k1": "chart",
+ "k2": "chart",
+ "g": "group",
+ "c": "chart",
+ }
diff --git a/tests/unit_tests/canvas/scopes_test.py b/tests/unit_tests/canvas/scopes_test.py
new file mode 100644
index 00000000000..d5d808f4a89
--- /dev/null
+++ b/tests/unit_tests/canvas/scopes_test.py
@@ -0,0 +1,191 @@
+# 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 typing import Any
+
+import pytest
+
+from superset.canvas.definition.ops import (
+ apply_operations,
+ FieldGroup,
+ overlapping,
+ Touch,
+)
+from superset.canvas.definition.scopes import resolve_filter_scopes
+from superset.canvas.definition.validation import (
+ DefinitionValidationError,
+ normalize_definition,
+)
+from tests.unit_tests.canvas.fixtures import (
+ canvas,
+ canvas_rules,
+ FakeResolver,
+ node,
+ ops,
+)
+
+
+def scopes(raw: dict[str, Any]) -> dict[str, list[str]]:
+ rules, resolver = canvas_rules(), FakeResolver()
+ return resolve_filter_scopes(
+ normalize_definition(raw, rules, resolver), rules, resolver
+ )
+
+
+def analyst_view(**interactions: Any) -> dict[str, Any]:
+ """Example dashboard 3: markdown intro, a nested container of two charts
+ sharing a filter, and a third chart with its own filter."""
+ raw = canvas(
+ {
+ "intro": node("markdown"),
+ "section": node("group", children=["f1", "c1", "c2"]),
+ "f1": node("filter-1"),
+ "c1": node("chart-1"),
+ "c2": node("chart-2"),
+ "f2": node("filter-2"),
+ "c3": node("chart-3"),
+ },
+ children=["intro", "section", "f2", "c3"],
+ )
+ raw["interactions"] = {"filters": interactions}
+ return raw
+
+
+def test_auto_scope_follows_the_nearest_container() -> None:
+ assert scopes(analyst_view()) == {
+ "f1": ["c1", "c2"],
+ # At the root a filter drives every chart on the canvas.
+ "f2": ["c1", "c2", "c3"],
+ }
+
+
+def test_global_scope_with_exclusions() -> None:
+ result = scopes(analyst_view(f2={"mode": "global", "exclude": ["c1", "c2"]}))
+
+ assert result["f2"] == ["c3"]
+
+
+def test_custom_scope_names_its_targets() -> None:
+ result = scopes(analyst_view(f1={"mode": "custom", "targets": ["c3"]}))
+
+ assert result["f1"] == ["c3"]
+
+
+def test_tabs_contain_their_filters() -> None:
+ raw = canvas(
+ {
+ "tabs": node("tabs", children=["t1", "t2"]),
+ "t1": node("tab-1", children=["f", "c1"]),
+ "t2": node("tab-2", children=["c2"]),
+ "f": node("filter"),
+ "c1": node("chart-1"),
+ "c2": node("chart-2"),
+ },
+ children=["tabs"],
+ )
+
+ assert scopes(raw) == {"f": ["c1"]}
+
+
+def test_filter_bars_do_not_contain_scope() -> None:
+ raw = canvas(
+ {
+ "bar": node("filterbar", children=["f"]),
+ "f": node("filter"),
+ "g": node("group", children=["c1"]),
+ "c1": node("chart-1"),
+ "c2": node("chart-2"),
+ },
+ children=["bar", "g", "c2"],
+ )
+
+ assert scopes(raw) == {"f": ["c1", "c2"]}
+
+
+@pytest.mark.parametrize(
+ "override,message",
+ [
+ ({"c1": {"mode": "global"}}, "node 'c1' is not a filter"),
+ (
+ {"f1": {"mode": "custom", "targets": ["intro"]}},
+ "node 'intro' cannot be filtered",
+ ),
+ ({"f1": {"mode": "custom", "targets": ["ghost"]}}, "unknown node 'ghost'"),
+ ({"f1": {"mode": "auto", "targets": ["c1"]}}, "takes exclude, not targets"),
+ ],
+)
+def test_invalid_overrides(override: dict[str, Any], message: str) -> None:
+ with pytest.raises(DefinitionValidationError) as excinfo:
+ normalize_definition(analyst_view(**override), canvas_rules(), FakeResolver())
+
+ assert any(message in issue.message for issue in excinfo.value.issues)
+
+
+def apply(raw: dict[str, Any], *raw_ops: dict[str, Any]) -> Any:
+ rules, resolver = canvas_rules(), FakeResolver()
+ return apply_operations(
+ normalize_definition(raw, rules, resolver),
+ ops(*raw_ops),
+ rules=rules,
+ resolver=resolver,
+ )
+
+
+def test_set_filter_scope_and_restore_auto() -> None:
+ doc, applied = apply(
+ analyst_view(),
+ {
+ "op": "set_filter_scope",
+ "id": "f2",
+ "scope": {"mode": "custom", "targets": ["c3"]},
+ },
+ )
+
+ assert doc["interactions"]["filters"]["f2"] == {
+ "mode": "custom",
+ "targets": ["c3"],
+ "exclude": [],
+ }
+ assert applied[0].touched == [Touch("f2", FieldGroup.SCOPE)]
+
+ doc, _ = apply(doc, {"op": "set_filter_scope", "id": "f2", "scope": None})
+
+ assert doc["interactions"]["filters"] == {}
+
+
+def test_removing_nodes_prunes_scope_overrides() -> None:
+ doc, applied = apply(
+ analyst_view(
+ f1={"mode": "custom", "targets": ["c1", "c3"]},
+ f2={"mode": "global", "exclude": ["c3"]},
+ ),
+ {"op": "remove", "id": "c3"},
+ )
+
+ assert doc["interactions"]["filters"]["f1"]["targets"] == ["c1"]
+ assert doc["interactions"]["filters"]["f2"]["exclude"] == []
+ assert Touch("f1", FieldGroup.SCOPE) in applied[0].touched
+
+ doc, _ = apply(doc, {"op": "remove", "id": "section"})
+
+ assert set(doc["interactions"]["filters"]) == {"f2"}
+
+
+def test_scope_edits_conflict_only_with_scope_edits() -> None:
+ scope = [Touch("f1", FieldGroup.SCOPE)]
+
+ assert overlapping(scope, [Touch("f1", FieldGroup.LAYOUT)]) == set()
+ assert overlapping(scope, scope) == {"f1"}
diff --git a/tests/unit_tests/canvas/validation_test.py b/tests/unit_tests/canvas/validation_test.py
new file mode 100644
index 00000000000..3c5096639d1
--- /dev/null
+++ b/tests/unit_tests/canvas/validation_test.py
@@ -0,0 +1,215 @@
+# 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 typing import Any
+
+import pytest
+
+from superset.canvas.definition.schemas import empty_definition
+from superset.canvas.definition.validation import (
+ DefinitionValidationError,
+ normalize_definition,
+)
+from tests.unit_tests.canvas.fixtures import (
+ canvas,
+ canvas_rules,
+ FakeResolver,
+ node,
+)
+
+
+def normalize(raw: dict[str, Any]) -> dict[str, Any]:
+ return normalize_definition(raw, canvas_rules(), FakeResolver())
+
+
+def issues(raw: dict[str, Any]) -> list[tuple[str, str]]:
+ with pytest.raises(DefinitionValidationError) as excinfo:
+ normalize(raw)
+ return [(issue.path, issue.message) for issue in excinfo.value.issues]
+
+
+def test_empty_canvas_is_valid() -> None:
+ assert normalize(empty_definition()) == {
+ "version": 1,
+ "root": {"layout": {"columns": 24, "gap": 16, "rowUnit": 40}, "children": []},
+ "nodes": {},
+ "interactions": {"filters": {}},
+ }
+
+
+def test_nodes_hold_only_a_widget_reference_and_placement() -> None:
+ found = issues(canvas({"a": node("chart", props={"title": "x"})}))
+
+ assert found[0][0] == "/nodes/a/props"
+
+
+def test_unknown_version_is_rejected() -> None:
+ assert issues({**empty_definition(), "version": 2})[0][0] == "/version"
+
+
+def test_missing_widgets_stay_as_placeholders() -> None:
+ raw = canvas(
+ {
+ "a": node("missing-1", layout={"colSpan": 6}, children=["b"]),
+ "b": node("chart-1", layout={"anything": "kept"}),
+ },
+ children=["a"],
+ )
+
+ result = normalize(raw)
+
+ # The placeholder is still placed like any other node...
+ assert result["nodes"]["a"]["layout"] == {"colSpan": 6}
+ # ...and its children are kept exactly as stored.
+ assert result["nodes"]["a"]["children"] == ["b"]
+ assert result["nodes"]["b"]["layout"] == {"anything": "kept"}
+
+
+def test_a_widget_is_placed_once() -> None:
+ assert issues(canvas({"a": node("chart-1"), "b": node("chart-1")})) == [
+ ("/nodes/b/widget", "widget 'chart-1' is already placed")
+ ]
+
+
+def test_unknown_child_and_orphan() -> None:
+ found = issues(
+ canvas({"a": node("chart-1"), "b": node("chart-2")}, children=["a", "ghost"])
+ )
+
+ assert ("/root/children/1", "unknown node 'ghost'") in found
+ assert ("/nodes/b", "not reachable from the root") in found
+
+
+def test_node_with_two_parents() -> None:
+ raw = canvas(
+ {"g": node("group", children=["a"]), "a": node("chart")}, children=["g", "a"]
+ )
+
+ assert issues(raw) == [("/nodes/g/children/0", "node 'a' has more than one parent")]
+
+
+def test_cycle_is_unreachable() -> None:
+ raw = canvas(
+ {
+ "g1": node("group-1", children=["g2"]),
+ "g2": node("group-2", children=["g1"]),
+ },
+ children=[],
+ )
+
+ found = issues(raw)
+
+ assert ("/nodes/g1", "not reachable from the root") in found
+ assert ("/nodes/g2", "not reachable from the root") in found
+
+
+def test_unregistered_type_with_children_is_kept() -> None:
+ # A container whose extension was removed: its type has no rules any more.
+ raw = canvas(
+ {"a": node("chart-1", children=["b"]), "b": node("chart-2")}, children=["a"]
+ )
+
+ assert normalize(raw)["nodes"]["a"]["children"] == ["b"]
+
+
+def test_size_limits_apply_on_grids() -> None:
+ from superset_core.canvas import CanvasLayoutRules
+
+ from tests.unit_tests.canvas.fixtures import canvas_rules
+
+ class KpiRules(CanvasLayoutRules):
+ widget_type = "kpi"
+ min_col_span = 4
+ max_row_span = 6
+
+ rules = canvas_rules()
+ rules.register(KpiRules)
+ raw = canvas({"k": node("kpi", layout={"colSpan": 2, "rowSpan": 8})})
+
+ with pytest.raises(DefinitionValidationError) as excinfo:
+ normalize_definition(raw, rules, FakeResolver())
+
+ assert [issue.message for issue in excinfo.value.issues] == [
+ "colSpan 2 is below the minimum of 4",
+ "rowSpan 8 is above the maximum of 6",
+ ]
+
+
+def test_nesting_rules() -> None:
+ assert issues(canvas({"t": node("tab")})) == [
+ ("/nodes/t/widget", "a tab widget cannot be placed in root")
+ ]
+ assert issues(
+ canvas({"s": node("tabs", children=["m"]), "m": node("markdown")}, ["s"])
+ ) == [("/nodes/m/widget", "a markdown widget cannot be placed in tabs")]
+
+
+def test_containers_own_their_children_layout() -> None:
+ raw = canvas(
+ {
+ "g": node("group", children=["a", "b"]),
+ "a": node("chart-1", layout={"colSpan": 16}),
+ "b": node("chart-2", layout={"col": 3}),
+ "k": node("board", children=["c", "d"]),
+ "c": node("chart-3", layout={"lane": "todo"}),
+ "d": node("chart-4", layout={"colSpan": 2}),
+ },
+ children=["g", "k"],
+ )
+
+ found = issues(raw)
+
+ # ``group`` is a 12-column grid, ``board`` has its own layout model.
+ assert ("/nodes/a/layout", "colSpan 16 exceeds the grid's 12 columns") in found
+ assert any(path == "/nodes/b/layout" for path, _ in found)
+ assert any(path.startswith("/nodes/d/layout") for path, _ in found)
+ assert not any(path.startswith("/nodes/c/") for path, _ in found)
+
+
+def test_containers_without_a_layout_accept_only_empty_layouts() -> None:
+ raw = canvas(
+ {
+ "s": node("tabs", children=["p"]),
+ "p": node("tab", layout={"colSpan": 3}),
+ },
+ children=["s"],
+ )
+
+ assert issues(raw)[0][0].startswith("/nodes/p/layout")
+
+
+def test_grid_collisions_are_resolved_on_normalize() -> None:
+ raw = canvas(
+ {
+ "a": node(
+ "chart-1", layout={"col": 1, "row": 1, "colSpan": 24, "rowSpan": 2}
+ ),
+ "b": node("chart-2", layout={"col": 1, "row": 1, "colSpan": 12}),
+ }
+ )
+
+ assert normalize(raw)["nodes"]["b"]["layout"] == {
+ "col": 1,
+ "row": 3,
+ "colSpan": 12,
+ }
+
+
+def test_containers_get_children_and_leaves_do_not() -> None:
+ result = normalize(canvas({"g": node("group"), "a": node("chart", children=[])}))
+
+ assert result["nodes"]["g"]["children"] == []
+ assert "children" not in result["nodes"]["a"]
(superset) 02/12: feat(canvas): list and view pages behind the CANVAS flag
Posted by ms...@apache.org.
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 <sa...@proton.me>
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}
(superset) 03/12: feat(canvas): let callers choose node ids on add
Posted by ms...@apache.org.
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 7dc9fcbaa7247319da3dd0b04b8dd12a56c7c357
Author: Mehmet Salih Yavuz <sa...@proton.me>
AuthorDate: Thu Oct 1 11:48:04 2026 +0300
feat(canvas): let callers choose node ids on add
An add operation may carry an id, a canonical lowercase UUID not already
on the canvas; otherwise the server generates one as before. Later
operations in the same request can then reference the new node, so a
container and its children are built in one atomic request, a UI can
draw a node before the response, and a retried add fails instead of
duplicating the node.
---
superset/canvas/definition/ops.py | 6 ++++--
superset/canvas/definition/schemas.py | 13 ++++++++++--
tests/unit_tests/canvas/api_test.py | 24 ++++++++++++++++++++++
tests/unit_tests/canvas/ops_test.py | 38 ++++++++++++++++++++++++++++++++---
4 files changed, 74 insertions(+), 7 deletions(-)
diff --git a/superset/canvas/definition/ops.py b/superset/canvas/definition/ops.py
index d3e37baa170..64fb3b10df0 100644
--- a/superset/canvas/definition/ops.py
+++ b/superset/canvas/definition/ops.py
@@ -67,7 +67,7 @@ class Touch:
@dataclass
class AppliedOperation:
- # The operation as logged; ``add`` carries the node id the server assigned.
+ # The operation as logged; ``add`` always carries the new node's id.
op: dict[str, Any]
touched: list[Touch] = field(default_factory=list)
@@ -179,7 +179,9 @@ class _Applier:
def apply(self, op: Operation) -> AppliedOperation:
logged = op.model_dump(mode="json", by_alias=True, exclude_none=True)
if isinstance(op, AddOp):
- node_id = self.new_id()
+ node_id = op.id or self.new_id()
+ if node_id in self.nodes:
+ raise ValueError(f"node {node_id!r} already exists")
self.insert(self.children(op.parent), node_id, op.index)
self.nodes[node_id] = {"widget": op.widget, "layout": op.layout}
return AppliedOperation(
diff --git a/superset/canvas/definition/schemas.py b/superset/canvas/definition/schemas.py
index b5f9b87df8e..feac87dd612 100644
--- a/superset/canvas/definition/schemas.py
+++ b/superset/canvas/definition/schemas.py
@@ -18,7 +18,7 @@
The dashboard canvas and the operations that change it.
A canvas is where a dashboard's widgets sit: a flat ``nodes`` map keyed by
-server-generated ids, with the tree expressed through ``children`` id lists.
+UUID node ids, with the tree expressed through ``children`` id lists.
A node references a widget by id and holds only its placement; widget
configuration lives with the widget. The root is not a node: it always
exists, is always a grid, and is addressed as ``"root"`` in operations.
@@ -34,6 +34,9 @@ from pydantic.alias_generators import to_camel
DEFINITION_VERSION = 1
ROOT_ID = "root"
+# Caller-chosen node ids must be canonical lowercase UUIDs, like the ones the
+# server generates, so every reference to a node spells it the same way.
+NODE_ID_PATTERN = r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
class _Model(BaseModel):
@@ -107,9 +110,15 @@ def empty_definition() -> dict[str, Any]:
class AddOp(_Model):
- """Place a widget under ``parent``; the server assigns the node id."""
+ """
+ Place a widget under ``parent``.
+
+ The node id is the caller's ``id`` when given, so later operations in the
+ same request can reference the new node; otherwise the server assigns one.
+ """
op: Literal["add"]
+ id: str | None = Field(default=None, pattern=NODE_ID_PATTERN)
widget: str = Field(min_length=1, max_length=64)
layout: dict[str, Any] = Field(default_factory=dict)
parent: str = ROOT_ID
diff --git a/tests/unit_tests/canvas/api_test.py b/tests/unit_tests/canvas/api_test.py
index 0445f8b875f..4a09f277abd 100644
--- a/tests/unit_tests/canvas/api_test.py
+++ b/tests/unit_tests/canvas/api_test.py
@@ -193,6 +193,30 @@ def test_write_from_the_future_is_stale(
assert response.json["stale"] is True
+def test_caller_ids_let_one_request_build_a_nested_layout(
+ client: Any, full_api_access: None, canvas: Any, published: MagicMock
+) -> None:
+ group_id = "6f1c2b7e-2d4a-4c1e-9a53-0f3b8d2e7a10"
+
+ response = patch_ops(
+ client,
+ 1,
+ add("group", id=group_id),
+ add("chart-1", parent=group_id),
+ add("chart-2", parent=group_id),
+ )
+
+ assert response.status_code == 200
+ assert response.json["result"]["revision"] == 2
+ assert response.json["result"]["ops"][0]["id"] == group_id
+ nodes = json.loads(canvas.definition)["nodes"]
+ assert len(nodes[group_id]["children"]) == 2
+
+ retried = patch_ops(client, 2, add("group", id=group_id))
+ assert retried.status_code == 422
+ assert canvas.revision == 2
+
+
def test_invalid_operation_reports_its_index(
client: Any, full_api_access: None, canvas: Any, published: MagicMock
) -> None:
diff --git a/tests/unit_tests/canvas/ops_test.py b/tests/unit_tests/canvas/ops_test.py
index c8eb6bf189e..85a0521609e 100644
--- a/tests/unit_tests/canvas/ops_test.py
+++ b/tests/unit_tests/canvas/ops_test.py
@@ -77,6 +77,38 @@ def test_add_into_a_container_added_in_the_same_batch() -> None:
assert result["nodes"]["n0"]["children"] == ["n2", "n1"]
+GROUP_ID = "6f1c2b7e-2d4a-4c1e-9a53-0f3b8d2e7a10"
+
+
+def test_add_takes_a_caller_id_that_later_ops_can_reference() -> None:
+ result, applied = apply(
+ empty_definition(),
+ add("group", id=GROUP_ID),
+ add("chart-1", parent=GROUP_ID),
+ {"op": "place", "id": GROUP_ID, "layout": {"colSpan": 12}},
+ )
+
+ assert result["root"]["children"] == [GROUP_ID]
+ assert result["nodes"][GROUP_ID]["children"] == ["n0"]
+ assert result["nodes"][GROUP_ID]["layout"]["colSpan"] == 12
+ assert applied[0].op["id"] == GROUP_ID
+
+
+def test_add_rejects_an_id_already_in_use() -> None:
+ doc = build(add("group", id=GROUP_ID))
+
+ with pytest.raises(OperationError, match="already exists"):
+ apply(doc, add("chart-1", id=GROUP_ID))
+
+
+@pytest.mark.parametrize(
+ "node_id", ["root", "n0", GROUP_ID.upper(), GROUP_ID.replace("-", "")]
+)
+def test_add_rejects_ids_that_are_not_canonical_uuids(node_id: str) -> None:
+ with pytest.raises(ValueError, match="should match pattern"):
+ ops(add("chart-1", id=node_id))
+
+
def test_add_into_a_leaf_is_rejected() -> None:
with pytest.raises(OperationError, match="'n0' cannot hold children"):
build(add("chart-1"), add("chart-2", parent="n0"))
@@ -147,11 +179,11 @@ def test_place_updates_layout_and_resolves_collisions() -> None:
def test_all_operations_apply_or_none_do() -> None:
- doc = build(add("chart-1"))
+ doc = build(add("chart-1", id=GROUP_ID))
with pytest.raises(DefinitionValidationError):
- apply(doc, add("chart-2"), add("missing-1"))
- assert list(doc["nodes"]) == ["n0"]
+ apply(doc, add("chart-2"), add("tab"))
+ assert list(doc["nodes"]) == [GROUP_ID]
def test_unknown_node() -> None: