From 4058e9ae23f4d5c365d232dc318853b61dfc5c6c Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Tue, 10 Feb 2026 17:26:08 +0800 Subject: [PATCH] refactor: extract sub-components and custom hooks from UpdateDSLModal and Metadata components (#32045) Co-authored-by: CodingOnStar Co-authored-by: Stephen Zhou <38493346+hyoban@users.noreply.github.com> --- .../metadata/components/doc-type-selector.tsx | 129 ++++ .../detail/metadata/components/field-info.tsx | 89 +++ .../components/metadata-field-list.tsx | 88 +++ .../metadata/hooks/use-metadata-state.ts | 137 +++++ .../documents/detail/metadata/index.spec.tsx | 194 +++++- .../documents/detail/metadata/index.tsx | 486 +++------------ .../components/update-dsl-modal.spec.tsx | 9 +- .../components/update-dsl-modal.tsx | 208 +------ .../version-mismatch-modal.spec.tsx | 117 ++++ .../components/version-mismatch-modal.tsx | 54 ++ .../hooks/use-update-dsl-modal.spec.ts | 551 ++++++++++++++++++ .../hooks/use-update-dsl-modal.ts | 205 +++++++ web/context/event-emitter.tsx | 16 +- web/eslint-suppressions.json | 18 +- 14 files changed, 1672 insertions(+), 629 deletions(-) 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.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/hooks/use-metadata-state.ts create mode 100644 web/app/components/rag-pipeline/components/version-mismatch-modal.spec.tsx create mode 100644 web/app/components/rag-pipeline/components/version-mismatch-modal.tsx create mode 100644 web/app/components/rag-pipeline/hooks/use-update-dsl-modal.spec.ts create mode 100644 web/app/components/rag-pipeline/hooks/use-update-dsl-modal.ts 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..d6f6e72da2 --- /dev/null +++ b/web/app/components/datasets/documents/detail/metadata/components/doc-type-selector.tsx @@ -0,0 +1,129 @@ +'use client' +import type { FC } from 'react' +import type { DocType } from '@/models/datasets' +import { useTranslation } from 'react-i18next' +import Button from '@/app/components/base/button' +import Radio from '@/app/components/base/radio' +import Tooltip from '@/app/components/base/tooltip' +import { useMetadataMap } from '@/hooks/use-metadata' +import { CUSTOMIZABLE_DOC_TYPES } from '@/models/datasets' +import { cn } from '@/utils/classnames' +import s from '../style.module.css' + +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 DocTypeSelectorProps = { + docType: DocType | '' + documentType?: DocType | '' + tempDocType: DocType | '' + onTempDocTypeChange: (type: DocType | '') => void + onConfirm: () => void + onCancel: () => void +} + +const DocTypeSelector: FC = ({ + docType, + documentType, + tempDocType, + onTempDocTypeChange, + onConfirm, + onCancel, +}) => { + const { t } = useTranslation() + const isFirstTime = !docType && !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 && ( +
+ + +
+ )} +
+ + ) +} + +type DocumentTypeDisplayProps = { + displayType: DocType | '' + showChangeLink?: boolean + onChangeClick?: () => void +} + +export const DocumentTypeDisplay: FC = ({ + displayType, + showChangeLink = false, + onChangeClick, +}) => { + const { t } = useTranslation() + const metadataMap = useMetadataMap() + const effectiveType = displayType || 'book' + + return ( +
+ {(displayType || !showChangeLink) && ( + <> + + {metadataMap[effectiveType].text} + {showChangeLink && ( +
+ · +
+ {t('operation.change', { ns: 'common' })} +
+
+ )} + + )} +
+ ) +} + +export default DocTypeSelector 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..fca21dd165 --- /dev/null +++ b/web/app/components/datasets/documents/detail/metadata/components/field-info.tsx @@ -0,0 +1,89 @@ +'use client' +import type { FC, ReactNode } from 'react' +import type { inputType } from '@/hooks/use-metadata' +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' + +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 FieldInfo 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..9f452279ed --- /dev/null +++ b/web/app/components/datasets/documents/detail/metadata/components/metadata-field-list.tsx @@ -0,0 +1,88 @@ +'use client' +import type { FC } from 'react' +import type { metadataType } from '@/hooks/use-metadata' +import type { FullDocumentDetail } from '@/models/datasets' +import { get } from 'es-toolkit/compat' +import { useBookCategories, useBusinessDocCategories, useLanguages, useMetadataMap, usePersonalDocCategories } from '@/hooks/use-metadata' +import FieldInfo from './field-info' + +const map2Options = (map: Record) => { + return Object.keys(map).map(key => ({ value: key, name: map[key] })) +} + +function useCategoryMapResolver(mainField: metadataType | '') { + const languageMap = useLanguages() + const bookCategoryMap = useBookCategories() + const personalDocCategoryMap = usePersonalDocCategories() + const businessDocCategoryMap = useBusinessDocCategories() + + return (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 {} + } +} + +type MetadataFieldListProps = { + mainField: metadataType | '' + canEdit?: boolean + metadata?: Record + docDetail?: FullDocumentDetail + onFieldUpdate?: (field: string, value: string) => void +} + +const MetadataFieldList: FC = ({ + mainField, + canEdit = false, + metadata, + docDetail, + onFieldUpdate, +}) => { + const metadataMap = useMetadataMap() + const getCategoryMap = useCategoryMapResolver(mainField) + + if (!mainField) + return null + + const fieldMap = metadataMap[mainField]?.subFieldsMap + const isFixedField = ['originInfo', 'technicalParameters'].includes(mainField) + const sourceData = isFixedField ? docDetail : metadata + + const getDisplayValue = (field: string) => { + const val = get(sourceData, field, '') + if (!val && val !== 0) + return '-' + if (fieldMap[field]?.inputType === 'select') + return getCategoryMap(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 => ( + onFieldUpdate?.(field, val)} + selectOptions={map2Options(getCategoryMap(field))} + /> + ))} +
+ ) +} + +export default MetadataFieldList diff --git a/web/app/components/datasets/documents/detail/metadata/hooks/use-metadata-state.ts b/web/app/components/datasets/documents/detail/metadata/hooks/use-metadata-state.ts new file mode 100644 index 0000000000..08651b699e --- /dev/null +++ b/web/app/components/datasets/documents/detail/metadata/hooks/use-metadata-state.ts @@ -0,0 +1,137 @@ +'use client' +import type { CommonResponse } from '@/models/common' +import type { DocType, FullDocumentDetail } from '@/models/datasets' +import { useEffect, 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' +import { useDocumentContext } from '../../context' + +type MetadataState = { + documentType?: DocType | '' + metadata: Record +} + +/** + * Normalize raw doc_type: treat 'others' as empty string. + */ +const normalizeDocType = (rawDocType: string): DocType | '' => { + return rawDocType === 'others' ? '' : rawDocType as DocType | '' +} + +type UseMetadataStateOptions = { + docDetail?: FullDocumentDetail + onUpdate?: () => void +} + +export function useMetadataState({ docDetail, onUpdate }: UseMetadataStateOptions) { + const { doc_metadata = {} } = docDetail || {} + const rawDocType = docDetail?.doc_type ?? '' + const docType = normalizeDocType(rawDocType) + + const { t } = useTranslation() + const { notify } = useContext(ToastContext) + const datasetId = useDocumentContext(s => s.datasetId) + const documentId = useDocumentContext(s => s.documentId) + + // If no documentType yet, start in editing + showDocTypes mode + const [editStatus, setEditStatus] = useState(!docType) + const [metadataParams, setMetadataParams] = useState( + docType + ? { documentType: docType, metadata: (doc_metadata || {}) as Record } + : { metadata: {} }, + ) + const [showDocTypes, setShowDocTypes] = useState(!docType) + const [tempDocType, setTempDocType] = useState('') + const [saveLoading, setSaveLoading] = useState(false) + + // Sync local state when the upstream docDetail changes (e.g. after save or navigation). + // These setters are intentionally called together to batch-reset multiple pieces + // of derived editing state that cannot be expressed as pure derived values. + useEffect(() => { + if (docDetail?.doc_type) { + // eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect + setEditStatus(false) + // eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect + setShowDocTypes(false) + // eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect + setTempDocType(docType) + // eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect + setMetadataParams({ + documentType: docType, + metadata: (docDetail?.doc_metadata || {}) as Record, + }) + } + }, [docDetail?.doc_type, docDetail?.doc_metadata, docType]) + + const confirmDocType = () => { + if (!tempDocType) + return + setMetadataParams({ + documentType: tempDocType, + // Clear metadata when switching to a different doc type + metadata: tempDocType === metadataParams.documentType ? metadataParams.metadata : {}, + }) + setEditStatus(true) + setShowDocTypes(false) + } + + const cancelDocType = () => { + setTempDocType(metadataParams.documentType ?? '') + setEditStatus(true) + setShowDocTypes(false) + } + + const enableEdit = () => { + setEditStatus(true) + } + + const cancelEdit = () => { + setMetadataParams({ documentType: docType || '', metadata: { ...(docDetail?.doc_metadata || {}) } }) + setEditStatus(!docType) + if (!docType) + setShowDocTypes(true) + } + + const saveMetadata = async () => { + setSaveLoading(true) + const [e] = await asyncRunSafe(modifyDocMetadata({ + datasetId, + documentId, + body: { + doc_type: metadataParams.documentType || docType || '', + 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) + } + + const updateMetadataField = (field: string, value: string) => { + setMetadataParams(prev => ({ ...prev, metadata: { ...prev.metadata, [field]: value } })) + } + + return { + docType, + editStatus, + showDocTypes, + tempDocType, + saveLoading, + metadataParams, + setTempDocType, + setShowDocTypes, + confirmDocType, + cancelDocType, + enableEdit, + cancelEdit, + saveMetadata, + updateMetadataField, + } +} diff --git a/web/app/components/datasets/documents/detail/metadata/index.spec.tsx b/web/app/components/datasets/documents/detail/metadata/index.spec.tsx index 367449f1b9..6efc9661d5 100644 --- a/web/app/components/datasets/documents/detail/metadata/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/metadata/index.spec.tsx @@ -1,7 +1,6 @@ import type { FullDocumentDetail } from '@/models/datasets' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' - import Metadata, { FieldInfo } from './index' // Mock document context @@ -121,7 +120,6 @@ vi.mock('@/hooks/use-metadata', () => ({ }), })) -// Mock getTextWidthWithCanvas vi.mock('@/utils', () => ({ asyncRunSafe: async (promise: Promise) => { try { @@ -135,33 +133,32 @@ vi.mock('@/utils', () => ({ getTextWidthWithCanvas: () => 100, })) +const createMockDocDetail = (overrides = {}): FullDocumentDetail => ({ + id: 'doc-1', + name: 'Test Document', + doc_type: 'book', + doc_metadata: { + title: 'Test Book', + author: 'Test Author', + language: 'en', + }, + data_source_type: 'upload_file', + segment_count: 10, + hit_count: 5, + ...overrides, +} as FullDocumentDetail) + describe('Metadata', () => { beforeEach(() => { vi.clearAllMocks() }) - const createMockDocDetail = (overrides = {}): FullDocumentDetail => ({ - id: 'doc-1', - name: 'Test Document', - doc_type: 'book', - doc_metadata: { - title: 'Test Book', - author: 'Test Author', - language: 'en', - }, - data_source_type: 'upload_file', - segment_count: 10, - hit_count: 5, - ...overrides, - } as FullDocumentDetail) - const defaultProps = { docDetail: createMockDocDetail(), loading: false, onUpdate: vi.fn(), } - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { // Arrange & Act @@ -191,7 +188,7 @@ describe('Metadata', () => { // Arrange & Act render() - // Assert - Loading component should be rendered + // Assert - Loading component should be rendered, title should not expect(screen.queryByText(/metadata\.title/i)).not.toBeInTheDocument() }) @@ -204,7 +201,7 @@ describe('Metadata', () => { }) }) - // Edit mode tests + // Edit mode (tests useMetadataState hook integration) describe('Edit Mode', () => { it('should enter edit mode when edit button is clicked', () => { // Arrange @@ -303,7 +300,7 @@ describe('Metadata', () => { }) }) - // Document type selection + // Document type selection (tests DocTypeSelector sub-component integration) describe('Document Type Selection', () => { it('should show doc type selection when no doc_type exists', () => { // Arrange @@ -353,13 +350,13 @@ describe('Metadata', () => { }) }) - // Origin info and technical parameters + // Fixed fields (tests MetadataFieldList sub-component integration) describe('Fixed Fields', () => { it('should render origin info fields', () => { // Arrange & Act render() - // Assert - Origin info fields should be displayed + // Assert expect(screen.getByText('Data Source Type')).toBeInTheDocument() }) @@ -382,7 +379,7 @@ describe('Metadata', () => { // Act const { container } = render() - // Assert - should render without crashing + // Assert expect(container.firstChild).toBeInTheDocument() }) @@ -390,7 +387,7 @@ describe('Metadata', () => { // Arrange & Act const { container } = render() - // Assert - should render without crashing + // Assert expect(container.firstChild).toBeInTheDocument() }) @@ -425,7 +422,6 @@ describe('Metadata', () => { }) }) -// FieldInfo component tests describe('FieldInfo', () => { beforeEach(() => { vi.clearAllMocks() @@ -543,3 +539,149 @@ describe('FieldInfo', () => { }) }) }) + +// --- useMetadataState hook coverage tests (via component interactions) --- +describe('useMetadataState coverage', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const defaultProps = { + docDetail: createMockDocDetail(), + loading: false, + onUpdate: vi.fn(), + } + + describe('cancelDocType', () => { + it('should cancel doc type change and return to edit mode', () => { + // Arrange + render() + + // Enter edit mode → click change to open doc type selector + fireEvent.click(screen.getByText(/operation\.edit/i)) + fireEvent.click(screen.getByText(/operation\.change/i)) + + // Now in doc type selector mode — should show cancel button + expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument() + + // Act — cancel the doc type change + fireEvent.click(screen.getByText(/operation\.cancel/i)) + + // Assert — should be back to edit mode (cancel + save buttons visible) + expect(screen.getByText(/operation\.save/i)).toBeInTheDocument() + }) + }) + + describe('confirmDocType', () => { + it('should confirm same doc type and return to edit mode keeping metadata', () => { + // Arrange — useEffect syncs tempDocType='book' from docDetail + render() + + // Enter edit mode → click change to open doc type selector + fireEvent.click(screen.getByText(/operation\.edit/i)) + fireEvent.click(screen.getByText(/operation\.change/i)) + + // DocTypeSelector shows save/cancel buttons + expect(screen.getByText(/metadata\.docTypeChangeTitle/i)).toBeInTheDocument() + + // Act — click save to confirm same doc type (tempDocType='book') + fireEvent.click(screen.getByText(/operation\.save/i)) + + // Assert — should return to edit mode with metadata fields visible + expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument() + expect(screen.getByText(/operation\.save/i)).toBeInTheDocument() + }) + }) + + describe('cancelEdit when no docType', () => { + it('should show doc type selection when cancel is clicked with doc_type others', () => { + // Arrange — doc with 'others' type normalizes to '' internally. + // The useEffect sees doc_type='others' (truthy) and syncs state, + // so the component initially shows view mode. Enter edit → cancel to trigger cancelEdit. + const docDetail = createMockDocDetail({ doc_type: 'others' }) + render() + + // 'others' is normalized to '' → useEffect fires (doc_type truthy) → view mode + // The rendered type uses default 'book' fallback for display + expect(screen.getByText(/operation\.edit/i)).toBeInTheDocument() + + // Enter edit mode + fireEvent.click(screen.getByText(/operation\.edit/i)) + expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument() + + // Act — cancel edit; internally docType is '' so cancelEdit goes to showDocTypes + fireEvent.click(screen.getByText(/operation\.cancel/i)) + + // Assert — should show doc type selection since normalized docType was '' + expect(screen.getByText(/metadata\.docTypeSelectTitle/i)).toBeInTheDocument() + }) + }) + + describe('updateMetadataField', () => { + it('should update metadata field value via input', () => { + // Arrange + render() + + // Enter edit mode + fireEvent.click(screen.getByText(/operation\.edit/i)) + + // Act — find an input and change its value (Title field) + const inputs = screen.getAllByRole('textbox') + expect(inputs.length).toBeGreaterThan(0) + fireEvent.change(inputs[0], { target: { value: 'Updated Title' } }) + + // Assert — the input should have the new value + expect(inputs[0]).toHaveValue('Updated Title') + }) + }) + + describe('saveMetadata calls modifyDocMetadata with correct body', () => { + it('should pass doc_type and doc_metadata in save request', async () => { + // Arrange + mockModifyDocMetadata.mockResolvedValueOnce({}) + render() + + // Enter edit mode + fireEvent.click(screen.getByText(/operation\.edit/i)) + + // Act — save + fireEvent.click(screen.getByText(/operation\.save/i)) + + // Assert + await waitFor(() => { + expect(mockModifyDocMetadata).toHaveBeenCalledWith( + expect.objectContaining({ + datasetId: 'test-dataset-id', + documentId: 'test-document-id', + body: expect.objectContaining({ + doc_type: 'book', + }), + }), + ) + }) + }) + }) + + describe('useEffect sync', () => { + it('should handle doc_metadata being null in effect sync', () => { + // Arrange — first render with null metadata + const { rerender } = render( + , + ) + + // Act — rerender with a different doc_type to trigger useEffect sync + rerender( + , + ) + + // Assert — should render without crashing, showing Paper type + expect(screen.getByText('Paper')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/documents/detail/metadata/index.tsx b/web/app/components/datasets/documents/detail/metadata/index.tsx index 7d1c65b1cd..87110ddc1d 100644 --- a/web/app/components/datasets/documents/detail/metadata/index.tsx +++ b/web/app/components/datasets/documents/detail/metadata/index.tsx @@ -1,422 +1,124 @@ '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 { 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 { useDocumentContext } from '../context' +import { useMetadataMap } from '@/hooks/use-metadata' +import DocTypeSelector, { DocumentTypeDisplay } from './components/doc-type-selector' +import MetadataFieldList from './components/metadata-field-list' +import { useMetadataState } from './hooks/use-metadata-state' import s from './style.module.css' -const map2Options = (map: { [key: string]: string }) => { - return Object.keys(map).map(key => ({ value: key, name: map[key] })) -} +export { default as FieldInfo } from './components/field-info' -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 { notify } = useContext(ToastContext) - const datasetId = useDocumentContext(s => s.datasetId) - const documentId = useDocumentContext(s => s.documentId) - - 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]) - - // 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 + const { + docType, + editStatus, + showDocTypes, + tempDocType, + saveLoading, + metadataParams, + setTempDocType, + setShowDocTypes, + confirmDocType, + cancelDocType, + enableEdit, + cancelEdit, + saveMetadata, + updateMetadataField, + } = useMetadataState({ docDetail, onUpdate }) + if (loading) { return ( - <> - {!doc_type && !documentType && ( - <> -
{t('metadata.desc', { ns: 'datasetDocuments' })}
- - )} -
- {!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 onCancel = () => { - setMetadataParams({ documentType: doc_type || '', metadata: { ...docDetail?.doc_metadata } }) - setEditStatus(!doc_type) - if (!doc_type) - setShowDocTypes(true) - } - - 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 (
- {loading - ? () - : ( - <> -
- {t('metadata.title', { ns: 'datasetDocuments' })} - {!editStatus - ? ( - - ) - : showDocTypes - ? null - : ( -
- - -
- )} + {/* Header: title + action buttons */} +
+ {t('metadata.title', { ns: 'datasetDocuments' })} + {!editStatus + ? ( + + ) + : !showDocTypes && ( +
+ +
- {/* 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' })} -
-
- )} - - )} -
- )} - {(!doc_type && showDocTypes) ? null : } - {showDocTypes ? renderSelectDocType() : renderFieldInfos({ mainField: metadataParams.documentType, canEdit: editStatus })} - {/* show fixed fields */} - - {renderFieldInfos({ mainField: 'originInfo', canEdit: false })} -
{metadataMap.technicalParameters.text}
- - {renderFieldInfos({ mainField: 'technicalParameters', canEdit: false })} - + )} +
+ + {/* Document type display / selector */} + {!editStatus + ? + : showDocTypes + ? null + : ( + setShowDocTypes(true)} + /> + )} + + {/* Divider between type display and fields (skip when in first-time selection) */} + {(!docType && showDocTypes) ? null : } + + {/* Doc type selector or editable metadata fields */} + {showDocTypes + ? ( + + ) + : ( + )} + + {/* Fixed fields: origin info */} + + + + {/* Fixed fields: technical parameters */} +
{metadataMap.technicalParameters.text}
+ +
) } diff --git a/web/app/components/rag-pipeline/components/update-dsl-modal.spec.tsx b/web/app/components/rag-pipeline/components/update-dsl-modal.spec.tsx index 6643d8239d..addfa3dc53 100644 --- a/web/app/components/rag-pipeline/components/update-dsl-modal.spec.tsx +++ b/web/app/components/rag-pipeline/components/update-dsl-modal.spec.tsx @@ -1,6 +1,6 @@ import type { PropsWithChildren } from 'react' -import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { DSLImportStatus } from '@/models/app' import UpdateDSLModal from './update-dsl-modal' @@ -145,11 +145,6 @@ vi.mock('@/app/components/workflow/constants', () => ({ WORKFLOW_DATA_UPDATE: 'WORKFLOW_DATA_UPDATE', })) -afterEach(() => { - cleanup() - vi.clearAllMocks() -}) - describe('UpdateDSLModal', () => { const mockOnCancel = vi.fn() const mockOnBackup = vi.fn() diff --git a/web/app/components/rag-pipeline/components/update-dsl-modal.tsx b/web/app/components/rag-pipeline/components/update-dsl-modal.tsx index 32bb4fdf7b..817eb60238 100644 --- a/web/app/components/rag-pipeline/components/update-dsl-modal.tsx +++ b/web/app/components/rag-pipeline/components/update-dsl-modal.tsx @@ -1,40 +1,17 @@ 'use client' -import type { MouseEventHandler } from 'react' import { RiAlertFill, RiCloseLine, RiFileDownloadLine, } from '@remixicon/react' -import { - memo, - useCallback, - useRef, - useState, -} from 'react' +import { memo } from 'react' import { useTranslation } from 'react-i18next' -import { useContext } from 'use-context-selector' import Uploader from '@/app/components/app/create-from-dsl-modal/uploader' import Button from '@/app/components/base/button' import Modal from '@/app/components/base/modal' -import { ToastContext } from '@/app/components/base/toast' -import { WORKFLOW_DATA_UPDATE } from '@/app/components/workflow/constants' -import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks' -import { useWorkflowStore } from '@/app/components/workflow/store' -import { - initialEdges, - initialNodes, -} from '@/app/components/workflow/utils' -import { useEventEmitterContextContext } from '@/context/event-emitter' -import { - DSLImportMode, - DSLImportStatus, -} from '@/models/app' -import { - useImportPipelineDSL, - useImportPipelineDSLConfirm, -} from '@/service/use-pipeline' -import { fetchWorkflowDraft } from '@/service/workflow' +import { useUpdateDSLModal } from '../hooks/use-update-dsl-modal' +import VersionMismatchModal from './version-mismatch-modal' type UpdateDSLModalProps = { onCancel: () => void @@ -48,146 +25,17 @@ const UpdateDSLModal = ({ onImport, }: UpdateDSLModalProps) => { const { t } = useTranslation() - const { notify } = useContext(ToastContext) - const [currentFile, setDSLFile] = useState() - const [fileContent, setFileContent] = useState() - const [loading, setLoading] = useState(false) - const { eventEmitter } = useEventEmitterContextContext() - const [show, setShow] = useState(true) - const [showErrorModal, setShowErrorModal] = useState(false) - const [versions, setVersions] = useState<{ importedVersion: string, systemVersion: string }>() - const [importId, setImportId] = useState() - const { handleCheckPluginDependencies } = usePluginDependencies() - const { mutateAsync: importDSL } = useImportPipelineDSL() - const { mutateAsync: importDSLConfirm } = useImportPipelineDSLConfirm() - const workflowStore = useWorkflowStore() - - const readFile = (file: File) => { - const reader = new FileReader() - reader.onload = function (event) { - const content = event.target?.result - setFileContent(content as string) - } - reader.readAsText(file) - } - - const handleFile = (file?: File) => { - setDSLFile(file) - if (file) - readFile(file) - if (!file) - setFileContent('') - } - - const handleWorkflowUpdate = useCallback(async (pipelineId: string) => { - const { - graph, - hash, - rag_pipeline_variables, - } = await fetchWorkflowDraft(`/rag/pipelines/${pipelineId}/workflows/draft`) - - const { nodes, edges, viewport } = graph - - eventEmitter?.emit({ - type: WORKFLOW_DATA_UPDATE, - payload: { - nodes: initialNodes(nodes, edges), - edges: initialEdges(edges, nodes), - viewport, - hash, - rag_pipeline_variables: rag_pipeline_variables || [], - }, - } as any) - }, [eventEmitter]) - - const isCreatingRef = useRef(false) - const handleImport: MouseEventHandler = useCallback(async () => { - const { pipelineId } = workflowStore.getState() - if (isCreatingRef.current) - return - isCreatingRef.current = true - if (!currentFile) - return - try { - if (pipelineId && fileContent) { - setLoading(true) - const response = await importDSL({ mode: DSLImportMode.YAML_CONTENT, yaml_content: fileContent, pipeline_id: pipelineId }) - const { id, status, pipeline_id, imported_dsl_version, current_dsl_version } = response - - if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) { - if (!pipeline_id) { - notify({ type: 'error', message: t('common.importFailure', { ns: 'workflow' }) }) - return - } - handleWorkflowUpdate(pipeline_id) - if (onImport) - onImport() - notify({ - type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning', - message: t(status === DSLImportStatus.COMPLETED ? 'common.importSuccess' : 'common.importWarning', { ns: 'workflow' }), - children: status === DSLImportStatus.COMPLETED_WITH_WARNINGS && t('common.importWarningDetails', { ns: 'workflow' }), - }) - await handleCheckPluginDependencies(pipeline_id, true) - setLoading(false) - onCancel() - } - else if (status === DSLImportStatus.PENDING) { - setShow(false) - setTimeout(() => { - setShowErrorModal(true) - }, 300) - setVersions({ - importedVersion: imported_dsl_version ?? '', - systemVersion: current_dsl_version ?? '', - }) - setImportId(id) - } - else { - setLoading(false) - notify({ type: 'error', message: t('common.importFailure', { ns: 'workflow' }) }) - } - } - } - // eslint-disable-next-line unused-imports/no-unused-vars - catch (e) { - setLoading(false) - notify({ type: 'error', message: t('common.importFailure', { ns: 'workflow' }) }) - } - isCreatingRef.current = false - }, [currentFile, fileContent, onCancel, notify, t, onImport, handleWorkflowUpdate, handleCheckPluginDependencies, workflowStore, importDSL]) - - const onUpdateDSLConfirm: MouseEventHandler = async () => { - try { - if (!importId) - return - const response = await importDSLConfirm(importId) - - const { status, pipeline_id } = response - - if (status === DSLImportStatus.COMPLETED) { - if (!pipeline_id) { - notify({ type: 'error', message: t('common.importFailure', { ns: 'workflow' }) }) - return - } - handleWorkflowUpdate(pipeline_id) - await handleCheckPluginDependencies(pipeline_id, true) - if (onImport) - onImport() - notify({ type: 'success', message: t('common.importSuccess', { ns: 'workflow' }) }) - setLoading(false) - onCancel() - } - else if (status === DSLImportStatus.FAILED) { - setLoading(false) - notify({ type: 'error', message: t('common.importFailure', { ns: 'workflow' }) }) - } - } - // eslint-disable-next-line unused-imports/no-unused-vars - catch (e) { - setLoading(false) - notify({ type: 'error', message: t('common.importFailure', { ns: 'workflow' }) }) - } - } + const { + currentFile, + handleFile, + show, + showErrorModal, + setShowErrorModal, + loading, + versions, + handleImport, + onUpdateDSLConfirm, + } = useUpdateDSLModal({ onCancel, onImport }) return ( <> @@ -250,32 +98,12 @@ const UpdateDSLModal = ({
- setShowErrorModal(false)} - className="w-[480px]" - > -
-
{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}
-
-
{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}
-
{t('newApp.appCreateDSLErrorPart2', { ns: 'app' })}
-
-
- {t('newApp.appCreateDSLErrorPart3', { ns: 'app' })} - {versions?.importedVersion} -
-
- {t('newApp.appCreateDSLErrorPart4', { ns: 'app' })} - {versions?.systemVersion} -
-
-
-
- - -
-
+ onConfirm={onUpdateDSLConfirm} + /> ) } diff --git a/web/app/components/rag-pipeline/components/version-mismatch-modal.spec.tsx b/web/app/components/rag-pipeline/components/version-mismatch-modal.spec.tsx new file mode 100644 index 0000000000..b14cdcf9c1 --- /dev/null +++ b/web/app/components/rag-pipeline/components/version-mismatch-modal.spec.tsx @@ -0,0 +1,117 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import VersionMismatchModal from './version-mismatch-modal' + +describe('VersionMismatchModal', () => { + const mockOnClose = vi.fn() + const mockOnConfirm = vi.fn() + + const defaultVersions = { + importedVersion: '0.8.0', + systemVersion: '1.0.0', + } + + const defaultProps = { + isShow: true, + versions: defaultVersions, + onClose: mockOnClose, + onConfirm: mockOnConfirm, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('rendering', () => { + it('should render dialog when isShow is true', () => { + render() + + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + + it('should not render dialog when isShow is false', () => { + render() + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('should render error title', () => { + render() + + expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument() + }) + + it('should render all error description parts', () => { + render() + + expect(screen.getByText('app.newApp.appCreateDSLErrorPart1')).toBeInTheDocument() + expect(screen.getByText('app.newApp.appCreateDSLErrorPart2')).toBeInTheDocument() + expect(screen.getByText('app.newApp.appCreateDSLErrorPart3')).toBeInTheDocument() + expect(screen.getByText('app.newApp.appCreateDSLErrorPart4')).toBeInTheDocument() + }) + + it('should display imported and system version numbers', () => { + render() + + expect(screen.getByText('0.8.0')).toBeInTheDocument() + expect(screen.getByText('1.0.0')).toBeInTheDocument() + }) + + it('should render cancel and confirm buttons', () => { + render() + + expect(screen.getByRole('button', { name: /app\.newApp\.Cancel/ })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /app\.newApp\.Confirm/ })).toBeInTheDocument() + }) + }) + + describe('user interactions', () => { + it('should call onClose when cancel button is clicked', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: /app\.newApp\.Cancel/ })) + + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + + it('should call onConfirm when confirm button is clicked', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: /app\.newApp\.Confirm/ })) + + expect(mockOnConfirm).toHaveBeenCalledTimes(1) + }) + }) + + describe('button variants', () => { + it('should render cancel button with secondary variant', () => { + render() + + const cancelBtn = screen.getByRole('button', { name: /app\.newApp\.Cancel/ }) + expect(cancelBtn).toHaveClass('btn-secondary') + }) + + it('should render confirm button with primary destructive variant', () => { + render() + + const confirmBtn = screen.getByRole('button', { name: /app\.newApp\.Confirm/ }) + expect(confirmBtn).toHaveClass('btn-primary') + expect(confirmBtn).toHaveClass('btn-destructive') + }) + }) + + describe('edge cases', () => { + it('should handle undefined versions gracefully', () => { + render() + + expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument() + }) + + it('should handle empty version strings', () => { + const emptyVersions = { importedVersion: '', systemVersion: '' } + render() + + expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/rag-pipeline/components/version-mismatch-modal.tsx b/web/app/components/rag-pipeline/components/version-mismatch-modal.tsx new file mode 100644 index 0000000000..ffe2bc6e53 --- /dev/null +++ b/web/app/components/rag-pipeline/components/version-mismatch-modal.tsx @@ -0,0 +1,54 @@ +import type { MouseEventHandler } from 'react' +import { useTranslation } from 'react-i18next' +import Button from '@/app/components/base/button' +import Modal from '@/app/components/base/modal' + +type VersionMismatchModalProps = { + isShow: boolean + versions?: { + importedVersion: string + systemVersion: string + } + onClose: () => void + onConfirm: MouseEventHandler +} + +const VersionMismatchModal = ({ + isShow, + versions, + onClose, + onConfirm, +}: VersionMismatchModalProps) => { + const { t } = useTranslation() + + return ( + +
+
{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}
+
+
{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}
+
{t('newApp.appCreateDSLErrorPart2', { ns: 'app' })}
+
+
+ {t('newApp.appCreateDSLErrorPart3', { ns: 'app' })} + {versions?.importedVersion} +
+
+ {t('newApp.appCreateDSLErrorPart4', { ns: 'app' })} + {versions?.systemVersion} +
+
+
+
+ + +
+
+ ) +} + +export default VersionMismatchModal diff --git a/web/app/components/rag-pipeline/hooks/use-update-dsl-modal.spec.ts b/web/app/components/rag-pipeline/hooks/use-update-dsl-modal.spec.ts new file mode 100644 index 0000000000..adf756c10f --- /dev/null +++ b/web/app/components/rag-pipeline/hooks/use-update-dsl-modal.spec.ts @@ -0,0 +1,551 @@ +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { DSLImportMode, DSLImportStatus } from '@/models/app' +import { useUpdateDSLModal } from './use-update-dsl-modal' + +// --- FileReader stub --- +class MockFileReader { + onload: ((this: FileReader, event: ProgressEvent) => void) | null = null + + readAsText(_file: Blob) { + const event = { target: { result: 'test content' } } as unknown as ProgressEvent + this.onload?.call(this as unknown as FileReader, event) + } +} +vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader) + +// --- Module-level mock functions --- +const mockNotify = vi.fn() +const mockEmit = vi.fn() +const mockImportDSL = vi.fn() +const mockImportDSLConfirm = vi.fn() +const mockHandleCheckPluginDependencies = vi.fn() + +// --- Mocks --- +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})) + +vi.mock('use-context-selector', () => ({ + useContext: () => ({ notify: mockNotify }), +})) + +vi.mock('@/app/components/base/toast', () => ({ + ToastContext: {}, +})) + +vi.mock('@/context/event-emitter', () => ({ + useEventEmitterContextContext: () => ({ + eventEmitter: { emit: mockEmit }, + }), +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useWorkflowStore: () => ({ + getState: () => ({ pipelineId: 'test-pipeline-id' }), + }), +})) + +vi.mock('@/app/components/workflow/utils', () => ({ + initialNodes: (nodes: unknown[]) => nodes, + initialEdges: (edges: unknown[]) => edges, +})) + +vi.mock('@/app/components/workflow/constants', () => ({ + WORKFLOW_DATA_UPDATE: 'WORKFLOW_DATA_UPDATE', +})) + +vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({ + usePluginDependencies: () => ({ + handleCheckPluginDependencies: mockHandleCheckPluginDependencies, + }), +})) + +vi.mock('@/service/use-pipeline', () => ({ + useImportPipelineDSL: () => ({ mutateAsync: mockImportDSL }), + useImportPipelineDSLConfirm: () => ({ mutateAsync: mockImportDSLConfirm }), +})) + +vi.mock('@/service/workflow', () => ({ + fetchWorkflowDraft: vi.fn().mockResolvedValue({ + graph: { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } }, + hash: 'test-hash', + rag_pipeline_variables: [], + }), +})) + +// --- Helpers --- +const createFile = () => new File(['test content'], 'test.pipeline', { type: 'text/yaml' }) + +// Cast MouseEventHandler to a plain callable for tests (event param is unused) +type AsyncFn = () => Promise + +describe('useUpdateDSLModal', () => { + const mockOnCancel = vi.fn() + const mockOnImport = vi.fn() + + const renderUpdateDSLModal = (overrides?: { onImport?: () => void }) => + renderHook(() => + useUpdateDSLModal({ + onCancel: mockOnCancel, + onImport: overrides?.onImport ?? mockOnImport, + }), + ) + + beforeEach(() => { + vi.clearAllMocks() + mockImportDSL.mockResolvedValue({ + id: 'import-id', + status: DSLImportStatus.COMPLETED, + pipeline_id: 'test-pipeline-id', + }) + mockHandleCheckPluginDependencies.mockResolvedValue(undefined) + }) + + // Initial state values + describe('initial state', () => { + it('should return correct defaults', () => { + const { result } = renderUpdateDSLModal() + + expect(result.current.currentFile).toBeUndefined() + expect(result.current.show).toBe(true) + expect(result.current.showErrorModal).toBe(false) + expect(result.current.loading).toBe(false) + expect(result.current.versions).toBeUndefined() + }) + }) + + // File handling + describe('handleFile', () => { + it('should set currentFile when file is provided', () => { + const { result } = renderUpdateDSLModal() + const file = createFile() + + act(() => { + result.current.handleFile(file) + }) + + expect(result.current.currentFile).toBe(file) + }) + + it('should clear currentFile when called with undefined', () => { + const { result } = renderUpdateDSLModal() + + act(() => { + result.current.handleFile(createFile()) + }) + act(() => { + result.current.handleFile(undefined) + }) + + expect(result.current.currentFile).toBeUndefined() + }) + }) + + // Modal state management + describe('modal state', () => { + it('should allow toggling showErrorModal', () => { + const { result } = renderUpdateDSLModal() + + expect(result.current.showErrorModal).toBe(false) + + act(() => { + result.current.setShowErrorModal(true) + }) + expect(result.current.showErrorModal).toBe(true) + + act(() => { + result.current.setShowErrorModal(false) + }) + expect(result.current.showErrorModal).toBe(false) + }) + }) + + // Import flow + describe('handleImport', () => { + it('should call importDSL with correct parameters', async () => { + const { result } = renderUpdateDSLModal() + + act(() => { + result.current.handleFile(createFile()) + }) + + await act(async () => { + await (result.current.handleImport as unknown as AsyncFn)() + }) + + expect(mockImportDSL).toHaveBeenCalledWith({ + mode: DSLImportMode.YAML_CONTENT, + yaml_content: 'test content', + pipeline_id: 'test-pipeline-id', + }) + }) + + it('should not call importDSL when no file is selected', async () => { + const { result } = renderUpdateDSLModal() + + await act(async () => { + await (result.current.handleImport as unknown as AsyncFn)() + }) + + expect(mockImportDSL).not.toHaveBeenCalled() + }) + + // COMPLETED status + it('should notify success on COMPLETED status', async () => { + const { result } = renderUpdateDSLModal() + act(() => { + result.current.handleFile(createFile()) + }) + + await act(async () => { + await (result.current.handleImport as unknown as AsyncFn)() + }) + + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' })) + }) + + it('should call onImport on successful import', async () => { + const { result } = renderUpdateDSLModal() + act(() => { + result.current.handleFile(createFile()) + }) + + await act(async () => { + await (result.current.handleImport as unknown as AsyncFn)() + }) + + expect(mockOnImport).toHaveBeenCalled() + }) + + it('should call onCancel on successful import', async () => { + const { result } = renderUpdateDSLModal() + act(() => { + result.current.handleFile(createFile()) + }) + + await act(async () => { + await (result.current.handleImport as unknown as AsyncFn)() + }) + + expect(mockOnCancel).toHaveBeenCalled() + }) + + it('should emit workflow update event on success', async () => { + const { result } = renderUpdateDSLModal() + act(() => { + result.current.handleFile(createFile()) + }) + + await act(async () => { + await (result.current.handleImport as unknown as AsyncFn)() + }) + + expect(mockEmit).toHaveBeenCalled() + }) + + it('should call handleCheckPluginDependencies on success', async () => { + const { result } = renderUpdateDSLModal() + act(() => { + result.current.handleFile(createFile()) + }) + + await act(async () => { + await (result.current.handleImport as unknown as AsyncFn)() + }) + + expect(mockHandleCheckPluginDependencies).toHaveBeenCalledWith('test-pipeline-id', true) + }) + + // COMPLETED_WITH_WARNINGS status + it('should notify warning on COMPLETED_WITH_WARNINGS status', async () => { + mockImportDSL.mockResolvedValue({ + id: 'import-id', + status: DSLImportStatus.COMPLETED_WITH_WARNINGS, + pipeline_id: 'test-pipeline-id', + }) + + const { result } = renderUpdateDSLModal() + act(() => { + result.current.handleFile(createFile()) + }) + + await act(async () => { + await (result.current.handleImport as unknown as AsyncFn)() + }) + + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'warning' })) + }) + + // PENDING status (version mismatch) + it('should switch to version mismatch modal on PENDING status', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + + mockImportDSL.mockResolvedValue({ + id: 'import-id', + status: DSLImportStatus.PENDING, + pipeline_id: 'test-pipeline-id', + imported_dsl_version: '0.8.0', + current_dsl_version: '1.0.0', + }) + + const { result } = renderUpdateDSLModal() + act(() => { + result.current.handleFile(createFile()) + }) + + await act(async () => { + await (result.current.handleImport as unknown as AsyncFn)() + await vi.advanceTimersByTimeAsync(350) + }) + + expect(result.current.show).toBe(false) + expect(result.current.showErrorModal).toBe(true) + expect(result.current.versions).toEqual({ + importedVersion: '0.8.0', + systemVersion: '1.0.0', + }) + + vi.useRealTimers() + }) + + it('should default version strings to empty when undefined', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + + mockImportDSL.mockResolvedValue({ + id: 'import-id', + status: DSLImportStatus.PENDING, + pipeline_id: 'test-pipeline-id', + imported_dsl_version: undefined, + current_dsl_version: undefined, + }) + + const { result } = renderUpdateDSLModal() + act(() => { + result.current.handleFile(createFile()) + }) + + await act(async () => { + await (result.current.handleImport as unknown as AsyncFn)() + await vi.advanceTimersByTimeAsync(350) + }) + + expect(result.current.versions).toEqual({ + importedVersion: '', + systemVersion: '', + }) + + vi.useRealTimers() + }) + + // FAILED / unknown status + it('should notify error on FAILED status', async () => { + mockImportDSL.mockResolvedValue({ + id: 'import-id', + status: DSLImportStatus.FAILED, + pipeline_id: 'test-pipeline-id', + }) + + const { result } = renderUpdateDSLModal() + act(() => { + result.current.handleFile(createFile()) + }) + + await act(async () => { + await (result.current.handleImport as unknown as AsyncFn)() + }) + + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) + }) + + // Exception + it('should notify error when importDSL throws', async () => { + mockImportDSL.mockRejectedValue(new Error('Network error')) + + const { result } = renderUpdateDSLModal() + act(() => { + result.current.handleFile(createFile()) + }) + + await act(async () => { + await (result.current.handleImport as unknown as AsyncFn)() + }) + + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) + }) + + // Missing pipeline_id + it('should notify error when pipeline_id is missing on success', async () => { + mockImportDSL.mockResolvedValue({ + id: 'import-id', + status: DSLImportStatus.COMPLETED, + pipeline_id: undefined, + }) + + const { result } = renderUpdateDSLModal() + act(() => { + result.current.handleFile(createFile()) + }) + + await act(async () => { + await (result.current.handleImport as unknown as AsyncFn)() + }) + + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) + }) + }) + + // Confirm flow (after PENDING → version mismatch) + describe('onUpdateDSLConfirm', () => { + // Helper: drive the hook into PENDING state so importId is set + const setupPendingState = async (result: { current: ReturnType }) => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + + mockImportDSL.mockResolvedValue({ + id: 'import-id', + status: DSLImportStatus.PENDING, + pipeline_id: 'test-pipeline-id', + imported_dsl_version: '0.8.0', + current_dsl_version: '1.0.0', + }) + + act(() => { + result.current.handleFile(createFile()) + }) + + await act(async () => { + await (result.current.handleImport as unknown as AsyncFn)() + await vi.advanceTimersByTimeAsync(350) + }) + + vi.useRealTimers() + vi.clearAllMocks() + mockHandleCheckPluginDependencies.mockResolvedValue(undefined) + } + + it('should call importDSLConfirm with the stored importId', async () => { + mockImportDSLConfirm.mockResolvedValue({ + status: DSLImportStatus.COMPLETED, + pipeline_id: 'test-pipeline-id', + }) + + const { result } = renderUpdateDSLModal() + await setupPendingState(result) + + await act(async () => { + await (result.current.onUpdateDSLConfirm as unknown as AsyncFn)() + }) + + expect(mockImportDSLConfirm).toHaveBeenCalledWith('import-id') + }) + + it('should notify success and call onCancel after successful confirm', async () => { + mockImportDSLConfirm.mockResolvedValue({ + status: DSLImportStatus.COMPLETED, + pipeline_id: 'test-pipeline-id', + }) + + const { result } = renderUpdateDSLModal() + await setupPendingState(result) + + await act(async () => { + await (result.current.onUpdateDSLConfirm as unknown as AsyncFn)() + }) + + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' })) + expect(mockOnCancel).toHaveBeenCalled() + }) + + it('should call onImport after successful confirm', async () => { + mockImportDSLConfirm.mockResolvedValue({ + status: DSLImportStatus.COMPLETED, + pipeline_id: 'test-pipeline-id', + }) + + const { result } = renderUpdateDSLModal() + await setupPendingState(result) + + await act(async () => { + await (result.current.onUpdateDSLConfirm as unknown as AsyncFn)() + }) + + expect(mockOnImport).toHaveBeenCalled() + }) + + it('should notify error on FAILED confirm status', async () => { + mockImportDSLConfirm.mockResolvedValue({ + status: DSLImportStatus.FAILED, + pipeline_id: 'test-pipeline-id', + }) + + const { result } = renderUpdateDSLModal() + await setupPendingState(result) + + await act(async () => { + await (result.current.onUpdateDSLConfirm as unknown as AsyncFn)() + }) + + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) + }) + + it('should notify error when confirm throws exception', async () => { + mockImportDSLConfirm.mockRejectedValue(new Error('Confirm failed')) + + const { result } = renderUpdateDSLModal() + await setupPendingState(result) + + await act(async () => { + await (result.current.onUpdateDSLConfirm as unknown as AsyncFn)() + }) + + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) + }) + + it('should notify error when confirm succeeds but pipeline_id is missing', async () => { + mockImportDSLConfirm.mockResolvedValue({ + status: DSLImportStatus.COMPLETED, + pipeline_id: undefined, + }) + + const { result } = renderUpdateDSLModal() + await setupPendingState(result) + + await act(async () => { + await (result.current.onUpdateDSLConfirm as unknown as AsyncFn)() + }) + + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) + }) + + it('should not call importDSLConfirm when importId is not set', async () => { + const { result } = renderUpdateDSLModal() + + // No pending state → importId is undefined + await act(async () => { + await (result.current.onUpdateDSLConfirm as unknown as AsyncFn)() + }) + + expect(mockImportDSLConfirm).not.toHaveBeenCalled() + }) + }) + + // Optional onImport callback + describe('optional onImport', () => { + it('should work without onImport callback', async () => { + const { result } = renderHook(() => + useUpdateDSLModal({ onCancel: mockOnCancel }), + ) + + act(() => { + result.current.handleFile(createFile()) + }) + + await act(async () => { + await (result.current.handleImport as unknown as AsyncFn)() + }) + + // Should succeed without throwing + expect(mockOnCancel).toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/rag-pipeline/hooks/use-update-dsl-modal.ts b/web/app/components/rag-pipeline/hooks/use-update-dsl-modal.ts new file mode 100644 index 0000000000..3b86937417 --- /dev/null +++ b/web/app/components/rag-pipeline/hooks/use-update-dsl-modal.ts @@ -0,0 +1,205 @@ +import type { MouseEventHandler } from 'react' +import { + useCallback, + useRef, + useState, +} from 'react' +import { useTranslation } from 'react-i18next' +import { useContext } from 'use-context-selector' +import { ToastContext } from '@/app/components/base/toast' +import { WORKFLOW_DATA_UPDATE } from '@/app/components/workflow/constants' +import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks' +import { useWorkflowStore } from '@/app/components/workflow/store' +import { + initialEdges, + initialNodes, +} from '@/app/components/workflow/utils' +import { useEventEmitterContextContext } from '@/context/event-emitter' +import { + DSLImportMode, + DSLImportStatus, +} from '@/models/app' +import { + useImportPipelineDSL, + useImportPipelineDSLConfirm, +} from '@/service/use-pipeline' +import { fetchWorkflowDraft } from '@/service/workflow' + +type VersionInfo = { + importedVersion: string + systemVersion: string +} + +type UseUpdateDSLModalParams = { + onCancel: () => void + onImport?: () => void +} + +const isCompletedStatus = (status: DSLImportStatus): boolean => + status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS + +export const useUpdateDSLModal = ({ onCancel, onImport }: UseUpdateDSLModalParams) => { + const { t } = useTranslation() + const { notify } = useContext(ToastContext) + const { eventEmitter } = useEventEmitterContextContext() + const workflowStore = useWorkflowStore() + const { handleCheckPluginDependencies } = usePluginDependencies() + const { mutateAsync: importDSL } = useImportPipelineDSL() + const { mutateAsync: importDSLConfirm } = useImportPipelineDSLConfirm() + + // File state + const [currentFile, setDSLFile] = useState() + const [fileContent, setFileContent] = useState() + + // Modal state + const [show, setShow] = useState(true) + const [showErrorModal, setShowErrorModal] = useState(false) + + // Import state + const [loading, setLoading] = useState(false) + const [versions, setVersions] = useState() + const [importId, setImportId] = useState() + const isCreatingRef = useRef(false) + + const readFile = (file: File) => { + const reader = new FileReader() + reader.onload = (event) => { + setFileContent(event.target?.result as string) + } + reader.readAsText(file) + } + + const handleFile = (file?: File) => { + setDSLFile(file) + if (file) + readFile(file) + if (!file) + setFileContent('') + } + + const notifyError = useCallback(() => { + setLoading(false) + notify({ type: 'error', message: t('common.importFailure', { ns: 'workflow' }) }) + }, [notify, t]) + + const updateWorkflow = useCallback(async (pipelineId: string) => { + const { graph, hash, rag_pipeline_variables } = await fetchWorkflowDraft( + `/rag/pipelines/${pipelineId}/workflows/draft`, + ) + const { nodes, edges, viewport } = graph + + eventEmitter?.emit({ + type: WORKFLOW_DATA_UPDATE, + payload: { + nodes: initialNodes(nodes, edges), + edges: initialEdges(edges, nodes), + viewport, + hash, + rag_pipeline_variables: rag_pipeline_variables || [], + }, + }) + }, [eventEmitter]) + + const completeImport = useCallback(async ( + pipelineId: string | undefined, + status: DSLImportStatus = DSLImportStatus.COMPLETED, + ) => { + if (!pipelineId) { + notifyError() + return + } + + updateWorkflow(pipelineId) + onImport?.() + + const isWarning = status === DSLImportStatus.COMPLETED_WITH_WARNINGS + notify({ + type: isWarning ? 'warning' : 'success', + message: t(isWarning ? 'common.importWarning' : 'common.importSuccess', { ns: 'workflow' }), + children: isWarning && t('common.importWarningDetails', { ns: 'workflow' }), + }) + + await handleCheckPluginDependencies(pipelineId, true) + setLoading(false) + onCancel() + }, [updateWorkflow, onImport, notify, t, handleCheckPluginDependencies, onCancel, notifyError]) + + const showVersionMismatch = useCallback(( + id: string, + importedVersion?: string, + systemVersion?: string, + ) => { + setShow(false) + setTimeout(() => setShowErrorModal(true), 300) + setVersions({ + importedVersion: importedVersion ?? '', + systemVersion: systemVersion ?? '', + }) + setImportId(id) + }, []) + + const handleImport: MouseEventHandler = useCallback(async () => { + const { pipelineId } = workflowStore.getState() + if (isCreatingRef.current) + return + isCreatingRef.current = true + if (!currentFile) + return + + try { + if (!pipelineId || !fileContent) + return + + setLoading(true) + const response = await importDSL({ + mode: DSLImportMode.YAML_CONTENT, + yaml_content: fileContent, + pipeline_id: pipelineId, + }) + const { id, status, pipeline_id, imported_dsl_version, current_dsl_version } = response + + if (isCompletedStatus(status)) + await completeImport(pipeline_id, status) + else if (status === DSLImportStatus.PENDING) + showVersionMismatch(id, imported_dsl_version, current_dsl_version) + else + notifyError() + } + catch { + notifyError() + } + isCreatingRef.current = false + }, [currentFile, fileContent, workflowStore, importDSL, completeImport, showVersionMismatch, notifyError]) + + const onUpdateDSLConfirm: MouseEventHandler = useCallback(async () => { + if (!importId) + return + + try { + const { status, pipeline_id } = await importDSLConfirm(importId) + + if (status === DSLImportStatus.COMPLETED) { + await completeImport(pipeline_id) + return + } + + if (status === DSLImportStatus.FAILED) + notifyError() + } + catch { + notifyError() + } + }, [importId, importDSLConfirm, completeImport, notifyError]) + + return { + currentFile, + handleFile, + show, + showErrorModal, + setShowErrorModal, + loading, + versions, + handleImport, + onUpdateDSLConfirm, + } +} diff --git a/web/context/event-emitter.tsx b/web/context/event-emitter.tsx index 61a605cabf..14b81eacb6 100644 --- a/web/context/event-emitter.tsx +++ b/web/context/event-emitter.tsx @@ -4,7 +4,19 @@ import type { EventEmitter } from 'ahooks/lib/useEventEmitter' import { useEventEmitter } from 'ahooks' import { createContext, useContext } from 'use-context-selector' -const EventEmitterContext = createContext<{ eventEmitter: EventEmitter | null }>({ +/** + * Typed event object emitted via the shared EventEmitter. + * Covers workflow updates, prompt-editor commands, DSL export checks, etc. + */ +export type EventEmitterMessage = { + type: string + payload?: unknown + instanceId?: string +} + +export type EventEmitterValue = string | EventEmitterMessage + +const EventEmitterContext = createContext<{ eventEmitter: EventEmitter | null }>({ eventEmitter: null, }) @@ -16,7 +28,7 @@ type EventEmitterContextProviderProps = { export const EventEmitterContextProvider = ({ children, }: EventEmitterContextProviderProps) => { - const eventEmitter = useEventEmitter() + const eventEmitter = useEventEmitter() return ( diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index 5684380979..02aa8707b4 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -3822,14 +3822,6 @@ "count": 3 } }, - "app/components/datasets/documents/detail/metadata/index.tsx": { - "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": { "tailwindcss/enforce-consistent-class-order": { "count": 3 @@ -5664,10 +5656,12 @@ }, "app/components/rag-pipeline/components/update-dsl-modal.tsx": { "tailwindcss/enforce-consistent-class-order": { - "count": 5 - }, - "ts/no-explicit-any": { - "count": 1 + "count": 3 + } + }, + "app/components/rag-pipeline/components/version-mismatch-modal.tsx": { + "tailwindcss/enforce-consistent-class-order": { + "count": 2 } }, "app/components/rag-pipeline/hooks/use-DSL.ts": {