diff --git a/web/app/components/datasets/common/image-uploader/utils.ts b/web/app/components/datasets/common/image-uploader/utils.ts
index c2fad83840..2a5b58c375 100644
--- a/web/app/components/datasets/common/image-uploader/utils.ts
+++ b/web/app/components/datasets/common/image-uploader/utils.ts
@@ -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
diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/file-list-item.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/file-list-item.spec.tsx
new file mode 100644
index 0000000000..60e87c4a70
--- /dev/null
+++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/file-list-item.spec.tsx
@@ -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 }) => (
+
+ Pie Chart:
+ {' '}
+ {percentage}
+ %
+
+ )
+ 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 }) => (
+
+ Document Icon
+
+ ),
+}))
+
+describe('FileListItem', () => {
+ const createMockFile = (overrides: Partial = {}): File => ({
+ name: 'test-document.pdf',
+ size: 1024 * 100, // 100KB
+ type: 'application/pdf',
+ lastModified: Date.now(),
+ ...overrides,
+ } as File)
+
+ const createMockFileItem = (overrides: Partial = {}): FileItem => ({
+ fileID: 'file-123',
+ file: createMockFile(overrides.file as Partial),
+ 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()
+
+ 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()
+
+ 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()
+
+ expect(screen.getByText('test-document.pdf')).toBeInTheDocument()
+ })
+
+ it('should render file extension in uppercase via CSS class', () => {
+ render()
+
+ // 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()
+
+ // 100KB (102400 bytes) is >= 10KB threshold so displays in MB
+ expect(screen.getByText('0.10MB')).toBeInTheDocument()
+ })
+
+ it('should render delete button', () => {
+ const { container } = render()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ // 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()
+ // 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()
+
+ 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()
+
+ expect(screen.getByText('5.00MB')).toBeInTheDocument()
+ })
+
+ it('should display size at threshold (10KB)', () => {
+ const fileItem = createMockFileItem({
+ file: createMockFile({ size: 10 * 1024 }), // 10KB
+ })
+ render()
+
+ expect(screen.getByText('0.01MB')).toBeInTheDocument()
+ })
+ })
+
+ describe('upload progress values', () => {
+ it('should show chart at progress 1', () => {
+ const fileItem = createMockFileItem({ progress: 1 })
+ render()
+
+ expect(screen.getByTestId('pie-chart')).toBeInTheDocument()
+ })
+
+ it('should show chart at progress 99', () => {
+ const fileItem = createMockFileItem({ progress: 99 })
+ render()
+
+ expect(screen.getByTestId('pie-chart')).toHaveAttribute('data-percentage', '99')
+ })
+
+ it('should not show chart at progress 100', () => {
+ const fileItem = createMockFileItem({ progress: 100 })
+ render()
+
+ expect(screen.queryByTestId('pie-chart')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('styling', () => {
+ it('should have proper shadow styling', () => {
+ const { container } = render()
+
+ const item = container.firstChild as HTMLElement
+ expect(item).toHaveClass('shadow-xs')
+ })
+
+ it('should have proper border styling', () => {
+ const { container } = render()
+
+ 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()
+
+ const nameElement = screen.getByText(longFileName)
+ expect(nameElement).toHaveClass('truncate')
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/file-list-item.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/file-list-item.tsx
new file mode 100644
index 0000000000..22129fcfe2
--- /dev/null
+++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/file-list-item.tsx
@@ -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 (
+
+
+
+
+
+
+
+ {getFileType(fileItem.file)}
+ ·
+ {getFileSize(fileItem.file.size)}
+
+
+
+ {isUploading && (
+
+ )}
+ {isError && (
+
+ )}
+
+
+
+
+
+ )
+}
+
+export default FileListItem
diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/upload-dropzone.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/upload-dropzone.spec.tsx
new file mode 100644
index 0000000000..21742b731c
--- /dev/null
+++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/upload-dropzone.spec.tsx
@@ -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 = (value: T | null = null): RefObject => ({ current: value })
+
+// Mock react-i18next
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string, options?: { ns?: string }) => {
+ const translations: Record = {
+ '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() as RefObject,
+ dragRef: createMockRef() as RefObject,
+ fileUploaderRef: createMockRef() as RefObject,
+ 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()
+
+ const dropzone = container.querySelector('[class*="border-dashed"]')
+ expect(dropzone).toBeInTheDocument()
+ })
+
+ it('should render hidden file input', () => {
+ render()
+
+ 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()
+
+ const icon = document.querySelector('svg')
+ expect(icon).toBeInTheDocument()
+ })
+
+ it('should render browse label when extensions are allowed', () => {
+ render()
+
+ expect(screen.getByText('Browse')).toBeInTheDocument()
+ })
+
+ it('should not render browse label when no extensions allowed', () => {
+ render()
+
+ expect(screen.queryByText('Browse')).not.toBeInTheDocument()
+ })
+
+ it('should render file size and count limits', () => {
+ render()
+
+ 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()
+
+ const input = document.getElementById('fileUploader') as HTMLInputElement
+ expect(input).toHaveAttribute('multiple')
+ })
+
+ it('should not allow multiple files when supportBatchUpload is false', () => {
+ render()
+
+ const input = document.getElementById('fileUploader') as HTMLInputElement
+ expect(input).not.toHaveAttribute('multiple')
+ })
+
+ it('should set accept attribute with correct types', () => {
+ render()
+
+ 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()
+
+ expect(screen.getByText(/Drag and drop files/i)).toBeInTheDocument()
+ })
+
+ it('should show single file text when supportBatchUpload is false', () => {
+ render()
+
+ expect(screen.getByText(/Drag and drop file/i)).toBeInTheDocument()
+ })
+ })
+
+ describe('dragging state', () => {
+ it('should apply dragging styles when dragging is true', () => {
+ const { container } = render()
+
+ const dropzone = container.querySelector('[class*="border-components-dropzone-border-accent"]')
+ expect(dropzone).toBeInTheDocument()
+ })
+
+ it('should render drag overlay when dragging', () => {
+ const dragRef = createMockRef()
+ render(} />)
+
+ const overlay = document.querySelector('.absolute.left-0.top-0')
+ expect(overlay).toBeInTheDocument()
+ })
+
+ it('should not render drag overlay when not dragging', () => {
+ render()
+
+ 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()
+
+ 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()
+
+ 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()
+ render(} />)
+
+ expect(dropRef.current).toBeInstanceOf(HTMLDivElement)
+ })
+
+ it('should attach fileUploaderRef to input element', () => {
+ const fileUploaderRef = createMockRef()
+ render(} />)
+
+ expect(fileUploaderRef.current).toBeInstanceOf(HTMLInputElement)
+ })
+
+ it('should attach dragRef to overlay when dragging', () => {
+ const dragRef = createMockRef()
+ render(} />)
+
+ expect(dragRef.current).toBeInstanceOf(HTMLDivElement)
+ })
+ })
+
+ describe('styling', () => {
+ it('should have base dropzone styling', () => {
+ const { container } = render()
+
+ const dropzone = container.querySelector('[class*="border-dashed"]')
+ expect(dropzone).toBeInTheDocument()
+ expect(dropzone).toHaveClass('rounded-xl')
+ })
+
+ it('should have cursor-pointer on browse label', () => {
+ render()
+
+ const browseLabel = screen.getByText('Browse')
+ expect(browseLabel).toHaveClass('cursor-pointer')
+ })
+ })
+
+ describe('accessibility', () => {
+ it('should have an accessible file input', () => {
+ render()
+
+ const input = document.getElementById('fileUploader') as HTMLInputElement
+ expect(input).toHaveAttribute('id', 'fileUploader')
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/upload-dropzone.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/upload-dropzone.tsx
new file mode 100644
index 0000000000..66bf42d365
--- /dev/null
+++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/upload-dropzone.tsx
@@ -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
+ dragRef: RefObject
+ fileUploaderRef: RefObject
+ dragging: boolean
+ supportBatchUpload: boolean
+ supportTypesShowNames: string
+ fileUploadConfig: FileUploadConfig
+ acceptTypes: string[]
+ onSelectFile: () => void
+ onFileChange: (e: ChangeEvent) => void
+ allowedExtensions: string[]
+}
+
+const UploadDropzone = ({
+ dropRef,
+ dragRef,
+ fileUploaderRef,
+ dragging,
+ supportBatchUpload,
+ supportTypesShowNames,
+ fileUploadConfig,
+ acceptTypes,
+ onSelectFile,
+ onFileChange,
+ allowedExtensions,
+}: UploadDropzoneProps) => {
+ const { t } = useTranslation()
+
+ return (
+ <>
+
+
+
+
+
+ {supportBatchUpload ? t('stepOne.uploader.button', { ns: 'datasetCreation' }) : t('stepOne.uploader.buttonSingleFile', { ns: 'datasetCreation' })}
+ {allowedExtensions.length > 0 && (
+
+ )}
+
+
+
+ {t('stepOne.uploader.tip', {
+ ns: 'datasetCreation',
+ size: fileUploadConfig.file_size_limit,
+ supportTypes: supportTypesShowNames,
+ batchCount: fileUploadConfig.batch_count_limit,
+ totalCount: fileUploadConfig.file_upload_limit,
+ })}
+
+ {dragging &&
}
+
+ >
+ )
+}
+
+export default UploadDropzone
diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/constants.ts b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/constants.ts
new file mode 100644
index 0000000000..cda2dae868
--- /dev/null
+++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/constants.ts
@@ -0,0 +1,3 @@
+export const PROGRESS_NOT_STARTED = -1
+export const PROGRESS_ERROR = -2
+export const PROGRESS_COMPLETE = 100
diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/use-local-file-upload.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/use-local-file-upload.spec.tsx
new file mode 100644
index 0000000000..f5bd2ac841
--- /dev/null
+++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/use-local-file-upload.spec.tsx
@@ -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 }) => (
+
+ {children}
+
+ )
+}
+
+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
+
+ 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
+
+ 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
+
+ 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
+
+ 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
+
+ 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
+
+ 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
+
+ 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
+
+ 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 (
+
+ )
+ }
+
+ it('should set dragging true on dragenter', async () => {
+ const { getByTestId } = await act(async () =>
+ render(
+
+
+ ,
+ ),
+ )
+
+ 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(
+
+
+ ,
+ ),
+ )
+
+ 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(
+
+
+ ,
+ ),
+ )
+
+ 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(
+
+
+ ,
+ ),
+ )
+
+ 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(
+
+
+ ,
+ ),
+ )
+
+ 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(
+
+
+ ,
+ ),
+ )
+
+ 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[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
+
+ 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[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
+
+ 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
+
+ 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
+
+ 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
+
+ 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)
+ })
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/use-local-file-upload.ts b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/use-local-file-upload.ts
new file mode 100644
index 0000000000..0add8d60d6
--- /dev/null
+++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/use-local-file-upload.ts
@@ -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(null)
+ const dragRef = useRef(null)
+ const fileUploaderRef = useRef(null)
+ const fileListRef = useRef([])
+
+ 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>
+
+ const fileUpload = useCallback(async (fileItem: FileItem): Promise => {
+ 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),
+ }) 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) => {
+ 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,
+ }
+}
diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/index.spec.tsx
new file mode 100644
index 0000000000..66f13be84f
--- /dev/null
+++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/index.spec.tsx
@@ -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 }) => {name}
,
+}))
+
+// Mock SimplePieChart
+vi.mock('next/dynamic', () => ({
+ default: () => {
+ const Component = ({ percentage }: { percentage: number }) => (
+
+ {percentage}
+ %
+
+ )
+ 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(
+ ,
+ )
+
+ expect(container.firstChild).toHaveClass('flex', 'flex-col')
+ })
+
+ it('should render UploadDropzone when hideUpload is false', () => {
+ render()
+
+ const fileInput = document.getElementById('fileUploader')
+ expect(fileInput).toBeInTheDocument()
+ })
+
+ it('should not render UploadDropzone when hideUpload is true', () => {
+ mockUseLocalFileUpload.mockReturnValue({
+ ...defaultHookReturn,
+ hideUpload: true,
+ })
+
+ render()
+
+ const fileInput = document.getElementById('fileUploader')
+ expect(fileInput).not.toBeInTheDocument()
+ })
+ })
+
+ describe('file list rendering', () => {
+ it('should not render file list when empty', () => {
+ render()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ // 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()
+
+ expect(mockUseLocalFileUpload).toHaveBeenCalledWith({
+ allowedExtensions: ['pdf', 'docx', 'txt'],
+ supportBatchUpload: true,
+ })
+ })
+
+ it('should pass supportBatchUpload true by default', () => {
+ render()
+
+ expect(mockUseLocalFileUpload).toHaveBeenCalledWith(
+ expect.objectContaining({ supportBatchUpload: true }),
+ )
+ })
+
+ it('should pass supportBatchUpload false when specified', () => {
+ render()
+
+ 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()
+
+ // 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ const fileListContainer = container.querySelector('.mt-1.flex.flex-col.gap-y-1')
+ expect(fileListContainer).toBeInTheDocument()
+ })
+ })
+
+ describe('edge cases', () => {
+ it('should handle empty allowedExtensions', () => {
+ render()
+
+ 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()
+
+ 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(
+ ,
+ )
+
+ // 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()
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/index.tsx
index d02d5927f2..cb3632ba9d 100644
--- a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/index.tsx
+++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/index.tsx
@@ -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(null)
- const dragRef = useRef(null)
- const fileUploader = useRef(null)
- const fileListRef = useRef([])
-
- 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>
-
- const fileUpload = useCallback(async (fileItem: FileItem): Promise => {
- 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),
- }) 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) => {
- 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 (
{!hideUpload && (
-
)}
- {!hideUpload && (
-
-
-
-
-
- {supportBatchUpload ? t('stepOne.uploader.button', { ns: 'datasetCreation' }) : t('stepOne.uploader.buttonSingleFile', { ns: 'datasetCreation' })}
- {allowedExtensions.length > 0 && (
-
- )}
-
-
-
- {t('stepOne.uploader.tip', {
- ns: 'datasetCreation',
- size: fileUploadConfig.file_size_limit,
- supportTypes: supportTypesShowNames,
- batchCount: fileUploadConfig.batch_count_limit,
- totalCount: fileUploadConfig.file_upload_limit,
- })}
-
- {dragging &&
}
-
- )}
{localFileList.length > 0 && (
- {localFileList.map((fileItem, index) => {
- const isUploading = fileItem.progress >= 0 && fileItem.progress < 100
- const isError = fileItem.progress === -2
- return (
-
-
-
-
-
-
-
- {getFileType(fileItem.file)}
- ·
- {getFileSize(fileItem.file.size)}
-
-
-
- {isUploading && (
-
- )}
- {
- isError && (
-
- )
- }
- {
- e.stopPropagation()
- removeFile(fileItem.fileID)
- }}
- >
-
-
-
-
- )
- })}
+ {localFileList.map((fileItem, index) => (
+
+ ))}
)}