From 13ea417c59c543b7ab6f45a2c7ed672098f9977f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=96=86=E5=AE=B8?= Date: Wed, 10 Sep 2025 15:30:22 -0400 Subject: [PATCH 1/6] fix: modify calendar UI for non-selected year cells --- .../ui/src/pages/Dag/Calendar/CalendarCell.tsx | 18 ++++++++++++------ .../pages/Dag/Calendar/DailyCalendarView.tsx | 10 +--------- 2 files changed, 13 insertions(+), 15 deletions(-) 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 fe06ea9377675..f4af3ec7b39fb 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 @@ -35,20 +35,26 @@ export const CalendarCell = ({ backgroundColor, cellData, index, marginRight }: const computedMarginRight = marginRight ?? (index !== undefined && index % 7 === 6 ? "8px" : "0"); + const hasData = Boolean(cellData); + return ( - + - : ""} /> + {cellData ? } /> : null} ); }; diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/DailyCalendarView.tsx b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/DailyCalendarView.tsx index c67b91a4726d6..0ac1787d2eac8 100644 --- a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/DailyCalendarView.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/DailyCalendarView.tsx @@ -107,15 +107,7 @@ export const DailyCalendarView = ({ data, scale, selectedYear }: Props) => { const isInSelectedYear = dayDate.year() === selectedYear; if (!isInSelectedYear) { - const emptyCellData = { - counts: { failed: 0, planned: 0, queued: 0, running: 0, success: 0, total: 0 }, - date: day.date, - runs: [], - }; - - return ( - - ); + return ; } return ( From 0bd325f6895cc22a29fd55c8da347b703a9e3f81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=96=86=E5=AE=B8?= Date: Fri, 12 Sep 2025 01:51:08 -0400 Subject: [PATCH 2/6] style: UI design roll back --- .../ui/src/pages/Dag/Calendar/Calendar.tsx | 2 +- .../src/pages/Dag/Calendar/CalendarCell.tsx | 12 ++-- .../src/pages/Dag/Calendar/CalendarLegend.tsx | 2 +- .../pages/Dag/Calendar/CalendarTooltip.tsx | 24 ++----- .../Dag/Calendar/CalendarTooltipContent.tsx | 68 ------------------- .../pages/Dag/Calendar/DailyCalendarView.tsx | 10 ++- .../pages/Dag/Calendar/HourlyCalendarView.tsx | 17 ++--- .../src/pages/Dag/Calendar/calendarUtils.ts | 7 +- .../pages/Dag/Calendar/richTooltipUtils.ts | 50 -------------- 9 files changed, 32 insertions(+), 160 deletions(-) delete mode 100644 airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarTooltipContent.tsx delete mode 100644 airflow-core/src/airflow/ui/src/pages/Dag/Calendar/richTooltipUtils.ts diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/Calendar.tsx b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/Calendar.tsx index 7267826516508..e4cd24c352395 100644 --- a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/Calendar.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/Calendar.tsx @@ -237,7 +237,7 @@ export const Calendar = () => { | string; - readonly cellData?: CalendarCellData; // For rich tooltip content + readonly content: string; readonly index?: number; readonly marginRight?: string; }; -export const CalendarCell = ({ backgroundColor, cellData, index, marginRight }: Props) => { +export const CalendarCell = ({ backgroundColor, content, index, marginRight }: Props) => { const { handleMouseEnter, handleMouseLeave } = useDelayedTooltip(); const computedMarginRight = marginRight ?? (index !== undefined && index % 7 === 6 ? "8px" : "0"); - const hasData = Boolean(cellData); + const hasData = Boolean(content); return ( - {cellData ? } /> : null} + ); }; diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarLegend.tsx b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarLegend.tsx index afb963e6c961e..86ed307f8d877 100644 --- a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarLegend.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarLegend.tsx @@ -84,7 +84,7 @@ export const CalendarLegend = ({ scale, vertical = false, viewMode }: Props) => { const tooltipStyle = useMemo( () => ({ - backgroundColor: "var(--chakra-colors-bg-muted)", - border: "1px solid var(--chakra-colors-border-emphasized)", + backgroundColor: "var(--chakra-colors-gray-800)", borderRadius: "4px", - color: "fg", + color: "white", 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, + padding: "8px", pointerEvents: "none" as const, position: "absolute" as const, top: "22px", 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, }), [], @@ -58,7 +46,7 @@ export const CalendarTooltip = ({ content }: Props) => { const arrowStyle = useMemo( () => ({ - borderBottom: "4px solid var(--chakra-colors-bg-muted)", + borderBottom: "4px solid var(--chakra-colors-gray-800)", borderLeft: "4px solid transparent", borderRight: "4px solid transparent", content: '""', diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarTooltipContent.tsx b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarTooltipContent.tsx deleted file mode 100644 index ebc483c40a4f3..0000000000000 --- a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarTooltipContent.tsx +++ /dev/null @@ -1,68 +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 { Box, HStack, Text, VStack } from "@chakra-ui/react"; -import { useTranslation } from "react-i18next"; - -import { createRichTooltipContent } from "./richTooltipUtils"; -import type { CalendarCellData } from "./types"; - -const SQUARE_SIZE = "12px"; -const SQUARE_BORDER_RADIUS = "2px"; - -type Props = { - readonly cellData: CalendarCellData; -}; - -export const CalendarTooltipContent = ({ cellData }: Props) => { - const { t: translate } = useTranslation("dag"); - const { date, hasRuns, states, total } = createRichTooltipContent(cellData); - - if (!hasRuns) { - return ( - - {date}: {translate("calendar.noRuns")} - - ); - } - - return ( - - - {date}: {total} {translate("calendar.runs")} - - - {states.map(({ color, count, state }) => ( - - - - {count} {state} - - - ))} - - - ); -}; diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/DailyCalendarView.tsx b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/DailyCalendarView.tsx index 0ac1787d2eac8..fae85e6165e48 100644 --- a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/DailyCalendarView.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/DailyCalendarView.tsx @@ -42,7 +42,7 @@ import { useTranslation } from "react-i18next"; import type { CalendarTimeRangeResponse } from "openapi/requests/types.gen"; import { CalendarCell } from "./CalendarCell"; -import { generateDailyCalendarData } from "./calendarUtils"; +import { createTooltipContent, generateDailyCalendarData } from "./calendarUtils"; import type { CalendarScale } from "./types"; type Props = { @@ -107,11 +107,15 @@ export const DailyCalendarView = ({ data, scale, selectedYear }: Props) => { const isInSelectedYear = dayDate.year() === selectedYear; if (!isInSelectedYear) { - return ; + return ; } return ( - + ); })} diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/HourlyCalendarView.tsx b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/HourlyCalendarView.tsx index 38bb50c50fc95..64903c9a19b60 100644 --- a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/HourlyCalendarView.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/HourlyCalendarView.tsx @@ -43,7 +43,7 @@ import { useTranslation } from "react-i18next"; import type { CalendarTimeRangeResponse } from "openapi/requests/types.gen"; import { CalendarCell } from "./CalendarCell"; -import { generateHourlyCalendarData } from "./calendarUtils"; +import { createTooltipContent, generateHourlyCalendarData } from "./calendarUtils"; import type { CalendarScale } from "./types"; dayjs.extend(isSameOrBefore); @@ -158,27 +158,28 @@ export const HourlyCalendarView = ({ data, scale, selectedMonth, selectedYear }: const hourData = day.hours.find((hourItem) => hourItem.hour === hour); if (!hourData) { + const noRunsTooltip = `${dayjs(day.day).format("MMM DD")}, ${hour.toString().padStart(2, "0")}:00 - ${translate("calendar.noRuns")}`; const emptyCounts = { failed: 0, planned: 0, queued: 0, running: 0, success: 0, total: 0 }; - const emptyCellData = { - counts: emptyCounts, - date: `${day.day}T${hour.toString().padStart(2, "0")}:00:00`, - runs: [], - }; return ( ); } + const tooltipContent = + hourData.counts.total > 0 + ? `${dayjs(day.day).format("MMM DD")}, ${hour.toString().padStart(2, "0")}:00 - ${createTooltipContent(hourData).split(": ")[1]}` + : `${dayjs(day.day).format("MMM DD")}, ${hour.toString().padStart(2, "0")}:00 - ${translate("calendar.noRuns")}`; + return ( diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/calendarUtils.ts b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/calendarUtils.ts index 7f145f41c220d..4aded47991c91 100644 --- a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/calendarUtils.ts +++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/calendarUtils.ts @@ -36,7 +36,7 @@ dayjs.extend(isSameOrBefore); // Calendar color constants const EMPTY_COLOR = { _dark: "gray.700", _light: "gray.100" }; -const PLANNED_COLOR = { _dark: "scheduled.600", _light: "scheduled.200" }; +const PLANNED_COLOR = { _dark: "stone.600", _light: "stone.200" }; const TOTAL_COLOR_INTENSITIES = [ EMPTY_COLOR, // 0 @@ -220,7 +220,8 @@ export const createCalendarScale = ( // Handle single value case if (minCount === maxCount) { - const singleColor = viewMode === "total" ? TOTAL_COLOR_INTENSITIES[2]! : FAILURE_COLOR_INTENSITIES[2]!; + const singleColor = + (viewMode === "total" ? TOTAL_COLOR_INTENSITIES[2] : FAILURE_COLOR_INTENSITIES[2]) ?? EMPTY_COLOR; return { getColor: (counts: RunCounts) => { @@ -294,7 +295,7 @@ export const createCalendarScale = ( label = `${threshold}-${nextThreshold - 1}`; } - const color = colorScheme[Math.min(index, colorScheme.length - 1)]!; + const color = colorScheme[Math.min(index, colorScheme.length - 1)] ?? EMPTY_COLOR; legendItems.push({ color, diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/richTooltipUtils.ts b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/richTooltipUtils.ts deleted file mode 100644 index 717ee73e03c19..0000000000000 --- a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/richTooltipUtils.ts +++ /dev/null @@ -1,50 +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 dayjs from "dayjs"; - -import type { CalendarCellData } from "./types"; - -export const createRichTooltipContent = (cellData: CalendarCellData) => { - const { counts, date } = cellData; - const hasRuns = counts.total > 0; - - if (!hasRuns) { - return { - date: dayjs(date).format("MMM DD, YYYY"), - hasRuns: false, - states: [], - total: 0, - }; - } - - const states = Object.entries(counts) - .filter(([key, value]) => key !== "total" && value > 0) - .map(([state, count]) => ({ - color: `var(--chakra-colors-${state}-solid)`, - count, - state, - })); - - return { - date: dayjs(date).format("MMM DD, YYYY"), - hasRuns: true, - states, - total: counts.total, - }; -}; From c943049ec494db2c189e0c3af0f34de1bcc43ae9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=96=86=E5=AE=B8?= Date: Fri, 12 Sep 2025 03:57:01 -0400 Subject: [PATCH 3/6] feat(ui): enhance calendar tooltip with rich content and filter support --- .../ui/public/i18n/locales/en/dag.json | 3 + .../ui/public/i18n/locales/zh-TW/dag.json | 3 + .../ui/src/pages/Dag/Calendar/Calendar.tsx | 8 +- .../src/pages/Dag/Calendar/CalendarCell.tsx | 23 +++-- .../pages/Dag/Calendar/CalendarTooltip.tsx | 84 ++++++++++++++++++- .../pages/Dag/Calendar/DailyCalendarView.tsx | 19 +++-- .../pages/Dag/Calendar/HourlyCalendarView.tsx | 33 +++++--- .../src/pages/Dag/Calendar/calendarUtils.ts | 15 ---- 8 files changed, 148 insertions(+), 40 deletions(-) diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json b/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json index fd1eef88f9518..afc6c5cc086d0 100644 --- a/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json +++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json @@ -7,6 +7,8 @@ }, "calendar": { "daily": "Daily", + "failedRun_one": "1 Failed Run}", + "failedRun_other": "{{count}} Failed Runs", "hourly": "Hourly", "legend": { "less": "Less", @@ -19,6 +21,7 @@ "previousYear": "Previous year" }, "noData": "No data available", + "noFailedRuns": "No failed runs", "noRuns": "No runs", "totalRuns": "Total Runs", "week": "Week {{weekNumber}}", diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/dag.json b/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/dag.json index f1e3c8630982b..6a21e2caca9d8 100644 --- a/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/dag.json +++ b/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/dag.json @@ -7,6 +7,8 @@ }, "calendar": { "daily": "每日", + "failedRun_one": "1 個失敗的執行", + "failedRun_other": "{{count}} 個失敗的執行", "hourly": "每小時", "legend": { "less": "較少", @@ -19,6 +21,7 @@ "previousYear": "上一年" }, "noData": "沒有可用的資料", + "noFailedRuns": "沒有失敗的執行", "noRuns": "沒有執行", "totalRuns": "總執行數", "week": "第 {{weekNumber}} 週", diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/Calendar.tsx b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/Calendar.tsx index e4cd24c352395..ca50957db7126 100644 --- a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/Calendar.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/Calendar.tsx @@ -248,7 +248,12 @@ export const Calendar = () => { ) : undefined} {granularity === "daily" ? ( <> - + ) : ( @@ -259,6 +264,7 @@ export const Calendar = () => { scale={scale} selectedMonth={selectedDate.month()} selectedYear={selectedDate.year()} + viewMode={viewMode} /> 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 81ffef4eda21a..4fb20f7dbaf38 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 @@ -19,26 +19,37 @@ import { Box } from "@chakra-ui/react"; import { CalendarTooltip } from "./CalendarTooltip"; +import type { CalendarCellData, CalendarColorMode } from "./types"; import { useDelayedTooltip } from "./useDelayedTooltip"; type Props = { readonly backgroundColor: Record | string; - readonly content: string; + readonly cellData: CalendarCellData | null; readonly index?: number; readonly marginRight?: string; + readonly viewMode?: CalendarColorMode; }; -export const CalendarCell = ({ backgroundColor, content, index, marginRight }: Props) => { +export const CalendarCell = ({ + backgroundColor, + cellData, + index, + marginRight, + viewMode = "total", +}: Props) => { const { handleMouseEnter, handleMouseLeave } = useDelayedTooltip(); const computedMarginRight = marginRight ?? (index !== undefined && index % 7 === 6 ? "8px" : "0"); - const hasData = Boolean(content); + const relevantCount = + viewMode === "failed" ? (cellData?.counts.failed ?? 0) : (cellData?.counts.total ?? 0); + const hasData = Boolean(cellData && relevantCount > 0); + const hasTooltip = Boolean(cellData); // Show tooltip if we have cell data (even if no runs) return ( - + ); }; 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 16559614210ed..fa794ee5aba96 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 @@ -16,13 +16,29 @@ * specific language governing permissions and limitations * under the License. */ +import { Box, HStack, Text, VStack } from "@chakra-ui/react"; import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; + +import type { CalendarCellData, CalendarColorMode } from "./types"; + +const SQUARE_SIZE = "12px"; +const SQUARE_BORDER_RADIUS = "2px"; type Props = { - readonly content: string; + readonly cellData: CalendarCellData | null; + readonly viewMode?: CalendarColorMode; }; -export const CalendarTooltip = ({ content }: Props) => { +const stateColorMap = { + failed: "red.500", + planned: "stone.500", + success: "green.500", +}; + +export const CalendarTooltip = ({ cellData, viewMode = "total" }: Props) => { + const { t: translate } = useTranslation(["dag", "common"]); + const tooltipStyle = useMemo( () => ({ backgroundColor: "var(--chakra-colors-gray-800)", @@ -30,6 +46,7 @@ export const CalendarTooltip = ({ content }: Props) => { color: "white", fontSize: "14px", left: "50%", + minWidth: "200px", opacity: 0, padding: "8px", pointerEvents: "none" as const, @@ -60,10 +77,71 @@ export const CalendarTooltip = ({ content }: Props) => { [], ); + if (!cellData) { + return ( +
+
+
+ ); + } + + 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 (
- {content} + {hasRuns ? ( + + + {date} + + + {states.map(({ color, count, state }) => ( + + + + {count} {state} + + + ))} + + + ) : ( + + {date}: {viewMode === "failed" ? translate("calendar.noFailedRuns") : translate("calendar.noRuns")} + + )}
); }; diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/DailyCalendarView.tsx b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/DailyCalendarView.tsx index fae85e6165e48..f3a71b555693e 100644 --- a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/DailyCalendarView.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/DailyCalendarView.tsx @@ -42,16 +42,17 @@ import { useTranslation } from "react-i18next"; import type { CalendarTimeRangeResponse } from "openapi/requests/types.gen"; import { CalendarCell } from "./CalendarCell"; -import { createTooltipContent, generateDailyCalendarData } from "./calendarUtils"; -import type { CalendarScale } from "./types"; +import { generateDailyCalendarData } from "./calendarUtils"; +import type { CalendarScale, CalendarColorMode } from "./types"; type Props = { readonly data: Array; readonly scale: CalendarScale; readonly selectedYear: number; + readonly viewMode?: CalendarColorMode; }; -export const DailyCalendarView = ({ data, scale, selectedYear }: Props) => { +export const DailyCalendarView = ({ data, scale, selectedYear, viewMode = "total" }: Props) => { const { t: translate } = useTranslation("dag"); const dailyData = generateDailyCalendarData(data, selectedYear); @@ -107,14 +108,22 @@ export const DailyCalendarView = ({ data, scale, selectedYear }: Props) => { const isInSelectedYear = dayDate.year() === selectedYear; if (!isInSelectedYear) { - return ; + return ( + + ); } return ( ); })} diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/HourlyCalendarView.tsx b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/HourlyCalendarView.tsx index 64903c9a19b60..0979f5efcb6f4 100644 --- a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/HourlyCalendarView.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/HourlyCalendarView.tsx @@ -43,8 +43,8 @@ import { useTranslation } from "react-i18next"; import type { CalendarTimeRangeResponse } from "openapi/requests/types.gen"; import { CalendarCell } from "./CalendarCell"; -import { createTooltipContent, generateHourlyCalendarData } from "./calendarUtils"; -import type { CalendarScale } from "./types"; +import { generateHourlyCalendarData } from "./calendarUtils"; +import type { CalendarScale, CalendarColorMode } from "./types"; dayjs.extend(isSameOrBefore); @@ -53,9 +53,16 @@ type Props = { readonly scale: CalendarScale; readonly selectedMonth: number; readonly selectedYear: number; + readonly viewMode?: CalendarColorMode; }; -export const HourlyCalendarView = ({ data, scale, selectedMonth, selectedYear }: Props) => { +export const HourlyCalendarView = ({ + data, + scale, + selectedMonth, + selectedYear, + viewMode = "total", +}: Props) => { const { t: translate } = useTranslation("dag"); const hourlyData = generateHourlyCalendarData(data, selectedYear, selectedMonth); @@ -158,30 +165,36 @@ export const HourlyCalendarView = ({ data, scale, selectedMonth, selectedYear }: const hourData = day.hours.find((hourItem) => hourItem.hour === hour); if (!hourData) { - const noRunsTooltip = `${dayjs(day.day).format("MMM DD")}, ${hour.toString().padStart(2, "0")}:00 - ${translate("calendar.noRuns")}`; const emptyCounts = { failed: 0, planned: 0, queued: 0, running: 0, success: 0, total: 0 }; + const emptyData = { + counts: emptyCounts, + date: `${dayjs(day.day).format("MMM DD")}, ${hour.toString().padStart(2, "0")}:00`, + runs: [], + }; return ( ); } - const tooltipContent = - hourData.counts.total > 0 - ? `${dayjs(day.day).format("MMM DD")}, ${hour.toString().padStart(2, "0")}:00 - ${createTooltipContent(hourData).split(": ")[1]}` - : `${dayjs(day.day).format("MMM DD")}, ${hour.toString().padStart(2, "0")}:00 - ${translate("calendar.noRuns")}`; + const formattedHourData = { + ...hourData, + date: `${dayjs(day.day).format("MMM DD")}, ${hour.toString().padStart(2, "0")}:00`, + }; return ( ); })} diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/calendarUtils.ts b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/calendarUtils.ts index 4aded47991c91..4ea24f89f8144 100644 --- a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/calendarUtils.ts +++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/calendarUtils.ts @@ -25,7 +25,6 @@ import type { RunCounts, DailyCalendarData, HourlyCalendarData, - CalendarCellData, CalendarColorMode, CalendarGranularity, CalendarScale, @@ -309,17 +308,3 @@ export const createCalendarScale = ( type: "gradient", }; }; - -export const createTooltipContent = (cellData: CalendarCellData): string => { - const { counts, date } = cellData; - - if (counts.total === 0) { - return `${date}: No runs`; - } - - const parts = Object.entries(counts) - .filter(([key, value]) => key !== "total" && value > 0) - .map(([state, count]) => `${count} ${state}`); - - return `${date}: ${counts.total} runs (${parts.join(", ")})`; -}; From d0aa157cc2540a9a77224c4a624d53b078bd7831 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=96=86=E5=AE=B8?= Date: Fri, 12 Sep 2025 12:27:01 -0400 Subject: [PATCH 4/6] fix(i18n): Revert new translation key to use fallback --- airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json | 3 --- .../src/airflow/ui/public/i18n/locales/zh-TW/dag.json | 3 --- .../airflow/ui/src/pages/Dag/Calendar/CalendarTooltip.tsx | 6 +++++- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json b/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json index afc6c5cc086d0..fd1eef88f9518 100644 --- a/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json +++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json @@ -7,8 +7,6 @@ }, "calendar": { "daily": "Daily", - "failedRun_one": "1 Failed Run}", - "failedRun_other": "{{count}} Failed Runs", "hourly": "Hourly", "legend": { "less": "Less", @@ -21,7 +19,6 @@ "previousYear": "Previous year" }, "noData": "No data available", - "noFailedRuns": "No failed runs", "noRuns": "No runs", "totalRuns": "Total Runs", "week": "Week {{weekNumber}}", diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/dag.json b/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/dag.json index 6a21e2caca9d8..f1e3c8630982b 100644 --- a/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/dag.json +++ b/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/dag.json @@ -7,8 +7,6 @@ }, "calendar": { "daily": "每日", - "failedRun_one": "1 個失敗的執行", - "failedRun_other": "{{count}} 個失敗的執行", "hourly": "每小時", "legend": { "less": "較少", @@ -21,7 +19,6 @@ "previousYear": "上一年" }, "noData": "沒有可用的資料", - "noFailedRuns": "沒有失敗的執行", "noRuns": "沒有執行", "totalRuns": "總執行數", "week": "第 {{weekNumber}} 週", 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 fa794ee5aba96..e783d11bebaf2 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 @@ -139,7 +139,11 @@ export const CalendarTooltip = ({ cellData, viewMode = "total" }: Props) => { ) : ( - {date}: {viewMode === "failed" ? translate("calendar.noFailedRuns") : translate("calendar.noRuns")} + {/* To do: remove fallback translations */} + {date}:{" "} + {viewMode === "failed" + ? translate("calendar.noFailedRuns", "No failed runs") + : translate("calendar.noRuns", "No runs")} )}
From d03a3d72273f81c9afc17b61d27ad80bbf1d266b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=96=86=E5=AE=B8?= Date: Mon, 15 Sep 2025 18:35:29 -0400 Subject: [PATCH 5/6] style: use semantic values for colors --- .../src/airflow/ui/src/pages/Dag/Calendar/Calendar.tsx | 2 +- .../airflow/ui/src/pages/Dag/Calendar/CalendarTooltip.tsx | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/Calendar.tsx b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/Calendar.tsx index ca50957db7126..634ff609e35e7 100644 --- a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/Calendar.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/Calendar.tsx @@ -237,7 +237,7 @@ export const Calendar = () => { { @@ -43,7 +43,7 @@ export const CalendarTooltip = ({ cellData, viewMode = "total" }: Props) => { () => ({ backgroundColor: "var(--chakra-colors-gray-800)", borderRadius: "4px", - color: "white", + color: "var(--chakra-colors-gray-contrast)", fontSize: "14px", left: "50%", minWidth: "200px", From adecb72b9335024641d4e0fa118662104b9ec02d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=96=86=E5=AE=B8?= Date: Tue, 16 Sep 2025 00:51:36 -0400 Subject: [PATCH 6/6] feat(ui): implement Portal-based calendar tooltips with theme support --- .../ui/src/components/HoverTooltip.tsx | 63 +++++++++++++++++++ .../src/pages/Dag/Calendar/CalendarCell.tsx | 47 +++++++------- .../pages/Dag/Calendar/CalendarTooltip.tsx | 46 +++++++------- .../pages/Dag/Calendar/DailyCalendarView.tsx | 2 +- .../pages/Dag/Calendar/useDelayedTooltip.ts | 60 ------------------ 5 files changed, 113 insertions(+), 105 deletions(-) create mode 100644 airflow-core/src/airflow/ui/src/components/HoverTooltip.tsx delete mode 100644 airflow-core/src/airflow/ui/src/pages/Dag/Calendar/useDelayedTooltip.ts diff --git a/airflow-core/src/airflow/ui/src/components/HoverTooltip.tsx b/airflow-core/src/airflow/ui/src/components/HoverTooltip.tsx new file mode 100644 index 0000000000000..46466858caec6 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/components/HoverTooltip.tsx @@ -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) => 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/pages/Dag/Calendar/CalendarCell.tsx b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarCell.tsx index 4fb20f7dbaf38..da6973810ea94 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,19 +17,27 @@ * 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 type { CalendarCellData, CalendarColorMode } from "./types"; -import { useDelayedTooltip } from "./useDelayedTooltip"; type Props = { readonly backgroundColor: Record | string; - readonly cellData: CalendarCellData | null; + readonly cellData: CalendarCellData | undefined; readonly index?: number; readonly marginRight?: string; readonly viewMode?: CalendarColorMode; }; +const renderTooltip = + (cellData: CalendarCellData | undefined, viewMode: CalendarColorMode) => + (triggerRef: React.RefObject) => ( + + ); + export const CalendarCell = ({ backgroundColor, cellData, @@ -37,31 +45,28 @@ export const CalendarCell = ({ marginRight, viewMode = "total", }: Props) => { - const { handleMouseEnter, handleMouseLeave } = useDelayedTooltip(); - const computedMarginRight = marginRight ?? (index !== undefined && index % 7 === 6 ? "8px" : "0"); const relevantCount = viewMode === "failed" ? (cellData?.counts.failed ?? 0) : (cellData?.counts.total ?? 0); const hasData = Boolean(cellData && relevantCount > 0); - const hasTooltip = Boolean(cellData); // Show tooltip if we have cell data (even if no runs) + const hasTooltip = Boolean(cellData); - return ( + const cellBox = ( - - - + _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 {cellBox}; }; 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 c16335967e783..d4c919436bd01 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 @@ -18,6 +18,7 @@ */ 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"; @@ -26,44 +27,47 @@ const SQUARE_SIZE = "12px"; const SQUARE_BORDER_RADIUS = "2px"; type Props = { - readonly cellData: CalendarCellData | null; + readonly cellData: CalendarCellData | undefined; + readonly triggerRef: RefObject; readonly viewMode?: CalendarColorMode; }; const stateColorMap = { failed: "failed.solid", - planned: "stone.600", + planned: "stone.solid", + running: "running.solid", success: "success.solid", }; -export const CalendarTooltip = ({ cellData, viewMode = "total" }: Props) => { +export const CalendarTooltip = ({ cellData, triggerRef, viewMode = "total" }: Props) => { const { t: translate } = useTranslation(["dag", "common"]); - const tooltipStyle = useMemo( - () => ({ - backgroundColor: "var(--chakra-colors-gray-800)", + 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-gray-contrast)", + color: "var(--chakra-colors-fg-inverted)", fontSize: "14px", - left: "50%", + left: `${rect.left + globalThis.scrollX + rect.width / 2}px`, minWidth: "200px", - opacity: 0, padding: "8px", - pointerEvents: "none" as const, 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: "nowrap" as const, zIndex: 1000, - }), - [], - ); + }; + }, [triggerRef]); const arrowStyle = useMemo( () => ({ - borderBottom: "4px solid var(--chakra-colors-gray-800)", + borderBottom: "4px solid var(--chakra-colors-bg-inverted)", borderLeft: "4px solid transparent", borderRight: "4px solid transparent", content: '""', @@ -78,11 +82,7 @@ export const CalendarTooltip = ({ cellData, viewMode = "total" }: Props) => { ); if (!cellData) { - return ( -
-
-
- ); + return undefined; } const { counts, date } = cellData; @@ -112,7 +112,7 @@ export const CalendarTooltip = ({ cellData, viewMode = "total" }: Props) => { })); return ( -
+
{hasRuns ? ( diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/DailyCalendarView.tsx b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/DailyCalendarView.tsx index f3a71b555693e..c34a22c8b78da 100644 --- a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/DailyCalendarView.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/DailyCalendarView.tsx @@ -111,7 +111,7 @@ export const DailyCalendarView = ({ data, scale, selectedYear, viewMode = "total return ( diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/useDelayedTooltip.ts b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/useDelayedTooltip.ts deleted file mode 100644 index b2e0d17c0ea72..0000000000000 --- a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/useDelayedTooltip.ts +++ /dev/null @@ -1,60 +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 { useRef } from "react"; - -export const useDelayedTooltip = (delayMs: number = 200) => { - const debounceTimeoutRef = useRef(undefined); - const activeTooltipRef = useRef(undefined); - - const handleMouseEnter = (event: React.MouseEvent) => { - if (debounceTimeoutRef.current) { - clearTimeout(debounceTimeoutRef.current); - } - - const tooltipElement = event.currentTarget.querySelector("[data-tooltip]"); - - if (tooltipElement) { - activeTooltipRef.current = tooltipElement as HTMLElement; - debounceTimeoutRef.current = setTimeout(() => { - if (activeTooltipRef.current) { - activeTooltipRef.current.style.opacity = "1"; - activeTooltipRef.current.style.visibility = "visible"; - } - }, delayMs); - } - }; - - const handleMouseLeave = () => { - if (debounceTimeoutRef.current) { - clearTimeout(debounceTimeoutRef.current); - debounceTimeoutRef.current = undefined; - } - - if (activeTooltipRef.current) { - activeTooltipRef.current.style.opacity = "0"; - activeTooltipRef.current.style.visibility = "hidden"; - activeTooltipRef.current = undefined; - } - }; - - return { - handleMouseEnter, - handleMouseLeave, - }; -};