mirror of
https://github.com/langgenius/dify.git
synced 2026-03-31 02:48:49 +08:00
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:
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
@ -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'
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
@ -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'])
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,3 @@
|
||||
export const map2Options = (map: { [key: string]: string }) => {
|
||||
return Object.keys(map).map(key => ({ value: key, name: map[key] }))
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
export { useMetadataEditor } from './use-metadata-editor'
|
||||
export type { MetadataState } from './use-metadata-editor'
|
||||
export { useMetadataSave } from './use-metadata-save'
|
||||
@ -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' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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: {},
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user