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 c5041604dc2 Allow pasting full datetime strings into date picker
inputs (#66251)
c5041604dc2 is described below
commit c5041604dc28190e9360231e200323ab417bba1d
Author: Jun Yeong Kim <[email protected]>
AuthorDate: Fri May 15 02:39:54 2026 +0900
Allow pasting full datetime strings into date picker inputs (#66251)
* Allow pasting full datetime strings into date picker inputs
* Honor selected UI timezone for pasted datetimes and add tests
* Drop unnecessary ChangeEvent cast on paste handler
* Cancel pending debounced typing call when handling paste
* Share parseInput between typing and paste, drop duplicate setDisplayDate
* Apply prettier and eslint auto-fixes from UI compile-lint hook
---
.../ui/src/components/DateTimeInput.test.tsx | 129 +++++++++++++++++++++
.../airflow/ui/src/components/DateTimeInput.tsx | 55 ++++++---
2 files changed, 171 insertions(+), 13 deletions(-)
diff --git a/airflow-core/src/airflow/ui/src/components/DateTimeInput.test.tsx
b/airflow-core/src/airflow/ui/src/components/DateTimeInput.test.tsx
new file mode 100644
index 00000000000..a38589b178b
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/DateTimeInput.test.tsx
@@ -0,0 +1,129 @@
+/*!
+ * 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 { fireEvent, render, screen } from "@testing-library/react";
+import dayjs from "dayjs";
+import timezone from "dayjs/plugin/timezone";
+import utc from "dayjs/plugin/utc";
+import type { ChangeEvent } from "react";
+import type { Mock } from "vitest";
+import { describe, it, expect, vi } from "vitest";
+
+import { TimezoneContext } from "src/context/timezone";
+import { Wrapper } from "src/utils/Wrapper";
+
+import { DateTimeInput } from "./DateTimeInput";
+
+dayjs.extend(utc);
+dayjs.extend(timezone);
+
+type ChangeHandler = (event: ChangeEvent<HTMLInputElement>) => void;
+
+const renderWithTimezone = (selectedTimezone: string) => {
+ const onChange: Mock<ChangeHandler> = vi.fn();
+
+ render(
+ <TimezoneContext.Provider value={{ selectedTimezone, setSelectedTimezone:
vi.fn() }}>
+ <DateTimeInput onChange={onChange} value="" />
+ </TimezoneContext.Provider>,
+ { wrapper: Wrapper },
+ );
+
+ return { input: screen.getByTestId<HTMLInputElement>("datetime-input"),
onChange };
+};
+
+const paste = (input: HTMLInputElement, text: string) =>
+ fireEvent.paste(input, { clipboardData: { getData: () => text } });
+
+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");
+
+ paste(input, "2026-01-15T10:30:00Z");
+
+ expect(input.value).toBe("2026-01-15T10:30");
+ expect(lastEmittedValue(onChange)).toBe("2026-01-15T10:30:00.000Z");
+ });
+
+ it("converts pasted UTC instant into a non-UTC selected timezone", () => {
+ const { input, onChange } = renderWithTimezone("Asia/Seoul");
+
+ paste(input, "2026-01-15T10:30:00Z");
+
+ // 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");
+ });
+
+ it("converts pasted offset value into the selected timezone", () => {
+ const { input, onChange } = renderWithTimezone("UTC");
+
+ paste(input, "2026-01-15T10:30:00+09:00");
+
+ // 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");
+ });
+
+ it("treats a bare datetime as being in the selected timezone", () => {
+ const { input, onChange } = renderWithTimezone("Asia/Seoul");
+
+ paste(input, "2026-01-15T10:30");
+
+ // bare 10:30 interpreted as Asia/Seoul (+09:00) == 01:30 UTC
+ expect(input.value).toBe("2026-01-15T10:30");
+ expect(lastEmittedValue(onChange)).toBe("2026-01-15T01:30:00.000Z");
+ });
+
+ it("ignores invalid pasted strings", () => {
+ const { input, onChange } = renderWithTimezone("UTC");
+
+ paste(input, "not a date");
+
+ expect(input.value).toBe("");
+ expect(onChange).not.toHaveBeenCalled();
+ });
+
+ 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();
+ }
+ });
+});
diff --git a/airflow-core/src/airflow/ui/src/components/DateTimeInput.tsx
b/airflow-core/src/airflow/ui/src/components/DateTimeInput.tsx
index add6addb8dd..e5b0cf90d6e 100644
--- a/airflow-core/src/airflow/ui/src/components/DateTimeInput.tsx
+++ b/airflow-core/src/airflow/ui/src/components/DateTimeInput.tsx
@@ -19,7 +19,7 @@
import { Input, type InputProps } from "@chakra-ui/react";
import dayjs from "dayjs";
import tz from "dayjs/plugin/timezone";
-import { forwardRef, type ChangeEvent, useState } from "react";
+import { forwardRef, type ChangeEvent, type ClipboardEvent, useState } from
"react";
import { useDebouncedCallback } from "use-debounce";
import { useTimezone } from "src/context/timezone";
@@ -29,6 +29,16 @@ dayjs.extend(tz);
const debounceDelay = 1000;
+// 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);
+
+ return parsed.isValid() ? parsed : undefined;
+};
+
type Props = {
readonly value: string;
} & InputProps;
@@ -37,16 +47,20 @@ export const DateTimeInput = forwardRef<HTMLInputElement,
Props>(({ onChange, va
const { selectedTimezone } = useTimezone();
const [displayDate, setDisplayDate] = useState(value);
+ const emit = (event: ChangeEvent<HTMLInputElement> |
ClipboardEvent<HTMLInputElement>, utc: string) => {
+ onChange?.({
+ ...event,
+ target: { ...event.currentTarget, value: utc },
+ });
+ };
+
const onDateChange = (event: ChangeEvent<HTMLInputElement>) => {
- const valid = dayjs(event.target.value).isValid();
- // UI Timezone -> Utc -> yyyy-mm-ddThh:mmZ
- const utc = valid ? dayjs.tz(event.target.value,
selectedTimezone).toISOString() : "";
- const local = Boolean(utc) ?
dayjs(utc).tz(selectedTimezone).format(DEFAULT_DATETIME_FORMAT) : "";
+ const parsed = parseInput(event.target.value, selectedTimezone);
- // Set display value to be from utc to local to avoid year mismatch
- // As dayjs() parses years before 1000 incorrectly, see dayjs/issues/1237
- setDisplayDate(local);
- onChange?.({ ...event, target: { ...event.target, value: utc } });
+ // 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 debouncedOnDateChange = useDebouncedCallback(
@@ -54,16 +68,31 @@ export const DateTimeInput = forwardRef<HTMLInputElement,
Props>(({ onChange, va
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 (
<Input
data-testid="datetime-input"
onChange={(event) => {
- const local = dayjs(event.target.value).isValid() ? event.target.value
: "";
-
- setDisplayDate(local);
- // Parse input to UTC once user finishes typing
+ setDisplayDate(dayjs(event.target.value).isValid() ?
event.target.value : "");
debouncedOnDateChange(event);
}}
+ onPaste={onPaste}
ref={ref}
type="datetime-local"
value={displayDate}