feat(web): snippet list fetching & display

This commit is contained in:
JzoNg 2026-03-23 16:37:05 +08:00
parent f782ac6b3c
commit 36c3d6e48a
5 changed files with 167 additions and 42 deletions

View File

@ -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()
})
})
})

View File

@ -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<Props> = ({
const { query: { tagIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false }, setQuery } = useAppsQueryState()
const [tagFilterValue, setTagFilterValue] = useState<string[]>(tagIDs)
const [appKeywords, setAppKeywords] = useState(keywords)
const [snippetKeywordsInput, setSnippetKeywordsInput] = useState('')
const [snippetKeywords, setSnippetKeywords] = useState('')
const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false)
const [droppedDSLFile, setDroppedDSLFile] = useState<File | undefined>()
@ -109,6 +110,22 @@ const List: FC<Props> = ({
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<Props> = ({
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<Props> = ({
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<Props> = ({
}
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<Props> = ({
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<Props> = ({
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<Props> = ({
<AppCard key={app.id} app={app} onRefresh={refetch} />
))}
{!showSkeleton && !isAppsPage && hasAnySnippet && filteredSnippetItems.map(snippet => (
{!showSkeleton && !isAppsPage && hasAnySnippet && snippetItems.map(snippet => (
<SnippetCard key={snippet.id} snippet={snippet} />
))}
@ -280,6 +302,10 @@ const List: FC<Props> = ({
{isAppsPage && isFetchingNextPage && (
<AppCardSkeleton count={3} />
)}
{!isAppsPage && isSnippetListFetchingNextPage && (
<AppCardSkeleton count={3} />
)}
</div>
{isAppsPage && isCurrentWorkspaceEditor && (

View File

@ -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 (
<Link href={`/snippets/${snippet.id}/orchestrate`} className="group col-span-1">
<article className="relative inline-flex h-[160px] w-full flex-col rounded-xl border border-components-card-border bg-components-card-bg shadow-sm transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg">
{snippet.status && (
{!snippet.is_published && (
<div className="absolute right-0 top-0 rounded-bl-lg rounded-tr-xl bg-background-default-dimmed px-2 py-1 text-[10px] font-medium uppercase leading-3 text-text-placeholder">
{snippet.status}
Draft
</div>
)}
<div className="flex h-[66px] items-center gap-3 px-[14px] pb-3 pt-[14px]">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-divider-regular text-xl text-white" style={{ background: snippet.iconBackground }}>
<span aria-hidden>{snippet.icon}</span>
</div>
<AppIcon
size="large"
iconType={snippet.icon_info.icon_type}
icon={snippet.icon_info.icon}
background={snippet.icon_info.icon_background}
imageUrl={snippet.icon_info.icon_url}
/>
<div className="w-0 grow py-[1px]">
<div className="truncate text-sm font-semibold leading-5 text-text-secondary" title={snippet.name}>
{snippet.name}
@ -34,9 +39,13 @@ const SnippetCard = ({ snippet }: Props) => {
<div className="mt-auto flex items-center gap-1 px-[14px] pb-3 pt-2 text-xs leading-4 text-text-tertiary">
<span className="truncate">{snippet.author}</span>
<span>·</span>
<span className="truncate">{snippet.updatedAt}</span>
<span>·</span>
<span className="truncate">{snippet.usage}</span>
<span className="truncate">{snippet.updated_at}</span>
{!snippet.is_published && (
<>
<span>·</span>
<span className="truncate">{snippet.use_count}</span>
</>
)}
</div>
</article>
</Link>

View File

@ -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<SnippetListResponse>({
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: {

View File

@ -1,6 +1,13 @@
import type { AppIconType } from '@/types/app'
export type SnippetType = 'node' | 'group'
export type SnippetIconInfo = Record<string, unknown>
export type SnippetIconInfo = {
icon_type: AppIconType | null
icon: string
icon_background?: string
icon_url?: string
}
export type SnippetInputField = Record<string, unknown>
@ -16,6 +23,7 @@ export type Snippet = {
input_fields: SnippetInputField[]
created_at: number
updated_at: number
author: string
}
export type SnippetListItem = Omit<Snippet, 'version' | 'input_fields'>