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> {

Reply via email to