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

Reply via email to