From 5a0d0d8c863dd97f9ea7257aafbf64f5627e6ba3 Mon Sep 17 00:00:00 2001 From: yyh Date: Fri, 26 Dec 2025 11:35:37 +0800 Subject: [PATCH] refactor: migrate useAppsQueryState tests to use NuqsTestingAdapter for improved URL handling --- .../apps/hooks/use-apps-query-state.spec.ts | 363 ------------------ .../apps/hooks/use-apps-query-state.spec.tsx | 248 ++++++++++++ .../apps/hooks/use-apps-query-state.ts | 88 ++--- 3 files changed, 290 insertions(+), 409 deletions(-) delete mode 100644 web/app/components/apps/hooks/use-apps-query-state.spec.ts create mode 100644 web/app/components/apps/hooks/use-apps-query-state.spec.tsx diff --git a/web/app/components/apps/hooks/use-apps-query-state.spec.ts b/web/app/components/apps/hooks/use-apps-query-state.spec.ts deleted file mode 100644 index c0a188d7c3..0000000000 --- a/web/app/components/apps/hooks/use-apps-query-state.spec.ts +++ /dev/null @@ -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) - }) - }) -}) diff --git a/web/app/components/apps/hooks/use-apps-query-state.spec.tsx b/web/app/components/apps/hooks/use-apps-query-state.spec.tsx new file mode 100644 index 0000000000..29f2e17556 --- /dev/null +++ b/web/app/components/apps/hooks/use-apps-query-state.spec.tsx @@ -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 }) => ( + + {children} + + ) + 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) + }) + }) +}) diff --git a/web/app/components/apps/hooks/use-apps-query-state.ts b/web/app/components/apps/hooks/use-apps-query-state.ts index f142b9e97e..ecf7707e8a 100644 --- a/web/app/components/apps/hooks/use-apps-query-state.ts +++ b/web/app/components/apps/hooks/use-apps-query-state.ts @@ -1,6 +1,5 @@ -import type { ReadonlyURLSearchParams } from 'next/navigation' -import { usePathname, useRouter, useSearchParams } from 'next/navigation' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { parseAsArrayOf, parseAsBoolean, parseAsString, useQueryStates } from 'nuqs' +import { useCallback, useMemo } from 'react' type AppsQuery = { tagIDs?: string[] @@ -8,54 +7,51 @@ type AppsQuery = { isCreatedByMe?: boolean } -// Parse the query parameters from the URL search string. -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') -} +const normalizeKeywords = (value: string | null) => value || undefined function useAppsQueryState() { - const searchParams = useSearchParams() - const [query, setQuery] = useState(() => parseParams(searchParams)) + const [urlQuery, setUrlQuery] = useQueryStates( + { + tagIDs: parseAsArrayOf(parseAsString, ';'), + keywords: parseAsString, + isCreatedByMe: parseAsBoolean, + }, + { + history: 'push', + }, + ) - const router = useRouter() - const pathname = usePathname() - const syncSearchParams = useCallback((params: URLSearchParams) => { - const search = params.toString() - const query = search ? `?${search}` : '' - router.push(`${pathname}${query}`, { scroll: false }) - }, [router, pathname]) + const query = useMemo(() => ({ + tagIDs: urlQuery.tagIDs ?? undefined, + keywords: normalizeKeywords(urlQuery.keywords), + isCreatedByMe: urlQuery.isCreatedByMe ?? false, + }), [urlQuery.isCreatedByMe, urlQuery.keywords, urlQuery.tagIDs]) - // Update the URL search string whenever the query changes. - useEffect(() => { - const params = new URLSearchParams(searchParams) - updateSearchParams(query, params) - syncSearchParams(params) - }, [query, searchParams, syncSearchParams]) + const setQuery = useCallback((next: AppsQuery | ((prev: AppsQuery) => AppsQuery)) => { + const buildPatch = (patch: AppsQuery) => { + const result: Partial = {} + if ('tagIDs' in patch) + result.tagIDs = patch.tagIDs && patch.tagIDs.length > 0 ? patch.tagIDs : null + 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