diff --git a/.agents/skills/how-to-write-component/SKILL.md b/.agents/skills/how-to-write-component/SKILL.md index f33a9dd75e..ac77112993 100644 --- a/.agents/skills/how-to-write-component/SKILL.md +++ b/.agents/skills/how-to-write-component/SKILL.md @@ -55,9 +55,17 @@ Use this as the decision guide for React/TypeScript component structure. Existin - Avoid unnecessary DOM hierarchy. Do not add wrapper elements unless they provide layout, semantics, accessibility, state ownership, or integration with a library API; prefer fragments or styling an existing element when possible. - Avoid shallow wrappers and prop renaming unless the wrapper adds validation, orchestration, error handling, state ownership, or a real semantic boundary. -## Navigation, Effects, And Performance +## You Might Not Need An Effect + +- Use Effects only to synchronize with external systems such as browser APIs, non-React widgets, subscriptions, timers, analytics that must run because the component was shown, or imperative DOM integration. +- Do not use Effects to transform props or state for rendering. Calculate derived values during render, and use `useMemo` only when the calculation is actually expensive. +- Do not use Effects to handle user actions. Put action-specific logic in the event handler where the cause is known. +- Do not use Effects to copy one state value into another state value representing the same concept. Pick one source of truth and derive the rest during render. +- Do not reset or adjust state from props with an Effect. Prefer a `key` reset, storing a stable ID and deriving the selected object, or guarded same-component render-time adjustment when truly necessary. +- Prefer framework data APIs or TanStack Query for data fetching instead of writing request Effects in components. +- If an Effect still seems necessary, first name the external system it synchronizes with. If there is no external system, remove the Effect and restructure the state or event flow. + +## Navigation And Performance - Prefer `Link` for normal navigation. Use router APIs only for command-flow side effects such as mutation success, guarded redirects, or form submission. -- Treat `useEffect` as a last resort. First try deriving values during render, moving event-driven work into handlers, or using existing hooks/APIs for persistence, subscriptions, media queries, timers, and DOM sync. -- Do not use `useEffect` directly in components. If unavoidable, encapsulate it in a purpose-built hook so the component consumes a declarative API. - Avoid `memo`, `useMemo`, and `useCallback` unless there is a clear performance reason. diff --git a/web/app/components/apps/__tests__/list.spec.tsx b/web/app/components/apps/__tests__/list.spec.tsx index 41d2ccbc80..0c6f1702d7 100644 --- a/web/app/components/apps/__tests__/list.spec.tsx +++ b/web/app/components/apps/__tests__/list.spec.tsx @@ -48,16 +48,24 @@ vi.mock('@/context/app-context', () => ({ }), })) -const mockSetQuery = vi.fn() +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, } vi.mock('../hooks/use-apps-query-state', () => ({ - default: () => ({ + isAppListCategory: (value: string) => value === 'all' || Object.values(AppModeEnum).includes(value as AppModeEnum), + useAppsQueryState: () => ({ query: mockQueryState, - setQuery: mockSetQuery, + setCategory: mockSetCategory, + setKeywords: mockSetKeywords, + setTagIDs: mockSetTagIDs, + setIsCreatedByMe: mockSetIsCreatedByMe, }), })) @@ -244,6 +252,7 @@ describe('List', () => { mockServiceState.hasNextPage = false mockServiceState.isLoading = false mockServiceState.isFetchingNextPage = false + mockQueryState.category = 'all' mockQueryState.tagIDs = [] mockQueryState.keywords = '' mockQueryState.isCreatedByMe = false @@ -317,25 +326,21 @@ describe('List', () => { }) describe('Tab Navigation', () => { - it('should update URL when workflow tab is clicked', async () => { - const { onUrlUpdate } = renderList() + it('should update category when workflow tab is clicked', () => { + renderList() fireEvent.click(screen.getByText('app.types.workflow')) - await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) - const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0] - expect(lastCall.searchParams.get('category')).toBe(AppModeEnum.WORKFLOW) + expect(mockSetCategory).toHaveBeenCalledWith(AppModeEnum.WORKFLOW) }) - it('should update URL when all tab is clicked', async () => { - const { onUrlUpdate } = renderList('?category=workflow') + it('should update category when all tab is clicked', () => { + mockQueryState.category = AppModeEnum.WORKFLOW + renderList() fireEvent.click(screen.getByText('app.types.all')) - await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) - const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0] - // nuqs removes the default value ('all') from URL params - expect(lastCall.searchParams.has('category')).toBe(false) + expect(mockSetCategory).toHaveBeenCalledWith('all') }) }) @@ -351,7 +356,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 +369,7 @@ describe('List', () => { if (clearButton) fireEvent.click(clearButton) - expect(mockSetQuery).toHaveBeenCalled() + expect(mockSetKeywords).toHaveBeenCalledWith('') }) }) @@ -373,8 +378,9 @@ describe('List', () => { mockQueryState.tagIDs = ['tag-1'] mockQueryState.keywords = 'sales' mockQueryState.isCreatedByMe = true + mockQueryState.category = AppModeEnum.WORKFLOW - renderList('?category=workflow') + renderList() const options = mockAppListInfiniteOptions.mock.calls.at(-1)?.[0] as AppListInfiniteOptions @@ -412,7 +418,7 @@ describe('List', () => { const checkbox = screen.getByTestId('checkbox-undefined') fireEvent.click(checkbox) - expect(mockSetQuery).toHaveBeenCalled() + expect(mockSetIsCreatedByMe).toHaveBeenCalledWith(true) }) }) @@ -506,8 +512,8 @@ describe('List', () => { expect(screen.getByText('app.types.completion'))!.toBeInTheDocument() }) - it('should update URL for each app type tab click', async () => { - const { onUrlUpdate } = renderList() + it('should update category for each app type tab click', () => { + renderList() const appTypeTexts = [ { mode: AppModeEnum.WORKFLOW, text: 'app.types.workflow' }, @@ -518,11 +524,9 @@ describe('List', () => { ] for (const { mode, text } of appTypeTexts) { - onUrlUpdate.mockClear() + mockSetCategory.mockClear() fireEvent.click(screen.getByText(text)) - await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) - const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0] - expect(lastCall.searchParams.get('category')).toBe(mode) + expect(mockSetCategory).toHaveBeenCalledWith(mode) } }) }) 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 4b0c63f580..782f6ec353 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,8 @@ import { act, waitFor } from '@testing-library/react' import { renderHookWithNuqs } from '@/test/nuqs-testing' -import useAppsQueryState from '../use-apps-query-state' +import { AppModeEnum } from '@/types/app' +import { APP_LIST_SEARCH_DEBOUNCE_MS } from '../../constants' +import { useAppsQueryState } from '../use-apps-query-state' const renderWithAdapter = (searchParams = '') => { return renderHookWithNuqs(() => useAppsQueryState(), { searchParams }) @@ -11,214 +13,161 @@ 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({ + 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') + }) - 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( + '?category=workflow&tagIDs=tag1;tag2&keywords=search+term&isCreatedByMe=true', + ) - expect(result.current.query.tagIDs).toBeUndefined() - expect(result.current.query.keywords).toBeUndefined() - expect(result.current.query.isCreatedByMe).toBe(false) + expect(result.current.query).toEqual({ + category: AppModeEnum.WORKFLOW, + tagIDs: ['tag1', 'tag2'], + keywords: 'search term', + isCreatedByMe: true, }) }) - describe('Parsing search params', () => { - it('should parse tagIDs when URL includes tagIDs', () => { - const { result } = renderWithAdapter('?tagIDs=tag1;tag2;tag3') + it('should update category URL state', async () => { + const { result, onUrlUpdate } = renderWithAdapter() - expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2', 'tag3']) + act(() => { + result.current.setCategory(AppModeEnum.WORKFLOW) }) - 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) - }) + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) + const update = onUrlUpdate.mock.calls.at(-1)![0] + expect(result.current.query.category).toBe(AppModeEnum.WORKFLOW) + expect(update.searchParams.get('category')).toBe(AppModeEnum.WORKFLOW) + expect(update.options.history).toBe('push') }) - describe('Updating query state', () => { - it('should update keywords when setQuery receives keywords', () => { - const { result } = renderWithAdapter() + it('should remove category from URL when set to all', async () => { + const { result, onUrlUpdate } = renderWithAdapter('?category=workflow') - act(() => { - result.current.setQuery({ keywords: 'new search' }) - }) - - expect(result.current.query.keywords).toBe('new search') + act(() => { + result.current.setCategory('all') }) - 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) - }) + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) + const update = onUrlUpdate.mock.calls.at(-1)![0] + expect(result.current.query.category).toBe('all') + expect(update.searchParams.has('category')).toBe(false) }) - 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') - }) + } + 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 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 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, isCreatedByMe: true })) - }) - - expect(result.current.query.keywords).toBe('first') - expect(result.current.query.tagIDs).toEqual(['tag1']) - 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 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 ecf7707e8a..a0109eb061 100644 --- a/web/app/components/apps/hooks/use-apps-query-state.ts +++ b/web/app/components/apps/hooks/use-apps-query-state.ts @@ -1,57 +1,56 @@ -import { parseAsArrayOf, parseAsBoolean, parseAsString, useQueryStates } from 'nuqs' +import { debounce, parseAsArrayOf, parseAsBoolean, parseAsString, parseAsStringLiteral, useQueryStates } from 'nuqs' import { useCallback, useMemo } from 'react' +import { AppModes } from '@/types/app' +import { APP_LIST_SEARCH_DEBOUNCE_MS } from '../constants' -type AppsQuery = { - tagIDs?: string[] - keywords?: string - isCreatedByMe?: boolean +const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const +export type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number] + +const appListCategorySet = new Set(APP_LIST_CATEGORY_VALUES) + +export const isAppListCategory = (value: string): value is AppListCategory => { + return appListCategorySet.has(value) } -const normalizeKeywords = (value: string | null) => value || undefined - -function useAppsQueryState() { - const [urlQuery, setUrlQuery] = useQueryStates( - { - tagIDs: parseAsArrayOf(parseAsString, ';'), - keywords: parseAsString, - isCreatedByMe: parseAsBoolean, - }, - { - history: 'push', - }, - ) - - const query = useMemo(() => ({ - tagIDs: urlQuery.tagIDs ?? undefined, - keywords: normalizeKeywords(urlQuery.keywords), - isCreatedByMe: urlQuery.isCreatedByMe ?? false, - }), [urlQuery.isCreatedByMe, urlQuery.keywords, urlQuery.tagIDs]) - - 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 ('keywords' in patch) - result.keywords = patch.keywords ? patch.keywords : null - if ('isCreatedByMe' in patch) - result.isCreatedByMe = patch.isCreatedByMe ? true : null - return result - } - - 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]) +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), + }), + isCreatedByMe: parseAsBoolean + .withDefault(false) + .withOptions({ history: 'push' }), } -export default useAppsQueryState +export function useAppsQueryState() { + const [query, setQuery] = useQueryStates(appListQueryParsers) + + const setCategory = useCallback((category: AppListCategory) => { + setQuery({ category }) + }, [setQuery]) + + const setKeywords = useCallback((keywords: string) => { + setQuery({ keywords }) + }, [setQuery]) + + const setTagIDs = useCallback((tagIDs: string[]) => { + setQuery({ tagIDs }) + }, [setQuery]) + + const setIsCreatedByMe = useCallback((isCreatedByMe: boolean) => { + setQuery({ isCreatedByMe }) + }, [setQuery]) + + return useMemo(() => ({ + query, + setCategory, + setKeywords, + setTagIDs, + setIsCreatedByMe, + }), [query, setCategory, setKeywords, setTagIDs, setIsCreatedByMe]) +} diff --git a/web/app/components/apps/list.tsx b/web/app/components/apps/list.tsx index 0fd31dfb79..e2e8e737fc 100644 --- a/web/app/components/apps/list.tsx +++ b/web/app/components/apps/list.tsx @@ -4,8 +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 { parseAsStringLiteral, useQueryState } from 'nuqs' +import { useDebounce } from 'ahooks' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import Checkbox from '@/app/components/base/checkbox' @@ -18,12 +17,13 @@ import { CheckModal } from '@/hooks/use-pay' import dynamic from '@/next/dynamic' import { consoleQuery } from '@/service/client' import { systemFeaturesQueryOptions } from '@/service/system-features' -import { AppModeEnum, AppModes } from '@/types/app' +import { AppModeEnum } 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 { isAppListCategory, 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' @@ -35,18 +35,6 @@ const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-fro ssr: false, }) -const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const -type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number] -const appListCategorySet = new Set(APP_LIST_CATEGORY_VALUES) - -const isAppListCategory = (value: string): value is AppListCategory => { - return appListCategorySet.has(value) -} - -const parseAsAppListCategory = parseAsStringLiteral(APP_LIST_CATEGORY_VALUES) - .withDefault('all') - .withOptions({ history: 'push' }) - type Props = { controlRefreshList?: number } @@ -56,28 +44,21 @@ const List: FC = ({ const { t } = useTranslation() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext() - const [activeTab, setActiveTab] = useQueryState( - 'category', - parseAsAppListCategory, - ) // 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: { category, tagIDs, keywords, isCreatedByMe }, + setCategory, + 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 +74,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]) + ...(category !== 'all' ? { mode: category } : {}), + }), [category, debouncedKeywords, isCreatedByMe, tagIDs]) const { data, @@ -177,27 +158,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]) @@ -232,10 +195,10 @@ const List: FC = ({
{ if (isAppListCategory(nextValue)) - setActiveTab(nextValue) + setCategory(nextValue) }} options={options} /> @@ -246,14 +209,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('')} /> @@ -267,7 +230,7 @@ const List: FC = ({ ref={newAppCardRef} isLoading={isLoadingCurrentWorkspace} onSuccess={refetch} - selectedAppType={activeTab} + selectedAppType={category} className={cn(!hasAnyApp && 'z-10')} /> )}