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 842ee05899c9a4e588585096b5ed1cdca5412dd6
Author: Mehmet Salih Yavuz <[email protected]>
AuthorDate: Thu Oct 1 12:46:03 2026 +0300

    feat(canvas): scope cross-filters and customizations like filters
    
    Widget types can declare themselves cross-filter sources or
    customizations (e.g. dynamic group-by). Each kind gets its own scope
    overrides under interactions and resolves like filters: auto drives the
    filterable widgets under the nearest scope-bounding container, never the
    node itself, with global and custom overrides. The definition response
    returns crossFilterScopes and customizationScopes alongside filterScopes;
    cross-filter scopes are empty while settings.crossFilters is off.
    
    set_filter_scope becomes set_scope with a kind. Each kind is its own
    concurrency group, and removing a node prunes it from every kind.
---
 superset-core/src/superset_core/canvas/base.py |  13 +--
 superset/canvas/api.py                         |  22 +++--
 superset/canvas/definition/ops.py              |  62 ++++++++------
 superset/canvas/definition/schemas.py          |  34 ++++++--
 superset/canvas/definition/scopes.py           |  97 ++++++++++++++--------
 superset/canvas/definition/validation.py       |  66 +++++++++------
 superset/commands/canvas/apply_ops.py          |   9 ++-
 tests/unit_tests/canvas/api_test.py            |   2 +
 tests/unit_tests/canvas/fixtures.py            |  17 ++++
 tests/unit_tests/canvas/scopes_test.py         | 107 ++++++++++++++++++++++---
 tests/unit_tests/canvas/validation_test.py     |   3 +-
 11 files changed, 317 insertions(+), 115 deletions(-)

diff --git a/superset-core/src/superset_core/canvas/base.py 
b/superset-core/src/superset_core/canvas/base.py
index 0e1fd95b0ce..23b4a81bd80 100644
--- a/superset-core/src/superset_core/canvas/base.py
+++ b/superset-core/src/superset_core/canvas/base.py
@@ -83,12 +83,15 @@ class CanvasLayoutRules:
     grid_columns: ClassVar[int | None] = None
     child_layout_model: ClassVar[type[BaseModel] | None] = None
 
-    # Filters drive filterable widgets. By default a filter drives every
-    # filterable widget under its nearest container that bounds filter scope
-    # (or the whole canvas at the root); the canvas can override that per
-    # filter. A container that does not bound scope, e.g. a filter bar, lets
-    # its filters reach further up.
+    # Filters, cross-filter sources and customizations (e.g. dynamic group-by)
+    # drive filterable widgets. By default each drives every filterable widget
+    # under its nearest container that bounds filter scope (or the whole canvas
+    # at the root), never itself; the canvas can override that per node. A
+    # container that does not bound scope, e.g. a filter bar, lets its
+    # children reach further up.
     is_filter: ClassVar[bool] = False
+    is_cross_filter_source: ClassVar[bool] = False
+    is_customization: ClassVar[bool] = False
     is_filterable: ClassVar[bool] = False
     bounds_filter_scope: ClassVar[bool] = True
 
diff --git a/superset/canvas/api.py b/superset/canvas/api.py
index e365154663c..f1c460ea316 100644
--- a/superset/canvas/api.py
+++ b/superset/canvas/api.py
@@ -39,7 +39,7 @@ from superset.canvas.definition.schemas import (
     DEFINITION_VERSION,
     Operation,
 )
-from superset.canvas.definition.scopes import resolve_filter_scopes
+from superset.canvas.definition.scopes import resolve_scopes
 from superset.canvas.definition.upgrades import DefinitionVersionError
 from superset.canvas.filters import (
     CanvasAccessFilter,
@@ -128,6 +128,8 @@ def describe_rules(rules: type[CanvasLayoutRules]) -> 
dict[str, Any]:
         if rules.child_layout_model is not None
         else None,
         "isFilter": rules.is_filter,
+        "isCrossFilterSource": rules.is_cross_filter_source,
+        "isCustomization": rules.is_customization,
         "isFilterable": rules.is_filterable,
         "boundsFilterScope": rules.bounds_filter_scope,
         "colSpan": {"min": rules.min_col_span, "max": rules.max_col_span},
@@ -447,6 +449,14 @@ class CanvasRestApi(BaseSupersetModelRestApi):
                             description: >-
                               The node ids each filter drives, resolved from
                               the tree and the definition's scope overrides
+                          crossFilterScopes:
+                            type: object
+                            description: >-
+                              The node ids each cross-filter source drives;
+                              empty while cross-filters are turned off
+                          customizationScopes:
+                            type: object
+                            description: The node ids each customization drives
                           placements:
                             type: object
                             description: >-
@@ -471,7 +481,7 @@ class CanvasRestApi(BaseSupersetModelRestApi):
                 "version": canvas.definition_version,
                 "revision": canvas.revision,
                 "definition": definition,
-                "filterScopes": resolve_filter_scopes(definition),
+                **resolve_scopes(definition),
                 **render_context(definition),
             },
         )
@@ -565,9 +575,9 @@ class CanvasRestApi(BaseSupersetModelRestApi):
           description: >-
             Applies `ops` in order to the latest definition, atomically. 
Changes
             made since `base_revision` are merged unless they touched the same
-            node and field group (layout or filter scope), or moved or removed
-            an edited node; then the whole request is rejected with 409 and the
-            conflicting node ids.
+            node and field group (layout, a scope kind, or a settings
+            section), or moved or removed an edited node; then the whole
+            request is rejected with 409 and the conflicting node ids.
           parameters:
           - in: path
             schema:
@@ -613,7 +623,7 @@ class CanvasRestApi(BaseSupersetModelRestApi):
             result={
                 "revision": result.revision,
                 "ops": result.ops,
-                "filterScopes": result.filter_scopes,
+                **result.scopes,
                 **result.render_context,
             },
         )
diff --git a/superset/canvas/definition/ops.py 
b/superset/canvas/definition/ops.py
index d8edf954a8f..c65e94f4925 100644
--- a/superset/canvas/definition/ops.py
+++ b/superset/canvas/definition/ops.py
@@ -46,7 +46,8 @@ from superset.canvas.definition.schemas import (
     PlaceOp,
     RemoveOp,
     ROOT_ID,
-    SetFilterScopeOp,
+    SCOPE_FIELDS,
+    SetScopeOp,
     SetSettingsOp,
 )
 from superset.canvas.definition.validation import normalize_definition
@@ -56,12 +57,22 @@ class FieldGroup(str, Enum):
     # Existence and position in the tree; overlaps with every other group.
     TREE = "tree"
     LAYOUT = "layout"
-    # A filter's scope override.
-    SCOPE = "scope"
+    # A node's scope override, per kind.
+    FILTER_SCOPE = "filterScope"
+    CROSS_FILTER_SCOPE = "crossFilterScope"
+    CUSTOMIZATION_SCOPE = "customizationScope"
     # Sections of the canvas settings, touched on ``SETTINGS_ID``.
     REFRESH = "refresh"
     COLORS = "colors"
     DISPLAY = "display"
+    CROSS_FILTERS = "crossFilters"
+
+
+SCOPE_GROUPS = {
+    "filter": FieldGroup.FILTER_SCOPE,
+    "crossFilter": FieldGroup.CROSS_FILTER_SCOPE,
+    "customization": FieldGroup.CUSTOMIZATION_SCOPE,
+}
 
 
 # Stands in for the settings in touches; never a node id, which is a UUID.
@@ -119,8 +130,8 @@ def named_touches(ops: Iterable[Operation]) -> list[Touch]:
             touches += [Touch(op.id, FieldGroup.TREE), Touch(op.id, 
FieldGroup.LAYOUT)]
         elif isinstance(op, PlaceOp):
             touches.append(Touch(op.id, FieldGroup.LAYOUT))
-        elif isinstance(op, SetFilterScopeOp):
-            touches.append(Touch(op.id, FieldGroup.SCOPE))
+        elif isinstance(op, SetScopeOp):
+            touches.append(Touch(op.id, SCOPE_GROUPS[op.kind]))
         elif isinstance(op, SetSettingsOp):
             touches.append(Touch(SETTINGS_ID, FieldGroup(op.key)))
     return touches
@@ -213,16 +224,16 @@ class _Applier:
             )
         if isinstance(op, MoveOp):
             return self.move(op, logged)
-        if isinstance(op, SetFilterScopeOp):
+        if isinstance(op, SetScopeOp):
             self.node(op.id)
-            filters = self.scopes()
+            overrides = self.scopes(op.kind)
             if op.scope is None:
-                filters.pop(op.id, None)
+                overrides.pop(op.id, None)
             else:
-                filters[op.id] = op.scope.model_dump(
+                overrides[op.id] = op.scope.model_dump(
                     mode="json", by_alias=True, exclude_none=True
                 )
-            return AppliedOperation(logged, [Touch(op.id, FieldGroup.SCOPE)])
+            return AppliedOperation(logged, [Touch(op.id, 
SCOPE_GROUPS[op.kind])])
         if isinstance(op, SetSettingsOp):
             settings = self.canvas.setdefault("settings", {})
             if op.value is None:
@@ -234,23 +245,26 @@ class _Applier:
         self.node(op.id)["layout"] = op.layout
         return AppliedOperation(logged, [Touch(op.id, FieldGroup.LAYOUT)])
 
-    def scopes(self) -> dict[str, Any]:
-        return self.canvas.setdefault("interactions", 
{}).setdefault("filters", {})
+    def scopes(self, kind: str) -> dict[str, Any]:
+        interactions = self.canvas.setdefault("interactions", {})
+        return interactions.setdefault(SCOPE_FIELDS[kind], {})
 
     def _prune_scopes(self, removed: set[str]) -> list[Touch]:
-        """Drop removed nodes from filter scope overrides."""
-        filters = self.scopes()
+        """Drop removed nodes from every kind of scope override."""
         touched = []
-        for filter_id in list(filters):
-            if filter_id in removed:
-                del filters[filter_id]
-                continue
-            scope = filters[filter_id]
-            for field_name in ("targets", "exclude"):
-                kept = [n for n in scope.get(field_name, []) if n not in 
removed]
-                if len(kept) != len(scope.get(field_name, [])):
-                    scope[field_name] = kept
-                    touched.append(Touch(filter_id, FieldGroup.SCOPE))
+        for kind, group in SCOPE_GROUPS.items():
+            overrides = self.scopes(kind)
+            for owner_id in list(overrides):
+                if owner_id in removed:
+                    del overrides[owner_id]
+                    continue
+                scope = overrides[owner_id]
+                for field_name in ("targets", "exclude"):
+                    listed = scope.get(field_name, [])
+                    kept = [n for n in listed if n not in removed]
+                    if len(kept) != len(listed):
+                        scope[field_name] = kept
+                        touched.append(Touch(owner_id, group))
         return touched
 
     def _prune_settings(self, removed: set[str]) -> list[Touch]:
diff --git a/superset/canvas/definition/schemas.py 
b/superset/canvas/definition/schemas.py
index 726627e8bf1..1bc55ba31ba 100644
--- a/superset/canvas/definition/schemas.py
+++ b/superset/canvas/definition/schemas.py
@@ -93,9 +93,21 @@ class FilterScope(_Model):
         return self
 
 
+ScopeKind = Literal["filter", "crossFilter", "customization"]
+
+# Where each kind's scope overrides live in ``interactions``.
+SCOPE_FIELDS: dict[str, str] = {
+    "filter": "filters",
+    "crossFilter": "crossFilters",
+    "customization": "customizations",
+}
+
+
 class Interactions(_Model):
-    # Scope overrides, by filter node id. Filters without one use auto scope.
+    # Scope overrides by node id, per kind. Nodes without one use auto scope.
     filters: dict[str, FilterScope] = Field(default_factory=dict)
+    cross_filters: dict[str, FilterScope] = Field(default_factory=dict)
+    customizations: dict[str, FilterScope] = Field(default_factory=dict)
 
 
 class RefreshSettings(_Model):
@@ -122,13 +134,19 @@ class DisplaySettings(_Model):
     show_timestamps: bool = False
 
 
+class CrossFilterSettings(_Model):
+    # Whether clicking a cross-filter source filters other widgets.
+    enabled: bool = True
+
+
 class Settings(_Model):
     refresh: RefreshSettings = Field(default_factory=RefreshSettings)
     colors: ColorSettings = Field(default_factory=ColorSettings)
     display: DisplaySettings = Field(default_factory=DisplaySettings)
+    cross_filters: CrossFilterSettings = 
Field(default_factory=CrossFilterSettings)
 
 
-SettingsKey = Literal["refresh", "colors", "display"]
+SettingsKey = Literal["refresh", "colors", "display", "crossFilters"]
 
 
 class CanvasDefinition(_Model):
@@ -186,10 +204,14 @@ class PlaceOp(_Model):
     layout: dict[str, Any]
 
 
-class SetFilterScopeOp(_Model):
-    """Override which widgets a filter drives; ``scope: null`` restores 
auto."""
+class SetScopeOp(_Model):
+    """
+    Override which widgets a filter, cross-filter source or customization
+    drives; ``scope: null`` restores auto.
+    """
 
-    op: Literal["set_filter_scope"]
+    op: Literal["set_scope"]
+    kind: ScopeKind
     id: str
     scope: FilterScope | None
 
@@ -203,7 +225,7 @@ class SetSettingsOp(_Model):
 
 
 Operation = Annotated[
-    Union[AddOp, RemoveOp, MoveOp, PlaceOp, SetFilterScopeOp, SetSettingsOp],
+    Union[AddOp, RemoveOp, MoveOp, PlaceOp, SetScopeOp, SetSettingsOp],
     Field(discriminator="op"),
 ]
 
diff --git a/superset/canvas/definition/scopes.py 
b/superset/canvas/definition/scopes.py
index 5556bdca4d8..2fe94c996de 100644
--- a/superset/canvas/definition/scopes.py
+++ b/superset/canvas/definition/scopes.py
@@ -15,17 +15,18 @@
 # specific language governing permissions and limitations
 # under the License.
 """
-Which widgets each filter on a canvas drives.
+Which widgets each filter, cross-filter source and customization drives.
 
-By default a filter drives every filterable widget under its nearest ancestor
-container that bounds filter scope, or the whole canvas when there is none. A
-filter in a tab drives that tab; a filter in a filter bar that does not bound
-scope drives the whole canvas. A per-filter override in
-``interactions.filters`` can make the scope global or name the targets, and
-can exclude nodes from an auto or global scope.
+By default a node drives every filterable widget under its nearest ancestor
+container that bounds filter scope, or the whole canvas when there is none,
+never itself. A filter in a tab drives that tab; a filter in a filter bar that
+does not bound scope drives the whole canvas. A per-node override in
+``interactions`` can make the scope global or name the targets, and can
+exclude nodes from an auto or global scope.
 
 Only filterable widgets are ever driven, and nodes whose widget or type no
-longer resolves are skipped.
+longer resolves are skipped. Cross-filter scopes are empty while cross-filters
+are turned off in the canvas settings.
 """
 
 from __future__ import annotations
@@ -39,7 +40,24 @@ from superset.canvas.definition.registry import (
     layout_rules,
     LayoutRulesRegistry,
 )
-from superset.canvas.definition.schemas import FilterScopeMode, ROOT_ID
+from superset.canvas.definition.schemas import (
+    FilterScopeMode,
+    ROOT_ID,
+    SCOPE_FIELDS,
+)
+
+# The layout rule that makes a node own each kind of scope.
+_ROLES = {
+    "filter": "is_filter",
+    "crossFilter": "is_cross_filter_source",
+    "customization": "is_customization",
+}
+# Response keys for each kind's resolved scopes.
+SCOPE_RESULT_KEYS = {
+    "filter": "filterScopes",
+    "crossFilter": "crossFilterScopes",
+    "customization": "customizationScopes",
+}
 
 
 def _reading_order(canvas: dict[str, Any], start: str) -> list[str]:
@@ -56,12 +74,15 @@ def _reading_order(canvas: dict[str, Any], start: str) -> 
list[str]:
     return ordered
 
 
-def resolve_filter_scopes(
+def resolve_scopes(
     canvas: dict[str, Any],
     rules: LayoutRulesRegistry | None = None,
     resolver: WidgetResolver | None = None,
-) -> dict[str, list[str]]:
-    """Map each filter node id to the node ids it drives, in reading order."""
+) -> dict[str, dict[str, list[str]]]:
+    """
+    Map each kind's response key (``filterScopes``, ``crossFilterScopes``,
+    ``customizationScopes``) to ``{node id: driven node ids}``, in reading 
order.
+    """
     rules = rules or layout_rules
     resolver = resolver or get_widget_resolver()
     nodes = canvas["nodes"]
@@ -77,29 +98,39 @@ def resolve_filter_scopes(
         for child in _children(canvas, parent)
     }
     filterable = {n for n, r in node_rules.items() if r.is_filterable}
-    overrides = canvas.get("interactions", {}).get("filters", {})
+    interactions = canvas.get("interactions", {})
+    cross_filters_on = (
+        canvas.get("settings", {}).get("crossFilters", {}).get("enabled", True)
+    )
 
-    scopes: dict[str, list[str]] = {}
-    for node_id, node_rule in node_rules.items():
-        if not node_rule.is_filter:
-            continue
-        override = overrides.get(node_id, {})
-        mode = FilterScopeMode(override.get("mode", FilterScopeMode.AUTO))
-        if mode == FilterScopeMode.CUSTOM:
-            scopes[node_id] = [t for t in override["targets"] if t in 
filterable]
+    resolved: dict[str, dict[str, list[str]]] = {}
+    for kind, role in _ROLES.items():
+        scopes: dict[str, list[str]] = {}
+        resolved[SCOPE_RESULT_KEYS[kind]] = scopes
+        if kind == "crossFilter" and not cross_filters_on:
             continue
-        boundary = (
-            ROOT_ID
-            if mode == FilterScopeMode.GLOBAL
-            else _scope_boundary(node_id, parents, node_rules)
-        )
-        excluded = set(override.get("exclude", []))
-        scopes[node_id] = [
-            n
-            for n in _reading_order(canvas, boundary)
-            if n in filterable and n not in excluded
-        ]
-    return scopes
+        overrides = interactions.get(SCOPE_FIELDS[kind], {})
+        for node_id, node_rule in node_rules.items():
+            if not getattr(node_rule, role):
+                continue
+            override = overrides.get(node_id, {})
+            mode = FilterScopeMode(override.get("mode", FilterScopeMode.AUTO))
+            if mode == FilterScopeMode.CUSTOM:
+                candidates = override["targets"]
+            else:
+                boundary = (
+                    ROOT_ID
+                    if mode == FilterScopeMode.GLOBAL
+                    else _scope_boundary(node_id, parents, node_rules)
+                )
+                excluded = set(override.get("exclude", []))
+                candidates = [
+                    n for n in _reading_order(canvas, boundary) if n not in 
excluded
+                ]
+            scopes[node_id] = [
+                n for n in candidates if n in filterable and n != node_id
+            ]
+    return resolved
 
 
 def _children(canvas: dict[str, Any], parent: str) -> list[str]:
diff --git a/superset/canvas/definition/validation.py 
b/superset/canvas/definition/validation.py
index 0999ec6492b..03ec67dda41 100644
--- a/superset/canvas/definition/validation.py
+++ b/superset/canvas/definition/validation.py
@@ -47,11 +47,22 @@ from superset.canvas.definition.registry import (
     layout_rules,
     LayoutRulesRegistry,
 )
-from superset.canvas.definition.schemas import CanvasDefinition, ROOT_ID
+from superset.canvas.definition.schemas import (
+    CanvasDefinition,
+    ROOT_ID,
+    SCOPE_FIELDS,
+)
 
 MAX_NODES = 500
 MAX_DEPTH = 8
 
+# The layout rule a node needs to own each kind of scope override.
+SCOPE_ROLES = {
+    "filter": ("is_filter", "a filter"),
+    "crossFilter": ("is_cross_filter_source", "a cross-filter source"),
+    "customization": ("is_customization", "a customization"),
+}
+
 
 @dataclass(frozen=True)
 class Issue:
@@ -213,31 +224,34 @@ class _Validator:
 
     def _check_interactions(self, doc: dict[str, Any]) -> None:
         nodes = doc["nodes"]
-        for filter_id, scope in doc["interactions"]["filters"].items():
-            path = ("interactions", "filters", filter_id)
-            if filter_id not in nodes:
-                self.fail(pointer(*path), f"unknown node {filter_id!r}")
-                continue
-            strict = self.is_strict(filter_id)
-            rules = self.node_rules.get(filter_id)
-            if strict and rules is not None and not rules.is_filter:
-                self.fail(pointer(*path), f"node {filter_id!r} is not a 
filter")
-            for field in ("targets", "exclude"):
-                for index, target in enumerate(scope[field]):
-                    target_rules = self.node_rules.get(target)
-                    if target not in nodes:
-                        self.fail(
-                            pointer(*path, field, index), f"unknown node 
{target!r}"
-                        )
-                    elif (
-                        strict
-                        and target_rules is not None
-                        and not target_rules.is_filterable
-                    ):
-                        self.fail(
-                            pointer(*path, field, index),
-                            f"node {target!r} cannot be filtered",
-                        )
+        for kind, field_name in SCOPE_FIELDS.items():
+            role, noun = SCOPE_ROLES[kind]
+            for owner_id, scope in doc["interactions"][field_name].items():
+                path = ("interactions", field_name, owner_id)
+                if owner_id not in nodes:
+                    self.fail(pointer(*path), f"unknown node {owner_id!r}")
+                    continue
+                strict = self.is_strict(owner_id)
+                rules = self.node_rules.get(owner_id)
+                if strict and rules is not None and not getattr(rules, role):
+                    self.fail(pointer(*path), f"node {owner_id!r} is not 
{noun}")
+                for field in ("targets", "exclude"):
+                    for index, target in enumerate(scope[field]):
+                        target_rules = self.node_rules.get(target)
+                        if target not in nodes:
+                            self.fail(
+                                pointer(*path, field, index),
+                                f"unknown node {target!r}",
+                            )
+                        elif (
+                            strict
+                            and target_rules is not None
+                            and not target_rules.is_filterable
+                        ):
+                            self.fail(
+                                pointer(*path, field, index),
+                                f"node {target!r} cannot be filtered",
+                            )
 
     def _check_settings(self, doc: dict[str, Any]) -> None:
         exempt = doc["settings"]["refresh"]["exempt"]
diff --git a/superset/commands/canvas/apply_ops.py 
b/superset/commands/canvas/apply_ops.py
index 790af1883b6..9f49215e419 100644
--- a/superset/commands/canvas/apply_ops.py
+++ b/superset/commands/canvas/apply_ops.py
@@ -36,7 +36,7 @@ from superset.canvas.definition.ops import (
 from superset.canvas.definition.registry import get_widget_resolver
 from superset.canvas.definition.render import render_context
 from superset.canvas.definition.schemas import AddOp, DEFINITION_VERSION, 
Operation
-from superset.canvas.definition.scopes import resolve_filter_scopes
+from superset.canvas.definition.scopes import resolve_scopes
 from superset.canvas.definition.validation import (
     DefinitionValidationError,
     Issue,
@@ -69,8 +69,9 @@ class ApplyResult:
     revision: int
     # Operations as applied; ``add`` entries carry the new node's id.
     ops: list[dict[str, Any]]
-    # Node ids each filter drives after the write.
-    filter_scopes: dict[str, list[str]]
+    # Node ids each filter, cross-filter source and customization drives
+    # after the write, keyed like the API response.
+    scopes: dict[str, dict[str, list[str]]]
     # Resolved placements and widget types after the write.
     render_context: dict[str, Any]
 
@@ -142,7 +143,7 @@ class ApplyCanvasOperationsCommand(BaseCommand):
         return ApplyResult(
             revision=revision,
             ops=[a.op for a in applied],
-            filter_scopes=resolve_filter_scopes(definition),
+            scopes=resolve_scopes(definition),
             render_context=render_context(definition),
         )
 
diff --git a/tests/unit_tests/canvas/api_test.py 
b/tests/unit_tests/canvas/api_test.py
index 18185df60d4..086958d4cda 100644
--- a/tests/unit_tests/canvas/api_test.py
+++ b/tests/unit_tests/canvas/api_test.py
@@ -114,6 +114,8 @@ def test_get_definition(client: Any, full_api_access: None, 
canvas: Any) -> None
     assert response.json["result"]["revision"] == 1
     assert response.json["result"]["definition"]["nodes"] == {}
     assert response.json["result"]["filterScopes"] == {}
+    assert response.json["result"]["crossFilterScopes"] == {}
+    assert response.json["result"]["customizationScopes"] == {}
     assert response.json["result"]["placements"] == {}
     assert response.json["result"]["widgetTypes"] == {}
     assert response.json["result"]["gridColumns"] == {}
diff --git a/tests/unit_tests/canvas/fixtures.py 
b/tests/unit_tests/canvas/fixtures.py
index d098d2da28a..0a18e5aa3c0 100644
--- a/tests/unit_tests/canvas/fixtures.py
+++ b/tests/unit_tests/canvas/fixtures.py
@@ -64,6 +64,21 @@ class ChartRules(CanvasLayoutRules):
     is_filterable = True
 
 
+class CrossFilterChartRules(CanvasLayoutRules):
+    """A chart that can also cross-filter others."""
+
+    widget_type = "xchart"
+    is_filterable = True
+    is_cross_filter_source = True
+
+
+class GroupByRules(CanvasLayoutRules):
+    """A customization, e.g. a dynamic group-by control."""
+
+    widget_type = "groupby"
+    is_customization = True
+
+
 class FilterRules(CanvasLayoutRules):
     widget_type = "filter"
     is_filter = True
@@ -87,6 +102,8 @@ def canvas_rules() -> LayoutRulesRegistry:
         TabRules,
         BoardRules,
         ChartRules,
+        CrossFilterChartRules,
+        GroupByRules,
         FilterRules,
         FilterBarRules,
     ):
diff --git a/tests/unit_tests/canvas/scopes_test.py 
b/tests/unit_tests/canvas/scopes_test.py
index d5d808f4a89..4aba73ada68 100644
--- a/tests/unit_tests/canvas/scopes_test.py
+++ b/tests/unit_tests/canvas/scopes_test.py
@@ -24,7 +24,7 @@ from superset.canvas.definition.ops import (
     overlapping,
     Touch,
 )
-from superset.canvas.definition.scopes import resolve_filter_scopes
+from superset.canvas.definition.scopes import resolve_scopes
 from superset.canvas.definition.validation import (
     DefinitionValidationError,
     normalize_definition,
@@ -38,11 +38,13 @@ from tests.unit_tests.canvas.fixtures import (
 )
 
 
-def scopes(raw: dict[str, Any]) -> dict[str, list[str]]:
+def all_scopes(raw: dict[str, Any]) -> dict[str, dict[str, list[str]]]:
     rules, resolver = canvas_rules(), FakeResolver()
-    return resolve_filter_scopes(
-        normalize_definition(raw, rules, resolver), rules, resolver
-    )
+    return resolve_scopes(normalize_definition(raw, rules, resolver), rules, 
resolver)
+
+
+def scopes(raw: dict[str, Any]) -> dict[str, list[str]]:
+    return all_scopes(raw)["filterScopes"]
 
 
 def analyst_view(**interactions: Any) -> dict[str, Any]:
@@ -148,7 +150,8 @@ def test_set_filter_scope_and_restore_auto() -> None:
     doc, applied = apply(
         analyst_view(),
         {
-            "op": "set_filter_scope",
+            "op": "set_scope",
+            "kind": "filter",
             "id": "f2",
             "scope": {"mode": "custom", "targets": ["c3"]},
         },
@@ -159,9 +162,11 @@ def test_set_filter_scope_and_restore_auto() -> None:
         "targets": ["c3"],
         "exclude": [],
     }
-    assert applied[0].touched == [Touch("f2", FieldGroup.SCOPE)]
+    assert applied[0].touched == [Touch("f2", FieldGroup.FILTER_SCOPE)]
 
-    doc, _ = apply(doc, {"op": "set_filter_scope", "id": "f2", "scope": None})
+    doc, _ = apply(
+        doc, {"op": "set_scope", "kind": "filter", "id": "f2", "scope": None}
+    )
 
     assert doc["interactions"]["filters"] == {}
 
@@ -177,7 +182,7 @@ def test_removing_nodes_prunes_scope_overrides() -> None:
 
     assert doc["interactions"]["filters"]["f1"]["targets"] == ["c1"]
     assert doc["interactions"]["filters"]["f2"]["exclude"] == []
-    assert Touch("f1", FieldGroup.SCOPE) in applied[0].touched
+    assert Touch("f1", FieldGroup.FILTER_SCOPE) in applied[0].touched
 
     doc, _ = apply(doc, {"op": "remove", "id": "section"})
 
@@ -185,7 +190,89 @@ def test_removing_nodes_prunes_scope_overrides() -> None:
 
 
 def test_scope_edits_conflict_only_with_scope_edits() -> None:
-    scope = [Touch("f1", FieldGroup.SCOPE)]
+    scope = [Touch("f1", FieldGroup.FILTER_SCOPE)]
 
     assert overlapping(scope, [Touch("f1", FieldGroup.LAYOUT)]) == set()
     assert overlapping(scope, scope) == {"f1"}
+
+
+def sales_view(**settings: Any) -> dict[str, Any]:
+    """Two cross-filtering charts and a group-by in a tab, plus a chart 
outside."""
+    raw = canvas(
+        {
+            "tabs": node("tabs", children=["tab"]),
+            "tab": node("tab", children=["x1", "x2", "g1"]),
+            "x1": node("xchart-1"),
+            "x2": node("xchart-2"),
+            "g1": node("groupby-1"),
+            "c1": node("chart-1"),
+        },
+        children=["tabs", "c1"],
+    )
+    return {**raw, "settings": settings}
+
+
+def test_cross_filters_and_customizations_scope_like_filters() -> None:
+    resolved = all_scopes(sales_view())
+
+    assert resolved["crossFilterScopes"] == {"x1": ["x2"], "x2": ["x1"]}
+    assert resolved["customizationScopes"] == {"g1": ["x1", "x2"]}
+    assert resolved["filterScopes"] == {}
+
+
+def test_cross_filter_scopes_are_empty_while_turned_off() -> None:
+    resolved = all_scopes(sales_view(crossFilters={"enabled": False}))
+
+    assert resolved["crossFilterScopes"] == {}
+    assert resolved["customizationScopes"] == {"g1": ["x1", "x2"]}
+
+
+def test_scope_kinds_are_set_and_merged_independently() -> None:
+    doc, applied = apply(
+        sales_view(),
+        {
+            "op": "set_scope",
+            "kind": "crossFilter",
+            "id": "x1",
+            "scope": {"mode": "global"},
+        },
+    )
+
+    assert doc["interactions"]["crossFilters"]["x1"]["mode"] == "global"
+    assert applied[0].touched == [Touch("x1", FieldGroup.CROSS_FILTER_SCOPE)]
+    assert (
+        overlapping(applied[0].touched, [Touch("x1", 
FieldGroup.FILTER_SCOPE)]) == set()
+    )
+    rules, resolver = canvas_rules(), FakeResolver()
+    assert resolve_scopes(doc, rules, resolver)["crossFilterScopes"]["x1"] == [
+        "x2",
+        "c1",
+    ]
+
+
+def test_scope_override_needs_the_matching_role() -> None:
+    with pytest.raises(DefinitionValidationError, match="is not a 
customization"):
+        apply(
+            sales_view(),
+            {
+                "op": "set_scope",
+                "kind": "customization",
+                "id": "x1",
+                "scope": {"mode": "global"},
+            },
+        )
+
+
+def test_removing_nodes_prunes_every_scope_kind() -> None:
+    raw = sales_view()
+    raw["interactions"] = {
+        "crossFilters": {"x1": {"mode": "custom", "targets": ["x2", "c1"]}},
+        "customizations": {"g1": {"mode": "auto", "exclude": ["x2"]}},
+    }
+
+    doc, applied = apply(raw, {"op": "remove", "id": "x2"})
+
+    assert doc["interactions"]["crossFilters"]["x1"]["targets"] == ["c1"]
+    assert doc["interactions"]["customizations"]["g1"]["exclude"] == []
+    assert Touch("x1", FieldGroup.CROSS_FILTER_SCOPE) in applied[0].touched
+    assert Touch("g1", FieldGroup.CUSTOMIZATION_SCOPE) in applied[0].touched
diff --git a/tests/unit_tests/canvas/validation_test.py 
b/tests/unit_tests/canvas/validation_test.py
index 63eab034496..a9ec1e1035f 100644
--- a/tests/unit_tests/canvas/validation_test.py
+++ b/tests/unit_tests/canvas/validation_test.py
@@ -46,11 +46,12 @@ def test_empty_canvas_is_valid() -> None:
         "version": 1,
         "root": {"layout": {"columns": 24, "gap": 16, "rowUnit": 40}, 
"children": []},
         "nodes": {},
-        "interactions": {"filters": {}},
+        "interactions": {"filters": {}, "crossFilters": {}, "customizations": 
{}},
         "settings": {
             "refresh": {"interval": 0, "stagger": 0, "exempt": []},
             "colors": {"labelColors": {}},
             "display": {"showTimestamps": False},
+            "crossFilters": {"enabled": True},
         },
     }
 

Reply via email to