Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions airflow-core/src/airflow/ui/src/components/HoverTooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*!
* 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>}
</>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ export const Calendar = () => {
<Box
animation={`${spin} 1s linear infinite`}
border="3px solid"
borderColor={{ _dark: "border.emphasized", _light: "brand.100" }}
borderColor={{ _dark: "none.600", _light: "brand.100" }}
borderRadius="50%"
borderTopColor="brand.500"
height="24px"
Expand All @@ -248,7 +248,12 @@ export const Calendar = () => {
) : undefined}
{granularity === "daily" ? (
<>
<DailyCalendarView data={data?.dag_runs ?? []} scale={scale} selectedYear={selectedDate.year()} />
<DailyCalendarView
data={data?.dag_runs ?? []}
scale={scale}
selectedYear={selectedDate.year()}
viewMode={viewMode}
/>
<CalendarLegend scale={scale} viewMode={viewMode} />
</>
) : (
Expand All @@ -259,6 +264,7 @@ export const Calendar = () => {
scale={scale}
selectedMonth={selectedDate.month()}
selectedYear={selectedDate.year()}
viewMode={viewMode}
/>
</Box>
<Box display="flex" flex="1" justifyContent="center" pt={16}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,38 +17,56 @@
* under the License.
*/
import { Box } from "@chakra-ui/react";
import type React from "react";

import { HoverTooltip } from "src/components/HoverTooltip";

import { CalendarTooltip } from "./CalendarTooltip";
import { CalendarTooltipContent } from "./CalendarTooltipContent";
import type { CalendarCellData } from "./types";
import { useDelayedTooltip } from "./useDelayedTooltip";
import type { CalendarCellData, CalendarColorMode } from "./types";

type Props = {
readonly backgroundColor: Record<string, string> | string;
readonly cellData?: CalendarCellData; // For rich tooltip content
readonly cellData: CalendarCellData | undefined;
readonly index?: number;
readonly marginRight?: string;
readonly viewMode?: CalendarColorMode;
};

export const CalendarCell = ({ backgroundColor, cellData, index, marginRight }: Props) => {
const { handleMouseEnter, handleMouseLeave } = useDelayedTooltip();
const renderTooltip =
(cellData: CalendarCellData | undefined, viewMode: CalendarColorMode) =>
(triggerRef: React.RefObject<HTMLElement>) => (
<CalendarTooltip cellData={cellData} triggerRef={triggerRef} viewMode={viewMode} />
);

export const CalendarCell = ({
backgroundColor,
cellData,
index,
marginRight,
viewMode = "total",
}: Props) => {
const computedMarginRight = marginRight ?? (index !== undefined && index % 7 === 6 ? "8px" : "0");

return (
<Box onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} position="relative">
<Box
_hover={{ transform: "scale(1.1)" }}
bg={backgroundColor}
border="1px solid"
borderColor="border.emphasized"
borderRadius="2px"
cursor="pointer"
height="14px"
marginRight={computedMarginRight}
width="14px"
/>
<CalendarTooltip content={cellData ? <CalendarTooltipContent cellData={cellData} /> : ""} />
</Box>
const relevantCount =
viewMode === "failed" ? (cellData?.counts.failed ?? 0) : (cellData?.counts.total ?? 0);
const hasData = Boolean(cellData && relevantCount > 0);
const hasTooltip = Boolean(cellData);

const cellBox = (
<Box
_hover={hasData ? { transform: "scale(1.1)" } : {}}
bg={backgroundColor}
borderRadius="2px"
cursor={hasData ? "pointer" : "default"}
height="14px"
marginRight={computedMarginRight}
width="14px"
/>
);

if (!hasTooltip) {
return cellBox;
}

return <HoverTooltip tooltip={renderTooltip(cellData, viewMode)}>{cellBox}</HoverTooltip>;
};
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export const CalendarLegend = ({ scale, vertical = false, viewMode }: Props) =>
<HStack gap={4} justify="center" wrap="wrap">
<HStack gap={2}>
<Box
bg={{ _dark: "scheduled.600", _light: "scheduled.200" }}
bg={{ _dark: "stone.600", _light: "stone.200" }}
borderRadius="2px"
boxShadow="sm"
height="14px"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,49 +16,58 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Box, HStack, Text, VStack } from "@chakra-ui/react";
import { useMemo } from "react";
import type { ReactNode } from "react";
import type { RefObject } from "react";
import { useTranslation } from "react-i18next";

const TOOLTIP_MIN_WIDTH = "200px";
const TOOLTIP_MAX_WIDTH = "350px";
const TOOLTIP_MAX_HEIGHT = "200px";
const TOOLTIP_PADDING = "12px";
import type { CalendarCellData, CalendarColorMode } from "./types";

const SQUARE_SIZE = "12px";
const SQUARE_BORDER_RADIUS = "2px";

type Props = {
readonly content: ReactNode;
readonly cellData: CalendarCellData | undefined;
readonly triggerRef: RefObject<HTMLElement>;
readonly viewMode?: CalendarColorMode;
};

export const CalendarTooltip = ({ content }: Props) => {
const tooltipStyle = useMemo(
() => ({
backgroundColor: "var(--chakra-colors-bg-muted)",
border: "1px solid var(--chakra-colors-border-emphasized)",
const stateColorMap = {
failed: "failed.solid",
planned: "stone.solid",
running: "running.solid",
success: "success.solid",
};

export const CalendarTooltip = ({ cellData, triggerRef, 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: "fg",
color: "var(--chakra-colors-fg-inverted)",
fontSize: "14px",
left: "50%",
maxHeight: TOOLTIP_MAX_HEIGHT,
maxWidth: TOOLTIP_MAX_WIDTH,
minWidth: TOOLTIP_MIN_WIDTH,
opacity: 0,
overflowY: "auto" as const,
padding: TOOLTIP_PADDING,
pointerEvents: "none" as const,
left: `${rect.left + globalThis.scrollX + rect.width / 2}px`,
minWidth: "200px",
padding: "8px",
position: "absolute" as const,
top: "22px",
top: `${rect.bottom + globalThis.scrollY + 8}px`,
transform: "translateX(-50%)",
transition: "opacity 0.2s, visibility 0.2s",
visibility: "hidden" as const,
whiteSpace: "normal" as const,
width: "auto",
whiteSpace: "nowrap" as const,
zIndex: 1000,
}),
[],
);
};
}, [triggerRef]);

const arrowStyle = useMemo(
() => ({
borderBottom: "4px solid var(--chakra-colors-bg-muted)",
borderBottom: "4px solid var(--chakra-colors-bg-inverted)",
borderLeft: "4px solid transparent",
borderRight: "4px solid transparent",
content: '""',
Expand All @@ -72,10 +81,71 @@ export const CalendarTooltip = ({ content }: Props) => {
[],
);

if (!cellData) {
return undefined;
}

const { counts, date } = cellData;

const relevantCount = viewMode === "failed" ? counts.failed : counts.total;
const hasRuns = relevantCount > 0;

// In failed mode, only show failed runs; in total mode, show all non-zero states
const states = Object.entries(counts)
.filter(([key, value]) => {
if (key === "total") {
return false;
}
if (value === 0) {
return false;
}
if (viewMode === "failed") {
return key === "failed";
}

return true;
})
.map(([state, count]) => ({
color: stateColorMap[state as keyof typeof stateColorMap] || "gray.500",
count,
state: translate(`common:states.${state}`),
}));

return (
<div data-tooltip style={tooltipStyle}>
<div style={tooltipStyle}>
<div style={arrowStyle} />
{content}
{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", "No failed runs")
: translate("calendar.noRuns", "No runs")}
</Text>
)}
</div>
);
};
Loading