mirror of
https://github.com/langgenius/dify.git
synced 2026-01-27 07:15:51 +08:00
Compare commits
23 Commits
refactor/t
...
feat/summa
| Author | SHA1 | Date | |
|---|---|---|---|
| a18d69ddcd | |||
| 865202c7d7 | |||
| 7e6c130aae | |||
| a9ec2283d9 | |||
| 51b985963b | |||
| ce81d62d40 | |||
| 513a49b969 | |||
| 0f80788cd1 | |||
| 22b69241e6 | |||
| e9d6ae7925 | |||
| 5293fbe8ba | |||
| 22974ea6b0 | |||
| 62c3f14570 | |||
| d97f2df85c | |||
| fde8efa4a2 | |||
| f02adc26e5 | |||
| 1126a2aa95 | |||
| 7c3ce7b1e6 | |||
| 830a7fb034 | |||
| 01a7dbcee8 | |||
| 4fe8d2491e | |||
| 5c2ae922bc | |||
| 13eec13a14 |
@ -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 |
@ -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"
|
||||
}
|
||||
@ -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
|
||||
@ -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'
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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" />
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -22,6 +22,7 @@ type DrawerGroupProps = {
|
||||
answer: string,
|
||||
keywords: string[],
|
||||
attachments: FileEntity[],
|
||||
summary?: string,
|
||||
needRegenerate?: boolean,
|
||||
) => Promise<void>
|
||||
isRegenerationModalOpen: boolean
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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} />)}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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'
|
||||
? (
|
||||
|
||||
228
web/app/components/datasets/settings/summary-index-setting.tsx
Normal file
228
web/app/components/datasets/settings/summary-index-setting.tsx
Normal 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)
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@ -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()
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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: "
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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": "向量哈希:"
|
||||
}
|
||||
|
||||
@ -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": "知识库设置"
|
||||
}
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"compilerOptions": {
|
||||
"incremental": true,
|
||||
"target": "es2022",
|
||||
"jsx": "react-jsx",
|
||||
"jsx": "preserve",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
|
||||
Reference in New Issue
Block a user