mirror of
https://github.com/langgenius/dify.git
synced 2026-04-05 10:12:43 +08:00
refactor(query-state): migrate query param state management to nuqs (#30184)
Co-authored-by: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
647
web/hooks/use-query-params.spec.tsx
Normal file
647
web/hooks/use-query-params.spec.tsx
Normal file
@@ -0,0 +1,647 @@
|
||||
import type { UrlUpdateEvent } from 'nuqs/adapters/testing'
|
||||
import type { ReactNode } from 'react'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||
import { ACCOUNT_SETTING_MODAL_ACTION } from '@/app/components/header/account-setting/constants'
|
||||
import {
|
||||
clearQueryParams,
|
||||
PRICING_MODAL_QUERY_PARAM,
|
||||
PRICING_MODAL_QUERY_VALUE,
|
||||
useAccountSettingModal,
|
||||
useMarketplaceFilters,
|
||||
usePluginInstallation,
|
||||
usePricingModal,
|
||||
} from './use-query-params'
|
||||
|
||||
const renderWithAdapter = <T,>(hook: () => T, searchParams = '') => {
|
||||
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>()
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<NuqsTestingAdapter searchParams={searchParams} onUrlUpdate={onUrlUpdate}>
|
||||
{children}
|
||||
</NuqsTestingAdapter>
|
||||
)
|
||||
const { result } = renderHook(hook, { wrapper })
|
||||
return { result, onUrlUpdate }
|
||||
}
|
||||
|
||||
// Query param hooks: defaults, parsing, and URL sync behavior.
|
||||
describe('useQueryParams hooks', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Pricing modal query behavior.
|
||||
describe('usePricingModal', () => {
|
||||
it('should return closed state when query param is missing', () => {
|
||||
// Arrange
|
||||
const { result } = renderWithAdapter(() => usePricingModal())
|
||||
|
||||
// Act
|
||||
const [isOpen] = result.current
|
||||
|
||||
// Assert
|
||||
expect(isOpen).toBe(false)
|
||||
})
|
||||
|
||||
it('should return open state when query param matches open value', () => {
|
||||
// Arrange
|
||||
const { result } = renderWithAdapter(
|
||||
() => usePricingModal(),
|
||||
`?${PRICING_MODAL_QUERY_PARAM}=${PRICING_MODAL_QUERY_VALUE}`,
|
||||
)
|
||||
|
||||
// Act
|
||||
const [isOpen] = result.current
|
||||
|
||||
// Assert
|
||||
expect(isOpen).toBe(true)
|
||||
})
|
||||
|
||||
it('should return closed state when query param has unexpected value', () => {
|
||||
// Arrange
|
||||
const { result } = renderWithAdapter(
|
||||
() => usePricingModal(),
|
||||
`?${PRICING_MODAL_QUERY_PARAM}=closed`,
|
||||
)
|
||||
|
||||
// Act
|
||||
const [isOpen] = result.current
|
||||
|
||||
// Assert
|
||||
expect(isOpen).toBe(false)
|
||||
})
|
||||
|
||||
it('should set pricing param when opening', async () => {
|
||||
// Arrange
|
||||
const { result, onUrlUpdate } = renderWithAdapter(() => usePricingModal())
|
||||
|
||||
// Act
|
||||
act(() => {
|
||||
result.current[1](true)
|
||||
})
|
||||
|
||||
// Assert
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.get(PRICING_MODAL_QUERY_PARAM)).toBe(PRICING_MODAL_QUERY_VALUE)
|
||||
})
|
||||
|
||||
it('should use push history when opening', async () => {
|
||||
// Arrange
|
||||
const { result, onUrlUpdate } = renderWithAdapter(() => usePricingModal())
|
||||
|
||||
// Act
|
||||
act(() => {
|
||||
result.current[1](true)
|
||||
})
|
||||
|
||||
// Assert
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.options.history).toBe('push')
|
||||
})
|
||||
|
||||
it('should clear pricing param when closing', async () => {
|
||||
// Arrange
|
||||
const { result, onUrlUpdate } = renderWithAdapter(
|
||||
() => usePricingModal(),
|
||||
`?${PRICING_MODAL_QUERY_PARAM}=${PRICING_MODAL_QUERY_VALUE}`,
|
||||
)
|
||||
|
||||
// Act
|
||||
act(() => {
|
||||
result.current[1](false)
|
||||
})
|
||||
|
||||
// Assert
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.has(PRICING_MODAL_QUERY_PARAM)).toBe(false)
|
||||
})
|
||||
|
||||
it('should use push history when closing', async () => {
|
||||
// Arrange
|
||||
const { result, onUrlUpdate } = renderWithAdapter(
|
||||
() => usePricingModal(),
|
||||
`?${PRICING_MODAL_QUERY_PARAM}=${PRICING_MODAL_QUERY_VALUE}`,
|
||||
)
|
||||
|
||||
// Act
|
||||
act(() => {
|
||||
result.current[1](false)
|
||||
})
|
||||
|
||||
// Assert
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.options.history).toBe('push')
|
||||
})
|
||||
|
||||
it('should respect explicit history options when provided', async () => {
|
||||
// Arrange
|
||||
const { result, onUrlUpdate } = renderWithAdapter(() => usePricingModal())
|
||||
|
||||
// Act
|
||||
act(() => {
|
||||
result.current[1](true, { history: 'replace' })
|
||||
})
|
||||
|
||||
// Assert
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.options.history).toBe('replace')
|
||||
})
|
||||
})
|
||||
|
||||
// Account settings modal query behavior.
|
||||
describe('useAccountSettingModal', () => {
|
||||
it('should return closed state with null payload when query params are missing', () => {
|
||||
// Arrange
|
||||
const { result } = renderWithAdapter(() => useAccountSettingModal())
|
||||
|
||||
// Act
|
||||
const [state] = result.current
|
||||
|
||||
// Assert
|
||||
expect(state.isOpen).toBe(false)
|
||||
expect(state.payload).toBeNull()
|
||||
})
|
||||
|
||||
it('should return open state when action matches', () => {
|
||||
// Arrange
|
||||
const { result } = renderWithAdapter(
|
||||
() => useAccountSettingModal(),
|
||||
`?action=${ACCOUNT_SETTING_MODAL_ACTION}&tab=billing`,
|
||||
)
|
||||
|
||||
// Act
|
||||
const [state] = result.current
|
||||
|
||||
// Assert
|
||||
expect(state.isOpen).toBe(true)
|
||||
expect(state.payload).toBe('billing')
|
||||
})
|
||||
|
||||
it('should return closed state when action does not match', () => {
|
||||
// Arrange
|
||||
const { result } = renderWithAdapter(
|
||||
() => useAccountSettingModal(),
|
||||
'?action=other&tab=billing',
|
||||
)
|
||||
|
||||
// Act
|
||||
const [state] = result.current
|
||||
|
||||
// Assert
|
||||
expect(state.isOpen).toBe(false)
|
||||
expect(state.payload).toBeNull()
|
||||
})
|
||||
|
||||
it('should set action and tab when opening', async () => {
|
||||
// Arrange
|
||||
const { result, onUrlUpdate } = renderWithAdapter(() => useAccountSettingModal())
|
||||
|
||||
// Act
|
||||
act(() => {
|
||||
result.current[1]({ payload: 'members' })
|
||||
})
|
||||
|
||||
// Assert
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.get('action')).toBe(ACCOUNT_SETTING_MODAL_ACTION)
|
||||
expect(update.searchParams.get('tab')).toBe('members')
|
||||
})
|
||||
|
||||
it('should use push history when opening from closed state', async () => {
|
||||
// Arrange
|
||||
const { result, onUrlUpdate } = renderWithAdapter(() => useAccountSettingModal())
|
||||
|
||||
// Act
|
||||
act(() => {
|
||||
result.current[1]({ payload: 'members' })
|
||||
})
|
||||
|
||||
// Assert
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.options.history).toBe('push')
|
||||
})
|
||||
|
||||
it('should update tab when switching while open', async () => {
|
||||
// Arrange
|
||||
const { result, onUrlUpdate } = renderWithAdapter(
|
||||
() => useAccountSettingModal(),
|
||||
`?action=${ACCOUNT_SETTING_MODAL_ACTION}&tab=billing`,
|
||||
)
|
||||
|
||||
// Act
|
||||
act(() => {
|
||||
result.current[1]({ payload: 'provider' })
|
||||
})
|
||||
|
||||
// Assert
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.get('tab')).toBe('provider')
|
||||
})
|
||||
|
||||
it('should use replace history when switching tabs while open', async () => {
|
||||
// Arrange
|
||||
const { result, onUrlUpdate } = renderWithAdapter(
|
||||
() => useAccountSettingModal(),
|
||||
`?action=${ACCOUNT_SETTING_MODAL_ACTION}&tab=billing`,
|
||||
)
|
||||
|
||||
// Act
|
||||
act(() => {
|
||||
result.current[1]({ payload: 'provider' })
|
||||
})
|
||||
|
||||
// Assert
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.options.history).toBe('replace')
|
||||
})
|
||||
|
||||
it('should clear action and tab when closing', async () => {
|
||||
// Arrange
|
||||
const { result, onUrlUpdate } = renderWithAdapter(
|
||||
() => useAccountSettingModal(),
|
||||
`?action=${ACCOUNT_SETTING_MODAL_ACTION}&tab=billing`,
|
||||
)
|
||||
|
||||
// Act
|
||||
act(() => {
|
||||
result.current[1](null)
|
||||
})
|
||||
|
||||
// Assert
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.has('action')).toBe(false)
|
||||
expect(update.searchParams.has('tab')).toBe(false)
|
||||
})
|
||||
|
||||
it('should use replace history when closing', async () => {
|
||||
// Arrange
|
||||
const { result, onUrlUpdate } = renderWithAdapter(
|
||||
() => useAccountSettingModal(),
|
||||
`?action=${ACCOUNT_SETTING_MODAL_ACTION}&tab=billing`,
|
||||
)
|
||||
|
||||
// Act
|
||||
act(() => {
|
||||
result.current[1](null)
|
||||
})
|
||||
|
||||
// Assert
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.options.history).toBe('replace')
|
||||
})
|
||||
})
|
||||
|
||||
// Marketplace filters query behavior.
|
||||
describe('useMarketplaceFilters', () => {
|
||||
it('should return default filters when query params are missing', () => {
|
||||
// Arrange
|
||||
const { result } = renderWithAdapter(() => useMarketplaceFilters())
|
||||
|
||||
// Act
|
||||
const [filters] = result.current
|
||||
|
||||
// Assert
|
||||
expect(filters.q).toBe('')
|
||||
expect(filters.category).toBe('all')
|
||||
expect(filters.tags).toEqual([])
|
||||
})
|
||||
|
||||
it('should parse filters when query params are present', () => {
|
||||
// Arrange
|
||||
const { result } = renderWithAdapter(
|
||||
() => useMarketplaceFilters(),
|
||||
'?q=prompt&category=tool&tags=ai,ml',
|
||||
)
|
||||
|
||||
// Act
|
||||
const [filters] = result.current
|
||||
|
||||
// Assert
|
||||
expect(filters.q).toBe('prompt')
|
||||
expect(filters.category).toBe('tool')
|
||||
expect(filters.tags).toEqual(['ai', 'ml'])
|
||||
})
|
||||
|
||||
it('should treat empty tags param as empty array', () => {
|
||||
// Arrange
|
||||
const { result } = renderWithAdapter(
|
||||
() => useMarketplaceFilters(),
|
||||
'?tags=',
|
||||
)
|
||||
|
||||
// Act
|
||||
const [filters] = result.current
|
||||
|
||||
// Assert
|
||||
expect(filters.tags).toEqual([])
|
||||
})
|
||||
|
||||
it('should preserve other filters when updating a single field', async () => {
|
||||
// Arrange
|
||||
const { result } = renderWithAdapter(
|
||||
() => useMarketplaceFilters(),
|
||||
'?category=tool&tags=ai,ml',
|
||||
)
|
||||
|
||||
// Act
|
||||
act(() => {
|
||||
result.current[1]({ q: 'search' })
|
||||
})
|
||||
|
||||
// Assert
|
||||
await waitFor(() => expect(result.current[0].q).toBe('search'))
|
||||
expect(result.current[0].category).toBe('tool')
|
||||
expect(result.current[0].tags).toEqual(['ai', 'ml'])
|
||||
})
|
||||
|
||||
it('should clear q param when q is empty', async () => {
|
||||
// Arrange
|
||||
const { result, onUrlUpdate } = renderWithAdapter(
|
||||
() => useMarketplaceFilters(),
|
||||
'?q=search',
|
||||
)
|
||||
|
||||
// Act
|
||||
act(() => {
|
||||
result.current[1]({ q: '' })
|
||||
})
|
||||
|
||||
// Assert
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.has('q')).toBe(false)
|
||||
})
|
||||
|
||||
it('should serialize tags as comma-separated values', async () => {
|
||||
// Arrange
|
||||
const { result, onUrlUpdate } = renderWithAdapter(() => useMarketplaceFilters())
|
||||
|
||||
// Act
|
||||
act(() => {
|
||||
result.current[1]({ tags: ['ai', 'ml'] })
|
||||
})
|
||||
|
||||
// Assert
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.get('tags')).toBe('ai,ml')
|
||||
})
|
||||
|
||||
it('should remove tags param when list is empty', async () => {
|
||||
// Arrange
|
||||
const { result, onUrlUpdate } = renderWithAdapter(
|
||||
() => useMarketplaceFilters(),
|
||||
'?tags=ai,ml',
|
||||
)
|
||||
|
||||
// Act
|
||||
act(() => {
|
||||
result.current[1]({ tags: [] })
|
||||
})
|
||||
|
||||
// Assert
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.has('tags')).toBe(false)
|
||||
})
|
||||
|
||||
it('should keep category in the URL when set to default', async () => {
|
||||
// Arrange
|
||||
const { result, onUrlUpdate } = renderWithAdapter(
|
||||
() => useMarketplaceFilters(),
|
||||
'?category=tool',
|
||||
)
|
||||
|
||||
// Act
|
||||
act(() => {
|
||||
result.current[1]({ category: 'all' })
|
||||
})
|
||||
|
||||
// Assert
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.get('category')).toBe('all')
|
||||
})
|
||||
|
||||
it('should clear all marketplace filters when set to null', async () => {
|
||||
// Arrange
|
||||
const { result, onUrlUpdate } = renderWithAdapter(
|
||||
() => useMarketplaceFilters(),
|
||||
'?q=search&category=tool&tags=ai,ml',
|
||||
)
|
||||
|
||||
// Act
|
||||
act(() => {
|
||||
result.current[1](null)
|
||||
})
|
||||
|
||||
// Assert
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.has('q')).toBe(false)
|
||||
expect(update.searchParams.has('category')).toBe(false)
|
||||
expect(update.searchParams.has('tags')).toBe(false)
|
||||
})
|
||||
|
||||
it('should use replace history when updating filters', async () => {
|
||||
// Arrange
|
||||
const { result, onUrlUpdate } = renderWithAdapter(() => useMarketplaceFilters())
|
||||
|
||||
// Act
|
||||
act(() => {
|
||||
result.current[1]({ q: 'search' })
|
||||
})
|
||||
|
||||
// Assert
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.options.history).toBe('replace')
|
||||
})
|
||||
})
|
||||
|
||||
// Plugin installation query behavior.
|
||||
describe('usePluginInstallation', () => {
|
||||
it('should parse package ids from JSON arrays', () => {
|
||||
// Arrange
|
||||
const bundleInfo = { org: 'org', name: 'bundle', version: '1.0.0' }
|
||||
const { result } = renderWithAdapter(
|
||||
() => usePluginInstallation(),
|
||||
`?package-ids=%5B%22org%2Fplugin%22%5D&bundle-info=${encodeURIComponent(JSON.stringify(bundleInfo))}`,
|
||||
)
|
||||
|
||||
// Act
|
||||
const [state] = result.current
|
||||
|
||||
// Assert
|
||||
expect(state.packageId).toBe('org/plugin')
|
||||
expect(state.bundleInfo).toEqual(bundleInfo)
|
||||
})
|
||||
|
||||
it('should return raw package id when JSON parsing fails', () => {
|
||||
// Arrange
|
||||
const { result } = renderWithAdapter(
|
||||
() => usePluginInstallation(),
|
||||
'?package-ids=org/plugin',
|
||||
)
|
||||
|
||||
// Act
|
||||
const [state] = result.current
|
||||
|
||||
// Assert
|
||||
expect(state.packageId).toBe('org/plugin')
|
||||
})
|
||||
|
||||
it('should return raw package id when JSON is not an array', () => {
|
||||
// Arrange
|
||||
const { result } = renderWithAdapter(
|
||||
() => usePluginInstallation(),
|
||||
'?package-ids=%22org%2Fplugin%22',
|
||||
)
|
||||
|
||||
// Act
|
||||
const [state] = result.current
|
||||
|
||||
// Assert
|
||||
expect(state.packageId).toBe('"org/plugin"')
|
||||
})
|
||||
|
||||
it('should write package ids as JSON arrays when setting packageId', async () => {
|
||||
// Arrange
|
||||
const { result, onUrlUpdate } = renderWithAdapter(() => usePluginInstallation())
|
||||
|
||||
// Act
|
||||
act(() => {
|
||||
result.current[1]({ packageId: 'org/plugin' })
|
||||
})
|
||||
|
||||
// Assert
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.get('package-ids')).toBe('["org/plugin"]')
|
||||
})
|
||||
|
||||
it('should set bundle info when provided', async () => {
|
||||
// Arrange
|
||||
const bundleInfo = { org: 'org', name: 'bundle', version: '1.0.0' }
|
||||
const { result, onUrlUpdate } = renderWithAdapter(() => usePluginInstallation())
|
||||
|
||||
// Act
|
||||
act(() => {
|
||||
result.current[1]({ bundleInfo })
|
||||
})
|
||||
|
||||
// Assert
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.get('bundle-info')).toBe(JSON.stringify(bundleInfo))
|
||||
})
|
||||
|
||||
it('should clear installation params when state is null', async () => {
|
||||
// Arrange
|
||||
const bundleInfo = { org: 'org', name: 'bundle', version: '1.0.0' }
|
||||
const { result, onUrlUpdate } = renderWithAdapter(
|
||||
() => usePluginInstallation(),
|
||||
`?package-ids=%5B%22org%2Fplugin%22%5D&bundle-info=${encodeURIComponent(JSON.stringify(bundleInfo))}`,
|
||||
)
|
||||
|
||||
// Act
|
||||
act(() => {
|
||||
result.current[1](null)
|
||||
})
|
||||
|
||||
// Assert
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.has('package-ids')).toBe(false)
|
||||
expect(update.searchParams.has('bundle-info')).toBe(false)
|
||||
})
|
||||
|
||||
it('should preserve bundle info when only packageId is updated', async () => {
|
||||
// Arrange
|
||||
const bundleInfo = { org: 'org', name: 'bundle', version: '1.0.0' }
|
||||
const { result, onUrlUpdate } = renderWithAdapter(
|
||||
() => usePluginInstallation(),
|
||||
`?bundle-info=${encodeURIComponent(JSON.stringify(bundleInfo))}`,
|
||||
)
|
||||
|
||||
// Act
|
||||
act(() => {
|
||||
result.current[1]({ packageId: 'org/plugin' })
|
||||
})
|
||||
|
||||
// Assert
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.get('bundle-info')).toBe(JSON.stringify(bundleInfo))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Utility to clear query params from the current URL.
|
||||
describe('clearQueryParams', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
window.history.replaceState(null, '', '/')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('should remove a single key when provided one key', () => {
|
||||
// Arrange
|
||||
const replaceSpy = vi.spyOn(window.history, 'replaceState')
|
||||
window.history.pushState(null, '', '/?foo=1&bar=2')
|
||||
|
||||
// Act
|
||||
clearQueryParams('foo')
|
||||
|
||||
// Assert
|
||||
expect(replaceSpy).toHaveBeenCalled()
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
expect(params.has('foo')).toBe(false)
|
||||
expect(params.get('bar')).toBe('2')
|
||||
replaceSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should remove multiple keys when provided an array', () => {
|
||||
// Arrange
|
||||
const replaceSpy = vi.spyOn(window.history, 'replaceState')
|
||||
window.history.pushState(null, '', '/?foo=1&bar=2&baz=3')
|
||||
|
||||
// Act
|
||||
clearQueryParams(['foo', 'baz'])
|
||||
|
||||
// Assert
|
||||
expect(replaceSpy).toHaveBeenCalled()
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
expect(params.has('foo')).toBe(false)
|
||||
expect(params.has('baz')).toBe(false)
|
||||
expect(params.get('bar')).toBe('2')
|
||||
replaceSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should no-op when window is undefined', () => {
|
||||
// Arrange
|
||||
const replaceSpy = vi.spyOn(window.history, 'replaceState')
|
||||
vi.stubGlobal('window', undefined)
|
||||
|
||||
// Act
|
||||
expect(() => clearQueryParams('foo')).not.toThrow()
|
||||
|
||||
// Assert
|
||||
expect(replaceSpy).not.toHaveBeenCalled()
|
||||
replaceSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
222
web/hooks/use-query-params.ts
Normal file
222
web/hooks/use-query-params.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Centralized URL query parameter management hooks using nuqs
|
||||
*
|
||||
* This file provides type-safe, performant query parameter management
|
||||
* that doesn't trigger full page refreshes (shallow routing).
|
||||
*
|
||||
* Best practices from nuqs documentation:
|
||||
* - Use useQueryState for single parameters
|
||||
* - Use useQueryStates for multiple related parameters (atomic updates)
|
||||
* - Always provide parsers with defaults for type safety
|
||||
* - Use shallow routing to avoid unnecessary re-renders
|
||||
*/
|
||||
|
||||
import {
|
||||
createParser,
|
||||
parseAsArrayOf,
|
||||
parseAsString,
|
||||
useQueryState,
|
||||
useQueryStates,
|
||||
} from 'nuqs'
|
||||
import { useCallback } from 'react'
|
||||
import { ACCOUNT_SETTING_MODAL_ACTION } from '@/app/components/header/account-setting/constants'
|
||||
|
||||
/**
|
||||
* Modal State Query Parameters
|
||||
* Manages modal visibility and configuration via URL
|
||||
*/
|
||||
export const PRICING_MODAL_QUERY_PARAM = 'pricing'
|
||||
export const PRICING_MODAL_QUERY_VALUE = 'open'
|
||||
const parseAsPricingModal = createParser<boolean>({
|
||||
parse: value => (value === PRICING_MODAL_QUERY_VALUE ? true : null),
|
||||
serialize: value => (value ? PRICING_MODAL_QUERY_VALUE : ''),
|
||||
})
|
||||
.withDefault(false)
|
||||
.withOptions({ history: 'push' })
|
||||
|
||||
/**
|
||||
* Hook to manage pricing modal state via URL
|
||||
* @returns [isOpen, setIsOpen] - Tuple like useState
|
||||
*
|
||||
* @example
|
||||
* const [isOpen, setIsOpen] = usePricingModal()
|
||||
* setIsOpen(true) // Sets ?pricing=open
|
||||
* setIsOpen(false) // Removes ?pricing
|
||||
*/
|
||||
export function usePricingModal() {
|
||||
return useQueryState(
|
||||
PRICING_MODAL_QUERY_PARAM,
|
||||
parseAsPricingModal,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage account setting modal state via URL
|
||||
* @returns [state, setState] - Object with isOpen + payload (tab) and setter
|
||||
*
|
||||
* @example
|
||||
* const [accountModalState, setAccountModalState] = useAccountSettingModal()
|
||||
* setAccountModalState({ payload: 'billing' }) // Sets ?action=showSettings&tab=billing
|
||||
* setAccountModalState(null) // Removes both params
|
||||
*/
|
||||
export function useAccountSettingModal<T extends string = string>() {
|
||||
const [accountState, setAccountState] = useQueryStates(
|
||||
{
|
||||
action: parseAsString,
|
||||
tab: parseAsString,
|
||||
},
|
||||
{
|
||||
history: 'replace',
|
||||
},
|
||||
)
|
||||
|
||||
const setState = useCallback(
|
||||
(state: { payload: T } | null) => {
|
||||
if (!state) {
|
||||
setAccountState({ action: null, tab: null }, { history: 'replace' })
|
||||
return
|
||||
}
|
||||
const shouldPush = accountState.action !== ACCOUNT_SETTING_MODAL_ACTION
|
||||
setAccountState(
|
||||
{ action: ACCOUNT_SETTING_MODAL_ACTION, tab: state.payload },
|
||||
{ history: shouldPush ? 'push' : 'replace' },
|
||||
)
|
||||
},
|
||||
[accountState.action, setAccountState],
|
||||
)
|
||||
|
||||
const isOpen = accountState.action === ACCOUNT_SETTING_MODAL_ACTION
|
||||
const currentTab = (isOpen ? accountState.tab : null) as T | null
|
||||
|
||||
return [{ isOpen, payload: currentTab }, setState] as const
|
||||
}
|
||||
|
||||
/**
|
||||
* Marketplace Search Query Parameters
|
||||
*/
|
||||
export type MarketplaceFilters = {
|
||||
q: string // search query
|
||||
category: string // plugin category
|
||||
tags: string[] // array of tags
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage marketplace search/filter state via URL
|
||||
* Provides atomic updates - all params update together
|
||||
*
|
||||
* @example
|
||||
* const [filters, setFilters] = useMarketplaceFilters()
|
||||
* setFilters({ q: 'search', category: 'tool', tags: ['ai'] }) // Updates all at once
|
||||
* setFilters({ q: '' }) // Only updates q, keeps others
|
||||
* setFilters(null) // Clears all marketplace params
|
||||
*/
|
||||
export function useMarketplaceFilters() {
|
||||
return useQueryStates(
|
||||
{
|
||||
q: parseAsString.withDefault(''),
|
||||
category: parseAsString.withDefault('all').withOptions({ clearOnDefault: false }),
|
||||
tags: parseAsArrayOf(parseAsString).withDefault([]),
|
||||
},
|
||||
{
|
||||
// Update URL without pushing to history (replaceState behavior)
|
||||
history: 'replace',
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin Installation Query Parameters
|
||||
*/
|
||||
const PACKAGE_IDS_PARAM = 'package-ids'
|
||||
const BUNDLE_INFO_PARAM = 'bundle-info'
|
||||
type BundleInfoQuery = {
|
||||
org: string
|
||||
name: string
|
||||
version: string
|
||||
}
|
||||
|
||||
const parseAsPackageId = createParser<string>({
|
||||
parse: (value) => {
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
if (Array.isArray(parsed)) {
|
||||
const first = parsed[0]
|
||||
return typeof first === 'string' ? first : null
|
||||
}
|
||||
return value
|
||||
}
|
||||
catch {
|
||||
return value
|
||||
}
|
||||
},
|
||||
serialize: value => JSON.stringify([value]),
|
||||
})
|
||||
|
||||
const parseAsBundleInfo = createParser<BundleInfoQuery>({
|
||||
parse: (value) => {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as Partial<BundleInfoQuery>
|
||||
if (parsed
|
||||
&& typeof parsed.org === 'string'
|
||||
&& typeof parsed.name === 'string'
|
||||
&& typeof parsed.version === 'string') {
|
||||
return { org: parsed.org, name: parsed.name, version: parsed.version }
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
return null
|
||||
},
|
||||
serialize: value => JSON.stringify(value),
|
||||
})
|
||||
|
||||
/**
|
||||
* Hook to manage plugin installation state via URL
|
||||
* @returns [installState, setInstallState] - installState includes parsed packageId and bundleInfo
|
||||
*
|
||||
* @example
|
||||
* const [installState, setInstallState] = usePluginInstallation()
|
||||
* setInstallState({ packageId: 'org/plugin' }) // Sets ?package-ids=["org/plugin"]
|
||||
* setInstallState({ bundleInfo: { org: 'org', name: 'bundle', version: '1.0.0' } }) // Sets ?bundle-info=...
|
||||
* setInstallState(null) // Clears installation params
|
||||
*/
|
||||
export function usePluginInstallation() {
|
||||
return useQueryStates(
|
||||
{
|
||||
packageId: parseAsPackageId,
|
||||
bundleInfo: parseAsBundleInfo,
|
||||
},
|
||||
{
|
||||
urlKeys: {
|
||||
packageId: PACKAGE_IDS_PARAM,
|
||||
bundleInfo: BUNDLE_INFO_PARAM,
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to clear specific query parameters from URL
|
||||
* This is a client-side utility that should be called from client components
|
||||
*
|
||||
* @param keys - Single key or array of keys to remove from URL
|
||||
*
|
||||
* @example
|
||||
* // In a client component
|
||||
* clearQueryParams('param1')
|
||||
* clearQueryParams(['param1', 'param2'])
|
||||
*/
|
||||
export function clearQueryParams(keys: string | string[]) {
|
||||
if (typeof window === 'undefined')
|
||||
return
|
||||
|
||||
const url = new URL(window.location.href)
|
||||
const keysArray = Array.isArray(keys) ? keys : [keys]
|
||||
|
||||
keysArray.forEach(key => url.searchParams.delete(key))
|
||||
|
||||
window.history.replaceState(null, '', url.toString())
|
||||
}
|
||||
@@ -1,545 +0,0 @@
|
||||
import type { Mock } from 'vitest'
|
||||
/**
|
||||
* Test suite for useTabSearchParams hook
|
||||
*
|
||||
* This hook manages tab state through URL search parameters, enabling:
|
||||
* - Bookmarkable tab states (users can share URLs with specific tabs active)
|
||||
* - Browser history integration (back/forward buttons work with tabs)
|
||||
* - Configurable routing behavior (push vs replace)
|
||||
* - Optional search parameter syncing (can disable URL updates)
|
||||
*
|
||||
* The hook syncs a local tab state with URL search parameters, making tab
|
||||
* navigation persistent and shareable across sessions.
|
||||
*/
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
// Import after mocks
|
||||
import { usePathname } from 'next/navigation'
|
||||
|
||||
import { useTabSearchParams } from './use-tab-searchparams'
|
||||
|
||||
// Mock Next.js navigation hooks
|
||||
const mockPush = vi.fn()
|
||||
const mockReplace = vi.fn()
|
||||
const mockPathname = '/test-path'
|
||||
const mockSearchParams = new URLSearchParams()
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: vi.fn(() => mockPathname),
|
||||
useRouter: vi.fn(() => ({
|
||||
push: mockPush,
|
||||
replace: mockReplace,
|
||||
})),
|
||||
useSearchParams: vi.fn(() => mockSearchParams),
|
||||
}))
|
||||
|
||||
describe('useTabSearchParams', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSearchParams.delete('category')
|
||||
mockSearchParams.delete('tab')
|
||||
})
|
||||
|
||||
describe('Basic functionality', () => {
|
||||
/**
|
||||
* Test that the hook returns a tuple with activeTab and setActiveTab
|
||||
* This is the primary interface matching React's useState pattern
|
||||
*/
|
||||
it('should return activeTab and setActiveTab function', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
const [activeTab, setActiveTab] = result.current
|
||||
|
||||
expect(typeof activeTab).toBe('string')
|
||||
expect(typeof setActiveTab).toBe('function')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that the hook initializes with the default tab
|
||||
* When no search param is present, should use defaultTab
|
||||
*/
|
||||
it('should initialize with default tab when no search param exists', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
const [activeTab] = result.current
|
||||
expect(activeTab).toBe('overview')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that the hook reads from URL search parameters
|
||||
* When a search param exists, it should take precedence over defaultTab
|
||||
*/
|
||||
it('should initialize with search param value when present', () => {
|
||||
mockSearchParams.set('category', 'settings')
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
const [activeTab] = result.current
|
||||
expect(activeTab).toBe('settings')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that setActiveTab updates the local state
|
||||
* The active tab should change when setActiveTab is called
|
||||
*/
|
||||
it('should update active tab when setActiveTab is called', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('settings')
|
||||
})
|
||||
|
||||
const [activeTab] = result.current
|
||||
expect(activeTab).toBe('settings')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Routing behavior', () => {
|
||||
/**
|
||||
* Test default push routing behavior
|
||||
* By default, tab changes should use router.push (adds to history)
|
||||
*/
|
||||
it('should use push routing by default', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('settings')
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/test-path?category=settings', { scroll: false })
|
||||
expect(mockReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
/**
|
||||
* Test replace routing behavior
|
||||
* When routingBehavior is 'replace', should use router.replace (no history)
|
||||
*/
|
||||
it('should use replace routing when specified', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({
|
||||
defaultTab: 'overview',
|
||||
routingBehavior: 'replace',
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('settings')
|
||||
})
|
||||
|
||||
expect(mockReplace).toHaveBeenCalledWith('/test-path?category=settings', { scroll: false })
|
||||
expect(mockPush).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that URL encoding is applied to tab values
|
||||
* Special characters in tab names should be properly encoded
|
||||
*/
|
||||
it('should encode special characters in tab values', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('settings & config')
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith(
|
||||
'/test-path?category=settings%20%26%20config',
|
||||
{ scroll: false },
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that URL decoding is applied when reading from search params
|
||||
* Encoded values in the URL should be properly decoded
|
||||
*/
|
||||
it('should decode encoded values from search params', () => {
|
||||
mockSearchParams.set('category', 'settings%20%26%20config')
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
const [activeTab] = result.current
|
||||
expect(activeTab).toBe('settings & config')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Custom search parameter name', () => {
|
||||
/**
|
||||
* Test using a custom search parameter name
|
||||
* Should support different param names instead of default 'category'
|
||||
*/
|
||||
it('should use custom search param name', () => {
|
||||
mockSearchParams.set('tab', 'profile')
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({
|
||||
defaultTab: 'overview',
|
||||
searchParamName: 'tab',
|
||||
}),
|
||||
)
|
||||
|
||||
const [activeTab] = result.current
|
||||
expect(activeTab).toBe('profile')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that setActiveTab uses the custom param name in the URL
|
||||
*/
|
||||
it('should update URL with custom param name', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({
|
||||
defaultTab: 'overview',
|
||||
searchParamName: 'tab',
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('profile')
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/test-path?tab=profile', { scroll: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Disabled search params mode', () => {
|
||||
/**
|
||||
* Test that disableSearchParams prevents URL updates
|
||||
* When disabled, tab state should be local only
|
||||
*/
|
||||
it('should not update URL when disableSearchParams is true', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({
|
||||
defaultTab: 'overview',
|
||||
disableSearchParams: true,
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('settings')
|
||||
})
|
||||
|
||||
expect(mockPush).not.toHaveBeenCalled()
|
||||
expect(mockReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that local state still updates when search params are disabled
|
||||
* The tab state should work even without URL syncing
|
||||
*/
|
||||
it('should still update local state when search params disabled', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({
|
||||
defaultTab: 'overview',
|
||||
disableSearchParams: true,
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('settings')
|
||||
})
|
||||
|
||||
const [activeTab] = result.current
|
||||
expect(activeTab).toBe('settings')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that disabled mode always uses defaultTab
|
||||
* Search params should be ignored when disabled
|
||||
*/
|
||||
it('should use defaultTab when search params disabled even if URL has value', () => {
|
||||
mockSearchParams.set('category', 'settings')
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({
|
||||
defaultTab: 'overview',
|
||||
disableSearchParams: true,
|
||||
}),
|
||||
)
|
||||
|
||||
const [activeTab] = result.current
|
||||
expect(activeTab).toBe('overview')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge cases', () => {
|
||||
/**
|
||||
* Test handling of empty string tab values
|
||||
* Empty strings should be handled gracefully
|
||||
*/
|
||||
it('should handle empty string tab values', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('')
|
||||
})
|
||||
|
||||
const [activeTab] = result.current
|
||||
expect(activeTab).toBe('')
|
||||
expect(mockPush).toHaveBeenCalledWith('/test-path?category=', { scroll: false })
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that special characters in tab names are properly encoded
|
||||
* This ensures URLs remain valid even with unusual tab names
|
||||
*/
|
||||
it('should handle tabs with various special characters', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
// Test tab with slashes
|
||||
act(() => result.current[1]('tab/with/slashes'))
|
||||
expect(result.current[0]).toBe('tab/with/slashes')
|
||||
|
||||
// Test tab with question marks
|
||||
act(() => result.current[1]('tab?with?questions'))
|
||||
expect(result.current[0]).toBe('tab?with?questions')
|
||||
|
||||
// Test tab with hash symbols
|
||||
act(() => result.current[1]('tab#with#hash'))
|
||||
expect(result.current[0]).toBe('tab#with#hash')
|
||||
|
||||
// Test tab with equals signs
|
||||
act(() => result.current[1]('tab=with=equals'))
|
||||
expect(result.current[0]).toBe('tab=with=equals')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test fallback when pathname is not available
|
||||
* Should use window.location.pathname as fallback
|
||||
*/
|
||||
it('should fallback to window.location.pathname when hook pathname is null', () => {
|
||||
;(usePathname as Mock).mockReturnValue(null)
|
||||
|
||||
// Mock window.location.pathname
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { pathname: '/fallback-path' },
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('settings')
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/fallback-path?category=settings', { scroll: false })
|
||||
|
||||
// Restore mock
|
||||
;(usePathname as Mock).mockReturnValue(mockPathname)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Multiple instances', () => {
|
||||
/**
|
||||
* Test that multiple instances with different param names work independently
|
||||
* Different hooks should not interfere with each other
|
||||
*/
|
||||
it('should support multiple independent tab states', () => {
|
||||
mockSearchParams.set('category', 'overview')
|
||||
mockSearchParams.set('subtab', 'details')
|
||||
|
||||
const { result: result1 } = renderHook(() =>
|
||||
useTabSearchParams({
|
||||
defaultTab: 'home',
|
||||
searchParamName: 'category',
|
||||
}),
|
||||
)
|
||||
|
||||
const { result: result2 } = renderHook(() =>
|
||||
useTabSearchParams({
|
||||
defaultTab: 'info',
|
||||
searchParamName: 'subtab',
|
||||
}),
|
||||
)
|
||||
|
||||
const [activeTab1] = result1.current
|
||||
const [activeTab2] = result2.current
|
||||
|
||||
expect(activeTab1).toBe('overview')
|
||||
expect(activeTab2).toBe('details')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Integration scenarios', () => {
|
||||
/**
|
||||
* Test typical usage in a tabbed interface
|
||||
* Simulates real-world tab switching behavior
|
||||
*/
|
||||
it('should handle sequential tab changes', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
// Change to settings tab
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('settings')
|
||||
})
|
||||
|
||||
expect(result.current[0]).toBe('settings')
|
||||
expect(mockPush).toHaveBeenCalledWith('/test-path?category=settings', { scroll: false })
|
||||
|
||||
// Change to profile tab
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('profile')
|
||||
})
|
||||
|
||||
expect(result.current[0]).toBe('profile')
|
||||
expect(mockPush).toHaveBeenCalledWith('/test-path?category=profile', { scroll: false })
|
||||
|
||||
// Verify push was called twice
|
||||
expect(mockPush).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that the hook works with complex pathnames
|
||||
* Should handle nested routes and existing query params
|
||||
*/
|
||||
it('should work with complex pathnames', () => {
|
||||
;(usePathname as Mock).mockReturnValue('/app/123/settings')
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('advanced')
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/app/123/settings?category=advanced', { scroll: false })
|
||||
|
||||
// Restore mock
|
||||
;(usePathname as Mock).mockReturnValue(mockPathname)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Type safety', () => {
|
||||
/**
|
||||
* Test that the return type is a const tuple
|
||||
* TypeScript should infer [string, (tab: string) => void] as const
|
||||
*/
|
||||
it('should return a const tuple type', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
// The result should be a tuple with exactly 2 elements
|
||||
expect(result.current).toHaveLength(2)
|
||||
expect(typeof result.current[0]).toBe('string')
|
||||
expect(typeof result.current[1]).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Performance', () => {
|
||||
/**
|
||||
* Test that the hook creates a new function on each render
|
||||
* Note: The current implementation doesn't use useCallback,
|
||||
* so setActiveTab is recreated on each render. This could lead to
|
||||
* unnecessary re-renders in child components that depend on this function.
|
||||
* TODO: Consider memoizing setActiveTab with useCallback for better performance.
|
||||
*/
|
||||
it('should create new setActiveTab function on each render', () => {
|
||||
const { result, rerender } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
const [, firstSetActiveTab] = result.current
|
||||
rerender()
|
||||
const [, secondSetActiveTab] = result.current
|
||||
|
||||
// Function reference changes on re-render (not memoized)
|
||||
expect(firstSetActiveTab).not.toBe(secondSetActiveTab)
|
||||
|
||||
// But both functions should work correctly
|
||||
expect(typeof firstSetActiveTab).toBe('function')
|
||||
expect(typeof secondSetActiveTab).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Browser history integration', () => {
|
||||
/**
|
||||
* Test that push behavior adds to browser history
|
||||
* This enables back/forward navigation through tabs
|
||||
*/
|
||||
it('should add to history with push behavior', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({
|
||||
defaultTab: 'overview',
|
||||
routingBehavior: 'push',
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('tab1')
|
||||
})
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('tab2')
|
||||
})
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('tab3')
|
||||
})
|
||||
|
||||
// Each tab change should create a history entry
|
||||
expect(mockPush).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that replace behavior doesn't add to history
|
||||
* This prevents cluttering browser history with tab changes
|
||||
*/
|
||||
it('should not add to history with replace behavior', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({
|
||||
defaultTab: 'overview',
|
||||
routingBehavior: 'replace',
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('tab1')
|
||||
})
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('tab2')
|
||||
})
|
||||
|
||||
// Should use replace instead of push
|
||||
expect(mockReplace).toHaveBeenCalledTimes(2)
|
||||
expect(mockPush).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,47 +0,0 @@
|
||||
'use client'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useState } from 'react'
|
||||
|
||||
type UseTabSearchParamsOptions = {
|
||||
defaultTab: string
|
||||
routingBehavior?: 'push' | 'replace'
|
||||
searchParamName?: string
|
||||
disableSearchParams?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook to manage tab state via URL search parameters in a Next.js application.
|
||||
* This hook allows for syncing the active tab with the browser's URL, enabling bookmarking and sharing of URLs with a specific tab activated.
|
||||
*
|
||||
* @param {UseTabSearchParamsOptions} options Configuration options for the hook:
|
||||
* - `defaultTab`: The tab to default to when no tab is specified in the URL.
|
||||
* - `routingBehavior`: Optional. Determines how changes to the active tab update the browser's history ('push' or 'replace'). Default is 'push'.
|
||||
* - `searchParamName`: Optional. The name of the search parameter that holds the tab state in the URL. Default is 'category'.
|
||||
* @returns A tuple where the first element is the active tab and the second element is a function to set the active tab.
|
||||
*/
|
||||
export const useTabSearchParams = ({
|
||||
defaultTab,
|
||||
routingBehavior = 'push',
|
||||
searchParamName = 'category',
|
||||
disableSearchParams = false,
|
||||
}: UseTabSearchParamsOptions) => {
|
||||
const pathnameFromHook = usePathname()
|
||||
const router = useRouter()
|
||||
const pathName = pathnameFromHook || window?.location?.pathname
|
||||
const searchParams = useSearchParams()
|
||||
const searchParamValue = searchParams.has(searchParamName) ? decodeURIComponent(searchParams.get(searchParamName)!) : defaultTab
|
||||
const [activeTab, setTab] = useState<string>(
|
||||
!disableSearchParams
|
||||
? searchParamValue
|
||||
: defaultTab,
|
||||
)
|
||||
|
||||
const setActiveTab = (newActiveTab: string) => {
|
||||
setTab(newActiveTab)
|
||||
if (disableSearchParams)
|
||||
return
|
||||
router[`${routingBehavior}`](`${pathName}?${searchParamName}=${encodeURIComponent(newActiveTab)}`, { scroll: false })
|
||||
}
|
||||
|
||||
return [activeTab, setActiveTab] as const
|
||||
}
|
||||
Reference in New Issue
Block a user