Skip to content
Closed
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
26 changes: 0 additions & 26 deletions airflow-core/src/airflow/ui/src/context/hover/Context.ts

This file was deleted.

36 changes: 0 additions & 36 deletions airflow-core/src/airflow/ui/src/context/hover/HoverProvider.tsx

This file was deleted.

21 changes: 0 additions & 21 deletions airflow-core/src/airflow/ui/src/context/hover/index.ts

This file was deleted.

9 changes: 9 additions & 0 deletions airflow-core/src/airflow/ui/src/hooks/navigation/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
import type { RefObject } from "react";

import type { GridRunsResponse } from "openapi/requests";
import type { GridTask } from "src/layouts/Details/Grid/utils";

Expand All @@ -26,11 +28,18 @@ export type ArrowKey = "ArrowDown" | "ArrowLeft" | "ArrowRight" | "ArrowUp";
export type NavigationDirection = "down" | "left" | "right" | "up";

export type NavigationIndices = {
colIndex?: number;
rowIndex?: number;
runIndex: number;
taskIndex: number;
};

export type UseNavigationProps = {
containerRef?: RefObject<HTMLElement | null>;
/** Refs to navigation overlay elements for direct DOM manipulation */
navCellRef?: RefObject<HTMLDivElement | null>;
navColRef?: RefObject<HTMLDivElement | null>;
navRowRef?: RefObject<HTMLDivElement | null>;
onToggleGroup?: (taskId: string) => void;
runs: Array<GridRunsResponse>;
tasks: Array<GridTask>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,17 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useCallback } from "react";
import { useCallback, useEffect } from "react";
import { useHotkeys } from "react-hotkeys-hook";
import { useDebouncedCallback } from "use-debounce";

import type { ArrowKey, NavigationDirection } from "./types";

const ARROW_KEYS = ["shift+ArrowDown", "shift+ArrowUp", "shift+ArrowLeft", "shift+ArrowRight"] as const;

type Props = {
enabled?: boolean;
onCommit?: () => void;
onNavigate: (direction: NavigationDirection) => void;
onToggleGroup?: () => void;
};
Expand All @@ -44,9 +46,13 @@ const mapKeyToDirection = (key: ArrowKey): NavigationDirection => {
}
};

export const useKeyboardNavigation = ({ enabled = true, onNavigate, onToggleGroup }: Props) => {
const createKeyHandler = useCallback(
() => (event: KeyboardEvent) => {
const isArrowKey = (key: string): key is ArrowKey =>
key === "ArrowDown" || key === "ArrowUp" || key === "ArrowLeft" || key === "ArrowRight";

export const useKeyboardNavigation = ({ enabled = true, onCommit, onNavigate, onToggleGroup }: Props) => {
// Handle keydown: preview navigation (instant highlight)
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
const direction = mapKeyToDirection(event.key as ArrowKey);

event.preventDefault();
Expand All @@ -57,11 +63,33 @@ export const useKeyboardNavigation = ({ enabled = true, onNavigate, onToggleGrou
[onNavigate],
);

const handleNormalKeyPress = createKeyHandler();

const hotkeyOptions = { enabled, preventDefault: true };

useHotkeys(ARROW_KEYS.join(","), handleNormalKeyPress, hotkeyOptions, [onNavigate]);
useHotkeys(ARROW_KEYS.join(","), handleKeyDown, hotkeyOptions, [onNavigate]);

useHotkeys("space", () => onToggleGroup?.(), hotkeyOptions, [onToggleGroup]);

const debouncedCommit = useDebouncedCallback(() => {
onCommit?.();
}, 500);

// Handle keyup: commit navigation (URL update)
useEffect(() => {
if (!enabled || !onCommit) {
return undefined;
}

const handleKeyUp = (event: KeyboardEvent) => {
// Only commit when shift + arrow key is released
if (isArrowKey(event.key)) {
debouncedCommit();
}
};

globalThis.addEventListener("keyup", handleKeyUp);

return () => {
globalThis.removeEventListener("keyup", handleKeyUp);
};
}, [enabled, onCommit, debouncedCommit]);
};
123 changes: 91 additions & 32 deletions airflow-core/src/airflow/ui/src/hooks/navigation/useNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,12 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom";

import type { GridRunsResponse } from "openapi/requests";
import type { GridTask } from "src/layouts/Details/Grid/utils";
import { CELL_WIDTH, CELL_HEIGHT, type GridTask } from "src/layouts/Details/Grid/utils";
import { setRef } from "src/utils/domUtils";
import { buildTaskInstanceUrl } from "src/utils/links";

import type {
Expand Down Expand Up @@ -93,7 +94,14 @@ const buildPath = (params: {
}
};

export const useNavigation = ({ onToggleGroup, runs, tasks }: UseNavigationProps): UseNavigationReturn => {
export const useNavigation = ({
navCellRef,
navColRef,
navRowRef,
onToggleGroup,
runs,
tasks,
}: UseNavigationProps): UseNavigationReturn => {
const { dagId = "", groupId = "", mapIndex = "-1", runId = "", taskId = "" } = useParams();
const enabled = Boolean(dagId) && (Boolean(runId) || Boolean(taskId) || Boolean(groupId));
const navigate = useNavigate();
Expand All @@ -119,22 +127,65 @@ export const useNavigation = ({ onToggleGroup, runs, tasks }: UseNavigationProps

const currentTask = useMemo(() => tasks[currentIndices.taskIndex], [tasks, currentIndices.taskIndex]);

// Ref to track preview position during key hold (no re-render)
const previewIndicesRef = useRef<NavigationIndices>(currentIndices);

// Sync ref with actual indices when URL changes
useEffect(() => {
previewIndicesRef.current = currentIndices;
}, [currentIndices]);

// Clear navigation highlight overlays
const clearHighlight = useCallback(() => {
setRef(navRowRef, { opacity: "0" });
setRef(navColRef, { opacity: "0" });
setRef(navCellRef, { opacity: "0" });
}, [navCellRef, navColRef, navRowRef]);

// Apply highlight using direct DOM manipulation (GPU composited)
const applyHighlight = useCallback(
(indices: NavigationIndices, navMode: NavigationMode) => {
const { runIndex, taskIndex } = indices;

// Calculate positions (same logic as hover)
const rowY = taskIndex * CELL_HEIGHT;
const colX = -(runIndex * CELL_WIDTH);

const showRow = navMode === "task" || navMode === "TI";
const showCol = navMode === "run" || navMode === "TI";

// Update row highlight
setRef(navRowRef, showRow ? { opacity: "1", transform: `translateY(${rowY}px)` } : { opacity: "0" });

// Update column highlight
setRef(navColRef, showCol ? { opacity: "1", transform: `translateX(${colX}px)` } : { opacity: "0" });

// Update cell highlight (TI mode only)
setRef(
navCellRef,
navMode === "TI" ? { opacity: "1", transform: `translate(${colX}px, ${rowY}px)` } : { opacity: "0" },
);
},
[navCellRef, navColRef, navRowRef],
);

// Preview navigation: update ref + overlay highlight (keydown)
const handleNavigation = useCallback(
(direction: NavigationDirection) => {
if (!enabled || !dagId || !isValidDirection(direction, mode)) {
return;
}

const prevIndices = previewIndicesRef.current;

const boundaries = {
down: currentIndices.taskIndex >= tasks.length - 1,
left: currentIndices.runIndex >= runs.length - 1,
right: currentIndices.runIndex <= 0,
up: currentIndices.taskIndex <= 0,
down: prevIndices.taskIndex >= tasks.length - 1,
left: prevIndices.runIndex >= runs.length - 1,
right: prevIndices.runIndex <= 0,
up: prevIndices.taskIndex <= 0,
};

const isAtBoundary = boundaries[direction];

if (isAtBoundary) {
if (boundaries[direction]) {
return;
}

Expand All @@ -149,44 +200,52 @@ export const useNavigation = ({ onToggleGroup, runs, tasks }: UseNavigationProps
};

const nav = navigationMap[direction];

const newIndices = { ...currentIndices };
const newIndices = { ...prevIndices };

if (nav.index === "taskIndex") {
newIndices.taskIndex = getNextIndex(currentIndices.taskIndex, nav.direction, {
max: nav.max,
});
newIndices.taskIndex = getNextIndex(prevIndices.taskIndex, nav.direction, { max: nav.max });
} else {
newIndices.runIndex = getNextIndex(currentIndices.runIndex, nav.direction, { max: nav.max });
newIndices.runIndex = getNextIndex(prevIndices.runIndex, nav.direction, { max: nav.max });
}

const { runIndex: newRunIndex, taskIndex: newTaskIndex } = newIndices;

if (newRunIndex === currentIndices.runIndex && newTaskIndex === currentIndices.taskIndex) {
if (newIndices.runIndex === prevIndices.runIndex && newIndices.taskIndex === prevIndices.taskIndex) {
return;
}

const run = runs[newRunIndex];
const task = tasks[newTaskIndex];
// Update ref (no re-render)
previewIndicesRef.current = newIndices;

// Apply highlight instantly via direct DOM manipulation
applyHighlight(newIndices, mode);
},
[applyHighlight, dagId, enabled, mode, runs, tasks],
);

// Commit navigation: URL update (keyup)
const commitNavigation = useCallback(() => {
const indices = previewIndicesRef.current;
const run = runs[indices.runIndex];
const task = tasks[indices.taskIndex];

// Clear highlight
clearHighlight();

if (run && task) {
const path = buildPath({ dagId, mapIndex, mode, pathname: location.pathname, run, task });
if (run && task) {
const path = buildPath({ dagId, mapIndex, mode, pathname: location.pathname, run, task });

navigate(path, { replace: true });
navigate(path, { replace: true });

const grid = document.querySelector(`[id='grid-${run.run_id}-${task.id}']`);
const grid = document.querySelector(`[id='grid-${run.run_id}-${task.id}']`);

// Set the focus to the grid link to allow a user to continue tabbing through with the keyboard
if (grid) {
(grid as HTMLLinkElement).focus();
}
if (grid) {
(grid as HTMLLinkElement).focus();
}
},
[currentIndices, dagId, enabled, location.pathname, mapIndex, mode, runs, tasks, navigate],
);
}
}, [clearHighlight, dagId, location.pathname, mapIndex, mode, navigate, runs, tasks]);

useKeyboardNavigation({
enabled,
onCommit: commitNavigation,
onNavigate: handleNavigation,
onToggleGroup: currentTask?.isGroup && onToggleGroup ? () => onToggleGroup(currentTask.id) : undefined,
});
Expand Down
Loading