diff --git a/web/app/components/datasets/common/image-uploader/utils.spec.ts b/web/app/components/datasets/common/image-uploader/utils.spec.ts index 0150b1fb23..5741f5704f 100644 --- a/web/app/components/datasets/common/image-uploader/utils.spec.ts +++ b/web/app/components/datasets/common/image-uploader/utils.spec.ts @@ -216,13 +216,22 @@ describe('image-uploader utils', () => { type FileCallback = (file: MockFile) => void type EntriesCallback = (entries: FileSystemEntry[]) => void + // Helper to create mock FileSystemEntry with required properties + const createMockEntry = (props: { + isFile: boolean + isDirectory: boolean + name?: string + file?: (callback: FileCallback) => void + createReader?: () => { readEntries: (callback: EntriesCallback) => void } + }): FileSystemEntry => props as unknown as FileSystemEntry + it('should resolve with file array for file entry', async () => { const mockFile: MockFile = { name: 'test.png' } - const mockEntry = { + const mockEntry = createMockEntry({ isFile: true, isDirectory: false, file: (callback: FileCallback) => callback(mockFile), - } + }) const result = await traverseFileEntry(mockEntry) expect(result).toHaveLength(1) @@ -232,11 +241,11 @@ describe('image-uploader utils', () => { it('should resolve with file array with prefix for nested file', async () => { const mockFile: MockFile = { name: 'test.png' } - const mockEntry = { + const mockEntry = createMockEntry({ isFile: true, isDirectory: false, file: (callback: FileCallback) => callback(mockFile), - } + }) const result = await traverseFileEntry(mockEntry, 'folder/') expect(result).toHaveLength(1) @@ -244,24 +253,24 @@ describe('image-uploader utils', () => { }) it('should resolve empty array for unknown entry type', async () => { - const mockEntry = { + const mockEntry = createMockEntry({ isFile: false, isDirectory: false, - } + }) const result = await traverseFileEntry(mockEntry) expect(result).toEqual([]) }) it('should handle directory with no files', async () => { - const mockEntry = { + const mockEntry = createMockEntry({ isFile: false, isDirectory: true, name: 'empty-folder', createReader: () => ({ readEntries: (callback: EntriesCallback) => callback([]), }), - } + }) const result = await traverseFileEntry(mockEntry) expect(result).toEqual([]) @@ -271,20 +280,20 @@ describe('image-uploader utils', () => { const mockFile1: MockFile = { name: 'file1.png' } const mockFile2: MockFile = { name: 'file2.png' } - const mockFileEntry1 = { + const mockFileEntry1 = createMockEntry({ isFile: true, isDirectory: false, file: (callback: FileCallback) => callback(mockFile1), - } + }) - const mockFileEntry2 = { + const mockFileEntry2 = createMockEntry({ isFile: true, isDirectory: false, file: (callback: FileCallback) => callback(mockFile2), - } + }) let readCount = 0 - const mockEntry = { + const mockEntry = createMockEntry({ isFile: false, isDirectory: true, name: 'folder', @@ -292,14 +301,14 @@ describe('image-uploader utils', () => { readEntries: (callback: EntriesCallback) => { if (readCount === 0) { readCount++ - callback([mockFileEntry1, mockFileEntry2] as unknown as FileSystemEntry[]) + callback([mockFileEntry1, mockFileEntry2]) } else { callback([]) } }, }), - } + }) const result = await traverseFileEntry(mockEntry) expect(result).toHaveLength(2) diff --git a/web/app/components/datasets/common/image-uploader/utils.ts b/web/app/components/datasets/common/image-uploader/utils.ts index c2fad83840..d8c8582e2a 100644 --- a/web/app/components/datasets/common/image-uploader/utils.ts +++ b/web/app/components/datasets/common/image-uploader/utils.ts @@ -18,17 +18,17 @@ type FileWithPath = { relativePath?: string } & File -export const traverseFileEntry = (entry: any, prefix = ''): Promise => { +export const traverseFileEntry = (entry: FileSystemEntry, prefix = ''): Promise => { return new Promise((resolve) => { if (entry.isFile) { - entry.file((file: FileWithPath) => { + (entry as FileSystemFileEntry).file((file: FileWithPath) => { file.relativePath = `${prefix}${file.name}` resolve([file]) }) } else if (entry.isDirectory) { - const reader = entry.createReader() - const entries: any[] = [] + const reader = (entry as FileSystemDirectoryEntry).createReader() + const entries: FileSystemEntry[] = [] const read = () => { reader.readEntries(async (results: FileSystemEntry[]) => { if (!results.length) { diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/use-dsl-import.spec.tsx b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/use-dsl-import.spec.tsx new file mode 100644 index 0000000000..e4955f58f6 --- /dev/null +++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/use-dsl-import.spec.tsx @@ -0,0 +1,1045 @@ +import type { ReactNode } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { CreateFromDSLModalTab, useDSLImport } from './use-dsl-import' + +// Mock next/navigation +const mockPush = vi.fn() +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: mockPush, + }), +})) + +// Mock service hooks +const mockImportDSL = vi.fn() +const mockImportDSLConfirm = vi.fn() + +vi.mock('@/service/use-pipeline', () => ({ + useImportPipelineDSL: () => ({ + mutateAsync: mockImportDSL, + }), + useImportPipelineDSLConfirm: () => ({ + mutateAsync: mockImportDSLConfirm, + }), +})) + +// Mock plugin dependencies hook +const mockHandleCheckPluginDependencies = vi.fn() + +vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({ + usePluginDependencies: () => ({ + handleCheckPluginDependencies: mockHandleCheckPluginDependencies, + }), +})) + +// Mock toast context +const mockNotify = vi.fn() + +vi.mock('use-context-selector', async () => { + const actual = await vi.importActual('use-context-selector') + return { + ...actual, + useContext: vi.fn(() => ({ notify: mockNotify })), + } +}) + +// Test data builders +const createImportDSLResponse = (overrides = {}) => ({ + id: 'import-123', + status: 'completed' as const, + pipeline_id: 'pipeline-456', + dataset_id: 'dataset-789', + current_dsl_version: '1.0.0', + imported_dsl_version: '1.0.0', + ...overrides, +}) + +// Helper function to create QueryClient wrapper +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + return ({ children }: { children: ReactNode }) => ( + + {children} + + ) +} + +describe('useDSLImport', () => { + beforeEach(() => { + vi.clearAllMocks() + mockImportDSL.mockReset() + mockImportDSLConfirm.mockReset() + mockPush.mockReset() + mockNotify.mockReset() + mockHandleCheckPluginDependencies.mockReset() + }) + + describe('initialization', () => { + it('should initialize with default values', () => { + const { result } = renderHook( + () => useDSLImport({}), + { wrapper: createWrapper() }, + ) + + expect(result.current.currentFile).toBeUndefined() + expect(result.current.currentTab).toBe(CreateFromDSLModalTab.FROM_FILE) + expect(result.current.dslUrlValue).toBe('') + expect(result.current.showConfirmModal).toBe(false) + expect(result.current.versions).toBeUndefined() + expect(result.current.buttonDisabled).toBe(true) + expect(result.current.isConfirming).toBe(false) + }) + + it('should use provided activeTab', () => { + const { result } = renderHook( + () => useDSLImport({ activeTab: CreateFromDSLModalTab.FROM_URL }), + { wrapper: createWrapper() }, + ) + + expect(result.current.currentTab).toBe(CreateFromDSLModalTab.FROM_URL) + }) + + it('should use provided dslUrl', () => { + const { result } = renderHook( + () => useDSLImport({ dslUrl: 'https://example.com/test.pipeline' }), + { wrapper: createWrapper() }, + ) + + expect(result.current.dslUrlValue).toBe('https://example.com/test.pipeline') + }) + }) + + describe('setCurrentTab', () => { + it('should update current tab', () => { + const { result } = renderHook( + () => useDSLImport({}), + { wrapper: createWrapper() }, + ) + + act(() => { + result.current.setCurrentTab(CreateFromDSLModalTab.FROM_URL) + }) + + expect(result.current.currentTab).toBe(CreateFromDSLModalTab.FROM_URL) + }) + }) + + describe('setDslUrlValue', () => { + it('should update DSL URL value', () => { + const { result } = renderHook( + () => useDSLImport({}), + { wrapper: createWrapper() }, + ) + + act(() => { + result.current.setDslUrlValue('https://new-url.com/pipeline') + }) + + expect(result.current.dslUrlValue).toBe('https://new-url.com/pipeline') + }) + }) + + describe('handleFile', () => { + it('should set file and trigger file reading', async () => { + const { result } = renderHook( + () => useDSLImport({}), + { wrapper: createWrapper() }, + ) + + const mockFile = new File(['test content'], 'test.pipeline', { type: 'application/octet-stream' }) + + await act(async () => { + result.current.handleFile(mockFile) + }) + + expect(result.current.currentFile).toBe(mockFile) + expect(result.current.buttonDisabled).toBe(false) + }) + + it('should clear file when undefined is passed', async () => { + const { result } = renderHook( + () => useDSLImport({}), + { wrapper: createWrapper() }, + ) + + const mockFile = new File(['test content'], 'test.pipeline', { type: 'application/octet-stream' }) + + // First set a file + await act(async () => { + result.current.handleFile(mockFile) + }) + + expect(result.current.currentFile).toBe(mockFile) + + // Then clear it + await act(async () => { + result.current.handleFile(undefined) + }) + + expect(result.current.currentFile).toBeUndefined() + expect(result.current.buttonDisabled).toBe(true) + }) + }) + + describe('buttonDisabled', () => { + it('should be true when file tab is active and no file is selected', () => { + const { result } = renderHook( + () => useDSLImport({ activeTab: CreateFromDSLModalTab.FROM_FILE }), + { wrapper: createWrapper() }, + ) + + expect(result.current.buttonDisabled).toBe(true) + }) + + it('should be false when file tab is active and file is selected', async () => { + const { result } = renderHook( + () => useDSLImport({ activeTab: CreateFromDSLModalTab.FROM_FILE }), + { wrapper: createWrapper() }, + ) + + const mockFile = new File(['content'], 'test.pipeline', { type: 'application/octet-stream' }) + + await act(async () => { + result.current.handleFile(mockFile) + }) + + expect(result.current.buttonDisabled).toBe(false) + }) + + it('should be true when URL tab is active and no URL is entered', () => { + const { result } = renderHook( + () => useDSLImport({ activeTab: CreateFromDSLModalTab.FROM_URL }), + { wrapper: createWrapper() }, + ) + + expect(result.current.buttonDisabled).toBe(true) + }) + + it('should be false when URL tab is active and URL is entered', () => { + const { result } = renderHook( + () => useDSLImport({ activeTab: CreateFromDSLModalTab.FROM_URL, dslUrl: 'https://example.com' }), + { wrapper: createWrapper() }, + ) + + expect(result.current.buttonDisabled).toBe(false) + }) + }) + + describe('handleCreateApp with URL mode', () => { + it('should call importDSL with URL mode', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + mockImportDSL.mockResolvedValue(createImportDSLResponse()) + mockHandleCheckPluginDependencies.mockResolvedValue(undefined) + + const onSuccess = vi.fn() + const onClose = vi.fn() + + const { result } = renderHook( + () => useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + onSuccess, + onClose, + }), + { wrapper: createWrapper() }, + ) + + await act(async () => { + result.current.handleCreateApp() + vi.advanceTimersByTime(400) // Wait for debounce + }) + + await waitFor(() => { + expect(mockImportDSL).toHaveBeenCalledWith({ + mode: 'yaml-url', + yaml_url: 'https://example.com/test.pipeline', + }) + }) + + vi.useRealTimers() + }) + + it('should handle successful import with COMPLETED status', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + mockImportDSL.mockResolvedValue(createImportDSLResponse({ status: 'completed' })) + mockHandleCheckPluginDependencies.mockResolvedValue(undefined) + + const onSuccess = vi.fn() + const onClose = vi.fn() + + const { result } = renderHook( + () => useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + onSuccess, + onClose, + }), + { wrapper: createWrapper() }, + ) + + await act(async () => { + result.current.handleCreateApp() + vi.advanceTimersByTime(400) + }) + + await waitFor(() => { + expect(onSuccess).toHaveBeenCalled() + expect(onClose).toHaveBeenCalled() + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'success', + })) + expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-789/pipeline') + }) + + vi.useRealTimers() + }) + + it('should handle import with COMPLETED_WITH_WARNINGS status', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + mockImportDSL.mockResolvedValue(createImportDSLResponse({ status: 'completed-with-warnings' })) + mockHandleCheckPluginDependencies.mockResolvedValue(undefined) + + const onSuccess = vi.fn() + const onClose = vi.fn() + + const { result } = renderHook( + () => useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + onSuccess, + onClose, + }), + { wrapper: createWrapper() }, + ) + + await act(async () => { + result.current.handleCreateApp() + vi.advanceTimersByTime(400) + }) + + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'warning', + })) + }) + + vi.useRealTimers() + }) + + it('should handle import with PENDING status and show confirm modal', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + mockImportDSL.mockResolvedValue(createImportDSLResponse({ + status: 'pending', + imported_dsl_version: '0.9.0', + current_dsl_version: '1.0.0', + })) + + const onClose = vi.fn() + + const { result } = renderHook( + () => useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + onClose, + }), + { wrapper: createWrapper() }, + ) + + await act(async () => { + result.current.handleCreateApp() + vi.advanceTimersByTime(400) + }) + + await waitFor(() => { + expect(onClose).toHaveBeenCalled() + }) + + // Wait for setTimeout to show confirm modal + await act(async () => { + vi.advanceTimersByTime(400) + }) + + expect(result.current.showConfirmModal).toBe(true) + expect(result.current.versions).toEqual({ + importedVersion: '0.9.0', + systemVersion: '1.0.0', + }) + + vi.useRealTimers() + }) + + it('should handle API error (null response)', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + mockImportDSL.mockResolvedValue(null) + + const { result } = renderHook( + () => useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + }), + { wrapper: createWrapper() }, + ) + + await act(async () => { + result.current.handleCreateApp() + vi.advanceTimersByTime(400) + }) + + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + })) + }) + + vi.useRealTimers() + }) + + it('should handle FAILED status', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + mockImportDSL.mockResolvedValue(createImportDSLResponse({ status: 'failed' })) + + const { result } = renderHook( + () => useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + }), + { wrapper: createWrapper() }, + ) + + await act(async () => { + result.current.handleCreateApp() + vi.advanceTimersByTime(400) + }) + + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + })) + }) + + vi.useRealTimers() + }) + + it('should check plugin dependencies when pipeline_id is present', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + mockImportDSL.mockResolvedValue(createImportDSLResponse({ + status: 'completed', + pipeline_id: 'pipeline-123', + })) + mockHandleCheckPluginDependencies.mockResolvedValue(undefined) + + const { result } = renderHook( + () => useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + }), + { wrapper: createWrapper() }, + ) + + await act(async () => { + result.current.handleCreateApp() + vi.advanceTimersByTime(400) + }) + + await waitFor(() => { + expect(mockHandleCheckPluginDependencies).toHaveBeenCalledWith('pipeline-123', true) + }) + + vi.useRealTimers() + }) + + it('should not check plugin dependencies when pipeline_id is null', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + mockImportDSL.mockResolvedValue(createImportDSLResponse({ + status: 'completed', + pipeline_id: null, + })) + + const { result } = renderHook( + () => useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + }), + { wrapper: createWrapper() }, + ) + + await act(async () => { + result.current.handleCreateApp() + vi.advanceTimersByTime(400) + }) + + await waitFor(() => { + expect(mockHandleCheckPluginDependencies).not.toHaveBeenCalled() + }) + + vi.useRealTimers() + }) + + it('should return early when URL tab is active but no URL is provided', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + + const { result } = renderHook( + () => useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: '', + }), + { wrapper: createWrapper() }, + ) + + await act(async () => { + result.current.handleCreateApp() + vi.advanceTimersByTime(400) + }) + + expect(mockImportDSL).not.toHaveBeenCalled() + + vi.useRealTimers() + }) + }) + + describe('handleCreateApp with FILE mode', () => { + it('should call importDSL with file content mode', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + mockImportDSL.mockResolvedValue(createImportDSLResponse()) + mockHandleCheckPluginDependencies.mockResolvedValue(undefined) + + const { result } = renderHook( + () => useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_FILE, + }), + { wrapper: createWrapper() }, + ) + + const fileContent = 'test yaml content' + const mockFile = new File([fileContent], 'test.pipeline', { type: 'application/octet-stream' }) + + // Set up file and wait for FileReader to complete + await act(async () => { + result.current.handleFile(mockFile) + // Give FileReader time to process + await new Promise(resolve => setTimeout(resolve, 100)) + }) + + // Trigger create + await act(async () => { + result.current.handleCreateApp() + vi.advanceTimersByTime(400) + }) + + await waitFor(() => { + expect(mockImportDSL).toHaveBeenCalledWith({ + mode: 'yaml-content', + yaml_content: fileContent, + }) + }) + + vi.useRealTimers() + }) + + it('should return early when file tab is active but no file is selected', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + + const { result } = renderHook( + () => useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_FILE, + }), + { wrapper: createWrapper() }, + ) + + await act(async () => { + result.current.handleCreateApp() + vi.advanceTimersByTime(400) + }) + + expect(mockImportDSL).not.toHaveBeenCalled() + + vi.useRealTimers() + }) + }) + + describe('onDSLConfirm', () => { + it('should call importDSLConfirm and handle success', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + + // First, trigger pending status to get importId + mockImportDSL.mockResolvedValue(createImportDSLResponse({ + id: 'import-123', + status: 'pending', + })) + + mockImportDSLConfirm.mockResolvedValue({ + status: 'completed', + pipeline_id: 'pipeline-456', + dataset_id: 'dataset-789', + }) + + mockHandleCheckPluginDependencies.mockResolvedValue(undefined) + + const onSuccess = vi.fn() + + const { result } = renderHook( + () => useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + onSuccess, + }), + { wrapper: createWrapper() }, + ) + + // Trigger pending status + await act(async () => { + result.current.handleCreateApp() + vi.advanceTimersByTime(400) + }) + + // Wait for confirm modal to show + await act(async () => { + vi.advanceTimersByTime(400) + }) + + expect(result.current.showConfirmModal).toBe(true) + + // Call onDSLConfirm + await act(async () => { + result.current.onDSLConfirm() + }) + + await waitFor(() => { + expect(mockImportDSLConfirm).toHaveBeenCalledWith('import-123') + expect(onSuccess).toHaveBeenCalled() + expect(result.current.showConfirmModal).toBe(false) + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'success', + })) + }) + + vi.useRealTimers() + }) + + it('should handle confirm API error', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + + mockImportDSL.mockResolvedValue(createImportDSLResponse({ + id: 'import-123', + status: 'pending', + })) + + mockImportDSLConfirm.mockResolvedValue(null) + + const { result } = renderHook( + () => useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + }), + { wrapper: createWrapper() }, + ) + + // Trigger pending status + await act(async () => { + result.current.handleCreateApp() + vi.advanceTimersByTime(400) + }) + + await act(async () => { + vi.advanceTimersByTime(400) + }) + + // Call onDSLConfirm + await act(async () => { + result.current.onDSLConfirm() + }) + + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + })) + }) + + vi.useRealTimers() + }) + + it('should handle confirm with FAILED status', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + + mockImportDSL.mockResolvedValue(createImportDSLResponse({ + id: 'import-123', + status: 'pending', + })) + + mockImportDSLConfirm.mockResolvedValue({ + status: 'failed', + pipeline_id: 'pipeline-456', + dataset_id: 'dataset-789', + }) + + const { result } = renderHook( + () => useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + }), + { wrapper: createWrapper() }, + ) + + // Trigger pending status + await act(async () => { + result.current.handleCreateApp() + vi.advanceTimersByTime(400) + }) + + await act(async () => { + vi.advanceTimersByTime(400) + }) + + // Call onDSLConfirm + await act(async () => { + result.current.onDSLConfirm() + }) + + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + })) + }) + + vi.useRealTimers() + }) + + it('should return early when importId is not set', async () => { + const { result } = renderHook( + () => useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + }), + { wrapper: createWrapper() }, + ) + + // Call onDSLConfirm without triggering pending status + await act(async () => { + result.current.onDSLConfirm() + }) + + expect(mockImportDSLConfirm).not.toHaveBeenCalled() + }) + + it('should check plugin dependencies on confirm success', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + + mockImportDSL.mockResolvedValue(createImportDSLResponse({ + id: 'import-123', + status: 'pending', + })) + + mockImportDSLConfirm.mockResolvedValue({ + status: 'completed', + pipeline_id: 'pipeline-789', + dataset_id: 'dataset-789', + }) + + mockHandleCheckPluginDependencies.mockResolvedValue(undefined) + + const { result } = renderHook( + () => useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + }), + { wrapper: createWrapper() }, + ) + + // Trigger pending status + await act(async () => { + result.current.handleCreateApp() + vi.advanceTimersByTime(400) + }) + + await act(async () => { + vi.advanceTimersByTime(400) + }) + + // Call onDSLConfirm + await act(async () => { + result.current.onDSLConfirm() + }) + + await waitFor(() => { + expect(mockHandleCheckPluginDependencies).toHaveBeenCalledWith('pipeline-789', true) + }) + + vi.useRealTimers() + }) + + it('should set isConfirming during confirm process', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + + let resolveConfirm: (value: unknown) => void + mockImportDSL.mockResolvedValue(createImportDSLResponse({ + id: 'import-123', + status: 'pending', + })) + + mockImportDSLConfirm.mockImplementation(() => new Promise((resolve) => { + resolveConfirm = resolve + })) + + const { result } = renderHook( + () => useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + }), + { wrapper: createWrapper() }, + ) + + // Trigger pending status + await act(async () => { + result.current.handleCreateApp() + vi.advanceTimersByTime(400) + }) + + await act(async () => { + vi.advanceTimersByTime(400) + }) + + expect(result.current.isConfirming).toBe(false) + + // Start confirm + let confirmPromise: Promise + act(() => { + confirmPromise = result.current.onDSLConfirm() + }) + + await waitFor(() => { + expect(result.current.isConfirming).toBe(true) + }) + + // Resolve confirm + await act(async () => { + resolveConfirm!({ + status: 'completed', + pipeline_id: 'pipeline-789', + dataset_id: 'dataset-789', + }) + }) + + await confirmPromise! + + expect(result.current.isConfirming).toBe(false) + + vi.useRealTimers() + }) + }) + + describe('handleCancelConfirm', () => { + it('should close confirm modal', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + + mockImportDSL.mockResolvedValue(createImportDSLResponse({ + id: 'import-123', + status: 'pending', + })) + + const { result } = renderHook( + () => useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + }), + { wrapper: createWrapper() }, + ) + + // Trigger pending status to show confirm modal + await act(async () => { + result.current.handleCreateApp() + vi.advanceTimersByTime(400) + }) + + await act(async () => { + vi.advanceTimersByTime(400) + }) + + expect(result.current.showConfirmModal).toBe(true) + + // Cancel confirm + act(() => { + result.current.handleCancelConfirm() + }) + + expect(result.current.showConfirmModal).toBe(false) + + vi.useRealTimers() + }) + }) + + describe('duplicate submission prevention', () => { + it('should prevent duplicate submissions while creating', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + + let resolveImport: (value: unknown) => void + mockImportDSL.mockImplementation(() => new Promise((resolve) => { + resolveImport = resolve + })) + + const { result } = renderHook( + () => useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + }), + { wrapper: createWrapper() }, + ) + + // First call + await act(async () => { + result.current.handleCreateApp() + vi.advanceTimersByTime(400) + }) + + // Second call should be ignored + await act(async () => { + result.current.handleCreateApp() + vi.advanceTimersByTime(400) + }) + + // Third call should be ignored + await act(async () => { + result.current.handleCreateApp() + vi.advanceTimersByTime(400) + }) + + // Only one call should be made + expect(mockImportDSL).toHaveBeenCalledTimes(1) + + // Resolve the first call + await act(async () => { + resolveImport!(createImportDSLResponse()) + }) + + vi.useRealTimers() + }) + }) + + describe('file reading', () => { + it('should read file content using FileReader', async () => { + const { result } = renderHook( + () => useDSLImport({ activeTab: CreateFromDSLModalTab.FROM_FILE }), + { wrapper: createWrapper() }, + ) + + const fileContent = 'yaml content here' + const mockFile = new File([fileContent], 'test.pipeline', { type: 'application/octet-stream' }) + + await act(async () => { + result.current.handleFile(mockFile) + }) + + expect(result.current.currentFile).toBe(mockFile) + }) + + it('should clear file content when file is removed', async () => { + const { result } = renderHook( + () => useDSLImport({ activeTab: CreateFromDSLModalTab.FROM_FILE }), + { wrapper: createWrapper() }, + ) + + const mockFile = new File(['content'], 'test.pipeline', { type: 'application/octet-stream' }) + + // Set file + await act(async () => { + result.current.handleFile(mockFile) + }) + + // Clear file + await act(async () => { + result.current.handleFile(undefined) + }) + + expect(result.current.currentFile).toBeUndefined() + }) + }) + + describe('navigation after import', () => { + it('should navigate to pipeline page after successful import', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + mockImportDSL.mockResolvedValue(createImportDSLResponse({ + status: 'completed', + dataset_id: 'test-dataset-id', + })) + mockHandleCheckPluginDependencies.mockResolvedValue(undefined) + + const { result } = renderHook( + () => useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + }), + { wrapper: createWrapper() }, + ) + + await act(async () => { + result.current.handleCreateApp() + vi.advanceTimersByTime(400) + }) + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith('/datasets/test-dataset-id/pipeline') + }) + + vi.useRealTimers() + }) + + it('should navigate to pipeline page after confirm success', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + + mockImportDSL.mockResolvedValue(createImportDSLResponse({ + id: 'import-123', + status: 'pending', + })) + + mockImportDSLConfirm.mockResolvedValue({ + status: 'completed', + pipeline_id: 'pipeline-456', + dataset_id: 'confirm-dataset-id', + }) + + mockHandleCheckPluginDependencies.mockResolvedValue(undefined) + + const { result } = renderHook( + () => useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + }), + { wrapper: createWrapper() }, + ) + + // Trigger pending status + await act(async () => { + result.current.handleCreateApp() + vi.advanceTimersByTime(400) + }) + + await act(async () => { + vi.advanceTimersByTime(400) + }) + + // Call onDSLConfirm + await act(async () => { + result.current.onDSLConfirm() + }) + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith('/datasets/confirm-dataset-id/pipeline') + }) + + vi.useRealTimers() + }) + }) + + describe('enum export', () => { + it('should export CreateFromDSLModalTab enum with correct values', () => { + expect(CreateFromDSLModalTab.FROM_FILE).toBe('from-file') + expect(CreateFromDSLModalTab.FROM_URL).toBe('from-url') + }) + }) +}) diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/use-dsl-import.ts b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/use-dsl-import.ts new file mode 100644 index 0000000000..87e55ea740 --- /dev/null +++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/use-dsl-import.ts @@ -0,0 +1,218 @@ +'use client' +import { useDebounceFn } from 'ahooks' +import { useRouter } from 'next/navigation' +import { useCallback, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useContext } from 'use-context-selector' +import { ToastContext } from '@/app/components/base/toast' +import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks' +import { + DSLImportMode, + DSLImportStatus, +} from '@/models/app' +import { useImportPipelineDSL, useImportPipelineDSLConfirm } from '@/service/use-pipeline' + +export enum CreateFromDSLModalTab { + FROM_FILE = 'from-file', + FROM_URL = 'from-url', +} + +export type UseDSLImportOptions = { + activeTab?: CreateFromDSLModalTab + dslUrl?: string + onSuccess?: () => void + onClose?: () => void +} + +export type DSLVersions = { + importedVersion: string + systemVersion: string +} + +export const useDSLImport = ({ + activeTab = CreateFromDSLModalTab.FROM_FILE, + dslUrl = '', + onSuccess, + onClose, +}: UseDSLImportOptions) => { + const { push } = useRouter() + const { t } = useTranslation() + const { notify } = useContext(ToastContext) + + const [currentFile, setDSLFile] = useState() + const [fileContent, setFileContent] = useState() + const [currentTab, setCurrentTab] = useState(activeTab) + const [dslUrlValue, setDslUrlValue] = useState(dslUrl) + const [showConfirmModal, setShowConfirmModal] = useState(false) + const [versions, setVersions] = useState() + const [importId, setImportId] = useState() + const [isConfirming, setIsConfirming] = useState(false) + + const { handleCheckPluginDependencies } = usePluginDependencies() + const isCreatingRef = useRef(false) + + const { mutateAsync: importDSL } = useImportPipelineDSL() + const { mutateAsync: importDSLConfirm } = useImportPipelineDSLConfirm() + + const readFile = useCallback((file: File) => { + const reader = new FileReader() + reader.onload = (event) => { + const content = event.target?.result + setFileContent(content as string) + } + reader.readAsText(file) + }, []) + + const handleFile = useCallback((file?: File) => { + setDSLFile(file) + if (file) + readFile(file) + if (!file) + setFileContent('') + }, [readFile]) + + const onCreate = useCallback(async () => { + if (currentTab === CreateFromDSLModalTab.FROM_FILE && !currentFile) + return + if (currentTab === CreateFromDSLModalTab.FROM_URL && !dslUrlValue) + return + if (isCreatingRef.current) + return + + isCreatingRef.current = true + + let response + if (currentTab === CreateFromDSLModalTab.FROM_FILE) { + response = await importDSL({ + mode: DSLImportMode.YAML_CONTENT, + yaml_content: fileContent || '', + }) + } + if (currentTab === CreateFromDSLModalTab.FROM_URL) { + response = await importDSL({ + mode: DSLImportMode.YAML_URL, + yaml_url: dslUrlValue || '', + }) + } + + if (!response) { + notify({ type: 'error', message: t('creation.errorTip', { ns: 'datasetPipeline' }) }) + isCreatingRef.current = false + return + } + + const { id, status, pipeline_id, dataset_id, imported_dsl_version, current_dsl_version } = response + + if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) { + onSuccess?.() + onClose?.() + + notify({ + type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning', + message: t(status === DSLImportStatus.COMPLETED ? 'creation.successTip' : 'creation.caution', { ns: 'datasetPipeline' }), + children: status === DSLImportStatus.COMPLETED_WITH_WARNINGS && t('newApp.appCreateDSLWarning', { ns: 'app' }), + }) + + if (pipeline_id) + await handleCheckPluginDependencies(pipeline_id, true) + + push(`/datasets/${dataset_id}/pipeline`) + isCreatingRef.current = false + } + else if (status === DSLImportStatus.PENDING) { + setVersions({ + importedVersion: imported_dsl_version ?? '', + systemVersion: current_dsl_version ?? '', + }) + onClose?.() + setTimeout(() => { + setShowConfirmModal(true) + }, 300) + setImportId(id) + isCreatingRef.current = false + } + else { + notify({ type: 'error', message: t('creation.errorTip', { ns: 'datasetPipeline' }) }) + isCreatingRef.current = false + } + }, [ + currentTab, + currentFile, + dslUrlValue, + fileContent, + importDSL, + notify, + t, + onSuccess, + onClose, + handleCheckPluginDependencies, + push, + ]) + + const { run: handleCreateApp } = useDebounceFn(onCreate, { wait: 300 }) + + const onDSLConfirm = useCallback(async () => { + if (!importId) + return + + setIsConfirming(true) + const response = await importDSLConfirm(importId) + setIsConfirming(false) + + if (!response) { + notify({ type: 'error', message: t('creation.errorTip', { ns: 'datasetPipeline' }) }) + return + } + + const { status, pipeline_id, dataset_id } = response + + if (status === DSLImportStatus.COMPLETED) { + onSuccess?.() + setShowConfirmModal(false) + + notify({ + type: 'success', + message: t('creation.successTip', { ns: 'datasetPipeline' }), + }) + + if (pipeline_id) + await handleCheckPluginDependencies(pipeline_id, true) + + push(`/datasets/${dataset_id}/pipeline`) + } + else if (status === DSLImportStatus.FAILED) { + notify({ type: 'error', message: t('creation.errorTip', { ns: 'datasetPipeline' }) }) + } + }, [importId, importDSLConfirm, notify, t, onSuccess, handleCheckPluginDependencies, push]) + + const handleCancelConfirm = useCallback(() => { + setShowConfirmModal(false) + }, []) + + const buttonDisabled = useMemo(() => { + if (currentTab === CreateFromDSLModalTab.FROM_FILE) + return !currentFile + if (currentTab === CreateFromDSLModalTab.FROM_URL) + return !dslUrlValue + return false + }, [currentTab, currentFile, dslUrlValue]) + + return { + // State + currentFile, + currentTab, + dslUrlValue, + showConfirmModal, + versions, + buttonDisabled, + isConfirming, + + // Actions + setCurrentTab, + setDslUrlValue, + handleFile, + handleCreateApp, + onDSLConfirm, + handleCancelConfirm, + } +} diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/index.tsx b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/index.tsx index 2d187010b8..079ea90687 100644 --- a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/index.tsx +++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/index.tsx @@ -1,24 +1,18 @@ 'use client' -import { useDebounceFn, useKeyPress } from 'ahooks' +import { useKeyPress } from 'ahooks' import { noop } from 'es-toolkit/function' -import { useRouter } from 'next/navigation' -import { useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useContext } from 'use-context-selector' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' import Modal from '@/app/components/base/modal' -import { ToastContext } from '@/app/components/base/toast' -import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks' -import { - DSLImportMode, - DSLImportStatus, -} from '@/models/app' -import { useImportPipelineDSL, useImportPipelineDSLConfirm } from '@/service/use-pipeline' +import DSLConfirmModal from './dsl-confirm-modal' import Header from './header' +import { CreateFromDSLModalTab, useDSLImport } from './hooks/use-dsl-import' import Tab from './tab' import Uploader from './uploader' +export { CreateFromDSLModalTab } + type CreateFromDSLModalProps = { show: boolean onSuccess?: () => void @@ -27,11 +21,6 @@ type CreateFromDSLModalProps = { dslUrl?: string } -export enum CreateFromDSLModalTab { - FROM_FILE = 'from-file', - FROM_URL = 'from-url', -} - const CreateFromDSLModal = ({ show, onSuccess, @@ -39,149 +28,33 @@ const CreateFromDSLModal = ({ activeTab = CreateFromDSLModalTab.FROM_FILE, dslUrl = '', }: CreateFromDSLModalProps) => { - const { push } = useRouter() const { t } = useTranslation() - const { notify } = useContext(ToastContext) - const [currentFile, setDSLFile] = useState() - const [fileContent, setFileContent] = useState() - const [currentTab, setCurrentTab] = useState(activeTab) - const [dslUrlValue, setDslUrlValue] = useState(dslUrl) - const [showErrorModal, setShowErrorModal] = useState(false) - const [versions, setVersions] = useState<{ importedVersion: string, systemVersion: string }>() - const [importId, setImportId] = useState() - const { handleCheckPluginDependencies } = usePluginDependencies() - const readFile = (file: File) => { - const reader = new FileReader() - reader.onload = function (event) { - const content = event.target?.result - setFileContent(content as string) - } - reader.readAsText(file) - } - - const handleFile = (file?: File) => { - setDSLFile(file) - if (file) - readFile(file) - if (!file) - setFileContent('') - } - - const isCreatingRef = useRef(false) - - const { mutateAsync: importDSL } = useImportPipelineDSL() - - const onCreate = async () => { - if (currentTab === CreateFromDSLModalTab.FROM_FILE && !currentFile) - return - if (currentTab === CreateFromDSLModalTab.FROM_URL && !dslUrlValue) - return - if (isCreatingRef.current) - return - isCreatingRef.current = true - let response - if (currentTab === CreateFromDSLModalTab.FROM_FILE) { - response = await importDSL({ - mode: DSLImportMode.YAML_CONTENT, - yaml_content: fileContent || '', - }) - } - if (currentTab === CreateFromDSLModalTab.FROM_URL) { - response = await importDSL({ - mode: DSLImportMode.YAML_URL, - yaml_url: dslUrlValue || '', - }) - } - - if (!response) { - notify({ type: 'error', message: t('creation.errorTip', { ns: 'datasetPipeline' }) }) - isCreatingRef.current = false - return - } - const { id, status, pipeline_id, dataset_id, imported_dsl_version, current_dsl_version } = response - if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) { - if (onSuccess) - onSuccess() - if (onClose) - onClose() - - notify({ - type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning', - message: t(status === DSLImportStatus.COMPLETED ? 'creation.successTip' : 'creation.caution', { ns: 'datasetPipeline' }), - children: status === DSLImportStatus.COMPLETED_WITH_WARNINGS && t('newApp.appCreateDSLWarning', { ns: 'app' }), - }) - if (pipeline_id) - await handleCheckPluginDependencies(pipeline_id, true) - push(`/datasets/${dataset_id}/pipeline`) - isCreatingRef.current = false - } - else if (status === DSLImportStatus.PENDING) { - setVersions({ - importedVersion: imported_dsl_version ?? '', - systemVersion: current_dsl_version ?? '', - }) - if (onClose) - onClose() - setTimeout(() => { - setShowErrorModal(true) - }, 300) - setImportId(id) - isCreatingRef.current = false - } - else { - notify({ type: 'error', message: t('creation.errorTip', { ns: 'datasetPipeline' }) }) - isCreatingRef.current = false - } - } - - const { run: handleCreateApp } = useDebounceFn(onCreate, { wait: 300 }) - - useKeyPress('esc', () => { - if (show && !showErrorModal) - onClose() + const { + currentFile, + currentTab, + dslUrlValue, + showConfirmModal, + versions, + buttonDisabled, + isConfirming, + setCurrentTab, + setDslUrlValue, + handleFile, + handleCreateApp, + onDSLConfirm, + handleCancelConfirm, + } = useDSLImport({ + activeTab, + dslUrl, + onSuccess, + onClose, }) - const { mutateAsync: importDSLConfirm } = useImportPipelineDSLConfirm() - - const onDSLConfirm = async () => { - if (!importId) - return - const response = await importDSLConfirm(importId) - - if (!response) { - notify({ type: 'error', message: t('creation.errorTip', { ns: 'datasetPipeline' }) }) - return - } - - const { status, pipeline_id, dataset_id } = response - - if (status === DSLImportStatus.COMPLETED) { - if (onSuccess) - onSuccess() - if (onClose) - onClose() - - notify({ - type: 'success', - message: t('creation.successTip', { ns: 'datasetPipeline' }), - }) - if (pipeline_id) - await handleCheckPluginDependencies(pipeline_id, true) - push(`datasets/${dataset_id}/pipeline`) - } - else if (status === DSLImportStatus.FAILED) { - notify({ type: 'error', message: t('creation.errorTip', { ns: 'datasetPipeline' }) }) - } - } - - const buttonDisabled = useMemo(() => { - if (currentTab === CreateFromDSLModalTab.FROM_FILE) - return !currentFile - if (currentTab === CreateFromDSLModalTab.FROM_URL) - return !dslUrlValue - return false - }, [currentTab, currentFile, dslUrlValue]) + useKeyPress('esc', () => { + if (show && !showConfirmModal) + onClose() + }) return ( <> @@ -196,29 +69,25 @@ const CreateFromDSLModal = ({ setCurrentTab={setCurrentTab} />
- { - currentTab === CreateFromDSLModalTab.FROM_FILE && ( - - ) - } - { - currentTab === CreateFromDSLModalTab.FROM_URL && ( -
-
- DSL URL -
- setDslUrlValue(e.target.value)} - /> + {currentTab === CreateFromDSLModalTab.FROM_FILE && ( + + )} + {currentTab === CreateFromDSLModalTab.FROM_URL && ( +
+
+ DSL URL
- ) - } + setDslUrlValue(e.target.value)} + /> +
+ )}
- setShowErrorModal(false)} - className="w-[480px]" - > -
-
{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}
-
-
{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}
-
{t('newApp.appCreateDSLErrorPart2', { ns: 'app' })}
-
-
- {t('newApp.appCreateDSLErrorPart3', { ns: 'app' })} - {versions?.importedVersion} -
-
- {t('newApp.appCreateDSLErrorPart4', { ns: 'app' })} - {versions?.systemVersion} -
-
-
-
- - -
-
+ {showConfirmModal && ( + + )} ) } diff --git a/web/app/components/datasets/create/file-uploader/components/file-list-item.spec.tsx b/web/app/components/datasets/create/file-uploader/components/file-list-item.spec.tsx new file mode 100644 index 0000000000..4da20a7bf7 --- /dev/null +++ b/web/app/components/datasets/create/file-uploader/components/file-list-item.spec.tsx @@ -0,0 +1,334 @@ +import type { FileListItemProps } from './file-list-item' +import type { CustomFile as File, FileItem } from '@/models/datasets' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PROGRESS_COMPLETE, PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../constants' +import FileListItem from './file-list-item' + +// Mock theme hook - can be changed per test +let mockTheme = 'light' +vi.mock('@/hooks/use-theme', () => ({ + default: () => ({ theme: mockTheme }), +})) + +// Mock theme types +vi.mock('@/types/app', () => ({ + Theme: { dark: 'dark', light: 'light' }, +})) + +// Mock SimplePieChart with dynamic import handling +vi.mock('next/dynamic', () => ({ + default: () => { + const DynamicComponent = ({ percentage, stroke, fill }: { percentage: number, stroke: string, fill: string }) => ( +
+ 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() + mockTheme = 'light' + }) + + 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', 'xl') + }) + + it('should render file name', () => { + render() + expect(screen.getByText('test-document.pdf')).toBeInTheDocument() + }) + + it('should render file extension in uppercase via CSS class', () => { + render() + const extensionSpan = screen.getByText('pdf') + expect(extensionSpan).toBeInTheDocument() + expect(extensionSpan).toHaveClass('uppercase') + }) + + it('should render file size', () => { + render() + // Default mock file is 100KB (1024 * 100 bytes) + expect(screen.getByText('100.00 KB')).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: PROGRESS_COMPLETE }) + 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 indicator when progress is PROGRESS_ERROR', () => { + const fileItem = createMockFileItem({ progress: PROGRESS_ERROR }) + const { container } = render() + + const errorIndicator = container.querySelector('.text-text-destructive') + expect(errorIndicator).toBeInTheDocument() + }) + + it('should not show error indicator when not in error state', () => { + const { container } = render() + const errorIndicator = container.querySelector('.text-text-destructive') + expect(errorIndicator).not.toBeInTheDocument() + }) + }) + + describe('theme handling', () => { + it('should use correct chart color for light theme', () => { + mockTheme = 'light' + const fileItem = createMockFileItem({ progress: 50 }) + render() + + 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 with file id', () => { + const onPreview = vi.fn() + const fileItem = createMockFileItem({ + file: createMockFile({ id: 'uploaded-id' } as Partial), + }) + 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 not call onPreview when file has no id', () => { + 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).not.toHaveBeenCalled() + }) + + 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 fileItem = createMockFileItem({ + file: createMockFile({ id: 'uploaded-id' } as Partial), + }) + 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() + expect(screen.getByText('docx')).toBeInTheDocument() + }) + + it('should handle files without extension', () => { + const fileItem = createMockFileItem({ + file: createMockFile({ name: 'README' }), + }) + render() + + // File name appears once, and extension area shows empty string + expect(screen.getByText('README')).toBeInTheDocument() + }) + + it('should handle various file extensions', () => { + const extensions = ['txt', 'md', 'json', 'csv', 'xlsx'] + + extensions.forEach((ext) => { + const fileItem = createMockFileItem({ + file: createMockFile({ name: `file.${ext}` }), + }) + const { unmount } = render() + expect(screen.getByText(ext)).toBeInTheDocument() + unmount() + }) + }) + }) + + describe('file size display', () => { + it('should display size in KB for small files', () => { + const fileItem = createMockFileItem({ + file: createMockFile({ size: 5 * 1024 }), + }) + render() + expect(screen.getByText('5.00 KB')).toBeInTheDocument() + }) + + it('should display size in MB for larger files', () => { + const fileItem = createMockFileItem({ + file: createMockFile({ size: 5 * 1024 * 1024 }), + }) + render() + expect(screen.getByText('5.00 MB')).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/create/file-uploader/components/file-list-item.tsx b/web/app/components/datasets/create/file-uploader/components/file-list-item.tsx new file mode 100644 index 0000000000..d36773fa5c --- /dev/null +++ b/web/app/components/datasets/create/file-uploader/components/file-list-item.tsx @@ -0,0 +1,89 @@ +'use client' +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 useTheme from '@/hooks/use-theme' +import { Theme } from '@/types/app' +import { formatFileSize, getFileExtension } from '@/utils/format' +import { PROGRESS_COMPLETE, PROGRESS_ERROR } from '../constants' + +const SimplePieChart = dynamic(() => import('@/app/components/base/simple-pie-chart'), { ssr: false }) + +export type FileListItemProps = { + fileItem: FileItem + onPreview: (file: File) => void + onRemove: (fileID: string) => void +} + +const FileListItem = ({ + fileItem, + onPreview, + onRemove, +}: FileListItemProps) => { + const { theme } = useTheme() + const chartColor = useMemo(() => theme === Theme.dark ? '#5289ff' : '#296dff', [theme]) + + const isUploading = fileItem.progress >= 0 && fileItem.progress < PROGRESS_COMPLETE + const isError = fileItem.progress === PROGRESS_ERROR + + const handleClick = () => { + if (fileItem.file?.id) + onPreview(fileItem.file) + } + + const handleRemove = (e: React.MouseEvent) => { + e.stopPropagation() + onRemove(fileItem.fileID) + } + + return ( +
+
+ +
+
+
+
+ {fileItem.file.name} +
+
+
+ {getFileExtension(fileItem.file.name)} + ยท + {formatFileSize(fileItem.file.size)} +
+
+
+ {isUploading && ( + + )} + {isError && ( + + )} + + + +
+
+ ) +} + +export default FileListItem diff --git a/web/app/components/datasets/create/file-uploader/components/upload-dropzone.spec.tsx b/web/app/components/datasets/create/file-uploader/components/upload-dropzone.spec.tsx new file mode 100644 index 0000000000..112d61250b --- /dev/null +++ b/web/app/components/datasets/create/file-uploader/components/upload-dropzone.spec.tsx @@ -0,0 +1,210 @@ +import type { RefObject } from 'react' +import type { UploadDropzoneProps } from './upload-dropzone' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import UploadDropzone from './upload-dropzone' + +// Helper to create mock ref objects for testing +const createMockRef = (value: T | null = null): RefObject => ({ current: value }) + +// Mock react-i18next +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => { + 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(), + } + + 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/create/file-uploader/components/upload-dropzone.tsx b/web/app/components/datasets/create/file-uploader/components/upload-dropzone.tsx new file mode 100644 index 0000000000..9fa577dace --- /dev/null +++ b/web/app/components/datasets/create/file-uploader/components/upload-dropzone.tsx @@ -0,0 +1,84 @@ +'use client' +import type { RefObject } from 'react' +import type { FileUploadConfig } from '../hooks/use-file-upload' +import { RiUploadCloud2Line } from '@remixicon/react' +import { useTranslation } from 'react-i18next' +import { cn } from '@/utils/classnames' + +export type UploadDropzoneProps = { + dropRef: RefObject + dragRef: RefObject + fileUploaderRef: RefObject + dragging: boolean + supportBatchUpload: boolean + supportTypesShowNames: string + fileUploadConfig: FileUploadConfig + acceptTypes: string[] + onSelectFile: () => void + onFileChange: (e: React.ChangeEvent) => void +} + +const UploadDropzone = ({ + dropRef, + dragRef, + fileUploaderRef, + dragging, + supportBatchUpload, + supportTypesShowNames, + fileUploadConfig, + acceptTypes, + onSelectFile, + onFileChange, +}: UploadDropzoneProps) => { + const { t } = useTranslation() + + return ( + <> + +
+
+ + + {supportBatchUpload + ? t('stepOne.uploader.button', { ns: 'datasetCreation' }) + : t('stepOne.uploader.buttonSingleFile', { ns: 'datasetCreation' })} + {acceptTypes.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/create/file-uploader/constants.ts b/web/app/components/datasets/create/file-uploader/constants.ts new file mode 100644 index 0000000000..cda2dae868 --- /dev/null +++ b/web/app/components/datasets/create/file-uploader/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/create/file-uploader/hooks/use-file-upload.spec.tsx b/web/app/components/datasets/create/file-uploader/hooks/use-file-upload.spec.tsx new file mode 100644 index 0000000000..222f038c84 --- /dev/null +++ b/web/app/components/datasets/create/file-uploader/hooks/use-file-upload.spec.tsx @@ -0,0 +1,921 @@ +import type { ReactNode } from 'react' +import type { CustomFile, FileItem } from '@/models/datasets' +import { act, render, renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ToastContext } from '@/app/components/base/toast' + +import { PROGRESS_COMPLETE, PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../constants' +// Import after mocks +import { useFileUpload } from './use-file-upload' + +// Mock notify function +const mockNotify = vi.fn() +const mockClose = vi.fn() + +// Mock ToastContext +vi.mock('use-context-selector', async () => { + const actual = await vi.importActual('use-context-selector') + return { + ...actual, + useContext: vi.fn(() => ({ notify: mockNotify, close: mockClose })), + } +}) + +// Mock upload service +const mockUpload = vi.fn() +vi.mock('@/service/base', () => ({ + upload: (...args: unknown[]) => mockUpload(...args), +})) + +// Mock file upload config +const mockFileUploadConfig = { + file_size_limit: 15, + batch_count_limit: 5, + file_upload_limit: 10, +} + +const mockSupportTypes = { + allowed_extensions: ['pdf', 'docx', 'txt', 'md'], +} + +vi.mock('@/service/use-common', () => ({ + useFileUploadConfig: () => ({ data: mockFileUploadConfig }), + useFileSupportTypes: () => ({ data: mockSupportTypes }), +})) + +// Mock i18n +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +// Mock locale +vi.mock('@/context/i18n', () => ({ + useLocale: () => 'en-US', +})) + +vi.mock('@/i18n-config/language', () => ({ + LanguagesSupported: ['en-US', 'zh-Hans'], +})) + +// Mock config +vi.mock('@/config', () => ({ + IS_CE_EDITION: false, +})) + +// Mock file upload error message +vi.mock('@/app/components/base/file-uploader/utils', () => ({ + getFileUploadErrorMessage: (_e: unknown, defaultMsg: string) => defaultMsg, +})) + +const createWrapper = () => { + return ({ children }: { children: ReactNode }) => ( + + {children} + + ) +} + +describe('useFileUpload', () => { + const defaultOptions = { + fileList: [] as FileItem[], + prepareFileList: vi.fn(), + onFileUpdate: vi.fn(), + onFileListUpdate: vi.fn(), + onPreview: vi.fn(), + supportBatchUpload: true, + } + + beforeEach(() => { + vi.clearAllMocks() + mockUpload.mockReset() + // Default mock to return a resolved promise to avoid unhandled rejections + mockUpload.mockResolvedValue({ id: 'default-id' }) + mockNotify.mockReset() + }) + + describe('initialization', () => { + it('should initialize with default values', () => { + const { result } = renderHook( + () => useFileUpload(defaultOptions), + { wrapper: createWrapper() }, + ) + + expect(result.current.dragging).toBe(false) + expect(result.current.hideUpload).toBe(false) + expect(result.current.dropRef.current).toBeNull() + expect(result.current.dragRef.current).toBeNull() + expect(result.current.fileUploaderRef.current).toBeNull() + }) + + it('should set hideUpload true when not batch upload and has files', () => { + const { result } = renderHook( + () => useFileUpload({ + ...defaultOptions, + supportBatchUpload: false, + fileList: [{ fileID: 'file-1', file: {} as CustomFile, progress: 100 }], + }), + { wrapper: createWrapper() }, + ) + + expect(result.current.hideUpload).toBe(true) + }) + + it('should compute acceptTypes correctly', () => { + const { result } = renderHook( + () => useFileUpload(defaultOptions), + { wrapper: createWrapper() }, + ) + + expect(result.current.acceptTypes).toEqual(['.pdf', '.docx', '.txt', '.md']) + }) + + it('should compute supportTypesShowNames correctly', () => { + const { result } = renderHook( + () => useFileUpload(defaultOptions), + { wrapper: createWrapper() }, + ) + + expect(result.current.supportTypesShowNames).toContain('PDF') + expect(result.current.supportTypesShowNames).toContain('DOCX') + expect(result.current.supportTypesShowNames).toContain('TXT') + // 'md' is mapped to 'markdown' in the extensionMap + expect(result.current.supportTypesShowNames).toContain('MARKDOWN') + }) + + it('should set batch limit to 1 when not batch upload', () => { + const { result } = renderHook( + () => useFileUpload({ + ...defaultOptions, + supportBatchUpload: false, + }), + { wrapper: createWrapper() }, + ) + + expect(result.current.fileUploadConfig.batch_count_limit).toBe(1) + expect(result.current.fileUploadConfig.file_upload_limit).toBe(1) + }) + }) + + describe('selectHandle', () => { + it('should trigger click on file input', () => { + const { result } = renderHook( + () => useFileUpload(defaultOptions), + { wrapper: createWrapper() }, + ) + + const mockClick = vi.fn() + const mockInput = { click: mockClick } as unknown as HTMLInputElement + Object.defineProperty(result.current.fileUploaderRef, 'current', { + value: mockInput, + writable: true, + }) + + act(() => { + result.current.selectHandle() + }) + + expect(mockClick).toHaveBeenCalled() + }) + + it('should do nothing when file input ref is null', () => { + const { result } = renderHook( + () => useFileUpload(defaultOptions), + { wrapper: createWrapper() }, + ) + + expect(() => { + act(() => { + result.current.selectHandle() + }) + }).not.toThrow() + }) + }) + + describe('handlePreview', () => { + it('should call onPreview when file has id', () => { + const onPreview = vi.fn() + const { result } = renderHook( + () => useFileUpload({ ...defaultOptions, onPreview }), + { wrapper: createWrapper() }, + ) + + const mockFile = { id: 'file-123', name: 'test.pdf', size: 1024 } as CustomFile + + act(() => { + result.current.handlePreview(mockFile) + }) + + expect(onPreview).toHaveBeenCalledWith(mockFile) + }) + + it('should not call onPreview when file has no id', () => { + const onPreview = vi.fn() + const { result } = renderHook( + () => useFileUpload({ ...defaultOptions, onPreview }), + { wrapper: createWrapper() }, + ) + + const mockFile = { name: 'test.pdf', size: 1024 } as CustomFile + + act(() => { + result.current.handlePreview(mockFile) + }) + + expect(onPreview).not.toHaveBeenCalled() + }) + }) + + describe('removeFile', () => { + it('should call onFileListUpdate with filtered list', () => { + const onFileListUpdate = vi.fn() + const { result } = renderHook( + () => useFileUpload({ ...defaultOptions, onFileListUpdate }), + { wrapper: createWrapper() }, + ) + + act(() => { + result.current.removeFile('file-to-remove') + }) + + expect(onFileListUpdate).toHaveBeenCalled() + }) + + it('should clear file input value', () => { + const { result } = renderHook( + () => useFileUpload(defaultOptions), + { wrapper: createWrapper() }, + ) + + const mockInput = { value: 'some-file' } as HTMLInputElement + Object.defineProperty(result.current.fileUploaderRef, 'current', { + value: mockInput, + writable: true, + }) + + act(() => { + result.current.removeFile('file-123') + }) + + expect(mockInput.value).toBe('') + }) + }) + + describe('fileChangeHandle', () => { + it('should handle valid files', async () => { + mockUpload.mockResolvedValue({ id: 'uploaded-id' }) + + const prepareFileList = vi.fn() + const { result } = renderHook( + () => useFileUpload({ ...defaultOptions, prepareFileList }), + { wrapper: createWrapper() }, + ) + + const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' }) + const event = { + target: { files: [mockFile] }, + } as unknown as React.ChangeEvent + + act(() => { + result.current.fileChangeHandle(event) + }) + + await waitFor(() => { + expect(prepareFileList).toHaveBeenCalled() + }) + }) + + it('should limit files to batch count', () => { + const prepareFileList = vi.fn() + const { result } = renderHook( + () => useFileUpload({ ...defaultOptions, prepareFileList }), + { wrapper: createWrapper() }, + ) + + const files = Array.from({ length: 10 }, (_, i) => + new File(['content'], `file${i}.pdf`, { type: 'application/pdf' })) + + const event = { + target: { files }, + } as unknown as React.ChangeEvent + + act(() => { + result.current.fileChangeHandle(event) + }) + + // Should be called with at most batch_count_limit files + if (prepareFileList.mock.calls.length > 0) { + const calledFiles = prepareFileList.mock.calls[0][0] + expect(calledFiles.length).toBeLessThanOrEqual(mockFileUploadConfig.batch_count_limit) + } + }) + + it('should reject invalid file types', () => { + const { result } = renderHook( + () => useFileUpload(defaultOptions), + { wrapper: createWrapper() }, + ) + + const mockFile = new File(['content'], 'test.exe', { type: 'application/x-msdownload' }) + const event = { + target: { files: [mockFile] }, + } as unknown as React.ChangeEvent + + act(() => { + result.current.fileChangeHandle(event) + }) + + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + }) + + it('should reject files exceeding size limit', () => { + const { result } = renderHook( + () => useFileUpload(defaultOptions), + { wrapper: createWrapper() }, + ) + + // Create a file larger than the limit (15MB) + const largeFile = new File([new ArrayBuffer(20 * 1024 * 1024)], 'large.pdf', { type: 'application/pdf' }) + + const event = { + target: { files: [largeFile] }, + } as unknown as React.ChangeEvent + + act(() => { + result.current.fileChangeHandle(event) + }) + + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + }) + + it('should handle null files', () => { + const prepareFileList = vi.fn() + const { result } = renderHook( + () => useFileUpload({ ...defaultOptions, prepareFileList }), + { wrapper: createWrapper() }, + ) + + const event = { + target: { files: null }, + } as unknown as React.ChangeEvent + + act(() => { + result.current.fileChangeHandle(event) + }) + + expect(prepareFileList).not.toHaveBeenCalled() + }) + }) + + describe('drag and drop handlers', () => { + const TestDropzone = ({ options }: { options: typeof defaultOptions }) => { + const { + dropRef, + dragRef, + dragging, + } = useFileUpload(options) + + return ( +
+
+ {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) + }) + + 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') + + await act(async () => { + const dragEnterEvent = new Event('dragenter', { bubbles: true, cancelable: true }) + dropzone.dispatchEvent(dragEnterEvent) + }) + + expect(getByTestId('dragging').textContent).toBe('true') + + const dragOverlay = queryByTestId('drag-overlay') + if (dragOverlay) { + await act(async () => { + const dragLeaveEvent = new Event('dragleave', { bubbles: true, cancelable: true }) + Object.defineProperty(dragLeaveEvent, 'target', { value: dragOverlay }) + dropzone.dispatchEvent(dragLeaveEvent) + }) + } + }) + + it('should handle drop with files', async () => { + mockUpload.mockResolvedValue({ id: 'uploaded-id' }) + const prepareFileList = vi.fn() + + const { getByTestId } = await act(async () => + render( + + + , + ), + ) + + const dropzone = getByTestId('dropzone') + const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' }) + + await act(async () => { + const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) as Event & { dataTransfer: DataTransfer | null } + Object.defineProperty(dropEvent, 'dataTransfer', { + value: { + items: [{ + getAsFile: () => mockFile, + webkitGetAsEntry: () => null, + }], + }, + }) + dropzone.dispatchEvent(dropEvent) + }) + + await waitFor(() => { + expect(prepareFileList).toHaveBeenCalled() + }) + }) + + it('should handle drop without dataTransfer', async () => { + const prepareFileList = vi.fn() + + const { getByTestId } = await act(async () => + render( + + + , + ), + ) + + const dropzone = getByTestId('dropzone') + + await act(async () => { + const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) as Event & { dataTransfer: DataTransfer | null } + Object.defineProperty(dropEvent, 'dataTransfer', { value: null }) + dropzone.dispatchEvent(dropEvent) + }) + + expect(prepareFileList).not.toHaveBeenCalled() + }) + + it('should limit to single file on drop when supportBatchUpload is false', async () => { + mockUpload.mockResolvedValue({ id: 'uploaded-id' }) + const prepareFileList = vi.fn() + + const { getByTestId } = await act(async () => + render( + + + , + ), + ) + + const dropzone = getByTestId('dropzone') + const files = [ + new File(['content1'], 'test1.pdf', { type: 'application/pdf' }), + new File(['content2'], 'test2.pdf', { type: 'application/pdf' }), + ] + + await act(async () => { + const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) as Event & { dataTransfer: DataTransfer | null } + Object.defineProperty(dropEvent, 'dataTransfer', { + value: { + items: files.map(f => ({ + getAsFile: () => f, + webkitGetAsEntry: () => null, + })), + }, + }) + dropzone.dispatchEvent(dropEvent) + }) + + await waitFor(() => { + if (prepareFileList.mock.calls.length > 0) { + const calledFiles = prepareFileList.mock.calls[0][0] + expect(calledFiles.length).toBe(1) + } + }) + }) + + it('should handle drop with FileSystemFileEntry', async () => { + mockUpload.mockResolvedValue({ id: 'uploaded-id' }) + const prepareFileList = vi.fn() + const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' }) + + const { getByTestId } = await act(async () => + render( + + + , + ), + ) + + const dropzone = getByTestId('dropzone') + + await act(async () => { + const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) as Event & { dataTransfer: DataTransfer | null } + Object.defineProperty(dropEvent, 'dataTransfer', { + value: { + items: [{ + getAsFile: () => mockFile, + webkitGetAsEntry: () => ({ + isFile: true, + isDirectory: false, + file: (callback: (file: File) => void) => callback(mockFile), + }), + }], + }, + }) + dropzone.dispatchEvent(dropEvent) + }) + + await waitFor(() => { + expect(prepareFileList).toHaveBeenCalled() + }) + }) + + it('should handle drop with FileSystemDirectoryEntry', async () => { + mockUpload.mockResolvedValue({ id: 'uploaded-id' }) + const prepareFileList = vi.fn() + const mockFile = new File(['content'], 'nested.pdf', { type: 'application/pdf' }) + + const { getByTestId } = await act(async () => + render( + + + , + ), + ) + + const dropzone = getByTestId('dropzone') + + await act(async () => { + let callCount = 0 + const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) as Event & { dataTransfer: DataTransfer | null } + Object.defineProperty(dropEvent, 'dataTransfer', { + value: { + items: [{ + getAsFile: () => null, + webkitGetAsEntry: () => ({ + isFile: false, + isDirectory: true, + name: 'folder', + createReader: () => ({ + readEntries: (callback: (entries: Array<{ isFile: boolean, isDirectory: boolean, name?: string, file?: (cb: (f: File) => void) => void }>) => void) => { + // First call returns file entry, second call returns empty (signals end) + if (callCount === 0) { + callCount++ + callback([{ + isFile: true, + isDirectory: false, + name: 'nested.pdf', + file: (cb: (f: File) => void) => cb(mockFile), + }]) + } + else { + callback([]) + } + }, + }), + }), + }], + }, + }) + dropzone.dispatchEvent(dropEvent) + }) + + await waitFor(() => { + expect(prepareFileList).toHaveBeenCalled() + }) + }) + + it('should handle drop with empty directory', async () => { + const prepareFileList = vi.fn() + + const { getByTestId } = await act(async () => + render( + + + , + ), + ) + + const dropzone = getByTestId('dropzone') + + await act(async () => { + const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) as Event & { dataTransfer: DataTransfer | null } + Object.defineProperty(dropEvent, 'dataTransfer', { + value: { + items: [{ + getAsFile: () => null, + webkitGetAsEntry: () => ({ + isFile: false, + isDirectory: true, + name: 'empty-folder', + createReader: () => ({ + readEntries: (callback: (entries: never[]) => void) => { + callback([]) + }, + }), + }), + }], + }, + }) + dropzone.dispatchEvent(dropEvent) + }) + + // Should not prepare file list if no valid files + await new Promise(resolve => setTimeout(resolve, 100)) + }) + + it('should handle entry that is neither file nor directory', async () => { + const prepareFileList = vi.fn() + + const { getByTestId } = await act(async () => + render( + + + , + ), + ) + + const dropzone = getByTestId('dropzone') + + await act(async () => { + const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) as Event & { dataTransfer: DataTransfer | null } + Object.defineProperty(dropEvent, 'dataTransfer', { + value: { + items: [{ + getAsFile: () => null, + webkitGetAsEntry: () => ({ + isFile: false, + isDirectory: false, + }), + }], + }, + }) + dropzone.dispatchEvent(dropEvent) + }) + + // Should not throw and should handle gracefully + await new Promise(resolve => setTimeout(resolve, 100)) + }) + }) + + describe('file upload', () => { + it('should call upload with correct parameters', async () => { + mockUpload.mockResolvedValue({ id: 'uploaded-id', name: 'test.pdf' }) + const onFileUpdate = vi.fn() + + const { result } = renderHook( + () => useFileUpload({ ...defaultOptions, onFileUpdate }), + { wrapper: createWrapper() }, + ) + + const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' }) + const event = { + target: { files: [mockFile] }, + } as unknown as React.ChangeEvent + + act(() => { + result.current.fileChangeHandle(event) + }) + + await waitFor(() => { + expect(mockUpload).toHaveBeenCalled() + }) + }) + + it('should update progress during upload', async () => { + let progressCallback: ((e: ProgressEvent) => void) | undefined + + mockUpload.mockImplementation(async (options: { onprogress: (e: ProgressEvent) => void }) => { + progressCallback = options.onprogress + return { id: 'uploaded-id' } + }) + + const onFileUpdate = vi.fn() + + const { result } = renderHook( + () => useFileUpload({ ...defaultOptions, onFileUpdate }), + { wrapper: createWrapper() }, + ) + + const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' }) + const event = { + target: { files: [mockFile] }, + } as unknown as React.ChangeEvent + + act(() => { + result.current.fileChangeHandle(event) + }) + + await waitFor(() => { + expect(mockUpload).toHaveBeenCalled() + }) + + if (progressCallback) { + act(() => { + progressCallback!({ + lengthComputable: true, + loaded: 50, + total: 100, + } as ProgressEvent) + }) + + expect(onFileUpdate).toHaveBeenCalled() + } + }) + + it('should handle upload error', async () => { + mockUpload.mockRejectedValue(new Error('Upload failed')) + const onFileUpdate = vi.fn() + + const { result } = renderHook( + () => useFileUpload({ ...defaultOptions, onFileUpdate }), + { wrapper: createWrapper() }, + ) + + const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' }) + const event = { + target: { files: [mockFile] }, + } as unknown as React.ChangeEvent + + act(() => { + result.current.fileChangeHandle(event) + }) + + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + }) + }) + + it('should update file with PROGRESS_COMPLETE on success', async () => { + mockUpload.mockResolvedValue({ id: 'uploaded-id', name: 'test.pdf' }) + const onFileUpdate = vi.fn() + + const { result } = renderHook( + () => useFileUpload({ ...defaultOptions, onFileUpdate }), + { wrapper: createWrapper() }, + ) + + const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' }) + const event = { + target: { files: [mockFile] }, + } as unknown as React.ChangeEvent + + act(() => { + result.current.fileChangeHandle(event) + }) + + await waitFor(() => { + const completeCalls = onFileUpdate.mock.calls.filter( + ([, progress]) => progress === PROGRESS_COMPLETE, + ) + expect(completeCalls.length).toBeGreaterThan(0) + }) + }) + + it('should update file with PROGRESS_ERROR on failure', async () => { + mockUpload.mockRejectedValue(new Error('Upload failed')) + const onFileUpdate = vi.fn() + + const { result } = renderHook( + () => useFileUpload({ ...defaultOptions, onFileUpdate }), + { wrapper: createWrapper() }, + ) + + const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' }) + const event = { + target: { files: [mockFile] }, + } as unknown as React.ChangeEvent + + act(() => { + result.current.fileChangeHandle(event) + }) + + await waitFor(() => { + const errorCalls = onFileUpdate.mock.calls.filter( + ([, progress]) => progress === PROGRESS_ERROR, + ) + expect(errorCalls.length).toBeGreaterThan(0) + }) + }) + }) + + describe('file count validation', () => { + it('should reject when total files exceed limit', () => { + const existingFiles: FileItem[] = Array.from({ length: 8 }, (_, i) => ({ + fileID: `existing-${i}`, + file: { name: `existing-${i}.pdf`, size: 1024 } as CustomFile, + progress: 100, + })) + + const { result } = renderHook( + () => useFileUpload({ + ...defaultOptions, + fileList: existingFiles, + }), + { wrapper: createWrapper() }, + ) + + const files = Array.from({ length: 5 }, (_, i) => + new File(['content'], `new-${i}.pdf`, { type: 'application/pdf' })) + + const event = { + target: { files }, + } as unknown as React.ChangeEvent + + act(() => { + result.current.fileChangeHandle(event) + }) + + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + }) + }) + + describe('progress constants', () => { + it('should use PROGRESS_NOT_STARTED for new files', async () => { + mockUpload.mockResolvedValue({ id: 'file-id' }) + + const prepareFileList = vi.fn() + const { result } = renderHook( + () => useFileUpload({ ...defaultOptions, prepareFileList }), + { wrapper: createWrapper() }, + ) + + const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' }) + const event = { + target: { files: [mockFile] }, + } as unknown as React.ChangeEvent + + act(() => { + result.current.fileChangeHandle(event) + }) + + await waitFor(() => { + if (prepareFileList.mock.calls.length > 0) { + const files = prepareFileList.mock.calls[0][0] + expect(files[0].progress).toBe(PROGRESS_NOT_STARTED) + } + }) + }) + }) +}) diff --git a/web/app/components/datasets/create/file-uploader/hooks/use-file-upload.ts b/web/app/components/datasets/create/file-uploader/hooks/use-file-upload.ts new file mode 100644 index 0000000000..e097bab755 --- /dev/null +++ b/web/app/components/datasets/create/file-uploader/hooks/use-file-upload.ts @@ -0,0 +1,351 @@ +'use client' +import type { RefObject } from 'react' +import type { CustomFile as File, FileItem } from '@/models/datasets' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useContext } from 'use-context-selector' +import { getFileUploadErrorMessage } from '@/app/components/base/file-uploader/utils' +import { ToastContext } from '@/app/components/base/toast' +import { IS_CE_EDITION } from '@/config' +import { useLocale } from '@/context/i18n' +import { LanguagesSupported } from '@/i18n-config/language' +import { upload } from '@/service/base' +import { useFileSupportTypes, useFileUploadConfig } from '@/service/use-common' +import { getFileExtension } from '@/utils/format' +import { PROGRESS_COMPLETE, PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../constants' + +export type FileUploadConfig = { + file_size_limit: number + batch_count_limit: number + file_upload_limit: number +} + +export type UseFileUploadOptions = { + fileList: FileItem[] + prepareFileList: (files: FileItem[]) => void + onFileUpdate: (fileItem: FileItem, progress: number, list: FileItem[]) => void + onFileListUpdate?: (files: FileItem[]) => void + onPreview: (file: File) => void + supportBatchUpload?: boolean + /** + * Optional list of allowed file extensions. If not provided, fetches from API. + * Pass this when you need custom extension filtering instead of using the global config. + */ + allowedExtensions?: string[] +} + +export type UseFileUploadReturn = { + // Refs + dropRef: RefObject + dragRef: RefObject + fileUploaderRef: RefObject + + // State + dragging: boolean + + // Config + fileUploadConfig: FileUploadConfig + acceptTypes: string[] + supportTypesShowNames: string + hideUpload: boolean + + // Handlers + selectHandle: () => void + fileChangeHandle: (e: React.ChangeEvent) => void + removeFile: (fileID: string) => void + handlePreview: (file: File) => void +} + +type FileWithPath = { + relativePath?: string +} & File + +export const useFileUpload = ({ + fileList, + prepareFileList, + onFileUpdate, + onFileListUpdate, + onPreview, + supportBatchUpload = false, + allowedExtensions, +}: UseFileUploadOptions): UseFileUploadReturn => { + const { t } = useTranslation() + const { notify } = useContext(ToastContext) + const locale = useLocale() + + const [dragging, setDragging] = useState(false) + const dropRef = useRef(null) + const dragRef = useRef(null) + const fileUploaderRef = useRef(null) + const fileListRef = useRef([]) + + const hideUpload = !supportBatchUpload && fileList.length > 0 + + const { data: fileUploadConfigResponse } = useFileUploadConfig() + const { data: supportFileTypesResponse } = useFileSupportTypes() + // Use provided allowedExtensions or fetch from API + const supportTypes = useMemo( + () => allowedExtensions ?? supportFileTypesResponse?.allowed_extensions ?? [], + [allowedExtensions, supportFileTypesResponse?.allowed_extensions], + ) + + const supportTypesShowNames = useMemo(() => { + const extensionMap: { [key: string]: string } = { + md: 'markdown', + pptx: 'pptx', + htm: 'html', + xlsx: 'xlsx', + docx: 'docx', + } + + return [...supportTypes] + .map(item => extensionMap[item] || item) + .map(item => item.toLowerCase()) + .filter((item, index, self) => self.indexOf(item) === index) + .map(item => item.toUpperCase()) + .join(locale !== LanguagesSupported[1] ? ', ' : 'ใ€ ') + }, [supportTypes, locale]) + + const acceptTypes = useMemo(() => supportTypes.map((ext: string) => `.${ext}`), [supportTypes]) + + const fileUploadConfig = useMemo(() => ({ + file_size_limit: fileUploadConfigResponse?.file_size_limit ?? 15, + batch_count_limit: supportBatchUpload ? (fileUploadConfigResponse?.batch_count_limit ?? 5) : 1, + file_upload_limit: supportBatchUpload ? (fileUploadConfigResponse?.file_upload_limit ?? 5) : 1, + }), [fileUploadConfigResponse, supportBatchUpload]) + + const isValid = useCallback((file: File) => { + const { size } = file + const ext = `.${getFileExtension(file.name)}` + const isValidType = acceptTypes.includes(ext.toLowerCase()) + if (!isValidType) + notify({ type: 'error', message: t('stepOne.uploader.validation.typeError', { ns: 'datasetCreation' }) }) + + const isValidSize = size <= fileUploadConfig.file_size_limit * 1024 * 1024 + if (!isValidSize) + notify({ type: 'error', message: t('stepOne.uploader.validation.size', { ns: 'datasetCreation', size: fileUploadConfig.file_size_limit }) }) + + return isValidType && isValidSize + }, [fileUploadConfig, notify, t, acceptTypes]) + + const fileUpload = useCallback(async (fileItem: FileItem): Promise => { + const formData = new FormData() + formData.append('file', fileItem.file) + const onProgress = (e: ProgressEvent) => { + if (e.lengthComputable) { + const percent = Math.floor(e.loaded / e.total * 100) + onFileUpdate(fileItem, percent, fileListRef.current) + } + } + + return upload({ + xhr: new XMLHttpRequest(), + data: formData, + onprogress: onProgress, + }, false, undefined, '?source=datasets') + .then((res) => { + const completeFile = { + fileID: fileItem.fileID, + file: res as unknown as File, + progress: PROGRESS_NOT_STARTED, + } + const index = fileListRef.current.findIndex(item => item.fileID === fileItem.fileID) + fileListRef.current[index] = completeFile + onFileUpdate(completeFile, PROGRESS_COMPLETE, fileListRef.current) + return Promise.resolve({ ...completeFile }) + }) + .catch((e) => { + const errorMessage = getFileUploadErrorMessage(e, t('stepOne.uploader.failed', { ns: 'datasetCreation' }), t) + notify({ type: 'error', message: errorMessage }) + onFileUpdate(fileItem, PROGRESS_ERROR, fileListRef.current) + return Promise.resolve({ ...fileItem }) + }) + .finally() + }, [notify, onFileUpdate, t]) + + const uploadBatchFiles = useCallback((bFiles: FileItem[]) => { + bFiles.forEach(bf => (bf.progress = 0)) + return Promise.all(bFiles.map(fileUpload)) + }, [fileUpload]) + + const uploadMultipleFiles = useCallback(async (files: FileItem[]) => { + const batchCountLimit = fileUploadConfig.batch_count_limit + const length = files.length + let start = 0 + let end = 0 + + while (start < length) { + if (start + batchCountLimit > length) + end = length + else + end = start + batchCountLimit + const bFiles = files.slice(start, end) + await uploadBatchFiles(bFiles) + start = end + } + }, [fileUploadConfig, uploadBatchFiles]) + + const initialUpload = useCallback((files: File[]) => { + const filesCountLimit = fileUploadConfig.file_upload_limit + if (!files.length) + return false + + if (files.length + fileList.length > filesCountLimit && !IS_CE_EDITION) { + notify({ type: 'error', message: t('stepOne.uploader.validation.filesNumber', { ns: 'datasetCreation', filesNumber: filesCountLimit }) }) + return false + } + + const preparedFiles = files.map((file, index) => ({ + fileID: `file${index}-${Date.now()}`, + file, + progress: PROGRESS_NOT_STARTED, + })) + const newFiles = [...fileListRef.current, ...preparedFiles] + prepareFileList(newFiles) + fileListRef.current = newFiles + uploadMultipleFiles(preparedFiles) + }, [prepareFileList, uploadMultipleFiles, notify, t, fileList, fileUploadConfig]) + + const traverseFileEntry = useCallback( + (entry: FileSystemEntry, prefix = ''): Promise => { + return new Promise((resolve) => { + if (entry.isFile) { + (entry as FileSystemFileEntry).file((file: FileWithPath) => { + file.relativePath = `${prefix}${file.name}` + resolve([file]) + }) + } + else if (entry.isDirectory) { + const reader = (entry as FileSystemDirectoryEntry).createReader() + const entries: FileSystemEntry[] = [] + const read = () => { + reader.readEntries(async (results: FileSystemEntry[]) => { + if (!results.length) { + const files = await Promise.all( + entries.map(ent => + traverseFileEntry(ent, `${prefix}${entry.name}/`), + ), + ) + resolve(files.flat()) + } + else { + entries.push(...results) + read() + } + }) + } + read() + } + else { + resolve([]) + } + }) + }, + [], + ) + + const handleDragEnter = useCallback((e: DragEvent) => { + e.preventDefault() + e.stopPropagation() + if (e.target !== dragRef.current) + setDragging(true) + }, []) + + const handleDragOver = useCallback((e: DragEvent) => { + e.preventDefault() + e.stopPropagation() + }, []) + + const handleDragLeave = useCallback((e: DragEvent) => { + e.preventDefault() + e.stopPropagation() + if (e.target === dragRef.current) + setDragging(false) + }, []) + + const handleDrop = useCallback( + async (e: DragEvent) => { + e.preventDefault() + e.stopPropagation() + setDragging(false) + if (!e.dataTransfer) + return + const nested = await Promise.all( + Array.from(e.dataTransfer.items).map((it) => { + const entry = (it as DataTransferItem & { webkitGetAsEntry?: () => FileSystemEntry | null }).webkitGetAsEntry?.() + if (entry) + return traverseFileEntry(entry) + const f = it.getAsFile?.() + return f ? Promise.resolve([f as FileWithPath]) : Promise.resolve([]) + }), + ) + let files = nested.flat() + if (!supportBatchUpload) + files = files.slice(0, 1) + files = files.slice(0, fileUploadConfig.batch_count_limit) + const valid = files.filter(isValid) + initialUpload(valid) + }, + [initialUpload, isValid, supportBatchUpload, traverseFileEntry, fileUploadConfig], + ) + + const selectHandle = useCallback(() => { + if (fileUploaderRef.current) + fileUploaderRef.current.click() + }, []) + + const removeFile = useCallback((fileID: string) => { + if (fileUploaderRef.current) + fileUploaderRef.current.value = '' + + fileListRef.current = fileListRef.current.filter(item => item.fileID !== fileID) + onFileListUpdate?.([...fileListRef.current]) + }, [onFileListUpdate]) + + const fileChangeHandle = useCallback((e: React.ChangeEvent) => { + let files = Array.from(e.target.files ?? []) as File[] + files = files.slice(0, fileUploadConfig.batch_count_limit) + initialUpload(files.filter(isValid)) + }, [isValid, initialUpload, fileUploadConfig]) + + const handlePreview = useCallback((file: File) => { + if (file?.id) + onPreview(file) + }, [onPreview]) + + useEffect(() => { + const dropArea = dropRef.current + dropArea?.addEventListener('dragenter', handleDragEnter) + dropArea?.addEventListener('dragover', handleDragOver) + dropArea?.addEventListener('dragleave', handleDragLeave) + dropArea?.addEventListener('drop', handleDrop) + return () => { + dropArea?.removeEventListener('dragenter', handleDragEnter) + dropArea?.removeEventListener('dragover', handleDragOver) + dropArea?.removeEventListener('dragleave', handleDragLeave) + dropArea?.removeEventListener('drop', handleDrop) + } + }, [handleDragEnter, handleDragOver, handleDragLeave, handleDrop]) + + return { + // Refs + dropRef, + dragRef, + fileUploaderRef, + + // State + dragging, + + // Config + fileUploadConfig, + acceptTypes, + supportTypesShowNames, + hideUpload, + + // Handlers + selectHandle, + fileChangeHandle, + removeFile, + handlePreview, + } +} diff --git a/web/app/components/datasets/create/file-uploader/index.spec.tsx b/web/app/components/datasets/create/file-uploader/index.spec.tsx new file mode 100644 index 0000000000..91f65652f3 --- /dev/null +++ b/web/app/components/datasets/create/file-uploader/index.spec.tsx @@ -0,0 +1,278 @@ +import type { CustomFile as File, FileItem } from '@/models/datasets' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PROGRESS_NOT_STARTED } from './constants' +import FileUploader from './index' + +// Mock react-i18next +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + 'stepOne.uploader.title': 'Upload Files', + 'stepOne.uploader.button': 'Drag and drop files, or', + 'stepOne.uploader.buttonSingleFile': 'Drag and drop file, or', + 'stepOne.uploader.browse': 'Browse', + 'stepOne.uploader.tip': 'Supports various file types', + } + return translations[key] || key + }, + }), +})) + +// Mock ToastContext +const mockNotify = vi.fn() +vi.mock('use-context-selector', async () => { + const actual = await vi.importActual('use-context-selector') + return { + ...actual, + useContext: vi.fn(() => ({ notify: mockNotify })), + } +}) + +// Mock services +vi.mock('@/service/base', () => ({ + upload: vi.fn().mockResolvedValue({ id: 'uploaded-id' }), +})) + +vi.mock('@/service/use-common', () => ({ + useFileUploadConfig: () => ({ + data: { file_size_limit: 15, batch_count_limit: 5, file_upload_limit: 10 }, + }), + useFileSupportTypes: () => ({ + data: { allowed_extensions: ['pdf', 'docx', 'txt'] }, + }), +})) + +vi.mock('@/context/i18n', () => ({ + useLocale: () => 'en-US', +})) + +vi.mock('@/i18n-config/language', () => ({ + LanguagesSupported: ['en-US', 'zh-Hans'], +})) + +vi.mock('@/config', () => ({ + IS_CE_EDITION: false, +})) + +vi.mock('@/app/components/base/file-uploader/utils', () => ({ + getFileUploadErrorMessage: () => 'Upload error', +})) + +// Mock theme +vi.mock('@/hooks/use-theme', () => ({ + default: () => ({ theme: 'light' }), +})) + +vi.mock('@/types/app', () => ({ + Theme: { dark: 'dark', light: 'light' }, +})) + +// Mock DocumentFileIcon - uses relative path from file-list-item.tsx +vi.mock('@/app/components/datasets/common/document-file-icon', () => ({ + default: ({ extension }: { extension: string }) =>
{extension}
, +})) + +// Mock SimplePieChart +vi.mock('next/dynamic', () => ({ + default: () => { + const Component = ({ percentage }: { percentage: number }) => ( +
+ {percentage} + % +
+ ) + return Component + }, +})) + +describe('FileUploader', () => { + const createMockFile = (overrides: Partial = {}): File => ({ + name: 'test.pdf', + size: 1024, + type: 'application/pdf', + ...overrides, + } as File) + + const createMockFileItem = (overrides: Partial = {}): FileItem => ({ + fileID: `file-${Date.now()}`, + file: createMockFile(overrides.file as Partial), + progress: PROGRESS_NOT_STARTED, + ...overrides, + }) + + const defaultProps = { + fileList: [] as FileItem[], + prepareFileList: vi.fn(), + onFileUpdate: vi.fn(), + onFileListUpdate: vi.fn(), + onPreview: vi.fn(), + supportBatchUpload: true, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('rendering', () => { + it('should render the component', () => { + render() + expect(screen.getByText('Upload Files')).toBeInTheDocument() + }) + + it('should render dropzone when no files', () => { + render() + expect(screen.getByText(/Drag and drop files/i)).toBeInTheDocument() + }) + + it('should render browse button', () => { + render() + expect(screen.getByText('Browse')).toBeInTheDocument() + }) + + it('should apply custom title className', () => { + render() + const title = screen.getByText('Upload Files') + expect(title).toHaveClass('custom-class') + }) + }) + + describe('file list rendering', () => { + it('should render file items when fileList has items', () => { + const fileList = [ + createMockFileItem({ file: createMockFile({ name: 'file1.pdf' }) }), + createMockFileItem({ file: createMockFile({ name: 'file2.pdf' }) }), + ] + + render() + + expect(screen.getByText('file1.pdf')).toBeInTheDocument() + expect(screen.getByText('file2.pdf')).toBeInTheDocument() + }) + + it('should render document icons for files', () => { + const fileList = [createMockFileItem()] + render() + + expect(screen.getByTestId('document-icon')).toBeInTheDocument() + }) + }) + + describe('batch upload mode', () => { + it('should show dropzone with batch upload enabled', () => { + render() + expect(screen.getByText(/Drag and drop files/i)).toBeInTheDocument() + }) + + it('should show single file text when batch upload disabled', () => { + render() + expect(screen.getByText(/Drag and drop file/i)).toBeInTheDocument() + }) + + it('should hide dropzone when not batch upload and has files', () => { + const fileList = [createMockFileItem()] + render() + + expect(screen.queryByText(/Drag and drop/i)).not.toBeInTheDocument() + }) + }) + + describe('event handlers', () => { + it('should handle file preview click', () => { + const onPreview = vi.fn() + const fileItem = createMockFileItem({ + file: createMockFile({ id: 'file-id' } as Partial), + }) + + const { container } = render() + + // Find the file list item container by its class pattern + const fileElement = container.querySelector('[class*="flex h-12"]') + if (fileElement) + fireEvent.click(fileElement) + + expect(onPreview).toHaveBeenCalledWith(fileItem.file) + }) + + it('should handle file remove click', () => { + const onFileListUpdate = vi.fn() + const fileItem = createMockFileItem() + + const { container } = render( + , + ) + + // Find the delete button (the span with cursor-pointer containing the icon) + const deleteButtons = container.querySelectorAll('[class*="cursor-pointer"]') + // Get the last one which should be the delete button (not the browse label) + const deleteButton = deleteButtons[deleteButtons.length - 1] + if (deleteButton) + fireEvent.click(deleteButton) + + expect(onFileListUpdate).toHaveBeenCalled() + }) + + it('should handle browse button click', () => { + render() + + // The browse label should trigger file input click + const browseLabel = screen.getByText('Browse') + expect(browseLabel).toHaveClass('cursor-pointer') + }) + }) + + describe('upload progress', () => { + it('should show progress chart for uploading files', () => { + const fileItem = createMockFileItem({ progress: 50 }) + render() + + expect(screen.getByTestId('pie-chart')).toBeInTheDocument() + expect(screen.getByText('50%')).toBeInTheDocument() + }) + + it('should not show progress chart for completed files', () => { + const fileItem = createMockFileItem({ progress: 100 }) + render() + + expect(screen.queryByTestId('pie-chart')).not.toBeInTheDocument() + }) + + it('should not show progress chart for not started files', () => { + const fileItem = createMockFileItem({ progress: PROGRESS_NOT_STARTED }) + render() + + expect(screen.queryByTestId('pie-chart')).not.toBeInTheDocument() + }) + }) + + describe('multiple files', () => { + it('should render all files in the list', () => { + const fileList = [ + createMockFileItem({ fileID: 'f1', file: createMockFile({ name: 'doc1.pdf' }) }), + createMockFileItem({ fileID: 'f2', file: createMockFile({ name: 'doc2.docx' }) }), + createMockFileItem({ fileID: 'f3', file: createMockFile({ name: 'doc3.txt' }) }), + ] + + render() + + expect(screen.getByText('doc1.pdf')).toBeInTheDocument() + expect(screen.getByText('doc2.docx')).toBeInTheDocument() + expect(screen.getByText('doc3.txt')).toBeInTheDocument() + }) + }) + + describe('styling', () => { + it('should have correct container width', () => { + const { container } = render() + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('w-[640px]') + }) + + it('should have proper spacing', () => { + const { container } = render() + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('mb-5') + }) + }) +}) diff --git a/web/app/components/datasets/create/file-uploader/index.tsx b/web/app/components/datasets/create/file-uploader/index.tsx index 781b97200a..b649554a12 100644 --- a/web/app/components/datasets/create/file-uploader/index.tsx +++ b/web/app/components/datasets/create/file-uploader/index.tsx @@ -1,23 +1,10 @@ 'use client' import type { CustomFile as File, FileItem } from '@/models/datasets' -import { RiDeleteBinLine, RiUploadCloud2Line } from '@remixicon/react' -import * as React from 'react' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useContext } from 'use-context-selector' -import { getFileUploadErrorMessage } from '@/app/components/base/file-uploader/utils' -import SimplePieChart from '@/app/components/base/simple-pie-chart' -import { ToastContext } from '@/app/components/base/toast' -import { IS_CE_EDITION } from '@/config' - -import { useLocale } from '@/context/i18n' -import useTheme from '@/hooks/use-theme' -import { LanguagesSupported } from '@/i18n-config/language' -import { upload } from '@/service/base' -import { useFileSupportTypes, useFileUploadConfig } from '@/service/use-common' -import { Theme } from '@/types/app' import { cn } from '@/utils/classnames' -import DocumentFileIcon from '../../common/document-file-icon' +import FileListItem from './components/file-list-item' +import UploadDropzone from './components/upload-dropzone' +import { useFileUpload } from './hooks/use-file-upload' type IFileUploaderProps = { fileList: FileItem[] @@ -39,358 +26,62 @@ const FileUploader = ({ supportBatchUpload = false, }: IFileUploaderProps) => { const { t } = useTranslation() - const { notify } = useContext(ToastContext) - const locale = useLocale() - const [dragging, setDragging] = useState(false) - const dropRef = useRef(null) - const dragRef = useRef(null) - const fileUploader = useRef(null) - const hideUpload = !supportBatchUpload && fileList.length > 0 - const { data: fileUploadConfigResponse } = useFileUploadConfig() - const { data: supportFileTypesResponse } = useFileSupportTypes() - const supportTypes = supportFileTypesResponse?.allowed_extensions || [] - const supportTypesShowNames = (() => { - const extensionMap: { [key: string]: string } = { - md: 'markdown', - pptx: 'pptx', - htm: 'html', - xlsx: 'xlsx', - docx: 'docx', - } - - return [...supportTypes] - .map(item => extensionMap[item] || item) // map to standardized extension - .map(item => item.toLowerCase()) // convert to lower case - .filter((item, index, self) => self.indexOf(item) === index) // remove duplicates - .map(item => item.toUpperCase()) // convert to upper case - .join(locale !== LanguagesSupported[1] ? ', ' : 'ใ€ ') - })() - const ACCEPTS = supportTypes.map((ext: string) => `.${ext}`) - const fileUploadConfig = useMemo(() => ({ - file_size_limit: fileUploadConfigResponse?.file_size_limit ?? 15, - batch_count_limit: supportBatchUpload ? (fileUploadConfigResponse?.batch_count_limit ?? 5) : 1, - file_upload_limit: supportBatchUpload ? (fileUploadConfigResponse?.file_upload_limit ?? 5) : 1, - }), [fileUploadConfigResponse, supportBatchUpload]) - - const fileListRef = useRef([]) - - // utils - const getFileType = (currentFile: File) => { - if (!currentFile) - return '' - - const arr = currentFile.name.split('.') - return arr[arr.length - 1] - } - - const getFileSize = (size: number) => { - if (size / 1024 < 10) - return `${(size / 1024).toFixed(2)}KB` - - return `${(size / 1024 / 1024).toFixed(2)}MB` - } - - const isValid = useCallback((file: File) => { - const { size } = file - const ext = `.${getFileType(file)}` - const isValidType = ACCEPTS.includes(ext.toLowerCase()) - if (!isValidType) - notify({ type: 'error', message: t('stepOne.uploader.validation.typeError', { ns: 'datasetCreation' }) }) - - const isValidSize = size <= fileUploadConfig.file_size_limit * 1024 * 1024 - if (!isValidSize) - notify({ type: 'error', message: t('stepOne.uploader.validation.size', { ns: 'datasetCreation', size: fileUploadConfig.file_size_limit }) }) - - return isValidType && isValidSize - }, [fileUploadConfig, notify, t, ACCEPTS]) - - const fileUpload = useCallback(async (fileItem: FileItem): Promise => { - const formData = new FormData() - formData.append('file', fileItem.file) - const onProgress = (e: ProgressEvent) => { - if (e.lengthComputable) { - const percent = Math.floor(e.loaded / e.total * 100) - onFileUpdate(fileItem, percent, fileListRef.current) - } - } - - return upload({ - xhr: new XMLHttpRequest(), - data: formData, - onprogress: onProgress, - }, false, undefined, '?source=datasets') - .then((res) => { - const completeFile = { - fileID: fileItem.fileID, - file: res as unknown as File, - progress: -1, - } - const index = fileListRef.current.findIndex(item => item.fileID === fileItem.fileID) - fileListRef.current[index] = completeFile - onFileUpdate(completeFile, 100, fileListRef.current) - return Promise.resolve({ ...completeFile }) - }) - .catch((e) => { - const errorMessage = getFileUploadErrorMessage(e, t('stepOne.uploader.failed', { ns: 'datasetCreation' }), t) - notify({ type: 'error', message: errorMessage }) - onFileUpdate(fileItem, -2, fileListRef.current) - return Promise.resolve({ ...fileItem }) - }) - .finally() - }, [fileListRef, notify, onFileUpdate, t]) - - const uploadBatchFiles = useCallback((bFiles: FileItem[]) => { - bFiles.forEach(bf => (bf.progress = 0)) - return Promise.all(bFiles.map(fileUpload)) - }, [fileUpload]) - - const uploadMultipleFiles = useCallback(async (files: FileItem[]) => { - const batchCountLimit = fileUploadConfig.batch_count_limit - const length = files.length - let start = 0 - let end = 0 - - while (start < length) { - if (start + batchCountLimit > length) - end = length - else - end = start + batchCountLimit - const bFiles = files.slice(start, end) - await uploadBatchFiles(bFiles) - start = end - } - }, [fileUploadConfig, uploadBatchFiles]) - - const initialUpload = useCallback((files: File[]) => { - const filesCountLimit = fileUploadConfig.file_upload_limit - if (!files.length) - return false - - if (files.length + fileList.length > filesCountLimit && !IS_CE_EDITION) { - notify({ type: 'error', message: t('stepOne.uploader.validation.filesNumber', { ns: 'datasetCreation', filesNumber: filesCountLimit }) }) - return false - } - - const preparedFiles = files.map((file, index) => ({ - fileID: `file${index}-${Date.now()}`, - file, - progress: -1, - })) - const newFiles = [...fileListRef.current, ...preparedFiles] - prepareFileList(newFiles) - fileListRef.current = newFiles - uploadMultipleFiles(preparedFiles) - }, [prepareFileList, uploadMultipleFiles, notify, t, fileList, fileUploadConfig]) - - const handleDragEnter = (e: DragEvent) => { - e.preventDefault() - e.stopPropagation() - if (e.target !== dragRef.current) - setDragging(true) - } - const handleDragOver = (e: DragEvent) => { - e.preventDefault() - e.stopPropagation() - } - const handleDragLeave = (e: DragEvent) => { - e.preventDefault() - e.stopPropagation() - if (e.target === dragRef.current) - setDragging(false) - } - type FileWithPath = { - relativePath?: string - } & File - const traverseFileEntry = useCallback( - (entry: any, prefix = ''): Promise => { - return new Promise((resolve) => { - if (entry.isFile) { - entry.file((file: FileWithPath) => { - file.relativePath = `${prefix}${file.name}` - resolve([file]) - }) - } - else if (entry.isDirectory) { - const reader = entry.createReader() - const entries: any[] = [] - const read = () => { - reader.readEntries(async (results: FileSystemEntry[]) => { - if (!results.length) { - const files = await Promise.all( - entries.map(ent => - traverseFileEntry(ent, `${prefix}${entry.name}/`), - ), - ) - resolve(files.flat()) - } - else { - entries.push(...results) - read() - } - }) - } - read() - } - else { - resolve([]) - } - }) - }, - [], - ) - - const handleDrop = useCallback( - async (e: DragEvent) => { - e.preventDefault() - e.stopPropagation() - setDragging(false) - if (!e.dataTransfer) - return - const nested = await Promise.all( - Array.from(e.dataTransfer.items).map((it) => { - const entry = (it as any).webkitGetAsEntry?.() - if (entry) - return traverseFileEntry(entry) - const f = it.getAsFile?.() - return f ? Promise.resolve([f]) : Promise.resolve([]) - }), - ) - let files = nested.flat() - if (!supportBatchUpload) - files = files.slice(0, 1) - files = files.slice(0, fileUploadConfig.batch_count_limit) - const valid = files.filter(isValid) - initialUpload(valid) - }, - [initialUpload, isValid, supportBatchUpload, traverseFileEntry, fileUploadConfig], - ) - const selectHandle = () => { - if (fileUploader.current) - fileUploader.current.click() - } - - const removeFile = (fileID: string) => { - if (fileUploader.current) - fileUploader.current.value = '' - - fileListRef.current = fileListRef.current.filter(item => item.fileID !== fileID) - onFileListUpdate?.([...fileListRef.current]) - } - const fileChangeHandle = useCallback((e: React.ChangeEvent) => { - let files = Array.from(e.target.files ?? []) as File[] - files = files.slice(0, fileUploadConfig.batch_count_limit) - initialUpload(files.filter(isValid)) - }, [isValid, initialUpload, fileUploadConfig]) - - const { theme } = useTheme() - const chartColor = useMemo(() => theme === Theme.dark ? '#5289ff' : '#296dff', [theme]) - - useEffect(() => { - dropRef.current?.addEventListener('dragenter', handleDragEnter) - dropRef.current?.addEventListener('dragover', handleDragOver) - dropRef.current?.addEventListener('dragleave', handleDragLeave) - dropRef.current?.addEventListener('drop', handleDrop) - return () => { - dropRef.current?.removeEventListener('dragenter', handleDragEnter) - dropRef.current?.removeEventListener('dragover', handleDragOver) - dropRef.current?.removeEventListener('dragleave', handleDragLeave) - dropRef.current?.removeEventListener('drop', handleDrop) - } - }, [handleDrop]) + const { + dropRef, + dragRef, + fileUploaderRef, + dragging, + fileUploadConfig, + acceptTypes, + supportTypesShowNames, + hideUpload, + selectHandle, + fileChangeHandle, + removeFile, + handlePreview, + } = useFileUpload({ + fileList, + prepareFileList, + onFileUpdate, + onFileListUpdate, + onPreview, + supportBatchUpload, + }) return (
+
+ {t('stepOne.uploader.title', { ns: 'datasetCreation' })} +
+ {!hideUpload && ( - )} -
{t('stepOne.uploader.title', { ns: 'datasetCreation' })}
- - {!hideUpload && ( -
-
- - - - {supportBatchUpload ? t('stepOne.uploader.button', { ns: 'datasetCreation' }) : t('stepOne.uploader.buttonSingleFile', { ns: 'datasetCreation' })} - {supportTypes.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 &&
} + {fileList.length > 0 && ( +
+ {fileList.map(fileItem => ( + + ))}
)} -
- - {fileList.map((fileItem, index) => ( -
fileItem.file?.id && onPreview(fileItem.file)} - className={cn( - 'flex h-12 max-w-[640px] items-center rounded-lg border border-components-panel-border bg-components-panel-on-panel-item-bg text-xs leading-3 text-text-tertiary shadow-xs', - // 'border-state-destructive-border bg-state-destructive-hover', - )} - > -
- -
-
-
-
{fileItem.file.name}
-
-
- {getFileType(fileItem.file)} - ยท - {getFileSize(fileItem.file.size)} - {/* ยท - 10k characters */} -
-
-
- {/* - - */} - {(fileItem.progress < 100 && fileItem.progress >= 0) && ( - //
{`${fileItem.progress}%`}
- - )} - { - e.stopPropagation() - removeFile(fileItem.fileID) - }} - > - - -
-
- ))} -
) } 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..7754ba6970 --- /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) formatted with formatFileSize + expect(screen.getByText('100.00 KB')).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.00 KB')).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.00 MB')).toBeInTheDocument() + }) + + it('should display size at threshold (10KB)', () => { + const fileItem = createMockFileItem({ + file: createMockFile({ size: 10 * 1024 }), // 10KB + }) + render() + + expect(screen.getByText('10.00 KB')).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..1a61fa04f0 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/file-list-item.tsx @@ -0,0 +1,85 @@ +import type { CustomFile as File, FileItem } from '@/models/datasets' +import { RiDeleteBinLine, RiErrorWarningFill } from '@remixicon/react' +import dynamic from 'next/dynamic' +import { useMemo } from 'react' +import DocumentFileIcon from '@/app/components/datasets/common/document-file-icon' +import { getFileType } from '@/app/components/datasets/common/image-uploader/utils' +import useTheme from '@/hooks/use-theme' +import { Theme } from '@/types/app' +import { cn } from '@/utils/classnames' +import { formatFileSize } from '@/utils/format' +import { PROGRESS_ERROR } from '../constants' + +const SimplePieChart = dynamic(() => import('@/app/components/base/simple-pie-chart'), { ssr: false }) + +export type FileListItemProps = { + fileItem: FileItem + onPreview: (file: File) => void + onRemove: (fileID: string) => void +} + +const FileListItem = ({ + fileItem, + onPreview, + onRemove, +}: FileListItemProps) => { + const { theme } = useTheme() + const chartColor = useMemo(() => theme === Theme.dark ? '#5289ff' : '#296dff', [theme]) + + const isUploading = fileItem.progress >= 0 && fileItem.progress < 100 + const isError = fileItem.progress === PROGRESS_ERROR + + const handleClick = () => { + onPreview(fileItem.file) + } + + const handleRemove = (e: React.MouseEvent) => { + e.stopPropagation() + onRemove(fileItem.fileID) + } + + return ( +
+
+ +
+
+
+
{fileItem.file.name}
+
+
+ {getFileType(fileItem.file)} + ยท + {formatFileSize(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..6248b70506 --- /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,911 @@ +import type { ReactNode } from 'react' +import type { CustomFile, FileItem } from '@/models/datasets' +import { act, render, renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../constants' + +// Mock notify function - defined before mocks +const mockNotify = vi.fn() +const mockClose = vi.fn() + +// Mock ToastContext with factory function +vi.mock('@/app/components/base/toast', async () => { + const { createContext, useContext } = await import('use-context-selector') + const context = createContext({ notify: mockNotify, close: mockClose }) + return { + ToastContext: context, + useToastContext: () => useContext(context), + } +}) + +// Mock file uploader utils +vi.mock('@/app/components/base/file-uploader/utils', () => ({ + getFileUploadErrorMessage: (e: Error, defaultMsg: string) => e.message || defaultMsg, +})) + +// Mock format utils used by the shared hook +vi.mock('@/utils/format', () => ({ + getFileExtension: (filename: string) => { + const parts = filename.split('.') + return parts[parts.length - 1] || '' + }, +})) + +// Mock react-i18next +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +// Mock locale context +vi.mock('@/context/i18n', () => ({ + useLocale: () => 'en-US', +})) + +// Mock i18n config +vi.mock('@/i18n-config/language', () => ({ + LanguagesSupported: ['en-US', 'zh-Hans'], +})) + +// Mock config +vi.mock('@/config', () => ({ + IS_CE_EDITION: false, +})) + +// Mock store functions +const mockSetLocalFileList = vi.fn() +const mockSetCurrentLocalFile = vi.fn() +const mockGetState = vi.fn(() => ({ + setLocalFileList: mockSetLocalFileList, + setCurrentLocalFile: mockSetCurrentLocalFile, +})) +const mockStore = { getState: mockGetState } + +vi.mock('../../store', () => ({ + useDataSourceStoreWithSelector: vi.fn((selector: (state: { localFileList: FileItem[] }) => FileItem[]) => + selector({ localFileList: [] }), + ), + useDataSourceStore: vi.fn(() => mockStore), +})) + +// Mock file upload config +vi.mock('@/service/use-common', () => ({ + useFileUploadConfig: vi.fn(() => ({ + data: { + file_size_limit: 15, + batch_count_limit: 5, + file_upload_limit: 10, + }, + })), + // Required by the shared useFileUpload hook + useFileSupportTypes: vi.fn(() => ({ + data: { + allowed_extensions: ['pdf', 'docx', 'txt'], + }, + })), +})) + +// Mock upload service +const mockUpload = vi.fn() +vi.mock('@/service/base', () => ({ + upload: (...args: unknown[]) => mockUpload(...args), +})) + +// Import after all mocks are set up +const { useLocalFileUpload } = await import('./use-local-file-upload') +const { ToastContext } = await import('@/app/components/base/toast') + +const createWrapper = () => { + return ({ children }: { children: ReactNode }) => ( + + {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: { items: DataTransferItem[], files: File[] } | null + } + // Mock dataTransfer with items array (used by the shared hook for directory traversal) + dropEvent.dataTransfer = { + items: [{ + kind: 'file', + getAsFile: () => mockFile, + }] as unknown as DataTransferItem[], + files: [mockFile], + } + dropzone.dispatchEvent(dropEvent) + }) + + await waitFor(() => { + expect(mockSetLocalFileList).toHaveBeenCalled() + }) + }) + + it('should handle drop without dataTransfer', async () => { + const { getByTestId } = await act(async () => + render( + + + , + ), + ) + + 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: { items: DataTransferItem[], files: File[] } | null + } + // Mock dataTransfer with items array (used by the shared hook for directory traversal) + dropEvent.dataTransfer = { + items: files.map(f => ({ + kind: 'file', + getAsFile: () => f, + })) as unknown as DataTransferItem[], + files, + } + dropzone.dispatchEvent(dropEvent) + }) + + await waitFor(() => { + expect(mockSetLocalFileList).toHaveBeenCalled() + // Should only have 1 file (limited by supportBatchUpload: false) + const callArgs = mockSetLocalFileList.mock.calls[0][0] + expect(callArgs.length).toBe(1) + }) + }) + }) + + describe('file upload limit', () => { + it('should reject files exceeding total file upload limit', async () => { + // Mock store to return existing files + const { useDataSourceStoreWithSelector } = vi.mocked(await import('../../store')) + const existingFiles: FileItem[] = Array.from({ length: 8 }, (_, i) => ({ + fileID: `existing-${i}`, + file: { name: `existing-${i}.pdf`, size: 1024 } as CustomFile, + progress: 100, + })) + vi.mocked(useDataSourceStoreWithSelector).mockImplementation(selector => + selector({ localFileList: existingFiles } as Parameters[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..1f7c9ecfed --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/use-local-file-upload.ts @@ -0,0 +1,105 @@ +import type { CustomFile as File, FileItem } from '@/models/datasets' +import { produce } from 'immer' +import { useCallback, useRef } from 'react' +import { useFileUpload } from '@/app/components/datasets/create/file-uploader/hooks/use-file-upload' +import { useDataSourceStore, useDataSourceStoreWithSelector } from '../../store' + +export type UseLocalFileUploadOptions = { + allowedExtensions: string[] + supportBatchUpload?: boolean +} + +/** + * Hook for handling local file uploads in the create-from-pipeline flow. + * This is a thin wrapper around the generic useFileUpload hook that provides + * Zustand store integration for state management. + */ +export const useLocalFileUpload = ({ + allowedExtensions, + supportBatchUpload = true, +}: UseLocalFileUploadOptions) => { + const localFileList = useDataSourceStoreWithSelector(state => state.localFileList) + const dataSourceStore = useDataSourceStore() + const fileListRef = useRef([]) + + // Sync fileListRef with localFileList for internal tracking + fileListRef.current = localFileList + + const prepareFileList = useCallback((files: FileItem[]) => { + const { setLocalFileList } = dataSourceStore.getState() + setLocalFileList(files) + fileListRef.current = files + }, [dataSourceStore]) + + const onFileUpdate = useCallback((fileItem: FileItem, progress: number, list: FileItem[]) => { + const { setLocalFileList } = dataSourceStore.getState() + const newList = produce(list, (draft) => { + const targetIndex = draft.findIndex(file => file.fileID === fileItem.fileID) + if (targetIndex !== -1) { + draft[targetIndex] = { + ...draft[targetIndex], + ...fileItem, + progress, + } + } + }) + setLocalFileList(newList) + }, [dataSourceStore]) + + const onFileListUpdate = useCallback((files: FileItem[]) => { + const { setLocalFileList } = dataSourceStore.getState() + setLocalFileList(files) + fileListRef.current = files + }, [dataSourceStore]) + + const onPreview = useCallback((file: File) => { + const { setCurrentLocalFile } = dataSourceStore.getState() + setCurrentLocalFile(file) + }, [dataSourceStore]) + + const { + dropRef, + dragRef, + fileUploaderRef, + dragging, + fileUploadConfig, + acceptTypes, + supportTypesShowNames, + hideUpload, + selectHandle, + fileChangeHandle, + removeFile, + handlePreview, + } = useFileUpload({ + fileList: localFileList, + prepareFileList, + onFileUpdate, + onFileListUpdate, + onPreview, + supportBatchUpload, + allowedExtensions, + }) + + return { + // Refs + dropRef, + dragRef, + fileUploaderRef, + + // State + dragging, + localFileList, + + // Config + fileUploadConfig, + acceptTypes, + supportTypesShowNames, + hideUpload, + + // Handlers + selectHandle, + fileChangeHandle, + removeFile, + handlePreview, + } +} 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) => ( + + ))}
)}
diff --git a/web/app/components/datasets/settings/form/components/basic-info-section.spec.tsx b/web/app/components/datasets/settings/form/components/basic-info-section.spec.tsx new file mode 100644 index 0000000000..28085e52fa --- /dev/null +++ b/web/app/components/datasets/settings/form/components/basic-info-section.spec.tsx @@ -0,0 +1,441 @@ +import type { Member } from '@/models/common' +import type { DataSet, IconInfo } from '@/models/datasets' +import type { RetrievalConfig } from '@/types/app' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' +import { RETRIEVE_METHOD } from '@/types/app' +import { IndexingType } from '../../../create/step-two' +import BasicInfoSection from './basic-info-section' + +// Mock app-context +vi.mock('@/context/app-context', () => ({ + useSelector: () => ({ + id: 'user-1', + name: 'Current User', + email: 'current@example.com', + avatar_url: '', + role: 'owner', + }), +})) + +// Mock image uploader hooks for AppIconPicker +vi.mock('@/app/components/base/image-uploader/hooks', () => ({ + useLocalFileUploader: () => ({ + disabled: false, + handleLocalFileUpload: vi.fn(), + }), + useImageFiles: () => ({ + files: [], + onUpload: vi.fn(), + onRemove: vi.fn(), + onReUpload: vi.fn(), + onImageLinkLoadError: vi.fn(), + onImageLinkLoadSuccess: vi.fn(), + onClear: vi.fn(), + }), +})) + +describe('BasicInfoSection', () => { + const mockDataset: DataSet = { + id: 'dataset-1', + name: 'Test Dataset', + description: 'Test description', + permission: DatasetPermission.onlyMe, + icon_info: { + icon_type: 'emoji', + icon: '๐Ÿ“š', + icon_background: '#FFFFFF', + icon_url: '', + }, + indexing_technique: IndexingType.QUALIFIED, + indexing_status: 'completed', + data_source_type: DataSourceType.FILE, + doc_form: ChunkingMode.text, + embedding_model: 'text-embedding-ada-002', + embedding_model_provider: 'openai', + embedding_available: true, + app_count: 0, + document_count: 5, + total_document_count: 5, + word_count: 1000, + provider: 'vendor', + tags: [], + partial_member_list: [], + external_knowledge_info: { + external_knowledge_id: 'ext-1', + external_knowledge_api_id: 'api-1', + external_knowledge_api_name: 'External API', + external_knowledge_api_endpoint: 'https://api.example.com', + }, + external_retrieval_model: { + top_k: 3, + score_threshold: 0.7, + score_threshold_enabled: true, + }, + retrieval_model_dict: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + } as RetrievalConfig, + retrieval_model: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + } as RetrievalConfig, + built_in_field_enabled: false, + keyword_number: 10, + created_by: 'user-1', + updated_by: 'user-1', + updated_at: Date.now(), + runtime_mode: 'general', + enable_api: true, + is_multimodal: false, + } + + const mockMemberList: Member[] = [ + { id: 'user-1', name: 'User 1', email: 'user1@example.com', role: 'owner', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' }, + { id: 'user-2', name: 'User 2', email: 'user2@example.com', role: 'admin', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' }, + ] + + const mockIconInfo: IconInfo = { + icon_type: 'emoji', + icon: '๐Ÿ“š', + icon_background: '#FFFFFF', + icon_url: '', + } + + const defaultProps = { + currentDataset: mockDataset, + isCurrentWorkspaceDatasetOperator: false, + name: 'Test Dataset', + setName: vi.fn(), + description: 'Test description', + setDescription: vi.fn(), + iconInfo: mockIconInfo, + showAppIconPicker: false, + handleOpenAppIconPicker: vi.fn(), + handleSelectAppIcon: vi.fn(), + handleCloseAppIconPicker: vi.fn(), + permission: DatasetPermission.onlyMe, + setPermission: vi.fn(), + selectedMemberIDs: ['user-1'], + setSelectedMemberIDs: vi.fn(), + memberList: mockMemberList, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + render() + expect(screen.getByText(/form\.nameAndIcon/i)).toBeInTheDocument() + }) + + it('should render name and icon section', () => { + render() + expect(screen.getByText(/form\.nameAndIcon/i)).toBeInTheDocument() + }) + + it('should render description section', () => { + render() + expect(screen.getByText(/form\.desc/i)).toBeInTheDocument() + }) + + it('should render permissions section', () => { + render() + // Use exact match to avoid matching "permissionsOnlyMe" + expect(screen.getByText('datasetSettings.form.permissions')).toBeInTheDocument() + }) + + it('should render name input with correct value', () => { + render() + const nameInput = screen.getByDisplayValue('Test Dataset') + expect(nameInput).toBeInTheDocument() + }) + + it('should render description textarea with correct value', () => { + render() + const descriptionTextarea = screen.getByDisplayValue('Test description') + expect(descriptionTextarea).toBeInTheDocument() + }) + + it('should render app icon with emoji', () => { + const { container } = render() + // The icon section should be rendered (emoji may be in a span or SVG) + const iconSection = container.querySelector('[class*="cursor-pointer"]') + expect(iconSection).toBeInTheDocument() + }) + }) + + describe('Name Input', () => { + it('should call setName when name input changes', () => { + const setName = vi.fn() + render() + + const nameInput = screen.getByDisplayValue('Test Dataset') + fireEvent.change(nameInput, { target: { value: 'New Name' } }) + + expect(setName).toHaveBeenCalledWith('New Name') + }) + + it('should disable name input when embedding is not available', () => { + const datasetWithoutEmbedding = { ...mockDataset, embedding_available: false } + render() + + const nameInput = screen.getByDisplayValue('Test Dataset') + expect(nameInput).toBeDisabled() + }) + + it('should enable name input when embedding is available', () => { + render() + + const nameInput = screen.getByDisplayValue('Test Dataset') + expect(nameInput).not.toBeDisabled() + }) + + it('should display empty name', () => { + const { container } = render() + + // Find the name input by its structure - may be type=text or just input + const nameInput = container.querySelector('input') + expect(nameInput).toHaveValue('') + }) + }) + + describe('Description Textarea', () => { + it('should call setDescription when description changes', () => { + const setDescription = vi.fn() + render() + + const descriptionTextarea = screen.getByDisplayValue('Test description') + fireEvent.change(descriptionTextarea, { target: { value: 'New Description' } }) + + expect(setDescription).toHaveBeenCalledWith('New Description') + }) + + it('should disable description textarea when embedding is not available', () => { + const datasetWithoutEmbedding = { ...mockDataset, embedding_available: false } + render() + + const descriptionTextarea = screen.getByDisplayValue('Test description') + expect(descriptionTextarea).toBeDisabled() + }) + + it('should render placeholder', () => { + render() + + const descriptionTextarea = screen.getByPlaceholderText(/form\.descPlaceholder/i) + expect(descriptionTextarea).toBeInTheDocument() + }) + }) + + describe('App Icon', () => { + it('should call handleOpenAppIconPicker when icon is clicked', () => { + const handleOpenAppIconPicker = vi.fn() + const { container } = render() + + // Find the clickable icon element - it's inside a wrapper that handles the click + const iconWrapper = container.querySelector('[class*="cursor-pointer"]') + if (iconWrapper) { + fireEvent.click(iconWrapper) + expect(handleOpenAppIconPicker).toHaveBeenCalled() + } + }) + + it('should render AppIconPicker when showAppIconPicker is true', () => { + const { baseElement } = render() + + // AppIconPicker renders a modal with emoji tabs and options via portal + // We just verify the component renders without crashing when picker is shown + expect(baseElement).toBeInTheDocument() + }) + + it('should not render AppIconPicker when showAppIconPicker is false', () => { + const { container } = render() + + // Check that AppIconPicker is not rendered + expect(container.querySelector('[data-testid="app-icon-picker"]')).not.toBeInTheDocument() + }) + + it('should render image icon when icon_type is image', () => { + const imageIconInfo: IconInfo = { + icon_type: 'image', + icon: 'file-123', + icon_background: undefined, + icon_url: 'https://example.com/icon.png', + } + render() + + // For image type, it renders an img element + const img = screen.queryByRole('img') + if (img) { + expect(img).toHaveAttribute('src', expect.stringContaining('icon.png')) + } + }) + }) + + describe('Permission Selector', () => { + it('should render with correct permission value', () => { + render() + + expect(screen.getByText(/form\.permissionsOnlyMe/i)).toBeInTheDocument() + }) + + it('should render all team members permission', () => { + render() + + expect(screen.getByText(/form\.permissionsAllMember/i)).toBeInTheDocument() + }) + + it('should be disabled when embedding is not available', () => { + const datasetWithoutEmbedding = { ...mockDataset, embedding_available: false } + const { container } = render( + , + ) + + // Check for disabled state via cursor-not-allowed class + const disabledElement = container.querySelector('[class*="cursor-not-allowed"]') + expect(disabledElement).toBeInTheDocument() + }) + + it('should be disabled when user is dataset operator', () => { + const { container } = render( + , + ) + + const disabledElement = container.querySelector('[class*="cursor-not-allowed"]') + expect(disabledElement).toBeInTheDocument() + }) + + it('should call setPermission when permission changes', async () => { + const setPermission = vi.fn() + render() + + // Open dropdown + const trigger = screen.getByText(/form\.permissionsOnlyMe/i) + fireEvent.click(trigger) + + await waitFor(() => { + // Click All Team Members option + const allMemberOptions = screen.getAllByText(/form\.permissionsAllMember/i) + fireEvent.click(allMemberOptions[0]) + }) + + expect(setPermission).toHaveBeenCalledWith(DatasetPermission.allTeamMembers) + }) + + it('should call setSelectedMemberIDs when members are selected', async () => { + const setSelectedMemberIDs = vi.fn() + const { container } = render( + , + ) + + // For partial members permission, the member selector should be visible + // The exact interaction depends on the MemberSelector component + // We verify the component renders without crashing + expect(container).toBeInTheDocument() + }) + }) + + describe('Undefined Dataset', () => { + it('should handle undefined currentDataset gracefully', () => { + render() + + // Should still render but inputs might behave differently + expect(screen.getByText(/form\.nameAndIcon/i)).toBeInTheDocument() + }) + }) + + describe('Props Validation', () => { + it('should update when name prop changes', () => { + const { rerender } = render() + + expect(screen.getByDisplayValue('Initial Name')).toBeInTheDocument() + + rerender() + + expect(screen.getByDisplayValue('Updated Name')).toBeInTheDocument() + }) + + it('should update when description prop changes', () => { + const { rerender } = render() + + expect(screen.getByDisplayValue('Initial Description')).toBeInTheDocument() + + rerender() + + expect(screen.getByDisplayValue('Updated Description')).toBeInTheDocument() + }) + + it('should update when permission prop changes', () => { + const { rerender } = render() + + expect(screen.getByText(/form\.permissionsOnlyMe/i)).toBeInTheDocument() + + rerender() + + expect(screen.getByText(/form\.permissionsAllMember/i)).toBeInTheDocument() + }) + }) + + describe('Member List', () => { + it('should pass member list to PermissionSelector', () => { + const { container } = render( + , + ) + + // For partial members, a member selector component should be rendered + // We verify it renders without crashing + expect(container).toBeInTheDocument() + }) + + it('should handle empty member list', () => { + render( + , + ) + + expect(screen.getByText(/form\.permissionsOnlyMe/i)).toBeInTheDocument() + }) + }) + + describe('Accessibility', () => { + it('should have accessible name input', () => { + render() + + const nameInput = screen.getByDisplayValue('Test Dataset') + expect(nameInput.tagName.toLowerCase()).toBe('input') + }) + + it('should have accessible description textarea', () => { + render() + + const descriptionTextarea = screen.getByDisplayValue('Test description') + expect(descriptionTextarea.tagName.toLowerCase()).toBe('textarea') + }) + }) +}) diff --git a/web/app/components/datasets/settings/form/components/basic-info-section.tsx b/web/app/components/datasets/settings/form/components/basic-info-section.tsx new file mode 100644 index 0000000000..3d3cf75851 --- /dev/null +++ b/web/app/components/datasets/settings/form/components/basic-info-section.tsx @@ -0,0 +1,124 @@ +'use client' +import type { AppIconSelection } from '@/app/components/base/app-icon-picker' +import type { Member } from '@/models/common' +import type { DataSet, DatasetPermission, IconInfo } from '@/models/datasets' +import type { AppIconType } from '@/types/app' +import { useTranslation } from 'react-i18next' +import AppIcon from '@/app/components/base/app-icon' +import AppIconPicker from '@/app/components/base/app-icon-picker' +import Input from '@/app/components/base/input' +import Textarea from '@/app/components/base/textarea' +import PermissionSelector from '../../permission-selector' + +const rowClass = 'flex gap-x-1' +const labelClass = 'flex items-center shrink-0 w-[180px] h-7 pt-1' + +type BasicInfoSectionProps = { + currentDataset: DataSet | undefined + isCurrentWorkspaceDatasetOperator: boolean + name: string + setName: (value: string) => void + description: string + setDescription: (value: string) => void + iconInfo: IconInfo + showAppIconPicker: boolean + handleOpenAppIconPicker: () => void + handleSelectAppIcon: (icon: AppIconSelection) => void + handleCloseAppIconPicker: () => void + permission: DatasetPermission | undefined + setPermission: (value: DatasetPermission | undefined) => void + selectedMemberIDs: string[] + setSelectedMemberIDs: (value: string[]) => void + memberList: Member[] +} + +const BasicInfoSection = ({ + currentDataset, + isCurrentWorkspaceDatasetOperator, + name, + setName, + description, + setDescription, + iconInfo, + showAppIconPicker, + handleOpenAppIconPicker, + handleSelectAppIcon, + handleCloseAppIconPicker, + permission, + setPermission, + selectedMemberIDs, + setSelectedMemberIDs, + memberList, +}: BasicInfoSectionProps) => { + const { t } = useTranslation() + + return ( + <> + {/* Dataset name and icon */} +
+
+
{t('form.nameAndIcon', { ns: 'datasetSettings' })}
+
+
+ + setName(e.target.value)} + /> +
+
+ + {/* Dataset description */} +
+
+
{t('form.desc', { ns: 'datasetSettings' })}
+
+
+