From f3072b804e1d0d580ce5baaf58785f9377e7eb1c Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 30 Jun 2025 08:13:04 +1000 Subject: [PATCH] [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 --- src/frontend/lib/types/Tables.tsx | 7 +- src/frontend/src/contexts/LanguageContext.tsx | 3 +- src/frontend/src/hooks/UseTable.tsx | 82 +-------- src/frontend/src/states/LocalState.tsx | 43 +---- src/frontend/src/states/StoredTableState.tsx | 91 +++++++++ src/frontend/src/tables/InvenTreeTable.tsx | 172 ++++++++---------- 6 files changed, 180 insertions(+), 218 deletions(-) create mode 100644 src/frontend/src/states/StoredTableState.tsx diff --git a/src/frontend/lib/types/Tables.tsx b/src/frontend/lib/types/Tables.tsx index e9baea055f..71edb756e8 100644 --- a/src/frontend/lib/types/Tables.tsx +++ b/src/frontend/lib/types/Tables.tsx @@ -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; }; diff --git a/src/frontend/src/contexts/LanguageContext.tsx b/src/frontend/src/contexts/LanguageContext.tsx index bfe65d3923..57b1838fc5 100644 --- a/src/frontend/src/contexts/LanguageContext.tsx +++ b/src/frontend/src/contexts/LanguageContext.tsx @@ -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) => { diff --git a/src/frontend/src/hooks/UseTable.tsx b/src/frontend/src/hooks/UseTable.tsx index 19928c5d0e..38034a5126 100644 --- a/src/frontend/src/hooks/UseTable.tsx +++ b/src/frontend/src/hooks/UseTable.tsx @@ -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 component. * @@ -51,6 +45,9 @@ export function useTable(tableName: string, idAccessor = 'pk'): TableState { [expandedRecords] ); + // Array of columns which are hidden + const [hiddenColumns, setHiddenColumns] = useState([]); + // Array of selected records const [selectedRecords, setSelectedRecords] = useState([]); @@ -73,70 +70,6 @@ export function useTable(tableName: string, idAccessor = 'pk'): TableState { const [page, setPage] = useState(1); - const [storedDataLoaded, setStoredDataLoaded] = useState(false); - - const [tableData, setTableData] = useState({ - pageSize: 25, - hiddenColumns: null - }); - - const [storedTableData, setStoredTableData] = - useLocalStorage({ - 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(''); @@ -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, diff --git a/src/frontend/src/states/LocalState.tsx b/src/frontend/src/states/LocalState.tsx index c798f275b9..fcd9f6d5cb 100644 --- a/src/frontend/src/states/LocalState.tsx +++ b/src/frontend/src/states/LocalState.tsx @@ -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; setLastUsedPanel: (panelKey: string) => (value: string) => void; - tableColumnNames: Record>; - getTableColumnNames: (tableKey: string) => Record; - setTableColumnNames: ( - tableKey: string - ) => (names: Record) => void; - tableSorting: Record; - getTableSorting: (tableKey: string) => DataTableSortStatus; - setTableSorting: ( - tableKey: string - ) => (sorting: DataTableSortStatus) => void; - clearTableColumnNames: () => void; detailDrawerStack: number; addDetailDrawer: (value: number | false) => void; navigationOpen: boolean; @@ -140,36 +128,7 @@ export const useLocalState = create()( }); } }, - // 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) => { diff --git a/src/frontend/src/states/StoredTableState.tsx b/src/frontend/src/states/StoredTableState.tsx new file mode 100644 index 0000000000..7d9519dc09 --- /dev/null +++ b/src/frontend/src/states/StoredTableState.tsx @@ -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; + getTableSorting: (tableKey: string) => DataTableSortStatus; + setTableSorting: ( + tableKey: string + ) => (sorting: DataTableSortStatus) => void; + tableColumnNames: Record>; + getTableColumnNames: (tableKey: string) => Record; + setTableColumnNames: ( + tableKey: string + ) => (names: Record) => void; + clearTableColumnNames: () => void; + hiddenColumns: Record; + getHiddenColumns: (tableKey: string) => string[] | null; + setHiddenColumns: (tableKey: string) => (columns: string[]) => void; +} + +export const useStoredTableState = create()( + 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' + } + ) +); diff --git a/src/frontend/src/tables/InvenTreeTable.tsx b/src/frontend/src/tables/InvenTreeTable.tsx index a89259c45e..546a705a0b 100644 --- a/src/frontend/src/tables/InvenTreeTable.tsx +++ b/src/frontend/src/tables/InvenTreeTable.tsx @@ -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>({ columns: TableColumn[]; props: InvenTreeTableProps; }>) { + const { userTheme } = useLocalState(); + const { + pageSize, + setPageSize, + getHiddenColumns, + setHiddenColumns, getTableColumnNames, setTableColumnNames, getTableSorting, - setTableSorting, - userTheme - } = useLocalState(); + setTableSorting + } = useStoredTableState(); const [fieldNames, setFieldNames] = useState>({}); @@ -191,7 +191,7 @@ export function InvenTreeTable>({ // 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>({ 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>({ 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>({ 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>({ // 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>({ return queryParams; }, [ + sortStatus, tableProps.params, tableProps.enablePagination, tableState.filterSet.activeFilters, tableState.queryFilters, tableState.searchTerm, - tableState.pageSize, - tableState.setPageSize, - sortStatus, getOrderingTerm ] ); - const [sortingLoaded, setSortingLoaded] = useState(false); + const [cacheLoaded, setCacheLoaded] = useState(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>({ tableProps.noRecordsText ?? t`No records found` ); - const handleSortStatusChange = (status: DataTableSortStatus) => { - tableState.setPage(1); - setSortStatus(status); + const handleSortStatusChange = useCallback( + (status: DataTableSortStatus) => { + 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>({ 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>({ 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>({ return true; }, - enabled: !!url && !tableData && tableState.storedDataLoaded, + enabled: !!url && !tableData, queryFn: fetchTableData }); @@ -629,16 +613,17 @@ export function InvenTreeTable>({ 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>({ 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>({ _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>({ return _params; }, [ + tablePageSize, tableProps.enablePagination, tableState.recordCount, - tableState.pageSize, tableState.page, tableState.setPage, updatePageSize @@ -806,7 +791,7 @@ export function InvenTreeTable>({ <> {!tableProps.noHeader && ( - + >({ /> )} - + -