mirror of
https://github.com/langgenius/dify.git
synced 2026-01-30 03:54:26 +00:00
Compare commits
8 Commits
main
...
refactor/d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bd5f059f2 | ||
|
|
c5008e15e2 | ||
|
|
874bc22632 | ||
|
|
1ab9e0b8a3 | ||
|
|
f88afd72ab | ||
|
|
062cefae82 | ||
|
|
c2191252fa | ||
|
|
94320139e7 |
@@ -216,13 +216,22 @@ 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 = {
|
||||
const mockEntry = createMockEntry({
|
||||
isFile: true,
|
||||
isDirectory: false,
|
||||
file: (callback: FileCallback) => callback(mockFile),
|
||||
}
|
||||
})
|
||||
|
||||
const result = await traverseFileEntry(mockEntry)
|
||||
expect(result).toHaveLength(1)
|
||||
@@ -232,11 +241,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 = {
|
||||
const mockEntry = createMockEntry({
|
||||
isFile: true,
|
||||
isDirectory: false,
|
||||
file: (callback: FileCallback) => callback(mockFile),
|
||||
}
|
||||
})
|
||||
|
||||
const result = await traverseFileEntry(mockEntry, 'folder/')
|
||||
expect(result).toHaveLength(1)
|
||||
@@ -244,24 +253,24 @@ describe('image-uploader utils', () => {
|
||||
})
|
||||
|
||||
it('should resolve empty array for unknown entry type', async () => {
|
||||
const mockEntry = {
|
||||
const mockEntry = createMockEntry({
|
||||
isFile: false,
|
||||
isDirectory: false,
|
||||
}
|
||||
})
|
||||
|
||||
const result = await traverseFileEntry(mockEntry)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle directory with no files', async () => {
|
||||
const mockEntry = {
|
||||
const mockEntry = createMockEntry({
|
||||
isFile: false,
|
||||
isDirectory: true,
|
||||
name: 'empty-folder',
|
||||
createReader: () => ({
|
||||
readEntries: (callback: EntriesCallback) => callback([]),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const result = await traverseFileEntry(mockEntry)
|
||||
expect(result).toEqual([])
|
||||
@@ -271,20 +280,20 @@ describe('image-uploader utils', () => {
|
||||
const mockFile1: MockFile = { name: 'file1.png' }
|
||||
const mockFile2: MockFile = { name: 'file2.png' }
|
||||
|
||||
const mockFileEntry1 = {
|
||||
const mockFileEntry1 = createMockEntry({
|
||||
isFile: true,
|
||||
isDirectory: false,
|
||||
file: (callback: FileCallback) => callback(mockFile1),
|
||||
}
|
||||
})
|
||||
|
||||
const mockFileEntry2 = {
|
||||
const mockFileEntry2 = createMockEntry({
|
||||
isFile: true,
|
||||
isDirectory: false,
|
||||
file: (callback: FileCallback) => callback(mockFile2),
|
||||
}
|
||||
})
|
||||
|
||||
let readCount = 0
|
||||
const mockEntry = {
|
||||
const mockEntry = createMockEntry({
|
||||
isFile: false,
|
||||
isDirectory: true,
|
||||
name: 'folder',
|
||||
@@ -292,14 +301,14 @@ describe('image-uploader utils', () => {
|
||||
readEntries: (callback: EntriesCallback) => {
|
||||
if (readCount === 0) {
|
||||
readCount++
|
||||
callback([mockFileEntry1, mockFileEntry2] as unknown as FileSystemEntry[])
|
||||
callback([mockFileEntry1, mockFileEntry2])
|
||||
}
|
||||
else {
|
||||
callback([])
|
||||
}
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const result = await traverseFileEntry(mockEntry)
|
||||
expect(result).toHaveLength(2)
|
||||
|
||||
@@ -18,17 +18,17 @@ type FileWithPath = {
|
||||
relativePath?: string
|
||||
} & File
|
||||
|
||||
export const traverseFileEntry = (entry: any, prefix = ''): Promise<FileWithPath[]> => {
|
||||
export const traverseFileEntry = (entry: FileSystemEntry, prefix = ''): Promise<FileWithPath[]> => {
|
||||
return new Promise((resolve) => {
|
||||
if (entry.isFile) {
|
||||
entry.file((file: FileWithPath) => {
|
||||
(entry as FileSystemFileEntry).file((file: FileWithPath) => {
|
||||
file.relativePath = `${prefix}${file.name}`
|
||||
resolve([file])
|
||||
})
|
||||
}
|
||||
else if (entry.isDirectory) {
|
||||
const reader = entry.createReader()
|
||||
const entries: any[] = []
|
||||
const reader = (entry as FileSystemDirectoryEntry).createReader()
|
||||
const entries: FileSystemEntry[] = []
|
||||
const read = () => {
|
||||
reader.readEntries(async (results: FileSystemEntry[]) => {
|
||||
if (!results.length) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,218 @@
|
||||
'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,24 +1,18 @@
|
||||
'use client'
|
||||
import { useDebounceFn, useKeyPress } from 'ahooks'
|
||||
import { 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 { 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 DSLConfirmModal from './dsl-confirm-modal'
|
||||
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
|
||||
@@ -27,11 +21,6 @@ type CreateFromDSLModalProps = {
|
||||
dslUrl?: string
|
||||
}
|
||||
|
||||
export enum CreateFromDSLModalTab {
|
||||
FROM_FILE = 'from-file',
|
||||
FROM_URL = 'from-url',
|
||||
}
|
||||
|
||||
const CreateFromDSLModal = ({
|
||||
show,
|
||||
onSuccess,
|
||||
@@ -39,149 +28,33 @@ 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 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 && !showErrorModal)
|
||||
onClose()
|
||||
const {
|
||||
currentFile,
|
||||
currentTab,
|
||||
dslUrlValue,
|
||||
showConfirmModal,
|
||||
versions,
|
||||
buttonDisabled,
|
||||
isConfirming,
|
||||
setCurrentTab,
|
||||
setDslUrlValue,
|
||||
handleFile,
|
||||
handleCreateApp,
|
||||
onDSLConfirm,
|
||||
handleCancelConfirm,
|
||||
} = useDSLImport({
|
||||
activeTab,
|
||||
dslUrl,
|
||||
onSuccess,
|
||||
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])
|
||||
useKeyPress('esc', () => {
|
||||
if (show && !showConfirmModal)
|
||||
onClose()
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -196,29 +69,25 @@ 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}
|
||||
/>
|
||||
)}
|
||||
{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}>
|
||||
@@ -234,32 +103,14 @@ const CreateFromDSLModal = ({
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
<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>
|
||||
{showConfirmModal && (
|
||||
<DSLConfirmModal
|
||||
versions={versions}
|
||||
onCancel={handleCancelConfirm}
|
||||
onConfirm={onDSLConfirm}
|
||||
confirmDisabled={isConfirming}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
'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
|
||||
@@ -0,0 +1,210 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
'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
|
||||
@@ -0,0 +1,3 @@
|
||||
export const PROGRESS_NOT_STARTED = -1
|
||||
export const PROGRESS_ERROR = -2
|
||||
export const PROGRESS_COMPLETE = 100
|
||||
@@ -0,0 +1,921 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,351 @@
|
||||
'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,
|
||||
}
|
||||
}
|
||||
278
web/app/components/datasets/create/file-uploader/index.spec.tsx
Normal file
278
web/app/components/datasets/create/file-uploader/index.spec.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
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,23 +1,10 @@
|
||||
'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 DocumentFileIcon from '../../common/document-file-icon'
|
||||
import FileListItem from './components/file-list-item'
|
||||
import UploadDropzone from './components/upload-dropzone'
|
||||
import { useFileUpload } from './hooks/use-file-upload'
|
||||
|
||||
type IFileUploaderProps = {
|
||||
fileList: FileItem[]
|
||||
@@ -39,358 +26,62 @@ 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 { 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])
|
||||
const {
|
||||
dropRef,
|
||||
dragRef,
|
||||
fileUploaderRef,
|
||||
dragging,
|
||||
fileUploadConfig,
|
||||
acceptTypes,
|
||||
supportTypesShowNames,
|
||||
hideUpload,
|
||||
selectHandle,
|
||||
fileChangeHandle,
|
||||
removeFile,
|
||||
handlePreview,
|
||||
} = useFileUpload({
|
||||
fileList,
|
||||
prepareFileList,
|
||||
onFileUpdate,
|
||||
onFileListUpdate,
|
||||
onPreview,
|
||||
supportBatchUpload,
|
||||
})
|
||||
|
||||
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 && (
|
||||
<input
|
||||
ref={fileUploader}
|
||||
id="fileUploader"
|
||||
className="hidden"
|
||||
type="file"
|
||||
multiple={supportBatchUpload}
|
||||
accept={ACCEPTS.join(',')}
|
||||
onChange={fileChangeHandle}
|
||||
<UploadDropzone
|
||||
dropRef={dropRef}
|
||||
dragRef={dragRef}
|
||||
fileUploaderRef={fileUploaderRef}
|
||||
dragging={dragging}
|
||||
supportBatchUpload={supportBatchUpload}
|
||||
supportTypesShowNames={supportTypesShowNames}
|
||||
fileUploadConfig={fileUploadConfig}
|
||||
acceptTypes={acceptTypes}
|
||||
onSelectFile={selectHandle}
|
||||
onFileChange={fileChangeHandle}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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" />}
|
||||
{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>
|
||||
)}
|
||||
<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,351 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
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
|
||||
@@ -0,0 +1,231 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
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
|
||||
@@ -0,0 +1,3 @@
|
||||
export const PROGRESS_NOT_STARTED = -1
|
||||
export const PROGRESS_ERROR = -2
|
||||
export const PROGRESS_COMPLETE = 100
|
||||
@@ -0,0 +1,911 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
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,26 +1,7 @@
|
||||
'use client'
|
||||
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 })
|
||||
import FileListItem from './components/file-list-item'
|
||||
import UploadDropzone from './components/upload-dropzone'
|
||||
import { useLocalFileUpload } from './hooks/use-local-file-upload'
|
||||
|
||||
export type LocalFileProps = {
|
||||
allowedExtensions: string[]
|
||||
@@ -31,345 +12,49 @@ const LocalFile = ({
|
||||
allowedExtensions,
|
||||
supportBatchUpload = true,
|
||||
}: LocalFileProps) => {
|
||||
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])
|
||||
const {
|
||||
dropRef,
|
||||
dragRef,
|
||||
fileUploaderRef,
|
||||
dragging,
|
||||
localFileList,
|
||||
fileUploadConfig,
|
||||
acceptTypes,
|
||||
supportTypesShowNames,
|
||||
hideUpload,
|
||||
selectHandle,
|
||||
fileChangeHandle,
|
||||
removeFile,
|
||||
handlePreview,
|
||||
} = useLocalFileUpload({ allowedExtensions, supportBatchUpload })
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{!hideUpload && (
|
||||
<input
|
||||
ref={fileUploader}
|
||||
id="fileUploader"
|
||||
className="hidden"
|
||||
type="file"
|
||||
multiple={supportBatchUpload}
|
||||
accept={ACCEPTS.join(',')}
|
||||
onChange={fileChangeHandle}
|
||||
<UploadDropzone
|
||||
dropRef={dropRef}
|
||||
dragRef={dragRef}
|
||||
fileUploaderRef={fileUploaderRef}
|
||||
dragging={dragging}
|
||||
supportBatchUpload={supportBatchUpload}
|
||||
supportTypesShowNames={supportTypesShowNames}
|
||||
fileUploadConfig={fileUploadConfig}
|
||||
acceptTypes={acceptTypes}
|
||||
onSelectFile={selectHandle}
|
||||
onFileChange={fileChangeHandle}
|
||||
allowedExtensions={allowedExtensions}
|
||||
/>
|
||||
)}
|
||||
{!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) => {
|
||||
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>
|
||||
)
|
||||
})}
|
||||
{localFileList.map((fileItem, index) => (
|
||||
<FileListItem
|
||||
key={`${fileItem.fileID}-${index}`}
|
||||
fileItem={fileItem}
|
||||
onPreview={handlePreview}
|
||||
onRemove={removeFile}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,124 @@
|
||||
'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
|
||||
@@ -0,0 +1,362 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
'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
|
||||
@@ -0,0 +1,501 @@
|
||||
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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,206 @@
|
||||
'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
|
||||
@@ -0,0 +1,763 @@
|
||||
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,
|
||||
},
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,264 @@
|
||||
'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,
|
||||
}
|
||||
}
|
||||
488
web/app/components/datasets/settings/form/index.spec.tsx
Normal file
488
web/app/components/datasets/settings/form/index.spec.tsx
Normal file
@@ -0,0 +1,488 @@
|
||||
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,486 +1,126 @@
|
||||
'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 { 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: '',
|
||||
}
|
||||
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'
|
||||
|
||||
const Form = () => {
|
||||
const { t } = useTranslation()
|
||||
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)
|
||||
const {
|
||||
// Context values
|
||||
currentDataset,
|
||||
isCurrentWorkspaceDatasetOperator,
|
||||
|
||||
const handleOpenAppIconPicker = useCallback(() => {
|
||||
setShowAppIconPicker(true)
|
||||
previousAppIcon.current = iconInfo
|
||||
}, [iconInfo])
|
||||
// Loading state
|
||||
loading,
|
||||
|
||||
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)
|
||||
}, [])
|
||||
// Basic form
|
||||
name,
|
||||
setName,
|
||||
description,
|
||||
setDescription,
|
||||
|
||||
const handleCloseAppIconPicker = useCallback(() => {
|
||||
setIconInfo(previousAppIcon.current)
|
||||
setShowAppIconPicker(false)
|
||||
}, [])
|
||||
// Icon
|
||||
iconInfo,
|
||||
showAppIconPicker,
|
||||
handleOpenAppIconPicker,
|
||||
handleSelectAppIcon,
|
||||
handleCloseAppIconPicker,
|
||||
|
||||
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)
|
||||
}, [])
|
||||
// Permission
|
||||
permission,
|
||||
setPermission,
|
||||
selectedMemberIDs,
|
||||
setSelectedMemberIDs,
|
||||
memberList,
|
||||
|
||||
useEffect(() => {
|
||||
if (!membersData?.accounts)
|
||||
setMemberList([])
|
||||
else
|
||||
setMemberList(membersData.accounts)
|
||||
}, [membersData])
|
||||
// External retrieval
|
||||
topK,
|
||||
scoreThreshold,
|
||||
scoreThresholdEnabled,
|
||||
handleSettingsChange,
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
// Indexing and retrieval
|
||||
indexMethod,
|
||||
setIndexMethod,
|
||||
keywordNumber,
|
||||
setKeywordNumber,
|
||||
retrievalConfig,
|
||||
setRetrievalConfig,
|
||||
embeddingModel,
|
||||
setEmbeddingModel,
|
||||
embeddingModelList,
|
||||
|
||||
const isShowIndexMethod = currentDataset && currentDataset.doc_form !== ChunkingMode.parentChild && currentDataset.indexing_technique && indexMethod
|
||||
// Summary index
|
||||
summaryIndexSetting,
|
||||
handleSummaryIndexSettingChange,
|
||||
|
||||
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])
|
||||
// Computed
|
||||
showMultiModalTip,
|
||||
|
||||
// Actions
|
||||
handleSave,
|
||||
} = useFormState()
|
||||
|
||||
const isExternalProvider = currentDataset?.provider === 'external'
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-y-4 px-20 py-8 sm:w-[960px]">
|
||||
{/* 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"
|
||||
/>
|
||||
{/* 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}
|
||||
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>
|
||||
</>
|
||||
)
|
||||
|
||||
: 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"
|
||||
<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}
|
||||
/>
|
||||
<div className={rowClass}>
|
||||
<div className={labelClass} />
|
||||
|
||||
{isExternalProvider
|
||||
? (
|
||||
<ExternalKnowledgeSection
|
||||
currentDataset={currentDataset}
|
||||
topK={topK}
|
||||
scoreThreshold={scoreThreshold}
|
||||
scoreThresholdEnabled={scoreThresholdEnabled}
|
||||
handleSettingsChange={handleSettingsChange}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<IndexingSection
|
||||
currentDataset={currentDataset}
|
||||
indexMethod={indexMethod}
|
||||
setIndexMethod={setIndexMethod}
|
||||
keywordNumber={keywordNumber}
|
||||
setKeywordNumber={setKeywordNumber}
|
||||
embeddingModel={embeddingModel}
|
||||
setEmbeddingModel={setEmbeddingModel}
|
||||
embeddingModelList={embeddingModelList}
|
||||
retrievalConfig={retrievalConfig}
|
||||
setRetrievalConfig={setRetrievalConfig}
|
||||
summaryIndexSetting={summaryIndexSetting}
|
||||
handleSummaryIndexSettingChange={handleSummaryIndexSettingChange}
|
||||
showMultiModalTip={showMultiModalTip}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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" />
|
||||
<div className="grow">
|
||||
<Button
|
||||
className="min-w-24"
|
||||
@@ -493,12 +133,6 @@ const Form = () => {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{showAppIconPicker && (
|
||||
<AppIconPicker
|
||||
onSelect={handleSelectAppIcon}
|
||||
onClose={handleCloseAppIconPicker}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,151 +1,95 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { act } from 'react'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// ============================================================================
|
||||
// Import after mocks
|
||||
// ============================================================================
|
||||
|
||||
import { useDSL } from './use-DSL'
|
||||
|
||||
// ============================================================================
|
||||
// Mocks
|
||||
// ============================================================================
|
||||
// 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),
|
||||
}))
|
||||
|
||||
// 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 workflow constants
|
||||
vi.mock('@/app/components/workflow/constants', () => ({
|
||||
DSL_EXPORT_CHECK: 'DSL_EXPORT_CHECK',
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('useDSL', () => {
|
||||
let mockLink: { href: string, download: string, click: ReturnType<typeof vi.fn> }
|
||||
let originalCreateElement: typeof document.createElement
|
||||
let mockCreateObjectURL: ReturnType<typeof vi.spyOn>
|
||||
let mockRevokeObjectURL: ReturnType<typeof vi.spyOn>
|
||||
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>>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Create a proper mock link element
|
||||
mockLink = {
|
||||
href: '',
|
||||
download: '',
|
||||
click: 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
|
||||
|
||||
mockCreateObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:test-url')
|
||||
mockRevokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
|
||||
// Default store state
|
||||
mockWorkflowStoreGetState.mockReturnValue({
|
||||
pipelineId: 'test-pipeline-id',
|
||||
knowledgeName: 'Test Knowledge Base',
|
||||
})
|
||||
|
||||
mockGetState.mockReturnValue({ pipelineId: 'test-pipeline-id', knowledgeName: 'test-knowledge' })
|
||||
mockDoSyncWorkflowDraft.mockResolvedValue(undefined)
|
||||
mockExportPipelineConfig.mockResolvedValue({ data: 'yaml-content' })
|
||||
mockFetchWorkflowDraft.mockResolvedValue({
|
||||
environment_variables: [],
|
||||
})
|
||||
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>()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.createElement = originalCreateElement
|
||||
mockCreateObjectURL.mockRestore()
|
||||
mockRevokeObjectURL.mockRestore()
|
||||
vi.clearAllMocks()
|
||||
// Restore originals
|
||||
URL.createObjectURL = originalCreateObjectURL
|
||||
URL.revokeObjectURL = originalRevokeObjectURL
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('hook initialization', () => {
|
||||
it('should return exportCheck function', () => {
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
expect(result.current.exportCheck).toBeDefined()
|
||||
expect(typeof result.current.exportCheck).toBe('function')
|
||||
// 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)
|
||||
})
|
||||
|
||||
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 not export when pipelineId is missing', async () => {
|
||||
mockWorkflowStoreGetState.mockReturnValue({
|
||||
pipelineId: undefined,
|
||||
knowledgeName: 'Test',
|
||||
})
|
||||
it('should return early when pipelineId is not set', async () => {
|
||||
mockGetState.mockReturnValue({ pipelineId: null, knowledgeName: 'test' })
|
||||
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
@@ -154,65 +98,44 @@ 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()
|
||||
})
|
||||
|
||||
expect(document.createElement).toHaveBeenCalledWith('a')
|
||||
expect(mockCreateObjectURL).toHaveBeenCalled()
|
||||
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:test-url')
|
||||
})
|
||||
|
||||
it('should use correct file extension for download', async () => {
|
||||
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(mockLink.download).toBe('Test Knowledge Base.pipeline')
|
||||
})
|
||||
|
||||
it('should trigger download click', async () => {
|
||||
setupAnchorMock()
|
||||
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExportDSL()
|
||||
})
|
||||
|
||||
expect(mockLink.click).toHaveBeenCalled()
|
||||
await waitFor(() => {
|
||||
expect(mockClick).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show error notification on export failure', async () => {
|
||||
it('should handle export error', async () => {
|
||||
mockExportPipelineConfig.mockRejectedValue(new Error('Export failed'))
|
||||
|
||||
const { result } = renderHook(() => useDSL())
|
||||
@@ -221,19 +144,35 @@ describe('useDSL', () => {
|
||||
await result.current.handleExportDSL()
|
||||
})
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'exportFailed',
|
||||
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,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('exportCheck', () => {
|
||||
it('should not check when pipelineId is missing', async () => {
|
||||
mockWorkflowStoreGetState.mockReturnValue({
|
||||
pipelineId: undefined,
|
||||
knowledgeName: 'Test',
|
||||
})
|
||||
it('should return early when pipelineId is not set', async () => {
|
||||
mockGetState.mockReturnValue({ pipelineId: null })
|
||||
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
@@ -244,22 +183,9 @@ describe('useDSL', () => {
|
||||
expect(mockFetchWorkflowDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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' },
|
||||
],
|
||||
})
|
||||
it('should call handleExportDSL directly when no secret variables', async () => {
|
||||
setupAnchorMock()
|
||||
mockFetchWorkflowDraft.mockResolvedValue({ environment_variables: [] })
|
||||
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
@@ -267,16 +193,15 @@ describe('useDSL', () => {
|
||||
await result.current.exportCheck()
|
||||
})
|
||||
|
||||
// Should call doSyncWorkflowDraft (which means handleExportDSL was called)
|
||||
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
|
||||
await waitFor(() => {
|
||||
expect(mockFetchWorkflowDraft).toHaveBeenCalledWith('/rag/pipelines/test-pipeline-id/workflows/draft')
|
||||
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should emit DSL_EXPORT_CHECK event when secret variables exist', async () => {
|
||||
mockFetchWorkflowDraft.mockResolvedValue({
|
||||
environment_variables: [
|
||||
{ id: '1', value_type: 'secret', value: 'secret-value' },
|
||||
],
|
||||
})
|
||||
it('should emit event when secret variables exist', async () => {
|
||||
const secretVars = [{ value_type: 'secret', name: 'API_KEY' }]
|
||||
mockFetchWorkflowDraft.mockResolvedValue({ environment_variables: secretVars })
|
||||
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
@@ -284,15 +209,17 @@ describe('useDSL', () => {
|
||||
await result.current.exportCheck()
|
||||
})
|
||||
|
||||
expect(mockEmit).toHaveBeenCalledWith({
|
||||
type: 'DSL_EXPORT_CHECK',
|
||||
payload: {
|
||||
data: [{ id: '1', value_type: 'secret', value: 'secret-value' }],
|
||||
},
|
||||
await waitFor(() => {
|
||||
expect(mockEventEmitter.emit).toHaveBeenCalledWith({
|
||||
type: expect.any(String),
|
||||
payload: {
|
||||
data: secretVars,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should show error notification on check failure', async () => {
|
||||
it('should handle export check error', async () => {
|
||||
mockFetchWorkflowDraft.mockRejectedValue(new Error('Fetch failed'))
|
||||
|
||||
const { result } = renderHook(() => useDSL())
|
||||
@@ -301,68 +228,12 @@ describe('useDSL', () => {
|
||||
await result.current.exportCheck()
|
||||
})
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'exportFailed',
|
||||
await waitFor(() => {
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1576,11 +1576,6 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"app/components/datasets/common/image-uploader/utils.ts": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/components/datasets/common/retrieval-method-config/index.spec.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
@@ -1591,11 +1586,6 @@
|
||||
"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
|
||||
@@ -1880,14 +1870,6 @@
|
||||
"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
|
||||
|
||||
@@ -148,3 +148,17 @@ 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