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:
parent
801b32e4e3
commit
871cd905f1
@ -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'
|
||||
)
|
||||
|
@ -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
|
||||
};
|
||||
}
|
||||
|
@ -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 />
|
||||
)
|
||||
},
|
||||
{
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
239
src/frontend/src/tables/InvenTreeTableHeader.tsx
Normal file
239
src/frontend/src/tables/InvenTreeTableHeader.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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);
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user