2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-04-28 13:54:25 +00:00

[UI] BOM Editing (#10210)

* Support minimum column width

* Adjust DescriptionColumn and StatusColumn

* Column refactoring

* Refactor PartColumn

* Refactor LineItemsProgerssColumn

* Tweaks

* Ensure "can_build" value is not negative

* Render row expansion icon

* Add subassembly table for BOM

* Add controls for BOM editing

* Fix row click context

* Improve rendering for BOM sub-rows

* Hide BOM actions unless editing

* Disable row expansion for now

* Revert gitleaks changes

* Remove gitleaks tags

* Remove dead code

* Remove commented code

* Adjust playwright tests

Co-authored-by: Copilot <copilot@github.com>

* Update docs

Co-authored-by: Copilot <copilot@github.com>

* Further playwright fixes

---------

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Oliver
2026-04-26 12:49:21 +10:00
committed by GitHub
parent f9cda95427
commit ffe6c13edf
5 changed files with 107 additions and 40 deletions
+9 -2
View File
@@ -92,9 +92,16 @@ Note that inherited BOM Line Items only flow "downwards" in the variant inherita
!!! info "Editing Inherited Items" !!! info "Editing Inherited Items"
When editing an inherited BOM Line Item for a template part, the changes are automatically reflected in the BOM of any variant parts. When editing an inherited BOM Line Item for a template part, the changes are automatically reflected in the BOM of any variant parts.
## BOM Creation ## BOM Editing
BOMs can be created manually, by adjusting individual line items, or by uploading (importing) an existing BOM file. Bills of Material (BOMs) can be created manually, by adjusting individual line items, or by uploading (importing) an existing BOM file.
### Editing Mode
By default, the BOM is displayed in "view" mode. To edit the BOM, click on the {{ icon("edit", color="blue", title="Edit") }} icon at the top of the BOM panel. This will enable editing mode, which allows you to add, adjust or delete BOM line items.
!!! warning "Permissions"
Only users with the appropriate permissions can edit BOMs. If you do not have permission to edit the BOM, the "Edit" icon will not be visible.
### Importing a BOM ### Importing a BOM
+61 -33
View File
@@ -1,3 +1,19 @@
import { t } from '@lingui/core/macro';
import { Alert, Group, Stack, Text } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import {
IconArrowRight,
IconCircleCheck,
IconEdit,
IconFileUpload,
IconLock,
IconPlus,
IconSwitch3
} from '@tabler/icons-react';
import { type ReactNode, useCallback, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ActionButton } from '@lib/components/ActionButton';
import { import {
type RowAction, type RowAction,
RowDeleteAction, RowDeleteAction,
@@ -12,20 +28,6 @@ import { navigateToLink } from '@lib/functions/Navigation';
import useTable from '@lib/hooks/UseTable'; import useTable from '@lib/hooks/UseTable';
import type { TableFilter } from '@lib/types/Filters'; import type { TableFilter } from '@lib/types/Filters';
import type { TableColumn } from '@lib/types/Tables'; import type { TableColumn } from '@lib/types/Tables';
import { t } from '@lingui/core/macro';
import { Alert, Group, Stack, Text } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import {
IconArrowRight,
IconCircleCheck,
IconFileUpload,
IconLock,
IconPlus,
IconSwitch3
} from '@tabler/icons-react';
import { type ReactNode, useCallback, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Thumbnail } from '../../components/images/Thumbnail';
import { ActionDropdown } from '../../components/items/ActionDropdown'; import { ActionDropdown } from '../../components/items/ActionDropdown';
import { RenderPart } from '../../components/render/Part'; import { RenderPart } from '../../components/render/Part';
import { useApi } from '../../contexts/ApiContext'; import { useApi } from '../../contexts/ApiContext';
@@ -45,7 +47,8 @@ import {
DescriptionColumn, DescriptionColumn,
IPNColumn, IPNColumn,
NoteColumn, NoteColumn,
ReferenceColumn ReferenceColumn,
RenderPartColumn
} from '../ColumnRenderers'; } from '../ColumnRenderers';
import { PartCategoryFilter } from '../Filter'; import { PartCategoryFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable'; import { InvenTreeTable } from '../InvenTreeTable';
@@ -80,27 +83,35 @@ export function BomTable({
const user = useUserState(); const user = useUserState();
const table = useTable('bom'); const table = useTable('bom');
const navigate = useNavigate(); const navigate = useNavigate();
const openImporter = useImporterState((state) => state.openImporter); const openImporter = useImporterState((state) => state.openImporter);
const [isEditing, setIsEditing] = useState<boolean>(false);
const tableColumns: TableColumn[] = useMemo(() => { const tableColumns: TableColumn[] = useMemo(() => {
return [ return [
{ {
accessor: 'sub_part', accessor: 'sub_part',
switchable: false, switchable: false,
sortable: true, sortable: true,
minWidth: 250,
render: (record: any) => { render: (record: any) => {
const part = record.sub_part_detail; const part = record.sub_part_detail;
const extra = []; const extra = [];
if (record.part != partId) { if (partId && record.part != partId) {
extra.push( extra.push(
<Text key='different-parent'>{t`This BOM item is defined for a different parent`}</Text> <Text
key='different-parent'
size='sm'
>{t`This BOM item is defined for a different parent`}</Text>
); );
} }
if (!record.validated) { if (!record.validated) {
extra.push( extra.push(
<Text key='not-validated' c='red'> <Text key='not-validated' c='red' size='sm'>
{t`This BOM item has not been validated`} {t`This BOM item has not been validated`}
</Text> </Text>
); );
@@ -109,13 +120,7 @@ export function BomTable({
return ( return (
part && ( part && (
<TableHoverCard <TableHoverCard
value={ value={<RenderPartColumn part={part} />}
<Thumbnail
src={part.thumbnail || part.image}
alt={part.description}
text={part.full_name}
/>
}
iconColor={record.validated ? undefined : 'red'} iconColor={record.validated ? undefined : 'red'}
extra={extra} extra={extra}
title={t`Part Information`} title={t`Part Information`}
@@ -376,7 +381,8 @@ export function BomTable({
return '-'; return '-';
} }
const can_build = Math.trunc(record.can_build); const can_build = Math.max(0, Math.trunc(record.can_build));
const value = ( const value = (
<Text <Text
fs={record.consumable && 'italic'} fs={record.consumable && 'italic'}
@@ -403,7 +409,7 @@ export function BomTable({
}, },
NoteColumn({}) NoteColumn({})
]; ];
}, [partId, params]); }, [isEditing, partId, params]);
const tableFilters: TableFilter[] = useMemo(() => { const tableFilters: TableFilter[] = useMemo(() => {
return [ return [
@@ -614,7 +620,7 @@ export function BomTable({
}) })
]; ];
}, },
[partId, partLocked, user] [isEditing, partId, partLocked, user]
); );
const tableActions = useMemo(() => { const tableActions = useMemo(() => {
@@ -624,7 +630,7 @@ export function BomTable({
tooltip={t`Add BOM Items`} tooltip={t`Add BOM Items`}
position='bottom-start' position='bottom-start'
icon={<IconPlus />} icon={<IconPlus />}
hidden={partLocked || !user.hasAddRole(UserRoles.part)} hidden={!isEditing || partLocked || !user.hasAddRole(UserRoles.part)}
actions={[ actions={[
{ {
name: t`Add BOM Item`, name: t`Add BOM Item`,
@@ -639,9 +645,29 @@ export function BomTable({
onClick: () => importBomItem.open() onClick: () => importBomItem.open()
} }
]} ]}
/>,
<ActionButton
key='edit-bom'
hidden={partLocked || !user.hasChangeRole(UserRoles.part) || isEditing}
tooltip={t`Edit BOM`}
icon={<IconEdit />}
onClick={() => {
setIsEditing(true);
}}
/>,
<ActionButton
key='finish-editing'
hidden={!isEditing}
color='green'
tooltip={t`Finish Editing BOM`}
icon={<IconCircleCheck />}
onClick={() => {
setIsEditing(false);
table.refreshTable();
}}
/> />
]; ];
}, [partLocked, user]); }, [isEditing, partLocked, user]);
return ( return (
<> <>
@@ -678,9 +704,11 @@ export function BomTable({
tableFilters: tableFilters, tableFilters: tableFilters,
modelType: ModelType.part, modelType: ModelType.part,
modelField: 'sub_part', modelField: 'sub_part',
rowActions: rowActions, onCellClick: () => {},
enableSelection: !partLocked, rowActions: isEditing ? rowActions : undefined,
enableBulkDelete: !partLocked && user.hasDeleteRole(UserRoles.part), enableSelection: isEditing && !partLocked,
enableBulkDelete:
isEditing && !partLocked && user.hasDeleteRole(UserRoles.part),
enableDownload: true enableDownload: true
}} }}
/> />
+30 -5
View File
@@ -179,10 +179,15 @@ test('Parts - BOM', async ({ browser }) => {
// Move the mouse away // Move the mouse away
await page.getByRole('link', { name: 'Bill of Materials' }).hover(); await page.getByRole('link', { name: 'Bill of Materials' }).hover();
const cell = await page.getByRole('cell', { // Enable BOM editing
name: 'Small plastic enclosure, black', await page.getByRole('button', { name: 'action-button-edit-bom' }).click();
exact: true await page
}); .getByRole('button', { name: 'action-button-finish-editing-' })
.waitFor();
const cell = await page
.getByRole('cell', { name: 'Thumbnail 1551ABK' })
.first();
await clickOnRowMenu(cell); await clickOnRowMenu(cell);
@@ -202,6 +207,12 @@ test('Parts - BOM', async ({ browser }) => {
await page.getByRole('button', { name: 'Add Substitute' }).waitFor(); await page.getByRole('button', { name: 'Add Substitute' }).waitFor();
await page.getByRole('button', { name: 'Close' }).click(); await page.getByRole('button', { name: 'Close' }).click();
// Finish editing the BOM
await page
.getByRole('button', { name: 'action-button-finish-editing-' })
.click();
await page.getByRole('button', { name: 'action-button-edit-bom' }).waitFor();
}); });
/** /**
@@ -211,8 +222,15 @@ test('Parts - BOM', async ({ browser }) => {
test('Parts - BOM Validation', async ({ browser }) => { test('Parts - BOM Validation', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'part/107/bom' }); const page = await doCachedLogin(browser, { url: 'part/107/bom' });
// Enable BOM editing
await page.getByRole('button', { name: 'action-button-edit-bom' }).click();
await page
.getByRole('button', { name: 'action-button-finish-editing-' })
.waitFor();
// Edit line item, to ensure BOM is not valid // Edit line item, to ensure BOM is not valid
const cell = await page.getByRole('cell', { name: 'Red paint Red Paint' }); const cell = await page.getByRole('cell', { name: 'Thumbnail Red Paint' });
await clickOnRowMenu(cell); await clickOnRowMenu(cell);
await page.getByRole('menuitem', { name: 'Edit', exact: true }).click(); await page.getByRole('menuitem', { name: 'Edit', exact: true }).click();
@@ -288,6 +306,13 @@ 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');
// Enable BOM editing
await page.getByRole('button', { name: 'action-button-edit-bom' }).click();
await page
.getByRole('button', { name: 'action-button-finish-editing-' })
.waitFor();
await page await page
.getByRole('button', { name: 'action-menu-add-bom-items' }) .getByRole('button', { name: 'action-menu-add-bom-items' })
.waitFor(); .waitFor();
+6
View File
@@ -76,6 +76,12 @@ test('Importing - BOM', async ({ browser }) => {
url: 'part/109/bom' url: 'part/109/bom'
}); });
// Enable BOM editing
await page.getByRole('button', { name: 'action-button-edit-bom' }).click();
await page
.getByRole('button', { name: 'action-button-finish-editing-' })
.waitFor();
// Open the BOM importer wizard // Open the BOM importer wizard
await page.getByRole('button', { name: 'action-menu-add-bom-items' }).click(); await page.getByRole('button', { name: 'action-menu-add-bom-items' }).click();
+1
View File
@@ -9,6 +9,7 @@ from inventree.api import InvenTreeAPI
server = os.environ.get('INVENTREE_PYTHON_TEST_SERVER', 'http://127.0.0.1:12345') server = os.environ.get('INVENTREE_PYTHON_TEST_SERVER', 'http://127.0.0.1:12345')
user = os.environ.get('INVENTREE_PYTHON_TEST_USERNAME', 'testuser') user = os.environ.get('INVENTREE_PYTHON_TEST_USERNAME', 'testuser')
pwd = os.environ.get('INVENTREE_PYTHON_TEST_PASSWORD', 'testpassword') pwd = os.environ.get('INVENTREE_PYTHON_TEST_PASSWORD', 'testpassword')
api_client = InvenTreeAPI( api_client = InvenTreeAPI(
server, server,
username=user, username=user,