feat(dashboard): pre-filter time grain (#38922)

This commit is contained in:
Brian Schreder
2026-04-21 10:35:24 -04:00
committed by GitHub
parent 230b25dd72
commit a222dab781
20 changed files with 1030 additions and 25 deletions

1
.gitignore vendored
View File

@@ -62,6 +62,7 @@ rat-results.txt
superset/app/
superset-websocket/config.json
.direnv
*.log
# Node.js, webpack artifacts, storybook
*.entry.js

View File

@@ -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[];

View File

@@ -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);
});

View File

@@ -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<RootState, boolean>(
state => state.dashboardState.isRefreshing,
@@ -114,6 +143,9 @@ const FilterValue: FC<FilterValueProps> = ({
}) => {
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<FilterValueProps> = ({
// 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<typeof waitForAsyncData>[0])
.then((asyncResult: ChartDataResponseResult[]) => {
setState(asyncResult);
setState(
applyTimeGrainAllowlist(
filterType,
allowedTimeGrains,
asyncResult,
),
);
setError(undefined);
handleFilterLoadFinish();
})
.catch((error: Response) => {
@@ -267,7 +311,13 @@ const FilterValue: FC<FilterValueProps> = ({
);
}
} else {
setState(json.result as ChartDataResponseResult[]);
setState(
applyTimeGrainAllowlist(
filterType,
allowedTimeGrains,
json.result as ChartDataResponseResult[],
),
);
setError(undefined);
handleFilterLoadFinish();
}
@@ -286,6 +336,7 @@ const FilterValue: FC<FilterValueProps> = ({
groupby,
handleFilterLoadFinish,
filter,
allowedTimeGrains,
hasDataSource,
isRefreshing,
shouldRefresh,

View File

@@ -212,6 +212,7 @@ const FilterTitleContainer = forwardRef<HTMLDivElement, Props>(
onRemove(id);
}}
alt={t('Remove filter')}
data-test="filter-remove-button"
/>
)}
</div>

View File

@@ -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 => (
<span
data-test={getOptionDataTest(
'customization-type-option',
option.value,
)}
>
{option.label || option.value}
</span>
)}
onChange={value => {
setNativeFilterFieldValues(form, filterId, {
filterType: value,
@@ -994,6 +1016,16 @@ const FiltersConfigForm = (
disabled: isDisabled,
};
})}
optionRender={option => (
<span
data-test={getOptionDataTest(
'filter-type-option',
option.value,
)}
>
{option.label || option.value}
</span>
)}
onChange={value => {
setNativeFilterFieldValues(form, filterId, {
filterType: value,
@@ -1254,6 +1286,78 @@ const FiltersConfigForm = (
</CollapsibleControl>
</FormItem>
)}
{itemTypeField === 'filter_timegrain' &&
hasDataset &&
datasetDetails?.time_grain_sqla &&
datasetDetails.time_grain_sqla.length > 0 && (
<FormItem
name={[
'filters',
filterId,
'preFilterTimegrain',
]}
>
<CollapsibleControl
initialValue={hasTimeGrainPreFilter}
title={t('Pre-filter available values')}
tooltip={t(
'Select which time grains are available in the filter control. This is a UI allow list only and does not add extra conditions to the underlying queries.',
)}
onChange={checked => {
if (!checked) {
setNativeFilterFieldValues(
form,
filterId,
{ time_grains: undefined },
);
forceUpdate();
}
formChanged();
}}
>
<FormItem
name={[
'filters',
filterId,
'time_grains',
]}
initialValue={
filterToEdit?.time_grains
}
{...getFiltersConfigModalTestId(
'time-grain-allowlist',
)}
>
<Select
mode="multiple"
ariaLabel={t('Time grain options')}
options={getTimeGrainOptions(
datasetDetails.time_grain_sqla,
)}
sortComparator={() => 0}
onChange={(values: string[]) => {
setNativeFilterFieldValues(
form,
filterId,
{
time_grains:
values.length > 0 &&
values.length <
datasetDetails
.time_grain_sqla.length
? values
: undefined,
},
);
forceUpdate();
formChanged();
}}
css={{ width: INPUT_WIDTH }}
/>
</FormItem>
</CollapsibleControl>
</FormItem>
)}
{itemTypeField !== 'filter_range' ? (
<FormItem
name={['filters', filterId, 'sortFilter']}
@@ -1347,24 +1451,21 @@ const FiltersConfigForm = (
}),
)}
onChange={value => {
if (value !== undefined) {
const previous =
form.getFieldValue(
'filters',
)?.[filterId].controlValues ||
{};
setNativeFilterFieldValues(
form,
filterId,
{
controlValues: {
...previous,
sortMetric: value,
},
const previous =
form.getFieldValue('filters')?.[
filterId
].controlValues || {};
setNativeFilterFieldValues(
form,
filterId,
{
controlValues: {
...previous,
sortMetric: value,
},
);
forceUpdate();
}
},
);
forceUpdate();
formChanged();
}}
/>

View File

@@ -0,0 +1,203 @@
/**
* 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 {
cleanup,
render,
screen,
userEvent,
waitFor,
} from 'spec/helpers/testing-library';
import { Form, Select } from '@superset-ui/core/components';
import { CollapsibleControl } from './CollapsibleControl';
import { getTimeGrainOptions } from './utils';
/**
* Tests for the Time Grain Pre-filter feature (CollapsibleControl + Select).
*
* These tests verify:
* - Options are rendered in database order (no client-side sorting)
* - A saved subset of time_grains is loaded and shown as selected values
* - The CollapsibleControl checkbox shows/hides the Select
* - Toggling the checkbox off clears the underlying selection
*/
const TIME_GRAIN_TUPLES: [string, string][] = [
['PT1S', 'Second'],
['PT1M', 'Minute'],
['PT1H', 'Hour'],
['P1D', 'Day'],
['P1W', 'Week'],
];
// NOTE: This file uses the real AntD Select. In jsdom, rc-overflow schedules
// deferred updates (raf/timers) that can fire after unmount and cause
// "state update on unmounted component" warnings. Scoped fake timers let us
// clear pending work deterministically during teardown for this test only.
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
cleanup();
jest.clearAllTimers();
jest.useRealTimers();
});
const renderPreFilter = (props: {
savedGrains?: string[];
initialChecked?: boolean;
onChangeMock?: jest.Mock;
}) => {
const {
savedGrains,
initialChecked = false,
onChangeMock = jest.fn(),
} = props;
const options = getTimeGrainOptions(TIME_GRAIN_TUPLES);
const PreFilterHarness = () => {
const [form] = Form.useForm();
const handleToggle = (checked: boolean) => {
// Mirrors the real form behavior: clear persisted values when disabled.
if (!checked) {
form.setFieldValue('time_grains', undefined);
}
onChangeMock(checked);
};
return (
<Form form={form}>
<CollapsibleControl
title="Pre-filter available values"
initialValue={initialChecked}
onChange={handleToggle}
>
<Form.Item name="time_grains" initialValue={savedGrains}>
<Select
mode="multiple"
ariaLabel="Time grain options"
options={options}
sortComparator={() => 0}
/>
</Form.Item>
</CollapsibleControl>
<Form.Item noStyle shouldUpdate>
{() => (
<div data-test="time-grains-value">
{JSON.stringify(form.getFieldValue('time_grains') ?? null)}
</div>
)}
</Form.Item>
</Form>
);
};
return render(<PreFilterHarness />);
};
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);
});

View File

@@ -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' },
]);
});

View File

@@ -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(() => {

View File

@@ -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)

View File

@@ -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;
}

View File

@@ -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,
),

View File

@@ -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']);
});

View File

@@ -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<QueryFormData> => {
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: [],

View File

@@ -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(<PluginFilterTimegrain {...defaultProps} />);
// 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(<PluginFilterTimegrain {...propsWithAllowlist} />);
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(<PluginFilterTimegrain {...propsWithEmptyAllowlist} />);
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(<PluginFilterTimegrain {...propsWithUndefined} />);
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);
});

View File

@@ -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 (
<FilterPluginStyle height={height} width={width}>

View File

@@ -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(<PluginFilterTimegrain {...(dashboardConfig as any)} />);
// 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(<PluginFilterTimegrain {...(dashboardConfig as any)} />);
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']);
});
});

View File

@@ -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<NativeFiltersFormItem>).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');
});

View File

@@ -24,6 +24,7 @@ import { PluginFilterHooks, PluginFilterStylesProps } from '../types';
interface PluginFilterTimeGrainCustomizeProps {
defaultValue?: string[] | null;
inputRef?: RefObject<HTMLInputElement>;
time_grains?: string[];
}
export type PluginFilterTimeGrainQueryFormData = QueryFormData &

View File

@@ -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