Skip to content

Commit

Permalink
feat(mantine): add syncWithUrl option to table hook
Browse files Browse the repository at this point in the history
  • Loading branch information
gdostie committed Dec 16, 2024
1 parent 9db7974 commit 14da387
Show file tree
Hide file tree
Showing 4 changed files with 186 additions and 19 deletions.
126 changes: 111 additions & 15 deletions packages/mantine/src/components/table/use-table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> = T extends object
? {
[P in keyof T]?: PartialDeep<T[P]>;
}
: T;
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};

export interface TableState<TData = unknown> {
/**
Expand Down Expand Up @@ -166,7 +165,7 @@ export interface UseTableOptions<TData = unknown> {
/**
* Initial state of the table.
*/
initialState?: PartialDeep<TableState<TData>>;
initialState?: DeepPartial<TableState<TData>>;
/**
* Whether rows can be selected.
*
Expand All @@ -186,12 +185,19 @@ export interface UseTableOptions<TData = unknown> {
* @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<TableState> = {
Expand All @@ -213,21 +219,111 @@ export const useTable = <TData>(userOptions: UseTableOptions<TData> = {}): Table
const options = defaultsDeep({}, userOptions, defaultOptions) as UseTableOptions<TData>;
const initialState = defaultsDeep({}, options.initialState, defaultState) as TableState<TData>;

const [pagination, setPagination] = useState<TableState<TData>['pagination']>(initialState.pagination);
// synced with url
const [pagination, setPagination] = useUrlSyncedState<TableState<TData>['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<TableState<TData>['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<TableState<TData>['globalFilter']>({
initialState: initialState.globalFilter,
serializer: (filter) => [['filter', filter]],
deserializer: (params) => params.get('filter') ?? initialState.globalFilter,
sync: options.syncWithUrl,
});
const [predicates, setPredicates] = useUrlSyncedState<TableState<TData>['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<TData>['predicates'],
),
sync: options.syncWithUrl,
});
const [layout, setLayout] = useUrlSyncedState<TableState<TData>['layout']>({
initialState: initialState.layout,
serializer: (_layout) => [['layout', _layout]],
deserializer: (params) => params.get('layout') ?? initialState.layout,
sync: options.syncWithUrl,
});
const [dateRange, setDateRange] = useUrlSyncedState<TableState<TData>['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<TableState<TData>['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<TData>['columnVisibility'],
);
},
sync: options.syncWithUrl,
});

// unsynced
const [totalEntries, _setTotalEntries] = useState<TableState<TData>['totalEntries']>(initialState.totalEntries);
const [unfilteredTotalEntries, setUnfilteredTotalEntries] = useState<TableState<TData>['totalEntries']>(
initialState.totalEntries,
);
const [sorting, setSorting] = useState<TableState<TData>['sorting']>(initialState.sorting);
const [globalFilter, setGlobalFilter] = useState<TableState<TData>['globalFilter']>(initialState.globalFilter);
const [expanded, setExpanded] = useState<TableState<TData>['expanded']>(initialState.expanded);
const [predicates, setPredicates] = useState<TableState<TData>['predicates']>(initialState.predicates);
const [layout, setLayout] = useState<TableState<TData>['layout']>(initialState.layout);
const [dateRange, setDateRange] = useState<TableState<TData>['dateRange']>(initialState.dateRange);
const [rowSelection, setRowSelection] = useState<TableState<TData>['rowSelection']>(initialState.rowSelection);
const [columnVisibility, setColumnVisibility] = useState<TableState<TData>['columnVisibility']>(
initialState.columnVisibility,
);

const isFiltered =
!!globalFilter ||
Expand Down
70 changes: 70 additions & 0 deletions packages/mantine/src/components/table/use-url-synced-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
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<T> {
/**
* The initial state
*/
initialState: T;
/**
* The serializer function is used to determine how the state is translated to url search parameters.
* Called each time the state changes.
* @param stateValue The new state value to serialize.
* @returns A list of [key, value] to set as url search parameters.
* @example (filterValue) => [['filter', filterValue]] // ?filter=filterValue
*/
serializer: (stateValue: T) => Array<[string, string]>;
/**
* The deserializer function is used to determine how the url parameters influence the initial state.
* Called only once when initializing the state.
* @param params All the search parameters of the current url.
* @returns The initial state based on the current url.
* @example (params) => params.get('filter') ?? '',
*/
deserializer: (params: URLSearchParams) => T;
/**
* Whether the state should be synced with the url.
* When set to false, the hook behaves just like a regular useState hook from react.
*/
sync?: boolean;
}

export const useUrlSyncedState = <T>({initialState, serializer, deserializer, sync}: UseUrlSyncedStateOptions<T>) => {
const [state, setState] = useState<T>(sync ? deserializer(getSearchParams()) : initialState);
const enhancedSetState = useMemo(() => {
if (sync) {
const initialSerialized = serializer(initialState).reduce(
(acc, [key, value]) => {
acc[key] = value;
return acc;
},
{} as Record<string, string>,
);
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;
};
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ const Demo = () => {
pagination: {pageSize: 5},
totalEntries: data.length,
},
syncWithUrl: true,
});
return (
<Table<Person> store={table} data={data} columns={columns} options={options} getRowId={({id}) => id}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const columns: Array<ColumnDef<Person>> = [
const Demo = () => {
const data = useMemo(() => makeData(10), []);
const table = useTable<Person>({
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,
Expand All @@ -51,7 +51,7 @@ const Demo = () => {
label="Age group"
data={[
{
value: '',
value: 'ANY',
label: 'Any',
},
{value: 'below20', label: 'Below 20'},
Expand All @@ -64,7 +64,7 @@ const Demo = () => {
label="Status"
data={[
{
value: '',
value: 'ALL',
label: 'All',
},
{value: 'relationship', label: 'In a relationship'},
Expand Down Expand Up @@ -121,5 +121,5 @@ const statusFilter = (row: Person, predicates: Record<string, string>) => {
const status = row['status'];
const filterValue = predicates['status'];

return !filterValue || status === filterValue;
return filterValue === 'ALL' || status === filterValue;
};

0 comments on commit 14da387

Please sign in to comment.