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:
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
}, []);
|
||||
}
|
||||
|
||||
@@ -139,6 +139,8 @@ export {
|
||||
type TableStateExtraProps
|
||||
} from './hooks/UseTable';
|
||||
|
||||
export { useInvenTreeHotkeys } from './functions/Events';
|
||||
|
||||
export {
|
||||
type DrawerProps,
|
||||
DetailDrawer,
|
||||
|
||||
@@ -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,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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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' />
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user