Compare commits

..

23 Commits

Author SHA1 Message Date
a18d69ddcd merge main 2026-01-26 15:00:55 +08:00
865202c7d7 Merge branch 'main' into feat/summary-index 2026-01-26 09:53:35 +08:00
7e6c130aae fix: summary status 2026-01-23 16:43:57 +08:00
a9ec2283d9 Merge branch 'main' into feat/summary-index 2026-01-23 13:42:19 +08:00
51b985963b Merge branch 'main' into feat/summary-index 2026-01-23 11:36:13 +08:00
ce81d62d40 fix: preview chunk summary 2026-01-23 11:12:27 +08:00
513a49b969 fix: edit summary 2026-01-22 14:55:50 +08:00
0f80788cd1 Merge branch 'main' into feat/summary-index 2026-01-22 14:27:23 +08:00
22b69241e6 merge main 2026-01-21 14:05:22 +08:00
e9d6ae7925 feat: summary index remove in create doc 2026-01-21 13:59:39 +08:00
5293fbe8ba fix: hit testing chunk detail summary 2026-01-19 15:35:07 +08:00
22974ea6b0 fix: preview chunk summary 2026-01-19 15:13:51 +08:00
62c3f14570 Merge branch 'main' into feat/summary-index 2026-01-19 10:21:40 +08:00
d97f2df85c Merge branch 'main' into feat/summary-index 2026-01-16 10:55:58 +08:00
fde8efa4a2 fix: summary index in parent child chunk 2026-01-16 10:49:38 +08:00
f02adc26e5 fix: pipeline run panel summary 2026-01-15 18:02:19 +08:00
1126a2aa95 merge main 2026-01-15 16:08:29 +08:00
7c3ce7b1e6 fix: summary index change in create document 2026-01-15 13:48:07 +08:00
830a7fb034 Merge branch 'main' into feat/summary-index 2026-01-14 13:40:15 +08:00
01a7dbcee8 Merge branch 'main' into feat/summary-index 2026-01-13 16:29:09 +08:00
4fe8d2491e feat: summary index 2026-01-13 16:27:32 +08:00
5c2ae922bc merge main 2026-01-12 13:42:17 +08:00
13eec13a14 feat: summary index 2026-01-12 13:38:18 +08:00
68 changed files with 1325 additions and 7158 deletions

View File

@ -0,0 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 7.33337V2.66671H4.00002V13.3334H8.00002C8.36821 13.3334 8.66669 13.6319 8.66669 14C8.66669 14.3682 8.36821 14.6667 8.00002 14.6667H3.33335C2.96516 14.6667 2.66669 14.3682 2.66669 14V2.00004C2.66669 1.63185 2.96516 1.33337 3.33335 1.33337H12.6667C13.0349 1.33337 13.3334 1.63185 13.3334 2.00004V7.33337C13.3334 7.70156 13.0349 8.00004 12.6667 8.00004C12.2985 8.00004 12 7.70156 12 7.33337Z" fill="#354052"/>
<path d="M10 4.00004C10.3682 4.00004 10.6667 4.29852 10.6667 4.66671C10.6667 5.0349 10.3682 5.33337 10 5.33337H6.00002C5.63183 5.33337 5.33335 5.0349 5.33335 4.66671C5.33335 4.29852 5.63183 4.00004 6.00002 4.00004H10Z" fill="#354052"/>
<path d="M8.00002 6.66671C8.36821 6.66671 8.66669 6.96518 8.66669 7.33337C8.66669 7.70156 8.36821 8.00004 8.00002 8.00004H6.00002C5.63183 8.00004 5.33335 7.70156 5.33335 7.33337C5.33335 6.96518 5.63183 6.66671 6.00002 6.66671H8.00002Z" fill="#354052"/>
<path d="M12.827 10.7902L12.3624 9.58224C12.3048 9.43231 12.1607 9.33337 12 9.33337C11.8394 9.33337 11.6953 9.43231 11.6376 9.58224L11.173 10.7902C11.1054 10.9662 10.9662 11.1054 10.7902 11.173L9.58222 11.6376C9.43229 11.6953 9.33335 11.8394 9.33335 12C9.33335 12.1607 9.43229 12.3048 9.58222 12.3624L10.7902 12.827C10.9662 12.8947 11.1054 13.0338 11.173 13.2099L11.6376 14.4178C11.6953 14.5678 11.8394 14.6667 12 14.6667C12.1607 14.6667 12.3048 14.5678 12.3624 14.4178L12.827 13.2099C12.8947 13.0338 13.0338 12.8947 13.2099 12.827L14.4178 12.3624C14.5678 12.3048 14.6667 12.1607 14.6667 12C14.6667 11.8394 14.5678 11.6953 14.4178 11.6376L13.2099 11.173C13.0338 11.1054 12.8947 10.9662 12.827 10.7902Z" fill="#354052"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1,53 @@
{
"icon": {
"type": "element",
"isRootNode": true,
"name": "svg",
"attributes": {
"width": "16",
"height": "16",
"viewBox": "0 0 16 16",
"fill": "none",
"xmlns": "http://www.w3.org/2000/svg"
},
"children": [
{
"type": "element",
"name": "path",
"attributes": {
"d": "M12 7.33337V2.66671H4.00002V13.3334H8.00002C8.36821 13.3334 8.66669 13.6319 8.66669 14C8.66669 14.3682 8.36821 14.6667 8.00002 14.6667H3.33335C2.96516 14.6667 2.66669 14.3682 2.66669 14V2.00004C2.66669 1.63185 2.96516 1.33337 3.33335 1.33337H12.6667C13.0349 1.33337 13.3334 1.63185 13.3334 2.00004V7.33337C13.3334 7.70156 13.0349 8.00004 12.6667 8.00004C12.2985 8.00004 12 7.70156 12 7.33337Z",
"fill": "currentColor"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"d": "M10 4.00004C10.3682 4.00004 10.6667 4.29852 10.6667 4.66671C10.6667 5.0349 10.3682 5.33337 10 5.33337H6.00002C5.63183 5.33337 5.33335 5.0349 5.33335 4.66671C5.33335 4.29852 5.63183 4.00004 6.00002 4.00004H10Z",
"fill": "currentColor"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"d": "M8.00002 6.66671C8.36821 6.66671 8.66669 6.96518 8.66669 7.33337C8.66669 7.70156 8.36821 8.00004 8.00002 8.00004H6.00002C5.63183 8.00004 5.33335 7.70156 5.33335 7.33337C5.33335 6.96518 5.63183 6.66671 6.00002 6.66671H8.00002Z",
"fill": "currentColor"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"d": "M12.827 10.7902L12.3624 9.58224C12.3048 9.43231 12.1607 9.33337 12 9.33337C11.8394 9.33337 11.6953 9.43231 11.6376 9.58224L11.173 10.7902C11.1054 10.9662 10.9662 11.1054 10.7902 11.173L9.58222 11.6376C9.43229 11.6953 9.33335 11.8394 9.33335 12C9.33335 12.1607 9.43229 12.3048 9.58222 12.3624L10.7902 12.827C10.9662 12.8947 11.1054 13.0338 11.173 13.2099L11.6376 14.4178C11.6953 14.5678 11.8394 14.6667 12 14.6667C12.1607 14.6667 12.3048 14.5678 12.3624 14.4178L12.827 13.2099C12.8947 13.0338 13.0338 12.8947 13.2099 12.827L14.4178 12.3624C14.5678 12.3048 14.6667 12.1607 14.6667 12C14.6667 11.8394 14.5678 11.6953 14.4178 11.6376L13.2099 11.173C13.0338 11.1054 12.8947 10.9662 12.827 10.7902Z",
"fill": "currentColor"
},
"children": []
}
]
},
"name": "SearchLinesSparkle"
}

View File

@ -0,0 +1,20 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import type { IconData } from '@/app/components/base/icons/IconBase'
import * as React from 'react'
import IconBase from '@/app/components/base/icons/IconBase'
import data from './SearchLinesSparkle.json'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'SearchLinesSparkle'
export default Icon

View File

@ -11,5 +11,6 @@ export { default as HighQuality } from './HighQuality'
export { default as HybridSearch } from './HybridSearch'
export { default as ParentChildChunk } from './ParentChildChunk'
export { default as QuestionAndAnswer } from './QuestionAndAnswer'
export { default as SearchLinesSparkle } from './SearchLinesSparkle'
export { default as SearchMenu } from './SearchMenu'
export { default as VectorSearch } from './VectorSearch'

View File

@ -1,7 +1,7 @@
'use client'
import type { FC } from 'react'
import type { PreProcessingRule } from '@/models/datasets'
import type { PreProcessingRule, SummaryIndexSetting as SummaryIndexSettingType } from '@/models/datasets'
import {
RiAlertFill,
RiSearchEyeLine,
@ -12,6 +12,7 @@ import Button from '@/app/components/base/button'
import Checkbox from '@/app/components/base/checkbox'
import Divider from '@/app/components/base/divider'
import Tooltip from '@/app/components/base/tooltip'
import SummaryIndexSetting from '@/app/components/datasets/settings/summary-index-setting'
import { IS_CE_EDITION } from '@/config'
import { ChunkingMode } from '@/models/datasets'
import SettingCog from '../../assets/setting-gear-mod.svg'
@ -52,6 +53,9 @@ type GeneralChunkingOptionsProps = {
onReset: () => void
// Locale
locale: string
showSummaryIndexSetting?: boolean
summaryIndexSetting?: SummaryIndexSettingType
onSummaryIndexSettingChange?: (payload: SummaryIndexSettingType) => void
}
export const GeneralChunkingOptions: FC<GeneralChunkingOptionsProps> = ({
@ -74,6 +78,9 @@ export const GeneralChunkingOptions: FC<GeneralChunkingOptionsProps> = ({
onPreview,
onReset,
locale,
showSummaryIndexSetting,
summaryIndexSetting,
onSummaryIndexSettingChange,
}) => {
const { t } = useTranslation()
@ -146,6 +153,17 @@ export const GeneralChunkingOptions: FC<GeneralChunkingOptionsProps> = ({
</label>
</div>
))}
{
showSummaryIndexSetting && (
<div className="mt-3">
<SummaryIndexSetting
entry="create-document"
summaryIndexSetting={summaryIndexSetting}
onSummaryIndexSettingChange={onSummaryIndexSettingChange}
/>
</div>
)
}
{IS_CE_EDITION && (
<>
<Divider type="horizontal" className="my-4 bg-divider-subtle" />

View File

@ -2,7 +2,7 @@
import type { FC } from 'react'
import type { ParentChildConfig } from '../hooks'
import type { ParentMode, PreProcessingRule } from '@/models/datasets'
import type { ParentMode, PreProcessingRule, SummaryIndexSetting as SummaryIndexSettingType } from '@/models/datasets'
import { RiSearchEyeLine } from '@remixicon/react'
import Image from 'next/image'
import { useTranslation } from 'react-i18next'
@ -11,6 +11,7 @@ import Checkbox from '@/app/components/base/checkbox'
import Divider from '@/app/components/base/divider'
import { ParentChildChunk } from '@/app/components/base/icons/src/vender/knowledge'
import RadioCard from '@/app/components/base/radio-card'
import SummaryIndexSetting from '@/app/components/datasets/settings/summary-index-setting'
import { ChunkingMode } from '@/models/datasets'
import FileList from '../../assets/file-list-3-fill.svg'
import Note from '../../assets/note-mod.svg'
@ -31,6 +32,8 @@ type ParentChildOptionsProps = {
// State
parentChildConfig: ParentChildConfig
rules: PreProcessingRule[]
summaryIndexSetting?: SummaryIndexSettingType
onSummaryIndexSettingChange?: (payload: SummaryIndexSettingType) => void
currentDocForm: ChunkingMode
// Flags
isActive: boolean
@ -46,11 +49,13 @@ type ParentChildOptionsProps = {
onRuleToggle: (id: string) => void
onPreview: () => void
onReset: () => void
showSummaryIndexSetting?: boolean
}
export const ParentChildOptions: FC<ParentChildOptionsProps> = ({
parentChildConfig,
rules,
summaryIndexSetting,
currentDocForm: _currentDocForm,
isActive,
isInUpload,
@ -62,8 +67,10 @@ export const ParentChildOptions: FC<ParentChildOptionsProps> = ({
onChildDelimiterChange,
onChildMaxLengthChange,
onRuleToggle,
onSummaryIndexSettingChange,
onPreview,
onReset,
showSummaryIndexSetting,
}) => {
const { t } = useTranslation()
@ -183,6 +190,17 @@ export const ParentChildOptions: FC<ParentChildOptionsProps> = ({
</label>
</div>
))}
{
showSummaryIndexSetting && (
<div className="mt-3">
<SummaryIndexSetting
entry="create-document"
summaryIndexSetting={summaryIndexSetting}
onSummaryIndexSettingChange={onSummaryIndexSettingChange}
/>
</div>
)
}
</div>
</div>
</div>

View File

@ -14,6 +14,7 @@ import { ChunkingMode } from '@/models/datasets'
import { cn } from '@/utils/classnames'
import { ChunkContainer, QAPreview } from '../../../chunk'
import PreviewDocumentPicker from '../../../common/document-picker/preview-document-picker'
import SummaryLabel from '../../../documents/detail/completed/common/summary-label'
import { PreviewSlice } from '../../../formatted-text/flavours/preview-slice'
import { FormattedText } from '../../../formatted-text/formatted'
import PreviewContainer from '../../../preview/container'
@ -99,6 +100,7 @@ export const PreviewPanel: FC<PreviewPanelProps> = ({
characterCount={item.content.length}
>
{item.content}
{item.summary && <SummaryLabel summary={item.summary} />}
</ChunkContainer>
))
)}
@ -131,6 +133,7 @@ export const PreviewPanel: FC<PreviewPanelProps> = ({
)
})}
</FormattedText>
{item.summary && <SummaryLabel summary={item.summary} />}
</ChunkContainer>
)
})

View File

@ -9,6 +9,7 @@ import type {
CustomFile,
FullDocumentDetail,
ProcessRule,
SummaryIndexSetting as SummaryIndexSettingType,
} from '@/models/datasets'
import type { RetrievalConfig, RETRIEVE_METHOD } from '@/types/app'
import { useCallback } from 'react'
@ -141,6 +142,7 @@ export const useDocumentCreation = (options: UseDocumentCreationOptions) => {
retrievalConfig: RetrievalConfig,
embeddingModel: DefaultModel,
indexingTechnique: string,
summaryIndexSetting?: SummaryIndexSettingType,
): CreateDocumentReq | null => {
if (isSetting) {
return {
@ -148,6 +150,7 @@ export const useDocumentCreation = (options: UseDocumentCreationOptions) => {
doc_form: currentDocForm,
doc_language: docLanguage,
process_rule: processRule,
summary_index_setting: summaryIndexSetting,
retrieval_model: retrievalConfig,
embedding_model: embeddingModel.model,
embedding_model_provider: embeddingModel.provider,
@ -164,6 +167,7 @@ export const useDocumentCreation = (options: UseDocumentCreationOptions) => {
},
indexing_technique: indexingTechnique,
process_rule: processRule,
summary_index_setting: summaryIndexSetting,
doc_form: currentDocForm,
doc_language: docLanguage,
retrieval_model: retrievalConfig,

View File

@ -1,5 +1,5 @@
import type { ParentMode, PreProcessingRule, ProcessRule, Rules } from '@/models/datasets'
import { useCallback, useState } from 'react'
import type { ParentMode, PreProcessingRule, ProcessRule, Rules, SummaryIndexSetting as SummaryIndexSettingType } from '@/models/datasets'
import { useCallback, useRef, useState } from 'react'
import { ChunkingMode, ProcessMode } from '@/models/datasets'
import escape from './escape'
import unescape from './unescape'
@ -39,10 +39,11 @@ export const defaultParentChildConfig: ParentChildConfig = {
export type UseSegmentationStateOptions = {
initialSegmentationType?: ProcessMode
initialSummaryIndexSetting?: SummaryIndexSettingType
}
export const useSegmentationState = (options: UseSegmentationStateOptions = {}) => {
const { initialSegmentationType } = options
const { initialSegmentationType, initialSummaryIndexSetting } = options
// Segmentation type (general or parent-child)
const [segmentationType, setSegmentationType] = useState<ProcessMode>(
@ -58,6 +59,12 @@ export const useSegmentationState = (options: UseSegmentationStateOptions = {})
// Pre-processing rules
const [rules, setRules] = useState<PreProcessingRule[]>([])
const [defaultConfig, setDefaultConfig] = useState<Rules>()
const [summaryIndexSetting, setSummaryIndexSetting] = useState<SummaryIndexSettingType | undefined>(initialSummaryIndexSetting)
const summaryIndexSettingRef = useRef<SummaryIndexSettingType | undefined>(initialSummaryIndexSetting)
const handleSummaryIndexSettingChange = useCallback((payload: SummaryIndexSettingType) => {
setSummaryIndexSetting({ ...summaryIndexSettingRef.current, ...payload })
summaryIndexSettingRef.current = { ...summaryIndexSettingRef.current, ...payload }
}, [])
// Parent-child config
const [parentChildConfig, setParentChildConfig] = useState<ParentChildConfig>(defaultParentChildConfig)
@ -134,6 +141,7 @@ export const useSegmentationState = (options: UseSegmentationStateOptions = {})
},
},
mode: 'hierarchical',
summary_index_setting: summaryIndexSettingRef.current,
} as ProcessRule
}
@ -147,6 +155,7 @@ export const useSegmentationState = (options: UseSegmentationStateOptions = {})
},
},
mode: segmentationType,
summary_index_setting: summaryIndexSettingRef.current,
} as ProcessRule
}, [rules, parentChildConfig, segmentIdentifier, maxChunkLength, overlap, segmentationType])
@ -204,6 +213,8 @@ export const useSegmentationState = (options: UseSegmentationStateOptions = {})
defaultConfig,
setDefaultConfig,
toggleRule,
summaryIndexSetting,
handleSummaryIndexSettingChange,
// Parent-child config
parentChildConfig,

View File

@ -65,7 +65,9 @@ const StepTwo: FC<StepTwoProps> = ({
// Custom hooks
const segmentation = useSegmentationState({
initialSegmentationType: currentDataset?.doc_form === ChunkingMode.parentChild ? ProcessMode.parentChild : ProcessMode.general,
initialSummaryIndexSetting: currentDataset?.summary_index_setting,
})
const showSummaryIndexSetting = !currentDataset
const indexing = useIndexingConfig({
initialIndexType: propsIndexingType,
initialEmbeddingModel: currentDataset?.embedding_model ? { provider: currentDataset.embedding_model_provider, model: currentDataset.embedding_model } : undefined,
@ -156,7 +158,7 @@ const StepTwo: FC<StepTwoProps> = ({
})
if (!isValid)
return
const params = creation.buildCreationParams(currentDocForm, docLanguage, segmentation.getProcessRule(currentDocForm), indexing.retrievalConfig, indexing.embeddingModel, indexing.getIndexingTechnique())
const params = creation.buildCreationParams(currentDocForm, docLanguage, segmentation.getProcessRule(currentDocForm), indexing.retrievalConfig, indexing.embeddingModel, indexing.getIndexingTechnique(), segmentation.summaryIndexSetting)
if (!params)
return
await creation.executeCreation(params, indexing.indexType, indexing.retrievalConfig)
@ -217,6 +219,9 @@ const StepTwo: FC<StepTwoProps> = ({
onPreview={updatePreview}
onReset={segmentation.resetToDefaults}
locale={locale}
showSummaryIndexSetting={showSummaryIndexSetting}
summaryIndexSetting={segmentation.summaryIndexSetting}
onSummaryIndexSettingChange={segmentation.handleSummaryIndexSettingChange}
/>
)}
{showParentChildOption && (
@ -236,6 +241,9 @@ const StepTwo: FC<StepTwoProps> = ({
onRuleToggle={segmentation.toggleRule}
onPreview={updatePreview}
onReset={segmentation.resetToDefaults}
showSummaryIndexSetting={showSummaryIndexSetting}
summaryIndexSetting={segmentation.summaryIndexSetting}
onSummaryIndexSettingChange={segmentation.handleSummaryIndexSettingChange}
/>
)}
<Divider className="my-5" />

View File

@ -30,12 +30,13 @@ import { useDatasetDetailContextWithSelector as useDatasetDetailContext } from '
import useTimestamp from '@/hooks/use-timestamp'
import { ChunkingMode, DataSourceType, DocumentActionType } from '@/models/datasets'
import { DatasourceType } from '@/models/pipeline'
import { useDocumentArchive, useDocumentBatchRetryIndex, useDocumentDelete, useDocumentDisable, useDocumentDownloadZip, useDocumentEnable } from '@/service/knowledge/use-document'
import { useDocumentArchive, useDocumentBatchRetryIndex, useDocumentDelete, useDocumentDisable, useDocumentDownloadZip, useDocumentEnable, useDocumentSummary } from '@/service/knowledge/use-document'
import { asyncRunSafe } from '@/utils'
import { cn } from '@/utils/classnames'
import { downloadBlob } from '@/utils/download'
import { formatNumber } from '@/utils/format'
import BatchAction from '../detail/completed/common/batch-action'
import SummaryStatus from '../detail/completed/common/summary-status'
import StatusItem from '../status-item'
import s from '../style.module.css'
import Operations from './operations'
@ -219,6 +220,7 @@ const DocumentList: FC<IDocumentListProps> = ({
onSelectedIdChange(uniq([...selectedIds, ...localDocs.map(doc => doc.id)]))
}, [isAllSelected, localDocs, onSelectedIdChange, selectedIds])
const { mutateAsync: archiveDocument } = useDocumentArchive()
const { mutateAsync: generateSummary } = useDocumentSummary()
const { mutateAsync: enableDocument } = useDocumentEnable()
const { mutateAsync: disableDocument } = useDocumentDisable()
const { mutateAsync: deleteDocument } = useDocumentDelete()
@ -232,6 +234,9 @@ const DocumentList: FC<IDocumentListProps> = ({
case DocumentActionType.archive:
opApi = archiveDocument
break
case DocumentActionType.summary:
opApi = generateSummary
break
case DocumentActionType.enable:
opApi = enableDocument
break
@ -444,6 +449,13 @@ const DocumentList: FC<IDocumentListProps> = ({
>
<span className="grow-1 truncate text-sm">{doc.name}</span>
</Tooltip>
{
doc.summary_index_status && (
<div className="ml-1 hidden shrink-0 group-hover:flex">
<SummaryStatus status={doc.summary_index_status} />
</div>
)
}
<div className="hidden shrink-0 group-hover:ml-auto group-hover:flex">
<Tooltip
popupContent={t('list.table.rename', { ns: 'datasetDocuments' })}
@ -496,6 +508,7 @@ const DocumentList: FC<IDocumentListProps> = ({
className="absolute bottom-16 left-0 z-20"
selectedIds={selectedIds}
onArchive={handleAction(DocumentActionType.archive)}
onBatchSummary={handleAction(DocumentActionType.summary)}
onBatchEnable={handleAction(DocumentActionType.enable)}
onBatchDisable={handleAction(DocumentActionType.disable)}
onBatchDownload={downloadableSelectedIds.length > 0 ? handleBatchDownload : undefined}

View File

@ -21,6 +21,7 @@ import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Confirm from '@/app/components/base/confirm'
import Divider from '@/app/components/base/divider'
import { SearchLinesSparkle } from '@/app/components/base/icons/src/vender/knowledge'
import CustomPopover from '@/app/components/base/popover'
import Switch from '@/app/components/base/switch'
import { ToastContext } from '@/app/components/base/toast'
@ -34,6 +35,7 @@ import {
useDocumentEnable,
useDocumentPause,
useDocumentResume,
useDocumentSummary,
useDocumentUnArchive,
useSyncDocument,
useSyncWebsite,
@ -87,6 +89,7 @@ const Operations = ({
const { mutateAsync: downloadDocument, isPending: isDownloading } = useDocumentDownload()
const { mutateAsync: syncDocument } = useSyncDocument()
const { mutateAsync: syncWebsite } = useSyncWebsite()
const { mutateAsync: generateSummary } = useDocumentSummary()
const { mutateAsync: pauseDocument } = useDocumentPause()
const { mutateAsync: resumeDocument } = useDocumentResume()
const isListScene = scene === 'list'
@ -112,6 +115,9 @@ const Operations = ({
else
opApi = syncWebsite
break
case 'summary':
opApi = generateSummary
break
case 'pause':
opApi = pauseDocument
break
@ -257,6 +263,10 @@ const Operations = ({
<span className={s.actionName}>{t('list.action.sync', { ns: 'datasetDocuments' })}</span>
</div>
)}
<div className={s.actionItem} onClick={() => onOperate('summary')}>
<SearchLinesSparkle className="h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.summary', { ns: 'datasetDocuments' })}</span>
</div>
<Divider className="my-1" />
</>
)}

View File

@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
import Button from '@/app/components/base/button'
import { SkeletonContainer, SkeletonPoint, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import SummaryLabel from '@/app/components/datasets/documents/detail/completed/common/summary-label'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { ChunkingMode } from '@/models/datasets'
import { DatasourceType } from '@/models/pipeline'
@ -181,6 +182,7 @@ const ChunkPreview = ({
characterCount={item.content.length}
>
{item.content}
{item.summary && <SummaryLabel summary={item.summary} />}
</ChunkContainer>
))
)}
@ -207,6 +209,7 @@ const ChunkPreview = ({
/>
)
})}
{item.summary && <SummaryLabel summary={item.summary} />}
</FormattedText>
</ChunkContainer>
)

View File

@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Confirm from '@/app/components/base/confirm'
import Divider from '@/app/components/base/divider'
import { SearchLinesSparkle } from '@/app/components/base/icons/src/vender/knowledge'
import { cn } from '@/utils/classnames'
const i18nPrefix = 'batchAction'
@ -16,6 +17,7 @@ type IBatchActionProps = {
onBatchDisable: () => void
onBatchDownload?: () => void
onBatchDelete: () => Promise<void>
onBatchSummary?: () => void
onArchive?: () => void
onEditMetadata?: () => void
onBatchReIndex?: () => void
@ -27,6 +29,7 @@ const BatchAction: FC<IBatchActionProps> = ({
selectedIds,
onBatchEnable,
onBatchDisable,
onBatchSummary,
onBatchDownload,
onArchive,
onBatchDelete,
@ -84,7 +87,16 @@ const BatchAction: FC<IBatchActionProps> = ({
<span className="px-0.5">{t('metadata.metadata', { ns: 'dataset' })}</span>
</Button>
)}
{onBatchSummary && (
<Button
variant="ghost"
className="gap-x-0.5 px-3"
onClick={onBatchSummary}
>
<SearchLinesSparkle className="size-4" />
<span className="px-0.5">{t('list.action.summary', { ns: 'datasetDocuments' })}</span>
</Button>
)}
{onArchive && (
<Button
variant="ghost"

View File

@ -0,0 +1,26 @@
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/utils/classnames'
type SummaryLabelProps = {
summary?: string
className?: string
}
const SummaryLabel = ({
summary,
className,
}: SummaryLabelProps) => {
const { t } = useTranslation()
return (
<div className={cn('space-y-1', className)}>
<div className="system-xs-medium-uppercase mt-2 flex items-center justify-between text-text-tertiary">
{t('segment.summary', { ns: 'datasetDocuments' })}
<div className="ml-2 h-px grow bg-divider-regular"></div>
</div>
<div className="body-xs-regular text-text-tertiary">{summary}</div>
</div>
)
}
export default memo(SummaryLabel)

View File

@ -0,0 +1,37 @@
import { memo, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
import { SearchLinesSparkle } from '@/app/components/base/icons/src/vender/knowledge'
import Tooltip from '@/app/components/base/tooltip'
type SummaryStatusProps = {
status: string
}
const SummaryStatus = ({ status }: SummaryStatusProps) => {
const { t } = useTranslation()
const tip = useMemo(() => {
if (status === 'SUMMARIZING') {
return t('list.summary.generatingSummary', { ns: 'datasetDocuments' })
}
return ''
}, [status, t])
return (
<Tooltip
popupContent={tip}
>
{
status === 'SUMMARIZING' && (
<Badge className="border-text-accent-secondary text-text-accent-secondary">
<SearchLinesSparkle className="mr-0.5 h-3 w-3" />
<span>{t('list.summary.generating', { ns: 'datasetDocuments' })}</span>
</Badge>
)
}
</Tooltip>
)
}
export default memo(SummaryStatus)

View File

@ -0,0 +1,35 @@
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import Textarea from 'react-textarea-autosize'
import { cn } from '@/utils/classnames'
type SummaryTextProps = {
value?: string
onChange?: (value: string) => void
disabled?: boolean
}
const SummaryText = ({
value,
onChange,
disabled,
}: SummaryTextProps) => {
const { t } = useTranslation()
return (
<div className="space-y-1">
<div className="system-xs-medium-uppercase text-text-tertiary">{t('segment.summary', { ns: 'datasetDocuments' })}</div>
<Textarea
className={cn(
'body-sm-regular w-full resize-none bg-transparent leading-6 text-text-secondary outline-none',
)}
placeholder={t('segment.summaryPlaceholder', { ns: 'datasetDocuments' })}
minRows={1}
value={value ?? ''}
onChange={e => onChange?.(e.target.value)}
disabled={disabled}
/>
</div>
)
}
export default memo(SummaryText)

View File

@ -22,6 +22,7 @@ type DrawerGroupProps = {
answer: string,
keywords: string[],
attachments: FileEntity[],
summary?: string,
needRegenerate?: boolean,
) => Promise<void>
isRegenerationModalOpen: boolean

View File

@ -614,7 +614,7 @@ describe('useSegmentListData', () => {
})
await act(async () => {
await result.current.handleUpdateSegment('seg-1', 'content', '', [], [], true)
await result.current.handleUpdateSegment('seg-1', 'content', '', [], [], 'summary', true)
})
expect(onCloseSegmentDetail).not.toHaveBeenCalled()

View File

@ -53,6 +53,7 @@ export type UseSegmentListDataReturn = {
answer: string,
keywords: string[],
attachments: FileEntity[],
summary?: string,
needRegenerate?: boolean,
) => Promise<void>
resetList: () => void
@ -248,6 +249,7 @@ export const useSegmentListData = (options: UseSegmentListDataOptions): UseSegme
answer: string,
keywords: string[],
attachments: FileEntity[],
summary?: string,
needRegenerate = false,
) => {
const params: SegmentUpdater = { content: '', attachment_ids: [] }
@ -285,6 +287,8 @@ export const useSegmentListData = (options: UseSegmentListDataOptions): UseSegme
params.attachment_ids = attachments.map(item => item.uploadedId!)
}
params.summary = summary ?? ''
if (needRegenerate)
params.regenerate_child_chunks = needRegenerate
@ -302,6 +306,7 @@ export const useSegmentListData = (options: UseSegmentListDataOptions): UseSegme
sign_content: res.data.sign_content,
keywords: res.data.keywords,
attachments: res.data.attachments,
summary: res.data.summary,
word_count: res.data.word_count,
hit_count: res.data.hit_count,
enabled: res.data.enabled,

View File

@ -19,13 +19,14 @@ import { useDocumentContext } from '../../context'
import ChildSegmentList from '../child-segment-list'
import Dot from '../common/dot'
import { SegmentIndexTag } from '../common/segment-index-tag'
import SummaryLabel from '../common/summary-label'
import Tag from '../common/tag'
import ParentChunkCardSkeleton from '../skeleton/parent-chunk-card-skeleton'
import ChunkContent from './chunk-content'
type ISegmentCardProps = {
loading: boolean
detail?: SegmentDetailModel & { document?: { name: string } }
detail?: SegmentDetailModel & { document?: { name: string }, status?: string }
onClick?: () => void
onChangeSwitch?: (enabled: boolean, segId?: string) => Promise<void>
onDelete?: (segId: string) => Promise<void>
@ -43,7 +44,7 @@ type ISegmentCardProps = {
}
const SegmentCard: FC<ISegmentCardProps> = ({
detail = {},
detail = { status: '' },
onClick,
onChangeSwitch,
onDelete,
@ -67,6 +68,7 @@ const SegmentCard: FC<ISegmentCardProps> = ({
word_count,
hit_count,
answer,
summary,
keywords,
child_chunks = [],
created_at,
@ -237,6 +239,11 @@ const SegmentCard: FC<ISegmentCardProps> = ({
className={contentOpacity}
/>
{images.length > 0 && <ImageList images={images} size="md" className="py-1" />}
{
summary && (
<SummaryLabel summary={summary} className="mt-2" />
)
}
{isGeneralMode && (
<div className={cn('flex flex-wrap items-center gap-2 py-1.5', contentOpacity)}>
{keywords?.map(keyword => <Tag key={keyword} text={keyword} />)}

View File

@ -25,6 +25,7 @@ import Dot from './common/dot'
import Keywords from './common/keywords'
import RegenerationModal from './common/regeneration-modal'
import { SegmentIndexTag } from './common/segment-index-tag'
import SummaryText from './common/summary-text'
import { useSegmentListContext } from './index'
type ISegmentDetailProps = {
@ -35,6 +36,7 @@ type ISegmentDetailProps = {
a: string,
k: string[],
attachments: FileEntity[],
summary?: string,
needRegenerate?: boolean,
) => void
onCancel: () => void
@ -57,6 +59,7 @@ const SegmentDetail: FC<ISegmentDetailProps> = ({
const { t } = useTranslation()
const [question, setQuestion] = useState(isEditMode ? segInfo?.content || '' : segInfo?.sign_content || '')
const [answer, setAnswer] = useState(segInfo?.answer || '')
const [summary, setSummary] = useState(segInfo?.summary || '')
const [attachments, setAttachments] = useState<FileEntity[]>(() => {
return segInfo?.attachments?.map(item => ({
id: uuid4(),
@ -91,8 +94,8 @@ const SegmentDetail: FC<ISegmentDetailProps> = ({
}, [onCancel])
const handleSave = useCallback(() => {
onUpdate(segInfo?.id || '', question, answer, keywords, attachments)
}, [onUpdate, segInfo?.id, question, answer, keywords, attachments])
onUpdate(segInfo?.id || '', question, answer, keywords, attachments, summary, false)
}, [onUpdate, segInfo?.id, question, answer, keywords, attachments, summary])
const handleRegeneration = useCallback(() => {
setShowRegenerationModal(true)
@ -111,8 +114,8 @@ const SegmentDetail: FC<ISegmentDetailProps> = ({
}, [onCancel, onModalStateChange])
const onConfirmRegeneration = useCallback(() => {
onUpdate(segInfo?.id || '', question, answer, keywords, attachments, true)
}, [onUpdate, segInfo?.id, question, answer, keywords, attachments])
onUpdate(segInfo?.id || '', question, answer, keywords, attachments, summary, true)
}, [onUpdate, segInfo?.id, question, answer, keywords, attachments, summary])
const onAttachmentsChange = useCallback((attachments: FileEntity[]) => {
setAttachments(attachments)
@ -197,6 +200,11 @@ const SegmentDetail: FC<ISegmentDetailProps> = ({
value={attachments}
onChange={onAttachmentsChange}
/>
<SummaryText
value={summary}
onChange={summary => setSummary(summary)}
disabled={!isEditMode}
/>
{isECOIndexing && (
<Keywords
className="w-full"

View File

@ -1 +1 @@
export type OperationName = 'delete' | 'archive' | 'enable' | 'disable' | 'sync' | 'un_archive' | 'pause' | 'resume'
export type OperationName = 'delete' | 'archive' | 'enable' | 'disable' | 'sync' | 'un_archive' | 'pause' | 'resume' | 'summary'

View File

@ -12,6 +12,7 @@ import { cn } from '@/utils/classnames'
import ImageList from '../../common/image-list'
import Dot from '../../documents/detail/completed/common/dot'
import { SegmentIndexTag } from '../../documents/detail/completed/common/segment-index-tag'
import SummaryText from '../../documents/detail/completed/common/summary-text'
import ChildChunksItem from './child-chunks-item'
import Mask from './mask'
import Score from './score'
@ -28,7 +29,7 @@ const ChunkDetailModal = ({
onHide,
}: ChunkDetailModalProps) => {
const { t } = useTranslation()
const { segment, score, child_chunks, files } = payload
const { segment, score, child_chunks, files, summary } = payload
const { position, content, sign_content, keywords, document, answer } = segment
const isParentChildRetrieval = !!(child_chunks && child_chunks.length > 0)
const extension = document.name.split('.').slice(-1)[0] as FileAppearanceTypeEnum
@ -104,11 +105,14 @@ const ChunkDetailModal = ({
{/* Mask */}
<Mask className="absolute inset-x-0 bottom-0" />
</div>
{(showImages || showKeywords) && (
{(showImages || showKeywords || !!summary) && (
<div className="flex flex-col gap-y-3 pt-3">
{showImages && (
<ImageList images={images} size="md" className="py-1" />
)}
{!!summary && (
<SummaryText value={summary} disabled />
)}
{showKeywords && (
<div className="flex flex-col gap-y-1">
<div className="text-xs font-medium uppercase text-text-tertiary">{t(`${i18nPrefix}keyword`, { ns: 'datasetHitTesting' })}</div>

View File

@ -7,6 +7,7 @@ import * as React from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { Markdown } from '@/app/components/base/markdown'
import SummaryLabel from '@/app/components/datasets/documents/detail/completed/common/summary-label'
import Tag from '@/app/components/datasets/documents/detail/completed/common/tag'
import { extensionToFileType } from '@/app/components/datasets/hit-testing/utils/extension-to-file-type'
import { cn } from '@/utils/classnames'
@ -25,7 +26,7 @@ const ResultItem = ({
payload,
}: ResultItemProps) => {
const { t } = useTranslation()
const { segment, score, child_chunks, files } = payload
const { segment, score, child_chunks, files, summary } = payload
const data = segment
const { position, word_count, content, sign_content, keywords, document } = data
const isParentChildRetrieval = !!(child_chunks && child_chunks.length > 0)
@ -98,6 +99,9 @@ const ResultItem = ({
))}
</div>
)}
{summary && (
<SummaryLabel summary={summary} className="mt-2" />
)}
</div>
{/* Foot */}
<ResultItemFooter docType={fileType} docTitle={document.name} showDetailModal={showDetailModal} />

View File

@ -2,7 +2,7 @@
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import type { DefaultModel } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { Member } from '@/models/common'
import type { IconInfo } from '@/models/datasets'
import type { IconInfo, SummaryIndexSetting as SummaryIndexSettingType } from '@/models/datasets'
import type { AppIconType, RetrievalConfig } from '@/types/app'
import { RiAlertFill } from '@remixicon/react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@ -33,6 +33,7 @@ import RetrievalSettings from '../../external-knowledge-base/create/RetrievalSet
import ChunkStructure from '../chunk-structure'
import IndexMethod from '../index-method'
import PermissionSelector from '../permission-selector'
import SummaryIndexSetting from '../summary-index-setting'
import { checkShowMultiModalTip } from '../utils'
const rowClass = 'flex gap-x-1'
@ -76,6 +77,12 @@ const Form = () => {
model: '',
},
)
const [summaryIndexSetting, setSummaryIndexSetting] = useState(currentDataset?.summary_index_setting)
const summaryIndexSettingRef = useRef(currentDataset?.summary_index_setting)
const handleSummaryIndexSettingChange = useCallback((payload: SummaryIndexSettingType) => {
setSummaryIndexSetting({ ...summaryIndexSettingRef.current, ...payload })
summaryIndexSettingRef.current = { ...summaryIndexSettingRef.current, ...payload }
}, [])
const { data: rerankModelList } = useModelList(ModelTypeEnum.rerank)
const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding)
const { data: membersData } = useMembers()
@ -167,6 +174,7 @@ const Form = () => {
},
}),
keyword_number: keywordNumber,
summary_index_setting: summaryIndexSetting,
},
} as any
if (permission === DatasetPermission.partialMembers) {
@ -348,6 +356,23 @@ const Form = () => {
</div>
</div>
)}
{
indexMethod === IndexingType.QUALIFIED
&& [ChunkingMode.text, ChunkingMode.parentChild].includes(currentDataset?.doc_form as ChunkingMode)
&& (
<>
<Divider
type="horizontal"
className="my-1 h-px bg-divider-subtle"
/>
<SummaryIndexSetting
entry="dataset-settings"
summaryIndexSetting={summaryIndexSetting}
onSummaryIndexSettingChange={handleSummaryIndexSettingChange}
/>
</>
)
}
{/* Retrieval Method Config */}
{currentDataset?.provider === 'external'
? (

View File

@ -0,0 +1,228 @@
import type { ChangeEvent } from 'react'
import type { DefaultModel } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { SummaryIndexSetting as SummaryIndexSettingType } from '@/models/datasets'
import {
memo,
useCallback,
useMemo,
} from 'react'
import { useTranslation } from 'react-i18next'
import Switch from '@/app/components/base/switch'
import Textarea from '@/app/components/base/textarea'
import Tooltip from '@/app/components/base/tooltip'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
type SummaryIndexSettingProps = {
entry?: 'knowledge-base' | 'dataset-settings' | 'create-document'
summaryIndexSetting?: SummaryIndexSettingType
onSummaryIndexSettingChange?: (payload: SummaryIndexSettingType) => void
readonly?: boolean
}
const SummaryIndexSetting = ({
entry = 'knowledge-base',
summaryIndexSetting,
onSummaryIndexSettingChange,
readonly = false,
}: SummaryIndexSettingProps) => {
const { t } = useTranslation()
const {
data: textGenerationModelList,
} = useModelList(ModelTypeEnum.textGeneration)
const summaryIndexModelConfig = useMemo(() => {
if (!summaryIndexSetting?.model_name || !summaryIndexSetting?.model_provider_name)
return undefined
return {
providerName: summaryIndexSetting?.model_provider_name,
modelName: summaryIndexSetting?.model_name,
}
}, [summaryIndexSetting?.model_name, summaryIndexSetting?.model_provider_name])
const handleSummaryIndexEnableChange = useCallback((value: boolean) => {
onSummaryIndexSettingChange?.({
enable: value,
})
}, [onSummaryIndexSettingChange])
const handleSummaryIndexModelChange = useCallback((model: DefaultModel) => {
onSummaryIndexSettingChange?.({
model_provider_name: model.provider,
model_name: model.model,
})
}, [onSummaryIndexSettingChange])
const handleSummaryIndexPromptChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
onSummaryIndexSettingChange?.({
summary_prompt: e.target.value,
})
}, [onSummaryIndexSettingChange])
if (entry === 'knowledge-base') {
return (
<div>
<div className="flex h-6 items-center justify-between">
<div className="system-sm-semibold-uppercase flex items-center text-text-secondary">
{t('form.summaryAutoGen', { ns: 'datasetSettings' })}
<Tooltip
triggerClassName="ml-1 h-4 w-4 shrink-0"
popupContent={t('form.summaryAutoGenTip', { ns: 'datasetSettings' })}
>
</Tooltip>
</div>
<Switch
defaultValue={summaryIndexSetting?.enable ?? false}
onChange={handleSummaryIndexEnableChange}
size="md"
/>
</div>
{
summaryIndexSetting?.enable && (
<div>
<div className="system-xs-medium-uppercase mb-1.5 mt-2 flex h-6 items-center text-text-tertiary">
{t('form.summaryModel', { ns: 'datasetSettings' })}
</div>
<ModelSelector
defaultModel={summaryIndexModelConfig && { provider: summaryIndexModelConfig.providerName, model: summaryIndexModelConfig.modelName }}
modelList={textGenerationModelList}
onSelect={handleSummaryIndexModelChange}
readonly={readonly}
showDeprecatedWarnIcon
/>
<div className="system-xs-medium-uppercase mt-3 flex h-6 items-center text-text-tertiary">
{t('form.summaryInstructions', { ns: 'datasetSettings' })}
</div>
<Textarea
value={summaryIndexSetting?.summary_prompt ?? ''}
onChange={handleSummaryIndexPromptChange}
disabled={readonly}
placeholder={t('form.summaryInstructionsPlaceholder', { ns: 'datasetSettings' })}
/>
</div>
)
}
</div>
)
}
if (entry === 'dataset-settings') {
return (
<div className="space-y-4">
<div className="flex gap-x-1">
<div className="flex h-7 w-[180px] shrink-0 items-center pt-1">
<div className="system-sm-semibold text-text-secondary">
{t('form.summaryAutoGen', { ns: 'datasetSettings' })}
</div>
</div>
<div className="py-1.5">
<div className="system-sm-semibold flex items-center text-text-secondary">
<Switch
className="mr-2"
defaultValue={summaryIndexSetting?.enable ?? false}
onChange={handleSummaryIndexEnableChange}
size="md"
/>
{
summaryIndexSetting?.enable ? t('list.status.enabled', { ns: 'datasetDocuments' }) : t('list.status.disabled', { ns: 'datasetDocuments' })
}
</div>
<div className="system-sm-regular mt-2 text-text-tertiary">
{
summaryIndexSetting?.enable && t('form.summaryAutoGenTip', { ns: 'datasetSettings' })
}
{
!summaryIndexSetting?.enable && t('form.summaryAutoGenEnableTip', { ns: 'datasetSettings' })
}
</div>
</div>
</div>
{
summaryIndexSetting?.enable && (
<>
<div className="flex gap-x-1">
<div className="flex h-7 w-[180px] shrink-0 items-center pt-1">
<div className="system-sm-medium text-text-tertiary">
{t('form.summaryModel', { ns: 'datasetSettings' })}
</div>
</div>
<div className="grow">
<ModelSelector
defaultModel={summaryIndexModelConfig && { provider: summaryIndexModelConfig.providerName, model: summaryIndexModelConfig.modelName }}
modelList={textGenerationModelList}
onSelect={handleSummaryIndexModelChange}
readonly={readonly}
showDeprecatedWarnIcon
triggerClassName="h-8"
/>
</div>
</div>
<div className="flex">
<div className="flex h-7 w-[180px] shrink-0 items-center pt-1">
<div className="system-sm-medium text-text-tertiary">
{t('form.summaryInstructions', { ns: 'datasetSettings' })}
</div>
</div>
<div className="grow">
<Textarea
value={summaryIndexSetting?.summary_prompt ?? ''}
onChange={handleSummaryIndexPromptChange}
disabled={readonly}
placeholder={t('form.summaryInstructionsPlaceholder', { ns: 'datasetSettings' })}
/>
</div>
</div>
</>
)
}
</div>
)
}
return (
<div className="space-y-3">
<div className="flex h-6 items-center">
<Switch
className="mr-2"
defaultValue={summaryIndexSetting?.enable ?? false}
onChange={handleSummaryIndexEnableChange}
size="md"
/>
<div className="system-sm-semibold text-text-secondary">
{t('form.summaryAutoGen', { ns: 'datasetSettings' })}
</div>
</div>
{
summaryIndexSetting?.enable && (
<>
<div>
<div className="system-sm-medium mb-1.5 flex h-6 items-center text-text-secondary">
{t('form.summaryModel', { ns: 'datasetSettings' })}
</div>
<ModelSelector
defaultModel={summaryIndexModelConfig && { provider: summaryIndexModelConfig.providerName, model: summaryIndexModelConfig.modelName }}
modelList={textGenerationModelList}
onSelect={handleSummaryIndexModelChange}
readonly={readonly}
showDeprecatedWarnIcon
triggerClassName="h-8"
/>
</div>
<div>
<div className="system-sm-medium mb-1.5 flex h-6 items-center text-text-secondary">
{t('form.summaryInstructions', { ns: 'datasetSettings' })}
</div>
<Textarea
value={summaryIndexSetting?.summary_prompt ?? ''}
onChange={handleSummaryIndexPromptChange}
disabled={readonly}
placeholder={t('form.summaryInstructionsPlaceholder', { ns: 'datasetSettings' })}
/>
</div>
</>
)
}
</div>
)
}
export default memo(SummaryIndexSetting)

View File

@ -1,10 +1,11 @@
import type { QAChunk } from './types'
import type { GeneralChunk, ParentChildChunk, QAChunk } from './types'
import type { ParentMode } from '@/models/datasets'
import * as React from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Dot from '@/app/components/datasets/documents/detail/completed/common/dot'
import SegmentIndexTag from '@/app/components/datasets/documents/detail/completed/common/segment-index-tag'
import SummaryLabel from '@/app/components/datasets/documents/detail/completed/common/summary-label'
import { PreviewSlice } from '@/app/components/datasets/formatted-text/flavours/preview-slice'
import { ChunkingMode } from '@/models/datasets'
import { formatNumber } from '@/utils/format'
@ -14,7 +15,7 @@ import { QAItemType } from './types'
type ChunkCardProps = {
chunkType: ChunkingMode
parentMode?: ParentMode
content: string | string[] | QAChunk
content: ParentChildChunk | QAChunk | GeneralChunk
positionId?: string | number
wordCount: number
}
@ -33,7 +34,7 @@ const ChunkCard = (props: ChunkCardProps) => {
const contentElement = useMemo(() => {
if (chunkType === ChunkingMode.parentChild) {
return (content as string[]).map((child, index) => {
return (content as ParentChildChunk).child_contents.map((child, index) => {
const indexForLabel = index + 1
return (
<PreviewSlice
@ -57,7 +58,17 @@ const ChunkCard = (props: ChunkCardProps) => {
)
}
return content as string
return (content as GeneralChunk).content
}, [content, chunkType])
const summaryElement = useMemo(() => {
if (chunkType === ChunkingMode.parentChild) {
return (content as ParentChildChunk).parent_summary
}
if (chunkType === ChunkingMode.text) {
return (content as GeneralChunk).summary
}
return null
}, [content, chunkType])
return (
@ -73,6 +84,7 @@ const ChunkCard = (props: ChunkCardProps) => {
</div>
)}
<div className="body-md-regular text-text-secondary">{contentElement}</div>
{summaryElement && <SummaryLabel summary={summaryElement} />}
</div>
)
}

View File

@ -10,13 +10,13 @@ import { QAItemType } from './types'
// Test Data Factories
// =============================================================================
const createGeneralChunks = (overrides: string[] = []): GeneralChunks => {
const createGeneralChunks = (overrides: GeneralChunks = []): GeneralChunks => {
if (overrides.length > 0)
return overrides
return [
'This is the first chunk of text content.',
'This is the second chunk with different content.',
'Third chunk here with more text.',
{ content: 'This is the first chunk of text content.' },
{ content: 'This is the second chunk with different content.' },
{ content: 'Third chunk here with more text.' },
]
}
@ -152,7 +152,7 @@ describe('ChunkCard', () => {
render(
<ChunkCard
chunkType={ChunkingMode.text}
content="This is plain text content."
content={createGeneralChunks()[0]}
wordCount={27}
positionId={1}
/>,
@ -196,7 +196,7 @@ describe('ChunkCard', () => {
<ChunkCard
chunkType={ChunkingMode.parentChild}
parentMode="paragraph"
content={childContents}
content={createParentChildChunk({ child_contents: childContents })}
wordCount={50}
positionId={1}
/>,
@ -218,7 +218,7 @@ describe('ChunkCard', () => {
<ChunkCard
chunkType={ChunkingMode.parentChild}
parentMode="paragraph"
content={['Child content']}
content={createParentChildChunk({ child_contents: ['Child content'] })}
wordCount={13}
positionId={1}
/>,
@ -234,7 +234,7 @@ describe('ChunkCard', () => {
<ChunkCard
chunkType={ChunkingMode.parentChild}
parentMode="full-doc"
content={['Child content']}
content={createParentChildChunk({ child_contents: ['Child content'] })}
wordCount={13}
positionId={1}
/>,
@ -250,7 +250,7 @@ describe('ChunkCard', () => {
render(
<ChunkCard
chunkType={ChunkingMode.text}
content="Text content"
content={createGeneralChunks()[0]}
wordCount={12}
positionId={5}
/>,
@ -268,7 +268,7 @@ describe('ChunkCard', () => {
render(
<ChunkCard
chunkType={ChunkingMode.text}
content="Some content"
content={createGeneralChunks()[0]}
wordCount={1234}
positionId={1}
/>,
@ -283,7 +283,7 @@ describe('ChunkCard', () => {
render(
<ChunkCard
chunkType={ChunkingMode.text}
content="Some content"
content={createGeneralChunks()[0]}
wordCount={100}
positionId={1}
/>,
@ -299,7 +299,7 @@ describe('ChunkCard', () => {
<ChunkCard
chunkType={ChunkingMode.parentChild}
parentMode="full-doc"
content={['Child']}
content={createParentChildChunk({ child_contents: ['Child'] })}
wordCount={500}
positionId={1}
/>,
@ -317,7 +317,7 @@ describe('ChunkCard', () => {
render(
<ChunkCard
chunkType={ChunkingMode.text}
content="Content"
content={createGeneralChunks()[0]}
wordCount={7}
positionId={42}
/>,
@ -332,7 +332,7 @@ describe('ChunkCard', () => {
render(
<ChunkCard
chunkType={ChunkingMode.text}
content="Content"
content={createGeneralChunks()[0]}
wordCount={7}
positionId="99"
/>,
@ -347,7 +347,7 @@ describe('ChunkCard', () => {
render(
<ChunkCard
chunkType={ChunkingMode.text}
content="Content"
content={createGeneralChunks()[0]}
wordCount={7}
positionId={3}
/>,
@ -366,7 +366,7 @@ describe('ChunkCard', () => {
<ChunkCard
chunkType={ChunkingMode.parentChild}
parentMode="paragraph"
content={['Child']}
content={createParentChildChunk({ child_contents: ['Child'] })}
wordCount={5}
positionId={1}
/>,
@ -380,7 +380,7 @@ describe('ChunkCard', () => {
<ChunkCard
chunkType={ChunkingMode.parentChild}
parentMode="full-doc"
content={['Child']}
content={createParentChildChunk({ child_contents: ['Child'] })}
wordCount={5}
positionId={1}
/>,
@ -395,7 +395,7 @@ describe('ChunkCard', () => {
const { rerender } = render(
<ChunkCard
chunkType={ChunkingMode.text}
content="Initial content"
content={createGeneralChunks()[0]}
wordCount={15}
positionId={1}
/>,
@ -408,7 +408,7 @@ describe('ChunkCard', () => {
rerender(
<ChunkCard
chunkType={ChunkingMode.text}
content="Updated content"
content={createGeneralChunks()[0]}
wordCount={15}
positionId={1}
/>,
@ -424,7 +424,7 @@ describe('ChunkCard', () => {
const { rerender } = render(
<ChunkCard
chunkType={ChunkingMode.text}
content="Text content"
content={createGeneralChunks()[0]}
wordCount={12}
positionId={1}
/>,
@ -458,7 +458,7 @@ describe('ChunkCard', () => {
<ChunkCard
chunkType={ChunkingMode.parentChild}
parentMode="paragraph"
content={[]}
content={createParentChildChunk({ child_contents: [] })}
wordCount={0}
positionId={1}
/>,
@ -495,7 +495,7 @@ describe('ChunkCard', () => {
render(
<ChunkCard
chunkType={ChunkingMode.text}
content={longContent}
content={createGeneralChunks()[0]}
wordCount={10000}
positionId={1}
/>,
@ -510,7 +510,7 @@ describe('ChunkCard', () => {
render(
<ChunkCard
chunkType={ChunkingMode.text}
content=""
content={createGeneralChunks()[0]}
wordCount={0}
positionId={1}
/>,
@ -546,9 +546,9 @@ describe('ChunkCardList', () => {
)
// Assert
expect(screen.getByText(chunks[0])).toBeInTheDocument()
expect(screen.getByText(chunks[1])).toBeInTheDocument()
expect(screen.getByText(chunks[2])).toBeInTheDocument()
expect(screen.getByText(chunks[0].content)).toBeInTheDocument()
expect(screen.getByText(chunks[1].content)).toBeInTheDocument()
expect(screen.getByText(chunks[2].content)).toBeInTheDocument()
})
it('should render parent-child chunks correctly', () => {
@ -594,7 +594,10 @@ describe('ChunkCardList', () => {
describe('Memoization - chunkList', () => {
it('should extract chunks from GeneralChunks for text mode', () => {
// Arrange
const chunks: GeneralChunks = ['Chunk 1', 'Chunk 2']
const chunks: GeneralChunks = [
{ content: 'Chunk 1' },
{ content: 'Chunk 2' },
]
// Act
render(
@ -653,7 +656,7 @@ describe('ChunkCardList', () => {
it('should update chunkList when chunkInfo changes', () => {
// Arrange
const initialChunks = createGeneralChunks(['Initial chunk'])
const initialChunks = createGeneralChunks([{ content: 'Initial chunk' }])
const { rerender } = render(
<ChunkCardList
@ -666,7 +669,7 @@ describe('ChunkCardList', () => {
expect(screen.getByText('Initial chunk')).toBeInTheDocument()
// Act - update chunks
const updatedChunks = createGeneralChunks(['Updated chunk'])
const updatedChunks = createGeneralChunks([{ content: 'Updated chunk' }])
rerender(
<ChunkCardList
chunkType={ChunkingMode.text}
@ -684,7 +687,7 @@ describe('ChunkCardList', () => {
describe('Word Count Calculation', () => {
it('should calculate word count for text chunks using string length', () => {
// Arrange - "Hello" has 5 characters
const chunks = createGeneralChunks(['Hello'])
const chunks = createGeneralChunks([{ content: 'Hello' }])
// Act
render(
@ -747,7 +750,11 @@ describe('ChunkCardList', () => {
describe('Position ID', () => {
it('should assign 1-based position IDs to chunks', () => {
// Arrange
const chunks = createGeneralChunks(['First', 'Second', 'Third'])
const chunks = createGeneralChunks([
{ content: 'First' },
{ content: 'Second' },
{ content: 'Third' },
])
// Act
render(
@ -768,7 +775,7 @@ describe('ChunkCardList', () => {
describe('Custom className', () => {
it('should apply custom className to container', () => {
// Arrange
const chunks = createGeneralChunks(['Test'])
const chunks = createGeneralChunks([{ content: 'Test' }])
// Act
const { container } = render(
@ -785,7 +792,7 @@ describe('ChunkCardList', () => {
it('should merge custom className with default classes', () => {
// Arrange
const chunks = createGeneralChunks(['Test'])
const chunks = createGeneralChunks([{ content: 'Test' }])
// Act
const { container } = render(
@ -805,7 +812,7 @@ describe('ChunkCardList', () => {
it('should render without className prop', () => {
// Arrange
const chunks = createGeneralChunks(['Test'])
const chunks = createGeneralChunks([{ content: 'Test' }])
// Act
const { container } = render(
@ -860,7 +867,7 @@ describe('ChunkCardList', () => {
it('should not use parentMode for text type', () => {
// Arrange
const chunks = createGeneralChunks(['Text'])
const chunks = createGeneralChunks([{ content: 'Text' }])
// Act
render(
@ -937,7 +944,7 @@ describe('ChunkCardList', () => {
it('should handle single item in chunks', () => {
// Arrange
const chunks = createGeneralChunks(['Single chunk'])
const chunks = createGeneralChunks([{ content: 'Single chunk' }])
// Act
render(
@ -954,7 +961,7 @@ describe('ChunkCardList', () => {
it('should handle large number of chunks', () => {
// Arrange
const chunks = Array.from({ length: 100 }, (_, i) => `Chunk number ${i + 1}`)
const chunks = Array.from({ length: 100 }, (_, i) => ({ content: `Chunk number ${i + 1}` }))
// Act
render(
@ -975,8 +982,11 @@ describe('ChunkCardList', () => {
describe('Key Generation', () => {
it('should generate unique keys for chunks', () => {
// Arrange - chunks with same content
const chunks = createGeneralChunks(['Same content', 'Same content', 'Same content'])
const chunks = createGeneralChunks([
{ content: 'Same content' },
{ content: 'Same content' },
{ content: 'Same content' },
])
// Act
const { container } = render(
<ChunkCardList
@ -1006,9 +1016,9 @@ describe('ChunkCardList Integration', () => {
it('should render complete text chunking workflow', () => {
// Arrange
const textChunks = createGeneralChunks([
'First paragraph of the document.',
'Second paragraph with more information.',
'Final paragraph concluding the content.',
{ content: 'First paragraph of the document.' },
{ content: 'Second paragraph with more information.' },
{ content: 'Final paragraph concluding the content.' },
])
// Act
@ -1104,7 +1114,7 @@ describe('ChunkCardList Integration', () => {
describe('Type Switching', () => {
it('should handle switching from text to QA type', () => {
// Arrange
const textChunks = createGeneralChunks(['Text content'])
const textChunks = createGeneralChunks([{ content: 'Text content' }])
const qaChunks = createQAChunks()
const { rerender } = render(
@ -1132,7 +1142,7 @@ describe('ChunkCardList Integration', () => {
it('should handle switching from text to parent-child type', () => {
// Arrange
const textChunks = createGeneralChunks(['Simple text'])
const textChunks = createGeneralChunks([{ content: 'Simple text' }])
const parentChildChunks = createParentChildChunks()
const { rerender } = render(

View File

@ -1,4 +1,4 @@
import type { ChunkInfo, GeneralChunks, ParentChildChunk, ParentChildChunks, QAChunk, QAChunks } from './types'
import type { ChunkInfo, GeneralChunk, GeneralChunks, ParentChildChunk, ParentChildChunks, QAChunk, QAChunks } from './types'
import type { ParentMode } from '@/models/datasets'
import { useMemo } from 'react'
import { ChunkingMode } from '@/models/datasets'
@ -21,13 +21,13 @@ export const ChunkCardList = (props: ChunkCardListProps) => {
if (chunkType === ChunkingMode.parentChild)
return (chunkInfo as ParentChildChunks).parent_child_chunks
return (chunkInfo as QAChunks).qa_chunks
}, [chunkInfo])
}, [chunkInfo, chunkType])
const getWordCount = (seg: string | ParentChildChunk | QAChunk) => {
const getWordCount = (seg: GeneralChunk | ParentChildChunk | QAChunk) => {
if (chunkType === ChunkingMode.parentChild)
return (seg as ParentChildChunk).parent_content.length
return (seg as ParentChildChunk).parent_content?.length
if (chunkType === ChunkingMode.text)
return (seg as string).length
return (seg as GeneralChunk).content.length
return (seg as QAChunk).question.length + (seg as QAChunk).answer.length
}
@ -41,7 +41,7 @@ export const ChunkCardList = (props: ChunkCardListProps) => {
key={`${chunkType}-${index}`}
chunkType={chunkType}
parentMode={parentMode}
content={chunkType === ChunkingMode.parentChild ? (seg as ParentChildChunk).child_contents : (seg as string | QAChunk)}
content={seg}
wordCount={wordCount}
positionId={index + 1}
/>

View File

@ -1,8 +1,12 @@
export type GeneralChunks = string[]
export type GeneralChunk = {
content: string
summary?: string
}
export type GeneralChunks = GeneralChunk[]
export type ParentChildChunk = {
child_contents: string[]
parent_content: string
parent_summary?: string
parent_mode: string
}

View File

@ -1,8 +1,8 @@
import type { GeneralChunks } from '@/app/components/rag-pipeline/components/chunk-card-list/types'
import type { WorkflowRunningData } from '@/app/components/workflow/types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { ChunkingMode } from '@/models/datasets'
import Header from './header'
// Import components after mocks
import TestRunPanel from './index'
@ -836,7 +836,7 @@ describe('formatPreviewChunks', () => {
it('should limit to RAG_PIPELINE_PREVIEW_CHUNK_NUM chunks', () => {
const manyChunks = Array.from({ length: 10 }, (_, i) => `chunk${i}`)
const outputs = createMockGeneralOutputs(manyChunks)
const result = formatPreviewChunks(outputs) as string[]
const result = formatPreviewChunks(outputs) as GeneralChunks
// RAG_PIPELINE_PREVIEW_CHUNK_NUM is mocked to 5
expect(result).toHaveLength(5)

View File

@ -5,13 +5,17 @@ import { ChunkingMode } from '@/models/datasets'
type GeneralChunkPreview = {
content: string
summary?: string
}
const formatGeneralChunks = (outputs: any) => {
const chunkInfo: GeneralChunks = []
const chunks = outputs.preview as GeneralChunkPreview[]
chunks.slice(0, RAG_PIPELINE_PREVIEW_CHUNK_NUM).forEach((chunk) => {
chunkInfo.push(chunk.content)
chunkInfo.push({
content: chunk.content,
summary: chunk.summary,
})
})
return chunkInfo
@ -20,6 +24,7 @@ const formatGeneralChunks = (outputs: any) => {
type ParentChildChunkPreview = {
content: string
child_chunks: string[]
summary?: string
}
const formatParentChildChunks = (outputs: any, parentMode: ParentMode) => {
@ -32,6 +37,7 @@ const formatParentChildChunks = (outputs: any, parentMode: ParentMode) => {
chunks.slice(0, RAG_PIPELINE_PREVIEW_CHUNK_NUM).forEach((chunk) => {
chunkInfo.parent_child_chunks?.push({
parent_content: chunk.content,
parent_summary: chunk.summary,
child_contents: chunk.child_chunks,
parent_mode: parentMode,
})

View File

@ -1,221 +0,0 @@
import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import NewMCPCard from './create-card'
// Track the mock functions
const mockCreateMCP = vi.fn().mockResolvedValue({ id: 'new-mcp-id', name: 'New MCP' })
// Mock the service
vi.mock('@/service/use-tools', () => ({
useCreateMCP: () => ({
mutateAsync: mockCreateMCP,
}),
}))
// Mock the MCP Modal
type MockMCPModalProps = {
show: boolean
onConfirm: (info: { name: string, server_url: string }) => void
onHide: () => void
}
vi.mock('./modal', () => ({
default: ({ show, onConfirm, onHide }: MockMCPModalProps) => {
if (!show)
return null
return (
<div data-testid="mcp-modal">
<span>tools.mcp.modal.title</span>
<button data-testid="confirm-btn" onClick={() => onConfirm({ name: 'Test MCP', server_url: 'https://test.com' })}>
Confirm
</button>
<button data-testid="close-btn" onClick={onHide}>
Close
</button>
</div>
)
},
}))
// Mutable workspace manager state
let mockIsCurrentWorkspaceManager = true
// Mock the app context
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager,
isCurrentWorkspaceEditor: true,
}),
}))
// Mock the plugins service
vi.mock('@/service/use-plugins', () => ({
useInstalledPluginList: () => ({
data: { pages: [] },
hasNextPage: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
isLoading: false,
isSuccess: true,
}),
}))
// Mock common service
vi.mock('@/service/common', () => ({
uploadRemoteFileInfo: vi.fn().mockResolvedValue({ url: 'https://example.com/icon.png' }),
}))
describe('NewMCPCard', () => {
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children)
}
const defaultProps = {
handleCreate: vi.fn(),
}
beforeEach(() => {
mockCreateMCP.mockClear()
mockIsCurrentWorkspaceManager = true
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<NewMCPCard {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.create.cardTitle')).toBeInTheDocument()
})
it('should render card title', () => {
render(<NewMCPCard {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.create.cardTitle')).toBeInTheDocument()
})
it('should render documentation link', () => {
render(<NewMCPCard {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.create.cardLink')).toBeInTheDocument()
})
it('should render add icon', () => {
render(<NewMCPCard {...defaultProps} />, { wrapper: createWrapper() })
const svgElements = document.querySelectorAll('svg')
expect(svgElements.length).toBeGreaterThan(0)
})
})
describe('User Interactions', () => {
it('should open modal when card is clicked', async () => {
render(<NewMCPCard {...defaultProps} />, { wrapper: createWrapper() })
const cardTitle = screen.getByText('tools.mcp.create.cardTitle')
const clickableArea = cardTitle.closest('.group')
if (clickableArea) {
fireEvent.click(clickableArea)
await waitFor(() => {
expect(screen.getByText('tools.mcp.modal.title')).toBeInTheDocument()
})
}
})
it('should have documentation link with correct target', () => {
render(<NewMCPCard {...defaultProps} />, { wrapper: createWrapper() })
const docLink = screen.getByText('tools.mcp.create.cardLink').closest('a')
expect(docLink).toHaveAttribute('target', '_blank')
expect(docLink).toHaveAttribute('rel', 'noopener noreferrer')
})
})
describe('Non-Manager User', () => {
it('should not render card when user is not workspace manager', () => {
mockIsCurrentWorkspaceManager = false
render(<NewMCPCard {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.queryByText('tools.mcp.create.cardTitle')).not.toBeInTheDocument()
})
})
describe('Styling', () => {
it('should have correct card structure', () => {
render(<NewMCPCard {...defaultProps} />, { wrapper: createWrapper() })
const card = document.querySelector('.rounded-xl')
expect(card).toBeInTheDocument()
})
it('should have clickable cursor style', () => {
render(<NewMCPCard {...defaultProps} />, { wrapper: createWrapper() })
const card = document.querySelector('.cursor-pointer')
expect(card).toBeInTheDocument()
})
})
describe('Modal Interactions', () => {
it('should call create function when modal confirms', async () => {
const handleCreate = vi.fn()
render(<NewMCPCard handleCreate={handleCreate} />, { wrapper: createWrapper() })
// Open the modal
const cardTitle = screen.getByText('tools.mcp.create.cardTitle')
const clickableArea = cardTitle.closest('.group')
if (clickableArea) {
fireEvent.click(clickableArea)
await waitFor(() => {
expect(screen.getByTestId('mcp-modal')).toBeInTheDocument()
})
// Click confirm
const confirmBtn = screen.getByTestId('confirm-btn')
fireEvent.click(confirmBtn)
await waitFor(() => {
expect(mockCreateMCP).toHaveBeenCalledWith({
name: 'Test MCP',
server_url: 'https://test.com',
})
expect(handleCreate).toHaveBeenCalled()
})
}
})
it('should close modal when close button is clicked', async () => {
render(<NewMCPCard {...defaultProps} />, { wrapper: createWrapper() })
// Open the modal
const cardTitle = screen.getByText('tools.mcp.create.cardTitle')
const clickableArea = cardTitle.closest('.group')
if (clickableArea) {
fireEvent.click(clickableArea)
await waitFor(() => {
expect(screen.getByTestId('mcp-modal')).toBeInTheDocument()
})
// Click close
const closeBtn = screen.getByTestId('close-btn')
fireEvent.click(closeBtn)
await waitFor(() => {
expect(screen.queryByTestId('mcp-modal')).not.toBeInTheDocument()
})
}
})
})
})

View File

@ -1,855 +0,0 @@
import type { ReactNode } from 'react'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import MCPDetailContent from './content'
// Mutable mock functions
const mockUpdateTools = vi.fn().mockResolvedValue({})
const mockAuthorizeMcp = vi.fn().mockResolvedValue({ result: 'success' })
const mockUpdateMCP = vi.fn().mockResolvedValue({ result: 'success' })
const mockDeleteMCP = vi.fn().mockResolvedValue({ result: 'success' })
const mockInvalidateMCPTools = vi.fn()
const mockOpenOAuthPopup = vi.fn()
// Mutable mock state
type MockTool = {
id: string
name: string
description?: string
}
let mockToolsData: { tools: MockTool[] } = { tools: [] }
let mockIsFetching = false
let mockIsUpdating = false
let mockIsAuthorizing = false
// Mock the services
vi.mock('@/service/use-tools', () => ({
useMCPTools: () => ({
data: mockToolsData,
isFetching: mockIsFetching,
}),
useInvalidateMCPTools: () => mockInvalidateMCPTools,
useUpdateMCPTools: () => ({
mutateAsync: mockUpdateTools,
isPending: mockIsUpdating,
}),
useAuthorizeMCP: () => ({
mutateAsync: mockAuthorizeMcp,
isPending: mockIsAuthorizing,
}),
useUpdateMCP: () => ({
mutateAsync: mockUpdateMCP,
}),
useDeleteMCP: () => ({
mutateAsync: mockDeleteMCP,
}),
}))
// Mock OAuth hook
type OAuthArgs = readonly unknown[]
vi.mock('@/hooks/use-oauth', () => ({
openOAuthPopup: (...args: OAuthArgs) => mockOpenOAuthPopup(...args),
}))
// Mock MCPModal
type MCPModalData = {
name: string
server_url: string
}
type MCPModalProps = {
show: boolean
onConfirm: (data: MCPModalData) => void
onHide: () => void
}
vi.mock('../modal', () => ({
default: ({ show, onConfirm, onHide }: MCPModalProps) => {
if (!show)
return null
return (
<div data-testid="mcp-update-modal">
<button data-testid="modal-confirm-btn" onClick={() => onConfirm({ name: 'Updated MCP', server_url: 'https://updated.com' })}>
Confirm
</button>
<button data-testid="modal-close-btn" onClick={onHide}>
Close
</button>
</div>
)
},
}))
// Mock Confirm dialog
vi.mock('@/app/components/base/confirm', () => ({
default: ({ isShow, onConfirm, onCancel, title }: { isShow: boolean, onConfirm: () => void, onCancel: () => void, title: string }) => {
if (!isShow)
return null
return (
<div data-testid="confirm-dialog" data-title={title}>
<button data-testid="confirm-btn" onClick={onConfirm}>Confirm</button>
<button data-testid="cancel-btn" onClick={onCancel}>Cancel</button>
</div>
)
},
}))
// Mock OperationDropdown
vi.mock('./operation-dropdown', () => ({
default: ({ onEdit, onRemove }: { onEdit: () => void, onRemove: () => void }) => (
<div data-testid="operation-dropdown">
<button data-testid="edit-btn" onClick={onEdit}>Edit</button>
<button data-testid="remove-btn" onClick={onRemove}>Remove</button>
</div>
),
}))
// Mock ToolItem
type ToolItemData = {
name: string
}
vi.mock('./tool-item', () => ({
default: ({ tool }: { tool: ToolItemData }) => (
<div data-testid="tool-item">{tool.name}</div>
),
}))
// Mutable workspace manager state
let mockIsCurrentWorkspaceManager = true
// Mock the app context
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager,
isCurrentWorkspaceEditor: true,
}),
}))
// Mock the plugins service
vi.mock('@/service/use-plugins', () => ({
useInstalledPluginList: () => ({
data: { pages: [] },
hasNextPage: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
isLoading: false,
isSuccess: true,
}),
}))
// Mock common service
vi.mock('@/service/common', () => ({
uploadRemoteFileInfo: vi.fn().mockResolvedValue({ url: 'https://example.com/icon.png' }),
}))
// Mock copy-to-clipboard
vi.mock('copy-to-clipboard', () => ({
default: vi.fn(),
}))
describe('MCPDetailContent', () => {
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children)
}
const createMockDetail = (overrides = {}): ToolWithProvider => ({
id: 'mcp-1',
name: 'Test MCP Server',
server_identifier: 'test-mcp',
server_url: 'https://example.com/mcp',
icon: { content: '🔧', background: '#FF0000' },
tools: [],
is_team_authorization: false,
...overrides,
} as unknown as ToolWithProvider)
const defaultProps = {
detail: createMockDetail(),
onUpdate: vi.fn(),
onHide: vi.fn(),
isTriggerAuthorize: false,
onFirstCreate: vi.fn(),
}
beforeEach(() => {
// Reset mocks
mockUpdateTools.mockClear()
mockAuthorizeMcp.mockClear()
mockUpdateMCP.mockClear()
mockDeleteMCP.mockClear()
mockInvalidateMCPTools.mockClear()
mockOpenOAuthPopup.mockClear()
// Reset mock return values
mockUpdateTools.mockResolvedValue({})
mockAuthorizeMcp.mockResolvedValue({ result: 'success' })
mockUpdateMCP.mockResolvedValue({ result: 'success' })
mockDeleteMCP.mockResolvedValue({ result: 'success' })
// Reset state
mockToolsData = { tools: [] }
mockIsFetching = false
mockIsUpdating = false
mockIsAuthorizing = false
mockIsCurrentWorkspaceManager = true
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('Test MCP Server')).toBeInTheDocument()
})
it('should display MCP name', () => {
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('Test MCP Server')).toBeInTheDocument()
})
it('should display server identifier', () => {
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('test-mcp')).toBeInTheDocument()
})
it('should display server URL', () => {
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('https://example.com/mcp')).toBeInTheDocument()
})
it('should render close button', () => {
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
// Close button should be present
const closeButtons = document.querySelectorAll('button')
expect(closeButtons.length).toBeGreaterThan(0)
})
it('should render operation dropdown', () => {
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
// Operation dropdown trigger should be present
expect(document.querySelector('button')).toBeInTheDocument()
})
})
describe('Authorization State', () => {
it('should show authorize button when not authorized', () => {
const detail = createMockDetail({ is_team_authorization: false })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('tools.mcp.authorize')).toBeInTheDocument()
})
it('should show authorized button when authorized', () => {
const detail = createMockDetail({ is_team_authorization: true })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('tools.auth.authorized')).toBeInTheDocument()
})
it('should show authorization required message when not authorized', () => {
const detail = createMockDetail({ is_team_authorization: false })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('tools.mcp.authorizingRequired')).toBeInTheDocument()
})
it('should show authorization tip', () => {
const detail = createMockDetail({ is_team_authorization: false })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('tools.mcp.authorizeTip')).toBeInTheDocument()
})
})
describe('Empty Tools State', () => {
it('should show empty message when authorized but no tools', () => {
const detail = createMockDetail({ is_team_authorization: true, tools: [] })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('tools.mcp.toolsEmpty')).toBeInTheDocument()
})
it('should show get tools button when empty', () => {
const detail = createMockDetail({ is_team_authorization: true, tools: [] })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('tools.mcp.getTools')).toBeInTheDocument()
})
})
describe('Icon Display', () => {
it('should render MCP icon', () => {
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
// Icon container should be present
const iconContainer = document.querySelector('[class*="rounded-xl"][class*="border"]')
expect(iconContainer).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle empty server URL', () => {
const detail = createMockDetail({ server_url: '' })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('Test MCP Server')).toBeInTheDocument()
})
it('should handle long MCP name', () => {
const longName = 'A'.repeat(100)
const detail = createMockDetail({ name: longName })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText(longName)).toBeInTheDocument()
})
})
describe('Tools List', () => {
it('should show tools list when authorized and has tools', () => {
mockToolsData = {
tools: [
{ id: 'tool1', name: 'tool1', description: 'Tool 1' },
{ id: 'tool2', name: 'tool2', description: 'Tool 2' },
],
}
const detail = createMockDetail({ is_team_authorization: true })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('tool1')).toBeInTheDocument()
expect(screen.getByText('tool2')).toBeInTheDocument()
})
it('should show single tool label when only one tool', () => {
mockToolsData = {
tools: [{ id: 'tool1', name: 'tool1', description: 'Tool 1' }],
}
const detail = createMockDetail({ is_team_authorization: true })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('tools.mcp.onlyTool')).toBeInTheDocument()
})
it('should show tools count when multiple tools', () => {
mockToolsData = {
tools: [
{ id: 'tool1', name: 'tool1', description: 'Tool 1' },
{ id: 'tool2', name: 'tool2', description: 'Tool 2' },
],
}
const detail = createMockDetail({ is_team_authorization: true })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText(/tools.mcp.toolsNum/)).toBeInTheDocument()
})
})
describe('Loading States', () => {
it('should show loading state when fetching tools', () => {
mockIsFetching = true
mockToolsData = {
tools: [{ id: 'tool1', name: 'tool1', description: 'Tool 1' }],
}
const detail = createMockDetail({ is_team_authorization: true })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('tools.mcp.gettingTools')).toBeInTheDocument()
})
it('should show updating state when updating tools', () => {
mockIsUpdating = true
mockToolsData = {
tools: [{ id: 'tool1', name: 'tool1', description: 'Tool 1' }],
}
const detail = createMockDetail({ is_team_authorization: true })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('tools.mcp.updateTools')).toBeInTheDocument()
})
it('should show authorizing button when authorizing', () => {
mockIsAuthorizing = true
const detail = createMockDetail({ is_team_authorization: false })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
// Multiple elements show authorizing text - use getAllByText
const authorizingElements = screen.getAllByText('tools.mcp.authorizing')
expect(authorizingElements.length).toBeGreaterThan(0)
})
})
describe('Authorize Flow', () => {
it('should call authorizeMcp when authorize button is clicked', async () => {
const onFirstCreate = vi.fn()
const detail = createMockDetail({ is_team_authorization: false })
render(
<MCPDetailContent {...defaultProps} detail={detail} onFirstCreate={onFirstCreate} />,
{ wrapper: createWrapper() },
)
const authorizeBtn = screen.getByText('tools.mcp.authorize')
fireEvent.click(authorizeBtn)
await waitFor(() => {
expect(onFirstCreate).toHaveBeenCalled()
expect(mockAuthorizeMcp).toHaveBeenCalledWith({ provider_id: 'mcp-1' })
})
})
it('should open OAuth popup when authorization_url is returned', async () => {
mockAuthorizeMcp.mockResolvedValue({ authorization_url: 'https://oauth.example.com' })
const detail = createMockDetail({ is_team_authorization: false })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
const authorizeBtn = screen.getByText('tools.mcp.authorize')
fireEvent.click(authorizeBtn)
await waitFor(() => {
expect(mockOpenOAuthPopup).toHaveBeenCalledWith(
'https://oauth.example.com',
expect.any(Function),
)
})
})
it('should trigger authorize on mount when isTriggerAuthorize is true', async () => {
const onFirstCreate = vi.fn()
const detail = createMockDetail({ is_team_authorization: false })
render(
<MCPDetailContent {...defaultProps} detail={detail} isTriggerAuthorize={true} onFirstCreate={onFirstCreate} />,
{ wrapper: createWrapper() },
)
await waitFor(() => {
expect(onFirstCreate).toHaveBeenCalled()
expect(mockAuthorizeMcp).toHaveBeenCalled()
})
})
it('should disable authorize button when not workspace manager', () => {
mockIsCurrentWorkspaceManager = false
const detail = createMockDetail({ is_team_authorization: false })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
const authorizeBtn = screen.getByText('tools.mcp.authorize')
expect(authorizeBtn.closest('button')).toBeDisabled()
})
})
describe('Update Tools Flow', () => {
it('should show update confirm dialog when update button is clicked', async () => {
mockToolsData = {
tools: [{ id: 'tool1', name: 'tool1', description: 'Tool 1' }],
}
const detail = createMockDetail({ is_team_authorization: true })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
const updateBtn = screen.getByText('tools.mcp.update')
fireEvent.click(updateBtn)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
})
it('should call updateTools when update is confirmed', async () => {
mockToolsData = {
tools: [{ id: 'tool1', name: 'tool1', description: 'Tool 1' }],
}
const onUpdate = vi.fn()
const detail = createMockDetail({ is_team_authorization: true })
render(
<MCPDetailContent {...defaultProps} detail={detail} onUpdate={onUpdate} />,
{ wrapper: createWrapper() },
)
// Open confirm dialog
const updateBtn = screen.getByText('tools.mcp.update')
fireEvent.click(updateBtn)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
// Confirm the update
const confirmBtn = screen.getByTestId('confirm-btn')
fireEvent.click(confirmBtn)
await waitFor(() => {
expect(mockUpdateTools).toHaveBeenCalledWith('mcp-1')
expect(mockInvalidateMCPTools).toHaveBeenCalledWith('mcp-1')
expect(onUpdate).toHaveBeenCalled()
})
})
it('should call handleUpdateTools when get tools button is clicked', async () => {
const onUpdate = vi.fn()
const detail = createMockDetail({ is_team_authorization: true, tools: [] })
render(
<MCPDetailContent {...defaultProps} detail={detail} onUpdate={onUpdate} />,
{ wrapper: createWrapper() },
)
const getToolsBtn = screen.getByText('tools.mcp.getTools')
fireEvent.click(getToolsBtn)
await waitFor(() => {
expect(mockUpdateTools).toHaveBeenCalledWith('mcp-1')
})
})
})
describe('Update MCP Modal', () => {
it('should open update modal when edit button is clicked', async () => {
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
const editBtn = screen.getByTestId('edit-btn')
fireEvent.click(editBtn)
await waitFor(() => {
expect(screen.getByTestId('mcp-update-modal')).toBeInTheDocument()
})
})
it('should close update modal when close button is clicked', async () => {
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
// Open modal
const editBtn = screen.getByTestId('edit-btn')
fireEvent.click(editBtn)
await waitFor(() => {
expect(screen.getByTestId('mcp-update-modal')).toBeInTheDocument()
})
// Close modal
const closeBtn = screen.getByTestId('modal-close-btn')
fireEvent.click(closeBtn)
await waitFor(() => {
expect(screen.queryByTestId('mcp-update-modal')).not.toBeInTheDocument()
})
})
it('should call updateMCP when form is confirmed', async () => {
const onUpdate = vi.fn()
render(<MCPDetailContent {...defaultProps} onUpdate={onUpdate} />, { wrapper: createWrapper() })
// Open modal
const editBtn = screen.getByTestId('edit-btn')
fireEvent.click(editBtn)
await waitFor(() => {
expect(screen.getByTestId('mcp-update-modal')).toBeInTheDocument()
})
// Confirm form
const confirmBtn = screen.getByTestId('modal-confirm-btn')
fireEvent.click(confirmBtn)
await waitFor(() => {
expect(mockUpdateMCP).toHaveBeenCalledWith({
name: 'Updated MCP',
server_url: 'https://updated.com',
provider_id: 'mcp-1',
})
expect(onUpdate).toHaveBeenCalled()
})
})
it('should not call onUpdate when updateMCP fails', async () => {
mockUpdateMCP.mockResolvedValue({ result: 'error' })
const onUpdate = vi.fn()
render(<MCPDetailContent {...defaultProps} onUpdate={onUpdate} />, { wrapper: createWrapper() })
// Open modal
const editBtn = screen.getByTestId('edit-btn')
fireEvent.click(editBtn)
await waitFor(() => {
expect(screen.getByTestId('mcp-update-modal')).toBeInTheDocument()
})
// Confirm form
const confirmBtn = screen.getByTestId('modal-confirm-btn')
fireEvent.click(confirmBtn)
await waitFor(() => {
expect(mockUpdateMCP).toHaveBeenCalled()
})
expect(onUpdate).not.toHaveBeenCalled()
})
})
describe('Delete MCP Flow', () => {
it('should open delete confirm when remove button is clicked', async () => {
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
const removeBtn = screen.getByTestId('remove-btn')
fireEvent.click(removeBtn)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
})
it('should close delete confirm when cancel is clicked', async () => {
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
// Open confirm
const removeBtn = screen.getByTestId('remove-btn')
fireEvent.click(removeBtn)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
// Cancel
const cancelBtn = screen.getByTestId('cancel-btn')
fireEvent.click(cancelBtn)
await waitFor(() => {
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
})
})
it('should call deleteMCP when delete is confirmed', async () => {
const onUpdate = vi.fn()
render(<MCPDetailContent {...defaultProps} onUpdate={onUpdate} />, { wrapper: createWrapper() })
// Open confirm
const removeBtn = screen.getByTestId('remove-btn')
fireEvent.click(removeBtn)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
// Confirm delete
const confirmBtn = screen.getByTestId('confirm-btn')
fireEvent.click(confirmBtn)
await waitFor(() => {
expect(mockDeleteMCP).toHaveBeenCalledWith('mcp-1')
expect(onUpdate).toHaveBeenCalledWith(true)
})
})
it('should not call onUpdate when deleteMCP fails', async () => {
mockDeleteMCP.mockResolvedValue({ result: 'error' })
const onUpdate = vi.fn()
render(<MCPDetailContent {...defaultProps} onUpdate={onUpdate} />, { wrapper: createWrapper() })
// Open confirm
const removeBtn = screen.getByTestId('remove-btn')
fireEvent.click(removeBtn)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
// Confirm delete
const confirmBtn = screen.getByTestId('confirm-btn')
fireEvent.click(confirmBtn)
await waitFor(() => {
expect(mockDeleteMCP).toHaveBeenCalled()
})
expect(onUpdate).not.toHaveBeenCalled()
})
})
describe('Close Button', () => {
it('should call onHide when close button is clicked', () => {
const onHide = vi.fn()
render(<MCPDetailContent {...defaultProps} onHide={onHide} />, { wrapper: createWrapper() })
// Find the close button (ActionButton with RiCloseLine)
const buttons = screen.getAllByRole('button')
const closeButton = buttons.find(btn =>
btn.querySelector('svg.h-4.w-4'),
)
if (closeButton) {
fireEvent.click(closeButton)
expect(onHide).toHaveBeenCalled()
}
})
})
describe('Copy Server Identifier', () => {
it('should copy server identifier when clicked', async () => {
const { default: copy } = await import('copy-to-clipboard')
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
// Find the server identifier element
const serverIdentifier = screen.getByText('test-mcp')
fireEvent.click(serverIdentifier)
expect(copy).toHaveBeenCalledWith('test-mcp')
})
})
describe('OAuth Callback', () => {
it('should call handleUpdateTools on OAuth callback when authorized', async () => {
// Simulate OAuth flow with authorization_url
mockAuthorizeMcp.mockResolvedValue({ authorization_url: 'https://oauth.example.com' })
const onUpdate = vi.fn()
const detail = createMockDetail({ is_team_authorization: false })
render(
<MCPDetailContent {...defaultProps} detail={detail} onUpdate={onUpdate} />,
{ wrapper: createWrapper() },
)
// Click authorize to trigger OAuth popup
const authorizeBtn = screen.getByText('tools.mcp.authorize')
fireEvent.click(authorizeBtn)
await waitFor(() => {
expect(mockOpenOAuthPopup).toHaveBeenCalled()
})
// Get the callback function and call it
const oauthCallback = mockOpenOAuthPopup.mock.calls[0][1]
oauthCallback()
await waitFor(() => {
expect(mockUpdateTools).toHaveBeenCalledWith('mcp-1')
})
})
it('should not call handleUpdateTools if not workspace manager', async () => {
mockIsCurrentWorkspaceManager = false
mockAuthorizeMcp.mockResolvedValue({ authorization_url: 'https://oauth.example.com' })
const detail = createMockDetail({ is_team_authorization: false })
// OAuth callback should not trigger update for non-manager
// The button is disabled, so we simulate a scenario where OAuth was already started
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
// Button should be disabled
const authorizeBtn = screen.getByText('tools.mcp.authorize')
expect(authorizeBtn.closest('button')).toBeDisabled()
})
})
describe('Authorized Button', () => {
it('should show authorized button when team is authorized', () => {
const detail = createMockDetail({ is_team_authorization: true })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('tools.auth.authorized')).toBeInTheDocument()
})
it('should call handleAuthorize when authorized button is clicked', async () => {
const onFirstCreate = vi.fn()
const detail = createMockDetail({ is_team_authorization: true })
render(
<MCPDetailContent {...defaultProps} detail={detail} onFirstCreate={onFirstCreate} />,
{ wrapper: createWrapper() },
)
const authorizedBtn = screen.getByText('tools.auth.authorized')
fireEvent.click(authorizedBtn)
await waitFor(() => {
expect(onFirstCreate).toHaveBeenCalled()
expect(mockAuthorizeMcp).toHaveBeenCalled()
})
})
it('should disable authorized button when not workspace manager', () => {
mockIsCurrentWorkspaceManager = false
const detail = createMockDetail({ is_team_authorization: true })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
const authorizedBtn = screen.getByText('tools.auth.authorized')
expect(authorizedBtn.closest('button')).toBeDisabled()
})
})
describe('Cancel Update Confirm', () => {
it('should close update confirm when cancel is clicked', async () => {
mockToolsData = {
tools: [{ id: 'tool1', name: 'tool1', description: 'Tool 1' }],
}
const detail = createMockDetail({ is_team_authorization: true })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
// Open confirm dialog
const updateBtn = screen.getByText('tools.mcp.update')
fireEvent.click(updateBtn)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
// Cancel the update
const cancelBtn = screen.getByTestId('cancel-btn')
fireEvent.click(cancelBtn)
await waitFor(() => {
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
})
})
})
})

View File

@ -1,71 +0,0 @@
import { render } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import ListLoading from './list-loading'
describe('ListLoading', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(<ListLoading />)
expect(container).toBeInTheDocument()
})
it('should render 5 skeleton items', () => {
render(<ListLoading />)
const skeletonItems = document.querySelectorAll('[class*="bg-components-panel-on-panel-item-bg-hover"]')
expect(skeletonItems.length).toBe(5)
})
it('should have rounded-xl class on skeleton items', () => {
render(<ListLoading />)
const skeletonItems = document.querySelectorAll('.rounded-xl')
expect(skeletonItems.length).toBeGreaterThanOrEqual(5)
})
it('should have proper spacing', () => {
render(<ListLoading />)
const container = document.querySelector('.space-y-2')
expect(container).toBeInTheDocument()
})
it('should render placeholder bars with different widths', () => {
render(<ListLoading />)
const bar180 = document.querySelector('.w-\\[180px\\]')
const bar148 = document.querySelector('.w-\\[148px\\]')
const bar196 = document.querySelector('.w-\\[196px\\]')
expect(bar180).toBeInTheDocument()
expect(bar148).toBeInTheDocument()
expect(bar196).toBeInTheDocument()
})
it('should have opacity styling on skeleton bars', () => {
render(<ListLoading />)
const opacity20Bars = document.querySelectorAll('.opacity-20')
const opacity10Bars = document.querySelectorAll('.opacity-10')
expect(opacity20Bars.length).toBeGreaterThan(0)
expect(opacity10Bars.length).toBeGreaterThan(0)
})
})
describe('Structure', () => {
it('should have correct nested structure', () => {
render(<ListLoading />)
const items = document.querySelectorAll('.space-y-3')
expect(items.length).toBe(5)
})
it('should render padding on skeleton items', () => {
render(<ListLoading />)
const paddedItems = document.querySelectorAll('.p-4')
expect(paddedItems.length).toBe(5)
})
it('should render height-2 skeleton bars', () => {
render(<ListLoading />)
const h2Bars = document.querySelectorAll('.h-2')
// 3 bars per skeleton item * 5 items = 15
expect(h2Bars.length).toBe(15)
})
})
})

View File

@ -1,193 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import OperationDropdown from './operation-dropdown'
describe('OperationDropdown', () => {
const defaultProps = {
onEdit: vi.fn(),
onRemove: vi.fn(),
}
describe('Rendering', () => {
it('should render without crashing', () => {
render(<OperationDropdown {...defaultProps} />)
expect(document.querySelector('button')).toBeInTheDocument()
})
it('should render trigger button with more icon', () => {
render(<OperationDropdown {...defaultProps} />)
const button = document.querySelector('button')
expect(button).toBeInTheDocument()
const svg = button?.querySelector('svg')
expect(svg).toBeInTheDocument()
})
it('should render medium size by default', () => {
render(<OperationDropdown {...defaultProps} />)
const icon = document.querySelector('.h-4.w-4')
expect(icon).toBeInTheDocument()
})
it('should render large size when inCard is true', () => {
render(<OperationDropdown {...defaultProps} inCard={true} />)
const icon = document.querySelector('.h-5.w-5')
expect(icon).toBeInTheDocument()
})
})
describe('Dropdown Behavior', () => {
it('should open dropdown when trigger is clicked', async () => {
render(<OperationDropdown {...defaultProps} />)
const trigger = document.querySelector('button')
if (trigger) {
fireEvent.click(trigger)
// Dropdown content should be rendered
expect(screen.getByText('tools.mcp.operation.edit')).toBeInTheDocument()
expect(screen.getByText('tools.mcp.operation.remove')).toBeInTheDocument()
}
})
it('should call onOpenChange when opened', () => {
const onOpenChange = vi.fn()
render(<OperationDropdown {...defaultProps} onOpenChange={onOpenChange} />)
const trigger = document.querySelector('button')
if (trigger) {
fireEvent.click(trigger)
expect(onOpenChange).toHaveBeenCalledWith(true)
}
})
it('should close dropdown when trigger is clicked again', async () => {
const onOpenChange = vi.fn()
render(<OperationDropdown {...defaultProps} onOpenChange={onOpenChange} />)
const trigger = document.querySelector('button')
if (trigger) {
fireEvent.click(trigger)
fireEvent.click(trigger)
expect(onOpenChange).toHaveBeenLastCalledWith(false)
}
})
})
describe('Menu Actions', () => {
it('should call onEdit when edit option is clicked', () => {
const onEdit = vi.fn()
render(<OperationDropdown {...defaultProps} onEdit={onEdit} />)
const trigger = document.querySelector('button')
if (trigger) {
fireEvent.click(trigger)
const editOption = screen.getByText('tools.mcp.operation.edit')
fireEvent.click(editOption)
expect(onEdit).toHaveBeenCalledTimes(1)
}
})
it('should call onRemove when remove option is clicked', () => {
const onRemove = vi.fn()
render(<OperationDropdown {...defaultProps} onRemove={onRemove} />)
const trigger = document.querySelector('button')
if (trigger) {
fireEvent.click(trigger)
const removeOption = screen.getByText('tools.mcp.operation.remove')
fireEvent.click(removeOption)
expect(onRemove).toHaveBeenCalledTimes(1)
}
})
it('should close dropdown after edit is clicked', () => {
const onOpenChange = vi.fn()
render(<OperationDropdown {...defaultProps} onOpenChange={onOpenChange} />)
const trigger = document.querySelector('button')
if (trigger) {
fireEvent.click(trigger)
onOpenChange.mockClear()
const editOption = screen.getByText('tools.mcp.operation.edit')
fireEvent.click(editOption)
expect(onOpenChange).toHaveBeenCalledWith(false)
}
})
it('should close dropdown after remove is clicked', () => {
const onOpenChange = vi.fn()
render(<OperationDropdown {...defaultProps} onOpenChange={onOpenChange} />)
const trigger = document.querySelector('button')
if (trigger) {
fireEvent.click(trigger)
onOpenChange.mockClear()
const removeOption = screen.getByText('tools.mcp.operation.remove')
fireEvent.click(removeOption)
expect(onOpenChange).toHaveBeenCalledWith(false)
}
})
})
describe('Styling', () => {
it('should have correct dropdown width', () => {
render(<OperationDropdown {...defaultProps} />)
const trigger = document.querySelector('button')
if (trigger) {
fireEvent.click(trigger)
const dropdown = document.querySelector('.w-\\[160px\\]')
expect(dropdown).toBeInTheDocument()
}
})
it('should have rounded-xl on dropdown', () => {
render(<OperationDropdown {...defaultProps} />)
const trigger = document.querySelector('button')
if (trigger) {
fireEvent.click(trigger)
const dropdown = document.querySelector('[class*="rounded-xl"][class*="border"]')
expect(dropdown).toBeInTheDocument()
}
})
it('should show destructive hover style on remove option', () => {
render(<OperationDropdown {...defaultProps} />)
const trigger = document.querySelector('button')
if (trigger) {
fireEvent.click(trigger)
// The text is in a div, and the hover style is on the parent div with group class
const removeOptionText = screen.getByText('tools.mcp.operation.remove')
const removeOptionContainer = removeOptionText.closest('.group')
expect(removeOptionContainer).toHaveClass('hover:bg-state-destructive-hover')
}
})
})
describe('inCard prop', () => {
it('should adjust offset when inCard is false', () => {
render(<OperationDropdown {...defaultProps} inCard={false} />)
// Component renders with different offset values
expect(document.querySelector('button')).toBeInTheDocument()
})
it('should adjust offset when inCard is true', () => {
render(<OperationDropdown {...defaultProps} inCard={true} />)
// Component renders with different offset values
expect(document.querySelector('button')).toBeInTheDocument()
})
})
})

View File

@ -1,153 +0,0 @@
import type { ReactNode } from 'react'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { describe, expect, it, vi } from 'vitest'
import MCPDetailPanel from './provider-detail'
// Mock the drawer component
vi.mock('@/app/components/base/drawer', () => ({
default: ({ children, isOpen }: { children: ReactNode, isOpen: boolean }) => {
if (!isOpen)
return null
return <div data-testid="drawer">{children}</div>
},
}))
// Mock the content component to expose onUpdate callback
vi.mock('./content', () => ({
default: ({ detail, onUpdate }: { detail: ToolWithProvider, onUpdate: (isDelete?: boolean) => void }) => (
<div data-testid="mcp-detail-content">
{detail.name}
<button data-testid="update-btn" onClick={() => onUpdate()}>Update</button>
<button data-testid="delete-btn" onClick={() => onUpdate(true)}>Delete</button>
</div>
),
}))
describe('MCPDetailPanel', () => {
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children)
}
const createMockDetail = (): ToolWithProvider => ({
id: 'mcp-1',
name: 'Test MCP',
server_identifier: 'test-mcp',
server_url: 'https://example.com/mcp',
icon: { content: '🔧', background: '#FF0000' },
tools: [],
is_team_authorization: true,
} as unknown as ToolWithProvider)
const defaultProps = {
onUpdate: vi.fn(),
onHide: vi.fn(),
isTriggerAuthorize: false,
onFirstCreate: vi.fn(),
}
describe('Rendering', () => {
it('should render nothing when detail is undefined', () => {
const { container } = render(
<MCPDetailPanel {...defaultProps} detail={undefined} />,
{ wrapper: createWrapper() },
)
expect(container.innerHTML).toBe('')
})
it('should render drawer when detail is provided', () => {
const detail = createMockDetail()
render(
<MCPDetailPanel {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByTestId('drawer')).toBeInTheDocument()
})
it('should render content when detail is provided', () => {
const detail = createMockDetail()
render(
<MCPDetailPanel {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByTestId('mcp-detail-content')).toBeInTheDocument()
})
it('should pass detail to content component', () => {
const detail = createMockDetail()
render(
<MCPDetailPanel {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('Test MCP')).toBeInTheDocument()
})
})
describe('Callbacks', () => {
it('should call onUpdate when update is triggered', () => {
const onUpdate = vi.fn()
const detail = createMockDetail()
render(
<MCPDetailPanel {...defaultProps} detail={detail} onUpdate={onUpdate} />,
{ wrapper: createWrapper() },
)
// The update callback is passed to content component
expect(screen.getByTestId('mcp-detail-content')).toBeInTheDocument()
})
it('should accept isTriggerAuthorize prop', () => {
const detail = createMockDetail()
render(
<MCPDetailPanel {...defaultProps} detail={detail} isTriggerAuthorize={true} />,
{ wrapper: createWrapper() },
)
expect(screen.getByTestId('mcp-detail-content')).toBeInTheDocument()
})
})
describe('handleUpdate', () => {
it('should call onUpdate but not onHide when isDelete is false (default)', () => {
const onUpdate = vi.fn()
const onHide = vi.fn()
const detail = createMockDetail()
render(
<MCPDetailPanel {...defaultProps} detail={detail} onUpdate={onUpdate} onHide={onHide} />,
{ wrapper: createWrapper() },
)
// Click update button which calls onUpdate() without isDelete parameter
const updateBtn = screen.getByTestId('update-btn')
fireEvent.click(updateBtn)
expect(onUpdate).toHaveBeenCalledTimes(1)
expect(onHide).not.toHaveBeenCalled()
})
it('should call both onHide and onUpdate when isDelete is true', () => {
const onUpdate = vi.fn()
const onHide = vi.fn()
const detail = createMockDetail()
render(
<MCPDetailPanel {...defaultProps} detail={detail} onUpdate={onUpdate} onHide={onHide} />,
{ wrapper: createWrapper() },
)
// Click delete button which calls onUpdate(true)
const deleteBtn = screen.getByTestId('delete-btn')
fireEvent.click(deleteBtn)
expect(onHide).toHaveBeenCalledTimes(1)
expect(onUpdate).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -1,126 +0,0 @@
import type { Tool } from '@/app/components/tools/types'
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import MCPToolItem from './tool-item'
describe('MCPToolItem', () => {
const createMockTool = (overrides = {}): Tool => ({
name: 'test-tool',
label: {
en_US: 'Test Tool',
zh_Hans: '测试工具',
},
description: {
en_US: 'A test tool description',
zh_Hans: '测试工具描述',
},
parameters: [],
...overrides,
} as unknown as Tool)
describe('Rendering', () => {
it('should render without crashing', () => {
const tool = createMockTool()
render(<MCPToolItem tool={tool} />)
expect(screen.getByText('Test Tool')).toBeInTheDocument()
})
it('should display tool label', () => {
const tool = createMockTool()
render(<MCPToolItem tool={tool} />)
expect(screen.getByText('Test Tool')).toBeInTheDocument()
})
it('should display tool description', () => {
const tool = createMockTool()
render(<MCPToolItem tool={tool} />)
expect(screen.getByText('A test tool description')).toBeInTheDocument()
})
})
describe('With Parameters', () => {
it('should not show parameters section when no parameters', () => {
const tool = createMockTool({ parameters: [] })
render(<MCPToolItem tool={tool} />)
expect(screen.queryByText('tools.mcp.toolItem.parameters')).not.toBeInTheDocument()
})
it('should render with parameters', () => {
const tool = createMockTool({
parameters: [
{
name: 'param1',
type: 'string',
human_description: {
en_US: 'A parameter description',
},
},
],
})
render(<MCPToolItem tool={tool} />)
// Tooltip content is rendered in portal, may not be visible immediately
expect(screen.getByText('Test Tool')).toBeInTheDocument()
})
})
describe('Styling', () => {
it('should have cursor-pointer class', () => {
const tool = createMockTool()
render(<MCPToolItem tool={tool} />)
const toolElement = document.querySelector('.cursor-pointer')
expect(toolElement).toBeInTheDocument()
})
it('should have rounded-xl class', () => {
const tool = createMockTool()
render(<MCPToolItem tool={tool} />)
const toolElement = document.querySelector('.rounded-xl')
expect(toolElement).toBeInTheDocument()
})
it('should have hover styles', () => {
const tool = createMockTool()
render(<MCPToolItem tool={tool} />)
const toolElement = document.querySelector('[class*="hover:bg-components-panel-on-panel-item-bg-hover"]')
expect(toolElement).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle empty label', () => {
const tool = createMockTool({
label: { en_US: '', zh_Hans: '' },
})
render(<MCPToolItem tool={tool} />)
// Should render without crashing
expect(document.querySelector('.cursor-pointer')).toBeInTheDocument()
})
it('should handle empty description', () => {
const tool = createMockTool({
description: { en_US: '', zh_Hans: '' },
})
render(<MCPToolItem tool={tool} />)
expect(screen.getByText('Test Tool')).toBeInTheDocument()
})
it('should handle long description with line clamp', () => {
const longDescription = 'This is a very long description '.repeat(20)
const tool = createMockTool({
description: { en_US: longDescription, zh_Hans: longDescription },
})
render(<MCPToolItem tool={tool} />)
const descElement = document.querySelector('.line-clamp-2')
expect(descElement).toBeInTheDocument()
})
it('should handle special characters in tool name', () => {
const tool = createMockTool({
name: 'special-tool_v2.0',
label: { en_US: 'Special Tool <v2.0>', zh_Hans: '特殊工具' },
})
render(<MCPToolItem tool={tool} />)
expect(screen.getByText('Special Tool <v2.0>')).toBeInTheDocument()
})
})
})

View File

@ -1,245 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import HeadersInput from './headers-input'
describe('HeadersInput', () => {
const defaultProps = {
headersItems: [],
onChange: vi.fn(),
}
describe('Empty State', () => {
it('should render no headers message when empty', () => {
render(<HeadersInput {...defaultProps} />)
expect(screen.getByText('tools.mcp.modal.noHeaders')).toBeInTheDocument()
})
it('should render add header button when empty and not readonly', () => {
render(<HeadersInput {...defaultProps} />)
expect(screen.getByText('tools.mcp.modal.addHeader')).toBeInTheDocument()
})
it('should not render add header button when empty and readonly', () => {
render(<HeadersInput {...defaultProps} readonly={true} />)
expect(screen.queryByText('tools.mcp.modal.addHeader')).not.toBeInTheDocument()
})
it('should call onChange with new item when add button is clicked', () => {
const onChange = vi.fn()
render(<HeadersInput {...defaultProps} onChange={onChange} />)
const addButton = screen.getByText('tools.mcp.modal.addHeader')
fireEvent.click(addButton)
expect(onChange).toHaveBeenCalledWith([
expect.objectContaining({
key: '',
value: '',
}),
])
})
})
describe('With Headers', () => {
const headersItems = [
{ id: '1', key: 'Authorization', value: 'Bearer token123' },
{ id: '2', key: 'Content-Type', value: 'application/json' },
]
it('should render header items', () => {
render(<HeadersInput {...defaultProps} headersItems={headersItems} />)
expect(screen.getByDisplayValue('Authorization')).toBeInTheDocument()
expect(screen.getByDisplayValue('Bearer token123')).toBeInTheDocument()
expect(screen.getByDisplayValue('Content-Type')).toBeInTheDocument()
expect(screen.getByDisplayValue('application/json')).toBeInTheDocument()
})
it('should render table headers', () => {
render(<HeadersInput {...defaultProps} headersItems={headersItems} />)
expect(screen.getByText('tools.mcp.modal.headerKey')).toBeInTheDocument()
expect(screen.getByText('tools.mcp.modal.headerValue')).toBeInTheDocument()
})
it('should render delete buttons for each item when not readonly', () => {
render(<HeadersInput {...defaultProps} headersItems={headersItems} />)
// Should have delete buttons for each header
const deleteButtons = document.querySelectorAll('[class*="text-text-destructive"]')
expect(deleteButtons.length).toBe(headersItems.length)
})
it('should not render delete buttons when readonly', () => {
render(<HeadersInput {...defaultProps} headersItems={headersItems} readonly={true} />)
const deleteButtons = document.querySelectorAll('[class*="text-text-destructive"]')
expect(deleteButtons.length).toBe(0)
})
it('should render add button at bottom when not readonly', () => {
render(<HeadersInput {...defaultProps} headersItems={headersItems} />)
expect(screen.getByText('tools.mcp.modal.addHeader')).toBeInTheDocument()
})
it('should not render add button when readonly', () => {
render(<HeadersInput {...defaultProps} headersItems={headersItems} readonly={true} />)
expect(screen.queryByText('tools.mcp.modal.addHeader')).not.toBeInTheDocument()
})
})
describe('Masked Headers', () => {
const headersItems = [{ id: '1', key: 'Secret', value: '***' }]
it('should show masked headers tip when isMasked is true', () => {
render(<HeadersInput {...defaultProps} headersItems={headersItems} isMasked={true} />)
expect(screen.getByText('tools.mcp.modal.maskedHeadersTip')).toBeInTheDocument()
})
it('should not show masked headers tip when isMasked is false', () => {
render(<HeadersInput {...defaultProps} headersItems={headersItems} isMasked={false} />)
expect(screen.queryByText('tools.mcp.modal.maskedHeadersTip')).not.toBeInTheDocument()
})
})
describe('Item Interactions', () => {
const headersItems = [
{ id: '1', key: 'Header1', value: 'Value1' },
]
it('should call onChange when key is changed', () => {
const onChange = vi.fn()
render(<HeadersInput {...defaultProps} headersItems={headersItems} onChange={onChange} />)
const keyInput = screen.getByDisplayValue('Header1')
fireEvent.change(keyInput, { target: { value: 'NewHeader' } })
expect(onChange).toHaveBeenCalledWith([
{ id: '1', key: 'NewHeader', value: 'Value1' },
])
})
it('should call onChange when value is changed', () => {
const onChange = vi.fn()
render(<HeadersInput {...defaultProps} headersItems={headersItems} onChange={onChange} />)
const valueInput = screen.getByDisplayValue('Value1')
fireEvent.change(valueInput, { target: { value: 'NewValue' } })
expect(onChange).toHaveBeenCalledWith([
{ id: '1', key: 'Header1', value: 'NewValue' },
])
})
it('should remove item when delete button is clicked', () => {
const onChange = vi.fn()
render(<HeadersInput {...defaultProps} headersItems={headersItems} onChange={onChange} />)
const deleteButton = document.querySelector('[class*="text-text-destructive"]')?.closest('button')
if (deleteButton) {
fireEvent.click(deleteButton)
expect(onChange).toHaveBeenCalledWith([])
}
})
it('should add new item when add button is clicked', () => {
const onChange = vi.fn()
render(<HeadersInput {...defaultProps} headersItems={headersItems} onChange={onChange} />)
const addButton = screen.getByText('tools.mcp.modal.addHeader')
fireEvent.click(addButton)
expect(onChange).toHaveBeenCalledWith([
{ id: '1', key: 'Header1', value: 'Value1' },
expect.objectContaining({ key: '', value: '' }),
])
})
})
describe('Multiple Headers', () => {
const headersItems = [
{ id: '1', key: 'Header1', value: 'Value1' },
{ id: '2', key: 'Header2', value: 'Value2' },
{ id: '3', key: 'Header3', value: 'Value3' },
]
it('should render all headers', () => {
render(<HeadersInput {...defaultProps} headersItems={headersItems} />)
expect(screen.getByDisplayValue('Header1')).toBeInTheDocument()
expect(screen.getByDisplayValue('Header2')).toBeInTheDocument()
expect(screen.getByDisplayValue('Header3')).toBeInTheDocument()
})
it('should update correct item when changed', () => {
const onChange = vi.fn()
render(<HeadersInput {...defaultProps} headersItems={headersItems} onChange={onChange} />)
const header2Input = screen.getByDisplayValue('Header2')
fireEvent.change(header2Input, { target: { value: 'UpdatedHeader2' } })
expect(onChange).toHaveBeenCalledWith([
{ id: '1', key: 'Header1', value: 'Value1' },
{ id: '2', key: 'UpdatedHeader2', value: 'Value2' },
{ id: '3', key: 'Header3', value: 'Value3' },
])
})
it('should remove correct item when deleted', () => {
const onChange = vi.fn()
render(<HeadersInput {...defaultProps} headersItems={headersItems} onChange={onChange} />)
// Find all delete buttons and click the second one
const deleteButtons = document.querySelectorAll('[class*="text-text-destructive"]')
const secondDeleteButton = deleteButtons[1]?.closest('button')
if (secondDeleteButton) {
fireEvent.click(secondDeleteButton)
expect(onChange).toHaveBeenCalledWith([
{ id: '1', key: 'Header1', value: 'Value1' },
{ id: '3', key: 'Header3', value: 'Value3' },
])
}
})
})
describe('Readonly Mode', () => {
const headersItems = [{ id: '1', key: 'ReadOnly', value: 'Value' }]
it('should make inputs readonly when readonly is true', () => {
render(<HeadersInput {...defaultProps} headersItems={headersItems} readonly={true} />)
const keyInput = screen.getByDisplayValue('ReadOnly')
const valueInput = screen.getByDisplayValue('Value')
expect(keyInput).toHaveAttribute('readonly')
expect(valueInput).toHaveAttribute('readonly')
})
it('should not make inputs readonly when readonly is false', () => {
render(<HeadersInput {...defaultProps} headersItems={headersItems} readonly={false} />)
const keyInput = screen.getByDisplayValue('ReadOnly')
const valueInput = screen.getByDisplayValue('Value')
expect(keyInput).not.toHaveAttribute('readonly')
expect(valueInput).not.toHaveAttribute('readonly')
})
})
describe('Edge Cases', () => {
it('should handle empty key and value', () => {
const headersItems = [{ id: '1', key: '', value: '' }]
render(<HeadersInput {...defaultProps} headersItems={headersItems} />)
const inputs = screen.getAllByRole('textbox')
expect(inputs.length).toBe(2)
})
it('should handle special characters in header key', () => {
const headersItems = [{ id: '1', key: 'X-Custom-Header', value: 'value' }]
render(<HeadersInput {...defaultProps} headersItems={headersItems} />)
expect(screen.getByDisplayValue('X-Custom-Header')).toBeInTheDocument()
})
it('should handle JSON value', () => {
const headersItems = [{ id: '1', key: 'Data', value: '{"key":"value"}' }]
render(<HeadersInput {...defaultProps} headersItems={headersItems} />)
expect(screen.getByDisplayValue('{"key":"value"}')).toBeInTheDocument()
})
})
})

View File

@ -1,500 +0,0 @@
import type { AppIconEmojiSelection, AppIconImageSelection } from '@/app/components/base/app-icon-picker'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { act, renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { MCPAuthMethod } from '@/app/components/tools/types'
import { isValidServerID, isValidUrl, useMCPModalForm } from './use-mcp-modal-form'
// Mock the API service
vi.mock('@/service/common', () => ({
uploadRemoteFileInfo: vi.fn(),
}))
describe('useMCPModalForm', () => {
describe('Utility Functions', () => {
describe('isValidUrl', () => {
it('should return true for valid http URL', () => {
expect(isValidUrl('http://example.com')).toBe(true)
})
it('should return true for valid https URL', () => {
expect(isValidUrl('https://example.com')).toBe(true)
})
it('should return true for URL with path', () => {
expect(isValidUrl('https://example.com/path/to/resource')).toBe(true)
})
it('should return true for URL with query params', () => {
expect(isValidUrl('https://example.com?foo=bar')).toBe(true)
})
it('should return false for invalid URL', () => {
expect(isValidUrl('not-a-url')).toBe(false)
})
it('should return false for ftp URL', () => {
expect(isValidUrl('ftp://example.com')).toBe(false)
})
it('should return false for empty string', () => {
expect(isValidUrl('')).toBe(false)
})
it('should return false for file URL', () => {
expect(isValidUrl('file:///path/to/file')).toBe(false)
})
})
describe('isValidServerID', () => {
it('should return true for lowercase letters', () => {
expect(isValidServerID('myserver')).toBe(true)
})
it('should return true for numbers', () => {
expect(isValidServerID('123')).toBe(true)
})
it('should return true for alphanumeric with hyphens', () => {
expect(isValidServerID('my-server-123')).toBe(true)
})
it('should return true for alphanumeric with underscores', () => {
expect(isValidServerID('my_server_123')).toBe(true)
})
it('should return true for max length (24 chars)', () => {
expect(isValidServerID('abcdefghijklmnopqrstuvwx')).toBe(true)
})
it('should return false for uppercase letters', () => {
expect(isValidServerID('MyServer')).toBe(false)
})
it('should return false for spaces', () => {
expect(isValidServerID('my server')).toBe(false)
})
it('should return false for special characters', () => {
expect(isValidServerID('my@server')).toBe(false)
})
it('should return false for empty string', () => {
expect(isValidServerID('')).toBe(false)
})
it('should return false for string longer than 24 chars', () => {
expect(isValidServerID('abcdefghijklmnopqrstuvwxy')).toBe(false)
})
})
})
describe('Hook Initialization', () => {
describe('Create Mode (no data)', () => {
it('should initialize with default values', () => {
const { result } = renderHook(() => useMCPModalForm())
expect(result.current.isCreate).toBe(true)
expect(result.current.formKey).toBe('create')
expect(result.current.state.url).toBe('')
expect(result.current.state.name).toBe('')
expect(result.current.state.serverIdentifier).toBe('')
expect(result.current.state.timeout).toBe(30)
expect(result.current.state.sseReadTimeout).toBe(300)
expect(result.current.state.headers).toEqual([])
expect(result.current.state.authMethod).toBe(MCPAuthMethod.authentication)
expect(result.current.state.isDynamicRegistration).toBe(true)
expect(result.current.state.clientID).toBe('')
expect(result.current.state.credentials).toBe('')
})
it('should initialize with default emoji icon', () => {
const { result } = renderHook(() => useMCPModalForm())
expect(result.current.state.appIcon).toEqual({
type: 'emoji',
icon: '🔗',
background: '#6366F1',
})
})
})
describe('Edit Mode (with data)', () => {
const mockData: ToolWithProvider = {
id: 'test-id-123',
name: 'Test MCP Server',
server_url: 'https://example.com/mcp',
server_identifier: 'test-server',
icon: { content: '🚀', background: '#FF0000' },
configuration: {
timeout: 60,
sse_read_timeout: 600,
},
masked_headers: {
'Authorization': '***',
'X-Custom': 'value',
},
is_dynamic_registration: false,
authentication: {
client_id: 'client-123',
client_secret: 'secret-456',
},
} as unknown as ToolWithProvider
it('should initialize with data values', () => {
const { result } = renderHook(() => useMCPModalForm(mockData))
expect(result.current.isCreate).toBe(false)
expect(result.current.formKey).toBe('test-id-123')
expect(result.current.state.url).toBe('https://example.com/mcp')
expect(result.current.state.name).toBe('Test MCP Server')
expect(result.current.state.serverIdentifier).toBe('test-server')
expect(result.current.state.timeout).toBe(60)
expect(result.current.state.sseReadTimeout).toBe(600)
expect(result.current.state.isDynamicRegistration).toBe(false)
expect(result.current.state.clientID).toBe('client-123')
expect(result.current.state.credentials).toBe('secret-456')
})
it('should initialize headers from masked_headers', () => {
const { result } = renderHook(() => useMCPModalForm(mockData))
expect(result.current.state.headers).toHaveLength(2)
expect(result.current.state.headers[0].key).toBe('Authorization')
expect(result.current.state.headers[0].value).toBe('***')
expect(result.current.state.headers[1].key).toBe('X-Custom')
expect(result.current.state.headers[1].value).toBe('value')
})
it('should initialize emoji icon from data', () => {
const { result } = renderHook(() => useMCPModalForm(mockData))
expect(result.current.state.appIcon.type).toBe('emoji')
expect(((result.current.state.appIcon) as AppIconEmojiSelection).icon).toBe('🚀')
expect(((result.current.state.appIcon) as AppIconEmojiSelection).background).toBe('#FF0000')
})
it('should store original server URL and ID', () => {
const { result } = renderHook(() => useMCPModalForm(mockData))
expect(result.current.originalServerUrl).toBe('https://example.com/mcp')
expect(result.current.originalServerID).toBe('test-server')
})
})
describe('Edit Mode with string icon', () => {
const mockDataWithImageIcon: ToolWithProvider = {
id: 'test-id',
name: 'Test',
icon: 'https://example.com/files/abc123/file-preview/icon.png',
} as unknown as ToolWithProvider
it('should initialize image icon from string URL', () => {
const { result } = renderHook(() => useMCPModalForm(mockDataWithImageIcon))
expect(result.current.state.appIcon.type).toBe('image')
expect(((result.current.state.appIcon) as AppIconImageSelection).url).toBe('https://example.com/files/abc123/file-preview/icon.png')
expect(((result.current.state.appIcon) as AppIconImageSelection).fileId).toBe('abc123')
})
})
})
describe('Actions', () => {
it('should update url', () => {
const { result } = renderHook(() => useMCPModalForm())
act(() => {
result.current.actions.setUrl('https://new-url.com')
})
expect(result.current.state.url).toBe('https://new-url.com')
})
it('should update name', () => {
const { result } = renderHook(() => useMCPModalForm())
act(() => {
result.current.actions.setName('New Server Name')
})
expect(result.current.state.name).toBe('New Server Name')
})
it('should update serverIdentifier', () => {
const { result } = renderHook(() => useMCPModalForm())
act(() => {
result.current.actions.setServerIdentifier('new-server-id')
})
expect(result.current.state.serverIdentifier).toBe('new-server-id')
})
it('should update timeout', () => {
const { result } = renderHook(() => useMCPModalForm())
act(() => {
result.current.actions.setTimeout(120)
})
expect(result.current.state.timeout).toBe(120)
})
it('should update sseReadTimeout', () => {
const { result } = renderHook(() => useMCPModalForm())
act(() => {
result.current.actions.setSseReadTimeout(900)
})
expect(result.current.state.sseReadTimeout).toBe(900)
})
it('should update headers', () => {
const { result } = renderHook(() => useMCPModalForm())
const newHeaders = [{ id: '1', key: 'X-New', value: 'new-value' }]
act(() => {
result.current.actions.setHeaders(newHeaders)
})
expect(result.current.state.headers).toEqual(newHeaders)
})
it('should update authMethod', () => {
const { result } = renderHook(() => useMCPModalForm())
act(() => {
result.current.actions.setAuthMethod(MCPAuthMethod.headers)
})
expect(result.current.state.authMethod).toBe(MCPAuthMethod.headers)
})
it('should update isDynamicRegistration', () => {
const { result } = renderHook(() => useMCPModalForm())
act(() => {
result.current.actions.setIsDynamicRegistration(false)
})
expect(result.current.state.isDynamicRegistration).toBe(false)
})
it('should update clientID', () => {
const { result } = renderHook(() => useMCPModalForm())
act(() => {
result.current.actions.setClientID('new-client-id')
})
expect(result.current.state.clientID).toBe('new-client-id')
})
it('should update credentials', () => {
const { result } = renderHook(() => useMCPModalForm())
act(() => {
result.current.actions.setCredentials('new-secret')
})
expect(result.current.state.credentials).toBe('new-secret')
})
it('should update appIcon', () => {
const { result } = renderHook(() => useMCPModalForm())
const newIcon = { type: 'emoji' as const, icon: '🎉', background: '#00FF00' }
act(() => {
result.current.actions.setAppIcon(newIcon)
})
expect(result.current.state.appIcon).toEqual(newIcon)
})
it('should toggle showAppIconPicker', () => {
const { result } = renderHook(() => useMCPModalForm())
expect(result.current.state.showAppIconPicker).toBe(false)
act(() => {
result.current.actions.setShowAppIconPicker(true)
})
expect(result.current.state.showAppIconPicker).toBe(true)
})
it('should reset icon to default', () => {
const { result } = renderHook(() => useMCPModalForm())
// Change icon first
act(() => {
result.current.actions.setAppIcon({ type: 'emoji', icon: '🎉', background: '#00FF00' })
})
expect(((result.current.state.appIcon) as AppIconEmojiSelection).icon).toBe('🎉')
// Reset icon
act(() => {
result.current.actions.resetIcon()
})
expect(result.current.state.appIcon).toEqual({
type: 'emoji',
icon: '🔗',
background: '#6366F1',
})
})
})
describe('handleUrlBlur', () => {
it('should not fetch icon in edit mode (when data is provided)', async () => {
const mockData = {
id: 'test',
name: 'Test',
icon: { content: '🔗', background: '#6366F1' },
} as unknown as ToolWithProvider
const { result } = renderHook(() => useMCPModalForm(mockData))
await act(async () => {
await result.current.actions.handleUrlBlur('https://example.com')
})
// In edit mode, handleUrlBlur should return early
expect(result.current.state.isFetchingIcon).toBe(false)
})
it('should not fetch icon for invalid URL', async () => {
const { result } = renderHook(() => useMCPModalForm())
await act(async () => {
await result.current.actions.handleUrlBlur('not-a-valid-url')
})
expect(result.current.state.isFetchingIcon).toBe(false)
})
it('should handle error when icon fetch fails with error code', async () => {
const { uploadRemoteFileInfo } = await import('@/service/common')
const mockError = {
json: vi.fn().mockResolvedValue({ code: 'UPLOAD_ERROR' }),
}
vi.mocked(uploadRemoteFileInfo).mockRejectedValueOnce(mockError)
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const { result } = renderHook(() => useMCPModalForm())
await act(async () => {
await result.current.actions.handleUrlBlur('https://example.com/mcp')
})
// Should have called console.error
expect(consoleErrorSpy).toHaveBeenCalled()
// isFetchingIcon should be reset to false after error
expect(result.current.state.isFetchingIcon).toBe(false)
consoleErrorSpy.mockRestore()
})
it('should handle error when icon fetch fails without error code', async () => {
const { uploadRemoteFileInfo } = await import('@/service/common')
const mockError = {
json: vi.fn().mockResolvedValue({}),
}
vi.mocked(uploadRemoteFileInfo).mockRejectedValueOnce(mockError)
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const { result } = renderHook(() => useMCPModalForm())
await act(async () => {
await result.current.actions.handleUrlBlur('https://example.com/mcp')
})
// Should have called console.error
expect(consoleErrorSpy).toHaveBeenCalled()
// isFetchingIcon should be reset to false after error
expect(result.current.state.isFetchingIcon).toBe(false)
consoleErrorSpy.mockRestore()
})
it('should fetch icon successfully for valid URL in create mode', async () => {
vi.mocked(await import('@/service/common').then(m => m.uploadRemoteFileInfo)).mockResolvedValueOnce({
id: 'file123',
name: 'icon.png',
size: 1024,
mime_type: 'image/png',
url: 'https://example.com/files/file123/file-preview/icon.png',
} as unknown as { id: string, name: string, size: number, mime_type: string, url: string })
const { result } = renderHook(() => useMCPModalForm())
await act(async () => {
await result.current.actions.handleUrlBlur('https://example.com/mcp')
})
// Icon should be set to image type
expect(result.current.state.appIcon.type).toBe('image')
expect(((result.current.state.appIcon) as AppIconImageSelection).url).toBe('https://example.com/files/file123/file-preview/icon.png')
expect(result.current.state.isFetchingIcon).toBe(false)
})
})
describe('Edge Cases', () => {
// Base mock data with required icon field
const baseMockData = {
id: 'test',
name: 'Test',
icon: { content: '🔗', background: '#6366F1' },
}
it('should handle undefined configuration', () => {
const mockData = { ...baseMockData } as unknown as ToolWithProvider
const { result } = renderHook(() => useMCPModalForm(mockData))
expect(result.current.state.timeout).toBe(30)
expect(result.current.state.sseReadTimeout).toBe(300)
})
it('should handle undefined authentication', () => {
const mockData = { ...baseMockData } as unknown as ToolWithProvider
const { result } = renderHook(() => useMCPModalForm(mockData))
expect(result.current.state.clientID).toBe('')
expect(result.current.state.credentials).toBe('')
})
it('should handle undefined masked_headers', () => {
const mockData = { ...baseMockData } as unknown as ToolWithProvider
const { result } = renderHook(() => useMCPModalForm(mockData))
expect(result.current.state.headers).toEqual([])
})
it('should handle undefined is_dynamic_registration (defaults to true)', () => {
const mockData = { ...baseMockData } as unknown as ToolWithProvider
const { result } = renderHook(() => useMCPModalForm(mockData))
expect(result.current.state.isDynamicRegistration).toBe(true)
})
it('should handle string icon URL', () => {
const mockData = {
id: 'test',
name: 'Test',
icon: 'https://example.com/icon.png',
} as unknown as ToolWithProvider
const { result } = renderHook(() => useMCPModalForm(mockData))
expect(result.current.state.appIcon.type).toBe('image')
expect(((result.current.state.appIcon) as AppIconImageSelection).url).toBe('https://example.com/icon.png')
})
})
})

View File

@ -1,203 +0,0 @@
'use client'
import type { HeaderItem } from '../headers-input'
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { useCallback, useMemo, useRef, useState } from 'react'
import { getDomain } from 'tldts'
import { v4 as uuid } from 'uuid'
import Toast from '@/app/components/base/toast'
import { MCPAuthMethod } from '@/app/components/tools/types'
import { uploadRemoteFileInfo } from '@/service/common'
const DEFAULT_ICON = { type: 'emoji', icon: '🔗', background: '#6366F1' }
const extractFileId = (url: string) => {
const match = url.match(/files\/(.+?)\/file-preview/)
return match ? match[1] : null
}
const getIcon = (data?: ToolWithProvider): AppIconSelection => {
if (!data)
return DEFAULT_ICON as AppIconSelection
if (typeof data.icon === 'string')
return { type: 'image', url: data.icon, fileId: extractFileId(data.icon) } as AppIconSelection
return {
...data.icon,
icon: data.icon.content,
type: 'emoji',
} as unknown as AppIconSelection
}
const getInitialHeaders = (data?: ToolWithProvider): HeaderItem[] => {
return Object.entries(data?.masked_headers || {}).map(([key, value]) => ({ id: uuid(), key, value }))
}
export const isValidUrl = (string: string) => {
try {
const url = new URL(string)
return url.protocol === 'http:' || url.protocol === 'https:'
}
catch {
return false
}
}
export const isValidServerID = (str: string) => {
return /^[a-z0-9_-]{1,24}$/.test(str)
}
export type MCPModalFormState = {
url: string
name: string
appIcon: AppIconSelection
showAppIconPicker: boolean
serverIdentifier: string
timeout: number
sseReadTimeout: number
headers: HeaderItem[]
isFetchingIcon: boolean
authMethod: MCPAuthMethod
isDynamicRegistration: boolean
clientID: string
credentials: string
}
export type MCPModalFormActions = {
setUrl: (url: string) => void
setName: (name: string) => void
setAppIcon: (icon: AppIconSelection) => void
setShowAppIconPicker: (show: boolean) => void
setServerIdentifier: (id: string) => void
setTimeout: (timeout: number) => void
setSseReadTimeout: (timeout: number) => void
setHeaders: (headers: HeaderItem[]) => void
setAuthMethod: (method: string) => void
setIsDynamicRegistration: (value: boolean) => void
setClientID: (id: string) => void
setCredentials: (credentials: string) => void
handleUrlBlur: (url: string) => Promise<void>
resetIcon: () => void
}
/**
* Custom hook for MCP Modal form state management.
*
* Note: This hook uses a `formKey` (data ID or 'create') to reset form state when
* switching between edit and create modes. All useState initializers read from `data`
* directly, and the key change triggers a remount of the consumer component.
*/
export const useMCPModalForm = (data?: ToolWithProvider) => {
const isCreate = !data
const originalServerUrl = data?.server_url
const originalServerID = data?.server_identifier
// Form key for resetting state - changes when data changes
const formKey = useMemo(() => data?.id ?? 'create', [data?.id])
// Form state - initialized from data
const [url, setUrl] = useState(() => data?.server_url || '')
const [name, setName] = useState(() => data?.name || '')
const [appIcon, setAppIcon] = useState<AppIconSelection>(() => getIcon(data))
const [showAppIconPicker, setShowAppIconPicker] = useState(false)
const [serverIdentifier, setServerIdentifier] = useState(() => data?.server_identifier || '')
const [timeout, setMcpTimeout] = useState(() => data?.configuration?.timeout || 30)
const [sseReadTimeout, setSseReadTimeout] = useState(() => data?.configuration?.sse_read_timeout || 300)
const [headers, setHeaders] = useState<HeaderItem[]>(() => getInitialHeaders(data))
const [isFetchingIcon, setIsFetchingIcon] = useState(false)
const appIconRef = useRef<HTMLDivElement>(null)
// Auth state
const [authMethod, setAuthMethod] = useState(MCPAuthMethod.authentication)
const [isDynamicRegistration, setIsDynamicRegistration] = useState(() => isCreate ? true : (data?.is_dynamic_registration ?? true))
const [clientID, setClientID] = useState(() => data?.authentication?.client_id || '')
const [credentials, setCredentials] = useState(() => data?.authentication?.client_secret || '')
const handleUrlBlur = useCallback(async (urlValue: string) => {
if (data)
return
if (!isValidUrl(urlValue))
return
const domain = getDomain(urlValue)
const remoteIcon = `https://www.google.com/s2/favicons?domain=${domain}&sz=128`
setIsFetchingIcon(true)
try {
const res = await uploadRemoteFileInfo(remoteIcon, undefined, true)
setAppIcon({ type: 'image', url: res.url, fileId: extractFileId(res.url) || '' })
}
catch (e) {
let errorMessage = 'Failed to fetch remote icon'
if (e instanceof Response) {
try {
const errorData = await e.json()
if (errorData?.code)
errorMessage = `Upload failed: ${errorData.code}`
}
catch {
// Ignore JSON parsing errors
}
}
else if (e instanceof Error) {
errorMessage = e.message
}
console.error('Failed to fetch remote icon:', e)
Toast.notify({ type: 'warning', message: errorMessage })
}
finally {
setIsFetchingIcon(false)
}
}, [data])
const resetIcon = useCallback(() => {
setAppIcon(getIcon(data))
}, [data])
const handleAuthMethodChange = useCallback((value: string) => {
setAuthMethod(value as MCPAuthMethod)
}, [])
return {
// Key for form reset (use as React key on parent)
formKey,
// Metadata
isCreate,
originalServerUrl,
originalServerID,
appIconRef,
// State
state: {
url,
name,
appIcon,
showAppIconPicker,
serverIdentifier,
timeout,
sseReadTimeout,
headers,
isFetchingIcon,
authMethod,
isDynamicRegistration,
clientID,
credentials,
} satisfies MCPModalFormState,
// Actions
actions: {
setUrl,
setName,
setAppIcon,
setShowAppIconPicker,
setServerIdentifier,
setTimeout: setMcpTimeout,
setSseReadTimeout,
setHeaders,
setAuthMethod: handleAuthMethodChange,
setIsDynamicRegistration,
setClientID,
setCredentials,
handleUrlBlur,
resetIcon,
} satisfies MCPModalFormActions,
}
}

View File

@ -1,451 +0,0 @@
import type { ReactNode } from 'react'
import type { AppDetailResponse } from '@/models/app'
import type { AppSSO } from '@/types/app'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AppModeEnum } from '@/types/app'
import { useMCPServiceCardState } from './use-mcp-service-card'
// Mutable mock data for MCP server detail
let mockMCPServerDetailData: {
id: string
status: string
server_code: string
description: string
parameters: Record<string, unknown>
} | undefined = {
id: 'server-123',
status: 'active',
server_code: 'abc123',
description: 'Test server',
parameters: {},
}
// Mock service hooks
vi.mock('@/service/use-tools', () => ({
useUpdateMCPServer: () => ({
mutateAsync: vi.fn().mockResolvedValue({}),
}),
useRefreshMCPServerCode: () => ({
mutateAsync: vi.fn().mockResolvedValue({}),
isPending: false,
}),
useMCPServerDetail: () => ({
data: mockMCPServerDetailData,
}),
useInvalidateMCPServerDetail: () => vi.fn(),
}))
// Mock workflow hook
vi.mock('@/service/use-workflow', () => ({
useAppWorkflow: (appId: string) => ({
data: appId
? {
graph: {
nodes: [
{ data: { type: 'start', variables: [{ variable: 'input', label: 'Input' }] } },
],
},
}
: undefined,
}),
}))
// Mock app context
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceManager: true,
isCurrentWorkspaceEditor: true,
}),
}))
// Mock apps service
vi.mock('@/service/apps', () => ({
fetchAppDetail: vi.fn().mockResolvedValue({
model_config: {
updated_at: '2024-01-01',
user_input_form: [],
},
}),
}))
describe('useMCPServiceCardState', () => {
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children)
}
const createMockAppInfo = (mode: AppModeEnum = AppModeEnum.CHAT): AppDetailResponse & Partial<AppSSO> => ({
id: 'app-123',
name: 'Test App',
mode,
api_base_url: 'https://api.example.com/v1',
} as AppDetailResponse & Partial<AppSSO>)
beforeEach(() => {
// Reset mock data to default (published server)
mockMCPServerDetailData = {
id: 'server-123',
status: 'active',
server_code: 'abc123',
description: 'Test server',
parameters: {},
}
})
describe('Initialization', () => {
it('should initialize with correct default values for basic app', () => {
const appInfo = createMockAppInfo(AppModeEnum.CHAT)
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
expect(result.current.serverPublished).toBe(true)
expect(result.current.serverActivated).toBe(true)
expect(result.current.showConfirmDelete).toBe(false)
expect(result.current.showMCPServerModal).toBe(false)
})
it('should initialize with correct values for workflow app', () => {
const appInfo = createMockAppInfo(AppModeEnum.WORKFLOW)
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
expect(result.current.isLoading).toBe(false)
})
it('should initialize with correct values for advanced chat app', () => {
const appInfo = createMockAppInfo(AppModeEnum.ADVANCED_CHAT)
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
expect(result.current.isLoading).toBe(false)
})
})
describe('Server URL Generation', () => {
it('should generate correct server URL when published', () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
expect(result.current.serverURL).toBe('https://api.example.com/mcp/server/abc123/mcp')
})
})
describe('Permission Flags', () => {
it('should have isCurrentWorkspaceManager as true', () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
expect(result.current.isCurrentWorkspaceManager).toBe(true)
})
it('should have toggleDisabled false when editor has permissions', () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
// Toggle is not disabled when user has permissions and app is published
expect(typeof result.current.toggleDisabled).toBe('boolean')
})
it('should have toggleDisabled true when triggerModeDisabled is true', () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, true),
{ wrapper: createWrapper() },
)
expect(result.current.toggleDisabled).toBe(true)
})
})
describe('UI State Actions', () => {
it('should open confirm delete modal', () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
expect(result.current.showConfirmDelete).toBe(false)
act(() => {
result.current.openConfirmDelete()
})
expect(result.current.showConfirmDelete).toBe(true)
})
it('should close confirm delete modal', () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
act(() => {
result.current.openConfirmDelete()
})
expect(result.current.showConfirmDelete).toBe(true)
act(() => {
result.current.closeConfirmDelete()
})
expect(result.current.showConfirmDelete).toBe(false)
})
it('should open server modal', () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
expect(result.current.showMCPServerModal).toBe(false)
act(() => {
result.current.openServerModal()
})
expect(result.current.showMCPServerModal).toBe(true)
})
it('should handle server modal hide', () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
act(() => {
result.current.openServerModal()
})
expect(result.current.showMCPServerModal).toBe(true)
let hideResult: { shouldDeactivate: boolean } | undefined
act(() => {
hideResult = result.current.handleServerModalHide(false)
})
expect(result.current.showMCPServerModal).toBe(false)
expect(hideResult?.shouldDeactivate).toBe(true)
})
it('should not deactivate when wasActivated is true', () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
let hideResult: { shouldDeactivate: boolean } | undefined
act(() => {
hideResult = result.current.handleServerModalHide(true)
})
expect(hideResult?.shouldDeactivate).toBe(false)
})
})
describe('Handler Functions', () => {
it('should have handleGenCode function', () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
expect(typeof result.current.handleGenCode).toBe('function')
})
it('should call handleGenCode and invalidate server detail', async () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
await act(async () => {
await result.current.handleGenCode()
})
// handleGenCode should complete without error
expect(result.current.genLoading).toBe(false)
})
it('should have handleStatusChange function', () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
expect(typeof result.current.handleStatusChange).toBe('function')
})
it('should have invalidateBasicAppConfig function', () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
expect(typeof result.current.invalidateBasicAppConfig).toBe('function')
})
it('should call invalidateBasicAppConfig', () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
// Call the function - should not throw
act(() => {
result.current.invalidateBasicAppConfig()
})
// Function should exist and be callable
expect(typeof result.current.invalidateBasicAppConfig).toBe('function')
})
})
describe('Status Change', () => {
it('should return activated state when status change succeeds', async () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
let statusResult: { activated: boolean } | undefined
await act(async () => {
statusResult = await result.current.handleStatusChange(true)
})
expect(statusResult?.activated).toBe(true)
})
it('should return deactivated state when disabling', async () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
let statusResult: { activated: boolean } | undefined
await act(async () => {
statusResult = await result.current.handleStatusChange(false)
})
expect(statusResult?.activated).toBe(false)
})
})
describe('Unpublished Server', () => {
it('should open modal and return not activated when enabling unpublished server', async () => {
// Set mock to return undefined (unpublished server)
mockMCPServerDetailData = undefined
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
// Verify server is not published
expect(result.current.serverPublished).toBe(false)
let statusResult: { activated: boolean } | undefined
await act(async () => {
statusResult = await result.current.handleStatusChange(true)
})
// Should open modal and return not activated
expect(result.current.showMCPServerModal).toBe(true)
expect(statusResult?.activated).toBe(false)
})
})
describe('Loading States', () => {
it('should have genLoading state', () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
expect(typeof result.current.genLoading).toBe('boolean')
})
it('should have isLoading state for basic app', () => {
const appInfo = createMockAppInfo(AppModeEnum.CHAT)
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
// Basic app doesn't need workflow, so isLoading should be false
expect(result.current.isLoading).toBe(false)
})
})
describe('Detail Data', () => {
it('should return detail data when available', () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
expect(result.current.detail).toBeDefined()
expect(result.current.detail?.id).toBe('server-123')
expect(result.current.detail?.status).toBe('active')
})
})
describe('Latest Params', () => {
it('should return latestParams for workflow app', () => {
const appInfo = createMockAppInfo(AppModeEnum.WORKFLOW)
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
expect(Array.isArray(result.current.latestParams)).toBe(true)
})
it('should return latestParams for basic app', () => {
const appInfo = createMockAppInfo(AppModeEnum.CHAT)
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
expect(Array.isArray(result.current.latestParams)).toBe(true)
})
})
})

View File

@ -1,179 +0,0 @@
'use client'
import type { AppDetailResponse } from '@/models/app'
import type { AppSSO } from '@/types/app'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useCallback, useMemo, useState } from 'react'
import { BlockEnum } from '@/app/components/workflow/types'
import { useAppContext } from '@/context/app-context'
import { fetchAppDetail } from '@/service/apps'
import {
useInvalidateMCPServerDetail,
useMCPServerDetail,
useRefreshMCPServerCode,
useUpdateMCPServer,
} from '@/service/use-tools'
import { useAppWorkflow } from '@/service/use-workflow'
import { AppModeEnum } from '@/types/app'
const BASIC_APP_CONFIG_KEY = 'basicAppConfig'
type AppInfo = AppDetailResponse & Partial<AppSSO>
type BasicAppConfig = {
updated_at?: string
user_input_form?: Array<Record<string, unknown>>
}
export const useMCPServiceCardState = (
appInfo: AppInfo,
triggerModeDisabled: boolean,
) => {
const appId = appInfo.id
const queryClient = useQueryClient()
// API hooks
const { mutateAsync: updateMCPServer } = useUpdateMCPServer()
const { mutateAsync: refreshMCPServerCode, isPending: genLoading } = useRefreshMCPServerCode()
const invalidateMCPServerDetail = useInvalidateMCPServerDetail()
// Context
const { isCurrentWorkspaceManager, isCurrentWorkspaceEditor } = useAppContext()
// UI state
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
const [showMCPServerModal, setShowMCPServerModal] = useState(false)
// Derived app type values
const isAdvancedApp = appInfo?.mode === AppModeEnum.ADVANCED_CHAT || appInfo?.mode === AppModeEnum.WORKFLOW
const isBasicApp = !isAdvancedApp
const isWorkflowApp = appInfo.mode === AppModeEnum.WORKFLOW
// Workflow data for advanced apps
const { data: currentWorkflow } = useAppWorkflow(isAdvancedApp ? appId : '')
// Basic app config fetch using React Query
const { data: basicAppConfig = {} } = useQuery<BasicAppConfig>({
queryKey: [BASIC_APP_CONFIG_KEY, appId],
queryFn: async () => {
const res = await fetchAppDetail({ url: '/apps', id: appId })
return (res?.model_config as BasicAppConfig) || {}
},
enabled: isBasicApp && !!appId,
})
// MCP server detail
const { data: detail } = useMCPServerDetail(appId)
const { id, status, server_code } = detail ?? {}
// Server state
const serverPublished = !!id
const serverActivated = status === 'active'
const serverURL = serverPublished
? `${appInfo.api_base_url.replace('/v1', '')}/mcp/server/${server_code}/mcp`
: '***********'
// App state checks
const appUnpublished = isAdvancedApp ? !currentWorkflow?.graph : !basicAppConfig.updated_at
const hasStartNode = currentWorkflow?.graph?.nodes?.some(node => node.data.type === BlockEnum.Start)
const missingStartNode = isWorkflowApp && !hasStartNode
const hasInsufficientPermissions = !isCurrentWorkspaceEditor
const toggleDisabled = hasInsufficientPermissions || appUnpublished || missingStartNode || triggerModeDisabled
const isMinimalState = appUnpublished || missingStartNode
// Basic app input form
const basicAppInputForm = useMemo(() => {
if (!isBasicApp || !basicAppConfig?.user_input_form)
return []
return (basicAppConfig.user_input_form as Array<Record<string, unknown>>).map((item) => {
const type = Object.keys(item)[0]
return {
...(item[type] as object),
type: type || 'text-input',
}
})
}, [basicAppConfig?.user_input_form, isBasicApp])
// Latest params for modal
const latestParams = useMemo(() => {
if (isAdvancedApp) {
if (!currentWorkflow?.graph)
return []
type StartNodeData = { type: string, variables?: Array<{ variable: string, label: string }> }
const startNode = currentWorkflow?.graph.nodes.find(node => node.data.type === BlockEnum.Start) as { data: StartNodeData } | undefined
return startNode?.data.variables || []
}
return basicAppInputForm
}, [currentWorkflow, basicAppInputForm, isAdvancedApp])
// Handlers
const handleGenCode = useCallback(async () => {
await refreshMCPServerCode(detail?.id || '')
invalidateMCPServerDetail(appId)
}, [refreshMCPServerCode, detail?.id, invalidateMCPServerDetail, appId])
const handleStatusChange = useCallback(async (state: boolean) => {
if (state && !serverPublished) {
setShowMCPServerModal(true)
return { activated: false }
}
await updateMCPServer({
appID: appId,
id: id || '',
description: detail?.description || '',
parameters: detail?.parameters || {},
status: state ? 'active' : 'inactive',
})
invalidateMCPServerDetail(appId)
return { activated: state }
}, [serverPublished, updateMCPServer, appId, id, detail, invalidateMCPServerDetail])
const handleServerModalHide = useCallback((wasActivated: boolean) => {
setShowMCPServerModal(false)
// If server wasn't activated before opening modal, keep it deactivated
return { shouldDeactivate: !wasActivated }
}, [])
const openConfirmDelete = useCallback(() => setShowConfirmDelete(true), [])
const closeConfirmDelete = useCallback(() => setShowConfirmDelete(false), [])
const openServerModal = useCallback(() => setShowMCPServerModal(true), [])
const invalidateBasicAppConfig = useCallback(() => {
queryClient.invalidateQueries({ queryKey: [BASIC_APP_CONFIG_KEY, appId] })
}, [queryClient, appId])
return {
// Loading states
genLoading,
isLoading: isAdvancedApp ? !currentWorkflow : false,
// Server state
serverPublished,
serverActivated,
serverURL,
detail,
// Permission & validation flags
isCurrentWorkspaceManager,
toggleDisabled,
isMinimalState,
appUnpublished,
missingStartNode,
// UI state
showConfirmDelete,
showMCPServerModal,
// Data
latestParams,
// Handlers
handleGenCode,
handleStatusChange,
handleServerModalHide,
openConfirmDelete,
closeConfirmDelete,
openServerModal,
invalidateBasicAppConfig,
}
}

View File

@ -1,361 +0,0 @@
import type { ReactNode } from 'react'
import type { MCPServerDetail } from '@/app/components/tools/types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { describe, expect, it, vi } from 'vitest'
import MCPServerModal from './mcp-server-modal'
// Mock the services
vi.mock('@/service/use-tools', () => ({
useCreateMCPServer: () => ({
mutateAsync: vi.fn().mockResolvedValue({ result: 'success' }),
isPending: false,
}),
useUpdateMCPServer: () => ({
mutateAsync: vi.fn().mockResolvedValue({ result: 'success' }),
isPending: false,
}),
useInvalidateMCPServerDetail: () => vi.fn(),
}))
describe('MCPServerModal', () => {
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children)
}
const defaultProps = {
appID: 'app-123',
show: true,
onHide: vi.fn(),
}
describe('Rendering', () => {
it('should render without crashing', () => {
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.server.modal.addTitle')).toBeInTheDocument()
})
it('should render add title when no data is provided', () => {
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.server.modal.addTitle')).toBeInTheDocument()
})
it('should render edit title when data is provided', () => {
const mockData = {
id: 'server-1',
description: 'Existing description',
parameters: {},
} as unknown as MCPServerDetail
render(<MCPServerModal {...defaultProps} data={mockData} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.server.modal.editTitle')).toBeInTheDocument()
})
it('should render description label', () => {
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.server.modal.description')).toBeInTheDocument()
})
it('should render required indicator', () => {
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('*')).toBeInTheDocument()
})
it('should render description textarea', () => {
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder')
expect(textarea).toBeInTheDocument()
})
it('should render cancel button', () => {
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.modal.cancel')).toBeInTheDocument()
})
it('should render confirm button in add mode', () => {
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.server.modal.confirm')).toBeInTheDocument()
})
it('should render save button in edit mode', () => {
const mockData = {
id: 'server-1',
description: 'Existing description',
parameters: {},
} as unknown as MCPServerDetail
render(<MCPServerModal {...defaultProps} data={mockData} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.modal.save')).toBeInTheDocument()
})
it('should render close icon', () => {
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
const closeButton = document.querySelector('.cursor-pointer svg')
expect(closeButton).toBeInTheDocument()
})
})
describe('Parameters Section', () => {
it('should not render parameters section when no latestParams', () => {
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.queryByText('tools.mcp.server.modal.parameters')).not.toBeInTheDocument()
})
it('should render parameters section when latestParams is provided', () => {
const latestParams = [
{ variable: 'param1', label: 'Parameter 1', type: 'string' },
]
render(<MCPServerModal {...defaultProps} latestParams={latestParams} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.server.modal.parameters')).toBeInTheDocument()
})
it('should render parameters tip', () => {
const latestParams = [
{ variable: 'param1', label: 'Parameter 1', type: 'string' },
]
render(<MCPServerModal {...defaultProps} latestParams={latestParams} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.server.modal.parametersTip')).toBeInTheDocument()
})
it('should render parameter items', () => {
const latestParams = [
{ variable: 'param1', label: 'Parameter 1', type: 'string' },
{ variable: 'param2', label: 'Parameter 2', type: 'number' },
]
render(<MCPServerModal {...defaultProps} latestParams={latestParams} />, { wrapper: createWrapper() })
expect(screen.getByText('Parameter 1')).toBeInTheDocument()
expect(screen.getByText('Parameter 2')).toBeInTheDocument()
})
})
describe('Form Interactions', () => {
it('should update description when typing', () => {
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder')
fireEvent.change(textarea, { target: { value: 'New description' } })
expect(textarea).toHaveValue('New description')
})
it('should call onHide when cancel button is clicked', () => {
const onHide = vi.fn()
render(<MCPServerModal {...defaultProps} onHide={onHide} />, { wrapper: createWrapper() })
const cancelButton = screen.getByText('tools.mcp.modal.cancel')
fireEvent.click(cancelButton)
expect(onHide).toHaveBeenCalledTimes(1)
})
it('should call onHide when close icon is clicked', () => {
const onHide = vi.fn()
render(<MCPServerModal {...defaultProps} onHide={onHide} />, { wrapper: createWrapper() })
const closeButton = document.querySelector('.cursor-pointer')
if (closeButton) {
fireEvent.click(closeButton)
expect(onHide).toHaveBeenCalled()
}
})
it('should disable confirm button when description is empty', () => {
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
const confirmButton = screen.getByText('tools.mcp.server.modal.confirm')
expect(confirmButton).toBeDisabled()
})
it('should enable confirm button when description is filled', () => {
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder')
fireEvent.change(textarea, { target: { value: 'Valid description' } })
const confirmButton = screen.getByText('tools.mcp.server.modal.confirm')
expect(confirmButton).not.toBeDisabled()
})
})
describe('Edit Mode', () => {
const mockData = {
id: 'server-1',
description: 'Existing description',
parameters: { param1: 'existing value' },
} as unknown as MCPServerDetail
it('should populate description with existing value', () => {
render(<MCPServerModal {...defaultProps} data={mockData} />, { wrapper: createWrapper() })
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder')
expect(textarea).toHaveValue('Existing description')
})
it('should populate parameters with existing values', () => {
const latestParams = [
{ variable: 'param1', label: 'Parameter 1', type: 'string' },
]
render(
<MCPServerModal {...defaultProps} data={mockData} latestParams={latestParams} />,
{ wrapper: createWrapper() },
)
const paramInput = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
expect(paramInput).toHaveValue('existing value')
})
})
describe('Form Submission', () => {
it('should submit form with description', async () => {
const onHide = vi.fn()
render(<MCPServerModal {...defaultProps} onHide={onHide} />, { wrapper: createWrapper() })
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder')
fireEvent.change(textarea, { target: { value: 'Test description' } })
const confirmButton = screen.getByText('tools.mcp.server.modal.confirm')
fireEvent.click(confirmButton)
await waitFor(() => {
expect(onHide).toHaveBeenCalled()
})
})
})
describe('With App Info', () => {
it('should use appInfo description as default when no data', () => {
const appInfo = { description: 'App default description' }
render(<MCPServerModal {...defaultProps} appInfo={appInfo} />, { wrapper: createWrapper() })
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder')
expect(textarea).toHaveValue('App default description')
})
it('should prefer data description over appInfo description', () => {
const appInfo = { description: 'App default description' }
const mockData = {
id: 'server-1',
description: 'Data description',
parameters: {},
} as unknown as MCPServerDetail
render(
<MCPServerModal {...defaultProps} data={mockData} appInfo={appInfo} />,
{ wrapper: createWrapper() },
)
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder')
expect(textarea).toHaveValue('Data description')
})
})
describe('Not Shown State', () => {
it('should not render modal content when show is false', () => {
render(<MCPServerModal {...defaultProps} show={false} />, { wrapper: createWrapper() })
expect(screen.queryByText('tools.mcp.server.modal.addTitle')).not.toBeInTheDocument()
})
})
describe('Update Mode Submission', () => {
it('should submit update when data is provided', async () => {
const onHide = vi.fn()
const mockData = {
id: 'server-1',
description: 'Existing description',
parameters: { param1: 'value1' },
} as unknown as MCPServerDetail
render(
<MCPServerModal {...defaultProps} data={mockData} onHide={onHide} />,
{ wrapper: createWrapper() },
)
// Change description
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder')
fireEvent.change(textarea, { target: { value: 'Updated description' } })
// Click save button
const saveButton = screen.getByText('tools.mcp.modal.save')
fireEvent.click(saveButton)
await waitFor(() => {
expect(onHide).toHaveBeenCalled()
})
})
})
describe('Parameter Handling', () => {
it('should update parameter value when changed', async () => {
const latestParams = [
{ variable: 'param1', label: 'Parameter 1', type: 'string' },
{ variable: 'param2', label: 'Parameter 2', type: 'string' },
]
render(
<MCPServerModal {...defaultProps} latestParams={latestParams} />,
{ wrapper: createWrapper() },
)
// Fill description first
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder')
fireEvent.change(textarea, { target: { value: 'Test description' } })
// Get all parameter inputs
const paramInputs = screen.getAllByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
// Change the first parameter value
fireEvent.change(paramInputs[0], { target: { value: 'new param value' } })
expect(paramInputs[0]).toHaveValue('new param value')
})
it('should submit with parameter values', async () => {
const onHide = vi.fn()
const latestParams = [
{ variable: 'param1', label: 'Parameter 1', type: 'string' },
]
render(
<MCPServerModal {...defaultProps} latestParams={latestParams} onHide={onHide} />,
{ wrapper: createWrapper() },
)
// Fill description
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder')
fireEvent.change(textarea, { target: { value: 'Test description' } })
// Fill parameter
const paramInput = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
fireEvent.change(paramInput, { target: { value: 'param value' } })
// Submit
const confirmButton = screen.getByText('tools.mcp.server.modal.confirm')
fireEvent.click(confirmButton)
await waitFor(() => {
expect(onHide).toHaveBeenCalled()
})
})
it('should handle empty description submission', async () => {
const onHide = vi.fn()
render(<MCPServerModal {...defaultProps} onHide={onHide} />, { wrapper: createWrapper() })
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder')
fireEvent.change(textarea, { target: { value: '' } })
// Button should be disabled
const confirmButton = screen.getByText('tools.mcp.server.modal.confirm')
expect(confirmButton).toBeDisabled()
})
})
})

View File

@ -1,165 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import MCPServerParamItem from './mcp-server-param-item'
describe('MCPServerParamItem', () => {
const defaultProps = {
data: {
label: 'Test Label',
variable: 'test_variable',
type: 'string',
},
value: '',
onChange: vi.fn(),
}
describe('Rendering', () => {
it('should render without crashing', () => {
render(<MCPServerParamItem {...defaultProps} />)
expect(screen.getByText('Test Label')).toBeInTheDocument()
})
it('should display label', () => {
render(<MCPServerParamItem {...defaultProps} />)
expect(screen.getByText('Test Label')).toBeInTheDocument()
})
it('should display variable name', () => {
render(<MCPServerParamItem {...defaultProps} />)
expect(screen.getByText('test_variable')).toBeInTheDocument()
})
it('should display type', () => {
render(<MCPServerParamItem {...defaultProps} />)
expect(screen.getByText('string')).toBeInTheDocument()
})
it('should display separator dot', () => {
render(<MCPServerParamItem {...defaultProps} />)
expect(screen.getByText('·')).toBeInTheDocument()
})
it('should render textarea with placeholder', () => {
render(<MCPServerParamItem {...defaultProps} />)
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
expect(textarea).toBeInTheDocument()
})
})
describe('Value Display', () => {
it('should display empty value by default', () => {
render(<MCPServerParamItem {...defaultProps} />)
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
expect(textarea).toHaveValue('')
})
it('should display provided value', () => {
render(<MCPServerParamItem {...defaultProps} value="test value" />)
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
expect(textarea).toHaveValue('test value')
})
it('should display long text value', () => {
const longValue = 'This is a very long text value that might span multiple lines'
render(<MCPServerParamItem {...defaultProps} value={longValue} />)
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
expect(textarea).toHaveValue(longValue)
})
})
describe('User Interactions', () => {
it('should call onChange when text is entered', () => {
const onChange = vi.fn()
render(<MCPServerParamItem {...defaultProps} onChange={onChange} />)
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
fireEvent.change(textarea, { target: { value: 'new value' } })
expect(onChange).toHaveBeenCalledWith('new value')
})
it('should call onChange with empty string when cleared', () => {
const onChange = vi.fn()
render(<MCPServerParamItem {...defaultProps} value="existing" onChange={onChange} />)
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
fireEvent.change(textarea, { target: { value: '' } })
expect(onChange).toHaveBeenCalledWith('')
})
it('should handle multiple changes', () => {
const onChange = vi.fn()
render(<MCPServerParamItem {...defaultProps} onChange={onChange} />)
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
fireEvent.change(textarea, { target: { value: 'first' } })
fireEvent.change(textarea, { target: { value: 'second' } })
fireEvent.change(textarea, { target: { value: 'third' } })
expect(onChange).toHaveBeenCalledTimes(3)
expect(onChange).toHaveBeenLastCalledWith('third')
})
})
describe('Different Data Types', () => {
it('should display number type', () => {
const props = {
...defaultProps,
data: { label: 'Count', variable: 'count', type: 'number' },
}
render(<MCPServerParamItem {...props} />)
expect(screen.getByText('number')).toBeInTheDocument()
})
it('should display boolean type', () => {
const props = {
...defaultProps,
data: { label: 'Enabled', variable: 'enabled', type: 'boolean' },
}
render(<MCPServerParamItem {...props} />)
expect(screen.getByText('boolean')).toBeInTheDocument()
})
it('should display array type', () => {
const props = {
...defaultProps,
data: { label: 'Items', variable: 'items', type: 'array' },
}
render(<MCPServerParamItem {...props} />)
expect(screen.getByText('array')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle special characters in label', () => {
const props = {
...defaultProps,
data: { label: 'Test <Label> & "Special"', variable: 'test', type: 'string' },
}
render(<MCPServerParamItem {...props} />)
expect(screen.getByText('Test <Label> & "Special"')).toBeInTheDocument()
})
it('should handle empty data object properties', () => {
const props = {
...defaultProps,
data: { label: '', variable: '', type: '' },
}
render(<MCPServerParamItem {...props} />)
// Should render without crashing
expect(screen.getByText('·')).toBeInTheDocument()
})
it('should handle unicode characters in value', () => {
const onChange = vi.fn()
render(<MCPServerParamItem {...defaultProps} onChange={onChange} />)
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
fireEvent.change(textarea, { target: { value: '你好世界 🌍' } })
expect(onChange).toHaveBeenCalledWith('你好世界 🌍')
})
})
})

File diff suppressed because it is too large Load Diff

View File

@ -1,234 +1,168 @@
'use client'
import type { TFunction } from 'i18next'
import type { FC, ReactNode } from 'react'
import type { AppDetailResponse } from '@/models/app'
import type { AppSSO } from '@/types/app'
import { RiEditLine, RiLoopLeftLine } from '@remixicon/react'
import { useState } from 'react'
import * as React from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Confirm from '@/app/components/base/confirm'
import CopyFeedback from '@/app/components/base/copy-feedback'
import Divider from '@/app/components/base/divider'
import { Mcp } from '@/app/components/base/icons/src/vender/other'
import {
Mcp,
} from '@/app/components/base/icons/src/vender/other'
import Switch from '@/app/components/base/switch'
import Tooltip from '@/app/components/base/tooltip'
import Indicator from '@/app/components/header/indicator'
import MCPServerModal from '@/app/components/tools/mcp/mcp-server-modal'
import { BlockEnum } from '@/app/components/workflow/types'
import { useAppContext } from '@/context/app-context'
import { useDocLink } from '@/context/i18n'
import { fetchAppDetail } from '@/service/apps'
import {
useInvalidateMCPServerDetail,
useMCPServerDetail,
useRefreshMCPServerCode,
useUpdateMCPServer,
} from '@/service/use-tools'
import { useAppWorkflow } from '@/service/use-workflow'
import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames'
import { useMCPServiceCardState } from './hooks/use-mcp-service-card'
// Sub-components
type StatusIndicatorProps = {
serverActivated: boolean
}
const StatusIndicator: FC<StatusIndicatorProps> = ({ serverActivated }) => {
const { t } = useTranslation()
return (
<div className="flex items-center gap-1">
<Indicator color={serverActivated ? 'green' : 'yellow'} />
<div className={cn('system-xs-semibold-uppercase', serverActivated ? 'text-text-success' : 'text-text-warning')}>
{serverActivated
? t('overview.status.running', { ns: 'appOverview' })
: t('overview.status.disable', { ns: 'appOverview' })}
</div>
</div>
)
}
type ServerURLSectionProps = {
serverURL: string
serverPublished: boolean
isCurrentWorkspaceManager: boolean
genLoading: boolean
onRegenerate: () => void
}
const ServerURLSection: FC<ServerURLSectionProps> = ({
serverURL,
serverPublished,
isCurrentWorkspaceManager,
genLoading,
onRegenerate,
}) => {
const { t } = useTranslation()
return (
<div className="flex flex-col items-start justify-center self-stretch">
<div className="system-xs-medium pb-1 text-text-tertiary">
{t('mcp.server.url', { ns: 'tools' })}
</div>
<div className="inline-flex h-9 w-full items-center gap-0.5 rounded-lg bg-components-input-bg-normal p-1 pl-2">
<div className="flex h-4 min-w-0 flex-1 items-start justify-start gap-2 px-1">
<div className="overflow-hidden text-ellipsis whitespace-nowrap text-xs font-medium text-text-secondary">
{serverURL}
</div>
</div>
{serverPublished && (
<>
<CopyFeedback content={serverURL} className="!size-6" />
<Divider type="vertical" className="!mx-0.5 !h-3.5 shrink-0" />
{isCurrentWorkspaceManager && (
<Tooltip popupContent={t('overview.appInfo.regenerate', { ns: 'appOverview' }) || ''}>
<div
className="cursor-pointer rounded-md p-1 hover:bg-state-base-hover"
onClick={onRegenerate}
>
<RiLoopLeftLine className={cn('h-4 w-4 text-text-tertiary hover:text-text-secondary', genLoading && 'animate-spin')} />
</div>
</Tooltip>
)}
</>
)}
</div>
</div>
)
}
type TriggerModeOverlayProps = {
triggerModeMessage: ReactNode
}
const TriggerModeOverlay: FC<TriggerModeOverlayProps> = ({ triggerModeMessage }) => {
if (triggerModeMessage) {
return (
<Tooltip
popupContent={triggerModeMessage}
popupClassName="max-w-64 rounded-xl bg-components-panel-bg px-3 py-2 text-xs text-text-secondary shadow-lg"
position="right"
>
<div className="absolute inset-0 z-10 cursor-not-allowed rounded-xl" aria-hidden="true"></div>
</Tooltip>
)
}
return <div className="absolute inset-0 z-10 cursor-not-allowed rounded-xl" aria-hidden="true"></div>
}
// Helper function for tooltip content
type TooltipContentParams = {
toggleDisabled: boolean
appUnpublished: boolean
missingStartNode: boolean
triggerModeMessage: ReactNode
t: TFunction
docLink: ReturnType<typeof useDocLink>
}
function getTooltipContent({
toggleDisabled,
appUnpublished,
missingStartNode,
triggerModeMessage,
t,
docLink,
}: TooltipContentParams): ReactNode {
if (!toggleDisabled)
return ''
if (appUnpublished)
return t('mcp.server.publishTip', { ns: 'tools' })
if (missingStartNode) {
return (
<>
<div className="mb-1 text-xs font-normal text-text-secondary">
{t('overview.appInfo.enableTooltip.description', { ns: 'appOverview' })}
</div>
<div
className="cursor-pointer text-xs font-normal text-text-accent hover:underline"
onClick={() => window.open(docLink('/use-dify/nodes/user-input'), '_blank')}
>
{t('overview.appInfo.enableTooltip.learnMore', { ns: 'appOverview' })}
</div>
</>
)
}
return triggerModeMessage || ''
}
// Main component
export type IAppCardProps = {
appInfo: AppDetailResponse & Partial<AppSSO>
triggerModeDisabled?: boolean
triggerModeMessage?: ReactNode
triggerModeDisabled?: boolean // align with Trigger Node vs User Input exclusivity
triggerModeMessage?: React.ReactNode // display-only message explaining the trigger restriction
}
const MCPServiceCard: FC<IAppCardProps> = ({
function MCPServiceCard({
appInfo,
triggerModeDisabled = false,
triggerModeMessage = '',
}) => {
}: IAppCardProps) {
const { t } = useTranslation()
const docLink = useDocLink()
const appId = appInfo.id
const { mutateAsync: updateMCPServer } = useUpdateMCPServer()
const { mutateAsync: refreshMCPServerCode, isPending: genLoading } = useRefreshMCPServerCode()
const invalidateMCPServerDetail = useInvalidateMCPServerDetail()
const { isCurrentWorkspaceManager, isCurrentWorkspaceEditor } = useAppContext()
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
const [showMCPServerModal, setShowMCPServerModal] = useState(false)
const {
genLoading,
isLoading,
serverPublished,
serverActivated,
serverURL,
detail,
isCurrentWorkspaceManager,
toggleDisabled,
isMinimalState,
appUnpublished,
missingStartNode,
showConfirmDelete,
showMCPServerModal,
latestParams,
handleGenCode,
handleStatusChange,
handleServerModalHide,
openConfirmDelete,
closeConfirmDelete,
openServerModal,
} = useMCPServiceCardState(appInfo, triggerModeDisabled)
const isAdvancedApp = appInfo?.mode === AppModeEnum.ADVANCED_CHAT || appInfo?.mode === AppModeEnum.WORKFLOW
const isBasicApp = !isAdvancedApp
const { data: currentWorkflow } = useAppWorkflow(isAdvancedApp ? appId : '')
const [basicAppConfig, setBasicAppConfig] = useState<any>({})
const basicAppInputForm = useMemo(() => {
if (!isBasicApp || !basicAppConfig?.user_input_form)
return []
return basicAppConfig.user_input_form.map((item: any) => {
const type = Object.keys(item)[0]
return {
...item[type],
type: type || 'text-input',
}
})
}, [basicAppConfig.user_input_form, isBasicApp])
useEffect(() => {
if (isBasicApp && appId) {
(async () => {
const res = await fetchAppDetail({ url: '/apps', id: appId })
setBasicAppConfig(res?.model_config || {})
})()
}
}, [appId, isBasicApp])
const { data: detail } = useMCPServerDetail(appId)
const { id, status, server_code } = detail ?? {}
// Pending status for optimistic updates (null means use server state)
const [pendingStatus, setPendingStatus] = useState<boolean | null>(null)
const activated = pendingStatus ?? serverActivated
const isWorkflowApp = appInfo.mode === AppModeEnum.WORKFLOW
const appUnpublished = isAdvancedApp ? !currentWorkflow?.graph : !basicAppConfig.updated_at
const serverPublished = !!id
const serverActivated = status === 'active'
const serverURL = serverPublished ? `${appInfo.api_base_url.replace('/v1', '')}/mcp/server/${server_code}/mcp` : '***********'
const hasStartNode = currentWorkflow?.graph?.nodes?.some(node => node.data.type === BlockEnum.Start)
const missingStartNode = isWorkflowApp && !hasStartNode
const hasInsufficientPermissions = !isCurrentWorkspaceEditor
const toggleDisabled = hasInsufficientPermissions || appUnpublished || missingStartNode || triggerModeDisabled
const isMinimalState = appUnpublished || missingStartNode
const [activated, setActivated] = useState(serverActivated)
const latestParams = useMemo(() => {
if (isAdvancedApp) {
if (!currentWorkflow?.graph)
return []
const startNode = currentWorkflow?.graph.nodes.find(node => node.data.type === BlockEnum.Start) as any
return startNode?.data.variables as any[] || []
}
return basicAppInputForm
}, [currentWorkflow, basicAppInputForm, isAdvancedApp])
const onGenCode = async () => {
await refreshMCPServerCode(detail?.id || '')
invalidateMCPServerDetail(appId)
}
const onChangeStatus = async (state: boolean) => {
setPendingStatus(state)
const result = await handleStatusChange(state)
if (!result.activated && state) {
// Server modal was opened instead, clear pending status
setPendingStatus(null)
setActivated(state)
if (state) {
if (!serverPublished) {
setShowMCPServerModal(true)
return
}
await updateMCPServer({
appID: appId,
id: id || '',
description: detail?.description || '',
parameters: detail?.parameters || {},
status: 'active',
})
invalidateMCPServerDetail(appId)
}
else {
await updateMCPServer({
appID: appId,
id: id || '',
description: detail?.description || '',
parameters: detail?.parameters || {},
status: 'inactive',
})
invalidateMCPServerDetail(appId)
}
}
const onServerModalHide = () => {
handleServerModalHide(serverActivated)
// Clear pending status when modal closes to sync with server state
setPendingStatus(null)
const handleServerModalHide = () => {
setShowMCPServerModal(false)
if (!serverActivated)
setActivated(false)
}
const onConfirmRegenerate = () => {
handleGenCode()
closeConfirmDelete()
}
useEffect(() => {
setActivated(serverActivated)
}, [serverActivated])
if (isLoading)
if (!currentWorkflow && isAdvancedApp)
return null
const tooltipContent = getTooltipContent({
toggleDisabled,
appUnpublished,
missingStartNode,
triggerModeMessage,
t,
docLink,
})
return (
<>
<div className={cn('w-full max-w-full rounded-xl border-l-[0.5px] border-t border-effects-highlight', isMinimalState && 'h-12')}>
<div className={cn('relative rounded-xl bg-background-default', triggerModeDisabled && 'opacity-60')}>
{triggerModeDisabled && (
<TriggerModeOverlay triggerModeMessage={triggerModeMessage} />
triggerModeMessage
? (
<Tooltip
popupContent={triggerModeMessage}
popupClassName="max-w-64 rounded-xl bg-components-panel-bg px-3 py-2 text-xs text-text-secondary shadow-lg"
position="right"
>
<div className="absolute inset-0 z-10 cursor-not-allowed rounded-xl" aria-hidden="true"></div>
</Tooltip>
)
: <div className="absolute inset-0 z-10 cursor-not-allowed rounded-xl" aria-hidden="true"></div>
)}
<div className={cn('flex w-full flex-col items-start justify-center gap-3 self-stretch p-3', isMinimalState ? 'border-0' : 'border-b-[0.5px] border-divider-subtle')}>
<div className="flex w-full items-center gap-3 self-stretch">
@ -242,9 +176,40 @@ const MCPServiceCard: FC<IAppCardProps> = ({
</div>
</div>
</div>
<StatusIndicator serverActivated={serverActivated} />
<div className="flex items-center gap-1">
<Indicator color={serverActivated ? 'green' : 'yellow'} />
<div className={`${serverActivated ? 'text-text-success' : 'text-text-warning'} system-xs-semibold-uppercase`}>
{serverActivated
? t('overview.status.running', { ns: 'appOverview' })
: t('overview.status.disable', { ns: 'appOverview' })}
</div>
</div>
<Tooltip
popupContent={tooltipContent}
popupContent={
toggleDisabled
? (
appUnpublished
? (
t('mcp.server.publishTip', { ns: 'tools' })
)
: missingStartNode
? (
<>
<div className="mb-1 text-xs font-normal text-text-secondary">
{t('overview.appInfo.enableTooltip.description', { ns: 'appOverview' })}
</div>
<div
className="cursor-pointer text-xs font-normal text-text-accent hover:underline"
onClick={() => window.open(docLink('/use-dify/nodes/user-input'), '_blank')}
>
{t('overview.appInfo.enableTooltip.learnMore', { ns: 'appOverview' })}
</div>
</>
)
: triggerModeMessage || ''
)
: ''
}
position="right"
popupClassName="w-58 max-w-60 rounded-xl bg-components-panel-bg px-3.5 py-3 shadow-lg"
offset={24}
@ -255,13 +220,39 @@ const MCPServiceCard: FC<IAppCardProps> = ({
</Tooltip>
</div>
{!isMinimalState && (
<ServerURLSection
serverURL={serverURL}
serverPublished={serverPublished}
isCurrentWorkspaceManager={isCurrentWorkspaceManager}
genLoading={genLoading}
onRegenerate={openConfirmDelete}
/>
<div className="flex flex-col items-start justify-center self-stretch">
<div className="system-xs-medium pb-1 text-text-tertiary">
{t('mcp.server.url', { ns: 'tools' })}
</div>
<div className="inline-flex h-9 w-full items-center gap-0.5 rounded-lg bg-components-input-bg-normal p-1 pl-2">
<div className="flex h-4 min-w-0 flex-1 items-start justify-start gap-2 px-1">
<div className="overflow-hidden text-ellipsis whitespace-nowrap text-xs font-medium text-text-secondary">
{serverURL}
</div>
</div>
{serverPublished && (
<>
<CopyFeedback
content={serverURL}
className="!size-6"
/>
<Divider type="vertical" className="!mx-0.5 !h-3.5 shrink-0" />
{isCurrentWorkspaceManager && (
<Tooltip
popupContent={t('overview.appInfo.regenerate', { ns: 'appOverview' }) || ''}
>
<div
className="cursor-pointer rounded-md p-1 hover:bg-state-base-hover"
onClick={() => setShowConfirmDelete(true)}
>
<RiLoopLeftLine className={cn('h-4 w-4 text-text-tertiary hover:text-text-secondary', genLoading && 'animate-spin')} />
</div>
</Tooltip>
)}
</>
)}
</div>
</div>
)}
</div>
{!isMinimalState && (
@ -270,39 +261,40 @@ const MCPServiceCard: FC<IAppCardProps> = ({
disabled={toggleDisabled}
size="small"
variant="ghost"
onClick={openServerModal}
onClick={() => setShowMCPServerModal(true)}
>
<div className="flex items-center justify-center gap-[1px]">
<RiEditLine className="h-3.5 w-3.5" />
<div className="system-xs-medium px-[3px] text-text-tertiary">
{serverPublished ? t('mcp.server.edit', { ns: 'tools' }) : t('mcp.server.addDescription', { ns: 'tools' })}
</div>
<div className="system-xs-medium px-[3px] text-text-tertiary">{serverPublished ? t('mcp.server.edit', { ns: 'tools' }) : t('mcp.server.addDescription', { ns: 'tools' })}</div>
</div>
</Button>
</div>
)}
</div>
</div>
{showMCPServerModal && (
<MCPServerModal
show={showMCPServerModal}
appID={appId}
data={serverPublished ? detail : undefined}
latestParams={latestParams}
onHide={onServerModalHide}
onHide={handleServerModalHide}
appInfo={appInfo}
/>
)}
{/* button copy link/ button regenerate */}
{showConfirmDelete && (
<Confirm
type="warning"
title={t('overview.appInfo.regenerate', { ns: 'appOverview' })}
content={t('mcp.server.reGen', { ns: 'tools' })}
isShow={showConfirmDelete}
onConfirm={onConfirmRegenerate}
onCancel={closeConfirmDelete}
onConfirm={() => {
onGenCode()
setShowConfirmDelete(false)
}}
onCancel={() => setShowConfirmDelete(false)}
/>
)}
</>

View File

@ -1,745 +0,0 @@
import type { ReactNode } from 'react'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { describe, expect, it, vi } from 'vitest'
import MCPModal from './modal'
// Mock the service API
vi.mock('@/service/common', () => ({
uploadRemoteFileInfo: vi.fn().mockResolvedValue({ url: 'https://example.com/icon.png' }),
}))
// Mock the AppIconPicker component
type IconPayload = {
type: string
icon: string
background: string
}
type AppIconPickerProps = {
onSelect: (payload: IconPayload) => void
onClose: () => void
}
vi.mock('@/app/components/base/app-icon-picker', () => ({
default: ({ onSelect, onClose }: AppIconPickerProps) => (
<div data-testid="app-icon-picker">
<button data-testid="select-emoji-btn" onClick={() => onSelect({ type: 'emoji', icon: '🎉', background: '#FF0000' })}>
Select Emoji
</button>
<button data-testid="close-picker-btn" onClick={onClose}>
Close Picker
</button>
</div>
),
}))
// Mock the plugins service to avoid React Query issues from TabSlider
vi.mock('@/service/use-plugins', () => ({
useInstalledPluginList: () => ({
data: { pages: [] },
hasNextPage: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
isLoading: false,
isSuccess: true,
}),
}))
describe('MCPModal', () => {
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children)
}
const defaultProps = {
show: true,
onConfirm: vi.fn(),
onHide: vi.fn(),
}
describe('Rendering', () => {
it('should render without crashing', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.modal.title')).toBeInTheDocument()
})
it('should not render when show is false', () => {
render(<MCPModal {...defaultProps} show={false} />, { wrapper: createWrapper() })
expect(screen.queryByText('tools.mcp.modal.title')).not.toBeInTheDocument()
})
it('should render create title when no data is provided', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.modal.title')).toBeInTheDocument()
})
it('should render edit title when data is provided', () => {
const mockData = {
id: 'test-id',
name: 'Test Server',
server_url: 'https://example.com/mcp',
server_identifier: 'test-server',
icon: { content: '🔗', background: '#6366F1' },
} as unknown as ToolWithProvider
render(<MCPModal {...defaultProps} data={mockData} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.modal.editTitle')).toBeInTheDocument()
})
})
describe('Form Fields', () => {
it('should render server URL input', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.modal.serverUrl')).toBeInTheDocument()
})
it('should render name input', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.modal.name')).toBeInTheDocument()
})
it('should render server identifier input', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.modal.serverIdentifier')).toBeInTheDocument()
})
it('should render auth method tabs', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.modal.authentication')).toBeInTheDocument()
expect(screen.getByText('tools.mcp.modal.headers')).toBeInTheDocument()
expect(screen.getByText('tools.mcp.modal.configurations')).toBeInTheDocument()
})
})
describe('Form Interactions', () => {
it('should update URL input value', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder')
fireEvent.change(urlInput, { target: { value: 'https://test.com/mcp' } })
expect(urlInput).toHaveValue('https://test.com/mcp')
})
it('should update name input value', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder')
fireEvent.change(nameInput, { target: { value: 'My Server' } })
expect(nameInput).toHaveValue('My Server')
})
it('should update server identifier input value', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
const identifierInput = screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder')
fireEvent.change(identifierInput, { target: { value: 'my-server' } })
expect(identifierInput).toHaveValue('my-server')
})
})
describe('Tab Navigation', () => {
it('should show authentication section by default', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.modal.useDynamicClientRegistration')).toBeInTheDocument()
})
it('should switch to headers section when clicked', async () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
const headersTab = screen.getByText('tools.mcp.modal.headers')
fireEvent.click(headersTab)
await waitFor(() => {
expect(screen.getByText('tools.mcp.modal.headersTip')).toBeInTheDocument()
})
})
it('should switch to configurations section when clicked', async () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
const configTab = screen.getByText('tools.mcp.modal.configurations')
fireEvent.click(configTab)
await waitFor(() => {
expect(screen.getByText('tools.mcp.modal.timeout')).toBeInTheDocument()
expect(screen.getByText('tools.mcp.modal.sseReadTimeout')).toBeInTheDocument()
})
})
})
describe('Action Buttons', () => {
it('should render confirm button', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.modal.confirm')).toBeInTheDocument()
})
it('should render save button in edit mode', () => {
const mockData = {
id: 'test-id',
name: 'Test',
icon: { content: '🔗', background: '#6366F1' },
} as unknown as ToolWithProvider
render(<MCPModal {...defaultProps} data={mockData} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.modal.save')).toBeInTheDocument()
})
it('should render cancel button', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.modal.cancel')).toBeInTheDocument()
})
it('should call onHide when cancel is clicked', () => {
const onHide = vi.fn()
render(<MCPModal {...defaultProps} onHide={onHide} />, { wrapper: createWrapper() })
const cancelButton = screen.getByText('tools.mcp.modal.cancel')
fireEvent.click(cancelButton)
expect(onHide).toHaveBeenCalledTimes(1)
})
it('should call onHide when close icon is clicked', () => {
const onHide = vi.fn()
render(<MCPModal {...defaultProps} onHide={onHide} />, { wrapper: createWrapper() })
// Find the close button by its parent div with cursor-pointer class
const closeButtons = document.querySelectorAll('.cursor-pointer')
const closeButton = Array.from(closeButtons).find(el =>
el.querySelector('svg'),
)
if (closeButton) {
fireEvent.click(closeButton)
expect(onHide).toHaveBeenCalled()
}
})
it('should have confirm button disabled when form is empty', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
const confirmButton = screen.getByText('tools.mcp.modal.confirm')
expect(confirmButton).toBeDisabled()
})
it('should enable confirm button when required fields are filled', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
// Fill required fields
const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder')
const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder')
const identifierInput = screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder')
fireEvent.change(urlInput, { target: { value: 'https://example.com/mcp' } })
fireEvent.change(nameInput, { target: { value: 'Test Server' } })
fireEvent.change(identifierInput, { target: { value: 'test-server' } })
const confirmButton = screen.getByText('tools.mcp.modal.confirm')
expect(confirmButton).not.toBeDisabled()
})
})
describe('Form Submission', () => {
it('should call onConfirm with correct data when form is submitted', async () => {
const onConfirm = vi.fn().mockResolvedValue(undefined)
render(<MCPModal {...defaultProps} onConfirm={onConfirm} />, { wrapper: createWrapper() })
// Fill required fields
const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder')
const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder')
const identifierInput = screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder')
fireEvent.change(urlInput, { target: { value: 'https://example.com/mcp' } })
fireEvent.change(nameInput, { target: { value: 'Test Server' } })
fireEvent.change(identifierInput, { target: { value: 'test-server' } })
const confirmButton = screen.getByText('tools.mcp.modal.confirm')
fireEvent.click(confirmButton)
await waitFor(() => {
expect(onConfirm).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Test Server',
server_url: 'https://example.com/mcp',
server_identifier: 'test-server',
}),
)
})
})
it('should not call onConfirm with invalid URL', async () => {
const onConfirm = vi.fn()
render(<MCPModal {...defaultProps} onConfirm={onConfirm} />, { wrapper: createWrapper() })
// Fill fields with invalid URL
const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder')
const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder')
const identifierInput = screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder')
fireEvent.change(urlInput, { target: { value: 'not-a-valid-url' } })
fireEvent.change(nameInput, { target: { value: 'Test Server' } })
fireEvent.change(identifierInput, { target: { value: 'test-server' } })
const confirmButton = screen.getByText('tools.mcp.modal.confirm')
fireEvent.click(confirmButton)
// Wait a bit and verify onConfirm was not called
await new Promise(resolve => setTimeout(resolve, 100))
expect(onConfirm).not.toHaveBeenCalled()
})
it('should not call onConfirm with invalid server identifier', async () => {
const onConfirm = vi.fn()
render(<MCPModal {...defaultProps} onConfirm={onConfirm} />, { wrapper: createWrapper() })
// Fill fields with invalid server identifier
const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder')
const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder')
const identifierInput = screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder')
fireEvent.change(urlInput, { target: { value: 'https://example.com/mcp' } })
fireEvent.change(nameInput, { target: { value: 'Test Server' } })
fireEvent.change(identifierInput, { target: { value: 'Invalid Server ID!' } })
const confirmButton = screen.getByText('tools.mcp.modal.confirm')
fireEvent.click(confirmButton)
// Wait a bit and verify onConfirm was not called
await new Promise(resolve => setTimeout(resolve, 100))
expect(onConfirm).not.toHaveBeenCalled()
})
})
describe('Edit Mode', () => {
const mockData = {
id: 'test-id',
name: 'Existing Server',
server_url: 'https://existing.com/mcp',
server_identifier: 'existing-server',
icon: { content: '🚀', background: '#FF0000' },
configuration: {
timeout: 60,
sse_read_timeout: 600,
},
masked_headers: {
Authorization: '***',
},
is_dynamic_registration: false,
authentication: {
client_id: 'client-123',
client_secret: 'secret-456',
},
} as unknown as ToolWithProvider
it('should populate form with existing data', () => {
render(<MCPModal {...defaultProps} data={mockData} />, { wrapper: createWrapper() })
expect(screen.getByDisplayValue('https://existing.com/mcp')).toBeInTheDocument()
expect(screen.getByDisplayValue('Existing Server')).toBeInTheDocument()
expect(screen.getByDisplayValue('existing-server')).toBeInTheDocument()
})
it('should show warning when URL is changed', () => {
render(<MCPModal {...defaultProps} data={mockData} />, { wrapper: createWrapper() })
const urlInput = screen.getByDisplayValue('https://existing.com/mcp')
fireEvent.change(urlInput, { target: { value: 'https://new.com/mcp' } })
expect(screen.getByText('tools.mcp.modal.serverUrlWarning')).toBeInTheDocument()
})
it('should show warning when server identifier is changed', () => {
render(<MCPModal {...defaultProps} data={mockData} />, { wrapper: createWrapper() })
const identifierInput = screen.getByDisplayValue('existing-server')
fireEvent.change(identifierInput, { target: { value: 'new-server' } })
expect(screen.getByText('tools.mcp.modal.serverIdentifierWarning')).toBeInTheDocument()
})
})
describe('Form Key Reset', () => {
it('should reset form when switching from create to edit mode', () => {
const { rerender } = render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
// Fill some data in create mode
const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder')
fireEvent.change(nameInput, { target: { value: 'New Server' } })
// Switch to edit mode with different data
const mockData = {
id: 'edit-id',
name: 'Edit Server',
icon: { content: '🔗', background: '#6366F1' },
} as unknown as ToolWithProvider
rerender(<MCPModal {...defaultProps} data={mockData} />)
// Should show edit mode data
expect(screen.getByDisplayValue('Edit Server')).toBeInTheDocument()
})
})
describe('URL Blur Handler', () => {
it('should trigger URL blur handler when URL input loses focus', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder')
fireEvent.change(urlInput, { target: { value: ' https://test.com/mcp ' } })
fireEvent.blur(urlInput)
// The blur handler trims the value
expect(urlInput).toHaveValue(' https://test.com/mcp ')
})
it('should handle URL blur with empty value', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder')
fireEvent.change(urlInput, { target: { value: '' } })
fireEvent.blur(urlInput)
expect(urlInput).toHaveValue('')
})
})
describe('App Icon', () => {
it('should render app icon with default emoji', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
// The app icon should be rendered
const appIcons = document.querySelectorAll('[class*="rounded-2xl"]')
expect(appIcons.length).toBeGreaterThan(0)
})
it('should render app icon in edit mode with custom icon', () => {
const mockData = {
id: 'test-id',
name: 'Test Server',
server_url: 'https://example.com/mcp',
server_identifier: 'test-server',
icon: { content: '🚀', background: '#FF0000' },
} as unknown as ToolWithProvider
render(<MCPModal {...defaultProps} data={mockData} />, { wrapper: createWrapper() })
// The app icon should be rendered
const appIcons = document.querySelectorAll('[class*="rounded-2xl"]')
expect(appIcons.length).toBeGreaterThan(0)
})
})
describe('Form Submission with Headers', () => {
it('should submit form with headers data', async () => {
const onConfirm = vi.fn().mockResolvedValue(undefined)
render(<MCPModal {...defaultProps} onConfirm={onConfirm} />, { wrapper: createWrapper() })
// Fill required fields
const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder')
const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder')
const identifierInput = screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder')
fireEvent.change(urlInput, { target: { value: 'https://example.com/mcp' } })
fireEvent.change(nameInput, { target: { value: 'Test Server' } })
fireEvent.change(identifierInput, { target: { value: 'test-server' } })
// Switch to headers tab and add a header
const headersTab = screen.getByText('tools.mcp.modal.headers')
fireEvent.click(headersTab)
await waitFor(() => {
expect(screen.getByText('tools.mcp.modal.headersTip')).toBeInTheDocument()
})
const confirmButton = screen.getByText('tools.mcp.modal.confirm')
fireEvent.click(confirmButton)
await waitFor(() => {
expect(onConfirm).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Test Server',
server_url: 'https://example.com/mcp',
server_identifier: 'test-server',
}),
)
})
})
it('should submit with authentication data', async () => {
const onConfirm = vi.fn().mockResolvedValue(undefined)
render(<MCPModal {...defaultProps} onConfirm={onConfirm} />, { wrapper: createWrapper() })
// Fill required fields
const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder')
const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder')
const identifierInput = screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder')
fireEvent.change(urlInput, { target: { value: 'https://example.com/mcp' } })
fireEvent.change(nameInput, { target: { value: 'Test Server' } })
fireEvent.change(identifierInput, { target: { value: 'test-server' } })
// Submit form
const confirmButton = screen.getByText('tools.mcp.modal.confirm')
fireEvent.click(confirmButton)
await waitFor(() => {
expect(onConfirm).toHaveBeenCalledWith(
expect.objectContaining({
authentication: expect.objectContaining({
client_id: '',
client_secret: '',
}),
}),
)
})
})
it('should format headers correctly when submitting with header keys', async () => {
const onConfirm = vi.fn().mockResolvedValue(undefined)
const mockData = {
id: 'test-id',
name: 'Test Server',
server_url: 'https://example.com/mcp',
server_identifier: 'test-server',
icon: { content: '🔗', background: '#6366F1' },
masked_headers: {
'Authorization': 'Bearer token',
'X-Custom': 'value',
},
} as unknown as ToolWithProvider
render(<MCPModal {...defaultProps} data={mockData} onConfirm={onConfirm} />, { wrapper: createWrapper() })
// Switch to headers tab
const headersTab = screen.getByText('tools.mcp.modal.headers')
fireEvent.click(headersTab)
await waitFor(() => {
expect(screen.getByText('tools.mcp.modal.headersTip')).toBeInTheDocument()
})
// Submit form
const saveButton = screen.getByText('tools.mcp.modal.save')
fireEvent.click(saveButton)
await waitFor(() => {
expect(onConfirm).toHaveBeenCalledWith(
expect.objectContaining({
headers: expect.objectContaining({
Authorization: expect.any(String),
}),
}),
)
})
})
})
describe('Edit Mode Submission', () => {
it('should send hidden URL when URL is unchanged in edit mode', async () => {
const onConfirm = vi.fn().mockResolvedValue(undefined)
const mockData = {
id: 'test-id',
name: 'Existing Server',
server_url: 'https://existing.com/mcp',
server_identifier: 'existing-server',
icon: { content: '🚀', background: '#FF0000' },
} as unknown as ToolWithProvider
render(<MCPModal {...defaultProps} data={mockData} onConfirm={onConfirm} />, { wrapper: createWrapper() })
// Don't change the URL, just submit
const saveButton = screen.getByText('tools.mcp.modal.save')
fireEvent.click(saveButton)
await waitFor(() => {
expect(onConfirm).toHaveBeenCalledWith(
expect.objectContaining({
server_url: '[__HIDDEN__]',
}),
)
})
})
it('should send new URL when URL is changed in edit mode', async () => {
const onConfirm = vi.fn().mockResolvedValue(undefined)
const mockData = {
id: 'test-id',
name: 'Existing Server',
server_url: 'https://existing.com/mcp',
server_identifier: 'existing-server',
icon: { content: '🚀', background: '#FF0000' },
} as unknown as ToolWithProvider
render(<MCPModal {...defaultProps} data={mockData} onConfirm={onConfirm} />, { wrapper: createWrapper() })
// Change the URL
const urlInput = screen.getByDisplayValue('https://existing.com/mcp')
fireEvent.change(urlInput, { target: { value: 'https://new.com/mcp' } })
const saveButton = screen.getByText('tools.mcp.modal.save')
fireEvent.click(saveButton)
await waitFor(() => {
expect(onConfirm).toHaveBeenCalledWith(
expect.objectContaining({
server_url: 'https://new.com/mcp',
}),
)
})
})
})
describe('Configuration Section', () => {
it('should submit with default timeout values', async () => {
const onConfirm = vi.fn().mockResolvedValue(undefined)
render(<MCPModal {...defaultProps} onConfirm={onConfirm} />, { wrapper: createWrapper() })
// Fill required fields
const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder')
const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder')
const identifierInput = screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder')
fireEvent.change(urlInput, { target: { value: 'https://example.com/mcp' } })
fireEvent.change(nameInput, { target: { value: 'Test Server' } })
fireEvent.change(identifierInput, { target: { value: 'test-server' } })
const confirmButton = screen.getByText('tools.mcp.modal.confirm')
fireEvent.click(confirmButton)
await waitFor(() => {
expect(onConfirm).toHaveBeenCalledWith(
expect.objectContaining({
configuration: expect.objectContaining({
timeout: 30,
sse_read_timeout: 300,
}),
}),
)
})
})
it('should submit with custom timeout values', async () => {
const onConfirm = vi.fn().mockResolvedValue(undefined)
render(<MCPModal {...defaultProps} onConfirm={onConfirm} />, { wrapper: createWrapper() })
// Fill required fields
const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder')
const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder')
const identifierInput = screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder')
fireEvent.change(urlInput, { target: { value: 'https://example.com/mcp' } })
fireEvent.change(nameInput, { target: { value: 'Test Server' } })
fireEvent.change(identifierInput, { target: { value: 'test-server' } })
// Switch to configurations tab
const configTab = screen.getByText('tools.mcp.modal.configurations')
fireEvent.click(configTab)
await waitFor(() => {
expect(screen.getByText('tools.mcp.modal.timeout')).toBeInTheDocument()
})
const confirmButton = screen.getByText('tools.mcp.modal.confirm')
fireEvent.click(confirmButton)
await waitFor(() => {
expect(onConfirm).toHaveBeenCalled()
})
})
})
describe('Dynamic Registration', () => {
it('should toggle dynamic registration', async () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
// Find the switch for dynamic registration
const switchElements = screen.getAllByRole('switch')
expect(switchElements.length).toBeGreaterThan(0)
// Click the first switch (dynamic registration)
fireEvent.click(switchElements[0])
// The switch should toggle
expect(switchElements[0]).toBeInTheDocument()
})
})
describe('App Icon Picker Interactions', () => {
it('should open app icon picker when app icon is clicked', async () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
// Find the app icon container with cursor-pointer and rounded-2xl classes
const appIconContainer = document.querySelector('[class*="rounded-2xl"][class*="cursor-pointer"]')
if (appIconContainer) {
fireEvent.click(appIconContainer)
// The mocked AppIconPicker should now be visible
await waitFor(() => {
expect(screen.getByTestId('app-icon-picker')).toBeInTheDocument()
})
}
})
it('should close app icon picker and update icon when selecting an icon', async () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
// Open the icon picker
const appIconContainer = document.querySelector('[class*="rounded-2xl"][class*="cursor-pointer"]')
if (appIconContainer) {
fireEvent.click(appIconContainer)
await waitFor(() => {
expect(screen.getByTestId('app-icon-picker')).toBeInTheDocument()
})
// Click the select emoji button
const selectBtn = screen.getByTestId('select-emoji-btn')
fireEvent.click(selectBtn)
// The picker should be closed
await waitFor(() => {
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
})
}
})
it('should close app icon picker and reset icon when close button is clicked', async () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
// Open the icon picker
const appIconContainer = document.querySelector('[class*="rounded-2xl"][class*="cursor-pointer"]')
if (appIconContainer) {
fireEvent.click(appIconContainer)
await waitFor(() => {
expect(screen.getByTestId('app-icon-picker')).toBeInTheDocument()
})
// Click the close button
const closeBtn = screen.getByTestId('close-picker-btn')
fireEvent.click(closeBtn)
// The picker should be closed
await waitFor(() => {
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
})
}
})
})
})

View File

@ -1,298 +1,429 @@
'use client'
import type { FC } from 'react'
import type { HeaderItem } from './headers-input'
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import type { AppIconType } from '@/types/app'
import { RiCloseLine, RiEditLine } from '@remixicon/react'
import { useHover } from 'ahooks'
import { noop } from 'es-toolkit/function'
import * as React from 'react'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getDomain } from 'tldts'
import { v4 as uuid } from 'uuid'
import AppIcon from '@/app/components/base/app-icon'
import AppIconPicker from '@/app/components/base/app-icon-picker'
import Button from '@/app/components/base/button'
import { Mcp } from '@/app/components/base/icons/src/vender/other'
import AlertTriangle from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback/AlertTriangle'
import Input from '@/app/components/base/input'
import Modal from '@/app/components/base/modal'
import Switch from '@/app/components/base/switch'
import TabSlider from '@/app/components/base/tab-slider'
import Toast from '@/app/components/base/toast'
import { MCPAuthMethod } from '@/app/components/tools/types'
import { API_PREFIX } from '@/config'
import { uploadRemoteFileInfo } from '@/service/common'
import { cn } from '@/utils/classnames'
import { shouldUseMcpIconForAppIcon } from '@/utils/mcp'
import { isValidServerID, isValidUrl, useMCPModalForm } from './hooks/use-mcp-modal-form'
import AuthenticationSection from './sections/authentication-section'
import ConfigurationsSection from './sections/configurations-section'
import HeadersSection from './sections/headers-section'
export type MCPModalConfirmPayload = {
name: string
server_url: string
icon_type: AppIconType
icon: string
icon_background?: string | null
server_identifier: string
headers?: Record<string, string>
is_dynamic_registration?: boolean
authentication?: {
client_id?: string
client_secret?: string
grant_type?: string
}
configuration: {
timeout: number
sse_read_timeout: number
}
}
import HeadersInput from './headers-input'
export type DuplicateAppModalProps = {
data?: ToolWithProvider
show: boolean
onConfirm: (info: MCPModalConfirmPayload) => void
onConfirm: (info: {
name: string
server_url: string
icon_type: AppIconType
icon: string
icon_background?: string | null
server_identifier: string
headers?: Record<string, string>
is_dynamic_registration?: boolean
authentication?: {
client_id?: string
client_secret?: string
grant_type?: string
}
configuration: {
timeout: number
sse_read_timeout: number
}
}) => void
onHide: () => void
}
type MCPModalContentProps = {
data?: ToolWithProvider
onConfirm: (info: MCPModalConfirmPayload) => void
onHide: () => void
const DEFAULT_ICON = { type: 'emoji', icon: '🔗', background: '#6366F1' }
const extractFileId = (url: string) => {
const match = url.match(/files\/(.+?)\/file-preview/)
return match ? match[1] : null
}
const getIcon = (data?: ToolWithProvider) => {
if (!data)
return DEFAULT_ICON as AppIconSelection
if (typeof data.icon === 'string')
return { type: 'image', url: data.icon, fileId: extractFileId(data.icon) } as AppIconSelection
return {
...data.icon,
icon: data.icon.content,
type: 'emoji',
} as unknown as AppIconSelection
}
const MCPModalContent: FC<MCPModalContentProps> = ({
const MCPModal = ({
data,
show,
onConfirm,
onHide,
}) => {
}: DuplicateAppModalProps) => {
const { t } = useTranslation()
const {
isCreate,
originalServerUrl,
originalServerID,
appIconRef,
state,
actions,
} = useMCPModalForm(data)
const isHovering = useHover(appIconRef)
const isCreate = !data
const authMethods = [
{ text: t('mcp.modal.authentication', { ns: 'tools' }), value: MCPAuthMethod.authentication },
{ text: t('mcp.modal.headers', { ns: 'tools' }), value: MCPAuthMethod.headers },
{ text: t('mcp.modal.configurations', { ns: 'tools' }), value: MCPAuthMethod.configurations },
{
text: t('mcp.modal.authentication', { ns: 'tools' }),
value: MCPAuthMethod.authentication,
},
{
text: t('mcp.modal.headers', { ns: 'tools' }),
value: MCPAuthMethod.headers,
},
{
text: t('mcp.modal.configurations', { ns: 'tools' }),
value: MCPAuthMethod.configurations,
},
]
const originalServerUrl = data?.server_url
const originalServerID = data?.server_identifier
const [url, setUrl] = React.useState(data?.server_url || '')
const [name, setName] = React.useState(data?.name || '')
const [appIcon, setAppIcon] = useState<AppIconSelection>(() => getIcon(data))
const [showAppIconPicker, setShowAppIconPicker] = useState(false)
const [serverIdentifier, setServerIdentifier] = React.useState(data?.server_identifier || '')
const [timeout, setMcpTimeout] = React.useState(data?.configuration?.timeout || 30)
const [sseReadTimeout, setSseReadTimeout] = React.useState(data?.configuration?.sse_read_timeout || 300)
const [headers, setHeaders] = React.useState<HeaderItem[]>(
Object.entries(data?.masked_headers || {}).map(([key, value]) => ({ id: uuid(), key, value })),
)
const [isFetchingIcon, setIsFetchingIcon] = useState(false)
const appIconRef = useRef<HTMLDivElement>(null)
const isHovering = useHover(appIconRef)
const [authMethod, setAuthMethod] = useState(MCPAuthMethod.authentication)
const [isDynamicRegistration, setIsDynamicRegistration] = useState(isCreate ? true : data?.is_dynamic_registration)
const [clientID, setClientID] = useState(data?.authentication?.client_id || '')
const [credentials, setCredentials] = useState(data?.authentication?.client_secret || '')
// Update states when data changes (for edit mode)
React.useEffect(() => {
if (data) {
setUrl(data.server_url || '')
setName(data.name || '')
setServerIdentifier(data.server_identifier || '')
setMcpTimeout(data.configuration?.timeout || 30)
setSseReadTimeout(data.configuration?.sse_read_timeout || 300)
setHeaders(Object.entries(data.masked_headers || {}).map(([key, value]) => ({ id: uuid(), key, value })))
setAppIcon(getIcon(data))
setIsDynamicRegistration(data.is_dynamic_registration)
setClientID(data.authentication?.client_id || '')
setCredentials(data.authentication?.client_secret || '')
}
else {
// Reset for create mode
setUrl('')
setName('')
setServerIdentifier('')
setMcpTimeout(30)
setSseReadTimeout(300)
setHeaders([])
setAppIcon(DEFAULT_ICON as AppIconSelection)
setIsDynamicRegistration(true)
setClientID('')
setCredentials('')
}
}, [data])
const isValidUrl = (string: string) => {
try {
const url = new URL(string)
return url.protocol === 'http:' || url.protocol === 'https:'
}
catch {
return false
}
}
const isValidServerID = (str: string) => {
return /^[a-z0-9_-]{1,24}$/.test(str)
}
const handleBlur = async (url: string) => {
if (data)
return
if (!isValidUrl(url))
return
const domain = getDomain(url)
const remoteIcon = `https://www.google.com/s2/favicons?domain=${domain}&sz=128`
setIsFetchingIcon(true)
try {
const res = await uploadRemoteFileInfo(remoteIcon, undefined, true)
setAppIcon({ type: 'image', url: res.url, fileId: extractFileId(res.url) || '' })
}
catch (e) {
let errorMessage = 'Failed to fetch remote icon'
const errorData = await (e as Response).json()
if (errorData?.code)
errorMessage = `Upload failed: ${errorData.code}`
console.error('Failed to fetch remote icon:', e)
Toast.notify({ type: 'warning', message: errorMessage })
}
finally {
setIsFetchingIcon(false)
}
}
const submit = async () => {
if (!isValidUrl(state.url)) {
if (!isValidUrl(url)) {
Toast.notify({ type: 'error', message: 'invalid server url' })
return
}
if (!isValidServerID(state.serverIdentifier.trim())) {
if (!isValidServerID(serverIdentifier.trim())) {
Toast.notify({ type: 'error', message: 'invalid server identifier' })
return
}
const formattedHeaders = state.headers.reduce((acc, item) => {
const formattedHeaders = headers.reduce((acc, item) => {
if (item.key.trim())
acc[item.key.trim()] = item.value
return acc
}, {} as Record<string, string>)
await onConfirm({
server_url: originalServerUrl === state.url ? '[__HIDDEN__]' : state.url.trim(),
name: state.name,
icon_type: state.appIcon.type,
icon: state.appIcon.type === 'emoji' ? state.appIcon.icon : state.appIcon.fileId,
icon_background: state.appIcon.type === 'emoji' ? state.appIcon.background : undefined,
server_identifier: state.serverIdentifier.trim(),
server_url: originalServerUrl === url ? '[__HIDDEN__]' : url.trim(),
name,
icon_type: appIcon.type,
icon: appIcon.type === 'emoji' ? appIcon.icon : appIcon.fileId,
icon_background: appIcon.type === 'emoji' ? appIcon.background : undefined,
server_identifier: serverIdentifier.trim(),
headers: Object.keys(formattedHeaders).length > 0 ? formattedHeaders : undefined,
is_dynamic_registration: state.isDynamicRegistration,
is_dynamic_registration: isDynamicRegistration,
authentication: {
client_id: state.clientID,
client_secret: state.credentials,
client_id: clientID,
client_secret: credentials,
},
configuration: {
timeout: state.timeout || 30,
sse_read_timeout: state.sseReadTimeout || 300,
timeout: timeout || 30,
sse_read_timeout: sseReadTimeout || 300,
},
})
if (isCreate)
onHide()
}
const handleIconSelect = (payload: AppIconSelection) => {
actions.setAppIcon(payload)
actions.setShowAppIconPicker(false)
}
const handleIconClose = () => {
actions.resetIcon()
actions.setShowAppIconPicker(false)
}
const isSubmitDisabled = !state.name || !state.url || !state.serverIdentifier || state.isFetchingIcon
const handleAuthMethodChange = useCallback((value: string) => {
setAuthMethod(value as MCPAuthMethod)
}, [])
return (
<>
<div className="absolute right-5 top-5 z-10 cursor-pointer p-1.5" onClick={onHide}>
<RiCloseLine className="h-5 w-5 text-text-tertiary" />
</div>
<div className="title-2xl-semi-bold relative pb-3 text-xl text-text-primary">
{!isCreate ? t('mcp.modal.editTitle', { ns: 'tools' }) : t('mcp.modal.title', { ns: 'tools' })}
</div>
<div className="space-y-5 py-3">
{/* Server URL */}
<div>
<div className="mb-1 flex h-6 items-center">
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.serverUrl', { ns: 'tools' })}</span>
</div>
<Input
value={state.url}
onChange={e => actions.setUrl(e.target.value)}
onBlur={e => actions.handleUrlBlur(e.target.value.trim())}
placeholder={t('mcp.modal.serverUrlPlaceholder', { ns: 'tools' })}
/>
{originalServerUrl && originalServerUrl !== state.url && (
<div className="mt-1 flex h-5 items-center">
<span className="body-xs-regular text-text-warning">{t('mcp.modal.serverUrlWarning', { ns: 'tools' })}</span>
</div>
)}
<Modal
isShow={show}
onClose={noop}
className={cn('relative !max-w-[520px]', 'p-6')}
>
<div className="absolute right-5 top-5 z-10 cursor-pointer p-1.5" onClick={onHide}>
<RiCloseLine className="h-5 w-5 text-text-tertiary" />
</div>
{/* Name and Icon */}
<div className="flex space-x-3">
<div className="grow pb-1">
<div className="title-2xl-semi-bold relative pb-3 text-xl text-text-primary">{!isCreate ? t('mcp.modal.editTitle', { ns: 'tools' }) : t('mcp.modal.title', { ns: 'tools' })}</div>
<div className="space-y-5 py-3">
<div>
<div className="mb-1 flex h-6 items-center">
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.name', { ns: 'tools' })}</span>
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.serverUrl', { ns: 'tools' })}</span>
</div>
<Input
value={state.name}
onChange={e => actions.setName(e.target.value)}
placeholder={t('mcp.modal.namePlaceholder', { ns: 'tools' })}
value={url}
onChange={e => setUrl(e.target.value)}
onBlur={e => handleBlur(e.target.value.trim())}
placeholder={t('mcp.modal.serverUrlPlaceholder', { ns: 'tools' })}
/>
{originalServerUrl && originalServerUrl !== url && (
<div className="mt-1 flex h-5 items-center">
<span className="body-xs-regular text-text-warning">{t('mcp.modal.serverUrlWarning', { ns: 'tools' })}</span>
</div>
)}
</div>
<div className="pt-2" ref={appIconRef}>
<AppIcon
iconType={state.appIcon.type}
icon={state.appIcon.type === 'emoji' ? state.appIcon.icon : state.appIcon.fileId}
background={state.appIcon.type === 'emoji' ? state.appIcon.background : undefined}
imageUrl={state.appIcon.type === 'image' ? state.appIcon.url : undefined}
innerIcon={shouldUseMcpIconForAppIcon(state.appIcon.type, state.appIcon.type === 'emoji' ? state.appIcon.icon : '') ? <Mcp className="h-8 w-8 text-text-primary-on-surface" /> : undefined}
size="xxl"
className="relative cursor-pointer rounded-2xl"
coverElement={
isHovering
? (
<div className="absolute inset-0 flex items-center justify-center overflow-hidden rounded-2xl bg-background-overlay-alt">
<RiEditLine className="size-6 text-text-primary-on-surface" />
</div>
)
: null
}
onClick={() => actions.setShowAppIconPicker(true)}
/>
</div>
</div>
{/* Server Identifier */}
<div>
<div className="flex h-6 items-center">
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.serverIdentifier', { ns: 'tools' })}</span>
</div>
<div className="body-xs-regular mb-1 text-text-tertiary">{t('mcp.modal.serverIdentifierTip', { ns: 'tools' })}</div>
<Input
value={state.serverIdentifier}
onChange={e => actions.setServerIdentifier(e.target.value)}
placeholder={t('mcp.modal.serverIdentifierPlaceholder', { ns: 'tools' })}
/>
{originalServerID && originalServerID !== state.serverIdentifier && (
<div className="mt-1 flex h-5 items-center">
<span className="body-xs-regular text-text-warning">{t('mcp.modal.serverIdentifierWarning', { ns: 'tools' })}</span>
<div className="flex space-x-3">
<div className="grow pb-1">
<div className="mb-1 flex h-6 items-center">
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.name', { ns: 'tools' })}</span>
</div>
<Input
value={name}
onChange={e => setName(e.target.value)}
placeholder={t('mcp.modal.namePlaceholder', { ns: 'tools' })}
/>
</div>
)}
<div className="pt-2" ref={appIconRef}>
<AppIcon
iconType={appIcon.type}
icon={appIcon.type === 'emoji' ? appIcon.icon : appIcon.fileId}
background={appIcon.type === 'emoji' ? appIcon.background : undefined}
imageUrl={appIcon.type === 'image' ? appIcon.url : undefined}
innerIcon={shouldUseMcpIconForAppIcon(appIcon.type, appIcon.type === 'emoji' ? appIcon.icon : '') ? <Mcp className="h-8 w-8 text-text-primary-on-surface" /> : undefined}
size="xxl"
className="relative cursor-pointer rounded-2xl"
coverElement={
isHovering
? (
<div className="absolute inset-0 flex items-center justify-center overflow-hidden rounded-2xl bg-background-overlay-alt">
<RiEditLine className="size-6 text-text-primary-on-surface" />
</div>
)
: null
}
onClick={() => { setShowAppIconPicker(true) }}
/>
</div>
</div>
<div>
<div className="flex h-6 items-center">
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.serverIdentifier', { ns: 'tools' })}</span>
</div>
<div className="body-xs-regular mb-1 text-text-tertiary">{t('mcp.modal.serverIdentifierTip', { ns: 'tools' })}</div>
<Input
value={serverIdentifier}
onChange={e => setServerIdentifier(e.target.value)}
placeholder={t('mcp.modal.serverIdentifierPlaceholder', { ns: 'tools' })}
/>
{originalServerID && originalServerID !== serverIdentifier && (
<div className="mt-1 flex h-5 items-center">
<span className="body-xs-regular text-text-warning">{t('mcp.modal.serverIdentifierWarning', { ns: 'tools' })}</span>
</div>
)}
</div>
<TabSlider
className="w-full"
itemClassName={(isActive) => {
return `flex-1 ${isActive && 'text-text-accent-light-mode-only'}`
}}
value={authMethod}
onChange={handleAuthMethodChange}
options={authMethods}
/>
{
authMethod === MCPAuthMethod.authentication && (
<>
<div>
<div className="mb-1 flex h-6 items-center">
<Switch
className="mr-2"
defaultValue={isDynamicRegistration}
onChange={setIsDynamicRegistration}
/>
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.useDynamicClientRegistration', { ns: 'tools' })}</span>
</div>
{!isDynamicRegistration && (
<div className="mt-2 flex gap-2 rounded-lg bg-state-warning-hover p-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-text-warning" />
<div className="system-xs-regular text-text-secondary">
<div className="mb-1">{t('mcp.modal.redirectUrlWarning', { ns: 'tools' })}</div>
<code className="system-xs-medium block break-all rounded bg-state-warning-active px-2 py-1 text-text-secondary">
{`${API_PREFIX}/mcp/oauth/callback`}
</code>
</div>
</div>
)}
</div>
<div>
<div className={cn('mb-1 flex h-6 items-center', isDynamicRegistration && 'opacity-50')}>
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.clientID', { ns: 'tools' })}</span>
</div>
<Input
value={clientID}
onChange={e => setClientID(e.target.value)}
onBlur={e => handleBlur(e.target.value.trim())}
placeholder={t('mcp.modal.clientID', { ns: 'tools' })}
disabled={isDynamicRegistration}
/>
</div>
<div>
<div className={cn('mb-1 flex h-6 items-center', isDynamicRegistration && 'opacity-50')}>
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.clientSecret', { ns: 'tools' })}</span>
</div>
<Input
value={credentials}
onChange={e => setCredentials(e.target.value)}
onBlur={e => handleBlur(e.target.value.trim())}
placeholder={t('mcp.modal.clientSecretPlaceholder', { ns: 'tools' })}
disabled={isDynamicRegistration}
/>
</div>
</>
)
}
{
authMethod === MCPAuthMethod.headers && (
<div>
<div className="mb-1 flex h-6 items-center">
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.headers', { ns: 'tools' })}</span>
</div>
<div className="body-xs-regular mb-2 text-text-tertiary">{t('mcp.modal.headersTip', { ns: 'tools' })}</div>
<HeadersInput
headersItems={headers}
onChange={setHeaders}
readonly={false}
isMasked={!isCreate && headers.filter(item => item.key.trim()).length > 0}
/>
</div>
)
}
{
authMethod === MCPAuthMethod.configurations && (
<>
<div>
<div className="mb-1 flex h-6 items-center">
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.timeout', { ns: 'tools' })}</span>
</div>
<Input
type="number"
value={timeout}
onChange={e => setMcpTimeout(Number(e.target.value))}
onBlur={e => handleBlur(e.target.value.trim())}
placeholder={t('mcp.modal.timeoutPlaceholder', { ns: 'tools' })}
/>
</div>
<div>
<div className="mb-1 flex h-6 items-center">
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.sseReadTimeout', { ns: 'tools' })}</span>
</div>
<Input
type="number"
value={sseReadTimeout}
onChange={e => setSseReadTimeout(Number(e.target.value))}
onBlur={e => handleBlur(e.target.value.trim())}
placeholder={t('mcp.modal.timeoutPlaceholder', { ns: 'tools' })}
/>
</div>
</>
)
}
</div>
{/* Auth Method Tabs */}
<TabSlider
className="w-full"
itemClassName={isActive => `flex-1 ${isActive && 'text-text-accent-light-mode-only'}`}
value={state.authMethod}
onChange={actions.setAuthMethod}
options={authMethods}
/>
{/* Tab Content */}
{state.authMethod === MCPAuthMethod.authentication && (
<AuthenticationSection
isDynamicRegistration={state.isDynamicRegistration}
onDynamicRegistrationChange={actions.setIsDynamicRegistration}
clientID={state.clientID}
onClientIDChange={actions.setClientID}
credentials={state.credentials}
onCredentialsChange={actions.setCredentials}
/>
)}
{state.authMethod === MCPAuthMethod.headers && (
<HeadersSection
headers={state.headers}
onHeadersChange={actions.setHeaders}
isCreate={isCreate}
/>
)}
{state.authMethod === MCPAuthMethod.configurations && (
<ConfigurationsSection
timeout={state.timeout}
onTimeoutChange={actions.setTimeout}
sseReadTimeout={state.sseReadTimeout}
onSseReadTimeoutChange={actions.setSseReadTimeout}
/>
)}
</div>
{/* Actions */}
<div className="flex flex-row-reverse pt-5">
<Button disabled={isSubmitDisabled} className="ml-2" variant="primary" onClick={submit}>
{data ? t('mcp.modal.save', { ns: 'tools' }) : t('mcp.modal.confirm', { ns: 'tools' })}
</Button>
<Button onClick={onHide}>{t('mcp.modal.cancel', { ns: 'tools' })}</Button>
</div>
{state.showAppIconPicker && (
<div className="flex flex-row-reverse pt-5">
<Button disabled={!name || !url || !serverIdentifier || isFetchingIcon} className="ml-2" variant="primary" onClick={submit}>{data ? t('mcp.modal.save', { ns: 'tools' }) : t('mcp.modal.confirm', { ns: 'tools' })}</Button>
<Button onClick={onHide}>{t('mcp.modal.cancel', { ns: 'tools' })}</Button>
</div>
</Modal>
{showAppIconPicker && (
<AppIconPicker
onSelect={handleIconSelect}
onClose={handleIconClose}
onSelect={(payload) => {
setAppIcon(payload)
setShowAppIconPicker(false)
}}
onClose={() => {
setAppIcon(getIcon(data))
setShowAppIconPicker(false)
}}
/>
)}
</>
)
}
/**
* MCP Modal component for creating and editing MCP server configurations.
*
* Uses a keyed inner component to ensure form state resets when switching
* between create mode and edit mode with different data.
*/
const MCPModal: FC<DuplicateAppModalProps> = ({
data,
show,
onConfirm,
onHide,
}) => {
// Use data ID as key to reset form state when switching between items
const formKey = data?.id ?? 'create'
return (
<Modal
isShow={show}
onClose={noop}
className={cn('relative !max-w-[520px]', 'p-6')}
>
<MCPModalContent
key={formKey}
data={data}
onConfirm={onConfirm}
onHide={onHide}
/>
</Modal>
)
}

View File

@ -1,524 +0,0 @@
import type { ReactNode } from 'react'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import MCPCard from './provider-card'
// Mutable mock functions
const mockUpdateMCP = vi.fn().mockResolvedValue({ result: 'success' })
const mockDeleteMCP = vi.fn().mockResolvedValue({ result: 'success' })
// Mock the services
vi.mock('@/service/use-tools', () => ({
useUpdateMCP: () => ({
mutateAsync: mockUpdateMCP,
}),
useDeleteMCP: () => ({
mutateAsync: mockDeleteMCP,
}),
}))
// Mock the MCPModal
type MCPModalForm = {
name: string
server_url: string
}
type MCPModalProps = {
show: boolean
onConfirm: (form: MCPModalForm) => void
onHide: () => void
}
vi.mock('./modal', () => ({
default: ({ show, onConfirm, onHide }: MCPModalProps) => {
if (!show)
return null
return (
<div data-testid="mcp-modal">
<button data-testid="modal-confirm-btn" onClick={() => onConfirm({ name: 'Updated MCP', server_url: 'https://updated.com' })}>
Confirm
</button>
<button data-testid="modal-close-btn" onClick={onHide}>
Close
</button>
</div>
)
},
}))
// Mock the Confirm dialog
type ConfirmDialogProps = {
isShow: boolean
onConfirm: () => void
onCancel: () => void
isLoading: boolean
}
vi.mock('@/app/components/base/confirm', () => ({
default: ({ isShow, onConfirm, onCancel, isLoading }: ConfirmDialogProps) => {
if (!isShow)
return null
return (
<div data-testid="confirm-dialog">
<button data-testid="confirm-delete-btn" onClick={onConfirm} disabled={isLoading}>
{isLoading ? 'Deleting...' : 'Confirm Delete'}
</button>
<button data-testid="cancel-delete-btn" onClick={onCancel}>
Cancel
</button>
</div>
)
},
}))
// Mock the OperationDropdown
type OperationDropdownProps = {
onEdit: () => void
onRemove: () => void
onOpenChange: (open: boolean) => void
}
vi.mock('./detail/operation-dropdown', () => ({
default: ({ onEdit, onRemove, onOpenChange }: OperationDropdownProps) => (
<div data-testid="operation-dropdown">
<button
data-testid="edit-btn"
onClick={() => {
onOpenChange(true)
onEdit()
}}
>
Edit
</button>
<button
data-testid="remove-btn"
onClick={() => {
onOpenChange(true)
onRemove()
}}
>
Remove
</button>
</div>
),
}))
// Mock the app context
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceManager: true,
isCurrentWorkspaceEditor: true,
}),
}))
// Mock the format time hook
vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({
formatTimeFromNow: (_timestamp: number) => '2 hours ago',
}),
}))
// Mock the plugins service
vi.mock('@/service/use-plugins', () => ({
useInstalledPluginList: () => ({
data: { pages: [] },
hasNextPage: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
isLoading: false,
isSuccess: true,
}),
}))
// Mock common service
vi.mock('@/service/common', () => ({
uploadRemoteFileInfo: vi.fn().mockResolvedValue({ url: 'https://example.com/icon.png' }),
}))
describe('MCPCard', () => {
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children)
}
const createMockData = (overrides = {}): ToolWithProvider => ({
id: 'mcp-1',
name: 'Test MCP Server',
server_identifier: 'test-server',
icon: { content: '🔧', background: '#FF0000' },
tools: [
{ name: 'tool1', description: 'Tool 1' },
{ name: 'tool2', description: 'Tool 2' },
],
is_team_authorization: true,
updated_at: Date.now() / 1000,
...overrides,
} as unknown as ToolWithProvider)
const defaultProps = {
data: createMockData(),
handleSelect: vi.fn(),
onUpdate: vi.fn(),
onDeleted: vi.fn(),
}
beforeEach(() => {
mockUpdateMCP.mockClear()
mockDeleteMCP.mockClear()
mockUpdateMCP.mockResolvedValue({ result: 'success' })
mockDeleteMCP.mockResolvedValue({ result: 'success' })
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<MCPCard {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('Test MCP Server')).toBeInTheDocument()
})
it('should display MCP name', () => {
render(<MCPCard {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('Test MCP Server')).toBeInTheDocument()
})
it('should display server identifier', () => {
render(<MCPCard {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('test-server')).toBeInTheDocument()
})
it('should display tools count', () => {
render(<MCPCard {...defaultProps} />, { wrapper: createWrapper() })
// The tools count uses i18n with count parameter
expect(screen.getByText(/tools.mcp.toolsCount/)).toBeInTheDocument()
})
it('should display update time', () => {
render(<MCPCard {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText(/tools.mcp.updateTime/)).toBeInTheDocument()
})
})
describe('No Tools State', () => {
it('should show no tools message when tools array is empty', () => {
const dataWithNoTools = createMockData({ tools: [] })
render(
<MCPCard {...defaultProps} data={dataWithNoTools} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('tools.mcp.noTools')).toBeInTheDocument()
})
it('should show not configured badge when not authorized', () => {
const dataNotAuthorized = createMockData({ is_team_authorization: false })
render(
<MCPCard {...defaultProps} data={dataNotAuthorized} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('tools.mcp.noConfigured')).toBeInTheDocument()
})
it('should show not configured badge when no tools', () => {
const dataWithNoTools = createMockData({ tools: [], is_team_authorization: true })
render(
<MCPCard {...defaultProps} data={dataWithNoTools} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('tools.mcp.noConfigured')).toBeInTheDocument()
})
})
describe('Selected State', () => {
it('should apply selected styles when current provider matches', () => {
render(
<MCPCard {...defaultProps} currentProvider={defaultProps.data} />,
{ wrapper: createWrapper() },
)
const card = document.querySelector('[class*="border-components-option-card-option-selected-border"]')
expect(card).toBeInTheDocument()
})
it('should not apply selected styles when different provider', () => {
const differentProvider = createMockData({ id: 'different-id' })
render(
<MCPCard {...defaultProps} currentProvider={differentProvider} />,
{ wrapper: createWrapper() },
)
const card = document.querySelector('[class*="border-components-option-card-option-selected-border"]')
expect(card).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call handleSelect when card is clicked', () => {
const handleSelect = vi.fn()
render(
<MCPCard {...defaultProps} handleSelect={handleSelect} />,
{ wrapper: createWrapper() },
)
const card = screen.getByText('Test MCP Server').closest('[class*="cursor-pointer"]')
if (card) {
fireEvent.click(card)
expect(handleSelect).toHaveBeenCalledWith('mcp-1')
}
})
})
describe('Card Icon', () => {
it('should render card icon', () => {
render(<MCPCard {...defaultProps} />, { wrapper: createWrapper() })
// Icon component is rendered
const iconContainer = document.querySelector('[class*="rounded-xl"][class*="border"]')
expect(iconContainer).toBeInTheDocument()
})
})
describe('Status Indicator', () => {
it('should show green indicator when authorized and has tools', () => {
const data = createMockData({ is_team_authorization: true, tools: [{ name: 'tool1' }] })
render(
<MCPCard {...defaultProps} data={data} />,
{ wrapper: createWrapper() },
)
// Should have green indicator (not showing red badge)
expect(screen.queryByText('tools.mcp.noConfigured')).not.toBeInTheDocument()
})
it('should show red indicator when not configured', () => {
const data = createMockData({ is_team_authorization: false })
render(
<MCPCard {...defaultProps} data={data} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('tools.mcp.noConfigured')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle long MCP name', () => {
const longName = 'A'.repeat(100)
const data = createMockData({ name: longName })
render(
<MCPCard {...defaultProps} data={data} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText(longName)).toBeInTheDocument()
})
it('should handle special characters in name', () => {
const data = createMockData({ name: 'Test <Script> & "Quotes"' })
render(
<MCPCard {...defaultProps} data={data} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('Test <Script> & "Quotes"')).toBeInTheDocument()
})
it('should handle undefined currentProvider', () => {
render(
<MCPCard {...defaultProps} currentProvider={undefined} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('Test MCP Server')).toBeInTheDocument()
})
})
describe('Operation Dropdown', () => {
it('should render operation dropdown for workspace managers', () => {
render(<MCPCard {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByTestId('operation-dropdown')).toBeInTheDocument()
})
it('should stop propagation when clicking on dropdown container', () => {
const handleSelect = vi.fn()
render(<MCPCard {...defaultProps} handleSelect={handleSelect} />, { wrapper: createWrapper() })
// Click on the dropdown area (which should stop propagation)
const dropdown = screen.getByTestId('operation-dropdown')
const dropdownContainer = dropdown.closest('[class*="absolute"]')
if (dropdownContainer) {
fireEvent.click(dropdownContainer)
// handleSelect should NOT be called because stopPropagation
expect(handleSelect).not.toHaveBeenCalled()
}
})
})
describe('Update Modal', () => {
it('should open update modal when edit button is clicked', async () => {
render(<MCPCard {...defaultProps} />, { wrapper: createWrapper() })
// Click the edit button
const editBtn = screen.getByTestId('edit-btn')
fireEvent.click(editBtn)
// Modal should be shown
await waitFor(() => {
expect(screen.getByTestId('mcp-modal')).toBeInTheDocument()
})
})
it('should close update modal when close button is clicked', async () => {
render(<MCPCard {...defaultProps} />, { wrapper: createWrapper() })
// Open the modal
const editBtn = screen.getByTestId('edit-btn')
fireEvent.click(editBtn)
await waitFor(() => {
expect(screen.getByTestId('mcp-modal')).toBeInTheDocument()
})
// Close the modal
const closeBtn = screen.getByTestId('modal-close-btn')
fireEvent.click(closeBtn)
await waitFor(() => {
expect(screen.queryByTestId('mcp-modal')).not.toBeInTheDocument()
})
})
it('should call updateMCP and onUpdate when form is confirmed', async () => {
const onUpdate = vi.fn()
render(<MCPCard {...defaultProps} onUpdate={onUpdate} />, { wrapper: createWrapper() })
// Open the modal
const editBtn = screen.getByTestId('edit-btn')
fireEvent.click(editBtn)
await waitFor(() => {
expect(screen.getByTestId('mcp-modal')).toBeInTheDocument()
})
// Confirm the form
const confirmBtn = screen.getByTestId('modal-confirm-btn')
fireEvent.click(confirmBtn)
await waitFor(() => {
expect(mockUpdateMCP).toHaveBeenCalledWith({
name: 'Updated MCP',
server_url: 'https://updated.com',
provider_id: 'mcp-1',
})
expect(onUpdate).toHaveBeenCalledWith('mcp-1')
})
})
it('should not call onUpdate when updateMCP fails', async () => {
mockUpdateMCP.mockResolvedValue({ result: 'error' })
const onUpdate = vi.fn()
render(<MCPCard {...defaultProps} onUpdate={onUpdate} />, { wrapper: createWrapper() })
// Open the modal
const editBtn = screen.getByTestId('edit-btn')
fireEvent.click(editBtn)
await waitFor(() => {
expect(screen.getByTestId('mcp-modal')).toBeInTheDocument()
})
// Confirm the form
const confirmBtn = screen.getByTestId('modal-confirm-btn')
fireEvent.click(confirmBtn)
await waitFor(() => {
expect(mockUpdateMCP).toHaveBeenCalled()
})
// onUpdate should not be called because result is not 'success'
expect(onUpdate).not.toHaveBeenCalled()
})
})
describe('Delete Confirm', () => {
it('should open delete confirm when remove button is clicked', async () => {
render(<MCPCard {...defaultProps} />, { wrapper: createWrapper() })
// Click the remove button
const removeBtn = screen.getByTestId('remove-btn')
fireEvent.click(removeBtn)
// Confirm dialog should be shown
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
})
it('should close delete confirm when cancel button is clicked', async () => {
render(<MCPCard {...defaultProps} />, { wrapper: createWrapper() })
// Open the confirm dialog
const removeBtn = screen.getByTestId('remove-btn')
fireEvent.click(removeBtn)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
// Cancel
const cancelBtn = screen.getByTestId('cancel-delete-btn')
fireEvent.click(cancelBtn)
await waitFor(() => {
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
})
})
it('should call deleteMCP and onDeleted when delete is confirmed', async () => {
const onDeleted = vi.fn()
render(<MCPCard {...defaultProps} onDeleted={onDeleted} />, { wrapper: createWrapper() })
// Open the confirm dialog
const removeBtn = screen.getByTestId('remove-btn')
fireEvent.click(removeBtn)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
// Confirm delete
const confirmBtn = screen.getByTestId('confirm-delete-btn')
fireEvent.click(confirmBtn)
await waitFor(() => {
expect(mockDeleteMCP).toHaveBeenCalledWith('mcp-1')
expect(onDeleted).toHaveBeenCalled()
})
})
it('should not call onDeleted when deleteMCP fails', async () => {
mockDeleteMCP.mockResolvedValue({ result: 'error' })
const onDeleted = vi.fn()
render(<MCPCard {...defaultProps} onDeleted={onDeleted} />, { wrapper: createWrapper() })
// Open the confirm dialog
const removeBtn = screen.getByTestId('remove-btn')
fireEvent.click(removeBtn)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
// Confirm delete
const confirmBtn = screen.getByTestId('confirm-delete-btn')
fireEvent.click(confirmBtn)
await waitFor(() => {
expect(mockDeleteMCP).toHaveBeenCalled()
})
// onDeleted should not be called because result is not 'success'
expect(onDeleted).not.toHaveBeenCalled()
})
})
})

View File

@ -1,162 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import AuthenticationSection from './authentication-section'
describe('AuthenticationSection', () => {
const defaultProps = {
isDynamicRegistration: true,
onDynamicRegistrationChange: vi.fn(),
clientID: '',
onClientIDChange: vi.fn(),
credentials: '',
onCredentialsChange: vi.fn(),
}
describe('Rendering', () => {
it('should render without crashing', () => {
render(<AuthenticationSection {...defaultProps} />)
expect(screen.getByText('tools.mcp.modal.useDynamicClientRegistration')).toBeInTheDocument()
})
it('should render switch for dynamic registration', () => {
render(<AuthenticationSection {...defaultProps} />)
expect(screen.getByRole('switch')).toBeInTheDocument()
})
it('should render client ID input', () => {
render(<AuthenticationSection {...defaultProps} clientID="test-client-id" />)
expect(screen.getByDisplayValue('test-client-id')).toBeInTheDocument()
})
it('should render credentials input', () => {
render(<AuthenticationSection {...defaultProps} credentials="test-secret" />)
expect(screen.getByDisplayValue('test-secret')).toBeInTheDocument()
})
it('should render labels for all fields', () => {
render(<AuthenticationSection {...defaultProps} />)
expect(screen.getByText('tools.mcp.modal.useDynamicClientRegistration')).toBeInTheDocument()
expect(screen.getByText('tools.mcp.modal.clientID')).toBeInTheDocument()
expect(screen.getByText('tools.mcp.modal.clientSecret')).toBeInTheDocument()
})
})
describe('Dynamic Registration Toggle', () => {
it('should not show warning when isDynamicRegistration is true', () => {
render(<AuthenticationSection {...defaultProps} isDynamicRegistration={true} />)
expect(screen.queryByText('tools.mcp.modal.redirectUrlWarning')).not.toBeInTheDocument()
})
it('should show warning when isDynamicRegistration is false', () => {
render(<AuthenticationSection {...defaultProps} isDynamicRegistration={false} />)
expect(screen.getByText('tools.mcp.modal.redirectUrlWarning')).toBeInTheDocument()
})
it('should show OAuth callback URL in warning', () => {
render(<AuthenticationSection {...defaultProps} isDynamicRegistration={false} />)
expect(screen.getByText(/\/mcp\/oauth\/callback/)).toBeInTheDocument()
})
it('should disable inputs when isDynamicRegistration is true', () => {
render(<AuthenticationSection {...defaultProps} isDynamicRegistration={true} />)
const inputs = screen.getAllByRole('textbox')
inputs.forEach((input) => {
expect(input).toBeDisabled()
})
})
it('should enable inputs when isDynamicRegistration is false', () => {
render(<AuthenticationSection {...defaultProps} isDynamicRegistration={false} />)
const inputs = screen.getAllByRole('textbox')
inputs.forEach((input) => {
expect(input).not.toBeDisabled()
})
})
})
describe('User Interactions', () => {
it('should call onDynamicRegistrationChange when switch is toggled', () => {
const onDynamicRegistrationChange = vi.fn()
render(
<AuthenticationSection
{...defaultProps}
onDynamicRegistrationChange={onDynamicRegistrationChange}
/>,
)
const switchElement = screen.getByRole('switch')
fireEvent.click(switchElement)
expect(onDynamicRegistrationChange).toHaveBeenCalled()
})
it('should call onClientIDChange when client ID input changes', () => {
const onClientIDChange = vi.fn()
render(
<AuthenticationSection
{...defaultProps}
isDynamicRegistration={false}
onClientIDChange={onClientIDChange}
/>,
)
const inputs = screen.getAllByRole('textbox')
const clientIDInput = inputs[0]
fireEvent.change(clientIDInput, { target: { value: 'new-client-id' } })
expect(onClientIDChange).toHaveBeenCalledWith('new-client-id')
})
it('should call onCredentialsChange when credentials input changes', () => {
const onCredentialsChange = vi.fn()
render(
<AuthenticationSection
{...defaultProps}
isDynamicRegistration={false}
onCredentialsChange={onCredentialsChange}
/>,
)
const inputs = screen.getAllByRole('textbox')
const credentialsInput = inputs[1]
fireEvent.change(credentialsInput, { target: { value: 'new-secret' } })
expect(onCredentialsChange).toHaveBeenCalledWith('new-secret')
})
})
describe('Props', () => {
it('should display provided clientID value', () => {
render(<AuthenticationSection {...defaultProps} clientID="my-client-123" />)
expect(screen.getByDisplayValue('my-client-123')).toBeInTheDocument()
})
it('should display provided credentials value', () => {
render(<AuthenticationSection {...defaultProps} credentials="secret-456" />)
expect(screen.getByDisplayValue('secret-456')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle empty string values', () => {
render(<AuthenticationSection {...defaultProps} clientID="" credentials="" />)
const inputs = screen.getAllByRole('textbox')
expect(inputs).toHaveLength(2)
inputs.forEach((input) => {
expect(input).toHaveValue('')
})
})
it('should handle special characters in values', () => {
render(
<AuthenticationSection
{...defaultProps}
clientID="client@123!#$"
credentials="secret&*()_+"
/>,
)
expect(screen.getByDisplayValue('client@123!#$')).toBeInTheDocument()
expect(screen.getByDisplayValue('secret&*()_+')).toBeInTheDocument()
})
})
})

View File

@ -1,78 +0,0 @@
'use client'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import AlertTriangle from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback/AlertTriangle'
import Input from '@/app/components/base/input'
import Switch from '@/app/components/base/switch'
import { API_PREFIX } from '@/config'
import { cn } from '@/utils/classnames'
type AuthenticationSectionProps = {
isDynamicRegistration: boolean
onDynamicRegistrationChange: (value: boolean) => void
clientID: string
onClientIDChange: (value: string) => void
credentials: string
onCredentialsChange: (value: string) => void
}
const AuthenticationSection: FC<AuthenticationSectionProps> = ({
isDynamicRegistration,
onDynamicRegistrationChange,
clientID,
onClientIDChange,
credentials,
onCredentialsChange,
}) => {
const { t } = useTranslation()
return (
<>
<div>
<div className="mb-1 flex h-6 items-center">
<Switch
className="mr-2"
defaultValue={isDynamicRegistration}
onChange={onDynamicRegistrationChange}
/>
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.useDynamicClientRegistration', { ns: 'tools' })}</span>
</div>
{!isDynamicRegistration && (
<div className="mt-2 flex gap-2 rounded-lg bg-state-warning-hover p-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-text-warning" />
<div className="system-xs-regular text-text-secondary">
<div className="mb-1">{t('mcp.modal.redirectUrlWarning', { ns: 'tools' })}</div>
<code className="system-xs-medium block break-all rounded bg-state-warning-active px-2 py-1 text-text-secondary">
{`${API_PREFIX}/mcp/oauth/callback`}
</code>
</div>
</div>
)}
</div>
<div>
<div className={cn('mb-1 flex h-6 items-center', isDynamicRegistration && 'opacity-50')}>
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.clientID', { ns: 'tools' })}</span>
</div>
<Input
value={clientID}
onChange={e => onClientIDChange(e.target.value)}
placeholder={t('mcp.modal.clientID', { ns: 'tools' })}
disabled={isDynamicRegistration}
/>
</div>
<div>
<div className={cn('mb-1 flex h-6 items-center', isDynamicRegistration && 'opacity-50')}>
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.clientSecret', { ns: 'tools' })}</span>
</div>
<Input
value={credentials}
onChange={e => onCredentialsChange(e.target.value)}
placeholder={t('mcp.modal.clientSecretPlaceholder', { ns: 'tools' })}
disabled={isDynamicRegistration}
/>
</div>
</>
)
}
export default AuthenticationSection

View File

@ -1,100 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import ConfigurationsSection from './configurations-section'
describe('ConfigurationsSection', () => {
const defaultProps = {
timeout: 30,
onTimeoutChange: vi.fn(),
sseReadTimeout: 300,
onSseReadTimeoutChange: vi.fn(),
}
describe('Rendering', () => {
it('should render without crashing', () => {
render(<ConfigurationsSection {...defaultProps} />)
expect(screen.getByDisplayValue('30')).toBeInTheDocument()
expect(screen.getByDisplayValue('300')).toBeInTheDocument()
})
it('should render timeout input with correct value', () => {
render(<ConfigurationsSection {...defaultProps} />)
const timeoutInput = screen.getByDisplayValue('30')
expect(timeoutInput).toHaveAttribute('type', 'number')
})
it('should render SSE read timeout input with correct value', () => {
render(<ConfigurationsSection {...defaultProps} />)
const sseInput = screen.getByDisplayValue('300')
expect(sseInput).toHaveAttribute('type', 'number')
})
it('should render labels for both inputs', () => {
render(<ConfigurationsSection {...defaultProps} />)
// i18n keys are rendered as-is in test environment
expect(screen.getByText('tools.mcp.modal.timeout')).toBeInTheDocument()
expect(screen.getByText('tools.mcp.modal.sseReadTimeout')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should display custom timeout value', () => {
render(<ConfigurationsSection {...defaultProps} timeout={60} />)
expect(screen.getByDisplayValue('60')).toBeInTheDocument()
})
it('should display custom SSE read timeout value', () => {
render(<ConfigurationsSection {...defaultProps} sseReadTimeout={600} />)
expect(screen.getByDisplayValue('600')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onTimeoutChange when timeout input changes', () => {
const onTimeoutChange = vi.fn()
render(<ConfigurationsSection {...defaultProps} onTimeoutChange={onTimeoutChange} />)
const timeoutInput = screen.getByDisplayValue('30')
fireEvent.change(timeoutInput, { target: { value: '45' } })
expect(onTimeoutChange).toHaveBeenCalledWith(45)
})
it('should call onSseReadTimeoutChange when SSE timeout input changes', () => {
const onSseReadTimeoutChange = vi.fn()
render(<ConfigurationsSection {...defaultProps} onSseReadTimeoutChange={onSseReadTimeoutChange} />)
const sseInput = screen.getByDisplayValue('300')
fireEvent.change(sseInput, { target: { value: '500' } })
expect(onSseReadTimeoutChange).toHaveBeenCalledWith(500)
})
it('should handle numeric conversion correctly', () => {
const onTimeoutChange = vi.fn()
render(<ConfigurationsSection {...defaultProps} onTimeoutChange={onTimeoutChange} />)
const timeoutInput = screen.getByDisplayValue('30')
fireEvent.change(timeoutInput, { target: { value: '0' } })
expect(onTimeoutChange).toHaveBeenCalledWith(0)
})
})
describe('Edge Cases', () => {
it('should handle zero timeout value', () => {
render(<ConfigurationsSection {...defaultProps} timeout={0} />)
expect(screen.getByDisplayValue('0')).toBeInTheDocument()
})
it('should handle zero SSE read timeout value', () => {
render(<ConfigurationsSection {...defaultProps} sseReadTimeout={0} />)
expect(screen.getByDisplayValue('0')).toBeInTheDocument()
})
it('should handle large timeout values', () => {
render(<ConfigurationsSection {...defaultProps} timeout={9999} sseReadTimeout={9999} />)
expect(screen.getAllByDisplayValue('9999')).toHaveLength(2)
})
})
})

View File

@ -1,49 +0,0 @@
'use client'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
type ConfigurationsSectionProps = {
timeout: number
onTimeoutChange: (timeout: number) => void
sseReadTimeout: number
onSseReadTimeoutChange: (timeout: number) => void
}
const ConfigurationsSection: FC<ConfigurationsSectionProps> = ({
timeout,
onTimeoutChange,
sseReadTimeout,
onSseReadTimeoutChange,
}) => {
const { t } = useTranslation()
return (
<>
<div>
<div className="mb-1 flex h-6 items-center">
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.timeout', { ns: 'tools' })}</span>
</div>
<Input
type="number"
value={timeout}
onChange={e => onTimeoutChange(Number(e.target.value))}
placeholder={t('mcp.modal.timeoutPlaceholder', { ns: 'tools' })}
/>
</div>
<div>
<div className="mb-1 flex h-6 items-center">
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.sseReadTimeout', { ns: 'tools' })}</span>
</div>
<Input
type="number"
value={sseReadTimeout}
onChange={e => onSseReadTimeoutChange(Number(e.target.value))}
placeholder={t('mcp.modal.timeoutPlaceholder', { ns: 'tools' })}
/>
</div>
</>
)
}
export default ConfigurationsSection

View File

@ -1,192 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import HeadersSection from './headers-section'
describe('HeadersSection', () => {
const defaultProps = {
headers: [],
onHeadersChange: vi.fn(),
isCreate: true,
}
describe('Rendering', () => {
it('should render without crashing', () => {
render(<HeadersSection {...defaultProps} />)
expect(screen.getByText('tools.mcp.modal.headers')).toBeInTheDocument()
})
it('should render headers label', () => {
render(<HeadersSection {...defaultProps} />)
expect(screen.getByText('tools.mcp.modal.headers')).toBeInTheDocument()
})
it('should render headers tip', () => {
render(<HeadersSection {...defaultProps} />)
expect(screen.getByText('tools.mcp.modal.headersTip')).toBeInTheDocument()
})
it('should render empty state when no headers', () => {
render(<HeadersSection {...defaultProps} headers={[]} />)
expect(screen.getByText('tools.mcp.modal.noHeaders')).toBeInTheDocument()
})
it('should render add header button when empty', () => {
render(<HeadersSection {...defaultProps} headers={[]} />)
expect(screen.getByText('tools.mcp.modal.addHeader')).toBeInTheDocument()
})
})
describe('With Headers', () => {
const headersWithItems = [
{ id: '1', key: 'Authorization', value: 'Bearer token123' },
{ id: '2', key: 'Content-Type', value: 'application/json' },
]
it('should render header items', () => {
render(<HeadersSection {...defaultProps} headers={headersWithItems} />)
expect(screen.getByDisplayValue('Authorization')).toBeInTheDocument()
expect(screen.getByDisplayValue('Bearer token123')).toBeInTheDocument()
expect(screen.getByDisplayValue('Content-Type')).toBeInTheDocument()
expect(screen.getByDisplayValue('application/json')).toBeInTheDocument()
})
it('should render table headers', () => {
render(<HeadersSection {...defaultProps} headers={headersWithItems} />)
expect(screen.getByText('tools.mcp.modal.headerKey')).toBeInTheDocument()
expect(screen.getByText('tools.mcp.modal.headerValue')).toBeInTheDocument()
})
it('should show masked tip when not isCreate and has headers with content', () => {
render(
<HeadersSection
{...defaultProps}
isCreate={false}
headers={headersWithItems}
/>,
)
expect(screen.getByText('tools.mcp.modal.maskedHeadersTip')).toBeInTheDocument()
})
it('should not show masked tip when isCreate is true', () => {
render(
<HeadersSection
{...defaultProps}
isCreate={true}
headers={headersWithItems}
/>,
)
expect(screen.queryByText('tools.mcp.modal.maskedHeadersTip')).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onHeadersChange when adding a header', () => {
const onHeadersChange = vi.fn()
render(<HeadersSection {...defaultProps} onHeadersChange={onHeadersChange} />)
const addButton = screen.getByText('tools.mcp.modal.addHeader')
fireEvent.click(addButton)
expect(onHeadersChange).toHaveBeenCalled()
const calledWithHeaders = onHeadersChange.mock.calls[0][0]
expect(calledWithHeaders).toHaveLength(1)
expect(calledWithHeaders[0]).toHaveProperty('id')
expect(calledWithHeaders[0]).toHaveProperty('key', '')
expect(calledWithHeaders[0]).toHaveProperty('value', '')
})
it('should call onHeadersChange when editing header key', () => {
const onHeadersChange = vi.fn()
const headers = [{ id: '1', key: '', value: '' }]
render(
<HeadersSection
{...defaultProps}
headers={headers}
onHeadersChange={onHeadersChange}
/>,
)
const inputs = screen.getAllByRole('textbox')
const keyInput = inputs[0]
fireEvent.change(keyInput, { target: { value: 'X-Custom-Header' } })
expect(onHeadersChange).toHaveBeenCalled()
})
it('should call onHeadersChange when editing header value', () => {
const onHeadersChange = vi.fn()
const headers = [{ id: '1', key: 'X-Custom-Header', value: '' }]
render(
<HeadersSection
{...defaultProps}
headers={headers}
onHeadersChange={onHeadersChange}
/>,
)
const inputs = screen.getAllByRole('textbox')
const valueInput = inputs[1]
fireEvent.change(valueInput, { target: { value: 'custom-value' } })
expect(onHeadersChange).toHaveBeenCalled()
})
it('should call onHeadersChange when removing a header', () => {
const onHeadersChange = vi.fn()
const headers = [{ id: '1', key: 'X-Header', value: 'value' }]
render(
<HeadersSection
{...defaultProps}
headers={headers}
onHeadersChange={onHeadersChange}
/>,
)
// Find and click the delete button
const deleteButton = screen.getByRole('button', { name: '' })
fireEvent.click(deleteButton)
expect(onHeadersChange).toHaveBeenCalledWith([])
})
})
describe('Props', () => {
it('should pass isCreate=true correctly (no masking)', () => {
const headers = [{ id: '1', key: 'Header', value: 'Value' }]
render(<HeadersSection {...defaultProps} isCreate={true} headers={headers} />)
expect(screen.queryByText('tools.mcp.modal.maskedHeadersTip')).not.toBeInTheDocument()
})
it('should pass isCreate=false correctly (with masking)', () => {
const headers = [{ id: '1', key: 'Header', value: 'Value' }]
render(<HeadersSection {...defaultProps} isCreate={false} headers={headers} />)
expect(screen.getByText('tools.mcp.modal.maskedHeadersTip')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle headers with empty keys (no masking even when not isCreate)', () => {
const headers = [{ id: '1', key: '', value: 'Value' }]
render(<HeadersSection {...defaultProps} isCreate={false} headers={headers} />)
// Empty key headers don't trigger masking
expect(screen.queryByText('tools.mcp.modal.maskedHeadersTip')).not.toBeInTheDocument()
})
it('should handle headers with whitespace-only keys', () => {
const headers = [{ id: '1', key: ' ', value: 'Value' }]
render(<HeadersSection {...defaultProps} isCreate={false} headers={headers} />)
// Whitespace-only key doesn't count as having content
expect(screen.queryByText('tools.mcp.modal.maskedHeadersTip')).not.toBeInTheDocument()
})
it('should handle multiple headers where some have empty keys', () => {
const headers = [
{ id: '1', key: '', value: 'Value1' },
{ id: '2', key: 'ValidKey', value: 'Value2' },
]
render(<HeadersSection {...defaultProps} isCreate={false} headers={headers} />)
// At least one header has a non-empty key, so masking should apply
expect(screen.getByText('tools.mcp.modal.maskedHeadersTip')).toBeInTheDocument()
})
})
})

View File

@ -1,36 +0,0 @@
'use client'
import type { FC } from 'react'
import type { HeaderItem } from '../headers-input'
import { useTranslation } from 'react-i18next'
import HeadersInput from '../headers-input'
type HeadersSectionProps = {
headers: HeaderItem[]
onHeadersChange: (headers: HeaderItem[]) => void
isCreate: boolean
}
const HeadersSection: FC<HeadersSectionProps> = ({
headers,
onHeadersChange,
isCreate,
}) => {
const { t } = useTranslation()
return (
<div>
<div className="mb-1 flex h-6 items-center">
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.headers', { ns: 'tools' })}</span>
</div>
<div className="body-xs-regular mb-2 text-text-tertiary">{t('mcp.modal.headersTip', { ns: 'tools' })}</div>
<HeadersInput
headersItems={headers}
onChange={onHeadersChange}
readonly={false}
isMasked={!isCreate && headers.filter(item => item.key.trim()).length > 0}
/>
</div>
)
}
export default HeadersSection

View File

@ -1,6 +1,7 @@
import type {
KnowledgeBaseNodeType,
RerankingModel,
SummaryIndexSetting,
} from '../types'
import type { ValueSelector } from '@/app/components/workflow/types'
import { produce } from 'immer'
@ -246,6 +247,16 @@ export const useConfig = (id: string) => {
})
}, [handleNodeDataUpdate])
const handleSummaryIndexSettingChange = useCallback((summaryIndexSetting: SummaryIndexSetting) => {
const nodeData = getNodeData()
handleNodeDataUpdate({
summary_index_setting: {
...nodeData?.data.summary_index_setting,
...summaryIndexSetting,
},
})
}, [handleNodeDataUpdate, getNodeData])
return {
handleChunkStructureChange,
handleIndexMethodChange,
@ -260,5 +271,6 @@ export const useConfig = (id: string) => {
handleScoreThresholdChange,
handleScoreThresholdEnabledChange,
handleInputVariableChange,
handleSummaryIndexSettingChange,
}
}

View File

@ -7,6 +7,7 @@ import {
useMemo,
} from 'react'
import { useTranslation } from 'react-i18next'
import SummaryIndexSetting from '@/app/components/datasets/settings/summary-index-setting'
import { checkShowMultiModalTip } from '@/app/components/datasets/settings/utils'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
@ -51,6 +52,7 @@ const Panel: FC<NodePanelProps<KnowledgeBaseNodeType>> = ({
handleScoreThresholdChange,
handleScoreThresholdEnabledChange,
handleInputVariableChange,
handleSummaryIndexSettingChange,
} = useConfig(id)
const filterVar = useCallback((variable: Var) => {
@ -167,6 +169,22 @@ const Panel: FC<NodePanelProps<KnowledgeBaseNodeType>> = ({
<div className="pt-1">
<Split className="h-[1px]" />
</div>
{
data.indexing_technique === IndexMethodEnum.QUALIFIED
&& [ChunkStructureEnum.general, ChunkStructureEnum.parent_child].includes(data.chunk_structure)
&& (
<>
<SummaryIndexSetting
summaryIndexSetting={data.summary_index_setting}
onSummaryIndexSettingChange={handleSummaryIndexSettingChange}
readonly={nodesReadOnly}
/>
<div className="pt-1">
<Split className="h-[1px]" />
</div>
</>
)
}
<RetrievalSetting
indexMethod={data.indexing_technique}
searchMethod={data.retrieval_model.search_method}

View File

@ -42,6 +42,12 @@ export type RetrievalSetting = {
score_threshold: number
reranking_mode?: RerankingModeEnum
}
export type SummaryIndexSetting = {
enable?: boolean
model_name?: string
model_provider_name?: string
summary_prompt?: string
}
export type KnowledgeBaseNodeType = CommonNodeType & {
index_chunk_variable_selector: string[]
chunk_structure?: ChunkStructureEnum
@ -52,4 +58,5 @@ export type KnowledgeBaseNodeType = CommonNodeType & {
retrieval_model: RetrievalSetting
_embeddingModelList?: Model[]
_rerankModelList?: Model[]
summary_index_setting?: SummaryIndexSetting
}

View File

@ -2650,6 +2650,19 @@
"count": 1
}
},
"app/components/tools/mcp/mcp-service-card.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
},
"ts/no-explicit-any": {
"count": 4
}
},
"app/components/tools/mcp/modal.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 20
}
},
"app/components/tools/mcp/provider-card.tsx": {
"ts/no-explicit-any": {
"count": 3

View File

@ -31,6 +31,7 @@
"list.action.pause": "Pause",
"list.action.resume": "Resume",
"list.action.settings": "Chunking Settings",
"list.action.summary": "Generate summary",
"list.action.sync": "Sync",
"list.action.unarchive": "Unarchive",
"list.action.uploadFile": "Upload new file",
@ -75,6 +76,9 @@
"list.status.indexing": "Indexing",
"list.status.paused": "Paused",
"list.status.queuing": "Queuing",
"list.summary.generating": "Generating...",
"list.summary.generatingSummary": "Generating summary",
"list.summary.ready": "Summary ready",
"list.table.header.action": "ACTION",
"list.table.header.chunkingMode": "CHUNKING MODE",
"list.table.header.fileName": "NAME",
@ -329,5 +333,7 @@
"segment.searchResults_one": "RESULT",
"segment.searchResults_other": "RESULTS",
"segment.searchResults_zero": "RESULT",
"segment.summary": "SUMMARY",
"segment.summaryPlaceholder": "Write a brief summary for better retrieval…",
"segment.vectorHash": "Vector hash: "
}

View File

@ -39,6 +39,12 @@
"form.retrievalSettings": "Retrieval Settings",
"form.save": "Save",
"form.searchModel": "Search model",
"form.summaryAutoGen": "Summary Auto-Gen",
"form.summaryAutoGenEnableTip": "Once enabled, summaries will be generated automatically for newly added documents. Existing documents can still be summarized manually.",
"form.summaryAutoGenTip": "Summaries are automatically generated for newly added documents. Existing documents can still be summarized manually.",
"form.summaryInstructions": "Instructions",
"form.summaryInstructionsPlaceholder": "Describe the rules or style for auto-generated summaries…",
"form.summaryModel": "Summary Model",
"form.upgradeHighQualityTip": "Once upgrading to High Quality mode, reverting to Economical mode is not available",
"title": "Knowledge settings"
}

View File

@ -31,6 +31,7 @@
"list.action.pause": "暂停",
"list.action.resume": "恢复",
"list.action.settings": "分段设置",
"list.action.summary": "生成摘要",
"list.action.sync": "同步",
"list.action.unarchive": "撤销归档",
"list.action.uploadFile": "上传新文件",
@ -75,6 +76,9 @@
"list.status.indexing": "索引中",
"list.status.paused": "已暂停",
"list.status.queuing": "排队中",
"list.summary.generating": "生成中...",
"list.summary.generatingSummary": "生成摘要中",
"list.summary.ready": "摘要已生成",
"list.table.header.action": "操作",
"list.table.header.chunkingMode": "分段模式",
"list.table.header.fileName": "名称",
@ -329,5 +333,7 @@
"segment.searchResults_one": "搜索结果",
"segment.searchResults_other": "搜索结果",
"segment.searchResults_zero": "搜索结果",
"segment.summary": "摘要",
"segment.summaryPlaceholder": "写一个简短的摘要,以便更好地检索…",
"segment.vectorHash": "向量哈希:"
}

View File

@ -39,6 +39,12 @@
"form.retrievalSettings": "检索设置",
"form.save": "保存",
"form.searchModel": "搜索模型",
"form.summaryAutoGen": "摘要自动生成",
"form.summaryAutoGenEnableTip": "启用后,将自动为新添加的文档生成摘要。已有的文档仍可以手动摘要。",
"form.summaryAutoGenTip": "将自动为新添加的文档生成摘要。已有的文档仍可以手动摘要。",
"form.summaryInstructions": "指令",
"form.summaryInstructionsPlaceholder": "描述自动生成摘要的规则或风格…",
"form.summaryModel": "摘要模型",
"form.upgradeHighQualityTip": "一旦升级为高质量模式,将无法切换回经济模式。",
"title": "知识库设置"
}

View File

@ -42,6 +42,13 @@ export type IconInfo = {
icon_url?: string
}
export type SummaryIndexSetting = {
enable?: boolean
model_name?: string
model_provider_name?: string
summary_prompt?: string
}
export type DataSet = {
id: string
name: string
@ -88,6 +95,7 @@ export type DataSet = {
runtime_mode: 'rag_pipeline' | 'general'
enable_api: boolean // Indicates if the service API is enabled
is_multimodal: boolean // Indicates if the dataset supports multimodal
summary_index_setting?: SummaryIndexSetting
}
export type ExternalAPIItem = {
@ -225,7 +233,7 @@ export type IndexingEstimateResponse = {
total_price: number
currency: string
total_segments: number
preview: Array<{ content: string, child_chunks: string[] }>
preview: Array<{ content: string, child_chunks: string[], summary?: string }>
qa_preview?: QA[]
}
@ -262,6 +270,7 @@ export type ProcessRuleResponse = {
mode: ProcessMode
rules: Rules
limits: Limits
summary_index_setting?: SummaryIndexSetting
}
export type Rules = {
@ -392,6 +401,7 @@ export type InitialDocumentDetail = {
total_segments?: number
doc_form: ChunkingMode
doc_language: string
summary_index_status?: string
}
export type SimpleDocumentDetail = InitialDocumentDetail & {
@ -425,6 +435,7 @@ export type DocumentReq = {
doc_form: ChunkingMode
doc_language: string
process_rule: ProcessRule
summary_index_setting?: SummaryIndexSetting
}
export type CreateDocumentReq = DocumentReq & {
@ -467,6 +478,7 @@ export type NotionPage = {
export type ProcessRule = {
mode: ProcessMode
rules: Rules
summary_index_setting?: SummaryIndexSetting
}
export type createDocumentResponse = {
@ -575,6 +587,7 @@ export type SegmentDetailModel = {
error: string | null
stopped_at: number
answer?: string
summary?: string
child_chunks?: ChildChunkDetail[]
updated_at: number
attachments: Attachment[]
@ -618,6 +631,7 @@ export type HitTesting = {
tsne_position: TsnePosition
child_chunks: HitTestingChildChunk[] | null
files: Attachment[]
summary?: string
}
export type ExternalKnowledgeBaseHitTesting = {
@ -697,6 +711,7 @@ export type RelatedAppResponse = {
export type SegmentUpdater = {
content: string
answer?: string
summary?: string
keywords?: string[]
regenerate_child_chunks?: boolean
attachment_ids?: string[]
@ -778,6 +793,7 @@ export enum DocumentActionType {
archive = 'archive',
unArchive = 'un_archive',
delete = 'delete',
summary = 'summary',
}
export type UpdateDocumentBatchParams = {

View File

@ -107,6 +107,18 @@ export const useSyncDocument = () => {
})
}
export const useDocumentSummary = () => {
return useMutation({
mutationFn: ({ datasetId, documentIds, documentId }: UpdateDocumentBatchParams) => {
return post<CommonResponse>(`/datasets/${datasetId}/documents/generate-summary`, {
body: {
document_list: documentId ? [documentId] : documentIds!,
},
})
},
})
}
export const useSyncWebsite = () => {
return useMutation({
mutationFn: ({ datasetId, documentId }: UpdateDocumentBatchParams) => {

View File

@ -2,7 +2,7 @@
"compilerOptions": {
"incremental": true,
"target": "es2022",
"jsx": "react-jsx",
"jsx": "preserve",
"lib": [
"dom",
"dom.iterable",