This is an automated email from the ASF dual-hosted git repository. kaxilnaik pushed a commit to branch v3-1-test in repository https://gitbox.apache.org/repos/asf/airflow.git
commit 213c5fe9e73449d7a037a0babe5469d6c79a5da3 Author: Guan Ming(Wesley) Chiu <[email protected]> AuthorDate: Wed Sep 17 02:07:24 2025 +0800 Add hover synchronization between Grid and Gantt chart (#55611) * Add crosshair hover effect between gantt and grid view * Declare util function outside of the components (cherry picked from commit 8d772e0062ba67f9ec5ae7215e065f6ba8f22c0b) --- .../src/airflow/ui/src/context/hover/Context.ts | 26 ++ .../airflow/ui/src/context/hover/HoverProvider.tsx | 36 +++ .../src/airflow/ui/src/context/hover/index.ts | 21 ++ .../src/airflow/ui/src/context/hover/useHover.ts | 31 +++ .../ui/src/layouts/Details/DetailsLayout.tsx | 307 +++++++++++---------- .../airflow/ui/src/layouts/Details/Gantt/Gantt.tsx | 54 +++- .../airflow/ui/src/layouts/Details/Gantt/utils.ts | 72 ++++- .../airflow/ui/src/layouts/Details/Grid/GridTI.tsx | 42 +-- .../ui/src/layouts/Details/Grid/TaskNames.tsx | 24 +- 9 files changed, 424 insertions(+), 189 deletions(-) diff --git a/airflow-core/src/airflow/ui/src/context/hover/Context.ts b/airflow-core/src/airflow/ui/src/context/hover/Context.ts new file mode 100644 index 00000000000..e834b371702 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/context/hover/Context.ts @@ -0,0 +1,26 @@ +/*! + * 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 { createContext } from "react"; + +export type HoverContextType = { + hoveredTaskId: string | undefined; + setHoveredTaskId: (taskId: string | undefined) => void; +}; + +export const HoverContext = createContext<HoverContextType | undefined>(undefined); diff --git a/airflow-core/src/airflow/ui/src/context/hover/HoverProvider.tsx b/airflow-core/src/airflow/ui/src/context/hover/HoverProvider.tsx new file mode 100644 index 00000000000..e125ce28965 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/context/hover/HoverProvider.tsx @@ -0,0 +1,36 @@ +/*! + * 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 type { PropsWithChildren } from "react"; +import { useState, useMemo } from "react"; + +import { HoverContext } from "./Context"; + +export const HoverProvider = ({ children }: PropsWithChildren) => { + const [hoveredTaskId, setHoveredTaskId] = useState<string | undefined>(undefined); + + const value = useMemo( + () => ({ + hoveredTaskId, + setHoveredTaskId, + }), + [hoveredTaskId], + ); + + return <HoverContext.Provider value={value}>{children}</HoverContext.Provider>; +}; diff --git a/airflow-core/src/airflow/ui/src/context/hover/index.ts b/airflow-core/src/airflow/ui/src/context/hover/index.ts new file mode 100644 index 00000000000..a1f52fc6f64 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/context/hover/index.ts @@ -0,0 +1,21 @@ +/*! + * 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. + */ +export { HoverProvider } from "./HoverProvider"; +export { useHover } from "./useHover"; +export type { HoverContextType } from "./Context"; diff --git a/airflow-core/src/airflow/ui/src/context/hover/useHover.ts b/airflow-core/src/airflow/ui/src/context/hover/useHover.ts new file mode 100644 index 00000000000..541d4306d9d --- /dev/null +++ b/airflow-core/src/airflow/ui/src/context/hover/useHover.ts @@ -0,0 +1,31 @@ +/*! + * 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 { useContext } from "react"; + +import { HoverContext } from "./Context"; + +export const useHover = () => { + const context = useContext(HoverContext); + + if (context === undefined) { + throw new Error("useHover must be used within a HoverProvider"); + } + + return context; +}; diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx b/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx index 77910c28326..c19d621ba9a 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx @@ -37,6 +37,7 @@ import { Toaster } from "src/components/ui"; import ActionButton from "src/components/ui/ActionButton"; import { DAGWarningsModal } from "src/components/ui/DagWarningsModal"; import { Tooltip } from "src/components/ui/Tooltip"; +import { HoverProvider } from "src/context/hover"; import { OpenGroupsProvider } from "src/context/openGroups"; import { DagBreadcrumb } from "./DagBreadcrumb"; @@ -78,164 +79,166 @@ export const DetailsLayout = ({ children, error, isLoading, tabs }: Props) => { const direction = i18n.dir(); return ( - <OpenGroupsProvider dagId={dagId}> - <HStack justifyContent="space-between" mb={2}> - <DagBreadcrumb /> - <Flex gap={1}> - <SearchDagsButton /> - {dag === undefined ? undefined : ( - <TriggerDAGButton - dagDisplayName={dag.dag_display_name} - dagId={dag.dag_id} - isPaused={dag.is_paused} - /> - )} - </Flex> - </HStack> - <Toaster /> - <BackfillBanner dagId={dagId} /> - <Box flex={1} minH={0}> - {isRightPanelCollapsed ? ( - <Tooltip content={translate("common:showDetailsPanel")}> - <IconButton - aria-label={translate("common:showDetailsPanel")} - bg="fg.subtle" - borderRadius={direction === "ltr" ? "100% 0 0 100%" : "0 100% 100% 0"} - boxShadow="md" - left={direction === "rtl" ? "-5px" : undefined} - onClick={() => setIsRightPanelCollapsed(false)} - position="absolute" - right={direction === "ltr" ? "-5px" : undefined} - size="2xs" - top="50%" - zIndex={10} - > - {direction === "ltr" ? <FaChevronLeft /> : <FaChevronRight />} - </IconButton> - </Tooltip> - ) : undefined} - <PanelGroup - autoSaveId={`${dagView}-${direction}`} - dir={direction} - direction="horizontal" - key={`${dagView}-${direction}`} - ref={panelGroupRef} - > - <Panel - defaultSize={dagView === "graph" ? 70 : 20} - id="main-panel" - minSize={showGantt && dagView === "grid" && Boolean(runId) ? 35 : 6} - order={1} - > - <Box height="100%" marginInlineEnd={2} overflowY="auto" position="relative"> - <PanelButtons - dagView={dagView} - limit={limit} - panelGroupRef={panelGroupRef} - runTypeFilter={runTypeFilter} - setDagView={setDagView} - setLimit={setLimit} - setRunTypeFilter={setRunTypeFilter} - setShowGantt={setShowGantt} - setTriggeringUserFilter={setTriggeringUserFilter} - showGantt={showGantt} - triggeringUserFilter={triggeringUserFilter} + <HoverProvider> + <OpenGroupsProvider dagId={dagId}> + <HStack justifyContent="space-between" mb={2}> + <DagBreadcrumb /> + <Flex gap={1}> + <SearchDagsButton /> + {dag === undefined ? undefined : ( + <TriggerDAGButton + dagDisplayName={dag.dag_display_name} + dagId={dag.dag_id} + isPaused={dag.is_paused} /> - {dagView === "graph" ? ( - <Graph /> - ) : ( - <HStack gap={0}> - <Grid - limit={limit} - runType={runTypeFilter} - showGantt={Boolean(runId) && showGantt} - triggeringUser={triggeringUserFilter} - /> - {showGantt ? <Gantt limit={limit} /> : undefined} - </HStack> - )} - </Box> - </Panel> - {!isRightPanelCollapsed && ( - <> - <PanelResizeHandle - className="resize-handle" - onDragging={(isDragging) => { - if (!isDragging) { - const zoom = getZoom(); - - void fitView({ maxZoom: zoom, minZoom: zoom }); - } - }} + )} + </Flex> + </HStack> + <Toaster /> + <BackfillBanner dagId={dagId} /> + <Box flex={1} minH={0}> + {isRightPanelCollapsed ? ( + <Tooltip content={translate("common:showDetailsPanel")}> + <IconButton + aria-label={translate("common:showDetailsPanel")} + bg="fg.subtle" + borderRadius={direction === "ltr" ? "100% 0 0 100%" : "0 100% 100% 0"} + boxShadow="md" + left={direction === "rtl" ? "-5px" : undefined} + onClick={() => setIsRightPanelCollapsed(false)} + position="absolute" + right={direction === "ltr" ? "-5px" : undefined} + size="2xs" + top="50%" + zIndex={10} > - <Box - alignItems="center" - bg="border.emphasized" - cursor="col-resize" - display="flex" - h="100%" - justifyContent="center" - position="relative" - w={0.5} - // onClick={(e) => console.log(e)} + {direction === "ltr" ? <FaChevronLeft /> : <FaChevronRight />} + </IconButton> + </Tooltip> + ) : undefined} + <PanelGroup + autoSaveId={`${dagView}-${direction}`} + dir={direction} + direction="horizontal" + key={`${dagView}-${direction}`} + ref={panelGroupRef} + > + <Panel + defaultSize={dagView === "graph" ? 70 : 20} + id="main-panel" + minSize={showGantt && dagView === "grid" && Boolean(runId) ? 35 : 6} + order={1} + > + <Box height="100%" marginInlineEnd={2} overflowY="auto" position="relative"> + <PanelButtons + dagView={dagView} + limit={limit} + panelGroupRef={panelGroupRef} + runTypeFilter={runTypeFilter} + setDagView={setDagView} + setLimit={setLimit} + setRunTypeFilter={setRunTypeFilter} + setShowGantt={setShowGantt} + setTriggeringUserFilter={setTriggeringUserFilter} + showGantt={showGantt} + triggeringUserFilter={triggeringUserFilter} /> - </PanelResizeHandle> + {dagView === "graph" ? ( + <Graph /> + ) : ( + <HStack gap={0}> + <Grid + limit={limit} + runType={runTypeFilter} + showGantt={Boolean(runId) && showGantt} + triggeringUser={triggeringUserFilter} + /> + {showGantt ? <Gantt limit={limit} /> : undefined} + </HStack> + )} + </Box> + </Panel> + {!isRightPanelCollapsed && ( + <> + <PanelResizeHandle + className="resize-handle" + onDragging={(isDragging) => { + if (!isDragging) { + const zoom = getZoom(); + + void fitView({ maxZoom: zoom, minZoom: zoom }); + } + }} + > + <Box + alignItems="center" + bg="border.emphasized" + cursor="col-resize" + display="flex" + h="100%" + justifyContent="center" + position="relative" + w={0.5} + // onClick={(e) => console.log(e)} + /> + </PanelResizeHandle> - {/* Collapse button positioned next to the resize handle */} + {/* Collapse button positioned next to the resize handle */} - <Panel defaultSize={dagView === "graph" ? 30 : 80} id="details-panel" minSize={20} order={2}> - <Box display="flex" flexDirection="column" h="100%" position="relative"> - <Tooltip content={translate("common:collapseDetailsPanel")}> - <IconButton - aria-label={translate("common:collapseDetailsPanel")} - bg="fg.subtle" - borderRadius={direction === "ltr" ? "0 100% 100% 0" : "100% 0 0 100%"} - boxShadow="md" - left={direction === "ltr" ? "-5px" : undefined} - onClick={() => setIsRightPanelCollapsed(true)} - position="absolute" - right={direction === "rtl" ? "-5px" : undefined} - size="2xs" - top="50%" - zIndex={2} - > - {direction === "ltr" ? <FaChevronRight /> : <FaChevronLeft />} - </IconButton> - </Tooltip> - {children} - {Boolean(error) || (warningData?.dag_warnings.length ?? 0) > 0 ? ( - <> - <ActionButton - actionName={translate("common:dagWarnings")} - colorPalette={Boolean(error) ? "red" : "orange"} - icon={<LuFileWarning />} - margin="2" - marginBottom="-1" - onClick={onOpen} - rounded="full" - text={String(warningData?.total_entries ?? 0 + Number(error))} - variant="solid" - /> + <Panel defaultSize={dagView === "graph" ? 30 : 80} id="details-panel" minSize={20} order={2}> + <Box display="flex" flexDirection="column" h="100%" position="relative"> + <Tooltip content={translate("common:collapseDetailsPanel")}> + <IconButton + aria-label={translate("common:collapseDetailsPanel")} + bg="fg.subtle" + borderRadius={direction === "ltr" ? "0 100% 100% 0" : "100% 0 0 100%"} + boxShadow="md" + left={direction === "ltr" ? "-5px" : undefined} + onClick={() => setIsRightPanelCollapsed(true)} + position="absolute" + right={direction === "rtl" ? "-5px" : undefined} + size="2xs" + top="50%" + zIndex={2} + > + {direction === "ltr" ? <FaChevronRight /> : <FaChevronLeft />} + </IconButton> + </Tooltip> + {children} + {Boolean(error) || (warningData?.dag_warnings.length ?? 0) > 0 ? ( + <> + <ActionButton + actionName={translate("common:dagWarnings")} + colorPalette={Boolean(error) ? "red" : "orange"} + icon={<LuFileWarning />} + margin="2" + marginBottom="-1" + onClick={onOpen} + rounded="full" + text={String(warningData?.total_entries ?? 0 + Number(error))} + variant="solid" + /> - <DAGWarningsModal - error={error} - onClose={onClose} - open={open} - warnings={warningData?.dag_warnings} - /> - </> - ) : undefined} - <ProgressBar size="xs" visibility={isLoading ? "visible" : "hidden"} /> - <NavTabs tabs={tabs} /> - <Box flexGrow={1} overflow="auto" px={2}> - <Outlet /> + <DAGWarningsModal + error={error} + onClose={onClose} + open={open} + warnings={warningData?.dag_warnings} + /> + </> + ) : undefined} + <ProgressBar size="xs" visibility={isLoading ? "visible" : "hidden"} /> + <NavTabs tabs={tabs} /> + <Box flexGrow={1} overflow="auto" px={2}> + <Outlet /> + </Box> </Box> - </Box> - </Panel> - </> - )} - </PanelGroup> - </Box> - </OpenGroupsProvider> + </Panel> + </> + )} + </PanelGroup> + </Box> + </OpenGroupsProvider> + </HoverProvider> ); }; 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 444cd6c9ce8..d06aa269033 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 @@ -34,13 +34,14 @@ 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, useRef, useDeferredValue } from "react"; +import { useMemo, useDeferredValue } 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"; import { useColorMode } from "src/context/colorMode"; +import { useHover } from "src/context/hover"; import { useOpenGroups } from "src/context/openGroups"; import { useTimezone } from "src/context/timezone"; import { flattenNodes } from "src/layouts/Details/Grid/utils"; @@ -51,7 +52,7 @@ import { getComputedCSSVariableValue } from "src/theme"; import { isStatePending, useAutoRefresh } from "src/utils"; import { DEFAULT_DATETIME_FORMAT_WITH_TZ, formatDate } from "src/utils/datetimeUtils"; -import { createHandleBarClick, createChartOptions } from "./utils"; +import { createHandleBarClick, createHandleBarHover, createChartOptions } from "./utils"; ChartJS.register( CategoryScale, @@ -82,18 +83,21 @@ export const Gantt = ({ limit }: Props) => { const { t: translate } = useTranslation("common"); const { selectedTimezone } = useTimezone(); const { colorMode } = useColorMode(); + const { hoveredTaskId, setHoveredTaskId } = useHover(); const navigate = useNavigate(); const location = useLocation(); - const ref = useRef(); - const [lightGridColor, darkGridColor, lightSelectedColor, darkSelectedColor] = useToken("colors", [ - "gray.200", - "gray.800", - "blue.200", - "blue.800", - ]); + const [ + lightGridColor, + darkGridColor, + lightSelectedColor, + darkSelectedColor, + lightHoverColor, + darkHoverColor, + ] = useToken("colors", ["gray.200", "gray.800", "blue.200", "blue.800", "blue.100", "blue.900"]); const gridColor = colorMode === "light" ? lightGridColor : darkGridColor; const selectedItemColor = colorMode === "light" ? lightSelectedColor : darkSelectedColor; + const hoveredItemColor = colorMode === "light" ? lightHoverColor : darkHoverColor; const { data: gridRuns, isLoading: runsLoading } = useGridRuns({ limit }); const { data: dagStructure, isLoading: structureLoading } = useGridStructure({ limit }); @@ -218,12 +222,20 @@ export const Gantt = ({ limit }: Props) => { [data, dagId, runId, navigate, location], ); + const handleBarHover = useMemo( + () => createHandleBarHover(data, setHoveredTaskId), + [data, setHoveredTaskId], + ); + const chartOptions = useMemo( () => createChartOptions({ data, gridColor, handleBarClick, + handleBarHover, + hoveredId: hoveredTaskId, + hoveredItemColor, selectedId, selectedItemColor, selectedRun, @@ -232,6 +244,8 @@ export const Gantt = ({ limit }: Props) => { }), [ data, + hoveredTaskId, + hoveredItemColor, selectedId, selectedItemColor, gridColor, @@ -239,6 +253,7 @@ export const Gantt = ({ limit }: Props) => { selectedTimezone, translate, handleBarClick, + handleBarHover, ], ); @@ -246,12 +261,29 @@ export const Gantt = ({ limit }: Props) => { return undefined; } + const handleChartMouseLeave = () => { + setHoveredTaskId(undefined); + + // Clear all hover styles when mouse leaves the chart area + const allTasks = document.querySelectorAll<HTMLDivElement>('[id*="-"]'); + + allTasks.forEach((task) => { + task.style.backgroundColor = ""; + }); + }; + return ( - <Box height={`${fixedHeight}px`} minW="250px" ml={-2} mt={36} w="100%"> + <Box + height={`${fixedHeight}px`} + minW="250px" + ml={-2} + mt={36} + onMouseLeave={handleChartMouseLeave} + w="100%" + > <Bar data={chartData} options={chartOptions} - ref={ref} style={{ paddingTop: flatNodes.length === 1 ? 15 : 1.5, }} 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 df93b963910..d3cab195c19 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 @@ -47,6 +47,9 @@ type ChartOptionsParams = { data: Array<GanttDataItem>; gridColor?: string; handleBarClick: (event: ChartEvent, elements: Array<ActiveElement>) => void; + handleBarHover: (event: ChartEvent, elements: Array<ActiveElement>) => void; + hoveredId?: string | null; + hoveredItemColor?: string; selectedId?: string; selectedItemColor?: string; selectedRun?: GridRunsResponse; @@ -83,10 +86,54 @@ export const createHandleBarClick = } }; +export const createHandleBarHover = ( + data: Array<GanttDataItem>, + setHoveredTaskId: (taskId: string | undefined) => void, +) => { + let lastHoveredTaskId: string | undefined = undefined; + + return (_: ChartEvent, elements: Array<ActiveElement>) => { + // Clear previous hover styles + if (lastHoveredTaskId !== undefined) { + const previousTasks = document.querySelectorAll<HTMLDivElement>( + `#${lastHoveredTaskId.replaceAll(".", "-")}`, + ); + + previousTasks.forEach((task) => { + task.style.backgroundColor = ""; + }); + } + + if (elements.length > 0 && elements[0] && elements[0].index < data.length) { + const hoveredData = data[elements[0].index]; + + if (hoveredData?.taskId !== undefined) { + lastHoveredTaskId = hoveredData.taskId; + setHoveredTaskId(hoveredData.taskId); + + // Apply new hover styles + const tasks = document.querySelectorAll<HTMLDivElement>( + `#${hoveredData.taskId.replaceAll(".", "-")}`, + ); + + tasks.forEach((task) => { + task.style.backgroundColor = "var(--chakra-colors-info-subtle)"; + }); + } + } else { + lastHoveredTaskId = undefined; + setHoveredTaskId(undefined); + } + }; +}; + export const createChartOptions = ({ data, gridColor, handleBarClick, + handleBarHover, + hoveredId, + hoveredItemColor, selectedId, selectedItemColor, selectedRun, @@ -112,11 +159,14 @@ export const createChartOptions = ({ if (target) { target.style.cursor = elements.length > 0 ? "pointer" : "default"; } + + handleBarHover(event, elements); }, plugins: { annotation: { - annotations: - selectedId === undefined || selectedId === "" + annotations: [ + // Selected task annotation + ...(selectedId === undefined || selectedId === "" || hoveredId === selectedId ? [] : [ { @@ -129,7 +179,23 @@ export const createChartOptions = ({ yMax: data.findIndex((dataItem) => dataItem.y === selectedId) + 0.5, yMin: data.findIndex((dataItem) => dataItem.y === selectedId) - 0.5, }, - ], + ]), + // Hovered task annotation + ...(hoveredId === null || hoveredId === undefined + ? [] + : [ + { + backgroundColor: hoveredItemColor, + borderWidth: 0, + drawTime: "beforeDatasetsDraw" as const, + type: "box" as const, + xMax: "max" as const, + xMin: "min" as const, + yMax: data.findIndex((dataItem) => dataItem.y === hoveredId) + 0.5, + yMin: data.findIndex((dataItem) => dataItem.y === hoveredId) - 0.5, + }, + ]), + ], }, legend: { display: false, 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 817f6733528..645728236df 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 @@ -26,8 +26,30 @@ import type { LightGridTaskInstanceSummary } from "openapi/requests/types.gen"; 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"; +const handleMouseEnter = + (setHoveredTaskId: HoverContextType["setHoveredTaskId"]) => (event: MouseEvent<HTMLDivElement>) => { + const tasks = document.querySelectorAll<HTMLDivElement>(`#${event.currentTarget.id}`); + + tasks.forEach((task) => { + task.style.backgroundColor = "var(--chakra-colors-info-subtle)"; + }); + + setHoveredTaskId(event.currentTarget.id.replaceAll("-", ".")); + }; + +const handleMouseLeave = (taskId: string, setHoveredTaskId: HoverContextType["setHoveredTaskId"]) => () => { + const tasks = document.querySelectorAll<HTMLDivElement>(`#${taskId.replaceAll(".", "-")}`); + + tasks.forEach((task) => { + task.style.backgroundColor = ""; + }); + + setHoveredTaskId(undefined); +}; + type Props = { readonly dagId: string; readonly instance: LightGridTaskInstanceSummary; @@ -40,27 +62,15 @@ type Props = { readonly taskId: string; }; -const onMouseEnter = (event: MouseEvent<HTMLDivElement>) => { - const tasks = document.querySelectorAll<HTMLDivElement>(`#${event.currentTarget.id}`); - - tasks.forEach((task) => { - task.style.backgroundColor = "var(--chakra-colors-brand-subtle)"; - }); -}; - -const onMouseLeave = (event: MouseEvent<HTMLDivElement>) => { - const tasks = document.querySelectorAll<HTMLDivElement>(`#${event.currentTarget.id}`); - - tasks.forEach((task) => { - task.style.backgroundColor = ""; - }); -}; - const Instance = ({ dagId, instance, isGroup, isMapped, onClick, runId, search, taskId }: Props) => { + const { setHoveredTaskId } = useHover(); const { groupId: selectedGroupId, taskId: selectedTaskId } = useParams(); const { t: translate } = useTranslation(); const location = useLocation(); + const onMouseEnter = handleMouseEnter(setHoveredTaskId); + const onMouseLeave = handleMouseLeave(taskId, setHoveredTaskId); + const getTaskUrl = useCallback( () => buildTaskInstanceUrl({ diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskNames.tsx b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskNames.tsx index 2e2ba611eca..05bf16c83c9 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskNames.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskNames.tsx @@ -23,6 +23,7 @@ import { FiChevronUp } from "react-icons/fi"; import { Link as RouterLink, useParams, useSearchParams } from "react-router-dom"; import { TaskName } from "src/components/TaskName"; +import { type HoverContextType, useHover } from "src/context/hover"; import { useOpenGroups } from "src/context/openGroups"; import type { GridTask } from "./utils"; @@ -33,26 +34,35 @@ type Props = { onRowClick?: () => void; }; -const onMouseEnter = (event: MouseEvent<HTMLDivElement>) => { +const indent = (depth: number) => `${depth * 0.75 + 0.5}rem`; + +const onMouseEnter = ( + event: MouseEvent<HTMLDivElement>, + nodeId: string, + setHoveredTaskId: HoverContextType["setHoveredTaskId"], +) => { const tasks = document.querySelectorAll<HTMLDivElement>(`#${event.currentTarget.id}`); tasks.forEach((task) => { task.style.backgroundColor = "var(--chakra-colors-info-subtle)"; }); + + setHoveredTaskId(nodeId); }; -const onMouseLeave = (event: MouseEvent<HTMLDivElement>) => { - const tasks = document.querySelectorAll<HTMLDivElement>(`#${event.currentTarget.id}`); +const onMouseLeave = (nodeId: string, setHoveredTaskId: HoverContextType["setHoveredTaskId"]) => { + const tasks = document.querySelectorAll<HTMLDivElement>(`#${nodeId.replaceAll(".", "-")}`); tasks.forEach((task) => { task.style.backgroundColor = ""; }); -}; -const indent = (depth: number) => `${depth * 0.75 + 0.5}rem`; + setHoveredTaskId(undefined); +}; export const TaskNames = ({ nodes, onRowClick }: Props) => { const { t: translate } = useTranslation("dag"); + const { setHoveredTaskId } = useHover(); const { toggleGroupId } = useOpenGroups(); const { dagId = "", groupId, taskId } = useParams(); const [searchParams] = useSearchParams(); @@ -66,8 +76,8 @@ export const TaskNames = ({ nodes, onRowClick }: Props) => { id={node.id.replaceAll(".", "-")} key={node.id} maxHeight="20px" - onMouseEnter={onMouseEnter} - onMouseLeave={onMouseLeave} + onMouseEnter={(event) => onMouseEnter(event, node.id, setHoveredTaskId)} + onMouseLeave={() => onMouseLeave(node.id, setHoveredTaskId)} transition="background-color 0.2s" > {node.isGroup ? (
