Просмотр исходного кода

test(hooks): add tests for useTableUrlState

satnaing 2 месяцев назад
Родитель
Сommit
7b3db0c885
1 измененных файлов с 376 добавлено и 0 удалено
  1. 376 0
      src/hooks/use-table-url-state.test.ts

+ 376 - 0
src/hooks/use-table-url-state.test.ts

@@ -0,0 +1,376 @@
+import { type Mock, describe, expect, it, vi } from 'vitest'
+import { renderHook } from 'vitest-browser-react'
+import { type NavigateFn, useTableUrlState } from '@/hooks/use-table-url-state'
+
+function lastNavigateOpts(navigate: Mock<NavigateFn>) {
+  const calls = navigate.mock.calls
+  return calls[calls.length - 1]?.[0]
+}
+
+function applyLastSearchFn(
+  navigate: Mock<NavigateFn>,
+  prev: Record<string, unknown>
+) {
+  const opts = lastNavigateOpts(navigate)
+  expect(opts).toBeDefined()
+  const s = opts!.search
+  if (typeof s === 'function') {
+    return s(prev) as Record<string, unknown>
+  }
+  return s as Record<string, unknown>
+}
+
+describe('useTableUrlState', () => {
+  it('derives pagination from search with defaults', async () => {
+    const navigate = vi.fn() as Mock<NavigateFn>
+    const { result } = await renderHook(() =>
+      useTableUrlState({
+        search: { page: 3, pageSize: 25 },
+        navigate,
+        pagination: { defaultPage: 1, defaultPageSize: 10 },
+      })
+    )
+
+    expect(result.current.pagination).toEqual({
+      pageIndex: 2,
+      pageSize: 25,
+    })
+  })
+
+  it('uses default page and pageSize when search omits them', async () => {
+    const navigate = vi.fn() as Mock<NavigateFn>
+    const { result } = await renderHook(() =>
+      useTableUrlState({
+        search: {},
+        navigate,
+        pagination: { defaultPage: 1, defaultPageSize: 10 },
+      })
+    )
+
+    expect(result.current.pagination).toEqual({
+      pageIndex: 0,
+      pageSize: 10,
+    })
+  })
+
+  it('clamps negative effective page via pageIndex', async () => {
+    const navigate = vi.fn() as Mock<NavigateFn>
+    const { result } = await renderHook(() =>
+      useTableUrlState({
+        search: { page: 0 },
+        navigate,
+        pagination: { defaultPage: 1, defaultPageSize: 10 },
+      })
+    )
+
+    expect(result.current.pagination.pageIndex).toBe(0)
+  })
+
+  it('onPaginationChange omits page and pageSize from search when they match defaults', async () => {
+    const navigate = vi.fn() as Mock<NavigateFn>
+    const prev = { page: 2, pageSize: 20, filter: 'q' }
+    const { result, act } = await renderHook(() =>
+      useTableUrlState({
+        search: prev,
+        navigate,
+        pagination: { defaultPage: 1, defaultPageSize: 10 },
+      })
+    )
+
+    await act(() => {
+      result.current.onPaginationChange({
+        pageIndex: 0,
+        pageSize: 10,
+      })
+    })
+
+    expect(applyLastSearchFn(navigate, prev)).toMatchObject({
+      page: undefined,
+      pageSize: undefined,
+      filter: 'q',
+    })
+  })
+
+  it('onPaginationChange writes non-default page and pageSize', async () => {
+    const navigate = vi.fn() as Mock<NavigateFn>
+    const prev = { filter: 'x' }
+    const { result, act } = await renderHook(() =>
+      useTableUrlState({
+        search: { ...prev, page: 1, pageSize: 10 },
+        navigate,
+        pagination: { defaultPage: 1, defaultPageSize: 10 },
+      })
+    )
+
+    await act(() => {
+      result.current.onPaginationChange({
+        pageIndex: 2,
+        pageSize: 25,
+      })
+    })
+
+    expect(applyLastSearchFn(navigate, prev)).toMatchObject({
+      page: 3,
+      pageSize: 25,
+      filter: 'x',
+    })
+  })
+
+  it('supports custom pagination search keys', async () => {
+    const navigate = vi.fn() as Mock<NavigateFn>
+    const { result } = await renderHook(() =>
+      useTableUrlState({
+        search: { p: 2, ps: 5 },
+        navigate,
+        pagination: {
+          pageKey: 'p',
+          pageSizeKey: 'ps',
+          defaultPage: 1,
+          defaultPageSize: 10,
+        },
+      })
+    )
+
+    expect(result.current.pagination).toEqual({
+      pageIndex: 1,
+      pageSize: 5,
+    })
+  })
+
+  it('reads globalFilter from search and onGlobalFilterChange updates URL and clears page', async () => {
+    const navigate = vi.fn() as Mock<NavigateFn>
+    const { result, act } = await renderHook(() =>
+      useTableUrlState({
+        search: { page: 2, filter: 'hello' },
+        navigate,
+        pagination: { defaultPage: 1, defaultPageSize: 10 },
+        globalFilter: { enabled: true, key: 'filter' },
+      })
+    )
+
+    expect(result.current.globalFilter).toBe('hello')
+
+    await act(() => {
+      result.current.onGlobalFilterChange?.('  next  ')
+    })
+
+    expect(applyLastSearchFn(navigate, { page: 2, filter: 'hello' })).toEqual({
+      page: undefined,
+      filter: 'next',
+    })
+  })
+
+  it('clears filter key in URL when global filter becomes empty after trim', async () => {
+    const navigate = vi.fn() as Mock<NavigateFn>
+    const { result, act } = await renderHook(() =>
+      useTableUrlState({
+        search: { filter: 'x' },
+        navigate,
+        pagination: { defaultPage: 1, defaultPageSize: 10 },
+        globalFilter: { enabled: true, key: 'filter' },
+      })
+    )
+
+    await act(() => {
+      result.current.onGlobalFilterChange?.('   ')
+    })
+
+    expect(applyLastSearchFn(navigate, { filter: 'x' })).toMatchObject({
+      filter: undefined,
+    })
+  })
+
+  it('does not trim global filter when trim is false', async () => {
+    const navigate = vi.fn() as Mock<NavigateFn>
+    const { result, act } = await renderHook(() =>
+      useTableUrlState({
+        search: {},
+        navigate,
+        pagination: { defaultPage: 1, defaultPageSize: 10 },
+        globalFilter: { enabled: true, key: 'filter', trim: false },
+      })
+    )
+
+    await act(() => {
+      result.current.onGlobalFilterChange?.('  spaced  ')
+    })
+
+    expect(applyLastSearchFn(navigate, {})).toMatchObject({
+      filter: '  spaced  ',
+    })
+  })
+
+  it('omits globalFilter and onGlobalFilterChange when global filter is disabled', async () => {
+    const navigate = vi.fn() as Mock<NavigateFn>
+    const { result } = await renderHook(() =>
+      useTableUrlState({
+        search: { filter: 'ignored' },
+        navigate,
+        pagination: { defaultPage: 1, defaultPageSize: 10 },
+        globalFilter: { enabled: false },
+      })
+    )
+
+    expect(result.current.globalFilter).toBeUndefined()
+    expect(result.current.onGlobalFilterChange).toBeUndefined()
+  })
+
+  it('builds array column filters from search', async () => {
+    const navigate = vi.fn() as Mock<NavigateFn>
+    const { result } = await renderHook(() =>
+      useTableUrlState({
+        search: { status: ['todo', 'done'], priority: ['high'] },
+        navigate,
+        pagination: { defaultPage: 1, defaultPageSize: 10 },
+        columnFilters: [
+          { columnId: 'status', searchKey: 'status', type: 'array' },
+          { columnId: 'priority', searchKey: 'priority', type: 'array' },
+        ],
+      })
+    )
+
+    expect(result.current.columnFilters).toEqual([
+      { id: 'status', value: ['todo', 'done'] },
+      { id: 'priority', value: ['high'] },
+    ])
+  })
+
+  it('builds string column filters from search', async () => {
+    const navigate = vi.fn() as Mock<NavigateFn>
+    const { result } = await renderHook(() =>
+      useTableUrlState({
+        search: { q: '  find me  ' },
+        navigate,
+        pagination: { defaultPage: 1, defaultPageSize: 10 },
+        columnFilters: [{ columnId: 'title', searchKey: 'q', type: 'string' }],
+      })
+    )
+
+    expect(result.current.columnFilters).toEqual([
+      { id: 'title', value: '  find me  ' },
+    ])
+  })
+
+  it('onColumnFiltersChange merges serialized filters into search and clears page', async () => {
+    const navigate = vi.fn() as Mock<NavigateFn>
+    const prev = { page: 3, status: ['old'], other: 1 }
+    const { result, act } = await renderHook(() =>
+      useTableUrlState({
+        search: prev,
+        navigate,
+        pagination: { defaultPage: 1, defaultPageSize: 10 },
+        columnFilters: [
+          { columnId: 'status', searchKey: 'status', type: 'array' },
+          { columnId: 'priority', searchKey: 'priority', type: 'array' },
+        ],
+      })
+    )
+
+    await act(() => {
+      result.current.onColumnFiltersChange([
+        { id: 'status', value: ['todo'] },
+        { id: 'priority', value: [] },
+      ])
+    })
+
+    expect(applyLastSearchFn(navigate, prev)).toEqual({
+      page: undefined,
+      status: ['todo'],
+      priority: undefined,
+      other: 1,
+    })
+  })
+
+  it('ensurePageInRange navigates with replace when current page exceeds pageCount', async () => {
+    const navigate = vi.fn() as Mock<NavigateFn>
+    const { result, act } = await renderHook(() =>
+      useTableUrlState({
+        search: { page: 5 },
+        navigate,
+        pagination: { defaultPage: 1, defaultPageSize: 10 },
+      })
+    )
+
+    await act(() => {
+      result.current.ensurePageInRange(2)
+    })
+
+    expect(navigate).toHaveBeenCalledTimes(1)
+    expect(lastNavigateOpts(navigate)?.replace).toBe(true)
+    expect(applyLastSearchFn(navigate, { page: 5, filter: 'x' })).toMatchObject(
+      {
+        page: undefined,
+        filter: 'x',
+      }
+    )
+  })
+
+  it('ensurePageInRange resets to last page when resetTo is last', async () => {
+    const navigate = vi.fn() as Mock<NavigateFn>
+    const { result, act } = await renderHook(() =>
+      useTableUrlState({
+        search: { page: 9 },
+        navigate,
+        pagination: { defaultPage: 1, defaultPageSize: 10 },
+      })
+    )
+
+    await act(() => {
+      result.current.ensurePageInRange(3, { resetTo: 'last' })
+    })
+
+    expect(lastNavigateOpts(navigate)?.replace).toBe(true)
+    expect(applyLastSearchFn(navigate, { page: 9 })).toMatchObject({
+      page: 3,
+    })
+  })
+
+  it('ensurePageInRange does not navigate when page is in range', async () => {
+    const navigate = vi.fn() as Mock<NavigateFn>
+    const { result, act } = await renderHook(() =>
+      useTableUrlState({
+        search: { page: 2 },
+        navigate,
+        pagination: { defaultPage: 1, defaultPageSize: 10 },
+      })
+    )
+
+    await act(() => {
+      result.current.ensurePageInRange(5)
+    })
+
+    expect(navigate).not.toHaveBeenCalled()
+  })
+
+  it('uses custom serialize and deserialize for column filters', async () => {
+    const navigate = vi.fn() as Mock<NavigateFn>
+    const { result, act } = await renderHook(() =>
+      useTableUrlState({
+        search: { tag: 'a|b' },
+        navigate,
+        pagination: { defaultPage: 1, defaultPageSize: 10 },
+        columnFilters: [
+          {
+            columnId: 'tag',
+            searchKey: 'tag',
+            type: 'array',
+            deserialize: (v) => (typeof v === 'string' ? v.split('|') : []),
+            serialize: (v) => (Array.isArray(v) ? v.join('|') : v),
+          },
+        ],
+      })
+    )
+
+    expect(result.current.columnFilters).toEqual([
+      { id: 'tag', value: ['a', 'b'] },
+    ])
+
+    await act(() => {
+      result.current.onColumnFiltersChange([{ id: 'tag', value: ['x', 'y'] }])
+    })
+
+    expect(applyLastSearchFn(navigate, { tag: 'a|b' })).toMatchObject({
+      tag: 'x|y',
+    })
+  })
+})