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",
+    }

Reply via email to