diff --git a/web/app/components/apps/__tests__/list.spec.tsx b/web/app/components/apps/__tests__/list.spec.tsx index b2f0125e90..acc2abb1ff 100644 --- a/web/app/components/apps/__tests__/list.spec.tsx +++ b/web/app/components/apps/__tests__/list.spec.tsx @@ -59,6 +59,7 @@ vi.mock('../hooks/use-dsl-drag-drop', () => ({ const mockRefetch = vi.fn() const mockFetchNextPage = vi.fn() +const mockFetchSnippetNextPage = vi.fn() const mockServiceState = { error: null as Error | null, @@ -119,6 +120,57 @@ vi.mock('@/service/use-apps', () => ({ }), })) +const mockSnippetServiceState = { + error: null as Error | null, + hasNextPage: false, + isLoading: false, + isFetching: false, + isFetchingNextPage: false, +} + +const defaultSnippetData = { + pages: [{ + data: [ + { + id: 'snippet-1', + name: 'Tone Rewriter', + description: 'Rewrites rough drafts into a concise, professional tone for internal stakeholder updates.', + author: '', + updatedAt: '2024-01-02 10:00', + usage: '19', + icon: '🪄', + iconBackground: '#E0EAFF', + status: undefined, + }, + ], + total: 1, + }], +} + +vi.mock('@/service/use-snippets', () => ({ + useInfiniteSnippetList: () => ({ + data: defaultSnippetData, + isLoading: mockSnippetServiceState.isLoading, + isFetching: mockSnippetServiceState.isFetching, + isFetchingNextPage: mockSnippetServiceState.isFetchingNextPage, + fetchNextPage: mockFetchSnippetNextPage, + hasNextPage: mockSnippetServiceState.hasNextPage, + error: mockSnippetServiceState.error, + }), + useCreateSnippetMutation: () => ({ + mutate: vi.fn(), + isPending: false, + }), + useImportSnippetDSLMutation: () => ({ + mutate: vi.fn(), + isPending: false, + }), + useConfirmSnippetImportMutation: () => ({ + mutate: vi.fn(), + isPending: false, + }), +})) + vi.mock('@/service/tag', () => ({ fetchTagList: vi.fn().mockResolvedValue([{ id: 'tag-1', name: 'Test Tag', type: 'app' }]), })) @@ -227,6 +279,11 @@ describe('List', () => { mockQueryState.tagIDs = [] mockQueryState.keywords = '' mockQueryState.isCreatedByMe = false + mockSnippetServiceState.error = null + mockSnippetServiceState.hasNextPage = false + mockSnippetServiceState.isLoading = false + mockSnippetServiceState.isFetching = false + mockSnippetServiceState.isFetchingNextPage = false intersectionCallback = null localStorage.clear() }) @@ -282,7 +339,7 @@ describe('List', () => { }) describe('Snippets Mode', () => { - it('should render the snippets create card and fake snippet card', () => { + it('should render the snippets create card and snippet card from the real query hook', () => { renderList({ pageType: 'snippets' }) expect(screen.getByText('snippet.create')).toBeInTheDocument() @@ -293,14 +350,15 @@ describe('List', () => { expect(screen.queryByTestId('app-card-app-1')).not.toBeInTheDocument() }) - it('should filter local snippets by the search input and show the snippet empty state', () => { + it('should request the next snippet page when the infinite-scroll anchor intersects', () => { + mockSnippetServiceState.hasNextPage = true renderList({ pageType: 'snippets' }) - const input = screen.getByRole('textbox') - fireEvent.change(input, { target: { value: 'missing snippet' } }) + act(() => { + intersectionCallback?.([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver) + }) - expect(screen.queryByText('Tone Rewriter')).not.toBeInTheDocument() - expect(screen.getByText('workflow.tabs.noSnippetsFound')).toBeInTheDocument() + expect(mockFetchSnippetNextPage).toHaveBeenCalled() }) it('should not render app-only controls in snippets mode', () => { @@ -311,14 +369,14 @@ describe('List', () => { expect(screen.queryByText('app.newApp.dropDSLToCreateApp')).not.toBeInTheDocument() }) - it('should reserve the infinite-scroll anchor without fetching more pages', () => { + it('should not fetch the next snippet page when no more data is available', () => { renderList({ pageType: 'snippets' }) act(() => { intersectionCallback?.([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver) }) - expect(mockFetchNextPage).not.toHaveBeenCalled() + expect(mockFetchSnippetNextPage).not.toHaveBeenCalled() }) }) }) diff --git a/web/app/components/apps/list.tsx b/web/app/components/apps/list.tsx index 92d024c5d0..736a7b4a3d 100644 --- a/web/app/components/apps/list.tsx +++ b/web/app/components/apps/list.tsx @@ -16,7 +16,7 @@ import { useGlobalPublicStore } from '@/context/global-public-context' import { CheckModal } from '@/hooks/use-pay' import dynamic from '@/next/dynamic' import { useInfiniteAppList } from '@/service/use-apps' -import { getSnippetListMock } from '@/service/use-snippets.mock' +import { useInfiniteSnippetList } from '@/service/use-snippets' import { cn } from '@/utils/classnames' import SnippetCard from '../snippets/components/snippet-card' import SnippetCreateCard from '../snippets/components/snippet-create-card' @@ -61,6 +61,7 @@ const List: FC = ({ const { query: { tagIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false }, setQuery } = useAppsQueryState() const [tagFilterValue, setTagFilterValue] = useState(tagIDs) const [appKeywords, setAppKeywords] = useState(keywords) + const [snippetKeywordsInput, setSnippetKeywordsInput] = useState('') const [snippetKeywords, setSnippetKeywords] = useState('') const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false) const [droppedDSLFile, setDroppedDSLFile] = useState() @@ -109,6 +110,22 @@ const List: FC = ({ enabled: isAppsPage && !isCurrentWorkspaceDatasetOperator, }) + const { + data: snippetData, + isLoading: isSnippetListLoading, + isFetching: isSnippetListFetching, + isFetchingNextPage: isSnippetListFetchingNextPage, + fetchNextPage: fetchSnippetNextPage, + hasNextPage: hasSnippetNextPage, + error: snippetError, + } = useInfiniteSnippetList({ + page: 1, + limit: 30, + keyword: snippetKeywords || undefined, + }, { + enabled: !isAppsPage, + }) + useEffect(() => { if (isAppsPage && controlRefreshList > 0) refetch() @@ -128,10 +145,13 @@ const List: FC = ({ if (isCurrentWorkspaceDatasetOperator) return - const hasMore = isAppsPage ? (hasNextPage ?? true) : false + const hasMore = isAppsPage ? (hasNextPage ?? true) : (hasSnippetNextPage ?? true) + const isPageLoading = isAppsPage ? isLoading : isSnippetListLoading + const isNextPageFetching = isAppsPage ? isFetchingNextPage : isSnippetListFetchingNextPage + const currentError = isAppsPage ? error : snippetError let observer: IntersectionObserver | undefined - if (error) { + if (currentError) { observer?.disconnect() return } @@ -141,8 +161,12 @@ const List: FC = ({ const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200)) observer = new IntersectionObserver((entries) => { - if (entries[0].isIntersecting && !isLoading && !isFetchingNextPage && !error && hasMore) - fetchNextPage() + if (entries[0].isIntersecting && !isPageLoading && !isNextPageFetching && !currentError && hasMore) { + if (isAppsPage) + fetchNextPage() + else + fetchSnippetNextPage() + } }, { root: containerRef.current, rootMargin: `${dynamicMargin}px`, @@ -152,12 +176,16 @@ const List: FC = ({ } return () => observer?.disconnect() - }, [error, fetchNextPage, hasNextPage, isAppsPage, isCurrentWorkspaceDatasetOperator, isFetchingNextPage, isLoading]) + }, [error, fetchNextPage, fetchSnippetNextPage, hasNextPage, hasSnippetNextPage, isAppsPage, isCurrentWorkspaceDatasetOperator, isFetchingNextPage, isLoading, isSnippetListFetchingNextPage, isSnippetListLoading, snippetError]) const { run: handleAppSearch } = useDebounceFn((value: string) => { setAppKeywords(value) }, { wait: 500 }) + const { run: handleSnippetSearch } = useDebounceFn((value: string) => { + setSnippetKeywords(value) + }, { wait: 500 }) + const handleKeywordsChange = useCallback((value: string) => { if (isAppsPage) { setKeywords(value) @@ -165,8 +193,9 @@ const List: FC = ({ return } - setSnippetKeywords(value) - }, [handleAppSearch, isAppsPage, setKeywords]) + setSnippetKeywordsInput(value) + handleSnippetSearch(value) + }, [handleAppSearch, handleSnippetSearch, isAppsPage, setKeywords]) const { run: handleTagsUpdate } = useDebounceFn((value: string[]) => { setTagIDs(value) @@ -181,23 +210,16 @@ const List: FC = ({ return (data?.pages ?? []).flatMap(({ data: apps }) => apps) }, [data?.pages]) - const snippetItems = useMemo(() => getSnippetListMock(), []) + const snippetItems = useMemo(() => { + return (snippetData?.pages ?? []).flatMap(({ data }) => data) + }, [snippetData?.pages]) - const filteredSnippetItems = useMemo(() => { - const normalizedKeywords = snippetKeywords.trim().toLowerCase() - if (!normalizedKeywords) - return snippetItems - - return snippetItems.filter(item => - item.name.toLowerCase().includes(normalizedKeywords) - || item.description.toLowerCase().includes(normalizedKeywords), - ) - }, [snippetItems, snippetKeywords]) - - const showSkeleton = isAppsPage && (isLoading || (isFetching && data?.pages?.length === 0)) + const showSkeleton = isAppsPage + ? (isLoading || (isFetching && data?.pages?.length === 0)) + : (isSnippetListLoading || (isSnippetListFetching && snippetItems.length === 0)) const hasAnyApp = (data?.pages?.[0]?.total ?? 0) > 0 - const hasAnySnippet = filteredSnippetItems.length > 0 - const currentKeywords = isAppsPage ? keywords : snippetKeywords + const hasAnySnippet = snippetItems.length > 0 + const currentKeywords = isAppsPage ? keywords : snippetKeywordsInput return ( <> @@ -265,7 +287,7 @@ const List: FC = ({ ))} - {!showSkeleton && !isAppsPage && hasAnySnippet && filteredSnippetItems.map(snippet => ( + {!showSkeleton && !isAppsPage && hasAnySnippet && snippetItems.map(snippet => ( ))} @@ -280,6 +302,10 @@ const List: FC = ({ {isAppsPage && isFetchingNextPage && ( )} + + {!isAppsPage && isSnippetListFetchingNextPage && ( + + )} {isAppsPage && isCurrentWorkspaceEditor && ( diff --git a/web/app/components/snippets/components/snippet-card.tsx b/web/app/components/snippets/components/snippet-card.tsx index 3b59c09e6c..a55fc5ec05 100644 --- a/web/app/components/snippets/components/snippet-card.tsx +++ b/web/app/components/snippets/components/snippet-card.tsx @@ -1,6 +1,7 @@ 'use client' -import type { SnippetListItem } from '@/models/snippet' +import type { SnippetListItem } from '@/types/snippet' +import AppIcon from '@/app/components/base/app-icon' import Link from '@/next/link' type Props = { @@ -11,15 +12,19 @@ const SnippetCard = ({ snippet }: Props) => { return (
- {snippet.status && ( + {!snippet.is_published && (
- {snippet.status} + Draft
)}
-
- {snippet.icon} -
+
{snippet.name} @@ -34,9 +39,13 @@ const SnippetCard = ({ snippet }: Props) => {
{snippet.author} · - {snippet.updatedAt} - · - {snippet.usage} + {snippet.updated_at} + {!snippet.is_published && ( + <> + · + {snippet.use_count} + + )}
diff --git a/web/service/use-snippets.ts b/web/service/use-snippets.ts index 3acf45f1b0..f8fc8537ef 100644 --- a/web/service/use-snippets.ts +++ b/web/service/use-snippets.ts @@ -14,6 +14,8 @@ import type { UpdateSnippetPayload, } from '@/types/snippet' import { + keepPreviousData, + useInfiniteQuery, useMutation, useQuery, useQueryClient, @@ -131,6 +133,8 @@ const isNotFoundError = (error: unknown) => { return !!error && typeof error === 'object' && 'status' in error && error.status === 404 } +const snippetListKey = (params: SnippetListParams) => ['snippets', 'list', params] + export const useSnippetList = (params: SnippetListParams = {}, options?: { enabled?: boolean }) => { const query = normalizeSnippetListParams(params) @@ -154,6 +158,26 @@ export const useSnippetListItems = (params: SnippetListParams = {}, options?: { }) } +export const useInfiniteSnippetList = (params: SnippetListParams = {}, options?: { enabled?: boolean }) => { + const normalizedParams = normalizeSnippetListParams(params) + + return useInfiniteQuery({ + queryKey: snippetListKey(normalizedParams), + queryFn: ({ pageParam = normalizedParams.page }) => { + return consoleClient.snippets.list({ + query: { + ...normalizedParams, + page: pageParam as number, + }, + }) + }, + getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : undefined, + initialPageParam: normalizedParams.page, + placeholderData: keepPreviousData, + ...options, + }) +} + export const useSnippetApiDetail = (snippetId: string) => { return useQuery(consoleQuery.snippets.detail.queryOptions({ input: { diff --git a/web/types/snippet.ts b/web/types/snippet.ts index 552555db9c..f962b20ab5 100644 --- a/web/types/snippet.ts +++ b/web/types/snippet.ts @@ -1,6 +1,13 @@ +import type { AppIconType } from '@/types/app' + export type SnippetType = 'node' | 'group' -export type SnippetIconInfo = Record +export type SnippetIconInfo = { + icon_type: AppIconType | null + icon: string + icon_background?: string + icon_url?: string +} export type SnippetInputField = Record @@ -16,6 +23,7 @@ export type Snippet = { input_fields: SnippetInputField[] created_at: number updated_at: number + author: string } export type SnippetListItem = Omit