mirror of
https://github.com/langgenius/dify.git
synced 2026-05-05 01:48:04 +08:00
Merge branch 'main' into feat/pull-a-variable
This commit is contained in:
@ -183,7 +183,6 @@ const AgentTools: FC = () => {
|
||||
onSelect={handleSelectTool}
|
||||
onSelectMultiple={handleSelectMultipleTool}
|
||||
selectedTools={tools as unknown as ToolValue[]}
|
||||
canChooseMCPTool
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -12,7 +12,6 @@ import { useDebounceFn } from 'ahooks'
|
||||
import dynamic from 'next/dynamic'
|
||||
import {
|
||||
useRouter,
|
||||
useSearchParams,
|
||||
} from 'next/navigation'
|
||||
import { parseAsString, useQueryState } from 'nuqs'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
@ -29,7 +28,6 @@ import { CheckModal } from '@/hooks/use-pay'
|
||||
import { useInfiniteAppList } from '@/service/use-apps'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { isServer } from '@/utils/client'
|
||||
import AppCard from './app-card'
|
||||
import { AppCardSkeleton } from './app-card-skeleton'
|
||||
import Empty from './empty'
|
||||
@ -59,7 +57,6 @@ const List = () => {
|
||||
const { t } = useTranslation()
|
||||
const { systemFeatures } = useGlobalPublicStore()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
|
||||
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
|
||||
const [activeTab, setActiveTab] = useQueryState(
|
||||
@ -67,33 +64,6 @@ const List = () => {
|
||||
parseAsString.withDefault('all').withOptions({ history: 'push' }),
|
||||
)
|
||||
|
||||
// valid tabs for apps list; anything else should fallback to 'all'
|
||||
|
||||
// 1) Normalize legacy/incorrect query params like ?mode=discover -> ?category=all
|
||||
useEffect(() => {
|
||||
// avoid running on server
|
||||
if (isServer)
|
||||
return
|
||||
const mode = searchParams.get('mode')
|
||||
if (!mode)
|
||||
return
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete('mode')
|
||||
if (validTabs.has(mode)) {
|
||||
// migrate to category key
|
||||
url.searchParams.set('category', mode)
|
||||
}
|
||||
else {
|
||||
url.searchParams.set('category', 'all')
|
||||
}
|
||||
router.replace(url.pathname + url.search)
|
||||
}, [router, searchParams])
|
||||
|
||||
// 2) If category has an invalid value (e.g., 'discover'), reset to 'all'
|
||||
useEffect(() => {
|
||||
if (!validTabs.has(activeTab))
|
||||
setActiveTab('all')
|
||||
}, [activeTab, setActiveTab])
|
||||
const { query: { tagIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false }, setQuery } = useAppsQueryState()
|
||||
const [isCreatedByMe, setIsCreatedByMe] = useState(queryIsCreatedByMe)
|
||||
const [tagFilterValue, setTagFilterValue] = useState<string[]>(tagIDs)
|
||||
|
||||
@ -27,7 +27,9 @@ vi.mock('@/service/billing', () => ({
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleClient: {
|
||||
billingUrl: vi.fn(),
|
||||
billing: {
|
||||
invoices: vi.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
@ -43,7 +45,7 @@ vi.mock('../../assets', () => ({
|
||||
|
||||
const mockUseAppContext = useAppContext as Mock
|
||||
const mockUseAsyncWindowOpen = useAsyncWindowOpen as Mock
|
||||
const mockBillingUrl = consoleClient.billingUrl as Mock
|
||||
const mockBillingInvoices = consoleClient.billing.invoices as Mock
|
||||
const mockFetchSubscriptionUrls = fetchSubscriptionUrls as Mock
|
||||
const mockToastNotify = Toast.notify as Mock
|
||||
|
||||
@ -75,7 +77,7 @@ beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: true })
|
||||
mockUseAsyncWindowOpen.mockReturnValue(vi.fn(async open => await open()))
|
||||
mockBillingUrl.mockResolvedValue({ url: 'https://billing.example' })
|
||||
mockBillingInvoices.mockResolvedValue({ url: 'https://billing.example' })
|
||||
mockFetchSubscriptionUrls.mockResolvedValue({ url: 'https://subscription.example' })
|
||||
assignedHref = ''
|
||||
})
|
||||
@ -149,7 +151,7 @@ describe('CloudPlanItem', () => {
|
||||
type: 'error',
|
||||
message: 'billing.buyPermissionDeniedTip',
|
||||
}))
|
||||
expect(mockBillingUrl).not.toHaveBeenCalled()
|
||||
expect(mockBillingInvoices).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should open billing portal when upgrading current paid plan', async () => {
|
||||
@ -168,7 +170,7 @@ describe('CloudPlanItem', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'billing.plansCommon.currentPlan' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockBillingUrl).toHaveBeenCalledTimes(1)
|
||||
expect(mockBillingInvoices).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(openWindow).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
@ -77,7 +77,7 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({
|
||||
try {
|
||||
if (isCurrentPaidPlan) {
|
||||
await openAsyncWindow(async () => {
|
||||
const res = await consoleClient.billingUrl()
|
||||
const res = await consoleClient.billing.invoices()
|
||||
if (res.url)
|
||||
return res.url
|
||||
throw new Error('Failed to open billing page')
|
||||
|
||||
@ -362,6 +362,18 @@ describe('PreviewDocumentPicker', () => {
|
||||
expect(screen.getByText('--')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render when value prop is omitted (optional)', () => {
|
||||
const files = createMockDocumentList(2)
|
||||
const onChange = vi.fn()
|
||||
// Do not pass `value` at all to verify optional behavior
|
||||
render(<PreviewDocumentPicker files={files} onChange={onChange} />)
|
||||
|
||||
// Renders placeholder for missing name
|
||||
expect(screen.getByText('--')).toBeInTheDocument()
|
||||
// Portal wrapper renders
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty files array', () => {
|
||||
renderComponent({ files: [] })
|
||||
|
||||
|
||||
@ -18,7 +18,7 @@ import DocumentList from './document-list'
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
value: DocumentItem
|
||||
value?: DocumentItem
|
||||
files: DocumentItem[]
|
||||
onChange: (value: DocumentItem) => void
|
||||
}
|
||||
@ -30,7 +30,8 @@ const PreviewDocumentPicker: FC<Props> = ({
|
||||
onChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { name, extension } = value
|
||||
const name = value?.name || ''
|
||||
const extension = value?.extension
|
||||
|
||||
const [open, {
|
||||
set: setOpen,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,201 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { Item } from '@/app/components/base/select'
|
||||
import type { BuiltInMetadataItem, MetadataItemWithValueLength } from '@/app/components/datasets/metadata/types'
|
||||
import type { SortType } from '@/service/datasets'
|
||||
import { PlusIcon } from '@heroicons/react/24/solid'
|
||||
import { RiDraftLine, RiExternalLinkLine } from '@remixicon/react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Chip from '@/app/components/base/chip'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Sort from '@/app/components/base/sort'
|
||||
import AutoDisabledDocument from '@/app/components/datasets/common/document-status-with-action/auto-disabled-document'
|
||||
import IndexFailed from '@/app/components/datasets/common/document-status-with-action/index-failed'
|
||||
import StatusWithAction from '@/app/components/datasets/common/document-status-with-action/status-with-action'
|
||||
import DatasetMetadataDrawer from '@/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { DataSourceType } from '@/models/datasets'
|
||||
import { useIndexStatus } from '../status-item/hooks'
|
||||
|
||||
type DocumentsHeaderProps = {
|
||||
// Dataset info
|
||||
datasetId: string
|
||||
dataSourceType?: DataSourceType
|
||||
embeddingAvailable: boolean
|
||||
isFreePlan: boolean
|
||||
|
||||
// Filter & sort
|
||||
statusFilterValue: string
|
||||
sortValue: SortType
|
||||
inputValue: string
|
||||
onStatusFilterChange: (value: string) => void
|
||||
onStatusFilterClear: () => void
|
||||
onSortChange: (value: string) => void
|
||||
onInputChange: (value: string) => void
|
||||
|
||||
// Metadata modal
|
||||
isShowEditMetadataModal: boolean
|
||||
showEditMetadataModal: () => void
|
||||
hideEditMetadataModal: () => void
|
||||
datasetMetaData?: MetadataItemWithValueLength[]
|
||||
builtInMetaData?: BuiltInMetadataItem[]
|
||||
builtInEnabled: boolean
|
||||
onAddMetaData: (payload: BuiltInMetadataItem) => Promise<void>
|
||||
onRenameMetaData: (payload: MetadataItemWithValueLength) => Promise<void>
|
||||
onDeleteMetaData: (metaDataId: string) => Promise<void>
|
||||
onBuiltInEnabledChange: (enabled: boolean) => void
|
||||
|
||||
// Actions
|
||||
onAddDocument: () => void
|
||||
}
|
||||
|
||||
const DocumentsHeader: FC<DocumentsHeaderProps> = ({
|
||||
datasetId,
|
||||
dataSourceType,
|
||||
embeddingAvailable,
|
||||
isFreePlan,
|
||||
statusFilterValue,
|
||||
sortValue,
|
||||
inputValue,
|
||||
onStatusFilterChange,
|
||||
onStatusFilterClear,
|
||||
onSortChange,
|
||||
onInputChange,
|
||||
isShowEditMetadataModal,
|
||||
showEditMetadataModal,
|
||||
hideEditMetadataModal,
|
||||
datasetMetaData,
|
||||
builtInMetaData,
|
||||
builtInEnabled,
|
||||
onAddMetaData,
|
||||
onRenameMetaData,
|
||||
onDeleteMetaData,
|
||||
onBuiltInEnabledChange,
|
||||
onAddDocument,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const docLink = useDocLink()
|
||||
const DOC_INDEX_STATUS_MAP = useIndexStatus()
|
||||
|
||||
const isDataSourceNotion = dataSourceType === DataSourceType.NOTION
|
||||
const isDataSourceWeb = dataSourceType === DataSourceType.WEB
|
||||
|
||||
const statusFilterItems: Item[] = useMemo(() => [
|
||||
{ value: 'all', name: t('list.index.all', { ns: 'datasetDocuments' }) as string },
|
||||
{ value: 'queuing', name: DOC_INDEX_STATUS_MAP.queuing.text },
|
||||
{ value: 'indexing', name: DOC_INDEX_STATUS_MAP.indexing.text },
|
||||
{ value: 'paused', name: DOC_INDEX_STATUS_MAP.paused.text },
|
||||
{ value: 'error', name: DOC_INDEX_STATUS_MAP.error.text },
|
||||
{ value: 'available', name: DOC_INDEX_STATUS_MAP.available.text },
|
||||
{ value: 'enabled', name: DOC_INDEX_STATUS_MAP.enabled.text },
|
||||
{ value: 'disabled', name: DOC_INDEX_STATUS_MAP.disabled.text },
|
||||
{ value: 'archived', name: DOC_INDEX_STATUS_MAP.archived.text },
|
||||
], [DOC_INDEX_STATUS_MAP, t])
|
||||
|
||||
const sortItems: Item[] = useMemo(() => [
|
||||
{ value: 'created_at', name: t('list.sort.uploadTime', { ns: 'datasetDocuments' }) as string },
|
||||
{ value: 'hit_count', name: t('list.sort.hitCount', { ns: 'datasetDocuments' }) as string },
|
||||
], [t])
|
||||
|
||||
// Determine add button text based on data source type
|
||||
const addButtonText = useMemo(() => {
|
||||
if (isDataSourceNotion)
|
||||
return t('list.addPages', { ns: 'datasetDocuments' })
|
||||
if (isDataSourceWeb)
|
||||
return t('list.addUrl', { ns: 'datasetDocuments' })
|
||||
return t('list.addFile', { ns: 'datasetDocuments' })
|
||||
}, [isDataSourceNotion, isDataSourceWeb, t])
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Title section */}
|
||||
<div className="flex flex-col justify-center gap-1 px-6 pt-4">
|
||||
<h1 className="text-base font-semibold text-text-primary">
|
||||
{t('list.title', { ns: 'datasetDocuments' })}
|
||||
</h1>
|
||||
<div className="flex items-center space-x-0.5 text-sm font-normal text-text-tertiary">
|
||||
<span>{t('list.desc', { ns: 'datasetDocuments' })}</span>
|
||||
<a
|
||||
className="flex items-center text-text-accent"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={docLink('/guides/knowledge-base/integrate-knowledge-within-application')}
|
||||
>
|
||||
<span>{t('list.learnMore', { ns: 'datasetDocuments' })}</span>
|
||||
<RiExternalLinkLine className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar section */}
|
||||
<div className="flex flex-wrap items-center justify-between px-6 pt-4">
|
||||
{/* Left: Filters */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Chip
|
||||
className="w-[160px]"
|
||||
showLeftIcon={false}
|
||||
value={statusFilterValue}
|
||||
items={statusFilterItems}
|
||||
onSelect={item => onStatusFilterChange(item?.value ? String(item.value) : '')}
|
||||
onClear={onStatusFilterClear}
|
||||
/>
|
||||
<Input
|
||||
showLeftIcon
|
||||
showClearIcon
|
||||
wrapperClassName="!w-[200px]"
|
||||
value={inputValue}
|
||||
onChange={e => onInputChange(e.target.value)}
|
||||
onClear={() => onInputChange('')}
|
||||
/>
|
||||
<div className="h-3.5 w-px bg-divider-regular"></div>
|
||||
<Sort
|
||||
order={sortValue.startsWith('-') ? '-' : ''}
|
||||
value={sortValue.replace('-', '')}
|
||||
items={sortItems}
|
||||
onSelect={value => onSortChange(String(value))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right: Actions */}
|
||||
<div className="flex !h-8 items-center justify-center gap-2">
|
||||
{!isFreePlan && <AutoDisabledDocument datasetId={datasetId} />}
|
||||
<IndexFailed datasetId={datasetId} />
|
||||
{!embeddingAvailable && (
|
||||
<StatusWithAction
|
||||
type="warning"
|
||||
description={t('embeddingModelNotAvailable', { ns: 'dataset' })}
|
||||
/>
|
||||
)}
|
||||
{embeddingAvailable && (
|
||||
<Button variant="secondary" className="shrink-0" onClick={showEditMetadataModal}>
|
||||
<RiDraftLine className="mr-1 size-4" />
|
||||
{t('metadata.metadata', { ns: 'dataset' })}
|
||||
</Button>
|
||||
)}
|
||||
{isShowEditMetadataModal && (
|
||||
<DatasetMetadataDrawer
|
||||
userMetadata={datasetMetaData ?? []}
|
||||
onClose={hideEditMetadataModal}
|
||||
onAdd={onAddMetaData}
|
||||
onRename={onRenameMetaData}
|
||||
onRemove={onDeleteMetaData}
|
||||
builtInMetadata={builtInMetaData ?? []}
|
||||
isBuiltInEnabled={builtInEnabled}
|
||||
onIsBuiltInEnabledChange={onBuiltInEnabledChange}
|
||||
/>
|
||||
)}
|
||||
{embeddingAvailable && (
|
||||
<Button variant="primary" onClick={onAddDocument} className="shrink-0">
|
||||
<PlusIcon className="mr-2 h-4 w-4 stroke-current" />
|
||||
{addButtonText}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default DocumentsHeader
|
||||
@ -0,0 +1,41 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { PlusIcon } from '@heroicons/react/24/solid'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import s from '../style.module.css'
|
||||
import { FolderPlusIcon, NotionIcon, ThreeDotsIcon } from './icons'
|
||||
|
||||
type EmptyElementProps = {
|
||||
canAdd: boolean
|
||||
onClick: () => void
|
||||
type?: 'upload' | 'sync'
|
||||
}
|
||||
|
||||
const EmptyElement: FC<EmptyElementProps> = ({ canAdd = true, onClick, type = 'upload' }) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className={s.emptyWrapper}>
|
||||
<div className={s.emptyElement}>
|
||||
<div className={s.emptySymbolIconWrapper}>
|
||||
{type === 'upload' ? <FolderPlusIcon /> : <NotionIcon />}
|
||||
</div>
|
||||
<span className={s.emptyTitle}>
|
||||
{t('list.empty.title', { ns: 'datasetDocuments' })}
|
||||
<ThreeDotsIcon className="relative -left-1.5 -top-3 inline" />
|
||||
</span>
|
||||
<div className={s.emptyTip}>
|
||||
{t(`list.empty.${type}.tip`, { ns: 'datasetDocuments' })}
|
||||
</div>
|
||||
{type === 'upload' && canAdd && (
|
||||
<Button onClick={onClick} className={s.addFileBtn} variant="secondary-accent">
|
||||
<PlusIcon className={s.plusIcon} />
|
||||
{t('list.addFile', { ns: 'datasetDocuments' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmptyElement
|
||||
34
web/app/components/datasets/documents/components/icons.tsx
Normal file
34
web/app/components/datasets/documents/components/icons.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import type * as React from 'react'
|
||||
|
||||
export const FolderPlusIcon = ({ className }: React.SVGProps<SVGElement>) => {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" className={className ?? ''}>
|
||||
<path d="M10.8332 5.83333L9.90355 3.9741C9.63601 3.439 9.50222 3.17144 9.30265 2.97597C9.12615 2.80311 8.91344 2.67164 8.6799 2.59109C8.41581 2.5 8.11668 2.5 7.51841 2.5H4.33317C3.39975 2.5 2.93304 2.5 2.57652 2.68166C2.26292 2.84144 2.00795 3.09641 1.84816 3.41002C1.6665 3.76654 1.6665 4.23325 1.6665 5.16667V5.83333M1.6665 5.83333H14.3332C15.7333 5.83333 16.4334 5.83333 16.9681 6.10582C17.4386 6.3455 17.821 6.72795 18.0607 7.19836C18.3332 7.73314 18.3332 8.4332 18.3332 9.83333V13.5C18.3332 14.9001 18.3332 15.6002 18.0607 16.135C17.821 16.6054 17.4386 16.9878 16.9681 17.2275C16.4334 17.5 15.7333 17.5 14.3332 17.5H5.6665C4.26637 17.5 3.56631 17.5 3.03153 17.2275C2.56112 16.9878 2.17867 16.6054 1.93899 16.135C1.6665 15.6002 1.6665 14.9001 1.6665 13.5V5.83333ZM9.99984 14.1667V9.16667M7.49984 11.6667H12.4998" stroke="#667085" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export const ThreeDotsIcon = ({ className }: React.SVGProps<SVGElement>) => {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" className={className ?? ''}>
|
||||
<path d="M5 6.5V5M8.93934 7.56066L10 6.5M10.0103 11.5H11.5103" stroke="#374151" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export const NotionIcon = ({ className }: React.SVGProps<SVGElement>) => {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" className={className ?? ''}>
|
||||
<g clipPath="url(#clip0_2164_11263)">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M3.5725 18.2611L1.4229 15.5832C0.905706 14.9389 0.625 14.1466 0.625 13.3312V3.63437C0.625 2.4129 1.60224 1.39936 2.86295 1.31328L12.8326 0.632614C13.5569 0.583164 14.2768 0.775682 14.8717 1.17794L18.3745 3.5462C19.0015 3.97012 19.375 4.66312 19.375 5.40266V16.427C19.375 17.6223 18.4141 18.6121 17.1798 18.688L6.11458 19.3692C5.12958 19.4298 4.17749 19.0148 3.5725 18.2611Z" fill="white" />
|
||||
<path d="M7.03006 8.48669V8.35974C7.03006 8.03794 7.28779 7.77104 7.61997 7.74886L10.0396 7.58733L13.3857 12.5147V8.19009L12.5244 8.07528V8.01498C12.5244 7.68939 12.788 7.42074 13.1244 7.4035L15.326 7.29073V7.60755C15.326 7.75628 15.2154 7.88349 15.0638 7.90913L14.534 7.99874V15.0023L13.8691 15.231C13.3136 15.422 12.6952 15.2175 12.3772 14.7377L9.12879 9.83574V14.5144L10.1287 14.7057L10.1147 14.7985C10.0711 15.089 9.82028 15.3087 9.51687 15.3222L7.03006 15.4329C6.99718 15.1205 7.23132 14.841 7.55431 14.807L7.88143 14.7727V8.53453L7.03006 8.48669Z" fill="black" />
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M12.9218 1.85424L2.95217 2.53491C2.35499 2.57568 1.89209 3.05578 1.89209 3.63437V13.3312C1.89209 13.8748 2.07923 14.403 2.42402 14.8325L4.57362 17.5104C4.92117 17.9434 5.46812 18.1818 6.03397 18.147L17.0991 17.4658C17.6663 17.4309 18.1078 16.9762 18.1078 16.427V5.40266C18.1078 5.06287 17.9362 4.74447 17.6481 4.54969L14.1453 2.18143C13.7883 1.94008 13.3564 1.82457 12.9218 1.85424ZM3.44654 3.78562C3.30788 3.68296 3.37387 3.46909 3.54806 3.4566L12.9889 2.77944C13.2897 2.75787 13.5886 2.8407 13.8318 3.01305L15.7261 4.35508C15.798 4.40603 15.7642 4.51602 15.6752 4.52086L5.67742 5.0646C5.37485 5.08106 5.0762 4.99217 4.83563 4.81406L3.44654 3.78562ZM5.20848 6.76919C5.20848 6.4444 5.47088 6.1761 5.80642 6.15783L16.3769 5.58216C16.7039 5.56435 16.9792 5.81583 16.9792 6.13239V15.6783C16.9792 16.0025 16.7177 16.2705 16.3829 16.2896L5.8793 16.8872C5.51537 16.9079 5.20848 16.6283 5.20848 16.2759V6.76919Z" fill="black" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2164_11263">
|
||||
<rect width="20" height="20" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@ -16,13 +16,16 @@ import * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import FileTypeIcon from '@/app/components/base/file-uploader/file-type-icon'
|
||||
import NotionIcon from '@/app/components/base/notion-icon'
|
||||
import Pagination from '@/app/components/base/pagination'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import ChunkingModeLabel from '@/app/components/datasets/common/chunking-mode-label'
|
||||
import { normalizeStatusForQuery } from '@/app/components/datasets/documents/status-filter'
|
||||
import { extensionToFileType } from '@/app/components/datasets/hit-testing/utils/extension-to-file-type'
|
||||
import EditMetadataBatchModal from '@/app/components/datasets/metadata/edit-metadata-batch/modal'
|
||||
import useBatchEditDocumentMetadata from '@/app/components/datasets/metadata/hooks/use-batch-edit-document-metadata'
|
||||
import { useDatasetDetailContextWithSelector as useDatasetDetailContext } from '@/context/dataset-detail'
|
||||
import useTimestamp from '@/hooks/use-timestamp'
|
||||
import { ChunkingMode, DataSourceType, DocumentActionType } from '@/models/datasets'
|
||||
@ -31,14 +34,11 @@ import { useDocumentArchive, useDocumentBatchRetryIndex, useDocumentDelete, useD
|
||||
import { asyncRunSafe } from '@/utils'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
import FileTypeIcon from '../../base/file-uploader/file-type-icon'
|
||||
import ChunkingModeLabel from '../common/chunking-mode-label'
|
||||
import useBatchEditDocumentMetadata from '../metadata/hooks/use-batch-edit-document-metadata'
|
||||
import BatchAction from './detail/completed/common/batch-action'
|
||||
import BatchAction from '../detail/completed/common/batch-action'
|
||||
import StatusItem from '../status-item'
|
||||
import s from '../style.module.css'
|
||||
import Operations from './operations'
|
||||
import RenameModal from './rename-modal'
|
||||
import StatusItem from './status-item'
|
||||
import s from './style.module.css'
|
||||
|
||||
export const renderTdValue = (value: string | number | null, isEmptyStyle = false) => {
|
||||
return (
|
||||
@ -1,4 +1,4 @@
|
||||
import type { OperationName } from './types'
|
||||
import type { OperationName } from '../types'
|
||||
import type { CommonResponse } from '@/models/common'
|
||||
import {
|
||||
RiArchive2Line,
|
||||
@ -17,6 +17,12 @@ import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import CustomPopover from '@/app/components/base/popover'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { DataSourceType, DocumentActionType } from '@/models/datasets'
|
||||
import {
|
||||
useDocumentArchive,
|
||||
@ -31,14 +37,8 @@ import {
|
||||
} from '@/service/knowledge/use-document'
|
||||
import { asyncRunSafe } from '@/utils'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Confirm from '../../base/confirm'
|
||||
import Divider from '../../base/divider'
|
||||
import CustomPopover from '../../base/popover'
|
||||
import Switch from '../../base/switch'
|
||||
import { ToastContext } from '../../base/toast'
|
||||
import Tooltip from '../../base/tooltip'
|
||||
import s from '../style.module.css'
|
||||
import RenameModal from './rename-modal'
|
||||
import s from './style.module.css'
|
||||
|
||||
type OperationsProps = {
|
||||
embeddingAvailable: boolean
|
||||
@ -7,8 +7,8 @@ import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { renameDocumentName } from '@/service/datasets'
|
||||
import Toast from '../../base/toast'
|
||||
|
||||
type Props = {
|
||||
datasetId: string
|
||||
@ -0,0 +1,5 @@
|
||||
export { useAddDocumentsSteps } from './use-add-documents-steps'
|
||||
export { useDatasourceActions } from './use-datasource-actions'
|
||||
export { useDatasourceOptions } from './use-datasource-options'
|
||||
export { useLocalFile, useOnlineDocument, useOnlineDrive, useWebsiteCrawl } from './use-datasource-store'
|
||||
export { useDatasourceUIState } from './use-datasource-ui-state'
|
||||
@ -0,0 +1,41 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AddDocumentsStep } from '../types'
|
||||
|
||||
/**
|
||||
* Hook for managing add documents wizard steps
|
||||
*/
|
||||
export const useAddDocumentsSteps = () => {
|
||||
const { t } = useTranslation()
|
||||
const [currentStep, setCurrentStep] = useState(1)
|
||||
|
||||
const handleNextStep = useCallback(() => {
|
||||
setCurrentStep(preStep => preStep + 1)
|
||||
}, [])
|
||||
|
||||
const handleBackStep = useCallback(() => {
|
||||
setCurrentStep(preStep => preStep - 1)
|
||||
}, [])
|
||||
|
||||
const steps = [
|
||||
{
|
||||
label: t('addDocuments.steps.chooseDatasource', { ns: 'datasetPipeline' }),
|
||||
value: AddDocumentsStep.dataSource,
|
||||
},
|
||||
{
|
||||
label: t('addDocuments.steps.processDocuments', { ns: 'datasetPipeline' }),
|
||||
value: AddDocumentsStep.processDocuments,
|
||||
},
|
||||
{
|
||||
label: t('addDocuments.steps.processingDocuments', { ns: 'datasetPipeline' }),
|
||||
value: AddDocumentsStep.processingDocuments,
|
||||
},
|
||||
]
|
||||
|
||||
return {
|
||||
steps,
|
||||
currentStep,
|
||||
handleNextStep,
|
||||
handleBackStep,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,321 @@
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { DataSourceShape } from '@/app/components/datasets/documents/create-from-pipeline/data-source/store'
|
||||
import type { Datasource } from '@/app/components/rag-pipeline/components/panel/test-run/types'
|
||||
import type { DataSourceNotionPageMap, NotionPage } from '@/models/common'
|
||||
import type { CrawlResultItem, DocumentItem, CustomFile as File, FileIndexingEstimateResponse } from '@/models/datasets'
|
||||
import type {
|
||||
OnlineDriveFile,
|
||||
PublishedPipelineRunPreviewResponse,
|
||||
PublishedPipelineRunResponse,
|
||||
} from '@/models/pipeline'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import { DatasourceType } from '@/models/pipeline'
|
||||
import { useRunPublishedPipeline } from '@/service/use-pipeline'
|
||||
import {
|
||||
buildLocalFileDatasourceInfo,
|
||||
buildOnlineDocumentDatasourceInfo,
|
||||
buildOnlineDriveDatasourceInfo,
|
||||
buildWebsiteCrawlDatasourceInfo,
|
||||
} from '../utils/datasource-info-builder'
|
||||
|
||||
type DatasourceActionsParams = {
|
||||
datasource: Datasource | undefined
|
||||
datasourceType: string | undefined
|
||||
pipelineId: string | undefined
|
||||
dataSourceStore: StoreApi<DataSourceShape>
|
||||
setEstimateData: (data: FileIndexingEstimateResponse | undefined) => void
|
||||
setBatchId: (id: string) => void
|
||||
setDocuments: (docs: PublishedPipelineRunResponse['documents']) => void
|
||||
handleNextStep: () => void
|
||||
PagesMapAndSelectedPagesId: DataSourceNotionPageMap
|
||||
currentWorkspacePages: { page_id: string }[] | undefined
|
||||
clearOnlineDocumentData: () => void
|
||||
clearWebsiteCrawlData: () => void
|
||||
clearOnlineDriveData: () => void
|
||||
setDatasource: (ds: Datasource) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for datasource-related actions (preview, process, etc.)
|
||||
*/
|
||||
export const useDatasourceActions = ({
|
||||
datasource,
|
||||
datasourceType,
|
||||
pipelineId,
|
||||
dataSourceStore,
|
||||
setEstimateData,
|
||||
setBatchId,
|
||||
setDocuments,
|
||||
handleNextStep,
|
||||
PagesMapAndSelectedPagesId,
|
||||
currentWorkspacePages,
|
||||
clearOnlineDocumentData,
|
||||
clearWebsiteCrawlData,
|
||||
clearOnlineDriveData,
|
||||
setDatasource,
|
||||
}: DatasourceActionsParams) => {
|
||||
const isPreview = useRef(false)
|
||||
const formRef = useRef<{ submit: () => void } | null>(null)
|
||||
|
||||
const { mutateAsync: runPublishedPipeline, isIdle, isPending } = useRunPublishedPipeline()
|
||||
|
||||
// Build datasource info for preview (single item)
|
||||
const buildPreviewDatasourceInfo = useCallback(() => {
|
||||
const {
|
||||
previewLocalFileRef,
|
||||
previewOnlineDocumentRef,
|
||||
previewWebsitePageRef,
|
||||
previewOnlineDriveFileRef,
|
||||
currentCredentialId,
|
||||
bucket,
|
||||
} = dataSourceStore.getState()
|
||||
|
||||
const datasourceInfoList: Record<string, unknown>[] = []
|
||||
|
||||
if (datasourceType === DatasourceType.localFile && previewLocalFileRef.current) {
|
||||
datasourceInfoList.push(buildLocalFileDatasourceInfo(
|
||||
previewLocalFileRef.current as File,
|
||||
currentCredentialId,
|
||||
))
|
||||
}
|
||||
|
||||
if (datasourceType === DatasourceType.onlineDocument && previewOnlineDocumentRef.current) {
|
||||
datasourceInfoList.push(buildOnlineDocumentDatasourceInfo(
|
||||
previewOnlineDocumentRef.current,
|
||||
currentCredentialId,
|
||||
))
|
||||
}
|
||||
|
||||
if (datasourceType === DatasourceType.websiteCrawl && previewWebsitePageRef.current) {
|
||||
datasourceInfoList.push(buildWebsiteCrawlDatasourceInfo(
|
||||
previewWebsitePageRef.current,
|
||||
currentCredentialId,
|
||||
))
|
||||
}
|
||||
|
||||
if (datasourceType === DatasourceType.onlineDrive && previewOnlineDriveFileRef.current) {
|
||||
datasourceInfoList.push(buildOnlineDriveDatasourceInfo(
|
||||
previewOnlineDriveFileRef.current,
|
||||
bucket,
|
||||
currentCredentialId,
|
||||
))
|
||||
}
|
||||
|
||||
return datasourceInfoList
|
||||
}, [dataSourceStore, datasourceType])
|
||||
|
||||
// Build datasource info for processing (all items)
|
||||
const buildProcessDatasourceInfo = useCallback(() => {
|
||||
const {
|
||||
currentCredentialId,
|
||||
localFileList,
|
||||
onlineDocuments,
|
||||
websitePages,
|
||||
bucket,
|
||||
selectedFileIds,
|
||||
onlineDriveFileList,
|
||||
} = dataSourceStore.getState()
|
||||
|
||||
const datasourceInfoList: Record<string, unknown>[] = []
|
||||
|
||||
if (datasourceType === DatasourceType.localFile) {
|
||||
localFileList.forEach((file) => {
|
||||
datasourceInfoList.push(buildLocalFileDatasourceInfo(file.file, currentCredentialId))
|
||||
})
|
||||
}
|
||||
|
||||
if (datasourceType === DatasourceType.onlineDocument) {
|
||||
onlineDocuments.forEach((page) => {
|
||||
datasourceInfoList.push(buildOnlineDocumentDatasourceInfo(page, currentCredentialId))
|
||||
})
|
||||
}
|
||||
|
||||
if (datasourceType === DatasourceType.websiteCrawl) {
|
||||
websitePages.forEach((page) => {
|
||||
datasourceInfoList.push(buildWebsiteCrawlDatasourceInfo(page, currentCredentialId))
|
||||
})
|
||||
}
|
||||
|
||||
if (datasourceType === DatasourceType.onlineDrive) {
|
||||
selectedFileIds.forEach((id) => {
|
||||
const file = onlineDriveFileList.find(f => f.id === id)
|
||||
if (file)
|
||||
datasourceInfoList.push(buildOnlineDriveDatasourceInfo(file, bucket, currentCredentialId))
|
||||
})
|
||||
}
|
||||
|
||||
return datasourceInfoList
|
||||
}, [dataSourceStore, datasourceType])
|
||||
|
||||
// Handle chunk preview
|
||||
const handlePreviewChunks = useCallback(async (data: Record<string, unknown>) => {
|
||||
if (!datasource || !pipelineId)
|
||||
return
|
||||
|
||||
const datasourceInfoList = buildPreviewDatasourceInfo()
|
||||
await runPublishedPipeline({
|
||||
pipeline_id: pipelineId,
|
||||
inputs: data,
|
||||
start_node_id: datasource.nodeId,
|
||||
datasource_type: datasourceType as DatasourceType,
|
||||
datasource_info_list: datasourceInfoList,
|
||||
is_preview: true,
|
||||
}, {
|
||||
onSuccess: (res) => {
|
||||
setEstimateData((res as PublishedPipelineRunPreviewResponse).data.outputs)
|
||||
},
|
||||
})
|
||||
}, [datasource, pipelineId, datasourceType, buildPreviewDatasourceInfo, runPublishedPipeline, setEstimateData])
|
||||
|
||||
// Handle document processing
|
||||
const handleProcess = useCallback(async (data: Record<string, unknown>) => {
|
||||
if (!datasource || !pipelineId)
|
||||
return
|
||||
|
||||
const datasourceInfoList = buildProcessDatasourceInfo()
|
||||
await runPublishedPipeline({
|
||||
pipeline_id: pipelineId,
|
||||
inputs: data,
|
||||
start_node_id: datasource.nodeId,
|
||||
datasource_type: datasourceType as DatasourceType,
|
||||
datasource_info_list: datasourceInfoList,
|
||||
is_preview: false,
|
||||
}, {
|
||||
onSuccess: (res) => {
|
||||
setBatchId((res as PublishedPipelineRunResponse).batch || '')
|
||||
setDocuments((res as PublishedPipelineRunResponse).documents || [])
|
||||
handleNextStep()
|
||||
trackEvent('dataset_document_added', {
|
||||
data_source_type: datasourceType,
|
||||
indexing_technique: 'pipeline',
|
||||
})
|
||||
},
|
||||
})
|
||||
}, [datasource, pipelineId, datasourceType, buildProcessDatasourceInfo, runPublishedPipeline, setBatchId, setDocuments, handleNextStep])
|
||||
|
||||
// Form submission handlers
|
||||
const onClickProcess = useCallback(() => {
|
||||
isPreview.current = false
|
||||
formRef.current?.submit()
|
||||
}, [])
|
||||
|
||||
const onClickPreview = useCallback(() => {
|
||||
isPreview.current = true
|
||||
formRef.current?.submit()
|
||||
}, [])
|
||||
|
||||
const handleSubmit = useCallback((data: Record<string, unknown>) => {
|
||||
if (isPreview.current)
|
||||
handlePreviewChunks(data)
|
||||
else
|
||||
handleProcess(data)
|
||||
}, [handlePreviewChunks, handleProcess])
|
||||
|
||||
// Preview change handlers
|
||||
const handlePreviewFileChange = useCallback((file: DocumentItem) => {
|
||||
const { previewLocalFileRef } = dataSourceStore.getState()
|
||||
previewLocalFileRef.current = file
|
||||
onClickPreview()
|
||||
}, [dataSourceStore, onClickPreview])
|
||||
|
||||
const handlePreviewOnlineDocumentChange = useCallback((page: NotionPage) => {
|
||||
const { previewOnlineDocumentRef } = dataSourceStore.getState()
|
||||
previewOnlineDocumentRef.current = page
|
||||
onClickPreview()
|
||||
}, [dataSourceStore, onClickPreview])
|
||||
|
||||
const handlePreviewWebsiteChange = useCallback((website: CrawlResultItem) => {
|
||||
const { previewWebsitePageRef } = dataSourceStore.getState()
|
||||
previewWebsitePageRef.current = website
|
||||
onClickPreview()
|
||||
}, [dataSourceStore, onClickPreview])
|
||||
|
||||
const handlePreviewOnlineDriveFileChange = useCallback((file: OnlineDriveFile) => {
|
||||
const { previewOnlineDriveFileRef } = dataSourceStore.getState()
|
||||
previewOnlineDriveFileRef.current = file
|
||||
onClickPreview()
|
||||
}, [dataSourceStore, onClickPreview])
|
||||
|
||||
// Select all handler
|
||||
const handleSelectAll = useCallback(() => {
|
||||
const {
|
||||
onlineDocuments,
|
||||
onlineDriveFileList,
|
||||
selectedFileIds,
|
||||
setOnlineDocuments,
|
||||
setSelectedFileIds,
|
||||
setSelectedPagesId,
|
||||
} = dataSourceStore.getState()
|
||||
|
||||
if (datasourceType === DatasourceType.onlineDocument) {
|
||||
const allIds = currentWorkspacePages?.map(page => page.page_id) || []
|
||||
if (onlineDocuments.length < allIds.length) {
|
||||
const selectedPages = Array.from(allIds).map(pageId => PagesMapAndSelectedPagesId[pageId])
|
||||
setOnlineDocuments(selectedPages)
|
||||
setSelectedPagesId(new Set(allIds))
|
||||
}
|
||||
else {
|
||||
setOnlineDocuments([])
|
||||
setSelectedPagesId(new Set())
|
||||
}
|
||||
}
|
||||
|
||||
if (datasourceType === DatasourceType.onlineDrive) {
|
||||
const allKeys = onlineDriveFileList.filter(item => item.type !== 'bucket').map(file => file.id)
|
||||
if (selectedFileIds.length < allKeys.length)
|
||||
setSelectedFileIds(allKeys)
|
||||
else
|
||||
setSelectedFileIds([])
|
||||
}
|
||||
}, [PagesMapAndSelectedPagesId, currentWorkspacePages, dataSourceStore, datasourceType])
|
||||
|
||||
// Clear datasource data based on type
|
||||
const clearDataSourceData = useCallback((dataSource: Datasource) => {
|
||||
const providerType = dataSource.nodeData.provider_type
|
||||
const clearFunctions: Record<string, () => void> = {
|
||||
[DatasourceType.onlineDocument]: clearOnlineDocumentData,
|
||||
[DatasourceType.websiteCrawl]: clearWebsiteCrawlData,
|
||||
[DatasourceType.onlineDrive]: clearOnlineDriveData,
|
||||
[DatasourceType.localFile]: () => {},
|
||||
}
|
||||
clearFunctions[providerType]?.()
|
||||
}, [clearOnlineDocumentData, clearOnlineDriveData, clearWebsiteCrawlData])
|
||||
|
||||
// Switch datasource handler
|
||||
const handleSwitchDataSource = useCallback((dataSource: Datasource) => {
|
||||
const {
|
||||
setCurrentCredentialId,
|
||||
currentNodeIdRef,
|
||||
} = dataSourceStore.getState()
|
||||
clearDataSourceData(dataSource)
|
||||
setCurrentCredentialId('')
|
||||
currentNodeIdRef.current = dataSource.nodeId
|
||||
setDatasource(dataSource)
|
||||
}, [clearDataSourceData, dataSourceStore, setDatasource])
|
||||
|
||||
// Credential change handler
|
||||
const handleCredentialChange = useCallback((credentialId: string) => {
|
||||
const { setCurrentCredentialId } = dataSourceStore.getState()
|
||||
if (datasource)
|
||||
clearDataSourceData(datasource)
|
||||
setCurrentCredentialId(credentialId)
|
||||
}, [clearDataSourceData, dataSourceStore, datasource])
|
||||
|
||||
return {
|
||||
isPreview,
|
||||
formRef,
|
||||
isIdle,
|
||||
isPending,
|
||||
onClickProcess,
|
||||
onClickPreview,
|
||||
handleSubmit,
|
||||
handlePreviewFileChange,
|
||||
handlePreviewOnlineDocumentChange,
|
||||
handlePreviewWebsiteChange,
|
||||
handlePreviewOnlineDriveFileChange,
|
||||
handleSelectAll,
|
||||
handleSwitchDataSource,
|
||||
handleCredentialChange,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
import type { DataSourceOption } from '@/app/components/rag-pipeline/components/panel/test-run/types'
|
||||
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
|
||||
import type { Node } from '@/app/components/workflow/types'
|
||||
import { useMemo } from 'react'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
|
||||
/**
|
||||
* Hook for getting datasource options from pipeline nodes
|
||||
*/
|
||||
export const useDatasourceOptions = (pipelineNodes: Node<DataSourceNodeType>[]) => {
|
||||
const datasourceNodes = pipelineNodes.filter(node => node.data.type === BlockEnum.DataSource)
|
||||
|
||||
const options = useMemo(() => {
|
||||
const options: DataSourceOption[] = []
|
||||
datasourceNodes.forEach((node) => {
|
||||
const label = node.data.title
|
||||
options.push({
|
||||
label,
|
||||
value: node.id,
|
||||
data: node.data,
|
||||
})
|
||||
})
|
||||
return options
|
||||
}, [datasourceNodes])
|
||||
|
||||
return options
|
||||
}
|
||||
@ -1,69 +1,12 @@
|
||||
import type { DataSourceOption } from '@/app/components/rag-pipeline/components/panel/test-run/types'
|
||||
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
|
||||
import type { Node } from '@/app/components/workflow/types'
|
||||
import type { DataSourceNotionPageMap, DataSourceNotionWorkspace } from '@/models/common'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { CrawlStep } from '@/models/datasets'
|
||||
import { useDataSourceStore, useDataSourceStoreWithSelector } from './data-source/store'
|
||||
import { AddDocumentsStep } from './types'
|
||||
|
||||
export const useAddDocumentsSteps = () => {
|
||||
const { t } = useTranslation()
|
||||
const [currentStep, setCurrentStep] = useState(1)
|
||||
|
||||
const handleNextStep = useCallback(() => {
|
||||
setCurrentStep(preStep => preStep + 1)
|
||||
}, [])
|
||||
|
||||
const handleBackStep = useCallback(() => {
|
||||
setCurrentStep(preStep => preStep - 1)
|
||||
}, [])
|
||||
|
||||
const steps = [
|
||||
{
|
||||
label: t('addDocuments.steps.chooseDatasource', { ns: 'datasetPipeline' }),
|
||||
value: AddDocumentsStep.dataSource,
|
||||
},
|
||||
{
|
||||
label: t('addDocuments.steps.processDocuments', { ns: 'datasetPipeline' }),
|
||||
value: AddDocumentsStep.processDocuments,
|
||||
},
|
||||
{
|
||||
label: t('addDocuments.steps.processingDocuments', { ns: 'datasetPipeline' }),
|
||||
value: AddDocumentsStep.processingDocuments,
|
||||
},
|
||||
]
|
||||
|
||||
return {
|
||||
steps,
|
||||
currentStep,
|
||||
handleNextStep,
|
||||
handleBackStep,
|
||||
}
|
||||
}
|
||||
|
||||
export const useDatasourceOptions = (pipelineNodes: Node<DataSourceNodeType>[]) => {
|
||||
const datasourceNodes = pipelineNodes.filter(node => node.data.type === BlockEnum.DataSource)
|
||||
|
||||
const options = useMemo(() => {
|
||||
const options: DataSourceOption[] = []
|
||||
datasourceNodes.forEach((node) => {
|
||||
const label = node.data.title
|
||||
options.push({
|
||||
label,
|
||||
value: node.id,
|
||||
data: node.data,
|
||||
})
|
||||
})
|
||||
return options
|
||||
}, [datasourceNodes])
|
||||
|
||||
return options
|
||||
}
|
||||
import { useDataSourceStore, useDataSourceStoreWithSelector } from '../data-source/store'
|
||||
|
||||
/**
|
||||
* Hook for local file datasource store operations
|
||||
*/
|
||||
export const useLocalFile = () => {
|
||||
const {
|
||||
localFileList,
|
||||
@ -89,6 +32,9 @@ export const useLocalFile = () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for online document datasource store operations
|
||||
*/
|
||||
export const useOnlineDocument = () => {
|
||||
const {
|
||||
documentsData,
|
||||
@ -147,6 +93,9 @@ export const useOnlineDocument = () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for website crawl datasource store operations
|
||||
*/
|
||||
export const useWebsiteCrawl = () => {
|
||||
const {
|
||||
websitePages,
|
||||
@ -186,6 +135,9 @@ export const useWebsiteCrawl = () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for online drive datasource store operations
|
||||
*/
|
||||
export const useOnlineDrive = () => {
|
||||
const {
|
||||
onlineDriveFileList,
|
||||
@ -0,0 +1,132 @@
|
||||
import type { Datasource } from '@/app/components/rag-pipeline/components/panel/test-run/types'
|
||||
import type { OnlineDriveFile } from '@/models/pipeline'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { DatasourceType } from '@/models/pipeline'
|
||||
|
||||
type DatasourceUIStateParams = {
|
||||
datasource: Datasource | undefined
|
||||
allFileLoaded: boolean
|
||||
localFileListLength: number
|
||||
onlineDocumentsLength: number
|
||||
websitePagesLength: number
|
||||
selectedFileIdsLength: number
|
||||
onlineDriveFileList: OnlineDriveFile[]
|
||||
isVectorSpaceFull: boolean
|
||||
enableBilling: boolean
|
||||
currentWorkspacePagesLength: number
|
||||
fileUploadConfig: { file_size_limit: number, batch_count_limit: number }
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for computing datasource UI state based on datasource type
|
||||
*/
|
||||
export const useDatasourceUIState = ({
|
||||
datasource,
|
||||
allFileLoaded,
|
||||
localFileListLength,
|
||||
onlineDocumentsLength,
|
||||
websitePagesLength,
|
||||
selectedFileIdsLength,
|
||||
onlineDriveFileList,
|
||||
isVectorSpaceFull,
|
||||
enableBilling,
|
||||
currentWorkspacePagesLength,
|
||||
fileUploadConfig,
|
||||
}: DatasourceUIStateParams) => {
|
||||
const { t } = useTranslation()
|
||||
const datasourceType = datasource?.nodeData.provider_type
|
||||
|
||||
const isShowVectorSpaceFull = useMemo(() => {
|
||||
if (!datasource || !datasourceType)
|
||||
return false
|
||||
|
||||
// Lookup table for vector space full condition check
|
||||
const vectorSpaceFullConditions: Record<string, boolean> = {
|
||||
[DatasourceType.localFile]: allFileLoaded,
|
||||
[DatasourceType.onlineDocument]: onlineDocumentsLength > 0,
|
||||
[DatasourceType.websiteCrawl]: websitePagesLength > 0,
|
||||
[DatasourceType.onlineDrive]: onlineDriveFileList.length > 0,
|
||||
}
|
||||
|
||||
const condition = vectorSpaceFullConditions[datasourceType]
|
||||
return condition && isVectorSpaceFull && enableBilling
|
||||
}, [datasource, datasourceType, allFileLoaded, onlineDocumentsLength, websitePagesLength, onlineDriveFileList.length, isVectorSpaceFull, enableBilling])
|
||||
|
||||
// Lookup table for next button disabled conditions
|
||||
const nextBtnDisabled = useMemo(() => {
|
||||
if (!datasource || !datasourceType)
|
||||
return true
|
||||
|
||||
const disabledConditions: Record<string, boolean> = {
|
||||
[DatasourceType.localFile]: isShowVectorSpaceFull || localFileListLength === 0 || !allFileLoaded,
|
||||
[DatasourceType.onlineDocument]: isShowVectorSpaceFull || onlineDocumentsLength === 0,
|
||||
[DatasourceType.websiteCrawl]: isShowVectorSpaceFull || websitePagesLength === 0,
|
||||
[DatasourceType.onlineDrive]: isShowVectorSpaceFull || selectedFileIdsLength === 0,
|
||||
}
|
||||
|
||||
return disabledConditions[datasourceType] ?? true
|
||||
}, [datasource, datasourceType, isShowVectorSpaceFull, localFileListLength, allFileLoaded, onlineDocumentsLength, websitePagesLength, selectedFileIdsLength])
|
||||
|
||||
// Check if select all should be shown
|
||||
const showSelect = useMemo(() => {
|
||||
if (datasourceType === DatasourceType.onlineDocument)
|
||||
return currentWorkspacePagesLength > 0
|
||||
|
||||
if (datasourceType === DatasourceType.onlineDrive) {
|
||||
const nonBucketItems = onlineDriveFileList.filter(item => item.type !== 'bucket')
|
||||
const isBucketList = onlineDriveFileList.some(file => file.type === 'bucket')
|
||||
return !isBucketList && nonBucketItems.length > 0
|
||||
}
|
||||
|
||||
return false
|
||||
}, [currentWorkspacePagesLength, datasourceType, onlineDriveFileList])
|
||||
|
||||
// Total selectable options count
|
||||
const totalOptions = useMemo(() => {
|
||||
if (datasourceType === DatasourceType.onlineDocument)
|
||||
return currentWorkspacePagesLength
|
||||
|
||||
if (datasourceType === DatasourceType.onlineDrive)
|
||||
return onlineDriveFileList.filter(item => item.type !== 'bucket').length
|
||||
|
||||
return undefined
|
||||
}, [currentWorkspacePagesLength, datasourceType, onlineDriveFileList])
|
||||
|
||||
// Selected options count
|
||||
const selectedOptions = useMemo(() => {
|
||||
if (datasourceType === DatasourceType.onlineDocument)
|
||||
return onlineDocumentsLength
|
||||
|
||||
if (datasourceType === DatasourceType.onlineDrive)
|
||||
return selectedFileIdsLength
|
||||
|
||||
return undefined
|
||||
}, [datasourceType, onlineDocumentsLength, selectedFileIdsLength])
|
||||
|
||||
// Tip message for selection
|
||||
const tip = useMemo(() => {
|
||||
if (datasourceType === DatasourceType.onlineDocument)
|
||||
return t('addDocuments.selectOnlineDocumentTip', { ns: 'datasetPipeline', count: 50 })
|
||||
|
||||
if (datasourceType === DatasourceType.onlineDrive) {
|
||||
return t('addDocuments.selectOnlineDriveTip', {
|
||||
ns: 'datasetPipeline',
|
||||
count: fileUploadConfig.batch_count_limit,
|
||||
fileSize: fileUploadConfig.file_size_limit,
|
||||
})
|
||||
}
|
||||
|
||||
return ''
|
||||
}, [datasourceType, fileUploadConfig.batch_count_limit, fileUploadConfig.file_size_limit, t])
|
||||
|
||||
return {
|
||||
datasourceType,
|
||||
isShowVectorSpaceFull,
|
||||
nextBtnDisabled,
|
||||
showSelect,
|
||||
totalOptions,
|
||||
selectedOptions,
|
||||
tip,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -2,75 +2,71 @@
|
||||
import type { Datasource } from '@/app/components/rag-pipeline/components/panel/test-run/types'
|
||||
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
|
||||
import type { Node } from '@/app/components/workflow/types'
|
||||
import type { NotionPage } from '@/models/common'
|
||||
import type { CrawlResultItem, DocumentItem, CustomFile as File, FileIndexingEstimateResponse } from '@/models/datasets'
|
||||
import type {
|
||||
InitialDocumentDetail,
|
||||
OnlineDriveFile,
|
||||
PublishedPipelineRunPreviewResponse,
|
||||
PublishedPipelineRunResponse,
|
||||
} from '@/models/pipeline'
|
||||
import type { FileIndexingEstimateResponse } from '@/models/datasets'
|
||||
import type { InitialDocumentDetail } from '@/models/pipeline'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import PlanUpgradeModal from '@/app/components/billing/plan-upgrade-modal'
|
||||
import VectorSpaceFull from '@/app/components/billing/vector-space-full'
|
||||
import LocalFile from '@/app/components/datasets/documents/create-from-pipeline/data-source/local-file'
|
||||
import OnlineDocuments from '@/app/components/datasets/documents/create-from-pipeline/data-source/online-documents'
|
||||
import OnlineDrive from '@/app/components/datasets/documents/create-from-pipeline/data-source/online-drive'
|
||||
import WebsiteCrawl from '@/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl'
|
||||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
import { useProviderContextSelector } from '@/context/provider-context'
|
||||
import { DatasourceType } from '@/models/pipeline'
|
||||
import { useFileUploadConfig } from '@/service/use-common'
|
||||
import { usePublishedPipelineInfo, useRunPublishedPipeline } from '@/service/use-pipeline'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import UpgradeCard from '../../create/step-one/upgrade-card'
|
||||
import Actions from './actions'
|
||||
import DataSourceOptions from './data-source-options'
|
||||
import { usePublishedPipelineInfo } from '@/service/use-pipeline'
|
||||
import { useDataSourceStore } from './data-source/store'
|
||||
import DataSourceProvider from './data-source/store/provider'
|
||||
import { useAddDocumentsSteps, useLocalFile, useOnlineDocument, useOnlineDrive, useWebsiteCrawl } from './hooks'
|
||||
import {
|
||||
useAddDocumentsSteps,
|
||||
useDatasourceActions,
|
||||
useDatasourceUIState,
|
||||
useLocalFile,
|
||||
useOnlineDocument,
|
||||
useOnlineDrive,
|
||||
useWebsiteCrawl,
|
||||
} from './hooks'
|
||||
import LeftHeader from './left-header'
|
||||
import ChunkPreview from './preview/chunk-preview'
|
||||
import FilePreview from './preview/file-preview'
|
||||
import OnlineDocumentPreview from './preview/online-document-preview'
|
||||
import WebsitePreview from './preview/web-preview'
|
||||
import ProcessDocuments from './process-documents'
|
||||
import Processing from './processing'
|
||||
import { StepOneContent, StepThreeContent, StepTwoContent } from './steps'
|
||||
import { StepOnePreview, StepTwoPreview } from './steps/preview-panel'
|
||||
|
||||
const CreateFormPipeline = () => {
|
||||
const { t } = useTranslation()
|
||||
const plan = useProviderContextSelector(state => state.plan)
|
||||
const enableBilling = useProviderContextSelector(state => state.enableBilling)
|
||||
const pipelineId = useDatasetDetailContextWithSelector(s => s.dataset?.pipeline_id)
|
||||
const dataSourceStore = useDataSourceStore()
|
||||
|
||||
// Core state
|
||||
const [datasource, setDatasource] = useState<Datasource>()
|
||||
const [estimateData, setEstimateData] = useState<FileIndexingEstimateResponse | undefined>(undefined)
|
||||
const [batchId, setBatchId] = useState('')
|
||||
const [documents, setDocuments] = useState<InitialDocumentDetail[]>([])
|
||||
const dataSourceStore = useDataSourceStore()
|
||||
|
||||
const isPreview = useRef(false)
|
||||
const formRef = useRef<any>(null)
|
||||
|
||||
// Data fetching
|
||||
const { data: pipelineInfo, isFetching: isFetchingPipelineInfo } = usePublishedPipelineInfo(pipelineId || '')
|
||||
const { data: fileUploadConfigResponse } = useFileUploadConfig()
|
||||
|
||||
const fileUploadConfig = useMemo(() => fileUploadConfigResponse ?? {
|
||||
file_size_limit: 15,
|
||||
batch_count_limit: 5,
|
||||
}, [fileUploadConfigResponse])
|
||||
|
||||
// Steps management
|
||||
const {
|
||||
steps,
|
||||
currentStep,
|
||||
handleNextStep: doHandleNextStep,
|
||||
handleBackStep,
|
||||
} = useAddDocumentsSteps()
|
||||
|
||||
// Datasource-specific hooks
|
||||
const {
|
||||
localFileList,
|
||||
allFileLoaded,
|
||||
currentLocalFile,
|
||||
hidePreviewLocalFile,
|
||||
} = useLocalFile()
|
||||
|
||||
const {
|
||||
currentWorkspace,
|
||||
onlineDocuments,
|
||||
@ -79,12 +75,14 @@ const CreateFormPipeline = () => {
|
||||
hidePreviewOnlineDocument,
|
||||
clearOnlineDocumentData,
|
||||
} = useOnlineDocument()
|
||||
|
||||
const {
|
||||
websitePages,
|
||||
currentWebsite,
|
||||
hideWebsitePreview,
|
||||
clearWebsiteCrawlData,
|
||||
} = useWebsiteCrawl()
|
||||
|
||||
const {
|
||||
onlineDriveFileList,
|
||||
selectedFileIds,
|
||||
@ -92,43 +90,50 @@ const CreateFormPipeline = () => {
|
||||
clearOnlineDriveData,
|
||||
} = useOnlineDrive()
|
||||
|
||||
const datasourceType = useMemo(() => datasource?.nodeData.provider_type, [datasource])
|
||||
// Computed values
|
||||
const isVectorSpaceFull = plan.usage.vectorSpace >= plan.total.vectorSpace
|
||||
const isShowVectorSpaceFull = useMemo(() => {
|
||||
if (!datasource)
|
||||
return false
|
||||
if (datasourceType === DatasourceType.localFile)
|
||||
return allFileLoaded && isVectorSpaceFull && enableBilling
|
||||
if (datasourceType === DatasourceType.onlineDocument)
|
||||
return onlineDocuments.length > 0 && isVectorSpaceFull && enableBilling
|
||||
if (datasourceType === DatasourceType.websiteCrawl)
|
||||
return websitePages.length > 0 && isVectorSpaceFull && enableBilling
|
||||
if (datasourceType === DatasourceType.onlineDrive)
|
||||
return onlineDriveFileList.length > 0 && isVectorSpaceFull && enableBilling
|
||||
return false
|
||||
}, [allFileLoaded, datasource, datasourceType, enableBilling, isVectorSpaceFull, onlineDocuments.length, onlineDriveFileList.length, websitePages.length])
|
||||
const supportBatchUpload = !enableBilling || plan.type !== 'sandbox'
|
||||
|
||||
// UI state
|
||||
const {
|
||||
datasourceType,
|
||||
isShowVectorSpaceFull,
|
||||
nextBtnDisabled,
|
||||
showSelect,
|
||||
totalOptions,
|
||||
selectedOptions,
|
||||
tip,
|
||||
} = useDatasourceUIState({
|
||||
datasource,
|
||||
allFileLoaded,
|
||||
localFileListLength: localFileList.length,
|
||||
onlineDocumentsLength: onlineDocuments.length,
|
||||
websitePagesLength: websitePages.length,
|
||||
selectedFileIdsLength: selectedFileIds.length,
|
||||
onlineDriveFileList,
|
||||
isVectorSpaceFull,
|
||||
enableBilling,
|
||||
currentWorkspacePagesLength: currentWorkspace?.pages.length ?? 0,
|
||||
fileUploadConfig,
|
||||
})
|
||||
|
||||
// Plan upgrade modal
|
||||
const [isShowPlanUpgradeModal, {
|
||||
setTrue: showPlanUpgradeModal,
|
||||
setFalse: hidePlanUpgradeModal,
|
||||
}] = useBoolean(false)
|
||||
|
||||
// Next step with batch upload check
|
||||
const handleNextStep = useCallback(() => {
|
||||
if (!supportBatchUpload) {
|
||||
let isMultiple = false
|
||||
if (datasourceType === DatasourceType.localFile && localFileList.length > 1)
|
||||
isMultiple = true
|
||||
|
||||
if (datasourceType === DatasourceType.onlineDocument && onlineDocuments.length > 1)
|
||||
isMultiple = true
|
||||
|
||||
if (datasourceType === DatasourceType.websiteCrawl && websitePages.length > 1)
|
||||
isMultiple = true
|
||||
|
||||
if (datasourceType === DatasourceType.onlineDrive && selectedFileIds.length > 1)
|
||||
isMultiple = true
|
||||
|
||||
if (isMultiple) {
|
||||
const multipleCheckMap: Record<string, number> = {
|
||||
[DatasourceType.localFile]: localFileList.length,
|
||||
[DatasourceType.onlineDocument]: onlineDocuments.length,
|
||||
[DatasourceType.websiteCrawl]: websitePages.length,
|
||||
[DatasourceType.onlineDrive]: selectedFileIds.length,
|
||||
}
|
||||
const count = datasourceType ? multipleCheckMap[datasourceType] : 0
|
||||
if (count > 1) {
|
||||
showPlanUpgradeModal()
|
||||
return
|
||||
}
|
||||
@ -136,334 +141,44 @@ const CreateFormPipeline = () => {
|
||||
doHandleNextStep()
|
||||
}, [datasourceType, doHandleNextStep, localFileList.length, onlineDocuments.length, selectedFileIds.length, showPlanUpgradeModal, supportBatchUpload, websitePages.length])
|
||||
|
||||
const nextBtnDisabled = useMemo(() => {
|
||||
if (!datasource)
|
||||
return true
|
||||
if (datasourceType === DatasourceType.localFile)
|
||||
return isShowVectorSpaceFull || !localFileList.length || !allFileLoaded
|
||||
if (datasourceType === DatasourceType.onlineDocument)
|
||||
return isShowVectorSpaceFull || !onlineDocuments.length
|
||||
if (datasourceType === DatasourceType.websiteCrawl)
|
||||
return isShowVectorSpaceFull || !websitePages.length
|
||||
if (datasourceType === DatasourceType.onlineDrive)
|
||||
return isShowVectorSpaceFull || !selectedFileIds.length
|
||||
return false
|
||||
}, [datasource, datasourceType, isShowVectorSpaceFull, localFileList.length, allFileLoaded, onlineDocuments.length, websitePages.length, selectedFileIds.length])
|
||||
// Datasource actions
|
||||
const {
|
||||
isPreview,
|
||||
formRef,
|
||||
isIdle,
|
||||
isPending,
|
||||
onClickProcess,
|
||||
onClickPreview,
|
||||
handleSubmit,
|
||||
handlePreviewFileChange,
|
||||
handlePreviewOnlineDocumentChange,
|
||||
handlePreviewWebsiteChange,
|
||||
handlePreviewOnlineDriveFileChange,
|
||||
handleSelectAll,
|
||||
handleSwitchDataSource,
|
||||
handleCredentialChange,
|
||||
} = useDatasourceActions({
|
||||
datasource,
|
||||
datasourceType,
|
||||
pipelineId,
|
||||
dataSourceStore,
|
||||
setEstimateData,
|
||||
setBatchId,
|
||||
setDocuments,
|
||||
handleNextStep,
|
||||
PagesMapAndSelectedPagesId,
|
||||
currentWorkspacePages: currentWorkspace?.pages,
|
||||
clearOnlineDocumentData,
|
||||
clearWebsiteCrawlData,
|
||||
clearOnlineDriveData,
|
||||
setDatasource,
|
||||
})
|
||||
|
||||
const fileUploadConfig = useMemo(() => fileUploadConfigResponse ?? {
|
||||
file_size_limit: 15,
|
||||
batch_count_limit: 5,
|
||||
}, [fileUploadConfigResponse])
|
||||
|
||||
const showSelect = useMemo(() => {
|
||||
if (datasourceType === DatasourceType.onlineDocument) {
|
||||
const pagesCount = currentWorkspace?.pages.length ?? 0
|
||||
return pagesCount > 0
|
||||
}
|
||||
if (datasourceType === DatasourceType.onlineDrive) {
|
||||
const isBucketList = onlineDriveFileList.some(file => file.type === 'bucket')
|
||||
return !isBucketList && onlineDriveFileList.filter((item) => {
|
||||
return item.type !== 'bucket'
|
||||
}).length > 0
|
||||
}
|
||||
return false
|
||||
}, [currentWorkspace?.pages.length, datasourceType, onlineDriveFileList])
|
||||
|
||||
const totalOptions = useMemo(() => {
|
||||
if (datasourceType === DatasourceType.onlineDocument)
|
||||
return currentWorkspace?.pages.length
|
||||
if (datasourceType === DatasourceType.onlineDrive) {
|
||||
return onlineDriveFileList.filter((item) => {
|
||||
return item.type !== 'bucket'
|
||||
}).length
|
||||
}
|
||||
}, [currentWorkspace?.pages.length, datasourceType, onlineDriveFileList])
|
||||
|
||||
const selectedOptions = useMemo(() => {
|
||||
if (datasourceType === DatasourceType.onlineDocument)
|
||||
return onlineDocuments.length
|
||||
if (datasourceType === DatasourceType.onlineDrive)
|
||||
return selectedFileIds.length
|
||||
}, [datasourceType, onlineDocuments.length, selectedFileIds.length])
|
||||
|
||||
const tip = useMemo(() => {
|
||||
if (datasourceType === DatasourceType.onlineDocument)
|
||||
return t('addDocuments.selectOnlineDocumentTip', { ns: 'datasetPipeline', count: 50 })
|
||||
if (datasourceType === DatasourceType.onlineDrive) {
|
||||
return t('addDocuments.selectOnlineDriveTip', {
|
||||
ns: 'datasetPipeline',
|
||||
count: fileUploadConfig.batch_count_limit,
|
||||
fileSize: fileUploadConfig.file_size_limit,
|
||||
})
|
||||
}
|
||||
return ''
|
||||
}, [datasourceType, fileUploadConfig.batch_count_limit, fileUploadConfig.file_size_limit, t])
|
||||
|
||||
const { mutateAsync: runPublishedPipeline, isIdle, isPending } = useRunPublishedPipeline()
|
||||
|
||||
const handlePreviewChunks = useCallback(async (data: Record<string, any>) => {
|
||||
if (!datasource)
|
||||
return
|
||||
const {
|
||||
previewLocalFileRef,
|
||||
previewOnlineDocumentRef,
|
||||
previewWebsitePageRef,
|
||||
previewOnlineDriveFileRef,
|
||||
currentCredentialId,
|
||||
} = dataSourceStore.getState()
|
||||
const datasourceInfoList: Record<string, any>[] = []
|
||||
if (datasourceType === DatasourceType.localFile) {
|
||||
const { id, name, type, size, extension, mime_type } = previewLocalFileRef.current as File
|
||||
const documentInfo = {
|
||||
related_id: id,
|
||||
name,
|
||||
type,
|
||||
size,
|
||||
extension,
|
||||
mime_type,
|
||||
url: '',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
credential_id: currentCredentialId,
|
||||
}
|
||||
datasourceInfoList.push(documentInfo)
|
||||
}
|
||||
if (datasourceType === DatasourceType.onlineDocument) {
|
||||
const { workspace_id, ...rest } = previewOnlineDocumentRef.current!
|
||||
const documentInfo = {
|
||||
workspace_id,
|
||||
page: rest,
|
||||
credential_id: currentCredentialId,
|
||||
}
|
||||
datasourceInfoList.push(documentInfo)
|
||||
}
|
||||
if (datasourceType === DatasourceType.websiteCrawl) {
|
||||
datasourceInfoList.push({
|
||||
...previewWebsitePageRef.current!,
|
||||
credential_id: currentCredentialId,
|
||||
})
|
||||
}
|
||||
if (datasourceType === DatasourceType.onlineDrive) {
|
||||
const { bucket } = dataSourceStore.getState()
|
||||
const { id, type, name } = previewOnlineDriveFileRef.current!
|
||||
datasourceInfoList.push({
|
||||
bucket,
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
credential_id: currentCredentialId,
|
||||
})
|
||||
}
|
||||
await runPublishedPipeline({
|
||||
pipeline_id: pipelineId!,
|
||||
inputs: data,
|
||||
start_node_id: datasource.nodeId,
|
||||
datasource_type: datasourceType as DatasourceType,
|
||||
datasource_info_list: datasourceInfoList,
|
||||
is_preview: true,
|
||||
}, {
|
||||
onSuccess: (res) => {
|
||||
setEstimateData((res as PublishedPipelineRunPreviewResponse).data.outputs)
|
||||
},
|
||||
})
|
||||
}, [datasource, datasourceType, runPublishedPipeline, pipelineId, dataSourceStore])
|
||||
|
||||
const handleProcess = useCallback(async (data: Record<string, any>) => {
|
||||
if (!datasource)
|
||||
return
|
||||
const { currentCredentialId } = dataSourceStore.getState()
|
||||
const datasourceInfoList: Record<string, any>[] = []
|
||||
if (datasourceType === DatasourceType.localFile) {
|
||||
const {
|
||||
localFileList,
|
||||
} = dataSourceStore.getState()
|
||||
localFileList.forEach((file) => {
|
||||
const { id, name, type, size, extension, mime_type } = file.file
|
||||
const documentInfo = {
|
||||
related_id: id,
|
||||
name,
|
||||
type,
|
||||
size,
|
||||
extension,
|
||||
mime_type,
|
||||
url: '',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
credential_id: currentCredentialId,
|
||||
}
|
||||
datasourceInfoList.push(documentInfo)
|
||||
})
|
||||
}
|
||||
if (datasourceType === DatasourceType.onlineDocument) {
|
||||
const {
|
||||
onlineDocuments,
|
||||
} = dataSourceStore.getState()
|
||||
onlineDocuments.forEach((page) => {
|
||||
const { workspace_id, ...rest } = page
|
||||
const documentInfo = {
|
||||
workspace_id,
|
||||
page: rest,
|
||||
credential_id: currentCredentialId,
|
||||
}
|
||||
datasourceInfoList.push(documentInfo)
|
||||
})
|
||||
}
|
||||
if (datasourceType === DatasourceType.websiteCrawl) {
|
||||
const {
|
||||
websitePages,
|
||||
} = dataSourceStore.getState()
|
||||
websitePages.forEach((websitePage) => {
|
||||
datasourceInfoList.push({
|
||||
...websitePage,
|
||||
credential_id: currentCredentialId,
|
||||
})
|
||||
})
|
||||
}
|
||||
if (datasourceType === DatasourceType.onlineDrive) {
|
||||
const {
|
||||
bucket,
|
||||
selectedFileIds,
|
||||
onlineDriveFileList,
|
||||
} = dataSourceStore.getState()
|
||||
selectedFileIds.forEach((id) => {
|
||||
const file = onlineDriveFileList.find(file => file.id === id)
|
||||
datasourceInfoList.push({
|
||||
bucket,
|
||||
id: file?.id,
|
||||
name: file?.name,
|
||||
type: file?.type,
|
||||
credential_id: currentCredentialId,
|
||||
})
|
||||
})
|
||||
}
|
||||
await runPublishedPipeline({
|
||||
pipeline_id: pipelineId!,
|
||||
inputs: data,
|
||||
start_node_id: datasource.nodeId,
|
||||
datasource_type: datasourceType as DatasourceType,
|
||||
datasource_info_list: datasourceInfoList,
|
||||
is_preview: false,
|
||||
}, {
|
||||
onSuccess: (res) => {
|
||||
setBatchId((res as PublishedPipelineRunResponse).batch || '')
|
||||
setDocuments((res as PublishedPipelineRunResponse).documents || [])
|
||||
handleNextStep()
|
||||
trackEvent('dataset_document_added', {
|
||||
data_source_type: datasourceType,
|
||||
indexing_technique: 'pipeline',
|
||||
})
|
||||
},
|
||||
})
|
||||
}, [dataSourceStore, datasource, datasourceType, handleNextStep, pipelineId, runPublishedPipeline])
|
||||
|
||||
const onClickProcess = useCallback(() => {
|
||||
isPreview.current = false
|
||||
formRef.current?.submit()
|
||||
}, [])
|
||||
|
||||
const onClickPreview = useCallback(() => {
|
||||
isPreview.current = true
|
||||
formRef.current?.submit()
|
||||
}, [])
|
||||
|
||||
const handleSubmit = useCallback((data: Record<string, any>) => {
|
||||
if (isPreview.current)
|
||||
handlePreviewChunks(data)
|
||||
else
|
||||
handleProcess(data)
|
||||
}, [handlePreviewChunks, handleProcess])
|
||||
|
||||
const handlePreviewFileChange = useCallback((file: DocumentItem) => {
|
||||
const { previewLocalFileRef } = dataSourceStore.getState()
|
||||
previewLocalFileRef.current = file
|
||||
onClickPreview()
|
||||
}, [dataSourceStore, onClickPreview])
|
||||
|
||||
const handlePreviewOnlineDocumentChange = useCallback((page: NotionPage) => {
|
||||
const { previewOnlineDocumentRef } = dataSourceStore.getState()
|
||||
previewOnlineDocumentRef.current = page
|
||||
onClickPreview()
|
||||
}, [dataSourceStore, onClickPreview])
|
||||
|
||||
const handlePreviewWebsiteChange = useCallback((website: CrawlResultItem) => {
|
||||
const { previewWebsitePageRef } = dataSourceStore.getState()
|
||||
previewWebsitePageRef.current = website
|
||||
onClickPreview()
|
||||
}, [dataSourceStore, onClickPreview])
|
||||
|
||||
const handlePreviewOnlineDriveFileChange = useCallback((file: OnlineDriveFile) => {
|
||||
const { previewOnlineDriveFileRef } = dataSourceStore.getState()
|
||||
previewOnlineDriveFileRef.current = file
|
||||
onClickPreview()
|
||||
}, [dataSourceStore, onClickPreview])
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
const {
|
||||
onlineDocuments,
|
||||
onlineDriveFileList,
|
||||
selectedFileIds,
|
||||
setOnlineDocuments,
|
||||
setSelectedFileIds,
|
||||
setSelectedPagesId,
|
||||
} = dataSourceStore.getState()
|
||||
if (datasourceType === DatasourceType.onlineDocument) {
|
||||
const allIds = currentWorkspace?.pages.map(page => page.page_id) || []
|
||||
if (onlineDocuments.length < allIds.length) {
|
||||
const selectedPages = Array.from(allIds).map(pageId => PagesMapAndSelectedPagesId[pageId])
|
||||
setOnlineDocuments(selectedPages)
|
||||
setSelectedPagesId(new Set(allIds))
|
||||
}
|
||||
else {
|
||||
setOnlineDocuments([])
|
||||
setSelectedPagesId(new Set())
|
||||
}
|
||||
}
|
||||
if (datasourceType === DatasourceType.onlineDrive) {
|
||||
const allKeys = onlineDriveFileList.filter((item) => {
|
||||
return item.type !== 'bucket'
|
||||
}).map(file => file.id)
|
||||
if (selectedFileIds.length < allKeys.length)
|
||||
setSelectedFileIds(allKeys)
|
||||
else
|
||||
setSelectedFileIds([])
|
||||
}
|
||||
}, [PagesMapAndSelectedPagesId, currentWorkspace?.pages, dataSourceStore, datasourceType])
|
||||
|
||||
const clearDataSourceData = useCallback((dataSource: Datasource) => {
|
||||
const providerType = dataSource.nodeData.provider_type
|
||||
if (providerType === DatasourceType.onlineDocument)
|
||||
clearOnlineDocumentData()
|
||||
else if (providerType === DatasourceType.websiteCrawl)
|
||||
clearWebsiteCrawlData()
|
||||
else if (providerType === DatasourceType.onlineDrive)
|
||||
clearOnlineDriveData()
|
||||
}, [clearOnlineDocumentData, clearOnlineDriveData, clearWebsiteCrawlData])
|
||||
|
||||
const handleSwitchDataSource = useCallback((dataSource: Datasource) => {
|
||||
const {
|
||||
setCurrentCredentialId,
|
||||
currentNodeIdRef,
|
||||
} = dataSourceStore.getState()
|
||||
clearDataSourceData(dataSource)
|
||||
setCurrentCredentialId('')
|
||||
currentNodeIdRef.current = dataSource.nodeId
|
||||
setDatasource(dataSource)
|
||||
}, [clearDataSourceData, dataSourceStore])
|
||||
|
||||
const handleCredentialChange = useCallback((credentialId: string) => {
|
||||
const { setCurrentCredentialId } = dataSourceStore.getState()
|
||||
clearDataSourceData(datasource!)
|
||||
setCurrentCredentialId(credentialId)
|
||||
}, [clearDataSourceData, dataSourceStore, datasource])
|
||||
|
||||
if (isFetchingPipelineInfo) {
|
||||
return (
|
||||
<Loading type="app" />
|
||||
)
|
||||
}
|
||||
if (isFetchingPipelineInfo)
|
||||
return <Loading type="app" />
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex h-[calc(100vh-56px)] w-full min-w-[1024px] overflow-x-auto rounded-t-2xl border-t border-effects-highlight bg-background-default-subtle"
|
||||
>
|
||||
<div className="relative flex h-[calc(100vh-56px)] w-full min-w-[1024px] overflow-x-auto rounded-t-2xl border-t border-effects-highlight bg-background-default-subtle">
|
||||
<div className="h-full min-w-0 flex-1">
|
||||
<div className="flex h-full flex-col px-14">
|
||||
<LeftHeader
|
||||
@ -472,139 +187,77 @@ const CreateFormPipeline = () => {
|
||||
currentStep={currentStep}
|
||||
/>
|
||||
<div className="grow overflow-y-auto">
|
||||
{
|
||||
currentStep === 1 && (
|
||||
<div className="flex flex-col gap-y-5 pt-4">
|
||||
<DataSourceOptions
|
||||
datasourceNodeId={datasource?.nodeId || ''}
|
||||
onSelect={handleSwitchDataSource}
|
||||
pipelineNodes={(pipelineInfo?.graph.nodes || []) as Node<DataSourceNodeType>[]}
|
||||
/>
|
||||
{datasourceType === DatasourceType.localFile && (
|
||||
<LocalFile
|
||||
allowedExtensions={datasource!.nodeData.fileExtensions || []}
|
||||
supportBatchUpload={supportBatchUpload}
|
||||
/>
|
||||
)}
|
||||
{datasourceType === DatasourceType.onlineDocument && (
|
||||
<OnlineDocuments
|
||||
nodeId={datasource!.nodeId}
|
||||
nodeData={datasource!.nodeData}
|
||||
onCredentialChange={handleCredentialChange}
|
||||
/>
|
||||
)}
|
||||
{datasourceType === DatasourceType.websiteCrawl && (
|
||||
<WebsiteCrawl
|
||||
nodeId={datasource!.nodeId}
|
||||
nodeData={datasource!.nodeData}
|
||||
onCredentialChange={handleCredentialChange}
|
||||
/>
|
||||
)}
|
||||
{datasourceType === DatasourceType.onlineDrive && (
|
||||
<OnlineDrive
|
||||
nodeId={datasource!.nodeId}
|
||||
nodeData={datasource!.nodeData}
|
||||
onCredentialChange={handleCredentialChange}
|
||||
/>
|
||||
)}
|
||||
{isShowVectorSpaceFull && (
|
||||
<VectorSpaceFull />
|
||||
)}
|
||||
<Actions
|
||||
showSelect={showSelect}
|
||||
totalOptions={totalOptions}
|
||||
selectedOptions={selectedOptions}
|
||||
onSelectAll={handleSelectAll}
|
||||
disabled={nextBtnDisabled}
|
||||
handleNextStep={handleNextStep}
|
||||
tip={tip}
|
||||
/>
|
||||
{
|
||||
!supportBatchUpload && datasourceType === DatasourceType.localFile && localFileList.length > 0 && (
|
||||
<>
|
||||
<Divider type="horizontal" className="my-4 h-px bg-divider-subtle" />
|
||||
<UpgradeCard />
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
currentStep === 2 && (
|
||||
<ProcessDocuments
|
||||
ref={formRef}
|
||||
dataSourceNodeId={datasource!.nodeId}
|
||||
isRunning={isPending}
|
||||
onProcess={onClickProcess}
|
||||
onPreview={onClickPreview}
|
||||
onSubmit={handleSubmit}
|
||||
onBack={handleBackStep}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
currentStep === 3 && (
|
||||
<Processing
|
||||
batchId={batchId}
|
||||
documents={documents}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{currentStep === 1 && (
|
||||
<StepOneContent
|
||||
datasource={datasource}
|
||||
datasourceType={datasourceType}
|
||||
pipelineNodes={(pipelineInfo?.graph.nodes || []) as Node<DataSourceNodeType>[]}
|
||||
supportBatchUpload={supportBatchUpload}
|
||||
localFileListLength={localFileList.length}
|
||||
isShowVectorSpaceFull={isShowVectorSpaceFull}
|
||||
showSelect={showSelect}
|
||||
totalOptions={totalOptions}
|
||||
selectedOptions={selectedOptions}
|
||||
tip={tip}
|
||||
nextBtnDisabled={nextBtnDisabled}
|
||||
onSelectDataSource={handleSwitchDataSource}
|
||||
onCredentialChange={handleCredentialChange}
|
||||
onSelectAll={handleSelectAll}
|
||||
onNextStep={handleNextStep}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 2 && (
|
||||
<StepTwoContent
|
||||
formRef={formRef}
|
||||
dataSourceNodeId={datasource!.nodeId}
|
||||
isRunning={isPending}
|
||||
onProcess={onClickProcess}
|
||||
onPreview={onClickPreview}
|
||||
onSubmit={handleSubmit}
|
||||
onBack={handleBackStep}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 3 && (
|
||||
<StepThreeContent
|
||||
batchId={batchId}
|
||||
documents={documents}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Preview */}
|
||||
{
|
||||
currentStep === 1 && (
|
||||
<div className="h-full min-w-0 flex-1">
|
||||
<div className="flex h-full flex-col pl-2 pt-2">
|
||||
{currentLocalFile && (
|
||||
<FilePreview
|
||||
file={currentLocalFile}
|
||||
hidePreview={hidePreviewLocalFile}
|
||||
/>
|
||||
)}
|
||||
{currentDocument && (
|
||||
<OnlineDocumentPreview
|
||||
datasourceNodeId={datasource!.nodeId}
|
||||
currentPage={currentDocument}
|
||||
hidePreview={hidePreviewOnlineDocument}
|
||||
/>
|
||||
)}
|
||||
{currentWebsite && (
|
||||
<WebsitePreview
|
||||
currentWebsite={currentWebsite}
|
||||
hidePreview={hideWebsitePreview}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
currentStep === 2 && (
|
||||
<div className="h-full min-w-0 flex-1">
|
||||
<div className="flex h-full flex-col pl-2 pt-2">
|
||||
<ChunkPreview
|
||||
dataSourceType={datasourceType as DatasourceType}
|
||||
localFiles={localFileList.map(file => file.file)}
|
||||
onlineDocuments={onlineDocuments}
|
||||
websitePages={websitePages}
|
||||
onlineDriveFiles={selectedOnlineDriveFileList}
|
||||
isIdle={isIdle}
|
||||
isPending={isPending && isPreview.current}
|
||||
estimateData={estimateData}
|
||||
onPreview={onClickPreview}
|
||||
handlePreviewFileChange={handlePreviewFileChange}
|
||||
handlePreviewOnlineDocumentChange={handlePreviewOnlineDocumentChange}
|
||||
handlePreviewWebsitePageChange={handlePreviewWebsiteChange}
|
||||
handlePreviewOnlineDriveFileChange={handlePreviewOnlineDriveFileChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{/* Preview Panel */}
|
||||
{currentStep === 1 && (
|
||||
<StepOnePreview
|
||||
datasource={datasource}
|
||||
currentLocalFile={currentLocalFile}
|
||||
currentDocument={currentDocument}
|
||||
currentWebsite={currentWebsite}
|
||||
hidePreviewLocalFile={hidePreviewLocalFile}
|
||||
hidePreviewOnlineDocument={hidePreviewOnlineDocument}
|
||||
hideWebsitePreview={hideWebsitePreview}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 2 && (
|
||||
<StepTwoPreview
|
||||
datasourceType={datasourceType}
|
||||
localFileList={localFileList}
|
||||
onlineDocuments={onlineDocuments}
|
||||
websitePages={websitePages}
|
||||
selectedOnlineDriveFileList={selectedOnlineDriveFileList}
|
||||
isIdle={isIdle}
|
||||
isPendingPreview={isPending && isPreview.current}
|
||||
estimateData={estimateData}
|
||||
onPreview={onClickPreview}
|
||||
handlePreviewFileChange={handlePreviewFileChange}
|
||||
handlePreviewOnlineDocumentChange={handlePreviewOnlineDocumentChange}
|
||||
handlePreviewWebsitePageChange={handlePreviewWebsiteChange}
|
||||
handlePreviewOnlineDriveFileChange={handlePreviewOnlineDriveFileChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Plan Upgrade Modal */}
|
||||
{isShowPlanUpgradeModal && (
|
||||
<PlanUpgradeModal
|
||||
show
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
export { default as StepOneContent } from './step-one-content'
|
||||
export { default as StepThreeContent } from './step-three-content'
|
||||
export { default as StepTwoContent } from './step-two-content'
|
||||
@ -0,0 +1,112 @@
|
||||
'use client'
|
||||
import type { Datasource } from '@/app/components/rag-pipeline/components/panel/test-run/types'
|
||||
import type { NotionPage } from '@/models/common'
|
||||
import type { CrawlResultItem, CustomFile, DocumentItem, FileIndexingEstimateResponse, FileItem } from '@/models/datasets'
|
||||
import type { DatasourceType, OnlineDriveFile } from '@/models/pipeline'
|
||||
import { memo } from 'react'
|
||||
import ChunkPreview from '../preview/chunk-preview'
|
||||
import FilePreview from '../preview/file-preview'
|
||||
import OnlineDocumentPreview from '../preview/online-document-preview'
|
||||
import WebsitePreview from '../preview/web-preview'
|
||||
|
||||
type StepOnePreviewProps = {
|
||||
datasource: Datasource | undefined
|
||||
currentLocalFile: CustomFile | undefined
|
||||
currentDocument: (NotionPage & { workspace_id: string }) | undefined
|
||||
currentWebsite: CrawlResultItem | undefined
|
||||
hidePreviewLocalFile: () => void
|
||||
hidePreviewOnlineDocument: () => void
|
||||
hideWebsitePreview: () => void
|
||||
}
|
||||
|
||||
export const StepOnePreview = memo(({
|
||||
datasource,
|
||||
currentLocalFile,
|
||||
currentDocument,
|
||||
currentWebsite,
|
||||
hidePreviewLocalFile,
|
||||
hidePreviewOnlineDocument,
|
||||
hideWebsitePreview,
|
||||
}: StepOnePreviewProps) => {
|
||||
return (
|
||||
<div className="h-full min-w-0 flex-1">
|
||||
<div className="flex h-full flex-col pl-2 pt-2">
|
||||
{currentLocalFile && (
|
||||
<FilePreview
|
||||
file={currentLocalFile}
|
||||
hidePreview={hidePreviewLocalFile}
|
||||
/>
|
||||
)}
|
||||
{currentDocument && (
|
||||
<OnlineDocumentPreview
|
||||
datasourceNodeId={datasource!.nodeId}
|
||||
currentPage={currentDocument}
|
||||
hidePreview={hidePreviewOnlineDocument}
|
||||
/>
|
||||
)}
|
||||
{currentWebsite && (
|
||||
<WebsitePreview
|
||||
currentWebsite={currentWebsite}
|
||||
hidePreview={hideWebsitePreview}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
StepOnePreview.displayName = 'StepOnePreview'
|
||||
|
||||
type StepTwoPreviewProps = {
|
||||
datasourceType: string | undefined
|
||||
localFileList: FileItem[]
|
||||
onlineDocuments: (NotionPage & { workspace_id: string })[]
|
||||
websitePages: CrawlResultItem[]
|
||||
selectedOnlineDriveFileList: OnlineDriveFile[]
|
||||
isIdle: boolean
|
||||
isPendingPreview: boolean
|
||||
estimateData: FileIndexingEstimateResponse | undefined
|
||||
onPreview: () => void
|
||||
handlePreviewFileChange: (file: DocumentItem) => void
|
||||
handlePreviewOnlineDocumentChange: (page: NotionPage) => void
|
||||
handlePreviewWebsitePageChange: (website: CrawlResultItem) => void
|
||||
handlePreviewOnlineDriveFileChange: (file: OnlineDriveFile) => void
|
||||
}
|
||||
|
||||
export const StepTwoPreview = memo(({
|
||||
datasourceType,
|
||||
localFileList,
|
||||
onlineDocuments,
|
||||
websitePages,
|
||||
selectedOnlineDriveFileList,
|
||||
isIdle,
|
||||
isPendingPreview,
|
||||
estimateData,
|
||||
onPreview,
|
||||
handlePreviewFileChange,
|
||||
handlePreviewOnlineDocumentChange,
|
||||
handlePreviewWebsitePageChange,
|
||||
handlePreviewOnlineDriveFileChange,
|
||||
}: StepTwoPreviewProps) => {
|
||||
return (
|
||||
<div className="h-full min-w-0 flex-1">
|
||||
<div className="flex h-full flex-col pl-2 pt-2">
|
||||
<ChunkPreview
|
||||
dataSourceType={datasourceType as DatasourceType}
|
||||
localFiles={localFileList.map(file => file.file)}
|
||||
onlineDocuments={onlineDocuments}
|
||||
websitePages={websitePages}
|
||||
onlineDriveFiles={selectedOnlineDriveFileList}
|
||||
isIdle={isIdle}
|
||||
isPending={isPendingPreview}
|
||||
estimateData={estimateData}
|
||||
onPreview={onPreview}
|
||||
handlePreviewFileChange={handlePreviewFileChange}
|
||||
handlePreviewOnlineDocumentChange={handlePreviewOnlineDocumentChange}
|
||||
handlePreviewWebsitePageChange={handlePreviewWebsitePageChange}
|
||||
handlePreviewOnlineDriveFileChange={handlePreviewOnlineDriveFileChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
StepTwoPreview.displayName = 'StepTwoPreview'
|
||||
@ -0,0 +1,110 @@
|
||||
'use client'
|
||||
import type { Datasource } from '@/app/components/rag-pipeline/components/panel/test-run/types'
|
||||
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
|
||||
import type { Node } from '@/app/components/workflow/types'
|
||||
import { memo } from 'react'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import VectorSpaceFull from '@/app/components/billing/vector-space-full'
|
||||
import LocalFile from '@/app/components/datasets/documents/create-from-pipeline/data-source/local-file'
|
||||
import OnlineDocuments from '@/app/components/datasets/documents/create-from-pipeline/data-source/online-documents'
|
||||
import OnlineDrive from '@/app/components/datasets/documents/create-from-pipeline/data-source/online-drive'
|
||||
import WebsiteCrawl from '@/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl'
|
||||
import { DatasourceType } from '@/models/pipeline'
|
||||
import UpgradeCard from '../../../create/step-one/upgrade-card'
|
||||
import Actions from '../actions'
|
||||
import DataSourceOptions from '../data-source-options'
|
||||
|
||||
type StepOneContentProps = {
|
||||
datasource: Datasource | undefined
|
||||
datasourceType: string | undefined
|
||||
pipelineNodes: Node<DataSourceNodeType>[]
|
||||
supportBatchUpload: boolean
|
||||
localFileListLength: number
|
||||
isShowVectorSpaceFull: boolean
|
||||
showSelect: boolean
|
||||
totalOptions: number | undefined
|
||||
selectedOptions: number | undefined
|
||||
tip: string
|
||||
nextBtnDisabled: boolean
|
||||
onSelectDataSource: (dataSource: Datasource) => void
|
||||
onCredentialChange: (credentialId: string) => void
|
||||
onSelectAll: () => void
|
||||
onNextStep: () => void
|
||||
}
|
||||
|
||||
const StepOneContent = ({
|
||||
datasource,
|
||||
datasourceType,
|
||||
pipelineNodes,
|
||||
supportBatchUpload,
|
||||
localFileListLength,
|
||||
isShowVectorSpaceFull,
|
||||
showSelect,
|
||||
totalOptions,
|
||||
selectedOptions,
|
||||
tip,
|
||||
nextBtnDisabled,
|
||||
onSelectDataSource,
|
||||
onCredentialChange,
|
||||
onSelectAll,
|
||||
onNextStep,
|
||||
}: StepOneContentProps) => {
|
||||
const showUpgradeCard = !supportBatchUpload
|
||||
&& datasourceType === DatasourceType.localFile
|
||||
&& localFileListLength > 0
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-5 pt-4">
|
||||
<DataSourceOptions
|
||||
datasourceNodeId={datasource?.nodeId || ''}
|
||||
onSelect={onSelectDataSource}
|
||||
pipelineNodes={pipelineNodes}
|
||||
/>
|
||||
{datasourceType === DatasourceType.localFile && (
|
||||
<LocalFile
|
||||
allowedExtensions={datasource!.nodeData.fileExtensions || []}
|
||||
supportBatchUpload={supportBatchUpload}
|
||||
/>
|
||||
)}
|
||||
{datasourceType === DatasourceType.onlineDocument && (
|
||||
<OnlineDocuments
|
||||
nodeId={datasource!.nodeId}
|
||||
nodeData={datasource!.nodeData}
|
||||
onCredentialChange={onCredentialChange}
|
||||
/>
|
||||
)}
|
||||
{datasourceType === DatasourceType.websiteCrawl && (
|
||||
<WebsiteCrawl
|
||||
nodeId={datasource!.nodeId}
|
||||
nodeData={datasource!.nodeData}
|
||||
onCredentialChange={onCredentialChange}
|
||||
/>
|
||||
)}
|
||||
{datasourceType === DatasourceType.onlineDrive && (
|
||||
<OnlineDrive
|
||||
nodeId={datasource!.nodeId}
|
||||
nodeData={datasource!.nodeData}
|
||||
onCredentialChange={onCredentialChange}
|
||||
/>
|
||||
)}
|
||||
{isShowVectorSpaceFull && <VectorSpaceFull />}
|
||||
<Actions
|
||||
showSelect={showSelect}
|
||||
totalOptions={totalOptions}
|
||||
selectedOptions={selectedOptions}
|
||||
onSelectAll={onSelectAll}
|
||||
disabled={nextBtnDisabled}
|
||||
handleNextStep={onNextStep}
|
||||
tip={tip}
|
||||
/>
|
||||
{showUpgradeCard && (
|
||||
<>
|
||||
<Divider type="horizontal" className="my-4 h-px bg-divider-subtle" />
|
||||
<UpgradeCard />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(StepOneContent)
|
||||
@ -0,0 +1,23 @@
|
||||
'use client'
|
||||
import type { InitialDocumentDetail } from '@/models/pipeline'
|
||||
import { memo } from 'react'
|
||||
import Processing from '../processing'
|
||||
|
||||
type StepThreeContentProps = {
|
||||
batchId: string
|
||||
documents: InitialDocumentDetail[]
|
||||
}
|
||||
|
||||
const StepThreeContent = ({
|
||||
batchId,
|
||||
documents,
|
||||
}: StepThreeContentProps) => {
|
||||
return (
|
||||
<Processing
|
||||
batchId={batchId}
|
||||
documents={documents}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(StepThreeContent)
|
||||
@ -0,0 +1,38 @@
|
||||
'use client'
|
||||
import type { RefObject } from 'react'
|
||||
import { memo } from 'react'
|
||||
import ProcessDocuments from '../process-documents'
|
||||
|
||||
type StepTwoContentProps = {
|
||||
formRef: RefObject<{ submit: () => void } | null>
|
||||
dataSourceNodeId: string
|
||||
isRunning: boolean
|
||||
onProcess: () => void
|
||||
onPreview: () => void
|
||||
onSubmit: (data: Record<string, unknown>) => void
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
const StepTwoContent = ({
|
||||
formRef,
|
||||
dataSourceNodeId,
|
||||
isRunning,
|
||||
onProcess,
|
||||
onPreview,
|
||||
onSubmit,
|
||||
onBack,
|
||||
}: StepTwoContentProps) => {
|
||||
return (
|
||||
<ProcessDocuments
|
||||
ref={formRef}
|
||||
dataSourceNodeId={dataSourceNodeId}
|
||||
isRunning={isRunning}
|
||||
onProcess={onProcess}
|
||||
onPreview={onPreview}
|
||||
onSubmit={onSubmit}
|
||||
onBack={onBack}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(StepTwoContent)
|
||||
@ -0,0 +1,63 @@
|
||||
import type { NotionPage } from '@/models/common'
|
||||
import type { CrawlResultItem, CustomFile as File } from '@/models/datasets'
|
||||
import type { OnlineDriveFile } from '@/models/pipeline'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
|
||||
/**
|
||||
* Build datasource info for local files
|
||||
*/
|
||||
export const buildLocalFileDatasourceInfo = (
|
||||
file: File,
|
||||
credentialId: string,
|
||||
): Record<string, unknown> => ({
|
||||
related_id: file.id,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
extension: file.extension,
|
||||
mime_type: file.mime_type,
|
||||
url: '',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
credential_id: credentialId,
|
||||
})
|
||||
|
||||
/**
|
||||
* Build datasource info for online documents
|
||||
*/
|
||||
export const buildOnlineDocumentDatasourceInfo = (
|
||||
page: NotionPage & { workspace_id: string },
|
||||
credentialId: string,
|
||||
): Record<string, unknown> => {
|
||||
const { workspace_id, ...rest } = page
|
||||
return {
|
||||
workspace_id,
|
||||
page: rest,
|
||||
credential_id: credentialId,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build datasource info for website crawl
|
||||
*/
|
||||
export const buildWebsiteCrawlDatasourceInfo = (
|
||||
page: CrawlResultItem,
|
||||
credentialId: string,
|
||||
): Record<string, unknown> => ({
|
||||
...page,
|
||||
credential_id: credentialId,
|
||||
})
|
||||
|
||||
/**
|
||||
* Build datasource info for online drive
|
||||
*/
|
||||
export const buildOnlineDriveDatasourceInfo = (
|
||||
file: OnlineDriveFile,
|
||||
bucket: string,
|
||||
credentialId: string,
|
||||
): Record<string, unknown> => ({
|
||||
bucket,
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
credential_id: credentialId,
|
||||
})
|
||||
@ -18,7 +18,7 @@ import { useDocumentDetail, useDocumentMetadata, useInvalidDocumentList } from '
|
||||
import { useCheckSegmentBatchImportProgress, useChildSegmentListKey, useSegmentBatchImport, useSegmentListKey } from '@/service/knowledge/use-segment'
|
||||
import { useInvalid } from '@/service/use-base'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Operations from '../operations'
|
||||
import Operations from '../components/operations'
|
||||
import StatusItem from '../status-item'
|
||||
import BatchModal from './batch-modal'
|
||||
import Completed from './completed'
|
||||
|
||||
@ -0,0 +1,197 @@
|
||||
import type { DocumentListResponse } from '@/models/datasets'
|
||||
import type { SortType } from '@/service/datasets'
|
||||
import { useDebounce, useDebounceFn } from 'ahooks'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { normalizeStatusForQuery, sanitizeStatusValue } from '../status-filter'
|
||||
import useDocumentListQueryState from './use-document-list-query-state'
|
||||
|
||||
/**
|
||||
* Custom hook to manage documents page state including:
|
||||
* - Search state (input value, debounced search value)
|
||||
* - Filter state (status filter, sort value)
|
||||
* - Pagination state (current page, limit)
|
||||
* - Selection state (selected document ids)
|
||||
* - Polling state (timer control for auto-refresh)
|
||||
*/
|
||||
export function useDocumentsPageState() {
|
||||
const { query, updateQuery } = useDocumentListQueryState()
|
||||
|
||||
// Search state
|
||||
const [inputValue, setInputValue] = useState<string>('')
|
||||
const [searchValue, setSearchValue] = useState<string>('')
|
||||
const debouncedSearchValue = useDebounce(searchValue, { wait: 500 })
|
||||
|
||||
// Filter & sort state
|
||||
const [statusFilterValue, setStatusFilterValue] = useState<string>(() => sanitizeStatusValue(query.status))
|
||||
const [sortValue, setSortValue] = useState<SortType>(query.sort)
|
||||
const normalizedStatusFilterValue = useMemo(
|
||||
() => normalizeStatusForQuery(statusFilterValue),
|
||||
[statusFilterValue],
|
||||
)
|
||||
|
||||
// Pagination state
|
||||
const [currPage, setCurrPage] = useState<number>(query.page - 1)
|
||||
const [limit, setLimit] = useState<number>(query.limit)
|
||||
|
||||
// Selection state
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||||
|
||||
// Polling state
|
||||
const [timerCanRun, setTimerCanRun] = useState(true)
|
||||
|
||||
// Initialize search value from URL on mount
|
||||
useEffect(() => {
|
||||
if (query.keyword) {
|
||||
setInputValue(query.keyword)
|
||||
setSearchValue(query.keyword)
|
||||
}
|
||||
}, []) // Only run on mount
|
||||
|
||||
// Sync local state with URL query changes
|
||||
useEffect(() => {
|
||||
setCurrPage(query.page - 1)
|
||||
setLimit(query.limit)
|
||||
if (query.keyword !== searchValue) {
|
||||
setInputValue(query.keyword)
|
||||
setSearchValue(query.keyword)
|
||||
}
|
||||
setStatusFilterValue((prev) => {
|
||||
const nextValue = sanitizeStatusValue(query.status)
|
||||
return prev === nextValue ? prev : nextValue
|
||||
})
|
||||
setSortValue(query.sort)
|
||||
}, [query])
|
||||
|
||||
// Update URL when search changes
|
||||
useEffect(() => {
|
||||
if (debouncedSearchValue !== query.keyword) {
|
||||
setCurrPage(0)
|
||||
updateQuery({ keyword: debouncedSearchValue, page: 1 })
|
||||
}
|
||||
}, [debouncedSearchValue, query.keyword, updateQuery])
|
||||
|
||||
// Clear selection when search changes
|
||||
useEffect(() => {
|
||||
if (searchValue !== query.keyword)
|
||||
setSelectedIds([])
|
||||
}, [searchValue, query.keyword])
|
||||
|
||||
// Clear selection when status filter changes
|
||||
useEffect(() => {
|
||||
setSelectedIds([])
|
||||
}, [normalizedStatusFilterValue])
|
||||
|
||||
// Page change handler
|
||||
const handlePageChange = useCallback((newPage: number) => {
|
||||
setCurrPage(newPage)
|
||||
updateQuery({ page: newPage + 1 })
|
||||
}, [updateQuery])
|
||||
|
||||
// Limit change handler
|
||||
const handleLimitChange = useCallback((newLimit: number) => {
|
||||
setLimit(newLimit)
|
||||
setCurrPage(0)
|
||||
updateQuery({ limit: newLimit, page: 1 })
|
||||
}, [updateQuery])
|
||||
|
||||
// Debounced search handler
|
||||
const { run: handleSearch } = useDebounceFn(() => {
|
||||
setSearchValue(inputValue)
|
||||
}, { wait: 500 })
|
||||
|
||||
// Input change handler
|
||||
const handleInputChange = useCallback((value: string) => {
|
||||
setInputValue(value)
|
||||
handleSearch()
|
||||
}, [handleSearch])
|
||||
|
||||
// Status filter change handler
|
||||
const handleStatusFilterChange = useCallback((value: string) => {
|
||||
const selectedValue = sanitizeStatusValue(value)
|
||||
setStatusFilterValue(selectedValue)
|
||||
setCurrPage(0)
|
||||
updateQuery({ status: selectedValue, page: 1 })
|
||||
}, [updateQuery])
|
||||
|
||||
// Status filter clear handler
|
||||
const handleStatusFilterClear = useCallback(() => {
|
||||
if (statusFilterValue === 'all')
|
||||
return
|
||||
setStatusFilterValue('all')
|
||||
setCurrPage(0)
|
||||
updateQuery({ status: 'all', page: 1 })
|
||||
}, [statusFilterValue, updateQuery])
|
||||
|
||||
// Sort change handler
|
||||
const handleSortChange = useCallback((value: string) => {
|
||||
const next = value as SortType
|
||||
if (next === sortValue)
|
||||
return
|
||||
setSortValue(next)
|
||||
setCurrPage(0)
|
||||
updateQuery({ sort: next, page: 1 })
|
||||
}, [sortValue, updateQuery])
|
||||
|
||||
// Update polling state based on documents response
|
||||
const updatePollingState = useCallback((documentsRes: DocumentListResponse | undefined) => {
|
||||
if (!documentsRes?.data)
|
||||
return
|
||||
|
||||
let completedNum = 0
|
||||
documentsRes.data.forEach((documentItem) => {
|
||||
const { indexing_status } = documentItem
|
||||
const isEmbedded = indexing_status === 'completed' || indexing_status === 'paused' || indexing_status === 'error'
|
||||
if (isEmbedded)
|
||||
completedNum++
|
||||
})
|
||||
|
||||
const hasIncompleteDocuments = completedNum !== documentsRes.data.length
|
||||
const transientStatuses = ['queuing', 'indexing', 'paused']
|
||||
const shouldForcePolling = normalizedStatusFilterValue === 'all'
|
||||
? false
|
||||
: transientStatuses.includes(normalizedStatusFilterValue)
|
||||
setTimerCanRun(shouldForcePolling || hasIncompleteDocuments)
|
||||
}, [normalizedStatusFilterValue])
|
||||
|
||||
// Adjust page when total pages change
|
||||
const adjustPageForTotal = useCallback((documentsRes: DocumentListResponse | undefined) => {
|
||||
if (!documentsRes)
|
||||
return
|
||||
const totalPages = Math.ceil(documentsRes.total / limit)
|
||||
if (currPage > 0 && currPage + 1 > totalPages)
|
||||
handlePageChange(totalPages > 0 ? totalPages - 1 : 0)
|
||||
}, [limit, currPage, handlePageChange])
|
||||
|
||||
return {
|
||||
// Search state
|
||||
inputValue,
|
||||
searchValue,
|
||||
debouncedSearchValue,
|
||||
handleInputChange,
|
||||
|
||||
// Filter & sort state
|
||||
statusFilterValue,
|
||||
sortValue,
|
||||
normalizedStatusFilterValue,
|
||||
handleStatusFilterChange,
|
||||
handleStatusFilterClear,
|
||||
handleSortChange,
|
||||
|
||||
// Pagination state
|
||||
currPage,
|
||||
limit,
|
||||
handlePageChange,
|
||||
handleLimitChange,
|
||||
|
||||
// Selection state
|
||||
selectedIds,
|
||||
setSelectedIds,
|
||||
|
||||
// Polling state
|
||||
timerCanRun,
|
||||
updatePollingState,
|
||||
adjustPageForTotal,
|
||||
}
|
||||
}
|
||||
|
||||
export default useDocumentsPageState
|
||||
@ -1,185 +1,55 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { Item } from '@/app/components/base/select'
|
||||
import type { SortType } from '@/service/datasets'
|
||||
import { PlusIcon } from '@heroicons/react/24/solid'
|
||||
import { RiDraftLine, RiExternalLinkLine } from '@remixicon/react'
|
||||
import { useDebounce, useDebounceFn } from 'ahooks'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import IndexFailed from '@/app/components/datasets/common/document-status-with-action/index-failed'
|
||||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { DataSourceType } from '@/models/datasets'
|
||||
import { useDocumentList, useInvalidDocumentDetail, useInvalidDocumentList } from '@/service/knowledge/use-document'
|
||||
import { useChildSegmentListKey, useSegmentListKey } from '@/service/knowledge/use-segment'
|
||||
import { useInvalid } from '@/service/use-base'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Chip from '../../base/chip'
|
||||
import Sort from '../../base/sort'
|
||||
import AutoDisabledDocument from '../common/document-status-with-action/auto-disabled-document'
|
||||
import StatusWithAction from '../common/document-status-with-action/status-with-action'
|
||||
import useEditDocumentMetadata from '../metadata/hooks/use-edit-dataset-metadata'
|
||||
import DatasetMetadataDrawer from '../metadata/metadata-dataset/dataset-metadata-drawer'
|
||||
import useDocumentListQueryState from './hooks/use-document-list-query-state'
|
||||
import List from './list'
|
||||
import { normalizeStatusForQuery, sanitizeStatusValue } from './status-filter'
|
||||
import { useIndexStatus } from './status-item/hooks'
|
||||
import s from './style.module.css'
|
||||
|
||||
const FolderPlusIcon = ({ className }: React.SVGProps<SVGElement>) => {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" className={className ?? ''}>
|
||||
<path d="M10.8332 5.83333L9.90355 3.9741C9.63601 3.439 9.50222 3.17144 9.30265 2.97597C9.12615 2.80311 8.91344 2.67164 8.6799 2.59109C8.41581 2.5 8.11668 2.5 7.51841 2.5H4.33317C3.39975 2.5 2.93304 2.5 2.57652 2.68166C2.26292 2.84144 2.00795 3.09641 1.84816 3.41002C1.6665 3.76654 1.6665 4.23325 1.6665 5.16667V5.83333M1.6665 5.83333H14.3332C15.7333 5.83333 16.4334 5.83333 16.9681 6.10582C17.4386 6.3455 17.821 6.72795 18.0607 7.19836C18.3332 7.73314 18.3332 8.4332 18.3332 9.83333V13.5C18.3332 14.9001 18.3332 15.6002 18.0607 16.135C17.821 16.6054 17.4386 16.9878 16.9681 17.2275C16.4334 17.5 15.7333 17.5 14.3332 17.5H5.6665C4.26637 17.5 3.56631 17.5 3.03153 17.2275C2.56112 16.9878 2.17867 16.6054 1.93899 16.135C1.6665 15.6002 1.6665 14.9001 1.6665 13.5V5.83333ZM9.99984 14.1667V9.16667M7.49984 11.6667H12.4998" stroke="#667085" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const ThreeDotsIcon = ({ className }: React.SVGProps<SVGElement>) => {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" className={className ?? ''}>
|
||||
<path d="M5 6.5V5M8.93934 7.56066L10 6.5M10.0103 11.5H11.5103" stroke="#374151" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const NotionIcon = ({ className }: React.SVGProps<SVGElement>) => {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" className={className ?? ''}>
|
||||
<g clipPath="url(#clip0_2164_11263)">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M3.5725 18.2611L1.4229 15.5832C0.905706 14.9389 0.625 14.1466 0.625 13.3312V3.63437C0.625 2.4129 1.60224 1.39936 2.86295 1.31328L12.8326 0.632614C13.5569 0.583164 14.2768 0.775682 14.8717 1.17794L18.3745 3.5462C19.0015 3.97012 19.375 4.66312 19.375 5.40266V16.427C19.375 17.6223 18.4141 18.6121 17.1798 18.688L6.11458 19.3692C5.12958 19.4298 4.17749 19.0148 3.5725 18.2611Z" fill="white" />
|
||||
<path d="M7.03006 8.48669V8.35974C7.03006 8.03794 7.28779 7.77104 7.61997 7.74886L10.0396 7.58733L13.3857 12.5147V8.19009L12.5244 8.07528V8.01498C12.5244 7.68939 12.788 7.42074 13.1244 7.4035L15.326 7.29073V7.60755C15.326 7.75628 15.2154 7.88349 15.0638 7.90913L14.534 7.99874V15.0023L13.8691 15.231C13.3136 15.422 12.6952 15.2175 12.3772 14.7377L9.12879 9.83574V14.5144L10.1287 14.7057L10.1147 14.7985C10.0711 15.089 9.82028 15.3087 9.51687 15.3222L7.03006 15.4329C6.99718 15.1205 7.23132 14.841 7.55431 14.807L7.88143 14.7727V8.53453L7.03006 8.48669Z" fill="black" />
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M12.9218 1.85424L2.95217 2.53491C2.35499 2.57568 1.89209 3.05578 1.89209 3.63437V13.3312C1.89209 13.8748 2.07923 14.403 2.42402 14.8325L4.57362 17.5104C4.92117 17.9434 5.46812 18.1818 6.03397 18.147L17.0991 17.4658C17.6663 17.4309 18.1078 16.9762 18.1078 16.427V5.40266C18.1078 5.06287 17.9362 4.74447 17.6481 4.54969L14.1453 2.18143C13.7883 1.94008 13.3564 1.82457 12.9218 1.85424ZM3.44654 3.78562C3.30788 3.68296 3.37387 3.46909 3.54806 3.4566L12.9889 2.77944C13.2897 2.75787 13.5886 2.8407 13.8318 3.01305L15.7261 4.35508C15.798 4.40603 15.7642 4.51602 15.6752 4.52086L5.67742 5.0646C5.37485 5.08106 5.0762 4.99217 4.83563 4.81406L3.44654 3.78562ZM5.20848 6.76919C5.20848 6.4444 5.47088 6.1761 5.80642 6.15783L16.3769 5.58216C16.7039 5.56435 16.9792 5.81583 16.9792 6.13239V15.6783C16.9792 16.0025 16.7177 16.2705 16.3829 16.2896L5.8793 16.8872C5.51537 16.9079 5.20848 16.6283 5.20848 16.2759V6.76919Z" fill="black" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2164_11263">
|
||||
<rect width="20" height="20" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const EmptyElement: FC<{ canAdd: boolean, onClick: () => void, type?: 'upload' | 'sync' }> = ({ canAdd = true, onClick, type = 'upload' }) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className={s.emptyWrapper}>
|
||||
<div className={s.emptyElement}>
|
||||
<div className={s.emptySymbolIconWrapper}>
|
||||
{type === 'upload' ? <FolderPlusIcon /> : <NotionIcon />}
|
||||
</div>
|
||||
<span className={s.emptyTitle}>
|
||||
{t('list.empty.title', { ns: 'datasetDocuments' })}
|
||||
<ThreeDotsIcon className="relative -left-1.5 -top-3 inline" />
|
||||
</span>
|
||||
<div className={s.emptyTip}>
|
||||
{t(`list.empty.${type}.tip`, { ns: 'datasetDocuments' })}
|
||||
</div>
|
||||
{type === 'upload' && canAdd && (
|
||||
<Button onClick={onClick} className={s.addFileBtn} variant="secondary-accent">
|
||||
<PlusIcon className={s.plusIcon} />
|
||||
{t('list.addFile', { ns: 'datasetDocuments' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import DocumentsHeader from './components/documents-header'
|
||||
import EmptyElement from './components/empty-element'
|
||||
import List from './components/list'
|
||||
import useDocumentsPageState from './hooks/use-documents-page-state'
|
||||
|
||||
type IDocumentsProps = {
|
||||
datasetId: string
|
||||
}
|
||||
|
||||
const Documents: FC<IDocumentsProps> = ({ datasetId }) => {
|
||||
const { t } = useTranslation()
|
||||
const docLink = useDocLink()
|
||||
const router = useRouter()
|
||||
const { plan } = useProviderContext()
|
||||
const isFreePlan = plan.type === 'sandbox'
|
||||
const { query, updateQuery } = useDocumentListQueryState()
|
||||
const [inputValue, setInputValue] = useState<string>('') // the input value
|
||||
const [searchValue, setSearchValue] = useState<string>('')
|
||||
const [statusFilterValue, setStatusFilterValue] = useState<string>(() => sanitizeStatusValue(query.status))
|
||||
const [sortValue, setSortValue] = useState<SortType>(query.sort)
|
||||
const DOC_INDEX_STATUS_MAP = useIndexStatus()
|
||||
const [currPage, setCurrPage] = React.useState<number>(query.page - 1) // Convert to 0-based index
|
||||
const [limit, setLimit] = useState<number>(query.limit)
|
||||
|
||||
const router = useRouter()
|
||||
const dataset = useDatasetDetailContextWithSelector(s => s.dataset)
|
||||
const [timerCanRun, setTimerCanRun] = useState(true)
|
||||
const isDataSourceNotion = dataset?.data_source_type === DataSourceType.NOTION
|
||||
const isDataSourceWeb = dataset?.data_source_type === DataSourceType.WEB
|
||||
const isDataSourceFile = dataset?.data_source_type === DataSourceType.FILE
|
||||
const embeddingAvailable = !!dataset?.embedding_available
|
||||
const debouncedSearchValue = useDebounce(searchValue, { wait: 500 })
|
||||
|
||||
const statusFilterItems: Item[] = useMemo(() => [
|
||||
{ value: 'all', name: t('list.index.all', { ns: 'datasetDocuments' }) as string },
|
||||
{ value: 'queuing', name: DOC_INDEX_STATUS_MAP.queuing.text },
|
||||
{ value: 'indexing', name: DOC_INDEX_STATUS_MAP.indexing.text },
|
||||
{ value: 'paused', name: DOC_INDEX_STATUS_MAP.paused.text },
|
||||
{ value: 'error', name: DOC_INDEX_STATUS_MAP.error.text },
|
||||
{ value: 'available', name: DOC_INDEX_STATUS_MAP.available.text },
|
||||
{ value: 'enabled', name: DOC_INDEX_STATUS_MAP.enabled.text },
|
||||
{ value: 'disabled', name: DOC_INDEX_STATUS_MAP.disabled.text },
|
||||
{ value: 'archived', name: DOC_INDEX_STATUS_MAP.archived.text },
|
||||
], [DOC_INDEX_STATUS_MAP, t])
|
||||
const normalizedStatusFilterValue = useMemo(() => normalizeStatusForQuery(statusFilterValue), [statusFilterValue])
|
||||
const sortItems: Item[] = useMemo(() => [
|
||||
{ value: 'created_at', name: t('list.sort.uploadTime', { ns: 'datasetDocuments' }) as string },
|
||||
{ value: 'hit_count', name: t('list.sort.hitCount', { ns: 'datasetDocuments' }) as string },
|
||||
], [t])
|
||||
|
||||
// Initialize search value from URL on mount
|
||||
useEffect(() => {
|
||||
if (query.keyword) {
|
||||
setInputValue(query.keyword)
|
||||
setSearchValue(query.keyword)
|
||||
}
|
||||
}, []) // Only run on mount
|
||||
|
||||
// Sync local state with URL query changes
|
||||
useEffect(() => {
|
||||
setCurrPage(query.page - 1)
|
||||
setLimit(query.limit)
|
||||
if (query.keyword !== searchValue) {
|
||||
setInputValue(query.keyword)
|
||||
setSearchValue(query.keyword)
|
||||
}
|
||||
setStatusFilterValue((prev) => {
|
||||
const nextValue = sanitizeStatusValue(query.status)
|
||||
return prev === nextValue ? prev : nextValue
|
||||
})
|
||||
setSortValue(query.sort)
|
||||
}, [query])
|
||||
|
||||
// Update URL when pagination changes
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setCurrPage(newPage)
|
||||
updateQuery({ page: newPage + 1 }) // Pagination emits 0-based page, convert to 1-based for URL
|
||||
}
|
||||
|
||||
// Update URL when limit changes
|
||||
const handleLimitChange = (newLimit: number) => {
|
||||
setLimit(newLimit)
|
||||
setCurrPage(0) // Reset to first page when limit changes
|
||||
updateQuery({ limit: newLimit, page: 1 })
|
||||
}
|
||||
|
||||
// Update URL when search changes
|
||||
useEffect(() => {
|
||||
if (debouncedSearchValue !== query.keyword) {
|
||||
setCurrPage(0) // Reset to first page when search changes
|
||||
updateQuery({ keyword: debouncedSearchValue, page: 1 })
|
||||
}
|
||||
}, [debouncedSearchValue, query.keyword, updateQuery])
|
||||
// Use custom hook for page state management
|
||||
const {
|
||||
inputValue,
|
||||
debouncedSearchValue,
|
||||
handleInputChange,
|
||||
statusFilterValue,
|
||||
sortValue,
|
||||
normalizedStatusFilterValue,
|
||||
handleStatusFilterChange,
|
||||
handleStatusFilterClear,
|
||||
handleSortChange,
|
||||
currPage,
|
||||
limit,
|
||||
handlePageChange,
|
||||
handleLimitChange,
|
||||
selectedIds,
|
||||
setSelectedIds,
|
||||
timerCanRun,
|
||||
updatePollingState,
|
||||
adjustPageForTotal,
|
||||
} = useDocumentsPageState()
|
||||
|
||||
// Fetch document list
|
||||
const { data: documentsRes, isLoading: isListLoading } = useDocumentList({
|
||||
datasetId,
|
||||
query: {
|
||||
@ -192,16 +62,18 @@ const Documents: FC<IDocumentsProps> = ({ datasetId }) => {
|
||||
refetchInterval: timerCanRun ? 2500 : 0,
|
||||
})
|
||||
|
||||
const invalidDocumentList = useInvalidDocumentList(datasetId)
|
||||
|
||||
// Update polling state when documents change
|
||||
useEffect(() => {
|
||||
if (documentsRes) {
|
||||
const totalPages = Math.ceil(documentsRes.total / limit)
|
||||
if (totalPages < currPage + 1)
|
||||
setCurrPage(totalPages === 0 ? 0 : totalPages - 1)
|
||||
}
|
||||
}, [documentsRes])
|
||||
updatePollingState(documentsRes)
|
||||
}, [documentsRes, updatePollingState])
|
||||
|
||||
// Adjust page when total changes
|
||||
useEffect(() => {
|
||||
adjustPageForTotal(documentsRes)
|
||||
}, [documentsRes, adjustPageForTotal])
|
||||
|
||||
// Invalidation hooks
|
||||
const invalidDocumentList = useInvalidDocumentList(datasetId)
|
||||
const invalidDocumentDetail = useInvalidDocumentDetail()
|
||||
const invalidChunkList = useInvalid(useSegmentListKey)
|
||||
const invalidChildChunkList = useInvalid(useChildSegmentListKey)
|
||||
@ -213,73 +85,9 @@ const Documents: FC<IDocumentsProps> = ({ datasetId }) => {
|
||||
invalidChunkList()
|
||||
invalidChildChunkList()
|
||||
}, 5000)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let completedNum = 0
|
||||
let percent = 0
|
||||
documentsRes?.data?.forEach((documentItem) => {
|
||||
const { indexing_status, completed_segments, total_segments } = documentItem
|
||||
const isEmbedded = indexing_status === 'completed' || indexing_status === 'paused' || indexing_status === 'error'
|
||||
|
||||
if (isEmbedded)
|
||||
completedNum++
|
||||
|
||||
const completedCount = completed_segments || 0
|
||||
const totalCount = total_segments || 0
|
||||
if (totalCount === 0 && completedCount === 0) {
|
||||
percent = isEmbedded ? 100 : 0
|
||||
}
|
||||
else {
|
||||
const per = Math.round(completedCount * 100 / totalCount)
|
||||
percent = per > 100 ? 100 : per
|
||||
}
|
||||
return {
|
||||
...documentItem,
|
||||
percent,
|
||||
}
|
||||
})
|
||||
|
||||
const hasIncompleteDocuments = completedNum !== documentsRes?.data?.length
|
||||
const transientStatuses = ['queuing', 'indexing', 'paused']
|
||||
const shouldForcePolling = normalizedStatusFilterValue === 'all'
|
||||
? false
|
||||
: transientStatuses.includes(normalizedStatusFilterValue)
|
||||
setTimerCanRun(shouldForcePolling || hasIncompleteDocuments)
|
||||
}, [documentsRes, normalizedStatusFilterValue])
|
||||
const total = documentsRes?.total || 0
|
||||
|
||||
const routeToDocCreate = () => {
|
||||
// if dataset is created from pipeline, go to create from pipeline page
|
||||
if (dataset?.runtime_mode === 'rag_pipeline') {
|
||||
router.push(`/datasets/${datasetId}/documents/create-from-pipeline`)
|
||||
return
|
||||
}
|
||||
router.push(`/datasets/${datasetId}/documents/create`)
|
||||
}
|
||||
|
||||
const documentsList = documentsRes?.data
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||||
|
||||
// Clear selection when search changes to avoid confusion
|
||||
useEffect(() => {
|
||||
if (searchValue !== query.keyword)
|
||||
setSelectedIds([])
|
||||
}, [searchValue, query.keyword])
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIds([])
|
||||
}, [normalizedStatusFilterValue])
|
||||
|
||||
const { run: handleSearch } = useDebounceFn(() => {
|
||||
setSearchValue(inputValue)
|
||||
}, { wait: 500 })
|
||||
|
||||
const handleInputChange = (value: string) => {
|
||||
setInputValue(value)
|
||||
handleSearch()
|
||||
}
|
||||
}, [invalidDocumentList, invalidDocumentDetail, invalidChunkList, invalidChildChunkList])
|
||||
|
||||
// Metadata editing hook
|
||||
const {
|
||||
isShowEditModal: isShowEditMetadataModal,
|
||||
showEditModal: showEditMetadataModal,
|
||||
@ -297,130 +105,84 @@ const Documents: FC<IDocumentsProps> = ({ datasetId }) => {
|
||||
onUpdateDocList: invalidDocumentList,
|
||||
})
|
||||
|
||||
// Route to document creation page
|
||||
const routeToDocCreate = useCallback(() => {
|
||||
if (dataset?.runtime_mode === 'rag_pipeline') {
|
||||
router.push(`/datasets/${datasetId}/documents/create-from-pipeline`)
|
||||
return
|
||||
}
|
||||
router.push(`/datasets/${datasetId}/documents/create`)
|
||||
}, [dataset?.runtime_mode, datasetId, router])
|
||||
|
||||
const total = documentsRes?.total || 0
|
||||
const documentsList = documentsRes?.data
|
||||
|
||||
// Render content based on loading and data state
|
||||
const renderContent = () => {
|
||||
if (isListLoading)
|
||||
return <Loading type="app" />
|
||||
|
||||
if (total > 0) {
|
||||
return (
|
||||
<List
|
||||
embeddingAvailable={embeddingAvailable}
|
||||
documents={documentsList || []}
|
||||
datasetId={datasetId}
|
||||
onUpdate={handleUpdate}
|
||||
selectedIds={selectedIds}
|
||||
onSelectedIdChange={setSelectedIds}
|
||||
statusFilterValue={normalizedStatusFilterValue}
|
||||
remoteSortValue={sortValue}
|
||||
pagination={{
|
||||
total,
|
||||
limit,
|
||||
onLimitChange: handleLimitChange,
|
||||
current: currPage,
|
||||
onChange: handlePageChange,
|
||||
}}
|
||||
onManageMetadata={showEditMetadataModal}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const isDataSourceNotion = dataset?.data_source_type === DataSourceType.NOTION
|
||||
return (
|
||||
<EmptyElement
|
||||
canAdd={embeddingAvailable}
|
||||
onClick={routeToDocCreate}
|
||||
type={isDataSourceNotion ? 'sync' : 'upload'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex flex-col justify-center gap-1 px-6 pt-4">
|
||||
<h1 className="text-base font-semibold text-text-primary">{t('list.title', { ns: 'datasetDocuments' })}</h1>
|
||||
<div className="flex items-center space-x-0.5 text-sm font-normal text-text-tertiary">
|
||||
<span>{t('list.desc', { ns: 'datasetDocuments' })}</span>
|
||||
<a
|
||||
className="flex items-center text-text-accent"
|
||||
target="_blank"
|
||||
href={docLink('/guides/knowledge-base/integrate-knowledge-within-application')}
|
||||
>
|
||||
<span>{t('list.learnMore', { ns: 'datasetDocuments' })}</span>
|
||||
<RiExternalLinkLine className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<DocumentsHeader
|
||||
datasetId={datasetId}
|
||||
dataSourceType={dataset?.data_source_type}
|
||||
embeddingAvailable={embeddingAvailable}
|
||||
isFreePlan={isFreePlan}
|
||||
statusFilterValue={statusFilterValue}
|
||||
sortValue={sortValue}
|
||||
inputValue={inputValue}
|
||||
onStatusFilterChange={handleStatusFilterChange}
|
||||
onStatusFilterClear={handleStatusFilterClear}
|
||||
onSortChange={handleSortChange}
|
||||
onInputChange={handleInputChange}
|
||||
isShowEditMetadataModal={isShowEditMetadataModal}
|
||||
showEditMetadataModal={showEditMetadataModal}
|
||||
hideEditMetadataModal={hideEditMetadataModal}
|
||||
datasetMetaData={datasetMetaData}
|
||||
builtInMetaData={builtInMetaData}
|
||||
builtInEnabled={!!builtInEnabled}
|
||||
onAddMetaData={handleAddMetaData}
|
||||
onRenameMetaData={handleRename}
|
||||
onDeleteMetaData={handleDeleteMetaData}
|
||||
onBuiltInEnabledChange={setBuiltInEnabled}
|
||||
onAddDocument={routeToDocCreate}
|
||||
/>
|
||||
<div className="flex h-0 grow flex-col px-6 pt-4">
|
||||
<div className="flex flex-wrap items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Chip
|
||||
className="w-[160px]"
|
||||
showLeftIcon={false}
|
||||
value={statusFilterValue}
|
||||
items={statusFilterItems}
|
||||
onSelect={(item) => {
|
||||
const selectedValue = sanitizeStatusValue(item?.value ? String(item.value) : '')
|
||||
setStatusFilterValue(selectedValue)
|
||||
setCurrPage(0)
|
||||
updateQuery({ status: selectedValue, page: 1 })
|
||||
}}
|
||||
onClear={() => {
|
||||
if (statusFilterValue === 'all')
|
||||
return
|
||||
setStatusFilterValue('all')
|
||||
setCurrPage(0)
|
||||
updateQuery({ status: 'all', page: 1 })
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
showLeftIcon
|
||||
showClearIcon
|
||||
wrapperClassName="!w-[200px]"
|
||||
value={inputValue}
|
||||
onChange={e => handleInputChange(e.target.value)}
|
||||
onClear={() => handleInputChange('')}
|
||||
/>
|
||||
<div className="h-3.5 w-px bg-divider-regular"></div>
|
||||
<Sort
|
||||
order={sortValue.startsWith('-') ? '-' : ''}
|
||||
value={sortValue.replace('-', '')}
|
||||
items={sortItems}
|
||||
onSelect={(value) => {
|
||||
const next = String(value) as SortType
|
||||
if (next === sortValue)
|
||||
return
|
||||
setSortValue(next)
|
||||
setCurrPage(0)
|
||||
updateQuery({ sort: next, page: 1 })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex !h-8 items-center justify-center gap-2">
|
||||
{!isFreePlan && <AutoDisabledDocument datasetId={datasetId} />}
|
||||
<IndexFailed datasetId={datasetId} />
|
||||
{!embeddingAvailable && <StatusWithAction type="warning" description={t('embeddingModelNotAvailable', { ns: 'dataset' })} />}
|
||||
{embeddingAvailable && (
|
||||
<Button variant="secondary" className="shrink-0" onClick={showEditMetadataModal}>
|
||||
<RiDraftLine className="mr-1 size-4" />
|
||||
{t('metadata.metadata', { ns: 'dataset' })}
|
||||
</Button>
|
||||
)}
|
||||
{isShowEditMetadataModal && (
|
||||
<DatasetMetadataDrawer
|
||||
userMetadata={datasetMetaData || []}
|
||||
onClose={hideEditMetadataModal}
|
||||
onAdd={handleAddMetaData}
|
||||
onRename={handleRename}
|
||||
onRemove={handleDeleteMetaData}
|
||||
builtInMetadata={builtInMetaData || []}
|
||||
isBuiltInEnabled={!!builtInEnabled}
|
||||
onIsBuiltInEnabledChange={setBuiltInEnabled}
|
||||
/>
|
||||
)}
|
||||
{embeddingAvailable && (
|
||||
<Button variant="primary" onClick={routeToDocCreate} className="shrink-0">
|
||||
<PlusIcon className={cn('mr-2 h-4 w-4 stroke-current')} />
|
||||
{isDataSourceNotion && t('list.addPages', { ns: 'datasetDocuments' })}
|
||||
{isDataSourceWeb && t('list.addUrl', { ns: 'datasetDocuments' })}
|
||||
{(!dataset?.data_source_type || isDataSourceFile) && t('list.addFile', { ns: 'datasetDocuments' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isListLoading
|
||||
? <Loading type="app" />
|
||||
// eslint-disable-next-line sonarjs/no-nested-conditional
|
||||
: total > 0
|
||||
? (
|
||||
<List
|
||||
embeddingAvailable={embeddingAvailable}
|
||||
documents={documentsList || []}
|
||||
datasetId={datasetId}
|
||||
onUpdate={handleUpdate}
|
||||
selectedIds={selectedIds}
|
||||
onSelectedIdChange={setSelectedIds}
|
||||
statusFilterValue={normalizedStatusFilterValue}
|
||||
remoteSortValue={sortValue}
|
||||
pagination={{
|
||||
total,
|
||||
limit,
|
||||
onLimitChange: handleLimitChange,
|
||||
current: currPage,
|
||||
onChange: handlePageChange,
|
||||
}}
|
||||
onManageMetadata={showEditMetadataModal}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<EmptyElement
|
||||
canAdd={embeddingAvailable}
|
||||
onClick={routeToDocCreate}
|
||||
type={isDataSourceNotion ? 'sync' : 'upload'}
|
||||
/>
|
||||
)}
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -30,7 +30,7 @@ const ApiBasedExtensionModal: FC<ApiBasedExtensionModalProps> = ({
|
||||
onSave,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const docLink = useDocLink()
|
||||
const docLink = useDocLink('https://docs.dify.ai/versions/3-0-x')
|
||||
const [localeData, setLocaleData] = useState(data)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { notify } = useToastContext()
|
||||
@ -102,7 +102,7 @@ const ApiBasedExtensionModal: FC<ApiBasedExtensionModalProps> = ({
|
||||
<div className="flex h-9 items-center justify-between text-sm font-medium text-text-primary">
|
||||
{t('apiBasedExtension.modal.apiEndpoint.title', { ns: 'common' })}
|
||||
<a
|
||||
href={docLink('/guides/extension/api-based-extension/README')}
|
||||
href={docLink('/user-guide/extension/api-based-extension/README#api-based-extension')}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-center text-xs font-normal text-text-accent"
|
||||
|
||||
@ -55,7 +55,6 @@ type FormProps<
|
||||
nodeId?: string
|
||||
nodeOutputVars?: NodeOutPutVar[]
|
||||
availableNodes?: Node[]
|
||||
canChooseMCPTool?: boolean
|
||||
}
|
||||
|
||||
function Form<
|
||||
@ -81,7 +80,6 @@ function Form<
|
||||
nodeId,
|
||||
nodeOutputVars,
|
||||
availableNodes,
|
||||
canChooseMCPTool,
|
||||
}: FormProps<CustomFormSchema>) {
|
||||
const language = useLanguage()
|
||||
const [changeKey, setChangeKey] = useState('')
|
||||
@ -407,7 +405,6 @@ function Form<
|
||||
value={value[variable] || []}
|
||||
onChange={item => handleFormChange(variable, item as any)}
|
||||
supportCollapse
|
||||
canChooseMCPTool={canChooseMCPTool}
|
||||
/>
|
||||
{fieldMoreInfo?.(formSchema)}
|
||||
{validating && changeKey === variable && <ValidatingTip />}
|
||||
|
||||
@ -2,9 +2,9 @@
|
||||
|
||||
import type { INavSelectorProps } from './nav-selector'
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useSearchParams, useSelectedLayoutSegment } from 'next/navigation'
|
||||
import { useSelectedLayoutSegment } from 'next/navigation'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { ArrowNarrowLeft } from '@/app/components/base/icons/src/vender/line/arrows'
|
||||
import { cn } from '@/utils/classnames'
|
||||
@ -36,14 +36,6 @@ const Nav = ({
|
||||
const [hovered, setHovered] = useState(false)
|
||||
const segment = useSelectedLayoutSegment()
|
||||
const isActivated = Array.isArray(activeSegment) ? activeSegment.includes(segment!) : segment === activeSegment
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const [linkLastSearchParams, setLinkLastSearchParams] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname === link)
|
||||
setLinkLastSearchParams(searchParams.toString())
|
||||
}, [pathname, searchParams])
|
||||
|
||||
return (
|
||||
<div className={`
|
||||
@ -52,7 +44,7 @@ const Nav = ({
|
||||
${!curNav && !isActivated && 'hover:bg-components-main-nav-nav-button-bg-hover'}
|
||||
`}
|
||||
>
|
||||
<Link href={link + (linkLastSearchParams && `?${linkLastSearchParams}`)}>
|
||||
<Link href={link}>
|
||||
<div
|
||||
onClick={(e) => {
|
||||
// Don't clear state if opening in new tab/window
|
||||
|
||||
@ -897,6 +897,58 @@ describe('Icon', () => {
|
||||
const iconDiv = container.firstChild as HTMLElement
|
||||
expect(iconDiv).toHaveStyle({ backgroundImage: 'url(/icon?name=test&size=large)' })
|
||||
})
|
||||
|
||||
it('should not render status indicators when src is object with installed=true', () => {
|
||||
render(<Icon src={{ content: '🎉', background: '#fff' }} installed={true} />)
|
||||
|
||||
// Status indicators should not render for object src
|
||||
expect(screen.queryByTestId('ri-check-line')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render status indicators when src is object with installFailed=true', () => {
|
||||
render(<Icon src={{ content: '🎉', background: '#fff' }} installFailed={true} />)
|
||||
|
||||
// Status indicators should not render for object src
|
||||
expect(screen.queryByTestId('ri-close-line')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render object src with all size variants', () => {
|
||||
const sizes: Array<'xs' | 'tiny' | 'small' | 'medium' | 'large'> = ['xs', 'tiny', 'small', 'medium', 'large']
|
||||
|
||||
sizes.forEach((size) => {
|
||||
const { unmount } = render(<Icon src={{ content: '🔗', background: '#fff' }} size={size} />)
|
||||
expect(screen.getByTestId('app-icon')).toHaveAttribute('data-size', size)
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render object src with custom className', () => {
|
||||
const { container } = render(
|
||||
<Icon src={{ content: '🎉', background: '#fff' }} className="custom-object-icon" />,
|
||||
)
|
||||
|
||||
expect(container.querySelector('.custom-object-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass correct props to AppIcon for object src', () => {
|
||||
render(<Icon src={{ content: '😀', background: '#123456' }} />)
|
||||
|
||||
const appIcon = screen.getByTestId('app-icon')
|
||||
expect(appIcon).toHaveAttribute('data-icon', '😀')
|
||||
expect(appIcon).toHaveAttribute('data-background', '#123456')
|
||||
expect(appIcon).toHaveAttribute('data-icon-type', 'emoji')
|
||||
})
|
||||
|
||||
it('should render inner icon only when shouldUseMcpIcon returns true', () => {
|
||||
// Test with MCP icon content
|
||||
const { unmount } = render(<Icon src={{ content: '🔗', background: '#fff' }} />)
|
||||
expect(screen.getByTestId('inner-icon')).toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
// Test without MCP icon content
|
||||
render(<Icon src={{ content: '🎉', background: '#fff' }} />)
|
||||
expect(screen.queryByTestId('inner-icon')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -180,7 +180,14 @@ const AppPicker: FC<Props> = ({
|
||||
background={app.icon_background}
|
||||
imageUrl={app.icon_url}
|
||||
/>
|
||||
<div title={app.name} className="system-sm-medium grow text-components-input-text-filled">{app.name}</div>
|
||||
<div title={`${app.name} (${app.id})`} className="system-sm-medium grow text-components-input-text-filled">
|
||||
<span className="mr-1">{app.name}</span>
|
||||
<span className="text-text-tertiary">
|
||||
(
|
||||
{app.id.slice(0, 8)}
|
||||
)
|
||||
</span>
|
||||
</div>
|
||||
<div className="system-2xs-medium-uppercase shrink-0 text-text-tertiary">{getAppType(app)}</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@ -16,7 +16,7 @@ import {
|
||||
import AppInputsPanel from '@/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-panel'
|
||||
import AppPicker from '@/app/components/plugins/plugin-detail-panel/app-selector/app-picker'
|
||||
import AppTrigger from '@/app/components/plugins/plugin-detail-panel/app-selector/app-trigger'
|
||||
import { useInfiniteAppList } from '@/service/use-apps'
|
||||
import { useAppDetail, useInfiniteAppList } from '@/service/use-apps'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
@ -70,6 +70,30 @@ const AppSelector: FC<Props> = ({
|
||||
return pages.flatMap(({ data: apps }) => apps)
|
||||
}, [pages])
|
||||
|
||||
// fetch selected app by id to avoid pagination gaps
|
||||
const { data: selectedAppDetail } = useAppDetail(value?.app_id || '')
|
||||
|
||||
// Ensure the currently selected app is available for display and in the picker options
|
||||
const currentAppInfo = useMemo(() => {
|
||||
if (!value?.app_id)
|
||||
return undefined
|
||||
return selectedAppDetail || displayedApps.find(app => app.id === value.app_id)
|
||||
}, [value?.app_id, selectedAppDetail, displayedApps])
|
||||
|
||||
const appsForPicker = useMemo(() => {
|
||||
if (!currentAppInfo)
|
||||
return displayedApps
|
||||
|
||||
const appIndex = displayedApps.findIndex(a => a.id === currentAppInfo.id)
|
||||
|
||||
if (appIndex === -1)
|
||||
return [currentAppInfo, ...displayedApps]
|
||||
|
||||
const updatedApps = [...displayedApps]
|
||||
updatedApps[appIndex] = currentAppInfo
|
||||
return updatedApps
|
||||
}, [currentAppInfo, displayedApps])
|
||||
|
||||
const hasMore = hasNextPage ?? true
|
||||
|
||||
const handleLoadMore = useCallback(async () => {
|
||||
@ -127,12 +151,6 @@ const AppSelector: FC<Props> = ({
|
||||
}
|
||||
}, [value])
|
||||
|
||||
const currentAppInfo = useMemo(() => {
|
||||
if (!displayedApps || !value)
|
||||
return undefined
|
||||
return displayedApps.find(app => app.id === value.app_id)
|
||||
}, [displayedApps, value])
|
||||
|
||||
return (
|
||||
<>
|
||||
<PortalToFollowElem
|
||||
@ -168,7 +186,7 @@ const AppSelector: FC<Props> = ({
|
||||
disabled={false}
|
||||
onSelect={handleSelectApp}
|
||||
scope={scope || 'all'}
|
||||
apps={displayedApps}
|
||||
apps={appsForPicker}
|
||||
isLoading={isLoading || isLoadingMore || isFetchingNextPage}
|
||||
hasMore={hasMore}
|
||||
onLoadMore={handleLoadMore}
|
||||
|
||||
@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// ==================== Imports (after mocks) ====================
|
||||
|
||||
import { MCPToolAvailabilityProvider } from '@/app/components/workflow/nodes/_base/components/mcp-tool-availability'
|
||||
import MultipleToolSelector from './index'
|
||||
|
||||
// ==================== Mock Setup ====================
|
||||
@ -190,10 +191,11 @@ type RenderOptions = {
|
||||
nodeOutputVars?: NodeOutPutVar[]
|
||||
availableNodes?: Node[]
|
||||
nodeId?: string
|
||||
canChooseMCPTool?: boolean
|
||||
versionSupported?: boolean
|
||||
}
|
||||
|
||||
const renderComponent = (options: RenderOptions = {}) => {
|
||||
const { versionSupported, ...overrides } = options
|
||||
const defaultProps = {
|
||||
disabled: false,
|
||||
value: [],
|
||||
@ -206,16 +208,17 @@ const renderComponent = (options: RenderOptions = {}) => {
|
||||
nodeOutputVars: [createNodeOutputVar()],
|
||||
availableNodes: [createNode()],
|
||||
nodeId: 'test-node-id',
|
||||
canChooseMCPTool: false,
|
||||
}
|
||||
|
||||
const props = { ...defaultProps, ...options }
|
||||
const props = { ...defaultProps, ...overrides }
|
||||
const queryClient = createQueryClient()
|
||||
|
||||
return {
|
||||
...render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MultipleToolSelector {...props} />
|
||||
<MCPToolAvailabilityProvider versionSupported={versionSupported}>
|
||||
<MultipleToolSelector {...props} />
|
||||
</MCPToolAvailabilityProvider>
|
||||
</QueryClientProvider>,
|
||||
),
|
||||
props,
|
||||
@ -410,7 +413,7 @@ describe('MultipleToolSelector', () => {
|
||||
expect(screen.getByText('2/3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should track enabled count with MCP tools when canChooseMCPTool is true', () => {
|
||||
it('should track enabled count with MCP tools when version is supported', () => {
|
||||
// Arrange
|
||||
const mcpTools = [createMCPTool({ id: 'mcp-provider' })]
|
||||
mockMCPToolsData.mockReturnValue(mcpTools)
|
||||
@ -421,13 +424,13 @@ describe('MultipleToolSelector', () => {
|
||||
]
|
||||
|
||||
// Act
|
||||
renderComponent({ value: tools, canChooseMCPTool: true })
|
||||
renderComponent({ value: tools, versionSupported: true })
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('2/2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not count MCP tools when canChooseMCPTool is false', () => {
|
||||
it('should not count MCP tools when version is unsupported', () => {
|
||||
// Arrange
|
||||
const mcpTools = [createMCPTool({ id: 'mcp-provider' })]
|
||||
mockMCPToolsData.mockReturnValue(mcpTools)
|
||||
@ -438,7 +441,7 @@ describe('MultipleToolSelector', () => {
|
||||
]
|
||||
|
||||
// Act
|
||||
renderComponent({ value: tools, canChooseMCPTool: false })
|
||||
renderComponent({ value: tools, versionSupported: false })
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('1/2')).toBeInTheDocument()
|
||||
@ -721,14 +724,6 @@ describe('MultipleToolSelector', () => {
|
||||
expect(screen.getByTestId('tool-selector-add')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass canChooseMCPTool prop correctly', () => {
|
||||
// Arrange & Act
|
||||
renderComponent({ canChooseMCPTool: true })
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('tool-selector-add')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with supportEnableSwitch for edit selectors', () => {
|
||||
// Arrange
|
||||
const tools = [createToolValue()]
|
||||
@ -771,13 +766,13 @@ describe('MultipleToolSelector', () => {
|
||||
]
|
||||
|
||||
// Act
|
||||
renderComponent({ value: tools, canChooseMCPTool: true })
|
||||
renderComponent({ value: tools, versionSupported: true })
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('2/2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should exclude MCP tools from enabled count when canChooseMCPTool is false', () => {
|
||||
it('should exclude MCP tools from enabled count when strategy version is unsupported', () => {
|
||||
// Arrange
|
||||
const mcpTools = [createMCPTool({ id: 'mcp-provider' })]
|
||||
mockMCPToolsData.mockReturnValue(mcpTools)
|
||||
@ -788,7 +783,7 @@ describe('MultipleToolSelector', () => {
|
||||
]
|
||||
|
||||
// Act
|
||||
renderComponent({ value: tools, canChooseMCPTool: false })
|
||||
renderComponent({ value: tools, versionSupported: false })
|
||||
|
||||
// Assert - Only regular tool should be counted
|
||||
expect(screen.getByText('1/2')).toBeInTheDocument()
|
||||
|
||||
@ -12,6 +12,7 @@ import Divider from '@/app/components/base/divider'
|
||||
import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid/general'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import ToolSelector from '@/app/components/plugins/plugin-detail-panel/tool-selector'
|
||||
import { useMCPToolAvailability } from '@/app/components/workflow/nodes/_base/components/mcp-tool-availability'
|
||||
import { useAllMCPTools } from '@/service/use-tools'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
@ -27,7 +28,6 @@ type Props = {
|
||||
nodeOutputVars: NodeOutPutVar[]
|
||||
availableNodes: Node[]
|
||||
nodeId?: string
|
||||
canChooseMCPTool?: boolean
|
||||
}
|
||||
|
||||
const MultipleToolSelector = ({
|
||||
@ -42,14 +42,14 @@ const MultipleToolSelector = ({
|
||||
nodeOutputVars,
|
||||
availableNodes,
|
||||
nodeId,
|
||||
canChooseMCPTool,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const { allowed: isMCPToolAllowed } = useMCPToolAvailability()
|
||||
const { data: mcpTools } = useAllMCPTools()
|
||||
const enabledCount = value.filter((item) => {
|
||||
const isMCPTool = mcpTools?.find(tool => tool.id === item.provider_name)
|
||||
if (isMCPTool)
|
||||
return item.enabled && canChooseMCPTool
|
||||
return item.enabled && isMCPToolAllowed
|
||||
return item.enabled
|
||||
}).length
|
||||
// collapse control
|
||||
@ -167,7 +167,6 @@ const MultipleToolSelector = ({
|
||||
onSelectMultiple={handleAddMultiple}
|
||||
onDelete={() => handleDelete(index)}
|
||||
supportEnableSwitch
|
||||
canChooseMCPTool={canChooseMCPTool}
|
||||
isEdit
|
||||
/>
|
||||
</div>
|
||||
@ -190,7 +189,6 @@ const MultipleToolSelector = ({
|
||||
panelShowState={panelShowState}
|
||||
onPanelShowStateChange={setPanelShowState}
|
||||
isEdit={false}
|
||||
canChooseMCPTool={canChooseMCPTool}
|
||||
onSelectMultiple={handleAddMultiple}
|
||||
/>
|
||||
</>
|
||||
|
||||
@ -64,7 +64,6 @@ type Props = {
|
||||
nodeOutputVars: NodeOutPutVar[]
|
||||
availableNodes: Node[]
|
||||
nodeId?: string
|
||||
canChooseMCPTool?: boolean
|
||||
}
|
||||
const ToolSelector: FC<Props> = ({
|
||||
value,
|
||||
@ -86,7 +85,6 @@ const ToolSelector: FC<Props> = ({
|
||||
nodeOutputVars,
|
||||
availableNodes,
|
||||
nodeId = '',
|
||||
canChooseMCPTool,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [isShow, onShowChange] = useState(false)
|
||||
@ -267,7 +265,6 @@ const ToolSelector: FC<Props> = ({
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
canChooseMCPTool={canChooseMCPTool}
|
||||
/>
|
||||
)}
|
||||
</PortalToFollowElemTrigger>
|
||||
@ -300,7 +297,6 @@ const ToolSelector: FC<Props> = ({
|
||||
onSelectMultiple={handleSelectMultipleTool}
|
||||
scope={scope}
|
||||
selectedTools={selectedTools}
|
||||
canChooseMCPTool={canChooseMCPTool}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
|
||||
@ -16,6 +16,7 @@ import Tooltip from '@/app/components/base/tooltip'
|
||||
import { ToolTipContent } from '@/app/components/base/tooltip/content'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
import { InstallPluginButton } from '@/app/components/workflow/nodes/_base/components/install-plugin-button'
|
||||
import { useMCPToolAvailability } from '@/app/components/workflow/nodes/_base/components/mcp-tool-availability'
|
||||
import McpToolNotSupportTooltip from '@/app/components/workflow/nodes/_base/components/mcp-tool-not-support-tooltip'
|
||||
import { SwitchPluginVersion } from '@/app/components/workflow/nodes/_base/components/switch-plugin-version'
|
||||
import { cn } from '@/utils/classnames'
|
||||
@ -39,7 +40,6 @@ type Props = {
|
||||
versionMismatch?: boolean
|
||||
open: boolean
|
||||
authRemoved?: boolean
|
||||
canChooseMCPTool?: boolean
|
||||
}
|
||||
|
||||
const ToolItem = ({
|
||||
@ -61,13 +61,13 @@ const ToolItem = ({
|
||||
errorTip,
|
||||
versionMismatch,
|
||||
authRemoved,
|
||||
canChooseMCPTool,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const { allowed: isMCPToolAllowed } = useMCPToolAvailability()
|
||||
const providerNameText = isMCPTool ? providerShowName : providerName?.split('/').pop()
|
||||
const isTransparent = uninstalled || versionMismatch || isError
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const isShowCanNotChooseMCPTip = isMCPTool && !canChooseMCPTool
|
||||
const isShowCanNotChooseMCPTip = isMCPTool && !isMCPToolAllowed
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
@ -125,9 +125,7 @@ const ToolItem = ({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isShowCanNotChooseMCPTip && (
|
||||
<McpToolNotSupportTooltip />
|
||||
)}
|
||||
{isShowCanNotChooseMCPTip && <McpToolNotSupportTooltip />}
|
||||
{!isError && !uninstalled && !versionMismatch && noAuth && (
|
||||
<Button variant="secondary" size="small">
|
||||
{t('notAuthorized', { ns: 'tools' })}
|
||||
|
||||
123
web/app/components/plugins/plugin-page/context.spec.tsx
Normal file
123
web/app/components/plugins/plugin-page/context.spec.tsx
Normal file
@ -0,0 +1,123 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
// Import mocks
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
|
||||
import { PluginPageContext, PluginPageContextProvider, usePluginPageContext } from './context'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('nuqs', () => ({
|
||||
useQueryState: vi.fn(() => ['plugins', vi.fn()]),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
PLUGIN_PAGE_TABS_MAP: {
|
||||
plugins: 'plugins',
|
||||
marketplace: 'discover',
|
||||
},
|
||||
usePluginPageTabs: () => [
|
||||
{ value: 'plugins', text: 'Plugins' },
|
||||
{ value: 'discover', text: 'Explore Marketplace' },
|
||||
],
|
||||
}))
|
||||
|
||||
// Helper function to mock useGlobalPublicStore with marketplace setting
|
||||
const mockGlobalPublicStore = (enableMarketplace: boolean) => {
|
||||
vi.mocked(useGlobalPublicStore).mockImplementation((selector) => {
|
||||
const state = { systemFeatures: { enable_marketplace: enableMarketplace } }
|
||||
return selector(state as Parameters<typeof selector>[0])
|
||||
})
|
||||
}
|
||||
|
||||
// Test component that uses the context
|
||||
const TestConsumer = () => {
|
||||
const containerRef = usePluginPageContext(v => v.containerRef)
|
||||
const options = usePluginPageContext(v => v.options)
|
||||
const activeTab = usePluginPageContext(v => v.activeTab)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="has-container-ref">{containerRef ? 'true' : 'false'}</span>
|
||||
<span data-testid="options-count">{options.length}</span>
|
||||
<span data-testid="active-tab">{activeTab}</span>
|
||||
{options.map((opt: { value: string, text: string }) => (
|
||||
<span key={opt.value} data-testid={`option-${opt.value}`}>{opt.text}</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
describe('PluginPageContext', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('PluginPageContextProvider', () => {
|
||||
it('should provide context values to children', () => {
|
||||
mockGlobalPublicStore(true)
|
||||
|
||||
render(
|
||||
<PluginPageContextProvider>
|
||||
<TestConsumer />
|
||||
</PluginPageContextProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('has-container-ref')).toHaveTextContent('true')
|
||||
expect(screen.getByTestId('options-count')).toHaveTextContent('2')
|
||||
})
|
||||
|
||||
it('should include marketplace tab when enable_marketplace is true', () => {
|
||||
mockGlobalPublicStore(true)
|
||||
|
||||
render(
|
||||
<PluginPageContextProvider>
|
||||
<TestConsumer />
|
||||
</PluginPageContextProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('option-plugins')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('option-discover')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should filter out marketplace tab when enable_marketplace is false', () => {
|
||||
mockGlobalPublicStore(false)
|
||||
|
||||
render(
|
||||
<PluginPageContextProvider>
|
||||
<TestConsumer />
|
||||
</PluginPageContextProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('option-plugins')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('option-discover')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('options-count')).toHaveTextContent('1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePluginPageContext', () => {
|
||||
it('should select specific context values', () => {
|
||||
mockGlobalPublicStore(true)
|
||||
|
||||
render(
|
||||
<PluginPageContextProvider>
|
||||
<TestConsumer />
|
||||
</PluginPageContextProvider>,
|
||||
)
|
||||
|
||||
// activeTab should be 'plugins' from the mock
|
||||
expect(screen.getByTestId('active-tab')).toHaveTextContent('plugins')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Default Context Values', () => {
|
||||
it('should have empty options by default from context', () => {
|
||||
// Test that the context has proper default values by checking the exported constant
|
||||
// The PluginPageContext is created with default values including empty options array
|
||||
expect(PluginPageContext).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
1041
web/app/components/plugins/plugin-page/index.spec.tsx
Normal file
1041
web/app/components/plugins/plugin-page/index.spec.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -207,6 +207,7 @@ const PluginPage = ({
|
||||
popupContent={t('privilege.title', { ns: 'plugin' })}
|
||||
>
|
||||
<Button
|
||||
data-testid="plugin-settings-button"
|
||||
className="group h-full w-full p-2 text-components-button-secondary-text"
|
||||
onClick={setShowPluginSettingModal}
|
||||
>
|
||||
|
||||
@ -0,0 +1,219 @@
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import type { PluginStatus } from '@/app/components/plugins/types'
|
||||
import type { Locale } from '@/i18n-config'
|
||||
import {
|
||||
RiCheckboxCircleFill,
|
||||
RiErrorWarningFill,
|
||||
RiLoaderLine,
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import CardIcon from '@/app/components/plugins/card/base/card-icon'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
|
||||
// Types
|
||||
type PluginItemProps = {
|
||||
plugin: PluginStatus
|
||||
getIconUrl: (icon: string) => string
|
||||
language: Locale
|
||||
statusIcon: ReactNode
|
||||
statusText: string
|
||||
statusClassName?: string
|
||||
action?: ReactNode
|
||||
}
|
||||
|
||||
type PluginSectionProps = {
|
||||
title: string
|
||||
count: number
|
||||
plugins: PluginStatus[]
|
||||
getIconUrl: (icon: string) => string
|
||||
language: Locale
|
||||
statusIcon: ReactNode
|
||||
defaultStatusText: string
|
||||
statusClassName?: string
|
||||
headerAction?: ReactNode
|
||||
renderItemAction?: (plugin: PluginStatus) => ReactNode
|
||||
}
|
||||
|
||||
type PluginTaskListProps = {
|
||||
runningPlugins: PluginStatus[]
|
||||
successPlugins: PluginStatus[]
|
||||
errorPlugins: PluginStatus[]
|
||||
getIconUrl: (icon: string) => string
|
||||
onClearAll: () => void
|
||||
onClearErrors: () => void
|
||||
onClearSingle: (taskId: string, pluginId: string) => void
|
||||
}
|
||||
|
||||
// Plugin Item Component
|
||||
const PluginItem: FC<PluginItemProps> = ({
|
||||
plugin,
|
||||
getIconUrl,
|
||||
language,
|
||||
statusIcon,
|
||||
statusText,
|
||||
statusClassName,
|
||||
action,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center rounded-lg p-2 hover:bg-state-base-hover">
|
||||
<div className="relative mr-2 flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge">
|
||||
{statusIcon}
|
||||
<CardIcon
|
||||
size="tiny"
|
||||
src={getIconUrl(plugin.icon)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div className="system-md-regular truncate text-text-secondary">
|
||||
{plugin.labels[language]}
|
||||
</div>
|
||||
<div className={`system-xs-regular ${statusClassName || 'text-text-tertiary'}`}>
|
||||
{statusText}
|
||||
</div>
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Plugin Section Component
|
||||
const PluginSection: FC<PluginSectionProps> = ({
|
||||
title,
|
||||
count,
|
||||
plugins,
|
||||
getIconUrl,
|
||||
language,
|
||||
statusIcon,
|
||||
defaultStatusText,
|
||||
statusClassName,
|
||||
headerAction,
|
||||
renderItemAction,
|
||||
}) => {
|
||||
if (plugins.length === 0)
|
||||
return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="system-sm-semibold-uppercase sticky top-0 flex h-7 items-center justify-between px-2 pt-1 text-text-secondary">
|
||||
{title}
|
||||
{' '}
|
||||
(
|
||||
{count}
|
||||
)
|
||||
{headerAction}
|
||||
</div>
|
||||
<div className="max-h-[200px] overflow-y-auto">
|
||||
{plugins.map(plugin => (
|
||||
<PluginItem
|
||||
key={plugin.plugin_unique_identifier}
|
||||
plugin={plugin}
|
||||
getIconUrl={getIconUrl}
|
||||
language={language}
|
||||
statusIcon={statusIcon}
|
||||
statusText={plugin.message || defaultStatusText}
|
||||
statusClassName={statusClassName}
|
||||
action={renderItemAction?.(plugin)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Main Plugin Task List Component
|
||||
const PluginTaskList: FC<PluginTaskListProps> = ({
|
||||
runningPlugins,
|
||||
successPlugins,
|
||||
errorPlugins,
|
||||
getIconUrl,
|
||||
onClearAll,
|
||||
onClearErrors,
|
||||
onClearSingle,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const language = useGetLanguage()
|
||||
|
||||
return (
|
||||
<div className="w-[360px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg">
|
||||
{/* Running Plugins Section */}
|
||||
{runningPlugins.length > 0 && (
|
||||
<PluginSection
|
||||
title={t('task.installing', { ns: 'plugin' })}
|
||||
count={runningPlugins.length}
|
||||
plugins={runningPlugins}
|
||||
getIconUrl={getIconUrl}
|
||||
language={language}
|
||||
statusIcon={
|
||||
<RiLoaderLine className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 animate-spin text-text-accent" />
|
||||
}
|
||||
defaultStatusText={t('task.installing', { ns: 'plugin' })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Success Plugins Section */}
|
||||
{successPlugins.length > 0 && (
|
||||
<PluginSection
|
||||
title={t('task.installed', { ns: 'plugin' })}
|
||||
count={successPlugins.length}
|
||||
plugins={successPlugins}
|
||||
getIconUrl={getIconUrl}
|
||||
language={language}
|
||||
statusIcon={
|
||||
<RiCheckboxCircleFill className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 text-text-success" />
|
||||
}
|
||||
defaultStatusText={t('task.installed', { ns: 'plugin' })}
|
||||
statusClassName="text-text-success"
|
||||
headerAction={(
|
||||
<Button
|
||||
className="shrink-0"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
onClick={onClearAll}
|
||||
>
|
||||
{t('task.clearAll', { ns: 'plugin' })}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Error Plugins Section */}
|
||||
{errorPlugins.length > 0 && (
|
||||
<PluginSection
|
||||
title={t('task.installError', { ns: 'plugin', errorLength: errorPlugins.length })}
|
||||
count={errorPlugins.length}
|
||||
plugins={errorPlugins}
|
||||
getIconUrl={getIconUrl}
|
||||
language={language}
|
||||
statusIcon={
|
||||
<RiErrorWarningFill className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 text-text-destructive" />
|
||||
}
|
||||
defaultStatusText={t('task.installError', { ns: 'plugin', errorLength: errorPlugins.length })}
|
||||
statusClassName="text-text-destructive break-all"
|
||||
headerAction={(
|
||||
<Button
|
||||
className="shrink-0"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
onClick={onClearErrors}
|
||||
>
|
||||
{t('task.clearAll', { ns: 'plugin' })}
|
||||
</Button>
|
||||
)}
|
||||
renderItemAction={plugin => (
|
||||
<Button
|
||||
className="shrink-0"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
onClick={() => onClearSingle(plugin.taskId, plugin.plugin_unique_identifier)}
|
||||
>
|
||||
{t('operation.clear', { ns: 'common' })}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PluginTaskList
|
||||
@ -0,0 +1,96 @@
|
||||
import type { FC } from 'react'
|
||||
import {
|
||||
RiCheckboxCircleFill,
|
||||
RiErrorWarningFill,
|
||||
RiInstallLine,
|
||||
} from '@remixicon/react'
|
||||
import ProgressCircle from '@/app/components/base/progress-bar/progress-circle'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import DownloadingIcon from '@/app/components/header/plugins-nav/downloading-icon'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
export type TaskStatusIndicatorProps = {
|
||||
tip: string
|
||||
isInstalling: boolean
|
||||
isInstallingWithSuccess: boolean
|
||||
isInstallingWithError: boolean
|
||||
isSuccess: boolean
|
||||
isFailed: boolean
|
||||
successPluginsLength: number
|
||||
runningPluginsLength: number
|
||||
totalPluginsLength: number
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
const TaskStatusIndicator: FC<TaskStatusIndicatorProps> = ({
|
||||
tip,
|
||||
isInstalling,
|
||||
isInstallingWithSuccess,
|
||||
isInstallingWithError,
|
||||
isSuccess,
|
||||
isFailed,
|
||||
successPluginsLength,
|
||||
runningPluginsLength,
|
||||
totalPluginsLength,
|
||||
onClick,
|
||||
}) => {
|
||||
const showDownloadingIcon = isInstalling || isInstallingWithError
|
||||
const showErrorStyle = isInstallingWithError || isFailed
|
||||
const showSuccessIcon = isSuccess || (successPluginsLength > 0 && runningPluginsLength === 0)
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
popupContent={tip}
|
||||
asChild
|
||||
offset={8}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex h-8 w-8 items-center justify-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs hover:bg-components-button-secondary-bg-hover',
|
||||
showErrorStyle && 'cursor-pointer border-components-button-destructive-secondary-border-hover bg-state-destructive-hover hover:bg-state-destructive-hover-alt',
|
||||
(isInstalling || isInstallingWithSuccess || isSuccess) && 'cursor-pointer hover:bg-components-button-secondary-bg-hover',
|
||||
)}
|
||||
id="plugin-task-trigger"
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* Main Icon */}
|
||||
{showDownloadingIcon
|
||||
? <DownloadingIcon />
|
||||
: (
|
||||
<RiInstallLine
|
||||
className={cn(
|
||||
'h-4 w-4 text-components-button-secondary-text',
|
||||
showErrorStyle && 'text-components-button-destructive-secondary-text',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Status Indicator Badge */}
|
||||
<div className="absolute -right-1 -top-1">
|
||||
{(isInstalling || isInstallingWithSuccess) && (
|
||||
<ProgressCircle
|
||||
percentage={(totalPluginsLength > 0 ? successPluginsLength / totalPluginsLength : 0) * 100}
|
||||
circleFillColor="fill-components-progress-brand-bg"
|
||||
/>
|
||||
)}
|
||||
{isInstallingWithError && (
|
||||
<ProgressCircle
|
||||
percentage={(totalPluginsLength > 0 ? runningPluginsLength / totalPluginsLength : 0) * 100}
|
||||
circleFillColor="fill-components-progress-brand-bg"
|
||||
sectorFillColor="fill-components-progress-error-border"
|
||||
circleStrokeColor="stroke-components-progress-error-border"
|
||||
/>
|
||||
)}
|
||||
{showSuccessIcon && !isInstalling && !isInstallingWithSuccess && !isInstallingWithError && (
|
||||
<RiCheckboxCircleFill className="h-3.5 w-3.5 text-text-success" />
|
||||
)}
|
||||
{isFailed && (
|
||||
<RiErrorWarningFill className="h-3.5 w-3.5 text-text-destructive" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default TaskStatusIndicator
|
||||
@ -0,0 +1,856 @@
|
||||
import type { PluginStatus } from '@/app/components/plugins/types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TaskStatus } from '@/app/components/plugins/types'
|
||||
// Import mocked modules
|
||||
import { useMutationClearTaskPlugin, usePluginTaskList } from '@/service/use-plugins'
|
||||
import PluginTaskList from './components/plugin-task-list'
|
||||
import TaskStatusIndicator from './components/task-status-indicator'
|
||||
import { usePluginTaskStatus } from './hooks'
|
||||
|
||||
import PluginTasks from './index'
|
||||
|
||||
// Mock external dependencies
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
usePluginTaskList: vi.fn(),
|
||||
useMutationClearTaskPlugin: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/base/use-get-icon', () => ({
|
||||
default: () => ({
|
||||
getIconUrl: (icon: string) => `https://example.com/${icon}`,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useGetLanguage: () => 'en_US',
|
||||
}))
|
||||
|
||||
// Helper to create mock plugin
|
||||
const createMockPlugin = (overrides: Partial<PluginStatus> = {}): PluginStatus => ({
|
||||
plugin_unique_identifier: `plugin-${Math.random().toString(36).substr(2, 9)}`,
|
||||
plugin_id: 'test-plugin',
|
||||
status: TaskStatus.running,
|
||||
message: '',
|
||||
icon: 'test-icon.png',
|
||||
labels: {
|
||||
en_US: 'Test Plugin',
|
||||
zh_Hans: '测试插件',
|
||||
} as Record<string, string>,
|
||||
taskId: 'task-1',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// Helper to setup mock hook returns
|
||||
const setupMocks = (plugins: PluginStatus[] = []) => {
|
||||
const mockMutateAsync = vi.fn().mockResolvedValue({})
|
||||
const mockHandleRefetch = vi.fn()
|
||||
|
||||
vi.mocked(usePluginTaskList).mockReturnValue({
|
||||
pluginTasks: plugins.length > 0
|
||||
? [{ id: 'task-1', plugins, created_at: '', updated_at: '', status: 'running', total_plugins: plugins.length, completed_plugins: 0 }]
|
||||
: [],
|
||||
handleRefetch: mockHandleRefetch,
|
||||
} as any)
|
||||
|
||||
vi.mocked(useMutationClearTaskPlugin).mockReturnValue({
|
||||
mutateAsync: mockMutateAsync,
|
||||
} as any)
|
||||
|
||||
return { mockMutateAsync, mockHandleRefetch }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// usePluginTaskStatus Hook Tests
|
||||
// ============================================================================
|
||||
describe('usePluginTaskStatus Hook', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Plugin categorization', () => {
|
||||
it('should categorize running plugins correctly', () => {
|
||||
const runningPlugin = createMockPlugin({ status: TaskStatus.running })
|
||||
setupMocks([runningPlugin])
|
||||
|
||||
const TestComponent = () => {
|
||||
const { runningPlugins, runningPluginsLength } = usePluginTaskStatus()
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="running-count">{runningPluginsLength}</span>
|
||||
<span data-testid="running-id">{runningPlugins[0]?.plugin_unique_identifier}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<TestComponent />)
|
||||
|
||||
expect(screen.getByTestId('running-count')).toHaveTextContent('1')
|
||||
expect(screen.getByTestId('running-id')).toHaveTextContent(runningPlugin.plugin_unique_identifier)
|
||||
})
|
||||
|
||||
it('should categorize success plugins correctly', () => {
|
||||
const successPlugin = createMockPlugin({ status: TaskStatus.success })
|
||||
setupMocks([successPlugin])
|
||||
|
||||
const TestComponent = () => {
|
||||
const { successPlugins, successPluginsLength } = usePluginTaskStatus()
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="success-count">{successPluginsLength}</span>
|
||||
<span data-testid="success-id">{successPlugins[0]?.plugin_unique_identifier}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<TestComponent />)
|
||||
|
||||
expect(screen.getByTestId('success-count')).toHaveTextContent('1')
|
||||
expect(screen.getByTestId('success-id')).toHaveTextContent(successPlugin.plugin_unique_identifier)
|
||||
})
|
||||
|
||||
it('should categorize error plugins correctly', () => {
|
||||
const errorPlugin = createMockPlugin({ status: TaskStatus.failed, message: 'Install failed' })
|
||||
setupMocks([errorPlugin])
|
||||
|
||||
const TestComponent = () => {
|
||||
const { errorPlugins, errorPluginsLength } = usePluginTaskStatus()
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="error-count">{errorPluginsLength}</span>
|
||||
<span data-testid="error-id">{errorPlugins[0]?.plugin_unique_identifier}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<TestComponent />)
|
||||
|
||||
expect(screen.getByTestId('error-count')).toHaveTextContent('1')
|
||||
expect(screen.getByTestId('error-id')).toHaveTextContent(errorPlugin.plugin_unique_identifier)
|
||||
})
|
||||
|
||||
it('should categorize mixed plugins correctly', () => {
|
||||
const plugins = [
|
||||
createMockPlugin({ status: TaskStatus.running, plugin_unique_identifier: 'running-1' }),
|
||||
createMockPlugin({ status: TaskStatus.success, plugin_unique_identifier: 'success-1' }),
|
||||
createMockPlugin({ status: TaskStatus.failed, plugin_unique_identifier: 'error-1' }),
|
||||
]
|
||||
setupMocks(plugins)
|
||||
|
||||
const TestComponent = () => {
|
||||
const { runningPluginsLength, successPluginsLength, errorPluginsLength, totalPluginsLength } = usePluginTaskStatus()
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="running">{runningPluginsLength}</span>
|
||||
<span data-testid="success">{successPluginsLength}</span>
|
||||
<span data-testid="error">{errorPluginsLength}</span>
|
||||
<span data-testid="total">{totalPluginsLength}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<TestComponent />)
|
||||
|
||||
expect(screen.getByTestId('running')).toHaveTextContent('1')
|
||||
expect(screen.getByTestId('success')).toHaveTextContent('1')
|
||||
expect(screen.getByTestId('error')).toHaveTextContent('1')
|
||||
expect(screen.getByTestId('total')).toHaveTextContent('3')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Status flags', () => {
|
||||
it('should set isInstalling when only running plugins exist', () => {
|
||||
setupMocks([createMockPlugin({ status: TaskStatus.running })])
|
||||
|
||||
const TestComponent = () => {
|
||||
const { isInstalling, isInstallingWithSuccess, isInstallingWithError, isSuccess, isFailed } = usePluginTaskStatus()
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="isInstalling">{String(isInstalling)}</span>
|
||||
<span data-testid="isInstallingWithSuccess">{String(isInstallingWithSuccess)}</span>
|
||||
<span data-testid="isInstallingWithError">{String(isInstallingWithError)}</span>
|
||||
<span data-testid="isSuccess">{String(isSuccess)}</span>
|
||||
<span data-testid="isFailed">{String(isFailed)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<TestComponent />)
|
||||
|
||||
expect(screen.getByTestId('isInstalling')).toHaveTextContent('true')
|
||||
expect(screen.getByTestId('isInstallingWithSuccess')).toHaveTextContent('false')
|
||||
expect(screen.getByTestId('isInstallingWithError')).toHaveTextContent('false')
|
||||
expect(screen.getByTestId('isSuccess')).toHaveTextContent('false')
|
||||
expect(screen.getByTestId('isFailed')).toHaveTextContent('false')
|
||||
})
|
||||
|
||||
it('should set isInstallingWithSuccess when running and success plugins exist', () => {
|
||||
setupMocks([
|
||||
createMockPlugin({ status: TaskStatus.running }),
|
||||
createMockPlugin({ status: TaskStatus.success }),
|
||||
])
|
||||
|
||||
const TestComponent = () => {
|
||||
const { isInstallingWithSuccess } = usePluginTaskStatus()
|
||||
return <span data-testid="flag">{String(isInstallingWithSuccess)}</span>
|
||||
}
|
||||
|
||||
render(<TestComponent />)
|
||||
expect(screen.getByTestId('flag')).toHaveTextContent('true')
|
||||
})
|
||||
|
||||
it('should set isInstallingWithError when running and error plugins exist', () => {
|
||||
setupMocks([
|
||||
createMockPlugin({ status: TaskStatus.running }),
|
||||
createMockPlugin({ status: TaskStatus.failed }),
|
||||
])
|
||||
|
||||
const TestComponent = () => {
|
||||
const { isInstallingWithError } = usePluginTaskStatus()
|
||||
return <span data-testid="flag">{String(isInstallingWithError)}</span>
|
||||
}
|
||||
|
||||
render(<TestComponent />)
|
||||
expect(screen.getByTestId('flag')).toHaveTextContent('true')
|
||||
})
|
||||
|
||||
it('should set isSuccess when all plugins succeeded', () => {
|
||||
setupMocks([
|
||||
createMockPlugin({ status: TaskStatus.success }),
|
||||
createMockPlugin({ status: TaskStatus.success }),
|
||||
])
|
||||
|
||||
const TestComponent = () => {
|
||||
const { isSuccess } = usePluginTaskStatus()
|
||||
return <span data-testid="flag">{String(isSuccess)}</span>
|
||||
}
|
||||
|
||||
render(<TestComponent />)
|
||||
expect(screen.getByTestId('flag')).toHaveTextContent('true')
|
||||
})
|
||||
|
||||
it('should set isFailed when no running plugins and some failed', () => {
|
||||
setupMocks([
|
||||
createMockPlugin({ status: TaskStatus.success }),
|
||||
createMockPlugin({ status: TaskStatus.failed }),
|
||||
])
|
||||
|
||||
const TestComponent = () => {
|
||||
const { isFailed } = usePluginTaskStatus()
|
||||
return <span data-testid="flag">{String(isFailed)}</span>
|
||||
}
|
||||
|
||||
render(<TestComponent />)
|
||||
expect(screen.getByTestId('flag')).toHaveTextContent('true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleClearErrorPlugin', () => {
|
||||
it('should call mutateAsync and handleRefetch', async () => {
|
||||
const { mockMutateAsync, mockHandleRefetch } = setupMocks([
|
||||
createMockPlugin({ status: TaskStatus.failed }),
|
||||
])
|
||||
|
||||
const TestComponent = () => {
|
||||
const { handleClearErrorPlugin } = usePluginTaskStatus()
|
||||
return (
|
||||
<button onClick={() => handleClearErrorPlugin('task-1', 'plugin-1')}>
|
||||
Clear
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
render(<TestComponent />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith({
|
||||
taskId: 'task-1',
|
||||
pluginId: 'plugin-1',
|
||||
})
|
||||
expect(mockHandleRefetch).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// TaskStatusIndicator Component Tests
|
||||
// ============================================================================
|
||||
describe('TaskStatusIndicator Component', () => {
|
||||
const defaultProps = {
|
||||
tip: 'Test tooltip',
|
||||
isInstalling: false,
|
||||
isInstallingWithSuccess: false,
|
||||
isInstallingWithError: false,
|
||||
isSuccess: false,
|
||||
isFailed: false,
|
||||
successPluginsLength: 0,
|
||||
runningPluginsLength: 0,
|
||||
totalPluginsLength: 1,
|
||||
onClick: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<TaskStatusIndicator {...defaultProps} />)
|
||||
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with correct id', () => {
|
||||
render(<TaskStatusIndicator {...defaultProps} />)
|
||||
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Icon display', () => {
|
||||
it('should show downloading icon when installing', () => {
|
||||
render(<TaskStatusIndicator {...defaultProps} isInstalling />)
|
||||
// DownloadingIcon is rendered when isInstalling is true
|
||||
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show downloading icon when installing with error', () => {
|
||||
render(<TaskStatusIndicator {...defaultProps} isInstallingWithError />)
|
||||
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show install icon when not installing', () => {
|
||||
render(<TaskStatusIndicator {...defaultProps} isSuccess />)
|
||||
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Status badge', () => {
|
||||
it('should show progress circle when installing', () => {
|
||||
render(
|
||||
<TaskStatusIndicator
|
||||
{...defaultProps}
|
||||
isInstalling
|
||||
successPluginsLength={1}
|
||||
totalPluginsLength={3}
|
||||
/>,
|
||||
)
|
||||
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show progress circle when installing with success', () => {
|
||||
render(
|
||||
<TaskStatusIndicator
|
||||
{...defaultProps}
|
||||
isInstallingWithSuccess
|
||||
successPluginsLength={2}
|
||||
totalPluginsLength={3}
|
||||
/>,
|
||||
)
|
||||
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show error progress circle when installing with error', () => {
|
||||
render(
|
||||
<TaskStatusIndicator
|
||||
{...defaultProps}
|
||||
isInstallingWithError
|
||||
runningPluginsLength={1}
|
||||
totalPluginsLength={3}
|
||||
/>,
|
||||
)
|
||||
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show success icon when all completed successfully', () => {
|
||||
render(
|
||||
<TaskStatusIndicator
|
||||
{...defaultProps}
|
||||
isSuccess
|
||||
successPluginsLength={3}
|
||||
runningPluginsLength={0}
|
||||
totalPluginsLength={3}
|
||||
/>,
|
||||
)
|
||||
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show error icon when failed', () => {
|
||||
render(<TaskStatusIndicator {...defaultProps} isFailed />)
|
||||
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should apply error styles when installing with error', () => {
|
||||
render(<TaskStatusIndicator {...defaultProps} isInstallingWithError />)
|
||||
const trigger = document.getElementById('plugin-task-trigger')
|
||||
expect(trigger).toHaveClass('bg-state-destructive-hover')
|
||||
})
|
||||
|
||||
it('should apply error styles when failed', () => {
|
||||
render(<TaskStatusIndicator {...defaultProps} isFailed />)
|
||||
const trigger = document.getElementById('plugin-task-trigger')
|
||||
expect(trigger).toHaveClass('bg-state-destructive-hover')
|
||||
})
|
||||
|
||||
it('should apply cursor-pointer when clickable', () => {
|
||||
render(<TaskStatusIndicator {...defaultProps} isInstalling />)
|
||||
const trigger = document.getElementById('plugin-task-trigger')
|
||||
expect(trigger).toHaveClass('cursor-pointer')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User interactions', () => {
|
||||
it('should call onClick when clicked', () => {
|
||||
const handleClick = vi.fn()
|
||||
render(<TaskStatusIndicator {...defaultProps} onClick={handleClick} />)
|
||||
|
||||
fireEvent.click(document.getElementById('plugin-task-trigger')!)
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// PluginTaskList Component Tests
|
||||
// ============================================================================
|
||||
describe('PluginTaskList Component', () => {
|
||||
const defaultProps = {
|
||||
runningPlugins: [] as PluginStatus[],
|
||||
successPlugins: [] as PluginStatus[],
|
||||
errorPlugins: [] as PluginStatus[],
|
||||
getIconUrl: (icon: string) => `https://example.com/${icon}`,
|
||||
onClearAll: vi.fn(),
|
||||
onClearErrors: vi.fn(),
|
||||
onClearSingle: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing with empty lists', () => {
|
||||
render(<PluginTaskList {...defaultProps} />)
|
||||
expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render running plugins section when plugins exist', () => {
|
||||
const runningPlugins = [createMockPlugin({ status: TaskStatus.running })]
|
||||
render(<PluginTaskList {...defaultProps} runningPlugins={runningPlugins} />)
|
||||
|
||||
// Translation key is returned as text in tests, multiple matches expected (title + status)
|
||||
expect(screen.getAllByText(/task\.installing/i).length).toBeGreaterThan(0)
|
||||
// Verify section container is rendered
|
||||
expect(document.querySelector('.max-h-\\[200px\\]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render success plugins section when plugins exist', () => {
|
||||
const successPlugins = [createMockPlugin({ status: TaskStatus.success })]
|
||||
render(<PluginTaskList {...defaultProps} successPlugins={successPlugins} />)
|
||||
|
||||
// Translation key is returned as text in tests, multiple matches expected
|
||||
expect(screen.getAllByText(/task\.installed/i).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should render error plugins section when plugins exist', () => {
|
||||
const errorPlugins = [createMockPlugin({ status: TaskStatus.failed, message: 'Error occurred' })]
|
||||
render(<PluginTaskList {...defaultProps} errorPlugins={errorPlugins} />)
|
||||
|
||||
expect(screen.getByText('Error occurred')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render all sections when all types exist', () => {
|
||||
render(
|
||||
<PluginTaskList
|
||||
{...defaultProps}
|
||||
runningPlugins={[createMockPlugin({ status: TaskStatus.running })]}
|
||||
successPlugins={[createMockPlugin({ status: TaskStatus.success })]}
|
||||
errorPlugins={[createMockPlugin({ status: TaskStatus.failed })]}
|
||||
/>,
|
||||
)
|
||||
|
||||
// All sections should be present
|
||||
expect(document.querySelectorAll('.max-h-\\[200px\\]').length).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('User interactions', () => {
|
||||
it('should call onClearAll when clear all button is clicked in success section', () => {
|
||||
const handleClearAll = vi.fn()
|
||||
const successPlugins = [createMockPlugin({ status: TaskStatus.success })]
|
||||
|
||||
render(
|
||||
<PluginTaskList
|
||||
{...defaultProps}
|
||||
successPlugins={successPlugins}
|
||||
onClearAll={handleClearAll}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /task\.clearAll/i }))
|
||||
|
||||
expect(handleClearAll).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onClearErrors when clear all button is clicked in error section', () => {
|
||||
const handleClearErrors = vi.fn()
|
||||
const errorPlugins = [createMockPlugin({ status: TaskStatus.failed })]
|
||||
|
||||
render(
|
||||
<PluginTaskList
|
||||
{...defaultProps}
|
||||
errorPlugins={errorPlugins}
|
||||
onClearErrors={handleClearErrors}
|
||||
/>,
|
||||
)
|
||||
|
||||
const clearButtons = screen.getAllByRole('button')
|
||||
fireEvent.click(clearButtons.find(btn => btn.textContent?.includes('task.clearAll'))!)
|
||||
|
||||
expect(handleClearErrors).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onClearSingle with correct args when individual clear is clicked', () => {
|
||||
const handleClearSingle = vi.fn()
|
||||
const errorPlugin = createMockPlugin({
|
||||
status: TaskStatus.failed,
|
||||
plugin_unique_identifier: 'error-plugin-1',
|
||||
taskId: 'task-123',
|
||||
})
|
||||
|
||||
render(
|
||||
<PluginTaskList
|
||||
{...defaultProps}
|
||||
errorPlugins={[errorPlugin]}
|
||||
onClearSingle={handleClearSingle}
|
||||
/>,
|
||||
)
|
||||
|
||||
// The individual clear button has the text 'operation.clear'
|
||||
fireEvent.click(screen.getByRole('button', { name: /operation\.clear/i }))
|
||||
|
||||
expect(handleClearSingle).toHaveBeenCalledWith('task-123', 'error-plugin-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Plugin display', () => {
|
||||
it('should display plugin name from labels', () => {
|
||||
const plugin = createMockPlugin({
|
||||
status: TaskStatus.running,
|
||||
labels: { en_US: 'My Test Plugin' } as Record<string, string>,
|
||||
})
|
||||
|
||||
render(<PluginTaskList {...defaultProps} runningPlugins={[plugin]} />)
|
||||
|
||||
expect(screen.getByText('My Test Plugin')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display plugin message when available', () => {
|
||||
const plugin = createMockPlugin({
|
||||
status: TaskStatus.success,
|
||||
message: 'Successfully installed!',
|
||||
})
|
||||
|
||||
render(<PluginTaskList {...defaultProps} successPlugins={[plugin]} />)
|
||||
|
||||
expect(screen.getByText('Successfully installed!')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display multiple plugins in each section', () => {
|
||||
const runningPlugins = [
|
||||
createMockPlugin({ status: TaskStatus.running, labels: { en_US: 'Plugin A' } as Record<string, string> }),
|
||||
createMockPlugin({ status: TaskStatus.running, labels: { en_US: 'Plugin B' } as Record<string, string> }),
|
||||
]
|
||||
|
||||
render(<PluginTaskList {...defaultProps} runningPlugins={runningPlugins} />)
|
||||
|
||||
expect(screen.getByText('Plugin A')).toBeInTheDocument()
|
||||
expect(screen.getByText('Plugin B')).toBeInTheDocument()
|
||||
// Count is rendered, verify multiple items are in list
|
||||
expect(document.querySelectorAll('.hover\\:bg-state-base-hover').length).toBe(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// PluginTasks Main Component Tests
|
||||
// ============================================================================
|
||||
describe('PluginTasks Component', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should return null when no plugins exist', () => {
|
||||
setupMocks([])
|
||||
|
||||
const { container } = render(<PluginTasks />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should render when plugins exist', () => {
|
||||
setupMocks([createMockPlugin({ status: TaskStatus.running })])
|
||||
|
||||
render(<PluginTasks />)
|
||||
|
||||
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tooltip text (tip memoization)', () => {
|
||||
it('should show installing tip when isInstalling', () => {
|
||||
setupMocks([createMockPlugin({ status: TaskStatus.running })])
|
||||
|
||||
render(<PluginTasks />)
|
||||
|
||||
// The component renders with a tooltip, we verify it exists
|
||||
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show success tip when all succeeded', () => {
|
||||
setupMocks([createMockPlugin({ status: TaskStatus.success })])
|
||||
|
||||
render(<PluginTasks />)
|
||||
|
||||
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show error tip when some failed', () => {
|
||||
setupMocks([
|
||||
createMockPlugin({ status: TaskStatus.success }),
|
||||
createMockPlugin({ status: TaskStatus.failed }),
|
||||
])
|
||||
|
||||
render(<PluginTasks />)
|
||||
|
||||
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Popover interaction', () => {
|
||||
it('should toggle popover when trigger is clicked and status allows', () => {
|
||||
setupMocks([createMockPlugin({ status: TaskStatus.running })])
|
||||
|
||||
render(<PluginTasks />)
|
||||
|
||||
// Click to open
|
||||
fireEvent.click(document.getElementById('plugin-task-trigger')!)
|
||||
|
||||
// The popover content should be visible (PluginTaskList)
|
||||
expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not toggle when status does not allow', () => {
|
||||
// Setup with no actionable status (edge case - should not happen in practice)
|
||||
setupMocks([createMockPlugin({ status: TaskStatus.running })])
|
||||
|
||||
render(<PluginTasks />)
|
||||
|
||||
// Component should still render
|
||||
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Clear handlers', () => {
|
||||
it('should clear all completed plugins when onClearAll is called', async () => {
|
||||
const { mockMutateAsync } = setupMocks([
|
||||
createMockPlugin({ status: TaskStatus.success, plugin_unique_identifier: 'success-1' }),
|
||||
createMockPlugin({ status: TaskStatus.failed, plugin_unique_identifier: 'error-1' }),
|
||||
])
|
||||
|
||||
render(<PluginTasks />)
|
||||
|
||||
// Open popover
|
||||
fireEvent.click(document.getElementById('plugin-task-trigger')!)
|
||||
|
||||
// Wait for popover content to render
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Find and click clear all button
|
||||
const clearButtons = screen.getAllByRole('button')
|
||||
const clearAllButton = clearButtons.find(btn => btn.textContent?.includes('clearAll'))
|
||||
if (clearAllButton)
|
||||
fireEvent.click(clearAllButton)
|
||||
|
||||
// Verify mutateAsync was called for each completed plugin
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsync).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should clear only error plugins when onClearErrors is called', async () => {
|
||||
const { mockMutateAsync } = setupMocks([
|
||||
createMockPlugin({ status: TaskStatus.failed, plugin_unique_identifier: 'error-1' }),
|
||||
])
|
||||
|
||||
render(<PluginTasks />)
|
||||
|
||||
// Open popover
|
||||
fireEvent.click(document.getElementById('plugin-task-trigger')!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Find and click the clear all button in error section
|
||||
const clearButtons = screen.getAllByRole('button')
|
||||
if (clearButtons.length > 0)
|
||||
fireEvent.click(clearButtons[0])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsync).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should clear single plugin when onClearSingle is called', async () => {
|
||||
const { mockMutateAsync } = setupMocks([
|
||||
createMockPlugin({
|
||||
status: TaskStatus.failed,
|
||||
plugin_unique_identifier: 'error-plugin',
|
||||
taskId: 'task-1',
|
||||
}),
|
||||
])
|
||||
|
||||
render(<PluginTasks />)
|
||||
|
||||
// Open popover
|
||||
fireEvent.click(document.getElementById('plugin-task-trigger')!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Find and click individual clear button (usually the last one)
|
||||
const clearButtons = screen.getAllByRole('button')
|
||||
const individualClearButton = clearButtons[clearButtons.length - 1]
|
||||
fireEvent.click(individualClearButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith({
|
||||
taskId: 'task-1',
|
||||
pluginId: 'error-plugin',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle empty plugin tasks array', () => {
|
||||
setupMocks([])
|
||||
|
||||
const { container } = render(<PluginTasks />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle single running plugin', () => {
|
||||
setupMocks([createMockPlugin({ status: TaskStatus.running })])
|
||||
|
||||
render(<PluginTasks />)
|
||||
|
||||
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle many plugins', () => {
|
||||
const manyPlugins = Array.from({ length: 10 }, (_, i) =>
|
||||
createMockPlugin({
|
||||
status: i % 3 === 0 ? TaskStatus.running : i % 3 === 1 ? TaskStatus.success : TaskStatus.failed,
|
||||
plugin_unique_identifier: `plugin-${i}`,
|
||||
}))
|
||||
setupMocks(manyPlugins)
|
||||
|
||||
render(<PluginTasks />)
|
||||
|
||||
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle plugins with empty labels', () => {
|
||||
const plugin = createMockPlugin({
|
||||
status: TaskStatus.running,
|
||||
labels: {} as Record<string, string>,
|
||||
})
|
||||
setupMocks([plugin])
|
||||
|
||||
render(<PluginTasks />)
|
||||
|
||||
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle plugins with long messages', () => {
|
||||
const plugin = createMockPlugin({
|
||||
status: TaskStatus.failed,
|
||||
message: 'A'.repeat(500),
|
||||
})
|
||||
setupMocks([plugin])
|
||||
|
||||
render(<PluginTasks />)
|
||||
|
||||
// Open popover
|
||||
fireEvent.click(document.getElementById('plugin-task-trigger')!)
|
||||
|
||||
expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Integration Tests
|
||||
// ============================================================================
|
||||
describe('PluginTasks Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should show correct UI flow from installing to success', async () => {
|
||||
// Start with installing state
|
||||
setupMocks([createMockPlugin({ status: TaskStatus.running })])
|
||||
|
||||
const { rerender } = render(<PluginTasks />)
|
||||
|
||||
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
|
||||
|
||||
// Simulate completion by re-rendering with success
|
||||
setupMocks([createMockPlugin({ status: TaskStatus.success })])
|
||||
rerender(<PluginTasks />)
|
||||
|
||||
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show correct UI flow from installing to failure', async () => {
|
||||
// Start with installing state
|
||||
setupMocks([createMockPlugin({ status: TaskStatus.running })])
|
||||
|
||||
const { rerender } = render(<PluginTasks />)
|
||||
|
||||
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
|
||||
|
||||
// Simulate failure by re-rendering with failed
|
||||
setupMocks([createMockPlugin({ status: TaskStatus.failed, message: 'Network error' })])
|
||||
rerender(<PluginTasks />)
|
||||
|
||||
expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle mixed status during installation', () => {
|
||||
setupMocks([
|
||||
createMockPlugin({ status: TaskStatus.running, plugin_unique_identifier: 'p1' }),
|
||||
createMockPlugin({ status: TaskStatus.success, plugin_unique_identifier: 'p2' }),
|
||||
createMockPlugin({ status: TaskStatus.failed, plugin_unique_identifier: 'p3' }),
|
||||
])
|
||||
|
||||
render(<PluginTasks />)
|
||||
|
||||
// Open popover
|
||||
fireEvent.click(document.getElementById('plugin-task-trigger')!)
|
||||
|
||||
// All sections should be visible
|
||||
const sections = document.querySelectorAll('.max-h-\\[200px\\]')
|
||||
expect(sections.length).toBe(3)
|
||||
})
|
||||
})
|
||||
@ -1,33 +1,21 @@
|
||||
import {
|
||||
RiCheckboxCircleFill,
|
||||
RiErrorWarningFill,
|
||||
RiInstallLine,
|
||||
RiLoaderLine,
|
||||
} from '@remixicon/react'
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import ProgressCircle from '@/app/components/base/progress-bar/progress-circle'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import DownloadingIcon from '@/app/components/header/plugins-nav/downloading-icon'
|
||||
import CardIcon from '@/app/components/plugins/card/base/card-icon'
|
||||
import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import PluginTaskList from './components/plugin-task-list'
|
||||
import TaskStatusIndicator from './components/task-status-indicator'
|
||||
import { usePluginTaskStatus } from './hooks'
|
||||
|
||||
const PluginTasks = () => {
|
||||
const { t } = useTranslation()
|
||||
const language = useGetLanguage()
|
||||
const [open, setOpen] = useState(false)
|
||||
const {
|
||||
errorPlugins,
|
||||
@ -46,35 +34,7 @@ const PluginTasks = () => {
|
||||
} = usePluginTaskStatus()
|
||||
const { getIconUrl } = useGetIcon()
|
||||
|
||||
const handleClearAllWithModal = useCallback(async () => {
|
||||
// Clear all completed plugins (success and error) but keep running ones
|
||||
const completedPlugins = [...successPlugins, ...errorPlugins]
|
||||
|
||||
// Clear all completed plugins individually
|
||||
for (const plugin of completedPlugins)
|
||||
await handleClearErrorPlugin(plugin.taskId, plugin.plugin_unique_identifier)
|
||||
|
||||
// Only close modal if no plugins are still installing
|
||||
if (runningPluginsLength === 0)
|
||||
setOpen(false)
|
||||
}, [successPlugins, errorPlugins, handleClearErrorPlugin, runningPluginsLength])
|
||||
|
||||
const handleClearErrorsWithModal = useCallback(async () => {
|
||||
// Clear only error plugins, not all plugins
|
||||
for (const plugin of errorPlugins)
|
||||
await handleClearErrorPlugin(plugin.taskId, plugin.plugin_unique_identifier)
|
||||
// Only close modal if no plugins are still installing
|
||||
if (runningPluginsLength === 0)
|
||||
setOpen(false)
|
||||
}, [errorPlugins, handleClearErrorPlugin, runningPluginsLength])
|
||||
|
||||
const handleClearSingleWithModal = useCallback(async (taskId: string, pluginId: string) => {
|
||||
await handleClearErrorPlugin(taskId, pluginId)
|
||||
// Only close modal if no plugins are still installing
|
||||
if (runningPluginsLength === 0)
|
||||
setOpen(false)
|
||||
}, [handleClearErrorPlugin, runningPluginsLength])
|
||||
|
||||
// Generate tooltip text based on status
|
||||
const tip = useMemo(() => {
|
||||
if (isInstallingWithError)
|
||||
return t('task.installingWithError', { ns: 'plugin', installingLength: runningPluginsLength, successLength: successPluginsLength, errorLength: errorPluginsLength })
|
||||
@ -99,8 +59,38 @@ const PluginTasks = () => {
|
||||
t,
|
||||
])
|
||||
|
||||
// Show icon if there are any plugin tasks (completed, running, or failed)
|
||||
// Only hide when there are absolutely no plugin tasks
|
||||
// Generic clear function that handles clearing and modal closing
|
||||
const clearPluginsAndClose = useCallback(async (
|
||||
plugins: Array<{ taskId: string, plugin_unique_identifier: string }>,
|
||||
) => {
|
||||
for (const plugin of plugins)
|
||||
await handleClearErrorPlugin(plugin.taskId, plugin.plugin_unique_identifier)
|
||||
if (runningPluginsLength === 0)
|
||||
setOpen(false)
|
||||
}, [handleClearErrorPlugin, runningPluginsLength])
|
||||
|
||||
// Clear handlers using the generic function
|
||||
const handleClearAll = useCallback(
|
||||
() => clearPluginsAndClose([...successPlugins, ...errorPlugins]),
|
||||
[clearPluginsAndClose, successPlugins, errorPlugins],
|
||||
)
|
||||
|
||||
const handleClearErrors = useCallback(
|
||||
() => clearPluginsAndClose(errorPlugins),
|
||||
[clearPluginsAndClose, errorPlugins],
|
||||
)
|
||||
|
||||
const handleClearSingle = useCallback(
|
||||
(taskId: string, pluginId: string) => clearPluginsAndClose([{ taskId, plugin_unique_identifier: pluginId }]),
|
||||
[clearPluginsAndClose],
|
||||
)
|
||||
|
||||
const handleTriggerClick = useCallback(() => {
|
||||
if (isFailed || isInstalling || isInstallingWithSuccess || isInstallingWithError || isSuccess)
|
||||
setOpen(v => !v)
|
||||
}, [isFailed, isInstalling, isInstallingWithSuccess, isInstallingWithError, isSuccess])
|
||||
|
||||
// Hide when no plugin tasks
|
||||
if (totalPluginsLength === 0)
|
||||
return null
|
||||
|
||||
@ -115,206 +105,30 @@ const PluginTasks = () => {
|
||||
crossAxis: 79,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={() => {
|
||||
if (isFailed || isInstalling || isInstallingWithSuccess || isInstallingWithError || isSuccess)
|
||||
setOpen(v => !v)
|
||||
}}
|
||||
>
|
||||
<Tooltip
|
||||
popupContent={tip}
|
||||
asChild
|
||||
offset={8}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex h-8 w-8 items-center justify-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs hover:bg-components-button-secondary-bg-hover',
|
||||
(isInstallingWithError || isFailed) && 'cursor-pointer border-components-button-destructive-secondary-border-hover bg-state-destructive-hover hover:bg-state-destructive-hover-alt',
|
||||
(isInstalling || isInstallingWithSuccess || isSuccess) && 'cursor-pointer hover:bg-components-button-secondary-bg-hover',
|
||||
)}
|
||||
id="plugin-task-trigger"
|
||||
>
|
||||
{
|
||||
(isInstalling || isInstallingWithError) && (
|
||||
<DownloadingIcon />
|
||||
)
|
||||
}
|
||||
{
|
||||
!(isInstalling || isInstallingWithError) && (
|
||||
<RiInstallLine
|
||||
className={cn(
|
||||
'h-4 w-4 text-components-button-secondary-text',
|
||||
(isInstallingWithError || isFailed) && 'text-components-button-destructive-secondary-text',
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<div className="absolute -right-1 -top-1">
|
||||
{
|
||||
(isInstalling || isInstallingWithSuccess) && (
|
||||
<ProgressCircle
|
||||
percentage={successPluginsLength / totalPluginsLength * 100}
|
||||
circleFillColor="fill-components-progress-brand-bg"
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
isInstallingWithError && (
|
||||
<ProgressCircle
|
||||
percentage={runningPluginsLength / totalPluginsLength * 100}
|
||||
circleFillColor="fill-components-progress-brand-bg"
|
||||
sectorFillColor="fill-components-progress-error-border"
|
||||
circleStrokeColor="stroke-components-progress-error-border"
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
(isSuccess || (successPluginsLength > 0 && runningPluginsLength === 0 && errorPluginsLength === 0)) && (
|
||||
<RiCheckboxCircleFill className="h-3.5 w-3.5 text-text-success" />
|
||||
)
|
||||
}
|
||||
{
|
||||
isFailed && (
|
||||
<RiErrorWarningFill className="h-3.5 w-3.5 text-text-destructive" />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PortalToFollowElemTrigger onClick={handleTriggerClick}>
|
||||
<TaskStatusIndicator
|
||||
tip={tip}
|
||||
isInstalling={isInstalling}
|
||||
isInstallingWithSuccess={isInstallingWithSuccess}
|
||||
isInstallingWithError={isInstallingWithError}
|
||||
isSuccess={isSuccess}
|
||||
isFailed={isFailed}
|
||||
successPluginsLength={successPluginsLength}
|
||||
runningPluginsLength={runningPluginsLength}
|
||||
totalPluginsLength={totalPluginsLength}
|
||||
onClick={() => {}}
|
||||
/>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[11]">
|
||||
<div className="w-[360px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg">
|
||||
{/* Running Plugins */}
|
||||
{runningPlugins.length > 0 && (
|
||||
<>
|
||||
<div className="system-sm-semibold-uppercase sticky top-0 flex h-7 items-center justify-between px-2 pt-1 text-text-secondary">
|
||||
{t('task.installing', { ns: 'plugin' })}
|
||||
{' '}
|
||||
(
|
||||
{runningPlugins.length}
|
||||
)
|
||||
</div>
|
||||
<div className="max-h-[200px] overflow-y-auto">
|
||||
{runningPlugins.map(runningPlugin => (
|
||||
<div
|
||||
key={runningPlugin.plugin_unique_identifier}
|
||||
className="flex items-center rounded-lg p-2 hover:bg-state-base-hover"
|
||||
>
|
||||
<div className="relative mr-2 flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge">
|
||||
<RiLoaderLine className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 animate-spin text-text-accent" />
|
||||
<CardIcon
|
||||
size="tiny"
|
||||
src={getIconUrl(runningPlugin.icon)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div className="system-md-regular truncate text-text-secondary">
|
||||
{runningPlugin.labels[language]}
|
||||
</div>
|
||||
<div className="system-xs-regular text-text-tertiary">
|
||||
{t('task.installing', { ns: 'plugin' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Success Plugins */}
|
||||
{successPlugins.length > 0 && (
|
||||
<>
|
||||
<div className="system-sm-semibold-uppercase sticky top-0 flex h-7 items-center justify-between px-2 pt-1 text-text-secondary">
|
||||
{t('task.installed', { ns: 'plugin' })}
|
||||
{' '}
|
||||
(
|
||||
{successPlugins.length}
|
||||
)
|
||||
<Button
|
||||
className="shrink-0"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
onClick={() => handleClearAllWithModal()}
|
||||
>
|
||||
{t('task.clearAll', { ns: 'plugin' })}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-[200px] overflow-y-auto">
|
||||
{successPlugins.map(successPlugin => (
|
||||
<div
|
||||
key={successPlugin.plugin_unique_identifier}
|
||||
className="flex items-center rounded-lg p-2 hover:bg-state-base-hover"
|
||||
>
|
||||
<div className="relative mr-2 flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge">
|
||||
<RiCheckboxCircleFill className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 text-text-success" />
|
||||
<CardIcon
|
||||
size="tiny"
|
||||
src={getIconUrl(successPlugin.icon)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div className="system-md-regular truncate text-text-secondary">
|
||||
{successPlugin.labels[language]}
|
||||
</div>
|
||||
<div className="system-xs-regular text-text-success">
|
||||
{successPlugin.message || t('task.installed', { ns: 'plugin' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Error Plugins */}
|
||||
{errorPlugins.length > 0 && (
|
||||
<>
|
||||
<div className="system-sm-semibold-uppercase sticky top-0 flex h-7 items-center justify-between px-2 pt-1 text-text-secondary">
|
||||
{t('task.installError', { ns: 'plugin', errorLength: errorPlugins.length })}
|
||||
<Button
|
||||
className="shrink-0"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
onClick={() => handleClearErrorsWithModal()}
|
||||
>
|
||||
{t('task.clearAll', { ns: 'plugin' })}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-[200px] overflow-y-auto">
|
||||
{errorPlugins.map(errorPlugin => (
|
||||
<div
|
||||
key={errorPlugin.plugin_unique_identifier}
|
||||
className="flex items-center rounded-lg p-2 hover:bg-state-base-hover"
|
||||
>
|
||||
<div className="relative mr-2 flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge">
|
||||
<RiErrorWarningFill className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 text-text-destructive" />
|
||||
<CardIcon
|
||||
size="tiny"
|
||||
src={getIconUrl(errorPlugin.icon)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div className="system-md-regular truncate text-text-secondary">
|
||||
{errorPlugin.labels[language]}
|
||||
</div>
|
||||
<div className="system-xs-regular break-all text-text-destructive">
|
||||
{errorPlugin.message}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="shrink-0"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
onClick={() => handleClearSingleWithModal(errorPlugin.taskId, errorPlugin.plugin_unique_identifier)}
|
||||
>
|
||||
{t('operation.clear', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<PluginTaskList
|
||||
runningPlugins={runningPlugins}
|
||||
successPlugins={successPlugins}
|
||||
errorPlugins={errorPlugins}
|
||||
getIconUrl={getIconUrl}
|
||||
onClearAll={handleClearAll}
|
||||
onClearErrors={handleClearErrors}
|
||||
onClearSingle={handleClearSingle}
|
||||
/>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,388 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
// Import mocks for assertions
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
|
||||
import { useInvalidateReferenceSettings, useMutationReferenceSettings, useReferenceSettings } from '@/service/use-plugins'
|
||||
import Toast from '../../base/toast'
|
||||
import { PermissionType } from '../types'
|
||||
import useReferenceSetting, { useCanInstallPluginFromMarketplace } from './use-reference-setting'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useReferenceSettings: vi.fn(),
|
||||
useMutationReferenceSettings: vi.fn(),
|
||||
useInvalidateReferenceSettings: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../base/toast', () => ({
|
||||
default: {
|
||||
notify: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('useReferenceSetting Hook', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Default mocks
|
||||
vi.mocked(useAppContext).mockReturnValue({
|
||||
isCurrentWorkspaceManager: false,
|
||||
isCurrentWorkspaceOwner: false,
|
||||
} as ReturnType<typeof useAppContext>)
|
||||
|
||||
vi.mocked(useReferenceSettings).mockReturnValue({
|
||||
data: {
|
||||
permission: {
|
||||
install_permission: PermissionType.everyone,
|
||||
debug_permission: PermissionType.everyone,
|
||||
},
|
||||
},
|
||||
} as ReturnType<typeof useReferenceSettings>)
|
||||
|
||||
vi.mocked(useMutationReferenceSettings).mockReturnValue({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
} as unknown as ReturnType<typeof useMutationReferenceSettings>)
|
||||
|
||||
vi.mocked(useInvalidateReferenceSettings).mockReturnValue(vi.fn())
|
||||
})
|
||||
|
||||
describe('hasPermission logic', () => {
|
||||
it('should return false when permission is undefined', () => {
|
||||
vi.mocked(useReferenceSettings).mockReturnValue({
|
||||
data: {
|
||||
permission: {
|
||||
install_permission: undefined,
|
||||
debug_permission: undefined,
|
||||
},
|
||||
},
|
||||
} as unknown as ReturnType<typeof useReferenceSettings>)
|
||||
|
||||
const { result } = renderHook(() => useReferenceSetting())
|
||||
|
||||
expect(result.current.canManagement).toBe(false)
|
||||
expect(result.current.canDebugger).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when permission is noOne', () => {
|
||||
vi.mocked(useReferenceSettings).mockReturnValue({
|
||||
data: {
|
||||
permission: {
|
||||
install_permission: PermissionType.noOne,
|
||||
debug_permission: PermissionType.noOne,
|
||||
},
|
||||
},
|
||||
} as ReturnType<typeof useReferenceSettings>)
|
||||
|
||||
const { result } = renderHook(() => useReferenceSetting())
|
||||
|
||||
expect(result.current.canManagement).toBe(false)
|
||||
expect(result.current.canDebugger).toBe(false)
|
||||
})
|
||||
|
||||
it('should return true when permission is everyone', () => {
|
||||
vi.mocked(useReferenceSettings).mockReturnValue({
|
||||
data: {
|
||||
permission: {
|
||||
install_permission: PermissionType.everyone,
|
||||
debug_permission: PermissionType.everyone,
|
||||
},
|
||||
},
|
||||
} as ReturnType<typeof useReferenceSettings>)
|
||||
|
||||
const { result } = renderHook(() => useReferenceSetting())
|
||||
|
||||
expect(result.current.canManagement).toBe(true)
|
||||
expect(result.current.canDebugger).toBe(true)
|
||||
})
|
||||
|
||||
it('should return isAdmin when permission is admin and user is manager', () => {
|
||||
vi.mocked(useAppContext).mockReturnValue({
|
||||
isCurrentWorkspaceManager: true,
|
||||
isCurrentWorkspaceOwner: false,
|
||||
} as ReturnType<typeof useAppContext>)
|
||||
|
||||
vi.mocked(useReferenceSettings).mockReturnValue({
|
||||
data: {
|
||||
permission: {
|
||||
install_permission: PermissionType.admin,
|
||||
debug_permission: PermissionType.admin,
|
||||
},
|
||||
},
|
||||
} as ReturnType<typeof useReferenceSettings>)
|
||||
|
||||
const { result } = renderHook(() => useReferenceSetting())
|
||||
|
||||
expect(result.current.canManagement).toBe(true)
|
||||
expect(result.current.canDebugger).toBe(true)
|
||||
})
|
||||
|
||||
it('should return isAdmin when permission is admin and user is owner', () => {
|
||||
vi.mocked(useAppContext).mockReturnValue({
|
||||
isCurrentWorkspaceManager: false,
|
||||
isCurrentWorkspaceOwner: true,
|
||||
} as ReturnType<typeof useAppContext>)
|
||||
|
||||
vi.mocked(useReferenceSettings).mockReturnValue({
|
||||
data: {
|
||||
permission: {
|
||||
install_permission: PermissionType.admin,
|
||||
debug_permission: PermissionType.admin,
|
||||
},
|
||||
},
|
||||
} as ReturnType<typeof useReferenceSettings>)
|
||||
|
||||
const { result } = renderHook(() => useReferenceSetting())
|
||||
|
||||
expect(result.current.canManagement).toBe(true)
|
||||
expect(result.current.canDebugger).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false when permission is admin and user is not admin', () => {
|
||||
vi.mocked(useAppContext).mockReturnValue({
|
||||
isCurrentWorkspaceManager: false,
|
||||
isCurrentWorkspaceOwner: false,
|
||||
} as ReturnType<typeof useAppContext>)
|
||||
|
||||
vi.mocked(useReferenceSettings).mockReturnValue({
|
||||
data: {
|
||||
permission: {
|
||||
install_permission: PermissionType.admin,
|
||||
debug_permission: PermissionType.admin,
|
||||
},
|
||||
},
|
||||
} as ReturnType<typeof useReferenceSettings>)
|
||||
|
||||
const { result } = renderHook(() => useReferenceSetting())
|
||||
|
||||
expect(result.current.canManagement).toBe(false)
|
||||
expect(result.current.canDebugger).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('canSetPermissions', () => {
|
||||
it('should be true when user is workspace manager', () => {
|
||||
vi.mocked(useAppContext).mockReturnValue({
|
||||
isCurrentWorkspaceManager: true,
|
||||
isCurrentWorkspaceOwner: false,
|
||||
} as ReturnType<typeof useAppContext>)
|
||||
|
||||
const { result } = renderHook(() => useReferenceSetting())
|
||||
|
||||
expect(result.current.canSetPermissions).toBe(true)
|
||||
})
|
||||
|
||||
it('should be true when user is workspace owner', () => {
|
||||
vi.mocked(useAppContext).mockReturnValue({
|
||||
isCurrentWorkspaceManager: false,
|
||||
isCurrentWorkspaceOwner: true,
|
||||
} as ReturnType<typeof useAppContext>)
|
||||
|
||||
const { result } = renderHook(() => useReferenceSetting())
|
||||
|
||||
expect(result.current.canSetPermissions).toBe(true)
|
||||
})
|
||||
|
||||
it('should be false when user is neither manager nor owner', () => {
|
||||
vi.mocked(useAppContext).mockReturnValue({
|
||||
isCurrentWorkspaceManager: false,
|
||||
isCurrentWorkspaceOwner: false,
|
||||
} as ReturnType<typeof useAppContext>)
|
||||
|
||||
const { result } = renderHook(() => useReferenceSetting())
|
||||
|
||||
expect(result.current.canSetPermissions).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setReferenceSettings callback', () => {
|
||||
it('should call invalidateReferenceSettings and show toast on success', async () => {
|
||||
const mockInvalidate = vi.fn()
|
||||
vi.mocked(useInvalidateReferenceSettings).mockReturnValue(mockInvalidate)
|
||||
|
||||
let onSuccessCallback: (() => void) | undefined
|
||||
vi.mocked(useMutationReferenceSettings).mockImplementation((options) => {
|
||||
onSuccessCallback = options?.onSuccess as () => void
|
||||
return {
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
} as unknown as ReturnType<typeof useMutationReferenceSettings>
|
||||
})
|
||||
|
||||
renderHook(() => useReferenceSetting())
|
||||
|
||||
// Trigger the onSuccess callback
|
||||
if (onSuccessCallback)
|
||||
onSuccessCallback()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInvalidate).toHaveBeenCalled()
|
||||
expect(Toast.notify).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: 'api.actionSuccess',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('returned values', () => {
|
||||
it('should return referenceSetting data', () => {
|
||||
const mockData = {
|
||||
permission: {
|
||||
install_permission: PermissionType.everyone,
|
||||
debug_permission: PermissionType.everyone,
|
||||
},
|
||||
}
|
||||
vi.mocked(useReferenceSettings).mockReturnValue({
|
||||
data: mockData,
|
||||
} as ReturnType<typeof useReferenceSettings>)
|
||||
|
||||
const { result } = renderHook(() => useReferenceSetting())
|
||||
|
||||
expect(result.current.referenceSetting).toEqual(mockData)
|
||||
})
|
||||
|
||||
it('should return isUpdatePending from mutation', () => {
|
||||
vi.mocked(useMutationReferenceSettings).mockReturnValue({
|
||||
mutate: vi.fn(),
|
||||
isPending: true,
|
||||
} as unknown as ReturnType<typeof useMutationReferenceSettings>)
|
||||
|
||||
const { result } = renderHook(() => useReferenceSetting())
|
||||
|
||||
expect(result.current.isUpdatePending).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle null data', () => {
|
||||
vi.mocked(useReferenceSettings).mockReturnValue({
|
||||
data: null,
|
||||
} as unknown as ReturnType<typeof useReferenceSettings>)
|
||||
|
||||
const { result } = renderHook(() => useReferenceSetting())
|
||||
|
||||
expect(result.current.canManagement).toBe(false)
|
||||
expect(result.current.canDebugger).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useCanInstallPluginFromMarketplace Hook', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
vi.mocked(useAppContext).mockReturnValue({
|
||||
isCurrentWorkspaceManager: true,
|
||||
isCurrentWorkspaceOwner: false,
|
||||
} as ReturnType<typeof useAppContext>)
|
||||
|
||||
vi.mocked(useReferenceSettings).mockReturnValue({
|
||||
data: {
|
||||
permission: {
|
||||
install_permission: PermissionType.everyone,
|
||||
debug_permission: PermissionType.everyone,
|
||||
},
|
||||
},
|
||||
} as ReturnType<typeof useReferenceSettings>)
|
||||
|
||||
vi.mocked(useMutationReferenceSettings).mockReturnValue({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
} as unknown as ReturnType<typeof useMutationReferenceSettings>)
|
||||
|
||||
vi.mocked(useInvalidateReferenceSettings).mockReturnValue(vi.fn())
|
||||
})
|
||||
|
||||
it('should return true when marketplace is enabled and canManagement is true', () => {
|
||||
vi.mocked(useGlobalPublicStore).mockImplementation((selector) => {
|
||||
const state = {
|
||||
systemFeatures: {
|
||||
enable_marketplace: true,
|
||||
},
|
||||
}
|
||||
return selector(state as Parameters<typeof selector>[0])
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useCanInstallPluginFromMarketplace())
|
||||
|
||||
expect(result.current.canInstallPluginFromMarketplace).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false when marketplace is disabled', () => {
|
||||
vi.mocked(useGlobalPublicStore).mockImplementation((selector) => {
|
||||
const state = {
|
||||
systemFeatures: {
|
||||
enable_marketplace: false,
|
||||
},
|
||||
}
|
||||
return selector(state as Parameters<typeof selector>[0])
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useCanInstallPluginFromMarketplace())
|
||||
|
||||
expect(result.current.canInstallPluginFromMarketplace).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when canManagement is false', () => {
|
||||
vi.mocked(useGlobalPublicStore).mockImplementation((selector) => {
|
||||
const state = {
|
||||
systemFeatures: {
|
||||
enable_marketplace: true,
|
||||
},
|
||||
}
|
||||
return selector(state as Parameters<typeof selector>[0])
|
||||
})
|
||||
|
||||
vi.mocked(useReferenceSettings).mockReturnValue({
|
||||
data: {
|
||||
permission: {
|
||||
install_permission: PermissionType.noOne,
|
||||
debug_permission: PermissionType.noOne,
|
||||
},
|
||||
},
|
||||
} as ReturnType<typeof useReferenceSettings>)
|
||||
|
||||
const { result } = renderHook(() => useCanInstallPluginFromMarketplace())
|
||||
|
||||
expect(result.current.canInstallPluginFromMarketplace).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when both marketplace is disabled and canManagement is false', () => {
|
||||
vi.mocked(useGlobalPublicStore).mockImplementation((selector) => {
|
||||
const state = {
|
||||
systemFeatures: {
|
||||
enable_marketplace: false,
|
||||
},
|
||||
}
|
||||
return selector(state as Parameters<typeof selector>[0])
|
||||
})
|
||||
|
||||
vi.mocked(useReferenceSettings).mockReturnValue({
|
||||
data: {
|
||||
permission: {
|
||||
install_permission: PermissionType.noOne,
|
||||
debug_permission: PermissionType.noOne,
|
||||
},
|
||||
},
|
||||
} as ReturnType<typeof useReferenceSettings>)
|
||||
|
||||
const { result } = renderHook(() => useCanInstallPluginFromMarketplace())
|
||||
|
||||
expect(result.current.canInstallPluginFromMarketplace).toBe(false)
|
||||
})
|
||||
})
|
||||
487
web/app/components/plugins/plugin-page/use-uploader.spec.ts
Normal file
487
web/app/components/plugins/plugin-page/use-uploader.spec.ts
Normal file
@ -0,0 +1,487 @@
|
||||
import type { RefObject } from 'react'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useUploader } from './use-uploader'
|
||||
|
||||
describe('useUploader Hook', () => {
|
||||
let mockContainerRef: RefObject<HTMLDivElement | null>
|
||||
let mockOnFileChange: (file: File | null) => void
|
||||
let mockContainer: HTMLDivElement
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockContainer = document.createElement('div')
|
||||
document.body.appendChild(mockContainer)
|
||||
|
||||
mockContainerRef = { current: mockContainer }
|
||||
mockOnFileChange = vi.fn()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (mockContainer.parentNode)
|
||||
document.body.removeChild(mockContainer)
|
||||
})
|
||||
|
||||
describe('Initial State', () => {
|
||||
it('should return initial state with dragging false', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useUploader({
|
||||
onFileChange: mockOnFileChange,
|
||||
containerRef: mockContainerRef,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.current.dragging).toBe(false)
|
||||
expect(result.current.fileUploader.current).toBeNull()
|
||||
expect(result.current.fileChangeHandle).not.toBeNull()
|
||||
expect(result.current.removeFile).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should return null handlers when disabled', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useUploader({
|
||||
onFileChange: mockOnFileChange,
|
||||
containerRef: mockContainerRef,
|
||||
enabled: false,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.current.dragging).toBe(false)
|
||||
expect(result.current.fileChangeHandle).toBeNull()
|
||||
expect(result.current.removeFile).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Drag Events', () => {
|
||||
it('should handle dragenter and set dragging to true', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useUploader({
|
||||
onFileChange: mockOnFileChange,
|
||||
containerRef: mockContainerRef,
|
||||
}),
|
||||
)
|
||||
|
||||
const dragEnterEvent = new Event('dragenter', { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(dragEnterEvent, 'dataTransfer', {
|
||||
value: { types: ['Files'] },
|
||||
})
|
||||
|
||||
act(() => {
|
||||
mockContainer.dispatchEvent(dragEnterEvent)
|
||||
})
|
||||
|
||||
expect(result.current.dragging).toBe(true)
|
||||
})
|
||||
|
||||
it('should not set dragging when dragenter without Files type', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useUploader({
|
||||
onFileChange: mockOnFileChange,
|
||||
containerRef: mockContainerRef,
|
||||
}),
|
||||
)
|
||||
|
||||
const dragEnterEvent = new Event('dragenter', { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(dragEnterEvent, 'dataTransfer', {
|
||||
value: { types: ['text/plain'] },
|
||||
})
|
||||
|
||||
act(() => {
|
||||
mockContainer.dispatchEvent(dragEnterEvent)
|
||||
})
|
||||
|
||||
expect(result.current.dragging).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle dragover event', () => {
|
||||
renderHook(() =>
|
||||
useUploader({
|
||||
onFileChange: mockOnFileChange,
|
||||
containerRef: mockContainerRef,
|
||||
}),
|
||||
)
|
||||
|
||||
const dragOverEvent = new Event('dragover', { bubbles: true, cancelable: true })
|
||||
|
||||
act(() => {
|
||||
mockContainer.dispatchEvent(dragOverEvent)
|
||||
})
|
||||
|
||||
// dragover should prevent default and stop propagation
|
||||
expect(mockContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle dragleave when relatedTarget is null', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useUploader({
|
||||
onFileChange: mockOnFileChange,
|
||||
containerRef: mockContainerRef,
|
||||
}),
|
||||
)
|
||||
|
||||
// First set dragging to true
|
||||
const dragEnterEvent = new Event('dragenter', { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(dragEnterEvent, 'dataTransfer', {
|
||||
value: { types: ['Files'] },
|
||||
})
|
||||
act(() => {
|
||||
mockContainer.dispatchEvent(dragEnterEvent)
|
||||
})
|
||||
expect(result.current.dragging).toBe(true)
|
||||
|
||||
// Then trigger dragleave with null relatedTarget
|
||||
const dragLeaveEvent = new Event('dragleave', { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(dragLeaveEvent, 'relatedTarget', {
|
||||
value: null,
|
||||
})
|
||||
|
||||
act(() => {
|
||||
mockContainer.dispatchEvent(dragLeaveEvent)
|
||||
})
|
||||
|
||||
expect(result.current.dragging).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle dragleave when relatedTarget is outside container', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useUploader({
|
||||
onFileChange: mockOnFileChange,
|
||||
containerRef: mockContainerRef,
|
||||
}),
|
||||
)
|
||||
|
||||
// First set dragging to true
|
||||
const dragEnterEvent = new Event('dragenter', { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(dragEnterEvent, 'dataTransfer', {
|
||||
value: { types: ['Files'] },
|
||||
})
|
||||
act(() => {
|
||||
mockContainer.dispatchEvent(dragEnterEvent)
|
||||
})
|
||||
expect(result.current.dragging).toBe(true)
|
||||
|
||||
// Create element outside container
|
||||
const outsideElement = document.createElement('div')
|
||||
document.body.appendChild(outsideElement)
|
||||
|
||||
// Trigger dragleave with relatedTarget outside container
|
||||
const dragLeaveEvent = new Event('dragleave', { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(dragLeaveEvent, 'relatedTarget', {
|
||||
value: outsideElement,
|
||||
})
|
||||
|
||||
act(() => {
|
||||
mockContainer.dispatchEvent(dragLeaveEvent)
|
||||
})
|
||||
|
||||
expect(result.current.dragging).toBe(false)
|
||||
document.body.removeChild(outsideElement)
|
||||
})
|
||||
|
||||
it('should not set dragging to false when relatedTarget is inside container', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useUploader({
|
||||
onFileChange: mockOnFileChange,
|
||||
containerRef: mockContainerRef,
|
||||
}),
|
||||
)
|
||||
|
||||
// First set dragging to true
|
||||
const dragEnterEvent = new Event('dragenter', { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(dragEnterEvent, 'dataTransfer', {
|
||||
value: { types: ['Files'] },
|
||||
})
|
||||
act(() => {
|
||||
mockContainer.dispatchEvent(dragEnterEvent)
|
||||
})
|
||||
expect(result.current.dragging).toBe(true)
|
||||
|
||||
// Create element inside container
|
||||
const insideElement = document.createElement('div')
|
||||
mockContainer.appendChild(insideElement)
|
||||
|
||||
// Trigger dragleave with relatedTarget inside container
|
||||
const dragLeaveEvent = new Event('dragleave', { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(dragLeaveEvent, 'relatedTarget', {
|
||||
value: insideElement,
|
||||
})
|
||||
|
||||
act(() => {
|
||||
mockContainer.dispatchEvent(dragLeaveEvent)
|
||||
})
|
||||
|
||||
// Should still be dragging since relatedTarget is inside container
|
||||
expect(result.current.dragging).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Drop Events', () => {
|
||||
it('should handle drop event with files', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useUploader({
|
||||
onFileChange: mockOnFileChange,
|
||||
containerRef: mockContainerRef,
|
||||
}),
|
||||
)
|
||||
|
||||
// First set dragging to true
|
||||
const dragEnterEvent = new Event('dragenter', { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(dragEnterEvent, 'dataTransfer', {
|
||||
value: { types: ['Files'] },
|
||||
})
|
||||
act(() => {
|
||||
mockContainer.dispatchEvent(dragEnterEvent)
|
||||
})
|
||||
|
||||
// Create mock file
|
||||
const file = new File(['content'], 'test.difypkg', { type: 'application/octet-stream' })
|
||||
|
||||
// Trigger drop event
|
||||
const dropEvent = new Event('drop', { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(dropEvent, 'dataTransfer', {
|
||||
value: { files: [file] },
|
||||
})
|
||||
|
||||
act(() => {
|
||||
mockContainer.dispatchEvent(dropEvent)
|
||||
})
|
||||
|
||||
expect(result.current.dragging).toBe(false)
|
||||
expect(mockOnFileChange).toHaveBeenCalledWith(file)
|
||||
})
|
||||
|
||||
it('should not call onFileChange when drop has no dataTransfer', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useUploader({
|
||||
onFileChange: mockOnFileChange,
|
||||
containerRef: mockContainerRef,
|
||||
}),
|
||||
)
|
||||
|
||||
// Set dragging first
|
||||
const dragEnterEvent = new Event('dragenter', { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(dragEnterEvent, 'dataTransfer', {
|
||||
value: { types: ['Files'] },
|
||||
})
|
||||
act(() => {
|
||||
mockContainer.dispatchEvent(dragEnterEvent)
|
||||
})
|
||||
|
||||
// Drop without dataTransfer
|
||||
const dropEvent = new Event('drop', { bubbles: true, cancelable: true })
|
||||
// No dataTransfer property
|
||||
|
||||
act(() => {
|
||||
mockContainer.dispatchEvent(dropEvent)
|
||||
})
|
||||
|
||||
expect(result.current.dragging).toBe(false)
|
||||
expect(mockOnFileChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call onFileChange when drop has empty files array', () => {
|
||||
renderHook(() =>
|
||||
useUploader({
|
||||
onFileChange: mockOnFileChange,
|
||||
containerRef: mockContainerRef,
|
||||
}),
|
||||
)
|
||||
|
||||
const dropEvent = new Event('drop', { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(dropEvent, 'dataTransfer', {
|
||||
value: { files: [] },
|
||||
})
|
||||
|
||||
act(() => {
|
||||
mockContainer.dispatchEvent(dropEvent)
|
||||
})
|
||||
|
||||
expect(mockOnFileChange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('File Change Handler', () => {
|
||||
it('should call onFileChange with file from input', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useUploader({
|
||||
onFileChange: mockOnFileChange,
|
||||
containerRef: mockContainerRef,
|
||||
}),
|
||||
)
|
||||
|
||||
const file = new File(['content'], 'test.difypkg', { type: 'application/octet-stream' })
|
||||
const mockEvent = {
|
||||
target: {
|
||||
files: [file],
|
||||
},
|
||||
} as unknown as React.ChangeEvent<HTMLInputElement>
|
||||
|
||||
act(() => {
|
||||
result.current.fileChangeHandle?.(mockEvent)
|
||||
})
|
||||
|
||||
expect(mockOnFileChange).toHaveBeenCalledWith(file)
|
||||
})
|
||||
|
||||
it('should call onFileChange with null when no files', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useUploader({
|
||||
onFileChange: mockOnFileChange,
|
||||
containerRef: mockContainerRef,
|
||||
}),
|
||||
)
|
||||
|
||||
const mockEvent = {
|
||||
target: {
|
||||
files: null,
|
||||
},
|
||||
} as unknown as React.ChangeEvent<HTMLInputElement>
|
||||
|
||||
act(() => {
|
||||
result.current.fileChangeHandle?.(mockEvent)
|
||||
})
|
||||
|
||||
expect(mockOnFileChange).toHaveBeenCalledWith(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Remove File', () => {
|
||||
it('should call onFileChange with null', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useUploader({
|
||||
onFileChange: mockOnFileChange,
|
||||
containerRef: mockContainerRef,
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.removeFile?.()
|
||||
})
|
||||
|
||||
expect(mockOnFileChange).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('should handle removeFile when fileUploader has a value', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useUploader({
|
||||
onFileChange: mockOnFileChange,
|
||||
containerRef: mockContainerRef,
|
||||
}),
|
||||
)
|
||||
|
||||
// Create a mock input element with value property
|
||||
const mockInput = {
|
||||
value: 'test.difypkg',
|
||||
}
|
||||
|
||||
// Override the fileUploader ref
|
||||
Object.defineProperty(result.current.fileUploader, 'current', {
|
||||
value: mockInput,
|
||||
writable: true,
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.removeFile?.()
|
||||
})
|
||||
|
||||
expect(mockOnFileChange).toHaveBeenCalledWith(null)
|
||||
expect(mockInput.value).toBe('')
|
||||
})
|
||||
|
||||
it('should handle removeFile when fileUploader is null', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useUploader({
|
||||
onFileChange: mockOnFileChange,
|
||||
containerRef: mockContainerRef,
|
||||
}),
|
||||
)
|
||||
|
||||
// fileUploader.current is null by default
|
||||
act(() => {
|
||||
result.current.removeFile?.()
|
||||
})
|
||||
|
||||
expect(mockOnFileChange).toHaveBeenCalledWith(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Enabled/Disabled State', () => {
|
||||
it('should not add event listeners when disabled', () => {
|
||||
const addEventListenerSpy = vi.spyOn(mockContainer, 'addEventListener')
|
||||
|
||||
renderHook(() =>
|
||||
useUploader({
|
||||
onFileChange: mockOnFileChange,
|
||||
containerRef: mockContainerRef,
|
||||
enabled: false,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(addEventListenerSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should add event listeners when enabled', () => {
|
||||
const addEventListenerSpy = vi.spyOn(mockContainer, 'addEventListener')
|
||||
|
||||
renderHook(() =>
|
||||
useUploader({
|
||||
onFileChange: mockOnFileChange,
|
||||
containerRef: mockContainerRef,
|
||||
enabled: true,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith('dragenter', expect.any(Function))
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith('dragover', expect.any(Function))
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith('dragleave', expect.any(Function))
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith('drop', expect.any(Function))
|
||||
})
|
||||
|
||||
it('should remove event listeners on cleanup', () => {
|
||||
const removeEventListenerSpy = vi.spyOn(mockContainer, 'removeEventListener')
|
||||
|
||||
const { unmount } = renderHook(() =>
|
||||
useUploader({
|
||||
onFileChange: mockOnFileChange,
|
||||
containerRef: mockContainerRef,
|
||||
enabled: true,
|
||||
}),
|
||||
)
|
||||
|
||||
unmount()
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('dragenter', expect.any(Function))
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('dragover', expect.any(Function))
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('dragleave', expect.any(Function))
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('drop', expect.any(Function))
|
||||
})
|
||||
|
||||
it('should return false for dragging when disabled', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useUploader({
|
||||
onFileChange: mockOnFileChange,
|
||||
containerRef: mockContainerRef,
|
||||
enabled: false,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.current.dragging).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Container Ref Edge Cases', () => {
|
||||
it('should handle null containerRef.current', () => {
|
||||
const nullRef: RefObject<HTMLDivElement | null> = { current: null }
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useUploader({
|
||||
onFileChange: mockOnFileChange,
|
||||
containerRef: nullRef,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.current.dragging).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
550
web/app/components/rag-pipeline/index.spec.tsx
Normal file
550
web/app/components/rag-pipeline/index.spec.tsx
Normal file
@ -0,0 +1,550 @@
|
||||
import type { FetchWorkflowDraftResponse } from '@/types/workflow'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
|
||||
// Import real utility functions (pure functions, no side effects)
|
||||
|
||||
// Import mocked modules for manipulation
|
||||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
import { usePipelineInit } from './hooks'
|
||||
import RagPipelineWrapper from './index'
|
||||
import { processNodesWithoutDataSource } from './utils'
|
||||
|
||||
// Mock: Context - need to control return values
|
||||
vi.mock('@/context/dataset-detail', () => ({
|
||||
useDatasetDetailContextWithSelector: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock: Hook with API calls
|
||||
vi.mock('./hooks', () => ({
|
||||
usePipelineInit: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock: Store creator
|
||||
vi.mock('./store', () => ({
|
||||
createRagPipelineSliceSlice: vi.fn(() => ({})),
|
||||
}))
|
||||
|
||||
// Mock: Utility with complex workflow dependencies (generateNewNode, etc.)
|
||||
vi.mock('./utils', () => ({
|
||||
processNodesWithoutDataSource: vi.fn((nodes, viewport) => ({
|
||||
nodes,
|
||||
viewport,
|
||||
})),
|
||||
}))
|
||||
|
||||
// Mock: Complex component with useParams, Toast, API calls
|
||||
vi.mock('./components/conversion', () => ({
|
||||
default: () => <div data-testid="conversion-component">Conversion Component</div>,
|
||||
}))
|
||||
|
||||
// Mock: Complex component with many hooks and workflow dependencies
|
||||
vi.mock('./components/rag-pipeline-main', () => ({
|
||||
default: ({ nodes, edges, viewport }: any) => (
|
||||
<div data-testid="rag-pipeline-main">
|
||||
<span data-testid="nodes-count">{nodes?.length ?? 0}</span>
|
||||
<span data-testid="edges-count">{edges?.length ?? 0}</span>
|
||||
<span data-testid="viewport-zoom">{viewport?.zoom ?? 'none'}</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock: Complex component with ReactFlow and many providers
|
||||
vi.mock('@/app/components/workflow', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="workflow-default-context">{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock: Context provider
|
||||
vi.mock('@/app/components/workflow/context', () => ({
|
||||
WorkflowContextProvider: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="workflow-context-provider">{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Type assertions for mocked functions
|
||||
const mockUseDatasetDetailContextWithSelector = vi.mocked(useDatasetDetailContextWithSelector)
|
||||
const mockUsePipelineInit = vi.mocked(usePipelineInit)
|
||||
const mockProcessNodesWithoutDataSource = vi.mocked(processNodesWithoutDataSource)
|
||||
|
||||
// Helper to mock selector with actual execution (increases function coverage)
|
||||
// This executes the real selector function: s => s.dataset?.pipeline_id
|
||||
const mockSelectorWithDataset = (pipelineId: string | null | undefined) => {
|
||||
mockUseDatasetDetailContextWithSelector.mockImplementation((selector: (state: any) => any) => {
|
||||
const mockState = { dataset: pipelineId ? { pipeline_id: pipelineId } : null }
|
||||
return selector(mockState)
|
||||
})
|
||||
}
|
||||
|
||||
// Test data factory
|
||||
const createMockWorkflowData = (overrides?: Partial<FetchWorkflowDraftResponse>): FetchWorkflowDraftResponse => ({
|
||||
graph: {
|
||||
nodes: [
|
||||
{ id: 'node-1', type: 'custom', data: { type: BlockEnum.Start, title: 'Start' }, position: { x: 100, y: 100 } },
|
||||
{ id: 'node-2', type: 'custom', data: { type: BlockEnum.End, title: 'End' }, position: { x: 300, y: 100 } },
|
||||
],
|
||||
edges: [
|
||||
{ id: 'edge-1', source: 'node-1', target: 'node-2', type: 'custom' },
|
||||
],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
hash: 'test-hash-123',
|
||||
updated_at: 1234567890,
|
||||
tool_published: false,
|
||||
environment_variables: [],
|
||||
...overrides,
|
||||
} as FetchWorkflowDraftResponse)
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('RagPipelineWrapper', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render Conversion component when pipelineId is null', () => {
|
||||
mockSelectorWithDataset(null)
|
||||
mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: false })
|
||||
|
||||
render(<RagPipelineWrapper />)
|
||||
|
||||
expect(screen.getByTestId('conversion-component')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('workflow-context-provider')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Conversion component when pipelineId is undefined', () => {
|
||||
mockSelectorWithDataset(undefined)
|
||||
mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: false })
|
||||
|
||||
render(<RagPipelineWrapper />)
|
||||
|
||||
expect(screen.getByTestId('conversion-component')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Conversion component when pipelineId is empty string', () => {
|
||||
mockSelectorWithDataset('')
|
||||
mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: false })
|
||||
|
||||
render(<RagPipelineWrapper />)
|
||||
|
||||
expect(screen.getByTestId('conversion-component')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render WorkflowContextProvider when pipelineId exists', () => {
|
||||
mockSelectorWithDataset('pipeline-123')
|
||||
mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: true })
|
||||
|
||||
render(<RagPipelineWrapper />)
|
||||
|
||||
expect(screen.getByTestId('workflow-context-provider')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('conversion-component')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props Variations', () => {
|
||||
it('should pass injectWorkflowStoreSliceFn to WorkflowContextProvider', () => {
|
||||
mockSelectorWithDataset('pipeline-456')
|
||||
mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: true })
|
||||
|
||||
render(<RagPipelineWrapper />)
|
||||
|
||||
expect(screen.getByTestId('workflow-context-provider')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('RagPipeline', () => {
|
||||
beforeEach(() => {
|
||||
// Default setup for RagPipeline tests - execute real selector function
|
||||
mockSelectorWithDataset('pipeline-123')
|
||||
})
|
||||
|
||||
describe('Loading State', () => {
|
||||
it('should render Loading component when isLoading is true', () => {
|
||||
mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: true })
|
||||
|
||||
render(<RagPipelineWrapper />)
|
||||
|
||||
// Real Loading component has role="status"
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Loading component when data is undefined', () => {
|
||||
mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: false })
|
||||
|
||||
render(<RagPipelineWrapper />)
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Loading component when both data is undefined and isLoading is true', () => {
|
||||
mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: true })
|
||||
|
||||
render(<RagPipelineWrapper />)
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Data Loaded State', () => {
|
||||
it('should render RagPipelineMain when data is loaded', () => {
|
||||
const mockData = createMockWorkflowData()
|
||||
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
|
||||
|
||||
render(<RagPipelineWrapper />)
|
||||
|
||||
expect(screen.getByTestId('rag-pipeline-main')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass processed nodes to RagPipelineMain', () => {
|
||||
const mockData = createMockWorkflowData()
|
||||
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
|
||||
|
||||
render(<RagPipelineWrapper />)
|
||||
|
||||
expect(screen.getByTestId('nodes-count').textContent).toBe('2')
|
||||
})
|
||||
|
||||
it('should pass edges to RagPipelineMain', () => {
|
||||
const mockData = createMockWorkflowData()
|
||||
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
|
||||
|
||||
render(<RagPipelineWrapper />)
|
||||
|
||||
expect(screen.getByTestId('edges-count').textContent).toBe('1')
|
||||
})
|
||||
|
||||
it('should pass viewport to RagPipelineMain', () => {
|
||||
const mockData = createMockWorkflowData({
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 100, y: 200, zoom: 1.5 },
|
||||
},
|
||||
})
|
||||
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
|
||||
|
||||
render(<RagPipelineWrapper />)
|
||||
|
||||
expect(screen.getByTestId('viewport-zoom').textContent).toBe('1.5')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Memoization Logic', () => {
|
||||
it('should process nodes through initialNodes when data is loaded', () => {
|
||||
const mockData = createMockWorkflowData()
|
||||
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
|
||||
|
||||
render(<RagPipelineWrapper />)
|
||||
|
||||
// initialNodes is a real function - verify nodes are rendered
|
||||
// The real initialNodes processes nodes and adds position data
|
||||
expect(screen.getByTestId('rag-pipeline-main')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should process edges through initialEdges when data is loaded', () => {
|
||||
const mockData = createMockWorkflowData()
|
||||
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
|
||||
|
||||
render(<RagPipelineWrapper />)
|
||||
|
||||
// initialEdges is a real function - verify component renders with edges
|
||||
expect(screen.getByTestId('edges-count').textContent).toBe('1')
|
||||
})
|
||||
|
||||
it('should call processNodesWithoutDataSource with nodesData and viewport', () => {
|
||||
const mockData = createMockWorkflowData()
|
||||
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
|
||||
|
||||
render(<RagPipelineWrapper />)
|
||||
|
||||
expect(mockProcessNodesWithoutDataSource).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not process nodes when data is undefined', () => {
|
||||
mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: false })
|
||||
|
||||
render(<RagPipelineWrapper />)
|
||||
|
||||
// When data is undefined, Loading is shown, processNodesWithoutDataSource is not called
|
||||
expect(mockProcessNodesWithoutDataSource).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use memoized values when data reference is same', () => {
|
||||
const mockData = createMockWorkflowData()
|
||||
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
|
||||
|
||||
const { rerender } = render(<RagPipelineWrapper />)
|
||||
|
||||
// Clear mock call count after initial render
|
||||
mockProcessNodesWithoutDataSource.mockClear()
|
||||
|
||||
// Rerender with same data reference (no change to mockUsePipelineInit)
|
||||
rerender(<RagPipelineWrapper />)
|
||||
|
||||
// processNodesWithoutDataSource should not be called again due to useMemo
|
||||
// Note: React strict mode may cause double render, so we check it's not excessive
|
||||
expect(mockProcessNodesWithoutDataSource.mock.calls.length).toBeLessThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty nodes array', () => {
|
||||
const mockData = createMockWorkflowData({
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
})
|
||||
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
|
||||
|
||||
render(<RagPipelineWrapper />)
|
||||
|
||||
expect(screen.getByTestId('nodes-count').textContent).toBe('0')
|
||||
})
|
||||
|
||||
it('should handle empty edges array', () => {
|
||||
const mockData = createMockWorkflowData({
|
||||
graph: {
|
||||
nodes: [{ id: 'node-1', type: 'custom', data: { type: BlockEnum.Start, title: 'Start', desc: '' }, position: { x: 0, y: 0 } }],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
})
|
||||
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
|
||||
|
||||
render(<RagPipelineWrapper />)
|
||||
|
||||
expect(screen.getByTestId('edges-count').textContent).toBe('0')
|
||||
})
|
||||
|
||||
it('should handle undefined viewport', () => {
|
||||
const mockData = createMockWorkflowData({
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: undefined as any,
|
||||
},
|
||||
})
|
||||
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
|
||||
|
||||
render(<RagPipelineWrapper />)
|
||||
|
||||
expect(screen.getByTestId('rag-pipeline-main')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle null viewport', () => {
|
||||
const mockData = createMockWorkflowData({
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: null as any,
|
||||
},
|
||||
})
|
||||
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
|
||||
|
||||
render(<RagPipelineWrapper />)
|
||||
|
||||
expect(screen.getByTestId('rag-pipeline-main')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle large number of nodes', () => {
|
||||
const largeNodesArray = Array.from({ length: 100 }, (_, i) => ({
|
||||
id: `node-${i}`,
|
||||
type: 'custom',
|
||||
data: { type: BlockEnum.Start, title: `Node ${i}`, desc: '' },
|
||||
position: { x: i * 100, y: 0 },
|
||||
}))
|
||||
|
||||
const mockData = createMockWorkflowData({
|
||||
graph: {
|
||||
nodes: largeNodesArray,
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
})
|
||||
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
|
||||
|
||||
render(<RagPipelineWrapper />)
|
||||
|
||||
expect(screen.getByTestId('nodes-count').textContent).toBe('100')
|
||||
})
|
||||
|
||||
it('should handle viewport with edge case zoom values', () => {
|
||||
const mockData = createMockWorkflowData({
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: -1000, y: -1000, zoom: 0.25 },
|
||||
},
|
||||
})
|
||||
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
|
||||
|
||||
render(<RagPipelineWrapper />)
|
||||
|
||||
expect(screen.getByTestId('viewport-zoom').textContent).toBe('0.25')
|
||||
})
|
||||
|
||||
it('should handle viewport with maximum zoom', () => {
|
||||
const mockData = createMockWorkflowData({
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 4 },
|
||||
},
|
||||
})
|
||||
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
|
||||
|
||||
render(<RagPipelineWrapper />)
|
||||
|
||||
expect(screen.getByTestId('viewport-zoom').textContent).toBe('4')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Integration', () => {
|
||||
it('should render WorkflowWithDefaultContext as wrapper', () => {
|
||||
const mockData = createMockWorkflowData()
|
||||
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
|
||||
|
||||
render(<RagPipelineWrapper />)
|
||||
|
||||
expect(screen.getByTestId('workflow-default-context')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should nest RagPipelineMain inside WorkflowWithDefaultContext', () => {
|
||||
const mockData = createMockWorkflowData()
|
||||
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
|
||||
|
||||
render(<RagPipelineWrapper />)
|
||||
|
||||
const workflowContext = screen.getByTestId('workflow-default-context')
|
||||
const ragPipelineMain = screen.getByTestId('rag-pipeline-main')
|
||||
|
||||
expect(workflowContext).toContainElement(ragPipelineMain)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('processNodesWithoutDataSource utility integration', () => {
|
||||
beforeEach(() => {
|
||||
mockSelectorWithDataset('pipeline-123')
|
||||
})
|
||||
|
||||
it('should process nodes through processNodesWithoutDataSource', () => {
|
||||
const mockData = createMockWorkflowData()
|
||||
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
|
||||
mockProcessNodesWithoutDataSource.mockReturnValue({
|
||||
nodes: [{ id: 'processed-node', type: 'custom', data: { type: BlockEnum.Start, title: 'Processed', desc: '' }, position: { x: 0, y: 0 } }] as any,
|
||||
viewport: { x: 0, y: 0, zoom: 2 },
|
||||
})
|
||||
|
||||
render(<RagPipelineWrapper />)
|
||||
|
||||
expect(mockProcessNodesWithoutDataSource).toHaveBeenCalled()
|
||||
expect(screen.getByTestId('nodes-count').textContent).toBe('1')
|
||||
expect(screen.getByTestId('viewport-zoom').textContent).toBe('2')
|
||||
})
|
||||
|
||||
it('should handle processNodesWithoutDataSource returning modified viewport', () => {
|
||||
const mockData = createMockWorkflowData()
|
||||
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
|
||||
mockProcessNodesWithoutDataSource.mockReturnValue({
|
||||
nodes: [],
|
||||
viewport: { x: 500, y: 500, zoom: 0.5 },
|
||||
})
|
||||
|
||||
render(<RagPipelineWrapper />)
|
||||
|
||||
expect(screen.getByTestId('viewport-zoom').textContent).toBe('0.5')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Conditional Rendering Flow', () => {
|
||||
it('should transition from loading to loaded state', () => {
|
||||
mockSelectorWithDataset('pipeline-123')
|
||||
|
||||
// Start with loading state
|
||||
mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: true })
|
||||
const { rerender } = render(<RagPipelineWrapper />)
|
||||
|
||||
// Real Loading component has role="status"
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
|
||||
// Transition to loaded state
|
||||
const mockData = createMockWorkflowData()
|
||||
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
|
||||
rerender(<RagPipelineWrapper />)
|
||||
|
||||
expect(screen.getByTestId('rag-pipeline-main')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should switch from Conversion to Pipeline when pipelineId becomes available', () => {
|
||||
// Start without pipelineId
|
||||
mockSelectorWithDataset(null)
|
||||
mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: false })
|
||||
|
||||
const { rerender } = render(<RagPipelineWrapper />)
|
||||
|
||||
expect(screen.getByTestId('conversion-component')).toBeInTheDocument()
|
||||
|
||||
// PipelineId becomes available
|
||||
mockSelectorWithDataset('new-pipeline-id')
|
||||
mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: true })
|
||||
rerender(<RagPipelineWrapper />)
|
||||
|
||||
expect(screen.queryByTestId('conversion-component')).not.toBeInTheDocument()
|
||||
// Real Loading component has role="status"
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error Handling', () => {
|
||||
beforeEach(() => {
|
||||
mockSelectorWithDataset('pipeline-123')
|
||||
})
|
||||
|
||||
it('should throw when graph nodes is null', () => {
|
||||
const mockData = {
|
||||
graph: {
|
||||
nodes: null as any,
|
||||
edges: null as any,
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
hash: 'test',
|
||||
updated_at: 123,
|
||||
} as FetchWorkflowDraftResponse
|
||||
|
||||
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
|
||||
|
||||
// Suppress console.error for expected error
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
// Real initialNodes will throw when nodes is null
|
||||
// This documents the component's current behavior - it requires valid nodes array
|
||||
expect(() => render(<RagPipelineWrapper />)).toThrow()
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should throw when graph property is missing', () => {
|
||||
const mockData = {
|
||||
hash: 'test',
|
||||
updated_at: 123,
|
||||
} as unknown as FetchWorkflowDraftResponse
|
||||
|
||||
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
|
||||
|
||||
// Suppress console.error for expected error
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
// When graph is undefined, component throws because data.graph.nodes is accessed
|
||||
// This documents the component's current behavior - it requires graph to be present
|
||||
expect(() => render(<RagPipelineWrapper />)).toThrow()
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
@ -47,7 +47,6 @@ type AllToolsProps = {
|
||||
canNotSelectMultiple?: boolean
|
||||
onSelectMultiple?: (type: BlockEnum, tools: ToolDefaultValue[]) => void
|
||||
selectedTools?: ToolValue[]
|
||||
canChooseMCPTool?: boolean
|
||||
onTagsChange?: Dispatch<SetStateAction<string[]>>
|
||||
isInRAGPipeline?: boolean
|
||||
featuredPlugins?: Plugin[]
|
||||
@ -71,7 +70,6 @@ const AllTools = ({
|
||||
customTools,
|
||||
mcpTools = [],
|
||||
selectedTools,
|
||||
canChooseMCPTool,
|
||||
onTagsChange,
|
||||
isInRAGPipeline = false,
|
||||
featuredPlugins = [],
|
||||
@ -249,7 +247,6 @@ const AllTools = ({
|
||||
providerMap={providerMap}
|
||||
onSelect={onSelect}
|
||||
selectedTools={selectedTools}
|
||||
canChooseMCPTool={canChooseMCPTool}
|
||||
isLoading={featuredLoading}
|
||||
onInstallSuccess={async () => {
|
||||
await onFeaturedInstallSuccess?.()
|
||||
@ -275,7 +272,6 @@ const AllTools = ({
|
||||
viewType={isSupportGroupView ? activeView : ViewType.flat}
|
||||
hasSearchText={hasSearchText}
|
||||
selectedTools={selectedTools}
|
||||
canChooseMCPTool={canChooseMCPTool}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -30,7 +30,6 @@ type FeaturedToolsProps = {
|
||||
providerMap: Map<string, ToolWithProvider>
|
||||
onSelect: (type: BlockEnum, tool: ToolDefaultValue) => void
|
||||
selectedTools?: ToolValue[]
|
||||
canChooseMCPTool?: boolean
|
||||
isLoading?: boolean
|
||||
onInstallSuccess?: () => void
|
||||
}
|
||||
@ -42,7 +41,6 @@ const FeaturedTools = ({
|
||||
providerMap,
|
||||
onSelect,
|
||||
selectedTools,
|
||||
canChooseMCPTool,
|
||||
isLoading = false,
|
||||
onInstallSuccess,
|
||||
}: FeaturedToolsProps) => {
|
||||
@ -166,7 +164,6 @@ const FeaturedTools = ({
|
||||
viewType={ViewType.flat}
|
||||
hasSearchText={false}
|
||||
selectedTools={selectedTools}
|
||||
canChooseMCPTool={canChooseMCPTool}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -223,7 +223,6 @@ const Tabs: FC<TabsProps> = ({
|
||||
customTools={customTools || []}
|
||||
workflowTools={workflowTools || []}
|
||||
mcpTools={mcpTools || []}
|
||||
canChooseMCPTool
|
||||
onTagsChange={onTagsChange}
|
||||
isInRAGPipeline={inRAGPipeline}
|
||||
featuredPlugins={featuredPlugins}
|
||||
|
||||
@ -50,7 +50,6 @@ type Props = {
|
||||
supportAddCustomTool?: boolean
|
||||
scope?: string
|
||||
selectedTools?: ToolValue[]
|
||||
canChooseMCPTool?: boolean
|
||||
}
|
||||
|
||||
const ToolPicker: FC<Props> = ({
|
||||
@ -66,7 +65,6 @@ const ToolPicker: FC<Props> = ({
|
||||
scope = 'all',
|
||||
selectedTools,
|
||||
panelClassName,
|
||||
canChooseMCPTool,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [searchText, setSearchText] = useState('')
|
||||
@ -198,7 +196,6 @@ const ToolPicker: FC<Props> = ({
|
||||
workflowTools={workflowToolList || []}
|
||||
mcpTools={mcpTools || []}
|
||||
selectedTools={selectedTools}
|
||||
canChooseMCPTool={canChooseMCPTool}
|
||||
onTagsChange={setTags}
|
||||
featuredPlugins={featuredPlugins}
|
||||
featuredLoading={isFeaturedLoading}
|
||||
|
||||
@ -18,7 +18,6 @@ type Props = {
|
||||
letters: string[]
|
||||
toolRefs: any
|
||||
selectedTools?: ToolValue[]
|
||||
canChooseMCPTool?: boolean
|
||||
}
|
||||
|
||||
const ToolViewFlatView: FC<Props> = ({
|
||||
@ -32,7 +31,6 @@ const ToolViewFlatView: FC<Props> = ({
|
||||
onSelectMultiple,
|
||||
toolRefs,
|
||||
selectedTools,
|
||||
canChooseMCPTool,
|
||||
}) => {
|
||||
const firstLetterToolIds = useMemo(() => {
|
||||
const res: Record<string, string> = {}
|
||||
@ -63,7 +61,6 @@ const ToolViewFlatView: FC<Props> = ({
|
||||
canNotSelectMultiple={canNotSelectMultiple}
|
||||
onSelectMultiple={onSelectMultiple}
|
||||
selectedTools={selectedTools}
|
||||
canChooseMCPTool={canChooseMCPTool}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@ -14,7 +14,6 @@ type Props = {
|
||||
canNotSelectMultiple?: boolean
|
||||
onSelectMultiple?: (type: BlockEnum, tools: ToolDefaultValue[]) => void
|
||||
selectedTools?: ToolValue[]
|
||||
canChooseMCPTool?: boolean
|
||||
}
|
||||
|
||||
const Item: FC<Props> = ({
|
||||
@ -25,7 +24,6 @@ const Item: FC<Props> = ({
|
||||
canNotSelectMultiple,
|
||||
onSelectMultiple,
|
||||
selectedTools,
|
||||
canChooseMCPTool,
|
||||
}) => {
|
||||
return (
|
||||
<div>
|
||||
@ -43,7 +41,6 @@ const Item: FC<Props> = ({
|
||||
canNotSelectMultiple={canNotSelectMultiple}
|
||||
onSelectMultiple={onSelectMultiple}
|
||||
selectedTools={selectedTools}
|
||||
canChooseMCPTool={canChooseMCPTool}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -15,7 +15,6 @@ type Props = {
|
||||
canNotSelectMultiple?: boolean
|
||||
onSelectMultiple?: (type: BlockEnum, tools: ToolDefaultValue[]) => void
|
||||
selectedTools?: ToolValue[]
|
||||
canChooseMCPTool?: boolean
|
||||
}
|
||||
|
||||
const ToolListTreeView: FC<Props> = ({
|
||||
@ -25,7 +24,6 @@ const ToolListTreeView: FC<Props> = ({
|
||||
canNotSelectMultiple,
|
||||
onSelectMultiple,
|
||||
selectedTools,
|
||||
canChooseMCPTool,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const getI18nGroupName = useCallback((name: string) => {
|
||||
@ -56,7 +54,6 @@ const ToolListTreeView: FC<Props> = ({
|
||||
canNotSelectMultiple={canNotSelectMultiple}
|
||||
onSelectMultiple={onSelectMultiple}
|
||||
selectedTools={selectedTools}
|
||||
canChooseMCPTool={canChooseMCPTool}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -9,6 +9,7 @@ import * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Mcp } from '@/app/components/base/icons/src/vender/other'
|
||||
import { useMCPToolAvailability } from '@/app/components/workflow/nodes/_base/components/mcp-tool-availability'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { Theme } from '@/types/app'
|
||||
@ -38,7 +39,6 @@ type Props = {
|
||||
canNotSelectMultiple?: boolean
|
||||
onSelectMultiple?: (type: BlockEnum, tools: ToolDefaultValue[]) => void
|
||||
selectedTools?: ToolValue[]
|
||||
canChooseMCPTool?: boolean
|
||||
isShowLetterIndex?: boolean
|
||||
}
|
||||
|
||||
@ -51,9 +51,9 @@ const Tool: FC<Props> = ({
|
||||
canNotSelectMultiple,
|
||||
onSelectMultiple,
|
||||
selectedTools,
|
||||
canChooseMCPTool,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { allowed: isMCPToolAllowed } = useMCPToolAvailability()
|
||||
const language = useGetLanguage()
|
||||
const isFlatView = viewType === ViewType.flat
|
||||
const notShowProvider = payload.type === CollectionType.workflow
|
||||
@ -63,7 +63,7 @@ const Tool: FC<Props> = ({
|
||||
const ref = useRef(null)
|
||||
const isHovering = useHover(ref)
|
||||
const isMCPTool = payload.type === CollectionType.mcp
|
||||
const isShowCanNotChooseMCPTip = !canChooseMCPTool && isMCPTool
|
||||
const isShowCanNotChooseMCPTip = !isMCPToolAllowed && isMCPTool
|
||||
const { theme } = useTheme()
|
||||
const normalizedIcon = useMemo<ToolWithProvider['icon']>(() => {
|
||||
return normalizeProviderIcon(payload.icon) ?? payload.icon
|
||||
|
||||
@ -21,7 +21,6 @@ type ToolsProps = {
|
||||
className?: string
|
||||
indexBarClassName?: string
|
||||
selectedTools?: ToolValue[]
|
||||
canChooseMCPTool?: boolean
|
||||
}
|
||||
const Tools = ({
|
||||
onSelect,
|
||||
@ -35,7 +34,6 @@ const Tools = ({
|
||||
className,
|
||||
indexBarClassName,
|
||||
selectedTools,
|
||||
canChooseMCPTool,
|
||||
}: ToolsProps) => {
|
||||
// const tools: any = []
|
||||
const language = useGetLanguage()
|
||||
@ -109,7 +107,6 @@ const Tools = ({
|
||||
canNotSelectMultiple={canNotSelectMultiple}
|
||||
onSelectMultiple={onSelectMultiple}
|
||||
selectedTools={selectedTools}
|
||||
canChooseMCPTool={canChooseMCPTool}
|
||||
indexBar={<IndexBar letters={letters} itemRefs={toolRefs} className={indexBarClassName} />}
|
||||
/>
|
||||
)
|
||||
@ -121,7 +118,6 @@ const Tools = ({
|
||||
canNotSelectMultiple={canNotSelectMultiple}
|
||||
onSelectMultiple={onSelectMultiple}
|
||||
selectedTools={selectedTools}
|
||||
canChooseMCPTool={canChooseMCPTool}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
@ -2067,6 +2067,7 @@ export const useNodesInteractions = () => {
|
||||
const offsetX = currentPosition.x - x
|
||||
const offsetY = currentPosition.y - y
|
||||
let idMapping: Record<string, string> = {}
|
||||
const parentChildrenToAppend: { parentId: string, childId: string, childType: BlockEnum }[] = []
|
||||
clipboardElements.forEach((nodeToPaste, index) => {
|
||||
const nodeType = nodeToPaste.data.type
|
||||
|
||||
@ -2080,6 +2081,7 @@ export const useNodesInteractions = () => {
|
||||
_isBundled: false,
|
||||
_connectedSourceHandleIds: [],
|
||||
_connectedTargetHandleIds: [],
|
||||
_dimmed: false,
|
||||
title: genNewNodeTitleFromOld(nodeToPaste.data.title),
|
||||
},
|
||||
position: {
|
||||
@ -2147,27 +2149,24 @@ export const useNodesInteractions = () => {
|
||||
return
|
||||
|
||||
// handle paste to nested block
|
||||
if (selectedNode.data.type === BlockEnum.Iteration) {
|
||||
newNode.data.isInIteration = true
|
||||
newNode.data.iteration_id = selectedNode.data.iteration_id
|
||||
newNode.parentId = selectedNode.id
|
||||
newNode.positionAbsolute = {
|
||||
x: newNode.position.x,
|
||||
y: newNode.position.y,
|
||||
}
|
||||
// set position base on parent node
|
||||
newNode.position = getNestedNodePosition(newNode, selectedNode)
|
||||
}
|
||||
else if (selectedNode.data.type === BlockEnum.Loop) {
|
||||
newNode.data.isInLoop = true
|
||||
newNode.data.loop_id = selectedNode.data.loop_id
|
||||
if (selectedNode.data.type === BlockEnum.Iteration || selectedNode.data.type === BlockEnum.Loop) {
|
||||
const isIteration = selectedNode.data.type === BlockEnum.Iteration
|
||||
|
||||
newNode.data.isInIteration = isIteration
|
||||
newNode.data.iteration_id = isIteration ? selectedNode.id : undefined
|
||||
newNode.data.isInLoop = !isIteration
|
||||
newNode.data.loop_id = !isIteration ? selectedNode.id : undefined
|
||||
|
||||
newNode.parentId = selectedNode.id
|
||||
newNode.zIndex = isIteration ? ITERATION_CHILDREN_Z_INDEX : LOOP_CHILDREN_Z_INDEX
|
||||
newNode.positionAbsolute = {
|
||||
x: newNode.position.x,
|
||||
y: newNode.position.y,
|
||||
}
|
||||
// set position base on parent node
|
||||
newNode.position = getNestedNodePosition(newNode, selectedNode)
|
||||
// update parent children array like native add
|
||||
parentChildrenToAppend.push({ parentId: selectedNode.id, childId: newNode.id, childType: newNode.data.type })
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2198,7 +2197,17 @@ export const useNodesInteractions = () => {
|
||||
}
|
||||
})
|
||||
|
||||
setNodes([...nodes, ...nodesToPaste])
|
||||
const newNodes = produce(nodes, (draft: Node[]) => {
|
||||
parentChildrenToAppend.forEach(({ parentId, childId, childType }) => {
|
||||
const p = draft.find(n => n.id === parentId)
|
||||
if (p) {
|
||||
p.data._children?.push({ nodeId: childId, nodeType: childType })
|
||||
}
|
||||
})
|
||||
draft.push(...nodesToPaste)
|
||||
})
|
||||
|
||||
setNodes(newNodes)
|
||||
setEdges([...edges, ...edgesToPaste])
|
||||
saveStateToHistory(WorkflowHistoryEvent.NodePaste, {
|
||||
nodeId: nodesToPaste?.[0]?.id,
|
||||
|
||||
@ -92,13 +92,12 @@ function formatStrategy(input: StrategyPluginDetail[], getIcon: (i: string) => s
|
||||
export type AgentStrategySelectorProps = {
|
||||
value?: Strategy
|
||||
onChange: (value?: Strategy) => void
|
||||
canChooseMCPTool: boolean
|
||||
}
|
||||
|
||||
export const AgentStrategySelector = memo((props: AgentStrategySelectorProps) => {
|
||||
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
|
||||
|
||||
const { value, onChange, canChooseMCPTool } = props
|
||||
const { value, onChange } = props
|
||||
const [open, setOpen] = useState(false)
|
||||
const [viewType, setViewType] = useState<ViewType>(ViewType.flat)
|
||||
const [query, setQuery] = useState('')
|
||||
@ -242,7 +241,6 @@ export const AgentStrategySelector = memo((props: AgentStrategySelectorProps) =>
|
||||
indexBarClassName="top-0 xl:top-36"
|
||||
hasSearchText={false}
|
||||
canNotSelectMultiple
|
||||
canChooseMCPTool={canChooseMCPTool}
|
||||
isAgent
|
||||
/>
|
||||
{enable_marketplace && (
|
||||
|
||||
@ -43,7 +43,6 @@ export type AgentStrategyProps = {
|
||||
nodeOutputVars?: NodeOutPutVar[]
|
||||
availableNodes?: Node[]
|
||||
nodeId?: string
|
||||
canChooseMCPTool: boolean
|
||||
}
|
||||
|
||||
type CustomSchema<Type, Field = {}> = Omit<CredentialFormSchema, 'type'> & { type: Type } & Field
|
||||
@ -54,7 +53,7 @@ type MultipleToolSelectorSchema = CustomSchema<'array[tools]'>
|
||||
type CustomField = ToolSelectorSchema | MultipleToolSelectorSchema
|
||||
|
||||
export const AgentStrategy = memo((props: AgentStrategyProps) => {
|
||||
const { strategy, onStrategyChange, formSchema, formValue, onFormValueChange, nodeOutputVars, availableNodes, nodeId, canChooseMCPTool } = props
|
||||
const { strategy, onStrategyChange, formSchema, formValue, onFormValueChange, nodeOutputVars, availableNodes, nodeId } = props
|
||||
const { t } = useTranslation()
|
||||
const docLink = useDocLink()
|
||||
const defaultModel = useDefaultModel(ModelTypeEnum.textGeneration)
|
||||
@ -189,7 +188,6 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => {
|
||||
value={value}
|
||||
onSelect={item => onChange(item)}
|
||||
onDelete={() => onChange(null)}
|
||||
canChooseMCPTool={canChooseMCPTool}
|
||||
onSelectMultiple={noop}
|
||||
/>
|
||||
</Field>
|
||||
@ -212,7 +210,6 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => {
|
||||
onChange={onChange}
|
||||
supportCollapse
|
||||
required={schema.required}
|
||||
canChooseMCPTool={canChooseMCPTool}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -220,7 +217,7 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => {
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<AgentStrategySelector value={strategy} onChange={onStrategyChange} canChooseMCPTool={canChooseMCPTool} />
|
||||
<AgentStrategySelector value={strategy} onChange={onStrategyChange} />
|
||||
{
|
||||
strategy
|
||||
? (
|
||||
@ -241,7 +238,6 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => {
|
||||
nodeId={nodeId}
|
||||
nodeOutputVars={nodeOutputVars || []}
|
||||
availableNodes={availableNodes || []}
|
||||
canChooseMCPTool={canChooseMCPTool}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -0,0 +1,38 @@
|
||||
'use client'
|
||||
import type { ReactNode } from 'react'
|
||||
import { createContext, useContext } from 'react'
|
||||
|
||||
type MCPToolAvailabilityContextValue = {
|
||||
versionSupported?: boolean
|
||||
}
|
||||
|
||||
const MCPToolAvailabilityContext = createContext<MCPToolAvailabilityContextValue | undefined>(undefined)
|
||||
|
||||
export type MCPToolAvailability = {
|
||||
allowed: boolean
|
||||
versionSupported?: boolean
|
||||
}
|
||||
|
||||
export const MCPToolAvailabilityProvider = ({
|
||||
versionSupported,
|
||||
children,
|
||||
}: {
|
||||
versionSupported?: boolean
|
||||
children: ReactNode
|
||||
}) => (
|
||||
<MCPToolAvailabilityContext.Provider value={{ versionSupported }}>
|
||||
{children}
|
||||
</MCPToolAvailabilityContext.Provider>
|
||||
)
|
||||
|
||||
export const useMCPToolAvailability = (): MCPToolAvailability => {
|
||||
const context = useContext(MCPToolAvailabilityContext)
|
||||
if (context === undefined)
|
||||
return { allowed: true }
|
||||
|
||||
const { versionSupported } = context
|
||||
return {
|
||||
allowed: versionSupported === true,
|
||||
versionSupported,
|
||||
}
|
||||
}
|
||||
@ -6,9 +6,11 @@ import type { StrategyParamItem } from '@/app/components/plugins/types'
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toType } from '@/app/components/tools/utils/to-form-schema'
|
||||
import { isSupportMCP } from '@/utils/plugin-version-feature'
|
||||
import { useStore } from '../../store'
|
||||
import { AgentStrategy } from '../_base/components/agent-strategy'
|
||||
import Field from '../_base/components/field'
|
||||
import { MCPToolAvailabilityProvider } from '../_base/components/mcp-tool-availability'
|
||||
import MemoryConfig from '../_base/components/memory-config'
|
||||
import OutputVars, { VarItem } from '../_base/components/output-vars'
|
||||
import Split from '../_base/components/split'
|
||||
@ -40,9 +42,9 @@ const AgentPanel: FC<NodePanelProps<AgentNodeType>> = (props) => {
|
||||
readOnly,
|
||||
outputSchema,
|
||||
handleMemoryChange,
|
||||
canChooseMCPTool,
|
||||
} = useConfig(props.id, props.data)
|
||||
const { t } = useTranslation()
|
||||
const isMCPVersionSupported = isSupportMCP(inputs.meta?.version)
|
||||
|
||||
const resetEditor = useStore(s => s.setControlPromptEditorRerenderKey)
|
||||
return (
|
||||
@ -53,37 +55,38 @@ const AgentPanel: FC<NodePanelProps<AgentNodeType>> = (props) => {
|
||||
className="px-4 py-2"
|
||||
tooltip={t('nodes.agent.strategy.tooltip', { ns: 'workflow' })}
|
||||
>
|
||||
<AgentStrategy
|
||||
strategy={inputs.agent_strategy_name
|
||||
? {
|
||||
agent_strategy_provider_name: inputs.agent_strategy_provider_name!,
|
||||
agent_strategy_name: inputs.agent_strategy_name!,
|
||||
agent_strategy_label: inputs.agent_strategy_label!,
|
||||
agent_output_schema: inputs.output_schema,
|
||||
plugin_unique_identifier: inputs.plugin_unique_identifier!,
|
||||
meta: inputs.meta,
|
||||
}
|
||||
: undefined}
|
||||
onStrategyChange={(strategy) => {
|
||||
setInputs({
|
||||
...inputs,
|
||||
agent_strategy_provider_name: strategy?.agent_strategy_provider_name,
|
||||
agent_strategy_name: strategy?.agent_strategy_name,
|
||||
agent_strategy_label: strategy?.agent_strategy_label,
|
||||
output_schema: strategy!.agent_output_schema,
|
||||
plugin_unique_identifier: strategy!.plugin_unique_identifier,
|
||||
meta: strategy?.meta,
|
||||
})
|
||||
resetEditor(Date.now())
|
||||
}}
|
||||
formSchema={currentStrategy?.parameters?.map(strategyParamToCredientialForm) || []}
|
||||
formValue={formData}
|
||||
onFormValueChange={onFormChange}
|
||||
nodeOutputVars={availableVars}
|
||||
availableNodes={availableNodesWithParent}
|
||||
nodeId={props.id}
|
||||
canChooseMCPTool={canChooseMCPTool}
|
||||
/>
|
||||
<MCPToolAvailabilityProvider versionSupported={isMCPVersionSupported}>
|
||||
<AgentStrategy
|
||||
strategy={inputs.agent_strategy_name
|
||||
? {
|
||||
agent_strategy_provider_name: inputs.agent_strategy_provider_name!,
|
||||
agent_strategy_name: inputs.agent_strategy_name!,
|
||||
agent_strategy_label: inputs.agent_strategy_label!,
|
||||
agent_output_schema: inputs.output_schema,
|
||||
plugin_unique_identifier: inputs.plugin_unique_identifier!,
|
||||
meta: inputs.meta,
|
||||
}
|
||||
: undefined}
|
||||
onStrategyChange={(strategy) => {
|
||||
setInputs({
|
||||
...inputs,
|
||||
agent_strategy_provider_name: strategy?.agent_strategy_provider_name,
|
||||
agent_strategy_name: strategy?.agent_strategy_name,
|
||||
agent_strategy_label: strategy?.agent_strategy_label,
|
||||
output_schema: strategy!.agent_output_schema,
|
||||
plugin_unique_identifier: strategy!.plugin_unique_identifier,
|
||||
meta: strategy?.meta,
|
||||
})
|
||||
resetEditor(Date.now())
|
||||
}}
|
||||
formSchema={currentStrategy?.parameters?.map(strategyParamToCredientialForm) || []}
|
||||
formValue={formData}
|
||||
onFormValueChange={onFormChange}
|
||||
nodeOutputVars={availableVars}
|
||||
availableNodes={availableNodesWithParent}
|
||||
nodeId={props.id}
|
||||
/>
|
||||
</MCPToolAvailabilityProvider>
|
||||
</Field>
|
||||
<div className="px-4 py-2">
|
||||
{isChatMode && currentStrategy?.features?.includes(AgentFeature.HISTORY_MESSAGES) && (
|
||||
|
||||
@ -11,7 +11,6 @@ import {
|
||||
} from '@/app/components/workflow/hooks'
|
||||
import { useCheckInstalled, useFetchPluginsInMarketPlaceByIds } from '@/service/use-plugins'
|
||||
import { useStrategyProviderDetail } from '@/service/use-strategy'
|
||||
import { isSupportMCP } from '@/utils/plugin-version-feature'
|
||||
import { VarType as VarKindType } from '../../types'
|
||||
import useAvailableVarList from '../_base/hooks/use-available-var-list'
|
||||
import useNodeCrud from '../_base/hooks/use-node-crud'
|
||||
@ -222,7 +221,6 @@ const useConfig = (id: string, payload: AgentNodeType) => {
|
||||
outputSchema,
|
||||
handleMemoryChange,
|
||||
isChatMode,
|
||||
canChooseMCPTool: isSupportMCP(inputs.meta?.version),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ import { useCallback } from 'react'
|
||||
import { useStoreApi } from 'reactflow'
|
||||
import { useNodesMetaData } from '@/app/components/workflow/hooks'
|
||||
import {
|
||||
LOOP_CHILDREN_Z_INDEX,
|
||||
LOOP_PADDING,
|
||||
} from '../../constants'
|
||||
import {
|
||||
@ -114,9 +115,7 @@ export const useNodeLoopInteractions = () => {
|
||||
|
||||
return childrenNodes.map((child, index) => {
|
||||
const childNodeType = child.data.type as BlockEnum
|
||||
const {
|
||||
defaultValue,
|
||||
} = nodesMetaDataMap![childNodeType]
|
||||
const { defaultValue } = nodesMetaDataMap![childNodeType]
|
||||
const nodesWithSameType = nodes.filter(node => node.data.type === childNodeType)
|
||||
const { newNode } = generateNewNode({
|
||||
type: getNodeCustomTypeByNodeDataType(childNodeType),
|
||||
@ -127,15 +126,17 @@ export const useNodeLoopInteractions = () => {
|
||||
_isBundled: false,
|
||||
_connectedSourceHandleIds: [],
|
||||
_connectedTargetHandleIds: [],
|
||||
_dimmed: false,
|
||||
title: nodesWithSameType.length > 0 ? `${defaultValue.title} ${nodesWithSameType.length + 1}` : defaultValue.title,
|
||||
isInLoop: true,
|
||||
loop_id: newNodeId,
|
||||
|
||||
type: childNodeType,
|
||||
},
|
||||
position: child.position,
|
||||
positionAbsolute: child.positionAbsolute,
|
||||
parentId: newNodeId,
|
||||
extent: child.extent,
|
||||
zIndex: child.zIndex,
|
||||
zIndex: LOOP_CHILDREN_Z_INDEX,
|
||||
})
|
||||
newNode.id = `${newNodeId}${newNode.id + index}`
|
||||
return newNode
|
||||
|
||||
Reference in New Issue
Block a user