Merge commit '956468eb847754e701d207bae2a51a6a00ca7d56' into block-notes

This commit is contained in:
Oliver Walters
2026-06-01 09:16:12 +00:00
134 changed files with 51167 additions and 44760 deletions
@@ -10,6 +10,7 @@ export interface ModelInformationInterface {
url_detail?: string;
api_endpoint: ApiEndpoints;
admin_url?: string;
pk_field?: string;
supports_barcode?: boolean;
icon: keyof InvenTreeIconType;
}
+1
View File
@@ -15,6 +15,7 @@ export interface InstanceRenderInterface {
link?: boolean;
navigate?: any;
showSecondary?: boolean;
showHover?: boolean;
extra?: Record<string, any>;
}
+1 -1
View File
@@ -127,7 +127,7 @@
"@lingui/babel-plugin-lingui-macro": "^5.9.2",
"@lingui/cli": "^5.9.2",
"@lingui/macro": "^5.9.2",
"@playwright/test": "^1.58.2",
"@playwright/test": "^1.16.0",
"@types/node": "^25.5.0",
"@types/qrcode": "^1.5.5",
"@types/react": "^19.2.14",
+1 -1
View File
@@ -271,7 +271,7 @@ function NavTabs() {
// static content
mainNavTabs.forEach((tab) => {
if (tab.role && !user.hasViewRole(tab.role)) {
if (tab.visible === false) {
return;
}
@@ -1,16 +1,20 @@
import { t } from '@lingui/core/macro';
import {
ActionIcon,
Alert,
Anchor,
Box,
Group,
HoverCard,
type MantineSize,
Paper,
Skeleton,
Space,
Stack,
Text
} from '@mantine/core';
import { useQuery } from '@tanstack/react-query';
import { type ReactNode, useCallback } from 'react';
import { type ReactNode, useCallback, useMemo } from 'react';
import { ModelInformationDict } from '@lib/enums/ModelInformation';
import { ModelType } from '@lib/enums/ModelType';
@@ -25,8 +29,11 @@ import type {
export type { InstanceRenderInterface } from '@lib/types/Rendering';
import { getBaseUrl, navigateToLink, shortenString } from '@lib/index';
import { IconLink } from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { useApi } from '../../contexts/ApiContext';
import { usePluginState } from '../../states/PluginState';
import { useUserSettingsState } from '../../states/SettingsStates';
import { Thumbnail } from '../images/Thumbnail';
import { RenderBuildItem, RenderBuildLine, RenderBuildOrder } from './Build';
import {
@@ -125,11 +132,95 @@ export function RenderInstance(props: RenderInstanceProps): ReactNode {
props.custom_model ?? props.model ?? ''
);
// provider component
if (!RenderComponent) {
return <UnknownRenderer model={props.model} />;
}
return <RenderComponent {...props} />;
const navigate = useNavigate();
const userSettings = useUserSettingsState();
// Extract model information from the defined model type
const modelInfo = useMemo(() => {
if (!props.model) {
return undefined;
}
return ModelInformationDict[
props.model.toString().toLowerCase() as ModelType
];
}, [props.model]);
const showHover: boolean = useMemo(() => {
if (!modelInfo) {
return false;
}
// Override with the props.showHover attribute
if (props.showHover !== undefined) {
return props.showHover;
}
// If not specified, fall back to the user configured setting
return userSettings.isSet('SHOW_EXTRA_MODEL_INFO');
}, [props.showHover, modelInfo, userSettings]);
// Extract model ID from the provided instance data, using the defined primary key field (or 'pk' as a fallback)
const modelId = useMemo(() => {
if (!modelInfo || !props.instance) {
return undefined;
}
return props.instance[modelInfo.pk_field ?? 'pk'];
}, [modelInfo, props.instance]);
const detailUrl = useMemo(() => {
if (!modelInfo || !modelId || !modelInfo.url_detail) {
return undefined;
}
return modelInfo.url_detail.replace(':pk', modelId.toString());
}, [modelInfo, modelId]);
return (
<HoverCard
disabled={!showHover}
position='top-end'
withinPortal
openDelay={500}
closeDelay={100}
zIndex={99999}
>
<HoverCard.Target>
<Box>
{!!RenderComponent ? (
<RenderComponent {...props} />
) : (
<UnknownRenderer model={props.model} />
)}
</Box>
</HoverCard.Target>
<HoverCard.Dropdown>
<Stack gap='xs'>
<Group justify='space-between'>
<Text size='sm' fw='bold'>
{modelInfo?.label()}
</Text>
{modelId && <Text size='xs'>{`[${t`ID`}: ${modelId}]`}</Text>}
</Group>
{detailUrl && (
<Anchor
href={detailUrl}
target='_blank'
onClick={(event) => navigateToLink(detailUrl, navigate, event)}
>
<Group gap='xs' wrap='nowrap'>
<ActionIcon variant='transparent' size='xs'>
<IconLink />
</ActionIcon>
<Text size='sm'>{t`View details`}</Text>
</Group>
</Anchor>
)}
</Stack>
</HoverCard.Dropdown>
</HoverCard>
);
}
export function RenderRemoteInstance({
+22
View File
@@ -124,6 +124,17 @@ export function getActions(navigate: NavigateFunction) {
leftSection: <IconLink size='1.2rem' />
});
globalSettings.isSet('TRANSFERORDER_ENABLED') &&
user?.hasViewRole(UserRoles.transfer_order) &&
_actions.push({
id: 'transfer-orders',
label: t`Transfer Orders`,
description: t`Go to Transfer Orders`,
onClick: () =>
navigate(ModelInformationDict['transferorder'].url_overview!),
leftSection: <IconLink size='1.2rem' />
});
globalSettings.isSet('RETURNORDER_ENABLED') &&
user?.hasViewRole(UserRoles.return_order) &&
_actions.push({
@@ -172,6 +183,17 @@ export function getActions(navigate: NavigateFunction) {
});
staff &&
user?.hasViewPermission(ModelType.error) &&
_actions.push({
id: 'error-logs',
label: t`Error Logs`,
description: t`View error logs for this instance`,
onClick: () => navigate('/settings/admin/errors'),
leftSection: <IconReport size='1.2rem' />
});
staff &&
user?.hasViewPermission(ModelType.pluginconfig) &&
_actions.push({
id: 'plugin-settings',
label: t`Plugins`,
+19 -8
View File
@@ -16,15 +16,18 @@ import {
} from '@tabler/icons-react';
import type { ReactNode } from 'react';
import type { MenuLinkItem } from '../components/items/MenuLinks';
import { useGlobalSettingsState } from '../states/SettingsStates';
type NavTab = {
name: string;
title: string;
icon: ReactNode;
role?: UserRoles;
visible?: boolean;
};
export function getNavTabs(user: UserStateProps): NavTab[] {
const globalSettings = useGlobalSettingsState.getState();
const navTabs: NavTab[] = [
{
name: 'home',
@@ -35,37 +38,45 @@ export function getNavTabs(user: UserStateProps): NavTab[] {
name: 'part',
title: t`Parts`,
icon: <IconBox />,
role: UserRoles.part
visible:
user.hasViewRole(UserRoles.part) ||
user.hasViewRole(UserRoles.part_category)
},
{
name: 'stock',
title: t`Stock`,
icon: <IconPackages />,
role: UserRoles.stock
visible:
user.hasViewRole(UserRoles.stock) ||
user.hasViewRole(UserRoles.stock_location) ||
(globalSettings.isSet('TRANSFERORDER_ENABLED') &&
user.hasViewRole(UserRoles.transfer_order))
},
{
name: 'manufacturing',
title: t`Manufacturing`,
icon: <IconBuildingFactory2 />,
role: UserRoles.build
visible: user.hasViewRole(UserRoles.build)
},
{
name: 'purchasing',
title: t`Purchasing`,
icon: <IconShoppingCart />,
role: UserRoles.purchase_order
visible: user.hasViewRole(UserRoles.purchase_order)
},
{
name: 'sales',
title: t`Sales`,
icon: <IconTruckDelivery />,
role: UserRoles.sales_order
visible:
user.hasViewRole(UserRoles.sales_order) ||
(globalSettings.isSet('RETURNORDER_ENABLED') &&
user.hasViewRole(UserRoles.return_order))
}
];
return navTabs.filter((tab) => {
if (!tab.role) return true;
return user.hasViewRole(tab.role);
return tab.visible !== false;
});
}
+7 -1
View File
@@ -1,7 +1,7 @@
import type { ApiFormFieldSet } from '@lib/types/Forms';
import { t } from '@lingui/core/macro';
import { IconBuildingStore, IconCopy, IconPackages } from '@tabler/icons-react';
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useGlobalSettingsState } from '../states/SettingsStates';
/**
@@ -23,6 +23,12 @@ export function usePartFields({
undefined
);
// Set the initial state for the tracked fields based on the global settings
useEffect(() => {
setVirtual(globalSettings.isSet('PART_VIRTUAL'));
setPurchaseable(globalSettings.isSet('PART_PURCHASEABLE'));
}, [partId, create]);
return useMemo(() => {
const fields: ApiFormFieldSet = {
category: {
+83 -29
View File
@@ -1,3 +1,16 @@
import { ActionButton } from '@lib/components/ActionButton';
import { StylishText } from '@lib/components/StylishText';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { apiUrl } from '@lib/functions/Api';
import { getDetailUrl } from '@lib/functions/Navigation';
import type {
ApiFormAdjustFilterType,
ApiFormFieldChoice,
ApiFormFieldSet,
ApiFormModalProps,
StockOperationProps
} from '@lib/types/Forms';
import { t } from '@lingui/core/macro';
import {
Alert,
@@ -20,27 +33,13 @@ import {
IconUsersGroup
} from '@tabler/icons-react';
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { type JSX, Suspense, useEffect, useMemo, useState } from 'react';
import { ActionButton } from '@lib/components/ActionButton';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import dayjs from 'dayjs';
import { type JSX, Suspense, useEffect, useMemo, useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { api } from '../App';
import RemoveRowButton from '../components/buttons/RemoveRowButton';
import { StandaloneField } from '../components/forms/StandaloneField';
import { StylishText } from '@lib/components/StylishText';
import { apiUrl } from '@lib/functions/Api';
import { getDetailUrl } from '@lib/functions/Navigation';
import type {
ApiFormAdjustFilterType,
ApiFormFieldChoice,
ApiFormFieldSet,
ApiFormModalProps,
StockOperationProps
} from '@lib/types/Forms';
import {
TableFieldExtraRow,
type TableFieldRowProps
@@ -490,12 +489,31 @@ function StockItemDefaultMove({
function moveToDefault(
stockItem: any,
value: StockItemQuantity,
refresh: () => void
refresh: () => void,
options?: {
title?: string;
onConfirm?: (location: number) => void;
}
) {
const location =
stockItem.part_detail?.default_location ??
stockItem.part_detail?.category_default_location;
modals.openConfirmModal({
title: <StylishText>{t`Confirm Stock Transfer`}</StylishText>,
title: (
<StylishText>{options?.title ?? t`Confirm Stock Transfer`}</StylishText>
),
children: <StockItemDefaultMove stockItem={stockItem} value={value} />,
onConfirm: () => {
if (!location) {
return;
}
if (options?.onConfirm) {
options.onConfirm(location);
return;
}
if (
stockItem.location === stockItem.part_detail?.default_location ||
stockItem.location === stockItem.part_detail?.category_default_location
@@ -512,9 +530,7 @@ function moveToDefault(
status: stockItem.status
}
],
location:
stockItem.part_detail?.default_location ??
stockItem.part_detail?.category_default_location
location: location
})
.then((response) => {
refresh();
@@ -548,6 +564,7 @@ function StockOperationsRow({
add = false,
setMax = false,
merge = false,
returnStock = false,
record
}: {
props: TableFieldRowProps;
@@ -556,8 +573,11 @@ function StockOperationsRow({
add?: boolean;
setMax?: boolean;
merge?: boolean;
returnStock?: boolean;
record?: any;
}) {
const form = useFormContext();
const statusOptions: ApiFormFieldChoice[] = useMemo(() => {
return (
StatusFilterOptions(ModelType.stockitem)()?.map((choice) => {
@@ -676,7 +696,22 @@ function StockOperationsRow({
{transfer && (
<ActionButton
onClick={() =>
moveToDefault(record, props.item.quantity, removeAndRefresh)
moveToDefault(
record,
props.item.quantity,
removeAndRefresh,
returnStock
? {
title: t`Confirm Stock Return`,
onConfirm: (location: number) => {
form.setValue('location', location, {
shouldDirty: true,
shouldValidate: true
});
}
}
: undefined
)
}
icon={<InvenTreeIcon icon='default_location' />}
tooltip={t`Move to default location`}
@@ -840,6 +875,7 @@ function stockReturnFields(items: any[]): ApiFormFieldSet {
key={record.pk}
record={record}
transfer
returnStock
changeStatus
/>
);
@@ -854,12 +890,25 @@ function stockReturnFields(items: any[]): ApiFormFieldSet {
]
},
location: {
field_type: 'related field',
api_url: apiUrl(ApiEndpoints.stock_location_list),
model: ModelType.stocklocation,
required: true,
filters: {
structural: false
}
},
merge: {},
notes: {}
merge: {
field_type: 'boolean',
label: t`Merge into existing stock`,
description: t`Merge returned items into existing stock items if possible`,
value: false
},
notes: {
field_type: 'string',
label: t`Notes`,
description: t`Stock transaction notes`
}
};
return fields;
@@ -967,6 +1016,9 @@ function stockCountFields(items: any[]): ApiFormFieldSet {
const initialValue = mapAdjustmentItems(items);
// Extract all location values from the items
const locations = [...new Set(items.map((item) => item.location))];
const fields: ApiFormFieldSet = {
items: {
field_type: 'table',
@@ -990,6 +1042,12 @@ function stockCountFields(items: any[]): ApiFormFieldSet {
{ title: t`Actions` }
]
},
location: {
value: locations.length === 1 ? locations[0] : undefined,
filters: {
structural: false
}
},
notes: {}
};
@@ -1584,11 +1642,7 @@ export function useTestResultFields({
/**
* Modal form for finding a particular stock item by serial number
*/
export function useFindSerialNumberForm({
partId
}: {
partId: number;
}) {
export function useFindSerialNumberForm({ partId }: { partId: number }) {
const navigate = useNavigate();
return useApiFormModal({
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -61,6 +61,7 @@ export default function UserSettings() {
'FORMS_CLOSE_USING_ESCAPE',
'DISPLAY_STOCKTAKE_TAB',
'ENABLE_LAST_BREADCRUMB',
'SHOW_EXTRA_MODEL_INFO',
'SHOW_FULL_LOCATION_IN_TABLES',
'SHOW_FULL_CATEGORY_IN_TABLES',
'SHOW_BOM_SUBASSEMBLY_LEVELS'
+3 -7
View File
@@ -331,7 +331,7 @@ export default function BuildDetail() {
name: 'can_build',
unit: build.part_detail?.units,
label: t`Can Build`,
hidden: partRequirementsQuery.isFetching
hidden: partRequirements?.can_build === undefined
},
{
type: 'progressbar',
@@ -454,12 +454,7 @@ export default function BuildDetail() {
<DetailsTable fields={br} item={data} />
</ItemDetailsGrid>
);
}, [
build,
instanceQuery,
partRequirements,
partRequirementsQuery.isFetching
]);
}, [build, instanceQuery, partRequirements, partRequirementsQuery]);
const buildPanels: PanelType[] = useMemo(() => {
return [
@@ -597,6 +592,7 @@ export default function BuildDetail() {
build,
id,
user,
partRequirements,
buildStatus,
globalSettings,
showChildBuilds,
+17 -2
View File
@@ -11,6 +11,7 @@ import {
Stack,
Text
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import {
IconBookmarks,
IconBuilding,
@@ -1165,11 +1166,25 @@ export default function PartDetail() {
variant='transparent'
disabled={!user.hasChangeRole(UserRoles.part)}
onClick={() => {
const locking = !part.locked;
api
.patch(apiUrl(ApiEndpoints.part_list, part.pk), {
locked: !part.locked
locked: locking
})
.then(refreshInstance);
.then(() => {
notifications.hide('part-lock');
notifications.show({
id: 'part-lock',
message: locking ? t`Part locked` : t`Part unlocked`,
color: 'green',
icon: locking ? (
<IconLock size='1rem' />
) : (
<IconLockOpen size='1rem' />
)
});
refreshInstance();
});
}}
>
{part?.locked ? <IconLock /> : <IconLockOpen />}
@@ -261,7 +261,7 @@ export default function PluginListTable() {
const [pluginPackage, setPluginPackage] = useState<string>('');
const activatePluginModal = useEditApiFormModal({
title: t`Activate Plugin`,
title: activate ? t`Activate Plugin` : t`Deactivate Plugin`,
url: ApiEndpoints.plugin_activate,
pathParams: { key: selectedPluginKey },
preFormContent: activateModalContent,
@@ -45,8 +45,12 @@ test('Build Order - Basic Tests', async ({ browser }) => {
// Load a particular build order
await page.getByRole('cell', { name: 'BO0017' }).click();
await loadTab(page, 'Build Details');
// This build order should be "on hold"
await page.getByText('On Hold').first().waitFor();
await page.getByText('Can Build').first().waitFor();
await page.getByText('Completed Outputs').first().waitFor();
// Edit the build order (via keyboard shortcut)
await page.keyboard.press('Control+E');
@@ -290,8 +290,6 @@ test('Parts - BOM Validation', async ({ browser }) => {
// Edit line item, to ensure BOM is not valid
const cell = await page.getByRole('cell', { name: 'paint', exact: true });
// await cell.click({ button: 'right' });
// await page.getByRole('button', { name: 'Edit', exact: true }).click();
await clickOnRowMenu(cell);
await page.getByRole('menuitem', { name: 'Edit', exact: true }).click();
+434 -430
View File
File diff suppressed because it is too large Load Diff