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>
+                                    &nbsp;
+                                    <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 &