You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ms...@apache.org on 2026/10/05 11:27:54 UTC
(superset) 05/12: feat(canvas): canvas-wide settings for refresh, colors and display
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 <sa...@proton.me>
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",
+ }