diff --git a/web/app/components/datasets/documents/components/document-list/components/document-source-icon.spec.tsx b/web/app/components/datasets/documents/components/document-list/components/document-source-icon.spec.tsx new file mode 100644 index 0000000000..33108fbbac --- /dev/null +++ b/web/app/components/datasets/documents/components/document-list/components/document-source-icon.spec.tsx @@ -0,0 +1,262 @@ +import type { SimpleDocumentDetail } from '@/models/datasets' +import { render } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { DataSourceType } from '@/models/datasets' +import { DatasourceType } from '@/models/pipeline' +import DocumentSourceIcon from './document-source-icon' + +const createMockDoc = (overrides: Record = {}): SimpleDocumentDetail => ({ + id: 'doc-1', + position: 1, + data_source_type: DataSourceType.FILE, + data_source_info: {}, + data_source_detail_dict: {}, + dataset_process_rule_id: 'rule-1', + dataset_id: 'dataset-1', + batch: 'batch-1', + name: 'test-document.txt', + created_from: 'web', + created_by: 'user-1', + created_at: Date.now(), + tokens: 100, + indexing_status: 'completed', + error: null, + enabled: true, + disabled_at: null, + disabled_by: null, + archived: false, + archived_reason: null, + archived_by: null, + archived_at: null, + updated_at: Date.now(), + doc_type: null, + doc_metadata: undefined, + doc_language: 'en', + display_status: 'available', + word_count: 100, + hit_count: 10, + doc_form: 'text_model', + ...overrides, +}) as unknown as SimpleDocumentDetail + +describe('DocumentSourceIcon', () => { + describe('Rendering', () => { + it('should render without crashing', () => { + const doc = createMockDoc() + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + }) + + describe('Local File Icon', () => { + it('should render FileTypeIcon for FILE data source type', () => { + const doc = createMockDoc({ + data_source_type: DataSourceType.FILE, + data_source_info: { + upload_file: { extension: 'pdf' }, + }, + }) + + const { container } = render() + const icon = container.querySelector('svg, img') + expect(icon).toBeInTheDocument() + }) + + it('should render FileTypeIcon for localFile data source type', () => { + const doc = createMockDoc({ + data_source_type: DatasourceType.localFile, + created_from: 'rag-pipeline', + data_source_info: { + extension: 'docx', + }, + }) + + const { container } = render() + const icon = container.querySelector('svg, img') + expect(icon).toBeInTheDocument() + }) + + it('should use extension from upload_file for legacy data source', () => { + const doc = createMockDoc({ + data_source_type: DataSourceType.FILE, + created_from: 'web', + data_source_info: { + upload_file: { extension: 'txt' }, + }, + }) + + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should use fileType prop as fallback for extension', () => { + const doc = createMockDoc({ + data_source_type: DataSourceType.FILE, + created_from: 'web', + data_source_info: {}, + }) + + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + }) + + describe('Notion Icon', () => { + it('should render NotionIcon for NOTION data source type', () => { + const doc = createMockDoc({ + data_source_type: DataSourceType.NOTION, + created_from: 'web', + data_source_info: { + notion_page_icon: 'https://notion.so/icon.png', + }, + }) + + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should render NotionIcon for onlineDocument data source type', () => { + const doc = createMockDoc({ + data_source_type: DatasourceType.onlineDocument, + created_from: 'rag-pipeline', + data_source_info: { + page: { page_icon: 'https://notion.so/icon.png' }, + }, + }) + + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should use page_icon for rag-pipeline created documents', () => { + const doc = createMockDoc({ + data_source_type: DataSourceType.NOTION, + created_from: 'rag-pipeline', + data_source_info: { + page: { page_icon: 'https://notion.so/custom-icon.png' }, + }, + }) + + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + }) + + describe('Web Crawl Icon', () => { + it('should render globe icon for WEB data source type', () => { + const doc = createMockDoc({ + data_source_type: DataSourceType.WEB, + }) + + const { container } = render() + const icon = container.querySelector('svg') + expect(icon).toBeInTheDocument() + expect(icon).toHaveClass('mr-1.5') + expect(icon).toHaveClass('size-4') + }) + + it('should render globe icon for websiteCrawl data source type', () => { + const doc = createMockDoc({ + data_source_type: DatasourceType.websiteCrawl, + }) + + const { container } = render() + const icon = container.querySelector('svg') + expect(icon).toBeInTheDocument() + }) + }) + + describe('Online Drive Icon', () => { + it('should render FileTypeIcon for onlineDrive data source type', () => { + const doc = createMockDoc({ + data_source_type: DatasourceType.onlineDrive, + data_source_info: { + name: 'document.xlsx', + }, + }) + + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should extract extension from file name', () => { + const doc = createMockDoc({ + data_source_type: DatasourceType.onlineDrive, + data_source_info: { + name: 'spreadsheet.xlsx', + }, + }) + + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should handle file name without extension', () => { + const doc = createMockDoc({ + data_source_type: DatasourceType.onlineDrive, + data_source_info: { + name: 'noextension', + }, + }) + + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should handle empty file name', () => { + const doc = createMockDoc({ + data_source_type: DatasourceType.onlineDrive, + data_source_info: { + name: '', + }, + }) + + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should handle hidden files (starting with dot)', () => { + const doc = createMockDoc({ + data_source_type: DatasourceType.onlineDrive, + data_source_info: { + name: '.gitignore', + }, + }) + + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + }) + + describe('Unknown Data Source Type', () => { + it('should return null for unknown data source type', () => { + const doc = createMockDoc({ + data_source_type: 'unknown', + }) + + const { container } = render() + expect(container.firstChild).toBeNull() + }) + }) + + describe('Edge Cases', () => { + it('should handle undefined data_source_info', () => { + const doc = createMockDoc({ + data_source_type: DataSourceType.FILE, + data_source_info: undefined, + }) + + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should memoize the component', () => { + const doc = createMockDoc() + const { rerender, container } = render() + + const firstRender = container.innerHTML + rerender() + expect(container.innerHTML).toBe(firstRender) + }) + }) +}) diff --git a/web/app/components/datasets/documents/components/document-list/components/document-source-icon.tsx b/web/app/components/datasets/documents/components/document-list/components/document-source-icon.tsx new file mode 100644 index 0000000000..5461f34921 --- /dev/null +++ b/web/app/components/datasets/documents/components/document-list/components/document-source-icon.tsx @@ -0,0 +1,100 @@ +import type { FC } from 'react' +import type { LegacyDataSourceInfo, LocalFileInfo, OnlineDocumentInfo, OnlineDriveInfo, SimpleDocumentDetail } from '@/models/datasets' +import { RiGlobalLine } from '@remixicon/react' +import * as React from 'react' +import FileTypeIcon from '@/app/components/base/file-uploader/file-type-icon' +import NotionIcon from '@/app/components/base/notion-icon' +import { extensionToFileType } from '@/app/components/datasets/hit-testing/utils/extension-to-file-type' +import { DataSourceType } from '@/models/datasets' +import { DatasourceType } from '@/models/pipeline' + +type DocumentSourceIconProps = { + doc: SimpleDocumentDetail + fileType?: string +} + +const isLocalFile = (dataSourceType: DataSourceType | DatasourceType) => { + return dataSourceType === DatasourceType.localFile || dataSourceType === DataSourceType.FILE +} + +const isOnlineDocument = (dataSourceType: DataSourceType | DatasourceType) => { + return dataSourceType === DatasourceType.onlineDocument || dataSourceType === DataSourceType.NOTION +} + +const isWebsiteCrawl = (dataSourceType: DataSourceType | DatasourceType) => { + return dataSourceType === DatasourceType.websiteCrawl || dataSourceType === DataSourceType.WEB +} + +const isOnlineDrive = (dataSourceType: DataSourceType | DatasourceType) => { + return dataSourceType === DatasourceType.onlineDrive +} + +const isCreateFromRAGPipeline = (createdFrom: string) => { + return createdFrom === 'rag-pipeline' +} + +const getFileExtension = (fileName: string): string => { + if (!fileName) + return '' + const parts = fileName.split('.') + if (parts.length <= 1 || (parts[0] === '' && parts.length === 2)) + return '' + return parts[parts.length - 1].toLowerCase() +} + +const DocumentSourceIcon: FC = React.memo(({ + doc, + fileType, +}) => { + if (isOnlineDocument(doc.data_source_type)) { + return ( + + ) + } + + if (isLocalFile(doc.data_source_type)) { + return ( + + ) + } + + if (isOnlineDrive(doc.data_source_type)) { + return ( + + ) + } + + if (isWebsiteCrawl(doc.data_source_type)) { + return + } + + return null +}) + +DocumentSourceIcon.displayName = 'DocumentSourceIcon' + +export default DocumentSourceIcon diff --git a/web/app/components/datasets/documents/components/document-list/components/document-table-row.spec.tsx b/web/app/components/datasets/documents/components/document-list/components/document-table-row.spec.tsx new file mode 100644 index 0000000000..7157a9bf4b --- /dev/null +++ b/web/app/components/datasets/documents/components/document-list/components/document-table-row.spec.tsx @@ -0,0 +1,342 @@ +import type { ReactNode } from 'react' +import type { SimpleDocumentDetail } from '@/models/datasets' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { DataSourceType } from '@/models/datasets' +import DocumentTableRow from './document-table-row' + +const mockPush = vi.fn() + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: mockPush, + }), +})) + +const createTestQueryClient = () => new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0 }, + mutations: { retry: false }, + }, +}) + +const createWrapper = () => { + const queryClient = createTestQueryClient() + return ({ children }: { children: ReactNode }) => ( + + + + {children} + +
+
+ ) +} + +type LocalDoc = SimpleDocumentDetail & { percent?: number } + +const createMockDoc = (overrides: Record = {}): LocalDoc => ({ + id: 'doc-1', + position: 1, + data_source_type: DataSourceType.FILE, + data_source_info: {}, + data_source_detail_dict: { + upload_file: { name: 'test.txt', extension: 'txt' }, + }, + dataset_process_rule_id: 'rule-1', + dataset_id: 'dataset-1', + batch: 'batch-1', + name: 'test-document.txt', + created_from: 'web', + created_by: 'user-1', + created_at: Date.now(), + tokens: 100, + indexing_status: 'completed', + error: null, + enabled: true, + disabled_at: null, + disabled_by: null, + archived: false, + archived_reason: null, + archived_by: null, + archived_at: null, + updated_at: Date.now(), + doc_type: null, + doc_metadata: undefined, + doc_language: 'en', + display_status: 'available', + word_count: 500, + hit_count: 10, + doc_form: 'text_model', + ...overrides, +}) as unknown as LocalDoc + +// Helper to find the custom checkbox div (Checkbox component renders as a div, not a native checkbox) +const findCheckbox = (container: HTMLElement): HTMLElement | null => { + return container.querySelector('[class*="shadow-xs"]') +} + +describe('DocumentTableRow', () => { + const defaultProps = { + doc: createMockDoc(), + index: 0, + datasetId: 'dataset-1', + isSelected: false, + isGeneralMode: true, + isQAMode: false, + embeddingAvailable: true, + selectedIds: [], + onSelectOne: vi.fn(), + onSelectedIdChange: vi.fn(), + onShowRenameModal: vi.fn(), + onUpdate: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + render(, { wrapper: createWrapper() }) + expect(screen.getByText('test-document.txt')).toBeInTheDocument() + }) + + it('should render index number correctly', () => { + render(, { wrapper: createWrapper() }) + expect(screen.getByText('6')).toBeInTheDocument() + }) + + it('should render document name with tooltip', () => { + render(, { wrapper: createWrapper() }) + expect(screen.getByText('test-document.txt')).toBeInTheDocument() + }) + + it('should render checkbox element', () => { + const { container } = render(, { wrapper: createWrapper() }) + const checkbox = findCheckbox(container) + expect(checkbox).toBeInTheDocument() + }) + }) + + describe('Selection', () => { + it('should show check icon when isSelected is true', () => { + const { container } = render(, { wrapper: createWrapper() }) + // When selected, the checkbox should have a check icon (RiCheckLine svg) + const checkbox = findCheckbox(container) + expect(checkbox).toBeInTheDocument() + const checkIcon = checkbox?.querySelector('svg') + expect(checkIcon).toBeInTheDocument() + }) + + it('should not show check icon when isSelected is false', () => { + const { container } = render(, { wrapper: createWrapper() }) + const checkbox = findCheckbox(container) + expect(checkbox).toBeInTheDocument() + // When not selected, there should be no check icon inside the checkbox + const checkIcon = checkbox?.querySelector('svg') + expect(checkIcon).not.toBeInTheDocument() + }) + + it('should call onSelectOne when checkbox is clicked', () => { + const onSelectOne = vi.fn() + const { container } = render(, { wrapper: createWrapper() }) + + const checkbox = findCheckbox(container) + if (checkbox) { + fireEvent.click(checkbox) + expect(onSelectOne).toHaveBeenCalledWith('doc-1') + } + }) + + it('should stop propagation when checkbox container is clicked', () => { + const { container } = render(, { wrapper: createWrapper() }) + + // Click the div containing the checkbox (which has stopPropagation) + const checkboxContainer = container.querySelector('td')?.querySelector('div') + if (checkboxContainer) { + fireEvent.click(checkboxContainer) + expect(mockPush).not.toHaveBeenCalled() + } + }) + }) + + describe('Row Navigation', () => { + it('should navigate to document detail on row click', () => { + render(, { wrapper: createWrapper() }) + + const row = screen.getByRole('row') + fireEvent.click(row) + + expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/documents/doc-1') + }) + + it('should navigate with correct datasetId and documentId', () => { + render( + , + { wrapper: createWrapper() }, + ) + + const row = screen.getByRole('row') + fireEvent.click(row) + + expect(mockPush).toHaveBeenCalledWith('/datasets/custom-dataset/documents/custom-doc') + }) + }) + + describe('Word Count Display', () => { + it('should display word count less than 1000 as is', () => { + const doc = createMockDoc({ word_count: 500 }) + render(, { wrapper: createWrapper() }) + expect(screen.getByText('500')).toBeInTheDocument() + }) + + it('should display word count 1000 or more in k format', () => { + const doc = createMockDoc({ word_count: 1500 }) + render(, { wrapper: createWrapper() }) + expect(screen.getByText('1.5k')).toBeInTheDocument() + }) + + it('should display 0 with empty style when word_count is 0', () => { + const doc = createMockDoc({ word_count: 0 }) + const { container } = render(, { wrapper: createWrapper() }) + const zeroCells = container.querySelectorAll('.text-text-tertiary') + expect(zeroCells.length).toBeGreaterThan(0) + }) + + it('should handle undefined word_count', () => { + const doc = createMockDoc({ word_count: undefined as unknown as number }) + const { container } = render(, { wrapper: createWrapper() }) + expect(container).toBeInTheDocument() + }) + }) + + describe('Hit Count Display', () => { + it('should display hit count less than 1000 as is', () => { + const doc = createMockDoc({ hit_count: 100 }) + render(, { wrapper: createWrapper() }) + expect(screen.getByText('100')).toBeInTheDocument() + }) + + it('should display hit count 1000 or more in k format', () => { + const doc = createMockDoc({ hit_count: 2500 }) + render(, { wrapper: createWrapper() }) + expect(screen.getByText('2.5k')).toBeInTheDocument() + }) + + it('should display 0 with empty style when hit_count is 0', () => { + const doc = createMockDoc({ hit_count: 0 }) + const { container } = render(, { wrapper: createWrapper() }) + const zeroCells = container.querySelectorAll('.text-text-tertiary') + expect(zeroCells.length).toBeGreaterThan(0) + }) + }) + + describe('Chunking Mode', () => { + it('should render ChunkingModeLabel with general mode', () => { + render(, { wrapper: createWrapper() }) + // ChunkingModeLabel should be rendered + expect(screen.getByRole('row')).toBeInTheDocument() + }) + + it('should render ChunkingModeLabel with QA mode', () => { + render(, { wrapper: createWrapper() }) + expect(screen.getByRole('row')).toBeInTheDocument() + }) + }) + + describe('Summary Status', () => { + it('should render SummaryStatus when summary_index_status is present', () => { + const doc = createMockDoc({ summary_index_status: 'completed' }) + render(, { wrapper: createWrapper() }) + expect(screen.getByRole('row')).toBeInTheDocument() + }) + + it('should not render SummaryStatus when summary_index_status is absent', () => { + const doc = createMockDoc({ summary_index_status: undefined }) + render(, { wrapper: createWrapper() }) + expect(screen.getByRole('row')).toBeInTheDocument() + }) + }) + + describe('Rename Action', () => { + it('should call onShowRenameModal when rename button is clicked', () => { + const onShowRenameModal = vi.fn() + const { container } = render( + , + { wrapper: createWrapper() }, + ) + + // Find the rename button by finding the RiEditLine icon's parent + const renameButtons = container.querySelectorAll('.cursor-pointer.rounded-md') + if (renameButtons.length > 0) { + fireEvent.click(renameButtons[0]) + expect(onShowRenameModal).toHaveBeenCalledWith(defaultProps.doc) + expect(mockPush).not.toHaveBeenCalled() + } + }) + }) + + describe('Operations', () => { + it('should pass selectedIds to Operations component', () => { + render(, { wrapper: createWrapper() }) + expect(screen.getByRole('row')).toBeInTheDocument() + }) + + it('should pass onSelectedIdChange to Operations component', () => { + const onSelectedIdChange = vi.fn() + render(, { wrapper: createWrapper() }) + expect(screen.getByRole('row')).toBeInTheDocument() + }) + }) + + describe('Document Source Icon', () => { + it('should render with FILE data source type', () => { + const doc = createMockDoc({ data_source_type: DataSourceType.FILE }) + render(, { wrapper: createWrapper() }) + expect(screen.getByRole('row')).toBeInTheDocument() + }) + + it('should render with NOTION data source type', () => { + const doc = createMockDoc({ + data_source_type: DataSourceType.NOTION, + data_source_info: { notion_page_icon: 'icon.png' }, + }) + render(, { wrapper: createWrapper() }) + expect(screen.getByRole('row')).toBeInTheDocument() + }) + + it('should render with WEB data source type', () => { + const doc = createMockDoc({ data_source_type: DataSourceType.WEB }) + render(, { wrapper: createWrapper() }) + expect(screen.getByRole('row')).toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should handle document with very long name', () => { + const doc = createMockDoc({ name: `${'a'.repeat(500)}.txt` }) + render(, { wrapper: createWrapper() }) + expect(screen.getByRole('row')).toBeInTheDocument() + }) + + it('should handle document with special characters in name', () => { + const doc = createMockDoc({ name: '.txt' }) + render(, { wrapper: createWrapper() }) + expect(screen.getByText('.txt')).toBeInTheDocument() + }) + + it('should memoize the component', () => { + const wrapper = createWrapper() + const { rerender } = render(, { wrapper }) + + rerender() + expect(screen.getByRole('row')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/documents/components/document-list/components/document-table-row.tsx b/web/app/components/datasets/documents/components/document-list/components/document-table-row.tsx new file mode 100644 index 0000000000..731c14e731 --- /dev/null +++ b/web/app/components/datasets/documents/components/document-list/components/document-table-row.tsx @@ -0,0 +1,152 @@ +import type { FC } from 'react' +import type { SimpleDocumentDetail } from '@/models/datasets' +import { RiEditLine } from '@remixicon/react' +import { pick } from 'es-toolkit/object' +import { useRouter } from 'next/navigation' +import * as React from 'react' +import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import Checkbox from '@/app/components/base/checkbox' +import Tooltip from '@/app/components/base/tooltip' +import ChunkingModeLabel from '@/app/components/datasets/common/chunking-mode-label' +import Operations from '@/app/components/datasets/documents/components/operations' +import SummaryStatus from '@/app/components/datasets/documents/detail/completed/common/summary-status' +import StatusItem from '@/app/components/datasets/documents/status-item' +import useTimestamp from '@/hooks/use-timestamp' +import { DataSourceType } from '@/models/datasets' +import { formatNumber } from '@/utils/format' +import DocumentSourceIcon from './document-source-icon' +import { renderTdValue } from './utils' + +type LocalDoc = SimpleDocumentDetail & { percent?: number } + +type DocumentTableRowProps = { + doc: LocalDoc + index: number + datasetId: string + isSelected: boolean + isGeneralMode: boolean + isQAMode: boolean + embeddingAvailable: boolean + selectedIds: string[] + onSelectOne: (docId: string) => void + onSelectedIdChange: (ids: string[]) => void + onShowRenameModal: (doc: LocalDoc) => void + onUpdate: () => void +} + +const renderCount = (count: number | undefined) => { + if (!count) + return renderTdValue(0, true) + + if (count < 1000) + return count + + return `${formatNumber((count / 1000).toFixed(1))}k` +} + +const DocumentTableRow: FC = React.memo(({ + doc, + index, + datasetId, + isSelected, + isGeneralMode, + isQAMode, + embeddingAvailable, + selectedIds, + onSelectOne, + onSelectedIdChange, + onShowRenameModal, + onUpdate, +}) => { + const { t } = useTranslation() + const { formatTime } = useTimestamp() + const router = useRouter() + + const isFile = doc.data_source_type === DataSourceType.FILE + const fileType = isFile ? doc.data_source_detail_dict?.upload_file?.extension : '' + + const handleRowClick = useCallback(() => { + router.push(`/datasets/${datasetId}/documents/${doc.id}`) + }, [router, datasetId, doc.id]) + + const handleCheckboxClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + }, []) + + const handleRenameClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + onShowRenameModal(doc) + }, [doc, onShowRenameModal]) + + return ( + + +
+ onSelectOne(doc.id)} + /> + {index + 1} +
+ + +
+
+ +
+ + {doc.name} + + {doc.summary_index_status && ( +
+ +
+ )} +
+ +
+ +
+
+
+
+ + + + + {renderCount(doc.word_count)} + {renderCount(doc.hit_count)} + + {formatTime(doc.created_at, t('dateTimeFormat', { ns: 'datasetHitTesting' }) as string)} + + + + + + + + + ) +}) + +DocumentTableRow.displayName = 'DocumentTableRow' + +export default DocumentTableRow diff --git a/web/app/components/datasets/documents/components/document-list/components/index.ts b/web/app/components/datasets/documents/components/document-list/components/index.ts new file mode 100644 index 0000000000..377f64a27f --- /dev/null +++ b/web/app/components/datasets/documents/components/document-list/components/index.ts @@ -0,0 +1,4 @@ +export { default as DocumentSourceIcon } from './document-source-icon' +export { default as DocumentTableRow } from './document-table-row' +export { default as SortHeader } from './sort-header' +export { renderTdValue } from './utils' diff --git a/web/app/components/datasets/documents/components/document-list/components/sort-header.spec.tsx b/web/app/components/datasets/documents/components/document-list/components/sort-header.spec.tsx new file mode 100644 index 0000000000..15cc55247b --- /dev/null +++ b/web/app/components/datasets/documents/components/document-list/components/sort-header.spec.tsx @@ -0,0 +1,124 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import SortHeader from './sort-header' + +describe('SortHeader', () => { + const defaultProps = { + field: 'name' as const, + label: 'File Name', + currentSortField: null, + sortOrder: 'desc' as const, + onSort: vi.fn(), + } + + describe('rendering', () => { + it('should render the label', () => { + render() + expect(screen.getByText('File Name')).toBeInTheDocument() + }) + + it('should render the sort icon', () => { + const { container } = render() + const icon = container.querySelector('svg') + expect(icon).toBeInTheDocument() + }) + }) + + describe('inactive state', () => { + it('should have disabled text color when not active', () => { + const { container } = render() + const icon = container.querySelector('svg') + expect(icon).toHaveClass('text-text-disabled') + }) + + it('should not be rotated when not active', () => { + const { container } = render() + const icon = container.querySelector('svg') + expect(icon).not.toHaveClass('rotate-180') + }) + }) + + describe('active state', () => { + it('should have tertiary text color when active', () => { + const { container } = render( + , + ) + const icon = container.querySelector('svg') + expect(icon).toHaveClass('text-text-tertiary') + }) + + it('should not be rotated when active and desc', () => { + const { container } = render( + , + ) + const icon = container.querySelector('svg') + expect(icon).not.toHaveClass('rotate-180') + }) + + it('should be rotated when active and asc', () => { + const { container } = render( + , + ) + const icon = container.querySelector('svg') + expect(icon).toHaveClass('rotate-180') + }) + }) + + describe('interaction', () => { + it('should call onSort when clicked', () => { + const onSort = vi.fn() + render() + + fireEvent.click(screen.getByText('File Name')) + + expect(onSort).toHaveBeenCalledWith('name') + }) + + it('should call onSort with correct field', () => { + const onSort = vi.fn() + render() + + fireEvent.click(screen.getByText('File Name')) + + expect(onSort).toHaveBeenCalledWith('word_count') + }) + }) + + describe('different fields', () => { + it('should work with word_count field', () => { + render( + , + ) + expect(screen.getByText('Words')).toBeInTheDocument() + }) + + it('should work with hit_count field', () => { + render( + , + ) + expect(screen.getByText('Hit Count')).toBeInTheDocument() + }) + + it('should work with created_at field', () => { + render( + , + ) + expect(screen.getByText('Upload Time')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/documents/components/document-list/components/sort-header.tsx b/web/app/components/datasets/documents/components/document-list/components/sort-header.tsx new file mode 100644 index 0000000000..1dc13df2b0 --- /dev/null +++ b/web/app/components/datasets/documents/components/document-list/components/sort-header.tsx @@ -0,0 +1,44 @@ +import type { FC } from 'react' +import type { SortField, SortOrder } from '../hooks' +import { RiArrowDownLine } from '@remixicon/react' +import * as React from 'react' +import { cn } from '@/utils/classnames' + +type SortHeaderProps = { + field: Exclude + label: string + currentSortField: SortField + sortOrder: SortOrder + onSort: (field: SortField) => void +} + +const SortHeader: FC = React.memo(({ + field, + label, + currentSortField, + sortOrder, + onSort, +}) => { + const isActive = currentSortField === field + const isDesc = isActive && sortOrder === 'desc' + + return ( +
onSort(field)} + > + {label} + +
+ ) +}) + +SortHeader.displayName = 'SortHeader' + +export default SortHeader diff --git a/web/app/components/datasets/documents/components/document-list/components/utils.spec.tsx b/web/app/components/datasets/documents/components/document-list/components/utils.spec.tsx new file mode 100644 index 0000000000..7dc66d4d39 --- /dev/null +++ b/web/app/components/datasets/documents/components/document-list/components/utils.spec.tsx @@ -0,0 +1,90 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { renderTdValue } from './utils' + +describe('renderTdValue', () => { + describe('Rendering', () => { + it('should render string value correctly', () => { + const { container } = render(<>{renderTdValue('test value')}) + expect(screen.getByText('test value')).toBeInTheDocument() + expect(container.querySelector('div')).toHaveClass('text-text-secondary') + }) + + it('should render number value correctly', () => { + const { container } = render(<>{renderTdValue(42)}) + expect(screen.getByText('42')).toBeInTheDocument() + expect(container.querySelector('div')).toHaveClass('text-text-secondary') + }) + + it('should render zero correctly', () => { + const { container } = render(<>{renderTdValue(0)}) + expect(screen.getByText('0')).toBeInTheDocument() + expect(container.querySelector('div')).toHaveClass('text-text-secondary') + }) + }) + + describe('Null and undefined handling', () => { + it('should render dash for null value', () => { + render(<>{renderTdValue(null)}) + expect(screen.getByText('-')).toBeInTheDocument() + }) + + it('should render dash for null value with empty style', () => { + const { container } = render(<>{renderTdValue(null, true)}) + expect(screen.getByText('-')).toBeInTheDocument() + expect(container.querySelector('div')).toHaveClass('text-text-tertiary') + }) + }) + + describe('Empty style', () => { + it('should apply text-text-tertiary class when isEmptyStyle is true', () => { + const { container } = render(<>{renderTdValue('value', true)}) + expect(container.querySelector('div')).toHaveClass('text-text-tertiary') + }) + + it('should apply text-text-secondary class when isEmptyStyle is false', () => { + const { container } = render(<>{renderTdValue('value', false)}) + expect(container.querySelector('div')).toHaveClass('text-text-secondary') + }) + + it('should apply text-text-secondary class when isEmptyStyle is not provided', () => { + const { container } = render(<>{renderTdValue('value')}) + expect(container.querySelector('div')).toHaveClass('text-text-secondary') + }) + }) + + describe('Edge Cases', () => { + it('should handle empty string', () => { + render(<>{renderTdValue('')}) + // Empty string should still render but with no visible text + const div = document.querySelector('div') + expect(div).toBeInTheDocument() + }) + + it('should handle large numbers', () => { + render(<>{renderTdValue(1234567890)}) + expect(screen.getByText('1234567890')).toBeInTheDocument() + }) + + it('should handle negative numbers', () => { + render(<>{renderTdValue(-42)}) + expect(screen.getByText('-42')).toBeInTheDocument() + }) + + it('should handle special characters in string', () => { + render(<>{renderTdValue('')}) + expect(screen.getByText('')).toBeInTheDocument() + }) + + it('should handle unicode characters', () => { + render(<>{renderTdValue('Test Unicode: \u4E2D\u6587')}) + expect(screen.getByText('Test Unicode: \u4E2D\u6587')).toBeInTheDocument() + }) + + it('should handle very long strings', () => { + const longString = 'a'.repeat(1000) + render(<>{renderTdValue(longString)}) + expect(screen.getByText(longString)).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/documents/components/document-list/components/utils.tsx b/web/app/components/datasets/documents/components/document-list/components/utils.tsx new file mode 100644 index 0000000000..4cb652108d --- /dev/null +++ b/web/app/components/datasets/documents/components/document-list/components/utils.tsx @@ -0,0 +1,16 @@ +import type { ReactNode } from 'react' +import { cn } from '@/utils/classnames' +import s from '../../../style.module.css' + +export const renderTdValue = (value: string | number | null, isEmptyStyle = false): ReactNode => { + const className = cn( + isEmptyStyle ? 'text-text-tertiary' : 'text-text-secondary', + s.tdValue, + ) + + return ( +
+ {value ?? '-'} +
+ ) +} diff --git a/web/app/components/datasets/documents/components/document-list/hooks/index.ts b/web/app/components/datasets/documents/components/document-list/hooks/index.ts new file mode 100644 index 0000000000..3ca7a920f2 --- /dev/null +++ b/web/app/components/datasets/documents/components/document-list/hooks/index.ts @@ -0,0 +1,4 @@ +export { useDocumentActions } from './use-document-actions' +export { useDocumentSelection } from './use-document-selection' +export { useDocumentSort } from './use-document-sort' +export type { SortField, SortOrder } from './use-document-sort' diff --git a/web/app/components/datasets/documents/components/document-list/hooks/use-document-actions.spec.tsx b/web/app/components/datasets/documents/components/document-list/hooks/use-document-actions.spec.tsx new file mode 100644 index 0000000000..bc84477744 --- /dev/null +++ b/web/app/components/datasets/documents/components/document-list/hooks/use-document-actions.spec.tsx @@ -0,0 +1,438 @@ +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 { DocumentActionType } from '@/models/datasets' +import * as useDocument from '@/service/knowledge/use-document' +import { useDocumentActions } from './use-document-actions' + +vi.mock('@/service/knowledge/use-document') + +const mockUseDocumentArchive = vi.mocked(useDocument.useDocumentArchive) +const mockUseDocumentSummary = vi.mocked(useDocument.useDocumentSummary) +const mockUseDocumentEnable = vi.mocked(useDocument.useDocumentEnable) +const mockUseDocumentDisable = vi.mocked(useDocument.useDocumentDisable) +const mockUseDocumentDelete = vi.mocked(useDocument.useDocumentDelete) +const mockUseDocumentBatchRetryIndex = vi.mocked(useDocument.useDocumentBatchRetryIndex) +const mockUseDocumentDownloadZip = vi.mocked(useDocument.useDocumentDownloadZip) + +const createTestQueryClient = () => new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, +}) + +const createWrapper = () => { + const queryClient = createTestQueryClient() + return ({ children }: { children: ReactNode }) => ( + + {children} + + ) +} + +describe('useDocumentActions', () => { + const mockMutateAsync = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + + // Setup all mocks with default values + const createMockMutation = () => ({ + mutateAsync: mockMutateAsync, + isPending: false, + isError: false, + isSuccess: false, + isIdle: true, + data: undefined, + error: null, + mutate: vi.fn(), + reset: vi.fn(), + status: 'idle' as const, + variables: undefined, + context: undefined, + failureCount: 0, + failureReason: null, + submittedAt: 0, + }) + + mockUseDocumentArchive.mockReturnValue(createMockMutation() as unknown as ReturnType) + mockUseDocumentSummary.mockReturnValue(createMockMutation() as unknown as ReturnType) + mockUseDocumentEnable.mockReturnValue(createMockMutation() as unknown as ReturnType) + mockUseDocumentDisable.mockReturnValue(createMockMutation() as unknown as ReturnType) + mockUseDocumentDelete.mockReturnValue(createMockMutation() as unknown as ReturnType) + mockUseDocumentBatchRetryIndex.mockReturnValue(createMockMutation() as unknown as ReturnType) + mockUseDocumentDownloadZip.mockReturnValue({ + ...createMockMutation(), + isPending: false, + } as unknown as ReturnType) + }) + + describe('handleAction', () => { + it('should call archive mutation when archive action is triggered', async () => { + mockMutateAsync.mockResolvedValue({ result: 'success' }) + const onUpdate = vi.fn() + const onClearSelection = vi.fn() + + const { result } = renderHook( + () => useDocumentActions({ + datasetId: 'ds1', + selectedIds: ['doc1'], + downloadableSelectedIds: [], + onUpdate, + onClearSelection, + }), + { wrapper: createWrapper() }, + ) + + await act(async () => { + await result.current.handleAction(DocumentActionType.archive)() + }) + + expect(mockMutateAsync).toHaveBeenCalledWith({ + datasetId: 'ds1', + documentIds: ['doc1'], + }) + }) + + it('should call onUpdate on successful action', async () => { + mockMutateAsync.mockResolvedValue({ result: 'success' }) + const onUpdate = vi.fn() + const onClearSelection = vi.fn() + + const { result } = renderHook( + () => useDocumentActions({ + datasetId: 'ds1', + selectedIds: ['doc1'], + downloadableSelectedIds: [], + onUpdate, + onClearSelection, + }), + { wrapper: createWrapper() }, + ) + + await act(async () => { + await result.current.handleAction(DocumentActionType.enable)() + }) + + await waitFor(() => { + expect(onUpdate).toHaveBeenCalled() + }) + }) + + it('should call onClearSelection on delete action', async () => { + mockMutateAsync.mockResolvedValue({ result: 'success' }) + const onUpdate = vi.fn() + const onClearSelection = vi.fn() + + const { result } = renderHook( + () => useDocumentActions({ + datasetId: 'ds1', + selectedIds: ['doc1'], + downloadableSelectedIds: [], + onUpdate, + onClearSelection, + }), + { wrapper: createWrapper() }, + ) + + await act(async () => { + await result.current.handleAction(DocumentActionType.delete)() + }) + + await waitFor(() => { + expect(onClearSelection).toHaveBeenCalled() + }) + }) + }) + + describe('handleBatchReIndex', () => { + it('should call retry index mutation', async () => { + mockMutateAsync.mockResolvedValue({ result: 'success' }) + const onUpdate = vi.fn() + const onClearSelection = vi.fn() + + const { result } = renderHook( + () => useDocumentActions({ + datasetId: 'ds1', + selectedIds: ['doc1', 'doc2'], + downloadableSelectedIds: [], + onUpdate, + onClearSelection, + }), + { wrapper: createWrapper() }, + ) + + await act(async () => { + await result.current.handleBatchReIndex() + }) + + expect(mockMutateAsync).toHaveBeenCalledWith({ + datasetId: 'ds1', + documentIds: ['doc1', 'doc2'], + }) + }) + + it('should call onClearSelection on success', async () => { + mockMutateAsync.mockResolvedValue({ result: 'success' }) + const onUpdate = vi.fn() + const onClearSelection = vi.fn() + + const { result } = renderHook( + () => useDocumentActions({ + datasetId: 'ds1', + selectedIds: ['doc1'], + downloadableSelectedIds: [], + onUpdate, + onClearSelection, + }), + { wrapper: createWrapper() }, + ) + + await act(async () => { + await result.current.handleBatchReIndex() + }) + + await waitFor(() => { + expect(onClearSelection).toHaveBeenCalled() + expect(onUpdate).toHaveBeenCalled() + }) + }) + }) + + describe('handleBatchDownload', () => { + it('should not proceed when already downloading', async () => { + mockUseDocumentDownloadZip.mockReturnValue({ + mutateAsync: mockMutateAsync, + isPending: true, + } as unknown as ReturnType) + + const { result } = renderHook( + () => useDocumentActions({ + datasetId: 'ds1', + selectedIds: ['doc1'], + downloadableSelectedIds: ['doc1'], + onUpdate: vi.fn(), + onClearSelection: vi.fn(), + }), + { wrapper: createWrapper() }, + ) + + await act(async () => { + await result.current.handleBatchDownload() + }) + + expect(mockMutateAsync).not.toHaveBeenCalled() + }) + + it('should call download mutation with downloadable ids', async () => { + const mockBlob = new Blob(['test']) + mockMutateAsync.mockResolvedValue(mockBlob) + + mockUseDocumentDownloadZip.mockReturnValue({ + mutateAsync: mockMutateAsync, + isPending: false, + } as unknown as ReturnType) + + const { result } = renderHook( + () => useDocumentActions({ + datasetId: 'ds1', + selectedIds: ['doc1', 'doc2'], + downloadableSelectedIds: ['doc1'], + onUpdate: vi.fn(), + onClearSelection: vi.fn(), + }), + { wrapper: createWrapper() }, + ) + + await act(async () => { + await result.current.handleBatchDownload() + }) + + expect(mockMutateAsync).toHaveBeenCalledWith({ + datasetId: 'ds1', + documentIds: ['doc1'], + }) + }) + }) + + describe('isDownloadingZip', () => { + it('should reflect isPending state from mutation', () => { + mockUseDocumentDownloadZip.mockReturnValue({ + mutateAsync: mockMutateAsync, + isPending: true, + } as unknown as ReturnType) + + const { result } = renderHook( + () => useDocumentActions({ + datasetId: 'ds1', + selectedIds: [], + downloadableSelectedIds: [], + onUpdate: vi.fn(), + onClearSelection: vi.fn(), + }), + { wrapper: createWrapper() }, + ) + + expect(result.current.isDownloadingZip).toBe(true) + }) + }) + + describe('error handling', () => { + it('should show error toast when handleAction fails', async () => { + mockMutateAsync.mockRejectedValue(new Error('Action failed')) + const onUpdate = vi.fn() + const onClearSelection = vi.fn() + + const { result } = renderHook( + () => useDocumentActions({ + datasetId: 'ds1', + selectedIds: ['doc1'], + downloadableSelectedIds: [], + onUpdate, + onClearSelection, + }), + { wrapper: createWrapper() }, + ) + + await act(async () => { + await result.current.handleAction(DocumentActionType.archive)() + }) + + // onUpdate should not be called on error + expect(onUpdate).not.toHaveBeenCalled() + }) + + it('should show error toast when handleBatchReIndex fails', async () => { + mockMutateAsync.mockRejectedValue(new Error('Re-index failed')) + const onUpdate = vi.fn() + const onClearSelection = vi.fn() + + const { result } = renderHook( + () => useDocumentActions({ + datasetId: 'ds1', + selectedIds: ['doc1'], + downloadableSelectedIds: [], + onUpdate, + onClearSelection, + }), + { wrapper: createWrapper() }, + ) + + await act(async () => { + await result.current.handleBatchReIndex() + }) + + // onUpdate and onClearSelection should not be called on error + expect(onUpdate).not.toHaveBeenCalled() + expect(onClearSelection).not.toHaveBeenCalled() + }) + + it('should show error toast when handleBatchDownload fails', async () => { + mockMutateAsync.mockRejectedValue(new Error('Download failed')) + + mockUseDocumentDownloadZip.mockReturnValue({ + mutateAsync: mockMutateAsync, + isPending: false, + } as unknown as ReturnType) + + const { result } = renderHook( + () => useDocumentActions({ + datasetId: 'ds1', + selectedIds: ['doc1'], + downloadableSelectedIds: ['doc1'], + onUpdate: vi.fn(), + onClearSelection: vi.fn(), + }), + { wrapper: createWrapper() }, + ) + + await act(async () => { + await result.current.handleBatchDownload() + }) + + // Mutation was called but failed + expect(mockMutateAsync).toHaveBeenCalled() + }) + + it('should show error toast when handleBatchDownload returns null blob', async () => { + mockMutateAsync.mockResolvedValue(null) + + mockUseDocumentDownloadZip.mockReturnValue({ + mutateAsync: mockMutateAsync, + isPending: false, + } as unknown as ReturnType) + + const { result } = renderHook( + () => useDocumentActions({ + datasetId: 'ds1', + selectedIds: ['doc1'], + downloadableSelectedIds: ['doc1'], + onUpdate: vi.fn(), + onClearSelection: vi.fn(), + }), + { wrapper: createWrapper() }, + ) + + await act(async () => { + await result.current.handleBatchDownload() + }) + + // Mutation was called but returned null + expect(mockMutateAsync).toHaveBeenCalled() + }) + }) + + describe('all action types', () => { + it('should handle summary action', async () => { + mockMutateAsync.mockResolvedValue({ result: 'success' }) + const onUpdate = vi.fn() + + const { result } = renderHook( + () => useDocumentActions({ + datasetId: 'ds1', + selectedIds: ['doc1'], + downloadableSelectedIds: [], + onUpdate, + onClearSelection: vi.fn(), + }), + { wrapper: createWrapper() }, + ) + + await act(async () => { + await result.current.handleAction(DocumentActionType.summary)() + }) + + expect(mockMutateAsync).toHaveBeenCalled() + await waitFor(() => { + expect(onUpdate).toHaveBeenCalled() + }) + }) + + it('should handle disable action', async () => { + mockMutateAsync.mockResolvedValue({ result: 'success' }) + const onUpdate = vi.fn() + + const { result } = renderHook( + () => useDocumentActions({ + datasetId: 'ds1', + selectedIds: ['doc1'], + downloadableSelectedIds: [], + onUpdate, + onClearSelection: vi.fn(), + }), + { wrapper: createWrapper() }, + ) + + await act(async () => { + await result.current.handleAction(DocumentActionType.disable)() + }) + + expect(mockMutateAsync).toHaveBeenCalled() + await waitFor(() => { + expect(onUpdate).toHaveBeenCalled() + }) + }) + }) +}) diff --git a/web/app/components/datasets/documents/components/document-list/hooks/use-document-actions.ts b/web/app/components/datasets/documents/components/document-list/hooks/use-document-actions.ts new file mode 100644 index 0000000000..5496ba326f --- /dev/null +++ b/web/app/components/datasets/documents/components/document-list/hooks/use-document-actions.ts @@ -0,0 +1,126 @@ +import type { CommonResponse } from '@/models/common' +import { useCallback, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import Toast from '@/app/components/base/toast' +import { DocumentActionType } from '@/models/datasets' +import { + useDocumentArchive, + useDocumentBatchRetryIndex, + useDocumentDelete, + useDocumentDisable, + useDocumentDownloadZip, + useDocumentEnable, + useDocumentSummary, +} from '@/service/knowledge/use-document' +import { asyncRunSafe } from '@/utils' +import { downloadBlob } from '@/utils/download' + +type UseDocumentActionsOptions = { + datasetId: string + selectedIds: string[] + downloadableSelectedIds: string[] + onUpdate: () => void + onClearSelection: () => void +} + +/** + * Generate a random ZIP filename for bulk document downloads. + * We intentionally avoid leaking dataset info in the exported archive name. + */ +const generateDocsZipFileName = (): string => { + const randomPart = (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') + ? crypto.randomUUID() + : `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}` + return `${randomPart}-docs.zip` +} + +export const useDocumentActions = ({ + datasetId, + selectedIds, + downloadableSelectedIds, + onUpdate, + onClearSelection, +}: UseDocumentActionsOptions) => { + const { t } = useTranslation() + + const { mutateAsync: archiveDocument } = useDocumentArchive() + const { mutateAsync: generateSummary } = useDocumentSummary() + const { mutateAsync: enableDocument } = useDocumentEnable() + const { mutateAsync: disableDocument } = useDocumentDisable() + const { mutateAsync: deleteDocument } = useDocumentDelete() + const { mutateAsync: retryIndexDocument } = useDocumentBatchRetryIndex() + const { mutateAsync: requestDocumentsZip, isPending: isDownloadingZip } = useDocumentDownloadZip() + + type SupportedActionType + = | typeof DocumentActionType.archive + | typeof DocumentActionType.summary + | typeof DocumentActionType.enable + | typeof DocumentActionType.disable + | typeof DocumentActionType.delete + + const actionMutationMap = useMemo(() => ({ + [DocumentActionType.archive]: archiveDocument, + [DocumentActionType.summary]: generateSummary, + [DocumentActionType.enable]: enableDocument, + [DocumentActionType.disable]: disableDocument, + [DocumentActionType.delete]: deleteDocument, + } as const), [archiveDocument, generateSummary, enableDocument, disableDocument, deleteDocument]) + + const handleAction = useCallback((actionName: SupportedActionType) => { + return async () => { + const opApi = actionMutationMap[actionName] + if (!opApi) + return + + const [e] = await asyncRunSafe( + opApi({ datasetId, documentIds: selectedIds }) as Promise, + ) + + if (!e) { + if (actionName === DocumentActionType.delete) + onClearSelection() + Toast.notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) }) + onUpdate() + } + else { + Toast.notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) }) + } + } + }, [actionMutationMap, datasetId, selectedIds, onClearSelection, onUpdate, t]) + + const handleBatchReIndex = useCallback(async () => { + const [e] = await asyncRunSafe( + retryIndexDocument({ datasetId, documentIds: selectedIds }), + ) + if (!e) { + onClearSelection() + Toast.notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) }) + onUpdate() + } + else { + Toast.notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) }) + } + }, [retryIndexDocument, datasetId, selectedIds, onClearSelection, onUpdate, t]) + + const handleBatchDownload = useCallback(async () => { + if (isDownloadingZip) + return + + const [e, blob] = await asyncRunSafe( + requestDocumentsZip({ datasetId, documentIds: downloadableSelectedIds }), + ) + if (e || !blob) { + Toast.notify({ type: 'error', message: t('actionMsg.downloadUnsuccessfully', { ns: 'common' }) }) + return + } + + downloadBlob({ data: blob, fileName: generateDocsZipFileName() }) + }, [datasetId, downloadableSelectedIds, isDownloadingZip, requestDocumentsZip, t]) + + return { + handleAction, + handleBatchReIndex, + handleBatchDownload, + isDownloadingZip, + } +} diff --git a/web/app/components/datasets/documents/components/document-list/hooks/use-document-selection.spec.ts b/web/app/components/datasets/documents/components/document-list/hooks/use-document-selection.spec.ts new file mode 100644 index 0000000000..7775c83f1c --- /dev/null +++ b/web/app/components/datasets/documents/components/document-list/hooks/use-document-selection.spec.ts @@ -0,0 +1,317 @@ +import type { SimpleDocumentDetail } from '@/models/datasets' +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { DataSourceType } from '@/models/datasets' +import { useDocumentSelection } from './use-document-selection' + +type LocalDoc = SimpleDocumentDetail & { percent?: number } + +const createMockDocument = (overrides: Partial = {}): LocalDoc => ({ + id: 'doc1', + name: 'Test Document', + data_source_type: DataSourceType.FILE, + data_source_info: {}, + data_source_detail_dict: {}, + word_count: 100, + hit_count: 10, + created_at: 1000000, + position: 1, + doc_form: 'text_model', + enabled: true, + archived: false, + display_status: 'available', + created_from: 'api', + ...overrides, +} as LocalDoc) + +describe('useDocumentSelection', () => { + describe('isAllSelected', () => { + it('should return false when documents is empty', () => { + const onSelectedIdChange = vi.fn() + const { result } = renderHook(() => + useDocumentSelection({ + documents: [], + selectedIds: [], + onSelectedIdChange, + }), + ) + + expect(result.current.isAllSelected).toBe(false) + }) + + it('should return true when all documents are selected', () => { + const docs = [ + createMockDocument({ id: 'doc1' }), + createMockDocument({ id: 'doc2' }), + ] + const onSelectedIdChange = vi.fn() + + const { result } = renderHook(() => + useDocumentSelection({ + documents: docs, + selectedIds: ['doc1', 'doc2'], + onSelectedIdChange, + }), + ) + + expect(result.current.isAllSelected).toBe(true) + }) + + it('should return false when not all documents are selected', () => { + const docs = [ + createMockDocument({ id: 'doc1' }), + createMockDocument({ id: 'doc2' }), + ] + const onSelectedIdChange = vi.fn() + + const { result } = renderHook(() => + useDocumentSelection({ + documents: docs, + selectedIds: ['doc1'], + onSelectedIdChange, + }), + ) + + expect(result.current.isAllSelected).toBe(false) + }) + }) + + describe('isSomeSelected', () => { + it('should return false when no documents are selected', () => { + const docs = [createMockDocument({ id: 'doc1' })] + const onSelectedIdChange = vi.fn() + + const { result } = renderHook(() => + useDocumentSelection({ + documents: docs, + selectedIds: [], + onSelectedIdChange, + }), + ) + + expect(result.current.isSomeSelected).toBe(false) + }) + + it('should return true when some documents are selected', () => { + const docs = [ + createMockDocument({ id: 'doc1' }), + createMockDocument({ id: 'doc2' }), + ] + const onSelectedIdChange = vi.fn() + + const { result } = renderHook(() => + useDocumentSelection({ + documents: docs, + selectedIds: ['doc1'], + onSelectedIdChange, + }), + ) + + expect(result.current.isSomeSelected).toBe(true) + }) + }) + + describe('onSelectAll', () => { + it('should select all documents when none are selected', () => { + const docs = [ + createMockDocument({ id: 'doc1' }), + createMockDocument({ id: 'doc2' }), + ] + const onSelectedIdChange = vi.fn() + + const { result } = renderHook(() => + useDocumentSelection({ + documents: docs, + selectedIds: [], + onSelectedIdChange, + }), + ) + + act(() => { + result.current.onSelectAll() + }) + + expect(onSelectedIdChange).toHaveBeenCalledWith(['doc1', 'doc2']) + }) + + it('should deselect all when all are selected', () => { + const docs = [ + createMockDocument({ id: 'doc1' }), + createMockDocument({ id: 'doc2' }), + ] + const onSelectedIdChange = vi.fn() + + const { result } = renderHook(() => + useDocumentSelection({ + documents: docs, + selectedIds: ['doc1', 'doc2'], + onSelectedIdChange, + }), + ) + + act(() => { + result.current.onSelectAll() + }) + + expect(onSelectedIdChange).toHaveBeenCalledWith([]) + }) + + it('should add to existing selection when some are selected', () => { + const docs = [ + createMockDocument({ id: 'doc1' }), + createMockDocument({ id: 'doc2' }), + createMockDocument({ id: 'doc3' }), + ] + const onSelectedIdChange = vi.fn() + + const { result } = renderHook(() => + useDocumentSelection({ + documents: docs, + selectedIds: ['doc1'], + onSelectedIdChange, + }), + ) + + act(() => { + result.current.onSelectAll() + }) + + expect(onSelectedIdChange).toHaveBeenCalledWith(['doc1', 'doc2', 'doc3']) + }) + }) + + describe('onSelectOne', () => { + it('should add document to selection when not selected', () => { + const onSelectedIdChange = vi.fn() + + const { result } = renderHook(() => + useDocumentSelection({ + documents: [], + selectedIds: [], + onSelectedIdChange, + }), + ) + + act(() => { + result.current.onSelectOne('doc1') + }) + + expect(onSelectedIdChange).toHaveBeenCalledWith(['doc1']) + }) + + it('should remove document from selection when already selected', () => { + const onSelectedIdChange = vi.fn() + + const { result } = renderHook(() => + useDocumentSelection({ + documents: [], + selectedIds: ['doc1', 'doc2'], + onSelectedIdChange, + }), + ) + + act(() => { + result.current.onSelectOne('doc1') + }) + + expect(onSelectedIdChange).toHaveBeenCalledWith(['doc2']) + }) + }) + + describe('hasErrorDocumentsSelected', () => { + it('should return false when no error documents are selected', () => { + const docs = [ + createMockDocument({ id: 'doc1', display_status: 'available' }), + createMockDocument({ id: 'doc2', display_status: 'error' }), + ] + const onSelectedIdChange = vi.fn() + + const { result } = renderHook(() => + useDocumentSelection({ + documents: docs, + selectedIds: ['doc1'], + onSelectedIdChange, + }), + ) + + expect(result.current.hasErrorDocumentsSelected).toBe(false) + }) + + it('should return true when an error document is selected', () => { + const docs = [ + createMockDocument({ id: 'doc1', display_status: 'available' }), + createMockDocument({ id: 'doc2', display_status: 'error' }), + ] + const onSelectedIdChange = vi.fn() + + const { result } = renderHook(() => + useDocumentSelection({ + documents: docs, + selectedIds: ['doc2'], + onSelectedIdChange, + }), + ) + + expect(result.current.hasErrorDocumentsSelected).toBe(true) + }) + }) + + describe('downloadableSelectedIds', () => { + it('should return only FILE type documents from selection', () => { + const docs = [ + createMockDocument({ id: 'doc1', data_source_type: DataSourceType.FILE }), + createMockDocument({ id: 'doc2', data_source_type: DataSourceType.NOTION }), + createMockDocument({ id: 'doc3', data_source_type: DataSourceType.FILE }), + ] + const onSelectedIdChange = vi.fn() + + const { result } = renderHook(() => + useDocumentSelection({ + documents: docs, + selectedIds: ['doc1', 'doc2', 'doc3'], + onSelectedIdChange, + }), + ) + + expect(result.current.downloadableSelectedIds).toEqual(['doc1', 'doc3']) + }) + + it('should return empty array when no FILE documents selected', () => { + const docs = [ + createMockDocument({ id: 'doc1', data_source_type: DataSourceType.NOTION }), + createMockDocument({ id: 'doc2', data_source_type: DataSourceType.WEB }), + ] + const onSelectedIdChange = vi.fn() + + const { result } = renderHook(() => + useDocumentSelection({ + documents: docs, + selectedIds: ['doc1', 'doc2'], + onSelectedIdChange, + }), + ) + + expect(result.current.downloadableSelectedIds).toEqual([]) + }) + }) + + describe('clearSelection', () => { + it('should call onSelectedIdChange with empty array', () => { + const onSelectedIdChange = vi.fn() + + const { result } = renderHook(() => + useDocumentSelection({ + documents: [], + selectedIds: ['doc1', 'doc2'], + onSelectedIdChange, + }), + ) + + act(() => { + result.current.clearSelection() + }) + + expect(onSelectedIdChange).toHaveBeenCalledWith([]) + }) + }) +}) diff --git a/web/app/components/datasets/documents/components/document-list/hooks/use-document-selection.ts b/web/app/components/datasets/documents/components/document-list/hooks/use-document-selection.ts new file mode 100644 index 0000000000..ad12b2b00f --- /dev/null +++ b/web/app/components/datasets/documents/components/document-list/hooks/use-document-selection.ts @@ -0,0 +1,66 @@ +import type { SimpleDocumentDetail } from '@/models/datasets' +import { uniq } from 'es-toolkit/array' +import { useCallback, useMemo } from 'react' +import { DataSourceType } from '@/models/datasets' + +type LocalDoc = SimpleDocumentDetail & { percent?: number } + +type UseDocumentSelectionOptions = { + documents: LocalDoc[] + selectedIds: string[] + onSelectedIdChange: (selectedIds: string[]) => void +} + +export const useDocumentSelection = ({ + documents, + selectedIds, + onSelectedIdChange, +}: UseDocumentSelectionOptions) => { + const isAllSelected = useMemo(() => { + return documents.length > 0 && documents.every(doc => selectedIds.includes(doc.id)) + }, [documents, selectedIds]) + + const isSomeSelected = useMemo(() => { + return documents.some(doc => selectedIds.includes(doc.id)) + }, [documents, selectedIds]) + + const onSelectAll = useCallback(() => { + if (isAllSelected) + onSelectedIdChange([]) + else + onSelectedIdChange(uniq([...selectedIds, ...documents.map(doc => doc.id)])) + }, [isAllSelected, documents, onSelectedIdChange, selectedIds]) + + const onSelectOne = useCallback((docId: string) => { + onSelectedIdChange( + selectedIds.includes(docId) + ? selectedIds.filter(id => id !== docId) + : [...selectedIds, docId], + ) + }, [selectedIds, onSelectedIdChange]) + + const hasErrorDocumentsSelected = useMemo(() => { + return documents.some(doc => selectedIds.includes(doc.id) && doc.display_status === 'error') + }, [documents, selectedIds]) + + const downloadableSelectedIds = useMemo(() => { + const selectedSet = new Set(selectedIds) + return documents + .filter(doc => selectedSet.has(doc.id) && doc.data_source_type === DataSourceType.FILE) + .map(doc => doc.id) + }, [documents, selectedIds]) + + const clearSelection = useCallback(() => { + onSelectedIdChange([]) + }, [onSelectedIdChange]) + + return { + isAllSelected, + isSomeSelected, + onSelectAll, + onSelectOne, + hasErrorDocumentsSelected, + downloadableSelectedIds, + clearSelection, + } +} diff --git a/web/app/components/datasets/documents/components/document-list/hooks/use-document-sort.spec.ts b/web/app/components/datasets/documents/components/document-list/hooks/use-document-sort.spec.ts new file mode 100644 index 0000000000..a41b42d6fa --- /dev/null +++ b/web/app/components/datasets/documents/components/document-list/hooks/use-document-sort.spec.ts @@ -0,0 +1,340 @@ +import type { SimpleDocumentDetail } from '@/models/datasets' +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { useDocumentSort } from './use-document-sort' + +type LocalDoc = SimpleDocumentDetail & { percent?: number } + +const createMockDocument = (overrides: Partial = {}): LocalDoc => ({ + id: 'doc1', + name: 'Test Document', + data_source_type: 'upload_file', + data_source_info: {}, + data_source_detail_dict: {}, + word_count: 100, + hit_count: 10, + created_at: 1000000, + position: 1, + doc_form: 'text_model', + enabled: true, + archived: false, + display_status: 'available', + created_from: 'api', + ...overrides, +} as LocalDoc) + +describe('useDocumentSort', () => { + describe('initial state', () => { + it('should return null sortField initially', () => { + const { result } = renderHook(() => + useDocumentSort({ + documents: [], + statusFilterValue: '', + remoteSortValue: '', + }), + ) + + expect(result.current.sortField).toBeNull() + expect(result.current.sortOrder).toBe('desc') + }) + + it('should return documents unchanged when no sort is applied', () => { + const docs = [ + createMockDocument({ id: 'doc1', name: 'B' }), + createMockDocument({ id: 'doc2', name: 'A' }), + ] + + const { result } = renderHook(() => + useDocumentSort({ + documents: docs, + statusFilterValue: '', + remoteSortValue: '', + }), + ) + + expect(result.current.sortedDocuments).toEqual(docs) + }) + }) + + describe('handleSort', () => { + it('should set sort field when called', () => { + const { result } = renderHook(() => + useDocumentSort({ + documents: [], + statusFilterValue: '', + remoteSortValue: '', + }), + ) + + act(() => { + result.current.handleSort('name') + }) + + expect(result.current.sortField).toBe('name') + expect(result.current.sortOrder).toBe('desc') + }) + + it('should toggle sort order when same field is clicked twice', () => { + const { result } = renderHook(() => + useDocumentSort({ + documents: [], + statusFilterValue: '', + remoteSortValue: '', + }), + ) + + act(() => { + result.current.handleSort('name') + }) + expect(result.current.sortOrder).toBe('desc') + + act(() => { + result.current.handleSort('name') + }) + expect(result.current.sortOrder).toBe('asc') + + act(() => { + result.current.handleSort('name') + }) + expect(result.current.sortOrder).toBe('desc') + }) + + it('should reset to desc when different field is selected', () => { + const { result } = renderHook(() => + useDocumentSort({ + documents: [], + statusFilterValue: '', + remoteSortValue: '', + }), + ) + + act(() => { + result.current.handleSort('name') + }) + act(() => { + result.current.handleSort('name') + }) + expect(result.current.sortOrder).toBe('asc') + + act(() => { + result.current.handleSort('word_count') + }) + expect(result.current.sortField).toBe('word_count') + expect(result.current.sortOrder).toBe('desc') + }) + + it('should not change state when null is passed', () => { + const { result } = renderHook(() => + useDocumentSort({ + documents: [], + statusFilterValue: '', + remoteSortValue: '', + }), + ) + + act(() => { + result.current.handleSort(null) + }) + + expect(result.current.sortField).toBeNull() + }) + }) + + describe('sorting documents', () => { + const docs = [ + createMockDocument({ id: 'doc1', name: 'Banana', word_count: 200, hit_count: 5, created_at: 3000 }), + createMockDocument({ id: 'doc2', name: 'Apple', word_count: 100, hit_count: 10, created_at: 1000 }), + createMockDocument({ id: 'doc3', name: 'Cherry', word_count: 300, hit_count: 1, created_at: 2000 }), + ] + + it('should sort by name descending', () => { + const { result } = renderHook(() => + useDocumentSort({ + documents: docs, + statusFilterValue: '', + remoteSortValue: '', + }), + ) + + act(() => { + result.current.handleSort('name') + }) + + const names = result.current.sortedDocuments.map(d => d.name) + expect(names).toEqual(['Cherry', 'Banana', 'Apple']) + }) + + it('should sort by name ascending', () => { + const { result } = renderHook(() => + useDocumentSort({ + documents: docs, + statusFilterValue: '', + remoteSortValue: '', + }), + ) + + act(() => { + result.current.handleSort('name') + }) + act(() => { + result.current.handleSort('name') + }) + + const names = result.current.sortedDocuments.map(d => d.name) + expect(names).toEqual(['Apple', 'Banana', 'Cherry']) + }) + + it('should sort by word_count descending', () => { + const { result } = renderHook(() => + useDocumentSort({ + documents: docs, + statusFilterValue: '', + remoteSortValue: '', + }), + ) + + act(() => { + result.current.handleSort('word_count') + }) + + const counts = result.current.sortedDocuments.map(d => d.word_count) + expect(counts).toEqual([300, 200, 100]) + }) + + it('should sort by hit_count ascending', () => { + const { result } = renderHook(() => + useDocumentSort({ + documents: docs, + statusFilterValue: '', + remoteSortValue: '', + }), + ) + + act(() => { + result.current.handleSort('hit_count') + }) + act(() => { + result.current.handleSort('hit_count') + }) + + const counts = result.current.sortedDocuments.map(d => d.hit_count) + expect(counts).toEqual([1, 5, 10]) + }) + + it('should sort by created_at descending', () => { + const { result } = renderHook(() => + useDocumentSort({ + documents: docs, + statusFilterValue: '', + remoteSortValue: '', + }), + ) + + act(() => { + result.current.handleSort('created_at') + }) + + const times = result.current.sortedDocuments.map(d => d.created_at) + expect(times).toEqual([3000, 2000, 1000]) + }) + }) + + describe('status filtering', () => { + const docs = [ + createMockDocument({ id: 'doc1', display_status: 'available' }), + createMockDocument({ id: 'doc2', display_status: 'error' }), + createMockDocument({ id: 'doc3', display_status: 'available' }), + ] + + it('should not filter when statusFilterValue is empty', () => { + const { result } = renderHook(() => + useDocumentSort({ + documents: docs, + statusFilterValue: '', + remoteSortValue: '', + }), + ) + + expect(result.current.sortedDocuments.length).toBe(3) + }) + + it('should not filter when statusFilterValue is all', () => { + const { result } = renderHook(() => + useDocumentSort({ + documents: docs, + statusFilterValue: 'all', + remoteSortValue: '', + }), + ) + + expect(result.current.sortedDocuments.length).toBe(3) + }) + }) + + describe('remoteSortValue reset', () => { + it('should reset sort state when remoteSortValue changes', () => { + const { result, rerender } = renderHook( + ({ remoteSortValue }) => + useDocumentSort({ + documents: [], + statusFilterValue: '', + remoteSortValue, + }), + { initialProps: { remoteSortValue: 'initial' } }, + ) + + act(() => { + result.current.handleSort('name') + }) + act(() => { + result.current.handleSort('name') + }) + expect(result.current.sortField).toBe('name') + expect(result.current.sortOrder).toBe('asc') + + rerender({ remoteSortValue: 'changed' }) + + expect(result.current.sortField).toBeNull() + expect(result.current.sortOrder).toBe('desc') + }) + }) + + describe('edge cases', () => { + it('should handle documents with missing values', () => { + const docs = [ + createMockDocument({ id: 'doc1', name: undefined as unknown as string, word_count: undefined }), + createMockDocument({ id: 'doc2', name: 'Test', word_count: 100 }), + ] + + const { result } = renderHook(() => + useDocumentSort({ + documents: docs, + statusFilterValue: '', + remoteSortValue: '', + }), + ) + + act(() => { + result.current.handleSort('name') + }) + + expect(result.current.sortedDocuments.length).toBe(2) + }) + + it('should handle empty documents array', () => { + const { result } = renderHook(() => + useDocumentSort({ + documents: [], + statusFilterValue: '', + remoteSortValue: '', + }), + ) + + act(() => { + result.current.handleSort('name') + }) + + expect(result.current.sortedDocuments).toEqual([]) + }) + }) +}) diff --git a/web/app/components/datasets/documents/components/document-list/hooks/use-document-sort.ts b/web/app/components/datasets/documents/components/document-list/hooks/use-document-sort.ts new file mode 100644 index 0000000000..98cf244f36 --- /dev/null +++ b/web/app/components/datasets/documents/components/document-list/hooks/use-document-sort.ts @@ -0,0 +1,102 @@ +import type { SimpleDocumentDetail } from '@/models/datasets' +import { useCallback, useMemo, useRef, useState } from 'react' +import { normalizeStatusForQuery } from '@/app/components/datasets/documents/status-filter' + +export type SortField = 'name' | 'word_count' | 'hit_count' | 'created_at' | null +export type SortOrder = 'asc' | 'desc' + +type LocalDoc = SimpleDocumentDetail & { percent?: number } + +type UseDocumentSortOptions = { + documents: LocalDoc[] + statusFilterValue: string + remoteSortValue: string +} + +export const useDocumentSort = ({ + documents, + statusFilterValue, + remoteSortValue, +}: UseDocumentSortOptions) => { + const [sortField, setSortField] = useState(null) + const [sortOrder, setSortOrder] = useState('desc') + const prevRemoteSortValueRef = useRef(remoteSortValue) + + // Reset sort when remote sort changes + if (prevRemoteSortValueRef.current !== remoteSortValue) { + prevRemoteSortValueRef.current = remoteSortValue + setSortField(null) + setSortOrder('desc') + } + + const handleSort = useCallback((field: SortField) => { + if (field === null) + return + + if (sortField === field) { + setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc') + } + else { + setSortField(field) + setSortOrder('desc') + } + }, [sortField]) + + const sortedDocuments = useMemo(() => { + let filteredDocs = documents + + if (statusFilterValue && statusFilterValue !== 'all') { + filteredDocs = filteredDocs.filter(doc => + typeof doc.display_status === 'string' + && normalizeStatusForQuery(doc.display_status) === statusFilterValue, + ) + } + + if (!sortField) + return filteredDocs + + const sortedDocs = [...filteredDocs].sort((a, b) => { + let aValue: string | number + let bValue: string | number + + switch (sortField) { + case 'name': + aValue = a.name?.toLowerCase() || '' + bValue = b.name?.toLowerCase() || '' + break + case 'word_count': + aValue = a.word_count || 0 + bValue = b.word_count || 0 + break + case 'hit_count': + aValue = a.hit_count || 0 + bValue = b.hit_count || 0 + break + case 'created_at': + aValue = a.created_at + bValue = b.created_at + break + default: + return 0 + } + + if (sortField === 'name') { + const result = (aValue as string).localeCompare(bValue as string) + return sortOrder === 'asc' ? result : -result + } + else { + const result = (aValue as number) - (bValue as number) + return sortOrder === 'asc' ? result : -result + } + }) + + return sortedDocs + }, [documents, sortField, sortOrder, statusFilterValue]) + + return { + sortField, + sortOrder, + handleSort, + sortedDocuments, + } +} diff --git a/web/app/components/datasets/documents/components/document-list/index.spec.tsx b/web/app/components/datasets/documents/components/document-list/index.spec.tsx new file mode 100644 index 0000000000..32429cc0ac --- /dev/null +++ b/web/app/components/datasets/documents/components/document-list/index.spec.tsx @@ -0,0 +1,487 @@ +import type { ReactNode } from 'react' +import type { Props as PaginationProps } from '@/app/components/base/pagination' +import type { SimpleDocumentDetail } from '@/models/datasets' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ChunkingMode, DataSourceType } from '@/models/datasets' +import DocumentList from '../list' + +const mockPush = vi.fn() + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: mockPush, + }), +})) + +vi.mock('@/context/dataset-detail', () => ({ + useDatasetDetailContextWithSelector: (selector: (state: { dataset: { doc_form: string } }) => unknown) => + selector({ dataset: { doc_form: ChunkingMode.text } }), +})) + +const createTestQueryClient = () => new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0 }, + mutations: { retry: false }, + }, +}) + +const createWrapper = () => { + const queryClient = createTestQueryClient() + return ({ children }: { children: ReactNode }) => ( + + {children} + + ) +} + +const createMockDoc = (overrides: Partial = {}): SimpleDocumentDetail => ({ + id: `doc-${Math.random().toString(36).substr(2, 9)}`, + position: 1, + data_source_type: DataSourceType.FILE, + data_source_info: {}, + data_source_detail_dict: { + upload_file: { name: 'test.txt', extension: 'txt' }, + }, + dataset_process_rule_id: 'rule-1', + batch: 'batch-1', + name: 'test-document.txt', + created_from: 'web', + created_by: 'user-1', + created_at: Date.now(), + tokens: 100, + indexing_status: 'completed', + error: null, + enabled: true, + disabled_at: null, + disabled_by: null, + archived: false, + archived_reason: null, + archived_by: null, + archived_at: null, + updated_at: Date.now(), + doc_type: null, + doc_metadata: undefined, + display_status: 'available', + word_count: 500, + hit_count: 10, + doc_form: 'text_model', + ...overrides, +} as SimpleDocumentDetail) + +const defaultPagination: PaginationProps = { + current: 1, + onChange: vi.fn(), + total: 100, +} + +describe('DocumentList', () => { + const defaultProps = { + embeddingAvailable: true, + documents: [ + createMockDoc({ id: 'doc-1', name: 'Document 1.txt', word_count: 100, hit_count: 5 }), + createMockDoc({ id: 'doc-2', name: 'Document 2.txt', word_count: 200, hit_count: 10 }), + createMockDoc({ id: 'doc-3', name: 'Document 3.txt', word_count: 300, hit_count: 15 }), + ], + selectedIds: [] as string[], + onSelectedIdChange: vi.fn(), + datasetId: 'dataset-1', + pagination: defaultPagination, + onUpdate: vi.fn(), + onManageMetadata: vi.fn(), + statusFilterValue: '', + remoteSortValue: '', + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + render(, { wrapper: createWrapper() }) + expect(screen.getByRole('table')).toBeInTheDocument() + }) + + it('should render all documents', () => { + render(, { wrapper: createWrapper() }) + expect(screen.getByText('Document 1.txt')).toBeInTheDocument() + expect(screen.getByText('Document 2.txt')).toBeInTheDocument() + expect(screen.getByText('Document 3.txt')).toBeInTheDocument() + }) + + it('should render table headers', () => { + render(, { wrapper: createWrapper() }) + expect(screen.getByText('#')).toBeInTheDocument() + }) + + it('should render pagination when total is provided', () => { + render(, { wrapper: createWrapper() }) + // Pagination component should be present + expect(screen.getByRole('table')).toBeInTheDocument() + }) + + it('should not render pagination when total is 0', () => { + const props = { + ...defaultProps, + pagination: { ...defaultPagination, total: 0 }, + } + render(, { wrapper: createWrapper() }) + expect(screen.getByRole('table')).toBeInTheDocument() + }) + + it('should render empty table when no documents', () => { + const props = { ...defaultProps, documents: [] } + render(, { wrapper: createWrapper() }) + expect(screen.getByRole('table')).toBeInTheDocument() + }) + }) + + describe('Selection', () => { + // Helper to find checkboxes (custom div components, not native checkboxes) + const findCheckboxes = (container: HTMLElement): NodeListOf => { + return container.querySelectorAll('[class*="shadow-xs"]') + } + + it('should render header checkbox when embeddingAvailable', () => { + const { container } = render(, { wrapper: createWrapper() }) + const checkboxes = findCheckboxes(container) + expect(checkboxes.length).toBeGreaterThan(0) + }) + + it('should not render header checkbox when embedding not available', () => { + const props = { ...defaultProps, embeddingAvailable: false } + render(, { wrapper: createWrapper() }) + // Row checkboxes should still be there, but header checkbox should be hidden + expect(screen.getByRole('table')).toBeInTheDocument() + }) + + it('should call onSelectedIdChange when select all is clicked', () => { + const onSelectedIdChange = vi.fn() + const props = { ...defaultProps, onSelectedIdChange } + const { container } = render(, { wrapper: createWrapper() }) + + const checkboxes = findCheckboxes(container) + if (checkboxes.length > 0) { + fireEvent.click(checkboxes[0]) + expect(onSelectedIdChange).toHaveBeenCalled() + } + }) + + it('should show all checkboxes as checked when all are selected', () => { + const props = { + ...defaultProps, + selectedIds: ['doc-1', 'doc-2', 'doc-3'], + } + const { container } = render(, { wrapper: createWrapper() }) + + const checkboxes = findCheckboxes(container) + // When checked, checkbox should have a check icon (svg) inside + checkboxes.forEach((checkbox) => { + const checkIcon = checkbox.querySelector('svg') + expect(checkIcon).toBeInTheDocument() + }) + }) + + it('should show indeterminate state when some are selected', () => { + const props = { + ...defaultProps, + selectedIds: ['doc-1'], + } + const { container } = render(, { wrapper: createWrapper() }) + + // First checkbox is the header checkbox which should be indeterminate + const checkboxes = findCheckboxes(container) + expect(checkboxes.length).toBeGreaterThan(0) + // Header checkbox should show indeterminate icon, not check icon + // Just verify it's rendered + expect(checkboxes[0]).toBeInTheDocument() + }) + + it('should call onSelectedIdChange with single document when row checkbox is clicked', () => { + const onSelectedIdChange = vi.fn() + const props = { ...defaultProps, onSelectedIdChange } + const { container } = render(, { wrapper: createWrapper() }) + + // Click the second checkbox (first row checkbox) + const checkboxes = findCheckboxes(container) + if (checkboxes.length > 1) { + fireEvent.click(checkboxes[1]) + expect(onSelectedIdChange).toHaveBeenCalled() + } + }) + }) + + describe('Sorting', () => { + it('should render sort headers for sortable columns', () => { + render(, { wrapper: createWrapper() }) + // Find svg icons which indicate sortable columns + const sortIcons = document.querySelectorAll('svg') + expect(sortIcons.length).toBeGreaterThan(0) + }) + + it('should update sort order when sort header is clicked', () => { + render(, { wrapper: createWrapper() }) + + // Find and click a sort header by its parent div containing the label text + const sortableHeaders = document.querySelectorAll('[class*="cursor-pointer"]') + if (sortableHeaders.length > 0) { + fireEvent.click(sortableHeaders[0]) + } + + expect(screen.getByRole('table')).toBeInTheDocument() + }) + }) + + describe('Batch Actions', () => { + it('should show batch action bar when documents are selected', () => { + const props = { + ...defaultProps, + selectedIds: ['doc-1', 'doc-2'], + } + render(, { wrapper: createWrapper() }) + + // BatchAction component should be visible + expect(screen.getByRole('table')).toBeInTheDocument() + }) + + it('should not show batch action bar when no documents selected', () => { + render(, { wrapper: createWrapper() }) + + // BatchAction should not be present + expect(screen.getByRole('table')).toBeInTheDocument() + }) + + it('should render batch action bar with archive option', () => { + const props = { + ...defaultProps, + selectedIds: ['doc-1'], + } + render(, { wrapper: createWrapper() }) + + // BatchAction component should be visible when documents are selected + expect(screen.getByRole('table')).toBeInTheDocument() + }) + + it('should render batch action bar with enable option', () => { + const props = { + ...defaultProps, + selectedIds: ['doc-1'], + } + render(, { wrapper: createWrapper() }) + + expect(screen.getByRole('table')).toBeInTheDocument() + }) + + it('should render batch action bar with disable option', () => { + const props = { + ...defaultProps, + selectedIds: ['doc-1'], + } + render(, { wrapper: createWrapper() }) + + expect(screen.getByRole('table')).toBeInTheDocument() + }) + + it('should render batch action bar with delete option', () => { + const props = { + ...defaultProps, + selectedIds: ['doc-1'], + } + render(, { wrapper: createWrapper() }) + + expect(screen.getByRole('table')).toBeInTheDocument() + }) + + it('should clear selection when cancel is clicked', () => { + const onSelectedIdChange = vi.fn() + const props = { + ...defaultProps, + selectedIds: ['doc-1'], + onSelectedIdChange, + } + render(, { wrapper: createWrapper() }) + + const cancelButton = screen.queryByRole('button', { name: /cancel/i }) + if (cancelButton) { + fireEvent.click(cancelButton) + expect(onSelectedIdChange).toHaveBeenCalledWith([]) + } + }) + + it('should show download option for downloadable documents', () => { + const props = { + ...defaultProps, + selectedIds: ['doc-1'], + documents: [ + createMockDoc({ id: 'doc-1', data_source_type: DataSourceType.FILE }), + ], + } + render(, { wrapper: createWrapper() }) + + // BatchAction should be visible + expect(screen.getByRole('table')).toBeInTheDocument() + }) + + it('should show re-index option for error documents', () => { + const props = { + ...defaultProps, + selectedIds: ['doc-1'], + documents: [ + createMockDoc({ id: 'doc-1', display_status: 'error' }), + ], + } + render(, { wrapper: createWrapper() }) + + // BatchAction with re-index should be present for error documents + expect(screen.getByRole('table')).toBeInTheDocument() + }) + }) + + describe('Row Click Navigation', () => { + it('should navigate to document detail when row is clicked', () => { + render(, { wrapper: createWrapper() }) + + const rows = screen.getAllByRole('row') + // First row is header, second row is first document + if (rows.length > 1) { + fireEvent.click(rows[1]) + expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/documents/doc-1') + } + }) + }) + + describe('Rename Modal', () => { + it('should not show rename modal initially', () => { + render(, { wrapper: createWrapper() }) + + // RenameModal should not be visible initially + const modal = screen.queryByRole('dialog') + expect(modal).not.toBeInTheDocument() + }) + + it('should show rename modal when rename button is clicked', () => { + const { container } = render(, { wrapper: createWrapper() }) + + // Find and click the rename button in the first row + const renameButtons = container.querySelectorAll('.cursor-pointer.rounded-md') + if (renameButtons.length > 0) { + fireEvent.click(renameButtons[0]) + } + + // After clicking rename, the modal should potentially be visible + expect(screen.getByRole('table')).toBeInTheDocument() + }) + + it('should call onUpdate when document is renamed', () => { + const onUpdate = vi.fn() + const props = { ...defaultProps, onUpdate } + render(, { wrapper: createWrapper() }) + + // The handleRenamed callback wraps onUpdate + expect(screen.getByRole('table')).toBeInTheDocument() + }) + }) + + describe('Edit Metadata Modal', () => { + it('should handle edit metadata action', () => { + const props = { + ...defaultProps, + selectedIds: ['doc-1'], + } + render(, { wrapper: createWrapper() }) + + const editButton = screen.queryByRole('button', { name: /metadata/i }) + if (editButton) { + fireEvent.click(editButton) + } + + expect(screen.getByRole('table')).toBeInTheDocument() + }) + + it('should call onManageMetadata when manage metadata is triggered', () => { + const onManageMetadata = vi.fn() + const props = { + ...defaultProps, + selectedIds: ['doc-1'], + onManageMetadata, + } + render(, { wrapper: createWrapper() }) + + // The onShowManage callback in EditMetadataBatchModal should call hideEditModal then onManageMetadata + expect(screen.getByRole('table')).toBeInTheDocument() + }) + }) + + describe('Chunking Mode', () => { + it('should render with general mode', () => { + render(, { wrapper: createWrapper() }) + expect(screen.getByRole('table')).toBeInTheDocument() + }) + + it('should render with QA mode', () => { + // This test uses the default mock which returns ChunkingMode.text + // The component will compute isQAMode based on doc_form + render(, { wrapper: createWrapper() }) + expect(screen.getByRole('table')).toBeInTheDocument() + }) + + it('should render with parent-child mode', () => { + render(, { wrapper: createWrapper() }) + expect(screen.getByRole('table')).toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should handle empty documents array', () => { + const props = { ...defaultProps, documents: [] } + render(, { wrapper: createWrapper() }) + + expect(screen.getByRole('table')).toBeInTheDocument() + }) + + it('should handle documents with missing optional fields', () => { + const docWithMissingFields = createMockDoc({ + word_count: undefined as unknown as number, + hit_count: undefined as unknown as number, + }) + const props = { + ...defaultProps, + documents: [docWithMissingFields], + } + render(, { wrapper: createWrapper() }) + + expect(screen.getByRole('table')).toBeInTheDocument() + }) + + it('should handle status filter value', () => { + const props = { + ...defaultProps, + statusFilterValue: 'completed', + } + render(, { wrapper: createWrapper() }) + + expect(screen.getByRole('table')).toBeInTheDocument() + }) + + it('should handle remote sort value', () => { + const props = { + ...defaultProps, + remoteSortValue: 'created_at', + } + render(, { wrapper: createWrapper() }) + + expect(screen.getByRole('table')).toBeInTheDocument() + }) + + it('should handle large number of documents', () => { + const manyDocs = Array.from({ length: 20 }, (_, i) => + createMockDoc({ id: `doc-${i}`, name: `Document ${i}.txt` })) + const props = { ...defaultProps, documents: manyDocs } + render(, { wrapper: createWrapper() }) + + expect(screen.getByRole('table')).toBeInTheDocument() + }, 10000) + }) +}) diff --git a/web/app/components/datasets/documents/components/document-list/index.tsx b/web/app/components/datasets/documents/components/document-list/index.tsx new file mode 100644 index 0000000000..46fd7a02d5 --- /dev/null +++ b/web/app/components/datasets/documents/components/document-list/index.tsx @@ -0,0 +1,3 @@ +// Re-export from parent for backwards compatibility +export { default } from '../list' +export { renderTdValue } from './components' diff --git a/web/app/components/datasets/documents/components/list.tsx b/web/app/components/datasets/documents/components/list.tsx index f63d6d987e..3106f6c30b 100644 --- a/web/app/components/datasets/documents/components/list.tsx +++ b/web/app/components/datasets/documents/components/list.tsx @@ -1,67 +1,26 @@ 'use client' import type { FC } from 'react' import type { Props as PaginationProps } from '@/app/components/base/pagination' -import type { CommonResponse } from '@/models/common' -import type { LegacyDataSourceInfo, LocalFileInfo, OnlineDocumentInfo, OnlineDriveInfo, SimpleDocumentDetail } from '@/models/datasets' -import { - RiArrowDownLine, - RiEditLine, - RiGlobalLine, -} from '@remixicon/react' +import type { SimpleDocumentDetail } from '@/models/datasets' import { useBoolean } from 'ahooks' -import { uniq } from 'es-toolkit/array' -import { pick } from 'es-toolkit/object' -import { useRouter } from 'next/navigation' import * as React from 'react' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Checkbox from '@/app/components/base/checkbox' -import FileTypeIcon from '@/app/components/base/file-uploader/file-type-icon' -import NotionIcon from '@/app/components/base/notion-icon' import Pagination from '@/app/components/base/pagination' -import Toast from '@/app/components/base/toast' -import Tooltip from '@/app/components/base/tooltip' -import ChunkingModeLabel from '@/app/components/datasets/common/chunking-mode-label' -import { normalizeStatusForQuery } from '@/app/components/datasets/documents/status-filter' -import { extensionToFileType } from '@/app/components/datasets/hit-testing/utils/extension-to-file-type' import EditMetadataBatchModal from '@/app/components/datasets/metadata/edit-metadata-batch/modal' import useBatchEditDocumentMetadata from '@/app/components/datasets/metadata/hooks/use-batch-edit-document-metadata' import { useDatasetDetailContextWithSelector as useDatasetDetailContext } from '@/context/dataset-detail' -import useTimestamp from '@/hooks/use-timestamp' -import { ChunkingMode, DataSourceType, DocumentActionType } from '@/models/datasets' -import { DatasourceType } from '@/models/pipeline' -import { useDocumentArchive, useDocumentBatchRetryIndex, useDocumentDelete, useDocumentDisable, useDocumentDownloadZip, useDocumentEnable, useDocumentSummary } from '@/service/knowledge/use-document' -import { asyncRunSafe } from '@/utils' -import { cn } from '@/utils/classnames' -import { downloadBlob } from '@/utils/download' -import { formatNumber } from '@/utils/format' +import { ChunkingMode, DocumentActionType } from '@/models/datasets' import BatchAction from '../detail/completed/common/batch-action' -import SummaryStatus from '../detail/completed/common/summary-status' -import StatusItem from '../status-item' import s from '../style.module.css' -import Operations from './operations' +import { DocumentTableRow, renderTdValue, SortHeader } from './document-list/components' +import { useDocumentActions, useDocumentSelection, useDocumentSort } from './document-list/hooks' import RenameModal from './rename-modal' -export const renderTdValue = (value: string | number | null, isEmptyStyle = false) => { - return ( -
- {value ?? '-'} -
- ) -} - -const renderCount = (count: number | undefined) => { - if (!count) - return renderTdValue(0, true) - - if (count < 1000) - return count - - return `${formatNumber((count / 1000).toFixed(1))}k` -} - type LocalDoc = SimpleDocumentDetail & { percent?: number } -type IDocumentListProps = { + +type DocumentListProps = { embeddingAvailable: boolean documents: LocalDoc[] selectedIds: string[] @@ -77,7 +36,7 @@ type IDocumentListProps = { /** * Document list component including basic information */ -const DocumentList: FC = ({ +const DocumentList: FC = ({ embeddingAvailable, documents = [], selectedIds, @@ -90,20 +49,43 @@ const DocumentList: FC = ({ remoteSortValue, }) => { const { t } = useTranslation() - const { formatTime } = useTimestamp() - const router = useRouter() const datasetConfig = useDatasetDetailContext(s => s.dataset) const chunkingMode = datasetConfig?.doc_form const isGeneralMode = chunkingMode !== ChunkingMode.parentChild const isQAMode = chunkingMode === ChunkingMode.qa - const [sortField, setSortField] = useState<'name' | 'word_count' | 'hit_count' | 'created_at' | null>(null) - const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc') - useEffect(() => { - setSortField(null) - setSortOrder('desc') - }, [remoteSortValue]) + // Sorting + const { sortField, sortOrder, handleSort, sortedDocuments } = useDocumentSort({ + documents, + statusFilterValue, + remoteSortValue, + }) + // Selection + const { + isAllSelected, + isSomeSelected, + onSelectAll, + onSelectOne, + hasErrorDocumentsSelected, + downloadableSelectedIds, + clearSelection, + } = useDocumentSelection({ + documents: sortedDocuments, + selectedIds, + onSelectedIdChange, + }) + + // Actions + const { handleAction, handleBatchReIndex, handleBatchDownload } = useDocumentActions({ + datasetId, + selectedIds, + downloadableSelectedIds, + onUpdate, + onClearSelection: clearSelection, + }) + + // Batch edit metadata const { isShowEditModal, showEditModal, @@ -113,233 +95,26 @@ const DocumentList: FC = ({ } = useBatchEditDocumentMetadata({ datasetId, docList: documents.filter(doc => selectedIds.includes(doc.id)), - selectedDocumentIds: selectedIds, // Pass all selected IDs separately + selectedDocumentIds: selectedIds, onUpdate, }) - const localDocs = useMemo(() => { - let filteredDocs = documents - - if (statusFilterValue && statusFilterValue !== 'all') { - filteredDocs = filteredDocs.filter(doc => - typeof doc.display_status === 'string' - && normalizeStatusForQuery(doc.display_status) === statusFilterValue, - ) - } - - if (!sortField) - return filteredDocs - - const sortedDocs = [...filteredDocs].sort((a, b) => { - let aValue: any - let bValue: any - - switch (sortField) { - case 'name': - aValue = a.name?.toLowerCase() || '' - bValue = b.name?.toLowerCase() || '' - break - case 'word_count': - aValue = a.word_count || 0 - bValue = b.word_count || 0 - break - case 'hit_count': - aValue = a.hit_count || 0 - bValue = b.hit_count || 0 - break - case 'created_at': - aValue = a.created_at - bValue = b.created_at - break - default: - return 0 - } - - if (sortField === 'name') { - const result = aValue.localeCompare(bValue) - return sortOrder === 'asc' ? result : -result - } - else { - const result = aValue - bValue - return sortOrder === 'asc' ? result : -result - } - }) - - return sortedDocs - }, [documents, sortField, sortOrder, statusFilterValue]) - - const handleSort = (field: 'name' | 'word_count' | 'hit_count' | 'created_at') => { - if (sortField === field) { - setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc') - } - else { - setSortField(field) - setSortOrder('desc') - } - } - - const renderSortHeader = (field: 'name' | 'word_count' | 'hit_count' | 'created_at', label: string) => { - const isActive = sortField === field - const isDesc = isActive && sortOrder === 'desc' - - return ( -
handleSort(field)}> - {label} - -
- ) - } - + // Rename modal const [currDocument, setCurrDocument] = useState(null) const [isShowRenameModal, { setTrue: setShowRenameModalTrue, setFalse: setShowRenameModalFalse, }] = useBoolean(false) + const handleShowRenameModal = useCallback((doc: LocalDoc) => { setCurrDocument(doc) setShowRenameModalTrue() }, [setShowRenameModalTrue]) + const handleRenamed = useCallback(() => { onUpdate() }, [onUpdate]) - const isAllSelected = useMemo(() => { - return localDocs.length > 0 && localDocs.every(doc => selectedIds.includes(doc.id)) - }, [localDocs, selectedIds]) - - const isSomeSelected = useMemo(() => { - return localDocs.some(doc => selectedIds.includes(doc.id)) - }, [localDocs, selectedIds]) - - const onSelectedAll = useCallback(() => { - if (isAllSelected) - onSelectedIdChange([]) - else - onSelectedIdChange(uniq([...selectedIds, ...localDocs.map(doc => doc.id)])) - }, [isAllSelected, localDocs, onSelectedIdChange, selectedIds]) - const { mutateAsync: archiveDocument } = useDocumentArchive() - const { mutateAsync: generateSummary } = useDocumentSummary() - const { mutateAsync: enableDocument } = useDocumentEnable() - const { mutateAsync: disableDocument } = useDocumentDisable() - const { mutateAsync: deleteDocument } = useDocumentDelete() - const { mutateAsync: retryIndexDocument } = useDocumentBatchRetryIndex() - const { mutateAsync: requestDocumentsZip, isPending: isDownloadingZip } = useDocumentDownloadZip() - - const handleAction = (actionName: DocumentActionType) => { - return async () => { - let opApi - switch (actionName) { - case DocumentActionType.archive: - opApi = archiveDocument - break - case DocumentActionType.summary: - opApi = generateSummary - break - case DocumentActionType.enable: - opApi = enableDocument - break - case DocumentActionType.disable: - opApi = disableDocument - break - default: - opApi = deleteDocument - break - } - const [e] = await asyncRunSafe(opApi({ datasetId, documentIds: selectedIds }) as Promise) - - if (!e) { - if (actionName === DocumentActionType.delete) - onSelectedIdChange([]) - Toast.notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) }) - onUpdate() - } - else { Toast.notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) }) } - } - } - - const handleBatchReIndex = async () => { - const [e] = await asyncRunSafe(retryIndexDocument({ datasetId, documentIds: selectedIds })) - if (!e) { - onSelectedIdChange([]) - Toast.notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) }) - onUpdate() - } - else { - Toast.notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) }) - } - } - - const hasErrorDocumentsSelected = useMemo(() => { - return localDocs.some(doc => selectedIds.includes(doc.id) && doc.display_status === 'error') - }, [localDocs, selectedIds]) - - const getFileExtension = useCallback((fileName: string): string => { - if (!fileName) - return '' - const parts = fileName.split('.') - if (parts.length <= 1 || (parts[0] === '' && parts.length === 2)) - return '' - - return parts[parts.length - 1].toLowerCase() - }, []) - - const isCreateFromRAGPipeline = useCallback((createdFrom: string) => { - return createdFrom === 'rag-pipeline' - }, []) - - /** - * Calculate the data source type - * DataSourceType: FILE, NOTION, WEB (legacy) - * DatasourceType: localFile, onlineDocument, websiteCrawl, onlineDrive (new) - */ - const isLocalFile = useCallback((dataSourceType: DataSourceType | DatasourceType) => { - return dataSourceType === DatasourceType.localFile || dataSourceType === DataSourceType.FILE - }, []) - const isOnlineDocument = useCallback((dataSourceType: DataSourceType | DatasourceType) => { - return dataSourceType === DatasourceType.onlineDocument || dataSourceType === DataSourceType.NOTION - }, []) - const isWebsiteCrawl = useCallback((dataSourceType: DataSourceType | DatasourceType) => { - return dataSourceType === DatasourceType.websiteCrawl || dataSourceType === DataSourceType.WEB - }, []) - const isOnlineDrive = useCallback((dataSourceType: DataSourceType | DatasourceType) => { - return dataSourceType === DatasourceType.onlineDrive - }, []) - - const downloadableSelectedIds = useMemo(() => { - const selectedSet = new Set(selectedIds) - return localDocs - .filter(doc => selectedSet.has(doc.id) && doc.data_source_type === DataSourceType.FILE) - .map(doc => doc.id) - }, [localDocs, selectedIds]) - - /** - * Generate a random ZIP filename for bulk document downloads. - * We intentionally avoid leaking dataset info in the exported archive name. - */ - const generateDocsZipFileName = useCallback((): string => { - // Prefer UUID for uniqueness; fall back to time+random when unavailable. - const randomPart = (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') - ? crypto.randomUUID() - : `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}` - return `${randomPart}-docs.zip` - }, []) - - const handleBatchDownload = useCallback(async () => { - if (isDownloadingZip) - return - - // Download as a single ZIP to avoid browser caps on multiple automatic downloads. - const [e, blob] = await asyncRunSafe(requestDocumentsZip({ datasetId, documentIds: downloadableSelectedIds })) - if (e || !blob) { - Toast.notify({ type: 'error', message: t('actionMsg.downloadUnsuccessfully', { ns: 'common' }) }) - return - } - - downloadBlob({ data: blob, fileName: generateDocsZipFileName() }) - }, [datasetId, downloadableSelectedIds, generateDocsZipFileName, isDownloadingZip, requestDocumentsZip, t]) - return (
@@ -353,157 +128,76 @@ const DocumentList: FC = ({ className="mr-2 shrink-0" checked={isAllSelected} indeterminate={!isAllSelected && isSomeSelected} - onCheck={onSelectedAll} + onCheck={onSelectAll} /> )} #
- {renderSortHeader('name', t('list.table.header.fileName', { ns: 'datasetDocuments' }))} + {t('list.table.header.chunkingMode', { ns: 'datasetDocuments' })} - {renderSortHeader('word_count', t('list.table.header.words', { ns: 'datasetDocuments' }))} + - {renderSortHeader('hit_count', t('list.table.header.hitCount', { ns: 'datasetDocuments' }))} + - {renderSortHeader('created_at', t('list.table.header.uploadTime', { ns: 'datasetDocuments' }))} + {t('list.table.header.status', { ns: 'datasetDocuments' })} {t('list.table.header.action', { ns: 'datasetDocuments' })} - {localDocs.map((doc, index) => { - const isFile = isLocalFile(doc.data_source_type) - const fileType = isFile ? doc.data_source_detail_dict?.upload_file?.extension : '' - return ( - { - router.push(`/datasets/${datasetId}/documents/${doc.id}`) - }} - > - -
e.stopPropagation()}> - { - onSelectedIdChange( - selectedIds.includes(doc.id) - ? selectedIds.filter(id => id !== doc.id) - : [...selectedIds, doc.id], - ) - }} - /> - {index + 1} -
- - -
-
- {isOnlineDocument(doc.data_source_type) && ( - - )} - {isLocalFile(doc.data_source_type) && ( - - )} - {isOnlineDrive(doc.data_source_type) && ( - - )} - {isWebsiteCrawl(doc.data_source_type) && ( - - )} -
- - {doc.name} - - { - doc.summary_index_status && ( -
- -
- ) - } -
- -
{ - e.stopPropagation() - handleShowRenameModal(doc) - }} - > - -
-
-
-
- - - - - {renderCount(doc.word_count)} - {renderCount(doc.hit_count)} - - {formatTime(doc.created_at, t('dateTimeFormat', { ns: 'datasetHitTesting' }) as string)} - - - - - - - - - ) - })} + {sortedDocuments.map((doc, index) => ( + + ))}
- {(selectedIds.length > 0) && ( + + {selectedIds.length > 0 && ( = ({ onBatchDelete={handleAction(DocumentActionType.delete)} onEditMetadata={showEditModal} onBatchReIndex={hasErrorDocumentsSelected ? handleBatchReIndex : undefined} - onCancel={() => { - onSelectedIdChange([]) - }} + onCancel={clearSelection} /> )} - {/* Show Pagination only if the total is more than the limit */} + {!!pagination.total && ( = ({ } export default DocumentList + +export { renderTdValue } diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index 6193a8ad4e..e23515ffe2 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -1669,14 +1669,6 @@ "count": 1 } }, - "app/components/datasets/documents/components/list.tsx": { - "react-hooks-extra/no-direct-set-state-in-use-effect": { - "count": 2 - }, - "ts/no-explicit-any": { - "count": 2 - } - }, "app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/index.spec.tsx": { "ts/no-explicit-any": { "count": 4