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
Binary file not shown.
|
After Width: | Height: | Size: 440 KiB |
@@ -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.
|
||||
|
||||
@@ -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