diff --git a/web/app/components/app/create-app-dialog/app-list/index.tsx b/web/app/components/app/create-app-dialog/app-list/index.tsx index 0df13e1ba1..6fd5d05aae 100644 --- a/web/app/components/app/create-app-dialog/app-list/index.tsx +++ b/web/app/components/app/create-app-dialog/app-list/index.tsx @@ -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([]) - const [currCategory, setCurrCategory] = useTabSearchParams({ - defaultTab: allCategoriesEn, - disableSearchParams: true, + const [currCategory, setCurrCategory] = useQueryState('category', { + defaultValue: allCategoriesEn, }) const { diff --git a/web/app/components/apps/list.tsx b/web/app/components/apps/list.tsx index cee0f892f2..f968fe7360 100644 --- a/web/app/components/apps/list.tsx +++ b/web/app/components/apps/list.tsx @@ -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) diff --git a/web/app/components/explore/app-list/index.tsx b/web/app/components/explore/app-list/index.tsx index 5ab68f9b04..da48139e4c 100644 --- a/web/app/components/explore/app-list/index.tsx +++ b/web/app/components/explore/app-list/index.tsx @@ -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 { diff --git a/web/app/components/plugins/plugin-page/context.tsx b/web/app/components/plugins/plugin-page/context.tsx index 146353da4f..3d420ca1ab 100644 --- a/web/app/components/plugins/plugin-page/context.tsx +++ b/web/app/components/plugins/plugin-page/context.tsx @@ -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 ( diff --git a/web/app/components/tools/provider-list.tsx b/web/app/components/tools/provider-list.tsx index 648ecb9802..95f36afcc3 100644 --- a/web/app/components/tools/provider-list.tsx +++ b/web/app/components/tools/provider-list.tsx @@ -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(null) - const [activeTab, setActiveTab] = useTabSearchParams({ - defaultTab: 'builtin', + const [activeTab, setActiveTab] = useQueryState('category', { + defaultValue: 'builtin', }) const options = [ { value: 'builtin', text: t('tools.type.builtIn') }, diff --git a/web/hooks/use-tab-searchparams.spec.ts b/web/hooks/use-tab-searchparams.spec.ts deleted file mode 100644 index e724f323af..0000000000 --- a/web/hooks/use-tab-searchparams.spec.ts +++ /dev/null @@ -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() - }) - }) -}) diff --git a/web/hooks/use-tab-searchparams.ts b/web/hooks/use-tab-searchparams.ts deleted file mode 100644 index 427da16eef..0000000000 --- a/web/hooks/use-tab-searchparams.ts +++ /dev/null @@ -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( - !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 -}