2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-06-11 19:27:02 +00:00

feat(frontend): add hotkey registration function and hotkey helper modal (#12128)

* add new hotkey registration interface and hotkey modal

* fix import

* add printing hotkey

* add todo

* add hotkey for barcode scanning

* register spotlight shortcut key

* sort keys

* render nicer overview

* fix props

* expose for plugins

---------

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
This commit is contained in:
Matthias Mair
2026-06-09 05:05:52 +02:00
committed by GitHub
parent 4fb4ba7b2e
commit 3d527eb392
15 changed files with 220 additions and 20 deletions
+4
View File
@@ -2,6 +2,10 @@
This file contains historical changelog information for the InvenTree UI components library.
### 1.4.6 - June 2026
Adds `useInvenTreeHotkeys` hook that mirrors mantine's `useHotkeys` hook, but adds visibility of the hotkeys to the user interface.
### 1.4.5 - June 2026
Fixes callback signature for `<LocalizedComponent>` to allow for an optional `loadLocale` function to be passed in, which is used to dynamically load locale messages for the plugin.
+31
View File
@@ -1,6 +1,37 @@
import { useHotkeys } from '@mantine/hooks';
import type { HotkeyItemOptions } from '@mantine/hooks';
import { useEffect } from 'react';
import { useLocalLibState } from '..';
// Helper function to cancel event propagation
export function cancelEvent(event: any) {
event?.preventDefault();
event?.stopPropagation();
event?.nativeEvent?.stopImmediatePropagation();
}
export type InvenTreeHotkeyItem = [
string,
string,
(event: KeyboardEvent) => void,
HotkeyItemOptions?
];
export function useInvenTreeHotkeys(hotkeys: InvenTreeHotkeyItem[]) {
// Register the hotkeys using the Mantine hook
useHotkeys(
hotkeys.map(([key, _, handler, options]) => [key, handler, options])
);
// register to helper state to store hotkeys
// This allows us to display the hotkeys in the UI
const keyelems: [string, string][] = hotkeys.map(([key, description]) => [
key,
description
]);
useEffect(() => {
useLocalLibState.getState().addHotkeys(keyelems);
return () =>
useLocalLibState.getState().removeHotkeys(keyelems.map(([key]) => key));
}, []);
}
+2
View File
@@ -139,6 +139,8 @@ export {
type TableStateExtraProps
} from './hooks/UseTable';
export { useInvenTreeHotkeys } from './functions/Events';
export {
type DrawerProps,
DetailDrawer,
+19
View File
@@ -10,8 +10,24 @@ export const useLocalLibState = create<LocalLibStateProps>()(
detailDrawerStack:
value === false ? 0 : get().detailDrawerStack + value
});
},
hotkeys: {},
addHotkeys: (hotkeys) => {
const newHotkeys = { ...get().hotkeys };
for (const [ref, details] of hotkeys) {
newHotkeys[ref] = details;
}
set({ hotkeys: newHotkeys });
},
removeHotkeys: (hotkeys) => {
const newHotkeys = { ...get().hotkeys };
for (const ref of hotkeys) {
delete newHotkeys[ref];
}
set({ hotkeys: newHotkeys });
}
}),
{
name: 'session-settings-inventreedb_lib'
}
@@ -20,4 +36,7 @@ export const useLocalLibState = create<LocalLibStateProps>()(
export interface LocalLibStateProps {
detailDrawerStack: number;
addDetailDrawer: (value: number | false) => void;
hotkeys: Record<string, string>;
addHotkeys: (hotkeys: [string, string][]) => void;
removeHotkeys: (hotkeys: string[]) => void;
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@inventreedb/ui",
"description": "UI components for the InvenTree project",
"version": "1.4.5",
"version": "1.4.6",
"private": false,
"type": "module",
"license": "MIT",
@@ -1,6 +1,7 @@
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import type { ModelType } from '@lib/enums/ModelType';
import { apiUrl } from '@lib/functions/Api';
import { useInvenTreeHotkeys } from '@lib/functions/Events';
import type { ApiFormFieldSet } from '@lib/types/Forms';
import { t } from '@lingui/core/macro';
import { IconPrinter, IconReport, IconTags } from '@tabler/icons-react';
@@ -34,6 +35,33 @@ export function PrintingActions({
const enabled = useMemo(() => items.length > 0, [items]);
useInvenTreeHotkeys([
[
'mod+P',
t`Open Print Report dialog`,
(event) => {
if (event.repeat) {
return;
}
if (enabled && !hidden) {
reportModal.open();
}
}
],
[
'mod+L',
t`Open Print Label dialog`,
(event) => {
if (event.repeat) {
return;
}
if (enabled && !hidden) {
labelModal.open();
}
}
]
]);
const defaultLabelPlugin = useMemo(
() => userSettings.getSetting('LABEL_DEFAULT_PRINTER'),
[userSettings]
@@ -1,3 +1,4 @@
import { useInvenTreeHotkeys } from '@lib/functions/Events';
import type { ModelType } from '@lib/index';
import { t } from '@lingui/core/macro';
import { ActionIcon, Tooltip } from '@mantine/core';
@@ -14,14 +15,28 @@ import BarcodeScanDialog, {
export function ScanButton({
modelType,
callback,
onScanSuccess
onScanSuccess,
hotkey = false
}: {
modelType?: ModelType;
callback?: BarcodeScanCallback;
onScanSuccess?: BarcodeScanSuccessCallback;
hotkey?: boolean;
}) {
const [opened, { open, close }] = useDisclosure(false);
if (hotkey) {
useInvenTreeHotkeys([
[
'mod+b',
t`Open barcode scanner`,
() => {
open();
}
]
]);
}
return (
<>
<Tooltip position='bottom-end' label={t`Scan Barcode`}>
@@ -2,12 +2,18 @@ import { t } from '@lingui/core/macro';
import { ActionIcon, Tooltip } from '@mantine/core';
import { IconCommand } from '@tabler/icons-react';
import { firstSpotlight } from '../nav/Layout';
import { useLocalLibState } from '@lib/states/LocalLibState';
import { firstSpotlight, searchShortcutKey } from '../nav/Layout';
/**
* A button which opens the quick command modal
*/
export function SpotlightButton() {
export function SpotlightButton({ hotkey = false }: { hotkey?: boolean }) {
if (hotkey) {
useLocalLibState
.getState()
.addHotkeys([[searchShortcutKey, t`Open spotlight`]]);
}
return (
<Tooltip position='bottom-end' label={t`Open spotlight`}>
<ActionIcon
@@ -8,11 +8,12 @@ import {
Space,
Text
} from '@mantine/core';
import { useDisclosure, useHotkeys } from '@mantine/hooks';
import { useDisclosure } from '@mantine/hooks';
import { IconExclamationCircle, IconInfoCircle } from '@tabler/icons-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { type Layout, Responsive, WidthProvider } from 'react-grid-layout';
import { useInvenTreeHotkeys } from '@lib/functions/Events';
import { useShallow } from 'zustand/react/shallow';
import { useDashboardItems } from '../../hooks/UseDashboardItems';
import { useLocalState } from '../../states/LocalState';
@@ -61,9 +62,10 @@ export default function DashboardLayout() {
const [loaded, setLoaded] = useState(false);
// Keyboard shortcut for editing the dashboard layout
useHotkeys([
useInvenTreeHotkeys([
[
'mod+E',
t`Toggle dashboard edit mode`,
() => {
setEditing.toggle();
}
@@ -0,0 +1,66 @@
import { useLocalLibState } from '@lib/states/LocalLibState';
import { Kbd, Table } from '@mantine/core';
import { type UseOSReturnValue, useOs } from '@mantine/hooks';
import type { ContextModalProps } from '@mantine/modals';
import { Fragment, useMemo } from 'react';
function modRenderer(value: string, os: UseOSReturnValue) {
if (os === 'macos') {
return value.replace('mod', '⌘');
}
return value.replace('mod', 'Ctrl');
}
function kbdRenderer(value: string, os: UseOSReturnValue) {
const parts = value.split('+');
if (parts.length > 1) {
return (
<>
{parts.map((part, idx) => (
<Fragment key={idx}>
<Kbd>{modRenderer(part, os)}</Kbd>
{idx < parts.length - 1 && ' + '}
</Fragment>
))}
</>
);
}
return <Kbd>{modRenderer(value, os)}</Kbd>;
}
export function HotkeyModal({
context,
id
}: ContextModalProps<{ modalBody: string }>) {
const os = useOs();
const hotkeys = useMemo(() => {
const keys = Object.entries(useLocalLibState.getState().hotkeys).map(
([hotkey, description]) => {
return {
key: hotkey,
dec: description
};
}
);
keys.sort((a, b) => a.key.localeCompare(b.key));
return keys;
}, []);
const data = useMemo(() => {
return {
head: ['Hotkey', 'Action'],
body: [...hotkeys.map((item) => [kbdRenderer(item.key, os), item.dec])]
};
}, [os, hotkeys]);
return (
<Table
striped
highlightOnHover
withColumnBorders
horizontalSpacing='md'
verticalSpacing='xs'
data={data}
/>
);
}
+7 -8
View File
@@ -10,11 +10,7 @@ import {
Tooltip,
UnstyledButton
} from '@mantine/core';
import {
useDisclosure,
useDocumentVisibility,
useHotkeys
} from '@mantine/hooks';
import { useDisclosure, useDocumentVisibility } from '@mantine/hooks';
import { IconBell, IconSearch, IconUserBolt } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { type ReactNode, useEffect, useMemo, useState } from 'react';
@@ -22,6 +18,7 @@ import { useMatch, useNavigate } from 'react-router-dom';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { apiUrl } from '@lib/functions/Api';
import { useInvenTreeHotkeys } from '@lib/functions/Events';
import { getBaseUrl } from '@lib/functions/Navigation';
import { navigateToLink } from '@lib/functions/Navigation';
import { t } from '@lingui/core/macro';
@@ -60,15 +57,17 @@ export function Header() {
{ open: openSearchDrawer, close: closeSearchDrawer }
] = useDisclosure(false);
useHotkeys([
useInvenTreeHotkeys([
[
'/',
t`Open search`,
() => {
openSearchDrawer();
}
],
[
'mod+/',
t`Open search`,
() => {
openSearchDrawer();
}
@@ -198,8 +197,8 @@ export function Header() {
<IconSearch />
</ActionIcon>
</Tooltip>
{userSettings.isSet('SHOW_SPOTLIGHT') && <SpotlightButton />}
{globalSettings.isSet('BARCODE_ENABLE') && <ScanButton />}
{userSettings.isSet('SHOW_SPOTLIGHT') && <SpotlightButton hotkey />}
{globalSettings.isSet('BARCODE_ENABLE') && <ScanButton hotkey />}
<Indicator
radius='lg'
size='18'
+3 -1
View File
@@ -54,6 +54,8 @@ export const ProtectedRoute = ({ children }: { children: JSX.Element }) => {
export const [firstStore, firstSpotlight] = createSpotlight();
export const searchShortcutKey = 'mod+K';
export default function LayoutComponent() {
const navigate = useNavigate();
const location = useLocation();
@@ -140,7 +142,7 @@ export default function LayoutComponent() {
leftSection: <IconSearch size='1.2rem' />,
placeholder: t`Search...`
}}
shortcut={['mod + K']}
shortcut={[searchShortcutKey]}
nothingFound={t`Nothing found...`}
/>
)}
@@ -1,8 +1,9 @@
import { Group, Paper, Space, Stack, Text } from '@mantine/core';
import { useHotkeys } from '@mantine/hooks';
import { StylishText } from '@lib/components/StylishText';
import { useInvenTreeHotkeys } from '@lib/functions/Events';
import { shortenString } from '@lib/functions/String';
import { t } from '@lingui/core/macro';
import { Fragment, type ReactNode, useMemo } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { usePluginUIFeature } from '../../hooks/UsePluginUIFeature';
@@ -52,9 +53,11 @@ export function PageDetail({
const userSettings = useUserSettingsState();
const navigate = useNavigate();
const location = useLocation();
useHotkeys([
useInvenTreeHotkeys([
[
'mod+E',
title ? t`Edit ${title}` : t`Edit`,
(event) => {
if (event.repeat) {
return;
+3 -1
View File
@@ -11,6 +11,7 @@ import { ContextMenuProvider } from 'mantine-contextmenu';
import type { JSX } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { AboutInvenTreeModal } from '../components/modals/AboutInvenTreeModal';
import { HotkeyModal } from '../components/modals/HotkeyModal';
import { LicenseModal } from '../components/modals/LicenseModal';
import { QrModal } from '../components/modals/QrModal';
import { ServerInfoModal } from '../components/modals/ServerInfoModal';
@@ -59,7 +60,8 @@ export function ThemeContext({
info: ServerInfoModal,
about: AboutInvenTreeModal,
license: LicenseModal,
qr: QrModal
qr: QrModal,
hotkey: HotkeyModal
}}
>
<Notifications />
+23 -2
View File
@@ -14,7 +14,8 @@ import {
import type { NavigateFunction } from 'react-router-dom';
import { ModelInformationDict } from '@lib/enums/ModelInformation';
import { ModelType, UserRoles } from '@lib/index';
import { ModelType, StylishText, UserRoles } from '@lib/index';
import { Trans } from '@lingui/react/macro';
import { openContextModal } from '@mantine/modals';
import { useMemo } from 'react';
import { useShallow } from 'zustand/react/shallow';
@@ -23,13 +24,26 @@ import { useGlobalSettingsState } from '../states/SettingsStates';
import { useUserState } from '../states/UserState';
import { aboutInvenTree, docLinks, licenseInfo, serverInfo } from './links';
export function openQrModal(navigate: NavigateFunction) {
function openQrModal(navigate: NavigateFunction) {
return openContextModal({
modal: 'qr',
innerProps: { navigate: navigate }
});
}
function openHotkeys() {
return openContextModal({
modal: 'hotkey',
title: (
<StylishText size='xl'>
<Trans>Hotkeys</Trans>
</StylishText>
),
size: 'xl',
innerProps: {}
});
}
export function getActions(navigate: NavigateFunction) {
const setNavigationOpen = useLocalState(
useShallow((state) => state.setNavigationOpen)
@@ -91,6 +105,13 @@ export function getActions(navigate: NavigateFunction) {
description: t`Go to your user settings`,
onClick: () => navigate('/settings/user'),
leftSection: <IconUserCog size='1.2rem' />
},
{
id: 'hotkeys',
label: t`Hotkeys`,
description: t`View a list of available hotkeys`,
onClick: () => openHotkeys(),
leftSection: <IconSettings size='1.2rem' />
}
];