From f1bc28ed5fc1d0a467e76bcfe4f44f2647e5391b Mon Sep 17 00:00:00 2001 From: yyh Date: Mon, 11 May 2026 17:12:01 +0800 Subject: [PATCH] Refactor apps query state and debounce search updates --- .../components/apps/__tests__/list.spec.tsx | 16 +- web/app/components/apps/constants.ts | 1 + .../__tests__/use-apps-query-state.spec.tsx | 359 +++++++----------- .../apps/hooks/use-apps-query-state.ts | 85 ++--- web/app/components/apps/list.tsx | 55 +-- 5 files changed, 192 insertions(+), 324 deletions(-) create mode 100644 web/app/components/apps/constants.ts diff --git a/web/app/components/apps/__tests__/list.spec.tsx b/web/app/components/apps/__tests__/list.spec.tsx index 41d2ccbc80..012a9408b6 100644 --- a/web/app/components/apps/__tests__/list.spec.tsx +++ b/web/app/components/apps/__tests__/list.spec.tsx @@ -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) }) }) diff --git a/web/app/components/apps/constants.ts b/web/app/components/apps/constants.ts new file mode 100644 index 0000000000..95c3dcff42 --- /dev/null +++ b/web/app/components/apps/constants.ts @@ -0,0 +1 @@ +export const APP_LIST_SEARCH_DEBOUNCE_MS = 500 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 d5734cce07..036466d37f 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 @@ -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) }) }) 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 50ae13a425..ff2657e302 100644 --- a/web/app/components/apps/hooks/use-apps-query-state.ts +++ b/web/app/components/apps/hooks/use-apps-query-state.ts @@ -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 -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(() => ({ - 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 = {} - 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 diff --git a/web/app/components/apps/list.tsx b/web/app/components/apps/list.tsx index 0fd31dfb79..26ea725d14 100644 --- a/web/app/components/apps/list.tsx +++ b/web/app/components/apps/list.tsx @@ -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 = ({ ) // 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(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(null) const containerRef = useRef(null) const [showTagManagementModal, setShowTagManagementModal] = useState(false) const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false) const [droppedDSLFile, setDroppedDSLFile] = useState() - 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 = ({ const appListQuery = useMemo(() => ({ 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 = ({ 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 = ({ {t('showMyCreatedAppsOnly', { ns: 'app' })} - setShowTagManagementModal(true)} /> + setShowTagManagementModal(true)} /> handleKeywordsChange(e.target.value)} - onClear={() => handleKeywordsChange('')} + onChange={e => setKeywords(e.target.value)} + onClear={() => setKeywords('')} />