Skip to content
129 changes: 129 additions & 0 deletions airflow-core/src/airflow/ui/src/components/DateTimeInput.test.tsx
Original file line number Diff line number Diff line change
@@ -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();
}
});
});
55 changes: 42 additions & 13 deletions airflow-core/src/airflow/ui/src/components/DateTimeInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand All @@ -37,33 +47,52 @@ 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(
(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 (
<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
Comment thread
bbovenzi marked this conversation as resolved.
setDisplayDate(dayjs(event.target.value).isValid() ? event.target.value : "");
debouncedOnDateChange(event);
}}
onPaste={onPaste}
ref={ref}
type="datetime-local"
value={displayDate}
Expand Down
Loading