mirror of
https://github.com/langgenius/dify.git
synced 2026-02-01 01:17:00 +08:00
Compare commits
9 Commits
refactor/d
...
refactor/d
| Author | SHA1 | Date | |
|---|---|---|---|
| d6b3a6d659 | |||
| 6fddf4fe14 | |||
| d3e6b73c81 | |||
| 9455d0500e | |||
| a90c056121 | |||
| 932a37d734 | |||
| a25e4c1b3a | |||
| ec7d80dd1a | |||
| aecae4f671 |
@ -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' })
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -1,3 +0,0 @@
|
||||
export const PROGRESS_NOT_STARTED = -1
|
||||
export const PROGRESS_ERROR = -2
|
||||
export const PROGRESS_COMPLETE = 100
|
||||
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -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'
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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'
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,3 @@
|
||||
// Re-export from parent for backwards compatibility
|
||||
export { default } from '../list'
|
||||
export { renderTdValue } from './components'
|
||||
@ -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 }
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -1,3 +0,0 @@
|
||||
export const PROGRESS_NOT_STARTED = -1
|
||||
export const PROGRESS_ERROR = -2
|
||||
export const PROGRESS_COMPLETE = 100
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
|
||||
@ -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'
|
||||
@ -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%' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -0,0 +1,10 @@
|
||||
export {
|
||||
calculatePercent,
|
||||
isEmbeddingStatus,
|
||||
isTerminalStatus,
|
||||
useEmbeddingStatus,
|
||||
useInvalidateEmbeddingStatus,
|
||||
usePauseIndexing,
|
||||
useResumeIndexing,
|
||||
} from './use-embedding-status'
|
||||
export type { EmbeddingStatusType } from './use-embedding-status'
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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])
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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 />
|
||||
</>
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -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,
|
||||
},
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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() : ''
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user