mirror of
https://github.com/inventree/InvenTree.git
synced 2026-10-14 14:11:55 +00:00
[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:
1 parent
445fa3e125
commit
99893f3453
9 files changed
+710
-179
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user