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

Reply via email to