2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 03:26:45 +00:00

[PUI] Table refactor (#8395)

* Refactor table header items out into new file

* Improve BomItem API query

* Allow table header to be removed entirely

* revert BomTable

* Re-add "box" component

* Reimplement partlocked attribute

* Fix for PartDetail

- Revert to proper panels

* Updated playwright tests

* Additional tests
This commit is contained in:
Oliver 2024-11-01 07:23:26 +11:00 committed by GitHub
parent 801b32e4e3
commit 871cd905f1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 411 additions and 261 deletions

View File

@ -1708,6 +1708,10 @@ class BomItemSerializer(
'sub_part__stock_items__sales_order_allocations',
)
queryset = queryset.select_related(
'part__pricing_data', 'sub_part__pricing_data'
)
queryset = queryset.prefetch_related(
'substitutes', 'substitutes__part__stock_items'
)

View File

@ -1,5 +1,6 @@
import { randomId, useLocalStorage } from '@mantine/hooks';
import { useCallback, useMemo, useState } from 'react';
import { SetURLSearchParams, useSearchParams } from 'react-router-dom';
import { TableFilter } from '../tables/Filter';
@ -8,19 +9,47 @@ import { TableFilter } from '../tables/Filter';
*
* tableKey: A unique key for the table. When this key changes, the table will be refreshed.
* refreshTable: A callback function to externally refresh the table.
* isLoading: A boolean flag to indicate if the table is currently loading data
* setIsLoading: A function to set the isLoading flag
* activeFilters: An array of active filters (saved to local storage)
* setActiveFilters: A function to set the active filters
* clearActiveFilters: A function to clear all active filters
* queryFilters: A map of query filters (e.g. ?active=true&overdue=false) passed in the URL
* setQueryFilters: A function to set the query filters
* clearQueryFilters: A function to clear all query filters
* expandedRecords: An array of expanded records (rows) in the table
* setExpandedRecords: A function to set the expanded records
* isRowExpanded: A function to determine if a record is expanded
* selectedRecords: An array of selected records (rows) in the table
* selectedIds: An array of primary key values for selected records
* hasSelectedRecords: A boolean flag to indicate if any records are selected
* setSelectedRecords: A function to set the selected records
* clearSelectedRecords: A function to clear all selected records
* hiddenColumns: An array of hidden column names
* setHiddenColumns: A function to set the hidden columns
* searchTerm: The current search term for the table
* setSearchTerm: A function to set the search term
* recordCount: The total number of records in the table
* setRecordCount: A function to set the record count
* page: The current page number
* setPage: A function to set the current page number
* pageSize: The number of records per page
* setPageSize: A function to set the number of records per page
* records: An array of records (rows) in the table
* setRecords: A function to set the records
* updateRecord: A function to update a single record in the table
*/
export type TableState = {
tableKey: string;
refreshTable: () => void;
activeFilters: TableFilter[];
isLoading: boolean;
setIsLoading: (value: boolean) => void;
activeFilters: TableFilter[];
setActiveFilters: (filters: TableFilter[]) => void;
clearActiveFilters: () => void;
queryFilters: URLSearchParams;
setQueryFilters: SetURLSearchParams;
clearQueryFilters: () => void;
expandedRecords: any[];
setExpandedRecords: (records: any[]) => void;
isRowExpanded: (pk: number) => boolean;
@ -42,8 +71,6 @@ export type TableState = {
records: any[];
setRecords: (records: any[]) => void;
updateRecord: (record: any) => void;
editable: boolean;
setEditable: (value: boolean) => void;
};
/**
@ -58,6 +85,13 @@ export function useTable(tableName: string): TableState {
return `${tableName.replaceAll('-', '')}-${randomId()}`;
}
// Extract URL query parameters (e.g. ?active=true&overdue=false)
const [queryFilters, setQueryFilters] = useSearchParams();
const clearQueryFilters = useCallback(() => {
setQueryFilters({});
}, []);
const [tableKey, setTableKey] = useState<string>(generateTableName());
// Callback used to refresh (reload) the table
@ -145,8 +179,6 @@ export function useTable(tableName: string): TableState {
const [isLoading, setIsLoading] = useState<boolean>(false);
const [editable, setEditable] = useState<boolean>(false);
return {
tableKey,
refreshTable,
@ -155,6 +187,9 @@ export function useTable(tableName: string): TableState {
activeFilters,
setActiveFilters,
clearActiveFilters,
queryFilters,
setQueryFilters,
clearQueryFilters,
expandedRecords,
setExpandedRecords,
isRowExpanded,
@ -175,8 +210,6 @@ export function useTable(tableName: string): TableState {
setPageSize,
records,
setRecords,
updateRecord,
editable,
setEditable
updateRecord
};
}

View File

@ -625,8 +625,10 @@ export default function PartDetail() {
label: t`Bill of Materials`,
icon: <IconListTree />,
hidden: !part.assembly,
content: (
content: part?.pk ? (
<BomTable partId={part.pk ?? -1} partLocked={part?.locked == true} />
) : (
<Skeleton />
)
},
{

View File

@ -1,21 +1,5 @@
import { t } from '@lingui/macro';
import {
ActionIcon,
Alert,
Box,
Group,
Indicator,
Space,
Stack,
Tooltip
} from '@mantine/core';
import {
IconBarcode,
IconFilter,
IconFilterCancel,
IconRefresh,
IconTrash
} from '@tabler/icons-react';
import { Box, Stack } from '@mantine/core';
import { useQuery } from '@tanstack/react-query';
import {
DataTable,
@ -23,20 +7,11 @@ import {
DataTableRowExpansionProps,
DataTableSortStatus
} from 'mantine-datatable';
import React, {
Fragment,
useCallback,
useEffect,
useMemo,
useState
} from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../App';
import { Boundary } from '../components/Boundary';
import { ActionButton } from '../components/buttons/ActionButton';
import { ButtonMenu } from '../components/buttons/ButtonMenu';
import { PrintingActions } from '../components/buttons/PrintingActions';
import { ApiFormFieldSet } from '../components/forms/fields/ApiFormField';
import { ModelType } from '../enums/ModelType';
import { resolveItem } from '../functions/conversion';
@ -44,16 +19,12 @@ import { cancelEvent } from '../functions/events';
import { extractAvailableFields, mapFields } from '../functions/forms';
import { navigateToLink } from '../functions/navigation';
import { getDetailUrl } from '../functions/urls';
import { useDeleteApiFormModal } from '../hooks/UseForm';
import { TableState } from '../hooks/UseTable';
import { useLocalState } from '../states/LocalState';
import { TableColumn } from './Column';
import { TableColumnSelect } from './ColumnSelect';
import { DownloadAction } from './DownloadAction';
import { TableFilter } from './Filter';
import { FilterSelectDrawer } from './FilterSelectDrawer';
import InvenTreeTableHeader from './InvenTreeTableHeader';
import { RowAction, RowActions } from './RowActions';
import { TableSearchInput } from './Search';
const defaultPageSize: number = 25;
const PAGE_SIZES = [10, 15, 20, 25, 50, 100, 500];
@ -84,6 +55,7 @@ const PAGE_SIZES = [10, 15, 20, 25, 50, 100, 500];
* @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 noHeader: boolean - Hide the table header
*/
export type InvenTreeTableProps<T = any> = {
params?: any;
@ -113,6 +85,7 @@ export type InvenTreeTableProps<T = any> = {
modelType?: ModelType;
rowStyle?: (record: T, index: number) => any;
modelField?: string;
noHeader?: boolean;
};
/**
@ -162,9 +135,6 @@ export function InvenTreeTable<T extends Record<string, any>>({
const navigate = useNavigate();
// Extract URL query parameters (e.g. ?active=true&overdue=false)
const [urlQueryParams, setUrlQueryParams] = useSearchParams();
// Construct table filters - note that we can introspect filter labels from column names
const filters: TableFilter[] = useMemo(() => {
return (
@ -286,7 +256,7 @@ export function InvenTreeTable<T extends Record<string, any>>({
// Update column visibility when hiddenColumns change
const dataColumns: any = useMemo(() => {
let cols = columns
let cols: TableColumn[] = columns
.filter((col) => col?.hidden != true)
.map((col) => {
let hidden: boolean = col.hidden ?? false;
@ -298,6 +268,7 @@ export function InvenTreeTable<T extends Record<string, any>>({
return {
...col,
hidden: hidden,
noWrap: true,
title: col.title ?? fieldNames[col.accessor] ?? `${col.accessor}`
};
});
@ -344,86 +315,79 @@ export function InvenTreeTable<T extends Record<string, any>>({
);
}
// Filter list visibility
const [filtersVisible, setFiltersVisible] = useState<boolean>(false);
// Reset the pagination state when the search term changes
useEffect(() => {
tableState.setPage(1);
}, [tableState.searchTerm]);
/*
* Construct query filters for the current table
*/
function getTableFilters(paginate: boolean = false) {
let queryParams = {
...tableProps.params
};
// Add custom filters
if (tableState.activeFilters) {
tableState.activeFilters.forEach(
(flt) => (queryParams[flt.name] = flt.value)
);
}
// Allow override of filters based on URL query parameters
if (urlQueryParams) {
for (let [key, value] of urlQueryParams) {
queryParams[key] = value;
}
}
// Add custom search term
if (tableState.searchTerm) {
queryParams.search = tableState.searchTerm;
}
// Pagination
if (tableProps.enablePagination && paginate) {
let pageSize = tableState.pageSize ?? defaultPageSize;
if (pageSize != tableState.pageSize) tableState.setPageSize(pageSize);
queryParams.limit = pageSize;
queryParams.offset = (tableState.page - 1) * pageSize;
}
// Ordering
let ordering = getOrderingTerm();
if (ordering) {
if (sortStatus.direction == 'asc') {
queryParams.ordering = ordering;
} else {
queryParams.ordering = `-${ordering}`;
}
}
return queryParams;
}
// Data download callback
function downloadData(fileFormat: string) {
// Download entire dataset (no pagination)
let queryParams = getTableFilters(false);
// Specify file format
queryParams.export = fileFormat;
let downloadUrl = api.getUri({
url: url,
params: queryParams
});
// Download file in a new window (to force download)
window.open(downloadUrl, '_blank');
}
// Data Sorting
const [sortStatus, setSortStatus] = useState<DataTableSortStatus<T>>({
columnAccessor: tableProps.defaultSortColumn ?? '',
direction: 'asc'
});
/*
* Construct query filters for the current table
*/
const getTableFilters = useCallback(
(paginate: boolean = false) => {
let queryParams = {
...tableProps.params
};
// Add custom filters
if (tableState.activeFilters) {
tableState.activeFilters.forEach(
(flt) => (queryParams[flt.name] = flt.value)
);
}
// Allow override of filters based on URL query parameters
if (tableState.queryFilters) {
for (let [key, value] of tableState.queryFilters) {
queryParams[key] = value;
}
}
// Add custom search term
if (tableState.searchTerm) {
queryParams.search = tableState.searchTerm;
}
// Pagination
if (tableProps.enablePagination && paginate) {
let pageSize = tableState.pageSize ?? defaultPageSize;
if (pageSize != tableState.pageSize) tableState.setPageSize(pageSize);
queryParams.limit = pageSize;
queryParams.offset = (tableState.page - 1) * pageSize;
}
// Ordering
let ordering = getOrderingTerm();
if (ordering) {
if (sortStatus.direction == 'asc') {
queryParams.ordering = ordering;
} else {
queryParams.ordering = `-${ordering}`;
}
}
return queryParams;
},
[
tableProps.params,
tableProps.enablePagination,
tableState.activeFilters,
tableState.queryFilters,
tableState.searchTerm,
tableState.pageSize,
tableState.setPageSize,
sortStatus,
getOrderingTerm
]
);
useEffect(() => {
const tableKey: string = tableState.tableKey.split('-')[0];
const sorting: DataTableSortStatus = getTableSorting(tableKey);
@ -538,7 +502,7 @@ export function InvenTreeTable<T extends Record<string, any>>({
// Refetch data when the query parameters change
useEffect(() => {
refetch();
}, [urlQueryParams]);
}, [tableState.queryFilters]);
useEffect(() => {
tableState.setIsLoading(
@ -559,35 +523,6 @@ export function InvenTreeTable<T extends Record<string, any>>({
}
}, [data]);
const deleteRecords = useDeleteApiFormModal({
url: url,
title: t`Delete Selected Items`,
preFormContent: (
<Alert
color="red"
title={t`Are you sure you want to delete the selected items?`}
>
{t`This action cannot be undone`}
</Alert>
),
initialData: {
items: tableState.selectedIds
},
fields: {
items: {
hidden: true
}
},
onFormSuccess: () => {
tableState.clearSelectedRecords();
tableState.refreshTable();
if (props.afterBulkDelete) {
props.afterBulkDelete();
}
}
});
// Callback when a cell is clicked
const handleCellClick = useCallback(
({
@ -672,122 +607,24 @@ export function InvenTreeTable<T extends Record<string, any>>({
return (
<>
{deleteRecords.modal}
{tableProps.enableFilters && (filters.length ?? 0) > 0 && (
<Boundary label="table-filter-drawer">
<FilterSelectDrawer
availableFilters={filters}
tableState={tableState}
opened={filtersVisible}
onClose={() => setFiltersVisible(false)}
/>
</Boundary>
)}
<Boundary label={`InvenTreeTable-${tableState.tableKey}`}>
<Stack gap="sm">
<Group justify="apart" grow wrap="nowrap">
<Group justify="left" key="custom-actions" gap={5} wrap="nowrap">
<PrintingActions
items={tableState.selectedIds}
modelType={tableProps.modelType}
enableLabels={tableProps.enableLabels}
enableReports={tableProps.enableReports}
/>
{(tableProps.barcodeActions?.length ?? 0) > 0 && (
<ButtonMenu
key="barcode-actions"
icon={<IconBarcode />}
label={t`Barcode Actions`}
tooltip={t`Barcode Actions`}
actions={tableProps.barcodeActions ?? []}
/>
)}
{tableProps.enableBulkDelete && (
<ActionButton
disabled={!tableState.hasSelectedRecords}
icon={<IconTrash />}
color="red"
tooltip={t`Delete selected records`}
onClick={() => {
deleteRecords.open();
}}
/>
)}
{tableProps.tableActions?.map((group, idx) => (
<Fragment key={idx}>{group}</Fragment>
))}
</Group>
<Space />
<Group justify="right" gap={5} wrap="nowrap">
{tableProps.enableSearch && (
<TableSearchInput
searchCallback={(term: string) =>
tableState.setSearchTerm(term)
}
/>
)}
{tableProps.enableRefresh && (
<ActionIcon variant="transparent" aria-label="table-refresh">
<Tooltip label={t`Refresh data`}>
<IconRefresh
onClick={() => {
refetch();
tableState.clearSelectedRecords();
}}
/>
</Tooltip>
</ActionIcon>
)}
{hasSwitchableColumns && (
<TableColumnSelect
columns={dataColumns}
onToggleColumn={toggleColumn}
/>
)}
{urlQueryParams.size > 0 && (
<ActionIcon
variant="transparent"
color="red"
aria-label="table-clear-query-filters"
>
<Tooltip label={t`Clear custom query filters`}>
<IconFilterCancel
onClick={() => {
setUrlQueryParams({});
}}
/>
</Tooltip>
</ActionIcon>
)}
{tableProps.enableFilters && filters.length > 0 && (
<Indicator
size="xs"
label={tableState.activeFilters?.length ?? 0}
disabled={tableState.activeFilters?.length == 0}
>
<ActionIcon
variant="transparent"
aria-label="table-select-filters"
>
<Tooltip label={t`Table Filters`}>
<IconFilter
onClick={() => setFiltersVisible(!filtersVisible)}
/>
</Tooltip>
</ActionIcon>
</Indicator>
)}
{tableProps.enableDownload && (
<DownloadAction
key="download-action"
downloadCallback={downloadData}
/>
)}
</Group>
</Group>
<Stack gap="xs">
{!tableProps.noHeader && (
<Boundary label={`InvenTreeTableHeader-${tableState.tableKey}`}>
<InvenTreeTableHeader
tableUrl={url}
tableState={tableState}
tableProps={tableProps}
hasSwitchableColumns={hasSwitchableColumns}
columns={dataColumns}
filters={filters}
toggleColumn={toggleColumn}
/>
</Boundary>
)}
<Boundary label={`InvenTreeTable-${tableState.tableKey}`}>
<Box pos="relative">
<DataTable
withTableBorder
withTableBorder={!tableProps.noHeader}
withColumnBorders
striped
highlightOnHover
@ -814,6 +651,7 @@ export function InvenTreeTable<T extends Record<string, any>>({
records={tableState.records}
columns={dataColumns}
onCellClick={handleCellClick}
noHeader={tableProps.noHeader ?? false}
defaultColumnProps={{
noWrap: true,
textAlign: 'left',
@ -825,8 +663,8 @@ export function InvenTreeTable<T extends Record<string, any>>({
{...optionalParams}
/>
</Box>
</Stack>
</Boundary>
</Boundary>
</Stack>
</>
);
}

View File

@ -0,0 +1,239 @@
import { t } from '@lingui/macro';
import {
ActionIcon,
Alert,
Group,
Indicator,
Space,
Tooltip
} from '@mantine/core';
import {
IconBarcode,
IconFilter,
IconFilterCancel,
IconRefresh,
IconTrash
} from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Fragment } from 'react/jsx-runtime';
import { api } from '../App';
import { Boundary } from '../components/Boundary';
import { ActionButton } from '../components/buttons/ActionButton';
import { ButtonMenu } from '../components/buttons/ButtonMenu';
import { PrintingActions } from '../components/buttons/PrintingActions';
import { useDeleteApiFormModal } from '../hooks/UseForm';
import { TableState } from '../hooks/UseTable';
import { TableColumnSelect } from './ColumnSelect';
import { DownloadAction } from './DownloadAction';
import { TableFilter } from './Filter';
import { FilterSelectDrawer } from './FilterSelectDrawer';
import { InvenTreeTableProps } from './InvenTreeTable';
import { TableSearchInput } from './Search';
/**
* Render a composite header for an InvenTree table
*/
export default function InvenTreeTableHeader({
tableUrl,
tableState,
tableProps,
hasSwitchableColumns,
columns,
filters,
toggleColumn
}: {
tableUrl: string;
tableState: TableState;
tableProps: InvenTreeTableProps<any>;
hasSwitchableColumns: boolean;
columns: any;
filters: TableFilter[];
toggleColumn: (column: string) => void;
}) {
// Filter list visibility
const [filtersVisible, setFiltersVisible] = useState<boolean>(false);
const downloadData = (fileFormat: string) => {
// Download entire dataset (no pagination)
let queryParams = {
...tableProps.params
};
// Add in active filters
if (tableState.activeFilters) {
tableState.activeFilters.forEach((filter) => {
queryParams[filter.name] = filter.value;
});
}
// Allow overriding of query parameters
if (tableState.queryFilters) {
for (let [key, value] of tableState.queryFilters) {
queryParams[key] = value;
}
}
// Add custom search term
if (tableState.searchTerm) {
queryParams.search = tableState.searchTerm;
}
// Specify file format
queryParams.export = fileFormat;
let downloadUrl = api.getUri({
url: tableUrl,
params: queryParams
});
// Download file in a new window (to force download)
window.open(downloadUrl, '_blank');
};
const deleteRecords = useDeleteApiFormModal({
url: tableUrl,
title: t`Delete Selected Items`,
preFormContent: (
<Alert
color="red"
title={t`Are you sure you want to delete the selected items?`}
>
{t`This action cannot be undone`}
</Alert>
),
initialData: {
items: tableState.selectedIds
},
fields: {
items: {
hidden: true
}
},
onFormSuccess: () => {
tableState.clearSelectedRecords();
tableState.refreshTable();
if (tableProps.afterBulkDelete) {
tableProps.afterBulkDelete();
}
}
});
return (
<>
{deleteRecords.modal}
{tableProps.enableFilters && (filters.length ?? 0) > 0 && (
<Boundary label={`InvenTreeTableFilterDrawer-${tableState.tableKey}`}>
<FilterSelectDrawer
availableFilters={filters}
tableState={tableState}
opened={filtersVisible}
onClose={() => setFiltersVisible(false)}
/>
</Boundary>
)}
<Group justify="apart" grow wrap="nowrap">
<Group justify="left" key="custom-actions" gap={5} wrap="nowrap">
<PrintingActions
items={tableState.selectedIds}
modelType={tableProps.modelType}
enableLabels={tableProps.enableLabels}
enableReports={tableProps.enableReports}
/>
{(tableProps.barcodeActions?.length ?? 0) > 0 && (
<ButtonMenu
key="barcode-actions"
icon={<IconBarcode />}
label={t`Barcode Actions`}
tooltip={t`Barcode Actions`}
actions={tableProps.barcodeActions ?? []}
/>
)}
{tableProps.enableBulkDelete && (
<ActionButton
disabled={!tableState.hasSelectedRecords}
icon={<IconTrash />}
color="red"
tooltip={t`Delete selected records`}
onClick={() => {
deleteRecords.open();
}}
/>
)}
{tableProps.tableActions?.map((group, idx) => (
<Fragment key={idx}>{group}</Fragment>
))}
</Group>
<Space />
<Group justify="right" gap={5} wrap="nowrap">
{tableProps.enableSearch && (
<TableSearchInput
searchCallback={(term: string) => tableState.setSearchTerm(term)}
/>
)}
{tableProps.enableRefresh && (
<ActionIcon variant="transparent" aria-label="table-refresh">
<Tooltip label={t`Refresh data`}>
<IconRefresh
onClick={() => {
tableState.refreshTable();
tableState.clearSelectedRecords();
}}
/>
</Tooltip>
</ActionIcon>
)}
{hasSwitchableColumns && (
<TableColumnSelect
columns={columns}
onToggleColumn={toggleColumn}
/>
)}
{tableState.queryFilters.size > 0 && (
<ActionIcon
variant="transparent"
color="red"
aria-label="table-clear-query-filters"
>
<Tooltip label={t`Clear custom query filters`}>
<IconFilterCancel
onClick={() => {
tableState.clearQueryFilters();
}}
/>
</Tooltip>
</ActionIcon>
)}
{tableProps.enableFilters && filters.length > 0 && (
<Indicator
size="xs"
label={tableState.activeFilters?.length ?? 0}
disabled={tableState.activeFilters?.length == 0}
>
<ActionIcon
variant="transparent"
aria-label="table-select-filters"
>
<Tooltip label={t`Table Filters`}>
<IconFilter
onClick={() => setFiltersVisible(!filtersVisible)}
/>
</Tooltip>
</ActionIcon>
</Indicator>
)}
{tableProps.enableDownload && (
<DownloadAction
key="download-action"
downloadCallback={downloadData}
/>
)}
</Group>
</Group>
</>
);
}

View File

@ -18,11 +18,45 @@ test('Pages - Part - Locking', async ({ page }) => {
await page.getByLabel('part-lock-icon').waitFor();
await page.getByText('Part is Locked', { exact: true }).waitFor();
// Check expected "badge" values
await page.getByText('In Stock: 13').waitFor();
await page.getByText('Required: 10').waitFor();
await page.getByText('In Production: 50').waitFor();
// Check the "parameters" tab also
await page.getByRole('tab', { name: 'Parameters' }).click();
await page.getByText('Part parameters cannot be').waitFor();
});
test('Pages - Part - Allocations', async ({ page }) => {
await doQuickLogin(page);
// Let's look at the allocations for a single stock item
await page.goto(`${baseUrl}/stock/item/324/`);
await page.getByRole('tab', { name: 'Allocations' }).click();
await page.getByRole('button', { name: 'Build Order Allocations' }).waitFor();
await page.getByRole('cell', { name: 'Making some blue chairs' }).waitFor();
await page.getByRole('cell', { name: 'Making tables for SO 0003' }).waitFor();
// Let's look at the allocations for the entire part
await page.getByRole('tab', { name: 'Details' }).click();
await page.getByRole('link', { name: 'Leg' }).click();
await page.getByRole('tab', { name: 'Part Details' }).click();
await page.getByText('660 / 760').waitFor();
await page.getByRole('tab', { name: 'Allocations' }).click();
// Number of table records
await page.getByText('1 - 4 / 4').waitFor();
await page.getByRole('cell', { name: 'Making red square tables' }).waitFor();
// Navigate through to the build order
await page.getByRole('cell', { name: 'BO0007' }).click();
await page.getByRole('tab', { name: 'Build Details' }).waitFor();
});
test('Pages - Part - Pricing (Nothing, BOM)', async ({ page }) => {
await doQuickLogin(page);