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}

Reply via email to