Compare commits

...

5 Commits

Author SHA1 Message Date
yyh
9487daf71c fix(web): remove legacy keyword parser compatibility in document query state 2026-02-15 20:46:58 +08:00
yyh
e80bd15d5c fix 2026-02-15 19:34:30 +08:00
yyh
a8ddc1408e fix test 2026-02-15 19:20:57 +08:00
yyh
5bb4110f85 fix(web): harden document query-state and icon button a11y 2026-02-15 17:41:06 +08:00
yyh
b0bae39696 refactor(web): migrate document list query state to nuqs
Replace hand-written URL parse/serialize logic in
useDocumentListQueryState with nuqs useQueryStates, reducing boilerplate
and fixing the keyword double-encoding bug. The detail page now reads
search params via useSearchParams() instead of window.location.search,
and interactive elements use semantic <button> with Tailwind icon classes.

- Replace parseParams/updateSearchParams with nuqs createParser parsers
- Stabilise updateQuery callback deps (setQuery only, no query/router)
- Add parseAsKeyword with backward-compat decodeURIComponent for legacy
  double-encoded URLs
- Replace RiArrowLeftLine/RiLayoutLeft2Line with Tailwind CSS icon spans
- Use data-testid selectors in detail tests instead of fragile DOM walks
- Rewrite query state tests to use NuqsTestingAdapter
2026-02-15 17:30:11 +08:00
6 changed files with 557 additions and 554 deletions

View File

@@ -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,

View File

@@ -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)
})
})

View File

@@ -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>

View File

@@ -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')
})
})
})

View File

@@ -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')
})
})
})

View File

@@ -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,