Files
portainer/app/react/hooks/useParamState.test.ts

291 lines
7.7 KiB
TypeScript

import { renderHook, act } from '@testing-library/react-hooks';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { keyBuilder } from './useLocalStorage';
import {
useParamState,
useParamsState,
usePersistedParamsState,
} from './useParamState';
const mockGo = vi.fn();
let mockUrlParams: Record<string, unknown> = {};
vi.mock('@uirouter/react', () => ({
useCurrentStateAndParams: () => ({ params: mockUrlParams }),
useRouter: () => ({ stateService: { go: mockGo } }),
}));
type State = {
status: string | null;
sort: string;
search: string;
};
function parse(params: Record<string, unknown>): State {
return {
status: typeof params.status === 'string' ? params.status : null,
sort: typeof params.sort === 'string' ? params.sort : 'name',
search: typeof params.search === 'string' ? params.search : '',
};
}
const STORAGE_KEY = 'test-persisted';
const STORAGE_KEY_FULL = keyBuilder(STORAGE_KEY);
const PERSIST = { storageKey: STORAGE_KEY, persistedKeys: ['status', 'sort'] };
describe('useParamState', () => {
beforeEach(() => {
mockUrlParams = {};
mockGo.mockClear();
});
it('returns the param value from URL', () => {
mockUrlParams = { filter: 'active' };
const { result } = renderHook(() => useParamState('filter'));
expect(result.current[0]).toBe('active');
});
it('returns undefined when param is absent', () => {
mockUrlParams = {};
const { result } = renderHook(() => useParamState('filter'));
expect(result.current[0]).toBeUndefined();
});
it('applies a custom parser', () => {
mockUrlParams = { count: '7' };
const { result } = renderHook(() =>
useParamState('count', (v) => parseInt(v ?? '0', 10))
);
expect(result.current[0]).toBe(7);
});
it('setter calls router.go with the new value', () => {
mockUrlParams = { filter: 'active' };
const { result } = renderHook(() => useParamState('filter'));
act(() => {
result.current[1]('inactive');
});
expect(mockGo).toHaveBeenCalledWith(
'.',
{ filter: 'inactive' },
{ reload: false, location: 'replace' }
);
});
});
describe('useParamsState', () => {
beforeEach(() => {
mockUrlParams = {};
mockGo.mockClear();
});
it('returns parsed state from URL params', () => {
mockUrlParams = { a: '1', b: '2' };
const { result } = renderHook(() =>
useParamsState((params) => ({ a: params.a ?? '', b: params.b ?? '' }))
);
expect(result.current[0]).toEqual({ a: '1', b: '2' });
});
it('setState calls router.go with the partial update', () => {
mockUrlParams = { a: '1', b: '2' };
const { result } = renderHook(() =>
useParamsState((params) => ({ a: params.a ?? '', b: params.b ?? '' }))
);
act(() => {
result.current[1]({ a: 'updated' });
});
expect(mockGo).toHaveBeenCalledWith(
'.',
{ a: 'updated' },
{ reload: false, location: 'replace' }
);
});
});
describe('usePersistedParamsState', () => {
beforeEach(() => {
mockUrlParams = {};
mockGo.mockClear();
vi.useFakeTimers();
localStorage.clear();
});
it('returns parsed state from URL params', () => {
mockUrlParams = { status: 'healthy', sort: 'date', search: 'foo' };
const { result } = renderHook(() =>
usePersistedParamsState(parse, PERSIST)
);
expect(result.current[0]).toEqual({
status: 'healthy',
sort: 'date',
search: 'foo',
});
});
it('falls back to stored values when URL has no persisted keys', () => {
localStorage.setItem(
STORAGE_KEY_FULL,
JSON.stringify({ status: 'error', sort: 'date' })
);
mockUrlParams = { search: 'foo' };
const { result } = renderHook(() =>
usePersistedParamsState(parse, PERSIST)
);
expect(result.current[0].status).toBe('error');
expect(result.current[0].sort).toBe('date');
expect(result.current[0].search).toBe('foo');
});
it('URL params take precedence; stored values fill in missing URL params', () => {
localStorage.setItem(
STORAGE_KEY_FULL,
JSON.stringify({ status: 'error', sort: 'date' })
);
mockUrlParams = { status: 'healthy' };
const { result } = renderHook(() =>
usePersistedParamsState(parse, PERSIST)
);
expect(result.current[0].status).toBe('healthy');
expect(result.current[0].sort).toBe('date');
});
it('backfills URL from storage on mount when URL has no persisted keys', () => {
localStorage.setItem(
STORAGE_KEY_FULL,
JSON.stringify({ status: 'error', sort: 'date' })
);
mockUrlParams = {};
renderHook(() => usePersistedParamsState(parse, PERSIST));
expect(mockGo).toHaveBeenCalledWith(
'.',
{ status: 'error', sort: 'date' },
{ reload: false, location: 'replace' }
);
});
it('backfills only missing URL params when some persisted keys are in URL', () => {
localStorage.setItem(
STORAGE_KEY_FULL,
JSON.stringify({ status: 'error', sort: 'date' })
);
mockUrlParams = { status: 'healthy' };
renderHook(() => usePersistedParamsState(parse, PERSIST));
expect(mockGo).toHaveBeenCalledWith(
'.',
{ sort: 'date' },
{ reload: false, location: 'replace' }
);
});
it('does not backfill URL when all persisted keys are already in URL', () => {
localStorage.setItem(
STORAGE_KEY_FULL,
JSON.stringify({ status: 'error', sort: 'date' })
);
mockUrlParams = { status: 'healthy', sort: 'name' };
renderHook(() => usePersistedParamsState(parse, PERSIST));
expect(mockGo).not.toHaveBeenCalled();
});
it('syncs URL params to storage on render when all persisted keys are in URL', () => {
localStorage.setItem(
STORAGE_KEY_FULL,
JSON.stringify({ status: 'error', sort: 'date' })
);
mockUrlParams = { status: 'healthy', sort: 'name' };
renderHook(() => usePersistedParamsState(parse, PERSIST));
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY_FULL)!);
expect(stored.status).toBe('healthy');
expect(stored.sort).toBe('name');
});
it('setState writes persisted keys to localStorage', () => {
mockUrlParams = { status: 'healthy', sort: 'name', search: 'x' };
const { result } = renderHook(() =>
usePersistedParamsState(parse, PERSIST)
);
act(() => {
result.current[1]({ status: 'error' } as Partial<State>);
});
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY_FULL)!);
expect(stored.status).toBe('error');
expect(stored.sort).toBe('name');
});
it('setState does not write non-persisted keys to localStorage', () => {
mockUrlParams = { status: 'healthy', sort: 'name', search: 'x' };
const { result } = renderHook(() =>
usePersistedParamsState(parse, PERSIST)
);
act(() => {
result.current[1]({ search: 'new' } as Partial<State>);
});
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY_FULL)!);
expect(stored).not.toHaveProperty('search');
});
it('does not write to storage when URL has no persisted keys and storage is empty', () => {
mockUrlParams = {};
renderHook(() => usePersistedParamsState(parse, PERSIST));
expect(localStorage.getItem(STORAGE_KEY_FULL)).toBeNull();
expect(mockGo).not.toHaveBeenCalled();
});
it('works without persist options (pure URL state)', () => {
mockUrlParams = { status: 'healthy' };
const { result } = renderHook(() => usePersistedParamsState(parse));
expect(result.current[0].status).toBe('healthy');
act(() => {
result.current[1]({ sort: 'date' } as Partial<State>);
});
expect(mockGo).toHaveBeenCalledWith(
'.',
{ sort: 'date' },
{ reload: false, location: 'replace' }
);
expect(localStorage.getItem(STORAGE_KEY_FULL)).toBeNull();
});
});