diff --git a/frontend/src/AppRoutes/index.tsx b/frontend/src/AppRoutes/index.tsx index 4b7d30c5ff..f1927c9a4c 100644 --- a/frontend/src/AppRoutes/index.tsx +++ b/frontend/src/AppRoutes/index.tsx @@ -8,6 +8,7 @@ import { LOCALSTORAGE } from 'constants/localStorage'; import ROUTES from 'constants/routes'; import AppLayout from 'container/AppLayout'; import useAnalytics from 'hooks/analytics/useAnalytics'; +import { KeyboardHotkeysProvider } from 'hooks/hotkeys/useKeyboardHotkeys'; import { useThemeConfig } from 'hooks/useDarkMode'; import useGetFeatureFlag from 'hooks/useGetFeatureFlag'; import useLicense, { LICENSE_PLAN_KEY } from 'hooks/useLicense'; @@ -177,22 +178,24 @@ function App(): JSX.Element { - - }> - - {routes.map(({ path, component, exact }) => ( - - ))} + + + }> + + {routes.map(({ path, component, exact }) => ( + + ))} - - - - + + + + + diff --git a/frontend/src/constants/shortcuts/globalShortcuts.ts b/frontend/src/constants/shortcuts/globalShortcuts.ts new file mode 100644 index 0000000000..0a439cee77 --- /dev/null +++ b/frontend/src/constants/shortcuts/globalShortcuts.ts @@ -0,0 +1,3 @@ +export const GlobalShortcuts = { + SidebarCollapse: '\\+meta', +}; diff --git a/frontend/src/container/SideNav/SideNav.tsx b/frontend/src/container/SideNav/SideNav.tsx index b7a06ff0e5..aaf516835e 100644 --- a/frontend/src/container/SideNav/SideNav.tsx +++ b/frontend/src/container/SideNav/SideNav.tsx @@ -8,7 +8,9 @@ import cx from 'classnames'; import { IS_SIDEBAR_COLLAPSED } from 'constants/app'; import { FeatureKeys } from 'constants/features'; import ROUTES from 'constants/routes'; +import { GlobalShortcuts } from 'constants/shortcuts/globalShortcuts'; import { ToggleButton } from 'container/Header/styles'; +import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys'; import useComponentPermission from 'hooks/useComponentPermission'; import useThemeMode, { useIsDarkMode } from 'hooks/useDarkMode'; import { LICENSE_PLAN_KEY, LICENSE_PLAN_STATUS } from 'hooks/useLicense'; @@ -98,6 +100,8 @@ function SideNav({ const [inviteMembers] = useComponentPermission(['invite_members'], role); + const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys(); + useEffect(() => { if (inviteMembers) { const updatedUserManagementMenuItems = [ @@ -156,6 +160,15 @@ function SideNav({ dispatch(sideBarCollapse(collapsed)); }, [collapsed, dispatch]); + useEffect(() => { + registerShortcut(GlobalShortcuts.SidebarCollapse, onCollapse); + + return (): void => { + deregisterShortcut(GlobalShortcuts.SidebarCollapse); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const isLicenseActive = licenseData?.payload?.licenses?.find((e: License) => e.isCurrent)?.status === LICENSE_PLAN_STATUS.VALID; diff --git a/frontend/src/hooks/hotkeys/__tests__/useKeyboardHotkeys.test.tsx b/frontend/src/hooks/hotkeys/__tests__/useKeyboardHotkeys.test.tsx new file mode 100644 index 0000000000..11e26b06f1 --- /dev/null +++ b/frontend/src/hooks/hotkeys/__tests__/useKeyboardHotkeys.test.tsx @@ -0,0 +1,75 @@ +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { + KeyboardHotkeysProvider, + useKeyboardHotkeys, +} from '../useKeyboardHotkeys'; + +function TestComponentWithRegister({ + handleShortcut, +}: { + handleShortcut: () => void; +}): JSX.Element { + const { registerShortcut } = useKeyboardHotkeys(); + + registerShortcut('a', handleShortcut); + + return ( +
+ Test Component +
+ ); +} +function TestComponentWithDeRegister({ + handleShortcut, +}: { + handleShortcut: () => void; +}): JSX.Element { + const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys(); + + registerShortcut('b', handleShortcut); + + // Deregister the shortcut before triggering it + deregisterShortcut('b'); + + return ( +
+ Test Component +
+ ); +} + +describe('KeyboardHotkeysProvider', () => { + it('registers and triggers shortcuts correctly', async () => { + const handleShortcut = jest.fn(); + + render( + + + , + ); + + // Trigger the registered shortcut + await userEvent.keyboard('a'); + + // Assert that the handleShortcut function has been called + expect(handleShortcut).toHaveBeenCalled(); + }); + + it('deregisters shortcuts correctly', () => { + const handleShortcut = jest.fn(); + + render( + + + , + ); + + // Try to trigger the deregistered shortcut + userEvent.keyboard('b'); + + // Assert that the handleShortcut function has NOT been called + expect(handleShortcut).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/hooks/hotkeys/useKeyboardHotkeys.tsx b/frontend/src/hooks/hotkeys/useKeyboardHotkeys.tsx new file mode 100644 index 0000000000..ec1b861664 --- /dev/null +++ b/frontend/src/hooks/hotkeys/useKeyboardHotkeys.tsx @@ -0,0 +1,121 @@ +import { noop, unset } from 'lodash-es'; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, +} from 'react'; + +interface KeyboardHotkeysContextReturnValue { + /** + * @param keyCombination provide the string for which the subsequent callback should be triggered. Example 'ctrl+a' + * @param callback the callback that should be triggered when the above key combination is being pressed + * @returns void + */ + registerShortcut: (keyCombination: string, callback: () => void) => void; + + /** + * + * @param keyCombination provide the string for which we want to deregister the callback + * @returns void + */ + deregisterShortcut: (keyCombination: string) => void; +} + +const KeyboardHotkeysContext = createContext( + { + registerShortcut: noop, + deregisterShortcut: noop, + }, +); + +const IGNORE_INPUTS = ['input', 'textarea']; // Inputs in which hotkey events will be ignored + +const useKeyboardHotkeys = (): KeyboardHotkeysContextReturnValue => { + const context = useContext(KeyboardHotkeysContext); + if (!context) { + throw new Error( + 'useKeyboardHotkeys must be used within a KeyboardHotkeysProvider', + ); + } + + return context; +}; + +function KeyboardHotkeysProvider({ + children, +}: { + children: JSX.Element; +}): JSX.Element { + const shortcuts = useRef void>>({}); + + const handleKeyPress = (event: KeyboardEvent): void => { + const { key, ctrlKey, altKey, shiftKey, metaKey, target } = event; + + if (IGNORE_INPUTS.includes((target as HTMLElement).tagName.toLowerCase())) { + return; + } + + // https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/metaKey + const modifiers = { ctrlKey, altKey, shiftKey, metaKey }; + + let shortcutKey = `${key.toLowerCase()}`; + + const isAltKey = `${modifiers.altKey ? '+alt' : ''}`; + const isShiftKey = `${modifiers.shiftKey ? '+shift' : ''}`; + + // ctrl and cmd have the same functionality for mac and windows parity + const isMetaKey = `${modifiers.metaKey || modifiers.ctrlKey ? '+meta' : ''}`; + + shortcutKey = shortcutKey + isAltKey + isShiftKey + isMetaKey; + + if (shortcuts.current[shortcutKey]) { + shortcuts.current[shortcutKey](); + } + }; + + useEffect(() => { + document.addEventListener('keydown', handleKeyPress); + return (): void => { + document.removeEventListener('keydown', handleKeyPress); + }; + }, []); + + const registerShortcut = useCallback( + (keyCombination: string, callback: () => void): void => { + if (!shortcuts.current[keyCombination]) { + shortcuts.current[keyCombination] = callback; + } else { + throw new Error('This shortcut is already present in current scope'); + } + }, + [shortcuts], + ); + + const deregisterShortcut = useCallback( + (keyCombination: string): void => { + if (shortcuts.current[keyCombination]) { + unset(shortcuts.current, keyCombination); + } + }, + [shortcuts], + ); + + const contextValue = useMemo( + () => ({ + registerShortcut, + deregisterShortcut, + }), + [registerShortcut, deregisterShortcut], + ); + + return ( + + {children} + + ); +} + +export { KeyboardHotkeysProvider, useKeyboardHotkeys };