Compare commits

..

3 Commits

Author SHA1 Message Date
Stephen Zhou
9d1d76055a test 2026-01-30 16:29:46 +08:00
Stephen Zhou
a34d3ffc28 update 2026-01-30 16:26:20 +08:00
Stephen Zhou
1d2ed8da2a test: fix failed test 2026-01-30 16:16:50 +08:00
40 changed files with 1040 additions and 5115 deletions

View File

@@ -1,4 +1,3 @@
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'
@@ -14,8 +13,8 @@ import { getRedirection } from '@/utils/app-redirection'
import CreateAppModal from './index'
vi.mock('ahooks', () => ({
useDebounceFn: <T extends (...args: unknown[]) => unknown>(fn: T) => {
const run = (...args: Parameters<T>) => fn(...args)
useDebounceFn: (fn: (...args: any[]) => any) => {
const run = (...args: any[]) => fn(...args)
const cancel = vi.fn()
const flush = vi.fn()
return { run, cancel, flush }
@@ -84,7 +83,7 @@ describe('CreateAppModal', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseRouter.mockReturnValue({ push: mockPush } as unknown as ReturnType<typeof useRouter>)
mockUseRouter.mockReturnValue({ push: mockPush } as any)
mockUseProviderContext.mockReturnValue({
plan: {
type: AppModeEnum.ADVANCED_CHAT,
@@ -93,10 +92,10 @@ describe('CreateAppModal', () => {
reset: {},
},
enableBilling: true,
} as unknown as ReturnType<typeof useProviderContext>)
} as any)
mockUseAppContext.mockReturnValue({
isCurrentWorkspaceEditor: true,
} as unknown as ReturnType<typeof useAppContext>)
} as any)
mockSetItem.mockClear()
Object.defineProperty(window, 'localStorage', {
value: {
@@ -119,8 +118,8 @@ describe('CreateAppModal', () => {
})
it('creates an app, notifies success, and fires callbacks', async () => {
const mockApp: Partial<App> = { id: 'app-1', mode: AppModeEnum.ADVANCED_CHAT }
mockCreateApp.mockResolvedValue(mockApp as App)
const mockApp = { id: 'app-1', mode: AppModeEnum.ADVANCED_CHAT }
mockCreateApp.mockResolvedValue(mockApp as any)
const { onClose, onSuccess } = renderModal()
const nameInput = screen.getByPlaceholderText('app.newApp.appNamePlaceholder')

View File

@@ -1,262 +0,0 @@
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<string, unknown> = {}): 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(<DocumentSourceIcon doc={doc} />)
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(<DocumentSourceIcon doc={doc} fileType="pdf" />)
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(<DocumentSourceIcon doc={doc} />)
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(<DocumentSourceIcon doc={doc} />)
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(<DocumentSourceIcon doc={doc} fileType="csv" />)
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(<DocumentSourceIcon doc={doc} />)
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(<DocumentSourceIcon doc={doc} />)
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(<DocumentSourceIcon doc={doc} />)
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(<DocumentSourceIcon doc={doc} />)
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(<DocumentSourceIcon doc={doc} />)
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(<DocumentSourceIcon doc={doc} />)
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(<DocumentSourceIcon doc={doc} />)
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(<DocumentSourceIcon doc={doc} />)
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(<DocumentSourceIcon doc={doc} />)
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(<DocumentSourceIcon doc={doc} />)
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(<DocumentSourceIcon doc={doc} />)
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(<DocumentSourceIcon doc={doc} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should memoize the component', () => {
const doc = createMockDoc()
const { rerender, container } = render(<DocumentSourceIcon doc={doc} />)
const firstRender = container.innerHTML
rerender(<DocumentSourceIcon doc={doc} />)
expect(container.innerHTML).toBe(firstRender)
})
})
})

View File

@@ -1,100 +0,0 @@
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<DocumentSourceIconProps> = React.memo(({
doc,
fileType,
}) => {
if (isOnlineDocument(doc.data_source_type)) {
return (
<NotionIcon
className="mr-1.5"
type="page"
src={
isCreateFromRAGPipeline(doc.created_from)
? (doc.data_source_info as OnlineDocumentInfo).page.page_icon
: (doc.data_source_info as LegacyDataSourceInfo).notion_page_icon
}
/>
)
}
if (isLocalFile(doc.data_source_type)) {
return (
<FileTypeIcon
type={
extensionToFileType(
isCreateFromRAGPipeline(doc.created_from)
? (doc?.data_source_info as LocalFileInfo)?.extension
: ((doc?.data_source_info as LegacyDataSourceInfo)?.upload_file?.extension ?? fileType),
)
}
className="mr-1.5"
/>
)
}
if (isOnlineDrive(doc.data_source_type)) {
return (
<FileTypeIcon
type={
extensionToFileType(
getFileExtension((doc?.data_source_info as unknown as OnlineDriveInfo)?.name),
)
}
className="mr-1.5"
/>
)
}
if (isWebsiteCrawl(doc.data_source_type)) {
return <RiGlobalLine className="mr-1.5 size-4" />
}
return null
})
DocumentSourceIcon.displayName = 'DocumentSourceIcon'
export default DocumentSourceIcon

View File

@@ -1,342 +0,0 @@
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 }) => (
<QueryClientProvider client={queryClient}>
<table>
<tbody>
{children}
</tbody>
</table>
</QueryClientProvider>
)
}
type LocalDoc = SimpleDocumentDetail & { percent?: number }
const createMockDoc = (overrides: Record<string, unknown> = {}): 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(<DocumentTableRow {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('test-document.txt')).toBeInTheDocument()
})
it('should render index number correctly', () => {
render(<DocumentTableRow {...defaultProps} index={5} />, { wrapper: createWrapper() })
expect(screen.getByText('6')).toBeInTheDocument()
})
it('should render document name with tooltip', () => {
render(<DocumentTableRow {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('test-document.txt')).toBeInTheDocument()
})
it('should render checkbox element', () => {
const { container } = render(<DocumentTableRow {...defaultProps} />, { wrapper: createWrapper() })
const checkbox = findCheckbox(container)
expect(checkbox).toBeInTheDocument()
})
})
describe('Selection', () => {
it('should show check icon when isSelected is true', () => {
const { container } = render(<DocumentTableRow {...defaultProps} isSelected />, { 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(<DocumentTableRow {...defaultProps} isSelected={false} />, { 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(<DocumentTableRow {...defaultProps} onSelectOne={onSelectOne} />, { 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(<DocumentTableRow {...defaultProps} />, { 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(<DocumentTableRow {...defaultProps} />, { 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(
<DocumentTableRow
{...defaultProps}
datasetId="custom-dataset"
doc={createMockDoc({ id: 'custom-doc' })}
/>,
{ 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(<DocumentTableRow {...defaultProps} doc={doc} />, { 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(<DocumentTableRow {...defaultProps} doc={doc} />, { 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(<DocumentTableRow {...defaultProps} doc={doc} />, { 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(<DocumentTableRow {...defaultProps} doc={doc} />, { 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(<DocumentTableRow {...defaultProps} doc={doc} />, { 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(<DocumentTableRow {...defaultProps} doc={doc} />, { 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(<DocumentTableRow {...defaultProps} doc={doc} />, { 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(<DocumentTableRow {...defaultProps} isGeneralMode isQAMode={false} />, { wrapper: createWrapper() })
// ChunkingModeLabel should be rendered
expect(screen.getByRole('row')).toBeInTheDocument()
})
it('should render ChunkingModeLabel with QA mode', () => {
render(<DocumentTableRow {...defaultProps} isGeneralMode={false} isQAMode />, { 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(<DocumentTableRow {...defaultProps} doc={doc} />, { 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(<DocumentTableRow {...defaultProps} doc={doc} />, { 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(
<DocumentTableRow {...defaultProps} onShowRenameModal={onShowRenameModal} />,
{ 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(<DocumentTableRow {...defaultProps} selectedIds={['doc-1', 'doc-2']} />, { wrapper: createWrapper() })
expect(screen.getByRole('row')).toBeInTheDocument()
})
it('should pass onSelectedIdChange to Operations component', () => {
const onSelectedIdChange = vi.fn()
render(<DocumentTableRow {...defaultProps} onSelectedIdChange={onSelectedIdChange} />, { 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(<DocumentTableRow {...defaultProps} doc={doc} />, { 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(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() })
expect(screen.getByRole('row')).toBeInTheDocument()
})
it('should render with WEB data source type', () => {
const doc = createMockDoc({ data_source_type: DataSourceType.WEB })
render(<DocumentTableRow {...defaultProps} doc={doc} />, { 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(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() })
expect(screen.getByRole('row')).toBeInTheDocument()
})
it('should handle document with special characters in name', () => {
const doc = createMockDoc({ name: '<script>test</script>.txt' })
render(<DocumentTableRow {...defaultProps} doc={doc} />, { wrapper: createWrapper() })
expect(screen.getByText('<script>test</script>.txt')).toBeInTheDocument()
})
it('should memoize the component', () => {
const wrapper = createWrapper()
const { rerender } = render(<DocumentTableRow {...defaultProps} />, { wrapper })
rerender(<DocumentTableRow {...defaultProps} />)
expect(screen.getByRole('row')).toBeInTheDocument()
})
})
})

View File

@@ -1,152 +0,0 @@
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<DocumentTableRowProps> = 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 (
<tr
className="h-8 cursor-pointer border-b border-divider-subtle hover:bg-background-default-hover"
onClick={handleRowClick}
>
<td className="text-left align-middle text-xs text-text-tertiary">
<div className="flex items-center" onClick={handleCheckboxClick}>
<Checkbox
className="mr-2 shrink-0"
checked={isSelected}
onCheck={() => onSelectOne(doc.id)}
/>
{index + 1}
</div>
</td>
<td>
<div className="group mr-6 flex max-w-[460px] items-center hover:mr-0">
<div className="flex shrink-0 items-center">
<DocumentSourceIcon doc={doc} fileType={fileType} />
</div>
<Tooltip popupContent={doc.name}>
<span className="grow-1 truncate text-sm">{doc.name}</span>
</Tooltip>
{doc.summary_index_status && (
<div className="ml-1 hidden shrink-0 group-hover:flex">
<SummaryStatus status={doc.summary_index_status} />
</div>
)}
<div className="hidden shrink-0 group-hover:ml-auto group-hover:flex">
<Tooltip popupContent={t('list.table.rename', { ns: 'datasetDocuments' })}>
<div
className="cursor-pointer rounded-md p-1 hover:bg-state-base-hover"
onClick={handleRenameClick}
>
<RiEditLine className="h-4 w-4 text-text-tertiary" />
</div>
</Tooltip>
</div>
</div>
</td>
<td>
<ChunkingModeLabel
isGeneralMode={isGeneralMode}
isQAMode={isQAMode}
/>
</td>
<td>{renderCount(doc.word_count)}</td>
<td>{renderCount(doc.hit_count)}</td>
<td className="text-[13px] text-text-secondary">
{formatTime(doc.created_at, t('dateTimeFormat', { ns: 'datasetHitTesting' }) as string)}
</td>
<td>
<StatusItem status={doc.display_status} />
</td>
<td>
<Operations
selectedIds={selectedIds}
onSelectedIdChange={onSelectedIdChange}
embeddingAvailable={embeddingAvailable}
datasetId={datasetId}
detail={pick(doc, ['name', 'enabled', 'archived', 'id', 'data_source_type', 'doc_form', 'display_status'])}
onUpdate={onUpdate}
/>
</td>
</tr>
)
})
DocumentTableRow.displayName = 'DocumentTableRow'
export default DocumentTableRow

View File

@@ -1,4 +0,0 @@
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'

View File

@@ -1,124 +0,0 @@
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(<SortHeader {...defaultProps} />)
expect(screen.getByText('File Name')).toBeInTheDocument()
})
it('should render the sort icon', () => {
const { container } = render(<SortHeader {...defaultProps} />)
const icon = container.querySelector('svg')
expect(icon).toBeInTheDocument()
})
})
describe('inactive state', () => {
it('should have disabled text color when not active', () => {
const { container } = render(<SortHeader {...defaultProps} />)
const icon = container.querySelector('svg')
expect(icon).toHaveClass('text-text-disabled')
})
it('should not be rotated when not active', () => {
const { container } = render(<SortHeader {...defaultProps} />)
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(
<SortHeader {...defaultProps} currentSortField="name" />,
)
const icon = container.querySelector('svg')
expect(icon).toHaveClass('text-text-tertiary')
})
it('should not be rotated when active and desc', () => {
const { container } = render(
<SortHeader {...defaultProps} currentSortField="name" sortOrder="desc" />,
)
const icon = container.querySelector('svg')
expect(icon).not.toHaveClass('rotate-180')
})
it('should be rotated when active and asc', () => {
const { container } = render(
<SortHeader {...defaultProps} currentSortField="name" sortOrder="asc" />,
)
const icon = container.querySelector('svg')
expect(icon).toHaveClass('rotate-180')
})
})
describe('interaction', () => {
it('should call onSort when clicked', () => {
const onSort = vi.fn()
render(<SortHeader {...defaultProps} onSort={onSort} />)
fireEvent.click(screen.getByText('File Name'))
expect(onSort).toHaveBeenCalledWith('name')
})
it('should call onSort with correct field', () => {
const onSort = vi.fn()
render(<SortHeader {...defaultProps} field="word_count" onSort={onSort} />)
fireEvent.click(screen.getByText('File Name'))
expect(onSort).toHaveBeenCalledWith('word_count')
})
})
describe('different fields', () => {
it('should work with word_count field', () => {
render(
<SortHeader
{...defaultProps}
field="word_count"
label="Words"
currentSortField="word_count"
/>,
)
expect(screen.getByText('Words')).toBeInTheDocument()
})
it('should work with hit_count field', () => {
render(
<SortHeader
{...defaultProps}
field="hit_count"
label="Hit Count"
currentSortField="hit_count"
/>,
)
expect(screen.getByText('Hit Count')).toBeInTheDocument()
})
it('should work with created_at field', () => {
render(
<SortHeader
{...defaultProps}
field="created_at"
label="Upload Time"
currentSortField="created_at"
/>,
)
expect(screen.getByText('Upload Time')).toBeInTheDocument()
})
})
})

View File

@@ -1,44 +0,0 @@
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<SortField, null>
label: string
currentSortField: SortField
sortOrder: SortOrder
onSort: (field: SortField) => void
}
const SortHeader: FC<SortHeaderProps> = React.memo(({
field,
label,
currentSortField,
sortOrder,
onSort,
}) => {
const isActive = currentSortField === field
const isDesc = isActive && sortOrder === 'desc'
return (
<div
className="flex cursor-pointer items-center hover:text-text-secondary"
onClick={() => onSort(field)}
>
{label}
<RiArrowDownLine
className={cn(
'ml-0.5 h-3 w-3 transition-all',
isActive ? 'text-text-tertiary' : 'text-text-disabled',
isActive && !isDesc ? 'rotate-180' : '',
)}
/>
</div>
)
})
SortHeader.displayName = 'SortHeader'
export default SortHeader

View File

@@ -1,90 +0,0 @@
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('<script>alert("xss")</script>')}</>)
expect(screen.getByText('<script>alert("xss")</script>')).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()
})
})
})

View File

@@ -1,16 +0,0 @@
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 (
<div className={className}>
{value ?? '-'}
</div>
)
}

View File

@@ -1,4 +0,0 @@
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'

View File

@@ -1,438 +0,0 @@
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 }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
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<typeof useDocument.useDocumentArchive>)
mockUseDocumentSummary.mockReturnValue(createMockMutation() as unknown as ReturnType<typeof useDocument.useDocumentSummary>)
mockUseDocumentEnable.mockReturnValue(createMockMutation() as unknown as ReturnType<typeof useDocument.useDocumentEnable>)
mockUseDocumentDisable.mockReturnValue(createMockMutation() as unknown as ReturnType<typeof useDocument.useDocumentDisable>)
mockUseDocumentDelete.mockReturnValue(createMockMutation() as unknown as ReturnType<typeof useDocument.useDocumentDelete>)
mockUseDocumentBatchRetryIndex.mockReturnValue(createMockMutation() as unknown as ReturnType<typeof useDocument.useDocumentBatchRetryIndex>)
mockUseDocumentDownloadZip.mockReturnValue({
...createMockMutation(),
isPending: false,
} as unknown as ReturnType<typeof useDocument.useDocumentDownloadZip>)
})
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<typeof useDocument.useDocumentDownloadZip>)
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<typeof useDocument.useDocumentDownloadZip>)
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<typeof useDocument.useDocumentDownloadZip>)
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<typeof useDocument.useDocumentDownloadZip>)
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<typeof useDocument.useDocumentDownloadZip>)
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()
})
})
})
})

View File

@@ -1,126 +0,0 @@
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<CommonResponse>(
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<CommonResponse>(
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,
}
}

View File

@@ -1,317 +0,0 @@
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> = {}): 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([])
})
})
})

View File

@@ -1,66 +0,0 @@
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,
}
}

View File

@@ -1,340 +0,0 @@
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> = {}): 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([])
})
})
})

View File

@@ -1,102 +0,0 @@
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<SortField>(null)
const [sortOrder, setSortOrder] = useState<SortOrder>('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,
}
}

View File

@@ -1,487 +0,0 @@
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 }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
const createMockDoc = (overrides: Partial<SimpleDocumentDetail> = {}): 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(<DocumentList {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByRole('table')).toBeInTheDocument()
})
it('should render all documents', () => {
render(<DocumentList {...defaultProps} />, { 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(<DocumentList {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('#')).toBeInTheDocument()
})
it('should render pagination when total is provided', () => {
render(<DocumentList {...defaultProps} />, { 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(<DocumentList {...props} />, { wrapper: createWrapper() })
expect(screen.getByRole('table')).toBeInTheDocument()
})
it('should render empty table when no documents', () => {
const props = { ...defaultProps, documents: [] }
render(<DocumentList {...props} />, { wrapper: createWrapper() })
expect(screen.getByRole('table')).toBeInTheDocument()
})
})
describe('Selection', () => {
// Helper to find checkboxes (custom div components, not native checkboxes)
const findCheckboxes = (container: HTMLElement): NodeListOf<Element> => {
return container.querySelectorAll('[class*="shadow-xs"]')
}
it('should render header checkbox when embeddingAvailable', () => {
const { container } = render(<DocumentList {...defaultProps} />, { 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(<DocumentList {...props} />, { 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(<DocumentList {...props} />, { 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(<DocumentList {...props} />, { 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(<DocumentList {...props} />, { 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(<DocumentList {...props} />, { 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(<DocumentList {...defaultProps} />, { 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(<DocumentList {...defaultProps} />, { 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(<DocumentList {...props} />, { wrapper: createWrapper() })
// BatchAction component should be visible
expect(screen.getByRole('table')).toBeInTheDocument()
})
it('should not show batch action bar when no documents selected', () => {
render(<DocumentList {...defaultProps} />, { 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(<DocumentList {...props} />, { 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(<DocumentList {...props} />, { wrapper: createWrapper() })
expect(screen.getByRole('table')).toBeInTheDocument()
})
it('should render batch action bar with disable option', () => {
const props = {
...defaultProps,
selectedIds: ['doc-1'],
}
render(<DocumentList {...props} />, { wrapper: createWrapper() })
expect(screen.getByRole('table')).toBeInTheDocument()
})
it('should render batch action bar with delete option', () => {
const props = {
...defaultProps,
selectedIds: ['doc-1'],
}
render(<DocumentList {...props} />, { 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(<DocumentList {...props} />, { 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(<DocumentList {...props} />, { 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(<DocumentList {...props} />, { 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(<DocumentList {...defaultProps} />, { 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(<DocumentList {...defaultProps} />, { 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(<DocumentList {...defaultProps} />, { 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(<DocumentList {...props} />, { 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(<DocumentList {...props} />, { 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(<DocumentList {...props} />, { 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(<DocumentList {...defaultProps} />, { 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(<DocumentList {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByRole('table')).toBeInTheDocument()
})
it('should render with parent-child mode', () => {
render(<DocumentList {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByRole('table')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle empty documents array', () => {
const props = { ...defaultProps, documents: [] }
render(<DocumentList {...props} />, { 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(<DocumentList {...props} />, { wrapper: createWrapper() })
expect(screen.getByRole('table')).toBeInTheDocument()
})
it('should handle status filter value', () => {
const props = {
...defaultProps,
statusFilterValue: 'completed',
}
render(<DocumentList {...props} />, { wrapper: createWrapper() })
expect(screen.getByRole('table')).toBeInTheDocument()
})
it('should handle remote sort value', () => {
const props = {
...defaultProps,
remoteSortValue: 'created_at',
}
render(<DocumentList {...props} />, { 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(<DocumentList {...props} />, { wrapper: createWrapper() })
expect(screen.getByRole('table')).toBeInTheDocument()
}, 10000)
})
})

View File

@@ -1,3 +0,0 @@
// Re-export from parent for backwards compatibility
export { default } from '../list'
export { renderTdValue } from './components'

View File

@@ -1,26 +1,67 @@
'use client'
import type { FC } from 'react'
import type { Props as PaginationProps } from '@/app/components/base/pagination'
import type { SimpleDocumentDetail } from '@/models/datasets'
import type { CommonResponse } from '@/models/common'
import type { LegacyDataSourceInfo, LocalFileInfo, OnlineDocumentInfo, OnlineDriveInfo, SimpleDocumentDetail } from '@/models/datasets'
import {
RiArrowDownLine,
RiEditLine,
RiGlobalLine,
} from '@remixicon/react'
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, useState } from 'react'
import { useCallback, useEffect, useMemo, 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 { ChunkingMode, DocumentActionType } from '@/models/datasets'
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 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 { DocumentTableRow, renderTdValue, SortHeader } from './document-list/components'
import { useDocumentActions, useDocumentSelection, useDocumentSort } from './document-list/hooks'
import Operations from './operations'
import RenameModal from './rename-modal'
type LocalDoc = SimpleDocumentDetail & { percent?: number }
export const renderTdValue = (value: string | number | null, isEmptyStyle = false) => {
return (
<div className={cn(isEmptyStyle ? 'text-text-tertiary' : 'text-text-secondary', s.tdValue)}>
{value ?? '-'}
</div>
)
}
type DocumentListProps = {
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 = {
embeddingAvailable: boolean
documents: LocalDoc[]
selectedIds: string[]
@@ -36,7 +77,7 @@ type DocumentListProps = {
/**
* Document list component including basic information
*/
const DocumentList: FC<DocumentListProps> = ({
const DocumentList: FC<IDocumentListProps> = ({
embeddingAvailable,
documents = [],
selectedIds,
@@ -49,43 +90,20 @@ const DocumentList: FC<DocumentListProps> = ({
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')
// Sorting
const { sortField, sortOrder, handleSort, sortedDocuments } = useDocumentSort({
documents,
statusFilterValue,
remoteSortValue,
})
useEffect(() => {
setSortField(null)
setSortOrder('desc')
}, [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,
@@ -95,26 +113,233 @@ const DocumentList: FC<DocumentListProps> = ({
} = useBatchEditDocumentMetadata({
datasetId,
docList: documents.filter(doc => selectedIds.includes(doc.id)),
selectedDocumentIds: selectedIds,
selectedDocumentIds: selectedIds, // Pass all selected IDs separately
onUpdate,
})
// Rename modal
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 (
<div className="flex cursor-pointer items-center hover:text-text-secondary" onClick={() => handleSort(field)}>
{label}
<RiArrowDownLine
className={cn('ml-0.5 h-3 w-3 transition-all', isActive ? 'text-text-tertiary' : 'text-text-disabled', isActive && !isDesc ? 'rotate-180' : '')}
/>
</div>
)
}
const [currDocument, setCurrDocument] = useState<LocalDoc | null>(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<CommonResponse>(opApi({ datasetId, documentIds: selectedIds }) as Promise<CommonResponse>)
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<CommonResponse>(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 (
<div className="relative mt-3 flex h-full w-full flex-col">
<div className="relative h-0 grow overflow-x-auto">
@@ -128,76 +353,157 @@ const DocumentList: FC<DocumentListProps> = ({
className="mr-2 shrink-0"
checked={isAllSelected}
indeterminate={!isAllSelected && isSomeSelected}
onCheck={onSelectAll}
onCheck={onSelectedAll}
/>
)}
#
</div>
</td>
<td>
<SortHeader
field="name"
label={t('list.table.header.fileName', { ns: 'datasetDocuments' })}
currentSortField={sortField}
sortOrder={sortOrder}
onSort={handleSort}
/>
{renderSortHeader('name', t('list.table.header.fileName', { ns: 'datasetDocuments' }))}
</td>
<td className="w-[130px]">{t('list.table.header.chunkingMode', { ns: 'datasetDocuments' })}</td>
<td className="w-24">
<SortHeader
field="word_count"
label={t('list.table.header.words', { ns: 'datasetDocuments' })}
currentSortField={sortField}
sortOrder={sortOrder}
onSort={handleSort}
/>
{renderSortHeader('word_count', t('list.table.header.words', { ns: 'datasetDocuments' }))}
</td>
<td className="w-44">
<SortHeader
field="hit_count"
label={t('list.table.header.hitCount', { ns: 'datasetDocuments' })}
currentSortField={sortField}
sortOrder={sortOrder}
onSort={handleSort}
/>
{renderSortHeader('hit_count', t('list.table.header.hitCount', { ns: 'datasetDocuments' }))}
</td>
<td className="w-44">
<SortHeader
field="created_at"
label={t('list.table.header.uploadTime', { ns: 'datasetDocuments' })}
currentSortField={sortField}
sortOrder={sortOrder}
onSort={handleSort}
/>
{renderSortHeader('created_at', t('list.table.header.uploadTime', { ns: 'datasetDocuments' }))}
</td>
<td className="w-40">{t('list.table.header.status', { ns: 'datasetDocuments' })}</td>
<td className="w-20">{t('list.table.header.action', { ns: 'datasetDocuments' })}</td>
</tr>
</thead>
<tbody className="text-text-secondary">
{sortedDocuments.map((doc, index) => (
<DocumentTableRow
key={doc.id}
doc={doc}
index={index}
datasetId={datasetId}
isSelected={selectedIds.includes(doc.id)}
isGeneralMode={isGeneralMode}
isQAMode={isQAMode}
embeddingAvailable={embeddingAvailable}
selectedIds={selectedIds}
onSelectOne={onSelectOne}
onSelectedIdChange={onSelectedIdChange}
onShowRenameModal={handleShowRenameModal}
onUpdate={onUpdate}
/>
))}
{localDocs.map((doc, index) => {
const isFile = isLocalFile(doc.data_source_type)
const fileType = isFile ? doc.data_source_detail_dict?.upload_file?.extension : ''
return (
<tr
key={doc.id}
className="h-8 cursor-pointer border-b border-divider-subtle hover:bg-background-default-hover"
onClick={() => {
router.push(`/datasets/${datasetId}/documents/${doc.id}`)
}}
>
<td className="text-left align-middle text-xs text-text-tertiary">
<div className="flex items-center" onClick={e => e.stopPropagation()}>
<Checkbox
className="mr-2 shrink-0"
checked={selectedIds.includes(doc.id)}
onCheck={() => {
onSelectedIdChange(
selectedIds.includes(doc.id)
? selectedIds.filter(id => id !== doc.id)
: [...selectedIds, doc.id],
)
}}
/>
{index + 1}
</div>
</td>
<td>
<div className="group mr-6 flex max-w-[460px] items-center hover:mr-0">
<div className="flex shrink-0 items-center">
{isOnlineDocument(doc.data_source_type) && (
<NotionIcon
className="mr-1.5"
type="page"
src={
isCreateFromRAGPipeline(doc.created_from)
? (doc.data_source_info as OnlineDocumentInfo).page.page_icon
: (doc.data_source_info as LegacyDataSourceInfo).notion_page_icon
}
/>
)}
{isLocalFile(doc.data_source_type) && (
<FileTypeIcon
type={
extensionToFileType(
isCreateFromRAGPipeline(doc.created_from)
? (doc?.data_source_info as LocalFileInfo)?.extension
: ((doc?.data_source_info as LegacyDataSourceInfo)?.upload_file?.extension ?? fileType),
)
}
className="mr-1.5"
/>
)}
{isOnlineDrive(doc.data_source_type) && (
<FileTypeIcon
type={
extensionToFileType(
getFileExtension((doc?.data_source_info as unknown as OnlineDriveInfo)?.name),
)
}
className="mr-1.5"
/>
)}
{isWebsiteCrawl(doc.data_source_type) && (
<RiGlobalLine className="mr-1.5 size-4" />
)}
</div>
<Tooltip
popupContent={doc.name}
>
<span className="grow-1 truncate text-sm">{doc.name}</span>
</Tooltip>
{
doc.summary_index_status && (
<div className="ml-1 hidden shrink-0 group-hover:flex">
<SummaryStatus status={doc.summary_index_status} />
</div>
)
}
<div className="hidden shrink-0 group-hover:ml-auto group-hover:flex">
<Tooltip
popupContent={t('list.table.rename', { ns: 'datasetDocuments' })}
>
<div
className="cursor-pointer rounded-md p-1 hover:bg-state-base-hover"
onClick={(e) => {
e.stopPropagation()
handleShowRenameModal(doc)
}}
>
<RiEditLine className="h-4 w-4 text-text-tertiary" />
</div>
</Tooltip>
</div>
</div>
</td>
<td>
<ChunkingModeLabel
isGeneralMode={isGeneralMode}
isQAMode={isQAMode}
/>
</td>
<td>{renderCount(doc.word_count)}</td>
<td>{renderCount(doc.hit_count)}</td>
<td className="text-[13px] text-text-secondary">
{formatTime(doc.created_at, t('dateTimeFormat', { ns: 'datasetHitTesting' }) as string)}
</td>
<td>
<StatusItem status={doc.display_status} />
</td>
<td>
<Operations
selectedIds={selectedIds}
onSelectedIdChange={onSelectedIdChange}
embeddingAvailable={embeddingAvailable}
datasetId={datasetId}
detail={pick(doc, ['name', 'enabled', 'archived', 'id', 'data_source_type', 'doc_form', 'display_status'])}
onUpdate={onUpdate}
/>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
{selectedIds.length > 0 && (
{(selectedIds.length > 0) && (
<BatchAction
className="absolute bottom-16 left-0 z-20"
selectedIds={selectedIds}
@@ -209,10 +515,12 @@ const DocumentList: FC<DocumentListProps> = ({
onBatchDelete={handleAction(DocumentActionType.delete)}
onEditMetadata={showEditModal}
onBatchReIndex={hasErrorDocumentsSelected ? handleBatchReIndex : undefined}
onCancel={clearSelection}
onCancel={() => {
onSelectedIdChange([])
}}
/>
)}
{/* Show Pagination only if the total is more than the limit */}
{!!pagination.total && (
<Pagination
{...pagination}
@@ -248,5 +556,3 @@ const DocumentList: FC<DocumentListProps> = ({
}
export default DocumentList
export { renderTdValue }

View File

@@ -1,4 +0,0 @@
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'

View File

@@ -1,159 +0,0 @@
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(<ProgressBar {...defaultProps} />)
const { wrapper, progressBar } = getProgressElements(container)
expect(wrapper).toBeInTheDocument()
expect(progressBar).toBeInTheDocument()
})
it('should render progress bar container with correct classes', () => {
const { container } = render(<ProgressBar {...defaultProps} />)
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(<ProgressBar {...defaultProps} />)
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(<ProgressBar {...defaultProps} percent={0} />)
const { progressBar } = getProgressElements(container)
expect(progressBar).toHaveStyle({ width: '0%' })
})
it('should set progress width to 50%', () => {
const { container } = render(<ProgressBar {...defaultProps} percent={50} />)
const { progressBar } = getProgressElements(container)
expect(progressBar).toHaveStyle({ width: '50%' })
})
it('should set progress width to 100%', () => {
const { container } = render(<ProgressBar {...defaultProps} percent={100} />)
const { progressBar } = getProgressElements(container)
expect(progressBar).toHaveStyle({ width: '100%' })
})
it('should set progress width to 75%', () => {
const { container } = render(<ProgressBar {...defaultProps} percent={75} />)
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(<ProgressBar {...defaultProps} isEmbedding />)
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(<ProgressBar {...defaultProps} isEmbedding={false} />)
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(<ProgressBar {...defaultProps} isEmbedding />)
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(<ProgressBar {...defaultProps} isCompleted />)
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(<ProgressBar {...defaultProps} isPaused />)
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(<ProgressBar {...defaultProps} isError />)
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(<ProgressBar {...defaultProps} />)
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(<ProgressBar {...defaultProps} isEmbedding isPaused />)
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(<ProgressBar {...defaultProps} isCompleted isError />)
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(<ProgressBar {...defaultProps} isEmbedding isPaused />)
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(
<ProgressBar
percent={0}
isEmbedding={false}
isCompleted={false}
isPaused={false}
isError={false}
/>,
)
const { wrapper, progressBar } = getProgressElements(container)
expect(wrapper).toBeInTheDocument()
expect(progressBar).toHaveStyle({ width: '0%' })
})
it('should handle decimal percent values', () => {
const { container } = render(<ProgressBar {...defaultProps} percent={33.33} />)
const { progressBar } = getProgressElements(container)
expect(progressBar).toHaveStyle({ width: '33.33%' })
})
})
})

View File

@@ -1,44 +0,0 @@
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<ProgressBarProps> = React.memo(({
percent,
isEmbedding,
isCompleted,
isPaused,
isError,
}) => {
const isActive = isEmbedding || isCompleted
const isHighlighted = isPaused || isError
return (
<div
className={cn(
'flex h-2 w-full items-center overflow-hidden rounded-md border border-components-progress-bar-border',
isEmbedding ? 'bg-components-progress-bar-bg/50' : 'bg-components-progress-bar-bg',
)}
>
<div
className={cn(
'h-full transition-all duration-300',
isActive && 'bg-components-progress-bar-progress-solid',
isHighlighted && 'bg-components-progress-bar-progress-highlight',
)}
style={{ width: `${percent}%` }}
/>
</div>
)
})
ProgressBar.displayName = 'ProgressBar'
export default ProgressBar

View File

@@ -1,203 +0,0 @@
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> = {}): 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(<RuleDetail {...defaultProps} />)
expect(screen.getByText(/stepTwo\.indexMode/i)).toBeInTheDocument()
})
it('should render with sourceData', () => {
const sourceData = createSourceData()
render(<RuleDetail {...defaultProps} sourceData={sourceData} />)
expect(screen.getByText(/embedding\.mode/i)).toBeInTheDocument()
})
it('should render all segmentation rule fields', () => {
const sourceData = createSourceData()
render(<RuleDetail {...defaultProps} sourceData={sourceData} />)
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(<RuleDetail {...defaultProps} sourceData={sourceData} />)
expect(screen.getByText(/embedding\.custom/i)).toBeInTheDocument()
})
it('should display mode label field', () => {
const sourceData = createSourceData()
render(<RuleDetail {...defaultProps} sourceData={sourceData} />)
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(<RuleDetail {...defaultProps} sourceData={sourceData} />)
expect(screen.getByText('500')).toBeInTheDocument()
})
it('should display segment length label', () => {
const sourceData = createSourceData()
render(<RuleDetail {...defaultProps} sourceData={sourceData} />)
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(<RuleDetail {...defaultProps} sourceData={sourceData} />)
expect(screen.getByText(/removeExtraSpaces/i)).toBeInTheDocument()
expect(screen.getByText(/removeUrlEmails/i)).toBeInTheDocument()
})
it('should display text cleaning label', () => {
const sourceData = createSourceData()
render(<RuleDetail {...defaultProps} sourceData={sourceData} />)
expect(screen.getByText(/embedding\.textCleaning/i)).toBeInTheDocument()
})
})
describe('Index Mode Display', () => {
it('should display economical mode when indexingType is ECONOMICAL', () => {
render(<RuleDetail {...defaultProps} indexingType={IndexingType.ECONOMICAL} />)
expect(screen.getByText(/stepTwo\.economical/i)).toBeInTheDocument()
})
it('should display qualified mode when indexingType is QUALIFIED', () => {
render(<RuleDetail {...defaultProps} indexingType={IndexingType.QUALIFIED} />)
expect(screen.getByText(/stepTwo\.qualified/i)).toBeInTheDocument()
})
})
describe('Retrieval Method Display', () => {
it('should display keyword search for economical mode', () => {
render(<RuleDetail {...defaultProps} indexingType={IndexingType.ECONOMICAL} />)
expect(screen.getByText(/retrieval\.keyword_search\.title/i)).toBeInTheDocument()
})
it('should display semantic search as default for qualified mode', () => {
render(<RuleDetail {...defaultProps} indexingType={IndexingType.QUALIFIED} />)
expect(screen.getByText(/retrieval\.semantic_search\.title/i)).toBeInTheDocument()
})
it('should display full text search when retrievalMethod is fullText', () => {
render(<RuleDetail {...defaultProps} retrievalMethod={RETRIEVE_METHOD.fullText} />)
expect(screen.getByText(/retrieval\.full_text_search\.title/i)).toBeInTheDocument()
})
it('should display hybrid search when retrievalMethod is hybrid', () => {
render(<RuleDetail {...defaultProps} retrievalMethod={RETRIEVE_METHOD.hybrid} />)
expect(screen.getByText(/retrieval\.hybrid_search\.title/i)).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should display dash for missing sourceData', () => {
render(<RuleDetail {...defaultProps} />)
const dashes = screen.getAllByText('-')
expect(dashes.length).toBeGreaterThan(0)
})
it('should display dash when mode is undefined', () => {
const sourceData = { rules: {} } as ProcessRuleResponse
render(<RuleDetail {...defaultProps} sourceData={sourceData} />)
const dashes = screen.getAllByText('-')
expect(dashes.length).toBeGreaterThan(0)
})
it('should handle undefined retrievalMethod', () => {
render(<RuleDetail indexingType={IndexingType.QUALIFIED} />)
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(<RuleDetail {...defaultProps} sourceData={sourceData} />)
expect(screen.getByText(/embedding\.textCleaning/i)).toBeInTheDocument()
})
it('should render container with correct structure', () => {
const { container } = render(<RuleDetail {...defaultProps} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('py-3')
})
it('should handle undefined indexingType', () => {
render(<RuleDetail retrievalMethod={RETRIEVE_METHOD.semantic} />)
expect(screen.getByText(/stepTwo\.indexMode/i)).toBeInTheDocument()
})
it('should render divider between sections', () => {
const { container } = render(<RuleDetail {...defaultProps} />)
const dividers = container.querySelectorAll('.bg-divider-subtle')
expect(dividers.length).toBeGreaterThan(0)
})
})
})

View File

@@ -1,128 +0,0 @@
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<RuleDetailProps> = 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<string, string> = {
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<string, string | number> = {
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 (
<div className="py-3">
<div className="flex flex-col gap-y-1">
{Object.keys(segmentationRuleMap).map(field => (
<FieldInfo
key={field}
label={segmentationRuleMap[field as keyof typeof segmentationRuleMap]}
displayedValue={String(getValue(field))}
/>
))}
</div>
<Divider type="horizontal" className="bg-divider-subtle" />
<FieldInfo
label={t('stepTwo.indexMode', { ns: 'datasetCreation' })}
displayedValue={t(`stepTwo.${isEconomical ? 'economical' : 'qualified'}`, { ns: 'datasetCreation' }) as string}
valueIcon={(
<Image
className="size-4"
src={isEconomical ? indexMethodIcon.economical : indexMethodIcon.high_quality}
alt=""
/>
)}
/>
<FieldInfo
label={t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
displayedValue={t(`retrieval.${isEconomical ? 'keyword_search' : retrievalMethod ?? 'semantic_search'}.title`, { ns: 'dataset' })}
valueIcon={(
<Image
className="size-4"
src={getRetrievalIcon(retrievalMethod)}
alt=""
/>
)}
/>
</div>
)
})
RuleDetail.displayName = 'RuleDetail'
export default RuleDetail

View File

@@ -1,81 +0,0 @@
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(<SegmentProgress {...defaultProps} />)
expect(screen.getByText(/segments/i)).toBeInTheDocument()
})
it('should render with correct CSS classes', () => {
const { container } = render(<SegmentProgress {...defaultProps} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('flex', 'w-full', 'items-center')
})
it('should render text with correct styling class', () => {
render(<SegmentProgress {...defaultProps} />)
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(<SegmentProgress completedSegments={50} totalSegments={100} percent={50} />)
expect(screen.getByText(/50\/100/)).toBeInTheDocument()
})
it('should display percent value', () => {
render(<SegmentProgress completedSegments={50} totalSegments={100} percent={50} />)
expect(screen.getByText(/50%/)).toBeInTheDocument()
})
it('should display 0/0 when segments are 0', () => {
render(<SegmentProgress completedSegments={0} totalSegments={0} percent={0} />)
expect(screen.getByText(/0\/0/)).toBeInTheDocument()
expect(screen.getByText(/0%/)).toBeInTheDocument()
})
it('should display 100% when completed', () => {
render(<SegmentProgress completedSegments={100} totalSegments={100} percent={100} />)
expect(screen.getByText(/100\/100/)).toBeInTheDocument()
expect(screen.getByText(/100%/)).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should display -- when completedSegments is undefined', () => {
render(<SegmentProgress totalSegments={100} percent={0} />)
expect(screen.getByText(/--\/100/)).toBeInTheDocument()
})
it('should display -- when totalSegments is undefined', () => {
render(<SegmentProgress completedSegments={50} percent={50} />)
expect(screen.getByText(/50\/--/)).toBeInTheDocument()
})
it('should display --/-- when both segments are undefined', () => {
render(<SegmentProgress percent={0} />)
expect(screen.getByText(/--\/--/)).toBeInTheDocument()
})
it('should handle large numbers', () => {
render(<SegmentProgress completedSegments={999999} totalSegments={1000000} percent={99} />)
expect(screen.getByText(/999999\/1000000/)).toBeInTheDocument()
})
it('should handle decimal percent', () => {
render(<SegmentProgress completedSegments={33} totalSegments={100} percent={33.33} />)
expect(screen.getByText(/33.33%/)).toBeInTheDocument()
})
})
})

View File

@@ -1,32 +0,0 @@
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<SegmentProgressProps> = React.memo(({
completedSegments,
totalSegments,
percent,
}) => {
const { t } = useTranslation()
const completed = completedSegments ?? '--'
const total = totalSegments ?? '--'
return (
<div className="flex w-full items-center">
<span className="system-xs-medium text-text-secondary">
{`${t('embedding.segments', { ns: 'datasetDocuments' })} ${completed}/${total} · ${percent}%`}
</span>
</div>
)
})
SegmentProgress.displayName = 'SegmentProgress'
export default SegmentProgress

View File

@@ -1,155 +0,0 @@
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(<StatusHeader {...defaultProps} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render with correct container classes', () => {
const { container } = render(<StatusHeader {...defaultProps} />)
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(<StatusHeader {...defaultProps} isEmbedding />)
expect(screen.getByText(/embedding\.processing/i)).toBeInTheDocument()
})
it('should display completed text when isCompleted is true', () => {
render(<StatusHeader {...defaultProps} isCompleted />)
expect(screen.getByText(/embedding\.completed/i)).toBeInTheDocument()
})
it('should display paused text when isPaused is true', () => {
render(<StatusHeader {...defaultProps} isPaused />)
expect(screen.getByText(/embedding\.paused/i)).toBeInTheDocument()
})
it('should display error text when isError is true', () => {
render(<StatusHeader {...defaultProps} isError />)
expect(screen.getByText(/embedding\.error/i)).toBeInTheDocument()
})
it('should display empty text when no status flags are set', () => {
render(<StatusHeader {...defaultProps} />)
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(<StatusHeader {...defaultProps} isEmbedding />)
const spinner = container.querySelector('svg.animate-spin')
expect(spinner).toBeInTheDocument()
})
it('should not show loading spinner when isEmbedding is false', () => {
const { container } = render(<StatusHeader {...defaultProps} isEmbedding={false} />)
const spinner = container.querySelector('svg.animate-spin')
expect(spinner).not.toBeInTheDocument()
})
})
describe('Pause Button', () => {
it('should show pause button when isEmbedding is true', () => {
render(<StatusHeader {...defaultProps} isEmbedding />)
expect(screen.getByRole('button')).toBeInTheDocument()
expect(screen.getByText(/embedding\.pause/i)).toBeInTheDocument()
})
it('should not show pause button when isEmbedding is false', () => {
render(<StatusHeader {...defaultProps} isEmbedding={false} />)
expect(screen.queryByText(/embedding\.pause/i)).not.toBeInTheDocument()
})
it('should call onPause when pause button is clicked', () => {
const onPause = vi.fn()
render(<StatusHeader {...defaultProps} isEmbedding onPause={onPause} />)
fireEvent.click(screen.getByRole('button'))
expect(onPause).toHaveBeenCalledTimes(1)
})
it('should disable pause button when isPauseLoading is true', () => {
render(<StatusHeader {...defaultProps} isEmbedding isPauseLoading />)
expect(screen.getByRole('button')).toBeDisabled()
})
})
describe('Resume Button', () => {
it('should show resume button when isPaused is true', () => {
render(<StatusHeader {...defaultProps} isPaused />)
expect(screen.getByRole('button')).toBeInTheDocument()
expect(screen.getByText(/embedding\.resume/i)).toBeInTheDocument()
})
it('should not show resume button when isPaused is false', () => {
render(<StatusHeader {...defaultProps} isPaused={false} />)
expect(screen.queryByText(/embedding\.resume/i)).not.toBeInTheDocument()
})
it('should call onResume when resume button is clicked', () => {
const onResume = vi.fn()
render(<StatusHeader {...defaultProps} isPaused onResume={onResume} />)
fireEvent.click(screen.getByRole('button'))
expect(onResume).toHaveBeenCalledTimes(1)
})
it('should disable resume button when isResumeLoading is true', () => {
render(<StatusHeader {...defaultProps} isPaused isResumeLoading />)
expect(screen.getByRole('button')).toBeDisabled()
})
})
describe('Button Styles', () => {
it('should have correct button styles for pause button', () => {
render(<StatusHeader {...defaultProps} isEmbedding />)
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(<StatusHeader {...defaultProps} isPaused />)
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(<StatusHeader {...defaultProps} isCompleted />)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('should not show any buttons when isError', () => {
render(<StatusHeader {...defaultProps} isError />)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('should show both buttons when isEmbedding and isPaused are both true', () => {
render(<StatusHeader {...defaultProps} isEmbedding isPaused />)
const buttons = screen.getAllByRole('button')
expect(buttons.length).toBe(2)
})
})
})

View File

@@ -1,84 +0,0 @@
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<StatusHeaderProps> = 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 (
<div className="flex h-6 items-center gap-x-1">
{isEmbedding && <RiLoader2Line className="h-4 w-4 animate-spin text-text-secondary" />}
<span className="system-md-semibold-uppercase grow text-text-secondary">
{getStatusText()}
</span>
{isEmbedding && (
<button
type="button"
className={buttonBaseClass}
onClick={onPause}
disabled={isPauseLoading}
>
<RiPauseCircleLine className="h-3.5 w-3.5 text-components-button-secondary-text" />
<span className="system-xs-medium pr-[3px] text-components-button-secondary-text">
{t('embedding.pause', { ns: 'datasetDocuments' })}
</span>
</button>
)}
{isPaused && (
<button
type="button"
className={buttonBaseClass}
onClick={onResume}
disabled={isResumeLoading}
>
<RiPlayCircleLine className="h-3.5 w-3.5 text-components-button-secondary-text" />
<span className="system-xs-medium pr-[3px] text-components-button-secondary-text">
{t('embedding.resume', { ns: 'datasetDocuments' })}
</span>
</button>
)}
</div>
)
})
StatusHeader.displayName = 'StatusHeader'
export default StatusHeader

View File

@@ -1,10 +0,0 @@
export {
calculatePercent,
isEmbeddingStatus,
isTerminalStatus,
useEmbeddingStatus,
useInvalidateEmbeddingStatus,
usePauseIndexing,
useResumeIndexing,
} from './use-embedding-status'
export type { EmbeddingStatusType } from './use-embedding-status'

View File

@@ -1,462 +0,0 @@
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 }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
const mockIndexingStatus = (overrides: Partial<IndexingStatusResponse> = {}): 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 }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
// 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 }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
// 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)
})
})
})

View File

@@ -1,149 +0,0 @@
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<IndexingStatusResponse>({
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<CommonResponse, Error>({
mutationKey: [NAME_SPACE, 'pause', datasetId, documentId],
mutationFn: () => pauseDocIndexing({ datasetId: datasetId!, documentId: documentId! }),
onSuccess,
onError,
})
}
export const useResumeIndexing = ({ datasetId, documentId, onSuccess, onError }: UsePauseResumeOptions) => {
return useMutation<CommonResponse, Error>({
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])
}

View File

@@ -1,337 +0,0 @@
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 }) => (
<QueryClientProvider client={queryClient}>
<DocumentContext.Provider value={contextValue}>
{children}
</DocumentContext.Provider>
</QueryClientProvider>
)
}
const mockIndexingStatus = (overrides: Partial<IndexingStatusResponse> = {}): 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> = {}): 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<typeof useDataset.useProcessRule>)
})
describe('Rendering', () => {
it('should render without crashing', async () => {
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus())
render(<EmbeddingDetail {...defaultProps} />, { 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(
<EmbeddingDetail {...defaultProps} datasetId="custom-ds" documentId="custom-doc" />,
{ 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(<EmbeddingDetail {...defaultProps} />, { 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(<EmbeddingDetail {...defaultProps} />, { wrapper: createWrapper() })
await waitFor(() => {
expect(screen.getByText(/embedding\.processing/i)).toBeInTheDocument()
})
})
it('should show completed status', async () => {
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus({ indexing_status: 'completed' }))
render(<EmbeddingDetail {...defaultProps} />, { wrapper: createWrapper() })
await waitFor(() => {
expect(screen.getByText(/embedding\.completed/i)).toBeInTheDocument()
})
})
it('should show paused status', async () => {
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus({ indexing_status: 'paused' }))
render(<EmbeddingDetail {...defaultProps} />, { wrapper: createWrapper() })
await waitFor(() => {
expect(screen.getByText(/embedding\.paused/i)).toBeInTheDocument()
})
})
it('should show error status', async () => {
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus({ indexing_status: 'error' }))
render(<EmbeddingDetail {...defaultProps} />, { 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(<EmbeddingDetail {...defaultProps} />, { 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(<EmbeddingDetail {...defaultProps} />, { 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(<EmbeddingDetail {...defaultProps} />, { 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(<EmbeddingDetail {...defaultProps} />, { 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(<EmbeddingDetail {...defaultProps} />, { 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(<EmbeddingDetail {...defaultProps} />, { wrapper: createWrapper() })
await waitFor(() => {
expect(screen.getByText(/stepTwo\.indexMode/i)).toBeInTheDocument()
})
})
it('should display qualified index mode', async () => {
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus())
render(
<EmbeddingDetail {...defaultProps} indexingType={IndexingType.QUALIFIED} />,
{ wrapper: createWrapper() },
)
await waitFor(() => {
expect(screen.getByText(/stepTwo\.qualified/i)).toBeInTheDocument()
})
})
it('should display economical index mode', async () => {
mockFetchIndexingStatus.mockResolvedValue(mockIndexingStatus())
render(
<EmbeddingDetail {...defaultProps} indexingType={IndexingType.ECONOMICAL} />,
{ 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(
<EmbeddingDetail {...defaultProps} detailUpdate={detailUpdate} />,
{ 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(
<EmbeddingDetail {...defaultProps} datasetId="explicit-ds" documentId="explicit-doc" />,
{ 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(<EmbeddingDetail {...defaultProps} />, { 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()
})
})
})
})

View File

@@ -1,18 +1,31 @@
import type { FC } from 'react'
import type { IndexingType } from '../../../create/step-two'
import type { RETRIEVE_METHOD } from '@/types/app'
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 * as React from 'react'
import { useCallback } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } 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 { ProgressBar, RuleDetail, SegmentProgress, StatusHeader } from './components'
import { useEmbeddingStatus, usePauseIndexing, useResumeIndexing } from './hooks'
import { FieldInfo } from '../metadata'
import EmbeddingSkeleton from './skeleton'
type EmbeddingDetailProps = {
type IEmbeddingDetailProps = {
datasetId?: string
documentId?: string
indexingType?: IndexingType
@@ -20,7 +33,128 @@ type EmbeddingDetailProps = {
detailUpdate: VoidFunction
}
const EmbeddingDetail: FC<EmbeddingDetailProps> = ({
type IRuleDetailProps = {
sourceData?: ProcessRuleResponse
indexingType?: IndexingType
retrievalMethod?: RETRIEVE_METHOD
}
const RuleDetail: FC<IRuleDetailProps> = 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 (
<div className="py-3">
<div className="flex flex-col gap-y-1">
{Object.keys(segmentationRuleMap).map((field) => {
return (
<FieldInfo
key={field}
label={segmentationRuleMap[field as keyof typeof segmentationRuleMap]}
displayedValue={String(getValue(field))}
/>
)
})}
</div>
<Divider type="horizontal" className="bg-divider-subtle" />
<FieldInfo
label={t('stepTwo.indexMode', { ns: 'datasetCreation' })}
displayedValue={t(`stepTwo.${indexingType === IndexingType.ECONOMICAL ? 'economical' : 'qualified'}`, { ns: 'datasetCreation' }) as string}
valueIcon={(
<Image
className="size-4"
src={
indexingType === IndexingType.ECONOMICAL
? indexMethodIcon.economical
: indexMethodIcon.high_quality
}
alt=""
/>
)}
/>
<FieldInfo
label={t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
displayedValue={t(`retrieval.${indexingType === IndexingType.ECONOMICAL ? 'keyword_search' : retrievalMethod ?? 'semantic_search'}.title`, { ns: 'dataset' })}
valueIcon={(
<Image
className="size-4"
src={
retrievalMethod === RETRIEVE_METHOD.fullText
? retrievalIcon.fullText
: retrievalMethod === RETRIEVE_METHOD.hybrid
? retrievalIcon.hybrid
: retrievalIcon.vector
}
alt=""
/>
)}
/>
</div>
)
})
RuleDetail.displayName = 'RuleDetail'
const EmbeddingDetail: FC<IEmbeddingDetailProps> = ({
datasetId: dstId,
documentId: docId,
detailUpdate,
@@ -30,95 +164,144 @@ const EmbeddingDetail: FC<EmbeddingDetailProps> = ({
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const contextDatasetId = useDocumentContext(s => s.datasetId)
const contextDocumentId = useDocumentContext(s => s.documentId)
const datasetId = dstId ?? contextDatasetId
const documentId = docId ?? contextDocumentId
const datasetId = useDocumentContext(s => s.datasetId)
const documentId = useDocumentContext(s => s.documentId)
const localDatasetId = dstId ?? datasetId
const localDocumentId = docId ?? documentId
const {
data: indexingStatus,
isEmbedding,
isCompleted,
isPaused,
isError,
percent,
resetStatus,
refetch,
} = useEmbeddingStatus({
datasetId,
documentId,
onComplete: detailUpdate,
})
const [indexingStatusDetail, setIndexingStatusDetail] = useState<IndexingStatusResponse | null>(null)
const fetchIndexingStatus = async () => {
const status = await doFetchIndexingStatus({ datasetId: localDatasetId, documentId: localDocumentId })
setIndexingStatusDetail(status)
return status
}
const { data: ruleDetail } = useProcessRule(documentId)
const isStopQuery = useRef(false)
const stopQueryStatus = useCallback(() => {
isStopQuery.current = true
}, [])
const handleSuccess = useCallback(() => {
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
}, [notify, t])
const startQueryStatus = useCallback(async () => {
if (isStopQuery.current)
return
const handleError = useCallback(() => {
notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
}, [notify, t])
try {
const indexingStatusDetail = await fetchIndexingStatus()
if (['completed', 'error', 'paused'].includes(indexingStatusDetail?.indexing_status)) {
stopQueryStatus()
detailUpdate()
return
}
const pauseMutation = usePauseIndexing({
datasetId,
documentId,
onSuccess: () => {
handleSuccess()
resetStatus()
},
onError: handleError,
})
await sleep(2500)
await startQueryStatus()
}
catch {
await sleep(2500)
await startQueryStatus()
}
}, [stopQueryStatus])
const resumeMutation = useResumeIndexing({
datasetId,
documentId,
onSuccess: () => {
handleSuccess()
refetch()
detailUpdate()
},
onError: handleError,
})
useEffect(() => {
isStopQuery.current = false
startQueryStatus()
return () => {
stopQueryStatus()
}
}, [startQueryStatus, stopQueryStatus])
const handlePause = useCallback(() => {
pauseMutation.mutate()
}, [pauseMutation])
const { data: ruleDetail } = useProcessRule(localDocumentId)
const handleResume = useCallback(() => {
resumeMutation.mutate()
}, [resumeMutation])
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<CommonResponse>(opApi({ datasetId: localDatasetId, documentId: localDocumentId }) as Promise<CommonResponse>)
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' }) })
}
}
return (
<>
<div className="flex flex-col gap-y-2 px-16 py-12">
<StatusHeader
isEmbedding={isEmbedding}
isCompleted={isCompleted}
isPaused={isPaused}
isError={isError}
onPause={handlePause}
onResume={handleResume}
isPauseLoading={pauseMutation.isPending}
isResumeLoading={resumeMutation.isPending}
/>
<ProgressBar
percent={percent}
isEmbedding={isEmbedding}
isCompleted={isCompleted}
isPaused={isPaused}
isError={isError}
/>
<SegmentProgress
completedSegments={indexingStatus?.completed_segments}
totalSegments={indexingStatus?.total_segments}
percent={percent}
/>
<RuleDetail
sourceData={ruleDetail}
indexingType={indexingType}
retrievalMethod={retrievalMethod}
/>
<div className="flex h-6 items-center gap-x-1">
{isEmbedding && <RiLoader2Line className="h-4 w-4 animate-spin text-text-secondary" />}
<span className="system-md-semibold-uppercase grow text-text-secondary">
{isEmbedding && t('embedding.processing', { ns: 'datasetDocuments' })}
{isEmbeddingCompleted && t('embedding.completed', { ns: 'datasetDocuments' })}
{isEmbeddingPaused && t('embedding.paused', { ns: 'datasetDocuments' })}
{isEmbeddingError && t('embedding.error', { ns: 'datasetDocuments' })}
</span>
{isEmbedding && (
<button
type="button"
className={`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]`}
onClick={handleSwitch}
>
<RiPauseCircleLine className="h-3.5 w-3.5 text-components-button-secondary-text" />
<span className="system-xs-medium pr-[3px] text-components-button-secondary-text">
{t('embedding.pause', { ns: 'datasetDocuments' })}
</span>
</button>
)}
{isEmbeddingPaused && (
<button
type="button"
className={`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]`}
onClick={handleSwitch}
>
<RiPlayCircleLine className="h-3.5 w-3.5 text-components-button-secondary-text" />
<span className="system-xs-medium pr-[3px] text-components-button-secondary-text">
{t('embedding.resume', { ns: 'datasetDocuments' })}
</span>
</button>
)}
</div>
{/* progress bar */}
<div className={cn(
'flex h-2 w-full items-center overflow-hidden rounded-md border border-components-progress-bar-border',
isEmbedding ? 'bg-components-progress-bar-bg/50' : 'bg-components-progress-bar-bg',
)}
>
<div
className={cn(
'h-full',
(isEmbedding || isEmbeddingCompleted) && 'bg-components-progress-bar-progress-solid',
(isEmbeddingPaused || isEmbeddingError) && 'bg-components-progress-bar-progress-highlight',
)}
style={{ width: `${percent}%` }}
/>
</div>
<div className="flex w-full items-center">
<span className="system-xs-medium text-text-secondary">
{`${t('embedding.segments', { ns: 'datasetDocuments' })} ${indexingStatusDetail?.completed_segments || '--'}/${indexingStatusDetail?.total_segments || '--'} · ${percent}%`}
</span>
</div>
<RuleDetail sourceData={ruleDetail} indexingType={indexingType} retrievalMethod={retrievalMethod} />
</div>
<EmbeddingSkeleton />
</>

View File

@@ -139,7 +139,7 @@ describe('CreateAppModal', () => {
expect(screen.getByText('explore.appCustomize.title:{"name":"My App"}')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /common\.operation\.create/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /common\.operation\.cancel/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()
})
it('should render edit-only fields when editing a chat app', () => {

View File

@@ -47,10 +47,6 @@ 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,
@@ -90,6 +86,7 @@ vi.mock('./actions/commands/registry', () => ({
vi.mock('@/app/components/workflow/utils/common', () => ({
getKeyboardKeyCodeBySystem: () => 'ctrl',
getKeyboardKeyNameBySystem: (key: string) => key,
isEventTargetInputArea: () => false,
isMac: () => false,
}))

View File

@@ -134,19 +134,20 @@ vi.mock('@/app/components/workflow/constants', () => ({
WORKFLOW_DATA_UPDATE: 'WORKFLOW_DATA_UPDATE',
}))
// Mock FileReader
// Mock FileReader - synchronous to avoid timing issues in tests
class MockFileReader {
result: string | null = null
onload: ((e: { target: { result: string | null } }) => void) | null = null
readAsText(_file: File) {
// Simulate async file reading
setTimeout(() => {
this.result = 'test file content'
if (this.onload) {
// Call onload synchronously to avoid race conditions in tests
this.result = 'test file content'
// Use queueMicrotask instead of setTimeout to ensure it runs before other timers
// but still allows React state updates to complete
queueMicrotask(() => {
if (this.onload)
this.onload({ target: { result: this.result } })
}
}, 0)
})
}
}

View File

@@ -83,41 +83,9 @@ vi.mock('@/app/components/workflow/constants', () => ({
// ============================================================================
describe('useDSL', () => {
let mockLink: { href: string, download: string, click: ReturnType<typeof vi.fn>, style: { display: string }, remove: ReturnType<typeof vi.fn> }
let originalCreateElement: typeof document.createElement
let originalAppendChild: typeof document.body.appendChild
let mockCreateObjectURL: ReturnType<typeof vi.spyOn>
let mockRevokeObjectURL: ReturnType<typeof vi.spyOn>
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(<T extends Node>(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
mockWorkflowStoreGetState.mockReturnValue({
pipelineId: 'test-pipeline-id',
@@ -132,10 +100,6 @@ describe('useDSL', () => {
})
afterEach(() => {
document.createElement = originalCreateElement
document.body.appendChild = originalAppendChild
mockCreateObjectURL.mockRestore()
mockRevokeObjectURL.mockRestore()
vi.clearAllMocks()
})

View File

@@ -11,12 +11,7 @@ vi.mock('@/app/components/base/modal', () => ({
onClose,
children,
closable,
}: {
isShow: boolean
onClose?: () => void
children?: React.ReactNode
closable?: boolean
}) {
}: any) {
if (!isShow)
return null
@@ -38,16 +33,18 @@ vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
}))
// Mock workflow utils for ShortcutsName component
vi.mock('@/app/components/workflow/utils', () => ({
getKeyboardKeyNameBySystem: (key: string) => key,
}))
// Mock StartNodeSelectionPanel (using real component would be better for integration,
// but for this test we'll mock to control behavior)
vi.mock('./start-node-selection-panel', () => ({
default: function MockStartNodeSelectionPanel({
onSelectUserInput,
onSelectTrigger,
}: {
onSelectUserInput?: () => void
onSelectTrigger?: (type: BlockEnum, config?: Record<string, unknown>) => void
}) {
}: any) {
return (
<div data-testid="start-node-selection-panel">
<button data-testid="select-user-input" onClick={onSelectUserInput}>
@@ -55,13 +52,13 @@ vi.mock('./start-node-selection-panel', () => ({
</button>
<button
data-testid="select-trigger-schedule"
onClick={() => onSelectTrigger?.(BlockEnum.TriggerSchedule)}
onClick={() => onSelectTrigger(BlockEnum.TriggerSchedule)}
>
Select Trigger Schedule
</button>
<button
data-testid="select-trigger-webhook"
onClick={() => onSelectTrigger?.(BlockEnum.TriggerWebhook, { config: 'test' })}
onClick={() => onSelectTrigger(BlockEnum.TriggerWebhook, { config: 'test' })}
>
Select Trigger Webhook
</button>
@@ -557,10 +554,12 @@ describe('WorkflowOnboardingModal', () => {
// Arrange & Act
renderComponent({ isShow: true })
// Assert - ShortcutsName component renders keys in div elements with system-kbd class
// Assert
const escKey = screen.getByText('workflow.onboarding.escTip.key')
expect(escKey).toBeInTheDocument()
expect(escKey).toHaveClass('system-kbd')
// ShortcutsName renders a div with system-kbd class, not a kbd element
const kbdContainer = escKey.closest('.system-kbd')
expect(kbdContainer).toBeInTheDocument()
expect(kbdContainer).toHaveClass('system-kbd')
})
it('should have descriptive text for ESC functionality', () => {

View File

@@ -182,6 +182,11 @@
"count": 1
}
},
"app/components/app/annotation/add-annotation-modal/edit-item/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/app/annotation/batch-add-annotation-modal/csv-downloader.spec.tsx": {
"ts/no-explicit-any": {
"count": 2
@@ -191,6 +196,9 @@
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
},
"react-refresh/only-export-components": {
"count": 1
},
"ts/no-explicit-any": {
"count": 2
}
@@ -198,6 +206,9 @@
"app/components/app/annotation/edit-annotation-modal/edit-item/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
},
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/app/annotation/edit-annotation-modal/index.spec.tsx": {
@@ -254,6 +265,11 @@
"count": 6
}
},
"app/components/app/configuration/base/var-highlight/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/app/configuration/config-prompt/advanced-prompt-input.tsx": {
"ts/no-explicit-any": {
"count": 2
@@ -424,6 +440,11 @@
"count": 6
}
},
"app/components/app/configuration/debug/debug-with-multiple-model/context.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/app/configuration/debug/debug-with-multiple-model/index.spec.tsx": {
"ts/no-explicit-any": {
"count": 5
@@ -506,6 +527,16 @@
"count": 1
}
},
"app/components/app/create-app-dialog/app-list/sidebar.tsx": {
"react-refresh/only-export-components": {
"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
@@ -517,6 +548,14 @@
"app/components/app/create-from-dsl-modal/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 2
},
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/app/log/filter.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/app/log/index.tsx": {
@@ -585,6 +624,9 @@
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 3
},
"react-refresh/only-export-components": {
"count": 1
},
"ts/no-explicit-any": {
"count": 4
}
@@ -594,6 +636,11 @@
"count": 2
}
},
"app/components/app/workflow-log/filter.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/app/workflow-log/list.spec.tsx": {
"ts/no-explicit-any": {
"count": 1
@@ -645,6 +692,11 @@
"count": 1
}
},
"app/components/base/action-button/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/base/agent-log-modal/detail.tsx": {
"ts/no-explicit-any": {
"count": 1
@@ -673,6 +725,11 @@
"count": 2
}
},
"app/components/base/amplitude/AmplitudeProvider.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/base/amplitude/utils.ts": {
"ts/no-explicit-any": {
"count": 2
@@ -721,6 +778,9 @@
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
},
"react-refresh/only-export-components": {
"count": 1
},
"react/no-nested-component-definitions": {
"count": 1
}
@@ -730,11 +790,21 @@
"count": 1
}
},
"app/components/base/button/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/base/button/sync-button.stories.tsx": {
"no-console": {
"count": 1
}
},
"app/components/base/carousel/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/base/chat/chat-with-history/chat-wrapper.tsx": {
"ts/no-explicit-any": {
"count": 7
@@ -822,6 +892,11 @@
"count": 1
}
},
"app/components/base/chat/chat/context.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/base/chat/chat/hooks.ts": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 2
@@ -925,10 +1000,18 @@
}
},
"app/components/base/error-boundary/index.tsx": {
"react-refresh/only-export-components": {
"count": 3
},
"ts/no-explicit-any": {
"count": 2
}
},
"app/components/base/features/context.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/base/features/new-feature-panel/annotation-reply/index.tsx": {
"ts/no-explicit-any": {
"count": 3
@@ -994,6 +1077,11 @@
"count": 3
}
},
"app/components/base/file-uploader/store.tsx": {
"react-refresh/only-export-components": {
"count": 4
}
},
"app/components/base/file-uploader/utils.spec.ts": {
"test/no-identical-title": {
"count": 1
@@ -1090,6 +1178,11 @@
"count": 2
}
},
"app/components/base/ga/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/base/icons/utils.ts": {
"ts/no-explicit-any": {
"count": 3
@@ -1141,6 +1234,16 @@
"count": 1
}
},
"app/components/base/input/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/base/logo/dify-logo.tsx": {
"react-refresh/only-export-components": {
"count": 2
}
},
"app/components/base/markdown-blocks/audio-block.tsx": {
"ts/no-explicit-any": {
"count": 5
@@ -1291,6 +1394,11 @@
"count": 1
}
},
"app/components/base/node-status/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/base/notion-connector/index.stories.tsx": {
"no-console": {
"count": 1
@@ -1322,6 +1430,9 @@
}
},
"app/components/base/portal-to-follow-elem/index.tsx": {
"react-refresh/only-export-components": {
"count": 2
},
"ts/no-explicit-any": {
"count": 1
}
@@ -1503,6 +1614,16 @@
"count": 1
}
},
"app/components/base/textarea/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/base/toast/index.tsx": {
"react-refresh/only-export-components": {
"count": 2
}
},
"app/components/base/video-gallery/VideoPlayer.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
@@ -1546,6 +1667,16 @@
"count": 2
}
},
"app/components/billing/pricing/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/billing/pricing/plan-switcher/plan-range-switcher.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/billing/pricing/plans/cloud-plan-item/index.spec.tsx": {
"test/prefer-hooks-in-order": {
"count": 1
@@ -1596,6 +1727,11 @@
"count": 3
}
},
"app/components/datasets/common/image-uploader/store.tsx": {
"react-refresh/only-export-components": {
"count": 4
}
},
"app/components/datasets/common/image-uploader/utils.ts": {
"ts/no-explicit-any": {
"count": 2
@@ -1606,6 +1742,16 @@
"count": 1
}
},
"app/components/datasets/common/retrieval-method-info/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/datasets/create/file-preview/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
@@ -1636,6 +1782,11 @@
"count": 3
}
},
"app/components/datasets/create/step-two/preview-item/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/datasets/create/stop-embedding-modal/index.spec.tsx": {
"test/prefer-hooks-in-order": {
"count": 1
@@ -1689,6 +1840,17 @@
"count": 1
}
},
"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": {
"ts/no-explicit-any": {
"count": 4
@@ -1749,6 +1911,11 @@
"count": 2
}
},
"app/components/datasets/documents/create-from-pipeline/data-source/store/provider.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/datasets/documents/create-from-pipeline/data-source/store/slices/online-drive.ts": {
"ts/no-explicit-any": {
"count": 4
@@ -1794,6 +1961,11 @@
"count": 1
}
},
"app/components/datasets/documents/detail/completed/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/datasets/documents/detail/completed/new-child-segment.tsx": {
"ts/no-explicit-any": {
"count": 1
@@ -1817,6 +1989,11 @@
"count": 1
}
},
"app/components/datasets/documents/detail/segment-add/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/datasets/documents/detail/settings/pipeline-settings/index.tsx": {
"ts/no-explicit-any": {
"count": 6
@@ -1957,6 +2134,11 @@
"count": 1
}
},
"app/components/explore/try-app/tab.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/goto-anything/actions/commands/command-bus.ts": {
"ts/no-explicit-any": {
"count": 2
@@ -1968,6 +2150,9 @@
}
},
"app/components/goto-anything/actions/commands/slash.tsx": {
"react-refresh/only-export-components": {
"count": 3
},
"ts/no-explicit-any": {
"count": 1
}
@@ -1985,6 +2170,9 @@
"app/components/goto-anything/context.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 4
},
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/goto-anything/index.spec.tsx": {
@@ -2181,6 +2369,11 @@
"count": 4
}
},
"app/components/plugins/install-plugin/install-bundle/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/plugins/install-plugin/install-bundle/item/github-item.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
@@ -2257,6 +2450,11 @@
"count": 2
}
},
"app/components/plugins/plugin-auth/index.tsx": {
"react-refresh/only-export-components": {
"count": 3
}
},
"app/components/plugins/plugin-auth/plugin-auth-in-agent.tsx": {
"ts/no-explicit-any": {
"count": 1
@@ -2341,6 +2539,9 @@
}
},
"app/components/plugins/plugin-detail-panel/subscription-list/create/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
@@ -2381,6 +2582,9 @@
}
},
"app/components/plugins/plugin-page/context.tsx": {
"react-refresh/only-export-components": {
"count": 2
},
"ts/no-explicit-any": {
"count": 1
}
@@ -2685,6 +2889,11 @@
"count": 2
}
},
"app/components/workflow-app/components/workflow-onboarding-modal/index.spec.tsx": {
"ts/no-explicit-any": {
"count": 2
}
},
"app/components/workflow-app/components/workflow-onboarding-modal/start-node-selection-panel.spec.tsx": {
"ts/no-explicit-any": {
"count": 1
@@ -2740,6 +2949,11 @@
"count": 1
}
},
"app/components/workflow/block-selector/constants.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/workflow/block-selector/featured-tools.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 2
@@ -2761,6 +2975,11 @@
"count": 1
}
},
"app/components/workflow/block-selector/index-bar.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/workflow/block-selector/market-place-plugin/action.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
@@ -2799,11 +3018,26 @@
"count": 1
}
},
"app/components/workflow/block-selector/view-type-select.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/workflow/candidate-node-main.tsx": {
"ts/no-explicit-any": {
"count": 2
}
},
"app/components/workflow/context.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/workflow/datasets-detail-store/provider.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/workflow/header/run-mode.tsx": {
"no-console": {
"count": 1
@@ -2812,11 +3046,21 @@
"count": 1
}
},
"app/components/workflow/header/test-run-menu.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/workflow/header/view-workflow-history.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"app/components/workflow/hooks-store/provider.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/workflow/hooks-store/store.ts": {
"ts/no-explicit-any": {
"count": 6
@@ -2937,10 +3181,18 @@
}
},
"app/components/workflow/nodes/_base/components/editor/code-editor/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
},
"ts/no-explicit-any": {
"count": 6
}
},
"app/components/workflow/nodes/_base/components/entry-node-container.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/workflow/nodes/_base/components/error-handle/default-value.tsx": {
"ts/no-explicit-any": {
"count": 1
@@ -2966,6 +3218,16 @@
"count": 1
}
},
"app/components/workflow/nodes/_base/components/layout/index.tsx": {
"react-refresh/only-export-components": {
"count": 7
}
},
"app/components/workflow/nodes/_base/components/mcp-tool-availability.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/workflow/nodes/_base/components/memory-config.tsx": {
"unicorn/prefer-number-properties": {
"count": 1
@@ -3049,6 +3311,9 @@
}
},
"app/components/workflow/nodes/_base/components/workflow-panel/tab.tsx": {
"react-refresh/only-export-components": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
@@ -3102,6 +3367,9 @@
}
},
"app/components/workflow/nodes/agent/panel.tsx": {
"react-refresh/only-export-components": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
@@ -3277,6 +3545,9 @@
}
},
"app/components/workflow/nodes/human-input/components/variable-in-markdown.tsx": {
"react-refresh/only-export-components": {
"count": 2
},
"ts/no-explicit-any": {
"count": 8
}
@@ -3406,6 +3677,11 @@
"count": 2
}
},
"app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/context.tsx": {
"react-refresh/only-export-components": {
"count": 3
}
},
"app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/auto-width-input.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
@@ -3703,6 +3979,11 @@
"count": 1
}
},
"app/components/workflow/note-node/note-editor/toolbar/color-picker.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"app/components/workflow/note-node/note-editor/utils.ts": {
"regexp/no-useless-quantifier": {
"count": 1
@@ -3739,6 +4020,9 @@
}
},
"app/components/workflow/panel/chat-variable-panel/components/object-value-item.tsx": {
"react-refresh/only-export-components": {
"count": 1
},
"ts/no-explicit-any": {
"count": 5
},
@@ -4025,6 +4309,11 @@
"count": 8
}
},
"app/components/workflow/workflow-history-store.tsx": {
"react-refresh/only-export-components": {
"count": 2
}
},
"app/components/workflow/workflow-preview/components/nodes/constants.ts": {
"ts/no-explicit-any": {
"count": 1
@@ -4086,30 +4375,79 @@
}
},
"context/app-context.tsx": {
"react-refresh/only-export-components": {
"count": 2
},
"ts/no-explicit-any": {
"count": 1
}
},
"context/datasets-context.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"context/event-emitter.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"context/external-api-panel-context.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"context/external-knowledge-api-context.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"context/global-public-context.tsx": {
"react-refresh/only-export-components": {
"count": 4
}
},
"context/hooks/use-trigger-events-limit-modal.ts": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 3
}
},
"context/mitt-context.tsx": {
"react-refresh/only-export-components": {
"count": 3
}
},
"context/modal-context.test.tsx": {
"ts/no-explicit-any": {
"count": 3
}
},
"context/modal-context.tsx": {
"react-refresh/only-export-components": {
"count": 2
},
"ts/no-explicit-any": {
"count": 5
}
},
"context/provider-context.tsx": {
"react-refresh/only-export-components": {
"count": 3
},
"ts/no-explicit-any": {
"count": 1
}
},
"context/web-app-context.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"context/workspace-context.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"hooks/use-async-window-open.spec.ts": {
"ts/no-explicit-any": {
"count": 6
@@ -4146,6 +4484,9 @@
"hooks/use-pay.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 4
},
"react-refresh/only-export-components": {
"count": 3
}
},
"i18n-config/README.md": {