This is an automated email from the ASF dual-hosted git repository.
bbovenzi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 9ef2b27b931 Fix datetime pickers unusable on Firefox and Safari
(#71627)
9ef2b27b931 is described below
commit 9ef2b27b931120852d809b72420245fdb665e90a
Author: Pierre Jeambrun <[email protected]>
AuthorDate: Tue Aug 18 20:09:45 2026 +0200
Fix datetime pickers unusable on Firefox and Safari (#71627)
Native `datetime-local` inputs report no value until both a date and a time
are entered, and Firefox and Safari do not auto-fill the time when a date is
picked from their calendar. Picking a date without a time therefore left the
field empty, which disabled the backfill form (and every other datetime
field). Rebuild the picker on text date and time inputs — the same building
blocks the date-range filter already uses — so a date-only entry defaults
the
time and produces a usable value consistently across browsers.
---
.../airflow/ui/public/i18n/locales/en/common.json | 5 +-
.../src/components/DagActions/RunBackfillForm.tsx | 5 +-
.../ui/src/components/DateTimeInput.test.tsx | 106 ++++-----
.../airflow/ui/src/components/DateTimeInput.tsx | 247 +++++++++++++++------
.../src/components/FilterBar/filters/DateInput.tsx | 5 +-
.../src/airflow/ui/src/hooks/useDateRangeFilter.ts | 28 ++-
.../src/airflow/ui/tests/e2e/pages/BackfillPage.ts | 48 ++--
.../airflow/ui/tests/e2e/specs/backfill.spec.ts | 17 +-
8 files changed, 304 insertions(+), 157 deletions(-)
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
index 4648ecd4eac..ef5fb8836cd 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
@@ -138,6 +138,7 @@
},
"filter": "Filter",
"filters": {
+ "date": "Date",
"durationFrom": "Duration From",
"durationTo": "Duration To",
"endTime": "End Time",
@@ -147,7 +148,9 @@
"runAfterTo": "Run After To",
"searchAsset": "Search Asset",
"selectDateRange": "Select Date Range",
- "startTime": "Start Time"
+ "selectDateTime": "Select date and time",
+ "startTime": "Start Time",
+ "time": "Time"
},
"fullscreen": {
"tooltip": "Press {{hotkey}} for fullscreen"
diff --git
a/airflow-core/src/airflow/ui/src/components/DagActions/RunBackfillForm.tsx
b/airflow-core/src/airflow/ui/src/components/DagActions/RunBackfillForm.tsx
index 11a5b8bd3cb..de857da0f94 100644
--- a/airflow-core/src/airflow/ui/src/components/DagActions/RunBackfillForm.tsx
+++ b/airflow-core/src/airflow/ui/src/components/DagActions/RunBackfillForm.tsx
@@ -46,7 +46,6 @@ type RunBackfillFormProps = {
readonly onClose: () => void;
};
type BackfillFormProps = DagRunTriggerParams & Omit<BackfillPostBody,
"dag_run_conf">;
-const today = new Date().toISOString().slice(0, 16);
const RunBackfillForm = ({ dag, onClose }: RunBackfillFormProps) => {
const { t: translate } = useTranslation(["components", "common"]);
@@ -167,7 +166,7 @@ const RunBackfillForm = ({ dag, onClose }:
RunBackfillFormProps) => {
render={({ field }) => (
<Field.Root invalid={Boolean(errors.date) ||
dataIntervalInvalid} required>
<Field.Label>{translate("common:table.from")}</Field.Label>
- <DateTimeInput {...field} max={today}
onBlur={resetDateError} size="sm" />
+ <DateTimeInput {...field} onBlur={resetDateError} />
<Field.ErrorText>{translate("backfill.errorStartDateBeforeEndDate")}</Field.ErrorText>
</Field.Root>
)}
@@ -178,7 +177,7 @@ const RunBackfillForm = ({ dag, onClose }:
RunBackfillFormProps) => {
render={({ field }) => (
<Field.Root invalid={Boolean(errors.date) ||
dataIntervalInvalid} required>
<Field.Label>{translate("common:table.to")}</Field.Label>
- <DateTimeInput {...field} max={today}
onBlur={resetDateError} size="sm" />
+ <DateTimeInput {...field} endOfDay onBlur={resetDateError} />
</Field.Root>
)}
/>
diff --git a/airflow-core/src/airflow/ui/src/components/DateTimeInput.test.tsx
b/airflow-core/src/airflow/ui/src/components/DateTimeInput.test.tsx
index a38589b178b..c7c3aefb165 100644
--- a/airflow-core/src/airflow/ui/src/components/DateTimeInput.test.tsx
+++ b/airflow-core/src/airflow/ui/src/components/DateTimeInput.test.tsx
@@ -34,96 +34,88 @@ dayjs.extend(timezone);
type ChangeHandler = (event: ChangeEvent<HTMLInputElement>) => void;
-const renderWithTimezone = (selectedTimezone: string) => {
+// The date + time inputs live inside a popover, so the trigger has to be
opened before they exist.
+const openPicker = async (selectedTimezone: string, props?: { endOfDay?:
boolean; value?: string }) => {
const onChange: Mock<ChangeHandler> = vi.fn();
render(
<TimezoneContext.Provider value={{ selectedTimezone, setSelectedTimezone:
vi.fn() }}>
- <DateTimeInput onChange={onChange} value="" />
+ <DateTimeInput onChange={onChange} value="" {...props} />
</TimezoneContext.Provider>,
{ wrapper: Wrapper },
);
- return { input: screen.getByTestId<HTMLInputElement>("datetime-input"),
onChange };
-};
+ fireEvent.click(screen.getByTestId("datetime-input"));
-const paste = (input: HTMLInputElement, text: string) =>
- fireEvent.paste(input, { clipboardData: { getData: () => text } });
+ return {
+ dateInput: await
screen.findByPlaceholderText<HTMLInputElement>("YYYY/MM/DD"),
+ onChange,
+ timeInput: screen.getByPlaceholderText<HTMLInputElement>("HH:mm"),
+ };
+};
const lastEmittedValue = (onChange: Mock<ChangeHandler>): string | undefined =>
onChange.mock.calls.at(-1)?.[0].target.value;
-describe("DateTimeInput onPaste timezone handling", () => {
- it("renders pasted UTC instant in the selected UTC timezone", () => {
- const { input, onChange } = renderWithTimezone("UTC");
+const type = (input: HTMLInputElement, value: string) =>
fireEvent.change(input, { target: { value } });
- paste(input, "2026-01-15T10:30:00Z");
+describe("DateTimeInput", () => {
+ it("emits the start of the day when only a date is entered", async () => {
+ const { dateInput, onChange } = await openPicker("UTC");
- expect(input.value).toBe("2026-01-15T10:30");
- expect(lastEmittedValue(onChange)).toBe("2026-01-15T10:30:00.000Z");
+ type(dateInput, "2026/01/15");
+
+ expect(lastEmittedValue(onChange)).toBe("2026-01-15T00:00:00.000Z");
});
- it("converts pasted UTC instant into a non-UTC selected timezone", () => {
- const { input, onChange } = renderWithTimezone("Asia/Seoul");
+ it("emits the end of the day when only a date is entered and endOfDay is
set", async () => {
+ const { dateInput, onChange } = await openPicker("UTC", { endOfDay: true
});
- paste(input, "2026-01-15T10:30:00Z");
+ type(dateInput, "2026/01/15");
- // 10:30 UTC == 19:30 Asia/Seoul (+09:00)
- expect(input.value).toBe("2026-01-15T19:30");
- expect(lastEmittedValue(onChange)).toBe("2026-01-15T10:30:00.000Z");
+ expect(lastEmittedValue(onChange)).toBe("2026-01-15T23:59:59.999Z");
});
- it("converts pasted offset value into the selected timezone", () => {
- const { input, onChange } = renderWithTimezone("UTC");
+ it("combines the entered date and time", async () => {
+ const { dateInput, onChange, timeInput } = await openPicker("UTC");
- paste(input, "2026-01-15T10:30:00+09:00");
+ type(dateInput, "2026/01/15");
+ type(timeInput, "10:30");
- // 10:30 in +09:00 == 01:30 UTC
- expect(input.value).toBe("2026-01-15T01:30");
- expect(lastEmittedValue(onChange)).toBe("2026-01-15T01:30:00.000Z");
+ expect(lastEmittedValue(onChange)).toBe("2026-01-15T10:30:00.000Z");
});
- it("treats a bare datetime as being in the selected timezone", () => {
- const { input, onChange } = renderWithTimezone("Asia/Seoul");
+ it("interprets the entered wall-clock time in the selected timezone", async
() => {
+ const { dateInput, onChange, timeInput } = await openPicker("Asia/Seoul");
- paste(input, "2026-01-15T10:30");
+ type(dateInput, "2026/01/15");
+ type(timeInput, "10:30");
- // bare 10:30 interpreted as Asia/Seoul (+09:00) == 01:30 UTC
- expect(input.value).toBe("2026-01-15T10:30");
+ // 10:30 in Asia/Seoul (+09:00) == 01:30 UTC
expect(lastEmittedValue(onChange)).toBe("2026-01-15T01:30:00.000Z");
});
- it("ignores invalid pasted strings", () => {
- const { input, onChange } = renderWithTimezone("UTC");
+ it("splits an incoming value into the date and time fields in the selected
timezone", async () => {
+ const { dateInput, timeInput } = await openPicker("Asia/Seoul", { value:
"2026-01-15T10:30:00Z" });
- paste(input, "not a date");
+ // 10:30 UTC == 19:30 Asia/Seoul
+ expect(dateInput.value).toBe("2026/01/15");
+ expect(timeInput.value).toBe("19:30");
+ });
- expect(input.value).toBe("");
- expect(onChange).not.toHaveBeenCalled();
+ it("emits an empty value when the date is cleared", async () => {
+ const { dateInput, onChange } = await openPicker("UTC", { value:
"2026-01-15T10:30:00Z" });
+
+ type(dateInput, "");
+
+ expect(lastEmittedValue(onChange)).toBe("");
});
- it("does not fire a pending debounced typing call after a paste", () => {
- vi.useFakeTimers();
- try {
- const { input, onChange } = renderWithTimezone("UTC");
-
- // 1. User types — schedules debouncedOnDateChange (1s delay).
- fireEvent.change(input, { target: { value: "2026-01-15T05:00" } });
- expect(onChange).not.toHaveBeenCalled();
-
- // 2. Within the debounce window, user pastes — fires onChange
immediately.
- vi.advanceTimersByTime(300);
- paste(input, "2026-12-31T23:59:00Z");
- expect(onChange).toHaveBeenCalledTimes(1);
-
- // 3. Advance past the debounce delay. The pending typed call must NOT
fire,
- // otherwise the parent form gets a redundant onChange (and any side
effects
- // attached to it run twice).
- vi.advanceTimersByTime(2000);
- expect(onChange).toHaveBeenCalledTimes(1);
- expect(lastEmittedValue(onChange)).toBe("2026-12-31T23:59:00.000Z");
- } finally {
- vi.useRealTimers();
- }
+ it("does not emit while the date is an invalid format", async () => {
+ const { dateInput, onChange } = await openPicker("UTC");
+
+ type(dateInput, "2026/99/99");
+
+ expect(onChange).not.toHaveBeenCalled();
});
});
diff --git a/airflow-core/src/airflow/ui/src/components/DateTimeInput.tsx
b/airflow-core/src/airflow/ui/src/components/DateTimeInput.tsx
index e5b0cf90d6e..291f38568be 100644
--- a/airflow-core/src/airflow/ui/src/components/DateTimeInput.tsx
+++ b/airflow-core/src/airflow/ui/src/components/DateTimeInput.tsx
@@ -16,87 +16,194 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Input, type InputProps } from "@chakra-ui/react";
-import dayjs from "dayjs";
-import tz from "dayjs/plugin/timezone";
-import { forwardRef, type ChangeEvent, type ClipboardEvent, useState } from
"react";
-import { useDebouncedCallback } from "use-debounce";
+import { Box, HStack, Text, VStack, type InputProps } from "@chakra-ui/react";
+import dayjs, { type Dayjs } from "dayjs";
+import timezone from "dayjs/plugin/timezone";
+import { forwardRef, useEffect, useState, type ChangeEvent, type
HTMLAttributes } from "react";
+import { useTranslation } from "react-i18next";
+import { MdAccessTime, MdCalendarToday } from "react-icons/md";
+import { DateInput } from "src/components/FilterBar/filters/DateInput";
+import { DateRangeCalendar } from
"src/components/FilterBar/filters/DateRangeCalendar";
+import { isValidDateValue } from "src/components/FilterBar/utils";
+import { Popover } from "src/components/ui";
import { useTimezone } from "src/context/timezone";
-import { DEFAULT_DATETIME_FORMAT } from "src/utils/datetimeUtils";
+import type { ValidationError } from "src/hooks/useDateRangeFilter";
+import {
+ combineDateAndTime,
+ DATE_INPUT_FORMAT,
+ TIME_INPUT_FORMAT,
+ validateDateInput,
+ validateTimeInput,
+} from "src/hooks/useDateRangeFilter";
-dayjs.extend(tz);
+dayjs.extend(timezone);
-const debounceDelay = 1000;
+// A single datetime picker built exactly like the date-range filter: a
trigger showing the selected
+// value opens a popover with the selected timezone, a text date input, a text
time input, and a
+// calendar. Text inputs (not a native `datetime-local`) keep it consistent
with the range picker and
+// avoid the Firefox/Safari problem where picking a date without a time yields
no value (#54429).
+type Props = {
+ // Default the time to the end of the day (instead of the start) when only a
date is entered — used
+ // for a range's upper bound so it stays inclusive.
+ readonly endOfDay?: boolean;
+ readonly value: string;
+} & Omit<InputProps, "onBlur" | "onFocus" | "onKeyDown"> &
+ Pick<HTMLAttributes<HTMLDivElement>, "onBlur" | "onFocus" | "onKeyDown">;
+
+const DISPLAY_FORMAT = "MMM DD, YYYY HH:mm";
-// Strings with an explicit timezone (`Z` or `+09:00`) are parsed as their
-// absolute instant. Strings without one are treated as being in the selected
-// Airflow UI timezone — consistent between manual input and paste.
-const parseInput = (raw: string, timezone: string) => {
- const hasExplicitTz = /(?:[Zz]|[+-]\d{2}:?\d{2})$/u.test(raw);
- const parsed = hasExplicitTz ? dayjs(raw) : dayjs.tz(raw, timezone);
+const splitValue = (value: string, tz: string) => {
+ const parsed = isValidDateValue(value) ? dayjs(value).tz(tz) : undefined;
- return parsed.isValid() ? parsed : undefined;
+ return {
+ date: parsed?.format(DATE_INPUT_FORMAT) ?? "",
+ time: parsed?.format(TIME_INPUT_FORMAT) ?? "",
+ };
};
-type Props = {
- readonly value: string;
-} & InputProps;
+export const DateTimeInput = forwardRef<HTMLDivElement, Props>(
+ ({ disabled, endOfDay = false, onBlur, onChange, onFocus, onKeyDown, value
}, ref) => {
+ const { t: translate } = useTranslation(["components", "common"]);
+ const { selectedTimezone } = useTimezone();
+ const selected = isValidDateValue(value) ?
dayjs(value).tz(selectedTimezone) : undefined;
-export const DateTimeInput = forwardRef<HTMLInputElement, Props>(({ onChange,
value, ...rest }, ref) => {
- const { selectedTimezone } = useTimezone();
- const [displayDate, setDisplayDate] = useState(value);
+ const [inputs, setInputs] = useState(() => splitValue(value,
selectedTimezone));
+ const [currentMonth, setCurrentMonth] = useState<Dayjs>(() => selected ??
dayjs());
- const emit = (event: ChangeEvent<HTMLInputElement> |
ClipboardEvent<HTMLInputElement>, utc: string) => {
- onChange?.({
- ...event,
- target: { ...event.currentTarget, value: utc },
- });
- };
+ // Reflect external value changes (form reset, calendar/input edits)
without clobbering
+ // in-progress typing: an incomplete date never emits, so `value` stays
put and this leaves it be.
+ useEffect(() => {
+ setInputs(splitValue(value, selectedTimezone));
+ }, [value, selectedTimezone]);
- const onDateChange = (event: ChangeEvent<HTMLInputElement>) => {
- const parsed = parseInput(event.target.value, selectedTimezone);
+ const emit = (emitted: string) => {
+ onChange?.({ target: { value: emitted } } as
ChangeEvent<HTMLInputElement>);
+ };
- // Set display value via UTC -> local to avoid year mismatch for years
- // before 1000 (dayjs/issues/1237).
- setDisplayDate(parsed ?
parsed.tz(selectedTimezone).format(DEFAULT_DATETIME_FORMAT) : "");
- emit(event, parsed ? parsed.toISOString() : "");
- };
+ const commit = (next: { date: string; time: string }) => {
+ if (next.date === "") {
+ emit("");
- const debouncedOnDateChange = useDebouncedCallback(
- (event: ChangeEvent<HTMLInputElement>) => onDateChange(event),
- debounceDelay,
- );
-
- const onPaste = (event: ClipboardEvent<HTMLInputElement>) => {
- const parsed = parseInput(event.clipboardData.getData("text").trim(),
selectedTimezone);
-
- if (!parsed) {
- return;
- }
-
- event.preventDefault();
- // Drop any debounced call queued by prior typing so it cannot fire after
- // this paste and trigger a redundant onChange on the parent form.
- debouncedOnDateChange.cancel();
- // datetime-local input requires YYYY-MM-DDTHH:mm format in the selected
- // Airflow UI timezone (not the browser's local timezone).
- setDisplayDate(parsed.tz(selectedTimezone).format("YYYY-MM-DDTHH:mm"));
- emit(event, parsed.toISOString());
- };
+ return;
+ }
+ if (validateDateInput(next.date) && (next.time === "" ||
validateTimeInput(next.time))) {
+ const combined = combineDateAndTime(next.date, next.time, { endOfDay,
timezone: selectedTimezone });
+
+ if (combined !== "") {
+ emit(combined);
+ }
+ }
+ };
+
+ const applyChange = (inputType: "date" | "time", nextValue: string) => {
+ const next = inputType === "date" ? { ...inputs, date: nextValue } : {
...inputs, time: nextValue };
+
+ setInputs(next);
+ commit(next);
+ };
+
+ const handleDateClick = (day: Dayjs) => {
+ const next = { ...inputs, date: day.format(DATE_INPUT_FORMAT) };
+
+ setInputs(next);
+ setCurrentMonth(day);
+ commit(next);
+ };
+
+ const getFieldError = (fieldName: ValidationError["field"]):
ValidationError | undefined => {
+ if (fieldName === "start" && inputs.date !== "" &&
!validateDateInput(inputs.date)) {
+ return { field: "start", message:
translate("dateRangeFilter.validation.invalidDateFormat") };
+ }
+ if (fieldName === "startTime" && inputs.time !== "" &&
!validateTimeInput(inputs.time)) {
+ return { field: "startTime", message:
translate("dateRangeFilter.validation.invalidTimeFormat") };
+ }
+
+ return undefined;
+ };
+
+ const getBorderColor = (fieldName: ValidationError["field"]) =>
+ getFieldError(fieldName) ? "danger.solid" : "border";
+
+ const handleInputChange =
+ (_field: "end" | "start", inputType: "date" | "time") => (event:
ChangeEvent<HTMLInputElement>) =>
+ applyChange(inputType, event.target.value);
+
+ const isoValue = value === "" ? undefined : value;
+ const calendarValue = { endDate: isoValue, startDate: isoValue };
+
+ return (
+ <Popover.Root lazyMount positioning={{ placement: "bottom-start" }}
unmountOnExit>
+ <Popover.Trigger asChild disabled={disabled}>
+ <Box
+ _hover={Boolean(disabled) ? undefined : { borderColor:
"border.emphasized" }}
+ alignItems="center"
+ borderColor="border"
+ borderRadius="md"
+ borderWidth="1px"
+ cursor={Boolean(disabled) ? "not-allowed" : "pointer"}
+ data-testid="datetime-input"
+ display="flex"
+ gap={2}
+ justifyContent="space-between"
+ onBlur={onBlur}
+ onFocus={onFocus}
+ onKeyDown={onKeyDown}
+ opacity={Boolean(disabled) ? 0.5 : 1}
+ px={3}
+ py={2}
+ ref={ref}
+ w="full"
+ >
+ <Text color={selected ? "fg" : "fg.muted"} fontSize="sm" truncate>
+ {selected ? selected.format(DISPLAY_FORMAT) :
translate("common:filters.selectDateTime")}
+ </Text>
+ <MdCalendarToday />
+ </Box>
+ </Popover.Trigger>
+ <Popover.Content p={3} w="320px">
+ <VStack gap={2} w="full">
+ <HStack gap={1} justify="flex-start" w="full">
+ <MdAccessTime size={14} />
+ <Text color="fg.muted" fontSize="xs">
+ {selectedTimezone}
+ </Text>
+ </HStack>
+
+ <HStack alignItems="flex-start" gap={2} w="full">
+ <DateInput
+ field="start"
+ getBorderColor={getBorderColor}
+ getFieldError={getFieldError}
+ handleInputChange={handleInputChange}
+ inputType="date"
+ inputValue={inputs.date}
+ label={translate("common:filters.date")}
+ onClear={() => applyChange("date", "")}
+ placeholder={DATE_INPUT_FORMAT}
+ />
+ <DateInput
+ field="start"
+ getBorderColor={getBorderColor}
+ getFieldError={getFieldError}
+ handleInputChange={handleInputChange}
+ inputType="time"
+ inputValue={inputs.time}
+ label={translate("common:filters.time")}
+ onClear={() => applyChange("time", "")}
+ placeholder={TIME_INPUT_FORMAT}
+ />
+ </HStack>
- return (
- <Input
- data-testid="datetime-input"
- onChange={(event) => {
- setDisplayDate(dayjs(event.target.value).isValid() ?
event.target.value : "");
- debouncedOnDateChange(event);
- }}
- onPaste={onPaste}
- ref={ref}
- type="datetime-local"
- value={displayDate}
- {...rest}
- />
- );
-});
+ <DateRangeCalendar
+ currentMonth={currentMonth}
+ onDateClick={handleDateClick}
+ onMonthChange={setCurrentMonth}
+ value={calendarValue}
+ />
+ </VStack>
+ </Popover.Content>
+ </Popover.Root>
+ );
+ },
+);
diff --git
a/airflow-core/src/airflow/ui/src/components/FilterBar/filters/DateInput.tsx
b/airflow-core/src/airflow/ui/src/components/FilterBar/filters/DateInput.tsx
index 21e38a1a58b..799191c3f98 100644
--- a/airflow-core/src/airflow/ui/src/components/FilterBar/filters/DateInput.tsx
+++ b/airflow-core/src/airflow/ui/src/components/FilterBar/filters/DateInput.tsx
@@ -24,6 +24,7 @@ import { IconButton } from "src/components/ui";
import type { ValidationError } from "src/hooks/useDateRangeFilter";
type DateInputProps = {
+ readonly disabled?: boolean;
readonly field: "end" | "start";
readonly getBorderColor: (field: ValidationError["field"]) => string;
readonly getFieldError: (field: ValidationError["field"]) => ValidationError
| undefined;
@@ -41,6 +42,7 @@ type DateInputProps = {
};
export const DateInput = ({
+ disabled,
field,
getBorderColor,
getFieldError,
@@ -64,6 +66,7 @@ export const DateInput = ({
<Input
_focus={{ borderColor: "brand.focusRing" }}
borderColor={getBorderColor(fieldName)}
+ disabled={disabled}
fontSize="sm"
fontWeight="medium"
onBlur={onDateBlur}
@@ -73,7 +76,7 @@ export const DateInput = ({
value={inputValue}
w="full"
/>
- {Boolean(inputValue) && (
+ {Boolean(inputValue) && !Boolean(disabled) && (
<IconButton
aria-label={`Clear ${field} ${inputType}`}
onClick={(event) => {
diff --git a/airflow-core/src/airflow/ui/src/hooks/useDateRangeFilter.ts
b/airflow-core/src/airflow/ui/src/hooks/useDateRangeFilter.ts
index b5bfdbb35f9..f0844ec9a2e 100644
--- a/airflow-core/src/airflow/ui/src/hooks/useDateRangeFilter.ts
+++ b/airflow-core/src/airflow/ui/src/hooks/useDateRangeFilter.ts
@@ -56,7 +56,7 @@ type UseDateRangeFilterArgs = {
value: DateRangeValue;
};
-const validateDateInput = (dateStr: string): boolean => {
+export const validateDateInput = (dateStr: string): boolean => {
if (!dateStr.trim()) {
return true; // Empty is valid
}
@@ -72,7 +72,7 @@ const validateDateInput = (dateStr: string): boolean => {
return parsed.format(DATE_INPUT_FORMAT) === dateStr;
};
-const validateTimeInput = (timeStr: string): boolean => {
+export const validateTimeInput = (timeStr: string): boolean => {
if (!timeStr.trim()) {
return true; // Empty is valid
}
@@ -99,22 +99,30 @@ const validateDateRange = (startDate?: string, endDate?:
string): boolean => {
return start.isBefore(end) || start.isSame(end);
};
-const combineDateAndTime = (dateStr: string, timeStr: string, tz: string):
string => {
+export const combineDateAndTime = (
+ dateStr: string,
+ timeStr: string,
+ { endOfDay = false, timezone: tz }: { endOfDay?: boolean; timezone: string },
+): string => {
const date = dayjs(dateStr, DATE_INPUT_FORMAT, true);
if (!date.isValid()) {
return "";
}
+ // When no (or an invalid) time is provided, fall back to the start of the
day — or the end of the
+ // day for a range's upper bound so it stays inclusive.
+ const withDefaultTime = () =>
+ (endOfDay ? date.endOf("day") : date.startOf("day")).tz(tz,
true).toISOString();
+
if (!timeStr.trim()) {
- // If no time is provided, set to 00:00
- return date.startOf("day").tz(tz, true).toISOString();
+ return withDefaultTime();
}
const time = dayjs(`2000-01-01 ${timeStr}`, `YYYY-MM-DD
${TIME_INPUT_FORMAT}`, true);
if (!time.isValid()) {
- return date.startOf("day").tz(tz, true).toISOString();
+ return withDefaultTime();
}
const combined =
date.hour(time.hour()).minute(time.minute()).second(0).millisecond(0);
@@ -176,8 +184,10 @@ export const useDateRangeFilter = ({ onChange, translate,
value }: UseDateRangeF
validateDateInput(inputs.start) &&
validateDateInput(inputs.end)
) {
- const startDateTime = combineDateAndTime(inputs.start, inputs.startTime,
selectedTimezone);
- const endDateTime = combineDateAndTime(inputs.end, inputs.endTime,
selectedTimezone);
+ const startDateTime = combineDateAndTime(inputs.start, inputs.startTime,
{
+ timezone: selectedTimezone,
+ });
+ const endDateTime = combineDateAndTime(inputs.end, inputs.endTime, {
timezone: selectedTimezone });
if (Boolean(startDateTime) && Boolean(endDateTime) &&
!validateDateRange(startDateTime, endDateTime)) {
errors.push({
@@ -264,7 +274,7 @@ export const useDateRangeFilter = ({ onChange, translate,
value }: UseDateRangeF
const timeStr = field === "start" ? newInputs.startTime :
newInputs.endTime;
if (dayjs(dateStr, DATE_INPUT_FORMAT, true).isValid()) {
- const combinedDateTime = combineDateAndTime(dateStr, timeStr,
selectedTimezone);
+ const combinedDateTime = combineDateAndTime(dateStr, timeStr, {
timezone: selectedTimezone });
if (Boolean(combinedDateTime)) {
onChange({
diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/BackfillPage.ts
b/airflow-core/src/airflow/ui/tests/e2e/pages/BackfillPage.ts
index e8a89812797..99ff3119be0 100644
--- a/airflow-core/src/airflow/ui/tests/e2e/pages/BackfillPage.ts
+++ b/airflow-core/src/airflow/ui/tests/e2e/pages/BackfillPage.ts
@@ -78,11 +78,11 @@ function getColumnIndex(columnMap: Map<string, number>,
name: string): number {
export class BackfillPage extends BasePage {
public readonly backfillDateError: Locator;
- public readonly backfillFromDateInput: Locator;
+ public readonly backfillFromTrigger: Locator;
public readonly backfillModeRadio: Locator;
public readonly backfillRunButton: Locator;
public readonly backfillsTable: Locator;
- public readonly backfillToDateInput: Locator;
+ public readonly backfillToTrigger: Locator;
public readonly cancelButton: Locator;
public readonly pauseButton: Locator;
public readonly triggerButton: Locator;
@@ -97,8 +97,10 @@ export class BackfillPage extends BasePage {
this.triggerButton = page.getByTestId("trigger-dag-button");
// Chakra UI radio cards: target the label directly since <input> is
hidden.
this.backfillModeRadio = page.locator("label").getByText("Backfill", {
exact: true });
- this.backfillFromDateInput = page.getByTestId("datetime-input").first();
- this.backfillToDateInput = page.getByTestId("datetime-input").nth(1);
+ // Each date-range bound is a trigger that opens a popover with a date
(YYYY/MM/DD) and a time
+ // (HH:mm) text input, matching the date-range filter (see #54429).
+ this.backfillFromTrigger = page.getByTestId("datetime-input").first();
+ this.backfillToTrigger = page.getByTestId("datetime-input").nth(1);
this.backfillRunButton = page.getByRole("button", { name: "Run Backfill"
});
this.backfillsTable = page.getByTestId("table-list");
this.backfillDateError = page.getByText("Start Date must be before the End
Date");
@@ -132,19 +134,16 @@ export class BackfillPage extends BasePage {
public async createBackfill(dagName: string, options:
CreateBackfillOptions): Promise<number> {
const { fromDate, reprocessBehavior = "none", toDate } = options;
- const uiFromDate = fromDate.slice(0, 16);
- const uiToDate = toDate.slice(0, 16);
+ const fromDateStr = fromDate.slice(0, 10).replaceAll("-", "/");
+ const fromTimeStr = fromDate.slice(11, 16);
+ const toDateStr = toDate.slice(0, 10).replaceAll("-", "/");
+ const toTimeStr = toDate.slice(11, 16);
await this.navigateToDagDetail(dagName);
await this.openBackfillDialog();
- await this.backfillFromDateInput.click();
- await this.backfillFromDateInput.fill(uiFromDate);
- await this.backfillFromDateInput.press("Tab");
-
- await this.backfillToDateInput.click();
- await this.backfillToDateInput.fill(uiToDate);
- await this.backfillToDateInput.press("Tab");
+ await this.setBound(this.backfillFromTrigger, fromDateStr, fromTimeStr);
+ await this.setBound(this.backfillToTrigger, toDateStr, toTimeStr);
await this.selectReprocessBehavior(reprocessBehavior);
@@ -381,7 +380,7 @@ export class BackfillPage extends BasePage {
public async openBackfillDialog(): Promise<void> {
await this.triggerButton.click({ timeout: 15_000 });
await this.backfillModeRadio.click();
- await expect(this.backfillFromDateInput).toBeVisible();
+ await expect(this.backfillFromTrigger).toBeVisible();
}
public async openFilterMenu(): Promise<void> {
@@ -444,6 +443,27 @@ export class BackfillPage extends BasePage {
.click();
}
+ /** Open a date-range bound's popover, fill its date (and optional time)
text input, then close it. */
+ public async setBound(trigger: Locator, dateStr: string, timeStr?: string):
Promise<void> {
+ const dateInput = this.page.getByPlaceholder("YYYY/MM/DD");
+ const timeInput = this.page.getByPlaceholder("HH:mm");
+
+ await trigger.click();
+ await dateInput.fill(dateStr);
+ if (timeStr !== undefined && timeStr !== "") {
+ await timeInput.fill(timeStr);
+ }
+ // Dismiss the popover so the next bound's inputs are the only ones
matching these placeholders.
+ // Under load webkit can drop a single toggle click, so retry until the
content detaches. (Escape
+ // is avoided on purpose: it would close the whole dialog, not just the
popover.)
+ await expect(async () => {
+ if ((await dateInput.count()) > 0) {
+ await trigger.click();
+ }
+ await expect(dateInput).toHaveCount(0, { timeout: 800 });
+ }).toPass({ intervals: [300, 700, 1500], timeout: 15_000 });
+ }
+
public async toggleColumn(columnName: string): Promise<void> {
await this.page.getByRole("menuitem", { name: columnName }).click();
}
diff --git a/airflow-core/src/airflow/ui/tests/e2e/specs/backfill.spec.ts
b/airflow-core/src/airflow/ui/tests/e2e/specs/backfill.spec.ts
index 4d79d34f74c..6f6d4b5695c 100644
--- a/airflow-core/src/airflow/ui/tests/e2e/specs/backfill.spec.ts
+++ b/airflow-core/src/airflow/ui/tests/e2e/specs/backfill.spec.ts
@@ -148,10 +148,23 @@ test.describe("Backfill", () => {
test("verify date range selection (start date, end date)", async ({
backfillPage }) => {
await backfillPage.navigateToDagDetail(testDagId);
await backfillPage.openBackfillDialog();
- await backfillPage.backfillFromDateInput.fill("2025-01-10T00:00");
- await backfillPage.backfillToDateInput.fill("2025-01-01T00:00");
+ // Date-only entry: the control defaults the time (start of day for
From, end of day for To).
+ // From (Jan 10) after To (Jan 01) must surface the range error.
+ await backfillPage.setBound(backfillPage.backfillFromTrigger,
"2025/01/10");
+ await backfillPage.setBound(backfillPage.backfillToTrigger,
"2025/01/01");
await expect(backfillPage.backfillDateError).toBeVisible();
});
+
+ // Regression for #54429: entering only dates (no time) must yield a valid
range. The range-style
+ // text inputs default the missing time, so the form is usable without a
native datetime-local
+ // picker (which yielded no value on Firefox/Safari when a date was picked
without a time).
+ test("a date-only entry yields a valid range", async ({ backfillPage }) =>
{
+ await backfillPage.navigateToDagDetail(testDagId);
+ await backfillPage.openBackfillDialog();
+ await backfillPage.setBound(backfillPage.backfillFromTrigger,
"2025/01/01");
+ await backfillPage.setBound(backfillPage.backfillToTrigger,
"2025/01/05");
+ await expect(backfillPage.backfillDateError).not.toBeVisible();
+ });
});
test.describe("Backfill pause, resume, and cancel controls", () => {