diff --git a/.github/workflows/api-tests.yml b/.github/workflows/api-tests.yml index 76cbf64fca..152a9caee8 100644 --- a/.github/workflows/api-tests.yml +++ b/.github/workflows/api-tests.yml @@ -22,12 +22,12 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: persist-credentials: false - name: Setup UV and Python - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v7 with: enable-cache: true python-version: ${{ matrix.python-version }} @@ -57,7 +57,7 @@ jobs: run: sh .github/workflows/expose_service_ports.sh - name: Set up Sandbox - uses: hoverkraft-tech/compose-action@v2.0.2 + uses: hoverkraft-tech/compose-action@v2 with: compose-file: | docker/docker-compose.middleware.yaml diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index 97027c2218..5413f83c27 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -12,7 +12,7 @@ jobs: if: github.repository == 'langgenius/dify' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Check Docker Compose inputs id: docker-compose-changes @@ -27,7 +27,7 @@ jobs: with: python-version: "3.11" - - uses: astral-sh/setup-uv@v6 + - uses: astral-sh/setup-uv@v7 - name: Generate Docker Compose if: steps.docker-compose-changes.outputs.any_changed == 'true' diff --git a/.github/workflows/build-push.yml b/.github/workflows/build-push.yml index 1545b8e7df..cc9a17320c 100644 --- a/.github/workflows/build-push.yml +++ b/.github/workflows/build-push.yml @@ -91,7 +91,7 @@ jobs: touch "/tmp/digests/${sanitized_digest}" - name: Upload digest - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: digests-${{ matrix.context }}-${{ env.PLATFORM_PAIR }} path: /tmp/digests/* diff --git a/.github/workflows/db-migration-test.yml b/.github/workflows/db-migration-test.yml index 101d973466..e20cf9850b 100644 --- a/.github/workflows/db-migration-test.yml +++ b/.github/workflows/db-migration-test.yml @@ -13,13 +13,13 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 persist-credentials: false - name: Setup UV and Python - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v7 with: enable-cache: true python-version: "3.12" @@ -63,13 +63,13 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 persist-credentials: false - name: Setup UV and Python - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v7 with: enable-cache: true python-version: "3.12" diff --git a/.github/workflows/main-ci.yml b/.github/workflows/main-ci.yml index 876ec23a3d..d6653de950 100644 --- a/.github/workflows/main-ci.yml +++ b/.github/workflows/main-ci.yml @@ -27,7 +27,7 @@ jobs: vdb-changed: ${{ steps.changes.outputs.vdb }} migration-changed: ${{ steps.changes.outputs.migration }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: dorny/paths-filter@v3 id: changes with: @@ -38,6 +38,7 @@ jobs: - '.github/workflows/api-tests.yml' web: - 'web/**' + - '.github/workflows/web-tests.yml' vdb: - 'api/core/rag/datasource/**' - 'docker/**' diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml index 8710f422fc..d463349686 100644 --- a/.github/workflows/style.yml +++ b/.github/workflows/style.yml @@ -19,13 +19,13 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: persist-credentials: false - name: Check changed files id: changed-files - uses: tj-actions/changed-files@v46 + uses: tj-actions/changed-files@v47 with: files: | api/** @@ -33,7 +33,7 @@ jobs: - name: Setup UV and Python if: steps.changed-files.outputs.any_changed == 'true' - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v7 with: enable-cache: false python-version: "3.12" @@ -68,15 +68,17 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: persist-credentials: false - name: Check changed files id: changed-files - uses: tj-actions/changed-files@v46 + uses: tj-actions/changed-files@v47 with: - files: web/** + files: | + web/** + .github/workflows/style.yml - name: Install pnpm uses: pnpm/action-setup@v4 @@ -85,7 +87,7 @@ jobs: run_install: false - name: Setup NodeJS - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 if: steps.changed-files.outputs.any_changed == 'true' with: node-version: 22 @@ -114,14 +116,14 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 persist-credentials: false - name: Check changed files id: changed-files - uses: tj-actions/changed-files@v46 + uses: tj-actions/changed-files@v47 with: files: | **.sh diff --git a/.github/workflows/tool-test-sdks.yaml b/.github/workflows/tool-test-sdks.yaml index b1ccd7417a..0259ef2232 100644 --- a/.github/workflows/tool-test-sdks.yaml +++ b/.github/workflows/tool-test-sdks.yaml @@ -25,12 +25,12 @@ jobs: working-directory: sdks/nodejs-client steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: persist-credentials: false - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: ${{ matrix.node-version }} cache: '' diff --git a/.github/workflows/translate-i18n-base-on-english.yml b/.github/workflows/translate-i18n-base-on-english.yml index 87e24a4f90..58cd18371f 100644 --- a/.github/workflows/translate-i18n-base-on-english.yml +++ b/.github/workflows/translate-i18n-base-on-english.yml @@ -18,7 +18,7 @@ jobs: run: working-directory: web steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} @@ -51,7 +51,7 @@ jobs: - name: Set up Node.js if: env.FILES_CHANGED == 'true' - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 'lts/*' cache: pnpm diff --git a/.github/workflows/vdb-tests.yml b/.github/workflows/vdb-tests.yml index 291171e5c7..7735afdaca 100644 --- a/.github/workflows/vdb-tests.yml +++ b/.github/workflows/vdb-tests.yml @@ -19,19 +19,19 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: persist-credentials: false - name: Free Disk Space - uses: endersonmenezes/free-disk-space@v2 + uses: endersonmenezes/free-disk-space@v3 with: remove_dotnet: true remove_haskell: true remove_tool_cache: true - name: Setup UV and Python - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v7 with: enable-cache: true python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/web-tests.yml b/.github/workflows/web-tests.yml index 1a8925e38d..0fd1d5d22b 100644 --- a/.github/workflows/web-tests.yml +++ b/.github/workflows/web-tests.yml @@ -18,7 +18,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: persist-credentials: false @@ -29,7 +29,7 @@ jobs: run_install: false - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 22 cache: pnpm @@ -360,7 +360,7 @@ jobs: - name: Upload Coverage Artifact if: steps.coverage-summary.outputs.has_coverage == 'true' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: web-coverage-report path: web/coverage diff --git a/api/core/app/apps/base_app_queue_manager.py b/api/core/app/apps/base_app_queue_manager.py index 698eee9894..b41bedbea4 100644 --- a/api/core/app/apps/base_app_queue_manager.py +++ b/api/core/app/apps/base_app_queue_manager.py @@ -90,6 +90,7 @@ class AppQueueManager: """ self._clear_task_belong_cache() self._q.put(None) + self._graph_runtime_state = None # Release reference to allow GC to reclaim memory def _clear_task_belong_cache(self) -> None: """ diff --git a/web/app/components/app/create-app-dialog/app-list/index.spec.tsx b/web/app/components/app/create-app-dialog/app-list/index.spec.tsx index 42f510b468..ff0ab7db9c 100644 --- a/web/app/components/app/create-app-dialog/app-list/index.spec.tsx +++ b/web/app/components/app/create-app-dialog/app-list/index.spec.tsx @@ -21,8 +21,8 @@ vi.mock('use-context-selector', async () => { useContext: () => ({ hasEditPermission: true }), } }) -vi.mock('@/hooks/use-tab-searchparams', () => ({ - useTabSearchParams: () => ['Recommended', vi.fn()], +vi.mock('nuqs', () => ({ + useQueryState: () => ['Recommended', vi.fn()], })) vi.mock('@/service/use-explore', () => ({ useExploreAppList: () => mockUseExploreAppList(), 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..d991f7b8ef 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 @@ -20,7 +20,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,10 +63,7 @@ const Apps = ({ } const [currentType, setCurrentType] = useState([]) - const [currCategory, setCurrCategory] = useTabSearchParams({ - defaultTab: allCategoriesEn, - disableSearchParams: true, - }) + const [currCategory, setCurrCategory] = useState(allCategoriesEn) const { data, diff --git a/web/app/components/apps/hooks/use-apps-query-state.spec.ts b/web/app/components/apps/hooks/use-apps-query-state.spec.ts deleted file mode 100644 index c0a188d7c3..0000000000 --- a/web/app/components/apps/hooks/use-apps-query-state.spec.ts +++ /dev/null @@ -1,363 +0,0 @@ -/** - * Test suite for useAppsQueryState hook - * - * This hook manages app filtering state through URL search parameters, enabling: - * - Bookmarkable filter states (users can share URLs with specific filters active) - * - Browser history integration (back/forward buttons work with filters) - * - Multiple filter types: tagIDs, keywords, isCreatedByMe - * - * The hook syncs local filter state with URL search parameters, making filter - * navigation persistent and shareable across sessions. - */ -import { act, renderHook } from '@testing-library/react' - -// Import the hook after mocks are set up -import useAppsQueryState from './use-apps-query-state' - -// Mock Next.js navigation hooks -const mockPush = vi.fn() -const mockPathname = '/apps' -let mockSearchParams = new URLSearchParams() - -vi.mock('next/navigation', () => ({ - usePathname: vi.fn(() => mockPathname), - useRouter: vi.fn(() => ({ - push: mockPush, - })), - useSearchParams: vi.fn(() => mockSearchParams), -})) - -describe('useAppsQueryState', () => { - beforeEach(() => { - vi.clearAllMocks() - mockSearchParams = new URLSearchParams() - }) - - describe('Basic functionality', () => { - it('should return query object and setQuery function', () => { - const { result } = renderHook(() => useAppsQueryState()) - - expect(result.current.query).toBeDefined() - expect(typeof result.current.setQuery).toBe('function') - }) - - it('should initialize with empty query when no search params exist', () => { - const { result } = renderHook(() => useAppsQueryState()) - - expect(result.current.query.tagIDs).toBeUndefined() - expect(result.current.query.keywords).toBeUndefined() - expect(result.current.query.isCreatedByMe).toBe(false) - }) - }) - - describe('Parsing search params', () => { - it('should parse tagIDs from URL', () => { - mockSearchParams.set('tagIDs', 'tag1;tag2;tag3') - - const { result } = renderHook(() => useAppsQueryState()) - - expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2', 'tag3']) - }) - - it('should parse single tagID from URL', () => { - mockSearchParams.set('tagIDs', 'single-tag') - - const { result } = renderHook(() => useAppsQueryState()) - - expect(result.current.query.tagIDs).toEqual(['single-tag']) - }) - - it('should parse keywords from URL', () => { - mockSearchParams.set('keywords', 'search term') - - const { result } = renderHook(() => useAppsQueryState()) - - expect(result.current.query.keywords).toBe('search term') - }) - - it('should parse isCreatedByMe as true from URL', () => { - mockSearchParams.set('isCreatedByMe', 'true') - - const { result } = renderHook(() => useAppsQueryState()) - - expect(result.current.query.isCreatedByMe).toBe(true) - }) - - it('should parse isCreatedByMe as false for other values', () => { - mockSearchParams.set('isCreatedByMe', 'false') - - const { result } = renderHook(() => useAppsQueryState()) - - expect(result.current.query.isCreatedByMe).toBe(false) - }) - - it('should parse all params together', () => { - mockSearchParams.set('tagIDs', 'tag1;tag2') - mockSearchParams.set('keywords', 'test') - mockSearchParams.set('isCreatedByMe', 'true') - - const { result } = renderHook(() => useAppsQueryState()) - - expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2']) - expect(result.current.query.keywords).toBe('test') - expect(result.current.query.isCreatedByMe).toBe(true) - }) - }) - - describe('Updating query state', () => { - it('should update keywords via setQuery', () => { - const { result } = renderHook(() => useAppsQueryState()) - - act(() => { - result.current.setQuery({ keywords: 'new search' }) - }) - - expect(result.current.query.keywords).toBe('new search') - }) - - it('should update tagIDs via setQuery', () => { - const { result } = renderHook(() => useAppsQueryState()) - - act(() => { - result.current.setQuery({ tagIDs: ['tag1', 'tag2'] }) - }) - - expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2']) - }) - - it('should update isCreatedByMe via setQuery', () => { - const { result } = renderHook(() => useAppsQueryState()) - - act(() => { - result.current.setQuery({ isCreatedByMe: true }) - }) - - expect(result.current.query.isCreatedByMe).toBe(true) - }) - - it('should support partial updates via callback', () => { - const { result } = renderHook(() => useAppsQueryState()) - - act(() => { - result.current.setQuery({ keywords: 'initial' }) - }) - - act(() => { - result.current.setQuery(prev => ({ ...prev, isCreatedByMe: true })) - }) - - expect(result.current.query.keywords).toBe('initial') - expect(result.current.query.isCreatedByMe).toBe(true) - }) - }) - - describe('URL synchronization', () => { - it('should sync keywords to URL', async () => { - const { result } = renderHook(() => useAppsQueryState()) - - act(() => { - result.current.setQuery({ keywords: 'search' }) - }) - - // Wait for useEffect to run - await act(async () => { - await new Promise(resolve => setTimeout(resolve, 0)) - }) - - expect(mockPush).toHaveBeenCalledWith( - expect.stringContaining('keywords=search'), - { scroll: false }, - ) - }) - - it('should sync tagIDs to URL with semicolon separator', async () => { - const { result } = renderHook(() => useAppsQueryState()) - - act(() => { - result.current.setQuery({ tagIDs: ['tag1', 'tag2'] }) - }) - - await act(async () => { - await new Promise(resolve => setTimeout(resolve, 0)) - }) - - expect(mockPush).toHaveBeenCalledWith( - expect.stringContaining('tagIDs=tag1%3Btag2'), - { scroll: false }, - ) - }) - - it('should sync isCreatedByMe to URL', async () => { - const { result } = renderHook(() => useAppsQueryState()) - - act(() => { - result.current.setQuery({ isCreatedByMe: true }) - }) - - await act(async () => { - await new Promise(resolve => setTimeout(resolve, 0)) - }) - - expect(mockPush).toHaveBeenCalledWith( - expect.stringContaining('isCreatedByMe=true'), - { scroll: false }, - ) - }) - - it('should remove keywords from URL when empty', async () => { - mockSearchParams.set('keywords', 'existing') - - const { result } = renderHook(() => useAppsQueryState()) - - act(() => { - result.current.setQuery({ keywords: '' }) - }) - - await act(async () => { - await new Promise(resolve => setTimeout(resolve, 0)) - }) - - // Should be called without keywords param - expect(mockPush).toHaveBeenCalled() - }) - - it('should remove tagIDs from URL when empty array', async () => { - mockSearchParams.set('tagIDs', 'tag1;tag2') - - const { result } = renderHook(() => useAppsQueryState()) - - act(() => { - result.current.setQuery({ tagIDs: [] }) - }) - - await act(async () => { - await new Promise(resolve => setTimeout(resolve, 0)) - }) - - expect(mockPush).toHaveBeenCalled() - }) - - it('should remove isCreatedByMe from URL when false', async () => { - mockSearchParams.set('isCreatedByMe', 'true') - - const { result } = renderHook(() => useAppsQueryState()) - - act(() => { - result.current.setQuery({ isCreatedByMe: false }) - }) - - await act(async () => { - await new Promise(resolve => setTimeout(resolve, 0)) - }) - - expect(mockPush).toHaveBeenCalled() - }) - }) - - describe('Edge cases', () => { - it('should handle empty tagIDs string in URL', () => { - // NOTE: This test documents current behavior where ''.split(';') returns [''] - // This could potentially cause filtering issues as it's treated as a tag with empty name - // rather than absence of tags. Consider updating parseParams if this is problematic. - mockSearchParams.set('tagIDs', '') - - const { result } = renderHook(() => useAppsQueryState()) - - expect(result.current.query.tagIDs).toEqual(['']) - }) - - it('should handle empty keywords', () => { - mockSearchParams.set('keywords', '') - - const { result } = renderHook(() => useAppsQueryState()) - - expect(result.current.query.keywords).toBeUndefined() - }) - - it('should handle undefined tagIDs', () => { - const { result } = renderHook(() => useAppsQueryState()) - - act(() => { - result.current.setQuery({ tagIDs: undefined }) - }) - - expect(result.current.query.tagIDs).toBeUndefined() - }) - - it('should handle special characters in keywords', () => { - // Use URLSearchParams constructor to properly simulate URL decoding behavior - // URLSearchParams.get() decodes URL-encoded characters - mockSearchParams = new URLSearchParams('keywords=test%20with%20spaces') - - const { result } = renderHook(() => useAppsQueryState()) - - expect(result.current.query.keywords).toBe('test with spaces') - }) - }) - - describe('Memoization', () => { - it('should return memoized object reference when query unchanged', () => { - const { result, rerender } = renderHook(() => useAppsQueryState()) - - const firstResult = result.current - rerender() - const secondResult = result.current - - expect(firstResult.query).toBe(secondResult.query) - }) - - it('should return new object reference when query changes', () => { - const { result } = renderHook(() => useAppsQueryState()) - - const firstQuery = result.current.query - - act(() => { - result.current.setQuery({ keywords: 'changed' }) - }) - - expect(result.current.query).not.toBe(firstQuery) - }) - }) - - describe('Integration scenarios', () => { - it('should handle sequential updates', async () => { - const { result } = renderHook(() => useAppsQueryState()) - - act(() => { - result.current.setQuery({ keywords: 'first' }) - }) - - act(() => { - result.current.setQuery(prev => ({ ...prev, tagIDs: ['tag1'] })) - }) - - act(() => { - result.current.setQuery(prev => ({ ...prev, isCreatedByMe: true })) - }) - - expect(result.current.query.keywords).toBe('first') - expect(result.current.query.tagIDs).toEqual(['tag1']) - expect(result.current.query.isCreatedByMe).toBe(true) - }) - - it('should clear all filters', () => { - mockSearchParams.set('tagIDs', 'tag1;tag2') - mockSearchParams.set('keywords', 'search') - mockSearchParams.set('isCreatedByMe', 'true') - - const { result } = renderHook(() => useAppsQueryState()) - - act(() => { - result.current.setQuery({ - tagIDs: undefined, - keywords: undefined, - isCreatedByMe: false, - }) - }) - - expect(result.current.query.tagIDs).toBeUndefined() - expect(result.current.query.keywords).toBeUndefined() - expect(result.current.query.isCreatedByMe).toBe(false) - }) - }) -}) diff --git a/web/app/components/apps/hooks/use-apps-query-state.spec.tsx b/web/app/components/apps/hooks/use-apps-query-state.spec.tsx new file mode 100644 index 0000000000..29f2e17556 --- /dev/null +++ b/web/app/components/apps/hooks/use-apps-query-state.spec.tsx @@ -0,0 +1,248 @@ +import type { UrlUpdateEvent } from 'nuqs/adapters/testing' +import type { ReactNode } from 'react' +/** + * Test suite for useAppsQueryState hook + * + * This hook manages app filtering state through URL search parameters, enabling: + * - Bookmarkable filter states (users can share URLs with specific filters active) + * - Browser history integration (back/forward buttons work with filters) + * - Multiple filter types: tagIDs, keywords, isCreatedByMe + */ +import { act, renderHook, waitFor } from '@testing-library/react' +import { NuqsTestingAdapter } from 'nuqs/adapters/testing' +import useAppsQueryState from './use-apps-query-state' + +const renderWithAdapter = (searchParams = '') => { + const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>() + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ) + const { result } = renderHook(() => useAppsQueryState(), { wrapper }) + return { result, onUrlUpdate } +} + +// Groups scenarios for useAppsQueryState behavior. +describe('useAppsQueryState', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Covers the hook return shape and default values. + describe('Initialization', () => { + it('should expose query and setQuery when initialized', () => { + const { result } = renderWithAdapter() + + expect(result.current.query).toBeDefined() + expect(typeof result.current.setQuery).toBe('function') + }) + + it('should default to empty filters when search params are missing', () => { + const { result } = renderWithAdapter() + + expect(result.current.query.tagIDs).toBeUndefined() + expect(result.current.query.keywords).toBeUndefined() + expect(result.current.query.isCreatedByMe).toBe(false) + }) + }) + + // Covers parsing of existing URL search params. + describe('Parsing search params', () => { + it('should parse tagIDs when URL includes tagIDs', () => { + const { result } = renderWithAdapter('?tagIDs=tag1;tag2;tag3') + + expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2', 'tag3']) + }) + + it('should parse keywords when URL includes keywords', () => { + const { result } = renderWithAdapter('?keywords=search+term') + + expect(result.current.query.keywords).toBe('search term') + }) + + it('should parse isCreatedByMe when URL includes true value', () => { + const { result } = renderWithAdapter('?isCreatedByMe=true') + + expect(result.current.query.isCreatedByMe).toBe(true) + }) + + it('should parse all params when URL includes multiple filters', () => { + const { result } = renderWithAdapter( + '?tagIDs=tag1;tag2&keywords=test&isCreatedByMe=true', + ) + + expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2']) + expect(result.current.query.keywords).toBe('test') + expect(result.current.query.isCreatedByMe).toBe(true) + }) + }) + + // Covers updates driven by setQuery. + describe('Updating query state', () => { + it('should update keywords when setQuery receives keywords', () => { + const { result } = renderWithAdapter() + + act(() => { + result.current.setQuery({ keywords: 'new search' }) + }) + + expect(result.current.query.keywords).toBe('new search') + }) + + it('should update tagIDs when setQuery receives tagIDs', () => { + const { result } = renderWithAdapter() + + act(() => { + result.current.setQuery({ tagIDs: ['tag1', 'tag2'] }) + }) + + expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2']) + }) + + it('should update isCreatedByMe when setQuery receives true', () => { + const { result } = renderWithAdapter() + + act(() => { + result.current.setQuery({ isCreatedByMe: true }) + }) + + expect(result.current.query.isCreatedByMe).toBe(true) + }) + + it('should support partial updates when setQuery uses callback', () => { + const { result } = renderWithAdapter() + + act(() => { + result.current.setQuery({ keywords: 'initial' }) + }) + + act(() => { + result.current.setQuery(prev => ({ ...prev, isCreatedByMe: true })) + }) + + expect(result.current.query.keywords).toBe('initial') + expect(result.current.query.isCreatedByMe).toBe(true) + }) + }) + + // Covers URL updates triggered by query changes. + describe('URL synchronization', () => { + it('should sync keywords to URL when keywords change', async () => { + const { result, onUrlUpdate } = renderWithAdapter() + + act(() => { + result.current.setQuery({ keywords: 'search' }) + }) + + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) + const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0] + expect(update.searchParams.get('keywords')).toBe('search') + expect(update.options.history).toBe('push') + }) + + it('should sync tagIDs to URL when tagIDs change', async () => { + const { result, onUrlUpdate } = renderWithAdapter() + + act(() => { + result.current.setQuery({ tagIDs: ['tag1', 'tag2'] }) + }) + + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) + const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0] + expect(update.searchParams.get('tagIDs')).toBe('tag1;tag2') + }) + + it('should sync isCreatedByMe to URL when enabled', async () => { + const { result, onUrlUpdate } = renderWithAdapter() + + act(() => { + result.current.setQuery({ isCreatedByMe: true }) + }) + + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) + const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0] + expect(update.searchParams.get('isCreatedByMe')).toBe('true') + }) + + it('should remove keywords from URL when keywords are cleared', async () => { + const { result, onUrlUpdate } = renderWithAdapter('?keywords=existing') + + act(() => { + result.current.setQuery({ keywords: '' }) + }) + + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) + const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0] + expect(update.searchParams.has('keywords')).toBe(false) + }) + + it('should remove tagIDs from URL when tagIDs are empty', async () => { + const { result, onUrlUpdate } = renderWithAdapter('?tagIDs=tag1;tag2') + + act(() => { + result.current.setQuery({ tagIDs: [] }) + }) + + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) + const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0] + expect(update.searchParams.has('tagIDs')).toBe(false) + }) + + it('should remove isCreatedByMe from URL when disabled', async () => { + const { result, onUrlUpdate } = renderWithAdapter('?isCreatedByMe=true') + + act(() => { + result.current.setQuery({ isCreatedByMe: false }) + }) + + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) + const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0] + expect(update.searchParams.has('isCreatedByMe')).toBe(false) + }) + }) + + // Covers decoding and empty values. + describe('Edge cases', () => { + it('should treat empty tagIDs as empty list when URL param is empty', () => { + const { result } = renderWithAdapter('?tagIDs=') + + expect(result.current.query.tagIDs).toEqual([]) + }) + + it('should treat empty keywords as undefined when URL param is empty', () => { + const { result } = renderWithAdapter('?keywords=') + + expect(result.current.query.keywords).toBeUndefined() + }) + + it('should decode keywords with spaces when URL contains encoded spaces', () => { + const { result } = renderWithAdapter('?keywords=test+with+spaces') + + expect(result.current.query.keywords).toBe('test with spaces') + }) + }) + + // Covers multi-step updates that mimic real usage. + describe('Integration scenarios', () => { + it('should keep accumulated filters when updates are sequential', () => { + const { result } = renderWithAdapter() + + act(() => { + result.current.setQuery({ keywords: 'first' }) + }) + + act(() => { + result.current.setQuery(prev => ({ ...prev, tagIDs: ['tag1'] })) + }) + + act(() => { + result.current.setQuery(prev => ({ ...prev, isCreatedByMe: true })) + }) + + expect(result.current.query.keywords).toBe('first') + expect(result.current.query.tagIDs).toEqual(['tag1']) + expect(result.current.query.isCreatedByMe).toBe(true) + }) + }) +}) diff --git a/web/app/components/apps/hooks/use-apps-query-state.ts b/web/app/components/apps/hooks/use-apps-query-state.ts index f142b9e97e..ecf7707e8a 100644 --- a/web/app/components/apps/hooks/use-apps-query-state.ts +++ b/web/app/components/apps/hooks/use-apps-query-state.ts @@ -1,6 +1,5 @@ -import type { ReadonlyURLSearchParams } from 'next/navigation' -import { usePathname, useRouter, useSearchParams } from 'next/navigation' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { parseAsArrayOf, parseAsBoolean, parseAsString, useQueryStates } from 'nuqs' +import { useCallback, useMemo } from 'react' type AppsQuery = { tagIDs?: string[] @@ -8,54 +7,51 @@ type AppsQuery = { isCreatedByMe?: boolean } -// Parse the query parameters from the URL search string. -function parseParams(params: ReadonlyURLSearchParams): AppsQuery { - const tagIDs = params.get('tagIDs')?.split(';') - const keywords = params.get('keywords') || undefined - const isCreatedByMe = params.get('isCreatedByMe') === 'true' - return { tagIDs, keywords, isCreatedByMe } -} - -// Update the URL search string with the given query parameters. -function updateSearchParams(query: AppsQuery, current: URLSearchParams) { - const { tagIDs, keywords, isCreatedByMe } = query || {} - - if (tagIDs && tagIDs.length > 0) - current.set('tagIDs', tagIDs.join(';')) - else - current.delete('tagIDs') - - if (keywords) - current.set('keywords', keywords) - else - current.delete('keywords') - - if (isCreatedByMe) - current.set('isCreatedByMe', 'true') - else - current.delete('isCreatedByMe') -} +const normalizeKeywords = (value: string | null) => value || undefined function useAppsQueryState() { - const searchParams = useSearchParams() - const [query, setQuery] = useState(() => parseParams(searchParams)) + const [urlQuery, setUrlQuery] = useQueryStates( + { + tagIDs: parseAsArrayOf(parseAsString, ';'), + keywords: parseAsString, + isCreatedByMe: parseAsBoolean, + }, + { + history: 'push', + }, + ) - const router = useRouter() - const pathname = usePathname() - const syncSearchParams = useCallback((params: URLSearchParams) => { - const search = params.toString() - const query = search ? `?${search}` : '' - router.push(`${pathname}${query}`, { scroll: false }) - }, [router, pathname]) + const query = useMemo(() => ({ + tagIDs: urlQuery.tagIDs ?? undefined, + keywords: normalizeKeywords(urlQuery.keywords), + isCreatedByMe: urlQuery.isCreatedByMe ?? false, + }), [urlQuery.isCreatedByMe, urlQuery.keywords, urlQuery.tagIDs]) - // Update the URL search string whenever the query changes. - useEffect(() => { - const params = new URLSearchParams(searchParams) - updateSearchParams(query, params) - syncSearchParams(params) - }, [query, searchParams, syncSearchParams]) + const setQuery = useCallback((next: AppsQuery | ((prev: AppsQuery) => AppsQuery)) => { + const buildPatch = (patch: AppsQuery) => { + const result: Partial = {} + if ('tagIDs' in patch) + result.tagIDs = patch.tagIDs && patch.tagIDs.length > 0 ? patch.tagIDs : null + if ('keywords' in patch) + result.keywords = patch.keywords ? patch.keywords : null + if ('isCreatedByMe' in patch) + result.isCreatedByMe = patch.isCreatedByMe ? true : null + return result + } - return useMemo(() => ({ query, setQuery }), [query]) + if (typeof next === 'function') { + setUrlQuery(prev => buildPatch(next({ + tagIDs: prev.tagIDs ?? undefined, + keywords: normalizeKeywords(prev.keywords), + isCreatedByMe: prev.isCreatedByMe ?? false, + }))) + return + } + + setUrlQuery(buildPatch(next)) + }, [setUrlQuery]) + + return useMemo(() => ({ query, setQuery }), [query, setQuery]) } export default useAppsQueryState diff --git a/web/app/components/apps/list.spec.tsx b/web/app/components/apps/list.spec.tsx index 244a8d997d..cde601d61f 100644 --- a/web/app/components/apps/list.spec.tsx +++ b/web/app/components/apps/list.spec.tsx @@ -57,8 +57,13 @@ vi.mock('./hooks/use-dsl-drag-drop', () => ({ })) const mockSetActiveTab = vi.fn() -vi.mock('@/hooks/use-tab-searchparams', () => ({ - useTabSearchParams: () => ['all', mockSetActiveTab], +vi.mock('nuqs', () => ({ + useQueryState: () => ['all', mockSetActiveTab], + parseAsString: { + withDefault: () => ({ + withOptions: () => ({}), + }), + }, })) // Mock service hooks - use object for mutable state (vi.mock is hoisted) diff --git a/web/app/components/apps/list.tsx b/web/app/components/apps/list.tsx index cee0f892f2..839b0dd50f 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 { parseAsString, 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,9 +47,10 @@ 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', + parseAsString.withDefault('all').withOptions({ history: 'push' }), + ) const { query: { tagIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false }, setQuery } = useAppsQueryState() const [isCreatedByMe, setIsCreatedByMe] = useState(queryIsCreatedByMe) const [tagFilterValue, setTagFilterValue] = useState(tagIDs) diff --git a/web/app/components/explore/app-list/index.spec.tsx b/web/app/components/explore/app-list/index.spec.tsx index c84da1931c..e6ffc937f7 100644 --- a/web/app/components/explore/app-list/index.spec.tsx +++ b/web/app/components/explore/app-list/index.spec.tsx @@ -16,8 +16,8 @@ let mockIsError = false const mockHandleImportDSL = vi.fn() const mockHandleImportDSLConfirm = vi.fn() -vi.mock('@/hooks/use-tab-searchparams', () => ({ - useTabSearchParams: () => [mockTabValue, mockSetTab], +vi.mock('nuqs', () => ({ + useQueryState: () => [mockTabValue, mockSetTab], })) vi.mock('ahooks', async () => { 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/marketplace/context.tsx b/web/app/components/plugins/marketplace/context.tsx index 4053c4a556..536b9c9fc3 100644 --- a/web/app/components/plugins/marketplace/context.tsx +++ b/web/app/components/plugins/marketplace/context.tsx @@ -22,6 +22,7 @@ import { createContext, useContextSelector, } from 'use-context-selector' +import { useMarketplaceFilters } from '@/hooks/use-query-params' import { useInstalledPluginList } from '@/service/use-plugins' import { getValidCategoryKeys, @@ -37,7 +38,6 @@ import { PLUGIN_TYPE_SEARCH_MAP } from './plugin-type-switch' import { getMarketplaceListCondition, getMarketplaceListFilterType, - updateSearchParams, } from './utils' export type MarketplaceContextValue = { @@ -107,16 +107,22 @@ export const MarketplaceContextProvider = ({ scrollContainerId, showSearchParams, }: MarketplaceContextProviderProps) => { + // Use nuqs hook for URL-based filter state + const [urlFilters, setUrlFilters] = useMarketplaceFilters() + const { data, isSuccess } = useInstalledPluginList(!shouldExclude) const exclude = useMemo(() => { if (shouldExclude) return data?.plugins.map(plugin => plugin.plugin_id) }, [data?.plugins, shouldExclude]) - const queryFromSearchParams = searchParams?.q || '' - const tagsFromSearchParams = searchParams?.tags ? getValidTagKeys(searchParams.tags.split(',')) : [] + + // Initialize from URL params (legacy support) or use nuqs state + const queryFromSearchParams = searchParams?.q || urlFilters.q + const tagsFromSearchParams = getValidTagKeys(urlFilters.tags) const hasValidTags = !!tagsFromSearchParams.length - const hasValidCategory = getValidCategoryKeys(searchParams?.category) + const hasValidCategory = getValidCategoryKeys(urlFilters.category) const categoryFromSearchParams = hasValidCategory || PLUGIN_TYPE_SEARCH_MAP.all + const [searchPluginText, setSearchPluginText] = useState(queryFromSearchParams) const searchPluginTextRef = useRef(searchPluginText) const [filterPluginTags, setFilterPluginTags] = useState(tagsFromSearchParams) @@ -158,10 +164,6 @@ export const MarketplaceContextProvider = ({ sortOrder: sortRef.current.sortOrder, type: getMarketplaceListFilterType(activePluginTypeRef.current), }) - const url = new URL(window.location.href) - if (searchParams?.language) - url.searchParams.set('language', searchParams?.language) - history.replaceState({}, '', url) } else { if (shouldExclude && isSuccess) { @@ -183,28 +185,32 @@ export const MarketplaceContextProvider = ({ resetPlugins() }, [exclude, queryMarketplaceCollectionsAndPlugins, resetPlugins]) - const debouncedUpdateSearchParams = useMemo(() => debounce(() => { - updateSearchParams({ - query: searchPluginTextRef.current, - category: activePluginTypeRef.current, - tags: filterPluginTagsRef.current, - }) - }, 500), []) - - const handleUpdateSearchParams = useCallback((debounced?: boolean) => { + const applyUrlFilters = useCallback(() => { if (!showSearchParams) return + const nextFilters = { + q: searchPluginTextRef.current, + category: activePluginTypeRef.current, + tags: filterPluginTagsRef.current, + } + const categoryChanged = urlFilters.category !== nextFilters.category + setUrlFilters(nextFilters, { + history: categoryChanged ? 'push' : 'replace', + }) + }, [setUrlFilters, showSearchParams, urlFilters.category]) + + const debouncedUpdateSearchParams = useMemo(() => debounce(() => { + applyUrlFilters() + }, 500), [applyUrlFilters]) + + const handleUpdateSearchParams = useCallback((debounced?: boolean) => { if (debounced) { debouncedUpdateSearchParams() } else { - updateSearchParams({ - query: searchPluginTextRef.current, - category: activePluginTypeRef.current, - tags: filterPluginTagsRef.current, - }) + applyUrlFilters() } - }, [debouncedUpdateSearchParams, showSearchParams]) + }, [applyUrlFilters, debouncedUpdateSearchParams]) const handleQueryPlugins = useCallback((debounced?: boolean) => { handleUpdateSearchParams(debounced) diff --git a/web/app/components/plugins/marketplace/plugin-type-switch.tsx b/web/app/components/plugins/marketplace/plugin-type-switch.tsx index 963a7de9a9..bb4a419335 100644 --- a/web/app/components/plugins/marketplace/plugin-type-switch.tsx +++ b/web/app/components/plugins/marketplace/plugin-type-switch.tsx @@ -84,12 +84,14 @@ const PluginTypeSwitch = ({ const handlePopState = useCallback(() => { if (!showSearchParams) return + // nuqs handles popstate automatically const url = new URL(window.location.href) const category = url.searchParams.get('category') || PLUGIN_TYPE_SEARCH_MAP.all handleActivePluginTypeChange(category) }, [showSearchParams, handleActivePluginTypeChange]) useEffect(() => { + // nuqs manages popstate internally, but we keep this for URL sync window.addEventListener('popstate', handlePopState) return () => { window.removeEventListener('popstate', handlePopState) diff --git a/web/app/components/plugins/marketplace/utils.ts b/web/app/components/plugins/marketplace/utils.ts index 82af4b65c8..e51c9b76a6 100644 --- a/web/app/components/plugins/marketplace/utils.ts +++ b/web/app/components/plugins/marketplace/utils.ts @@ -1,7 +1,6 @@ import type { CollectionsAndPluginsSearchParams, MarketplaceCollection, - PluginsSearchParams, } from '@/app/components/plugins/marketplace/types' import type { Plugin } from '@/app/components/plugins/types' import { PluginCategoryEnum } from '@/app/components/plugins/types' @@ -152,22 +151,3 @@ export const getMarketplaceListFilterType = (category: string) => { return 'plugin' } - -export const updateSearchParams = (pluginsSearchParams: PluginsSearchParams) => { - const { query, category, tags } = pluginsSearchParams - const url = new URL(window.location.href) - const categoryChanged = url.searchParams.get('category') !== category - if (query) - url.searchParams.set('q', query) - else - url.searchParams.delete('q') - if (category) - url.searchParams.set('category', category) - else - url.searchParams.delete('category') - if (tags && tags.length) - url.searchParams.set('tags', tags.join(',')) - else - url.searchParams.delete('tags') - history[`${categoryChanged ? 'pushState' : 'replaceState'}`]({}, '', url) -} 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/plugins/plugin-page/index.tsx b/web/app/components/plugins/plugin-page/index.tsx index afa85d7010..047d1fb8cf 100644 --- a/web/app/components/plugins/plugin-page/index.tsx +++ b/web/app/components/plugins/plugin-page/index.tsx @@ -9,10 +9,6 @@ import { import { useBoolean } from 'ahooks' import { noop } from 'es-toolkit/compat' import Link from 'next/link' -import { - useRouter, - useSearchParams, -} from 'next/navigation' import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' @@ -25,6 +21,7 @@ import { MARKETPLACE_API_PREFIX, SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS } from '@ import { useGlobalPublicStore } from '@/context/global-public-context' import I18n from '@/context/i18n' import useDocumentTitle from '@/hooks/use-document-title' +import { usePluginInstallation } from '@/hooks/use-query-params' import { fetchBundleInfoFromMarketPlace, fetchManifestFromMarketPlace } from '@/service/plugins' import { sleep } from '@/utils' import { cn } from '@/utils/classnames' @@ -42,9 +39,6 @@ import PluginTasks from './plugin-tasks' import useReferenceSetting from './use-reference-setting' import { useUploader } from './use-uploader' -const PACKAGE_IDS_KEY = 'package-ids' -const BUNDLE_INFO_KEY = 'bundle-info' - export type PluginPageProps = { plugins: React.ReactNode marketplace: React.ReactNode @@ -55,33 +49,13 @@ const PluginPage = ({ }: PluginPageProps) => { const { t } = useTranslation() const { locale } = useContext(I18n) - const searchParams = useSearchParams() - const { replace } = useRouter() useDocumentTitle(t('plugin.metadata.title')) - // just support install one package now - const packageId = useMemo(() => { - const idStrings = searchParams.get(PACKAGE_IDS_KEY) - try { - return idStrings ? JSON.parse(idStrings)[0] : '' - } - catch { - return '' - } - }, [searchParams]) + // Use nuqs hook for installation state + const [{ packageId, bundleInfo }, setInstallState] = usePluginInstallation() const [uniqueIdentifier, setUniqueIdentifier] = useState(null) - const [dependencies, setDependencies] = useState([]) - const bundleInfo = useMemo(() => { - const info = searchParams.get(BUNDLE_INFO_KEY) - try { - return info ? JSON.parse(info) : undefined - } - catch { - return undefined - } - }, [searchParams]) const [isShowInstallFromMarketplace, { setTrue: showInstallFromMarketplace, @@ -90,11 +64,9 @@ const PluginPage = ({ const hideInstallFromMarketplace = () => { doHideInstallFromMarketplace() - const url = new URL(window.location.href) - url.searchParams.delete(PACKAGE_IDS_KEY) - url.searchParams.delete(BUNDLE_INFO_KEY) - replace(url.toString()) + setInstallState(null) } + const [manifest, setManifest] = useState(null) useEffect(() => { @@ -114,12 +86,17 @@ const PluginPage = ({ return } if (bundleInfo) { - const { data } = await fetchBundleInfoFromMarketPlace(bundleInfo) - setDependencies(data.version.dependencies) - showInstallFromMarketplace() + try { + const { data } = await fetchBundleInfoFromMarketPlace(bundleInfo) + setDependencies(data.version.dependencies) + showInstallFromMarketplace() + } + catch (error) { + console.error('Failed to load bundle info:', error) + } } })() - }, [packageId, bundleInfo]) + }, [packageId, bundleInfo, showInstallFromMarketplace]) const { referenceSetting, 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/app/education-apply/constants.ts b/web/app/education-apply/constants.ts index 2c42d2b263..cfe80556c6 100644 --- a/web/app/education-apply/constants.ts +++ b/web/app/education-apply/constants.ts @@ -1,4 +1,3 @@ export const EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION = 'getEducationVerify' export const EDUCATION_VERIFYING_LOCALSTORAGE_ITEM = 'educationVerifying' -export const EDUCATION_PRICING_SHOW_ACTION = 'educationPricing' export const EDUCATION_RE_VERIFY_ACTION = 'educationReVerify' diff --git a/web/app/education-apply/hooks.ts b/web/app/education-apply/hooks.ts index 27895b89be..52acde2975 100644 --- a/web/app/education-apply/hooks.ts +++ b/web/app/education-apply/hooks.ts @@ -15,7 +15,6 @@ import { useModalContextSelector } from '@/context/modal-context' import { useProviderContext } from '@/context/provider-context' import { useEducationAutocomplete, useEducationVerify } from '@/service/use-education' import { - EDUCATION_PRICING_SHOW_ACTION, EDUCATION_RE_VERIFY_ACTION, EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION, EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, @@ -133,7 +132,6 @@ const useEducationReverifyNotice = ({ export const useEducationInit = () => { const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal) - const setShowPricingModal = useModalContextSelector(s => s.setShowPricingModal) const setShowEducationExpireNoticeModal = useModalContextSelector(s => s.setShowEducationExpireNoticeModal) const educationVerifying = localStorage.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM) const searchParams = useSearchParams() @@ -160,8 +158,6 @@ export const useEducationInit = () => { if (educationVerifyAction === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION) localStorage.setItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, 'yes') } - if (educationVerifyAction === EDUCATION_PRICING_SHOW_ACTION) - setShowPricingModal() if (educationVerifyAction === EDUCATION_RE_VERIFY_ACTION) handleVerify() }, [setShowAccountSettingModal, educationVerifying, educationVerifyAction]) diff --git a/web/app/layout.tsx b/web/app/layout.tsx index 90bd6050a6..c182f12dc9 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -1,8 +1,7 @@ import type { Viewport } from 'next' import { ThemeProvider } from 'next-themes' -import dynamic from 'next/dynamic' import { Instrument_Serif } from 'next/font/google' -import { IS_DEV } from '@/config' +import { NuqsAdapter } from 'nuqs/adapters/next/app' import GlobalPublicStoreProvider from '@/context/global-public-context' import { TanstackQueryInitializer } from '@/context/query-client' import { getLocaleOnServer } from '@/i18n-config/server' @@ -10,15 +9,12 @@ import { DatasetAttr } from '@/types/feature' import { cn } from '@/utils/classnames' import BrowserInitializer from './components/browser-initializer' import I18nServer from './components/i18n-server' +import { ReactScan } from './components/react-scan' import SentryInitializer from './components/sentry-initializer' import RoutePrefixHandle from './routePrefixHandle' import './styles/globals.css' import './styles/markdown.scss' -const ReactScan = IS_DEV - ? dynamic(() => import('./components/react-scan').then(m => m.ReactScan), { ssr: false }) - : () => null - export const viewport: Viewport = { width: 'device-width', initialScale: 1, @@ -102,17 +98,19 @@ const LocaleLayout = async ({ disableTransitionOnChange enableColorScheme={false} > - - - - - - {children} - - - - - + + + + + + + {children} + + + + + + diff --git a/web/context/modal-context.test.tsx b/web/context/modal-context.test.tsx index 4f41c19df6..245ac027ac 100644 --- a/web/context/modal-context.test.tsx +++ b/web/context/modal-context.test.tsx @@ -1,4 +1,5 @@ import { act, render, screen, waitFor } from '@testing-library/react' +import { NuqsTestingAdapter } from 'nuqs/adapters/testing' import * as React from 'react' import { defaultPlan } from '@/app/components/billing/config' import { Plan } from '@/app/components/billing/type' @@ -72,9 +73,11 @@ const createPlan = (overrides: PlanOverrides = {}): PlanShape => ({ }) const renderProvider = () => render( - -
- , + + +
+ + , ) describe('ModalContextProvider trigger events limit modal', () => { diff --git a/web/context/modal-context.tsx b/web/context/modal-context.tsx index 5b417a64ff..dce7b9f6e1 100644 --- a/web/context/modal-context.tsx +++ b/web/context/modal-context.tsx @@ -24,21 +24,22 @@ import type { import type { ModerationConfig, PromptVariable } from '@/models/debug' import { noop } from 'es-toolkit/compat' import dynamic from 'next/dynamic' -import { useSearchParams } from 'next/navigation' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { createContext, useContext, useContextSelector } from 'use-context-selector' import { - ACCOUNT_SETTING_MODAL_ACTION, DEFAULT_ACCOUNT_SETTING_TAB, isValidAccountSettingTab, } from '@/app/components/header/account-setting/constants' import { - EDUCATION_PRICING_SHOW_ACTION, EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, } from '@/app/education-apply/constants' import { useAppContext } from '@/context/app-context' import { useProviderContext } from '@/context/provider-context' -import { removeSpecificQueryParam } from '@/utils' +import { + useAccountSettingModal, + usePricingModal, +} from '@/hooks/use-query-params' + import { useTriggerEventsLimitModal, @@ -125,8 +126,6 @@ export type ModalContextState = { setShowEducationExpireNoticeModal: Dispatch | null>> setShowTriggerEventsLimitModal: Dispatch | null>> } -const PRICING_MODAL_QUERY_PARAM = 'pricing' -const PRICING_MODAL_QUERY_VALUE = 'open' const ModalContext = createContext({ setShowAccountSettingModal: noop, @@ -157,16 +156,16 @@ type ModalContextProviderProps = { export const ModalContextProvider = ({ children, }: ModalContextProviderProps) => { - const searchParams = useSearchParams() + // Use nuqs hooks for URL-based modal state management + const [showPricingModal, setPricingModalOpen] = usePricingModal() + const [urlAccountModalState, setUrlAccountModalState] = useAccountSettingModal() - const [showAccountSettingModal, setShowAccountSettingModal] = useState | null>(() => { - if (searchParams.get('action') === ACCOUNT_SETTING_MODAL_ACTION) { - const tabParam = searchParams.get('tab') - const tab = isValidAccountSettingTab(tabParam) ? tabParam : DEFAULT_ACCOUNT_SETTING_TAB - return { payload: tab } - } - return null - }) + const accountSettingCallbacksRef = useRef, 'payload'> | null>(null) + const accountSettingTab = urlAccountModalState.isOpen + ? (isValidAccountSettingTab(urlAccountModalState.payload) + ? urlAccountModalState.payload + : DEFAULT_ACCOUNT_SETTING_TAB) + : null const [showApiBasedExtensionModal, setShowApiBasedExtensionModal] = useState | null>(null) const [showModerationSettingModal, setShowModerationSettingModal] = useState | null>(null) const [showExternalDataToolModal, setShowExternalDataToolModal] = useState | null>(null) @@ -182,9 +181,6 @@ export const ModalContextProvider = ({ const [showEducationExpireNoticeModal, setShowEducationExpireNoticeModal] = useState | null>(null) const { currentWorkspace } = useAppContext() - const [showPricingModal, setShowPricingModal] = useState( - searchParams.get(PRICING_MODAL_QUERY_PARAM) === PRICING_MODAL_QUERY_VALUE, - ) const [showAnnotationFullModal, setShowAnnotationFullModal] = useState(false) const handleCancelAccountSettingModal = () => { const educationVerifying = localStorage.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM) @@ -192,54 +188,34 @@ export const ModalContextProvider = ({ if (educationVerifying === 'yes') localStorage.removeItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM) - removeSpecificQueryParam('action') - removeSpecificQueryParam('tab') - setShowAccountSettingModal(null) - if (showAccountSettingModal?.onCancelCallback) - showAccountSettingModal?.onCancelCallback() + accountSettingCallbacksRef.current?.onCancelCallback?.() + accountSettingCallbacksRef.current = null + setUrlAccountModalState(null) } const handleAccountSettingTabChange = useCallback((tab: AccountSettingTab) => { - setShowAccountSettingModal((prev) => { - if (!prev) - return { payload: tab } - if (prev.payload === tab) - return prev - return { ...prev, payload: tab } - }) - }, [setShowAccountSettingModal]) + setUrlAccountModalState({ payload: tab }) + }, [setUrlAccountModalState]) + + const setShowAccountSettingModal = useCallback((next: SetStateAction | null>) => { + const currentState = accountSettingTab + ? { payload: accountSettingTab, ...(accountSettingCallbacksRef.current ?? {}) } + : null + const resolvedState = typeof next === 'function' ? next(currentState) : next + if (!resolvedState) { + accountSettingCallbacksRef.current = null + setUrlAccountModalState(null) + return + } + const { payload, ...callbacks } = resolvedState + accountSettingCallbacksRef.current = callbacks + setUrlAccountModalState({ payload }) + }, [accountSettingTab, setUrlAccountModalState]) useEffect(() => { - if (typeof window === 'undefined') - return - const url = new URL(window.location.href) - if (!showAccountSettingModal?.payload) { - if (url.searchParams.get('action') !== ACCOUNT_SETTING_MODAL_ACTION) - return - url.searchParams.delete('action') - url.searchParams.delete('tab') - window.history.replaceState(null, '', url.toString()) - return - } - url.searchParams.set('action', ACCOUNT_SETTING_MODAL_ACTION) - url.searchParams.set('tab', showAccountSettingModal.payload) - window.history.replaceState(null, '', url.toString()) - }, [showAccountSettingModal]) - - useEffect(() => { - if (typeof window === 'undefined') - return - const url = new URL(window.location.href) - if (showPricingModal) { - url.searchParams.set(PRICING_MODAL_QUERY_PARAM, PRICING_MODAL_QUERY_VALUE) - } - else { - url.searchParams.delete(PRICING_MODAL_QUERY_PARAM) - if (url.searchParams.get('action') === EDUCATION_PRICING_SHOW_ACTION) - url.searchParams.delete('action') - } - window.history.replaceState(null, '', url.toString()) - }, [showPricingModal]) + if (!urlAccountModalState.isOpen) + accountSettingCallbacksRef.current = null + }, [urlAccountModalState.isOpen]) const { plan, isFetchedPlan } = useProviderContext() const { @@ -337,12 +313,12 @@ export const ModalContextProvider = ({ } const handleShowPricingModal = useCallback(() => { - setShowPricingModal(true) - }, []) + setPricingModalOpen(true) + }, [setPricingModalOpen]) const handleCancelPricingModal = useCallback(() => { - setShowPricingModal(false) - }, []) + setPricingModalOpen(false) + }, [setPricingModalOpen]) return ( {children} { - !!showAccountSettingModal && ( + accountSettingTab && ( diff --git a/web/hooks/use-query-params.spec.tsx b/web/hooks/use-query-params.spec.tsx new file mode 100644 index 0000000000..2aa6b7998f --- /dev/null +++ b/web/hooks/use-query-params.spec.tsx @@ -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 = (hook: () => T, searchParams = '') => { + const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>() + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ) + 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() + }) +}) diff --git a/web/hooks/use-query-params.ts b/web/hooks/use-query-params.ts new file mode 100644 index 0000000000..e0d7cc3c02 --- /dev/null +++ b/web/hooks/use-query-params.ts @@ -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({ + 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() { + 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({ + 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({ + parse: (value) => { + try { + const parsed = JSON.parse(value) as Partial + 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()) +} 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 -} diff --git a/web/package.json b/web/package.json index 41f9f62a64..369cc212ab 100644 --- a/web/package.json +++ b/web/package.json @@ -108,6 +108,7 @@ "next": "~15.5.9", "next-pwa": "^5.6.0", "next-themes": "^0.4.6", + "nuqs": "^2.8.6", "pinyin-pro": "^3.27.0", "qrcode.react": "^4.2.0", "qs": "^6.14.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 8fbf4462d6..f69ac5adda 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -240,6 +240,9 @@ importers: next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + nuqs: + specifier: ^2.8.6 + version: 2.8.6(next@15.5.9(@babel/core@7.28.5)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.95.0))(react@19.2.3) pinyin-pro: specifier: ^3.27.0 version: 3.27.0 @@ -3205,6 +3208,9 @@ packages: peerDependencies: solid-js: ^1.6.12 + '@standard-schema/spec@1.0.0': + resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -3698,9 +3704,6 @@ packages: '@types/node@20.19.26': resolution: {integrity: sha512-0l6cjgF0XnihUpndDhk+nyD3exio3iKaYROSgvh/qSevPXax3L8p5DBRFjbvalnwatGgHEQn2R88y2fA3g4irg==} - '@types/node@20.19.27': - resolution: {integrity: sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==} - '@types/papaparse@5.5.1': resolution: {integrity: sha512-esEO+VISsLIyE+JZBmb89NzsYYbpwV8lmv2rPo6oX5y9KhBaIP7hhHgjuTut54qjdKVMufTEcrh5fUl9+58huw==} @@ -6315,8 +6318,8 @@ packages: lexical@0.38.2: resolution: {integrity: sha512-JJmfsG3c4gwBHzUGffbV7ifMNkKAWMCnYE3xJl87gty7hjyV5f3xq7eqTjP5HFYvO4XpjJvvWO2/djHp5S10tw==} - lib0@0.2.116: - resolution: {integrity: sha512-4zsosjzmt33rx5XjmFVYUAeLNh+BTeDTiwGdLt4muxiir2btsc60Nal0EvkvDRizg+pnlK1q+BtYi7M+d4eStw==} + lib0@0.2.115: + resolution: {integrity: sha512-noaW4yNp6hCjOgDnWWxW0vGXE3kZQI5Kqiwz+jIWXavI9J9WyfJ9zjsbQlQlgjIbHBrvlA/x3TSIXBUJj+0L6g==} engines: {node: '>=16'} hasBin: true @@ -6843,6 +6846,27 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + nuqs@2.8.6: + resolution: {integrity: sha512-aRxeX68b4ULmhio8AADL2be1FWDy0EPqaByPvIYWrA7Pm07UjlrICp/VPlSnXJNAG0+3MQwv3OporO2sOXMVGA==} + peerDependencies: + '@remix-run/react': '>=2' + '@tanstack/react-router': ^1 + next: '>=14.2.0' + react: '>=18.2.0 || ^19.0.0-0' + react-router: ^5 || ^6 || ^7 + react-router-dom: ^5 || ^6 || ^7 + peerDependenciesMeta: + '@remix-run/react': + optional: true + '@tanstack/react-router': + optional: true + next: + optional: true + react-router: + optional: true + react-router-dom: + optional: true + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -8711,7 +8735,6 @@ packages: whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} - deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-mimetype@3.0.0: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} @@ -11696,6 +11719,8 @@ snapshots: dependencies: solid-js: 1.9.10 + '@standard-schema/spec@1.0.0': {} + '@standard-schema/spec@1.1.0': {} '@standard-schema/utils@0.3.0': {} @@ -12342,11 +12367,6 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/node@20.19.27': - dependencies: - undici-types: 6.21.0 - optional: true - '@types/papaparse@5.5.1': dependencies: '@types/node': 18.15.0 @@ -14772,7 +14792,7 @@ snapshots: happy-dom@20.0.11: dependencies: - '@types/node': 20.19.27 + '@types/node': 20.19.26 '@types/whatwg-mimetype': 3.0.2 whatwg-mimetype: 3.0.0 optional: true @@ -15387,7 +15407,7 @@ snapshots: lexical@0.38.2: {} - lib0@0.2.116: + lib0@0.2.115: dependencies: isomorphic.js: 0.2.5 @@ -16253,6 +16273,13 @@ snapshots: dependencies: boolbase: 1.0.0 + nuqs@2.8.6(next@15.5.9(@babel/core@7.28.5)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.95.0))(react@19.2.3): + dependencies: + '@standard-schema/spec': 1.0.0 + react: 19.2.3 + optionalDependencies: + next: 15.5.9(@babel/core@7.28.5)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.95.0) + object-assign@4.1.1: {} object-deep-merge@2.0.0: {} @@ -18472,7 +18499,7 @@ snapshots: yjs@13.6.27: dependencies: - lib0: 0.2.116 + lib0: 0.2.115 yocto-queue@0.1.0: {} diff --git a/web/utils/index.spec.ts b/web/utils/index.spec.ts index 7eb6c32eca..a99e0ad134 100644 --- a/web/utils/index.spec.ts +++ b/web/utils/index.spec.ts @@ -1,4 +1,3 @@ -import type { Mock } from 'vitest' import { asyncRunSafe, canFindTool, @@ -8,7 +7,6 @@ import { getPurifyHref, getTextWidthWithCanvas, randomString, - removeSpecificQueryParam, sleep, } from './index' @@ -231,72 +229,6 @@ describe('canFindTool', () => { }) }) -describe('removeSpecificQueryParam', () => { - let originalLocation: Location - let originalReplaceState: typeof window.history.replaceState - - beforeEach(() => { - originalLocation = window.location - originalReplaceState = window.history.replaceState - - const mockUrl = new URL('https://example.com?param1=value1¶m2=value2¶m3=value3') - - // Mock window.location using defineProperty to handle URL properly - delete (window as any).location - Object.defineProperty(window, 'location', { - configurable: true, - writable: true, - value: { - ...originalLocation, - href: mockUrl.href, - search: mockUrl.search, - toString: () => mockUrl.toString(), - }, - }) - - window.history.replaceState = vi.fn() - }) - - afterEach(() => { - Object.defineProperty(window, 'location', { - configurable: true, - writable: true, - value: originalLocation, - }) - window.history.replaceState = originalReplaceState - }) - - it('should remove a single query parameter', () => { - removeSpecificQueryParam('param2') - expect(window.history.replaceState).toHaveBeenCalledTimes(1) - const replaceStateCall = (window.history.replaceState as Mock).mock.calls[0] - expect(replaceStateCall[0]).toBe(null) - expect(replaceStateCall[1]).toBe('') - expect(replaceStateCall[2]).toMatch(/param1=value1/) - expect(replaceStateCall[2]).toMatch(/param3=value3/) - expect(replaceStateCall[2]).not.toMatch(/param2=value2/) - }) - - it('should remove multiple query parameters', () => { - removeSpecificQueryParam(['param1', 'param3']) - expect(window.history.replaceState).toHaveBeenCalledTimes(1) - const replaceStateCall = (window.history.replaceState as Mock).mock.calls[0] - expect(replaceStateCall[2]).toMatch(/param2=value2/) - expect(replaceStateCall[2]).not.toMatch(/param1=value1/) - expect(replaceStateCall[2]).not.toMatch(/param3=value3/) - }) - - it('should handle non-existent parameters gracefully', () => { - removeSpecificQueryParam('nonexistent') - - expect(window.history.replaceState).toHaveBeenCalledTimes(1) - const replaceStateCall = (window.history.replaceState as Mock).mock.calls[0] - expect(replaceStateCall[2]).toMatch(/param1=value1/) - expect(replaceStateCall[2]).toMatch(/param2=value2/) - expect(replaceStateCall[2]).toMatch(/param3=value3/) - }) -}) - describe('sleep', () => { it('should resolve after specified milliseconds', async () => { const start = Date.now() @@ -560,47 +492,3 @@ describe('canFindTool extended', () => { expect(canFindTool('openai', undefined)).toBe(false) }) }) - -describe('removeSpecificQueryParam extended', () => { - beforeEach(() => { - // Reset window.location - delete (window as any).location - window.location = { - href: 'https://example.com?param1=value1¶m2=value2¶m3=value3', - } as any - }) - - it('should remove single query parameter', () => { - const mockReplaceState = vi.fn() - window.history.replaceState = mockReplaceState - - removeSpecificQueryParam('param1') - - expect(mockReplaceState).toHaveBeenCalled() - const newUrl = mockReplaceState.mock.calls[0][2] - expect(newUrl).not.toContain('param1') - }) - - it('should remove multiple query parameters', () => { - const mockReplaceState = vi.fn() - window.history.replaceState = mockReplaceState - - removeSpecificQueryParam(['param1', 'param2']) - - expect(mockReplaceState).toHaveBeenCalled() - const newUrl = mockReplaceState.mock.calls[0][2] - expect(newUrl).not.toContain('param1') - expect(newUrl).not.toContain('param2') - }) - - it('should preserve other parameters', () => { - const mockReplaceState = vi.fn() - window.history.replaceState = mockReplaceState - - removeSpecificQueryParam('param1') - - const newUrl = mockReplaceState.mock.calls[0][2] - expect(newUrl).toContain('param2') - expect(newUrl).toContain('param3') - }) -}) diff --git a/web/utils/index.ts b/web/utils/index.ts index ebb8b90645..5704e82d87 100644 --- a/web/utils/index.ts +++ b/web/utils/index.ts @@ -90,12 +90,3 @@ export const canFindTool = (providerId: string, oldToolId?: string) => { || providerId === `langgenius/${oldToolId}/${oldToolId}` || providerId === `langgenius/${oldToolId}_tool/${oldToolId}` } - -export const removeSpecificQueryParam = (key: string | string[]) => { - const url = new URL(window.location.href) - if (Array.isArray(key)) - key.forEach(k => url.searchParams.delete(k)) - else - url.searchParams.delete(key) - window.history.replaceState(null, '', url.toString()) -}