mirror of
https://github.com/apache/superset.git
synced 2026-06-09 17:49:26 +00:00
feat(dashboard): pre-filter time grain (#38922)
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -62,6 +62,7 @@ rat-results.txt
|
||||
superset/app/
|
||||
superset-websocket/config.json
|
||||
.direnv
|
||||
*.log
|
||||
|
||||
# Node.js, webpack artifacts, storybook
|
||||
*.entry.js
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -212,6 +212,7 @@ const FilterTitleContainer = forwardRef<HTMLDivElement, Props>(
|
||||
onRemove(id);
|
||||
}}
|
||||
alt={t('Remove filter')}
|
||||
data-test="filter-remove-button"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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' },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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}>
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -24,6 +24,7 @@ import { PluginFilterHooks, PluginFilterStylesProps } from '../types';
|
||||
interface PluginFilterTimeGrainCustomizeProps {
|
||||
defaultValue?: string[] | null;
|
||||
inputRef?: RefObject<HTMLInputElement>;
|
||||
time_grains?: string[];
|
||||
}
|
||||
|
||||
export type PluginFilterTimeGrainQueryFormData = QueryFormData &
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user