This is an automated email from the ASF dual-hosted git repository. msyavuz pushed a commit to branch msyavuz/feat/canvas in repository https://gitbox.apache.org/repos/asf/superset.git
commit a5bcbe3a57f2a20c2a66f494ece88527eb5ce059 Author: Mehmet Salih Yavuz <[email protected]> AuthorDate: Thu Oct 1 12:41:27 2026 +0300 feat(canvas): canvas-wide settings for refresh, colors and display The definition gains a settings section: automatic refresh (interval, stagger, exempt nodes), shared colors (scheme and fixed label colors) and display (data timestamps). A set_settings operation replaces one section or resets it. Each section is its own concurrency group, so concurrent changes to different sections, or to settings and layout, merge. Removing a node drops it from the refresh exemptions. --- superset/canvas/definition/ops.py | 35 +++++++++++++++++++++++- superset/canvas/definition/schemas.py | 44 +++++++++++++++++++++++++++++- superset/canvas/definition/validation.py | 10 +++++++ tests/unit_tests/canvas/ops_test.py | 42 ++++++++++++++++++++++++++++ tests/unit_tests/canvas/validation_test.py | 30 ++++++++++++++++++++ 5 files changed, 159 insertions(+), 2 deletions(-) diff --git a/superset/canvas/definition/ops.py b/superset/canvas/definition/ops.py index e17e964cb19..d8edf954a8f 100644 --- a/superset/canvas/definition/ops.py +++ b/superset/canvas/definition/ops.py @@ -47,6 +47,7 @@ from superset.canvas.definition.schemas import ( RemoveOp, ROOT_ID, SetFilterScopeOp, + SetSettingsOp, ) from superset.canvas.definition.validation import normalize_definition @@ -57,6 +58,14 @@ class FieldGroup(str, Enum): LAYOUT = "layout" # A filter's scope override. SCOPE = "scope" + # Sections of the canvas settings, touched on ``SETTINGS_ID``. + REFRESH = "refresh" + COLORS = "colors" + DISPLAY = "display" + + +# Stands in for the settings in touches; never a node id, which is a UUID. +SETTINGS_ID = "settings" @dataclass(frozen=True) @@ -112,6 +121,8 @@ def named_touches(ops: Iterable[Operation]) -> list[Touch]: touches.append(Touch(op.id, FieldGroup.LAYOUT)) elif isinstance(op, SetFilterScopeOp): touches.append(Touch(op.id, FieldGroup.SCOPE)) + elif isinstance(op, SetSettingsOp): + touches.append(Touch(SETTINGS_ID, FieldGroup(op.key))) return touches @@ -194,7 +205,12 @@ class _Applier: for node_id in removed: del self.nodes[node_id] touched = [Touch(node_id, FieldGroup.TREE) for node_id in removed] - return AppliedOperation(logged, touched + self._prune_scopes(set(removed))) + return AppliedOperation( + logged, + touched + + self._prune_scopes(set(removed)) + + self._prune_settings(set(removed)), + ) if isinstance(op, MoveOp): return self.move(op, logged) if isinstance(op, SetFilterScopeOp): @@ -207,6 +223,13 @@ class _Applier: mode="json", by_alias=True, exclude_none=True ) return AppliedOperation(logged, [Touch(op.id, FieldGroup.SCOPE)]) + if isinstance(op, SetSettingsOp): + settings = self.canvas.setdefault("settings", {}) + if op.value is None: + settings.pop(op.key, None) + else: + settings[op.key] = op.value + return AppliedOperation(logged, [Touch(SETTINGS_ID, FieldGroup(op.key))]) assert isinstance(op, PlaceOp) # noqa: S101 self.node(op.id)["layout"] = op.layout return AppliedOperation(logged, [Touch(op.id, FieldGroup.LAYOUT)]) @@ -230,6 +253,16 @@ class _Applier: touched.append(Touch(filter_id, FieldGroup.SCOPE)) return touched + def _prune_settings(self, removed: set[str]) -> list[Touch]: + """Drop removed nodes from the refresh exemptions.""" + refresh = self.canvas.get("settings", {}).get("refresh", {}) + exempt = refresh.get("exempt", []) + kept = [node_id for node_id in exempt if node_id not in removed] + if len(kept) == len(exempt): + return [] + refresh["exempt"] = kept + return [Touch(SETTINGS_ID, FieldGroup.REFRESH)] + def move(self, op: MoveOp, logged: dict[str, Any]) -> AppliedOperation: node = self.node(op.id) if op.parent != ROOT_ID and op.parent in self.subtree(op.id): diff --git a/superset/canvas/definition/schemas.py b/superset/canvas/definition/schemas.py index feac87dd612..726627e8bf1 100644 --- a/superset/canvas/definition/schemas.py +++ b/superset/canvas/definition/schemas.py @@ -98,11 +98,45 @@ class Interactions(_Model): filters: dict[str, FilterScope] = Field(default_factory=dict) +class RefreshSettings(_Model): + # Seconds between automatic refreshes of every widget's data; 0 is off. + interval: int = Field(default=0, ge=0, le=7 * 24 * 60 * 60) + # Milliseconds a refresh is spread over across widgets; 0 refreshes all at + # once. + stagger: int = Field(default=0, ge=0, le=10 * 60 * 1000) + # Nodes left out of automatic refresh. + exempt: list[str] = Field(default_factory=list) + + +class ColorSettings(_Model): + # Categorical color scheme shared by every widget; the default when unset. + scheme: str | None = Field(default=None, min_length=1, max_length=255) + # Fixed colors for series labels across widgets, e.g. {"France": "#1f77b4"}. + label_colors: dict[str, Annotated[str, Field(max_length=64)]] = Field( + default_factory=dict, max_length=1000 + ) + + +class DisplaySettings(_Model): + # Show when each widget's data was last refreshed. + show_timestamps: bool = False + + +class Settings(_Model): + refresh: RefreshSettings = Field(default_factory=RefreshSettings) + colors: ColorSettings = Field(default_factory=ColorSettings) + display: DisplaySettings = Field(default_factory=DisplaySettings) + + +SettingsKey = Literal["refresh", "colors", "display"] + + class CanvasDefinition(_Model): version: Literal[1] = 1 root: Root = Field(default_factory=Root) nodes: dict[str, Node] = Field(default_factory=dict) interactions: Interactions = Field(default_factory=Interactions) + settings: Settings = Field(default_factory=Settings) def empty_definition() -> dict[str, Any]: @@ -160,8 +194,16 @@ class SetFilterScopeOp(_Model): scope: FilterScope | None +class SetSettingsOp(_Model): + """Replace one section of the canvas settings; ``value: null`` resets it.""" + + op: Literal["set_settings"] + key: SettingsKey + value: dict[str, Any] | None + + Operation = Annotated[ - Union[AddOp, RemoveOp, MoveOp, PlaceOp, SetFilterScopeOp], + Union[AddOp, RemoveOp, MoveOp, PlaceOp, SetFilterScopeOp, SetSettingsOp], Field(discriminator="op"), ] diff --git a/superset/canvas/definition/validation.py b/superset/canvas/definition/validation.py index ec4b342a940..0999ec6492b 100644 --- a/superset/canvas/definition/validation.py +++ b/superset/canvas/definition/validation.py @@ -119,6 +119,7 @@ class _Validator: self._check_nesting() self._check_depth(doc) self._check_interactions(doc) + self._check_settings(doc) if not self.issues: self._resolve_layouts(doc) if self.issues: @@ -238,6 +239,15 @@ class _Validator: f"node {target!r} cannot be filtered", ) + def _check_settings(self, doc: dict[str, Any]) -> None: + exempt = doc["settings"]["refresh"]["exempt"] + for index, node_id in enumerate(exempt): + if node_id not in doc["nodes"]: + self.fail( + pointer("settings", "refresh", "exempt", index), + f"unknown node {node_id!r}", + ) + def _check_depth(self, doc: dict[str, Any]) -> None: for node_id in doc["nodes"]: depth, current = 1, node_id diff --git a/tests/unit_tests/canvas/ops_test.py b/tests/unit_tests/canvas/ops_test.py index 5449adb2733..0607bd88f31 100644 --- a/tests/unit_tests/canvas/ops_test.py +++ b/tests/unit_tests/canvas/ops_test.py @@ -22,8 +22,10 @@ from superset.canvas.definition.ops import ( AppliedOperation, apply_operations, FieldGroup, + named_touches, OperationError, overlapping, + SETTINGS_ID, Touch, ) from superset.canvas.definition.registry import LayoutRulesRegistry @@ -257,3 +259,43 @@ def test_overlapping( earlier: list[Touch], later: list[Touch], expected: set[str] ) -> None: assert overlapping(earlier, later) == expected + + +def test_set_settings_replaces_one_section_and_null_resets_it() -> None: + doc = build( + add("chart-1"), + {"op": "set_settings", "key": "refresh", "value": {"interval": 300}}, + {"op": "set_settings", "key": "colors", "value": {"scheme": "supersetColors"}}, + ) + + assert doc["settings"]["refresh"] == {"interval": 300, "stagger": 0, "exempt": []} + assert doc["settings"]["colors"]["scheme"] == "supersetColors" + + reset, applied = apply(doc, {"op": "set_settings", "key": "refresh", "value": None}) + + assert reset["settings"]["refresh"]["interval"] == 0 + assert reset["settings"]["colors"]["scheme"] == "supersetColors" + assert applied[0].touched == [Touch(SETTINGS_ID, FieldGroup.REFRESH)] + + +def test_settings_sections_do_not_overlap_each_other() -> None: + refresh = named_touches( + ops({"op": "set_settings", "key": "refresh", "value": {"interval": 60}}) + ) + colors = named_touches(ops({"op": "set_settings", "key": "colors", "value": {}})) + + assert overlapping(refresh, colors) == set() + assert overlapping(refresh, refresh) == {SETTINGS_ID} + + +def test_removing_a_node_drops_its_refresh_exemption() -> None: + doc = build( + add("chart-1"), + add("chart-2"), + {"op": "set_settings", "key": "refresh", "value": {"exempt": ["n0", "n1"]}}, + ) + + result, applied = apply(doc, {"op": "remove", "id": "n0"}) + + assert result["settings"]["refresh"]["exempt"] == ["n1"] + assert Touch(SETTINGS_ID, FieldGroup.REFRESH) in applied[0].touched diff --git a/tests/unit_tests/canvas/validation_test.py b/tests/unit_tests/canvas/validation_test.py index 3c5096639d1..63eab034496 100644 --- a/tests/unit_tests/canvas/validation_test.py +++ b/tests/unit_tests/canvas/validation_test.py @@ -47,6 +47,11 @@ def test_empty_canvas_is_valid() -> None: "root": {"layout": {"columns": 24, "gap": 16, "rowUnit": 40}, "children": []}, "nodes": {}, "interactions": {"filters": {}}, + "settings": { + "refresh": {"interval": 0, "stagger": 0, "exempt": []}, + "colors": {"labelColors": {}}, + "display": {"showTimestamps": False}, + }, } @@ -213,3 +218,28 @@ def test_containers_get_children_and_leaves_do_not() -> None: assert result["nodes"]["g"]["children"] == [] assert "children" not in result["nodes"]["a"] + + +def test_refresh_exemptions_must_be_nodes() -> None: + found = issues( + { + **canvas({"a": node("chart")}), + "settings": {"refresh": {"exempt": ["a", "b"]}}, + } + ) + + assert found == [("/settings/refresh/exempt/1", "unknown node 'b'")] + + +def test_settings_are_validated() -> None: + found = issues( + { + **empty_definition(), + "settings": {"refresh": {"interval": -1}, "colors": {"theme": "dark"}}, + } + ) + + assert {path for path, _ in found} == { + "/settings/refresh/interval", + "/settings/colors/theme", + }
