From 1e344f773be23ed89fa34171bbe4a317df1b9f75 Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Wed, 4 Feb 2026 18:35:31 +0800 Subject: [PATCH] refactor(web): extract complex components into modular structure with comprehensive tests (#31729) Co-authored-by: CodingOnStar Co-authored-by: Claude Opus 4.5 --- .../app/create-app-modal/index.spec.tsx | 15 +- .../components/document-source-icon.spec.tsx | 262 +++++++++ .../components/document-source-icon.tsx | 100 ++++ .../components/document-table-row.spec.tsx | 342 ++++++++++++ .../components/document-table-row.tsx | 152 ++++++ .../document-list/components/index.ts | 4 + .../components/sort-header.spec.tsx | 124 +++++ .../document-list/components/sort-header.tsx | 44 ++ .../document-list/components/utils.spec.tsx | 90 ++++ .../document-list/components/utils.tsx | 16 + .../components/document-list/hooks/index.ts | 4 + .../hooks/use-document-actions.spec.tsx | 438 ++++++++++++++++ .../hooks/use-document-actions.ts | 126 +++++ .../hooks/use-document-selection.spec.ts | 317 +++++++++++ .../hooks/use-document-selection.ts | 66 +++ .../hooks/use-document-sort.spec.ts | 340 ++++++++++++ .../document-list/hooks/use-document-sort.ts | 102 ++++ .../components/document-list/index.spec.tsx | 487 +++++++++++++++++ .../components/document-list/index.tsx | 3 + .../datasets/documents/components/list.tsx | 496 ++++-------------- .../detail/embedding/components/index.ts | 4 + .../components/progress-bar.spec.tsx | 159 ++++++ .../embedding/components/progress-bar.tsx | 44 ++ .../embedding/components/rule-detail.spec.tsx | 203 +++++++ .../embedding/components/rule-detail.tsx | 128 +++++ .../components/segment-progress.spec.tsx | 81 +++ .../embedding/components/segment-progress.tsx | 32 ++ .../components/status-header.spec.tsx | 155 ++++++ .../embedding/components/status-header.tsx | 84 +++ .../documents/detail/embedding/hooks/index.ts | 10 + .../hooks/use-embedding-status.spec.tsx | 462 ++++++++++++++++ .../embedding/hooks/use-embedding-status.ts | 149 ++++++ .../documents/detail/embedding/index.spec.tsx | 337 ++++++++++++ .../documents/detail/embedding/index.tsx | 351 +++---------- .../components/dataset-card-header.spec.tsx | 7 + .../components/dataset-card-modals.spec.tsx | 30 +- .../components/goto-anything/index.spec.tsx | 4 + .../components/panel/index.spec.tsx | 163 +++--- .../components/update-dsl-modal.spec.tsx | 51 +- .../rag-pipeline/hooks/use-DSL.spec.ts | 36 ++ .../workflow-onboarding-modal/index.spec.tsx | 14 +- web/eslint-suppressions.json | 11 - 42 files changed, 5234 insertions(+), 809 deletions(-) create mode 100644 web/app/components/datasets/documents/components/document-list/components/document-source-icon.spec.tsx create mode 100644 web/app/components/datasets/documents/components/document-list/components/document-source-icon.tsx create mode 100644 web/app/components/datasets/documents/components/document-list/components/document-table-row.spec.tsx create mode 100644 web/app/components/datasets/documents/components/document-list/components/document-table-row.tsx create mode 100644 web/app/components/datasets/documents/components/document-list/components/index.ts create mode 100644 web/app/components/datasets/documents/components/document-list/components/sort-header.spec.tsx create mode 100644 web/app/components/datasets/documents/components/document-list/components/sort-header.tsx create mode 100644 web/app/components/datasets/documents/components/document-list/components/utils.spec.tsx create mode 100644 web/app/components/datasets/documents/components/document-list/components/utils.tsx create mode 100644 web/app/components/datasets/documents/components/document-list/hooks/index.ts create mode 100644 web/app/components/datasets/documents/components/document-list/hooks/use-document-actions.spec.tsx create mode 100644 web/app/components/datasets/documents/components/document-list/hooks/use-document-actions.ts create mode 100644 web/app/components/datasets/documents/components/document-list/hooks/use-document-selection.spec.ts create mode 100644 web/app/components/datasets/documents/components/document-list/hooks/use-document-selection.ts create mode 100644 web/app/components/datasets/documents/components/document-list/hooks/use-document-sort.spec.ts create mode 100644 web/app/components/datasets/documents/components/document-list/hooks/use-document-sort.ts create mode 100644 web/app/components/datasets/documents/components/document-list/index.spec.tsx create mode 100644 web/app/components/datasets/documents/components/document-list/index.tsx create mode 100644 web/app/components/datasets/documents/detail/embedding/components/index.ts create mode 100644 web/app/components/datasets/documents/detail/embedding/components/progress-bar.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/embedding/components/progress-bar.tsx create mode 100644 web/app/components/datasets/documents/detail/embedding/components/rule-detail.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/embedding/components/rule-detail.tsx create mode 100644 web/app/components/datasets/documents/detail/embedding/components/segment-progress.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/embedding/components/segment-progress.tsx create mode 100644 web/app/components/datasets/documents/detail/embedding/components/status-header.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/embedding/components/status-header.tsx create mode 100644 web/app/components/datasets/documents/detail/embedding/hooks/index.ts create mode 100644 web/app/components/datasets/documents/detail/embedding/hooks/use-embedding-status.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/embedding/hooks/use-embedding-status.ts create mode 100644 web/app/components/datasets/documents/detail/embedding/index.spec.tsx diff --git a/web/app/components/app/create-app-modal/index.spec.tsx b/web/app/components/app/create-app-modal/index.spec.tsx index d26a581fda..8c368df62c 100644 --- a/web/app/components/app/create-app-modal/index.spec.tsx +++ b/web/app/components/app/create-app-modal/index.spec.tsx @@ -1,3 +1,4 @@ +import type { App } from '@/types/app' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { useRouter } from 'next/navigation' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' @@ -13,8 +14,8 @@ import { getRedirection } from '@/utils/app-redirection' import CreateAppModal from './index' vi.mock('ahooks', () => ({ - useDebounceFn: (fn: (...args: any[]) => any) => { - const run = (...args: any[]) => fn(...args) + useDebounceFn: unknown>(fn: T) => { + const run = (...args: Parameters) => fn(...args) const cancel = vi.fn() const flush = vi.fn() return { run, cancel, flush } @@ -83,7 +84,7 @@ describe('CreateAppModal', () => { beforeEach(() => { vi.clearAllMocks() - mockUseRouter.mockReturnValue({ push: mockPush } as any) + mockUseRouter.mockReturnValue({ push: mockPush } as unknown as ReturnType) mockUseProviderContext.mockReturnValue({ plan: { type: AppModeEnum.ADVANCED_CHAT, @@ -92,10 +93,10 @@ describe('CreateAppModal', () => { reset: {}, }, enableBilling: true, - } as any) + } as unknown as ReturnType) mockUseAppContext.mockReturnValue({ isCurrentWorkspaceEditor: true, - } as any) + } as unknown as ReturnType) mockSetItem.mockClear() Object.defineProperty(window, 'localStorage', { value: { @@ -118,8 +119,8 @@ describe('CreateAppModal', () => { }) it('creates an app, notifies success, and fires callbacks', async () => { - const mockApp = { id: 'app-1', mode: AppModeEnum.ADVANCED_CHAT } - mockCreateApp.mockResolvedValue(mockApp as any) + const mockApp: Partial = { id: 'app-1', mode: AppModeEnum.ADVANCED_CHAT } + mockCreateApp.mockResolvedValue(mockApp as App) const { onClose, onSuccess } = renderModal() const nameInput = screen.getByPlaceholderText('app.newApp.appNamePlaceholder') 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..56553faa9e --- /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 }), + ) + + 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/app/components/datasets/documents/detail/embedding/components/index.ts b/web/app/components/datasets/documents/detail/embedding/components/index.ts new file mode 100644 index 0000000000..5faac4e027 --- /dev/null +++ b/web/app/components/datasets/documents/detail/embedding/components/index.ts @@ -0,0 +1,4 @@ +export { default as ProgressBar } from './progress-bar' +export { default as RuleDetail } from './rule-detail' +export { default as SegmentProgress } from './segment-progress' +export { default as StatusHeader } from './status-header' diff --git a/web/app/components/datasets/documents/detail/embedding/components/progress-bar.spec.tsx b/web/app/components/datasets/documents/detail/embedding/components/progress-bar.spec.tsx new file mode 100644 index 0000000000..b54c8000fe --- /dev/null +++ b/web/app/components/datasets/documents/detail/embedding/components/progress-bar.spec.tsx @@ -0,0 +1,159 @@ +import { render } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import ProgressBar from './progress-bar' + +describe('ProgressBar', () => { + const defaultProps = { + percent: 50, + isEmbedding: false, + isCompleted: false, + isPaused: false, + isError: false, + } + + const getProgressElements = (container: HTMLElement) => { + const wrapper = container.firstChild as HTMLElement + const progressBar = wrapper.firstChild as HTMLElement + return { wrapper, progressBar } + } + + describe('Rendering', () => { + it('should render without crashing', () => { + const { container } = render() + const { wrapper, progressBar } = getProgressElements(container) + expect(wrapper).toBeInTheDocument() + expect(progressBar).toBeInTheDocument() + }) + + it('should render progress bar container with correct classes', () => { + const { container } = render() + const { wrapper } = getProgressElements(container) + expect(wrapper).toHaveClass('flex', 'h-2', 'w-full', 'items-center', 'overflow-hidden', 'rounded-md') + }) + + it('should render inner progress bar with transition classes', () => { + const { container } = render() + const { progressBar } = getProgressElements(container) + expect(progressBar).toHaveClass('h-full', 'transition-all', 'duration-300') + }) + }) + + describe('Progress Width', () => { + it('should set progress width to 0%', () => { + const { container } = render() + const { progressBar } = getProgressElements(container) + expect(progressBar).toHaveStyle({ width: '0%' }) + }) + + it('should set progress width to 50%', () => { + const { container } = render() + const { progressBar } = getProgressElements(container) + expect(progressBar).toHaveStyle({ width: '50%' }) + }) + + it('should set progress width to 100%', () => { + const { container } = render() + const { progressBar } = getProgressElements(container) + expect(progressBar).toHaveStyle({ width: '100%' }) + }) + + it('should set progress width to 75%', () => { + const { container } = render() + const { progressBar } = getProgressElements(container) + expect(progressBar).toHaveStyle({ width: '75%' }) + }) + }) + + describe('Container Background States', () => { + it('should apply semi-transparent background when isEmbedding is true', () => { + const { container } = render() + const { wrapper } = getProgressElements(container) + expect(wrapper).toHaveClass('bg-components-progress-bar-bg/50') + }) + + it('should apply default background when isEmbedding is false', () => { + const { container } = render() + const { wrapper } = getProgressElements(container) + expect(wrapper).toHaveClass('bg-components-progress-bar-bg') + expect(wrapper).not.toHaveClass('bg-components-progress-bar-bg/50') + }) + }) + + describe('Progress Bar Fill States', () => { + it('should apply solid progress style when isEmbedding is true', () => { + const { container } = render() + const { progressBar } = getProgressElements(container) + expect(progressBar).toHaveClass('bg-components-progress-bar-progress-solid') + }) + + it('should apply solid progress style when isCompleted is true', () => { + const { container } = render() + const { progressBar } = getProgressElements(container) + expect(progressBar).toHaveClass('bg-components-progress-bar-progress-solid') + }) + + it('should apply highlight style when isPaused is true', () => { + const { container } = render() + const { progressBar } = getProgressElements(container) + expect(progressBar).toHaveClass('bg-components-progress-bar-progress-highlight') + }) + + it('should apply highlight style when isError is true', () => { + const { container } = render() + const { progressBar } = getProgressElements(container) + expect(progressBar).toHaveClass('bg-components-progress-bar-progress-highlight') + }) + + it('should not apply fill styles when no status flags are set', () => { + const { container } = render() + const { progressBar } = getProgressElements(container) + expect(progressBar).not.toHaveClass('bg-components-progress-bar-progress-solid') + expect(progressBar).not.toHaveClass('bg-components-progress-bar-progress-highlight') + }) + }) + + describe('Combined States', () => { + it('should apply highlight when isEmbedding and isPaused', () => { + const { container } = render() + const { progressBar } = getProgressElements(container) + // highlight takes precedence since isPaused condition is separate + expect(progressBar).toHaveClass('bg-components-progress-bar-progress-highlight') + }) + + it('should apply highlight when isCompleted and isError', () => { + const { container } = render() + const { progressBar } = getProgressElements(container) + // highlight takes precedence since isError condition is separate + expect(progressBar).toHaveClass('bg-components-progress-bar-progress-highlight') + }) + + it('should apply semi-transparent bg for embedding and highlight for paused', () => { + const { container } = render() + const { wrapper } = getProgressElements(container) + expect(wrapper).toHaveClass('bg-components-progress-bar-bg/50') + }) + }) + + describe('Edge Cases', () => { + it('should handle all props set to false', () => { + const { container } = render( + , + ) + const { wrapper, progressBar } = getProgressElements(container) + expect(wrapper).toBeInTheDocument() + expect(progressBar).toHaveStyle({ width: '0%' }) + }) + + it('should handle decimal percent values', () => { + const { container } = render() + const { progressBar } = getProgressElements(container) + expect(progressBar).toHaveStyle({ width: '33.33%' }) + }) + }) +}) diff --git a/web/app/components/datasets/documents/detail/embedding/components/progress-bar.tsx b/web/app/components/datasets/documents/detail/embedding/components/progress-bar.tsx new file mode 100644 index 0000000000..19c6493922 --- /dev/null +++ b/web/app/components/datasets/documents/detail/embedding/components/progress-bar.tsx @@ -0,0 +1,44 @@ +import type { FC } from 'react' +import * as React from 'react' +import { cn } from '@/utils/classnames' + +type ProgressBarProps = { + percent: number + isEmbedding: boolean + isCompleted: boolean + isPaused: boolean + isError: boolean +} + +const ProgressBar: FC = React.memo(({ + percent, + isEmbedding, + isCompleted, + isPaused, + isError, +}) => { + const isActive = isEmbedding || isCompleted + const isHighlighted = isPaused || isError + + return ( +
+
+
+ ) +}) + +ProgressBar.displayName = 'ProgressBar' + +export default ProgressBar diff --git a/web/app/components/datasets/documents/detail/embedding/components/rule-detail.spec.tsx b/web/app/components/datasets/documents/detail/embedding/components/rule-detail.spec.tsx new file mode 100644 index 0000000000..138a4eacd8 --- /dev/null +++ b/web/app/components/datasets/documents/detail/embedding/components/rule-detail.spec.tsx @@ -0,0 +1,203 @@ +import type { ProcessRuleResponse } from '@/models/datasets' +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { ProcessMode } from '@/models/datasets' +import { RETRIEVE_METHOD } from '@/types/app' +import { IndexingType } from '../../../../create/step-two' +import RuleDetail from './rule-detail' + +describe('RuleDetail', () => { + const defaultProps = { + indexingType: IndexingType.QUALIFIED, + retrievalMethod: RETRIEVE_METHOD.semantic, + } + + const createSourceData = (overrides: Partial = {}): ProcessRuleResponse => ({ + mode: ProcessMode.general, + rules: { + segmentation: { + separator: '\n', + max_tokens: 500, + chunk_overlap: 50, + }, + pre_processing_rules: [ + { id: 'remove_extra_spaces', enabled: true }, + { id: 'remove_urls_emails', enabled: false }, + ], + parent_mode: 'full-doc', + subchunk_segmentation: { + separator: '\n', + max_tokens: 200, + chunk_overlap: 20, + }, + }, + limits: { indexing_max_segmentation_tokens_length: 4000 }, + ...overrides, + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + render() + expect(screen.getByText(/stepTwo\.indexMode/i)).toBeInTheDocument() + }) + + it('should render with sourceData', () => { + const sourceData = createSourceData() + render() + expect(screen.getByText(/embedding\.mode/i)).toBeInTheDocument() + }) + + it('should render all segmentation rule fields', () => { + const sourceData = createSourceData() + render() + expect(screen.getByText(/embedding\.mode/i)).toBeInTheDocument() + expect(screen.getByText(/embedding\.segmentLength/i)).toBeInTheDocument() + expect(screen.getByText(/embedding\.textCleaning/i)).toBeInTheDocument() + }) + }) + + describe('Mode Display', () => { + it('should display custom mode for general process mode', () => { + const sourceData = createSourceData({ mode: ProcessMode.general }) + render() + expect(screen.getByText(/embedding\.custom/i)).toBeInTheDocument() + }) + + it('should display mode label field', () => { + const sourceData = createSourceData() + render() + expect(screen.getByText(/embedding\.mode/i)).toBeInTheDocument() + }) + }) + + describe('Segment Length Display', () => { + it('should display max tokens for general mode', () => { + const sourceData = createSourceData({ + mode: ProcessMode.general, + rules: { + segmentation: { separator: '\n', max_tokens: 500, chunk_overlap: 50 }, + pre_processing_rules: [], + parent_mode: 'full-doc', + subchunk_segmentation: { separator: '\n', max_tokens: 200, chunk_overlap: 20 }, + }, + }) + render() + expect(screen.getByText('500')).toBeInTheDocument() + }) + + it('should display segment length label', () => { + const sourceData = createSourceData() + render() + expect(screen.getByText(/embedding\.segmentLength/i)).toBeInTheDocument() + }) + }) + + describe('Text Cleaning Display', () => { + it('should display enabled pre-processing rules', () => { + const sourceData = createSourceData({ + rules: { + segmentation: { separator: '\n', max_tokens: 500, chunk_overlap: 50 }, + pre_processing_rules: [ + { id: 'remove_extra_spaces', enabled: true }, + { id: 'remove_urls_emails', enabled: true }, + ], + parent_mode: 'full-doc', + subchunk_segmentation: { separator: '\n', max_tokens: 200, chunk_overlap: 20 }, + }, + }) + render() + expect(screen.getByText(/removeExtraSpaces/i)).toBeInTheDocument() + expect(screen.getByText(/removeUrlEmails/i)).toBeInTheDocument() + }) + + it('should display text cleaning label', () => { + const sourceData = createSourceData() + render() + expect(screen.getByText(/embedding\.textCleaning/i)).toBeInTheDocument() + }) + }) + + describe('Index Mode Display', () => { + it('should display economical mode when indexingType is ECONOMICAL', () => { + render() + expect(screen.getByText(/stepTwo\.economical/i)).toBeInTheDocument() + }) + + it('should display qualified mode when indexingType is QUALIFIED', () => { + render() + expect(screen.getByText(/stepTwo\.qualified/i)).toBeInTheDocument() + }) + }) + + describe('Retrieval Method Display', () => { + it('should display keyword search for economical mode', () => { + render() + expect(screen.getByText(/retrieval\.keyword_search\.title/i)).toBeInTheDocument() + }) + + it('should display semantic search as default for qualified mode', () => { + render() + expect(screen.getByText(/retrieval\.semantic_search\.title/i)).toBeInTheDocument() + }) + + it('should display full text search when retrievalMethod is fullText', () => { + render() + expect(screen.getByText(/retrieval\.full_text_search\.title/i)).toBeInTheDocument() + }) + + it('should display hybrid search when retrievalMethod is hybrid', () => { + render() + expect(screen.getByText(/retrieval\.hybrid_search\.title/i)).toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should display dash for missing sourceData', () => { + render() + const dashes = screen.getAllByText('-') + expect(dashes.length).toBeGreaterThan(0) + }) + + it('should display dash when mode is undefined', () => { + const sourceData = { rules: {} } as ProcessRuleResponse + render() + const dashes = screen.getAllByText('-') + expect(dashes.length).toBeGreaterThan(0) + }) + + it('should handle undefined retrievalMethod', () => { + render() + expect(screen.getByText(/retrieval\.semantic_search\.title/i)).toBeInTheDocument() + }) + + it('should handle empty pre_processing_rules array', () => { + const sourceData = createSourceData({ + rules: { + segmentation: { separator: '\n', max_tokens: 500, chunk_overlap: 50 }, + pre_processing_rules: [], + parent_mode: 'full-doc', + subchunk_segmentation: { separator: '\n', max_tokens: 200, chunk_overlap: 20 }, + }, + }) + render() + expect(screen.getByText(/embedding\.textCleaning/i)).toBeInTheDocument() + }) + + it('should render container with correct structure', () => { + const { container } = render() + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('py-3') + }) + + it('should handle undefined indexingType', () => { + render() + expect(screen.getByText(/stepTwo\.indexMode/i)).toBeInTheDocument() + }) + + it('should render divider between sections', () => { + const { container } = render() + const dividers = container.querySelectorAll('.bg-divider-subtle') + expect(dividers.length).toBeGreaterThan(0) + }) + }) +}) diff --git a/web/app/components/datasets/documents/detail/embedding/components/rule-detail.tsx b/web/app/components/datasets/documents/detail/embedding/components/rule-detail.tsx new file mode 100644 index 0000000000..486b94175b --- /dev/null +++ b/web/app/components/datasets/documents/detail/embedding/components/rule-detail.tsx @@ -0,0 +1,128 @@ +import type { FC } from 'react' +import type { ProcessRuleResponse } from '@/models/datasets' +import type { RETRIEVE_METHOD } from '@/types/app' +import Image from 'next/image' +import * as React from 'react' +import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import Divider from '@/app/components/base/divider' +import { ProcessMode } from '@/models/datasets' +import { indexMethodIcon, retrievalIcon } from '../../../../create/icons' +import { IndexingType } from '../../../../create/step-two' +import { FieldInfo } from '../../metadata' + +type RuleDetailProps = { + sourceData?: ProcessRuleResponse + indexingType?: IndexingType + retrievalMethod?: RETRIEVE_METHOD +} + +const getRetrievalIcon = (method?: RETRIEVE_METHOD) => { + if (method === 'full_text_search') + return retrievalIcon.fullText + if (method === 'hybrid_search') + return retrievalIcon.hybrid + return retrievalIcon.vector +} + +const RuleDetail: FC = React.memo(({ + sourceData, + indexingType, + retrievalMethod, +}) => { + const { t } = useTranslation() + + const segmentationRuleMap = { + mode: t('embedding.mode', { ns: 'datasetDocuments' }), + segmentLength: t('embedding.segmentLength', { ns: 'datasetDocuments' }), + textCleaning: t('embedding.textCleaning', { ns: 'datasetDocuments' }), + } + + const getRuleName = useCallback((key: string) => { + const ruleNameMap: Record = { + remove_extra_spaces: t('stepTwo.removeExtraSpaces', { ns: 'datasetCreation' }), + remove_urls_emails: t('stepTwo.removeUrlEmails', { ns: 'datasetCreation' }), + remove_stopwords: t('stepTwo.removeStopwords', { ns: 'datasetCreation' }), + } + return ruleNameMap[key] + }, [t]) + + const getValue = useCallback((field: string) => { + const defaultValue = '-' + + if (!sourceData?.mode) + return defaultValue + + const maxTokens = typeof sourceData?.rules?.segmentation?.max_tokens === 'number' + ? sourceData.rules.segmentation.max_tokens + : defaultValue + + const childMaxTokens = typeof sourceData?.rules?.subchunk_segmentation?.max_tokens === 'number' + ? sourceData.rules.subchunk_segmentation.max_tokens + : defaultValue + + const isGeneralMode = sourceData.mode === ProcessMode.general + + const fieldValueMap: Record = { + mode: isGeneralMode + ? t('embedding.custom', { ns: 'datasetDocuments' }) + : `${t('embedding.hierarchical', { ns: 'datasetDocuments' })} · ${ + sourceData?.rules?.parent_mode === 'paragraph' + ? t('parentMode.paragraph', { ns: 'dataset' }) + : t('parentMode.fullDoc', { ns: 'dataset' }) + }`, + segmentLength: isGeneralMode + ? maxTokens + : `${t('embedding.parentMaxTokens', { ns: 'datasetDocuments' })} ${maxTokens}; ${t('embedding.childMaxTokens', { ns: 'datasetDocuments' })} ${childMaxTokens}`, + textCleaning: sourceData?.rules?.pre_processing_rules + ?.filter(rule => rule.enabled) + .map(rule => getRuleName(rule.id)) + .join(',') || defaultValue, + } + + return fieldValueMap[field] ?? defaultValue + }, [sourceData, t, getRuleName]) + + const isEconomical = indexingType === IndexingType.ECONOMICAL + + return ( +
+
+ {Object.keys(segmentationRuleMap).map(field => ( + + ))} +
+ + + )} + /> + + )} + /> +
+ ) +}) + +RuleDetail.displayName = 'RuleDetail' + +export default RuleDetail diff --git a/web/app/components/datasets/documents/detail/embedding/components/segment-progress.spec.tsx b/web/app/components/datasets/documents/detail/embedding/components/segment-progress.spec.tsx new file mode 100644 index 0000000000..1afc2f42f1 --- /dev/null +++ b/web/app/components/datasets/documents/detail/embedding/components/segment-progress.spec.tsx @@ -0,0 +1,81 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import SegmentProgress from './segment-progress' + +describe('SegmentProgress', () => { + const defaultProps = { + completedSegments: 50, + totalSegments: 100, + percent: 50, + } + + describe('Rendering', () => { + it('should render without crashing', () => { + render() + expect(screen.getByText(/segments/i)).toBeInTheDocument() + }) + + it('should render with correct CSS classes', () => { + const { container } = render() + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('flex', 'w-full', 'items-center') + }) + + it('should render text with correct styling class', () => { + render() + const text = screen.getByText(/segments/i) + expect(text).toHaveClass('system-xs-medium', 'text-text-secondary') + }) + }) + + describe('Progress Display', () => { + it('should display completed and total segments', () => { + render() + expect(screen.getByText(/50\/100/)).toBeInTheDocument() + }) + + it('should display percent value', () => { + render() + expect(screen.getByText(/50%/)).toBeInTheDocument() + }) + + it('should display 0/0 when segments are 0', () => { + render() + expect(screen.getByText(/0\/0/)).toBeInTheDocument() + expect(screen.getByText(/0%/)).toBeInTheDocument() + }) + + it('should display 100% when completed', () => { + render() + expect(screen.getByText(/100\/100/)).toBeInTheDocument() + expect(screen.getByText(/100%/)).toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should display -- when completedSegments is undefined', () => { + render() + expect(screen.getByText(/--\/100/)).toBeInTheDocument() + }) + + it('should display -- when totalSegments is undefined', () => { + render() + expect(screen.getByText(/50\/--/)).toBeInTheDocument() + }) + + it('should display --/-- when both segments are undefined', () => { + render() + expect(screen.getByText(/--\/--/)).toBeInTheDocument() + }) + + it('should handle large numbers', () => { + render() + expect(screen.getByText(/999999\/1000000/)).toBeInTheDocument() + }) + + it('should handle decimal percent', () => { + render() + expect(screen.getByText(/33.33%/)).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/documents/detail/embedding/components/segment-progress.tsx b/web/app/components/datasets/documents/detail/embedding/components/segment-progress.tsx new file mode 100644 index 0000000000..a76704391d --- /dev/null +++ b/web/app/components/datasets/documents/detail/embedding/components/segment-progress.tsx @@ -0,0 +1,32 @@ +import type { FC } from 'react' +import * as React from 'react' +import { useTranslation } from 'react-i18next' + +type SegmentProgressProps = { + completedSegments?: number + totalSegments?: number + percent: number +} + +const SegmentProgress: FC = React.memo(({ + completedSegments, + totalSegments, + percent, +}) => { + const { t } = useTranslation() + + const completed = completedSegments ?? '--' + const total = totalSegments ?? '--' + + return ( +
+ + {`${t('embedding.segments', { ns: 'datasetDocuments' })} ${completed}/${total} · ${percent}%`} + +
+ ) +}) + +SegmentProgress.displayName = 'SegmentProgress' + +export default SegmentProgress diff --git a/web/app/components/datasets/documents/detail/embedding/components/status-header.spec.tsx b/web/app/components/datasets/documents/detail/embedding/components/status-header.spec.tsx new file mode 100644 index 0000000000..519d2f3aa8 --- /dev/null +++ b/web/app/components/datasets/documents/detail/embedding/components/status-header.spec.tsx @@ -0,0 +1,155 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import StatusHeader from './status-header' + +describe('StatusHeader', () => { + const defaultProps = { + isEmbedding: false, + isCompleted: false, + isPaused: false, + isError: false, + onPause: vi.fn(), + onResume: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should render with correct container classes', () => { + const { container } = render() + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('flex', 'h-6', 'items-center', 'gap-x-1') + }) + }) + + describe('Status Text', () => { + it('should display processing text when isEmbedding is true', () => { + render() + expect(screen.getByText(/embedding\.processing/i)).toBeInTheDocument() + }) + + it('should display completed text when isCompleted is true', () => { + render() + expect(screen.getByText(/embedding\.completed/i)).toBeInTheDocument() + }) + + it('should display paused text when isPaused is true', () => { + render() + expect(screen.getByText(/embedding\.paused/i)).toBeInTheDocument() + }) + + it('should display error text when isError is true', () => { + render() + expect(screen.getByText(/embedding\.error/i)).toBeInTheDocument() + }) + + it('should display empty text when no status flags are set', () => { + render() + const statusText = screen.getByText('', { selector: 'span.system-md-semibold-uppercase' }) + expect(statusText).toBeInTheDocument() + }) + }) + + describe('Loading Spinner', () => { + it('should show loading spinner when isEmbedding is true', () => { + const { container } = render() + const spinner = container.querySelector('svg.animate-spin') + expect(spinner).toBeInTheDocument() + }) + + it('should not show loading spinner when isEmbedding is false', () => { + const { container } = render() + const spinner = container.querySelector('svg.animate-spin') + expect(spinner).not.toBeInTheDocument() + }) + }) + + describe('Pause Button', () => { + it('should show pause button when isEmbedding is true', () => { + render() + expect(screen.getByRole('button')).toBeInTheDocument() + expect(screen.getByText(/embedding\.pause/i)).toBeInTheDocument() + }) + + it('should not show pause button when isEmbedding is false', () => { + render() + expect(screen.queryByText(/embedding\.pause/i)).not.toBeInTheDocument() + }) + + it('should call onPause when pause button is clicked', () => { + const onPause = vi.fn() + render() + fireEvent.click(screen.getByRole('button')) + expect(onPause).toHaveBeenCalledTimes(1) + }) + + it('should disable pause button when isPauseLoading is true', () => { + render() + expect(screen.getByRole('button')).toBeDisabled() + }) + }) + + describe('Resume Button', () => { + it('should show resume button when isPaused is true', () => { + render() + expect(screen.getByRole('button')).toBeInTheDocument() + expect(screen.getByText(/embedding\.resume/i)).toBeInTheDocument() + }) + + it('should not show resume button when isPaused is false', () => { + render() + expect(screen.queryByText(/embedding\.resume/i)).not.toBeInTheDocument() + }) + + it('should call onResume when resume button is clicked', () => { + const onResume = vi.fn() + render() + fireEvent.click(screen.getByRole('button')) + expect(onResume).toHaveBeenCalledTimes(1) + }) + + it('should disable resume button when isResumeLoading is true', () => { + render() + expect(screen.getByRole('button')).toBeDisabled() + }) + }) + + describe('Button Styles', () => { + it('should have correct button styles for pause button', () => { + render() + const button = screen.getByRole('button') + expect(button).toHaveClass('flex', 'items-center', 'gap-x-1', 'rounded-md') + }) + + it('should have correct button styles for resume button', () => { + render() + const button = screen.getByRole('button') + expect(button).toHaveClass('flex', 'items-center', 'gap-x-1', 'rounded-md') + }) + }) + + describe('Edge Cases', () => { + it('should not show any buttons when isCompleted', () => { + render() + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('should not show any buttons when isError', () => { + render() + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('should show both buttons when isEmbedding and isPaused are both true', () => { + render() + const buttons = screen.getAllByRole('button') + expect(buttons.length).toBe(2) + }) + }) +}) diff --git a/web/app/components/datasets/documents/detail/embedding/components/status-header.tsx b/web/app/components/datasets/documents/detail/embedding/components/status-header.tsx new file mode 100644 index 0000000000..e72f0553b5 --- /dev/null +++ b/web/app/components/datasets/documents/detail/embedding/components/status-header.tsx @@ -0,0 +1,84 @@ +import type { FC } from 'react' +import { RiLoader2Line, RiPauseCircleLine, RiPlayCircleLine } from '@remixicon/react' +import * as React from 'react' +import { useTranslation } from 'react-i18next' + +type StatusHeaderProps = { + isEmbedding: boolean + isCompleted: boolean + isPaused: boolean + isError: boolean + onPause: () => void + onResume: () => void + isPauseLoading?: boolean + isResumeLoading?: boolean +} + +const StatusHeader: FC = React.memo(({ + isEmbedding, + isCompleted, + isPaused, + isError, + onPause, + onResume, + isPauseLoading, + isResumeLoading, +}) => { + const { t } = useTranslation() + + const getStatusText = () => { + if (isEmbedding) + return t('embedding.processing', { ns: 'datasetDocuments' }) + if (isCompleted) + return t('embedding.completed', { ns: 'datasetDocuments' }) + if (isPaused) + return t('embedding.paused', { ns: 'datasetDocuments' }) + if (isError) + return t('embedding.error', { ns: 'datasetDocuments' }) + return '' + } + + const buttonBaseClass = `flex items-center gap-x-1 rounded-md border-[0.5px] + border-components-button-secondary-border bg-components-button-secondary-bg + px-1.5 py-1 shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px] + disabled:cursor-not-allowed disabled:opacity-50` + + return ( +
+ {isEmbedding && } + + {getStatusText()} + + {isEmbedding && ( + + )} + {isPaused && ( + + )} +
+ ) +}) + +StatusHeader.displayName = 'StatusHeader' + +export default StatusHeader diff --git a/web/app/components/datasets/documents/detail/embedding/hooks/index.ts b/web/app/components/datasets/documents/detail/embedding/hooks/index.ts new file mode 100644 index 0000000000..603c16dda5 --- /dev/null +++ b/web/app/components/datasets/documents/detail/embedding/hooks/index.ts @@ -0,0 +1,10 @@ +export { + calculatePercent, + isEmbeddingStatus, + isTerminalStatus, + useEmbeddingStatus, + useInvalidateEmbeddingStatus, + usePauseIndexing, + useResumeIndexing, +} from './use-embedding-status' +export type { EmbeddingStatusType } from './use-embedding-status' diff --git a/web/app/components/datasets/documents/detail/embedding/hooks/use-embedding-status.spec.tsx b/web/app/components/datasets/documents/detail/embedding/hooks/use-embedding-status.spec.tsx new file mode 100644 index 0000000000..7cadc12dfc --- /dev/null +++ b/web/app/components/datasets/documents/detail/embedding/hooks/use-embedding-status.spec.tsx @@ -0,0 +1,462 @@ +import type { ReactNode } from 'react' +import type { IndexingStatusResponse } from '@/models/datasets' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as datasetsService from '@/service/datasets' +import { + calculatePercent, + isEmbeddingStatus, + isTerminalStatus, + useEmbeddingStatus, + useInvalidateEmbeddingStatus, + usePauseIndexing, + useResumeIndexing, +} from './use-embedding-status' + +vi.mock('@/service/datasets') + +const mockFetchIndexingStatus = vi.mocked(datasetsService.fetchIndexingStatus) +const mockPauseDocIndexing = vi.mocked(datasetsService.pauseDocIndexing) +const mockResumeDocIndexing = vi.mocked(datasetsService.resumeDocIndexing) + +const createTestQueryClient = () => new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, +}) + +const createWrapper = () => { + const queryClient = createTestQueryClient() + return ({ children }: { children: ReactNode }) => ( + + {children} + + ) +} + +const mockIndexingStatus = (overrides: Partial = {}): IndexingStatusResponse => ({ + id: 'doc1', + indexing_status: 'indexing', + completed_segments: 50, + total_segments: 100, + processing_started_at: 0, + parsing_completed_at: 0, + cleaning_completed_at: 0, + splitting_completed_at: 0, + completed_at: null, + paused_at: null, + error: null, + stopped_at: null, + ...overrides, +}) + +describe('use-embedding-status', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('isEmbeddingStatus', () => { + it('should return true for indexing status', () => { + expect(isEmbeddingStatus('indexing')).toBe(true) + }) + + it('should return true for splitting status', () => { + expect(isEmbeddingStatus('splitting')).toBe(true) + }) + + it('should return true for parsing status', () => { + expect(isEmbeddingStatus('parsing')).toBe(true) + }) + + it('should return true for cleaning status', () => { + expect(isEmbeddingStatus('cleaning')).toBe(true) + }) + + it('should return false for completed status', () => { + expect(isEmbeddingStatus('completed')).toBe(false) + }) + + it('should return false for paused status', () => { + expect(isEmbeddingStatus('paused')).toBe(false) + }) + + it('should return false for error status', () => { + expect(isEmbeddingStatus('error')).toBe(false) + }) + + it('should return false for undefined', () => { + expect(isEmbeddingStatus(undefined)).toBe(false) + }) + + it('should return false for empty string', () => { + expect(isEmbeddingStatus('')).toBe(false) + }) + }) + + describe('isTerminalStatus', () => { + it('should return true for completed status', () => { + expect(isTerminalStatus('completed')).toBe(true) + }) + + it('should return true for error status', () => { + expect(isTerminalStatus('error')).toBe(true) + }) + + it('should return true for paused status', () => { + expect(isTerminalStatus('paused')).toBe(true) + }) + + it('should return false for indexing status', () => { + expect(isTerminalStatus('indexing')).toBe(false) + }) + + it('should return false for undefined', () => { + expect(isTerminalStatus(undefined)).toBe(false) + }) + }) + + describe('calculatePercent', () => { + it('should calculate percent correctly', () => { + expect(calculatePercent(50, 100)).toBe(50) + }) + + it('should return 0 when total is 0', () => { + expect(calculatePercent(50, 0)).toBe(0) + }) + + it('should return 0 when total is undefined', () => { + expect(calculatePercent(50, undefined)).toBe(0) + }) + + it('should return 0 when completed is undefined', () => { + expect(calculatePercent(undefined, 100)).toBe(0) + }) + + it('should cap at 100 when percent exceeds 100', () => { + expect(calculatePercent(150, 100)).toBe(100) + }) + + it('should round to nearest integer', () => { + expect(calculatePercent(33, 100)).toBe(33) + expect(calculatePercent(1, 3)).toBe(33) + }) + }) + + describe('useEmbeddingStatus', () => { + it('should return initial state when disabled', () => { + const { result } = renderHook( + () => useEmbeddingStatus({ datasetId: 'ds1', documentId: 'doc1', enabled: false }), + { wrapper: createWrapper() }, + ) + + expect(result.current.isEmbedding).toBe(false) + expect(result.current.isCompleted).toBe(false) + expect(result.current.isPaused).toBe(false) + expect(result.current.isError).toBe(false) + expect(result.current.percent).toBe(0) + }) + + it('should not fetch when datasetId is missing', () => { + renderHook( + () => useEmbeddingStatus({ documentId: 'doc1' }), + { wrapper: createWrapper() }, + ) + + expect(mockFetchIndexingStatus).not.toHaveBeenCalled() + }) + + it('should not fetch when documentId is missing', () => { + renderHook( + () => useEmbeddingStatus({ datasetId: 'ds1' }), + { wrapper: createWrapper() }, + ) + + expect(mockFetchIndexingStatus).not.toHaveBeenCalled() + }) + + it('should fetch indexing status when enabled with valid ids', async () => { + mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus()) + + const { result } = renderHook( + () => useEmbeddingStatus({ datasetId: 'ds1', documentId: 'doc1' }), + { wrapper: createWrapper() }, + ) + + await waitFor(() => { + expect(result.current.isEmbedding).toBe(true) + }) + + expect(mockFetchIndexingStatus).toHaveBeenCalledWith({ + datasetId: 'ds1', + documentId: 'doc1', + }) + expect(result.current.percent).toBe(50) + }) + + it('should set isCompleted when status is completed', async () => { + mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus({ + indexing_status: 'completed', + completed_segments: 100, + })) + + const { result } = renderHook( + () => useEmbeddingStatus({ datasetId: 'ds1', documentId: 'doc1' }), + { wrapper: createWrapper() }, + ) + + await waitFor(() => { + expect(result.current.isCompleted).toBe(true) + }) + + expect(result.current.percent).toBe(100) + }) + + it('should set isPaused when status is paused', async () => { + mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus({ + indexing_status: 'paused', + })) + + const { result } = renderHook( + () => useEmbeddingStatus({ datasetId: 'ds1', documentId: 'doc1' }), + { wrapper: createWrapper() }, + ) + + await waitFor(() => { + expect(result.current.isPaused).toBe(true) + }) + }) + + it('should set isError when status is error', async () => { + mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus({ + indexing_status: 'error', + completed_segments: 25, + })) + + const { result } = renderHook( + () => useEmbeddingStatus({ datasetId: 'ds1', documentId: 'doc1' }), + { wrapper: createWrapper() }, + ) + + await waitFor(() => { + expect(result.current.isError).toBe(true) + }) + }) + + it('should provide invalidate function', async () => { + mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus()) + + const { result } = renderHook( + () => useEmbeddingStatus({ datasetId: 'ds1', documentId: 'doc1' }), + { wrapper: createWrapper() }, + ) + + await waitFor(() => { + expect(result.current.isEmbedding).toBe(true) + }) + + expect(typeof result.current.invalidate).toBe('function') + + // Call invalidate should not throw + await act(async () => { + result.current.invalidate() + }) + }) + + it('should provide resetStatus function that clears data', async () => { + mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus()) + + const { result } = renderHook( + () => useEmbeddingStatus({ datasetId: 'ds1', documentId: 'doc1' }), + { wrapper: createWrapper() }, + ) + + await waitFor(() => { + expect(result.current.data).toBeDefined() + }) + + // Reset status should clear the data + await act(async () => { + result.current.resetStatus() + }) + + await waitFor(() => { + expect(result.current.data).toBeNull() + }) + }) + }) + + describe('usePauseIndexing', () => { + it('should call pauseDocIndexing when mutate is called', async () => { + mockPauseDocIndexing.mockResolvedValue({ result: 'success' }) + + const { result } = renderHook( + () => usePauseIndexing({ datasetId: 'ds1', documentId: 'doc1' }), + { wrapper: createWrapper() }, + ) + + await act(async () => { + result.current.mutate() + }) + + await waitFor(() => { + expect(mockPauseDocIndexing).toHaveBeenCalledWith({ + datasetId: 'ds1', + documentId: 'doc1', + }) + }) + }) + + it('should call onSuccess callback on successful pause', async () => { + mockPauseDocIndexing.mockResolvedValue({ result: 'success' }) + const onSuccess = vi.fn() + + const { result } = renderHook( + () => usePauseIndexing({ datasetId: 'ds1', documentId: 'doc1', onSuccess }), + { wrapper: createWrapper() }, + ) + + await act(async () => { + result.current.mutate() + }) + + await waitFor(() => { + expect(onSuccess).toHaveBeenCalled() + }) + }) + + it('should call onError callback on failed pause', async () => { + const error = new Error('Network error') + mockPauseDocIndexing.mockRejectedValue(error) + const onError = vi.fn() + + const { result } = renderHook( + () => usePauseIndexing({ datasetId: 'ds1', documentId: 'doc1', onError }), + { wrapper: createWrapper() }, + ) + + await act(async () => { + result.current.mutate() + }) + + await waitFor(() => { + expect(onError).toHaveBeenCalled() + expect(onError.mock.calls[0][0]).toEqual(error) + }) + }) + }) + + describe('useResumeIndexing', () => { + it('should call resumeDocIndexing when mutate is called', async () => { + mockResumeDocIndexing.mockResolvedValue({ result: 'success' }) + + const { result } = renderHook( + () => useResumeIndexing({ datasetId: 'ds1', documentId: 'doc1' }), + { wrapper: createWrapper() }, + ) + + await act(async () => { + result.current.mutate() + }) + + await waitFor(() => { + expect(mockResumeDocIndexing).toHaveBeenCalledWith({ + datasetId: 'ds1', + documentId: 'doc1', + }) + }) + }) + + it('should call onSuccess callback on successful resume', async () => { + mockResumeDocIndexing.mockResolvedValue({ result: 'success' }) + const onSuccess = vi.fn() + + const { result } = renderHook( + () => useResumeIndexing({ datasetId: 'ds1', documentId: 'doc1', onSuccess }), + { wrapper: createWrapper() }, + ) + + await act(async () => { + result.current.mutate() + }) + + await waitFor(() => { + expect(onSuccess).toHaveBeenCalled() + }) + }) + }) + + describe('useInvalidateEmbeddingStatus', () => { + it('should return a function', () => { + const { result } = renderHook( + () => useInvalidateEmbeddingStatus(), + { wrapper: createWrapper() }, + ) + + expect(typeof result.current).toBe('function') + }) + + it('should invalidate specific query when datasetId and documentId are provided', async () => { + const queryClient = createTestQueryClient() + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ) + + // Set some initial data in the cache + queryClient.setQueryData(['embedding', 'indexing-status', 'ds1', 'doc1'], { + id: 'doc1', + indexing_status: 'indexing', + }) + + const { result } = renderHook( + () => useInvalidateEmbeddingStatus(), + { wrapper }, + ) + + await act(async () => { + result.current('ds1', 'doc1') + }) + + // The query should be invalidated (marked as stale) + const queryState = queryClient.getQueryState(['embedding', 'indexing-status', 'ds1', 'doc1']) + expect(queryState?.isInvalidated).toBe(true) + }) + + it('should invalidate all embedding status queries when ids are not provided', async () => { + const queryClient = createTestQueryClient() + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ) + + // Set some initial data in the cache for multiple documents + queryClient.setQueryData(['embedding', 'indexing-status', 'ds1', 'doc1'], { + id: 'doc1', + indexing_status: 'indexing', + }) + queryClient.setQueryData(['embedding', 'indexing-status', 'ds2', 'doc2'], { + id: 'doc2', + indexing_status: 'completed', + }) + + const { result } = renderHook( + () => useInvalidateEmbeddingStatus(), + { wrapper }, + ) + + await act(async () => { + result.current() + }) + + // Both queries should be invalidated + const queryState1 = queryClient.getQueryState(['embedding', 'indexing-status', 'ds1', 'doc1']) + const queryState2 = queryClient.getQueryState(['embedding', 'indexing-status', 'ds2', 'doc2']) + expect(queryState1?.isInvalidated).toBe(true) + expect(queryState2?.isInvalidated).toBe(true) + }) + }) +}) diff --git a/web/app/components/datasets/documents/detail/embedding/hooks/use-embedding-status.ts b/web/app/components/datasets/documents/detail/embedding/hooks/use-embedding-status.ts new file mode 100644 index 0000000000..e55cd8f9aa --- /dev/null +++ b/web/app/components/datasets/documents/detail/embedding/hooks/use-embedding-status.ts @@ -0,0 +1,149 @@ +import type { CommonResponse } from '@/models/common' +import type { IndexingStatusResponse } from '@/models/datasets' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useCallback, useEffect, useMemo, useRef } from 'react' +import { + fetchIndexingStatus, + pauseDocIndexing, + resumeDocIndexing, +} from '@/service/datasets' + +const NAME_SPACE = 'embedding' + +export type EmbeddingStatusType = 'indexing' | 'splitting' | 'parsing' | 'cleaning' | 'completed' | 'paused' | 'error' | 'waiting' | '' + +const EMBEDDING_STATUSES = ['indexing', 'splitting', 'parsing', 'cleaning'] as const +const TERMINAL_STATUSES = ['completed', 'error', 'paused'] as const + +export const isEmbeddingStatus = (status?: string): boolean => { + return EMBEDDING_STATUSES.includes(status as typeof EMBEDDING_STATUSES[number]) +} + +export const isTerminalStatus = (status?: string): boolean => { + return TERMINAL_STATUSES.includes(status as typeof TERMINAL_STATUSES[number]) +} + +export const calculatePercent = (completed?: number, total?: number): number => { + if (!total || total === 0) + return 0 + const percent = Math.round((completed || 0) * 100 / total) + return Math.min(percent, 100) +} + +type UseEmbeddingStatusOptions = { + datasetId?: string + documentId?: string + enabled?: boolean + onComplete?: () => void +} + +export const useEmbeddingStatus = ({ + datasetId, + documentId, + enabled = true, + onComplete, +}: UseEmbeddingStatusOptions) => { + const queryClient = useQueryClient() + const isPolling = useRef(false) + const onCompleteRef = useRef(onComplete) + onCompleteRef.current = onComplete + + const queryKey = useMemo( + () => [NAME_SPACE, 'indexing-status', datasetId, documentId] as const, + [datasetId, documentId], + ) + + const query = useQuery({ + queryKey, + queryFn: () => fetchIndexingStatus({ datasetId: datasetId!, documentId: documentId! }), + enabled: enabled && !!datasetId && !!documentId, + refetchInterval: (query) => { + const status = query.state.data?.indexing_status + if (isTerminalStatus(status)) { + return false + } + return 2500 + }, + refetchOnWindowFocus: false, + }) + + const status = query.data?.indexing_status || '' + const isEmbedding = isEmbeddingStatus(status) + const isCompleted = status === 'completed' + const isPaused = status === 'paused' + const isError = status === 'error' + const percent = calculatePercent(query.data?.completed_segments, query.data?.total_segments) + + // Handle completion callback + useEffect(() => { + if (isTerminalStatus(status) && isPolling.current) { + isPolling.current = false + onCompleteRef.current?.() + } + if (isEmbedding) { + isPolling.current = true + } + }, [status, isEmbedding]) + + const invalidate = useCallback(() => { + queryClient.invalidateQueries({ queryKey }) + }, [queryClient, queryKey]) + + const resetStatus = useCallback(() => { + queryClient.setQueryData(queryKey, null) + }, [queryClient, queryKey]) + + return { + data: query.data, + isLoading: query.isLoading, + isEmbedding, + isCompleted, + isPaused, + isError, + percent, + invalidate, + resetStatus, + refetch: query.refetch, + } +} + +type UsePauseResumeOptions = { + datasetId?: string + documentId?: string + onSuccess?: () => void + onError?: (error: Error) => void +} + +export const usePauseIndexing = ({ datasetId, documentId, onSuccess, onError }: UsePauseResumeOptions) => { + return useMutation({ + mutationKey: [NAME_SPACE, 'pause', datasetId, documentId], + mutationFn: () => pauseDocIndexing({ datasetId: datasetId!, documentId: documentId! }), + onSuccess, + onError, + }) +} + +export const useResumeIndexing = ({ datasetId, documentId, onSuccess, onError }: UsePauseResumeOptions) => { + return useMutation({ + mutationKey: [NAME_SPACE, 'resume', datasetId, documentId], + mutationFn: () => resumeDocIndexing({ datasetId: datasetId!, documentId: documentId! }), + onSuccess, + onError, + }) +} + +export const useInvalidateEmbeddingStatus = () => { + const queryClient = useQueryClient() + return useCallback((datasetId?: string, documentId?: string) => { + if (datasetId && documentId) { + queryClient.invalidateQueries({ + queryKey: [NAME_SPACE, 'indexing-status', datasetId, documentId], + }) + } + else { + queryClient.invalidateQueries({ + queryKey: [NAME_SPACE, 'indexing-status'], + }) + } + }, [queryClient]) +} diff --git a/web/app/components/datasets/documents/detail/embedding/index.spec.tsx b/web/app/components/datasets/documents/detail/embedding/index.spec.tsx new file mode 100644 index 0000000000..699de4f12a --- /dev/null +++ b/web/app/components/datasets/documents/detail/embedding/index.spec.tsx @@ -0,0 +1,337 @@ +import type { ReactNode } from 'react' +import type { DocumentContextValue } from '../context' +import type { IndexingStatusResponse, ProcessRuleResponse } from '@/models/datasets' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ProcessMode } from '@/models/datasets' +import * as datasetsService from '@/service/datasets' +import * as useDataset from '@/service/knowledge/use-dataset' +import { RETRIEVE_METHOD } from '@/types/app' +import { IndexingType } from '../../../create/step-two' +import { DocumentContext } from '../context' +import EmbeddingDetail from './index' + +vi.mock('@/service/datasets') +vi.mock('@/service/knowledge/use-dataset') + +const mockFetchIndexingStatus = vi.mocked(datasetsService.fetchIndexingStatus) +const mockPauseDocIndexing = vi.mocked(datasetsService.pauseDocIndexing) +const mockResumeDocIndexing = vi.mocked(datasetsService.resumeDocIndexing) +const mockUseProcessRule = vi.mocked(useDataset.useProcessRule) + +const createTestQueryClient = () => new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0 }, + mutations: { retry: false }, + }, +}) + +const createWrapper = (contextValue: DocumentContextValue = { datasetId: 'ds1', documentId: 'doc1' }) => { + const queryClient = createTestQueryClient() + return ({ children }: { children: ReactNode }) => ( + + + {children} + + + ) +} + +const mockIndexingStatus = (overrides: Partial = {}): IndexingStatusResponse => ({ + id: 'doc1', + indexing_status: 'indexing', + completed_segments: 50, + total_segments: 100, + processing_started_at: Date.now(), + parsing_completed_at: 0, + cleaning_completed_at: 0, + splitting_completed_at: 0, + completed_at: null, + paused_at: null, + error: null, + stopped_at: null, + ...overrides, +}) + +const mockProcessRule = (overrides: Partial = {}): ProcessRuleResponse => ({ + mode: ProcessMode.general, + rules: { + segmentation: { separator: '\n', max_tokens: 500, chunk_overlap: 50 }, + pre_processing_rules: [{ id: 'remove_extra_spaces', enabled: true }], + parent_mode: 'full-doc', + subchunk_segmentation: { separator: '\n', max_tokens: 200, chunk_overlap: 20 }, + }, + limits: { indexing_max_segmentation_tokens_length: 4000 }, + ...overrides, +}) + +describe('EmbeddingDetail', () => { + const defaultProps = { + detailUpdate: vi.fn(), + indexingType: IndexingType.QUALIFIED, + retrievalMethod: RETRIEVE_METHOD.semantic, + } + + beforeEach(() => { + vi.clearAllMocks() + + mockUseProcessRule.mockReturnValue({ + data: mockProcessRule(), + isLoading: false, + error: null, + } as ReturnType) + }) + + describe('Rendering', () => { + it('should render without crashing', async () => { + mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus()) + + render(, { wrapper: createWrapper() }) + + await waitFor(() => { + expect(screen.getByText(/embedding\.processing/i)).toBeInTheDocument() + }) + }) + + it('should render with provided datasetId and documentId props', async () => { + mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus()) + + render( + , + { wrapper: createWrapper({ datasetId: '', documentId: '' }) }, + ) + + await waitFor(() => { + expect(mockFetchIndexingStatus).toHaveBeenCalledWith({ + datasetId: 'custom-ds', + documentId: 'custom-doc', + }) + }) + }) + + it('should fall back to context values when props are not provided', async () => { + mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus()) + + render(, { wrapper: createWrapper() }) + + await waitFor(() => { + expect(mockFetchIndexingStatus).toHaveBeenCalledWith({ + datasetId: 'ds1', + documentId: 'doc1', + }) + }) + }) + }) + + describe('Status Display', () => { + it('should show processing status when indexing', async () => { + mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus({ indexing_status: 'indexing' })) + + render(, { wrapper: createWrapper() }) + + await waitFor(() => { + expect(screen.getByText(/embedding\.processing/i)).toBeInTheDocument() + }) + }) + + it('should show completed status', async () => { + mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus({ indexing_status: 'completed' })) + + render(, { wrapper: createWrapper() }) + + await waitFor(() => { + expect(screen.getByText(/embedding\.completed/i)).toBeInTheDocument() + }) + }) + + it('should show paused status', async () => { + mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus({ indexing_status: 'paused' })) + + render(, { wrapper: createWrapper() }) + + await waitFor(() => { + expect(screen.getByText(/embedding\.paused/i)).toBeInTheDocument() + }) + }) + + it('should show error status', async () => { + mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus({ indexing_status: 'error' })) + + render(, { wrapper: createWrapper() }) + + await waitFor(() => { + expect(screen.getByText(/embedding\.error/i)).toBeInTheDocument() + }) + }) + }) + + describe('Progress Display', () => { + it('should display segment progress', async () => { + mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus({ + completed_segments: 50, + total_segments: 100, + })) + + render(, { wrapper: createWrapper() }) + + await waitFor(() => { + expect(screen.getByText(/50\/100/)).toBeInTheDocument() + expect(screen.getByText(/50%/)).toBeInTheDocument() + }) + }) + }) + + describe('Pause/Resume Actions', () => { + it('should show pause button when embedding is in progress', async () => { + mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus({ indexing_status: 'indexing' })) + + render(, { wrapper: createWrapper() }) + + await waitFor(() => { + expect(screen.getByText(/embedding\.pause/i)).toBeInTheDocument() + }) + }) + + it('should show resume button when paused', async () => { + mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus({ indexing_status: 'paused' })) + + render(, { wrapper: createWrapper() }) + + await waitFor(() => { + expect(screen.getByText(/embedding\.resume/i)).toBeInTheDocument() + }) + }) + + it('should call pause API when pause button is clicked', async () => { + const user = userEvent.setup() + mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus({ indexing_status: 'indexing' })) + mockPauseDocIndexing.mockResolvedValue({ result: 'success' }) + + render(, { wrapper: createWrapper() }) + + await waitFor(() => { + expect(screen.getByText(/embedding\.pause/i)).toBeInTheDocument() + }) + + await user.click(screen.getByRole('button', { name: /pause/i })) + + await waitFor(() => { + expect(mockPauseDocIndexing).toHaveBeenCalledWith({ + datasetId: 'ds1', + documentId: 'doc1', + }) + }) + }) + + it('should call resume API when resume button is clicked', async () => { + const user = userEvent.setup() + mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus({ indexing_status: 'paused' })) + mockResumeDocIndexing.mockResolvedValue({ result: 'success' }) + + render(, { wrapper: createWrapper() }) + + await waitFor(() => { + expect(screen.getByText(/embedding\.resume/i)).toBeInTheDocument() + }) + + await user.click(screen.getByRole('button', { name: /resume/i })) + + await waitFor(() => { + expect(mockResumeDocIndexing).toHaveBeenCalledWith({ + datasetId: 'ds1', + documentId: 'doc1', + }) + }) + }) + }) + + describe('Rule Detail', () => { + it('should display rule detail section', async () => { + mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus()) + + render(, { wrapper: createWrapper() }) + + await waitFor(() => { + expect(screen.getByText(/stepTwo\.indexMode/i)).toBeInTheDocument() + }) + }) + + it('should display qualified index mode', async () => { + mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus()) + + render( + , + { wrapper: createWrapper() }, + ) + + await waitFor(() => { + expect(screen.getByText(/stepTwo\.qualified/i)).toBeInTheDocument() + }) + }) + + it('should display economical index mode', async () => { + mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus()) + + render( + , + { wrapper: createWrapper() }, + ) + + await waitFor(() => { + expect(screen.getByText(/stepTwo\.economical/i)).toBeInTheDocument() + }) + }) + }) + + describe('detailUpdate Callback', () => { + it('should call detailUpdate when status becomes terminal', async () => { + const detailUpdate = vi.fn() + // First call returns indexing, subsequent call returns completed + mockFetchIndexingStatus + .mockResolvedValueOnce(mockIndexingStatus({ indexing_status: 'indexing' })) + .mockResolvedValueOnce(mockIndexingStatus({ indexing_status: 'completed' })) + + render( + , + { wrapper: createWrapper() }, + ) + + // Wait for the terminal status to trigger detailUpdate + await waitFor(() => { + expect(mockFetchIndexingStatus).toHaveBeenCalled() + }, { timeout: 5000 }) + }) + }) + + describe('Edge Cases', () => { + it('should handle missing context values', async () => { + mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus()) + + render( + , + { wrapper: createWrapper({ datasetId: undefined, documentId: undefined }) }, + ) + + await waitFor(() => { + expect(mockFetchIndexingStatus).toHaveBeenCalledWith({ + datasetId: 'explicit-ds', + documentId: 'explicit-doc', + }) + }) + }) + + it('should render skeleton component', async () => { + mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus()) + + const { container } = render(, { wrapper: createWrapper() }) + + // EmbeddingSkeleton should be rendered - check for the skeleton wrapper element + await waitFor(() => { + const skeletonWrapper = container.querySelector('.bg-dataset-chunk-list-mask-bg') + expect(skeletonWrapper).toBeInTheDocument() + }) + }) + }) +}) diff --git a/web/app/components/datasets/documents/detail/embedding/index.tsx b/web/app/components/datasets/documents/detail/embedding/index.tsx index 37b5bb85e7..e89a85c6de 100644 --- a/web/app/components/datasets/documents/detail/embedding/index.tsx +++ b/web/app/components/datasets/documents/detail/embedding/index.tsx @@ -1,31 +1,18 @@ import type { FC } from 'react' -import type { CommonResponse } from '@/models/common' -import type { IndexingStatusResponse, ProcessRuleResponse } from '@/models/datasets' -import { RiLoader2Line, RiPauseCircleLine, RiPlayCircleLine } from '@remixicon/react' -import Image from 'next/image' +import type { IndexingType } from '../../../create/step-two' +import type { RETRIEVE_METHOD } from '@/types/app' import * as React from 'react' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' -import Divider from '@/app/components/base/divider' import { ToastContext } from '@/app/components/base/toast' -import { ProcessMode } from '@/models/datasets' -import { - fetchIndexingStatus as doFetchIndexingStatus, - pauseDocIndexing, - resumeDocIndexing, -} from '@/service/datasets' import { useProcessRule } from '@/service/knowledge/use-dataset' -import { RETRIEVE_METHOD } from '@/types/app' -import { asyncRunSafe, sleep } from '@/utils' -import { cn } from '@/utils/classnames' -import { indexMethodIcon, retrievalIcon } from '../../../create/icons' -import { IndexingType } from '../../../create/step-two' import { useDocumentContext } from '../context' -import { FieldInfo } from '../metadata' +import { ProgressBar, RuleDetail, SegmentProgress, StatusHeader } from './components' +import { useEmbeddingStatus, usePauseIndexing, useResumeIndexing } from './hooks' import EmbeddingSkeleton from './skeleton' -type IEmbeddingDetailProps = { +type EmbeddingDetailProps = { datasetId?: string documentId?: string indexingType?: IndexingType @@ -33,128 +20,7 @@ type IEmbeddingDetailProps = { detailUpdate: VoidFunction } -type IRuleDetailProps = { - sourceData?: ProcessRuleResponse - indexingType?: IndexingType - retrievalMethod?: RETRIEVE_METHOD -} - -const RuleDetail: FC = React.memo(({ - sourceData, - indexingType, - retrievalMethod, -}) => { - const { t } = useTranslation() - - const segmentationRuleMap = { - mode: t('embedding.mode', { ns: 'datasetDocuments' }), - segmentLength: t('embedding.segmentLength', { ns: 'datasetDocuments' }), - textCleaning: t('embedding.textCleaning', { ns: 'datasetDocuments' }), - } - - const getRuleName = (key: string) => { - if (key === 'remove_extra_spaces') - return t('stepTwo.removeExtraSpaces', { ns: 'datasetCreation' }) - - if (key === 'remove_urls_emails') - return t('stepTwo.removeUrlEmails', { ns: 'datasetCreation' }) - - if (key === 'remove_stopwords') - return t('stepTwo.removeStopwords', { ns: 'datasetCreation' }) - } - - const isNumber = (value: unknown) => { - return typeof value === 'number' - } - - const getValue = useCallback((field: string) => { - let value: string | number | undefined = '-' - const maxTokens = isNumber(sourceData?.rules?.segmentation?.max_tokens) - ? sourceData.rules.segmentation.max_tokens - : value - const childMaxTokens = isNumber(sourceData?.rules?.subchunk_segmentation?.max_tokens) - ? sourceData.rules.subchunk_segmentation.max_tokens - : value - switch (field) { - case 'mode': - value = !sourceData?.mode - ? value - : sourceData.mode === ProcessMode.general - ? (t('embedding.custom', { ns: 'datasetDocuments' }) as string) - : `${t('embedding.hierarchical', { ns: 'datasetDocuments' })} · ${sourceData?.rules?.parent_mode === 'paragraph' - ? t('parentMode.paragraph', { ns: 'dataset' }) - : t('parentMode.fullDoc', { ns: 'dataset' })}` - break - case 'segmentLength': - value = !sourceData?.mode - ? value - : sourceData.mode === ProcessMode.general - ? maxTokens - : `${t('embedding.parentMaxTokens', { ns: 'datasetDocuments' })} ${maxTokens}; ${t('embedding.childMaxTokens', { ns: 'datasetDocuments' })} ${childMaxTokens}` - break - default: - value = !sourceData?.mode - ? value - : sourceData?.rules?.pre_processing_rules?.filter(rule => - rule.enabled).map(rule => getRuleName(rule.id)).join(',') - break - } - return value - }, [sourceData]) - - return ( -
-
- {Object.keys(segmentationRuleMap).map((field) => { - return ( - - ) - })} -
- - - )} - /> - - )} - /> -
- ) -}) - -RuleDetail.displayName = 'RuleDetail' - -const EmbeddingDetail: FC = ({ +const EmbeddingDetail: FC = ({ datasetId: dstId, documentId: docId, detailUpdate, @@ -164,144 +30,95 @@ const EmbeddingDetail: FC = ({ const { t } = useTranslation() const { notify } = useContext(ToastContext) - const datasetId = useDocumentContext(s => s.datasetId) - const documentId = useDocumentContext(s => s.documentId) - const localDatasetId = dstId ?? datasetId - const localDocumentId = docId ?? documentId + const contextDatasetId = useDocumentContext(s => s.datasetId) + const contextDocumentId = useDocumentContext(s => s.documentId) + const datasetId = dstId ?? contextDatasetId + const documentId = docId ?? contextDocumentId - const [indexingStatusDetail, setIndexingStatusDetail] = useState(null) - const fetchIndexingStatus = async () => { - const status = await doFetchIndexingStatus({ datasetId: localDatasetId, documentId: localDocumentId }) - setIndexingStatusDetail(status) - return status - } + const { + data: indexingStatus, + isEmbedding, + isCompleted, + isPaused, + isError, + percent, + resetStatus, + refetch, + } = useEmbeddingStatus({ + datasetId, + documentId, + onComplete: detailUpdate, + }) - const isStopQuery = useRef(false) - const stopQueryStatus = useCallback(() => { - isStopQuery.current = true - }, []) + const { data: ruleDetail } = useProcessRule(documentId) - const startQueryStatus = useCallback(async () => { - if (isStopQuery.current) - return + const handleSuccess = useCallback(() => { + notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) }) + }, [notify, t]) - try { - const indexingStatusDetail = await fetchIndexingStatus() - if (['completed', 'error', 'paused'].includes(indexingStatusDetail?.indexing_status)) { - stopQueryStatus() - detailUpdate() - return - } + const handleError = useCallback(() => { + notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) }) + }, [notify, t]) - await sleep(2500) - await startQueryStatus() - } - catch { - await sleep(2500) - await startQueryStatus() - } - }, [stopQueryStatus]) + const pauseMutation = usePauseIndexing({ + datasetId, + documentId, + onSuccess: () => { + handleSuccess() + resetStatus() + }, + onError: handleError, + }) - useEffect(() => { - isStopQuery.current = false - startQueryStatus() - return () => { - stopQueryStatus() - } - }, [startQueryStatus, stopQueryStatus]) + const resumeMutation = useResumeIndexing({ + datasetId, + documentId, + onSuccess: () => { + handleSuccess() + refetch() + detailUpdate() + }, + onError: handleError, + }) - const { data: ruleDetail } = useProcessRule(localDocumentId) + const handlePause = useCallback(() => { + pauseMutation.mutate() + }, [pauseMutation]) - const isEmbedding = useMemo(() => ['indexing', 'splitting', 'parsing', 'cleaning'].includes(indexingStatusDetail?.indexing_status || ''), [indexingStatusDetail]) - const isEmbeddingCompleted = useMemo(() => ['completed'].includes(indexingStatusDetail?.indexing_status || ''), [indexingStatusDetail]) - const isEmbeddingPaused = useMemo(() => ['paused'].includes(indexingStatusDetail?.indexing_status || ''), [indexingStatusDetail]) - const isEmbeddingError = useMemo(() => ['error'].includes(indexingStatusDetail?.indexing_status || ''), [indexingStatusDetail]) - const percent = useMemo(() => { - const completedCount = indexingStatusDetail?.completed_segments || 0 - const totalCount = indexingStatusDetail?.total_segments || 0 - if (totalCount === 0) - return 0 - const percent = Math.round(completedCount * 100 / totalCount) - return percent > 100 ? 100 : percent - }, [indexingStatusDetail]) - - const handleSwitch = async () => { - const opApi = isEmbedding ? pauseDocIndexing : resumeDocIndexing - const [e] = await asyncRunSafe(opApi({ datasetId: localDatasetId, documentId: localDocumentId }) as Promise) - if (!e) { - notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) }) - // if the embedding is resumed from paused, we need to start the query status - if (isEmbeddingPaused) { - isStopQuery.current = false - startQueryStatus() - detailUpdate() - } - setIndexingStatusDetail(null) - } - else { - notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) }) - } - } + const handleResume = useCallback(() => { + resumeMutation.mutate() + }, [resumeMutation]) return ( <>
-
- {isEmbedding && } - - {isEmbedding && t('embedding.processing', { ns: 'datasetDocuments' })} - {isEmbeddingCompleted && t('embedding.completed', { ns: 'datasetDocuments' })} - {isEmbeddingPaused && t('embedding.paused', { ns: 'datasetDocuments' })} - {isEmbeddingError && t('embedding.error', { ns: 'datasetDocuments' })} - - {isEmbedding && ( - - )} - {isEmbeddingPaused && ( - - )} -
- {/* progress bar */} -
-
-
-
- - {`${t('embedding.segments', { ns: 'datasetDocuments' })} ${indexingStatusDetail?.completed_segments || '--'}/${indexingStatusDetail?.total_segments || '--'} · ${percent}%`} - -
- + + + +
diff --git a/web/app/components/datasets/list/dataset-card/components/dataset-card-header.spec.tsx b/web/app/components/datasets/list/dataset-card/components/dataset-card-header.spec.tsx index 6a0e3693a3..c7121287b3 100644 --- a/web/app/components/datasets/list/dataset-card/components/dataset-card-header.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/components/dataset-card-header.spec.tsx @@ -6,6 +6,13 @@ import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datase import { RETRIEVE_METHOD } from '@/types/app' import DatasetCardHeader from './dataset-card-header' +// Mock AppIcon component to avoid emoji-mart initialization issues +vi.mock('@/app/components/base/app-icon', () => ({ + default: ({ icon, className }: { icon?: string, className?: string }) => ( +
{icon}
+ ), +})) + // Mock useFormatTimeFromNow hook vi.mock('@/hooks/use-format-time-from-now', () => ({ useFormatTimeFromNow: () => ({ diff --git a/web/app/components/datasets/list/dataset-card/components/dataset-card-modals.spec.tsx b/web/app/components/datasets/list/dataset-card/components/dataset-card-modals.spec.tsx index ebee72159e..607830661d 100644 --- a/web/app/components/datasets/list/dataset-card/components/dataset-card-modals.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/components/dataset-card-modals.spec.tsx @@ -19,6 +19,28 @@ vi.mock('../../../rename-modal', () => ({ ), })) +// Mock Confirm component since it uses createPortal which can cause issues in tests +vi.mock('@/app/components/base/confirm', () => ({ + default: ({ isShow, title, content, onConfirm, onCancel }: { + isShow: boolean + title: string + content?: React.ReactNode + onConfirm: () => void + onCancel: () => void + }) => ( + isShow + ? ( +
+
{title}
+
{content}
+ + +
+ ) + : null + ), +})) + describe('DatasetCardModals', () => { const mockDataset: DataSet = { id: 'dataset-1', @@ -172,11 +194,9 @@ describe('DatasetCardModals', () => { />, ) - // Find and click the confirm button - const confirmButton = screen.getByRole('button', { name: /confirm|ok|delete/i }) - || screen.getAllByRole('button').find(btn => btn.textContent?.toLowerCase().includes('confirm')) - if (confirmButton) - fireEvent.click(confirmButton) + // Find and click the confirm button using our mocked Confirm component + const confirmButton = screen.getByRole('button', { name: /confirm/i }) + fireEvent.click(confirmButton) expect(onConfirmDelete).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/goto-anything/index.spec.tsx b/web/app/components/goto-anything/index.spec.tsx index 7fb45726e8..6a6143a6e2 100644 --- a/web/app/components/goto-anything/index.spec.tsx +++ b/web/app/components/goto-anything/index.spec.tsx @@ -70,6 +70,10 @@ vi.mock('./context', () => ({ GotoAnythingProvider: ({ children }: { children: React.ReactNode }) => <>{children}, })) +vi.mock('@/app/components/workflow/utils', () => ({ + getKeyboardKeyNameBySystem: (key: string) => key, +})) + const createActionItem = (key: ActionItem['key'], shortcut: string): ActionItem => ({ key, shortcut, diff --git a/web/app/components/rag-pipeline/components/panel/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/index.spec.tsx index 97229aa443..11f9f8b2c4 100644 --- a/web/app/components/rag-pipeline/components/panel/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/index.spec.tsx @@ -7,47 +7,72 @@ import RagPipelinePanel from './index' // Mock External Dependencies // ============================================================================ -// Type definitions for dynamic module -type DynamicModule = { - default?: React.ComponentType> -} +// Mock reactflow to avoid zustand provider error +vi.mock('reactflow', () => ({ + useNodes: () => [], + useStoreApi: () => ({ + getState: () => ({ + getNodes: () => [], + }), + }), + useReactFlow: () => ({ + getNodes: () => [], + }), + useStore: (selector: (state: Record) => unknown) => { + const state = { + getNodes: () => [], + } + return selector(state) + }, +})) -type PromiseOrModule = Promise | DynamicModule +// Use vi.hoisted to create variables that can be used in vi.mock +const { dynamicMocks, mockInputFieldEditorProps } = vi.hoisted(() => { + let counter = 0 + const mockInputFieldEditorProps = vi.fn() -// Mock next/dynamic to return synchronous components immediately + const createMockComponent = () => { + const index = counter++ + // Order matches the imports in index.tsx: + // 0: Record + // 1: TestRunPanel + // 2: InputFieldPanel + // 3: InputFieldEditorPanel + // 4: PreviewPanel + // 5: GlobalVariablePanel + switch (index) { + case 0: + return () =>
Record Panel
+ case 1: + return () =>
Test Run Panel
+ case 2: + return () =>
Input Field Panel
+ case 3: + return (props: Record) => { + mockInputFieldEditorProps(props) + return
Input Field Editor Panel
+ } + case 4: + return () =>
Preview Panel
+ case 5: + return () =>
Global Variable Panel
+ default: + return () => ( +
+ Dynamic Component + {index} +
+ ) + } + } + + return { dynamicMocks: { createMockComponent }, mockInputFieldEditorProps } +}) + +// Mock next/dynamic vi.mock('next/dynamic', () => ({ - default: (loader: () => PromiseOrModule, _options?: Record) => { - let Component: React.ComponentType> | null = null - - // Try to resolve the loader synchronously for mocked modules - try { - const result = loader() as PromiseOrModule - if (result && typeof (result as Promise).then === 'function') { - // For async modules, we need to handle them specially - // This will work with vi.mock since mocks resolve synchronously - (result as Promise).then((mod: DynamicModule) => { - Component = (mod.default || mod) as React.ComponentType> - }) - } - else if (result) { - Component = ((result as DynamicModule).default || result) as React.ComponentType> - } - } - catch { - // If the module can't be resolved, Component stays null - } - - // Return a simple wrapper that renders the component or null - const DynamicComponent = React.forwardRef((props: Record, ref: React.Ref) => { - // For mocked modules, Component should already be set - if (Component) - return - - return null - }) - - DynamicComponent.displayName = 'DynamicComponent' - return DynamicComponent + default: (_loader: () => Promise<{ default: React.ComponentType }>, _options?: Record) => { + return dynamicMocks.createMockComponent() }, })) @@ -68,6 +93,28 @@ type MockStoreState = { showInputFieldPreviewPanel: boolean inputFieldEditPanelProps: Record | null pipelineId: string + nodePanelWidth: number + workflowCanvasWidth: number + otherPanelWidth: number + setShowInputFieldPanel?: (show: boolean) => void + setShowInputFieldPreviewPanel?: (show: boolean) => void + setInputFieldEditPanelProps?: (props: Record | null) => void +} + +const mockWorkflowStoreState: MockStoreState = { + historyWorkflowData: null, + showDebugAndPreviewPanel: false, + showGlobalVariablePanel: false, + showInputFieldPanel: false, + showInputFieldPreviewPanel: false, + inputFieldEditPanelProps: null, + pipelineId: 'test-pipeline-123', + nodePanelWidth: 400, + workflowCanvasWidth: 1200, + otherPanelWidth: 0, + setShowInputFieldPanel: vi.fn(), + setShowInputFieldPreviewPanel: vi.fn(), + setInputFieldEditPanelProps: vi.fn(), } vi.mock('@/app/components/workflow/store', () => ({ @@ -80,9 +127,15 @@ vi.mock('@/app/components/workflow/store', () => ({ showInputFieldPreviewPanel: mockShowInputFieldPreviewPanel, inputFieldEditPanelProps: mockInputFieldEditPanelProps, pipelineId: mockPipelineId, + nodePanelWidth: 400, + workflowCanvasWidth: 1200, + otherPanelWidth: 0, } return selector(state) }, + useWorkflowStore: () => ({ + getState: () => mockWorkflowStoreState, + }), })) // Mock Panel component to capture props and render children @@ -99,40 +152,6 @@ vi.mock('@/app/components/workflow/panel', () => ({ }, })) -// Mock Record component -vi.mock('@/app/components/workflow/panel/record', () => ({ - default: () =>
Record Panel
, -})) - -// Mock TestRunPanel component -vi.mock('@/app/components/rag-pipeline/components/panel/test-run', () => ({ - default: () =>
Test Run Panel
, -})) - -// Mock InputFieldPanel component -vi.mock('./input-field', () => ({ - default: () =>
Input Field Panel
, -})) - -// Mock InputFieldEditorPanel component -const mockInputFieldEditorProps = vi.fn() -vi.mock('./input-field/editor', () => ({ - default: (props: Record) => { - mockInputFieldEditorProps(props) - return
Input Field Editor Panel
- }, -})) - -// Mock PreviewPanel component -vi.mock('./input-field/preview', () => ({ - default: () =>
Preview Panel
, -})) - -// Mock GlobalVariablePanel component -vi.mock('@/app/components/workflow/panel/global-variable-panel', () => ({ - default: () =>
Global Variable Panel
, -})) - // ============================================================================ // Helper Functions // ============================================================================ diff --git a/web/app/components/rag-pipeline/components/update-dsl-modal.spec.tsx b/web/app/components/rag-pipeline/components/update-dsl-modal.spec.tsx index 45eb1cafe1..317f2b19d4 100644 --- a/web/app/components/rag-pipeline/components/update-dsl-modal.spec.tsx +++ b/web/app/components/rag-pipeline/components/update-dsl-modal.spec.tsx @@ -134,22 +134,6 @@ vi.mock('@/app/components/workflow/constants', () => ({ WORKFLOW_DATA_UPDATE: 'WORKFLOW_DATA_UPDATE', })) -// Mock FileReader -class MockFileReader { - result: string | null = null - onload: ((e: { target: { result: string | null } }) => void) | null = null - - readAsText(_file: File) { - // Simulate async file reading using queueMicrotask for more reliable async behavior - queueMicrotask(() => { - this.result = 'test file content' - if (this.onload) { - this.onload({ target: { result: this.result } }) - } - }) - } -} - afterEach(() => { cleanup() vi.clearAllMocks() @@ -159,7 +143,6 @@ describe('UpdateDSLModal', () => { const mockOnCancel = vi.fn() const mockOnBackup = vi.fn() const mockOnImport = vi.fn() - let originalFileReader: typeof FileReader const defaultProps = { onCancel: mockOnCancel, @@ -175,14 +158,6 @@ describe('UpdateDSLModal', () => { pipeline_id: 'test-pipeline-id', }) mockHandleCheckPluginDependencies.mockResolvedValue(undefined) - - // Mock FileReader - originalFileReader = globalThis.FileReader - globalThis.FileReader = MockFileReader as unknown as typeof FileReader - }) - - afterEach(() => { - globalThis.FileReader = originalFileReader }) describe('rendering', () => { @@ -552,6 +527,7 @@ describe('UpdateDSLModal', () => { const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' }) fireEvent.change(fileInput, { target: { files: [file] } }) + // Wait for FileReader to process and button to be enabled await waitFor(() => { const importButton = screen.getByText('common.overwriteAndImport') expect(importButton).not.toBeDisabled() @@ -576,15 +552,12 @@ describe('UpdateDSLModal', () => { const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' }) fireEvent.change(fileInput, { target: { files: [file] } }) - // Wait for FileReader to complete (setTimeout 0) and button to be enabled + // Wait for FileReader to complete and button to be enabled await waitFor(() => { const importButton = screen.getByText('common.overwriteAndImport') expect(importButton).not.toBeDisabled() }) - // Give extra time for the FileReader's setTimeout to complete - await new Promise(resolve => setTimeout(resolve, 10)) - const importButton = screen.getByText('common.overwriteAndImport') fireEvent.click(importButton) @@ -719,7 +692,7 @@ describe('UpdateDSLModal', () => { await waitFor(() => { expect(screen.getByText('1.0.0')).toBeInTheDocument() expect(screen.getByText('2.0.0')).toBeInTheDocument() - }, { timeout: 500 }) + }, { timeout: 1000 }) }) it('should close error modal when cancel button is clicked', async () => { @@ -748,7 +721,7 @@ describe('UpdateDSLModal', () => { // Wait for error modal await waitFor(() => { expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument() - }, { timeout: 500 }) + }, { timeout: 1000 }) // Find and click cancel button in error modal - it should be the one with secondary variant const cancelButtons = screen.getAllByText('newApp.Cancel') @@ -805,7 +778,7 @@ describe('UpdateDSLModal', () => { await waitFor(() => { expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument() - }) + }, { timeout: 1000 }) // Click confirm button const confirmButton = screen.getByText('newApp.Confirm') @@ -848,7 +821,7 @@ describe('UpdateDSLModal', () => { await waitFor(() => { expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument() - }, { timeout: 500 }) + }, { timeout: 1000 }) const confirmButton = screen.getByText('newApp.Confirm') fireEvent.click(confirmButton) @@ -890,7 +863,7 @@ describe('UpdateDSLModal', () => { await waitFor(() => { expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument() - }, { timeout: 500 }) + }, { timeout: 1000 }) const confirmButton = screen.getByText('newApp.Confirm') fireEvent.click(confirmButton) @@ -929,7 +902,7 @@ describe('UpdateDSLModal', () => { await waitFor(() => { expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument() - }, { timeout: 500 }) + }, { timeout: 1000 }) const confirmButton = screen.getByText('newApp.Confirm') fireEvent.click(confirmButton) @@ -971,7 +944,7 @@ describe('UpdateDSLModal', () => { await waitFor(() => { expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument() - }, { timeout: 500 }) + }, { timeout: 1000 }) const confirmButton = screen.getByText('newApp.Confirm') fireEvent.click(confirmButton) @@ -1013,7 +986,7 @@ describe('UpdateDSLModal', () => { await waitFor(() => { expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument() - }, { timeout: 500 }) + }, { timeout: 1000 }) const confirmButton = screen.getByText('newApp.Confirm') fireEvent.click(confirmButton) @@ -1063,7 +1036,7 @@ describe('UpdateDSLModal', () => { await waitFor(() => { expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument() - }) + }, { timeout: 1000 }) const confirmButton = screen.getByText('newApp.Confirm') fireEvent.click(confirmButton) @@ -1101,7 +1074,7 @@ describe('UpdateDSLModal', () => { // Should show error modal even with undefined versions await waitFor(() => { expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument() - }, { timeout: 500 }) + }, { timeout: 1000 }) }) it('should not call importDSLConfirm when importId is not set', async () => { diff --git a/web/app/components/rag-pipeline/hooks/use-DSL.spec.ts b/web/app/components/rag-pipeline/hooks/use-DSL.spec.ts index c6e3d261c0..295ed20bd8 100644 --- a/web/app/components/rag-pipeline/hooks/use-DSL.spec.ts +++ b/web/app/components/rag-pipeline/hooks/use-DSL.spec.ts @@ -53,9 +53,41 @@ vi.mock('@/app/components/workflow/constants', () => ({ // ============================================================================ describe('useDSL', () => { + let mockLink: { href: string, download: string, click: ReturnType, style: { display: string }, remove: ReturnType } + let originalCreateElement: typeof document.createElement + let originalAppendChild: typeof document.body.appendChild + let mockCreateObjectURL: ReturnType + let mockRevokeObjectURL: ReturnType + beforeEach(() => { vi.clearAllMocks() + // Create a proper mock link element with all required properties for downloadBlob + mockLink = { + href: '', + download: '', + click: vi.fn(), + style: { display: '' }, + remove: vi.fn(), + } + + // Save original and mock selectively - only intercept 'a' elements + originalCreateElement = document.createElement.bind(document) + document.createElement = vi.fn((tagName: string) => { + if (tagName === 'a') { + return mockLink as unknown as HTMLElement + } + return originalCreateElement(tagName) + }) as typeof document.createElement + + // Mock document.body.appendChild for downloadBlob + originalAppendChild = document.body.appendChild.bind(document.body) + document.body.appendChild = vi.fn((node: T): T => node) as typeof document.body.appendChild + + // downloadBlob uses window.URL, not URL + mockCreateObjectURL = vi.spyOn(window.URL, 'createObjectURL').mockReturnValue('blob:test-url') + mockRevokeObjectURL = vi.spyOn(window.URL, 'revokeObjectURL').mockImplementation(() => {}) + // Default store state mockGetState.mockReturnValue({ pipelineId: 'test-pipeline-id', @@ -68,6 +100,10 @@ describe('useDSL', () => { }) afterEach(() => { + document.createElement = originalCreateElement + document.body.appendChild = originalAppendChild + mockCreateObjectURL.mockRestore() + mockRevokeObjectURL.mockRestore() vi.clearAllMocks() }) diff --git a/web/app/components/workflow-app/components/workflow-onboarding-modal/index.spec.tsx b/web/app/components/workflow-app/components/workflow-onboarding-modal/index.spec.tsx index 19f5e8b346..63d0344275 100644 --- a/web/app/components/workflow-app/components/workflow-onboarding-modal/index.spec.tsx +++ b/web/app/components/workflow-app/components/workflow-onboarding-modal/index.spec.tsx @@ -13,8 +13,8 @@ vi.mock('@/app/components/base/modal', () => ({ closable, }: { isShow: boolean - onClose: () => void - children: React.ReactNode + onClose?: () => void + children?: React.ReactNode closable?: boolean }) { if (!isShow) @@ -45,8 +45,8 @@ vi.mock('./start-node-selection-panel', () => ({ onSelectUserInput, onSelectTrigger, }: { - onSelectUserInput: () => void - onSelectTrigger: (type: BlockEnum, config?: Record) => void + onSelectUserInput?: () => void + onSelectTrigger?: (type: BlockEnum, config?: Record) => void }) { return (
@@ -55,13 +55,13 @@ vi.mock('./start-node-selection-panel', () => ({ @@ -557,7 +557,7 @@ describe('WorkflowOnboardingModal', () => { // Arrange & Act renderComponent({ isShow: true }) - // Assert + // Assert - ShortcutsName component renders keys in div elements with system-kbd class const escKey = screen.getByText('workflow.onboarding.escTip.key') // ShortcutsName renders a
with class system-kbd, not a element expect(escKey.closest('.system-kbd')).toBeInTheDocument() diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index 79742805df..2a35bf49b6 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -530,11 +530,6 @@ "count": 1 } }, - "app/components/app/create-app-modal/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 7 - } - }, "app/components/app/create-app-modal/index.tsx": { "react-hooks-extra/no-direct-set-state-in-use-effect": { "count": 1 @@ -1801,14 +1796,8 @@ } }, "app/components/datasets/documents/components/list.tsx": { - "react-hooks-extra/no-direct-set-state-in-use-effect": { - "count": 2 - }, "react-refresh/only-export-components": { "count": 1 - }, - "ts/no-explicit-any": { - "count": 2 } }, "app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/index.spec.tsx": {