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."""