mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-28 03:26:45 +00:00
Edit BOM substitutes (#9521)
* Edit BOM substitutes * Add playwright tests
This commit is contained in:
parent
0707ebf59b
commit
204e2e6d46
@ -102,6 +102,7 @@ export enum ApiEndpoints {
|
||||
bom_list = 'bom/',
|
||||
bom_item_validate = 'bom/:id/validate/',
|
||||
bom_validate = 'part/:id/bom-validate/',
|
||||
bom_substitute_list = 'bom/substitute/',
|
||||
|
||||
// Part API endpoints
|
||||
part_list = 'part/',
|
||||
|
@ -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 { 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
|
||||
@ -24,3 +37,111 @@ export function bomItemFields(): ApiFormFieldSet {
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -1,24 +1,47 @@
|
||||
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> = {
|
||||
accessor?: string; // The key in the record to access
|
||||
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
|
||||
ordering?: string; // The key in the record to sort by (defaults to accessor)
|
||||
sortable?: boolean; // Whether the column is sortable
|
||||
switchable?: boolean; // Whether the column is switchable
|
||||
hidden?: boolean; // Whether the column is hidden
|
||||
editable?: boolean; // Whether the value of this column can be edited
|
||||
definition?: ApiFormFieldType; // Optional field definition for the column
|
||||
render?: (record: T, index?: number) => any; // A custom render function
|
||||
filter?: any; // A custom filter function
|
||||
filtering?: boolean; // Whether the column is filterable
|
||||
width?: number; // The width of the column
|
||||
noWrap?: boolean; // Whether the column should wrap
|
||||
ellipsis?: boolean; // Whether the column should be ellipsized
|
||||
textAlign?: 'left' | 'center' | 'right'; // The text alignment of the column
|
||||
cellsStyle?: any; // The style of the cells in the column
|
||||
extra?: any; // Extra data to pass to the render function
|
||||
noContext?: boolean; // Disable context menu for this column
|
||||
accessor?: string;
|
||||
title?: string;
|
||||
ordering?: string;
|
||||
sortable?: boolean;
|
||||
switchable?: boolean;
|
||||
hidden?: boolean;
|
||||
editable?: boolean;
|
||||
definition?: ApiFormFieldType;
|
||||
render?: (record: T, index?: number) => any;
|
||||
filter?: any;
|
||||
filtering?: boolean;
|
||||
width?: number;
|
||||
noWrap?: boolean;
|
||||
ellipsis?: boolean;
|
||||
textAlign?: 'left' | 'center' | 'right';
|
||||
cellsStyle?: any;
|
||||
extra?: any;
|
||||
noContext?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -22,11 +22,11 @@ import { AddItemButton } from '../../components/buttons/AddItemButton';
|
||||
import { YesNoButton } from '../../components/buttons/YesNoButton';
|
||||
import { Thumbnail } from '../../components/images/Thumbnail';
|
||||
import ImporterDrawer from '../../components/importer/ImporterDrawer';
|
||||
import { RenderPart } from '../../components/render/Part';
|
||||
import { useApi } from '../../contexts/ApiContext';
|
||||
import { formatDecimal, formatPriceRange } from '../../defaults/formatters';
|
||||
import { bomItemFields } from '../../forms/BomForms';
|
||||
import { bomItemFields, useEditBomSubstitutesForm } from '../../forms/BomForms';
|
||||
import { dataImporterSessionFields } from '../../forms/ImporterForms';
|
||||
import { notYetImplemented } from '../../functions/notifications';
|
||||
import {
|
||||
useApiFormModal,
|
||||
useCreateApiFormModal,
|
||||
@ -145,12 +145,17 @@ export function BomTable({
|
||||
},
|
||||
{
|
||||
accessor: 'substitutes',
|
||||
// TODO: Show hovercard with list of substitutes
|
||||
render: (row) => {
|
||||
const substitutes = row.substitutes ?? [];
|
||||
|
||||
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} />
|
||||
);
|
||||
@ -363,7 +368,7 @@ export function BomTable({
|
||||
];
|
||||
}, [partId, params]);
|
||||
|
||||
const [selectedBomItem, setSelectedBomItem] = useState<number>(0);
|
||||
const [selectedBomItem, setSelectedBomItem] = useState<any>({});
|
||||
|
||||
const importSessionFields = useMemo(() => {
|
||||
const fields = dataImporterSessionFields();
|
||||
@ -401,7 +406,7 @@ export function BomTable({
|
||||
|
||||
const editBomItem = useEditApiFormModal({
|
||||
url: ApiEndpoints.bom_list,
|
||||
pk: selectedBomItem,
|
||||
pk: selectedBomItem.pk,
|
||||
title: t`Edit BOM Item`,
|
||||
fields: bomItemFields(),
|
||||
successMessage: t`BOM item updated`,
|
||||
@ -410,12 +415,20 @@ export function BomTable({
|
||||
|
||||
const deleteBomItem = useDeleteApiFormModal({
|
||||
url: ApiEndpoints.bom_list,
|
||||
pk: selectedBomItem,
|
||||
pk: selectedBomItem.pk,
|
||||
title: t`Delete BOM Item`,
|
||||
successMessage: t`BOM item deleted`,
|
||||
table: table
|
||||
});
|
||||
|
||||
const editSubstitues = useEditBomSubstitutesForm({
|
||||
bomItemId: selectedBomItem.pk,
|
||||
substitutes: selectedBomItem?.substitutes ?? [],
|
||||
onClose: () => {
|
||||
table.refreshTable();
|
||||
}
|
||||
});
|
||||
|
||||
const validateBom = useApiFormModal({
|
||||
url: ApiEndpoints.bom_validate,
|
||||
method: 'PUT',
|
||||
@ -488,21 +501,24 @@ export function BomTable({
|
||||
RowEditAction({
|
||||
hidden: partLocked || !user.hasChangeRole(UserRoles.part),
|
||||
onClick: () => {
|
||||
setSelectedBomItem(record.pk);
|
||||
setSelectedBomItem(record);
|
||||
editBomItem.open();
|
||||
}
|
||||
}),
|
||||
{
|
||||
title: t`Edit Substitutes`,
|
||||
color: 'blue',
|
||||
hidden: partLocked || !user.hasChangeRole(UserRoles.part),
|
||||
hidden: partLocked || !user.hasAddRole(UserRoles.part),
|
||||
icon: <IconSwitch3 />,
|
||||
onClick: notYetImplemented
|
||||
onClick: () => {
|
||||
setSelectedBomItem(record);
|
||||
editSubstitues.open();
|
||||
}
|
||||
},
|
||||
RowDeleteAction({
|
||||
hidden: partLocked || !user.hasDeleteRole(UserRoles.part),
|
||||
onClick: () => {
|
||||
setSelectedBomItem(record.pk);
|
||||
setSelectedBomItem(record);
|
||||
deleteBomItem.open();
|
||||
}
|
||||
})
|
||||
@ -543,6 +559,7 @@ export function BomTable({
|
||||
{editBomItem.modal}
|
||||
{validateBom.modal}
|
||||
{deleteBomItem.modal}
|
||||
{editSubstitues.modal}
|
||||
<Stack gap='xs'>
|
||||
{partLocked && (
|
||||
<Alert
|
||||
|
@ -80,6 +80,39 @@ test('Parts - Supplier Parts', async ({ browser }) => {
|
||||
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 }) => {
|
||||
const page = await doCachedLogin(browser, { url: 'part/104/bom' });
|
||||
await loadTab(page, 'Bill of Materials');
|
||||
|
Loading…
x
Reference in New Issue
Block a user