[UI] Nested bom (#12952)

* [UI] Replace BOM subassembly table

- Do not render as a separate table
- Dynamically load and insert subassembly data
- Retains single table layout
- Observe same column setup

* Adjust alignment of icons

* Hide certain icons for nested rows

* Display subassembly quantity chain

* Display full multiplication chain

* Add nested pricing multipliers

* Display the multiplied "can-build" quantity

* Updated BOM docs

* Playwright tests

* Handle case where can_build is zero

* Refresh nested BOM when underlying records change

* Remove trunc for display

* fix playwright test
This commit is contained in:
Oliver authored and GitHub committed 2026-10-13 22:59:51 +11:00
1 parent 445fa3e125
commit 99893f3453
9 files changed
+710 -179

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 440 KiB

+41
View File
@@ -188,6 +188,47 @@ Select a part in the list and click on "Add Substitute" button to confirm.
Multi-level (hierarchical) BOMs are natively supported by InvenTree. A Bill of Materials (BOM) can contain sub-assemblies which themselves have a defined BOM. This can continue for an unlimited number of levels.
### Nested BOM View
The BOM table can display the BOM for any sub-assembly directly within the parent BOM. Any line item which references an assembled part displays a {{ icon("chevron-right") }} icon next to the part name. Click on this icon to load the BOM for that sub-assembly, which is inserted into the table directly below the sub-assembly line item.
Sub-assembly line items share the same columns as the top-level BOM, and are indicated by:
- An indent and a {{ icon("corner-down-right") }} icon in the *Component* column, showing the depth of each line item within the BOM hierarchy
- *Italic* text, to distinguish them from the line items defined for the top-level assembly
Sub-assemblies within a sub-assembly can be expanded in the same way, for any number of levels. Click on the {{ icon("chevron-down") }} icon to collapse a sub-assembly again.
{{ image("build/bom_nested.png", "Nested BOM View") }}
#### Calculated Values
For sub-assembly line items, some columns are calculated relative to a single unit of the *top-level* assembly, rather than a single unit of the sub-assembly. Each of these columns displays the calculation underneath the calculated value:
| Column | Calculation | Example |
| --- | --- | --- |
| Quantity | The line item quantity, multiplied by the quantity of each upstream sub-assembly | `4 × 3` |
| Total Price | The total price for the sub-assembly, multiplied by the quantity of each upstream sub-assembly | `3 × ($1.12 - $2.44)` |
| Can Build | The number of sub-assemblies which can be built, divided by the quantity of each upstream sub-assembly | `120 ÷ 3` |
In the example above, the *Doohickey* sub-assembly is required three times for each top-level assembly. Each *Doohickey* requires four *M3x8 Torx* screws, so twelve screws are required in total. Where there are multiple levels of sub-assemblies, the quantity of each level is included in the calculation (e.g. `19 × 1 × 3`).
Other columns (such as *Available Stock*) display the same values as they would in the BOM for the sub-assembly itself.
!!! info "Setup Quantity and Attrition"
The calculated quantity values are based on the line item quantities only. The [setup quantity](#bom-line-items) and [attrition](#bom-line-items) of any upstream sub-assemblies are not included in the calculation.
#### Behavior
- Sub-assembly line items are loaded using the same sort order as the top-level BOM.
- Table filters are only applied to the top-level line items. When a sub-assembly is expanded, its entire BOM is displayed.
- Any expanded sub-assemblies are collapsed when the table is reloaded - for example when changing the page, search term, sort order or table filters.
- Sub-assemblies cannot be expanded while the BOM is in [editing mode](#editing-mode). To edit the line items of a sub-assembly, navigate to the sub-assembly part and edit its BOM directly.
- Sub-assembly line items are not included when [exporting](../concepts/data_export.md) the table data. To export a complete multi-level BOM, use the [BOM Exporter](../plugins/builtin/bom_exporter.md) plugin.
!!! tip "User Setting"
Display of sub-assemblies within the BOM table can be disabled via the [Show Subassemblies in BOM table](../settings/user.md#display-settings) user setting.
## BOM Validation
InvenTree maintains a "validated" flag for each assembled part. When set, this flag indicates that the production requirements for this part have been validated, and that the BOM has not been changed since the last validation.
+23
View File
@@ -192,9 +192,11 @@ export type RowViewProps = RowAction & RowModelProps & RowViewBehaviorProps;
* @param onRowClick : (record: any, index: number, event: any) => void - Callback function when a row is clicked
* @param onCellClick : (event: any, record: any, index: number, column: any, columnIndex: number) => void - Callback function when a cell is clicked
* @param modelType: ModelType - The model type for the table
* @param rowStyle: (record: any, index: number) => MantineStyleProp - Callback function to apply custom styling to each row
* @param height: string | number - Height of the table (default = 'auto')
* @param minHeight: number - Minimum height of the table (default 300px)
* @param noHeader: boolean - Hide the table header
* @param nestedRows: InvenTreeTableNestedRowProps - Configuration for displaying nested (child) rows within the table
*/
export type InvenTreeTableProps<T = any> = {
params?: any;
@@ -232,6 +234,27 @@ export type InvenTreeTableProps<T = any> = {
height?: string | number;
minHeight?: number;
noHeader?: boolean;
nestedRows?: InvenTreeTableNestedRowProps<T>;
};
/**
* Configuration for displaying nested (child) rows within a table.
*
* Child rows are fetched on demand from the same API endpoint as the table,
* and inserted directly below their parent row - sharing the same columns.
* Child rows are discarded whenever the top-level table data is reloaded,
* or when this configuration object changes - so it should be memoized.
*
* @param accessor - Column accessor used to display the expansion icon and indentation (defaults to the first column)
* @param expandable - Callback to determine if a given record can be expanded
* @param childParams - Callback which returns the query parameters used to fetch child rows for a given record
* @param transformChild - Optional callback to modify each child row (e.g. based on values from the parent row)
*/
export type InvenTreeTableNestedRowProps<T = any> = {
accessor?: string;
expandable: (record: T) => boolean;
childParams: (record: T) => Record<string, any>;
transformChild?: (child: T, parent: T) => T;
};
export type InvenTreeTableRenderProps<T extends Record<string, any>> = {
@@ -31,7 +31,7 @@ import {
useDataTableColumns
} from 'mantine-datatable';
import type React from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useApi } from '../../contexts/ApiContext';
import {
@@ -47,10 +47,34 @@ import { usePreviewDrawerState } from '../../states/PreviewDrawerState';
import { useUserSettingsState } from '../../states/SettingsStates';
import { ColumnFilterPopover } from './FilterSelectDrawer';
import InvenTreeTableHeader from './InvenTreeTableHeader';
import NestedRowCell from './NestedRowCell';
const ACTIONS_COLUMN_ACCESSOR: string = '--actions--';
const PAGE_SIZES = [10, 15, 20, 25, 50, 100, 500];
// Key used to attach nesting information to child rows
const NESTED_ROW_INFO: string = '__nested';
// Nesting information attached to each child row
type NestedRowInfo = {
key: string;
depth: number;
};
/**
* Determine if a record is a nested (child) row, inserted below a parent row
*/
export function isNestedRecord(record: any): boolean {
return !!record?.[NESTED_ROW_INFO];
}
// State of a single expanded (or expanding) parent row
type NestedRowState<T> = {
expanded: boolean;
loading: boolean;
children?: T[];
};
/**
* Default table properties (used if not specified)
*/
@@ -283,6 +307,38 @@ export function InvenTreeTableInternal<T extends Record<string, any>>({
[tableState.setSelectedRecords]
);
// Nested (child) row state, keyed by the unique row key of the parent row
const [nestedState, setNestedState] = useState<
Record<string, NestedRowState<T>>
>({});
// Incremented whenever nested data is discarded, to ignore stale responses
const nestedGeneration = useRef<number>(0);
// Callback to toggle a nested row (defined below, once sorting is available)
const toggleNestedRow = useRef<(record: T) => void>(() => {});
// Return the unique key for a row (accounting for nesting)
const getRowKey = useCallback(
(record: any): string => {
const info: NestedRowInfo | undefined = record?.[NESTED_ROW_INFO];
return (
info?.key ?? String(resolveItem(record, tableState.idAccessor ?? 'pk'))
);
},
[tableState.idAccessor]
);
// Column which displays the nesting indicators
const nestedAccessor: string | undefined = useMemo(() => {
if (!props.nestedRows) {
return undefined;
}
return props.nestedRows.accessor ?? columns[0]?.accessor;
}, [props.nestedRows, columns]);
// Update column visibility when hiddenColumns change
const dataColumns: any = useMemo(() => {
// Include all columns (even prop-hidden ones) so useDataTableColumns always
@@ -330,6 +386,32 @@ export function InvenTreeTableInternal<T extends Record<string, any>>({
};
}
// Wrap the render function to display nesting indicators
if (props.nestedRows && col.accessor == nestedAccessor) {
const innerRender = wrappedRender;
const nestedRows = props.nestedRows;
wrappedRender = (record: any, index?: number) => {
const content =
innerRender?.(record, index) ?? resolveItem(record, col.accessor);
const info: NestedRowInfo | undefined = record?.[NESTED_ROW_INFO];
const state = nestedState[getRowKey(record)];
return (
<NestedRowCell
depth={info?.depth ?? 0}
expandable={nestedRows.expandable(record)}
expanded={state?.expanded ?? false}
loading={state?.loading ?? false}
onToggle={() => toggleNestedRow.current(record)}
>
{content}
</NestedRowCell>
);
};
}
// col.filter can be:
// string → single filter name to look up in tableFilters
// string[] → multiple filter names; all matches shown in one popover
@@ -422,6 +504,10 @@ export function InvenTreeTableInternal<T extends Record<string, any>>({
columns,
filters,
fieldNames,
props.nestedRows,
nestedAccessor,
nestedState,
getRowKey,
tableProps.rowActions,
tableState.hiddenColumns,
tableState.selectedRecords,
@@ -776,6 +862,170 @@ export function InvenTreeTableInternal<T extends Record<string, any>>({
tableState.setRecords(tableData ?? apiData ?? []);
}, [tableData, apiData]);
// Serialized params, so that nested data is not discarded on every render
const paramsKey: string = useMemo(
() => JSON.stringify(tableProps.params ?? {}),
[tableProps.params]
);
// Discard nested data whenever the top-level table data is reloaded,
// or the nesting configuration changes (as child rows depend on it)
useEffect(() => {
nestedGeneration.current += 1;
// Keep the existing (empty) state object to avoid a needless re-render
setNestedState((state) => (Object.keys(state).length > 0 ? {} : state));
}, [
props.nestedRows,
url,
tableData,
apiData,
paramsKey,
pageSize,
sortStatus,
tableState.page,
tableState.searchTerm,
tableState.tableKey,
tableState.filterSet.activeFilters,
tableSearchParams
]);
// Expand or collapse a nested row, fetching child rows if required
toggleNestedRow.current = (record: T) => {
const nestedRows = props.nestedRows;
if (!nestedRows || !url) {
return;
}
const key = getRowKey(record);
const current = nestedState[key];
if (current) {
// Ignore toggle requests while data is loading
if (!current.loading) {
setNestedState((state) => ({
...state,
[key]: { ...state[key], expanded: !state[key]?.expanded }
}));
}
return;
}
const generation = nestedGeneration.current;
const depth = ((record as any)?.[NESTED_ROW_INFO]?.depth ?? 0) + 1;
// Child rows use the same ordering as the top-level table
const queryParams: Record<string, any> = {
...tableProps.params,
...nestedRows.childParams(record)
};
const ordering = getTableFilters(false).ordering;
if (ordering) {
queryParams.ordering = ordering;
}
setNestedState((state) => ({
...state,
[key]: { expanded: true, loading: true }
}));
api
.get(url, {
params: queryParams,
timeout: 10 * 1000
})
.then((response) => {
if (generation != nestedGeneration.current) {
return;
}
let results = response.data?.results ?? response.data ?? [];
if (props.dataFormatter) {
results = props.dataFormatter(results);
}
if (!Array.isArray(results)) {
results = [];
}
const children: T[] = results.map((child: any) => ({
...(nestedRows.transformChild?.(child, record) ?? child),
[NESTED_ROW_INFO]: {
key: `${key}/${resolveItem(child, tableState.idAccessor ?? 'pk')}`,
depth: depth
} as NestedRowInfo
}));
setNestedState((state) => ({
...state,
[key]: { expanded: true, loading: false, children: children }
}));
})
.catch((error) => {
if (generation != nestedGeneration.current) {
return;
}
setNestedState((state) => {
const { [key]: _, ...rest } = state;
return rest;
});
showApiErrorMessage({
error: error,
title: t`Error loading table data`
});
});
};
// Records to display - with any expanded child rows inserted below their parent
const displayRecords: T[] = useMemo(() => {
if (!props.nestedRows || Object.keys(nestedState).length == 0) {
return tableState.records;
}
const result: T[] = [];
const insertRecords = (records: T[]) => {
for (const record of records) {
result.push(record);
const state = nestedState[getRowKey(record)];
if (state?.expanded && state.children) {
insertRecords(state.children);
}
}
};
insertRecords(tableState.records);
return result;
}, [props.nestedRows, nestedState, tableState.records, getRowKey]);
// Unique row identifier (child rows may duplicate the primary key of other rows)
const rowIdAccessor = useMemo(() => {
if (!props.nestedRows) {
return tableState.idAccessor ?? 'pk';
}
return (record: T) => getRowKey(record);
}, [props.nestedRows, tableState.idAccessor, getRowKey]);
// Child rows cannot be selected
const isRecordSelectable = useMemo(() => {
if (!props.nestedRows) {
return tableProps.isRecordSelectable;
}
return (record: T, index: number) =>
!(record as any)?.[NESTED_ROW_INFO] &&
(tableProps.isRecordSelectable?.(record, index) ?? true);
}, [props.nestedRows, tableProps.isRecordSelectable]);
const previewDrawer = usePreviewDrawerState();
// Callback to display "preview" view for a row (if available)
@@ -826,8 +1076,13 @@ export function InvenTreeTableInternal<T extends Record<string, any>>({
if (pk) {
cancelEvent(event);
// If a model type is provided and USE_TABLE_NAVIGATION is enabled, navigate to the detail view for that model
// Child rows are not part of the top-level query
const recordIndex = props.nestedRows
? tableState.records.indexOf(record)
: index;
const detailUrl = userSettings.isSet('USE_TABLE_NAVIGATION')
? (getDetailNavigationUrl(record, index) ??
? (getDetailNavigationUrl(record, recordIndex) ??
getDetailUrl(tableProps.modelType, pk))
: getDetailUrl(tableProps.modelType, pk);
@@ -843,6 +1098,8 @@ export function InvenTreeTableInternal<T extends Record<string, any>>({
getDetailNavigationUrl,
props.onCellClick,
props.onRowClick,
props.nestedRows,
tableState.records,
showPreviewPanel,
showRowPreview,
tableProps.modelType
@@ -986,12 +1243,28 @@ export function InvenTreeTableInternal<T extends Record<string, any>>({
recordsPerPageOptions: PAGE_SIZES,
onRecordsPerPageChange: updatePageSize
};
// Pagination text must exclude any nested (child) rows
if (props.nestedRows) {
_params.paginationText = ({
totalRecords
}: {
totalRecords: number;
}) => {
const from = (Math.max(1, tableState.page) - 1) * tablePageSize + 1;
const to = from + tableState.records.length - 1;
return `${from} - ${to} / ${totalRecords}`;
};
}
}
return _params;
}, [
tablePageSize,
tableProps.enablePagination,
props.nestedRows,
tableState.records,
tableState.recordCount,
tableState.page,
tableState.setPage,
@@ -1009,10 +1282,10 @@ export function InvenTreeTableInternal<T extends Record<string, any>>({
// When sticky headers are enabled, we adjust the maximum viewport height,
// based on the number of records being displayed (up to a maximum of 80vh)
const autoHeight = useMemo(() => {
const rows = Math.min(80, 6 * Math.max(tableState.records.length, 3));
const rows = Math.min(80, 6 * Math.max(displayRecords.length, 3));
return `${rows}vh`;
}, [tableState.records]);
}, [displayRecords]);
return (
<>
@@ -1051,7 +1324,7 @@ export function InvenTreeTableInternal<T extends Record<string, any>>({
highlightOnHover
loaderType={userTheme.loader}
pinLastColumn={tableProps.rowActions != undefined}
idAccessor={tableState.idAccessor ?? 'pk'}
idAccessor={rowIdAccessor}
minHeight={tableProps.minHeight ?? 300}
sortStatus={sortStatus}
onSortStatusChange={handleSortStatusChange}
@@ -1061,11 +1334,12 @@ export function InvenTreeTableInternal<T extends Record<string, any>>({
onSelectedRecordsChange={
enableSelection ? onSelectedRecordsChange : undefined
}
isRecordSelectable={tableProps.isRecordSelectable}
isRecordSelectable={isRecordSelectable}
rowStyle={tableProps.rowStyle}
rowExpansion={rowExpansion}
fetching={isFetching}
noRecordsText={missingRecordsText}
records={tableState.records}
records={displayRecords}
storeColumnsKey={cacheKey}
columns={tableColumns.effectiveColumns}
onCellClick={supportsCellClick ? handleCellClick : undefined}
@@ -0,0 +1,63 @@
import { cancelEvent } from '@lib/functions/Events';
import { Box, Group, Loader } from '@mantine/core';
import { IconCornerDownRight } from '@tabler/icons-react';
import type { ReactNode } from 'react';
import RowExpansionIcon from './RowExpansionIcon';
// Indentation (in pixels) applied for each level of nesting
const NESTED_ROW_INDENT: number = 20;
/**
* Cell wrapper for a table which displays nested (child) rows.
*
* - Indents the cell content according to the nesting depth
* - Displays an icon to indicate that the row is a child of the row above
* - Displays an expansion toggle for rows which can be expanded
*/
export default function NestedRowCell({
depth,
expandable,
expanded,
loading,
onToggle,
children
}: Readonly<{
depth: number;
expandable: boolean;
expanded: boolean;
loading: boolean;
onToggle: () => void;
children: ReactNode;
}>) {
return (
<Group
gap='xs'
wrap='nowrap'
justify='left'
style={{ paddingLeft: Math.max(0, depth - 1) * NESTED_ROW_INDENT }}
>
{depth > 0 && (
<IconCornerDownRight
size={16}
color='var(--mantine-color-dimmed)'
style={{ flexShrink: 0 }}
/>
)}
{expandable &&
(loading ? (
<Loader size='xs' aria-label='nested-row-loading' />
) : (
<RowExpansionIcon
enabled
expanded={expanded}
ariaLabel={expanded ? 'nested-row-collapse' : 'nested-row-expand'}
onClick={(event: React.MouseEvent) => {
cancelEvent(event);
onToggle();
}}
/>
))}
<Box style={{ flex: 1, minWidth: 0 }}>{children}</Box>
</Group>
);
}
@@ -3,13 +3,23 @@ import { IconChevronDown, IconChevronRight } from '@tabler/icons-react';
export default function RowExpansionIcon({
enabled,
expanded
expanded,
onClick,
ariaLabel
}: Readonly<{
enabled: boolean;
expanded: boolean;
onClick?: (event: React.MouseEvent) => void;
ariaLabel?: string;
}>) {
return (
<ActionIcon size='sm' variant='transparent' disabled={!enabled}>
<ActionIcon
aria-label={ariaLabel}
size='sm'
variant='transparent'
disabled={!enabled}
onClick={onClick}
>
{expanded ? <IconChevronDown /> : <IconChevronRight />}
</ActionIcon>
);
@@ -1,123 +0,0 @@
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 {
IPNColumn,
ReferenceColumn,
RenderPartColumn
} from '../../components/tables/ColumnRenderers';
import { InvenTreeTable } from '../../components/tables/InvenTreeTable';
import RowExpansionIcon from '../../components/tables/RowExpansionIcon';
import { useUserSettingsState } from '../../states/SettingsStates';
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({
width: 400
}),
{
accessor: 'quantity',
width: 250
}
];
}, [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,
height: 'auto',
minHeight: 150,
params: {
part: partId,
substitutes: false,
part_detail: true,
sub_part_detail: true
}
}}
/>
</Expand>
</Group>
</Paper>
);
}
+158 -44
View File
@@ -1,5 +1,5 @@
import { t } from '@lingui/core/macro';
import { Alert, Group, Stack, Text } from '@mantine/core';
import { Alert, Box, Group, Stack, Text } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import {
IconArrowRight,
@@ -27,7 +27,10 @@ import { apiUrl } from '@lib/functions/Api';
import { navigateToLink } from '@lib/functions/Navigation';
import useTable from '@lib/hooks/UseTable';
import type { TableFilter } from '@lib/types/Filters';
import type { TableColumn } from '@lib/types/Tables';
import type {
InvenTreeTableNestedRowProps,
TableColumn
} from '@lib/types/Tables';
import { ActionDropdown } from '../../components/items/ActionDropdown';
import { RenderPart } from '../../components/render/Part';
import {
@@ -40,8 +43,10 @@ import {
RenderPartColumn
} from '../../components/tables/ColumnRenderers';
import { PartCategoryFilter } from '../../components/tables/Filter';
import { InvenTreeTable } from '../../components/tables/InvenTreeTable';
import RowExpansionIcon from '../../components/tables/RowExpansionIcon';
import {
InvenTreeTable,
isNestedRecord
} from '../../components/tables/InvenTreeTable';
import { TableHoverCard } from '../../components/tables/TableHoverCard';
import { useApi } from '../../contexts/ApiContext';
import { formatDecimal, formatPriceRange } from '../../defaults/formatters';
@@ -58,7 +63,6 @@ import {
useUserSettingsState
} from '../../states/SettingsStates';
import { useUserState } from '../../states/UserState';
import { subassemblyRowExpansion } from './BomSubassemblyTable';
// Calculate the total stock quantity available for a given BomItem
function availableStockQuantity(record: any): number {
@@ -76,6 +80,28 @@ function availableStockQuantity(record: any): number {
return available;
}
// Chain of quantities from a BomItem up to the top-level assembly (innermost first)
function quantityChain(record: any): number[] {
return record.quantity_chain ?? [Number(record.quantity)];
}
// Quantity of a BomItem required for a single unit of the top-level assembly
function totalQuantity(record: any): number {
return quantityChain(record).reduce((total, qty) => total * qty, 1);
}
// Multiplier applied by any upstream sub-assemblies (1 for top-level items)
function upstreamMultiplier(record: any): number {
return quantityChain(record)
.slice(1)
.reduce((total, qty) => total * qty, 1);
}
// Scale a (nullable) price value by the upstream multiplier
function scalePrice(value: any, multiplier: number): number | null {
return value == null ? null : Number(value) * multiplier;
}
export function BomTable({
partId,
partLocked,
@@ -103,9 +129,12 @@ export function BomTable({
const userSettings = useUserSettingsState();
const tableColumns: TableColumn[] = useMemo(() => {
const allowExpansion = userSettings.isSet('SHOW_BOM_SUBASSEMBLY_LEVELS');
const showSubassemblies: boolean = useMemo(
() => userSettings.isSet('SHOW_BOM_SUBASSEMBLY_LEVELS'),
[userSettings]
);
const tableColumns: TableColumn[] = useMemo(() => {
return [
{
accessor: 'sub_part',
@@ -118,7 +147,8 @@ export function BomTable({
const extra = [];
if (partId && record.part != partId) {
// Sub-assembly rows are always defined for a different parent
if (partId && record.part != partId && !isNestedRecord(record)) {
extra.push(
<Text
key='different-parent'
@@ -135,26 +165,24 @@ export function BomTable({
);
}
const assembly: boolean = record.sub_part_detail?.assembly ?? false;
// Fill the available width, so that the hover icon is right-aligned,
// leaving space for the copy button (which overlays the right edge)
return (
part && (
<TableHoverCard
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`}
/>
<Box
style={{
flex: 1,
minWidth: 0,
paddingRight: window.isSecureContext ? 26 : 0
}}
>
<TableHoverCard
value={<RenderPartColumn part={part} />}
iconColor={record.validated ? undefined : 'red'}
extra={extra}
title={t`Part Information`}
/>
</Box>
)
);
}
@@ -180,20 +208,30 @@ export function BomTable({
switchable: false,
sortable: true,
render: (record: any) => {
const quantity = formatDecimal(record.quantity);
const nested: boolean = isNestedRecord(record);
const quantity = formatDecimal(totalQuantity(record));
const units = record.sub_part_detail?.units;
return (
<Group justify='space-between'>
<Group gap='xs'>
<Text>{quantity}</Text>
{record.setup_quantity && record.setup_quantity > 0 && (
<Text size='xs'>{`(+${record.setup_quantity})`}</Text>
<Group justify='space-between' wrap='nowrap'>
<Stack gap={0}>
<Group gap='xs'>
<Text>{quantity}</Text>
{record.setup_quantity && record.setup_quantity > 0 && (
<Text size='xs'>{`(+${record.setup_quantity})`}</Text>
)}
{record.attrition && record.attrition > 0 && (
<Text size='xs'>{`(+${record.attrition}%)`}</Text>
)}
</Group>
{nested && (
<Text size='xs' c='dimmed'>
{quantityChain(record)
.map((qty) => formatDecimal(qty))
.join(' × ')}
</Text>
)}
{record.attrition && record.attrition > 0 && (
<Text size='xs'>{`(+${record.attrition}%)`}</Text>
)}
</Group>
</Stack>
{units && <Text size='xs'>[{units}]</Text>}
</Group>
);
@@ -325,8 +363,46 @@ export function BomTable({
ordering: 'pricing_max_total',
sortable: true,
switchable: true,
render: (record: any) =>
formatPriceRange(record.pricing_min_total, record.pricing_max_total)
render: (record: any) => {
// Account for the quantity of any upstream sub-assemblies
const multiplier = upstreamMultiplier(record);
const price = formatPriceRange(
scalePrice(record.pricing_min_total, multiplier),
scalePrice(record.pricing_max_total, multiplier)
);
const hasPricing =
record.pricing_min_total != null ||
record.pricing_max_total != null;
if (!isNestedRecord(record) || !hasPricing) {
return price;
}
let basePrice = formatPriceRange(
record.pricing_min_total,
record.pricing_max_total
);
// Wrap price ranges in brackets, so the multiplication is unambiguous
if (
record.pricing_min_total != null &&
record.pricing_max_total != null &&
record.pricing_min_total != record.pricing_max_total
) {
basePrice = `(${basePrice})`;
}
return (
<Stack gap={0}>
<Text inherit>{price}</Text>
<Text size='xs' c='dimmed'>
{`${formatDecimal(multiplier)} × ${basePrice}`}
</Text>
</Stack>
);
}
},
{
accessor: 'available_stock',
@@ -425,7 +501,12 @@ export function BomTable({
return '-';
}
const can_build = Math.max(0, Math.trunc(record.can_build));
// Account for the quantity of any upstream sub-assemblies
const multiplier = upstreamMultiplier(record);
const can_build = !record.can_build
? 0
: Math.max(0, Math.trunc(record.can_build / multiplier));
const value = (
<Text
@@ -436,6 +517,18 @@ export function BomTable({
</Text>
);
// Sub-assembly rows also display the "per sub-assembly" quantity
const display = isNestedRecord(record) ? (
<Stack gap={0}>
{value}
<Text size='xs' c='dimmed'>
{`${formatDecimal(Math.max(0, record.can_build))} ÷ ${formatDecimal(multiplier)}`}
</Text>
</Stack>
) : (
value
);
const extra = [];
if (record.consumable) {
@@ -447,13 +540,17 @@ export function BomTable({
}
return (
<TableHoverCard value={value} extra={extra} title={t`Can Build`} />
<TableHoverCard
value={display}
extra={extra}
title={t`Can Build`}
/>
);
}
},
NoteColumn({})
];
}, [table.isRowExpanded, isEditing, partId, params, userSettings]);
}, [partId, params]);
const tableFilters: TableFilter[] = useMemo(() => {
return [
@@ -711,8 +808,23 @@ export function BomTable({
];
}, [isEditing, isLocked, user]);
// Row expansion (for displaying subassemblies)
const rowExpansion = subassemblyRowExpansion({ table: table });
// Nested rows (for displaying subassemblies) - not available while editing
const nestedRows: InvenTreeTableNestedRowProps | undefined = useMemo(() => {
if (isEditing || !showSubassemblies) {
return undefined;
}
return {
accessor: 'sub_part',
expandable: (record: any) => !!record.sub_part_detail?.assembly,
childParams: (record: any) => ({ part: record.sub_part }),
// Multiply quantities through each level of the BOM
transformChild: (child: any, parent: any) => ({
...child,
quantity_chain: [Number(child.quantity), ...quantityChain(parent)]
})
};
}, [isEditing, showSubassemblies]);
return (
<>
@@ -761,7 +873,9 @@ export function BomTable({
return record.part === partId;
},
enableDownload: true,
rowExpansion: isEditing ? undefined : rowExpansion
nestedRows: nestedRows,
rowStyle: (record: any) =>
isNestedRecord(record) ? { fontStyle: 'italic' } : undefined
}}
/>
</Stack>
+132 -3
View File
@@ -196,16 +196,34 @@ 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();
// Test sub-assembly row expansion (rows are inserted into the same table)
const assemblyRow = page
.getByRole('row')
.filter({ hasText: 'Widget Board (assembled)' });
await assemblyRow.getByRole('button', { name: 'nested-row-expand' }).click();
await page.getByText('R_10R_0402_1%').waitFor();
await page.getByText('MAX232IDR').waitFor();
// Enable BOM editing
// Collapse the sub-assembly rows
await assemblyRow
.getByRole('button', { name: 'nested-row-collapse' })
.click();
await expect(page.getByText('MAX232IDR')).toHaveCount(0);
// Re-expand (from cached data)
await assemblyRow.getByRole('button', { name: 'nested-row-expand' }).click();
await page.getByText('MAX232IDR').waitFor();
// Enable BOM editing - sub-assembly rows are removed
await page.getByRole('button', { name: 'action-button-edit-bom' }).click();
await page
.getByRole('button', { name: 'action-button-finish-editing-' })
.waitFor();
await expect(page.getByText('MAX232IDR')).toHaveCount(0);
await expect(
page.getByRole('button', { name: 'nested-row-expand' })
).toHaveCount(0);
const cell = await page
.getByRole('cell', { name: 'Thumbnail 1551ABK' })
@@ -284,6 +302,117 @@ test('Parts - BOM', async ({ browser }) => {
await page.getByRole('button', { name: 'action-button-edit-bom' }).waitFor();
});
/**
* Expand and collapse sub-assembly BOMs within the BOM table
*/
test('Parts - BOM Nesting', async ({ browser }) => {
// Assembly with multiple levels of sub-assemblies
const page = await doCachedLogin(browser, { url: 'part/113/bom' });
const row = (text: string) => page.getByRole('row').filter({ hasText: text });
// Return the cell for a given row and column title
const cell = async (rowText: string, column: string) => {
const index = await page
.locator('thead th')
.evaluateAll(
(headers, title) =>
headers.findIndex((th) => th.textContent?.trim().startsWith(title)),
column
);
expect(index).toBeGreaterThanOrEqual(0);
return row(rowText).first().locator('td').nth(index);
};
const doohickey = row('Doohickey').first();
await doohickey.waitFor();
// Sub-assembly rows are not loaded initially
await expect(page.getByText('M3x10 Torx')).toHaveCount(0);
await expect(row('Widget Board (assembled)')).toHaveCount(1);
// Expand the "Doohickey" sub-assembly (quantity 3)
await doohickey.getByRole('button', { name: 'nested-row-expand' }).click();
await page.getByText('M3x10 Torx').waitFor();
await page.getByText('1551ABK').waitFor();
// Nested "Widget Board (assembled)" row is inserted into the same table
await expect(row('Widget Board (assembled)')).toHaveCount(2);
// Pagination only counts the top-level rows
await page.getByText('1 - 10 / 10').waitFor();
// Quantity is multiplied by the parent quantity
const m3x8Quantity = await cell('M3x8 Torx', 'Quantity');
await expect(m3x8Quantity.getByText('12', { exact: true })).toBeVisible();
await expect(m3x8Quantity.getByText('4 × 3', { exact: true })).toBeVisible();
// Total price displays the multiplier and the base price
await expect(await cell('M3x8 Torx', 'Total Price')).toContainText(
/3 × \(\$[\d.,]+ - \$[\d.,]+\)/
);
// Can build is divided by the parent quantity
const m3x8Build = await cell('M3x8 Torx', 'Can Build');
const buildText = await m3x8Build.innerText();
const buildMatch = buildText.match(/^([\d,]+)\s+([\d,.]+) ÷ 3$/);
expect(buildMatch).not.toBeNull();
const chained = Number(buildMatch![1].replaceAll(',', ''));
const unit = Number(buildMatch![2].replaceAll(',', ''));
expect(chained).toBe(Math.trunc(unit / 3));
// Expand the nested "Widget Board (assembled)" sub-assembly (quantity 1)
await row('Widget Board (assembled)')
.nth(1)
.getByRole('button', { name: 'nested-row-expand' })
.click();
await page.getByText('MAX232IDR').waitFor();
// Quantity is multiplied through each level
const capQuantity = await cell('C_1uF_0402', 'Quantity');
await expect(capQuantity.getByText('57', { exact: true })).toBeVisible();
await expect(
capQuantity.getByText('19 × 1 × 3', { exact: true })
).toBeVisible();
// Widget Board (PCB) line item with quantity 3
const pcbQuantity = await cell('PCB3, PCB4, PCB5', 'Quantity');
await expect(pcbQuantity.getByText('9', { exact: true })).toBeVisible();
await expect(
pcbQuantity.getByText('3 × 1 × 3', { exact: true })
).toBeVisible();
// Collapse the nested sub-assembly - its rows are removed
await row('Widget Board (assembled)')
.nth(1)
.getByRole('button', { name: 'nested-row-collapse' })
.click();
await expect(page.getByText('MAX232IDR')).toHaveCount(0);
await expect(page.getByText('M3x10 Torx')).toHaveCount(1);
// Collapse the top-level sub-assembly - all descendant rows are removed
await doohickey.getByRole('button', { name: 'nested-row-collapse' }).click();
await expect(page.getByText('M3x10 Torx')).toHaveCount(0);
await expect(row('Widget Board (assembled)')).toHaveCount(1);
// Re-expand - nested sub-assemblies remain collapsed
await doohickey.getByRole('button', { name: 'nested-row-expand' }).click();
await page.getByText('M3x10 Torx').waitFor();
await expect(page.getByText('MAX232IDR')).toHaveCount(0);
// Searching reloads the table, which discards sub-assembly rows
await page.getByLabel('table-search-input').fill('Doohickey');
await expect(page.getByText('M3x10 Torx')).toHaveCount(0);
await expect(
page.getByRole('button', { name: 'nested-row-collapse' })
).toHaveCount(0);
});
/**
* Perform BOM validation process
* Note that this is a "background task" which is monitored by the "useBackgroundTask" hook