This is an automated email from the ASF dual-hosted git repository.
pierrejeambrun 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 3fa1eb5a27a Add URL param support to calendar view (#73237) (#73762)
3fa1eb5a27a is described below
commit 3fa1eb5a27a3f7bb8ca7dfbc9ae8172d210a6001
Author: Basant_Jamwal <[email protected]>
AuthorDate: Thu Oct 8 20:31:10 2026 +0530
Add URL param support to calendar view (#73237) (#73762)
* Add URL param support to calendar view (#73237)
* Add tests for calendar URL params
* Fix end-of-file and line-ending issues in Calendar.test.tsx
* Fix line endings in Calendar.test.tsx
* Wait for calendar view mode URL update in e2e page object
---
.../src/airflow/ui/src/constants/localStorage.ts | 2 -
.../src/airflow/ui/src/constants/searchParams.ts | 3 +
.../ui/src/pages/Dag/Calendar/Calendar.test.tsx | 153 +++++++++++++++++++++
.../airflow/ui/src/pages/Dag/Calendar/Calendar.tsx | 53 +++++--
.../airflow/ui/tests/e2e/pages/DagCalendarTab.ts | 5 +
5 files changed, 203 insertions(+), 13 deletions(-)
diff --git a/airflow-core/src/airflow/ui/src/constants/localStorage.ts
b/airflow-core/src/airflow/ui/src/constants/localStorage.ts
index ac720b04206..a34d98c6032 100644
--- a/airflow-core/src/airflow/ui/src/constants/localStorage.ts
+++ b/airflow-core/src/airflow/ui/src/constants/localStorage.ts
@@ -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";
diff --git a/airflow-core/src/airflow/ui/src/constants/searchParams.ts
b/airflow-core/src/airflow/ui/src/constants/searchParams.ts
index 44cd47fdb09..2b7dd4ff81c 100644
--- a/airflow-core/src/airflow/ui/src/constants/searchParams.ts
+++ b/airflow-core/src/airflow/ui/src/constants/searchParams.ts
@@ -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",
diff --git
a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/Calendar.test.tsx
b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/Calendar.test.tsx
new file mode 100644
index 00000000000..27a7aee414c
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/Calendar.test.tsx
@@ -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");
+ });
+});
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/Calendar.tsx
b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/Calendar.tsx
index 878465b156d..2bf5d2d7b79 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/Calendar.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/Calendar.tsx
@@ -16,8 +16,6 @@
* 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";
@@ -25,8 +23,7 @@ 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,
@@ -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";
@@ -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();
diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/DagCalendarTab.ts
b/airflow-core/src/airflow/ui/tests/e2e/pages/DagCalendarTab.ts
index 3da576a8421..7971a41025c 100644
--- a/airflow-core/src/airflow/ui/tests/e2e/pages/DagCalendarTab.ts
+++ b/airflow-core/src/airflow/ui/tests/e2e/pages/DagCalendarTab.ts
@@ -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> {
@@ -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> {