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 8d3ccae1610 Fix dashboard alert clamping and collapse controls (#68893)
8d3ccae1610 is described below

commit 8d3ccae16106ec77e1191d5fe4d7edc723830ec6
Author: Pierre Jeambrun <[email protected]>
AuthorDate: Tue Jun 23 20:11:49 2026 +0200

    Fix dashboard alert clamping and collapse controls (#68893)
---
 .../ui/src/pages/Dashboard/AlertContent.test.tsx   |  12 +++
 .../ui/src/pages/Dashboard/AlertContent.tsx        |  21 +++-
 .../ui/src/pages/Dashboard/Dashboard.test.tsx      | 119 ++++++---------------
 .../airflow/ui/src/pages/Dashboard/Dashboard.tsx   | 108 ++++++++++---------
 .../src/airflow/ui/tests/e2e/pages/HomePage.ts     |   6 +-
 .../ui/tests/e2e/specs/home-dashboard.spec.ts      |   8 +-
 6 files changed, 125 insertions(+), 149 deletions(-)

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
index c31763fcacc..dbbfa96a1c4 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dashboard/AlertContent.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dashboard/AlertContent.test.tsx
@@ -87,6 +87,18 @@ describe("AlertContent", () => {
     expect(screen.queryByRole("button", { name: "See more" 
})).not.toBeInTheDocument();
   });
 
+  it("shows no See more when content exceeds clientHeight but still fits the 
clamp", () => {
+    // Regression: a single line of inline content (bold, inline code) leaves 
scrollHeight
+    // a few px above clientHeight. Overflow must be decided by the clamp 
height, not by
+    // scrollHeight > clientHeight, which would surface a See more toggle with 
nothing to reveal.
+    stubScrollHeight(CLAMPED_HEIGHT + 10);
+    stubClientHeight(CLAMPED_HEIGHT);
+
+    renderAlert({ category: "info", text: "Short alert text" });
+
+    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);
diff --git a/airflow-core/src/airflow/ui/src/pages/Dashboard/AlertContent.tsx 
b/airflow-core/src/airflow/ui/src/pages/Dashboard/AlertContent.tsx
index 12447850a5c..6b769d83660 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dashboard/AlertContent.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dashboard/AlertContent.tsx
@@ -24,7 +24,14 @@ 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;
+// Clamp by height rather than `-webkit-line-clamp`: alert text is rendered as 
markdown
+// (a stack of block-level headings/paragraphs/lists), and line-clamp only 
counts inline
+// text lines, so it fails to clamp multi-block content. Overflow is then 
detected against
+// this height directly: comparing scrollHeight to clientHeight is unreliable 
because a
+// single line of inline content (bold, inline code) leaves scrollHeight a few 
px above
+// clientHeight even when nothing is clipped, producing a spurious "See more". 
~5 lines.
+const MAX_VISIBLE_HEIGHT = 120;
+const FADE_MASK = "linear-gradient(to bottom, black calc(100% - 1.5rem), 
transparent)";
 
 export const AlertContent = ({ alert }: { readonly alert: UIAlert }) => {
   const { t: translate } = useTranslation("dashboard");
@@ -45,7 +52,7 @@ export const AlertContent = ({ alert }: { readonly alert: 
UIAlert }) => {
 
     const checkOverflow = () => {
       if (!isExpandedRef.current) {
-        setIsOverflowing(element.scrollHeight > element.clientHeight);
+        setIsOverflowing(element.scrollHeight > MAX_VISIBLE_HEIGHT);
       }
     };
 
@@ -60,15 +67,19 @@ export const AlertContent = ({ alert }: { readonly alert: 
UIAlert }) => {
   return (
     <Alert status={alert.category}>
       <Box
+        data-testid="dashboard-alert-content"
         ref={contentRef}
         style={
           isExpanded
             ? undefined
             : {
-                display: "-webkit-box",
+                // The clamp can land mid-line (markdown mixes line-heights 
and block margins);
+                // fade the bottom so the cut edge softens instead of showing 
a sliced line.
+                // Only when actually clipped, so a short alert that fits is 
not dimmed.
+                maskImage: isOverflowing ? FADE_MASK : undefined,
+                maxHeight: `${MAX_VISIBLE_HEIGHT}px`,
                 overflow: "hidden",
-                WebkitBoxOrient: "vertical",
-                WebkitLineClamp: MAX_VISIBLE_LINES,
+                WebkitMaskImage: isOverflowing ? FADE_MASK : undefined,
               }
         }
         width="100%"
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
index c15b880562b..5a5dcb79e67 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dashboard/Dashboard.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dashboard/Dashboard.test.tsx
@@ -50,49 +50,6 @@ 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 }));
@@ -107,10 +64,13 @@ const setAlerts = (alerts: Array<UIAlert>) => {
   mockConfig.instance_name = "Airflow";
 };
 
-const accordionState = (container: HTMLElement) =>
-  
container.querySelector<HTMLElement>('[data-part="item-trigger"]')?.dataset.state;
+const threeAlerts: Array<UIAlert> = [
+  { category: "info", text: "Alert one" },
+  { category: "warning", text: "Alert two" },
+  { category: "error", text: "Alert three" },
+];
 
-describe("Dashboard alerts accordion", () => {
+describe("Dashboard alerts collapse toggle", () => {
   beforeEach(() => {
     globalThis.localStorage.clear();
   });
@@ -127,78 +87,67 @@ describe("Dashboard alerts accordion", () => {
     
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" },
-    ]);
+  it("shows every alert expanded by default with a collapse toggle", () => {
+    setAlerts(threeAlerts);
 
-    const { container } = render(<Dashboard />, { wrapper: Wrapper });
+    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();
+    expect(screen.getByRole("button", { name: "Show fewer alerts" 
})).toBeVisible();
+    expect(screen.queryByText("+2 more alerts")).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" },
-    ]);
+  it("persists the collapsed state and hides extra alerts when collapsed", () 
=> {
+    setAlerts(threeAlerts);
 
-    const { container } = render(<Dashboard />, { wrapper: Wrapper });
-
-    expect(accordionState(container)).toBe("open");
+    render(<Dashboard />, { wrapper: Wrapper });
 
-    fireEvent.click(container.querySelector('[data-part="item-trigger"]') as 
HTMLElement);
+    fireEvent.click(screen.getByRole("button", { name: "Show fewer alerts" }));
 
     
expect(globalThis.localStorage.getItem(COLLAPSED_UI_ALERTS_KEY)).toBe("true");
-    expect(accordionState(container)).toBe("closed");
-    expect(screen.getByRole("button", { name: "+2 more alerts" 
})).toBeVisible();
+    expect(screen.getByText("Alert one")).toBeInTheDocument();
+    expect(screen.queryByText("Alert two")).not.toBeInTheDocument();
+    expect(screen.queryByText("Alert three")).not.toBeInTheDocument();
+    expect(screen.getByText("+2 more alerts")).toBeVisible();
+    expect(screen.getByRole("button", { name: "Show 2 more alerts" 
})).toBeVisible();
   });
 
-  it("restores the collapsed state from localStorage on reload and surfaces a 
show-more button", () => {
+  it("restores the collapsed state from localStorage on reload", () => {
     globalThis.localStorage.setItem(COLLAPSED_UI_ALERTS_KEY, "true");
-    setAlerts([
-      { category: "info", text: "Alert one" },
-      { category: "warning", text: "Alert two" },
-      { category: "error", text: "Alert three" },
-    ]);
+    setAlerts(threeAlerts);
 
-    const { container } = render(<Dashboard />, { wrapper: Wrapper });
+    render(<Dashboard />, { wrapper: Wrapper });
 
-    expect(accordionState(container)).toBe("closed");
-    expect(screen.getByRole("button", { name: "+2 more alerts" 
})).toBeVisible();
+    expect(screen.getByText("Alert one")).toBeInTheDocument();
+    expect(screen.queryByText("Alert two")).not.toBeInTheDocument();
+    expect(screen.getByRole("button", { name: "Show 2 more alerts" 
})).toBeVisible();
   });
 
-  it("expands and clears the persisted collapsed state when show-more is 
clicked", () => {
+  it("expands and clears the persisted collapsed state when the toggle 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 });
+    render(<Dashboard />, { wrapper: Wrapper });
 
-    fireEvent.click(screen.getByRole("button", { name: "+1 more alert" }));
+    fireEvent.click(screen.getByRole("button", { name: "Show 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();
+    expect(screen.getByText("Alert two")).toBeInTheDocument();
+    expect(screen.getByRole("button", { name: "Show fewer alerts" 
})).toBeVisible();
   });
 
-  it("renders a single alert without an accordion trigger or show-more 
button", () => {
+  it("renders a single alert without a collapse toggle", () => {
     setAlerts([{ category: "info", text: "Only alert" }]);
 
-    const { container } = render(<Dashboard />, { wrapper: Wrapper });
+    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(screen.queryByRole("button", { name: /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 2dc7b8f3943..a5867947d15 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dashboard/Dashboard.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dashboard/Dashboard.tsx
@@ -16,17 +16,17 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { Box, Button, Flex, Heading, VStack } from "@chakra-ui/react";
-import type { AccordionValueChangeDetails } from "@chakra-ui/react";
+import { Box, Button, Flex, Heading, Separator, VStack } from 
"@chakra-ui/react";
 import dayjs from "dayjs";
-import { Fragment, useState } from "react";
+import { useState } from "react";
 import { useTranslation } from "react-i18next";
+import { LuChevronDown } from "react-icons/lu";
 import { useLocalStorage } from "usehooks-ts";
 
 import { usePluginServiceGetPlugins } from "openapi/queries";
 import type { ReactAppResponse, UIAlert } from "openapi/requests/types.gen";
 import TimeRangeSelector from "src/components/TimeRangeSelector";
-import { Accordion } from "src/components/ui";
+import { IconButton } from "src/components/ui";
 import { COLLAPSED_UI_ALERTS_KEY } from "src/constants/localStorage";
 import { useConfig } from "src/queries/useConfig";
 
@@ -51,12 +51,8 @@ export const Dashboard = () => {
   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 [firstAlert, ...restAlerts] = alerts;
+  const hasMultipleAlerts = restAlerts.length > 0;
 
   const { data: pluginData } = usePluginServiceGetPlugins();
 
@@ -70,49 +66,57 @@ export const Dashboard = () => {
       <VStack alignItems="stretch" gap={6}>
         {/* 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
-            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 ? (
-                  <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}>
+        {firstAlert ? (
+          <Box data-testid="dashboard-alerts" order={1}>
+            <Box mb={2}>
+              <AlertContent alert={firstAlert} />
+            </Box>
+            {isCollapsed
+              ? undefined
+              : restAlerts.map((alert: UIAlert) => (
+                  <Box key={alert.text} mb={2}>
                     <AlertContent alert={alert} />
-                  </Accordion.ItemContent>
-                ),
-              )}
-            </Accordion.Item>
-          </Accordion.Root>
+                  </Box>
+                ))}
+            {hasMultipleAlerts ? (
+              <>
+                {isCollapsed ? (
+                  <Flex justifyContent="center" mb={2}>
+                    <Button
+                      _hover={{ textDecoration: "underline" }}
+                      color="fg.muted"
+                      onClick={() => setIsCollapsed(false)}
+                      size="sm"
+                      variant="plain"
+                    >
+                      {translate("alerts.showMoreAlerts", { count: 
restAlerts.length })}
+                    </Button>
+                  </Flex>
+                ) : undefined}
+                <Flex alignItems="center" gap={2}>
+                  <Separator flex="1" />
+                  <IconButton
+                    aria-label={
+                      isCollapsed
+                        ? `Show ${restAlerts.length} more 
alert${restAlerts.length === 1 ? "" : "s"}`
+                        : "Show fewer alerts"
+                    }
+                    onClick={() => setIsCollapsed((previous) => !previous)}
+                    size="xs"
+                    variant="ghost"
+                  >
+                    <LuChevronDown
+                      style={{
+                        transform: isCollapsed ? undefined : "rotate(180deg)",
+                        transition: "transform 0.2s",
+                      }}
+                    />
+                  </IconButton>
+                  <Separator flex="1" />
+                </Flex>
+              </>
+            ) : undefined}
+          </Box>
         ) : undefined}
         <Heading order={2} size="2xl">
           {typeof instanceName === "string" && instanceName !== "" && 
instanceName !== "Airflow"
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 beb42db7088..bfbbbc35459 100644
--- a/airflow-core/src/airflow/ui/tests/e2e/pages/HomePage.ts
+++ b/airflow-core/src/airflow/ui/tests/e2e/pages/HomePage.ts
@@ -31,13 +31,13 @@ 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;
   public readonly failedDagsCard: Locator;
+  public readonly firstAlertContent: Locator;
 
   public readonly healthSection: Locator;
   public readonly historicalMetricsSection: Locator;
@@ -80,9 +80,9 @@ export class HomePage extends BasePage {
     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");
+    const alertsContainer = page.getByTestId("dashboard-alerts");
 
-    this.alertsAccordionTrigger = 
alertsAccordion.locator('[data-part="item-trigger"]');
+    this.firstAlertContent = 
alertsContainer.getByTestId("dashboard-alert-content").first();
     this.alertSeeMoreButton = page.getByRole("button", { exact: true, name: 
"See more" });
     this.alertSeeLessButton = page.getByRole("button", { exact: true, name: 
"See less" });
   }
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 7ef4dc020e6..e74669e6b77 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
@@ -149,19 +149,19 @@ test.describe("Dashboard Alert Clamping", () => {
     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;
+    // The tall alert's clamped content height reflects the See more / See 
less toggle.
+    const collapsedHeight = (await 
homePage.firstAlertContent.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;
+    const expandedHeight = (await 
homePage.firstAlertContent.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);
+    expect((await homePage.firstAlertContent.boundingBox())?.height ?? 
0).toBeLessThan(expandedHeight);
   });
 });

Reply via email to