Merge branch 'main' into feat/pull-a-variable

This commit is contained in:
zhsama
2026-01-15 16:14:15 +08:00
141 changed files with 17063 additions and 4450 deletions

View File

@ -183,7 +183,6 @@ const AgentTools: FC = () => {
onSelect={handleSelectTool}
onSelectMultiple={handleSelectMultipleTool}
selectedTools={tools as unknown as ToolValue[]}
canChooseMCPTool
/>
</>
)}

View File

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

View File

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

View File

@ -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')

View File

@ -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: [] })

View File

@ -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,

View File

@ -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

View File

@ -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

View 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>
)
}

View File

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

View File

@ -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

View File

@ -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

View File

@ -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'

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

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

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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'

View File

@ -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'

View File

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

View File

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

View File

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

View File

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

View File

@ -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'

View File

@ -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

View File

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

View File

@ -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"

View File

@ -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 />}

View File

@ -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

View File

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

View File

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

View File

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

View File

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

View File

@ -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}
/>
</>

View File

@ -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">

View File

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

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -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

View File

@ -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

View File

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

View File

@ -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>

View File

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

View 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)
})
})
})

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

View File

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

View File

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

View File

@ -223,7 +223,6 @@ const Tabs: FC<TabsProps> = ({
customTools={customTools || []}
workflowTools={workflowTools || []}
mcpTools={mcpTools || []}
canChooseMCPTool
onTagsChange={onTagsChange}
isInRAGPipeline={inRAGPipeline}
featuredPlugins={featuredPlugins}

View File

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

View File

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

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

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

View File

@ -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,

View File

@ -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 && (

View File

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

View File

@ -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,
}
}

View File

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

View File

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

View File

@ -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