feat(local-file-upload): implement file upload functionality with components and tests

- Add LocalFile component for handling file uploads with support for multiple file types
- Introduce UploadDropzone and FileListItem components for improved UI and file management
- Implement useLocalFileUpload hook for managing file upload logic and state
- Create constants for progress states and integrate error handling
- Add comprehensive tests for all new components and hooks to ensure functionality and reliability
This commit is contained in:
CodingOnStar
2026-01-29 14:54:13 +08:00
parent 94320139e7
commit c2191252fa
10 changed files with 2366 additions and 353 deletions

View File

@ -14,6 +14,13 @@ export const getFileType = (currentFile: File) => {
return arr[arr.length - 1]
}
export const getFileSize = (size: number): string => {
if (size / 1024 < 10)
return `${(size / 1024).toFixed(2)}KB`
return `${(size / 1024 / 1024).toFixed(2)}MB`
}
type FileWithPath = {
relativePath?: string
} & File

View File

@ -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) is >= 10KB threshold so displays in MB
expect(screen.getByText('0.10MB')).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.00KB')).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.00MB')).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('0.01MB')).toBeInTheDocument()
})
})
describe('upload progress values', () => {
it('should show chart at progress 1', () => {
const fileItem = createMockFileItem({ progress: 1 })
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
expect(screen.getByTestId('pie-chart')).toBeInTheDocument()
})
it('should show chart at progress 99', () => {
const fileItem = createMockFileItem({ progress: 99 })
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
expect(screen.getByTestId('pie-chart')).toHaveAttribute('data-percentage', '99')
})
it('should not show chart at progress 100', () => {
const fileItem = createMockFileItem({ progress: 100 })
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
expect(screen.queryByTestId('pie-chart')).not.toBeInTheDocument()
})
})
describe('styling', () => {
it('should have proper shadow styling', () => {
const { container } = render(<FileListItem {...defaultProps} />)
const item = container.firstChild as HTMLElement
expect(item).toHaveClass('shadow-xs')
})
it('should have proper border styling', () => {
const { container } = render(<FileListItem {...defaultProps} />)
const item = container.firstChild as HTMLElement
expect(item).toHaveClass('border', 'border-components-panel-border')
})
it('should truncate long file names', () => {
const longFileName = 'this-is-a-very-long-file-name-that-should-be-truncated.pdf'
const fileItem = createMockFileItem({
file: createMockFile({ name: longFileName }),
})
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
const nameElement = screen.getByText(longFileName)
expect(nameElement).toHaveClass('truncate')
})
})
})

View File

@ -0,0 +1,84 @@
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 { getFileSize, 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 { 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>{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={handleRemove}
>
<RiDeleteBinLine className="size-4 text-text-tertiary" />
</span>
</div>
</div>
)
}
export default FileListItem

View File

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

View File

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

View File

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

View File

@ -0,0 +1,879 @@
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 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,
},
})),
}))
// 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: { files: File[] } | null }
dropEvent.dataTransfer = { 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: { files: File[] } | null }
dropEvent.dataTransfer = { files }
dropzone.dispatchEvent(dropEvent)
})
await waitFor(() => {
expect(mockSetLocalFileList).toHaveBeenCalled()
// Should only have 1 file (limited by supportBatchUpload: false)
const callArgs = mockSetLocalFileList.mock.calls[0][0]
expect(callArgs.length).toBe(1)
})
})
})
describe('file upload limit', () => {
it('should reject files exceeding total file upload limit', async () => {
// Mock store to return existing files
const { useDataSourceStoreWithSelector } = vi.mocked(await import('../../store'))
const existingFiles: FileItem[] = Array.from({ length: 8 }, (_, i) => ({
fileID: `existing-${i}`,
file: { name: `existing-${i}.pdf`, size: 1024 } as CustomFile,
progress: 100,
}))
vi.mocked(useDataSourceStoreWithSelector).mockImplementation(selector =>
selector({ localFileList: existingFiles } as Parameters<typeof selector>[0]),
)
const { result } = renderHook(
() => useLocalFileUpload({ allowedExtensions: ['pdf'] }),
{ wrapper: createWrapper() },
)
// Try to add 5 more files when limit is 10 and we already have 8
const files = Array.from({ length: 5 }, (_, i) =>
new File(['content'], `new-${i}.pdf`, { type: 'application/pdf' }))
const event = {
target: { files },
} as unknown as React.ChangeEvent<HTMLInputElement>
act(() => {
result.current.fileChangeHandle(event)
})
// Should show error about files number limit
expect(mockNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
)
// Reset mock for other tests
vi.mocked(useDataSourceStoreWithSelector).mockImplementation(selector =>
selector({ localFileList: [] as FileItem[] } as Parameters<typeof selector>[0]),
)
})
})
describe('upload progress tracking', () => {
it('should track upload progress', async () => {
let progressCallback: ((e: ProgressEvent) => void) | undefined
mockUpload.mockImplementation(async (options: { onprogress: (e: ProgressEvent) => void }) => {
progressCallback = options.onprogress
return { id: 'uploaded-id' }
})
const { result } = renderHook(
() => useLocalFileUpload({ allowedExtensions: ['pdf'] }),
{ wrapper: createWrapper() },
)
const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' })
const event = {
target: { files: [mockFile] },
} as unknown as React.ChangeEvent<HTMLInputElement>
act(() => {
result.current.fileChangeHandle(event)
})
await waitFor(() => {
expect(mockUpload).toHaveBeenCalled()
})
// Simulate progress event
if (progressCallback) {
act(() => {
progressCallback!({
lengthComputable: true,
loaded: 50,
total: 100,
} as ProgressEvent)
})
expect(mockSetLocalFileList).toHaveBeenCalled()
}
})
it('should not update progress when not lengthComputable', async () => {
let progressCallback: ((e: ProgressEvent) => void) | undefined
const uploadCallCount = { value: 0 }
mockUpload.mockImplementation(async (options: { onprogress: (e: ProgressEvent) => void }) => {
progressCallback = options.onprogress
uploadCallCount.value++
return { id: 'uploaded-id' }
})
const { result } = renderHook(
() => useLocalFileUpload({ allowedExtensions: ['pdf'] }),
{ wrapper: createWrapper() },
)
const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' })
const event = {
target: { files: [mockFile] },
} as unknown as React.ChangeEvent<HTMLInputElement>
mockSetLocalFileList.mockClear()
act(() => {
result.current.fileChangeHandle(event)
})
await waitFor(() => {
expect(mockUpload).toHaveBeenCalled()
})
const callsBeforeProgress = mockSetLocalFileList.mock.calls.length
// Simulate progress event without lengthComputable
if (progressCallback) {
act(() => {
progressCallback!({
lengthComputable: false,
loaded: 50,
total: 100,
} as ProgressEvent)
})
// Should not have additional calls
expect(mockSetLocalFileList.mock.calls.length).toBe(callsBeforeProgress)
}
})
})
describe('file progress constants', () => {
it('should use PROGRESS_NOT_STARTED for new files', async () => {
mockUpload.mockResolvedValue({ id: 'file-id' })
const { result } = renderHook(
() => useLocalFileUpload({ allowedExtensions: ['pdf'] }),
{ wrapper: createWrapper() },
)
const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' })
const event = {
target: {
files: [mockFile],
},
} as unknown as React.ChangeEvent<HTMLInputElement>
act(() => {
result.current.fileChangeHandle(event)
})
await waitFor(() => {
const callArgs = mockSetLocalFileList.mock.calls[0][0]
expect(callArgs[0].progress).toBe(PROGRESS_NOT_STARTED)
})
})
it('should set PROGRESS_ERROR on upload failure', async () => {
mockUpload.mockRejectedValue(new Error('Upload failed'))
const { result } = renderHook(
() => useLocalFileUpload({ allowedExtensions: ['pdf'] }),
{ wrapper: createWrapper() },
)
const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' })
const event = {
target: {
files: [mockFile],
},
} as unknown as React.ChangeEvent<HTMLInputElement>
act(() => {
result.current.fileChangeHandle(event)
})
await waitFor(() => {
const calls = mockSetLocalFileList.mock.calls
const lastCall = calls[calls.length - 1][0]
expect(lastCall.some((f: FileItem) => f.progress === PROGRESS_ERROR)).toBe(true)
})
})
})
})

View File

@ -0,0 +1,292 @@
import type { CustomFile as File, FileItem } from '@/models/datasets'
import { produce } from 'immer'
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 { useFileUploadConfig } from '@/service/use-common'
import { useDataSourceStore, useDataSourceStoreWithSelector } from '../../store'
import { PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../constants'
export type UseLocalFileUploadOptions = {
allowedExtensions: string[]
supportBatchUpload?: boolean
}
type FileUploadConfig = {
file_size_limit: number
batch_count_limit: number
file_upload_limit: number
}
export const useLocalFileUpload = ({
allowedExtensions,
supportBatchUpload = true,
}: UseLocalFileUploadOptions) => {
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 fileUploaderRef = 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(item => item.toLowerCase())
.filter((item, index, self) => self.indexOf(item) === index)
.map(item => item.toUpperCase())
.join(locale !== LanguagesSupported[1] ? ', ' : '、 ')
}, [locale, allowedExtensions])
const acceptTypes = useMemo(
() => allowedExtensions.map((ext: string) => `.${ext}`),
[allowedExtensions],
)
const fileUploadConfig: 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])
const getFileType = (currentFile: File) => {
if (!currentFile)
return ''
const arr = currentFile.name.split('.')
return arr[arr.length - 1]
}
const isValid = useCallback((file: File) => {
const { size } = file
const ext = `.${getFileType(file)}`
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
}, [notify, t, acceptTypes, 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: PROGRESS_NOT_STARTED,
}
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, PROGRESS_ERROR, fileListRef.current)
return Promise.resolve({ ...fileItem })
})
}, [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: PROGRESS_NOT_STARTED,
}))
const newFiles = [...fileListRef.current, ...preparedFiles]
updateFileList(newFiles)
fileListRef.current = newFiles
uploadMultipleFiles(preparedFiles)
}, [fileUploadConfig.file_upload_limit, localFileList.length, updateFileList, uploadMultipleFiles, notify, t])
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((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 (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)
updateFileList([...fileListRef.current])
}, [updateFileList])
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])
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)
}
}, [handleDragEnter, handleDragOver, handleDragLeave, handleDrop])
return {
// Refs
dropRef,
dragRef,
fileUploaderRef,
// State
dragging,
localFileList,
// Config
fileUploadConfig,
acceptTypes,
supportTypesShowNames,
hideUpload,
// Handlers
selectHandle,
fileChangeHandle,
removeFile,
handlePreview,
}
}

View File

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

View File

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