2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-07-02 03:30:54 +00:00

[UI] Stored table state (#9902)

* Add useStoredTableState zustand

* Store table page size in the zustand state

* Store tablesorting data in zustand state

* Actually provide records...

* Transfer table names state

* Store hidden columns too
This commit is contained in:
Oliver
2025-06-30 08:13:04 +10:00
committed by GitHub
parent c6d53b8c57
commit f3072b804e
6 changed files with 180 additions and 218 deletions

View File

@ -52,19 +52,16 @@ export type TableState = {
hasSelectedRecords: boolean;
setSelectedRecords: (records: any[]) => void;
clearSelectedRecords: () => void;
hiddenColumns: string[] | null;
setHiddenColumns: (columns: string[]) => void;
searchTerm: string;
setSearchTerm: (term: string) => void;
recordCount: number;
setRecordCount: (count: number) => void;
page: number;
setPage: (page: number) => void;
pageSize: number;
setPageSize: (pageSize: number) => void;
storedDataLoaded: boolean;
records: any[];
setRecords: (records: any[]) => void;
updateRecord: (record: any) => void;
hiddenColumns: string[];
setHiddenColumns: (columns: string[]) => void;
idAccessor?: string;
};

View File

@ -8,6 +8,7 @@ import { useShallow } from 'zustand/react/shallow';
import { api } from '../App';
import { useLocalState } from '../states/LocalState';
import { useServerApiState } from '../states/ServerApiState';
import { useStoredTableState } from '../states/StoredTableState';
import { fetchGlobalStates } from '../states/states';
export const defaultLocale = 'en';
@ -117,7 +118,7 @@ export function LanguageContext({
fetchGlobalStates();
// Clear out cached table column names
useLocalState.getState().clearTableColumnNames();
useStoredTableState.getState().clearTableColumnNames();
})
/* istanbul ignore next */
.catch((err) => {

View File

@ -1,4 +1,4 @@
import { randomId, useLocalStorage } from '@mantine/hooks';
import { randomId } from '@mantine/hooks';
import { useCallback, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
@ -6,12 +6,6 @@ import type { FilterSetState } from '@lib/types/Filters';
import type { TableState } from '@lib/types/Tables';
import { useFilterSet } from './UseFilterSet';
// Interface for the stored table data in local storage
interface StoredTableData {
pageSize: number;
hiddenColumns: string[] | null;
}
/**
* A custom hook for managing the state of an <InvenTreeTable> component.
*
@ -51,6 +45,9 @@ export function useTable(tableName: string, idAccessor = 'pk'): TableState {
[expandedRecords]
);
// Array of columns which are hidden
const [hiddenColumns, setHiddenColumns] = useState<string[]>([]);
// Array of selected records
const [selectedRecords, setSelectedRecords] = useState<any[]>([]);
@ -73,70 +70,6 @@ export function useTable(tableName: string, idAccessor = 'pk'): TableState {
const [page, setPage] = useState<number>(1);
const [storedDataLoaded, setStoredDataLoaded] = useState<boolean>(false);
const [tableData, setTableData] = useState<StoredTableData>({
pageSize: 25,
hiddenColumns: null
});
const [storedTableData, setStoredTableData] =
useLocalStorage<StoredTableData>({
key: `inventree-table-data-${tableName}`,
getInitialValueInEffect: true,
// sync: false, // Do not use this option - see below
defaultValue: {
pageSize: 25,
hiddenColumns: null
},
deserialize: (value: any) => {
const tableData =
value === undefined
? {
pageSize: 25,
hiddenColumns: null
}
: JSON.parse(value);
if (!storedDataLoaded) {
setStoredDataLoaded((wasLoaded: boolean) => {
if (!wasLoaded) {
// First load of stored table data - copy to local state
// We only do this on first load, to avoid live syncing between tabs
// Note: The 'sync: false' option is not used, it does not perform as expected
setTableData(tableData);
}
return true;
});
}
return tableData;
}
});
const setPageSize = useCallback((size: number) => {
setStoredTableData((prev) => ({
...prev,
pageSize: size
}));
setTableData((prev) => ({
...prev,
pageSize: size
}));
}, []);
const setHiddenColumns = useCallback((columns: string[] | null) => {
setStoredTableData((prev) => {
return {
...prev,
hiddenColumns: columns
};
});
setTableData((prev) => ({
...prev,
hiddenColumns: columns
}));
}, []);
// Search term
const [searchTerm, setSearchTerm] = useState<string>('');
@ -186,17 +119,14 @@ export function useTable(tableName: string, idAccessor = 'pk'): TableState {
setSelectedRecords,
clearSelectedRecords,
hasSelectedRecords,
pageSize: tableData.pageSize,
hiddenColumns: tableData.hiddenColumns,
setHiddenColumns,
searchTerm,
setSearchTerm,
recordCount,
setRecordCount,
hiddenColumns,
setHiddenColumns,
page,
setPage,
setPageSize,
storedDataLoaded,
records,
setRecords,
updateRecord,

View File

@ -1,4 +1,3 @@
import type { DataTableSortStatus } from 'mantine-datatable';
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
@ -35,17 +34,6 @@ interface LocalStateProps {
// panels
lastUsedPanels: Record<string, string>;
setLastUsedPanel: (panelKey: string) => (value: string) => void;
tableColumnNames: Record<string, Record<string, string>>;
getTableColumnNames: (tableKey: string) => Record<string, string>;
setTableColumnNames: (
tableKey: string
) => (names: Record<string, string>) => void;
tableSorting: Record<string, any>;
getTableSorting: (tableKey: string) => DataTableSortStatus;
setTableSorting: (
tableKey: string
) => (sorting: DataTableSortStatus<any>) => void;
clearTableColumnNames: () => void;
detailDrawerStack: number;
addDetailDrawer: (value: number | false) => void;
navigationOpen: boolean;
@ -140,36 +128,7 @@ export const useLocalState = create<LocalStateProps>()(
});
}
},
// tables
tableColumnNames: {},
getTableColumnNames: (tableKey) => {
return get().tableColumnNames[tableKey] || null;
},
setTableColumnNames: (tableKey) => (names) => {
// Update the table column names for the given table
set({
tableColumnNames: {
...get().tableColumnNames,
[tableKey]: names
}
});
},
clearTableColumnNames: () => {
set({ tableColumnNames: {} });
},
tableSorting: {},
getTableSorting: (tableKey) => {
return get().tableSorting[tableKey] || {};
},
setTableSorting: (tableKey) => (sorting) => {
// Update the table sorting for the given table
set({
tableSorting: {
...get().tableSorting,
[tableKey]: sorting
}
});
},
// detail drawers
detailDrawerStack: 0,
addDetailDrawer: (value) => {

View File

@ -0,0 +1,91 @@
import type { DataTableSortStatus } from 'mantine-datatable';
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
const DEFAULT_PAGE_SIZE: number = 25;
/**
* Interfacing for storing persistent table state in the browser.
*
* The following properties are stored:
* - pageSize: The number of rows to display per page in the table.
* - hiddenColumns: An array of column names that are hidden in a given table.
* - columnNames: An object mapping table keys to arrays of column names.
* - sorting: An object mapping table keys to sorting configurations.
*/
interface StoredTableStateProps {
pageSize: number;
setPageSize: (size: number) => void;
tableSorting: Record<string, any>;
getTableSorting: (tableKey: string) => DataTableSortStatus;
setTableSorting: (
tableKey: string
) => (sorting: DataTableSortStatus<any>) => void;
tableColumnNames: Record<string, Record<string, string>>;
getTableColumnNames: (tableKey: string) => Record<string, string>;
setTableColumnNames: (
tableKey: string
) => (names: Record<string, string>) => void;
clearTableColumnNames: () => void;
hiddenColumns: Record<string, string[]>;
getHiddenColumns: (tableKey: string) => string[] | null;
setHiddenColumns: (tableKey: string) => (columns: string[]) => void;
}
export const useStoredTableState = create<StoredTableStateProps>()(
persist(
(set, get) => ({
pageSize: DEFAULT_PAGE_SIZE,
setPageSize: (size: number) => {
set((state) => ({
pageSize: size
}));
},
tableSorting: {},
getTableSorting: (tableKey) => {
return get().tableSorting[tableKey] || {};
},
setTableSorting: (tableKey) => (sorting) => {
// Update the table sorting for the given table
set({
tableSorting: {
...get().tableSorting,
[tableKey]: sorting
}
});
},
tableColumnNames: {},
getTableColumnNames: (tableKey) => {
return get().tableColumnNames[tableKey] || null;
},
setTableColumnNames: (tableKey) => (names) => {
// Update the table column names for the given table
set({
tableColumnNames: {
...get().tableColumnNames,
[tableKey]: names
}
});
},
clearTableColumnNames: () => {
set({ tableColumnNames: {} });
},
hiddenColumns: {},
getHiddenColumns: (tableKey) => {
return get().hiddenColumns?.[tableKey] ?? null;
},
setHiddenColumns: (tableKey) => (columns) => {
// Update the hidden columns for the given table
set({
hiddenColumns: {
...get().hiddenColumns,
[tableKey]: columns
}
});
}
}),
{
name: 'inventree-table-state'
}
)
);

View File

@ -1,10 +1,5 @@
import { t } from '@lingui/core/macro';
import {
Box,
LoadingOverlay,
type MantineStyleProp,
Stack
} from '@mantine/core';
import { Box, type MantineStyleProp, Stack } from '@mantine/core';
import { useQuery } from '@tanstack/react-query';
import {
type ContextMenuItemOptions,
@ -35,12 +30,12 @@ import { resolveItem } from '../functions/conversion';
import { extractAvailableFields, mapFields } from '../functions/forms';
import { showApiErrorMessage } from '../functions/notifications';
import { useLocalState } from '../states/LocalState';
import { useStoredTableState } from '../states/StoredTableState';
import type { TableColumn } from './Column';
import InvenTreeTableHeader from './InvenTreeTableHeader';
import { type RowAction, RowActions } from './RowActions';
const ACTIONS_COLUMN_ACCESSOR: string = '--actions--';
const defaultPageSize: number = 25;
const PAGE_SIZES = [10, 15, 20, 25, 50, 100, 500];
/**
@ -140,13 +135,18 @@ export function InvenTreeTable<T extends Record<string, any>>({
columns: TableColumn<T>[];
props: InvenTreeTableProps<T>;
}>) {
const { userTheme } = useLocalState();
const {
pageSize,
setPageSize,
getHiddenColumns,
setHiddenColumns,
getTableColumnNames,
setTableColumnNames,
getTableSorting,
setTableSorting,
userTheme
} = useLocalState();
setTableSorting
} = useStoredTableState();
const [fieldNames, setFieldNames] = useState<Record<string, string>>({});
@ -191,7 +191,7 @@ export function InvenTreeTable<T extends Record<string, any>>({
// Request OPTIONS data from the API, before we load the table
const tableOptionQuery = useQuery({
enabled: !!url && !tableData && tableState.storedDataLoaded,
enabled: !!url && !tableData,
queryKey: [
'options',
url,
@ -274,30 +274,6 @@ export function InvenTreeTable<T extends Record<string, any>>({
return tableProps.enableSelection || tableProps.enableBulkDelete || false;
}, [tableProps]);
useEffect(() => {
// On first table render, "hide" any default hidden columns
if (tableProps.enableColumnSwitching == false) {
return;
}
// A "null" value indicates that the initial "hidden" columns have not been set
if (tableState.storedDataLoaded && tableState.hiddenColumns == null) {
const columnNames: string[] = columns
.filter((col) => {
// Find any switchable columns which are hidden by default
return col.switchable != false && col.defaultVisible == false;
})
.map((col) => col.accessor);
tableState.setHiddenColumns(columnNames);
}
}, [
columns,
tableProps.enableColumnSwitching,
tableState.hiddenColumns,
tableState.storedDataLoaded
]);
// Check if any columns are switchable (can be hidden)
const hasSwitchableColumns: boolean = useMemo(() => {
if (props.enableColumnSwitching == false) {
@ -327,10 +303,6 @@ export function InvenTreeTable<T extends Record<string, any>>({
const dataColumns: any = useMemo(() => {
let cols: TableColumn[] = columns.filter((col) => col?.hidden != true);
if (!tableState.storedDataLoaded) {
cols = cols.filter((col) => col?.defaultVisible != false);
}
cols = cols.map((col) => {
// If the column is *not* switchable, it is always visible
// Otherwise, check if it is "default hidden"
@ -373,24 +345,29 @@ export function InvenTreeTable<T extends Record<string, any>>({
fieldNames,
tableProps.rowActions,
tableState.hiddenColumns,
tableState.selectedRecords,
tableState.storedDataLoaded
tableState.selectedRecords
]);
// Callback when column visibility is toggled
function toggleColumn(columnName: string) {
const newColumns = [...dataColumns];
const toggleColumn = useCallback(
(columnName: string) => {
const newColumns = [...dataColumns];
const colIdx = newColumns.findIndex((col) => col.accessor == columnName);
const colIdx = newColumns.findIndex((col) => col.accessor == columnName);
if (colIdx >= 0 && colIdx < newColumns.length) {
newColumns[colIdx].hidden = !newColumns[colIdx].hidden;
}
if (colIdx >= 0 && colIdx < newColumns.length) {
newColumns[colIdx].hidden = !newColumns[colIdx].hidden;
}
tableState.setHiddenColumns(
newColumns.filter((col) => col.hidden).map((col) => col.accessor)
);
}
const hiddenColumns = newColumns
.filter((col) => col.hidden)
.map((col) => col.accessor);
tableState.setHiddenColumns(hiddenColumns);
setHiddenColumns(cacheKey)(hiddenColumns);
},
[cacheKey, dataColumns]
);
// Final state of the table columns
const tableColumns = useDataTableColumns({
@ -456,8 +433,6 @@ export function InvenTreeTable<T extends Record<string, any>>({
// Pagination
if (tableProps.enablePagination && paginate) {
const pageSize = tableState.pageSize ?? defaultPageSize;
if (pageSize != tableState.pageSize) tableState.setPageSize(pageSize);
queryParams.limit = pageSize;
queryParams.offset = (tableState.page - 1) * pageSize;
}
@ -476,30 +451,44 @@ export function InvenTreeTable<T extends Record<string, any>>({
return queryParams;
},
[
sortStatus,
tableProps.params,
tableProps.enablePagination,
tableState.filterSet.activeFilters,
tableState.queryFilters,
tableState.searchTerm,
tableState.pageSize,
tableState.setPageSize,
sortStatus,
getOrderingTerm
]
);
const [sortingLoaded, setSortingLoaded] = useState<boolean>(false);
const [cacheLoaded, setCacheLoaded] = useState<boolean>(false);
useEffect(() => {
const tableKey: string = tableState.tableKey.split('-')[0];
const sorting: DataTableSortStatus = getTableSorting(tableKey);
const sorting: DataTableSortStatus = getTableSorting(cacheKey);
if (sorting && !!sorting.columnAccessor && !!sorting.direction) {
setSortStatus(sorting);
}
setSortingLoaded(true);
}, []);
const hiddenColumns = getHiddenColumns(cacheKey);
if (hiddenColumns == null) {
// A "null" value indicates that the initial "hidden" columns have not been set
const columnNames: string[] = columns
.filter((col) => {
// Find any switchable columns which are hidden by default
return col.switchable != false && col.defaultVisible == false;
})
.map((col) => col.accessor);
setHiddenColumns(cacheKey)(columnNames);
tableState.setHiddenColumns(columnNames);
} else {
tableState.setHiddenColumns(hiddenColumns);
}
setCacheLoaded(true);
}, [cacheKey]);
// Return the ordering parameter
function getOrderingTerm() {
@ -521,13 +510,15 @@ export function InvenTreeTable<T extends Record<string, any>>({
tableProps.noRecordsText ?? t`No records found`
);
const handleSortStatusChange = (status: DataTableSortStatus<T>) => {
tableState.setPage(1);
setSortStatus(status);
const handleSortStatusChange = useCallback(
(status: DataTableSortStatus<T>) => {
tableState.setPage(1);
setSortStatus(status);
const tableKey = tableState.tableKey.split('-')[0];
setTableSorting(tableKey)(status);
};
setTableSorting(cacheKey)(status);
},
[cacheKey]
);
// Function to perform API query to fetch required data
const fetchTableData = async () => {
@ -538,16 +529,11 @@ export function InvenTreeTable<T extends Record<string, any>>({
return [];
}
if (!sortingLoaded) {
if (!cacheLoaded) {
// Sorting not yet loaded - do not load!
return [];
}
if (!tableState.storedDataLoaded) {
// Table data not yet loaded - do not load!
return [];
}
return api
.get(url, {
params: queryParams,
@ -581,15 +567,13 @@ export function InvenTreeTable<T extends Record<string, any>>({
queryKey: [
'tabledata',
url,
tableState.tableKey,
tableState.page,
props.params,
sortingLoaded,
cacheLoaded,
sortStatus.columnAccessor,
sortStatus.direction,
tableState.tableKey,
tableState.filterSet.activeFilters,
tableState.storedDataLoaded,
tableState.searchTerm
],
retry: 5,
@ -602,7 +586,7 @@ export function InvenTreeTable<T extends Record<string, any>>({
return true;
},
enabled: !!url && !tableData && tableState.storedDataLoaded,
enabled: !!url && !tableData,
queryFn: fetchTableData
});
@ -629,16 +613,17 @@ export function InvenTreeTable<T extends Record<string, any>>({
tableState.isLoading
]);
const tablePageSize = useMemo(() => {
if (tableProps.enablePagination != false) {
return pageSize;
} else {
return tableState.recordCount;
}
}, [tableProps.enablePagination, pageSize, tableState.recordCount]);
// Update tableState.records when new data received
useEffect(() => {
const data = tableData ?? apiData ?? [];
tableState.setRecords(data);
// set pagesize to length if pagination is disabled
if (!tableProps.enablePagination) {
tableState.setPageSize(data?.length ?? defaultPageSize);
}
tableState.setRecords(tableData ?? apiData ?? []);
}, [tableData, apiData]);
// Callback when a cell is clicked
@ -735,12 +720,12 @@ export function InvenTreeTable<T extends Record<string, any>>({
return showContextMenu(items)(event);
};
// pagination refresh table if pageSize changes
function updatePageSize(newData: number) {
tableState.setPageSize(newData);
// Pagination refresh table if pageSize changes
const updatePageSize = useCallback((size: number) => {
setPageSize(size);
tableState.setPage(1);
tableState.refreshTable();
}
}, []);
/**
* Memoize row expansion options:
@ -776,7 +761,7 @@ export function InvenTreeTable<T extends Record<string, any>>({
_params = {
..._params,
totalRecords: tableState.recordCount,
recordsPerPage: tableState.pageSize,
recordsPerPage: tablePageSize,
page: tableState.page,
onPageChange: tableState.setPage,
recordsPerPageOptions: PAGE_SIZES,
@ -786,9 +771,9 @@ export function InvenTreeTable<T extends Record<string, any>>({
return _params;
}, [
tablePageSize,
tableProps.enablePagination,
tableState.recordCount,
tableState.pageSize,
tableState.page,
tableState.setPage,
updatePageSize
@ -806,7 +791,7 @@ export function InvenTreeTable<T extends Record<string, any>>({
<>
<Stack gap='xs'>
{!tableProps.noHeader && (
<Boundary label={`InvenTreeTableHeader-${tableState.tableKey}`}>
<Boundary label={`InvenTreeTableHeader-${cacheKey}`}>
<InvenTreeTableHeader
tableUrl={url}
tableState={tableState}
@ -818,9 +803,8 @@ export function InvenTreeTable<T extends Record<string, any>>({
/>
</Boundary>
)}
<Boundary label={`InvenTreeTable-${tableState.tableKey}`}>
<Boundary label={`InvenTreeTable-${cacheKey}`}>
<Box pos='relative'>
<LoadingOverlay visible={!tableState.storedDataLoaded} />
<DataTable
withTableBorder={!tableProps.noHeader}
withColumnBorders