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