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);
+  });
+});

Reply via email to