From 871cd905f197543f6d1b79fd1d232c58c3aabe2f Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 1 Nov 2024 07:23:26 +1100 Subject: [PATCH] [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 --- src/backend/InvenTree/part/serializers.py | 4 + src/frontend/src/hooks/UseTable.tsx | 49 ++- src/frontend/src/pages/part/PartDetail.tsx | 4 +- src/frontend/src/tables/InvenTreeTable.tsx | 328 +++++------------- .../src/tables/InvenTreeTableHeader.tsx | 239 +++++++++++++ src/frontend/tests/pages/pui_part.spec.ts | 34 ++ 6 files changed, 404 insertions(+), 254 deletions(-) create mode 100644 src/frontend/src/tables/InvenTreeTableHeader.tsx diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index 58675b44b4f6..5ac3d2c54ecd 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -1708,6 +1708,10 @@ def setup_eager_loading(queryset): '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' ) diff --git a/src/frontend/src/hooks/UseTable.tsx b/src/frontend/src/hooks/UseTable.tsx index e8af9ad5a51e..62b012d6cc0b 100644 --- a/src/frontend/src/hooks/UseTable.tsx +++ b/src/frontend/src/hooks/UseTable.tsx @@ -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(generateTableName()); // Callback used to refresh (reload) the table @@ -145,8 +179,6 @@ export function useTable(tableName: string): TableState { const [isLoading, setIsLoading] = useState(false); - const [editable, setEditable] = useState(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 }; } diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index 78bf9565e530..3a8baf259d16 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -625,8 +625,10 @@ export default function PartDetail() { label: t`Bill of Materials`, icon: , hidden: !part.assembly, - content: ( + content: part?.pk ? ( + ) : ( + ) }, { diff --git a/src/frontend/src/tables/InvenTreeTable.tsx b/src/frontend/src/tables/InvenTreeTable.tsx index 1f9eb01df9b2..99d5bca5fa55 100644 --- a/src/frontend/src/tables/InvenTreeTable.tsx +++ b/src/frontend/src/tables/InvenTreeTable.tsx @@ -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 = { params?: any; @@ -113,6 +85,7 @@ export type InvenTreeTableProps = { modelType?: ModelType; rowStyle?: (record: T, index: number) => any; modelField?: string; + noHeader?: boolean; }; /** @@ -162,9 +135,6 @@ export function InvenTreeTable>({ 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>({ // 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>({ return { ...col, hidden: hidden, + noWrap: true, title: col.title ?? fieldNames[col.accessor] ?? `${col.accessor}` }; }); @@ -344,85 +315,78 @@ export function InvenTreeTable>({ ); } - // Filter list visibility - const [filtersVisible, setFiltersVisible] = useState(false); - // Reset the pagination state when the search term changes useEffect(() => { tableState.setPage(1); }, [tableState.searchTerm]); + // Data Sorting + const [sortStatus, setSortStatus] = useState>({ + columnAccessor: tableProps.defaultSortColumn ?? '', + direction: 'asc' + }); + /* * 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) - ); - } + const getTableFilters = useCallback( + (paginate: boolean = false) => { + let queryParams = { + ...tableProps.params + }; - // Allow override of filters based on URL query parameters - if (urlQueryParams) { - for (let [key, value] of urlQueryParams) { - queryParams[key] = value; + // Add custom filters + if (tableState.activeFilters) { + tableState.activeFilters.forEach( + (flt) => (queryParams[flt.name] = flt.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}`; + // Allow override of filters based on URL query parameters + if (tableState.queryFilters) { + for (let [key, value] of tableState.queryFilters) { + queryParams[key] = value; + } } - } - - return queryParams; - } - // Data download callback - function downloadData(fileFormat: string) { - // Download entire dataset (no pagination) - let queryParams = getTableFilters(false); + // Add custom search term + if (tableState.searchTerm) { + queryParams.search = tableState.searchTerm; + } - // Specify file format - queryParams.export = fileFormat; + // 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; + } - let downloadUrl = api.getUri({ - url: url, - params: queryParams - }); + // Ordering + let ordering = getOrderingTerm(); - // Download file in a new window (to force download) - window.open(downloadUrl, '_blank'); - } + if (ordering) { + if (sortStatus.direction == 'asc') { + queryParams.ordering = ordering; + } else { + queryParams.ordering = `-${ordering}`; + } + } - // Data Sorting - const [sortStatus, setSortStatus] = useState>({ - columnAccessor: tableProps.defaultSortColumn ?? '', - direction: 'asc' - }); + 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]; @@ -538,7 +502,7 @@ export function InvenTreeTable>({ // Refetch data when the query parameters change useEffect(() => { refetch(); - }, [urlQueryParams]); + }, [tableState.queryFilters]); useEffect(() => { tableState.setIsLoading( @@ -559,35 +523,6 @@ export function InvenTreeTable>({ } }, [data]); - const deleteRecords = useDeleteApiFormModal({ - url: url, - title: t`Delete Selected Items`, - preFormContent: ( - - {t`This action cannot be undone`} - - ), - 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>({ return ( <> - {deleteRecords.modal} - {tableProps.enableFilters && (filters.length ?? 0) > 0 && ( - - setFiltersVisible(false)} - /> - - )} - - - - - - {(tableProps.barcodeActions?.length ?? 0) > 0 && ( - } - label={t`Barcode Actions`} - tooltip={t`Barcode Actions`} - actions={tableProps.barcodeActions ?? []} - /> - )} - {tableProps.enableBulkDelete && ( - } - color="red" - tooltip={t`Delete selected records`} - onClick={() => { - deleteRecords.open(); - }} - /> - )} - {tableProps.tableActions?.map((group, idx) => ( - {group} - ))} - - - - {tableProps.enableSearch && ( - - tableState.setSearchTerm(term) - } - /> - )} - {tableProps.enableRefresh && ( - - - { - refetch(); - tableState.clearSelectedRecords(); - }} - /> - - - )} - {hasSwitchableColumns && ( - - )} - {urlQueryParams.size > 0 && ( - - - { - setUrlQueryParams({}); - }} - /> - - - )} - {tableProps.enableFilters && filters.length > 0 && ( - - - - setFiltersVisible(!filtersVisible)} - /> - - - - )} - {tableProps.enableDownload && ( - - )} - - + + {!tableProps.noHeader && ( + + + + )} + >({ records={tableState.records} columns={dataColumns} onCellClick={handleCellClick} + noHeader={tableProps.noHeader ?? false} defaultColumnProps={{ noWrap: true, textAlign: 'left', @@ -825,8 +663,8 @@ export function InvenTreeTable>({ {...optionalParams} /> - - + + ); } diff --git a/src/frontend/src/tables/InvenTreeTableHeader.tsx b/src/frontend/src/tables/InvenTreeTableHeader.tsx new file mode 100644 index 000000000000..4cf2deca2031 --- /dev/null +++ b/src/frontend/src/tables/InvenTreeTableHeader.tsx @@ -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; + hasSwitchableColumns: boolean; + columns: any; + filters: TableFilter[]; + toggleColumn: (column: string) => void; +}) { + // Filter list visibility + const [filtersVisible, setFiltersVisible] = useState(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: ( + + {t`This action cannot be undone`} + + ), + 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 && ( + + setFiltersVisible(false)} + /> + + )} + + + + + {(tableProps.barcodeActions?.length ?? 0) > 0 && ( + } + label={t`Barcode Actions`} + tooltip={t`Barcode Actions`} + actions={tableProps.barcodeActions ?? []} + /> + )} + {tableProps.enableBulkDelete && ( + } + color="red" + tooltip={t`Delete selected records`} + onClick={() => { + deleteRecords.open(); + }} + /> + )} + {tableProps.tableActions?.map((group, idx) => ( + {group} + ))} + + + + {tableProps.enableSearch && ( + tableState.setSearchTerm(term)} + /> + )} + {tableProps.enableRefresh && ( + + + { + tableState.refreshTable(); + tableState.clearSelectedRecords(); + }} + /> + + + )} + {hasSwitchableColumns && ( + + )} + {tableState.queryFilters.size > 0 && ( + + + { + tableState.clearQueryFilters(); + }} + /> + + + )} + {tableProps.enableFilters && filters.length > 0 && ( + + + + setFiltersVisible(!filtersVisible)} + /> + + + + )} + {tableProps.enableDownload && ( + + )} + + + + ); +} diff --git a/src/frontend/tests/pages/pui_part.spec.ts b/src/frontend/tests/pages/pui_part.spec.ts index 0c3f7e713718..4b85d6386876 100644 --- a/src/frontend/tests/pages/pui_part.spec.ts +++ b/src/frontend/tests/pages/pui_part.spec.ts @@ -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);