diff --git a/frontend/src/hooks/useTableState.test.ts b/frontend/src/hooks/useTableState.test.ts new file mode 100644 index 0000000000..ce2f716d4f --- /dev/null +++ b/frontend/src/hooks/useTableState.test.ts @@ -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', + }); + }); +}); diff --git a/frontend/src/hooks/useTableState.ts b/frontend/src/hooks/useTableState.ts new file mode 100644 index 0000000000..8e2ad79351 --- /dev/null +++ b/frontend/src/hooks/useTableState.ts @@ -0,0 +1,82 @@ +import { useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { createLocalStorage } from '../utils/createLocalStorage'; + +const filterObjectKeys = >( + obj: T, + keys: Array, +) => + 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 = >( + defaultParams: Params, + storageId: string, + queryKeys?: Array, + storageKeys?: Array, +) => { + 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, 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; +};