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 => 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 => { // 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 => 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 = 'deck.gl' + @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."""