Skip to content
127 changes: 127 additions & 0 deletions airflow-core/src/airflow/ui/src/components/BasicTooltip.tsx
Original file line number Diff line number Diff line change
@@ -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>
</>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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) ? (
Expand Down
63 changes: 0 additions & 63 deletions airflow-core/src/airflow/ui/src/components/HoverTooltip.tsx

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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>
);
};
65 changes: 34 additions & 31 deletions airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridTI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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"
Expand All @@ -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>
);
};
Expand Down
Loading