mirror of
https://github.com/Unleash/unleash.git
synced 2025-02-23 00:22:19 +01:00
feat: useTableState hook (#5362)
Simplified logic for handling interaction between URL (query), table state and localstorage.
This commit is contained in:
parent
ae375703d2
commit
d5049e6197
291
frontend/src/hooks/useTableState.test.ts
Normal file
291
frontend/src/hooks/useTableState.test.ts
Normal file
@ -0,0 +1,291 @@
|
||||
import { vi, type Mock } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { useTableState } from './useTableState';
|
||||
import { createLocalStorage } from 'utils/createLocalStorage';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { act } from 'react-test-renderer';
|
||||
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useSearchParams: vi.fn(() => [new URLSearchParams(), vi.fn()]),
|
||||
}));
|
||||
vi.mock('../utils/createLocalStorage', () => ({
|
||||
createLocalStorage: vi.fn(() => ({
|
||||
value: {},
|
||||
setValue: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockStorage = createLocalStorage as Mock;
|
||||
const mockQuery = useSearchParams as Mock;
|
||||
|
||||
describe('useTableState', () => {
|
||||
beforeEach(() => {
|
||||
mockStorage.mockRestore();
|
||||
mockQuery.mockRestore();
|
||||
});
|
||||
|
||||
it('should return default params', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableState<{
|
||||
page: '0';
|
||||
pageSize: '10';
|
||||
}>({ page: '0', pageSize: '10' }, 'test', [], []),
|
||||
);
|
||||
expect(result.current[0]).toEqual({ page: '0', pageSize: '10' });
|
||||
});
|
||||
|
||||
it('should return params from local storage', () => {
|
||||
mockStorage.mockReturnValue({
|
||||
value: { pageSize: 25 },
|
||||
setValue: vi.fn(),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTableState({ pageSize: '10' }, 'test', [], []),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toEqual({ pageSize: 25 });
|
||||
});
|
||||
|
||||
it('should return params from url', () => {
|
||||
mockQuery.mockReturnValue([new URLSearchParams('page=1'), vi.fn()]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTableState({ page: '0' }, 'test', [], []),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toEqual({ page: '1' });
|
||||
});
|
||||
|
||||
it('should use params from url over local storage', () => {
|
||||
mockQuery.mockReturnValue([
|
||||
new URLSearchParams('page=2&pageSize=25'),
|
||||
vi.fn(),
|
||||
]);
|
||||
mockStorage.mockReturnValue({
|
||||
value: { pageSize: '10', sortOrder: 'desc' },
|
||||
setValue: vi.fn(),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTableState({ page: '1', pageSize: '5' }, 'test', [], []),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toEqual({
|
||||
page: '2',
|
||||
pageSize: '25',
|
||||
});
|
||||
});
|
||||
|
||||
it('sets local state', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableState({ page: '1' }, 'test', [], []),
|
||||
);
|
||||
const setParams = result.current[1];
|
||||
|
||||
act(() => {
|
||||
setParams({ page: '2' });
|
||||
});
|
||||
|
||||
expect(result.current[0]).toEqual({ page: '2' });
|
||||
});
|
||||
|
||||
it('keeps previous state', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableState({ page: '1', pageSize: '10' }, 'test', [], []),
|
||||
);
|
||||
const setParams = result.current[1];
|
||||
|
||||
act(() => {
|
||||
setParams({ page: '2' });
|
||||
});
|
||||
|
||||
expect(result.current[0]).toEqual({ page: '2', pageSize: '10' });
|
||||
});
|
||||
|
||||
it('removes params from previous state', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableState({ page: '1', pageSize: '10' }, 'test', [], []),
|
||||
);
|
||||
const setParams = result.current[1];
|
||||
|
||||
act(() => {
|
||||
setParams({ pageSize: undefined });
|
||||
});
|
||||
|
||||
expect(result.current[0]).toEqual({ page: '1' });
|
||||
|
||||
// ensure that there are no keys with undefined values
|
||||
expect(Object.keys(result.current[0])).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('removes params from url', () => {
|
||||
const querySetter = vi.fn();
|
||||
mockQuery.mockReturnValue([new URLSearchParams('page=2'), querySetter]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTableState(
|
||||
{ page: '1', pageSize: '10' },
|
||||
'test',
|
||||
['page', 'pageSize'],
|
||||
[],
|
||||
),
|
||||
);
|
||||
const setParams = result.current[1];
|
||||
|
||||
expect(result.current[0]).toEqual({ page: '2', pageSize: '10' });
|
||||
|
||||
act(() => {
|
||||
setParams({ page: '10', pageSize: undefined });
|
||||
});
|
||||
|
||||
expect(result.current[0]).toEqual({ page: '10' });
|
||||
|
||||
expect(querySetter).toHaveBeenCalledWith({
|
||||
page: '10',
|
||||
});
|
||||
});
|
||||
|
||||
it('removes params from local storage', () => {
|
||||
const storageSetter = vi.fn();
|
||||
mockStorage.mockReturnValue({
|
||||
value: { sortBy: 'type' },
|
||||
setValue: storageSetter,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTableState(
|
||||
{ sortBy: 'createdAt', pageSize: '10' },
|
||||
'test',
|
||||
[],
|
||||
['sortBy', 'pageSize'],
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toEqual({ sortBy: 'type', pageSize: '10' });
|
||||
|
||||
act(() => {
|
||||
result.current[1]({ pageSize: undefined });
|
||||
});
|
||||
|
||||
expect(result.current[0]).toEqual({ sortBy: 'type' });
|
||||
|
||||
expect(storageSetter).toHaveBeenCalledWith({
|
||||
sortBy: 'type',
|
||||
});
|
||||
});
|
||||
|
||||
test('saves default parameters if not explicitly provided', (key) => {
|
||||
const querySetter = vi.fn();
|
||||
const storageSetter = vi.fn();
|
||||
mockQuery.mockReturnValue([new URLSearchParams(), querySetter]);
|
||||
mockStorage.mockReturnValue({
|
||||
value: {},
|
||||
setValue: storageSetter,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTableState({}, 'test'));
|
||||
|
||||
act(() => {
|
||||
result.current[1]({
|
||||
unspecified: 'test',
|
||||
page: '2',
|
||||
pageSize: '10',
|
||||
search: 'test',
|
||||
sortBy: 'type',
|
||||
sortOrder: 'favorites',
|
||||
favorites: 'false',
|
||||
columns: ['test', 'id'],
|
||||
});
|
||||
});
|
||||
|
||||
expect(storageSetter).toHaveBeenCalledTimes(1);
|
||||
expect(storageSetter).toHaveBeenCalledWith({
|
||||
pageSize: '10',
|
||||
search: 'test',
|
||||
sortBy: 'type',
|
||||
sortOrder: 'favorites',
|
||||
favorites: 'false',
|
||||
columns: ['test', 'id'],
|
||||
});
|
||||
expect(querySetter).toHaveBeenCalledTimes(1);
|
||||
expect(querySetter).toHaveBeenCalledWith({
|
||||
page: '2',
|
||||
pageSize: '10',
|
||||
search: 'test',
|
||||
sortBy: 'type',
|
||||
sortOrder: 'favorites',
|
||||
favorites: 'false',
|
||||
columns: ['test', 'id'],
|
||||
});
|
||||
});
|
||||
|
||||
it("doesn't save default params if explicitly specified", () => {
|
||||
const storageSetter = vi.fn();
|
||||
mockStorage.mockReturnValue({
|
||||
value: {},
|
||||
setValue: storageSetter,
|
||||
});
|
||||
const querySetter = vi.fn();
|
||||
mockQuery.mockReturnValue([new URLSearchParams(), querySetter]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTableState<{
|
||||
[key: string]: string | string[];
|
||||
}>({}, 'test', ['saveOnlyThisToUrl'], ['page']),
|
||||
);
|
||||
const setParams = result.current[1];
|
||||
|
||||
act(() => {
|
||||
setParams({
|
||||
saveOnlyThisToUrl: 'test',
|
||||
page: '2',
|
||||
pageSize: '10',
|
||||
search: 'test',
|
||||
sortBy: 'type',
|
||||
sortOrder: 'favorites',
|
||||
favorites: 'false',
|
||||
columns: ['test', 'id'],
|
||||
});
|
||||
});
|
||||
|
||||
expect(querySetter).toHaveBeenCalledWith({ saveOnlyThisToUrl: 'test' });
|
||||
expect(storageSetter).toHaveBeenCalledWith({ page: '2' });
|
||||
});
|
||||
|
||||
it('can reset state to the default instead of overwriting', () => {
|
||||
mockStorage.mockReturnValue({
|
||||
value: { pageSize: 25 },
|
||||
setValue: vi.fn(),
|
||||
});
|
||||
mockQuery.mockReturnValue([new URLSearchParams('page=4'), vi.fn()]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTableState<{
|
||||
page: string;
|
||||
pageSize?: string;
|
||||
sortBy?: string;
|
||||
}>({ page: '1', pageSize: '10' }, 'test'),
|
||||
);
|
||||
|
||||
const setParams = result.current[1];
|
||||
|
||||
act(() => {
|
||||
setParams({ sortBy: 'type' });
|
||||
});
|
||||
expect(result.current[0]).toEqual({
|
||||
page: '4',
|
||||
pageSize: '10',
|
||||
sortBy: 'type',
|
||||
});
|
||||
|
||||
act(() => {
|
||||
setParams({ pageSize: '50' }, true);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toEqual({
|
||||
page: '1',
|
||||
pageSize: '50',
|
||||
});
|
||||
});
|
||||
});
|
82
frontend/src/hooks/useTableState.ts
Normal file
82
frontend/src/hooks/useTableState.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { createLocalStorage } from '../utils/createLocalStorage';
|
||||
|
||||
const filterObjectKeys = <T extends Record<string, unknown>>(
|
||||
obj: T,
|
||||
keys: Array<keyof T>,
|
||||
) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(obj).filter(([key]) => keys.includes(key as keyof T)),
|
||||
) as T;
|
||||
|
||||
const defaultStoredKeys = [
|
||||
'pageSize',
|
||||
'search',
|
||||
'sortBy',
|
||||
'sortOrder',
|
||||
'favorites',
|
||||
'columns',
|
||||
];
|
||||
const defaultQueryKeys = [...defaultStoredKeys, 'page'];
|
||||
|
||||
/**
|
||||
* There are 3 sources of params, in order of priority:
|
||||
* 1. local state
|
||||
* 2. search params from the url
|
||||
* 3. stored params in local storage
|
||||
* 4. default parameters
|
||||
*
|
||||
* `queryKeys` will be saved in the url
|
||||
* `storedKeys` will be saved in local storage
|
||||
*
|
||||
* @param defaultParams initial state
|
||||
* @param storageId identifier for the local storage
|
||||
* @param queryKeys array of elements to be saved in the url
|
||||
* @param storageKeys array of elements to be saved in local storage
|
||||
*/
|
||||
export const useTableState = <Params extends Record<string, string | string[]>>(
|
||||
defaultParams: Params,
|
||||
storageId: string,
|
||||
queryKeys?: Array<keyof Params>,
|
||||
storageKeys?: Array<keyof Params>,
|
||||
) => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { value: storedParams, setValue: setStoredParams } =
|
||||
createLocalStorage(`${storageId}:tableQuery`, defaultParams);
|
||||
|
||||
const searchQuery = Object.fromEntries(searchParams.entries());
|
||||
const [params, setParams] = useState({
|
||||
...defaultParams,
|
||||
...(Object.keys(searchQuery).length ? {} : storedParams),
|
||||
...searchQuery,
|
||||
} as Params);
|
||||
|
||||
const updateParams = (value: Partial<Params>, reset = false) => {
|
||||
const newState: Params = reset
|
||||
? { ...defaultParams, ...value }
|
||||
: {
|
||||
...params,
|
||||
...value,
|
||||
};
|
||||
|
||||
// remove keys with undefined values
|
||||
Object.keys(newState).forEach((key) => {
|
||||
if (newState[key] === undefined) {
|
||||
delete newState[key];
|
||||
}
|
||||
});
|
||||
|
||||
setParams(newState);
|
||||
setSearchParams(
|
||||
filterObjectKeys(newState, queryKeys || defaultQueryKeys),
|
||||
);
|
||||
setStoredParams(
|
||||
filterObjectKeys(newState, storageKeys || defaultStoredKeys),
|
||||
);
|
||||
|
||||
return params;
|
||||
};
|
||||
|
||||
return [params, updateParams] as const;
|
||||
};
|
Loading…
Reference in New Issue
Block a user