diff --git a/web/__tests__/apps/app-list-browsing-flow.test.tsx b/web/__tests__/apps/app-list-browsing-flow.test.tsx
index 4829adacf0..b16f7a2bc0 100644
--- a/web/__tests__/apps/app-list-browsing-flow.test.tsx
+++ b/web/__tests__/apps/app-list-browsing-flow.test.tsx
@@ -45,6 +45,7 @@ vi.mock('@/next/navigation', () => ({
push: mockRouterPush,
replace: mockRouterReplace,
}),
+ usePathname: () => '/apps',
useSearchParams: () => new URLSearchParams(),
}))
diff --git a/web/__tests__/apps/create-app-flow.test.tsx b/web/__tests__/apps/create-app-flow.test.tsx
index a487f102dd..ba3ab166de 100644
--- a/web/__tests__/apps/create-app-flow.test.tsx
+++ b/web/__tests__/apps/create-app-flow.test.tsx
@@ -42,6 +42,7 @@ vi.mock('@/next/navigation', () => ({
push: mockRouterPush,
replace: mockRouterReplace,
}),
+ usePathname: () => '/apps',
useSearchParams: () => new URLSearchParams(),
}))
diff --git a/web/app/components/apps/__tests__/list.spec.tsx b/web/app/components/apps/__tests__/list.spec.tsx
index 75442c1ebd..1a8b910bd4 100644
--- a/web/app/components/apps/__tests__/list.spec.tsx
+++ b/web/app/components/apps/__tests__/list.spec.tsx
@@ -1,4 +1,4 @@
-import { act, fireEvent, screen } from '@testing-library/react'
+import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
import { renderWithNuqs } from '@/test/nuqs-testing'
@@ -13,9 +13,11 @@ const mockUseWorkflowOnlineUsers = vi.hoisted(() => vi.fn((_options: unknown) =>
const mockReplace = vi.fn()
const mockRouter = { replace: mockReplace }
+let mockSearchParams = new URLSearchParams('')
vi.mock('@/next/navigation', () => ({
useRouter: () => mockRouter,
- useSearchParams: () => new URLSearchParams(''),
+ usePathname: () => '/apps',
+ useSearchParams: () => mockSearchParams,
}))
vi.mock('@/service/client', () => ({
@@ -49,12 +51,10 @@ vi.mock('@/context/app-context', () => ({
}))
const mockSetKeywords = vi.fn()
-const mockSetTagIDs = vi.fn()
const mockSetIsCreatedByMe = vi.fn()
const mockSetCategory = vi.fn()
const mockQueryState = {
category: 'all',
- tagIDs: [] as string[],
keywords: '',
isCreatedByMe: false,
}
@@ -64,11 +64,20 @@ vi.mock('../hooks/use-apps-query-state', () => ({
query: mockQueryState,
setCategory: mockSetCategory,
setKeywords: mockSetKeywords,
- setTagIDs: mockSetTagIDs,
setIsCreatedByMe: mockSetIsCreatedByMe,
}),
}))
+vi.mock('@/features/tag-management/components/tag-filter', () => ({
+ TagFilter: ({ value, onChange, onOpenTagManagement }: { value: string[], onChange: (value: string[]) => void, onOpenTagManagement: () => void }) => (
+
+
+ {value.join(',')}
+
+
+ ),
+}))
+
let mockOnDSLFileDropped: ((file: File) => void) | null = null
let mockDragging = false
vi.mock('../hooks/use-dsl-drag-drop', () => ({
@@ -230,6 +239,7 @@ beforeAll(() => {
// Render helper wrapping with shared nuqs testing helper plus a seeded
// systemFeatures cache so List can resolve its useSuspenseQuery.
const renderList = (searchParams = '') => {
+ mockSearchParams = new URLSearchParams(searchParams)
const { wrapper: SystemFeaturesWrapper } = createSystemFeaturesWrapper({
systemFeatures: { branding: { enabled: false } },
})
@@ -253,7 +263,6 @@ describe('List', () => {
mockServiceState.isLoading = false
mockServiceState.isFetchingNextPage = false
mockQueryState.category = 'all'
- mockQueryState.tagIDs = []
mockQueryState.keywords = ''
mockQueryState.isCreatedByMe = false
mockUseWorkflowOnlineUsers.mockClear()
@@ -375,12 +384,12 @@ describe('List', () => {
describe('App List Query', () => {
it('should build paged query input from active filters', () => {
- mockQueryState.tagIDs = ['tag-1']
mockQueryState.keywords = 'sales'
mockQueryState.isCreatedByMe = true
mockQueryState.category = AppModeEnum.WORKFLOW
renderList()
+ fireEvent.click(screen.getByText('common.tag.placeholder'))
const options = mockAppListInfiniteOptions.mock.calls.at(-1)?.[0] as AppListInfiniteOptions
@@ -397,6 +406,17 @@ describe('List', () => {
expect(options.getNextPageParam({ has_more: true, page: 2 })).toBe(3)
expect(options.getNextPageParam({ has_more: false, page: 2 })).toBeUndefined()
})
+
+ it('should remove legacy tagIDs from URL while preserving other filters', async () => {
+ renderList('?category=workflow&tagIDs=tag-1;tag-2&keywords=sales&isCreatedByMe=true')
+
+ await waitFor(() => {
+ expect(mockReplace).toHaveBeenCalledWith(
+ '/apps?category=workflow&keywords=sales&isCreatedByMe=true',
+ { scroll: false },
+ )
+ })
+ })
})
describe('Tag Filter', () => {
diff --git a/web/app/components/apps/hooks/__tests__/use-apps-query-state.spec.tsx b/web/app/components/apps/hooks/__tests__/use-apps-query-state.spec.tsx
index 782f6ec353..dfb0c3d7e6 100644
--- a/web/app/components/apps/hooks/__tests__/use-apps-query-state.spec.tsx
+++ b/web/app/components/apps/hooks/__tests__/use-apps-query-state.spec.tsx
@@ -5,6 +5,7 @@ import { APP_LIST_SEARCH_DEBOUNCE_MS } from '../../constants'
import { useAppsQueryState } from '../use-apps-query-state'
const renderWithAdapter = (searchParams = '') => {
+ // eslint-disable-next-line react/use-state -- renderHook executes a custom hook, not React.useState
return renderHookWithNuqs(() => useAppsQueryState(), { searchParams })
}
@@ -18,13 +19,11 @@ describe('useAppsQueryState', () => {
expect(result.current.query).toEqual({
category: 'all',
- tagIDs: [],
keywords: '',
isCreatedByMe: false,
})
expect(typeof result.current.setCategory).toBe('function')
expect(typeof result.current.setKeywords).toBe('function')
- expect(typeof result.current.setTagIDs).toBe('function')
expect(typeof result.current.setIsCreatedByMe).toBe('function')
})
@@ -35,7 +34,6 @@ describe('useAppsQueryState', () => {
expect(result.current.query).toEqual({
category: AppModeEnum.WORKFLOW,
- tagIDs: ['tag1', 'tag2'],
keywords: 'search term',
isCreatedByMe: true,
})
@@ -117,33 +115,6 @@ describe('useAppsQueryState', () => {
}
})
- it('should update tag filter URL state', async () => {
- const { result, onUrlUpdate } = renderWithAdapter()
-
- act(() => {
- result.current.setTagIDs(['tag1', 'tag2'])
- })
-
- await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
- const update = onUrlUpdate.mock.calls.at(-1)![0]
- expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2'])
- expect(update.searchParams.get('tagIDs')).toBe('tag1;tag2')
- expect(update.options.history).toBe('push')
- })
-
- it('should remove tagIDs from URL when empty', async () => {
- const { result, onUrlUpdate } = renderWithAdapter('?tagIDs=tag1;tag2')
-
- act(() => {
- result.current.setTagIDs([])
- })
-
- await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
- const update = onUrlUpdate.mock.calls.at(-1)![0]
- expect(result.current.query.tagIDs).toEqual([])
- expect(update.searchParams.has('tagIDs')).toBe(false)
- })
-
it('should update created-by-me URL state', async () => {
const { result, onUrlUpdate } = renderWithAdapter()
diff --git a/web/app/components/apps/hooks/use-apps-query-state.ts b/web/app/components/apps/hooks/use-apps-query-state.ts
index a0109eb061..d4b4c2fb2d 100644
--- a/web/app/components/apps/hooks/use-apps-query-state.ts
+++ b/web/app/components/apps/hooks/use-apps-query-state.ts
@@ -1,4 +1,4 @@
-import { debounce, parseAsArrayOf, parseAsBoolean, parseAsString, parseAsStringLiteral, useQueryStates } from 'nuqs'
+import { debounce, parseAsBoolean, parseAsString, parseAsStringLiteral, useQueryStates } from 'nuqs'
import { useCallback, useMemo } from 'react'
import { AppModes } from '@/types/app'
import { APP_LIST_SEARCH_DEBOUNCE_MS } from '../constants'
@@ -16,9 +16,6 @@ const appListQueryParsers = {
category: parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
.withDefault('all')
.withOptions({ history: 'push' }),
- tagIDs: parseAsArrayOf(parseAsString, ';')
- .withDefault([])
- .withOptions({ history: 'push' }),
keywords: parseAsString.withDefault('').withOptions({
limitUrlUpdates: debounce(APP_LIST_SEARCH_DEBOUNCE_MS),
}),
@@ -38,10 +35,6 @@ export function useAppsQueryState() {
setQuery({ keywords })
}, [setQuery])
- const setTagIDs = useCallback((tagIDs: string[]) => {
- setQuery({ tagIDs })
- }, [setQuery])
-
const setIsCreatedByMe = useCallback((isCreatedByMe: boolean) => {
setQuery({ isCreatedByMe })
}, [setQuery])
@@ -50,7 +43,6 @@ export function useAppsQueryState() {
query,
setCategory,
setKeywords,
- setTagIDs,
setIsCreatedByMe,
- }), [query, setCategory, setKeywords, setTagIDs, setIsCreatedByMe])
+ }), [query, setCategory, setKeywords, setIsCreatedByMe])
}
diff --git a/web/app/components/apps/list.tsx b/web/app/components/apps/list.tsx
index 7eb871c734..c10e1c20e5 100644
--- a/web/app/components/apps/list.tsx
+++ b/web/app/components/apps/list.tsx
@@ -15,6 +15,7 @@ import { useAppContext } from '@/context/app-context'
import { TagFilter } from '@/features/tag-management/components/tag-filter'
import { CheckModal } from '@/hooks/use-pay'
import dynamic from '@/next/dynamic'
+import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { AppModeEnum } from '@/types/app'
@@ -44,15 +45,18 @@ const List: FC = ({
const { t } = useTranslation()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
+ const searchParams = useSearchParams()
+ const pathname = usePathname()
+ const { replace } = useRouter()
// eslint-disable-next-line react/use-state -- custom URL query hook, not React.useState
const {
- query: { category, tagIDs, keywords, isCreatedByMe },
+ query: { category, keywords, isCreatedByMe },
setCategory,
setKeywords,
- setTagIDs,
setIsCreatedByMe,
} = useAppsQueryState()
+ const [tagIDs, setTagIDs] = useState([])
const debouncedKeywords = useDebounce(keywords, { wait: APP_LIST_SEARCH_DEBOUNCE_MS })
const newAppCardRef = useRef(null)
const containerRef = useRef(null)
@@ -71,6 +75,16 @@ const List: FC = ({
enabled: isCurrentWorkspaceEditor,
})
+ useEffect(() => {
+ if (!searchParams.has('tagIDs'))
+ return
+
+ const params = new URLSearchParams(searchParams.toString())
+ params.delete('tagIDs')
+ const query = params.toString()
+ replace(query ? `${pathname}?${query}` : pathname, { scroll: false })
+ }, [pathname, replace, searchParams])
+
const appListQuery = useMemo(() => ({
page: 1,
limit: 30,