Compare commits

...

6 Commits

Author SHA1 Message Date
Amin Ghadersohi
892143071b style: apply auto-walrus to _merge_json_metadata
Collapses the two-line `parsed = ...; if isinstance(parsed, dict):` into
a single walrus expression to satisfy the auto-walrus pre-commit hook.
2026-06-29 16:37:27 +00:00
Amin Ghadersohi
7ea015050d feat(mcp): add tags + typed metadata fields to update_dashboard
Builds on the update_dashboard tool added in #40399 rather than adding a
second tool. Extends its UpdateDashboardRequest with:

- tags: full-replacement list of tag IDs, routed through the same
  validate_tags/update_tags helpers UpdateDashboardCommand uses
- cross_filters_enabled, refresh_frequency, filter_bar_orientation: typed
  convenience fields that fold into json_metadata (so an LLM need not
  hand-build json_metadata_overrides for common toggles)
- slug normalization mirroring the REST DashboardPutSchema contract
- validate_css parity so the tool rejects the same CSS the REST path does

A key set via both a typed field and json_metadata_overrides is rejected
as ambiguous; the generic overrides dict remains an escape hatch.
2026-06-26 15:41:14 +00:00
dependabot[bot]
84e07df735 chore(deps): bump react-draggable from 4.6.0 to 4.7.0 in /superset-frontend (#41446)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-26 08:31:37 -07:00
dependabot[bot]
b8f3918bcf chore(deps-dev): bump react-resizable from 4.0.1 to 4.0.2 in /superset-frontend (#41448)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-26 08:31:23 -07:00
dependabot[bot]
ee43d8869f chore(deps): bump nanoid from 5.1.11 to 5.1.14 in /superset-frontend (#41450)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-26 08:31:11 -07:00
Evan Rusackas
01a0c66c79 fix(sunburst): make "Show Null Values" non-breaking and cover all layers (#41442)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-26 08:30:09 -07:00
10 changed files with 567 additions and 75 deletions

View File

@@ -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/"
}
}
}
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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]);
});

View File

@@ -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,
},
]);
});
});

View File

@@ -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``.

View File

@@ -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)

View File

@@ -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