mirror of https://github.com/langgenius/dify.git
feat(web): snippet list fetching & display
This commit is contained in:
parent
f782ac6b3c
commit
36c3d6e48a
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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'>
|
||||
|
|
|
|||
Loading…
Reference in New Issue