From 94c3699126bedfecbc4ff38f4f5c5c0d7fe5856e Mon Sep 17 00:00:00 2001 From: "Guan Ming(Wesley) Chiu" <105915352+guan404ming@users.noreply.github.com> Date: Tue, 23 Sep 2025 23:49:25 +0800 Subject: [PATCH] Replace Chart.js default tooltip with a custom Chakra UI tooltip --- .../ui/src/layouts/Details/Gantt/Gantt.tsx | 52 +++++++++-- .../layouts/Details/Gantt/GanttTooltip.tsx | 86 +++++++++++++++++++ .../ui/src/layouts/Details/Gantt/index.ts | 1 + .../ui/src/layouts/Details/Gantt/utils.ts | 27 +----- 4 files changed, 135 insertions(+), 31 deletions(-) create mode 100644 airflow-core/src/airflow/ui/src/layouts/Details/Gantt/GanttTooltip.tsx diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx index fa5b4ccc6f87b..9c435b8ed18d8 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx @@ -31,14 +31,15 @@ import { Tooltip, Legend, TimeScale, + type ChartEvent, + type ActiveElement, } from "chart.js"; import "chart.js/auto"; import "chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm"; import annotationPlugin from "chartjs-plugin-annotation"; import dayjs from "dayjs"; -import { useMemo, useDeferredValue } from "react"; +import { useMemo, useDeferredValue, useState } from "react"; import { Bar } from "react-chartjs-2"; -import { useTranslation } from "react-i18next"; import { useParams, useNavigate, useLocation } from "react-router-dom"; import { useTaskInstanceServiceGetTaskInstances } from "openapi/queries"; @@ -55,6 +56,7 @@ import { getComputedCSSVariableValue } from "src/theme"; import { isStatePending, useAutoRefresh } from "src/utils"; import { DEFAULT_DATETIME_FORMAT_WITH_TZ, formatDate } from "src/utils/datetimeUtils"; +import { GanttTooltip } from "./GanttTooltip"; import { createHandleBarClick, createHandleBarHover, createChartOptions } from "./utils"; ChartJS.register( @@ -86,13 +88,22 @@ export const Gantt = ({ dagRunState, limit, runType, triggeringUser }: Props) => const { dagId = "", groupId: selectedGroupId, runId = "", taskId: selectedTaskId } = useParams(); const { openGroupIds } = useOpenGroups(); const deferredOpenGroupIds = useDeferredValue(openGroupIds); - const { t: translate } = useTranslation("common"); const { selectedTimezone } = useTimezone(); const { colorMode } = useColorMode(); const { hoveredTaskId, setHoveredTaskId } = useHover(); const navigate = useNavigate(); const location = useLocation(); + const [tooltipData, setTooltipData] = useState<{ + taskId: string | undefined; + x: number; + y: number; + }>({ + taskId: undefined, + x: 0, + y: 0, + }); + const [ lightGridColor, darkGridColor, @@ -245,20 +256,40 @@ export const Gantt = ({ dagRunState, limit, runType, triggeringUser }: Props) => [data, setHoveredTaskId], ); + const handleCustomBarHover = useMemo( + () => (event: ChartEvent, elements: Array) => { + handleBarHover(event, elements); + + if (elements.length > 0 && elements[0] && event.native) { + const hoveredData = data[elements[0].index]; + + if (hoveredData?.taskId !== undefined) { + setTooltipData({ + taskId: hoveredData.taskId, + x: (event.native as MouseEvent).clientX + 10, + y: (event.native as MouseEvent).clientY - 10, + }); + } + } else { + setTooltipData((prev) => ({ ...prev, taskId: undefined })); + } + }, + [data, handleBarHover], + ); + const chartOptions = useMemo( () => createChartOptions({ data, gridColor, handleBarClick, - handleBarHover, + handleBarHover: handleCustomBarHover, hoveredId: hoveredTaskId, hoveredItemColor, selectedId, selectedItemColor, selectedRun, selectedTimezone, - translate, }), [ data, @@ -269,9 +300,8 @@ export const Gantt = ({ dagRunState, limit, runType, triggeringUser }: Props) => gridColor, selectedRun, selectedTimezone, - translate, handleBarClick, - handleBarHover, + handleCustomBarHover, ], ); @@ -281,6 +311,7 @@ export const Gantt = ({ dagRunState, limit, runType, triggeringUser }: Props) => const handleChartMouseLeave = () => { setHoveredTaskId(undefined); + setTooltipData((prev) => ({ ...prev, taskId: undefined })); // Clear all hover styles when mouse leaves the chart area const allTasks = document.querySelectorAll('[id*="-"]'); @@ -306,6 +337,13 @@ export const Gantt = ({ dagRunState, limit, runType, triggeringUser }: Props) => paddingTop: flatNodes.length === 1 ? 15 : 1.5, }} /> + ); }; diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/GanttTooltip.tsx b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/GanttTooltip.tsx new file mode 100644 index 0000000000000..6be8061f6868f --- /dev/null +++ b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/GanttTooltip.tsx @@ -0,0 +1,86 @@ +/*! + * 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, Text, VStack } from "@chakra-ui/react"; +import { useTranslation } from "react-i18next"; + +import { getDuration } from "src/utils"; +import { formatDate } from "src/utils/datetimeUtils"; + +import type { GanttDataItem } from "./utils"; + +type GanttTooltipProps = { + readonly data: Array; + readonly selectedTimezone: string; + readonly taskId?: string; + readonly x: number; + readonly y: number; +}; + +export const GanttTooltip = ({ data, selectedTimezone, taskId, x, y }: GanttTooltipProps) => { + const { t: translate } = useTranslation("common"); + + if (taskId === undefined) { + return undefined; + } + + const taskInstance = data.find((dataItem) => dataItem.taskId === taskId); + + if (!taskInstance) { + return undefined; + } + + const startDate = formatDate(taskInstance.x[0], selectedTimezone); + const endDate = formatDate(taskInstance.x[1], selectedTimezone); + const duration = getDuration(taskInstance.x[0], taskInstance.x[1]); + + return ( + + + + {taskInstance.taskId} + + + {translate("state")}: {translate(`states.${taskInstance.state}`)} + + + {translate("startDate")}: {startDate} + + + {translate("endDate")}: {endDate} + + + {translate("duration")}: {duration} + + + + ); +}; diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/index.ts b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/index.ts index 24f6dabe4cfe7..f52302f81c95e 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/index.ts +++ b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/index.ts @@ -18,3 +18,4 @@ */ export * from "./Gantt"; +export * from "./GanttTooltip"; diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/utils.ts b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/utils.ts index d3cab195c196b..703a38f548b01 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/utils.ts +++ b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/utils.ts @@ -16,13 +16,12 @@ * specific language governing permissions and limitations * under the License. */ -import type { ChartEvent, ActiveElement, TooltipItem } from "chart.js"; +import type { ChartEvent, ActiveElement } from "chart.js"; import dayjs from "dayjs"; -import type { TFunction } from "i18next"; import type { NavigateFunction, Location } from "react-router-dom"; import type { GridRunsResponse, TaskInstanceState } from "openapi/requests"; -import { getDuration, isStatePending } from "src/utils"; +import { isStatePending } from "src/utils"; import { formatDate } from "src/utils/datetimeUtils"; import { buildTaskInstanceUrl } from "src/utils/links"; @@ -54,7 +53,6 @@ type ChartOptionsParams = { selectedItemColor?: string; selectedRun?: GridRunsResponse; selectedTimezone: string; - translate: TFunction; }; export const createHandleBarClick = @@ -138,7 +136,6 @@ export const createChartOptions = ({ selectedItemColor, selectedRun, selectedTimezone, - translate, }: ChartOptionsParams) => { const isActivePending = isStatePending(selectedRun?.state); const effectiveEndDate = isActivePending @@ -201,25 +198,7 @@ export const createChartOptions = ({ display: false, }, tooltip: { - callbacks: { - afterBody(tooltipItems: Array>) { - const taskInstance = data.find((dataItem) => dataItem.y === tooltipItems[0]?.label); - const startDate = formatDate(taskInstance?.x[0], selectedTimezone); - const endDate = formatDate(taskInstance?.x[1], selectedTimezone); - - return [ - `${translate("startDate")}: ${startDate}`, - `${translate("endDate")}: ${endDate}`, - `${translate("duration")}: ${getDuration(taskInstance?.x[0], taskInstance?.x[1])}`, - ]; - }, - label(tooltipItem: TooltipItem<"bar">) { - const { label } = tooltipItem; - const taskInstance = data.find((dataItem) => dataItem.y === label); - - return `${translate("state")}: ${translate(`states.${taskInstance?.state}`)}`; - }, - }, + enabled: false, }, }, resizeDelay: 100,