From 01d2cabbb6d93f528bf4cfd5efa3f4342d2d01c3 Mon Sep 17 00:00:00 2001 From: Yeonguk Date: Mon, 8 Jun 2026 13:51:15 +0900 Subject: [PATCH 1/6] feat: keyboard shortcuts help dialog (press ?) --- .../ui/public/i18n/locales/en/common.json | 41 +++++++ .../components/Clear/Run/ClearRunButton.tsx | 14 ++- .../TaskInstance/ClearTaskInstanceButton.tsx | 14 ++- .../FilterBar/filters/TextSearchFilter.tsx | 16 ++- .../ui/src/components/GraphTaskFilters.tsx | 10 +- .../KeyboardShortcutsModal.tsx | 103 ++++++++++++++++++ .../formatShortcutCombo.test.ts | 41 +++++++ .../KeyboardShortcuts/formatShortcutCombo.ts | 66 +++++++++++ .../src/components/KeyboardShortcuts/index.ts | 20 ++++ .../components/MarkAs/Run/MarkRunAsButton.tsx | 26 +++-- .../TaskGroup/MarkTaskGroupAsButton.tsx | 26 +++-- .../TaskInstance/MarkTaskInstanceAsButton.tsx | 26 +++-- .../airflow/ui/src/components/SearchBar.tsx | 14 ++- .../SearchDags/SearchDagsButton.tsx | 16 +-- .../src/context/keyboardShortcuts/Context.ts | 58 ++++++++++ .../ShortcutRegistryProvider.tsx | 45 ++++++++ .../ui/src/context/keyboardShortcuts/index.ts | 21 ++++ .../keyboardShortcuts/useShortcutRegistry.ts | 23 ++++ .../hooks/navigation/useKeyboardNavigation.ts | 24 +++- .../airflow/ui/src/hooks/useShortcut.test.tsx | 95 ++++++++++++++++ .../src/airflow/ui/src/hooks/useShortcut.ts | 73 +++++++++++++ .../src/airflow/ui/src/layouts/BaseLayout.tsx | 2 + .../ui/src/layouts/Details/PanelButtons.tsx | 16 +-- airflow-core/src/airflow/ui/src/main.tsx | 5 +- .../airflow/ui/src/pages/Dag/Code/Code.tsx | 9 +- .../ui/src/pages/Dag/Overview/FailedLogs.tsx | 9 +- .../TaskInstance/Logs/LogSearchInput.tsx | 14 ++- .../ui/src/pages/TaskInstance/Logs/Logs.tsx | 44 ++++++-- .../TaskInstance/Logs/TaskLogContent.tsx | 20 +++- 29 files changed, 795 insertions(+), 96 deletions(-) create mode 100644 airflow-core/src/airflow/ui/src/components/KeyboardShortcuts/KeyboardShortcutsModal.tsx create mode 100644 airflow-core/src/airflow/ui/src/components/KeyboardShortcuts/formatShortcutCombo.test.ts create mode 100644 airflow-core/src/airflow/ui/src/components/KeyboardShortcuts/formatShortcutCombo.ts create mode 100644 airflow-core/src/airflow/ui/src/components/KeyboardShortcuts/index.ts create mode 100644 airflow-core/src/airflow/ui/src/context/keyboardShortcuts/Context.ts create mode 100644 airflow-core/src/airflow/ui/src/context/keyboardShortcuts/ShortcutRegistryProvider.tsx create mode 100644 airflow-core/src/airflow/ui/src/context/keyboardShortcuts/index.ts create mode 100644 airflow-core/src/airflow/ui/src/context/keyboardShortcuts/useShortcutRegistry.ts create mode 100644 airflow-core/src/airflow/ui/src/hooks/useShortcut.test.tsx create mode 100644 airflow-core/src/airflow/ui/src/hooks/useShortcut.ts diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json index cc27c1faf5439..b1938d5bb46c3 100644 --- a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json +++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json @@ -219,6 +219,47 @@ }, "selectLanguage": "Select Language", "selected": "Selected", + "shortcuts": { + "categories": { + "code": "Code", + "dagView": "Dag View", + "filters": "Filters", + "global": "Global", + "logs": "Logs", + "navigation": "Navigation", + "runActions": "Run & Task Actions", + "search": "Search" + }, + "descriptions": { + "clearRun": "Clear Dag run", + "clearTaskInstance": "Clear task instance", + "downloadLogs": "Download logs", + "focusFilterSearch": "Focus filter search", + "focusLogSearch": "Search logs", + "focusSearch": "Focus search", + "markRunFailed": "Mark Dag run as failed", + "markRunSuccess": "Mark Dag run as success", + "markTaskFailed": "Mark task as failed", + "markTaskGroupFailed": "Mark task group as failed", + "markTaskGroupSuccess": "Mark task group as success", + "markTaskSuccess": "Mark task as success", + "navigateTasks": "Navigate tasks", + "openGraphFilters": "Open graph filters", + "scrollBottom": "Scroll to bottom", + "scrollTop": "Scroll to top", + "searchDags": "Search Dags", + "toggleExpand": "Expand or collapse all groups", + "toggleFullscreen": "Toggle fullscreen", + "toggleGraphGrid": "Toggle graph / grid view", + "toggleSource": "Toggle source", + "toggleTaskGroup": "Expand or collapse task group", + "toggleTimestamp": "Toggle timestamps", + "toggleWrap": "Toggle wrap" + }, + "empty": "No keyboard shortcuts are available on this page.", + "showHelp": "Show keyboard shortcuts", + "title": "Keyboard Shortcuts" + }, "showDetailsPanel": "Show Details Panel", "signedInAs": "Signed in as", "source": { diff --git a/airflow-core/src/airflow/ui/src/components/Clear/Run/ClearRunButton.tsx b/airflow-core/src/airflow/ui/src/components/Clear/Run/ClearRunButton.tsx index 6cbc3aa08b004..2a9edef70e64c 100644 --- a/airflow-core/src/airflow/ui/src/components/Clear/Run/ClearRunButton.tsx +++ b/airflow-core/src/airflow/ui/src/components/Clear/Run/ClearRunButton.tsx @@ -17,12 +17,12 @@ * under the License. */ import { useDisclosure } from "@chakra-ui/react"; -import { useHotkeys } from "react-hotkeys-hook"; import { useTranslation } from "react-i18next"; import { CgRedo } from "react-icons/cg"; import type { DAGRunResponse } from "openapi/requests/types.gen"; import { IconButton } from "src/components/ui"; +import { useShortcut } from "src/hooks/useShortcut"; import ClearRunDialog from "./ClearRunDialog"; @@ -35,13 +35,15 @@ const ClearRunButton = ({ dagRun, isHotkeyEnabled = false }: Props) => { const { onClose, onOpen, open } = useDisclosure(); const { t: translate } = useTranslation(); - useHotkeys( - "shift+c", - () => { + useShortcut({ + callback: () => { onOpen(); }, - { enabled: isHotkeyEnabled }, - ); + category: "runActions", + description: translate("common:shortcuts.descriptions.clearRun"), + keys: "shift+c", + options: { enabled: isHotkeyEnabled }, + }); return ( <> diff --git a/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceButton.tsx b/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceButton.tsx index 84fc78c1e687b..9843c49ed0703 100644 --- a/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceButton.tsx +++ b/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceButton.tsx @@ -17,13 +17,13 @@ * under the License. */ import { useDisclosure } from "@chakra-ui/react"; -import { useHotkeys } from "react-hotkeys-hook"; import { useTranslation } from "react-i18next"; import { CgRedo } from "react-icons/cg"; import type { LightGridTaskInstanceSummary, TaskInstanceResponse } from "openapi/requests/types.gen"; import { ClearGroupTaskInstanceDialog } from "src/components/Clear/TaskInstance/ClearGroupTaskInstanceDialog"; import { IconButton } from "src/components/ui"; +import { useShortcut } from "src/hooks/useShortcut"; import ClearTaskInstanceDialog from "./ClearTaskInstanceDialog"; @@ -58,17 +58,19 @@ const ClearTaskInstanceButton = ({ const selectedInstance = taskInstance ?? groupTaskInstance; - useHotkeys( - "shift+c", - () => { + useShortcut({ + callback: () => { if (onOpen && selectedInstance) { onOpen(selectedInstance); } else { onOpenInternal(); } }, - { enabled: isHotkeyEnabled }, - ); + category: "runActions", + description: translate("common:shortcuts.descriptions.clearTaskInstance"), + keys: "shift+c", + options: { enabled: isHotkeyEnabled }, + }); const label = allMapped ? isHotkeyEnabled diff --git a/airflow-core/src/airflow/ui/src/components/FilterBar/filters/TextSearchFilter.tsx b/airflow-core/src/airflow/ui/src/components/FilterBar/filters/TextSearchFilter.tsx index 32bfa29231849..e08e3747873e8 100644 --- a/airflow-core/src/airflow/ui/src/components/FilterBar/filters/TextSearchFilter.tsx +++ b/airflow-core/src/airflow/ui/src/components/FilterBar/filters/TextSearchFilter.tsx @@ -18,11 +18,12 @@ */ import { HStack } from "@chakra-ui/react"; import { useRef } from "react"; -import { useHotkeys } from "react-hotkeys-hook"; +import { useTranslation } from "react-i18next"; import { LuRegex } from "react-icons/lu"; import { AdvancedSearchToggle } from "src/components/AdvancedSearchToggle"; import { useAdvancedSearch } from "src/hooks/useAdvancedSearch"; +import { useShortcut } from "src/hooks/useShortcut"; import { InputWithAddon } from "../../ui"; import { FilterPill } from "../FilterPill"; @@ -30,6 +31,7 @@ import type { FilterPluginProps } from "../types"; import { isValidFilterValue } from "../utils"; export const TextSearchFilter = ({ filter, onChange, onRemove }: FilterPluginProps) => { + const { t: translate } = useTranslation("common"); const hotkeyInputRef = useRef(null); const advanced = useAdvancedSearch(filter.config.key); const showAdvancedToggle = filter.config.supportsAdvancedSearch === true; @@ -42,15 +44,17 @@ export const TextSearchFilter = ({ filter, onChange, onRemove }: FilterPluginPro onChange(newValue || undefined); }; - useHotkeys( - "mod+k", - () => { + useShortcut({ + callback: () => { if (!filter.config.hotkeyDisabled) { hotkeyInputRef.current?.focus(); } }, - { enabled: !filter.config.hotkeyDisabled, preventDefault: true }, - ); + category: "search", + description: translate("shortcuts.descriptions.focusFilterSearch"), + keys: "mod+k", + options: { enabled: !filter.config.hotkeyDisabled, preventDefault: true }, + }); const isAdvanced = showAdvancedToggle && advanced.enabled; const stringValue = hasValue && typeof filter.value === "string" ? filter.value : ""; diff --git a/airflow-core/src/airflow/ui/src/components/GraphTaskFilters.tsx b/airflow-core/src/airflow/ui/src/components/GraphTaskFilters.tsx index e6515458f4853..d7d653a94bb94 100644 --- a/airflow-core/src/airflow/ui/src/components/GraphTaskFilters.tsx +++ b/airflow-core/src/airflow/ui/src/components/GraphTaskFilters.tsx @@ -25,7 +25,6 @@ import { VStack, } from "@chakra-ui/react"; import { useEffect, useRef, useState } from "react"; -import { useHotkeys } from "react-hotkeys-hook"; import { useTranslation } from "react-i18next"; import { FiSearch } from "react-icons/fi"; import { useParams, useSearchParams } from "react-router-dom"; @@ -39,6 +38,7 @@ import { NumberInputField, NumberInputRoot } from "src/components/ui/NumberInput import { SearchParamsKeys } from "src/constants/searchParams"; import { taskInstanceStateOptions } from "src/constants/stateOptions"; import { useGroups } from "src/context/groups"; +import { useShortcut } from "src/hooks/useShortcut"; export const GraphTaskFilters = () => { const { t: translate } = useTranslation(["dag", "tasks"]); @@ -140,7 +140,13 @@ export const GraphTaskFilters = () => { const [isOpen, setIsOpen] = useState(false); - useHotkeys("mod+shift+f", () => setIsOpen(true), { preventDefault: true }); + useShortcut({ + callback: () => setIsOpen(true), + category: "filters", + description: translate("common:shortcuts.descriptions.openGraphFilters"), + keys: "mod+shift+f", + options: { preventDefault: true }, + }); const panelTitle = translate("dag:panel.graphFilters.title"); diff --git a/airflow-core/src/airflow/ui/src/components/KeyboardShortcuts/KeyboardShortcutsModal.tsx b/airflow-core/src/airflow/ui/src/components/KeyboardShortcuts/KeyboardShortcutsModal.tsx new file mode 100644 index 0000000000000..14a3b39edfe73 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/components/KeyboardShortcuts/KeyboardShortcutsModal.tsx @@ -0,0 +1,103 @@ +/*! + * 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, Flex, HStack, Heading, Kbd, Text, VStack } from "@chakra-ui/react"; +import { useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { Dialog } from "src/components/ui"; +import { SHORTCUT_CATEGORIES, type ShortcutEntry, useShortcutRegistry } from "src/context/keyboardShortcuts"; +import { useShortcut } from "src/hooks/useShortcut"; +import { getMetaKey } from "src/utils"; + +import { formatShortcutCombo } from "./formatShortcutCombo"; + +const buildGroups = (shortcuts: ReadonlyArray) => + SHORTCUT_CATEGORIES.map((category) => { + const seen = new Set(); + const items = shortcuts + .filter((entry) => entry.category === category) + .filter((entry) => { + const key = `${entry.keys.join("+")}|${entry.description}`; + + if (seen.has(key)) { + return false; + } + seen.add(key); + + return true; + }); + + return { category, items }; + }).filter((group) => group.items.length > 0); + +export const KeyboardShortcutsModal = () => { + const { t: translate } = useTranslation("common"); + const { shortcuts } = useShortcutRegistry(); + const [open, setOpen] = useState(false); + const metaKey = getMetaKey(); + + const toggle = useCallback(() => setOpen((prev) => !prev), []); + + useShortcut({ + callback: toggle, + category: "global", + description: translate("shortcuts.showHelp"), + keys: "shift+Slash", + }); + + const groups = useMemo(() => buildGroups(shortcuts), [shortcuts]); + + return ( + setOpen(event.open)} open={open} size="md"> + + {translate("shortcuts.title")} + + + {groups.length === 0 ? ( + {translate("shortcuts.empty")} + ) : ( + + {groups.map(({ category, items }) => ( + + + {translate(`shortcuts.categories.${category}`)} + + + {items.map((entry) => ( + + {entry.description} + + {entry.keys.map((combo) => ( + + {formatShortcutCombo(combo, metaKey)} + + ))} + + + ))} + + + ))} + + )} + + + + ); +}; diff --git a/airflow-core/src/airflow/ui/src/components/KeyboardShortcuts/formatShortcutCombo.test.ts b/airflow-core/src/airflow/ui/src/components/KeyboardShortcuts/formatShortcutCombo.test.ts new file mode 100644 index 0000000000000..e128fc376f6a3 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/components/KeyboardShortcuts/formatShortcutCombo.test.ts @@ -0,0 +1,41 @@ +/*! + * 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 { describe, expect, it } from "vitest"; + +import { formatShortcutCombo } from "./formatShortcutCombo"; + +describe("formatShortcutCombo", () => { + it.each([ + ["w", "⌘", "W"], + ["mod+k", "⌘", "⌘ K"], + ["mod+k", "Ctrl", "Ctrl K"], + ["mod+shift+f", "⌘", "⌘ ⇧ F"], + ["mod+ArrowUp", "Ctrl", "Ctrl ↑"], + ["shift+ArrowDown", "⌘", "⇧ ↓"], + ["space", "⌘", "Space"], + ["/", "⌘", "/"], + ])("formats %s (meta=%s) as %s", (combo, metaKey, expected) => { + expect(formatShortcutCombo(combo, metaKey)).toBe(expected); + }); + + it("renders the ? alias for the help shortcut regardless of casing", () => { + expect(formatShortcutCombo("shift+Slash", "⌘")).toBe("?"); + expect(formatShortcutCombo("shift+slash", "Ctrl")).toBe("?"); + }); +}); diff --git a/airflow-core/src/airflow/ui/src/components/KeyboardShortcuts/formatShortcutCombo.ts b/airflow-core/src/airflow/ui/src/components/KeyboardShortcuts/formatShortcutCombo.ts new file mode 100644 index 0000000000000..4819a84f5a1ae --- /dev/null +++ b/airflow-core/src/airflow/ui/src/components/KeyboardShortcuts/formatShortcutCombo.ts @@ -0,0 +1,66 @@ +/*! + * 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. + */ + +// Symbols for keys whose name is not the desired display label. +const KEY_SYMBOLS: Record = { + alt: "⌥", + arrowdown: "↓", + arrowleft: "←", + arrowright: "→", + arrowup: "↑", + ctrl: "Ctrl", + shift: "⇧", + slash: "/", + space: "Space", +}; + +// Combos bound to one key for matching reasons but better shown as a single glyph. +// `shift+Slash` is how react-hotkeys-hook reliably matches the "?" key. +const COMBO_ALIASES: Record = { + "shift+slash": "?", +}; + +/** + * Turn a `react-hotkeys-hook` combo such as `"mod+shift+f"` into a human-readable + * label like `"⌘ ⇧ F"`. `metaKey` is the platform's meta key symbol (`⌘`/`Ctrl`). + */ +export const formatShortcutCombo = (combo: string, metaKey: string): string => { + const alias = COMBO_ALIASES[combo.toLowerCase()]; + + if (alias !== undefined) { + return alias; + } + + return combo + .split("+") + .map((part) => { + const lower = part.toLowerCase(); + + if (lower === "mod") { + return metaKey; + } + + if (lower in KEY_SYMBOLS) { + return KEY_SYMBOLS[lower]; + } + + return part.length === 1 ? part.toUpperCase() : part; + }) + .join(" "); +}; diff --git a/airflow-core/src/airflow/ui/src/components/KeyboardShortcuts/index.ts b/airflow-core/src/airflow/ui/src/components/KeyboardShortcuts/index.ts new file mode 100644 index 0000000000000..f98aa5f41b7ca --- /dev/null +++ b/airflow-core/src/airflow/ui/src/components/KeyboardShortcuts/index.ts @@ -0,0 +1,20 @@ +/*! + * 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 { KeyboardShortcutsModal } from "./KeyboardShortcutsModal"; +export { formatShortcutCombo } from "./formatShortcutCombo"; diff --git a/airflow-core/src/airflow/ui/src/components/MarkAs/Run/MarkRunAsButton.tsx b/airflow-core/src/airflow/ui/src/components/MarkAs/Run/MarkRunAsButton.tsx index e933ec8883871..53af0f6a86329 100644 --- a/airflow-core/src/airflow/ui/src/components/MarkAs/Run/MarkRunAsButton.tsx +++ b/airflow-core/src/airflow/ui/src/components/MarkAs/Run/MarkRunAsButton.tsx @@ -18,7 +18,6 @@ */ import { Box, HStack, useDisclosure } from "@chakra-ui/react"; import { useState } from "react"; -import { useHotkeys } from "react-hotkeys-hook"; import { useTranslation } from "react-i18next"; import { FiX } from "react-icons/fi"; import { LuCheck } from "react-icons/lu"; @@ -26,6 +25,7 @@ import { LuCheck } from "react-icons/lu"; import type { DagRunMutableStates, DAGRunResponse } from "openapi/requests/types.gen"; import { StateBadge } from "src/components/StateBadge"; import { IconButton, Menu, Tooltip } from "src/components/ui"; +import { useShortcut } from "src/hooks/useShortcut"; import { allowedStates } from "../utils"; import MarkRunAsDialog from "./MarkRunAsDialog"; @@ -40,23 +40,27 @@ const MarkRunAsButton = ({ dagRun, isHotkeyEnabled = false }: Props) => { const [state, setState] = useState("success"); const { t: translate } = useTranslation(); - useHotkeys( - "shift+f", - () => { + useShortcut({ + callback: () => { setState("failed"); onOpen(); }, - { enabled: isHotkeyEnabled }, - ); + category: "runActions", + description: translate("common:shortcuts.descriptions.markRunFailed"), + keys: "shift+f", + options: { enabled: isHotkeyEnabled }, + }); - useHotkeys( - "shift+s", - () => { + useShortcut({ + callback: () => { setState("success"); onOpen(); }, - { enabled: isHotkeyEnabled }, - ); + category: "runActions", + description: translate("common:shortcuts.descriptions.markRunSuccess"), + keys: "shift+s", + options: { enabled: isHotkeyEnabled }, + }); const label = translate("dags:runAndTaskActions.markAs.button", { type: translate("dagRun_one"), diff --git a/airflow-core/src/airflow/ui/src/components/MarkAs/TaskGroup/MarkTaskGroupAsButton.tsx b/airflow-core/src/airflow/ui/src/components/MarkAs/TaskGroup/MarkTaskGroupAsButton.tsx index fa5cd2322ae5d..c4b89bb8fff07 100644 --- a/airflow-core/src/airflow/ui/src/components/MarkAs/TaskGroup/MarkTaskGroupAsButton.tsx +++ b/airflow-core/src/airflow/ui/src/components/MarkAs/TaskGroup/MarkTaskGroupAsButton.tsx @@ -18,7 +18,6 @@ */ import { Box, HStack, useDisclosure } from "@chakra-ui/react"; import { useState } from "react"; -import { useHotkeys } from "react-hotkeys-hook"; import { useTranslation } from "react-i18next"; import { FiX } from "react-icons/fi"; import { LuCheck } from "react-icons/lu"; @@ -26,6 +25,7 @@ import { LuCheck } from "react-icons/lu"; import type { LightGridTaskInstanceSummary, TaskInstanceState } from "openapi/requests/types.gen"; import { StateBadge } from "src/components/StateBadge"; import { IconButton, Menu, Tooltip } from "src/components/ui"; +import { useShortcut } from "src/hooks/useShortcut"; import { allowedStates } from "../utils"; import MarkTaskGroupAsDialog from "./MarkTaskGroupAsDialog"; @@ -40,23 +40,27 @@ const MarkTaskGroupAsButton = ({ groupTaskInstance, isHotkeyEnabled = false }: P const { t: translate } = useTranslation(); const [state, setState] = useState("success"); - useHotkeys( - "shift+f", - () => { + useShortcut({ + callback: () => { setState("failed"); onOpen(); }, - { enabled: isHotkeyEnabled }, - ); + category: "runActions", + description: translate("common:shortcuts.descriptions.markTaskGroupFailed"), + keys: "shift+f", + options: { enabled: isHotkeyEnabled }, + }); - useHotkeys( - "shift+s", - () => { + useShortcut({ + callback: () => { setState("success"); onOpen(); }, - { enabled: isHotkeyEnabled }, - ); + category: "runActions", + description: translate("common:shortcuts.descriptions.markTaskGroupSuccess"), + keys: "shift+s", + options: { enabled: isHotkeyEnabled }, + }); const label = translate("dags:runAndTaskActions.markAs.button", { type: translate("taskGroup_one"), diff --git a/airflow-core/src/airflow/ui/src/components/MarkAs/TaskInstance/MarkTaskInstanceAsButton.tsx b/airflow-core/src/airflow/ui/src/components/MarkAs/TaskInstance/MarkTaskInstanceAsButton.tsx index 20680890aa72d..cc2cb9e839519 100644 --- a/airflow-core/src/airflow/ui/src/components/MarkAs/TaskInstance/MarkTaskInstanceAsButton.tsx +++ b/airflow-core/src/airflow/ui/src/components/MarkAs/TaskInstance/MarkTaskInstanceAsButton.tsx @@ -18,7 +18,6 @@ */ import { Box, HStack, useDisclosure } from "@chakra-ui/react"; import { useState } from "react"; -import { useHotkeys } from "react-hotkeys-hook"; import { useTranslation } from "react-i18next"; import { FiX } from "react-icons/fi"; import { LuCheck } from "react-icons/lu"; @@ -26,6 +25,7 @@ import { LuCheck } from "react-icons/lu"; import type { TaskInstanceResponse, TaskInstanceState } from "openapi/requests/types.gen"; import { StateBadge } from "src/components/StateBadge"; import { IconButton, Menu, Tooltip } from "src/components/ui"; +import { useShortcut } from "src/hooks/useShortcut"; import { allowedStates } from "../utils"; import MarkTaskInstanceAsDialog from "./MarkTaskInstanceAsDialog"; @@ -41,23 +41,27 @@ const MarkTaskInstanceAsButton = ({ isHotkeyEnabled = false, taskInstance }: Pro const [state, setState] = useState("success"); - useHotkeys( - "shift+f", - () => { + useShortcut({ + callback: () => { setState("failed"); onOpen(); }, - { enabled: isHotkeyEnabled }, - ); + category: "runActions", + description: translate("common:shortcuts.descriptions.markTaskFailed"), + keys: "shift+f", + options: { enabled: isHotkeyEnabled }, + }); - useHotkeys( - "shift+s", - () => { + useShortcut({ + callback: () => { setState("success"); onOpen(); }, - { enabled: isHotkeyEnabled }, - ); + category: "runActions", + description: translate("common:shortcuts.descriptions.markTaskSuccess"), + keys: "shift+s", + options: { enabled: isHotkeyEnabled }, + }); const label = translate("dags:runAndTaskActions.markAs.button", { type: translate("taskInstance_one"), diff --git a/airflow-core/src/airflow/ui/src/components/SearchBar.tsx b/airflow-core/src/airflow/ui/src/components/SearchBar.tsx index cf51c59981ad9..03043c2f6411a 100644 --- a/airflow-core/src/airflow/ui/src/components/SearchBar.tsx +++ b/airflow-core/src/airflow/ui/src/components/SearchBar.tsx @@ -18,12 +18,12 @@ */ import { CloseButton, HStack, Input, InputGroup, Kbd, type InputGroupProps } from "@chakra-ui/react"; import { useEffect, useRef, useState, type ChangeEvent } from "react"; -import { useHotkeys } from "react-hotkeys-hook"; import { useTranslation } from "react-i18next"; import { FiSearch } from "react-icons/fi"; import { useDebouncedCallback } from "use-debounce"; import { AdvancedSearchToggle, type AdvancedSearchToggleProps } from "src/components/AdvancedSearchToggle"; +import { useShortcut } from "src/hooks/useShortcut"; import { getMetaKey } from "src/utils"; const debounceDelay = 200; @@ -74,13 +74,15 @@ export const SearchBar = ({ onChange(""); }; - useHotkeys( - "mod+k", - () => { + useShortcut({ + callback: () => { searchRef.current?.focus(); }, - { enabled: !hotkeyDisabled, preventDefault: true }, - ); + category: "search", + description: translate("common:shortcuts.descriptions.focusSearch"), + keys: "mod+k", + options: { enabled: !hotkeyDisabled, preventDefault: true }, + }); const inputGroup = ( { setIsOpen(false); }; - useHotkeys( - "mod+k", - () => { + useShortcut({ + callback: () => { setIsOpen(true); }, - [isOpen], - { preventDefault: true }, - ); + category: "search", + dependencies: [isOpen], + description: translate("common:shortcuts.descriptions.searchDags"), + keys: "mod+k", + options: { preventDefault: true }, + }); return ( diff --git a/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/Context.ts b/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/Context.ts new file mode 100644 index 0000000000000..e4320ed9c0629 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/Context.ts @@ -0,0 +1,58 @@ +/*! + * 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"; + +// Display order of shortcut categories in the help modal. +export const SHORTCUT_CATEGORIES = [ + "global", + "navigation", + "dagView", + "search", + "filters", + "logs", + "code", + "runActions", +] as const; + +export type ShortcutCategory = (typeof SHORTCUT_CATEGORIES)[number]; + +export type ShortcutEntry = { + readonly category: ShortcutCategory; + readonly description: string; + readonly id: string; + readonly keys: ReadonlyArray; +}; + +export type ShortcutRegistryContextValue = { + readonly register: (entry: ShortcutEntry) => void; + readonly shortcuts: ReadonlyArray; + readonly unregister: (id: string) => void; +}; + +// No-op default so components using shortcuts can render without the provider +// (e.g. in isolated unit tests). The hotkeys still work; they just aren't +// listed in the help dialog. The real app wraps the tree in +// ShortcutRegistryProvider, so the dialog is populated there. +const NOOP_REGISTRY: ShortcutRegistryContextValue = { + register: () => undefined, + shortcuts: [], + unregister: () => undefined, +}; + +export const ShortcutRegistryContext = createContext(NOOP_REGISTRY); diff --git a/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/ShortcutRegistryProvider.tsx b/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/ShortcutRegistryProvider.tsx new file mode 100644 index 0000000000000..40d7096e1df33 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/ShortcutRegistryProvider.tsx @@ -0,0 +1,45 @@ +/*! + * 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 { useCallback, useMemo, useState, type PropsWithChildren } from "react"; + +import { ShortcutRegistryContext, type ShortcutEntry } from "./Context"; + +/** + * Holds the set of keyboard shortcuts that are currently mounted and enabled. + * + * Each `useShortcut` call registers itself here on mount and removes itself on + * unmount, mirroring the lifecycle of the underlying `react-hotkeys-hook` + * binding. The help modal renders from this registry, so it always reflects the + * shortcuts actually available on the current page. + */ +export const ShortcutRegistryProvider = ({ children }: PropsWithChildren) => { + const [shortcuts, setShortcuts] = useState>([]); + + const register = useCallback((entry: ShortcutEntry) => { + setShortcuts((prev) => [...prev.filter((item) => item.id !== entry.id), entry]); + }, []); + + const unregister = useCallback((id: string) => { + setShortcuts((prev) => prev.filter((item) => item.id !== id)); + }, []); + + const value = useMemo(() => ({ register, shortcuts, unregister }), [register, shortcuts, unregister]); + + return {children}; +}; diff --git a/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/index.ts b/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/index.ts new file mode 100644 index 0000000000000..551dd6f3e759e --- /dev/null +++ b/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/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 { SHORTCUT_CATEGORIES, type ShortcutCategory, type ShortcutEntry } from "./Context"; +export { ShortcutRegistryProvider } from "./ShortcutRegistryProvider"; +export { useShortcutRegistry } from "./useShortcutRegistry"; diff --git a/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/useShortcutRegistry.ts b/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/useShortcutRegistry.ts new file mode 100644 index 0000000000000..ff35397696ad8 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/useShortcutRegistry.ts @@ -0,0 +1,23 @@ +/*! + * 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 { ShortcutRegistryContext, type ShortcutRegistryContextValue } from "./Context"; + +export const useShortcutRegistry = (): ShortcutRegistryContextValue => useContext(ShortcutRegistryContext); diff --git a/airflow-core/src/airflow/ui/src/hooks/navigation/useKeyboardNavigation.ts b/airflow-core/src/airflow/ui/src/hooks/navigation/useKeyboardNavigation.ts index 5a99d9cc8f87f..a20f1ec46484e 100644 --- a/airflow-core/src/airflow/ui/src/hooks/navigation/useKeyboardNavigation.ts +++ b/airflow-core/src/airflow/ui/src/hooks/navigation/useKeyboardNavigation.ts @@ -16,7 +16,9 @@ * specific language governing permissions and limitations * under the License. */ -import { useHotkeys } from "react-hotkeys-hook"; +import { useTranslation } from "react-i18next"; + +import { useShortcut } from "src/hooks/useShortcut"; import type { ArrowKey, NavigationDirection } from "./types"; @@ -44,6 +46,8 @@ const mapKeyToDirection = (key: ArrowKey): NavigationDirection => { }; export const useKeyboardNavigation = ({ enabled = true, onNavigate, onToggleGroup }: Props) => { + const { t: translate } = useTranslation("common"); + const handleNormalKeyPress = (event: KeyboardEvent) => { const direction = mapKeyToDirection(event.key as ArrowKey); @@ -55,7 +59,21 @@ export const useKeyboardNavigation = ({ enabled = true, onNavigate, onToggleGrou const hotkeyOptions = { enabled, preventDefault: true }; - useHotkeys(ARROW_KEYS.join(","), handleNormalKeyPress, hotkeyOptions, [onNavigate]); + useShortcut({ + callback: handleNormalKeyPress, + category: "navigation", + dependencies: [onNavigate], + description: translate("shortcuts.descriptions.navigateTasks"), + keys: [...ARROW_KEYS], + options: hotkeyOptions, + }); - useHotkeys("space", () => onToggleGroup?.(), hotkeyOptions, [onToggleGroup]); + useShortcut({ + callback: () => onToggleGroup?.(), + category: "navigation", + dependencies: [onToggleGroup], + description: translate("shortcuts.descriptions.toggleTaskGroup"), + keys: "space", + options: hotkeyOptions, + }); }; diff --git a/airflow-core/src/airflow/ui/src/hooks/useShortcut.test.tsx b/airflow-core/src/airflow/ui/src/hooks/useShortcut.test.tsx new file mode 100644 index 0000000000000..12c582b46183f --- /dev/null +++ b/airflow-core/src/airflow/ui/src/hooks/useShortcut.test.tsx @@ -0,0 +1,95 @@ +/*! + * 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 { renderHook } from "@testing-library/react"; +import type { PropsWithChildren } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { + ShortcutRegistryProvider, + type ShortcutEntry, + useShortcutRegistry, +} from "src/context/keyboardShortcuts"; + +import { useShortcut } from "./useShortcut"; + +const wrapper = ({ children }: PropsWithChildren) => ( + {children} +); + +const renderShortcut = (initialEnabled = true) => + renderHook( + ({ enabled }: { enabled: boolean }): ReadonlyArray => { + useShortcut({ + callback: vi.fn(), + category: "logs", + description: "Toggle wrap", + keys: "w", + options: { enabled }, + }); + + return useShortcutRegistry().shortcuts; + }, + { initialProps: { enabled: initialEnabled }, wrapper }, + ); + +describe("useShortcut", () => { + it("registers the shortcut while mounted", () => { + const { result } = renderShortcut(); + + expect(result.current).toHaveLength(1); + expect(result.current[0]).toMatchObject({ + category: "logs", + description: "Toggle wrap", + keys: ["w"], + }); + }); + + it("removes the shortcut when it becomes disabled", () => { + const { rerender, result } = renderShortcut(true); + + expect(result.current).toHaveLength(1); + rerender({ enabled: false }); + expect(result.current).toHaveLength(0); + }); + + it("does not register a disabled shortcut", () => { + const { result } = renderShortcut(false); + + expect(result.current).toHaveLength(0); + }); + + it("registers each combo of a multi-key shortcut for display", () => { + const { result } = renderHook( + (): ReadonlyArray => { + useShortcut({ + callback: vi.fn(), + category: "navigation", + description: "Navigate tasks", + keys: ["shift+ArrowUp", "shift+ArrowDown"], + }); + + return useShortcutRegistry().shortcuts; + }, + { wrapper }, + ); + + expect(result.current).toHaveLength(1); + expect(result.current[0]?.keys).toEqual(["shift+ArrowUp", "shift+ArrowDown"]); + }); +}); diff --git a/airflow-core/src/airflow/ui/src/hooks/useShortcut.ts b/airflow-core/src/airflow/ui/src/hooks/useShortcut.ts new file mode 100644 index 0000000000000..271575099a324 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/hooks/useShortcut.ts @@ -0,0 +1,73 @@ +/*! + * 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 DependencyList, useEffect, useId, useMemo } from "react"; +import { type HotkeyCallback, type Keys, type Options, useHotkeys } from "react-hotkeys-hook"; + +import { type ShortcutCategory, useShortcutRegistry } from "src/context/keyboardShortcuts"; + +type UseShortcutParams = { + readonly callback: HotkeyCallback; + readonly category: ShortcutCategory; + readonly dependencies?: DependencyList; + readonly description: string; + readonly keys: Keys; + readonly options?: Options; +}; + +/** + * Thin wrapper around `react-hotkeys-hook`'s `useHotkeys` that also publishes the + * shortcut to the keyboard-shortcut registry so it shows up in the help modal. + * + * Behaviour of the underlying hotkey is unchanged: `keys`, `callback`, `options` + * and `dependencies` are forwarded verbatim. The shortcut is registered only + * while it is enabled, matching when the hotkey is actually bound. + */ +export const useShortcut = ({ + callback, + category, + dependencies, + description, + keys, + options, +}: UseShortcutParams) => { + const id = useId(); + const { register, unregister } = useShortcutRegistry(); + + const ref = useHotkeys(keys, callback, options, dependencies); + + const keyList = useMemo>(() => (typeof keys === "string" ? [keys] : [...keys]), [keys]); + // A `false` literal means the hotkey is not bound; a function trigger is evaluated + // per event but the binding still exists, so we treat it as enabled here. + const isEnabled = options?.enabled !== false; + const keyListId = keyList.join(","); + + useEffect(() => { + if (!isEnabled) { + return undefined; + } + + register({ category, description, id, keys: keyList }); + + return () => unregister(id); + // `keyListId` stands in for `keyList`'s identity to avoid re-running on each render. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [category, description, id, isEnabled, keyListId, register, unregister]); + + return ref; +}; diff --git a/airflow-core/src/airflow/ui/src/layouts/BaseLayout.tsx b/airflow-core/src/airflow/ui/src/layouts/BaseLayout.tsx index e06c92bc0e50d..5664e702a33ef 100644 --- a/airflow-core/src/airflow/ui/src/layouts/BaseLayout.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/BaseLayout.tsx @@ -23,6 +23,7 @@ import { Outlet } from "react-router-dom"; import { usePluginServiceGetPlugins } from "openapi/queries"; import type { ReactAppResponse } from "openapi/requests/types.gen"; +import { KeyboardShortcutsModal } from "src/components/KeyboardShortcuts"; import { ReactPlugin } from "src/pages/ReactPlugin"; import { useConfig } from "src/queries/useConfig"; import { DocumentTitleProvider } from "src/utils"; @@ -101,6 +102,7 @@ export const BaseLayout = ({ children }: PropsWithChildren) => { +