mirror of
https://github.com/langgenius/dify.git
synced 2026-04-27 19:27:23 +08:00
refactor: migrate useAppsQueryState tests to use NuqsTestingAdapter for improved URL handling
This commit is contained in:
parent
f3a42878ae
commit
5a0d0d8c86
@ -1,363 +0,0 @@
|
|||||||
/**
|
|
||||||
* Test suite for useAppsQueryState hook
|
|
||||||
*
|
|
||||||
* This hook manages app filtering state through URL search parameters, enabling:
|
|
||||||
* - Bookmarkable filter states (users can share URLs with specific filters active)
|
|
||||||
* - Browser history integration (back/forward buttons work with filters)
|
|
||||||
* - Multiple filter types: tagIDs, keywords, isCreatedByMe
|
|
||||||
*
|
|
||||||
* The hook syncs local filter state with URL search parameters, making filter
|
|
||||||
* navigation persistent and shareable across sessions.
|
|
||||||
*/
|
|
||||||
import { act, renderHook } from '@testing-library/react'
|
|
||||||
|
|
||||||
// Import the hook after mocks are set up
|
|
||||||
import useAppsQueryState from './use-apps-query-state'
|
|
||||||
|
|
||||||
// Mock Next.js navigation hooks
|
|
||||||
const mockPush = vi.fn()
|
|
||||||
const mockPathname = '/apps'
|
|
||||||
let mockSearchParams = new URLSearchParams()
|
|
||||||
|
|
||||||
vi.mock('next/navigation', () => ({
|
|
||||||
usePathname: vi.fn(() => mockPathname),
|
|
||||||
useRouter: vi.fn(() => ({
|
|
||||||
push: mockPush,
|
|
||||||
})),
|
|
||||||
useSearchParams: vi.fn(() => mockSearchParams),
|
|
||||||
}))
|
|
||||||
|
|
||||||
describe('useAppsQueryState', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mockSearchParams = new URLSearchParams()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Basic functionality', () => {
|
|
||||||
it('should return query object and setQuery function', () => {
|
|
||||||
const { result } = renderHook(() => useAppsQueryState())
|
|
||||||
|
|
||||||
expect(result.current.query).toBeDefined()
|
|
||||||
expect(typeof result.current.setQuery).toBe('function')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should initialize with empty query when no search params exist', () => {
|
|
||||||
const { result } = renderHook(() => useAppsQueryState())
|
|
||||||
|
|
||||||
expect(result.current.query.tagIDs).toBeUndefined()
|
|
||||||
expect(result.current.query.keywords).toBeUndefined()
|
|
||||||
expect(result.current.query.isCreatedByMe).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Parsing search params', () => {
|
|
||||||
it('should parse tagIDs from URL', () => {
|
|
||||||
mockSearchParams.set('tagIDs', 'tag1;tag2;tag3')
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useAppsQueryState())
|
|
||||||
|
|
||||||
expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2', 'tag3'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should parse single tagID from URL', () => {
|
|
||||||
mockSearchParams.set('tagIDs', 'single-tag')
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useAppsQueryState())
|
|
||||||
|
|
||||||
expect(result.current.query.tagIDs).toEqual(['single-tag'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should parse keywords from URL', () => {
|
|
||||||
mockSearchParams.set('keywords', 'search term')
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useAppsQueryState())
|
|
||||||
|
|
||||||
expect(result.current.query.keywords).toBe('search term')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should parse isCreatedByMe as true from URL', () => {
|
|
||||||
mockSearchParams.set('isCreatedByMe', 'true')
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useAppsQueryState())
|
|
||||||
|
|
||||||
expect(result.current.query.isCreatedByMe).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should parse isCreatedByMe as false for other values', () => {
|
|
||||||
mockSearchParams.set('isCreatedByMe', 'false')
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useAppsQueryState())
|
|
||||||
|
|
||||||
expect(result.current.query.isCreatedByMe).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should parse all params together', () => {
|
|
||||||
mockSearchParams.set('tagIDs', 'tag1;tag2')
|
|
||||||
mockSearchParams.set('keywords', 'test')
|
|
||||||
mockSearchParams.set('isCreatedByMe', 'true')
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useAppsQueryState())
|
|
||||||
|
|
||||||
expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2'])
|
|
||||||
expect(result.current.query.keywords).toBe('test')
|
|
||||||
expect(result.current.query.isCreatedByMe).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Updating query state', () => {
|
|
||||||
it('should update keywords via setQuery', () => {
|
|
||||||
const { result } = renderHook(() => useAppsQueryState())
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.setQuery({ keywords: 'new search' })
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.current.query.keywords).toBe('new search')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should update tagIDs via setQuery', () => {
|
|
||||||
const { result } = renderHook(() => useAppsQueryState())
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.setQuery({ tagIDs: ['tag1', 'tag2'] })
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should update isCreatedByMe via setQuery', () => {
|
|
||||||
const { result } = renderHook(() => useAppsQueryState())
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.setQuery({ isCreatedByMe: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.current.query.isCreatedByMe).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should support partial updates via callback', () => {
|
|
||||||
const { result } = renderHook(() => useAppsQueryState())
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.setQuery({ keywords: 'initial' })
|
|
||||||
})
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.setQuery(prev => ({ ...prev, isCreatedByMe: true }))
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.current.query.keywords).toBe('initial')
|
|
||||||
expect(result.current.query.isCreatedByMe).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('URL synchronization', () => {
|
|
||||||
it('should sync keywords to URL', async () => {
|
|
||||||
const { result } = renderHook(() => useAppsQueryState())
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.setQuery({ keywords: 'search' })
|
|
||||||
})
|
|
||||||
|
|
||||||
// Wait for useEffect to run
|
|
||||||
await act(async () => {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 0))
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(mockPush).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('keywords=search'),
|
|
||||||
{ scroll: false },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should sync tagIDs to URL with semicolon separator', async () => {
|
|
||||||
const { result } = renderHook(() => useAppsQueryState())
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.setQuery({ tagIDs: ['tag1', 'tag2'] })
|
|
||||||
})
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 0))
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(mockPush).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('tagIDs=tag1%3Btag2'),
|
|
||||||
{ scroll: false },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should sync isCreatedByMe to URL', async () => {
|
|
||||||
const { result } = renderHook(() => useAppsQueryState())
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.setQuery({ isCreatedByMe: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 0))
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(mockPush).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('isCreatedByMe=true'),
|
|
||||||
{ scroll: false },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should remove keywords from URL when empty', async () => {
|
|
||||||
mockSearchParams.set('keywords', 'existing')
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useAppsQueryState())
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.setQuery({ keywords: '' })
|
|
||||||
})
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 0))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Should be called without keywords param
|
|
||||||
expect(mockPush).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should remove tagIDs from URL when empty array', async () => {
|
|
||||||
mockSearchParams.set('tagIDs', 'tag1;tag2')
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useAppsQueryState())
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.setQuery({ tagIDs: [] })
|
|
||||||
})
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 0))
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(mockPush).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should remove isCreatedByMe from URL when false', async () => {
|
|
||||||
mockSearchParams.set('isCreatedByMe', 'true')
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useAppsQueryState())
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.setQuery({ isCreatedByMe: false })
|
|
||||||
})
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 0))
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(mockPush).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Edge cases', () => {
|
|
||||||
it('should handle empty tagIDs string in URL', () => {
|
|
||||||
// NOTE: This test documents current behavior where ''.split(';') returns ['']
|
|
||||||
// This could potentially cause filtering issues as it's treated as a tag with empty name
|
|
||||||
// rather than absence of tags. Consider updating parseParams if this is problematic.
|
|
||||||
mockSearchParams.set('tagIDs', '')
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useAppsQueryState())
|
|
||||||
|
|
||||||
expect(result.current.query.tagIDs).toEqual([''])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should handle empty keywords', () => {
|
|
||||||
mockSearchParams.set('keywords', '')
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useAppsQueryState())
|
|
||||||
|
|
||||||
expect(result.current.query.keywords).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should handle undefined tagIDs', () => {
|
|
||||||
const { result } = renderHook(() => useAppsQueryState())
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.setQuery({ tagIDs: undefined })
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.current.query.tagIDs).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should handle special characters in keywords', () => {
|
|
||||||
// Use URLSearchParams constructor to properly simulate URL decoding behavior
|
|
||||||
// URLSearchParams.get() decodes URL-encoded characters
|
|
||||||
mockSearchParams = new URLSearchParams('keywords=test%20with%20spaces')
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useAppsQueryState())
|
|
||||||
|
|
||||||
expect(result.current.query.keywords).toBe('test with spaces')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Memoization', () => {
|
|
||||||
it('should return memoized object reference when query unchanged', () => {
|
|
||||||
const { result, rerender } = renderHook(() => useAppsQueryState())
|
|
||||||
|
|
||||||
const firstResult = result.current
|
|
||||||
rerender()
|
|
||||||
const secondResult = result.current
|
|
||||||
|
|
||||||
expect(firstResult.query).toBe(secondResult.query)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return new object reference when query changes', () => {
|
|
||||||
const { result } = renderHook(() => useAppsQueryState())
|
|
||||||
|
|
||||||
const firstQuery = result.current.query
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.setQuery({ keywords: 'changed' })
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.current.query).not.toBe(firstQuery)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Integration scenarios', () => {
|
|
||||||
it('should handle sequential updates', async () => {
|
|
||||||
const { result } = renderHook(() => useAppsQueryState())
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.setQuery({ keywords: 'first' })
|
|
||||||
})
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.setQuery(prev => ({ ...prev, tagIDs: ['tag1'] }))
|
|
||||||
})
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.setQuery(prev => ({ ...prev, isCreatedByMe: true }))
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.current.query.keywords).toBe('first')
|
|
||||||
expect(result.current.query.tagIDs).toEqual(['tag1'])
|
|
||||||
expect(result.current.query.isCreatedByMe).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should clear all filters', () => {
|
|
||||||
mockSearchParams.set('tagIDs', 'tag1;tag2')
|
|
||||||
mockSearchParams.set('keywords', 'search')
|
|
||||||
mockSearchParams.set('isCreatedByMe', 'true')
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useAppsQueryState())
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.setQuery({
|
|
||||||
tagIDs: undefined,
|
|
||||||
keywords: undefined,
|
|
||||||
isCreatedByMe: false,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.current.query.tagIDs).toBeUndefined()
|
|
||||||
expect(result.current.query.keywords).toBeUndefined()
|
|
||||||
expect(result.current.query.isCreatedByMe).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
248
web/app/components/apps/hooks/use-apps-query-state.spec.tsx
Normal file
248
web/app/components/apps/hooks/use-apps-query-state.spec.tsx
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
import type { UrlUpdateEvent } from 'nuqs/adapters/testing'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
/**
|
||||||
|
* Test suite for useAppsQueryState hook
|
||||||
|
*
|
||||||
|
* This hook manages app filtering state through URL search parameters, enabling:
|
||||||
|
* - Bookmarkable filter states (users can share URLs with specific filters active)
|
||||||
|
* - Browser history integration (back/forward buttons work with filters)
|
||||||
|
* - Multiple filter types: tagIDs, keywords, isCreatedByMe
|
||||||
|
*/
|
||||||
|
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||||
|
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||||
|
import useAppsQueryState from './use-apps-query-state'
|
||||||
|
|
||||||
|
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(() => useAppsQueryState(), { wrapper })
|
||||||
|
return { result, onUrlUpdate }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Groups scenarios for useAppsQueryState behavior.
|
||||||
|
describe('useAppsQueryState', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Covers the hook return shape and default values.
|
||||||
|
describe('Initialization', () => {
|
||||||
|
it('should expose query and setQuery when initialized', () => {
|
||||||
|
const { result } = renderWithAdapter()
|
||||||
|
|
||||||
|
expect(result.current.query).toBeDefined()
|
||||||
|
expect(typeof result.current.setQuery).toBe('function')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should default to empty filters when search params are missing', () => {
|
||||||
|
const { result } = renderWithAdapter()
|
||||||
|
|
||||||
|
expect(result.current.query.tagIDs).toBeUndefined()
|
||||||
|
expect(result.current.query.keywords).toBeUndefined()
|
||||||
|
expect(result.current.query.isCreatedByMe).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Covers parsing of existing URL search params.
|
||||||
|
describe('Parsing search params', () => {
|
||||||
|
it('should parse tagIDs when URL includes tagIDs', () => {
|
||||||
|
const { result } = renderWithAdapter('?tagIDs=tag1;tag2;tag3')
|
||||||
|
|
||||||
|
expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2', 'tag3'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should parse keywords when URL includes keywords', () => {
|
||||||
|
const { result } = renderWithAdapter('?keywords=search+term')
|
||||||
|
|
||||||
|
expect(result.current.query.keywords).toBe('search term')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should parse isCreatedByMe when URL includes true value', () => {
|
||||||
|
const { result } = renderWithAdapter('?isCreatedByMe=true')
|
||||||
|
|
||||||
|
expect(result.current.query.isCreatedByMe).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should parse all params when URL includes multiple filters', () => {
|
||||||
|
const { result } = renderWithAdapter(
|
||||||
|
'?tagIDs=tag1;tag2&keywords=test&isCreatedByMe=true',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2'])
|
||||||
|
expect(result.current.query.keywords).toBe('test')
|
||||||
|
expect(result.current.query.isCreatedByMe).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Covers updates driven by setQuery.
|
||||||
|
describe('Updating query state', () => {
|
||||||
|
it('should update keywords when setQuery receives keywords', () => {
|
||||||
|
const { result } = renderWithAdapter()
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setQuery({ keywords: 'new search' })
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.query.keywords).toBe('new search')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should update tagIDs when setQuery receives tagIDs', () => {
|
||||||
|
const { result } = renderWithAdapter()
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setQuery({ tagIDs: ['tag1', 'tag2'] })
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should update isCreatedByMe when setQuery receives true', () => {
|
||||||
|
const { result } = renderWithAdapter()
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setQuery({ isCreatedByMe: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.query.isCreatedByMe).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support partial updates when setQuery uses callback', () => {
|
||||||
|
const { result } = renderWithAdapter()
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setQuery({ keywords: 'initial' })
|
||||||
|
})
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setQuery(prev => ({ ...prev, isCreatedByMe: true }))
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.query.keywords).toBe('initial')
|
||||||
|
expect(result.current.query.isCreatedByMe).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Covers URL updates triggered by query changes.
|
||||||
|
describe('URL synchronization', () => {
|
||||||
|
it('should sync keywords to URL when keywords change', async () => {
|
||||||
|
const { result, onUrlUpdate } = renderWithAdapter()
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setQuery({ keywords: 'search' })
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||||
|
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||||
|
expect(update.searchParams.get('keywords')).toBe('search')
|
||||||
|
expect(update.options.history).toBe('push')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should sync tagIDs to URL when tagIDs change', async () => {
|
||||||
|
const { result, onUrlUpdate } = renderWithAdapter()
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setQuery({ tagIDs: ['tag1', 'tag2'] })
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||||
|
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||||
|
expect(update.searchParams.get('tagIDs')).toBe('tag1;tag2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should sync isCreatedByMe to URL when enabled', async () => {
|
||||||
|
const { result, onUrlUpdate } = renderWithAdapter()
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setQuery({ isCreatedByMe: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||||
|
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||||
|
expect(update.searchParams.get('isCreatedByMe')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should remove keywords from URL when keywords are cleared', async () => {
|
||||||
|
const { result, onUrlUpdate } = renderWithAdapter('?keywords=existing')
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setQuery({ keywords: '' })
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||||
|
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||||
|
expect(update.searchParams.has('keywords')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should remove tagIDs from URL when tagIDs are empty', async () => {
|
||||||
|
const { result, onUrlUpdate } = renderWithAdapter('?tagIDs=tag1;tag2')
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setQuery({ tagIDs: [] })
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||||
|
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||||
|
expect(update.searchParams.has('tagIDs')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should remove isCreatedByMe from URL when disabled', async () => {
|
||||||
|
const { result, onUrlUpdate } = renderWithAdapter('?isCreatedByMe=true')
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setQuery({ isCreatedByMe: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||||
|
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||||
|
expect(update.searchParams.has('isCreatedByMe')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Covers decoding and empty values.
|
||||||
|
describe('Edge cases', () => {
|
||||||
|
it('should treat empty tagIDs as empty list when URL param is empty', () => {
|
||||||
|
const { result } = renderWithAdapter('?tagIDs=')
|
||||||
|
|
||||||
|
expect(result.current.query.tagIDs).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should treat empty keywords as undefined when URL param is empty', () => {
|
||||||
|
const { result } = renderWithAdapter('?keywords=')
|
||||||
|
|
||||||
|
expect(result.current.query.keywords).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should decode keywords with spaces when URL contains encoded spaces', () => {
|
||||||
|
const { result } = renderWithAdapter('?keywords=test+with+spaces')
|
||||||
|
|
||||||
|
expect(result.current.query.keywords).toBe('test with spaces')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Covers multi-step updates that mimic real usage.
|
||||||
|
describe('Integration scenarios', () => {
|
||||||
|
it('should keep accumulated filters when updates are sequential', () => {
|
||||||
|
const { result } = renderWithAdapter()
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setQuery({ keywords: 'first' })
|
||||||
|
})
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setQuery(prev => ({ ...prev, tagIDs: ['tag1'] }))
|
||||||
|
})
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setQuery(prev => ({ ...prev, isCreatedByMe: true }))
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.query.keywords).toBe('first')
|
||||||
|
expect(result.current.query.tagIDs).toEqual(['tag1'])
|
||||||
|
expect(result.current.query.isCreatedByMe).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -1,6 +1,5 @@
|
|||||||
import type { ReadonlyURLSearchParams } from 'next/navigation'
|
import { parseAsArrayOf, parseAsBoolean, parseAsString, useQueryStates } from 'nuqs'
|
||||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
import { useCallback, useMemo } from 'react'
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
||||||
|
|
||||||
type AppsQuery = {
|
type AppsQuery = {
|
||||||
tagIDs?: string[]
|
tagIDs?: string[]
|
||||||
@ -8,54 +7,51 @@ type AppsQuery = {
|
|||||||
isCreatedByMe?: boolean
|
isCreatedByMe?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the query parameters from the URL search string.
|
const normalizeKeywords = (value: string | null) => value || undefined
|
||||||
function parseParams(params: ReadonlyURLSearchParams): AppsQuery {
|
|
||||||
const tagIDs = params.get('tagIDs')?.split(';')
|
|
||||||
const keywords = params.get('keywords') || undefined
|
|
||||||
const isCreatedByMe = params.get('isCreatedByMe') === 'true'
|
|
||||||
return { tagIDs, keywords, isCreatedByMe }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the URL search string with the given query parameters.
|
|
||||||
function updateSearchParams(query: AppsQuery, current: URLSearchParams) {
|
|
||||||
const { tagIDs, keywords, isCreatedByMe } = query || {}
|
|
||||||
|
|
||||||
if (tagIDs && tagIDs.length > 0)
|
|
||||||
current.set('tagIDs', tagIDs.join(';'))
|
|
||||||
else
|
|
||||||
current.delete('tagIDs')
|
|
||||||
|
|
||||||
if (keywords)
|
|
||||||
current.set('keywords', keywords)
|
|
||||||
else
|
|
||||||
current.delete('keywords')
|
|
||||||
|
|
||||||
if (isCreatedByMe)
|
|
||||||
current.set('isCreatedByMe', 'true')
|
|
||||||
else
|
|
||||||
current.delete('isCreatedByMe')
|
|
||||||
}
|
|
||||||
|
|
||||||
function useAppsQueryState() {
|
function useAppsQueryState() {
|
||||||
const searchParams = useSearchParams()
|
const [urlQuery, setUrlQuery] = useQueryStates(
|
||||||
const [query, setQuery] = useState<AppsQuery>(() => parseParams(searchParams))
|
{
|
||||||
|
tagIDs: parseAsArrayOf(parseAsString, ';'),
|
||||||
|
keywords: parseAsString,
|
||||||
|
isCreatedByMe: parseAsBoolean,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
history: 'push',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const router = useRouter()
|
const query = useMemo<AppsQuery>(() => ({
|
||||||
const pathname = usePathname()
|
tagIDs: urlQuery.tagIDs ?? undefined,
|
||||||
const syncSearchParams = useCallback((params: URLSearchParams) => {
|
keywords: normalizeKeywords(urlQuery.keywords),
|
||||||
const search = params.toString()
|
isCreatedByMe: urlQuery.isCreatedByMe ?? false,
|
||||||
const query = search ? `?${search}` : ''
|
}), [urlQuery.isCreatedByMe, urlQuery.keywords, urlQuery.tagIDs])
|
||||||
router.push(`${pathname}${query}`, { scroll: false })
|
|
||||||
}, [router, pathname])
|
|
||||||
|
|
||||||
// Update the URL search string whenever the query changes.
|
const setQuery = useCallback((next: AppsQuery | ((prev: AppsQuery) => AppsQuery)) => {
|
||||||
useEffect(() => {
|
const buildPatch = (patch: AppsQuery) => {
|
||||||
const params = new URLSearchParams(searchParams)
|
const result: Partial<typeof urlQuery> = {}
|
||||||
updateSearchParams(query, params)
|
if ('tagIDs' in patch)
|
||||||
syncSearchParams(params)
|
result.tagIDs = patch.tagIDs && patch.tagIDs.length > 0 ? patch.tagIDs : null
|
||||||
}, [query, searchParams, syncSearchParams])
|
if ('keywords' in patch)
|
||||||
|
result.keywords = patch.keywords ? patch.keywords : null
|
||||||
|
if ('isCreatedByMe' in patch)
|
||||||
|
result.isCreatedByMe = patch.isCreatedByMe ? true : null
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
return useMemo(() => ({ query, setQuery }), [query])
|
if (typeof next === 'function') {
|
||||||
|
setUrlQuery(prev => buildPatch(next({
|
||||||
|
tagIDs: prev.tagIDs ?? undefined,
|
||||||
|
keywords: normalizeKeywords(prev.keywords),
|
||||||
|
isCreatedByMe: prev.isCreatedByMe ?? false,
|
||||||
|
})))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setUrlQuery(buildPatch(next))
|
||||||
|
}, [setUrlQuery])
|
||||||
|
|
||||||
|
return useMemo(() => ({ query, setQuery }), [query, setQuery])
|
||||||
}
|
}
|
||||||
|
|
||||||
export default useAppsQueryState
|
export default useAppsQueryState
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user