2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-29 12:06:44 +00:00

[PUI] Part category page (#5555)

* Replace PartIndex with CategoryDetail

- Can pass a category ID to show a single category
- Otherwise, show the top-level parts category

* Refactor <InvenTreeTable> component

- Simplify property passing
- Easier tableRefresh mechanism

* Refetch table data when base parameters change

* Correctly update pages when ID changes

* Notification panel cleanup

* Remove column from InvenTreeTableProps type

* more fancy

* Fix notification alert

* Implement useLocalStorage hook

* useLocalStorage hook for table filters too
This commit is contained in:
Oliver 2023-09-17 00:21:59 +10:00 committed by GitHub
parent 41cbe30db1
commit a68c1d28c6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 502 additions and 351 deletions

View File

@ -2,10 +2,16 @@ import { Text } from '@mantine/core';
import { InvenTreeStyle } from '../../globalStyle'; import { InvenTreeStyle } from '../../globalStyle';
export function StylishText({ children }: { children: JSX.Element | string }) { export function StylishText({
children,
size = 'md'
}: {
children: JSX.Element | string;
size?: string;
}) {
const { classes } = InvenTreeStyle(); const { classes } = InvenTreeStyle();
return ( return (
<Text className={classes.signText} variant="gradient"> <Text size={size} className={classes.signText} variant="gradient">
{children} {children}
</Text> </Text>
); );

View File

@ -62,7 +62,10 @@ export function Header() {
<NavigationDrawer opened={navDrawerOpened} close={closeNavDrawer} /> <NavigationDrawer opened={navDrawerOpened} close={closeNavDrawer} />
<NotificationDrawer <NotificationDrawer
opened={notificationDrawerOpened} opened={notificationDrawerOpened}
onClose={closeNotificationDrawer} onClose={() => {
notifications.refetch();
closeNotificationDrawer();
}}
/> />
<Container className={classes.layoutHeaderSection} size={'xl'}> <Container className={classes.layoutHeaderSection} size={'xl'}>
<Group position="apart"> <Group position="apart">

View File

@ -1,16 +1,17 @@
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { import {
ActionIcon, ActionIcon,
Alert,
Divider, Divider,
Drawer, Drawer,
LoadingOverlay, LoadingOverlay,
Space, Space,
Tooltip Tooltip
} from '@mantine/core'; } from '@mantine/core';
import { Badge, Group, Stack, Text } from '@mantine/core'; import { Group, Stack, Text } from '@mantine/core';
import { IconBellCheck, IconBellPlus, IconBookmark } from '@tabler/icons-react'; import { IconBellCheck, IconBellPlus } from '@tabler/icons-react';
import { IconMacro } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { api } from '../../App'; import { api } from '../../App';
@ -79,6 +80,11 @@ export function NotificationDrawer({
<Stack spacing="xs"> <Stack spacing="xs">
<Divider /> <Divider />
<LoadingOverlay visible={notificationQuery.isFetching} /> <LoadingOverlay visible={notificationQuery.isFetching} />
{notificationQuery.data?.results?.length == 0 && (
<Alert color="green">
<Text size="sm">{t`You have no unread notifications.`}</Text>
</Alert>
)}
{notificationQuery.data?.results.map((notification: any) => ( {notificationQuery.data?.results.map((notification: any) => (
<Group position="apart"> <Group position="apart">
<Stack spacing="3"> <Stack spacing="3">

View File

@ -1,6 +1,7 @@
import { Group, Paper, Space, Stack, Text } from '@mantine/core'; import { Group, Paper, Space, Stack, Text } from '@mantine/core';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { StylishText } from '../items/StylishText';
import { Breadcrumb, BreadcrumbList } from './BreadcrumbList'; import { Breadcrumb, BreadcrumbList } from './BreadcrumbList';
/** /**
@ -33,7 +34,7 @@ export function PageDetail({
<Stack spacing="xs"> <Stack spacing="xs">
<Group position="apart"> <Group position="apart">
<Group position="left"> <Group position="left">
<Text size="xl">{title}</Text> <StylishText size="xl">{title}</StylishText>
{subtitle && <Text size="lg">{subtitle}</Text>} {subtitle && <Text size="lg">{subtitle}</Text>}
</Group> </Group>
<Space /> <Space />

View File

@ -81,9 +81,7 @@ export function AttachmentTable({
pk: number; pk: number;
model: string; model: string;
}): ReactNode { }): ReactNode {
const tableId = useId(); const { tableKey, refreshTable } = useTableRefresh(`${model}-attachments`);
const { refreshId, refreshTable } = useTableRefresh();
const tableColumns = useMemo(() => attachmentTableColumns(), []); const tableColumns = useMemo(() => attachmentTableColumns(), []);
@ -224,14 +222,16 @@ export function AttachmentTable({
<Stack spacing="xs"> <Stack spacing="xs">
<InvenTreeTable <InvenTreeTable
url={url} url={url}
tableKey={tableId} tableKey={tableKey}
refreshId={refreshId}
params={{
[model]: pk
}}
customActionGroups={customActionGroups}
columns={tableColumns} columns={tableColumns}
rowActions={allowEdit && allowDelete ? rowActions : undefined} props={{
enableSelection: true,
customActionGroups: customActionGroups,
rowActions: allowEdit && allowDelete ? rowActions : undefined,
params: {
[model]: pk
}
}}
/> />
{allowEdit && ( {allowEdit && (
<Dropzone onDrop={uploadFiles}> <Dropzone onDrop={uploadFiles}>

View File

@ -1,6 +1,7 @@
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { ActionIcon, Indicator, Space, Stack, Tooltip } from '@mantine/core'; import { ActionIcon, Indicator, Space, Stack, Tooltip } from '@mantine/core';
import { Group } from '@mantine/core'; import { Group } from '@mantine/core';
import { useLocalStorage } from '@mantine/hooks';
import { IconFilter, IconRefresh } from '@tabler/icons-react'; import { IconFilter, IconRefresh } from '@tabler/icons-react';
import { IconBarcode, IconPrinter } from '@tabler/icons-react'; import { IconBarcode, IconPrinter } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
@ -18,96 +19,33 @@ import { FilterSelectModal } from './FilterSelectModal';
import { RowAction, RowActions } from './RowActions'; import { RowAction, RowActions } from './RowActions';
import { TableSearchInput } from './Search'; import { TableSearchInput } from './Search';
/* const defaultPageSize: number = 25;
* Load list of hidden columns from local storage.
* Returns a list of column names which are "hidden" for the current table
*/
function loadHiddenColumns(tableKey: string) {
return JSON.parse(
localStorage.getItem(`inventree-hidden-table-columns-${tableKey}`) || '[]'
);
}
/** /**
* Write list of hidden columns to local storage * Set of optional properties which can be passed to an InvenTreeTable component
* @param tableKey : string - unique key for the table
* @param columns : string[] - list of column names
*/
function saveHiddenColumns(tableKey: string, columns: any[]) {
localStorage.setItem(
`inventree-hidden-table-columns-${tableKey}`,
JSON.stringify(columns)
);
}
/**
* Loads the list of active filters from local storage
* @param tableKey : string - unique key for the table
* @param filterList : TableFilter[] - list of available filters
* @returns a map of active filters for the current table, {name: value}
*/
function loadActiveFilters(tableKey: string, filterList: TableFilter[]) {
let active = JSON.parse(
localStorage.getItem(`inventree-active-table-filters-${tableKey}`) || '{}'
);
// We expect that the active filter list is a map of {name: value}
// Return *only* those filters which are in the filter list
let x = filterList
.filter((f) => f.name in active)
.map((f) => ({
...f,
value: active[f.name]
}));
return x;
}
/**
* Write the list of active filters to local storage
* @param tableKey : string - unique key for the table
* @param filters : any - map of active filters, {name: value}
*/
function saveActiveFilters(tableKey: string, filters: TableFilter[]) {
let active = Object.fromEntries(filters.map((flt) => [flt.name, flt.value]));
localStorage.setItem(
`inventree-active-table-filters-${tableKey}`,
JSON.stringify(active)
);
}
/**
* Table Component which extends DataTable with custom InvenTree functionality
* *
* TODO: Refactor table props into a single type * @param url : string - The API endpoint to query
* @param params : any - Base query parameters
* @param tableKey : string - Unique key for the table (used for local storage)
* @param refreshId : string - Unique ID for the table (used to trigger a refresh)
* @param defaultSortColumn : string - Default column to sort by
* @param noRecordsText : string - Text to display when no records are found
* @param enableDownload : boolean - Enable download actions
* @param enableFilters : boolean - Enable filter actions
* @param enableSelection : boolean - Enable row selection
* @param enableSearch : boolean - Enable search actions
* @param enablePagination : boolean - Enable pagination
* @param enableRefresh : boolean - Enable refresh actions
* @param pageSize : number - Number of records per page
* @param barcodeActions : any[] - List of barcode actions
* @param customFilters : TableFilter[] - List of custom filters
* @param customActionGroups : any[] - List of custom action groups
* @param printingActions : any[] - List of printing actions
* @param rowActions : (record: any) => RowAction[] - Callback function to generate row actions
* @param onRowClick : (record: any, index: number, event: any) => void - Callback function when a row is clicked
*/ */
export function InvenTreeTable({ export type InvenTreeTableProps = {
url, params?: any;
params,
columns,
enableDownload = false,
enableFilters = true,
enablePagination = true,
enableRefresh = true,
enableSearch = true,
enableSelection = false,
pageSize = 25,
tableKey = '',
defaultSortColumn = '',
noRecordsText = t`No records found`,
printingActions = [],
barcodeActions = [],
customActionGroups = [],
customFilters = [],
rowActions,
onRowClick,
refreshId
}: {
url: string;
params: any;
columns: TableColumn[];
tableKey: string;
defaultSortColumn?: string; defaultSortColumn?: string;
noRecordsText?: string; noRecordsText?: string;
enableDownload?: boolean; enableDownload?: boolean;
@ -117,23 +55,79 @@ export function InvenTreeTable({
enablePagination?: boolean; enablePagination?: boolean;
enableRefresh?: boolean; enableRefresh?: boolean;
pageSize?: number; pageSize?: number;
printingActions?: any[];
barcodeActions?: any[]; barcodeActions?: any[];
customActionGroups?: any[];
customFilters?: TableFilter[]; customFilters?: TableFilter[];
customActionGroups?: any[];
printingActions?: any[];
rowActions?: (record: any) => RowAction[]; rowActions?: (record: any) => RowAction[];
onRowClick?: (record: any, index: number, event: any) => void; onRowClick?: (record: any, index: number, event: any) => void;
refreshId?: string; };
/**
* Default table properties (used if not specified)
*/
const defaultInvenTreeTableProps: InvenTreeTableProps = {
params: {},
noRecordsText: t`No records found`,
enableDownload: false,
enableFilters: true,
enablePagination: true,
enableRefresh: true,
enableSearch: true,
enableSelection: false,
pageSize: defaultPageSize,
defaultSortColumn: '',
printingActions: [],
barcodeActions: [],
customFilters: [],
customActionGroups: [],
rowActions: (record: any) => [],
onRowClick: (record: any, index: number, event: any) => {}
};
/**
* Table Component which extends DataTable with custom InvenTree functionality
*/
export function InvenTreeTable({
url,
tableKey,
columns,
props
}: {
url: string;
tableKey: string;
columns: TableColumn[];
props: InvenTreeTableProps;
}) { }) {
// Use the first part of the table key as the table name
const tableName: string = useMemo(() => {
return tableKey.split('-')[0];
}, []);
// Build table properties based on provided props (and default props)
const tableProps: InvenTreeTableProps = useMemo(() => {
return {
...defaultInvenTreeTableProps,
...props
};
}, [props]);
// Check if any columns are switchable (can be hidden) // Check if any columns are switchable (can be hidden)
const hasSwitchableColumns = columns.some( const hasSwitchableColumns = columns.some(
(col: TableColumn) => col.switchable (col: TableColumn) => col.switchable
); );
// Manage state for switchable columns (initially load from local storage) // A list of hidden columns, saved to local storage
let [hiddenColumns, setHiddenColumns] = useState(() => const [hiddenColumns, setHiddenColumns] = useLocalStorage<string[]>({
loadHiddenColumns(tableKey) key: `inventree-hidden-table-columns-${tableName}`,
); defaultValue: []
});
// Active filters (saved to local storage)
const [activeFilters, setActiveFilters] = useLocalStorage<any[]>({
key: `inventree-active-table-filters-${tableName}`,
defaultValue: []
});
// Data selection // Data selection
const [selectedRecords, setSelectedRecords] = useState<any[]>([]); const [selectedRecords, setSelectedRecords] = useState<any[]>([]);
@ -158,7 +152,7 @@ export function InvenTreeTable({
}); });
// If row actions are available, add a column for them // If row actions are available, add a column for them
if (rowActions) { if (tableProps.rowActions) {
cols.push({ cols.push({
accessor: 'actions', accessor: 'actions',
title: '', title: '',
@ -168,7 +162,7 @@ export function InvenTreeTable({
render: function (record: any) { render: function (record: any) {
return ( return (
<RowActions <RowActions
actions={rowActions(record)} actions={tableProps.rowActions?.(record) ?? []}
disabled={selectedRecords.length > 0} disabled={selectedRecords.length > 0}
/> />
); );
@ -177,7 +171,13 @@ export function InvenTreeTable({
} }
return cols; return cols;
}, [columns, hiddenColumns, rowActions, enableSelection, selectedRecords]); }, [
columns,
hiddenColumns,
tableProps.rowActions,
tableProps.enableSelection,
selectedRecords
]);
// Callback when column visibility is toggled // Callback when column visibility is toggled
function toggleColumn(columnName: string) { function toggleColumn(columnName: string) {
@ -189,20 +189,11 @@ export function InvenTreeTable({
newColumns[colIdx].hidden = !newColumns[colIdx].hidden; newColumns[colIdx].hidden = !newColumns[colIdx].hidden;
} }
let hiddenColumnNames = newColumns setHiddenColumns(
.filter((col) => col.hidden) newColumns.filter((col) => col.hidden).map((col) => col.accessor)
.map((col) => col.accessor); );
// Save list of hidden columns to local storage
saveHiddenColumns(tableKey, hiddenColumnNames);
// Refresh state
setHiddenColumns(loadHiddenColumns(tableKey));
} }
// Check if custom filtering is enabled for this table
const hasCustomFilters = enableFilters && customFilters.length > 0;
// Filter selection open state // Filter selection open state
const [filterSelectOpen, setFilterSelectOpen] = useState<boolean>(false); const [filterSelectOpen, setFilterSelectOpen] = useState<boolean>(false);
@ -212,11 +203,6 @@ export function InvenTreeTable({
// Filter list visibility // Filter list visibility
const [filtersVisible, setFiltersVisible] = useState<boolean>(false); const [filtersVisible, setFiltersVisible] = useState<boolean>(false);
// Map of currently active filters, {name: value}
const [activeFilters, setActiveFilters] = useState(() =>
loadActiveFilters(tableKey, customFilters)
);
/* /*
* Callback for the "add filter" button. * Callback for the "add filter" button.
* Launches a modal dialog to add a new filter * Launches a modal dialog to add a new filter
@ -224,7 +210,7 @@ export function InvenTreeTable({
function onFilterAdd(name: string, value: string) { function onFilterAdd(name: string, value: string) {
let filters = [...activeFilters]; let filters = [...activeFilters];
let newFilter = customFilters.find((flt) => flt.name == name); let newFilter = tableProps.customFilters?.find((flt) => flt.name == name);
if (newFilter) { if (newFilter) {
filters.push({ filters.push({
@ -232,7 +218,6 @@ export function InvenTreeTable({
value: value value: value
}); });
saveActiveFilters(tableKey, filters);
setActiveFilters(filters); setActiveFilters(filters);
} }
} }
@ -242,7 +227,7 @@ export function InvenTreeTable({
*/ */
function onFilterRemove(filterName: string) { function onFilterRemove(filterName: string) {
let filters = activeFilters.filter((flt) => flt.name != filterName); let filters = activeFilters.filter((flt) => flt.name != filterName);
saveActiveFilters(tableKey, filters);
setActiveFilters(filters); setActiveFilters(filters);
} }
@ -250,7 +235,6 @@ export function InvenTreeTable({
* Callback function when all custom filters are removed from the table * Callback function when all custom filters are removed from the table
*/ */
function onFilterClearAll() { function onFilterClearAll() {
saveActiveFilters(tableKey, []);
setActiveFilters([]); setActiveFilters([]);
} }
@ -266,7 +250,9 @@ export function InvenTreeTable({
* Construct query filters for the current table * Construct query filters for the current table
*/ */
function getTableFilters(paginate: boolean = false) { function getTableFilters(paginate: boolean = false) {
let queryParams = { ...params }; let queryParams = {
...tableProps.params
};
// Add custom filters // Add custom filters
activeFilters.forEach((flt) => (queryParams[flt.name] = flt.value)); activeFilters.forEach((flt) => (queryParams[flt.name] = flt.value));
@ -277,7 +263,8 @@ export function InvenTreeTable({
} }
// Pagination // Pagination
if (enablePagination && paginate) { if (tableProps.enablePagination && paginate) {
let pageSize = tableProps.pageSize ?? defaultPageSize;
queryParams.limit = pageSize; queryParams.limit = pageSize;
queryParams.offset = (page - 1) * pageSize; queryParams.offset = (page - 1) * pageSize;
} }
@ -315,7 +302,7 @@ export function InvenTreeTable({
// Data Sorting // Data Sorting
const [sortStatus, setSortStatus] = useState<DataTableSortStatus>({ const [sortStatus, setSortStatus] = useState<DataTableSortStatus>({
columnAccessor: defaultSortColumn, columnAccessor: tableProps.defaultSortColumn ?? '',
direction: 'asc' direction: 'asc'
}); });
@ -335,8 +322,9 @@ export function InvenTreeTable({
} }
// Missing records text (based on server response) // Missing records text (based on server response)
const [missingRecordsText, setMissingRecordsText] = const [missingRecordsText, setMissingRecordsText] = useState<string>(
useState<string>(noRecordsText); tableProps.noRecordsText ?? t`No records found`
);
const handleSortStatusChange = (status: DataTableSortStatus) => { const handleSortStatusChange = (status: DataTableSortStatus) => {
setPage(1); setPage(1);
@ -355,7 +343,9 @@ export function InvenTreeTable({
.then(function (response) { .then(function (response) {
switch (response.status) { switch (response.status) {
case 200: case 200:
setMissingRecordsText(noRecordsText); setMissingRecordsText(
tableProps.noRecordsText ?? t`No records found`
);
return response.data; return response.data;
case 400: case 400:
setMissingRecordsText(t`Bad request`); setMissingRecordsText(t`Bad request`);
@ -386,7 +376,7 @@ export function InvenTreeTable({
const { data, isError, isFetching, isLoading, refetch } = useQuery( const { data, isError, isFetching, isLoading, refetch } = useQuery(
[ [
`table-${tableKey}`, `table-${tableName}`,
sortStatus.columnAccessor, sortStatus.columnAccessor,
sortStatus.direction, sortStatus.direction,
page, page,
@ -407,15 +397,13 @@ export function InvenTreeTable({
* Implement this using the custom useTableRefresh hook * Implement this using the custom useTableRefresh hook
*/ */
useEffect(() => { useEffect(() => {
if (refreshId) {
refetch(); refetch();
} }, [tableKey, props.params]);
}, [refreshId]);
return ( return (
<> <>
<FilterSelectModal <FilterSelectModal
availableFilters={customFilters} availableFilters={tableProps.customFilters ?? []}
activeFilters={activeFilters} activeFilters={activeFilters}
opened={filterSelectOpen} opened={filterSelectOpen}
onCreateFilter={onFilterAdd} onCreateFilter={onFilterAdd}
@ -424,35 +412,37 @@ export function InvenTreeTable({
<Stack> <Stack>
<Group position="apart"> <Group position="apart">
<Group position="left" spacing={5}> <Group position="left" spacing={5}>
{customActionGroups.map((group: any, idx: number) => group)} {tableProps.customActionGroups?.map(
{barcodeActions.length > 0 && ( (group: any, idx: number) => group
)}
{(tableProps.barcodeActions?.length ?? 0 > 0) && (
<ButtonMenu <ButtonMenu
icon={<IconBarcode />} icon={<IconBarcode />}
label={t`Barcode actions`} label={t`Barcode actions`}
tooltip={t`Barcode actions`} tooltip={t`Barcode actions`}
actions={barcodeActions} actions={tableProps.barcodeActions ?? []}
/> />
)} )}
{printingActions.length > 0 && ( {(tableProps.printingActions?.length ?? 0 > 0) && (
<ButtonMenu <ButtonMenu
icon={<IconPrinter />} icon={<IconPrinter />}
label={t`Print actions`} label={t`Print actions`}
tooltip={t`Print actions`} tooltip={t`Print actions`}
actions={printingActions} actions={tableProps.printingActions ?? []}
/> />
)} )}
{enableDownload && ( {tableProps.enableDownload && (
<DownloadAction downloadCallback={downloadData} /> <DownloadAction downloadCallback={downloadData} />
)} )}
</Group> </Group>
<Space /> <Space />
<Group position="right" spacing={5}> <Group position="right" spacing={5}>
{enableSearch && ( {tableProps.enableSearch && (
<TableSearchInput <TableSearchInput
searchCallback={(term: string) => setSearchTerm(term)} searchCallback={(term: string) => setSearchTerm(term)}
/> />
)} )}
{enableRefresh && ( {tableProps.enableRefresh && (
<ActionIcon> <ActionIcon>
<Tooltip label={t`Refresh data`}> <Tooltip label={t`Refresh data`}>
<IconRefresh onClick={() => refetch()} /> <IconRefresh onClick={() => refetch()} />
@ -465,7 +455,8 @@ export function InvenTreeTable({
onToggleColumn={toggleColumn} onToggleColumn={toggleColumn}
/> />
)} )}
{hasCustomFilters && ( {tableProps.enableFilters &&
(tableProps.customFilters?.length ?? 0 > 0) && (
<Indicator <Indicator
size="xs" size="xs"
label={activeFilters.length} label={activeFilters.length}
@ -498,20 +489,22 @@ export function InvenTreeTable({
idAccessor={'pk'} idAccessor={'pk'}
minHeight={200} minHeight={200}
totalRecords={data?.count ?? data?.length ?? 0} totalRecords={data?.count ?? data?.length ?? 0}
recordsPerPage={pageSize} recordsPerPage={tableProps.pageSize ?? defaultPageSize}
page={page} page={page}
onPageChange={setPage} onPageChange={setPage}
sortStatus={sortStatus} sortStatus={sortStatus}
onSortStatusChange={handleSortStatusChange} onSortStatusChange={handleSortStatusChange}
selectedRecords={enableSelection ? selectedRecords : undefined} selectedRecords={
tableProps.enableSelection ? selectedRecords : undefined
}
onSelectedRecordsChange={ onSelectedRecordsChange={
enableSelection ? onSelectedRecordsChange : undefined tableProps.enableSelection ? onSelectedRecordsChange : undefined
} }
fetching={isFetching} fetching={isFetching}
noRecordsText={missingRecordsText} noRecordsText={missingRecordsText}
records={data?.results ?? data ?? []} records={data?.results ?? data ?? []}
columns={dataColumns} columns={dataColumns}
onRowClick={onRowClick} onRowClick={tableProps.onRowClick}
/> />
</Stack> </Stack>
</> </>

View File

@ -1,8 +1,9 @@
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Progress } from '@mantine/core'; import { Progress, Text } from '@mantine/core';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTableRefresh } from '../../../hooks/TableRefresh';
import { ThumbnailHoverCard } from '../../items/Thumbnail'; import { ThumbnailHoverCard } from '../../items/Thumbnail';
import { TableColumn } from '../Column'; import { TableColumn } from '../Column';
import { TableFilter } from '../Filter'; import { TableFilter } from '../Filter';
@ -27,11 +28,12 @@ function buildOrderTableColumns(): TableColumn[] {
let part = record.part_detail; let part = record.part_detail;
return ( return (
part && ( part && (
<ThumbnailHoverCard <Text>{part.full_name}</Text>
src={part.thumbnail || part.image} // <ThumbnailHoverCard
text={part.full_name} // src={part.thumbnail || part.image}
link="" // text={part.full_name}
/> // link=""
// />
) )
); );
} }
@ -127,35 +129,31 @@ function buildOrderTableFilters(): TableFilter[] {
return []; return [];
} }
function buildOrderTableParams(params: any): any {
return {
...params,
part_detail: true
};
}
/* /*
* Construct a table of build orders, according to the provided parameters * Construct a table of build orders, according to the provided parameters
*/ */
export function BuildOrderTable({ params = {} }: { params?: any }) { export function BuildOrderTable({ params = {} }: { params?: any }) {
// Add required query parameters
const tableParams = useMemo(() => buildOrderTableParams(params), [params]);
const tableColumns = useMemo(() => buildOrderTableColumns(), []); const tableColumns = useMemo(() => buildOrderTableColumns(), []);
const tableFilters = useMemo(() => buildOrderTableFilters(), []); const tableFilters = useMemo(() => buildOrderTableFilters(), []);
const navigate = useNavigate(); const navigate = useNavigate();
tableParams.part_detail = true; const { tableKey, refreshTable } = useTableRefresh('buildorder');
return ( return (
<InvenTreeTable <InvenTreeTable
url="build/" url="build/"
enableDownload tableKey={tableKey}
tableKey="build-order-table"
params={tableParams}
columns={tableColumns} columns={tableColumns}
customFilters={tableFilters} props={{
onRowClick={(row) => navigate(`/build/${row.pk}`)} enableDownload: true,
params: {
...params,
part_detail: true
},
customFilters: tableFilters,
onRowClick: (row) => navigate(`/build/${row.pk}`)
}}
/> />
); );
} }

View File

@ -7,12 +7,10 @@ import { RowAction } from '../RowActions';
export function NotificationTable({ export function NotificationTable({
params, params,
refreshId,
tableKey, tableKey,
actions actions
}: { }: {
params: any; params: any;
refreshId: string;
tableKey: string; tableKey: string;
actions: (record: any) => RowAction[]; actions: (record: any) => RowAction[];
}) { }) {
@ -43,10 +41,12 @@ export function NotificationTable({
<InvenTreeTable <InvenTreeTable
url="/notifications/" url="/notifications/"
tableKey={tableKey} tableKey={tableKey}
refreshId={refreshId}
params={params}
rowActions={actions}
columns={columns} columns={columns}
props={{
rowActions: actions,
enableSelection: true,
params: params
}}
/> />
); );
} }

View File

@ -0,0 +1,63 @@
import { t } from '@lingui/macro';
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTableRefresh } from '../../../hooks/TableRefresh';
import { TableColumn } from '../Column';
import { InvenTreeTable } from '../InvenTreeTable';
/**
* PartCategoryTable - Displays a table of part categories
*/
export function PartCategoryTable({ params = {} }: { params?: any }) {
const navigate = useNavigate();
const { tableKey, refreshTable } = useTableRefresh('partcategory');
const tableColumns: TableColumn[] = useMemo(() => {
return [
{
accessor: 'name',
title: t`Name`,
sortable: true,
switchable: false
},
{
accessor: 'description',
title: t`Description`,
sortable: false,
switchable: true
},
{
accessor: 'pathstring',
title: t`Path`,
sortable: false,
switchable: true
},
{
accessor: 'part_count',
title: t`Parts`,
sortable: true,
switchable: true
}
];
}, []);
return (
<InvenTreeTable
url="part/category/"
tableKey={tableKey}
columns={tableColumns}
props={{
enableDownload: true,
enableSelection: true,
params: {
...params
},
onRowClick: (record, index, event) => {
navigate(`/part/category/${record.pk}`);
}
}}
/>
);
}

View File

@ -1,16 +1,16 @@
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Text } from '@mantine/core'; import { Text } from '@mantine/core';
import { IconEdit, IconTrash } from '@tabler/icons-react';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { editPart } from '../../../functions/forms/PartForms'; import { editPart } from '../../../functions/forms/PartForms';
import { notYetImplemented } from '../../../functions/notifications'; import { notYetImplemented } from '../../../functions/notifications';
import { shortenString } from '../../../functions/tables'; import { shortenString } from '../../../functions/tables';
import { useTableRefresh } from '../../../hooks/TableRefresh';
import { ThumbnailHoverCard } from '../../items/Thumbnail'; import { ThumbnailHoverCard } from '../../items/Thumbnail';
import { TableColumn } from '../Column'; import { TableColumn } from '../Column';
import { TableFilter } from '../Filter'; import { TableFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable'; import { InvenTreeTable, InvenTreeTableProps } from '../InvenTreeTable';
import { RowAction } from '../RowActions'; import { RowAction } from '../RowActions';
/** /**
@ -26,11 +26,12 @@ function partTableColumns(): TableColumn[] {
render: function (record: any) { render: function (record: any) {
// TODO - Link to the part detail page // TODO - Link to the part detail page
return ( return (
<ThumbnailHoverCard <Text>{record.full_name}</Text>
src={record.thumbnail || record.image} // <ThumbnailHoverCard
text={record.name} // src={record.thumbnail || record.image}
link="" // text={record.name}
/> // link=""
// />
); );
} }
}, },
@ -178,23 +179,17 @@ function partTableFilters(): TableFilter[] {
]; ];
} }
function partTableParams(params: any): any {
return {
...params,
category_detail: true
};
}
/** /**
* PartListTable - Displays a list of parts, based on the provided parameters * PartListTable - Displays a list of parts, based on the provided parameters
* @param {Object} params - The query parameters to pass to the API * @param {Object} params - The query parameters to pass to the API
* @returns * @returns
*/ */
export function PartListTable({ params = {} }: { params?: any }) { export function PartListTable({ props }: { props: InvenTreeTableProps }) {
let tableParams = useMemo(() => partTableParams(params), [params]);
let tableColumns = useMemo(() => partTableColumns(), []); let tableColumns = useMemo(() => partTableColumns(), []);
let tableFilters = useMemo(() => partTableFilters(), []); let tableFilters = useMemo(() => partTableFilters(), []);
const { tableKey, refreshTable } = useTableRefresh('part');
// Callback function for generating set of row actions // Callback function for generating set of row actions
function partTableRowActions(record: any): RowAction[] { function partTableRowActions(record: any): RowAction[] {
let actions: RowAction[] = []; let actions: RowAction[] = [];
@ -227,16 +222,18 @@ export function PartListTable({ params = {} }: { params?: any }) {
return ( return (
<InvenTreeTable <InvenTreeTable
url="part/" url="part/"
enableDownload tableKey={tableKey}
tableKey="part-table"
printingActions={[
<Text onClick={notYetImplemented}>Hello</Text>,
<Text onClick={notYetImplemented}>World</Text>
]}
params={tableParams}
columns={tableColumns} columns={tableColumns}
customFilters={tableFilters} props={{
rowActions={partTableRowActions} ...props,
enableDownload: true,
customFilters: tableFilters,
rowActions: partTableRowActions,
params: {
...props.params,
category_detail: true
}
}}
/> />
); );
} }

View File

@ -11,7 +11,7 @@ import { TableColumn } from '../Column';
import { InvenTreeTable } from '../InvenTreeTable'; import { InvenTreeTable } from '../InvenTreeTable';
export function RelatedPartTable({ partId }: { partId: number }): ReactNode { export function RelatedPartTable({ partId }: { partId: number }): ReactNode {
const { refreshId, refreshTable } = useTableRefresh(); const { tableKey, refreshTable } = useTableRefresh('relatedparts');
const navigate = useNavigate(); const navigate = useNavigate();
@ -116,14 +116,16 @@ export function RelatedPartTable({ partId }: { partId: number }): ReactNode {
return ( return (
<InvenTreeTable <InvenTreeTable
url="/part/related/" url="/part/related/"
tableKey="related-part-table" tableKey={tableKey}
refreshId={refreshId}
params={{
part: partId
}}
rowActions={rowActions}
columns={tableColumns} columns={tableColumns}
customActionGroups={customActions} props={{
params: {
part: partId,
catefory_detail: true
},
rowActions: rowActions,
customActionGroups: customActions
}}
/> />
); );
} }

View File

@ -1,10 +1,9 @@
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Group } from '@mantine/core'; import { Text } from '@mantine/core';
import { IconEdit, IconTrash } from '@tabler/icons-react'; import { useMemo } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { notYetImplemented } from '../../../functions/notifications'; import { notYetImplemented } from '../../../functions/notifications';
import { ActionButton } from '../../items/ActionButton'; import { useTableRefresh } from '../../../hooks/TableRefresh';
import { ThumbnailHoverCard } from '../../items/Thumbnail'; import { ThumbnailHoverCard } from '../../items/Thumbnail';
import { TableColumn } from '../Column'; import { TableColumn } from '../Column';
import { TableFilter } from '../Filter'; import { TableFilter } from '../Filter';
@ -23,11 +22,12 @@ function stockItemTableColumns(): TableColumn[] {
render: function (record: any) { render: function (record: any) {
let part = record.part_detail; let part = record.part_detail;
return ( return (
<ThumbnailHoverCard <Text>{part.full_name}</Text>
src={part.thumbnail || part.image} // <ThumbnailHoverCard
text={part.name} // src={part.thumbnail || part.image}
link="" // text={part.name}
/> // link=""
// />
); );
} }
}, },
@ -102,17 +102,11 @@ function stockItemTableFilters(): TableFilter[] {
* Load a table of stock items * Load a table of stock items
*/ */
export function StockItemTable({ params = {} }: { params?: any }) { export function StockItemTable({ params = {} }: { params?: any }) {
let tableParams = useMemo(() => {
return {
part_detail: true,
location_detail: true,
...params
};
}, [params]);
let tableColumns = useMemo(() => stockItemTableColumns(), []); let tableColumns = useMemo(() => stockItemTableColumns(), []);
let tableFilters = useMemo(() => stockItemTableFilters(), []); let tableFilters = useMemo(() => stockItemTableFilters(), []);
const { tableKey, refreshTable } = useTableRefresh('stockitem');
function stockItemRowActions(record: any): RowAction[] { function stockItemRowActions(record: any): RowAction[] {
let actions: RowAction[] = []; let actions: RowAction[] = [];
@ -129,13 +123,19 @@ export function StockItemTable({ params = {} }: { params?: any }) {
return ( return (
<InvenTreeTable <InvenTreeTable
url="stock/" url="stock/"
tableKey="stock-table" tableKey={tableKey}
enableDownload
enableSelection
params={tableParams}
columns={tableColumns} columns={tableColumns}
customFilters={tableFilters} props={{
rowActions={stockItemRowActions} enableDownload: true,
enableSelection: true,
customFilters: tableFilters,
rowActions: stockItemRowActions,
params: {
...params,
part_detail: true,
location_detail: true
}
}}
/> />
); );
} }

View File

@ -5,21 +5,25 @@ import { useCallback, useState } from 'react';
* Custom hook for refreshing an InvenTreeTable externally * Custom hook for refreshing an InvenTreeTable externally
* Returns a unique ID for the table, which can be updated to trigger a refresh of the <table className=""></table> * Returns a unique ID for the table, which can be updated to trigger a refresh of the <table className=""></table>
* *
* @returns [refreshId, refreshTable] * @returns { tableKey, refreshTable }
* *
* To use this hook: * To use this hook:
* const [refreshId, refreshTable] = useTableRefresh(); * const { tableKey, refreshTable } = useTableRefresh();
* *
* Then, pass the refreshId to the InvenTreeTable component: * Then, pass the refreshId to the InvenTreeTable component:
* <InvenTreeTable refreshId={refreshId} ... /> * <InvenTreeTable tableKey={tableKey} ... />
*/ */
export function useTableRefresh() { export function useTableRefresh(tableName: string) {
const [refreshId, setRefreshId] = useState<string>(randomId()); const [tableKey, setTableKey] = useState<string>(generateTableName());
function generateTableName() {
return `${tableName}-${randomId()}`;
}
// Generate a new ID to refresh the table // Generate a new ID to refresh the table
const refreshTable = useCallback(function () { const refreshTable = useCallback(function () {
setRefreshId(randomId()); setTableKey(generateTableName());
}, []); }, []);
return { refreshId, refreshTable }; return { tableKey, refreshTable };
} }

View File

@ -1,20 +1,37 @@
import { Trans } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Group } from '@mantine/core'; import { Stack } from '@mantine/core';
import { IconPackages, IconSitemap } from '@tabler/icons-react';
import { useMemo } from 'react';
import { PlaceholderPill } from '../../components/items/Placeholder'; import { PlaceholderPanel } from '../../components/items/Placeholder';
import { StylishText } from '../../components/items/StylishText'; import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { StockItemTable } from '../../components/tables/stock/StockItemTable'; import { StockItemTable } from '../../components/tables/stock/StockItemTable';
export default function Stock() { export default function Stock() {
const categoryPanels: PanelType[] = useMemo(() => {
return [
{
name: 'stock-items',
label: t`Stock Items`,
icon: <IconPackages size="18" />,
content: <StockItemTable />
},
{
name: 'sublocations',
label: t`Sublocations`,
icon: <IconSitemap size="18" />,
content: <PlaceholderPanel />
}
];
}, []);
return ( return (
<> <>
<Group> <Stack>
<StylishText> <PageDetail title={t`Stock Items`} />
<Trans>Stock Items</Trans> <PanelGroup panels={categoryPanels} />
</StylishText> </Stack>
<PlaceholderPill />
</Group>
<StockItemTable />
</> </>
); );
} }

View File

@ -5,13 +5,14 @@ import { useMemo } from 'react';
import { api } from '../App'; import { api } from '../App';
import { StylishText } from '../components/items/StylishText'; import { StylishText } from '../components/items/StylishText';
import { PageDetail } from '../components/nav/PageDetail';
import { PanelGroup } from '../components/nav/PanelGroup'; import { PanelGroup } from '../components/nav/PanelGroup';
import { NotificationTable } from '../components/tables/notifications/NotificationsTable'; import { NotificationTable } from '../components/tables/notifications/NotificationsTable';
import { useTableRefresh } from '../hooks/TableRefresh'; import { useTableRefresh } from '../hooks/TableRefresh';
export default function NotificationsPage() { export default function NotificationsPage() {
const unreadRefresh = useTableRefresh(); const unreadRefresh = useTableRefresh('unreadnotifications');
const historyRefresh = useTableRefresh(); const historyRefresh = useTableRefresh('readnotifications');
const notificationPanels = useMemo(() => { const notificationPanels = useMemo(() => {
return [ return [
@ -22,8 +23,7 @@ export default function NotificationsPage() {
content: ( content: (
<NotificationTable <NotificationTable
params={{ read: false }} params={{ read: false }}
refreshId={unreadRefresh.refreshId} tableKey={unreadRefresh.tableKey}
tableKey="notifications-unread"
actions={(record) => [ actions={(record) => [
{ {
title: t`Mark as read`, title: t`Mark as read`,
@ -48,8 +48,7 @@ export default function NotificationsPage() {
content: ( content: (
<NotificationTable <NotificationTable
params={{ read: true }} params={{ read: true }}
refreshId={historyRefresh.refreshId} tableKey={historyRefresh.tableKey}
tableKey="notifications-history"
actions={(record) => [ actions={(record) => [
{ {
title: t`Mark as unread`, title: t`Mark as unread`,
@ -83,8 +82,8 @@ export default function NotificationsPage() {
return ( return (
<> <>
<Stack spacing="xs"> <Stack>
<StylishText>{t`Notifications`}</StylishText> <PageDetail title={t`Notifications`} />
<PanelGroup panels={notificationPanels} /> <PanelGroup panels={notificationPanels} />
</Stack> </Stack>
</> </>

View File

@ -12,7 +12,7 @@ import {
IconSitemap IconSitemap
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { api } from '../../App'; import { api } from '../../App';
@ -36,6 +36,10 @@ export default function BuildDetail() {
// Build data // Build data
const [build, setBuild] = useState<any>({}); const [build, setBuild] = useState<any>({});
useEffect(() => {
setBuild({});
}, [id]);
// Query hook for fetching build data // Query hook for fetching build data
const buildQuery = useQuery(['build', id ?? -1], async () => { const buildQuery = useQuery(['build', id ?? -1], async () => {
let url = `/build/${id}/`; let url = `/build/${id}/`;

View File

@ -0,0 +1,108 @@
import { t } from '@lingui/macro';
import { Stack, Text } from '@mantine/core';
import {
IconCategory,
IconListDetails,
IconSitemap
} from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { api } from '../../App';
import { PlaceholderPanel } from '../../components/items/Placeholder';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { PartCategoryTable } from '../../components/tables/part/PartCategoryTable';
import { PartListTable } from '../../components/tables/part/PartTable';
/**
* Detail view for a single PartCategory instance.
*
* Note: If no category ID is supplied, this acts as the top-level part category page
*/
export default function CategoryDetail({}: {}) {
const { id } = useParams();
const [category, setCategory] = useState<any>({});
useEffect(() => {
setCategory({});
}, [id]);
const categoryQuery = useQuery({
enabled: id != null && id != undefined,
queryKey: ['category', id],
queryFn: async () => {
return api
.get(`/part/category/${id}/`)
.then((response) => {
setCategory(response.data);
return response.data;
})
.catch((error) => {
console.error('Error fetching category data:', error);
});
}
});
const categoryPanels: PanelType[] = useMemo(
() => [
{
name: 'parts',
label: t`Parts`,
icon: <IconCategory size="18" />,
content: (
<PartListTable
props={{
params: {
category: category.pk ?? null
}
}}
/>
)
},
{
name: 'subcategories',
label: t`Subcategories`,
icon: <IconSitemap size="18" />,
content: (
<PartCategoryTable
params={{
parent: category.pk ?? null
}}
/>
)
},
{
name: 'parameters',
label: t`Parameters`,
icon: <IconListDetails size="18" />,
content: <PlaceholderPanel />
}
],
[category, id]
);
return (
<Stack spacing="xs">
<PageDetail
title={t`Part Category`}
detail={<Text>{category.name ?? 'Top level'}</Text>}
breadcrumbs={
id
? [
{ name: t`Parts`, url: '/part' },
{ name: '...', url: '' },
{
name: category.name ?? t`Top level`,
url: `/part/category/${category.pk}`
}
]
: []
}
/>
<PanelGroup panels={categoryPanels} />
</Stack>
);
}

View File

@ -25,15 +25,12 @@ import {
IconVersions IconVersions
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import React, { useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { api } from '../../App'; import { api } from '../../App';
import { import { PlaceholderPanel } from '../../components/items/Placeholder';
PlaceholderPanel,
PlaceholderPill
} from '../../components/items/Placeholder';
import { PageDetail } from '../../components/nav/PageDetail'; import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup'; import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { AttachmentTable } from '../../components/tables/AttachmentTable'; import { AttachmentTable } from '../../components/tables/AttachmentTable';
@ -45,12 +42,19 @@ import {
} from '../../components/widgets/MarkdownEditor'; } from '../../components/widgets/MarkdownEditor';
import { editPart } from '../../functions/forms/PartForms'; import { editPart } from '../../functions/forms/PartForms';
/**
* Detail view for a single Part instance
*/
export default function PartDetail() { export default function PartDetail() {
const { id } = useParams(); const { id } = useParams();
// Part data // Part data
const [part, setPart] = useState<any>({}); const [part, setPart] = useState<any>({});
useEffect(() => {
setPart({});
}, [id]);
// Part data panels (recalculate when part data changes) // Part data panels (recalculate when part data changes)
const partPanels: PanelType[] = useMemo(() => { const partPanels: PanelType[] = useMemo(() => {
return [ return [
@ -212,7 +216,7 @@ export default function PartDetail() {
breadcrumbs={[ breadcrumbs={[
{ name: t`Parts`, url: '/part' }, { name: t`Parts`, url: '/part' },
{ name: '...', url: '' }, { name: '...', url: '' },
{ name: part.full_name, url: `/part/${part.pk}` } { name: part.name, url: `/part/${part.pk}` }
]} ]}
actions={[ actions={[
<Button <Button

View File

@ -1,61 +0,0 @@
import { Trans, t } from '@lingui/macro';
import { Stack } from '@mantine/core';
import {
IconCategory,
IconListDetails,
IconSitemap
} from '@tabler/icons-react';
import { useMemo } from 'react';
import { PlaceholderPill } from '../../components/items/Placeholder';
import { StylishText } from '../../components/items/StylishText';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { PartListTable } from '../../components/tables/part/PartTable';
/**
* Part index page
*/
export default function PartIndex() {
const panels: PanelType[] = useMemo(() => {
return [
{
name: 'parts',
label: t`Parts`,
icon: <IconCategory size="18" />,
content: <PartListTable />
},
{
name: 'categories',
label: t`Categories`,
icon: <IconSitemap size="18" />,
content: <PlaceholderPill />
},
{
name: 'parameters',
label: t`Parameters`,
icon: <IconListDetails size="18" />,
content: <PlaceholderPill />
}
];
}, []);
return (
<>
<Stack>
<PageDetail
title={t`Parts`}
breadcrumbs={
[
// {
// name: t`Parts`,
// url: '/part',
// }
]
}
/>
<PanelGroup panels={panels} />
</Stack>
</>
);
}

View File

@ -11,7 +11,10 @@ export const Home = Loadable(lazy(() => import('./pages/Index/Home')));
export const Playground = Loadable( export const Playground = Loadable(
lazy(() => import('./pages/Index/Playground')) lazy(() => import('./pages/Index/Playground'))
); );
export const PartIndex = Loadable(lazy(() => import('./pages/part/PartIndex')));
export const CategoryDetail = Loadable(
lazy(() => import('./pages/part/CategoryDetail'))
);
export const PartDetail = Loadable( export const PartDetail = Loadable(
lazy(() => import('./pages/part/PartDetail')) lazy(() => import('./pages/part/PartDetail'))
); );
@ -87,7 +90,11 @@ export const router = createBrowserRouter(
}, },
{ {
path: 'part/', path: 'part/',
element: <PartIndex /> element: <CategoryDetail />
},
{
path: 'part/category/:id',
element: <CategoryDetail />
}, },
{ {
path: 'part/:id', path: 'part/:id',