mirror of https://github.com/langgenius/dify.git
Merge branch 'main' into feat/hitl-frontend
This commit is contained in:
commit
eca3e23af0
|
|
@ -22,12 +22,12 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup UV and Python
|
- name: Setup UV and Python
|
||||||
uses: astral-sh/setup-uv@v6
|
uses: astral-sh/setup-uv@v7
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
@ -57,7 +57,7 @@ jobs:
|
||||||
run: sh .github/workflows/expose_service_ports.sh
|
run: sh .github/workflows/expose_service_ports.sh
|
||||||
|
|
||||||
- name: Set up Sandbox
|
- name: Set up Sandbox
|
||||||
uses: hoverkraft-tech/compose-action@v2.0.2
|
uses: hoverkraft-tech/compose-action@v2
|
||||||
with:
|
with:
|
||||||
compose-file: |
|
compose-file: |
|
||||||
docker/docker-compose.middleware.yaml
|
docker/docker-compose.middleware.yaml
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ jobs:
|
||||||
if: github.repository == 'langgenius/dify'
|
if: github.repository == 'langgenius/dify'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Check Docker Compose inputs
|
- name: Check Docker Compose inputs
|
||||||
id: docker-compose-changes
|
id: docker-compose-changes
|
||||||
|
|
@ -27,7 +27,7 @@ jobs:
|
||||||
with:
|
with:
|
||||||
python-version: "3.11"
|
python-version: "3.11"
|
||||||
|
|
||||||
- uses: astral-sh/setup-uv@v6
|
- uses: astral-sh/setup-uv@v7
|
||||||
|
|
||||||
- name: Generate Docker Compose
|
- name: Generate Docker Compose
|
||||||
if: steps.docker-compose-changes.outputs.any_changed == 'true'
|
if: steps.docker-compose-changes.outputs.any_changed == 'true'
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,7 @@ jobs:
|
||||||
touch "/tmp/digests/${sanitized_digest}"
|
touch "/tmp/digests/${sanitized_digest}"
|
||||||
|
|
||||||
- name: Upload digest
|
- name: Upload digest
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: digests-${{ matrix.context }}-${{ env.PLATFORM_PAIR }}
|
name: digests-${{ matrix.context }}-${{ env.PLATFORM_PAIR }}
|
||||||
path: /tmp/digests/*
|
path: /tmp/digests/*
|
||||||
|
|
|
||||||
|
|
@ -13,13 +13,13 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup UV and Python
|
- name: Setup UV and Python
|
||||||
uses: astral-sh/setup-uv@v6
|
uses: astral-sh/setup-uv@v7
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
|
|
@ -63,13 +63,13 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup UV and Python
|
- name: Setup UV and Python
|
||||||
uses: astral-sh/setup-uv@v6
|
uses: astral-sh/setup-uv@v7
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ jobs:
|
||||||
vdb-changed: ${{ steps.changes.outputs.vdb }}
|
vdb-changed: ${{ steps.changes.outputs.vdb }}
|
||||||
migration-changed: ${{ steps.changes.outputs.migration }}
|
migration-changed: ${{ steps.changes.outputs.migration }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- uses: dorny/paths-filter@v3
|
- uses: dorny/paths-filter@v3
|
||||||
id: changes
|
id: changes
|
||||||
with:
|
with:
|
||||||
|
|
@ -38,6 +38,7 @@ jobs:
|
||||||
- '.github/workflows/api-tests.yml'
|
- '.github/workflows/api-tests.yml'
|
||||||
web:
|
web:
|
||||||
- 'web/**'
|
- 'web/**'
|
||||||
|
- '.github/workflows/web-tests.yml'
|
||||||
vdb:
|
vdb:
|
||||||
- 'api/core/rag/datasource/**'
|
- 'api/core/rag/datasource/**'
|
||||||
- 'docker/**'
|
- 'docker/**'
|
||||||
|
|
|
||||||
|
|
@ -19,13 +19,13 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Check changed files
|
- name: Check changed files
|
||||||
id: changed-files
|
id: changed-files
|
||||||
uses: tj-actions/changed-files@v46
|
uses: tj-actions/changed-files@v47
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
api/**
|
api/**
|
||||||
|
|
@ -33,7 +33,7 @@ jobs:
|
||||||
|
|
||||||
- name: Setup UV and Python
|
- name: Setup UV and Python
|
||||||
if: steps.changed-files.outputs.any_changed == 'true'
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
uses: astral-sh/setup-uv@v6
|
uses: astral-sh/setup-uv@v7
|
||||||
with:
|
with:
|
||||||
enable-cache: false
|
enable-cache: false
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
|
|
@ -68,15 +68,17 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Check changed files
|
- name: Check changed files
|
||||||
id: changed-files
|
id: changed-files
|
||||||
uses: tj-actions/changed-files@v46
|
uses: tj-actions/changed-files@v47
|
||||||
with:
|
with:
|
||||||
files: web/**
|
files: |
|
||||||
|
web/**
|
||||||
|
.github/workflows/style.yml
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
|
|
@ -85,7 +87,7 @@ jobs:
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
- name: Setup NodeJS
|
- name: Setup NodeJS
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
if: steps.changed-files.outputs.any_changed == 'true'
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
|
@ -114,14 +116,14 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Check changed files
|
- name: Check changed files
|
||||||
id: changed-files
|
id: changed-files
|
||||||
uses: tj-actions/changed-files@v46
|
uses: tj-actions/changed-files@v47
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
**.sh
|
**.sh
|
||||||
|
|
|
||||||
|
|
@ -25,12 +25,12 @@ jobs:
|
||||||
working-directory: sdks/nodejs-client
|
working-directory: sdks/nodejs-client
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Use Node.js ${{ matrix.node-version }}
|
- name: Use Node.js ${{ matrix.node-version }}
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node-version }}
|
node-version: ${{ matrix.node-version }}
|
||||||
cache: ''
|
cache: ''
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ jobs:
|
||||||
run:
|
run:
|
||||||
working-directory: web
|
working-directory: web
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
@ -51,7 +51,7 @@ jobs:
|
||||||
|
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
if: env.FILES_CHANGED == 'true'
|
if: env.FILES_CHANGED == 'true'
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 'lts/*'
|
node-version: 'lts/*'
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
|
||||||
|
|
@ -19,19 +19,19 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Free Disk Space
|
- name: Free Disk Space
|
||||||
uses: endersonmenezes/free-disk-space@v2
|
uses: endersonmenezes/free-disk-space@v3
|
||||||
with:
|
with:
|
||||||
remove_dotnet: true
|
remove_dotnet: true
|
||||||
remove_haskell: true
|
remove_haskell: true
|
||||||
remove_tool_cache: true
|
remove_tool_cache: true
|
||||||
|
|
||||||
- name: Setup UV and Python
|
- name: Setup UV and Python
|
||||||
uses: astral-sh/setup-uv@v6
|
uses: astral-sh/setup-uv@v7
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
|
|
@ -29,7 +29,7 @@ jobs:
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
@ -360,7 +360,7 @@ jobs:
|
||||||
|
|
||||||
- name: Upload Coverage Artifact
|
- name: Upload Coverage Artifact
|
||||||
if: steps.coverage-summary.outputs.has_coverage == 'true'
|
if: steps.coverage-summary.outputs.has_coverage == 'true'
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: web-coverage-report
|
name: web-coverage-report
|
||||||
path: web/coverage
|
path: web/coverage
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,7 @@ class AppQueueManager:
|
||||||
"""
|
"""
|
||||||
self._clear_task_belong_cache()
|
self._clear_task_belong_cache()
|
||||||
self._q.put(None)
|
self._q.put(None)
|
||||||
|
self._graph_runtime_state = None # Release reference to allow GC to reclaim memory
|
||||||
|
|
||||||
def _clear_task_belong_cache(self) -> None:
|
def _clear_task_belong_cache(self) -> None:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,8 @@ vi.mock('use-context-selector', async () => {
|
||||||
useContext: () => ({ hasEditPermission: true }),
|
useContext: () => ({ hasEditPermission: true }),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
vi.mock('@/hooks/use-tab-searchparams', () => ({
|
vi.mock('nuqs', () => ({
|
||||||
useTabSearchParams: () => ['Recommended', vi.fn()],
|
useQueryState: () => ['Recommended', vi.fn()],
|
||||||
}))
|
}))
|
||||||
vi.mock('@/service/use-explore', () => ({
|
vi.mock('@/service/use-explore', () => ({
|
||||||
useExploreAppList: () => mockUseExploreAppList(),
|
useExploreAppList: () => mockUseExploreAppList(),
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ import { usePluginDependencies } from '@/app/components/workflow/plugin-dependen
|
||||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||||
import { useAppContext } from '@/context/app-context'
|
import { useAppContext } from '@/context/app-context'
|
||||||
import ExploreContext from '@/context/explore-context'
|
import ExploreContext from '@/context/explore-context'
|
||||||
import { useTabSearchParams } from '@/hooks/use-tab-searchparams'
|
|
||||||
import { DSLImportMode } from '@/models/app'
|
import { DSLImportMode } from '@/models/app'
|
||||||
import { importDSL } from '@/service/apps'
|
import { importDSL } from '@/service/apps'
|
||||||
import { fetchAppDetail } from '@/service/explore'
|
import { fetchAppDetail } from '@/service/explore'
|
||||||
|
|
@ -64,10 +63,7 @@ const Apps = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
const [currentType, setCurrentType] = useState<AppModeEnum[]>([])
|
const [currentType, setCurrentType] = useState<AppModeEnum[]>([])
|
||||||
const [currCategory, setCurrCategory] = useTabSearchParams({
|
const [currCategory, setCurrCategory] = useState<AppCategories | string>(allCategoriesEn)
|
||||||
defaultTab: allCategoriesEn,
|
|
||||||
disableSearchParams: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import type { ReadonlyURLSearchParams } from 'next/navigation'
|
import { parseAsArrayOf, parseAsBoolean, parseAsString, useQueryStates } from 'nuqs'
|
||||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
import { useCallback, useMemo } from 'react'
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
||||||
|
|
||||||
type AppsQuery = {
|
type AppsQuery = {
|
||||||
tagIDs?: string[]
|
tagIDs?: string[]
|
||||||
|
|
@ -8,54 +7,51 @@ type AppsQuery = {
|
||||||
isCreatedByMe?: boolean
|
isCreatedByMe?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the query parameters from the URL search string.
|
const normalizeKeywords = (value: string | null) => value || undefined
|
||||||
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')
|
|
||||||
}
|
|
||||||
|
|
||||||
function useAppsQueryState() {
|
function useAppsQueryState() {
|
||||||
const searchParams = useSearchParams()
|
const [urlQuery, setUrlQuery] = useQueryStates(
|
||||||
const [query, setQuery] = useState<AppsQuery>(() => parseParams(searchParams))
|
{
|
||||||
|
tagIDs: parseAsArrayOf(parseAsString, ';'),
|
||||||
|
keywords: parseAsString,
|
||||||
|
isCreatedByMe: parseAsBoolean,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
history: 'push',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const router = useRouter()
|
const query = useMemo<AppsQuery>(() => ({
|
||||||
const pathname = usePathname()
|
tagIDs: urlQuery.tagIDs ?? undefined,
|
||||||
const syncSearchParams = useCallback((params: URLSearchParams) => {
|
keywords: normalizeKeywords(urlQuery.keywords),
|
||||||
const search = params.toString()
|
isCreatedByMe: urlQuery.isCreatedByMe ?? false,
|
||||||
const query = search ? `?${search}` : ''
|
}), [urlQuery.isCreatedByMe, urlQuery.keywords, urlQuery.tagIDs])
|
||||||
router.push(`${pathname}${query}`, { scroll: false })
|
|
||||||
}, [router, pathname])
|
|
||||||
|
|
||||||
// Update the URL search string whenever the query changes.
|
const setQuery = useCallback((next: AppsQuery | ((prev: AppsQuery) => AppsQuery)) => {
|
||||||
useEffect(() => {
|
const buildPatch = (patch: AppsQuery) => {
|
||||||
const params = new URLSearchParams(searchParams)
|
const result: Partial<typeof urlQuery> = {}
|
||||||
updateSearchParams(query, params)
|
if ('tagIDs' in patch)
|
||||||
syncSearchParams(params)
|
result.tagIDs = patch.tagIDs && patch.tagIDs.length > 0 ? patch.tagIDs : null
|
||||||
}, [query, searchParams, syncSearchParams])
|
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
|
export default useAppsQueryState
|
||||||
|
|
|
||||||
|
|
@ -57,8 +57,13 @@ vi.mock('./hooks/use-dsl-drag-drop', () => ({
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const mockSetActiveTab = vi.fn()
|
const mockSetActiveTab = vi.fn()
|
||||||
vi.mock('@/hooks/use-tab-searchparams', () => ({
|
vi.mock('nuqs', () => ({
|
||||||
useTabSearchParams: () => ['all', mockSetActiveTab],
|
useQueryState: () => ['all', mockSetActiveTab],
|
||||||
|
parseAsString: {
|
||||||
|
withDefault: () => ({
|
||||||
|
withOptions: () => ({}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock service hooks - use object for mutable state (vi.mock is hoisted)
|
// Mock service hooks - use object for mutable state (vi.mock is hoisted)
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import dynamic from 'next/dynamic'
|
||||||
import {
|
import {
|
||||||
useRouter,
|
useRouter,
|
||||||
} from 'next/navigation'
|
} from 'next/navigation'
|
||||||
|
import { parseAsString, useQueryState } from 'nuqs'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import Input from '@/app/components/base/input'
|
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 { useAppContext } from '@/context/app-context'
|
||||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||||
import { CheckModal } from '@/hooks/use-pay'
|
import { CheckModal } from '@/hooks/use-pay'
|
||||||
import { useTabSearchParams } from '@/hooks/use-tab-searchparams'
|
|
||||||
import { useInfiniteAppList } from '@/service/use-apps'
|
import { useInfiniteAppList } from '@/service/use-apps'
|
||||||
import { AppModeEnum } from '@/types/app'
|
import { AppModeEnum } from '@/types/app'
|
||||||
import AppCard from './app-card'
|
import AppCard from './app-card'
|
||||||
|
|
@ -47,9 +47,10 @@ const List = () => {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator } = useAppContext()
|
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator } = useAppContext()
|
||||||
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
|
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
|
||||||
const [activeTab, setActiveTab] = useTabSearchParams({
|
const [activeTab, setActiveTab] = useQueryState(
|
||||||
defaultTab: 'all',
|
'category',
|
||||||
})
|
parseAsString.withDefault('all').withOptions({ history: 'push' }),
|
||||||
|
)
|
||||||
const { query: { tagIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false }, setQuery } = useAppsQueryState()
|
const { query: { tagIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false }, setQuery } = useAppsQueryState()
|
||||||
const [isCreatedByMe, setIsCreatedByMe] = useState(queryIsCreatedByMe)
|
const [isCreatedByMe, setIsCreatedByMe] = useState(queryIsCreatedByMe)
|
||||||
const [tagFilterValue, setTagFilterValue] = useState<string[]>(tagIDs)
|
const [tagFilterValue, setTagFilterValue] = useState<string[]>(tagIDs)
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@ let mockIsError = false
|
||||||
const mockHandleImportDSL = vi.fn()
|
const mockHandleImportDSL = vi.fn()
|
||||||
const mockHandleImportDSLConfirm = vi.fn()
|
const mockHandleImportDSLConfirm = vi.fn()
|
||||||
|
|
||||||
vi.mock('@/hooks/use-tab-searchparams', () => ({
|
vi.mock('nuqs', () => ({
|
||||||
useTabSearchParams: () => [mockTabValue, mockSetTab],
|
useQueryState: () => [mockTabValue, mockSetTab],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('ahooks', async () => {
|
vi.mock('ahooks', async () => {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
|
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
|
||||||
import type { App } from '@/models/explore'
|
import type { App } from '@/models/explore'
|
||||||
import { useDebounceFn } from 'ahooks'
|
import { useDebounceFn } from 'ahooks'
|
||||||
|
import { useQueryState } from 'nuqs'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { useCallback, useMemo, useState } from 'react'
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
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 CreateAppModal from '@/app/components/explore/create-app-modal'
|
||||||
import ExploreContext from '@/context/explore-context'
|
import ExploreContext from '@/context/explore-context'
|
||||||
import { useImportDSL } from '@/hooks/use-import-dsl'
|
import { useImportDSL } from '@/hooks/use-import-dsl'
|
||||||
import { useTabSearchParams } from '@/hooks/use-tab-searchparams'
|
|
||||||
import {
|
import {
|
||||||
DSLImportMode,
|
DSLImportMode,
|
||||||
} from '@/models/app'
|
} from '@/models/app'
|
||||||
|
|
@ -47,9 +47,8 @@ const Apps = ({
|
||||||
handleSearch()
|
handleSearch()
|
||||||
}
|
}
|
||||||
|
|
||||||
const [currCategory, setCurrCategory] = useTabSearchParams({
|
const [currCategory, setCurrCategory] = useQueryState('category', {
|
||||||
defaultTab: allCategoriesEn,
|
defaultValue: allCategoriesEn,
|
||||||
disableSearchParams: false,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import {
|
||||||
createContext,
|
createContext,
|
||||||
useContextSelector,
|
useContextSelector,
|
||||||
} from 'use-context-selector'
|
} from 'use-context-selector'
|
||||||
|
import { useMarketplaceFilters } from '@/hooks/use-query-params'
|
||||||
import { useInstalledPluginList } from '@/service/use-plugins'
|
import { useInstalledPluginList } from '@/service/use-plugins'
|
||||||
import {
|
import {
|
||||||
getValidCategoryKeys,
|
getValidCategoryKeys,
|
||||||
|
|
@ -37,7 +38,6 @@ import { PLUGIN_TYPE_SEARCH_MAP } from './plugin-type-switch'
|
||||||
import {
|
import {
|
||||||
getMarketplaceListCondition,
|
getMarketplaceListCondition,
|
||||||
getMarketplaceListFilterType,
|
getMarketplaceListFilterType,
|
||||||
updateSearchParams,
|
|
||||||
} from './utils'
|
} from './utils'
|
||||||
|
|
||||||
export type MarketplaceContextValue = {
|
export type MarketplaceContextValue = {
|
||||||
|
|
@ -107,16 +107,22 @@ export const MarketplaceContextProvider = ({
|
||||||
scrollContainerId,
|
scrollContainerId,
|
||||||
showSearchParams,
|
showSearchParams,
|
||||||
}: MarketplaceContextProviderProps) => {
|
}: MarketplaceContextProviderProps) => {
|
||||||
|
// Use nuqs hook for URL-based filter state
|
||||||
|
const [urlFilters, setUrlFilters] = useMarketplaceFilters()
|
||||||
|
|
||||||
const { data, isSuccess } = useInstalledPluginList(!shouldExclude)
|
const { data, isSuccess } = useInstalledPluginList(!shouldExclude)
|
||||||
const exclude = useMemo(() => {
|
const exclude = useMemo(() => {
|
||||||
if (shouldExclude)
|
if (shouldExclude)
|
||||||
return data?.plugins.map(plugin => plugin.plugin_id)
|
return data?.plugins.map(plugin => plugin.plugin_id)
|
||||||
}, [data?.plugins, shouldExclude])
|
}, [data?.plugins, shouldExclude])
|
||||||
const queryFromSearchParams = searchParams?.q || ''
|
|
||||||
const tagsFromSearchParams = searchParams?.tags ? getValidTagKeys(searchParams.tags.split(',')) : []
|
// Initialize from URL params (legacy support) or use nuqs state
|
||||||
|
const queryFromSearchParams = searchParams?.q || urlFilters.q
|
||||||
|
const tagsFromSearchParams = getValidTagKeys(urlFilters.tags)
|
||||||
const hasValidTags = !!tagsFromSearchParams.length
|
const hasValidTags = !!tagsFromSearchParams.length
|
||||||
const hasValidCategory = getValidCategoryKeys(searchParams?.category)
|
const hasValidCategory = getValidCategoryKeys(urlFilters.category)
|
||||||
const categoryFromSearchParams = hasValidCategory || PLUGIN_TYPE_SEARCH_MAP.all
|
const categoryFromSearchParams = hasValidCategory || PLUGIN_TYPE_SEARCH_MAP.all
|
||||||
|
|
||||||
const [searchPluginText, setSearchPluginText] = useState(queryFromSearchParams)
|
const [searchPluginText, setSearchPluginText] = useState(queryFromSearchParams)
|
||||||
const searchPluginTextRef = useRef(searchPluginText)
|
const searchPluginTextRef = useRef(searchPluginText)
|
||||||
const [filterPluginTags, setFilterPluginTags] = useState<string[]>(tagsFromSearchParams)
|
const [filterPluginTags, setFilterPluginTags] = useState<string[]>(tagsFromSearchParams)
|
||||||
|
|
@ -158,10 +164,6 @@ export const MarketplaceContextProvider = ({
|
||||||
sortOrder: sortRef.current.sortOrder,
|
sortOrder: sortRef.current.sortOrder,
|
||||||
type: getMarketplaceListFilterType(activePluginTypeRef.current),
|
type: getMarketplaceListFilterType(activePluginTypeRef.current),
|
||||||
})
|
})
|
||||||
const url = new URL(window.location.href)
|
|
||||||
if (searchParams?.language)
|
|
||||||
url.searchParams.set('language', searchParams?.language)
|
|
||||||
history.replaceState({}, '', url)
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
if (shouldExclude && isSuccess) {
|
if (shouldExclude && isSuccess) {
|
||||||
|
|
@ -183,28 +185,32 @@ export const MarketplaceContextProvider = ({
|
||||||
resetPlugins()
|
resetPlugins()
|
||||||
}, [exclude, queryMarketplaceCollectionsAndPlugins, resetPlugins])
|
}, [exclude, queryMarketplaceCollectionsAndPlugins, resetPlugins])
|
||||||
|
|
||||||
const debouncedUpdateSearchParams = useMemo(() => debounce(() => {
|
const applyUrlFilters = useCallback(() => {
|
||||||
updateSearchParams({
|
|
||||||
query: searchPluginTextRef.current,
|
|
||||||
category: activePluginTypeRef.current,
|
|
||||||
tags: filterPluginTagsRef.current,
|
|
||||||
})
|
|
||||||
}, 500), [])
|
|
||||||
|
|
||||||
const handleUpdateSearchParams = useCallback((debounced?: boolean) => {
|
|
||||||
if (!showSearchParams)
|
if (!showSearchParams)
|
||||||
return
|
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) {
|
if (debounced) {
|
||||||
debouncedUpdateSearchParams()
|
debouncedUpdateSearchParams()
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
updateSearchParams({
|
applyUrlFilters()
|
||||||
query: searchPluginTextRef.current,
|
|
||||||
category: activePluginTypeRef.current,
|
|
||||||
tags: filterPluginTagsRef.current,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}, [debouncedUpdateSearchParams, showSearchParams])
|
}, [applyUrlFilters, debouncedUpdateSearchParams])
|
||||||
|
|
||||||
const handleQueryPlugins = useCallback((debounced?: boolean) => {
|
const handleQueryPlugins = useCallback((debounced?: boolean) => {
|
||||||
handleUpdateSearchParams(debounced)
|
handleUpdateSearchParams(debounced)
|
||||||
|
|
|
||||||
|
|
@ -84,12 +84,14 @@ const PluginTypeSwitch = ({
|
||||||
const handlePopState = useCallback(() => {
|
const handlePopState = useCallback(() => {
|
||||||
if (!showSearchParams)
|
if (!showSearchParams)
|
||||||
return
|
return
|
||||||
|
// nuqs handles popstate automatically
|
||||||
const url = new URL(window.location.href)
|
const url = new URL(window.location.href)
|
||||||
const category = url.searchParams.get('category') || PLUGIN_TYPE_SEARCH_MAP.all
|
const category = url.searchParams.get('category') || PLUGIN_TYPE_SEARCH_MAP.all
|
||||||
handleActivePluginTypeChange(category)
|
handleActivePluginTypeChange(category)
|
||||||
}, [showSearchParams, handleActivePluginTypeChange])
|
}, [showSearchParams, handleActivePluginTypeChange])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// nuqs manages popstate internally, but we keep this for URL sync
|
||||||
window.addEventListener('popstate', handlePopState)
|
window.addEventListener('popstate', handlePopState)
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('popstate', handlePopState)
|
window.removeEventListener('popstate', handlePopState)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import type {
|
import type {
|
||||||
CollectionsAndPluginsSearchParams,
|
CollectionsAndPluginsSearchParams,
|
||||||
MarketplaceCollection,
|
MarketplaceCollection,
|
||||||
PluginsSearchParams,
|
|
||||||
} from '@/app/components/plugins/marketplace/types'
|
} from '@/app/components/plugins/marketplace/types'
|
||||||
import type { Plugin } from '@/app/components/plugins/types'
|
import type { Plugin } from '@/app/components/plugins/types'
|
||||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||||
|
|
@ -152,22 +151,3 @@ export const getMarketplaceListFilterType = (category: string) => {
|
||||||
|
|
||||||
return 'plugin'
|
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)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import type { ReactNode, RefObject } from 'react'
|
import type { ReactNode, RefObject } from 'react'
|
||||||
import type { FilterState } from './filter-management'
|
import type { FilterState } from './filter-management'
|
||||||
import { noop } from 'es-toolkit/compat'
|
import { noop } from 'es-toolkit/compat'
|
||||||
|
import { useQueryState } from 'nuqs'
|
||||||
import {
|
import {
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
|
|
@ -13,7 +14,6 @@ import {
|
||||||
useContextSelector,
|
useContextSelector,
|
||||||
} from 'use-context-selector'
|
} from 'use-context-selector'
|
||||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||||
import { useTabSearchParams } from '@/hooks/use-tab-searchparams'
|
|
||||||
import { PLUGIN_PAGE_TABS_MAP, usePluginPageTabs } from '../hooks'
|
import { PLUGIN_PAGE_TABS_MAP, usePluginPageTabs } from '../hooks'
|
||||||
|
|
||||||
export type PluginPageContextValue = {
|
export type PluginPageContextValue = {
|
||||||
|
|
@ -68,8 +68,8 @@ export const PluginPageContextProvider = ({
|
||||||
const options = useMemo(() => {
|
const options = useMemo(() => {
|
||||||
return enable_marketplace ? tabs : tabs.filter(tab => tab.value !== PLUGIN_PAGE_TABS_MAP.marketplace)
|
return enable_marketplace ? tabs : tabs.filter(tab => tab.value !== PLUGIN_PAGE_TABS_MAP.marketplace)
|
||||||
}, [tabs, enable_marketplace])
|
}, [tabs, enable_marketplace])
|
||||||
const [activeTab, setActiveTab] = useTabSearchParams({
|
const [activeTab, setActiveTab] = useQueryState('category', {
|
||||||
defaultTab: options[0].value,
|
defaultValue: options[0].value,
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,6 @@ import {
|
||||||
import { useBoolean } from 'ahooks'
|
import { useBoolean } from 'ahooks'
|
||||||
import { noop } from 'es-toolkit/compat'
|
import { noop } from 'es-toolkit/compat'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import {
|
|
||||||
useRouter,
|
|
||||||
useSearchParams,
|
|
||||||
} from 'next/navigation'
|
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useContext } from 'use-context-selector'
|
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 { useGlobalPublicStore } from '@/context/global-public-context'
|
||||||
import I18n from '@/context/i18n'
|
import I18n from '@/context/i18n'
|
||||||
import useDocumentTitle from '@/hooks/use-document-title'
|
import useDocumentTitle from '@/hooks/use-document-title'
|
||||||
|
import { usePluginInstallation } from '@/hooks/use-query-params'
|
||||||
import { fetchBundleInfoFromMarketPlace, fetchManifestFromMarketPlace } from '@/service/plugins'
|
import { fetchBundleInfoFromMarketPlace, fetchManifestFromMarketPlace } from '@/service/plugins'
|
||||||
import { sleep } from '@/utils'
|
import { sleep } from '@/utils'
|
||||||
import { cn } from '@/utils/classnames'
|
import { cn } from '@/utils/classnames'
|
||||||
|
|
@ -42,9 +39,6 @@ import PluginTasks from './plugin-tasks'
|
||||||
import useReferenceSetting from './use-reference-setting'
|
import useReferenceSetting from './use-reference-setting'
|
||||||
import { useUploader } from './use-uploader'
|
import { useUploader } from './use-uploader'
|
||||||
|
|
||||||
const PACKAGE_IDS_KEY = 'package-ids'
|
|
||||||
const BUNDLE_INFO_KEY = 'bundle-info'
|
|
||||||
|
|
||||||
export type PluginPageProps = {
|
export type PluginPageProps = {
|
||||||
plugins: React.ReactNode
|
plugins: React.ReactNode
|
||||||
marketplace: React.ReactNode
|
marketplace: React.ReactNode
|
||||||
|
|
@ -55,33 +49,13 @@ const PluginPage = ({
|
||||||
}: PluginPageProps) => {
|
}: PluginPageProps) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { locale } = useContext(I18n)
|
const { locale } = useContext(I18n)
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const { replace } = useRouter()
|
|
||||||
useDocumentTitle(t('plugin.metadata.title'))
|
useDocumentTitle(t('plugin.metadata.title'))
|
||||||
|
|
||||||
// just support install one package now
|
// Use nuqs hook for installation state
|
||||||
const packageId = useMemo(() => {
|
const [{ packageId, bundleInfo }, setInstallState] = usePluginInstallation()
|
||||||
const idStrings = searchParams.get(PACKAGE_IDS_KEY)
|
|
||||||
try {
|
|
||||||
return idStrings ? JSON.parse(idStrings)[0] : ''
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
}, [searchParams])
|
|
||||||
|
|
||||||
const [uniqueIdentifier, setUniqueIdentifier] = useState<string | null>(null)
|
const [uniqueIdentifier, setUniqueIdentifier] = useState<string | null>(null)
|
||||||
|
|
||||||
const [dependencies, setDependencies] = useState<Dependency[]>([])
|
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, {
|
const [isShowInstallFromMarketplace, {
|
||||||
setTrue: showInstallFromMarketplace,
|
setTrue: showInstallFromMarketplace,
|
||||||
|
|
@ -90,11 +64,9 @@ const PluginPage = ({
|
||||||
|
|
||||||
const hideInstallFromMarketplace = () => {
|
const hideInstallFromMarketplace = () => {
|
||||||
doHideInstallFromMarketplace()
|
doHideInstallFromMarketplace()
|
||||||
const url = new URL(window.location.href)
|
setInstallState(null)
|
||||||
url.searchParams.delete(PACKAGE_IDS_KEY)
|
|
||||||
url.searchParams.delete(BUNDLE_INFO_KEY)
|
|
||||||
replace(url.toString())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const [manifest, setManifest] = useState<PluginDeclaration | PluginManifestInMarket | null>(null)
|
const [manifest, setManifest] = useState<PluginDeclaration | PluginManifestInMarket | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -114,12 +86,17 @@ const PluginPage = ({
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (bundleInfo) {
|
if (bundleInfo) {
|
||||||
const { data } = await fetchBundleInfoFromMarketPlace(bundleInfo)
|
try {
|
||||||
setDependencies(data.version.dependencies)
|
const { data } = await fetchBundleInfoFromMarketPlace(bundleInfo)
|
||||||
showInstallFromMarketplace()
|
setDependencies(data.version.dependencies)
|
||||||
|
showInstallFromMarketplace()
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('Failed to load bundle info:', error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
}, [packageId, bundleInfo])
|
}, [packageId, bundleInfo, showInstallFromMarketplace])
|
||||||
|
|
||||||
const {
|
const {
|
||||||
referenceSetting,
|
referenceSetting,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
'use client'
|
'use client'
|
||||||
import type { Collection } from './types'
|
import type { Collection } from './types'
|
||||||
|
import { useQueryState } from 'nuqs'
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import Input from '@/app/components/base/input'
|
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 ProviderDetail from '@/app/components/tools/provider/detail'
|
||||||
import WorkflowToolEmpty from '@/app/components/tools/provider/empty'
|
import WorkflowToolEmpty from '@/app/components/tools/provider/empty'
|
||||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||||
import { useTabSearchParams } from '@/hooks/use-tab-searchparams'
|
|
||||||
import { useCheckInstalled, useInvalidateInstalledPluginList } from '@/service/use-plugins'
|
import { useCheckInstalled, useInvalidateInstalledPluginList } from '@/service/use-plugins'
|
||||||
import { useAllToolProviders } from '@/service/use-tools'
|
import { useAllToolProviders } from '@/service/use-tools'
|
||||||
import { cn } from '@/utils/classnames'
|
import { cn } from '@/utils/classnames'
|
||||||
|
|
@ -45,8 +45,8 @@ const ProviderList = () => {
|
||||||
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
|
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useTabSearchParams({
|
const [activeTab, setActiveTab] = useQueryState('category', {
|
||||||
defaultTab: 'builtin',
|
defaultValue: 'builtin',
|
||||||
})
|
})
|
||||||
const options = [
|
const options = [
|
||||||
{ value: 'builtin', text: t('tools.type.builtIn') },
|
{ value: 'builtin', text: t('tools.type.builtIn') },
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
export const EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION = 'getEducationVerify'
|
export const EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION = 'getEducationVerify'
|
||||||
export const EDUCATION_VERIFYING_LOCALSTORAGE_ITEM = 'educationVerifying'
|
export const EDUCATION_VERIFYING_LOCALSTORAGE_ITEM = 'educationVerifying'
|
||||||
export const EDUCATION_PRICING_SHOW_ACTION = 'educationPricing'
|
|
||||||
export const EDUCATION_RE_VERIFY_ACTION = 'educationReVerify'
|
export const EDUCATION_RE_VERIFY_ACTION = 'educationReVerify'
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ import { useModalContextSelector } from '@/context/modal-context'
|
||||||
import { useProviderContext } from '@/context/provider-context'
|
import { useProviderContext } from '@/context/provider-context'
|
||||||
import { useEducationAutocomplete, useEducationVerify } from '@/service/use-education'
|
import { useEducationAutocomplete, useEducationVerify } from '@/service/use-education'
|
||||||
import {
|
import {
|
||||||
EDUCATION_PRICING_SHOW_ACTION,
|
|
||||||
EDUCATION_RE_VERIFY_ACTION,
|
EDUCATION_RE_VERIFY_ACTION,
|
||||||
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
|
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
|
||||||
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
|
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
|
||||||
|
|
@ -133,7 +132,6 @@ const useEducationReverifyNotice = ({
|
||||||
|
|
||||||
export const useEducationInit = () => {
|
export const useEducationInit = () => {
|
||||||
const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal)
|
const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal)
|
||||||
const setShowPricingModal = useModalContextSelector(s => s.setShowPricingModal)
|
|
||||||
const setShowEducationExpireNoticeModal = useModalContextSelector(s => s.setShowEducationExpireNoticeModal)
|
const setShowEducationExpireNoticeModal = useModalContextSelector(s => s.setShowEducationExpireNoticeModal)
|
||||||
const educationVerifying = localStorage.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)
|
const educationVerifying = localStorage.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
|
|
@ -160,8 +158,6 @@ export const useEducationInit = () => {
|
||||||
if (educationVerifyAction === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION)
|
if (educationVerifyAction === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION)
|
||||||
localStorage.setItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, 'yes')
|
localStorage.setItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, 'yes')
|
||||||
}
|
}
|
||||||
if (educationVerifyAction === EDUCATION_PRICING_SHOW_ACTION)
|
|
||||||
setShowPricingModal()
|
|
||||||
if (educationVerifyAction === EDUCATION_RE_VERIFY_ACTION)
|
if (educationVerifyAction === EDUCATION_RE_VERIFY_ACTION)
|
||||||
handleVerify()
|
handleVerify()
|
||||||
}, [setShowAccountSettingModal, educationVerifying, educationVerifyAction])
|
}, [setShowAccountSettingModal, educationVerifying, educationVerifyAction])
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import type { Viewport } from 'next'
|
import type { Viewport } from 'next'
|
||||||
import { ThemeProvider } from 'next-themes'
|
import { ThemeProvider } from 'next-themes'
|
||||||
import dynamic from 'next/dynamic'
|
|
||||||
import { Instrument_Serif } from 'next/font/google'
|
import { Instrument_Serif } from 'next/font/google'
|
||||||
import { IS_DEV } from '@/config'
|
import { NuqsAdapter } from 'nuqs/adapters/next/app'
|
||||||
import GlobalPublicStoreProvider from '@/context/global-public-context'
|
import GlobalPublicStoreProvider from '@/context/global-public-context'
|
||||||
import { TanstackQueryInitializer } from '@/context/query-client'
|
import { TanstackQueryInitializer } from '@/context/query-client'
|
||||||
import { getLocaleOnServer } from '@/i18n-config/server'
|
import { getLocaleOnServer } from '@/i18n-config/server'
|
||||||
|
|
@ -10,15 +9,12 @@ import { DatasetAttr } from '@/types/feature'
|
||||||
import { cn } from '@/utils/classnames'
|
import { cn } from '@/utils/classnames'
|
||||||
import BrowserInitializer from './components/browser-initializer'
|
import BrowserInitializer from './components/browser-initializer'
|
||||||
import I18nServer from './components/i18n-server'
|
import I18nServer from './components/i18n-server'
|
||||||
|
import { ReactScan } from './components/react-scan'
|
||||||
import SentryInitializer from './components/sentry-initializer'
|
import SentryInitializer from './components/sentry-initializer'
|
||||||
import RoutePrefixHandle from './routePrefixHandle'
|
import RoutePrefixHandle from './routePrefixHandle'
|
||||||
import './styles/globals.css'
|
import './styles/globals.css'
|
||||||
import './styles/markdown.scss'
|
import './styles/markdown.scss'
|
||||||
|
|
||||||
const ReactScan = IS_DEV
|
|
||||||
? dynamic(() => import('./components/react-scan').then(m => m.ReactScan), { ssr: false })
|
|
||||||
: () => null
|
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
width: 'device-width',
|
width: 'device-width',
|
||||||
initialScale: 1,
|
initialScale: 1,
|
||||||
|
|
@ -102,17 +98,19 @@ const LocaleLayout = async ({
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
enableColorScheme={false}
|
enableColorScheme={false}
|
||||||
>
|
>
|
||||||
<BrowserInitializer>
|
<NuqsAdapter>
|
||||||
<SentryInitializer>
|
<BrowserInitializer>
|
||||||
<TanstackQueryInitializer>
|
<SentryInitializer>
|
||||||
<I18nServer>
|
<TanstackQueryInitializer>
|
||||||
<GlobalPublicStoreProvider>
|
<I18nServer>
|
||||||
{children}
|
<GlobalPublicStoreProvider>
|
||||||
</GlobalPublicStoreProvider>
|
{children}
|
||||||
</I18nServer>
|
</GlobalPublicStoreProvider>
|
||||||
</TanstackQueryInitializer>
|
</I18nServer>
|
||||||
</SentryInitializer>
|
</TanstackQueryInitializer>
|
||||||
</BrowserInitializer>
|
</SentryInitializer>
|
||||||
|
</BrowserInitializer>
|
||||||
|
</NuqsAdapter>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
<RoutePrefixHandle />
|
<RoutePrefixHandle />
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { defaultPlan } from '@/app/components/billing/config'
|
import { defaultPlan } from '@/app/components/billing/config'
|
||||||
import { Plan } from '@/app/components/billing/type'
|
import { Plan } from '@/app/components/billing/type'
|
||||||
|
|
@ -72,9 +73,11 @@ const createPlan = (overrides: PlanOverrides = {}): PlanShape => ({
|
||||||
})
|
})
|
||||||
|
|
||||||
const renderProvider = () => render(
|
const renderProvider = () => render(
|
||||||
<ModalContextProvider>
|
<NuqsTestingAdapter>
|
||||||
<div data-testid="modal-context-test-child" />
|
<ModalContextProvider>
|
||||||
</ModalContextProvider>,
|
<div data-testid="modal-context-test-child" />
|
||||||
|
</ModalContextProvider>
|
||||||
|
</NuqsTestingAdapter>,
|
||||||
)
|
)
|
||||||
|
|
||||||
describe('ModalContextProvider trigger events limit modal', () => {
|
describe('ModalContextProvider trigger events limit modal', () => {
|
||||||
|
|
|
||||||
|
|
@ -24,21 +24,22 @@ import type {
|
||||||
import type { ModerationConfig, PromptVariable } from '@/models/debug'
|
import type { ModerationConfig, PromptVariable } from '@/models/debug'
|
||||||
import { noop } from 'es-toolkit/compat'
|
import { noop } from 'es-toolkit/compat'
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
import { useSearchParams } from 'next/navigation'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
|
||||||
import { createContext, useContext, useContextSelector } from 'use-context-selector'
|
import { createContext, useContext, useContextSelector } from 'use-context-selector'
|
||||||
import {
|
import {
|
||||||
ACCOUNT_SETTING_MODAL_ACTION,
|
|
||||||
DEFAULT_ACCOUNT_SETTING_TAB,
|
DEFAULT_ACCOUNT_SETTING_TAB,
|
||||||
isValidAccountSettingTab,
|
isValidAccountSettingTab,
|
||||||
} from '@/app/components/header/account-setting/constants'
|
} from '@/app/components/header/account-setting/constants'
|
||||||
import {
|
import {
|
||||||
EDUCATION_PRICING_SHOW_ACTION,
|
|
||||||
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
|
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
|
||||||
} from '@/app/education-apply/constants'
|
} from '@/app/education-apply/constants'
|
||||||
import { useAppContext } from '@/context/app-context'
|
import { useAppContext } from '@/context/app-context'
|
||||||
import { useProviderContext } from '@/context/provider-context'
|
import { useProviderContext } from '@/context/provider-context'
|
||||||
import { removeSpecificQueryParam } from '@/utils'
|
import {
|
||||||
|
useAccountSettingModal,
|
||||||
|
usePricingModal,
|
||||||
|
} from '@/hooks/use-query-params'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
||||||
useTriggerEventsLimitModal,
|
useTriggerEventsLimitModal,
|
||||||
|
|
@ -125,8 +126,6 @@ export type ModalContextState = {
|
||||||
setShowEducationExpireNoticeModal: Dispatch<SetStateAction<ModalState<ExpireNoticeModalPayloadProps> | null>>
|
setShowEducationExpireNoticeModal: Dispatch<SetStateAction<ModalState<ExpireNoticeModalPayloadProps> | null>>
|
||||||
setShowTriggerEventsLimitModal: Dispatch<SetStateAction<ModalState<TriggerEventsLimitModalPayload> | null>>
|
setShowTriggerEventsLimitModal: Dispatch<SetStateAction<ModalState<TriggerEventsLimitModalPayload> | null>>
|
||||||
}
|
}
|
||||||
const PRICING_MODAL_QUERY_PARAM = 'pricing'
|
|
||||||
const PRICING_MODAL_QUERY_VALUE = 'open'
|
|
||||||
|
|
||||||
const ModalContext = createContext<ModalContextState>({
|
const ModalContext = createContext<ModalContextState>({
|
||||||
setShowAccountSettingModal: noop,
|
setShowAccountSettingModal: noop,
|
||||||
|
|
@ -157,16 +156,16 @@ type ModalContextProviderProps = {
|
||||||
export const ModalContextProvider = ({
|
export const ModalContextProvider = ({
|
||||||
children,
|
children,
|
||||||
}: ModalContextProviderProps) => {
|
}: 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>(() => {
|
const accountSettingCallbacksRef = useRef<Omit<ModalState<AccountSettingTab>, 'payload'> | null>(null)
|
||||||
if (searchParams.get('action') === ACCOUNT_SETTING_MODAL_ACTION) {
|
const accountSettingTab = urlAccountModalState.isOpen
|
||||||
const tabParam = searchParams.get('tab')
|
? (isValidAccountSettingTab(urlAccountModalState.payload)
|
||||||
const tab = isValidAccountSettingTab(tabParam) ? tabParam : DEFAULT_ACCOUNT_SETTING_TAB
|
? urlAccountModalState.payload
|
||||||
return { payload: tab }
|
: DEFAULT_ACCOUNT_SETTING_TAB)
|
||||||
}
|
: null
|
||||||
return null
|
|
||||||
})
|
|
||||||
const [showApiBasedExtensionModal, setShowApiBasedExtensionModal] = useState<ModalState<ApiBasedExtension> | null>(null)
|
const [showApiBasedExtensionModal, setShowApiBasedExtensionModal] = useState<ModalState<ApiBasedExtension> | null>(null)
|
||||||
const [showModerationSettingModal, setShowModerationSettingModal] = useState<ModalState<ModerationConfig> | null>(null)
|
const [showModerationSettingModal, setShowModerationSettingModal] = useState<ModalState<ModerationConfig> | null>(null)
|
||||||
const [showExternalDataToolModal, setShowExternalDataToolModal] = useState<ModalState<ExternalDataTool> | 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 [showEducationExpireNoticeModal, setShowEducationExpireNoticeModal] = useState<ModalState<ExpireNoticeModalPayloadProps> | null>(null)
|
||||||
const { currentWorkspace } = useAppContext()
|
const { currentWorkspace } = useAppContext()
|
||||||
|
|
||||||
const [showPricingModal, setShowPricingModal] = useState(
|
|
||||||
searchParams.get(PRICING_MODAL_QUERY_PARAM) === PRICING_MODAL_QUERY_VALUE,
|
|
||||||
)
|
|
||||||
const [showAnnotationFullModal, setShowAnnotationFullModal] = useState(false)
|
const [showAnnotationFullModal, setShowAnnotationFullModal] = useState(false)
|
||||||
const handleCancelAccountSettingModal = () => {
|
const handleCancelAccountSettingModal = () => {
|
||||||
const educationVerifying = localStorage.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)
|
const educationVerifying = localStorage.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)
|
||||||
|
|
@ -192,54 +188,34 @@ export const ModalContextProvider = ({
|
||||||
if (educationVerifying === 'yes')
|
if (educationVerifying === 'yes')
|
||||||
localStorage.removeItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)
|
localStorage.removeItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)
|
||||||
|
|
||||||
removeSpecificQueryParam('action')
|
accountSettingCallbacksRef.current?.onCancelCallback?.()
|
||||||
removeSpecificQueryParam('tab')
|
accountSettingCallbacksRef.current = null
|
||||||
setShowAccountSettingModal(null)
|
setUrlAccountModalState(null)
|
||||||
if (showAccountSettingModal?.onCancelCallback)
|
|
||||||
showAccountSettingModal?.onCancelCallback()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAccountSettingTabChange = useCallback((tab: AccountSettingTab) => {
|
const handleAccountSettingTabChange = useCallback((tab: AccountSettingTab) => {
|
||||||
setShowAccountSettingModal((prev) => {
|
setUrlAccountModalState({ payload: tab })
|
||||||
if (!prev)
|
}, [setUrlAccountModalState])
|
||||||
return { payload: tab }
|
|
||||||
if (prev.payload === tab)
|
const setShowAccountSettingModal = useCallback((next: SetStateAction<ModalState<AccountSettingTab> | null>) => {
|
||||||
return prev
|
const currentState = accountSettingTab
|
||||||
return { ...prev, payload: tab }
|
? { payload: accountSettingTab, ...(accountSettingCallbacksRef.current ?? {}) }
|
||||||
})
|
: null
|
||||||
}, [setShowAccountSettingModal])
|
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(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined')
|
if (!urlAccountModalState.isOpen)
|
||||||
return
|
accountSettingCallbacksRef.current = null
|
||||||
const url = new URL(window.location.href)
|
}, [urlAccountModalState.isOpen])
|
||||||
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])
|
|
||||||
|
|
||||||
const { plan, isFetchedPlan } = useProviderContext()
|
const { plan, isFetchedPlan } = useProviderContext()
|
||||||
const {
|
const {
|
||||||
|
|
@ -337,12 +313,12 @@ export const ModalContextProvider = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleShowPricingModal = useCallback(() => {
|
const handleShowPricingModal = useCallback(() => {
|
||||||
setShowPricingModal(true)
|
setPricingModalOpen(true)
|
||||||
}, [])
|
}, [setPricingModalOpen])
|
||||||
|
|
||||||
const handleCancelPricingModal = useCallback(() => {
|
const handleCancelPricingModal = useCallback(() => {
|
||||||
setShowPricingModal(false)
|
setPricingModalOpen(false)
|
||||||
}, [])
|
}, [setPricingModalOpen])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalContext.Provider value={{
|
<ModalContext.Provider value={{
|
||||||
|
|
@ -364,9 +340,9 @@ export const ModalContextProvider = ({
|
||||||
<>
|
<>
|
||||||
{children}
|
{children}
|
||||||
{
|
{
|
||||||
!!showAccountSettingModal && (
|
accountSettingTab && (
|
||||||
<AccountSetting
|
<AccountSetting
|
||||||
activeTab={showAccountSettingModal.payload}
|
activeTab={accountSettingTab}
|
||||||
onCancel={handleCancelAccountSettingModal}
|
onCancel={handleCancelAccountSettingModal}
|
||||||
onTabChange={handleAccountSettingTabChange}
|
onTabChange={handleAccountSettingTabChange}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,222 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralized URL query parameter management hooks using nuqs
|
||||||
|
*
|
||||||
|
* This file provides type-safe, performant query parameter management
|
||||||
|
* that doesn't trigger full page refreshes (shallow routing).
|
||||||
|
*
|
||||||
|
* Best practices from nuqs documentation:
|
||||||
|
* - Use useQueryState for single parameters
|
||||||
|
* - Use useQueryStates for multiple related parameters (atomic updates)
|
||||||
|
* - Always provide parsers with defaults for type safety
|
||||||
|
* - Use shallow routing to avoid unnecessary re-renders
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
createParser,
|
||||||
|
parseAsArrayOf,
|
||||||
|
parseAsString,
|
||||||
|
useQueryState,
|
||||||
|
useQueryStates,
|
||||||
|
} from 'nuqs'
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
import { ACCOUNT_SETTING_MODAL_ACTION } from '@/app/components/header/account-setting/constants'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modal State Query Parameters
|
||||||
|
* Manages modal visibility and configuration via URL
|
||||||
|
*/
|
||||||
|
export const PRICING_MODAL_QUERY_PARAM = 'pricing'
|
||||||
|
export const PRICING_MODAL_QUERY_VALUE = 'open'
|
||||||
|
const parseAsPricingModal = createParser<boolean>({
|
||||||
|
parse: value => (value === PRICING_MODAL_QUERY_VALUE ? true : null),
|
||||||
|
serialize: value => (value ? PRICING_MODAL_QUERY_VALUE : ''),
|
||||||
|
})
|
||||||
|
.withDefault(false)
|
||||||
|
.withOptions({ history: 'push' })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to manage pricing modal state via URL
|
||||||
|
* @returns [isOpen, setIsOpen] - Tuple like useState
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const [isOpen, setIsOpen] = usePricingModal()
|
||||||
|
* setIsOpen(true) // Sets ?pricing=open
|
||||||
|
* setIsOpen(false) // Removes ?pricing
|
||||||
|
*/
|
||||||
|
export function usePricingModal() {
|
||||||
|
return useQueryState(
|
||||||
|
PRICING_MODAL_QUERY_PARAM,
|
||||||
|
parseAsPricingModal,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to manage account setting modal state via URL
|
||||||
|
* @returns [state, setState] - Object with isOpen + payload (tab) and setter
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const [accountModalState, setAccountModalState] = useAccountSettingModal()
|
||||||
|
* setAccountModalState({ payload: 'billing' }) // Sets ?action=showSettings&tab=billing
|
||||||
|
* setAccountModalState(null) // Removes both params
|
||||||
|
*/
|
||||||
|
export function useAccountSettingModal<T extends string = string>() {
|
||||||
|
const [accountState, setAccountState] = useQueryStates(
|
||||||
|
{
|
||||||
|
action: parseAsString,
|
||||||
|
tab: parseAsString,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
history: 'replace',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const setState = useCallback(
|
||||||
|
(state: { payload: T } | null) => {
|
||||||
|
if (!state) {
|
||||||
|
setAccountState({ action: null, tab: null }, { history: 'replace' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const shouldPush = accountState.action !== ACCOUNT_SETTING_MODAL_ACTION
|
||||||
|
setAccountState(
|
||||||
|
{ action: ACCOUNT_SETTING_MODAL_ACTION, tab: state.payload },
|
||||||
|
{ history: shouldPush ? 'push' : 'replace' },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[accountState.action, setAccountState],
|
||||||
|
)
|
||||||
|
|
||||||
|
const isOpen = accountState.action === ACCOUNT_SETTING_MODAL_ACTION
|
||||||
|
const currentTab = (isOpen ? accountState.tab : null) as T | null
|
||||||
|
|
||||||
|
return [{ isOpen, payload: currentTab }, setState] as const
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marketplace Search Query Parameters
|
||||||
|
*/
|
||||||
|
export type MarketplaceFilters = {
|
||||||
|
q: string // search query
|
||||||
|
category: string // plugin category
|
||||||
|
tags: string[] // array of tags
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to manage marketplace search/filter state via URL
|
||||||
|
* Provides atomic updates - all params update together
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const [filters, setFilters] = useMarketplaceFilters()
|
||||||
|
* setFilters({ q: 'search', category: 'tool', tags: ['ai'] }) // Updates all at once
|
||||||
|
* setFilters({ q: '' }) // Only updates q, keeps others
|
||||||
|
* setFilters(null) // Clears all marketplace params
|
||||||
|
*/
|
||||||
|
export function useMarketplaceFilters() {
|
||||||
|
return useQueryStates(
|
||||||
|
{
|
||||||
|
q: parseAsString.withDefault(''),
|
||||||
|
category: parseAsString.withDefault('all').withOptions({ clearOnDefault: false }),
|
||||||
|
tags: parseAsArrayOf(parseAsString).withDefault([]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Update URL without pushing to history (replaceState behavior)
|
||||||
|
history: 'replace',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plugin Installation Query Parameters
|
||||||
|
*/
|
||||||
|
const PACKAGE_IDS_PARAM = 'package-ids'
|
||||||
|
const BUNDLE_INFO_PARAM = 'bundle-info'
|
||||||
|
type BundleInfoQuery = {
|
||||||
|
org: string
|
||||||
|
name: string
|
||||||
|
version: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseAsPackageId = createParser<string>({
|
||||||
|
parse: (value) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value)
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
const first = parsed[0]
|
||||||
|
return typeof first === 'string' ? first : null
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
},
|
||||||
|
serialize: value => JSON.stringify([value]),
|
||||||
|
})
|
||||||
|
|
||||||
|
const parseAsBundleInfo = createParser<BundleInfoQuery>({
|
||||||
|
parse: (value) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value) as Partial<BundleInfoQuery>
|
||||||
|
if (parsed
|
||||||
|
&& typeof parsed.org === 'string'
|
||||||
|
&& typeof parsed.name === 'string'
|
||||||
|
&& typeof parsed.version === 'string') {
|
||||||
|
return { org: parsed.org, name: parsed.name, version: parsed.version }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
serialize: value => JSON.stringify(value),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to manage plugin installation state via URL
|
||||||
|
* @returns [installState, setInstallState] - installState includes parsed packageId and bundleInfo
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const [installState, setInstallState] = usePluginInstallation()
|
||||||
|
* setInstallState({ packageId: 'org/plugin' }) // Sets ?package-ids=["org/plugin"]
|
||||||
|
* setInstallState({ bundleInfo: { org: 'org', name: 'bundle', version: '1.0.0' } }) // Sets ?bundle-info=...
|
||||||
|
* setInstallState(null) // Clears installation params
|
||||||
|
*/
|
||||||
|
export function usePluginInstallation() {
|
||||||
|
return useQueryStates(
|
||||||
|
{
|
||||||
|
packageId: parseAsPackageId,
|
||||||
|
bundleInfo: parseAsBundleInfo,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
urlKeys: {
|
||||||
|
packageId: PACKAGE_IDS_PARAM,
|
||||||
|
bundleInfo: BUNDLE_INFO_PARAM,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility to clear specific query parameters from URL
|
||||||
|
* This is a client-side utility that should be called from client components
|
||||||
|
*
|
||||||
|
* @param keys - Single key or array of keys to remove from URL
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // In a client component
|
||||||
|
* clearQueryParams('param1')
|
||||||
|
* clearQueryParams(['param1', 'param2'])
|
||||||
|
*/
|
||||||
|
export function clearQueryParams(keys: string | string[]) {
|
||||||
|
if (typeof window === 'undefined')
|
||||||
|
return
|
||||||
|
|
||||||
|
const url = new URL(window.location.href)
|
||||||
|
const keysArray = Array.isArray(keys) ? keys : [keys]
|
||||||
|
|
||||||
|
keysArray.forEach(key => url.searchParams.delete(key))
|
||||||
|
|
||||||
|
window.history.replaceState(null, '', url.toString())
|
||||||
|
}
|
||||||
|
|
@ -1,545 +0,0 @@
|
||||||
import type { Mock } from 'vitest'
|
|
||||||
/**
|
|
||||||
* Test suite for useTabSearchParams hook
|
|
||||||
*
|
|
||||||
* This hook manages tab state through URL search parameters, enabling:
|
|
||||||
* - Bookmarkable tab states (users can share URLs with specific tabs active)
|
|
||||||
* - Browser history integration (back/forward buttons work with tabs)
|
|
||||||
* - Configurable routing behavior (push vs replace)
|
|
||||||
* - Optional search parameter syncing (can disable URL updates)
|
|
||||||
*
|
|
||||||
* The hook syncs a local tab state with URL search parameters, making tab
|
|
||||||
* navigation persistent and shareable across sessions.
|
|
||||||
*/
|
|
||||||
import { act, renderHook } from '@testing-library/react'
|
|
||||||
// Import after mocks
|
|
||||||
import { usePathname } from 'next/navigation'
|
|
||||||
|
|
||||||
import { useTabSearchParams } from './use-tab-searchparams'
|
|
||||||
|
|
||||||
// Mock Next.js navigation hooks
|
|
||||||
const mockPush = vi.fn()
|
|
||||||
const mockReplace = vi.fn()
|
|
||||||
const mockPathname = '/test-path'
|
|
||||||
const mockSearchParams = new URLSearchParams()
|
|
||||||
|
|
||||||
vi.mock('next/navigation', () => ({
|
|
||||||
usePathname: vi.fn(() => mockPathname),
|
|
||||||
useRouter: vi.fn(() => ({
|
|
||||||
push: mockPush,
|
|
||||||
replace: mockReplace,
|
|
||||||
})),
|
|
||||||
useSearchParams: vi.fn(() => mockSearchParams),
|
|
||||||
}))
|
|
||||||
|
|
||||||
describe('useTabSearchParams', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mockSearchParams.delete('category')
|
|
||||||
mockSearchParams.delete('tab')
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Basic functionality', () => {
|
|
||||||
/**
|
|
||||||
* Test that the hook returns a tuple with activeTab and setActiveTab
|
|
||||||
* This is the primary interface matching React's useState pattern
|
|
||||||
*/
|
|
||||||
it('should return activeTab and setActiveTab function', () => {
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useTabSearchParams({ defaultTab: 'overview' }),
|
|
||||||
)
|
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = result.current
|
|
||||||
|
|
||||||
expect(typeof activeTab).toBe('string')
|
|
||||||
expect(typeof setActiveTab).toBe('function')
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test that the hook initializes with the default tab
|
|
||||||
* When no search param is present, should use defaultTab
|
|
||||||
*/
|
|
||||||
it('should initialize with default tab when no search param exists', () => {
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useTabSearchParams({ defaultTab: 'overview' }),
|
|
||||||
)
|
|
||||||
|
|
||||||
const [activeTab] = result.current
|
|
||||||
expect(activeTab).toBe('overview')
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test that the hook reads from URL search parameters
|
|
||||||
* When a search param exists, it should take precedence over defaultTab
|
|
||||||
*/
|
|
||||||
it('should initialize with search param value when present', () => {
|
|
||||||
mockSearchParams.set('category', 'settings')
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useTabSearchParams({ defaultTab: 'overview' }),
|
|
||||||
)
|
|
||||||
|
|
||||||
const [activeTab] = result.current
|
|
||||||
expect(activeTab).toBe('settings')
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test that setActiveTab updates the local state
|
|
||||||
* The active tab should change when setActiveTab is called
|
|
||||||
*/
|
|
||||||
it('should update active tab when setActiveTab is called', () => {
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useTabSearchParams({ defaultTab: 'overview' }),
|
|
||||||
)
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
const [, setActiveTab] = result.current
|
|
||||||
setActiveTab('settings')
|
|
||||||
})
|
|
||||||
|
|
||||||
const [activeTab] = result.current
|
|
||||||
expect(activeTab).toBe('settings')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Routing behavior', () => {
|
|
||||||
/**
|
|
||||||
* Test default push routing behavior
|
|
||||||
* By default, tab changes should use router.push (adds to history)
|
|
||||||
*/
|
|
||||||
it('should use push routing by default', () => {
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useTabSearchParams({ defaultTab: 'overview' }),
|
|
||||||
)
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
const [, setActiveTab] = result.current
|
|
||||||
setActiveTab('settings')
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(mockPush).toHaveBeenCalledWith('/test-path?category=settings', { scroll: false })
|
|
||||||
expect(mockReplace).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test replace routing behavior
|
|
||||||
* When routingBehavior is 'replace', should use router.replace (no history)
|
|
||||||
*/
|
|
||||||
it('should use replace routing when specified', () => {
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useTabSearchParams({
|
|
||||||
defaultTab: 'overview',
|
|
||||||
routingBehavior: 'replace',
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
const [, setActiveTab] = result.current
|
|
||||||
setActiveTab('settings')
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(mockReplace).toHaveBeenCalledWith('/test-path?category=settings', { scroll: false })
|
|
||||||
expect(mockPush).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test that URL encoding is applied to tab values
|
|
||||||
* Special characters in tab names should be properly encoded
|
|
||||||
*/
|
|
||||||
it('should encode special characters in tab values', () => {
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useTabSearchParams({ defaultTab: 'overview' }),
|
|
||||||
)
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
const [, setActiveTab] = result.current
|
|
||||||
setActiveTab('settings & config')
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(mockPush).toHaveBeenCalledWith(
|
|
||||||
'/test-path?category=settings%20%26%20config',
|
|
||||||
{ scroll: false },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test that URL decoding is applied when reading from search params
|
|
||||||
* Encoded values in the URL should be properly decoded
|
|
||||||
*/
|
|
||||||
it('should decode encoded values from search params', () => {
|
|
||||||
mockSearchParams.set('category', 'settings%20%26%20config')
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useTabSearchParams({ defaultTab: 'overview' }),
|
|
||||||
)
|
|
||||||
|
|
||||||
const [activeTab] = result.current
|
|
||||||
expect(activeTab).toBe('settings & config')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Custom search parameter name', () => {
|
|
||||||
/**
|
|
||||||
* Test using a custom search parameter name
|
|
||||||
* Should support different param names instead of default 'category'
|
|
||||||
*/
|
|
||||||
it('should use custom search param name', () => {
|
|
||||||
mockSearchParams.set('tab', 'profile')
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useTabSearchParams({
|
|
||||||
defaultTab: 'overview',
|
|
||||||
searchParamName: 'tab',
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const [activeTab] = result.current
|
|
||||||
expect(activeTab).toBe('profile')
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test that setActiveTab uses the custom param name in the URL
|
|
||||||
*/
|
|
||||||
it('should update URL with custom param name', () => {
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useTabSearchParams({
|
|
||||||
defaultTab: 'overview',
|
|
||||||
searchParamName: 'tab',
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
const [, setActiveTab] = result.current
|
|
||||||
setActiveTab('profile')
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(mockPush).toHaveBeenCalledWith('/test-path?tab=profile', { scroll: false })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Disabled search params mode', () => {
|
|
||||||
/**
|
|
||||||
* Test that disableSearchParams prevents URL updates
|
|
||||||
* When disabled, tab state should be local only
|
|
||||||
*/
|
|
||||||
it('should not update URL when disableSearchParams is true', () => {
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useTabSearchParams({
|
|
||||||
defaultTab: 'overview',
|
|
||||||
disableSearchParams: true,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
const [, setActiveTab] = result.current
|
|
||||||
setActiveTab('settings')
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(mockPush).not.toHaveBeenCalled()
|
|
||||||
expect(mockReplace).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test that local state still updates when search params are disabled
|
|
||||||
* The tab state should work even without URL syncing
|
|
||||||
*/
|
|
||||||
it('should still update local state when search params disabled', () => {
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useTabSearchParams({
|
|
||||||
defaultTab: 'overview',
|
|
||||||
disableSearchParams: true,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
const [, setActiveTab] = result.current
|
|
||||||
setActiveTab('settings')
|
|
||||||
})
|
|
||||||
|
|
||||||
const [activeTab] = result.current
|
|
||||||
expect(activeTab).toBe('settings')
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test that disabled mode always uses defaultTab
|
|
||||||
* Search params should be ignored when disabled
|
|
||||||
*/
|
|
||||||
it('should use defaultTab when search params disabled even if URL has value', () => {
|
|
||||||
mockSearchParams.set('category', 'settings')
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useTabSearchParams({
|
|
||||||
defaultTab: 'overview',
|
|
||||||
disableSearchParams: true,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const [activeTab] = result.current
|
|
||||||
expect(activeTab).toBe('overview')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Edge cases', () => {
|
|
||||||
/**
|
|
||||||
* Test handling of empty string tab values
|
|
||||||
* Empty strings should be handled gracefully
|
|
||||||
*/
|
|
||||||
it('should handle empty string tab values', () => {
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useTabSearchParams({ defaultTab: 'overview' }),
|
|
||||||
)
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
const [, setActiveTab] = result.current
|
|
||||||
setActiveTab('')
|
|
||||||
})
|
|
||||||
|
|
||||||
const [activeTab] = result.current
|
|
||||||
expect(activeTab).toBe('')
|
|
||||||
expect(mockPush).toHaveBeenCalledWith('/test-path?category=', { scroll: false })
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test that special characters in tab names are properly encoded
|
|
||||||
* This ensures URLs remain valid even with unusual tab names
|
|
||||||
*/
|
|
||||||
it('should handle tabs with various special characters', () => {
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useTabSearchParams({ defaultTab: 'overview' }),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Test tab with slashes
|
|
||||||
act(() => result.current[1]('tab/with/slashes'))
|
|
||||||
expect(result.current[0]).toBe('tab/with/slashes')
|
|
||||||
|
|
||||||
// Test tab with question marks
|
|
||||||
act(() => result.current[1]('tab?with?questions'))
|
|
||||||
expect(result.current[0]).toBe('tab?with?questions')
|
|
||||||
|
|
||||||
// Test tab with hash symbols
|
|
||||||
act(() => result.current[1]('tab#with#hash'))
|
|
||||||
expect(result.current[0]).toBe('tab#with#hash')
|
|
||||||
|
|
||||||
// Test tab with equals signs
|
|
||||||
act(() => result.current[1]('tab=with=equals'))
|
|
||||||
expect(result.current[0]).toBe('tab=with=equals')
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test fallback when pathname is not available
|
|
||||||
* Should use window.location.pathname as fallback
|
|
||||||
*/
|
|
||||||
it('should fallback to window.location.pathname when hook pathname is null', () => {
|
|
||||||
;(usePathname as Mock).mockReturnValue(null)
|
|
||||||
|
|
||||||
// Mock window.location.pathname
|
|
||||||
Object.defineProperty(window, 'location', {
|
|
||||||
value: { pathname: '/fallback-path' },
|
|
||||||
writable: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useTabSearchParams({ defaultTab: 'overview' }),
|
|
||||||
)
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
const [, setActiveTab] = result.current
|
|
||||||
setActiveTab('settings')
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(mockPush).toHaveBeenCalledWith('/fallback-path?category=settings', { scroll: false })
|
|
||||||
|
|
||||||
// Restore mock
|
|
||||||
;(usePathname as Mock).mockReturnValue(mockPathname)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Multiple instances', () => {
|
|
||||||
/**
|
|
||||||
* Test that multiple instances with different param names work independently
|
|
||||||
* Different hooks should not interfere with each other
|
|
||||||
*/
|
|
||||||
it('should support multiple independent tab states', () => {
|
|
||||||
mockSearchParams.set('category', 'overview')
|
|
||||||
mockSearchParams.set('subtab', 'details')
|
|
||||||
|
|
||||||
const { result: result1 } = renderHook(() =>
|
|
||||||
useTabSearchParams({
|
|
||||||
defaultTab: 'home',
|
|
||||||
searchParamName: 'category',
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const { result: result2 } = renderHook(() =>
|
|
||||||
useTabSearchParams({
|
|
||||||
defaultTab: 'info',
|
|
||||||
searchParamName: 'subtab',
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const [activeTab1] = result1.current
|
|
||||||
const [activeTab2] = result2.current
|
|
||||||
|
|
||||||
expect(activeTab1).toBe('overview')
|
|
||||||
expect(activeTab2).toBe('details')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Integration scenarios', () => {
|
|
||||||
/**
|
|
||||||
* Test typical usage in a tabbed interface
|
|
||||||
* Simulates real-world tab switching behavior
|
|
||||||
*/
|
|
||||||
it('should handle sequential tab changes', () => {
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useTabSearchParams({ defaultTab: 'overview' }),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Change to settings tab
|
|
||||||
act(() => {
|
|
||||||
const [, setActiveTab] = result.current
|
|
||||||
setActiveTab('settings')
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.current[0]).toBe('settings')
|
|
||||||
expect(mockPush).toHaveBeenCalledWith('/test-path?category=settings', { scroll: false })
|
|
||||||
|
|
||||||
// Change to profile tab
|
|
||||||
act(() => {
|
|
||||||
const [, setActiveTab] = result.current
|
|
||||||
setActiveTab('profile')
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.current[0]).toBe('profile')
|
|
||||||
expect(mockPush).toHaveBeenCalledWith('/test-path?category=profile', { scroll: false })
|
|
||||||
|
|
||||||
// Verify push was called twice
|
|
||||||
expect(mockPush).toHaveBeenCalledTimes(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test that the hook works with complex pathnames
|
|
||||||
* Should handle nested routes and existing query params
|
|
||||||
*/
|
|
||||||
it('should work with complex pathnames', () => {
|
|
||||||
;(usePathname as Mock).mockReturnValue('/app/123/settings')
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useTabSearchParams({ defaultTab: 'overview' }),
|
|
||||||
)
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
const [, setActiveTab] = result.current
|
|
||||||
setActiveTab('advanced')
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(mockPush).toHaveBeenCalledWith('/app/123/settings?category=advanced', { scroll: false })
|
|
||||||
|
|
||||||
// Restore mock
|
|
||||||
;(usePathname as Mock).mockReturnValue(mockPathname)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Type safety', () => {
|
|
||||||
/**
|
|
||||||
* Test that the return type is a const tuple
|
|
||||||
* TypeScript should infer [string, (tab: string) => void] as const
|
|
||||||
*/
|
|
||||||
it('should return a const tuple type', () => {
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useTabSearchParams({ defaultTab: 'overview' }),
|
|
||||||
)
|
|
||||||
|
|
||||||
// The result should be a tuple with exactly 2 elements
|
|
||||||
expect(result.current).toHaveLength(2)
|
|
||||||
expect(typeof result.current[0]).toBe('string')
|
|
||||||
expect(typeof result.current[1]).toBe('function')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Performance', () => {
|
|
||||||
/**
|
|
||||||
* Test that the hook creates a new function on each render
|
|
||||||
* Note: The current implementation doesn't use useCallback,
|
|
||||||
* so setActiveTab is recreated on each render. This could lead to
|
|
||||||
* unnecessary re-renders in child components that depend on this function.
|
|
||||||
* TODO: Consider memoizing setActiveTab with useCallback for better performance.
|
|
||||||
*/
|
|
||||||
it('should create new setActiveTab function on each render', () => {
|
|
||||||
const { result, rerender } = renderHook(() =>
|
|
||||||
useTabSearchParams({ defaultTab: 'overview' }),
|
|
||||||
)
|
|
||||||
|
|
||||||
const [, firstSetActiveTab] = result.current
|
|
||||||
rerender()
|
|
||||||
const [, secondSetActiveTab] = result.current
|
|
||||||
|
|
||||||
// Function reference changes on re-render (not memoized)
|
|
||||||
expect(firstSetActiveTab).not.toBe(secondSetActiveTab)
|
|
||||||
|
|
||||||
// But both functions should work correctly
|
|
||||||
expect(typeof firstSetActiveTab).toBe('function')
|
|
||||||
expect(typeof secondSetActiveTab).toBe('function')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Browser history integration', () => {
|
|
||||||
/**
|
|
||||||
* Test that push behavior adds to browser history
|
|
||||||
* This enables back/forward navigation through tabs
|
|
||||||
*/
|
|
||||||
it('should add to history with push behavior', () => {
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useTabSearchParams({
|
|
||||||
defaultTab: 'overview',
|
|
||||||
routingBehavior: 'push',
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
const [, setActiveTab] = result.current
|
|
||||||
setActiveTab('tab1')
|
|
||||||
})
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
const [, setActiveTab] = result.current
|
|
||||||
setActiveTab('tab2')
|
|
||||||
})
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
const [, setActiveTab] = result.current
|
|
||||||
setActiveTab('tab3')
|
|
||||||
})
|
|
||||||
|
|
||||||
// Each tab change should create a history entry
|
|
||||||
expect(mockPush).toHaveBeenCalledTimes(3)
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test that replace behavior doesn't add to history
|
|
||||||
* This prevents cluttering browser history with tab changes
|
|
||||||
*/
|
|
||||||
it('should not add to history with replace behavior', () => {
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useTabSearchParams({
|
|
||||||
defaultTab: 'overview',
|
|
||||||
routingBehavior: 'replace',
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
const [, setActiveTab] = result.current
|
|
||||||
setActiveTab('tab1')
|
|
||||||
})
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
const [, setActiveTab] = result.current
|
|
||||||
setActiveTab('tab2')
|
|
||||||
})
|
|
||||||
|
|
||||||
// Should use replace instead of push
|
|
||||||
expect(mockReplace).toHaveBeenCalledTimes(2)
|
|
||||||
expect(mockPush).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
'use client'
|
|
||||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
|
||||||
import { useState } from 'react'
|
|
||||||
|
|
||||||
type UseTabSearchParamsOptions = {
|
|
||||||
defaultTab: string
|
|
||||||
routingBehavior?: 'push' | 'replace'
|
|
||||||
searchParamName?: string
|
|
||||||
disableSearchParams?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom hook to manage tab state via URL search parameters in a Next.js application.
|
|
||||||
* This hook allows for syncing the active tab with the browser's URL, enabling bookmarking and sharing of URLs with a specific tab activated.
|
|
||||||
*
|
|
||||||
* @param {UseTabSearchParamsOptions} options Configuration options for the hook:
|
|
||||||
* - `defaultTab`: The tab to default to when no tab is specified in the URL.
|
|
||||||
* - `routingBehavior`: Optional. Determines how changes to the active tab update the browser's history ('push' or 'replace'). Default is 'push'.
|
|
||||||
* - `searchParamName`: Optional. The name of the search parameter that holds the tab state in the URL. Default is 'category'.
|
|
||||||
* @returns A tuple where the first element is the active tab and the second element is a function to set the active tab.
|
|
||||||
*/
|
|
||||||
export const useTabSearchParams = ({
|
|
||||||
defaultTab,
|
|
||||||
routingBehavior = 'push',
|
|
||||||
searchParamName = 'category',
|
|
||||||
disableSearchParams = false,
|
|
||||||
}: UseTabSearchParamsOptions) => {
|
|
||||||
const pathnameFromHook = usePathname()
|
|
||||||
const router = useRouter()
|
|
||||||
const pathName = pathnameFromHook || window?.location?.pathname
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const searchParamValue = searchParams.has(searchParamName) ? decodeURIComponent(searchParams.get(searchParamName)!) : defaultTab
|
|
||||||
const [activeTab, setTab] = useState<string>(
|
|
||||||
!disableSearchParams
|
|
||||||
? searchParamValue
|
|
||||||
: defaultTab,
|
|
||||||
)
|
|
||||||
|
|
||||||
const setActiveTab = (newActiveTab: string) => {
|
|
||||||
setTab(newActiveTab)
|
|
||||||
if (disableSearchParams)
|
|
||||||
return
|
|
||||||
router[`${routingBehavior}`](`${pathName}?${searchParamName}=${encodeURIComponent(newActiveTab)}`, { scroll: false })
|
|
||||||
}
|
|
||||||
|
|
||||||
return [activeTab, setActiveTab] as const
|
|
||||||
}
|
|
||||||
|
|
@ -108,6 +108,7 @@
|
||||||
"next": "~15.5.9",
|
"next": "~15.5.9",
|
||||||
"next-pwa": "^5.6.0",
|
"next-pwa": "^5.6.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"nuqs": "^2.8.6",
|
||||||
"pinyin-pro": "^3.27.0",
|
"pinyin-pro": "^3.27.0",
|
||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"qs": "^6.14.0",
|
"qs": "^6.14.0",
|
||||||
|
|
|
||||||
|
|
@ -240,6 +240,9 @@ importers:
|
||||||
next-themes:
|
next-themes:
|
||||||
specifier: ^0.4.6
|
specifier: ^0.4.6
|
||||||
version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
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:
|
pinyin-pro:
|
||||||
specifier: ^3.27.0
|
specifier: ^3.27.0
|
||||||
version: 3.27.0
|
version: 3.27.0
|
||||||
|
|
@ -3205,6 +3208,9 @@ packages:
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
solid-js: ^1.6.12
|
solid-js: ^1.6.12
|
||||||
|
|
||||||
|
'@standard-schema/spec@1.0.0':
|
||||||
|
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
|
||||||
|
|
||||||
'@standard-schema/spec@1.1.0':
|
'@standard-schema/spec@1.1.0':
|
||||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||||
|
|
||||||
|
|
@ -3698,9 +3704,6 @@ packages:
|
||||||
'@types/node@20.19.26':
|
'@types/node@20.19.26':
|
||||||
resolution: {integrity: sha512-0l6cjgF0XnihUpndDhk+nyD3exio3iKaYROSgvh/qSevPXax3L8p5DBRFjbvalnwatGgHEQn2R88y2fA3g4irg==}
|
resolution: {integrity: sha512-0l6cjgF0XnihUpndDhk+nyD3exio3iKaYROSgvh/qSevPXax3L8p5DBRFjbvalnwatGgHEQn2R88y2fA3g4irg==}
|
||||||
|
|
||||||
'@types/node@20.19.27':
|
|
||||||
resolution: {integrity: sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==}
|
|
||||||
|
|
||||||
'@types/papaparse@5.5.1':
|
'@types/papaparse@5.5.1':
|
||||||
resolution: {integrity: sha512-esEO+VISsLIyE+JZBmb89NzsYYbpwV8lmv2rPo6oX5y9KhBaIP7hhHgjuTut54qjdKVMufTEcrh5fUl9+58huw==}
|
resolution: {integrity: sha512-esEO+VISsLIyE+JZBmb89NzsYYbpwV8lmv2rPo6oX5y9KhBaIP7hhHgjuTut54qjdKVMufTEcrh5fUl9+58huw==}
|
||||||
|
|
||||||
|
|
@ -6315,8 +6318,8 @@ packages:
|
||||||
lexical@0.38.2:
|
lexical@0.38.2:
|
||||||
resolution: {integrity: sha512-JJmfsG3c4gwBHzUGffbV7ifMNkKAWMCnYE3xJl87gty7hjyV5f3xq7eqTjP5HFYvO4XpjJvvWO2/djHp5S10tw==}
|
resolution: {integrity: sha512-JJmfsG3c4gwBHzUGffbV7ifMNkKAWMCnYE3xJl87gty7hjyV5f3xq7eqTjP5HFYvO4XpjJvvWO2/djHp5S10tw==}
|
||||||
|
|
||||||
lib0@0.2.116:
|
lib0@0.2.115:
|
||||||
resolution: {integrity: sha512-4zsosjzmt33rx5XjmFVYUAeLNh+BTeDTiwGdLt4muxiir2btsc60Nal0EvkvDRizg+pnlK1q+BtYi7M+d4eStw==}
|
resolution: {integrity: sha512-noaW4yNp6hCjOgDnWWxW0vGXE3kZQI5Kqiwz+jIWXavI9J9WyfJ9zjsbQlQlgjIbHBrvlA/x3TSIXBUJj+0L6g==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
|
@ -6843,6 +6846,27 @@ packages:
|
||||||
nth-check@2.1.1:
|
nth-check@2.1.1:
|
||||||
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
|
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:
|
object-assign@4.1.1:
|
||||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
@ -8711,7 +8735,6 @@ packages:
|
||||||
whatwg-encoding@3.1.1:
|
whatwg-encoding@3.1.1:
|
||||||
resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
|
resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
|
|
||||||
|
|
||||||
whatwg-mimetype@3.0.0:
|
whatwg-mimetype@3.0.0:
|
||||||
resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
|
resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
|
||||||
|
|
@ -11696,6 +11719,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
solid-js: 1.9.10
|
solid-js: 1.9.10
|
||||||
|
|
||||||
|
'@standard-schema/spec@1.0.0': {}
|
||||||
|
|
||||||
'@standard-schema/spec@1.1.0': {}
|
'@standard-schema/spec@1.1.0': {}
|
||||||
|
|
||||||
'@standard-schema/utils@0.3.0': {}
|
'@standard-schema/utils@0.3.0': {}
|
||||||
|
|
@ -12342,11 +12367,6 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
undici-types: 6.21.0
|
||||||
|
|
||||||
'@types/node@20.19.27':
|
|
||||||
dependencies:
|
|
||||||
undici-types: 6.21.0
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@types/papaparse@5.5.1':
|
'@types/papaparse@5.5.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 18.15.0
|
'@types/node': 18.15.0
|
||||||
|
|
@ -14772,7 +14792,7 @@ snapshots:
|
||||||
|
|
||||||
happy-dom@20.0.11:
|
happy-dom@20.0.11:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 20.19.27
|
'@types/node': 20.19.26
|
||||||
'@types/whatwg-mimetype': 3.0.2
|
'@types/whatwg-mimetype': 3.0.2
|
||||||
whatwg-mimetype: 3.0.0
|
whatwg-mimetype: 3.0.0
|
||||||
optional: true
|
optional: true
|
||||||
|
|
@ -15387,7 +15407,7 @@ snapshots:
|
||||||
|
|
||||||
lexical@0.38.2: {}
|
lexical@0.38.2: {}
|
||||||
|
|
||||||
lib0@0.2.116:
|
lib0@0.2.115:
|
||||||
dependencies:
|
dependencies:
|
||||||
isomorphic.js: 0.2.5
|
isomorphic.js: 0.2.5
|
||||||
|
|
||||||
|
|
@ -16253,6 +16273,13 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
boolbase: 1.0.0
|
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-assign@4.1.1: {}
|
||||||
|
|
||||||
object-deep-merge@2.0.0: {}
|
object-deep-merge@2.0.0: {}
|
||||||
|
|
@ -18472,7 +18499,7 @@ snapshots:
|
||||||
|
|
||||||
yjs@13.6.27:
|
yjs@13.6.27:
|
||||||
dependencies:
|
dependencies:
|
||||||
lib0: 0.2.116
|
lib0: 0.2.115
|
||||||
|
|
||||||
yocto-queue@0.1.0: {}
|
yocto-queue@0.1.0: {}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import type { Mock } from 'vitest'
|
|
||||||
import {
|
import {
|
||||||
asyncRunSafe,
|
asyncRunSafe,
|
||||||
canFindTool,
|
canFindTool,
|
||||||
|
|
@ -8,7 +7,6 @@ import {
|
||||||
getPurifyHref,
|
getPurifyHref,
|
||||||
getTextWidthWithCanvas,
|
getTextWidthWithCanvas,
|
||||||
randomString,
|
randomString,
|
||||||
removeSpecificQueryParam,
|
|
||||||
sleep,
|
sleep,
|
||||||
} from './index'
|
} from './index'
|
||||||
|
|
||||||
|
|
@ -231,72 +229,6 @@ describe('canFindTool', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('removeSpecificQueryParam', () => {
|
|
||||||
let originalLocation: Location
|
|
||||||
let originalReplaceState: typeof window.history.replaceState
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
originalLocation = window.location
|
|
||||||
originalReplaceState = window.history.replaceState
|
|
||||||
|
|
||||||
const mockUrl = new URL('https://example.com?param1=value1¶m2=value2¶m3=value3')
|
|
||||||
|
|
||||||
// Mock window.location using defineProperty to handle URL properly
|
|
||||||
delete (window as any).location
|
|
||||||
Object.defineProperty(window, 'location', {
|
|
||||||
configurable: true,
|
|
||||||
writable: true,
|
|
||||||
value: {
|
|
||||||
...originalLocation,
|
|
||||||
href: mockUrl.href,
|
|
||||||
search: mockUrl.search,
|
|
||||||
toString: () => mockUrl.toString(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
window.history.replaceState = vi.fn()
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
Object.defineProperty(window, 'location', {
|
|
||||||
configurable: true,
|
|
||||||
writable: true,
|
|
||||||
value: originalLocation,
|
|
||||||
})
|
|
||||||
window.history.replaceState = originalReplaceState
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should remove a single query parameter', () => {
|
|
||||||
removeSpecificQueryParam('param2')
|
|
||||||
expect(window.history.replaceState).toHaveBeenCalledTimes(1)
|
|
||||||
const replaceStateCall = (window.history.replaceState as Mock).mock.calls[0]
|
|
||||||
expect(replaceStateCall[0]).toBe(null)
|
|
||||||
expect(replaceStateCall[1]).toBe('')
|
|
||||||
expect(replaceStateCall[2]).toMatch(/param1=value1/)
|
|
||||||
expect(replaceStateCall[2]).toMatch(/param3=value3/)
|
|
||||||
expect(replaceStateCall[2]).not.toMatch(/param2=value2/)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should remove multiple query parameters', () => {
|
|
||||||
removeSpecificQueryParam(['param1', 'param3'])
|
|
||||||
expect(window.history.replaceState).toHaveBeenCalledTimes(1)
|
|
||||||
const replaceStateCall = (window.history.replaceState as Mock).mock.calls[0]
|
|
||||||
expect(replaceStateCall[2]).toMatch(/param2=value2/)
|
|
||||||
expect(replaceStateCall[2]).not.toMatch(/param1=value1/)
|
|
||||||
expect(replaceStateCall[2]).not.toMatch(/param3=value3/)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should handle non-existent parameters gracefully', () => {
|
|
||||||
removeSpecificQueryParam('nonexistent')
|
|
||||||
|
|
||||||
expect(window.history.replaceState).toHaveBeenCalledTimes(1)
|
|
||||||
const replaceStateCall = (window.history.replaceState as Mock).mock.calls[0]
|
|
||||||
expect(replaceStateCall[2]).toMatch(/param1=value1/)
|
|
||||||
expect(replaceStateCall[2]).toMatch(/param2=value2/)
|
|
||||||
expect(replaceStateCall[2]).toMatch(/param3=value3/)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('sleep', () => {
|
describe('sleep', () => {
|
||||||
it('should resolve after specified milliseconds', async () => {
|
it('should resolve after specified milliseconds', async () => {
|
||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
|
|
@ -560,47 +492,3 @@ describe('canFindTool extended', () => {
|
||||||
expect(canFindTool('openai', undefined)).toBe(false)
|
expect(canFindTool('openai', undefined)).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('removeSpecificQueryParam extended', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
// Reset window.location
|
|
||||||
delete (window as any).location
|
|
||||||
window.location = {
|
|
||||||
href: 'https://example.com?param1=value1¶m2=value2¶m3=value3',
|
|
||||||
} as any
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should remove single query parameter', () => {
|
|
||||||
const mockReplaceState = vi.fn()
|
|
||||||
window.history.replaceState = mockReplaceState
|
|
||||||
|
|
||||||
removeSpecificQueryParam('param1')
|
|
||||||
|
|
||||||
expect(mockReplaceState).toHaveBeenCalled()
|
|
||||||
const newUrl = mockReplaceState.mock.calls[0][2]
|
|
||||||
expect(newUrl).not.toContain('param1')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should remove multiple query parameters', () => {
|
|
||||||
const mockReplaceState = vi.fn()
|
|
||||||
window.history.replaceState = mockReplaceState
|
|
||||||
|
|
||||||
removeSpecificQueryParam(['param1', 'param2'])
|
|
||||||
|
|
||||||
expect(mockReplaceState).toHaveBeenCalled()
|
|
||||||
const newUrl = mockReplaceState.mock.calls[0][2]
|
|
||||||
expect(newUrl).not.toContain('param1')
|
|
||||||
expect(newUrl).not.toContain('param2')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should preserve other parameters', () => {
|
|
||||||
const mockReplaceState = vi.fn()
|
|
||||||
window.history.replaceState = mockReplaceState
|
|
||||||
|
|
||||||
removeSpecificQueryParam('param1')
|
|
||||||
|
|
||||||
const newUrl = mockReplaceState.mock.calls[0][2]
|
|
||||||
expect(newUrl).toContain('param2')
|
|
||||||
expect(newUrl).toContain('param3')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
|
||||||
|
|
@ -90,12 +90,3 @@ export const canFindTool = (providerId: string, oldToolId?: string) => {
|
||||||
|| providerId === `langgenius/${oldToolId}/${oldToolId}`
|
|| providerId === `langgenius/${oldToolId}/${oldToolId}`
|
||||||
|| providerId === `langgenius/${oldToolId}_tool/${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())
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue