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..96400e5d5f053 --- /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(null); + const tooltipRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + const [showOnTop, setShowOnTop] = useState(false); + const timeoutRef = useRef(); + + 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} + + + + {content} + + + + ); +}; diff --git a/airflow-core/src/airflow/ui/src/components/DagRunInfo.tsx b/airflow-core/src/airflow/ui/src/components/DagRunInfo.tsx index 0fa1cab76585a..fe6370fb8ddf4 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) {state === undefined ? undefined : ( - {translate("state")}: {state} + {translate("state")}: {translate(`common:states.${state}`)} )} {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 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/components/TaskInstanceTooltip.tsx b/airflow-core/src/airflow/ui/src/components/TaskInstanceTooltip.tsx index 025463dc955f6..a3494ee4a0b01 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={ - {translate("state")}: {taskInstance.state} + {translate("state")}:{" "} + {taskInstance.state + ? translate(`common:states.${taskInstance.state}`) + : translate("common:states.no_status")} {"dag_run_id" in taskInstance ? ( 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 f3dd0b3dc077f..c452cf43a7953 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 ? ( - - {children} - - ) : ( - +}: Props) => { + const { t: translate } = useTranslation(); + + const tooltipContent = ( + <> + {label} +
+ {translate("state")}:{" "} + {state ? translate(`common:states.${state}`) : translate("common:states.no_status")} + + ); + + return isGroup ? ( + {children} - + + ) : ( + + + + {children} + + + ); +}; 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..d4d775da97771 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" > - + {translate("taskId")}: {taskId} +
+ {translate("state")}:{" "} + {instance.state + ? translate(`common:states.${instance.state}`) + : translate("common:states.no_status")} + {instance.min_start_date !== null && ( + <> +
+ {translate("startDate")}: