Skip to content
Merged
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
2 changes: 0 additions & 2 deletions airflow-core/src/airflow/ui/src/constants/localStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,6 @@ export const TIMEZONE_KEY = "timezone";
export const DEFAULT_DAG_VIEW_KEY = "default_dag_view";
export const DAGS_LIST_DISPLAY_KEY = "dags_list_display";
export const DAGS_LIST_SHOW_FOLDERS_KEY = "dags_list_show_folders";
export const CALENDAR_GRANULARITY_KEY = "calendar-granularity";
export const CALENDAR_VIEW_MODE_KEY = "calendar-view-mode";
export const LOG_WRAP_KEY = "log_wrap";
export const LOG_SHOW_TIMESTAMP_KEY = "log_show_timestamp";
export const LOG_SHOW_SOURCE_KEY = "log_show_source";
Expand Down
3 changes: 3 additions & 0 deletions airflow-core/src/airflow/ui/src/constants/searchParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ export enum SearchParamsKeys {
BEFORE = "before",
BODY_SEARCH = "body_search",
BUNDLE_VERSION = "bundle_version",
CALENDAR_DATE = "calendar-date",
CALENDAR_GRANULARITY = "calendar-granularity",
CALENDAR_VIEW_MODE = "calendar-view-mode",
COMPLETED_AT_GTE = "completed_at_gte",
COMPLETED_AT_LTE = "completed_at_lte",
COMPLETED_AT_RANGE = "completed_at_range",
Expand Down
153 changes: 153 additions & 0 deletions airflow-core/src/airflow/ui/src/pages/Dag/Calendar/Calendar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/*!
* 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 "@testing-library/jest-dom";
import { fireEvent, render, screen } from "@testing-library/react";
import dayjs from "dayjs";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";

import { TimezoneProvider } from "src/context/timezone";
import { BaseWrapper } from "src/utils/Wrapper";

import { Calendar } from "./Calendar";

const mocks = vi.hoisted(() => ({
getCalendar: vi.fn(),
getDagDetails: vi.fn(),
getDeadlines: vi.fn(),
}));

vi.mock("openapi/queries", () => ({
useCalendarServiceGetCalendar: mocks.getCalendar,
useCalendarServiceGetCalendarDeadlines: mocks.getDeadlines,
useDagServiceGetDagDetails: mocks.getDagDetails,
}));

// Return translation keys as-is so the tests can look elements up by key.
vi.mock("react-i18next", () => ({
useTranslation: () => ({
i18n: { language: "en" },
// eslint-disable-next-line id-length
t: (key: string) => key,
}),
}));

// The grid views are not under test here. Stub them so they only expose the
// viewMode prop, which lets us check that the URL state reaches the children.
vi.mock("./DailyCalendarView", () => ({
DailyCalendarView: ({ viewMode }: { readonly viewMode: string }) => (
<div data-testid="daily-view">{viewMode}</div>
),
}));

vi.mock("./HourlyCalendarView", () => ({
HourlyCalendarView: ({ viewMode }: { readonly viewMode: string }) => (
<div data-testid="hourly-view">{viewMode}</div>
),
}));

vi.mock("./CalendarLegend", () => ({ CalendarLegend: () => undefined }));

// Renders the current query string so tests can assert on URL changes.
const LocationDisplay = () => <span data-testid="search">{useLocation().search}</span>;

const renderCalendar = (search = "") =>
render(
<BaseWrapper>
<MemoryRouter initialEntries={[`/dags/example_dag/calendar${search}`]}>
<TimezoneProvider>
<Routes>
<Route
element={
<>
<Calendar />
<LocationDisplay />
</>
}
path="/dags/:dagId/calendar"
/>
</Routes>
</TimezoneProvider>
</MemoryRouter>
</BaseWrapper>,
);

describe("Calendar URL params", () => {
beforeEach(() => {
mocks.getCalendar.mockReset();
mocks.getDeadlines.mockReset();
mocks.getDagDetails.mockReset();
mocks.getCalendar.mockReturnValue({ data: { dag_runs: [] }, error: undefined, isLoading: false });
mocks.getDeadlines.mockReturnValue({ data: { deadlines: [] } });
mocks.getDagDetails.mockReturnValue({ data: { timetable_partitioned: false } });
});

it("uses the default view (hourly, total runs) when there are no params", () => {
renderCalendar();

expect(screen.getByTestId("hourly-view")).toHaveTextContent("total");
expect(screen.queryByTestId("daily-view")).not.toBeInTheDocument();
});

it("reads granularity, view mode and date from the URL", () => {
renderCalendar("?calendar-granularity=daily&calendar-view-mode=failed&calendar-date=2026-07-26");

expect(screen.getByTestId("daily-view")).toHaveTextContent("failed");
expect(screen.getByTestId("calendar-current-period")).toHaveTextContent("2026");
expect(mocks.getCalendar).toHaveBeenLastCalledWith(
expect.objectContaining({ granularity: "daily" }),
undefined,
expect.anything(),
);
});

it("shows the month from calendar-date in hourly mode", () => {
renderCalendar("?calendar-granularity=hourly&calendar-date=2026-07-26");

expect(screen.getByTestId("hourly-view")).toBeInTheDocument();
expect(screen.getByTestId("calendar-current-period")).toHaveTextContent("Jul 2026");
});

it("falls back to defaults for invalid param values", () => {
renderCalendar("?calendar-granularity=abc&calendar-view-mode=xyz&calendar-date=not-a-date");

expect(screen.getByTestId("hourly-view")).toHaveTextContent("total");
// An invalid date falls back to the current month, not "Invalid Date".
expect(screen.getByTestId("calendar-current-period")).toHaveTextContent(dayjs().format("MMM YYYY"));
});

it("updates the URL when toggling granularity and view mode", () => {
renderCalendar();

fireEvent.click(screen.getByText("calendar.daily"));
expect(screen.getByTestId("search")).toHaveTextContent("calendar-granularity=daily");

fireEvent.click(screen.getByText("overview.buttons.failedRun_other"));
expect(screen.getByTestId("search")).toHaveTextContent("calendar-view-mode=failed");
// Changing one param must keep the others.
expect(screen.getByTestId("search")).toHaveTextContent("calendar-granularity=daily");
});

it("updates calendar-date when navigating to the next year", () => {
renderCalendar("?calendar-granularity=daily&calendar-date=2026-07-26");

fireEvent.click(screen.getByRole("button", { name: "calendar.navigation.nextYear" }));
expect(screen.getByTestId("search")).toHaveTextContent("calendar-date=2027-07-26");
});
});
53 changes: 42 additions & 11 deletions airflow-core/src/airflow/ui/src/pages/Dag/Calendar/Calendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,14 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useState } from "react";

import { Box, HStack, Text } from "@chakra-ui/react";
import { keyframes } from "@emotion/react";
import dayjs from "dayjs";
import tz from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { useTranslation } from "react-i18next";
import { FiChevronLeft, FiChevronRight } from "react-icons/fi";
import { useParams } from "react-router-dom";
import { useLocalStorage } from "usehooks-ts";
import { useParams, useSearchParams } from "react-router-dom";

import {
useCalendarServiceGetCalendar,
Expand All @@ -38,7 +35,7 @@ import { IconButton, ButtonGroupToggle } from "src/system-components";

import { ErrorAlert } from "src/components/ErrorAlert";

import { CALENDAR_GRANULARITY_KEY, CALENDAR_VIEW_MODE_KEY } from "src/constants/localStorage";
import { SearchParamsKeys } from "src/constants/searchParams";
import { useTimezone } from "src/context/timezone";

import { CalendarLegend } from "./CalendarLegend";
Expand All @@ -57,12 +54,46 @@ dayjs.extend(tz);
export const Calendar = () => {
const { dagId = "" } = useParams();
const { t: translate } = useTranslation("dag");
const [selectedDate, setSelectedDate] = useState(dayjs());
const [granularity, setGranularity] = useLocalStorage<"daily" | "hourly">(
CALENDAR_GRANULARITY_KEY,
"hourly",
);
const [viewMode, setViewMode] = useLocalStorage<"failed" | "total">(CALENDAR_VIEW_MODE_KEY, "total");

const [searchParams, setSearchParams] = useSearchParams();

const granularity: "daily" | "hourly" =
searchParams.get(SearchParamsKeys.CALENDAR_GRANULARITY) === "daily" ? "daily" : "hourly";
const viewMode: "failed" | "total" =
searchParams.get(SearchParamsKeys.CALENDAR_VIEW_MODE) === "failed" ? "failed" : "total";

const dateParam = searchParams.get(SearchParamsKeys.CALENDAR_DATE);
const selectedDate = dateParam !== null && dayjs(dateParam).isValid() ? dayjs(dateParam) : dayjs();

const setGranularity = (value: "daily" | "hourly") => {
setSearchParams((previous) => {
const next = new URLSearchParams(previous);

next.set(SearchParamsKeys.CALENDAR_GRANULARITY, value);

return next;
});
};

const setViewMode = (value: "failed" | "total") => {
setSearchParams((previous) => {
const next = new URLSearchParams(previous);

next.set(SearchParamsKeys.CALENDAR_VIEW_MODE, value);

return next;
});
};

const setSelectedDate = (value: dayjs.Dayjs) => {
setSearchParams((previous) => {
const next = new URLSearchParams(previous);

next.set(SearchParamsKeys.CALENDAR_DATE, value.format("YYYY-MM-DD"));

return next;
});
};

const currentDate = dayjs();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,9 @@ export class DagCalendarTab extends BasePage {

public async switchToFailedView(): Promise<void> {
await this.failedToggle.click();

await expect(this.page).toHaveURL(/calendar-view-mode=failed/u);
await expect(this.activeCells.first()).toHaveAttribute("data-view-mode", "failed");
}

public async switchToHourly(): Promise<void> {
Expand All @@ -127,6 +130,8 @@ export class DagCalendarTab extends BasePage {

public async switchToTotalView(): Promise<void> {
await this.totalToggle.click();

await expect(this.page).toHaveURL(/calendar-view-mode=total/u);
}

private async waitForCalendarReady(): Promise<void> {
Expand Down
Loading