Merge branch 'main' into 12-24-json-for-translation

This commit is contained in:
Stephen Zhou 2025-12-29 11:33:00 +08:00 committed by GitHub
commit 21956767bd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 1370 additions and 1359 deletions

View File

@ -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

View File

@ -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'

View File

@ -90,7 +90,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/*

View File

@ -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"

View File

@ -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/**'

View File

@ -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

View File

@ -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: ''

View File

@ -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

View File

@ -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 }}

View File

@ -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

View File

@ -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(),

View File

@ -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<AppModeEnum[]>([])
const [currCategory, setCurrCategory] = useTabSearchParams({
defaultTab: allCategoriesEn,
disableSearchParams: true,
})
const [currCategory, setCurrCategory] = useState<AppCategories | string>(allCategoriesEn)
const {
data,

View File

@ -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)
})
})
})

View File

@ -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 }) => (
<NuqsTestingAdapter searchParams={searchParams} onUrlUpdate={onUrlUpdate}>
{children}
</NuqsTestingAdapter>
)
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)
})
})
})

View File

@ -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<AppsQuery>(() => 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<AppsQuery>(() => ({
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<typeof urlQuery> = {}
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

View File

@ -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)

View File

@ -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<string[]>(tagIDs)

View File

@ -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 () => {

View File

@ -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 {

View File

@ -23,6 +23,7 @@ import {
createContext,
useContextSelector,
} from 'use-context-selector'
import { useMarketplaceFilters } from '@/hooks/use-query-params'
import { useInstalledPluginList } from '@/service/use-plugins'
import {
getValidCategoryKeys,
@ -38,7 +39,6 @@ import { PLUGIN_TYPE_SEARCH_MAP } from './plugin-type-switch'
import {
getMarketplaceListCondition,
getMarketplaceListFilterType,
updateSearchParams,
} from './utils'
export type MarketplaceContextValue = {
@ -108,16 +108,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(',') as TagKey[]) : []
// 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<string[]>(tagsFromSearchParams)
@ -159,10 +165,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) {
@ -184,28 +186,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)

View File

@ -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)

View File

@ -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)
}

View File

@ -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 (

View File

@ -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('metadata.title', { ns: 'plugin' }))
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<string | null>(null)
const [dependencies, setDependencies] = useState<Dependency[]>([])
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<PluginDeclaration | PluginManifestInMarket | null>(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,

View File

@ -1,5 +1,6 @@
'use client'
import type { Collection } from './types'
import { useQueryState } from 'nuqs'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
@ -14,7 +15,6 @@ import CustomCreateCard from '@/app/components/tools/provider/custom-create-card
import ProviderDetail from '@/app/components/tools/provider/detail'
import WorkflowToolEmpty from '@/app/components/tools/provider/empty'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useTabSearchParams } from '@/hooks/use-tab-searchparams'
import { useCheckInstalled, useInvalidateInstalledPluginList } from '@/service/use-plugins'
import { useAllToolProviders } from '@/service/use-tools'
import { cn } from '@/utils/classnames'
@ -45,8 +45,8 @@ const ProviderList = () => {
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
const containerRef = useRef<HTMLDivElement>(null)
const [activeTab, setActiveTab] = useTabSearchParams({
defaultTab: 'builtin',
const [activeTab, setActiveTab] = useQueryState('category', {
defaultValue: 'builtin',
})
const options = [
{ value: 'builtin', text: t('type.builtIn', { ns: 'tools' }) },

View File

@ -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'

View File

@ -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])

View File

@ -1,6 +1,7 @@
import type { Viewport } from 'next'
import { ThemeProvider } from 'next-themes'
import { Instrument_Serif } from 'next/font/google'
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'
@ -97,17 +98,19 @@ const LocaleLayout = async ({
disableTransitionOnChange
enableColorScheme={false}
>
<BrowserInitializer>
<SentryInitializer>
<TanstackQueryInitializer>
<I18nServer>
<GlobalPublicStoreProvider>
{children}
</GlobalPublicStoreProvider>
</I18nServer>
</TanstackQueryInitializer>
</SentryInitializer>
</BrowserInitializer>
<NuqsAdapter>
<BrowserInitializer>
<SentryInitializer>
<TanstackQueryInitializer>
<I18nServer>
<GlobalPublicStoreProvider>
{children}
</GlobalPublicStoreProvider>
</I18nServer>
</TanstackQueryInitializer>
</SentryInitializer>
</BrowserInitializer>
</NuqsAdapter>
</ThemeProvider>
<RoutePrefixHandle />
</body>

View File

@ -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(
<ModalContextProvider>
<div data-testid="modal-context-test-child" />
</ModalContextProvider>,
<NuqsTestingAdapter>
<ModalContextProvider>
<div data-testid="modal-context-test-child" />
</ModalContextProvider>
</NuqsTestingAdapter>,
)
describe('ModalContextProvider trigger events limit modal', () => {

View File

@ -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<SetStateAction<ModalState<ExpireNoticeModalPayloadProps> | null>>
setShowTriggerEventsLimitModal: Dispatch<SetStateAction<ModalState<TriggerEventsLimitModalPayload> | null>>
}
const PRICING_MODAL_QUERY_PARAM = 'pricing'
const PRICING_MODAL_QUERY_VALUE = 'open'
const ModalContext = createContext<ModalContextState>({
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<AccountSettingTab>()
const [showAccountSettingModal, setShowAccountSettingModal] = useState<ModalState<AccountSettingTab> | 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<Omit<ModalState<AccountSettingTab>, 'payload'> | null>(null)
const accountSettingTab = urlAccountModalState.isOpen
? (isValidAccountSettingTab(urlAccountModalState.payload)
? urlAccountModalState.payload
: DEFAULT_ACCOUNT_SETTING_TAB)
: null
const [showApiBasedExtensionModal, setShowApiBasedExtensionModal] = useState<ModalState<ApiBasedExtension> | null>(null)
const [showModerationSettingModal, setShowModerationSettingModal] = useState<ModalState<ModerationConfig> | null>(null)
const [showExternalDataToolModal, setShowExternalDataToolModal] = useState<ModalState<ExternalDataTool> | null>(null)
@ -182,9 +181,6 @@ export const ModalContextProvider = ({
const [showEducationExpireNoticeModal, setShowEducationExpireNoticeModal] = useState<ModalState<ExpireNoticeModalPayloadProps> | 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<ModalState<AccountSettingTab> | 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 (
<ModalContext.Provider value={{
@ -364,9 +340,9 @@ export const ModalContextProvider = ({
<>
{children}
{
!!showAccountSettingModal && (
accountSettingTab && (
<AccountSetting
activeTab={showAccountSettingModal.payload}
activeTab={accountSettingTab}
onCancel={handleCancelAccountSettingModal}
onTabChange={handleAccountSettingTabChange}
/>

View File

@ -0,0 +1,647 @@
import type { UrlUpdateEvent } from 'nuqs/adapters/testing'
import type { ReactNode } from 'react'
import { act, renderHook, waitFor } from '@testing-library/react'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { ACCOUNT_SETTING_MODAL_ACTION } from '@/app/components/header/account-setting/constants'
import {
clearQueryParams,
PRICING_MODAL_QUERY_PARAM,
PRICING_MODAL_QUERY_VALUE,
useAccountSettingModal,
useMarketplaceFilters,
usePluginInstallation,
usePricingModal,
} from './use-query-params'
const renderWithAdapter = <T,>(hook: () => T, searchParams = '') => {
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>()
const wrapper = ({ children }: { children: ReactNode }) => (
<NuqsTestingAdapter searchParams={searchParams} onUrlUpdate={onUrlUpdate}>
{children}
</NuqsTestingAdapter>
)
const { result } = renderHook(hook, { wrapper })
return { result, onUrlUpdate }
}
// Query param hooks: defaults, parsing, and URL sync behavior.
describe('useQueryParams hooks', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Pricing modal query behavior.
describe('usePricingModal', () => {
it('should return closed state when query param is missing', () => {
// Arrange
const { result } = renderWithAdapter(() => usePricingModal())
// Act
const [isOpen] = result.current
// Assert
expect(isOpen).toBe(false)
})
it('should return open state when query param matches open value', () => {
// Arrange
const { result } = renderWithAdapter(
() => usePricingModal(),
`?${PRICING_MODAL_QUERY_PARAM}=${PRICING_MODAL_QUERY_VALUE}`,
)
// Act
const [isOpen] = result.current
// Assert
expect(isOpen).toBe(true)
})
it('should return closed state when query param has unexpected value', () => {
// Arrange
const { result } = renderWithAdapter(
() => usePricingModal(),
`?${PRICING_MODAL_QUERY_PARAM}=closed`,
)
// Act
const [isOpen] = result.current
// Assert
expect(isOpen).toBe(false)
})
it('should set pricing param when opening', async () => {
// Arrange
const { result, onUrlUpdate } = renderWithAdapter(() => usePricingModal())
// Act
act(() => {
result.current[1](true)
})
// Assert
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.searchParams.get(PRICING_MODAL_QUERY_PARAM)).toBe(PRICING_MODAL_QUERY_VALUE)
})
it('should use push history when opening', async () => {
// Arrange
const { result, onUrlUpdate } = renderWithAdapter(() => usePricingModal())
// Act
act(() => {
result.current[1](true)
})
// Assert
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.options.history).toBe('push')
})
it('should clear pricing param when closing', async () => {
// Arrange
const { result, onUrlUpdate } = renderWithAdapter(
() => usePricingModal(),
`?${PRICING_MODAL_QUERY_PARAM}=${PRICING_MODAL_QUERY_VALUE}`,
)
// Act
act(() => {
result.current[1](false)
})
// Assert
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.searchParams.has(PRICING_MODAL_QUERY_PARAM)).toBe(false)
})
it('should use push history when closing', async () => {
// Arrange
const { result, onUrlUpdate } = renderWithAdapter(
() => usePricingModal(),
`?${PRICING_MODAL_QUERY_PARAM}=${PRICING_MODAL_QUERY_VALUE}`,
)
// Act
act(() => {
result.current[1](false)
})
// Assert
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.options.history).toBe('push')
})
it('should respect explicit history options when provided', async () => {
// Arrange
const { result, onUrlUpdate } = renderWithAdapter(() => usePricingModal())
// Act
act(() => {
result.current[1](true, { history: 'replace' })
})
// Assert
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.options.history).toBe('replace')
})
})
// Account settings modal query behavior.
describe('useAccountSettingModal', () => {
it('should return closed state with null payload when query params are missing', () => {
// Arrange
const { result } = renderWithAdapter(() => useAccountSettingModal())
// Act
const [state] = result.current
// Assert
expect(state.isOpen).toBe(false)
expect(state.payload).toBeNull()
})
it('should return open state when action matches', () => {
// Arrange
const { result } = renderWithAdapter(
() => useAccountSettingModal(),
`?action=${ACCOUNT_SETTING_MODAL_ACTION}&tab=billing`,
)
// Act
const [state] = result.current
// Assert
expect(state.isOpen).toBe(true)
expect(state.payload).toBe('billing')
})
it('should return closed state when action does not match', () => {
// Arrange
const { result } = renderWithAdapter(
() => useAccountSettingModal(),
'?action=other&tab=billing',
)
// Act
const [state] = result.current
// Assert
expect(state.isOpen).toBe(false)
expect(state.payload).toBeNull()
})
it('should set action and tab when opening', async () => {
// Arrange
const { result, onUrlUpdate } = renderWithAdapter(() => useAccountSettingModal())
// Act
act(() => {
result.current[1]({ payload: 'members' })
})
// Assert
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.searchParams.get('action')).toBe(ACCOUNT_SETTING_MODAL_ACTION)
expect(update.searchParams.get('tab')).toBe('members')
})
it('should use push history when opening from closed state', async () => {
// Arrange
const { result, onUrlUpdate } = renderWithAdapter(() => useAccountSettingModal())
// Act
act(() => {
result.current[1]({ payload: 'members' })
})
// Assert
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.options.history).toBe('push')
})
it('should update tab when switching while open', async () => {
// Arrange
const { result, onUrlUpdate } = renderWithAdapter(
() => useAccountSettingModal(),
`?action=${ACCOUNT_SETTING_MODAL_ACTION}&tab=billing`,
)
// Act
act(() => {
result.current[1]({ payload: 'provider' })
})
// Assert
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.searchParams.get('tab')).toBe('provider')
})
it('should use replace history when switching tabs while open', async () => {
// Arrange
const { result, onUrlUpdate } = renderWithAdapter(
() => useAccountSettingModal(),
`?action=${ACCOUNT_SETTING_MODAL_ACTION}&tab=billing`,
)
// Act
act(() => {
result.current[1]({ payload: 'provider' })
})
// Assert
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.options.history).toBe('replace')
})
it('should clear action and tab when closing', async () => {
// Arrange
const { result, onUrlUpdate } = renderWithAdapter(
() => useAccountSettingModal(),
`?action=${ACCOUNT_SETTING_MODAL_ACTION}&tab=billing`,
)
// Act
act(() => {
result.current[1](null)
})
// Assert
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.searchParams.has('action')).toBe(false)
expect(update.searchParams.has('tab')).toBe(false)
})
it('should use replace history when closing', async () => {
// Arrange
const { result, onUrlUpdate } = renderWithAdapter(
() => useAccountSettingModal(),
`?action=${ACCOUNT_SETTING_MODAL_ACTION}&tab=billing`,
)
// Act
act(() => {
result.current[1](null)
})
// Assert
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.options.history).toBe('replace')
})
})
// Marketplace filters query behavior.
describe('useMarketplaceFilters', () => {
it('should return default filters when query params are missing', () => {
// Arrange
const { result } = renderWithAdapter(() => useMarketplaceFilters())
// Act
const [filters] = result.current
// Assert
expect(filters.q).toBe('')
expect(filters.category).toBe('all')
expect(filters.tags).toEqual([])
})
it('should parse filters when query params are present', () => {
// Arrange
const { result } = renderWithAdapter(
() => useMarketplaceFilters(),
'?q=prompt&category=tool&tags=ai,ml',
)
// Act
const [filters] = result.current
// Assert
expect(filters.q).toBe('prompt')
expect(filters.category).toBe('tool')
expect(filters.tags).toEqual(['ai', 'ml'])
})
it('should treat empty tags param as empty array', () => {
// Arrange
const { result } = renderWithAdapter(
() => useMarketplaceFilters(),
'?tags=',
)
// Act
const [filters] = result.current
// Assert
expect(filters.tags).toEqual([])
})
it('should preserve other filters when updating a single field', async () => {
// Arrange
const { result } = renderWithAdapter(
() => useMarketplaceFilters(),
'?category=tool&tags=ai,ml',
)
// Act
act(() => {
result.current[1]({ q: 'search' })
})
// Assert
await waitFor(() => expect(result.current[0].q).toBe('search'))
expect(result.current[0].category).toBe('tool')
expect(result.current[0].tags).toEqual(['ai', 'ml'])
})
it('should clear q param when q is empty', async () => {
// Arrange
const { result, onUrlUpdate } = renderWithAdapter(
() => useMarketplaceFilters(),
'?q=search',
)
// Act
act(() => {
result.current[1]({ q: '' })
})
// Assert
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.searchParams.has('q')).toBe(false)
})
it('should serialize tags as comma-separated values', async () => {
// Arrange
const { result, onUrlUpdate } = renderWithAdapter(() => useMarketplaceFilters())
// Act
act(() => {
result.current[1]({ tags: ['ai', 'ml'] })
})
// Assert
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.searchParams.get('tags')).toBe('ai,ml')
})
it('should remove tags param when list is empty', async () => {
// Arrange
const { result, onUrlUpdate } = renderWithAdapter(
() => useMarketplaceFilters(),
'?tags=ai,ml',
)
// Act
act(() => {
result.current[1]({ tags: [] })
})
// Assert
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.searchParams.has('tags')).toBe(false)
})
it('should keep category in the URL when set to default', async () => {
// Arrange
const { result, onUrlUpdate } = renderWithAdapter(
() => useMarketplaceFilters(),
'?category=tool',
)
// Act
act(() => {
result.current[1]({ category: 'all' })
})
// Assert
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.searchParams.get('category')).toBe('all')
})
it('should clear all marketplace filters when set to null', async () => {
// Arrange
const { result, onUrlUpdate } = renderWithAdapter(
() => useMarketplaceFilters(),
'?q=search&category=tool&tags=ai,ml',
)
// Act
act(() => {
result.current[1](null)
})
// Assert
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.searchParams.has('q')).toBe(false)
expect(update.searchParams.has('category')).toBe(false)
expect(update.searchParams.has('tags')).toBe(false)
})
it('should use replace history when updating filters', async () => {
// Arrange
const { result, onUrlUpdate } = renderWithAdapter(() => useMarketplaceFilters())
// Act
act(() => {
result.current[1]({ q: 'search' })
})
// Assert
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.options.history).toBe('replace')
})
})
// Plugin installation query behavior.
describe('usePluginInstallation', () => {
it('should parse package ids from JSON arrays', () => {
// Arrange
const bundleInfo = { org: 'org', name: 'bundle', version: '1.0.0' }
const { result } = renderWithAdapter(
() => usePluginInstallation(),
`?package-ids=%5B%22org%2Fplugin%22%5D&bundle-info=${encodeURIComponent(JSON.stringify(bundleInfo))}`,
)
// Act
const [state] = result.current
// Assert
expect(state.packageId).toBe('org/plugin')
expect(state.bundleInfo).toEqual(bundleInfo)
})
it('should return raw package id when JSON parsing fails', () => {
// Arrange
const { result } = renderWithAdapter(
() => usePluginInstallation(),
'?package-ids=org/plugin',
)
// Act
const [state] = result.current
// Assert
expect(state.packageId).toBe('org/plugin')
})
it('should return raw package id when JSON is not an array', () => {
// Arrange
const { result } = renderWithAdapter(
() => usePluginInstallation(),
'?package-ids=%22org%2Fplugin%22',
)
// Act
const [state] = result.current
// Assert
expect(state.packageId).toBe('"org/plugin"')
})
it('should write package ids as JSON arrays when setting packageId', async () => {
// Arrange
const { result, onUrlUpdate } = renderWithAdapter(() => usePluginInstallation())
// Act
act(() => {
result.current[1]({ packageId: 'org/plugin' })
})
// Assert
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.searchParams.get('package-ids')).toBe('["org/plugin"]')
})
it('should set bundle info when provided', async () => {
// Arrange
const bundleInfo = { org: 'org', name: 'bundle', version: '1.0.0' }
const { result, onUrlUpdate } = renderWithAdapter(() => usePluginInstallation())
// Act
act(() => {
result.current[1]({ bundleInfo })
})
// Assert
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.searchParams.get('bundle-info')).toBe(JSON.stringify(bundleInfo))
})
it('should clear installation params when state is null', async () => {
// Arrange
const bundleInfo = { org: 'org', name: 'bundle', version: '1.0.0' }
const { result, onUrlUpdate } = renderWithAdapter(
() => usePluginInstallation(),
`?package-ids=%5B%22org%2Fplugin%22%5D&bundle-info=${encodeURIComponent(JSON.stringify(bundleInfo))}`,
)
// Act
act(() => {
result.current[1](null)
})
// Assert
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.searchParams.has('package-ids')).toBe(false)
expect(update.searchParams.has('bundle-info')).toBe(false)
})
it('should preserve bundle info when only packageId is updated', async () => {
// Arrange
const bundleInfo = { org: 'org', name: 'bundle', version: '1.0.0' }
const { result, onUrlUpdate } = renderWithAdapter(
() => usePluginInstallation(),
`?bundle-info=${encodeURIComponent(JSON.stringify(bundleInfo))}`,
)
// Act
act(() => {
result.current[1]({ packageId: 'org/plugin' })
})
// Assert
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.searchParams.get('bundle-info')).toBe(JSON.stringify(bundleInfo))
})
})
})
// Utility to clear query params from the current URL.
describe('clearQueryParams', () => {
beforeEach(() => {
vi.clearAllMocks()
window.history.replaceState(null, '', '/')
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('should remove a single key when provided one key', () => {
// Arrange
const replaceSpy = vi.spyOn(window.history, 'replaceState')
window.history.pushState(null, '', '/?foo=1&bar=2')
// Act
clearQueryParams('foo')
// Assert
expect(replaceSpy).toHaveBeenCalled()
const params = new URLSearchParams(window.location.search)
expect(params.has('foo')).toBe(false)
expect(params.get('bar')).toBe('2')
replaceSpy.mockRestore()
})
it('should remove multiple keys when provided an array', () => {
// Arrange
const replaceSpy = vi.spyOn(window.history, 'replaceState')
window.history.pushState(null, '', '/?foo=1&bar=2&baz=3')
// Act
clearQueryParams(['foo', 'baz'])
// Assert
expect(replaceSpy).toHaveBeenCalled()
const params = new URLSearchParams(window.location.search)
expect(params.has('foo')).toBe(false)
expect(params.has('baz')).toBe(false)
expect(params.get('bar')).toBe('2')
replaceSpy.mockRestore()
})
it('should no-op when window is undefined', () => {
// Arrange
const replaceSpy = vi.spyOn(window.history, 'replaceState')
vi.stubGlobal('window', undefined)
// Act
expect(() => clearQueryParams('foo')).not.toThrow()
// Assert
expect(replaceSpy).not.toHaveBeenCalled()
replaceSpy.mockRestore()
})
})

View File

@ -0,0 +1,222 @@
'use client'
/**
* Centralized URL query parameter management hooks using nuqs
*
* This file provides type-safe, performant query parameter management
* that doesn't trigger full page refreshes (shallow routing).
*
* Best practices from nuqs documentation:
* - Use useQueryState for single parameters
* - Use useQueryStates for multiple related parameters (atomic updates)
* - Always provide parsers with defaults for type safety
* - Use shallow routing to avoid unnecessary re-renders
*/
import {
createParser,
parseAsArrayOf,
parseAsString,
useQueryState,
useQueryStates,
} from 'nuqs'
import { useCallback } from 'react'
import { ACCOUNT_SETTING_MODAL_ACTION } from '@/app/components/header/account-setting/constants'
/**
* Modal State Query Parameters
* Manages modal visibility and configuration via URL
*/
export const PRICING_MODAL_QUERY_PARAM = 'pricing'
export const PRICING_MODAL_QUERY_VALUE = 'open'
const parseAsPricingModal = createParser<boolean>({
parse: value => (value === PRICING_MODAL_QUERY_VALUE ? true : null),
serialize: value => (value ? PRICING_MODAL_QUERY_VALUE : ''),
})
.withDefault(false)
.withOptions({ history: 'push' })
/**
* Hook to manage pricing modal state via URL
* @returns [isOpen, setIsOpen] - Tuple like useState
*
* @example
* const [isOpen, setIsOpen] = usePricingModal()
* setIsOpen(true) // Sets ?pricing=open
* setIsOpen(false) // Removes ?pricing
*/
export function usePricingModal() {
return useQueryState(
PRICING_MODAL_QUERY_PARAM,
parseAsPricingModal,
)
}
/**
* Hook to manage account setting modal state via URL
* @returns [state, setState] - Object with isOpen + payload (tab) and setter
*
* @example
* const [accountModalState, setAccountModalState] = useAccountSettingModal()
* setAccountModalState({ payload: 'billing' }) // Sets ?action=showSettings&tab=billing
* setAccountModalState(null) // Removes both params
*/
export function useAccountSettingModal<T extends string = string>() {
const [accountState, setAccountState] = useQueryStates(
{
action: parseAsString,
tab: parseAsString,
},
{
history: 'replace',
},
)
const setState = useCallback(
(state: { payload: T } | null) => {
if (!state) {
setAccountState({ action: null, tab: null }, { history: 'replace' })
return
}
const shouldPush = accountState.action !== ACCOUNT_SETTING_MODAL_ACTION
setAccountState(
{ action: ACCOUNT_SETTING_MODAL_ACTION, tab: state.payload },
{ history: shouldPush ? 'push' : 'replace' },
)
},
[accountState.action, setAccountState],
)
const isOpen = accountState.action === ACCOUNT_SETTING_MODAL_ACTION
const currentTab = (isOpen ? accountState.tab : null) as T | null
return [{ isOpen, payload: currentTab }, setState] as const
}
/**
* Marketplace Search Query Parameters
*/
export type MarketplaceFilters = {
q: string // search query
category: string // plugin category
tags: string[] // array of tags
}
/**
* Hook to manage marketplace search/filter state via URL
* Provides atomic updates - all params update together
*
* @example
* const [filters, setFilters] = useMarketplaceFilters()
* setFilters({ q: 'search', category: 'tool', tags: ['ai'] }) // Updates all at once
* setFilters({ q: '' }) // Only updates q, keeps others
* setFilters(null) // Clears all marketplace params
*/
export function useMarketplaceFilters() {
return useQueryStates(
{
q: parseAsString.withDefault(''),
category: parseAsString.withDefault('all').withOptions({ clearOnDefault: false }),
tags: parseAsArrayOf(parseAsString).withDefault([]),
},
{
// Update URL without pushing to history (replaceState behavior)
history: 'replace',
},
)
}
/**
* Plugin Installation Query Parameters
*/
const PACKAGE_IDS_PARAM = 'package-ids'
const BUNDLE_INFO_PARAM = 'bundle-info'
type BundleInfoQuery = {
org: string
name: string
version: string
}
const parseAsPackageId = createParser<string>({
parse: (value) => {
try {
const parsed = JSON.parse(value)
if (Array.isArray(parsed)) {
const first = parsed[0]
return typeof first === 'string' ? first : null
}
return value
}
catch {
return value
}
},
serialize: value => JSON.stringify([value]),
})
const parseAsBundleInfo = createParser<BundleInfoQuery>({
parse: (value) => {
try {
const parsed = JSON.parse(value) as Partial<BundleInfoQuery>
if (parsed
&& typeof parsed.org === 'string'
&& typeof parsed.name === 'string'
&& typeof parsed.version === 'string') {
return { org: parsed.org, name: parsed.name, version: parsed.version }
}
}
catch {
return null
}
return null
},
serialize: value => JSON.stringify(value),
})
/**
* Hook to manage plugin installation state via URL
* @returns [installState, setInstallState] - installState includes parsed packageId and bundleInfo
*
* @example
* const [installState, setInstallState] = usePluginInstallation()
* setInstallState({ packageId: 'org/plugin' }) // Sets ?package-ids=["org/plugin"]
* setInstallState({ bundleInfo: { org: 'org', name: 'bundle', version: '1.0.0' } }) // Sets ?bundle-info=...
* setInstallState(null) // Clears installation params
*/
export function usePluginInstallation() {
return useQueryStates(
{
packageId: parseAsPackageId,
bundleInfo: parseAsBundleInfo,
},
{
urlKeys: {
packageId: PACKAGE_IDS_PARAM,
bundleInfo: BUNDLE_INFO_PARAM,
},
},
)
}
/**
* Utility to clear specific query parameters from URL
* This is a client-side utility that should be called from client components
*
* @param keys - Single key or array of keys to remove from URL
*
* @example
* // In a client component
* clearQueryParams('param1')
* clearQueryParams(['param1', 'param2'])
*/
export function clearQueryParams(keys: string | string[]) {
if (typeof window === 'undefined')
return
const url = new URL(window.location.href)
const keysArray = Array.isArray(keys) ? keys : [keys]
keysArray.forEach(key => url.searchParams.delete(key))
window.history.replaceState(null, '', url.toString())
}

View File

@ -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()
})
})
})

View File

@ -1,47 +0,0 @@
'use client'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { useState } from 'react'
type UseTabSearchParamsOptions = {
defaultTab: string
routingBehavior?: 'push' | 'replace'
searchParamName?: string
disableSearchParams?: boolean
}
/**
* Custom hook to manage tab state via URL search parameters in a Next.js application.
* This hook allows for syncing the active tab with the browser's URL, enabling bookmarking and sharing of URLs with a specific tab activated.
*
* @param {UseTabSearchParamsOptions} options Configuration options for the hook:
* - `defaultTab`: The tab to default to when no tab is specified in the URL.
* - `routingBehavior`: Optional. Determines how changes to the active tab update the browser's history ('push' or 'replace'). Default is 'push'.
* - `searchParamName`: Optional. The name of the search parameter that holds the tab state in the URL. Default is 'category'.
* @returns A tuple where the first element is the active tab and the second element is a function to set the active tab.
*/
export const useTabSearchParams = ({
defaultTab,
routingBehavior = 'push',
searchParamName = 'category',
disableSearchParams = false,
}: UseTabSearchParamsOptions) => {
const pathnameFromHook = usePathname()
const router = useRouter()
const pathName = pathnameFromHook || window?.location?.pathname
const searchParams = useSearchParams()
const searchParamValue = searchParams.has(searchParamName) ? decodeURIComponent(searchParams.get(searchParamName)!) : defaultTab
const [activeTab, setTab] = useState<string>(
!disableSearchParams
? searchParamValue
: defaultTab,
)
const setActiveTab = (newActiveTab: string) => {
setTab(newActiveTab)
if (disableSearchParams)
return
router[`${routingBehavior}`](`${pathName}?${searchParamName}=${encodeURIComponent(newActiveTab)}`, { scroll: false })
}
return [activeTab, setActiveTab] as const
}

View File

@ -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",

View File

@ -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
@ -3199,6 +3202,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==}
@ -3692,9 +3698,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==}
@ -6346,8 +6349,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
@ -6871,6 +6874,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'}
@ -8734,7 +8758,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==}
@ -11719,6 +11742,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': {}
@ -12365,11 +12390,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
@ -14846,7 +14866,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
@ -15461,7 +15481,7 @@ snapshots:
lexical@0.38.2: {}
lib0@0.2.116:
lib0@0.2.115:
dependencies:
isomorphic.js: 0.2.5
@ -16321,6 +16341,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: {}
@ -18536,7 +18563,7 @@ snapshots:
yjs@13.6.27:
dependencies:
lib0: 0.2.116
lib0: 0.2.115
yocto-queue@0.1.0: {}

View File

@ -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&param2=value2&param3=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&param2=value2&param3=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')
})
})

View File

@ -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())
}