Refactor apps query state and debounce search updates

This commit is contained in:
yyh 2026-05-11 17:12:01 +08:00
parent a7efd92200
commit f1bc28ed5f
No known key found for this signature in database
5 changed files with 192 additions and 324 deletions

View File

@ -48,16 +48,20 @@ vi.mock('@/context/app-context', () => ({
}),
}))
const mockSetQuery = vi.fn()
const mockSetKeywords = vi.fn()
const mockSetTagIDs = vi.fn()
const mockSetIsCreatedByMe = vi.fn()
const mockQueryState = {
tagIDs: [] as string[],
keywords: '',
isCreatedByMe: false,
}
vi.mock('../hooks/use-apps-query-state', () => ({
default: () => ({
useAppsQueryState: () => ({
query: mockQueryState,
setQuery: mockSetQuery,
setKeywords: mockSetKeywords,
setTagIDs: mockSetTagIDs,
setIsCreatedByMe: mockSetIsCreatedByMe,
}),
}))
@ -351,7 +355,7 @@ describe('List', () => {
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'test search' } })
expect(mockSetQuery).toHaveBeenCalled()
expect(mockSetKeywords).toHaveBeenCalledWith('test search')
})
it('should handle search clear button click', () => {
@ -364,7 +368,7 @@ describe('List', () => {
if (clearButton)
fireEvent.click(clearButton)
expect(mockSetQuery).toHaveBeenCalled()
expect(mockSetKeywords).toHaveBeenCalledWith('')
})
})
@ -412,7 +416,7 @@ describe('List', () => {
const checkbox = screen.getByTestId('checkbox-undefined')
fireEvent.click(checkbox)
expect(mockSetQuery).toHaveBeenCalled()
expect(mockSetIsCreatedByMe).toHaveBeenCalledWith(true)
})
})

View File

@ -0,0 +1 @@
export const APP_LIST_SEARCH_DEBOUNCE_MS = 500

View File

@ -1,6 +1,7 @@
import { act, waitFor } from '@testing-library/react'
import { renderHookWithNuqs } from '@/test/nuqs-testing'
import useAppsQueryState from '../use-apps-query-state'
import { APP_LIST_SEARCH_DEBOUNCE_MS } from '../../constants'
import { useAppsQueryState } from '../use-apps-query-state'
const renderWithAdapter = (searchParams = '') => {
return renderHookWithNuqs(() => useAppsQueryState(), { searchParams })
@ -11,261 +12,163 @@ describe('useAppsQueryState', () => {
vi.clearAllMocks()
})
describe('Initialization', () => {
it('should expose query and setQuery when initialized', () => {
const { result } = renderWithAdapter()
it('should expose app list query state actions', () => {
const { result } = renderWithAdapter()
expect(result.current.query).toBeDefined()
expect(typeof result.current.setQuery).toBe('function')
expect(result.current.query).toEqual({
tagIDs: [],
creatorIDs: [],
keywords: '',
isCreatedByMe: false,
})
expect(typeof result.current.setKeywords).toBe('function')
expect(typeof result.current.setTagIDs).toBe('function')
expect(typeof result.current.setCreatorIDs).toBe('function')
expect(typeof result.current.setIsCreatedByMe).toBe('function')
})
it('should default to empty filters when search params are missing', () => {
const { result } = renderWithAdapter()
it('should parse app list filters from URL', () => {
const { result } = renderWithAdapter(
'?tagIDs=tag1;tag2&creatorIDs=user-1;user-2&keywords=search+term&isCreatedByMe=true',
)
expect(result.current.query.tagIDs).toBeUndefined()
expect(result.current.query.creatorIDs).toBeUndefined()
expect(result.current.query.keywords).toBeUndefined()
expect(result.current.query.isCreatedByMe).toBe(false)
expect(result.current.query).toEqual({
tagIDs: ['tag1', 'tag2'],
creatorIDs: ['user-1', 'user-2'],
keywords: 'search term',
isCreatedByMe: true,
})
})
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 creatorIDs when URL includes creatorIDs', () => {
const { result } = renderWithAdapter('?creatorIDs=user-1;user-2')
expect(result.current.query.creatorIDs).toEqual(['user-1', 'user-2'])
})
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&creatorIDs=user-1;user-2&keywords=test&isCreatedByMe=true',
)
expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2'])
expect(result.current.query.creatorIDs).toEqual(['user-1', 'user-2'])
expect(result.current.query.keywords).toBe('test')
expect(result.current.query.isCreatedByMe).toBe(true)
})
})
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 creatorIDs when setQuery receives creatorIDs', () => {
const { result } = renderWithAdapter()
act(() => {
result.current.setQuery({ creatorIDs: ['user-1', 'user-2'] })
})
expect(result.current.query.creatorIDs).toEqual(['user-1', 'user-2'])
})
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)
})
})
describe('URL synchronization', () => {
it('should sync keywords to URL when keywords change', async () => {
it('should update keywords state immediately while debouncing URL writes', async () => {
vi.useFakeTimers()
try {
const { result, onUrlUpdate } = renderWithAdapter()
act(() => {
result.current.setQuery({ keywords: 'search' })
result.current.setKeywords('search')
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0]
expect(result.current.query.keywords).toBe('search')
expect(onUrlUpdate).not.toHaveBeenCalled()
await act(async () => {
await vi.advanceTimersByTimeAsync(APP_LIST_SEARCH_DEBOUNCE_MS + 100)
})
expect(onUrlUpdate).toHaveBeenCalled()
const update = onUrlUpdate.mock.calls.at(-1)![0]
expect(update.searchParams.get('keywords')).toBe('search')
expect(update.options.history).toBe('push')
})
expect(update.options.history).toBe('replace')
expect(update.options.shallow).toBe(false)
}
finally {
vi.useRealTimers()
}
})
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 creatorIDs to URL when creatorIDs change', async () => {
const { result, onUrlUpdate } = renderWithAdapter()
act(() => {
result.current.setQuery({ creatorIDs: ['user-1', 'user-2'] })
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.searchParams.get('creatorIDs')).toBe('user-1;user-2')
})
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 () => {
it('should remove keywords from URL when cleared', async () => {
vi.useFakeTimers()
try {
const { result, onUrlUpdate } = renderWithAdapter('?keywords=existing')
act(() => {
result.current.setQuery({ keywords: '' })
result.current.setKeywords('')
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0]
expect(result.current.query.keywords).toBe('')
await act(async () => {
await vi.advanceTimersByTimeAsync(APP_LIST_SEARCH_DEBOUNCE_MS + 100)
})
expect(onUrlUpdate).toHaveBeenCalled()
const update = onUrlUpdate.mock.calls.at(-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 creatorIDs from URL when creatorIDs are empty', async () => {
const { result, onUrlUpdate } = renderWithAdapter('?creatorIDs=user-1;user-2')
act(() => {
result.current.setQuery({ creatorIDs: [] })
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.searchParams.has('creatorIDs')).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)
})
}
finally {
vi.useRealTimers()
}
})
describe('Edge cases', () => {
it('should treat empty tagIDs as empty list when URL param is empty', () => {
const { result } = renderWithAdapter('?tagIDs=')
it('should update tag filter URL state', async () => {
const { result, onUrlUpdate } = renderWithAdapter()
expect(result.current.query.tagIDs).toEqual([])
act(() => {
result.current.setTagIDs(['tag1', 'tag2'])
})
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')
})
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')
})
describe('Integration scenarios', () => {
it('should keep accumulated filters when updates are sequential', () => {
const { result } = renderWithAdapter()
it('should remove tagIDs from URL when empty', async () => {
const { result, onUrlUpdate } = renderWithAdapter('?tagIDs=tag1;tag2')
act(() => {
result.current.setQuery({ keywords: 'first' })
})
act(() => {
result.current.setQuery(prev => ({ ...prev, tagIDs: ['tag1'] }))
})
act(() => {
result.current.setQuery(prev => ({ ...prev, creatorIDs: ['user-1'] }))
})
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.creatorIDs).toEqual(['user-1'])
expect(result.current.query.isCreatedByMe).toBe(true)
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 creator filter URL state', async () => {
const { result, onUrlUpdate } = renderWithAdapter()
act(() => {
result.current.setCreatorIDs(['user-1', 'user-2'])
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls.at(-1)![0]
expect(result.current.query.creatorIDs).toEqual(['user-1', 'user-2'])
expect(update.searchParams.get('creatorIDs')).toBe('user-1;user-2')
expect(update.options.history).toBe('push')
})
it('should remove creatorIDs from URL when empty', async () => {
const { result, onUrlUpdate } = renderWithAdapter('?creatorIDs=user-1;user-2')
act(() => {
result.current.setCreatorIDs([])
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls.at(-1)![0]
expect(result.current.query.creatorIDs).toEqual([])
expect(update.searchParams.has('creatorIDs')).toBe(false)
})
it('should update created-by-me URL state', async () => {
const { result, onUrlUpdate } = renderWithAdapter()
act(() => {
result.current.setIsCreatedByMe(true)
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls.at(-1)![0]
expect(result.current.query.isCreatedByMe).toBe(true)
expect(update.searchParams.get('isCreatedByMe')).toBe('true')
expect(update.options.history).toBe('push')
})
it('should remove isCreatedByMe from URL when disabled', async () => {
const { result, onUrlUpdate } = renderWithAdapter('?isCreatedByMe=true')
act(() => {
result.current.setIsCreatedByMe(false)
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls.at(-1)![0]
expect(result.current.query.isCreatedByMe).toBe(false)
expect(update.searchParams.has('isCreatedByMe')).toBe(false)
})
})

View File

@ -1,63 +1,44 @@
import { parseAsArrayOf, parseAsBoolean, parseAsString, useQueryStates } from 'nuqs'
import type { inferParserType } from 'nuqs'
import { debounce, parseAsArrayOf, parseAsBoolean, parseAsString, useQueryStates } from 'nuqs'
import { useCallback, useMemo } from 'react'
import { APP_LIST_SEARCH_DEBOUNCE_MS } from '../constants'
type AppsQuery = {
tagIDs?: string[]
creatorIDs?: string[]
keywords?: string
isCreatedByMe?: boolean
const appListQueryParsers = {
tagIDs: parseAsArrayOf(parseAsString, ';').withDefault([]),
creatorIDs: parseAsArrayOf(parseAsString, ';').withDefault([]),
keywords: parseAsString.withDefault('').withOptions({
shallow: false,
limitUrlUpdates: debounce(APP_LIST_SEARCH_DEBOUNCE_MS),
}),
isCreatedByMe: parseAsBoolean.withDefault(false),
}
const normalizeKeywords = (value: string | null) => value || undefined
export type AppsQuery = inferParserType<typeof appListQueryParsers>
function useAppsQueryState() {
const [urlQuery, setUrlQuery] = useQueryStates(
{
tagIDs: parseAsArrayOf(parseAsString, ';'),
creatorIDs: parseAsArrayOf(parseAsString, ';'),
keywords: parseAsString,
isCreatedByMe: parseAsBoolean,
},
{
history: 'push',
},
)
export function useAppsQueryState() {
const [query, setQuery] = useQueryStates(appListQueryParsers)
const query = useMemo<AppsQuery>(() => ({
tagIDs: urlQuery.tagIDs ?? undefined,
creatorIDs: urlQuery.creatorIDs ?? undefined,
keywords: normalizeKeywords(urlQuery.keywords),
isCreatedByMe: urlQuery.isCreatedByMe ?? false,
}), [urlQuery.creatorIDs, urlQuery.isCreatedByMe, urlQuery.keywords, urlQuery.tagIDs])
const setKeywords = useCallback((keywords: string) => {
setQuery({ keywords })
}, [setQuery])
const setQuery = useCallback((next: AppsQuery | ((prev: AppsQuery) => AppsQuery)) => {
const buildPatch = (patch: AppsQuery) => {
const result: Partial<typeof urlQuery> = {}
if ('tagIDs' in patch)
result.tagIDs = patch.tagIDs && patch.tagIDs.length > 0 ? patch.tagIDs : null
if ('creatorIDs' in patch)
result.creatorIDs = patch.creatorIDs && patch.creatorIDs.length > 0 ? patch.creatorIDs : null
if ('keywords' in patch)
result.keywords = patch.keywords ? patch.keywords : null
if ('isCreatedByMe' in patch)
result.isCreatedByMe = patch.isCreatedByMe ? true : null
return result
}
const setTagIDs = useCallback((tagIDs: string[]) => {
setQuery({ tagIDs }, { history: 'push' })
}, [setQuery])
if (typeof next === 'function') {
setUrlQuery(prev => buildPatch(next({
tagIDs: prev.tagIDs ?? undefined,
creatorIDs: prev.creatorIDs ?? undefined,
keywords: normalizeKeywords(prev.keywords),
isCreatedByMe: prev.isCreatedByMe ?? false,
})))
return
}
const setCreatorIDs = useCallback((creatorIDs: string[]) => {
setQuery({ creatorIDs }, { history: 'push' })
}, [setQuery])
setUrlQuery(buildPatch(next))
}, [setUrlQuery])
const setIsCreatedByMe = useCallback((isCreatedByMe: boolean) => {
setQuery({ isCreatedByMe }, { history: 'push' })
}, [setQuery])
return useMemo(() => ({ query, setQuery }), [query, setQuery])
return useMemo(() => ({
query,
setKeywords,
setTagIDs,
setCreatorIDs,
setIsCreatedByMe,
}), [query, setKeywords, setTagIDs, setCreatorIDs, setIsCreatedByMe])
}
export default useAppsQueryState

View File

@ -4,7 +4,7 @@ import type { FC } from 'react'
import type { AppListQuery } from '@/contract/console/apps'
import { cn } from '@langgenius/dify-ui/cn'
import { keepPreviousData, useInfiniteQuery, useSuspenseQuery } from '@tanstack/react-query'
import { useDebounceFn } from 'ahooks'
import { useDebounce } from 'ahooks'
import { parseAsStringLiteral, useQueryState } from 'nuqs'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -21,9 +21,10 @@ import { systemFeaturesQueryOptions } from '@/service/system-features'
import { AppModeEnum, AppModes } from '@/types/app'
import AppCard from './app-card'
import { AppCardSkeleton } from './app-card-skeleton'
import { APP_LIST_SEARCH_DEBOUNCE_MS } from './constants'
import Empty from './empty'
import Footer from './footer'
import useAppsQueryStateHook from './hooks/use-apps-query-state'
import { useAppsQueryState } from './hooks/use-apps-query-state'
import { useDSLDragDrop } from './hooks/use-dsl-drag-drop'
import { useWorkflowOnlineUsers } from './hooks/use-workflow-online-users'
import NewAppCard from './new-app-card'
@ -62,22 +63,18 @@ const List: FC<Props> = ({
)
// eslint-disable-next-line react/use-state -- custom URL query hook, not React.useState
const appsQuery = useAppsQueryStateHook()
const { query: { tagIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false }, setQuery } = appsQuery
const [isCreatedByMe, setIsCreatedByMe] = useState(queryIsCreatedByMe)
const [tagFilterValue, setTagFilterValue] = useState<string[]>(tagIDs)
const [searchKeywords, setSearchKeywords] = useState(keywords)
const {
query: { tagIDs, keywords, isCreatedByMe },
setKeywords,
setTagIDs,
setIsCreatedByMe,
} = useAppsQueryState()
const debouncedKeywords = useDebounce(keywords, { wait: APP_LIST_SEARCH_DEBOUNCE_MS })
const newAppCardRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [showTagManagementModal, setShowTagManagementModal] = useState(false)
const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false)
const [droppedDSLFile, setDroppedDSLFile] = useState<File | undefined>()
const setKeywords = useCallback((keywords: string) => {
setQuery(prev => ({ ...prev, keywords }))
}, [setQuery])
const setTagIDs = useCallback((tagIDs: string[]) => {
setQuery(prev => ({ ...prev, tagIDs }))
}, [setQuery])
const handleDSLFileDropped = useCallback((file: File) => {
setDroppedDSLFile(file)
@ -93,11 +90,11 @@ const List: FC<Props> = ({
const appListQuery = useMemo<AppListQuery>(() => ({
page: 1,
limit: 30,
name: searchKeywords,
name: debouncedKeywords,
...(tagIDs.length ? { tag_ids: tagIDs } : {}),
...(isCreatedByMe ? { is_created_by_me: isCreatedByMe } : {}),
...(activeTab !== 'all' ? { mode: activeTab } : {}),
}), [activeTab, isCreatedByMe, searchKeywords, tagIDs])
}), [activeTab, debouncedKeywords, isCreatedByMe, tagIDs])
const {
data,
@ -177,27 +174,9 @@ const List: FC<Props> = ({
return () => observer?.disconnect()
}, [isLoading, isFetchingNextPage, fetchNextPage, error, hasNextPage, isCurrentWorkspaceDatasetOperator])
const { run: handleSearch } = useDebounceFn(() => {
setSearchKeywords(keywords)
}, { wait: 500 })
const handleKeywordsChange = (value: string) => {
setKeywords(value)
handleSearch()
}
const { run: handleTagsUpdate } = useDebounceFn(() => {
setTagIDs(tagFilterValue)
}, { wait: 500 })
const handleTagsChange = (value: string[]) => {
setTagFilterValue(value)
handleTagsUpdate()
}
const handleCreatedByMeChange = useCallback(() => {
const newValue = !isCreatedByMe
setIsCreatedByMe(newValue)
setQuery(prev => ({ ...prev, isCreatedByMe: newValue }))
}, [isCreatedByMe, setQuery])
setIsCreatedByMe(!isCreatedByMe)
}, [isCreatedByMe, setIsCreatedByMe])
const pages = useMemo(() => data?.pages ?? [], [data?.pages])
const apps = useMemo(() => pages.flatMap(({ data: pageApps }) => pageApps), [pages])
@ -246,14 +225,14 @@ const List: FC<Props> = ({
{t('showMyCreatedAppsOnly', { ns: 'app' })}
</div>
</label>
<TagFilter type="app" value={tagFilterValue} onChange={handleTagsChange} onOpenTagManagement={() => setShowTagManagementModal(true)} />
<TagFilter type="app" value={tagIDs} onChange={setTagIDs} onOpenTagManagement={() => setShowTagManagementModal(true)} />
<Input
showLeftIcon
showClearIcon
wrapperClassName="w-[200px]"
value={keywords}
onChange={e => handleKeywordsChange(e.target.value)}
onClear={() => handleKeywordsChange('')}
onChange={e => setKeywords(e.target.value)}
onClear={() => setKeywords('')}
/>
</div>
</div>