Merge commit '457fe16f9c31d08fbb6f3493f17105aac2638e86' into block-notes

This commit is contained in:
Oliver Walters
2026-06-13 10:01:38 +00:00
28 changed files with 1114 additions and 910 deletions
+13 -9
View File
@@ -17,18 +17,22 @@ export type InvenTreeHotkeyItem = [
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]) => [
export function useInvenTreeHotkeys(_keys: InvenTreeHotkeyItem[]) {
const keyelems: [string, string][] = _keys.map(([key, description]) => [
key,
description
]);
const mappedHotkeys: [
string,
(event: KeyboardEvent) => void,
HotkeyItemOptions?
][] = _keys.map(([key, _, handler, options]) => [key, handler, options]);
// Register the hotkeys using the Mantine hook
useHotkeys(mappedHotkeys);
// register to helper state to store hotkeys
// This allows us to display the hotkeys in the UI
useEffect(() => {
useLocalLibState.getState().addHotkeys(keyelems);
return () =>
+1
View File
@@ -17,6 +17,7 @@ export type PanelType = {
disabled?: boolean;
showHeadline?: boolean;
supportsDirty?: boolean;
hotkey?: string;
};
/**
+1
View File
@@ -122,6 +122,7 @@
"@babel/preset-typescript": "^7.28.5",
"@babel/runtime": "^7.28.6",
"@codecov/vite-plugin": "^1.9.1",
"@flakiness/playwright": "^1.13.0",
"@lingui/babel-plugin-lingui-macro": "^5.9.2",
"@lingui/cli": "^5.9.2",
"@lingui/macro": "^5.9.2",
+6 -1
View File
@@ -43,7 +43,12 @@ export default defineConfig({
retries: MAX_RETRIES,
workers: MAX_WORKERS,
reporter: IS_CI
? [['html', { open: 'never' }], ['blob'], ['github']]
? [
['html', { open: 'never' }],
['blob'],
['github'],
['@flakiness/playwright', { flakinessProject: 'InvenTree/InvenTree' }]
]
: 'list',
/* Configure projects for major browsers */
@@ -11,6 +11,7 @@ import { api } from '../../App';
import { extractAvailableFields } from '../../functions/forms';
import useDataOutput from '../../hooks/UseDataOutput';
import { useCreateApiFormModal } from '../../hooks/UseForm';
import { useLocalState } from '../../states/LocalState';
import {
useGlobalSettingsState,
useUserSettingsState
@@ -32,6 +33,11 @@ export function PrintingActions({
}) {
const userSettings = useUserSettingsState();
const globalSettings = useGlobalSettingsState();
const localState = useLocalState();
const lastUsedPrinting = useMemo(() => {
return modelType ? localState.lastUsedPrinting[modelType] : undefined;
}, [localState.lastUsedPrinting, modelType]);
const enabled = useMemo(() => items.length > 0, [items]);
@@ -118,6 +124,7 @@ export function PrintingActions({
fields.template = {
...fields.template,
autoFill: true,
value: lastUsedPrinting?.template,
filters: {
enabled: true,
model_type: modelType,
@@ -147,7 +154,14 @@ export function PrintingActions({
};
return fields;
}, [defaultLabelPlugin, pluginKey, printingFields.data, itemIdList]);
}, [
defaultLabelPlugin,
pluginKey,
printingFields.data,
itemIdList,
lastUsedPrinting,
modelType
]);
const labelModal = useCreateApiFormModal({
url: apiUrl(ApiEndpoints.label_print),
@@ -158,15 +172,21 @@ export function PrintingActions({
onOpen: () => {
setLabelDialogOpen(true);
setItemIdList(items);
setPluginKey(lastUsedPrinting?.plugin ?? null);
},
onClose: () => {
setLabelDialogOpen(false);
setPluginKey('');
},
submitText: t`Print`,
successMessage: null,
onFormSuccess: (response: any) => {
setPluginKey('');
onFormSuccess: (response: any, form: any) => {
if (modelType) {
const values = form?.getValues?.();
localState.setLastUsedPrinting(modelType, {
plugin: pluginKey || undefined,
template: values?.template ? Number(values.template) : undefined
});
}
setLabelId(response.pk);
}
});
@@ -28,7 +28,7 @@ export function ScanButton({
if (hotkey) {
useInvenTreeHotkeys([
[
'mod+b',
'mod+Shift+B',
t`Open barcode scanner`,
() => {
open();
@@ -1,6 +1,7 @@
import type {
CalendarOptions,
DatesSetArg,
DayCellContentArg,
EventContentArg
} from '@fullcalendar/core';
import allLocales from '@fullcalendar/core/locales-all';
@@ -182,6 +183,25 @@ export default function Calendar({
[calendarProps.eventContent, eventTooltipContent]
);
const monthDayCellClassNames = useCallback(
(arg: DayCellContentArg): string[] => {
const monthClass =
arg.date.getMonth() % 2 === 0
? 'fc-day-month-even'
: 'fc-day-month-odd';
const existing = calendarProps.dayCellClassNames;
if (!existing) return [monthClass];
if (typeof existing === 'function') {
const result = existing(arg);
const arr = Array.isArray(result) ? result : result ? [result] : [];
return [monthClass, ...arr];
}
if (Array.isArray(existing)) return [monthClass, ...existing];
return [monthClass, existing as string];
},
[calendarProps.dayCellClassNames]
);
return (
<>
{state.exportModal.modal}
@@ -309,6 +329,11 @@ export default function Calendar({
{...calendarProps}
datesSet={datesSet}
eventContent={wrappedEventContent}
dayCellClassNames={
isScrollView
? monthDayCellClassNames
: calendarProps.dayCellClassNames
}
/>
</Box>
</Stack>
@@ -34,6 +34,7 @@ export type ActionDropdownItem = {
hidden?: boolean;
onClick: (event?: any) => void;
indicator?: Omit<IndicatorProps, 'children'>;
hotkey?: string;
};
/**
@@ -184,7 +185,8 @@ export function BarcodeActionDropdown({
title: t`Link Barcode`,
icon: <IconLink />,
tooltip: t`Link a custom barcode to this item`,
ChildItem: QRCodeLink
ChildItem: QRCodeLink,
hotkey: 'mod+L'
}),
GeneralBarcodeAction({
hidden: hidden || !hash || !permission,
@@ -220,6 +222,7 @@ function GeneralBarcodeAction({
icon: ReactNode;
tooltip: string;
ChildItem: any;
hotkey?: string;
}): ActionDropdownItem {
const onClick = () => {
modals.open({
@@ -255,7 +258,8 @@ export function DeleteItemAction(
...props,
icon: <IconTrash color='red' />,
name: t`Delete`,
tooltip: props.tooltip ?? t`Delete item`
tooltip: props.tooltip ?? t`Delete item`,
hotkey: 'mod+X'
};
}
@@ -275,7 +279,8 @@ export function CancelItemAction(
...props,
icon: <InvenTreeIcon icon='cancel' iconProps={{ color: 'red' }} />,
name: t`Cancel`,
tooltip: props.tooltip ?? t`Cancel`
tooltip: props.tooltip ?? t`Cancel`,
hotkey: 'mod+X'
};
}
@@ -287,6 +292,7 @@ export function DuplicateItemAction(
...props,
icon: <IconCopy color='green' />,
name: t`Duplicate`,
tooltip: props.tooltip ?? t`Duplicate item`
tooltip: props.tooltip ?? t`Duplicate item`,
hotkey: 'mod+D'
};
}
@@ -45,7 +45,8 @@ export function HotkeyModal({
);
keys.sort((a, b) => a.key.localeCompare(b.key));
return keys;
}, []);
}, [context, id]);
const data = useMemo(() => {
return {
head: ['Hotkey', 'Action'],
+73 -1
View File
@@ -57,7 +57,7 @@ export function PageDetail({
useInvenTreeHotkeys([
[
'mod+E',
title ? t`Edit ${title}` : t`Edit`,
t`Edit`,
(event) => {
if (event.repeat) {
return;
@@ -68,6 +68,7 @@ export function PageDetail({
}
]
]);
useActionHotkeys(actions);
const pageTitleString = useMemo(
() =>
@@ -196,3 +197,74 @@ export function PageDetail({
</>
);
}
function useActionHotkeys(actions: ReactNode[] = []) {
const hotkeys = useMemo(() => extractHotkeys(actions), [actions]);
useInvenTreeHotkeys(
hotkeys.map(({ hotkey, onClick, name }) => [
hotkey,
name,
(event) => {
if (event.repeat) {
return;
}
onClick();
}
])
);
}
function extractHotkeys(actions: ReactNode[]) {
const calcActions = actions
.filter(
(action) =>
action &&
typeof action === 'object' &&
'hotkey' in action &&
action.hotkey
)
.map((action: any) => {
return {
hotkey: action?.hotkey,
name: action?.name,
onClick: action?.onClick
};
})
.filter((action) => action !== null);
let primaryActionHotkeyAdded = false;
// now iterate over the actions to extract more possible hotkeys
actions.forEach((action: any) => {
const typeName = action?.type?.name;
// dropdowns - nested actions
if (typeName === 'ActionDropdown' || typeName === 'OptionsActionDropdown') {
const dropdownActions = action?.props?.actions as any[];
dropdownActions.forEach((dropdownAction: any) => {
if (dropdownAction.hotkey) {
calcActions.push({
hotkey: dropdownAction.hotkey,
name: dropdownAction.name,
onClick: dropdownAction.onClick
});
}
});
}
// PrimaryActionButton - use the 'mod+A' hotkey if it is enabled
if (typeName === 'PrimaryActionButton' && action?.props?.hidden !== true) {
if (primaryActionHotkeyAdded) return;
const hotkey = action?.props?.hotkey ?? 'mod+A';
calcActions.push({
hotkey,
name:
action?.props?.tooltip ?? action?.props?.title ?? t`Primary Action`,
onClick: action?.props?.onClick
});
primaryActionHotkeyAdded = true;
}
});
return calcActions;
}
@@ -19,6 +19,7 @@ export default function AttachmentPanel({
name: 'attachments',
label: t`Attachments`,
icon: <IconPaperclip />,
hotkey: 'mod+Shift+A',
notification_dot: async () => {
if (!model_type || !model_id) {
return null;
@@ -23,6 +23,7 @@ export default function NotesPanel({
name: 'notes',
label: t`Notes`,
icon: <IconNotes />,
hotkey: 'mod+Shift+N',
notification_dot: has_note ? 'info' : null,
content:
model_type && model_id ? (
@@ -38,7 +38,11 @@ import { Boundary } from '@lib/components/Boundary';
import { StylishText } from '@lib/components/StylishText';
import type { ModelType, PluginPanelKey } from '@lib/enums/ModelType';
import { identifierString } from '@lib/functions/Conversion';
import { cancelEvent } from '@lib/functions/Events';
import {
type InvenTreeHotkeyItem,
cancelEvent,
useInvenTreeHotkeys
} from '@lib/functions/Events';
import { eventModified, getBaseUrl } from '@lib/functions/Navigation';
import { navigateToLink } from '@lib/functions/Navigation';
import type {
@@ -355,6 +359,22 @@ function BasePanelGroup({
}
}, [activePanels, panel]);
// hotkeys
const hotkeys = useMemo(() => {
const keys: InvenTreeHotkeyItem[] = [];
activePanels.forEach((panel) => {
if (panel.hotkey) {
keys.push([
panel.hotkey,
t`Navigate to panel ${panel.name}`,
() => handlePanelChange(panel.name)
]);
}
});
return keys;
}, [activePanels]);
useInvenTreeHotkeys(hotkeys);
return (
<Boundary label={`PanelGroup-${pageKey}`}>
<Paper p='sm' radius='xs' shadow='xs' aria-label={`${pageKey}`}>
@@ -23,6 +23,7 @@ export default function ParametersPanel({
name: 'parameters',
label: t`Parameters`,
icon: <IconListDetails />,
hotkey: 'mod+Shift+P',
hidden: hidden ?? false,
notification_dot: async () => {
if (!model_type || !model_id) {
+25
View File
@@ -33,6 +33,12 @@ interface LocalStateProps {
setLayouts: (layouts: any, noPatch?: boolean) => void;
showSampleDashboard: boolean;
setShowSampleDashboard: (value: boolean) => void;
// printing
lastUsedPrinting: Record<string, { plugin?: string; template?: number }>;
setLastUsedPrinting: (
modelType: string,
values: { plugin?: string; template?: number }
) => void;
// panels
lastUsedPanels: Record<string, string>;
setLastUsedPanel: (panelKey: string) => (value: string) => void;
@@ -122,6 +128,25 @@ export const useLocalState = create<LocalStateProps>()(
setShowSampleDashboard: (value) => {
set({ showSampleDashboard: value });
},
// printing
lastUsedPrinting: {},
setLastUsedPrinting: (modelType, values) => {
const current = get().lastUsedPrinting[modelType] || {};
if (
current.plugin !== values.plugin ||
current.template !== values.template
) {
set({
lastUsedPrinting: {
...get().lastUsedPrinting,
[modelType]: {
...current,
...values
}
}
});
}
},
// panels
lastUsedPanels: {},
setLastUsedPanel: (panelKey) => (value) => {
+8
View File
@@ -3,6 +3,14 @@
padding: 0.35em 0;
}
/* Alternating month backgrounds in the multi-month scroll calendar view */
[data-mantine-color-scheme='light'] .fc-day-month-odd:not(.fc-day-today) {
background-color: var(--mantine-color-gray-1);
}
[data-mantine-color-scheme='dark'] .fc-day-month-odd:not(.fc-day-today) {
background-color: var(--mantine-color-dark-4);
}
/* mantine-datatable overrides */
.mantine-datatable-pointer-cursor,
.mantine-datatable-context-menu-cursor {
+36 -1
View File
@@ -65,7 +65,42 @@ test('Printing - Label Printing', async ({ browser }) => {
await page.getByRole('button', { name: 'Print', exact: true }).isEnabled();
await page.getByRole('button', { name: 'Print', exact: true }).click();
await page.getByText('Process completed successfully').first().waitFor();
const successMessage = page
.getByText('Process completed successfully')
.first();
await successMessage.waitFor();
await successMessage.waitFor({ state: 'hidden' });
// Re-open print dialog to verify persistence (issue #12129)
await page
.getByLabel('Stock Items')
.getByLabel('action-menu-printing-actions')
.click();
await page.getByLabel('action-menu-printing-actions-print-labels').click();
const labelDialog = page.getByRole('dialog', { name: 'Print Label' });
// Wait for the dialog to fully load
await labelDialog.getByLabel('related-field-template').waitFor();
await labelDialog.getByLabel('related-field-plugin').waitFor();
// Verify the last-used template is preselected
await expect(labelDialog).toContainText('InvenTree Stock Item Label');
// Verify the last-used plugin is preselected
await expect(labelDialog).toContainText('InvenTreeLabel');
// Submit again without re-selecting template or plugin
const printResponse = page.waitForResponse(
(response) =>
response.url().includes('/api/label/print/') &&
response.request().method() === 'POST' &&
response.ok()
);
await labelDialog.getByRole('button', { name: 'Print', exact: true }).click();
await printResponse;
await page.context().close();
});
+574 -575
View File
File diff suppressed because it is too large Load Diff