fix(web): add loading skeletons for tools and knowledge lists (#36712)

This commit is contained in:
Jingyi 2026-05-26 22:07:40 -07:00 committed by GitHub
parent 7ae4ca9a60
commit cab215e209
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 344 additions and 78 deletions

View File

@ -2,6 +2,7 @@ import type { DataSet } from '@/models/datasets'
import { render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { useSelector as useAppContextSelector } from '@/context/app-context'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
import Datasets from '../datasets'
@ -44,6 +45,8 @@ vi.mock('@/service/knowledge/use-dataset', () => ({
hasNextPage: false,
isFetching: false,
isFetchingNextPage: false,
isLoading: false,
isPlaceholderData: false,
})),
useInvalidDatasetList: () => mockInvalidDatasetList,
}))
@ -155,6 +158,7 @@ describe('Datasets', () => {
// Setup IntersectionObserver mock
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
vi.mocked(useAppContextSelector).mockReturnValue(true)
})
afterEach(() => {
@ -238,6 +242,77 @@ describe('Datasets', () => {
})
describe('Loading States', () => {
it('should show dataset card skeletons while initial dataset list is loading', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetList).mockReturnValue({
data: undefined,
fetchNextPage: mockFetchNextPage,
hasNextPage: false,
isFetching: true,
isFetchingNextPage: false,
isLoading: true,
isPlaceholderData: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
expect(screen.getByRole('status', { name: /common\.loading/ })).toBeInTheDocument()
expect(screen.queryByText('Dataset 1')).not.toBeInTheDocument()
})
it('should not show dataset card skeletons after an empty dataset list has loaded', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetList).mockReturnValue({
data: { pages: [{ data: [] }] },
fetchNextPage: mockFetchNextPage,
hasNextPage: false,
isFetching: false,
isFetchingNextPage: false,
isLoading: false,
isPlaceholderData: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
expect(screen.queryByRole('status', { name: /common\.loading/ })).not.toBeInTheDocument()
expect(screen.getByText(/createDataset/)).toBeInTheDocument()
})
it('should show dataset card skeletons when placeholder data is empty and the next query is fetching', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetList).mockReturnValue({
data: { pages: [{ data: [] }] },
fetchNextPage: mockFetchNextPage,
hasNextPage: false,
isFetching: true,
isFetchingNextPage: false,
isLoading: false,
isPlaceholderData: true,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
expect(screen.getByRole('status', { name: /common\.loading/ })).toBeInTheDocument()
})
it('should keep rendered dataset cards when placeholder data has results during refetch', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetList).mockReturnValue({
data: { pages: [{ data: [createMockDataset({ id: 'dataset-1', name: 'Dataset 1' })] }] },
fetchNextPage: mockFetchNextPage,
hasNextPage: false,
isFetching: true,
isFetchingNextPage: false,
isLoading: false,
isPlaceholderData: true,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
expect(screen.queryByRole('status', { name: /common\.loading/ })).not.toBeInTheDocument()
expect(screen.getByText('Dataset 1')).toBeInTheDocument()
})
it('should show Loading component when isFetchingNextPage is true', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetList).mockReturnValue({
@ -246,6 +321,8 @@ describe('Datasets', () => {
hasNextPage: true,
isFetching: false,
isFetchingNextPage: true,
isLoading: false,
isPlaceholderData: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
@ -262,6 +339,8 @@ describe('Datasets', () => {
hasNextPage: true,
isFetching: false,
isFetchingNextPage: false,
isLoading: false,
isPlaceholderData: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
@ -278,6 +357,8 @@ describe('Datasets', () => {
hasNextPage: false,
isFetching: false,
isFetchingNextPage: false,
isLoading: false,
isPlaceholderData: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
@ -292,6 +373,8 @@ describe('Datasets', () => {
hasNextPage: false,
isFetching: false,
isFetchingNextPage: false,
isLoading: false,
isPlaceholderData: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
@ -306,6 +389,8 @@ describe('Datasets', () => {
hasNextPage: false,
isFetching: false,
isFetchingNextPage: false,
isLoading: false,
isPlaceholderData: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
@ -322,6 +407,8 @@ describe('Datasets', () => {
hasNextPage: true,
isFetching: false,
isFetchingNextPage: false,
isLoading: false,
isPlaceholderData: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
@ -338,6 +425,8 @@ describe('Datasets', () => {
hasNextPage: true,
isFetching: false,
isFetchingNextPage: false,
isLoading: false,
isPlaceholderData: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
@ -361,6 +450,8 @@ describe('Datasets', () => {
hasNextPage: true,
isFetching: false,
isFetchingNextPage: false,
isLoading: false,
isPlaceholderData: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
@ -383,6 +474,8 @@ describe('Datasets', () => {
hasNextPage: false, // No more pages
isFetching: false,
isFetchingNextPage: false,
isLoading: false,
isPlaceholderData: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
@ -405,6 +498,32 @@ describe('Datasets', () => {
hasNextPage: true,
isFetching: true, // Already fetching
isFetchingNextPage: false,
isLoading: false,
isPlaceholderData: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
if (intersectionObserverCallback) {
intersectionObserverCallback(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
}
expect(mockFetchNextPage).not.toHaveBeenCalled()
})
it('should NOT call fetchNextPage when placeholder data is showing', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetList).mockReturnValue({
data: { pages: [{ data: [createMockDataset({ id: 'dataset-1', name: 'Dataset 1' })] }] },
fetchNextPage: mockFetchNextPage,
hasNextPage: true,
isFetching: false,
isFetchingNextPage: false,
isLoading: false,
isPlaceholderData: true,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
@ -427,6 +546,8 @@ describe('Datasets', () => {
hasNextPage: true,
isFetching: false,
isFetchingNextPage: false,
isLoading: false,
isPlaceholderData: false,
} as unknown as ReturnType<typeof useDatasetList>)
const { unmount } = render(<Datasets {...defaultProps} />)
@ -471,6 +592,8 @@ describe('Datasets', () => {
hasNextPage: false,
isFetching: false,
isFetchingNextPage: false,
isLoading: false,
isPlaceholderData: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)

View File

@ -0,0 +1,40 @@
import { SkeletonContainer, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
type DatasetCardSkeletonProps = {
label: string
count?: number
}
const DatasetCardSkeleton = ({
label,
count = 6,
}: DatasetCardSkeletonProps) => (
<div className="contents" role="status" aria-label={label} aria-live="polite">
{Array.from({ length: count }, (_, index) => (
<div
key={index}
className="h-47.5 rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg p-4 shadow-xs shadow-shadow-shadow-3"
>
<SkeletonContainer className="h-full">
<SkeletonRow>
<SkeletonRectangle className="size-10 animate-pulse rounded-lg" />
<div className="flex flex-1 flex-col gap-1">
<SkeletonRectangle className="h-4 w-2/3 animate-pulse" />
<SkeletonRectangle className="h-3 w-1/3 animate-pulse" />
</div>
</SkeletonRow>
<div className="mt-4 flex flex-col gap-2">
<SkeletonRectangle className="h-3 w-full animate-pulse" />
<SkeletonRectangle className="h-3 w-4/5 animate-pulse" />
</div>
<div className="mt-auto flex items-center justify-between">
<SkeletonRectangle className="h-3 w-24 animate-pulse" />
<SkeletonRectangle className="h-3 w-16 animate-pulse" />
</div>
</SkeletonContainer>
</div>
))}
</div>
)
export default DatasetCardSkeleton

View File

@ -6,6 +6,7 @@ import Loading from '@/app/components/base/loading'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { useDatasetList, useInvalidDatasetList } from '@/service/knowledge/use-dataset'
import DatasetCard from './dataset-card'
import DatasetCardSkeleton from './dataset-card-skeleton'
import NewDatasetCard from './new-dataset-card'
type Props = {
@ -29,6 +30,8 @@ const Datasets = ({
hasNextPage,
isFetching,
isFetchingNextPage,
isLoading,
isPlaceholderData,
} = useDatasetList({
initialPage: 1,
tag_ids: tags,
@ -39,6 +42,9 @@ const Datasets = ({
const invalidDatasetList = useInvalidDatasetList()
const anchorRef = useRef<HTMLDivElement>(null)
const observerRef = useRef<IntersectionObserver>(null)
const pages = datasetList?.pages ?? []
const datasets = pages.flatMap(({ data }) => data)
const showDatasetSkeleton = !isFetchingNextPage && (isLoading || (isPlaceholderData && isFetching && datasets.length === 0))
useEffect(() => {
document.title = `${t('knowledge', { ns: 'dataset' })} - Dify`
@ -47,7 +53,7 @@ const Datasets = ({
useEffect(() => {
if (anchorRef.current) {
observerRef.current = new IntersectionObserver((entries) => {
if (entries[0]!.isIntersecting && hasNextPage && !isFetching)
if (entries[0]!.isIntersecting && hasNextPage && !isFetching && !isPlaceholderData)
fetchNextPage()
}, {
rootMargin: '100px',
@ -55,15 +61,17 @@ const Datasets = ({
observerRef.current.observe(anchorRef.current)
}
return () => observerRef.current?.disconnect()
}, [anchorRef, hasNextPage, isFetching, fetchNextPage])
}, [anchorRef, hasNextPage, isFetching, isPlaceholderData, fetchNextPage])
return (
<>
<nav className="grid grow grid-cols-1 content-start gap-3 px-12 pt-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{isCurrentWorkspaceEditor && <NewDatasetCard />}
{datasetList?.pages.map(({ data: datasets }) => datasets.map(dataset => (
<DatasetCard key={dataset.id} dataset={dataset} onSuccess={invalidDatasetList} onOpenTagManagement={onOpenTagManagement} />),
))}
{showDatasetSkeleton
? <DatasetCardSkeleton label={t('loading', { ns: 'common' })} />
: datasets.map(dataset => (
<DatasetCard key={dataset.id} dataset={dataset} onSuccess={invalidDatasetList} onOpenTagManagement={onOpenTagManagement} />),
)}
{isFetchingNextPage && <Loading />}
<div ref={anchorRef} className="h-0" />
</nav>

View File

@ -87,10 +87,12 @@ const createDefaultCollections = () => [
]
let mockCollectionData: ReturnType<typeof createDefaultCollections> = []
let mockIsLoadingToolProviders = false
const mockRefetch = vi.fn()
vi.mock('@/service/use-tools', () => ({
useAllToolProviders: () => ({
data: mockCollectionData,
isLoading: mockIsLoadingToolProviders,
refetch: mockRefetch,
}),
}))
@ -106,7 +108,19 @@ vi.mock('@/service/use-plugins', () => ({
vi.mock('@/app/components/plugins/card', () => ({
default: ({ payload, className }: { payload: { name: string }, className?: string }) => (
<div data-testid={`card-${payload.name}`} className={className}>{payload.name}</div>
<div data-testid={`card-${payload.name}`} className={className}>
{payload.name}
</div>
),
}))
vi.mock('@/app/components/tools/provider/tool-card-skeleton', () => ({
default: () => (
<>
{Array.from({ length: 6 }, (_, index) => (
<div key={index} data-testid="tool-card-skeleton">Loading tool</div>
))}
</>
),
}))
@ -221,6 +235,7 @@ describe('ProviderList', () => {
vi.clearAllMocks()
mockEnableMarketplace = false
mockCollectionData = createDefaultCollections()
mockIsLoadingToolProviders = false
mockCheckedInstalledData = null
Element.prototype.scrollTo = vi.fn()
})
@ -331,6 +346,13 @@ describe('ProviderList', () => {
renderProviderList({ category: 'api' })
expect(screen.getByTestId('custom-create-card')).toBeInTheDocument()
})
it('shows card skeletons instead of custom create card while tool providers are loading', () => {
mockIsLoadingToolProviders = true
renderProviderList({ category: 'api' })
expect(screen.getAllByTestId('tool-card-skeleton')).toHaveLength(6)
expect(screen.queryByTestId('custom-create-card')).not.toBeInTheDocument()
})
})
describe('Workflow Tab', () => {
@ -344,6 +366,14 @@ describe('ProviderList', () => {
renderProviderList({ category: 'workflow' })
expect(screen.getByTestId('workflow-empty')).toBeInTheDocument()
})
it('shows card skeletons instead of empty state while tool providers are loading', () => {
mockIsLoadingToolProviders = true
mockCollectionData = []
renderProviderList({ category: 'workflow' })
expect(screen.getAllByTestId('tool-card-skeleton')).toHaveLength(6)
expect(screen.queryByTestId('workflow-empty')).not.toBeInTheDocument()
})
})
describe('Builtin Tab Empty State', () => {
@ -353,6 +383,14 @@ describe('ProviderList', () => {
expect(screen.getByTestId('empty')).toBeInTheDocument()
})
it('shows card skeletons instead of empty component while tool providers are loading', () => {
mockIsLoadingToolProviders = true
mockCollectionData = []
renderProviderList()
expect(screen.getAllByTestId('tool-card-skeleton')).toHaveLength(6)
expect(screen.queryByTestId('empty')).not.toBeInTheDocument()
})
it('renders collection that has no labels property', () => {
mockCollectionData = [{
id: 'no-labels',

View File

@ -13,14 +13,26 @@ type MockDetail = MockProvider | undefined
// Mock dependencies
const mockRefetch = vi.fn()
let mockProviders: MockProvider[] = []
let mockIsLoadingToolProviders = false
vi.mock('@/service/use-tools', () => ({
useAllToolProviders: () => ({
data: mockProviders,
isLoading: mockIsLoadingToolProviders,
refetch: mockRefetch,
}),
}))
vi.mock('@/app/components/tools/provider/tool-card-skeleton', () => ({
default: () => (
<>
{Array.from({ length: 6 }, (_, index) => (
<div key={index} data-testid="mcp-card-skeleton">Loading MCP</div>
))}
</>
),
}))
// Mock child components
vi.mock('../create-card', () => ({
default: ({ handleCreate }: { handleCreate: (provider: { id: string, name: string }) => void }) => (
@ -65,6 +77,7 @@ describe('MCPList', () => {
vi.clearAllMocks()
vi.useFakeTimers()
mockProviders = []
mockIsLoadingToolProviders = false
mockRefetch.mockResolvedValue(undefined)
})
@ -85,15 +98,18 @@ describe('MCPList', () => {
expect(screen.getByTestId('create-card')).toBeInTheDocument()
})
it('should render default skeleton cards when list is empty', () => {
it('should render card skeletons while tool providers are loading', () => {
mockIsLoadingToolProviders = true
render(<MCPList searchText="" />)
// Should render skeleton cards when no providers
const container = document.querySelector('.grid')
expect(container).toBeInTheDocument()
// Check for skeleton cards (36 of them)
const skeletonCards = document.querySelectorAll('.h-\\[111px\\]')
expect(skeletonCards.length).toBe(36)
expect(screen.getAllByTestId('mcp-card-skeleton')).toHaveLength(6)
expect(screen.queryByTestId('provider-card-1')).not.toBeInTheDocument()
})
it('should not render card skeletons when the loaded list is empty', () => {
render(<MCPList searchText="" />)
expect(screen.queryByTestId('mcp-card-skeleton')).not.toBeInTheDocument()
})
it('should not render skeleton cards when providers exist', () => {
@ -102,8 +118,7 @@ describe('MCPList', () => {
]
render(<MCPList searchText="" />)
const skeletonCards = document.querySelectorAll('.h-\\[111px\\]')
expect(skeletonCards.length).toBe(0)
expect(screen.queryByTestId('mcp-card-skeleton')).not.toBeInTheDocument()
})
})
@ -325,15 +340,16 @@ describe('MCPList', () => {
expect(grid).toHaveClass('xl:grid-cols-4')
})
it('should have overflow hidden when list is empty', () => {
it('should have overflow hidden while loading', () => {
mockProviders = []
mockIsLoadingToolProviders = true
render(<MCPList searchText="" />)
const grid = document.querySelector('.grid')
expect(grid).toHaveClass('overflow-hidden')
})
it('should not have overflow hidden when list has providers', () => {
it('should not have overflow hidden when loading is complete', () => {
mockProviders = [{ id: '1', name: 'Provider 1', type: 'mcp' }]
render(<MCPList searchText="" />)

View File

@ -2,6 +2,7 @@
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import { useMemo, useState } from 'react'
import ToolCardSkeletonGrid from '@/app/components/tools/provider/tool-card-skeleton'
import {
useAllToolProviders,
} from '@/service/use-tools'
@ -13,29 +14,10 @@ type Props = {
searchText: string
}
function renderDefaultCard() {
const defaultCards = Array.from({ length: 36 }, (_, index) => (
<div
key={index}
className={cn(
'inline-flex h-[111px] rounded-xl bg-background-default-lighter opacity-10',
index < 4 && 'opacity-60',
index >= 4 && index < 8 && 'opacity-50',
index >= 8 && index < 12 && 'opacity-40',
index >= 12 && index < 16 && 'opacity-30',
index >= 16 && index < 20 && 'opacity-25',
index >= 20 && index < 24 && 'opacity-20',
)}
>
</div>
))
return defaultCards
}
const MCPList = ({
searchText,
}: Props) => {
const { data: list = [] as ToolWithProvider[], refetch } = useAllToolProviders()
const { data: list = [] as ToolWithProvider[], isLoading, refetch } = useAllToolProviders()
const [isTriggerAuthorize, setIsTriggerAuthorize] = useState<boolean>(false)
const filteredList = useMemo(() => {
@ -68,21 +50,22 @@ const MCPList = ({
<div
className={cn(
'relative grid shrink-0 grid-cols-1 content-start gap-4 px-12 pt-2 pb-4 2k:grid-cols-6 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5',
!list.length && 'h-[calc(100vh-136px)] overflow-hidden',
isLoading && 'h-[calc(100vh-136px)] overflow-hidden',
)}
>
<NewMCPCard handleCreate={handleCreate} />
{filteredList.map(provider => (
<MCPCard
key={provider.id}
data={provider}
currentProvider={currentProvider as ToolWithProvider}
handleSelect={setCurrentProviderID}
onUpdate={handleUpdate}
onDeleted={refetch}
/>
))}
{!list.length && renderDefaultCard()}
{isLoading
? <ToolCardSkeletonGrid />
: filteredList.map(provider => (
<MCPCard
key={provider.id}
data={provider}
currentProvider={currentProvider as ToolWithProvider}
handleSelect={setCurrentProviderID}
onUpdate={handleUpdate}
onDeleted={refetch}
/>
))}
</div>
{currentProvider && (
<MCPDetailPanel

View File

@ -16,6 +16,7 @@ import LabelFilter from '@/app/components/tools/labels/filter'
import CustomCreateCard from '@/app/components/tools/provider/custom-create-card'
import ProviderDetail from '@/app/components/tools/provider/detail'
import WorkflowToolEmpty from '@/app/components/tools/provider/empty'
import ToolCardSkeletonGrid from '@/app/components/tools/provider/tool-card-skeleton'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useCheckInstalled, useInvalidateInstalledPluginList } from '@/service/use-plugins'
import { useAllToolProviders } from '@/service/use-tools'
@ -61,7 +62,7 @@ const ProviderList = () => {
const handleKeywordsChange = (value: string) => {
setKeywords(value)
}
const { data: collectionList = [], refetch } = useAllToolProviders()
const { data: collectionList = [], isLoading: isCollectionListLoading, refetch } = useAllToolProviders()
const filteredCollectionList = useMemo(() => {
return collectionList.filter((collection) => {
if (collection.type !== activeTab)
@ -165,36 +166,42 @@ const ProviderList = () => {
!filteredCollectionList.length && activeTab === 'workflow' && 'grow',
)}
>
{activeTab === 'api' && <CustomCreateCard onRefreshData={refetch} />}
{filteredCollectionList.map(collection => (
<div
key={collection.id}
onClick={() => setCurrentProviderId(collection.id)}
>
<Card
className={cn(
'cursor-pointer border-[1.5px] border-transparent',
currentProviderId === collection.id && 'border-components-option-card-option-selected-border',
)}
hideCornerMark
payload={{
...collection,
brief: collection.description,
org: collection.plugin_id ? collection.plugin_id.split('/')[0] : '',
name: collection.plugin_id ? collection.plugin_id.split('/')[1] : collection.name,
} as any}
footer={(
<CardMoreInfo
tags={collection.labels?.map(label => getTagLabel(label)) || []}
/>
)}
/>
</div>
))}
{!filteredCollectionList.length && activeTab === 'workflow' && <div className="absolute top-1/2 left-1/2 -translate-1/2"><WorkflowToolEmpty type={getToolType(activeTab)} /></div>}
{isCollectionListLoading
? <ToolCardSkeletonGrid />
: (
<>
{activeTab === 'api' && <CustomCreateCard onRefreshData={refetch} />}
{filteredCollectionList.map(collection => (
<div
key={collection.id}
onClick={() => setCurrentProviderId(collection.id)}
>
<Card
className={cn(
'cursor-pointer border-[1.5px] border-transparent',
currentProviderId === collection.id && 'border-components-option-card-option-selected-border',
)}
hideCornerMark
payload={{
...collection,
brief: collection.description,
org: collection.plugin_id ? collection.plugin_id.split('/')[0] : '',
name: collection.plugin_id ? collection.plugin_id.split('/')[1] : collection.name,
} as any}
footer={(
<CardMoreInfo
tags={collection.labels?.map(label => getTagLabel(label)) || []}
/>
)}
/>
</div>
))}
{!filteredCollectionList.length && activeTab === 'workflow' && <div className="absolute top-1/2 left-1/2 -translate-1/2"><WorkflowToolEmpty type={getToolType(activeTab)} /></div>}
</>
)}
</div>
)}
{!filteredCollectionList.length && activeTab === 'builtin' && (
{!isCollectionListLoading && !filteredCollectionList.length && activeTab === 'builtin' && (
<Empty lightCard text={t('noTools', { ns: 'tools' })} className="h-[224px] shrink-0 px-12" />
)}
<div ref={toolListTailRef} />

View File

@ -0,0 +1,50 @@
import { cn } from '@langgenius/dify-ui/cn'
import { SkeletonContainer, SkeletonPoint, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
type ToolCardSkeletonGridProps = {
className?: string
count?: number
}
const ToolCardSkeleton = () => (
<div className="relative overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg shadow-xs">
<div className="p-4 pb-3">
<div className="flex">
<SkeletonRectangle className="my-0 size-10 shrink-0 animate-pulse rounded-md" />
<div className="ml-3 w-0 grow">
<div className="flex h-5 items-center">
<SkeletonRectangle className="h-4 w-2/3 animate-pulse" />
</div>
<SkeletonRow className="mt-0.5 h-4">
<SkeletonRectangle className="w-[41px] animate-pulse" />
<SkeletonPoint />
<SkeletonRectangle className="w-1/3 animate-pulse" />
</SkeletonRow>
</div>
</div>
<SkeletonContainer className="mt-3 h-8 gap-0">
<SkeletonRectangle className="h-3 w-full animate-pulse" />
<SkeletonRectangle className="h-3 w-4/5 animate-pulse" />
</SkeletonContainer>
<div className="flex h-5 items-center gap-2">
<SkeletonRectangle className="h-3 w-12 animate-pulse" />
<SkeletonRectangle className="h-3 w-20 animate-pulse" />
</div>
</div>
</div>
)
const ToolCardSkeletonGrid = ({
className,
count = 6,
}: ToolCardSkeletonGridProps) => (
<>
{Array.from({ length: count }, (_, index) => (
<div key={index} className={cn(className)}>
<ToolCardSkeleton />
</div>
))}
</>
)
export default ToolCardSkeletonGrid

View File

@ -96,6 +96,7 @@ export const useDatasetList = (params: DatasetListRequest) => {
},
getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : null,
initialPageParam: initialPage,
placeholderData: keepPreviousData,
})
}