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
- [#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.
- [#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.
+1
View File
@@ -27,6 +27,7 @@ The *Display Settings* screen shows general display configuration options:
{{ usersetting("FORMS_CLOSE_USING_ESCAPE") }}
{{ usersetting("DISPLAY_STOCKTAKE_TAB") }}
{{ usersetting("SHOW_FULL_CATEGORY_IN_TABLES")}}
{{ usersetting("SHOW_BOM_SUBASSEMBLY_LEVELS")}}
{{ usersetting("ENABLE_LAST_BREADCRUMB") }}
{{ usersetting("SHOW_FULL_LOCATION_IN_TABLES") }}
@@ -251,6 +251,12 @@ USER_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'default': False,
'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': {
'name': _('Receive error reports'),
'description': _('Receive notifications for system errors'),
@@ -66,7 +66,8 @@ export default function UserSettings() {
'DISPLAY_STOCKTAKE_TAB',
'ENABLE_LAST_BREADCRUMB',
'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
} from '../../hooks/UseForm';
import { useImporterState } from '../../states/ImporterState';
import { useUserSettingsState } from '../../states/SettingsStates';
import { useUserState } from '../../states/UserState';
import {
BooleanColumn,
@@ -52,7 +53,9 @@ import {
} from '../ColumnRenderers';
import { PartCategoryFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
import RowExpansionIcon from '../RowExpansionIcon';
import { TableHoverCard } from '../TableHoverCard';
import { subassemblyRowExpansion } from './BomSubassemblyTable';
// Calculate the total stock quantity available for a given BomItem
function availableStockQuantity(record: any): number {
@@ -88,7 +91,11 @@ export function BomTable({
const [isEditing, setIsEditing] = useState<boolean>(false);
const userSettings = useUserSettingsState();
const tableColumns: TableColumn[] = useMemo(() => {
const allowExpansion = userSettings.isSet('SHOW_BOM_SUBASSEMBLY_LEVELS');
return [
{
accessor: 'sub_part',
@@ -117,10 +124,22 @@ export function BomTable({
);
}
const assembly: boolean = record.sub_part_detail?.assembly ?? false;
return (
part && (
<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'}
extra={extra}
title={t`Part Information`}
@@ -409,7 +428,7 @@ export function BomTable({
},
NoteColumn({})
];
}, [isEditing, partId, params]);
}, [table.isRowExpanded, isEditing, partId, params, userSettings]);
const tableFilters: TableFilter[] = useMemo(() => {
return [
@@ -535,7 +554,7 @@ export function BomTable({
table: table
});
const editSubstitues = useEditBomSubstitutesForm({
const editSubstitutes = useEditBomSubstitutesForm({
bomItemId: selectedBomItem.pk,
bomItem: selectedBomItem,
onClose: () => {
@@ -608,7 +627,7 @@ export function BomTable({
icon: <IconSwitch3 />,
onClick: () => {
setSelectedBomItem(record);
editSubstitues.open();
editSubstitutes.open();
}
},
RowDeleteAction({
@@ -669,13 +688,16 @@ export function BomTable({
];
}, [isEditing, partLocked, user]);
// Row expansion (for displaying subassemblies)
const rowExpansion = subassemblyRowExpansion({ table: table });
return (
<>
{importBomItem.modal}
{newBomItem.modal}
{editBomItem.modal}
{deleteBomItem.modal}
{editSubstitues.modal}
{editSubstitutes.modal}
<Stack gap='xs'>
{partLocked && (
<Alert
@@ -692,6 +714,7 @@ export function BomTable({
tableState={table}
columns={tableColumns}
props={{
minHeight: 400,
params: {
...params,
part: partId,
@@ -709,7 +732,8 @@ export function BomTable({
enableSelection: isEditing && !partLocked,
enableBulkDelete:
isEditing && !partLocked && user.hasDeleteRole(UserRoles.part),
enableDownload: true
enableDownload: true,
rowExpansion: isEditing ? undefined : rowExpansion
}}
/>
</Stack>
@@ -179,6 +179,11 @@ test('Parts - BOM', async ({ browser }) => {
// Move the mouse away
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
await page.getByRole('button', { name: 'action-button-edit-bom' }).click();
await page