mirror of
https://github.com/apache/superset.git
synced 2026-07-06 14:55:32 +00:00
Compare commits
6 Commits
chart-samp
...
mcp-update
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
892143071b | ||
|
|
7ea015050d | ||
|
|
84e07df735 | ||
|
|
b8f3918bcf | ||
|
|
ee43d8869f | ||
|
|
01a0c66c79 |
84
superset-frontend/package-lock.json
generated
84
superset-frontend/package-lock.json
generated
@@ -115,7 +115,7 @@
|
||||
"memoize-one": "^6.0.0",
|
||||
"mousetrap": "^1.6.5",
|
||||
"mustache": "^4.2.0",
|
||||
"nanoid": "^5.1.11",
|
||||
"nanoid": "^5.1.14",
|
||||
"ol": "^10.9.0",
|
||||
"query-string": "9.4.0",
|
||||
"re-resizable": "^6.11.2",
|
||||
@@ -267,7 +267,7 @@
|
||||
"process": "^0.11.10",
|
||||
"react-dnd-test-backend": "^16.0.1",
|
||||
"react-refresh": "^0.18.0",
|
||||
"react-resizable": "^4.0.1",
|
||||
"react-resizable": "^4.0.2",
|
||||
"redux-mock-store": "^1.5.4",
|
||||
"source-map": "^0.7.6",
|
||||
"source-map-support": "^0.5.21",
|
||||
@@ -26533,21 +26533,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/@noble/hashes": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/css-tree": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||
@@ -31452,9 +31437,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "5.1.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz",
|
||||
"integrity": "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==",
|
||||
"version": "5.1.14",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.14.tgz",
|
||||
"integrity": "sha512-5c8l8kVzqpnDPaicbEop/fV0Q1w16FmbWtVhMqugTozAwYdlIQojWH5a/M7UfziFmGdQRrUdV+EPzc9Xng3VAQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -36305,9 +36290,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-resizable": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-4.0.1.tgz",
|
||||
"integrity": "sha512-FR25Rcfxpi1iKiC7taIrqc1Tz6VnslqM94/IrA1LxoM5C3ap2EqaKLnCit/aKrcn3H4wfzO0nFBadFBc+SzEWA==",
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-4.0.2.tgz",
|
||||
"integrity": "sha512-jZD9ghYRmyJCw0+awYctSZ+9pmX1WXQvzDrTovELYc8obC/BShTI2r4c14LIVzeQ+vJZNb0yKM7bG2eqv7Vfyg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -43585,21 +43570,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url/node_modules/@noble/hashes": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url/node_modules/webidl-conversions": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||
@@ -44851,7 +44821,7 @@
|
||||
"parse-ms": "^4.0.0",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react-ace": "^14.0.1",
|
||||
"react-draggable": "^4.6.0",
|
||||
"react-draggable": "^4.7.0",
|
||||
"react-error-boundary": "6.0.0",
|
||||
"react-js-cron": "^5.2.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
@@ -44970,9 +44940,9 @@
|
||||
}
|
||||
},
|
||||
"packages/superset-ui-core/node_modules/react-draggable": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.6.0.tgz",
|
||||
"integrity": "sha512-g4vqY53xhmPrBnZvGP+1YQV0eYnB3o0VLzoi6q2IpwnQrxIZ34tYRKpVtsWIXPg4D/pvLn+oYCW5gOK2cWIrgA==",
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.7.0.tgz",
|
||||
"integrity": "sha512-kTpANmKWVnFXiZ76Ag2ZowiFStuBYnJ606PI1TbUsOg29/400/JNIxI9+CuenhiAqFuXWJffz6F4UI3R51kUug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
@@ -45681,6 +45651,36 @@
|
||||
"version": "1.0.0",
|
||||
"extraneous": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/jsdom/node_modules/@noble/hashes": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url/node_modules/@noble/hashes": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,7 +198,7 @@
|
||||
"memoize-one": "^6.0.0",
|
||||
"mousetrap": "^1.6.5",
|
||||
"mustache": "^4.2.0",
|
||||
"nanoid": "^5.1.11",
|
||||
"nanoid": "^5.1.14",
|
||||
"ol": "^10.9.0",
|
||||
"query-string": "9.4.0",
|
||||
"re-resizable": "^6.11.2",
|
||||
@@ -350,7 +350,7 @@
|
||||
"process": "^0.11.10",
|
||||
"react-dnd-test-backend": "^16.0.1",
|
||||
"react-refresh": "^0.18.0",
|
||||
"react-resizable": "^4.0.1",
|
||||
"react-resizable": "^4.0.2",
|
||||
"redux-mock-store": "^1.5.4",
|
||||
"source-map": "^0.7.6",
|
||||
"source-map-support": "^0.5.21",
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
"parse-ms": "^4.0.0",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react-ace": "^14.0.1",
|
||||
"react-draggable": "^4.6.0",
|
||||
"react-draggable": "^4.7.0",
|
||||
"react-error-boundary": "6.0.0",
|
||||
"react-js-cron": "^5.2.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
|
||||
@@ -186,7 +186,9 @@ export default function transformProps(
|
||||
showLabels,
|
||||
showLabelsThreshold,
|
||||
showTotal,
|
||||
showNullValues,
|
||||
// Default to true so charts saved before this control existed keep
|
||||
// showing null values instead of silently hiding them on upgrade.
|
||||
showNullValues = true,
|
||||
sliceId,
|
||||
} = formData;
|
||||
const {
|
||||
|
||||
@@ -40,7 +40,7 @@ export function treeBuilder(
|
||||
): TreeNode[] {
|
||||
const [curGroupBy, ...restGroupby] = groupBy;
|
||||
const curData = _groupBy(data, curGroupBy);
|
||||
return transform(
|
||||
const nodes = transform(
|
||||
curData,
|
||||
(result, value, key) => {
|
||||
const name = curData[key][0][curGroupBy]!;
|
||||
@@ -59,6 +59,9 @@ export function treeBuilder(
|
||||
result.push(item);
|
||||
});
|
||||
} else {
|
||||
// Children are already null-filtered by the recursive call, so the
|
||||
// parent's value/secondaryValue exclude hidden nulls. This keeps the
|
||||
// parent arc sized to its visible children (no empty gap).
|
||||
const children = treeBuilder(
|
||||
value,
|
||||
restGroupby,
|
||||
@@ -76,12 +79,9 @@ export function treeBuilder(
|
||||
0,
|
||||
)
|
||||
: metricValue;
|
||||
const validChildren = filterNullNames
|
||||
? children.filter(child => child.name !== null)
|
||||
: children;
|
||||
result.push({
|
||||
name,
|
||||
children: validChildren,
|
||||
children,
|
||||
value: metricValue,
|
||||
secondaryValue,
|
||||
groupBy: curGroupBy,
|
||||
@@ -90,4 +90,13 @@ export function treeBuilder(
|
||||
},
|
||||
[] as TreeNode[],
|
||||
);
|
||||
// Filter at every level so single-level charts and root nodes are covered,
|
||||
// not just nested children. A parent whose children were all null-filtered
|
||||
// is dropped too: keeping it would leave a zero-value arc that yields a NaN
|
||||
// secondaryValue/value ratio for coloring and tooltips.
|
||||
return filterNullNames
|
||||
? nodes.filter(
|
||||
node => node.name !== null && node.children?.length !== 0,
|
||||
)
|
||||
: nodes;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,13 @@ import { supersetTheme } from '@apache-superset/core/theme';
|
||||
import { EchartsSunburstChartProps } from '../../src/Sunburst/types';
|
||||
import transformProps from '../../src/Sunburst/transformProps';
|
||||
|
||||
type SunburstSeries = {
|
||||
label?: Record<string, unknown>;
|
||||
data: { value: number }[];
|
||||
};
|
||||
const firstSeries = (echartOptions: unknown) =>
|
||||
(echartOptions as { series: SunburstSeries[] }).series[0];
|
||||
|
||||
const formData = {
|
||||
colorScheme: 'bnbColors',
|
||||
datasource: '3__table',
|
||||
@@ -47,7 +54,52 @@ test('series label has no textBorderColor or textBorderWidth', () => {
|
||||
const { echartOptions } = transformProps(
|
||||
chartProps as EchartsSunburstChartProps,
|
||||
);
|
||||
const series = (echartOptions as any).series[0];
|
||||
const series = firstSeries(echartOptions);
|
||||
expect(series.label).not.toHaveProperty('textBorderColor');
|
||||
expect(series.label).not.toHaveProperty('textBorderWidth');
|
||||
});
|
||||
|
||||
const nullValueProps = (showNullValues?: boolean) =>
|
||||
new ChartProps({
|
||||
formData: {
|
||||
colorScheme: 'bnbColors',
|
||||
datasource: '3__table',
|
||||
columns: ['category'],
|
||||
metric: 'sum__value',
|
||||
...(showNullValues === undefined ? {} : { showNullValues }),
|
||||
},
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData: [
|
||||
{
|
||||
data: [
|
||||
{ category: 'A', sum__value: 10 },
|
||||
{ category: 'B', sum__value: 20 },
|
||||
{ category: null, sum__value: 5 },
|
||||
],
|
||||
},
|
||||
],
|
||||
theme: supersetTheme,
|
||||
});
|
||||
|
||||
const seriesValues = (props: ChartProps) => {
|
||||
const { echartOptions } = transformProps(props as EchartsSunburstChartProps);
|
||||
return firstSeries(echartOptions)
|
||||
.data.map(node => node.value)
|
||||
.sort((a, b) => a - b);
|
||||
};
|
||||
|
||||
// Charts saved before the "Show Null Values" control existed have no
|
||||
// `showNullValues` in form data; they must keep showing nulls (non-breaking).
|
||||
test('keeps null values when showNullValues is unset (legacy charts)', () => {
|
||||
expect(seriesValues(nullValueProps(undefined))).toEqual([5, 10, 20]);
|
||||
});
|
||||
|
||||
test('keeps null values when showNullValues is true', () => {
|
||||
expect(seriesValues(nullValueProps(true))).toEqual([5, 10, 20]);
|
||||
});
|
||||
|
||||
// Single-column sunburst: the toggle must actually drop the null node.
|
||||
test('removes null values when showNullValues is false', () => {
|
||||
expect(seriesValues(nullValueProps(false))).toEqual([10, 20]);
|
||||
});
|
||||
|
||||
@@ -394,7 +394,7 @@ describe('test treeBuilder', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('filter null values', () => {
|
||||
test('filter null values in a nested layer (parent total excludes hidden nulls)', () => {
|
||||
const tree = treeBuilder(
|
||||
[
|
||||
...data,
|
||||
@@ -426,6 +426,8 @@ describe('test treeBuilder', () => {
|
||||
value: 2,
|
||||
},
|
||||
{
|
||||
// The null `bar` child is removed AND its value is excluded from the
|
||||
// parent total, so the arc stays sized to its visible children (no gap).
|
||||
children: [
|
||||
{
|
||||
groupBy: 'bar',
|
||||
@@ -436,8 +438,8 @@ describe('test treeBuilder', () => {
|
||||
],
|
||||
groupBy: 'foo',
|
||||
name: 'a-2',
|
||||
secondaryValue: 4,
|
||||
value: 4,
|
||||
secondaryValue: 2,
|
||||
value: 2,
|
||||
},
|
||||
{
|
||||
children: [
|
||||
@@ -511,4 +513,137 @@ describe('test treeBuilder', () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
// Regression: a single-level (single column) sunburst previously never
|
||||
// filtered, because filtering only happened in the multi-level branch.
|
||||
test('single-level: shows null nodes when filtering is off', () => {
|
||||
const tree = treeBuilder(
|
||||
[
|
||||
{ foo: 'a', count: 2, count2: 3 },
|
||||
{ foo: null, count: 5, count2: 7 },
|
||||
],
|
||||
['foo'],
|
||||
'count',
|
||||
);
|
||||
expect(tree).toEqual([
|
||||
{ groupBy: 'foo', name: 'a', secondaryValue: 2, value: 2 },
|
||||
{ groupBy: 'foo', name: null, secondaryValue: 5, value: 5 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('single-level: removes null nodes when filtering is on', () => {
|
||||
const tree = treeBuilder(
|
||||
[
|
||||
{ foo: 'a', count: 2, count2: 3 },
|
||||
{ foo: null, count: 5, count2: 7 },
|
||||
],
|
||||
['foo'],
|
||||
'count',
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
expect(tree).toEqual([
|
||||
{ groupBy: 'foo', name: 'a', secondaryValue: 2, value: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
// Regression: a null in the *root* (first) column previously slipped through
|
||||
// because the top-level result array was never filtered.
|
||||
test('multi-level: shows null root node when filtering is off', () => {
|
||||
const tree = treeBuilder(
|
||||
[
|
||||
{ foo: 'a-1', bar: 'a', count: 2, count2: 3 },
|
||||
{ foo: null, bar: 'x', count: 5, count2: 7 },
|
||||
],
|
||||
['foo', 'bar'],
|
||||
'count',
|
||||
);
|
||||
expect(tree).toEqual([
|
||||
{
|
||||
children: [{ groupBy: 'bar', name: 'a', secondaryValue: 2, value: 2 }],
|
||||
groupBy: 'foo',
|
||||
name: 'a-1',
|
||||
secondaryValue: 2,
|
||||
value: 2,
|
||||
},
|
||||
{
|
||||
children: [{ groupBy: 'bar', name: 'x', secondaryValue: 5, value: 5 }],
|
||||
groupBy: 'foo',
|
||||
name: null,
|
||||
secondaryValue: 5,
|
||||
value: 5,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('multi-level: removes null root node (and its subtree) when filtering is on', () => {
|
||||
const tree = treeBuilder(
|
||||
[
|
||||
{ foo: 'a-1', bar: 'a', count: 2, count2: 3 },
|
||||
{ foo: null, bar: 'x', count: 5, count2: 7 },
|
||||
],
|
||||
['foo', 'bar'],
|
||||
'count',
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
expect(tree).toEqual([
|
||||
{
|
||||
children: [{ groupBy: 'bar', name: 'a', secondaryValue: 2, value: 2 }],
|
||||
groupBy: 'foo',
|
||||
name: 'a-1',
|
||||
secondaryValue: 2,
|
||||
value: 2,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
// With a secondary metric, the parent's secondaryValue must also exclude the
|
||||
// hidden null child rather than leaving a stale (inflated) total.
|
||||
test('filtering excludes hidden nulls from secondary-metric totals', () => {
|
||||
const tree = treeBuilder(
|
||||
[
|
||||
{ foo: 'p', bar: 'a', count: 2, count2: 3 },
|
||||
{ foo: 'p', bar: null, count: 2, count2: 7 },
|
||||
],
|
||||
['foo', 'bar'],
|
||||
'count',
|
||||
'count2',
|
||||
true,
|
||||
);
|
||||
expect(tree).toEqual([
|
||||
{
|
||||
children: [{ groupBy: 'bar', name: 'a', secondaryValue: 3, value: 2 }],
|
||||
groupBy: 'foo',
|
||||
name: 'p',
|
||||
secondaryValue: 3,
|
||||
value: 2,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
// A parent whose children are all null must be dropped, not kept as a
|
||||
// zero-value arc: a retained `value: 0` node yields NaN for the
|
||||
// secondaryValue/value ratio used in linear coloring and tooltips.
|
||||
test('filtering drops parents left with no children', () => {
|
||||
const tree = treeBuilder(
|
||||
[
|
||||
{ foo: 'keep', bar: 'a', count: 2, count2: 3 },
|
||||
{ foo: 'drop', bar: null, count: 5, count2: 7 },
|
||||
],
|
||||
['foo', 'bar'],
|
||||
'count',
|
||||
'count2',
|
||||
true,
|
||||
);
|
||||
expect(tree).toEqual([
|
||||
{
|
||||
children: [{ groupBy: 'bar', name: 'a', secondaryValue: 3, value: 2 }],
|
||||
groupBy: 'foo',
|
||||
name: 'keep',
|
||||
secondaryValue: 3,
|
||||
value: 2,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,6 +66,7 @@ Example usage:
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Any, cast, Dict, List, Literal, TYPE_CHECKING
|
||||
|
||||
@@ -796,6 +797,36 @@ class UpdateDashboardRequest(BaseModel):
|
||||
"Optional new dashboard CSS. Pass empty string to clear existing CSS."
|
||||
),
|
||||
)
|
||||
tags: List[int] | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Optional FULL-REPLACEMENT list of tag IDs to associate with the "
|
||||
"dashboard. Discover IDs with ``list_tags``. An empty list clears "
|
||||
"all custom tags. Omit (None) to leave tags unchanged."
|
||||
),
|
||||
)
|
||||
cross_filters_enabled: bool | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Optional toggle for dashboard-wide cross filtering. Typed "
|
||||
"convenience for the ``cross_filters_enabled`` json_metadata key."
|
||||
),
|
||||
)
|
||||
refresh_frequency: int | None = Field(
|
||||
None,
|
||||
ge=0,
|
||||
description=(
|
||||
"Optional auto-refresh interval in seconds (0 = off). Typed "
|
||||
"convenience for the ``refresh_frequency`` json_metadata key."
|
||||
),
|
||||
)
|
||||
filter_bar_orientation: Literal["VERTICAL", "HORIZONTAL"] | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Optional native filter bar orientation. Typed convenience for "
|
||||
"the ``filter_bar_orientation`` json_metadata key."
|
||||
),
|
||||
)
|
||||
sanitization_warnings: List[str] = Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
@@ -858,6 +889,20 @@ class UpdateDashboardRequest(BaseModel):
|
||||
v, "Dashboard title", max_length=500, allow_empty=True
|
||||
)
|
||||
|
||||
@field_validator("slug")
|
||||
@classmethod
|
||||
def normalize_slug(cls, v: str | None) -> str | None:
|
||||
"""Normalize the slug to match the REST DashboardPutSchema contract.
|
||||
|
||||
Mirrors ``BaseDashboardSchema.post_load``: strip, replace spaces with
|
||||
hyphens, and drop characters outside ``[\\w-]`` so the tool cannot
|
||||
persist slugs the REST update path would have cleaned.
|
||||
"""
|
||||
if not v:
|
||||
return v
|
||||
v = v.strip().replace(" ", "-")
|
||||
return re.sub(r"[^\w\-]+", "", v)
|
||||
|
||||
|
||||
class UpdateDashboardResponse(BaseModel):
|
||||
"""Response schema for ``update_dashboard``.
|
||||
|
||||
@@ -113,8 +113,7 @@ def _merge_json_metadata(dashboard: Any, overrides: dict[str, Any]) -> str:
|
||||
existing: dict[str, Any] = {}
|
||||
if dashboard.json_metadata:
|
||||
try:
|
||||
parsed = json.loads(dashboard.json_metadata)
|
||||
if isinstance(parsed, dict):
|
||||
if isinstance(parsed := json.loads(dashboard.json_metadata), dict):
|
||||
existing = parsed
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
@@ -122,13 +121,50 @@ def _merge_json_metadata(dashboard: Any, overrides: dict[str, Any]) -> str:
|
||||
return json.dumps(existing)
|
||||
|
||||
|
||||
# Typed json_metadata convenience fields. Each maps 1:1 to a json_metadata
|
||||
# key but is exposed as a validated field so an LLM does not have to hand-build
|
||||
# the raw ``json_metadata_overrides`` dict for common toggles.
|
||||
_TYPED_METADATA_FIELDS = (
|
||||
"cross_filters_enabled",
|
||||
"refresh_frequency",
|
||||
"filter_bar_orientation",
|
||||
)
|
||||
|
||||
|
||||
def _collect_metadata_overrides(request: UpdateDashboardRequest) -> dict[str, Any]:
|
||||
"""Combine the generic ``json_metadata_overrides`` with the typed fields.
|
||||
|
||||
A key set via both a typed field and the generic dict is ambiguous, so a
|
||||
collision raises ``ValueError``. Otherwise the typed fields are layered on
|
||||
top of the generic overrides. The generic dict stays as an escape hatch for
|
||||
keys without a typed field.
|
||||
"""
|
||||
overrides: dict[str, Any] = dict(request.json_metadata_overrides or {})
|
||||
typed = {
|
||||
field: value
|
||||
for field in _TYPED_METADATA_FIELDS
|
||||
if (value := getattr(request, field)) is not None
|
||||
}
|
||||
if clashes := sorted(set(overrides) & set(typed)):
|
||||
raise ValueError(
|
||||
"Conflicting metadata for "
|
||||
+ ", ".join(clashes)
|
||||
+ ": set via both a typed field and json_metadata_overrides. "
|
||||
"Pass each key only once."
|
||||
)
|
||||
overrides.update(typed)
|
||||
return overrides
|
||||
|
||||
|
||||
def _apply_field_updates(dashboard: Any, request: UpdateDashboardRequest) -> list[str]:
|
||||
"""Apply each explicitly-passed field to the dashboard.
|
||||
|
||||
Returns the names of fields actually changed. Mutates ``dashboard``
|
||||
in place. ``json_metadata_overrides`` is merged shallowly with the
|
||||
existing ``json_metadata``; an empty string in ``slug`` or ``css``
|
||||
clears the underlying value.
|
||||
in place. ``json_metadata_overrides`` (plus the typed metadata fields) is
|
||||
merged shallowly with the existing ``json_metadata``; an empty string in
|
||||
``slug`` or ``css`` clears the underlying value; ``tags`` fully replaces the
|
||||
dashboard's custom tags. Inputs are assumed pre-validated by
|
||||
``_validate_update_request``.
|
||||
"""
|
||||
changed: list[str] = []
|
||||
|
||||
@@ -152,25 +188,84 @@ def _apply_field_updates(dashboard: Any, request: UpdateDashboardRequest) -> lis
|
||||
dashboard.position_json = json.dumps(request.position_json)
|
||||
changed.append("position_json")
|
||||
|
||||
if request.json_metadata_overrides is not None:
|
||||
dashboard.json_metadata = _merge_json_metadata(
|
||||
dashboard, request.json_metadata_overrides
|
||||
)
|
||||
metadata_overrides = _collect_metadata_overrides(request)
|
||||
if metadata_overrides:
|
||||
dashboard.json_metadata = _merge_json_metadata(dashboard, metadata_overrides)
|
||||
changed.append("json_metadata")
|
||||
|
||||
if request.css is not None:
|
||||
dashboard.css = request.css or None
|
||||
changed.append("css")
|
||||
|
||||
if request.tags is not None:
|
||||
# Reuse the same helper the REST UpdateDashboardCommand uses so tag
|
||||
# association semantics (custom-tag full replacement) stay identical.
|
||||
from superset.commands.utils import update_tags
|
||||
from superset.tags.models import ObjectType
|
||||
|
||||
update_tags(ObjectType.dashboard, dashboard.id, dashboard.tags, request.tags)
|
||||
changed.append("tags")
|
||||
|
||||
return changed
|
||||
|
||||
|
||||
def _validate_update_request(
|
||||
dashboard: Any, request: UpdateDashboardRequest
|
||||
) -> DashboardError | None:
|
||||
"""Pre-flight validation mirroring the REST update path.
|
||||
|
||||
Runs before any mutation so the tool rejects the same payloads the REST
|
||||
``DashboardPutSchema`` / ``UpdateDashboardCommand`` would — invalid CSS,
|
||||
conflicting metadata keys, and unauthorized or unknown tag IDs — returning a
|
||||
structured error instead of failing deep inside the commit.
|
||||
"""
|
||||
from marshmallow import ValidationError as MarshmallowValidationError
|
||||
|
||||
from superset.commands.exceptions import (
|
||||
TagForbiddenError,
|
||||
TagNotFoundValidationError,
|
||||
)
|
||||
from superset.commands.utils import validate_tags
|
||||
from superset.dashboards.schemas import validate_css
|
||||
from superset.tags.models import ObjectType
|
||||
|
||||
# Empty string clears CSS (no validation needed); only validate real content.
|
||||
if request.css:
|
||||
try:
|
||||
validate_css(request.css)
|
||||
except MarshmallowValidationError as ex:
|
||||
detail = (
|
||||
"; ".join(str(m) for m in ex.messages)
|
||||
if isinstance(ex.messages, list)
|
||||
else str(ex.messages)
|
||||
)
|
||||
return DashboardError(
|
||||
error=f"Dashboard CSS is invalid: {detail}",
|
||||
error_type="InvalidCSS",
|
||||
)
|
||||
|
||||
try:
|
||||
_collect_metadata_overrides(request)
|
||||
except ValueError as ex:
|
||||
return DashboardError(error=str(ex), error_type="InvalidRequest")
|
||||
|
||||
if request.tags is not None:
|
||||
try:
|
||||
validate_tags(ObjectType.dashboard, dashboard.tags, request.tags)
|
||||
except TagForbiddenError as ex:
|
||||
return DashboardError(error=str(ex), error_type="TagForbidden")
|
||||
except TagNotFoundValidationError as ex:
|
||||
return DashboardError(error=str(ex), error_type="TagNotFound")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@tool(
|
||||
tags=["mutate"],
|
||||
class_permission_name="Dashboard",
|
||||
method_permission_name="write",
|
||||
annotations=ToolAnnotations(
|
||||
title="Update dashboard layout/theme/CSS",
|
||||
title="Update dashboard layout/theme/CSS/metadata",
|
||||
readOnlyHint=False,
|
||||
destructiveHint=False,
|
||||
),
|
||||
@@ -178,31 +273,32 @@ def _apply_field_updates(dashboard: Any, request: UpdateDashboardRequest) -> lis
|
||||
def update_dashboard(
|
||||
request: UpdateDashboardRequest, ctx: Context
|
||||
) -> UpdateDashboardResponse | DashboardError:
|
||||
"""Patch an existing dashboard's layout, theme, or styling.
|
||||
"""Patch an existing dashboard's layout, theme, styling, or metadata.
|
||||
|
||||
Companion to ``generate_dashboard`` for incremental edits. Accepts
|
||||
the same layout/theme/CSS fields that ``generate_dashboard`` does, so
|
||||
an LLM can:
|
||||
Companion to ``generate_dashboard`` for incremental edits. An LLM can:
|
||||
|
||||
- Set or replace ``position_json`` after auto-generation
|
||||
- Apply brand ``label_colors`` and ``color_scheme`` via
|
||||
``json_metadata_overrides``
|
||||
- Toggle ``cross_filters_enabled`` via ``json_metadata_overrides``
|
||||
- Inject ``css`` to hide chrome on print-ready dashboards
|
||||
- Update ``dashboard_title``, ``description``, ``slug``, ``published``
|
||||
- Replace the dashboard's ``tags`` (FULL list of IDs; find them with
|
||||
``list_tags``)
|
||||
- Toggle ``cross_filters_enabled``, ``refresh_frequency``, or
|
||||
``filter_bar_orientation`` via typed fields (no need to hand-build
|
||||
``json_metadata_overrides``)
|
||||
|
||||
Only the fields explicitly passed are applied; other fields are left
|
||||
unchanged. ``json_metadata_overrides`` is merged shallowly with the
|
||||
existing json_metadata — pass only the keys you want to change.
|
||||
existing json_metadata — pass only the keys you want to change. A key may
|
||||
not be set via both a typed field and ``json_metadata_overrides``.
|
||||
|
||||
Example::
|
||||
|
||||
update_dashboard(request={
|
||||
"identifier": 42,
|
||||
"json_metadata_overrides": {
|
||||
"label_colors": {"Electronics": "#4C78A8"},
|
||||
"cross_filters_enabled": False,
|
||||
},
|
||||
"tags": [3, 7],
|
||||
"refresh_frequency": 300,
|
||||
"css": ".header-controls {display: none;}",
|
||||
})
|
||||
"""
|
||||
@@ -212,6 +308,10 @@ def update_dashboard(
|
||||
if auth_error is not None:
|
||||
return auth_error
|
||||
|
||||
validation_error = _validate_update_request(dashboard, request)
|
||||
if validation_error is not None:
|
||||
return validation_error
|
||||
|
||||
changed_fields: list[str] = []
|
||||
warnings: list[str] = list(request.sanitization_warnings)
|
||||
|
||||
|
||||
@@ -309,3 +309,152 @@ class TestUpdateDashboard:
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@patch("superset.commands.utils.update_tags")
|
||||
@patch("superset.commands.utils.validate_tags")
|
||||
@patch("superset.daos.dashboard.DashboardDAO.get_by_id_or_slug")
|
||||
@patch("superset.extensions.db.session")
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_tags_replaces(
|
||||
self, mock_session, mock_get, mock_validate_tags, mock_update_tags, mcp_server
|
||||
) -> None:
|
||||
"""``tags`` routes through the same validate/update helpers the REST
|
||||
UpdateDashboardCommand uses, and reports ``tags`` as changed."""
|
||||
from superset.tags.models import ObjectType
|
||||
|
||||
dash = _mock_dashboard(id=42)
|
||||
mock_get.return_value = dash
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"update_dashboard",
|
||||
{"request": {"identifier": 42, "tags": [3, 7]}},
|
||||
)
|
||||
|
||||
mock_validate_tags.assert_called_once()
|
||||
mock_update_tags.assert_called_once()
|
||||
update_args = mock_update_tags.call_args.args
|
||||
assert update_args[0] == ObjectType.dashboard
|
||||
assert update_args[1] == 42
|
||||
assert update_args[3] == [3, 7]
|
||||
payload = json.loads(result.content[0].text)
|
||||
assert "tags" in payload.get("changed_fields", [])
|
||||
|
||||
@patch("superset.commands.utils.update_tags")
|
||||
@patch("superset.commands.utils.validate_tags")
|
||||
@patch("superset.daos.dashboard.DashboardDAO.get_by_id_or_slug")
|
||||
@patch("superset.extensions.db.session")
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_tags_empty_list_clears(
|
||||
self, mock_session, mock_get, mock_validate_tags, mock_update_tags, mcp_server
|
||||
) -> None:
|
||||
"""An empty ``tags`` list is a full replacement that clears all
|
||||
custom tags — it must still reach ``update_tags`` (not be treated
|
||||
as 'unchanged')."""
|
||||
dash = _mock_dashboard(id=42)
|
||||
mock_get.return_value = dash
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
await client.call_tool(
|
||||
"update_dashboard",
|
||||
{"request": {"identifier": 42, "tags": []}},
|
||||
)
|
||||
|
||||
mock_update_tags.assert_called_once()
|
||||
assert mock_update_tags.call_args.args[3] == []
|
||||
|
||||
@patch("superset.daos.dashboard.DashboardDAO.get_by_id_or_slug")
|
||||
@patch("superset.extensions.db.session")
|
||||
@pytest.mark.asyncio
|
||||
async def test_typed_metadata_toggles_fold_into_json_metadata(
|
||||
self, mock_session, mock_get, mcp_server
|
||||
) -> None:
|
||||
"""Typed convenience fields are merged into json_metadata without
|
||||
clobbering unrelated keys."""
|
||||
dash = _mock_dashboard(
|
||||
id=42, json_metadata=json.dumps({"label_colors": {"A": "#111"}})
|
||||
)
|
||||
mock_get.return_value = dash
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"update_dashboard",
|
||||
{
|
||||
"request": {
|
||||
"identifier": 42,
|
||||
"cross_filters_enabled": False,
|
||||
"refresh_frequency": 300,
|
||||
"filter_bar_orientation": "HORIZONTAL",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
merged = json.loads(dash.json_metadata)
|
||||
assert merged["cross_filters_enabled"] is False
|
||||
assert merged["refresh_frequency"] == 300
|
||||
assert merged["filter_bar_orientation"] == "HORIZONTAL"
|
||||
assert merged["label_colors"] == {"A": "#111"} # preserved
|
||||
payload = json.loads(result.content[0].text)
|
||||
assert "json_metadata" in payload.get("changed_fields", [])
|
||||
|
||||
@patch("superset.daos.dashboard.DashboardDAO.get_by_id_or_slug")
|
||||
@patch("superset.extensions.db.session")
|
||||
@pytest.mark.asyncio
|
||||
async def test_typed_metadata_conflict_is_rejected(
|
||||
self, mock_session, mock_get, mcp_server
|
||||
) -> None:
|
||||
"""Setting the same key via a typed field AND json_metadata_overrides
|
||||
is ambiguous and rejected before any write."""
|
||||
dash = _mock_dashboard(id=42)
|
||||
mock_get.return_value = dash
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"update_dashboard",
|
||||
{
|
||||
"request": {
|
||||
"identifier": 42,
|
||||
"cross_filters_enabled": False,
|
||||
"json_metadata_overrides": {"cross_filters_enabled": True},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
payload = json.loads(result.content[0].text)
|
||||
assert "cross_filters_enabled" in (payload.get("error") or "")
|
||||
mock_session.commit.assert_not_called()
|
||||
|
||||
@patch("superset.dashboards.schemas.validate_css")
|
||||
@patch("superset.daos.dashboard.DashboardDAO.get_by_id_or_slug")
|
||||
@patch("superset.extensions.db.session")
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_css_is_rejected(
|
||||
self, mock_session, mock_get, mock_validate_css, mcp_server
|
||||
) -> None:
|
||||
"""CSS is run through the same ``validate_css`` the REST schema uses;
|
||||
a rejection short-circuits before any write."""
|
||||
from marshmallow import ValidationError
|
||||
|
||||
dash = _mock_dashboard(id=42)
|
||||
mock_get.return_value = dash
|
||||
mock_validate_css.side_effect = ValidationError("CSS is invalid")
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"update_dashboard",
|
||||
{"request": {"identifier": 42, "css": "@import url(evil);"}},
|
||||
)
|
||||
|
||||
payload = json.loads(result.content[0].text)
|
||||
assert "css is invalid" in (payload.get("error") or "").lower()
|
||||
mock_session.commit.assert_not_called()
|
||||
|
||||
def test_request_slug_is_normalized(self) -> None:
|
||||
"""Slug is cleaned to match the REST DashboardPutSchema contract."""
|
||||
from superset.mcp_service.dashboard.schemas import UpdateDashboardRequest
|
||||
|
||||
assert (
|
||||
UpdateDashboardRequest(identifier=1, slug=" My Slug!? ").slug == "My-Slug"
|
||||
)
|
||||
assert UpdateDashboardRequest(identifier=1, slug="").slug == ""
|
||||
assert UpdateDashboardRequest(identifier=1).slug is None
|
||||
|
||||
Reference in New Issue
Block a user