You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/06/24 09:14:32 UTC

(superset) branch master updated: fix(deckgl): show dashboard filter badges for multi-layer charts (#40003)

This is an automated email from the ASF dual-hosted git repository.

rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new a57b5f60780 fix(deckgl): show dashboard filter badges for multi-layer charts (#40003)
a57b5f60780 is described below

commit a57b5f6078056e411278cbb93d3595d4cdebe62a
Author: Shlummie <ld...@gmail.com>
AuthorDate: Wed Jun 24 02:14:25 2026 -0700

    fix(deckgl): show dashboard filter badges for multi-layer charts (#40003)
    
    Co-authored-by: Evan Rusackas <ev...@rusackas.com>
    Co-authored-by: Claude Opus 4.8 <no...@anthropic.com>
---
 .../components/nativeFilters/selectors.test.ts     | 61 ++++++++++++++++++
 .../components/nativeFilters/selectors.ts          | 18 ++++--
 superset/viz.py                                    | 56 ++++++++++++++++
 tests/integration_tests/viz_tests.py               | 75 +++++++++++++++++++++-
 4 files changed, 205 insertions(+), 5 deletions(-)

diff --git a/superset-frontend/src/dashboard/components/nativeFilters/selectors.test.ts b/superset-frontend/src/dashboard/components/nativeFilters/selectors.test.ts
index 77fe6b7b0c7..7ac733981ac 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/selectors.test.ts
+++ b/superset-frontend/src/dashboard/components/nativeFilters/selectors.test.ts
@@ -22,6 +22,8 @@ import {
   extractLabel,
   getAppliedColumnsWithFallback,
   getCrossFilterIndicator,
+  IndicatorStatus,
+  selectNativeIndicatorsForChart,
 } from './selectors';
 
 // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
@@ -207,6 +209,21 @@ test('getAppliedColumnsWithFallback returns columns from query response when ava
   expect(result).toEqual(new Set(['age', 'name']));
 });
 
+test('getAppliedColumnsWithFallback returns columns from all query responses', () => {
+  const chart = {
+    queriesResponse: [
+      {
+        applied_filters: [],
+      },
+      {
+        applied_filters: [{ column: 'age' }, { column: 'name' }],
+      },
+    ],
+  };
+  const result = getAppliedColumnsWithFallback(chart);
+  expect(result).toEqual(new Set(['age', 'name']));
+});
+
 test('getAppliedColumnsWithFallback returns empty set when query response has no applied_filters and no fallback params', () => {
   const chart = {
     queriesResponse: [{ applied_filters: [] }],
@@ -565,3 +582,47 @@ test('getAppliedColumnsWithFallback prioritizes query response over fallback', (
   );
   expect(result).toEqual(new Set(['query_column']));
 });
+
+test('selectNativeIndicatorsForChart marks rejected filters from later query responses incompatible', () => {
+  const chartId = 987;
+  const nativeFilters = {
+    filter1: {
+      id: 'filter1',
+      name: 'Age',
+      type: NativeFilterType.NativeFilter,
+      chartsInScope: [chartId],
+      targets: [{ column: { name: 'age' } }],
+    },
+  } as any;
+  const dataMask = {
+    filter1: {
+      id: 'filter1',
+      filterState: { value: '25' },
+      extraFormData: {},
+    },
+  } as any;
+  const chart = {
+    queriesResponse: [
+      { rejected_filters: [] },
+      { rejected_filters: [{ column: 'age' }] },
+    ],
+  };
+
+  const result = selectNativeIndicatorsForChart(
+    nativeFilters,
+    dataMask,
+    chartId,
+    chart,
+    [],
+  );
+
+  expect(result).toEqual([
+    {
+      column: 'age',
+      name: 'Age',
+      path: ['filter1'],
+      status: IndicatorStatus.Incompatible,
+      value: '25',
+    },
+  ]);
+});
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/selectors.ts b/superset-frontend/src/dashboard/components/nativeFilters/selectors.ts
index 13f3852beb1..4ca36581264 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/selectors.ts
+++ b/superset-frontend/src/dashboard/components/nativeFilters/selectors.ts
@@ -141,9 +141,20 @@ const selectIndicatorsForChartFromFilter = (
     }));
 };
 
+const getQueryFilterMetadata = (
+  chart: any,
+  metadataKey: 'applied_filters' | 'rejected_filters',
+) =>
+  ensureIsArray(chart?.queriesResponse).flatMap(
+    queryResponse =>
+      (metadataKey === 'applied_filters'
+        ? queryResponse?.applied_filters
+        : queryResponse?.rejected_filters) || [],
+  );
+
 const getAppliedColumns = (chart: any): Set<string> =>
   new Set(
-    (chart?.queriesResponse?.[0]?.applied_filters || []).map(
+    getQueryFilterMetadata(chart, 'applied_filters').map(
       (filter: any) => filter.column,
     ),
   );
@@ -161,8 +172,7 @@ export const getAppliedColumnsWithFallback = (
   chartId?: number,
 ): Set<string> => {
   // First try to get from query response (preferred source of truth)
-  const queryAppliedFilters =
-    chart?.queriesResponse?.[0]?.applied_filters || [];
+  const queryAppliedFilters = getQueryFilterMetadata(chart, 'applied_filters');
   if (queryAppliedFilters.length > 0) {
     return new Set(queryAppliedFilters.map((filter: any) => filter.column));
   }
@@ -191,7 +201,7 @@ export const getAppliedColumnsWithFallback = (
 
 const getRejectedColumns = (chart: any): Set<string> =>
   new Set(
-    (chart?.queriesResponse?.[0]?.rejected_filters || []).map((filter: any) =>
+    getQueryFilterMetadata(chart, 'rejected_filters').map((filter: any) =>
       getColumnLabel(filter.column),
     ),
   );
diff --git a/superset/viz.py b/superset/viz.py
index a751c99d70a..5b55552c1a9 100644
--- a/superset/viz.py
+++ b/superset/viz.py
@@ -1626,6 +1626,32 @@ class DeckGLMultiLayer(BaseViz):
     is_timeseries = False
     credits = '<a href="https://uber.github.io/deck.gl/">deck.gl</a>'
 
+    @staticmethod
+    def _merge_filter_metadata(
+        *filter_groups: list[dict[str, Any]] | None,
+    ) -> list[dict[str, Any]]:
+        """Merge multiple filter metadata lists, de-duplicating identical entries.
+
+        Used to combine the applied/rejected filter metadata reported by each
+        child layer into a single list for the multi-layer chart payload.
+        """
+        merged_filters: list[dict[str, Any]] = []
+        seen_filters: set[str] = set()
+
+        for filters in filter_groups:
+            for filter_metadata in filters or []:
+                if not isinstance(filter_metadata, dict):
+                    continue
+
+                cache_key = json.dumps(filter_metadata, sort_keys=True)
+                if cache_key in seen_filters:
+                    continue
+
+                merged_filters.append(filter_metadata)
+                seen_filters.add(cache_key)
+
+        return merged_filters
+
     @deprecated(deprecated_in="3.0")
     def query_obj(self) -> QueryObjectDict:
         return {}
@@ -1726,6 +1752,8 @@ class DeckGLMultiLayer(BaseViz):
         slices = db.session.query(Slice).filter(Slice.id.in_(slice_ids)).all()
 
         features: dict[str, list[Any]] = {}
+        self.applied_filters = []
+        self.rejected_filters = []
 
         for layer_index, slc in enumerate(slices):
             form_data = slc.form_data
@@ -1738,6 +1766,15 @@ class DeckGLMultiLayer(BaseViz):
 
             viz_instance = viz_class(datasource=slc.datasource, form_data=form_data)
             payload = viz_instance.get_payload()
+            if payload:
+                self.applied_filters = self._merge_filter_metadata(
+                    self.applied_filters,
+                    payload.get("applied_filters"),
+                )
+                self.rejected_filters = self._merge_filter_metadata(
+                    self.rejected_filters,
+                    payload.get("rejected_filters"),
+                )
 
             if (
                 payload
@@ -1755,6 +1792,25 @@ class DeckGLMultiLayer(BaseViz):
             "slices": [slc.data for slc in slices if slc.data is not None],
         }
 
+    @deprecated(deprecated_in="3.0")
+    def get_payload(self, query_obj: QueryObjectDict | None = None) -> VizPayload:
+        """Extend the base payload with merged child-layer filter metadata.
+
+        The applied/rejected filter metadata collected from each sub-slice in
+        ``get_data`` is merged into the base payload so dashboard filter badges
+        reflect the filters applied across all layers.
+        """
+        payload = super().get_payload(query_obj)
+        payload["applied_filters"] = self._merge_filter_metadata(
+            payload.get("applied_filters"),
+            self.applied_filters,
+        )
+        payload["rejected_filters"] = self._merge_filter_metadata(
+            payload.get("rejected_filters"),
+            self.rejected_filters,
+        )
+        return payload
+
 
 class BaseDeckGLViz(BaseViz):
     """Base class for deck.gl visualizations"""
diff --git a/tests/integration_tests/viz_tests.py b/tests/integration_tests/viz_tests.py
index b26840e3759..ad6d9b16a5a 100644
--- a/tests/integration_tests/viz_tests.py
+++ b/tests/integration_tests/viz_tests.py
@@ -27,7 +27,7 @@ import tests.integration_tests.test_app  # noqa: F401
 import superset.viz as viz
 from flask import current_app
 from superset.exceptions import QueryObjectValidationError, SpatialException
-from superset.utils.core import DTTM_ALIAS
+from superset.utils.core import DTTM_ALIAS, ExtraFiltersReasonType
 from superset.utils.pandas_postprocessing.utils import FLAT_COLUMN_SEPARATOR
 from tests.conftest import with_config
 
@@ -1849,6 +1849,79 @@ class TestDeckGLMultiLayer(SupersetTestCase):
         assert len(result["slices"]) == 1
         assert result["slices"][0] == slice_1.data
 
+    @with_config({"MAPBOX_API_KEY": "test_key"})
+    @patch("superset.viz.viz_types")
+    @patch("superset.db.session")
+    def test_get_payload_includes_subslice_filter_metadata(
+        self,
+        mock_db_session,
+        mock_viz_types,
+    ):
+        """Test deck.gl multi-layer payload includes child filter metadata."""
+        datasource = self.get_datasource_mock()
+
+        slice_1 = Mock()
+        slice_1.form_data = {"viz_type": "deck_scatter"}
+        slice_1.data = {"features": [{"type": "Feature"}]}
+        slice_1.datasource = datasource
+
+        slice_2 = Mock()
+        slice_2.form_data = {"viz_type": "deck_path"}
+        slice_2.data = {"features": [{"type": "Feature"}]}
+        slice_2.datasource = datasource
+
+        mock_db_session.query.return_value.filter.return_value.all.return_value = [
+            slice_1,
+            slice_2,
+        ]
+
+        mock_scatter_viz_class = Mock()
+        mock_scatter_viz_instance = Mock()
+        mock_scatter_viz_instance.get_payload.return_value = {
+            "data": {"features": [{"id": 1}]},
+            "applied_filters": [{"column": "Latitude"}],
+            "rejected_filters": [],
+        }
+        mock_scatter_viz_class.return_value = mock_scatter_viz_instance
+
+        mock_path_viz_class = Mock()
+        mock_path_viz_instance = Mock()
+        mock_path_viz_instance.get_payload.return_value = {
+            "data": {"features": [{"id": 2}]},
+            "applied_filters": [
+                {"column": "Latitude"},
+                {"column": "Longitude"},
+            ],
+            "rejected_filters": [
+                {
+                    "column": "Country",
+                    "reason": ExtraFiltersReasonType.COL_NOT_IN_DATASOURCE,
+                },
+            ],
+        }
+        mock_path_viz_class.return_value = mock_path_viz_instance
+
+        mock_viz_types.get.side_effect = lambda viz_type: {
+            "deck_scatter": mock_scatter_viz_class,
+            "deck_path": mock_path_viz_class,
+        }.get(viz_type)
+
+        test_viz = viz.DeckGLMultiLayer(datasource, {"deck_slices": [1, 2]})
+        test_viz.get_df_payload = Mock(return_value={"df": pd.DataFrame()})
+
+        result = test_viz.get_payload()
+
+        assert result["applied_filters"] == [
+            {"column": "Latitude"},
+            {"column": "Longitude"},
+        ]
+        assert result["rejected_filters"] == [
+            {
+                "column": "Country",
+                "reason": ExtraFiltersReasonType.COL_NOT_IN_DATASOURCE,
+            },
+        ]
+
     @with_config({"MAPBOX_API_KEY": "test_key"})
     def test_get_data_empty_deck_slices(self):
         """Test get_data method with empty deck_slices."""