UNPKG

@coveord/plasma-mantine

Version:

A Plasma flavoured Mantine theme

495 lines (463 loc) 15.5 kB
import type {DatesRangeValue, DateStringValue} from '@mantine/dates'; import {useDidUpdate} from '@mantine/hooks'; import {type ExpandedState, type PaginationState, type SortingState} from '@tanstack/table-core'; import defaultsDeep from 'lodash.defaultsdeep'; import {Dispatch, SetStateAction, useCallback, useMemo, useState} from 'react'; import {useUrlSyncedState, UseUrlSyncedStateOptions} from '../../hooks/use-url-synced-state.js'; import {usePersistedColumnVisibility} from './use-persisted-column-visibility.js'; // Create a deeply optional version of another type type DeepPartial<T> = { [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P]; }; export interface TableState<TData = unknown> { /** * Current pagination state * * @default { pageIndex: 0, pageSize: 50 } */ pagination: PaginationState; /** * Total number of entries in the table. * This number is used to calculate the number of pages in the pagination. * When null, the number of pages is calculated using the current data length. * * @default null */ totalEntries: number | null; /** * Current sorting state * * @default [] */ sorting: SortingState; /** * Current global filter value * * @default '' */ globalFilter: string; /** * Current expanded state * * @default {} */ expanded: ExpandedState; /** * Predicates and their current value * * @default {} */ predicates: Record<string, string>; /** * Layout currently selected. When null, the first layout is used. * * @default null */ layout: string | null; /** * Currently selected date range * * @default [null, null] */ dateRange: DatesRangeValue<DateStringValue | null>; /** * Currently selected rows * * @default {} */ rowSelection: Record<string, TData>; /** * Columns that are currently visible * * @default {} */ columnVisibility: Record<string, boolean>; } export interface TableStore<TData = unknown> { /** * Current state of the table. */ state: TableState<TData>; /** * Allows to change the pagination state. */ setPagination: Dispatch<SetStateAction<TableState<TData>['pagination']>>; /** * Allows to change the total number of entries. */ setTotalEntries: Dispatch<SetStateAction<TableState<TData>['totalEntries']>>; /** * Allows to change the sorting state. */ setSorting: Dispatch<SetStateAction<TableState<TData>['sorting']>>; /** * Allows to change the global filter value. */ setGlobalFilter: Dispatch<SetStateAction<TableState<TData>['globalFilter']>>; /** * Allows to change the rows expanded state. */ setExpanded: Dispatch<SetStateAction<TableState<TData>['expanded']>>; /** * Allows to change the predicates values. */ setPredicates: Dispatch<SetStateAction<TableState<TData>['predicates']>>; /** * Allows to change the selected layout. */ setLayout: Dispatch<SetStateAction<TableState<TData>['layout']>>; /** * Allows to change the selected date range. */ setDateRange: Dispatch<SetStateAction<TableState<TData>['dateRange']>>; /** * Allows to change the current row selection. */ setRowSelection: Dispatch<SetStateAction<TableState<TData>['rowSelection']>>; /** * Allows to change the visible columns. */ setColumnVisibility: Dispatch<SetStateAction<TableState<TData>['columnVisibility']>>; /** * Whether the table is currently filtered. */ isFiltered: boolean; /** * Whether the table has data when unfiltered. * * This is derived from the totalEntries so make sure you set that number correctly, even if you're using a client side table. */ isVacant: boolean; /** * Clear currently applied filters. */ clearFilters: () => void; /** * Deselects all currently selected rows. */ clearRowSelection: () => void; /** * Get currently selected rows. */ getSelectedRows: () => TData[]; /** * Get currently selected row */ getSelectedRow: () => TData | null; /** * Whether the user can select multiple rows at the same time. */ multiRowSelectionEnabled: boolean; /** * Whether rows can be selected. */ rowSelectionEnabled: boolean; /** * Whether row selection is forced. */ rowSelectionForced: boolean; } export interface UseTableOptions<TData = unknown> { /** * Initial state of the table. */ initialState?: DeepPartial<TableState<TData>>; /** * Whether rows can be selected. * * @default true */ enableRowSelection?: boolean; /** * Whether multiple rows can be selected at the same time. * * @default false */ enableMultiRowSelection?: boolean; /** * Forces the user to always have one row selected. * When activating that setting, a good practice is to have a row already selected in the initial state. * * @default false */ forceSelection?: boolean; /** * Whether to sync the table state with the URL. * * @default false */ syncWithUrl?: boolean; /** * Unique identifier for the table. When provided, column visibility preferences are persisted to localStorage. */ tableId?: string; /** * Maximum number of columns that can be visible when restoring persisted visibility from localStorage. * This only affects the initial column visibility resolved on mount when `tableId` is set. * It does not enforce a runtime limit on `setColumnVisibility` — use `TableColumnsSelector` for UI enforcement. * * @default Infinity */ maxSelectableColumns?: number; } const defaultOptions: UseTableOptions = { enableRowSelection: true, enableMultiRowSelection: false, forceSelection: false, syncWithUrl: false, }; const defaultState: Partial<TableState> = { pagination: { pageIndex: 0, pageSize: 50, }, totalEntries: null, sorting: [], globalFilter: '', predicates: {}, layout: null, dateRange: [null, null], rowSelection: {}, columnVisibility: {}, }; const serialization = <K extends keyof TableState>( input: Pick<UseUrlSyncedStateOptions<TableState[K]>, 'serializer' | 'deserializer'>, ) => Object.freeze(input); const PAGINATION_SERIALIZATION = serialization<'pagination'>({ serializer: ({pageIndex, pageSize}) => [ ['page', (pageIndex + 1).toString()], ['pageSize', pageSize.toString()], ], deserializer: (params, initialState) => defaultsDeep( { pageIndex: params.get('page') ? Math.max(1, parseInt(params.get('page'), 10)) - 1 : undefined, pageSize: params.get('pageSize') ? parseInt(params.get('pageSize'), 10) : undefined, }, initialState, ), }); const SORTING_SERIALIZATION = serialization<'sorting'>({ serializer: (sorting) => [['sortBy', sorting.map(({id, desc}) => `${id}.${desc ? 'desc' : 'asc'}`).join(',')]], deserializer: (params, initialState) => { if (!params.has('sortBy')) { return initialState; } const sorts = params.get('sortBy')?.split(',') ?? []; return sorts.map((sort) => { const [id, order] = sort.split('.'); return {id, desc: order === 'desc'}; }); }, }); const GLOBAL_FILTER_SERIALIZATION = serialization<'globalFilter'>({ serializer: (filter) => [['filter', filter]], deserializer: (params, initialState) => params.get('filter') ?? initialState, }); const PREDICATES_SERIALIZATION = serialization<'predicates'>({ serializer: (predicates) => Object.entries(predicates), deserializer: (params, initialState) => Object.keys(initialState).reduce( (acc, predicateKey) => { acc[predicateKey] = params.get(predicateKey) ?? initialState[predicateKey]; return acc; }, {} as TableState['predicates'], ), }); const LAYOUT_SERIALIZATION = serialization<'layout'>({ serializer: (_layout) => [['layout', _layout]], deserializer: (params, initialState) => params.get('layout') ?? initialState, }); const DATE_RANGE_SERIALIZATION = serialization<'dateRange'>({ serializer: ([from, to]) => [ ['from', from ? new Date(from).toISOString() : '', true], ['to', to ? new Date(to).toISOString() : '', true], ], deserializer: (params, initial) => [ params.get('from') ? params.get('from') : initial[0], params.get('to') ? params.get('to') : initial[1], ], }); const COLUMN_VISIBILITY_SERIALIZATION = serialization<'columnVisibility'>({ serializer: (columns) => [ [ 'show', Object.entries(columns) .filter(([, visible]) => visible === true) .map(([columnName]) => columnName) .join(','), ], [ 'hide', Object.entries(columns) .filter(([, visible]) => visible === false) .map(([columnName]) => columnName) .join(','), ], ], deserializer: (params, initial) => { if (!params.has('show') && !params.has('hide')) { return initial; } const visible = params.get('show')?.split(',') ?? []; const invisible = params.get('hide')?.split(',') ?? []; const columns = {} as TableState['columnVisibility']; visible.forEach((column) => { columns[column] = true; }); invisible.forEach((column) => { columns[column] = false; }); return columns; }, }); export const useTable = <TData>(userOptions: UseTableOptions<TData> = {}): TableStore<TData> => { const options = defaultsDeep({}, userOptions, defaultOptions) as UseTableOptions<TData>; const [initialState] = useState( () => defaultsDeep({}, userOptions.initialState, defaultState) as TableState<TData>, ); /** * The `useUrlSyncedState` hook defaults to synchronize, but the table wants to default to not synchronize, * so always pass the sync option as a resolved boolean value. */ const sync = !!options.syncWithUrl; // (Optionally) synced with url const [pagination, setPagination] = useUrlSyncedState<TableState<TData>['pagination']>({ ...PAGINATION_SERIALIZATION, initialState: initialState.pagination, sync, }); const [sorting, setSorting] = useUrlSyncedState<TableState<TData>['sorting']>({ ...SORTING_SERIALIZATION, initialState: initialState.sorting, sync, }); const [globalFilter, setGlobalFilter] = useUrlSyncedState<TableState<TData>['globalFilter']>({ ...GLOBAL_FILTER_SERIALIZATION, initialState: initialState.globalFilter, sync, }); const [predicates, setPredicates] = useUrlSyncedState<TableState<TData>['predicates']>({ ...PREDICATES_SERIALIZATION, initialState: initialState.predicates, sync, }); const [layout, setLayout] = useUrlSyncedState<TableState<TData>['layout']>({ ...LAYOUT_SERIALIZATION, initialState: initialState.layout, sync, }); const [dateRange, setDateRange] = useUrlSyncedState<TableState<TData>['dateRange']>({ ...DATE_RANGE_SERIALIZATION, initialState: initialState.dateRange, sync, }); const {initialColumnVisibility, persistColumnVisibility} = usePersistedColumnVisibility( initialState.columnVisibility, options.maxSelectableColumns ?? Infinity, options.tableId, ); const [columnVisibility, _setColumnVisibility] = useUrlSyncedState<TableState<TData>['columnVisibility']>({ ...COLUMN_VISIBILITY_SERIALIZATION, initialState: initialColumnVisibility, sync, }); const setColumnVisibility: typeof _setColumnVisibility = useCallback( (updater) => { _setColumnVisibility((old) => { const newVis = updater instanceof Function ? updater(old) : updater; persistColumnVisibility(newVis); return newVis; }); }, [_setColumnVisibility, persistColumnVisibility], ); // unsynced const [totalEntries, _setTotalEntries] = useState<TableState<TData>['totalEntries']>(initialState.totalEntries); const [unfilteredTotalEntries, setUnfilteredTotalEntries] = useState<TableState<TData>['totalEntries']>( initialState.totalEntries, ); const [expanded, setExpanded] = useState<TableState<TData>['expanded']>(initialState.expanded); const [rowSelection, setRowSelection] = useState<TableState<TData>['rowSelection']>(initialState.rowSelection); const isFiltered = !!globalFilter || Object.keys(predicates).some((predicate) => !!predicates[predicate]) || !!dateRange?.[0] || !!dateRange?.[1]; const isVacant = unfilteredTotalEntries === 0; const setTotalEntries: typeof _setTotalEntries = useCallback( (updater) => { _setTotalEntries((old) => { const newTotalEntries = updater instanceof Function ? updater(old) : updater; if (!isFiltered) { setUnfilteredTotalEntries(newTotalEntries); } return newTotalEntries; }); }, [isFiltered], ); const clearFilters = useCallback(() => { setPredicates(initialState.predicates); setGlobalFilter(''); }, []); const clearRowSelection = useCallback(() => { setRowSelection({}); }, []); const getSelectedRows = useCallback(() => Object.values(rowSelection), [rowSelection]); const getSelectedRow = () => getSelectedRows()[0] ?? null; useDidUpdate(() => { if (!options.enableMultiRowSelection) { clearRowSelection(); } }, [globalFilter, pagination, sorting, dateRange, predicates]); const state = useMemo( () => ({ pagination, totalEntries, sorting, globalFilter, expanded, predicates, layout, dateRange, rowSelection, columnVisibility, }), [ pagination, totalEntries, sorting, globalFilter, expanded, predicates, layout, dateRange, rowSelection, columnVisibility, ], ); return { state, setPagination, setTotalEntries, setSorting, setGlobalFilter, setExpanded, setPredicates, setLayout, setDateRange, setRowSelection, setColumnVisibility, isFiltered, isVacant, clearFilters, clearRowSelection, getSelectedRows, getSelectedRow, rowSelectionEnabled: options.enableRowSelection, rowSelectionForced: options.forceSelection, multiRowSelectionEnabled: options.enableMultiRowSelection, }; };