refactor(web): restructure metadata components and add tests

This commit refactors the metadata components within the document detail section by extracting and organizing them into separate files for better maintainability and readability. The following components were created or modified:

- DocTypeSelector: A new component for selecting document types.
- FieldInfo: A new component for displaying and editing field information.
- IconButton: A new component for rendering document type icons.
- MetadataFieldList: A new component for rendering a list of metadata fields.
- TypeIcon: A new component for displaying icons based on document types.
- Utility functions for mapping options.

Additionally, comprehensive tests were added for each new component to ensure functionality and reliability.
This commit is contained in:
CodingOnStar
2026-01-30 10:09:58 +08:00
parent ec7d80dd1a
commit a25e4c1b3a
20 changed files with 1961 additions and 373 deletions

View File

@ -0,0 +1,181 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import DocTypeSelector from './doc-type-selector'
vi.mock('@/hooks/use-metadata', () => ({
useMetadataMap: () => ({
book: { text: 'Book', iconName: 'book' },
paper: { text: 'Paper', iconName: 'paper' },
personal_document: { text: 'Personal Document', iconName: 'personal_document' },
business_document: { text: 'Business Document', iconName: 'business_document' },
web_page: { text: 'Web Page', iconName: 'web_page' },
social_media_post: { text: 'Social Media', iconName: 'social_media_post' },
wikipedia_entry: { text: 'Wikipedia', iconName: 'wikipedia_entry' },
im_chat_log: { text: 'IM Chat Log', iconName: 'im_chat_log' },
synced_from_github: { text: 'GitHub', iconName: 'synced_from_github' },
synced_from_notion: { text: 'Notion', iconName: 'synced_from_notion' },
others: { text: 'Others', iconName: 'others' },
}),
}))
describe('DocTypeSelector', () => {
const defaultProps = {
documentType: '' as const,
tempDocType: '' as const,
doc_type: '',
onTempDocTypeChange: vi.fn(),
onConfirm: vi.fn(),
onCancel: vi.fn(),
}
describe('first time selection (no existing doc_type)', () => {
it('should render description text for first time', () => {
render(<DocTypeSelector {...defaultProps} />)
expect(screen.getByText(/metadata.desc/i)).toBeInTheDocument()
})
it('should render doc type select title for first time', () => {
render(<DocTypeSelector {...defaultProps} />)
expect(screen.getByText(/metadata.docTypeSelectTitle/i)).toBeInTheDocument()
})
it('should render first meta action button', () => {
render(<DocTypeSelector {...defaultProps} />)
expect(screen.getByText(/metadata.firstMetaAction/i)).toBeInTheDocument()
})
it('should disable confirm button when no temp doc type selected', () => {
render(<DocTypeSelector {...defaultProps} />)
const button = screen.getByText(/metadata.firstMetaAction/i)
expect(button).toBeDisabled()
})
it('should enable confirm button when temp doc type is selected', () => {
render(<DocTypeSelector {...defaultProps} tempDocType="book" />)
const button = screen.getByText(/metadata.firstMetaAction/i)
expect(button).not.toBeDisabled()
})
})
describe('changing existing doc type', () => {
const propsWithDocType = {
...defaultProps,
documentType: 'book' as const,
doc_type: 'book',
}
it('should render change title when documentType exists', () => {
render(<DocTypeSelector {...propsWithDocType} />)
expect(screen.getByText(/metadata.docTypeChangeTitle/i)).toBeInTheDocument()
})
it('should render warning text when documentType exists', () => {
render(<DocTypeSelector {...propsWithDocType} />)
expect(screen.getByText(/metadata.docTypeSelectWarning/i)).toBeInTheDocument()
})
it('should render save and cancel buttons', () => {
render(<DocTypeSelector {...propsWithDocType} />)
expect(screen.getByText(/operation.save/i)).toBeInTheDocument()
expect(screen.getByText(/operation.cancel/i)).toBeInTheDocument()
})
it('should not render first meta action button', () => {
render(<DocTypeSelector {...propsWithDocType} />)
expect(screen.queryByText(/metadata.firstMetaAction/i)).not.toBeInTheDocument()
})
})
describe('radio group', () => {
it('should render icon buttons for each doc type', () => {
const { container } = render(<DocTypeSelector {...defaultProps} />)
// Radio component uses divs with onClick, not actual radio inputs
// IconButton renders buttons with iconWrapper class
const iconButtons = container.querySelectorAll('button[class*="iconWrapper"]')
expect(iconButtons.length).toBe(7) // CUSTOMIZABLE_DOC_TYPES has 7 items
})
it('should call onTempDocTypeChange when radio option is clicked', () => {
const onTempDocTypeChange = vi.fn()
const { container } = render(<DocTypeSelector {...defaultProps} onTempDocTypeChange={onTempDocTypeChange} />)
// Click on the parent Radio div (not the IconButton)
const radioOptions = container.querySelectorAll('div[class*="label"]')
if (radioOptions.length > 0) {
fireEvent.click(radioOptions[0])
expect(onTempDocTypeChange).toHaveBeenCalled()
}
else {
// Fallback: click on icon button's parent
const iconButtons = container.querySelectorAll('button[type="button"]')
const parentDiv = iconButtons[0]?.parentElement?.parentElement
if (parentDiv)
fireEvent.click(parentDiv)
expect(onTempDocTypeChange).toHaveBeenCalled()
}
})
it('should apply checked styles when selected', () => {
const { container } = render(<DocTypeSelector {...defaultProps} tempDocType="book" />)
// The first icon should have the checked styling
const iconButtons = container.querySelectorAll('button[type="button"]')
const firstButton = iconButtons[0]
// Check if iconCheck class is applied
expect(firstButton?.className).toContain('iconCheck')
})
})
describe('button callbacks', () => {
it('should call onConfirm when confirm button is clicked', () => {
const onConfirm = vi.fn()
render(<DocTypeSelector {...defaultProps} tempDocType="book" onConfirm={onConfirm} />)
const confirmButton = screen.getByText(/metadata.firstMetaAction/i)
fireEvent.click(confirmButton)
expect(onConfirm).toHaveBeenCalled()
})
it('should call onCancel when cancel button is clicked', () => {
const onCancel = vi.fn()
const propsWithDocType = {
...defaultProps,
documentType: 'book' as const,
doc_type: 'book',
onCancel,
}
render(<DocTypeSelector {...propsWithDocType} />)
const cancelButton = screen.getByText(/operation.cancel/i)
fireEvent.click(cancelButton)
expect(onCancel).toHaveBeenCalled()
})
it('should call onConfirm when save button is clicked in change mode', () => {
const onConfirm = vi.fn()
const propsWithDocType = {
...defaultProps,
documentType: 'book' as const,
doc_type: 'book',
onConfirm,
}
render(<DocTypeSelector {...propsWithDocType} />)
const saveButton = screen.getByText(/operation.save/i)
fireEvent.click(saveButton)
expect(onConfirm).toHaveBeenCalled()
})
})
describe('memoization', () => {
it('should be memoized', () => {
const { container, rerender } = render(<DocTypeSelector {...defaultProps} />)
const firstRender = container.innerHTML
rerender(<DocTypeSelector {...defaultProps} />)
expect(container.innerHTML).toBe(firstRender)
})
})
})

View File

@ -0,0 +1,77 @@
import type { FC } from 'react'
import type { DocType } from '@/models/datasets'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Radio from '@/app/components/base/radio'
import { CUSTOMIZABLE_DOC_TYPES } from '@/models/datasets'
import s from '../style.module.css'
import IconButton from './icon-button'
export type DocTypeSelectorProps = {
documentType?: DocType | ''
tempDocType: DocType | ''
doc_type: string
onTempDocTypeChange: (type: DocType | '') => void
onConfirm: () => void
onCancel: () => void
}
const DocTypeSelector: FC<DocTypeSelectorProps> = ({
documentType,
tempDocType,
doc_type,
onTempDocTypeChange,
onConfirm,
onCancel,
}) => {
const { t } = useTranslation()
const isFirstTime = !doc_type && !documentType
const currValue = tempDocType ?? documentType
return (
<>
{isFirstTime && (
<div className={s.desc}>{t('metadata.desc', { ns: 'datasetDocuments' })}</div>
)}
<div className={s.operationWrapper}>
{isFirstTime && (
<span className={s.title}>{t('metadata.docTypeSelectTitle', { ns: 'datasetDocuments' })}</span>
)}
{documentType && (
<>
<span className={s.title}>{t('metadata.docTypeChangeTitle', { ns: 'datasetDocuments' })}</span>
<span className={s.changeTip}>{t('metadata.docTypeSelectWarning', { ns: 'datasetDocuments' })}</span>
</>
)}
<Radio.Group value={currValue ?? ''} onChange={onTempDocTypeChange} className={s.radioGroup}>
{CUSTOMIZABLE_DOC_TYPES.map(type => (
<Radio key={type} value={type} className={`${s.radio} ${currValue === type ? 'shadow-none' : ''}`}>
<IconButton
type={type}
isChecked={currValue === type}
/>
</Radio>
))}
</Radio.Group>
{isFirstTime && (
<Button
variant="primary"
onClick={onConfirm}
disabled={!tempDocType}
>
{t('metadata.firstMetaAction', { ns: 'datasetDocuments' })}
</Button>
)}
{documentType && (
<div className={s.opBtnWrapper}>
<Button onClick={onConfirm} className={`${s.opBtn} ${s.opSaveBtn}`} variant="primary">{t('operation.save', { ns: 'common' })}</Button>
<Button onClick={onCancel} className={`${s.opBtn} ${s.opCancelBtn}`}>{t('operation.cancel', { ns: 'common' })}</Button>
</div>
)}
</div>
</>
)
}
export default memo(DocTypeSelector)

View File

@ -0,0 +1,152 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import FieldInfo from './field-info'
vi.mock('@/utils', () => ({
getTextWidthWithCanvas: vi.fn().mockReturnValue(100),
}))
describe('FieldInfo', () => {
describe('rendering', () => {
it('should render label and displayed value', () => {
render(<FieldInfo label="Title" displayedValue="Test Value" />)
expect(screen.getByText('Title')).toBeInTheDocument()
expect(screen.getByText('Test Value')).toBeInTheDocument()
})
it('should render valueIcon when provided', () => {
const icon = <span data-testid="test-icon">Icon</span>
render(<FieldInfo label="Title" displayedValue="Value" valueIcon={icon} />)
expect(screen.getByTestId('test-icon')).toBeInTheDocument()
})
it('should render displayed value when not in edit mode', () => {
render(<FieldInfo label="Title" displayedValue="Display Text" showEdit={false} />)
expect(screen.getByText('Display Text')).toBeInTheDocument()
})
})
describe('edit mode - input type', () => {
it('should render input when showEdit is true and inputType is input', () => {
render(<FieldInfo label="Title" value="test" showEdit inputType="input" />)
const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
expect(input).toHaveValue('test')
})
it('should call onUpdate when input value changes', () => {
const onUpdate = vi.fn()
render(<FieldInfo label="Title" value="" showEdit inputType="input" onUpdate={onUpdate} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'new value' } })
expect(onUpdate).toHaveBeenCalledWith('new value')
})
it('should render with defaultValue', () => {
render(<FieldInfo label="Title" defaultValue="default" showEdit inputType="input" />)
const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
})
})
describe('edit mode - textarea type', () => {
it('should render textarea when inputType is textarea', () => {
render(<FieldInfo label="Description" value="test desc" showEdit inputType="textarea" />)
const textarea = screen.getByRole('textbox')
expect(textarea).toBeInTheDocument()
expect(textarea).toHaveValue('test desc')
})
it('should call onUpdate when textarea value changes', () => {
const onUpdate = vi.fn()
render(<FieldInfo label="Description" value="" showEdit inputType="textarea" onUpdate={onUpdate} />)
const textarea = screen.getByRole('textbox')
fireEvent.change(textarea, { target: { value: 'new description' } })
expect(onUpdate).toHaveBeenCalledWith('new description')
})
})
describe('edit mode - select type', () => {
const selectOptions = [
{ value: 'en', name: 'English' },
{ value: 'zh', name: 'Chinese' },
]
it('should render select when inputType is select', () => {
render(
<FieldInfo
label="Language"
value="en"
showEdit
inputType="select"
selectOptions={selectOptions}
/>,
)
expect(screen.getByText('English')).toBeInTheDocument()
})
it('should call onUpdate when select value changes', () => {
const onUpdate = vi.fn()
render(
<FieldInfo
label="Language"
value="en"
showEdit
inputType="select"
selectOptions={selectOptions}
onUpdate={onUpdate}
/>,
)
const selectTrigger = screen.getByText('English')
fireEvent.click(selectTrigger)
const chineseOption = screen.getByText('Chinese')
fireEvent.click(chineseOption)
expect(onUpdate).toHaveBeenCalledWith('zh')
})
})
describe('alignment', () => {
it('should apply top alignment class for textarea edit mode', () => {
const { container } = render(
<FieldInfo label="Description" showEdit inputType="textarea" />,
)
const wrapper = container.firstChild
expect(wrapper).toHaveClass('!items-start')
})
})
describe('default props', () => {
it('should use default empty string for value', () => {
render(<FieldInfo label="Title" showEdit inputType="input" />)
const input = screen.getByRole('textbox')
expect(input).toHaveValue('')
})
it('should use default input type', () => {
render(<FieldInfo label="Title" showEdit />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should use empty select options by default', () => {
const { container } = render(<FieldInfo label="Select Field" showEdit inputType="select" />)
expect(container.querySelector('button')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,89 @@
import type { FC, ReactNode } from 'react'
import type { inputType } from '@/hooks/use-metadata'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import AutoHeightTextarea from '@/app/components/base/auto-height-textarea'
import Input from '@/app/components/base/input'
import { SimpleSelect } from '@/app/components/base/select'
import { getTextWidthWithCanvas } from '@/utils'
import { cn } from '@/utils/classnames'
import s from '../style.module.css'
export type FieldInfoProps = {
label: string
value?: string
valueIcon?: ReactNode
displayedValue?: string
defaultValue?: string
showEdit?: boolean
inputType?: inputType
selectOptions?: Array<{ value: string, name: string }>
onUpdate?: (v: string) => void
}
const FieldInfo: FC<FieldInfoProps> = ({
label,
value = '',
valueIcon,
displayedValue = '',
defaultValue,
showEdit = false,
inputType = 'input',
selectOptions = [],
onUpdate,
}) => {
const { t } = useTranslation()
const textNeedWrap = getTextWidthWithCanvas(displayedValue) > 190
const editAlignTop = showEdit && inputType === 'textarea'
const readAlignTop = !showEdit && textNeedWrap
const renderContent = () => {
if (!showEdit)
return displayedValue
if (inputType === 'select') {
return (
<SimpleSelect
onSelect={({ value }) => onUpdate?.(value as string)}
items={selectOptions}
defaultValue={value}
className={s.select}
wrapperClassName={s.selectWrapper}
placeholder={`${t('metadata.placeholder.select', { ns: 'datasetDocuments' })}${label}`}
/>
)
}
if (inputType === 'textarea') {
return (
<AutoHeightTextarea
onChange={e => onUpdate?.(e.target.value)}
value={value}
className={s.textArea}
placeholder={`${t('metadata.placeholder.add', { ns: 'datasetDocuments' })}${label}`}
/>
)
}
return (
<Input
onChange={e => onUpdate?.(e.target.value)}
value={value}
defaultValue={defaultValue}
placeholder={`${t('metadata.placeholder.add', { ns: 'datasetDocuments' })}${label}`}
/>
)
}
return (
<div className={cn('flex min-h-5 items-center gap-1 py-0.5 text-xs', editAlignTop && '!items-start', readAlignTop && '!items-start pt-1')}>
<div className={cn('w-[200px] shrink-0 overflow-hidden text-ellipsis whitespace-nowrap text-text-tertiary', editAlignTop && 'pt-1')}>{label}</div>
<div className="flex grow items-center gap-1 text-text-secondary">
{valueIcon}
{renderContent()}
</div>
</div>
)
}
export default memo(FieldInfo)

View File

@ -0,0 +1,104 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import IconButton from './icon-button'
vi.mock('@/hooks/use-metadata', () => ({
useMetadataMap: () => ({
book: { text: 'Book', iconName: 'book' },
paper: { text: 'Paper', iconName: 'paper' },
personal_document: { text: 'Personal Document', iconName: 'personal_document' },
business_document: { text: 'Business Document', iconName: 'business_document' },
}),
}))
describe('IconButton', () => {
describe('rendering', () => {
it('should render without crashing', () => {
const { container } = render(<IconButton type="book" isChecked={false} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render as a button element', () => {
render(<IconButton type="book" isChecked={false} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should have type="button" attribute', () => {
render(<IconButton type="book" isChecked={false} />)
const button = screen.getByRole('button')
expect(button).toHaveAttribute('type', 'button')
})
})
describe('checked state', () => {
it('should apply iconCheck class when isChecked is true', () => {
render(<IconButton type="book" isChecked />)
const button = screen.getByRole('button')
expect(button.className).toContain('iconCheck')
})
it('should not apply iconCheck class when isChecked is false', () => {
render(<IconButton type="book" isChecked={false} />)
const button = screen.getByRole('button')
expect(button.className).not.toContain('iconCheck')
})
it('should apply primary color to TypeIcon when checked', () => {
const { container } = render(<IconButton type="book" isChecked />)
const typeIcon = container.querySelector('div[class*="Icon"]')
expect(typeIcon?.className).toContain('!bg-primary-600')
})
})
describe('tooltip', () => {
it('should display tooltip with doc type text', async () => {
render(<IconButton type="book" isChecked={false} />)
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
})
})
describe('different doc types', () => {
it('should render book type', () => {
const { container } = render(<IconButton type="book" isChecked={false} />)
const icon = container.querySelector('div[class*="bookIcon"]')
expect(icon).toBeInTheDocument()
})
it('should render paper type', () => {
const { container } = render(<IconButton type="paper" isChecked={false} />)
const icon = container.querySelector('div[class*="paperIcon"]')
expect(icon).toBeInTheDocument()
})
it('should render personal_document type', () => {
const { container } = render(<IconButton type="personal_document" isChecked={false} />)
const icon = container.querySelector('div[class*="personal_documentIcon"]')
expect(icon).toBeInTheDocument()
})
it('should render business_document type', () => {
const { container } = render(<IconButton type="business_document" isChecked={false} />)
const icon = container.querySelector('div[class*="business_documentIcon"]')
expect(icon).toBeInTheDocument()
})
})
describe('hover states', () => {
it('should have group class for hover styles', () => {
render(<IconButton type="book" isChecked={false} />)
const button = screen.getByRole('button')
expect(button.className).toContain('group')
})
})
describe('memoization', () => {
it('should be memoized', () => {
const { container, rerender } = render(<IconButton type="book" isChecked={false} />)
const firstRender = container.innerHTML
rerender(<IconButton type="book" isChecked={false} />)
expect(container.innerHTML).toBe(firstRender)
})
})
})

View File

@ -0,0 +1,32 @@
import type { FC } from 'react'
import type { DocType } from '@/models/datasets'
import { memo } from 'react'
import Tooltip from '@/app/components/base/tooltip'
import { useMetadataMap } from '@/hooks/use-metadata'
import { cn } from '@/utils/classnames'
import s from '../style.module.css'
import TypeIcon from './type-icon'
export type IconButtonProps = {
type: DocType
isChecked: boolean
}
const IconButton: FC<IconButtonProps> = ({ type, isChecked = false }) => {
const metadataMap = useMetadataMap()
return (
<Tooltip
popupContent={metadataMap[type].text}
>
<button type="button" className={cn(s.iconWrapper, 'group', isChecked ? s.iconCheck : '')}>
<TypeIcon
iconName={metadataMap[type].iconName || ''}
className={`group-hover:bg-primary-600 ${isChecked ? '!bg-primary-600' : ''}`}
/>
</button>
</Tooltip>
)
}
export default memo(IconButton)

View File

@ -0,0 +1,11 @@
export { default as DocTypeSelector } from './doc-type-selector'
export type { DocTypeSelectorProps } from './doc-type-selector'
export { default as FieldInfo } from './field-info'
export type { FieldInfoProps } from './field-info'
export { default as IconButton } from './icon-button'
export type { IconButtonProps } from './icon-button'
export { default as MetadataFieldList } from './metadata-field-list'
export type { MetadataFieldListProps } from './metadata-field-list'
export { default as TypeIcon } from './type-icon'
export type { TypeIconProps } from './type-icon'
export { map2Options } from './utils'

View File

@ -0,0 +1,258 @@
import type { FullDocumentDetail } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import MetadataFieldList from './metadata-field-list'
vi.mock('@/hooks/use-metadata', () => ({
useMetadataMap: () => ({
book: {
text: 'Book',
iconName: 'book',
subFieldsMap: {
title: { label: 'Title', inputType: 'input' },
author: { label: 'Author', inputType: 'input' },
language: { label: 'Language', inputType: 'select' },
},
},
personal_document: {
text: 'Personal Document',
iconName: 'personal_document',
subFieldsMap: {
document_type: { label: 'Document Type', inputType: 'select' },
},
},
business_document: {
text: 'Business Document',
iconName: 'business_document',
subFieldsMap: {
document_type: { label: 'Document Type', inputType: 'select' },
},
},
originInfo: {
text: 'Origin Info',
subFieldsMap: {
source: { label: 'Source', inputType: 'input' },
},
},
technicalParameters: {
text: 'Technical Parameters',
subFieldsMap: {
hit_count: {
label: 'Hit Count',
inputType: 'input',
render: (val: number, segmentCount?: number) => `${val} (${segmentCount} segments)`,
},
},
},
}),
useLanguages: () => ({
en: 'English',
zh: 'Chinese',
}),
useBookCategories: () => ({
fiction: 'Fiction',
nonfiction: 'Non-Fiction',
}),
usePersonalDocCategories: () => ({
resume: 'Resume',
letter: 'Letter',
}),
useBusinessDocCategories: () => ({
contract: 'Contract',
report: 'Report',
}),
}))
vi.mock('@/utils', () => ({
getTextWidthWithCanvas: vi.fn().mockReturnValue(100),
}))
describe('MetadataFieldList', () => {
const defaultProps = {
mainField: 'book' as const,
canEdit: false,
metadataParams: {
documentType: 'book' as const,
metadata: { title: 'Test Book', author: 'Test Author' },
},
onUpdateField: vi.fn(),
}
describe('rendering', () => {
it('should return null when mainField is empty', () => {
const { container } = render(
<MetadataFieldList {...defaultProps} mainField="" />,
)
expect(container.firstChild).toBeNull()
})
it('should render fields for book type', () => {
render(<MetadataFieldList {...defaultProps} />)
expect(screen.getByText('Title')).toBeInTheDocument()
expect(screen.getByText('Author')).toBeInTheDocument()
expect(screen.getByText('Language')).toBeInTheDocument()
})
it('should display metadata values', () => {
render(<MetadataFieldList {...defaultProps} />)
expect(screen.getByText('Test Book')).toBeInTheDocument()
expect(screen.getByText('Test Author')).toBeInTheDocument()
})
it('should display dash for empty values', () => {
const props = {
...defaultProps,
metadataParams: {
documentType: 'book' as const,
metadata: {},
},
}
render(<MetadataFieldList {...props} />)
const dashes = screen.getAllByText('-')
expect(dashes.length).toBeGreaterThan(0)
})
})
describe('select fields', () => {
it('should display language name for select field', () => {
const props = {
...defaultProps,
metadataParams: {
documentType: 'book' as const,
metadata: { language: 'en' },
},
}
render(<MetadataFieldList {...props} />)
expect(screen.getByText('English')).toBeInTheDocument()
})
it('should display dash for unknown select value', () => {
const props = {
...defaultProps,
metadataParams: {
documentType: 'book' as const,
metadata: { language: 'unknown' },
},
}
render(<MetadataFieldList {...props} />)
const dashes = screen.getAllByText('-')
expect(dashes.length).toBeGreaterThan(0)
})
})
describe('edit mode', () => {
it('should render input fields in edit mode', () => {
render(<MetadataFieldList {...defaultProps} canEdit />)
const inputs = screen.getAllByRole('textbox')
expect(inputs.length).toBeGreaterThan(0)
})
})
describe('originInfo and technicalParameters', () => {
it('should use docDetail for originInfo fields', () => {
const docDetail = {
source: 'Web Upload',
} as unknown as FullDocumentDetail
render(
<MetadataFieldList
{...defaultProps}
mainField="originInfo"
docDetail={docDetail}
/>,
)
expect(screen.getByText('Source')).toBeInTheDocument()
})
it('should use docDetail for technicalParameters fields', () => {
const docDetail = {
hit_count: 100,
segment_count: 10,
} as unknown as FullDocumentDetail
render(
<MetadataFieldList
{...defaultProps}
mainField="technicalParameters"
docDetail={docDetail}
/>,
)
expect(screen.getByText('Hit Count')).toBeInTheDocument()
})
it('should use render function for fields with custom render', () => {
const docDetail = {
hit_count: 100,
segment_count: 10,
} as unknown as FullDocumentDetail
render(
<MetadataFieldList
{...defaultProps}
mainField="technicalParameters"
docDetail={docDetail}
/>,
)
expect(screen.getByText('100 (10 segments)')).toBeInTheDocument()
})
})
describe('category maps', () => {
it('should use bookCategoryMap for book category field', () => {
const props = {
...defaultProps,
metadataParams: {
documentType: 'book' as const,
metadata: { category: 'fiction' },
},
}
render(<MetadataFieldList {...props} />)
expect(screen.getByText('Title')).toBeInTheDocument()
})
it('should use personalDocCategoryMap for personal_document', () => {
const props = {
...defaultProps,
mainField: 'personal_document' as const,
metadataParams: {
documentType: 'personal_document' as const,
metadata: { document_type: 'resume' },
},
}
render(<MetadataFieldList {...props} />)
expect(screen.getByText('Resume')).toBeInTheDocument()
})
it('should use businessDocCategoryMap for business_document', () => {
const props = {
...defaultProps,
mainField: 'business_document' as const,
metadataParams: {
documentType: 'business_document' as const,
metadata: { document_type: 'contract' },
},
}
render(<MetadataFieldList {...props} />)
expect(screen.getByText('Contract')).toBeInTheDocument()
})
})
describe('memoization', () => {
it('should be memoized', () => {
const { container, rerender } = render(<MetadataFieldList {...defaultProps} />)
const firstRender = container.innerHTML
rerender(<MetadataFieldList {...defaultProps} />)
expect(container.innerHTML).toBe(firstRender)
})
})
})

View File

@ -0,0 +1,85 @@
import type { FC } from 'react'
import type { MetadataState } from '../hooks'
import type { metadataType } from '@/hooks/use-metadata'
import type { FullDocumentDetail } from '@/models/datasets'
import { get } from 'es-toolkit/compat'
import { memo } from 'react'
import { useBookCategories, useBusinessDocCategories, useLanguages, useMetadataMap, usePersonalDocCategories } from '@/hooks/use-metadata'
import FieldInfo from './field-info'
import { map2Options } from './utils'
export type MetadataFieldListProps = {
mainField: metadataType | ''
canEdit: boolean
docDetail?: FullDocumentDetail
metadataParams: MetadataState
onUpdateField: (field: string, value: string) => void
}
const MetadataFieldList: FC<MetadataFieldListProps> = ({
mainField,
canEdit,
docDetail,
metadataParams,
onUpdateField,
}) => {
const metadataMap = useMetadataMap()
const languageMap = useLanguages()
const bookCategoryMap = useBookCategories()
const personalDocCategoryMap = usePersonalDocCategories()
const businessDocCategoryMap = useBusinessDocCategories()
if (!mainField)
return null
const fieldMap = metadataMap[mainField]?.subFieldsMap
const sourceData = ['originInfo', 'technicalParameters'].includes(mainField) ? docDetail : metadataParams.metadata
const getTargetMap = (field: string): Record<string, string> => {
if (field === 'language')
return languageMap
if (field === 'category' && mainField === 'book')
return bookCategoryMap
if (field === 'document_type') {
if (mainField === 'personal_document')
return personalDocCategoryMap
if (mainField === 'business_document')
return businessDocCategoryMap
}
return {}
}
const getTargetValue = (field: string): string => {
const val = get(sourceData, field, '')
if (!val && val !== 0)
return '-'
if (fieldMap[field]?.inputType === 'select')
return getTargetMap(field)[val as string] || '-'
if (fieldMap[field]?.render) {
const rendered = fieldMap[field]?.render?.(val, field === 'hit_count' ? get(sourceData, 'segment_count', 0) as number : undefined)
return typeof rendered === 'string' ? rendered : String(rendered ?? '-')
}
return String(val)
}
return (
<div className="flex flex-col gap-1">
{Object.keys(fieldMap).map(field => (
<FieldInfo
key={fieldMap[field]?.label}
label={fieldMap[field]?.label}
displayedValue={getTargetValue(field)}
value={get(sourceData, field, '')}
inputType={fieldMap[field]?.inputType || 'input'}
showEdit={canEdit}
onUpdate={(val) => {
onUpdateField(field, val)
}}
selectOptions={map2Options(getTargetMap(field))}
/>
))}
</div>
)
}
export default memo(MetadataFieldList)

View File

@ -0,0 +1,56 @@
import { render } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import TypeIcon from './type-icon'
describe('TypeIcon', () => {
describe('rendering', () => {
it('should render without crashing', () => {
const { container } = render(<TypeIcon iconName="book" />)
expect(container.firstChild).toBeInTheDocument()
})
it('should apply commonIcon class', () => {
const { container } = render(<TypeIcon iconName="book" />)
const icon = container.firstChild as HTMLElement
expect(icon.className).toContain('commonIcon')
})
it('should apply icon-specific class based on iconName', () => {
const { container } = render(<TypeIcon iconName="book" />)
const icon = container.firstChild as HTMLElement
expect(icon.className).toContain('bookIcon')
})
it('should apply additional className when provided', () => {
const { container } = render(<TypeIcon iconName="book" className="custom-class" />)
const icon = container.firstChild as HTMLElement
expect(icon.className).toContain('custom-class')
})
it('should handle empty className', () => {
const { container } = render(<TypeIcon iconName="book" className="" />)
const icon = container.firstChild as HTMLElement
expect(icon).toBeInTheDocument()
})
it('should handle different icon names', () => {
const iconNames = ['book', 'paper', 'web_page', 'social_media_post', 'wikipedia_entry', 'personal_document', 'business_document']
iconNames.forEach((iconName) => {
const { container } = render(<TypeIcon iconName={iconName} />)
const icon = container.firstChild as HTMLElement
expect(icon.className).toContain(`${iconName}Icon`)
})
})
})
describe('memoization', () => {
it('should be memoized', () => {
const { container, rerender } = render(<TypeIcon iconName="book" />)
const firstRender = container.innerHTML
rerender(<TypeIcon iconName="book" />)
expect(container.innerHTML).toBe(firstRender)
})
})
})

View File

@ -0,0 +1,17 @@
import type { FC } from 'react'
import { memo } from 'react'
import { cn } from '@/utils/classnames'
import s from '../style.module.css'
export type TypeIconProps = {
iconName: string
className?: string
}
const TypeIcon: FC<TypeIconProps> = ({ iconName, className = '' }) => {
return (
<div className={cn(s.commonIcon, s[`${iconName}Icon`], className)} />
)
}
export default memo(TypeIcon)

View File

@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest'
import { map2Options } from './utils'
describe('map2Options', () => {
it('should convert a map to array of options', () => {
const map = {
en: 'English',
zh: 'Chinese',
}
const result = map2Options(map)
expect(result).toEqual([
{ value: 'en', name: 'English' },
{ value: 'zh', name: 'Chinese' },
])
})
it('should return empty array for empty map', () => {
const map = {}
const result = map2Options(map)
expect(result).toEqual([])
})
it('should handle single entry map', () => {
const map = { key: 'value' }
const result = map2Options(map)
expect(result).toEqual([{ value: 'key', name: 'value' }])
})
it('should handle map with special characters in keys', () => {
const map = {
'key-with-dash': 'Value 1',
'key_with_underscore': 'Value 2',
}
const result = map2Options(map)
expect(result).toContainEqual({ value: 'key-with-dash', name: 'Value 1' })
expect(result).toContainEqual({ value: 'key_with_underscore', name: 'Value 2' })
})
it('should preserve order of keys', () => {
const map = {
a: 'Alpha',
b: 'Beta',
c: 'Gamma',
}
const result = map2Options(map)
const values = result.map(opt => opt.value)
expect(values).toEqual(['a', 'b', 'c'])
})
})

View File

@ -0,0 +1,3 @@
export const map2Options = (map: { [key: string]: string }) => {
return Object.keys(map).map(key => ({ value: key, name: map[key] }))
}

View File

@ -0,0 +1,3 @@
export { useMetadataEditor } from './use-metadata-editor'
export type { MetadataState } from './use-metadata-editor'
export { useMetadataSave } from './use-metadata-save'

View File

@ -0,0 +1,333 @@
import type { FullDocumentDetail } from '@/models/datasets'
import { act, renderHook } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { useMetadataEditor } from './use-metadata-editor'
const createMockDocDetail = (overrides: Record<string, unknown> = {}): FullDocumentDetail => ({
id: 'doc-1',
position: 1,
data_source_type: 'upload_file',
data_source_info: {},
data_source_detail_dict: {},
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: 'book',
doc_metadata: { title: 'Test Book', author: 'Test Author' },
display_status: 'available',
word_count: 100,
hit_count: 10,
doc_form: 'text_model',
segment_count: 5,
...overrides,
}) as unknown as FullDocumentDetail
describe('useMetadataEditor', () => {
describe('initial state', () => {
it('should initialize with edit mode when no doc_type', () => {
const { result } = renderHook(() =>
useMetadataEditor({ docDetail: undefined }),
)
expect(result.current.editStatus).toBe(true)
expect(result.current.showDocTypes).toBe(true)
expect(result.current.doc_type).toBe('')
})
it('should initialize with view mode when doc_type exists', () => {
const docDetail = createMockDocDetail({ doc_type: 'book' })
const { result } = renderHook(() =>
useMetadataEditor({ docDetail }),
)
expect(result.current.editStatus).toBe(false)
expect(result.current.showDocTypes).toBe(false)
expect(result.current.doc_type).toBe('book')
})
it('should treat "others" doc_type as empty string', () => {
const docDetail = createMockDocDetail({ doc_type: 'others' })
const { result } = renderHook(() =>
useMetadataEditor({ docDetail }),
)
expect(result.current.doc_type).toBe('')
})
it('should initialize metadataParams with doc_metadata', () => {
const docDetail = createMockDocDetail({
doc_type: 'book',
doc_metadata: { title: 'My Book' },
})
const { result } = renderHook(() =>
useMetadataEditor({ docDetail }),
)
expect(result.current.metadataParams.documentType).toBe('book')
expect(result.current.metadataParams.metadata).toEqual({ title: 'My Book' })
})
})
describe('enableEdit', () => {
it('should set editStatus to true', () => {
const docDetail = createMockDocDetail({ doc_type: 'book' })
const { result } = renderHook(() =>
useMetadataEditor({ docDetail }),
)
expect(result.current.editStatus).toBe(false)
act(() => {
result.current.enableEdit()
})
expect(result.current.editStatus).toBe(true)
})
})
describe('confirmDocType', () => {
it('should not do anything when tempDocType is empty', () => {
const { result } = renderHook(() =>
useMetadataEditor({ docDetail: undefined }),
)
act(() => {
result.current.confirmDocType()
})
expect(result.current.showDocTypes).toBe(true)
})
it('should set documentType and close doc type selector', () => {
const { result } = renderHook(() =>
useMetadataEditor({ docDetail: undefined }),
)
act(() => {
result.current.setTempDocType('book')
})
act(() => {
result.current.confirmDocType()
})
expect(result.current.metadataParams.documentType).toBe('book')
expect(result.current.showDocTypes).toBe(false)
expect(result.current.editStatus).toBe(true)
})
it('should preserve metadata when same doc type is selected', () => {
const docDetail = createMockDocDetail({
doc_type: 'book',
doc_metadata: { title: 'Existing Title' },
})
const { result } = renderHook(() =>
useMetadataEditor({ docDetail }),
)
act(() => {
result.current.openDocTypeSelector()
})
act(() => {
result.current.setTempDocType('book')
})
act(() => {
result.current.confirmDocType()
})
expect(result.current.metadataParams.metadata).toEqual({ title: 'Existing Title' })
})
it('should clear metadata when different doc type is selected', () => {
const docDetail = createMockDocDetail({
doc_type: 'book',
doc_metadata: { title: 'Existing Title' },
})
const { result } = renderHook(() =>
useMetadataEditor({ docDetail }),
)
act(() => {
result.current.openDocTypeSelector()
})
act(() => {
result.current.setTempDocType('personal_document')
})
act(() => {
result.current.confirmDocType()
})
expect(result.current.metadataParams.documentType).toBe('personal_document')
expect(result.current.metadataParams.metadata).toEqual({})
})
})
describe('cancelDocType', () => {
it('should restore tempDocType to current documentType', () => {
const docDetail = createMockDocDetail({ doc_type: 'book' })
const { result } = renderHook(() =>
useMetadataEditor({ docDetail }),
)
act(() => {
result.current.openDocTypeSelector()
})
act(() => {
result.current.setTempDocType('personal_document')
})
act(() => {
result.current.cancelDocType()
})
expect(result.current.tempDocType).toBe('book')
expect(result.current.showDocTypes).toBe(false)
})
})
describe('resetToInitial', () => {
it('should reset to initial state from docDetail', () => {
const docDetail = createMockDocDetail({
doc_type: 'book',
doc_metadata: { title: 'Original Title' },
})
const { result } = renderHook(() =>
useMetadataEditor({ docDetail }),
)
act(() => {
result.current.enableEdit()
})
act(() => {
result.current.updateMetadataField('title', 'Modified Title')
})
act(() => {
result.current.resetToInitial()
})
expect(result.current.metadataParams.metadata).toEqual({ title: 'Original Title' })
expect(result.current.editStatus).toBe(false)
})
it('should show doc types when no initial doc_type', () => {
const docDetail = createMockDocDetail({ doc_type: '' })
const { result } = renderHook(() =>
useMetadataEditor({ docDetail }),
)
act(() => {
result.current.setTempDocType('book')
})
act(() => {
result.current.confirmDocType()
})
act(() => {
result.current.resetToInitial()
})
expect(result.current.showDocTypes).toBe(true)
})
})
describe('updateMetadataField', () => {
it('should update a single metadata field', () => {
const docDetail = createMockDocDetail({ doc_type: 'book' })
const { result } = renderHook(() =>
useMetadataEditor({ docDetail }),
)
act(() => {
result.current.updateMetadataField('title', 'New Title')
})
expect(result.current.metadataParams.metadata.title).toBe('New Title')
})
it('should preserve other metadata fields when updating', () => {
const docDetail = createMockDocDetail({
doc_type: 'book',
doc_metadata: { title: 'Title', author: 'Author' },
})
const { result } = renderHook(() =>
useMetadataEditor({ docDetail }),
)
act(() => {
result.current.updateMetadataField('title', 'New Title')
})
expect(result.current.metadataParams.metadata).toEqual({
title: 'New Title',
author: 'Author',
})
})
})
describe('openDocTypeSelector', () => {
it('should set showDocTypes to true', () => {
const docDetail = createMockDocDetail({ doc_type: 'book' })
const { result } = renderHook(() =>
useMetadataEditor({ docDetail }),
)
act(() => {
result.current.enableEdit()
})
expect(result.current.showDocTypes).toBe(false)
act(() => {
result.current.openDocTypeSelector()
})
expect(result.current.showDocTypes).toBe(true)
})
})
describe('effect on docDetail change', () => {
it('should update state when docDetail changes', () => {
const initialDoc = createMockDocDetail({
doc_type: 'book',
doc_metadata: { title: 'Initial' },
})
const { result, rerender } = renderHook(
({ docDetail }) => useMetadataEditor({ docDetail }),
{ initialProps: { docDetail: initialDoc } },
)
expect(result.current.metadataParams.metadata).toEqual({ title: 'Initial' })
const updatedDoc = createMockDocDetail({
doc_type: 'book',
doc_metadata: { title: 'Updated' },
})
rerender({ docDetail: updatedDoc })
expect(result.current.metadataParams.metadata).toEqual({ title: 'Updated' })
})
})
})

View File

@ -0,0 +1,99 @@
import type { DocType, FullDocumentDetail } from '@/models/datasets'
import { useCallback, useEffect, useState } from 'react'
export type MetadataState = {
documentType?: DocType | ''
metadata: Record<string, string>
}
type UseMetadataEditorOptions = {
docDetail?: FullDocumentDetail
}
export function useMetadataEditor({ docDetail }: UseMetadataEditorOptions) {
const doc_metadata = docDetail?.doc_metadata ?? {}
const rawDocType = docDetail?.doc_type ?? ''
const doc_type = rawDocType === 'others' ? '' : rawDocType
const [editStatus, setEditStatus] = useState(!doc_type)
const [metadataParams, setMetadataParams] = useState<MetadataState>(
doc_type
? {
documentType: doc_type as DocType,
metadata: (doc_metadata || {}) as Record<string, string>,
}
: { metadata: {} },
)
const [showDocTypes, setShowDocTypes] = useState(!doc_type)
const [tempDocType, setTempDocType] = useState<DocType | ''>('')
useEffect(() => {
if (docDetail?.doc_type) {
setEditStatus(false)
setShowDocTypes(false)
setTempDocType(doc_type as DocType | '')
setMetadataParams({
documentType: doc_type as DocType | '',
metadata: (docDetail?.doc_metadata || {}) as Record<string, string>,
})
}
}, [docDetail?.doc_type, docDetail?.doc_metadata, doc_type])
const confirmDocType = useCallback(() => {
if (!tempDocType)
return
setMetadataParams({
documentType: tempDocType,
metadata: tempDocType === metadataParams.documentType ? metadataParams.metadata : {},
})
setEditStatus(true)
setShowDocTypes(false)
}, [tempDocType, metadataParams.documentType, metadataParams.metadata])
const cancelDocType = useCallback(() => {
setTempDocType(metadataParams.documentType ?? '')
setEditStatus(true)
setShowDocTypes(false)
}, [metadataParams.documentType])
const enableEdit = useCallback(() => {
setEditStatus(true)
}, [])
const resetToInitial = useCallback(() => {
setMetadataParams({
documentType: doc_type || '',
metadata: { ...docDetail?.doc_metadata },
})
setEditStatus(!doc_type)
if (!doc_type)
setShowDocTypes(true)
}, [doc_type, docDetail?.doc_metadata])
const updateMetadataField = useCallback((field: string, value: string) => {
setMetadataParams(prev => ({
...prev,
metadata: { ...prev.metadata, [field]: value },
}))
}, [])
const openDocTypeSelector = useCallback(() => {
setShowDocTypes(true)
}, [])
return {
doc_type,
editStatus,
setEditStatus,
metadataParams,
showDocTypes,
tempDocType,
setTempDocType,
confirmDocType,
cancelDocType,
enableEdit,
resetToInitial,
updateMetadataField,
openDocTypeSelector,
}
}

View File

@ -0,0 +1,224 @@
import type { MetadataState } from './use-metadata-editor'
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useMetadataSave } from './use-metadata-save'
const mockNotify = vi.fn()
const mockModifyDocMetadata = vi.fn()
vi.mock('use-context-selector', async (importOriginal) => {
const actual = await importOriginal() as object
return {
...actual,
useContext: () => ({ notify: mockNotify }),
}
})
vi.mock('@/service/datasets', () => ({
modifyDocMetadata: (params: unknown) => mockModifyDocMetadata(params),
}))
describe('useMetadataSave', () => {
const defaultOptions = {
datasetId: 'dataset-1',
documentId: 'doc-1',
metadataParams: {
documentType: 'book',
metadata: { title: 'Test Title' },
} as MetadataState,
doc_type: 'book',
onSuccess: vi.fn(),
onUpdate: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
mockModifyDocMetadata.mockResolvedValue({})
})
describe('initial state', () => {
it('should initialize with saveLoading false', () => {
const { result } = renderHook(() =>
useMetadataSave(defaultOptions),
)
expect(result.current.saveLoading).toBe(false)
})
it('should return handleSave function', () => {
const { result } = renderHook(() =>
useMetadataSave(defaultOptions),
)
expect(typeof result.current.handleSave).toBe('function')
})
})
describe('handleSave', () => {
it('should call modifyDocMetadata with correct params', async () => {
const { result } = renderHook(() =>
useMetadataSave(defaultOptions),
)
await act(async () => {
await result.current.handleSave()
})
expect(mockModifyDocMetadata).toHaveBeenCalledWith({
datasetId: 'dataset-1',
documentId: 'doc-1',
body: {
doc_type: 'book',
doc_metadata: { title: 'Test Title' },
},
})
})
it('should use metadataParams.documentType over doc_type', async () => {
const options = {
...defaultOptions,
metadataParams: {
documentType: 'personal_document',
metadata: { name: 'Test' },
} as MetadataState,
doc_type: 'book',
}
const { result } = renderHook(() =>
useMetadataSave(options),
)
await act(async () => {
await result.current.handleSave()
})
expect(mockModifyDocMetadata).toHaveBeenCalledWith({
datasetId: 'dataset-1',
documentId: 'doc-1',
body: {
doc_type: 'personal_document',
doc_metadata: { name: 'Test' },
},
})
})
it('should set saveLoading to true during save', async () => {
let resolvePromise: () => void
mockModifyDocMetadata.mockReturnValue(
new Promise((resolve) => {
resolvePromise = () => resolve({})
}),
)
const { result } = renderHook(() =>
useMetadataSave(defaultOptions),
)
act(() => {
result.current.handleSave()
})
await waitFor(() => {
expect(result.current.saveLoading).toBe(true)
})
await act(async () => {
resolvePromise!()
})
await waitFor(() => {
expect(result.current.saveLoading).toBe(false)
})
})
it('should call onSuccess and onUpdate on successful save', async () => {
const onSuccess = vi.fn()
const onUpdate = vi.fn()
const { result } = renderHook(() =>
useMetadataSave({ ...defaultOptions, onSuccess, onUpdate }),
)
await act(async () => {
await result.current.handleSave()
})
expect(onSuccess).toHaveBeenCalled()
expect(onUpdate).toHaveBeenCalled()
})
it('should show success toast on successful save', async () => {
const { result } = renderHook(() =>
useMetadataSave(defaultOptions),
)
await act(async () => {
await result.current.handleSave()
})
expect(mockNotify).toHaveBeenCalledWith({
type: 'success',
message: expect.any(String),
})
})
it('should show error toast on failed save', async () => {
mockModifyDocMetadata.mockRejectedValue(new Error('Save failed'))
const { result } = renderHook(() =>
useMetadataSave(defaultOptions),
)
await act(async () => {
await result.current.handleSave()
})
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: expect.any(String),
})
})
it('should still call onUpdate and onSuccess even on error', async () => {
mockModifyDocMetadata.mockRejectedValue(new Error('Save failed'))
const onSuccess = vi.fn()
const onUpdate = vi.fn()
const { result } = renderHook(() =>
useMetadataSave({ ...defaultOptions, onSuccess, onUpdate }),
)
await act(async () => {
await result.current.handleSave()
})
expect(onUpdate).toHaveBeenCalled()
expect(onSuccess).toHaveBeenCalled()
})
it('should use empty doc_type when both are empty', async () => {
const options = {
...defaultOptions,
metadataParams: {
documentType: '',
metadata: {},
} as MetadataState,
doc_type: '',
}
const { result } = renderHook(() =>
useMetadataSave(options),
)
await act(async () => {
await result.current.handleSave()
})
expect(mockModifyDocMetadata).toHaveBeenCalledWith({
datasetId: 'dataset-1',
documentId: 'doc-1',
body: {
doc_type: '',
doc_metadata: {},
},
})
})
})
})

View File

@ -0,0 +1,56 @@
import type { MetadataState } from './use-metadata-editor'
import type { CommonResponse } from '@/models/common'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { ToastContext } from '@/app/components/base/toast'
import { modifyDocMetadata } from '@/service/datasets'
import { asyncRunSafe } from '@/utils'
type UseMetadataSaveOptions = {
datasetId: string
documentId: string
metadataParams: MetadataState
doc_type: string
onSuccess: () => void
onUpdate: () => void
}
export function useMetadataSave({
datasetId,
documentId,
metadataParams,
doc_type,
onSuccess,
onUpdate,
}: UseMetadataSaveOptions) {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const [saveLoading, setSaveLoading] = useState(false)
const handleSave = useCallback(async () => {
setSaveLoading(true)
const [e] = await asyncRunSafe<CommonResponse>(modifyDocMetadata({
datasetId,
documentId,
body: {
doc_type: metadataParams.documentType || doc_type || '',
doc_metadata: metadataParams.metadata,
},
}) as Promise<CommonResponse>)
if (!e)
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
else
notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
onUpdate()
onSuccess()
setSaveLoading(false)
}, [datasetId, documentId, metadataParams, doc_type, notify, t, onUpdate, onSuccess])
return {
saveLoading,
handleSave,
}
}

View File

@ -1,348 +1,121 @@
'use client'
import type { FC, ReactNode } from 'react'
import type { inputType, metadataType } from '@/hooks/use-metadata'
import type { CommonResponse } from '@/models/common'
import type { DocType, FullDocumentDetail } from '@/models/datasets'
import type { FC } from 'react'
import type { metadataType } from '@/hooks/use-metadata'
import type { FullDocumentDetail } from '@/models/datasets'
import { PencilIcon } from '@heroicons/react/24/outline'
import { get } from 'es-toolkit/compat'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import AutoHeightTextarea from '@/app/components/base/auto-height-textarea'
import Button from '@/app/components/base/button'
import Divider from '@/app/components/base/divider'
import Input from '@/app/components/base/input'
import Loading from '@/app/components/base/loading'
import Radio from '@/app/components/base/radio'
import { SimpleSelect } from '@/app/components/base/select'
import { ToastContext } from '@/app/components/base/toast'
import Tooltip from '@/app/components/base/tooltip'
import { useBookCategories, useBusinessDocCategories, useLanguages, useMetadataMap, usePersonalDocCategories } from '@/hooks/use-metadata'
import { CUSTOMIZABLE_DOC_TYPES } from '@/models/datasets'
import { modifyDocMetadata } from '@/service/datasets'
import { asyncRunSafe, getTextWidthWithCanvas } from '@/utils'
import { cn } from '@/utils/classnames'
import { useMetadataMap } from '@/hooks/use-metadata'
import { useDocumentContext } from '../context'
import { DocTypeSelector, MetadataFieldList, TypeIcon } from './components'
import { useMetadataEditor, useMetadataSave } from './hooks'
import s from './style.module.css'
const map2Options = (map: { [key: string]: string }) => {
return Object.keys(map).map(key => ({ value: key, name: map[key] }))
}
export { FieldInfo } from './components'
type IFieldInfoProps = {
label: string
value?: string
valueIcon?: ReactNode
displayedValue?: string
defaultValue?: string
showEdit?: boolean
inputType?: inputType
selectOptions?: Array<{ value: string, name: string }>
onUpdate?: (v: any) => void
}
export const FieldInfo: FC<IFieldInfoProps> = ({
label,
value = '',
valueIcon,
displayedValue = '',
defaultValue,
showEdit = false,
inputType = 'input',
selectOptions = [],
onUpdate,
}) => {
const { t } = useTranslation()
const textNeedWrap = getTextWidthWithCanvas(displayedValue) > 190
const editAlignTop = showEdit && inputType === 'textarea'
const readAlignTop = !showEdit && textNeedWrap
const renderContent = () => {
if (!showEdit)
return displayedValue
if (inputType === 'select') {
return (
<SimpleSelect
onSelect={({ value }) => onUpdate?.(value as string)}
items={selectOptions}
defaultValue={value}
className={s.select}
wrapperClassName={s.selectWrapper}
placeholder={`${t('metadata.placeholder.select', { ns: 'datasetDocuments' })}${label}`}
/>
)
}
if (inputType === 'textarea') {
return (
<AutoHeightTextarea
onChange={e => onUpdate?.(e.target.value)}
value={value}
className={s.textArea}
placeholder={`${t('metadata.placeholder.add', { ns: 'datasetDocuments' })}${label}`}
/>
)
}
return (
<Input
onChange={e => onUpdate?.(e.target.value)}
value={value}
defaultValue={defaultValue}
placeholder={`${t('metadata.placeholder.add', { ns: 'datasetDocuments' })}${label}`}
/>
)
}
return (
<div className={cn('flex min-h-5 items-center gap-1 py-0.5 text-xs', editAlignTop && '!items-start', readAlignTop && '!items-start pt-1')}>
<div className={cn('w-[200px] shrink-0 overflow-hidden text-ellipsis whitespace-nowrap text-text-tertiary', editAlignTop && 'pt-1')}>{label}</div>
<div className="flex grow items-center gap-1 text-text-secondary">
{valueIcon}
{renderContent()}
</div>
</div>
)
}
const TypeIcon: FC<{ iconName: string, className?: string }> = ({ iconName, className = '' }) => {
return (
<div className={cn(s.commonIcon, s[`${iconName}Icon`], className)} />
)
}
const IconButton: FC<{
type: DocType
isChecked: boolean
}> = ({ type, isChecked = false }) => {
const metadataMap = useMetadataMap()
return (
<Tooltip
popupContent={metadataMap[type].text}
>
<button type="button" className={cn(s.iconWrapper, 'group', isChecked ? s.iconCheck : '')}>
<TypeIcon
iconName={metadataMap[type].iconName || ''}
className={`group-hover:bg-primary-600 ${isChecked ? '!bg-primary-600' : ''}`}
/>
</button>
</Tooltip>
)
}
type IMetadataProps = {
type MetadataProps = {
docDetail?: FullDocumentDetail
loading: boolean
onUpdate: () => void
}
type MetadataState = {
documentType?: DocType | ''
metadata: Record<string, string>
}
const Metadata: FC<IMetadataProps> = ({ docDetail, loading, onUpdate }) => {
const { doc_metadata = {} } = docDetail || {}
const rawDocType = docDetail?.doc_type ?? ''
const doc_type = rawDocType === 'others' ? '' : rawDocType
const Metadata: FC<MetadataProps> = ({ docDetail, loading, onUpdate }) => {
const { t } = useTranslation()
const metadataMap = useMetadataMap()
const languageMap = useLanguages()
const bookCategoryMap = useBookCategories()
const personalDocCategoryMap = usePersonalDocCategories()
const businessDocCategoryMap = useBusinessDocCategories()
const [editStatus, setEditStatus] = useState(!doc_type) // if no documentType, in editing status by default
// the initial values are according to the documentType
const [metadataParams, setMetadataParams] = useState<MetadataState>(
doc_type
? {
documentType: doc_type as DocType,
metadata: (doc_metadata || {}) as Record<string, string>,
}
: { metadata: {} },
)
const [showDocTypes, setShowDocTypes] = useState(!doc_type) // whether show doc types
const [tempDocType, setTempDocType] = useState<DocType | ''>('') // for remember icon click
const [saveLoading, setSaveLoading] = useState(false)
const datasetId = useDocumentContext(state => state.datasetId)
const documentId = useDocumentContext(state => state.documentId)
const { notify } = useContext(ToastContext)
const datasetId = useDocumentContext(s => s.datasetId)
const documentId = useDocumentContext(s => s.documentId)
const {
doc_type,
editStatus,
setEditStatus,
metadataParams,
showDocTypes,
tempDocType,
setTempDocType,
confirmDocType,
cancelDocType,
enableEdit,
resetToInitial,
updateMetadataField,
openDocTypeSelector,
} = useMetadataEditor({ docDetail })
useEffect(() => {
if (docDetail?.doc_type) {
setEditStatus(false)
setShowDocTypes(false)
setTempDocType(doc_type as DocType | '')
setMetadataParams({
documentType: doc_type as DocType | '',
metadata: (docDetail?.doc_metadata || {}) as Record<string, string>,
})
const { saveLoading, handleSave } = useMetadataSave({
datasetId,
documentId,
metadataParams,
doc_type,
onSuccess: () => setEditStatus(false),
onUpdate,
})
const renderDocTypeDisplay = () => {
const docTypeKey = (doc_type || 'book') as metadataType
if (!editStatus) {
return (
<div className={s.documentTypeShow}>
<TypeIcon iconName={metadataMap[docTypeKey]?.iconName || ''} className={s.iconShow} />
{metadataMap[docTypeKey].text}
</div>
)
}
}, [docDetail?.doc_type, docDetail?.doc_metadata, doc_type])
// confirm doc type
const confirmDocType = () => {
if (!tempDocType)
return
setMetadataParams({
documentType: tempDocType,
metadata: tempDocType === metadataParams.documentType ? metadataParams.metadata : {} as Record<string, string>, // change doc type, clear metadata
})
setEditStatus(true)
setShowDocTypes(false)
}
// cancel doc type
const cancelDocType = () => {
setTempDocType(metadataParams.documentType ?? '')
setEditStatus(true)
setShowDocTypes(false)
}
// show doc type select
const renderSelectDocType = () => {
const { documentType } = metadataParams
if (showDocTypes)
return null
return (
<>
{!doc_type && !documentType && (
<div className={s.documentTypeShow}>
{metadataParams.documentType && (
<>
<div className={s.desc}>{t('metadata.desc', { ns: 'datasetDocuments' })}</div>
<TypeIcon iconName={metadataMap[metadataParams.documentType || 'book'].iconName || ''} className={s.iconShow} />
{metadataMap[metadataParams.documentType || 'book'].text}
{editStatus && (
<div className="ml-1 inline-flex items-center gap-1">
·
<div
onClick={openDocTypeSelector}
className="cursor-pointer hover:text-text-accent"
>
{t('operation.change', { ns: 'common' })}
</div>
</div>
)}
</>
)}
<div className={s.operationWrapper}>
{!doc_type && !documentType && (
<>
<span className={s.title}>{t('metadata.docTypeSelectTitle', { ns: 'datasetDocuments' })}</span>
</>
)}
{documentType && (
<>
<span className={s.title}>{t('metadata.docTypeChangeTitle', { ns: 'datasetDocuments' })}</span>
<span className={s.changeTip}>{t('metadata.docTypeSelectWarning', { ns: 'datasetDocuments' })}</span>
</>
)}
<Radio.Group value={tempDocType ?? documentType ?? ''} onChange={setTempDocType} className={s.radioGroup}>
{CUSTOMIZABLE_DOC_TYPES.map((type, index) => {
const currValue = tempDocType ?? documentType
return (
<Radio key={index} value={type} className={`${s.radio} ${currValue === type ? 'shadow-none' : ''}`}>
<IconButton
type={type}
isChecked={currValue === type}
/>
</Radio>
)
})}
</Radio.Group>
{!doc_type && !documentType && (
<Button
variant="primary"
onClick={confirmDocType}
disabled={!tempDocType}
>
{t('metadata.firstMetaAction', { ns: 'datasetDocuments' })}
</Button>
)}
{documentType && (
<div className={s.opBtnWrapper}>
<Button onClick={confirmDocType} className={`${s.opBtn} ${s.opSaveBtn}`} variant="primary">{t('operation.save', { ns: 'common' })}</Button>
<Button onClick={cancelDocType} className={`${s.opBtn} ${s.opCancelBtn}`}>{t('operation.cancel', { ns: 'common' })}</Button>
</div>
)}
</div>
</>
)
}
// show metadata info and edit
const renderFieldInfos = ({ mainField = 'book', canEdit }: { mainField?: metadataType | '', canEdit?: boolean }) => {
if (!mainField)
return null
const fieldMap = metadataMap[mainField]?.subFieldsMap
const sourceData = ['originInfo', 'technicalParameters'].includes(mainField) ? docDetail : metadataParams.metadata
const getTargetMap = (field: string) => {
if (field === 'language')
return languageMap
if (field === 'category' && mainField === 'book')
return bookCategoryMap
if (field === 'document_type') {
if (mainField === 'personal_document')
return personalDocCategoryMap
if (mainField === 'business_document')
return businessDocCategoryMap
}
return {} as any
}
const getTargetValue = (field: string) => {
const val = get(sourceData, field, '')
if (!val && val !== 0)
return '-'
if (fieldMap[field]?.inputType === 'select')
return getTargetMap(field)[val]
if (fieldMap[field]?.render)
return fieldMap[field]?.render?.(val, field === 'hit_count' ? get(sourceData, 'segment_count', 0) as number : undefined)
return val
}
return (
<div className="flex flex-col gap-1">
{Object.keys(fieldMap).map((field) => {
return (
<FieldInfo
key={fieldMap[field]?.label}
label={fieldMap[field]?.label}
displayedValue={getTargetValue(field)}
value={get(sourceData, field, '')}
inputType={fieldMap[field]?.inputType || 'input'}
showEdit={canEdit}
onUpdate={(val) => {
setMetadataParams(pre => ({ ...pre, metadata: { ...pre.metadata, [field]: val } }))
}}
selectOptions={map2Options(getTargetMap(field))}
/>
)
})}
</div>
)
}
const enabledEdit = () => {
setEditStatus(true)
}
const renderHeaderActions = () => {
if (!editStatus) {
return (
<Button onClick={enableEdit} className={`${s.opBtn} ${s.opEditBtn}`}>
<PencilIcon className={s.opIcon} />
{t('operation.edit', { ns: 'common' })}
</Button>
)
}
const onCancel = () => {
setMetadataParams({ documentType: doc_type || '', metadata: { ...docDetail?.doc_metadata } })
setEditStatus(!doc_type)
if (!doc_type)
setShowDocTypes(true)
}
if (showDocTypes)
return null
const onSave = async () => {
setSaveLoading(true)
const [e] = await asyncRunSafe<CommonResponse>(modifyDocMetadata({
datasetId,
documentId,
body: {
doc_type: metadataParams.documentType || doc_type || '',
doc_metadata: metadataParams.metadata,
},
}) as Promise<CommonResponse>)
if (!e)
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
else
notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
onUpdate?.()
setEditStatus(false)
setSaveLoading(false)
return (
<div className={s.opBtnWrapper}>
<Button onClick={resetToInitial} className={`${s.opBtn} ${s.opCancelBtn}`}>{t('operation.cancel', { ns: 'common' })}</Button>
<Button
onClick={handleSave}
className={`${s.opBtn} ${s.opSaveBtn}`}
variant="primary"
loading={saveLoading}
>
{t('operation.save', { ns: 'common' })}
</Button>
</div>
)
}
return (
@ -353,68 +126,47 @@ const Metadata: FC<IMetadataProps> = ({ docDetail, loading, onUpdate }) => {
<>
<div className={s.titleWrapper}>
<span className={s.title}>{t('metadata.title', { ns: 'datasetDocuments' })}</span>
{!editStatus
? (
<Button onClick={enabledEdit} className={`${s.opBtn} ${s.opEditBtn}`}>
<PencilIcon className={s.opIcon} />
{t('operation.edit', { ns: 'common' })}
</Button>
)
: showDocTypes
? null
: (
<div className={s.opBtnWrapper}>
<Button onClick={onCancel} className={`${s.opBtn} ${s.opCancelBtn}`}>{t('operation.cancel', { ns: 'common' })}</Button>
<Button
onClick={onSave}
className={`${s.opBtn} ${s.opSaveBtn}`}
variant="primary"
loading={saveLoading}
>
{t('operation.save', { ns: 'common' })}
</Button>
</div>
)}
{renderHeaderActions()}
</div>
{/* show selected doc type and changing entry */}
{!editStatus
? (
<div className={s.documentTypeShow}>
<TypeIcon iconName={metadataMap[doc_type || 'book']?.iconName || ''} className={s.iconShow} />
{metadataMap[doc_type || 'book'].text}
</div>
)
: showDocTypes
? null
: (
<div className={s.documentTypeShow}>
{metadataParams.documentType && (
<>
<TypeIcon iconName={metadataMap[metadataParams.documentType || 'book'].iconName || ''} className={s.iconShow} />
{metadataMap[metadataParams.documentType || 'book'].text}
{editStatus && (
<div className="ml-1 inline-flex items-center gap-1">
·
<div
onClick={() => { setShowDocTypes(true) }}
className="cursor-pointer hover:text-text-accent"
>
{t('operation.change', { ns: 'common' })}
</div>
</div>
)}
</>
)}
</div>
)}
{renderDocTypeDisplay()}
{(!doc_type && showDocTypes) ? null : <Divider />}
{showDocTypes ? renderSelectDocType() : renderFieldInfos({ mainField: metadataParams.documentType, canEdit: editStatus })}
{/* show fixed fields */}
{showDocTypes
? (
<DocTypeSelector
documentType={metadataParams.documentType}
tempDocType={tempDocType}
doc_type={doc_type}
onTempDocTypeChange={setTempDocType}
onConfirm={confirmDocType}
onCancel={cancelDocType}
/>
)
: (
<MetadataFieldList
mainField={metadataParams.documentType || ''}
canEdit={editStatus}
docDetail={docDetail}
metadataParams={metadataParams}
onUpdateField={updateMetadataField}
/>
)}
<Divider />
{renderFieldInfos({ mainField: 'originInfo', canEdit: false })}
<MetadataFieldList
mainField="originInfo"
canEdit={false}
docDetail={docDetail}
metadataParams={metadataParams}
onUpdateField={updateMetadataField}
/>
<div className={`${s.title} mt-8`}>{metadataMap.technicalParameters.text}</div>
<Divider />
{renderFieldInfos({ mainField: 'technicalParameters', canEdit: false })}
<MetadataFieldList
mainField="technicalParameters"
canEdit={false}
docDetail={docDetail}
metadataParams={metadataParams}
onUpdateField={updateMetadataField}
/>
</>
)}
</div>

View File

@ -1784,12 +1784,9 @@
"count": 1
}
},
"app/components/datasets/documents/detail/metadata/index.tsx": {
"app/components/datasets/documents/detail/metadata/hooks/use-metadata-editor.ts": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 4
},
"ts/no-explicit-any": {
"count": 2
}
},
"app/components/datasets/documents/detail/new-segment.tsx": {