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 0000000000000..170e6c64f09a7 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/components/DateTimeInput.test.tsx @@ -0,0 +1,134 @@ +/*! + * 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 { describe, it, expect, vi } from "vitest"; + +import { TimezoneContext } from "src/context/timezone"; +import { Wrapper } from "src/utils/Wrapper"; + +import { DateTimeInput } from "./DateTimeInput"; + +describe("DateTimeInput paste", () => { + it("Pastes a valid datetime and fires onChange with UTC value", () => { + const onChange = vi.fn(); + + render( + + + , + { wrapper: Wrapper }, + ); + + const input = screen.getByTestId("datetime-input"); + const pasteEvent = new ClipboardEvent("paste", { + clipboardData: new DataTransfer(), + }); + Object.defineProperty(pasteEvent, "clipboardData", { + value: { + getData: vi.fn().mockReturnValue("2026-05-13 10:00:00"), + }, + }); + + fireEvent(input, pasteEvent); + + expect(onChange).toHaveBeenCalledTimes(1); + const callArg = onChange.mock.calls[0][0]; + expect(callArg.target.value).toBe("2026-05-13T10:00:00.000Z"); + }); + + it("Interprets timezone-less pasted values in the selected timezone", () => { + const onChange = vi.fn(); + + render( + + + , + { wrapper: Wrapper }, + ); + + const input = screen.getByTestId("datetime-input"); + const pasteEvent = new ClipboardEvent("paste", { + clipboardData: new DataTransfer(), + }); + Object.defineProperty(pasteEvent, "clipboardData", { + value: { + getData: vi.fn().mockReturnValue("2026-05-13 10:00:00"), + }, + }); + + fireEvent(input, pasteEvent); + + expect(onChange).toHaveBeenCalledTimes(1); + const callArg = onChange.mock.calls[0][0]; + // 10:00 in New York (EDT, UTC-4) → 14:00 UTC + expect(callArg.target.value).toBe("2026-05-13T14:00:00.000Z"); + }); + + it("Does not fire onChange for an invalid pasted string", () => { + const onChange = vi.fn(); + + render( + + + , + { wrapper: Wrapper }, + ); + + const input = screen.getByTestId("datetime-input"); + const pasteEvent = new ClipboardEvent("paste", { + clipboardData: new DataTransfer(), + }); + Object.defineProperty(pasteEvent, "clipboardData", { + value: { + getData: vi.fn().mockReturnValue("not a date"), + }, + }); + + fireEvent(input, pasteEvent); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it("Does not fire onChange for an empty pasted string", () => { + const onChange = vi.fn(); + + render( + + + , + { wrapper: Wrapper }, + ); + + const input = screen.getByTestId("datetime-input"); + const pasteEvent = new ClipboardEvent("paste", { + clipboardData: new DataTransfer(), + }); + Object.defineProperty(pasteEvent, "clipboardData", { + value: { + getData: vi.fn().mockReturnValue(""), + }, + }); + + fireEvent(input, pasteEvent); + + 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 add6addb8ddc8..d67c889efe3ad 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"; @@ -54,9 +54,22 @@ export const DateTimeInput = forwardRef(({ onChange, va debounceDelay, ); + const onPaste = (event: ClipboardEvent) => { + const pasted = event.clipboardData.getData("text"); + if (!pasted) return; + const parsed = dayjs.tz(pasted, selectedTimezone); + if (!parsed.isValid()) return; + event.preventDefault(); + const local = parsed.tz(selectedTimezone).format(DEFAULT_DATETIME_FORMAT); + const utc = parsed.toISOString(); + setDisplayDate(local); + onChange?.({ ...event, target: { ...event.target, value: utc } } as unknown as ChangeEvent); + }; + return ( { const local = dayjs(event.target.value).isValid() ? event.target.value : "";