From c2191252fac44bc8d8aa3f7ff1eac6962ff17d3f Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Thu, 29 Jan 2026 14:54:13 +0800 Subject: [PATCH] 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 --- .../datasets/common/image-uploader/utils.ts | 7 + .../components/file-list-item.spec.tsx | 351 +++++++ .../local-file/components/file-list-item.tsx | 84 ++ .../components/upload-dropzone.spec.tsx | 231 +++++ .../local-file/components/upload-dropzone.tsx | 83 ++ .../data-source/local-file/constants.ts | 3 + .../hooks/use-local-file-upload.spec.tsx | 879 ++++++++++++++++++ .../local-file/hooks/use-local-file-upload.ts | 292 ++++++ .../data-source/local-file/index.spec.tsx | 398 ++++++++ .../data-source/local-file/index.tsx | 391 +------- 10 files changed, 2366 insertions(+), 353 deletions(-) create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/file-list-item.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/file-list-item.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/upload-dropzone.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/upload-dropzone.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/constants.ts create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/use-local-file-upload.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/use-local-file-upload.ts create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/index.spec.tsx 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 ( +
+
+ +
+
+
+
{fileItem.file.name}
+
+
+ {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 ( +
+
+ {dragging &&
} +
+ {String(dragging)} +
+ ) + } + + 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 ( -
-
- -
-
-
-
{fileItem.file.name}
-
-
- {getFileType(fileItem.file)} - · - {getFileSize(fileItem.file.size)} -
-
-
- {isUploading && ( - - )} - { - isError && ( - - ) - } - { - e.stopPropagation() - removeFile(fileItem.fileID) - }} - > - - -
-
- ) - })} + {localFileList.map((fileItem, index) => ( + + ))}
)}