mirror of
https://github.com/langgenius/dify.git
synced 2026-02-15 21:33:59 +00:00
Compare commits
5 Commits
feat/defau
...
refactor/d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9487daf71c | ||
|
|
e80bd15d5c | ||
|
|
a8ddc1408e | ||
|
|
5bb4110f85 | ||
|
|
b0bae39696 |
@@ -6,8 +6,11 @@
|
||||
* Validates the data contract between documents page hooks and list component hooks.
|
||||
*/
|
||||
|
||||
import type { UrlUpdateEvent } from 'nuqs/adapters/testing'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { SimpleDocumentDetail } from '@/models/datasets'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DataSourceType } from '@/models/datasets'
|
||||
|
||||
@@ -34,6 +37,17 @@ const { default: useDocumentListQueryState } = await import(
|
||||
|
||||
type LocalDoc = SimpleDocumentDetail & { percent?: number }
|
||||
|
||||
const renderQueryStateHook = (searchParams = '') => {
|
||||
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>()
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<NuqsTestingAdapter searchParams={searchParams} onUrlUpdate={onUrlUpdate}>
|
||||
{children}
|
||||
</NuqsTestingAdapter>
|
||||
)
|
||||
const rendered = renderHook(() => useDocumentListQueryState(), { wrapper })
|
||||
return { ...rendered, onUrlUpdate }
|
||||
}
|
||||
|
||||
const createDoc = (overrides?: Partial<LocalDoc>): LocalDoc => ({
|
||||
id: `doc-${Math.random().toString(36).slice(2, 8)}`,
|
||||
name: 'test-doc.txt',
|
||||
@@ -85,7 +99,7 @@ describe('Document Management Flow', () => {
|
||||
|
||||
describe('URL-based Query State', () => {
|
||||
it('should parse default query from empty URL params', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
const { result } = renderQueryStateHook()
|
||||
|
||||
expect(result.current.query).toEqual({
|
||||
page: 1,
|
||||
@@ -96,31 +110,31 @@ describe('Document Management Flow', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should update query and push to router', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
it('should update query and push to router', async () => {
|
||||
const { result, onUrlUpdate } = renderQueryStateHook()
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ keyword: 'test', page: 2 })
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalled()
|
||||
// The push call should contain the updated query params
|
||||
const pushUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushUrl).toContain('keyword=test')
|
||||
expect(pushUrl).toContain('page=2')
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.options.history).toBe('push')
|
||||
expect(update.searchParams.get('keyword')).toBe('test')
|
||||
expect(update.searchParams.get('page')).toBe('2')
|
||||
})
|
||||
|
||||
it('should reset query to defaults', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
it('should reset query to defaults', async () => {
|
||||
const { result, onUrlUpdate } = renderQueryStateHook()
|
||||
|
||||
act(() => {
|
||||
result.current.resetQuery()
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalled()
|
||||
// Default query omits default values from URL
|
||||
const pushUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushUrl).toBe('/datasets/ds-1/documents')
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.options.history).toBe('push')
|
||||
expect(update.searchParams.toString()).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -309,7 +323,7 @@ describe('Document Management Flow', () => {
|
||||
describe('Cross-Module: Query State → Sort → Selection Pipeline', () => {
|
||||
it('should maintain consistent default state across all hooks', () => {
|
||||
const docs = [createDoc({ id: 'doc-1' })]
|
||||
const { result: queryResult } = renderHook(() => useDocumentListQueryState())
|
||||
const { result: queryResult } = renderQueryStateHook()
|
||||
const { result: sortResult } = renderHook(() => useDocumentSort({
|
||||
documents: docs,
|
||||
statusFilterValue: queryResult.current.query.status,
|
||||
|
||||
@@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => {
|
||||
documentError: null as Error | null,
|
||||
documentMetadata: null as Record<string, unknown> | null,
|
||||
media: 'desktop' as string,
|
||||
searchParams: '' as string,
|
||||
}
|
||||
return {
|
||||
state,
|
||||
@@ -26,6 +27,7 @@ const mocks = vi.hoisted(() => {
|
||||
// --- External mocks ---
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: mocks.push }),
|
||||
useSearchParams: () => new URLSearchParams(mocks.state.searchParams),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-breakpoints', () => ({
|
||||
@@ -193,6 +195,7 @@ describe('DocumentDetail', () => {
|
||||
mocks.state.documentError = null
|
||||
mocks.state.documentMetadata = null
|
||||
mocks.state.media = 'desktop'
|
||||
mocks.state.searchParams = ''
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -286,15 +289,23 @@ describe('DocumentDetail', () => {
|
||||
})
|
||||
|
||||
it('should toggle metadata panel when button clicked', () => {
|
||||
const { container } = render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />)
|
||||
render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />)
|
||||
expect(screen.getByTestId('metadata')).toBeInTheDocument()
|
||||
|
||||
const svgs = container.querySelectorAll('svg')
|
||||
const toggleBtn = svgs[svgs.length - 1].closest('button')!
|
||||
fireEvent.click(toggleBtn)
|
||||
fireEvent.click(screen.getByTestId('document-detail-metadata-toggle'))
|
||||
expect(screen.queryByTestId('metadata')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should expose aria semantics for metadata toggle button', () => {
|
||||
render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />)
|
||||
const toggle = screen.getByTestId('document-detail-metadata-toggle')
|
||||
expect(toggle).toHaveAttribute('aria-label')
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true')
|
||||
|
||||
fireEvent.click(toggle)
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
|
||||
it('should pass correct props to Metadata', () => {
|
||||
render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />)
|
||||
const metadata = screen.getByTestId('metadata')
|
||||
@@ -305,20 +316,21 @@ describe('DocumentDetail', () => {
|
||||
|
||||
describe('Navigation', () => {
|
||||
it('should navigate back when back button clicked', () => {
|
||||
const { container } = render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />)
|
||||
const backBtn = container.querySelector('svg')!.parentElement!
|
||||
fireEvent.click(backBtn)
|
||||
render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />)
|
||||
fireEvent.click(screen.getByTestId('document-detail-back-button'))
|
||||
expect(mocks.push).toHaveBeenCalledWith('/datasets/ds-1/documents')
|
||||
})
|
||||
|
||||
it('should expose aria label for back button', () => {
|
||||
render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />)
|
||||
expect(screen.getByTestId('document-detail-back-button')).toHaveAttribute('aria-label')
|
||||
})
|
||||
|
||||
it('should preserve query params when navigating back', () => {
|
||||
const origLocation = window.location
|
||||
window.history.pushState({}, '', '?page=2&status=active')
|
||||
const { container } = render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />)
|
||||
const backBtn = container.querySelector('svg')!.parentElement!
|
||||
fireEvent.click(backBtn)
|
||||
mocks.state.searchParams = 'page=2&status=active'
|
||||
render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />)
|
||||
fireEvent.click(screen.getByTestId('document-detail-back-button'))
|
||||
expect(mocks.push).toHaveBeenCalledWith('/datasets/ds-1/documents?page=2&status=active')
|
||||
window.history.pushState({}, '', origLocation.href)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { DataSourceInfo, FileItem, FullDocumentDetail, LegacyDataSourceInfo } from '@/models/datasets'
|
||||
import { RiArrowLeftLine, RiLayoutLeft2Line, RiLayoutRight2Line } from '@remixicon/react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import * as React from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -35,6 +34,7 @@ type DocumentDetailProps = {
|
||||
|
||||
const DocumentDetail: FC<DocumentDetailProps> = ({ datasetId, documentId }) => {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const media = useBreakpoints()
|
||||
@@ -98,11 +98,8 @@ const DocumentDetail: FC<DocumentDetailProps> = ({ datasetId, documentId }) => {
|
||||
})
|
||||
|
||||
const backToPrev = () => {
|
||||
// Preserve pagination and filter states when navigating back
|
||||
const searchParams = new URLSearchParams(window.location.search)
|
||||
const queryString = searchParams.toString()
|
||||
const separator = queryString ? '?' : ''
|
||||
const backPath = `/datasets/${datasetId}/documents${separator}${queryString}`
|
||||
const backPath = `/datasets/${datasetId}/documents${queryString ? `?${queryString}` : ''}`
|
||||
router.push(backPath)
|
||||
}
|
||||
|
||||
@@ -152,6 +149,11 @@ const DocumentDetail: FC<DocumentDetailProps> = ({ datasetId, documentId }) => {
|
||||
return chunkMode === ChunkingMode.parentChild && parentMode === 'full-doc'
|
||||
}, [documentDetail?.doc_form, parentMode])
|
||||
|
||||
const backButtonLabel = t('operation.back', { ns: 'common' })
|
||||
const metadataToggleLabel = `${showMetadata
|
||||
? t('operation.close', { ns: 'common' })
|
||||
: t('operation.view', { ns: 'common' })} ${t('metadata.title', { ns: 'datasetDocuments' })}`
|
||||
|
||||
return (
|
||||
<DocumentContext.Provider value={{
|
||||
datasetId,
|
||||
@@ -162,9 +164,19 @@ const DocumentDetail: FC<DocumentDetailProps> = ({ datasetId, documentId }) => {
|
||||
>
|
||||
<div className="flex h-full flex-col bg-background-default">
|
||||
<div className="flex min-h-16 flex-wrap items-center justify-between border-b border-b-divider-subtle py-2.5 pl-3 pr-4">
|
||||
<div onClick={backToPrev} className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full hover:bg-components-button-tertiary-bg">
|
||||
<RiArrowLeftLine className="h-4 w-4 text-components-button-ghost-text hover:text-text-tertiary" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="document-detail-back-button"
|
||||
aria-label={backButtonLabel}
|
||||
title={backButtonLabel}
|
||||
onClick={backToPrev}
|
||||
className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full hover:bg-components-button-tertiary-bg"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="i-ri-arrow-left-line h-4 w-4 text-components-button-ghost-text hover:text-text-tertiary"
|
||||
/>
|
||||
</button>
|
||||
<DocumentTitle
|
||||
datasetId={datasetId}
|
||||
extension={documentUploadFile?.extension}
|
||||
@@ -216,13 +228,17 @@ const DocumentDetail: FC<DocumentDetailProps> = ({ datasetId, documentId }) => {
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="document-detail-metadata-toggle"
|
||||
aria-label={metadataToggleLabel}
|
||||
aria-pressed={showMetadata}
|
||||
title={metadataToggleLabel}
|
||||
className={style.layoutRightIcon}
|
||||
onClick={() => setShowMetadata(!showMetadata)}
|
||||
>
|
||||
{
|
||||
showMetadata
|
||||
? <RiLayoutLeft2Line className="h-4 w-4 text-components-button-secondary-text" />
|
||||
: <RiLayoutRight2Line className="h-4 w-4 text-components-button-secondary-text" />
|
||||
? <span aria-hidden="true" className="i-ri-layout-left-2-line h-4 w-4 text-components-button-secondary-text" />
|
||||
: <span aria-hidden="true" className="i-ri-layout-right-2-line h-4 w-4 text-components-button-secondary-text" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,439 +0,0 @@
|
||||
import type { DocumentListQuery } from '../use-document-list-query-state'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import useDocumentListQueryState from '../use-document-list-query-state'
|
||||
|
||||
const mockPush = vi.fn()
|
||||
const mockSearchParams = new URLSearchParams()
|
||||
|
||||
vi.mock('@/models/datasets', () => ({
|
||||
DisplayStatusList: [
|
||||
'queuing',
|
||||
'indexing',
|
||||
'paused',
|
||||
'error',
|
||||
'available',
|
||||
'enabled',
|
||||
'disabled',
|
||||
'archived',
|
||||
],
|
||||
}))
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
usePathname: () => '/datasets/test-id/documents',
|
||||
useSearchParams: () => mockSearchParams,
|
||||
}))
|
||||
|
||||
describe('useDocumentListQueryState', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Reset mock search params to empty
|
||||
for (const key of [...mockSearchParams.keys()])
|
||||
mockSearchParams.delete(key)
|
||||
})
|
||||
|
||||
// Tests for parseParams (exposed via the query property)
|
||||
describe('parseParams (via query)', () => {
|
||||
it('should return default query when no search params present', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query).toEqual({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
keyword: '',
|
||||
status: 'all',
|
||||
sort: '-created_at',
|
||||
})
|
||||
})
|
||||
|
||||
it('should parse page from search params', () => {
|
||||
mockSearchParams.set('page', '3')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.page).toBe(3)
|
||||
})
|
||||
|
||||
it('should default page to 1 when page is zero', () => {
|
||||
mockSearchParams.set('page', '0')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.page).toBe(1)
|
||||
})
|
||||
|
||||
it('should default page to 1 when page is negative', () => {
|
||||
mockSearchParams.set('page', '-5')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.page).toBe(1)
|
||||
})
|
||||
|
||||
it('should default page to 1 when page is NaN', () => {
|
||||
mockSearchParams.set('page', 'abc')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.page).toBe(1)
|
||||
})
|
||||
|
||||
it('should parse limit from search params', () => {
|
||||
mockSearchParams.set('limit', '50')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.limit).toBe(50)
|
||||
})
|
||||
|
||||
it('should default limit to 10 when limit is zero', () => {
|
||||
mockSearchParams.set('limit', '0')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.limit).toBe(10)
|
||||
})
|
||||
|
||||
it('should default limit to 10 when limit exceeds 100', () => {
|
||||
mockSearchParams.set('limit', '101')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.limit).toBe(10)
|
||||
})
|
||||
|
||||
it('should default limit to 10 when limit is negative', () => {
|
||||
mockSearchParams.set('limit', '-1')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.limit).toBe(10)
|
||||
})
|
||||
|
||||
it('should accept limit at boundary 100', () => {
|
||||
mockSearchParams.set('limit', '100')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.limit).toBe(100)
|
||||
})
|
||||
|
||||
it('should accept limit at boundary 1', () => {
|
||||
mockSearchParams.set('limit', '1')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.limit).toBe(1)
|
||||
})
|
||||
|
||||
it('should parse and decode keyword from search params', () => {
|
||||
mockSearchParams.set('keyword', encodeURIComponent('hello world'))
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.keyword).toBe('hello world')
|
||||
})
|
||||
|
||||
it('should return empty keyword when not present', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.keyword).toBe('')
|
||||
})
|
||||
|
||||
it('should sanitize status from search params', () => {
|
||||
mockSearchParams.set('status', 'available')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.status).toBe('available')
|
||||
})
|
||||
|
||||
it('should fallback status to all for unknown status', () => {
|
||||
mockSearchParams.set('status', 'badvalue')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.status).toBe('all')
|
||||
})
|
||||
|
||||
it('should resolve active status alias to available', () => {
|
||||
mockSearchParams.set('status', 'active')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.status).toBe('available')
|
||||
})
|
||||
|
||||
it('should parse valid sort value from search params', () => {
|
||||
mockSearchParams.set('sort', 'hit_count')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.sort).toBe('hit_count')
|
||||
})
|
||||
|
||||
it('should default sort to -created_at for invalid sort value', () => {
|
||||
mockSearchParams.set('sort', 'invalid_sort')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.sort).toBe('-created_at')
|
||||
})
|
||||
|
||||
it('should default sort to -created_at when not present', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.sort).toBe('-created_at')
|
||||
})
|
||||
|
||||
it.each([
|
||||
'-created_at',
|
||||
'created_at',
|
||||
'-hit_count',
|
||||
'hit_count',
|
||||
] as const)('should accept valid sort value %s', (sortValue) => {
|
||||
mockSearchParams.set('sort', sortValue)
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.sort).toBe(sortValue)
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for updateQuery
|
||||
describe('updateQuery', () => {
|
||||
it('should call router.push with updated params when page is changed', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ page: 3 })
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalledTimes(1)
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushedUrl).toContain('page=3')
|
||||
})
|
||||
|
||||
it('should call router.push with scroll false', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ page: 2 })
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
{ scroll: false },
|
||||
)
|
||||
})
|
||||
|
||||
it('should set status in URL when status is not all', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ status: 'error' })
|
||||
})
|
||||
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushedUrl).toContain('status=error')
|
||||
})
|
||||
|
||||
it('should not set status in URL when status is all', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ status: 'all' })
|
||||
})
|
||||
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushedUrl).not.toContain('status=')
|
||||
})
|
||||
|
||||
it('should set sort in URL when sort is not the default -created_at', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ sort: 'hit_count' })
|
||||
})
|
||||
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushedUrl).toContain('sort=hit_count')
|
||||
})
|
||||
|
||||
it('should not set sort in URL when sort is default -created_at', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ sort: '-created_at' })
|
||||
})
|
||||
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushedUrl).not.toContain('sort=')
|
||||
})
|
||||
|
||||
it('should encode keyword in URL when keyword is provided', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ keyword: 'test query' })
|
||||
})
|
||||
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
// Source code applies encodeURIComponent before setting in URLSearchParams
|
||||
expect(pushedUrl).toContain('keyword=')
|
||||
const params = new URLSearchParams(pushedUrl.split('?')[1])
|
||||
// params.get decodes one layer, but the value was pre-encoded with encodeURIComponent
|
||||
expect(decodeURIComponent(params.get('keyword')!)).toBe('test query')
|
||||
})
|
||||
|
||||
it('should remove keyword from URL when keyword is empty', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ keyword: '' })
|
||||
})
|
||||
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushedUrl).not.toContain('keyword=')
|
||||
})
|
||||
|
||||
it('should sanitize invalid status to all and not include in URL', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ status: 'invalidstatus' })
|
||||
})
|
||||
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushedUrl).not.toContain('status=')
|
||||
})
|
||||
|
||||
it('should sanitize invalid sort to -created_at and not include in URL', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ sort: 'invalidsort' as DocumentListQuery['sort'] })
|
||||
})
|
||||
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushedUrl).not.toContain('sort=')
|
||||
})
|
||||
|
||||
it('should omit page and limit when they are default and no keyword', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ page: 1, limit: 10 })
|
||||
})
|
||||
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushedUrl).not.toContain('page=')
|
||||
expect(pushedUrl).not.toContain('limit=')
|
||||
})
|
||||
|
||||
it('should include page and limit when page is greater than 1', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ page: 2 })
|
||||
})
|
||||
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushedUrl).toContain('page=2')
|
||||
expect(pushedUrl).toContain('limit=10')
|
||||
})
|
||||
|
||||
it('should include page and limit when limit is non-default', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ limit: 25 })
|
||||
})
|
||||
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushedUrl).toContain('page=1')
|
||||
expect(pushedUrl).toContain('limit=25')
|
||||
})
|
||||
|
||||
it('should include page and limit when keyword is provided', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ keyword: 'search' })
|
||||
})
|
||||
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushedUrl).toContain('page=1')
|
||||
expect(pushedUrl).toContain('limit=10')
|
||||
})
|
||||
|
||||
it('should use pathname prefix in pushed URL', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ page: 2 })
|
||||
})
|
||||
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushedUrl).toMatch(/^\/datasets\/test-id\/documents/)
|
||||
})
|
||||
|
||||
it('should push path without query string when all values are defaults', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({})
|
||||
})
|
||||
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushedUrl).toBe('/datasets/test-id/documents')
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for resetQuery
|
||||
describe('resetQuery', () => {
|
||||
it('should push URL with default query params when called', () => {
|
||||
mockSearchParams.set('page', '5')
|
||||
mockSearchParams.set('status', 'error')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.resetQuery()
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalledTimes(1)
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
// Default query has all defaults, so no params should be in the URL
|
||||
expect(pushedUrl).toBe('/datasets/test-id/documents')
|
||||
})
|
||||
|
||||
it('should call router.push with scroll false when resetting', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.resetQuery()
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
{ scroll: false },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for return value stability
|
||||
describe('return value', () => {
|
||||
it('should return query, updateQuery, and resetQuery', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current).toHaveProperty('query')
|
||||
expect(result.current).toHaveProperty('updateQuery')
|
||||
expect(result.current).toHaveProperty('resetQuery')
|
||||
expect(typeof result.current.updateQuery).toBe('function')
|
||||
expect(typeof result.current.resetQuery).toBe('function')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,419 @@
|
||||
import type { UrlUpdateEvent } from 'nuqs/adapters/testing'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { DocumentListQuery } from '../use-document-list-query-state'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import useDocumentListQueryState from '../use-document-list-query-state'
|
||||
|
||||
vi.mock('@/models/datasets', () => ({
|
||||
DisplayStatusList: [
|
||||
'queuing',
|
||||
'indexing',
|
||||
'paused',
|
||||
'error',
|
||||
'available',
|
||||
'enabled',
|
||||
'disabled',
|
||||
'archived',
|
||||
],
|
||||
}))
|
||||
|
||||
const renderWithAdapter = (searchParams = '') => {
|
||||
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>()
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<NuqsTestingAdapter searchParams={searchParams} onUrlUpdate={onUrlUpdate}>
|
||||
{children}
|
||||
</NuqsTestingAdapter>
|
||||
)
|
||||
const { result } = renderHook(() => useDocumentListQueryState(), { wrapper })
|
||||
return { result, onUrlUpdate }
|
||||
}
|
||||
|
||||
describe('useDocumentListQueryState', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('query parsing', () => {
|
||||
it('should return default query when no search params present', () => {
|
||||
const { result } = renderWithAdapter()
|
||||
|
||||
expect(result.current.query).toEqual({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
keyword: '',
|
||||
status: 'all',
|
||||
sort: '-created_at',
|
||||
})
|
||||
})
|
||||
|
||||
it('should parse page from search params', () => {
|
||||
const { result } = renderWithAdapter('?page=3')
|
||||
expect(result.current.query.page).toBe(3)
|
||||
})
|
||||
|
||||
it('should default page to 1 when page is zero', () => {
|
||||
const { result } = renderWithAdapter('?page=0')
|
||||
expect(result.current.query.page).toBe(1)
|
||||
})
|
||||
|
||||
it('should default page to 1 when page is negative', () => {
|
||||
const { result } = renderWithAdapter('?page=-5')
|
||||
expect(result.current.query.page).toBe(1)
|
||||
})
|
||||
|
||||
it('should default page to 1 when page is NaN', () => {
|
||||
const { result } = renderWithAdapter('?page=abc')
|
||||
expect(result.current.query.page).toBe(1)
|
||||
})
|
||||
|
||||
it('should parse limit from search params', () => {
|
||||
const { result } = renderWithAdapter('?limit=50')
|
||||
expect(result.current.query.limit).toBe(50)
|
||||
})
|
||||
|
||||
it('should default limit to 10 when limit is zero', () => {
|
||||
const { result } = renderWithAdapter('?limit=0')
|
||||
expect(result.current.query.limit).toBe(10)
|
||||
})
|
||||
|
||||
it('should default limit to 10 when limit exceeds 100', () => {
|
||||
const { result } = renderWithAdapter('?limit=101')
|
||||
expect(result.current.query.limit).toBe(10)
|
||||
})
|
||||
|
||||
it('should default limit to 10 when limit is negative', () => {
|
||||
const { result } = renderWithAdapter('?limit=-1')
|
||||
expect(result.current.query.limit).toBe(10)
|
||||
})
|
||||
|
||||
it('should accept limit at boundary 100', () => {
|
||||
const { result } = renderWithAdapter('?limit=100')
|
||||
expect(result.current.query.limit).toBe(100)
|
||||
})
|
||||
|
||||
it('should accept limit at boundary 1', () => {
|
||||
const { result } = renderWithAdapter('?limit=1')
|
||||
expect(result.current.query.limit).toBe(1)
|
||||
})
|
||||
|
||||
it('should parse keyword from search params', () => {
|
||||
const { result } = renderWithAdapter('?keyword=hello+world')
|
||||
expect(result.current.query.keyword).toBe('hello world')
|
||||
})
|
||||
|
||||
it('should preserve legacy double-encoded keyword text after URL decoding', () => {
|
||||
const { result } = renderWithAdapter('?keyword=test%2520query')
|
||||
expect(result.current.query.keyword).toBe('test%20query')
|
||||
})
|
||||
|
||||
it('should return empty keyword when not present', () => {
|
||||
const { result } = renderWithAdapter()
|
||||
expect(result.current.query.keyword).toBe('')
|
||||
})
|
||||
|
||||
it('should sanitize status from search params', () => {
|
||||
const { result } = renderWithAdapter('?status=available')
|
||||
expect(result.current.query.status).toBe('available')
|
||||
})
|
||||
|
||||
it('should fallback status to all for unknown status', () => {
|
||||
const { result } = renderWithAdapter('?status=badvalue')
|
||||
expect(result.current.query.status).toBe('all')
|
||||
})
|
||||
|
||||
it('should resolve active status alias to available', () => {
|
||||
const { result } = renderWithAdapter('?status=active')
|
||||
expect(result.current.query.status).toBe('available')
|
||||
})
|
||||
|
||||
it('should parse valid sort value from search params', () => {
|
||||
const { result } = renderWithAdapter('?sort=hit_count')
|
||||
expect(result.current.query.sort).toBe('hit_count')
|
||||
})
|
||||
|
||||
it('should default sort to -created_at for invalid sort value', () => {
|
||||
const { result } = renderWithAdapter('?sort=invalid_sort')
|
||||
expect(result.current.query.sort).toBe('-created_at')
|
||||
})
|
||||
|
||||
it('should default sort to -created_at when not present', () => {
|
||||
const { result } = renderWithAdapter()
|
||||
expect(result.current.query.sort).toBe('-created_at')
|
||||
})
|
||||
|
||||
it.each([
|
||||
'-created_at',
|
||||
'created_at',
|
||||
'-hit_count',
|
||||
'hit_count',
|
||||
] as const)('should accept valid sort value %s', (sortValue) => {
|
||||
const { result } = renderWithAdapter(`?sort=${sortValue}`)
|
||||
expect(result.current.query.sort).toBe(sortValue)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateQuery', () => {
|
||||
it('should update page in state when page is changed', () => {
|
||||
const { result } = renderWithAdapter()
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ page: 3 })
|
||||
})
|
||||
|
||||
expect(result.current.query.page).toBe(3)
|
||||
})
|
||||
|
||||
it('should sync page to URL with push history', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter()
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ page: 2 })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.get('page')).toBe('2')
|
||||
expect(update.options.history).toBe('push')
|
||||
})
|
||||
|
||||
it('should set status in URL when status is not all', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter()
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ status: 'error' })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.get('status')).toBe('error')
|
||||
})
|
||||
|
||||
it('should not set status in URL when status is all', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter()
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ status: 'all' })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.has('status')).toBe(false)
|
||||
})
|
||||
|
||||
it('should set sort in URL when sort is not the default -created_at', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter()
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ sort: 'hit_count' })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.get('sort')).toBe('hit_count')
|
||||
})
|
||||
|
||||
it('should not set sort in URL when sort is default -created_at', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter()
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ sort: '-created_at' })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.has('sort')).toBe(false)
|
||||
})
|
||||
|
||||
it('should set keyword in URL when keyword is provided', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter()
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ keyword: 'test query' })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.get('keyword')).toBe('test query')
|
||||
})
|
||||
|
||||
it('should remove keyword from URL when keyword is empty', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter('?keyword=existing')
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ keyword: '' })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.has('keyword')).toBe(false)
|
||||
})
|
||||
|
||||
it('should remove keyword from URL when keyword contains only whitespace', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter('?keyword=existing')
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ keyword: ' ' })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.has('keyword')).toBe(false)
|
||||
expect(result.current.query.keyword).toBe('')
|
||||
})
|
||||
|
||||
it('should preserve literal percent-encoded-like keyword values', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter()
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ keyword: '%2F' })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.get('keyword')).toBe('%2F')
|
||||
expect(result.current.query.keyword).toBe('%2F')
|
||||
})
|
||||
|
||||
it('should keep keyword text unchanged when updating query from legacy URL', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter('?keyword=test%2520query')
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ page: 2 })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
expect(result.current.query.keyword).toBe('test%20query')
|
||||
})
|
||||
|
||||
it('should sanitize invalid status to all and not include in URL', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter()
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ status: 'invalidstatus' })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.has('status')).toBe(false)
|
||||
})
|
||||
|
||||
it('should sanitize invalid sort to -created_at and not include in URL', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter()
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ sort: 'invalidsort' as DocumentListQuery['sort'] })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.has('sort')).toBe(false)
|
||||
})
|
||||
|
||||
it('should not include page in URL when page is default', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter()
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ page: 1 })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.has('page')).toBe(false)
|
||||
})
|
||||
|
||||
it('should include page in URL when page is greater than 1', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter()
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ page: 2 })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.get('page')).toBe('2')
|
||||
})
|
||||
|
||||
it('should include limit in URL when limit is non-default', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter()
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ limit: 25 })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.get('limit')).toBe('25')
|
||||
})
|
||||
|
||||
it('should sanitize invalid page to default and omit page from URL', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter()
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ page: -1 })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.has('page')).toBe(false)
|
||||
expect(result.current.query.page).toBe(1)
|
||||
})
|
||||
|
||||
it('should sanitize invalid limit to default and omit limit from URL', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter()
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ limit: 999 })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.has('limit')).toBe(false)
|
||||
expect(result.current.query.limit).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resetQuery', () => {
|
||||
it('should reset all values to defaults', () => {
|
||||
const { result } = renderWithAdapter('?page=5&status=error&sort=hit_count')
|
||||
|
||||
act(() => {
|
||||
result.current.resetQuery()
|
||||
})
|
||||
|
||||
expect(result.current.query).toEqual({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
keyword: '',
|
||||
status: 'all',
|
||||
sort: '-created_at',
|
||||
})
|
||||
})
|
||||
|
||||
it('should clear all params from URL when called', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter('?page=5&status=error')
|
||||
|
||||
act(() => {
|
||||
result.current.resetQuery()
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.has('page')).toBe(false)
|
||||
expect(update.searchParams.has('status')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('return value', () => {
|
||||
it('should return query, updateQuery, and resetQuery', () => {
|
||||
const { result } = renderWithAdapter()
|
||||
|
||||
expect(result.current).toHaveProperty('query')
|
||||
expect(result.current).toHaveProperty('updateQuery')
|
||||
expect(result.current).toHaveProperty('resetQuery')
|
||||
expect(typeof result.current.updateQuery).toBe('function')
|
||||
expect(typeof result.current.resetQuery).toBe('function')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ReadonlyURLSearchParams } from 'next/navigation'
|
||||
import type { SortType } from '@/service/datasets'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { createParser, parseAsString, useQueryStates } from 'nuqs'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { sanitizeStatusValue } from '../status-filter'
|
||||
|
||||
@@ -13,6 +12,14 @@ const sanitizeSortValue = (value?: string | null): SortType => {
|
||||
return (ALLOWED_SORT_VALUES.includes(value as SortType) ? value : '-created_at') as SortType
|
||||
}
|
||||
|
||||
const sanitizePageValue = (value: number): number => {
|
||||
return Number.isInteger(value) && value > 0 ? value : 1
|
||||
}
|
||||
|
||||
const sanitizeLimitValue = (value: number): number => {
|
||||
return Number.isInteger(value) && value > 0 && value <= 100 ? value : 10
|
||||
}
|
||||
|
||||
export type DocumentListQuery = {
|
||||
page: number
|
||||
limit: number
|
||||
@@ -21,91 +28,65 @@ export type DocumentListQuery = {
|
||||
sort: SortType
|
||||
}
|
||||
|
||||
const DEFAULT_QUERY: DocumentListQuery = {
|
||||
page: 1,
|
||||
limit: 10,
|
||||
keyword: '',
|
||||
status: 'all',
|
||||
sort: '-created_at',
|
||||
}
|
||||
const parseAsPage = createParser<number>({
|
||||
parse: (value) => {
|
||||
const n = Number.parseInt(value, 10)
|
||||
return Number.isNaN(n) || n <= 0 ? null : n
|
||||
},
|
||||
serialize: value => value.toString(),
|
||||
}).withDefault(1)
|
||||
|
||||
// Parse the query parameters from the URL search string.
|
||||
function parseParams(params: ReadonlyURLSearchParams): DocumentListQuery {
|
||||
const page = Number.parseInt(params.get('page') || '1', 10)
|
||||
const limit = Number.parseInt(params.get('limit') || '10', 10)
|
||||
const keyword = params.get('keyword') || ''
|
||||
const status = sanitizeStatusValue(params.get('status'))
|
||||
const sort = sanitizeSortValue(params.get('sort'))
|
||||
const parseAsLimit = createParser<number>({
|
||||
parse: (value) => {
|
||||
const n = Number.parseInt(value, 10)
|
||||
return Number.isNaN(n) || n <= 0 || n > 100 ? null : n
|
||||
},
|
||||
serialize: value => value.toString(),
|
||||
}).withDefault(10)
|
||||
|
||||
return {
|
||||
page: page > 0 ? page : 1,
|
||||
limit: (limit > 0 && limit <= 100) ? limit : 10,
|
||||
keyword: keyword ? decodeURIComponent(keyword) : '',
|
||||
status,
|
||||
sort,
|
||||
}
|
||||
}
|
||||
const parseAsDocStatus = createParser<string>({
|
||||
parse: value => sanitizeStatusValue(value),
|
||||
serialize: value => value,
|
||||
}).withDefault('all')
|
||||
|
||||
// Update the URL search string with the given query parameters.
|
||||
function updateSearchParams(query: DocumentListQuery, searchParams: URLSearchParams) {
|
||||
const { page, limit, keyword, status, sort } = query || {}
|
||||
const parseAsDocSort = createParser<SortType>({
|
||||
parse: value => sanitizeSortValue(value),
|
||||
serialize: value => value,
|
||||
}).withDefault('-created_at' as SortType)
|
||||
|
||||
const hasNonDefaultParams = (page && page > 1) || (limit && limit !== 10) || (keyword && keyword.trim())
|
||||
const parseAsKeyword = parseAsString.withDefault('')
|
||||
|
||||
if (hasNonDefaultParams) {
|
||||
searchParams.set('page', (page || 1).toString())
|
||||
searchParams.set('limit', (limit || 10).toString())
|
||||
}
|
||||
else {
|
||||
searchParams.delete('page')
|
||||
searchParams.delete('limit')
|
||||
}
|
||||
|
||||
if (keyword && keyword.trim())
|
||||
searchParams.set('keyword', encodeURIComponent(keyword))
|
||||
else
|
||||
searchParams.delete('keyword')
|
||||
|
||||
const sanitizedStatus = sanitizeStatusValue(status)
|
||||
if (sanitizedStatus && sanitizedStatus !== 'all')
|
||||
searchParams.set('status', sanitizedStatus)
|
||||
else
|
||||
searchParams.delete('status')
|
||||
|
||||
const sanitizedSort = sanitizeSortValue(sort)
|
||||
if (sanitizedSort !== '-created_at')
|
||||
searchParams.set('sort', sanitizedSort)
|
||||
else
|
||||
searchParams.delete('sort')
|
||||
export const documentListParsers = {
|
||||
page: parseAsPage,
|
||||
limit: parseAsLimit,
|
||||
keyword: parseAsKeyword,
|
||||
status: parseAsDocStatus,
|
||||
sort: parseAsDocSort,
|
||||
}
|
||||
|
||||
function useDocumentListQueryState() {
|
||||
const searchParams = useSearchParams()
|
||||
const query = useMemo(() => parseParams(searchParams), [searchParams])
|
||||
const [query, setQuery] = useQueryStates(documentListParsers, {
|
||||
history: 'push',
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
|
||||
// Helper function to update specific query parameters
|
||||
const updateQuery = useCallback((updates: Partial<DocumentListQuery>) => {
|
||||
const newQuery = { ...query, ...updates }
|
||||
newQuery.status = sanitizeStatusValue(newQuery.status)
|
||||
newQuery.sort = sanitizeSortValue(newQuery.sort)
|
||||
const params = new URLSearchParams()
|
||||
updateSearchParams(newQuery, params)
|
||||
const search = params.toString()
|
||||
const queryString = search ? `?${search}` : ''
|
||||
router.push(`${pathname}${queryString}`, { scroll: false })
|
||||
}, [query, router, pathname])
|
||||
const patch = { ...updates }
|
||||
if ('page' in patch && patch.page !== undefined)
|
||||
patch.page = sanitizePageValue(patch.page)
|
||||
if ('limit' in patch && patch.limit !== undefined)
|
||||
patch.limit = sanitizeLimitValue(patch.limit)
|
||||
if ('status' in patch)
|
||||
patch.status = sanitizeStatusValue(patch.status)
|
||||
if ('sort' in patch)
|
||||
patch.sort = sanitizeSortValue(patch.sort)
|
||||
if ('keyword' in patch && typeof patch.keyword === 'string' && patch.keyword.trim() === '')
|
||||
patch.keyword = ''
|
||||
setQuery(patch)
|
||||
}, [setQuery])
|
||||
|
||||
// Helper function to reset query to defaults
|
||||
const resetQuery = useCallback(() => {
|
||||
const params = new URLSearchParams()
|
||||
updateSearchParams(DEFAULT_QUERY, params)
|
||||
const search = params.toString()
|
||||
const queryString = search ? `?${search}` : ''
|
||||
router.push(`${pathname}${queryString}`, { scroll: false })
|
||||
}, [router, pathname])
|
||||
setQuery(null)
|
||||
}, [setQuery])
|
||||
|
||||
return useMemo(() => ({
|
||||
query,
|
||||
|
||||
Reference in New Issue
Block a user