| /** |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with this work for additional information |
| * regarding copyright ownership. The ASF licenses this file |
| * to you under the Apache License, Version 2.0 (the |
| * "License"); you may not use this file except in compliance |
| * with the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, |
| * software distributed under the License is distributed on an |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| * KIND, either express or implied. See the License for the |
| * specific language governing permissions and limitations |
| * under the License. |
| */ |
| |
| import reducer, { getInitialDataMask } from './reducer'; |
| import { |
| SET_DATA_MASK_FOR_FILTER_CHANGES_COMPLETE, |
| type SetDataMaskForFilterChangesComplete, |
| } from './actions'; |
| import { |
| type ChartCustomization, |
| type DataMaskStateWithId, |
| type Filter, |
| type FilterConfiguration, |
| type NativeFilterTarget, |
| NativeFilterType, |
| ChartCustomizationType, |
| } from '@superset-ui/core'; |
| import { HYDRATE_DASHBOARD } from 'src/dashboard/actions/hydrate'; |
| |
| // Helper to create minimal filter for testing |
| const createFilter = ( |
| id: string, |
| columnName = 'col', |
| controlValues = {}, |
| ): Filter => |
| ({ |
| id, |
| name: id, |
| type: NativeFilterType.NativeFilter, |
| scope: { rootPath: [], excluded: [] }, |
| chartsInScope: [], |
| tabsInScope: [], |
| controlValues, |
| filterType: 'filter_select', |
| targets: [{ column: { name: columnName } } as NativeFilterTarget], |
| defaultDataMask: { filterState: { value: undefined } }, |
| cascadeParentIds: [], |
| description: '', |
| }) satisfies Partial<Filter> as Filter; |
| |
| // Helper to create action for filter modification |
| const createModifyAction = ( |
| modifiedFilter: Filter, |
| oldFilters = {}, |
| ): SetDataMaskForFilterChangesComplete => ({ |
| type: SET_DATA_MASK_FOR_FILTER_CHANGES_COMPLETE, |
| filterChanges: { |
| deleted: [], |
| reordered: [], |
| modified: [modifiedFilter], |
| }, |
| filters: oldFilters, |
| }); |
| |
| test('when user edits a filter and changes targets, other filters maintain their selected values', () => { |
| const initialState: DataMaskStateWithId = { |
| 'NATIVE_FILTER-1': { |
| id: 'NATIVE_FILTER-1', |
| ...getInitialDataMask('NATIVE_FILTER-1'), |
| filterState: { value: ['foo'] }, |
| }, |
| 'NATIVE_FILTER-2': { |
| id: 'NATIVE_FILTER-2', |
| ...getInitialDataMask('NATIVE_FILTER-2'), |
| filterState: { value: ['bar'] }, |
| }, |
| }; |
| |
| const action = createModifyAction( |
| createFilter('NATIVE_FILTER-1', 'col_changed'), |
| ); |
| |
| const result = reducer(initialState, action); |
| |
| expect(result['NATIVE_FILTER-2']?.filterState?.value).toEqual(['bar']); |
| expect(result['NATIVE_FILTER-1']?.filterState?.value).toBeUndefined(); |
| }); |
| |
| test('when user edits a filter without changing targets, their selection is preserved', () => { |
| const initialState: DataMaskStateWithId = { |
| 'NATIVE_FILTER-1': { |
| id: 'NATIVE_FILTER-1', |
| ...getInitialDataMask('NATIVE_FILTER-1'), |
| extraFormData: { time_range: '1 year ago' }, |
| filterState: { value: ['foo'] }, |
| }, |
| }; |
| |
| const oldFilters = { |
| 'NATIVE_FILTER-1': createFilter('NATIVE_FILTER-1', 'col_a', { |
| enableEmptyFilter: true, |
| }), |
| }; |
| |
| const action = createModifyAction( |
| createFilter('NATIVE_FILTER-1', 'col_a', { enableEmptyFilter: true }), |
| oldFilters, |
| ); |
| |
| const result = reducer(initialState, action); |
| |
| expect(result['NATIVE_FILTER-1']?.filterState?.value).toEqual(['foo']); |
| expect(result['NATIVE_FILTER-1']?.extraFormData?.time_range).toEqual( |
| '1 year ago', |
| ); |
| }); |
| |
| test('when a required range filter was cleared to [null, null], modifying it applies the new default instead of the cleared state', () => { |
| // Regression for the PR #40470 review: [null, null] is a range filter's |
| // canonical "cleared" value. It must count as "no value" so the empty state |
| // does not wipe a newly-defined default — consistent with `loadedHasValue` |
| // in fillNativeFilters. |
| const initialState: DataMaskStateWithId = { |
| 'NATIVE_FILTER-1': { |
| id: 'NATIVE_FILTER-1', |
| ...getInitialDataMask('NATIVE_FILTER-1'), |
| filterState: { value: [null, null] }, |
| }, |
| }; |
| |
| const oldFilters = { |
| 'NATIVE_FILTER-1': createFilter('NATIVE_FILTER-1', 'col_a', { |
| enableEmptyFilter: true, |
| }), |
| }; |
| |
| const modifiedFilter: Filter = { |
| ...createFilter('NATIVE_FILTER-1', 'col_a', { enableEmptyFilter: true }), |
| defaultDataMask: { filterState: { value: [10, 20] } }, |
| }; |
| |
| const action = createModifyAction(modifiedFilter, oldFilters); |
| |
| const result = reducer(initialState, action); |
| |
| // The cleared [null, null] state must not be preserved; the new default wins. |
| expect(result['NATIVE_FILTER-1']?.filterState?.value).toEqual([10, 20]); |
| }); |
| |
| // Runtime data from the server can contain null entries in |
| // chart_customization_config even though the TS type does not include | null |
| // yet. These helpers build HYDRATE_DASHBOARD actions that mirror that reality. |
| function hydrateAction( |
| chartCustomizationConfig: unknown[], |
| nativeFilterConfig: FilterConfiguration = [], |
| ) { |
| return { |
| type: HYDRATE_DASHBOARD as typeof HYDRATE_DASHBOARD, |
| data: { |
| dashboardInfo: { |
| metadata: { |
| native_filter_configuration: nativeFilterConfig, |
| chart_customization_config: |
| chartCustomizationConfig as ChartCustomization[], |
| }, |
| }, |
| dataMask: {}, |
| }, |
| }; |
| } |
| |
| test('HYDRATE_DASHBOARD filters null entries from chart_customization_config', () => { |
| const customizationId = 'CHART_CUSTOMIZATION-group-1'; |
| const action = hydrateAction([ |
| null, |
| { |
| id: customizationId, |
| type: ChartCustomizationType.ChartCustomization, |
| name: 'Dynamic Group By', |
| filterType: 'chart_customization_dynamic_groupby', |
| targets: [{ datasetId: 1, column: { name: 'status' } }], |
| scope: { rootPath: ['ROOT_ID'], excluded: [] }, |
| chartsInScope: [10], |
| defaultDataMask: { |
| extraFormData: {}, |
| filterState: { value: ['status'] }, |
| }, |
| controlValues: {}, |
| cascadeParentIds: [], |
| description: '', |
| }, |
| null, |
| ]); |
| |
| const result = reducer({}, action); |
| |
| expect(result[customizationId]).toBeDefined(); |
| expect(result[customizationId].filterState?.value).toEqual(['status']); |
| }); |
| |
| test('HYDRATE_DASHBOARD handles chart_customization_config that is entirely null', () => { |
| const action = hydrateAction([null, null]); |
| |
| const result = reducer({}, action); |
| |
| // Should not crash; no customization keys should appear |
| const customizationKeys = Object.keys(result).filter(k => |
| k.startsWith('CHART_CUSTOMIZATION'), |
| ); |
| expect(customizationKeys).toHaveLength(0); |
| }); |