mirror of
https://github.com/langgenius/dify.git
synced 2026-06-07 16:32:01 +08:00
fix(web): add loading skeletons for tools and knowledge lists (#36712)
This commit is contained in:
parent
7ae4ca9a60
commit
cab215e209
@ -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} />)
|
||||
|
||||
40
web/app/components/datasets/list/dataset-card-skeleton.tsx
Normal file
40
web/app/components/datasets/list/dataset-card-skeleton.tsx
Normal 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
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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="" />)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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} />
|
||||
|
||||
50
web/app/components/tools/provider/tool-card-skeleton.tsx
Normal file
50
web/app/components/tools/provider/tool-card-skeleton.tsx
Normal 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
|
||||
@ -96,6 +96,7 @@ export const useDatasetList = (params: DatasetListRequest) => {
|
||||
},
|
||||
getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : null,
|
||||
initialPageParam: initialPage,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user