From aa7402efbabd2a2f2d7f601e6e0f864674ff311d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Dostie?= <35579930+gdostie@users.noreply.github.com> Date: Mon, 16 Dec 2024 15:12:31 -0500 Subject: [PATCH] feat(mantine): add syncWithUrl option to table hook --- .../mantine/src/components/table/use-table.ts | 126 +++++++++++++++--- .../components/table/use-url-synced-state.ts | 49 +++++++ .../layout/Table/TableClientSide.demo.tsx | 1 + .../layout/Table/TablePredicate.demo.tsx | 8 +- 4 files changed, 165 insertions(+), 19 deletions(-) create mode 100644 packages/mantine/src/components/table/use-url-synced-state.ts diff --git a/packages/mantine/src/components/table/use-table.ts b/packages/mantine/src/components/table/use-table.ts index 6fa9939d3b..caf0208e10 100644 --- a/packages/mantine/src/components/table/use-table.ts +++ b/packages/mantine/src/components/table/use-table.ts @@ -3,13 +3,12 @@ import {type ExpandedState, type PaginationState, type SortingState} from '@tans import defaultsDeep from 'lodash.defaultsdeep'; import {Dispatch, SetStateAction, useCallback, useMemo, useState} from 'react'; import {type DateRangePickerValue} from '../date-range-picker'; +import {useUrlSyncedState} from './use-url-synced-state'; // Create a deeply optional version of another type -type PartialDeep = T extends object - ? { - [P in keyof T]?: PartialDeep; - } - : T; +type DeepPartial = { + [P in keyof T]?: T[P] extends object ? DeepPartial : T[P]; +}; export interface TableState { /** @@ -166,7 +165,7 @@ export interface UseTableOptions { /** * Initial state of the table. */ - initialState?: PartialDeep>; + initialState?: DeepPartial>; /** * Whether rows can be selected. * @@ -186,12 +185,19 @@ export interface UseTableOptions { * @default false */ forceSelection?: boolean; + /** + * Whether to sync the table state with the URL. + * + * @default false + */ + syncWithUrl?: boolean; } const defaultOptions: UseTableOptions = { enableRowSelection: true, enableMultiRowSelection: false, forceSelection: false, + syncWithUrl: false, }; const defaultState: Partial = { @@ -213,21 +219,111 @@ export const useTable = (userOptions: UseTableOptions = {}): Table const options = defaultsDeep({}, userOptions, defaultOptions) as UseTableOptions; const initialState = defaultsDeep({}, options.initialState, defaultState) as TableState; - const [pagination, setPagination] = useState['pagination']>(initialState.pagination); + // synced with url + const [pagination, setPagination] = useUrlSyncedState['pagination']>({ + initialState: initialState.pagination, + serializer: ({pageIndex, pageSize}) => [ + ['page', pageIndex.toString()], + ['pageSize', pageSize.toString()], + ], + deserializer: (params) => + defaultsDeep( + { + pageIndex: params.get('page') ? parseInt(params.get('page'), 10) : undefined, + pageSize: params.get('pageSize') ? parseInt(params.get('pageSize'), 10) : undefined, + }, + initialState.pagination, + ), + sync: options.syncWithUrl, + }); + const [sorting, setSorting] = useUrlSyncedState['sorting']>({ + initialState: initialState.sorting, + serializer: (_sorting) => [ + ['sortBy', _sorting.map(({id, desc}) => `${id}.${desc ? 'desc' : 'asc'}`).join(',')], + ], + deserializer: (params) => { + if (!params.has('sortBy')) { + return initialState.sorting; + } + const sorts = params.get('sortBy')?.split(',') ?? []; + return sorts.map((sort) => { + const [id, order] = sort.split('.'); + return {id, desc: order === 'desc'}; + }); + }, + sync: options.syncWithUrl, + }); + const [globalFilter, setGlobalFilter] = useUrlSyncedState['globalFilter']>({ + initialState: initialState.globalFilter, + serializer: (filter) => [['filter', filter]], + deserializer: (params) => params.get('filter') ?? initialState.globalFilter, + sync: options.syncWithUrl, + }); + const [predicates, setPredicates] = useUrlSyncedState['predicates']>({ + initialState: initialState.predicates, + serializer: (_predicates) => Object.entries(_predicates).map(([key, value]) => [key, value]), + deserializer: (params) => + Object.keys(initialState.predicates).reduce( + (acc, predicateKey) => { + acc[predicateKey] = params.get(predicateKey) ?? initialState.predicates[predicateKey]; + return acc; + }, + {} as TableState['predicates'], + ), + sync: options.syncWithUrl, + }); + const [layout, setLayout] = useUrlSyncedState['layout']>({ + initialState: initialState.layout, + serializer: (_layout) => [['layout', _layout]], + deserializer: (params) => params.get('layout') ?? initialState.layout, + sync: options.syncWithUrl, + }); + const [dateRange, setDateRange] = useUrlSyncedState['dateRange']>({ + initialState: initialState.dateRange, + serializer: ([from, to]) => [ + ['from', from?.toISOString() ?? ''], + ['to', to?.toISOString() ?? ''], + ], + deserializer: (params) => [ + params.get('from') ? new Date(params.get('from') as string) : initialState.dateRange[0], + params.get('to') ? new Date(params.get('to') as string) : initialState.dateRange[1], + ], + sync: options.syncWithUrl, + }); + const [columnVisibility, setColumnVisibility] = useUrlSyncedState['columnVisibility']>({ + initialState: initialState.columnVisibility, + serializer: (columns) => [ + [ + 'columns', + Object.entries(columns) + .filter(([, visible]) => !!visible) + .map(([columnName]) => columnName) + .join(','), + ], + ], + deserializer: (params) => { + if (!params.has('columns')) { + return initialState.columnVisibility; + } + const columns = params.get('columns')?.split(',') ?? []; + return columns.reduce( + (acc, column) => { + acc[column] = true; + return acc; + }, + {} as TableState['columnVisibility'], + ); + }, + sync: options.syncWithUrl, + }); + + // unsynced const [totalEntries, _setTotalEntries] = useState['totalEntries']>(initialState.totalEntries); const [unfilteredTotalEntries, setUnfilteredTotalEntries] = useState['totalEntries']>( initialState.totalEntries, ); - const [sorting, setSorting] = useState['sorting']>(initialState.sorting); - const [globalFilter, setGlobalFilter] = useState['globalFilter']>(initialState.globalFilter); const [expanded, setExpanded] = useState['expanded']>(initialState.expanded); - const [predicates, setPredicates] = useState['predicates']>(initialState.predicates); - const [layout, setLayout] = useState['layout']>(initialState.layout); - const [dateRange, setDateRange] = useState['dateRange']>(initialState.dateRange); const [rowSelection, setRowSelection] = useState['rowSelection']>(initialState.rowSelection); - const [columnVisibility, setColumnVisibility] = useState['columnVisibility']>( - initialState.columnVisibility, - ); const isFiltered = !!globalFilter || diff --git a/packages/mantine/src/components/table/use-url-synced-state.ts b/packages/mantine/src/components/table/use-url-synced-state.ts new file mode 100644 index 0000000000..25237a7d2f --- /dev/null +++ b/packages/mantine/src/components/table/use-url-synced-state.ts @@ -0,0 +1,49 @@ +import {useMemo, useState} from 'react'; + +const setSearchParam = (key: string, value: string, initial: string) => { + const url = new URL(window.location.href); + if (value === '' || value === initial) { + url.searchParams.delete(key); + } else { + url.searchParams.set(key, value); + } + window.history.replaceState(null, '', url.toString()); +}; + +const getSearchParams = (): URLSearchParams => { + const url = new URL(window.location.href); + return url.searchParams; +}; + +export interface UseUrlSyncedStateOptions { + initialState: T; + serializer: (stateValue: T) => Array<[string, string]>; + deserializer: (params: URLSearchParams) => T; + sync?: boolean; +} + +export const useUrlSyncedState = ({initialState, serializer, deserializer, sync}: UseUrlSyncedStateOptions) => { + const [state, setState] = useState(sync ? deserializer(getSearchParams()) : initialState); + const enhancedSetState = useMemo(() => { + if (sync) { + const initialSerialized = serializer(initialState).reduce( + (acc, [key, value]) => { + acc[key] = value; + return acc; + }, + {} as Record, + ); + return (updater: T | ((old: T) => T)) => { + setState((old) => { + const newValue = updater instanceof Function ? updater(old) : updater; + serializer(newValue).forEach(([key, value]) => setSearchParam(key, value, initialSerialized[key])); + return newValue; + }); + }; + } + + return setState; + }, [sync]); + + return [state, enhancedSetState] as const; +}; diff --git a/packages/website/src/examples/layout/Table/TableClientSide.demo.tsx b/packages/website/src/examples/layout/Table/TableClientSide.demo.tsx index 0bc33f8a7c..b4e688946a 100644 --- a/packages/website/src/examples/layout/Table/TableClientSide.demo.tsx +++ b/packages/website/src/examples/layout/Table/TableClientSide.demo.tsx @@ -67,6 +67,7 @@ const Demo = () => { pagination: {pageSize: 5}, totalEntries: data.length, }, + syncWithUrl: true, }); return ( store={table} data={data} columns={columns} options={options} getRowId={({id}) => id}> diff --git a/packages/website/src/examples/layout/Table/TablePredicate.demo.tsx b/packages/website/src/examples/layout/Table/TablePredicate.demo.tsx index 9a2c15da20..4b5826e490 100644 --- a/packages/website/src/examples/layout/Table/TablePredicate.demo.tsx +++ b/packages/website/src/examples/layout/Table/TablePredicate.demo.tsx @@ -30,7 +30,7 @@ const columns: Array> = [ const Demo = () => { const data = useMemo(() => makeData(10), []); const table = useTable({ - initialState: {totalEntries: data.length, predicates: {status: '', age: ''}}, + initialState: {totalEntries: data.length, predicates: {status: 'ALL', age: 'ANY'}}, }); // we're filtering the data ourselves here for the example, @@ -51,7 +51,7 @@ const Demo = () => { label="Age group" data={[ { - value: '', + value: 'ANY', label: 'Any', }, {value: 'below20', label: 'Below 20'}, @@ -64,7 +64,7 @@ const Demo = () => { label="Status" data={[ { - value: '', + value: 'ALL', label: 'All', }, {value: 'relationship', label: 'In a relationship'}, @@ -121,5 +121,5 @@ const statusFilter = (row: Person, predicates: Record) => { const status = row['status']; const filterValue = predicates['status']; - return !filterValue || status === filterValue; + return filterValue === 'ALL' || status === filterValue; };