Compare commits

..

9 Commits

Author SHA1 Message Date
d6b3a6d659 Remove unused ESLint suppressions for react-refresh/only-export-components across various components to clean up the linting configuration. 2026-01-30 16:43:22 +08:00
6fddf4fe14 Merge remote-tracking branch 'origin/main' into refactor/datasets-complex-components 2026-01-30 16:09:45 +08:00
d3e6b73c81 fix(tests): update button role queries to use regex for better matching
This commit modifies the test files for the CreateAppModal component to replace exact string matches with regular expressions for button role queries. This change enhances the robustness of the tests by allowing for more flexible matching of button names, ensuring they remain valid even if the text changes slightly in the future. Additionally, type assertions were improved in the CreateAppModal tests to enhance type safety.
2026-01-30 15:48:14 +08:00
9455d0500e Merge remote-tracking branch 'origin/main' into refactor/datasets-complex-components 2026-01-30 13:29:38 +08:00
a90c056121 Revert "refactor(web): restructure metadata components and add tests"
This reverts commit a25e4c1b3a.
2026-01-30 11:28:44 +08:00
932a37d734 refactor(web): improve document actions and metadata handling
This commit refactors the document actions and metadata components to enhance code clarity and maintainability. Key changes include:

- Simplified the  hook by removing unnecessary type assertions.
- Streamlined the rendering logic in the  component by consolidating JSX elements.
- Updated dependencies in the  hook to ensure proper reactivity.

These adjustments aim to improve the overall structure and performance of the document management features.
2026-01-30 10:53:11 +08:00
a25e4c1b3a refactor(web): restructure metadata components and add tests
This commit refactors the metadata components within the document detail section by extracting and organizing them into separate files for better maintainability and readability. The following components were created or modified:

- DocTypeSelector: A new component for selecting document types.
- FieldInfo: A new component for displaying and editing field information.
- IconButton: A new component for rendering document type icons.
- MetadataFieldList: A new component for rendering a list of metadata fields.
- TypeIcon: A new component for displaying icons based on document types.
- Utility functions for mapping options.

Additionally, comprehensive tests were added for each new component to ensure functionality and reliability.
2026-01-30 10:09:58 +08:00
ec7d80dd1a refactor(web): extract hooks and components from DocumentList
Extract reusable hooks and sub-components from the DocumentList component
to reduce complexity and improve testability:

- useDocumentSort: handles sorting state and logic
- useDocumentSelection: manages document selection state
- useDocumentActions: handles batch actions with React Query mutations
- DocumentTableRow: extracted table row component
- SortHeader: reusable sort header component
- DocumentSourceIcon: data source type icon renderer
- renderTdValue: utility for displaying formatted values

The main component remains in list.tsx with document-list/index.tsx
providing a re-export for backwards compatibility.

Test coverage: 156 tests covering all extracted modules.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 18:33:38 +08:00
aecae4f671 refactor(web): extract hooks and components from EmbeddingDetail
Refactor the EmbeddingDetail component to improve maintainability:
- Extract useEmbeddingStatus hook with React Query for status polling
- Extract usePauseIndexing and useResumeIndexing mutation hooks
- Split UI into StatusHeader, ProgressBar, SegmentProgress, RuleDetail components
- Add comprehensive tests with 98.57% statement coverage

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 17:01:06 +08:00
73 changed files with 6774 additions and 10370 deletions

View File

@ -1,3 +1,4 @@
import type { App } from '@/types/app'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { useRouter } from 'next/navigation'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
@ -13,8 +14,8 @@ import { getRedirection } from '@/utils/app-redirection'
import CreateAppModal from './index'
vi.mock('ahooks', () => ({
useDebounceFn: (fn: (...args: any[]) => any) => {
const run = (...args: any[]) => fn(...args)
useDebounceFn: <T extends (...args: unknown[]) => unknown>(fn: T) => {
const run = (...args: Parameters<T>) => fn(...args)
const cancel = vi.fn()
const flush = vi.fn()
return { run, cancel, flush }
@ -83,7 +84,7 @@ describe('CreateAppModal', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseRouter.mockReturnValue({ push: mockPush } as any)
mockUseRouter.mockReturnValue({ push: mockPush } as unknown as ReturnType<typeof useRouter>)
mockUseProviderContext.mockReturnValue({
plan: {
type: AppModeEnum.ADVANCED_CHAT,
@ -92,10 +93,10 @@ describe('CreateAppModal', () => {
reset: {},
},
enableBilling: true,
} as any)
} as unknown as ReturnType<typeof useProviderContext>)
mockUseAppContext.mockReturnValue({
isCurrentWorkspaceEditor: true,
} as any)
} as unknown as ReturnType<typeof useAppContext>)
mockSetItem.mockClear()
Object.defineProperty(window, 'localStorage', {
value: {
@ -118,13 +119,13 @@ describe('CreateAppModal', () => {
})
it('creates an app, notifies success, and fires callbacks', async () => {
const mockApp = { id: 'app-1', mode: AppModeEnum.ADVANCED_CHAT }
mockCreateApp.mockResolvedValue(mockApp as any)
const mockApp: Partial<App> = { id: 'app-1', mode: AppModeEnum.ADVANCED_CHAT }
mockCreateApp.mockResolvedValue(mockApp as App)
const { onClose, onSuccess } = renderModal()
const nameInput = screen.getByPlaceholderText('app.newApp.appNamePlaceholder')
fireEvent.change(nameInput, { target: { value: 'My App' } })
fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Create' }))
fireEvent.click(screen.getByRole('button', { name: /app\.newApp\.Create/ }))
await waitFor(() => expect(mockCreateApp).toHaveBeenCalledWith({
name: 'My App',
@ -152,7 +153,7 @@ describe('CreateAppModal', () => {
const nameInput = screen.getByPlaceholderText('app.newApp.appNamePlaceholder')
fireEvent.change(nameInput, { target: { value: 'My App' } })
fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Create' }))
fireEvent.click(screen.getByRole('button', { name: /app\.newApp\.Create/ }))
await waitFor(() => expect(mockCreateApp).toHaveBeenCalled())
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'boom' })

View File

@ -216,22 +216,13 @@ describe('image-uploader utils', () => {
type FileCallback = (file: MockFile) => void
type EntriesCallback = (entries: FileSystemEntry[]) => void
// Helper to create mock FileSystemEntry with required properties
const createMockEntry = (props: {
isFile: boolean
isDirectory: boolean
name?: string
file?: (callback: FileCallback) => void
createReader?: () => { readEntries: (callback: EntriesCallback) => void }
}): FileSystemEntry => props as unknown as FileSystemEntry
it('should resolve with file array for file entry', async () => {
const mockFile: MockFile = { name: 'test.png' }
const mockEntry = createMockEntry({
const mockEntry = {
isFile: true,
isDirectory: false,
file: (callback: FileCallback) => callback(mockFile),
})
}
const result = await traverseFileEntry(mockEntry)
expect(result).toHaveLength(1)
@ -241,11 +232,11 @@ describe('image-uploader utils', () => {
it('should resolve with file array with prefix for nested file', async () => {
const mockFile: MockFile = { name: 'test.png' }
const mockEntry = createMockEntry({
const mockEntry = {
isFile: true,
isDirectory: false,
file: (callback: FileCallback) => callback(mockFile),
})
}
const result = await traverseFileEntry(mockEntry, 'folder/')
expect(result).toHaveLength(1)
@ -253,24 +244,24 @@ describe('image-uploader utils', () => {
})
it('should resolve empty array for unknown entry type', async () => {
const mockEntry = createMockEntry({
const mockEntry = {
isFile: false,
isDirectory: false,
})
}
const result = await traverseFileEntry(mockEntry)
expect(result).toEqual([])
})
it('should handle directory with no files', async () => {
const mockEntry = createMockEntry({
const mockEntry = {
isFile: false,
isDirectory: true,
name: 'empty-folder',
createReader: () => ({
readEntries: (callback: EntriesCallback) => callback([]),
}),
})
}
const result = await traverseFileEntry(mockEntry)
expect(result).toEqual([])
@ -280,20 +271,20 @@ describe('image-uploader utils', () => {
const mockFile1: MockFile = { name: 'file1.png' }
const mockFile2: MockFile = { name: 'file2.png' }
const mockFileEntry1 = createMockEntry({
const mockFileEntry1 = {
isFile: true,
isDirectory: false,
file: (callback: FileCallback) => callback(mockFile1),
})
}
const mockFileEntry2 = createMockEntry({
const mockFileEntry2 = {
isFile: true,
isDirectory: false,
file: (callback: FileCallback) => callback(mockFile2),
})
}
let readCount = 0
const mockEntry = createMockEntry({
const mockEntry = {
isFile: false,
isDirectory: true,
name: 'folder',
@ -301,14 +292,14 @@ describe('image-uploader utils', () => {
readEntries: (callback: EntriesCallback) => {
if (readCount === 0) {
readCount++
callback([mockFileEntry1, mockFileEntry2])
callback([mockFileEntry1, mockFileEntry2] as unknown as FileSystemEntry[])
}
else {
callback([])
}
},
}),
})
}
const result = await traverseFileEntry(mockEntry)
expect(result).toHaveLength(2)

View File

@ -18,17 +18,17 @@ type FileWithPath = {
relativePath?: string
} & File
export const traverseFileEntry = (entry: FileSystemEntry, prefix = ''): Promise<FileWithPath[]> => {
export const traverseFileEntry = (entry: any, prefix = ''): Promise<FileWithPath[]> => {
return new Promise((resolve) => {
if (entry.isFile) {
(entry as FileSystemFileEntry).file((file: FileWithPath) => {
entry.file((file: FileWithPath) => {
file.relativePath = `${prefix}${file.name}`
resolve([file])
})
}
else if (entry.isDirectory) {
const reader = (entry as FileSystemDirectoryEntry).createReader()
const entries: FileSystemEntry[] = []
const reader = entry.createReader()
const entries: any[] = []
const read = () => {
reader.readEntries(async (results: FileSystemEntry[]) => {
if (!results.length) {

View File

@ -1,218 +0,0 @@
'use client'
import { useDebounceFn } from 'ahooks'
import { useRouter } from 'next/navigation'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { ToastContext } from '@/app/components/base/toast'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
import {
DSLImportMode,
DSLImportStatus,
} from '@/models/app'
import { useImportPipelineDSL, useImportPipelineDSLConfirm } from '@/service/use-pipeline'
export enum CreateFromDSLModalTab {
FROM_FILE = 'from-file',
FROM_URL = 'from-url',
}
export type UseDSLImportOptions = {
activeTab?: CreateFromDSLModalTab
dslUrl?: string
onSuccess?: () => void
onClose?: () => void
}
export type DSLVersions = {
importedVersion: string
systemVersion: string
}
export const useDSLImport = ({
activeTab = CreateFromDSLModalTab.FROM_FILE,
dslUrl = '',
onSuccess,
onClose,
}: UseDSLImportOptions) => {
const { push } = useRouter()
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const [currentFile, setDSLFile] = useState<File>()
const [fileContent, setFileContent] = useState<string>()
const [currentTab, setCurrentTab] = useState(activeTab)
const [dslUrlValue, setDslUrlValue] = useState(dslUrl)
const [showConfirmModal, setShowConfirmModal] = useState(false)
const [versions, setVersions] = useState<DSLVersions>()
const [importId, setImportId] = useState<string>()
const [isConfirming, setIsConfirming] = useState(false)
const { handleCheckPluginDependencies } = usePluginDependencies()
const isCreatingRef = useRef(false)
const { mutateAsync: importDSL } = useImportPipelineDSL()
const { mutateAsync: importDSLConfirm } = useImportPipelineDSLConfirm()
const readFile = useCallback((file: File) => {
const reader = new FileReader()
reader.onload = (event) => {
const content = event.target?.result
setFileContent(content as string)
}
reader.readAsText(file)
}, [])
const handleFile = useCallback((file?: File) => {
setDSLFile(file)
if (file)
readFile(file)
if (!file)
setFileContent('')
}, [readFile])
const onCreate = useCallback(async () => {
if (currentTab === CreateFromDSLModalTab.FROM_FILE && !currentFile)
return
if (currentTab === CreateFromDSLModalTab.FROM_URL && !dslUrlValue)
return
if (isCreatingRef.current)
return
isCreatingRef.current = true
let response
if (currentTab === CreateFromDSLModalTab.FROM_FILE) {
response = await importDSL({
mode: DSLImportMode.YAML_CONTENT,
yaml_content: fileContent || '',
})
}
if (currentTab === CreateFromDSLModalTab.FROM_URL) {
response = await importDSL({
mode: DSLImportMode.YAML_URL,
yaml_url: dslUrlValue || '',
})
}
if (!response) {
notify({ type: 'error', message: t('creation.errorTip', { ns: 'datasetPipeline' }) })
isCreatingRef.current = false
return
}
const { id, status, pipeline_id, dataset_id, imported_dsl_version, current_dsl_version } = response
if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) {
onSuccess?.()
onClose?.()
notify({
type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning',
message: t(status === DSLImportStatus.COMPLETED ? 'creation.successTip' : 'creation.caution', { ns: 'datasetPipeline' }),
children: status === DSLImportStatus.COMPLETED_WITH_WARNINGS && t('newApp.appCreateDSLWarning', { ns: 'app' }),
})
if (pipeline_id)
await handleCheckPluginDependencies(pipeline_id, true)
push(`/datasets/${dataset_id}/pipeline`)
isCreatingRef.current = false
}
else if (status === DSLImportStatus.PENDING) {
setVersions({
importedVersion: imported_dsl_version ?? '',
systemVersion: current_dsl_version ?? '',
})
onClose?.()
setTimeout(() => {
setShowConfirmModal(true)
}, 300)
setImportId(id)
isCreatingRef.current = false
}
else {
notify({ type: 'error', message: t('creation.errorTip', { ns: 'datasetPipeline' }) })
isCreatingRef.current = false
}
}, [
currentTab,
currentFile,
dslUrlValue,
fileContent,
importDSL,
notify,
t,
onSuccess,
onClose,
handleCheckPluginDependencies,
push,
])
const { run: handleCreateApp } = useDebounceFn(onCreate, { wait: 300 })
const onDSLConfirm = useCallback(async () => {
if (!importId)
return
setIsConfirming(true)
const response = await importDSLConfirm(importId)
setIsConfirming(false)
if (!response) {
notify({ type: 'error', message: t('creation.errorTip', { ns: 'datasetPipeline' }) })
return
}
const { status, pipeline_id, dataset_id } = response
if (status === DSLImportStatus.COMPLETED) {
onSuccess?.()
setShowConfirmModal(false)
notify({
type: 'success',
message: t('creation.successTip', { ns: 'datasetPipeline' }),
})
if (pipeline_id)
await handleCheckPluginDependencies(pipeline_id, true)
push(`/datasets/${dataset_id}/pipeline`)
}
else if (status === DSLImportStatus.FAILED) {
notify({ type: 'error', message: t('creation.errorTip', { ns: 'datasetPipeline' }) })
}
}, [importId, importDSLConfirm, notify, t, onSuccess, handleCheckPluginDependencies, push])
const handleCancelConfirm = useCallback(() => {
setShowConfirmModal(false)
}, [])
const buttonDisabled = useMemo(() => {
if (currentTab === CreateFromDSLModalTab.FROM_FILE)
return !currentFile
if (currentTab === CreateFromDSLModalTab.FROM_URL)
return !dslUrlValue
return false
}, [currentTab, currentFile, dslUrlValue])
return {
// State
currentFile,
currentTab,
dslUrlValue,
showConfirmModal,
versions,
buttonDisabled,
isConfirming,
// Actions
setCurrentTab,
setDslUrlValue,
handleFile,
handleCreateApp,
onDSLConfirm,
handleCancelConfirm,
}
}

View File

@ -1,18 +1,24 @@
'use client'
import { useKeyPress } from 'ahooks'
import { useDebounceFn, useKeyPress } from 'ahooks'
import { noop } from 'es-toolkit/function'
import { useRouter } from 'next/navigation'
import { useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Modal from '@/app/components/base/modal'
import DSLConfirmModal from './dsl-confirm-modal'
import { ToastContext } from '@/app/components/base/toast'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
import {
DSLImportMode,
DSLImportStatus,
} from '@/models/app'
import { useImportPipelineDSL, useImportPipelineDSLConfirm } from '@/service/use-pipeline'
import Header from './header'
import { CreateFromDSLModalTab, useDSLImport } from './hooks/use-dsl-import'
import Tab from './tab'
import Uploader from './uploader'
export { CreateFromDSLModalTab }
type CreateFromDSLModalProps = {
show: boolean
onSuccess?: () => void
@ -21,6 +27,11 @@ type CreateFromDSLModalProps = {
dslUrl?: string
}
export enum CreateFromDSLModalTab {
FROM_FILE = 'from-file',
FROM_URL = 'from-url',
}
const CreateFromDSLModal = ({
show,
onSuccess,
@ -28,34 +39,150 @@ const CreateFromDSLModal = ({
activeTab = CreateFromDSLModalTab.FROM_FILE,
dslUrl = '',
}: CreateFromDSLModalProps) => {
const { push } = useRouter()
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const [currentFile, setDSLFile] = useState<File>()
const [fileContent, setFileContent] = useState<string>()
const [currentTab, setCurrentTab] = useState(activeTab)
const [dslUrlValue, setDslUrlValue] = useState(dslUrl)
const [showErrorModal, setShowErrorModal] = useState(false)
const [versions, setVersions] = useState<{ importedVersion: string, systemVersion: string }>()
const [importId, setImportId] = useState<string>()
const { handleCheckPluginDependencies } = usePluginDependencies()
const {
currentFile,
currentTab,
dslUrlValue,
showConfirmModal,
versions,
buttonDisabled,
isConfirming,
setCurrentTab,
setDslUrlValue,
handleFile,
handleCreateApp,
onDSLConfirm,
handleCancelConfirm,
} = useDSLImport({
activeTab,
dslUrl,
onSuccess,
onClose,
})
const readFile = (file: File) => {
const reader = new FileReader()
reader.onload = function (event) {
const content = event.target?.result
setFileContent(content as string)
}
reader.readAsText(file)
}
const handleFile = (file?: File) => {
setDSLFile(file)
if (file)
readFile(file)
if (!file)
setFileContent('')
}
const isCreatingRef = useRef(false)
const { mutateAsync: importDSL } = useImportPipelineDSL()
const onCreate = async () => {
if (currentTab === CreateFromDSLModalTab.FROM_FILE && !currentFile)
return
if (currentTab === CreateFromDSLModalTab.FROM_URL && !dslUrlValue)
return
if (isCreatingRef.current)
return
isCreatingRef.current = true
let response
if (currentTab === CreateFromDSLModalTab.FROM_FILE) {
response = await importDSL({
mode: DSLImportMode.YAML_CONTENT,
yaml_content: fileContent || '',
})
}
if (currentTab === CreateFromDSLModalTab.FROM_URL) {
response = await importDSL({
mode: DSLImportMode.YAML_URL,
yaml_url: dslUrlValue || '',
})
}
if (!response) {
notify({ type: 'error', message: t('creation.errorTip', { ns: 'datasetPipeline' }) })
isCreatingRef.current = false
return
}
const { id, status, pipeline_id, dataset_id, imported_dsl_version, current_dsl_version } = response
if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) {
if (onSuccess)
onSuccess()
if (onClose)
onClose()
notify({
type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning',
message: t(status === DSLImportStatus.COMPLETED ? 'creation.successTip' : 'creation.caution', { ns: 'datasetPipeline' }),
children: status === DSLImportStatus.COMPLETED_WITH_WARNINGS && t('newApp.appCreateDSLWarning', { ns: 'app' }),
})
if (pipeline_id)
await handleCheckPluginDependencies(pipeline_id, true)
push(`/datasets/${dataset_id}/pipeline`)
isCreatingRef.current = false
}
else if (status === DSLImportStatus.PENDING) {
setVersions({
importedVersion: imported_dsl_version ?? '',
systemVersion: current_dsl_version ?? '',
})
if (onClose)
onClose()
setTimeout(() => {
setShowErrorModal(true)
}, 300)
setImportId(id)
isCreatingRef.current = false
}
else {
notify({ type: 'error', message: t('creation.errorTip', { ns: 'datasetPipeline' }) })
isCreatingRef.current = false
}
}
const { run: handleCreateApp } = useDebounceFn(onCreate, { wait: 300 })
useKeyPress('esc', () => {
if (show && !showConfirmModal)
if (show && !showErrorModal)
onClose()
})
const { mutateAsync: importDSLConfirm } = useImportPipelineDSLConfirm()
const onDSLConfirm = async () => {
if (!importId)
return
const response = await importDSLConfirm(importId)
if (!response) {
notify({ type: 'error', message: t('creation.errorTip', { ns: 'datasetPipeline' }) })
return
}
const { status, pipeline_id, dataset_id } = response
if (status === DSLImportStatus.COMPLETED) {
if (onSuccess)
onSuccess()
if (onClose)
onClose()
notify({
type: 'success',
message: t('creation.successTip', { ns: 'datasetPipeline' }),
})
if (pipeline_id)
await handleCheckPluginDependencies(pipeline_id, true)
push(`datasets/${dataset_id}/pipeline`)
}
else if (status === DSLImportStatus.FAILED) {
notify({ type: 'error', message: t('creation.errorTip', { ns: 'datasetPipeline' }) })
}
}
const buttonDisabled = useMemo(() => {
if (currentTab === CreateFromDSLModalTab.FROM_FILE)
return !currentFile
if (currentTab === CreateFromDSLModalTab.FROM_URL)
return !dslUrlValue
return false
}, [currentTab, currentFile, dslUrlValue])
return (
<>
<Modal
@ -69,25 +196,29 @@ const CreateFromDSLModal = ({
setCurrentTab={setCurrentTab}
/>
<div className="px-6 py-4">
{currentTab === CreateFromDSLModalTab.FROM_FILE && (
<Uploader
className="mt-0"
file={currentFile}
updateFile={handleFile}
/>
)}
{currentTab === CreateFromDSLModalTab.FROM_URL && (
<div>
<div className="system-md-semibold leading6 mb-1 text-text-secondary">
DSL URL
</div>
<Input
placeholder={t('importFromDSLUrlPlaceholder', { ns: 'app' }) || ''}
value={dslUrlValue}
onChange={e => setDslUrlValue(e.target.value)}
{
currentTab === CreateFromDSLModalTab.FROM_FILE && (
<Uploader
className="mt-0"
file={currentFile}
updateFile={handleFile}
/>
</div>
)}
)
}
{
currentTab === CreateFromDSLModalTab.FROM_URL && (
<div>
<div className="system-md-semibold leading6 mb-1 text-text-secondary">
DSL URL
</div>
<Input
placeholder={t('importFromDSLUrlPlaceholder', { ns: 'app' }) || ''}
value={dslUrlValue}
onChange={e => setDslUrlValue(e.target.value)}
/>
</div>
)
}
</div>
<div className="flex justify-end gap-x-2 p-6 pt-5">
<Button onClick={onClose}>
@ -103,14 +234,32 @@ const CreateFromDSLModal = ({
</Button>
</div>
</Modal>
{showConfirmModal && (
<DSLConfirmModal
versions={versions}
onCancel={handleCancelConfirm}
onConfirm={onDSLConfirm}
confirmDisabled={isConfirming}
/>
)}
<Modal
isShow={showErrorModal}
onClose={() => setShowErrorModal(false)}
className="w-[480px]"
>
<div className="flex flex-col items-start gap-2 self-stretch pb-4">
<div className="title-2xl-semi-bold text-text-primary">{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}</div>
<div className="system-md-regular flex grow flex-col text-text-secondary">
<div>{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}</div>
<div>{t('newApp.appCreateDSLErrorPart2', { ns: 'app' })}</div>
<br />
<div>
{t('newApp.appCreateDSLErrorPart3', { ns: 'app' })}
<span className="system-md-medium">{versions?.importedVersion}</span>
</div>
<div>
{t('newApp.appCreateDSLErrorPart4', { ns: 'app' })}
<span className="system-md-medium">{versions?.systemVersion}</span>
</div>
</div>
</div>
<div className="flex items-start justify-end gap-2 self-stretch pt-6">
<Button variant="secondary" onClick={() => setShowErrorModal(false)}>{t('newApp.Cancel', { ns: 'app' })}</Button>
<Button variant="primary" destructive onClick={onDSLConfirm}>{t('newApp.Confirm', { ns: 'app' })}</Button>
</div>
</Modal>
</>
)
}

View File

@ -1,334 +0,0 @@
import type { FileListItemProps } from './file-list-item'
import type { CustomFile as File, FileItem } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PROGRESS_COMPLETE, PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../constants'
import FileListItem from './file-list-item'
// Mock theme hook - can be changed per test
let mockTheme = 'light'
vi.mock('@/hooks/use-theme', () => ({
default: () => ({ theme: mockTheme }),
}))
// Mock theme types
vi.mock('@/types/app', () => ({
Theme: { dark: 'dark', light: 'light' },
}))
// Mock SimplePieChart with dynamic import handling
vi.mock('next/dynamic', () => ({
default: () => {
const DynamicComponent = ({ percentage, stroke, fill }: { percentage: number, stroke: string, fill: string }) => (
<div data-testid="pie-chart" data-percentage={percentage} data-stroke={stroke} data-fill={fill}>
Pie Chart:
{' '}
{percentage}
%
</div>
)
DynamicComponent.displayName = 'SimplePieChart'
return DynamicComponent
},
}))
// Mock DocumentFileIcon
vi.mock('@/app/components/datasets/common/document-file-icon', () => ({
default: ({ name, extension, size }: { name: string, extension: string, size: string }) => (
<div data-testid="document-icon" data-name={name} data-extension={extension} data-size={size}>
Document Icon
</div>
),
}))
describe('FileListItem', () => {
const createMockFile = (overrides: Partial<File> = {}): File => ({
name: 'test-document.pdf',
size: 1024 * 100, // 100KB
type: 'application/pdf',
lastModified: Date.now(),
...overrides,
} as File)
const createMockFileItem = (overrides: Partial<FileItem> = {}): FileItem => ({
fileID: 'file-123',
file: createMockFile(overrides.file as Partial<File>),
progress: PROGRESS_NOT_STARTED,
...overrides,
})
const defaultProps: FileListItemProps = {
fileItem: createMockFileItem(),
onPreview: vi.fn(),
onRemove: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
mockTheme = 'light'
})
describe('rendering', () => {
it('should render the file item container', () => {
const { container } = render(<FileListItem {...defaultProps} />)
const item = container.firstChild as HTMLElement
expect(item).toHaveClass('flex', 'h-12', 'items-center', 'rounded-lg')
})
it('should render document icon with correct props', () => {
render(<FileListItem {...defaultProps} />)
const icon = screen.getByTestId('document-icon')
expect(icon).toBeInTheDocument()
expect(icon).toHaveAttribute('data-name', 'test-document.pdf')
expect(icon).toHaveAttribute('data-extension', 'pdf')
expect(icon).toHaveAttribute('data-size', 'xl')
})
it('should render file name', () => {
render(<FileListItem {...defaultProps} />)
expect(screen.getByText('test-document.pdf')).toBeInTheDocument()
})
it('should render file extension in uppercase via CSS class', () => {
render(<FileListItem {...defaultProps} />)
const extensionSpan = screen.getByText('pdf')
expect(extensionSpan).toBeInTheDocument()
expect(extensionSpan).toHaveClass('uppercase')
})
it('should render file size', () => {
render(<FileListItem {...defaultProps} />)
// Default mock file is 100KB (1024 * 100 bytes)
expect(screen.getByText('100.00 KB')).toBeInTheDocument()
})
it('should render delete button', () => {
const { container } = render(<FileListItem {...defaultProps} />)
const deleteButton = container.querySelector('.cursor-pointer')
expect(deleteButton).toBeInTheDocument()
})
})
describe('progress states', () => {
it('should show progress chart when uploading (0-99)', () => {
const fileItem = createMockFileItem({ progress: 50 })
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
const pieChart = screen.getByTestId('pie-chart')
expect(pieChart).toBeInTheDocument()
expect(pieChart).toHaveAttribute('data-percentage', '50')
})
it('should show progress chart at 0%', () => {
const fileItem = createMockFileItem({ progress: 0 })
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
const pieChart = screen.getByTestId('pie-chart')
expect(pieChart).toHaveAttribute('data-percentage', '0')
})
it('should not show progress chart when complete (100)', () => {
const fileItem = createMockFileItem({ progress: PROGRESS_COMPLETE })
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
expect(screen.queryByTestId('pie-chart')).not.toBeInTheDocument()
})
it('should not show progress chart when not started (-1)', () => {
const fileItem = createMockFileItem({ progress: PROGRESS_NOT_STARTED })
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
expect(screen.queryByTestId('pie-chart')).not.toBeInTheDocument()
})
})
describe('error state', () => {
it('should show error indicator when progress is PROGRESS_ERROR', () => {
const fileItem = createMockFileItem({ progress: PROGRESS_ERROR })
const { container } = render(<FileListItem {...defaultProps} fileItem={fileItem} />)
const errorIndicator = container.querySelector('.text-text-destructive')
expect(errorIndicator).toBeInTheDocument()
})
it('should not show error indicator when not in error state', () => {
const { container } = render(<FileListItem {...defaultProps} />)
const errorIndicator = container.querySelector('.text-text-destructive')
expect(errorIndicator).not.toBeInTheDocument()
})
})
describe('theme handling', () => {
it('should use correct chart color for light theme', () => {
mockTheme = 'light'
const fileItem = createMockFileItem({ progress: 50 })
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
const pieChart = screen.getByTestId('pie-chart')
expect(pieChart).toHaveAttribute('data-stroke', '#296dff')
expect(pieChart).toHaveAttribute('data-fill', '#296dff')
})
it('should use correct chart color for dark theme', () => {
mockTheme = 'dark'
const fileItem = createMockFileItem({ progress: 50 })
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
const pieChart = screen.getByTestId('pie-chart')
expect(pieChart).toHaveAttribute('data-stroke', '#5289ff')
expect(pieChart).toHaveAttribute('data-fill', '#5289ff')
})
})
describe('event handlers', () => {
it('should call onPreview when item is clicked with file id', () => {
const onPreview = vi.fn()
const fileItem = createMockFileItem({
file: createMockFile({ id: 'uploaded-id' } as Partial<File>),
})
render(<FileListItem {...defaultProps} fileItem={fileItem} onPreview={onPreview} />)
const item = screen.getByText('test-document.pdf').closest('[class*="flex h-12"]')!
fireEvent.click(item)
expect(onPreview).toHaveBeenCalledTimes(1)
expect(onPreview).toHaveBeenCalledWith(fileItem.file)
})
it('should not call onPreview when file has no id', () => {
const onPreview = vi.fn()
const fileItem = createMockFileItem()
render(<FileListItem {...defaultProps} fileItem={fileItem} onPreview={onPreview} />)
const item = screen.getByText('test-document.pdf').closest('[class*="flex h-12"]')!
fireEvent.click(item)
expect(onPreview).not.toHaveBeenCalled()
})
it('should call onRemove when delete button is clicked', () => {
const onRemove = vi.fn()
const fileItem = createMockFileItem()
const { container } = render(<FileListItem {...defaultProps} fileItem={fileItem} onRemove={onRemove} />)
const deleteButton = container.querySelector('.cursor-pointer')!
fireEvent.click(deleteButton)
expect(onRemove).toHaveBeenCalledTimes(1)
expect(onRemove).toHaveBeenCalledWith('file-123')
})
it('should stop propagation when delete button is clicked', () => {
const onPreview = vi.fn()
const onRemove = vi.fn()
const fileItem = createMockFileItem({
file: createMockFile({ id: 'uploaded-id' } as Partial<File>),
})
const { container } = render(<FileListItem {...defaultProps} fileItem={fileItem} onPreview={onPreview} onRemove={onRemove} />)
const deleteButton = container.querySelector('.cursor-pointer')!
fireEvent.click(deleteButton)
expect(onRemove).toHaveBeenCalledTimes(1)
expect(onPreview).not.toHaveBeenCalled()
})
})
describe('file type handling', () => {
it('should handle files with multiple dots in name', () => {
const fileItem = createMockFileItem({
file: createMockFile({ name: 'my.document.file.docx' }),
})
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
expect(screen.getByText('my.document.file.docx')).toBeInTheDocument()
expect(screen.getByText('docx')).toBeInTheDocument()
})
it('should handle files without extension', () => {
const fileItem = createMockFileItem({
file: createMockFile({ name: 'README' }),
})
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
// File name appears once, and extension area shows empty string
expect(screen.getByText('README')).toBeInTheDocument()
})
it('should handle various file extensions', () => {
const extensions = ['txt', 'md', 'json', 'csv', 'xlsx']
extensions.forEach((ext) => {
const fileItem = createMockFileItem({
file: createMockFile({ name: `file.${ext}` }),
})
const { unmount } = render(<FileListItem {...defaultProps} fileItem={fileItem} />)
expect(screen.getByText(ext)).toBeInTheDocument()
unmount()
})
})
})
describe('file size display', () => {
it('should display size in KB for small files', () => {
const fileItem = createMockFileItem({
file: createMockFile({ size: 5 * 1024 }),
})
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
expect(screen.getByText('5.00 KB')).toBeInTheDocument()
})
it('should display size in MB for larger files', () => {
const fileItem = createMockFileItem({
file: createMockFile({ size: 5 * 1024 * 1024 }),
})
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
expect(screen.getByText('5.00 MB')).toBeInTheDocument()
})
})
describe('upload progress values', () => {
it('should show chart at progress 1', () => {
const fileItem = createMockFileItem({ progress: 1 })
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
expect(screen.getByTestId('pie-chart')).toBeInTheDocument()
})
it('should show chart at progress 99', () => {
const fileItem = createMockFileItem({ progress: 99 })
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
expect(screen.getByTestId('pie-chart')).toHaveAttribute('data-percentage', '99')
})
it('should not show chart at progress 100', () => {
const fileItem = createMockFileItem({ progress: 100 })
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
expect(screen.queryByTestId('pie-chart')).not.toBeInTheDocument()
})
})
describe('styling', () => {
it('should have proper shadow styling', () => {
const { container } = render(<FileListItem {...defaultProps} />)
const item = container.firstChild as HTMLElement
expect(item).toHaveClass('shadow-xs')
})
it('should have proper border styling', () => {
const { container } = render(<FileListItem {...defaultProps} />)
const item = container.firstChild as HTMLElement
expect(item).toHaveClass('border', 'border-components-panel-border')
})
it('should truncate long file names', () => {
const longFileName = 'this-is-a-very-long-file-name-that-should-be-truncated.pdf'
const fileItem = createMockFileItem({
file: createMockFile({ name: longFileName }),
})
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
const nameElement = screen.getByText(longFileName)
expect(nameElement).toHaveClass('truncate')
})
})
})

View File

@ -1,89 +0,0 @@
'use client'
import type { CustomFile as File, FileItem } from '@/models/datasets'
import { RiDeleteBinLine } from '@remixicon/react'
import dynamic from 'next/dynamic'
import { useMemo } from 'react'
import DocumentFileIcon from '@/app/components/datasets/common/document-file-icon'
import useTheme from '@/hooks/use-theme'
import { Theme } from '@/types/app'
import { formatFileSize, getFileExtension } from '@/utils/format'
import { PROGRESS_COMPLETE, PROGRESS_ERROR } from '../constants'
const SimplePieChart = dynamic(() => import('@/app/components/base/simple-pie-chart'), { ssr: false })
export type FileListItemProps = {
fileItem: FileItem
onPreview: (file: File) => void
onRemove: (fileID: string) => void
}
const FileListItem = ({
fileItem,
onPreview,
onRemove,
}: FileListItemProps) => {
const { theme } = useTheme()
const chartColor = useMemo(() => theme === Theme.dark ? '#5289ff' : '#296dff', [theme])
const isUploading = fileItem.progress >= 0 && fileItem.progress < PROGRESS_COMPLETE
const isError = fileItem.progress === PROGRESS_ERROR
const handleClick = () => {
if (fileItem.file?.id)
onPreview(fileItem.file)
}
const handleRemove = (e: React.MouseEvent) => {
e.stopPropagation()
onRemove(fileItem.fileID)
}
return (
<div
onClick={handleClick}
className="flex h-12 max-w-[640px] items-center rounded-lg border border-components-panel-border bg-components-panel-on-panel-item-bg text-xs leading-3 text-text-tertiary shadow-xs"
>
<div className="flex w-12 shrink-0 items-center justify-center">
<DocumentFileIcon
size="xl"
className="shrink-0"
name={fileItem.file.name}
extension={getFileExtension(fileItem.file.name)}
/>
</div>
<div className="flex shrink grow flex-col gap-0.5">
<div className="flex w-full">
<div className="w-0 grow truncate text-sm leading-4 text-text-secondary">
{fileItem.file.name}
</div>
</div>
<div className="w-full truncate leading-3 text-text-tertiary">
<span className="uppercase">{getFileExtension(fileItem.file.name)}</span>
<span className="px-1 text-text-quaternary">·</span>
<span>{formatFileSize(fileItem.file.size)}</span>
</div>
</div>
<div className="flex w-16 shrink-0 items-center justify-end gap-1 pr-3">
{isUploading && (
<SimplePieChart
percentage={fileItem.progress}
stroke={chartColor}
fill={chartColor}
animationDuration={0}
/>
)}
{isError && (
<span className="text-text-destructive">!</span>
)}
<span
className="flex h-6 w-6 cursor-pointer items-center justify-center"
onClick={handleRemove}
>
<RiDeleteBinLine className="size-4 text-text-tertiary" />
</span>
</div>
</div>
)
}
export default FileListItem

View File

@ -1,210 +0,0 @@
import type { RefObject } from 'react'
import type { UploadDropzoneProps } from './upload-dropzone'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import UploadDropzone from './upload-dropzone'
// Helper to create mock ref objects for testing
const createMockRef = <T,>(value: T | null = null): RefObject<T | null> => ({ current: value })
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
const translations: Record<string, string> = {
'stepOne.uploader.button': 'Drag and drop files, or',
'stepOne.uploader.buttonSingleFile': 'Drag and drop file, or',
'stepOne.uploader.browse': 'Browse',
'stepOne.uploader.tip': 'Supports {{supportTypes}}, Max {{size}}MB each, up to {{batchCount}} files at a time, {{totalCount}} files total',
}
let result = translations[key] || key
if (options && typeof options === 'object') {
Object.entries(options).forEach(([k, v]) => {
result = result.replace(`{{${k}}}`, String(v))
})
}
return result
},
}),
}))
describe('UploadDropzone', () => {
const defaultProps: UploadDropzoneProps = {
dropRef: createMockRef<HTMLDivElement>() as RefObject<HTMLDivElement | null>,
dragRef: createMockRef<HTMLDivElement>() as RefObject<HTMLDivElement | null>,
fileUploaderRef: createMockRef<HTMLInputElement>() as RefObject<HTMLInputElement | null>,
dragging: false,
supportBatchUpload: true,
supportTypesShowNames: 'PDF, DOCX, TXT',
fileUploadConfig: {
file_size_limit: 15,
batch_count_limit: 5,
file_upload_limit: 10,
},
acceptTypes: ['.pdf', '.docx', '.txt'],
onSelectFile: vi.fn(),
onFileChange: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('rendering', () => {
it('should render the dropzone container', () => {
const { container } = render(<UploadDropzone {...defaultProps} />)
const dropzone = container.querySelector('[class*="border-dashed"]')
expect(dropzone).toBeInTheDocument()
})
it('should render hidden file input', () => {
render(<UploadDropzone {...defaultProps} />)
const input = document.getElementById('fileUploader') as HTMLInputElement
expect(input).toBeInTheDocument()
expect(input).toHaveClass('hidden')
expect(input).toHaveAttribute('type', 'file')
})
it('should render upload icon', () => {
render(<UploadDropzone {...defaultProps} />)
const icon = document.querySelector('svg')
expect(icon).toBeInTheDocument()
})
it('should render browse label when extensions are allowed', () => {
render(<UploadDropzone {...defaultProps} />)
expect(screen.getByText('Browse')).toBeInTheDocument()
})
it('should not render browse label when no extensions allowed', () => {
render(<UploadDropzone {...defaultProps} acceptTypes={[]} />)
expect(screen.queryByText('Browse')).not.toBeInTheDocument()
})
it('should render file size and count limits', () => {
render(<UploadDropzone {...defaultProps} />)
const tipText = screen.getByText(/Supports.*Max.*15MB/i)
expect(tipText).toBeInTheDocument()
})
})
describe('file input configuration', () => {
it('should allow multiple files when supportBatchUpload is true', () => {
render(<UploadDropzone {...defaultProps} supportBatchUpload={true} />)
const input = document.getElementById('fileUploader') as HTMLInputElement
expect(input).toHaveAttribute('multiple')
})
it('should not allow multiple files when supportBatchUpload is false', () => {
render(<UploadDropzone {...defaultProps} supportBatchUpload={false} />)
const input = document.getElementById('fileUploader') as HTMLInputElement
expect(input).not.toHaveAttribute('multiple')
})
it('should set accept attribute with correct types', () => {
render(<UploadDropzone {...defaultProps} acceptTypes={['.pdf', '.docx']} />)
const input = document.getElementById('fileUploader') as HTMLInputElement
expect(input).toHaveAttribute('accept', '.pdf,.docx')
})
})
describe('text content', () => {
it('should show batch upload text when supportBatchUpload is true', () => {
render(<UploadDropzone {...defaultProps} supportBatchUpload={true} />)
expect(screen.getByText(/Drag and drop files/i)).toBeInTheDocument()
})
it('should show single file text when supportBatchUpload is false', () => {
render(<UploadDropzone {...defaultProps} supportBatchUpload={false} />)
expect(screen.getByText(/Drag and drop file/i)).toBeInTheDocument()
})
})
describe('dragging state', () => {
it('should apply dragging styles when dragging is true', () => {
const { container } = render(<UploadDropzone {...defaultProps} dragging={true} />)
const dropzone = container.querySelector('[class*="border-components-dropzone-border-accent"]')
expect(dropzone).toBeInTheDocument()
})
it('should render drag overlay when dragging', () => {
const dragRef = createMockRef<HTMLDivElement>()
render(<UploadDropzone {...defaultProps} dragging={true} dragRef={dragRef as RefObject<HTMLDivElement | null>} />)
const overlay = document.querySelector('.absolute.left-0.top-0')
expect(overlay).toBeInTheDocument()
})
it('should not render drag overlay when not dragging', () => {
render(<UploadDropzone {...defaultProps} dragging={false} />)
const overlay = document.querySelector('.absolute.left-0.top-0')
expect(overlay).not.toBeInTheDocument()
})
})
describe('event handlers', () => {
it('should call onSelectFile when browse label is clicked', () => {
const onSelectFile = vi.fn()
render(<UploadDropzone {...defaultProps} onSelectFile={onSelectFile} />)
const browseLabel = screen.getByText('Browse')
fireEvent.click(browseLabel)
expect(onSelectFile).toHaveBeenCalledTimes(1)
})
it('should call onFileChange when files are selected', () => {
const onFileChange = vi.fn()
render(<UploadDropzone {...defaultProps} onFileChange={onFileChange} />)
const input = document.getElementById('fileUploader') as HTMLInputElement
const file = new File(['content'], 'test.pdf', { type: 'application/pdf' })
fireEvent.change(input, { target: { files: [file] } })
expect(onFileChange).toHaveBeenCalledTimes(1)
})
})
describe('refs', () => {
it('should attach dropRef to drop container', () => {
const dropRef = createMockRef<HTMLDivElement>()
render(<UploadDropzone {...defaultProps} dropRef={dropRef as RefObject<HTMLDivElement | null>} />)
expect(dropRef.current).toBeInstanceOf(HTMLDivElement)
})
it('should attach fileUploaderRef to input element', () => {
const fileUploaderRef = createMockRef<HTMLInputElement>()
render(<UploadDropzone {...defaultProps} fileUploaderRef={fileUploaderRef as RefObject<HTMLInputElement | null>} />)
expect(fileUploaderRef.current).toBeInstanceOf(HTMLInputElement)
})
it('should attach dragRef to overlay when dragging', () => {
const dragRef = createMockRef<HTMLDivElement>()
render(<UploadDropzone {...defaultProps} dragging={true} dragRef={dragRef as RefObject<HTMLDivElement | null>} />)
expect(dragRef.current).toBeInstanceOf(HTMLDivElement)
})
})
describe('styling', () => {
it('should have base dropzone styling', () => {
const { container } = render(<UploadDropzone {...defaultProps} />)
const dropzone = container.querySelector('[class*="border-dashed"]')
expect(dropzone).toBeInTheDocument()
expect(dropzone).toHaveClass('rounded-xl')
})
it('should have cursor-pointer on browse label', () => {
render(<UploadDropzone {...defaultProps} />)
const browseLabel = screen.getByText('Browse')
expect(browseLabel).toHaveClass('cursor-pointer')
})
})
describe('accessibility', () => {
it('should have an accessible file input', () => {
render(<UploadDropzone {...defaultProps} />)
const input = document.getElementById('fileUploader') as HTMLInputElement
expect(input).toHaveAttribute('id', 'fileUploader')
})
})
})

View File

@ -1,84 +0,0 @@
'use client'
import type { RefObject } from 'react'
import type { FileUploadConfig } from '../hooks/use-file-upload'
import { RiUploadCloud2Line } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/utils/classnames'
export type UploadDropzoneProps = {
dropRef: RefObject<HTMLDivElement | null>
dragRef: RefObject<HTMLDivElement | null>
fileUploaderRef: RefObject<HTMLInputElement | null>
dragging: boolean
supportBatchUpload: boolean
supportTypesShowNames: string
fileUploadConfig: FileUploadConfig
acceptTypes: string[]
onSelectFile: () => void
onFileChange: (e: React.ChangeEvent<HTMLInputElement>) => void
}
const UploadDropzone = ({
dropRef,
dragRef,
fileUploaderRef,
dragging,
supportBatchUpload,
supportTypesShowNames,
fileUploadConfig,
acceptTypes,
onSelectFile,
onFileChange,
}: UploadDropzoneProps) => {
const { t } = useTranslation()
return (
<>
<input
ref={fileUploaderRef}
id="fileUploader"
className="hidden"
type="file"
multiple={supportBatchUpload}
accept={acceptTypes.join(',')}
onChange={onFileChange}
/>
<div
ref={dropRef}
className={cn(
'relative mb-2 box-border flex min-h-20 max-w-[640px] flex-col items-center justify-center gap-1 rounded-xl border border-dashed border-components-dropzone-border bg-components-dropzone-bg px-4 py-3 text-xs leading-4 text-text-tertiary',
dragging && 'border-components-dropzone-border-accent bg-components-dropzone-bg-accent',
)}
>
<div className="flex min-h-5 items-center justify-center text-sm leading-4 text-text-secondary">
<RiUploadCloud2Line className="mr-2 size-5" />
<span>
{supportBatchUpload
? t('stepOne.uploader.button', { ns: 'datasetCreation' })
: t('stepOne.uploader.buttonSingleFile', { ns: 'datasetCreation' })}
{acceptTypes.length > 0 && (
<label
className="ml-1 cursor-pointer text-text-accent"
onClick={onSelectFile}
>
{t('stepOne.uploader.browse', { ns: 'datasetCreation' })}
</label>
)}
</span>
</div>
<div>
{t('stepOne.uploader.tip', {
ns: 'datasetCreation',
size: fileUploadConfig.file_size_limit,
supportTypes: supportTypesShowNames,
batchCount: fileUploadConfig.batch_count_limit,
totalCount: fileUploadConfig.file_upload_limit,
})}
</div>
{dragging && <div ref={dragRef} className="absolute left-0 top-0 h-full w-full" />}
</div>
</>
)
}
export default UploadDropzone

View File

@ -1,3 +0,0 @@
export const PROGRESS_NOT_STARTED = -1
export const PROGRESS_ERROR = -2
export const PROGRESS_COMPLETE = 100

View File

@ -1,921 +0,0 @@
import type { ReactNode } from 'react'
import type { CustomFile, FileItem } from '@/models/datasets'
import { act, render, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ToastContext } from '@/app/components/base/toast'
import { PROGRESS_COMPLETE, PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../constants'
// Import after mocks
import { useFileUpload } from './use-file-upload'
// Mock notify function
const mockNotify = vi.fn()
const mockClose = vi.fn()
// Mock ToastContext
vi.mock('use-context-selector', async () => {
const actual = await vi.importActual<typeof import('use-context-selector')>('use-context-selector')
return {
...actual,
useContext: vi.fn(() => ({ notify: mockNotify, close: mockClose })),
}
})
// Mock upload service
const mockUpload = vi.fn()
vi.mock('@/service/base', () => ({
upload: (...args: unknown[]) => mockUpload(...args),
}))
// Mock file upload config
const mockFileUploadConfig = {
file_size_limit: 15,
batch_count_limit: 5,
file_upload_limit: 10,
}
const mockSupportTypes = {
allowed_extensions: ['pdf', 'docx', 'txt', 'md'],
}
vi.mock('@/service/use-common', () => ({
useFileUploadConfig: () => ({ data: mockFileUploadConfig }),
useFileSupportTypes: () => ({ data: mockSupportTypes }),
}))
// Mock i18n
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock locale
vi.mock('@/context/i18n', () => ({
useLocale: () => 'en-US',
}))
vi.mock('@/i18n-config/language', () => ({
LanguagesSupported: ['en-US', 'zh-Hans'],
}))
// Mock config
vi.mock('@/config', () => ({
IS_CE_EDITION: false,
}))
// Mock file upload error message
vi.mock('@/app/components/base/file-uploader/utils', () => ({
getFileUploadErrorMessage: (_e: unknown, defaultMsg: string) => defaultMsg,
}))
const createWrapper = () => {
return ({ children }: { children: ReactNode }) => (
<ToastContext.Provider value={{ notify: mockNotify, close: mockClose }}>
{children}
</ToastContext.Provider>
)
}
describe('useFileUpload', () => {
const defaultOptions = {
fileList: [] as FileItem[],
prepareFileList: vi.fn(),
onFileUpdate: vi.fn(),
onFileListUpdate: vi.fn(),
onPreview: vi.fn(),
supportBatchUpload: true,
}
beforeEach(() => {
vi.clearAllMocks()
mockUpload.mockReset()
// Default mock to return a resolved promise to avoid unhandled rejections
mockUpload.mockResolvedValue({ id: 'default-id' })
mockNotify.mockReset()
})
describe('initialization', () => {
it('should initialize with default values', () => {
const { result } = renderHook(
() => useFileUpload(defaultOptions),
{ wrapper: createWrapper() },
)
expect(result.current.dragging).toBe(false)
expect(result.current.hideUpload).toBe(false)
expect(result.current.dropRef.current).toBeNull()
expect(result.current.dragRef.current).toBeNull()
expect(result.current.fileUploaderRef.current).toBeNull()
})
it('should set hideUpload true when not batch upload and has files', () => {
const { result } = renderHook(
() => useFileUpload({
...defaultOptions,
supportBatchUpload: false,
fileList: [{ fileID: 'file-1', file: {} as CustomFile, progress: 100 }],
}),
{ wrapper: createWrapper() },
)
expect(result.current.hideUpload).toBe(true)
})
it('should compute acceptTypes correctly', () => {
const { result } = renderHook(
() => useFileUpload(defaultOptions),
{ wrapper: createWrapper() },
)
expect(result.current.acceptTypes).toEqual(['.pdf', '.docx', '.txt', '.md'])
})
it('should compute supportTypesShowNames correctly', () => {
const { result } = renderHook(
() => useFileUpload(defaultOptions),
{ wrapper: createWrapper() },
)
expect(result.current.supportTypesShowNames).toContain('PDF')
expect(result.current.supportTypesShowNames).toContain('DOCX')
expect(result.current.supportTypesShowNames).toContain('TXT')
// 'md' is mapped to 'markdown' in the extensionMap
expect(result.current.supportTypesShowNames).toContain('MARKDOWN')
})
it('should set batch limit to 1 when not batch upload', () => {
const { result } = renderHook(
() => useFileUpload({
...defaultOptions,
supportBatchUpload: false,
}),
{ wrapper: createWrapper() },
)
expect(result.current.fileUploadConfig.batch_count_limit).toBe(1)
expect(result.current.fileUploadConfig.file_upload_limit).toBe(1)
})
})
describe('selectHandle', () => {
it('should trigger click on file input', () => {
const { result } = renderHook(
() => useFileUpload(defaultOptions),
{ wrapper: createWrapper() },
)
const mockClick = vi.fn()
const mockInput = { click: mockClick } as unknown as HTMLInputElement
Object.defineProperty(result.current.fileUploaderRef, 'current', {
value: mockInput,
writable: true,
})
act(() => {
result.current.selectHandle()
})
expect(mockClick).toHaveBeenCalled()
})
it('should do nothing when file input ref is null', () => {
const { result } = renderHook(
() => useFileUpload(defaultOptions),
{ wrapper: createWrapper() },
)
expect(() => {
act(() => {
result.current.selectHandle()
})
}).not.toThrow()
})
})
describe('handlePreview', () => {
it('should call onPreview when file has id', () => {
const onPreview = vi.fn()
const { result } = renderHook(
() => useFileUpload({ ...defaultOptions, onPreview }),
{ wrapper: createWrapper() },
)
const mockFile = { id: 'file-123', name: 'test.pdf', size: 1024 } as CustomFile
act(() => {
result.current.handlePreview(mockFile)
})
expect(onPreview).toHaveBeenCalledWith(mockFile)
})
it('should not call onPreview when file has no id', () => {
const onPreview = vi.fn()
const { result } = renderHook(
() => useFileUpload({ ...defaultOptions, onPreview }),
{ wrapper: createWrapper() },
)
const mockFile = { name: 'test.pdf', size: 1024 } as CustomFile
act(() => {
result.current.handlePreview(mockFile)
})
expect(onPreview).not.toHaveBeenCalled()
})
})
describe('removeFile', () => {
it('should call onFileListUpdate with filtered list', () => {
const onFileListUpdate = vi.fn()
const { result } = renderHook(
() => useFileUpload({ ...defaultOptions, onFileListUpdate }),
{ wrapper: createWrapper() },
)
act(() => {
result.current.removeFile('file-to-remove')
})
expect(onFileListUpdate).toHaveBeenCalled()
})
it('should clear file input value', () => {
const { result } = renderHook(
() => useFileUpload(defaultOptions),
{ wrapper: createWrapper() },
)
const mockInput = { value: 'some-file' } as HTMLInputElement
Object.defineProperty(result.current.fileUploaderRef, 'current', {
value: mockInput,
writable: true,
})
act(() => {
result.current.removeFile('file-123')
})
expect(mockInput.value).toBe('')
})
})
describe('fileChangeHandle', () => {
it('should handle valid files', async () => {
mockUpload.mockResolvedValue({ id: 'uploaded-id' })
const prepareFileList = vi.fn()
const { result } = renderHook(
() => useFileUpload({ ...defaultOptions, prepareFileList }),
{ wrapper: createWrapper() },
)
const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' })
const event = {
target: { files: [mockFile] },
} as unknown as React.ChangeEvent<HTMLInputElement>
act(() => {
result.current.fileChangeHandle(event)
})
await waitFor(() => {
expect(prepareFileList).toHaveBeenCalled()
})
})
it('should limit files to batch count', () => {
const prepareFileList = vi.fn()
const { result } = renderHook(
() => useFileUpload({ ...defaultOptions, prepareFileList }),
{ wrapper: createWrapper() },
)
const files = Array.from({ length: 10 }, (_, i) =>
new File(['content'], `file${i}.pdf`, { type: 'application/pdf' }))
const event = {
target: { files },
} as unknown as React.ChangeEvent<HTMLInputElement>
act(() => {
result.current.fileChangeHandle(event)
})
// Should be called with at most batch_count_limit files
if (prepareFileList.mock.calls.length > 0) {
const calledFiles = prepareFileList.mock.calls[0][0]
expect(calledFiles.length).toBeLessThanOrEqual(mockFileUploadConfig.batch_count_limit)
}
})
it('should reject invalid file types', () => {
const { result } = renderHook(
() => useFileUpload(defaultOptions),
{ wrapper: createWrapper() },
)
const mockFile = new File(['content'], 'test.exe', { type: 'application/x-msdownload' })
const event = {
target: { files: [mockFile] },
} as unknown as React.ChangeEvent<HTMLInputElement>
act(() => {
result.current.fileChangeHandle(event)
})
expect(mockNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
)
})
it('should reject files exceeding size limit', () => {
const { result } = renderHook(
() => useFileUpload(defaultOptions),
{ wrapper: createWrapper() },
)
// Create a file larger than the limit (15MB)
const largeFile = new File([new ArrayBuffer(20 * 1024 * 1024)], 'large.pdf', { type: 'application/pdf' })
const event = {
target: { files: [largeFile] },
} as unknown as React.ChangeEvent<HTMLInputElement>
act(() => {
result.current.fileChangeHandle(event)
})
expect(mockNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
)
})
it('should handle null files', () => {
const prepareFileList = vi.fn()
const { result } = renderHook(
() => useFileUpload({ ...defaultOptions, prepareFileList }),
{ wrapper: createWrapper() },
)
const event = {
target: { files: null },
} as unknown as React.ChangeEvent<HTMLInputElement>
act(() => {
result.current.fileChangeHandle(event)
})
expect(prepareFileList).not.toHaveBeenCalled()
})
})
describe('drag and drop handlers', () => {
const TestDropzone = ({ options }: { options: typeof defaultOptions }) => {
const {
dropRef,
dragRef,
dragging,
} = useFileUpload(options)
return (
<div>
<div ref={dropRef} data-testid="dropzone">
{dragging && <div ref={dragRef} data-testid="drag-overlay" />}
</div>
<span data-testid="dragging">{String(dragging)}</span>
</div>
)
}
it('should set dragging true on dragenter', async () => {
const { getByTestId } = await act(async () =>
render(
<ToastContext.Provider value={{ notify: mockNotify, close: mockClose }}>
<TestDropzone options={defaultOptions} />
</ToastContext.Provider>,
),
)
const dropzone = getByTestId('dropzone')
await act(async () => {
const dragEnterEvent = new Event('dragenter', { bubbles: true, cancelable: true })
dropzone.dispatchEvent(dragEnterEvent)
})
expect(getByTestId('dragging').textContent).toBe('true')
})
it('should handle dragover event', async () => {
const { getByTestId } = await act(async () =>
render(
<ToastContext.Provider value={{ notify: mockNotify, close: mockClose }}>
<TestDropzone options={defaultOptions} />
</ToastContext.Provider>,
),
)
const dropzone = getByTestId('dropzone')
await act(async () => {
const dragOverEvent = new Event('dragover', { bubbles: true, cancelable: true })
dropzone.dispatchEvent(dragOverEvent)
})
expect(dropzone).toBeInTheDocument()
})
it('should set dragging false on dragleave from drag overlay', async () => {
const { getByTestId, queryByTestId } = await act(async () =>
render(
<ToastContext.Provider value={{ notify: mockNotify, close: mockClose }}>
<TestDropzone options={defaultOptions} />
</ToastContext.Provider>,
),
)
const dropzone = getByTestId('dropzone')
await act(async () => {
const dragEnterEvent = new Event('dragenter', { bubbles: true, cancelable: true })
dropzone.dispatchEvent(dragEnterEvent)
})
expect(getByTestId('dragging').textContent).toBe('true')
const dragOverlay = queryByTestId('drag-overlay')
if (dragOverlay) {
await act(async () => {
const dragLeaveEvent = new Event('dragleave', { bubbles: true, cancelable: true })
Object.defineProperty(dragLeaveEvent, 'target', { value: dragOverlay })
dropzone.dispatchEvent(dragLeaveEvent)
})
}
})
it('should handle drop with files', async () => {
mockUpload.mockResolvedValue({ id: 'uploaded-id' })
const prepareFileList = vi.fn()
const { getByTestId } = await act(async () =>
render(
<ToastContext.Provider value={{ notify: mockNotify, close: mockClose }}>
<TestDropzone options={{ ...defaultOptions, prepareFileList }} />
</ToastContext.Provider>,
),
)
const dropzone = getByTestId('dropzone')
const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' })
await act(async () => {
const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) as Event & { dataTransfer: DataTransfer | null }
Object.defineProperty(dropEvent, 'dataTransfer', {
value: {
items: [{
getAsFile: () => mockFile,
webkitGetAsEntry: () => null,
}],
},
})
dropzone.dispatchEvent(dropEvent)
})
await waitFor(() => {
expect(prepareFileList).toHaveBeenCalled()
})
})
it('should handle drop without dataTransfer', async () => {
const prepareFileList = vi.fn()
const { getByTestId } = await act(async () =>
render(
<ToastContext.Provider value={{ notify: mockNotify, close: mockClose }}>
<TestDropzone options={{ ...defaultOptions, prepareFileList }} />
</ToastContext.Provider>,
),
)
const dropzone = getByTestId('dropzone')
await act(async () => {
const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) as Event & { dataTransfer: DataTransfer | null }
Object.defineProperty(dropEvent, 'dataTransfer', { value: null })
dropzone.dispatchEvent(dropEvent)
})
expect(prepareFileList).not.toHaveBeenCalled()
})
it('should limit to single file on drop when supportBatchUpload is false', async () => {
mockUpload.mockResolvedValue({ id: 'uploaded-id' })
const prepareFileList = vi.fn()
const { getByTestId } = await act(async () =>
render(
<ToastContext.Provider value={{ notify: mockNotify, close: mockClose }}>
<TestDropzone options={{ ...defaultOptions, supportBatchUpload: false, prepareFileList }} />
</ToastContext.Provider>,
),
)
const dropzone = getByTestId('dropzone')
const files = [
new File(['content1'], 'test1.pdf', { type: 'application/pdf' }),
new File(['content2'], 'test2.pdf', { type: 'application/pdf' }),
]
await act(async () => {
const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) as Event & { dataTransfer: DataTransfer | null }
Object.defineProperty(dropEvent, 'dataTransfer', {
value: {
items: files.map(f => ({
getAsFile: () => f,
webkitGetAsEntry: () => null,
})),
},
})
dropzone.dispatchEvent(dropEvent)
})
await waitFor(() => {
if (prepareFileList.mock.calls.length > 0) {
const calledFiles = prepareFileList.mock.calls[0][0]
expect(calledFiles.length).toBe(1)
}
})
})
it('should handle drop with FileSystemFileEntry', async () => {
mockUpload.mockResolvedValue({ id: 'uploaded-id' })
const prepareFileList = vi.fn()
const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' })
const { getByTestId } = await act(async () =>
render(
<ToastContext.Provider value={{ notify: mockNotify, close: mockClose }}>
<TestDropzone options={{ ...defaultOptions, prepareFileList }} />
</ToastContext.Provider>,
),
)
const dropzone = getByTestId('dropzone')
await act(async () => {
const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) as Event & { dataTransfer: DataTransfer | null }
Object.defineProperty(dropEvent, 'dataTransfer', {
value: {
items: [{
getAsFile: () => mockFile,
webkitGetAsEntry: () => ({
isFile: true,
isDirectory: false,
file: (callback: (file: File) => void) => callback(mockFile),
}),
}],
},
})
dropzone.dispatchEvent(dropEvent)
})
await waitFor(() => {
expect(prepareFileList).toHaveBeenCalled()
})
})
it('should handle drop with FileSystemDirectoryEntry', async () => {
mockUpload.mockResolvedValue({ id: 'uploaded-id' })
const prepareFileList = vi.fn()
const mockFile = new File(['content'], 'nested.pdf', { type: 'application/pdf' })
const { getByTestId } = await act(async () =>
render(
<ToastContext.Provider value={{ notify: mockNotify, close: mockClose }}>
<TestDropzone options={{ ...defaultOptions, prepareFileList }} />
</ToastContext.Provider>,
),
)
const dropzone = getByTestId('dropzone')
await act(async () => {
let callCount = 0
const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) as Event & { dataTransfer: DataTransfer | null }
Object.defineProperty(dropEvent, 'dataTransfer', {
value: {
items: [{
getAsFile: () => null,
webkitGetAsEntry: () => ({
isFile: false,
isDirectory: true,
name: 'folder',
createReader: () => ({
readEntries: (callback: (entries: Array<{ isFile: boolean, isDirectory: boolean, name?: string, file?: (cb: (f: File) => void) => void }>) => void) => {
// First call returns file entry, second call returns empty (signals end)
if (callCount === 0) {
callCount++
callback([{
isFile: true,
isDirectory: false,
name: 'nested.pdf',
file: (cb: (f: File) => void) => cb(mockFile),
}])
}
else {
callback([])
}
},
}),
}),
}],
},
})
dropzone.dispatchEvent(dropEvent)
})
await waitFor(() => {
expect(prepareFileList).toHaveBeenCalled()
})
})
it('should handle drop with empty directory', async () => {
const prepareFileList = vi.fn()
const { getByTestId } = await act(async () =>
render(
<ToastContext.Provider value={{ notify: mockNotify, close: mockClose }}>
<TestDropzone options={{ ...defaultOptions, prepareFileList }} />
</ToastContext.Provider>,
),
)
const dropzone = getByTestId('dropzone')
await act(async () => {
const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) as Event & { dataTransfer: DataTransfer | null }
Object.defineProperty(dropEvent, 'dataTransfer', {
value: {
items: [{
getAsFile: () => null,
webkitGetAsEntry: () => ({
isFile: false,
isDirectory: true,
name: 'empty-folder',
createReader: () => ({
readEntries: (callback: (entries: never[]) => void) => {
callback([])
},
}),
}),
}],
},
})
dropzone.dispatchEvent(dropEvent)
})
// Should not prepare file list if no valid files
await new Promise(resolve => setTimeout(resolve, 100))
})
it('should handle entry that is neither file nor directory', async () => {
const prepareFileList = vi.fn()
const { getByTestId } = await act(async () =>
render(
<ToastContext.Provider value={{ notify: mockNotify, close: mockClose }}>
<TestDropzone options={{ ...defaultOptions, prepareFileList }} />
</ToastContext.Provider>,
),
)
const dropzone = getByTestId('dropzone')
await act(async () => {
const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) as Event & { dataTransfer: DataTransfer | null }
Object.defineProperty(dropEvent, 'dataTransfer', {
value: {
items: [{
getAsFile: () => null,
webkitGetAsEntry: () => ({
isFile: false,
isDirectory: false,
}),
}],
},
})
dropzone.dispatchEvent(dropEvent)
})
// Should not throw and should handle gracefully
await new Promise(resolve => setTimeout(resolve, 100))
})
})
describe('file upload', () => {
it('should call upload with correct parameters', async () => {
mockUpload.mockResolvedValue({ id: 'uploaded-id', name: 'test.pdf' })
const onFileUpdate = vi.fn()
const { result } = renderHook(
() => useFileUpload({ ...defaultOptions, onFileUpdate }),
{ wrapper: createWrapper() },
)
const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' })
const event = {
target: { files: [mockFile] },
} as unknown as React.ChangeEvent<HTMLInputElement>
act(() => {
result.current.fileChangeHandle(event)
})
await waitFor(() => {
expect(mockUpload).toHaveBeenCalled()
})
})
it('should update progress during upload', async () => {
let progressCallback: ((e: ProgressEvent) => void) | undefined
mockUpload.mockImplementation(async (options: { onprogress: (e: ProgressEvent) => void }) => {
progressCallback = options.onprogress
return { id: 'uploaded-id' }
})
const onFileUpdate = vi.fn()
const { result } = renderHook(
() => useFileUpload({ ...defaultOptions, onFileUpdate }),
{ wrapper: createWrapper() },
)
const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' })
const event = {
target: { files: [mockFile] },
} as unknown as React.ChangeEvent<HTMLInputElement>
act(() => {
result.current.fileChangeHandle(event)
})
await waitFor(() => {
expect(mockUpload).toHaveBeenCalled()
})
if (progressCallback) {
act(() => {
progressCallback!({
lengthComputable: true,
loaded: 50,
total: 100,
} as ProgressEvent)
})
expect(onFileUpdate).toHaveBeenCalled()
}
})
it('should handle upload error', async () => {
mockUpload.mockRejectedValue(new Error('Upload failed'))
const onFileUpdate = vi.fn()
const { result } = renderHook(
() => useFileUpload({ ...defaultOptions, onFileUpdate }),
{ wrapper: createWrapper() },
)
const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' })
const event = {
target: { files: [mockFile] },
} as unknown as React.ChangeEvent<HTMLInputElement>
act(() => {
result.current.fileChangeHandle(event)
})
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
)
})
})
it('should update file with PROGRESS_COMPLETE on success', async () => {
mockUpload.mockResolvedValue({ id: 'uploaded-id', name: 'test.pdf' })
const onFileUpdate = vi.fn()
const { result } = renderHook(
() => useFileUpload({ ...defaultOptions, onFileUpdate }),
{ wrapper: createWrapper() },
)
const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' })
const event = {
target: { files: [mockFile] },
} as unknown as React.ChangeEvent<HTMLInputElement>
act(() => {
result.current.fileChangeHandle(event)
})
await waitFor(() => {
const completeCalls = onFileUpdate.mock.calls.filter(
([, progress]) => progress === PROGRESS_COMPLETE,
)
expect(completeCalls.length).toBeGreaterThan(0)
})
})
it('should update file with PROGRESS_ERROR on failure', async () => {
mockUpload.mockRejectedValue(new Error('Upload failed'))
const onFileUpdate = vi.fn()
const { result } = renderHook(
() => useFileUpload({ ...defaultOptions, onFileUpdate }),
{ wrapper: createWrapper() },
)
const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' })
const event = {
target: { files: [mockFile] },
} as unknown as React.ChangeEvent<HTMLInputElement>
act(() => {
result.current.fileChangeHandle(event)
})
await waitFor(() => {
const errorCalls = onFileUpdate.mock.calls.filter(
([, progress]) => progress === PROGRESS_ERROR,
)
expect(errorCalls.length).toBeGreaterThan(0)
})
})
})
describe('file count validation', () => {
it('should reject when total files exceed limit', () => {
const existingFiles: FileItem[] = Array.from({ length: 8 }, (_, i) => ({
fileID: `existing-${i}`,
file: { name: `existing-${i}.pdf`, size: 1024 } as CustomFile,
progress: 100,
}))
const { result } = renderHook(
() => useFileUpload({
...defaultOptions,
fileList: existingFiles,
}),
{ wrapper: createWrapper() },
)
const files = Array.from({ length: 5 }, (_, i) =>
new File(['content'], `new-${i}.pdf`, { type: 'application/pdf' }))
const event = {
target: { files },
} as unknown as React.ChangeEvent<HTMLInputElement>
act(() => {
result.current.fileChangeHandle(event)
})
expect(mockNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
)
})
})
describe('progress constants', () => {
it('should use PROGRESS_NOT_STARTED for new files', async () => {
mockUpload.mockResolvedValue({ id: 'file-id' })
const prepareFileList = vi.fn()
const { result } = renderHook(
() => useFileUpload({ ...defaultOptions, prepareFileList }),
{ wrapper: createWrapper() },
)
const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' })
const event = {
target: { files: [mockFile] },
} as unknown as React.ChangeEvent<HTMLInputElement>
act(() => {
result.current.fileChangeHandle(event)
})
await waitFor(() => {
if (prepareFileList.mock.calls.length > 0) {
const files = prepareFileList.mock.calls[0][0]
expect(files[0].progress).toBe(PROGRESS_NOT_STARTED)
}
})
})
})
})

View File

@ -1,351 +0,0 @@
'use client'
import type { RefObject } from 'react'
import type { CustomFile as File, FileItem } from '@/models/datasets'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { getFileUploadErrorMessage } from '@/app/components/base/file-uploader/utils'
import { ToastContext } from '@/app/components/base/toast'
import { IS_CE_EDITION } from '@/config'
import { useLocale } from '@/context/i18n'
import { LanguagesSupported } from '@/i18n-config/language'
import { upload } from '@/service/base'
import { useFileSupportTypes, useFileUploadConfig } from '@/service/use-common'
import { getFileExtension } from '@/utils/format'
import { PROGRESS_COMPLETE, PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../constants'
export type FileUploadConfig = {
file_size_limit: number
batch_count_limit: number
file_upload_limit: number
}
export type UseFileUploadOptions = {
fileList: FileItem[]
prepareFileList: (files: FileItem[]) => void
onFileUpdate: (fileItem: FileItem, progress: number, list: FileItem[]) => void
onFileListUpdate?: (files: FileItem[]) => void
onPreview: (file: File) => void
supportBatchUpload?: boolean
/**
* Optional list of allowed file extensions. If not provided, fetches from API.
* Pass this when you need custom extension filtering instead of using the global config.
*/
allowedExtensions?: string[]
}
export type UseFileUploadReturn = {
// Refs
dropRef: RefObject<HTMLDivElement | null>
dragRef: RefObject<HTMLDivElement | null>
fileUploaderRef: RefObject<HTMLInputElement | null>
// State
dragging: boolean
// Config
fileUploadConfig: FileUploadConfig
acceptTypes: string[]
supportTypesShowNames: string
hideUpload: boolean
// Handlers
selectHandle: () => void
fileChangeHandle: (e: React.ChangeEvent<HTMLInputElement>) => void
removeFile: (fileID: string) => void
handlePreview: (file: File) => void
}
type FileWithPath = {
relativePath?: string
} & File
export const useFileUpload = ({
fileList,
prepareFileList,
onFileUpdate,
onFileListUpdate,
onPreview,
supportBatchUpload = false,
allowedExtensions,
}: UseFileUploadOptions): UseFileUploadReturn => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const locale = useLocale()
const [dragging, setDragging] = useState(false)
const dropRef = useRef<HTMLDivElement>(null)
const dragRef = useRef<HTMLDivElement>(null)
const fileUploaderRef = useRef<HTMLInputElement>(null)
const fileListRef = useRef<FileItem[]>([])
const hideUpload = !supportBatchUpload && fileList.length > 0
const { data: fileUploadConfigResponse } = useFileUploadConfig()
const { data: supportFileTypesResponse } = useFileSupportTypes()
// Use provided allowedExtensions or fetch from API
const supportTypes = useMemo(
() => allowedExtensions ?? supportFileTypesResponse?.allowed_extensions ?? [],
[allowedExtensions, supportFileTypesResponse?.allowed_extensions],
)
const supportTypesShowNames = useMemo(() => {
const extensionMap: { [key: string]: string } = {
md: 'markdown',
pptx: 'pptx',
htm: 'html',
xlsx: 'xlsx',
docx: 'docx',
}
return [...supportTypes]
.map(item => extensionMap[item] || item)
.map(item => item.toLowerCase())
.filter((item, index, self) => self.indexOf(item) === index)
.map(item => item.toUpperCase())
.join(locale !== LanguagesSupported[1] ? ', ' : '、 ')
}, [supportTypes, locale])
const acceptTypes = useMemo(() => supportTypes.map((ext: string) => `.${ext}`), [supportTypes])
const fileUploadConfig = useMemo(() => ({
file_size_limit: fileUploadConfigResponse?.file_size_limit ?? 15,
batch_count_limit: supportBatchUpload ? (fileUploadConfigResponse?.batch_count_limit ?? 5) : 1,
file_upload_limit: supportBatchUpload ? (fileUploadConfigResponse?.file_upload_limit ?? 5) : 1,
}), [fileUploadConfigResponse, supportBatchUpload])
const isValid = useCallback((file: File) => {
const { size } = file
const ext = `.${getFileExtension(file.name)}`
const isValidType = acceptTypes.includes(ext.toLowerCase())
if (!isValidType)
notify({ type: 'error', message: t('stepOne.uploader.validation.typeError', { ns: 'datasetCreation' }) })
const isValidSize = size <= fileUploadConfig.file_size_limit * 1024 * 1024
if (!isValidSize)
notify({ type: 'error', message: t('stepOne.uploader.validation.size', { ns: 'datasetCreation', size: fileUploadConfig.file_size_limit }) })
return isValidType && isValidSize
}, [fileUploadConfig, notify, t, acceptTypes])
const fileUpload = useCallback(async (fileItem: FileItem): Promise<FileItem> => {
const formData = new FormData()
formData.append('file', fileItem.file)
const onProgress = (e: ProgressEvent) => {
if (e.lengthComputable) {
const percent = Math.floor(e.loaded / e.total * 100)
onFileUpdate(fileItem, percent, fileListRef.current)
}
}
return upload({
xhr: new XMLHttpRequest(),
data: formData,
onprogress: onProgress,
}, false, undefined, '?source=datasets')
.then((res) => {
const completeFile = {
fileID: fileItem.fileID,
file: res as unknown as File,
progress: PROGRESS_NOT_STARTED,
}
const index = fileListRef.current.findIndex(item => item.fileID === fileItem.fileID)
fileListRef.current[index] = completeFile
onFileUpdate(completeFile, PROGRESS_COMPLETE, fileListRef.current)
return Promise.resolve({ ...completeFile })
})
.catch((e) => {
const errorMessage = getFileUploadErrorMessage(e, t('stepOne.uploader.failed', { ns: 'datasetCreation' }), t)
notify({ type: 'error', message: errorMessage })
onFileUpdate(fileItem, PROGRESS_ERROR, fileListRef.current)
return Promise.resolve({ ...fileItem })
})
.finally()
}, [notify, onFileUpdate, t])
const uploadBatchFiles = useCallback((bFiles: FileItem[]) => {
bFiles.forEach(bf => (bf.progress = 0))
return Promise.all(bFiles.map(fileUpload))
}, [fileUpload])
const uploadMultipleFiles = useCallback(async (files: FileItem[]) => {
const batchCountLimit = fileUploadConfig.batch_count_limit
const length = files.length
let start = 0
let end = 0
while (start < length) {
if (start + batchCountLimit > length)
end = length
else
end = start + batchCountLimit
const bFiles = files.slice(start, end)
await uploadBatchFiles(bFiles)
start = end
}
}, [fileUploadConfig, uploadBatchFiles])
const initialUpload = useCallback((files: File[]) => {
const filesCountLimit = fileUploadConfig.file_upload_limit
if (!files.length)
return false
if (files.length + fileList.length > filesCountLimit && !IS_CE_EDITION) {
notify({ type: 'error', message: t('stepOne.uploader.validation.filesNumber', { ns: 'datasetCreation', filesNumber: filesCountLimit }) })
return false
}
const preparedFiles = files.map((file, index) => ({
fileID: `file${index}-${Date.now()}`,
file,
progress: PROGRESS_NOT_STARTED,
}))
const newFiles = [...fileListRef.current, ...preparedFiles]
prepareFileList(newFiles)
fileListRef.current = newFiles
uploadMultipleFiles(preparedFiles)
}, [prepareFileList, uploadMultipleFiles, notify, t, fileList, fileUploadConfig])
const traverseFileEntry = useCallback(
(entry: FileSystemEntry, prefix = ''): Promise<FileWithPath[]> => {
return new Promise((resolve) => {
if (entry.isFile) {
(entry as FileSystemFileEntry).file((file: FileWithPath) => {
file.relativePath = `${prefix}${file.name}`
resolve([file])
})
}
else if (entry.isDirectory) {
const reader = (entry as FileSystemDirectoryEntry).createReader()
const entries: FileSystemEntry[] = []
const read = () => {
reader.readEntries(async (results: FileSystemEntry[]) => {
if (!results.length) {
const files = await Promise.all(
entries.map(ent =>
traverseFileEntry(ent, `${prefix}${entry.name}/`),
),
)
resolve(files.flat())
}
else {
entries.push(...results)
read()
}
})
}
read()
}
else {
resolve([])
}
})
},
[],
)
const handleDragEnter = useCallback((e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (e.target !== dragRef.current)
setDragging(true)
}, [])
const handleDragOver = useCallback((e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
}, [])
const handleDragLeave = useCallback((e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (e.target === dragRef.current)
setDragging(false)
}, [])
const handleDrop = useCallback(
async (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
setDragging(false)
if (!e.dataTransfer)
return
const nested = await Promise.all(
Array.from(e.dataTransfer.items).map((it) => {
const entry = (it as DataTransferItem & { webkitGetAsEntry?: () => FileSystemEntry | null }).webkitGetAsEntry?.()
if (entry)
return traverseFileEntry(entry)
const f = it.getAsFile?.()
return f ? Promise.resolve([f as FileWithPath]) : Promise.resolve([])
}),
)
let files = nested.flat()
if (!supportBatchUpload)
files = files.slice(0, 1)
files = files.slice(0, fileUploadConfig.batch_count_limit)
const valid = files.filter(isValid)
initialUpload(valid)
},
[initialUpload, isValid, supportBatchUpload, traverseFileEntry, fileUploadConfig],
)
const selectHandle = useCallback(() => {
if (fileUploaderRef.current)
fileUploaderRef.current.click()
}, [])
const removeFile = useCallback((fileID: string) => {
if (fileUploaderRef.current)
fileUploaderRef.current.value = ''
fileListRef.current = fileListRef.current.filter(item => item.fileID !== fileID)
onFileListUpdate?.([...fileListRef.current])
}, [onFileListUpdate])
const fileChangeHandle = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
let files = Array.from(e.target.files ?? []) as File[]
files = files.slice(0, fileUploadConfig.batch_count_limit)
initialUpload(files.filter(isValid))
}, [isValid, initialUpload, fileUploadConfig])
const handlePreview = useCallback((file: File) => {
if (file?.id)
onPreview(file)
}, [onPreview])
useEffect(() => {
const dropArea = dropRef.current
dropArea?.addEventListener('dragenter', handleDragEnter)
dropArea?.addEventListener('dragover', handleDragOver)
dropArea?.addEventListener('dragleave', handleDragLeave)
dropArea?.addEventListener('drop', handleDrop)
return () => {
dropArea?.removeEventListener('dragenter', handleDragEnter)
dropArea?.removeEventListener('dragover', handleDragOver)
dropArea?.removeEventListener('dragleave', handleDragLeave)
dropArea?.removeEventListener('drop', handleDrop)
}
}, [handleDragEnter, handleDragOver, handleDragLeave, handleDrop])
return {
// Refs
dropRef,
dragRef,
fileUploaderRef,
// State
dragging,
// Config
fileUploadConfig,
acceptTypes,
supportTypesShowNames,
hideUpload,
// Handlers
selectHandle,
fileChangeHandle,
removeFile,
handlePreview,
}
}

View File

@ -1,278 +0,0 @@
import type { CustomFile as File, FileItem } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PROGRESS_NOT_STARTED } from './constants'
import FileUploader from './index'
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
'stepOne.uploader.title': 'Upload Files',
'stepOne.uploader.button': 'Drag and drop files, or',
'stepOne.uploader.buttonSingleFile': 'Drag and drop file, or',
'stepOne.uploader.browse': 'Browse',
'stepOne.uploader.tip': 'Supports various file types',
}
return translations[key] || key
},
}),
}))
// Mock ToastContext
const mockNotify = vi.fn()
vi.mock('use-context-selector', async () => {
const actual = await vi.importActual<typeof import('use-context-selector')>('use-context-selector')
return {
...actual,
useContext: vi.fn(() => ({ notify: mockNotify })),
}
})
// Mock services
vi.mock('@/service/base', () => ({
upload: vi.fn().mockResolvedValue({ id: 'uploaded-id' }),
}))
vi.mock('@/service/use-common', () => ({
useFileUploadConfig: () => ({
data: { file_size_limit: 15, batch_count_limit: 5, file_upload_limit: 10 },
}),
useFileSupportTypes: () => ({
data: { allowed_extensions: ['pdf', 'docx', 'txt'] },
}),
}))
vi.mock('@/context/i18n', () => ({
useLocale: () => 'en-US',
}))
vi.mock('@/i18n-config/language', () => ({
LanguagesSupported: ['en-US', 'zh-Hans'],
}))
vi.mock('@/config', () => ({
IS_CE_EDITION: false,
}))
vi.mock('@/app/components/base/file-uploader/utils', () => ({
getFileUploadErrorMessage: () => 'Upload error',
}))
// Mock theme
vi.mock('@/hooks/use-theme', () => ({
default: () => ({ theme: 'light' }),
}))
vi.mock('@/types/app', () => ({
Theme: { dark: 'dark', light: 'light' },
}))
// Mock DocumentFileIcon - uses relative path from file-list-item.tsx
vi.mock('@/app/components/datasets/common/document-file-icon', () => ({
default: ({ extension }: { extension: string }) => <div data-testid="document-icon">{extension}</div>,
}))
// Mock SimplePieChart
vi.mock('next/dynamic', () => ({
default: () => {
const Component = ({ percentage }: { percentage: number }) => (
<div data-testid="pie-chart">
{percentage}
%
</div>
)
return Component
},
}))
describe('FileUploader', () => {
const createMockFile = (overrides: Partial<File> = {}): File => ({
name: 'test.pdf',
size: 1024,
type: 'application/pdf',
...overrides,
} as File)
const createMockFileItem = (overrides: Partial<FileItem> = {}): FileItem => ({
fileID: `file-${Date.now()}`,
file: createMockFile(overrides.file as Partial<File>),
progress: PROGRESS_NOT_STARTED,
...overrides,
})
const defaultProps = {
fileList: [] as FileItem[],
prepareFileList: vi.fn(),
onFileUpdate: vi.fn(),
onFileListUpdate: vi.fn(),
onPreview: vi.fn(),
supportBatchUpload: true,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('rendering', () => {
it('should render the component', () => {
render(<FileUploader {...defaultProps} />)
expect(screen.getByText('Upload Files')).toBeInTheDocument()
})
it('should render dropzone when no files', () => {
render(<FileUploader {...defaultProps} />)
expect(screen.getByText(/Drag and drop files/i)).toBeInTheDocument()
})
it('should render browse button', () => {
render(<FileUploader {...defaultProps} />)
expect(screen.getByText('Browse')).toBeInTheDocument()
})
it('should apply custom title className', () => {
render(<FileUploader {...defaultProps} titleClassName="custom-class" />)
const title = screen.getByText('Upload Files')
expect(title).toHaveClass('custom-class')
})
})
describe('file list rendering', () => {
it('should render file items when fileList has items', () => {
const fileList = [
createMockFileItem({ file: createMockFile({ name: 'file1.pdf' }) }),
createMockFileItem({ file: createMockFile({ name: 'file2.pdf' }) }),
]
render(<FileUploader {...defaultProps} fileList={fileList} />)
expect(screen.getByText('file1.pdf')).toBeInTheDocument()
expect(screen.getByText('file2.pdf')).toBeInTheDocument()
})
it('should render document icons for files', () => {
const fileList = [createMockFileItem()]
render(<FileUploader {...defaultProps} fileList={fileList} />)
expect(screen.getByTestId('document-icon')).toBeInTheDocument()
})
})
describe('batch upload mode', () => {
it('should show dropzone with batch upload enabled', () => {
render(<FileUploader {...defaultProps} supportBatchUpload={true} />)
expect(screen.getByText(/Drag and drop files/i)).toBeInTheDocument()
})
it('should show single file text when batch upload disabled', () => {
render(<FileUploader {...defaultProps} supportBatchUpload={false} />)
expect(screen.getByText(/Drag and drop file/i)).toBeInTheDocument()
})
it('should hide dropzone when not batch upload and has files', () => {
const fileList = [createMockFileItem()]
render(<FileUploader {...defaultProps} supportBatchUpload={false} fileList={fileList} />)
expect(screen.queryByText(/Drag and drop/i)).not.toBeInTheDocument()
})
})
describe('event handlers', () => {
it('should handle file preview click', () => {
const onPreview = vi.fn()
const fileItem = createMockFileItem({
file: createMockFile({ id: 'file-id' } as Partial<File>),
})
const { container } = render(<FileUploader {...defaultProps} fileList={[fileItem]} onPreview={onPreview} />)
// Find the file list item container by its class pattern
const fileElement = container.querySelector('[class*="flex h-12"]')
if (fileElement)
fireEvent.click(fileElement)
expect(onPreview).toHaveBeenCalledWith(fileItem.file)
})
it('should handle file remove click', () => {
const onFileListUpdate = vi.fn()
const fileItem = createMockFileItem()
const { container } = render(
<FileUploader {...defaultProps} fileList={[fileItem]} onFileListUpdate={onFileListUpdate} />,
)
// Find the delete button (the span with cursor-pointer containing the icon)
const deleteButtons = container.querySelectorAll('[class*="cursor-pointer"]')
// Get the last one which should be the delete button (not the browse label)
const deleteButton = deleteButtons[deleteButtons.length - 1]
if (deleteButton)
fireEvent.click(deleteButton)
expect(onFileListUpdate).toHaveBeenCalled()
})
it('should handle browse button click', () => {
render(<FileUploader {...defaultProps} />)
// The browse label should trigger file input click
const browseLabel = screen.getByText('Browse')
expect(browseLabel).toHaveClass('cursor-pointer')
})
})
describe('upload progress', () => {
it('should show progress chart for uploading files', () => {
const fileItem = createMockFileItem({ progress: 50 })
render(<FileUploader {...defaultProps} fileList={[fileItem]} />)
expect(screen.getByTestId('pie-chart')).toBeInTheDocument()
expect(screen.getByText('50%')).toBeInTheDocument()
})
it('should not show progress chart for completed files', () => {
const fileItem = createMockFileItem({ progress: 100 })
render(<FileUploader {...defaultProps} fileList={[fileItem]} />)
expect(screen.queryByTestId('pie-chart')).not.toBeInTheDocument()
})
it('should not show progress chart for not started files', () => {
const fileItem = createMockFileItem({ progress: PROGRESS_NOT_STARTED })
render(<FileUploader {...defaultProps} fileList={[fileItem]} />)
expect(screen.queryByTestId('pie-chart')).not.toBeInTheDocument()
})
})
describe('multiple files', () => {
it('should render all files in the list', () => {
const fileList = [
createMockFileItem({ fileID: 'f1', file: createMockFile({ name: 'doc1.pdf' }) }),
createMockFileItem({ fileID: 'f2', file: createMockFile({ name: 'doc2.docx' }) }),
createMockFileItem({ fileID: 'f3', file: createMockFile({ name: 'doc3.txt' }) }),
]
render(<FileUploader {...defaultProps} fileList={fileList} />)
expect(screen.getByText('doc1.pdf')).toBeInTheDocument()
expect(screen.getByText('doc2.docx')).toBeInTheDocument()
expect(screen.getByText('doc3.txt')).toBeInTheDocument()
})
})
describe('styling', () => {
it('should have correct container width', () => {
const { container } = render(<FileUploader {...defaultProps} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('w-[640px]')
})
it('should have proper spacing', () => {
const { container } = render(<FileUploader {...defaultProps} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('mb-5')
})
})
})

View File

@ -1,10 +1,23 @@
'use client'
import type { CustomFile as File, FileItem } from '@/models/datasets'
import { RiDeleteBinLine, RiUploadCloud2Line } from '@remixicon/react'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { getFileUploadErrorMessage } from '@/app/components/base/file-uploader/utils'
import SimplePieChart from '@/app/components/base/simple-pie-chart'
import { ToastContext } from '@/app/components/base/toast'
import { IS_CE_EDITION } from '@/config'
import { useLocale } from '@/context/i18n'
import useTheme from '@/hooks/use-theme'
import { LanguagesSupported } from '@/i18n-config/language'
import { upload } from '@/service/base'
import { useFileSupportTypes, useFileUploadConfig } from '@/service/use-common'
import { Theme } from '@/types/app'
import { cn } from '@/utils/classnames'
import FileListItem from './components/file-list-item'
import UploadDropzone from './components/upload-dropzone'
import { useFileUpload } from './hooks/use-file-upload'
import DocumentFileIcon from '../../common/document-file-icon'
type IFileUploaderProps = {
fileList: FileItem[]
@ -26,62 +39,358 @@ const FileUploader = ({
supportBatchUpload = false,
}: IFileUploaderProps) => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const locale = useLocale()
const [dragging, setDragging] = useState(false)
const dropRef = useRef<HTMLDivElement>(null)
const dragRef = useRef<HTMLDivElement>(null)
const fileUploader = useRef<HTMLInputElement>(null)
const hideUpload = !supportBatchUpload && fileList.length > 0
const {
dropRef,
dragRef,
fileUploaderRef,
dragging,
fileUploadConfig,
acceptTypes,
supportTypesShowNames,
hideUpload,
selectHandle,
fileChangeHandle,
removeFile,
handlePreview,
} = useFileUpload({
fileList,
prepareFileList,
onFileUpdate,
onFileListUpdate,
onPreview,
supportBatchUpload,
})
const { data: fileUploadConfigResponse } = useFileUploadConfig()
const { data: supportFileTypesResponse } = useFileSupportTypes()
const supportTypes = supportFileTypesResponse?.allowed_extensions || []
const supportTypesShowNames = (() => {
const extensionMap: { [key: string]: string } = {
md: 'markdown',
pptx: 'pptx',
htm: 'html',
xlsx: 'xlsx',
docx: 'docx',
}
return [...supportTypes]
.map(item => extensionMap[item] || item) // map to standardized extension
.map(item => item.toLowerCase()) // convert to lower case
.filter((item, index, self) => self.indexOf(item) === index) // remove duplicates
.map(item => item.toUpperCase()) // convert to upper case
.join(locale !== LanguagesSupported[1] ? ', ' : '、 ')
})()
const ACCEPTS = supportTypes.map((ext: string) => `.${ext}`)
const fileUploadConfig = useMemo(() => ({
file_size_limit: fileUploadConfigResponse?.file_size_limit ?? 15,
batch_count_limit: supportBatchUpload ? (fileUploadConfigResponse?.batch_count_limit ?? 5) : 1,
file_upload_limit: supportBatchUpload ? (fileUploadConfigResponse?.file_upload_limit ?? 5) : 1,
}), [fileUploadConfigResponse, supportBatchUpload])
const fileListRef = useRef<FileItem[]>([])
// utils
const getFileType = (currentFile: File) => {
if (!currentFile)
return ''
const arr = currentFile.name.split('.')
return arr[arr.length - 1]
}
const getFileSize = (size: number) => {
if (size / 1024 < 10)
return `${(size / 1024).toFixed(2)}KB`
return `${(size / 1024 / 1024).toFixed(2)}MB`
}
const isValid = useCallback((file: File) => {
const { size } = file
const ext = `.${getFileType(file)}`
const isValidType = ACCEPTS.includes(ext.toLowerCase())
if (!isValidType)
notify({ type: 'error', message: t('stepOne.uploader.validation.typeError', { ns: 'datasetCreation' }) })
const isValidSize = size <= fileUploadConfig.file_size_limit * 1024 * 1024
if (!isValidSize)
notify({ type: 'error', message: t('stepOne.uploader.validation.size', { ns: 'datasetCreation', size: fileUploadConfig.file_size_limit }) })
return isValidType && isValidSize
}, [fileUploadConfig, notify, t, ACCEPTS])
const fileUpload = useCallback(async (fileItem: FileItem): Promise<FileItem> => {
const formData = new FormData()
formData.append('file', fileItem.file)
const onProgress = (e: ProgressEvent) => {
if (e.lengthComputable) {
const percent = Math.floor(e.loaded / e.total * 100)
onFileUpdate(fileItem, percent, fileListRef.current)
}
}
return upload({
xhr: new XMLHttpRequest(),
data: formData,
onprogress: onProgress,
}, false, undefined, '?source=datasets')
.then((res) => {
const completeFile = {
fileID: fileItem.fileID,
file: res as unknown as File,
progress: -1,
}
const index = fileListRef.current.findIndex(item => item.fileID === fileItem.fileID)
fileListRef.current[index] = completeFile
onFileUpdate(completeFile, 100, fileListRef.current)
return Promise.resolve({ ...completeFile })
})
.catch((e) => {
const errorMessage = getFileUploadErrorMessage(e, t('stepOne.uploader.failed', { ns: 'datasetCreation' }), t)
notify({ type: 'error', message: errorMessage })
onFileUpdate(fileItem, -2, fileListRef.current)
return Promise.resolve({ ...fileItem })
})
.finally()
}, [fileListRef, notify, onFileUpdate, t])
const uploadBatchFiles = useCallback((bFiles: FileItem[]) => {
bFiles.forEach(bf => (bf.progress = 0))
return Promise.all(bFiles.map(fileUpload))
}, [fileUpload])
const uploadMultipleFiles = useCallback(async (files: FileItem[]) => {
const batchCountLimit = fileUploadConfig.batch_count_limit
const length = files.length
let start = 0
let end = 0
while (start < length) {
if (start + batchCountLimit > length)
end = length
else
end = start + batchCountLimit
const bFiles = files.slice(start, end)
await uploadBatchFiles(bFiles)
start = end
}
}, [fileUploadConfig, uploadBatchFiles])
const initialUpload = useCallback((files: File[]) => {
const filesCountLimit = fileUploadConfig.file_upload_limit
if (!files.length)
return false
if (files.length + fileList.length > filesCountLimit && !IS_CE_EDITION) {
notify({ type: 'error', message: t('stepOne.uploader.validation.filesNumber', { ns: 'datasetCreation', filesNumber: filesCountLimit }) })
return false
}
const preparedFiles = files.map((file, index) => ({
fileID: `file${index}-${Date.now()}`,
file,
progress: -1,
}))
const newFiles = [...fileListRef.current, ...preparedFiles]
prepareFileList(newFiles)
fileListRef.current = newFiles
uploadMultipleFiles(preparedFiles)
}, [prepareFileList, uploadMultipleFiles, notify, t, fileList, fileUploadConfig])
const handleDragEnter = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (e.target !== dragRef.current)
setDragging(true)
}
const handleDragOver = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
}
const handleDragLeave = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (e.target === dragRef.current)
setDragging(false)
}
type FileWithPath = {
relativePath?: string
} & File
const traverseFileEntry = useCallback(
(entry: any, prefix = ''): Promise<FileWithPath[]> => {
return new Promise((resolve) => {
if (entry.isFile) {
entry.file((file: FileWithPath) => {
file.relativePath = `${prefix}${file.name}`
resolve([file])
})
}
else if (entry.isDirectory) {
const reader = entry.createReader()
const entries: any[] = []
const read = () => {
reader.readEntries(async (results: FileSystemEntry[]) => {
if (!results.length) {
const files = await Promise.all(
entries.map(ent =>
traverseFileEntry(ent, `${prefix}${entry.name}/`),
),
)
resolve(files.flat())
}
else {
entries.push(...results)
read()
}
})
}
read()
}
else {
resolve([])
}
})
},
[],
)
const handleDrop = useCallback(
async (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
setDragging(false)
if (!e.dataTransfer)
return
const nested = await Promise.all(
Array.from(e.dataTransfer.items).map((it) => {
const entry = (it as any).webkitGetAsEntry?.()
if (entry)
return traverseFileEntry(entry)
const f = it.getAsFile?.()
return f ? Promise.resolve([f]) : Promise.resolve([])
}),
)
let files = nested.flat()
if (!supportBatchUpload)
files = files.slice(0, 1)
files = files.slice(0, fileUploadConfig.batch_count_limit)
const valid = files.filter(isValid)
initialUpload(valid)
},
[initialUpload, isValid, supportBatchUpload, traverseFileEntry, fileUploadConfig],
)
const selectHandle = () => {
if (fileUploader.current)
fileUploader.current.click()
}
const removeFile = (fileID: string) => {
if (fileUploader.current)
fileUploader.current.value = ''
fileListRef.current = fileListRef.current.filter(item => item.fileID !== fileID)
onFileListUpdate?.([...fileListRef.current])
}
const fileChangeHandle = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
let files = Array.from(e.target.files ?? []) as File[]
files = files.slice(0, fileUploadConfig.batch_count_limit)
initialUpload(files.filter(isValid))
}, [isValid, initialUpload, fileUploadConfig])
const { theme } = useTheme()
const chartColor = useMemo(() => theme === Theme.dark ? '#5289ff' : '#296dff', [theme])
useEffect(() => {
dropRef.current?.addEventListener('dragenter', handleDragEnter)
dropRef.current?.addEventListener('dragover', handleDragOver)
dropRef.current?.addEventListener('dragleave', handleDragLeave)
dropRef.current?.addEventListener('drop', handleDrop)
return () => {
dropRef.current?.removeEventListener('dragenter', handleDragEnter)
dropRef.current?.removeEventListener('dragover', handleDragOver)
dropRef.current?.removeEventListener('dragleave', handleDragLeave)
dropRef.current?.removeEventListener('drop', handleDrop)
}
}, [handleDrop])
return (
<div className="mb-5 w-[640px]">
<div className={cn('mb-1 text-sm font-semibold leading-6 text-text-secondary', titleClassName)}>
{t('stepOne.uploader.title', { ns: 'datasetCreation' })}
</div>
{!hideUpload && (
<UploadDropzone
dropRef={dropRef}
dragRef={dragRef}
fileUploaderRef={fileUploaderRef}
dragging={dragging}
supportBatchUpload={supportBatchUpload}
supportTypesShowNames={supportTypesShowNames}
fileUploadConfig={fileUploadConfig}
acceptTypes={acceptTypes}
onSelectFile={selectHandle}
onFileChange={fileChangeHandle}
<input
ref={fileUploader}
id="fileUploader"
className="hidden"
type="file"
multiple={supportBatchUpload}
accept={ACCEPTS.join(',')}
onChange={fileChangeHandle}
/>
)}
{fileList.length > 0 && (
<div className="max-w-[640px] cursor-default space-y-1">
{fileList.map(fileItem => (
<FileListItem
key={fileItem.fileID}
fileItem={fileItem}
onPreview={handlePreview}
onRemove={removeFile}
/>
))}
<div className={cn('mb-1 text-sm font-semibold leading-6 text-text-secondary', titleClassName)}>{t('stepOne.uploader.title', { ns: 'datasetCreation' })}</div>
{!hideUpload && (
<div ref={dropRef} className={cn('relative mb-2 box-border flex min-h-20 max-w-[640px] flex-col items-center justify-center gap-1 rounded-xl border border-dashed border-components-dropzone-border bg-components-dropzone-bg px-4 py-3 text-xs leading-4 text-text-tertiary', dragging && 'border-components-dropzone-border-accent bg-components-dropzone-bg-accent')}>
<div className="flex min-h-5 items-center justify-center text-sm leading-4 text-text-secondary">
<RiUploadCloud2Line className="mr-2 size-5" />
<span>
{supportBatchUpload ? t('stepOne.uploader.button', { ns: 'datasetCreation' }) : t('stepOne.uploader.buttonSingleFile', { ns: 'datasetCreation' })}
{supportTypes.length > 0 && (
<label className="ml-1 cursor-pointer text-text-accent" onClick={selectHandle}>{t('stepOne.uploader.browse', { ns: 'datasetCreation' })}</label>
)}
</span>
</div>
<div>
{t('stepOne.uploader.tip', {
ns: 'datasetCreation',
size: fileUploadConfig.file_size_limit,
supportTypes: supportTypesShowNames,
batchCount: fileUploadConfig.batch_count_limit,
totalCount: fileUploadConfig.file_upload_limit,
})}
</div>
{dragging && <div ref={dragRef} className="absolute left-0 top-0 h-full w-full" />}
</div>
)}
<div className="max-w-[640px] cursor-default space-y-1">
{fileList.map((fileItem, index) => (
<div
key={`${fileItem.fileID}-${index}`}
onClick={() => fileItem.file?.id && onPreview(fileItem.file)}
className={cn(
'flex h-12 max-w-[640px] items-center rounded-lg border border-components-panel-border bg-components-panel-on-panel-item-bg text-xs leading-3 text-text-tertiary shadow-xs',
// 'border-state-destructive-border bg-state-destructive-hover',
)}
>
<div className="flex w-12 shrink-0 items-center justify-center">
<DocumentFileIcon
size="xl"
className="shrink-0"
name={fileItem.file.name}
extension={getFileType(fileItem.file)}
/>
</div>
<div className="flex shrink grow flex-col gap-0.5">
<div className="flex w-full">
<div className="w-0 grow truncate text-sm leading-4 text-text-secondary">{fileItem.file.name}</div>
</div>
<div className="w-full truncate leading-3 text-text-tertiary">
<span className="uppercase">{getFileType(fileItem.file)}</span>
<span className="px-1 text-text-quaternary">·</span>
<span>{getFileSize(fileItem.file.size)}</span>
{/* <span className='px-1 text-text-quaternary'>·</span>
<span>10k characters</span> */}
</div>
</div>
<div className="flex w-16 shrink-0 items-center justify-end gap-1 pr-3">
{/* <span className="flex justify-center items-center w-6 h-6 cursor-pointer">
<RiErrorWarningFill className='size-4 text-text-warning' />
</span> */}
{(fileItem.progress < 100 && fileItem.progress >= 0) && (
// <div className={s.percent}>{`${fileItem.progress}%`}</div>
<SimplePieChart percentage={fileItem.progress} stroke={chartColor} fill={chartColor} animationDuration={0} />
)}
<span
className="flex h-6 w-6 cursor-pointer items-center justify-center"
onClick={(e) => {
e.stopPropagation()
removeFile(fileItem.fileID)
}}
>
<RiDeleteBinLine className="size-4 text-text-tertiary" />
</span>
</div>
</div>
))}
</div>
</div>
)
}

View File

@ -0,0 +1,262 @@
import type { SimpleDocumentDetail } from '@/models/datasets'
import { render } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { DataSourceType } from '@/models/datasets'
import { DatasourceType } from '@/models/pipeline'
import DocumentSourceIcon from './document-source-icon'
const createMockDoc = (overrides: Record<string, unknown> = {}): SimpleDocumentDetail => ({
id: 'doc-1',
position: 1,
data_source_type: DataSourceType.FILE,
data_source_info: {},
data_source_detail_dict: {},
dataset_process_rule_id: 'rule-1',
dataset_id: 'dataset-1',
batch: 'batch-1',
name: 'test-document.txt',
created_from: 'web',
created_by: 'user-1',
created_at: Date.now(),
tokens: 100,
indexing_status: 'completed',
error: null,
enabled: true,
disabled_at: null,
disabled_by: null,
archived: false,
archived_reason: null,
archived_by: null,
archived_at: null,
updated_at: Date.now(),
doc_type: null,
doc_metadata: undefined,
doc_language: 'en',
display_status: 'available',
word_count: 100,
hit_count: 10,
doc_form: 'text_model',
...overrides,
}) as unknown as SimpleDocumentDetail
describe('DocumentSourceIcon', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
const doc = createMockDoc()
const { container } = render(<DocumentSourceIcon doc={doc} />)
expect(container.firstChild).toBeInTheDocument()
})
})
describe('Local File Icon', () => {
it('should render FileTypeIcon for FILE data source type', () => {
const doc = createMockDoc({
data_source_type: DataSourceType.FILE,
data_source_info: {
upload_file: { extension: 'pdf' },
},
})
const { container } = render(<DocumentSourceIcon doc={doc} fileType="pdf" />)
const icon = container.querySelector('svg, img')
expect(icon).toBeInTheDocument()
})
it('should render FileTypeIcon for localFile data source type', () => {
const doc = createMockDoc({
data_source_type: DatasourceType.localFile,
created_from: 'rag-pipeline',
data_source_info: {
extension: 'docx',
},
})
const { container } = render(<DocumentSourceIcon doc={doc} />)
const icon = container.querySelector('svg, img')
expect(icon).toBeInTheDocument()
})
it('should use extension from upload_file for legacy data source', () => {
const doc = createMockDoc({
data_source_type: DataSourceType.FILE,
created_from: 'web',
data_source_info: {
upload_file: { extension: 'txt' },
},
})
const { container } = render(<DocumentSourceIcon doc={doc} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should use fileType prop as fallback for extension', () => {
const doc = createMockDoc({
data_source_type: DataSourceType.FILE,
created_from: 'web',
data_source_info: {},
})
const { container } = render(<DocumentSourceIcon doc={doc} fileType="csv" />)
expect(container.firstChild).toBeInTheDocument()
})
})
describe('Notion Icon', () => {
it('should render NotionIcon for NOTION data source type', () => {
const doc = createMockDoc({
data_source_type: DataSourceType.NOTION,
created_from: 'web',
data_source_info: {
notion_page_icon: 'https://notion.so/icon.png',
},
})
const { container } = render(<DocumentSourceIcon doc={doc} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render NotionIcon for onlineDocument data source type', () => {
const doc = createMockDoc({
data_source_type: DatasourceType.onlineDocument,
created_from: 'rag-pipeline',
data_source_info: {
page: { page_icon: 'https://notion.so/icon.png' },
},
})
const { container } = render(<DocumentSourceIcon doc={doc} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should use page_icon for rag-pipeline created documents', () => {
const doc = createMockDoc({
data_source_type: DataSourceType.NOTION,
created_from: 'rag-pipeline',
data_source_info: {
page: { page_icon: 'https://notion.so/custom-icon.png' },
},
})
const { container } = render(<DocumentSourceIcon doc={doc} />)
expect(container.firstChild).toBeInTheDocument()
})
})
describe('Web Crawl Icon', () => {
it('should render globe icon for WEB data source type', () => {
const doc = createMockDoc({
data_source_type: DataSourceType.WEB,
})
const { container } = render(<DocumentSourceIcon doc={doc} />)
const icon = container.querySelector('svg')
expect(icon).toBeInTheDocument()
expect(icon).toHaveClass('mr-1.5')
expect(icon).toHaveClass('size-4')
})
it('should render globe icon for websiteCrawl data source type', () => {
const doc = createMockDoc({
data_source_type: DatasourceType.websiteCrawl,
})
const { container } = render(<DocumentSourceIcon doc={doc} />)
const icon = container.querySelector('svg')
expect(icon).toBeInTheDocument()
})
})
describe('Online Drive Icon', () => {
it('should render FileTypeIcon for onlineDrive data source type', () => {
const doc = createMockDoc({
data_source_type: DatasourceType.onlineDrive,
data_source_info: {
name: 'document.xlsx',
},
})
const { container } = render(<DocumentSourceIcon doc={doc} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should extract extension from file name', () => {
const doc = createMockDoc({
data_source_type: DatasourceType.onlineDrive,
data_source_info: {
name: 'spreadsheet.xlsx',
},
})
const { container } = render(<DocumentSourceIcon doc={doc} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should handle file name without extension', () => {
const doc = createMockDoc({
data_source_type: DatasourceType.onlineDrive,
data_source_info: {
name: 'noextension',
},
})
const { container } = render(<DocumentSourceIcon doc={doc} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should handle empty file name', () => {
const doc = createMockDoc({
data_source_type: DatasourceType.onlineDrive,
data_source_info: {
name: '',
},
})
const { container } = render(<DocumentSourceIcon doc={doc} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should handle hidden files (starting with dot)', () => {
const doc = createMockDoc({
data_source_type: DatasourceType.onlineDrive,
data_source_info: {
name: '.gitignore',
},
})
const { container } = render(<DocumentSourceIcon doc={doc} />)
expect(container.firstChild).toBeInTheDocument()
})
})
describe('Unknown Data Source Type', () => {
it('should return null for unknown data source type', () => {
const doc = createMockDoc({
data_source_type: 'unknown',
})
const { container } = render(<DocumentSourceIcon doc={doc} />)
expect(container.firstChild).toBeNull()
})
})
describe('Edge Cases', () => {
it('should handle undefined data_source_info', () => {
const doc = createMockDoc({
data_source_type: DataSourceType.FILE,
data_source_info: undefined,
})
const { container } = render(<DocumentSourceIcon doc={doc} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should memoize the component', () => {
const doc = createMockDoc()
const { rerender, container } = render(<DocumentSourceIcon doc={doc} />)
const firstRender = container.innerHTML
rerender(<DocumentSourceIcon doc={doc} />)
expect(container.innerHTML).toBe(firstRender)
})
})
})

View File

@ -0,0 +1,100 @@
import type { FC } from 'react'
import type { LegacyDataSourceInfo, LocalFileInfo, OnlineDocumentInfo, OnlineDriveInfo, SimpleDocumentDetail } from '@/models/datasets'
import { RiGlobalLine } from '@remixicon/react'
import * as React from 'react'
import FileTypeIcon from '@/app/components/base/file-uploader/file-type-icon'
import NotionIcon from '@/app/components/base/notion-icon'
import { extensionToFileType } from '@/app/components/datasets/hit-testing/utils/extension-to-file-type'
import { DataSourceType } from '@/models/datasets'
import { DatasourceType } from '@/models/pipeline'
type DocumentSourceIconProps = {
doc: SimpleDocumentDetail
fileType?: string
}
const isLocalFile = (dataSourceType: DataSourceType | DatasourceType) => {
return dataSourceType === DatasourceType.localFile || dataSourceType === DataSourceType.FILE
}
const isOnlineDocument = (dataSourceType: DataSourceType | DatasourceType) => {
return dataSourceType === DatasourceType.onlineDocument || dataSourceType === DataSourceType.NOTION
}
const isWebsiteCrawl = (dataSourceType: DataSourceType | DatasourceType) => {
return dataSourceType === DatasourceType.websiteCrawl || dataSourceType === DataSourceType.WEB
}
const isOnlineDrive = (dataSourceType: DataSourceType | DatasourceType) => {
return dataSourceType === DatasourceType.onlineDrive
}
const isCreateFromRAGPipeline = (createdFrom: string) => {
return createdFrom === 'rag-pipeline'
}
const getFileExtension = (fileName: string): string => {
if (!fileName)
return ''
const parts = fileName.split('.')
if (parts.length <= 1 || (parts[0] === '' && parts.length === 2))
return ''
return parts[parts.length - 1].toLowerCase()
}
const DocumentSourceIcon: FC<DocumentSourceIconProps> = React.memo(({
doc,
fileType,
}) => {
if (isOnlineDocument(doc.data_source_type)) {
return (
<NotionIcon
className="mr-1.5"
type="page"
src={
isCreateFromRAGPipeline(doc.created_from)
? (doc.data_source_info as OnlineDocumentInfo).page.page_icon
: (doc.data_source_info as LegacyDataSourceInfo).notion_page_icon
}
/>
)
}
if (isLocalFile(doc.data_source_type)) {
return (
<FileTypeIcon
type={
extensionToFileType(
isCreateFromRAGPipeline(doc.created_from)
? (doc?.data_source_info as LocalFileInfo)?.extension
: ((doc?.data_source_info as LegacyDataSourceInfo)?.upload_file?.extension ?? fileType),
)
}
className="mr-1.5"
/>
)
}
if (isOnlineDrive(doc.data_source_type)) {
return (
<FileTypeIcon
type={
extensionToFileType(
getFileExtension((doc?.data_source_info as unknown as OnlineDriveInfo)?.name),
)
}
className="mr-1.5"
/>
)
}
if (isWebsiteCrawl(doc.data_source_type)) {
return <RiGlobalLine className="mr-1.5 size-4" />
}
return null
})
DocumentSourceIcon.displayName = 'DocumentSourceIcon'
export default DocumentSourceIcon

View File

@ -0,0 +1,342 @@
import type { ReactNode } from 'react'
import type { SimpleDocumentDetail } from '@/models/datasets'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DataSourceType } from '@/models/datasets'
import DocumentTableRow from './document-table-row'
const mockPush = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
}))
const createTestQueryClient = () => new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
mutations: { retry: false },
},
})
const createWrapper = () => {
const queryClient = createTestQueryClient()
return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>
<table>
<tbody>
{children}
</tbody>
</table>
</QueryClientProvider>
)
}
type LocalDoc = SimpleDocumentDetail & { percent?: number }
const createMockDoc = (overrides: Record<string, unknown> = {}): LocalDoc => ({
id: 'doc-1',
position: 1,
data_source_type: DataSourceType.FILE,
data_source_info: {},
data_source_detail_dict: {
upload_file: { name: 'test.txt', extension: 'txt' },
},
dataset_process_rule_id: 'rule-1',
dataset_id: 'dataset-1',
batch: 'batch-1',
name: 'test-document.txt',
created_from: 'web',
created_by: 'user-1',
created_at: Date.now(),
tokens: 100,
indexing_status: 'completed',
error: null,
enabled: true,
disabled_at: null,
disabled_by: null,
archived: false,
archived_reason: null,
archived_by: null,
archived_at: null,
updated_at: Date.now(),
doc_type: null,
doc_metadata: undefined,
doc_language: 'en',
display_status: 'available',
word_count: 500,
hit_count: 10,
doc_form: 'text_model',
...overrides,
}) as unknown as LocalDoc
// Helper to find the custom checkbox div (Checkbox component renders as a div, not a native checkbox)
const findCheckbox = (container: HTMLElement): HTMLElement | null => {
return container.querySelector('[class*="shadow-xs"]')
}
describe('DocumentTableRow', () => {
const defaultProps = {
doc: createMockDoc(),
index: 0,
datasetId: 'dataset-1',
isSelected: false,
isGeneralMode: true,
isQAMode: false,
embeddingAvailable: true,
selectedIds: [],
onSelectOne: vi.fn(),
onSelectedIdChange: vi.fn(),
onShowRenameModal: vi.fn(),
onUpdate: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<DocumentTableRow {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('test-document.txt')).toBeInTheDocument()
})
it('should render index number correctly', () => {
render(<DocumentTableRow {...defaultProps} index={5} />, { wrapper: createWrapper() })
expect(screen.getByText('6')).toBeInTheDocument()
})
it('should render document name with tooltip', () => {
render(<DocumentTableRow {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('test-document.txt')).toBeInTheDocument()
})
it('should render checkbox element', () => {
const { container } = render(<DocumentTableRow {...defaultProps} />, { wrapper: createWrapper() })
const checkbox = findCheckbox(container)
expect(checkbox).toBeInTheDocument()
})
})
describe('Selection', () => {
it('should show check icon when isSelected is true', () => {
const { container } = render(<DocumentTableRow {...defaultProps} isSelected />, { wrapper: createWrapper() })
// When selected, the checkbox should have a check icon (RiCheckLine svg)
const checkbox = findCheckbox(container)
expect(checkbox).toBeInTheDocument()
const checkIcon = checkbox?.querySelector('svg')
expect(checkIcon).toBeInTheDocument()
})
it('should not show check icon when isSelected is false', () => {
const { container } = render(<DocumentTableRow {...defaultProps} isSelected={false} />, { wrapper: createWrapper() })
const checkbox = findCheckbox(container)
expect(checkbox).toBeInTheDocument()
// When not selected, there should be no check icon inside the checkbox
const checkIcon = checkbox?.querySelector('svg')
expect(checkIcon).not.toBeInTheDocument()
})
it('should call onSelectOne when checkbox is clicked', () => {
const onSelectOne = vi.fn()
const { container } = render(<DocumentTableRow {...defaultProps} onSelectOne={onSelectOne} />, { wrapper: createWrapper() })
const checkbox = findCheckbox(container)
if (checkbox) {
fireEvent.click(checkbox)
expect(onSelectOne).toHaveBeenCalledWith('doc-1')
}
})
it('should stop propagation when checkbox container is clicked', () => {
const { container } = render(<DocumentTableRow {...defaultProps} />, { wrapper: createWrapper() })
// Click the div containing the checkbox (which has stopPropagation)
const checkboxContainer = container.querySelector('td')?.querySelector('div')
if (checkboxContainer) {
fireEvent.click(checkboxContainer)
expect(mockPush).not.toHaveBeenCalled()
}
})
})
describe('Row Navigation', () => {
it('should navigate to document detail on row click', () => {
render(<DocumentTableRow {...defaultProps} />, { wrapper: createWrapper() })
const row = screen.getByRole('row')
fireEvent.click(row)
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/documents/doc-1')
})
it('should navigate with correct datasetId and documentId', () => {
render(
<DocumentTableRow
{...defaultProps}
datasetId="custom-dataset"
doc={createMockDoc({ id: 'custom-doc' })}
/>,
{ wrapper: createWrapper() },
)
const row = screen.getByRole('row')
fireEvent.click(row)
expect(mockPush).toHaveBeenCalledWith('/datasets/custom-dataset/documents/custom-doc')
})
})
describe('Word Count Display', () => {
it('should display word count less than 1000 as is', () => {
const doc = createMockDoc({ word_count: 500 })
render(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() })
expect(screen.getByText('500')).toBeInTheDocument()
})
it('should display word count 1000 or more in k format', () => {
const doc = createMockDoc({ word_count: 1500 })
render(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() })
expect(screen.getByText('1.5k')).toBeInTheDocument()
})
it('should display 0 with empty style when word_count is 0', () => {
const doc = createMockDoc({ word_count: 0 })
const { container } = render(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() })
const zeroCells = container.querySelectorAll('.text-text-tertiary')
expect(zeroCells.length).toBeGreaterThan(0)
})
it('should handle undefined word_count', () => {
const doc = createMockDoc({ word_count: undefined as unknown as number })
const { container } = render(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() })
expect(container).toBeInTheDocument()
})
})
describe('Hit Count Display', () => {
it('should display hit count less than 1000 as is', () => {
const doc = createMockDoc({ hit_count: 100 })
render(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() })
expect(screen.getByText('100')).toBeInTheDocument()
})
it('should display hit count 1000 or more in k format', () => {
const doc = createMockDoc({ hit_count: 2500 })
render(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() })
expect(screen.getByText('2.5k')).toBeInTheDocument()
})
it('should display 0 with empty style when hit_count is 0', () => {
const doc = createMockDoc({ hit_count: 0 })
const { container } = render(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() })
const zeroCells = container.querySelectorAll('.text-text-tertiary')
expect(zeroCells.length).toBeGreaterThan(0)
})
})
describe('Chunking Mode', () => {
it('should render ChunkingModeLabel with general mode', () => {
render(<DocumentTableRow {...defaultProps} isGeneralMode isQAMode={false} />, { wrapper: createWrapper() })
// ChunkingModeLabel should be rendered
expect(screen.getByRole('row')).toBeInTheDocument()
})
it('should render ChunkingModeLabel with QA mode', () => {
render(<DocumentTableRow {...defaultProps} isGeneralMode={false} isQAMode />, { wrapper: createWrapper() })
expect(screen.getByRole('row')).toBeInTheDocument()
})
})
describe('Summary Status', () => {
it('should render SummaryStatus when summary_index_status is present', () => {
const doc = createMockDoc({ summary_index_status: 'completed' })
render(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() })
expect(screen.getByRole('row')).toBeInTheDocument()
})
it('should not render SummaryStatus when summary_index_status is absent', () => {
const doc = createMockDoc({ summary_index_status: undefined })
render(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() })
expect(screen.getByRole('row')).toBeInTheDocument()
})
})
describe('Rename Action', () => {
it('should call onShowRenameModal when rename button is clicked', () => {
const onShowRenameModal = vi.fn()
const { container } = render(
<DocumentTableRow {...defaultProps} onShowRenameModal={onShowRenameModal} />,
{ wrapper: createWrapper() },
)
// Find the rename button by finding the RiEditLine icon's parent
const renameButtons = container.querySelectorAll('.cursor-pointer.rounded-md')
if (renameButtons.length > 0) {
fireEvent.click(renameButtons[0])
expect(onShowRenameModal).toHaveBeenCalledWith(defaultProps.doc)
expect(mockPush).not.toHaveBeenCalled()
}
})
})
describe('Operations', () => {
it('should pass selectedIds to Operations component', () => {
render(<DocumentTableRow {...defaultProps} selectedIds={['doc-1', 'doc-2']} />, { wrapper: createWrapper() })
expect(screen.getByRole('row')).toBeInTheDocument()
})
it('should pass onSelectedIdChange to Operations component', () => {
const onSelectedIdChange = vi.fn()
render(<DocumentTableRow {...defaultProps} onSelectedIdChange={onSelectedIdChange} />, { wrapper: createWrapper() })
expect(screen.getByRole('row')).toBeInTheDocument()
})
})
describe('Document Source Icon', () => {
it('should render with FILE data source type', () => {
const doc = createMockDoc({ data_source_type: DataSourceType.FILE })
render(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() })
expect(screen.getByRole('row')).toBeInTheDocument()
})
it('should render with NOTION data source type', () => {
const doc = createMockDoc({
data_source_type: DataSourceType.NOTION,
data_source_info: { notion_page_icon: 'icon.png' },
})
render(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() })
expect(screen.getByRole('row')).toBeInTheDocument()
})
it('should render with WEB data source type', () => {
const doc = createMockDoc({ data_source_type: DataSourceType.WEB })
render(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() })
expect(screen.getByRole('row')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle document with very long name', () => {
const doc = createMockDoc({ name: `${'a'.repeat(500)}.txt` })
render(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() })
expect(screen.getByRole('row')).toBeInTheDocument()
})
it('should handle document with special characters in name', () => {
const doc = createMockDoc({ name: '<script>test</script>.txt' })
render(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() })
expect(screen.getByText('<script>test</script>.txt')).toBeInTheDocument()
})
it('should memoize the component', () => {
const wrapper = createWrapper()
const { rerender } = render(<DocumentTableRow {...defaultProps} />, { wrapper })
rerender(<DocumentTableRow {...defaultProps} />)
expect(screen.getByRole('row')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,152 @@
import type { FC } from 'react'
import type { SimpleDocumentDetail } from '@/models/datasets'
import { RiEditLine } from '@remixicon/react'
import { pick } from 'es-toolkit/object'
import { useRouter } from 'next/navigation'
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import Checkbox from '@/app/components/base/checkbox'
import Tooltip from '@/app/components/base/tooltip'
import ChunkingModeLabel from '@/app/components/datasets/common/chunking-mode-label'
import Operations from '@/app/components/datasets/documents/components/operations'
import SummaryStatus from '@/app/components/datasets/documents/detail/completed/common/summary-status'
import StatusItem from '@/app/components/datasets/documents/status-item'
import useTimestamp from '@/hooks/use-timestamp'
import { DataSourceType } from '@/models/datasets'
import { formatNumber } from '@/utils/format'
import DocumentSourceIcon from './document-source-icon'
import { renderTdValue } from './utils'
type LocalDoc = SimpleDocumentDetail & { percent?: number }
type DocumentTableRowProps = {
doc: LocalDoc
index: number
datasetId: string
isSelected: boolean
isGeneralMode: boolean
isQAMode: boolean
embeddingAvailable: boolean
selectedIds: string[]
onSelectOne: (docId: string) => void
onSelectedIdChange: (ids: string[]) => void
onShowRenameModal: (doc: LocalDoc) => void
onUpdate: () => void
}
const renderCount = (count: number | undefined) => {
if (!count)
return renderTdValue(0, true)
if (count < 1000)
return count
return `${formatNumber((count / 1000).toFixed(1))}k`
}
const DocumentTableRow: FC<DocumentTableRowProps> = React.memo(({
doc,
index,
datasetId,
isSelected,
isGeneralMode,
isQAMode,
embeddingAvailable,
selectedIds,
onSelectOne,
onSelectedIdChange,
onShowRenameModal,
onUpdate,
}) => {
const { t } = useTranslation()
const { formatTime } = useTimestamp()
const router = useRouter()
const isFile = doc.data_source_type === DataSourceType.FILE
const fileType = isFile ? doc.data_source_detail_dict?.upload_file?.extension : ''
const handleRowClick = useCallback(() => {
router.push(`/datasets/${datasetId}/documents/${doc.id}`)
}, [router, datasetId, doc.id])
const handleCheckboxClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
}, [])
const handleRenameClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
onShowRenameModal(doc)
}, [doc, onShowRenameModal])
return (
<tr
className="h-8 cursor-pointer border-b border-divider-subtle hover:bg-background-default-hover"
onClick={handleRowClick}
>
<td className="text-left align-middle text-xs text-text-tertiary">
<div className="flex items-center" onClick={handleCheckboxClick}>
<Checkbox
className="mr-2 shrink-0"
checked={isSelected}
onCheck={() => onSelectOne(doc.id)}
/>
{index + 1}
</div>
</td>
<td>
<div className="group mr-6 flex max-w-[460px] items-center hover:mr-0">
<div className="flex shrink-0 items-center">
<DocumentSourceIcon doc={doc} fileType={fileType} />
</div>
<Tooltip popupContent={doc.name}>
<span className="grow-1 truncate text-sm">{doc.name}</span>
</Tooltip>
{doc.summary_index_status && (
<div className="ml-1 hidden shrink-0 group-hover:flex">
<SummaryStatus status={doc.summary_index_status} />
</div>
)}
<div className="hidden shrink-0 group-hover:ml-auto group-hover:flex">
<Tooltip popupContent={t('list.table.rename', { ns: 'datasetDocuments' })}>
<div
className="cursor-pointer rounded-md p-1 hover:bg-state-base-hover"
onClick={handleRenameClick}
>
<RiEditLine className="h-4 w-4 text-text-tertiary" />
</div>
</Tooltip>
</div>
</div>
</td>
<td>
<ChunkingModeLabel
isGeneralMode={isGeneralMode}
isQAMode={isQAMode}
/>
</td>
<td>{renderCount(doc.word_count)}</td>
<td>{renderCount(doc.hit_count)}</td>
<td className="text-[13px] text-text-secondary">
{formatTime(doc.created_at, t('dateTimeFormat', { ns: 'datasetHitTesting' }) as string)}
</td>
<td>
<StatusItem status={doc.display_status} />
</td>
<td>
<Operations
selectedIds={selectedIds}
onSelectedIdChange={onSelectedIdChange}
embeddingAvailable={embeddingAvailable}
datasetId={datasetId}
detail={pick(doc, ['name', 'enabled', 'archived', 'id', 'data_source_type', 'doc_form', 'display_status'])}
onUpdate={onUpdate}
/>
</td>
</tr>
)
})
DocumentTableRow.displayName = 'DocumentTableRow'
export default DocumentTableRow

View File

@ -0,0 +1,4 @@
export { default as DocumentSourceIcon } from './document-source-icon'
export { default as DocumentTableRow } from './document-table-row'
export { default as SortHeader } from './sort-header'
export { renderTdValue } from './utils'

View File

@ -0,0 +1,124 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import SortHeader from './sort-header'
describe('SortHeader', () => {
const defaultProps = {
field: 'name' as const,
label: 'File Name',
currentSortField: null,
sortOrder: 'desc' as const,
onSort: vi.fn(),
}
describe('rendering', () => {
it('should render the label', () => {
render(<SortHeader {...defaultProps} />)
expect(screen.getByText('File Name')).toBeInTheDocument()
})
it('should render the sort icon', () => {
const { container } = render(<SortHeader {...defaultProps} />)
const icon = container.querySelector('svg')
expect(icon).toBeInTheDocument()
})
})
describe('inactive state', () => {
it('should have disabled text color when not active', () => {
const { container } = render(<SortHeader {...defaultProps} />)
const icon = container.querySelector('svg')
expect(icon).toHaveClass('text-text-disabled')
})
it('should not be rotated when not active', () => {
const { container } = render(<SortHeader {...defaultProps} />)
const icon = container.querySelector('svg')
expect(icon).not.toHaveClass('rotate-180')
})
})
describe('active state', () => {
it('should have tertiary text color when active', () => {
const { container } = render(
<SortHeader {...defaultProps} currentSortField="name" />,
)
const icon = container.querySelector('svg')
expect(icon).toHaveClass('text-text-tertiary')
})
it('should not be rotated when active and desc', () => {
const { container } = render(
<SortHeader {...defaultProps} currentSortField="name" sortOrder="desc" />,
)
const icon = container.querySelector('svg')
expect(icon).not.toHaveClass('rotate-180')
})
it('should be rotated when active and asc', () => {
const { container } = render(
<SortHeader {...defaultProps} currentSortField="name" sortOrder="asc" />,
)
const icon = container.querySelector('svg')
expect(icon).toHaveClass('rotate-180')
})
})
describe('interaction', () => {
it('should call onSort when clicked', () => {
const onSort = vi.fn()
render(<SortHeader {...defaultProps} onSort={onSort} />)
fireEvent.click(screen.getByText('File Name'))
expect(onSort).toHaveBeenCalledWith('name')
})
it('should call onSort with correct field', () => {
const onSort = vi.fn()
render(<SortHeader {...defaultProps} field="word_count" onSort={onSort} />)
fireEvent.click(screen.getByText('File Name'))
expect(onSort).toHaveBeenCalledWith('word_count')
})
})
describe('different fields', () => {
it('should work with word_count field', () => {
render(
<SortHeader
{...defaultProps}
field="word_count"
label="Words"
currentSortField="word_count"
/>,
)
expect(screen.getByText('Words')).toBeInTheDocument()
})
it('should work with hit_count field', () => {
render(
<SortHeader
{...defaultProps}
field="hit_count"
label="Hit Count"
currentSortField="hit_count"
/>,
)
expect(screen.getByText('Hit Count')).toBeInTheDocument()
})
it('should work with created_at field', () => {
render(
<SortHeader
{...defaultProps}
field="created_at"
label="Upload Time"
currentSortField="created_at"
/>,
)
expect(screen.getByText('Upload Time')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,44 @@
import type { FC } from 'react'
import type { SortField, SortOrder } from '../hooks'
import { RiArrowDownLine } from '@remixicon/react'
import * as React from 'react'
import { cn } from '@/utils/classnames'
type SortHeaderProps = {
field: Exclude<SortField, null>
label: string
currentSortField: SortField
sortOrder: SortOrder
onSort: (field: SortField) => void
}
const SortHeader: FC<SortHeaderProps> = React.memo(({
field,
label,
currentSortField,
sortOrder,
onSort,
}) => {
const isActive = currentSortField === field
const isDesc = isActive && sortOrder === 'desc'
return (
<div
className="flex cursor-pointer items-center hover:text-text-secondary"
onClick={() => onSort(field)}
>
{label}
<RiArrowDownLine
className={cn(
'ml-0.5 h-3 w-3 transition-all',
isActive ? 'text-text-tertiary' : 'text-text-disabled',
isActive && !isDesc ? 'rotate-180' : '',
)}
/>
</div>
)
})
SortHeader.displayName = 'SortHeader'
export default SortHeader

View File

@ -0,0 +1,90 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { renderTdValue } from './utils'
describe('renderTdValue', () => {
describe('Rendering', () => {
it('should render string value correctly', () => {
const { container } = render(<>{renderTdValue('test value')}</>)
expect(screen.getByText('test value')).toBeInTheDocument()
expect(container.querySelector('div')).toHaveClass('text-text-secondary')
})
it('should render number value correctly', () => {
const { container } = render(<>{renderTdValue(42)}</>)
expect(screen.getByText('42')).toBeInTheDocument()
expect(container.querySelector('div')).toHaveClass('text-text-secondary')
})
it('should render zero correctly', () => {
const { container } = render(<>{renderTdValue(0)}</>)
expect(screen.getByText('0')).toBeInTheDocument()
expect(container.querySelector('div')).toHaveClass('text-text-secondary')
})
})
describe('Null and undefined handling', () => {
it('should render dash for null value', () => {
render(<>{renderTdValue(null)}</>)
expect(screen.getByText('-')).toBeInTheDocument()
})
it('should render dash for null value with empty style', () => {
const { container } = render(<>{renderTdValue(null, true)}</>)
expect(screen.getByText('-')).toBeInTheDocument()
expect(container.querySelector('div')).toHaveClass('text-text-tertiary')
})
})
describe('Empty style', () => {
it('should apply text-text-tertiary class when isEmptyStyle is true', () => {
const { container } = render(<>{renderTdValue('value', true)}</>)
expect(container.querySelector('div')).toHaveClass('text-text-tertiary')
})
it('should apply text-text-secondary class when isEmptyStyle is false', () => {
const { container } = render(<>{renderTdValue('value', false)}</>)
expect(container.querySelector('div')).toHaveClass('text-text-secondary')
})
it('should apply text-text-secondary class when isEmptyStyle is not provided', () => {
const { container } = render(<>{renderTdValue('value')}</>)
expect(container.querySelector('div')).toHaveClass('text-text-secondary')
})
})
describe('Edge Cases', () => {
it('should handle empty string', () => {
render(<>{renderTdValue('')}</>)
// Empty string should still render but with no visible text
const div = document.querySelector('div')
expect(div).toBeInTheDocument()
})
it('should handle large numbers', () => {
render(<>{renderTdValue(1234567890)}</>)
expect(screen.getByText('1234567890')).toBeInTheDocument()
})
it('should handle negative numbers', () => {
render(<>{renderTdValue(-42)}</>)
expect(screen.getByText('-42')).toBeInTheDocument()
})
it('should handle special characters in string', () => {
render(<>{renderTdValue('<script>alert("xss")</script>')}</>)
expect(screen.getByText('<script>alert("xss")</script>')).toBeInTheDocument()
})
it('should handle unicode characters', () => {
render(<>{renderTdValue('Test Unicode: \u4E2D\u6587')}</>)
expect(screen.getByText('Test Unicode: \u4E2D\u6587')).toBeInTheDocument()
})
it('should handle very long strings', () => {
const longString = 'a'.repeat(1000)
render(<>{renderTdValue(longString)}</>)
expect(screen.getByText(longString)).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,16 @@
import type { ReactNode } from 'react'
import { cn } from '@/utils/classnames'
import s from '../../../style.module.css'
export const renderTdValue = (value: string | number | null, isEmptyStyle = false): ReactNode => {
const className = cn(
isEmptyStyle ? 'text-text-tertiary' : 'text-text-secondary',
s.tdValue,
)
return (
<div className={className}>
{value ?? '-'}
</div>
)
}

View File

@ -0,0 +1,4 @@
export { useDocumentActions } from './use-document-actions'
export { useDocumentSelection } from './use-document-selection'
export { useDocumentSort } from './use-document-sort'
export type { SortField, SortOrder } from './use-document-sort'

View File

@ -0,0 +1,438 @@
import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DocumentActionType } from '@/models/datasets'
import * as useDocument from '@/service/knowledge/use-document'
import { useDocumentActions } from './use-document-actions'
vi.mock('@/service/knowledge/use-document')
const mockUseDocumentArchive = vi.mocked(useDocument.useDocumentArchive)
const mockUseDocumentSummary = vi.mocked(useDocument.useDocumentSummary)
const mockUseDocumentEnable = vi.mocked(useDocument.useDocumentEnable)
const mockUseDocumentDisable = vi.mocked(useDocument.useDocumentDisable)
const mockUseDocumentDelete = vi.mocked(useDocument.useDocumentDelete)
const mockUseDocumentBatchRetryIndex = vi.mocked(useDocument.useDocumentBatchRetryIndex)
const mockUseDocumentDownloadZip = vi.mocked(useDocument.useDocumentDownloadZip)
const createTestQueryClient = () => new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const createWrapper = () => {
const queryClient = createTestQueryClient()
return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
describe('useDocumentActions', () => {
const mockMutateAsync = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
// Setup all mocks with default values
const createMockMutation = () => ({
mutateAsync: mockMutateAsync,
isPending: false,
isError: false,
isSuccess: false,
isIdle: true,
data: undefined,
error: null,
mutate: vi.fn(),
reset: vi.fn(),
status: 'idle' as const,
variables: undefined,
context: undefined,
failureCount: 0,
failureReason: null,
submittedAt: 0,
})
mockUseDocumentArchive.mockReturnValue(createMockMutation() as unknown as ReturnType<typeof useDocument.useDocumentArchive>)
mockUseDocumentSummary.mockReturnValue(createMockMutation() as unknown as ReturnType<typeof useDocument.useDocumentSummary>)
mockUseDocumentEnable.mockReturnValue(createMockMutation() as unknown as ReturnType<typeof useDocument.useDocumentEnable>)
mockUseDocumentDisable.mockReturnValue(createMockMutation() as unknown as ReturnType<typeof useDocument.useDocumentDisable>)
mockUseDocumentDelete.mockReturnValue(createMockMutation() as unknown as ReturnType<typeof useDocument.useDocumentDelete>)
mockUseDocumentBatchRetryIndex.mockReturnValue(createMockMutation() as unknown as ReturnType<typeof useDocument.useDocumentBatchRetryIndex>)
mockUseDocumentDownloadZip.mockReturnValue({
...createMockMutation(),
isPending: false,
} as unknown as ReturnType<typeof useDocument.useDocumentDownloadZip>)
})
describe('handleAction', () => {
it('should call archive mutation when archive action is triggered', async () => {
mockMutateAsync.mockResolvedValue({ result: 'success' })
const onUpdate = vi.fn()
const onClearSelection = vi.fn()
const { result } = renderHook(
() => useDocumentActions({
datasetId: 'ds1',
selectedIds: ['doc1'],
downloadableSelectedIds: [],
onUpdate,
onClearSelection,
}),
{ wrapper: createWrapper() },
)
await act(async () => {
await result.current.handleAction(DocumentActionType.archive)()
})
expect(mockMutateAsync).toHaveBeenCalledWith({
datasetId: 'ds1',
documentIds: ['doc1'],
})
})
it('should call onUpdate on successful action', async () => {
mockMutateAsync.mockResolvedValue({ result: 'success' })
const onUpdate = vi.fn()
const onClearSelection = vi.fn()
const { result } = renderHook(
() => useDocumentActions({
datasetId: 'ds1',
selectedIds: ['doc1'],
downloadableSelectedIds: [],
onUpdate,
onClearSelection,
}),
{ wrapper: createWrapper() },
)
await act(async () => {
await result.current.handleAction(DocumentActionType.enable)()
})
await waitFor(() => {
expect(onUpdate).toHaveBeenCalled()
})
})
it('should call onClearSelection on delete action', async () => {
mockMutateAsync.mockResolvedValue({ result: 'success' })
const onUpdate = vi.fn()
const onClearSelection = vi.fn()
const { result } = renderHook(
() => useDocumentActions({
datasetId: 'ds1',
selectedIds: ['doc1'],
downloadableSelectedIds: [],
onUpdate,
onClearSelection,
}),
{ wrapper: createWrapper() },
)
await act(async () => {
await result.current.handleAction(DocumentActionType.delete)()
})
await waitFor(() => {
expect(onClearSelection).toHaveBeenCalled()
})
})
})
describe('handleBatchReIndex', () => {
it('should call retry index mutation', async () => {
mockMutateAsync.mockResolvedValue({ result: 'success' })
const onUpdate = vi.fn()
const onClearSelection = vi.fn()
const { result } = renderHook(
() => useDocumentActions({
datasetId: 'ds1',
selectedIds: ['doc1', 'doc2'],
downloadableSelectedIds: [],
onUpdate,
onClearSelection,
}),
{ wrapper: createWrapper() },
)
await act(async () => {
await result.current.handleBatchReIndex()
})
expect(mockMutateAsync).toHaveBeenCalledWith({
datasetId: 'ds1',
documentIds: ['doc1', 'doc2'],
})
})
it('should call onClearSelection on success', async () => {
mockMutateAsync.mockResolvedValue({ result: 'success' })
const onUpdate = vi.fn()
const onClearSelection = vi.fn()
const { result } = renderHook(
() => useDocumentActions({
datasetId: 'ds1',
selectedIds: ['doc1'],
downloadableSelectedIds: [],
onUpdate,
onClearSelection,
}),
{ wrapper: createWrapper() },
)
await act(async () => {
await result.current.handleBatchReIndex()
})
await waitFor(() => {
expect(onClearSelection).toHaveBeenCalled()
expect(onUpdate).toHaveBeenCalled()
})
})
})
describe('handleBatchDownload', () => {
it('should not proceed when already downloading', async () => {
mockUseDocumentDownloadZip.mockReturnValue({
mutateAsync: mockMutateAsync,
isPending: true,
} as unknown as ReturnType<typeof useDocument.useDocumentDownloadZip>)
const { result } = renderHook(
() => useDocumentActions({
datasetId: 'ds1',
selectedIds: ['doc1'],
downloadableSelectedIds: ['doc1'],
onUpdate: vi.fn(),
onClearSelection: vi.fn(),
}),
{ wrapper: createWrapper() },
)
await act(async () => {
await result.current.handleBatchDownload()
})
expect(mockMutateAsync).not.toHaveBeenCalled()
})
it('should call download mutation with downloadable ids', async () => {
const mockBlob = new Blob(['test'])
mockMutateAsync.mockResolvedValue(mockBlob)
mockUseDocumentDownloadZip.mockReturnValue({
mutateAsync: mockMutateAsync,
isPending: false,
} as unknown as ReturnType<typeof useDocument.useDocumentDownloadZip>)
const { result } = renderHook(
() => useDocumentActions({
datasetId: 'ds1',
selectedIds: ['doc1', 'doc2'],
downloadableSelectedIds: ['doc1'],
onUpdate: vi.fn(),
onClearSelection: vi.fn(),
}),
{ wrapper: createWrapper() },
)
await act(async () => {
await result.current.handleBatchDownload()
})
expect(mockMutateAsync).toHaveBeenCalledWith({
datasetId: 'ds1',
documentIds: ['doc1'],
})
})
})
describe('isDownloadingZip', () => {
it('should reflect isPending state from mutation', () => {
mockUseDocumentDownloadZip.mockReturnValue({
mutateAsync: mockMutateAsync,
isPending: true,
} as unknown as ReturnType<typeof useDocument.useDocumentDownloadZip>)
const { result } = renderHook(
() => useDocumentActions({
datasetId: 'ds1',
selectedIds: [],
downloadableSelectedIds: [],
onUpdate: vi.fn(),
onClearSelection: vi.fn(),
}),
{ wrapper: createWrapper() },
)
expect(result.current.isDownloadingZip).toBe(true)
})
})
describe('error handling', () => {
it('should show error toast when handleAction fails', async () => {
mockMutateAsync.mockRejectedValue(new Error('Action failed'))
const onUpdate = vi.fn()
const onClearSelection = vi.fn()
const { result } = renderHook(
() => useDocumentActions({
datasetId: 'ds1',
selectedIds: ['doc1'],
downloadableSelectedIds: [],
onUpdate,
onClearSelection,
}),
{ wrapper: createWrapper() },
)
await act(async () => {
await result.current.handleAction(DocumentActionType.archive)()
})
// onUpdate should not be called on error
expect(onUpdate).not.toHaveBeenCalled()
})
it('should show error toast when handleBatchReIndex fails', async () => {
mockMutateAsync.mockRejectedValue(new Error('Re-index failed'))
const onUpdate = vi.fn()
const onClearSelection = vi.fn()
const { result } = renderHook(
() => useDocumentActions({
datasetId: 'ds1',
selectedIds: ['doc1'],
downloadableSelectedIds: [],
onUpdate,
onClearSelection,
}),
{ wrapper: createWrapper() },
)
await act(async () => {
await result.current.handleBatchReIndex()
})
// onUpdate and onClearSelection should not be called on error
expect(onUpdate).not.toHaveBeenCalled()
expect(onClearSelection).not.toHaveBeenCalled()
})
it('should show error toast when handleBatchDownload fails', async () => {
mockMutateAsync.mockRejectedValue(new Error('Download failed'))
mockUseDocumentDownloadZip.mockReturnValue({
mutateAsync: mockMutateAsync,
isPending: false,
} as unknown as ReturnType<typeof useDocument.useDocumentDownloadZip>)
const { result } = renderHook(
() => useDocumentActions({
datasetId: 'ds1',
selectedIds: ['doc1'],
downloadableSelectedIds: ['doc1'],
onUpdate: vi.fn(),
onClearSelection: vi.fn(),
}),
{ wrapper: createWrapper() },
)
await act(async () => {
await result.current.handleBatchDownload()
})
// Mutation was called but failed
expect(mockMutateAsync).toHaveBeenCalled()
})
it('should show error toast when handleBatchDownload returns null blob', async () => {
mockMutateAsync.mockResolvedValue(null)
mockUseDocumentDownloadZip.mockReturnValue({
mutateAsync: mockMutateAsync,
isPending: false,
} as unknown as ReturnType<typeof useDocument.useDocumentDownloadZip>)
const { result } = renderHook(
() => useDocumentActions({
datasetId: 'ds1',
selectedIds: ['doc1'],
downloadableSelectedIds: ['doc1'],
onUpdate: vi.fn(),
onClearSelection: vi.fn(),
}),
{ wrapper: createWrapper() },
)
await act(async () => {
await result.current.handleBatchDownload()
})
// Mutation was called but returned null
expect(mockMutateAsync).toHaveBeenCalled()
})
})
describe('all action types', () => {
it('should handle summary action', async () => {
mockMutateAsync.mockResolvedValue({ result: 'success' })
const onUpdate = vi.fn()
const { result } = renderHook(
() => useDocumentActions({
datasetId: 'ds1',
selectedIds: ['doc1'],
downloadableSelectedIds: [],
onUpdate,
onClearSelection: vi.fn(),
}),
{ wrapper: createWrapper() },
)
await act(async () => {
await result.current.handleAction(DocumentActionType.summary)()
})
expect(mockMutateAsync).toHaveBeenCalled()
await waitFor(() => {
expect(onUpdate).toHaveBeenCalled()
})
})
it('should handle disable action', async () => {
mockMutateAsync.mockResolvedValue({ result: 'success' })
const onUpdate = vi.fn()
const { result } = renderHook(
() => useDocumentActions({
datasetId: 'ds1',
selectedIds: ['doc1'],
downloadableSelectedIds: [],
onUpdate,
onClearSelection: vi.fn(),
}),
{ wrapper: createWrapper() },
)
await act(async () => {
await result.current.handleAction(DocumentActionType.disable)()
})
expect(mockMutateAsync).toHaveBeenCalled()
await waitFor(() => {
expect(onUpdate).toHaveBeenCalled()
})
})
})
})

View File

@ -0,0 +1,126 @@
import type { CommonResponse } from '@/models/common'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Toast from '@/app/components/base/toast'
import { DocumentActionType } from '@/models/datasets'
import {
useDocumentArchive,
useDocumentBatchRetryIndex,
useDocumentDelete,
useDocumentDisable,
useDocumentDownloadZip,
useDocumentEnable,
useDocumentSummary,
} from '@/service/knowledge/use-document'
import { asyncRunSafe } from '@/utils'
import { downloadBlob } from '@/utils/download'
type UseDocumentActionsOptions = {
datasetId: string
selectedIds: string[]
downloadableSelectedIds: string[]
onUpdate: () => void
onClearSelection: () => void
}
/**
* Generate a random ZIP filename for bulk document downloads.
* We intentionally avoid leaking dataset info in the exported archive name.
*/
const generateDocsZipFileName = (): string => {
const randomPart = (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')
? crypto.randomUUID()
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
return `${randomPart}-docs.zip`
}
export const useDocumentActions = ({
datasetId,
selectedIds,
downloadableSelectedIds,
onUpdate,
onClearSelection,
}: UseDocumentActionsOptions) => {
const { t } = useTranslation()
const { mutateAsync: archiveDocument } = useDocumentArchive()
const { mutateAsync: generateSummary } = useDocumentSummary()
const { mutateAsync: enableDocument } = useDocumentEnable()
const { mutateAsync: disableDocument } = useDocumentDisable()
const { mutateAsync: deleteDocument } = useDocumentDelete()
const { mutateAsync: retryIndexDocument } = useDocumentBatchRetryIndex()
const { mutateAsync: requestDocumentsZip, isPending: isDownloadingZip } = useDocumentDownloadZip()
type SupportedActionType
= | typeof DocumentActionType.archive
| typeof DocumentActionType.summary
| typeof DocumentActionType.enable
| typeof DocumentActionType.disable
| typeof DocumentActionType.delete
const actionMutationMap = useMemo(() => ({
[DocumentActionType.archive]: archiveDocument,
[DocumentActionType.summary]: generateSummary,
[DocumentActionType.enable]: enableDocument,
[DocumentActionType.disable]: disableDocument,
[DocumentActionType.delete]: deleteDocument,
} as const), [archiveDocument, generateSummary, enableDocument, disableDocument, deleteDocument])
const handleAction = useCallback((actionName: SupportedActionType) => {
return async () => {
const opApi = actionMutationMap[actionName]
if (!opApi)
return
const [e] = await asyncRunSafe<CommonResponse>(
opApi({ datasetId, documentIds: selectedIds }),
)
if (!e) {
if (actionName === DocumentActionType.delete)
onClearSelection()
Toast.notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
onUpdate()
}
else {
Toast.notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
}
}
}, [actionMutationMap, datasetId, selectedIds, onClearSelection, onUpdate, t])
const handleBatchReIndex = useCallback(async () => {
const [e] = await asyncRunSafe<CommonResponse>(
retryIndexDocument({ datasetId, documentIds: selectedIds }),
)
if (!e) {
onClearSelection()
Toast.notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
onUpdate()
}
else {
Toast.notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
}
}, [retryIndexDocument, datasetId, selectedIds, onClearSelection, onUpdate, t])
const handleBatchDownload = useCallback(async () => {
if (isDownloadingZip)
return
const [e, blob] = await asyncRunSafe(
requestDocumentsZip({ datasetId, documentIds: downloadableSelectedIds }),
)
if (e || !blob) {
Toast.notify({ type: 'error', message: t('actionMsg.downloadUnsuccessfully', { ns: 'common' }) })
return
}
downloadBlob({ data: blob, fileName: generateDocsZipFileName() })
}, [datasetId, downloadableSelectedIds, isDownloadingZip, requestDocumentsZip, t])
return {
handleAction,
handleBatchReIndex,
handleBatchDownload,
isDownloadingZip,
}
}

View File

@ -0,0 +1,317 @@
import type { SimpleDocumentDetail } from '@/models/datasets'
import { act, renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DataSourceType } from '@/models/datasets'
import { useDocumentSelection } from './use-document-selection'
type LocalDoc = SimpleDocumentDetail & { percent?: number }
const createMockDocument = (overrides: Partial<LocalDoc> = {}): LocalDoc => ({
id: 'doc1',
name: 'Test Document',
data_source_type: DataSourceType.FILE,
data_source_info: {},
data_source_detail_dict: {},
word_count: 100,
hit_count: 10,
created_at: 1000000,
position: 1,
doc_form: 'text_model',
enabled: true,
archived: false,
display_status: 'available',
created_from: 'api',
...overrides,
} as LocalDoc)
describe('useDocumentSelection', () => {
describe('isAllSelected', () => {
it('should return false when documents is empty', () => {
const onSelectedIdChange = vi.fn()
const { result } = renderHook(() =>
useDocumentSelection({
documents: [],
selectedIds: [],
onSelectedIdChange,
}),
)
expect(result.current.isAllSelected).toBe(false)
})
it('should return true when all documents are selected', () => {
const docs = [
createMockDocument({ id: 'doc1' }),
createMockDocument({ id: 'doc2' }),
]
const onSelectedIdChange = vi.fn()
const { result } = renderHook(() =>
useDocumentSelection({
documents: docs,
selectedIds: ['doc1', 'doc2'],
onSelectedIdChange,
}),
)
expect(result.current.isAllSelected).toBe(true)
})
it('should return false when not all documents are selected', () => {
const docs = [
createMockDocument({ id: 'doc1' }),
createMockDocument({ id: 'doc2' }),
]
const onSelectedIdChange = vi.fn()
const { result } = renderHook(() =>
useDocumentSelection({
documents: docs,
selectedIds: ['doc1'],
onSelectedIdChange,
}),
)
expect(result.current.isAllSelected).toBe(false)
})
})
describe('isSomeSelected', () => {
it('should return false when no documents are selected', () => {
const docs = [createMockDocument({ id: 'doc1' })]
const onSelectedIdChange = vi.fn()
const { result } = renderHook(() =>
useDocumentSelection({
documents: docs,
selectedIds: [],
onSelectedIdChange,
}),
)
expect(result.current.isSomeSelected).toBe(false)
})
it('should return true when some documents are selected', () => {
const docs = [
createMockDocument({ id: 'doc1' }),
createMockDocument({ id: 'doc2' }),
]
const onSelectedIdChange = vi.fn()
const { result } = renderHook(() =>
useDocumentSelection({
documents: docs,
selectedIds: ['doc1'],
onSelectedIdChange,
}),
)
expect(result.current.isSomeSelected).toBe(true)
})
})
describe('onSelectAll', () => {
it('should select all documents when none are selected', () => {
const docs = [
createMockDocument({ id: 'doc1' }),
createMockDocument({ id: 'doc2' }),
]
const onSelectedIdChange = vi.fn()
const { result } = renderHook(() =>
useDocumentSelection({
documents: docs,
selectedIds: [],
onSelectedIdChange,
}),
)
act(() => {
result.current.onSelectAll()
})
expect(onSelectedIdChange).toHaveBeenCalledWith(['doc1', 'doc2'])
})
it('should deselect all when all are selected', () => {
const docs = [
createMockDocument({ id: 'doc1' }),
createMockDocument({ id: 'doc2' }),
]
const onSelectedIdChange = vi.fn()
const { result } = renderHook(() =>
useDocumentSelection({
documents: docs,
selectedIds: ['doc1', 'doc2'],
onSelectedIdChange,
}),
)
act(() => {
result.current.onSelectAll()
})
expect(onSelectedIdChange).toHaveBeenCalledWith([])
})
it('should add to existing selection when some are selected', () => {
const docs = [
createMockDocument({ id: 'doc1' }),
createMockDocument({ id: 'doc2' }),
createMockDocument({ id: 'doc3' }),
]
const onSelectedIdChange = vi.fn()
const { result } = renderHook(() =>
useDocumentSelection({
documents: docs,
selectedIds: ['doc1'],
onSelectedIdChange,
}),
)
act(() => {
result.current.onSelectAll()
})
expect(onSelectedIdChange).toHaveBeenCalledWith(['doc1', 'doc2', 'doc3'])
})
})
describe('onSelectOne', () => {
it('should add document to selection when not selected', () => {
const onSelectedIdChange = vi.fn()
const { result } = renderHook(() =>
useDocumentSelection({
documents: [],
selectedIds: [],
onSelectedIdChange,
}),
)
act(() => {
result.current.onSelectOne('doc1')
})
expect(onSelectedIdChange).toHaveBeenCalledWith(['doc1'])
})
it('should remove document from selection when already selected', () => {
const onSelectedIdChange = vi.fn()
const { result } = renderHook(() =>
useDocumentSelection({
documents: [],
selectedIds: ['doc1', 'doc2'],
onSelectedIdChange,
}),
)
act(() => {
result.current.onSelectOne('doc1')
})
expect(onSelectedIdChange).toHaveBeenCalledWith(['doc2'])
})
})
describe('hasErrorDocumentsSelected', () => {
it('should return false when no error documents are selected', () => {
const docs = [
createMockDocument({ id: 'doc1', display_status: 'available' }),
createMockDocument({ id: 'doc2', display_status: 'error' }),
]
const onSelectedIdChange = vi.fn()
const { result } = renderHook(() =>
useDocumentSelection({
documents: docs,
selectedIds: ['doc1'],
onSelectedIdChange,
}),
)
expect(result.current.hasErrorDocumentsSelected).toBe(false)
})
it('should return true when an error document is selected', () => {
const docs = [
createMockDocument({ id: 'doc1', display_status: 'available' }),
createMockDocument({ id: 'doc2', display_status: 'error' }),
]
const onSelectedIdChange = vi.fn()
const { result } = renderHook(() =>
useDocumentSelection({
documents: docs,
selectedIds: ['doc2'],
onSelectedIdChange,
}),
)
expect(result.current.hasErrorDocumentsSelected).toBe(true)
})
})
describe('downloadableSelectedIds', () => {
it('should return only FILE type documents from selection', () => {
const docs = [
createMockDocument({ id: 'doc1', data_source_type: DataSourceType.FILE }),
createMockDocument({ id: 'doc2', data_source_type: DataSourceType.NOTION }),
createMockDocument({ id: 'doc3', data_source_type: DataSourceType.FILE }),
]
const onSelectedIdChange = vi.fn()
const { result } = renderHook(() =>
useDocumentSelection({
documents: docs,
selectedIds: ['doc1', 'doc2', 'doc3'],
onSelectedIdChange,
}),
)
expect(result.current.downloadableSelectedIds).toEqual(['doc1', 'doc3'])
})
it('should return empty array when no FILE documents selected', () => {
const docs = [
createMockDocument({ id: 'doc1', data_source_type: DataSourceType.NOTION }),
createMockDocument({ id: 'doc2', data_source_type: DataSourceType.WEB }),
]
const onSelectedIdChange = vi.fn()
const { result } = renderHook(() =>
useDocumentSelection({
documents: docs,
selectedIds: ['doc1', 'doc2'],
onSelectedIdChange,
}),
)
expect(result.current.downloadableSelectedIds).toEqual([])
})
})
describe('clearSelection', () => {
it('should call onSelectedIdChange with empty array', () => {
const onSelectedIdChange = vi.fn()
const { result } = renderHook(() =>
useDocumentSelection({
documents: [],
selectedIds: ['doc1', 'doc2'],
onSelectedIdChange,
}),
)
act(() => {
result.current.clearSelection()
})
expect(onSelectedIdChange).toHaveBeenCalledWith([])
})
})
})

View File

@ -0,0 +1,66 @@
import type { SimpleDocumentDetail } from '@/models/datasets'
import { uniq } from 'es-toolkit/array'
import { useCallback, useMemo } from 'react'
import { DataSourceType } from '@/models/datasets'
type LocalDoc = SimpleDocumentDetail & { percent?: number }
type UseDocumentSelectionOptions = {
documents: LocalDoc[]
selectedIds: string[]
onSelectedIdChange: (selectedIds: string[]) => void
}
export const useDocumentSelection = ({
documents,
selectedIds,
onSelectedIdChange,
}: UseDocumentSelectionOptions) => {
const isAllSelected = useMemo(() => {
return documents.length > 0 && documents.every(doc => selectedIds.includes(doc.id))
}, [documents, selectedIds])
const isSomeSelected = useMemo(() => {
return documents.some(doc => selectedIds.includes(doc.id))
}, [documents, selectedIds])
const onSelectAll = useCallback(() => {
if (isAllSelected)
onSelectedIdChange([])
else
onSelectedIdChange(uniq([...selectedIds, ...documents.map(doc => doc.id)]))
}, [isAllSelected, documents, onSelectedIdChange, selectedIds])
const onSelectOne = useCallback((docId: string) => {
onSelectedIdChange(
selectedIds.includes(docId)
? selectedIds.filter(id => id !== docId)
: [...selectedIds, docId],
)
}, [selectedIds, onSelectedIdChange])
const hasErrorDocumentsSelected = useMemo(() => {
return documents.some(doc => selectedIds.includes(doc.id) && doc.display_status === 'error')
}, [documents, selectedIds])
const downloadableSelectedIds = useMemo(() => {
const selectedSet = new Set(selectedIds)
return documents
.filter(doc => selectedSet.has(doc.id) && doc.data_source_type === DataSourceType.FILE)
.map(doc => doc.id)
}, [documents, selectedIds])
const clearSelection = useCallback(() => {
onSelectedIdChange([])
}, [onSelectedIdChange])
return {
isAllSelected,
isSomeSelected,
onSelectAll,
onSelectOne,
hasErrorDocumentsSelected,
downloadableSelectedIds,
clearSelection,
}
}

View File

@ -0,0 +1,340 @@
import type { SimpleDocumentDetail } from '@/models/datasets'
import { act, renderHook } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { useDocumentSort } from './use-document-sort'
type LocalDoc = SimpleDocumentDetail & { percent?: number }
const createMockDocument = (overrides: Partial<LocalDoc> = {}): LocalDoc => ({
id: 'doc1',
name: 'Test Document',
data_source_type: 'upload_file',
data_source_info: {},
data_source_detail_dict: {},
word_count: 100,
hit_count: 10,
created_at: 1000000,
position: 1,
doc_form: 'text_model',
enabled: true,
archived: false,
display_status: 'available',
created_from: 'api',
...overrides,
} as LocalDoc)
describe('useDocumentSort', () => {
describe('initial state', () => {
it('should return null sortField initially', () => {
const { result } = renderHook(() =>
useDocumentSort({
documents: [],
statusFilterValue: '',
remoteSortValue: '',
}),
)
expect(result.current.sortField).toBeNull()
expect(result.current.sortOrder).toBe('desc')
})
it('should return documents unchanged when no sort is applied', () => {
const docs = [
createMockDocument({ id: 'doc1', name: 'B' }),
createMockDocument({ id: 'doc2', name: 'A' }),
]
const { result } = renderHook(() =>
useDocumentSort({
documents: docs,
statusFilterValue: '',
remoteSortValue: '',
}),
)
expect(result.current.sortedDocuments).toEqual(docs)
})
})
describe('handleSort', () => {
it('should set sort field when called', () => {
const { result } = renderHook(() =>
useDocumentSort({
documents: [],
statusFilterValue: '',
remoteSortValue: '',
}),
)
act(() => {
result.current.handleSort('name')
})
expect(result.current.sortField).toBe('name')
expect(result.current.sortOrder).toBe('desc')
})
it('should toggle sort order when same field is clicked twice', () => {
const { result } = renderHook(() =>
useDocumentSort({
documents: [],
statusFilterValue: '',
remoteSortValue: '',
}),
)
act(() => {
result.current.handleSort('name')
})
expect(result.current.sortOrder).toBe('desc')
act(() => {
result.current.handleSort('name')
})
expect(result.current.sortOrder).toBe('asc')
act(() => {
result.current.handleSort('name')
})
expect(result.current.sortOrder).toBe('desc')
})
it('should reset to desc when different field is selected', () => {
const { result } = renderHook(() =>
useDocumentSort({
documents: [],
statusFilterValue: '',
remoteSortValue: '',
}),
)
act(() => {
result.current.handleSort('name')
})
act(() => {
result.current.handleSort('name')
})
expect(result.current.sortOrder).toBe('asc')
act(() => {
result.current.handleSort('word_count')
})
expect(result.current.sortField).toBe('word_count')
expect(result.current.sortOrder).toBe('desc')
})
it('should not change state when null is passed', () => {
const { result } = renderHook(() =>
useDocumentSort({
documents: [],
statusFilterValue: '',
remoteSortValue: '',
}),
)
act(() => {
result.current.handleSort(null)
})
expect(result.current.sortField).toBeNull()
})
})
describe('sorting documents', () => {
const docs = [
createMockDocument({ id: 'doc1', name: 'Banana', word_count: 200, hit_count: 5, created_at: 3000 }),
createMockDocument({ id: 'doc2', name: 'Apple', word_count: 100, hit_count: 10, created_at: 1000 }),
createMockDocument({ id: 'doc3', name: 'Cherry', word_count: 300, hit_count: 1, created_at: 2000 }),
]
it('should sort by name descending', () => {
const { result } = renderHook(() =>
useDocumentSort({
documents: docs,
statusFilterValue: '',
remoteSortValue: '',
}),
)
act(() => {
result.current.handleSort('name')
})
const names = result.current.sortedDocuments.map(d => d.name)
expect(names).toEqual(['Cherry', 'Banana', 'Apple'])
})
it('should sort by name ascending', () => {
const { result } = renderHook(() =>
useDocumentSort({
documents: docs,
statusFilterValue: '',
remoteSortValue: '',
}),
)
act(() => {
result.current.handleSort('name')
})
act(() => {
result.current.handleSort('name')
})
const names = result.current.sortedDocuments.map(d => d.name)
expect(names).toEqual(['Apple', 'Banana', 'Cherry'])
})
it('should sort by word_count descending', () => {
const { result } = renderHook(() =>
useDocumentSort({
documents: docs,
statusFilterValue: '',
remoteSortValue: '',
}),
)
act(() => {
result.current.handleSort('word_count')
})
const counts = result.current.sortedDocuments.map(d => d.word_count)
expect(counts).toEqual([300, 200, 100])
})
it('should sort by hit_count ascending', () => {
const { result } = renderHook(() =>
useDocumentSort({
documents: docs,
statusFilterValue: '',
remoteSortValue: '',
}),
)
act(() => {
result.current.handleSort('hit_count')
})
act(() => {
result.current.handleSort('hit_count')
})
const counts = result.current.sortedDocuments.map(d => d.hit_count)
expect(counts).toEqual([1, 5, 10])
})
it('should sort by created_at descending', () => {
const { result } = renderHook(() =>
useDocumentSort({
documents: docs,
statusFilterValue: '',
remoteSortValue: '',
}),
)
act(() => {
result.current.handleSort('created_at')
})
const times = result.current.sortedDocuments.map(d => d.created_at)
expect(times).toEqual([3000, 2000, 1000])
})
})
describe('status filtering', () => {
const docs = [
createMockDocument({ id: 'doc1', display_status: 'available' }),
createMockDocument({ id: 'doc2', display_status: 'error' }),
createMockDocument({ id: 'doc3', display_status: 'available' }),
]
it('should not filter when statusFilterValue is empty', () => {
const { result } = renderHook(() =>
useDocumentSort({
documents: docs,
statusFilterValue: '',
remoteSortValue: '',
}),
)
expect(result.current.sortedDocuments.length).toBe(3)
})
it('should not filter when statusFilterValue is all', () => {
const { result } = renderHook(() =>
useDocumentSort({
documents: docs,
statusFilterValue: 'all',
remoteSortValue: '',
}),
)
expect(result.current.sortedDocuments.length).toBe(3)
})
})
describe('remoteSortValue reset', () => {
it('should reset sort state when remoteSortValue changes', () => {
const { result, rerender } = renderHook(
({ remoteSortValue }) =>
useDocumentSort({
documents: [],
statusFilterValue: '',
remoteSortValue,
}),
{ initialProps: { remoteSortValue: 'initial' } },
)
act(() => {
result.current.handleSort('name')
})
act(() => {
result.current.handleSort('name')
})
expect(result.current.sortField).toBe('name')
expect(result.current.sortOrder).toBe('asc')
rerender({ remoteSortValue: 'changed' })
expect(result.current.sortField).toBeNull()
expect(result.current.sortOrder).toBe('desc')
})
})
describe('edge cases', () => {
it('should handle documents with missing values', () => {
const docs = [
createMockDocument({ id: 'doc1', name: undefined as unknown as string, word_count: undefined }),
createMockDocument({ id: 'doc2', name: 'Test', word_count: 100 }),
]
const { result } = renderHook(() =>
useDocumentSort({
documents: docs,
statusFilterValue: '',
remoteSortValue: '',
}),
)
act(() => {
result.current.handleSort('name')
})
expect(result.current.sortedDocuments.length).toBe(2)
})
it('should handle empty documents array', () => {
const { result } = renderHook(() =>
useDocumentSort({
documents: [],
statusFilterValue: '',
remoteSortValue: '',
}),
)
act(() => {
result.current.handleSort('name')
})
expect(result.current.sortedDocuments).toEqual([])
})
})
})

View File

@ -0,0 +1,102 @@
import type { SimpleDocumentDetail } from '@/models/datasets'
import { useCallback, useMemo, useRef, useState } from 'react'
import { normalizeStatusForQuery } from '@/app/components/datasets/documents/status-filter'
export type SortField = 'name' | 'word_count' | 'hit_count' | 'created_at' | null
export type SortOrder = 'asc' | 'desc'
type LocalDoc = SimpleDocumentDetail & { percent?: number }
type UseDocumentSortOptions = {
documents: LocalDoc[]
statusFilterValue: string
remoteSortValue: string
}
export const useDocumentSort = ({
documents,
statusFilterValue,
remoteSortValue,
}: UseDocumentSortOptions) => {
const [sortField, setSortField] = useState<SortField>(null)
const [sortOrder, setSortOrder] = useState<SortOrder>('desc')
const prevRemoteSortValueRef = useRef(remoteSortValue)
// Reset sort when remote sort changes
if (prevRemoteSortValueRef.current !== remoteSortValue) {
prevRemoteSortValueRef.current = remoteSortValue
setSortField(null)
setSortOrder('desc')
}
const handleSort = useCallback((field: SortField) => {
if (field === null)
return
if (sortField === field) {
setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc')
}
else {
setSortField(field)
setSortOrder('desc')
}
}, [sortField])
const sortedDocuments = useMemo(() => {
let filteredDocs = documents
if (statusFilterValue && statusFilterValue !== 'all') {
filteredDocs = filteredDocs.filter(doc =>
typeof doc.display_status === 'string'
&& normalizeStatusForQuery(doc.display_status) === statusFilterValue,
)
}
if (!sortField)
return filteredDocs
const sortedDocs = [...filteredDocs].sort((a, b) => {
let aValue: string | number
let bValue: string | number
switch (sortField) {
case 'name':
aValue = a.name?.toLowerCase() || ''
bValue = b.name?.toLowerCase() || ''
break
case 'word_count':
aValue = a.word_count || 0
bValue = b.word_count || 0
break
case 'hit_count':
aValue = a.hit_count || 0
bValue = b.hit_count || 0
break
case 'created_at':
aValue = a.created_at
bValue = b.created_at
break
default:
return 0
}
if (sortField === 'name') {
const result = (aValue as string).localeCompare(bValue as string)
return sortOrder === 'asc' ? result : -result
}
else {
const result = (aValue as number) - (bValue as number)
return sortOrder === 'asc' ? result : -result
}
})
return sortedDocs
}, [documents, sortField, sortOrder, statusFilterValue])
return {
sortField,
sortOrder,
handleSort,
sortedDocuments,
}
}

View File

@ -0,0 +1,487 @@
import type { ReactNode } from 'react'
import type { Props as PaginationProps } from '@/app/components/base/pagination'
import type { SimpleDocumentDetail } from '@/models/datasets'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ChunkingMode, DataSourceType } from '@/models/datasets'
import DocumentList from '../list'
const mockPush = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
}))
vi.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: (selector: (state: { dataset: { doc_form: string } }) => unknown) =>
selector({ dataset: { doc_form: ChunkingMode.text } }),
}))
const createTestQueryClient = () => new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
mutations: { retry: false },
},
})
const createWrapper = () => {
const queryClient = createTestQueryClient()
return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
const createMockDoc = (overrides: Partial<SimpleDocumentDetail> = {}): SimpleDocumentDetail => ({
id: `doc-${Math.random().toString(36).substr(2, 9)}`,
position: 1,
data_source_type: DataSourceType.FILE,
data_source_info: {},
data_source_detail_dict: {
upload_file: { name: 'test.txt', extension: 'txt' },
},
dataset_process_rule_id: 'rule-1',
batch: 'batch-1',
name: 'test-document.txt',
created_from: 'web',
created_by: 'user-1',
created_at: Date.now(),
tokens: 100,
indexing_status: 'completed',
error: null,
enabled: true,
disabled_at: null,
disabled_by: null,
archived: false,
archived_reason: null,
archived_by: null,
archived_at: null,
updated_at: Date.now(),
doc_type: null,
doc_metadata: undefined,
display_status: 'available',
word_count: 500,
hit_count: 10,
doc_form: 'text_model',
...overrides,
} as SimpleDocumentDetail)
const defaultPagination: PaginationProps = {
current: 1,
onChange: vi.fn(),
total: 100,
}
describe('DocumentList', () => {
const defaultProps = {
embeddingAvailable: true,
documents: [
createMockDoc({ id: 'doc-1', name: 'Document 1.txt', word_count: 100, hit_count: 5 }),
createMockDoc({ id: 'doc-2', name: 'Document 2.txt', word_count: 200, hit_count: 10 }),
createMockDoc({ id: 'doc-3', name: 'Document 3.txt', word_count: 300, hit_count: 15 }),
],
selectedIds: [] as string[],
onSelectedIdChange: vi.fn(),
datasetId: 'dataset-1',
pagination: defaultPagination,
onUpdate: vi.fn(),
onManageMetadata: vi.fn(),
statusFilterValue: '',
remoteSortValue: '',
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<DocumentList {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByRole('table')).toBeInTheDocument()
})
it('should render all documents', () => {
render(<DocumentList {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('Document 1.txt')).toBeInTheDocument()
expect(screen.getByText('Document 2.txt')).toBeInTheDocument()
expect(screen.getByText('Document 3.txt')).toBeInTheDocument()
})
it('should render table headers', () => {
render(<DocumentList {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('#')).toBeInTheDocument()
})
it('should render pagination when total is provided', () => {
render(<DocumentList {...defaultProps} />, { wrapper: createWrapper() })
// Pagination component should be present
expect(screen.getByRole('table')).toBeInTheDocument()
})
it('should not render pagination when total is 0', () => {
const props = {
...defaultProps,
pagination: { ...defaultPagination, total: 0 },
}
render(<DocumentList {...props} />, { wrapper: createWrapper() })
expect(screen.getByRole('table')).toBeInTheDocument()
})
it('should render empty table when no documents', () => {
const props = { ...defaultProps, documents: [] }
render(<DocumentList {...props} />, { wrapper: createWrapper() })
expect(screen.getByRole('table')).toBeInTheDocument()
})
})
describe('Selection', () => {
// Helper to find checkboxes (custom div components, not native checkboxes)
const findCheckboxes = (container: HTMLElement): NodeListOf<Element> => {
return container.querySelectorAll('[class*="shadow-xs"]')
}
it('should render header checkbox when embeddingAvailable', () => {
const { container } = render(<DocumentList {...defaultProps} />, { wrapper: createWrapper() })
const checkboxes = findCheckboxes(container)
expect(checkboxes.length).toBeGreaterThan(0)
})
it('should not render header checkbox when embedding not available', () => {
const props = { ...defaultProps, embeddingAvailable: false }
render(<DocumentList {...props} />, { wrapper: createWrapper() })
// Row checkboxes should still be there, but header checkbox should be hidden
expect(screen.getByRole('table')).toBeInTheDocument()
})
it('should call onSelectedIdChange when select all is clicked', () => {
const onSelectedIdChange = vi.fn()
const props = { ...defaultProps, onSelectedIdChange }
const { container } = render(<DocumentList {...props} />, { wrapper: createWrapper() })
const checkboxes = findCheckboxes(container)
if (checkboxes.length > 0) {
fireEvent.click(checkboxes[0])
expect(onSelectedIdChange).toHaveBeenCalled()
}
})
it('should show all checkboxes as checked when all are selected', () => {
const props = {
...defaultProps,
selectedIds: ['doc-1', 'doc-2', 'doc-3'],
}
const { container } = render(<DocumentList {...props} />, { wrapper: createWrapper() })
const checkboxes = findCheckboxes(container)
// When checked, checkbox should have a check icon (svg) inside
checkboxes.forEach((checkbox) => {
const checkIcon = checkbox.querySelector('svg')
expect(checkIcon).toBeInTheDocument()
})
})
it('should show indeterminate state when some are selected', () => {
const props = {
...defaultProps,
selectedIds: ['doc-1'],
}
const { container } = render(<DocumentList {...props} />, { wrapper: createWrapper() })
// First checkbox is the header checkbox which should be indeterminate
const checkboxes = findCheckboxes(container)
expect(checkboxes.length).toBeGreaterThan(0)
// Header checkbox should show indeterminate icon, not check icon
// Just verify it's rendered
expect(checkboxes[0]).toBeInTheDocument()
})
it('should call onSelectedIdChange with single document when row checkbox is clicked', () => {
const onSelectedIdChange = vi.fn()
const props = { ...defaultProps, onSelectedIdChange }
const { container } = render(<DocumentList {...props} />, { wrapper: createWrapper() })
// Click the second checkbox (first row checkbox)
const checkboxes = findCheckboxes(container)
if (checkboxes.length > 1) {
fireEvent.click(checkboxes[1])
expect(onSelectedIdChange).toHaveBeenCalled()
}
})
})
describe('Sorting', () => {
it('should render sort headers for sortable columns', () => {
render(<DocumentList {...defaultProps} />, { wrapper: createWrapper() })
// Find svg icons which indicate sortable columns
const sortIcons = document.querySelectorAll('svg')
expect(sortIcons.length).toBeGreaterThan(0)
})
it('should update sort order when sort header is clicked', () => {
render(<DocumentList {...defaultProps} />, { wrapper: createWrapper() })
// Find and click a sort header by its parent div containing the label text
const sortableHeaders = document.querySelectorAll('[class*="cursor-pointer"]')
if (sortableHeaders.length > 0) {
fireEvent.click(sortableHeaders[0])
}
expect(screen.getByRole('table')).toBeInTheDocument()
})
})
describe('Batch Actions', () => {
it('should show batch action bar when documents are selected', () => {
const props = {
...defaultProps,
selectedIds: ['doc-1', 'doc-2'],
}
render(<DocumentList {...props} />, { wrapper: createWrapper() })
// BatchAction component should be visible
expect(screen.getByRole('table')).toBeInTheDocument()
})
it('should not show batch action bar when no documents selected', () => {
render(<DocumentList {...defaultProps} />, { wrapper: createWrapper() })
// BatchAction should not be present
expect(screen.getByRole('table')).toBeInTheDocument()
})
it('should render batch action bar with archive option', () => {
const props = {
...defaultProps,
selectedIds: ['doc-1'],
}
render(<DocumentList {...props} />, { wrapper: createWrapper() })
// BatchAction component should be visible when documents are selected
expect(screen.getByRole('table')).toBeInTheDocument()
})
it('should render batch action bar with enable option', () => {
const props = {
...defaultProps,
selectedIds: ['doc-1'],
}
render(<DocumentList {...props} />, { wrapper: createWrapper() })
expect(screen.getByRole('table')).toBeInTheDocument()
})
it('should render batch action bar with disable option', () => {
const props = {
...defaultProps,
selectedIds: ['doc-1'],
}
render(<DocumentList {...props} />, { wrapper: createWrapper() })
expect(screen.getByRole('table')).toBeInTheDocument()
})
it('should render batch action bar with delete option', () => {
const props = {
...defaultProps,
selectedIds: ['doc-1'],
}
render(<DocumentList {...props} />, { wrapper: createWrapper() })
expect(screen.getByRole('table')).toBeInTheDocument()
})
it('should clear selection when cancel is clicked', () => {
const onSelectedIdChange = vi.fn()
const props = {
...defaultProps,
selectedIds: ['doc-1'],
onSelectedIdChange,
}
render(<DocumentList {...props} />, { wrapper: createWrapper() })
const cancelButton = screen.queryByRole('button', { name: /cancel/i })
if (cancelButton) {
fireEvent.click(cancelButton)
expect(onSelectedIdChange).toHaveBeenCalledWith([])
}
})
it('should show download option for downloadable documents', () => {
const props = {
...defaultProps,
selectedIds: ['doc-1'],
documents: [
createMockDoc({ id: 'doc-1', data_source_type: DataSourceType.FILE }),
],
}
render(<DocumentList {...props} />, { wrapper: createWrapper() })
// BatchAction should be visible
expect(screen.getByRole('table')).toBeInTheDocument()
})
it('should show re-index option for error documents', () => {
const props = {
...defaultProps,
selectedIds: ['doc-1'],
documents: [
createMockDoc({ id: 'doc-1', display_status: 'error' }),
],
}
render(<DocumentList {...props} />, { wrapper: createWrapper() })
// BatchAction with re-index should be present for error documents
expect(screen.getByRole('table')).toBeInTheDocument()
})
})
describe('Row Click Navigation', () => {
it('should navigate to document detail when row is clicked', () => {
render(<DocumentList {...defaultProps} />, { wrapper: createWrapper() })
const rows = screen.getAllByRole('row')
// First row is header, second row is first document
if (rows.length > 1) {
fireEvent.click(rows[1])
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/documents/doc-1')
}
})
})
describe('Rename Modal', () => {
it('should not show rename modal initially', () => {
render(<DocumentList {...defaultProps} />, { wrapper: createWrapper() })
// RenameModal should not be visible initially
const modal = screen.queryByRole('dialog')
expect(modal).not.toBeInTheDocument()
})
it('should show rename modal when rename button is clicked', () => {
const { container } = render(<DocumentList {...defaultProps} />, { wrapper: createWrapper() })
// Find and click the rename button in the first row
const renameButtons = container.querySelectorAll('.cursor-pointer.rounded-md')
if (renameButtons.length > 0) {
fireEvent.click(renameButtons[0])
}
// After clicking rename, the modal should potentially be visible
expect(screen.getByRole('table')).toBeInTheDocument()
})
it('should call onUpdate when document is renamed', () => {
const onUpdate = vi.fn()
const props = { ...defaultProps, onUpdate }
render(<DocumentList {...props} />, { wrapper: createWrapper() })
// The handleRenamed callback wraps onUpdate
expect(screen.getByRole('table')).toBeInTheDocument()
})
})
describe('Edit Metadata Modal', () => {
it('should handle edit metadata action', () => {
const props = {
...defaultProps,
selectedIds: ['doc-1'],
}
render(<DocumentList {...props} />, { wrapper: createWrapper() })
const editButton = screen.queryByRole('button', { name: /metadata/i })
if (editButton) {
fireEvent.click(editButton)
}
expect(screen.getByRole('table')).toBeInTheDocument()
})
it('should call onManageMetadata when manage metadata is triggered', () => {
const onManageMetadata = vi.fn()
const props = {
...defaultProps,
selectedIds: ['doc-1'],
onManageMetadata,
}
render(<DocumentList {...props} />, { wrapper: createWrapper() })
// The onShowManage callback in EditMetadataBatchModal should call hideEditModal then onManageMetadata
expect(screen.getByRole('table')).toBeInTheDocument()
})
})
describe('Chunking Mode', () => {
it('should render with general mode', () => {
render(<DocumentList {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByRole('table')).toBeInTheDocument()
})
it('should render with QA mode', () => {
// This test uses the default mock which returns ChunkingMode.text
// The component will compute isQAMode based on doc_form
render(<DocumentList {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByRole('table')).toBeInTheDocument()
})
it('should render with parent-child mode', () => {
render(<DocumentList {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByRole('table')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle empty documents array', () => {
const props = { ...defaultProps, documents: [] }
render(<DocumentList {...props} />, { wrapper: createWrapper() })
expect(screen.getByRole('table')).toBeInTheDocument()
})
it('should handle documents with missing optional fields', () => {
const docWithMissingFields = createMockDoc({
word_count: undefined as unknown as number,
hit_count: undefined as unknown as number,
})
const props = {
...defaultProps,
documents: [docWithMissingFields],
}
render(<DocumentList {...props} />, { wrapper: createWrapper() })
expect(screen.getByRole('table')).toBeInTheDocument()
})
it('should handle status filter value', () => {
const props = {
...defaultProps,
statusFilterValue: 'completed',
}
render(<DocumentList {...props} />, { wrapper: createWrapper() })
expect(screen.getByRole('table')).toBeInTheDocument()
})
it('should handle remote sort value', () => {
const props = {
...defaultProps,
remoteSortValue: 'created_at',
}
render(<DocumentList {...props} />, { wrapper: createWrapper() })
expect(screen.getByRole('table')).toBeInTheDocument()
})
it('should handle large number of documents', () => {
const manyDocs = Array.from({ length: 20 }, (_, i) =>
createMockDoc({ id: `doc-${i}`, name: `Document ${i}.txt` }))
const props = { ...defaultProps, documents: manyDocs }
render(<DocumentList {...props} />, { wrapper: createWrapper() })
expect(screen.getByRole('table')).toBeInTheDocument()
}, 10000)
})
})

View File

@ -0,0 +1,3 @@
// Re-export from parent for backwards compatibility
export { default } from '../list'
export { renderTdValue } from './components'

View File

@ -1,67 +1,26 @@
'use client'
import type { FC } from 'react'
import type { Props as PaginationProps } from '@/app/components/base/pagination'
import type { CommonResponse } from '@/models/common'
import type { LegacyDataSourceInfo, LocalFileInfo, OnlineDocumentInfo, OnlineDriveInfo, SimpleDocumentDetail } from '@/models/datasets'
import {
RiArrowDownLine,
RiEditLine,
RiGlobalLine,
} from '@remixicon/react'
import type { SimpleDocumentDetail } from '@/models/datasets'
import { useBoolean } from 'ahooks'
import { uniq } from 'es-toolkit/array'
import { pick } from 'es-toolkit/object'
import { useRouter } from 'next/navigation'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, 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'
import { DatasourceType } from '@/models/pipeline'
import { useDocumentArchive, useDocumentBatchRetryIndex, useDocumentDelete, useDocumentDisable, useDocumentDownloadZip, useDocumentEnable, useDocumentSummary } from '@/service/knowledge/use-document'
import { asyncRunSafe } from '@/utils'
import { cn } from '@/utils/classnames'
import { downloadBlob } from '@/utils/download'
import { formatNumber } from '@/utils/format'
import { ChunkingMode, DocumentActionType } from '@/models/datasets'
import BatchAction from '../detail/completed/common/batch-action'
import SummaryStatus from '../detail/completed/common/summary-status'
import StatusItem from '../status-item'
import s from '../style.module.css'
import Operations from './operations'
import { DocumentTableRow, renderTdValue, SortHeader } from './document-list/components'
import { useDocumentActions, useDocumentSelection, useDocumentSort } from './document-list/hooks'
import RenameModal from './rename-modal'
export const renderTdValue = (value: string | number | null, isEmptyStyle = false) => {
return (
<div className={cn(isEmptyStyle ? 'text-text-tertiary' : 'text-text-secondary', s.tdValue)}>
{value ?? '-'}
</div>
)
}
const renderCount = (count: number | undefined) => {
if (!count)
return renderTdValue(0, true)
if (count < 1000)
return count
return `${formatNumber((count / 1000).toFixed(1))}k`
}
type LocalDoc = SimpleDocumentDetail & { percent?: number }
type IDocumentListProps = {
type DocumentListProps = {
embeddingAvailable: boolean
documents: LocalDoc[]
selectedIds: string[]
@ -77,7 +36,7 @@ type IDocumentListProps = {
/**
* Document list component including basic information
*/
const DocumentList: FC<IDocumentListProps> = ({
const DocumentList: FC<DocumentListProps> = ({
embeddingAvailable,
documents = [],
selectedIds,
@ -90,20 +49,43 @@ const DocumentList: FC<IDocumentListProps> = ({
remoteSortValue,
}) => {
const { t } = useTranslation()
const { formatTime } = useTimestamp()
const router = useRouter()
const datasetConfig = useDatasetDetailContext(s => s.dataset)
const chunkingMode = datasetConfig?.doc_form
const isGeneralMode = chunkingMode !== ChunkingMode.parentChild
const isQAMode = chunkingMode === ChunkingMode.qa
const [sortField, setSortField] = useState<'name' | 'word_count' | 'hit_count' | 'created_at' | null>(null)
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc')
useEffect(() => {
setSortField(null)
setSortOrder('desc')
}, [remoteSortValue])
// Sorting
const { sortField, sortOrder, handleSort, sortedDocuments } = useDocumentSort({
documents,
statusFilterValue,
remoteSortValue,
})
// Selection
const {
isAllSelected,
isSomeSelected,
onSelectAll,
onSelectOne,
hasErrorDocumentsSelected,
downloadableSelectedIds,
clearSelection,
} = useDocumentSelection({
documents: sortedDocuments,
selectedIds,
onSelectedIdChange,
})
// Actions
const { handleAction, handleBatchReIndex, handleBatchDownload } = useDocumentActions({
datasetId,
selectedIds,
downloadableSelectedIds,
onUpdate,
onClearSelection: clearSelection,
})
// Batch edit metadata
const {
isShowEditModal,
showEditModal,
@ -113,233 +95,26 @@ const DocumentList: FC<IDocumentListProps> = ({
} = useBatchEditDocumentMetadata({
datasetId,
docList: documents.filter(doc => selectedIds.includes(doc.id)),
selectedDocumentIds: selectedIds, // Pass all selected IDs separately
selectedDocumentIds: selectedIds,
onUpdate,
})
const localDocs = useMemo(() => {
let filteredDocs = documents
if (statusFilterValue && statusFilterValue !== 'all') {
filteredDocs = filteredDocs.filter(doc =>
typeof doc.display_status === 'string'
&& normalizeStatusForQuery(doc.display_status) === statusFilterValue,
)
}
if (!sortField)
return filteredDocs
const sortedDocs = [...filteredDocs].sort((a, b) => {
let aValue: any
let bValue: any
switch (sortField) {
case 'name':
aValue = a.name?.toLowerCase() || ''
bValue = b.name?.toLowerCase() || ''
break
case 'word_count':
aValue = a.word_count || 0
bValue = b.word_count || 0
break
case 'hit_count':
aValue = a.hit_count || 0
bValue = b.hit_count || 0
break
case 'created_at':
aValue = a.created_at
bValue = b.created_at
break
default:
return 0
}
if (sortField === 'name') {
const result = aValue.localeCompare(bValue)
return sortOrder === 'asc' ? result : -result
}
else {
const result = aValue - bValue
return sortOrder === 'asc' ? result : -result
}
})
return sortedDocs
}, [documents, sortField, sortOrder, statusFilterValue])
const handleSort = (field: 'name' | 'word_count' | 'hit_count' | 'created_at') => {
if (sortField === field) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')
}
else {
setSortField(field)
setSortOrder('desc')
}
}
const renderSortHeader = (field: 'name' | 'word_count' | 'hit_count' | 'created_at', label: string) => {
const isActive = sortField === field
const isDesc = isActive && sortOrder === 'desc'
return (
<div className="flex cursor-pointer items-center hover:text-text-secondary" onClick={() => handleSort(field)}>
{label}
<RiArrowDownLine
className={cn('ml-0.5 h-3 w-3 transition-all', isActive ? 'text-text-tertiary' : 'text-text-disabled', isActive && !isDesc ? 'rotate-180' : '')}
/>
</div>
)
}
// Rename modal
const [currDocument, setCurrDocument] = useState<LocalDoc | null>(null)
const [isShowRenameModal, {
setTrue: setShowRenameModalTrue,
setFalse: setShowRenameModalFalse,
}] = useBoolean(false)
const handleShowRenameModal = useCallback((doc: LocalDoc) => {
setCurrDocument(doc)
setShowRenameModalTrue()
}, [setShowRenameModalTrue])
const handleRenamed = useCallback(() => {
onUpdate()
}, [onUpdate])
const isAllSelected = useMemo(() => {
return localDocs.length > 0 && localDocs.every(doc => selectedIds.includes(doc.id))
}, [localDocs, selectedIds])
const isSomeSelected = useMemo(() => {
return localDocs.some(doc => selectedIds.includes(doc.id))
}, [localDocs, selectedIds])
const onSelectedAll = useCallback(() => {
if (isAllSelected)
onSelectedIdChange([])
else
onSelectedIdChange(uniq([...selectedIds, ...localDocs.map(doc => doc.id)]))
}, [isAllSelected, localDocs, onSelectedIdChange, selectedIds])
const { mutateAsync: archiveDocument } = useDocumentArchive()
const { mutateAsync: generateSummary } = useDocumentSummary()
const { mutateAsync: enableDocument } = useDocumentEnable()
const { mutateAsync: disableDocument } = useDocumentDisable()
const { mutateAsync: deleteDocument } = useDocumentDelete()
const { mutateAsync: retryIndexDocument } = useDocumentBatchRetryIndex()
const { mutateAsync: requestDocumentsZip, isPending: isDownloadingZip } = useDocumentDownloadZip()
const handleAction = (actionName: DocumentActionType) => {
return async () => {
let opApi
switch (actionName) {
case DocumentActionType.archive:
opApi = archiveDocument
break
case DocumentActionType.summary:
opApi = generateSummary
break
case DocumentActionType.enable:
opApi = enableDocument
break
case DocumentActionType.disable:
opApi = disableDocument
break
default:
opApi = deleteDocument
break
}
const [e] = await asyncRunSafe<CommonResponse>(opApi({ datasetId, documentIds: selectedIds }) as Promise<CommonResponse>)
if (!e) {
if (actionName === DocumentActionType.delete)
onSelectedIdChange([])
Toast.notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
onUpdate()
}
else { Toast.notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) }) }
}
}
const handleBatchReIndex = async () => {
const [e] = await asyncRunSafe<CommonResponse>(retryIndexDocument({ datasetId, documentIds: selectedIds }))
if (!e) {
onSelectedIdChange([])
Toast.notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
onUpdate()
}
else {
Toast.notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
}
}
const hasErrorDocumentsSelected = useMemo(() => {
return localDocs.some(doc => selectedIds.includes(doc.id) && doc.display_status === 'error')
}, [localDocs, selectedIds])
const getFileExtension = useCallback((fileName: string): string => {
if (!fileName)
return ''
const parts = fileName.split('.')
if (parts.length <= 1 || (parts[0] === '' && parts.length === 2))
return ''
return parts[parts.length - 1].toLowerCase()
}, [])
const isCreateFromRAGPipeline = useCallback((createdFrom: string) => {
return createdFrom === 'rag-pipeline'
}, [])
/**
* Calculate the data source type
* DataSourceType: FILE, NOTION, WEB (legacy)
* DatasourceType: localFile, onlineDocument, websiteCrawl, onlineDrive (new)
*/
const isLocalFile = useCallback((dataSourceType: DataSourceType | DatasourceType) => {
return dataSourceType === DatasourceType.localFile || dataSourceType === DataSourceType.FILE
}, [])
const isOnlineDocument = useCallback((dataSourceType: DataSourceType | DatasourceType) => {
return dataSourceType === DatasourceType.onlineDocument || dataSourceType === DataSourceType.NOTION
}, [])
const isWebsiteCrawl = useCallback((dataSourceType: DataSourceType | DatasourceType) => {
return dataSourceType === DatasourceType.websiteCrawl || dataSourceType === DataSourceType.WEB
}, [])
const isOnlineDrive = useCallback((dataSourceType: DataSourceType | DatasourceType) => {
return dataSourceType === DatasourceType.onlineDrive
}, [])
const downloadableSelectedIds = useMemo(() => {
const selectedSet = new Set(selectedIds)
return localDocs
.filter(doc => selectedSet.has(doc.id) && doc.data_source_type === DataSourceType.FILE)
.map(doc => doc.id)
}, [localDocs, selectedIds])
/**
* Generate a random ZIP filename for bulk document downloads.
* We intentionally avoid leaking dataset info in the exported archive name.
*/
const generateDocsZipFileName = useCallback((): string => {
// Prefer UUID for uniqueness; fall back to time+random when unavailable.
const randomPart = (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')
? crypto.randomUUID()
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
return `${randomPart}-docs.zip`
}, [])
const handleBatchDownload = useCallback(async () => {
if (isDownloadingZip)
return
// Download as a single ZIP to avoid browser caps on multiple automatic downloads.
const [e, blob] = await asyncRunSafe(requestDocumentsZip({ datasetId, documentIds: downloadableSelectedIds }))
if (e || !blob) {
Toast.notify({ type: 'error', message: t('actionMsg.downloadUnsuccessfully', { ns: 'common' }) })
return
}
downloadBlob({ data: blob, fileName: generateDocsZipFileName() })
}, [datasetId, downloadableSelectedIds, generateDocsZipFileName, isDownloadingZip, requestDocumentsZip, t])
return (
<div className="relative mt-3 flex h-full w-full flex-col">
<div className="relative h-0 grow overflow-x-auto">
@ -353,157 +128,76 @@ const DocumentList: FC<IDocumentListProps> = ({
className="mr-2 shrink-0"
checked={isAllSelected}
indeterminate={!isAllSelected && isSomeSelected}
onCheck={onSelectedAll}
onCheck={onSelectAll}
/>
)}
#
</div>
</td>
<td>
{renderSortHeader('name', t('list.table.header.fileName', { ns: 'datasetDocuments' }))}
<SortHeader
field="name"
label={t('list.table.header.fileName', { ns: 'datasetDocuments' })}
currentSortField={sortField}
sortOrder={sortOrder}
onSort={handleSort}
/>
</td>
<td className="w-[130px]">{t('list.table.header.chunkingMode', { ns: 'datasetDocuments' })}</td>
<td className="w-24">
{renderSortHeader('word_count', t('list.table.header.words', { ns: 'datasetDocuments' }))}
<SortHeader
field="word_count"
label={t('list.table.header.words', { ns: 'datasetDocuments' })}
currentSortField={sortField}
sortOrder={sortOrder}
onSort={handleSort}
/>
</td>
<td className="w-44">
{renderSortHeader('hit_count', t('list.table.header.hitCount', { ns: 'datasetDocuments' }))}
<SortHeader
field="hit_count"
label={t('list.table.header.hitCount', { ns: 'datasetDocuments' })}
currentSortField={sortField}
sortOrder={sortOrder}
onSort={handleSort}
/>
</td>
<td className="w-44">
{renderSortHeader('created_at', t('list.table.header.uploadTime', { ns: 'datasetDocuments' }))}
<SortHeader
field="created_at"
label={t('list.table.header.uploadTime', { ns: 'datasetDocuments' })}
currentSortField={sortField}
sortOrder={sortOrder}
onSort={handleSort}
/>
</td>
<td className="w-40">{t('list.table.header.status', { ns: 'datasetDocuments' })}</td>
<td className="w-20">{t('list.table.header.action', { ns: 'datasetDocuments' })}</td>
</tr>
</thead>
<tbody className="text-text-secondary">
{localDocs.map((doc, index) => {
const isFile = isLocalFile(doc.data_source_type)
const fileType = isFile ? doc.data_source_detail_dict?.upload_file?.extension : ''
return (
<tr
key={doc.id}
className="h-8 cursor-pointer border-b border-divider-subtle hover:bg-background-default-hover"
onClick={() => {
router.push(`/datasets/${datasetId}/documents/${doc.id}`)
}}
>
<td className="text-left align-middle text-xs text-text-tertiary">
<div className="flex items-center" onClick={e => e.stopPropagation()}>
<Checkbox
className="mr-2 shrink-0"
checked={selectedIds.includes(doc.id)}
onCheck={() => {
onSelectedIdChange(
selectedIds.includes(doc.id)
? selectedIds.filter(id => id !== doc.id)
: [...selectedIds, doc.id],
)
}}
/>
{index + 1}
</div>
</td>
<td>
<div className="group mr-6 flex max-w-[460px] items-center hover:mr-0">
<div className="flex shrink-0 items-center">
{isOnlineDocument(doc.data_source_type) && (
<NotionIcon
className="mr-1.5"
type="page"
src={
isCreateFromRAGPipeline(doc.created_from)
? (doc.data_source_info as OnlineDocumentInfo).page.page_icon
: (doc.data_source_info as LegacyDataSourceInfo).notion_page_icon
}
/>
)}
{isLocalFile(doc.data_source_type) && (
<FileTypeIcon
type={
extensionToFileType(
isCreateFromRAGPipeline(doc.created_from)
? (doc?.data_source_info as LocalFileInfo)?.extension
: ((doc?.data_source_info as LegacyDataSourceInfo)?.upload_file?.extension ?? fileType),
)
}
className="mr-1.5"
/>
)}
{isOnlineDrive(doc.data_source_type) && (
<FileTypeIcon
type={
extensionToFileType(
getFileExtension((doc?.data_source_info as unknown as OnlineDriveInfo)?.name),
)
}
className="mr-1.5"
/>
)}
{isWebsiteCrawl(doc.data_source_type) && (
<RiGlobalLine className="mr-1.5 size-4" />
)}
</div>
<Tooltip
popupContent={doc.name}
>
<span className="grow-1 truncate text-sm">{doc.name}</span>
</Tooltip>
{
doc.summary_index_status && (
<div className="ml-1 hidden shrink-0 group-hover:flex">
<SummaryStatus status={doc.summary_index_status} />
</div>
)
}
<div className="hidden shrink-0 group-hover:ml-auto group-hover:flex">
<Tooltip
popupContent={t('list.table.rename', { ns: 'datasetDocuments' })}
>
<div
className="cursor-pointer rounded-md p-1 hover:bg-state-base-hover"
onClick={(e) => {
e.stopPropagation()
handleShowRenameModal(doc)
}}
>
<RiEditLine className="h-4 w-4 text-text-tertiary" />
</div>
</Tooltip>
</div>
</div>
</td>
<td>
<ChunkingModeLabel
isGeneralMode={isGeneralMode}
isQAMode={isQAMode}
/>
</td>
<td>{renderCount(doc.word_count)}</td>
<td>{renderCount(doc.hit_count)}</td>
<td className="text-[13px] text-text-secondary">
{formatTime(doc.created_at, t('dateTimeFormat', { ns: 'datasetHitTesting' }) as string)}
</td>
<td>
<StatusItem status={doc.display_status} />
</td>
<td>
<Operations
selectedIds={selectedIds}
onSelectedIdChange={onSelectedIdChange}
embeddingAvailable={embeddingAvailable}
datasetId={datasetId}
detail={pick(doc, ['name', 'enabled', 'archived', 'id', 'data_source_type', 'doc_form', 'display_status'])}
onUpdate={onUpdate}
/>
</td>
</tr>
)
})}
{sortedDocuments.map((doc, index) => (
<DocumentTableRow
key={doc.id}
doc={doc}
index={index}
datasetId={datasetId}
isSelected={selectedIds.includes(doc.id)}
isGeneralMode={isGeneralMode}
isQAMode={isQAMode}
embeddingAvailable={embeddingAvailable}
selectedIds={selectedIds}
onSelectOne={onSelectOne}
onSelectedIdChange={onSelectedIdChange}
onShowRenameModal={handleShowRenameModal}
onUpdate={onUpdate}
/>
))}
</tbody>
</table>
</div>
{(selectedIds.length > 0) && (
{selectedIds.length > 0 && (
<BatchAction
className="absolute bottom-16 left-0 z-20"
selectedIds={selectedIds}
@ -515,12 +209,10 @@ const DocumentList: FC<IDocumentListProps> = ({
onBatchDelete={handleAction(DocumentActionType.delete)}
onEditMetadata={showEditModal}
onBatchReIndex={hasErrorDocumentsSelected ? handleBatchReIndex : undefined}
onCancel={() => {
onSelectedIdChange([])
}}
onCancel={clearSelection}
/>
)}
{/* Show Pagination only if the total is more than the limit */}
{!!pagination.total && (
<Pagination
{...pagination}
@ -556,3 +248,5 @@ const DocumentList: FC<IDocumentListProps> = ({
}
export default DocumentList
export { renderTdValue }

View File

@ -1,351 +0,0 @@
import type { FileListItemProps } from './file-list-item'
import type { CustomFile as File, FileItem } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../constants'
import FileListItem from './file-list-item'
// Mock theme hook - can be changed per test
let mockTheme = 'light'
vi.mock('@/hooks/use-theme', () => ({
default: () => ({ theme: mockTheme }),
}))
// Mock theme types
vi.mock('@/types/app', () => ({
Theme: { dark: 'dark', light: 'light' },
}))
// Mock SimplePieChart with dynamic import handling
vi.mock('next/dynamic', () => ({
default: () => {
const DynamicComponent = ({ percentage, stroke, fill }: { percentage: number, stroke: string, fill: string }) => (
<div data-testid="pie-chart" data-percentage={percentage} data-stroke={stroke} data-fill={fill}>
Pie Chart:
{' '}
{percentage}
%
</div>
)
DynamicComponent.displayName = 'SimplePieChart'
return DynamicComponent
},
}))
// Mock DocumentFileIcon
vi.mock('@/app/components/datasets/common/document-file-icon', () => ({
default: ({ name, extension, size }: { name: string, extension: string, size: string }) => (
<div data-testid="document-icon" data-name={name} data-extension={extension} data-size={size}>
Document Icon
</div>
),
}))
describe('FileListItem', () => {
const createMockFile = (overrides: Partial<File> = {}): File => ({
name: 'test-document.pdf',
size: 1024 * 100, // 100KB
type: 'application/pdf',
lastModified: Date.now(),
...overrides,
} as File)
const createMockFileItem = (overrides: Partial<FileItem> = {}): FileItem => ({
fileID: 'file-123',
file: createMockFile(overrides.file as Partial<File>),
progress: PROGRESS_NOT_STARTED,
...overrides,
})
const defaultProps: FileListItemProps = {
fileItem: createMockFileItem(),
onPreview: vi.fn(),
onRemove: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('rendering', () => {
it('should render the file item container', () => {
const { container } = render(<FileListItem {...defaultProps} />)
const item = container.firstChild as HTMLElement
expect(item).toHaveClass('flex', 'h-12', 'items-center', 'rounded-lg')
})
it('should render document icon with correct props', () => {
render(<FileListItem {...defaultProps} />)
const icon = screen.getByTestId('document-icon')
expect(icon).toBeInTheDocument()
expect(icon).toHaveAttribute('data-name', 'test-document.pdf')
expect(icon).toHaveAttribute('data-extension', 'pdf')
expect(icon).toHaveAttribute('data-size', 'lg')
})
it('should render file name', () => {
render(<FileListItem {...defaultProps} />)
expect(screen.getByText('test-document.pdf')).toBeInTheDocument()
})
it('should render file extension in uppercase via CSS class', () => {
render(<FileListItem {...defaultProps} />)
// Extension is rendered in lowercase but styled with uppercase CSS
const extensionSpan = screen.getByText('pdf')
expect(extensionSpan).toBeInTheDocument()
expect(extensionSpan).toHaveClass('uppercase')
})
it('should render file size', () => {
render(<FileListItem {...defaultProps} />)
// 100KB (102400 bytes) formatted with formatFileSize
expect(screen.getByText('100.00 KB')).toBeInTheDocument()
})
it('should render delete button', () => {
const { container } = render(<FileListItem {...defaultProps} />)
const deleteButton = container.querySelector('.cursor-pointer')
expect(deleteButton).toBeInTheDocument()
})
})
describe('progress states', () => {
it('should show progress chart when uploading (0-99)', () => {
const fileItem = createMockFileItem({ progress: 50 })
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
const pieChart = screen.getByTestId('pie-chart')
expect(pieChart).toBeInTheDocument()
expect(pieChart).toHaveAttribute('data-percentage', '50')
})
it('should show progress chart at 0%', () => {
const fileItem = createMockFileItem({ progress: 0 })
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
const pieChart = screen.getByTestId('pie-chart')
expect(pieChart).toHaveAttribute('data-percentage', '0')
})
it('should not show progress chart when complete (100)', () => {
const fileItem = createMockFileItem({ progress: 100 })
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
expect(screen.queryByTestId('pie-chart')).not.toBeInTheDocument()
})
it('should not show progress chart when not started (-1)', () => {
const fileItem = createMockFileItem({ progress: PROGRESS_NOT_STARTED })
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
expect(screen.queryByTestId('pie-chart')).not.toBeInTheDocument()
})
})
describe('error state', () => {
it('should show error icon when progress is PROGRESS_ERROR', () => {
const fileItem = createMockFileItem({ progress: PROGRESS_ERROR })
const { container } = render(<FileListItem {...defaultProps} fileItem={fileItem} />)
const errorIcon = container.querySelector('.text-text-destructive')
expect(errorIcon).toBeInTheDocument()
})
it('should apply error styling to container', () => {
const fileItem = createMockFileItem({ progress: PROGRESS_ERROR })
const { container } = render(<FileListItem {...defaultProps} fileItem={fileItem} />)
const item = container.firstChild as HTMLElement
expect(item).toHaveClass('border-state-destructive-border', 'bg-state-destructive-hover')
})
it('should not show error styling when not in error state', () => {
const { container } = render(<FileListItem {...defaultProps} />)
const item = container.firstChild as HTMLElement
expect(item).not.toHaveClass('border-state-destructive-border')
})
})
describe('theme handling', () => {
it('should use correct chart color for light theme', () => {
mockTheme = 'light'
const fileItem = createMockFileItem({ progress: 50 })
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
const pieChart = screen.getByTestId('pie-chart')
expect(pieChart).toHaveAttribute('data-stroke', '#296dff')
expect(pieChart).toHaveAttribute('data-fill', '#296dff')
})
it('should use correct chart color for dark theme', () => {
mockTheme = 'dark'
const fileItem = createMockFileItem({ progress: 50 })
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
const pieChart = screen.getByTestId('pie-chart')
expect(pieChart).toHaveAttribute('data-stroke', '#5289ff')
expect(pieChart).toHaveAttribute('data-fill', '#5289ff')
})
})
describe('event handlers', () => {
it('should call onPreview when item is clicked', () => {
const onPreview = vi.fn()
const fileItem = createMockFileItem()
render(<FileListItem {...defaultProps} fileItem={fileItem} onPreview={onPreview} />)
const item = screen.getByText('test-document.pdf').closest('[class*="flex h-12"]')!
fireEvent.click(item)
expect(onPreview).toHaveBeenCalledTimes(1)
expect(onPreview).toHaveBeenCalledWith(fileItem.file)
})
it('should call onRemove when delete button is clicked', () => {
const onRemove = vi.fn()
const fileItem = createMockFileItem()
const { container } = render(<FileListItem {...defaultProps} fileItem={fileItem} onRemove={onRemove} />)
const deleteButton = container.querySelector('.cursor-pointer')!
fireEvent.click(deleteButton)
expect(onRemove).toHaveBeenCalledTimes(1)
expect(onRemove).toHaveBeenCalledWith('file-123')
})
it('should stop propagation when delete button is clicked', () => {
const onPreview = vi.fn()
const onRemove = vi.fn()
const { container } = render(<FileListItem {...defaultProps} onPreview={onPreview} onRemove={onRemove} />)
const deleteButton = container.querySelector('.cursor-pointer')!
fireEvent.click(deleteButton)
expect(onRemove).toHaveBeenCalledTimes(1)
expect(onPreview).not.toHaveBeenCalled()
})
})
describe('file type handling', () => {
it('should handle files with multiple dots in name', () => {
const fileItem = createMockFileItem({
file: createMockFile({ name: 'my.document.file.docx' }),
})
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
expect(screen.getByText('my.document.file.docx')).toBeInTheDocument()
// Extension is lowercase with uppercase CSS class
expect(screen.getByText('docx')).toBeInTheDocument()
})
it('should handle files without extension', () => {
const fileItem = createMockFileItem({
file: createMockFile({ name: 'README' }),
})
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
// getFileType returns 'README' when there's no extension (last part after split)
expect(screen.getAllByText('README')).toHaveLength(2) // filename and extension
})
it('should handle various file extensions', () => {
const extensions = ['txt', 'md', 'json', 'csv', 'xlsx']
extensions.forEach((ext) => {
const fileItem = createMockFileItem({
file: createMockFile({ name: `file.${ext}` }),
})
const { unmount } = render(<FileListItem {...defaultProps} fileItem={fileItem} />)
// Extension is rendered in lowercase with uppercase CSS class
expect(screen.getByText(ext)).toBeInTheDocument()
unmount()
})
})
})
describe('file size display', () => {
it('should display size in KB for small files', () => {
const fileItem = createMockFileItem({
file: createMockFile({ size: 5 * 1024 }), // 5KB
})
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
expect(screen.getByText('5.00 KB')).toBeInTheDocument()
})
it('should display size in MB for larger files', () => {
const fileItem = createMockFileItem({
file: createMockFile({ size: 5 * 1024 * 1024 }), // 5MB
})
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
expect(screen.getByText('5.00 MB')).toBeInTheDocument()
})
it('should display size at threshold (10KB)', () => {
const fileItem = createMockFileItem({
file: createMockFile({ size: 10 * 1024 }), // 10KB
})
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
expect(screen.getByText('10.00 KB')).toBeInTheDocument()
})
})
describe('upload progress values', () => {
it('should show chart at progress 1', () => {
const fileItem = createMockFileItem({ progress: 1 })
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
expect(screen.getByTestId('pie-chart')).toBeInTheDocument()
})
it('should show chart at progress 99', () => {
const fileItem = createMockFileItem({ progress: 99 })
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
expect(screen.getByTestId('pie-chart')).toHaveAttribute('data-percentage', '99')
})
it('should not show chart at progress 100', () => {
const fileItem = createMockFileItem({ progress: 100 })
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
expect(screen.queryByTestId('pie-chart')).not.toBeInTheDocument()
})
})
describe('styling', () => {
it('should have proper shadow styling', () => {
const { container } = render(<FileListItem {...defaultProps} />)
const item = container.firstChild as HTMLElement
expect(item).toHaveClass('shadow-xs')
})
it('should have proper border styling', () => {
const { container } = render(<FileListItem {...defaultProps} />)
const item = container.firstChild as HTMLElement
expect(item).toHaveClass('border', 'border-components-panel-border')
})
it('should truncate long file names', () => {
const longFileName = 'this-is-a-very-long-file-name-that-should-be-truncated.pdf'
const fileItem = createMockFileItem({
file: createMockFile({ name: longFileName }),
})
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
const nameElement = screen.getByText(longFileName)
expect(nameElement).toHaveClass('truncate')
})
})
})

View File

@ -1,85 +0,0 @@
import type { CustomFile as File, FileItem } from '@/models/datasets'
import { RiDeleteBinLine, RiErrorWarningFill } from '@remixicon/react'
import dynamic from 'next/dynamic'
import { useMemo } from 'react'
import DocumentFileIcon from '@/app/components/datasets/common/document-file-icon'
import { getFileType } from '@/app/components/datasets/common/image-uploader/utils'
import useTheme from '@/hooks/use-theme'
import { Theme } from '@/types/app'
import { cn } from '@/utils/classnames'
import { formatFileSize } from '@/utils/format'
import { PROGRESS_ERROR } from '../constants'
const SimplePieChart = dynamic(() => import('@/app/components/base/simple-pie-chart'), { ssr: false })
export type FileListItemProps = {
fileItem: FileItem
onPreview: (file: File) => void
onRemove: (fileID: string) => void
}
const FileListItem = ({
fileItem,
onPreview,
onRemove,
}: FileListItemProps) => {
const { theme } = useTheme()
const chartColor = useMemo(() => theme === Theme.dark ? '#5289ff' : '#296dff', [theme])
const isUploading = fileItem.progress >= 0 && fileItem.progress < 100
const isError = fileItem.progress === PROGRESS_ERROR
const handleClick = () => {
onPreview(fileItem.file)
}
const handleRemove = (e: React.MouseEvent) => {
e.stopPropagation()
onRemove(fileItem.fileID)
}
return (
<div
onClick={handleClick}
className={cn(
'flex h-12 items-center rounded-lg border border-components-panel-border bg-components-panel-on-panel-item-bg shadow-xs shadow-shadow-shadow-4',
isError && 'border-state-destructive-border bg-state-destructive-hover',
)}
>
<div className="flex w-12 shrink-0 items-center justify-center">
<DocumentFileIcon
size="lg"
className="shrink-0"
name={fileItem.file.name}
extension={getFileType(fileItem.file)}
/>
</div>
<div className="flex shrink grow flex-col gap-0.5">
<div className="flex w-full">
<div className="w-0 grow truncate text-xs text-text-secondary">{fileItem.file.name}</div>
</div>
<div className="w-full truncate text-2xs leading-3 text-text-tertiary">
<span className="uppercase">{getFileType(fileItem.file)}</span>
<span className="px-1 text-text-quaternary">·</span>
<span>{formatFileSize(fileItem.file.size)}</span>
</div>
</div>
<div className="flex w-16 shrink-0 items-center justify-end gap-1 pr-3">
{isUploading && (
<SimplePieChart percentage={fileItem.progress} stroke={chartColor} fill={chartColor} animationDuration={0} />
)}
{isError && (
<RiErrorWarningFill className="size-4 text-text-destructive" />
)}
<span
className="flex h-6 w-6 cursor-pointer items-center justify-center"
onClick={handleRemove}
>
<RiDeleteBinLine className="size-4 text-text-tertiary" />
</span>
</div>
</div>
)
}
export default FileListItem

View File

@ -1,231 +0,0 @@
import type { RefObject } from 'react'
import type { UploadDropzoneProps } from './upload-dropzone'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import UploadDropzone from './upload-dropzone'
// Helper to create mock ref objects for testing
const createMockRef = <T,>(value: T | null = null): RefObject<T | null> => ({ current: value })
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string }) => {
const translations: Record<string, string> = {
'stepOne.uploader.button': 'Drag and drop files, or',
'stepOne.uploader.buttonSingleFile': 'Drag and drop file, or',
'stepOne.uploader.browse': 'Browse',
'stepOne.uploader.tip': 'Supports {{supportTypes}}, Max {{size}}MB each, up to {{batchCount}} files at a time, {{totalCount}} files total',
}
let result = translations[key] || key
if (options && typeof options === 'object') {
Object.entries(options).forEach(([k, v]) => {
result = result.replace(`{{${k}}}`, String(v))
})
}
return result
},
}),
}))
describe('UploadDropzone', () => {
const defaultProps: UploadDropzoneProps = {
dropRef: createMockRef<HTMLDivElement>() as RefObject<HTMLDivElement | null>,
dragRef: createMockRef<HTMLDivElement>() as RefObject<HTMLDivElement | null>,
fileUploaderRef: createMockRef<HTMLInputElement>() as RefObject<HTMLInputElement | null>,
dragging: false,
supportBatchUpload: true,
supportTypesShowNames: 'PDF, DOCX, TXT',
fileUploadConfig: {
file_size_limit: 15,
batch_count_limit: 5,
file_upload_limit: 10,
},
acceptTypes: ['.pdf', '.docx', '.txt'],
onSelectFile: vi.fn(),
onFileChange: vi.fn(),
allowedExtensions: ['pdf', 'docx', 'txt'],
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('rendering', () => {
it('should render the dropzone container', () => {
const { container } = render(<UploadDropzone {...defaultProps} />)
const dropzone = container.querySelector('[class*="border-dashed"]')
expect(dropzone).toBeInTheDocument()
})
it('should render hidden file input', () => {
render(<UploadDropzone {...defaultProps} />)
const input = document.getElementById('fileUploader') as HTMLInputElement
expect(input).toBeInTheDocument()
expect(input).toHaveClass('hidden')
expect(input).toHaveAttribute('type', 'file')
})
it('should render upload icon', () => {
render(<UploadDropzone {...defaultProps} />)
const icon = document.querySelector('svg')
expect(icon).toBeInTheDocument()
})
it('should render browse label when extensions are allowed', () => {
render(<UploadDropzone {...defaultProps} />)
expect(screen.getByText('Browse')).toBeInTheDocument()
})
it('should not render browse label when no extensions allowed', () => {
render(<UploadDropzone {...defaultProps} allowedExtensions={[]} />)
expect(screen.queryByText('Browse')).not.toBeInTheDocument()
})
it('should render file size and count limits', () => {
render(<UploadDropzone {...defaultProps} />)
const tipText = screen.getByText(/Supports.*Max.*15MB/i)
expect(tipText).toBeInTheDocument()
})
})
describe('file input configuration', () => {
it('should allow multiple files when supportBatchUpload is true', () => {
render(<UploadDropzone {...defaultProps} supportBatchUpload={true} />)
const input = document.getElementById('fileUploader') as HTMLInputElement
expect(input).toHaveAttribute('multiple')
})
it('should not allow multiple files when supportBatchUpload is false', () => {
render(<UploadDropzone {...defaultProps} supportBatchUpload={false} />)
const input = document.getElementById('fileUploader') as HTMLInputElement
expect(input).not.toHaveAttribute('multiple')
})
it('should set accept attribute with correct types', () => {
render(<UploadDropzone {...defaultProps} acceptTypes={['.pdf', '.docx']} />)
const input = document.getElementById('fileUploader') as HTMLInputElement
expect(input).toHaveAttribute('accept', '.pdf,.docx')
})
})
describe('text content', () => {
it('should show batch upload text when supportBatchUpload is true', () => {
render(<UploadDropzone {...defaultProps} supportBatchUpload={true} />)
expect(screen.getByText(/Drag and drop files/i)).toBeInTheDocument()
})
it('should show single file text when supportBatchUpload is false', () => {
render(<UploadDropzone {...defaultProps} supportBatchUpload={false} />)
expect(screen.getByText(/Drag and drop file/i)).toBeInTheDocument()
})
})
describe('dragging state', () => {
it('should apply dragging styles when dragging is true', () => {
const { container } = render(<UploadDropzone {...defaultProps} dragging={true} />)
const dropzone = container.querySelector('[class*="border-components-dropzone-border-accent"]')
expect(dropzone).toBeInTheDocument()
})
it('should render drag overlay when dragging', () => {
const dragRef = createMockRef<HTMLDivElement>()
render(<UploadDropzone {...defaultProps} dragging={true} dragRef={dragRef as RefObject<HTMLDivElement | null>} />)
const overlay = document.querySelector('.absolute.left-0.top-0')
expect(overlay).toBeInTheDocument()
})
it('should not render drag overlay when not dragging', () => {
render(<UploadDropzone {...defaultProps} dragging={false} />)
const overlay = document.querySelector('.absolute.left-0.top-0')
expect(overlay).not.toBeInTheDocument()
})
})
describe('event handlers', () => {
it('should call onSelectFile when browse label is clicked', () => {
const onSelectFile = vi.fn()
render(<UploadDropzone {...defaultProps} onSelectFile={onSelectFile} />)
const browseLabel = screen.getByText('Browse')
fireEvent.click(browseLabel)
expect(onSelectFile).toHaveBeenCalledTimes(1)
})
it('should call onFileChange when files are selected', () => {
const onFileChange = vi.fn()
render(<UploadDropzone {...defaultProps} onFileChange={onFileChange} />)
const input = document.getElementById('fileUploader') as HTMLInputElement
const file = new File(['content'], 'test.pdf', { type: 'application/pdf' })
fireEvent.change(input, { target: { files: [file] } })
expect(onFileChange).toHaveBeenCalledTimes(1)
})
})
describe('refs', () => {
it('should attach dropRef to drop container', () => {
const dropRef = createMockRef<HTMLDivElement>()
render(<UploadDropzone {...defaultProps} dropRef={dropRef as RefObject<HTMLDivElement | null>} />)
expect(dropRef.current).toBeInstanceOf(HTMLDivElement)
})
it('should attach fileUploaderRef to input element', () => {
const fileUploaderRef = createMockRef<HTMLInputElement>()
render(<UploadDropzone {...defaultProps} fileUploaderRef={fileUploaderRef as RefObject<HTMLInputElement | null>} />)
expect(fileUploaderRef.current).toBeInstanceOf(HTMLInputElement)
})
it('should attach dragRef to overlay when dragging', () => {
const dragRef = createMockRef<HTMLDivElement>()
render(<UploadDropzone {...defaultProps} dragging={true} dragRef={dragRef as RefObject<HTMLDivElement | null>} />)
expect(dragRef.current).toBeInstanceOf(HTMLDivElement)
})
})
describe('styling', () => {
it('should have base dropzone styling', () => {
const { container } = render(<UploadDropzone {...defaultProps} />)
const dropzone = container.querySelector('[class*="border-dashed"]')
expect(dropzone).toBeInTheDocument()
expect(dropzone).toHaveClass('rounded-xl')
})
it('should have cursor-pointer on browse label', () => {
render(<UploadDropzone {...defaultProps} />)
const browseLabel = screen.getByText('Browse')
expect(browseLabel).toHaveClass('cursor-pointer')
})
})
describe('accessibility', () => {
it('should have an accessible file input', () => {
render(<UploadDropzone {...defaultProps} />)
const input = document.getElementById('fileUploader') as HTMLInputElement
expect(input).toHaveAttribute('id', 'fileUploader')
})
})
})

View File

@ -1,83 +0,0 @@
import type { ChangeEvent, RefObject } from 'react'
import { RiUploadCloud2Line } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/utils/classnames'
type FileUploadConfig = {
file_size_limit: number
batch_count_limit: number
file_upload_limit: number
}
export type UploadDropzoneProps = {
dropRef: RefObject<HTMLDivElement | null>
dragRef: RefObject<HTMLDivElement | null>
fileUploaderRef: RefObject<HTMLInputElement | null>
dragging: boolean
supportBatchUpload: boolean
supportTypesShowNames: string
fileUploadConfig: FileUploadConfig
acceptTypes: string[]
onSelectFile: () => void
onFileChange: (e: ChangeEvent<HTMLInputElement>) => void
allowedExtensions: string[]
}
const UploadDropzone = ({
dropRef,
dragRef,
fileUploaderRef,
dragging,
supportBatchUpload,
supportTypesShowNames,
fileUploadConfig,
acceptTypes,
onSelectFile,
onFileChange,
allowedExtensions,
}: UploadDropzoneProps) => {
const { t } = useTranslation()
return (
<>
<input
ref={fileUploaderRef}
id="fileUploader"
className="hidden"
type="file"
multiple={supportBatchUpload}
accept={acceptTypes.join(',')}
onChange={onFileChange}
/>
<div
ref={dropRef}
className={cn(
'relative box-border flex min-h-20 flex-col items-center justify-center gap-1 rounded-xl border border-dashed border-components-dropzone-border bg-components-dropzone-bg px-4 py-3 text-xs leading-4 text-text-tertiary',
dragging && 'border-components-dropzone-border-accent bg-components-dropzone-bg-accent',
)}
>
<div className="flex min-h-5 items-center justify-center text-sm leading-4 text-text-secondary">
<RiUploadCloud2Line className="mr-2 size-5" />
<span>
{supportBatchUpload ? t('stepOne.uploader.button', { ns: 'datasetCreation' }) : t('stepOne.uploader.buttonSingleFile', { ns: 'datasetCreation' })}
{allowedExtensions.length > 0 && (
<label className="ml-1 cursor-pointer text-text-accent" onClick={onSelectFile}>{t('stepOne.uploader.browse', { ns: 'datasetCreation' })}</label>
)}
</span>
</div>
<div>
{t('stepOne.uploader.tip', {
ns: 'datasetCreation',
size: fileUploadConfig.file_size_limit,
supportTypes: supportTypesShowNames,
batchCount: fileUploadConfig.batch_count_limit,
totalCount: fileUploadConfig.file_upload_limit,
})}
</div>
{dragging && <div ref={dragRef} className="absolute left-0 top-0 h-full w-full" />}
</div>
</>
)
}
export default UploadDropzone

View File

@ -1,3 +0,0 @@
export const PROGRESS_NOT_STARTED = -1
export const PROGRESS_ERROR = -2
export const PROGRESS_COMPLETE = 100

View File

@ -1,911 +0,0 @@
import type { ReactNode } from 'react'
import type { CustomFile, FileItem } from '@/models/datasets'
import { act, render, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../constants'
// Mock notify function - defined before mocks
const mockNotify = vi.fn()
const mockClose = vi.fn()
// Mock ToastContext with factory function
vi.mock('@/app/components/base/toast', async () => {
const { createContext, useContext } = await import('use-context-selector')
const context = createContext({ notify: mockNotify, close: mockClose })
return {
ToastContext: context,
useToastContext: () => useContext(context),
}
})
// Mock file uploader utils
vi.mock('@/app/components/base/file-uploader/utils', () => ({
getFileUploadErrorMessage: (e: Error, defaultMsg: string) => e.message || defaultMsg,
}))
// Mock format utils used by the shared hook
vi.mock('@/utils/format', () => ({
getFileExtension: (filename: string) => {
const parts = filename.split('.')
return parts[parts.length - 1] || ''
},
}))
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock locale context
vi.mock('@/context/i18n', () => ({
useLocale: () => 'en-US',
}))
// Mock i18n config
vi.mock('@/i18n-config/language', () => ({
LanguagesSupported: ['en-US', 'zh-Hans'],
}))
// Mock config
vi.mock('@/config', () => ({
IS_CE_EDITION: false,
}))
// Mock store functions
const mockSetLocalFileList = vi.fn()
const mockSetCurrentLocalFile = vi.fn()
const mockGetState = vi.fn(() => ({
setLocalFileList: mockSetLocalFileList,
setCurrentLocalFile: mockSetCurrentLocalFile,
}))
const mockStore = { getState: mockGetState }
vi.mock('../../store', () => ({
useDataSourceStoreWithSelector: vi.fn((selector: (state: { localFileList: FileItem[] }) => FileItem[]) =>
selector({ localFileList: [] }),
),
useDataSourceStore: vi.fn(() => mockStore),
}))
// Mock file upload config
vi.mock('@/service/use-common', () => ({
useFileUploadConfig: vi.fn(() => ({
data: {
file_size_limit: 15,
batch_count_limit: 5,
file_upload_limit: 10,
},
})),
// Required by the shared useFileUpload hook
useFileSupportTypes: vi.fn(() => ({
data: {
allowed_extensions: ['pdf', 'docx', 'txt'],
},
})),
}))
// Mock upload service
const mockUpload = vi.fn()
vi.mock('@/service/base', () => ({
upload: (...args: unknown[]) => mockUpload(...args),
}))
// Import after all mocks are set up
const { useLocalFileUpload } = await import('./use-local-file-upload')
const { ToastContext } = await import('@/app/components/base/toast')
const createWrapper = () => {
return ({ children }: { children: ReactNode }) => (
<ToastContext.Provider value={{ notify: mockNotify, close: mockClose }}>
{children}
</ToastContext.Provider>
)
}
describe('useLocalFileUpload', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUpload.mockReset()
})
describe('initialization', () => {
it('should initialize with default values', () => {
const { result } = renderHook(
() => useLocalFileUpload({ allowedExtensions: ['pdf', 'docx'] }),
{ wrapper: createWrapper() },
)
expect(result.current.dragging).toBe(false)
expect(result.current.localFileList).toEqual([])
expect(result.current.hideUpload).toBe(false)
})
it('should create refs for dropzone, drag area, and file uploader', () => {
const { result } = renderHook(
() => useLocalFileUpload({ allowedExtensions: ['pdf'] }),
{ wrapper: createWrapper() },
)
expect(result.current.dropRef).toBeDefined()
expect(result.current.dragRef).toBeDefined()
expect(result.current.fileUploaderRef).toBeDefined()
})
it('should compute acceptTypes from allowedExtensions', () => {
const { result } = renderHook(
() => useLocalFileUpload({ allowedExtensions: ['pdf', 'docx', 'txt'] }),
{ wrapper: createWrapper() },
)
expect(result.current.acceptTypes).toEqual(['.pdf', '.docx', '.txt'])
})
it('should compute supportTypesShowNames correctly', () => {
const { result } = renderHook(
() => useLocalFileUpload({ allowedExtensions: ['pdf', 'docx', 'md'] }),
{ wrapper: createWrapper() },
)
expect(result.current.supportTypesShowNames).toContain('PDF')
expect(result.current.supportTypesShowNames).toContain('DOCX')
expect(result.current.supportTypesShowNames).toContain('MARKDOWN')
})
it('should provide file upload config with defaults', () => {
const { result } = renderHook(
() => useLocalFileUpload({ allowedExtensions: ['pdf'] }),
{ wrapper: createWrapper() },
)
expect(result.current.fileUploadConfig.file_size_limit).toBe(15)
expect(result.current.fileUploadConfig.batch_count_limit).toBe(5)
expect(result.current.fileUploadConfig.file_upload_limit).toBe(10)
})
})
describe('supportBatchUpload option', () => {
it('should use batch limits when supportBatchUpload is true', () => {
const { result } = renderHook(
() => useLocalFileUpload({ allowedExtensions: ['pdf'], supportBatchUpload: true }),
{ wrapper: createWrapper() },
)
expect(result.current.fileUploadConfig.batch_count_limit).toBe(5)
expect(result.current.fileUploadConfig.file_upload_limit).toBe(10)
})
it('should use single file limits when supportBatchUpload is false', () => {
const { result } = renderHook(
() => useLocalFileUpload({ allowedExtensions: ['pdf'], supportBatchUpload: false }),
{ wrapper: createWrapper() },
)
expect(result.current.fileUploadConfig.batch_count_limit).toBe(1)
expect(result.current.fileUploadConfig.file_upload_limit).toBe(1)
})
})
describe('selectHandle', () => {
it('should trigger file input click', () => {
const { result } = renderHook(
() => useLocalFileUpload({ allowedExtensions: ['pdf'] }),
{ wrapper: createWrapper() },
)
const mockClick = vi.fn()
const mockInput = { click: mockClick } as unknown as HTMLInputElement
Object.defineProperty(result.current.fileUploaderRef, 'current', {
value: mockInput,
writable: true,
})
act(() => {
result.current.selectHandle()
})
expect(mockClick).toHaveBeenCalled()
})
it('should handle null fileUploaderRef gracefully', () => {
const { result } = renderHook(
() => useLocalFileUpload({ allowedExtensions: ['pdf'] }),
{ wrapper: createWrapper() },
)
expect(() => {
act(() => {
result.current.selectHandle()
})
}).not.toThrow()
})
})
describe('removeFile', () => {
it('should remove file from list', () => {
const { result } = renderHook(
() => useLocalFileUpload({ allowedExtensions: ['pdf'] }),
{ wrapper: createWrapper() },
)
act(() => {
result.current.removeFile('file-id-123')
})
expect(mockSetLocalFileList).toHaveBeenCalled()
})
it('should clear file input value when removing', () => {
const { result } = renderHook(
() => useLocalFileUpload({ allowedExtensions: ['pdf'] }),
{ wrapper: createWrapper() },
)
const mockInput = { value: 'some-file.pdf' } as HTMLInputElement
Object.defineProperty(result.current.fileUploaderRef, 'current', {
value: mockInput,
writable: true,
})
act(() => {
result.current.removeFile('file-id')
})
expect(mockInput.value).toBe('')
})
})
describe('handlePreview', () => {
it('should set current local file when file has id', () => {
const { result } = renderHook(
() => useLocalFileUpload({ allowedExtensions: ['pdf'] }),
{ wrapper: createWrapper() },
)
const mockFile = { id: 'file-123', name: 'test.pdf', size: 1024 }
act(() => {
result.current.handlePreview(mockFile as unknown as CustomFile)
})
expect(mockSetCurrentLocalFile).toHaveBeenCalledWith(mockFile)
})
it('should not set current file when file has no id', () => {
const { result } = renderHook(
() => useLocalFileUpload({ allowedExtensions: ['pdf'] }),
{ wrapper: createWrapper() },
)
const mockFile = { name: 'test.pdf', size: 1024 }
act(() => {
result.current.handlePreview(mockFile as unknown as CustomFile)
})
expect(mockSetCurrentLocalFile).not.toHaveBeenCalled()
})
})
describe('fileChangeHandle', () => {
it('should handle valid files', async () => {
mockUpload.mockResolvedValue({ id: 'uploaded-id' })
const { result } = renderHook(
() => useLocalFileUpload({ allowedExtensions: ['pdf'] }),
{ wrapper: createWrapper() },
)
const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' })
const event = {
target: {
files: [mockFile],
},
} as unknown as React.ChangeEvent<HTMLInputElement>
act(() => {
result.current.fileChangeHandle(event)
})
await waitFor(() => {
expect(mockSetLocalFileList).toHaveBeenCalled()
})
})
it('should handle empty file list', () => {
const { result } = renderHook(
() => useLocalFileUpload({ allowedExtensions: ['pdf'] }),
{ wrapper: createWrapper() },
)
const event = {
target: {
files: null,
},
} as unknown as React.ChangeEvent<HTMLInputElement>
act(() => {
result.current.fileChangeHandle(event)
})
expect(mockSetLocalFileList).not.toHaveBeenCalled()
})
it('should reject files with invalid type', () => {
const { result } = renderHook(
() => useLocalFileUpload({ allowedExtensions: ['pdf'] }),
{ wrapper: createWrapper() },
)
const mockFile = new File(['content'], 'test.exe', { type: 'application/exe' })
const event = {
target: {
files: [mockFile],
},
} as unknown as React.ChangeEvent<HTMLInputElement>
act(() => {
result.current.fileChangeHandle(event)
})
expect(mockNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
)
})
it('should reject files exceeding size limit', () => {
const { result } = renderHook(
() => useLocalFileUpload({ allowedExtensions: ['pdf'] }),
{ wrapper: createWrapper() },
)
// Create a mock file larger than 15MB
const largeSize = 20 * 1024 * 1024
const mockFile = new File([''], 'large.pdf', { type: 'application/pdf' })
Object.defineProperty(mockFile, 'size', { value: largeSize })
const event = {
target: {
files: [mockFile],
},
} as unknown as React.ChangeEvent<HTMLInputElement>
act(() => {
result.current.fileChangeHandle(event)
})
expect(mockNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
)
})
it('should limit files to batch count limit', async () => {
mockUpload.mockResolvedValue({ id: 'uploaded-id' })
const { result } = renderHook(
() => useLocalFileUpload({ allowedExtensions: ['pdf'] }),
{ wrapper: createWrapper() },
)
// Create 10 files but batch limit is 5
const files = Array.from({ length: 10 }, (_, i) =>
new File(['content'], `file${i}.pdf`, { type: 'application/pdf' }))
const event = {
target: {
files,
},
} as unknown as React.ChangeEvent<HTMLInputElement>
act(() => {
result.current.fileChangeHandle(event)
})
await waitFor(() => {
expect(mockSetLocalFileList).toHaveBeenCalled()
})
// Should only process first 5 files (batch_count_limit)
const firstCall = mockSetLocalFileList.mock.calls[0]
expect(firstCall[0].length).toBeLessThanOrEqual(5)
})
})
describe('upload handling', () => {
it('should handle successful upload', async () => {
const uploadedResponse = { id: 'server-file-id' }
mockUpload.mockResolvedValue(uploadedResponse)
const { result } = renderHook(
() => useLocalFileUpload({ allowedExtensions: ['pdf'] }),
{ wrapper: createWrapper() },
)
const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' })
const event = {
target: {
files: [mockFile],
},
} as unknown as React.ChangeEvent<HTMLInputElement>
act(() => {
result.current.fileChangeHandle(event)
})
await waitFor(() => {
expect(mockUpload).toHaveBeenCalled()
})
})
it('should handle upload error', async () => {
mockUpload.mockRejectedValue(new Error('Upload failed'))
const { result } = renderHook(
() => useLocalFileUpload({ allowedExtensions: ['pdf'] }),
{ wrapper: createWrapper() },
)
const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' })
const event = {
target: {
files: [mockFile],
},
} as unknown as React.ChangeEvent<HTMLInputElement>
act(() => {
result.current.fileChangeHandle(event)
})
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
)
})
})
it('should call upload with correct parameters', async () => {
mockUpload.mockResolvedValue({ id: 'file-id' })
const { result } = renderHook(
() => useLocalFileUpload({ allowedExtensions: ['pdf'] }),
{ wrapper: createWrapper() },
)
const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' })
const event = {
target: {
files: [mockFile],
},
} as unknown as React.ChangeEvent<HTMLInputElement>
act(() => {
result.current.fileChangeHandle(event)
})
await waitFor(() => {
expect(mockUpload).toHaveBeenCalledWith(
expect.objectContaining({
xhr: expect.any(XMLHttpRequest),
data: expect.any(FormData),
}),
false,
undefined,
'?source=datasets',
)
})
})
})
describe('extension mapping', () => {
it('should map md to markdown', () => {
const { result } = renderHook(
() => useLocalFileUpload({ allowedExtensions: ['md'] }),
{ wrapper: createWrapper() },
)
expect(result.current.supportTypesShowNames).toContain('MARKDOWN')
})
it('should map htm to html', () => {
const { result } = renderHook(
() => useLocalFileUpload({ allowedExtensions: ['htm'] }),
{ wrapper: createWrapper() },
)
expect(result.current.supportTypesShowNames).toContain('HTML')
})
it('should preserve unmapped extensions', () => {
const { result } = renderHook(
() => useLocalFileUpload({ allowedExtensions: ['pdf', 'txt'] }),
{ wrapper: createWrapper() },
)
expect(result.current.supportTypesShowNames).toContain('PDF')
expect(result.current.supportTypesShowNames).toContain('TXT')
})
it('should remove duplicate extensions', () => {
const { result } = renderHook(
() => useLocalFileUpload({ allowedExtensions: ['pdf', 'pdf', 'PDF'] }),
{ wrapper: createWrapper() },
)
const count = (result.current.supportTypesShowNames.match(/PDF/g) || []).length
expect(count).toBe(1)
})
})
describe('drag and drop handlers', () => {
// Helper component that renders with the hook and connects refs
const TestDropzone = ({ allowedExtensions, supportBatchUpload = true }: {
allowedExtensions: string[]
supportBatchUpload?: boolean
}) => {
const {
dropRef,
dragRef,
dragging,
} = useLocalFileUpload({ allowedExtensions, supportBatchUpload })
return (
<div>
<div ref={dropRef} data-testid="dropzone">
{dragging && <div ref={dragRef} data-testid="drag-overlay" />}
</div>
<span data-testid="dragging">{String(dragging)}</span>
</div>
)
}
it('should set dragging true on dragenter', async () => {
const { getByTestId } = await act(async () =>
render(
<ToastContext.Provider value={{ notify: mockNotify, close: mockClose }}>
<TestDropzone allowedExtensions={['pdf']} />
</ToastContext.Provider>,
),
)
const dropzone = getByTestId('dropzone')
await act(async () => {
const dragEnterEvent = new Event('dragenter', { bubbles: true, cancelable: true })
dropzone.dispatchEvent(dragEnterEvent)
})
expect(getByTestId('dragging').textContent).toBe('true')
})
it('should handle dragover event', async () => {
const { getByTestId } = await act(async () =>
render(
<ToastContext.Provider value={{ notify: mockNotify, close: mockClose }}>
<TestDropzone allowedExtensions={['pdf']} />
</ToastContext.Provider>,
),
)
const dropzone = getByTestId('dropzone')
await act(async () => {
const dragOverEvent = new Event('dragover', { bubbles: true, cancelable: true })
dropzone.dispatchEvent(dragOverEvent)
})
// dragover should not throw
expect(dropzone).toBeInTheDocument()
})
it('should set dragging false on dragleave from drag overlay', async () => {
const { getByTestId, queryByTestId } = await act(async () =>
render(
<ToastContext.Provider value={{ notify: mockNotify, close: mockClose }}>
<TestDropzone allowedExtensions={['pdf']} />
</ToastContext.Provider>,
),
)
const dropzone = getByTestId('dropzone')
// First trigger dragenter to set dragging true
await act(async () => {
const dragEnterEvent = new Event('dragenter', { bubbles: true, cancelable: true })
dropzone.dispatchEvent(dragEnterEvent)
})
expect(getByTestId('dragging').textContent).toBe('true')
// Now the drag overlay should be rendered
const dragOverlay = queryByTestId('drag-overlay')
if (dragOverlay) {
await act(async () => {
const dragLeaveEvent = new Event('dragleave', { bubbles: true, cancelable: true })
Object.defineProperty(dragLeaveEvent, 'target', { value: dragOverlay })
dropzone.dispatchEvent(dragLeaveEvent)
})
}
})
it('should handle drop with files', async () => {
mockUpload.mockResolvedValue({ id: 'uploaded-id' })
const { getByTestId } = await act(async () =>
render(
<ToastContext.Provider value={{ notify: mockNotify, close: mockClose }}>
<TestDropzone allowedExtensions={['pdf']} />
</ToastContext.Provider>,
),
)
const dropzone = getByTestId('dropzone')
const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' })
await act(async () => {
const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) as Event & {
dataTransfer: { items: DataTransferItem[], files: File[] } | null
}
// Mock dataTransfer with items array (used by the shared hook for directory traversal)
dropEvent.dataTransfer = {
items: [{
kind: 'file',
getAsFile: () => mockFile,
}] as unknown as DataTransferItem[],
files: [mockFile],
}
dropzone.dispatchEvent(dropEvent)
})
await waitFor(() => {
expect(mockSetLocalFileList).toHaveBeenCalled()
})
})
it('should handle drop without dataTransfer', async () => {
const { getByTestId } = await act(async () =>
render(
<ToastContext.Provider value={{ notify: mockNotify, close: mockClose }}>
<TestDropzone allowedExtensions={['pdf']} />
</ToastContext.Provider>,
),
)
const dropzone = getByTestId('dropzone')
mockSetLocalFileList.mockClear()
await act(async () => {
const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) as Event & { dataTransfer: { files: File[] } | null }
dropEvent.dataTransfer = null
dropzone.dispatchEvent(dropEvent)
})
// Should not upload when no dataTransfer
expect(mockSetLocalFileList).not.toHaveBeenCalled()
})
it('should limit to single file on drop when supportBatchUpload is false', async () => {
mockUpload.mockResolvedValue({ id: 'uploaded-id' })
const { getByTestId } = await act(async () =>
render(
<ToastContext.Provider value={{ notify: mockNotify, close: mockClose }}>
<TestDropzone allowedExtensions={['pdf']} supportBatchUpload={false} />
</ToastContext.Provider>,
),
)
const dropzone = getByTestId('dropzone')
const files = [
new File(['content1'], 'test1.pdf', { type: 'application/pdf' }),
new File(['content2'], 'test2.pdf', { type: 'application/pdf' }),
]
await act(async () => {
const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) as Event & {
dataTransfer: { items: DataTransferItem[], files: File[] } | null
}
// Mock dataTransfer with items array (used by the shared hook for directory traversal)
dropEvent.dataTransfer = {
items: files.map(f => ({
kind: 'file',
getAsFile: () => f,
})) as unknown as DataTransferItem[],
files,
}
dropzone.dispatchEvent(dropEvent)
})
await waitFor(() => {
expect(mockSetLocalFileList).toHaveBeenCalled()
// Should only have 1 file (limited by supportBatchUpload: false)
const callArgs = mockSetLocalFileList.mock.calls[0][0]
expect(callArgs.length).toBe(1)
})
})
})
describe('file upload limit', () => {
it('should reject files exceeding total file upload limit', async () => {
// Mock store to return existing files
const { useDataSourceStoreWithSelector } = vi.mocked(await import('../../store'))
const existingFiles: FileItem[] = Array.from({ length: 8 }, (_, i) => ({
fileID: `existing-${i}`,
file: { name: `existing-${i}.pdf`, size: 1024 } as CustomFile,
progress: 100,
}))
vi.mocked(useDataSourceStoreWithSelector).mockImplementation(selector =>
selector({ localFileList: existingFiles } as Parameters<typeof selector>[0]),
)
const { result } = renderHook(
() => useLocalFileUpload({ allowedExtensions: ['pdf'] }),
{ wrapper: createWrapper() },
)
// Try to add 5 more files when limit is 10 and we already have 8
const files = Array.from({ length: 5 }, (_, i) =>
new File(['content'], `new-${i}.pdf`, { type: 'application/pdf' }))
const event = {
target: { files },
} as unknown as React.ChangeEvent<HTMLInputElement>
act(() => {
result.current.fileChangeHandle(event)
})
// Should show error about files number limit
expect(mockNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
)
// Reset mock for other tests
vi.mocked(useDataSourceStoreWithSelector).mockImplementation(selector =>
selector({ localFileList: [] as FileItem[] } as Parameters<typeof selector>[0]),
)
})
})
describe('upload progress tracking', () => {
it('should track upload progress', async () => {
let progressCallback: ((e: ProgressEvent) => void) | undefined
mockUpload.mockImplementation(async (options: { onprogress: (e: ProgressEvent) => void }) => {
progressCallback = options.onprogress
return { id: 'uploaded-id' }
})
const { result } = renderHook(
() => useLocalFileUpload({ allowedExtensions: ['pdf'] }),
{ wrapper: createWrapper() },
)
const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' })
const event = {
target: { files: [mockFile] },
} as unknown as React.ChangeEvent<HTMLInputElement>
act(() => {
result.current.fileChangeHandle(event)
})
await waitFor(() => {
expect(mockUpload).toHaveBeenCalled()
})
// Simulate progress event
if (progressCallback) {
act(() => {
progressCallback!({
lengthComputable: true,
loaded: 50,
total: 100,
} as ProgressEvent)
})
expect(mockSetLocalFileList).toHaveBeenCalled()
}
})
it('should not update progress when not lengthComputable', async () => {
let progressCallback: ((e: ProgressEvent) => void) | undefined
const uploadCallCount = { value: 0 }
mockUpload.mockImplementation(async (options: { onprogress: (e: ProgressEvent) => void }) => {
progressCallback = options.onprogress
uploadCallCount.value++
return { id: 'uploaded-id' }
})
const { result } = renderHook(
() => useLocalFileUpload({ allowedExtensions: ['pdf'] }),
{ wrapper: createWrapper() },
)
const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' })
const event = {
target: { files: [mockFile] },
} as unknown as React.ChangeEvent<HTMLInputElement>
mockSetLocalFileList.mockClear()
act(() => {
result.current.fileChangeHandle(event)
})
await waitFor(() => {
expect(mockUpload).toHaveBeenCalled()
})
const callsBeforeProgress = mockSetLocalFileList.mock.calls.length
// Simulate progress event without lengthComputable
if (progressCallback) {
act(() => {
progressCallback!({
lengthComputable: false,
loaded: 50,
total: 100,
} as ProgressEvent)
})
// Should not have additional calls
expect(mockSetLocalFileList.mock.calls.length).toBe(callsBeforeProgress)
}
})
})
describe('file progress constants', () => {
it('should use PROGRESS_NOT_STARTED for new files', async () => {
mockUpload.mockResolvedValue({ id: 'file-id' })
const { result } = renderHook(
() => useLocalFileUpload({ allowedExtensions: ['pdf'] }),
{ wrapper: createWrapper() },
)
const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' })
const event = {
target: {
files: [mockFile],
},
} as unknown as React.ChangeEvent<HTMLInputElement>
act(() => {
result.current.fileChangeHandle(event)
})
await waitFor(() => {
const callArgs = mockSetLocalFileList.mock.calls[0][0]
expect(callArgs[0].progress).toBe(PROGRESS_NOT_STARTED)
})
})
it('should set PROGRESS_ERROR on upload failure', async () => {
mockUpload.mockRejectedValue(new Error('Upload failed'))
const { result } = renderHook(
() => useLocalFileUpload({ allowedExtensions: ['pdf'] }),
{ wrapper: createWrapper() },
)
const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' })
const event = {
target: {
files: [mockFile],
},
} as unknown as React.ChangeEvent<HTMLInputElement>
act(() => {
result.current.fileChangeHandle(event)
})
await waitFor(() => {
const calls = mockSetLocalFileList.mock.calls
const lastCall = calls[calls.length - 1][0]
expect(lastCall.some((f: FileItem) => f.progress === PROGRESS_ERROR)).toBe(true)
})
})
})
})

View File

@ -1,105 +0,0 @@
import type { CustomFile as File, FileItem } from '@/models/datasets'
import { produce } from 'immer'
import { useCallback, useRef } from 'react'
import { useFileUpload } from '@/app/components/datasets/create/file-uploader/hooks/use-file-upload'
import { useDataSourceStore, useDataSourceStoreWithSelector } from '../../store'
export type UseLocalFileUploadOptions = {
allowedExtensions: string[]
supportBatchUpload?: boolean
}
/**
* Hook for handling local file uploads in the create-from-pipeline flow.
* This is a thin wrapper around the generic useFileUpload hook that provides
* Zustand store integration for state management.
*/
export const useLocalFileUpload = ({
allowedExtensions,
supportBatchUpload = true,
}: UseLocalFileUploadOptions) => {
const localFileList = useDataSourceStoreWithSelector(state => state.localFileList)
const dataSourceStore = useDataSourceStore()
const fileListRef = useRef<FileItem[]>([])
// Sync fileListRef with localFileList for internal tracking
fileListRef.current = localFileList
const prepareFileList = useCallback((files: FileItem[]) => {
const { setLocalFileList } = dataSourceStore.getState()
setLocalFileList(files)
fileListRef.current = files
}, [dataSourceStore])
const onFileUpdate = useCallback((fileItem: FileItem, progress: number, list: FileItem[]) => {
const { setLocalFileList } = dataSourceStore.getState()
const newList = produce(list, (draft) => {
const targetIndex = draft.findIndex(file => file.fileID === fileItem.fileID)
if (targetIndex !== -1) {
draft[targetIndex] = {
...draft[targetIndex],
...fileItem,
progress,
}
}
})
setLocalFileList(newList)
}, [dataSourceStore])
const onFileListUpdate = useCallback((files: FileItem[]) => {
const { setLocalFileList } = dataSourceStore.getState()
setLocalFileList(files)
fileListRef.current = files
}, [dataSourceStore])
const onPreview = useCallback((file: File) => {
const { setCurrentLocalFile } = dataSourceStore.getState()
setCurrentLocalFile(file)
}, [dataSourceStore])
const {
dropRef,
dragRef,
fileUploaderRef,
dragging,
fileUploadConfig,
acceptTypes,
supportTypesShowNames,
hideUpload,
selectHandle,
fileChangeHandle,
removeFile,
handlePreview,
} = useFileUpload({
fileList: localFileList,
prepareFileList,
onFileUpdate,
onFileListUpdate,
onPreview,
supportBatchUpload,
allowedExtensions,
})
return {
// Refs
dropRef,
dragRef,
fileUploaderRef,
// State
dragging,
localFileList,
// Config
fileUploadConfig,
acceptTypes,
supportTypesShowNames,
hideUpload,
// Handlers
selectHandle,
fileChangeHandle,
removeFile,
handlePreview,
}
}

View File

@ -1,398 +0,0 @@
import type { FileItem } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import LocalFile from './index'
// Mock the hook
const mockUseLocalFileUpload = vi.fn()
vi.mock('./hooks/use-local-file-upload', () => ({
useLocalFileUpload: (...args: unknown[]) => mockUseLocalFileUpload(...args),
}))
// Mock react-i18next for sub-components
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock theme hook for sub-components
vi.mock('@/hooks/use-theme', () => ({
default: () => ({ theme: 'light' }),
}))
// Mock theme types
vi.mock('@/types/app', () => ({
Theme: { dark: 'dark', light: 'light' },
}))
// Mock DocumentFileIcon
vi.mock('@/app/components/datasets/common/document-file-icon', () => ({
default: ({ name }: { name: string }) => <div data-testid="document-icon">{name}</div>,
}))
// Mock SimplePieChart
vi.mock('next/dynamic', () => ({
default: () => {
const Component = ({ percentage }: { percentage: number }) => (
<div data-testid="pie-chart">
{percentage}
%
</div>
)
return Component
},
}))
describe('LocalFile', () => {
const mockDropRef = { current: null }
const mockDragRef = { current: null }
const mockFileUploaderRef = { current: null }
const defaultHookReturn = {
dropRef: mockDropRef,
dragRef: mockDragRef,
fileUploaderRef: mockFileUploaderRef,
dragging: false,
localFileList: [] as FileItem[],
fileUploadConfig: {
file_size_limit: 15,
batch_count_limit: 5,
file_upload_limit: 10,
},
acceptTypes: ['.pdf', '.docx'],
supportTypesShowNames: 'PDF, DOCX',
hideUpload: false,
selectHandle: vi.fn(),
fileChangeHandle: vi.fn(),
removeFile: vi.fn(),
handlePreview: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
mockUseLocalFileUpload.mockReturnValue(defaultHookReturn)
})
describe('rendering', () => {
it('should render the component container', () => {
const { container } = render(
<LocalFile allowedExtensions={['pdf', 'docx']} />,
)
expect(container.firstChild).toHaveClass('flex', 'flex-col')
})
it('should render UploadDropzone when hideUpload is false', () => {
render(<LocalFile allowedExtensions={['pdf']} />)
const fileInput = document.getElementById('fileUploader')
expect(fileInput).toBeInTheDocument()
})
it('should not render UploadDropzone when hideUpload is true', () => {
mockUseLocalFileUpload.mockReturnValue({
...defaultHookReturn,
hideUpload: true,
})
render(<LocalFile allowedExtensions={['pdf']} />)
const fileInput = document.getElementById('fileUploader')
expect(fileInput).not.toBeInTheDocument()
})
})
describe('file list rendering', () => {
it('should not render file list when empty', () => {
render(<LocalFile allowedExtensions={['pdf']} />)
expect(screen.queryByTestId('document-icon')).not.toBeInTheDocument()
})
it('should render file list when files exist', () => {
const mockFile = {
name: 'test.pdf',
size: 1024,
type: 'application/pdf',
lastModified: Date.now(),
} as File
mockUseLocalFileUpload.mockReturnValue({
...defaultHookReturn,
localFileList: [
{
fileID: 'file-1',
file: mockFile,
progress: -1,
},
],
})
render(<LocalFile allowedExtensions={['pdf']} />)
expect(screen.getByTestId('document-icon')).toBeInTheDocument()
})
it('should render multiple file items', () => {
const createMockFile = (name: string) => ({
name,
size: 1024,
type: 'application/pdf',
lastModified: Date.now(),
}) as File
mockUseLocalFileUpload.mockReturnValue({
...defaultHookReturn,
localFileList: [
{ fileID: 'file-1', file: createMockFile('doc1.pdf'), progress: -1 },
{ fileID: 'file-2', file: createMockFile('doc2.pdf'), progress: -1 },
{ fileID: 'file-3', file: createMockFile('doc3.pdf'), progress: -1 },
],
})
render(<LocalFile allowedExtensions={['pdf']} />)
const icons = screen.getAllByTestId('document-icon')
expect(icons).toHaveLength(3)
})
it('should use correct key for file items', () => {
const mockFile = {
name: 'test.pdf',
size: 1024,
type: 'application/pdf',
lastModified: Date.now(),
} as File
mockUseLocalFileUpload.mockReturnValue({
...defaultHookReturn,
localFileList: [
{ fileID: 'unique-id-123', file: mockFile, progress: -1 },
],
})
render(<LocalFile allowedExtensions={['pdf']} />)
// The component should render without errors (key is used internally)
expect(screen.getByTestId('document-icon')).toBeInTheDocument()
})
})
describe('hook integration', () => {
it('should pass allowedExtensions to hook', () => {
render(<LocalFile allowedExtensions={['pdf', 'docx', 'txt']} />)
expect(mockUseLocalFileUpload).toHaveBeenCalledWith({
allowedExtensions: ['pdf', 'docx', 'txt'],
supportBatchUpload: true,
})
})
it('should pass supportBatchUpload true by default', () => {
render(<LocalFile allowedExtensions={['pdf']} />)
expect(mockUseLocalFileUpload).toHaveBeenCalledWith(
expect.objectContaining({ supportBatchUpload: true }),
)
})
it('should pass supportBatchUpload false when specified', () => {
render(<LocalFile allowedExtensions={['pdf']} supportBatchUpload={false} />)
expect(mockUseLocalFileUpload).toHaveBeenCalledWith(
expect.objectContaining({ supportBatchUpload: false }),
)
})
})
describe('props passed to UploadDropzone', () => {
it('should pass all required props to UploadDropzone', () => {
const selectHandle = vi.fn()
const fileChangeHandle = vi.fn()
mockUseLocalFileUpload.mockReturnValue({
...defaultHookReturn,
selectHandle,
fileChangeHandle,
supportTypesShowNames: 'PDF, DOCX',
acceptTypes: ['.pdf', '.docx'],
fileUploadConfig: {
file_size_limit: 20,
batch_count_limit: 10,
file_upload_limit: 50,
},
})
render(<LocalFile allowedExtensions={['pdf', 'docx']} supportBatchUpload={true} />)
// Verify the dropzone is rendered with correct configuration
const fileInput = document.getElementById('fileUploader')
expect(fileInput).toBeInTheDocument()
expect(fileInput).toHaveAttribute('accept', '.pdf,.docx')
expect(fileInput).toHaveAttribute('multiple')
})
})
describe('props passed to FileListItem', () => {
it('should pass correct props to file items', () => {
const handlePreview = vi.fn()
const removeFile = vi.fn()
const mockFile = {
name: 'document.pdf',
size: 2048,
type: 'application/pdf',
lastModified: Date.now(),
} as File
mockUseLocalFileUpload.mockReturnValue({
...defaultHookReturn,
handlePreview,
removeFile,
localFileList: [
{ fileID: 'test-id', file: mockFile, progress: 50 },
],
})
render(<LocalFile allowedExtensions={['pdf']} />)
expect(screen.getByTestId('document-icon')).toHaveTextContent('document.pdf')
})
})
describe('conditional rendering', () => {
it('should show both dropzone and file list when files exist and hideUpload is false', () => {
const mockFile = {
name: 'test.pdf',
size: 1024,
type: 'application/pdf',
lastModified: Date.now(),
} as File
mockUseLocalFileUpload.mockReturnValue({
...defaultHookReturn,
hideUpload: false,
localFileList: [
{ fileID: 'file-1', file: mockFile, progress: -1 },
],
})
render(<LocalFile allowedExtensions={['pdf']} />)
expect(document.getElementById('fileUploader')).toBeInTheDocument()
expect(screen.getByTestId('document-icon')).toBeInTheDocument()
})
it('should show only file list when hideUpload is true', () => {
const mockFile = {
name: 'test.pdf',
size: 1024,
type: 'application/pdf',
lastModified: Date.now(),
} as File
mockUseLocalFileUpload.mockReturnValue({
...defaultHookReturn,
hideUpload: true,
localFileList: [
{ fileID: 'file-1', file: mockFile, progress: -1 },
],
})
render(<LocalFile allowedExtensions={['pdf']} />)
expect(document.getElementById('fileUploader')).not.toBeInTheDocument()
expect(screen.getByTestId('document-icon')).toBeInTheDocument()
})
})
describe('file list container styling', () => {
it('should apply correct container classes for file list', () => {
const mockFile = {
name: 'test.pdf',
size: 1024,
type: 'application/pdf',
lastModified: Date.now(),
} as File
mockUseLocalFileUpload.mockReturnValue({
...defaultHookReturn,
localFileList: [
{ fileID: 'file-1', file: mockFile, progress: -1 },
],
})
const { container } = render(<LocalFile allowedExtensions={['pdf']} />)
const fileListContainer = container.querySelector('.mt-1.flex.flex-col.gap-y-1')
expect(fileListContainer).toBeInTheDocument()
})
})
describe('edge cases', () => {
it('should handle empty allowedExtensions', () => {
render(<LocalFile allowedExtensions={[]} />)
expect(mockUseLocalFileUpload).toHaveBeenCalledWith({
allowedExtensions: [],
supportBatchUpload: true,
})
})
it('should handle files with same fileID but different index', () => {
const mockFile = {
name: 'test.pdf',
size: 1024,
type: 'application/pdf',
lastModified: Date.now(),
} as File
mockUseLocalFileUpload.mockReturnValue({
...defaultHookReturn,
localFileList: [
{ fileID: 'same-id', file: { ...mockFile, name: 'doc1.pdf' } as File, progress: -1 },
{ fileID: 'same-id', file: { ...mockFile, name: 'doc2.pdf' } as File, progress: -1 },
],
})
// Should render without key collision errors due to index in key
render(<LocalFile allowedExtensions={['pdf']} />)
const icons = screen.getAllByTestId('document-icon')
expect(icons).toHaveLength(2)
})
})
describe('component integration', () => {
it('should render complete component tree', () => {
const mockFile = {
name: 'complete-test.pdf',
size: 5 * 1024,
type: 'application/pdf',
lastModified: Date.now(),
} as File
mockUseLocalFileUpload.mockReturnValue({
...defaultHookReturn,
hideUpload: false,
localFileList: [
{ fileID: 'file-1', file: mockFile, progress: 50 },
],
dragging: false,
})
const { container } = render(
<LocalFile allowedExtensions={['pdf', 'docx']} supportBatchUpload={true} />,
)
// Main container
expect(container.firstChild).toHaveClass('flex', 'flex-col')
// Dropzone exists
expect(document.getElementById('fileUploader')).toBeInTheDocument()
// File list exists
expect(screen.getByTestId('document-icon')).toBeInTheDocument()
})
})
})

View File

@ -1,7 +1,26 @@
'use client'
import FileListItem from './components/file-list-item'
import UploadDropzone from './components/upload-dropzone'
import { useLocalFileUpload } from './hooks/use-local-file-upload'
import type { CustomFile as File, FileItem } from '@/models/datasets'
import { RiDeleteBinLine, RiErrorWarningFill, RiUploadCloud2Line } from '@remixicon/react'
import { produce } from 'immer'
import dynamic from 'next/dynamic'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { getFileUploadErrorMessage } from '@/app/components/base/file-uploader/utils'
import { ToastContext } from '@/app/components/base/toast'
import DocumentFileIcon from '@/app/components/datasets/common/document-file-icon'
import { IS_CE_EDITION } from '@/config'
import { useLocale } from '@/context/i18n'
import useTheme from '@/hooks/use-theme'
import { LanguagesSupported } from '@/i18n-config/language'
import { upload } from '@/service/base'
import { useFileUploadConfig } from '@/service/use-common'
import { Theme } from '@/types/app'
import { cn } from '@/utils/classnames'
import { useDataSourceStore, useDataSourceStoreWithSelector } from '../store'
const SimplePieChart = dynamic(() => import('@/app/components/base/simple-pie-chart'), { ssr: false })
export type LocalFileProps = {
allowedExtensions: string[]
@ -12,49 +31,345 @@ const LocalFile = ({
allowedExtensions,
supportBatchUpload = true,
}: LocalFileProps) => {
const {
dropRef,
dragRef,
fileUploaderRef,
dragging,
localFileList,
fileUploadConfig,
acceptTypes,
supportTypesShowNames,
hideUpload,
selectHandle,
fileChangeHandle,
removeFile,
handlePreview,
} = useLocalFileUpload({ allowedExtensions, supportBatchUpload })
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const locale = useLocale()
const localFileList = useDataSourceStoreWithSelector(state => state.localFileList)
const dataSourceStore = useDataSourceStore()
const [dragging, setDragging] = useState(false)
const dropRef = useRef<HTMLDivElement>(null)
const dragRef = useRef<HTMLDivElement>(null)
const fileUploader = useRef<HTMLInputElement>(null)
const fileListRef = useRef<FileItem[]>([])
const hideUpload = !supportBatchUpload && localFileList.length > 0
const { data: fileUploadConfigResponse } = useFileUploadConfig()
const supportTypesShowNames = useMemo(() => {
const extensionMap: { [key: string]: string } = {
md: 'markdown',
pptx: 'pptx',
htm: 'html',
xlsx: 'xlsx',
docx: 'docx',
}
return allowedExtensions
.map(item => extensionMap[item] || item) // map to standardized extension
.map(item => item.toLowerCase()) // convert to lower case
.filter((item, index, self) => self.indexOf(item) === index) // remove duplicates
.map(item => item.toUpperCase()) // convert to upper case
.join(locale !== LanguagesSupported[1] ? ', ' : '、 ')
}, [locale, allowedExtensions])
const ACCEPTS = allowedExtensions.map((ext: string) => `.${ext}`)
const fileUploadConfig = useMemo(() => ({
file_size_limit: fileUploadConfigResponse?.file_size_limit ?? 15,
batch_count_limit: supportBatchUpload ? (fileUploadConfigResponse?.batch_count_limit ?? 5) : 1,
file_upload_limit: supportBatchUpload ? (fileUploadConfigResponse?.file_upload_limit ?? 5) : 1,
}), [fileUploadConfigResponse, supportBatchUpload])
const updateFile = useCallback((fileItem: FileItem, progress: number, list: FileItem[]) => {
const { setLocalFileList } = dataSourceStore.getState()
const newList = produce(list, (draft) => {
const targetIndex = draft.findIndex(file => file.fileID === fileItem.fileID)
draft[targetIndex] = {
...draft[targetIndex],
progress,
}
})
setLocalFileList(newList)
}, [dataSourceStore])
const updateFileList = useCallback((preparedFiles: FileItem[]) => {
const { setLocalFileList } = dataSourceStore.getState()
setLocalFileList(preparedFiles)
}, [dataSourceStore])
const handlePreview = useCallback((file: File) => {
const { setCurrentLocalFile } = dataSourceStore.getState()
if (file.id)
setCurrentLocalFile(file)
}, [dataSourceStore])
// utils
const getFileType = (currentFile: File) => {
if (!currentFile)
return ''
const arr = currentFile.name.split('.')
return arr[arr.length - 1]
}
const getFileSize = (size: number) => {
if (size / 1024 < 10)
return `${(size / 1024).toFixed(2)}KB`
return `${(size / 1024 / 1024).toFixed(2)}MB`
}
const isValid = useCallback((file: File) => {
const { size } = file
const ext = `.${getFileType(file)}`
const isValidType = ACCEPTS.includes(ext.toLowerCase())
if (!isValidType)
notify({ type: 'error', message: t('stepOne.uploader.validation.typeError', { ns: 'datasetCreation' }) })
const isValidSize = size <= fileUploadConfig.file_size_limit * 1024 * 1024
if (!isValidSize)
notify({ type: 'error', message: t('stepOne.uploader.validation.size', { ns: 'datasetCreation', size: fileUploadConfig.file_size_limit }) })
return isValidType && isValidSize
}, [notify, t, ACCEPTS, fileUploadConfig.file_size_limit])
type UploadResult = Awaited<ReturnType<typeof upload>>
const fileUpload = useCallback(async (fileItem: FileItem): Promise<FileItem> => {
const formData = new FormData()
formData.append('file', fileItem.file)
const onProgress = (e: ProgressEvent) => {
if (e.lengthComputable) {
const percent = Math.floor(e.loaded / e.total * 100)
updateFile(fileItem, percent, fileListRef.current)
}
}
return upload({
xhr: new XMLHttpRequest(),
data: formData,
onprogress: onProgress,
}, false, undefined, '?source=datasets')
.then((res: UploadResult) => {
const updatedFile = Object.assign({}, fileItem.file, {
id: res.id,
...(res as Partial<File>),
}) as File
const completeFile: FileItem = {
fileID: fileItem.fileID,
file: updatedFile,
progress: -1,
}
const index = fileListRef.current.findIndex(item => item.fileID === fileItem.fileID)
fileListRef.current[index] = completeFile
updateFile(completeFile, 100, fileListRef.current)
return Promise.resolve({ ...completeFile })
})
.catch((e) => {
const errorMessage = getFileUploadErrorMessage(e, t('stepOne.uploader.failed', { ns: 'datasetCreation' }), t)
notify({ type: 'error', message: errorMessage })
updateFile(fileItem, -2, fileListRef.current)
return Promise.resolve({ ...fileItem })
})
.finally()
}, [fileListRef, notify, updateFile, t])
const uploadBatchFiles = useCallback((bFiles: FileItem[]) => {
bFiles.forEach(bf => (bf.progress = 0))
return Promise.all(bFiles.map(fileUpload))
}, [fileUpload])
const uploadMultipleFiles = useCallback(async (files: FileItem[]) => {
const batchCountLimit = fileUploadConfig.batch_count_limit
const length = files.length
let start = 0
let end = 0
while (start < length) {
if (start + batchCountLimit > length)
end = length
else
end = start + batchCountLimit
const bFiles = files.slice(start, end)
await uploadBatchFiles(bFiles)
start = end
}
}, [fileUploadConfig, uploadBatchFiles])
const initialUpload = useCallback((files: File[]) => {
const filesCountLimit = fileUploadConfig.file_upload_limit
if (!files.length)
return false
if (files.length + localFileList.length > filesCountLimit && !IS_CE_EDITION) {
notify({ type: 'error', message: t('stepOne.uploader.validation.filesNumber', { ns: 'datasetCreation', filesNumber: filesCountLimit }) })
return false
}
const preparedFiles = files.map((file, index) => ({
fileID: `file${index}-${Date.now()}`,
file,
progress: -1,
}))
const newFiles = [...fileListRef.current, ...preparedFiles]
updateFileList(newFiles)
fileListRef.current = newFiles
uploadMultipleFiles(preparedFiles)
}, [fileUploadConfig.file_upload_limit, localFileList.length, updateFileList, uploadMultipleFiles, notify, t])
const handleDragEnter = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (e.target !== dragRef.current)
setDragging(true)
}
const handleDragOver = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
}
const handleDragLeave = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (e.target === dragRef.current)
setDragging(false)
}
const handleDrop = useCallback((e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
setDragging(false)
if (!e.dataTransfer)
return
let files = Array.from(e.dataTransfer.files) as File[]
if (!supportBatchUpload)
files = files.slice(0, 1)
const validFiles = files.filter(isValid)
initialUpload(validFiles)
}, [initialUpload, isValid, supportBatchUpload])
const selectHandle = useCallback(() => {
if (fileUploader.current)
fileUploader.current.click()
}, [])
const removeFile = (fileID: string) => {
if (fileUploader.current)
fileUploader.current.value = ''
fileListRef.current = fileListRef.current.filter(item => item.fileID !== fileID)
updateFileList([...fileListRef.current])
}
const fileChangeHandle = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
let files = Array.from(e.target.files ?? []) as File[]
files = files.slice(0, fileUploadConfig.batch_count_limit)
initialUpload(files.filter(isValid))
}, [isValid, initialUpload, fileUploadConfig.batch_count_limit])
const { theme } = useTheme()
const chartColor = useMemo(() => theme === Theme.dark ? '#5289ff' : '#296dff', [theme])
useEffect(() => {
const dropElement = dropRef.current
dropElement?.addEventListener('dragenter', handleDragEnter)
dropElement?.addEventListener('dragover', handleDragOver)
dropElement?.addEventListener('dragleave', handleDragLeave)
dropElement?.addEventListener('drop', handleDrop)
return () => {
dropElement?.removeEventListener('dragenter', handleDragEnter)
dropElement?.removeEventListener('dragover', handleDragOver)
dropElement?.removeEventListener('dragleave', handleDragLeave)
dropElement?.removeEventListener('drop', handleDrop)
}
}, [handleDrop])
return (
<div className="flex flex-col">
{!hideUpload && (
<UploadDropzone
dropRef={dropRef}
dragRef={dragRef}
fileUploaderRef={fileUploaderRef}
dragging={dragging}
supportBatchUpload={supportBatchUpload}
supportTypesShowNames={supportTypesShowNames}
fileUploadConfig={fileUploadConfig}
acceptTypes={acceptTypes}
onSelectFile={selectHandle}
onFileChange={fileChangeHandle}
allowedExtensions={allowedExtensions}
<input
ref={fileUploader}
id="fileUploader"
className="hidden"
type="file"
multiple={supportBatchUpload}
accept={ACCEPTS.join(',')}
onChange={fileChangeHandle}
/>
)}
{!hideUpload && (
<div
ref={dropRef}
className={cn(
'relative box-border flex min-h-20 flex-col items-center justify-center gap-1 rounded-xl border border-dashed border-components-dropzone-border bg-components-dropzone-bg px-4 py-3 text-xs leading-4 text-text-tertiary',
dragging && 'border-components-dropzone-border-accent bg-components-dropzone-bg-accent',
)}
>
<div className="flex min-h-5 items-center justify-center text-sm leading-4 text-text-secondary">
<RiUploadCloud2Line className="mr-2 size-5" />
<span>
{supportBatchUpload ? t('stepOne.uploader.button', { ns: 'datasetCreation' }) : t('stepOne.uploader.buttonSingleFile', { ns: 'datasetCreation' })}
{allowedExtensions.length > 0 && (
<label className="ml-1 cursor-pointer text-text-accent" onClick={selectHandle}>{t('stepOne.uploader.browse', { ns: 'datasetCreation' })}</label>
)}
</span>
</div>
<div>
{t('stepOne.uploader.tip', {
ns: 'datasetCreation',
size: fileUploadConfig.file_size_limit,
supportTypes: supportTypesShowNames,
batchCount: fileUploadConfig.batch_count_limit,
totalCount: fileUploadConfig.file_upload_limit,
})}
</div>
{dragging && <div ref={dragRef} className="absolute left-0 top-0 h-full w-full" />}
</div>
)}
{localFileList.length > 0 && (
<div className="mt-1 flex flex-col gap-y-1">
{localFileList.map((fileItem, index) => (
<FileListItem
key={`${fileItem.fileID}-${index}`}
fileItem={fileItem}
onPreview={handlePreview}
onRemove={removeFile}
/>
))}
{localFileList.map((fileItem, index) => {
const isUploading = fileItem.progress >= 0 && fileItem.progress < 100
const isError = fileItem.progress === -2
return (
<div
key={`${fileItem.fileID}-${index}`}
onClick={handlePreview.bind(null, fileItem.file)}
className={cn(
'flex h-12 items-center rounded-lg border border-components-panel-border bg-components-panel-on-panel-item-bg shadow-xs shadow-shadow-shadow-4',
isError && 'border-state-destructive-border bg-state-destructive-hover',
)}
>
<div className="flex w-12 shrink-0 items-center justify-center">
<DocumentFileIcon
size="lg"
className="shrink-0"
name={fileItem.file.name}
extension={getFileType(fileItem.file)}
/>
</div>
<div className="flex shrink grow flex-col gap-0.5">
<div className="flex w-full">
<div className="w-0 grow truncate text-xs text-text-secondary">{fileItem.file.name}</div>
</div>
<div className="w-full truncate text-2xs leading-3 text-text-tertiary">
<span className="uppercase">{getFileType(fileItem.file)}</span>
<span className="px-1 text-text-quaternary">·</span>
<span>{getFileSize(fileItem.file.size)}</span>
</div>
</div>
<div className="flex w-16 shrink-0 items-center justify-end gap-1 pr-3">
{isUploading && (
<SimplePieChart percentage={fileItem.progress} stroke={chartColor} fill={chartColor} animationDuration={0} />
)}
{
isError && (
<RiErrorWarningFill className="size-4 text-text-destructive" />
)
}
<span
className="flex h-6 w-6 cursor-pointer items-center justify-center"
onClick={(e) => {
e.stopPropagation()
removeFile(fileItem.fileID)
}}
>
<RiDeleteBinLine className="size-4 text-text-tertiary" />
</span>
</div>
</div>
)
})}
</div>
)}
</div>

View File

@ -0,0 +1,4 @@
export { default as ProgressBar } from './progress-bar'
export { default as RuleDetail } from './rule-detail'
export { default as SegmentProgress } from './segment-progress'
export { default as StatusHeader } from './status-header'

View File

@ -0,0 +1,159 @@
import { render } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import ProgressBar from './progress-bar'
describe('ProgressBar', () => {
const defaultProps = {
percent: 50,
isEmbedding: false,
isCompleted: false,
isPaused: false,
isError: false,
}
const getProgressElements = (container: HTMLElement) => {
const wrapper = container.firstChild as HTMLElement
const progressBar = wrapper.firstChild as HTMLElement
return { wrapper, progressBar }
}
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(<ProgressBar {...defaultProps} />)
const { wrapper, progressBar } = getProgressElements(container)
expect(wrapper).toBeInTheDocument()
expect(progressBar).toBeInTheDocument()
})
it('should render progress bar container with correct classes', () => {
const { container } = render(<ProgressBar {...defaultProps} />)
const { wrapper } = getProgressElements(container)
expect(wrapper).toHaveClass('flex', 'h-2', 'w-full', 'items-center', 'overflow-hidden', 'rounded-md')
})
it('should render inner progress bar with transition classes', () => {
const { container } = render(<ProgressBar {...defaultProps} />)
const { progressBar } = getProgressElements(container)
expect(progressBar).toHaveClass('h-full', 'transition-all', 'duration-300')
})
})
describe('Progress Width', () => {
it('should set progress width to 0%', () => {
const { container } = render(<ProgressBar {...defaultProps} percent={0} />)
const { progressBar } = getProgressElements(container)
expect(progressBar).toHaveStyle({ width: '0%' })
})
it('should set progress width to 50%', () => {
const { container } = render(<ProgressBar {...defaultProps} percent={50} />)
const { progressBar } = getProgressElements(container)
expect(progressBar).toHaveStyle({ width: '50%' })
})
it('should set progress width to 100%', () => {
const { container } = render(<ProgressBar {...defaultProps} percent={100} />)
const { progressBar } = getProgressElements(container)
expect(progressBar).toHaveStyle({ width: '100%' })
})
it('should set progress width to 75%', () => {
const { container } = render(<ProgressBar {...defaultProps} percent={75} />)
const { progressBar } = getProgressElements(container)
expect(progressBar).toHaveStyle({ width: '75%' })
})
})
describe('Container Background States', () => {
it('should apply semi-transparent background when isEmbedding is true', () => {
const { container } = render(<ProgressBar {...defaultProps} isEmbedding />)
const { wrapper } = getProgressElements(container)
expect(wrapper).toHaveClass('bg-components-progress-bar-bg/50')
})
it('should apply default background when isEmbedding is false', () => {
const { container } = render(<ProgressBar {...defaultProps} isEmbedding={false} />)
const { wrapper } = getProgressElements(container)
expect(wrapper).toHaveClass('bg-components-progress-bar-bg')
expect(wrapper).not.toHaveClass('bg-components-progress-bar-bg/50')
})
})
describe('Progress Bar Fill States', () => {
it('should apply solid progress style when isEmbedding is true', () => {
const { container } = render(<ProgressBar {...defaultProps} isEmbedding />)
const { progressBar } = getProgressElements(container)
expect(progressBar).toHaveClass('bg-components-progress-bar-progress-solid')
})
it('should apply solid progress style when isCompleted is true', () => {
const { container } = render(<ProgressBar {...defaultProps} isCompleted />)
const { progressBar } = getProgressElements(container)
expect(progressBar).toHaveClass('bg-components-progress-bar-progress-solid')
})
it('should apply highlight style when isPaused is true', () => {
const { container } = render(<ProgressBar {...defaultProps} isPaused />)
const { progressBar } = getProgressElements(container)
expect(progressBar).toHaveClass('bg-components-progress-bar-progress-highlight')
})
it('should apply highlight style when isError is true', () => {
const { container } = render(<ProgressBar {...defaultProps} isError />)
const { progressBar } = getProgressElements(container)
expect(progressBar).toHaveClass('bg-components-progress-bar-progress-highlight')
})
it('should not apply fill styles when no status flags are set', () => {
const { container } = render(<ProgressBar {...defaultProps} />)
const { progressBar } = getProgressElements(container)
expect(progressBar).not.toHaveClass('bg-components-progress-bar-progress-solid')
expect(progressBar).not.toHaveClass('bg-components-progress-bar-progress-highlight')
})
})
describe('Combined States', () => {
it('should apply highlight when isEmbedding and isPaused', () => {
const { container } = render(<ProgressBar {...defaultProps} isEmbedding isPaused />)
const { progressBar } = getProgressElements(container)
// highlight takes precedence since isPaused condition is separate
expect(progressBar).toHaveClass('bg-components-progress-bar-progress-highlight')
})
it('should apply highlight when isCompleted and isError', () => {
const { container } = render(<ProgressBar {...defaultProps} isCompleted isError />)
const { progressBar } = getProgressElements(container)
// highlight takes precedence since isError condition is separate
expect(progressBar).toHaveClass('bg-components-progress-bar-progress-highlight')
})
it('should apply semi-transparent bg for embedding and highlight for paused', () => {
const { container } = render(<ProgressBar {...defaultProps} isEmbedding isPaused />)
const { wrapper } = getProgressElements(container)
expect(wrapper).toHaveClass('bg-components-progress-bar-bg/50')
})
})
describe('Edge Cases', () => {
it('should handle all props set to false', () => {
const { container } = render(
<ProgressBar
percent={0}
isEmbedding={false}
isCompleted={false}
isPaused={false}
isError={false}
/>,
)
const { wrapper, progressBar } = getProgressElements(container)
expect(wrapper).toBeInTheDocument()
expect(progressBar).toHaveStyle({ width: '0%' })
})
it('should handle decimal percent values', () => {
const { container } = render(<ProgressBar {...defaultProps} percent={33.33} />)
const { progressBar } = getProgressElements(container)
expect(progressBar).toHaveStyle({ width: '33.33%' })
})
})
})

View File

@ -0,0 +1,44 @@
import type { FC } from 'react'
import * as React from 'react'
import { cn } from '@/utils/classnames'
type ProgressBarProps = {
percent: number
isEmbedding: boolean
isCompleted: boolean
isPaused: boolean
isError: boolean
}
const ProgressBar: FC<ProgressBarProps> = React.memo(({
percent,
isEmbedding,
isCompleted,
isPaused,
isError,
}) => {
const isActive = isEmbedding || isCompleted
const isHighlighted = isPaused || isError
return (
<div
className={cn(
'flex h-2 w-full items-center overflow-hidden rounded-md border border-components-progress-bar-border',
isEmbedding ? 'bg-components-progress-bar-bg/50' : 'bg-components-progress-bar-bg',
)}
>
<div
className={cn(
'h-full transition-all duration-300',
isActive && 'bg-components-progress-bar-progress-solid',
isHighlighted && 'bg-components-progress-bar-progress-highlight',
)}
style={{ width: `${percent}%` }}
/>
</div>
)
})
ProgressBar.displayName = 'ProgressBar'
export default ProgressBar

View File

@ -0,0 +1,203 @@
import type { ProcessRuleResponse } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { ProcessMode } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
import { IndexingType } from '../../../../create/step-two'
import RuleDetail from './rule-detail'
describe('RuleDetail', () => {
const defaultProps = {
indexingType: IndexingType.QUALIFIED,
retrievalMethod: RETRIEVE_METHOD.semantic,
}
const createSourceData = (overrides: Partial<ProcessRuleResponse> = {}): ProcessRuleResponse => ({
mode: ProcessMode.general,
rules: {
segmentation: {
separator: '\n',
max_tokens: 500,
chunk_overlap: 50,
},
pre_processing_rules: [
{ id: 'remove_extra_spaces', enabled: true },
{ id: 'remove_urls_emails', enabled: false },
],
parent_mode: 'full-doc',
subchunk_segmentation: {
separator: '\n',
max_tokens: 200,
chunk_overlap: 20,
},
},
limits: { indexing_max_segmentation_tokens_length: 4000 },
...overrides,
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<RuleDetail {...defaultProps} />)
expect(screen.getByText(/stepTwo\.indexMode/i)).toBeInTheDocument()
})
it('should render with sourceData', () => {
const sourceData = createSourceData()
render(<RuleDetail {...defaultProps} sourceData={sourceData} />)
expect(screen.getByText(/embedding\.mode/i)).toBeInTheDocument()
})
it('should render all segmentation rule fields', () => {
const sourceData = createSourceData()
render(<RuleDetail {...defaultProps} sourceData={sourceData} />)
expect(screen.getByText(/embedding\.mode/i)).toBeInTheDocument()
expect(screen.getByText(/embedding\.segmentLength/i)).toBeInTheDocument()
expect(screen.getByText(/embedding\.textCleaning/i)).toBeInTheDocument()
})
})
describe('Mode Display', () => {
it('should display custom mode for general process mode', () => {
const sourceData = createSourceData({ mode: ProcessMode.general })
render(<RuleDetail {...defaultProps} sourceData={sourceData} />)
expect(screen.getByText(/embedding\.custom/i)).toBeInTheDocument()
})
it('should display mode label field', () => {
const sourceData = createSourceData()
render(<RuleDetail {...defaultProps} sourceData={sourceData} />)
expect(screen.getByText(/embedding\.mode/i)).toBeInTheDocument()
})
})
describe('Segment Length Display', () => {
it('should display max tokens for general mode', () => {
const sourceData = createSourceData({
mode: ProcessMode.general,
rules: {
segmentation: { separator: '\n', max_tokens: 500, chunk_overlap: 50 },
pre_processing_rules: [],
parent_mode: 'full-doc',
subchunk_segmentation: { separator: '\n', max_tokens: 200, chunk_overlap: 20 },
},
})
render(<RuleDetail {...defaultProps} sourceData={sourceData} />)
expect(screen.getByText('500')).toBeInTheDocument()
})
it('should display segment length label', () => {
const sourceData = createSourceData()
render(<RuleDetail {...defaultProps} sourceData={sourceData} />)
expect(screen.getByText(/embedding\.segmentLength/i)).toBeInTheDocument()
})
})
describe('Text Cleaning Display', () => {
it('should display enabled pre-processing rules', () => {
const sourceData = createSourceData({
rules: {
segmentation: { separator: '\n', max_tokens: 500, chunk_overlap: 50 },
pre_processing_rules: [
{ id: 'remove_extra_spaces', enabled: true },
{ id: 'remove_urls_emails', enabled: true },
],
parent_mode: 'full-doc',
subchunk_segmentation: { separator: '\n', max_tokens: 200, chunk_overlap: 20 },
},
})
render(<RuleDetail {...defaultProps} sourceData={sourceData} />)
expect(screen.getByText(/removeExtraSpaces/i)).toBeInTheDocument()
expect(screen.getByText(/removeUrlEmails/i)).toBeInTheDocument()
})
it('should display text cleaning label', () => {
const sourceData = createSourceData()
render(<RuleDetail {...defaultProps} sourceData={sourceData} />)
expect(screen.getByText(/embedding\.textCleaning/i)).toBeInTheDocument()
})
})
describe('Index Mode Display', () => {
it('should display economical mode when indexingType is ECONOMICAL', () => {
render(<RuleDetail {...defaultProps} indexingType={IndexingType.ECONOMICAL} />)
expect(screen.getByText(/stepTwo\.economical/i)).toBeInTheDocument()
})
it('should display qualified mode when indexingType is QUALIFIED', () => {
render(<RuleDetail {...defaultProps} indexingType={IndexingType.QUALIFIED} />)
expect(screen.getByText(/stepTwo\.qualified/i)).toBeInTheDocument()
})
})
describe('Retrieval Method Display', () => {
it('should display keyword search for economical mode', () => {
render(<RuleDetail {...defaultProps} indexingType={IndexingType.ECONOMICAL} />)
expect(screen.getByText(/retrieval\.keyword_search\.title/i)).toBeInTheDocument()
})
it('should display semantic search as default for qualified mode', () => {
render(<RuleDetail {...defaultProps} indexingType={IndexingType.QUALIFIED} />)
expect(screen.getByText(/retrieval\.semantic_search\.title/i)).toBeInTheDocument()
})
it('should display full text search when retrievalMethod is fullText', () => {
render(<RuleDetail {...defaultProps} retrievalMethod={RETRIEVE_METHOD.fullText} />)
expect(screen.getByText(/retrieval\.full_text_search\.title/i)).toBeInTheDocument()
})
it('should display hybrid search when retrievalMethod is hybrid', () => {
render(<RuleDetail {...defaultProps} retrievalMethod={RETRIEVE_METHOD.hybrid} />)
expect(screen.getByText(/retrieval\.hybrid_search\.title/i)).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should display dash for missing sourceData', () => {
render(<RuleDetail {...defaultProps} />)
const dashes = screen.getAllByText('-')
expect(dashes.length).toBeGreaterThan(0)
})
it('should display dash when mode is undefined', () => {
const sourceData = { rules: {} } as ProcessRuleResponse
render(<RuleDetail {...defaultProps} sourceData={sourceData} />)
const dashes = screen.getAllByText('-')
expect(dashes.length).toBeGreaterThan(0)
})
it('should handle undefined retrievalMethod', () => {
render(<RuleDetail indexingType={IndexingType.QUALIFIED} />)
expect(screen.getByText(/retrieval\.semantic_search\.title/i)).toBeInTheDocument()
})
it('should handle empty pre_processing_rules array', () => {
const sourceData = createSourceData({
rules: {
segmentation: { separator: '\n', max_tokens: 500, chunk_overlap: 50 },
pre_processing_rules: [],
parent_mode: 'full-doc',
subchunk_segmentation: { separator: '\n', max_tokens: 200, chunk_overlap: 20 },
},
})
render(<RuleDetail {...defaultProps} sourceData={sourceData} />)
expect(screen.getByText(/embedding\.textCleaning/i)).toBeInTheDocument()
})
it('should render container with correct structure', () => {
const { container } = render(<RuleDetail {...defaultProps} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('py-3')
})
it('should handle undefined indexingType', () => {
render(<RuleDetail retrievalMethod={RETRIEVE_METHOD.semantic} />)
expect(screen.getByText(/stepTwo\.indexMode/i)).toBeInTheDocument()
})
it('should render divider between sections', () => {
const { container } = render(<RuleDetail {...defaultProps} />)
const dividers = container.querySelectorAll('.bg-divider-subtle')
expect(dividers.length).toBeGreaterThan(0)
})
})
})

View File

@ -0,0 +1,128 @@
import type { FC } from 'react'
import type { ProcessRuleResponse } from '@/models/datasets'
import type { RETRIEVE_METHOD } from '@/types/app'
import Image from 'next/image'
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import { ProcessMode } from '@/models/datasets'
import { indexMethodIcon, retrievalIcon } from '../../../../create/icons'
import { IndexingType } from '../../../../create/step-two'
import { FieldInfo } from '../../metadata'
type RuleDetailProps = {
sourceData?: ProcessRuleResponse
indexingType?: IndexingType
retrievalMethod?: RETRIEVE_METHOD
}
const getRetrievalIcon = (method?: RETRIEVE_METHOD) => {
if (method === 'full_text_search')
return retrievalIcon.fullText
if (method === 'hybrid_search')
return retrievalIcon.hybrid
return retrievalIcon.vector
}
const RuleDetail: FC<RuleDetailProps> = React.memo(({
sourceData,
indexingType,
retrievalMethod,
}) => {
const { t } = useTranslation()
const segmentationRuleMap = {
mode: t('embedding.mode', { ns: 'datasetDocuments' }),
segmentLength: t('embedding.segmentLength', { ns: 'datasetDocuments' }),
textCleaning: t('embedding.textCleaning', { ns: 'datasetDocuments' }),
}
const getRuleName = useCallback((key: string) => {
const ruleNameMap: Record<string, string> = {
remove_extra_spaces: t('stepTwo.removeExtraSpaces', { ns: 'datasetCreation' }),
remove_urls_emails: t('stepTwo.removeUrlEmails', { ns: 'datasetCreation' }),
remove_stopwords: t('stepTwo.removeStopwords', { ns: 'datasetCreation' }),
}
return ruleNameMap[key]
}, [t])
const getValue = useCallback((field: string) => {
const defaultValue = '-'
if (!sourceData?.mode)
return defaultValue
const maxTokens = typeof sourceData?.rules?.segmentation?.max_tokens === 'number'
? sourceData.rules.segmentation.max_tokens
: defaultValue
const childMaxTokens = typeof sourceData?.rules?.subchunk_segmentation?.max_tokens === 'number'
? sourceData.rules.subchunk_segmentation.max_tokens
: defaultValue
const isGeneralMode = sourceData.mode === ProcessMode.general
const fieldValueMap: Record<string, string | number> = {
mode: isGeneralMode
? t('embedding.custom', { ns: 'datasetDocuments' })
: `${t('embedding.hierarchical', { ns: 'datasetDocuments' })} · ${
sourceData?.rules?.parent_mode === 'paragraph'
? t('parentMode.paragraph', { ns: 'dataset' })
: t('parentMode.fullDoc', { ns: 'dataset' })
}`,
segmentLength: isGeneralMode
? maxTokens
: `${t('embedding.parentMaxTokens', { ns: 'datasetDocuments' })} ${maxTokens}; ${t('embedding.childMaxTokens', { ns: 'datasetDocuments' })} ${childMaxTokens}`,
textCleaning: sourceData?.rules?.pre_processing_rules
?.filter(rule => rule.enabled)
.map(rule => getRuleName(rule.id))
.join(',') || defaultValue,
}
return fieldValueMap[field] ?? defaultValue
}, [sourceData, t, getRuleName])
const isEconomical = indexingType === IndexingType.ECONOMICAL
return (
<div className="py-3">
<div className="flex flex-col gap-y-1">
{Object.keys(segmentationRuleMap).map(field => (
<FieldInfo
key={field}
label={segmentationRuleMap[field as keyof typeof segmentationRuleMap]}
displayedValue={String(getValue(field))}
/>
))}
</div>
<Divider type="horizontal" className="bg-divider-subtle" />
<FieldInfo
label={t('stepTwo.indexMode', { ns: 'datasetCreation' })}
displayedValue={t(`stepTwo.${isEconomical ? 'economical' : 'qualified'}`, { ns: 'datasetCreation' }) as string}
valueIcon={(
<Image
className="size-4"
src={isEconomical ? indexMethodIcon.economical : indexMethodIcon.high_quality}
alt=""
/>
)}
/>
<FieldInfo
label={t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
displayedValue={t(`retrieval.${isEconomical ? 'keyword_search' : retrievalMethod ?? 'semantic_search'}.title`, { ns: 'dataset' })}
valueIcon={(
<Image
className="size-4"
src={getRetrievalIcon(retrievalMethod)}
alt=""
/>
)}
/>
</div>
)
})
RuleDetail.displayName = 'RuleDetail'
export default RuleDetail

View File

@ -0,0 +1,81 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import SegmentProgress from './segment-progress'
describe('SegmentProgress', () => {
const defaultProps = {
completedSegments: 50,
totalSegments: 100,
percent: 50,
}
describe('Rendering', () => {
it('should render without crashing', () => {
render(<SegmentProgress {...defaultProps} />)
expect(screen.getByText(/segments/i)).toBeInTheDocument()
})
it('should render with correct CSS classes', () => {
const { container } = render(<SegmentProgress {...defaultProps} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('flex', 'w-full', 'items-center')
})
it('should render text with correct styling class', () => {
render(<SegmentProgress {...defaultProps} />)
const text = screen.getByText(/segments/i)
expect(text).toHaveClass('system-xs-medium', 'text-text-secondary')
})
})
describe('Progress Display', () => {
it('should display completed and total segments', () => {
render(<SegmentProgress completedSegments={50} totalSegments={100} percent={50} />)
expect(screen.getByText(/50\/100/)).toBeInTheDocument()
})
it('should display percent value', () => {
render(<SegmentProgress completedSegments={50} totalSegments={100} percent={50} />)
expect(screen.getByText(/50%/)).toBeInTheDocument()
})
it('should display 0/0 when segments are 0', () => {
render(<SegmentProgress completedSegments={0} totalSegments={0} percent={0} />)
expect(screen.getByText(/0\/0/)).toBeInTheDocument()
expect(screen.getByText(/0%/)).toBeInTheDocument()
})
it('should display 100% when completed', () => {
render(<SegmentProgress completedSegments={100} totalSegments={100} percent={100} />)
expect(screen.getByText(/100\/100/)).toBeInTheDocument()
expect(screen.getByText(/100%/)).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should display -- when completedSegments is undefined', () => {
render(<SegmentProgress totalSegments={100} percent={0} />)
expect(screen.getByText(/--\/100/)).toBeInTheDocument()
})
it('should display -- when totalSegments is undefined', () => {
render(<SegmentProgress completedSegments={50} percent={50} />)
expect(screen.getByText(/50\/--/)).toBeInTheDocument()
})
it('should display --/-- when both segments are undefined', () => {
render(<SegmentProgress percent={0} />)
expect(screen.getByText(/--\/--/)).toBeInTheDocument()
})
it('should handle large numbers', () => {
render(<SegmentProgress completedSegments={999999} totalSegments={1000000} percent={99} />)
expect(screen.getByText(/999999\/1000000/)).toBeInTheDocument()
})
it('should handle decimal percent', () => {
render(<SegmentProgress completedSegments={33} totalSegments={100} percent={33.33} />)
expect(screen.getByText(/33.33%/)).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,32 @@
import type { FC } from 'react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
type SegmentProgressProps = {
completedSegments?: number
totalSegments?: number
percent: number
}
const SegmentProgress: FC<SegmentProgressProps> = React.memo(({
completedSegments,
totalSegments,
percent,
}) => {
const { t } = useTranslation()
const completed = completedSegments ?? '--'
const total = totalSegments ?? '--'
return (
<div className="flex w-full items-center">
<span className="system-xs-medium text-text-secondary">
{`${t('embedding.segments', { ns: 'datasetDocuments' })} ${completed}/${total} · ${percent}%`}
</span>
</div>
)
})
SegmentProgress.displayName = 'SegmentProgress'
export default SegmentProgress

View File

@ -0,0 +1,155 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import StatusHeader from './status-header'
describe('StatusHeader', () => {
const defaultProps = {
isEmbedding: false,
isCompleted: false,
isPaused: false,
isError: false,
onPause: vi.fn(),
onResume: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(<StatusHeader {...defaultProps} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render with correct container classes', () => {
const { container } = render(<StatusHeader {...defaultProps} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('flex', 'h-6', 'items-center', 'gap-x-1')
})
})
describe('Status Text', () => {
it('should display processing text when isEmbedding is true', () => {
render(<StatusHeader {...defaultProps} isEmbedding />)
expect(screen.getByText(/embedding\.processing/i)).toBeInTheDocument()
})
it('should display completed text when isCompleted is true', () => {
render(<StatusHeader {...defaultProps} isCompleted />)
expect(screen.getByText(/embedding\.completed/i)).toBeInTheDocument()
})
it('should display paused text when isPaused is true', () => {
render(<StatusHeader {...defaultProps} isPaused />)
expect(screen.getByText(/embedding\.paused/i)).toBeInTheDocument()
})
it('should display error text when isError is true', () => {
render(<StatusHeader {...defaultProps} isError />)
expect(screen.getByText(/embedding\.error/i)).toBeInTheDocument()
})
it('should display empty text when no status flags are set', () => {
render(<StatusHeader {...defaultProps} />)
const statusText = screen.getByText('', { selector: 'span.system-md-semibold-uppercase' })
expect(statusText).toBeInTheDocument()
})
})
describe('Loading Spinner', () => {
it('should show loading spinner when isEmbedding is true', () => {
const { container } = render(<StatusHeader {...defaultProps} isEmbedding />)
const spinner = container.querySelector('svg.animate-spin')
expect(spinner).toBeInTheDocument()
})
it('should not show loading spinner when isEmbedding is false', () => {
const { container } = render(<StatusHeader {...defaultProps} isEmbedding={false} />)
const spinner = container.querySelector('svg.animate-spin')
expect(spinner).not.toBeInTheDocument()
})
})
describe('Pause Button', () => {
it('should show pause button when isEmbedding is true', () => {
render(<StatusHeader {...defaultProps} isEmbedding />)
expect(screen.getByRole('button')).toBeInTheDocument()
expect(screen.getByText(/embedding\.pause/i)).toBeInTheDocument()
})
it('should not show pause button when isEmbedding is false', () => {
render(<StatusHeader {...defaultProps} isEmbedding={false} />)
expect(screen.queryByText(/embedding\.pause/i)).not.toBeInTheDocument()
})
it('should call onPause when pause button is clicked', () => {
const onPause = vi.fn()
render(<StatusHeader {...defaultProps} isEmbedding onPause={onPause} />)
fireEvent.click(screen.getByRole('button'))
expect(onPause).toHaveBeenCalledTimes(1)
})
it('should disable pause button when isPauseLoading is true', () => {
render(<StatusHeader {...defaultProps} isEmbedding isPauseLoading />)
expect(screen.getByRole('button')).toBeDisabled()
})
})
describe('Resume Button', () => {
it('should show resume button when isPaused is true', () => {
render(<StatusHeader {...defaultProps} isPaused />)
expect(screen.getByRole('button')).toBeInTheDocument()
expect(screen.getByText(/embedding\.resume/i)).toBeInTheDocument()
})
it('should not show resume button when isPaused is false', () => {
render(<StatusHeader {...defaultProps} isPaused={false} />)
expect(screen.queryByText(/embedding\.resume/i)).not.toBeInTheDocument()
})
it('should call onResume when resume button is clicked', () => {
const onResume = vi.fn()
render(<StatusHeader {...defaultProps} isPaused onResume={onResume} />)
fireEvent.click(screen.getByRole('button'))
expect(onResume).toHaveBeenCalledTimes(1)
})
it('should disable resume button when isResumeLoading is true', () => {
render(<StatusHeader {...defaultProps} isPaused isResumeLoading />)
expect(screen.getByRole('button')).toBeDisabled()
})
})
describe('Button Styles', () => {
it('should have correct button styles for pause button', () => {
render(<StatusHeader {...defaultProps} isEmbedding />)
const button = screen.getByRole('button')
expect(button).toHaveClass('flex', 'items-center', 'gap-x-1', 'rounded-md')
})
it('should have correct button styles for resume button', () => {
render(<StatusHeader {...defaultProps} isPaused />)
const button = screen.getByRole('button')
expect(button).toHaveClass('flex', 'items-center', 'gap-x-1', 'rounded-md')
})
})
describe('Edge Cases', () => {
it('should not show any buttons when isCompleted', () => {
render(<StatusHeader {...defaultProps} isCompleted />)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('should not show any buttons when isError', () => {
render(<StatusHeader {...defaultProps} isError />)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('should show both buttons when isEmbedding and isPaused are both true', () => {
render(<StatusHeader {...defaultProps} isEmbedding isPaused />)
const buttons = screen.getAllByRole('button')
expect(buttons.length).toBe(2)
})
})
})

View File

@ -0,0 +1,84 @@
import type { FC } from 'react'
import { RiLoader2Line, RiPauseCircleLine, RiPlayCircleLine } from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
type StatusHeaderProps = {
isEmbedding: boolean
isCompleted: boolean
isPaused: boolean
isError: boolean
onPause: () => void
onResume: () => void
isPauseLoading?: boolean
isResumeLoading?: boolean
}
const StatusHeader: FC<StatusHeaderProps> = React.memo(({
isEmbedding,
isCompleted,
isPaused,
isError,
onPause,
onResume,
isPauseLoading,
isResumeLoading,
}) => {
const { t } = useTranslation()
const getStatusText = () => {
if (isEmbedding)
return t('embedding.processing', { ns: 'datasetDocuments' })
if (isCompleted)
return t('embedding.completed', { ns: 'datasetDocuments' })
if (isPaused)
return t('embedding.paused', { ns: 'datasetDocuments' })
if (isError)
return t('embedding.error', { ns: 'datasetDocuments' })
return ''
}
const buttonBaseClass = `flex items-center gap-x-1 rounded-md border-[0.5px]
border-components-button-secondary-border bg-components-button-secondary-bg
px-1.5 py-1 shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]
disabled:cursor-not-allowed disabled:opacity-50`
return (
<div className="flex h-6 items-center gap-x-1">
{isEmbedding && <RiLoader2Line className="h-4 w-4 animate-spin text-text-secondary" />}
<span className="system-md-semibold-uppercase grow text-text-secondary">
{getStatusText()}
</span>
{isEmbedding && (
<button
type="button"
className={buttonBaseClass}
onClick={onPause}
disabled={isPauseLoading}
>
<RiPauseCircleLine className="h-3.5 w-3.5 text-components-button-secondary-text" />
<span className="system-xs-medium pr-[3px] text-components-button-secondary-text">
{t('embedding.pause', { ns: 'datasetDocuments' })}
</span>
</button>
)}
{isPaused && (
<button
type="button"
className={buttonBaseClass}
onClick={onResume}
disabled={isResumeLoading}
>
<RiPlayCircleLine className="h-3.5 w-3.5 text-components-button-secondary-text" />
<span className="system-xs-medium pr-[3px] text-components-button-secondary-text">
{t('embedding.resume', { ns: 'datasetDocuments' })}
</span>
</button>
)}
</div>
)
})
StatusHeader.displayName = 'StatusHeader'
export default StatusHeader

View File

@ -0,0 +1,10 @@
export {
calculatePercent,
isEmbeddingStatus,
isTerminalStatus,
useEmbeddingStatus,
useInvalidateEmbeddingStatus,
usePauseIndexing,
useResumeIndexing,
} from './use-embedding-status'
export type { EmbeddingStatusType } from './use-embedding-status'

View File

@ -0,0 +1,462 @@
import type { ReactNode } from 'react'
import type { IndexingStatusResponse } from '@/models/datasets'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as datasetsService from '@/service/datasets'
import {
calculatePercent,
isEmbeddingStatus,
isTerminalStatus,
useEmbeddingStatus,
useInvalidateEmbeddingStatus,
usePauseIndexing,
useResumeIndexing,
} from './use-embedding-status'
vi.mock('@/service/datasets')
const mockFetchIndexingStatus = vi.mocked(datasetsService.fetchIndexingStatus)
const mockPauseDocIndexing = vi.mocked(datasetsService.pauseDocIndexing)
const mockResumeDocIndexing = vi.mocked(datasetsService.resumeDocIndexing)
const createTestQueryClient = () => new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const createWrapper = () => {
const queryClient = createTestQueryClient()
return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
const mockIndexingStatus = (overrides: Partial<IndexingStatusResponse> = {}): IndexingStatusResponse => ({
id: 'doc1',
indexing_status: 'indexing',
completed_segments: 50,
total_segments: 100,
processing_started_at: 0,
parsing_completed_at: 0,
cleaning_completed_at: 0,
splitting_completed_at: 0,
completed_at: null,
paused_at: null,
error: null,
stopped_at: null,
...overrides,
})
describe('use-embedding-status', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('isEmbeddingStatus', () => {
it('should return true for indexing status', () => {
expect(isEmbeddingStatus('indexing')).toBe(true)
})
it('should return true for splitting status', () => {
expect(isEmbeddingStatus('splitting')).toBe(true)
})
it('should return true for parsing status', () => {
expect(isEmbeddingStatus('parsing')).toBe(true)
})
it('should return true for cleaning status', () => {
expect(isEmbeddingStatus('cleaning')).toBe(true)
})
it('should return false for completed status', () => {
expect(isEmbeddingStatus('completed')).toBe(false)
})
it('should return false for paused status', () => {
expect(isEmbeddingStatus('paused')).toBe(false)
})
it('should return false for error status', () => {
expect(isEmbeddingStatus('error')).toBe(false)
})
it('should return false for undefined', () => {
expect(isEmbeddingStatus(undefined)).toBe(false)
})
it('should return false for empty string', () => {
expect(isEmbeddingStatus('')).toBe(false)
})
})
describe('isTerminalStatus', () => {
it('should return true for completed status', () => {
expect(isTerminalStatus('completed')).toBe(true)
})
it('should return true for error status', () => {
expect(isTerminalStatus('error')).toBe(true)
})
it('should return true for paused status', () => {
expect(isTerminalStatus('paused')).toBe(true)
})
it('should return false for indexing status', () => {
expect(isTerminalStatus('indexing')).toBe(false)
})
it('should return false for undefined', () => {
expect(isTerminalStatus(undefined)).toBe(false)
})
})
describe('calculatePercent', () => {
it('should calculate percent correctly', () => {
expect(calculatePercent(50, 100)).toBe(50)
})
it('should return 0 when total is 0', () => {
expect(calculatePercent(50, 0)).toBe(0)
})
it('should return 0 when total is undefined', () => {
expect(calculatePercent(50, undefined)).toBe(0)
})
it('should return 0 when completed is undefined', () => {
expect(calculatePercent(undefined, 100)).toBe(0)
})
it('should cap at 100 when percent exceeds 100', () => {
expect(calculatePercent(150, 100)).toBe(100)
})
it('should round to nearest integer', () => {
expect(calculatePercent(33, 100)).toBe(33)
expect(calculatePercent(1, 3)).toBe(33)
})
})
describe('useEmbeddingStatus', () => {
it('should return initial state when disabled', () => {
const { result } = renderHook(
() => useEmbeddingStatus({ datasetId: 'ds1', documentId: 'doc1', enabled: false }),
{ wrapper: createWrapper() },
)
expect(result.current.isEmbedding).toBe(false)
expect(result.current.isCompleted).toBe(false)
expect(result.current.isPaused).toBe(false)
expect(result.current.isError).toBe(false)
expect(result.current.percent).toBe(0)
})
it('should not fetch when datasetId is missing', () => {
renderHook(
() => useEmbeddingStatus({ documentId: 'doc1' }),
{ wrapper: createWrapper() },
)
expect(mockFetchIndexingStatus).not.toHaveBeenCalled()
})
it('should not fetch when documentId is missing', () => {
renderHook(
() => useEmbeddingStatus({ datasetId: 'ds1' }),
{ wrapper: createWrapper() },
)
expect(mockFetchIndexingStatus).not.toHaveBeenCalled()
})
it('should fetch indexing status when enabled with valid ids', async () => {
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus())
const { result } = renderHook(
() => useEmbeddingStatus({ datasetId: 'ds1', documentId: 'doc1' }),
{ wrapper: createWrapper() },
)
await waitFor(() => {
expect(result.current.isEmbedding).toBe(true)
})
expect(mockFetchIndexingStatus).toHaveBeenCalledWith({
datasetId: 'ds1',
documentId: 'doc1',
})
expect(result.current.percent).toBe(50)
})
it('should set isCompleted when status is completed', async () => {
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus({
indexing_status: 'completed',
completed_segments: 100,
}))
const { result } = renderHook(
() => useEmbeddingStatus({ datasetId: 'ds1', documentId: 'doc1' }),
{ wrapper: createWrapper() },
)
await waitFor(() => {
expect(result.current.isCompleted).toBe(true)
})
expect(result.current.percent).toBe(100)
})
it('should set isPaused when status is paused', async () => {
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus({
indexing_status: 'paused',
}))
const { result } = renderHook(
() => useEmbeddingStatus({ datasetId: 'ds1', documentId: 'doc1' }),
{ wrapper: createWrapper() },
)
await waitFor(() => {
expect(result.current.isPaused).toBe(true)
})
})
it('should set isError when status is error', async () => {
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus({
indexing_status: 'error',
completed_segments: 25,
}))
const { result } = renderHook(
() => useEmbeddingStatus({ datasetId: 'ds1', documentId: 'doc1' }),
{ wrapper: createWrapper() },
)
await waitFor(() => {
expect(result.current.isError).toBe(true)
})
})
it('should provide invalidate function', async () => {
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus())
const { result } = renderHook(
() => useEmbeddingStatus({ datasetId: 'ds1', documentId: 'doc1' }),
{ wrapper: createWrapper() },
)
await waitFor(() => {
expect(result.current.isEmbedding).toBe(true)
})
expect(typeof result.current.invalidate).toBe('function')
// Call invalidate should not throw
await act(async () => {
result.current.invalidate()
})
})
it('should provide resetStatus function that clears data', async () => {
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus())
const { result } = renderHook(
() => useEmbeddingStatus({ datasetId: 'ds1', documentId: 'doc1' }),
{ wrapper: createWrapper() },
)
await waitFor(() => {
expect(result.current.data).toBeDefined()
})
// Reset status should clear the data
await act(async () => {
result.current.resetStatus()
})
await waitFor(() => {
expect(result.current.data).toBeNull()
})
})
})
describe('usePauseIndexing', () => {
it('should call pauseDocIndexing when mutate is called', async () => {
mockPauseDocIndexing.mockResolvedValue({ result: 'success' })
const { result } = renderHook(
() => usePauseIndexing({ datasetId: 'ds1', documentId: 'doc1' }),
{ wrapper: createWrapper() },
)
await act(async () => {
result.current.mutate()
})
await waitFor(() => {
expect(mockPauseDocIndexing).toHaveBeenCalledWith({
datasetId: 'ds1',
documentId: 'doc1',
})
})
})
it('should call onSuccess callback on successful pause', async () => {
mockPauseDocIndexing.mockResolvedValue({ result: 'success' })
const onSuccess = vi.fn()
const { result } = renderHook(
() => usePauseIndexing({ datasetId: 'ds1', documentId: 'doc1', onSuccess }),
{ wrapper: createWrapper() },
)
await act(async () => {
result.current.mutate()
})
await waitFor(() => {
expect(onSuccess).toHaveBeenCalled()
})
})
it('should call onError callback on failed pause', async () => {
const error = new Error('Network error')
mockPauseDocIndexing.mockRejectedValue(error)
const onError = vi.fn()
const { result } = renderHook(
() => usePauseIndexing({ datasetId: 'ds1', documentId: 'doc1', onError }),
{ wrapper: createWrapper() },
)
await act(async () => {
result.current.mutate()
})
await waitFor(() => {
expect(onError).toHaveBeenCalled()
expect(onError.mock.calls[0][0]).toEqual(error)
})
})
})
describe('useResumeIndexing', () => {
it('should call resumeDocIndexing when mutate is called', async () => {
mockResumeDocIndexing.mockResolvedValue({ result: 'success' })
const { result } = renderHook(
() => useResumeIndexing({ datasetId: 'ds1', documentId: 'doc1' }),
{ wrapper: createWrapper() },
)
await act(async () => {
result.current.mutate()
})
await waitFor(() => {
expect(mockResumeDocIndexing).toHaveBeenCalledWith({
datasetId: 'ds1',
documentId: 'doc1',
})
})
})
it('should call onSuccess callback on successful resume', async () => {
mockResumeDocIndexing.mockResolvedValue({ result: 'success' })
const onSuccess = vi.fn()
const { result } = renderHook(
() => useResumeIndexing({ datasetId: 'ds1', documentId: 'doc1', onSuccess }),
{ wrapper: createWrapper() },
)
await act(async () => {
result.current.mutate()
})
await waitFor(() => {
expect(onSuccess).toHaveBeenCalled()
})
})
})
describe('useInvalidateEmbeddingStatus', () => {
it('should return a function', () => {
const { result } = renderHook(
() => useInvalidateEmbeddingStatus(),
{ wrapper: createWrapper() },
)
expect(typeof result.current).toBe('function')
})
it('should invalidate specific query when datasetId and documentId are provided', async () => {
const queryClient = createTestQueryClient()
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
// Set some initial data in the cache
queryClient.setQueryData(['embedding', 'indexing-status', 'ds1', 'doc1'], {
id: 'doc1',
indexing_status: 'indexing',
})
const { result } = renderHook(
() => useInvalidateEmbeddingStatus(),
{ wrapper },
)
await act(async () => {
result.current('ds1', 'doc1')
})
// The query should be invalidated (marked as stale)
const queryState = queryClient.getQueryState(['embedding', 'indexing-status', 'ds1', 'doc1'])
expect(queryState?.isInvalidated).toBe(true)
})
it('should invalidate all embedding status queries when ids are not provided', async () => {
const queryClient = createTestQueryClient()
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
// Set some initial data in the cache for multiple documents
queryClient.setQueryData(['embedding', 'indexing-status', 'ds1', 'doc1'], {
id: 'doc1',
indexing_status: 'indexing',
})
queryClient.setQueryData(['embedding', 'indexing-status', 'ds2', 'doc2'], {
id: 'doc2',
indexing_status: 'completed',
})
const { result } = renderHook(
() => useInvalidateEmbeddingStatus(),
{ wrapper },
)
await act(async () => {
result.current()
})
// Both queries should be invalidated
const queryState1 = queryClient.getQueryState(['embedding', 'indexing-status', 'ds1', 'doc1'])
const queryState2 = queryClient.getQueryState(['embedding', 'indexing-status', 'ds2', 'doc2'])
expect(queryState1?.isInvalidated).toBe(true)
expect(queryState2?.isInvalidated).toBe(true)
})
})
})

View File

@ -0,0 +1,149 @@
import type { CommonResponse } from '@/models/common'
import type { IndexingStatusResponse } from '@/models/datasets'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import {
fetchIndexingStatus,
pauseDocIndexing,
resumeDocIndexing,
} from '@/service/datasets'
const NAME_SPACE = 'embedding'
export type EmbeddingStatusType = 'indexing' | 'splitting' | 'parsing' | 'cleaning' | 'completed' | 'paused' | 'error' | 'waiting' | ''
const EMBEDDING_STATUSES = ['indexing', 'splitting', 'parsing', 'cleaning'] as const
const TERMINAL_STATUSES = ['completed', 'error', 'paused'] as const
export const isEmbeddingStatus = (status?: string): boolean => {
return EMBEDDING_STATUSES.includes(status as typeof EMBEDDING_STATUSES[number])
}
export const isTerminalStatus = (status?: string): boolean => {
return TERMINAL_STATUSES.includes(status as typeof TERMINAL_STATUSES[number])
}
export const calculatePercent = (completed?: number, total?: number): number => {
if (!total || total === 0)
return 0
const percent = Math.round((completed || 0) * 100 / total)
return Math.min(percent, 100)
}
type UseEmbeddingStatusOptions = {
datasetId?: string
documentId?: string
enabled?: boolean
onComplete?: () => void
}
export const useEmbeddingStatus = ({
datasetId,
documentId,
enabled = true,
onComplete,
}: UseEmbeddingStatusOptions) => {
const queryClient = useQueryClient()
const isPolling = useRef(false)
const onCompleteRef = useRef(onComplete)
onCompleteRef.current = onComplete
const queryKey = useMemo(
() => [NAME_SPACE, 'indexing-status', datasetId, documentId] as const,
[datasetId, documentId],
)
const query = useQuery<IndexingStatusResponse>({
queryKey,
queryFn: () => fetchIndexingStatus({ datasetId: datasetId!, documentId: documentId! }),
enabled: enabled && !!datasetId && !!documentId,
refetchInterval: (query) => {
const status = query.state.data?.indexing_status
if (isTerminalStatus(status)) {
return false
}
return 2500
},
refetchOnWindowFocus: false,
})
const status = query.data?.indexing_status || ''
const isEmbedding = isEmbeddingStatus(status)
const isCompleted = status === 'completed'
const isPaused = status === 'paused'
const isError = status === 'error'
const percent = calculatePercent(query.data?.completed_segments, query.data?.total_segments)
// Handle completion callback
useEffect(() => {
if (isTerminalStatus(status) && isPolling.current) {
isPolling.current = false
onCompleteRef.current?.()
}
if (isEmbedding) {
isPolling.current = true
}
}, [status, isEmbedding])
const invalidate = useCallback(() => {
queryClient.invalidateQueries({ queryKey })
}, [queryClient, queryKey])
const resetStatus = useCallback(() => {
queryClient.setQueryData(queryKey, null)
}, [queryClient, queryKey])
return {
data: query.data,
isLoading: query.isLoading,
isEmbedding,
isCompleted,
isPaused,
isError,
percent,
invalidate,
resetStatus,
refetch: query.refetch,
}
}
type UsePauseResumeOptions = {
datasetId?: string
documentId?: string
onSuccess?: () => void
onError?: (error: Error) => void
}
export const usePauseIndexing = ({ datasetId, documentId, onSuccess, onError }: UsePauseResumeOptions) => {
return useMutation<CommonResponse, Error>({
mutationKey: [NAME_SPACE, 'pause', datasetId, documentId],
mutationFn: () => pauseDocIndexing({ datasetId: datasetId!, documentId: documentId! }),
onSuccess,
onError,
})
}
export const useResumeIndexing = ({ datasetId, documentId, onSuccess, onError }: UsePauseResumeOptions) => {
return useMutation<CommonResponse, Error>({
mutationKey: [NAME_SPACE, 'resume', datasetId, documentId],
mutationFn: () => resumeDocIndexing({ datasetId: datasetId!, documentId: documentId! }),
onSuccess,
onError,
})
}
export const useInvalidateEmbeddingStatus = () => {
const queryClient = useQueryClient()
return useCallback((datasetId?: string, documentId?: string) => {
if (datasetId && documentId) {
queryClient.invalidateQueries({
queryKey: [NAME_SPACE, 'indexing-status', datasetId, documentId],
})
}
else {
queryClient.invalidateQueries({
queryKey: [NAME_SPACE, 'indexing-status'],
})
}
}, [queryClient])
}

View File

@ -0,0 +1,337 @@
import type { ReactNode } from 'react'
import type { DocumentContextValue } from '../context'
import type { IndexingStatusResponse, ProcessRuleResponse } from '@/models/datasets'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ProcessMode } from '@/models/datasets'
import * as datasetsService from '@/service/datasets'
import * as useDataset from '@/service/knowledge/use-dataset'
import { RETRIEVE_METHOD } from '@/types/app'
import { IndexingType } from '../../../create/step-two'
import { DocumentContext } from '../context'
import EmbeddingDetail from './index'
vi.mock('@/service/datasets')
vi.mock('@/service/knowledge/use-dataset')
const mockFetchIndexingStatus = vi.mocked(datasetsService.fetchIndexingStatus)
const mockPauseDocIndexing = vi.mocked(datasetsService.pauseDocIndexing)
const mockResumeDocIndexing = vi.mocked(datasetsService.resumeDocIndexing)
const mockUseProcessRule = vi.mocked(useDataset.useProcessRule)
const createTestQueryClient = () => new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
mutations: { retry: false },
},
})
const createWrapper = (contextValue: DocumentContextValue = { datasetId: 'ds1', documentId: 'doc1' }) => {
const queryClient = createTestQueryClient()
return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>
<DocumentContext.Provider value={contextValue}>
{children}
</DocumentContext.Provider>
</QueryClientProvider>
)
}
const mockIndexingStatus = (overrides: Partial<IndexingStatusResponse> = {}): IndexingStatusResponse => ({
id: 'doc1',
indexing_status: 'indexing',
completed_segments: 50,
total_segments: 100,
processing_started_at: Date.now(),
parsing_completed_at: 0,
cleaning_completed_at: 0,
splitting_completed_at: 0,
completed_at: null,
paused_at: null,
error: null,
stopped_at: null,
...overrides,
})
const mockProcessRule = (overrides: Partial<ProcessRuleResponse> = {}): ProcessRuleResponse => ({
mode: ProcessMode.general,
rules: {
segmentation: { separator: '\n', max_tokens: 500, chunk_overlap: 50 },
pre_processing_rules: [{ id: 'remove_extra_spaces', enabled: true }],
parent_mode: 'full-doc',
subchunk_segmentation: { separator: '\n', max_tokens: 200, chunk_overlap: 20 },
},
limits: { indexing_max_segmentation_tokens_length: 4000 },
...overrides,
})
describe('EmbeddingDetail', () => {
const defaultProps = {
detailUpdate: vi.fn(),
indexingType: IndexingType.QUALIFIED,
retrievalMethod: RETRIEVE_METHOD.semantic,
}
beforeEach(() => {
vi.clearAllMocks()
mockUseProcessRule.mockReturnValue({
data: mockProcessRule(),
isLoading: false,
error: null,
} as ReturnType<typeof useDataset.useProcessRule>)
})
describe('Rendering', () => {
it('should render without crashing', async () => {
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus())
render(<EmbeddingDetail {...defaultProps} />, { wrapper: createWrapper() })
await waitFor(() => {
expect(screen.getByText(/embedding\.processing/i)).toBeInTheDocument()
})
})
it('should render with provided datasetId and documentId props', async () => {
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus())
render(
<EmbeddingDetail {...defaultProps} datasetId="custom-ds" documentId="custom-doc" />,
{ wrapper: createWrapper({ datasetId: '', documentId: '' }) },
)
await waitFor(() => {
expect(mockFetchIndexingStatus).toHaveBeenCalledWith({
datasetId: 'custom-ds',
documentId: 'custom-doc',
})
})
})
it('should fall back to context values when props are not provided', async () => {
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus())
render(<EmbeddingDetail {...defaultProps} />, { wrapper: createWrapper() })
await waitFor(() => {
expect(mockFetchIndexingStatus).toHaveBeenCalledWith({
datasetId: 'ds1',
documentId: 'doc1',
})
})
})
})
describe('Status Display', () => {
it('should show processing status when indexing', async () => {
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus({ indexing_status: 'indexing' }))
render(<EmbeddingDetail {...defaultProps} />, { wrapper: createWrapper() })
await waitFor(() => {
expect(screen.getByText(/embedding\.processing/i)).toBeInTheDocument()
})
})
it('should show completed status', async () => {
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus({ indexing_status: 'completed' }))
render(<EmbeddingDetail {...defaultProps} />, { wrapper: createWrapper() })
await waitFor(() => {
expect(screen.getByText(/embedding\.completed/i)).toBeInTheDocument()
})
})
it('should show paused status', async () => {
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus({ indexing_status: 'paused' }))
render(<EmbeddingDetail {...defaultProps} />, { wrapper: createWrapper() })
await waitFor(() => {
expect(screen.getByText(/embedding\.paused/i)).toBeInTheDocument()
})
})
it('should show error status', async () => {
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus({ indexing_status: 'error' }))
render(<EmbeddingDetail {...defaultProps} />, { wrapper: createWrapper() })
await waitFor(() => {
expect(screen.getByText(/embedding\.error/i)).toBeInTheDocument()
})
})
})
describe('Progress Display', () => {
it('should display segment progress', async () => {
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus({
completed_segments: 50,
total_segments: 100,
}))
render(<EmbeddingDetail {...defaultProps} />, { wrapper: createWrapper() })
await waitFor(() => {
expect(screen.getByText(/50\/100/)).toBeInTheDocument()
expect(screen.getByText(/50%/)).toBeInTheDocument()
})
})
})
describe('Pause/Resume Actions', () => {
it('should show pause button when embedding is in progress', async () => {
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus({ indexing_status: 'indexing' }))
render(<EmbeddingDetail {...defaultProps} />, { wrapper: createWrapper() })
await waitFor(() => {
expect(screen.getByText(/embedding\.pause/i)).toBeInTheDocument()
})
})
it('should show resume button when paused', async () => {
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus({ indexing_status: 'paused' }))
render(<EmbeddingDetail {...defaultProps} />, { wrapper: createWrapper() })
await waitFor(() => {
expect(screen.getByText(/embedding\.resume/i)).toBeInTheDocument()
})
})
it('should call pause API when pause button is clicked', async () => {
const user = userEvent.setup()
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus({ indexing_status: 'indexing' }))
mockPauseDocIndexing.mockResolvedValue({ result: 'success' })
render(<EmbeddingDetail {...defaultProps} />, { wrapper: createWrapper() })
await waitFor(() => {
expect(screen.getByText(/embedding\.pause/i)).toBeInTheDocument()
})
await user.click(screen.getByRole('button', { name: /pause/i }))
await waitFor(() => {
expect(mockPauseDocIndexing).toHaveBeenCalledWith({
datasetId: 'ds1',
documentId: 'doc1',
})
})
})
it('should call resume API when resume button is clicked', async () => {
const user = userEvent.setup()
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus({ indexing_status: 'paused' }))
mockResumeDocIndexing.mockResolvedValue({ result: 'success' })
render(<EmbeddingDetail {...defaultProps} />, { wrapper: createWrapper() })
await waitFor(() => {
expect(screen.getByText(/embedding\.resume/i)).toBeInTheDocument()
})
await user.click(screen.getByRole('button', { name: /resume/i }))
await waitFor(() => {
expect(mockResumeDocIndexing).toHaveBeenCalledWith({
datasetId: 'ds1',
documentId: 'doc1',
})
})
})
})
describe('Rule Detail', () => {
it('should display rule detail section', async () => {
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus())
render(<EmbeddingDetail {...defaultProps} />, { wrapper: createWrapper() })
await waitFor(() => {
expect(screen.getByText(/stepTwo\.indexMode/i)).toBeInTheDocument()
})
})
it('should display qualified index mode', async () => {
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus())
render(
<EmbeddingDetail {...defaultProps} indexingType={IndexingType.QUALIFIED} />,
{ wrapper: createWrapper() },
)
await waitFor(() => {
expect(screen.getByText(/stepTwo\.qualified/i)).toBeInTheDocument()
})
})
it('should display economical index mode', async () => {
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus())
render(
<EmbeddingDetail {...defaultProps} indexingType={IndexingType.ECONOMICAL} />,
{ wrapper: createWrapper() },
)
await waitFor(() => {
expect(screen.getByText(/stepTwo\.economical/i)).toBeInTheDocument()
})
})
})
describe('detailUpdate Callback', () => {
it('should call detailUpdate when status becomes terminal', async () => {
const detailUpdate = vi.fn()
// First call returns indexing, subsequent call returns completed
mockFetchIndexingStatus
.mockResolvedValueOnce(mockIndexingStatus({ indexing_status: 'indexing' }))
.mockResolvedValueOnce(mockIndexingStatus({ indexing_status: 'completed' }))
render(
<EmbeddingDetail {...defaultProps} detailUpdate={detailUpdate} />,
{ wrapper: createWrapper() },
)
// Wait for the terminal status to trigger detailUpdate
await waitFor(() => {
expect(mockFetchIndexingStatus).toHaveBeenCalled()
}, { timeout: 5000 })
})
})
describe('Edge Cases', () => {
it('should handle missing context values', async () => {
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus())
render(
<EmbeddingDetail {...defaultProps} datasetId="explicit-ds" documentId="explicit-doc" />,
{ wrapper: createWrapper({ datasetId: undefined, documentId: undefined }) },
)
await waitFor(() => {
expect(mockFetchIndexingStatus).toHaveBeenCalledWith({
datasetId: 'explicit-ds',
documentId: 'explicit-doc',
})
})
})
it('should render skeleton component', async () => {
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus())
const { container } = render(<EmbeddingDetail {...defaultProps} />, { wrapper: createWrapper() })
// EmbeddingSkeleton should be rendered - check for the skeleton wrapper element
await waitFor(() => {
const skeletonWrapper = container.querySelector('.bg-dataset-chunk-list-mask-bg')
expect(skeletonWrapper).toBeInTheDocument()
})
})
})
})

View File

@ -1,31 +1,18 @@
import type { FC } from 'react'
import type { CommonResponse } from '@/models/common'
import type { IndexingStatusResponse, ProcessRuleResponse } from '@/models/datasets'
import { RiLoader2Line, RiPauseCircleLine, RiPlayCircleLine } from '@remixicon/react'
import Image from 'next/image'
import type { IndexingType } from '../../../create/step-two'
import type { RETRIEVE_METHOD } from '@/types/app'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Divider from '@/app/components/base/divider'
import { ToastContext } from '@/app/components/base/toast'
import { ProcessMode } from '@/models/datasets'
import {
fetchIndexingStatus as doFetchIndexingStatus,
pauseDocIndexing,
resumeDocIndexing,
} from '@/service/datasets'
import { useProcessRule } from '@/service/knowledge/use-dataset'
import { RETRIEVE_METHOD } from '@/types/app'
import { asyncRunSafe, sleep } from '@/utils'
import { cn } from '@/utils/classnames'
import { indexMethodIcon, retrievalIcon } from '../../../create/icons'
import { IndexingType } from '../../../create/step-two'
import { useDocumentContext } from '../context'
import { FieldInfo } from '../metadata'
import { ProgressBar, RuleDetail, SegmentProgress, StatusHeader } from './components'
import { useEmbeddingStatus, usePauseIndexing, useResumeIndexing } from './hooks'
import EmbeddingSkeleton from './skeleton'
type IEmbeddingDetailProps = {
type EmbeddingDetailProps = {
datasetId?: string
documentId?: string
indexingType?: IndexingType
@ -33,128 +20,7 @@ type IEmbeddingDetailProps = {
detailUpdate: VoidFunction
}
type IRuleDetailProps = {
sourceData?: ProcessRuleResponse
indexingType?: IndexingType
retrievalMethod?: RETRIEVE_METHOD
}
const RuleDetail: FC<IRuleDetailProps> = React.memo(({
sourceData,
indexingType,
retrievalMethod,
}) => {
const { t } = useTranslation()
const segmentationRuleMap = {
mode: t('embedding.mode', { ns: 'datasetDocuments' }),
segmentLength: t('embedding.segmentLength', { ns: 'datasetDocuments' }),
textCleaning: t('embedding.textCleaning', { ns: 'datasetDocuments' }),
}
const getRuleName = (key: string) => {
if (key === 'remove_extra_spaces')
return t('stepTwo.removeExtraSpaces', { ns: 'datasetCreation' })
if (key === 'remove_urls_emails')
return t('stepTwo.removeUrlEmails', { ns: 'datasetCreation' })
if (key === 'remove_stopwords')
return t('stepTwo.removeStopwords', { ns: 'datasetCreation' })
}
const isNumber = (value: unknown) => {
return typeof value === 'number'
}
const getValue = useCallback((field: string) => {
let value: string | number | undefined = '-'
const maxTokens = isNumber(sourceData?.rules?.segmentation?.max_tokens)
? sourceData.rules.segmentation.max_tokens
: value
const childMaxTokens = isNumber(sourceData?.rules?.subchunk_segmentation?.max_tokens)
? sourceData.rules.subchunk_segmentation.max_tokens
: value
switch (field) {
case 'mode':
value = !sourceData?.mode
? value
: sourceData.mode === ProcessMode.general
? (t('embedding.custom', { ns: 'datasetDocuments' }) as string)
: `${t('embedding.hierarchical', { ns: 'datasetDocuments' })} · ${sourceData?.rules?.parent_mode === 'paragraph'
? t('parentMode.paragraph', { ns: 'dataset' })
: t('parentMode.fullDoc', { ns: 'dataset' })}`
break
case 'segmentLength':
value = !sourceData?.mode
? value
: sourceData.mode === ProcessMode.general
? maxTokens
: `${t('embedding.parentMaxTokens', { ns: 'datasetDocuments' })} ${maxTokens}; ${t('embedding.childMaxTokens', { ns: 'datasetDocuments' })} ${childMaxTokens}`
break
default:
value = !sourceData?.mode
? value
: sourceData?.rules?.pre_processing_rules?.filter(rule =>
rule.enabled).map(rule => getRuleName(rule.id)).join(',')
break
}
return value
}, [sourceData])
return (
<div className="py-3">
<div className="flex flex-col gap-y-1">
{Object.keys(segmentationRuleMap).map((field) => {
return (
<FieldInfo
key={field}
label={segmentationRuleMap[field as keyof typeof segmentationRuleMap]}
displayedValue={String(getValue(field))}
/>
)
})}
</div>
<Divider type="horizontal" className="bg-divider-subtle" />
<FieldInfo
label={t('stepTwo.indexMode', { ns: 'datasetCreation' })}
displayedValue={t(`stepTwo.${indexingType === IndexingType.ECONOMICAL ? 'economical' : 'qualified'}`, { ns: 'datasetCreation' }) as string}
valueIcon={(
<Image
className="size-4"
src={
indexingType === IndexingType.ECONOMICAL
? indexMethodIcon.economical
: indexMethodIcon.high_quality
}
alt=""
/>
)}
/>
<FieldInfo
label={t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
displayedValue={t(`retrieval.${indexingType === IndexingType.ECONOMICAL ? 'keyword_search' : retrievalMethod ?? 'semantic_search'}.title`, { ns: 'dataset' })}
valueIcon={(
<Image
className="size-4"
src={
retrievalMethod === RETRIEVE_METHOD.fullText
? retrievalIcon.fullText
: retrievalMethod === RETRIEVE_METHOD.hybrid
? retrievalIcon.hybrid
: retrievalIcon.vector
}
alt=""
/>
)}
/>
</div>
)
})
RuleDetail.displayName = 'RuleDetail'
const EmbeddingDetail: FC<IEmbeddingDetailProps> = ({
const EmbeddingDetail: FC<EmbeddingDetailProps> = ({
datasetId: dstId,
documentId: docId,
detailUpdate,
@ -164,144 +30,95 @@ const EmbeddingDetail: FC<IEmbeddingDetailProps> = ({
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const datasetId = useDocumentContext(s => s.datasetId)
const documentId = useDocumentContext(s => s.documentId)
const localDatasetId = dstId ?? datasetId
const localDocumentId = docId ?? documentId
const contextDatasetId = useDocumentContext(s => s.datasetId)
const contextDocumentId = useDocumentContext(s => s.documentId)
const datasetId = dstId ?? contextDatasetId
const documentId = docId ?? contextDocumentId
const [indexingStatusDetail, setIndexingStatusDetail] = useState<IndexingStatusResponse | null>(null)
const fetchIndexingStatus = async () => {
const status = await doFetchIndexingStatus({ datasetId: localDatasetId, documentId: localDocumentId })
setIndexingStatusDetail(status)
return status
}
const {
data: indexingStatus,
isEmbedding,
isCompleted,
isPaused,
isError,
percent,
resetStatus,
refetch,
} = useEmbeddingStatus({
datasetId,
documentId,
onComplete: detailUpdate,
})
const isStopQuery = useRef(false)
const stopQueryStatus = useCallback(() => {
isStopQuery.current = true
}, [])
const { data: ruleDetail } = useProcessRule(documentId)
const startQueryStatus = useCallback(async () => {
if (isStopQuery.current)
return
const handleSuccess = useCallback(() => {
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
}, [notify, t])
try {
const indexingStatusDetail = await fetchIndexingStatus()
if (['completed', 'error', 'paused'].includes(indexingStatusDetail?.indexing_status)) {
stopQueryStatus()
detailUpdate()
return
}
const handleError = useCallback(() => {
notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
}, [notify, t])
await sleep(2500)
await startQueryStatus()
}
catch {
await sleep(2500)
await startQueryStatus()
}
}, [stopQueryStatus])
const pauseMutation = usePauseIndexing({
datasetId,
documentId,
onSuccess: () => {
handleSuccess()
resetStatus()
},
onError: handleError,
})
useEffect(() => {
isStopQuery.current = false
startQueryStatus()
return () => {
stopQueryStatus()
}
}, [startQueryStatus, stopQueryStatus])
const resumeMutation = useResumeIndexing({
datasetId,
documentId,
onSuccess: () => {
handleSuccess()
refetch()
detailUpdate()
},
onError: handleError,
})
const { data: ruleDetail } = useProcessRule(localDocumentId)
const handlePause = useCallback(() => {
pauseMutation.mutate()
}, [pauseMutation])
const isEmbedding = useMemo(() => ['indexing', 'splitting', 'parsing', 'cleaning'].includes(indexingStatusDetail?.indexing_status || ''), [indexingStatusDetail])
const isEmbeddingCompleted = useMemo(() => ['completed'].includes(indexingStatusDetail?.indexing_status || ''), [indexingStatusDetail])
const isEmbeddingPaused = useMemo(() => ['paused'].includes(indexingStatusDetail?.indexing_status || ''), [indexingStatusDetail])
const isEmbeddingError = useMemo(() => ['error'].includes(indexingStatusDetail?.indexing_status || ''), [indexingStatusDetail])
const percent = useMemo(() => {
const completedCount = indexingStatusDetail?.completed_segments || 0
const totalCount = indexingStatusDetail?.total_segments || 0
if (totalCount === 0)
return 0
const percent = Math.round(completedCount * 100 / totalCount)
return percent > 100 ? 100 : percent
}, [indexingStatusDetail])
const handleSwitch = async () => {
const opApi = isEmbedding ? pauseDocIndexing : resumeDocIndexing
const [e] = await asyncRunSafe<CommonResponse>(opApi({ datasetId: localDatasetId, documentId: localDocumentId }) as Promise<CommonResponse>)
if (!e) {
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
// if the embedding is resumed from paused, we need to start the query status
if (isEmbeddingPaused) {
isStopQuery.current = false
startQueryStatus()
detailUpdate()
}
setIndexingStatusDetail(null)
}
else {
notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
}
}
const handleResume = useCallback(() => {
resumeMutation.mutate()
}, [resumeMutation])
return (
<>
<div className="flex flex-col gap-y-2 px-16 py-12">
<div className="flex h-6 items-center gap-x-1">
{isEmbedding && <RiLoader2Line className="h-4 w-4 animate-spin text-text-secondary" />}
<span className="system-md-semibold-uppercase grow text-text-secondary">
{isEmbedding && t('embedding.processing', { ns: 'datasetDocuments' })}
{isEmbeddingCompleted && t('embedding.completed', { ns: 'datasetDocuments' })}
{isEmbeddingPaused && t('embedding.paused', { ns: 'datasetDocuments' })}
{isEmbeddingError && t('embedding.error', { ns: 'datasetDocuments' })}
</span>
{isEmbedding && (
<button
type="button"
className={`flex items-center gap-x-1 rounded-md border-[0.5px]
border-components-button-secondary-border bg-components-button-secondary-bg px-1.5 py-1 shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]`}
onClick={handleSwitch}
>
<RiPauseCircleLine className="h-3.5 w-3.5 text-components-button-secondary-text" />
<span className="system-xs-medium pr-[3px] text-components-button-secondary-text">
{t('embedding.pause', { ns: 'datasetDocuments' })}
</span>
</button>
)}
{isEmbeddingPaused && (
<button
type="button"
className={`flex items-center gap-x-1 rounded-md border-[0.5px]
border-components-button-secondary-border bg-components-button-secondary-bg px-1.5 py-1 shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]`}
onClick={handleSwitch}
>
<RiPlayCircleLine className="h-3.5 w-3.5 text-components-button-secondary-text" />
<span className="system-xs-medium pr-[3px] text-components-button-secondary-text">
{t('embedding.resume', { ns: 'datasetDocuments' })}
</span>
</button>
)}
</div>
{/* progress bar */}
<div className={cn(
'flex h-2 w-full items-center overflow-hidden rounded-md border border-components-progress-bar-border',
isEmbedding ? 'bg-components-progress-bar-bg/50' : 'bg-components-progress-bar-bg',
)}
>
<div
className={cn(
'h-full',
(isEmbedding || isEmbeddingCompleted) && 'bg-components-progress-bar-progress-solid',
(isEmbeddingPaused || isEmbeddingError) && 'bg-components-progress-bar-progress-highlight',
)}
style={{ width: `${percent}%` }}
/>
</div>
<div className="flex w-full items-center">
<span className="system-xs-medium text-text-secondary">
{`${t('embedding.segments', { ns: 'datasetDocuments' })} ${indexingStatusDetail?.completed_segments || '--'}/${indexingStatusDetail?.total_segments || '--'} · ${percent}%`}
</span>
</div>
<RuleDetail sourceData={ruleDetail} indexingType={indexingType} retrievalMethod={retrievalMethod} />
<StatusHeader
isEmbedding={isEmbedding}
isCompleted={isCompleted}
isPaused={isPaused}
isError={isError}
onPause={handlePause}
onResume={handleResume}
isPauseLoading={pauseMutation.isPending}
isResumeLoading={resumeMutation.isPending}
/>
<ProgressBar
percent={percent}
isEmbedding={isEmbedding}
isCompleted={isCompleted}
isPaused={isPaused}
isError={isError}
/>
<SegmentProgress
completedSegments={indexingStatus?.completed_segments}
totalSegments={indexingStatus?.total_segments}
percent={percent}
/>
<RuleDetail
sourceData={ruleDetail}
indexingType={indexingType}
retrievalMethod={retrievalMethod}
/>
</div>
<EmbeddingSkeleton />
</>

View File

@ -1,441 +0,0 @@
import type { Member } from '@/models/common'
import type { DataSet, IconInfo } from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
import { IndexingType } from '../../../create/step-two'
import BasicInfoSection from './basic-info-section'
// Mock app-context
vi.mock('@/context/app-context', () => ({
useSelector: () => ({
id: 'user-1',
name: 'Current User',
email: 'current@example.com',
avatar_url: '',
role: 'owner',
}),
}))
// Mock image uploader hooks for AppIconPicker
vi.mock('@/app/components/base/image-uploader/hooks', () => ({
useLocalFileUploader: () => ({
disabled: false,
handleLocalFileUpload: vi.fn(),
}),
useImageFiles: () => ({
files: [],
onUpload: vi.fn(),
onRemove: vi.fn(),
onReUpload: vi.fn(),
onImageLinkLoadError: vi.fn(),
onImageLinkLoadSuccess: vi.fn(),
onClear: vi.fn(),
}),
}))
describe('BasicInfoSection', () => {
const mockDataset: DataSet = {
id: 'dataset-1',
name: 'Test Dataset',
description: 'Test description',
permission: DatasetPermission.onlyMe,
icon_info: {
icon_type: 'emoji',
icon: '📚',
icon_background: '#FFFFFF',
icon_url: '',
},
indexing_technique: IndexingType.QUALIFIED,
indexing_status: 'completed',
data_source_type: DataSourceType.FILE,
doc_form: ChunkingMode.text,
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
embedding_available: true,
app_count: 0,
document_count: 5,
total_document_count: 5,
word_count: 1000,
provider: 'vendor',
tags: [],
partial_member_list: [],
external_knowledge_info: {
external_knowledge_id: 'ext-1',
external_knowledge_api_id: 'api-1',
external_knowledge_api_name: 'External API',
external_knowledge_api_endpoint: 'https://api.example.com',
},
external_retrieval_model: {
top_k: 3,
score_threshold: 0.7,
score_threshold_enabled: true,
},
retrieval_model_dict: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0.5,
} as RetrievalConfig,
retrieval_model: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0.5,
} as RetrievalConfig,
built_in_field_enabled: false,
keyword_number: 10,
created_by: 'user-1',
updated_by: 'user-1',
updated_at: Date.now(),
runtime_mode: 'general',
enable_api: true,
is_multimodal: false,
}
const mockMemberList: Member[] = [
{ id: 'user-1', name: 'User 1', email: 'user1@example.com', role: 'owner', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' },
{ id: 'user-2', name: 'User 2', email: 'user2@example.com', role: 'admin', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' },
]
const mockIconInfo: IconInfo = {
icon_type: 'emoji',
icon: '📚',
icon_background: '#FFFFFF',
icon_url: '',
}
const defaultProps = {
currentDataset: mockDataset,
isCurrentWorkspaceDatasetOperator: false,
name: 'Test Dataset',
setName: vi.fn(),
description: 'Test description',
setDescription: vi.fn(),
iconInfo: mockIconInfo,
showAppIconPicker: false,
handleOpenAppIconPicker: vi.fn(),
handleSelectAppIcon: vi.fn(),
handleCloseAppIconPicker: vi.fn(),
permission: DatasetPermission.onlyMe,
setPermission: vi.fn(),
selectedMemberIDs: ['user-1'],
setSelectedMemberIDs: vi.fn(),
memberList: mockMemberList,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<BasicInfoSection {...defaultProps} />)
expect(screen.getByText(/form\.nameAndIcon/i)).toBeInTheDocument()
})
it('should render name and icon section', () => {
render(<BasicInfoSection {...defaultProps} />)
expect(screen.getByText(/form\.nameAndIcon/i)).toBeInTheDocument()
})
it('should render description section', () => {
render(<BasicInfoSection {...defaultProps} />)
expect(screen.getByText(/form\.desc/i)).toBeInTheDocument()
})
it('should render permissions section', () => {
render(<BasicInfoSection {...defaultProps} />)
// Use exact match to avoid matching "permissionsOnlyMe"
expect(screen.getByText('datasetSettings.form.permissions')).toBeInTheDocument()
})
it('should render name input with correct value', () => {
render(<BasicInfoSection {...defaultProps} />)
const nameInput = screen.getByDisplayValue('Test Dataset')
expect(nameInput).toBeInTheDocument()
})
it('should render description textarea with correct value', () => {
render(<BasicInfoSection {...defaultProps} />)
const descriptionTextarea = screen.getByDisplayValue('Test description')
expect(descriptionTextarea).toBeInTheDocument()
})
it('should render app icon with emoji', () => {
const { container } = render(<BasicInfoSection {...defaultProps} />)
// The icon section should be rendered (emoji may be in a span or SVG)
const iconSection = container.querySelector('[class*="cursor-pointer"]')
expect(iconSection).toBeInTheDocument()
})
})
describe('Name Input', () => {
it('should call setName when name input changes', () => {
const setName = vi.fn()
render(<BasicInfoSection {...defaultProps} setName={setName} />)
const nameInput = screen.getByDisplayValue('Test Dataset')
fireEvent.change(nameInput, { target: { value: 'New Name' } })
expect(setName).toHaveBeenCalledWith('New Name')
})
it('should disable name input when embedding is not available', () => {
const datasetWithoutEmbedding = { ...mockDataset, embedding_available: false }
render(<BasicInfoSection {...defaultProps} currentDataset={datasetWithoutEmbedding} />)
const nameInput = screen.getByDisplayValue('Test Dataset')
expect(nameInput).toBeDisabled()
})
it('should enable name input when embedding is available', () => {
render(<BasicInfoSection {...defaultProps} />)
const nameInput = screen.getByDisplayValue('Test Dataset')
expect(nameInput).not.toBeDisabled()
})
it('should display empty name', () => {
const { container } = render(<BasicInfoSection {...defaultProps} name="" />)
// Find the name input by its structure - may be type=text or just input
const nameInput = container.querySelector('input')
expect(nameInput).toHaveValue('')
})
})
describe('Description Textarea', () => {
it('should call setDescription when description changes', () => {
const setDescription = vi.fn()
render(<BasicInfoSection {...defaultProps} setDescription={setDescription} />)
const descriptionTextarea = screen.getByDisplayValue('Test description')
fireEvent.change(descriptionTextarea, { target: { value: 'New Description' } })
expect(setDescription).toHaveBeenCalledWith('New Description')
})
it('should disable description textarea when embedding is not available', () => {
const datasetWithoutEmbedding = { ...mockDataset, embedding_available: false }
render(<BasicInfoSection {...defaultProps} currentDataset={datasetWithoutEmbedding} />)
const descriptionTextarea = screen.getByDisplayValue('Test description')
expect(descriptionTextarea).toBeDisabled()
})
it('should render placeholder', () => {
render(<BasicInfoSection {...defaultProps} description="" />)
const descriptionTextarea = screen.getByPlaceholderText(/form\.descPlaceholder/i)
expect(descriptionTextarea).toBeInTheDocument()
})
})
describe('App Icon', () => {
it('should call handleOpenAppIconPicker when icon is clicked', () => {
const handleOpenAppIconPicker = vi.fn()
const { container } = render(<BasicInfoSection {...defaultProps} handleOpenAppIconPicker={handleOpenAppIconPicker} />)
// Find the clickable icon element - it's inside a wrapper that handles the click
const iconWrapper = container.querySelector('[class*="cursor-pointer"]')
if (iconWrapper) {
fireEvent.click(iconWrapper)
expect(handleOpenAppIconPicker).toHaveBeenCalled()
}
})
it('should render AppIconPicker when showAppIconPicker is true', () => {
const { baseElement } = render(<BasicInfoSection {...defaultProps} showAppIconPicker={true} />)
// AppIconPicker renders a modal with emoji tabs and options via portal
// We just verify the component renders without crashing when picker is shown
expect(baseElement).toBeInTheDocument()
})
it('should not render AppIconPicker when showAppIconPicker is false', () => {
const { container } = render(<BasicInfoSection {...defaultProps} showAppIconPicker={false} />)
// Check that AppIconPicker is not rendered
expect(container.querySelector('[data-testid="app-icon-picker"]')).not.toBeInTheDocument()
})
it('should render image icon when icon_type is image', () => {
const imageIconInfo: IconInfo = {
icon_type: 'image',
icon: 'file-123',
icon_background: undefined,
icon_url: 'https://example.com/icon.png',
}
render(<BasicInfoSection {...defaultProps} iconInfo={imageIconInfo} />)
// For image type, it renders an img element
const img = screen.queryByRole('img')
if (img) {
expect(img).toHaveAttribute('src', expect.stringContaining('icon.png'))
}
})
})
describe('Permission Selector', () => {
it('should render with correct permission value', () => {
render(<BasicInfoSection {...defaultProps} permission={DatasetPermission.onlyMe} />)
expect(screen.getByText(/form\.permissionsOnlyMe/i)).toBeInTheDocument()
})
it('should render all team members permission', () => {
render(<BasicInfoSection {...defaultProps} permission={DatasetPermission.allTeamMembers} />)
expect(screen.getByText(/form\.permissionsAllMember/i)).toBeInTheDocument()
})
it('should be disabled when embedding is not available', () => {
const datasetWithoutEmbedding = { ...mockDataset, embedding_available: false }
const { container } = render(
<BasicInfoSection {...defaultProps} currentDataset={datasetWithoutEmbedding} />,
)
// Check for disabled state via cursor-not-allowed class
const disabledElement = container.querySelector('[class*="cursor-not-allowed"]')
expect(disabledElement).toBeInTheDocument()
})
it('should be disabled when user is dataset operator', () => {
const { container } = render(
<BasicInfoSection {...defaultProps} isCurrentWorkspaceDatasetOperator={true} />,
)
const disabledElement = container.querySelector('[class*="cursor-not-allowed"]')
expect(disabledElement).toBeInTheDocument()
})
it('should call setPermission when permission changes', async () => {
const setPermission = vi.fn()
render(<BasicInfoSection {...defaultProps} setPermission={setPermission} />)
// Open dropdown
const trigger = screen.getByText(/form\.permissionsOnlyMe/i)
fireEvent.click(trigger)
await waitFor(() => {
// Click All Team Members option
const allMemberOptions = screen.getAllByText(/form\.permissionsAllMember/i)
fireEvent.click(allMemberOptions[0])
})
expect(setPermission).toHaveBeenCalledWith(DatasetPermission.allTeamMembers)
})
it('should call setSelectedMemberIDs when members are selected', async () => {
const setSelectedMemberIDs = vi.fn()
const { container } = render(
<BasicInfoSection
{...defaultProps}
permission={DatasetPermission.partialMembers}
setSelectedMemberIDs={setSelectedMemberIDs}
/>,
)
// For partial members permission, the member selector should be visible
// The exact interaction depends on the MemberSelector component
// We verify the component renders without crashing
expect(container).toBeInTheDocument()
})
})
describe('Undefined Dataset', () => {
it('should handle undefined currentDataset gracefully', () => {
render(<BasicInfoSection {...defaultProps} currentDataset={undefined} />)
// Should still render but inputs might behave differently
expect(screen.getByText(/form\.nameAndIcon/i)).toBeInTheDocument()
})
})
describe('Props Validation', () => {
it('should update when name prop changes', () => {
const { rerender } = render(<BasicInfoSection {...defaultProps} name="Initial Name" />)
expect(screen.getByDisplayValue('Initial Name')).toBeInTheDocument()
rerender(<BasicInfoSection {...defaultProps} name="Updated Name" />)
expect(screen.getByDisplayValue('Updated Name')).toBeInTheDocument()
})
it('should update when description prop changes', () => {
const { rerender } = render(<BasicInfoSection {...defaultProps} description="Initial Description" />)
expect(screen.getByDisplayValue('Initial Description')).toBeInTheDocument()
rerender(<BasicInfoSection {...defaultProps} description="Updated Description" />)
expect(screen.getByDisplayValue('Updated Description')).toBeInTheDocument()
})
it('should update when permission prop changes', () => {
const { rerender } = render(<BasicInfoSection {...defaultProps} permission={DatasetPermission.onlyMe} />)
expect(screen.getByText(/form\.permissionsOnlyMe/i)).toBeInTheDocument()
rerender(<BasicInfoSection {...defaultProps} permission={DatasetPermission.allTeamMembers} />)
expect(screen.getByText(/form\.permissionsAllMember/i)).toBeInTheDocument()
})
})
describe('Member List', () => {
it('should pass member list to PermissionSelector', () => {
const { container } = render(
<BasicInfoSection
{...defaultProps}
permission={DatasetPermission.partialMembers}
memberList={mockMemberList}
/>,
)
// For partial members, a member selector component should be rendered
// We verify it renders without crashing
expect(container).toBeInTheDocument()
})
it('should handle empty member list', () => {
render(
<BasicInfoSection
{...defaultProps}
memberList={[]}
/>,
)
expect(screen.getByText(/form\.permissionsOnlyMe/i)).toBeInTheDocument()
})
})
describe('Accessibility', () => {
it('should have accessible name input', () => {
render(<BasicInfoSection {...defaultProps} />)
const nameInput = screen.getByDisplayValue('Test Dataset')
expect(nameInput.tagName.toLowerCase()).toBe('input')
})
it('should have accessible description textarea', () => {
render(<BasicInfoSection {...defaultProps} />)
const descriptionTextarea = screen.getByDisplayValue('Test description')
expect(descriptionTextarea.tagName.toLowerCase()).toBe('textarea')
})
})
})

View File

@ -1,124 +0,0 @@
'use client'
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import type { Member } from '@/models/common'
import type { DataSet, DatasetPermission, IconInfo } from '@/models/datasets'
import type { AppIconType } from '@/types/app'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import AppIconPicker from '@/app/components/base/app-icon-picker'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import PermissionSelector from '../../permission-selector'
const rowClass = 'flex gap-x-1'
const labelClass = 'flex items-center shrink-0 w-[180px] h-7 pt-1'
type BasicInfoSectionProps = {
currentDataset: DataSet | undefined
isCurrentWorkspaceDatasetOperator: boolean
name: string
setName: (value: string) => void
description: string
setDescription: (value: string) => void
iconInfo: IconInfo
showAppIconPicker: boolean
handleOpenAppIconPicker: () => void
handleSelectAppIcon: (icon: AppIconSelection) => void
handleCloseAppIconPicker: () => void
permission: DatasetPermission | undefined
setPermission: (value: DatasetPermission | undefined) => void
selectedMemberIDs: string[]
setSelectedMemberIDs: (value: string[]) => void
memberList: Member[]
}
const BasicInfoSection = ({
currentDataset,
isCurrentWorkspaceDatasetOperator,
name,
setName,
description,
setDescription,
iconInfo,
showAppIconPicker,
handleOpenAppIconPicker,
handleSelectAppIcon,
handleCloseAppIconPicker,
permission,
setPermission,
selectedMemberIDs,
setSelectedMemberIDs,
memberList,
}: BasicInfoSectionProps) => {
const { t } = useTranslation()
return (
<>
{/* Dataset name and icon */}
<div className={rowClass}>
<div className={labelClass}>
<div className="system-sm-semibold text-text-secondary">{t('form.nameAndIcon', { ns: 'datasetSettings' })}</div>
</div>
<div className="flex grow items-center gap-x-2">
<AppIcon
size="small"
onClick={handleOpenAppIconPicker}
className="cursor-pointer"
iconType={iconInfo.icon_type as AppIconType}
icon={iconInfo.icon}
background={iconInfo.icon_background}
imageUrl={iconInfo.icon_url}
showEditIcon
/>
<Input
disabled={!currentDataset?.embedding_available}
value={name}
onChange={e => setName(e.target.value)}
/>
</div>
</div>
{/* Dataset description */}
<div className={rowClass}>
<div className={labelClass}>
<div className="system-sm-semibold text-text-secondary">{t('form.desc', { ns: 'datasetSettings' })}</div>
</div>
<div className="grow">
<Textarea
disabled={!currentDataset?.embedding_available}
className="resize-none"
placeholder={t('form.descPlaceholder', { ns: 'datasetSettings' }) || ''}
value={description}
onChange={e => setDescription(e.target.value)}
/>
</div>
</div>
{/* Permissions */}
<div className={rowClass}>
<div className={labelClass}>
<div className="system-sm-semibold text-text-secondary">{t('form.permissions', { ns: 'datasetSettings' })}</div>
</div>
<div className="grow">
<PermissionSelector
disabled={!currentDataset?.embedding_available || isCurrentWorkspaceDatasetOperator}
permission={permission}
value={selectedMemberIDs}
onChange={v => setPermission(v)}
onMemberSelect={setSelectedMemberIDs}
memberList={memberList}
/>
</div>
</div>
{showAppIconPicker && (
<AppIconPicker
onSelect={handleSelectAppIcon}
onClose={handleCloseAppIconPicker}
/>
)}
</>
)
}
export default BasicInfoSection

View File

@ -1,362 +0,0 @@
import type { DataSet } from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app'
import { render, screen } from '@testing-library/react'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
import { IndexingType } from '../../../create/step-two'
import ExternalKnowledgeSection from './external-knowledge-section'
describe('ExternalKnowledgeSection', () => {
const mockRetrievalConfig: RetrievalConfig = {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0.5,
}
const mockDataset: DataSet = {
id: 'dataset-1',
name: 'External Dataset',
description: 'External dataset description',
permission: DatasetPermission.onlyMe,
icon_info: {
icon_type: 'emoji',
icon: '📚',
icon_background: '#FFFFFF',
icon_url: '',
},
indexing_technique: IndexingType.QUALIFIED,
indexing_status: 'completed',
data_source_type: DataSourceType.FILE,
doc_form: ChunkingMode.text,
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
embedding_available: true,
app_count: 0,
document_count: 5,
total_document_count: 5,
word_count: 1000,
provider: 'external',
tags: [],
partial_member_list: [],
external_knowledge_info: {
external_knowledge_id: 'ext-knowledge-123',
external_knowledge_api_id: 'api-456',
external_knowledge_api_name: 'My External API',
external_knowledge_api_endpoint: 'https://api.external.example.com/v1',
},
external_retrieval_model: {
top_k: 5,
score_threshold: 0.8,
score_threshold_enabled: true,
},
retrieval_model_dict: mockRetrievalConfig,
retrieval_model: mockRetrievalConfig,
built_in_field_enabled: false,
keyword_number: 10,
created_by: 'user-1',
updated_by: 'user-1',
updated_at: Date.now(),
runtime_mode: 'general',
enable_api: true,
is_multimodal: false,
}
const defaultProps = {
currentDataset: mockDataset,
topK: 5,
scoreThreshold: 0.8,
scoreThresholdEnabled: true,
handleSettingsChange: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<ExternalKnowledgeSection {...defaultProps} />)
expect(screen.getByText(/form\.retrievalSetting\.title/i)).toBeInTheDocument()
})
it('should render retrieval settings section', () => {
render(<ExternalKnowledgeSection {...defaultProps} />)
expect(screen.getByText(/form\.retrievalSetting\.title/i)).toBeInTheDocument()
})
it('should render external knowledge API section', () => {
render(<ExternalKnowledgeSection {...defaultProps} />)
expect(screen.getByText(/form\.externalKnowledgeAPI/i)).toBeInTheDocument()
})
it('should render external knowledge ID section', () => {
render(<ExternalKnowledgeSection {...defaultProps} />)
expect(screen.getByText(/form\.externalKnowledgeID/i)).toBeInTheDocument()
})
})
describe('External Knowledge API Info', () => {
it('should display external API name', () => {
render(<ExternalKnowledgeSection {...defaultProps} />)
expect(screen.getByText('My External API')).toBeInTheDocument()
})
it('should display external API endpoint', () => {
render(<ExternalKnowledgeSection {...defaultProps} />)
expect(screen.getByText('https://api.external.example.com/v1')).toBeInTheDocument()
})
it('should render API connection icon', () => {
const { container } = render(<ExternalKnowledgeSection {...defaultProps} />)
// The ApiConnectionMod icon should be rendered
const icon = container.querySelector('svg')
expect(icon).toBeInTheDocument()
})
it('should display API name and endpoint in the same row', () => {
render(<ExternalKnowledgeSection {...defaultProps} />)
const apiName = screen.getByText('My External API')
const apiEndpoint = screen.getByText('https://api.external.example.com/v1')
// Both should be in the same container
expect(apiName.parentElement?.parentElement).toBe(apiEndpoint.parentElement?.parentElement)
})
})
describe('External Knowledge ID', () => {
it('should display external knowledge ID value', () => {
render(<ExternalKnowledgeSection {...defaultProps} />)
expect(screen.getByText('ext-knowledge-123')).toBeInTheDocument()
})
it('should render ID in a read-only display', () => {
render(<ExternalKnowledgeSection {...defaultProps} />)
const idElement = screen.getByText('ext-knowledge-123')
// The ID should be in a div with input-like styling, not an actual input
expect(idElement.tagName.toLowerCase()).toBe('div')
})
})
describe('Retrieval Settings', () => {
it('should pass topK to RetrievalSettings', () => {
render(<ExternalKnowledgeSection {...defaultProps} topK={10} />)
// RetrievalSettings should receive topK prop
// The exact rendering depends on RetrievalSettings component
})
it('should pass scoreThreshold to RetrievalSettings', () => {
render(<ExternalKnowledgeSection {...defaultProps} scoreThreshold={0.9} />)
// RetrievalSettings should receive scoreThreshold prop
})
it('should pass scoreThresholdEnabled to RetrievalSettings', () => {
render(<ExternalKnowledgeSection {...defaultProps} scoreThresholdEnabled={false} />)
// RetrievalSettings should receive scoreThresholdEnabled prop
})
it('should call handleSettingsChange when settings change', () => {
const handleSettingsChange = vi.fn()
render(<ExternalKnowledgeSection {...defaultProps} handleSettingsChange={handleSettingsChange} />)
// The handler should be properly passed to RetrievalSettings
// Actual interaction depends on RetrievalSettings implementation
})
})
describe('Dividers', () => {
it('should render dividers between sections', () => {
const { container } = render(<ExternalKnowledgeSection {...defaultProps} />)
const dividers = container.querySelectorAll('.bg-divider-subtle')
expect(dividers.length).toBeGreaterThanOrEqual(2)
})
})
describe('Props Updates', () => {
it('should update when currentDataset changes', () => {
const { rerender } = render(<ExternalKnowledgeSection {...defaultProps} />)
expect(screen.getByText('My External API')).toBeInTheDocument()
const updatedDataset = {
...mockDataset,
external_knowledge_info: {
...mockDataset.external_knowledge_info,
external_knowledge_api_name: 'Updated API Name',
},
}
rerender(<ExternalKnowledgeSection {...defaultProps} currentDataset={updatedDataset} />)
expect(screen.getByText('Updated API Name')).toBeInTheDocument()
})
it('should update when external knowledge ID changes', () => {
const { rerender } = render(<ExternalKnowledgeSection {...defaultProps} />)
expect(screen.getByText('ext-knowledge-123')).toBeInTheDocument()
const updatedDataset = {
...mockDataset,
external_knowledge_info: {
...mockDataset.external_knowledge_info,
external_knowledge_id: 'new-ext-id-789',
},
}
rerender(<ExternalKnowledgeSection {...defaultProps} currentDataset={updatedDataset} />)
expect(screen.getByText('new-ext-id-789')).toBeInTheDocument()
})
it('should update when API endpoint changes', () => {
const { rerender } = render(<ExternalKnowledgeSection {...defaultProps} />)
expect(screen.getByText('https://api.external.example.com/v1')).toBeInTheDocument()
const updatedDataset = {
...mockDataset,
external_knowledge_info: {
...mockDataset.external_knowledge_info,
external_knowledge_api_endpoint: 'https://new-api.example.com/v2',
},
}
rerender(<ExternalKnowledgeSection {...defaultProps} currentDataset={updatedDataset} />)
expect(screen.getByText('https://new-api.example.com/v2')).toBeInTheDocument()
})
})
describe('Layout', () => {
it('should have consistent row layout', () => {
const { container } = render(<ExternalKnowledgeSection {...defaultProps} />)
// Check for flex gap-x-1 class on rows
const rows = container.querySelectorAll('.flex.gap-x-1')
expect(rows.length).toBeGreaterThan(0)
})
it('should have consistent label width', () => {
const { container } = render(<ExternalKnowledgeSection {...defaultProps} />)
// Check for w-[180px] label containers
const labels = container.querySelectorAll('.w-\\[180px\\]')
expect(labels.length).toBeGreaterThan(0)
})
})
describe('Styling', () => {
it('should apply correct background to info displays', () => {
const { container } = render(<ExternalKnowledgeSection {...defaultProps} />)
// Info displays should have bg-components-input-bg-normal
const infoDisplays = container.querySelectorAll('.bg-components-input-bg-normal')
expect(infoDisplays.length).toBeGreaterThan(0)
})
it('should apply rounded corners to info displays', () => {
const { container } = render(<ExternalKnowledgeSection {...defaultProps} />)
const roundedElements = container.querySelectorAll('.rounded-lg')
expect(roundedElements.length).toBeGreaterThan(0)
})
})
describe('Different External Knowledge Info', () => {
it('should handle long API names', () => {
const longNameDataset = {
...mockDataset,
external_knowledge_info: {
...mockDataset.external_knowledge_info,
external_knowledge_api_name: 'This is a very long external knowledge API name that should be truncated',
},
}
render(<ExternalKnowledgeSection {...defaultProps} currentDataset={longNameDataset} />)
expect(screen.getByText(/This is a very long external knowledge API name/)).toBeInTheDocument()
})
it('should handle long API endpoints', () => {
const longEndpointDataset = {
...mockDataset,
external_knowledge_info: {
...mockDataset.external_knowledge_info,
external_knowledge_api_endpoint: 'https://api.very-long-domain-name.example.com/api/v1/external/knowledge',
},
}
render(<ExternalKnowledgeSection {...defaultProps} currentDataset={longEndpointDataset} />)
expect(screen.getByText(/https:\/\/api.very-long-domain-name.example.com/)).toBeInTheDocument()
})
it('should handle special characters in API name', () => {
const specialCharDataset = {
...mockDataset,
external_knowledge_info: {
...mockDataset.external_knowledge_info,
external_knowledge_api_name: 'API & Service <Test>',
},
}
render(<ExternalKnowledgeSection {...defaultProps} currentDataset={specialCharDataset} />)
expect(screen.getByText('API & Service <Test>')).toBeInTheDocument()
})
})
describe('RetrievalSettings Integration', () => {
it('should pass isInRetrievalSetting=true to RetrievalSettings', () => {
render(<ExternalKnowledgeSection {...defaultProps} />)
// The RetrievalSettings component should be rendered with isInRetrievalSetting=true
// This affects the component's layout/styling
})
it('should handle settings change for top_k', () => {
const handleSettingsChange = vi.fn()
render(<ExternalKnowledgeSection {...defaultProps} handleSettingsChange={handleSettingsChange} />)
// Find and interact with the top_k control in RetrievalSettings
// The exact interaction depends on RetrievalSettings implementation
})
it('should handle settings change for score_threshold', () => {
const handleSettingsChange = vi.fn()
render(<ExternalKnowledgeSection {...defaultProps} handleSettingsChange={handleSettingsChange} />)
// Find and interact with the score_threshold control in RetrievalSettings
})
it('should handle settings change for score_threshold_enabled', () => {
const handleSettingsChange = vi.fn()
render(<ExternalKnowledgeSection {...defaultProps} handleSettingsChange={handleSettingsChange} />)
// Find and interact with the score_threshold_enabled toggle in RetrievalSettings
})
})
describe('Accessibility', () => {
it('should have semantic structure', () => {
render(<ExternalKnowledgeSection {...defaultProps} />)
// Section labels should be present
expect(screen.getByText(/form\.retrievalSetting\.title/i)).toBeInTheDocument()
expect(screen.getByText(/form\.externalKnowledgeAPI/i)).toBeInTheDocument()
expect(screen.getByText(/form\.externalKnowledgeID/i)).toBeInTheDocument()
})
})
})

View File

@ -1,84 +0,0 @@
'use client'
import type { DataSet } from '@/models/datasets'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import { ApiConnectionMod } from '@/app/components/base/icons/src/vender/solid/development'
import RetrievalSettings from '../../../external-knowledge-base/create/RetrievalSettings'
const rowClass = 'flex gap-x-1'
const labelClass = 'flex items-center shrink-0 w-[180px] h-7 pt-1'
type ExternalKnowledgeSectionProps = {
currentDataset: DataSet
topK: number
scoreThreshold: number
scoreThresholdEnabled: boolean
handleSettingsChange: (data: { top_k?: number, score_threshold?: number, score_threshold_enabled?: boolean }) => void
}
const ExternalKnowledgeSection = ({
currentDataset,
topK,
scoreThreshold,
scoreThresholdEnabled,
handleSettingsChange,
}: ExternalKnowledgeSectionProps) => {
const { t } = useTranslation()
return (
<>
<Divider type="horizontal" className="my-1 h-px bg-divider-subtle" />
{/* Retrieval Settings */}
<div className={rowClass}>
<div className={labelClass}>
<div className="system-sm-semibold text-text-secondary">{t('form.retrievalSetting.title', { ns: 'datasetSettings' })}</div>
</div>
<RetrievalSettings
topK={topK}
scoreThreshold={scoreThreshold}
scoreThresholdEnabled={scoreThresholdEnabled}
onChange={handleSettingsChange}
isInRetrievalSetting={true}
/>
</div>
<Divider type="horizontal" className="my-1 h-px bg-divider-subtle" />
{/* External Knowledge API */}
<div className={rowClass}>
<div className={labelClass}>
<div className="system-sm-semibold text-text-secondary">{t('form.externalKnowledgeAPI', { ns: 'datasetSettings' })}</div>
</div>
<div className="w-full">
<div className="flex h-full items-center gap-1 rounded-lg bg-components-input-bg-normal px-3 py-2">
<ApiConnectionMod className="h-4 w-4 text-text-secondary" />
<div className="system-sm-medium overflow-hidden text-ellipsis text-text-secondary">
{currentDataset.external_knowledge_info.external_knowledge_api_name}
</div>
<div className="system-xs-regular text-text-tertiary">·</div>
<div className="system-xs-regular text-text-tertiary">
{currentDataset.external_knowledge_info.external_knowledge_api_endpoint}
</div>
</div>
</div>
</div>
{/* External Knowledge ID */}
<div className={rowClass}>
<div className={labelClass}>
<div className="system-sm-semibold text-text-secondary">{t('form.externalKnowledgeID', { ns: 'datasetSettings' })}</div>
</div>
<div className="w-full">
<div className="flex h-full items-center gap-1 rounded-lg bg-components-input-bg-normal px-3 py-2">
<div className="system-xs-regular text-text-tertiary">
{currentDataset.external_knowledge_info.external_knowledge_id}
</div>
</div>
</div>
</div>
</>
)
}
export default ExternalKnowledgeSection

View File

@ -1,501 +0,0 @@
import type { DefaultModel, Model } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { DataSet, SummaryIndexSetting } from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app'
import { fireEvent, render, screen } from '@testing-library/react'
import { ConfigurationMethodEnum, ModelStatusEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
import { IndexingType } from '../../../create/step-two'
import IndexingSection from './indexing-section'
// Mock i18n doc link
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.dify.ai${path}`,
}))
// Mock app-context for child components
vi.mock('@/context/app-context', () => ({
useSelector: (selector: (state: unknown) => unknown) => {
const state = {
isCurrentWorkspaceDatasetOperator: false,
userProfile: {
id: 'user-1',
name: 'Current User',
email: 'current@example.com',
avatar_url: '',
role: 'owner',
},
}
return selector(state)
},
}))
// Mock model-provider-page hooks
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelList: () => ({ data: [], mutate: vi.fn(), isLoading: false }),
useCurrentProviderAndModel: () => ({ currentProvider: undefined, currentModel: undefined }),
useDefaultModel: () => ({ data: undefined, mutate: vi.fn(), isLoading: false }),
useModelListAndDefaultModel: () => ({ modelList: [], defaultModel: undefined }),
useModelListAndDefaultModelAndCurrentProviderAndModel: () => ({
modelList: [],
defaultModel: undefined,
currentProvider: undefined,
currentModel: undefined,
}),
useUpdateModelList: () => vi.fn(),
useUpdateModelProviders: () => vi.fn(),
useLanguage: () => 'en_US',
useSystemDefaultModelAndModelList: () => [undefined, vi.fn()],
useProviderCredentialsAndLoadBalancing: () => ({
credentials: undefined,
loadBalancing: undefined,
mutate: vi.fn(),
isLoading: false,
}),
useAnthropicBuyQuota: () => vi.fn(),
useMarketplaceAllPlugins: () => ({ plugins: [], isLoading: false }),
useRefreshModel: () => ({ handleRefreshModel: vi.fn() }),
useModelModalHandler: () => vi.fn(),
}))
// Mock provider-context
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
textGenerationModelList: [],
embeddingsModelList: [],
rerankModelList: [],
agentThoughtModelList: [],
modelProviders: [],
textEmbeddingModelList: [],
speech2textModelList: [],
ttsModelList: [],
moderationModelList: [],
hasSettedApiKey: true,
plan: { type: 'free' },
enableBilling: false,
onPlanInfoChanged: vi.fn(),
isCurrentWorkspaceDatasetOperator: false,
supportRetrievalMethods: ['semantic_search', 'full_text_search', 'hybrid_search'],
}),
}))
describe('IndexingSection', () => {
const mockRetrievalConfig: RetrievalConfig = {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0.5,
}
const mockDataset: DataSet = {
id: 'dataset-1',
name: 'Test Dataset',
description: 'Test description',
permission: DatasetPermission.onlyMe,
icon_info: {
icon_type: 'emoji',
icon: '📚',
icon_background: '#FFFFFF',
icon_url: '',
},
indexing_technique: IndexingType.QUALIFIED,
indexing_status: 'completed',
data_source_type: DataSourceType.FILE,
doc_form: ChunkingMode.text,
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
embedding_available: true,
app_count: 0,
document_count: 5,
total_document_count: 5,
word_count: 1000,
provider: 'vendor',
tags: [],
partial_member_list: [],
external_knowledge_info: {
external_knowledge_id: 'ext-1',
external_knowledge_api_id: 'api-1',
external_knowledge_api_name: 'External API',
external_knowledge_api_endpoint: 'https://api.example.com',
},
external_retrieval_model: {
top_k: 3,
score_threshold: 0.7,
score_threshold_enabled: true,
},
retrieval_model_dict: mockRetrievalConfig,
retrieval_model: mockRetrievalConfig,
built_in_field_enabled: false,
keyword_number: 10,
created_by: 'user-1',
updated_by: 'user-1',
updated_at: Date.now(),
runtime_mode: 'general',
enable_api: true,
is_multimodal: false,
}
const mockEmbeddingModel: DefaultModel = {
provider: 'openai',
model: 'text-embedding-ada-002',
}
const mockEmbeddingModelList: Model[] = [
{
provider: 'openai',
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
icon_small: { en_US: '', zh_Hans: '' },
status: ModelStatusEnum.active,
models: [
{
model: 'text-embedding-ada-002',
label: { en_US: 'text-embedding-ada-002', zh_Hans: 'text-embedding-ada-002' },
model_type: ModelTypeEnum.textEmbedding,
features: [],
fetch_from: ConfigurationMethodEnum.predefinedModel,
model_properties: {},
deprecated: false,
status: ModelStatusEnum.active,
load_balancing_enabled: false,
},
],
},
]
const mockSummaryIndexSetting: SummaryIndexSetting = {
enable: false,
}
const defaultProps = {
currentDataset: mockDataset,
indexMethod: IndexingType.QUALIFIED,
setIndexMethod: vi.fn(),
keywordNumber: 10,
setKeywordNumber: vi.fn(),
embeddingModel: mockEmbeddingModel,
setEmbeddingModel: vi.fn(),
embeddingModelList: mockEmbeddingModelList,
retrievalConfig: mockRetrievalConfig,
setRetrievalConfig: vi.fn(),
summaryIndexSetting: mockSummaryIndexSetting,
handleSummaryIndexSettingChange: vi.fn(),
showMultiModalTip: false,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<IndexingSection {...defaultProps} />)
expect(screen.getByText(/form\.chunkStructure\.title/i)).toBeInTheDocument()
})
it('should render chunk structure section when doc_form is set', () => {
render(<IndexingSection {...defaultProps} />)
expect(screen.getByText(/form\.chunkStructure\.title/i)).toBeInTheDocument()
})
it('should render index method section when conditions are met', () => {
render(<IndexingSection {...defaultProps} />)
// May match multiple elements (label and descriptions)
expect(screen.getAllByText(/form\.indexMethod/i).length).toBeGreaterThan(0)
})
it('should render embedding model section when indexMethod is high_quality', () => {
render(<IndexingSection {...defaultProps} indexMethod={IndexingType.QUALIFIED} />)
expect(screen.getByText(/form\.embeddingModel/i)).toBeInTheDocument()
})
it('should render retrieval settings section', () => {
render(<IndexingSection {...defaultProps} />)
expect(screen.getByText(/form\.retrievalSetting\.title/i)).toBeInTheDocument()
})
})
describe('Chunk Structure Section', () => {
it('should not render chunk structure when doc_form is not set', () => {
const datasetWithoutDocForm = { ...mockDataset, doc_form: undefined as unknown as ChunkingMode }
render(<IndexingSection {...defaultProps} currentDataset={datasetWithoutDocForm} />)
expect(screen.queryByText(/form\.chunkStructure\.title/i)).not.toBeInTheDocument()
})
it('should render learn more link for chunk structure', () => {
render(<IndexingSection {...defaultProps} />)
const learnMoreLink = screen.getByText(/form\.chunkStructure\.learnMore/i)
expect(learnMoreLink).toBeInTheDocument()
expect(learnMoreLink).toHaveAttribute('href', expect.stringContaining('chunking-and-cleaning-text'))
})
it('should render chunk structure description', () => {
render(<IndexingSection {...defaultProps} />)
expect(screen.getByText(/form\.chunkStructure\.description/i)).toBeInTheDocument()
})
})
describe('Index Method Section', () => {
it('should not render index method for parentChild chunking mode', () => {
const parentChildDataset = { ...mockDataset, doc_form: ChunkingMode.parentChild }
render(<IndexingSection {...defaultProps} currentDataset={parentChildDataset} />)
expect(screen.queryByText(/form\.indexMethod/i)).not.toBeInTheDocument()
})
it('should render high quality option', () => {
render(<IndexingSection {...defaultProps} />)
expect(screen.getByText(/stepTwo\.qualified/i)).toBeInTheDocument()
})
it('should render economy option', () => {
render(<IndexingSection {...defaultProps} />)
// May match multiple elements (title and tip)
expect(screen.getAllByText(/form\.indexMethodEconomy/i).length).toBeGreaterThan(0)
})
it('should call setIndexMethod when index method changes', () => {
const setIndexMethod = vi.fn()
const { container } = render(<IndexingSection {...defaultProps} setIndexMethod={setIndexMethod} />)
// Find the economy option card by looking for clickable elements containing the economy text
const economyOptions = screen.getAllByText(/form\.indexMethodEconomy/i)
if (economyOptions.length > 0) {
const economyCard = economyOptions[0].closest('[class*="cursor-pointer"]')
if (economyCard) {
fireEvent.click(economyCard)
}
}
// The handler should be properly passed - verify component renders without crashing
expect(container).toBeInTheDocument()
})
it('should show upgrade warning when switching from economy to high quality', () => {
const economyDataset = { ...mockDataset, indexing_technique: IndexingType.ECONOMICAL }
render(
<IndexingSection
{...defaultProps}
currentDataset={economyDataset}
indexMethod={IndexingType.QUALIFIED}
/>,
)
expect(screen.getByText(/form\.upgradeHighQualityTip/i)).toBeInTheDocument()
})
it('should not show upgrade warning when already on high quality', () => {
render(
<IndexingSection
{...defaultProps}
indexMethod={IndexingType.QUALIFIED}
/>,
)
expect(screen.queryByText(/form\.upgradeHighQualityTip/i)).not.toBeInTheDocument()
})
it('should disable index method when embedding is not available', () => {
const datasetWithoutEmbedding = { ...mockDataset, embedding_available: false }
render(<IndexingSection {...defaultProps} currentDataset={datasetWithoutEmbedding} />)
// Index method options should be disabled
// The exact implementation depends on the IndexMethod component
})
})
describe('Embedding Model Section', () => {
it('should render embedding model when indexMethod is high_quality', () => {
render(<IndexingSection {...defaultProps} indexMethod={IndexingType.QUALIFIED} />)
expect(screen.getByText(/form\.embeddingModel/i)).toBeInTheDocument()
})
it('should not render embedding model when indexMethod is economy', () => {
render(<IndexingSection {...defaultProps} indexMethod={IndexingType.ECONOMICAL} />)
expect(screen.queryByText(/form\.embeddingModel/i)).not.toBeInTheDocument()
})
it('should call setEmbeddingModel when model changes', () => {
const setEmbeddingModel = vi.fn()
render(
<IndexingSection
{...defaultProps}
setEmbeddingModel={setEmbeddingModel}
indexMethod={IndexingType.QUALIFIED}
/>,
)
// The embedding model selector should be rendered
expect(screen.getByText(/form\.embeddingModel/i)).toBeInTheDocument()
})
})
describe('Summary Index Setting Section', () => {
it('should render summary index setting for high quality with text chunking', () => {
render(
<IndexingSection
{...defaultProps}
indexMethod={IndexingType.QUALIFIED}
/>,
)
// Summary index setting should be rendered based on conditions
// The exact rendering depends on the SummaryIndexSetting component
})
it('should not render summary index setting for economy indexing', () => {
render(
<IndexingSection
{...defaultProps}
indexMethod={IndexingType.ECONOMICAL}
/>,
)
// Summary index setting should not be rendered for economy
})
it('should call handleSummaryIndexSettingChange when setting changes', () => {
const handleSummaryIndexSettingChange = vi.fn()
render(
<IndexingSection
{...defaultProps}
handleSummaryIndexSettingChange={handleSummaryIndexSettingChange}
indexMethod={IndexingType.QUALIFIED}
/>,
)
// The handler should be properly passed
})
})
describe('Retrieval Settings Section', () => {
it('should render retrieval settings', () => {
render(<IndexingSection {...defaultProps} />)
expect(screen.getByText(/form\.retrievalSetting\.title/i)).toBeInTheDocument()
})
it('should render learn more link for retrieval settings', () => {
render(<IndexingSection {...defaultProps} />)
const learnMoreLinks = screen.getAllByText(/learnMore/i)
const retrievalLearnMore = learnMoreLinks.find(link =>
link.closest('a')?.href?.includes('setting-indexing-methods'),
)
expect(retrievalLearnMore).toBeInTheDocument()
})
it('should render RetrievalMethodConfig for high quality indexing', () => {
render(<IndexingSection {...defaultProps} indexMethod={IndexingType.QUALIFIED} />)
// RetrievalMethodConfig should be rendered
expect(screen.getByText(/form\.retrievalSetting\.title/i)).toBeInTheDocument()
})
it('should render EconomicalRetrievalMethodConfig for economy indexing', () => {
render(<IndexingSection {...defaultProps} indexMethod={IndexingType.ECONOMICAL} />)
// EconomicalRetrievalMethodConfig should be rendered
expect(screen.getByText(/form\.retrievalSetting\.title/i)).toBeInTheDocument()
})
it('should call setRetrievalConfig when config changes', () => {
const setRetrievalConfig = vi.fn()
render(<IndexingSection {...defaultProps} setRetrievalConfig={setRetrievalConfig} />)
// The handler should be properly passed
})
it('should pass showMultiModalTip to RetrievalMethodConfig', () => {
render(<IndexingSection {...defaultProps} showMultiModalTip={true} />)
// The tip should be passed to the config component
})
})
describe('External Provider', () => {
it('should not render retrieval config for external provider', () => {
const externalDataset = { ...mockDataset, provider: 'external' }
render(<IndexingSection {...defaultProps} currentDataset={externalDataset} />)
// Retrieval config should not be rendered for external provider
// This is handled by the parent component, but we verify the condition
})
})
describe('Conditional Rendering', () => {
it('should show divider between sections', () => {
const { container } = render(<IndexingSection {...defaultProps} />)
// Dividers should be present
const dividers = container.querySelectorAll('.bg-divider-subtle')
expect(dividers.length).toBeGreaterThan(0)
})
it('should not render index method when indexing_technique is not set', () => {
const datasetWithoutTechnique = { ...mockDataset, indexing_technique: undefined as unknown as IndexingType }
render(<IndexingSection {...defaultProps} currentDataset={datasetWithoutTechnique} indexMethod={undefined} />)
expect(screen.queryByText(/form\.indexMethod/i)).not.toBeInTheDocument()
})
})
describe('Keyword Number', () => {
it('should pass keywordNumber to IndexMethod', () => {
render(<IndexingSection {...defaultProps} keywordNumber={15} />)
// The keyword number should be displayed in the economy option description
// The exact rendering depends on the IndexMethod component
})
it('should call setKeywordNumber when keyword number changes', () => {
const setKeywordNumber = vi.fn()
render(<IndexingSection {...defaultProps} setKeywordNumber={setKeywordNumber} />)
// The handler should be properly passed
})
})
describe('Props Updates', () => {
it('should update when indexMethod changes', () => {
const { rerender } = render(<IndexingSection {...defaultProps} indexMethod={IndexingType.QUALIFIED} />)
expect(screen.getByText(/form\.embeddingModel/i)).toBeInTheDocument()
rerender(<IndexingSection {...defaultProps} indexMethod={IndexingType.ECONOMICAL} />)
expect(screen.queryByText(/form\.embeddingModel/i)).not.toBeInTheDocument()
})
it('should update when currentDataset changes', () => {
const { rerender } = render(<IndexingSection {...defaultProps} />)
expect(screen.getByText(/form\.chunkStructure\.title/i)).toBeInTheDocument()
const datasetWithoutDocForm = { ...mockDataset, doc_form: undefined as unknown as ChunkingMode }
rerender(<IndexingSection {...defaultProps} currentDataset={datasetWithoutDocForm} />)
expect(screen.queryByText(/form\.chunkStructure\.title/i)).not.toBeInTheDocument()
})
})
describe('Undefined Dataset', () => {
it('should handle undefined currentDataset gracefully', () => {
render(<IndexingSection {...defaultProps} currentDataset={undefined} />)
// Should not crash and should handle undefined gracefully
// Most sections should not render without a dataset
})
})
})

View File

@ -1,206 +0,0 @@
'use client'
import type { DefaultModel, Model } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { DataSet, SummaryIndexSetting as SummaryIndexSettingType } from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app'
import { RiAlertFill } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import EconomicalRetrievalMethodConfig from '@/app/components/datasets/common/economical-retrieval-method-config'
import RetrievalMethodConfig from '@/app/components/datasets/common/retrieval-method-config'
import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
import { useDocLink } from '@/context/i18n'
import { ChunkingMode } from '@/models/datasets'
import { IndexingType } from '../../../create/step-two'
import ChunkStructure from '../../chunk-structure'
import IndexMethod from '../../index-method'
import SummaryIndexSetting from '../../summary-index-setting'
const rowClass = 'flex gap-x-1'
const labelClass = 'flex items-center shrink-0 w-[180px] h-7 pt-1'
type IndexingSectionProps = {
currentDataset: DataSet | undefined
indexMethod: IndexingType | undefined
setIndexMethod: (value: IndexingType | undefined) => void
keywordNumber: number
setKeywordNumber: (value: number) => void
embeddingModel: DefaultModel
setEmbeddingModel: (value: DefaultModel) => void
embeddingModelList: Model[]
retrievalConfig: RetrievalConfig
setRetrievalConfig: (value: RetrievalConfig) => void
summaryIndexSetting: SummaryIndexSettingType | undefined
handleSummaryIndexSettingChange: (payload: SummaryIndexSettingType) => void
showMultiModalTip: boolean
}
const IndexingSection = ({
currentDataset,
indexMethod,
setIndexMethod,
keywordNumber,
setKeywordNumber,
embeddingModel,
setEmbeddingModel,
embeddingModelList,
retrievalConfig,
setRetrievalConfig,
summaryIndexSetting,
handleSummaryIndexSettingChange,
showMultiModalTip,
}: IndexingSectionProps) => {
const { t } = useTranslation()
const docLink = useDocLink()
const isShowIndexMethod = currentDataset
&& currentDataset.doc_form !== ChunkingMode.parentChild
&& currentDataset.indexing_technique
&& indexMethod
const showUpgradeWarning = currentDataset?.indexing_technique === IndexingType.ECONOMICAL
&& indexMethod === IndexingType.QUALIFIED
const showSummaryIndexSetting = indexMethod === IndexingType.QUALIFIED
&& [ChunkingMode.text, ChunkingMode.parentChild].includes(currentDataset?.doc_form as ChunkingMode)
return (
<>
{/* Chunk Structure */}
{!!currentDataset?.doc_form && (
<>
<Divider type="horizontal" className="my-1 h-px bg-divider-subtle" />
<div className={rowClass}>
<div className="flex w-[180px] shrink-0 flex-col">
<div className="system-sm-semibold flex h-8 items-center text-text-secondary">
{t('form.chunkStructure.title', { ns: 'datasetSettings' })}
</div>
<div className="body-xs-regular text-text-tertiary">
<a
target="_blank"
rel="noopener noreferrer"
href={docLink('/use-dify/knowledge/create-knowledge/chunking-and-cleaning-text')}
className="text-text-accent"
>
{t('form.chunkStructure.learnMore', { ns: 'datasetSettings' })}
</a>
{t('form.chunkStructure.description', { ns: 'datasetSettings' })}
</div>
</div>
<div className="grow">
<ChunkStructure chunkStructure={currentDataset?.doc_form} />
</div>
</div>
</>
)}
{!!(isShowIndexMethod || indexMethod === 'high_quality') && (
<Divider type="horizontal" className="my-1 h-px bg-divider-subtle" />
)}
{/* Index Method */}
{!!isShowIndexMethod && (
<div className={rowClass}>
<div className={labelClass}>
<div className="system-sm-semibold text-text-secondary">{t('form.indexMethod', { ns: 'datasetSettings' })}</div>
</div>
<div className="grow">
<IndexMethod
value={indexMethod!}
disabled={!currentDataset?.embedding_available}
onChange={setIndexMethod}
currentValue={currentDataset.indexing_technique}
keywordNumber={keywordNumber}
onKeywordNumberChange={setKeywordNumber}
/>
{showUpgradeWarning && (
<div className="relative mt-2 flex h-10 items-center gap-x-0.5 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur px-2 shadow-xs shadow-shadow-shadow-3">
<div className="absolute left-0 top-0 flex h-full w-full items-center bg-toast-warning-bg opacity-40" />
<div className="p-1">
<RiAlertFill className="size-4 text-text-warning-secondary" />
</div>
<span className="system-xs-medium text-text-primary">
{t('form.upgradeHighQualityTip', { ns: 'datasetSettings' })}
</span>
</div>
)}
</div>
</div>
)}
{/* Embedding Model */}
{indexMethod === IndexingType.QUALIFIED && (
<div className={rowClass}>
<div className={labelClass}>
<div className="system-sm-semibold text-text-secondary">
{t('form.embeddingModel', { ns: 'datasetSettings' })}
</div>
</div>
<div className="grow">
<ModelSelector
defaultModel={embeddingModel}
modelList={embeddingModelList}
onSelect={setEmbeddingModel}
/>
</div>
</div>
)}
{/* Summary Index Setting */}
{showSummaryIndexSetting && (
<>
<Divider type="horizontal" className="my-1 h-px bg-divider-subtle" />
<SummaryIndexSetting
entry="dataset-settings"
summaryIndexSetting={summaryIndexSetting}
onSummaryIndexSettingChange={handleSummaryIndexSettingChange}
/>
</>
)}
{/* Retrieval Method Config */}
{indexMethod && currentDataset?.provider !== 'external' && (
<>
<Divider type="horizontal" className="my-1 h-px bg-divider-subtle" />
<div className={rowClass}>
<div className={labelClass}>
<div className="flex w-[180px] shrink-0 flex-col">
<div className="system-sm-semibold flex h-7 items-center pt-1 text-text-secondary">
{t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
</div>
<div className="body-xs-regular text-text-tertiary">
<a
target="_blank"
rel="noopener noreferrer"
href={docLink('/use-dify/knowledge/create-knowledge/setting-indexing-methods')}
className="text-text-accent"
>
{t('form.retrievalSetting.learnMore', { ns: 'datasetSettings' })}
</a>
{t('form.retrievalSetting.description', { ns: 'datasetSettings' })}
</div>
</div>
</div>
<div className="grow">
{indexMethod === IndexingType.QUALIFIED
? (
<RetrievalMethodConfig
value={retrievalConfig}
onChange={setRetrievalConfig}
showMultiModalTip={showMultiModalTip}
/>
)
: (
<EconomicalRetrievalMethodConfig
value={retrievalConfig}
onChange={setRetrievalConfig}
/>
)}
</div>
</div>
</>
)}
</>
)
}
export default IndexingSection

View File

@ -1,763 +0,0 @@
import type { DataSet } from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app'
import { act, renderHook, waitFor } from '@testing-library/react'
import { ChunkingMode, DatasetPermission, DataSourceType, WeightedScoreEnum } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
import { IndexingType } from '../../../create/step-two'
import { useFormState } from './use-form-state'
// Mock contexts
const mockMutateDatasets = vi.fn()
const mockInvalidDatasetList = vi.fn()
vi.mock('@/context/app-context', () => ({
useSelector: () => false, // isCurrentWorkspaceDatasetOperator
}))
const createDefaultMockDataset = (): DataSet => ({
id: 'dataset-1',
name: 'Test Dataset',
description: 'Test description',
permission: DatasetPermission.onlyMe,
icon_info: {
icon_type: 'emoji',
icon: '📚',
icon_background: '#FFFFFF',
icon_url: '',
},
indexing_technique: IndexingType.QUALIFIED,
indexing_status: 'completed',
data_source_type: DataSourceType.FILE,
doc_form: ChunkingMode.text,
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
embedding_available: true,
app_count: 0,
document_count: 5,
total_document_count: 5,
word_count: 1000,
provider: 'vendor',
tags: [],
partial_member_list: [],
external_knowledge_info: {
external_knowledge_id: 'ext-1',
external_knowledge_api_id: 'api-1',
external_knowledge_api_name: 'External API',
external_knowledge_api_endpoint: 'https://api.example.com',
},
external_retrieval_model: {
top_k: 3,
score_threshold: 0.7,
score_threshold_enabled: true,
},
retrieval_model_dict: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0.5,
} as RetrievalConfig,
retrieval_model: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0.5,
} as RetrievalConfig,
built_in_field_enabled: false,
keyword_number: 10,
created_by: 'user-1',
updated_by: 'user-1',
updated_at: Date.now(),
runtime_mode: 'general',
enable_api: true,
is_multimodal: false,
})
let mockDataset: DataSet = createDefaultMockDataset()
vi.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: (selector: (state: { dataset: DataSet | null, mutateDatasetRes: () => void }) => unknown) => {
const state = {
dataset: mockDataset,
mutateDatasetRes: mockMutateDatasets,
}
return selector(state)
},
}))
// Mock services
vi.mock('@/service/datasets', () => ({
updateDatasetSetting: vi.fn().mockResolvedValue({}),
}))
vi.mock('@/service/knowledge/use-dataset', () => ({
useInvalidDatasetList: () => mockInvalidDatasetList,
}))
vi.mock('@/service/use-common', () => ({
useMembers: () => ({
data: {
accounts: [
{ id: 'user-1', name: 'User 1', email: 'user1@example.com', role: 'owner', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' },
{ id: 'user-2', name: 'User 2', email: 'user2@example.com', role: 'admin', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' },
],
},
}),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelList: () => ({ data: [] }),
}))
vi.mock('@/app/components/datasets/common/check-rerank-model', () => ({
isReRankModelSelected: () => true,
}))
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
}))
describe('useFormState', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDataset = createDefaultMockDataset()
})
describe('Initial State', () => {
it('should initialize with dataset values', () => {
const { result } = renderHook(() => useFormState())
expect(result.current.name).toBe('Test Dataset')
expect(result.current.description).toBe('Test description')
expect(result.current.permission).toBe(DatasetPermission.onlyMe)
expect(result.current.indexMethod).toBe(IndexingType.QUALIFIED)
expect(result.current.keywordNumber).toBe(10)
})
it('should initialize icon info from dataset', () => {
const { result } = renderHook(() => useFormState())
expect(result.current.iconInfo).toEqual({
icon_type: 'emoji',
icon: '📚',
icon_background: '#FFFFFF',
icon_url: '',
})
})
it('should initialize external retrieval settings', () => {
const { result } = renderHook(() => useFormState())
expect(result.current.topK).toBe(3)
expect(result.current.scoreThreshold).toBe(0.7)
expect(result.current.scoreThresholdEnabled).toBe(true)
})
it('should derive member list from API data', () => {
const { result } = renderHook(() => useFormState())
expect(result.current.memberList).toHaveLength(2)
expect(result.current.memberList[0].name).toBe('User 1')
})
it('should return currentDataset from context', () => {
const { result } = renderHook(() => useFormState())
expect(result.current.currentDataset).toBeDefined()
expect(result.current.currentDataset?.id).toBe('dataset-1')
})
})
describe('State Setters', () => {
it('should update name when setName is called', () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.setName('New Name')
})
expect(result.current.name).toBe('New Name')
})
it('should update description when setDescription is called', () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.setDescription('New Description')
})
expect(result.current.description).toBe('New Description')
})
it('should update permission when setPermission is called', () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.setPermission(DatasetPermission.allTeamMembers)
})
expect(result.current.permission).toBe(DatasetPermission.allTeamMembers)
})
it('should update indexMethod when setIndexMethod is called', () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.setIndexMethod(IndexingType.ECONOMICAL)
})
expect(result.current.indexMethod).toBe(IndexingType.ECONOMICAL)
})
it('should update keywordNumber when setKeywordNumber is called', () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.setKeywordNumber(20)
})
expect(result.current.keywordNumber).toBe(20)
})
it('should update selectedMemberIDs when setSelectedMemberIDs is called', () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.setSelectedMemberIDs(['user-1', 'user-2'])
})
expect(result.current.selectedMemberIDs).toEqual(['user-1', 'user-2'])
})
})
describe('Icon Handlers', () => {
it('should open app icon picker and save previous icon', () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.handleOpenAppIconPicker()
})
expect(result.current.showAppIconPicker).toBe(true)
})
it('should select emoji icon and close picker', () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.handleOpenAppIconPicker()
})
act(() => {
result.current.handleSelectAppIcon({
type: 'emoji',
icon: '🎉',
background: '#FF0000',
})
})
expect(result.current.showAppIconPicker).toBe(false)
expect(result.current.iconInfo).toEqual({
icon_type: 'emoji',
icon: '🎉',
icon_background: '#FF0000',
icon_url: undefined,
})
})
it('should select image icon and close picker', () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.handleOpenAppIconPicker()
})
act(() => {
result.current.handleSelectAppIcon({
type: 'image',
fileId: 'file-123',
url: 'https://example.com/icon.png',
})
})
expect(result.current.showAppIconPicker).toBe(false)
expect(result.current.iconInfo).toEqual({
icon_type: 'image',
icon: 'file-123',
icon_background: undefined,
icon_url: 'https://example.com/icon.png',
})
})
it('should restore previous icon when picker is closed', () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.handleOpenAppIconPicker()
})
act(() => {
result.current.handleSelectAppIcon({
type: 'emoji',
icon: '🎉',
background: '#FF0000',
})
})
act(() => {
result.current.handleOpenAppIconPicker()
})
act(() => {
result.current.handleCloseAppIconPicker()
})
expect(result.current.showAppIconPicker).toBe(false)
// After close, icon should be restored to the icon before opening
expect(result.current.iconInfo).toEqual({
icon_type: 'emoji',
icon: '🎉',
icon_background: '#FF0000',
icon_url: undefined,
})
})
})
describe('External Retrieval Settings Handler', () => {
it('should update topK when provided', () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.handleSettingsChange({ top_k: 5 })
})
expect(result.current.topK).toBe(5)
})
it('should update scoreThreshold when provided', () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.handleSettingsChange({ score_threshold: 0.8 })
})
expect(result.current.scoreThreshold).toBe(0.8)
})
it('should update scoreThresholdEnabled when provided', () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.handleSettingsChange({ score_threshold_enabled: false })
})
expect(result.current.scoreThresholdEnabled).toBe(false)
})
it('should update multiple settings at once', () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.handleSettingsChange({
top_k: 10,
score_threshold: 0.9,
score_threshold_enabled: true,
})
})
expect(result.current.topK).toBe(10)
expect(result.current.scoreThreshold).toBe(0.9)
expect(result.current.scoreThresholdEnabled).toBe(true)
})
})
describe('Summary Index Setting Handler', () => {
it('should update summary index setting', () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.handleSummaryIndexSettingChange({
enable: true,
})
})
expect(result.current.summaryIndexSetting).toMatchObject({
enable: true,
})
})
it('should merge with existing settings', () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.handleSummaryIndexSettingChange({
enable: true,
})
})
act(() => {
result.current.handleSummaryIndexSettingChange({
model_provider_name: 'openai',
model_name: 'gpt-4',
})
})
expect(result.current.summaryIndexSetting).toMatchObject({
enable: true,
model_provider_name: 'openai',
model_name: 'gpt-4',
})
})
})
describe('handleSave', () => {
it('should show error toast when name is empty', async () => {
const Toast = await import('@/app/components/base/toast')
const { result } = renderHook(() => useFormState())
act(() => {
result.current.setName('')
})
await act(async () => {
await result.current.handleSave()
})
expect(Toast.default.notify).toHaveBeenCalledWith({
type: 'error',
message: expect.any(String),
})
})
it('should show error toast when name is whitespace only', async () => {
const Toast = await import('@/app/components/base/toast')
const { result } = renderHook(() => useFormState())
act(() => {
result.current.setName(' ')
})
await act(async () => {
await result.current.handleSave()
})
expect(Toast.default.notify).toHaveBeenCalledWith({
type: 'error',
message: expect.any(String),
})
})
it('should call updateDatasetSetting with correct params', async () => {
const { updateDatasetSetting } = await import('@/service/datasets')
const { result } = renderHook(() => useFormState())
await act(async () => {
await result.current.handleSave()
})
expect(updateDatasetSetting).toHaveBeenCalledWith({
datasetId: 'dataset-1',
body: expect.objectContaining({
name: 'Test Dataset',
description: 'Test description',
permission: DatasetPermission.onlyMe,
}),
})
})
it('should show success toast on successful save', async () => {
const Toast = await import('@/app/components/base/toast')
const { result } = renderHook(() => useFormState())
await act(async () => {
await result.current.handleSave()
})
await waitFor(() => {
expect(Toast.default.notify).toHaveBeenCalledWith({
type: 'success',
message: expect.any(String),
})
})
})
it('should call mutateDatasets after successful save', async () => {
const { result } = renderHook(() => useFormState())
await act(async () => {
await result.current.handleSave()
})
await waitFor(() => {
expect(mockMutateDatasets).toHaveBeenCalled()
})
})
it('should call invalidDatasetList after successful save', async () => {
const { result } = renderHook(() => useFormState())
await act(async () => {
await result.current.handleSave()
})
await waitFor(() => {
expect(mockInvalidDatasetList).toHaveBeenCalled()
})
})
it('should set loading to true during save', async () => {
const { result } = renderHook(() => useFormState())
expect(result.current.loading).toBe(false)
const savePromise = act(async () => {
await result.current.handleSave()
})
// Loading should be true during the save operation
await savePromise
expect(result.current.loading).toBe(false) // After completion
})
it('should not save when already loading', async () => {
const { updateDatasetSetting } = await import('@/service/datasets')
vi.mocked(updateDatasetSetting).mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)))
const { result } = renderHook(() => useFormState())
// Start first save
act(() => {
result.current.handleSave()
})
// Try to start second save immediately
await act(async () => {
await result.current.handleSave()
})
// Should only have been called once
expect(updateDatasetSetting).toHaveBeenCalledTimes(1)
})
it('should show error toast on save failure', async () => {
const { updateDatasetSetting } = await import('@/service/datasets')
const Toast = await import('@/app/components/base/toast')
vi.mocked(updateDatasetSetting).mockRejectedValueOnce(new Error('Network error'))
const { result } = renderHook(() => useFormState())
await act(async () => {
await result.current.handleSave()
})
expect(Toast.default.notify).toHaveBeenCalledWith({
type: 'error',
message: expect.any(String),
})
})
it('should include partial_member_list when permission is partialMembers', async () => {
const { updateDatasetSetting } = await import('@/service/datasets')
const { result } = renderHook(() => useFormState())
act(() => {
result.current.setPermission(DatasetPermission.partialMembers)
result.current.setSelectedMemberIDs(['user-1', 'user-2'])
})
await act(async () => {
await result.current.handleSave()
})
expect(updateDatasetSetting).toHaveBeenCalledWith({
datasetId: 'dataset-1',
body: expect.objectContaining({
partial_member_list: expect.arrayContaining([
expect.objectContaining({ user_id: 'user-1' }),
expect.objectContaining({ user_id: 'user-2' }),
]),
}),
})
})
})
describe('Embedding Model', () => {
it('should initialize embedding model from dataset', () => {
const { result } = renderHook(() => useFormState())
expect(result.current.embeddingModel).toEqual({
provider: 'openai',
model: 'text-embedding-ada-002',
})
})
it('should update embedding model when setEmbeddingModel is called', () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.setEmbeddingModel({
provider: 'cohere',
model: 'embed-english-v3.0',
})
})
expect(result.current.embeddingModel).toEqual({
provider: 'cohere',
model: 'embed-english-v3.0',
})
})
})
describe('Retrieval Config', () => {
it('should initialize retrieval config from dataset', () => {
const { result } = renderHook(() => useFormState())
expect(result.current.retrievalConfig).toBeDefined()
expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.semantic)
})
it('should update retrieval config when setRetrievalConfig is called', () => {
const { result } = renderHook(() => useFormState())
const newConfig: RetrievalConfig = {
...result.current.retrievalConfig,
reranking_enable: true,
}
act(() => {
result.current.setRetrievalConfig(newConfig)
})
expect(result.current.retrievalConfig.reranking_enable).toBe(true)
})
it('should include weights in save request when weights are set', async () => {
const { updateDatasetSetting } = await import('@/service/datasets')
const { result } = renderHook(() => useFormState())
// Set retrieval config with weights
const configWithWeights: RetrievalConfig = {
...result.current.retrievalConfig,
search_method: RETRIEVE_METHOD.hybrid,
weights: {
weight_type: WeightedScoreEnum.Customized,
vector_setting: {
vector_weight: 0.7,
embedding_provider_name: '',
embedding_model_name: '',
},
keyword_setting: {
keyword_weight: 0.3,
},
},
}
act(() => {
result.current.setRetrievalConfig(configWithWeights)
})
await act(async () => {
await result.current.handleSave()
})
// Verify that weights were included and embedding model info was added
expect(updateDatasetSetting).toHaveBeenCalledWith({
datasetId: 'dataset-1',
body: expect.objectContaining({
retrieval_model: expect.objectContaining({
weights: expect.objectContaining({
vector_setting: expect.objectContaining({
embedding_provider_name: 'openai',
embedding_model_name: 'text-embedding-ada-002',
}),
}),
}),
}),
})
})
})
describe('External Provider', () => {
beforeEach(() => {
// Update mock dataset to be external provider
mockDataset = {
...mockDataset,
provider: 'external',
external_knowledge_info: {
external_knowledge_id: 'ext-123',
external_knowledge_api_id: 'api-456',
external_knowledge_api_name: 'External API',
external_knowledge_api_endpoint: 'https://api.example.com',
},
external_retrieval_model: {
top_k: 5,
score_threshold: 0.8,
score_threshold_enabled: true,
},
}
})
it('should include external knowledge info in save request for external provider', async () => {
const { updateDatasetSetting } = await import('@/service/datasets')
const { result } = renderHook(() => useFormState())
await act(async () => {
await result.current.handleSave()
})
expect(updateDatasetSetting).toHaveBeenCalledWith({
datasetId: 'dataset-1',
body: expect.objectContaining({
external_knowledge_id: 'ext-123',
external_knowledge_api_id: 'api-456',
external_retrieval_model: expect.objectContaining({
top_k: expect.any(Number),
score_threshold: expect.any(Number),
score_threshold_enabled: expect.any(Boolean),
}),
}),
})
})
it('should use correct external retrieval settings', async () => {
const { updateDatasetSetting } = await import('@/service/datasets')
const { result } = renderHook(() => useFormState())
// Update external retrieval settings
act(() => {
result.current.handleSettingsChange({
top_k: 10,
score_threshold: 0.9,
score_threshold_enabled: false,
})
})
await act(async () => {
await result.current.handleSave()
})
expect(updateDatasetSetting).toHaveBeenCalledWith({
datasetId: 'dataset-1',
body: expect.objectContaining({
external_retrieval_model: {
top_k: 10,
score_threshold: 0.9,
score_threshold_enabled: false,
},
}),
})
})
})
})

View File

@ -1,264 +0,0 @@
'use client'
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import type { DefaultModel } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { Member } from '@/models/common'
import type { IconInfo, SummaryIndexSetting as SummaryIndexSettingType } from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Toast from '@/app/components/base/toast'
import { isReRankModelSelected } from '@/app/components/datasets/common/check-rerank-model'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { DatasetPermission } from '@/models/datasets'
import { updateDatasetSetting } from '@/service/datasets'
import { useInvalidDatasetList } from '@/service/knowledge/use-dataset'
import { useMembers } from '@/service/use-common'
import { checkShowMultiModalTip } from '../../utils'
const DEFAULT_APP_ICON: IconInfo = {
icon_type: 'emoji',
icon: '📙',
icon_background: '#FFF4ED',
icon_url: '',
}
export const useFormState = () => {
const { t } = useTranslation()
const isCurrentWorkspaceDatasetOperator = useAppContextWithSelector(state => state.isCurrentWorkspaceDatasetOperator)
const currentDataset = useDatasetDetailContextWithSelector(state => state.dataset)
const mutateDatasets = useDatasetDetailContextWithSelector(state => state.mutateDatasetRes)
// Basic form state
const [loading, setLoading] = useState(false)
const [name, setName] = useState(currentDataset?.name ?? '')
const [description, setDescription] = useState(currentDataset?.description ?? '')
// Icon state
const [iconInfo, setIconInfo] = useState(currentDataset?.icon_info || DEFAULT_APP_ICON)
const [showAppIconPicker, setShowAppIconPicker] = useState(false)
const previousAppIcon = useRef(DEFAULT_APP_ICON)
// Permission state
const [permission, setPermission] = useState(currentDataset?.permission)
const [selectedMemberIDs, setSelectedMemberIDs] = useState<string[]>(currentDataset?.partial_member_list || [])
// External retrieval state
const [topK, setTopK] = useState(currentDataset?.external_retrieval_model.top_k ?? 2)
const [scoreThreshold, setScoreThreshold] = useState(currentDataset?.external_retrieval_model.score_threshold ?? 0.5)
const [scoreThresholdEnabled, setScoreThresholdEnabled] = useState(currentDataset?.external_retrieval_model.score_threshold_enabled ?? false)
// Indexing and retrieval state
const [indexMethod, setIndexMethod] = useState(currentDataset?.indexing_technique)
const [keywordNumber, setKeywordNumber] = useState(currentDataset?.keyword_number ?? 10)
const [retrievalConfig, setRetrievalConfig] = useState(currentDataset?.retrieval_model_dict as RetrievalConfig)
const [embeddingModel, setEmbeddingModel] = useState<DefaultModel>(
currentDataset?.embedding_model
? {
provider: currentDataset.embedding_model_provider,
model: currentDataset.embedding_model,
}
: {
provider: '',
model: '',
},
)
// Summary index state
const [summaryIndexSetting, setSummaryIndexSetting] = useState(currentDataset?.summary_index_setting)
// Model lists
const { data: rerankModelList } = useModelList(ModelTypeEnum.rerank)
const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding)
const { data: membersData } = useMembers()
const invalidDatasetList = useInvalidDatasetList()
// Derive member list from API data
const memberList = useMemo<Member[]>(() => {
return membersData?.accounts ?? []
}, [membersData])
// Icon handlers
const handleOpenAppIconPicker = useCallback(() => {
setShowAppIconPicker(true)
previousAppIcon.current = iconInfo
}, [iconInfo])
const handleSelectAppIcon = useCallback((icon: AppIconSelection) => {
const newIconInfo: IconInfo = {
icon_type: icon.type,
icon: icon.type === 'emoji' ? icon.icon : icon.fileId,
icon_background: icon.type === 'emoji' ? icon.background : undefined,
icon_url: icon.type === 'emoji' ? undefined : icon.url,
}
setIconInfo(newIconInfo)
setShowAppIconPicker(false)
}, [])
const handleCloseAppIconPicker = useCallback(() => {
setIconInfo(previousAppIcon.current)
setShowAppIconPicker(false)
}, [])
// External retrieval settings handler
const handleSettingsChange = useCallback((data: { top_k?: number, score_threshold?: number, score_threshold_enabled?: boolean }) => {
if (data.top_k !== undefined)
setTopK(data.top_k)
if (data.score_threshold !== undefined)
setScoreThreshold(data.score_threshold)
if (data.score_threshold_enabled !== undefined)
setScoreThresholdEnabled(data.score_threshold_enabled)
}, [])
// Summary index setting handler
const handleSummaryIndexSettingChange = useCallback((payload: SummaryIndexSettingType) => {
setSummaryIndexSetting(prev => ({ ...prev, ...payload }))
}, [])
// Save handler
const handleSave = async () => {
if (loading)
return
if (!name?.trim()) {
Toast.notify({ type: 'error', message: t('form.nameError', { ns: 'datasetSettings' }) })
return
}
if (!isReRankModelSelected({ rerankModelList, retrievalConfig, indexMethod })) {
Toast.notify({ type: 'error', message: t('datasetConfig.rerankModelRequired', { ns: 'appDebug' }) })
return
}
if (retrievalConfig.weights) {
retrievalConfig.weights.vector_setting.embedding_provider_name = embeddingModel.provider || ''
retrievalConfig.weights.vector_setting.embedding_model_name = embeddingModel.model || ''
}
try {
setLoading(true)
const body: Record<string, unknown> = {
name,
icon_info: iconInfo,
doc_form: currentDataset?.doc_form,
description,
permission,
indexing_technique: indexMethod,
retrieval_model: {
...retrievalConfig,
score_threshold: retrievalConfig.score_threshold_enabled ? retrievalConfig.score_threshold : 0,
},
embedding_model: embeddingModel.model,
embedding_model_provider: embeddingModel.provider,
keyword_number: keywordNumber,
summary_index_setting: summaryIndexSetting,
}
if (currentDataset!.provider === 'external') {
body.external_knowledge_id = currentDataset!.external_knowledge_info.external_knowledge_id
body.external_knowledge_api_id = currentDataset!.external_knowledge_info.external_knowledge_api_id
body.external_retrieval_model = {
top_k: topK,
score_threshold: scoreThreshold,
score_threshold_enabled: scoreThresholdEnabled,
}
}
if (permission === DatasetPermission.partialMembers) {
body.partial_member_list = selectedMemberIDs.map((id) => {
return {
user_id: id,
role: memberList.find(member => member.id === id)?.role,
}
})
}
await updateDatasetSetting({ datasetId: currentDataset!.id, body })
Toast.notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
if (mutateDatasets) {
await mutateDatasets()
invalidDatasetList()
}
}
catch {
Toast.notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
}
finally {
setLoading(false)
}
}
// Computed values
const showMultiModalTip = useMemo(() => {
return checkShowMultiModalTip({
embeddingModel,
rerankingEnable: retrievalConfig.reranking_enable,
rerankModel: {
rerankingProviderName: retrievalConfig.reranking_model.reranking_provider_name,
rerankingModelName: retrievalConfig.reranking_model.reranking_model_name,
},
indexMethod,
embeddingModelList,
rerankModelList,
})
}, [embeddingModel, rerankModelList, retrievalConfig.reranking_enable, retrievalConfig.reranking_model, embeddingModelList, indexMethod])
return {
// Context values
currentDataset,
isCurrentWorkspaceDatasetOperator,
// Loading state
loading,
// Basic form
name,
setName,
description,
setDescription,
// Icon
iconInfo,
showAppIconPicker,
handleOpenAppIconPicker,
handleSelectAppIcon,
handleCloseAppIconPicker,
// Permission
permission,
setPermission,
selectedMemberIDs,
setSelectedMemberIDs,
memberList,
// External retrieval
topK,
scoreThreshold,
scoreThresholdEnabled,
handleSettingsChange,
// Indexing and retrieval
indexMethod,
setIndexMethod,
keywordNumber,
setKeywordNumber,
retrievalConfig,
setRetrievalConfig,
embeddingModel,
setEmbeddingModel,
embeddingModelList,
// Summary index
summaryIndexSetting,
handleSummaryIndexSettingChange,
// Computed
showMultiModalTip,
// Actions
handleSave,
}
}

View File

@ -1,488 +0,0 @@
import type { DataSet } from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
import { IndexingType } from '../../create/step-two'
import Form from './index'
// Mock contexts
const mockMutateDatasets = vi.fn()
const mockInvalidDatasetList = vi.fn()
const mockUserProfile = {
id: 'user-1',
name: 'Current User',
email: 'current@example.com',
avatar_url: '',
role: 'owner',
}
vi.mock('@/context/app-context', () => ({
useSelector: (selector: (state: unknown) => unknown) => {
const state = {
isCurrentWorkspaceDatasetOperator: false,
userProfile: mockUserProfile,
}
return selector(state)
},
}))
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
name: 'Test Dataset',
description: 'Test description',
permission: DatasetPermission.onlyMe,
icon_info: {
icon_type: 'emoji',
icon: '📚',
icon_background: '#FFFFFF',
icon_url: '',
},
indexing_technique: IndexingType.QUALIFIED,
indexing_status: 'completed',
data_source_type: DataSourceType.FILE,
doc_form: ChunkingMode.text,
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
embedding_available: true,
app_count: 0,
document_count: 5,
total_document_count: 5,
word_count: 1000,
provider: 'vendor',
tags: [],
partial_member_list: [],
external_knowledge_info: {
external_knowledge_id: 'ext-1',
external_knowledge_api_id: 'api-1',
external_knowledge_api_name: 'External API',
external_knowledge_api_endpoint: 'https://api.example.com',
},
external_retrieval_model: {
top_k: 3,
score_threshold: 0.7,
score_threshold_enabled: true,
},
retrieval_model_dict: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0.5,
} as RetrievalConfig,
retrieval_model: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0.5,
} as RetrievalConfig,
built_in_field_enabled: false,
keyword_number: 10,
created_by: 'user-1',
updated_by: 'user-1',
updated_at: Date.now(),
runtime_mode: 'general',
enable_api: true,
is_multimodal: false,
...overrides,
})
let mockDataset: DataSet = createMockDataset()
vi.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: (selector: (state: { dataset: DataSet | null, mutateDatasetRes: () => void }) => unknown) => {
const state = {
dataset: mockDataset,
mutateDatasetRes: mockMutateDatasets,
}
return selector(state)
},
}))
// Mock services
vi.mock('@/service/datasets', () => ({
updateDatasetSetting: vi.fn().mockResolvedValue({}),
}))
vi.mock('@/service/knowledge/use-dataset', () => ({
useInvalidDatasetList: () => mockInvalidDatasetList,
}))
vi.mock('@/service/use-common', () => ({
useMembers: () => ({
data: {
accounts: [
{ id: 'user-1', name: 'User 1', email: 'user1@example.com', role: 'owner', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' },
{ id: 'user-2', name: 'User 2', email: 'user2@example.com', role: 'admin', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' },
],
},
}),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelList: () => ({ data: [], mutate: vi.fn(), isLoading: false }),
useCurrentProviderAndModel: () => ({ currentProvider: undefined, currentModel: undefined }),
useDefaultModel: () => ({ data: undefined, mutate: vi.fn(), isLoading: false }),
useModelListAndDefaultModel: () => ({ modelList: [], defaultModel: undefined }),
useModelListAndDefaultModelAndCurrentProviderAndModel: () => ({
modelList: [],
defaultModel: undefined,
currentProvider: undefined,
currentModel: undefined,
}),
useUpdateModelList: () => vi.fn(),
useUpdateModelProviders: () => vi.fn(),
useLanguage: () => 'en_US',
useSystemDefaultModelAndModelList: () => [undefined, vi.fn()],
useProviderCredentialsAndLoadBalancing: () => ({
credentials: undefined,
loadBalancing: undefined,
mutate: vi.fn(),
isLoading: false,
}),
useAnthropicBuyQuota: () => vi.fn(),
useMarketplaceAllPlugins: () => ({ plugins: [], isLoading: false }),
useRefreshModel: () => ({ handleRefreshModel: vi.fn() }),
useModelModalHandler: () => vi.fn(),
}))
// Mock provider-context
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
textGenerationModelList: [],
embeddingsModelList: [],
rerankModelList: [],
agentThoughtModelList: [],
modelProviders: [],
textEmbeddingModelList: [],
speech2textModelList: [],
ttsModelList: [],
moderationModelList: [],
hasSettedApiKey: true,
plan: { type: 'free' },
enableBilling: false,
onPlanInfoChanged: vi.fn(),
isCurrentWorkspaceDatasetOperator: false,
supportRetrievalMethods: ['semantic_search', 'full_text_search', 'hybrid_search'],
}),
}))
vi.mock('@/app/components/datasets/common/check-rerank-model', () => ({
isReRankModelSelected: () => true,
}))
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.dify.ai${path}`,
}))
describe('Form', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDataset = createMockDataset()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Form />)
expect(screen.getByRole('button', { name: /form\.save/i })).toBeInTheDocument()
})
it('should render dataset name input with initial value', () => {
render(<Form />)
const nameInput = screen.getByDisplayValue('Test Dataset')
expect(nameInput).toBeInTheDocument()
})
it('should render dataset description textarea', () => {
render(<Form />)
const descriptionTextarea = screen.getByDisplayValue('Test description')
expect(descriptionTextarea).toBeInTheDocument()
})
it('should render save button', () => {
render(<Form />)
const saveButton = screen.getByRole('button', { name: /form\.save/i })
expect(saveButton).toBeInTheDocument()
})
it('should render permission selector', () => {
render(<Form />)
// Permission selector renders the current permission text
expect(screen.getByText(/form\.permissionsOnlyMe/i)).toBeInTheDocument()
})
})
describe('BasicInfoSection', () => {
it('should allow editing dataset name', () => {
render(<Form />)
const nameInput = screen.getByDisplayValue('Test Dataset')
fireEvent.change(nameInput, { target: { value: 'Updated Dataset Name' } })
expect(nameInput).toHaveValue('Updated Dataset Name')
})
it('should allow editing dataset description', () => {
render(<Form />)
const descriptionTextarea = screen.getByDisplayValue('Test description')
fireEvent.change(descriptionTextarea, { target: { value: 'Updated description' } })
expect(descriptionTextarea).toHaveValue('Updated description')
})
it('should render app icon', () => {
const { container } = render(<Form />)
// The app icon wrapper should be rendered (icon may be in a span or SVG)
// The icon is rendered within a clickable container in the name and icon section
const iconSection = container.querySelector('[class*="cursor-pointer"]')
expect(iconSection).toBeInTheDocument()
})
})
describe('IndexingSection - Internal Provider', () => {
it('should render chunk structure section when doc_form is set', () => {
render(<Form />)
expect(screen.getByText(/form\.chunkStructure\.title/i)).toBeInTheDocument()
})
it('should render index method section', () => {
render(<Form />)
// May match multiple elements (label and descriptions)
expect(screen.getAllByText(/form\.indexMethod/i).length).toBeGreaterThan(0)
})
it('should render embedding model section when indexMethod is high_quality', () => {
render(<Form />)
expect(screen.getByText(/form\.embeddingModel/i)).toBeInTheDocument()
})
it('should render retrieval settings section', () => {
render(<Form />)
expect(screen.getByText(/form\.retrievalSetting\.title/i)).toBeInTheDocument()
})
it('should render learn more links', () => {
render(<Form />)
const learnMoreLinks = screen.getAllByText(/learnMore/i)
expect(learnMoreLinks.length).toBeGreaterThan(0)
})
})
describe('ExternalKnowledgeSection - External Provider', () => {
beforeEach(() => {
mockDataset = createMockDataset({ provider: 'external' })
})
it('should render external knowledge API info when provider is external', () => {
render(<Form />)
expect(screen.getByText(/form\.externalKnowledgeAPI/i)).toBeInTheDocument()
})
it('should render external knowledge ID when provider is external', () => {
render(<Form />)
expect(screen.getByText(/form\.externalKnowledgeID/i)).toBeInTheDocument()
})
it('should display external API name', () => {
render(<Form />)
expect(screen.getByText('External API')).toBeInTheDocument()
})
it('should display external API endpoint', () => {
render(<Form />)
expect(screen.getByText('https://api.example.com')).toBeInTheDocument()
})
it('should display external knowledge ID value', () => {
render(<Form />)
expect(screen.getByText('ext-1')).toBeInTheDocument()
})
})
describe('Save Functionality', () => {
it('should call save when save button is clicked', async () => {
const { updateDatasetSetting } = await import('@/service/datasets')
render(<Form />)
const saveButton = screen.getByRole('button', { name: /form\.save/i })
fireEvent.click(saveButton)
await waitFor(() => {
expect(updateDatasetSetting).toHaveBeenCalled()
})
})
it('should show loading state on save button while saving', async () => {
const { updateDatasetSetting } = await import('@/service/datasets')
vi.mocked(updateDatasetSetting).mockImplementation(
() => new Promise(resolve => setTimeout(resolve, 100)),
)
render(<Form />)
const saveButton = screen.getByRole('button', { name: /form\.save/i })
fireEvent.click(saveButton)
// Button should be disabled during loading
await waitFor(() => {
expect(saveButton).toBeDisabled()
})
})
it('should show error when trying to save with empty name', async () => {
const Toast = await import('@/app/components/base/toast')
render(<Form />)
// Clear the name
const nameInput = screen.getByDisplayValue('Test Dataset')
fireEvent.change(nameInput, { target: { value: '' } })
// Try to save
const saveButton = screen.getByRole('button', { name: /form\.save/i })
fireEvent.click(saveButton)
await waitFor(() => {
expect(Toast.default.notify).toHaveBeenCalledWith({
type: 'error',
message: expect.any(String),
})
})
})
it('should save with updated name', async () => {
const { updateDatasetSetting } = await import('@/service/datasets')
render(<Form />)
// Update name
const nameInput = screen.getByDisplayValue('Test Dataset')
fireEvent.change(nameInput, { target: { value: 'New Dataset Name' } })
// Save
const saveButton = screen.getByRole('button', { name: /form\.save/i })
fireEvent.click(saveButton)
await waitFor(() => {
expect(updateDatasetSetting).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.objectContaining({
name: 'New Dataset Name',
}),
}),
)
})
})
it('should save with updated description', async () => {
const { updateDatasetSetting } = await import('@/service/datasets')
render(<Form />)
// Update description
const descriptionTextarea = screen.getByDisplayValue('Test description')
fireEvent.change(descriptionTextarea, { target: { value: 'New description' } })
// Save
const saveButton = screen.getByRole('button', { name: /form\.save/i })
fireEvent.click(saveButton)
await waitFor(() => {
expect(updateDatasetSetting).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.objectContaining({
description: 'New description',
}),
}),
)
})
})
})
describe('Disabled States', () => {
it('should disable inputs when embedding is not available', () => {
mockDataset = createMockDataset({ embedding_available: false })
render(<Form />)
const nameInput = screen.getByDisplayValue('Test Dataset')
expect(nameInput).toBeDisabled()
const descriptionTextarea = screen.getByDisplayValue('Test description')
expect(descriptionTextarea).toBeDisabled()
})
})
describe('Conditional Rendering', () => {
it('should not render chunk structure when doc_form is not set', () => {
mockDataset = createMockDataset({ doc_form: undefined as unknown as ChunkingMode })
render(<Form />)
// Chunk structure should not be present
expect(screen.queryByText(/form\.chunkStructure\.title/i)).not.toBeInTheDocument()
})
it('should render IndexingSection for internal provider', () => {
mockDataset = createMockDataset({ provider: 'vendor' })
render(<Form />)
// May match multiple elements (label and descriptions)
expect(screen.getAllByText(/form\.indexMethod/i).length).toBeGreaterThan(0)
expect(screen.queryByText(/form\.externalKnowledgeAPI/i)).not.toBeInTheDocument()
})
it('should render ExternalKnowledgeSection for external provider', () => {
mockDataset = createMockDataset({ provider: 'external' })
render(<Form />)
expect(screen.getByText(/form\.externalKnowledgeAPI/i)).toBeInTheDocument()
})
})
describe('Permission Selection', () => {
it('should open permission dropdown when clicked', async () => {
render(<Form />)
const permissionTrigger = screen.getByText(/form\.permissionsOnlyMe/i)
fireEvent.click(permissionTrigger)
await waitFor(() => {
// Should show all permission options
expect(screen.getAllByText(/form\.permissionsOnlyMe/i).length).toBeGreaterThanOrEqual(1)
})
})
})
describe('Integration', () => {
it('should render all main sections', () => {
render(<Form />)
// Basic info
expect(screen.getByText(/form\.nameAndIcon/i)).toBeInTheDocument()
expect(screen.getByText(/form\.desc/i)).toBeInTheDocument()
// form.permissions matches multiple elements (label and permission options)
expect(screen.getAllByText(/form\.permissions/i).length).toBeGreaterThan(0)
// Indexing (for internal provider)
expect(screen.getByText(/form\.chunkStructure\.title/i)).toBeInTheDocument()
// form.indexMethod matches multiple elements
expect(screen.getAllByText(/form\.indexMethod/i).length).toBeGreaterThan(0)
// Save button
expect(screen.getByRole('button', { name: /form\.save/i })).toBeInTheDocument()
})
})
})

View File

@ -1,126 +1,486 @@
'use client'
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import type { DefaultModel } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { Member } from '@/models/common'
import type { IconInfo, SummaryIndexSetting as SummaryIndexSettingType } from '@/models/datasets'
import type { AppIconType, RetrievalConfig } from '@/types/app'
import { RiAlertFill } from '@remixicon/react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import AppIconPicker from '@/app/components/base/app-icon-picker'
import Button from '@/app/components/base/button'
import Divider from '@/app/components/base/divider'
import BasicInfoSection from './components/basic-info-section'
import ExternalKnowledgeSection from './components/external-knowledge-section'
import IndexingSection from './components/indexing-section'
import { useFormState } from './hooks/use-form-state'
import { ApiConnectionMod } from '@/app/components/base/icons/src/vender/solid/development'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import Toast from '@/app/components/base/toast'
import { isReRankModelSelected } from '@/app/components/datasets/common/check-rerank-model'
import EconomicalRetrievalMethodConfig from '@/app/components/datasets/common/economical-retrieval-method-config'
import RetrievalMethodConfig from '@/app/components/datasets/common/retrieval-method-config'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useDocLink } from '@/context/i18n'
import { ChunkingMode, DatasetPermission } from '@/models/datasets'
import { updateDatasetSetting } from '@/service/datasets'
import { useInvalidDatasetList } from '@/service/knowledge/use-dataset'
import { useMembers } from '@/service/use-common'
import { IndexingType } from '../../create/step-two'
import RetrievalSettings from '../../external-knowledge-base/create/RetrievalSettings'
import ChunkStructure from '../chunk-structure'
import IndexMethod from '../index-method'
import PermissionSelector from '../permission-selector'
import SummaryIndexSetting from '../summary-index-setting'
import { checkShowMultiModalTip } from '../utils'
const rowClass = 'flex gap-x-1'
const labelClass = 'flex items-center shrink-0 w-[180px] h-7 pt-1'
const DEFAULT_APP_ICON: IconInfo = {
icon_type: 'emoji',
icon: '📙',
icon_background: '#FFF4ED',
icon_url: '',
}
const Form = () => {
const { t } = useTranslation()
const {
// Context values
currentDataset,
isCurrentWorkspaceDatasetOperator,
const docLink = useDocLink()
const isCurrentWorkspaceDatasetOperator = useAppContextWithSelector(state => state.isCurrentWorkspaceDatasetOperator)
const currentDataset = useDatasetDetailContextWithSelector(state => state.dataset)
const mutateDatasets = useDatasetDetailContextWithSelector(state => state.mutateDatasetRes)
const [loading, setLoading] = useState(false)
const [name, setName] = useState(currentDataset?.name ?? '')
const [iconInfo, setIconInfo] = useState(currentDataset?.icon_info || DEFAULT_APP_ICON)
const [showAppIconPicker, setShowAppIconPicker] = useState(false)
const [description, setDescription] = useState(currentDataset?.description ?? '')
const [permission, setPermission] = useState(currentDataset?.permission)
const [topK, setTopK] = useState(currentDataset?.external_retrieval_model.top_k ?? 2)
const [scoreThreshold, setScoreThreshold] = useState(currentDataset?.external_retrieval_model.score_threshold ?? 0.5)
const [scoreThresholdEnabled, setScoreThresholdEnabled] = useState(currentDataset?.external_retrieval_model.score_threshold_enabled ?? false)
const [selectedMemberIDs, setSelectedMemberIDs] = useState<string[]>(currentDataset?.partial_member_list || [])
const [memberList, setMemberList] = useState<Member[]>([])
const [indexMethod, setIndexMethod] = useState(currentDataset?.indexing_technique)
const [keywordNumber, setKeywordNumber] = useState(currentDataset?.keyword_number ?? 10)
const [retrievalConfig, setRetrievalConfig] = useState(currentDataset?.retrieval_model_dict as RetrievalConfig)
const [embeddingModel, setEmbeddingModel] = useState<DefaultModel>(
currentDataset?.embedding_model
? {
provider: currentDataset.embedding_model_provider,
model: currentDataset.embedding_model,
}
: {
provider: '',
model: '',
},
)
const [summaryIndexSetting, setSummaryIndexSetting] = useState(currentDataset?.summary_index_setting)
const handleSummaryIndexSettingChange = useCallback((payload: SummaryIndexSettingType) => {
setSummaryIndexSetting((prev) => {
return { ...prev, ...payload }
})
}, [])
const { data: rerankModelList } = useModelList(ModelTypeEnum.rerank)
const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding)
const { data: membersData } = useMembers()
const previousAppIcon = useRef(DEFAULT_APP_ICON)
// Loading state
loading,
const handleOpenAppIconPicker = useCallback(() => {
setShowAppIconPicker(true)
previousAppIcon.current = iconInfo
}, [iconInfo])
// Basic form
name,
setName,
description,
setDescription,
const handleSelectAppIcon = useCallback((icon: AppIconSelection) => {
const iconInfo: IconInfo = {
icon_type: icon.type,
icon: icon.type === 'emoji' ? icon.icon : icon.fileId,
icon_background: icon.type === 'emoji' ? icon.background : undefined,
icon_url: icon.type === 'emoji' ? undefined : icon.url,
}
setIconInfo(iconInfo)
setShowAppIconPicker(false)
}, [])
// Icon
iconInfo,
showAppIconPicker,
handleOpenAppIconPicker,
handleSelectAppIcon,
handleCloseAppIconPicker,
const handleCloseAppIconPicker = useCallback(() => {
setIconInfo(previousAppIcon.current)
setShowAppIconPicker(false)
}, [])
// Permission
permission,
setPermission,
selectedMemberIDs,
setSelectedMemberIDs,
memberList,
const handleSettingsChange = useCallback((data: { top_k?: number, score_threshold?: number, score_threshold_enabled?: boolean }) => {
if (data.top_k !== undefined)
setTopK(data.top_k)
if (data.score_threshold !== undefined)
setScoreThreshold(data.score_threshold)
if (data.score_threshold_enabled !== undefined)
setScoreThresholdEnabled(data.score_threshold_enabled)
}, [])
// External retrieval
topK,
scoreThreshold,
scoreThresholdEnabled,
handleSettingsChange,
useEffect(() => {
if (!membersData?.accounts)
setMemberList([])
else
setMemberList(membersData.accounts)
}, [membersData])
// Indexing and retrieval
indexMethod,
setIndexMethod,
keywordNumber,
setKeywordNumber,
retrievalConfig,
setRetrievalConfig,
embeddingModel,
setEmbeddingModel,
embeddingModelList,
const invalidDatasetList = useInvalidDatasetList()
const handleSave = async () => {
if (loading)
return
if (!name?.trim()) {
Toast.notify({ type: 'error', message: t('form.nameError', { ns: 'datasetSettings' }) })
return
}
if (
!isReRankModelSelected({
rerankModelList,
retrievalConfig,
indexMethod,
})
) {
Toast.notify({ type: 'error', message: t('datasetConfig.rerankModelRequired', { ns: 'appDebug' }) })
return
}
if (retrievalConfig.weights) {
retrievalConfig.weights.vector_setting.embedding_provider_name = embeddingModel.provider || ''
retrievalConfig.weights.vector_setting.embedding_model_name = embeddingModel.model || ''
}
try {
setLoading(true)
const requestParams = {
datasetId: currentDataset!.id,
body: {
name,
icon_info: iconInfo,
doc_form: currentDataset?.doc_form,
description,
permission,
indexing_technique: indexMethod,
retrieval_model: {
...retrievalConfig,
score_threshold: retrievalConfig.score_threshold_enabled ? retrievalConfig.score_threshold : 0,
},
embedding_model: embeddingModel.model,
embedding_model_provider: embeddingModel.provider,
...(currentDataset!.provider === 'external' && {
external_knowledge_id: currentDataset!.external_knowledge_info.external_knowledge_id,
external_knowledge_api_id: currentDataset!.external_knowledge_info.external_knowledge_api_id,
external_retrieval_model: {
top_k: topK,
score_threshold: scoreThreshold,
score_threshold_enabled: scoreThresholdEnabled,
},
}),
keyword_number: keywordNumber,
summary_index_setting: summaryIndexSetting,
},
} as any
if (permission === DatasetPermission.partialMembers) {
requestParams.body.partial_member_list = selectedMemberIDs.map((id) => {
return {
user_id: id,
role: memberList.find(member => member.id === id)?.role,
}
})
}
await updateDatasetSetting(requestParams)
Toast.notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
if (mutateDatasets) {
await mutateDatasets()
invalidDatasetList()
}
}
catch {
Toast.notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
}
finally {
setLoading(false)
}
}
// Summary index
summaryIndexSetting,
handleSummaryIndexSettingChange,
const isShowIndexMethod = currentDataset && currentDataset.doc_form !== ChunkingMode.parentChild && currentDataset.indexing_technique && indexMethod
// Computed
showMultiModalTip,
// Actions
handleSave,
} = useFormState()
const isExternalProvider = currentDataset?.provider === 'external'
const showMultiModalTip = useMemo(() => {
return checkShowMultiModalTip({
embeddingModel,
rerankingEnable: retrievalConfig.reranking_enable,
rerankModel: {
rerankingProviderName: retrievalConfig.reranking_model.reranking_provider_name,
rerankingModelName: retrievalConfig.reranking_model.reranking_model_name,
},
indexMethod,
embeddingModelList,
rerankModelList,
})
}, [embeddingModel, rerankModelList, retrievalConfig.reranking_enable, retrievalConfig.reranking_model, embeddingModelList, indexMethod])
return (
<div className="flex w-full flex-col gap-y-4 px-20 py-8 sm:w-[960px]">
<BasicInfoSection
currentDataset={currentDataset}
isCurrentWorkspaceDatasetOperator={isCurrentWorkspaceDatasetOperator}
name={name}
setName={setName}
description={description}
setDescription={setDescription}
iconInfo={iconInfo}
showAppIconPicker={showAppIconPicker}
handleOpenAppIconPicker={handleOpenAppIconPicker}
handleSelectAppIcon={handleSelectAppIcon}
handleCloseAppIconPicker={handleCloseAppIconPicker}
permission={permission}
setPermission={setPermission}
selectedMemberIDs={selectedMemberIDs}
setSelectedMemberIDs={setSelectedMemberIDs}
memberList={memberList}
/>
{isExternalProvider
? (
<ExternalKnowledgeSection
currentDataset={currentDataset}
topK={topK}
scoreThreshold={scoreThreshold}
scoreThresholdEnabled={scoreThresholdEnabled}
handleSettingsChange={handleSettingsChange}
{/* Dataset name and icon */}
<div className={rowClass}>
<div className={labelClass}>
<div className="system-sm-semibold text-text-secondary">{t('form.nameAndIcon', { ns: 'datasetSettings' })}</div>
</div>
<div className="flex grow items-center gap-x-2">
<AppIcon
size="small"
onClick={handleOpenAppIconPicker}
className="cursor-pointer"
iconType={iconInfo.icon_type as AppIconType}
icon={iconInfo.icon}
background={iconInfo.icon_background}
imageUrl={iconInfo.icon_url}
showEditIcon
/>
<Input
disabled={!currentDataset?.embedding_available}
value={name}
onChange={e => setName(e.target.value)}
/>
</div>
</div>
{/* Dataset description */}
<div className={rowClass}>
<div className={labelClass}>
<div className="system-sm-semibold text-text-secondary">{t('form.desc', { ns: 'datasetSettings' })}</div>
</div>
<div className="grow">
<Textarea
disabled={!currentDataset?.embedding_available}
className="resize-none"
placeholder={t('form.descPlaceholder', { ns: 'datasetSettings' }) || ''}
value={description}
onChange={e => setDescription(e.target.value)}
/>
</div>
</div>
{/* Permissions */}
<div className={rowClass}>
<div className={labelClass}>
<div className="system-sm-semibold text-text-secondary">{t('form.permissions', { ns: 'datasetSettings' })}</div>
</div>
<div className="grow">
<PermissionSelector
disabled={!currentDataset?.embedding_available || isCurrentWorkspaceDatasetOperator}
permission={permission}
value={selectedMemberIDs}
onChange={v => setPermission(v)}
onMemberSelect={setSelectedMemberIDs}
memberList={memberList}
/>
</div>
</div>
{
!!currentDataset?.doc_form && (
<>
<Divider
type="horizontal"
className="my-1 h-px bg-divider-subtle"
/>
)
: (
<IndexingSection
currentDataset={currentDataset}
indexMethod={indexMethod}
setIndexMethod={setIndexMethod}
{/* Chunk Structure */}
<div className={rowClass}>
<div className="flex w-[180px] shrink-0 flex-col">
<div className="system-sm-semibold flex h-8 items-center text-text-secondary">
{t('form.chunkStructure.title', { ns: 'datasetSettings' })}
</div>
<div className="body-xs-regular text-text-tertiary">
<a
target="_blank"
rel="noopener noreferrer"
href={docLink('/use-dify/knowledge/create-knowledge/chunking-and-cleaning-text')}
className="text-text-accent"
>
{t('form.chunkStructure.learnMore', { ns: 'datasetSettings' })}
</a>
{t('form.chunkStructure.description', { ns: 'datasetSettings' })}
</div>
</div>
<div className="grow">
<ChunkStructure
chunkStructure={currentDataset?.doc_form}
/>
</div>
</div>
</>
)
}
{!!(isShowIndexMethod || indexMethod === 'high_quality') && (
<Divider
type="horizontal"
className="my-1 h-px bg-divider-subtle"
/>
)}
{!!isShowIndexMethod && (
<div className={rowClass}>
<div className={labelClass}>
<div className="system-sm-semibold text-text-secondary">{t('form.indexMethod', { ns: 'datasetSettings' })}</div>
</div>
<div className="grow">
<IndexMethod
value={indexMethod}
disabled={!currentDataset?.embedding_available}
onChange={v => setIndexMethod(v!)}
currentValue={currentDataset.indexing_technique}
keywordNumber={keywordNumber}
setKeywordNumber={setKeywordNumber}
embeddingModel={embeddingModel}
setEmbeddingModel={setEmbeddingModel}
embeddingModelList={embeddingModelList}
retrievalConfig={retrievalConfig}
setRetrievalConfig={setRetrievalConfig}
summaryIndexSetting={summaryIndexSetting}
handleSummaryIndexSettingChange={handleSummaryIndexSettingChange}
showMultiModalTip={showMultiModalTip}
onKeywordNumberChange={setKeywordNumber}
/>
)}
{currentDataset.indexing_technique === IndexingType.ECONOMICAL && indexMethod === IndexingType.QUALIFIED && (
<div className="relative mt-2 flex h-10 items-center gap-x-0.5 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur px-2 shadow-xs shadow-shadow-shadow-3">
<div className="absolute left-0 top-0 flex h-full w-full items-center bg-toast-warning-bg opacity-40" />
<div className="p-1">
<RiAlertFill className="size-4 text-text-warning-secondary" />
</div>
<span className="system-xs-medium text-text-primary">
{t('form.upgradeHighQualityTip', { ns: 'datasetSettings' })}
</span>
</div>
)}
</div>
</div>
)}
{indexMethod === IndexingType.QUALIFIED && (
<div className={rowClass}>
<div className={labelClass}>
<div className="system-sm-semibold text-text-secondary">
{t('form.embeddingModel', { ns: 'datasetSettings' })}
</div>
</div>
<div className="grow">
<ModelSelector
defaultModel={embeddingModel}
modelList={embeddingModelList}
onSelect={setEmbeddingModel}
/>
</div>
</div>
)}
{
indexMethod === IndexingType.QUALIFIED
&& [ChunkingMode.text, ChunkingMode.parentChild].includes(currentDataset?.doc_form as ChunkingMode)
&& (
<>
<Divider
type="horizontal"
className="my-1 h-px bg-divider-subtle"
/>
<SummaryIndexSetting
entry="dataset-settings"
summaryIndexSetting={summaryIndexSetting}
onSummaryIndexSettingChange={handleSummaryIndexSettingChange}
/>
</>
)
}
{/* Retrieval Method Config */}
{currentDataset?.provider === 'external'
? (
<>
<Divider
type="horizontal"
className="my-1 h-px bg-divider-subtle"
/>
<div className={rowClass}>
<div className={labelClass}>
<div className="system-sm-semibold text-text-secondary">{t('form.retrievalSetting.title', { ns: 'datasetSettings' })}</div>
</div>
<RetrievalSettings
topK={topK}
scoreThreshold={scoreThreshold}
scoreThresholdEnabled={scoreThresholdEnabled}
onChange={handleSettingsChange}
isInRetrievalSetting={true}
/>
</div>
<Divider
type="horizontal"
className="my-1 h-px bg-divider-subtle"
/>
<div className={rowClass}>
<div className={labelClass}>
<div className="system-sm-semibold text-text-secondary">{t('form.externalKnowledgeAPI', { ns: 'datasetSettings' })}</div>
</div>
<div className="w-full">
<div className="flex h-full items-center gap-1 rounded-lg bg-components-input-bg-normal px-3 py-2">
<ApiConnectionMod className="h-4 w-4 text-text-secondary" />
<div className="system-sm-medium overflow-hidden text-ellipsis text-text-secondary">
{currentDataset?.external_knowledge_info.external_knowledge_api_name}
</div>
<div className="system-xs-regular text-text-tertiary">·</div>
<div className="system-xs-regular text-text-tertiary">
{currentDataset?.external_knowledge_info.external_knowledge_api_endpoint}
</div>
</div>
</div>
</div>
<div className={rowClass}>
<div className={labelClass}>
<div className="system-sm-semibold text-text-secondary">{t('form.externalKnowledgeID', { ns: 'datasetSettings' })}</div>
</div>
<div className="w-full">
<div className="flex h-full items-center gap-1 rounded-lg bg-components-input-bg-normal px-3 py-2">
<div className="system-xs-regular text-text-tertiary">
{currentDataset?.external_knowledge_info.external_knowledge_id}
</div>
</div>
</div>
</div>
</>
)
<Divider type="horizontal" className="my-1 h-px bg-divider-subtle" />
{/* Save Button */}
<div className="flex gap-x-1">
<div className="flex h-7 w-[180px] shrink-0 items-center pt-1" />
: indexMethod
? (
<>
<Divider
type="horizontal"
className="my-1 h-px bg-divider-subtle"
/>
<div className={rowClass}>
<div className={labelClass}>
<div className="flex w-[180px] shrink-0 flex-col">
<div className="system-sm-semibold flex h-7 items-center pt-1 text-text-secondary">
{t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
</div>
<div className="body-xs-regular text-text-tertiary">
<a
target="_blank"
rel="noopener noreferrer"
href={docLink('/use-dify/knowledge/create-knowledge/setting-indexing-methods')}
className="text-text-accent"
>
{t('form.retrievalSetting.learnMore', { ns: 'datasetSettings' })}
</a>
{t('form.retrievalSetting.description', { ns: 'datasetSettings' })}
</div>
</div>
</div>
<div className="grow">
{indexMethod === IndexingType.QUALIFIED
? (
<RetrievalMethodConfig
value={retrievalConfig}
onChange={setRetrievalConfig}
showMultiModalTip={showMultiModalTip}
/>
)
: (
<EconomicalRetrievalMethodConfig
value={retrievalConfig}
onChange={setRetrievalConfig}
/>
)}
</div>
</div>
</>
)
: null}
<Divider
type="horizontal"
className="my-1 h-px bg-divider-subtle"
/>
<div className={rowClass}>
<div className={labelClass} />
<div className="grow">
<Button
className="min-w-24"
@ -133,6 +493,12 @@ const Form = () => {
</Button>
</div>
</div>
{showAppIconPicker && (
<AppIconPicker
onSelect={handleSelectAppIcon}
onClose={handleCloseAppIconPicker}
/>
)}
</div>
)
}

View File

@ -139,7 +139,7 @@ describe('CreateAppModal', () => {
expect(screen.getByText('explore.appCustomize.title:{"name":"My App"}')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /common\.operation\.create/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /common\.operation\.cancel/ })).toBeInTheDocument()
})
it('should render edit-only fields when editing a chat app', () => {
@ -166,7 +166,7 @@ describe('CreateAppModal', () => {
it('should not render modal content when hidden', () => {
setup({ show: false })
expect(screen.queryByRole('button', { name: 'common.operation.create' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /common\.operation\.create/ })).not.toBeInTheDocument()
})
})

View File

@ -47,6 +47,10 @@ vi.mock('./context', () => ({
GotoAnythingProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}))
vi.mock('@/app/components/workflow/utils', () => ({
getKeyboardKeyNameBySystem: (key: string) => key,
}))
const createActionItem = (key: ActionItem['key'], shortcut: string): ActionItem => ({
key,
shortcut,

View File

@ -1,95 +1,166 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { renderHook } from '@testing-library/react'
import { act } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// ============================================================================
// Import after mocks
// ============================================================================
import { useDSL } from './use-DSL'
// Mock dependencies
const mockNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
useToastContext: () => ({ notify: mockNotify }),
}))
const mockEventEmitter = { emit: vi.fn() }
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({ eventEmitter: mockEventEmitter }),
}))
const mockDoSyncWorkflowDraft = vi.fn()
vi.mock('./use-nodes-sync-draft', () => ({
useNodesSyncDraft: () => ({ doSyncWorkflowDraft: mockDoSyncWorkflowDraft }),
}))
const mockGetState = vi.fn()
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({ getState: mockGetState }),
}))
const mockExportPipelineConfig = vi.fn()
vi.mock('@/service/use-pipeline', () => ({
useExportPipelineDSL: () => ({ mutateAsync: mockExportPipelineConfig }),
}))
const mockFetchWorkflowDraft = vi.fn()
vi.mock('@/service/workflow', () => ({
fetchWorkflowDraft: (...args: unknown[]) => mockFetchWorkflowDraft(...args),
}))
// ============================================================================
// Mocks
// ============================================================================
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock toast context
const mockNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
useToastContext: () => ({
notify: mockNotify,
}),
}))
// Mock event emitter context
const mockEmit = vi.fn()
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
emit: mockEmit,
},
}),
}))
// Mock workflow store
const mockWorkflowStoreGetState = vi.fn()
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: mockWorkflowStoreGetState,
}),
}))
// Mock useNodesSyncDraft
const mockDoSyncWorkflowDraft = vi.fn()
vi.mock('./use-nodes-sync-draft', () => ({
useNodesSyncDraft: () => ({
doSyncWorkflowDraft: mockDoSyncWorkflowDraft,
}),
}))
// Mock pipeline service
const mockExportPipelineConfig = vi.fn()
vi.mock('@/service/use-pipeline', () => ({
useExportPipelineDSL: () => ({
mutateAsync: mockExportPipelineConfig,
}),
}))
// Mock workflow service
const mockFetchWorkflowDraft = vi.fn()
vi.mock('@/service/workflow', () => ({
fetchWorkflowDraft: (url: string) => mockFetchWorkflowDraft(url),
}))
// Mock download utility
const mockDownloadBlob = vi.fn()
vi.mock('@/utils/download', () => ({
downloadBlob: (options: { data: Blob, fileName: string }) => mockDownloadBlob(options),
}))
// Mock workflow constants
vi.mock('@/app/components/workflow/constants', () => ({
DSL_EXPORT_CHECK: 'DSL_EXPORT_CHECK',
}))
// ============================================================================
// Tests
// ============================================================================
describe('useDSL', () => {
let originalCreateObjectURL: typeof URL.createObjectURL
let originalRevokeObjectURL: typeof URL.revokeObjectURL
let mockCreateObjectURL: ReturnType<typeof vi.fn<(obj: Blob | MediaSource) => string>>
let mockRevokeObjectURL: ReturnType<typeof vi.fn<(url: string) => void>>
let mockClick: ReturnType<typeof vi.fn<() => void>>
let mockLink: { href: string, download: string, click: ReturnType<typeof vi.fn>, style: { display: string }, remove: ReturnType<typeof vi.fn> }
let originalCreateElement: typeof document.createElement
let originalAppendChild: typeof document.body.appendChild
let mockCreateObjectURL: ReturnType<typeof vi.spyOn>
let mockRevokeObjectURL: ReturnType<typeof vi.spyOn>
beforeEach(() => {
vi.clearAllMocks()
mockGetState.mockReturnValue({ pipelineId: 'test-pipeline-id', knowledgeName: 'test-knowledge' })
// Create a proper mock link element with all required properties for downloadBlob
mockLink = {
href: '',
download: '',
click: vi.fn(),
style: { display: '' },
remove: vi.fn(),
}
// Save original and mock selectively - only intercept 'a' elements
originalCreateElement = document.createElement.bind(document)
document.createElement = vi.fn((tagName: string) => {
if (tagName === 'a') {
return mockLink as unknown as HTMLElement
}
return originalCreateElement(tagName)
}) as typeof document.createElement
// Mock document.body.appendChild for downloadBlob
originalAppendChild = document.body.appendChild.bind(document.body)
document.body.appendChild = vi.fn(<T extends Node>(node: T): T => node) as typeof document.body.appendChild
// downloadBlob uses window.URL, not URL
mockCreateObjectURL = vi.spyOn(window.URL, 'createObjectURL').mockReturnValue('blob:test-url')
mockRevokeObjectURL = vi.spyOn(window.URL, 'revokeObjectURL').mockImplementation(() => {})
// Default store state
mockWorkflowStoreGetState.mockReturnValue({
pipelineId: 'test-pipeline-id',
knowledgeName: 'Test Knowledge Base',
})
mockDoSyncWorkflowDraft.mockResolvedValue(undefined)
mockExportPipelineConfig.mockResolvedValue({ data: 'yaml-content' })
mockFetchWorkflowDraft.mockResolvedValue({ environment_variables: [] })
// Save originals
originalCreateObjectURL = URL.createObjectURL
originalRevokeObjectURL = URL.revokeObjectURL
// Setup URL mocks with correct types
mockCreateObjectURL = vi.fn<(obj: Blob | MediaSource) => string>().mockReturnValue('blob:test-url')
mockRevokeObjectURL = vi.fn<(url: string) => void>()
URL.createObjectURL = mockCreateObjectURL
URL.revokeObjectURL = mockRevokeObjectURL
// Setup click mock
mockClick = vi.fn<() => void>()
mockFetchWorkflowDraft.mockResolvedValue({
environment_variables: [],
})
})
afterEach(() => {
// Restore originals
URL.createObjectURL = originalCreateObjectURL
URL.revokeObjectURL = originalRevokeObjectURL
vi.restoreAllMocks()
document.createElement = originalCreateElement
document.body.appendChild = originalAppendChild
mockCreateObjectURL.mockRestore()
mockRevokeObjectURL.mockRestore()
vi.clearAllMocks()
})
// Helper to setup anchor element mock
const setupAnchorMock = () => {
const realCreateElement = document.createElement.bind(document)
vi.spyOn(document, 'createElement').mockImplementation((tagName: string) => {
if (tagName === 'a') {
const anchor = realCreateElement('a')
;(anchor as HTMLAnchorElement).click = mockClick
return anchor
}
return realCreateElement(tagName)
describe('hook initialization', () => {
it('should return exportCheck function', () => {
const { result } = renderHook(() => useDSL())
expect(result.current.exportCheck).toBeDefined()
expect(typeof result.current.exportCheck).toBe('function')
})
}
it('should return handleExportDSL function', () => {
const { result } = renderHook(() => useDSL())
expect(result.current.handleExportDSL).toBeDefined()
expect(typeof result.current.handleExportDSL).toBe('function')
})
})
describe('handleExportDSL', () => {
it('should return early when pipelineId is not set', async () => {
mockGetState.mockReturnValue({ pipelineId: null, knowledgeName: 'test' })
it('should not export when pipelineId is missing', async () => {
mockWorkflowStoreGetState.mockReturnValue({
pipelineId: undefined,
knowledgeName: 'Test',
})
const { result } = renderHook(() => useDSL())
@ -98,44 +169,72 @@ describe('useDSL', () => {
})
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
expect(mockExportPipelineConfig).not.toHaveBeenCalled()
})
it('should sync workflow draft before export', async () => {
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.handleExportDSL()
})
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
})
it('should call exportPipelineConfig with correct params', async () => {
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.handleExportDSL(true)
})
expect(mockExportPipelineConfig).toHaveBeenCalledWith({
pipelineId: 'test-pipeline-id',
include: true,
})
})
it('should create and download file', async () => {
setupAnchorMock()
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.handleExportDSL()
})
await waitFor(() => {
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
expect(mockExportPipelineConfig).toHaveBeenCalledWith({
pipelineId: 'test-pipeline-id',
include: false,
})
expect(document.createElement).toHaveBeenCalledWith('a')
expect(mockCreateObjectURL).toHaveBeenCalled()
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:test-url')
})
expect(mockDownloadBlob).toHaveBeenCalled()
const callArg = mockDownloadBlob.mock.calls[0][0]
expect(callArg.data).toBeInstanceOf(Blob)
expect(callArg.fileName).toBe('Test Knowledge Base.pipeline')
})
it('should trigger download click', async () => {
setupAnchorMock()
it('should use correct file extension for download', async () => {
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.handleExportDSL()
})
await waitFor(() => {
expect(mockClick).toHaveBeenCalled()
})
expect(mockDownloadBlob).toHaveBeenCalledWith(
expect.objectContaining({
fileName: 'Test Knowledge Base.pipeline',
}),
)
})
it('should handle export error', async () => {
it('should trigger download with yaml blob', async () => {
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.handleExportDSL()
})
expect(mockDownloadBlob).toHaveBeenCalled()
const callArg = mockDownloadBlob.mock.calls[0][0]
expect(callArg.data.type).toBe('application/yaml')
})
it('should show error notification on export failure', async () => {
mockExportPipelineConfig.mockRejectedValue(new Error('Export failed'))
const { result } = renderHook(() => useDSL())
@ -144,35 +243,19 @@ describe('useDSL', () => {
await result.current.handleExportDSL()
})
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'exportFailed',
})
})
})
it('should pass include parameter', async () => {
setupAnchorMock()
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.handleExportDSL(true)
})
await waitFor(() => {
expect(mockExportPipelineConfig).toHaveBeenCalledWith({
pipelineId: 'test-pipeline-id',
include: true,
})
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'exportFailed',
})
})
})
describe('exportCheck', () => {
it('should return early when pipelineId is not set', async () => {
mockGetState.mockReturnValue({ pipelineId: null })
it('should not check when pipelineId is missing', async () => {
mockWorkflowStoreGetState.mockReturnValue({
pipelineId: undefined,
knowledgeName: 'Test',
})
const { result } = renderHook(() => useDSL())
@ -183,9 +266,22 @@ describe('useDSL', () => {
expect(mockFetchWorkflowDraft).not.toHaveBeenCalled()
})
it('should call handleExportDSL directly when no secret variables', async () => {
setupAnchorMock()
mockFetchWorkflowDraft.mockResolvedValue({ environment_variables: [] })
it('should fetch workflow draft', async () => {
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.exportCheck()
})
expect(mockFetchWorkflowDraft).toHaveBeenCalledWith('/rag/pipelines/test-pipeline-id/workflows/draft')
})
it('should directly export when no secret environment variables', async () => {
mockFetchWorkflowDraft.mockResolvedValue({
environment_variables: [
{ id: '1', value_type: 'string', value: 'test' },
],
})
const { result } = renderHook(() => useDSL())
@ -193,15 +289,16 @@ describe('useDSL', () => {
await result.current.exportCheck()
})
await waitFor(() => {
expect(mockFetchWorkflowDraft).toHaveBeenCalledWith('/rag/pipelines/test-pipeline-id/workflows/draft')
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
})
// Should call doSyncWorkflowDraft (which means handleExportDSL was called)
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
})
it('should emit event when secret variables exist', async () => {
const secretVars = [{ value_type: 'secret', name: 'API_KEY' }]
mockFetchWorkflowDraft.mockResolvedValue({ environment_variables: secretVars })
it('should emit DSL_EXPORT_CHECK event when secret variables exist', async () => {
mockFetchWorkflowDraft.mockResolvedValue({
environment_variables: [
{ id: '1', value_type: 'secret', value: 'secret-value' },
],
})
const { result } = renderHook(() => useDSL())
@ -209,17 +306,15 @@ describe('useDSL', () => {
await result.current.exportCheck()
})
await waitFor(() => {
expect(mockEventEmitter.emit).toHaveBeenCalledWith({
type: expect.any(String),
payload: {
data: secretVars,
},
})
expect(mockEmit).toHaveBeenCalledWith({
type: 'DSL_EXPORT_CHECK',
payload: {
data: [{ id: '1', value_type: 'secret', value: 'secret-value' }],
},
})
})
it('should handle export check error', async () => {
it('should show error notification on check failure', async () => {
mockFetchWorkflowDraft.mockRejectedValue(new Error('Fetch failed'))
const { result } = renderHook(() => useDSL())
@ -228,12 +323,68 @@ describe('useDSL', () => {
await result.current.exportCheck()
})
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'exportFailed',
})
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'exportFailed',
})
})
it('should filter only secret environment variables', async () => {
mockFetchWorkflowDraft.mockResolvedValue({
environment_variables: [
{ id: '1', value_type: 'string', value: 'plain' },
{ id: '2', value_type: 'secret', value: 'secret1' },
{ id: '3', value_type: 'number', value: '123' },
{ id: '4', value_type: 'secret', value: 'secret2' },
],
})
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.exportCheck()
})
expect(mockEmit).toHaveBeenCalledWith({
type: 'DSL_EXPORT_CHECK',
payload: {
data: [
{ id: '2', value_type: 'secret', value: 'secret1' },
{ id: '4', value_type: 'secret', value: 'secret2' },
],
},
})
})
it('should handle empty environment variables', async () => {
mockFetchWorkflowDraft.mockResolvedValue({
environment_variables: [],
})
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.exportCheck()
})
// Should directly call handleExportDSL since no secrets
expect(mockEmit).not.toHaveBeenCalled()
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
})
it('should handle undefined environment variables', async () => {
mockFetchWorkflowDraft.mockResolvedValue({
environment_variables: undefined,
})
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.exportCheck()
})
// Should directly call handleExportDSL since no secrets
expect(mockEmit).not.toHaveBeenCalled()
})
})
})

View File

@ -11,7 +11,12 @@ vi.mock('@/app/components/base/modal', () => ({
onClose,
children,
closable,
}: any) {
}: {
isShow: boolean
onClose?: () => void
children?: React.ReactNode
closable?: boolean
}) {
if (!isShow)
return null
@ -39,7 +44,10 @@ vi.mock('./start-node-selection-panel', () => ({
default: function MockStartNodeSelectionPanel({
onSelectUserInput,
onSelectTrigger,
}: any) {
}: {
onSelectUserInput?: () => void
onSelectTrigger?: (type: BlockEnum, config?: Record<string, unknown>) => void
}) {
return (
<div data-testid="start-node-selection-panel">
<button data-testid="select-user-input" onClick={onSelectUserInput}>
@ -47,13 +55,13 @@ vi.mock('./start-node-selection-panel', () => ({
</button>
<button
data-testid="select-trigger-schedule"
onClick={() => onSelectTrigger(BlockEnum.TriggerSchedule)}
onClick={() => onSelectTrigger?.(BlockEnum.TriggerSchedule)}
>
Select Trigger Schedule
</button>
<button
data-testid="select-trigger-webhook"
onClick={() => onSelectTrigger(BlockEnum.TriggerWebhook, { config: 'test' })}
onClick={() => onSelectTrigger?.(BlockEnum.TriggerWebhook, { config: 'test' })}
>
Select Trigger Webhook
</button>
@ -549,11 +557,10 @@ describe('WorkflowOnboardingModal', () => {
// Arrange & Act
renderComponent({ isShow: true })
// Assert
// Assert - ShortcutsName component renders keys in div elements with system-kbd class
const escKey = screen.getByText('workflow.onboarding.escTip.key')
// ShortcutsName component renders a div with system-kbd class, not a kbd element
expect(escKey.closest('.system-kbd')).toBeInTheDocument()
expect(escKey.closest('.system-kbd')).toHaveClass('system-kbd')
expect(escKey).toBeInTheDocument()
expect(escKey).toHaveClass('system-kbd')
})
it('should have descriptive text for ESC functionality', () => {

View File

@ -182,11 +182,6 @@
"count": 1
}
},
"app/components/app/annotation/add-annotation-modal/edit-item/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/app/annotation/batch-add-annotation-modal/csv-downloader.spec.tsx": {
"ts/no-explicit-any": {
"count": 2
@ -196,9 +191,6 @@
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
},
"react-refresh/only-export-components": {
"count": 1
},
"ts/no-explicit-any": {
"count": 2
}
@ -206,9 +198,6 @@
"app/components/app/annotation/edit-annotation-modal/edit-item/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
},
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/app/annotation/edit-annotation-modal/index.spec.tsx": {
@ -265,11 +254,6 @@
"count": 6
}
},
"app/components/app/configuration/base/var-highlight/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/app/configuration/config-prompt/advanced-prompt-input.tsx": {
"ts/no-explicit-any": {
"count": 2
@ -440,11 +424,6 @@
"count": 6
}
},
"app/components/app/configuration/debug/debug-with-multiple-model/context.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/app/configuration/debug/debug-with-multiple-model/index.spec.tsx": {
"ts/no-explicit-any": {
"count": 5
@ -527,16 +506,6 @@
"count": 1
}
},
"app/components/app/create-app-dialog/app-list/sidebar.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/app/create-app-modal/index.spec.tsx": {
"ts/no-explicit-any": {
"count": 7
}
},
"app/components/app/create-app-modal/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
@ -548,14 +517,6 @@
"app/components/app/create-from-dsl-modal/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 2
},
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/app/log/filter.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/app/log/index.tsx": {
@ -624,9 +585,6 @@
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 3
},
"react-refresh/only-export-components": {
"count": 1
},
"ts/no-explicit-any": {
"count": 4
}
@ -636,11 +594,6 @@
"count": 2
}
},
"app/components/app/workflow-log/filter.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/app/workflow-log/list.spec.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -692,11 +645,6 @@
"count": 1
}
},
"app/components/base/action-button/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/base/agent-log-modal/detail.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -725,11 +673,6 @@
"count": 2
}
},
"app/components/base/amplitude/AmplitudeProvider.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/base/amplitude/utils.ts": {
"ts/no-explicit-any": {
"count": 2
@ -778,9 +721,6 @@
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
},
"react-refresh/only-export-components": {
"count": 1
},
"react/no-nested-component-definitions": {
"count": 1
}
@ -790,21 +730,11 @@
"count": 1
}
},
"app/components/base/button/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/base/button/sync-button.stories.tsx": {
"no-console": {
"count": 1
}
},
"app/components/base/carousel/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/base/chat/chat-with-history/chat-wrapper.tsx": {
"ts/no-explicit-any": {
"count": 7
@ -892,11 +822,6 @@
"count": 1
}
},
"app/components/base/chat/chat/context.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/base/chat/chat/hooks.ts": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 2
@ -1000,18 +925,10 @@
}
},
"app/components/base/error-boundary/index.tsx": {
"react-refresh/only-export-components": {
"count": 3
},
"ts/no-explicit-any": {
"count": 2
}
},
"app/components/base/features/context.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/base/features/new-feature-panel/annotation-reply/index.tsx": {
"ts/no-explicit-any": {
"count": 3
@ -1077,11 +994,6 @@
"count": 3
}
},
"app/components/base/file-uploader/store.tsx": {
"react-refresh/only-export-components": {
"count": 4
}
},
"app/components/base/file-uploader/utils.spec.ts": {
"test/no-identical-title": {
"count": 1
@ -1178,11 +1090,6 @@
"count": 2
}
},
"app/components/base/ga/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/base/icons/utils.ts": {
"ts/no-explicit-any": {
"count": 3
@ -1234,16 +1141,6 @@
"count": 1
}
},
"app/components/base/input/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/base/logo/dify-logo.tsx": {
"react-refresh/only-export-components": {
"count": 2
}
},
"app/components/base/markdown-blocks/audio-block.tsx": {
"ts/no-explicit-any": {
"count": 5
@ -1394,11 +1291,6 @@
"count": 1
}
},
"app/components/base/node-status/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/base/notion-connector/index.stories.tsx": {
"no-console": {
"count": 1
@ -1430,9 +1322,6 @@
}
},
"app/components/base/portal-to-follow-elem/index.tsx": {
"react-refresh/only-export-components": {
"count": 2
},
"ts/no-explicit-any": {
"count": 1
}
@ -1614,16 +1503,6 @@
"count": 1
}
},
"app/components/base/textarea/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/base/toast/index.tsx": {
"react-refresh/only-export-components": {
"count": 2
}
},
"app/components/base/video-gallery/VideoPlayer.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
@ -1667,16 +1546,6 @@
"count": 2
}
},
"app/components/billing/pricing/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/billing/pricing/plan-switcher/plan-range-switcher.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/billing/pricing/plans/cloud-plan-item/index.spec.tsx": {
"test/prefer-hooks-in-order": {
"count": 1
@ -1727,9 +1596,9 @@
"count": 3
}
},
"app/components/datasets/common/image-uploader/store.tsx": {
"react-refresh/only-export-components": {
"count": 4
"app/components/datasets/common/image-uploader/utils.ts": {
"ts/no-explicit-any": {
"count": 2
}
},
"app/components/datasets/common/retrieval-method-config/index.spec.tsx": {
@ -1737,16 +1606,16 @@
"count": 1
}
},
"app/components/datasets/common/retrieval-method-info/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/datasets/create/file-preview/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
}
},
"app/components/datasets/create/file-uploader/index.tsx": {
"ts/no-explicit-any": {
"count": 3
}
},
"app/components/datasets/create/index.spec.tsx": {
"ts/no-explicit-any": {
"count": 16
@ -1767,11 +1636,6 @@
"count": 3
}
},
"app/components/datasets/create/step-two/preview-item/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/datasets/create/stop-embedding-modal/index.spec.tsx": {
"test/prefer-hooks-in-order": {
"count": 1
@ -1825,17 +1689,6 @@
"count": 1
}
},
"app/components/datasets/documents/components/list.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 2
},
"react-refresh/only-export-components": {
"count": 1
},
"ts/no-explicit-any": {
"count": 2
}
},
"app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/index.spec.tsx": {
"ts/no-explicit-any": {
"count": 4
@ -1896,11 +1749,6 @@
"count": 2
}
},
"app/components/datasets/documents/create-from-pipeline/data-source/store/provider.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/datasets/documents/create-from-pipeline/data-source/store/slices/online-drive.ts": {
"ts/no-explicit-any": {
"count": 4
@ -1946,11 +1794,6 @@
"count": 1
}
},
"app/components/datasets/documents/detail/completed/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/datasets/documents/detail/completed/new-child-segment.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -1974,11 +1817,6 @@
"count": 1
}
},
"app/components/datasets/documents/detail/segment-add/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/datasets/documents/detail/settings/pipeline-settings/index.tsx": {
"ts/no-explicit-any": {
"count": 6
@ -2054,6 +1892,14 @@
"count": 1
}
},
"app/components/datasets/settings/form/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 2
},
"ts/no-explicit-any": {
"count": 1
}
},
"app/components/datasets/settings/permission-selector/index.tsx": {
"react/no-missing-key": {
"count": 1
@ -2111,11 +1957,6 @@
"count": 1
}
},
"app/components/explore/try-app/tab.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/goto-anything/actions/commands/command-bus.ts": {
"ts/no-explicit-any": {
"count": 2
@ -2127,9 +1968,6 @@
}
},
"app/components/goto-anything/actions/commands/slash.tsx": {
"react-refresh/only-export-components": {
"count": 3
},
"ts/no-explicit-any": {
"count": 1
}
@ -2147,9 +1985,6 @@
"app/components/goto-anything/context.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 4
},
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/goto-anything/index.spec.tsx": {
@ -2346,11 +2181,6 @@
"count": 4
}
},
"app/components/plugins/install-plugin/install-bundle/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/plugins/install-plugin/install-bundle/item/github-item.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
@ -2427,11 +2257,6 @@
"count": 2
}
},
"app/components/plugins/plugin-auth/index.tsx": {
"react-refresh/only-export-components": {
"count": 3
}
},
"app/components/plugins/plugin-auth/plugin-auth-in-agent.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -2516,9 +2341,6 @@
}
},
"app/components/plugins/plugin-detail-panel/subscription-list/create/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
@ -2559,9 +2381,6 @@
}
},
"app/components/plugins/plugin-page/context.tsx": {
"react-refresh/only-export-components": {
"count": 2
},
"ts/no-explicit-any": {
"count": 1
}
@ -2866,11 +2685,6 @@
"count": 2
}
},
"app/components/workflow-app/components/workflow-onboarding-modal/index.spec.tsx": {
"ts/no-explicit-any": {
"count": 2
}
},
"app/components/workflow-app/components/workflow-onboarding-modal/start-node-selection-panel.spec.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -2926,11 +2740,6 @@
"count": 1
}
},
"app/components/workflow/block-selector/constants.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/workflow/block-selector/featured-tools.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 2
@ -2952,11 +2761,6 @@
"count": 1
}
},
"app/components/workflow/block-selector/index-bar.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/workflow/block-selector/market-place-plugin/action.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
@ -2995,26 +2799,11 @@
"count": 1
}
},
"app/components/workflow/block-selector/view-type-select.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/workflow/candidate-node-main.tsx": {
"ts/no-explicit-any": {
"count": 2
}
},
"app/components/workflow/context.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/workflow/datasets-detail-store/provider.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/workflow/header/run-mode.tsx": {
"no-console": {
"count": 1
@ -3023,21 +2812,11 @@
"count": 1
}
},
"app/components/workflow/header/test-run-menu.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/workflow/header/view-workflow-history.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"app/components/workflow/hooks-store/provider.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/workflow/hooks-store/store.ts": {
"ts/no-explicit-any": {
"count": 6
@ -3158,18 +2937,10 @@
}
},
"app/components/workflow/nodes/_base/components/editor/code-editor/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
},
"ts/no-explicit-any": {
"count": 6
}
},
"app/components/workflow/nodes/_base/components/entry-node-container.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/workflow/nodes/_base/components/error-handle/default-value.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -3195,16 +2966,6 @@
"count": 1
}
},
"app/components/workflow/nodes/_base/components/layout/index.tsx": {
"react-refresh/only-export-components": {
"count": 7
}
},
"app/components/workflow/nodes/_base/components/mcp-tool-availability.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/workflow/nodes/_base/components/memory-config.tsx": {
"unicorn/prefer-number-properties": {
"count": 1
@ -3288,9 +3049,6 @@
}
},
"app/components/workflow/nodes/_base/components/workflow-panel/tab.tsx": {
"react-refresh/only-export-components": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
@ -3344,9 +3102,6 @@
}
},
"app/components/workflow/nodes/agent/panel.tsx": {
"react-refresh/only-export-components": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
@ -3522,9 +3277,6 @@
}
},
"app/components/workflow/nodes/human-input/components/variable-in-markdown.tsx": {
"react-refresh/only-export-components": {
"count": 2
},
"ts/no-explicit-any": {
"count": 8
}
@ -3654,11 +3406,6 @@
"count": 2
}
},
"app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/context.tsx": {
"react-refresh/only-export-components": {
"count": 3
}
},
"app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/auto-width-input.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
@ -3956,11 +3703,6 @@
"count": 1
}
},
"app/components/workflow/note-node/note-editor/toolbar/color-picker.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/workflow/note-node/note-editor/utils.ts": {
"regexp/no-useless-quantifier": {
"count": 1
@ -3997,9 +3739,6 @@
}
},
"app/components/workflow/panel/chat-variable-panel/components/object-value-item.tsx": {
"react-refresh/only-export-components": {
"count": 1
},
"ts/no-explicit-any": {
"count": 5
},
@ -4286,11 +4025,6 @@
"count": 8
}
},
"app/components/workflow/workflow-history-store.tsx": {
"react-refresh/only-export-components": {
"count": 2
}
},
"app/components/workflow/workflow-preview/components/nodes/constants.ts": {
"ts/no-explicit-any": {
"count": 1
@ -4352,79 +4086,30 @@
}
},
"context/app-context.tsx": {
"react-refresh/only-export-components": {
"count": 2
},
"ts/no-explicit-any": {
"count": 1
}
},
"context/datasets-context.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"context/event-emitter.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"context/external-api-panel-context.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"context/external-knowledge-api-context.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"context/global-public-context.tsx": {
"react-refresh/only-export-components": {
"count": 4
}
},
"context/hooks/use-trigger-events-limit-modal.ts": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 3
}
},
"context/mitt-context.tsx": {
"react-refresh/only-export-components": {
"count": 3
}
},
"context/modal-context.test.tsx": {
"ts/no-explicit-any": {
"count": 3
}
},
"context/modal-context.tsx": {
"react-refresh/only-export-components": {
"count": 2
},
"ts/no-explicit-any": {
"count": 5
}
},
"context/provider-context.tsx": {
"react-refresh/only-export-components": {
"count": 3
},
"ts/no-explicit-any": {
"count": 1
}
},
"context/web-app-context.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"context/workspace-context.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"hooks/use-async-window-open.spec.ts": {
"ts/no-explicit-any": {
"count": 6
@ -4461,9 +4146,6 @@
"hooks/use-pay.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 4
},
"react-refresh/only-export-components": {
"count": 3
}
},
"i18n-config/README.md": {

View File

@ -148,17 +148,3 @@ export const formatNumberAbbreviated = (num: number) => {
export const formatToLocalTime = (time: Dayjs, local: Locale, format: string) => {
return time.locale(localeMap[local] ?? 'en').format(format)
}
/**
* Get file extension from file name.
* @param fileName file name
* @example getFileExtension('document.pdf') will return 'pdf'
* @example getFileExtension('archive.tar.gz') will return 'gz'
*/
export const getFileExtension = (fileName: string): string => {
if (!fileName)
return ''
const arr = fileName.split('.')
return arr.length > 1 ? arr[arr.length - 1].toLowerCase() : ''
}