@coveord/plasma-mantine
Version:
A Plasma flavoured Mantine theme
495 lines (463 loc) • 15.5 kB
text/typescript
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,
};
};