From a25e4c1b3abd77c33c4cbc4fbc2ad9f90b9825aa Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Fri, 30 Jan 2026 10:09:58 +0800 Subject: [PATCH] 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. --- .../components/doc-type-selector.spec.tsx | 181 +++++++ .../metadata/components/doc-type-selector.tsx | 77 +++ .../metadata/components/field-info.spec.tsx | 152 ++++++ .../detail/metadata/components/field-info.tsx | 89 ++++ .../metadata/components/icon-button.spec.tsx | 104 ++++ .../metadata/components/icon-button.tsx | 32 ++ .../detail/metadata/components/index.ts | 11 + .../components/metadata-field-list.spec.tsx | 258 +++++++++ .../components/metadata-field-list.tsx | 85 +++ .../metadata/components/type-icon.spec.tsx | 56 ++ .../detail/metadata/components/type-icon.tsx | 17 + .../detail/metadata/components/utils.spec.ts | 59 +++ .../detail/metadata/components/utils.ts | 3 + .../documents/detail/metadata/hooks/index.ts | 3 + .../hooks/use-metadata-editor.spec.ts | 333 ++++++++++++ .../metadata/hooks/use-metadata-editor.ts | 99 ++++ .../metadata/hooks/use-metadata-save.spec.ts | 224 ++++++++ .../metadata/hooks/use-metadata-save.ts | 56 ++ .../documents/detail/metadata/index.tsx | 490 +++++------------- web/eslint-suppressions.json | 5 +- 20 files changed, 1961 insertions(+), 373 deletions(-) create mode 100644 web/app/components/datasets/documents/detail/metadata/components/doc-type-selector.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/metadata/components/doc-type-selector.tsx create mode 100644 web/app/components/datasets/documents/detail/metadata/components/field-info.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/metadata/components/field-info.tsx create mode 100644 web/app/components/datasets/documents/detail/metadata/components/icon-button.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/metadata/components/icon-button.tsx create mode 100644 web/app/components/datasets/documents/detail/metadata/components/index.ts create mode 100644 web/app/components/datasets/documents/detail/metadata/components/metadata-field-list.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/metadata/components/metadata-field-list.tsx create mode 100644 web/app/components/datasets/documents/detail/metadata/components/type-icon.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/metadata/components/type-icon.tsx create mode 100644 web/app/components/datasets/documents/detail/metadata/components/utils.spec.ts create mode 100644 web/app/components/datasets/documents/detail/metadata/components/utils.ts create mode 100644 web/app/components/datasets/documents/detail/metadata/hooks/index.ts create mode 100644 web/app/components/datasets/documents/detail/metadata/hooks/use-metadata-editor.spec.ts create mode 100644 web/app/components/datasets/documents/detail/metadata/hooks/use-metadata-editor.ts create mode 100644 web/app/components/datasets/documents/detail/metadata/hooks/use-metadata-save.spec.ts create mode 100644 web/app/components/datasets/documents/detail/metadata/hooks/use-metadata-save.ts 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": {