Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 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,100 @@
/*!
* 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 { render, screen, fireEvent } from "@testing-library/react";
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { describe, expect, it, vi } from "vitest";

import { TimezoneContext } from "src/context/timezone";
import { ChakraWrapper } from "src/utils/ChakraWrapper";

import { DateTimeInput } from "./DateTimeInput";

dayjs.extend(utc);
dayjs.extend(timezone);

const renderWithTimezone = (tz: string, value: string, onChange?: ReturnType<typeof vi.fn>) =>
render(
<TimezoneContext.Provider value={{ selectedTimezone: tz, setSelectedTimezone: vi.fn() }}>
<DateTimeInput data-testid="dt-input" onChange={onChange ?? vi.fn()} value={value} />
</TimezoneContext.Provider>,
{ wrapper: ChakraWrapper },
);

describe("DateTimeInput", () => {
it("displays UTC ISO value in the selected timezone using datetime-local T format", () => {
// 15:30 UTC = 10:30 US/Eastern (EST, UTC-5)
renderWithTimezone("US/Eastern", "2026-02-16T15:30:00.000Z");
const input = screen.getByTestId("dt-input") as HTMLInputElement;

// datetime-local inputs should use T separator; value should be in Eastern time
expect(input.value).toBe(dayjs("2026-02-16T15:30:00.000Z").tz("US/Eastern").format("YYYY-MM-DDTHH:mm:ss"));

Check failure on line 48 in airflow-core/src/airflow/ui/src/components/DateTimeInput.test.tsx

View workflow job for this annotation

GitHub Actions / Basic tests / React UI tests

src/components/DateTimeInput.test.tsx > DateTimeInput > displays UTC ISO value in the selected timezone using datetime-local T format

AssertionError: expected '2026-02-16T10:30' to be '2026-02-16T10:30:00' // Object.is equality Expected: "2026-02-16T10:30:00" Received: "2026-02-16T10:30" ❯ src/components/DateTimeInput.test.tsx:48:25
});

it("displays correctly when selectedTimezone is UTC", () => {
renderWithTimezone("UTC", "2026-02-16T15:30:00.000Z");
const input = screen.getByTestId("dt-input") as HTMLInputElement;

expect(input.value).toBe("2026-02-16T15:30:00");

Check failure on line 55 in airflow-core/src/airflow/ui/src/components/DateTimeInput.test.tsx

View workflow job for this annotation

GitHub Actions / Basic tests / React UI tests

src/components/DateTimeInput.test.tsx > DateTimeInput > displays correctly when selectedTimezone is UTC

AssertionError: expected '2026-02-16T15:30' to be '2026-02-16T15:30:00' // Object.is equality Expected: "2026-02-16T15:30:00" Received: "2026-02-16T15:30" ❯ src/components/DateTimeInput.test.tsx:55:25
});

it("converts user-edited value from selected timezone to UTC ISO on change", () => {
const onChange = vi.fn();

renderWithTimezone("US/Eastern", "2026-02-16T15:30:00.000Z", onChange);
const input = screen.getByTestId("dt-input") as HTMLInputElement;

// Simulate user picking 14:00 Eastern via the datetime-local picker
fireEvent.change(input, { target: { value: "2026-02-16T14:00:00" } });

// The onChange should emit the value converted to UTC ISO
const emittedValue = onChange.mock.calls[0]?.[0]?.target?.value as string;

// 14:00 Eastern = 19:00 UTC
expect(emittedValue).toBe(dayjs.tz("2026-02-16T14:00:00", "US/Eastern").toISOString());
});

it("emits empty string for invalid date input", () => {
const onChange = vi.fn();

renderWithTimezone("UTC", "2026-02-16T15:30:00.000Z", onChange);
const input = screen.getByTestId("dt-input") as HTMLInputElement;

fireEvent.change(input, { target: { value: "" } });

const emittedValue = onChange.mock.calls[0]?.[0]?.target?.value as string;

expect(emittedValue).toBe("");
});

it("handles non-UTC timezone roundtrip correctly", () => {
// Verify that displaying then re-submitting the same time is lossless
const utcIso = "2026-06-15T12:00:00.000Z"; // During DST in Eastern (UTC-4)
const tz = "US/Eastern";

// Display: 12:00 UTC = 08:00 EDT
const displayValue = dayjs(utcIso).tz(tz).format("YYYY-MM-DDTHH:mm:ss");

// Re-parse as Eastern → should give back original UTC
const roundtrippedUtc = dayjs.tz(displayValue, tz).toISOString();

expect(roundtrippedUtc).toBe(utcIso);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -22,21 +22,23 @@ import tz from "dayjs/plugin/timezone";
import { forwardRef } from "react";

import { useTimezone } from "src/context/timezone";
import { DEFAULT_DATETIME_FORMAT } from "src/utils/datetimeUtils";

dayjs.extend(tz);

// HTML datetime-local inputs require the ISO "T" separator, not a space.
const DATETIME_LOCAL_FORMAT = "YYYY-MM-DDTHH:mm:ss";

type Props = {
readonly value: string;
} & InputProps;

export const DateTimeInput = forwardRef<HTMLInputElement, Props>(({ onChange, value, ...rest }, ref) => {
const { selectedTimezone } = useTimezone();

// Convert UTC value to local time for display
// Convert UTC value to the selected timezone for display in the native picker
const displayValue =
Boolean(value) && dayjs(value).isValid()
? dayjs(value).tz(selectedTimezone).format(DEFAULT_DATETIME_FORMAT)
? dayjs(value).tz(selectedTimezone).format(DATETIME_LOCAL_FORMAT)
: "";

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import { useDagParams } from "src/queries/useDagParams";
import { useParamStore } from "src/queries/useParamStore";
import { useTogglePause } from "src/queries/useTogglePause";
import { useTrigger } from "src/queries/useTrigger";
import { DEFAULT_DATETIME_FORMAT } from "src/utils/datetimeUtils";

import ConfigForm from "../ConfigForm";
import { DateTimeInput } from "../DateTimeInput";
Expand Down Expand Up @@ -80,8 +79,8 @@ const TriggerDAGForm = ({
dataIntervalEnd: "",
dataIntervalMode: "auto",
dataIntervalStart: "",
// Default logical date to now, show it in the selected timezone
logicalDate: dayjs().format(DEFAULT_DATETIME_FORMAT),
// Default logical date to now as an unambiguous UTC ISO string
logicalDate: dayjs().toISOString(),
note: "",
partitionKey: undefined,
},
Expand All @@ -99,7 +98,7 @@ const TriggerDAGForm = ({
dataIntervalEnd: "",
dataIntervalMode: "auto",
dataIntervalStart: "",
logicalDate: dayjs().format(DEFAULT_DATETIME_FORMAT),
logicalDate: dayjs().toISOString(),
note: "",
partitionKey: undefined,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1977,6 +1977,29 @@ def test_custom_timetable_generate_run_id_for_manual_trigger(self, dag_maker, te
assert run.dag_id == custom_dag_id


@time_machine.travel("2025-10-02 12:00:00", tick=False)
@pytest.mark.usefixtures("configure_git_connection_for_dag_bundle")
def test_non_utc_logical_date_is_normalized_to_utc(self, test_client, session):
"""Verify a logical_date sent with a non-UTC offset is stored and returned as the equivalent UTC."""
# 10:30 in UTC-5 == 15:30 UTC
logical_date_est = "2025-10-02T10:30:00-05:00"
expected_utc = "2025-10-02T15:30:00Z"

response = test_client.post(
f"/dags/{DAG1_ID}/dagRuns",
json={"dag_run_id": "non_utc_run", "logical_date": logical_date_est},
)
assert response.status_code == 200
body = response.json()
assert body["logical_date"] == expected_utc

# Verify the stored value in the database matches
run = session.scalars(
select(DagRun).where(DagRun.dag_id == DAG1_ID, DagRun.run_id == "non_utc_run")
).one()
assert run.logical_date == datetime(2025, 10, 2, 15, 30, 0, tzinfo=timezone.utc)


class TestWaitDagRun:
# The way we init async engine does not work well with FastAPI app init.
# Creating the engine implicitly creates an event loop, which Airflow does
Expand Down
Loading