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