[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

+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