This is an automated email from the ASF dual-hosted git repository.
bbovenzi 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 04d39ab9741 UI: Keep Grid run tooltips inside the viewport (#72161)
04d39ab9741 is described below
commit 04d39ab97416b6128e830c5c0a2dd904cba9fc1f
Author: NihalRadhakrishna <[email protected]>
AuthorDate: Wed Sep 16 23:59:15 2026 +0530
UI: Keep Grid run tooltips inside the viewport (#72161)
* UI: Keep Grid run tooltips inside the viewport
* UI: Replace BasicTooltip with Chakra Tooltip
* UI: Fix GridButton import grouping after rebase.
* UI: Point Calendar Tooltip at system-components after the ui folder
rename.
---
.../src/airflow/ui/src/components/BasicTooltip.tsx | 134 ---------------------
.../src/layouts/Details/Grid/GridButton.test.tsx | 24 ++--
.../ui/src/layouts/Details/Grid/GridButton.tsx | 121 +++++++++++--------
.../ui/src/pages/Dag/Calendar/CalendarCell.tsx | 19 ++-
.../airflow/ui/tests/e2e/pages/DagCalendarTab.ts | 2 +-
5 files changed, 100 insertions(+), 200 deletions(-)
diff --git a/airflow-core/src/airflow/ui/src/components/BasicTooltip.tsx
b/airflow-core/src/airflow/ui/src/components/BasicTooltip.tsx
deleted file mode 100644
index ccedc5b947c..00000000000
--- a/airflow-core/src/airflow/ui/src/components/BasicTooltip.tsx
+++ /dev/null
@@ -1,134 +0,0 @@
-/*!
- * 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 type { ReactElement, ReactNode } from "react";
-import { useEffect, useLayoutEffect, useRef, useState } from "react";
-
-import { Box, Portal } from "@chakra-ui/react";
-
-type Props = {
- readonly children: ReactNode;
- readonly content: ReactNode;
-};
-
-const offset = 8;
-
-export const BasicTooltip = ({ children, content }: Props): ReactElement => {
- const triggerRef = useRef<HTMLSpanElement>(null);
- const tooltipRef = useRef<HTMLDivElement>(null);
- const [isOpen, setIsOpen] = useState(false);
- const [showOnTop, setShowOnTop] = useState(false);
- const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
-
- const handleMouseEnter = () => {
- if (timeoutRef.current !== null) {
- clearTimeout(timeoutRef.current);
- }
- timeoutRef.current = setTimeout(() => {
- setIsOpen(true);
- }, 500);
- };
-
- const handleMouseLeave = () => {
- if (timeoutRef.current !== null) {
- clearTimeout(timeoutRef.current);
- timeoutRef.current = null;
- }
- setIsOpen(false);
- };
-
- // Calculate position based on actual tooltip height before paint
- useLayoutEffect(() => {
- if (isOpen && triggerRef.current && tooltipRef.current) {
- const triggerRect = triggerRef.current.getBoundingClientRect();
- const tooltipHeight = tooltipRef.current.clientHeight;
- const wouldOverflow = triggerRect.bottom + offset + tooltipHeight >
globalThis.innerHeight;
-
- setShowOnTop(wouldOverflow);
- }
- }, [isOpen]);
-
- // Cleanup on unmount
- useEffect(
- () => () => {
- if (timeoutRef.current !== null) {
- clearTimeout(timeoutRef.current);
- }
- },
- [],
- );
-
- const trigger = (
- <Box
- as="span"
- display="inline-block"
- onMouseEnter={handleMouseEnter}
- onMouseLeave={handleMouseLeave}
- ref={triggerRef}
- >
- {children}
- </Box>
- );
-
- if (!isOpen || !triggerRef.current) {
- return trigger;
- }
-
- const rect = triggerRef.current.getBoundingClientRect();
- const { scrollX, scrollY } = globalThis;
-
- return (
- <>
- {trigger}
- <Portal>
- <Box
- bg="bg.inverted"
- borderRadius="md"
- boxShadow="md"
- color="fg.inverted"
- data-testid="basic-tooltip"
- fontSize="sm"
- left={`${rect.left + scrollX + rect.width / 2}px`}
- paddingX="3"
- paddingY="2"
- pointerEvents="none"
- position="absolute"
- ref={tooltipRef}
- top={showOnTop ? `${rect.top + scrollY - offset}px` : `${rect.bottom
+ scrollY + offset}px`}
- transform={showOnTop ? "translate(-50%, -100%)" : "translateX(-50%)"}
- whiteSpace="nowrap"
- zIndex="popover"
- >
- <Box
- borderLeft="4px solid transparent"
- borderRight="4px solid transparent"
- height={0}
- left="50%"
- position="absolute"
- transform="translateX(-50%)"
- width={0}
- {...(showOnTop
- ? { borderTop: "4px solid var(--chakra-colors-bg-inverted)",
bottom: "-4px" }
- : { borderBottom: "4px solid var(--chakra-colors-bg-inverted)",
top: "-4px" })}
- />
- {content}
- </Box>
- </Portal>
- </>
- );
-};
diff --git
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridButton.test.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridButton.test.tsx
index f4bdfacd609..5a773f27fe3 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridButton.test.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridButton.test.tsx
@@ -26,7 +26,7 @@ import { Wrapper } from "src/utils/Wrapper";
import { GridButton } from "./GridButton";
describe("GridButton", () => {
- it("shows run details in the grid tooltip respecting the selected timezone",
() => {
+ it("shows run details in the grid tooltip respecting the selected timezone",
async () => {
vi.useFakeTimers();
render(
@@ -45,17 +45,19 @@ describe("GridButton", () => {
{ wrapper: Wrapper },
);
- act(() => {
- fireEvent.mouseEnter(screen.getByText("bar"));
- vi.advanceTimersByTime(500);
- });
+ try {
+ await act(async () => {
+ fireEvent.pointerEnter(screen.getByText("bar"));
+ await vi.advanceTimersByTimeAsync(500);
+ });
- expect(screen.getByTestId("basic-tooltip")).toHaveTextContent("2026-04-21
02:00:00");
- expect(screen.getByTestId("basic-tooltip")).toHaveTextContent(
- "common:runId: manual__2026-04-21T00:00:00+00:00",
- );
- expect(screen.getByTestId("basic-tooltip")).toHaveTextContent("duration:
1h 1m");
+ const tooltip = screen.getByRole("tooltip");
- vi.useRealTimers();
+ expect(tooltip).toHaveTextContent("2026-04-21 02:00:00");
+ expect(tooltip).toHaveTextContent("common:runId:
manual__2026-04-21T00:00:00+00:00");
+ expect(tooltip).toHaveTextContent("duration: 1h 1m");
+ } finally {
+ vi.useRealTimers();
+ }
});
});
diff --git
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridButton.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridButton.tsx
index 0a15583dd36..fa2015e810b 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridButton.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridButton.tsx
@@ -16,13 +16,14 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Flex, type FlexProps } from "@chakra-ui/react";
+import { Box, Flex, type FlexProps, Text, VStack } from "@chakra-ui/react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import type { DagRunState, TaskInstanceState } from
"openapi/requests/types.gen";
-import { BasicTooltip } from "src/components/BasicTooltip";
+import { Tooltip } from "src/system-components";
+
import Time from "src/components/Time";
import { useDurationFormat } from "src/utils";
@@ -53,54 +54,72 @@ export const GridButton = ({
const { t: translate } = useTranslation();
const { renderDuration } = useDurationFormat();
- const tooltipContent = (
- <>
- <Time datetime={runAfter} />
- <br />
- {translate("common:runId")}: {runId}
- <br />
- {translate("state")}:{" "}
- {state ? translate(`common:states.${state}`) :
translate("common:states.no_status")}
- <br />
- {translate("duration")}: {renderDuration(duration)}
- </>
- );
-
- return isGroup ? (
- <BasicTooltip content={tooltipContent}>
- <Flex
- background={`${state}.solid`}
- borderRadius={2}
- height="10px"
- minW="14px"
- pb="2px"
- px="2px"
- {...rest}
- >
- {children}
- </Flex>
- </BasicTooltip>
- ) : (
- <BasicTooltip content={tooltipContent}>
- <Link
- replace
- to={{
- pathname: `/dags/${dagId}/runs/${runId}/${taskId === undefined ? ""
: `tasks/${taskId}`}`,
- search: searchParams.toString(),
- }}
- >
- <Flex
- background={`${state}.solid`}
- borderRadius={2}
- height="10px"
- pb="2px"
- px="2px"
- width="14px"
- {...rest}
- >
- {children}
- </Flex>
- </Link>
- </BasicTooltip>
+ return (
+ <Tooltip
+ content={
+ <VStack align="start" gap={1}>
+ <Text>
+ <Time datetime={runAfter} />
+ </Text>
+ <Text>
+ {translate("common:runId")}: {runId}
+ </Text>
+ <Text>
+ {translate("state")}:{" "}
+ {state ? translate(`common:states.${state}`) :
translate("common:states.no_status")}
+ </Text>
+ <Text>
+ {translate("duration")}: {renderDuration(duration)}
+ </Text>
+ </VStack>
+ }
+ lazyMount
+ openDelay={500}
+ portalled
+ positioning={{
+ offset: {
+ crossAxis: 5,
+ mainAxis: 5,
+ },
+ placement: "bottom",
+ }}
+ unmountOnExit
+ >
+ <Box as="span" display="inline-block">
+ {isGroup ? (
+ <Flex
+ background={`${state}.solid`}
+ borderRadius={2}
+ height="10px"
+ minW="14px"
+ pb="2px"
+ px="2px"
+ {...rest}
+ >
+ {children}
+ </Flex>
+ ) : (
+ <Link
+ replace
+ to={{
+ pathname: `/dags/${dagId}/runs/${runId}/${taskId === undefined ?
"" : `tasks/${taskId}`}`,
+ search: searchParams.toString(),
+ }}
+ >
+ <Flex
+ background={`${state}.solid`}
+ borderRadius={2}
+ height="10px"
+ pb="2px"
+ px="2px"
+ width="14px"
+ {...rest}
+ >
+ {children}
+ </Flex>
+ </Link>
+ )}
+ </Box>
+ </Tooltip>
);
};
diff --git
a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarCell.tsx
b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarCell.tsx
index 375aaf86542..c5059691e4e 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarCell.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarCell.tsx
@@ -19,7 +19,7 @@
import { Box } from "@chakra-ui/react";
import { FiAlertTriangle, FiClock } from "react-icons/fi";
-import { BasicTooltip } from "src/components/BasicTooltip";
+import { Tooltip } from "src/system-components";
import { CalendarTooltip } from "./CalendarTooltip";
import type { CalendarCellData, CalendarColorMode } from "./types";
@@ -146,8 +146,21 @@ export const CalendarCell = ({
}
return (
- <BasicTooltip content={<CalendarTooltip cellData={cellData}
viewMode={viewMode} />}>
+ <Tooltip
+ content={<CalendarTooltip cellData={cellData} viewMode={viewMode} />}
+ lazyMount
+ openDelay={500}
+ portalled
+ positioning={{
+ offset: {
+ crossAxis: 5,
+ mainAxis: 5,
+ },
+ placement: "bottom",
+ }}
+ unmountOnExit
+ >
{cellBox}
- </BasicTooltip>
+ </Tooltip>
);
};
diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/DagCalendarTab.ts
b/airflow-core/src/airflow/ui/tests/e2e/pages/DagCalendarTab.ts
index b0656bc5575..3da576a8421 100644
--- a/airflow-core/src/airflow/ui/tests/e2e/pages/DagCalendarTab.ts
+++ b/airflow-core/src/airflow/ui/tests/e2e/pages/DagCalendarTab.ts
@@ -90,7 +90,7 @@ export class DagCalendarTab extends BasePage {
const states: Array<string> = [];
// Read run states from the cell's `data-states` attribute rather than
hovering to
- // read the tooltip. The tooltip (BasicTooltip) opens on a `mouseenter`
after a
+ // read the tooltip. The tooltip opens on a `mouseenter` after a
// 500ms delay and renders through a portal; synthetic pointer events do
not open
// it reliably in headless Firefox, which made these tests flaky.
`data-states` is
// populated with the same view-mode-aware logic the tooltip uses (see