1
0
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:
Tymoteusz Czech 2023-11-21 11:25:31 +01:00 committed by GitHub
parent ae375703d2
commit d5049e6197
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 373 additions and 0 deletions

View 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',
});
});
});

View 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;
};