diff --git a/web/app/components/datasets/documents/detail/metadata/components/doc-type-selector.spec.tsx b/web/app/components/datasets/documents/detail/metadata/components/doc-type-selector.spec.tsx new file mode 100644 index 0000000000..25102ea119 --- /dev/null +++ b/web/app/components/datasets/documents/detail/metadata/components/doc-type-selector.spec.tsx @@ -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() + expect(screen.getByText(/metadata.desc/i)).toBeInTheDocument() + }) + + it('should render doc type select title for first time', () => { + render() + expect(screen.getByText(/metadata.docTypeSelectTitle/i)).toBeInTheDocument() + }) + + it('should render first meta action button', () => { + render() + expect(screen.getByText(/metadata.firstMetaAction/i)).toBeInTheDocument() + }) + + it('should disable confirm button when no temp doc type selected', () => { + render() + const button = screen.getByText(/metadata.firstMetaAction/i) + expect(button).toBeDisabled() + }) + + it('should enable confirm button when temp doc type is selected', () => { + render() + 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() + expect(screen.getByText(/metadata.docTypeChangeTitle/i)).toBeInTheDocument() + }) + + it('should render warning text when documentType exists', () => { + render() + expect(screen.getByText(/metadata.docTypeSelectWarning/i)).toBeInTheDocument() + }) + + it('should render save and cancel buttons', () => { + render() + expect(screen.getByText(/operation.save/i)).toBeInTheDocument() + expect(screen.getByText(/operation.cancel/i)).toBeInTheDocument() + }) + + it('should not render first meta action button', () => { + render() + expect(screen.queryByText(/metadata.firstMetaAction/i)).not.toBeInTheDocument() + }) + }) + + describe('radio group', () => { + it('should render icon buttons for each doc type', () => { + const { container } = render() + // 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() + + // 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() + // 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() + + 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() + + 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() + + const saveButton = screen.getByText(/operation.save/i) + fireEvent.click(saveButton) + + expect(onConfirm).toHaveBeenCalled() + }) + }) + + describe('memoization', () => { + it('should be memoized', () => { + const { container, rerender } = render() + const firstRender = container.innerHTML + + rerender() + expect(container.innerHTML).toBe(firstRender) + }) + }) +}) diff --git a/web/app/components/datasets/documents/detail/metadata/components/doc-type-selector.tsx b/web/app/components/datasets/documents/detail/metadata/components/doc-type-selector.tsx new file mode 100644 index 0000000000..c85626d778 --- /dev/null +++ b/web/app/components/datasets/documents/detail/metadata/components/doc-type-selector.tsx @@ -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 = ({ + documentType, + tempDocType, + doc_type, + onTempDocTypeChange, + onConfirm, + onCancel, +}) => { + const { t } = useTranslation() + const isFirstTime = !doc_type && !documentType + const currValue = tempDocType ?? documentType + + return ( + <> + {isFirstTime && ( +
{t('metadata.desc', { ns: 'datasetDocuments' })}
+ )} +
+ {isFirstTime && ( + {t('metadata.docTypeSelectTitle', { ns: 'datasetDocuments' })} + )} + {documentType && ( + <> + {t('metadata.docTypeChangeTitle', { ns: 'datasetDocuments' })} + {t('metadata.docTypeSelectWarning', { ns: 'datasetDocuments' })} + + )} + + {CUSTOMIZABLE_DOC_TYPES.map(type => ( + + + + ))} + + {isFirstTime && ( + + )} + {documentType && ( +
+ + +
+ )} +
+ + ) +} + +export default memo(DocTypeSelector) diff --git a/web/app/components/datasets/documents/detail/metadata/components/field-info.spec.tsx b/web/app/components/datasets/documents/detail/metadata/components/field-info.spec.tsx new file mode 100644 index 0000000000..84ed7760c0 --- /dev/null +++ b/web/app/components/datasets/documents/detail/metadata/components/field-info.spec.tsx @@ -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() + + expect(screen.getByText('Title')).toBeInTheDocument() + expect(screen.getByText('Test Value')).toBeInTheDocument() + }) + + it('should render valueIcon when provided', () => { + const icon = Icon + render() + + expect(screen.getByTestId('test-icon')).toBeInTheDocument() + }) + + it('should render displayed value when not in edit mode', () => { + render() + + expect(screen.getByText('Display Text')).toBeInTheDocument() + }) + }) + + describe('edit mode - input type', () => { + it('should render input when showEdit is true and inputType is input', () => { + render() + + 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() + + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: 'new value' } }) + + expect(onUpdate).toHaveBeenCalledWith('new value') + }) + + it('should render with defaultValue', () => { + render() + + const input = screen.getByRole('textbox') + expect(input).toBeInTheDocument() + }) + }) + + describe('edit mode - textarea type', () => { + it('should render textarea when inputType is textarea', () => { + render() + + 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() + + 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( + , + ) + + expect(screen.getByText('English')).toBeInTheDocument() + }) + + it('should call onUpdate when select value changes', () => { + const onUpdate = vi.fn() + render( + , + ) + + 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( + , + ) + + const wrapper = container.firstChild + expect(wrapper).toHaveClass('!items-start') + }) + }) + + describe('default props', () => { + it('should use default empty string for value', () => { + render() + + const input = screen.getByRole('textbox') + expect(input).toHaveValue('') + }) + + it('should use default input type', () => { + render() + + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) + + it('should use empty select options by default', () => { + const { container } = render() + expect(container.querySelector('button')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/documents/detail/metadata/components/field-info.tsx b/web/app/components/datasets/documents/detail/metadata/components/field-info.tsx new file mode 100644 index 0000000000..ea04786d4d --- /dev/null +++ b/web/app/components/datasets/documents/detail/metadata/components/field-info.tsx @@ -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 = ({ + 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 ( + 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 ( + onUpdate?.(e.target.value)} + value={value} + className={s.textArea} + placeholder={`${t('metadata.placeholder.add', { ns: 'datasetDocuments' })}${label}`} + /> + ) + } + + return ( + onUpdate?.(e.target.value)} + value={value} + defaultValue={defaultValue} + placeholder={`${t('metadata.placeholder.add', { ns: 'datasetDocuments' })}${label}`} + /> + ) + } + + return ( +
+
{label}
+
+ {valueIcon} + {renderContent()} +
+
+ ) +} + +export default memo(FieldInfo) diff --git a/web/app/components/datasets/documents/detail/metadata/components/icon-button.spec.tsx b/web/app/components/datasets/documents/detail/metadata/components/icon-button.spec.tsx new file mode 100644 index 0000000000..425b669dfd --- /dev/null +++ b/web/app/components/datasets/documents/detail/metadata/components/icon-button.spec.tsx @@ -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() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should render as a button element', () => { + render() + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('should have type="button" attribute', () => { + render() + const button = screen.getByRole('button') + expect(button).toHaveAttribute('type', 'button') + }) + }) + + describe('checked state', () => { + it('should apply iconCheck class when isChecked is true', () => { + render() + const button = screen.getByRole('button') + expect(button.className).toContain('iconCheck') + }) + + it('should not apply iconCheck class when isChecked is false', () => { + render() + const button = screen.getByRole('button') + expect(button.className).not.toContain('iconCheck') + }) + + it('should apply primary color to TypeIcon when checked', () => { + const { container } = render() + 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() + const button = screen.getByRole('button') + expect(button).toBeInTheDocument() + }) + }) + + describe('different doc types', () => { + it('should render book type', () => { + const { container } = render() + const icon = container.querySelector('div[class*="bookIcon"]') + expect(icon).toBeInTheDocument() + }) + + it('should render paper type', () => { + const { container } = render() + const icon = container.querySelector('div[class*="paperIcon"]') + expect(icon).toBeInTheDocument() + }) + + it('should render personal_document type', () => { + const { container } = render() + const icon = container.querySelector('div[class*="personal_documentIcon"]') + expect(icon).toBeInTheDocument() + }) + + it('should render business_document type', () => { + const { container } = render() + const icon = container.querySelector('div[class*="business_documentIcon"]') + expect(icon).toBeInTheDocument() + }) + }) + + describe('hover states', () => { + it('should have group class for hover styles', () => { + render() + const button = screen.getByRole('button') + expect(button.className).toContain('group') + }) + }) + + describe('memoization', () => { + it('should be memoized', () => { + const { container, rerender } = render() + const firstRender = container.innerHTML + + rerender() + expect(container.innerHTML).toBe(firstRender) + }) + }) +}) diff --git a/web/app/components/datasets/documents/detail/metadata/components/icon-button.tsx b/web/app/components/datasets/documents/detail/metadata/components/icon-button.tsx new file mode 100644 index 0000000000..a2175e5af3 --- /dev/null +++ b/web/app/components/datasets/documents/detail/metadata/components/icon-button.tsx @@ -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 = ({ type, isChecked = false }) => { + const metadataMap = useMetadataMap() + + return ( + + + + ) +} + +export default memo(IconButton) diff --git a/web/app/components/datasets/documents/detail/metadata/components/index.ts b/web/app/components/datasets/documents/detail/metadata/components/index.ts new file mode 100644 index 0000000000..dd628c576d --- /dev/null +++ b/web/app/components/datasets/documents/detail/metadata/components/index.ts @@ -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' diff --git a/web/app/components/datasets/documents/detail/metadata/components/metadata-field-list.spec.tsx b/web/app/components/datasets/documents/detail/metadata/components/metadata-field-list.spec.tsx new file mode 100644 index 0000000000..273a0b6498 --- /dev/null +++ b/web/app/components/datasets/documents/detail/metadata/components/metadata-field-list.spec.tsx @@ -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( + , + ) + expect(container.firstChild).toBeNull() + }) + + it('should render fields for book type', () => { + render() + + expect(screen.getByText('Title')).toBeInTheDocument() + expect(screen.getByText('Author')).toBeInTheDocument() + expect(screen.getByText('Language')).toBeInTheDocument() + }) + + it('should display metadata values', () => { + render() + + 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() + + 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() + + 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() + + const dashes = screen.getAllByText('-') + expect(dashes.length).toBeGreaterThan(0) + }) + }) + + describe('edit mode', () => { + it('should render input fields in edit mode', () => { + render() + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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() + 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() + 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() + expect(screen.getByText('Contract')).toBeInTheDocument() + }) + }) + + describe('memoization', () => { + it('should be memoized', () => { + const { container, rerender } = render() + const firstRender = container.innerHTML + + rerender() + expect(container.innerHTML).toBe(firstRender) + }) + }) +}) diff --git a/web/app/components/datasets/documents/detail/metadata/components/metadata-field-list.tsx b/web/app/components/datasets/documents/detail/metadata/components/metadata-field-list.tsx new file mode 100644 index 0000000000..565bab7407 --- /dev/null +++ b/web/app/components/datasets/documents/detail/metadata/components/metadata-field-list.tsx @@ -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 = ({ + 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 => { + 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 ( +
+ {Object.keys(fieldMap).map(field => ( + { + onUpdateField(field, val) + }} + selectOptions={map2Options(getTargetMap(field))} + /> + ))} +
+ ) +} + +export default memo(MetadataFieldList) diff --git a/web/app/components/datasets/documents/detail/metadata/components/type-icon.spec.tsx b/web/app/components/datasets/documents/detail/metadata/components/type-icon.spec.tsx new file mode 100644 index 0000000000..f53d552879 --- /dev/null +++ b/web/app/components/datasets/documents/detail/metadata/components/type-icon.spec.tsx @@ -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() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should apply commonIcon class', () => { + const { container } = render() + const icon = container.firstChild as HTMLElement + expect(icon.className).toContain('commonIcon') + }) + + it('should apply icon-specific class based on iconName', () => { + const { container } = render() + const icon = container.firstChild as HTMLElement + expect(icon.className).toContain('bookIcon') + }) + + it('should apply additional className when provided', () => { + const { container } = render() + const icon = container.firstChild as HTMLElement + expect(icon.className).toContain('custom-class') + }) + + it('should handle empty className', () => { + const { container } = render() + 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() + const icon = container.firstChild as HTMLElement + expect(icon.className).toContain(`${iconName}Icon`) + }) + }) + }) + + describe('memoization', () => { + it('should be memoized', () => { + const { container, rerender } = render() + const firstRender = container.innerHTML + + rerender() + expect(container.innerHTML).toBe(firstRender) + }) + }) +}) diff --git a/web/app/components/datasets/documents/detail/metadata/components/type-icon.tsx b/web/app/components/datasets/documents/detail/metadata/components/type-icon.tsx new file mode 100644 index 0000000000..0908760cda --- /dev/null +++ b/web/app/components/datasets/documents/detail/metadata/components/type-icon.tsx @@ -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 = ({ iconName, className = '' }) => { + return ( +
+ ) +} + +export default memo(TypeIcon) diff --git a/web/app/components/datasets/documents/detail/metadata/components/utils.spec.ts b/web/app/components/datasets/documents/detail/metadata/components/utils.spec.ts new file mode 100644 index 0000000000..9dc43003d5 --- /dev/null +++ b/web/app/components/datasets/documents/detail/metadata/components/utils.spec.ts @@ -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']) + }) +}) diff --git a/web/app/components/datasets/documents/detail/metadata/components/utils.ts b/web/app/components/datasets/documents/detail/metadata/components/utils.ts new file mode 100644 index 0000000000..e5a3764e59 --- /dev/null +++ b/web/app/components/datasets/documents/detail/metadata/components/utils.ts @@ -0,0 +1,3 @@ +export const map2Options = (map: { [key: string]: string }) => { + return Object.keys(map).map(key => ({ value: key, name: map[key] })) +} diff --git a/web/app/components/datasets/documents/detail/metadata/hooks/index.ts b/web/app/components/datasets/documents/detail/metadata/hooks/index.ts new file mode 100644 index 0000000000..17a7d4faaf --- /dev/null +++ b/web/app/components/datasets/documents/detail/metadata/hooks/index.ts @@ -0,0 +1,3 @@ +export { useMetadataEditor } from './use-metadata-editor' +export type { MetadataState } from './use-metadata-editor' +export { useMetadataSave } from './use-metadata-save' diff --git a/web/app/components/datasets/documents/detail/metadata/hooks/use-metadata-editor.spec.ts b/web/app/components/datasets/documents/detail/metadata/hooks/use-metadata-editor.spec.ts new file mode 100644 index 0000000000..4b960e4a06 --- /dev/null +++ b/web/app/components/datasets/documents/detail/metadata/hooks/use-metadata-editor.spec.ts @@ -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 = {}): 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' }) + }) + }) +}) diff --git a/web/app/components/datasets/documents/detail/metadata/hooks/use-metadata-editor.ts b/web/app/components/datasets/documents/detail/metadata/hooks/use-metadata-editor.ts new file mode 100644 index 0000000000..df471cc949 --- /dev/null +++ b/web/app/components/datasets/documents/detail/metadata/hooks/use-metadata-editor.ts @@ -0,0 +1,99 @@ +import type { DocType, FullDocumentDetail } from '@/models/datasets' +import { useCallback, useEffect, useState } from 'react' + +export type MetadataState = { + documentType?: DocType | '' + metadata: Record +} + +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( + doc_type + ? { + documentType: doc_type as DocType, + metadata: (doc_metadata || {}) as Record, + } + : { metadata: {} }, + ) + const [showDocTypes, setShowDocTypes] = useState(!doc_type) + const [tempDocType, setTempDocType] = useState('') + + 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, + }) + } + }, [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, + } +} diff --git a/web/app/components/datasets/documents/detail/metadata/hooks/use-metadata-save.spec.ts b/web/app/components/datasets/documents/detail/metadata/hooks/use-metadata-save.spec.ts new file mode 100644 index 0000000000..6f6ffba5e6 --- /dev/null +++ b/web/app/components/datasets/documents/detail/metadata/hooks/use-metadata-save.spec.ts @@ -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: {}, + }, + }) + }) + }) +}) diff --git a/web/app/components/datasets/documents/detail/metadata/hooks/use-metadata-save.ts b/web/app/components/datasets/documents/detail/metadata/hooks/use-metadata-save.ts new file mode 100644 index 0000000000..064e6692c0 --- /dev/null +++ b/web/app/components/datasets/documents/detail/metadata/hooks/use-metadata-save.ts @@ -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(modifyDocMetadata({ + datasetId, + documentId, + body: { + doc_type: metadataParams.documentType || doc_type || '', + doc_metadata: metadataParams.metadata, + }, + }) as Promise) + + 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, + } +} diff --git a/web/app/components/datasets/documents/detail/metadata/index.tsx b/web/app/components/datasets/documents/detail/metadata/index.tsx index 7d1c65b1cd..d329a5a33c 100644 --- a/web/app/components/datasets/documents/detail/metadata/index.tsx +++ b/web/app/components/datasets/documents/detail/metadata/index.tsx @@ -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 = ({ - 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 ( - 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 ( - onUpdate?.(e.target.value)} - value={value} - className={s.textArea} - placeholder={`${t('metadata.placeholder.add', { ns: 'datasetDocuments' })}${label}`} - /> - ) - } - - return ( - onUpdate?.(e.target.value)} - value={value} - defaultValue={defaultValue} - placeholder={`${t('metadata.placeholder.add', { ns: 'datasetDocuments' })}${label}`} - /> - ) - } - - return ( -
-
{label}
-
- {valueIcon} - {renderContent()} -
-
- ) -} - -const TypeIcon: FC<{ iconName: string, className?: string }> = ({ iconName, className = '' }) => { - return ( -
- ) -} - -const IconButton: FC<{ - type: DocType - isChecked: boolean -}> = ({ type, isChecked = false }) => { - const metadataMap = useMetadataMap() - - return ( - - - - ) -} - -type IMetadataProps = { +type MetadataProps = { docDetail?: FullDocumentDetail loading: boolean onUpdate: () => void } -type MetadataState = { - documentType?: DocType | '' - metadata: Record -} - -const Metadata: FC = ({ docDetail, loading, onUpdate }) => { - const { doc_metadata = {} } = docDetail || {} - const rawDocType = docDetail?.doc_type ?? '' - const doc_type = rawDocType === 'others' ? '' : rawDocType - +const Metadata: FC = ({ 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( - doc_type - ? { - documentType: doc_type as DocType, - metadata: (doc_metadata || {}) as Record, - } - : { metadata: {} }, - ) - const [showDocTypes, setShowDocTypes] = useState(!doc_type) // whether show doc types - const [tempDocType, setTempDocType] = useState('') // 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, - }) + 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 ( +
+ + {metadataMap[docTypeKey].text} +
+ ) } - }, [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, // 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 && ( +
+ {metadataParams.documentType && ( <> -
{t('metadata.desc', { ns: 'datasetDocuments' })}
+ + {metadataMap[metadataParams.documentType || 'book'].text} + {editStatus && ( +
+ · +
+ {t('operation.change', { ns: 'common' })} +
+
+ )} )} -
- {!doc_type && !documentType && ( - <> - {t('metadata.docTypeSelectTitle', { ns: 'datasetDocuments' })} - - )} - {documentType && ( - <> - {t('metadata.docTypeChangeTitle', { ns: 'datasetDocuments' })} - {t('metadata.docTypeSelectWarning', { ns: 'datasetDocuments' })} - - )} - - {CUSTOMIZABLE_DOC_TYPES.map((type, index) => { - const currValue = tempDocType ?? documentType - return ( - - - - ) - })} - - {!doc_type && !documentType && ( - - )} - {documentType && ( -
- - -
- )} -
- - ) - } - - // 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 ( -
- {Object.keys(fieldMap).map((field) => { - return ( - { - setMetadataParams(pre => ({ ...pre, metadata: { ...pre.metadata, [field]: val } })) - }} - selectOptions={map2Options(getTargetMap(field))} - /> - ) - })}
) } - const enabledEdit = () => { - setEditStatus(true) - } + const renderHeaderActions = () => { + if (!editStatus) { + return ( + + ) + } - 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(modifyDocMetadata({ - datasetId, - documentId, - body: { - doc_type: metadataParams.documentType || doc_type || '', - doc_metadata: metadataParams.metadata, - }, - }) as Promise) - 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 ( +
+ + +
+ ) } return ( @@ -353,68 +126,47 @@ const Metadata: FC = ({ docDetail, loading, onUpdate }) => { <>
{t('metadata.title', { ns: 'datasetDocuments' })} - {!editStatus - ? ( - - ) - : showDocTypes - ? null - : ( -
- - -
- )} + {renderHeaderActions()}
- {/* show selected doc type and changing entry */} - {!editStatus - ? ( -
- - {metadataMap[doc_type || 'book'].text} -
- ) - : showDocTypes - ? null - : ( -
- {metadataParams.documentType && ( - <> - - {metadataMap[metadataParams.documentType || 'book'].text} - {editStatus && ( -
- · -
{ setShowDocTypes(true) }} - className="cursor-pointer hover:text-text-accent" - > - {t('operation.change', { ns: 'common' })} -
-
- )} - - )} -
- )} + {renderDocTypeDisplay()} {(!doc_type && showDocTypes) ? null : } - {showDocTypes ? renderSelectDocType() : renderFieldInfos({ mainField: metadataParams.documentType, canEdit: editStatus })} - {/* show fixed fields */} + {showDocTypes + ? ( + + ) + : ( + + )} - {renderFieldInfos({ mainField: 'originInfo', canEdit: false })} +
{metadataMap.technicalParameters.text}
- {renderFieldInfos({ mainField: 'technicalParameters', canEdit: false })} + )}
diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index e23515ffe2..2223094e35 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -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": {