You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/09/22 17:29:35 UTC
(superset) branch master updated: feat(native-filters): add configurable display format for time filters (#44237)
This is an automated email from the ASF dual-hosted git repository.
rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new c4943a0ab34 feat(native-filters): add configurable display format for time filters (#44237)
c4943a0ab34 is described below
commit c4943a0ab3466bdd4f9974e53e86369ceb3e5656
Author: Ujjwal Jain <ja...@gmail.com>
AuthorDate: Tue Sep 22 22:59:28 2026 +0530
feat(native-filters): add configurable display format for time filters (#44237)
---
.../src/time-comparison/fetchTimeRange.ts | 29 +++++-
.../superset-ui-core/src/ui-overrides/types.ts | 1 +
.../test/time-comparison/fetchTimeRange.test.ts | 60 +++++++++++
.../FiltersConfigForm/FiltersConfigForm.tsx | 61 ++++++++++-
.../FiltersConfigForm/getControlItemsMap.test.tsx | 14 +++
.../FiltersConfigForm/getControlItemsMap.tsx | 3 +-
.../components/nativeFilters/utils.test.ts | 13 +++
.../controls/DateFilterControl/DateFilterLabel.tsx | 116 ++++++++++++---------
.../tests/DateFilterLabel.test.tsx | 114 ++++++++++++++++++++
.../components/controls/DateFilterControl/types.ts | 1 +
.../components/Time/TimeFilterPlugin.test.tsx | 95 +++++++++++++++++
.../filters/components/Time/TimeFilterPlugin.tsx | 1 +
.../src/filters/components/Time/controlPanel.ts | 17 +++
.../src/filters/components/Time/types.ts | 1 +
14 files changed, 471 insertions(+), 55 deletions(-)
diff --git a/superset-frontend/packages/superset-ui-core/src/time-comparison/fetchTimeRange.ts b/superset-frontend/packages/superset-ui-core/src/time-comparison/fetchTimeRange.ts
index 638c7ec719a..131bbb333c8 100644
--- a/superset-frontend/packages/superset-ui-core/src/time-comparison/fetchTimeRange.ts
+++ b/superset-frontend/packages/superset-ui-core/src/time-comparison/fetchTimeRange.ts
@@ -22,26 +22,46 @@ import {
SupersetClient,
getClientErrorObject,
ensureIsArray,
+ getTimeFormatter,
} from '@superset-ui/core';
+import { extendedDayjs } from '@superset-ui/core/utils/dates';
export const SEPARATOR = ' : ';
export const buildTimeRangeString = (since: string, until: string): string =>
`${since}${SEPARATOR}${until}`;
-const formatDateEndpoint = (dttm: string, isStart?: boolean): string =>
- dttm.replace('T00:00:00', '') || (isStart ? '-∞' : '∞');
+const formatDateEndpoint = (
+ dttm: string,
+ isStart?: boolean,
+ dateFormat?: string,
+): string => {
+ if (!dttm) return isStart ? '-∞' : '∞';
+ if (dateFormat) {
+ const parsedDttm = extendedDayjs.utc(dttm);
+ if (parsedDttm.isValid()) {
+ return getTimeFormatter(dateFormat)(parsedDttm.toDate());
+ }
+ }
+ return dttm.replace('T00:00:00', '');
+};
export const formatTimeRange = (
timeRange: string,
columnPlaceholder = 'col',
+ dateFormat?: string,
) => {
const splitDateRange = timeRange.split(SEPARATOR);
if (splitDateRange.length === 1) return timeRange;
return `${formatDateEndpoint(
splitDateRange[0],
true,
- )} ≤ ${columnPlaceholder} < ${formatDateEndpoint(splitDateRange[1])}`;
+ dateFormat,
+ )} ≤ ${columnPlaceholder} < ${formatDateEndpoint(
+ splitDateRange[1],
+ false,
+ dateFormat,
+ )}`;
};
export const formatTimeRangeComparison = (
@@ -64,6 +84,7 @@ export const fetchTimeRange = async (
timeRange: string,
columnPlaceholder = 'col',
shifts?: string[],
+ dateFormat?: string,
) => {
let query;
let endpoint;
@@ -86,7 +107,7 @@ export const fetchTimeRange = async (
response?.json?.result[0]?.until || '',
);
return {
- value: formatTimeRange(timeRangeString, columnPlaceholder),
+ value: formatTimeRange(timeRangeString, columnPlaceholder, dateFormat),
};
}
const timeRanges = response?.json?.result.map((result: any) =>
diff --git a/superset-frontend/packages/superset-ui-core/src/ui-overrides/types.ts b/superset-frontend/packages/superset-ui-core/src/ui-overrides/types.ts
index c888c0d49ca..fd5496ecffc 100644
--- a/superset-frontend/packages/superset-ui-core/src/ui-overrides/types.ts
+++ b/superset-frontend/packages/superset-ui-core/src/ui-overrides/types.ts
@@ -230,6 +230,7 @@ export interface DateFilterControlProps {
onClosePopover?: () => void;
overlayStyle?: 'Modal' | 'Popover';
isOverflowingFilterBar?: boolean;
+ displayFormat?: string;
}
export interface ExploreChartHeaderProps {
diff --git a/superset-frontend/packages/superset-ui-core/test/time-comparison/fetchTimeRange.test.ts b/superset-frontend/packages/superset-ui-core/test/time-comparison/fetchTimeRange.test.ts
index 84bbaa5e628..74da8797371 100644
--- a/superset-frontend/packages/superset-ui-core/test/time-comparison/fetchTimeRange.test.ts
+++ b/superset-frontend/packages/superset-ui-core/test/time-comparison/fetchTimeRange.test.ts
@@ -58,6 +58,44 @@ test('generates a readable time range', () => {
expect(formatTimeRange('')).toBe('');
});
+test('formatTimeRange applies an optional D3 date format to both endpoints without reinterpreting the timezone', () => {
+ // jest.config.js pins the test runner to America/New_York, so if the
+ // implementation ever parsed these naive ISO strings as local time
+ // instead of UTC, the formatted date/time below would shift.
+ expect(
+ formatTimeRange(
+ '2019-01-14T23:30:00 : 2019-01-21T08:15:00',
+ 'col',
+ '%Y-%m-%d %H:%M',
+ ),
+ ).toBe('2019-01-14 23:30 ≤ col < 2019-01-21 08:15');
+
+ expect(
+ formatTimeRange(
+ '2019-01-14T01:32:10 : 2019-01-21T01:32:10',
+ 'col',
+ '%d-%m-%Y %H:%M:%S',
+ ),
+ ).toBe('14-01-2019 01:32:10 ≤ col < 21-01-2019 01:32:10');
+});
+
+test('formatTimeRange preserves the -∞/∞ placeholders when a date format is set', () => {
+ expect(formatTimeRange('2019-01-14T00:00:00 : ', 'col', '%d-%m-%Y')).toBe(
+ '14-01-2019 ≤ col < ∞',
+ );
+ expect(formatTimeRange(' : 2019-01-21T00:00:00', 'col', '%d-%m-%Y')).toBe(
+ '-∞ ≤ col < 21-01-2019',
+ );
+});
+
+test('formatTimeRange leaves human-readable values untouched even when a date format is set', () => {
+ expect(formatTimeRange('Last week', 'col', '%d-%m-%Y')).toBe('Last week');
+ expect(formatTimeRange('No filter', 'col', '%d-%m-%Y')).toBe('No filter');
+ expect(formatTimeRange('Yesterday : Tomorrow', 'col', '%d-%m-%Y')).toBe(
+ 'Yesterday ≤ col < Tomorrow',
+ );
+});
+
test('returns a formatted time range from response', async () => {
fetchMock.get('glob:*/api/v1/time_range/?q=%27Last+day%27', {
result: [
@@ -75,6 +113,28 @@ test('returns a formatted time range from response', async () => {
});
});
+test('returns a formatted time range from response using a custom date format', async () => {
+ fetchMock.get('glob:*/api/v1/time_range/?q=%27Last+day%27', {
+ result: [
+ {
+ since: '2021-04-13T00:00:00',
+ until: '2021-04-14T00:00:00',
+ timeRange: 'Last day',
+ },
+ ],
+ });
+
+ const timeRange = await fetchTimeRange(
+ 'Last day',
+ 'temporal_col',
+ undefined,
+ '%d/%m/%Y',
+ );
+ expect(timeRange).toEqual({
+ value: '13/04/2021 ≤ temporal_col < 14/04/2021',
+ });
+});
+
test('returns a formatted time range from empty response', async () => {
fetchMock.get('glob:*/api/v1/time_range/?q=%27Last+day%27', {
result: [],
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 2ec56efc8e4..ce90acef857 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx
@@ -17,7 +17,12 @@
* under the License.
*/
/* eslint-disable react-hooks/rules-of-hooks */
-import { ColumnMeta, Metric } from '@superset-ui/chart-controls';
+import {
+ ColumnMeta,
+ D3_TIME_FORMAT_DOCS,
+ D3_TIME_FORMAT_OPTIONS,
+ Metric,
+} from '@superset-ui/chart-controls';
import { t } from '@apache-superset/core/translation';
import {
Behavior,
@@ -646,6 +651,10 @@ const FiltersConfigForm = (
filterToEdit?.controlValues?.operatorType ??
SelectFilterOperatorType.Exact;
+ const currentDisplayFormat: string | undefined =
+ formFilter?.controlValues?.displayFormat ??
+ filterToEdit?.controlValues?.displayFormat;
+
const selectedColumnIsString = useMemo(() => {
const columnName = formFilter?.column;
if (!columnName || !datasetDetails?.columns) return true;
@@ -669,6 +678,18 @@ const FiltersConfigForm = (
forceUpdate();
};
+ const onDisplayFormatChanged = (value?: string) => {
+ const previous = form.getFieldValue('filters')?.[filterId].controlValues;
+ setNativeFilterFieldValues(form, filterId, {
+ controlValues: {
+ ...previous,
+ displayFormat: value || undefined,
+ },
+ });
+ formChanged();
+ forceUpdate();
+ };
+
const prevColumnRef = useRef(formFilter?.column);
const datasetLoaded = !!datasetDetails?.columns;
useEffect(() => {
@@ -1780,6 +1801,44 @@ const FiltersConfigForm = (
/>
</StyledRowFormItem>
)}
+ {!isChartCustomization &&
+ itemTypeField === 'filter_time' && (
+ <StyledRowFormItem
+ expanded={expanded}
+ name={[
+ 'filters',
+ filterId,
+ 'controlValues',
+ 'displayFormat',
+ ]}
+ initialValue={currentDisplayFormat}
+ label={
+ <>
+ <StyledLabel>
+ {t('Display format')}
+ </StyledLabel>
+
+ <InfoTooltip
+ placement="top"
+ tooltip={D3_TIME_FORMAT_DOCS}
+ />
+ </>
+ }
+ >
+ <Select
+ allowClear
+ allowNewOptions
+ ariaLabel={t('Display format')}
+ options={D3_TIME_FORMAT_OPTIONS.map(
+ ([value, label]) => ({ value, label }),
+ )}
+ placeholder={t('Default')}
+ onChange={value =>
+ onDisplayFormatChanged(value as string)
+ }
+ />
+ </StyledRowFormItem>
+ )}
<FormItem
name={['filters', filterId, 'defaultValue']}
>
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/getControlItemsMap.test.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/getControlItemsMap.test.tsx
index 9c97e04c7d8..99adca28e22 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/getControlItemsMap.test.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/getControlItemsMap.test.tsx
@@ -194,6 +194,20 @@ test('Clicking on checkbox', async () => {
expect(props.forceUpdate).toHaveBeenCalled();
});
+test('excludes displayFormat from the generic checkbox control items', () => {
+ const props = createProps();
+ (getControlItems as jest.Mock).mockReturnValue([
+ {
+ name: 'displayFormat',
+ config: { renderTrigger: true, label: 'Display format' },
+ },
+ ]);
+ const controlItemsMap = getControlItemsMap(props);
+ expect(controlItemsMap.controlItems).not.toHaveProperty('displayFormat');
+ renderControlItems(controlItemsMap);
+ expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
+});
+
test('Clicking on checkbox when resetConfig:false', async () => {
const props = createProps();
(getControlItems as jest.Mock).mockReturnValue([
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/getControlItemsMap.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/getControlItemsMap.tsx
index fe626b0cfc8..0b1a1a41868 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/getControlItemsMap.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/getControlItemsMap.tsx
@@ -172,7 +172,8 @@ export default function getControlItemsMap({
controlItem?.config?.renderTrigger &&
controlItem.name !== 'sortAscending' &&
controlItem.name !== 'enableSingleValue' &&
- controlItem.name !== 'operatorType',
+ controlItem.name !== 'operatorType' &&
+ controlItem.name !== 'displayFormat',
)
.forEach(controlItem => {
const initialValue =
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/utils.test.ts b/superset-frontend/src/dashboard/components/nativeFilters/utils.test.ts
index be5e59846da..287747f6cd6 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/utils.test.ts
+++ b/superset-frontend/src/dashboard/components/nativeFilters/utils.test.ts
@@ -135,3 +135,16 @@ test('getFormData should include persisted time_grains for time grain filters',
expect((formData as any).time_grains).toEqual(['PT1H', 'P1D', 'P1W']);
});
+
+test('getFormData passes controlValues.displayFormat through to the filter plugin formData', () => {
+ const formData = getFormData({
+ dashboardId: 10,
+ id: 'NATIVE_FILTER-1',
+ filterType: 'filter_time',
+ type: 'NATIVE_FILTER' as any,
+ controlValues: { displayFormat: '%d-%m-%Y' },
+ defaultDataMask: {},
+ });
+
+ expect((formData as any).displayFormat).toBe('%d-%m-%Y');
+});
diff --git a/superset-frontend/src/explore/components/controls/DateFilterControl/DateFilterLabel.tsx b/superset-frontend/src/explore/components/controls/DateFilterControl/DateFilterLabel.tsx
index 8ab72c13ff2..1099e724af8 100644
--- a/superset-frontend/src/explore/components/controls/DateFilterControl/DateFilterLabel.tsx
+++ b/superset-frontend/src/explore/components/controls/DateFilterControl/DateFilterLabel.tsx
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { ReactNode, useState, useEffect, useMemo } from 'react';
+import { ReactNode, useState, useEffect, useMemo, useRef } from 'react';
import { t } from '@apache-superset/core/translation';
import {
NO_TIME_RANGE,
@@ -148,6 +148,7 @@ export default function DateFilterLabel(props: DateFilterControlProps) {
onClosePopover = noOp,
isOverflowingFilterBar = false,
hovered: isControlHovered = false,
+ displayFormat,
} = props;
const defaultTimeFilter = useDefaultTimeFilter();
@@ -165,6 +166,13 @@ export default function DateFilterLabel(props: DateFilterControlProps) {
const [isDescriptionHovered, setIsDescriptionHovered] = useState(false);
const theme = useTheme();
const [labelRef, labelIsTruncated] = useCSSTextTruncation<HTMLSpanElement>();
+ // Separate per-effect: each only guards against a later request from the
+ // *same* effect. A shared counter would let effect 2's debounced draft
+ // fetch (which can still be pending/leftover after Apply) invalidate
+ // effect 1's Apply-triggered fetch purely because it started later,
+ // even though effect 1's result is the more relevant one.
+ const latestValueRequestId = useRef(0);
+ const latestDraftRequestId = useRef(0);
useEffect(() => {
if (!isControlHovered) {
@@ -179,45 +187,50 @@ export default function DateFilterLabel(props: DateFilterControlProps) {
setValidTimeRange(true);
return;
}
- fetchTimeRange(value).then(({ value: actualRange, error }) => {
- if (error) {
- setEvalResponse(error || '');
- setValidTimeRange(false);
- setTooltipTitle(t(value) || null);
- } else {
- /*
- HRT == human readable text
- ADR == actual datetime range
- +--------------+------+----------+--------+----------+-----------+
- | | Last | Previous | Custom | Advanced | No Filter |
- +--------------+------+----------+--------+----------+-----------+
- | control pill | HRT | HRT | ADR | ADR | HRT |
- +--------------+------+----------+--------+----------+-----------+
- | tooltip | ADR | ADR | HRT | HRT | ADR |
- +--------------+------+----------+--------+----------+-----------+
- */
- if (
- guessedFrame === 'Common' ||
- guessedFrame === 'Calendar' ||
- guessedFrame === 'Current' ||
- guessedFrame === 'No filter'
- ) {
- setActualTimeRange(value);
- setTooltipTitle(
- getTooltipTitle(labelIsTruncated, value, actualRange),
- );
+ latestValueRequestId.current += 1;
+ const requestId = latestValueRequestId.current;
+ fetchTimeRange(value, 'col', undefined, displayFormat).then(
+ ({ value: actualRange, error }) => {
+ if (requestId !== latestValueRequestId.current) return;
+ if (error) {
+ setEvalResponse(error || '');
+ setValidTimeRange(false);
+ setTooltipTitle(t(value) || null);
} else {
- setActualTimeRange(actualRange || '');
- setTooltipTitle(
- getTooltipTitle(labelIsTruncated, actualRange, value),
- );
+ /*
+ HRT == human readable text
+ ADR == actual datetime range
+ +--------------+------+----------+--------+----------+-----------+
+ | | Last | Previous | Custom | Advanced | No Filter |
+ +--------------+------+----------+--------+----------+-----------+
+ | control pill | HRT | HRT | ADR | ADR | HRT |
+ +--------------+------+----------+--------+----------+-----------+
+ | tooltip | ADR | ADR | HRT | HRT | ADR |
+ +--------------+------+----------+--------+----------+-----------+
+ */
+ if (
+ guessedFrame === 'Common' ||
+ guessedFrame === 'Calendar' ||
+ guessedFrame === 'Current' ||
+ guessedFrame === 'No filter'
+ ) {
+ setActualTimeRange(value);
+ setTooltipTitle(
+ getTooltipTitle(labelIsTruncated, value, actualRange),
+ );
+ } else {
+ setActualTimeRange(actualRange || '');
+ setTooltipTitle(
+ getTooltipTitle(labelIsTruncated, actualRange, value),
+ );
+ }
+ setValidTimeRange(true);
}
- setValidTimeRange(true);
- }
- setLastFetchedTimeRange(value);
- setEvalResponse(actualRange || value);
- });
- }, [guessedFrame, labelIsTruncated, labelRef, value]);
+ setLastFetchedTimeRange(value);
+ setEvalResponse(actualRange || value);
+ },
+ );
+ }, [displayFormat, guessedFrame, labelIsTruncated, labelRef, value]);
useDebouncedEffect(
() => {
@@ -228,20 +241,25 @@ export default function DateFilterLabel(props: DateFilterControlProps) {
return;
}
if (lastFetchedTimeRange !== timeRangeValue) {
- fetchTimeRange(timeRangeValue).then(({ value: actualRange, error }) => {
- if (error) {
- setEvalResponse(error || '');
- setValidTimeRange(false);
- } else {
- setEvalResponse(actualRange || '');
- setValidTimeRange(true);
- }
- setLastFetchedTimeRange(timeRangeValue);
- });
+ latestDraftRequestId.current += 1;
+ const requestId = latestDraftRequestId.current;
+ fetchTimeRange(timeRangeValue, 'col', undefined, displayFormat).then(
+ ({ value: actualRange, error }) => {
+ if (requestId !== latestDraftRequestId.current) return;
+ if (error) {
+ setEvalResponse(error || '');
+ setValidTimeRange(false);
+ } else {
+ setEvalResponse(actualRange || '');
+ setValidTimeRange(true);
+ }
+ setLastFetchedTimeRange(timeRangeValue);
+ },
+ );
}
},
Constants.SLOW_DEBOUNCE,
- [timeRangeValue],
+ [displayFormat, timeRangeValue],
);
function onSave() {
diff --git a/superset-frontend/src/explore/components/controls/DateFilterControl/tests/DateFilterLabel.test.tsx b/superset-frontend/src/explore/components/controls/DateFilterControl/tests/DateFilterLabel.test.tsx
index c511a201b32..8e28ce658b2 100644
--- a/superset-frontend/src/explore/components/controls/DateFilterControl/tests/DateFilterLabel.test.tsx
+++ b/superset-frontend/src/explore/components/controls/DateFilterControl/tests/DateFilterLabel.test.tsx
@@ -21,6 +21,7 @@ import { Provider } from 'react-redux';
import configureMockStore from 'redux-mock-store';
import {
+ act,
render,
screen,
userEvent,
@@ -197,6 +198,119 @@ test('DateFilter should properly handle isOverflowingFilterBar prop changes', as
expect(popoverAfterRerender?.parentElement).toBe(document.body);
});
+test('applies a configured displayFormat to the evaluated range but leaves the human-readable pill untouched', async () => {
+ mockedFetchTimeRange.mockImplementation(
+ async (_value, _columnPlaceholder, _shifts, dateFormat) =>
+ dateFormat
+ ? { value: `2024-01-01 ≤ col < 2024-01-08 (${dateFormat})` }
+ : { value: FIELD_TOOLTIP },
+ );
+
+ render(
+ setup({
+ ...defaultProps,
+ value: 'Last week',
+ displayFormat: '%d-%m-%Y',
+ }),
+ );
+
+ // the pill shows the raw human-readable value regardless of displayFormat
+ expect(await screen.findByText('Last week')).toBeInTheDocument();
+
+ // the tooltip shows the evaluated range, formatted with displayFormat
+ await userEvent.hover(screen.getByText('Last week'));
+ expect(await screen.findByRole('tooltip')).toHaveTextContent(
+ '2024-01-01 ≤ col < 2024-01-08 (%d-%m-%Y)',
+ );
+});
+
+test('regression: Apply during a pending debounced draft fetch must still update the pill', async () => {
+ // Reproduces the maintainer-identified race: the user edits the draft
+ // range (scheduling a 500ms debounced preview fetch), then clicks Apply
+ // before that timer fires. The Apply-triggered fetch (effect 1) must not
+ // be discarded just because the leftover draft fetch (effect 2) resolves
+ // its own request-tracking after Apply's fetch started.
+ jest.useFakeTimers({ advanceTimers: true });
+
+ const pendingResolvers: Array<(result: { value: string }) => void> = [];
+ mockedFetchTimeRange.mockImplementation(
+ () =>
+ new Promise(resolve => {
+ pendingResolvers.push(resolve);
+ }),
+ );
+
+ const onChange = jest.fn();
+ const { rerender } = render(
+ setup({ ...defaultProps, onChange, value: 'Last week' }),
+ );
+
+ // Settle every fetch effect-1 issues on mount (useCSSTextTruncation's
+ // ref/measurement can cause it to re-run once after the initial paint),
+ // so validTimeRange reflects the settled 'Last week' state and Apply is
+ // enabled before we start the actual scenario.
+ await act(async () => {
+ pendingResolvers.forEach(resolve =>
+ resolve({ value: 'evaluated: Last week' }),
+ );
+ });
+
+ // open the popover and wait for Apply to be enabled
+ await userEvent.click(screen.getByText('Last week'));
+ await waitFor(() => {
+ expect(screen.getByText('Apply').closest('button')).not.toBeDisabled();
+ });
+ const baseRequestCount = pendingResolvers.length;
+
+ // change the draft selection to 'Last month' — this schedules effect 2's
+ // 500ms debounced draft-preview fetch
+ fireEvent.click(screen.getByText('Last month'));
+
+ // click Apply well before that debounce timer fires
+ await act(async () => {
+ jest.advanceTimersByTime(100);
+ });
+ fireEvent.click(screen.getByText('Apply'));
+ expect(onChange).toHaveBeenCalledWith('Last month');
+
+ // simulate the real parent (TimeFilterPlugin) re-rendering with the
+ // applied value, exactly as it would after onChange updates formData
+ rerender(setup({ ...defaultProps, onChange, value: 'Last month' }));
+
+ // effect 1 (driven by the new `value` prop) starts its own fetch
+ await waitFor(() => {
+ expect(pendingResolvers.length).toBe(baseRequestCount + 1);
+ });
+ const applyRequestIndex = baseRequestCount;
+
+ // the leftover debounce timer from before Apply now fires and starts
+ // effect 2's fetch for the same value, *before* effect 1's fetch above
+ // has resolved
+ await act(async () => {
+ jest.advanceTimersByTime(500);
+ });
+ await waitFor(() => {
+ expect(pendingResolvers.length).toBe(baseRequestCount + 2);
+ });
+ const staleDraftRequestIndex = baseRequestCount + 1;
+
+ // effect 1's (older, Apply-triggered) fetch finally resolves
+ await act(async () => {
+ pendingResolvers[applyRequestIndex]({ value: 'evaluated: Last month' });
+ });
+
+ // the pill must reflect the applied selection, not the stale pre-Apply one
+ expect(screen.getByText('Last month')).toBeInTheDocument();
+ expect(screen.queryByText('Last week')).not.toBeInTheDocument();
+
+ // drain effect 2's own fetch so it doesn't dangle past the test
+ await act(async () => {
+ pendingResolvers[staleDraftRequestIndex]({
+ value: 'evaluated: Last month',
+ });
+ });
+});
+
test('hovering the description icon does not show the date range tooltip', async () => {
const tooltipOnClick = jest.fn();
render(
diff --git a/superset-frontend/src/explore/components/controls/DateFilterControl/types.ts b/superset-frontend/src/explore/components/controls/DateFilterControl/types.ts
index 0b8fcc67d61..b2a0d126c45 100644
--- a/superset-frontend/src/explore/components/controls/DateFilterControl/types.ts
+++ b/superset-frontend/src/explore/components/controls/DateFilterControl/types.ts
@@ -119,4 +119,5 @@ export interface DateFilterControlProps {
description?: ReactNode;
label?: ReactNode;
tooltipOnClick?: () => void;
+ displayFormat?: string;
}
diff --git a/superset-frontend/src/filters/components/Time/TimeFilterPlugin.test.tsx b/superset-frontend/src/filters/components/Time/TimeFilterPlugin.test.tsx
new file mode 100644
index 00000000000..9c4b628ba1b
--- /dev/null
+++ b/superset-frontend/src/filters/components/Time/TimeFilterPlugin.test.tsx
@@ -0,0 +1,95 @@
+/**
+ * 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 { render } from 'spec/helpers/testing-library';
+import TimeFilterPlugin from './TimeFilterPlugin';
+import { PluginFilterTimeProps } from './types';
+
+const capturedProps: Array<Record<string, unknown>> = [];
+jest.mock(
+ 'src/explore/components/controls/DateFilterControl',
+ () =>
+ function MockDateFilterControl(props: Record<string, unknown>) {
+ capturedProps.push(props);
+ return null;
+ },
+);
+
+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: PluginFilterTimeProps = {
+ behaviors: [],
+ data: [],
+ formData: {
+ datasource: '3__table',
+ viz_type: 'filter_time',
+ 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',
+ inView: true,
+ },
+ 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 },
+};
+
+beforeEach(() => {
+ capturedProps.length = 0;
+});
+
+test('passes formData.displayFormat through to the underlying date filter control', () => {
+ render(
+ <TimeFilterPlugin
+ {...defaultProps}
+ formData={{ ...defaultProps.formData, displayFormat: '%d-%m-%Y' }}
+ />,
+ );
+
+ expect(capturedProps).toHaveLength(1);
+ expect(capturedProps[0]).toMatchObject({ displayFormat: '%d-%m-%Y' });
+});
+
+test('passes an undefined displayFormat through when none is configured', () => {
+ render(<TimeFilterPlugin {...defaultProps} />);
+
+ expect(capturedProps).toHaveLength(1);
+ expect(capturedProps[0].displayFormat).toBeUndefined();
+});
diff --git a/superset-frontend/src/filters/components/Time/TimeFilterPlugin.tsx b/superset-frontend/src/filters/components/Time/TimeFilterPlugin.tsx
index f2f88f67621..44179cb11b2 100644
--- a/superset-frontend/src/filters/components/Time/TimeFilterPlugin.tsx
+++ b/superset-frontend/src/filters/components/Time/TimeFilterPlugin.tsx
@@ -131,6 +131,7 @@ export default function TimeFilterPlugin(props: PluginFilterTimeProps) {
unsetFocusedFilter();
}}
isOverflowingFilterBar={isOverflowingFilterBar}
+ displayFormat={props.formData.displayFormat}
/>
</ControlContainer>
</TimeFilterStyles>
diff --git a/superset-frontend/src/filters/components/Time/controlPanel.ts b/superset-frontend/src/filters/components/Time/controlPanel.ts
index ceec77c2c3a..bbac572015a 100644
--- a/superset-frontend/src/filters/components/Time/controlPanel.ts
+++ b/superset-frontend/src/filters/components/Time/controlPanel.ts
@@ -19,6 +19,8 @@
import {
ControlPanelConfig,
sharedControls,
+ D3_TIME_FORMAT_DOCS,
+ D3_TIME_FORMAT_OPTIONS,
} from '@superset-ui/chart-controls';
import { t } from '@apache-superset/core/translation';
@@ -58,6 +60,21 @@ const config: ControlPanelConfig = {
},
},
],
+ [
+ {
+ name: 'displayFormat',
+ config: {
+ type: 'SelectControl',
+ freeForm: true,
+ label: () => t('Display format'),
+ default: null,
+ clearable: true,
+ renderTrigger: true,
+ choices: D3_TIME_FORMAT_OPTIONS,
+ description: D3_TIME_FORMAT_DOCS,
+ },
+ },
+ ],
],
},
],
diff --git a/superset-frontend/src/filters/components/Time/types.ts b/superset-frontend/src/filters/components/Time/types.ts
index 39ee425bb0f..3b06cc4029d 100644
--- a/superset-frontend/src/filters/components/Time/types.ts
+++ b/superset-frontend/src/filters/components/Time/types.ts
@@ -27,6 +27,7 @@ import { PluginFilterHooks, PluginFilterStylesProps } from '../types';
interface PluginFilterTimeCustomizeProps {
defaultValue?: string | null;
+ displayFormat?: string;
}
export type PluginFilterSelectQueryFormData = QueryFormData &