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