Skip to content

Commit

Permalink
feat(mantine): export useUrlSyncedState and support hash router [ADUI…
Browse files Browse the repository at this point in the history
…-10457] (#3992)
  • Loading branch information
rphilippen authored Jan 8, 2025
1 parent f375705 commit a10fbd0
Show file tree
Hide file tree
Showing 4 changed files with 176 additions and 67 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ describe('useUrlSyncedState', () => {
initialState: '',
serializer: (state) => [['key', state]],
deserializer: (params) => params.get('key') ?? '',
sync: true,
}),
);
act(() => result.current[1]('value'));
Expand All @@ -23,34 +22,34 @@ describe('useUrlSyncedState', () => {
it('allows to serialize the state value into multiple parameters', () => {
const {result} = renderHook(() =>
useUrlSyncedState({
initialState: {key1: '', key2: ''},
serializer: (state) => [
['key1', state.key1],
['key2', state.key2],
],
deserializer: (params) => ({
key1: params.get('key1') ?? '',
key2: params.get('key2') ?? '',
}),
sync: true,
initialState: new Date(),
serializer: (state) => {
const iso = state.toISOString();
return [
['date', iso.substring(0, 10)],
['time', iso.substring(11, 24)],
];
},
deserializer: (params) =>
new Date(`${params.get('date') ?? '2025-01-01'}T${params.get('time') ?? '00:00:00.000Z'}`),
}),
);
act(() => result.current[1]({key1: 'value1', key2: 'value2'}));
expect(window.location.search).toBe('?key1=value1&key2=value2');
act(() => result.current[1](new Date(Date.UTC(2025, 0, 31, 12, 34, 56, 789))));
expect(window.location.search).toBe('?date=2025-01-31&time=12%3A34%3A56.789Z');
});

it('removes the parameter from the url if the state serializes to the same value as the initial state', () => {
const {result} = renderHook(() =>
useUrlSyncedState({
initialState: 'initial',
serializer: (state) => [['key', state]],
deserializer: (params) => params.get('key') ?? '',
initialState: true,
serializer: (state) => [['key', state ? 'true' : 'false']],
deserializer: (params) => params.get('key') === 'true',
sync: true,
}),
);
act(() => result.current[1]('value'));
expect(window.location.search).toBe('?key=value');
act(() => result.current[1]('initial'));
act(() => result.current[1](false));
expect(window.location.search).toBe('?key=false');
act(() => result.current[1](true));
expect(window.location.search).toBe('');
});

Expand All @@ -60,7 +59,6 @@ describe('useUrlSyncedState', () => {
initialState: 'initial',
serializer: (state) => [['key', state]],
deserializer: (params) => params.get('key') ?? '',
sync: true,
}),
);
act(() => result.current[1]('value'));
Expand Down Expand Up @@ -91,7 +89,6 @@ describe('useUrlSyncedState', () => {
initialState: 'initial',
serializer: (state) => [['key', state]],
deserializer: (params) => params.get('key') ?? '',
sync: true,
}),
);
expect(result.current[0]).toBe('value');
Expand All @@ -110,4 +107,60 @@ describe('useUrlSyncedState', () => {
);
expect(result.current[0]).toBe('initial');
});

describe('with hash router urls', () => {
it('reads values from the hash parameters', () => {
window.history.replaceState(null, '', '?key=unexpected#/hash/route?key=value');

const {result} = renderHook(() =>
useUrlSyncedState({
initialState: 'initial',
serializer: (state) => [['key', state]],
deserializer: (params) => params.get('key') ?? '',
}),
);
expect(result.current[0]).toBe('value');
});

it('serializes the state values to the hash route parameters', () => {
window.history.replaceState(null, '', '?a=untouched#/hash/route');

const {result} = renderHook(() =>
useUrlSyncedState({
initialState: {a: null, b: null},
serializer: (state) => [
['a', state.a],
['b', state.b],
],
deserializer: (params) => ({a: params.get('a') ?? '', b: params.get('b')}),
}),
);
act(() => result.current[1]({a: 'test', b: 'state'}));
expect(result.current[0]).toStrictEqual({a: 'test', b: 'state'});
expect(window.location.search).toBe('?a=untouched');
expect(window.location.hash).toBe('#/hash/route?a=test&b=state');
});

it('removes the state values from the hash route parameters', () => {
window.history.replaceState(null, '', '?a=untouched&b=part-of-search#/hash/route?a=1&b=2');

const {result} = renderHook(() =>
useUrlSyncedState<{a: number | null; b: number}>({
initialState: {a: 13, b: 37},
serializer: (state) => [
['a', state.a?.toString()],
['b', state.b?.toString()],
],
deserializer: (params) => ({
a: Number.parseInt(params.get('a') ?? '0', 10),
b: Number.parseInt(params.get('b') ?? '0', 10),
}),
}),
);
act(() => result.current[1]({a: null, b: 37}));
expect(result.current[0]).toStrictEqual({a: null, b: 37});
expect(window.location.search).toBe('?a=untouched&b=part-of-search');
expect(window.location.hash).toBe('#/hash/route');
});
});
});
1 change: 1 addition & 0 deletions packages/mantine/src/components/table/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export {type TablePredicateProps} from './table-predicate/TablePredicate';
export {type TableAction, type TableLayout, type TableLayoutProps, type TableProps} from './Table.types';
export {useTableContext} from './TableContext';
export {useTable, type TableState, type TableStore, type UseTableOptions} from './use-table';
export {useUrlSyncedState} from './use-url-synced-state';
19 changes: 12 additions & 7 deletions packages/mantine/src/components/table/use-table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,11 @@ const defaultState: Partial<TableState> = {
export const useTable = <TData>(userOptions: UseTableOptions<TData> = {}): TableStore<TData> => {
const options = defaultsDeep({}, userOptions, defaultOptions) as UseTableOptions<TData>;
const initialState = defaultsDeep({}, options.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;

// synced with url
const [pagination, setPagination] = useUrlSyncedState<TableState<TData>['pagination']>({
Expand All @@ -234,7 +239,7 @@ export const useTable = <TData>(userOptions: UseTableOptions<TData> = {}): Table
},
initialState.pagination,
),
sync: options.syncWithUrl,
sync,
});
const [sorting, setSorting] = useUrlSyncedState<TableState<TData>['sorting']>({
initialState: initialState.sorting,
Expand All @@ -251,13 +256,13 @@ export const useTable = <TData>(userOptions: UseTableOptions<TData> = {}): Table
return {id, desc: order === 'desc'};
});
},
sync: options.syncWithUrl,
sync,
});
const [globalFilter, setGlobalFilter] = useUrlSyncedState<TableState<TData>['globalFilter']>({
initialState: initialState.globalFilter,
serializer: (filter) => [['filter', filter]],
deserializer: (params) => params.get('filter') ?? initialState.globalFilter,
sync: options.syncWithUrl,
sync,
});
const [predicates, setPredicates] = useUrlSyncedState<TableState<TData>['predicates']>({
initialState: initialState.predicates,
Expand All @@ -270,13 +275,13 @@ export const useTable = <TData>(userOptions: UseTableOptions<TData> = {}): Table
},
{} as TableState<TData>['predicates'],
),
sync: options.syncWithUrl,
sync,
});
const [layout, setLayout] = useUrlSyncedState<TableState<TData>['layout']>({
initialState: initialState.layout,
serializer: (_layout) => [['layout', _layout]],
deserializer: (params) => params.get('layout') ?? initialState.layout,
sync: options.syncWithUrl,
sync,
});
const [dateRange, setDateRange] = useUrlSyncedState<TableState<TData>['dateRange']>({
initialState: initialState.dateRange,
Expand All @@ -288,7 +293,7 @@ export const useTable = <TData>(userOptions: UseTableOptions<TData> = {}): Table
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,
sync,
});
const [columnVisibility, setColumnVisibility] = useUrlSyncedState<TableState<TData>['columnVisibility']>({
initialState: initialState.columnVisibility,
Expand Down Expand Up @@ -323,7 +328,7 @@ export const useTable = <TData>(userOptions: UseTableOptions<TData> = {}): Table
});
return columns;
},
sync: options.syncWithUrl,
sync,
});

// unsynced
Expand Down
128 changes: 89 additions & 39 deletions packages/mantine/src/components/table/use-url-synced-state.ts
Original file line number Diff line number Diff line change
@@ -1,70 +1,120 @@
import {useMemo, useState} from 'react';
import {Dispatch, SetStateAction, 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);
/**
* A search param entry defines the key (index 0) and value (index 1) of a search parameter.
*/
export type SearchParamEntry = [string, string | null | undefined];

/**
* Iterates over the `SearchParamEntry` values, and applies them to `target`, optionally filtering values.
*
* @param target The target to write values to, can be a Map (for the initial values) or `URLSearchParams`.
* @param entries The entries to apply (as returned by the serializer).
* @param filter Optional filter that allows to treat non-empty values as empty (e.g. to not set default values).
*/
const applyValues = (
target: Map<string, string> | URLSearchParams,
entries: Iterable<SearchParamEntry>,
filter: (entry: Readonly<SearchParamEntry>) => boolean,
): void => {
for (const entry of entries) {
if (entry[1] && filter(entry)) {
target.set(entry[0], entry[1]);
} else {
target.delete(entry[0]);
}
}
window.history.replaceState(null, '', url.toString());
};

const getSearchParams = (): URLSearchParams => {
const url = new URL(window.location.href);
return url.searchParams;
/**
* Read the **current** search params from `window.location`, with support for detecting React's HashRouter.
* Also returns a method that will yield the href (string) value, after any changes made on the params object.
*
* @returns The `URLSearchParams` instance, and a function that can be used to get an updated href.
*/
const getSearchParams = (): [URLSearchParams, () => string] => {
const href = window.location.href;
// Search for '#/' to detect hash router urls
const searchStart = href.indexOf('?', href.indexOf('#/') + 1);
const params = new URLSearchParams(searchStart < 0 ? '' : href.substring(searchStart));
return [
params,
() => {
let result = searchStart < 0 ? href : href.substring(0, searchStart);
if (params.size > 0) {
result = result.concat('?', params.toString());
}
return result;
},
];
};

export interface UseUrlSyncedStateOptions<T> {
/**
* The initial state
* The initial state to use, if there would be no search params to deserialize from.
* These values are also treated as defaults, and if the current state matches the initialState,
* no value will be written to the search params.
*/
initialState: T;
initialState: T extends object ? Readonly<T> : T;
/**
* The serializer function is used to determine how the state is translated to url search parameters.
* Called each time the state changes.
* Note that the serializer should always return entries for keys it controls, also if the current value is "unset" (`null` or empty).
* This ensures params get removed from the search when they are being unset.
*
* @param stateValue The new state value to serialize.
* @returns A list of [key, value] to set as url search parameters.
* @returns An iterable of `[key, value]` to set as url search parameters.
* @example (filterValue) => [['filter', filterValue]] // ?filter=filterValue
*/
serializer: (stateValue: T) => Array<[string, string]>;
serializer: (stateValue: T) => Iterable<SearchParamEntry>;
/**
* The deserializer function is used to determine how the url parameters influence the initial state.
* May return a partial state, values that are not deserialed are taken from the `initialState`.
* 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.
* Whether the state should be synced with the url, defaults to `true`.
* 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]);
export const useUrlSyncedState = <T>(options: UseUrlSyncedStateOptions<T>) => {
const sync = options.sync !== false;
const initialState = useMemo(
() => (sync ? options.deserializer(getSearchParams()[0]) : options.initialState),
[options.initialState, options.sync],
);
const [state, setState] = useState<T>(initialState);
// Capture the initial state as a map, to compare values and not set them if they match.
const initialStateSerialized = useMemo(() => {
const v = new Map<string, string>();
applyValues(v, options.serializer(options.initialState), (_) => true);
return v;
}, [initialState, options.serializer]);
const enhancedSetState = useMemo<Dispatch<SetStateAction<T>>>(
() =>
sync
? (updater: SetStateAction<T>) => {
setState((old) => {
const [search, getUrl] = getSearchParams();
const newValue = updater instanceof Function ? updater(old) : updater;
applyValues(
search,
options.serializer(newValue),
([key, value]) => initialStateSerialized.get(key) !== value,
);
window.history.replaceState(null, '', getUrl());
return newValue;
});
}
: setState,
[sync],
);

return [state, enhancedSetState] as const;
};

0 comments on commit a10fbd0

Please sign in to comment.