mirror of
https://git.mirrors.martin98.com/https://github.com/SigNoz/signoz
synced 2025-08-01 00:11:59 +08:00
feat: setup the context for keyboard hotkeys (#4493)
* feat: setup the context for keyboard hotkeys * feat: add error handling for duplicate callbacks * feat: supported added for caps and document the return value * feat: added shortcut for cmd+b for sideNav open and close * feat: added jest test * fix: address review comments * fix: block the browser default actions wherever possible * fix: remove browser ovverides prevention code
This commit is contained in:
parent
554c4332c4
commit
f6ab060545
@ -8,6 +8,7 @@ import { LOCALSTORAGE } from 'constants/localStorage';
|
|||||||
import ROUTES from 'constants/routes';
|
import ROUTES from 'constants/routes';
|
||||||
import AppLayout from 'container/AppLayout';
|
import AppLayout from 'container/AppLayout';
|
||||||
import useAnalytics from 'hooks/analytics/useAnalytics';
|
import useAnalytics from 'hooks/analytics/useAnalytics';
|
||||||
|
import { KeyboardHotkeysProvider } from 'hooks/hotkeys/useKeyboardHotkeys';
|
||||||
import { useThemeConfig } from 'hooks/useDarkMode';
|
import { useThemeConfig } from 'hooks/useDarkMode';
|
||||||
import useGetFeatureFlag from 'hooks/useGetFeatureFlag';
|
import useGetFeatureFlag from 'hooks/useGetFeatureFlag';
|
||||||
import useLicense, { LICENSE_PLAN_KEY } from 'hooks/useLicense';
|
import useLicense, { LICENSE_PLAN_KEY } from 'hooks/useLicense';
|
||||||
@ -177,22 +178,24 @@ function App(): JSX.Element {
|
|||||||
<ResourceProvider>
|
<ResourceProvider>
|
||||||
<QueryBuilderProvider>
|
<QueryBuilderProvider>
|
||||||
<DashboardProvider>
|
<DashboardProvider>
|
||||||
<AppLayout>
|
<KeyboardHotkeysProvider>
|
||||||
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
|
<AppLayout>
|
||||||
<Switch>
|
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
|
||||||
{routes.map(({ path, component, exact }) => (
|
<Switch>
|
||||||
<Route
|
{routes.map(({ path, component, exact }) => (
|
||||||
key={`${path}`}
|
<Route
|
||||||
exact={exact}
|
key={`${path}`}
|
||||||
path={path}
|
exact={exact}
|
||||||
component={component}
|
path={path}
|
||||||
/>
|
component={component}
|
||||||
))}
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
<Route path="*" component={NotFound} />
|
<Route path="*" component={NotFound} />
|
||||||
</Switch>
|
</Switch>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
|
</KeyboardHotkeysProvider>
|
||||||
</DashboardProvider>
|
</DashboardProvider>
|
||||||
</QueryBuilderProvider>
|
</QueryBuilderProvider>
|
||||||
</ResourceProvider>
|
</ResourceProvider>
|
||||||
|
3
frontend/src/constants/shortcuts/globalShortcuts.ts
Normal file
3
frontend/src/constants/shortcuts/globalShortcuts.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export const GlobalShortcuts = {
|
||||||
|
SidebarCollapse: '\\+meta',
|
||||||
|
};
|
@ -8,7 +8,9 @@ import cx from 'classnames';
|
|||||||
import { IS_SIDEBAR_COLLAPSED } from 'constants/app';
|
import { IS_SIDEBAR_COLLAPSED } from 'constants/app';
|
||||||
import { FeatureKeys } from 'constants/features';
|
import { FeatureKeys } from 'constants/features';
|
||||||
import ROUTES from 'constants/routes';
|
import ROUTES from 'constants/routes';
|
||||||
|
import { GlobalShortcuts } from 'constants/shortcuts/globalShortcuts';
|
||||||
import { ToggleButton } from 'container/Header/styles';
|
import { ToggleButton } from 'container/Header/styles';
|
||||||
|
import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
|
||||||
import useComponentPermission from 'hooks/useComponentPermission';
|
import useComponentPermission from 'hooks/useComponentPermission';
|
||||||
import useThemeMode, { useIsDarkMode } from 'hooks/useDarkMode';
|
import useThemeMode, { useIsDarkMode } from 'hooks/useDarkMode';
|
||||||
import { LICENSE_PLAN_KEY, LICENSE_PLAN_STATUS } from 'hooks/useLicense';
|
import { LICENSE_PLAN_KEY, LICENSE_PLAN_STATUS } from 'hooks/useLicense';
|
||||||
@ -98,6 +100,8 @@ function SideNav({
|
|||||||
|
|
||||||
const [inviteMembers] = useComponentPermission(['invite_members'], role);
|
const [inviteMembers] = useComponentPermission(['invite_members'], role);
|
||||||
|
|
||||||
|
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (inviteMembers) {
|
if (inviteMembers) {
|
||||||
const updatedUserManagementMenuItems = [
|
const updatedUserManagementMenuItems = [
|
||||||
@ -156,6 +160,15 @@ function SideNav({
|
|||||||
dispatch(sideBarCollapse(collapsed));
|
dispatch(sideBarCollapse(collapsed));
|
||||||
}, [collapsed, dispatch]);
|
}, [collapsed, dispatch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
registerShortcut(GlobalShortcuts.SidebarCollapse, onCollapse);
|
||||||
|
|
||||||
|
return (): void => {
|
||||||
|
deregisterShortcut(GlobalShortcuts.SidebarCollapse);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
const isLicenseActive =
|
const isLicenseActive =
|
||||||
licenseData?.payload?.licenses?.find((e: License) => e.isCurrent)?.status ===
|
licenseData?.payload?.licenses?.find((e: License) => e.isCurrent)?.status ===
|
||||||
LICENSE_PLAN_STATUS.VALID;
|
LICENSE_PLAN_STATUS.VALID;
|
||||||
|
@ -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 (
|
||||||
|
<div>
|
||||||
|
<span>Test Component</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function TestComponentWithDeRegister({
|
||||||
|
handleShortcut,
|
||||||
|
}: {
|
||||||
|
handleShortcut: () => void;
|
||||||
|
}): JSX.Element {
|
||||||
|
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
|
||||||
|
|
||||||
|
registerShortcut('b', handleShortcut);
|
||||||
|
|
||||||
|
// Deregister the shortcut before triggering it
|
||||||
|
deregisterShortcut('b');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span>Test Component</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('KeyboardHotkeysProvider', () => {
|
||||||
|
it('registers and triggers shortcuts correctly', async () => {
|
||||||
|
const handleShortcut = jest.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<KeyboardHotkeysProvider>
|
||||||
|
<TestComponentWithRegister handleShortcut={handleShortcut} />
|
||||||
|
</KeyboardHotkeysProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
<KeyboardHotkeysProvider>
|
||||||
|
<TestComponentWithDeRegister handleShortcut={handleShortcut} />
|
||||||
|
</KeyboardHotkeysProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Try to trigger the deregistered shortcut
|
||||||
|
userEvent.keyboard('b');
|
||||||
|
|
||||||
|
// Assert that the handleShortcut function has NOT been called
|
||||||
|
expect(handleShortcut).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
121
frontend/src/hooks/hotkeys/useKeyboardHotkeys.tsx
Normal file
121
frontend/src/hooks/hotkeys/useKeyboardHotkeys.tsx
Normal file
@ -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<KeyboardHotkeysContextReturnValue>(
|
||||||
|
{
|
||||||
|
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<Record<string, () => 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 (
|
||||||
|
<KeyboardHotkeysContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</KeyboardHotkeysContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { KeyboardHotkeysProvider, useKeyboardHotkeys };
|
Loading…
x
Reference in New Issue
Block a user