Compare commits

...
Author SHA1 Message Date
Mehmet Salih Yavuz 8dbb6d663a feat(canvas): add Canvas v2 declarative dashboard prototype
Introduces CDL (Canvas Definition Language), a declarative component tree
rendered by a new frontend runtime, plus the backend model, REST API,
migration, and MCP tools for generating and patching canvases.

- superset/models/canvas.py, superset/canvas/: model, API, filters
- superset/mcp_service/canvas/: generate/get/update/schema tools, patch ops,
  CDL validation
- superset-frontend/src/Canvas/: renderer, runtime, resolver, viz adapters,
  action dispatch, validator
- superset-frontend/src/pages/Canvas{,List}/: routes and list view
2026-07-31 00:56:29 +03:00
46 changed files with 6071 additions and 0 deletions
@@ -0,0 +1,41 @@
/**
* 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 { Meta, StoryObj } from '@storybook/react-webpack5';
import { CanvasRenderer } from './CanvasRenderer';
import { salesCanvas, createMockRunner } from './fixtures/salesCanvas';
export default {
title: 'Canvas/CanvasRenderer',
component: CanvasRenderer,
} as Meta<typeof CanvasRenderer>;
type Story = StoryObj<typeof CanvasRenderer>;
/** Change the Region select (or hit Reset) and watch the bound chart re-query. */
export const InteractiveCanvas: Story = {
render: () => (
<div style={{ maxWidth: 720 }}>
<CanvasRenderer
definition={salesCanvas}
queryRunner={createMockRunner(300)}
/>
</div>
),
};
@@ -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 {
render,
screen,
userEvent,
waitFor,
} from 'spec/helpers/testing-library';
import { CanvasRenderer } from './CanvasRenderer';
import { CanvasDefinition, CdlQueryContext } from './types';
import type { QueryRunner } from './runtime';
import { QueryResult } from './resolve';
// echarts touches canvas APIs jsdom lacks; the reactive loop under test is the
// query dispatch, not the render, so stub the engine.
jest.mock('echarts/core', () => ({
use: jest.fn(),
registerTheme: jest.fn(),
init: jest.fn(() => ({
setOption: jest.fn(),
resize: jest.fn(),
dispose: jest.fn(),
})),
}));
const definition: CanvasDefinition = {
cdlVersion: 2,
variables: { region: { type: 'string', default: 'APAC', scope: 'query' } },
tree: {
id: 'root',
type: 'Column',
children: [
{
id: 'to-emea',
type: 'Button',
props: { children: 'EMEA' },
on: {
click: [{ action: 'setVariable', name: 'region', value: 'EMEA' }],
},
},
{
id: 'chart',
type: 'Viz',
renderer: 'echarts',
data: {
queryContext: {
datasetId: 1,
metrics: ['SUM(sales)'],
groupby: ['month'],
filters: [{ col: 'region', op: '==', val: '$region' }],
},
encoding: { x: 'month', y: 'SUM(sales)' },
},
option: { series: [{ type: 'line' }] },
},
],
},
};
const regionOf = (qc: CdlQueryContext): unknown =>
qc.filters?.find(f => f.col === 'region')?.val;
test('a bound query re-runs with the new value when a control changes a variable', async () => {
const run = jest.fn(
async (_queryContext: CdlQueryContext): Promise<QueryResult> => ({
columns: ['month', 'SUM(sales)'],
records: [{ month: 'Jan', 'SUM(sales)': 1 }],
}),
);
const runner: QueryRunner = { run };
render(<CanvasRenderer definition={definition} queryRunner={runner} />);
// Initial fetch resolves $region to its default.
await waitFor(() => expect(run).toHaveBeenCalled());
expect(regionOf(run.mock.calls[0][0])).toBe('APAC');
run.mockClear();
await userEvent.click(screen.getByText('EMEA'));
// The variable change re-runs the bound query with the new value.
await waitFor(() => expect(run).toHaveBeenCalled());
const lastCall = run.mock.calls[run.mock.calls.length - 1][0];
expect(regionOf(lastCall)).toBe('EMEA');
});
test('an invalid canvas renders validation errors instead of the tree', () => {
const broken = {
...definition,
tree: { ...definition.tree, children: [{ id: 'bad', type: 'NotAThing' }] },
} as CanvasDefinition;
render(
<CanvasRenderer definition={broken} queryRunner={{ run: jest.fn() }} />,
);
expect(screen.getByTestId('canvas-validation-errors')).toBeInTheDocument();
});
@@ -0,0 +1,188 @@
/**
* 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 } from 'react';
import { styled, useTheme } from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation';
import { CanvasDefinition, CdlFilter, CdlNode, Primitive } from './types';
import { MANIFEST } from './manifest';
import { runActions } from './actions';
import { resolveVars } from './resolve';
import { resolveStyle } from './style';
import {
FilterProvider,
QueryRunner,
QueryRunnerProvider,
UiStateProvider,
VariableProvider,
VariableProviderProps,
useFilters,
useUiState,
useVariables,
} from './runtime';
import { validateCanvas, ValidationError } from './validator';
const InvalidNode = styled.div`
padding: ${({ theme }) => theme.sizeUnit * 2}px;
border: 1px solid ${({ theme }) => theme.colorErrorBorder};
border-radius: ${({ theme }) => theme.borderRadius}px;
color: ${({ theme }) => theme.colorError};
font-family: ${({ theme }) => theme.fontFamilyCode};
font-size: ${({ theme }) => theme.fontSizeSM}px;
`;
/** The recursive resolver: catalog lookup, prop/bind/event wiring, recursion. */
function NodeRenderer({ node }: { node: CdlNode }) {
const { vars, decls, setVariable, reset } = useVariables();
const { setFilter, clearFilters } = useFilters();
const { setActiveTab, setModalOpen, refresh } = useUiState();
const theme = useTheme();
const entry = MANIFEST[node.type];
if (!entry) {
return <InvalidNode>{t('Unknown node type: %s', node.type)}</InvalidNode>;
}
const resolvedProps = resolveVars(node.props ?? {}, vars);
const getBound = (prop: string): Primitive | undefined => {
const ref = node.bind?.[prop];
return ref ? vars[ref.slice(1)] : undefined;
};
const setBound = (prop: string, value: Primitive) => {
const ref = node.bind?.[prop];
if (!ref) {
return;
}
const name = ref.slice(1);
// Controls emit strings (an Input always does). Coerce to the variable's
// declared type so a numeric parameter reaches the query as a number.
const declared = decls[name]?.type;
let next: Primitive = value;
if (declared === 'number' && typeof value === 'string') {
const parsed = Number(value);
next = Number.isFinite(parsed) ? parsed : 0;
} else if (declared === 'boolean' && typeof value === 'string') {
next = value === 'true';
}
setVariable(name, next);
};
const fire = (event: string, value?: Primitive) => {
runActions(node.on?.[event], {
vars,
setVariable,
// Keyed by node+column so repeated clicks replace rather than accumulate.
applyFilter: filter =>
setFilter(`${node.id}:${filter.col}`, { filter: filter as CdlFilter }),
crossFilter: filter =>
setFilter(`${node.id}:${filter.col}`, { filter: filter as CdlFilter }),
clearFilters: () => {
clearFilters();
reset();
},
navigateTab: (tabsId, tab) => setActiveTab(tabsId, tab),
setModalOpen: (modalId, open) => setModalOpen(modalId, open),
refresh: () => refresh(),
eventValue: value,
});
};
const Component = entry.component;
const renderNode = (child: CdlNode) => (
<NodeRenderer key={child.id} node={child} />
);
const children = node.children?.map(renderNode);
return (
<Component
node={node}
resolvedProps={resolvedProps}
fire={fire}
getBound={getBound}
setBound={setBound}
renderNode={renderNode}
style={resolveStyle(
node.style,
theme as unknown as Record<string, unknown>,
)}
>
{children}
</Component>
);
}
const ErrorList = styled.ul`
margin: 0;
padding: ${({ theme }) => theme.sizeUnit * 2}px
${({ theme }) => theme.sizeUnit * 4}px;
color: ${({ theme }) => theme.colorError};
font-family: ${({ theme }) => theme.fontFamilyCode};
font-size: ${({ theme }) => theme.fontSizeSM}px;
`;
function ValidationErrors({ errors }: { errors: ValidationError[] }) {
return (
<ErrorList data-test="canvas-validation-errors">
{errors.map(error => (
<li key={`${error.path}:${error.message}`}>
<strong>{error.path}</strong>: {error.message}
</li>
))}
</ErrorList>
);
}
export interface CanvasRendererProps {
definition: CanvasDefinition;
queryRunner: QueryRunner;
dataMaskSink?: VariableProviderProps['dataMaskSink'];
}
/**
* Top-level entry: validate the CDL (hard-reject on failure), then render the
* tree inside the query-runner and variable providers.
*/
export function CanvasRenderer({
definition,
queryRunner,
dataMaskSink,
}: CanvasRendererProps) {
const validation = useMemo(() => validateCanvas(definition), [definition]);
if (!validation.valid) {
return <ValidationErrors errors={validation.errors} />;
}
return (
<QueryRunnerProvider runner={queryRunner}>
<UiStateProvider>
<FilterProvider>
<VariableProvider
variables={definition.variables}
dataMaskSink={dataMaskSink}
>
<NodeRenderer node={definition.tree} />
</VariableProvider>
</FilterProvider>
</UiStateProvider>
</QueryRunnerProvider>
);
}
@@ -0,0 +1,131 @@
/**
* 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 { SupersetClient } from '@superset-ui/core';
import { t } from '@apache-superset/core/translation';
import { styled } from '@apache-superset/core/theme';
import { Loading } from '@superset-ui/core/components';
import { CanvasRenderer } from './CanvasRenderer';
import { createSupersetQueryRunner } from './queryRunner';
import { CanvasDefinition } from './types';
const runner = createSupersetQueryRunner();
const Page = styled.div<{ $maxWidth: string }>`
/* width:100% so the page fills the app's flex main area instead of
collapsing to its content width. The cap is canvas-controlled
(envelope.canvasWidth); default 'full' → no cap, matching dashboards. */
width: 100%;
max-width: ${({ $maxWidth }) => $maxWidth};
margin: 0 auto;
padding: ${({ theme }) => theme.sizeUnit * 6}px;
/* There is no global border-box reset in this codebase, so any node that
combines width:100% with author-supplied padding would overflow and force
horizontal scrolling. Scope the reset to the canvas subtree. */
&,
& *,
& *::before,
& *::after {
box-sizing: border-box;
}
`;
const Title = styled.h1`
font-size: ${({ theme }) => theme.fontSizeXL}px;
font-weight: ${({ theme }) => theme.fontWeightStrong};
margin-bottom: ${({ theme }) => theme.sizeUnit * 4}px;
`;
interface CanvasResponse {
result: {
name?: string;
// The REST API returns the CDL as a stored JSON string.
definition: CanvasDefinition | string;
};
}
const parseDefinition = (
definition: CanvasDefinition | string,
): CanvasDefinition =>
typeof definition === 'string' ? JSON.parse(definition) : definition;
interface ViewerState {
loading: boolean;
error?: string;
title?: string;
definition?: CanvasDefinition;
}
/**
* Loads a saved Canvas by id/uuid from the REST API and renders it against real
* data. The route element supplies `idOrUuid` from the URL.
*/
export function CanvasViewer({ idOrUuid }: { idOrUuid: string }) {
const [state, setState] = useState<ViewerState>({ loading: true });
useEffect(() => {
let live = true;
setState({ loading: true });
SupersetClient.get({ endpoint: `/api/v1/canvas/${idOrUuid}` })
.then(({ json }) => {
if (!live) return;
const { result } = json as unknown as CanvasResponse;
setState({
loading: false,
title: result.name,
definition: parseDefinition(result.definition),
});
})
.catch((error: unknown) => {
if (!live) return;
setState({
loading: false,
error: error instanceof Error ? error.message : String(error),
});
});
return () => {
live = false;
};
}, [idOrUuid]);
if (state.loading) {
return <Loading />;
}
if (state.error || !state.definition) {
return (
<Page $maxWidth="760px">
{t('Could not load canvas: %s', state.error ?? 'not found')}
</Page>
);
}
// Default to full-bleed (like a dashboard); a canvas opts into a narrower
// reading measure via envelope.canvasWidth (e.g. "820px").
const width = state.definition.canvasWidth;
const maxWidth = !width || width === 'full' ? 'none' : width;
return (
<Page $maxWidth={maxWidth}>
{state.title && <Title>{state.title}</Title>}
<CanvasRenderer definition={state.definition} queryRunner={runner} />
</Page>
);
}
+244
View File
@@ -0,0 +1,244 @@
/**
* 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 { CSSProperties, useEffect, useMemo, useRef } from 'react';
import { useTheme } from '@apache-superset/core/theme';
import {
init,
registerTheme,
use,
type EChartsCoreOption,
type EChartsType,
} from 'echarts/core';
import {
BarChart,
FunnelChart,
GaugeChart,
HeatmapChart,
LineChart,
PieChart,
RadarChart,
ScatterChart,
SunburstChart,
TreemapChart,
} from 'echarts/charts';
import {
DataZoomComponent,
GraphicComponent,
GridComponent,
LegendComponent,
MarkLineComponent,
PolarComponent,
RadarComponent,
TitleComponent,
TooltipComponent,
VisualMapComponent,
} from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
import { t } from '@apache-superset/core/translation';
import { VizNode } from './types';
import { encodeToOption, resolveFormatters } from './resolve';
import { useBoundQuery } from './runtime';
use([
CanvasRenderer,
LineChart,
BarChart,
PieChart,
ScatterChart,
RadarChart,
FunnelChart,
GaugeChart,
TreemapChart,
SunburstChart,
HeatmapChart,
GridComponent,
TooltipComponent,
LegendComponent,
TitleComponent,
VisualMapComponent,
PolarComponent,
RadarComponent,
DataZoomComponent,
GraphicComponent,
MarkLineComponent,
]);
const THEME_NAME = 'supersetCanvas';
type ThemeTokens = ReturnType<typeof useTheme>;
/** Signature that changes when the app flips light/dark — drives re-theming. */
const themeSignature = (theme: ThemeTokens): string =>
`${theme.colorText}|${theme.colorBgContainer}|${theme.colorPrimary}`;
/**
* A full echarts theme built from antd tokens. Registered globally so axis,
* legend and title colours follow the app theme — the parts a per-chart
* `option` can't reach, since option values can't reference `@themeToken`s.
*/
function buildEchartsTheme(theme: ThemeTokens): Record<string, unknown> {
const text = theme.colorText;
const sub = theme.colorTextSecondary;
const border = theme.colorBorderSecondary ?? theme.colorBorder;
const split = theme.colorSplit ?? border;
const palette = [
theme.colorPrimary,
theme.colorSuccess,
theme.colorWarning,
theme.colorInfo,
theme.colorError,
theme.colorPrimaryBorder,
theme.colorWarningBorder,
].filter(Boolean);
const axis = {
axisLine: { lineStyle: { color: border } },
axisTick: { lineStyle: { color: border } },
axisLabel: { color: sub },
splitLine: { lineStyle: { color: split } },
splitArea: { areaStyle: { color: ['transparent', 'transparent'] } },
};
return {
color: palette,
backgroundColor: 'transparent',
textStyle: { color: text, fontFamily: theme.fontFamily },
title: { textStyle: { color: text }, subtextStyle: { color: sub } },
legend: { textStyle: { color: sub } },
categoryAxis: axis,
valueAxis: axis,
logAxis: axis,
timeAxis: axis,
visualMap: { textStyle: { color: sub } },
graph: { color: palette },
};
}
/** Default framing; colours now come from the registered theme. */
const themedBase: Record<string, unknown> = {
grid: { left: 56, right: 16, top: 32, bottom: 32, containLabel: true },
};
function EchartsChart({
option,
themeKey,
height = 320,
style,
}: {
option: EChartsCoreOption;
themeKey: string;
height?: number;
style?: CSSProperties;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<EChartsType>();
const optionRef = useRef(option);
optionRef.current = option;
// Re-init when the theme flips (an echarts instance binds its theme at init).
useEffect(() => {
const element = containerRef.current;
if (!element) {
return undefined;
}
const chart = init(element, THEME_NAME);
chartRef.current = chart;
chart.setOption(optionRef.current, { notMerge: true });
const handleResize = () => chart.resize();
const observer = new ResizeObserver(handleResize);
observer.observe(element);
window.addEventListener('resize', handleResize);
return () => {
observer.disconnect();
window.removeEventListener('resize', handleResize);
chart.dispose();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [themeKey]);
useEffect(() => {
chartRef.current?.setOption(option, { notMerge: true });
}, [option]);
return (
<div
ref={containerRef}
data-test="canvas-echarts"
css={{ width: '100%', height }}
style={style}
/>
);
}
/**
* The echarts head of the Viz union: fetch a governed query, map the result onto
* the declarative option via `encoding`, resolve formatters, render.
*/
export function EchartsViz({
node,
style,
}: {
node: VizNode;
style?: CSSProperties;
}) {
const theme = useTheme();
const { data } = node;
const { loading, error, result } = useBoundQuery(
data?.queryContext ?? { datasetId: -1, metrics: [] },
);
// Register (or refresh) the theme during render, before the child's init
// effect runs — child effects fire before parent effects, so an effect here
// would be too late for the first paint.
const themeKey = themeSignature(theme);
useMemo(() => {
registerTheme(THEME_NAME, buildEchartsTheme(theme));
return null;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [themeKey]);
const option = useMemo<EChartsCoreOption | undefined>(() => {
if (!result || !data) {
return undefined;
}
const encoded = encodeToOption(
{ ...themedBase, ...node.option },
data.encoding,
result,
);
return resolveFormatters(encoded) as EChartsCoreOption;
}, [result, data, node.option]);
if (loading) {
return <div data-test="canvas-echarts-loading">{t('Loading…')}</div>;
}
if (error) {
return (
<div data-test="canvas-echarts-error">{t('Query error: %s', error)}</div>
);
}
if (result && result.records.length === 0) {
return (
<div data-test="canvas-echarts-empty">{t('No data for this query')}</div>
);
}
if (!option) {
return null;
}
return <EchartsChart option={option} themeKey={themeKey} style={style} />;
}
@@ -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 { CSSProperties, useEffect, useState } from 'react';
import { Select } from '@superset-ui/core/components';
import { CdlFilter, Primitive } from './types';
import { ActiveFilter, useFilters, useQueryRunner } from './runtime';
interface Option {
value: string | number;
label: string;
}
export interface FilterControlProps {
filterId: string;
column: string;
datasetId?: number;
label?: string;
multiple?: boolean;
op?: CdlFilter['op'];
options?: Option[];
style?: CSSProperties;
}
/**
* A canvas filter: renders a (multi)select of a column's distinct values and
* writes the selection into the canvas-global filter store, which every bound
* chart on the same dataset merges into its query.
*/
export function FilterControl({
filterId,
column,
datasetId,
label,
multiple,
op,
options: provided,
style,
}: FilterControlProps) {
const runner = useQueryRunner();
const { setFilter } = useFilters();
const [options, setOptions] = useState<Option[]>(provided ?? []);
const [value, setValue] = useState<Primitive | Primitive[] | undefined>(
multiple ? [] : undefined,
);
// Auto-populate options with the column's distinct values (COUNT(*) works on
// any dataset without needing a saved metric).
useEffect(() => {
if (provided?.length || datasetId == null || !column) {
return undefined;
}
let live = true;
runner
.run({ datasetId, metrics: ['COUNT(*)'], groupby: [column] })
.then(result => {
if (!live) return;
const seen = new Set<string>();
const opts: Option[] = [];
result.records.forEach(row => {
const raw = row[column];
const key = String(raw);
if (raw != null && !seen.has(key)) {
seen.add(key);
opts.push({ value: raw as string | number, label: key });
}
});
setOptions(opts);
})
.catch(() => undefined);
return () => {
live = false;
};
}, [runner, datasetId, column, provided]);
const handleChange = (next: unknown) => {
const selected = next as Primitive | Primitive[] | undefined;
setValue(selected);
const isEmpty =
selected == null ||
selected === '' ||
(Array.isArray(selected) && selected.length === 0);
const active: ActiveFilter | null = isEmpty
? null
: {
datasetId,
filter: {
col: column,
op: op ?? (multiple ? 'IN' : '=='),
val: selected as CdlFilter['val'],
},
};
setFilter(filterId, active);
};
return (
<div style={style}>
<Select
ariaLabel={label ?? column}
header={label ?? column}
placeholder={label ?? column}
mode={multiple ? 'multiple' : undefined}
options={options}
value={value as string | number | (string | number)[] | undefined}
onChange={handleChange}
allowClear
/>
</div>
);
}
@@ -0,0 +1,204 @@
/**
* 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 { CSSProperties, useEffect, useMemo, useState } from 'react';
import {
getClientErrorObject,
SupersetClient,
SuperChart,
type QueryData,
} from '@superset-ui/core';
import { getChartDataRequest } from 'src/components/Chart/chartAction';
import { t } from '@apache-superset/core/translation';
import { CdlFilter, VizNode } from './types';
import { resolveVars } from './resolve';
import { useActiveFilters, useUiState, useVariables } from './runtime';
interface ChartMeta {
vizType: string;
formData: Record<string, unknown>;
datasourceId?: number;
}
const parseJson = (value: unknown): Record<string, unknown> | undefined => {
if (typeof value === 'string' && value.trim()) {
try {
return JSON.parse(value) as Record<string, unknown>;
} catch {
return undefined;
}
}
return (value as Record<string, unknown>) || undefined;
};
/** SupersetClient rejects with a Response, not an Error — unwrap it. */
const describeError = async (err: unknown): Promise<string> => {
try {
const obj = await getClientErrorObject(
err as Parameters<typeof getClientErrorObject>[0],
);
return obj.error || obj.message || JSON.stringify(obj).slice(0, 200);
} catch {
return err instanceof Error ? err.message : String(err);
}
};
/**
* The governed head of the Viz union: renders an existing saved Superset chart
* with its own viz plugin and form_data. Data goes through the same
* getChartDataRequest the dashboard uses, so the plugin's buildQuery (and the
* legacy API fallback) apply. Canvas filters are passed as `extra_form_data`,
* exactly how native dashboard filters reach a chart.
*/
export function SupersetChartViz({
node,
style,
}: {
node: VizNode;
style?: CSSProperties;
}) {
const { chartId } = node;
const { vars } = useVariables();
const { refreshNonce } = useUiState();
const [meta, setMeta] = useState<ChartMeta | undefined>();
const [queriesData, setQueriesData] = useState<QueryData[] | undefined>();
const [error, setError] = useState<string | undefined>();
// 1. Load the saved chart's viz type and form_data.
useEffect(() => {
if (!chartId) {
return undefined;
}
let live = true;
setError(undefined);
SupersetClient.get({ endpoint: `/api/v1/chart/${chartId}` })
.then(({ json }) => {
if (!live) return;
const { result } = json as unknown as {
result: Record<string, unknown>;
};
// Prefer the API's form_data (already normalised); fall back to params.
const base =
parseJson(result.form_data) ?? parseJson(result.params) ?? {};
const vizType = String(result.viz_type ?? base.viz_type ?? '');
// A saved chart's params.datasource can be stale (example charts ship
// with the wrong id). Slice.form_data overrides it server-side with the
// authoritative datasource_id, so do the same here.
const datasourceId = result.datasource_id;
const datasourceType = String(result.datasource_type ?? 'table');
const datasource =
datasourceId != null
? `${datasourceId}__${datasourceType}`
: base.datasource;
setMeta({
vizType,
formData: {
...base,
datasource,
viz_type: vizType,
slice_id: chartId,
},
datasourceId: Number(datasourceId ?? NaN),
});
})
.catch(async err => {
const message = await describeError(err);
if (live) setError(message);
});
return () => {
live = false;
};
}, [chartId]);
// Canvas-global filters for this chart's dataset, plus node-level ones.
const activeFilters = useActiveFilters(meta?.datasourceId ?? -1);
const extraFilters = useMemo<CdlFilter[]>(
() => [...activeFilters, ...resolveVars(node.filters ?? [], vars)],
[activeFilters, node.filters, vars],
);
const filterKey = JSON.stringify(extraFilters);
// 2. Fetch data through the standard chart-data path.
useEffect(() => {
if (!meta) {
return undefined;
}
let live = true;
const formData = extraFilters.length
? {
...meta.formData,
extra_form_data: {
...((meta.formData.extra_form_data as Record<string, unknown>) ??
{}),
filters: extraFilters.map(f => ({
col: f.col,
op: f.op,
val: f.val,
})),
},
}
: meta.formData;
getChartDataRequest({ formData, resultFormat: 'json', resultType: 'full' })
.then(({ json }) => {
if (live) setQueriesData(json.result);
})
.catch(async err => {
const message = await describeError(err);
if (live) setError(message);
});
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [meta, filterKey, refreshNonce]);
if (!chartId) {
return (
<div data-test="canvas-superset-chart-error">
{t('chartId is required')}
</div>
);
}
if (error) {
return (
<div data-test="canvas-superset-chart-error">
{t('Chart error: %s', error)}
</div>
);
}
if (!meta || !queriesData) {
return <div data-test="canvas-superset-chart-loading">{t('Loading…')}</div>;
}
return (
<div
data-test="canvas-superset-chart"
style={{ width: '100%', height: 320, ...style }}
>
<SuperChart
chartType={meta.vizType}
formData={meta.formData}
queriesData={queriesData}
width="100%"
height="100%"
/>
</div>
);
}
+113
View File
@@ -0,0 +1,113 @@
/**
* 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 {
CdlAction,
CdlFilter,
Primitive,
VariableScope,
VariableValues,
} from './types';
import { isVarRef } from './resolve';
/** Token that resolves to the value emitted by the triggering event. */
const EVENT_TOKEN = '$event';
const HTTP_URL = /^https?:\/\//i;
/**
* The side-effecting surface an action can touch. Every capability is an
* explicit method here; there is no escape to arbitrary code. In-app, filter
* methods dispatch onto dataMask; in the prototype they are injected.
*/
export interface ActionContext {
vars: VariableValues;
setVariable: (name: string, value: Primitive) => void;
applyFilter: (filter: Pick<CdlFilter, 'col' | 'op' | 'val'>) => void;
crossFilter: (filter: Pick<CdlFilter, 'col' | 'op' | 'val'>) => void;
clearFilters: (scope?: VariableScope) => void;
navigateTab: (tabsId: string, tab: string) => void;
setModalOpen: (modalId: string, open: boolean) => void;
refresh: (target?: string) => void;
/** Value from the event that triggered this action list (e.g. Select value). */
eventValue?: Primitive;
}
function resolveActionValue(value: unknown, ctx: ActionContext): Primitive {
if (value === EVENT_TOKEN) {
return ctx.eventValue as Primitive;
}
if (isVarRef(value)) {
return ctx.vars[value.slice(1)];
}
return value as Primitive;
}
/** Run an ordered list of declarative actions against the injected context. */
export function runActions(
actions: CdlAction[] | undefined,
ctx: ActionContext,
): void {
(actions ?? []).forEach(action => {
switch (action.action) {
case 'setVariable':
ctx.setVariable(action.name, resolveActionValue(action.value, ctx));
break;
case 'applyFilter':
ctx.applyFilter({
col: action.col,
op: action.op,
val: resolveActionValue(action.val, ctx),
});
break;
case 'crossFilter':
ctx.crossFilter({
col: action.col,
op: action.op,
val: resolveActionValue(action.val, ctx),
});
break;
case 'clearFilters':
ctx.clearFilters(action.scope);
break;
case 'navigateTab':
ctx.navigateTab(action.tabsId, action.tab);
break;
case 'openModal':
ctx.setModalOpen(action.modalId, true);
break;
case 'closeModal':
ctx.setModalOpen(action.modalId, false);
break;
case 'openUrl':
if (HTTP_URL.test(action.url)) {
if (action.newTab) {
window.open(action.url, '_blank', 'noopener,noreferrer');
} else {
window.location.assign(action.url);
}
}
break;
case 'refresh':
ctx.refresh(action.target);
break;
default:
break;
}
});
}
+191
View File
@@ -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.
*/
/**
* The catalog is the schema half of the component manifest — pure metadata, no
* React. It is the allowlist of node types plus their contract (events,
* bindable props). The renderer's manifest maps these names to components; the
* validator and (eventually) the backend/MCP JSON-schema consume the same
* source so the AI-facing contract never drifts from the renderer.
*/
export type NodeCategory = 'layout' | 'viz' | 'control' | 'display';
export interface CatalogEntry {
name: string;
category: NodeCategory;
/** May hold `children`. */
container: boolean;
/** Allowed `on.*` event names. */
events: string[];
/** Props that accept a `$var` reference / two-way `bind`. */
bindableProps: string[];
/** Props that must be present. */
requiredProps: string[];
}
export const NODE_CATALOG: Record<string, CatalogEntry> = {
Column: {
name: 'Column',
category: 'layout',
container: true,
events: [],
bindableProps: [],
requiredProps: [],
},
Row: {
name: 'Row',
category: 'layout',
container: true,
events: [],
bindableProps: [],
requiredProps: [],
},
Card: {
name: 'Card',
category: 'layout',
container: true,
events: [],
bindableProps: [],
requiredProps: [],
},
Tabs: {
name: 'Tabs',
category: 'layout',
container: true,
events: [],
bindableProps: [],
requiredProps: [],
},
Board: {
name: 'Board',
category: 'layout',
container: true,
events: [],
bindableProps: [],
requiredProps: [],
},
Tab: {
name: 'Tab',
category: 'layout',
container: true,
events: [],
bindableProps: [],
requiredProps: ['label'],
},
Divider: {
name: 'Divider',
category: 'display',
container: false,
events: [],
bindableProps: [],
requiredProps: [],
},
Alert: {
name: 'Alert',
category: 'display',
container: false,
events: [],
bindableProps: [],
requiredProps: ['message'],
},
Progress: {
name: 'Progress',
category: 'display',
container: false,
events: [],
bindableProps: ['value'],
requiredProps: [],
},
Collapse: {
name: 'Collapse',
category: 'layout',
container: true,
events: [],
bindableProps: [],
requiredProps: [],
},
Modal: {
name: 'Modal',
category: 'layout',
container: true,
events: [],
bindableProps: [],
requiredProps: [],
},
Input: {
name: 'Input',
category: 'control',
container: false,
events: ['change'],
bindableProps: ['value'],
requiredProps: [],
},
Switch: {
name: 'Switch',
category: 'control',
container: false,
events: ['change'],
bindableProps: ['value'],
requiredProps: [],
},
Select: {
name: 'Select',
category: 'control',
container: false,
events: ['change'],
bindableProps: ['value'],
requiredProps: ['options'],
},
Button: {
name: 'Button',
category: 'control',
container: false,
events: ['click'],
bindableProps: [],
requiredProps: [],
},
Filter: {
name: 'Filter',
category: 'control',
container: false,
events: [],
bindableProps: [],
requiredProps: ['column'],
},
Markdown: {
name: 'Markdown',
category: 'display',
container: false,
events: [],
bindableProps: [],
requiredProps: ['text'],
},
Viz: {
name: 'Viz',
category: 'viz',
container: false,
events: [],
bindableProps: [],
requiredProps: [],
},
};
export const isKnownType = (type: string): boolean =>
Object.prototype.hasOwnProperty.call(NODE_CATALOG, type);
+306
View File
@@ -0,0 +1,306 @@
/**
* 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 { validateCanvas } from './validator';
import { encodeToOption, resolveVars, resolveFormatters } from './resolve';
import { runActions, ActionContext } from './actions';
import { salesCanvas } from './fixtures/salesCanvas';
import { CanvasDefinition } from './types';
const clone = (): CanvasDefinition => JSON.parse(JSON.stringify(salesCanvas));
test('the demo canvas is valid', () => {
expect(validateCanvas(salesCanvas)).toEqual({ valid: true, errors: [] });
});
test('rejects an executable string in an echarts option (no-code invariant)', () => {
const def = clone();
const chart = def.tree.children![2] as { option: Record<string, unknown> };
chart.option.tooltip = { formatter: '(v) => v.toFixed(2)' };
const result = validateCanvas(def);
expect(result.valid).toBe(false);
expect(
result.errors.some(e =>
/formatter must be a declarative object/.test(e.message),
),
).toBe(true);
});
test('rejects a reference to an undeclared variable', () => {
const def = clone();
const select = def.tree.children![1].children![0] as {
bind: Record<string, string>;
};
select.bind.value = '$undeclared';
const result = validateCanvas(def);
expect(result.valid).toBe(false);
expect(result.errors.some(e => /undeclared variable/.test(e.message))).toBe(
true,
);
});
test('rejects a javascript: url in openUrl', () => {
const def = clone();
def.tree.children![1].children![1].on = {
// eslint-disable-next-line no-script-url
click: [{ action: 'openUrl', url: 'javascript:alert(1)' }],
};
const result = validateCanvas(def);
expect(result.valid).toBe(false);
expect(result.errors.some(e => /http\(s\) only/.test(e.message))).toBe(true);
});
test('rejects children on a non-container node', () => {
const def = clone();
(def.tree.children![0] as { children: unknown[] }).children = [
{ id: 'x', type: 'Markdown', props: { text: 'nope' } },
];
const result = validateCanvas(def);
expect(result.valid).toBe(false);
expect(result.errors.some(e => /cannot have children/.test(e.message))).toBe(
true,
);
});
test('accepts the Alert/Progress/Collapse/Modal nodes and modal actions', () => {
const definition: CanvasDefinition = {
cdlVersion: 2,
variables: { goal: { type: 'number', default: 60, scope: 'ui' } },
tree: {
id: 'root',
type: 'Column',
children: [
{
id: 'a',
type: 'Alert',
props: { message: 'Partial year', type: 'warning' },
},
{ id: 'p', type: 'Progress', bind: { value: '$goal' } },
{
id: 'c',
type: 'Collapse',
children: [
{
id: 'sec',
type: 'Column',
props: { label: 'Notes' },
children: [{ id: 'm', type: 'Markdown', props: { text: 'hi' } }],
},
],
},
{
id: 'btn',
type: 'Button',
props: { label: 'Details' },
on: { click: [{ action: 'openModal', modalId: 'dlg' }] },
},
{
id: 'dlg',
type: 'Modal',
props: { title: 'Detail' },
children: [{ id: 'm2', type: 'Markdown', props: { text: 'drill' } }],
},
],
},
};
expect(validateCanvas(definition)).toEqual({ valid: true, errors: [] });
});
test('openModal without a modalId is rejected', () => {
const definition = {
cdlVersion: 2,
variables: {},
tree: {
id: 'b',
type: 'Button',
on: { click: [{ action: 'openModal' }] },
},
} as unknown as CanvasDefinition;
const result = validateCanvas(definition);
expect(result.valid).toBe(false);
expect(result.errors.some(e => /missing "modalId"/.test(e.message))).toBe(
true,
);
});
test('accepts a Board with layout placement and freeform styles', () => {
const definition: CanvasDefinition = {
cdlVersion: 2,
variables: {},
tree: {
id: 'board',
type: 'Board',
props: { columns: 12, rowHeight: 40 },
children: [
{
id: 'bg',
type: 'Markdown',
props: { text: 'behind' },
layout: { x: 0, y: 0, w: 8, h: 4 },
},
{
id: 'front',
type: 'Markdown',
props: { text: 'over it, tilted' },
layout: { x: 5, y: 1, w: 5, h: 3, z: 2 },
style: { position: 'relative', transform: 'rotate(-4deg)' },
},
],
},
};
expect(validateCanvas(definition)).toEqual({ valid: true, errors: [] });
});
test('rejects a malformed Board layout', () => {
const definition = {
cdlVersion: 2,
variables: {},
tree: {
id: 'board',
type: 'Board',
children: [
{
id: 'x',
type: 'Markdown',
props: { text: 'a' },
layout: { x: 0, y: 0, w: 0, h: 'tall' },
},
],
},
} as unknown as CanvasDefinition;
const result = validateCanvas(definition);
expect(result.valid).toBe(false);
expect(result.errors.some(e => /w must be at least 1/.test(e.message))).toBe(
true,
);
expect(result.errors.some(e => /h must be a number/.test(e.message))).toBe(
true,
);
});
test('resolveVars substitutes $var references deeply', () => {
const resolved = resolveVars(
{ filters: [{ col: 'region', val: '$region' }], keep: 1 },
{ region: 'EMEA' },
);
expect(resolved).toEqual({
filters: [{ col: 'region', val: 'EMEA' }],
keep: 1,
});
});
test('encodeToOption maps query results onto series data', () => {
const option = encodeToOption(
{ series: [{ type: 'bar' }] },
{ x: 'month', y: 'sales' },
{
columns: ['month', 'sales'],
records: [
{ month: 'Jan', sales: 10 },
{ month: 'Feb', sales: 20 },
],
},
);
expect(option.xAxis).toEqual({ type: 'category', data: ['Jan', 'Feb'] });
expect(option.series).toEqual([
{ type: 'bar', name: 'sales', data: [10, 20] },
]);
});
test('encodeToOption shapes pie data as name/value and drops axes', () => {
const option = encodeToOption(
{ series: [{ type: 'pie' }] },
{ x: 'genre', y: 'sales' },
{
columns: ['genre', 'sales'],
records: [
{ genre: 'Action', sales: 5 },
{ genre: 'Sports', sales: 7 },
],
},
);
expect(option.series).toEqual([
{
type: 'pie',
name: 'sales',
data: [
{ name: 'Action', value: 5 },
{ name: 'Sports', value: 7 },
],
},
]);
expect(option.xAxis).toBeUndefined();
});
test('encodeToOption shapes scatter data as [x, y, label] pairs', () => {
const option = encodeToOption(
{ series: [{ type: 'scatter' }] },
{ x: 'publisher', y: ['na', 'eu'] },
{
columns: ['publisher', 'na', 'eu'],
records: [{ publisher: 'Nintendo', na: 1, eu: 2 }],
},
);
const series = option.series as Array<{ data: unknown[] }>;
expect(series[0].data).toEqual([[1, 2, 'Nintendo']]);
});
test('encodeToOption builds radar indicators from categories', () => {
const option = encodeToOption(
{ series: [{ type: 'radar' }] },
{ x: 'genre', y: 'sales' },
{
columns: ['genre', 'sales'],
records: [
{ genre: 'Action', sales: 5 },
{ genre: 'Sports', sales: 9 },
],
},
);
const radar = option.radar as { indicator: Array<{ name: string }> };
expect(radar.indicator.map(i => i.name)).toEqual(['Action', 'Sports']);
const series = option.series as Array<{ data: Array<{ value: number[] }> }>;
expect(series[0].data[0].value).toEqual([5, 9]);
});
test('resolveFormatters turns a declarative spec into a function', () => {
const resolved = resolveFormatters({
tooltip: { valueFormatter: { kind: 'currency', currency: 'USD' } },
}) as { tooltip: { valueFormatter: (v: unknown) => string } };
const fn = resolved.tooltip.valueFormatter;
expect(typeof fn).toBe('function');
expect(fn(1000)).toMatch(/\$1,000/);
});
test('runActions dispatches setVariable with the event value', () => {
const writes: Array<[string, unknown]> = [];
const ctx: ActionContext = {
vars: {},
setVariable: (name, value) => writes.push([name, value]),
applyFilter: () => {},
crossFilter: () => {},
clearFilters: () => {},
navigateTab: () => {},
setModalOpen: () => {},
refresh: () => {},
eventValue: 'EMEA',
};
runActions([{ action: 'setVariable', name: 'region', value: '$event' }], ctx);
expect(writes).toEqual([['region', 'EMEA']]);
});
@@ -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.
*/
import { CanvasDefinition, CdlQueryContext } from '../types';
import { QueryResult } from '../resolve';
import { QueryRunner } from '../runtime';
/**
* Demo canvas: a Region select two-way bound to `$region`, a Reset button, and
* a bound echarts line chart whose query filters on `$region`. Changing the
* select re-runs the query — the reactive loop the prototype proves.
*/
export const salesCanvas: CanvasDefinition = {
cdlVersion: 2,
variables: {
region: { type: 'string', default: 'APAC', scope: 'query' },
},
tree: {
id: 'root',
type: 'Column',
children: [
{
id: 'title',
type: 'Markdown',
props: { text: 'Monthly sales by region' },
},
{
id: 'controls',
type: 'Row',
children: [
{
id: 'region-select',
type: 'Select',
props: {
label: 'Region',
options: [
{ value: 'APAC', label: 'APAC' },
{ value: 'EMEA', label: 'EMEA' },
{ value: 'AMER', label: 'AMER' },
],
},
bind: { value: '$region' },
},
{
id: 'reset',
type: 'Button',
props: { children: 'Reset', buttonStyle: 'secondary' },
on: {
click: [{ action: 'setVariable', name: 'region', value: 'APAC' }],
},
},
],
},
{
id: 'chart',
type: 'Viz',
renderer: 'echarts',
data: {
queryContext: {
datasetId: 1,
metrics: ['SUM(sales)'],
groupby: ['month'],
filters: [{ col: 'region', op: '==', val: '$region' }],
},
encoding: { x: 'month', y: 'SUM(sales)' },
},
option: {
series: [{ type: 'line', smooth: true }],
tooltip: {
trigger: 'axis',
valueFormatter: { kind: 'currency', currency: 'USD' },
},
yAxis: {
type: 'value',
axisLabel: { formatter: { kind: 'currency', currency: 'USD' } },
},
},
},
],
},
};
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'];
const SERIES_BY_REGION: Record<string, number[]> = {
APAC: [120, 132, 101, 134, 90, 230],
EMEA: [220, 182, 191, 234, 290, 330],
AMER: [150, 232, 201, 154, 190, 330],
};
/** In-memory QueryRunner standing in for /api/v1/chart/data during the prototype. */
export function createMockRunner(delayMs = 0): QueryRunner {
return {
run: (queryContext: CdlQueryContext): Promise<QueryResult> => {
const regionFilter = queryContext.filters?.find(f => f.col === 'region');
const region = String(regionFilter?.val ?? 'APAC');
const values = SERIES_BY_REGION[region] ?? SERIES_BY_REGION.APAC;
const records = MONTHS.map((month, i) => ({
month,
'SUM(sales)': values[i] * 1000,
}));
return new Promise(resolve => {
setTimeout(
() => resolve({ columns: ['month', 'SUM(sales)'], records }),
delayMs,
);
});
},
};
}
+28
View File
@@ -0,0 +1,28 @@
/**
* 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 { CanvasRenderer } from './CanvasRenderer';
export type { CanvasRendererProps } from './CanvasRenderer';
export { validateCanvas } from './validator';
export type { ValidationError, ValidationResult } from './validator';
export { MANIFEST } from './manifest';
export { NODE_CATALOG } from './catalog';
export type { QueryRunner } from './runtime';
export { createSupersetQueryRunner } from './queryRunner';
export * from './types';
+468
View File
@@ -0,0 +1,468 @@
/**
* 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 { CSSProperties, FC, ReactNode } from 'react';
import { styled } from '@apache-superset/core/theme';
import {
Button,
Card,
Collapse,
Divider,
Input,
Modal,
Progress,
SafeMarkdown,
Select,
Switch,
Tabs,
} from '@superset-ui/core/components';
import { Alert } from '@apache-superset/core/components';
import { CatalogEntry, NODE_CATALOG } from './catalog';
import { CdlFilter, CdlNode, Primitive, isVizNode } from './types';
import { EchartsViz } from './EchartsViz';
import { FilterControl } from './FilterControl';
import { SupersetChartViz } from './SupersetChartViz';
import { useUiState } from './runtime';
/**
* The manifest maps a catalog type to a React adapter. Adapters are thin: the
* renderer resolves props/bindings/events generically and hands them here; each
* adapter only knows how to wire the normalized inputs onto its component.
*/
export interface AdapterProps {
node: CdlNode;
resolvedProps: Record<string, unknown>;
children?: ReactNode;
/** Run the node's declarative handler list for an event. */
fire: (event: string, value?: Primitive) => void;
/** Current value of a two-way-bound prop (from the variable store). */
getBound: (prop: string) => Primitive | undefined;
/** Write a two-way-bound prop back to the variable store. */
setBound: (prop: string, value: Primitive) => void;
/**
* Render a specific child node. Containers that need structural control over
* their children (e.g. Tabs) use this instead of the flat `children`.
*/
renderNode: (child: CdlNode) => ReactNode;
/** Resolved inline styling from the node's `style` (theme tokens applied). */
style?: CSSProperties;
}
export interface ManifestEntry {
component: FC<AdapterProps>;
catalog: CatalogEntry;
}
const Column = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.sizeUnit * 3}px;
width: 100%;
/* padding must not widen the box (no global reset in this codebase) */
box-sizing: border-box;
min-width: 0;
`;
const Row = styled.div`
display: flex;
flex-direction: row;
gap: ${({ theme }) => theme.sizeUnit * 3}px;
width: 100%;
box-sizing: border-box;
min-width: 0;
/* Wrap rather than overflow on narrow viewports. */
flex-wrap: wrap;
& > * {
flex: 1 1 0;
min-width: 0;
}
`;
const ColumnAdapter: FC<AdapterProps> = ({ children, style }) => (
<Column style={style}>{children}</Column>
);
const RowAdapter: FC<AdapterProps> = ({ children, style }) => (
<Row style={style}>{children}</Row>
);
interface Option {
value: string | number;
label: string;
}
const SelectAdapter: FC<AdapterProps> = ({
resolvedProps,
fire,
getBound,
setBound,
style,
}) => {
const bound = getBound('value');
const value = bound ?? (resolvedProps.value as Primitive | undefined);
const options = (resolvedProps.options as Option[]) ?? [];
return (
<div style={style}>
<Select
ariaLabel={(resolvedProps.label as string) ?? 'Select'}
header={resolvedProps.label as ReactNode}
options={options}
value={(value ?? null) as string | number | null}
onChange={(next: unknown) => {
const val = next as Primitive;
setBound('value', val);
fire('change', val);
}}
/>
</div>
);
};
// Agents label controls under varying keys — accept the common ones.
const firstText = (
props: Record<string, unknown>,
keys: string[],
): ReactNode => {
const key = keys.find(k => props[k] != null);
return key ? (props[key] as ReactNode) : undefined;
};
const ButtonAdapter: FC<AdapterProps> = ({ resolvedProps, fire, style }) => (
<Button
style={style}
buttonStyle={
(resolvedProps.buttonStyle as 'primary' | 'secondary') ?? 'secondary'
}
buttonSize="small"
onClick={() => fire('click')}
>
{firstText(resolvedProps, [
'children',
'label',
'text',
'title',
'content',
]) ?? 'Button'}
</Button>
);
const MarkdownAdapter: FC<AdapterProps> = ({ resolvedProps, style }) => {
const source = firstText(resolvedProps, [
'text',
'source',
'content',
'markdown',
]);
return (
<div
data-test="canvas-markdown"
// SafeMarkdown wraps text in <p>/<h*> with default outer margins, which
// inflates a node's height — in a fixed-height Board that overflows into
// the cell below. Collapse the leading/trailing margins; inter-paragraph
// spacing (for narrative docs) is preserved.
css={{
'& > :first-child': { marginTop: 0 },
'& > :last-child': { marginBottom: 0 },
}}
style={style}
>
<SafeMarkdown source={source == null ? '' : String(source)} />
</div>
);
};
const InlineControl = styled.div`
display: flex;
align-items: center;
gap: ${({ theme }) => theme.sizeUnit * 2}px;
`;
const CardAdapter: FC<AdapterProps> = ({ resolvedProps, children, style }) => (
<Card
title={firstText(resolvedProps, ['title', 'label', 'header'])}
padded
style={style}
>
{children}
</Card>
);
const DividerAdapter: FC<AdapterProps> = ({ style }) => (
<Divider style={style} />
);
/** Tabs owns its children structurally, so it renders them via `renderNode`. */
const TabsAdapter: FC<AdapterProps> = ({
node,
resolvedProps,
renderNode,
style,
}) => {
const { activeTabs, setActiveTab } = useUiState();
const tabNodes = (node.children ?? []).filter(child => child.type === 'Tab');
const defaultKey = (resolvedProps.defaultTab as string) ?? tabNodes[0]?.id;
const activeKey = activeTabs[node.id] ?? defaultKey;
return (
<Tabs
style={style}
activeKey={activeKey}
onChange={key => setActiveTab(node.id, key)}
items={tabNodes.map(tab => ({
key: tab.id,
label: String(
firstText(tab.props ?? {}, ['label', 'title', 'text']) ?? tab.id,
),
children: renderNode(tab),
}))}
/>
);
};
const TabAdapter: FC<AdapterProps> = ({ children, style }) => (
<Column style={style}>{children}</Column>
);
/**
* Freeform layout: children are placed by their `layout` {x,y,w,h,z} on a
* `columns`-wide grid of `rowHeight`px rows. Overlap is allowed (grid areas can
* share cells), which is why `z` exists. Responsive — columns are fractions.
*/
const BoardAdapter: FC<AdapterProps> = ({
node,
resolvedProps,
renderNode,
style,
}) => {
const columns = Number(resolvedProps.columns ?? 12);
const rowHeight = Number(resolvedProps.rowHeight ?? 40);
const gap = Number(resolvedProps.gap ?? 8);
return (
<div
style={{
display: 'grid',
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
// rowHeight is a MINIMUM, not a cap: rows grow to fit content so an
// over-tall cell pushes the layout down instead of overlapping.
gridAutoRows: `minmax(${rowHeight}px, auto)`,
gap,
width: '100%',
...style,
}}
>
{(node.children ?? []).map(child => {
const l = child.layout;
// display:grid makes the single child stretch to fill the cell in both
// dimensions, so a Viz with height:100% fills its board cell.
const cell: CSSProperties = l
? {
gridColumn: `${l.x + 1} / span ${l.w}`,
gridRow: `${l.y + 1} / span ${l.h}`,
zIndex: l.z,
display: 'grid',
minWidth: 0,
minHeight: 0,
}
: { display: 'grid' };
return (
<div key={child.id} style={cell}>
{renderNode(child)}
</div>
);
})}
</div>
);
};
/** Narrative callouts — caveats, data-quality notes, "read this first". */
const AlertAdapter: FC<AdapterProps> = ({ resolvedProps, style }) => (
<Alert
style={style}
type={
(resolvedProps.type as 'info' | 'success' | 'warning' | 'error') ?? 'info'
}
message={firstText(resolvedProps, ['message', 'title', 'text', 'label'])}
description={resolvedProps.description as ReactNode}
showIcon={resolvedProps.showIcon !== false}
closable={Boolean(resolvedProps.closable)}
/>
);
/** Goal / target tracking — value is bindable to a variable. */
const ProgressAdapter: FC<AdapterProps> = ({
resolvedProps,
getBound,
style,
}) => {
const bound = getBound('value');
const percent = Number(bound ?? resolvedProps.value ?? 0);
return (
<div style={style}>
{resolvedProps.label != null && <div>{String(resolvedProps.label)}</div>}
<Progress
percent={Number.isFinite(percent) ? percent : 0}
type={(resolvedProps.type as 'line' | 'circle' | 'dashboard') ?? 'line'}
status={
resolvedProps.status as
'success' | 'exception' | 'active' | 'normal' | undefined
}
strokeColor={resolvedProps.strokeColor as string | undefined}
/>
</div>
);
};
/** Collapsible sections — each child becomes a panel titled by its label. */
const CollapseAdapter: FC<AdapterProps> = ({ node, renderNode, style }) => {
const sections = node.children ?? [];
return (
<Collapse
style={style}
ghost
items={sections.map(section => ({
key: section.id,
label: String(
firstText(section.props ?? {}, ['label', 'title', 'text']) ??
section.id,
),
children: renderNode(section),
}))}
/>
);
};
/**
* A drill-in panel: hidden until an `openModal` action targets it, so a button
* can reveal detail charts without leaving the canvas.
*/
const ModalAdapter: FC<AdapterProps> = ({ node, resolvedProps, children }) => {
const { openModals, setModalOpen } = useUiState();
return (
<Modal
show={Boolean(openModals[node.id])}
onHide={() => setModalOpen(node.id, false)}
title={firstText(resolvedProps, ['title', 'label', 'text'])}
footer={null}
width={resolvedProps.width as string | number | undefined}
hideFooter
>
{children}
</Modal>
);
};
const InputAdapter: FC<AdapterProps> = ({
resolvedProps,
fire,
getBound,
setBound,
style,
}) => {
const bound = getBound('value');
const value = String(bound ?? (resolvedProps.value as Primitive) ?? '');
return (
<InlineControl style={style}>
{resolvedProps.label != null && (
<span>{String(resolvedProps.label)}</span>
)}
<Input
placeholder={resolvedProps.placeholder as string | undefined}
value={value}
onChange={event => {
const next = event.target.value;
setBound('value', next);
fire('change', next);
}}
/>
</InlineControl>
);
};
const SwitchAdapter: FC<AdapterProps> = ({
resolvedProps,
fire,
getBound,
setBound,
style,
}) => {
const bound = getBound('value');
const checked = Boolean(bound ?? resolvedProps.value);
return (
<InlineControl style={style}>
<Switch
checked={checked}
onChange={(next: boolean) => {
setBound('value', next);
fire('change', next);
}}
/>
{resolvedProps.label != null && (
<span>{String(resolvedProps.label)}</span>
)}
</InlineControl>
);
};
interface FilterOption {
value: string | number;
label: string;
}
const FilterAdapter: FC<AdapterProps> = ({ node, resolvedProps, style }) => (
<FilterControl
style={style}
filterId={node.id}
column={resolvedProps.column as string}
datasetId={resolvedProps.dataset as number | undefined}
label={resolvedProps.label as string | undefined}
multiple={Boolean(resolvedProps.multiple)}
op={resolvedProps.op as CdlFilter['op'] | undefined}
options={resolvedProps.options as FilterOption[] | undefined}
/>
);
const VizAdapter: FC<AdapterProps> = ({ node, style }) => {
if (!isVizNode(node)) {
return null;
}
if (node.renderer === 'echarts') {
return <EchartsViz node={node} style={style} />;
}
return <SupersetChartViz node={node} style={style} />;
};
export const MANIFEST: Record<string, ManifestEntry> = {
Column: { component: ColumnAdapter, catalog: NODE_CATALOG.Column },
Row: { component: RowAdapter, catalog: NODE_CATALOG.Row },
Card: { component: CardAdapter, catalog: NODE_CATALOG.Card },
Tabs: { component: TabsAdapter, catalog: NODE_CATALOG.Tabs },
Tab: { component: TabAdapter, catalog: NODE_CATALOG.Tab },
Board: { component: BoardAdapter, catalog: NODE_CATALOG.Board },
Input: { component: InputAdapter, catalog: NODE_CATALOG.Input },
Switch: { component: SwitchAdapter, catalog: NODE_CATALOG.Switch },
Divider: { component: DividerAdapter, catalog: NODE_CATALOG.Divider },
Alert: { component: AlertAdapter, catalog: NODE_CATALOG.Alert },
Progress: { component: ProgressAdapter, catalog: NODE_CATALOG.Progress },
Collapse: { component: CollapseAdapter, catalog: NODE_CATALOG.Collapse },
Modal: { component: ModalAdapter, catalog: NODE_CATALOG.Modal },
Select: { component: SelectAdapter, catalog: NODE_CATALOG.Select },
Button: { component: ButtonAdapter, catalog: NODE_CATALOG.Button },
Filter: { component: FilterAdapter, catalog: NODE_CATALOG.Filter },
Markdown: { component: MarkdownAdapter, catalog: NODE_CATALOG.Markdown },
Viz: { component: VizAdapter, catalog: NODE_CATALOG.Viz },
};
+118
View File
@@ -0,0 +1,118 @@
/**
* 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 { SupersetClient } from '@superset-ui/core';
import { CdlQueryContext, Primitive } from './types';
import { QueryResult } from './resolve';
import { QueryRunner } from './runtime';
type AdhocMetric = {
expressionType: 'SQL';
sqlExpression: string;
label: string;
hasCustomLabel: boolean;
};
/**
* A metric string is either a saved-metric name or a SQL aggregate expression
* (e.g. `SUM(sales)`). The latter becomes an adhoc SQL metric so the demo works
* whether the agent used a saved metric or an inline expression.
*/
function toMetric(metric: string): string | AdhocMetric {
if (/[()]/.test(metric)) {
return {
expressionType: 'SQL',
sqlExpression: metric,
label: metric,
hasCustomLabel: false,
};
}
return metric;
}
function toQueryContext(qc: CdlQueryContext): Record<string, unknown> {
return {
datasource: { id: qc.datasetId, type: 'table' },
force: false,
queries: [
{
columns: qc.groupby ?? [],
metrics: qc.metrics.map(toMetric),
filters: (qc.filters ?? []).map(f => ({
col: f.col,
op: f.op,
val: f.val,
})),
row_limit: qc.rowLimit ?? 1000,
// query_context orderby is [[metric-or-column, isAscending], ...];
// a metric must be the same shape as it appears in `metrics`.
orderby: (qc.orderby ?? []).map(({ by, desc }) => [
qc.metrics.includes(by) ? toMetric(by) : by,
!desc,
]),
},
],
result_format: 'json',
result_type: 'full',
};
}
interface ChartDataPayload {
result?: Array<{
data?: Array<Record<string, Primitive>>;
colnames?: string[];
error?: string;
status?: string;
}>;
message?: string;
}
/**
* The real QueryRunner: bound queries go through the governed /api/v1/chart/data
* endpoint, inheriting RLS, row limits, and caching. Used by the Canvas viewer.
*/
export function createSupersetQueryRunner(): QueryRunner {
return {
run: async (queryContext: CdlQueryContext): Promise<QueryResult> => {
const { json } = await SupersetClient.post({
endpoint: '/api/v1/chart/data',
jsonPayload: toQueryContext(queryContext),
});
const payload = json as ChartDataPayload;
const first = payload.result?.[0];
// The endpoint returns HTTP 200 even when a query fails — the real
// message is per-query, so surface it instead of showing "No data".
// When the shape is unexpected (async job, error envelope, …) echo the
// raw body so we can see exactly what came back.
if (!first) {
throw new Error(
payload.message ??
`unexpected response: ${JSON.stringify(json).slice(0, 400)}`,
);
}
if (first.error || first.status === 'failed') {
throw new Error(first.error ?? 'query failed');
}
const records = first.data ?? [];
const columns =
first.colnames ?? (records[0] ? Object.keys(records[0]) : []);
return { columns, records };
},
};
}
+359
View File
@@ -0,0 +1,359 @@
/**
* 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 {
Encoding,
EncodingShape,
Formatter,
Primitive,
VariableValues,
} from './types';
const VAR_REF = /^\$([A-Za-z_][\w]*)$/;
export const isVarRef = (v: unknown): v is string =>
typeof v === 'string' && VAR_REF.test(v);
/** Deep-substitute every `$var` reference in a value with its current value. */
export function resolveVars<T>(value: T, vars: VariableValues): T {
if (isVarRef(value)) {
return vars[value.slice(1)] as unknown as T;
}
if (Array.isArray(value)) {
return value.map(v => resolveVars(v, vars)) as unknown as T;
}
if (value && typeof value === 'object') {
const out: Record<string, unknown> = {};
Object.entries(value as Record<string, unknown>).forEach(([k, v]) => {
out[k] = resolveVars(v, vars);
});
return out as unknown as T;
}
return value;
}
export interface QueryResult {
columns: string[];
records: Array<Record<string, Primitive>>;
}
const distinct = (values: string[]): string[] => Array.from(new Set(values));
const toNumber = (v: unknown): number =>
typeof v === 'number' ? v : Number(v);
/**
* Resolve a requested column name against what the query actually returned.
* The AI's `encoding` label (e.g. `SUM(global_sales)`) may not exactly match
* the response column, so fall back to a case-insensitive match, a substring
* match, or — if only one measure column remains — that column.
*/
function resolveColumn(
requested: string,
columns: string[],
exclude: string[],
): string {
if (columns.includes(requested)) {
return requested;
}
const lower = requested.toLowerCase();
const caseInsensitive = columns.find(c => c.toLowerCase() === lower);
if (caseInsensitive) {
return caseInsensitive;
}
const candidates = columns.filter(c => !exclude.includes(c));
const substring = candidates.find(
c => c.toLowerCase().includes(lower) || lower.includes(c.toLowerCase()),
);
if (substring) {
return substring;
}
if (candidates.length === 1) {
return candidates[0];
}
return requested;
}
const NAME_VALUE_TYPES = new Set([
'pie',
'funnel',
'treemap',
'sunburst',
'gauge',
]);
function firstSeriesType(
baseOption: Record<string, unknown>,
): string | undefined {
const { series } = baseOption;
const first = Array.isArray(series) ? series[0] : series;
const type = (first as Record<string, unknown> | undefined)?.type;
return typeof type === 'string' ? type : undefined;
}
/** Infer the data shape from the series type unless the encoding overrides it. */
function detectShape(
baseOption: Record<string, unknown>,
encoding: Encoding,
): EncodingShape {
if (encoding.shape) {
return encoding.shape;
}
const type = firstSeriesType(baseOption);
if (type === 'scatter' || type === 'effectScatter') {
return 'pairs';
}
if (type === 'heatmap') {
return 'matrix';
}
if (type === 'radar') {
return 'radar';
}
if (type && NAME_VALUE_TYPES.has(type)) {
return 'nameValue';
}
return 'categoryValue';
}
/**
* Map a query result onto an echarts option using the declarative `encoding`.
* The base `option` supplies presentation (series type, axis config); we fill in
* `xAxis.data` and `series[].data`.
*/
export function encodeToOption(
baseOption: Record<string, unknown>,
encoding: Encoding,
result: QueryResult,
): Record<string, unknown> {
const { x, y, series } = encoding;
const rows = result.records;
const columns = result.columns.length
? result.columns
: Object.keys(rows[0] ?? {});
// Reconcile the encoding's labels with the actual result columns.
const xKey = resolveColumn(x, columns, []);
const seriesKey = series ? resolveColumn(series, columns, [xKey]) : undefined;
const exclude = seriesKey ? [xKey, seriesKey] : [xKey];
const metricKeys = (Array.isArray(y) ? y : [y]).map(metric =>
resolveColumn(metric, columns, exclude),
);
const categories = distinct(rows.map(r => String(r[xKey])));
const baseSeriesList = Array.isArray(baseOption.series)
? (baseOption.series as Array<Record<string, unknown>>)
: [(baseOption.series as Record<string, unknown>) ?? {}];
const withBase = (index: number, spec: Record<string, unknown>) => ({
...baseSeriesList[index % baseSeriesList.length],
...spec,
});
const axis = (key: 'xAxis' | 'yAxis') =>
(baseOption[key] as Record<string, unknown>) ?? {};
const valueAt = (cat: string, metric: string): number | null => {
const row = rows.find(r => String(r[xKey]) === cat);
return row ? toNumber(row[metric]) : null;
};
switch (detectShape(baseOption, encoding)) {
// pie / funnel / treemap / sunburst / gauge — no axes, [{name, value}]
case 'nameValue': {
const metric = metricKeys[0];
return {
...baseOption,
series: [
withBase(0, {
name: metric,
data: categories.map(cat => ({
name: cat,
value: valueAt(cat, metric),
})),
}),
],
};
}
// scatter — [[xMetric, yMetric, categoryLabel]]
case 'pairs': {
const [mx, my = metricKeys[0]] = metricKeys;
return {
...baseOption,
xAxis: { type: 'value', ...axis('xAxis') },
yAxis: { type: 'value', ...axis('yAxis') },
series: [
withBase(0, {
name: `${mx} vs ${my}`,
data: rows.map(r => [
toNumber(r[mx]),
toNumber(r[my]),
String(r[xKey]),
]),
}),
],
};
}
// heatmap — [[xIndex, yIndex, value]] across x and the series column
case 'matrix': {
const metric = metricKeys[0];
const yCats = seriesKey
? distinct(rows.map(r => String(r[seriesKey])))
: [];
return {
...baseOption,
xAxis: { type: 'category', ...axis('xAxis'), data: categories },
yAxis: { type: 'category', ...axis('yAxis'), data: yCats },
series: [
withBase(0, {
name: metric,
data: rows.map(r => [
categories.indexOf(String(r[xKey])),
seriesKey ? yCats.indexOf(String(r[seriesKey])) : 0,
toNumber(r[metric]),
]),
}),
],
};
}
// radar — categories become indicators, one ring per metric
case 'radar': {
const values = metricKeys.flatMap(metric =>
categories.map(cat => valueAt(cat, metric) ?? 0),
);
const max = Math.max(1, ...values);
return {
...baseOption,
radar: {
...((baseOption.radar as Record<string, unknown>) ?? {}),
indicator: categories.map(cat => ({ name: cat, max })),
},
series: [
withBase(0, {
type: 'radar',
data: metricKeys.map(metric => ({
name: metric,
value: categories.map(cat => valueAt(cat, metric) ?? 0),
})),
}),
],
};
}
default:
break;
}
let seriesData: Array<{ name: string; data: Array<number | null> }>;
if (seriesKey) {
const groups = distinct(rows.map(r => String(r[seriesKey])));
seriesData = groups.map(group => ({
name: group,
data: categories.map(cat => {
const row = rows.find(
r => String(r[xKey]) === cat && String(r[seriesKey]) === group,
);
return row ? toNumber(row[metricKeys[0]]) : null;
}),
}));
} else {
seriesData = metricKeys.map(metric => ({
name: metric,
data: categories.map(cat => {
const row = rows.find(r => String(r[xKey]) === cat);
return row ? toNumber(row[metric]) : null;
}),
}));
}
const baseSeries = Array.isArray(baseOption.series)
? (baseOption.series as Array<Record<string, unknown>>)
: [(baseOption.series as Record<string, unknown>) ?? {}];
const mergedSeries = seriesData.map((s, i) => ({
...baseSeries[i % baseSeries.length],
...s,
}));
const baseXAxis = (baseOption.xAxis as Record<string, unknown>) ?? {
type: 'category',
};
return {
...baseOption,
xAxis: { ...baseXAxis, data: categories },
series: mergedSeries,
};
}
const FORMATTER_KINDS = new Set<Formatter['kind']>([
'number',
'currency',
'percent',
'date',
'template',
]);
function makeFormatter(f: Formatter): (v: unknown) => string {
switch (f.kind) {
case 'currency': {
const nf = new Intl.NumberFormat(undefined, {
style: 'currency',
currency: f.currency,
maximumFractionDigits: f.decimals ?? 0,
});
return v => nf.format(toNumber(v));
}
case 'number': {
const nf = new Intl.NumberFormat(undefined, {
maximumFractionDigits: f.decimals ?? 2,
});
return v => nf.format(toNumber(v));
}
case 'percent':
return v => `${(toNumber(v) * 100).toFixed(f.decimals ?? 0)}%`;
case 'date':
return v => String(v);
case 'template':
return v => f.template.replace('{value}', String(v));
default:
return v => String(v);
}
}
const isFormatterSpec = (v: unknown): v is Formatter =>
!!v &&
typeof v === 'object' &&
typeof (v as { kind?: unknown }).kind === 'string' &&
FORMATTER_KINDS.has((v as Formatter).kind);
/**
* Convert declarative formatter objects in an option into real functions — the
* only place functions are ever produced, and only at render time from a fixed
* vocabulary, never from persisted strings.
*/
export function resolveFormatters(value: unknown): unknown {
if (isFormatterSpec(value)) {
return makeFormatter(value);
}
if (Array.isArray(value)) {
return value.map(resolveFormatters);
}
if (value && typeof value === 'object') {
const out: Record<string, unknown> = {};
Object.entries(value as Record<string, unknown>).forEach(([k, v]) => {
out[k] = resolveFormatters(v);
});
return out;
}
return value;
}
+314
View File
@@ -0,0 +1,314 @@
/**
* 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 {
createContext,
ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import {
CdlFilter,
CdlQueryContext,
Primitive,
VariableDecl,
VariableValues,
} from './types';
import { QueryResult, resolveVars } from './resolve';
/* -------------------------------------------------------------------------- */
/* Variable store — the reactive spine. */
/* */
/* In-app, `query`-scoped variables project onto the host dashboard dataMask */
/* (governed re-queries, caching, RLS). The prototype keeps them in React */
/* state; `dataMaskSink` is the seam where the real dataMask dispatch plugs in.*/
/* -------------------------------------------------------------------------- */
interface VariableStore {
vars: VariableValues;
decls: Record<string, VariableDecl>;
setVariable: (name: string, value: Primitive) => void;
reset: () => void;
}
const VariableContext = createContext<VariableStore | undefined>(undefined);
export interface VariableProviderProps {
variables: Record<string, VariableDecl>;
children: ReactNode;
/** Seam: receives every write so query-scoped vars can be pushed to dataMask. */
dataMaskSink?: (
name: string,
value: Primitive,
scope: VariableDecl['scope'],
) => void;
}
export function VariableProvider({
variables,
children,
dataMaskSink,
}: VariableProviderProps) {
const initial = useMemo<VariableValues>(
() =>
Object.fromEntries(
Object.entries(variables).map(([name, decl]) => [name, decl.default]),
),
[variables],
);
const [vars, setVars] = useState<VariableValues>(initial);
const setVariable = useCallback(
(name: string, value: Primitive) => {
setVars(prev => ({ ...prev, [name]: value }));
dataMaskSink?.(name, value, variables[name]?.scope ?? 'ui');
},
[dataMaskSink, variables],
);
const reset = useCallback(() => setVars(initial), [initial]);
const value = useMemo<VariableStore>(
() => ({ vars, decls: variables, setVariable, reset }),
[vars, variables, setVariable, reset],
);
return (
<VariableContext.Provider value={value}>
{children}
</VariableContext.Provider>
);
}
export function useVariables(): VariableStore {
const ctx = useContext(VariableContext);
if (!ctx) {
throw new Error('useVariables must be used within a VariableProvider');
}
return ctx;
}
/* -------------------------------------------------------------------------- */
/* Query runner — the data seam. */
/* */
/* The prototype injects a runner (mock or real). The real runner maps a */
/* CdlQueryContext to a Superset query_context and POSTs /api/v1/chart/data, */
/* inheriting RLS/caching. Bound Viz nodes re-fetch when a referenced */
/* variable changes. */
/* -------------------------------------------------------------------------- */
export interface QueryRunner {
run: (queryContext: CdlQueryContext) => Promise<QueryResult>;
}
const QueryRunnerContext = createContext<QueryRunner | undefined>(undefined);
export function QueryRunnerProvider({
runner,
children,
}: {
runner: QueryRunner;
children: ReactNode;
}) {
return (
<QueryRunnerContext.Provider value={runner}>
{children}
</QueryRunnerContext.Provider>
);
}
export function useQueryRunner(): QueryRunner {
const ctx = useContext(QueryRunnerContext);
if (!ctx) {
throw new Error('useQueryRunner must be used within a QueryRunnerProvider');
}
return ctx;
}
/* -------------------------------------------------------------------------- */
/* Filter store — canvas-global filters (the native-filter analogue). */
/* */
/* A Filter node writes an entry here; every bound Viz on the same dataset */
/* merges the active filters into its query — so the AI places one filter and */
/* it applies across charts without wiring each query by hand. */
/* -------------------------------------------------------------------------- */
export interface ActiveFilter {
/** When set, the filter only applies to queries on this dataset. */
datasetId?: number;
filter: CdlFilter;
}
interface FilterStore {
filters: Record<string, ActiveFilter | null>;
setFilter: (id: string, value: ActiveFilter | null) => void;
clearFilters: () => void;
}
const FilterContext = createContext<FilterStore | undefined>(undefined);
export function FilterProvider({ children }: { children: ReactNode }) {
const [filters, setFilters] = useState<Record<string, ActiveFilter | null>>(
{},
);
const setFilter = useCallback(
(id: string, value: ActiveFilter | null) =>
setFilters(prev => ({ ...prev, [id]: value })),
[],
);
const clearFilters = useCallback(() => setFilters({}), []);
const value = useMemo<FilterStore>(
() => ({ filters, setFilter, clearFilters }),
[filters, setFilter, clearFilters],
);
return (
<FilterContext.Provider value={value}>{children}</FilterContext.Provider>
);
}
export function useFilters(): FilterStore {
const ctx = useContext(FilterContext);
if (!ctx) {
throw new Error('useFilters must be used within a FilterProvider');
}
return ctx;
}
/** The active filters applicable to a query on `datasetId`. */
export function useActiveFilters(datasetId: number): CdlFilter[] {
const { filters } = useFilters();
return useMemo(
() =>
Object.values(filters)
.filter((f): f is ActiveFilter => f != null)
.filter(f => f.datasetId === undefined || f.datasetId === datasetId)
.map(f => f.filter),
[filters, datasetId],
);
}
/* -------------------------------------------------------------------------- */
/* UI state — active tabs and a refresh nonce. */
/* */
/* Backs the `navigateTab` and `refresh` actions so the bounded action */
/* vocabulary is fully functional rather than partly stubbed. */
/* -------------------------------------------------------------------------- */
interface UiState {
activeTabs: Record<string, string>;
setActiveTab: (tabsId: string, tab: string) => void;
/** Modal nodes are hidden until an `openModal` action opens them. */
openModals: Record<string, boolean>;
setModalOpen: (modalId: string, open: boolean) => void;
/** Bumped by the `refresh` action; participates in every query cache key. */
refreshNonce: number;
refresh: () => void;
}
const UiStateContext = createContext<UiState | undefined>(undefined);
export function UiStateProvider({ children }: { children: ReactNode }) {
const [activeTabs, setActiveTabs] = useState<Record<string, string>>({});
const [openModals, setOpenModals] = useState<Record<string, boolean>>({});
const [refreshNonce, setRefreshNonce] = useState(0);
const setActiveTab = useCallback(
(tabsId: string, tab: string) =>
setActiveTabs(prev => ({ ...prev, [tabsId]: tab })),
[],
);
const setModalOpen = useCallback(
(modalId: string, open: boolean) =>
setOpenModals(prev => ({ ...prev, [modalId]: open })),
[],
);
const refresh = useCallback(() => setRefreshNonce(n => n + 1), []);
const value = useMemo<UiState>(
() => ({
activeTabs,
setActiveTab,
openModals,
setModalOpen,
refreshNonce,
refresh,
}),
[activeTabs, setActiveTab, openModals, setModalOpen, refreshNonce, refresh],
);
return (
<UiStateContext.Provider value={value}>{children}</UiStateContext.Provider>
);
}
export function useUiState(): UiState {
const ctx = useContext(UiStateContext);
if (!ctx) {
throw new Error('useUiState must be used within a UiStateProvider');
}
return ctx;
}
export interface BoundQueryState {
loading: boolean;
error?: string;
result?: QueryResult;
}
/**
* Resolve `$vars` in a query context against the live store, merge in active
* canvas filters, fetch, and re-fetch whenever the resolved context changes.
*/
export function useBoundQuery(queryContext: CdlQueryContext): BoundQueryState {
const runner = useQueryRunner();
const { vars } = useVariables();
const { refreshNonce } = useUiState();
const activeFilters = useActiveFilters(queryContext.datasetId);
const resolved = useMemo(() => {
const base = resolveVars(queryContext, vars);
return { ...base, filters: [...(base.filters ?? []), ...activeFilters] };
}, [queryContext, vars, activeFilters]);
const key = `${JSON.stringify(resolved)}|${refreshNonce}`;
const [state, setState] = useState<BoundQueryState>({ loading: true });
useEffect(() => {
let live = true;
setState({ loading: true });
runner
.run(resolved)
.then(result => {
if (live) setState({ loading: false, result });
})
.catch((error: unknown) => {
if (live) {
setState({
loading: false,
error: error instanceof Error ? error.message : String(error),
});
}
});
return () => {
live = false;
};
// `key` captures the resolved context; runner is stable.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
return state;
}
+177
View File
@@ -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.
*/
import { CSSProperties } from 'react';
import { CdlStyle } from './types';
/**
* Styling stays inside the no-code invariant: a node's `style` is a data object
* (never a CSS string), restricted to an allowlist of layout/appearance
* properties. Values may reference antd theme tokens as `@tokenName`, so
* AI-authored styling still respects light/dark theming.
*/
export const STYLE_PROPERTIES: ReadonlySet<string> = new Set([
// spacing
'padding',
'paddingTop',
'paddingRight',
'paddingBottom',
'paddingLeft',
'margin',
'marginTop',
'marginRight',
'marginBottom',
'marginLeft',
'gap',
'rowGap',
'columnGap',
// sizing
'width',
'minWidth',
'maxWidth',
'height',
'minHeight',
'maxHeight',
// surface
'background',
'backgroundColor',
'color',
'border',
'borderColor',
'borderWidth',
'borderStyle',
'borderRadius',
'boxShadow',
'opacity',
'overflow',
// typography
'fontSize',
'fontWeight',
'fontFamily',
'lineHeight',
'letterSpacing',
'textAlign',
'textTransform',
// layout
'display',
'flex',
'flexDirection',
'flexWrap',
'alignItems',
'justifyContent',
'alignSelf',
'gridTemplateColumns',
'gridTemplateRows',
'gridColumn',
'gridRow',
// freeform positioning + transforms (overlap, rotation, pinning)
'position',
'top',
'right',
'bottom',
'left',
'inset',
'zIndex',
'transform',
'transformOrigin',
'rotate',
'scale',
'translate',
'transition',
'aspectRatio',
'objectFit',
'filter',
'backdropFilter',
'mixBlendMode',
'cursor',
'pointerEvents',
]);
/** Values that could smuggle behaviour or external fetches into CSS. */
const UNSAFE_VALUE = /url\(|expression\(|javascript:|@import|<|\/\*/i;
const TOKEN_REF = /^@([A-Za-z_][\w]*)$/;
export const isTokenRef = (value: unknown): value is string =>
typeof value === 'string' && TOKEN_REF.test(value);
/**
* Validate a node's `style`. Returns human-readable errors (empty == valid).
* Shared shape with the server-side validator in
* superset/mcp_service/canvas/validation.py.
*/
export function validateStyle(style: unknown, path: string): string[] {
const errors: string[] = [];
if (style === undefined) {
return errors;
}
if (typeof style !== 'object' || style === null || Array.isArray(style)) {
errors.push(`${path}: style must be an object`);
return errors;
}
Object.entries(style as Record<string, unknown>).forEach(([prop, value]) => {
if (!STYLE_PROPERTIES.has(prop)) {
errors.push(`${path}.${prop}: unsupported style property`);
return;
}
if (typeof value !== 'string' && typeof value !== 'number') {
errors.push(`${path}.${prop}: style values must be a string or number`);
return;
}
if (typeof value === 'string' && UNSAFE_VALUE.test(value)) {
errors.push(`${path}.${prop}: disallowed value`);
}
});
return errors;
}
/**
* Resolve a validated `style` into React inline styles, substituting
* `@tokenName` references from the active theme. Unknown properties and unsafe
* values are dropped rather than thrown, so a bad style never breaks a render.
*/
export function resolveStyle(
style: CdlStyle | undefined,
theme: Record<string, unknown>,
): CSSProperties | undefined {
if (!style) {
return undefined;
}
const out: Record<string, string | number> = {};
Object.entries(style).forEach(([prop, value]) => {
if (!STYLE_PROPERTIES.has(prop)) {
return;
}
if (typeof value === 'string' && UNSAFE_VALUE.test(value)) {
return;
}
if (isTokenRef(value)) {
const token = value.slice(1);
const resolved = theme[token];
if (typeof resolved === 'string' || typeof resolved === 'number') {
out[prop] = resolved;
}
return;
}
if (typeof value === 'string' || typeof value === 'number') {
out[prop] = value;
}
});
return Object.keys(out).length ? (out as CSSProperties) : undefined;
}
+189
View File
@@ -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.
*/
/**
* CDL (Canvas Definition Language) — the typed, declarative tree an AI (or a
* human) emits to describe a v2 dashboard. Nothing here is executable code:
* presentation is a data `option`, data is a `queryContext` + `encoding`, and
* behaviour is a bounded `action` enum. See canvas-v2-design.md.
*/
export type Primitive = string | number | boolean;
/** A reference to a declared variable, written `$name` anywhere a value is expected. */
export type VarRef = `$${string}`;
export type VariableScope = 'query' | 'ui';
export interface VariableDecl {
type: 'string' | 'number' | 'boolean';
default: Primitive;
/**
* `query` variables project onto the host dashboard's dataMask (governed,
* re-queries bound Viz nodes). `ui` variables live client-side only.
*/
scope: VariableScope;
}
export type VariableValues = Record<string, Primitive>;
/** A value that may be a literal or a `$var` reference resolved at render time. */
export type Bindable<T extends Primitive> = T | VarRef;
export interface CdlFilter {
col: string;
op: '==' | '!=' | '>' | '<' | '>=' | '<=' | 'IN' | 'LIKE';
/** May be a literal or a `$var` reference. */
val: Primitive | Primitive[] | VarRef;
}
/** Declarative formatter vocabulary — resolved into a real function client-side. */
export type Formatter =
| { kind: 'number'; decimals?: number }
| { kind: 'currency'; currency: string; decimals?: number }
| { kind: 'percent'; decimals?: number }
| { kind: 'date'; format?: string }
| { kind: 'template'; template: string };
/** Minimal query spec; expands into a Superset query_context at fetch time. */
export interface CdlQueryContext {
datasetId: number;
metrics: string[];
groupby?: string[];
filters?: CdlFilter[];
rowLimit?: number;
/**
* Sort the result. Combine with `rowLimit` for top-N charts, e.g.
* `[{ by: 'SUM(global_sales)', desc: true }]` with `rowLimit: 10`.
* `by` may name a metric (from `metrics`) or a groupby column.
*/
orderby?: Array<{ by: string; desc?: boolean }>;
}
/**
* How the query result is shaped onto the echarts series. Inferred from the
* series type when omitted:
* - `categoryValue` — xAxis categories + numeric series (bar, line, area)
* - `nameValue` — [{name, value}] (pie, funnel, treemap, sunburst, gauge)
* - `pairs` — [[x, y]] from two metrics (scatter)
* - `matrix` — [[xIndex, yIndex, value]] (heatmap; needs `series`)
* - `radar` — indicators from categories, one ring per metric
*/
export type EncodingShape =
'categoryValue' | 'nameValue' | 'pairs' | 'matrix' | 'radar';
/** How query result columns map onto an echarts option. */
export interface Encoding {
/** Category/x dimension column. */
x: string;
/** Value column(s) for the series. */
y: string | string[];
/** Optional column whose distinct values fan out into one series each. */
series?: string | null;
/** Override the inferred data shape. */
shape?: EncodingShape;
}
export type CdlAction =
| { action: 'setVariable'; name: string; value: Primitive | VarRef }
| {
action: 'applyFilter';
col: string;
op: CdlFilter['op'];
val: CdlFilter['val'];
}
| {
action: 'crossFilter';
col: string;
op: CdlFilter['op'];
val: CdlFilter['val'];
}
| { action: 'clearFilters'; scope?: VariableScope }
| { action: 'navigateTab'; tabsId: string; tab: string }
| { action: 'openModal'; modalId: string }
| { action: 'closeModal'; modalId: string }
| { action: 'openUrl'; url: string; newTab?: boolean }
| { action: 'refresh'; target?: string };
export type CdlActionName = CdlAction['action'];
/**
* Declarative styling: an allowlisted CSS-property object (never a CSS string).
* Values may be literals or `@themeToken` references resolved from the antd
* theme at render time. See style.ts.
*/
export type CdlStyle = Record<string, string | number>;
/**
* Placement for a node inside a `Board` (freeform) container, in grid units:
* `x`/`y` are the top-left cell (0-based), `w`/`h` the span, `z` the stacking
* order for deliberate overlap. Ignored outside a Board.
*/
export interface BoardLayout {
x: number;
y: number;
w: number;
h: number;
z?: number;
}
export interface BaseNode {
/** Stable id — AI-addressable for targeted edits/diffs. */
id: string;
type: string;
props?: Record<string, unknown>;
/** Allowlisted inline styling, theme-token aware. */
style?: CdlStyle;
/** Position within a parent Board (grid units). */
layout?: BoardLayout;
/** Two-way binding of a prop to a `$var`. */
bind?: Record<string, VarRef>;
/** Declarative event handlers: event name -> ordered action list. */
on?: Record<string, CdlAction[]>;
children?: CdlNode[];
}
export interface VizNode extends BaseNode {
type: 'Viz';
renderer: 'echarts' | 'supersetChart';
/** supersetChart: reference an existing governed Slice. */
chartId?: number;
/** supersetChart: extra dataMask filters. */
filters?: CdlFilter[];
/** echarts: bound data spec. */
data?: { queryContext: CdlQueryContext; encoding: Encoding };
/** echarts: the presentation option (data, not code). */
option?: Record<string, unknown>;
}
export type CdlNode = BaseNode | VizNode;
export interface CanvasDefinition {
cdlVersion: number;
variables: Record<string, VariableDecl>;
tree: CdlNode;
/**
* Outer width cap. `"full"` (default) is full-bleed like a dashboard; a CSS
* width (e.g. `"820px"`) centres a narrower reading measure for documents.
*/
canvasWidth?: string;
}
export const isVizNode = (node: CdlNode): node is VizNode =>
node.type === 'Viz';
+326
View File
@@ -0,0 +1,326 @@
/**
* 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 { NODE_CATALOG, isKnownType } from './catalog';
import { validateStyle } from './style';
import {
CanvasDefinition,
CdlAction,
CdlActionName,
CdlNode,
isVizNode,
} from './types';
export interface ValidationError {
path: string;
message: string;
}
export interface ValidationResult {
valid: boolean;
errors: ValidationError[];
}
const VAR_REF = /^\$([A-Za-z_][\w]*)$/;
const EVENT_TOKEN = '$event';
/** The no-code invariant: reject anything that smells like an executable string. */
const CODE_SMELL = /=>|\bfunction\b|new\s+Function/i;
const JS_URL = /^\s*(javascript|data|vbscript):/i;
/**
* Keyed by the action union, so adding an action to `CdlAction` without
* declaring its required params is a compile error rather than a runtime
* "unknown action" that only shows up in the browser.
*/
const ACTION_REQUIRED: Record<CdlActionName, string[]> = {
setVariable: ['name', 'value'],
applyFilter: ['col', 'op', 'val'],
crossFilter: ['col', 'op', 'val'],
clearFilters: [],
navigateTab: ['tabsId', 'tab'],
openModal: ['modalId'],
closeModal: ['modalId'],
openUrl: ['url'],
refresh: [],
};
const isVarRef = (v: unknown): v is string =>
typeof v === 'string' && VAR_REF.test(v);
const varName = (ref: string): string => ref.slice(1);
/** Deep-scan a value for executable-looking strings (the core safety gate). */
function scanNoCode(
value: unknown,
path: string,
errors: ValidationError[],
): void {
if (typeof value === 'string') {
if (CODE_SMELL.test(value)) {
errors.push({
path,
message: `disallowed executable string (no-code invariant): ${value.slice(0, 40)}`,
});
}
return;
}
if (Array.isArray(value)) {
value.forEach((v, i) => scanNoCode(v, `${path}[${i}]`, errors));
return;
}
if (value && typeof value === 'object') {
Object.entries(value as Record<string, unknown>).forEach(([k, v]) => {
// A `formatter` must be a declarative object, never a raw string/function.
if (k === 'formatter' && typeof v === 'string') {
errors.push({
path: `${path}.formatter`,
message: 'formatter must be a declarative object, not a string',
});
}
scanNoCode(v, `${path}.${k}`, errors);
});
}
}
/** Collect every `$var` reference in a value (excluding the `$event` token). */
function collectRefs(value: unknown, out: Set<string>): void {
if (isVarRef(value) && value !== EVENT_TOKEN) {
out.add(varName(value));
} else if (Array.isArray(value)) {
value.forEach(v => collectRefs(v, out));
} else if (value && typeof value === 'object') {
Object.values(value as Record<string, unknown>).forEach(v =>
collectRefs(v, out),
);
}
}
function validateAction(
action: CdlAction,
path: string,
declared: Set<string>,
errors: ValidationError[],
): void {
const required = ACTION_REQUIRED[action.action];
if (!required) {
errors.push({ path, message: `unknown action "${action.action}"` });
return;
}
const record = action as unknown as Record<string, unknown>;
required.forEach(key => {
if (record[key] === undefined) {
errors.push({
path,
message: `action "${action.action}" missing "${key}"`,
});
}
});
if (action.action === 'openUrl' && JS_URL.test(action.url)) {
errors.push({
path: `${path}.url`,
message: 'openUrl allows http(s) only',
});
}
if (action.action === 'setVariable' && !declared.has(action.name)) {
errors.push({
path: `${path}.name`,
message: `undeclared variable "${action.name}"`,
});
}
}
function validateNode(
node: CdlNode,
path: string,
declared: Set<string>,
errors: ValidationError[],
): void {
if (!node || typeof node !== 'object') {
errors.push({ path, message: 'node must be an object' });
return;
}
if (typeof node.id !== 'string' || !node.id) {
errors.push({ path, message: 'node.id (string) is required' });
}
if (!isKnownType(node.type)) {
errors.push({
path,
message: `unknown node type "${node.type}" (not in catalog)`,
});
return; // can't validate further against an unknown contract
}
const entry = NODE_CATALOG[node.type];
// Required props
entry.requiredProps.forEach(prop => {
if (node.props?.[prop] === undefined) {
errors.push({
path: `${path}.props.${prop}`,
message: `required prop "${prop}" missing`,
});
}
});
// Children only on containers
if (node.children?.length && !entry.container) {
errors.push({
path: `${path}.children`,
message: `"${node.type}" cannot have children`,
});
}
// bind targets must be bindable + reference declared vars
Object.entries(node.bind ?? {}).forEach(([prop, ref]) => {
if (!entry.bindableProps.includes(prop)) {
errors.push({
path: `${path}.bind.${prop}`,
message: `prop "${prop}" is not bindable`,
});
}
if (!isVarRef(ref)) {
errors.push({
path: `${path}.bind.${prop}`,
message: `bind must be a $var reference`,
});
} else if (!declared.has(varName(ref))) {
errors.push({
path: `${path}.bind.${prop}`,
message: `undeclared variable "${ref}"`,
});
}
});
// Events must be in the catalog; actions must be valid
Object.entries(node.on ?? {}).forEach(([event, actions]) => {
if (!entry.events.includes(event)) {
errors.push({
path: `${path}.on.${event}`,
message: `"${node.type}" does not emit "${event}"`,
});
}
(actions ?? []).forEach((action, i) =>
validateAction(action, `${path}.on.${event}[${i}]`, declared, errors),
);
});
// Viz-specific
if (isVizNode(node)) {
if (node.renderer === 'echarts') {
if (!node.data?.queryContext) {
errors.push({
path: `${path}.data`,
message: 'echarts Viz requires data.queryContext',
});
}
if (!node.data?.encoding) {
errors.push({
path: `${path}.data`,
message: 'echarts Viz requires data.encoding',
});
}
scanNoCode(node.option, `${path}.option`, errors);
} else if (node.renderer === 'supersetChart') {
if (typeof node.chartId !== 'number') {
errors.push({
path: `${path}.chartId`,
message: 'supersetChart Viz requires chartId',
});
}
} else {
errors.push({
path: `${path}.renderer`,
message: `unknown Viz renderer`,
});
}
}
// No-code scan over props (option scanned above for Viz)
scanNoCode(node.props, `${path}.props`, errors);
// Declarative styling: allowlisted properties, safe values only.
validateStyle(node.style, `${path}.style`).forEach(message =>
errors.push({ path: `${path}.style`, message }),
);
// Board placement: numeric grid units.
if (node.layout !== undefined) {
const layout = node.layout as unknown as Record<string, unknown>;
(['x', 'y', 'w', 'h'] as const).forEach(key => {
if (typeof layout[key] !== 'number') {
errors.push({
path: `${path}.layout.${key}`,
message: `${key} must be a number`,
});
}
});
if (typeof layout.w === 'number' && layout.w < 1) {
errors.push({
path: `${path}.layout.w`,
message: 'w must be at least 1',
});
}
if (typeof layout.h === 'number' && layout.h < 1) {
errors.push({
path: `${path}.layout.h`,
message: 'h must be at least 1',
});
}
}
// Reference integrity across props / bind / on / data
const refs = new Set<string>();
collectRefs(node.props, refs);
collectRefs(node.on, refs);
if (isVizNode(node)) collectRefs(node.data?.queryContext, refs);
refs.forEach(name => {
if (!declared.has(name)) {
errors.push({
path,
message: `references undeclared variable "$${name}"`,
});
}
});
node.children?.forEach((child, i) =>
validateNode(child, `${path}.children[${i}]`, declared, errors),
);
}
export function validateCanvas(definition: CanvasDefinition): ValidationResult {
const errors: ValidationError[] = [];
if (typeof definition?.cdlVersion !== 'number') {
errors.push({
path: 'cdlVersion',
message: 'cdlVersion (number) is required',
});
}
if (!definition?.variables || typeof definition.variables !== 'object') {
errors.push({ path: 'variables', message: 'variables object is required' });
}
if (!definition?.tree) {
errors.push({ path: 'tree', message: 'tree (root node) is required' });
return { valid: false, errors };
}
const declared = new Set(Object.keys(definition.variables ?? {}));
validateNode(definition.tree, 'tree', declared, errors);
return { valid: errors.length === 0, errors };
}
@@ -0,0 +1,28 @@
/**
* 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 { FC } from 'react';
import { useParams } from 'react-router-dom';
import { CanvasViewer } from 'src/Canvas/CanvasViewer';
const CanvasRoute: FC = () => {
const { canvasId } = useParams<{ canvasId: string }>();
return <CanvasViewer idOrUuid={canvasId} />;
};
export default CanvasRoute;
@@ -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.
*/
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { t } from '@apache-superset/core/translation';
import withToasts from 'src/components/MessageToasts/withToasts';
import SubMenu from 'src/features/home/SubMenu';
import { useListViewResource } from 'src/views/CRUD/hooks';
import {
ListView,
ListViewFilterOperator as FilterOperator,
type ListViewFilters,
} from 'src/components';
const PAGE_SIZE = 25;
interface CanvasObject {
id: number;
name: string;
changed_on_delta_humanized?: string;
created_by?: { first_name: string; last_name: string };
}
interface CanvasListProps {
addDangerToast: (msg: string) => void;
addSuccessToast: (msg: string) => void;
}
function CanvasList({ addDangerToast, addSuccessToast }: CanvasListProps) {
const {
state: {
loading,
resourceCount: canvasCount,
resourceCollection: canvases,
},
fetchData,
refreshData,
} = useListViewResource<CanvasObject>(
'canvas',
t('Canvases'),
addDangerToast,
);
const columns = useMemo(
() => [
{
accessor: 'name',
Header: t('Name'),
Cell: ({ row: { original } }: { row: { original: CanvasObject } }) => (
<Link to={`/canvas/${original.id}/`}>{original.name}</Link>
),
},
{
accessor: 'created_by',
Header: t('Created by'),
disableSortBy: true,
Cell: ({ row: { original } }: { row: { original: CanvasObject } }) =>
original.created_by
? `${original.created_by.first_name} ${original.created_by.last_name}`
: '',
},
{
accessor: 'changed_on_delta_humanized',
Header: t('Last modified'),
},
],
[],
);
const filters: ListViewFilters = useMemo(
() => [
{
Header: t('Name'),
key: 'search',
id: 'name',
input: 'search',
operator: FilterOperator.Contains,
},
],
[],
);
return (
<>
<SubMenu name={t('Canvases')} />
<ListView<CanvasObject>
className="canvas-list-view"
columns={columns}
count={canvasCount}
data={canvases}
fetchData={fetchData}
refreshData={refreshData}
filters={filters}
initialSort={[{ id: 'changed_on_delta_humanized', desc: true }]}
loading={loading}
pageSize={PAGE_SIZE}
addDangerToast={addDangerToast}
addSuccessToast={addSuccessToast}
/>
</>
);
}
export default withToasts(CanvasList);
@@ -27,6 +27,8 @@ export const RoutePaths = {
FILE_HANDLER: '/file-handler',
DASHBOARD: '/dashboard/:idOrSlug/',
DASHBOARD_LIST: '/dashboard/list/',
CANVAS: '/canvas/:canvasId/',
CANVAS_LIST: '/canvas/list/',
CHART_ADD: '/chart/add',
CHART_LIST: '/chart/list/',
DATASET_LIST: '/tablemodelview/list/',
+12
View File
@@ -80,6 +80,14 @@ const Dashboard = lazy(
() => import(/* webpackChunkName: "Dashboard" */ 'src/pages/Dashboard'),
);
const Canvas = lazy(
() => import(/* webpackChunkName: "Canvas" */ 'src/pages/Canvas'),
);
const CanvasList = lazy(
() => import(/* webpackChunkName: "CanvasList" */ 'src/pages/CanvasList'),
);
const DatabaseList = lazy(
() => import(/* webpackChunkName: "DatabaseList" */ 'src/pages/DatabaseList'),
);
@@ -206,6 +214,10 @@ export const routes: Routes = [
{ path: RoutePaths.FILE_HANDLER, Component: FileHandler },
{ path: RoutePaths.DASHBOARD_LIST, Component: DashboardList },
{ path: RoutePaths.DASHBOARD, Component: Dashboard },
// CANVAS_LIST must precede CANVAS: '/canvas/:canvasId/' would otherwise
// match '/canvas/list/' with canvasId="list".
{ path: RoutePaths.CANVAS_LIST, Component: CanvasList },
{ path: RoutePaths.CANVAS, Component: Canvas },
{ path: RoutePaths.CHART_ADD, Component: ChartCreation },
{ path: RoutePaths.CHART_LIST, Component: ChartList },
{ path: RoutePaths.DATASET_LIST, Component: DatasetList },
+16
View File
@@ -0,0 +1,16 @@
# 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.
+70
View File
@@ -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.
import logging
from flask_appbuilder.models.sqla.interface import SQLAInterface
from superset.canvas.filters import CanvasAccessFilter
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod
from superset.models.canvas import Canvas
from superset.views.base_api import BaseSupersetModelRestApi, RelatedFieldFilter
from superset.views.filters import BaseFilterRelatedUsers, FilterRelatedUsers
logger = logging.getLogger(__name__)
class CanvasRestApi(BaseSupersetModelRestApi):
datamodel = SQLAInterface(Canvas)
include_route_methods = RouteMethod.REST_MODEL_VIEW_CRUD_SET | {RouteMethod.RELATED}
class_permission_name = "Canvas"
method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
resource_name = "canvas"
allow_browser_login = True
base_filters = [["id", CanvasAccessFilter, lambda: []]]
show_columns = [
"id",
"name",
"definition",
"created_by.first_name",
"created_by.id",
"created_by.last_name",
"changed_on_delta_humanized",
]
list_columns = [
"id",
"name",
"changed_on_delta_humanized",
"created_by.first_name",
"created_by.id",
"created_by.last_name",
]
add_columns = ["name", "definition"]
edit_columns = add_columns
order_columns = ["name", "changed_on_delta_humanized"]
allowed_rel_fields = {"created_by", "changed_by"}
related_field_filters = {
"created_by": RelatedFieldFilter("first_name", FilterRelatedUsers),
}
base_related_field_filters = {
"created_by": [["id", BaseFilterRelatedUsers, lambda: []]],
}
openapi_spec_tag = "Canvas"
+59
View File
@@ -0,0 +1,59 @@
# 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 flask_appbuilder.models.sqla.filters import BaseFilter
from sqlalchemy import false, or_
from sqlalchemy.orm.query import Query
from superset.extensions import db, security_manager
from superset.models.canvas import Canvas
from superset.utils.core import get_user_id
class CanvasAccessFilter(BaseFilter): # pylint: disable=too-few-public-methods
"""Scope canvases to the current user's editor/viewer grants.
Mirrors the chart/dashboard editors-viewers pattern (see
``superset/charts/filters.py``); admins are unfiltered.
"""
def apply(self, query: Query, value: Any) -> Query:
if security_manager.is_admin():
return query
from superset.subjects.models import canvas_editors, canvas_viewers
from superset.subjects.utils import get_user_subject_ids_subquery
user_id = get_user_id()
if not user_id:
return query.filter(false())
subject_subquery = get_user_subject_ids_subquery(user_id)
editor_query = (
db.session.query(Canvas.id)
.join(canvas_editors, Canvas.id == canvas_editors.c.canvas_id)
.filter(canvas_editors.c.subject_id.in_(subject_subquery))
)
viewer_query = (
db.session.query(Canvas.id)
.join(canvas_viewers, Canvas.id == canvas_viewers.c.canvas_id)
.filter(canvas_viewers.c.subject_id.in_(subject_subquery))
)
return query.filter(
or_(Canvas.id.in_(editor_query), Canvas.id.in_(viewer_query))
)
+11
View File
@@ -164,6 +164,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
from superset.async_events.api import AsyncEventsRestApi
from superset.available_domains.api import AvailableDomainsRestApi
from superset.cachekeys.api import CacheRestApi
from superset.canvas.api import CanvasRestApi
from superset.charts.api import ChartRestApi
from superset.charts.data.api import ChartDataRestApi
from superset.css_templates.api import CssTemplateRestApi
@@ -201,6 +202,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
from superset.views.all_entities import TaggedObjectsModelView
from superset.views.annotations import AnnotationLayerView
from superset.views.api import Api
from superset.views.canvas import CanvasModelView
from superset.views.chart.views import SliceModelView
from superset.views.core import Superset
from superset.views.css_templates import CssTemplateModelView
@@ -256,6 +258,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
appbuilder.add_api(CacheRestApi)
appbuilder.add_api(ChartRestApi)
appbuilder.add_api(ChartDataRestApi)
appbuilder.add_api(CanvasRestApi)
appbuilder.add_api(CssTemplateRestApi)
appbuilder.add_api(ThemeRestApi)
appbuilder.add_api(CurrentUserRestApi)
@@ -347,6 +350,14 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
category="",
category_icon="",
)
appbuilder.add_view(
CanvasModelView,
"Canvases",
label=_("Canvases"),
icon="fa-object-group",
category="",
category_icon="",
)
appbuilder.add_link(
"Datasets",
+6
View File
@@ -752,6 +752,12 @@ 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
generate_canvas,
get_canvas,
get_canvas_schema,
update_canvas,
)
from superset.mcp_service.chart import ( # noqa: F401, E402
prompts as chart_prompts,
resources as chart_resources,
+16
View File
@@ -0,0 +1,16 @@
# 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.
+187
View File
@@ -0,0 +1,187 @@
# 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.
"""Targeted edits to a CDL tree, addressed by stable node id.
This is why nodes carry ids: an agent can restyle, move, replace or remove a
single node instead of regenerating the whole canvas (which silently drifts the
parts nobody asked to change).
"""
from __future__ import annotations
import copy
from collections.abc import Iterator
from typing import Any
Node = dict[str, Any]
def _walk(node: Node, parent: Node | None = None) -> Iterator[tuple[Node, Node | None]]:
"""Yield (node, parent) for the tree, depth-first."""
yield node, parent
for child in node.get("children") or []:
yield from _walk(child, node)
def find_node(tree: Node, node_id: str) -> tuple[Node | None, Node | None]:
"""Return (node, parent) for `node_id`; (None, None) when absent."""
for node, parent in _walk(tree):
if node.get("id") == node_id:
return node, parent
return None, None
def _detach(tree: Node, node_id: str) -> Node | None:
node, parent = find_node(tree, node_id)
if node is None or parent is None:
return None
parent["children"] = [c for c in parent.get("children") or [] if c is not node]
return node
def _insert_into(
parent: Node,
node: Node,
before: str | None,
after: str | None,
index: int | None,
) -> None:
children = parent.setdefault("children", [])
position = len(children)
if index is not None:
position = max(0, min(index, len(children)))
elif before is not None:
position = next(
(i for i, c in enumerate(children) if c.get("id") == before), len(children)
)
elif after is not None:
position = next(
(i + 1 for i, c in enumerate(children) if c.get("id") == after),
len(children),
)
children.insert(position, node)
def apply_ops( # noqa: C901
definition: Node, ops: list[dict[str, Any]]
) -> tuple[Node, list[str]]:
"""Apply patch ops to a CDL definition.
Returns (new_definition, errors). The input is never mutated; on any error
the caller should discard the result rather than persist a partial edit.
"""
updated = copy.deepcopy(definition)
tree = updated.get("tree")
errors: list[str] = []
if not isinstance(tree, dict):
return updated, ["definition.tree is missing or not an object"]
for i, op in enumerate(ops):
path = f"ops[{i}]"
kind = op.get("op")
node_id = op.get("id")
if not kind:
errors.append(f"{path}: 'op' is required")
continue
if not node_id:
errors.append(f"{path}: 'id' is required")
continue
target, parent = find_node(tree, node_id)
# `insert` addresses the PARENT, so it is the one op that tolerates a
# target that is not itself being edited.
if kind == "insert":
new_node = op.get("node")
if not isinstance(new_node, dict):
errors.append(f"{path}: 'node' object is required for insert")
elif target is None:
errors.append(f"{path}: no parent node with id {node_id!r}")
else:
_insert_into(
target,
new_node,
op.get("before"),
op.get("after"),
op.get("index"),
)
continue
if target is None:
errors.append(f"{path}: no node with id {node_id!r}")
continue
if kind == "setStyle":
style = op.get("style") or {}
if op.get("merge", True):
target["style"] = {**(target.get("style") or {}), **style}
else:
target["style"] = dict(style)
elif kind == "setProps":
props = op.get("props") or {}
if op.get("merge", True):
target["props"] = {**(target.get("props") or {}), **props}
else:
target["props"] = dict(props)
elif kind == "setOption":
option = op.get("option") or {}
if op.get("merge", True):
target["option"] = {**(target.get("option") or {}), **option}
else:
target["option"] = dict(option)
elif kind == "remove":
if parent is None:
errors.append(f"{path}: cannot remove the root node")
continue
_detach(tree, node_id)
elif kind == "replace":
node = op.get("node")
if not isinstance(node, dict):
errors.append(f"{path}: 'node' object is required for replace")
continue
if parent is None:
updated["tree"] = node
tree = node
continue
children = parent.get("children") or []
parent["children"] = [node if c is target else c for c in children]
elif kind == "move":
if parent is None:
errors.append(f"{path}: cannot move the root node")
continue
new_parent_id = op.get("parent")
before, after, index = op.get("before"), op.get("after"), op.get("index")
if new_parent_id:
new_parent, _ = find_node(tree, new_parent_id)
if new_parent is None:
errors.append(f"{path}: no parent node with id {new_parent_id!r}")
continue
else:
# Default to reordering within the current parent.
new_parent = parent
if before is None and after is None and index is None:
errors.append(f"{path}: move needs one of 'before', 'after', 'index'")
continue
detached = _detach(tree, node_id)
if detached is None:
errors.append(f"{path}: could not detach {node_id!r}")
continue
_insert_into(new_parent, detached, before, after, index)
else:
errors.append(f"{path}: unknown op {kind!r}")
return updated, errors
+139
View File
@@ -0,0 +1,139 @@
# 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 request/response schemas for the canvas MCP tools."""
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, Field
class GetCanvasSchemaRequest(BaseModel):
"""No arguments — returns the static CDL contract."""
class GetCanvasSchemaResponse(BaseModel):
cdl_schema: dict[str, Any] = Field(
..., description="The CDL contract to author a canvas against."
)
class GenerateCanvasRequest(BaseModel):
name: str = Field(..., description="Human-readable canvas title.")
definition: dict[str, Any] = Field(
...,
description=(
"The full CDL tree ({cdlVersion, variables, tree}). "
"Call get_canvas_schema first for the contract."
),
)
class CanvasInfo(BaseModel):
id: int
name: str
url: str
uuid: str | None = None
class GetCanvasRequest(BaseModel):
identifier: int | str = Field(..., description="Canvas id or uuid.")
class GetCanvasResponse(BaseModel):
canvas: CanvasInfo | None = None
definition: dict[str, Any] | None = Field(
default=None, description="The stored CDL tree — read node ids from here."
)
error: str | None = None
class CanvasPatchOp(BaseModel):
"""A single targeted edit addressed by node id."""
op: Literal[
"setStyle", "setProps", "setOption", "move", "replace", "insert", "remove"
] = Field(..., description="The edit to perform.")
id: str = Field(
...,
description=(
"Target node id. For 'insert' this is the PARENT node to insert into."
),
)
style: dict[str, Any] | None = Field(
default=None, description="setStyle: style object to apply."
)
props: dict[str, Any] | None = Field(
default=None, description="setProps: props to apply."
)
option: dict[str, Any] | None = Field(
default=None, description="setOption: echarts option to apply (Viz nodes)."
)
node: dict[str, Any] | None = Field(
default=None, description="replace/insert: the full node object."
)
parent: str | None = Field(
default=None,
description="move: id of the new parent (defaults to the current parent).",
)
before: str | None = Field(
default=None, description="move/insert: place before this sibling id."
)
after: str | None = Field(
default=None, description="move/insert: place after this sibling id."
)
index: int | None = Field(
default=None, description="move/insert: explicit position among children."
)
merge: bool = Field(
default=True,
description=(
"setStyle/setProps/setOption: merge into the existing object (True) "
"or replace it wholesale (False)."
),
)
class UpdateCanvasRequest(BaseModel):
identifier: int | str = Field(..., description="Canvas id or uuid.")
ops: list[CanvasPatchOp] = Field(
..., description="Ordered patch operations, applied in sequence."
)
name: str | None = Field(default=None, description="Optionally rename the canvas.")
class UpdateCanvasResponse(BaseModel):
canvas: CanvasInfo | None = None
canvas_url: str | None = None
applied_ops: int | None = None
error: str | None = None
validation_errors: list[str] | None = Field(
default=None,
description="Patch or CDL failures — nothing was saved; fix and retry.",
)
class GenerateCanvasResponse(BaseModel):
canvas: CanvasInfo | None = None
canvas_url: str | None = None
error: str | None = None
validation_errors: list[str] | None = Field(
default=None,
description="CDL validation failures — fix these and retry.",
)
warnings: list[str] | None = None
@@ -0,0 +1,28 @@
# 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.mcp_service.canvas.tool.generate_canvas import generate_canvas
from superset.mcp_service.canvas.tool.get_canvas import get_canvas
from superset.mcp_service.canvas.tool.get_canvas_schema import get_canvas_schema
from superset.mcp_service.canvas.tool.update_canvas import update_canvas
__all__ = [
"generate_canvas",
"get_canvas",
"get_canvas_schema",
"update_canvas",
]
@@ -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: generate_canvas."""
import logging
from fastmcp import Context
from flask import g
from sqlalchemy.exc import SQLAlchemyError
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.extensions import db, event_logger
from superset.mcp_service.canvas.schemas import (
CanvasInfo,
GenerateCanvasRequest,
GenerateCanvasResponse,
)
from superset.mcp_service.canvas.validation import validate_cdl
from superset.mcp_service.utils.url_utils import get_superset_base_url
from superset.utils import json
logger = logging.getLogger(__name__)
@tool(
tags=["mutate"],
class_permission_name="Canvas",
annotations=ToolAnnotations(
title="Create canvas",
readOnlyHint=False,
destructiveHint=False,
),
)
def generate_canvas(
request: GenerateCanvasRequest,
ctx: Context, # noqa: ARG001
) -> GenerateCanvasResponse:
"""Create a NEW AI-native canvas (v2 dashboard) from a CDL definition.
Call get_canvas_schema first for the contract, and list_datasets /
get_dataset_info to resolve real datasetId/columns/metrics for bound
charts. The definition is validated server-side; on failure the response
carries ``validation_errors`` to fix and retry (no canvas is created).
Returns the canvas id and URL on success.
"""
definition = request.definition
if validation_errors := validate_cdl(definition):
return GenerateCanvasResponse(
error="CDL validation failed; fix validation_errors and retry.",
validation_errors=validation_errors,
)
try:
# Imported lazily to avoid encrypted-column init before app setup,
# matching generate_dashboard.
from superset.extensions import security_manager
from superset.models.canvas import Canvas
from superset.subjects.utils import get_user_subject
with event_logger.log_context(action="mcp.generate_canvas.db_write"):
canvas = Canvas()
canvas.name = request.name
canvas.definition = json.dumps(definition)
# Re-query the user in this session (g.user may be bound to a
# torn-down session in the MCP context; see generate_dashboard).
current_user = (
db.session.query(security_manager.user_model)
.filter_by(id=g.user.id)
.first()
)
if current_user:
subject = get_user_subject(current_user.id)
if subject:
canvas.editors = [subject]
db.session.add(canvas)
db.session.commit() # pylint: disable=consider-using-transaction
try:
db.session.refresh(canvas)
except SQLAlchemyError:
logger.warning(
"Canvas %s created but refresh failed", canvas.id, exc_info=True
)
canvas_url = f"{get_superset_base_url()}/canvas/{canvas.id}/"
logger.info("Created canvas %s", canvas.id)
return GenerateCanvasResponse(
canvas=CanvasInfo(
id=canvas.id,
name=canvas.name,
url=canvas_url,
uuid=str(canvas.uuid) if canvas.uuid else None,
),
canvas_url=canvas_url,
)
except (SQLAlchemyError, ValueError, AttributeError) as ex:
try:
db.session.rollback() # pylint: disable=consider-using-transaction
except SQLAlchemyError:
logger.warning("Rollback failed during error handling", exc_info=True)
logger.error("Error creating canvas: %s", ex, exc_info=True)
return GenerateCanvasResponse(
error="Failed to create canvas due to an internal error."
)
@@ -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.
"""MCP tool: get_canvas."""
import logging
from fastmcp import Context
from sqlalchemy.exc import SQLAlchemyError
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.mcp_service.canvas.schemas import (
CanvasInfo,
GetCanvasRequest,
GetCanvasResponse,
)
from superset.mcp_service.canvas.utils import find_canvas
from superset.mcp_service.utils.url_utils import get_superset_base_url
from superset.utils import json
logger = logging.getLogger(__name__)
@tool(
tags=["read"],
class_permission_name="Canvas",
annotations=ToolAnnotations(
title="Get canvas",
readOnlyHint=True,
destructiveHint=False,
),
)
def get_canvas(
request: GetCanvasRequest,
ctx: Context, # noqa: ARG001
) -> GetCanvasResponse:
"""Read a canvas and its CDL definition by id or uuid.
Call this before update_canvas so you can read the node ids you want to
target with patch operations.
"""
try:
canvas = find_canvas(request.identifier)
if canvas is None:
return GetCanvasResponse(error=f"Canvas {request.identifier!r} not found.")
definition = json.loads(canvas.definition) if canvas.definition else None
return GetCanvasResponse(
canvas=CanvasInfo(
id=canvas.id,
name=canvas.name,
url=f"{get_superset_base_url()}/canvas/{canvas.id}/",
uuid=str(canvas.uuid) if canvas.uuid else None,
),
definition=definition,
)
except (SQLAlchemyError, ValueError) as ex:
logger.error("Error reading canvas: %s", ex, exc_info=True)
return GetCanvasResponse(
error="Failed to read canvas due to an internal error."
)
@@ -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.
"""MCP tool: get_canvas_schema."""
import logging
from fastmcp import Context
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.mcp_service.canvas.schemas import (
GetCanvasSchemaRequest,
GetCanvasSchemaResponse,
)
from superset.mcp_service.canvas.validation import build_cdl_schema
logger = logging.getLogger(__name__)
@tool(
tags=["read"],
class_permission_name="Canvas",
annotations=ToolAnnotations(
title="Get canvas schema",
readOnlyHint=True,
destructiveHint=False,
),
)
def get_canvas_schema(
request: GetCanvasSchemaRequest, # noqa: ARG001
ctx: Context, # noqa: ARG001
) -> GetCanvasSchemaResponse:
"""Return the CDL contract to author an AI-native canvas (v2 dashboard).
Call this BEFORE composing a canvas so you emit a valid definition. It
lists node types, the action vocabulary, formatter kinds, hard rules
(no code, declarative formatters), and a worked example.
Workflow: get_canvas_schema -> list_datasets -> get_dataset_info (columns
and metrics) -> compose CDL -> generate_canvas.
"""
return GetCanvasSchemaResponse(cdl_schema=build_cdl_schema())
@@ -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.
"""MCP tool: update_canvas."""
import logging
from fastmcp import Context
from sqlalchemy.exc import SQLAlchemyError
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.extensions import db, event_logger
from superset.mcp_service.canvas.patch import apply_ops
from superset.mcp_service.canvas.schemas import (
CanvasInfo,
UpdateCanvasRequest,
UpdateCanvasResponse,
)
from superset.mcp_service.canvas.utils import find_canvas
from superset.mcp_service.canvas.validation import validate_cdl
from superset.mcp_service.utils.url_utils import get_superset_base_url
from superset.utils import json
logger = logging.getLogger(__name__)
@tool(
tags=["mutate"],
class_permission_name="Canvas",
annotations=ToolAnnotations(
title="Update canvas",
readOnlyHint=False,
destructiveHint=False,
),
)
def update_canvas(
request: UpdateCanvasRequest,
ctx: Context, # noqa: ARG001
) -> UpdateCanvasResponse:
"""Edit an existing canvas with targeted patch ops instead of regenerating it.
Call get_canvas first to read node ids. Ops are applied in order:
- setStyle {id, style} restyle a node (merge by default)
- setProps {id, props} change component props (e.g. a title)
- setOption {id, option} change a Viz node's echarts option
- move {id, before|after|index, parent?} reorder / reparent a node
- insert {id: <parentId>, node, before|after|index} add a node
- replace {id, node} swap a node wholesale
- remove {id} delete a node
The patched CDL is fully re-validated; on any failure NOTHING is saved and
validation_errors explains why. Prefer this over generate_canvas for edits
regenerating drifts the parts the user did not ask to change.
"""
try:
canvas = find_canvas(request.identifier)
if canvas is None:
return UpdateCanvasResponse(
error=f"Canvas {request.identifier!r} not found."
)
definition = json.loads(canvas.definition) if canvas.definition else {}
ops = [op.model_dump(exclude_none=True) for op in request.ops]
updated, patch_errors = apply_ops(definition, ops)
if patch_errors:
return UpdateCanvasResponse(
error="Patch failed; nothing was saved.",
validation_errors=patch_errors,
)
cdl_errors = validate_cdl(updated)
if cdl_errors:
return UpdateCanvasResponse(
error="Patched canvas is not valid CDL; nothing was saved.",
validation_errors=cdl_errors,
)
with event_logger.log_context(action="mcp.update_canvas.db_write"):
canvas.definition = json.dumps(updated)
if request.name:
canvas.name = request.name
db.session.add(canvas)
db.session.commit() # pylint: disable=consider-using-transaction
canvas_url = f"{get_superset_base_url()}/canvas/{canvas.id}/"
logger.info("Updated canvas %s with %s ops", canvas.id, len(ops))
return UpdateCanvasResponse(
canvas=CanvasInfo(
id=canvas.id,
name=canvas.name,
url=canvas_url,
uuid=str(canvas.uuid) if canvas.uuid else None,
),
canvas_url=canvas_url,
applied_ops=len(ops),
)
except (SQLAlchemyError, ValueError, AttributeError) as ex:
try:
db.session.rollback() # pylint: disable=consider-using-transaction
except SQLAlchemyError:
logger.warning("Rollback failed during error handling", exc_info=True)
logger.error("Error updating canvas: %s", ex, exc_info=True)
return UpdateCanvasResponse(
error="Failed to update canvas due to an internal error."
)
+33
View File
@@ -0,0 +1,33 @@
# 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 canvas MCP tools."""
from __future__ import annotations
from typing import Any
from superset.extensions import db
def find_canvas(identifier: int | str) -> Any:
"""Look up a Canvas by integer id or uuid string."""
from superset.models.canvas import Canvas
query = db.session.query(Canvas)
if isinstance(identifier, int) or str(identifier).isdigit():
return query.filter(Canvas.id == int(identifier)).one_or_none()
return query.filter(Canvas.uuid == str(identifier)).one_or_none()
+734
View File
@@ -0,0 +1,734 @@
# 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.
"""Server-side CDL validation.
A faithful port of the frontend validator (superset-frontend/src/Canvas/
validator.ts). It enforces structure, the no-code invariant, and variable
reference integrity so that ``generate_canvas`` can reject bad AI output with
structured errors the agent self-corrects against.
"""
from __future__ import annotations
import re
from typing import Any
# The catalog: the allowlist of node types plus their contract. Mirrors
# superset-frontend/src/Canvas/catalog.ts.
NODE_CATALOG: dict[str, dict[str, Any]] = {
"Column": {
"category": "layout",
"container": True,
"events": [],
"bindableProps": [],
"requiredProps": [],
}, # noqa: E501
"Row": {
"category": "layout",
"container": True,
"events": [],
"bindableProps": [],
"requiredProps": [],
}, # noqa: E501
"Card": {
"category": "layout",
"container": True,
"events": [],
"bindableProps": [],
"requiredProps": [],
}, # noqa: E501
"Tabs": {
"category": "layout",
"container": True,
"events": [],
"bindableProps": [],
"requiredProps": [],
}, # noqa: E501
"Board": {
"category": "layout",
"container": True,
"events": [],
"bindableProps": [],
"requiredProps": [],
}, # noqa: E501
"Tab": {
"category": "layout",
"container": True,
"events": [],
"bindableProps": [],
"requiredProps": ["label"],
}, # noqa: E501
"Divider": {
"category": "display",
"container": False,
"events": [],
"bindableProps": [],
"requiredProps": [],
}, # noqa: E501
"Alert": {
"category": "display",
"container": False,
"events": [],
"bindableProps": [],
"requiredProps": ["message"],
}, # noqa: E501
"Progress": {
"category": "display",
"container": False,
"events": [],
"bindableProps": ["value"],
"requiredProps": [],
}, # noqa: E501
"Collapse": {
"category": "layout",
"container": True,
"events": [],
"bindableProps": [],
"requiredProps": [],
}, # noqa: E501
"Modal": {
"category": "layout",
"container": True,
"events": [],
"bindableProps": [],
"requiredProps": [],
}, # noqa: E501
"Input": {
"category": "control",
"container": False,
"events": ["change"],
"bindableProps": ["value"],
"requiredProps": [],
}, # noqa: E501
"Switch": {
"category": "control",
"container": False,
"events": ["change"],
"bindableProps": ["value"],
"requiredProps": [],
}, # noqa: E501
"Select": {
"category": "control",
"container": False,
"events": ["change"],
"bindableProps": ["value"],
"requiredProps": ["options"],
}, # noqa: E501
"Button": {
"category": "control",
"container": False,
"events": ["click"],
"bindableProps": [],
"requiredProps": [],
}, # noqa: E501
"Filter": {
"category": "control",
"container": False,
"events": [],
"bindableProps": [],
"requiredProps": ["column"],
}, # noqa: E501
"Markdown": {
"category": "display",
"container": False,
"events": [],
"bindableProps": [],
"requiredProps": ["text"],
}, # noqa: E501
"Viz": {
"category": "viz",
"container": False,
"events": [],
"bindableProps": [],
"requiredProps": [],
}, # noqa: E501
}
ACTION_REQUIRED: dict[str, list[str]] = {
"setVariable": ["name", "value"],
"applyFilter": ["col", "op", "val"],
"crossFilter": ["col", "op", "val"],
"clearFilters": [],
"navigateTab": ["tabsId", "tab"],
"openModal": ["modalId"],
"closeModal": ["modalId"],
"openUrl": ["url"],
"refresh": [],
}
FORMATTER_KINDS = ["number", "currency", "percent", "date", "template"]
# Declarative styling: an allowlisted CSS-property object (never a CSS string).
# Values may be literals or "@themeToken" references resolved from the antd
# theme at render time. Mirrors superset-frontend/src/Canvas/style.ts.
STYLE_PROPERTIES = [
"padding",
"paddingTop",
"paddingRight",
"paddingBottom",
"paddingLeft",
"margin",
"marginTop",
"marginRight",
"marginBottom",
"marginLeft",
"gap",
"rowGap",
"columnGap",
"width",
"minWidth",
"maxWidth",
"height",
"minHeight",
"maxHeight",
"background",
"backgroundColor",
"color",
"border",
"borderColor",
"borderWidth",
"borderStyle",
"borderRadius",
"boxShadow",
"opacity",
"overflow",
"fontSize",
"fontWeight",
"fontFamily",
"lineHeight",
"letterSpacing",
"textAlign",
"textTransform",
"display",
"flex",
"flexDirection",
"flexWrap",
"alignItems",
"justifyContent",
"alignSelf",
"gridTemplateColumns",
"gridTemplateRows",
"gridColumn",
"gridRow",
"position",
"top",
"right",
"bottom",
"left",
"inset",
"zIndex",
"transform",
"transformOrigin",
"rotate",
"scale",
"translate",
"transition",
"aspectRatio",
"objectFit",
"filter",
"backdropFilter",
"mixBlendMode",
"cursor",
"pointerEvents",
]
_UNSAFE_STYLE_VALUE = re.compile(r"url\(|expression\(|javascript:|@import|<|/\*", re.I)
_VAR_REF = re.compile(r"^\$([A-Za-z_]\w*)$")
_CODE_SMELL = re.compile(r"=>|\bfunction\b|new\s+Function", re.IGNORECASE)
_JS_URL = re.compile(r"^\s*(javascript|data|vbscript):", re.IGNORECASE)
_EVENT_TOKEN = "$event" # noqa: S105
def _is_var_ref(value: Any) -> bool:
return isinstance(value, str) and bool(_VAR_REF.match(value))
def _scan_no_code(value: Any, path: str, errors: list[str]) -> None:
"""The core safety gate: reject executable-looking strings."""
if isinstance(value, str):
if _CODE_SMELL.search(value):
errors.append(f"{path}: disallowed executable string (no-code invariant)")
return
if isinstance(value, list):
for i, item in enumerate(value):
_scan_no_code(item, f"{path}[{i}]", errors)
return
if isinstance(value, dict):
for key, item in value.items():
if key == "formatter" and isinstance(item, str):
errors.append(
f"{path}.formatter: formatter must be a declarative object, "
"not a string"
)
_scan_no_code(item, f"{path}.{key}", errors)
def _collect_refs(value: Any, out: set[str]) -> None:
if _is_var_ref(value) and value != _EVENT_TOKEN:
out.add(value[1:])
elif isinstance(value, list):
for item in value:
_collect_refs(item, out)
elif isinstance(value, dict):
for item in value.values():
_collect_refs(item, out)
def _validate_style(style: Any, path: str, errors: list[str]) -> None:
if style is None:
return
if not isinstance(style, dict):
errors.append(f"{path}: style must be an object")
return
for prop, value in style.items():
if prop not in STYLE_PROPERTIES:
errors.append(f"{path}.{prop}: unsupported style property")
continue
if not isinstance(value, (str, int, float)) or isinstance(value, bool):
errors.append(f"{path}.{prop}: style values must be a string or number")
continue
if isinstance(value, str) and _UNSAFE_STYLE_VALUE.search(value):
errors.append(f"{path}.{prop}: disallowed value")
def _validate_layout(layout: Any, path: str, errors: list[str]) -> None:
if layout is None:
return
if not isinstance(layout, dict):
errors.append(f"{path}: layout must be an object")
return
for key in ("x", "y", "w", "h"):
value = layout.get(key)
if not isinstance(value, (int, float)) or isinstance(value, bool):
errors.append(f"{path}.{key}: {key} must be a number")
for key in ("w", "h"):
value = layout.get(key)
if isinstance(value, (int, float)) and value < 1:
errors.append(f"{path}.{key}: {key} must be at least 1")
def _validate_action(
action: Any, path: str, declared: set[str], errors: list[str]
) -> None:
if not isinstance(action, dict):
errors.append(f"{path}: action must be an object")
return
name = action.get("action")
required = ACTION_REQUIRED.get(name) if isinstance(name, str) else None
if required is None:
errors.append(f"{path}: unknown action {name!r}")
return
for key in required:
if action.get(key) is None:
errors.append(f"{path}: action {name!r} missing {key!r}")
if name == "openUrl":
url = action.get("url")
if isinstance(url, str) and _JS_URL.match(url):
errors.append(f"{path}.url: openUrl allows http(s) only")
if name == "setVariable" and action.get("name") not in declared:
errors.append(f"{path}.name: undeclared variable {action.get('name')!r}")
def _validate_node( # noqa: C901
node: Any, path: str, declared: set[str], errors: list[str]
) -> None:
if not isinstance(node, dict):
errors.append(f"{path}: node must be an object")
return
if not isinstance(node.get("id"), str) or not node.get("id"):
errors.append(f"{path}: node.id (string) is required")
node_type = node.get("type")
if node_type not in NODE_CATALOG:
errors.append(f"{path}: unknown node type {node_type!r} (not in catalog)")
return
entry = NODE_CATALOG[node_type]
props = node.get("props") or {}
for prop in entry["requiredProps"]:
if props.get(prop) is None:
errors.append(f"{path}.props.{prop}: required prop missing")
children = node.get("children") or []
if children and not entry["container"]:
errors.append(f"{path}.children: {node_type!r} cannot have children")
for prop, ref in (node.get("bind") or {}).items():
if prop not in entry["bindableProps"]:
errors.append(f"{path}.bind.{prop}: prop is not bindable")
elif not _is_var_ref(ref):
errors.append(f"{path}.bind.{prop}: must be a $var reference")
elif ref[1:] not in declared:
errors.append(f"{path}.bind.{prop}: undeclared variable {ref!r}")
for event, actions in (node.get("on") or {}).items():
if event not in entry["events"]:
errors.append(f"{path}.on.{event}: {node_type!r} does not emit {event!r}")
for i, action in enumerate(actions or []):
_validate_action(action, f"{path}.on.{event}[{i}]", declared, errors)
if node_type == "Viz":
renderer = node.get("renderer")
if renderer == "echarts":
data = node.get("data") or {}
if not data.get("queryContext"):
errors.append(f"{path}.data: echarts Viz requires data.queryContext")
if not data.get("encoding"):
errors.append(f"{path}.data: echarts Viz requires data.encoding")
_scan_no_code(node.get("option"), f"{path}.option", errors)
elif renderer == "supersetChart":
if not isinstance(node.get("chartId"), int):
errors.append(f"{path}.chartId: supersetChart Viz requires chartId")
else:
errors.append(f"{path}.renderer: unknown Viz renderer {renderer!r}")
_scan_no_code(props, f"{path}.props", errors)
_validate_style(node.get("style"), f"{path}.style", errors)
_validate_layout(node.get("layout"), f"{path}.layout", errors)
refs: set[str] = set()
_collect_refs(props, refs)
_collect_refs(node.get("on"), refs)
if node_type == "Viz":
_collect_refs((node.get("data") or {}).get("queryContext"), refs)
for name in refs:
if name not in declared:
errors.append(f"{path}: references undeclared variable ${name}")
for i, child in enumerate(children):
_validate_node(child, f"{path}.children[{i}]", declared, errors)
def validate_cdl(definition: Any) -> list[str]:
"""Return a list of human-readable validation errors (empty == valid)."""
errors: list[str] = []
if not isinstance(definition, dict):
return ["definition must be an object"]
if not isinstance(definition.get("cdlVersion"), int):
errors.append("cdlVersion (int) is required")
variables = definition.get("variables")
if not isinstance(variables, dict):
errors.append("variables object is required")
variables = {}
tree = definition.get("tree")
if not tree:
errors.append("tree (root node) is required")
return errors
declared = set(variables.keys())
_validate_node(tree, "tree", declared, errors)
return errors
def build_cdl_schema() -> dict[str, Any]:
"""The machine-readable CDL contract handed to the authoring agent."""
return {
"cdlVersion": 2,
"summary": (
"Canvas Definition Language: a typed, declarative component tree for "
"an AI-native dashboard. Compose layout, controls, and charts. Nothing "
"is code — presentation is a data 'option', data is a 'queryContext' + "
"'encoding', behaviour is a bounded 'action' enum."
),
"envelope": {
"cdlVersion": "int (use 2)",
"variables": "map of name -> {type: string|number|boolean, default, scope: query|ui}", # noqa: E501
"tree": "the root node",
"canvasWidth": (
"optional outer width cap. Omit or 'full' for full-bleed "
"(default, like a dashboard — use for boards and overviews); a "
"CSS width like '820px' centres a narrow reading column (use "
"for the narrative idiom)."
),
},
"variableScopes": {
"query": "projected onto dataMask; drives bound queries (governed, RLS, cached)", # noqa: E501
"ui": "client-only (active tab, toggles); never hits the backend",
},
"composition": {
"summary": (
"Do NOT default to a grid of chart cards with a filter bar on "
"top — that is just a dashboard, and Superset already builds "
"those. Choose the layout that fits the job, and put controls "
"next to the thing they control."
),
"idioms": {
"narrative": (
"A written brief. Set envelope canvasWidth:'820px' (or a "
"root Column with style {maxWidth:'820px', margin:'0 auto'}); "
"prose Markdown, charts as BARE Viz nodes "
"between paragraphs (no Card), each followed by a small "
"caption Markdown styled {color:'@colorTextSecondary', "
"fontSize:'13px'}. Use when the canvas makes an argument."
),
"tool": (
"A parameterised instrument. Inline controls (see "
"controlPlacement) followed by the charts they govern; "
"split into sections so a later control only affects what "
"comes after it."
),
"bento": (
"An overview. A Row with style {display:'grid', "
"gridTemplateColumns:'repeat(12, minmax(0, 1fr))', gap:'20px'} "
"and children given varied {gridColumn:'span N'} (and "
"optionally {gridRow:'span 2'}). Vary the spans — a uniform "
"grid reads as a dashboard."
),
"sidebar": (
"A Row containing a Column {flex:'0 0 300px'} rail and a "
"Column {flex:'1 1 420px'} main area."
),
"freeform": (
"A Board node — coordinate placement, like a whiteboard or "
"a report designer. Set props {columns:12, rowHeight:40, "
"gap:8}; give each child a 'layout' {x,y,w,h,z?} in grid "
"units (x,y = top-left cell, w,h = span). Children may "
"OVERLAP — use 'z' to order them, e.g. a big-number tile "
"floating over a faint background chart, or a callout "
"pinned to a corner. Nothing else in Superset can do this."
),
},
"controlPlacement": {
"rule": (
"Prefer controls embedded where they are relevant over a "
"control panel at the top. Spread multiple controls down "
"the page, each above the charts it affects."
),
"inlineSentenceRecipe": (
"A Row styled {alignItems:'center', gap:'10px', "
"flexWrap:'wrap', fontSize:'19px'} containing: Markdown "
"fragment, the Input/Select, another Markdown fragment. "
"Give each Markdown {flex:'0 0 auto', margin:'0'} and the "
"input {flex:'0 0 110px'}, and drop the control's label — "
"the sentence carries the meaning."
),
},
"parametersVsFilters": (
"A Filter node picks values FROM a column — the same thing a "
"native dashboard filter does. A variable bound to an Input, "
"referenced in a filter with an operator "
"({col:'sales', op:'>', val:'$threshold'}), is a real "
"PARAMETER and has no dashboard equivalent. Reach for it "
"whenever the question is 'above/below X'. Declare the "
"variable as type 'number' so typed text is coerced."
),
"gotchas": [
"Modal must be a child of the ROOT node, never of a grid — it "
"portals out of the DOM and would leave an empty grid cell.",
"Buttons/Switches inside a Row need {flex:'0 0 auto'}, or the "
"row stretches them to equal width.",
"Use 'repeat(12, minmax(0, 1fr))'; plain '1fr' lets a wide "
"chart blow out its track and force horizontal scrolling.",
"Panels sharing a grid band should have similar heights, or "
"set the grid {alignItems:'stretch'} and the cards "
"{height:'100%'}.",
"Any node that HOSTS a control (Filter/Select/Input) should "
"use '@colorBgContainer' for its background, not a hardcoded "
"colour — the control's label follows the theme and will be "
"invisible on a fixed surface in one of light/dark.",
],
},
"styling": {
"summary": (
"Every node accepts an optional 'style' OBJECT (never a CSS "
"string) for layout and appearance. Values are literals "
"('16px', '1fr 1fr') or '@themeToken' references resolved from "
"the antd theme at render time — prefer tokens so light/dark "
"theming keeps working."
),
"properties": STYLE_PROPERTIES,
"tokenExamples": [
"@colorPrimary",
"@colorBgContainer",
"@colorBorder",
"@colorText",
"@colorTextSecondary",
"@borderRadius",
"@boxShadow",
"@fontSizeLG",
],
"example": {
"style": {
"padding": "16px",
"background": "@colorBgContainer",
"borderRadius": "@borderRadius",
"boxShadow": "@boxShadow",
}
},
},
"commonNodeFields": {
"id": "stable unique string",
"type": "one of nodeTypes",
"props": "typed props for the component",
"style": "optional allowlisted style object (see 'styling')",
"layout": "optional {x,y,w,h,z?} placement inside a Board parent",
"bind": "{prop: '$var'} two-way binding (only bindableProps)",
"on": "{event: [action, ...]} declarative handlers (only listed events)",
"children": "array of nodes (containers only)",
},
"nodeTypes": NODE_CATALOG,
"vizRenderers": {
"echarts": {
"data": {
"queryContext": {
"datasetId": "int (from list_datasets / get_dataset_info)",
"metrics": "list of saved-metric names or SQL like 'SUM(sales)'", # noqa: E501
"groupby": "list of column names",
"filters": "list of {col, op, val} — val may be '$var'",
"rowLimit": "int — combine with orderby for top-N",
"orderby": (
"list of {by, desc} — 'by' names a metric or groupby "
"column. Use [{by: 'SUM(sales)', desc: true}] with "
"rowLimit for top-N, or [{by: 'year'}] to sort a "
"time axis chronologically."
),
},
"encoding": {
"x": "category column",
"y": "value column or list",
"series": "optional column that fans into one series each",
},
},
"option": "an echarts option object (DATA ONLY — no functions)",
},
"supersetChart": {
"chartId": "int — an existing saved chart (use list_charts to find one)", # noqa: E501
"filters": "optional extra [{col, op, val}] — val may be '$var'",
"notes": (
"Renders the saved chart with its own viz plugin and "
"form_data. Canvas Filter nodes on the same dataset are "
"merged into its query automatically, so governed charts "
"react to canvas filters. Prefer this over an echarts Viz "
"when a suitable saved chart already exists."
),
},
},
"filters": {
"summary": (
"A Filter node is a dashboard-style filter the user places. It "
"auto-populates its options from the column's distinct values and "
"auto-applies to EVERY echarts Viz on the same dataset — you do "
"NOT need to add it to each chart's queryContext.filters."
),
"props": {
"column": "column to filter on (required)",
"dataset": "datasetId the filter applies to (match your Viz datasetId)", # noqa: E501
"label": "display label",
"multiple": "true for a multi-select (IN) filter",
"op": "override the operator (default '==' single, 'IN' multiple)",
"options": "optional explicit [{value,label}]; omit to auto-fetch",
},
"example": {
"id": "f_platform",
"type": "Filter",
"props": {
"column": "platform",
"dataset": 1,
"label": "Platform",
"multiple": True,
},
},
},
"actions": {
name: {"required": required} for name, required in ACTION_REQUIRED.items()
},
"formatters": {
"kinds": FORMATTER_KINDS,
"shape": "{kind: 'currency', currency: 'USD'} etc — declarative, resolved to a function client-side", # noqa: E501
"usage": "attach under option.yAxis.axisLabel.formatter or option.tooltip.valueFormatter", # noqa: E501
},
"rules": [
"NEVER emit a function or code string anywhere (formatters, handlers, option).", # noqa: E501
"A 'formatter' must be a declarative object, never a string.",
"openUrl allows http(s) only.",
"Every '$var' referenced must be declared in variables.",
"Only containers (Column, Row, Card, Tabs, Tab) may have children.",
"Tabs must contain Tab children (each needs a 'label'); the navigateTab "
"action switches them via {tabsId: <Tabs node id>, tab: <Tab node id>}.",
"Button actions are fully wired: setVariable, applyFilter/crossFilter "
"(write canvas filters), clearFilters (clears filters + resets variables), "
"navigateTab, refresh (re-runs every bound query).",
"Input and Switch two-way bind their 'value' to a $var, like Select.",
"Modal is hidden until a Button fires "
"{action:'openModal', modalId:<Modal id>} "
"— use it for drill-in detail panels without leaving the canvas.",
"Collapse children each become a panel titled by their props.label.",
"Alert (message + type info|success|warning|error) is for narrative "
"callouts; Progress binds 'value' to a $var for goal tracking.",
"Introspect datasets via list_datasets + get_dataset_info before building queryContext.", # noqa: E501
],
"example": {
"cdlVersion": 2,
"variables": {
"region": {"type": "string", "default": "APAC", "scope": "query"}
}, # noqa: E501
"tree": {
"id": "root",
"type": "Column",
"children": [
{
"id": "title",
"type": "Markdown",
"props": {"text": "Sales by month"},
}, # noqa: E501
{
"id": "controls",
"type": "Row",
"children": [
{
"id": "region",
"type": "Select",
"props": {
"label": "Region",
"options": [
{"value": "APAC", "label": "APAC"},
{"value": "EMEA", "label": "EMEA"},
],
},
"bind": {"value": "$region"},
}
],
},
{
"id": "chart",
"type": "Viz",
"renderer": "echarts",
"data": {
"queryContext": {
"datasetId": 1,
"metrics": ["SUM(sales)"],
"groupby": ["month"],
"filters": [
{"col": "region", "op": "==", "val": "$region"}
], # noqa: E501
},
"encoding": {"x": "month", "y": "SUM(sales)"},
},
"option": {
"series": [{"type": "line"}],
"tooltip": {
"valueFormatter": {
"kind": "currency",
"currency": "USD",
}
},
},
},
],
},
},
}
@@ -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.
"""add canvas tables
Revision ID: c4a1b2d3e5f6
Revises: e5f6a7b8c9d0
Create Date: 2026-07-24 00:00:00.000000
"""
import sqlalchemy as sa
from sqlalchemy_utils import UUIDType
from superset.migrations.shared.utils import create_table, drop_table
# revision identifiers, used by Alembic.
revision = "c4a1b2d3e5f6"
down_revision = "e5f6a7b8c9d0"
def upgrade() -> None:
create_table(
"canvas",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("uuid", UUIDType(binary=True), nullable=True),
sa.Column("name", sa.String(length=500), nullable=False),
sa.Column("definition", sa.Text(), 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.ForeignKeyConstraint(["created_by_fk"], ["ab_user.id"]),
sa.ForeignKeyConstraint(["changed_by_fk"], ["ab_user.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("uuid"),
)
create_table(
"canvas_editors",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("subject_id", sa.Integer(), nullable=False),
sa.Column("canvas_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(["subject_id"], ["subjects.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["canvas_id"], ["canvas.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("subject_id", "canvas_id"),
)
create_table(
"canvas_viewers",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("subject_id", sa.Integer(), nullable=False),
sa.Column("canvas_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(["subject_id"], ["subjects.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["canvas_id"], ["canvas.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("subject_id", "canvas_id"),
)
def downgrade() -> None:
drop_table("canvas_viewers")
drop_table("canvas_editors")
drop_table("canvas")
+48
View File
@@ -0,0 +1,48 @@
# 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.
"""A Canvas is a v2, AI-native dashboard.
Unlike a v1 ``Dashboard`` (which stores a ``position_json`` layout referencing
saved charts), a Canvas stores a single CDL (Canvas Definition Language) tree in
``definition`` a typed, declarative component tree an AI emits and the
``CanvasRenderer`` walks.
"""
from __future__ import annotations
from flask_appbuilder import Model
from sqlalchemy import Column, Integer, String
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.core import MediumText
class Canvas(AuditMixinNullable, UUIDMixin, Model):
__tablename__ = "canvas"
id = Column(Integer, primary_key=True)
name = Column(String(500), nullable=False, default="Untitled canvas")
# The CDL definition, stored as a JSON string.
definition = Column(MediumText())
editors = relationship(Subject, secondary=canvas_editors, passive_deletes=True)
viewers = relationship(Subject, secondary=canvas_viewers, passive_deletes=True)
def __repr__(self) -> str:
return f"Canvas<{self.id} {self.name}>"
+38
View File
@@ -201,3 +201,41 @@ report_schedule_editors = Table(
),
UniqueConstraint("subject_id", "report_schedule_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("canvas.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("canvas.id", ondelete="CASCADE"),
nullable=False,
),
UniqueConstraint("subject_id", "canvas_id"),
)
+41
View File
@@ -0,0 +1,41 @@
# 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_appbuilder.api import expose
from flask_appbuilder.models.sqla.interface import SQLAInterface
from flask_appbuilder.security.decorators import has_access
from superset.constants import MODEL_VIEW_RW_METHOD_PERMISSION_MAP, RouteMethod
from superset.models.canvas import Canvas
from superset.superset_typing import FlaskResponse
from superset.views.base import DeleteMixin, SupersetModelView
class CanvasModelView( # pylint: disable=too-many-ancestors
SupersetModelView,
DeleteMixin,
):
route_base = "/canvas"
datamodel = SQLAInterface(Canvas)
include_route_methods = RouteMethod.LIST
class_permission_name = "Canvas"
method_permission_name = MODEL_VIEW_RW_METHOD_PERMISSION_MAP
@expose("/list/")
@has_access
def list(self) -> FlaskResponse:
return super().render_app_template()
+25
View File
@@ -847,6 +847,31 @@ class Superset(BaseSupersetView):
standalone_mode=ReservedUrlParameters.is_standalone_mode(),
)
@has_access
@expose("/canvas/<int:pk>/")
def canvas(self, pk: int) -> FlaskResponse:
"""Server-side entry that serves the SPA shell for a v2 canvas.
Object-level data access is enforced by ``CanvasRestApi`` (the viewer
fetches the definition through it); this view only gates the shell.
"""
from superset.models.canvas import Canvas
canvas_obj = db.session.query(Canvas).filter(Canvas.id == pk).one_or_none()
if not canvas_obj:
if not get_current_user():
return redirect_to_login()
abort(404)
bootstrap_payload = {
"user": bootstrap_user_data(g.user, include_perms=True),
"common": common_bootstrap_payload(),
}
return self.render_app_template(
extra_bootstrap_data=bootstrap_payload,
title=canvas_obj.name,
)
@has_access
@expose("/dashboard/p/<key>/", methods=("GET",))
def dashboard_permalink(
@@ -0,0 +1,16 @@
# 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.
@@ -0,0 +1,151 @@
# 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.
"""Unit tests for targeted CDL patch operations."""
from copy import deepcopy
from typing import Any
from superset.mcp_service.canvas.patch import apply_ops, find_node
from superset.mcp_service.canvas.validation import validate_cdl
def _definition() -> dict[str, Any]:
return {
"cdlVersion": 2,
"variables": {},
"tree": {
"id": "root",
"type": "Column",
"children": [
{
"id": "a",
"type": "Markdown",
"props": {"text": "A"},
"style": {"color": "#fff"},
},
{"id": "b", "type": "Markdown", "props": {"text": "B"}},
{"id": "c", "type": "Markdown", "props": {"text": "C"}},
],
},
}
def _order(definition: dict[str, Any]) -> list[str]:
return [child["id"] for child in definition["tree"]["children"]]
def test_set_style_merges_by_default() -> None:
updated, errors = apply_ops(
_definition(),
[{"op": "setStyle", "id": "a", "style": {"background": "@colorBgContainer"}}],
)
assert errors == []
node, _ = find_node(updated["tree"], "a")
assert node is not None
assert node["style"] == {"color": "#fff", "background": "@colorBgContainer"}
def test_set_style_replaces_when_merge_false() -> None:
updated, errors = apply_ops(
_definition(),
[
{
"op": "setStyle",
"id": "a",
"style": {"padding": "8px"},
"merge": False,
}
],
)
assert errors == []
node, _ = find_node(updated["tree"], "a")
assert node is not None
assert node["style"] == {"padding": "8px"}
def test_move_reorders_within_parent() -> None:
updated, errors = apply_ops(
_definition(), [{"op": "move", "id": "c", "before": "a"}]
)
assert errors == []
assert _order(updated) == ["c", "a", "b"]
def test_remove_and_insert() -> None:
updated, errors = apply_ops(
_definition(),
[
{"op": "remove", "id": "b"},
{
"op": "insert",
"id": "root",
"node": {"id": "z", "type": "Divider"},
"after": "a",
},
],
)
assert errors == []
assert _order(updated) == ["a", "z", "c"]
def test_replace_swaps_a_node() -> None:
updated, errors = apply_ops(
_definition(),
[
{
"op": "replace",
"id": "b",
"node": {"id": "b", "type": "Markdown", "props": {"text": "new"}},
}
],
)
assert errors == []
node, _ = find_node(updated["tree"], "b")
assert node is not None
assert node["props"]["text"] == "new"
def test_input_definition_is_never_mutated() -> None:
definition = _definition()
snapshot = deepcopy(definition)
apply_ops(definition, [{"op": "move", "id": "c", "before": "a"}])
assert definition == snapshot
def test_errors_for_unknown_id_and_op() -> None:
_, errors = apply_ops(
_definition(), [{"op": "setStyle", "id": "nope", "style": {}}]
)
assert any("no node with id" in e for e in errors)
_, errors = apply_ops(_definition(), [{"op": "bogus", "id": "a"}])
assert any("unknown op" in e for e in errors)
def test_move_requires_a_target_position() -> None:
_, errors = apply_ops(_definition(), [{"op": "move", "id": "a"}])
assert any("needs one of" in e for e in errors)
def test_root_cannot_be_removed() -> None:
_, errors = apply_ops(_definition(), [{"op": "remove", "id": "root"}])
assert any("cannot remove the root" in e for e in errors)
def test_patched_tree_still_validates() -> None:
updated, _ = apply_ops(_definition(), [{"op": "move", "id": "c", "before": "a"}])
assert validate_cdl(updated) == []
@@ -0,0 +1,81 @@
# 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.
"""Unit tests for the server-side CDL validator."""
from copy import deepcopy
from typing import Any
from superset.mcp_service.canvas.validation import build_cdl_schema, validate_cdl
def _example() -> dict[str, Any]:
return deepcopy(build_cdl_schema()["example"])
def test_schema_example_is_valid() -> None:
assert validate_cdl(_example()) == []
def test_rejects_function_string_in_option() -> None:
definition = _example()
definition["tree"]["children"][2]["option"]["tooltip"] = {
"formatter": "(v) => v.toFixed(2)"
}
errors = validate_cdl(definition)
assert any("no-code invariant" in e for e in errors)
assert any("must be a declarative object" in e for e in errors)
def test_rejects_undeclared_variable() -> None:
definition = _example()
definition["tree"]["children"][1]["children"][0]["bind"]["value"] = "$nope"
errors = validate_cdl(definition)
assert any("undeclared variable" in e for e in errors)
def test_rejects_javascript_url_in_open_url() -> None:
definition = _example()
definition["tree"]["children"][0] = {
"id": "link",
"type": "Button",
"props": {"children": "Go"},
"on": {"click": [{"action": "openUrl", "url": "javascript:alert(1)"}]},
}
errors = validate_cdl(definition)
assert any("http(s) only" in e for e in errors)
def test_rejects_children_on_non_container() -> None:
definition = _example()
definition["tree"]["children"][0]["children"] = [
{"id": "x", "type": "Markdown", "props": {"text": "no"}}
]
errors = validate_cdl(definition)
assert any("cannot have children" in e for e in errors)
def test_rejects_unknown_node_type() -> None:
definition = _example()
definition["tree"]["children"][0]["type"] = "NotAThing"
errors = validate_cdl(definition)
assert any("unknown node type" in e for e in errors)
def test_missing_tree_is_reported() -> None:
assert validate_cdl({"cdlVersion": 2, "variables": {}}) == [
"tree (root node) is required"
]