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

[UI] BOM row expand (#11809)

* Add subassembly expansion

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

* Enable multi-level subassembly display

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

* Adjust padding

* Adds user setting to enable / disable subassembly view

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

* Add icon

* Additional playwright tests

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

---------

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Oliver
2026-04-26 16:32:58 +10:00
committed by GitHub
parent b8f13b8aa9
commit f271725def
7 changed files with 163 additions and 7 deletions
+1
View File
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- [#11809](https://github.com/inventree/InvenTree/pull/11809) adds multi-level subassembly display mode to the BOM table, allowing users to view multiple levels of subassemblies in a single table view. This is an optional display mode which can be toggled on or off by the user.
- [#11778](https://github.com/inventree/InvenTree/pull/11778) adds inline supplier part creation to po line item addition dialog. - [#11778](https://github.com/inventree/InvenTree/pull/11778) adds inline supplier part creation to po line item addition dialog.
- [#11772](https://github.com/inventree/InvenTree/pull/11772) the UI now warns if you navigate away from a note panel with unsaved changes - [#11772](https://github.com/inventree/InvenTree/pull/11772) the UI now warns if you navigate away from a note panel with unsaved changes
- [#11788](https://github.com/inventree/InvenTree/pull/11788) adds support for custom permissions checks on database models defined in plugins. If a model defines a `check_user_permission` classmethod, this will be called to determine if a user has permission to view the model. This is required for plugin models which do not have the required ruleset definitions for the standard permission system. - [#11788](https://github.com/inventree/InvenTree/pull/11788) adds support for custom permissions checks on database models defined in plugins. If a model defines a `check_user_permission` classmethod, this will be called to determine if a user has permission to view the model. This is required for plugin models which do not have the required ruleset definitions for the standard permission system.
+1
View File
@@ -27,6 +27,7 @@ The *Display Settings* screen shows general display configuration options:
{{ usersetting("FORMS_CLOSE_USING_ESCAPE") }} {{ usersetting("FORMS_CLOSE_USING_ESCAPE") }}
{{ usersetting("DISPLAY_STOCKTAKE_TAB") }} {{ usersetting("DISPLAY_STOCKTAKE_TAB") }}
{{ usersetting("SHOW_FULL_CATEGORY_IN_TABLES")}} {{ usersetting("SHOW_FULL_CATEGORY_IN_TABLES")}}
{{ usersetting("SHOW_BOM_SUBASSEMBLY_LEVELS")}}
{{ usersetting("ENABLE_LAST_BREADCRUMB") }} {{ usersetting("ENABLE_LAST_BREADCRUMB") }}
{{ usersetting("SHOW_FULL_LOCATION_IN_TABLES") }} {{ usersetting("SHOW_FULL_LOCATION_IN_TABLES") }}
@@ -251,6 +251,12 @@ USER_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'default': False, 'default': False,
'validator': bool, 'validator': bool,
}, },
'SHOW_BOM_SUBASSEMBLY_LEVELS': {
'name': _('Show Subassemblies in BOM table'),
'description': _('Enable display of subassemblies in the BOM table'),
'default': True,
'validator': bool,
},
'NOTIFICATION_ERROR_REPORT': { 'NOTIFICATION_ERROR_REPORT': {
'name': _('Receive error reports'), 'name': _('Receive error reports'),
'description': _('Receive notifications for system errors'), 'description': _('Receive notifications for system errors'),
@@ -66,7 +66,8 @@ export default function UserSettings() {
'DISPLAY_STOCKTAKE_TAB', 'DISPLAY_STOCKTAKE_TAB',
'ENABLE_LAST_BREADCRUMB', 'ENABLE_LAST_BREADCRUMB',
'SHOW_FULL_LOCATION_IN_TABLES', 'SHOW_FULL_LOCATION_IN_TABLES',
'SHOW_FULL_CATEGORY_IN_TABLES' 'SHOW_FULL_CATEGORY_IN_TABLES',
'SHOW_BOM_SUBASSEMBLY_LEVELS'
]} ]}
/> />
) )
@@ -0,0 +1,118 @@
import { ApiEndpoints, ModelType, apiUrl, useTable } from '@lib/index';
import type { TableColumn, TableState } from '@lib/types/Tables';
import { Group, Paper } from '@mantine/core';
import { IconCornerLeftUp } from '@tabler/icons-react';
import { useMemo } from 'react';
import Expand from '../../components/items/Expand';
import { useUserSettingsState } from '../../states/SettingsStates';
import {
IPNColumn,
ReferenceColumn,
RenderPartColumn
} from '../ColumnRenderers';
import { InvenTreeTable } from '../InvenTreeTable';
import RowExpansionIcon from '../RowExpansionIcon';
export function subassemblyRowExpansion({
table
}: {
table: TableState;
}) {
const userSettings = useUserSettingsState();
return useMemo(() => {
// Display of sub-assemblies is optional
if (!userSettings.isSet('SHOW_BOM_SUBASSEMBLY_LEVELS')) {
return undefined;
}
return {
allowMultiple: true,
expandable: ({ record }: { record: any }) => {
return (
table.isRowExpanded(record.pk) || !!record.sub_part_detail?.assembly
);
},
content: ({ record }: { record: any }) => {
return <BomSubassemblyTable partId={record.sub_part} />;
}
};
}, [table.isRowExpanded, userSettings]);
}
/**
* Display a sub-table of the BOM, for displaying sub-assemblies within the main BOM table.
*/
export default function BomSubassemblyTable({
partId
}: {
partId: number;
}) {
const table = useTable('bom-subassembly');
const rowExpansion = subassemblyRowExpansion({ table: table });
const userSettings = useUserSettingsState();
const tableColumns: TableColumn[] = useMemo(() => {
const allowExpansion = userSettings.isSet('SHOW_BOM_SUBASSEMBLY_LEVELS');
return [
{
accessor: 'sub_part',
render: (record: any) => {
return (
<Group gap='xs' justify='left' p={0}>
{record.sub_part_detail?.assembly && allowExpansion && (
<RowExpansionIcon
enabled
expanded={table.isRowExpanded(record.pk)}
/>
)}
<RenderPartColumn part={record.sub_part_detail} />
</Group>
);
}
},
IPNColumn({
accessor: 'sub_part_detail.IPN'
}),
ReferenceColumn({}),
{
accessor: 'quantity'
}
];
}, [table.isRowExpanded, userSettings]);
return (
<Paper p={'xs'}>
<Group gap='xs' align='top' wrap={'nowrap'}>
<IconCornerLeftUp />
<Expand>
<InvenTreeTable
url={apiUrl(ApiEndpoints.bom_list)}
tableState={table}
columns={tableColumns}
props={{
modelType: ModelType.part,
modelField: 'sub_part',
onCellClick: () => {},
rowExpansion: rowExpansion,
enableSearch: false,
enableFilters: false,
enableColumnSwitching: false,
enableRefresh: false,
enableReports: false,
params: {
part: partId,
substitutes: false,
part_detail: true,
sub_part_detail: true
}
}}
/>
</Expand>
</Group>
</Paper>
);
}
+30 -6
View File
@@ -40,6 +40,7 @@ import {
useEditApiFormModal useEditApiFormModal
} from '../../hooks/UseForm'; } from '../../hooks/UseForm';
import { useImporterState } from '../../states/ImporterState'; import { useImporterState } from '../../states/ImporterState';
import { useUserSettingsState } from '../../states/SettingsStates';
import { useUserState } from '../../states/UserState'; import { useUserState } from '../../states/UserState';
import { import {
BooleanColumn, BooleanColumn,
@@ -52,7 +53,9 @@ import {
} from '../ColumnRenderers'; } from '../ColumnRenderers';
import { PartCategoryFilter } from '../Filter'; import { PartCategoryFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable'; import { InvenTreeTable } from '../InvenTreeTable';
import RowExpansionIcon from '../RowExpansionIcon';
import { TableHoverCard } from '../TableHoverCard'; import { TableHoverCard } from '../TableHoverCard';
import { subassemblyRowExpansion } from './BomSubassemblyTable';
// Calculate the total stock quantity available for a given BomItem // Calculate the total stock quantity available for a given BomItem
function availableStockQuantity(record: any): number { function availableStockQuantity(record: any): number {
@@ -88,7 +91,11 @@ export function BomTable({
const [isEditing, setIsEditing] = useState<boolean>(false); const [isEditing, setIsEditing] = useState<boolean>(false);
const userSettings = useUserSettingsState();
const tableColumns: TableColumn[] = useMemo(() => { const tableColumns: TableColumn[] = useMemo(() => {
const allowExpansion = userSettings.isSet('SHOW_BOM_SUBASSEMBLY_LEVELS');
return [ return [
{ {
accessor: 'sub_part', accessor: 'sub_part',
@@ -117,10 +124,22 @@ export function BomTable({
); );
} }
const assembly: boolean = record.sub_part_detail?.assembly ?? false;
return ( return (
part && ( part && (
<TableHoverCard <TableHoverCard
value={<RenderPartColumn part={part} />} value={
<Group gap='xs' justify='left'>
{assembly && !isEditing && allowExpansion && (
<RowExpansionIcon
enabled
expanded={table.isRowExpanded(record.pk)}
/>
)}
<RenderPartColumn part={part} />
</Group>
}
iconColor={record.validated ? undefined : 'red'} iconColor={record.validated ? undefined : 'red'}
extra={extra} extra={extra}
title={t`Part Information`} title={t`Part Information`}
@@ -409,7 +428,7 @@ export function BomTable({
}, },
NoteColumn({}) NoteColumn({})
]; ];
}, [isEditing, partId, params]); }, [table.isRowExpanded, isEditing, partId, params, userSettings]);
const tableFilters: TableFilter[] = useMemo(() => { const tableFilters: TableFilter[] = useMemo(() => {
return [ return [
@@ -535,7 +554,7 @@ export function BomTable({
table: table table: table
}); });
const editSubstitues = useEditBomSubstitutesForm({ const editSubstitutes = useEditBomSubstitutesForm({
bomItemId: selectedBomItem.pk, bomItemId: selectedBomItem.pk,
bomItem: selectedBomItem, bomItem: selectedBomItem,
onClose: () => { onClose: () => {
@@ -608,7 +627,7 @@ export function BomTable({
icon: <IconSwitch3 />, icon: <IconSwitch3 />,
onClick: () => { onClick: () => {
setSelectedBomItem(record); setSelectedBomItem(record);
editSubstitues.open(); editSubstitutes.open();
} }
}, },
RowDeleteAction({ RowDeleteAction({
@@ -669,13 +688,16 @@ export function BomTable({
]; ];
}, [isEditing, partLocked, user]); }, [isEditing, partLocked, user]);
// Row expansion (for displaying subassemblies)
const rowExpansion = subassemblyRowExpansion({ table: table });
return ( return (
<> <>
{importBomItem.modal} {importBomItem.modal}
{newBomItem.modal} {newBomItem.modal}
{editBomItem.modal} {editBomItem.modal}
{deleteBomItem.modal} {deleteBomItem.modal}
{editSubstitues.modal} {editSubstitutes.modal}
<Stack gap='xs'> <Stack gap='xs'>
{partLocked && ( {partLocked && (
<Alert <Alert
@@ -692,6 +714,7 @@ export function BomTable({
tableState={table} tableState={table}
columns={tableColumns} columns={tableColumns}
props={{ props={{
minHeight: 400,
params: { params: {
...params, ...params,
part: partId, part: partId,
@@ -709,7 +732,8 @@ export function BomTable({
enableSelection: isEditing && !partLocked, enableSelection: isEditing && !partLocked,
enableBulkDelete: enableBulkDelete:
isEditing && !partLocked && user.hasDeleteRole(UserRoles.part), isEditing && !partLocked && user.hasDeleteRole(UserRoles.part),
enableDownload: true enableDownload: true,
rowExpansion: isEditing ? undefined : rowExpansion
}} }}
/> />
</Stack> </Stack>
@@ -179,6 +179,11 @@ 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();
// Test sub-assembly row expansion
await page.getByText('Widget Board (assembled)').click();
await page.getByText('R_10R_0402_1%').waitFor();
await page.getByText('MAX232IDR').waitFor();
// Enable BOM editing // Enable BOM editing
await page.getByRole('button', { name: 'action-button-edit-bom' }).click(); await page.getByRole('button', { name: 'action-button-edit-bom' }).click();
await page await page