From e5c5179c0b6a3c0ee7c470e8bd12628efb76e57c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=96=86=E5=AE=B8?= Date: Sat, 15 Nov 2025 23:44:46 -0500 Subject: [PATCH 1/9] refactor: replace hovertooltip with basictooltip --- .../ui/src/components/BasicTooltip.tsx | 257 ++++++++++++++++++ .../ui/src/components/HoverTooltip.tsx | 63 ----- .../src/pages/Dag/Calendar/CalendarCell.tsx | 15 +- .../pages/Dag/Calendar/CalendarTooltip.tsx | 104 ++----- 4 files changed, 291 insertions(+), 148 deletions(-) create mode 100644 airflow-core/src/airflow/ui/src/components/BasicTooltip.tsx delete mode 100644 airflow-core/src/airflow/ui/src/components/HoverTooltip.tsx 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 0000000000000..a723c04fcee54 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/components/BasicTooltip.tsx @@ -0,0 +1,257 @@ +/*! + * 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 type { CSSProperties, ReactElement, ReactNode } from "react"; +import { cloneElement, useCallback, useEffect, useMemo, useRef, useState } from "react"; + +export type TooltipPlacement = + | "bottom-end" + | "bottom-start" + | "bottom" + | "left" + | "right" + | "top-end" + | "top-start" + | "top"; + +type Props = { + readonly children: ReactElement; + readonly content: ReactNode; + readonly placement?: TooltipPlacement; +}; + +const calculatePosition = ( + rect: DOMRect, + placement: TooltipPlacement, + offset: number, +): { left: string; top: string; transform: string } => { + const { bottom, height, left, right, top, width } = rect; + const { scrollX, scrollY } = globalThis; + + switch (placement) { + case "bottom": + return { + left: `${left + scrollX + width / 2}px`, + top: `${bottom + scrollY + offset}px`, + transform: "translateX(-50%)", + }; + + case "bottom-end": + return { + left: `${right + scrollX}px`, + top: `${bottom + scrollY + offset}px`, + transform: "translateX(-100%)", + }; + + case "bottom-start": + return { + left: `${left + scrollX}px`, + top: `${bottom + scrollY + offset}px`, + transform: "none", + }; + + case "left": + return { + left: `${left + scrollX - offset}px`, + top: `${top + scrollY + height / 2}px`, + transform: "translate(-100%, -50%)", + }; + + case "right": + return { + left: `${right + scrollX + offset}px`, + top: `${top + scrollY + height / 2}px`, + transform: "translateY(-50%)", + }; + + case "top": + return { + left: `${left + scrollX + width / 2}px`, + top: `${top + scrollY - offset}px`, + transform: "translate(-50%, -100%)", + }; + + case "top-end": + return { + left: `${right + scrollX}px`, + top: `${top + scrollY - offset}px`, + transform: "translate(-100%, -100%)", + }; + + case "top-start": + return { + left: `${left + scrollX}px`, + top: `${top + scrollY - offset}px`, + transform: "translateY(-100%)", + }; + + default: + return { + left: `${left + scrollX + width / 2}px`, + top: `${top + scrollY - offset}px`, + transform: "translate(-50%, -100%)", + }; + } +}; + +const getArrowStyle = (placement: TooltipPlacement): CSSProperties => { + const baseStyle: CSSProperties = { + content: '""', + height: 0, + position: "absolute", + width: 0, + }; + + switch (placement) { + case "bottom": + case "bottom-end": + case "bottom-start": + return { + ...baseStyle, + borderBottom: "4px solid var(--chakra-colors-bg-inverted)", + borderLeft: "4px solid transparent", + borderRight: "4px solid transparent", + left: placement === "bottom" ? "50%" : placement === "bottom-start" ? "12px" : undefined, + right: placement === "bottom-end" ? "12px" : undefined, + top: "-4px", + transform: placement === "bottom" ? "translateX(-50%)" : undefined, + }; + + case "left": + return { + ...baseStyle, + borderBottom: "4px solid transparent", + borderLeft: "4px solid var(--chakra-colors-bg-inverted)", + borderTop: "4px solid transparent", + right: "-4px", + top: "50%", + transform: "translateY(-50%)", + }; + + case "right": + return { + ...baseStyle, + borderBottom: "4px solid transparent", + borderRight: "4px solid var(--chakra-colors-bg-inverted)", + borderTop: "4px solid transparent", + left: "-4px", + top: "50%", + transform: "translateY(-50%)", + }; + + case "top": + case "top-end": + case "top-start": + return { + ...baseStyle, + borderLeft: "4px solid transparent", + borderRight: "4px solid transparent", + borderTop: "4px solid var(--chakra-colors-bg-inverted)", + bottom: "-4px", + left: placement === "top" ? "50%" : placement === "top-start" ? "12px" : undefined, + right: placement === "top-end" ? "12px" : undefined, + transform: placement === "top" ? "translateX(-50%)" : undefined, + }; + + default: + return baseStyle; + } +}; + +export const BasicTooltip = ({ children, content, placement = "bottom" }: Props): ReactElement => { + const triggerRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + const timeoutRef = useRef(); + + const offset = 8; + const zIndex = 1500; + + 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); + }, []); + + // Cleanup on unmount + useEffect( + () => () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }, + [], + ); + + const tooltipStyle = useMemo(() => { + if (!isOpen || !triggerRef.current) { + return { display: "none" }; + } + + const rect = triggerRef.current.getBoundingClientRect(); + const position = calculatePosition(rect, placement, offset); + + return { + ...position, + backgroundColor: "var(--chakra-colors-bg-inverted)", + borderRadius: "4px", + boxShadow: "0 2px 8px rgba(0, 0, 0, 0.15)", + color: "var(--chakra-colors-fg-inverted)", + fontSize: "14px", + padding: "8px 12px", + pointerEvents: "none" as const, + position: "absolute" as const, + whiteSpace: "nowrap" as const, + zIndex, + }; + }, [isOpen, placement, offset, zIndex]); + + const arrowStyle = useMemo(() => getArrowStyle(placement), [placement]); + + // Clone children and attach event handlers + ref + const trigger = cloneElement(children, { + onMouseEnter: handleMouseEnter, + onMouseLeave: handleMouseLeave, + ref: triggerRef, + }); + + return ( + <> + {trigger} + {Boolean(isOpen) && ( + +
+
+ {content ?? undefined} +
+ + )} + + ); +}; 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 46466858caec6..0000000000000 --- 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) => ReactNode; -}; - -export const HoverTooltip = ({ children, delayMs = 200, tooltip }: Props) => { - const triggerRef = useRef(null); - const [isOpen, setIsOpen] = useState(false); - const timeoutRef = useRef(); - - 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) && {tooltip(triggerRef)}} - - ); -}; 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 4428a2b4e4246..952aaa77367cc 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) => ( - - ); - export const CalendarCell = ({ backgroundColor, cellData, @@ -103,5 +96,9 @@ export const CalendarCell = ({ return cellBox; } - return {cellBox}; + return ( + } placement="bottom"> + {cellBox} + + ); }; 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 61f65e9b8769f..2edad9f62c24a 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; 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 ( -
-
- {hasRuns ? ( - - - {date} - - - {states.map(({ color, count, state }) => ( - - - - {count} {state} - - - ))} - - - ) : ( - - {/* To do: remove fallback translations */} - {date}: {viewMode === "failed" ? translate("calendar.noFailedRuns") : translate("calendar.noRuns")} - - )} -
+ return hasRuns ? ( + + + {date} + + + {states.map(({ color, count, state }) => ( + + + + {count} {state} + + + ))} + + + ) : ( + + {date}: {viewMode === "failed" ? translate("calendar.noFailedRuns") : translate("calendar.noRuns")} + ); }; From 3a6ba7dc055cc26cee673b58dd4f3968e76cd6be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=96=86=E5=AE=B8?= Date: Sat, 15 Nov 2025 23:50:14 -0500 Subject: [PATCH 2/9] feat: implement basic tooltip for grid TI --- .../ui/src/layouts/Details/Grid/GridTI.tsx | 62 +++++++++---------- 1 file changed, 31 insertions(+), 31 deletions(-) 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 ddf294353cb70..3228ee8b22c9a 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,35 @@ const Instance = ({ dagId, instance, isGroup, isMapped, onClick, runId, taskId } py={0} transition="background-color 0.2s" > - + {translate("taskId")}: {taskId} +
+ {translate("state")}: {instance.state} + {instance.min_start_date !== null && ( + <> +
+ {translate("startDate")}: