mirror of https://github.com/langgenius/dify.git
remove useTabSearchParams, test not ready
This commit is contained in:
parent
eeb2b9d39c
commit
ddd66af024
|
|
@ -5,6 +5,7 @@ import type { App } from '@/models/explore'
|
|||
import { RiRobot2Line } from '@remixicon/react'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import * as React from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
|
@ -20,7 +21,6 @@ import { usePluginDependencies } from '@/app/components/workflow/plugin-dependen
|
|||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import ExploreContext from '@/context/explore-context'
|
||||
import { useTabSearchParams } from '@/hooks/use-tab-searchparams'
|
||||
import { DSLImportMode } from '@/models/app'
|
||||
import { importDSL } from '@/service/apps'
|
||||
import { fetchAppDetail } from '@/service/explore'
|
||||
|
|
@ -64,9 +64,8 @@ const Apps = ({
|
|||
}
|
||||
|
||||
const [currentType, setCurrentType] = useState<AppModeEnum[]>([])
|
||||
const [currCategory, setCurrCategory] = useTabSearchParams({
|
||||
defaultTab: allCategoriesEn,
|
||||
disableSearchParams: true,
|
||||
const [currCategory, setCurrCategory] = useQueryState('category', {
|
||||
defaultValue: allCategoriesEn,
|
||||
})
|
||||
|
||||
const {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import dynamic from 'next/dynamic'
|
|||
import {
|
||||
useRouter,
|
||||
} from 'next/navigation'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
|
|
@ -24,7 +25,6 @@ import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
|||
import { useAppContext } from '@/context/app-context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { CheckModal } from '@/hooks/use-pay'
|
||||
import { useTabSearchParams } from '@/hooks/use-tab-searchparams'
|
||||
import { useInfiniteAppList } from '@/service/use-apps'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import AppCard from './app-card'
|
||||
|
|
@ -47,8 +47,8 @@ const List = () => {
|
|||
const router = useRouter()
|
||||
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator } = useAppContext()
|
||||
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
|
||||
const [activeTab, setActiveTab] = useTabSearchParams({
|
||||
defaultTab: 'all',
|
||||
const [activeTab, setActiveTab] = useQueryState('category', {
|
||||
defaultValue: 'all',
|
||||
})
|
||||
const { query: { tagIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false }, setQuery } = useAppsQueryState()
|
||||
const [isCreatedByMe, setIsCreatedByMe] = useState(queryIsCreatedByMe)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
|
||||
import type { App } from '@/models/explore'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
|
@ -15,7 +16,6 @@ import Category from '@/app/components/explore/category'
|
|||
import CreateAppModal from '@/app/components/explore/create-app-modal'
|
||||
import ExploreContext from '@/context/explore-context'
|
||||
import { useImportDSL } from '@/hooks/use-import-dsl'
|
||||
import { useTabSearchParams } from '@/hooks/use-tab-searchparams'
|
||||
import {
|
||||
DSLImportMode,
|
||||
} from '@/models/app'
|
||||
|
|
@ -47,9 +47,8 @@ const Apps = ({
|
|||
handleSearch()
|
||||
}
|
||||
|
||||
const [currCategory, setCurrCategory] = useTabSearchParams({
|
||||
defaultTab: allCategoriesEn,
|
||||
disableSearchParams: false,
|
||||
const [currCategory, setCurrCategory] = useQueryState('category', {
|
||||
defaultValue: allCategoriesEn,
|
||||
})
|
||||
|
||||
const {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import type { ReactNode, RefObject } from 'react'
|
||||
import type { FilterState } from './filter-management'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import {
|
||||
useMemo,
|
||||
useRef,
|
||||
|
|
@ -13,7 +14,6 @@ import {
|
|||
useContextSelector,
|
||||
} from 'use-context-selector'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useTabSearchParams } from '@/hooks/use-tab-searchparams'
|
||||
import { PLUGIN_PAGE_TABS_MAP, usePluginPageTabs } from '../hooks'
|
||||
|
||||
export type PluginPageContextValue = {
|
||||
|
|
@ -68,8 +68,8 @@ export const PluginPageContextProvider = ({
|
|||
const options = useMemo(() => {
|
||||
return enable_marketplace ? tabs : tabs.filter(tab => tab.value !== PLUGIN_PAGE_TABS_MAP.marketplace)
|
||||
}, [tabs, enable_marketplace])
|
||||
const [activeTab, setActiveTab] = useTabSearchParams({
|
||||
defaultTab: options[0].value,
|
||||
const [activeTab, setActiveTab] = useQueryState('category', {
|
||||
defaultValue: options[0].value,
|
||||
})
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
'use client'
|
||||
import type { Collection } from './types'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
|
|
@ -14,7 +15,6 @@ import CustomCreateCard from '@/app/components/tools/provider/custom-create-card
|
|||
import ProviderDetail from '@/app/components/tools/provider/detail'
|
||||
import WorkflowToolEmpty from '@/app/components/tools/provider/empty'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useTabSearchParams } from '@/hooks/use-tab-searchparams'
|
||||
import { useCheckInstalled, useInvalidateInstalledPluginList } from '@/service/use-plugins'
|
||||
import { useAllToolProviders } from '@/service/use-tools'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
|
@ -45,8 +45,8 @@ const ProviderList = () => {
|
|||
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [activeTab, setActiveTab] = useTabSearchParams({
|
||||
defaultTab: 'builtin',
|
||||
const [activeTab, setActiveTab] = useQueryState('category', {
|
||||
defaultValue: 'builtin',
|
||||
})
|
||||
const options = [
|
||||
{ value: 'builtin', text: t('tools.type.builtIn') },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue