mirror of
https://github.com/apache/superset.git
synced 2026-09-01 13:01:33 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dbb6d663a |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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 },
|
||||
};
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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/',
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,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"
|
||||
@@ -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))
|
||||
)
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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}>"
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
]
|
||||
Reference in New Issue
Block a user