2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 11:36:44 +00:00

Edit BOM substitutes (#9521)

* Edit BOM substitutes

* Add playwright tests
This commit is contained in:
Oliver 2025-04-16 14:38:59 +10:00 committed by GitHub
parent 0707ebf59b
commit 204e2e6d46
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 224 additions and 29 deletions

View File

@ -102,6 +102,7 @@ export enum ApiEndpoints {
bom_list = 'bom/', bom_list = 'bom/',
bom_item_validate = 'bom/:id/validate/', bom_item_validate = 'bom/:id/validate/',
bom_validate = 'part/:id/bom-validate/', bom_validate = 'part/:id/bom-validate/',
bom_substitute_list = 'bom/substitute/',
// Part API endpoints // Part API endpoints
part_list = 'part/', part_list = 'part/',

View File

@ -1,4 +1,17 @@
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { apiUrl } from '@lib/functions/Api';
import { UserRoles } from '@lib/index';
import type { ApiFormFieldSet } from '@lib/types/Forms'; import type { ApiFormFieldSet } from '@lib/types/Forms';
import { t } from '@lingui/core/macro';
import { Table } from '@mantine/core';
import { useEffect, useMemo, useState } from 'react';
import { api } from '../App';
import RemoveRowButton from '../components/buttons/RemoveRowButton';
import type { TableFieldRowProps } from '../components/forms/fields/TableField';
import { RenderPart } from '../components/render/Part';
import { showApiErrorMessage } from '../functions/notifications';
import { useCreateApiFormModal } from '../hooks/UseForm';
import { useUserState } from '../states/UserState';
/** /**
* Field set for BomItem form * Field set for BomItem form
@ -24,3 +37,111 @@ export function bomItemFields(): ApiFormFieldSet {
optional: {} optional: {}
}; };
} }
function BomItemSubstituteRow({
props,
record
}: Readonly<{
props: TableFieldRowProps;
record: any;
}>) {
const user = useUserState();
return (
<Table.Tr>
<Table.Td>
{record.part_detail && <RenderPart instance={record.part_detail} />}
</Table.Td>
<Table.Td>
{user.hasDeleteRole(UserRoles.part) && (
<RemoveRowButton
onClick={() => {
api
.delete(apiUrl(ApiEndpoints.bom_substitute_list, record.pk))
.then(() => {
props.removeFn(props.idx);
})
.catch((err) => {
showApiErrorMessage({
error: err,
title: t`Error`
});
});
}}
/>
)}
</Table.Td>
</Table.Tr>
);
}
type BomItemSubstituteFormProps = {
bomItemId: number;
substitutes: any[];
onClose?: () => void;
};
/**
* Edit substitutes for a BOM item
*/
export function useEditBomSubstitutesForm(props: BomItemSubstituteFormProps) {
const [substitutes, setSubstitutes] = useState<any[]>([]);
useEffect(() => {
setSubstitutes(props.substitutes);
}, [props.substitutes]);
const formFields: ApiFormFieldSet = useMemo(() => {
return {
substitutes: {
field_type: 'table',
value: substitutes,
ignore: true,
modelRenderer: (row: TableFieldRowProps) => {
const record = substitutes.find((r) => r.pk == row.item.pk);
return record ? (
<BomItemSubstituteRow props={row} record={record} />
) : null;
},
headers: [
{ title: t`Substitute Part`, style: { width: '100%' } },
{ title: '', style: { width: '50px' } }
]
},
bom_item: {
hidden: true,
value: props.bomItemId
},
part: {
filters: {
component: true
}
}
};
}, [props, substitutes]);
return useCreateApiFormModal({
title: t`Edit BOM Substitutes`,
url: apiUrl(ApiEndpoints.bom_substitute_list),
fields: formFields,
initialData: {
substitutes: substitutes
},
cancelText: t`Close`,
submitText: t`Add Substitute`,
successMessage: t`Substitute added`,
onClose: () => {
props.onClose?.();
},
checkClose: (response, form) => {
// Keep the form open
return false;
},
onFormSuccess(data, form) {
// Add the new substitute to the list
setSubstitutes((old) => [...old, data]);
form.setValue('part', null);
return false;
}
});
}

View File

@ -1,24 +1,47 @@
import type { ApiFormFieldType } from '@lib/types/Forms'; import type { ApiFormFieldType } from '@lib/types/Forms';
/**
* Table column properties
*
* @param T - The type of the record
* @param accessor - The key in the record to access
* @param title - The title of the column - Note: this may be supplied by the API, and is not required, but it can be overridden if required
* @param ordering - The key in the record to sort by (defaults to accessor)
* @param sortable - Whether the column is sortable
* @param switchable - Whether the column is switchable
* @param hidden - Whether the column is hidden
* @param editable - Whether the value of this column can be edited
* @param definition - Optional field definition for the column
* @param render - A custom render function
* @param filter - A custom filter function
* @param filtering - Whether the column is filterable
* @param width - The width of the column
* @param noWrap - Whether the column should wrap
* @param ellipsis - Whether the column should be ellipsized
* @param textAlign - The text alignment of the column
* @param cellsStyle - The style of the cells in the column
* @param extra - Extra data to pass to the render function
* @param noContext - Disable context menu for this column
*/
export type TableColumnProps<T = any> = { export type TableColumnProps<T = any> = {
accessor?: string; // The key in the record to access accessor?: string;
title?: string; // The title of the column - Note: this may be supplied by the API, and is not required, but it can be overridden if required title?: string;
ordering?: string; // The key in the record to sort by (defaults to accessor) ordering?: string;
sortable?: boolean; // Whether the column is sortable sortable?: boolean;
switchable?: boolean; // Whether the column is switchable switchable?: boolean;
hidden?: boolean; // Whether the column is hidden hidden?: boolean;
editable?: boolean; // Whether the value of this column can be edited editable?: boolean;
definition?: ApiFormFieldType; // Optional field definition for the column definition?: ApiFormFieldType;
render?: (record: T, index?: number) => any; // A custom render function render?: (record: T, index?: number) => any;
filter?: any; // A custom filter function filter?: any;
filtering?: boolean; // Whether the column is filterable filtering?: boolean;
width?: number; // The width of the column width?: number;
noWrap?: boolean; // Whether the column should wrap noWrap?: boolean;
ellipsis?: boolean; // Whether the column should be ellipsized ellipsis?: boolean;
textAlign?: 'left' | 'center' | 'right'; // The text alignment of the column textAlign?: 'left' | 'center' | 'right';
cellsStyle?: any; // The style of the cells in the column cellsStyle?: any;
extra?: any; // Extra data to pass to the render function extra?: any;
noContext?: boolean; // Disable context menu for this column noContext?: boolean;
}; };
/** /**

View File

@ -22,11 +22,11 @@ import { AddItemButton } from '../../components/buttons/AddItemButton';
import { YesNoButton } from '../../components/buttons/YesNoButton'; import { YesNoButton } from '../../components/buttons/YesNoButton';
import { Thumbnail } from '../../components/images/Thumbnail'; import { Thumbnail } from '../../components/images/Thumbnail';
import ImporterDrawer from '../../components/importer/ImporterDrawer'; import ImporterDrawer from '../../components/importer/ImporterDrawer';
import { RenderPart } from '../../components/render/Part';
import { useApi } from '../../contexts/ApiContext'; import { useApi } from '../../contexts/ApiContext';
import { formatDecimal, formatPriceRange } from '../../defaults/formatters'; import { formatDecimal, formatPriceRange } from '../../defaults/formatters';
import { bomItemFields } from '../../forms/BomForms'; import { bomItemFields, useEditBomSubstitutesForm } from '../../forms/BomForms';
import { dataImporterSessionFields } from '../../forms/ImporterForms'; import { dataImporterSessionFields } from '../../forms/ImporterForms';
import { notYetImplemented } from '../../functions/notifications';
import { import {
useApiFormModal, useApiFormModal,
useCreateApiFormModal, useCreateApiFormModal,
@ -145,12 +145,17 @@ export function BomTable({
}, },
{ {
accessor: 'substitutes', accessor: 'substitutes',
// TODO: Show hovercard with list of substitutes
render: (row) => { render: (row) => {
const substitutes = row.substitutes ?? []; const substitutes = row.substitutes ?? [];
return substitutes.length > 0 ? ( return substitutes.length > 0 ? (
row.length <TableHoverCard
value={<Text>{substitutes.length}</Text>}
title={t`Substitutes`}
extra={substitutes.map((sub: any) => (
<RenderPart instance={sub.part_detail} />
))}
/>
) : ( ) : (
<YesNoButton value={false} /> <YesNoButton value={false} />
); );
@ -363,7 +368,7 @@ export function BomTable({
]; ];
}, [partId, params]); }, [partId, params]);
const [selectedBomItem, setSelectedBomItem] = useState<number>(0); const [selectedBomItem, setSelectedBomItem] = useState<any>({});
const importSessionFields = useMemo(() => { const importSessionFields = useMemo(() => {
const fields = dataImporterSessionFields(); const fields = dataImporterSessionFields();
@ -401,7 +406,7 @@ export function BomTable({
const editBomItem = useEditApiFormModal({ const editBomItem = useEditApiFormModal({
url: ApiEndpoints.bom_list, url: ApiEndpoints.bom_list,
pk: selectedBomItem, pk: selectedBomItem.pk,
title: t`Edit BOM Item`, title: t`Edit BOM Item`,
fields: bomItemFields(), fields: bomItemFields(),
successMessage: t`BOM item updated`, successMessage: t`BOM item updated`,
@ -410,12 +415,20 @@ export function BomTable({
const deleteBomItem = useDeleteApiFormModal({ const deleteBomItem = useDeleteApiFormModal({
url: ApiEndpoints.bom_list, url: ApiEndpoints.bom_list,
pk: selectedBomItem, pk: selectedBomItem.pk,
title: t`Delete BOM Item`, title: t`Delete BOM Item`,
successMessage: t`BOM item deleted`, successMessage: t`BOM item deleted`,
table: table table: table
}); });
const editSubstitues = useEditBomSubstitutesForm({
bomItemId: selectedBomItem.pk,
substitutes: selectedBomItem?.substitutes ?? [],
onClose: () => {
table.refreshTable();
}
});
const validateBom = useApiFormModal({ const validateBom = useApiFormModal({
url: ApiEndpoints.bom_validate, url: ApiEndpoints.bom_validate,
method: 'PUT', method: 'PUT',
@ -488,21 +501,24 @@ export function BomTable({
RowEditAction({ RowEditAction({
hidden: partLocked || !user.hasChangeRole(UserRoles.part), hidden: partLocked || !user.hasChangeRole(UserRoles.part),
onClick: () => { onClick: () => {
setSelectedBomItem(record.pk); setSelectedBomItem(record);
editBomItem.open(); editBomItem.open();
} }
}), }),
{ {
title: t`Edit Substitutes`, title: t`Edit Substitutes`,
color: 'blue', color: 'blue',
hidden: partLocked || !user.hasChangeRole(UserRoles.part), hidden: partLocked || !user.hasAddRole(UserRoles.part),
icon: <IconSwitch3 />, icon: <IconSwitch3 />,
onClick: notYetImplemented onClick: () => {
setSelectedBomItem(record);
editSubstitues.open();
}
}, },
RowDeleteAction({ RowDeleteAction({
hidden: partLocked || !user.hasDeleteRole(UserRoles.part), hidden: partLocked || !user.hasDeleteRole(UserRoles.part),
onClick: () => { onClick: () => {
setSelectedBomItem(record.pk); setSelectedBomItem(record);
deleteBomItem.open(); deleteBomItem.open();
} }
}) })
@ -543,6 +559,7 @@ export function BomTable({
{editBomItem.modal} {editBomItem.modal}
{validateBom.modal} {validateBom.modal}
{deleteBomItem.modal} {deleteBomItem.modal}
{editSubstitues.modal}
<Stack gap='xs'> <Stack gap='xs'>
{partLocked && ( {partLocked && (
<Alert <Alert

View File

@ -80,6 +80,39 @@ test('Parts - Supplier Parts', async ({ browser }) => {
await page.getByText('DIG-84670-SJI - R_550R_0805_1%').waitFor(); await page.getByText('DIG-84670-SJI - R_550R_0805_1%').waitFor();
}); });
test('Parts - BOM', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'part/87/bom' });
await loadTab(page, 'Bill of Materials');
await page.waitForLoadState('networkidle');
const cell = await page.getByRole('cell', {
name: 'Small plastic enclosure, black',
exact: true
});
await cell.click({ button: 'right' });
// Check for expected context menu actions
await page.getByRole('button', { name: 'Edit', exact: true }).waitFor();
await page.getByRole('button', { name: 'Delete', exact: true }).waitFor();
await page
.getByRole('button', { name: 'View details', exact: true })
.waitFor();
await page
.getByRole('button', { name: 'Edit Substitutes', exact: true })
.click();
await page.getByText('Edit BOM Substitutes').waitFor();
await page.getByText('1551ACLR').first().waitFor();
await page.getByText('1551AGY').first().waitFor();
await page.getByLabel('related-field-part').fill('enclosure');
await page.getByText('1591BTBU').click();
await page.getByRole('button', { name: 'Add Substitute' }).waitFor();
await page.getByRole('button', { name: 'Close' }).click();
});
test('Parts - Locking', async ({ browser }) => { test('Parts - Locking', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'part/104/bom' }); const page = await doCachedLogin(browser, { url: 'part/104/bom' });
await loadTab(page, 'Bill of Materials'); await loadTab(page, 'Bill of Materials');