From a222dab781e4deaef11ab48da0044d2d2b56148b Mon Sep 17 00:00:00 2001 From: Brian Schreder Date: Tue, 21 Apr 2026 10:35:24 -0400 Subject: [PATCH] feat(dashboard): pre-filter time grain (#38922) --- .gitignore | 1 + .../src/query/types/Dashboard.ts | 1 + .../FilterValue.timeGrain.test.ts | 60 ++++++ .../FilterBar/FilterControls/FilterValue.tsx | 57 ++++- .../FilterTitleContainer.tsx | 1 + .../FiltersConfigForm/FiltersConfigForm.tsx | 135 ++++++++++-- .../TimeGrainPreFilter.test.tsx | 203 ++++++++++++++++++ .../FiltersConfigForm/utils.test.ts | 27 +++ .../FiltersConfigForm/utils.ts | 10 + .../transformers/filterTransformer.ts | 3 + .../nativeFilters/FiltersConfigModal/types.ts | 1 + .../nativeFilters/FiltersConfigModal/utils.ts | 3 + .../components/nativeFilters/utils.test.ts | 21 +- .../components/nativeFilters/utils.ts | 5 + .../TimeGrain/TimeGrainFilterPlugin.test.tsx | 145 +++++++++++++ .../TimeGrain/TimeGrainFilterPlugin.tsx | 15 +- .../TimeGrainPreFilter.integration.test.tsx | 165 ++++++++++++++ .../TimeGrainsTransformer.integration.test.ts | 116 ++++++++++ .../src/filters/components/TimeGrain/types.ts | 1 + .../commands/importers/v1/utils_test.py | 85 ++++++++ 20 files changed, 1030 insertions(+), 25 deletions(-) create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.timeGrain.test.ts create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/TimeGrainPreFilter.test.tsx create mode 100644 superset-frontend/src/filters/components/TimeGrain/TimeGrainFilterPlugin.test.tsx create mode 100644 superset-frontend/src/filters/components/TimeGrain/TimeGrainPreFilter.integration.test.tsx create mode 100644 superset-frontend/src/filters/components/TimeGrain/TimeGrainsTransformer.integration.test.ts diff --git a/.gitignore b/.gitignore index 644835a533a..851cbab14e3 100644 --- a/.gitignore +++ b/.gitignore @@ -62,6 +62,7 @@ rat-results.txt superset/app/ superset-websocket/config.json .direnv +*.log # Node.js, webpack artifacts, storybook *.entry.js diff --git a/superset-frontend/packages/superset-ui-core/src/query/types/Dashboard.ts b/superset-frontend/packages/superset-ui-core/src/query/types/Dashboard.ts index bcf84355665..3fdf674f4f5 100644 --- a/superset-frontend/packages/superset-ui-core/src/query/types/Dashboard.ts +++ b/superset-frontend/packages/superset-ui-core/src/query/types/Dashboard.ts @@ -81,6 +81,7 @@ export type Filter = { granularity?: string; time_grain_sqla?: string; time_range?: string; + time_grains?: string[]; requiredFirst?: boolean; tabsInScope?: string[]; chartsInScope?: number[]; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.timeGrain.test.ts b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.timeGrain.test.ts new file mode 100644 index 00000000000..6680e8e70b8 --- /dev/null +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.timeGrain.test.ts @@ -0,0 +1,60 @@ +/** + * 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 { ChartDataResponseResult } from '@superset-ui/core'; +import { applyTimeGrainAllowlist } from './FilterValue'; + +const baseResults = [ + { + data: [ + { duration: 'PT1H', name: 'Hour' }, + { duration: 'P1D', name: 'Day' }, + { duration: 'P1W', name: 'Week' }, + { duration: 'P1M', name: 'Month' }, + ], + }, +] as unknown as ChartDataResponseResult[]; + +test('applyTimeGrainAllowlist should filter to configured durations', () => { + const filtered = applyTimeGrainAllowlist( + 'filter_timegrain', + ['PT1H', 'P1D', 'P1W'], + baseResults, + ); + + expect(filtered[0].data).toEqual([ + { duration: 'PT1H', name: 'Hour' }, + { duration: 'P1D', name: 'Day' }, + { duration: 'P1W', name: 'Week' }, + ]); +}); + +test('applyTimeGrainAllowlist should return unfiltered results for non-timegrain filters', () => { + const filtered = applyTimeGrainAllowlist( + 'filter_select', + ['PT1H'], + baseResults, + ); + expect(filtered).toEqual(baseResults); +}); + +test('applyTimeGrainAllowlist should return unfiltered results when allowlist is empty', () => { + const filtered = applyTimeGrainAllowlist('filter_timegrain', [], baseResults); + expect(filtered).toEqual(baseResults); +}); diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.tsx index f2ee5fc5acf..7c2fa1c8c28 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.tsx @@ -84,6 +84,35 @@ const StyledDiv = styled.div<{ const queriesDataPlaceholder = [{ data: [{}] }]; +type TimeGrainFilterConfig = { + time_grains?: string[]; +}; + +export const applyTimeGrainAllowlist = ( + filterType: string, + allowedTimeGrains: string[] | undefined, + results: ChartDataResponseResult[], +): ChartDataResponseResult[] => { + if (filterType !== 'filter_timegrain' || !allowedTimeGrains?.length) { + return results; + } + + return results.map(result => { + if (!Array.isArray(result.data)) { + return result; + } + + return { + ...result, + data: result.data.filter(row => + allowedTimeGrains.includes( + (row as { duration?: string }).duration ?? '', + ), + ), + }; + }); +}; + const useShouldFilterRefresh = () => { const isDashboardRefreshing = useSelector( state => state.dashboardState.isRefreshing, @@ -114,6 +143,9 @@ const FilterValue: FC = ({ }) => { const { id, targets, filterType } = filter; const isCustomization = isChartCustomization(filter); + const allowedTimeGrains = isCustomization + ? undefined + : (filter as TimeGrainFilterConfig).time_grains; const adhocFilters = isCustomization ? undefined : filter.adhoc_filters; const timeRange = isCustomization ? undefined : filter.time_range; const granularitySqla = isCustomization ? undefined : filter.granularity_sqla; @@ -247,12 +279,24 @@ const FilterValue: FC = ({ // deal with getChartDataRequest transforming the response data const result = 'result' in json ? json.result[0] : json; if (response.status === 200) { - setState([result as ChartDataResponseResult]); + setState( + applyTimeGrainAllowlist(filterType, allowedTimeGrains, [ + result as ChartDataResponseResult, + ]), + ); + setError(undefined); handleFilterLoadFinish(); } else if (response.status === 202) { waitForAsyncData(result as Parameters[0]) .then((asyncResult: ChartDataResponseResult[]) => { - setState(asyncResult); + setState( + applyTimeGrainAllowlist( + filterType, + allowedTimeGrains, + asyncResult, + ), + ); + setError(undefined); handleFilterLoadFinish(); }) .catch((error: Response) => { @@ -267,7 +311,13 @@ const FilterValue: FC = ({ ); } } else { - setState(json.result as ChartDataResponseResult[]); + setState( + applyTimeGrainAllowlist( + filterType, + allowedTimeGrains, + json.result as ChartDataResponseResult[], + ), + ); setError(undefined); handleFilterLoadFinish(); } @@ -286,6 +336,7 @@ const FilterValue: FC = ({ groupby, handleFilterLoadFinish, filter, + allowedTimeGrains, hasDataSource, isRefreshing, shouldRefresh, diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterTitleContainer.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterTitleContainer.tsx index b04a63ecf6d..6e0bbddbb5f 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterTitleContainer.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterTitleContainer.tsx @@ -212,6 +212,7 @@ const FilterTitleContainer = forwardRef( onRemove(id); }} alt={t('Remove filter')} + data-test="filter-remove-button" /> )} diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx index cc53dcbcb61..575319f7516 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx @@ -107,6 +107,7 @@ import RemovedFilter from './RemovedFilter'; import { useBackendFormUpdate, useDefaultValue } from './state'; import { hasTemporalColumns, + getTimeGrainOptions, isValidFilterValue, mostUsedDataset, setNativeFilterFieldValues, @@ -262,6 +263,12 @@ export interface FiltersConfigFormProps { const FILTERS_WITH_ADHOC_FILTERS = ['filter_select', 'filter_range']; +const getOptionDataTest = ( + prefix: string, + value: string | number | undefined, +) => + `${prefix}-${String(value ?? 'undefined').replace(/[^a-zA-Z0-9_-]/g, '-')}`; + // TODO: Rename the filter plugins and remove this mapping const FILTER_TYPE_NAME_MAPPING = { [t('Select filter')]: t('Value'), @@ -579,6 +586,10 @@ const FiltersConfigForm = ( !!filterToEdit?.adhoc_filters?.length || !!filterToEdit?.time_range; + const hasTimeGrainPreFilter = !!( + formFilter?.time_grains?.length || filterToEdit?.time_grains?.length + ); + const hasEnableSingleValue = formFilter?.controlValues?.enableSingleValue !== undefined || filterToEdit?.controlValues?.enableSingleValue !== undefined; @@ -746,6 +757,7 @@ const FiltersConfigForm = ( 'schema', 'sql', 'table_name', + 'time_grain_sqla', ], })}`, }) @@ -936,6 +948,16 @@ const FiltersConfigForm = ( label: name || pluginKey, }; })} + optionRender={option => ( + + {option.label || option.value} + + )} onChange={value => { setNativeFilterFieldValues(form, filterId, { filterType: value, @@ -994,6 +1016,16 @@ const FiltersConfigForm = ( disabled: isDisabled, }; })} + optionRender={option => ( + + {option.label || option.value} + + )} onChange={value => { setNativeFilterFieldValues(form, filterId, { filterType: value, @@ -1254,6 +1286,78 @@ const FiltersConfigForm = ( )} + {itemTypeField === 'filter_timegrain' && + hasDataset && + datasetDetails?.time_grain_sqla && + datasetDetails.time_grain_sqla.length > 0 && ( + + { + if (!checked) { + setNativeFilterFieldValues( + form, + filterId, + { time_grains: undefined }, + ); + forceUpdate(); + } + formChanged(); + }} + > + + 0} + /> + + + + {() => ( +
+ {JSON.stringify(form.getFieldValue('time_grains') ?? null)} +
+ )} +
+ + ); + }; + + return render(); +}; + +test('time grain options preserve database order (no sorting)', async () => { + renderPreFilter({ initialChecked: true }); + + const combobox = screen.getByRole('combobox', { + name: /Time grain options/i, + }); + await userEvent.click(combobox); + + const labels = (await screen.findAllByRole('option')).map( + option => option.textContent, + ); + + // Options must follow database order: Second, Minute, Hour, Day, Week + expect(labels).toEqual(['Second', 'Minute', 'Hour', 'Day', 'Week']); +}); + +test('saved time grains are loaded as selected values', () => { + renderPreFilter({ savedGrains: ['P1D', 'P1W'], initialChecked: true }); + + expect(screen.getByTitle('Day')).toBeInTheDocument(); + expect(screen.getByTitle('Week')).toBeInTheDocument(); + expect(screen.queryByTitle('Second')).not.toBeInTheDocument(); + expect(screen.getByTestId('time-grains-value')).toHaveTextContent( + '["P1D","P1W"]', + ); +}); + +test('unchecking CollapsibleControl clears underlying time_grains selection', async () => { + renderPreFilter({ savedGrains: ['P1D', 'P1W'], initialChecked: true }); + + expect(screen.getByTestId('time-grains-value')).toHaveTextContent( + '["P1D","P1W"]', + ); + + const checkbox = screen.getByRole('checkbox', { + name: /Pre-filter available values/i, + }); + await userEvent.click(checkbox); + + await waitFor(() => { + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + expect(screen.getByTestId('time-grains-value')).toHaveTextContent('null'); + }); +}); + +test('CollapsibleControl checkbox shows and hides the grain Select', async () => { + renderPreFilter({}); + + // Initially unchecked — select should be hidden + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + + const checkbox = screen.getByRole('checkbox', { + name: /Pre-filter available values/i, + }); + await userEvent.click(checkbox); + + // After checking — select should appear + expect(screen.getByRole('combobox')).toBeInTheDocument(); + + await userEvent.click(checkbox); + + // After unchecking — select should disappear + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); +}); + +test('CollapsibleControl starts expanded when initialValue is true', () => { + renderPreFilter({ initialChecked: true }); + + // When initialValue=true the checkbox is checked and children are visible + expect(screen.getByRole('combobox')).toBeInTheDocument(); + const checkbox = screen.getByRole('checkbox', { + name: /Pre-filter available values/i, + }); + expect(checkbox).toBeChecked(); +}); + +test('onChange is called with correct value when checkbox is toggled', async () => { + const onChangeMock = jest.fn(); + renderPreFilter({ onChangeMock }); + + const checkbox = screen.getByRole('checkbox', { + name: /Pre-filter available values/i, + }); + await userEvent.click(checkbox); + expect(onChangeMock).toHaveBeenCalledWith(true); + + await userEvent.click(checkbox); + expect(onChangeMock).toHaveBeenCalledWith(false); +}); diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/utils.test.ts b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/utils.test.ts index 65d5313d86e..6bfbb52a80d 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/utils.test.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/utils.test.ts @@ -30,6 +30,7 @@ import { shouldShowTimeRangePicker, mostUsedDataset, doesColumnMatchFilterType, + getTimeGrainOptions, } from './utils'; // Test hasTemporalColumns - validates time range pre-filter visibility logic @@ -276,3 +277,29 @@ test('isValidFilterValue returns false when range filter value is not an array', expect(isValidFilterValue(null, true)).toBe(false); expect(isValidFilterValue(undefined, true)).toBe(false); }); + +test('getTimeGrainOptions normalizes tuple payloads into visible select options', () => { + expect( + getTimeGrainOptions([ + ['P1D', 'Day'], + ['PT1H', 'Hour'], + ['P1W', 'Week'], + ]), + ).toEqual([ + { value: 'P1D', label: 'Day' }, + { value: 'PT1H', label: 'Hour' }, + { value: 'P1W', label: 'Week' }, + ]); +}); + +test('getTimeGrainOptions falls back to value when tuple label is empty', () => { + expect( + getTimeGrainOptions([ + ['P1D', ''], + ['P1W', 'Week'], + ]), + ).toEqual([ + { value: 'P1D', label: 'P1D' }, + { value: 'P1W', label: 'Week' }, + ]); +}); diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/utils.ts b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/utils.ts index 7c05f393cee..f586dcb5024 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/utils.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/utils.ts @@ -27,6 +27,16 @@ import { FILTER_SUPPORTED_TYPES } from './constants'; const FILTERS_FIELD_NAME = 'filters'; +type TimeGrainTuple = [string, string]; + +export const getTimeGrainOptions = ( + timeGrains: TimeGrainTuple[] = [], +): { value: string; label: string }[] => + timeGrains.map(timeGrain => { + const [value, label] = timeGrain; + return { value, label: label || value }; + }); + export const useForceUpdate = (isActive = true) => { const [, updateState] = useState({}); return useCallback(() => { diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/transformers/filterTransformer.ts b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/transformers/filterTransformer.ts index 8a28084f355..b2d059cdcb3 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/transformers/filterTransformer.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/transformers/filterTransformer.ts @@ -127,6 +127,9 @@ function transformFormInput( adhoc_filters: formInputs.adhoc_filters, time_range: formInputs.time_range, granularity_sqla: formInputs.granularity_sqla, + time_grains: formInputs.time_grains?.length + ? formInputs.time_grains + : undefined, sortMetric: formInputs.sortMetric ?? null, requiredFirst: formInputs.requiredFirst ? Object.values(formInputs.requiredFirst).find(rf => rf) diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/types.ts b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/types.ts index 82d1c0aa387..78461fcbf20 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/types.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/types.ts @@ -51,6 +51,7 @@ export interface NativeFiltersFormItem { adhoc_filters?: AdhocFilter[]; time_range?: string; granularity_sqla?: string; + time_grains?: string[]; type: typeof NativeFilterType.NativeFilter; description: string; } diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/utils.ts b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/utils.ts index d412349adc3..9b3b87641cf 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/utils.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/utils.ts @@ -140,6 +140,9 @@ export const createHandleSave = time_range: formInputs.time_range, controlValues: formInputs.controlValues ?? {}, granularity_sqla: formInputs.granularity_sqla, + ...(formInputs.time_grains?.length + ? { time_grains: formInputs.time_grains } + : {}), requiredFirst: Object.values(formInputs.requiredFirst ?? {}).find( rf => rf, ), diff --git a/superset-frontend/src/dashboard/components/nativeFilters/utils.test.ts b/superset-frontend/src/dashboard/components/nativeFilters/utils.test.ts index 03fc3a0f4a9..2873e43300b 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/utils.test.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/utils.test.ts @@ -19,7 +19,11 @@ import { Behavior } from '@superset-ui/core'; import { DashboardLayout } from 'src/dashboard/types'; import { CHART_TYPE } from 'src/dashboard/util/componentTypes'; -import { nativeFilterGate, findTabsWithChartsInScope } from './utils'; +import { + nativeFilterGate, + findTabsWithChartsInScope, + getFormData, +} from './utils'; // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('nativeFilterGate', () => { @@ -81,3 +85,18 @@ test('findTabsWithChartsInScope should handle a recursive layout structure', () [], ); }); + +test('getFormData should include persisted time_grains for time grain filters', () => { + const formData = getFormData({ + dashboardId: 10, + id: 'NATIVE_FILTER-1', + filterType: 'filter_timegrain', + type: 'NATIVE_FILTER' as any, + controlValues: {}, + defaultDataMask: {}, + datasetId: 11, + time_grains: ['PT1H', 'P1D', 'P1W'], + }); + + expect((formData as any).time_grains).toEqual(['PT1H', 'P1D', 'P1W']); +}); diff --git a/superset-frontend/src/dashboard/components/nativeFilters/utils.ts b/superset-frontend/src/dashboard/components/nativeFilters/utils.ts index 7fc18f0aeab..4401071eb8f 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/utils.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/utils.ts @@ -50,6 +50,7 @@ export const getFormData = ({ groupby, defaultDataMask, controlValues, + time_grains, filterType, sortMetric, adhoc_filters, @@ -67,6 +68,7 @@ export const getFormData = ({ time_range?: string; sortMetric?: string | null; granularity_sqla?: string; + time_grains?: string[]; }): Partial => { const otherProps: { datasource?: string; @@ -84,9 +86,12 @@ export const getFormData = ({ } const vizType = filterType; + const timeGrainsFormData = + time_grains && time_grains.length > 0 ? { time_grains } : {}; return { ...controlValues, + ...timeGrainsFormData, ...otherProps, adhoc_filters: adhoc_filters ?? [], extra_filters: [], diff --git a/superset-frontend/src/filters/components/TimeGrain/TimeGrainFilterPlugin.test.tsx b/superset-frontend/src/filters/components/TimeGrain/TimeGrainFilterPlugin.test.tsx new file mode 100644 index 00000000000..d2a2c3bd7fa --- /dev/null +++ b/superset-frontend/src/filters/components/TimeGrain/TimeGrainFilterPlugin.test.tsx @@ -0,0 +1,145 @@ +/** + * 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 { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { render } from 'spec/helpers/testing-library'; +import PluginFilterTimegrain from './TimeGrainFilterPlugin'; +import { PluginFilterTimeGrainProps } from './types'; + +const mockSetDataMask = jest.fn(); +const mockSetFilterActive = jest.fn(); +const mockSetHoveredFilter = jest.fn(); +const mockUnsetHoveredFilter = jest.fn(); +const mockSetFocusedFilter = jest.fn(); +const mockUnsetFocusedFilter = jest.fn(); + +const defaultProps: PluginFilterTimeGrainProps = { + data: [ + { duration: 'P1D', name: 'Day' }, + { duration: 'P1W', name: 'Week' }, + { duration: 'P1M', name: 'Month' }, + { duration: 'P1Y', name: 'Year' }, + ], + formData: { + datasource: '3__table', + viz_type: 'filter_timegrain', + groupby: [], + adhoc_filters: [], + extra_filters: [], + extra_form_data: {}, + granularity_sqla: 'ds', + time_range_endpoints: ['inclusive', 'exclusive'], + url_params: {}, + height: 300, + width: 300, + nativeFilterId: 'filter-1', + defaultValue: null, + inputRef: { current: null }, + }, + filterState: { + value: null, + validateStatus: undefined, + validateMessage: undefined, + }, + height: 300, + width: 300, + setDataMask: mockSetDataMask, + setFilterActive: mockSetFilterActive, + setHoveredFilter: mockSetHoveredFilter, + unsetHoveredFilter: mockUnsetHoveredFilter, + setFocusedFilter: mockSetFocusedFilter, + unsetFocusedFilter: mockUnsetFocusedFilter, + inputRef: { current: null }, +}; + +test('renders all options when time_grains is not set', async () => { + render(); + + // Verify the select component is rendered + const select = screen.getByRole('combobox'); + expect(select).toBeInTheDocument(); + + // Open the dropdown and verify all options are available + await userEvent.click(select); + const options = await screen.findAllByRole('option'); + expect(options.length).toBe(4); + expect(options[0]).toHaveTextContent('Day'); + expect(options[1]).toHaveTextContent('Week'); + expect(options[2]).toHaveTextContent('Month'); + expect(options[3]).toHaveTextContent('Year'); +}); + +test('filters options based on time_grains allowlist', async () => { + const propsWithAllowlist = { + ...defaultProps, + formData: { + ...defaultProps.formData, + time_grains: ['P1D', 'P1W'], + }, + }; + + render(); + + const select = screen.getByRole('combobox'); + await userEvent.click(select); + + // Only Day and Week should be available + const options = await screen.findAllByRole('option'); + expect(options.length).toBe(2); + expect(options[0]).toHaveTextContent('Day'); + expect(options[1]).toHaveTextContent('Week'); +}); + +test('shows all options when time_grains is empty array', async () => { + const propsWithEmptyAllowlist = { + ...defaultProps, + formData: { + ...defaultProps.formData, + time_grains: [], + }, + }; + + render(); + + const select = screen.getByRole('combobox'); + await userEvent.click(select); + + // All 4 options should be available + const options = await screen.findAllByRole('option'); + expect(options.length).toBe(4); +}); + +test('shows all options when time_grains is undefined', async () => { + const propsWithUndefined = { + ...defaultProps, + formData: { + ...defaultProps.formData, + time_grains: undefined, + }, + }; + + render(); + + const select = screen.getByRole('combobox'); + await userEvent.click(select); + + // All 4 options should be available + const options = await screen.findAllByRole('option'); + expect(options.length).toBe(4); +}); diff --git a/superset-frontend/src/filters/components/TimeGrain/TimeGrainFilterPlugin.tsx b/superset-frontend/src/filters/components/TimeGrain/TimeGrainFilterPlugin.tsx index 4b011a2e170..1f0dfce91e6 100644 --- a/superset-frontend/src/filters/components/TimeGrain/TimeGrainFilterPlugin.tsx +++ b/superset-frontend/src/filters/components/TimeGrain/TimeGrainFilterPlugin.tsx @@ -107,15 +107,22 @@ export default function PluginFilterTimegrain( ); } - const options = (data || []).map( - (row: { name: string; duration: string }) => { + const options = (data || []) + .map((row: { name: string; duration: string }) => { const { name, duration } = row; return { label: name, value: duration, }; - }, - ); + }) + // Apply allowlist filter if time_grains is configured, but keep current selection visible + .filter(option => { + const allowlist = formData.time_grains; + if (!allowlist || allowlist.length === 0) { + return true; + } + return allowlist.includes(option.value) || value.includes(option.value); + }); return ( diff --git a/superset-frontend/src/filters/components/TimeGrain/TimeGrainPreFilter.integration.test.tsx b/superset-frontend/src/filters/components/TimeGrain/TimeGrainPreFilter.integration.test.tsx new file mode 100644 index 00000000000..501eafe596f --- /dev/null +++ b/superset-frontend/src/filters/components/TimeGrain/TimeGrainPreFilter.integration.test.tsx @@ -0,0 +1,165 @@ +/** + * 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. + */ + +/** + * Integration Test: Time Grain Pre-filter Feature (End-to-End) + * + * Tests the full flow: + * 1. Dashboard config: User enables pre-filter and selects allowed time grains + * 2. Dashboard persistence: Config is saved with time_grains array + * 3. Runtime filter: Dashboard displays only the pre-filtered time grains + * + * Note: This documents the expected behavior. Full E2E testing requires + * Playwright/browser tests since it involves dashboard state + filter interactions. + */ + +import { + render, + screen, + userEvent, + waitFor, +} from 'spec/helpers/testing-library'; +import PluginFilterTimegrain from 'src/filters/components/TimeGrain/TimeGrainFilterPlugin'; + +/** + * Scenario: Dashboard owner configures a time grain filter to show only Hour, Day, Week. + * End-user opens the dashboard and can only select from those three options. + */ +test('time grain pre-filter restricts dashboard filter options', async () => { + // Step 1: Simulate saved dashboard config + // (User previously set pre-filter to ['PT1H', 'P1D', 'P1W']) + const setDataMask = jest.fn(); + const dashboardConfig = { + data: [ + { duration: 'PT1M', name: 'Minute' }, + { duration: 'PT1H', name: 'Hour' }, + { duration: 'P1D', name: 'Day' }, + { duration: 'P1W', name: 'Week' }, + { duration: 'P1M', name: 'Month' }, + ], + formData: { + nativeFilterId: 'time_grain_1', + defaultValue: null, + viz_type: 'filter_timegrain', + // This is what was saved by the config form: + time_grains: ['PT1H', 'P1D', 'P1W'], + }, + filterState: { + value: null, + validateStatus: undefined, + validateMessage: undefined, + }, + height: 100, + width: 300, + setDataMask, + setFilterActive: jest.fn(), + setHoveredFilter: jest.fn(), + unsetHoveredFilter: jest.fn(), + setFocusedFilter: jest.fn(), + unsetFocusedFilter: jest.fn(), + inputRef: { current: null }, + }; + + // Step 2: Render the dashboard filter + render(); + + // Ignore initialization updates and validate the explicit user-selection payload. + setDataMask.mockClear(); + + // Step 3: Verify only pre-filtered options appear + const select = screen.getByRole('combobox'); + await userEvent.click(select); + + await waitFor(() => { + const options = screen.getAllByRole('option'); + const labels = options.map(o => o.textContent); + + // Should only show Hour, Day, Week (in database order) + expect(labels).toEqual(['Hour', 'Day', 'Week']); + + // Should NOT show Minute or Month + expect(labels).not.toContain('Minute'); + expect(labels).not.toContain('Month'); + }); + + // Step 4: Selecting one allowed option should update runtime payload + await userEvent.click(screen.getByText('Day')); + + await waitFor(() => { + expect(setDataMask).toHaveBeenCalledWith({ + extraFormData: { + time_grain_sqla: 'P1D', + }, + filterState: { + label: 'Day', + value: ['P1D'], + }, + }); + }); +}); + +/** + * Scenario: Dashboard owner disables pre-filter (unchecks the CollapsibleControl). + * No restrictions: all time grains appear in the runtime filter. + */ +test('all time grains appear when pre-filter is unchecked', async () => { + const dashboardConfig = { + data: [ + { duration: 'PT1M', name: 'Minute' }, + { duration: 'PT1H', name: 'Hour' }, + { duration: 'P1D', name: 'Day' }, + { duration: 'P1W', name: 'Week' }, + { duration: 'P1M', name: 'Month' }, + ], + formData: { + nativeFilterId: 'time_grain_1', + defaultValue: null, + viz_type: 'filter_timegrain', + // Pre-filter not set (checkbox unchecked in config) + time_grains: undefined, + }, + filterState: { + value: null, + validateStatus: undefined, + validateMessage: undefined, + }, + height: 100, + width: 300, + setDataMask: jest.fn(), + setFilterActive: jest.fn(), + setHoveredFilter: jest.fn(), + unsetHoveredFilter: jest.fn(), + setFocusedFilter: jest.fn(), + unsetFocusedFilter: jest.fn(), + inputRef: { current: null }, + }; + + render(); + + const select = screen.getByRole('combobox'); + await userEvent.click(select); + + await waitFor(() => { + const options = screen.getAllByRole('option'); + const labels = options.map(o => o.textContent); + + // All 5 options should be available + expect(labels).toEqual(['Minute', 'Hour', 'Day', 'Week', 'Month']); + }); +}); diff --git a/superset-frontend/src/filters/components/TimeGrain/TimeGrainsTransformer.integration.test.ts b/superset-frontend/src/filters/components/TimeGrain/TimeGrainsTransformer.integration.test.ts new file mode 100644 index 00000000000..704bc58a11e --- /dev/null +++ b/superset-frontend/src/filters/components/TimeGrain/TimeGrainsTransformer.integration.test.ts @@ -0,0 +1,116 @@ +/** + * 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 { NativeFilterType } from '@superset-ui/core'; +import { getInitialDataMask } from 'src/dataMask/reducer'; +import type { NativeFiltersFormItem } from 'src/dashboard/components/nativeFilters/FiltersConfigModal/types'; +import { transformFilterForSave } from 'src/dashboard/components/nativeFilters/FiltersConfigModal/transformers'; + +const createTimeGrainFormInput = ( + timeGrains?: string[], +): NativeFiltersFormItem => ({ + type: NativeFilterType.NativeFilter, + scope: { + rootPath: ['ROOT_ID'], + excluded: [], + }, + name: 'Time Grain', + filterType: 'filter_timegrain', + dataset: { + value: 10, + label: 'main.dataset', + }, + column: 'dttm', + controlValues: {}, + requiredFirst: {}, + defaultValue: null, + defaultDataMask: getInitialDataMask(), + sortMetric: null, + time_grains: timeGrains, + description: '', +}); + +test('transformFilterForSave persists time_grains when a subset is selected', () => { + const transformed = transformFilterForSave( + 'NATIVE_FILTER-subset', + createTimeGrainFormInput(['PT1H', 'P1D', 'P1W']), + ); + + expect(transformed).toBeDefined(); + expect(transformed && 'time_grains' in transformed).toBe(true); + expect(transformed && transformed.type).toBe(NativeFilterType.NativeFilter); + expect( + transformed && 'time_grains' in transformed + ? transformed.time_grains + : undefined, + ).toEqual(['PT1H', 'P1D', 'P1W']); +}); + +test('transformFilterForSave omits time_grains from API payload when all are selected', () => { + const transformed = transformFilterForSave( + 'NATIVE_FILTER-all', + createTimeGrainFormInput(undefined), + ); + + expect(transformed).toBeDefined(); + expect( + transformed && 'time_grains' in transformed + ? transformed.time_grains + : undefined, + ).toBeUndefined(); + + // API boundary: undefined keys are omitted from JSON payloads. + const serialized = JSON.parse(JSON.stringify(transformed)); + expect(serialized).not.toHaveProperty('time_grains'); +}); + +test('transformFilterForSave remains backward compatible when time_grains is missing', () => { + const formInput = createTimeGrainFormInput(); + delete (formInput as Partial).time_grains; + + const transformed = transformFilterForSave('NATIVE_FILTER-legacy', formInput); + + expect(transformed).toBeDefined(); + expect( + transformed && 'time_grains' in transformed + ? transformed.time_grains + : undefined, + ).toBeUndefined(); + + const serialized = JSON.parse(JSON.stringify(transformed)); + expect(serialized).not.toHaveProperty('time_grains'); +}); + +test('transformFilterForSave omits time_grains when an empty array is provided', () => { + const transformed = transformFilterForSave( + 'NATIVE_FILTER-empty-array', + createTimeGrainFormInput([]), + ); + + expect(transformed).toBeDefined(); + expect( + transformed && 'time_grains' in transformed + ? transformed.time_grains + : undefined, + ).toBeUndefined(); + + // API boundary: empty allowlist should behave like unrestricted and be omitted. + const serialized = JSON.parse(JSON.stringify(transformed)); + expect(serialized).not.toHaveProperty('time_grains'); +}); diff --git a/superset-frontend/src/filters/components/TimeGrain/types.ts b/superset-frontend/src/filters/components/TimeGrain/types.ts index 611566f0d1d..c1db6a57a8d 100644 --- a/superset-frontend/src/filters/components/TimeGrain/types.ts +++ b/superset-frontend/src/filters/components/TimeGrain/types.ts @@ -24,6 +24,7 @@ import { PluginFilterHooks, PluginFilterStylesProps } from '../types'; interface PluginFilterTimeGrainCustomizeProps { defaultValue?: string[] | null; inputRef?: RefObject; + time_grains?: string[]; } export type PluginFilterTimeGrainQueryFormData = QueryFormData & diff --git a/tests/unit_tests/dashboards/commands/importers/v1/utils_test.py b/tests/unit_tests/dashboards/commands/importers/v1/utils_test.py index 72bf490a5b0..0edd659bb21 100644 --- a/tests/unit_tests/dashboards/commands/importers/v1/utils_test.py +++ b/tests/unit_tests/dashboards/commands/importers/v1/utils_test.py @@ -199,3 +199,88 @@ def test_update_id_refs_cross_filter_handles_string_excluded(): fixed = update_id_refs(config, chart_ids, dataset_info) # Should not raise and should remap key assert "1" in fixed["metadata"]["chart_configuration"] + + +def test_update_id_refs_preserves_time_grains_in_native_filters(): + """ + Test that time_grains allowlist is preserved during dashboard import. + + The time_grains field is a top-level filter configuration key that should + survive the update_id_refs transformation without modification. + """ + from superset.commands.dashboard.importers.v1.utils import update_id_refs + + config: dict[str, Any] = { + "position": { + "CHART1": { + "id": "CHART1", + "meta": {"chartId": 101, "uuid": "uuid1"}, + "type": "CHART", + }, + }, + "metadata": { + "native_filter_configuration": [ + { + "id": "NATIVE_FILTER-abc123", + "filterType": "filter_timegrain", + "name": "Time Grain", + "scope": {"rootPath": ["ROOT_ID"], "excluded": []}, + "targets": [{"datasetId": 201, "column": {"name": "dttm"}}], + "controlValues": {}, + "time_grains": ["P1D", "P1W", "P1M"], + } + ] + }, + } + + chart_ids = {"uuid1": 1} + dataset_info: dict[str, dict[str, Any]] = {} + + fixed = update_id_refs(config, chart_ids, dataset_info) + + # Verify time_grains is preserved unchanged + filter_config = fixed["metadata"]["native_filter_configuration"][0] + assert filter_config.get("time_grains") == ["P1D", "P1W", "P1M"] + assert filter_config.get("filterType") == "filter_timegrain" + + +def test_update_id_refs_handles_missing_time_grains(): + """ + Test backward compatibility when time_grains is not present. + + Existing filters without time_grains should not break during import. + """ + from superset.commands.dashboard.importers.v1.utils import update_id_refs + + config: dict[str, Any] = { + "position": { + "CHART1": { + "id": "CHART1", + "meta": {"chartId": 101, "uuid": "uuid1"}, + "type": "CHART", + }, + }, + "metadata": { + "native_filter_configuration": [ + { + "id": "NATIVE_FILTER-legacy", + "filterType": "filter_timegrain", + "name": "Legacy Time Grain", + "scope": {"rootPath": ["ROOT_ID"], "excluded": []}, + "targets": [{"datasetId": 201, "column": {"name": "dttm"}}], + "controlValues": {}, + # Note: no time_grains key (legacy filter) + } + ] + }, + } + + chart_ids = {"uuid1": 1} + dataset_info: dict[str, dict[str, Any]] = {} + + fixed = update_id_refs(config, chart_ids, dataset_info) + + # Verify filter is still valid and legacy payload keeps time_grains absent + filter_config = fixed["metadata"]["native_filter_configuration"][0] + assert filter_config.get("filterType") == "filter_timegrain" + assert "time_grains" not in filter_config