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..03af7294afbc5 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 $t(dagRun_one)", + "clearTaskInstance": "Clear $t(taskInstance_one)", + "downloadLogs": "Download logs", + "focusFilterSearch": "Focus filter search", + "focusLogSearch": "Search logs", + "focusSearch": "Focus search", + "markRunFailed": "Mark $t(dagRun_one) as failed", + "markRunSuccess": "Mark $t(dagRun_one) as success", + "markTaskFailed": "Mark $t(task_one) as failed", + "markTaskGroupFailed": "Mark $t(taskGroup_one) as failed", + "markTaskGroupSuccess": "Mark $t(taskGroup_one) as success", + "markTaskSuccess": "Mark $t(task_one) as success", + "navigateTasks": "Navigate $t(task_other)", + "openGraphFilters": "Open graph filters", + "scrollBottom": "Scroll to bottom", + "scrollTop": "Scroll to top", + "searchDags": "Search $t(dag_other)", + "showHelp": "Show $t(shortcuts.title)", + "toggleExpand": "Expand or collapse all groups", + "toggleFullscreen": "Toggle fullscreen", + "toggleGraphGrid": "Toggle graph / grid view", + "toggleSource": "Toggle source", + "toggleTaskGroup": "Expand or collapse $t(taskGroup_one)", + "toggleTimestamp": "Toggle timestamps", + "toggleWrap": "Toggle $t(wrap.wrap)" + }, + "empty": "No keyboard shortcuts are available on this page.", + "title": "Keyboard Shortcuts" + }, "showDetailsPanel": "Show Details Panel", "signedInAs": "Signed in as", "source": { 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 fdb3e941a1aa6..1d99ba1533a8b 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 @@ -107,9 +107,7 @@ "warning": "WARNING" }, "navigation": { - "navigation": "Navigation: Shift+{{arrow}}", - "openGraphFilters": "Task Filters: Ctrl+Shift+F", - "toggleGroup": "Toggle group: Space" + "openGraphFilters": "Task Filters: Ctrl+Shift+F" }, "notFound": { "back": "Go Back", 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..c4c040a1c1bc5 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,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 { DAGRunResponse } from "openapi/requests/types.gen"; import { IconButton } from "src/components/ui"; +import { SHORTCUTS } from "src/context/keyboardShortcuts"; +import { useShortcut } from "src/hooks/useShortcut"; import ClearRunDialog from "./ClearRunDialog"; @@ -35,13 +36,13 @@ const ClearRunButton = ({ dagRun, isHotkeyEnabled = false }: Props) => { const { onClose, onOpen, open } = useDisclosure(); const { t: translate } = useTranslation(); - useHotkeys( - "shift+c", - () => { + useShortcut({ + ...SHORTCUTS.runActions.clearRun, + callback: () => { onOpen(); }, - { enabled: isHotkeyEnabled }, - ); + 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..07bfc56f80c45 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,14 @@ * 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 { SHORTCUTS } from "src/context/keyboardShortcuts"; +import { useShortcut } from "src/hooks/useShortcut"; import ClearTaskInstanceDialog from "./ClearTaskInstanceDialog"; @@ -58,17 +59,17 @@ const ClearTaskInstanceButton = ({ const selectedInstance = taskInstance ?? groupTaskInstance; - useHotkeys( - "shift+c", - () => { + useShortcut({ + ...SHORTCUTS.runActions.clearTaskInstance, + callback: () => { if (onOpen && selectedInstance) { onOpen(selectedInstance); } else { onOpenInternal(); } }, - { enabled: isHotkeyEnabled }, - ); + 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..09f1c5d3ee4ba 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 { LuRegex } from "react-icons/lu"; import { AdvancedSearchToggle } from "src/components/AdvancedSearchToggle"; +import { SHORTCUTS } from "src/context/keyboardShortcuts"; import { useAdvancedSearch } from "src/hooks/useAdvancedSearch"; +import { useShortcut } from "src/hooks/useShortcut"; import { InputWithAddon } from "../../ui"; import { FilterPill } from "../FilterPill"; @@ -42,15 +43,15 @@ export const TextSearchFilter = ({ filter, onChange, onRemove }: FilterPluginPro onChange(newValue || undefined); }; - useHotkeys( - "mod+k", - () => { + useShortcut({ + ...SHORTCUTS.search.focusFilterSearch, + callback: () => { if (!filter.config.hotkeyDisabled) { hotkeyInputRef.current?.focus(); } }, - { enabled: !filter.config.hotkeyDisabled, preventDefault: true }, - ); + 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..208f9a7b14913 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,8 @@ 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 { SHORTCUTS } from "src/context/keyboardShortcuts"; +import { useShortcut } from "src/hooks/useShortcut"; export const GraphTaskFilters = () => { const { t: translate } = useTranslation(["dag", "tasks"]); @@ -140,7 +141,11 @@ export const GraphTaskFilters = () => { const [isOpen, setIsOpen] = useState(false); - useHotkeys("mod+shift+f", () => setIsOpen(true), { preventDefault: true }); + useShortcut({ + ...SHORTCUTS.filters.openGraphFilters, + callback: () => setIsOpen(true), + 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..6ff477c626c30 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/components/KeyboardShortcuts/KeyboardShortcutsModal.tsx @@ -0,0 +1,106 @@ +/*! + * 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 { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { Dialog } from "src/components/ui"; +import { + SHORTCUT_CATEGORIES, + SHORTCUTS, + 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 = () => setOpen((prev) => !prev); + + useShortcut({ + ...SHORTCUTS.global.showHelp, + callback: toggle, + }); + + const groups = buildGroups(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..b89946aa35255 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,8 @@ 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 { SHORTCUTS } from "src/context/keyboardShortcuts"; +import { useShortcut } from "src/hooks/useShortcut"; import { allowedStates } from "../utils"; import MarkRunAsDialog from "./MarkRunAsDialog"; @@ -40,23 +41,23 @@ const MarkRunAsButton = ({ dagRun, isHotkeyEnabled = false }: Props) => { const [state, setState] = useState("success"); const { t: translate } = useTranslation(); - useHotkeys( - "shift+f", - () => { + useShortcut({ + ...SHORTCUTS.runActions.markRunFailed, + callback: () => { setState("failed"); onOpen(); }, - { enabled: isHotkeyEnabled }, - ); + options: { enabled: isHotkeyEnabled }, + }); - useHotkeys( - "shift+s", - () => { + useShortcut({ + ...SHORTCUTS.runActions.markRunSuccess, + callback: () => { setState("success"); onOpen(); }, - { enabled: isHotkeyEnabled }, - ); + 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..9cc44162fb061 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,8 @@ 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 { SHORTCUTS } from "src/context/keyboardShortcuts"; +import { useShortcut } from "src/hooks/useShortcut"; import { allowedStates } from "../utils"; import MarkTaskGroupAsDialog from "./MarkTaskGroupAsDialog"; @@ -40,23 +41,23 @@ const MarkTaskGroupAsButton = ({ groupTaskInstance, isHotkeyEnabled = false }: P const { t: translate } = useTranslation(); const [state, setState] = useState("success"); - useHotkeys( - "shift+f", - () => { + useShortcut({ + ...SHORTCUTS.runActions.markTaskGroupFailed, + callback: () => { setState("failed"); onOpen(); }, - { enabled: isHotkeyEnabled }, - ); + options: { enabled: isHotkeyEnabled }, + }); - useHotkeys( - "shift+s", - () => { + useShortcut({ + ...SHORTCUTS.runActions.markTaskGroupSuccess, + callback: () => { setState("success"); onOpen(); }, - { enabled: isHotkeyEnabled }, - ); + 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..5c235474ac832 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,8 @@ 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 { SHORTCUTS } from "src/context/keyboardShortcuts"; +import { useShortcut } from "src/hooks/useShortcut"; import { allowedStates } from "../utils"; import MarkTaskInstanceAsDialog from "./MarkTaskInstanceAsDialog"; @@ -41,23 +42,23 @@ const MarkTaskInstanceAsButton = ({ isHotkeyEnabled = false, taskInstance }: Pro const [state, setState] = useState("success"); - useHotkeys( - "shift+f", - () => { + useShortcut({ + ...SHORTCUTS.runActions.markTaskFailed, + callback: () => { setState("failed"); onOpen(); }, - { enabled: isHotkeyEnabled }, - ); + options: { enabled: isHotkeyEnabled }, + }); - useHotkeys( - "shift+s", - () => { + useShortcut({ + ...SHORTCUTS.runActions.markTaskSuccess, + callback: () => { setState("success"); onOpen(); }, - { enabled: isHotkeyEnabled }, - ); + 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..57981808c6250 100644 --- a/airflow-core/src/airflow/ui/src/components/SearchBar.tsx +++ b/airflow-core/src/airflow/ui/src/components/SearchBar.tsx @@ -18,12 +18,13 @@ */ 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 { SHORTCUTS } from "src/context/keyboardShortcuts"; +import { useShortcut } from "src/hooks/useShortcut"; import { getMetaKey } from "src/utils"; const debounceDelay = 200; @@ -74,13 +75,13 @@ export const SearchBar = ({ onChange(""); }; - useHotkeys( - "mod+k", - () => { + useShortcut({ + ...SHORTCUTS.search.focusSearch, + callback: () => { searchRef.current?.focus(); }, - { enabled: !hotkeyDisabled, preventDefault: true }, - ); + options: { enabled: !hotkeyDisabled, preventDefault: true }, + }); const inputGroup = ( { setIsOpen(false); }; - useHotkeys( - "mod+k", - () => { + useShortcut({ + ...SHORTCUTS.search.searchDags, + callback: () => { setIsOpen(true); }, - [isOpen], - { preventDefault: true }, - ); + dependencies: [isOpen], + 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..645a5a5da1965 --- /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 { 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 = (entry: ShortcutEntry) => { + setShortcuts((prev) => [...prev.filter((item) => item.id !== entry.id), entry]); + }; + + const unregister = (id: string) => { + setShortcuts((prev) => prev.filter((item) => item.id !== id)); + }; + + const value = { 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..b2634450013b7 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/index.ts @@ -0,0 +1,22 @@ +/*! + * 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 { SHORTCUTS, type ShortcutDefinition } from "./shortcuts"; +export { ShortcutRegistryProvider } from "./ShortcutRegistryProvider"; +export { useShortcutRegistry } from "./useShortcutRegistry"; diff --git a/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/shortcuts.ts b/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/shortcuts.ts new file mode 100644 index 0000000000000..1271037a99f38 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/shortcuts.ts @@ -0,0 +1,179 @@ +/*! + * 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 { Keys } from "react-hotkeys-hook"; + +import type { ShortcutCategory } from "./Context"; + +/** + * Static definition of a keyboard shortcut: its key combination, the category it + * belongs to in the help modal, and the `common` i18n key for its description. + * + * The runtime callback and any dynamic options (e.g. `enabled`) stay at the call + * site, since they depend on component state. + */ +export type ShortcutDefinition = { + readonly category: ShortcutCategory; + readonly descriptionKey: string; + readonly keys: Keys; +}; + +// Every category must be present, and each entry's `category` field must match +// the group it sits in — both are enforced at compile time. +type CategorizedShortcuts = { + readonly [Category in ShortcutCategory]: Record< + string, + { readonly category: Category } & ShortcutDefinition + >; +}; + +/** + * Single source of truth for every keyboard shortcut in the UI, grouped by the + * category shown in the help modal. + * + * Each `useShortcut` call spreads one of these entries (e.g. + * `...SHORTCUTS.logs.toggleWrap`) instead of redeclaring its keys, category and + * description inline, so maintainers can see every shortcut that already exists + * in one place and avoid clashing key bindings. Description text lives in + * `common.json` under the referenced `descriptionKey`. + */ +export const SHORTCUTS = { + code: { + toggleFullscreen: { + category: "code", + descriptionKey: "shortcuts.descriptions.toggleFullscreen", + keys: "f", + }, + toggleWrap: { category: "code", descriptionKey: "shortcuts.descriptions.toggleWrap", keys: "w" }, + }, + dagView: { + toggleGraphGrid: { + category: "dagView", + descriptionKey: "shortcuts.descriptions.toggleGraphGrid", + keys: "g", + }, + }, + filters: { + openGraphFilters: { + category: "filters", + descriptionKey: "shortcuts.descriptions.openGraphFilters", + keys: "mod+shift+f", + }, + }, + global: { + showHelp: { + category: "global", + descriptionKey: "shortcuts.descriptions.showHelp", + keys: "shift+Slash", + }, + }, + logs: { + downloadLogs: { category: "logs", descriptionKey: "shortcuts.descriptions.downloadLogs", keys: "d" }, + focusLogSearch: { + category: "logs", + descriptionKey: "shortcuts.descriptions.focusLogSearch", + keys: "/", + }, + scrollBottom: { + category: "logs", + descriptionKey: "shortcuts.descriptions.scrollBottom", + keys: "mod+ArrowDown", + }, + scrollTop: { category: "logs", descriptionKey: "shortcuts.descriptions.scrollTop", keys: "mod+ArrowUp" }, + toggleExpand: { category: "logs", descriptionKey: "shortcuts.descriptions.toggleExpand", keys: "e" }, + toggleFullscreen: { + category: "logs", + descriptionKey: "shortcuts.descriptions.toggleFullscreen", + keys: "f", + }, + toggleSource: { category: "logs", descriptionKey: "shortcuts.descriptions.toggleSource", keys: "s" }, + toggleTimestamp: { + category: "logs", + descriptionKey: "shortcuts.descriptions.toggleTimestamp", + keys: "t", + }, + toggleWrap: { category: "logs", descriptionKey: "shortcuts.descriptions.toggleWrap", keys: "w" }, + }, + navigation: { + navigateTasks: { + category: "navigation", + descriptionKey: "shortcuts.descriptions.navigateTasks", + keys: ["shift+ArrowDown", "shift+ArrowUp", "shift+ArrowLeft", "shift+ArrowRight"], + }, + toggleTaskGroup: { + category: "navigation", + descriptionKey: "shortcuts.descriptions.toggleTaskGroup", + keys: "space", + }, + }, + runActions: { + clearRun: { + category: "runActions", + descriptionKey: "shortcuts.descriptions.clearRun", + keys: "shift+c", + }, + clearTaskInstance: { + category: "runActions", + descriptionKey: "shortcuts.descriptions.clearTaskInstance", + keys: "shift+c", + }, + markRunFailed: { + category: "runActions", + descriptionKey: "shortcuts.descriptions.markRunFailed", + keys: "shift+f", + }, + markRunSuccess: { + category: "runActions", + descriptionKey: "shortcuts.descriptions.markRunSuccess", + keys: "shift+s", + }, + markTaskFailed: { + category: "runActions", + descriptionKey: "shortcuts.descriptions.markTaskFailed", + keys: "shift+f", + }, + markTaskGroupFailed: { + category: "runActions", + descriptionKey: "shortcuts.descriptions.markTaskGroupFailed", + keys: "shift+f", + }, + markTaskGroupSuccess: { + category: "runActions", + descriptionKey: "shortcuts.descriptions.markTaskGroupSuccess", + keys: "shift+s", + }, + markTaskSuccess: { + category: "runActions", + descriptionKey: "shortcuts.descriptions.markTaskSuccess", + keys: "shift+s", + }, + }, + search: { + focusFilterSearch: { + category: "search", + descriptionKey: "shortcuts.descriptions.focusFilterSearch", + keys: "mod+k", + }, + focusSearch: { + category: "search", + descriptionKey: "shortcuts.descriptions.focusSearch", + keys: "mod+k", + }, + searchDags: { category: "search", descriptionKey: "shortcuts.descriptions.searchDags", keys: "mod+k" }, + }, +} satisfies CategorizedShortcuts; 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..cfeb0976c3c36 100644 --- a/airflow-core/src/airflow/ui/src/hooks/navigation/useKeyboardNavigation.ts +++ b/airflow-core/src/airflow/ui/src/hooks/navigation/useKeyboardNavigation.ts @@ -16,12 +16,11 @@ * specific language governing permissions and limitations * under the License. */ -import { useHotkeys } from "react-hotkeys-hook"; +import { SHORTCUTS } from "src/context/keyboardShortcuts"; +import { useShortcut } from "src/hooks/useShortcut"; import type { ArrowKey, NavigationDirection } from "./types"; -const ARROW_KEYS = ["shift+ArrowDown", "shift+ArrowUp", "shift+ArrowLeft", "shift+ArrowRight"] as const; - type Props = { enabled?: boolean; onNavigate: (direction: NavigationDirection) => void; @@ -55,7 +54,17 @@ export const useKeyboardNavigation = ({ enabled = true, onNavigate, onToggleGrou const hotkeyOptions = { enabled, preventDefault: true }; - useHotkeys(ARROW_KEYS.join(","), handleNormalKeyPress, hotkeyOptions, [onNavigate]); + useShortcut({ + ...SHORTCUTS.navigation.navigateTasks, + callback: handleNormalKeyPress, + dependencies: [onNavigate], + options: hotkeyOptions, + }); - useHotkeys("space", () => onToggleGroup?.(), hotkeyOptions, [onToggleGroup]); + useShortcut({ + ...SHORTCUTS.navigation.toggleTaskGroup, + callback: () => onToggleGroup?.(), + dependencies: [onToggleGroup], + 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..45e1f5bf6a80d --- /dev/null +++ b/airflow-core/src/airflow/ui/src/hooks/useShortcut.test.tsx @@ -0,0 +1,111 @@ +/*! + * 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 mockTranslate = vi.fn((key: string) => { + const translations: Record = { + "shortcuts.descriptions.toggleWrap": "Toggle Wrap", + }; + + return translations[key] ?? key; +}); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + i18n: { language: "en" }, + // eslint-disable-next-line id-length + t: mockTranslate, + }), +})); + +const wrapper = ({ children }: PropsWithChildren) => ( + {children} +); + +const renderShortcut = (initialEnabled = true) => + renderHook( + ({ enabled }: { enabled: boolean }): ReadonlyArray => { + useShortcut({ + callback: vi.fn(), + category: "logs", + descriptionKey: "shortcuts.descriptions.toggleWrap", + 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", + descriptionKey: "shortcuts.descriptions.navigateTasks", + 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..548389657d5db --- /dev/null +++ b/airflow-core/src/airflow/ui/src/hooks/useShortcut.ts @@ -0,0 +1,76 @@ +/*! + * 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 } from "react"; +import { type HotkeyCallback, type Options, useHotkeys } from "react-hotkeys-hook"; +import { useTranslation } from "react-i18next"; + +import { type ShortcutDefinition, useShortcutRegistry } from "src/context/keyboardShortcuts"; + +type UseShortcutParams = { + readonly callback: HotkeyCallback; + readonly dependencies?: DependencyList; + readonly options?: Options; +} & ShortcutDefinition; + +/** + * 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. + * + * The static `category`, `descriptionKey` and `keys` come from a `SHORTCUTS` + * definition (spread in at the call site); `callback`, `options` and + * `dependencies` stay at the call site since they depend on component state. The + * shortcut is registered only while it is enabled, matching when the hotkey is + * actually bound. + */ +export const useShortcut = ({ + callback, + category, + dependencies, + descriptionKey, + keys, + options, +}: UseShortcutParams) => { + const id = useId(); + const { t: translate } = useTranslation("common"); + const { register, unregister } = useShortcutRegistry(); + + const ref = useHotkeys(keys, callback, options, dependencies); + + const description = translate(descriptionKey); + + const keyList: Array = typeof keys === "string" ? [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) => { +