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 40d8c505524 UI: Persist dashboard alert collapse state and clamp long
alerts (#68329)
40d8c505524 is described below
commit 40d8c505524ba528593e5c0a4b481e384d3b38a1
Author: Ei Sandi Aung <[email protected]>
AuthorDate: Mon Jun 22 16:57:59 2026 +0100
UI: Persist dashboard alert collapse state and clamp long alerts (#68329)
The alerts accordion was collapsible, but the choice was lost on
every navigation. The collapsed/expanded state is now remembered in
localStorage, and when collapsed a +N more alerts button surfaces how
many alerts are hidden and expands them again.
A single alert no longer renders an accordion trigger at all: the
chevron and +N more alerts button appear with two or more alerts,
so one alert shows as plain, non-collapsible content.
Individual long alerts are also clamped to ~5 lines with a per-alert
See more / See less toggle, so a single verbose alert no longer
dominates the dashboard. The clamp reads the element's rendered
line-height at runtime so it matches the actual text.
Covered by Playwright e2e tests for the persist/expand flow, the
single-alert case, and the per-alert see-more/see-less behaviour.
---
.../ui/public/i18n/locales/en/dashboard.json | 6 +
.../src/airflow/ui/src/constants/localStorage.ts | 1 +
.../ui/src/pages/Dashboard/AlertContent.test.tsx | 130 +++++++++++++
.../ui/src/pages/Dashboard/AlertContent.tsx | 98 ++++++++++
.../ui/src/pages/Dashboard/Dashboard.test.tsx | 204 +++++++++++++++++++++
.../airflow/ui/src/pages/Dashboard/Dashboard.tsx | 60 ++++--
.../src/airflow/ui/tests/e2e/pages/HomePage.ts | 27 +++
.../ui/tests/e2e/specs/home-dashboard.spec.ts | 36 ++++
8 files changed, 549 insertions(+), 13 deletions(-)
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/dashboard.json
b/airflow-core/src/airflow/ui/public/i18n/locales/en/dashboard.json
index 9fe1e0f21bd..1b131ee7f26 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/dashboard.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/dashboard.json
@@ -1,4 +1,10 @@
{
+ "alerts": {
+ "seeLessContext": "See less",
+ "seeMoreContext": "See more",
+ "showMoreAlerts_one": "+{{count}} more alert",
+ "showMoreAlerts_other": "+{{count}} more alerts"
+ },
"deadlines": {
"pending": {
"empty": "No upcoming deadlines",
diff --git a/airflow-core/src/airflow/ui/src/constants/localStorage.ts
b/airflow-core/src/airflow/ui/src/constants/localStorage.ts
index 97285aef6fa..acce227b947 100644
--- a/airflow-core/src/airflow/ui/src/constants/localStorage.ts
+++ b/airflow-core/src/airflow/ui/src/constants/localStorage.ts
@@ -27,6 +27,7 @@ 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";
export const VERSION_INDICATOR_DISPLAY_MODE_KEY =
"version_indicator_display_mode";
+export const COLLAPSED_UI_ALERTS_KEY = "collapsed_ui_alerts";
// Dag-scoped keys
export const dagRunsLimitKey = (dagId: string) => `dag_runs_limit-${dagId}`;
diff --git
a/airflow-core/src/airflow/ui/src/pages/Dashboard/AlertContent.test.tsx
b/airflow-core/src/airflow/ui/src/pages/Dashboard/AlertContent.test.tsx
new file mode 100644
index 00000000000..c31763fcacc
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Dashboard/AlertContent.test.tsx
@@ -0,0 +1,130 @@
+/*!
+ * 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { UIAlert } from "openapi/requests/types.gen";
+import { Wrapper } from "src/utils/Wrapper";
+
+import { AlertContent } from "./AlertContent";
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ // eslint-disable-next-line id-length
+ t: (key: string) =>
+ key === "alerts.seeMoreContext" ? "See more" : key ===
"alerts.seeLessContext" ? "See less" : key,
+ }),
+}));
+
+// happy-dom reports 0 for all layout dimensions, so overflow is driven by
+// stubbing scrollHeight and clientHeight to simulate clamped vs. full height.
+const CLAMPED_HEIGHT = 100;
+const OVERFLOW_HEIGHT = 200;
+
+const stubScrollHeight = (height: number) => {
+ Object.defineProperty(HTMLElement.prototype, "scrollHeight", {
+ configurable: true,
+ get: () => height,
+ });
+};
+
+const stubClientHeight = (height: number) => {
+ Object.defineProperty(HTMLElement.prototype, "clientHeight", {
+ configurable: true,
+ get: () => height,
+ });
+};
+
+const renderAlert = (alert: UIAlert) => render(<AlertContent alert={alert} />,
{ wrapper: Wrapper });
+
+describe("AlertContent", () => {
+ beforeEach(() => {
+ vi.stubGlobal(
+ "ResizeObserver",
+ class MockResizeObserver {
+ // eslint-disable-next-line @typescript-eslint/class-methods-use-this
+ public disconnect() {
+ /* empty */
+ }
+ // eslint-disable-next-line @typescript-eslint/class-methods-use-this
+ public observe() {
+ /* empty */
+ }
+ },
+ );
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ Reflect.deleteProperty(HTMLElement.prototype, "scrollHeight");
+ Reflect.deleteProperty(HTMLElement.prototype, "clientHeight");
+ });
+
+ it("shows no See more toggle when the alert fits within the clamp", () => {
+ stubScrollHeight(CLAMPED_HEIGHT);
+ stubClientHeight(CLAMPED_HEIGHT);
+
+ renderAlert({ category: "info", text: "Short alert text" });
+
+ expect(screen.getByText("Short alert text")).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "See more"
})).not.toBeInTheDocument();
+ });
+
+ it("clamps a tall alert and toggles between See more and See less", () => {
+ stubScrollHeight(OVERFLOW_HEIGHT);
+ stubClientHeight(CLAMPED_HEIGHT);
+
+ renderAlert({ category: "info", text: "Long alert text" });
+
+ const seeMore = screen.getByRole("button", { name: "See more" });
+
+ expect(seeMore).toBeVisible();
+ expect(screen.queryByRole("button", { name: "See less"
})).not.toBeInTheDocument();
+
+ fireEvent.click(seeMore);
+
+ expect(screen.getByRole("button", { name: "See less" })).toBeVisible();
+ expect(screen.queryByRole("button", { name: "See more"
})).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole("button", { name: "See less" }));
+
+ expect(screen.getByRole("button", { name: "See more" })).toBeVisible();
+ });
+
+ it("stops the toggle click from bubbling to a surrounding accordion
trigger", () => {
+ stubScrollHeight(OVERFLOW_HEIGHT);
+ stubClientHeight(CLAMPED_HEIGHT);
+
+ const onParentClick = vi.fn();
+
+ render(
+ // eslint-disable-next-line jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions
+ <div onClick={onParentClick}>
+ <AlertContent alert={{ category: "info", text: "Long alert text" }} />
+ </div>,
+ { wrapper: Wrapper },
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "See more" }));
+
+ expect(screen.getByRole("button", { name: "See less" })).toBeVisible();
+ expect(onParentClick).not.toHaveBeenCalled();
+ });
+});
diff --git a/airflow-core/src/airflow/ui/src/pages/Dashboard/AlertContent.tsx
b/airflow-core/src/airflow/ui/src/pages/Dashboard/AlertContent.tsx
new file mode 100644
index 00000000000..12447850a5c
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Dashboard/AlertContent.tsx
@@ -0,0 +1,98 @@
+/*!
+ * 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 { Box, Button } from "@chakra-ui/react";
+import { useLayoutEffect, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+
+import type { UIAlert } from "openapi/requests/types.gen";
+import ReactMarkdown from "src/components/ReactMarkdown";
+import { Alert } from "src/components/ui";
+
+const MAX_VISIBLE_LINES = 5;
+
+export const AlertContent = ({ alert }: { readonly alert: UIAlert }) => {
+ const { t: translate } = useTranslation("dashboard");
+ const [isExpanded, setIsExpanded] = useState(false);
+ const [isOverflowing, setIsOverflowing] = useState(false);
+ const contentRef = useRef<HTMLDivElement>(null);
+ const isExpandedRef = useRef(isExpanded);
+
+ // Sync before effects run so the ResizeObserver sees the current value.
+ isExpandedRef.current = isExpanded;
+
+ useLayoutEffect(() => {
+ const element = contentRef.current;
+
+ if (element === null) {
+ return undefined;
+ }
+
+ const checkOverflow = () => {
+ if (!isExpandedRef.current) {
+ setIsOverflowing(element.scrollHeight > element.clientHeight);
+ }
+ };
+
+ const observer = new ResizeObserver(checkOverflow);
+
+ observer.observe(element);
+ checkOverflow();
+
+ return () => observer.disconnect();
+ }, []);
+
+ return (
+ <Alert status={alert.category}>
+ <Box
+ ref={contentRef}
+ style={
+ isExpanded
+ ? undefined
+ : {
+ display: "-webkit-box",
+ overflow: "hidden",
+ WebkitBoxOrient: "vertical",
+ WebkitLineClamp: MAX_VISIBLE_LINES,
+ }
+ }
+ width="100%"
+ >
+ <ReactMarkdown>{alert.text}</ReactMarkdown>
+ </Box>
+ {isOverflowing ? (
+ <Button
+ _hover={{ textDecoration: "underline" }}
+ alignSelf="flex-start"
+ color="fg.muted"
+ mt={1}
+ onClick={(event) => {
+ // Stop the click from toggling the surrounding accordion trigger.
+ event.stopPropagation();
+ setIsExpanded((prev) => !prev);
+ }}
+ px={0}
+ size="sm"
+ variant="plain"
+ >
+ {isExpanded ? translate("alerts.seeLessContext") :
translate("alerts.seeMoreContext")}
+ </Button>
+ ) : undefined}
+ </Alert>
+ );
+};
diff --git a/airflow-core/src/airflow/ui/src/pages/Dashboard/Dashboard.test.tsx
b/airflow-core/src/airflow/ui/src/pages/Dashboard/Dashboard.test.tsx
new file mode 100644
index 00000000000..c15b880562b
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Dashboard/Dashboard.test.tsx
@@ -0,0 +1,204 @@
+/*!
+ * 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { UIAlert } from "openapi/requests/types.gen";
+import { COLLAPSED_UI_ALERTS_KEY } from "src/constants/localStorage";
+import { Wrapper } from "src/utils/Wrapper";
+
+import { Dashboard } from "./Dashboard";
+
+const mockConfig: Record<string, unknown> = {};
+
+vi.mock("src/queries/useConfig", () => ({
+ useConfig: (key: string) => mockConfig[key],
+}));
+
+vi.mock("openapi/queries", () => ({
+ usePluginServiceGetPlugins: () => ({ data: { plugins: [] } }),
+}));
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ // eslint-disable-next-line id-length
+ t: (key: string, options?: { count?: number }) =>
+ key === "alerts.showMoreAlerts"
+ ? `+${options?.count} more ${options?.count === 1 ? "alert" :
"alerts"}`
+ : key,
+ }),
+}));
+
+vi.mock("./AlertContent", () => ({
+ AlertContent: ({ alert }: { readonly alert: UIAlert }) =>
<div>{alert.text}</div>,
+}));
+
+// Ark UI's accordion machine does not respond to synthetic clicks under
happy-dom, so this
+// stand-in wires the controlled value/onValueChange contract manually.
+vi.mock("src/components/ui", async () => {
+ const { createContext, useContext } = await import("react");
+
+ type AccordionContextValue = {
+ onValueChange: (details: { value: Array<string> }) => void;
+ value: Array<string>;
+ };
+ const AccordionContext = createContext<AccordionContextValue>({
+ onValueChange: () => undefined,
+ value: [],
+ });
+
+ type RootProps = { readonly children: React.ReactNode } &
AccordionContextValue;
+ const passthrough = ({ children }: { readonly children: React.ReactNode })
=> <div>{children}</div>;
+
+ return {
+ Accordion: {
+ Item: passthrough,
+ ItemContent: passthrough,
+ ItemTrigger: ({ children }: { readonly children: React.ReactNode }) => {
+ const { onValueChange, value } = useContext(AccordionContext);
+ const isOpen = value.includes("ui_alerts");
+
+ return (
+ <button
+ data-part="item-trigger"
+ data-state={isOpen ? "open" : "closed"}
+ onClick={() => onValueChange({ value: isOpen ? [] : ["ui_alerts"]
})}
+ type="button"
+ >
+ {children}
+ </button>
+ );
+ },
+ Root: ({ children, onValueChange, value }: RootProps) => (
+ <AccordionContext.Provider value={{ onValueChange, value
}}>{children}</AccordionContext.Provider>
+ ),
+ },
+ };
+});
+
+vi.mock("./Deadlines", () => ({ DashboardDeadlines: () => null }));
+vi.mock("./FavoriteDags", () => ({ FavoriteDags: () => null }));
+vi.mock("./Health", () => ({ Health: () => null }));
+vi.mock("./HistoricalMetrics", () => ({ HistoricalMetrics: () => null }));
+vi.mock("./PoolSummary", () => ({ PoolSummary: () => null }));
+vi.mock("./Stats", () => ({ Stats: () => null }));
+vi.mock("src/components/TimeRangeSelector", () => ({ default: () => null }));
+vi.mock("../ReactPlugin", () => ({ ReactPlugin: () => null }));
+
+const setAlerts = (alerts: Array<UIAlert>) => {
+ mockConfig.dashboard_alert = alerts;
+ mockConfig.instance_name = "Airflow";
+};
+
+const accordionState = (container: HTMLElement) =>
+
container.querySelector<HTMLElement>('[data-part="item-trigger"]')?.dataset.state;
+
+describe("Dashboard alerts accordion", () => {
+ beforeEach(() => {
+ globalThis.localStorage.clear();
+ });
+
+ afterEach(() => {
+ globalThis.localStorage.clear();
+ });
+
+ it("does not write the collapsed key when no alerts are configured", () => {
+ setAlerts([]);
+
+ render(<Dashboard />, { wrapper: Wrapper });
+
+
expect(globalThis.localStorage.getItem(COLLAPSED_UI_ALERTS_KEY)).toBeNull();
+ });
+
+ it("shows every alert expanded by default with no show-more button", () => {
+ setAlerts([
+ { category: "info", text: "Alert one" },
+ { category: "warning", text: "Alert two" },
+ { category: "error", text: "Alert three" },
+ ]);
+
+ const { container } = render(<Dashboard />, { wrapper: Wrapper });
+
+ expect(screen.getByText("Alert one")).toBeInTheDocument();
+ expect(screen.getByText("Alert two")).toBeInTheDocument();
+ expect(screen.getByText("Alert three")).toBeInTheDocument();
+ expect(accordionState(container)).toBe("open");
+ expect(screen.queryByRole("button", { name: /more alerts/u
})).not.toBeInTheDocument();
+ });
+
+ it("persists the collapsed state to localStorage when the accordion is
collapsed", () => {
+ setAlerts([
+ { category: "info", text: "Alert one" },
+ { category: "warning", text: "Alert two" },
+ { category: "error", text: "Alert three" },
+ ]);
+
+ const { container } = render(<Dashboard />, { wrapper: Wrapper });
+
+ expect(accordionState(container)).toBe("open");
+
+ fireEvent.click(container.querySelector('[data-part="item-trigger"]') as
HTMLElement);
+
+
expect(globalThis.localStorage.getItem(COLLAPSED_UI_ALERTS_KEY)).toBe("true");
+ expect(accordionState(container)).toBe("closed");
+ expect(screen.getByRole("button", { name: "+2 more alerts"
})).toBeVisible();
+ });
+
+ it("restores the collapsed state from localStorage on reload and surfaces a
show-more button", () => {
+ globalThis.localStorage.setItem(COLLAPSED_UI_ALERTS_KEY, "true");
+ setAlerts([
+ { category: "info", text: "Alert one" },
+ { category: "warning", text: "Alert two" },
+ { category: "error", text: "Alert three" },
+ ]);
+
+ const { container } = render(<Dashboard />, { wrapper: Wrapper });
+
+ expect(accordionState(container)).toBe("closed");
+ expect(screen.getByRole("button", { name: "+2 more alerts"
})).toBeVisible();
+ });
+
+ it("expands and clears the persisted collapsed state when show-more is
clicked", () => {
+ globalThis.localStorage.setItem(COLLAPSED_UI_ALERTS_KEY, "true");
+ setAlerts([
+ { category: "info", text: "Alert one" },
+ { category: "warning", text: "Alert two" },
+ ]);
+
+ const { container } = render(<Dashboard />, { wrapper: Wrapper });
+
+ fireEvent.click(screen.getByRole("button", { name: "+1 more alert" }));
+
+
expect(globalThis.localStorage.getItem(COLLAPSED_UI_ALERTS_KEY)).toBe("false");
+ expect(accordionState(container)).toBe("open");
+ expect(screen.queryByRole("button", { name: /more alert/u
})).not.toBeInTheDocument();
+ });
+
+ it("renders a single alert without an accordion trigger or show-more
button", () => {
+ setAlerts([{ category: "info", text: "Only alert" }]);
+
+ const { container } = render(<Dashboard />, { wrapper: Wrapper });
+
+ expect(screen.getByText("Only alert")).toBeInTheDocument();
+ expect(container.querySelector('[data-part="item-trigger"]')).toBeNull();
+ expect(screen.queryByRole("button", { name: /more alert/u
})).not.toBeInTheDocument();
+
expect(globalThis.localStorage.getItem(COLLAPSED_UI_ALERTS_KEY)).toBeNull();
+ });
+});
diff --git a/airflow-core/src/airflow/ui/src/pages/Dashboard/Dashboard.tsx
b/airflow-core/src/airflow/ui/src/pages/Dashboard/Dashboard.tsx
index 969d9e8787b..2dc7b8f3943 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dashboard/Dashboard.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dashboard/Dashboard.tsx
@@ -16,19 +16,22 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Box, Heading, VStack } from "@chakra-ui/react";
+import { Box, Button, Flex, Heading, VStack } from "@chakra-ui/react";
+import type { AccordionValueChangeDetails } from "@chakra-ui/react";
import dayjs from "dayjs";
-import { useState } from "react";
+import { Fragment, useState } from "react";
import { useTranslation } from "react-i18next";
+import { useLocalStorage } from "usehooks-ts";
import { usePluginServiceGetPlugins } from "openapi/queries";
import type { ReactAppResponse, UIAlert } from "openapi/requests/types.gen";
-import ReactMarkdown from "src/components/ReactMarkdown";
import TimeRangeSelector from "src/components/TimeRangeSelector";
-import { Accordion, Alert } from "src/components/ui";
+import { Accordion } from "src/components/ui";
+import { COLLAPSED_UI_ALERTS_KEY } from "src/constants/localStorage";
import { useConfig } from "src/queries/useConfig";
import { ReactPlugin } from "../ReactPlugin";
+import { AlertContent } from "./AlertContent";
import { DashboardDeadlines } from "./Deadlines";
import { FavoriteDags } from "./FavoriteDags";
import { Health } from "./Health";
@@ -46,6 +49,14 @@ export const Dashboard = () => {
const now = dayjs();
const [startDate, setStartDate] = useState(now.subtract(Number(defaultHour),
"hour").toISOString());
const [endDate, setEndDate] = useState(now.toISOString());
+ const [isCollapsed, setIsCollapsed] =
useLocalStorage(COLLAPSED_UI_ALERTS_KEY, false);
+
+ const accordionValue = isCollapsed ? [] : ["ui_alerts"];
+ const hasMultipleAlerts = alerts.length > 1;
+
+ const handleAccordionChange = (details: AccordionValueChangeDetails) => {
+ setIsCollapsed(!details.value.includes("ui_alerts"));
+ };
const { data: pluginData } = usePluginServiceGetPlugins();
@@ -60,20 +71,43 @@ export const Dashboard = () => {
{/* All flex items within this VStack should specify an increasing
order. This
will be used by third parties plugins to position themselves within
the page via CSS */}
{alerts.length > 0 ? (
- <Accordion.Root collapsible defaultValue={["ui_alerts"]} order={1}>
+ <Accordion.Root
+ collapsible
+ data-testid="dashboard-alerts"
+ onValueChange={handleAccordionChange}
+ order={1}
+ value={accordionValue}
+ >
<Accordion.Item key="ui_alerts" value="ui_alerts">
{alerts.map((alert: UIAlert, index) =>
index === 0 ? (
- <Accordion.ItemTrigger key={alert.text} mb={2}>
- <Alert status={alert.category}>
- <ReactMarkdown>{alert.text}</ReactMarkdown>
- </Alert>
- </Accordion.ItemTrigger>
+ <Fragment key={alert.text}>
+ {hasMultipleAlerts ? (
+ <Accordion.ItemTrigger mb={2}>
+ <AlertContent alert={alert} />
+ </Accordion.ItemTrigger>
+ ) : (
+ <Box mb={2}>
+ <AlertContent alert={alert} />
+ </Box>
+ )}
+ {isCollapsed && hasMultipleAlerts ? (
+ <Flex justifyContent="center" mb={2}>
+ <Button
+ _hover={{ textDecoration: "underline" }}
+ color="fg.muted"
+ onClick={() => setIsCollapsed(false)}
+ size="sm"
+ variant="plain"
+ >
+ {translate("alerts.showMoreAlerts", { count:
alerts.length - 1 })}
+ </Button>
+ </Flex>
+ ) : undefined}
+ </Fragment>
) : (
<Accordion.ItemContent key={alert.text} pr={8}>
- <Alert status={alert.category}>
- <ReactMarkdown>{alert.text}</ReactMarkdown>
- </Alert>
+ <AlertContent alert={alert} />
</Accordion.ItemContent>
),
)}
diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/HomePage.ts
b/airflow-core/src/airflow/ui/tests/e2e/pages/HomePage.ts
index b1367458de5..beb42db7088 100644
--- a/airflow-core/src/airflow/ui/tests/e2e/pages/HomePage.ts
+++ b/airflow-core/src/airflow/ui/tests/e2e/pages/HomePage.ts
@@ -20,6 +20,8 @@ import { expect, type Locator, type Page } from
"@playwright/test";
import { HITLReviewModal } from "tests/e2e/components/HITLReviewModal";
import { BasePage } from "tests/e2e/pages/BasePage";
+import type { UIAlert } from "openapi/requests/types.gen";
+
/**
* Home/Dashboard Page Object
*/
@@ -29,6 +31,9 @@ export class HomePage extends BasePage {
}
public readonly activeDagsCard: Locator;
+ public readonly alertsAccordionTrigger: Locator;
+ public readonly alertSeeLessButton: Locator;
+ public readonly alertSeeMoreButton: Locator;
public readonly dagImportErrorsCard: Locator;
public readonly dagProcessorHealth: Locator;
public readonly dagRunMetrics: Locator;
@@ -74,6 +79,12 @@ export class HomePage extends BasePage {
this.historicalMetricsSection = page.getByRole("heading", { name:
"History" }).locator("..");
this.dagRunMetrics = page.getByRole("heading", { name: /dag run/i
}).first();
this.taskInstanceMetrics = page.getByRole("heading", { name: /task
instance/i }).first();
+
+ const alertsAccordion = page.getByTestId("dashboard-alerts");
+
+ this.alertsAccordionTrigger =
alertsAccordion.locator('[data-part="item-trigger"]');
+ this.alertSeeMoreButton = page.getByRole("button", { exact: true, name:
"See more" });
+ this.alertSeeLessButton = page.getByRole("button", { exact: true, name:
"See less" });
}
/**
@@ -108,6 +119,22 @@ export class HomePage extends BasePage {
}
}
+ /**
+ * Stub the /ui/config response so the dashboard renders the given alerts.
+ */
+ public async mockDashboardAlerts(alerts: Array<UIAlert>): Promise<void> {
+ await this.page.route("**/ui/config", async (route) => {
+ const response = await route.fetch();
+ const body = (await response.json()) as Record<string, unknown>;
+
+ await route.fulfill({
+ body: JSON.stringify({ ...body, dashboard_alert: alerts }),
+ contentType: "application/json",
+ status: response.status(),
+ });
+ });
+ }
+
/**
* Navigate to Home/Dashboard page
*/
diff --git a/airflow-core/src/airflow/ui/tests/e2e/specs/home-dashboard.spec.ts
b/airflow-core/src/airflow/ui/tests/e2e/specs/home-dashboard.spec.ts
index 1c03a2c6995..7ef4dc020e6 100644
--- a/airflow-core/src/airflow/ui/tests/e2e/specs/home-dashboard.spec.ts
+++ b/airflow-core/src/airflow/ui/tests/e2e/specs/home-dashboard.spec.ts
@@ -129,3 +129,39 @@ test.describe("Dashboard Metrics Display", () => {
await expect(homePage.dagImportErrorsCard).toBeVisible();
});
});
+
+test.describe("Dashboard Alert Clamping", () => {
+ const longText = [
+ "Long alert start",
+ ...Array.from({ length: 10 }, (_, index) => `Line ${index + 2}`),
+ "Long alert end",
+ ].join("\n\n");
+ const alerts = [
+ { category: "info" as const, text: longText },
+ { category: "warning" as const, text: "Short alert text" },
+ ];
+
+ test("clamps a tall alert and toggles its height with See more / See less",
async ({ homePage }) => {
+ await homePage.mockDashboardAlerts(alerts);
+ await homePage.navigate();
+ await homePage.waitForDashboardLoad();
+
+ await expect(homePage.alertSeeMoreButton).toHaveCount(1);
+ await expect(homePage.alertSeeLessButton).toHaveCount(0);
+
+ // The tall alert is the accordion trigger; its height reflects the clamp.
+ const collapsedHeight = (await
homePage.alertsAccordionTrigger.boundingBox())?.height ?? 0;
+
+ await homePage.alertSeeMoreButton.click();
+ await expect(homePage.alertSeeLessButton).toBeVisible();
+ await expect(homePage.alertSeeMoreButton).toHaveCount(0);
+
+ const expandedHeight = (await
homePage.alertsAccordionTrigger.boundingBox())?.height ?? 0;
+
+ expect(expandedHeight).toBeGreaterThan(collapsedHeight);
+
+ await homePage.alertSeeLessButton.click();
+ await expect(homePage.alertSeeMoreButton).toBeVisible();
+ expect((await homePage.alertsAccordionTrigger.boundingBox())?.height ??
0).toBeLessThan(expandedHeight);
+ });
+});