refactor(web): extract complex components into modular structure with comprehensive tests (#31729)

Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Coding On Star
2026-02-04 18:35:31 +08:00
committed by GitHub
parent bba2040a05
commit 1e344f773b
42 changed files with 5234 additions and 809 deletions

View File

@ -0,0 +1,4 @@
export { default as ProgressBar } from './progress-bar'
export { default as RuleDetail } from './rule-detail'
export { default as SegmentProgress } from './segment-progress'
export { default as StatusHeader } from './status-header'

View File

@ -0,0 +1,159 @@
import { render } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import ProgressBar from './progress-bar'
describe('ProgressBar', () => {
const defaultProps = {
percent: 50,
isEmbedding: false,
isCompleted: false,
isPaused: false,
isError: false,
}
const getProgressElements = (container: HTMLElement) => {
const wrapper = container.firstChild as HTMLElement
const progressBar = wrapper.firstChild as HTMLElement
return { wrapper, progressBar }
}
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(<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

@ -0,0 +1,44 @@
import type { FC } from 'react'
import * as React from 'react'
import { cn } from '@/utils/classnames'
type ProgressBarProps = {
percent: number
isEmbedding: boolean
isCompleted: boolean
isPaused: boolean
isError: boolean
}
const ProgressBar: FC<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

@ -0,0 +1,203 @@
import type { ProcessRuleResponse } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { ProcessMode } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
import { IndexingType } from '../../../../create/step-two'
import RuleDetail from './rule-detail'
describe('RuleDetail', () => {
const defaultProps = {
indexingType: IndexingType.QUALIFIED,
retrievalMethod: RETRIEVE_METHOD.semantic,
}
const createSourceData = (overrides: Partial<ProcessRuleResponse> = {}): 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

@ -0,0 +1,128 @@
import type { FC } from 'react'
import type { ProcessRuleResponse } from '@/models/datasets'
import type { RETRIEVE_METHOD } from '@/types/app'
import Image from 'next/image'
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import { ProcessMode } from '@/models/datasets'
import { indexMethodIcon, retrievalIcon } from '../../../../create/icons'
import { IndexingType } from '../../../../create/step-two'
import { FieldInfo } from '../../metadata'
type RuleDetailProps = {
sourceData?: ProcessRuleResponse
indexingType?: IndexingType
retrievalMethod?: RETRIEVE_METHOD
}
const getRetrievalIcon = (method?: RETRIEVE_METHOD) => {
if (method === 'full_text_search')
return retrievalIcon.fullText
if (method === 'hybrid_search')
return retrievalIcon.hybrid
return retrievalIcon.vector
}
const RuleDetail: FC<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

@ -0,0 +1,81 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import SegmentProgress from './segment-progress'
describe('SegmentProgress', () => {
const defaultProps = {
completedSegments: 50,
totalSegments: 100,
percent: 50,
}
describe('Rendering', () => {
it('should render without crashing', () => {
render(<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

@ -0,0 +1,32 @@
import type { FC } from 'react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
type SegmentProgressProps = {
completedSegments?: number
totalSegments?: number
percent: number
}
const SegmentProgress: FC<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

@ -0,0 +1,155 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import StatusHeader from './status-header'
describe('StatusHeader', () => {
const defaultProps = {
isEmbedding: false,
isCompleted: false,
isPaused: false,
isError: false,
onPause: vi.fn(),
onResume: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(<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

@ -0,0 +1,84 @@
import type { FC } from 'react'
import { RiLoader2Line, RiPauseCircleLine, RiPlayCircleLine } from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
type StatusHeaderProps = {
isEmbedding: boolean
isCompleted: boolean
isPaused: boolean
isError: boolean
onPause: () => void
onResume: () => void
isPauseLoading?: boolean
isResumeLoading?: boolean
}
const StatusHeader: FC<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

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

View File

@ -0,0 +1,462 @@
import type { ReactNode } from 'react'
import type { IndexingStatusResponse } from '@/models/datasets'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as datasetsService from '@/service/datasets'
import {
calculatePercent,
isEmbeddingStatus,
isTerminalStatus,
useEmbeddingStatus,
useInvalidateEmbeddingStatus,
usePauseIndexing,
useResumeIndexing,
} from './use-embedding-status'
vi.mock('@/service/datasets')
const mockFetchIndexingStatus = vi.mocked(datasetsService.fetchIndexingStatus)
const mockPauseDocIndexing = vi.mocked(datasetsService.pauseDocIndexing)
const mockResumeDocIndexing = vi.mocked(datasetsService.resumeDocIndexing)
const createTestQueryClient = () => new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const createWrapper = () => {
const queryClient = createTestQueryClient()
return ({ children }: { children: ReactNode }) => (
<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

@ -0,0 +1,149 @@
import type { CommonResponse } from '@/models/common'
import type { IndexingStatusResponse } from '@/models/datasets'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import {
fetchIndexingStatus,
pauseDocIndexing,
resumeDocIndexing,
} from '@/service/datasets'
const NAME_SPACE = 'embedding'
export type EmbeddingStatusType = 'indexing' | 'splitting' | 'parsing' | 'cleaning' | 'completed' | 'paused' | 'error' | 'waiting' | ''
const EMBEDDING_STATUSES = ['indexing', 'splitting', 'parsing', 'cleaning'] as const
const TERMINAL_STATUSES = ['completed', 'error', 'paused'] as const
export const isEmbeddingStatus = (status?: string): boolean => {
return EMBEDDING_STATUSES.includes(status as typeof EMBEDDING_STATUSES[number])
}
export const isTerminalStatus = (status?: string): boolean => {
return TERMINAL_STATUSES.includes(status as typeof TERMINAL_STATUSES[number])
}
export const calculatePercent = (completed?: number, total?: number): number => {
if (!total || total === 0)
return 0
const percent = Math.round((completed || 0) * 100 / total)
return Math.min(percent, 100)
}
type UseEmbeddingStatusOptions = {
datasetId?: string
documentId?: string
enabled?: boolean
onComplete?: () => void
}
export const useEmbeddingStatus = ({
datasetId,
documentId,
enabled = true,
onComplete,
}: UseEmbeddingStatusOptions) => {
const queryClient = useQueryClient()
const isPolling = useRef(false)
const onCompleteRef = useRef(onComplete)
onCompleteRef.current = onComplete
const queryKey = useMemo(
() => [NAME_SPACE, 'indexing-status', datasetId, documentId] as const,
[datasetId, documentId],
)
const query = useQuery<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

@ -0,0 +1,337 @@
import type { ReactNode } from 'react'
import type { DocumentContextValue } from '../context'
import type { IndexingStatusResponse, ProcessRuleResponse } from '@/models/datasets'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ProcessMode } from '@/models/datasets'
import * as datasetsService from '@/service/datasets'
import * as useDataset from '@/service/knowledge/use-dataset'
import { RETRIEVE_METHOD } from '@/types/app'
import { IndexingType } from '../../../create/step-two'
import { DocumentContext } from '../context'
import EmbeddingDetail from './index'
vi.mock('@/service/datasets')
vi.mock('@/service/knowledge/use-dataset')
const mockFetchIndexingStatus = vi.mocked(datasetsService.fetchIndexingStatus)
const mockPauseDocIndexing = vi.mocked(datasetsService.pauseDocIndexing)
const mockResumeDocIndexing = vi.mocked(datasetsService.resumeDocIndexing)
const mockUseProcessRule = vi.mocked(useDataset.useProcessRule)
const createTestQueryClient = () => new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
mutations: { retry: false },
},
})
const createWrapper = (contextValue: DocumentContextValue = { datasetId: 'ds1', documentId: 'doc1' }) => {
const queryClient = createTestQueryClient()
return ({ children }: { children: ReactNode }) => (
<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,31 +1,18 @@
import type { FC } from 'react'
import type { CommonResponse } from '@/models/common'
import type { IndexingStatusResponse, ProcessRuleResponse } from '@/models/datasets'
import { RiLoader2Line, RiPauseCircleLine, RiPlayCircleLine } from '@remixicon/react'
import Image from 'next/image'
import type { IndexingType } from '../../../create/step-two'
import type { RETRIEVE_METHOD } from '@/types/app'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Divider from '@/app/components/base/divider'
import { ToastContext } from '@/app/components/base/toast'
import { ProcessMode } from '@/models/datasets'
import {
fetchIndexingStatus as doFetchIndexingStatus,
pauseDocIndexing,
resumeDocIndexing,
} from '@/service/datasets'
import { useProcessRule } from '@/service/knowledge/use-dataset'
import { RETRIEVE_METHOD } from '@/types/app'
import { asyncRunSafe, sleep } from '@/utils'
import { cn } from '@/utils/classnames'
import { indexMethodIcon, retrievalIcon } from '../../../create/icons'
import { IndexingType } from '../../../create/step-two'
import { useDocumentContext } from '../context'
import { FieldInfo } from '../metadata'
import { ProgressBar, RuleDetail, SegmentProgress, StatusHeader } from './components'
import { useEmbeddingStatus, usePauseIndexing, useResumeIndexing } from './hooks'
import EmbeddingSkeleton from './skeleton'
type IEmbeddingDetailProps = {
type EmbeddingDetailProps = {
datasetId?: string
documentId?: string
indexingType?: IndexingType
@ -33,128 +20,7 @@ type IEmbeddingDetailProps = {
detailUpdate: VoidFunction
}
type IRuleDetailProps = {
sourceData?: ProcessRuleResponse
indexingType?: IndexingType
retrievalMethod?: RETRIEVE_METHOD
}
const RuleDetail: FC<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> = ({
const EmbeddingDetail: FC<EmbeddingDetailProps> = ({
datasetId: dstId,
documentId: docId,
detailUpdate,
@ -164,144 +30,95 @@ const EmbeddingDetail: FC<IEmbeddingDetailProps> = ({
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const datasetId = useDocumentContext(s => s.datasetId)
const documentId = useDocumentContext(s => s.documentId)
const localDatasetId = dstId ?? datasetId
const localDocumentId = docId ?? documentId
const contextDatasetId = useDocumentContext(s => s.datasetId)
const contextDocumentId = useDocumentContext(s => s.documentId)
const datasetId = dstId ?? contextDatasetId
const documentId = docId ?? contextDocumentId
const [indexingStatusDetail, setIndexingStatusDetail] = useState<IndexingStatusResponse | null>(null)
const fetchIndexingStatus = async () => {
const status = await doFetchIndexingStatus({ datasetId: localDatasetId, documentId: localDocumentId })
setIndexingStatusDetail(status)
return status
}
const {
data: indexingStatus,
isEmbedding,
isCompleted,
isPaused,
isError,
percent,
resetStatus,
refetch,
} = useEmbeddingStatus({
datasetId,
documentId,
onComplete: detailUpdate,
})
const isStopQuery = useRef(false)
const stopQueryStatus = useCallback(() => {
isStopQuery.current = true
}, [])
const { data: ruleDetail } = useProcessRule(documentId)
const startQueryStatus = useCallback(async () => {
if (isStopQuery.current)
return
const handleSuccess = useCallback(() => {
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
}, [notify, t])
try {
const indexingStatusDetail = await fetchIndexingStatus()
if (['completed', 'error', 'paused'].includes(indexingStatusDetail?.indexing_status)) {
stopQueryStatus()
detailUpdate()
return
}
const handleError = useCallback(() => {
notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
}, [notify, t])
await sleep(2500)
await startQueryStatus()
}
catch {
await sleep(2500)
await startQueryStatus()
}
}, [stopQueryStatus])
const pauseMutation = usePauseIndexing({
datasetId,
documentId,
onSuccess: () => {
handleSuccess()
resetStatus()
},
onError: handleError,
})
useEffect(() => {
isStopQuery.current = false
startQueryStatus()
return () => {
stopQueryStatus()
}
}, [startQueryStatus, stopQueryStatus])
const resumeMutation = useResumeIndexing({
datasetId,
documentId,
onSuccess: () => {
handleSuccess()
refetch()
detailUpdate()
},
onError: handleError,
})
const { data: ruleDetail } = useProcessRule(localDocumentId)
const handlePause = useCallback(() => {
pauseMutation.mutate()
}, [pauseMutation])
const isEmbedding = useMemo(() => ['indexing', 'splitting', 'parsing', 'cleaning'].includes(indexingStatusDetail?.indexing_status || ''), [indexingStatusDetail])
const isEmbeddingCompleted = useMemo(() => ['completed'].includes(indexingStatusDetail?.indexing_status || ''), [indexingStatusDetail])
const isEmbeddingPaused = useMemo(() => ['paused'].includes(indexingStatusDetail?.indexing_status || ''), [indexingStatusDetail])
const isEmbeddingError = useMemo(() => ['error'].includes(indexingStatusDetail?.indexing_status || ''), [indexingStatusDetail])
const percent = useMemo(() => {
const completedCount = indexingStatusDetail?.completed_segments || 0
const totalCount = indexingStatusDetail?.total_segments || 0
if (totalCount === 0)
return 0
const percent = Math.round(completedCount * 100 / totalCount)
return percent > 100 ? 100 : percent
}, [indexingStatusDetail])
const handleSwitch = async () => {
const opApi = isEmbedding ? pauseDocIndexing : resumeDocIndexing
const [e] = await asyncRunSafe<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' }) })
}
}
const handleResume = useCallback(() => {
resumeMutation.mutate()
}, [resumeMutation])
return (
<>
<div className="flex flex-col gap-y-2 px-16 py-12">
<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} />
<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>
<EmbeddingSkeleton />
</>