This is an automated email from the ASF dual-hosted git repository.
ephraimanierobi pushed a commit to branch v3-1-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
commit 17cf1e75cfc6e34d91444ed56be1662e305fae78
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Wed Dec 3 13:58:07 2025 -0500
[v3-1-test] Fix task instance and runs tooltips in Grid view (#58359)
(#59013)
* refactor: replace hovertooltip with basictooltip
* feat: implement basic tooltip for grid TI
* feat: implement basic tooltip for grid run
* fix: use get duration util
* refactor: simplify BasicTooltip and add auto-flip for viewport overflow
* fix(i18n): add translation for tooltip states
* refactor: improve BasicTooltip positioning and remove duration
* fix: use Chakra semantic tokens
* refactor: use dynamic height for BasicTooltip positioning
(cherry picked from commit 4970ea2978ea675d5057475bc9200d0a5b598671)
Co-authored-by: LI,JHE-CHEN <[email protected]>
---
.../src/airflow/ui/src/components/BasicTooltip.tsx | 127 +++++++++++++++++++++
.../src/airflow/ui/src/components/DagRunInfo.tsx | 2 +-
.../src/airflow/ui/src/components/HoverTooltip.tsx | 63 ----------
.../ui/src/components/TaskInstanceTooltip.tsx | 5 +-
.../ui/src/layouts/Details/Grid/GridButton.tsx | 66 +++++++----
.../airflow/ui/src/layouts/Details/Grid/GridTI.tsx | 65 ++++++-----
.../ui/src/pages/Dag/Calendar/CalendarCell.tsx | 15 +--
.../ui/src/pages/Dag/Calendar/CalendarTooltip.tsx | 104 +++++------------
8 files changed, 241 insertions(+), 206 deletions(-)
diff --git a/airflow-core/src/airflow/ui/src/components/BasicTooltip.tsx
b/airflow-core/src/airflow/ui/src/components/BasicTooltip.tsx
new file mode 100644
index 00000000000..96400e5d5f0
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/BasicTooltip.tsx
@@ -0,0 +1,127 @@
+/*!
+ * 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, Portal } from "@chakra-ui/react";
+import type { ReactElement, ReactNode } from "react";
+import { cloneElement, useCallback, useEffect, useLayoutEffect, useRef,
useState } from "react";
+
+type Props = {
+ readonly children: ReactElement;
+ readonly content: ReactNode;
+};
+
+const offset = 8;
+
+export const BasicTooltip = ({ children, content }: Props): ReactElement => {
+ const triggerRef = useRef<HTMLElement>(null);
+ const tooltipRef = useRef<HTMLDivElement>(null);
+ const [isOpen, setIsOpen] = useState(false);
+ const [showOnTop, setShowOnTop] = useState(false);
+ const timeoutRef = useRef<NodeJS.Timeout>();
+
+ const handleMouseEnter = useCallback(() => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ }
+ timeoutRef.current = setTimeout(() => {
+ setIsOpen(true);
+ }, 500);
+ }, []);
+
+ const handleMouseLeave = useCallback(() => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ timeoutRef.current = undefined;
+ }
+ 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) {
+ clearTimeout(timeoutRef.current);
+ }
+ },
+ [],
+ );
+
+ // Clone children and attach event handlers + ref
+ const trigger = cloneElement(children, {
+ onMouseEnter: handleMouseEnter,
+ onMouseLeave: handleMouseLeave,
+ ref: triggerRef,
+ });
+
+ 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"
+ 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/components/DagRunInfo.tsx
b/airflow-core/src/airflow/ui/src/components/DagRunInfo.tsx
index 0fa1cab7658..fe6370fb8dd 100644
--- a/airflow-core/src/airflow/ui/src/components/DagRunInfo.tsx
+++ b/airflow-core/src/airflow/ui/src/components/DagRunInfo.tsx
@@ -42,7 +42,7 @@ const DagRunInfo = ({ endDate, logicalDate, runAfter,
startDate, state }: Props)
<VStack align="left" gap={0}>
{state === undefined ? undefined : (
<Text>
- {translate("state")}: {state}
+ {translate("state")}: {translate(`common:states.${state}`)}
</Text>
)}
{Boolean(logicalDate) ? (
diff --git a/airflow-core/src/airflow/ui/src/components/HoverTooltip.tsx
b/airflow-core/src/airflow/ui/src/components/HoverTooltip.tsx
deleted file mode 100644
index 46466858cae..00000000000
--- a/airflow-core/src/airflow/ui/src/components/HoverTooltip.tsx
+++ /dev/null
@@ -1,63 +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 { Portal } from "@chakra-ui/react";
-import { useState, useRef, useCallback, cloneElement } from "react";
-import type { ReactElement, ReactNode, RefObject } from "react";
-
-type Props = {
- readonly children: ReactElement;
- readonly delayMs?: number;
- readonly tooltip: (triggerRef: RefObject<HTMLElement>) => ReactNode;
-};
-
-export const HoverTooltip = ({ children, delayMs = 200, tooltip }: Props) => {
- const triggerRef = useRef<HTMLElement>(null);
- const [isOpen, setIsOpen] = useState(false);
- const timeoutRef = useRef<NodeJS.Timeout>();
-
- const handleMouseEnter = useCallback(() => {
- if (timeoutRef.current) {
- clearTimeout(timeoutRef.current);
- }
- timeoutRef.current = setTimeout(() => {
- setIsOpen(true);
- }, delayMs);
- }, [delayMs]);
-
- const handleMouseLeave = useCallback(() => {
- if (timeoutRef.current) {
- clearTimeout(timeoutRef.current);
- timeoutRef.current = undefined;
- }
- setIsOpen(false);
- }, []);
-
- const trigger = cloneElement(children, {
- onMouseEnter: handleMouseEnter,
- onMouseLeave: handleMouseLeave,
- ref: triggerRef,
- });
-
- return (
- <>
- {trigger}
- {Boolean(isOpen) && <Portal>{tooltip(triggerRef)}</Portal>}
- </>
- );
-};
diff --git a/airflow-core/src/airflow/ui/src/components/TaskInstanceTooltip.tsx
b/airflow-core/src/airflow/ui/src/components/TaskInstanceTooltip.tsx
index 025463dc955..a3494ee4a0b 100644
--- a/airflow-core/src/airflow/ui/src/components/TaskInstanceTooltip.tsx
+++ b/airflow-core/src/airflow/ui/src/components/TaskInstanceTooltip.tsx
@@ -43,7 +43,10 @@ const TaskInstanceTooltip = ({ children, positioning,
taskInstance, ...rest }: P
content={
<Box>
<Text>
- {translate("state")}: {taskInstance.state}
+ {translate("state")}:{" "}
+ {taskInstance.state
+ ? translate(`common:states.${taskInstance.state}`)
+ : translate("common:states.no_status")}
</Text>
{"dag_run_id" in taskInstance ? (
<Text>
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 f3dd0b3dc07..c452cf43a79 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
@@ -17,9 +17,11 @@
* under the License.
*/
import { Flex, type FlexProps } 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";
type Props = {
readonly dagId: string;
@@ -41,39 +43,53 @@ export const GridButton = ({
state,
taskId,
...rest
-}: Props) =>
- isGroup ? (
- <Flex
- background={`${state}.solid`}
- borderRadius={2}
- height="10px"
- minW="14px"
- pb="2px"
- px="2px"
- title={`${label}\n${state}`}
- {...rest}
- >
- {children}
- </Flex>
- ) : (
- <Link
- replace
- to={{
- pathname: `/dags/${dagId}/runs/${runId}/${taskId === undefined ? "" :
`tasks/${taskId}`}`,
- search: searchParams.toString(),
- }}
- >
+}: Props) => {
+ const { t: translate } = useTranslation();
+
+ const tooltipContent = (
+ <>
+ {label}
+ <br />
+ {translate("state")}:{" "}
+ {state ? translate(`common:states.${state}`) :
translate("common:states.no_status")}
+ </>
+ );
+
+ return isGroup ? (
+ <BasicTooltip content={tooltipContent}>
<Flex
background={`${state}.solid`}
borderRadius={2}
height="10px"
+ minW="14px"
pb="2px"
px="2px"
- title={`${label}\n${state}`}
- width="14px"
{...rest}
>
{children}
</Flex>
- </Link>
+ </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>
);
+};
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridTI.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridTI.tsx
index ddf294353cb..d4d775da977 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridTI.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridTI.tsx
@@ -23,9 +23,9 @@ import { useTranslation } from "react-i18next";
import { Link, useLocation, useParams, useSearchParams } from
"react-router-dom";
import type { LightGridTaskInstanceSummary } from "openapi/requests/types.gen";
+import { BasicTooltip } from "src/components/BasicTooltip";
import { StateIcon } from "src/components/StateIcon";
import Time from "src/components/Time";
-import { Tooltip } from "src/components/ui";
import { type HoverContextType, useHover } from "src/context/hover";
import { buildTaskInstanceUrl } from "src/utils/links";
@@ -106,35 +106,38 @@ const Instance = ({ dagId, instance, isGroup, isMapped,
onClick, runId, taskId }
py={0}
transition="background-color 0.2s"
>
- <Link
- id={`grid-${runId}-${taskId}`}
- onClick={onClick}
- replace
- to={{
- pathname: getTaskUrl(),
- search: redirectionSearch,
- }}
+ <BasicTooltip
+ content={
+ <>
+ {translate("taskId")}: {taskId}
+ <br />
+ {translate("state")}:{" "}
+ {instance.state
+ ? translate(`common:states.${instance.state}`)
+ : translate("common:states.no_status")}
+ {instance.min_start_date !== null && (
+ <>
+ <br />
+ {translate("startDate")}: <Time
datetime={instance.min_start_date} />
+ </>
+ )}
+ {instance.max_end_date !== null && (
+ <>
+ <br />
+ {translate("endDate")}: <Time datetime={instance.max_end_date}
/>
+ </>
+ )}
+ </>
+ }
>
- <Tooltip
- content={
- <>
- {translate("taskId")}: {taskId}
- <br />
- {translate("state")}: {instance.state}
- {instance.min_start_date !== null && (
- <>
- <br />
- {translate("startDate")}: <Time
datetime={instance.min_start_date} />
- </>
- )}
- {instance.max_end_date !== null && (
- <>
- <br />
- {translate("endDate")}: <Time
datetime={instance.max_end_date} />
- </>
- )}
- </>
- }
+ <Link
+ id={`grid-${runId}-${taskId}`}
+ onClick={onClick}
+ replace
+ to={{
+ pathname: getTaskUrl(),
+ search: redirectionSearch,
+ }}
>
<Badge
alignItems="center"
@@ -150,8 +153,8 @@ const Instance = ({ dagId, instance, isGroup, isMapped,
onClick, runId, taskId }
>
<StateIcon size={10} state={instance.state} />
</Badge>
- </Tooltip>
- </Link>
+ </Link>
+ </BasicTooltip>
</Flex>
);
};
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 4428a2b4e42..1a3c0fe30f1 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
@@ -17,9 +17,8 @@
* under the License.
*/
import { Box } from "@chakra-ui/react";
-import type React from "react";
-import { HoverTooltip } from "src/components/HoverTooltip";
+import { BasicTooltip } from "src/components/BasicTooltip";
import { CalendarTooltip } from "./CalendarTooltip";
import type { CalendarCellData, CalendarColorMode } from "./types";
@@ -38,12 +37,6 @@ type Props = {
readonly viewMode?: CalendarColorMode;
};
-const renderTooltip =
- (cellData: CalendarCellData | undefined, viewMode: CalendarColorMode) =>
- (triggerRef: React.RefObject<HTMLElement>) => (
- <CalendarTooltip cellData={cellData} triggerRef={triggerRef}
viewMode={viewMode} />
- );
-
export const CalendarCell = ({
backgroundColor,
cellData,
@@ -103,5 +96,9 @@ export const CalendarCell = ({
return cellBox;
}
- return <HoverTooltip tooltip={renderTooltip(cellData,
viewMode)}>{cellBox}</HoverTooltip>;
+ return (
+ <BasicTooltip content={<CalendarTooltip cellData={cellData}
viewMode={viewMode} />}>
+ {cellBox}
+ </BasicTooltip>
+ );
};
diff --git
a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarTooltip.tsx
b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarTooltip.tsx
index 61f65e9b876..2edad9f62c2 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarTooltip.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarTooltip.tsx
@@ -17,8 +17,6 @@
* under the License.
*/
import { Box, HStack, Text, VStack } from "@chakra-ui/react";
-import { useMemo } from "react";
-import type { RefObject } from "react";
import { useTranslation } from "react-i18next";
import type { CalendarCellData, CalendarColorMode } from "./types";
@@ -28,7 +26,6 @@ const SQUARE_BORDER_RADIUS = "2px";
type Props = {
readonly cellData: CalendarCellData | undefined;
- readonly triggerRef: RefObject<HTMLElement>;
readonly viewMode?: CalendarColorMode;
};
@@ -39,48 +36,9 @@ const stateColorMap = {
success: "success.solid",
};
-export const CalendarTooltip = ({ cellData, triggerRef, viewMode = "total" }:
Props) => {
+export const CalendarTooltip = ({ cellData, viewMode = "total" }: Props) => {
const { t: translate } = useTranslation(["dag", "common"]);
- const tooltipStyle = useMemo(() => {
- if (!triggerRef.current) {
- return { display: "none" };
- }
-
- const rect = triggerRef.current.getBoundingClientRect();
-
- return {
- backgroundColor: "var(--chakra-colors-bg-inverted)",
- borderRadius: "4px",
- color: "var(--chakra-colors-fg-inverted)",
- fontSize: "14px",
- left: `${rect.left + globalThis.scrollX + rect.width / 2}px`,
- minWidth: "200px",
- padding: "8px",
- position: "absolute" as const,
- top: `${rect.bottom + globalThis.scrollY + 8}px`,
- transform: "translateX(-50%)",
- whiteSpace: "nowrap" as const,
- zIndex: 1000,
- };
- }, [triggerRef]);
-
- const arrowStyle = useMemo(
- () => ({
- borderBottom: "4px solid var(--chakra-colors-bg-inverted)",
- borderLeft: "4px solid transparent",
- borderRight: "4px solid transparent",
- content: '""',
- height: 0,
- left: "50%",
- position: "absolute" as const,
- top: "-4px",
- transform: "translateX(-50%)",
- width: 0,
- }),
- [],
- );
-
if (!cellData) {
return undefined;
}
@@ -111,38 +69,32 @@ export const CalendarTooltip = ({ cellData, triggerRef,
viewMode = "total" }: Pr
state: translate(`common:states.${state}`),
}));
- return (
- <div style={tooltipStyle}>
- <div style={arrowStyle} />
- {hasRuns ? (
- <VStack align="start" gap={2}>
- <Text fontSize="sm" fontWeight="medium">
- {date}
- </Text>
- <VStack align="start" gap={1.5}>
- {states.map(({ color, count, state }) => (
- <HStack gap={3} key={state}>
- <Box
- bg={color}
- border="1px solid"
- borderColor="border.emphasized"
- borderRadius={SQUARE_BORDER_RADIUS}
- height={SQUARE_SIZE}
- width={SQUARE_SIZE}
- />
- <Text fontSize="xs">
- {count} {state}
- </Text>
- </HStack>
- ))}
- </VStack>
- </VStack>
- ) : (
- <Text fontSize="sm">
- {/* To do: remove fallback translations */}
- {date}: {viewMode === "failed" ? translate("calendar.noFailedRuns")
: translate("calendar.noRuns")}
- </Text>
- )}
- </div>
+ return hasRuns ? (
+ <VStack align="start" gap={2}>
+ <Text fontSize="sm" fontWeight="medium">
+ {date}
+ </Text>
+ <VStack align="start" gap={1.5}>
+ {states.map(({ color, count, state }) => (
+ <HStack gap={3} key={state}>
+ <Box
+ bg={color}
+ border="1px solid"
+ borderColor="border.emphasized"
+ borderRadius={SQUARE_BORDER_RADIUS}
+ height={SQUARE_SIZE}
+ width={SQUARE_SIZE}
+ />
+ <Text fontSize="xs">
+ {count} {state}
+ </Text>
+ </HStack>
+ ))}
+ </VStack>
+ </VStack>
+ ) : (
+ <Text fontSize="sm">
+ {date}: {viewMode === "failed" ? translate("calendar.noFailedRuns") :
translate("calendar.noRuns")}
+ </Text>
);
};