Compare commits

..

1 Commits

Author SHA1 Message Date
0ed0a31ed6 fix: workflow sync draft 2026-01-26 14:41:12 +08:00
119 changed files with 3905 additions and 28647 deletions

View File

@ -470,7 +470,7 @@ class AdvancedChatDraftRunLoopNodeApi(Resource):
Run draft workflow loop node
"""
current_user, _ = current_account_with_tenant()
args = LoopNodeRunPayload.model_validate(console_ns.payload or {})
args = LoopNodeRunPayload.model_validate(console_ns.payload or {}).model_dump(exclude_none=True)
try:
response = AppGenerateService.generate_single_loop(
@ -508,7 +508,7 @@ class WorkflowDraftRunLoopNodeApi(Resource):
Run draft workflow loop node
"""
current_user, _ = current_account_with_tenant()
args = LoopNodeRunPayload.model_validate(console_ns.payload or {})
args = LoopNodeRunPayload.model_validate(console_ns.payload or {}).model_dump(exclude_none=True)
try:
response = AppGenerateService.generate_single_loop(
@ -999,7 +999,6 @@ class DraftWorkflowTriggerRunApi(Resource):
if not event:
return jsonable_encoder({"status": "waiting", "retry_in": LISTENING_RETRY_IN})
workflow_args = dict(event.workflow_args)
workflow_args[SKIP_PREPARE_USER_INPUTS_KEY] = True
return helper.compact_generate_response(
AppGenerateService.generate(
@ -1148,7 +1147,6 @@ class DraftWorkflowTriggerRunAllApi(Resource):
try:
workflow_args = dict(trigger_debug_event.workflow_args)
workflow_args[SKIP_PREPARE_USER_INPUTS_KEY] = True
response = AppGenerateService.generate(
app_model=app_model,

View File

@ -1,11 +1,9 @@
from __future__ import annotations
import contextvars
import logging
import threading
import uuid
from collections.abc import Generator, Mapping
from typing import TYPE_CHECKING, Any, Literal, Union, overload
from typing import Any, Literal, Union, overload
from flask import Flask, current_app
from pydantic import ValidationError
@ -15,9 +13,6 @@ from sqlalchemy.orm import Session, sessionmaker
import contexts
from configs import dify_config
from constants import UUID_NIL
if TYPE_CHECKING:
from controllers.console.app.workflow import LoopNodeRunPayload
from core.app.app_config.features.file_upload.manager import FileUploadConfigManager
from core.app.apps.advanced_chat.app_config_manager import AdvancedChatAppConfigManager
from core.app.apps.advanced_chat.app_runner import AdvancedChatAppRunner
@ -309,7 +304,7 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
workflow: Workflow,
node_id: str,
user: Account | EndUser,
args: LoopNodeRunPayload,
args: Mapping,
streaming: bool = True,
) -> Mapping[str, Any] | Generator[str | Mapping[str, Any], Any, None]:
"""
@ -325,7 +320,7 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
if not node_id:
raise ValueError("node_id is required")
if args.inputs is None:
if args.get("inputs") is None:
raise ValueError("inputs is required")
# convert to app config
@ -343,7 +338,7 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
stream=streaming,
invoke_from=InvokeFrom.DEBUGGER,
extras={"auto_generate_conversation_name": False},
single_loop_run=AdvancedChatAppGenerateEntity.SingleLoopRunEntity(node_id=node_id, inputs=args.inputs),
single_loop_run=AdvancedChatAppGenerateEntity.SingleLoopRunEntity(node_id=node_id, inputs=args["inputs"]),
)
contexts.plugin_tool_providers.set({})
contexts.plugin_tool_providers_lock.set(threading.Lock())

View File

@ -1,11 +1,9 @@
from __future__ import annotations
import contextvars
import logging
import threading
import uuid
from collections.abc import Generator, Mapping, Sequence
from typing import TYPE_CHECKING, Any, Literal, Union, overload
from typing import Any, Literal, Union, overload
from flask import Flask, current_app
from pydantic import ValidationError
@ -42,9 +40,6 @@ from models import Account, App, EndUser, Workflow, WorkflowNodeExecutionTrigger
from models.enums import WorkflowRunTriggeredFrom
from services.workflow_draft_variable_service import DraftVarLoader, WorkflowDraftVariableService
if TYPE_CHECKING:
from controllers.console.app.workflow import LoopNodeRunPayload
SKIP_PREPARE_USER_INPUTS_KEY = "_skip_prepare_user_inputs"
logger = logging.getLogger(__name__)
@ -386,7 +381,7 @@ class WorkflowAppGenerator(BaseAppGenerator):
workflow: Workflow,
node_id: str,
user: Account | EndUser,
args: LoopNodeRunPayload,
args: Mapping[str, Any],
streaming: bool = True,
) -> Mapping[str, Any] | Generator[str | Mapping[str, Any], None, None]:
"""
@ -402,7 +397,7 @@ class WorkflowAppGenerator(BaseAppGenerator):
if not node_id:
raise ValueError("node_id is required")
if args.inputs is None:
if args.get("inputs") is None:
raise ValueError("inputs is required")
# convert to app config
@ -418,7 +413,7 @@ class WorkflowAppGenerator(BaseAppGenerator):
stream=streaming,
invoke_from=InvokeFrom.DEBUGGER,
extras={"auto_generate_conversation_name": False},
single_loop_run=WorkflowAppGenerateEntity.SingleLoopRunEntity(node_id=node_id, inputs=args.inputs or {}),
single_loop_run=WorkflowAppGenerateEntity.SingleLoopRunEntity(node_id=node_id, inputs=args["inputs"]),
workflow_execution_id=str(uuid.uuid4()),
)
contexts.plugin_tool_providers.set({})

View File

@ -1,21 +0,0 @@
from enum import StrEnum
class HostedTrialProvider(StrEnum):
"""
Enum representing hosted model provider names for trial access.
"""
OPENAI = "langgenius/openai/openai"
ANTHROPIC = "langgenius/anthropic/anthropic"
GEMINI = "langgenius/gemini/google"
X = "langgenius/x/x"
DEEPSEEK = "langgenius/deepseek/deepseek"
TONGYI = "langgenius/tongyi/tongyi"
@property
def config_key(self) -> str:
"""Return the config key used in dify_config (e.g., HOSTED_{config_key}_PAID_ENABLED)."""
if self == HostedTrialProvider.X:
return "XAI"
return self.name

View File

@ -1,8 +1,6 @@
from __future__ import annotations
import uuid
from collections.abc import Generator, Mapping
from typing import TYPE_CHECKING, Any, Union
from typing import Any, Union
from configs import dify_config
from core.app.apps.advanced_chat.app_generator import AdvancedChatAppGenerator
@ -20,9 +18,6 @@ from services.errors.app import QuotaExceededError, WorkflowIdFormatError, Workf
from services.errors.llm import InvokeRateLimitError
from services.workflow_service import WorkflowService
if TYPE_CHECKING:
from controllers.console.app.workflow import LoopNodeRunPayload
class AppGenerateService:
@classmethod
@ -170,9 +165,7 @@ class AppGenerateService:
raise ValueError(f"Invalid app mode {app_model.mode}")
@classmethod
def generate_single_loop(
cls, app_model: App, user: Account, node_id: str, args: LoopNodeRunPayload, streaming: bool = True
):
def generate_single_loop(cls, app_model: App, user: Account, node_id: str, args: Any, streaming: bool = True):
if app_model.mode == AppMode.ADVANCED_CHAT:
workflow = cls._get_workflow(app_model, InvokeFrom.DEBUGGER)
return AdvancedChatAppGenerator.convert_to_event_stream(

View File

@ -4,7 +4,6 @@ from pydantic import BaseModel, ConfigDict, Field
from configs import dify_config
from enums.cloud_plan import CloudPlan
from enums.hosted_provider import HostedTrialProvider
from services.billing_service import BillingService
from services.enterprise.enterprise_service import EnterpriseService
@ -171,7 +170,6 @@ class SystemFeatureModel(BaseModel):
plugin_installation_permission: PluginInstallationPermissionModel = PluginInstallationPermissionModel()
enable_change_email: bool = True
plugin_manager: PluginManagerModel = PluginManagerModel()
trial_models: list[str] = []
enable_trial_app: bool = False
enable_explore_banner: bool = False
@ -229,21 +227,9 @@ class FeatureService:
system_features.is_allow_register = dify_config.ALLOW_REGISTER
system_features.is_allow_create_workspace = dify_config.ALLOW_CREATE_WORKSPACE
system_features.is_email_setup = dify_config.MAIL_TYPE is not None and dify_config.MAIL_TYPE != ""
system_features.trial_models = cls._fulfill_trial_models_from_env()
system_features.enable_trial_app = dify_config.ENABLE_TRIAL_APP
system_features.enable_explore_banner = dify_config.ENABLE_EXPLORE_BANNER
@classmethod
def _fulfill_trial_models_from_env(cls) -> list[str]:
return [
provider.value
for provider in HostedTrialProvider
if (
getattr(dify_config, f"HOSTED_{provider.config_key}_PAID_ENABLED", False)
and getattr(dify_config, f"HOSTED_{provider.config_key}_TRIAL_ENABLED", False)
)
]
@classmethod
def _fulfill_params_from_env(cls, features: FeatureModel):
features.can_replace_logo = dify_config.CAN_REPLACE_LOGO

View File

@ -1,6 +1,3 @@
/**
* @vitest-environment jsdom
*/
import type { ReactNode } from 'react'
import type { ModalContextState } from '@/context/modal-context'
import type { ProviderContextState } from '@/context/provider-context'

View File

@ -1,6 +1,3 @@
/**
* @vitest-environment jsdom
*/
import type { Mock } from 'vitest'
import type { CrawlOptions, CrawlResultItem } from '@/models/datasets'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'

View File

@ -1,499 +0,0 @@
import type { DocumentContextValue } from '@/app/components/datasets/documents/detail/context'
import type { ChildChunkDetail, ChunkingMode, ParentMode } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import ChildSegmentList from './child-segment-list'
// ============================================================================
// Hoisted Mocks
// ============================================================================
const {
mockParentMode,
mockCurrChildChunk,
} = vi.hoisted(() => ({
mockParentMode: { current: 'paragraph' as ParentMode },
mockCurrChildChunk: { current: { childChunkInfo: undefined, showModal: false } as { childChunkInfo?: ChildChunkDetail, showModal: boolean } },
}))
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { count?: number, ns?: string }) => {
if (key === 'segment.childChunks')
return options?.count === 1 ? 'child chunk' : 'child chunks'
if (key === 'segment.searchResults')
return 'search results'
if (key === 'segment.edited')
return 'edited'
if (key === 'operation.add')
return 'Add'
const prefix = options?.ns ? `${options.ns}.` : ''
return `${prefix}${key}`
},
}),
}))
// Mock document context
vi.mock('../context', () => ({
useDocumentContext: (selector: (value: DocumentContextValue) => unknown) => {
const value: DocumentContextValue = {
datasetId: 'test-dataset-id',
documentId: 'test-document-id',
docForm: 'text' as ChunkingMode,
parentMode: mockParentMode.current,
}
return selector(value)
},
}))
// Mock segment list context
vi.mock('./index', () => ({
useSegmentListContext: (selector: (value: { currChildChunk: { childChunkInfo?: ChildChunkDetail, showModal: boolean } }) => unknown) => {
return selector({ currChildChunk: mockCurrChildChunk.current })
},
}))
// Mock skeleton component
vi.mock('./skeleton/full-doc-list-skeleton', () => ({
default: () => <div data-testid="full-doc-list-skeleton">Loading...</div>,
}))
// Mock Empty component
vi.mock('./common/empty', () => ({
default: ({ onClearFilter }: { onClearFilter: () => void }) => (
<div data-testid="empty-component">
<button onClick={onClearFilter}>Clear Filter</button>
</div>
),
}))
// Mock FormattedText and EditSlice
vi.mock('../../../formatted-text/formatted', () => ({
FormattedText: ({ children, className }: { children: React.ReactNode, className?: string }) => (
<div data-testid="formatted-text" className={className}>{children}</div>
),
}))
vi.mock('../../../formatted-text/flavours/edit-slice', () => ({
EditSlice: ({ label, text, onDelete, onClick, labelClassName, contentClassName }: {
label: string
text: string
onDelete: () => void
onClick: (e: React.MouseEvent) => void
labelClassName?: string
contentClassName?: string
}) => (
<div data-testid="edit-slice" onClick={onClick}>
<span data-testid="edit-slice-label" className={labelClassName}>{label}</span>
<span data-testid="edit-slice-content" className={contentClassName}>{text}</span>
<button
data-testid="delete-button"
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
>
Delete
</button>
</div>
),
}))
// ============================================================================
// Test Data Factories
// ============================================================================
const createMockChildChunk = (overrides: Partial<ChildChunkDetail> = {}): ChildChunkDetail => ({
id: `child-${Math.random().toString(36).substr(2, 9)}`,
position: 1,
segment_id: 'segment-1',
content: 'Child chunk content',
word_count: 100,
created_at: 1700000000,
updated_at: 1700000000,
type: 'automatic',
...overrides,
})
// ============================================================================
// Tests
// ============================================================================
describe('ChildSegmentList', () => {
const defaultProps = {
childChunks: [] as ChildChunkDetail[],
parentChunkId: 'parent-1',
enabled: true,
}
beforeEach(() => {
vi.clearAllMocks()
mockParentMode.current = 'paragraph'
mockCurrChildChunk.current = { childChunkInfo: undefined, showModal: false }
})
describe('Rendering', () => {
it('should render with empty child chunks', () => {
render(<ChildSegmentList {...defaultProps} />)
expect(screen.getByText(/child chunks/i)).toBeInTheDocument()
})
it('should render child chunks when provided', () => {
const childChunks = [
createMockChildChunk({ id: 'child-1', position: 1, content: 'First chunk' }),
createMockChildChunk({ id: 'child-2', position: 2, content: 'Second chunk' }),
]
render(<ChildSegmentList {...defaultProps} childChunks={childChunks} />)
// In paragraph mode, content is collapsed by default
expect(screen.getByText(/2 child chunks/i)).toBeInTheDocument()
})
it('should render total count correctly with total prop in full-doc mode', () => {
mockParentMode.current = 'full-doc'
const childChunks = [createMockChildChunk()]
// Pass inputValue="" to ensure isSearching is false
render(<ChildSegmentList {...defaultProps} childChunks={childChunks} total={5} isLoading={false} inputValue="" />)
expect(screen.getByText(/5 child chunks/i)).toBeInTheDocument()
})
it('should render loading skeleton in full-doc mode when loading', () => {
mockParentMode.current = 'full-doc'
render(<ChildSegmentList {...defaultProps} isLoading={true} />)
expect(screen.getByTestId('full-doc-list-skeleton')).toBeInTheDocument()
})
it('should not render loading skeleton when not loading', () => {
mockParentMode.current = 'full-doc'
render(<ChildSegmentList {...defaultProps} isLoading={false} />)
expect(screen.queryByTestId('full-doc-list-skeleton')).not.toBeInTheDocument()
})
})
describe('Paragraph Mode', () => {
beforeEach(() => {
mockParentMode.current = 'paragraph'
})
it('should show collapse icon in paragraph mode', () => {
const childChunks = [createMockChildChunk()]
render(<ChildSegmentList {...defaultProps} childChunks={childChunks} />)
// Check for collapse/expand behavior
const totalRow = screen.getByText(/1 child chunk/i).closest('div')
expect(totalRow).toBeInTheDocument()
})
it('should toggle collapsed state when clicked', () => {
const childChunks = [createMockChildChunk({ content: 'Test content' })]
render(<ChildSegmentList {...defaultProps} childChunks={childChunks} />)
// Initially collapsed in paragraph mode - content should not be visible
expect(screen.queryByTestId('formatted-text')).not.toBeInTheDocument()
// Find and click the toggle area
const toggleArea = screen.getByText(/1 child chunk/i).closest('div')
// Click to expand
if (toggleArea)
fireEvent.click(toggleArea)
// After expansion, content should be visible
expect(screen.getByTestId('formatted-text')).toBeInTheDocument()
})
it('should apply opacity when disabled', () => {
const { container } = render(<ChildSegmentList {...defaultProps} enabled={false} />)
const wrapper = container.firstChild
expect(wrapper).toHaveClass('opacity-50')
})
it('should not apply opacity when enabled', () => {
const { container } = render(<ChildSegmentList {...defaultProps} enabled={true} />)
const wrapper = container.firstChild
expect(wrapper).not.toHaveClass('opacity-50')
})
})
describe('Full-Doc Mode', () => {
beforeEach(() => {
mockParentMode.current = 'full-doc'
})
it('should show content by default in full-doc mode', () => {
const childChunks = [createMockChildChunk({ content: 'Full doc content' })]
render(<ChildSegmentList {...defaultProps} childChunks={childChunks} isLoading={false} />)
expect(screen.getByTestId('formatted-text')).toBeInTheDocument()
})
it('should render search input in full-doc mode', () => {
render(<ChildSegmentList {...defaultProps} inputValue="" handleInputChange={vi.fn()} />)
const input = document.querySelector('input')
expect(input).toBeInTheDocument()
})
it('should call handleInputChange when input changes', () => {
const handleInputChange = vi.fn()
render(<ChildSegmentList {...defaultProps} inputValue="" handleInputChange={handleInputChange} />)
const input = document.querySelector('input')
if (input) {
fireEvent.change(input, { target: { value: 'test search' } })
expect(handleInputChange).toHaveBeenCalledWith('test search')
}
})
it('should show search results text when searching', () => {
render(<ChildSegmentList {...defaultProps} inputValue="search term" total={3} />)
expect(screen.getByText(/3 search results/i)).toBeInTheDocument()
})
it('should show empty component when no results and searching', () => {
render(
<ChildSegmentList
{...defaultProps}
childChunks={[]}
inputValue="search term"
onClearFilter={vi.fn()}
isLoading={false}
/>,
)
expect(screen.getByTestId('empty-component')).toBeInTheDocument()
})
it('should call onClearFilter when clear button clicked in empty state', () => {
const onClearFilter = vi.fn()
render(
<ChildSegmentList
{...defaultProps}
childChunks={[]}
inputValue="search term"
onClearFilter={onClearFilter}
isLoading={false}
/>,
)
const clearButton = screen.getByText('Clear Filter')
fireEvent.click(clearButton)
expect(onClearFilter).toHaveBeenCalled()
})
})
describe('Child Chunk Items', () => {
it('should render edited label when chunk is edited', () => {
mockParentMode.current = 'full-doc'
const editedChunk = createMockChildChunk({
id: 'edited-chunk',
position: 1,
created_at: 1700000000,
updated_at: 1700000001, // Different from created_at
})
render(<ChildSegmentList {...defaultProps} childChunks={[editedChunk]} isLoading={false} />)
expect(screen.getByText(/C-1 · edited/i)).toBeInTheDocument()
})
it('should not show edited label when chunk is not edited', () => {
mockParentMode.current = 'full-doc'
const normalChunk = createMockChildChunk({
id: 'normal-chunk',
position: 2,
created_at: 1700000000,
updated_at: 1700000000, // Same as created_at
})
render(<ChildSegmentList {...defaultProps} childChunks={[normalChunk]} isLoading={false} />)
expect(screen.getByText('C-2')).toBeInTheDocument()
expect(screen.queryByText(/edited/i)).not.toBeInTheDocument()
})
it('should call onClickSlice when chunk is clicked', () => {
mockParentMode.current = 'full-doc'
const onClickSlice = vi.fn()
const chunk = createMockChildChunk({ id: 'clickable-chunk' })
render(
<ChildSegmentList
{...defaultProps}
childChunks={[chunk]}
onClickSlice={onClickSlice}
isLoading={false}
/>,
)
const editSlice = screen.getByTestId('edit-slice')
fireEvent.click(editSlice)
expect(onClickSlice).toHaveBeenCalledWith(chunk)
})
it('should call onDelete when delete button is clicked', () => {
mockParentMode.current = 'full-doc'
const onDelete = vi.fn()
const chunk = createMockChildChunk({ id: 'deletable-chunk', segment_id: 'seg-1' })
render(
<ChildSegmentList
{...defaultProps}
childChunks={[chunk]}
onDelete={onDelete}
isLoading={false}
/>,
)
const deleteButton = screen.getByTestId('delete-button')
fireEvent.click(deleteButton)
expect(onDelete).toHaveBeenCalledWith('seg-1', 'deletable-chunk')
})
it('should apply focused styles when chunk is currently selected', () => {
mockParentMode.current = 'full-doc'
const chunk = createMockChildChunk({ id: 'focused-chunk' })
mockCurrChildChunk.current = { childChunkInfo: chunk, showModal: true }
render(<ChildSegmentList {...defaultProps} childChunks={[chunk]} isLoading={false} />)
const label = screen.getByTestId('edit-slice-label')
expect(label).toHaveClass('bg-state-accent-solid')
})
})
describe('Add Button', () => {
it('should call handleAddNewChildChunk when Add button is clicked', () => {
const handleAddNewChildChunk = vi.fn()
render(
<ChildSegmentList
{...defaultProps}
handleAddNewChildChunk={handleAddNewChildChunk}
parentChunkId="parent-123"
/>,
)
const addButton = screen.getByText('Add')
fireEvent.click(addButton)
expect(handleAddNewChildChunk).toHaveBeenCalledWith('parent-123')
})
it('should disable Add button when loading in full-doc mode', () => {
mockParentMode.current = 'full-doc'
render(<ChildSegmentList {...defaultProps} isLoading={true} />)
const addButton = screen.getByText('Add')
expect(addButton).toBeDisabled()
})
it('should stop propagation when Add button is clicked', () => {
const handleAddNewChildChunk = vi.fn()
const parentClickHandler = vi.fn()
render(
<div onClick={parentClickHandler}>
<ChildSegmentList
{...defaultProps}
handleAddNewChildChunk={handleAddNewChildChunk}
/>
</div>,
)
const addButton = screen.getByText('Add')
fireEvent.click(addButton)
expect(handleAddNewChildChunk).toHaveBeenCalled()
// Parent should not be called due to stopPropagation
})
})
describe('computeTotalInfo function', () => {
it('should return search results when searching in full-doc mode', () => {
mockParentMode.current = 'full-doc'
render(<ChildSegmentList {...defaultProps} inputValue="search" total={10} />)
expect(screen.getByText(/10 search results/i)).toBeInTheDocument()
})
it('should return "--" when total is 0 in full-doc mode', () => {
mockParentMode.current = 'full-doc'
render(<ChildSegmentList {...defaultProps} total={0} />)
// When total is 0, displayText is '--'
expect(screen.getByText(/--/)).toBeInTheDocument()
})
it('should use childChunks length in paragraph mode', () => {
mockParentMode.current = 'paragraph'
const childChunks = [
createMockChildChunk(),
createMockChildChunk(),
createMockChildChunk(),
]
render(<ChildSegmentList {...defaultProps} childChunks={childChunks} />)
expect(screen.getByText(/3 child chunks/i)).toBeInTheDocument()
})
})
describe('Focused State', () => {
it('should not apply opacity when focused even if disabled', () => {
const { container } = render(
<ChildSegmentList {...defaultProps} enabled={false} focused={true} />,
)
const wrapper = container.firstChild
expect(wrapper).not.toHaveClass('opacity-50')
})
})
describe('Input clear button', () => {
it('should call handleInputChange with empty string when clear is clicked', () => {
mockParentMode.current = 'full-doc'
const handleInputChange = vi.fn()
render(
<ChildSegmentList
{...defaultProps}
inputValue="test"
handleInputChange={handleInputChange}
/>,
)
// Find the clear button (it's the showClearIcon button in Input)
const input = document.querySelector('input')
if (input) {
// Trigger clear by simulating the input's onClear
const clearButton = document.querySelector('[class*="cursor-pointer"]')
if (clearButton)
fireEvent.click(clearButton)
}
})
})
})

View File

@ -1,7 +1,7 @@
import type { FC } from 'react'
import type { ChildChunkDetail } from '@/models/datasets'
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react'
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import Input from '@/app/components/base/input'
@ -29,37 +29,6 @@ type IChildSegmentCardProps = {
focused?: boolean
}
function computeTotalInfo(
isFullDocMode: boolean,
isSearching: boolean,
total: number | undefined,
childChunksLength: number,
): { displayText: string, count: number, translationKey: 'segment.searchResults' | 'segment.childChunks' } {
if (isSearching) {
const count = total ?? 0
return {
displayText: count === 0 ? '--' : String(formatNumber(count)),
count,
translationKey: 'segment.searchResults',
}
}
if (isFullDocMode) {
const count = total ?? 0
return {
displayText: count === 0 ? '--' : String(formatNumber(count)),
count,
translationKey: 'segment.childChunks',
}
}
return {
displayText: String(formatNumber(childChunksLength)),
count: childChunksLength,
translationKey: 'segment.childChunks',
}
}
const ChildSegmentList: FC<IChildSegmentCardProps> = ({
childChunks,
parentChunkId,
@ -80,87 +49,59 @@ const ChildSegmentList: FC<IChildSegmentCardProps> = ({
const [collapsed, setCollapsed] = useState(true)
const isParagraphMode = parentMode === 'paragraph'
const isFullDocMode = parentMode === 'full-doc'
const isSearching = inputValue !== '' && isFullDocMode
const contentOpacity = (enabled || focused) ? '' : 'opacity-50 group-hover/card:opacity-100'
const { displayText, count, translationKey } = computeTotalInfo(isFullDocMode, isSearching, total, childChunks.length)
const totalText = `${displayText} ${t(translationKey, { ns: 'datasetDocuments', count })}`
const toggleCollapse = () => setCollapsed(prev => !prev)
const showContent = (isFullDocMode && !isLoading) || !collapsed
const hoverVisibleClass = isParagraphMode ? 'hidden group-hover/card:inline-block' : ''
const renderCollapseIcon = () => {
if (!isParagraphMode)
return null
const Icon = collapsed ? RiArrowRightSLine : RiArrowDownSLine
return <Icon className={cn('mr-0.5 h-4 w-4 text-text-secondary', collapsed && 'opacity-50')} />
const toggleCollapse = () => {
setCollapsed(!collapsed)
}
const renderChildChunkItem = (childChunk: ChildChunkDetail) => {
const isEdited = childChunk.updated_at !== childChunk.created_at
const isFocused = currChildChunk?.childChunkInfo?.id === childChunk.id
const label = isEdited
? `C-${childChunk.position} · ${t('segment.edited', { ns: 'datasetDocuments' })}`
: `C-${childChunk.position}`
const isParagraphMode = useMemo(() => {
return parentMode === 'paragraph'
}, [parentMode])
return (
<EditSlice
key={childChunk.id}
label={label}
text={childChunk.content}
onDelete={() => onDelete?.(childChunk.segment_id, childChunk.id)}
className="child-chunk"
labelClassName={isFocused ? 'bg-state-accent-solid text-text-primary-on-surface' : ''}
labelInnerClassName="text-[10px] font-semibold align-bottom leading-6"
contentClassName={cn('!leading-6', isFocused ? 'bg-state-accent-hover-alt text-text-primary' : 'text-text-secondary')}
showDivider={false}
onClick={(e) => {
e.stopPropagation()
onClickSlice?.(childChunk)
}}
offsetOptions={({ rects }) => ({
mainAxis: isFullDocMode ? -rects.floating.width : 12 - rects.floating.width,
crossAxis: (20 - rects.floating.height) / 2,
})}
/>
)
}
const isFullDocMode = useMemo(() => {
return parentMode === 'full-doc'
}, [parentMode])
const renderContent = () => {
if (childChunks.length > 0) {
return (
<FormattedText className={cn('flex w-full flex-col !leading-6', isParagraphMode ? 'gap-y-2' : 'gap-y-3')}>
{childChunks.map(renderChildChunkItem)}
</FormattedText>
)
const contentOpacity = useMemo(() => {
return (enabled || focused) ? '' : 'opacity-50 group-hover/card:opacity-100'
}, [enabled, focused])
const totalText = useMemo(() => {
const isSearch = inputValue !== '' && isFullDocMode
if (!isSearch) {
const text = isFullDocMode
? !total
? '--'
: formatNumber(total)
: formatNumber(childChunks.length)
const count = isFullDocMode
? text === '--'
? 0
: total
: childChunks.length
return `${text} ${t('segment.childChunks', { ns: 'datasetDocuments', count })}`
}
if (inputValue !== '') {
return (
<div className="h-full w-full">
<Empty onClearFilter={onClearFilter!} />
</div>
)
else {
const text = !total ? '--' : formatNumber(total)
const count = text === '--' ? 0 : total
return `${count} ${t('segment.searchResults', { ns: 'datasetDocuments', count })}`
}
return null
}
}, [isFullDocMode, total, childChunks.length, inputValue])
return (
<div className={cn(
'flex flex-col',
contentOpacity,
isParagraphMode ? 'pb-2 pt-1' : 'grow px-3',
isFullDocMode && isLoading && 'overflow-y-hidden',
(isFullDocMode && isLoading) && 'overflow-y-hidden',
)}
>
{isFullDocMode && <Divider type="horizontal" className="my-1 h-px bg-divider-subtle" />}
<div className={cn('flex items-center justify-between', isFullDocMode && 'sticky -top-2 left-0 bg-background-default pb-3 pt-2')}>
{isFullDocMode ? <Divider type="horizontal" className="my-1 h-px bg-divider-subtle" /> : null}
<div className={cn('flex items-center justify-between', isFullDocMode ? 'sticky -top-2 left-0 bg-background-default pb-3 pt-2' : '')}>
<div
className={cn(
'flex h-7 items-center rounded-lg pl-1 pr-3',
isParagraphMode && 'cursor-pointer',
isParagraphMode && collapsed && 'bg-dataset-child-chunk-expand-btn-bg',
(isParagraphMode && collapsed) && 'bg-dataset-child-chunk-expand-btn-bg',
isFullDocMode && 'pl-0',
)}
onClick={(event) => {
@ -168,15 +109,23 @@ const ChildSegmentList: FC<IChildSegmentCardProps> = ({
toggleCollapse()
}}
>
{renderCollapseIcon()}
{
isParagraphMode
? collapsed
? (
<RiArrowRightSLine className="mr-0.5 h-4 w-4 text-text-secondary opacity-50" />
)
: (<RiArrowDownSLine className="mr-0.5 h-4 w-4 text-text-secondary" />)
: null
}
<span className="system-sm-semibold-uppercase text-text-secondary">{totalText}</span>
<span className={cn('pl-1.5 text-xs font-medium text-text-quaternary', hoverVisibleClass)}>·</span>
<span className={cn('pl-1.5 text-xs font-medium text-text-quaternary', isParagraphMode ? 'hidden group-hover/card:inline-block' : '')}>·</span>
<button
type="button"
className={cn(
'system-xs-semibold-uppercase px-1.5 py-1 text-components-button-secondary-accent-text',
hoverVisibleClass,
isFullDocMode && isLoading && 'text-components-button-secondary-accent-text-disabled',
isParagraphMode ? 'hidden group-hover/card:inline-block' : '',
(isFullDocMode && isLoading) ? 'text-components-button-secondary-accent-text-disabled' : '',
)}
onClick={(event) => {
event.stopPropagation()
@ -187,28 +136,70 @@ const ChildSegmentList: FC<IChildSegmentCardProps> = ({
{t('operation.add', { ns: 'common' })}
</button>
</div>
{isFullDocMode && (
<Input
showLeftIcon
showClearIcon
wrapperClassName="!w-52"
value={inputValue}
onChange={e => handleInputChange?.(e.target.value)}
onClear={() => handleInputChange?.('')}
/>
)}
{isFullDocMode
? (
<Input
showLeftIcon
showClearIcon
wrapperClassName="!w-52"
value={inputValue}
onChange={e => handleInputChange?.(e.target.value)}
onClear={() => handleInputChange?.('')}
/>
)
: null}
</div>
{isLoading && <FullDocListSkeleton />}
{showContent && (
<div className={cn('flex gap-x-0.5', isFullDocMode ? 'mb-6 grow' : 'items-center')}>
{isParagraphMode && (
<div className="self-stretch">
<Divider type="vertical" className="mx-[7px] w-[2px] bg-text-accent-secondary" />
{isLoading ? <FullDocListSkeleton /> : null}
{((isFullDocMode && !isLoading) || !collapsed)
? (
<div className={cn('flex gap-x-0.5', isFullDocMode ? 'mb-6 grow' : 'items-center')}>
{isParagraphMode && (
<div className="self-stretch">
<Divider type="vertical" className="mx-[7px] w-[2px] bg-text-accent-secondary" />
</div>
)}
{childChunks.length > 0
? (
<FormattedText className={cn('flex w-full flex-col !leading-6', isParagraphMode ? 'gap-y-2' : 'gap-y-3')}>
{childChunks.map((childChunk) => {
const edited = childChunk.updated_at !== childChunk.created_at
const focused = currChildChunk?.childChunkInfo?.id === childChunk.id
return (
<EditSlice
key={childChunk.id}
label={`C-${childChunk.position}${edited ? ` · ${t('segment.edited', { ns: 'datasetDocuments' })}` : ''}`}
text={childChunk.content}
onDelete={() => onDelete?.(childChunk.segment_id, childChunk.id)}
className="child-chunk"
labelClassName={focused ? 'bg-state-accent-solid text-text-primary-on-surface' : ''}
labelInnerClassName="text-[10px] font-semibold align-bottom leading-6"
contentClassName={cn('!leading-6', focused ? 'bg-state-accent-hover-alt text-text-primary' : 'text-text-secondary')}
showDivider={false}
onClick={(e) => {
e.stopPropagation()
onClickSlice?.(childChunk)
}}
offsetOptions={({ rects }) => {
return {
mainAxis: isFullDocMode ? -rects.floating.width : 12 - rects.floating.width,
crossAxis: (20 - rects.floating.height) / 2,
}
}}
/>
)
})}
</FormattedText>
)
: inputValue !== ''
? (
<div className="h-full w-full">
<Empty onClearFilter={onClearFilter!} />
</div>
)
: null}
</div>
)}
{renderContent()}
</div>
)}
)
: null}
</div>
)
}

View File

@ -17,31 +17,6 @@ type DrawerProps = {
needCheckChunks?: boolean
}
const SIDE_POSITION_CLASS = {
right: 'right-0',
left: 'left-0',
bottom: 'bottom-0',
top: 'top-0',
} as const
function containsTarget(selector: string, target: Node | null): boolean {
const elements = document.querySelectorAll(selector)
return Array.from(elements).some(el => el?.contains(target))
}
function shouldReopenChunkDetail(
isClickOnChunk: boolean,
isClickOnChildChunk: boolean,
segmentModalOpen: boolean,
childChunkModalOpen: boolean,
): boolean {
if (segmentModalOpen && isClickOnChildChunk)
return true
if (childChunkModalOpen && isClickOnChunk && !isClickOnChildChunk)
return true
return !isClickOnChunk && !isClickOnChildChunk
}
const Drawer = ({
open,
onClose,
@ -66,22 +41,22 @@ const Drawer = ({
const shouldCloseDrawer = useCallback((target: Node | null) => {
const panelContent = panelContentRef.current
if (!panelContent || !target)
if (!panelContent)
return false
if (panelContent.contains(target))
return false
if (containsTarget('.image-previewer', target))
return false
if (!needCheckChunks)
return true
const isClickOnChunk = containsTarget('.chunk-card', target)
const isClickOnChildChunk = containsTarget('.child-chunk', target)
return shouldReopenChunkDetail(isClickOnChunk, isClickOnChildChunk, currSegment.showModal, currChildChunk.showModal)
}, [currSegment.showModal, currChildChunk.showModal, needCheckChunks])
const chunks = document.querySelectorAll('.chunk-card')
const childChunks = document.querySelectorAll('.child-chunk')
const imagePreviewer = document.querySelector('.image-previewer')
const isClickOnChunk = Array.from(chunks).some((chunk) => {
return chunk && chunk.contains(target)
})
const isClickOnChildChunk = Array.from(childChunks).some((chunk) => {
return chunk && chunk.contains(target)
})
const reopenChunkDetail = (currSegment.showModal && isClickOnChildChunk)
|| (currChildChunk.showModal && isClickOnChunk && !isClickOnChildChunk) || (!isClickOnChunk && !isClickOnChildChunk)
const isClickOnImagePreviewer = imagePreviewer && imagePreviewer.contains(target)
return target && !panelContent.contains(target) && (!needCheckChunks || reopenChunkDetail) && !isClickOnImagePreviewer
}, [currSegment, currChildChunk, needCheckChunks])
const onDownCapture = useCallback((e: PointerEvent) => {
if (!open || modal)
@ -102,27 +77,32 @@ const Drawer = ({
const isHorizontal = side === 'left' || side === 'right'
const overlayPointerEvents = modal && open ? 'pointer-events-auto' : 'pointer-events-none'
const content = (
<div className="pointer-events-none fixed inset-0 z-[9999]">
{showOverlay && (
<div
onClick={modal ? onClose : undefined}
aria-hidden="true"
className={cn(
'fixed inset-0 bg-black/30 opacity-0 transition-opacity duration-200 ease-in',
open && 'opacity-100',
overlayPointerEvents,
)}
/>
)}
{showOverlay
? (
<div
onClick={modal ? onClose : undefined}
aria-hidden="true"
className={cn(
'fixed inset-0 bg-black/30 opacity-0 transition-opacity duration-200 ease-in',
open && 'opacity-100',
modal && open ? 'pointer-events-auto' : 'pointer-events-none',
)}
/>
)
: null}
{/* Drawer panel */}
<div
role="dialog"
aria-modal={modal ? 'true' : 'false'}
className={cn(
'pointer-events-auto fixed flex flex-col',
SIDE_POSITION_CLASS[side],
side === 'right' && 'right-0',
side === 'left' && 'left-0',
side === 'bottom' && 'bottom-0',
side === 'top' && 'top-0',
isHorizontal ? 'h-screen' : 'w-screen',
panelClassName,
)}
@ -134,10 +114,7 @@ const Drawer = ({
</div>
)
if (!open)
return null
return createPortal(content, document.body)
return open && createPortal(content, document.body)
}
export default Drawer

View File

@ -1,129 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import Empty from './empty'
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
if (key === 'segment.empty')
return 'No results found'
if (key === 'segment.clearFilter')
return 'Clear Filter'
return key
},
}),
}))
describe('Empty Component', () => {
const defaultProps = {
onClearFilter: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render empty state message', () => {
render(<Empty {...defaultProps} />)
expect(screen.getByText('No results found')).toBeInTheDocument()
})
it('should render clear filter button', () => {
render(<Empty {...defaultProps} />)
expect(screen.getByText('Clear Filter')).toBeInTheDocument()
})
it('should render icon', () => {
const { container } = render(<Empty {...defaultProps} />)
// Check for the icon container
const iconContainer = container.querySelector('.shadow-lg')
expect(iconContainer).toBeInTheDocument()
})
it('should render decorative lines', () => {
const { container } = render(<Empty {...defaultProps} />)
// Check for SVG lines
const svgs = container.querySelectorAll('svg')
expect(svgs.length).toBeGreaterThan(0)
})
it('should render background cards', () => {
const { container } = render(<Empty {...defaultProps} />)
// Check for background empty cards (10 of them)
const backgroundCards = container.querySelectorAll('.rounded-xl.bg-background-section-burn')
expect(backgroundCards.length).toBe(10)
})
it('should render mask overlay', () => {
const { container } = render(<Empty {...defaultProps} />)
const maskOverlay = container.querySelector('.bg-dataset-chunk-list-mask-bg')
expect(maskOverlay).toBeInTheDocument()
})
})
describe('Interactions', () => {
it('should call onClearFilter when clear filter button is clicked', () => {
const onClearFilter = vi.fn()
render(<Empty onClearFilter={onClearFilter} />)
const clearButton = screen.getByText('Clear Filter')
fireEvent.click(clearButton)
expect(onClearFilter).toHaveBeenCalledTimes(1)
})
})
describe('Memoization', () => {
it('should be memoized', () => {
// Empty is wrapped with React.memo
const { rerender } = render(<Empty {...defaultProps} />)
// Same props should not cause re-render issues
rerender(<Empty {...defaultProps} />)
expect(screen.getByText('No results found')).toBeInTheDocument()
})
})
})
describe('EmptyCard Component', () => {
it('should render within Empty component', () => {
const { container } = render(<Empty onClearFilter={vi.fn()} />)
// EmptyCard renders as background cards
const emptyCards = container.querySelectorAll('.h-32.w-full')
expect(emptyCards.length).toBe(10)
})
it('should have correct opacity', () => {
const { container } = render(<Empty onClearFilter={vi.fn()} />)
const emptyCards = container.querySelectorAll('.opacity-30')
expect(emptyCards.length).toBe(10)
})
})
describe('Line Component', () => {
it('should render SVG lines within Empty component', () => {
const { container } = render(<Empty onClearFilter={vi.fn()} />)
// Line components render as SVG elements (4 Line components + 1 icon SVG)
const lines = container.querySelectorAll('svg')
expect(lines.length).toBeGreaterThanOrEqual(4)
})
it('should have gradient definition', () => {
const { container } = render(<Empty onClearFilter={vi.fn()} />)
const gradients = container.querySelectorAll('linearGradient')
expect(gradients.length).toBeGreaterThan(0)
})
})

View File

@ -1,151 +0,0 @@
'use client'
import type { FC } from 'react'
import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types'
import type { ChildChunkDetail, ChunkingMode, SegmentDetailModel } from '@/models/datasets'
import NewSegment from '@/app/components/datasets/documents/detail/new-segment'
import ChildSegmentDetail from '../child-segment-detail'
import FullScreenDrawer from '../common/full-screen-drawer'
import NewChildSegment from '../new-child-segment'
import SegmentDetail from '../segment-detail'
type DrawerGroupProps = {
// Segment detail drawer
currSegment: {
segInfo?: SegmentDetailModel
showModal: boolean
isEditMode?: boolean
}
onCloseSegmentDetail: () => void
onUpdateSegment: (
segmentId: string,
question: string,
answer: string,
keywords: string[],
attachments: FileEntity[],
needRegenerate?: boolean,
) => Promise<void>
isRegenerationModalOpen: boolean
setIsRegenerationModalOpen: (open: boolean) => void
// New segment drawer
showNewSegmentModal: boolean
onCloseNewSegmentModal: () => void
onSaveNewSegment: () => void
viewNewlyAddedChunk: () => void
// Child segment detail drawer
currChildChunk: {
childChunkInfo?: ChildChunkDetail
showModal: boolean
}
currChunkId: string
onCloseChildSegmentDetail: () => void
onUpdateChildChunk: (segmentId: string, childChunkId: string, content: string) => Promise<void>
// New child segment drawer
showNewChildSegmentModal: boolean
onCloseNewChildChunkModal: () => void
onSaveNewChildChunk: (newChildChunk?: ChildChunkDetail) => void
viewNewlyAddedChildChunk: () => void
// Common props
fullScreen: boolean
docForm: ChunkingMode
}
const DrawerGroup: FC<DrawerGroupProps> = ({
// Segment detail drawer
currSegment,
onCloseSegmentDetail,
onUpdateSegment,
isRegenerationModalOpen,
setIsRegenerationModalOpen,
// New segment drawer
showNewSegmentModal,
onCloseNewSegmentModal,
onSaveNewSegment,
viewNewlyAddedChunk,
// Child segment detail drawer
currChildChunk,
currChunkId,
onCloseChildSegmentDetail,
onUpdateChildChunk,
// New child segment drawer
showNewChildSegmentModal,
onCloseNewChildChunkModal,
onSaveNewChildChunk,
viewNewlyAddedChildChunk,
// Common props
fullScreen,
docForm,
}) => {
return (
<>
{/* Edit or view segment detail */}
<FullScreenDrawer
isOpen={currSegment.showModal}
fullScreen={fullScreen}
onClose={onCloseSegmentDetail}
showOverlay={false}
needCheckChunks
modal={isRegenerationModalOpen}
>
<SegmentDetail
key={currSegment.segInfo?.id}
segInfo={currSegment.segInfo ?? { id: '' }}
docForm={docForm}
isEditMode={currSegment.isEditMode}
onUpdate={onUpdateSegment}
onCancel={onCloseSegmentDetail}
onModalStateChange={setIsRegenerationModalOpen}
/>
</FullScreenDrawer>
{/* Create New Segment */}
<FullScreenDrawer
isOpen={showNewSegmentModal}
fullScreen={fullScreen}
onClose={onCloseNewSegmentModal}
modal
>
<NewSegment
docForm={docForm}
onCancel={onCloseNewSegmentModal}
onSave={onSaveNewSegment}
viewNewlyAddedChunk={viewNewlyAddedChunk}
/>
</FullScreenDrawer>
{/* Edit or view child segment detail */}
<FullScreenDrawer
isOpen={currChildChunk.showModal}
fullScreen={fullScreen}
onClose={onCloseChildSegmentDetail}
showOverlay={false}
needCheckChunks
>
<ChildSegmentDetail
key={currChildChunk.childChunkInfo?.id}
chunkId={currChunkId}
childChunkInfo={currChildChunk.childChunkInfo ?? { id: '' }}
docForm={docForm}
onUpdate={onUpdateChildChunk}
onCancel={onCloseChildSegmentDetail}
/>
</FullScreenDrawer>
{/* Create New Child Segment */}
<FullScreenDrawer
isOpen={showNewChildSegmentModal}
fullScreen={fullScreen}
onClose={onCloseNewChildChunkModal}
modal
>
<NewChildSegment
chunkId={currChunkId}
onCancel={onCloseNewChildChunkModal}
onSave={onSaveNewChildChunk}
viewNewlyAddedChildChunk={viewNewlyAddedChildChunk}
/>
</FullScreenDrawer>
</>
)
}
export default DrawerGroup

View File

@ -1,3 +0,0 @@
export { default as DrawerGroup } from './drawer-group'
export { default as MenuBar } from './menu-bar'
export { FullDocModeContent, GeneralModeContent } from './segment-list-content'

View File

@ -1,76 +0,0 @@
'use client'
import type { FC } from 'react'
import type { Item } from '@/app/components/base/select'
import Checkbox from '@/app/components/base/checkbox'
import Divider from '@/app/components/base/divider'
import Input from '@/app/components/base/input'
import { SimpleSelect } from '@/app/components/base/select'
import DisplayToggle from '../display-toggle'
import StatusItem from '../status-item'
import s from '../style.module.css'
type MenuBarProps = {
isAllSelected: boolean
isSomeSelected: boolean
onSelectedAll: () => void
isLoading: boolean
totalText: string
statusList: Item[]
selectDefaultValue: 'all' | 0 | 1
onChangeStatus: (item: Item) => void
inputValue: string
onInputChange: (value: string) => void
isCollapsed: boolean
toggleCollapsed: () => void
}
const MenuBar: FC<MenuBarProps> = ({
isAllSelected,
isSomeSelected,
onSelectedAll,
isLoading,
totalText,
statusList,
selectDefaultValue,
onChangeStatus,
inputValue,
onInputChange,
isCollapsed,
toggleCollapsed,
}) => {
return (
<div className={s.docSearchWrapper}>
<Checkbox
className="shrink-0"
checked={isAllSelected}
indeterminate={!isAllSelected && isSomeSelected}
onCheck={onSelectedAll}
disabled={isLoading}
/>
<div className="system-sm-semibold-uppercase flex-1 pl-5 text-text-secondary">{totalText}</div>
<SimpleSelect
onSelect={onChangeStatus}
items={statusList}
defaultValue={selectDefaultValue}
className={s.select}
wrapperClassName="h-fit mr-2"
optionWrapClassName="w-[160px]"
optionClassName="p-0"
renderOption={({ item, selected }) => <StatusItem item={item} selected={selected} />}
notClearable
/>
<Input
showLeftIcon
showClearIcon
wrapperClassName="!w-52"
value={inputValue}
onChange={e => onInputChange(e.target.value)}
onClear={() => onInputChange('')}
/>
<Divider type="vertical" className="mx-3 h-3.5" />
<DisplayToggle isCollapsed={isCollapsed} toggleCollapsed={toggleCollapsed} />
</div>
)
}
export default MenuBar

View File

@ -1,127 +0,0 @@
'use client'
import type { FC } from 'react'
import type { ChildChunkDetail, SegmentDetailModel } from '@/models/datasets'
import { cn } from '@/utils/classnames'
import ChildSegmentList from '../child-segment-list'
import SegmentCard from '../segment-card'
import SegmentList from '../segment-list'
type FullDocModeContentProps = {
segments: SegmentDetailModel[]
childSegments: ChildChunkDetail[]
isLoadingSegmentList: boolean
isLoadingChildSegmentList: boolean
currSegmentId?: string
onClickCard: (detail: SegmentDetailModel, isEditMode?: boolean) => void
onDeleteChildChunk: (segmentId: string, childChunkId: string) => Promise<void>
handleInputChange: (value: string) => void
handleAddNewChildChunk: (parentChunkId: string) => void
onClickSlice: (detail: ChildChunkDetail) => void
archived?: boolean
childChunkTotal: number
inputValue: string
onClearFilter: () => void
}
export const FullDocModeContent: FC<FullDocModeContentProps> = ({
segments,
childSegments,
isLoadingSegmentList,
isLoadingChildSegmentList,
currSegmentId,
onClickCard,
onDeleteChildChunk,
handleInputChange,
handleAddNewChildChunk,
onClickSlice,
archived,
childChunkTotal,
inputValue,
onClearFilter,
}) => {
const firstSegment = segments[0]
return (
<div className={cn(
'flex grow flex-col overflow-x-hidden',
(isLoadingSegmentList || isLoadingChildSegmentList) ? 'overflow-y-hidden' : 'overflow-y-auto',
)}
>
<SegmentCard
detail={firstSegment}
onClick={() => onClickCard(firstSegment)}
loading={isLoadingSegmentList}
focused={{
segmentIndex: currSegmentId === firstSegment?.id,
segmentContent: currSegmentId === firstSegment?.id,
}}
/>
<ChildSegmentList
parentChunkId={firstSegment?.id}
onDelete={onDeleteChildChunk}
childChunks={childSegments}
handleInputChange={handleInputChange}
handleAddNewChildChunk={handleAddNewChildChunk}
onClickSlice={onClickSlice}
enabled={!archived}
total={childChunkTotal}
inputValue={inputValue}
onClearFilter={onClearFilter}
isLoading={isLoadingSegmentList || isLoadingChildSegmentList}
/>
</div>
)
}
type GeneralModeContentProps = {
segmentListRef: React.RefObject<HTMLDivElement | null>
embeddingAvailable: boolean
isLoadingSegmentList: boolean
segments: SegmentDetailModel[]
selectedSegmentIds: string[]
onSelected: (segId: string) => void
onChangeSwitch: (enable: boolean, segId?: string) => Promise<void>
onDelete: (segId?: string) => Promise<void>
onClickCard: (detail: SegmentDetailModel, isEditMode?: boolean) => void
archived?: boolean
onDeleteChildChunk: (segmentId: string, childChunkId: string) => Promise<void>
handleAddNewChildChunk: (parentChunkId: string) => void
onClickSlice: (detail: ChildChunkDetail) => void
onClearFilter: () => void
}
export const GeneralModeContent: FC<GeneralModeContentProps> = ({
segmentListRef,
embeddingAvailable,
isLoadingSegmentList,
segments,
selectedSegmentIds,
onSelected,
onChangeSwitch,
onDelete,
onClickCard,
archived,
onDeleteChildChunk,
handleAddNewChildChunk,
onClickSlice,
onClearFilter,
}) => {
return (
<SegmentList
ref={segmentListRef}
embeddingAvailable={embeddingAvailable}
isLoading={isLoadingSegmentList}
items={segments}
selectedSegmentIds={selectedSegmentIds}
onSelected={onSelected}
onChangeSwitch={onChangeSwitch}
onDelete={onDelete}
onClick={onClickCard}
archived={archived}
onDeleteChildChunk={onDeleteChildChunk}
handleAddNewChildChunk={handleAddNewChildChunk}
onClickSlice={onClickSlice}
onClearFilter={onClearFilter}
/>
)
}

View File

@ -1,14 +0,0 @@
export { useChildSegmentData } from './use-child-segment-data'
export type { UseChildSegmentDataReturn } from './use-child-segment-data'
export { useModalState } from './use-modal-state'
export type { CurrChildChunkType, CurrSegmentType, UseModalStateReturn } from './use-modal-state'
export { useSearchFilter } from './use-search-filter'
export type { UseSearchFilterReturn } from './use-search-filter'
export { useSegmentListData } from './use-segment-list-data'
export type { UseSegmentListDataReturn } from './use-segment-list-data'
export { useSegmentSelection } from './use-segment-selection'
export type { UseSegmentSelectionReturn } from './use-segment-selection'

View File

@ -1,568 +0,0 @@
import type { DocumentContextValue } from '@/app/components/datasets/documents/detail/context'
import type { ChildChunkDetail, ChildSegmentsResponse, ChunkingMode, ParentMode, SegmentDetailModel } from '@/models/datasets'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook } from '@testing-library/react'
import * as React from 'react'
import { useChildSegmentData } from './use-child-segment-data'
// Type for mutation callbacks
type MutationResponse = { data: ChildChunkDetail }
type MutationCallbacks = {
onSuccess: (res: MutationResponse) => void
onSettled: () => void
}
type _ErrorCallback = { onSuccess?: () => void, onError: () => void }
// ============================================================================
// Hoisted Mocks
// ============================================================================
const {
mockParentMode,
mockDatasetId,
mockDocumentId,
mockNotify,
mockEventEmitter,
mockQueryClient,
mockChildSegmentListData,
mockDeleteChildSegment,
mockUpdateChildSegment,
mockInvalidChildSegmentList,
} = vi.hoisted(() => ({
mockParentMode: { current: 'paragraph' as ParentMode },
mockDatasetId: { current: 'test-dataset-id' },
mockDocumentId: { current: 'test-document-id' },
mockNotify: vi.fn(),
mockEventEmitter: { emit: vi.fn(), on: vi.fn(), off: vi.fn() },
mockQueryClient: { setQueryData: vi.fn() },
mockChildSegmentListData: { current: { data: [] as ChildChunkDetail[], total: 0, total_pages: 0 } as ChildSegmentsResponse | undefined },
mockDeleteChildSegment: vi.fn(),
mockUpdateChildSegment: vi.fn(),
mockInvalidChildSegmentList: vi.fn(),
}))
// Mock dependencies
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
if (key === 'actionMsg.modifiedSuccessfully')
return 'Modified successfully'
if (key === 'actionMsg.modifiedUnsuccessfully')
return 'Modified unsuccessfully'
if (key === 'segment.contentEmpty')
return 'Content cannot be empty'
return key
},
}),
}))
vi.mock('@tanstack/react-query', async () => {
const actual = await vi.importActual('@tanstack/react-query')
return {
...actual,
useQueryClient: () => mockQueryClient,
}
})
vi.mock('../../context', () => ({
useDocumentContext: (selector: (value: DocumentContextValue) => unknown) => {
const value: DocumentContextValue = {
datasetId: mockDatasetId.current,
documentId: mockDocumentId.current,
docForm: 'text' as ChunkingMode,
parentMode: mockParentMode.current,
}
return selector(value)
},
}))
vi.mock('@/app/components/base/toast', () => ({
useToastContext: () => ({ notify: mockNotify }),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({ eventEmitter: mockEventEmitter }),
}))
vi.mock('@/service/knowledge/use-segment', () => ({
useChildSegmentList: () => ({
isLoading: false,
data: mockChildSegmentListData.current,
}),
useChildSegmentListKey: ['segment', 'childChunkList'],
useDeleteChildSegment: () => ({ mutateAsync: mockDeleteChildSegment }),
useUpdateChildSegment: () => ({ mutateAsync: mockUpdateChildSegment }),
}))
vi.mock('@/service/use-base', () => ({
useInvalid: () => mockInvalidChildSegmentList,
}))
// ============================================================================
// Test Utilities
// ============================================================================
const createQueryClient = () => new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const createWrapper = () => {
const queryClient = createQueryClient()
return ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children)
}
const createMockChildChunk = (overrides: Partial<ChildChunkDetail> = {}): ChildChunkDetail => ({
id: `child-${Math.random().toString(36).substr(2, 9)}`,
position: 1,
segment_id: 'segment-1',
content: 'Child chunk content',
word_count: 100,
created_at: 1700000000,
updated_at: 1700000000,
type: 'automatic',
...overrides,
})
const createMockSegment = (overrides: Partial<SegmentDetailModel> = {}): SegmentDetailModel => ({
id: 'segment-1',
position: 1,
document_id: 'doc-1',
content: 'Test content',
sign_content: 'Test signed content',
word_count: 100,
tokens: 50,
keywords: [],
index_node_id: 'index-1',
index_node_hash: 'hash-1',
hit_count: 0,
enabled: true,
disabled_at: 0,
disabled_by: '',
status: 'completed',
created_by: 'user-1',
created_at: 1700000000,
indexing_at: 1700000100,
completed_at: 1700000200,
error: null,
stopped_at: 0,
updated_at: 1700000000,
attachments: [],
child_chunks: [],
...overrides,
})
const defaultOptions = {
searchValue: '',
currentPage: 1,
limit: 10,
segments: [createMockSegment()] as SegmentDetailModel[],
currChunkId: 'segment-1',
isFullDocMode: true,
onCloseChildSegmentDetail: vi.fn(),
refreshChunkListDataWithDetailChanged: vi.fn(),
updateSegmentInCache: vi.fn(),
}
// ============================================================================
// Tests
// ============================================================================
describe('useChildSegmentData', () => {
beforeEach(() => {
vi.clearAllMocks()
mockParentMode.current = 'paragraph'
mockDatasetId.current = 'test-dataset-id'
mockDocumentId.current = 'test-document-id'
mockChildSegmentListData.current = { data: [], total: 0, total_pages: 0, page: 1, limit: 20 }
})
describe('Initial State', () => {
it('should return empty child segments initially', () => {
const { result } = renderHook(() => useChildSegmentData(defaultOptions), {
wrapper: createWrapper(),
})
expect(result.current.childSegments).toEqual([])
expect(result.current.isLoadingChildSegmentList).toBe(false)
})
})
describe('resetChildList', () => {
it('should call invalidChildSegmentList', () => {
const { result } = renderHook(() => useChildSegmentData(defaultOptions), {
wrapper: createWrapper(),
})
act(() => {
result.current.resetChildList()
})
expect(mockInvalidChildSegmentList).toHaveBeenCalled()
})
})
describe('onDeleteChildChunk', () => {
it('should delete child chunk and update parent cache in paragraph mode', async () => {
mockParentMode.current = 'paragraph'
const updateSegmentInCache = vi.fn()
mockDeleteChildSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => {
onSuccess()
})
const { result } = renderHook(() => useChildSegmentData({
...defaultOptions,
updateSegmentInCache,
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.onDeleteChildChunk('seg-1', 'child-1')
})
expect(mockDeleteChildSegment).toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'Modified successfully' })
expect(updateSegmentInCache).toHaveBeenCalledWith('seg-1', expect.any(Function))
})
it('should delete child chunk and reset list in full-doc mode', async () => {
mockParentMode.current = 'full-doc'
mockDeleteChildSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => {
onSuccess()
})
const { result } = renderHook(() => useChildSegmentData(defaultOptions), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.onDeleteChildChunk('seg-1', 'child-1')
})
expect(mockInvalidChildSegmentList).toHaveBeenCalled()
})
it('should notify error on failure', async () => {
mockDeleteChildSegment.mockImplementation(async (_params, { onError }: { onError: () => void }) => {
onError()
})
const { result } = renderHook(() => useChildSegmentData(defaultOptions), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.onDeleteChildChunk('seg-1', 'child-1')
})
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'Modified unsuccessfully' })
})
})
describe('handleUpdateChildChunk', () => {
it('should validate empty content', async () => {
const { result } = renderHook(() => useChildSegmentData(defaultOptions), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateChildChunk('seg-1', 'child-1', ' ')
})
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'Content cannot be empty' })
expect(mockUpdateChildSegment).not.toHaveBeenCalled()
})
it('should update child chunk and parent cache in paragraph mode', async () => {
mockParentMode.current = 'paragraph'
const updateSegmentInCache = vi.fn()
const onCloseChildSegmentDetail = vi.fn()
const refreshChunkListDataWithDetailChanged = vi.fn()
mockUpdateChildSegment.mockImplementation(async (_params, { onSuccess, onSettled }: MutationCallbacks) => {
onSuccess({
data: createMockChildChunk({
content: 'updated content',
type: 'customized',
word_count: 50,
updated_at: 1700000001,
}),
})
onSettled()
})
const { result } = renderHook(() => useChildSegmentData({
...defaultOptions,
updateSegmentInCache,
onCloseChildSegmentDetail,
refreshChunkListDataWithDetailChanged,
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateChildChunk('seg-1', 'child-1', 'updated content')
})
expect(mockUpdateChildSegment).toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'Modified successfully' })
expect(onCloseChildSegmentDetail).toHaveBeenCalled()
expect(updateSegmentInCache).toHaveBeenCalled()
expect(refreshChunkListDataWithDetailChanged).toHaveBeenCalled()
expect(mockEventEmitter.emit).toHaveBeenCalledWith('update-child-segment')
expect(mockEventEmitter.emit).toHaveBeenCalledWith('update-child-segment-done')
})
it('should update child chunk cache in full-doc mode', async () => {
mockParentMode.current = 'full-doc'
const onCloseChildSegmentDetail = vi.fn()
mockUpdateChildSegment.mockImplementation(async (_params, { onSuccess, onSettled }: MutationCallbacks) => {
onSuccess({
data: createMockChildChunk({
content: 'updated content',
type: 'customized',
word_count: 50,
updated_at: 1700000001,
}),
})
onSettled()
})
const { result } = renderHook(() => useChildSegmentData({
...defaultOptions,
onCloseChildSegmentDetail,
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateChildChunk('seg-1', 'child-1', 'updated content')
})
expect(mockQueryClient.setQueryData).toHaveBeenCalled()
})
})
describe('onSaveNewChildChunk', () => {
it('should update parent cache in paragraph mode', () => {
mockParentMode.current = 'paragraph'
const updateSegmentInCache = vi.fn()
const refreshChunkListDataWithDetailChanged = vi.fn()
const newChildChunk = createMockChildChunk({ id: 'new-child' })
const { result } = renderHook(() => useChildSegmentData({
...defaultOptions,
updateSegmentInCache,
refreshChunkListDataWithDetailChanged,
}), {
wrapper: createWrapper(),
})
act(() => {
result.current.onSaveNewChildChunk(newChildChunk)
})
expect(updateSegmentInCache).toHaveBeenCalled()
expect(refreshChunkListDataWithDetailChanged).toHaveBeenCalled()
})
it('should reset child list in full-doc mode', () => {
mockParentMode.current = 'full-doc'
const { result } = renderHook(() => useChildSegmentData(defaultOptions), {
wrapper: createWrapper(),
})
act(() => {
result.current.onSaveNewChildChunk(createMockChildChunk())
})
expect(mockInvalidChildSegmentList).toHaveBeenCalled()
})
})
describe('viewNewlyAddedChildChunk', () => {
it('should set needScrollToBottom and not reset when adding new page', () => {
mockChildSegmentListData.current = { data: [], total: 10, total_pages: 1, page: 1, limit: 20 }
const { result } = renderHook(() => useChildSegmentData({
...defaultOptions,
limit: 10,
}), {
wrapper: createWrapper(),
})
act(() => {
result.current.viewNewlyAddedChildChunk()
})
expect(result.current.needScrollToBottom.current).toBe(true)
})
it('should call resetChildList when not adding new page', () => {
mockChildSegmentListData.current = { data: [], total: 5, total_pages: 1, page: 1, limit: 20 }
const { result } = renderHook(() => useChildSegmentData({
...defaultOptions,
limit: 10,
}), {
wrapper: createWrapper(),
})
act(() => {
result.current.viewNewlyAddedChildChunk()
})
expect(mockInvalidChildSegmentList).toHaveBeenCalled()
})
})
describe('Query disabled states', () => {
it('should disable query when not in fullDocMode', () => {
const { result } = renderHook(() => useChildSegmentData({
...defaultOptions,
isFullDocMode: false,
}), {
wrapper: createWrapper(),
})
// Query should be disabled but hook should still work
expect(result.current.childSegments).toEqual([])
})
it('should disable query when segments is empty', () => {
const { result } = renderHook(() => useChildSegmentData({
...defaultOptions,
segments: [],
}), {
wrapper: createWrapper(),
})
expect(result.current.childSegments).toEqual([])
})
})
describe('Cache update callbacks', () => {
it('should use updateSegmentInCache when deleting in paragraph mode', async () => {
mockParentMode.current = 'paragraph'
const updateSegmentInCache = vi.fn()
mockDeleteChildSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => {
onSuccess()
})
const { result } = renderHook(() => useChildSegmentData({
...defaultOptions,
updateSegmentInCache,
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.onDeleteChildChunk('seg-1', 'child-1')
})
expect(updateSegmentInCache).toHaveBeenCalledWith('seg-1', expect.any(Function))
// Verify the updater function filters correctly
const updaterFn = updateSegmentInCache.mock.calls[0][1]
const testSegment = createMockSegment({
child_chunks: [
createMockChildChunk({ id: 'child-1' }),
createMockChildChunk({ id: 'child-2' }),
],
})
const updatedSegment = updaterFn(testSegment)
expect(updatedSegment.child_chunks).toHaveLength(1)
expect(updatedSegment.child_chunks[0].id).toBe('child-2')
})
it('should use updateSegmentInCache when updating in paragraph mode', async () => {
mockParentMode.current = 'paragraph'
const updateSegmentInCache = vi.fn()
const onCloseChildSegmentDetail = vi.fn()
const refreshChunkListDataWithDetailChanged = vi.fn()
mockUpdateChildSegment.mockImplementation(async (_params, { onSuccess, onSettled }: MutationCallbacks) => {
onSuccess({
data: createMockChildChunk({
id: 'child-1',
content: 'new content',
type: 'customized',
word_count: 50,
updated_at: 1700000001,
}),
})
onSettled()
})
const { result } = renderHook(() => useChildSegmentData({
...defaultOptions,
updateSegmentInCache,
onCloseChildSegmentDetail,
refreshChunkListDataWithDetailChanged,
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateChildChunk('seg-1', 'child-1', 'new content')
})
expect(updateSegmentInCache).toHaveBeenCalledWith('seg-1', expect.any(Function))
// Verify the updater function maps correctly
const updaterFn = updateSegmentInCache.mock.calls[0][1]
const testSegment = createMockSegment({
child_chunks: [
createMockChildChunk({ id: 'child-1', content: 'old content' }),
createMockChildChunk({ id: 'child-2', content: 'other content' }),
],
})
const updatedSegment = updaterFn(testSegment)
expect(updatedSegment.child_chunks).toHaveLength(2)
expect(updatedSegment.child_chunks[0].content).toBe('new content')
expect(updatedSegment.child_chunks[1].content).toBe('other content')
})
})
describe('updateChildSegmentInCache in full-doc mode', () => {
it('should use updateChildSegmentInCache when updating in full-doc mode', async () => {
mockParentMode.current = 'full-doc'
const onCloseChildSegmentDetail = vi.fn()
mockUpdateChildSegment.mockImplementation(async (_params, { onSuccess, onSettled }: MutationCallbacks) => {
onSuccess({
data: createMockChildChunk({
id: 'child-1',
content: 'new content',
type: 'customized',
word_count: 50,
updated_at: 1700000001,
}),
})
onSettled()
})
const { result } = renderHook(() => useChildSegmentData({
...defaultOptions,
onCloseChildSegmentDetail,
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateChildChunk('seg-1', 'child-1', 'new content')
})
expect(mockQueryClient.setQueryData).toHaveBeenCalled()
})
})
})

View File

@ -1,241 +0,0 @@
import type { ChildChunkDetail, ChildSegmentsResponse, SegmentDetailModel, SegmentUpdater } from '@/models/datasets'
import { useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useToastContext } from '@/app/components/base/toast'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import {
useChildSegmentList,
useChildSegmentListKey,
useDeleteChildSegment,
useUpdateChildSegment,
} from '@/service/knowledge/use-segment'
import { useInvalid } from '@/service/use-base'
import { useDocumentContext } from '../../context'
export type UseChildSegmentDataOptions = {
searchValue: string
currentPage: number
limit: number
segments: SegmentDetailModel[]
currChunkId: string
isFullDocMode: boolean
onCloseChildSegmentDetail: () => void
refreshChunkListDataWithDetailChanged: () => void
updateSegmentInCache: (segmentId: string, updater: (seg: SegmentDetailModel) => SegmentDetailModel) => void
}
export type UseChildSegmentDataReturn = {
childSegments: ChildChunkDetail[]
isLoadingChildSegmentList: boolean
childChunkListData: ReturnType<typeof useChildSegmentList>['data']
childSegmentListRef: React.RefObject<HTMLDivElement | null>
needScrollToBottom: React.RefObject<boolean>
// Operations
onDeleteChildChunk: (segmentId: string, childChunkId: string) => Promise<void>
handleUpdateChildChunk: (segmentId: string, childChunkId: string, content: string) => Promise<void>
onSaveNewChildChunk: (newChildChunk?: ChildChunkDetail) => void
resetChildList: () => void
viewNewlyAddedChildChunk: () => void
}
export const useChildSegmentData = (options: UseChildSegmentDataOptions): UseChildSegmentDataReturn => {
const {
searchValue,
currentPage,
limit,
segments,
currChunkId,
isFullDocMode,
onCloseChildSegmentDetail,
refreshChunkListDataWithDetailChanged,
updateSegmentInCache,
} = options
const { t } = useTranslation()
const { notify } = useToastContext()
const { eventEmitter } = useEventEmitterContextContext()
const queryClient = useQueryClient()
const datasetId = useDocumentContext(s => s.datasetId) || ''
const documentId = useDocumentContext(s => s.documentId) || ''
const parentMode = useDocumentContext(s => s.parentMode)
const childSegmentListRef = useRef<HTMLDivElement>(null)
const needScrollToBottom = useRef(false)
// Build query params
const queryParams = useMemo(() => ({
page: currentPage === 0 ? 1 : currentPage,
limit,
keyword: searchValue,
}), [currentPage, limit, searchValue])
const segmentId = segments[0]?.id || ''
// Build query key for optimistic updates
const currentQueryKey = useMemo(() =>
[...useChildSegmentListKey, datasetId, documentId, segmentId, queryParams], [datasetId, documentId, segmentId, queryParams])
// Fetch child segment list
const { isLoading: isLoadingChildSegmentList, data: childChunkListData } = useChildSegmentList(
{
datasetId,
documentId,
segmentId,
params: queryParams,
},
!isFullDocMode || segments.length === 0,
)
// Derive child segments from query data
const childSegments = useMemo(() => childChunkListData?.data || [], [childChunkListData])
const invalidChildSegmentList = useInvalid(useChildSegmentListKey)
// Scroll to bottom when child segments change
useEffect(() => {
if (childSegmentListRef.current && needScrollToBottom.current) {
childSegmentListRef.current.scrollTo({ top: childSegmentListRef.current.scrollHeight, behavior: 'smooth' })
needScrollToBottom.current = false
}
}, [childSegments])
const resetChildList = useCallback(() => {
invalidChildSegmentList()
}, [invalidChildSegmentList])
// Optimistic update helper for child segments
const updateChildSegmentInCache = useCallback((
childChunkId: string,
updater: (chunk: ChildChunkDetail) => ChildChunkDetail,
) => {
queryClient.setQueryData<ChildSegmentsResponse>(currentQueryKey, (old) => {
if (!old)
return old
return {
...old,
data: old.data.map(chunk => chunk.id === childChunkId ? updater(chunk) : chunk),
}
})
}, [queryClient, currentQueryKey])
// Mutations
const { mutateAsync: deleteChildSegment } = useDeleteChildSegment()
const { mutateAsync: updateChildSegment } = useUpdateChildSegment()
const onDeleteChildChunk = useCallback(async (segmentIdParam: string, childChunkId: string) => {
await deleteChildSegment(
{ datasetId, documentId, segmentId: segmentIdParam, childChunkId },
{
onSuccess: () => {
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
if (parentMode === 'paragraph') {
// Update parent segment's child_chunks in cache
updateSegmentInCache(segmentIdParam, seg => ({
...seg,
child_chunks: seg.child_chunks?.filter(chunk => chunk.id !== childChunkId),
}))
}
else {
resetChildList()
}
},
onError: () => {
notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
},
},
)
}, [datasetId, documentId, parentMode, deleteChildSegment, updateSegmentInCache, resetChildList, t, notify])
const handleUpdateChildChunk = useCallback(async (
segmentIdParam: string,
childChunkId: string,
content: string,
) => {
const params: SegmentUpdater = { content: '' }
if (!content.trim()) {
notify({ type: 'error', message: t('segment.contentEmpty', { ns: 'datasetDocuments' }) })
return
}
params.content = content
eventEmitter?.emit('update-child-segment')
await updateChildSegment({ datasetId, documentId, segmentId: segmentIdParam, childChunkId, body: params }, {
onSuccess: (res) => {
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
onCloseChildSegmentDetail()
if (parentMode === 'paragraph') {
// Update parent segment's child_chunks in cache
updateSegmentInCache(segmentIdParam, seg => ({
...seg,
child_chunks: seg.child_chunks?.map(childSeg =>
childSeg.id === childChunkId
? {
...childSeg,
content: res.data.content,
type: res.data.type,
word_count: res.data.word_count,
updated_at: res.data.updated_at,
}
: childSeg,
),
}))
refreshChunkListDataWithDetailChanged()
}
else {
updateChildSegmentInCache(childChunkId, chunk => ({
...chunk,
content: res.data.content,
type: res.data.type,
word_count: res.data.word_count,
updated_at: res.data.updated_at,
}))
}
},
onSettled: () => {
eventEmitter?.emit('update-child-segment-done')
},
})
}, [datasetId, documentId, parentMode, updateChildSegment, notify, eventEmitter, onCloseChildSegmentDetail, updateSegmentInCache, updateChildSegmentInCache, refreshChunkListDataWithDetailChanged, t])
const onSaveNewChildChunk = useCallback((newChildChunk?: ChildChunkDetail) => {
if (parentMode === 'paragraph') {
// Update parent segment's child_chunks in cache
updateSegmentInCache(currChunkId, seg => ({
...seg,
child_chunks: [...(seg.child_chunks || []), newChildChunk!],
}))
refreshChunkListDataWithDetailChanged()
}
else {
resetChildList()
}
}, [parentMode, currChunkId, updateSegmentInCache, refreshChunkListDataWithDetailChanged, resetChildList])
const viewNewlyAddedChildChunk = useCallback(() => {
const totalPages = childChunkListData?.total_pages || 0
const total = childChunkListData?.total || 0
const newPage = Math.ceil((total + 1) / limit)
needScrollToBottom.current = true
if (newPage > totalPages)
return
resetChildList()
}, [childChunkListData, limit, resetChildList])
return {
childSegments,
isLoadingChildSegmentList,
childChunkListData,
childSegmentListRef,
needScrollToBottom,
onDeleteChildChunk,
handleUpdateChildChunk,
onSaveNewChildChunk,
resetChildList,
viewNewlyAddedChildChunk,
}
}

View File

@ -1,141 +0,0 @@
import type { ChildChunkDetail, SegmentDetailModel } from '@/models/datasets'
import { useCallback, useState } from 'react'
export type CurrSegmentType = {
segInfo?: SegmentDetailModel
showModal: boolean
isEditMode?: boolean
}
export type CurrChildChunkType = {
childChunkInfo?: ChildChunkDetail
showModal: boolean
}
export type UseModalStateReturn = {
// Segment detail modal
currSegment: CurrSegmentType
onClickCard: (detail: SegmentDetailModel, isEditMode?: boolean) => void
onCloseSegmentDetail: () => void
// Child segment detail modal
currChildChunk: CurrChildChunkType
currChunkId: string
onClickSlice: (detail: ChildChunkDetail) => void
onCloseChildSegmentDetail: () => void
// New segment modal
onCloseNewSegmentModal: () => void
// New child segment modal
showNewChildSegmentModal: boolean
handleAddNewChildChunk: (parentChunkId: string) => void
onCloseNewChildChunkModal: () => void
// Regeneration modal
isRegenerationModalOpen: boolean
setIsRegenerationModalOpen: (open: boolean) => void
// Full screen
fullScreen: boolean
toggleFullScreen: () => void
setFullScreen: (fullScreen: boolean) => void
// Collapsed state
isCollapsed: boolean
toggleCollapsed: () => void
}
type UseModalStateOptions = {
onNewSegmentModalChange: (state: boolean) => void
}
export const useModalState = (options: UseModalStateOptions): UseModalStateReturn => {
const { onNewSegmentModalChange } = options
// Segment detail modal state
const [currSegment, setCurrSegment] = useState<CurrSegmentType>({ showModal: false })
// Child segment detail modal state
const [currChildChunk, setCurrChildChunk] = useState<CurrChildChunkType>({ showModal: false })
const [currChunkId, setCurrChunkId] = useState('')
// New child segment modal state
const [showNewChildSegmentModal, setShowNewChildSegmentModal] = useState(false)
// Regeneration modal state
const [isRegenerationModalOpen, setIsRegenerationModalOpen] = useState(false)
// Display state
const [fullScreen, setFullScreen] = useState(false)
const [isCollapsed, setIsCollapsed] = useState(true)
// Segment detail handlers
const onClickCard = useCallback((detail: SegmentDetailModel, isEditMode = false) => {
setCurrSegment({ segInfo: detail, showModal: true, isEditMode })
}, [])
const onCloseSegmentDetail = useCallback(() => {
setCurrSegment({ showModal: false })
setFullScreen(false)
}, [])
// Child segment detail handlers
const onClickSlice = useCallback((detail: ChildChunkDetail) => {
setCurrChildChunk({ childChunkInfo: detail, showModal: true })
setCurrChunkId(detail.segment_id)
}, [])
const onCloseChildSegmentDetail = useCallback(() => {
setCurrChildChunk({ showModal: false })
setFullScreen(false)
}, [])
// New segment modal handlers
const onCloseNewSegmentModal = useCallback(() => {
onNewSegmentModalChange(false)
setFullScreen(false)
}, [onNewSegmentModalChange])
// New child segment modal handlers
const handleAddNewChildChunk = useCallback((parentChunkId: string) => {
setShowNewChildSegmentModal(true)
setCurrChunkId(parentChunkId)
}, [])
const onCloseNewChildChunkModal = useCallback(() => {
setShowNewChildSegmentModal(false)
setFullScreen(false)
}, [])
// Display handlers - handles both direct calls and click events
const toggleFullScreen = useCallback(() => {
setFullScreen(prev => !prev)
}, [])
const toggleCollapsed = useCallback(() => {
setIsCollapsed(prev => !prev)
}, [])
return {
// Segment detail modal
currSegment,
onClickCard,
onCloseSegmentDetail,
// Child segment detail modal
currChildChunk,
currChunkId,
onClickSlice,
onCloseChildSegmentDetail,
// New segment modal
onCloseNewSegmentModal,
// New child segment modal
showNewChildSegmentModal,
handleAddNewChildChunk,
onCloseNewChildChunkModal,
// Regeneration modal
isRegenerationModalOpen,
setIsRegenerationModalOpen,
// Full screen
fullScreen,
toggleFullScreen,
setFullScreen,
// Collapsed state
isCollapsed,
toggleCollapsed,
}
}

View File

@ -1,85 +0,0 @@
import type { Item } from '@/app/components/base/select'
import { useDebounceFn } from 'ahooks'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
export type SearchFilterState = {
inputValue: string
searchValue: string
selectedStatus: boolean | 'all'
}
export type UseSearchFilterReturn = {
inputValue: string
searchValue: string
selectedStatus: boolean | 'all'
statusList: Item[]
selectDefaultValue: 'all' | 0 | 1
handleInputChange: (value: string) => void
onChangeStatus: (item: Item) => void
onClearFilter: () => void
resetPage: () => void
}
type UseSearchFilterOptions = {
onPageChange: (page: number) => void
}
export const useSearchFilter = (options: UseSearchFilterOptions): UseSearchFilterReturn => {
const { t } = useTranslation()
const { onPageChange } = options
const [inputValue, setInputValue] = useState<string>('')
const [searchValue, setSearchValue] = useState<string>('')
const [selectedStatus, setSelectedStatus] = useState<boolean | 'all'>('all')
const statusList = useRef<Item[]>([
{ value: 'all', name: t('list.index.all', { ns: 'datasetDocuments' }) },
{ value: 0, name: t('list.status.disabled', { ns: 'datasetDocuments' }) },
{ value: 1, name: t('list.status.enabled', { ns: 'datasetDocuments' }) },
])
const { run: handleSearch } = useDebounceFn(() => {
setSearchValue(inputValue)
onPageChange(1)
}, { wait: 500 })
const handleInputChange = useCallback((value: string) => {
setInputValue(value)
handleSearch()
}, [handleSearch])
const onChangeStatus = useCallback(({ value }: Item) => {
setSelectedStatus(value === 'all' ? 'all' : !!value)
onPageChange(1)
}, [onPageChange])
const onClearFilter = useCallback(() => {
setInputValue('')
setSearchValue('')
setSelectedStatus('all')
onPageChange(1)
}, [onPageChange])
const resetPage = useCallback(() => {
onPageChange(1)
}, [onPageChange])
const selectDefaultValue = useMemo(() => {
if (selectedStatus === 'all')
return 'all'
return selectedStatus ? 1 : 0
}, [selectedStatus])
return {
inputValue,
searchValue,
selectedStatus,
statusList: statusList.current,
selectDefaultValue,
handleInputChange,
onChangeStatus,
onClearFilter,
resetPage,
}
}

View File

@ -1,942 +0,0 @@
import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types'
import type { DocumentContextValue } from '@/app/components/datasets/documents/detail/context'
import type { ChunkingMode, ParentMode, SegmentDetailModel, SegmentsResponse } from '@/models/datasets'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook } from '@testing-library/react'
import * as React from 'react'
import { ChunkingMode as ChunkingModeEnum } from '@/models/datasets'
import { ProcessStatus } from '../../segment-add'
import { useSegmentListData } from './use-segment-list-data'
// Type for mutation callbacks
type SegmentMutationResponse = { data: SegmentDetailModel }
type SegmentMutationCallbacks = {
onSuccess: (res: SegmentMutationResponse) => void
onSettled: () => void
}
// Mock file entity factory
const createMockFileEntity = (overrides: Partial<FileEntity> = {}): FileEntity => ({
id: 'file-1',
name: 'test.png',
size: 1024,
extension: 'png',
mimeType: 'image/png',
progress: 100,
uploadedId: undefined,
base64Url: undefined,
...overrides,
})
// ============================================================================
// Hoisted Mocks
// ============================================================================
const {
mockDocForm,
mockParentMode,
mockDatasetId,
mockDocumentId,
mockNotify,
mockEventEmitter,
mockQueryClient,
mockSegmentListData,
mockEnableSegment,
mockDisableSegment,
mockDeleteSegment,
mockUpdateSegment,
mockInvalidSegmentList,
mockInvalidChunkListAll,
mockInvalidChunkListEnabled,
mockInvalidChunkListDisabled,
mockPathname,
} = vi.hoisted(() => ({
mockDocForm: { current: 'text' as ChunkingMode },
mockParentMode: { current: 'paragraph' as ParentMode },
mockDatasetId: { current: 'test-dataset-id' },
mockDocumentId: { current: 'test-document-id' },
mockNotify: vi.fn(),
mockEventEmitter: { emit: vi.fn(), on: vi.fn(), off: vi.fn() },
mockQueryClient: { setQueryData: vi.fn() },
mockSegmentListData: { current: { data: [] as SegmentDetailModel[], total: 0, total_pages: 0, has_more: false, limit: 20, page: 1 } as SegmentsResponse | undefined },
mockEnableSegment: vi.fn(),
mockDisableSegment: vi.fn(),
mockDeleteSegment: vi.fn(),
mockUpdateSegment: vi.fn(),
mockInvalidSegmentList: vi.fn(),
mockInvalidChunkListAll: vi.fn(),
mockInvalidChunkListEnabled: vi.fn(),
mockInvalidChunkListDisabled: vi.fn(),
mockPathname: { current: '/datasets/test/documents/test' },
}))
// Mock dependencies
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { count?: number, ns?: string }) => {
if (key === 'actionMsg.modifiedSuccessfully')
return 'Modified successfully'
if (key === 'actionMsg.modifiedUnsuccessfully')
return 'Modified unsuccessfully'
if (key === 'segment.contentEmpty')
return 'Content cannot be empty'
if (key === 'segment.questionEmpty')
return 'Question cannot be empty'
if (key === 'segment.answerEmpty')
return 'Answer cannot be empty'
if (key === 'segment.allFilesUploaded')
return 'All files must be uploaded'
if (key === 'segment.chunks')
return options?.count === 1 ? 'chunk' : 'chunks'
if (key === 'segment.parentChunks')
return options?.count === 1 ? 'parent chunk' : 'parent chunks'
if (key === 'segment.searchResults')
return 'search results'
return `${options?.ns || ''}.${key}`
},
}),
}))
vi.mock('next/navigation', () => ({
usePathname: () => mockPathname.current,
}))
vi.mock('@tanstack/react-query', async () => {
const actual = await vi.importActual('@tanstack/react-query')
return {
...actual,
useQueryClient: () => mockQueryClient,
}
})
vi.mock('../../context', () => ({
useDocumentContext: (selector: (value: DocumentContextValue) => unknown) => {
const value: DocumentContextValue = {
datasetId: mockDatasetId.current,
documentId: mockDocumentId.current,
docForm: mockDocForm.current,
parentMode: mockParentMode.current,
}
return selector(value)
},
}))
vi.mock('@/app/components/base/toast', () => ({
useToastContext: () => ({ notify: mockNotify }),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({ eventEmitter: mockEventEmitter }),
}))
vi.mock('@/service/knowledge/use-segment', () => ({
useSegmentList: () => ({
isLoading: false,
data: mockSegmentListData.current,
}),
useSegmentListKey: ['segment', 'chunkList'],
useChunkListAllKey: ['segment', 'chunkList', { enabled: 'all' }],
useChunkListEnabledKey: ['segment', 'chunkList', { enabled: true }],
useChunkListDisabledKey: ['segment', 'chunkList', { enabled: false }],
useEnableSegment: () => ({ mutateAsync: mockEnableSegment }),
useDisableSegment: () => ({ mutateAsync: mockDisableSegment }),
useDeleteSegment: () => ({ mutateAsync: mockDeleteSegment }),
useUpdateSegment: () => ({ mutateAsync: mockUpdateSegment }),
}))
vi.mock('@/service/use-base', () => ({
useInvalid: (key: unknown[]) => {
const keyObj = key[2] as { enabled?: boolean | 'all' } | undefined
if (keyObj?.enabled === 'all')
return mockInvalidChunkListAll
if (keyObj?.enabled === true)
return mockInvalidChunkListEnabled
if (keyObj?.enabled === false)
return mockInvalidChunkListDisabled
return mockInvalidSegmentList
},
}))
// ============================================================================
// Test Utilities
// ============================================================================
const createQueryClient = () => new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const createWrapper = () => {
const queryClient = createQueryClient()
return ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children)
}
const createMockSegment = (overrides: Partial<SegmentDetailModel> = {}): SegmentDetailModel => ({
id: `segment-${Math.random().toString(36).substr(2, 9)}`,
position: 1,
document_id: 'doc-1',
content: 'Test content',
sign_content: 'Test signed content',
word_count: 100,
tokens: 50,
keywords: [],
index_node_id: 'index-1',
index_node_hash: 'hash-1',
hit_count: 0,
enabled: true,
disabled_at: 0,
disabled_by: '',
status: 'completed',
created_by: 'user-1',
created_at: 1700000000,
indexing_at: 1700000100,
completed_at: 1700000200,
error: null,
stopped_at: 0,
updated_at: 1700000000,
attachments: [],
child_chunks: [],
...overrides,
})
const defaultOptions = {
searchValue: '',
selectedStatus: 'all' as boolean | 'all',
selectedSegmentIds: [] as string[],
importStatus: undefined as ProcessStatus | string | undefined,
currentPage: 1,
limit: 10,
onCloseSegmentDetail: vi.fn(),
clearSelection: vi.fn(),
}
// ============================================================================
// Tests
// ============================================================================
describe('useSegmentListData', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDocForm.current = ChunkingModeEnum.text as ChunkingMode
mockParentMode.current = 'paragraph'
mockDatasetId.current = 'test-dataset-id'
mockDocumentId.current = 'test-document-id'
mockSegmentListData.current = { data: [], total: 0, total_pages: 0, has_more: false, limit: 20, page: 1 }
mockPathname.current = '/datasets/test/documents/test'
})
describe('Initial State', () => {
it('should return empty segments initially', () => {
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
expect(result.current.segments).toEqual([])
expect(result.current.isLoadingSegmentList).toBe(false)
})
it('should compute isFullDocMode correctly', () => {
mockDocForm.current = ChunkingModeEnum.parentChild
mockParentMode.current = 'full-doc'
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
expect(result.current.isFullDocMode).toBe(true)
})
it('should compute isFullDocMode as false for text mode', () => {
mockDocForm.current = ChunkingModeEnum.text
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
expect(result.current.isFullDocMode).toBe(false)
})
})
describe('totalText computation', () => {
it('should show chunks count when not searching', () => {
mockSegmentListData.current = { data: [], total: 10, total_pages: 1, has_more: false, limit: 20, page: 1 }
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
expect(result.current.totalText).toContain('10')
expect(result.current.totalText).toContain('chunks')
})
it('should show search results when searching', () => {
mockSegmentListData.current = { data: [], total: 5, total_pages: 1, has_more: false, limit: 20, page: 1 }
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
searchValue: 'test',
}), {
wrapper: createWrapper(),
})
expect(result.current.totalText).toContain('5')
expect(result.current.totalText).toContain('search results')
})
it('should show search results when status is filtered', () => {
mockSegmentListData.current = { data: [], total: 3, total_pages: 1, has_more: false, limit: 20, page: 1 }
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
selectedStatus: true,
}), {
wrapper: createWrapper(),
})
expect(result.current.totalText).toContain('search results')
})
it('should show parent chunks in parentChild paragraph mode', () => {
mockDocForm.current = ChunkingModeEnum.parentChild
mockParentMode.current = 'paragraph'
mockSegmentListData.current = { data: [], total: 7, total_pages: 1, has_more: false, limit: 20, page: 1 }
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
expect(result.current.totalText).toContain('parent chunk')
})
it('should show "--" when total is undefined', () => {
mockSegmentListData.current = undefined
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
expect(result.current.totalText).toContain('--')
})
})
describe('resetList', () => {
it('should call clearSelection and invalidSegmentList', () => {
const clearSelection = vi.fn()
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
clearSelection,
}), {
wrapper: createWrapper(),
})
act(() => {
result.current.resetList()
})
expect(clearSelection).toHaveBeenCalled()
expect(mockInvalidSegmentList).toHaveBeenCalled()
})
})
describe('refreshChunkListWithStatusChanged', () => {
it('should invalidate disabled and enabled when status is all', async () => {
mockEnableSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => {
onSuccess()
})
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
selectedStatus: 'all',
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.onChangeSwitch(true, 'seg-1')
})
expect(mockInvalidChunkListDisabled).toHaveBeenCalled()
expect(mockInvalidChunkListEnabled).toHaveBeenCalled()
})
it('should invalidate segment list when status is not all', async () => {
mockEnableSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => {
onSuccess()
})
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
selectedStatus: true,
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.onChangeSwitch(true, 'seg-1')
})
expect(mockInvalidSegmentList).toHaveBeenCalled()
})
})
describe('onChangeSwitch', () => {
it('should call enableSegment when enable is true', async () => {
mockEnableSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => {
onSuccess()
})
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.onChangeSwitch(true, 'seg-1')
})
expect(mockEnableSegment).toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'Modified successfully' })
})
it('should call disableSegment when enable is false', async () => {
mockDisableSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => {
onSuccess()
})
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.onChangeSwitch(false, 'seg-1')
})
expect(mockDisableSegment).toHaveBeenCalled()
})
it('should use selectedSegmentIds when segId is empty', async () => {
mockEnableSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => {
onSuccess()
})
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
selectedSegmentIds: ['seg-1', 'seg-2'],
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.onChangeSwitch(true, '')
})
expect(mockEnableSegment).toHaveBeenCalledWith(
expect.objectContaining({ segmentIds: ['seg-1', 'seg-2'] }),
expect.any(Object),
)
})
it('should notify error on failure', async () => {
mockEnableSegment.mockImplementation(async (_params, { onError }: { onError: () => void }) => {
onError()
})
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.onChangeSwitch(true, 'seg-1')
})
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'Modified unsuccessfully' })
})
})
describe('onDelete', () => {
it('should call deleteSegment and resetList on success', async () => {
const clearSelection = vi.fn()
mockDeleteSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => {
onSuccess()
})
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
clearSelection,
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.onDelete('seg-1')
})
expect(mockDeleteSegment).toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'Modified successfully' })
})
it('should clear selection when deleting batch (no segId)', async () => {
const clearSelection = vi.fn()
mockDeleteSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => {
onSuccess()
})
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
selectedSegmentIds: ['seg-1', 'seg-2'],
clearSelection,
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.onDelete('')
})
// clearSelection is called twice: once in resetList, once after
expect(clearSelection).toHaveBeenCalled()
})
it('should notify error on failure', async () => {
mockDeleteSegment.mockImplementation(async (_params, { onError }: { onError: () => void }) => {
onError()
})
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.onDelete('seg-1')
})
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'Modified unsuccessfully' })
})
})
describe('handleUpdateSegment', () => {
it('should validate empty content', async () => {
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateSegment('seg-1', ' ', '', [], [])
})
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'Content cannot be empty' })
expect(mockUpdateSegment).not.toHaveBeenCalled()
})
it('should validate empty question in QA mode', async () => {
mockDocForm.current = ChunkingModeEnum.qa
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateSegment('seg-1', '', 'answer', [], [])
})
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'Question cannot be empty' })
})
it('should validate empty answer in QA mode', async () => {
mockDocForm.current = ChunkingModeEnum.qa
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateSegment('seg-1', 'question', ' ', [], [])
})
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'Answer cannot be empty' })
})
it('should validate attachments are uploaded', async () => {
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateSegment('seg-1', 'content', '', [], [
createMockFileEntity({ id: '1', name: 'test.png', uploadedId: undefined }),
])
})
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'All files must be uploaded' })
})
it('should call updateSegment with correct params', async () => {
mockUpdateSegment.mockImplementation(async (_params, { onSuccess, onSettled }: SegmentMutationCallbacks) => {
onSuccess({ data: createMockSegment() })
onSettled()
})
const onCloseSegmentDetail = vi.fn()
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
onCloseSegmentDetail,
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateSegment('seg-1', 'updated content', '', ['keyword1'], [])
})
expect(mockUpdateSegment).toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'Modified successfully' })
expect(onCloseSegmentDetail).toHaveBeenCalled()
expect(mockEventEmitter.emit).toHaveBeenCalledWith('update-segment')
expect(mockEventEmitter.emit).toHaveBeenCalledWith('update-segment-success')
expect(mockEventEmitter.emit).toHaveBeenCalledWith('update-segment-done')
})
it('should not close modal when needRegenerate is true', async () => {
mockUpdateSegment.mockImplementation(async (_params, { onSuccess, onSettled }: SegmentMutationCallbacks) => {
onSuccess({ data: createMockSegment() })
onSettled()
})
const onCloseSegmentDetail = vi.fn()
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
onCloseSegmentDetail,
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateSegment('seg-1', 'content', '', [], [], true)
})
expect(onCloseSegmentDetail).not.toHaveBeenCalled()
})
it('should include attachments in params', async () => {
mockUpdateSegment.mockImplementation(async (_params, { onSuccess, onSettled }: SegmentMutationCallbacks) => {
onSuccess({ data: createMockSegment() })
onSettled()
})
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateSegment('seg-1', 'content', '', [], [
createMockFileEntity({ id: '1', name: 'test.png', uploadedId: 'uploaded-1' }),
])
})
expect(mockUpdateSegment).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.objectContaining({ attachment_ids: ['uploaded-1'] }),
}),
expect.any(Object),
)
})
})
describe('viewNewlyAddedChunk', () => {
it('should set needScrollToBottom and not call resetList when adding new page', () => {
mockSegmentListData.current = { data: [], total: 10, total_pages: 1, has_more: false, limit: 20, page: 1 }
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
limit: 10,
}), {
wrapper: createWrapper(),
})
act(() => {
result.current.viewNewlyAddedChunk()
})
expect(result.current.needScrollToBottom.current).toBe(true)
})
it('should call resetList when not adding new page', () => {
mockSegmentListData.current = { data: [], total: 5, total_pages: 1, has_more: false, limit: 20, page: 1 }
const clearSelection = vi.fn()
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
clearSelection,
limit: 10,
}), {
wrapper: createWrapper(),
})
act(() => {
result.current.viewNewlyAddedChunk()
})
// resetList should be called
expect(clearSelection).toHaveBeenCalled()
})
})
describe('updateSegmentInCache', () => {
it('should call queryClient.setQueryData', () => {
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
act(() => {
result.current.updateSegmentInCache('seg-1', seg => ({ ...seg, enabled: false }))
})
expect(mockQueryClient.setQueryData).toHaveBeenCalled()
})
})
describe('Effect: pathname change', () => {
it('should reset list when pathname changes', async () => {
const clearSelection = vi.fn()
renderHook(() => useSegmentListData({
...defaultOptions,
clearSelection,
}), {
wrapper: createWrapper(),
})
// Initial call from effect
expect(clearSelection).toHaveBeenCalled()
expect(mockInvalidSegmentList).toHaveBeenCalled()
})
})
describe('Effect: import status', () => {
it('should reset list when import status is COMPLETED', () => {
const clearSelection = vi.fn()
renderHook(() => useSegmentListData({
...defaultOptions,
importStatus: ProcessStatus.COMPLETED,
clearSelection,
}), {
wrapper: createWrapper(),
})
expect(clearSelection).toHaveBeenCalled()
})
})
describe('refreshChunkListDataWithDetailChanged', () => {
it('should call correct invalidation for status all', async () => {
mockUpdateSegment.mockImplementation(async (_params, { onSuccess, onSettled }: SegmentMutationCallbacks) => {
onSuccess({ data: createMockSegment() })
onSettled()
})
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
selectedStatus: 'all',
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateSegment('seg-1', 'content', '', [], [])
})
expect(mockInvalidChunkListDisabled).toHaveBeenCalled()
expect(mockInvalidChunkListEnabled).toHaveBeenCalled()
})
it('should call correct invalidation for status true', async () => {
mockUpdateSegment.mockImplementation(async (_params, { onSuccess, onSettled }: SegmentMutationCallbacks) => {
onSuccess({ data: createMockSegment() })
onSettled()
})
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
selectedStatus: true,
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateSegment('seg-1', 'content', '', [], [])
})
expect(mockInvalidChunkListAll).toHaveBeenCalled()
expect(mockInvalidChunkListDisabled).toHaveBeenCalled()
})
it('should call correct invalidation for status false', async () => {
mockUpdateSegment.mockImplementation(async (_params, { onSuccess, onSettled }: SegmentMutationCallbacks) => {
onSuccess({ data: createMockSegment() })
onSettled()
})
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
selectedStatus: false,
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateSegment('seg-1', 'content', '', [], [])
})
expect(mockInvalidChunkListAll).toHaveBeenCalled()
expect(mockInvalidChunkListEnabled).toHaveBeenCalled()
})
})
describe('QA Mode validation', () => {
it('should set content and answer for QA mode', async () => {
mockDocForm.current = ChunkingModeEnum.qa as ChunkingMode
mockUpdateSegment.mockImplementation(async (_params, { onSuccess, onSettled }: SegmentMutationCallbacks) => {
onSuccess({ data: createMockSegment() })
onSettled()
})
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.handleUpdateSegment('seg-1', 'question', 'answer', [], [])
})
expect(mockUpdateSegment).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.objectContaining({
content: 'question',
answer: 'answer',
}),
}),
expect.any(Object),
)
})
})
describe('updateSegmentsInCache', () => {
it('should handle undefined old data', () => {
mockQueryClient.setQueryData.mockImplementation((_key, updater) => {
const result = typeof updater === 'function' ? updater(undefined) : updater
return result
})
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
// Call updateSegmentInCache which should handle undefined gracefully
act(() => {
result.current.updateSegmentInCache('seg-1', seg => ({ ...seg, enabled: false }))
})
expect(mockQueryClient.setQueryData).toHaveBeenCalled()
})
it('should map segments correctly when old data exists', () => {
const mockOldData = {
data: [
createMockSegment({ id: 'seg-1', enabled: true }),
createMockSegment({ id: 'seg-2', enabled: true }),
],
total: 2,
total_pages: 1,
}
mockQueryClient.setQueryData.mockImplementation((_key, updater) => {
const result = typeof updater === 'function' ? updater(mockOldData) : updater
// Verify the updater transforms the data correctly
expect(result.data[0].enabled).toBe(false) // seg-1 should be updated
expect(result.data[1].enabled).toBe(true) // seg-2 should remain unchanged
return result
})
const { result } = renderHook(() => useSegmentListData(defaultOptions), {
wrapper: createWrapper(),
})
act(() => {
result.current.updateSegmentInCache('seg-1', seg => ({ ...seg, enabled: false }))
})
expect(mockQueryClient.setQueryData).toHaveBeenCalled()
})
})
describe('updateSegmentsInCache batch', () => {
it('should handle undefined old data in batch update', async () => {
mockQueryClient.setQueryData.mockImplementation((_key, updater) => {
const result = typeof updater === 'function' ? updater(undefined) : updater
return result
})
mockEnableSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => {
onSuccess()
})
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
selectedSegmentIds: ['seg-1', 'seg-2'],
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.onChangeSwitch(true, '')
})
expect(mockQueryClient.setQueryData).toHaveBeenCalled()
})
it('should map multiple segments correctly when old data exists', async () => {
const mockOldData = {
data: [
createMockSegment({ id: 'seg-1', enabled: false }),
createMockSegment({ id: 'seg-2', enabled: false }),
createMockSegment({ id: 'seg-3', enabled: false }),
],
total: 3,
total_pages: 1,
}
mockQueryClient.setQueryData.mockImplementation((_key, updater) => {
const result = typeof updater === 'function' ? updater(mockOldData) : updater
// Verify only selected segments are updated
if (result && result.data) {
expect(result.data[0].enabled).toBe(true) // seg-1 should be updated
expect(result.data[1].enabled).toBe(true) // seg-2 should be updated
expect(result.data[2].enabled).toBe(false) // seg-3 should remain unchanged
}
return result
})
mockEnableSegment.mockImplementation(async (_params, { onSuccess }: { onSuccess: () => void }) => {
onSuccess()
})
const { result } = renderHook(() => useSegmentListData({
...defaultOptions,
selectedSegmentIds: ['seg-1', 'seg-2'],
}), {
wrapper: createWrapper(),
})
await act(async () => {
await result.current.onChangeSwitch(true, '')
})
expect(mockQueryClient.setQueryData).toHaveBeenCalled()
})
})
})

View File

@ -1,363 +0,0 @@
import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types'
import type { SegmentDetailModel, SegmentsResponse, SegmentUpdater } from '@/models/datasets'
import { useQueryClient } from '@tanstack/react-query'
import { usePathname } from 'next/navigation'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useToastContext } from '@/app/components/base/toast'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { ChunkingMode } from '@/models/datasets'
import {
useChunkListAllKey,
useChunkListDisabledKey,
useChunkListEnabledKey,
useDeleteSegment,
useDisableSegment,
useEnableSegment,
useSegmentList,
useSegmentListKey,
useUpdateSegment,
} from '@/service/knowledge/use-segment'
import { useInvalid } from '@/service/use-base'
import { formatNumber } from '@/utils/format'
import { useDocumentContext } from '../../context'
import { ProcessStatus } from '../../segment-add'
const DEFAULT_LIMIT = 10
export type UseSegmentListDataOptions = {
searchValue: string
selectedStatus: boolean | 'all'
selectedSegmentIds: string[]
importStatus: ProcessStatus | string | undefined
currentPage: number
limit: number
onCloseSegmentDetail: () => void
clearSelection: () => void
}
export type UseSegmentListDataReturn = {
segments: SegmentDetailModel[]
isLoadingSegmentList: boolean
segmentListData: ReturnType<typeof useSegmentList>['data']
totalText: string
isFullDocMode: boolean
segmentListRef: React.RefObject<HTMLDivElement | null>
needScrollToBottom: React.RefObject<boolean>
// Operations
onChangeSwitch: (enable: boolean, segId?: string) => Promise<void>
onDelete: (segId?: string) => Promise<void>
handleUpdateSegment: (
segmentId: string,
question: string,
answer: string,
keywords: string[],
attachments: FileEntity[],
needRegenerate?: boolean,
) => Promise<void>
resetList: () => void
viewNewlyAddedChunk: () => void
invalidSegmentList: () => void
updateSegmentInCache: (segmentId: string, updater: (seg: SegmentDetailModel) => SegmentDetailModel) => void
}
export const useSegmentListData = (options: UseSegmentListDataOptions): UseSegmentListDataReturn => {
const {
searchValue,
selectedStatus,
selectedSegmentIds,
importStatus,
currentPage,
limit,
onCloseSegmentDetail,
clearSelection,
} = options
const { t } = useTranslation()
const { notify } = useToastContext()
const pathname = usePathname()
const { eventEmitter } = useEventEmitterContextContext()
const queryClient = useQueryClient()
const datasetId = useDocumentContext(s => s.datasetId) || ''
const documentId = useDocumentContext(s => s.documentId) || ''
const docForm = useDocumentContext(s => s.docForm)
const parentMode = useDocumentContext(s => s.parentMode)
const segmentListRef = useRef<HTMLDivElement>(null)
const needScrollToBottom = useRef(false)
const isFullDocMode = useMemo(() => {
return docForm === ChunkingMode.parentChild && parentMode === 'full-doc'
}, [docForm, parentMode])
// Build query params
const queryParams = useMemo(() => ({
page: isFullDocMode ? 1 : currentPage,
limit: isFullDocMode ? DEFAULT_LIMIT : limit,
keyword: isFullDocMode ? '' : searchValue,
enabled: selectedStatus,
}), [isFullDocMode, currentPage, limit, searchValue, selectedStatus])
// Build query key for optimistic updates
const currentQueryKey = useMemo(() =>
[...useSegmentListKey, datasetId, documentId, queryParams], [datasetId, documentId, queryParams])
// Fetch segment list
const { isLoading: isLoadingSegmentList, data: segmentListData } = useSegmentList({
datasetId,
documentId,
params: queryParams,
})
// Derive segments from query data
const segments = useMemo(() => segmentListData?.data || [], [segmentListData])
// Invalidation hooks
const invalidSegmentList = useInvalid(useSegmentListKey)
const invalidChunkListAll = useInvalid(useChunkListAllKey)
const invalidChunkListEnabled = useInvalid(useChunkListEnabledKey)
const invalidChunkListDisabled = useInvalid(useChunkListDisabledKey)
// Scroll to bottom when needed
useEffect(() => {
if (segmentListRef.current && needScrollToBottom.current) {
segmentListRef.current.scrollTo({ top: segmentListRef.current.scrollHeight, behavior: 'smooth' })
needScrollToBottom.current = false
}
}, [segments])
// Reset list on pathname change
useEffect(() => {
clearSelection()
invalidSegmentList()
}, [pathname])
// Reset list on import completion
useEffect(() => {
if (importStatus === ProcessStatus.COMPLETED) {
clearSelection()
invalidSegmentList()
}
}, [importStatus])
const resetList = useCallback(() => {
clearSelection()
invalidSegmentList()
}, [clearSelection, invalidSegmentList])
const refreshChunkListWithStatusChanged = useCallback(() => {
if (selectedStatus === 'all') {
invalidChunkListDisabled()
invalidChunkListEnabled()
}
else {
invalidSegmentList()
}
}, [selectedStatus, invalidChunkListDisabled, invalidChunkListEnabled, invalidSegmentList])
const refreshChunkListDataWithDetailChanged = useCallback(() => {
const refreshMap: Record<string, () => void> = {
all: () => {
invalidChunkListDisabled()
invalidChunkListEnabled()
},
true: () => {
invalidChunkListAll()
invalidChunkListDisabled()
},
false: () => {
invalidChunkListAll()
invalidChunkListEnabled()
},
}
refreshMap[String(selectedStatus)]?.()
}, [selectedStatus, invalidChunkListDisabled, invalidChunkListEnabled, invalidChunkListAll])
// Optimistic update helper using React Query's setQueryData
const updateSegmentInCache = useCallback((
segmentId: string,
updater: (seg: SegmentDetailModel) => SegmentDetailModel,
) => {
queryClient.setQueryData<SegmentsResponse>(currentQueryKey, (old) => {
if (!old)
return old
return {
...old,
data: old.data.map(seg => seg.id === segmentId ? updater(seg) : seg),
}
})
}, [queryClient, currentQueryKey])
// Batch update helper
const updateSegmentsInCache = useCallback((
segmentIds: string[],
updater: (seg: SegmentDetailModel) => SegmentDetailModel,
) => {
queryClient.setQueryData<SegmentsResponse>(currentQueryKey, (old) => {
if (!old)
return old
return {
...old,
data: old.data.map(seg => segmentIds.includes(seg.id) ? updater(seg) : seg),
}
})
}, [queryClient, currentQueryKey])
// Mutations
const { mutateAsync: enableSegment } = useEnableSegment()
const { mutateAsync: disableSegment } = useDisableSegment()
const { mutateAsync: deleteSegment } = useDeleteSegment()
const { mutateAsync: updateSegment } = useUpdateSegment()
const onChangeSwitch = useCallback(async (enable: boolean, segId?: string) => {
const operationApi = enable ? enableSegment : disableSegment
const targetIds = segId ? [segId] : selectedSegmentIds
await operationApi({ datasetId, documentId, segmentIds: targetIds }, {
onSuccess: () => {
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
updateSegmentsInCache(targetIds, seg => ({ ...seg, enabled: enable }))
refreshChunkListWithStatusChanged()
},
onError: () => {
notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
},
})
}, [datasetId, documentId, selectedSegmentIds, disableSegment, enableSegment, t, notify, updateSegmentsInCache, refreshChunkListWithStatusChanged])
const onDelete = useCallback(async (segId?: string) => {
const targetIds = segId ? [segId] : selectedSegmentIds
await deleteSegment({ datasetId, documentId, segmentIds: targetIds }, {
onSuccess: () => {
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
resetList()
if (!segId)
clearSelection()
},
onError: () => {
notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
},
})
}, [datasetId, documentId, selectedSegmentIds, deleteSegment, resetList, clearSelection, t, notify])
const handleUpdateSegment = useCallback(async (
segmentId: string,
question: string,
answer: string,
keywords: string[],
attachments: FileEntity[],
needRegenerate = false,
) => {
const params: SegmentUpdater = { content: '', attachment_ids: [] }
// Validate and build params based on doc form
if (docForm === ChunkingMode.qa) {
if (!question.trim()) {
notify({ type: 'error', message: t('segment.questionEmpty', { ns: 'datasetDocuments' }) })
return
}
if (!answer.trim()) {
notify({ type: 'error', message: t('segment.answerEmpty', { ns: 'datasetDocuments' }) })
return
}
params.content = question
params.answer = answer
}
else {
if (!question.trim()) {
notify({ type: 'error', message: t('segment.contentEmpty', { ns: 'datasetDocuments' }) })
return
}
params.content = question
}
if (keywords.length)
params.keywords = keywords
if (attachments.length) {
const notAllUploaded = attachments.some(item => !item.uploadedId)
if (notAllUploaded) {
notify({ type: 'error', message: t('segment.allFilesUploaded', { ns: 'datasetDocuments' }) })
return
}
params.attachment_ids = attachments.map(item => item.uploadedId!)
}
if (needRegenerate)
params.regenerate_child_chunks = needRegenerate
eventEmitter?.emit('update-segment')
await updateSegment({ datasetId, documentId, segmentId, body: params }, {
onSuccess(res) {
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
if (!needRegenerate)
onCloseSegmentDetail()
updateSegmentInCache(segmentId, seg => ({
...seg,
answer: res.data.answer,
content: res.data.content,
sign_content: res.data.sign_content,
keywords: res.data.keywords,
attachments: res.data.attachments,
word_count: res.data.word_count,
hit_count: res.data.hit_count,
enabled: res.data.enabled,
updated_at: res.data.updated_at,
child_chunks: res.data.child_chunks,
}))
refreshChunkListDataWithDetailChanged()
eventEmitter?.emit('update-segment-success')
},
onSettled() {
eventEmitter?.emit('update-segment-done')
},
})
}, [datasetId, documentId, docForm, updateSegment, notify, eventEmitter, onCloseSegmentDetail, updateSegmentInCache, refreshChunkListDataWithDetailChanged, t])
const viewNewlyAddedChunk = useCallback(() => {
const totalPages = segmentListData?.total_pages || 0
const total = segmentListData?.total || 0
const newPage = Math.ceil((total + 1) / limit)
needScrollToBottom.current = true
if (newPage > totalPages)
return
resetList()
}, [segmentListData, limit, resetList])
// Compute total text for display
const totalText = useMemo(() => {
const isSearch = searchValue !== '' || selectedStatus !== 'all'
if (!isSearch) {
const total = segmentListData?.total ? formatNumber(segmentListData.total) : '--'
const count = total === '--' ? 0 : segmentListData!.total
const translationKey = (docForm === ChunkingMode.parentChild && parentMode === 'paragraph')
? 'segment.parentChunks' as const
: 'segment.chunks' as const
return `${total} ${t(translationKey, { ns: 'datasetDocuments', count })}`
}
const total = typeof segmentListData?.total === 'number' ? formatNumber(segmentListData.total) : 0
const count = segmentListData?.total || 0
return `${total} ${t('segment.searchResults', { ns: 'datasetDocuments', count })}`
}, [segmentListData, docForm, parentMode, searchValue, selectedStatus, t])
return {
segments,
isLoadingSegmentList,
segmentListData,
totalText,
isFullDocMode,
segmentListRef,
needScrollToBottom,
onChangeSwitch,
onDelete,
handleUpdateSegment,
resetList,
viewNewlyAddedChunk,
invalidSegmentList,
updateSegmentInCache,
}
}

View File

@ -1,58 +0,0 @@
import type { SegmentDetailModel } from '@/models/datasets'
import { useCallback, useMemo, useState } from 'react'
export type UseSegmentSelectionReturn = {
selectedSegmentIds: string[]
isAllSelected: boolean
isSomeSelected: boolean
onSelected: (segId: string) => void
onSelectedAll: () => void
onCancelBatchOperation: () => void
clearSelection: () => void
}
export const useSegmentSelection = (segments: SegmentDetailModel[]): UseSegmentSelectionReturn => {
const [selectedSegmentIds, setSelectedSegmentIds] = useState<string[]>([])
const onSelected = useCallback((segId: string) => {
setSelectedSegmentIds(prev =>
prev.includes(segId)
? prev.filter(id => id !== segId)
: [...prev, segId],
)
}, [])
const isAllSelected = useMemo(() => {
return segments.length > 0 && segments.every(seg => selectedSegmentIds.includes(seg.id))
}, [segments, selectedSegmentIds])
const isSomeSelected = useMemo(() => {
return segments.some(seg => selectedSegmentIds.includes(seg.id))
}, [segments, selectedSegmentIds])
const onSelectedAll = useCallback(() => {
setSelectedSegmentIds((prev) => {
const currentAllSegIds = segments.map(seg => seg.id)
const prevSelectedIds = prev.filter(item => !currentAllSegIds.includes(item))
return [...prevSelectedIds, ...(isAllSelected ? [] : currentAllSegIds)]
})
}, [segments, isAllSelected])
const onCancelBatchOperation = useCallback(() => {
setSelectedSegmentIds([])
}, [])
const clearSelection = useCallback(() => {
setSelectedSegmentIds([])
}, [])
return {
selectedSegmentIds,
isAllSelected,
isSomeSelected,
onSelected,
onSelectedAll,
onCancelBatchOperation,
clearSelection,
}
}

View File

@ -1,33 +1,89 @@
'use client'
import type { FC } from 'react'
import type { ProcessStatus } from '../segment-add'
import type { SegmentListContextValue } from './segment-list-context'
import { useCallback, useMemo, useState } from 'react'
import type { Item } from '@/app/components/base/select'
import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types'
import type { ChildChunkDetail, SegmentDetailModel, SegmentUpdater } from '@/models/datasets'
import { useDebounceFn } from 'ahooks'
import { noop } from 'es-toolkit/function'
import { usePathname } from 'next/navigation'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { createContext, useContext, useContextSelector } from 'use-context-selector'
import Checkbox from '@/app/components/base/checkbox'
import Divider from '@/app/components/base/divider'
import Input from '@/app/components/base/input'
import Pagination from '@/app/components/base/pagination'
import { SimpleSelect } from '@/app/components/base/select'
import { ToastContext } from '@/app/components/base/toast'
import NewSegment from '@/app/components/datasets/documents/detail/new-segment'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { ChunkingMode } from '@/models/datasets'
import {
useChildSegmentList,
useChildSegmentListKey,
useChunkListAllKey,
useChunkListDisabledKey,
useChunkListEnabledKey,
useDeleteChildSegment,
useDeleteSegment,
useDisableSegment,
useEnableSegment,
useSegmentList,
useSegmentListKey,
useUpdateChildSegment,
useUpdateSegment,
} from '@/service/knowledge/use-segment'
import { useInvalid } from '@/service/use-base'
import { cn } from '@/utils/classnames'
import { formatNumber } from '@/utils/format'
import { useDocumentContext } from '../context'
import { ProcessStatus } from '../segment-add'
import ChildSegmentDetail from './child-segment-detail'
import ChildSegmentList from './child-segment-list'
import BatchAction from './common/batch-action'
import { DrawerGroup, FullDocModeContent, GeneralModeContent, MenuBar } from './components'
import {
useChildSegmentData,
useModalState,
useSearchFilter,
useSegmentListData,
useSegmentSelection,
} from './hooks'
import {
SegmentListContext,
useSegmentListContext,
} from './segment-list-context'
import FullScreenDrawer from './common/full-screen-drawer'
import DisplayToggle from './display-toggle'
import NewChildSegment from './new-child-segment'
import SegmentCard from './segment-card'
import SegmentDetail from './segment-detail'
import SegmentList from './segment-list'
import StatusItem from './status-item'
import s from './style.module.css'
const DEFAULT_LIMIT = 10
type CurrSegmentType = {
segInfo?: SegmentDetailModel
showModal: boolean
isEditMode?: boolean
}
type CurrChildChunkType = {
childChunkInfo?: ChildChunkDetail
showModal: boolean
}
export type SegmentListContextValue = {
isCollapsed: boolean
fullScreen: boolean
toggleFullScreen: (fullscreen?: boolean) => void
currSegment: CurrSegmentType
currChildChunk: CurrChildChunkType
}
const SegmentListContext = createContext<SegmentListContextValue>({
isCollapsed: true,
fullScreen: false,
toggleFullScreen: noop,
currSegment: { showModal: false },
currChildChunk: { showModal: false },
})
export const useSegmentListContext = (selector: (value: SegmentListContextValue) => any) => {
return useContextSelector(SegmentListContext, selector)
}
type ICompletedProps = {
embeddingAvailable: boolean
showNewSegmentModal: boolean
@ -35,7 +91,6 @@ type ICompletedProps = {
importStatus: ProcessStatus | string | undefined
archived?: boolean
}
/**
* Embedding done, show list of all segments
* Support search and filter
@ -47,219 +102,669 @@ const Completed: FC<ICompletedProps> = ({
importStatus,
archived,
}) => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const pathname = usePathname()
const datasetId = useDocumentContext(s => s.datasetId) || ''
const documentId = useDocumentContext(s => s.documentId) || ''
const docForm = useDocumentContext(s => s.docForm)
const parentMode = useDocumentContext(s => s.parentMode)
// the current segment id and whether to show the modal
const [currSegment, setCurrSegment] = useState<CurrSegmentType>({ showModal: false })
const [currChildChunk, setCurrChildChunk] = useState<CurrChildChunkType>({ showModal: false })
const [currChunkId, setCurrChunkId] = useState('')
// Pagination state
const [currentPage, setCurrentPage] = useState(1)
const [inputValue, setInputValue] = useState<string>('') // the input value
const [searchValue, setSearchValue] = useState<string>('') // the search value
const [selectedStatus, setSelectedStatus] = useState<boolean | 'all'>('all') // the selected status, enabled/disabled/undefined
const [segments, setSegments] = useState<SegmentDetailModel[]>([]) // all segments data
const [childSegments, setChildSegments] = useState<ChildChunkDetail[]>([]) // all child segments data
const [selectedSegmentIds, setSelectedSegmentIds] = useState<string[]>([])
const { eventEmitter } = useEventEmitterContextContext()
const [isCollapsed, setIsCollapsed] = useState(true)
const [currentPage, setCurrentPage] = useState(1) // start from 1
const [limit, setLimit] = useState(DEFAULT_LIMIT)
const [fullScreen, setFullScreen] = useState(false)
const [showNewChildSegmentModal, setShowNewChildSegmentModal] = useState(false)
const [isRegenerationModalOpen, setIsRegenerationModalOpen] = useState(false)
// Search and filter state
const searchFilter = useSearchFilter({
onPageChange: setCurrentPage,
})
const segmentListRef = useRef<HTMLDivElement>(null)
const childSegmentListRef = useRef<HTMLDivElement>(null)
const needScrollToBottom = useRef(false)
const statusList = useRef<Item[]>([
{ value: 'all', name: t('list.index.all', { ns: 'datasetDocuments' }) },
{ value: 0, name: t('list.status.disabled', { ns: 'datasetDocuments' }) },
{ value: 1, name: t('list.status.enabled', { ns: 'datasetDocuments' }) },
])
// Modal state
const modalState = useModalState({
onNewSegmentModalChange,
})
const { run: handleSearch } = useDebounceFn(() => {
setSearchValue(inputValue)
setCurrentPage(1)
}, { wait: 500 })
// Selection state (need segments first, so we use a placeholder initially)
const [segmentsForSelection, setSegmentsForSelection] = useState<string[]>([])
const handleInputChange = (value: string) => {
setInputValue(value)
handleSearch()
}
// Invalidation hooks for child segment data
const onChangeStatus = ({ value }: Item) => {
setSelectedStatus(value === 'all' ? 'all' : !!value)
setCurrentPage(1)
}
const isFullDocMode = useMemo(() => {
return docForm === ChunkingMode.parentChild && parentMode === 'full-doc'
}, [docForm, parentMode])
const { isLoading: isLoadingSegmentList, data: segmentListData } = useSegmentList(
{
datasetId,
documentId,
params: {
page: isFullDocMode ? 1 : currentPage,
limit: isFullDocMode ? 10 : limit,
keyword: isFullDocMode ? '' : searchValue,
enabled: selectedStatus,
},
},
)
const invalidSegmentList = useInvalid(useSegmentListKey)
useEffect(() => {
if (segmentListData) {
setSegments(segmentListData.data || [])
const totalPages = segmentListData.total_pages
if (totalPages < currentPage)
setCurrentPage(totalPages === 0 ? 1 : totalPages)
}
}, [segmentListData])
useEffect(() => {
if (segmentListRef.current && needScrollToBottom.current) {
segmentListRef.current.scrollTo({ top: segmentListRef.current.scrollHeight, behavior: 'smooth' })
needScrollToBottom.current = false
}
}, [segments])
const { isLoading: isLoadingChildSegmentList, data: childChunkListData } = useChildSegmentList(
{
datasetId,
documentId,
segmentId: segments[0]?.id || '',
params: {
page: currentPage === 0 ? 1 : currentPage,
limit,
keyword: searchValue,
},
},
!isFullDocMode || segments.length === 0,
)
const invalidChildSegmentList = useInvalid(useChildSegmentListKey)
useEffect(() => {
if (childSegmentListRef.current && needScrollToBottom.current) {
childSegmentListRef.current.scrollTo({ top: childSegmentListRef.current.scrollHeight, behavior: 'smooth' })
needScrollToBottom.current = false
}
}, [childSegments])
useEffect(() => {
if (childChunkListData) {
setChildSegments(childChunkListData.data || [])
const totalPages = childChunkListData.total_pages
if (totalPages < currentPage)
setCurrentPage(totalPages === 0 ? 1 : totalPages)
}
}, [childChunkListData])
const resetList = useCallback(() => {
setSelectedSegmentIds([])
invalidSegmentList()
}, [invalidSegmentList])
const resetChildList = useCallback(() => {
invalidChildSegmentList()
}, [invalidChildSegmentList])
const onClickCard = (detail: SegmentDetailModel, isEditMode = false) => {
setCurrSegment({ segInfo: detail, showModal: true, isEditMode })
}
const onCloseSegmentDetail = useCallback(() => {
setCurrSegment({ showModal: false })
setFullScreen(false)
}, [])
const onCloseNewSegmentModal = useCallback(() => {
onNewSegmentModalChange(false)
setFullScreen(false)
}, [onNewSegmentModalChange])
const onCloseNewChildChunkModal = useCallback(() => {
setShowNewChildSegmentModal(false)
setFullScreen(false)
}, [])
const { mutateAsync: enableSegment } = useEnableSegment()
const { mutateAsync: disableSegment } = useDisableSegment()
const invalidChunkListAll = useInvalid(useChunkListAllKey)
const invalidChunkListEnabled = useInvalid(useChunkListEnabledKey)
const invalidChunkListDisabled = useInvalid(useChunkListDisabledKey)
const refreshChunkListDataWithDetailChanged = useCallback(() => {
const refreshMap: Record<string, () => void> = {
all: () => {
const refreshChunkListWithStatusChanged = useCallback(() => {
switch (selectedStatus) {
case 'all':
invalidChunkListDisabled()
invalidChunkListEnabled()
},
true: () => {
invalidChunkListAll()
invalidChunkListDisabled()
},
false: () => {
invalidChunkListAll()
invalidChunkListEnabled()
},
break
default:
invalidSegmentList()
}
refreshMap[String(searchFilter.selectedStatus)]?.()
}, [searchFilter.selectedStatus, invalidChunkListDisabled, invalidChunkListEnabled, invalidChunkListAll])
}, [selectedStatus, invalidChunkListDisabled, invalidChunkListEnabled, invalidSegmentList])
// Segment list data
const segmentListDataHook = useSegmentListData({
searchValue: searchFilter.searchValue,
selectedStatus: searchFilter.selectedStatus,
selectedSegmentIds: segmentsForSelection,
importStatus,
currentPage,
limit,
onCloseSegmentDetail: modalState.onCloseSegmentDetail,
clearSelection: () => setSegmentsForSelection([]),
})
const onChangeSwitch = useCallback(async (enable: boolean, segId?: string) => {
const operationApi = enable ? enableSegment : disableSegment
await operationApi({ datasetId, documentId, segmentIds: segId ? [segId] : selectedSegmentIds }, {
onSuccess: () => {
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
for (const seg of segments) {
if (segId ? seg.id === segId : selectedSegmentIds.includes(seg.id))
seg.enabled = enable
}
setSegments([...segments])
refreshChunkListWithStatusChanged()
},
onError: () => {
notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
},
})
}, [datasetId, documentId, selectedSegmentIds, segments, disableSegment, enableSegment, t, notify, refreshChunkListWithStatusChanged])
// Selection state (with actual segments)
const selectionState = useSegmentSelection(segmentListDataHook.segments)
const { mutateAsync: deleteSegment } = useDeleteSegment()
// Sync selection state for segment list data hook
useMemo(() => {
setSegmentsForSelection(selectionState.selectedSegmentIds)
}, [selectionState.selectedSegmentIds])
const onDelete = useCallback(async (segId?: string) => {
await deleteSegment({ datasetId, documentId, segmentIds: segId ? [segId] : selectedSegmentIds }, {
onSuccess: () => {
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
resetList()
if (!segId)
setSelectedSegmentIds([])
},
onError: () => {
notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
},
})
}, [datasetId, documentId, selectedSegmentIds, deleteSegment, resetList, t, notify])
// Child segment data
const childSegmentDataHook = useChildSegmentData({
searchValue: searchFilter.searchValue,
currentPage,
limit,
segments: segmentListDataHook.segments,
currChunkId: modalState.currChunkId,
isFullDocMode: segmentListDataHook.isFullDocMode,
onCloseChildSegmentDetail: modalState.onCloseChildSegmentDetail,
refreshChunkListDataWithDetailChanged,
updateSegmentInCache: segmentListDataHook.updateSegmentInCache,
})
const { mutateAsync: updateSegment } = useUpdateSegment()
// Compute total for pagination
const paginationTotal = useMemo(() => {
if (segmentListDataHook.isFullDocMode)
return childSegmentDataHook.childChunkListData?.total || 0
return segmentListDataHook.segmentListData?.total || 0
}, [segmentListDataHook.isFullDocMode, childSegmentDataHook.childChunkListData, segmentListDataHook.segmentListData])
const refreshChunkListDataWithDetailChanged = useCallback(() => {
switch (selectedStatus) {
case 'all':
invalidChunkListDisabled()
invalidChunkListEnabled()
break
case true:
invalidChunkListAll()
invalidChunkListDisabled()
break
case false:
invalidChunkListAll()
invalidChunkListEnabled()
break
}
}, [selectedStatus, invalidChunkListDisabled, invalidChunkListEnabled, invalidChunkListAll])
// Handle page change
const handlePageChange = useCallback((page: number) => {
setCurrentPage(page + 1)
const handleUpdateSegment = useCallback(async (
segmentId: string,
question: string,
answer: string,
keywords: string[],
attachments: FileEntity[],
needRegenerate = false,
) => {
const params: SegmentUpdater = { content: '', attachment_ids: [] }
if (docForm === ChunkingMode.qa) {
if (!question.trim())
return notify({ type: 'error', message: t('segment.questionEmpty', { ns: 'datasetDocuments' }) })
if (!answer.trim())
return notify({ type: 'error', message: t('segment.answerEmpty', { ns: 'datasetDocuments' }) })
params.content = question
params.answer = answer
}
else {
if (!question.trim())
return notify({ type: 'error', message: t('segment.contentEmpty', { ns: 'datasetDocuments' }) })
params.content = question
}
if (keywords.length)
params.keywords = keywords
if (attachments.length) {
const notAllUploaded = attachments.some(item => !item.uploadedId)
if (notAllUploaded)
return notify({ type: 'error', message: t('segment.allFilesUploaded', { ns: 'datasetDocuments' }) })
params.attachment_ids = attachments.map(item => item.uploadedId!)
}
if (needRegenerate)
params.regenerate_child_chunks = needRegenerate
eventEmitter?.emit('update-segment')
await updateSegment({ datasetId, documentId, segmentId, body: params }, {
onSuccess(res) {
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
if (!needRegenerate)
onCloseSegmentDetail()
for (const seg of segments) {
if (seg.id === segmentId) {
seg.answer = res.data.answer
seg.content = res.data.content
seg.sign_content = res.data.sign_content
seg.keywords = res.data.keywords
seg.attachments = res.data.attachments
seg.word_count = res.data.word_count
seg.hit_count = res.data.hit_count
seg.enabled = res.data.enabled
seg.updated_at = res.data.updated_at
seg.child_chunks = res.data.child_chunks
}
}
setSegments([...segments])
refreshChunkListDataWithDetailChanged()
eventEmitter?.emit('update-segment-success')
},
onSettled() {
eventEmitter?.emit('update-segment-done')
},
})
}, [segments, datasetId, documentId, updateSegment, docForm, notify, eventEmitter, onCloseSegmentDetail, refreshChunkListDataWithDetailChanged, t])
useEffect(() => {
resetList()
}, [pathname])
useEffect(() => {
if (importStatus === ProcessStatus.COMPLETED)
resetList()
}, [importStatus])
const onCancelBatchOperation = useCallback(() => {
setSelectedSegmentIds([])
}, [])
// Context value
const onSelected = useCallback((segId: string) => {
setSelectedSegmentIds(prev =>
prev.includes(segId)
? prev.filter(id => id !== segId)
: [...prev, segId],
)
}, [])
const isAllSelected = useMemo(() => {
return segments.length > 0 && segments.every(seg => selectedSegmentIds.includes(seg.id))
}, [segments, selectedSegmentIds])
const isSomeSelected = useMemo(() => {
return segments.some(seg => selectedSegmentIds.includes(seg.id))
}, [segments, selectedSegmentIds])
const onSelectedAll = useCallback(() => {
setSelectedSegmentIds((prev) => {
const currentAllSegIds = segments.map(seg => seg.id)
const prevSelectedIds = prev.filter(item => !currentAllSegIds.includes(item))
return [...prevSelectedIds, ...(isAllSelected ? [] : currentAllSegIds)]
})
}, [segments, isAllSelected])
const totalText = useMemo(() => {
const isSearch = searchValue !== '' || selectedStatus !== 'all'
if (!isSearch) {
const total = segmentListData?.total ? formatNumber(segmentListData.total) : '--'
const count = total === '--' ? 0 : segmentListData!.total
const translationKey = (docForm === ChunkingMode.parentChild && parentMode === 'paragraph')
? 'segment.parentChunks' as const
: 'segment.chunks' as const
return `${total} ${t(translationKey, { ns: 'datasetDocuments', count })}`
}
else {
const total = typeof segmentListData?.total === 'number' ? formatNumber(segmentListData.total) : 0
const count = segmentListData?.total || 0
return `${total} ${t('segment.searchResults', { ns: 'datasetDocuments', count })}`
}
}, [segmentListData, docForm, parentMode, searchValue, selectedStatus, t])
const toggleFullScreen = useCallback(() => {
setFullScreen(!fullScreen)
}, [fullScreen])
const toggleCollapsed = useCallback(() => {
setIsCollapsed(prev => !prev)
}, [])
const viewNewlyAddedChunk = useCallback(async () => {
const totalPages = segmentListData?.total_pages || 0
const total = segmentListData?.total || 0
const newPage = Math.ceil((total + 1) / limit)
needScrollToBottom.current = true
if (newPage > totalPages) {
setCurrentPage(totalPages + 1)
}
else {
resetList()
if (currentPage !== totalPages)
setCurrentPage(totalPages)
}
}, [segmentListData, limit, currentPage, resetList])
const { mutateAsync: deleteChildSegment } = useDeleteChildSegment()
const onDeleteChildChunk = useCallback(async (segmentId: string, childChunkId: string) => {
await deleteChildSegment(
{ datasetId, documentId, segmentId, childChunkId },
{
onSuccess: () => {
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
if (parentMode === 'paragraph')
resetList()
else
resetChildList()
},
onError: () => {
notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
},
},
)
}, [datasetId, documentId, parentMode, deleteChildSegment, resetList, resetChildList, t, notify])
const handleAddNewChildChunk = useCallback((parentChunkId: string) => {
setShowNewChildSegmentModal(true)
setCurrChunkId(parentChunkId)
}, [])
const onSaveNewChildChunk = useCallback((newChildChunk?: ChildChunkDetail) => {
if (parentMode === 'paragraph') {
for (const seg of segments) {
if (seg.id === currChunkId)
seg.child_chunks?.push(newChildChunk!)
}
setSegments([...segments])
refreshChunkListDataWithDetailChanged()
}
else {
resetChildList()
}
}, [parentMode, currChunkId, segments, refreshChunkListDataWithDetailChanged, resetChildList])
const viewNewlyAddedChildChunk = useCallback(() => {
const totalPages = childChunkListData?.total_pages || 0
const total = childChunkListData?.total || 0
const newPage = Math.ceil((total + 1) / limit)
needScrollToBottom.current = true
if (newPage > totalPages) {
setCurrentPage(totalPages + 1)
}
else {
resetChildList()
if (currentPage !== totalPages)
setCurrentPage(totalPages)
}
}, [childChunkListData, limit, currentPage, resetChildList])
const onClickSlice = useCallback((detail: ChildChunkDetail) => {
setCurrChildChunk({ childChunkInfo: detail, showModal: true })
setCurrChunkId(detail.segment_id)
}, [])
const onCloseChildSegmentDetail = useCallback(() => {
setCurrChildChunk({ showModal: false })
setFullScreen(false)
}, [])
const { mutateAsync: updateChildSegment } = useUpdateChildSegment()
const handleUpdateChildChunk = useCallback(async (
segmentId: string,
childChunkId: string,
content: string,
) => {
const params: SegmentUpdater = { content: '' }
if (!content.trim())
return notify({ type: 'error', message: t('segment.contentEmpty', { ns: 'datasetDocuments' }) })
params.content = content
eventEmitter?.emit('update-child-segment')
await updateChildSegment({ datasetId, documentId, segmentId, childChunkId, body: params }, {
onSuccess: (res) => {
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
onCloseChildSegmentDetail()
if (parentMode === 'paragraph') {
for (const seg of segments) {
if (seg.id === segmentId) {
for (const childSeg of seg.child_chunks!) {
if (childSeg.id === childChunkId) {
childSeg.content = res.data.content
childSeg.type = res.data.type
childSeg.word_count = res.data.word_count
childSeg.updated_at = res.data.updated_at
}
}
}
}
setSegments([...segments])
refreshChunkListDataWithDetailChanged()
}
else {
resetChildList()
}
},
onSettled: () => {
eventEmitter?.emit('update-child-segment-done')
},
})
}, [segments, datasetId, documentId, parentMode, updateChildSegment, notify, eventEmitter, onCloseChildSegmentDetail, refreshChunkListDataWithDetailChanged, resetChildList, t])
const onClearFilter = useCallback(() => {
setInputValue('')
setSearchValue('')
setSelectedStatus('all')
setCurrentPage(1)
}, [])
const selectDefaultValue = useMemo(() => {
if (selectedStatus === 'all')
return 'all'
return selectedStatus ? 1 : 0
}, [selectedStatus])
const contextValue = useMemo<SegmentListContextValue>(() => ({
isCollapsed: modalState.isCollapsed,
fullScreen: modalState.fullScreen,
toggleFullScreen: modalState.toggleFullScreen,
currSegment: modalState.currSegment,
currChildChunk: modalState.currChildChunk,
}), [
modalState.isCollapsed,
modalState.fullScreen,
modalState.toggleFullScreen,
modalState.currSegment,
modalState.currChildChunk,
])
isCollapsed,
fullScreen,
toggleFullScreen,
currSegment,
currChildChunk,
}), [isCollapsed, fullScreen, toggleFullScreen, currSegment, currChildChunk])
return (
<SegmentListContext.Provider value={contextValue}>
{/* Menu Bar */}
{!segmentListDataHook.isFullDocMode && (
<MenuBar
isAllSelected={selectionState.isAllSelected}
isSomeSelected={selectionState.isSomeSelected}
onSelectedAll={selectionState.onSelectedAll}
isLoading={segmentListDataHook.isLoadingSegmentList}
totalText={segmentListDataHook.totalText}
statusList={searchFilter.statusList}
selectDefaultValue={searchFilter.selectDefaultValue}
onChangeStatus={searchFilter.onChangeStatus}
inputValue={searchFilter.inputValue}
onInputChange={searchFilter.handleInputChange}
isCollapsed={modalState.isCollapsed}
toggleCollapsed={modalState.toggleCollapsed}
/>
{!isFullDocMode && (
<div className={s.docSearchWrapper}>
<Checkbox
className="shrink-0"
checked={isAllSelected}
indeterminate={!isAllSelected && isSomeSelected}
onCheck={onSelectedAll}
disabled={isLoadingSegmentList}
/>
<div className="system-sm-semibold-uppercase flex-1 pl-5 text-text-secondary">{totalText}</div>
<SimpleSelect
onSelect={onChangeStatus}
items={statusList.current}
defaultValue={selectDefaultValue}
className={s.select}
wrapperClassName="h-fit mr-2"
optionWrapClassName="w-[160px]"
optionClassName="p-0"
renderOption={({ item, selected }) => <StatusItem item={item} selected={selected} />}
notClearable
/>
<Input
showLeftIcon
showClearIcon
wrapperClassName="!w-52"
value={inputValue}
onChange={e => handleInputChange(e.target.value)}
onClear={() => handleInputChange('')}
/>
<Divider type="vertical" className="mx-3 h-3.5" />
<DisplayToggle isCollapsed={isCollapsed} toggleCollapsed={toggleCollapsed} />
</div>
)}
{/* Segment list */}
{segmentListDataHook.isFullDocMode
? (
<FullDocModeContent
segments={segmentListDataHook.segments}
childSegments={childSegmentDataHook.childSegments}
isLoadingSegmentList={segmentListDataHook.isLoadingSegmentList}
isLoadingChildSegmentList={childSegmentDataHook.isLoadingChildSegmentList}
currSegmentId={modalState.currSegment?.segInfo?.id}
onClickCard={modalState.onClickCard}
onDeleteChildChunk={childSegmentDataHook.onDeleteChildChunk}
handleInputChange={searchFilter.handleInputChange}
handleAddNewChildChunk={modalState.handleAddNewChildChunk}
onClickSlice={modalState.onClickSlice}
archived={archived}
childChunkTotal={childSegmentDataHook.childChunkListData?.total || 0}
inputValue={searchFilter.inputValue}
onClearFilter={searchFilter.onClearFilter}
/>
)
: (
<GeneralModeContent
segmentListRef={segmentListDataHook.segmentListRef}
embeddingAvailable={embeddingAvailable}
isLoadingSegmentList={segmentListDataHook.isLoadingSegmentList}
segments={segmentListDataHook.segments}
selectedSegmentIds={selectionState.selectedSegmentIds}
onSelected={selectionState.onSelected}
onChangeSwitch={segmentListDataHook.onChangeSwitch}
onDelete={segmentListDataHook.onDelete}
onClickCard={modalState.onClickCard}
archived={archived}
onDeleteChildChunk={childSegmentDataHook.onDeleteChildChunk}
handleAddNewChildChunk={modalState.handleAddNewChildChunk}
onClickSlice={modalState.onClickSlice}
onClearFilter={searchFilter.onClearFilter}
/>
)}
{
isFullDocMode
? (
<div className={cn(
'flex grow flex-col overflow-x-hidden',
(isLoadingSegmentList || isLoadingChildSegmentList) ? 'overflow-y-hidden' : 'overflow-y-auto',
)}
>
<SegmentCard
detail={segments[0]}
onClick={() => onClickCard(segments[0])}
loading={isLoadingSegmentList}
focused={{
segmentIndex: currSegment?.segInfo?.id === segments[0]?.id,
segmentContent: currSegment?.segInfo?.id === segments[0]?.id,
}}
/>
<ChildSegmentList
parentChunkId={segments[0]?.id}
onDelete={onDeleteChildChunk}
childChunks={childSegments}
handleInputChange={handleInputChange}
handleAddNewChildChunk={handleAddNewChildChunk}
onClickSlice={onClickSlice}
enabled={!archived}
total={childChunkListData?.total || 0}
inputValue={inputValue}
onClearFilter={onClearFilter}
isLoading={isLoadingSegmentList || isLoadingChildSegmentList}
/>
</div>
)
: (
<SegmentList
ref={segmentListRef}
embeddingAvailable={embeddingAvailable}
isLoading={isLoadingSegmentList}
items={segments}
selectedSegmentIds={selectedSegmentIds}
onSelected={onSelected}
onChangeSwitch={onChangeSwitch}
onDelete={onDelete}
onClick={onClickCard}
archived={archived}
onDeleteChildChunk={onDeleteChildChunk}
handleAddNewChildChunk={handleAddNewChildChunk}
onClickSlice={onClickSlice}
onClearFilter={onClearFilter}
/>
)
}
{/* Pagination */}
<Divider type="horizontal" className="mx-6 my-0 h-px w-auto bg-divider-subtle" />
<Pagination
current={currentPage - 1}
onChange={handlePageChange}
total={paginationTotal}
onChange={cur => setCurrentPage(cur + 1)}
total={(isFullDocMode ? childChunkListData?.total : segmentListData?.total) || 0}
limit={limit}
onLimitChange={setLimit}
className={segmentListDataHook.isFullDocMode ? 'px-3' : ''}
onLimitChange={limit => setLimit(limit)}
className={isFullDocMode ? 'px-3' : ''}
/>
{/* Drawer Group - only render when docForm is available */}
{docForm && (
<DrawerGroup
currSegment={modalState.currSegment}
onCloseSegmentDetail={modalState.onCloseSegmentDetail}
onUpdateSegment={segmentListDataHook.handleUpdateSegment}
isRegenerationModalOpen={modalState.isRegenerationModalOpen}
setIsRegenerationModalOpen={modalState.setIsRegenerationModalOpen}
showNewSegmentModal={showNewSegmentModal}
onCloseNewSegmentModal={modalState.onCloseNewSegmentModal}
onSaveNewSegment={segmentListDataHook.resetList}
viewNewlyAddedChunk={segmentListDataHook.viewNewlyAddedChunk}
currChildChunk={modalState.currChildChunk}
currChunkId={modalState.currChunkId}
onCloseChildSegmentDetail={modalState.onCloseChildSegmentDetail}
onUpdateChildChunk={childSegmentDataHook.handleUpdateChildChunk}
showNewChildSegmentModal={modalState.showNewChildSegmentModal}
onCloseNewChildChunkModal={modalState.onCloseNewChildChunkModal}
onSaveNewChildChunk={childSegmentDataHook.onSaveNewChildChunk}
viewNewlyAddedChildChunk={childSegmentDataHook.viewNewlyAddedChildChunk}
fullScreen={modalState.fullScreen}
{/* Edit or view segment detail */}
<FullScreenDrawer
isOpen={currSegment.showModal}
fullScreen={fullScreen}
onClose={onCloseSegmentDetail}
showOverlay={false}
needCheckChunks
modal={isRegenerationModalOpen}
>
<SegmentDetail
key={currSegment.segInfo?.id}
segInfo={currSegment.segInfo ?? { id: '' }}
docForm={docForm}
isEditMode={currSegment.isEditMode}
onUpdate={handleUpdateSegment}
onCancel={onCloseSegmentDetail}
onModalStateChange={setIsRegenerationModalOpen}
/>
)}
</FullScreenDrawer>
{/* Create New Segment */}
<FullScreenDrawer
isOpen={showNewSegmentModal}
fullScreen={fullScreen}
onClose={onCloseNewSegmentModal}
modal
>
<NewSegment
docForm={docForm}
onCancel={onCloseNewSegmentModal}
onSave={resetList}
viewNewlyAddedChunk={viewNewlyAddedChunk}
/>
</FullScreenDrawer>
{/* Edit or view child segment detail */}
<FullScreenDrawer
isOpen={currChildChunk.showModal}
fullScreen={fullScreen}
onClose={onCloseChildSegmentDetail}
showOverlay={false}
needCheckChunks
>
<ChildSegmentDetail
key={currChildChunk.childChunkInfo?.id}
chunkId={currChunkId}
childChunkInfo={currChildChunk.childChunkInfo ?? { id: '' }}
docForm={docForm}
onUpdate={handleUpdateChildChunk}
onCancel={onCloseChildSegmentDetail}
/>
</FullScreenDrawer>
{/* Create New Child Segment */}
<FullScreenDrawer
isOpen={showNewChildSegmentModal}
fullScreen={fullScreen}
onClose={onCloseNewChildChunkModal}
modal
>
<NewChildSegment
chunkId={currChunkId}
onCancel={onCloseNewChildChunkModal}
onSave={onSaveNewChildChunk}
viewNewlyAddedChildChunk={viewNewlyAddedChildChunk}
/>
</FullScreenDrawer>
{/* Batch Action Buttons */}
{selectionState.selectedSegmentIds.length > 0 && (
{selectedSegmentIds.length > 0 && (
<BatchAction
className="absolute bottom-16 left-0 z-20"
selectedIds={selectionState.selectedSegmentIds}
onBatchEnable={() => segmentListDataHook.onChangeSwitch(true, '')}
onBatchDisable={() => segmentListDataHook.onChangeSwitch(false, '')}
onBatchDelete={() => segmentListDataHook.onDelete('')}
onCancel={selectionState.onCancelBatchOperation}
selectedIds={selectedSegmentIds}
onBatchEnable={onChangeSwitch.bind(null, true, '')}
onBatchDisable={onChangeSwitch.bind(null, false, '')}
onBatchDelete={onDelete.bind(null, '')}
onCancel={onCancelBatchOperation}
/>
)}
</SegmentListContext.Provider>
)
}
export { useSegmentListContext }
export type { SegmentListContextValue }
export default Completed

View File

@ -1,34 +0,0 @@
import type { ChildChunkDetail, SegmentDetailModel } from '@/models/datasets'
import { noop } from 'es-toolkit/function'
import { createContext, useContextSelector } from 'use-context-selector'
export type CurrSegmentType = {
segInfo?: SegmentDetailModel
showModal: boolean
isEditMode?: boolean
}
export type CurrChildChunkType = {
childChunkInfo?: ChildChunkDetail
showModal: boolean
}
export type SegmentListContextValue = {
isCollapsed: boolean
fullScreen: boolean
toggleFullScreen: () => void
currSegment: CurrSegmentType
currChildChunk: CurrChildChunkType
}
export const SegmentListContext = createContext<SegmentListContextValue>({
isCollapsed: true,
fullScreen: false,
toggleFullScreen: noop,
currSegment: { showModal: false },
currChildChunk: { showModal: false },
})
export const useSegmentListContext = <T>(selector: (value: SegmentListContextValue) => T): T => {
return useContextSelector(SegmentListContext, selector)
}

View File

@ -1,93 +0,0 @@
import { render } from '@testing-library/react'
import FullDocListSkeleton from './full-doc-list-skeleton'
describe('FullDocListSkeleton', () => {
describe('Rendering', () => {
it('should render the skeleton container', () => {
const { container } = render(<FullDocListSkeleton />)
const skeletonContainer = container.firstChild
expect(skeletonContainer).toHaveClass('flex', 'w-full', 'grow', 'flex-col')
})
it('should render 15 Slice components', () => {
const { container } = render(<FullDocListSkeleton />)
// Each Slice has a specific structure with gap-y-1
const slices = container.querySelectorAll('.gap-y-1')
expect(slices.length).toBe(15)
})
it('should render mask overlay', () => {
const { container } = render(<FullDocListSkeleton />)
const maskOverlay = container.querySelector('.bg-dataset-chunk-list-mask-bg')
expect(maskOverlay).toBeInTheDocument()
})
it('should have overflow hidden', () => {
const { container } = render(<FullDocListSkeleton />)
const skeletonContainer = container.firstChild
expect(skeletonContainer).toHaveClass('overflow-y-hidden')
})
})
describe('Slice Component', () => {
it('should render slice with correct structure', () => {
const { container } = render(<FullDocListSkeleton />)
// Each slice has two rows
const sliceRows = container.querySelectorAll('.bg-state-base-hover')
expect(sliceRows.length).toBeGreaterThan(0)
})
it('should render label placeholder in each slice', () => {
const { container } = render(<FullDocListSkeleton />)
// Label placeholder has specific width
const labelPlaceholders = container.querySelectorAll('.w-\\[30px\\]')
expect(labelPlaceholders.length).toBe(15) // One per slice
})
it('should render content placeholder in each slice', () => {
const { container } = render(<FullDocListSkeleton />)
// Content placeholder has 2/3 width
const contentPlaceholders = container.querySelectorAll('.w-2\\/3')
expect(contentPlaceholders.length).toBe(15) // One per slice
})
})
describe('Memoization', () => {
it('should be memoized', () => {
const { rerender, container } = render(<FullDocListSkeleton />)
const initialContent = container.innerHTML
// Rerender should produce same output
rerender(<FullDocListSkeleton />)
expect(container.innerHTML).toBe(initialContent)
})
})
describe('Styling', () => {
it('should have correct z-index layering', () => {
const { container } = render(<FullDocListSkeleton />)
const skeletonContainer = container.firstChild
expect(skeletonContainer).toHaveClass('z-10')
const maskOverlay = container.querySelector('.z-20')
expect(maskOverlay).toBeInTheDocument()
})
it('should have gap between slices', () => {
const { container } = render(<FullDocListSkeleton />)
const skeletonContainer = container.firstChild
expect(skeletonContainer).toHaveClass('gap-y-3')
})
})
})

View File

@ -1,43 +1,34 @@
import type { ComponentType, FC } from 'react'
import type { FC } from 'react'
import type { ModelProvider } from '../declarations'
import type { Plugin } from '@/app/components/plugins/types'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { AnthropicShortLight, Deepseek, Gemini, Grok, OpenaiSmall, Tongyi } from '@/app/components/base/icons/src/public/llm'
import { OpenaiSmall } from '@/app/components/base/icons/src/public/llm'
import Loading from '@/app/components/base/loading'
import Tooltip from '@/app/components/base/tooltip'
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import useTimestamp from '@/hooks/use-timestamp'
import { ModelProviderQuotaGetPaid } from '@/types/model-provider'
import { cn } from '@/utils/classnames'
import { formatNumber } from '@/utils/format'
import { PreferredProviderTypeEnum } from '../declarations'
import { useMarketplaceAllPlugins } from '../hooks'
import { MODEL_PROVIDER_QUOTA_GET_PAID, modelNameMap } from '../utils'
import { modelNameMap, ModelProviderQuotaGetPaid } from '../utils'
// Icon map for each provider - single source of truth for provider icons
const providerIconMap: Record<ModelProviderQuotaGetPaid, ComponentType<{ className?: string }>> = {
[ModelProviderQuotaGetPaid.OPENAI]: OpenaiSmall,
[ModelProviderQuotaGetPaid.ANTHROPIC]: AnthropicShortLight,
[ModelProviderQuotaGetPaid.GEMINI]: Gemini,
[ModelProviderQuotaGetPaid.X]: Grok,
[ModelProviderQuotaGetPaid.DEEPSEEK]: Deepseek,
[ModelProviderQuotaGetPaid.TONGYI]: Tongyi,
}
// Derive allProviders from the shared constant
const allProviders = MODEL_PROVIDER_QUOTA_GET_PAID.map(key => ({
key,
Icon: providerIconMap[key],
}))
const allProviders = [
{ key: ModelProviderQuotaGetPaid.OPENAI, Icon: OpenaiSmall },
// { key: ModelProviderQuotaGetPaid.ANTHROPIC, Icon: AnthropicShortLight },
// { key: ModelProviderQuotaGetPaid.GEMINI, Icon: Gemini },
// { key: ModelProviderQuotaGetPaid.X, Icon: Grok },
// { key: ModelProviderQuotaGetPaid.DEEPSEEK, Icon: Deepseek },
// { key: ModelProviderQuotaGetPaid.TONGYI, Icon: Tongyi },
] as const
// Map provider key to plugin ID
// provider key format: langgenius/provider/model, plugin ID format: langgenius/provider
const providerKeyToPluginId: Record<ModelProviderQuotaGetPaid, string> = {
const providerKeyToPluginId: Record<string, string> = {
[ModelProviderQuotaGetPaid.OPENAI]: 'langgenius/openai',
[ModelProviderQuotaGetPaid.ANTHROPIC]: 'langgenius/anthropic',
[ModelProviderQuotaGetPaid.GEMINI]: 'langgenius/gemini',
@ -56,7 +47,6 @@ const QuotaPanel: FC<QuotaPanelProps> = ({
}) => {
const { t } = useTranslation()
const { currentWorkspace } = useAppContext()
const { trial_models } = useGlobalPublicStore(s => s.systemFeatures)
const credits = Math.max((currentWorkspace.trial_credits - currentWorkspace.trial_credits_used) || 0, 0)
const providerMap = useMemo(() => new Map(
providers.map(p => [p.provider, p.preferred_provider_type]),
@ -72,7 +62,7 @@ const QuotaPanel: FC<QuotaPanelProps> = ({
}] = useBoolean(false)
const selectedPluginIdRef = useRef<string | null>(null)
const handleIconClick = useCallback((key: ModelProviderQuotaGetPaid) => {
const handleIconClick = useCallback((key: string) => {
const providerType = providerMap.get(key)
if (!providerType && allPlugins) {
const pluginId = providerKeyToPluginId[key]
@ -107,7 +97,7 @@ const QuotaPanel: FC<QuotaPanelProps> = ({
<div className={cn('my-2 min-w-[72px] shrink-0 rounded-xl border-[0.5px] pb-2.5 pl-4 pr-2.5 pt-3 shadow-xs', credits <= 0 ? 'border-state-destructive-border hover:bg-state-destructive-hover' : 'border-components-panel-border bg-third-party-model-bg-default')}>
<div className="system-xs-medium-uppercase mb-2 flex h-4 items-center text-text-tertiary">
{t('modelProvider.quota', { ns: 'common' })}
<Tooltip popupContent={t('modelProvider.card.tip', { ns: 'common', modelNames: trial_models.map(key => modelNameMap[key as keyof typeof modelNameMap]).filter(Boolean).join(', ') })} />
<Tooltip popupContent={t('modelProvider.card.tip', { ns: 'common' })} />
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 text-xs text-text-tertiary">
@ -129,7 +119,7 @@ const QuotaPanel: FC<QuotaPanelProps> = ({
: null}
</div>
<div className="flex items-center gap-1">
{allProviders.filter(({ key }) => trial_models.includes(key)).map(({ key, Icon }) => {
{allProviders.map(({ key, Icon }) => {
const providerType = providerMap.get(key)
const usingQuota = providerType === PreferredProviderTypeEnum.system
const getTooltipKey = () => {

View File

@ -1,5 +1,4 @@
import type {
CredentialFormSchemaSelect,
CredentialFormSchemaTextInput,
FormValue,
ModelLoadBalancingConfig,
@ -10,7 +9,6 @@ import {
validateModelLoadBalancingCredentials,
validateModelProvider,
} from '@/service/common'
import { ModelProviderQuotaGetPaid } from '@/types/model-provider'
import { ValidatedStatus } from '../key-validator/declarations'
import {
ConfigurationMethodEnum,
@ -19,8 +17,15 @@ import {
ModelTypeEnum,
} from './declarations'
export { ModelProviderQuotaGetPaid } from '@/types/model-provider'
export enum ModelProviderQuotaGetPaid {
ANTHROPIC = 'langgenius/anthropic/anthropic',
OPENAI = 'langgenius/openai/openai',
// AZURE_OPENAI = 'langgenius/azure_openai/azure_openai',
GEMINI = 'langgenius/gemini/google',
X = 'langgenius/x/x',
DEEPSEEK = 'langgenius/deepseek/deepseek',
TONGYI = 'langgenius/tongyi/tongyi',
}
export const MODEL_PROVIDER_QUOTA_GET_PAID = [ModelProviderQuotaGetPaid.ANTHROPIC, ModelProviderQuotaGetPaid.OPENAI, ModelProviderQuotaGetPaid.GEMINI, ModelProviderQuotaGetPaid.X, ModelProviderQuotaGetPaid.DEEPSEEK, ModelProviderQuotaGetPaid.TONGYI]
export const modelNameMap = {
@ -32,7 +37,7 @@ export const modelNameMap = {
[ModelProviderQuotaGetPaid.TONGYI]: 'Tongyi',
}
export const isNullOrUndefined = (value: unknown): value is null | undefined => {
export const isNullOrUndefined = (value: any) => {
return value === undefined || value === null
}
@ -61,9 +66,8 @@ export const validateCredentials = async (predefined: boolean, provider: string,
else
return Promise.resolve({ status: ValidatedStatus.Error, message: res.error || 'error' })
}
catch (e: unknown) {
const message = e instanceof Error ? e.message : 'Unknown error'
return Promise.resolve({ status: ValidatedStatus.Error, message })
catch (e: any) {
return Promise.resolve({ status: ValidatedStatus.Error, message: e.message })
}
}
@ -86,9 +90,8 @@ export const validateLoadBalancingCredentials = async (predefined: boolean, prov
else
return Promise.resolve({ status: ValidatedStatus.Error, message: res.error || 'error' })
}
catch (e: unknown) {
const message = e instanceof Error ? e.message : 'Unknown error'
return Promise.resolve({ status: ValidatedStatus.Error, message })
catch (e: any) {
return Promise.resolve({ status: ValidatedStatus.Error, message: e.message })
}
}
@ -174,7 +177,7 @@ export const modelTypeFormat = (modelType: ModelTypeEnum) => {
return modelType.toLocaleUpperCase()
}
export const genModelTypeFormSchema = (modelTypes: ModelTypeEnum[]): Omit<CredentialFormSchemaSelect, 'name'> => {
export const genModelTypeFormSchema = (modelTypes: ModelTypeEnum[]) => {
return {
type: FormTypeEnum.select,
label: {
@ -195,10 +198,10 @@ export const genModelTypeFormSchema = (modelTypes: ModelTypeEnum[]): Omit<Creden
show_on: [],
}
}),
}
} as any
}
export const genModelNameFormSchema = (model?: Pick<CredentialFormSchemaTextInput, 'label' | 'placeholder'>): Omit<CredentialFormSchemaTextInput, 'name'> => {
export const genModelNameFormSchema = (model?: Pick<CredentialFormSchemaTextInput, 'label' | 'placeholder'>) => {
return {
type: FormTypeEnum.textInput,
label: model?.label || {
@ -212,5 +215,5 @@ export const genModelNameFormSchema = (model?: Pick<CredentialFormSchemaTextInpu
zh_Hans: '请输入模型名称',
en_US: 'Please enter model name',
},
}
} as any
}

View File

@ -1,259 +0,0 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Theme } from '@/types/app'
import IconWithTooltip from './icon-with-tooltip'
// Mock Tooltip component
vi.mock('@/app/components/base/tooltip', () => ({
default: ({
children,
popupContent,
popupClassName,
}: {
children: React.ReactNode
popupContent?: string
popupClassName?: string
}) => (
<div data-testid="tooltip" data-popup-content={popupContent} data-popup-classname={popupClassName}>
{children}
</div>
),
}))
// Mock icon components
const MockLightIcon = ({ className }: { className?: string }) => (
<div data-testid="light-icon" className={className}>Light Icon</div>
)
const MockDarkIcon = ({ className }: { className?: string }) => (
<div data-testid="dark-icon" className={className}>Dark Icon</div>
)
describe('IconWithTooltip', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(
<IconWithTooltip
theme={Theme.light}
BadgeIconLight={MockLightIcon}
BadgeIconDark={MockDarkIcon}
/>,
)
expect(screen.getByTestId('tooltip')).toBeInTheDocument()
})
it('should render Tooltip wrapper', () => {
render(
<IconWithTooltip
theme={Theme.light}
BadgeIconLight={MockLightIcon}
BadgeIconDark={MockDarkIcon}
popupContent="Test tooltip"
/>,
)
expect(screen.getByTestId('tooltip')).toHaveAttribute('data-popup-content', 'Test tooltip')
})
it('should apply correct popupClassName to Tooltip', () => {
render(
<IconWithTooltip
theme={Theme.light}
BadgeIconLight={MockLightIcon}
BadgeIconDark={MockDarkIcon}
/>,
)
const tooltip = screen.getByTestId('tooltip')
expect(tooltip).toHaveAttribute('data-popup-classname')
expect(tooltip.getAttribute('data-popup-classname')).toContain('border-components-panel-border')
})
})
describe('Theme Handling', () => {
it('should render light icon when theme is light', () => {
render(
<IconWithTooltip
theme={Theme.light}
BadgeIconLight={MockLightIcon}
BadgeIconDark={MockDarkIcon}
/>,
)
expect(screen.getByTestId('light-icon')).toBeInTheDocument()
expect(screen.queryByTestId('dark-icon')).not.toBeInTheDocument()
})
it('should render dark icon when theme is dark', () => {
render(
<IconWithTooltip
theme={Theme.dark}
BadgeIconLight={MockLightIcon}
BadgeIconDark={MockDarkIcon}
/>,
)
expect(screen.getByTestId('dark-icon')).toBeInTheDocument()
expect(screen.queryByTestId('light-icon')).not.toBeInTheDocument()
})
it('should render light icon when theme is system (not dark)', () => {
render(
<IconWithTooltip
theme={'system' as Theme}
BadgeIconLight={MockLightIcon}
BadgeIconDark={MockDarkIcon}
/>,
)
// When theme is not 'dark', it should use light icon
expect(screen.getByTestId('light-icon')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should apply custom className to icon', () => {
render(
<IconWithTooltip
className="custom-class"
theme={Theme.light}
BadgeIconLight={MockLightIcon}
BadgeIconDark={MockDarkIcon}
/>,
)
const icon = screen.getByTestId('light-icon')
expect(icon).toHaveClass('custom-class')
})
it('should apply default h-5 w-5 class to icon', () => {
render(
<IconWithTooltip
theme={Theme.light}
BadgeIconLight={MockLightIcon}
BadgeIconDark={MockDarkIcon}
/>,
)
const icon = screen.getByTestId('light-icon')
expect(icon).toHaveClass('h-5')
expect(icon).toHaveClass('w-5')
})
it('should merge custom className with default classes', () => {
render(
<IconWithTooltip
className="ml-2"
theme={Theme.light}
BadgeIconLight={MockLightIcon}
BadgeIconDark={MockDarkIcon}
/>,
)
const icon = screen.getByTestId('light-icon')
expect(icon).toHaveClass('h-5')
expect(icon).toHaveClass('w-5')
expect(icon).toHaveClass('ml-2')
})
it('should pass popupContent to Tooltip', () => {
render(
<IconWithTooltip
theme={Theme.light}
BadgeIconLight={MockLightIcon}
BadgeIconDark={MockDarkIcon}
popupContent="Custom tooltip content"
/>,
)
expect(screen.getByTestId('tooltip')).toHaveAttribute(
'data-popup-content',
'Custom tooltip content',
)
})
it('should handle undefined popupContent', () => {
render(
<IconWithTooltip
theme={Theme.light}
BadgeIconLight={MockLightIcon}
BadgeIconDark={MockDarkIcon}
/>,
)
expect(screen.getByTestId('tooltip')).toBeInTheDocument()
})
})
describe('Memoization', () => {
it('should be wrapped with React.memo', () => {
// The component is exported as React.memo(IconWithTooltip)
expect(IconWithTooltip).toBeDefined()
// Check if it's a memo component
expect(typeof IconWithTooltip).toBe('object')
})
})
describe('Container Structure', () => {
it('should render icon inside flex container', () => {
const { container } = render(
<IconWithTooltip
theme={Theme.light}
BadgeIconLight={MockLightIcon}
BadgeIconDark={MockDarkIcon}
/>,
)
const flexContainer = container.querySelector('.flex.shrink-0.items-center.justify-center')
expect(flexContainer).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle empty className', () => {
render(
<IconWithTooltip
className=""
theme={Theme.light}
BadgeIconLight={MockLightIcon}
BadgeIconDark={MockDarkIcon}
/>,
)
expect(screen.getByTestId('light-icon')).toBeInTheDocument()
})
it('should handle long popupContent', () => {
const longContent = 'A'.repeat(500)
render(
<IconWithTooltip
theme={Theme.light}
BadgeIconLight={MockLightIcon}
BadgeIconDark={MockDarkIcon}
popupContent={longContent}
/>,
)
expect(screen.getByTestId('tooltip')).toHaveAttribute('data-popup-content', longContent)
})
it('should handle special characters in popupContent', () => {
const specialContent = '<script>alert("xss")</script> & "quotes"'
render(
<IconWithTooltip
theme={Theme.light}
BadgeIconLight={MockLightIcon}
BadgeIconDark={MockDarkIcon}
popupContent={specialContent}
/>,
)
expect(screen.getByTestId('tooltip')).toHaveAttribute('data-popup-content', specialContent)
})
})
})

View File

@ -1,205 +0,0 @@
import type { ComponentProps } from 'react'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Theme } from '@/types/app'
import Partner from './partner'
// Mock useTheme hook
const mockUseTheme = vi.fn()
vi.mock('@/hooks/use-theme', () => ({
default: () => mockUseTheme(),
}))
// Mock IconWithTooltip to directly test Partner's behavior
type IconWithTooltipProps = ComponentProps<typeof import('./icon-with-tooltip').default>
const mockIconWithTooltip = vi.fn()
vi.mock('./icon-with-tooltip', () => ({
default: (props: IconWithTooltipProps) => {
mockIconWithTooltip(props)
const { theme, BadgeIconLight, BadgeIconDark, className, popupContent } = props
const isDark = theme === Theme.dark
const Icon = isDark ? BadgeIconDark : BadgeIconLight
return (
<div data-testid="icon-with-tooltip" data-popup-content={popupContent} data-theme={theme}>
<Icon className={className} data-testid={isDark ? 'partner-dark-icon' : 'partner-light-icon'} />
</div>
)
},
}))
// Mock Partner icons
vi.mock('@/app/components/base/icons/src/public/plugins/PartnerDark', () => ({
default: ({ className, ...rest }: { className?: string }) => (
<div data-testid="partner-dark-icon" className={className} {...rest}>PartnerDark</div>
),
}))
vi.mock('@/app/components/base/icons/src/public/plugins/PartnerLight', () => ({
default: ({ className, ...rest }: { className?: string }) => (
<div data-testid="partner-light-icon" className={className} {...rest}>PartnerLight</div>
),
}))
describe('Partner', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseTheme.mockReturnValue({ theme: Theme.light })
mockIconWithTooltip.mockClear()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Partner text="Partner Tip" />)
expect(screen.getByTestId('icon-with-tooltip')).toBeInTheDocument()
})
it('should call useTheme hook', () => {
render(<Partner text="Partner" />)
expect(mockUseTheme).toHaveBeenCalled()
})
it('should pass text prop as popupContent to IconWithTooltip', () => {
render(<Partner text="This is a partner" />)
expect(screen.getByTestId('icon-with-tooltip')).toHaveAttribute(
'data-popup-content',
'This is a partner',
)
expect(mockIconWithTooltip).toHaveBeenCalledWith(
expect.objectContaining({ popupContent: 'This is a partner' }),
)
})
it('should pass theme from useTheme to IconWithTooltip', () => {
mockUseTheme.mockReturnValue({ theme: Theme.light })
render(<Partner text="Partner" />)
expect(mockIconWithTooltip).toHaveBeenCalledWith(
expect.objectContaining({ theme: Theme.light }),
)
})
it('should render light icon in light theme', () => {
mockUseTheme.mockReturnValue({ theme: Theme.light })
render(<Partner text="Partner" />)
expect(screen.getByTestId('partner-light-icon')).toBeInTheDocument()
})
it('should render dark icon in dark theme', () => {
mockUseTheme.mockReturnValue({ theme: Theme.dark })
render(<Partner text="Partner" />)
expect(screen.getByTestId('partner-dark-icon')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should pass className to IconWithTooltip', () => {
render(<Partner className="custom-class" text="Partner" />)
expect(mockIconWithTooltip).toHaveBeenCalledWith(
expect.objectContaining({ className: 'custom-class' }),
)
})
it('should pass correct BadgeIcon components to IconWithTooltip', () => {
render(<Partner text="Partner" />)
expect(mockIconWithTooltip).toHaveBeenCalledWith(
expect.objectContaining({
BadgeIconLight: expect.any(Function),
BadgeIconDark: expect.any(Function),
}),
)
})
})
describe('Theme Handling', () => {
it('should handle light theme correctly', () => {
mockUseTheme.mockReturnValue({ theme: Theme.light })
render(<Partner text="Partner" />)
expect(mockUseTheme).toHaveBeenCalled()
expect(mockIconWithTooltip).toHaveBeenCalledWith(
expect.objectContaining({ theme: Theme.light }),
)
expect(screen.getByTestId('partner-light-icon')).toBeInTheDocument()
})
it('should handle dark theme correctly', () => {
mockUseTheme.mockReturnValue({ theme: Theme.dark })
render(<Partner text="Partner" />)
expect(mockUseTheme).toHaveBeenCalled()
expect(mockIconWithTooltip).toHaveBeenCalledWith(
expect.objectContaining({ theme: Theme.dark }),
)
expect(screen.getByTestId('partner-dark-icon')).toBeInTheDocument()
})
it('should pass updated theme when theme changes', () => {
mockUseTheme.mockReturnValue({ theme: Theme.light })
const { rerender } = render(<Partner text="Partner" />)
expect(mockIconWithTooltip).toHaveBeenLastCalledWith(
expect.objectContaining({ theme: Theme.light }),
)
mockIconWithTooltip.mockClear()
mockUseTheme.mockReturnValue({ theme: Theme.dark })
rerender(<Partner text="Partner" />)
expect(mockIconWithTooltip).toHaveBeenLastCalledWith(
expect.objectContaining({ theme: Theme.dark }),
)
})
})
describe('Edge Cases', () => {
it('should handle empty text', () => {
render(<Partner text="" />)
expect(mockIconWithTooltip).toHaveBeenCalledWith(
expect.objectContaining({ popupContent: '' }),
)
})
it('should handle long text', () => {
const longText = 'A'.repeat(500)
render(<Partner text={longText} />)
expect(mockIconWithTooltip).toHaveBeenCalledWith(
expect.objectContaining({ popupContent: longText }),
)
})
it('should handle special characters in text', () => {
const specialText = '<script>alert("xss")</script>'
render(<Partner text={specialText} />)
expect(mockIconWithTooltip).toHaveBeenCalledWith(
expect.objectContaining({ popupContent: specialText }),
)
})
it('should handle undefined className', () => {
render(<Partner text="Partner" />)
expect(mockIconWithTooltip).toHaveBeenCalledWith(
expect.objectContaining({ className: undefined }),
)
})
it('should always call useTheme to get current theme', () => {
render(<Partner text="Partner 1" />)
expect(mockUseTheme).toHaveBeenCalledTimes(1)
mockUseTheme.mockClear()
render(<Partner text="Partner 2" />)
expect(mockUseTheme).toHaveBeenCalledTimes(1)
})
})
})

File diff suppressed because it is too large Load Diff

View File

@ -1,404 +0,0 @@
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PLUGIN_PAGE_TABS_MAP, useCategories, usePluginPageTabs, useTags } from './hooks'
// Create mock translation function
const mockT = vi.fn((key: string, _options?: Record<string, string>) => {
const translations: Record<string, string> = {
'tags.agent': 'Agent',
'tags.rag': 'RAG',
'tags.search': 'Search',
'tags.image': 'Image',
'tags.videos': 'Videos',
'tags.weather': 'Weather',
'tags.finance': 'Finance',
'tags.design': 'Design',
'tags.travel': 'Travel',
'tags.social': 'Social',
'tags.news': 'News',
'tags.medical': 'Medical',
'tags.productivity': 'Productivity',
'tags.education': 'Education',
'tags.business': 'Business',
'tags.entertainment': 'Entertainment',
'tags.utilities': 'Utilities',
'tags.other': 'Other',
'category.models': 'Models',
'category.tools': 'Tools',
'category.datasources': 'Datasources',
'category.agents': 'Agents',
'category.extensions': 'Extensions',
'category.bundles': 'Bundles',
'category.triggers': 'Triggers',
'categorySingle.model': 'Model',
'categorySingle.tool': 'Tool',
'categorySingle.datasource': 'Datasource',
'categorySingle.agent': 'Agent',
'categorySingle.extension': 'Extension',
'categorySingle.bundle': 'Bundle',
'categorySingle.trigger': 'Trigger',
'menus.plugins': 'Plugins',
'menus.exploreMarketplace': 'Explore Marketplace',
}
return translations[key] || key
})
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: mockT,
}),
}))
describe('useTags', () => {
beforeEach(() => {
vi.clearAllMocks()
mockT.mockClear()
})
describe('Rendering', () => {
it('should return tags array', () => {
const { result } = renderHook(() => useTags())
expect(result.current.tags).toBeDefined()
expect(Array.isArray(result.current.tags)).toBe(true)
expect(result.current.tags.length).toBeGreaterThan(0)
})
it('should call translation function for each tag', () => {
renderHook(() => useTags())
// Verify t() was called for tag translations
expect(mockT).toHaveBeenCalled()
const tagCalls = mockT.mock.calls.filter(call => call[0].startsWith('tags.'))
expect(tagCalls.length).toBeGreaterThan(0)
})
it('should return tags with name and label properties', () => {
const { result } = renderHook(() => useTags())
result.current.tags.forEach((tag) => {
expect(tag).toHaveProperty('name')
expect(tag).toHaveProperty('label')
expect(typeof tag.name).toBe('string')
expect(typeof tag.label).toBe('string')
})
})
it('should return tagsMap object', () => {
const { result } = renderHook(() => useTags())
expect(result.current.tagsMap).toBeDefined()
expect(typeof result.current.tagsMap).toBe('object')
})
})
describe('tagsMap', () => {
it('should map tag name to tag object', () => {
const { result } = renderHook(() => useTags())
expect(result.current.tagsMap.agent).toBeDefined()
expect(result.current.tagsMap.agent.name).toBe('agent')
expect(result.current.tagsMap.agent.label).toBe('Agent')
})
it('should contain all tags from tags array', () => {
const { result } = renderHook(() => useTags())
result.current.tags.forEach((tag) => {
expect(result.current.tagsMap[tag.name]).toBeDefined()
expect(result.current.tagsMap[tag.name]).toEqual(tag)
})
})
})
describe('getTagLabel', () => {
it('should return label for existing tag', () => {
const { result } = renderHook(() => useTags())
// Test existing tags - this covers the branch where tagsMap[name] exists
expect(result.current.getTagLabel('agent')).toBe('Agent')
expect(result.current.getTagLabel('search')).toBe('Search')
})
it('should return name for non-existing tag', () => {
const { result } = renderHook(() => useTags())
// Test non-existing tags - this covers the branch where !tagsMap[name]
expect(result.current.getTagLabel('non-existing')).toBe('non-existing')
expect(result.current.getTagLabel('custom-tag')).toBe('custom-tag')
})
it('should cover both branches of getTagLabel conditional', () => {
const { result } = renderHook(() => useTags())
// Branch 1: tag exists in tagsMap - returns label
const existingTagResult = result.current.getTagLabel('rag')
expect(existingTagResult).toBe('RAG')
// Branch 2: tag does not exist in tagsMap - returns name itself
const nonExistingTagResult = result.current.getTagLabel('unknown-tag-xyz')
expect(nonExistingTagResult).toBe('unknown-tag-xyz')
})
it('should be a function', () => {
const { result } = renderHook(() => useTags())
expect(typeof result.current.getTagLabel).toBe('function')
})
it('should return correct labels for all predefined tags', () => {
const { result } = renderHook(() => useTags())
// Test all predefined tags
expect(result.current.getTagLabel('rag')).toBe('RAG')
expect(result.current.getTagLabel('image')).toBe('Image')
expect(result.current.getTagLabel('videos')).toBe('Videos')
expect(result.current.getTagLabel('weather')).toBe('Weather')
expect(result.current.getTagLabel('finance')).toBe('Finance')
expect(result.current.getTagLabel('design')).toBe('Design')
expect(result.current.getTagLabel('travel')).toBe('Travel')
expect(result.current.getTagLabel('social')).toBe('Social')
expect(result.current.getTagLabel('news')).toBe('News')
expect(result.current.getTagLabel('medical')).toBe('Medical')
expect(result.current.getTagLabel('productivity')).toBe('Productivity')
expect(result.current.getTagLabel('education')).toBe('Education')
expect(result.current.getTagLabel('business')).toBe('Business')
expect(result.current.getTagLabel('entertainment')).toBe('Entertainment')
expect(result.current.getTagLabel('utilities')).toBe('Utilities')
expect(result.current.getTagLabel('other')).toBe('Other')
})
it('should handle empty string tag name', () => {
const { result } = renderHook(() => useTags())
// Empty string tag doesn't exist, so should return the empty string
expect(result.current.getTagLabel('')).toBe('')
})
it('should handle special characters in tag name', () => {
const { result } = renderHook(() => useTags())
expect(result.current.getTagLabel('tag-with-dashes')).toBe('tag-with-dashes')
expect(result.current.getTagLabel('tag_with_underscores')).toBe('tag_with_underscores')
})
})
describe('Memoization', () => {
it('should return same structure on re-render', () => {
const { result, rerender } = renderHook(() => useTags())
const firstTagsLength = result.current.tags.length
const firstTagNames = result.current.tags.map(t => t.name)
rerender()
// Structure should remain consistent
expect(result.current.tags.length).toBe(firstTagsLength)
expect(result.current.tags.map(t => t.name)).toEqual(firstTagNames)
})
})
})
describe('useCategories', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should return categories array', () => {
const { result } = renderHook(() => useCategories())
expect(result.current.categories).toBeDefined()
expect(Array.isArray(result.current.categories)).toBe(true)
expect(result.current.categories.length).toBeGreaterThan(0)
})
it('should return categories with name and label properties', () => {
const { result } = renderHook(() => useCategories())
result.current.categories.forEach((category) => {
expect(category).toHaveProperty('name')
expect(category).toHaveProperty('label')
expect(typeof category.name).toBe('string')
expect(typeof category.label).toBe('string')
})
})
it('should return categoriesMap object', () => {
const { result } = renderHook(() => useCategories())
expect(result.current.categoriesMap).toBeDefined()
expect(typeof result.current.categoriesMap).toBe('object')
})
})
describe('categoriesMap', () => {
it('should map category name to category object', () => {
const { result } = renderHook(() => useCategories())
expect(result.current.categoriesMap.tool).toBeDefined()
expect(result.current.categoriesMap.tool.name).toBe('tool')
})
it('should contain all categories from categories array', () => {
const { result } = renderHook(() => useCategories())
result.current.categories.forEach((category) => {
expect(result.current.categoriesMap[category.name]).toBeDefined()
expect(result.current.categoriesMap[category.name]).toEqual(category)
})
})
})
describe('isSingle parameter', () => {
it('should use plural labels when isSingle is false', () => {
const { result } = renderHook(() => useCategories(false))
expect(result.current.categoriesMap.tool.label).toBe('Tools')
})
it('should use plural labels when isSingle is undefined', () => {
const { result } = renderHook(() => useCategories())
expect(result.current.categoriesMap.tool.label).toBe('Tools')
})
it('should use singular labels when isSingle is true', () => {
const { result } = renderHook(() => useCategories(true))
expect(result.current.categoriesMap.tool.label).toBe('Tool')
})
it('should handle agent category specially', () => {
const { result: resultPlural } = renderHook(() => useCategories(false))
const { result: resultSingle } = renderHook(() => useCategories(true))
expect(resultPlural.current.categoriesMap['agent-strategy'].label).toBe('Agents')
expect(resultSingle.current.categoriesMap['agent-strategy'].label).toBe('Agent')
})
})
describe('Memoization', () => {
it('should return same structure on re-render', () => {
const { result, rerender } = renderHook(() => useCategories())
const firstCategoriesLength = result.current.categories.length
const firstCategoryNames = result.current.categories.map(c => c.name)
rerender()
// Structure should remain consistent
expect(result.current.categories.length).toBe(firstCategoriesLength)
expect(result.current.categories.map(c => c.name)).toEqual(firstCategoryNames)
})
})
})
describe('usePluginPageTabs', () => {
beforeEach(() => {
vi.clearAllMocks()
mockT.mockClear()
})
describe('Rendering', () => {
it('should return tabs array', () => {
const { result } = renderHook(() => usePluginPageTabs())
expect(result.current).toBeDefined()
expect(Array.isArray(result.current)).toBe(true)
})
it('should return two tabs', () => {
const { result } = renderHook(() => usePluginPageTabs())
expect(result.current.length).toBe(2)
})
it('should return tabs with value and text properties', () => {
const { result } = renderHook(() => usePluginPageTabs())
result.current.forEach((tab) => {
expect(tab).toHaveProperty('value')
expect(tab).toHaveProperty('text')
expect(typeof tab.value).toBe('string')
expect(typeof tab.text).toBe('string')
})
})
it('should call translation function for tab texts', () => {
renderHook(() => usePluginPageTabs())
// Verify t() was called for menu translations
expect(mockT).toHaveBeenCalledWith('menus.plugins', { ns: 'common' })
expect(mockT).toHaveBeenCalledWith('menus.exploreMarketplace', { ns: 'common' })
})
})
describe('Tab Values', () => {
it('should have plugins tab with correct value', () => {
const { result } = renderHook(() => usePluginPageTabs())
const pluginsTab = result.current.find(tab => tab.value === PLUGIN_PAGE_TABS_MAP.plugins)
expect(pluginsTab).toBeDefined()
expect(pluginsTab?.value).toBe('plugins')
expect(pluginsTab?.text).toBe('Plugins')
})
it('should have marketplace tab with correct value', () => {
const { result } = renderHook(() => usePluginPageTabs())
const marketplaceTab = result.current.find(tab => tab.value === PLUGIN_PAGE_TABS_MAP.marketplace)
expect(marketplaceTab).toBeDefined()
expect(marketplaceTab?.value).toBe('discover')
expect(marketplaceTab?.text).toBe('Explore Marketplace')
})
})
describe('Tab Order', () => {
it('should return plugins tab as first tab', () => {
const { result } = renderHook(() => usePluginPageTabs())
expect(result.current[0].value).toBe('plugins')
expect(result.current[0].text).toBe('Plugins')
})
it('should return marketplace tab as second tab', () => {
const { result } = renderHook(() => usePluginPageTabs())
expect(result.current[1].value).toBe('discover')
expect(result.current[1].text).toBe('Explore Marketplace')
})
})
describe('Tab Structure', () => {
it('should have consistent structure across re-renders', () => {
const { result, rerender } = renderHook(() => usePluginPageTabs())
const firstTabs = [...result.current]
rerender()
expect(result.current).toEqual(firstTabs)
})
it('should return new array reference on each call', () => {
const { result, rerender } = renderHook(() => usePluginPageTabs())
const firstTabs = result.current
rerender()
// Each call creates a new array (not memoized)
expect(result.current).not.toBe(firstTabs)
})
})
})
describe('PLUGIN_PAGE_TABS_MAP', () => {
it('should have plugins key with correct value', () => {
expect(PLUGIN_PAGE_TABS_MAP.plugins).toBe('plugins')
})
it('should have marketplace key with correct value', () => {
expect(PLUGIN_PAGE_TABS_MAP.marketplace).toBe('discover')
})
})

View File

@ -1,945 +0,0 @@
import type { Dependency, GitHubItemAndMarketPlaceDependency, PackageDependency, Plugin, VersionInfo } from '../../../types'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginCategoryEnum } from '../../../types'
import InstallMulti from './install-multi'
// ==================== Mock Setup ====================
// Mock useFetchPluginsInMarketPlaceByInfo
const mockMarketplaceData = {
data: {
list: [
{
plugin: {
plugin_id: 'plugin-0',
org: 'test-org',
name: 'Test Plugin 0',
version: '1.0.0',
latest_version: '1.0.0',
},
version: {
unique_identifier: 'plugin-0-uid',
},
},
],
},
}
let mockInfoByIdError: Error | null = null
let mockInfoByMetaError: Error | null = null
vi.mock('@/service/use-plugins', () => ({
useFetchPluginsInMarketPlaceByInfo: () => {
// Return error based on the mock variables to simulate different error scenarios
if (mockInfoByIdError || mockInfoByMetaError) {
return {
isLoading: false,
data: null,
error: mockInfoByIdError || mockInfoByMetaError,
}
}
return {
isLoading: false,
data: mockMarketplaceData,
error: null,
}
},
}))
// Mock useCheckInstalled
const mockInstalledInfo: Record<string, VersionInfo> = {}
vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({
default: () => ({
installedInfo: mockInstalledInfo,
}),
}))
// Mock useGlobalPublicStore
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: () => ({}),
}))
// Mock pluginInstallLimit
vi.mock('../../hooks/use-install-plugin-limit', () => ({
pluginInstallLimit: () => ({ canInstall: true }),
}))
// Mock child components
vi.mock('../item/github-item', () => ({
default: vi.fn().mockImplementation(({
checked,
onCheckedChange,
dependency,
onFetchedPayload,
}: {
checked: boolean
onCheckedChange: () => void
dependency: GitHubItemAndMarketPlaceDependency
onFetchedPayload: (plugin: Plugin) => void
}) => {
// Simulate successful fetch - use ref to avoid dependency
const fetchedRef = React.useRef(false)
React.useEffect(() => {
if (fetchedRef.current)
return
fetchedRef.current = true
const mockPlugin: Plugin = {
type: 'plugin',
org: 'test-org',
name: 'GitHub Plugin',
plugin_id: 'github-plugin-id',
version: '1.0.0',
latest_version: '1.0.0',
latest_package_identifier: 'github-pkg-id',
icon: 'icon.png',
verified: true,
label: { 'en-US': 'GitHub Plugin' },
brief: { 'en-US': 'Brief' },
description: { 'en-US': 'Description' },
introduction: 'Intro',
repository: 'https://github.com/test/plugin',
category: PluginCategoryEnum.tool,
install_count: 100,
endpoint: { settings: [] },
tags: [],
badges: [],
verification: { authorized_category: 'community' },
from: 'github',
}
onFetchedPayload(mockPlugin)
}, [onFetchedPayload])
return (
<div data-testid="github-item" onClick={onCheckedChange}>
<span data-testid="github-item-checked">{checked ? 'checked' : 'unchecked'}</span>
<span data-testid="github-item-repo">{dependency.value.repo}</span>
</div>
)
}),
}))
vi.mock('../item/marketplace-item', () => ({
default: vi.fn().mockImplementation(({
checked,
onCheckedChange,
payload,
version,
_versionInfo,
}: {
checked: boolean
onCheckedChange: () => void
payload: Plugin
version: string
_versionInfo: VersionInfo
}) => (
<div data-testid="marketplace-item" onClick={onCheckedChange}>
<span data-testid="marketplace-item-checked">{checked ? 'checked' : 'unchecked'}</span>
<span data-testid="marketplace-item-name">{payload?.name || 'Loading'}</span>
<span data-testid="marketplace-item-version">{version}</span>
</div>
)),
}))
vi.mock('../item/package-item', () => ({
default: vi.fn().mockImplementation(({
checked,
onCheckedChange,
payload,
_isFromMarketPlace,
_versionInfo,
}: {
checked: boolean
onCheckedChange: () => void
payload: PackageDependency
_isFromMarketPlace: boolean
_versionInfo: VersionInfo
}) => (
<div data-testid="package-item" onClick={onCheckedChange}>
<span data-testid="package-item-checked">{checked ? 'checked' : 'unchecked'}</span>
<span data-testid="package-item-name">{payload.value.manifest.name}</span>
</div>
)),
}))
vi.mock('../../base/loading-error', () => ({
default: () => <div data-testid="loading-error">Loading Error</div>,
}))
// ==================== Test Utilities ====================
const createMockPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({
type: 'plugin',
org: 'test-org',
name: 'Test Plugin',
plugin_id: 'test-plugin-id',
version: '1.0.0',
latest_version: '1.0.0',
latest_package_identifier: 'test-package-id',
icon: 'test-icon.png',
verified: true,
label: { 'en-US': 'Test Plugin' },
brief: { 'en-US': 'A test plugin' },
description: { 'en-US': 'A test plugin description' },
introduction: 'Introduction text',
repository: 'https://github.com/test/plugin',
category: PluginCategoryEnum.tool,
install_count: 100,
endpoint: { settings: [] },
tags: [],
badges: [],
verification: { authorized_category: 'community' },
from: 'marketplace',
...overrides,
})
const createMarketplaceDependency = (index: number): GitHubItemAndMarketPlaceDependency => ({
type: 'marketplace',
value: {
marketplace_plugin_unique_identifier: `test-org/plugin-${index}:1.0.0`,
plugin_unique_identifier: `plugin-${index}`,
version: '1.0.0',
},
})
const createGitHubDependency = (index: number): GitHubItemAndMarketPlaceDependency => ({
type: 'github',
value: {
repo: `test-org/plugin-${index}`,
version: 'v1.0.0',
package: `plugin-${index}.zip`,
},
})
const createPackageDependency = (index: number) => ({
type: 'package',
value: {
unique_identifier: `package-plugin-${index}-uid`,
manifest: {
plugin_unique_identifier: `package-plugin-${index}-uid`,
version: '1.0.0',
author: 'test-author',
icon: 'icon.png',
name: `Package Plugin ${index}`,
category: PluginCategoryEnum.tool,
label: { 'en-US': `Package Plugin ${index}` },
description: { 'en-US': 'Test package plugin' },
created_at: '2024-01-01',
resource: {},
plugins: [],
verified: true,
endpoint: { settings: [], endpoints: [] },
model: null,
tags: [],
agent_strategy: null,
meta: { version: '1.0.0' },
trigger: {},
},
},
} as unknown as PackageDependency)
// ==================== InstallMulti Component Tests ====================
describe('InstallMulti Component', () => {
const defaultProps = {
allPlugins: [createPackageDependency(0)] as Dependency[],
selectedPlugins: [] as Plugin[],
onSelect: vi.fn(),
onSelectAll: vi.fn(),
onDeSelectAll: vi.fn(),
onLoadedAllPlugin: vi.fn(),
isFromMarketPlace: false,
}
beforeEach(() => {
vi.clearAllMocks()
})
// ==================== Rendering Tests ====================
describe('Rendering', () => {
it('should render without crashing', () => {
render(<InstallMulti {...defaultProps} />)
expect(screen.getByTestId('package-item')).toBeInTheDocument()
})
it('should render PackageItem for package type dependency', () => {
render(<InstallMulti {...defaultProps} />)
expect(screen.getByTestId('package-item')).toBeInTheDocument()
expect(screen.getByTestId('package-item-name')).toHaveTextContent('Package Plugin 0')
})
it('should render GithubItem for github type dependency', async () => {
const githubProps = {
...defaultProps,
allPlugins: [createGitHubDependency(0)] as Dependency[],
}
render(<InstallMulti {...githubProps} />)
await waitFor(() => {
expect(screen.getByTestId('github-item')).toBeInTheDocument()
})
expect(screen.getByTestId('github-item-repo')).toHaveTextContent('test-org/plugin-0')
})
it('should render MarketplaceItem for marketplace type dependency', async () => {
const marketplaceProps = {
...defaultProps,
allPlugins: [createMarketplaceDependency(0)] as Dependency[],
}
render(<InstallMulti {...marketplaceProps} />)
await waitFor(() => {
expect(screen.getByTestId('marketplace-item')).toBeInTheDocument()
})
})
it('should render multiple items for mixed dependency types', async () => {
const mixedProps = {
...defaultProps,
allPlugins: [
createPackageDependency(0),
createGitHubDependency(1),
] as Dependency[],
}
render(<InstallMulti {...mixedProps} />)
await waitFor(() => {
expect(screen.getByTestId('package-item')).toBeInTheDocument()
expect(screen.getByTestId('github-item')).toBeInTheDocument()
})
})
it('should render LoadingError for failed plugin fetches', async () => {
// This test requires simulating an error state
// The component tracks errorIndexes for failed fetches
// We'll test this through the GitHub item's onFetchError callback
const githubProps = {
...defaultProps,
allPlugins: [createGitHubDependency(0)] as Dependency[],
}
// The actual error handling is internal to the component
// Just verify component renders
render(<InstallMulti {...githubProps} />)
await waitFor(() => {
expect(screen.queryByTestId('github-item')).toBeInTheDocument()
})
})
})
// ==================== Selection Tests ====================
describe('Selection', () => {
it('should call onSelect when item is clicked', async () => {
render(<InstallMulti {...defaultProps} />)
const packageItem = screen.getByTestId('package-item')
await act(async () => {
fireEvent.click(packageItem)
})
expect(defaultProps.onSelect).toHaveBeenCalled()
})
it('should show checked state when plugin is selected', async () => {
const selectedPlugin = createMockPlugin({ plugin_id: 'package-plugin-0-uid' })
const propsWithSelected = {
...defaultProps,
selectedPlugins: [selectedPlugin],
}
render(<InstallMulti {...propsWithSelected} />)
expect(screen.getByTestId('package-item-checked')).toHaveTextContent('checked')
})
it('should show unchecked state when plugin is not selected', () => {
render(<InstallMulti {...defaultProps} />)
expect(screen.getByTestId('package-item-checked')).toHaveTextContent('unchecked')
})
})
// ==================== useImperativeHandle Tests ====================
describe('Imperative Handle', () => {
it('should expose selectAllPlugins function', async () => {
const ref: { current: { selectAllPlugins: () => void, deSelectAllPlugins: () => void } | null } = { current: null }
render(<InstallMulti {...defaultProps} ref={ref} />)
await waitFor(() => {
expect(ref.current).not.toBeNull()
})
await act(async () => {
ref.current?.selectAllPlugins()
})
expect(defaultProps.onSelectAll).toHaveBeenCalled()
})
it('should expose deSelectAllPlugins function', async () => {
const ref: { current: { selectAllPlugins: () => void, deSelectAllPlugins: () => void } | null } = { current: null }
render(<InstallMulti {...defaultProps} ref={ref} />)
await waitFor(() => {
expect(ref.current).not.toBeNull()
})
await act(async () => {
ref.current?.deSelectAllPlugins()
})
expect(defaultProps.onDeSelectAll).toHaveBeenCalled()
})
})
// ==================== onLoadedAllPlugin Callback Tests ====================
describe('onLoadedAllPlugin Callback', () => {
it('should call onLoadedAllPlugin when all plugins are loaded', async () => {
render(<InstallMulti {...defaultProps} />)
await waitFor(() => {
expect(defaultProps.onLoadedAllPlugin).toHaveBeenCalled()
})
})
it('should pass installedInfo to onLoadedAllPlugin', async () => {
render(<InstallMulti {...defaultProps} />)
await waitFor(() => {
expect(defaultProps.onLoadedAllPlugin).toHaveBeenCalledWith(expect.any(Object))
})
})
})
// ==================== Version Info Tests ====================
describe('Version Info', () => {
it('should pass version info to items', async () => {
render(<InstallMulti {...defaultProps} />)
// The getVersionInfo function returns hasInstalled, installedVersion, toInstallVersion
// These are passed to child components
await waitFor(() => {
expect(screen.getByTestId('package-item')).toBeInTheDocument()
})
})
})
// ==================== GitHub Plugin Fetch Tests ====================
describe('GitHub Plugin Fetch', () => {
it('should handle successful GitHub plugin fetch', async () => {
const githubProps = {
...defaultProps,
allPlugins: [createGitHubDependency(0)] as Dependency[],
}
render(<InstallMulti {...githubProps} />)
await waitFor(() => {
expect(screen.getByTestId('github-item')).toBeInTheDocument()
})
// The onFetchedPayload callback should have been called by the mock
// which updates the internal plugins state
})
})
// ==================== Marketplace Data Fetch Tests ====================
describe('Marketplace Data Fetch', () => {
it('should fetch and display marketplace plugin data', async () => {
const marketplaceProps = {
...defaultProps,
allPlugins: [createMarketplaceDependency(0)] as Dependency[],
}
render(<InstallMulti {...marketplaceProps} />)
await waitFor(() => {
expect(screen.getByTestId('marketplace-item')).toBeInTheDocument()
})
})
})
// ==================== Edge Cases ====================
describe('Edge Cases', () => {
it('should handle empty allPlugins array', () => {
const emptyProps = {
...defaultProps,
allPlugins: [],
}
const { container } = render(<InstallMulti {...emptyProps} />)
// Should render empty fragment
expect(container.firstChild).toBeNull()
})
it('should handle plugins without version info', async () => {
render(<InstallMulti {...defaultProps} />)
await waitFor(() => {
expect(screen.getByTestId('package-item')).toBeInTheDocument()
})
})
it('should pass isFromMarketPlace to PackageItem', async () => {
const propsWithMarketplace = {
...defaultProps,
isFromMarketPlace: true,
}
render(<InstallMulti {...propsWithMarketplace} />)
await waitFor(() => {
expect(screen.getByTestId('package-item')).toBeInTheDocument()
})
})
})
// ==================== Plugin State Management ====================
describe('Plugin State Management', () => {
it('should initialize plugins array with package plugins', () => {
render(<InstallMulti {...defaultProps} />)
// Package plugins are initialized immediately
expect(screen.getByTestId('package-item')).toBeInTheDocument()
})
it('should update plugins when GitHub plugin is fetched', async () => {
const githubProps = {
...defaultProps,
allPlugins: [createGitHubDependency(0)] as Dependency[],
}
render(<InstallMulti {...githubProps} />)
await waitFor(() => {
expect(screen.getByTestId('github-item')).toBeInTheDocument()
})
})
})
// ==================== Multiple Marketplace Plugins ====================
describe('Multiple Marketplace Plugins', () => {
it('should handle multiple marketplace plugins', async () => {
const multipleMarketplace = {
...defaultProps,
allPlugins: [
createMarketplaceDependency(0),
createMarketplaceDependency(1),
] as Dependency[],
}
render(<InstallMulti {...multipleMarketplace} />)
await waitFor(() => {
const items = screen.getAllByTestId('marketplace-item')
expect(items.length).toBeGreaterThanOrEqual(1)
})
})
})
// ==================== Error Handling ====================
describe('Error Handling', () => {
it('should handle fetch errors gracefully', async () => {
// Component should still render even with errors
render(<InstallMulti {...defaultProps} />)
await waitFor(() => {
expect(screen.getByTestId('package-item')).toBeInTheDocument()
})
})
it('should show LoadingError for failed marketplace fetch', async () => {
// This tests the error handling branch in useEffect
const marketplaceProps = {
...defaultProps,
allPlugins: [createMarketplaceDependency(0)] as Dependency[],
}
render(<InstallMulti {...marketplaceProps} />)
// Component should render
await waitFor(() => {
expect(screen.queryByTestId('marketplace-item') || screen.queryByTestId('loading-error')).toBeTruthy()
})
})
})
// ==================== selectAllPlugins Edge Cases ====================
describe('selectAllPlugins Edge Cases', () => {
it('should skip plugins that are not loaded', async () => {
const ref: { current: { selectAllPlugins: () => void, deSelectAllPlugins: () => void } | null } = { current: null }
// Use mixed plugins where some might not be loaded
const mixedProps = {
...defaultProps,
allPlugins: [
createPackageDependency(0),
createMarketplaceDependency(1),
] as Dependency[],
}
render(<InstallMulti {...mixedProps} ref={ref} />)
await waitFor(() => {
expect(ref.current).not.toBeNull()
})
await act(async () => {
ref.current?.selectAllPlugins()
})
// onSelectAll should be called with only the loaded plugins
expect(defaultProps.onSelectAll).toHaveBeenCalled()
})
})
// ==================== Version with fallback ====================
describe('Version Handling', () => {
it('should handle marketplace item version display', async () => {
const marketplaceProps = {
...defaultProps,
allPlugins: [createMarketplaceDependency(0)] as Dependency[],
}
render(<InstallMulti {...marketplaceProps} />)
await waitFor(() => {
expect(screen.getByTestId('marketplace-item')).toBeInTheDocument()
})
// Version should be displayed
expect(screen.getByTestId('marketplace-item-version')).toBeInTheDocument()
})
})
// ==================== GitHub Plugin Error Handling ====================
describe('GitHub Plugin Error Handling', () => {
it('should handle GitHub fetch error', async () => {
const githubProps = {
...defaultProps,
allPlugins: [createGitHubDependency(0)] as Dependency[],
}
render(<InstallMulti {...githubProps} />)
// Should render even with error
await waitFor(() => {
expect(screen.queryByTestId('github-item')).toBeTruthy()
})
})
})
// ==================== Marketplace Fetch Error Scenarios ====================
describe('Marketplace Fetch Error Scenarios', () => {
beforeEach(() => {
vi.clearAllMocks()
mockInfoByIdError = null
mockInfoByMetaError = null
})
afterEach(() => {
mockInfoByIdError = null
mockInfoByMetaError = null
})
it('should add to errorIndexes when infoByIdError occurs', async () => {
// Set the error to simulate API failure
mockInfoByIdError = new Error('Failed to fetch by ID')
const marketplaceProps = {
...defaultProps,
allPlugins: [createMarketplaceDependency(0)] as Dependency[],
}
render(<InstallMulti {...marketplaceProps} />)
// Component should handle error gracefully
await waitFor(() => {
// Either loading error or marketplace item should be present
expect(
screen.queryByTestId('loading-error')
|| screen.queryByTestId('marketplace-item'),
).toBeTruthy()
})
})
it('should add to errorIndexes when infoByMetaError occurs', async () => {
// Set the error to simulate API failure
mockInfoByMetaError = new Error('Failed to fetch by meta')
const marketplaceProps = {
...defaultProps,
allPlugins: [createMarketplaceDependency(0)] as Dependency[],
}
render(<InstallMulti {...marketplaceProps} />)
// Component should handle error gracefully
await waitFor(() => {
expect(
screen.queryByTestId('loading-error')
|| screen.queryByTestId('marketplace-item'),
).toBeTruthy()
})
})
it('should handle both infoByIdError and infoByMetaError', async () => {
// Set both errors
mockInfoByIdError = new Error('Failed to fetch by ID')
mockInfoByMetaError = new Error('Failed to fetch by meta')
const marketplaceProps = {
...defaultProps,
allPlugins: [createMarketplaceDependency(0), createMarketplaceDependency(1)] as Dependency[],
}
render(<InstallMulti {...marketplaceProps} />)
await waitFor(() => {
// Component should render
expect(document.body).toBeInTheDocument()
})
})
})
// ==================== Installed Info Handling ====================
describe('Installed Info', () => {
it('should pass installed info to getVersionInfo', async () => {
render(<InstallMulti {...defaultProps} />)
await waitFor(() => {
expect(screen.getByTestId('package-item')).toBeInTheDocument()
})
// The getVersionInfo callback should return correct structure
// This is tested indirectly through the item rendering
})
})
// ==================== Selected Plugins Checked State ====================
describe('Selected Plugins Checked State', () => {
it('should show checked state for github item when selected', async () => {
const selectedPlugin = createMockPlugin({ plugin_id: 'github-plugin-id' })
const propsWithSelected = {
...defaultProps,
allPlugins: [createGitHubDependency(0)] as Dependency[],
selectedPlugins: [selectedPlugin],
}
render(<InstallMulti {...propsWithSelected} />)
await waitFor(() => {
expect(screen.getByTestId('github-item')).toBeInTheDocument()
})
expect(screen.getByTestId('github-item-checked')).toHaveTextContent('checked')
})
it('should show checked state for marketplace item when selected', async () => {
const selectedPlugin = createMockPlugin({ plugin_id: 'plugin-0' })
const propsWithSelected = {
...defaultProps,
allPlugins: [createMarketplaceDependency(0)] as Dependency[],
selectedPlugins: [selectedPlugin],
}
render(<InstallMulti {...propsWithSelected} />)
await waitFor(() => {
expect(screen.getByTestId('marketplace-item')).toBeInTheDocument()
})
// The checked prop should be passed to the item
})
it('should handle unchecked state for items not in selectedPlugins', async () => {
const propsWithoutSelected = {
...defaultProps,
allPlugins: [createGitHubDependency(0)] as Dependency[],
selectedPlugins: [],
}
render(<InstallMulti {...propsWithoutSelected} />)
await waitFor(() => {
expect(screen.getByTestId('github-item')).toBeInTheDocument()
})
expect(screen.getByTestId('github-item-checked')).toHaveTextContent('unchecked')
})
})
// ==================== Plugin Not Loaded Scenario ====================
describe('Plugin Not Loaded', () => {
it('should skip undefined plugins in selectAllPlugins', async () => {
const ref: { current: { selectAllPlugins: () => void, deSelectAllPlugins: () => void } | null } = { current: null }
// Create a scenario where some plugins might not be loaded
const mixedProps = {
...defaultProps,
allPlugins: [
createPackageDependency(0),
createGitHubDependency(1),
createMarketplaceDependency(2),
] as Dependency[],
}
render(<InstallMulti {...mixedProps} ref={ref} />)
await waitFor(() => {
expect(ref.current).not.toBeNull()
})
// Call selectAllPlugins - it should handle undefined plugins gracefully
await act(async () => {
ref.current?.selectAllPlugins()
})
expect(defaultProps.onSelectAll).toHaveBeenCalled()
})
})
// ==================== handleSelect with Plugin Install Limits ====================
describe('handleSelect with Plugin Install Limits', () => {
it('should filter plugins based on canInstall when selecting', async () => {
const mixedProps = {
...defaultProps,
allPlugins: [
createPackageDependency(0),
createPackageDependency(1),
] as Dependency[],
}
render(<InstallMulti {...mixedProps} />)
const packageItems = screen.getAllByTestId('package-item')
await act(async () => {
fireEvent.click(packageItems[0])
})
// onSelect should be called with filtered plugin count
expect(defaultProps.onSelect).toHaveBeenCalled()
})
})
// ==================== Version fallback handling ====================
describe('Version Fallback', () => {
it('should use latest_version when version is not available', async () => {
const marketplaceProps = {
...defaultProps,
allPlugins: [createMarketplaceDependency(0)] as Dependency[],
}
render(<InstallMulti {...marketplaceProps} />)
await waitFor(() => {
expect(screen.getByTestId('marketplace-item')).toBeInTheDocument()
})
// The version should be displayed (from dependency or plugin)
expect(screen.getByTestId('marketplace-item-version')).toBeInTheDocument()
})
})
// ==================== getVersionInfo edge cases ====================
describe('getVersionInfo Edge Cases', () => {
it('should return correct version info structure', async () => {
render(<InstallMulti {...defaultProps} />)
await waitFor(() => {
expect(screen.getByTestId('package-item')).toBeInTheDocument()
})
// The component should pass versionInfo to items
// This is verified indirectly through successful rendering
})
it('should handle plugins with author instead of org', async () => {
// Package plugins use author instead of org
render(<InstallMulti {...defaultProps} />)
await waitFor(() => {
expect(screen.getByTestId('package-item')).toBeInTheDocument()
expect(defaultProps.onLoadedAllPlugin).toHaveBeenCalled()
})
})
})
// ==================== Multiple marketplace items ====================
describe('Multiple Marketplace Items', () => {
it('should process all marketplace items correctly', async () => {
const multiMarketplace = {
...defaultProps,
allPlugins: [
createMarketplaceDependency(0),
createMarketplaceDependency(1),
createMarketplaceDependency(2),
] as Dependency[],
}
render(<InstallMulti {...multiMarketplace} />)
await waitFor(() => {
const items = screen.getAllByTestId('marketplace-item')
expect(items.length).toBeGreaterThanOrEqual(1)
})
})
})
// ==================== Multiple GitHub items ====================
describe('Multiple GitHub Items', () => {
it('should handle multiple GitHub plugin fetches', async () => {
const multiGithub = {
...defaultProps,
allPlugins: [
createGitHubDependency(0),
createGitHubDependency(1),
] as Dependency[],
}
render(<InstallMulti {...multiGithub} />)
await waitFor(() => {
const items = screen.getAllByTestId('github-item')
expect(items.length).toBe(2)
})
})
})
// ==================== canInstall false scenario ====================
describe('canInstall False Scenario', () => {
it('should skip plugins that cannot be installed in selectAllPlugins', async () => {
const ref: { current: { selectAllPlugins: () => void, deSelectAllPlugins: () => void } | null } = { current: null }
const multiplePlugins = {
...defaultProps,
allPlugins: [
createPackageDependency(0),
createPackageDependency(1),
createPackageDependency(2),
] as Dependency[],
}
render(<InstallMulti {...multiplePlugins} ref={ref} />)
await waitFor(() => {
expect(ref.current).not.toBeNull()
})
await act(async () => {
ref.current?.selectAllPlugins()
})
expect(defaultProps.onSelectAll).toHaveBeenCalled()
})
})
})

View File

@ -1,846 +0,0 @@
import type { Dependency, InstallStatusResponse, PackageDependency } from '../../../types'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginCategoryEnum, TaskStatus } from '../../../types'
import Install from './install'
// ==================== Mock Setup ====================
// Mock useInstallOrUpdate and usePluginTaskList
const mockInstallOrUpdate = vi.fn()
const mockHandleRefetch = vi.fn()
let mockInstallResponse: 'success' | 'failed' | 'running' = 'success'
vi.mock('@/service/use-plugins', () => ({
useInstallOrUpdate: (options: { onSuccess: (res: InstallStatusResponse[]) => void }) => {
mockInstallOrUpdate.mockImplementation((params: { payload: Dependency[] }) => {
// Call onSuccess with mock response based on mockInstallResponse
const getStatus = () => {
if (mockInstallResponse === 'success')
return TaskStatus.success
if (mockInstallResponse === 'failed')
return TaskStatus.failed
return TaskStatus.running
}
const mockResponse: InstallStatusResponse[] = params.payload.map(() => ({
status: getStatus(),
taskId: 'mock-task-id',
uniqueIdentifier: 'mock-uid',
}))
options.onSuccess(mockResponse)
})
return {
mutate: mockInstallOrUpdate,
isPending: false,
}
},
usePluginTaskList: () => ({
handleRefetch: mockHandleRefetch,
}),
}))
// Mock checkTaskStatus
const mockCheck = vi.fn()
const mockStop = vi.fn()
vi.mock('../../base/check-task-status', () => ({
default: () => ({
check: mockCheck,
stop: mockStop,
}),
}))
// Mock useRefreshPluginList
const mockRefreshPluginList = vi.fn()
vi.mock('../../hooks/use-refresh-plugin-list', () => ({
default: () => ({
refreshPluginList: mockRefreshPluginList,
}),
}))
// Mock mitt context
const mockEmit = vi.fn()
vi.mock('@/context/mitt-context', () => ({
useMittContextSelector: () => mockEmit,
}))
// Mock useCanInstallPluginFromMarketplace
vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({
useCanInstallPluginFromMarketplace: () => ({ canInstallPluginFromMarketplace: true }),
}))
// Mock InstallMulti component with forwardRef support
vi.mock('./install-multi', async () => {
const React = await import('react')
const createPlugin = (index: number) => ({
type: 'plugin',
org: 'test-org',
name: `Test Plugin ${index}`,
plugin_id: `test-plugin-${index}`,
version: '1.0.0',
latest_version: '1.0.0',
latest_package_identifier: `test-pkg-${index}`,
icon: 'icon.png',
verified: true,
label: { 'en-US': `Test Plugin ${index}` },
brief: { 'en-US': 'Brief' },
description: { 'en-US': 'Description' },
introduction: 'Intro',
repository: 'https://github.com/test/plugin',
category: 'tool',
install_count: 100,
endpoint: { settings: [] },
tags: [],
badges: [],
verification: { authorized_category: 'community' },
from: 'marketplace',
})
const MockInstallMulti = React.forwardRef((props: {
allPlugins: { length: number }[]
selectedPlugins: { plugin_id: string }[]
onSelect: (plugin: ReturnType<typeof createPlugin>, index: number, total: number) => void
onSelectAll: (plugins: ReturnType<typeof createPlugin>[], indexes: number[]) => void
onDeSelectAll: () => void
onLoadedAllPlugin: (info: Record<string, unknown>) => void
}, ref: React.ForwardedRef<{ selectAllPlugins: () => void, deSelectAllPlugins: () => void }>) => {
const {
allPlugins,
selectedPlugins,
onSelect,
onSelectAll,
onDeSelectAll,
onLoadedAllPlugin,
} = props
const allPluginsRef = React.useRef(allPlugins)
React.useEffect(() => {
allPluginsRef.current = allPlugins
}, [allPlugins])
// Expose ref methods
React.useImperativeHandle(ref, () => ({
selectAllPlugins: () => {
const plugins = allPluginsRef.current.map((_, i) => createPlugin(i))
const indexes = allPluginsRef.current.map((_, i) => i)
onSelectAll(plugins, indexes)
},
deSelectAllPlugins: () => {
onDeSelectAll()
},
}), [onSelectAll, onDeSelectAll])
// Simulate loading completion when mounted
React.useEffect(() => {
const installedInfo = {}
onLoadedAllPlugin(installedInfo)
}, [onLoadedAllPlugin])
return (
<div data-testid="install-multi">
<span data-testid="all-plugins-count">{allPlugins.length}</span>
<span data-testid="selected-plugins-count">{selectedPlugins.length}</span>
<button
data-testid="select-plugin-0"
onClick={() => {
onSelect(createPlugin(0), 0, allPlugins.length)
}}
>
Select Plugin 0
</button>
<button
data-testid="select-plugin-1"
onClick={() => {
onSelect(createPlugin(1), 1, allPlugins.length)
}}
>
Select Plugin 1
</button>
<button
data-testid="toggle-plugin-0"
onClick={() => {
const plugin = createPlugin(0)
onSelect(plugin, 0, allPlugins.length)
}}
>
Toggle Plugin 0
</button>
<button
data-testid="select-all-plugins"
onClick={() => {
const plugins = allPlugins.map((_, i) => createPlugin(i))
const indexes = allPlugins.map((_, i) => i)
onSelectAll(plugins, indexes)
}}
>
Select All
</button>
<button
data-testid="deselect-all-plugins"
onClick={() => onDeSelectAll()}
>
Deselect All
</button>
</div>
)
})
return { default: MockInstallMulti }
})
// ==================== Test Utilities ====================
const createMockDependency = (type: 'marketplace' | 'github' | 'package' = 'marketplace', index = 0): Dependency => {
if (type === 'marketplace') {
return {
type: 'marketplace',
value: {
marketplace_plugin_unique_identifier: `plugin-${index}-uid`,
},
} as Dependency
}
if (type === 'github') {
return {
type: 'github',
value: {
repo: `test/plugin${index}`,
version: 'v1.0.0',
package: `plugin${index}.zip`,
},
} as Dependency
}
return {
type: 'package',
value: {
unique_identifier: `package-plugin-${index}-uid`,
manifest: {
plugin_unique_identifier: `package-plugin-${index}-uid`,
version: '1.0.0',
author: 'test-author',
icon: 'icon.png',
name: `Package Plugin ${index}`,
category: PluginCategoryEnum.tool,
label: { 'en-US': `Package Plugin ${index}` },
description: { 'en-US': 'Test package plugin' },
created_at: '2024-01-01',
resource: {},
plugins: [],
verified: true,
endpoint: { settings: [], endpoints: [] },
model: null,
tags: [],
agent_strategy: null,
meta: { version: '1.0.0' },
trigger: {},
},
},
} as unknown as PackageDependency
}
// ==================== Install Component Tests ====================
describe('Install Component', () => {
const defaultProps = {
allPlugins: [createMockDependency('marketplace', 0), createMockDependency('github', 1)],
onStartToInstall: vi.fn(),
onInstalled: vi.fn(),
onCancel: vi.fn(),
isFromMarketPlace: true,
}
beforeEach(() => {
vi.clearAllMocks()
})
// ==================== Rendering Tests ====================
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Install {...defaultProps} />)
expect(screen.getByTestId('install-multi')).toBeInTheDocument()
})
it('should render InstallMulti component with correct props', () => {
render(<Install {...defaultProps} />)
expect(screen.getByTestId('all-plugins-count')).toHaveTextContent('2')
})
it('should show singular text when one plugin is selected', async () => {
render(<Install {...defaultProps} />)
// Select one plugin
await act(async () => {
fireEvent.click(screen.getByTestId('select-plugin-0'))
})
// Should show "1" in the ready to install message
expect(screen.getByText(/plugin\.installModal\.readyToInstallPackage/i)).toBeInTheDocument()
})
it('should show plural text when multiple plugins are selected', async () => {
render(<Install {...defaultProps} />)
// Select all plugins
await act(async () => {
fireEvent.click(screen.getByTestId('select-all-plugins'))
})
// Should show "2" in the ready to install packages message
expect(screen.getByText(/plugin\.installModal\.readyToInstallPackages/i)).toBeInTheDocument()
})
it('should render action buttons when isHideButton is false', () => {
render(<Install {...defaultProps} />)
// Install button should be present
expect(screen.getByText(/plugin\.installModal\.install/i)).toBeInTheDocument()
})
it('should not render action buttons when isHideButton is true', () => {
render(<Install {...defaultProps} isHideButton={true} />)
// Install button should not be present
expect(screen.queryByText(/plugin\.installModal\.install/i)).not.toBeInTheDocument()
})
it('should show cancel button when canInstall is false', () => {
// Create a fresh component that hasn't loaded yet
vi.doMock('./install-multi', () => ({
default: vi.fn().mockImplementation(() => (
<div data-testid="install-multi">Loading...</div>
)),
}))
// Since InstallMulti doesn't call onLoadedAllPlugin, canInstall stays false
// But we need to test this properly - for now just verify button states
render(<Install {...defaultProps} />)
// After loading, cancel button should not be shown
// Wait for the component to load
expect(screen.getByText(/plugin\.installModal\.install/i)).toBeInTheDocument()
})
})
// ==================== Selection Tests ====================
describe('Selection', () => {
it('should handle single plugin selection', async () => {
render(<Install {...defaultProps} />)
await act(async () => {
fireEvent.click(screen.getByTestId('select-plugin-0'))
})
expect(screen.getByTestId('selected-plugins-count')).toHaveTextContent('1')
})
it('should handle select all plugins', async () => {
render(<Install {...defaultProps} />)
await act(async () => {
fireEvent.click(screen.getByTestId('select-all-plugins'))
})
expect(screen.getByTestId('selected-plugins-count')).toHaveTextContent('2')
})
it('should handle deselect all plugins', async () => {
render(<Install {...defaultProps} />)
// First select all
await act(async () => {
fireEvent.click(screen.getByTestId('select-all-plugins'))
})
// Then deselect all
await act(async () => {
fireEvent.click(screen.getByTestId('deselect-all-plugins'))
})
expect(screen.getByTestId('selected-plugins-count')).toHaveTextContent('0')
})
it('should toggle select all checkbox state', async () => {
render(<Install {...defaultProps} />)
// After loading, handleLoadedAllPlugin triggers handleClickSelectAll which selects all
// So initially it shows deSelectAll
await waitFor(() => {
expect(screen.getByText(/common\.operation\.deSelectAll/i)).toBeInTheDocument()
})
// Click deselect all to deselect
await act(async () => {
fireEvent.click(screen.getByTestId('deselect-all-plugins'))
})
// Now should show selectAll since none are selected
await waitFor(() => {
expect(screen.getByText(/common\.operation\.selectAll/i)).toBeInTheDocument()
})
})
it('should call deSelectAllPlugins when clicking selectAll checkbox while isSelectAll is true', async () => {
render(<Install {...defaultProps} />)
// After loading, handleLoadedAllPlugin is called which triggers handleClickSelectAll
// Since isSelectAll is initially false, it calls selectAllPlugins
// So all plugins are selected after loading
await waitFor(() => {
expect(screen.getByText(/common\.operation\.deSelectAll/i)).toBeInTheDocument()
})
// Click the checkbox container div (parent of the text) to trigger handleClickSelectAll
// The div has onClick={handleClickSelectAll}
// Since isSelectAll is true, it should call deSelectAllPlugins
const deSelectText = screen.getByText(/common\.operation\.deSelectAll/i)
const checkboxContainer = deSelectText.parentElement
await act(async () => {
if (checkboxContainer)
fireEvent.click(checkboxContainer)
})
// Should now show selectAll again (deSelectAllPlugins was called)
await waitFor(() => {
expect(screen.getByText(/common\.operation\.selectAll/i)).toBeInTheDocument()
})
})
it('should show indeterminate state when some plugins are selected', async () => {
const threePlugins = [
createMockDependency('marketplace', 0),
createMockDependency('marketplace', 1),
createMockDependency('marketplace', 2),
]
render(<Install {...defaultProps} allPlugins={threePlugins} />)
// After loading, all 3 plugins are selected
await waitFor(() => {
expect(screen.getByTestId('selected-plugins-count')).toHaveTextContent('3')
})
// Deselect two plugins to get to indeterminate state (1 selected out of 3)
await act(async () => {
fireEvent.click(screen.getByTestId('toggle-plugin-0'))
})
await act(async () => {
fireEvent.click(screen.getByTestId('toggle-plugin-0'))
})
// After toggle twice, we're back to all selected
// Let's instead click toggle once and check the checkbox component
// For now, verify the component handles partial selection
expect(screen.getByTestId('selected-plugins-count')).toHaveTextContent('3')
})
})
// ==================== Install Action Tests ====================
describe('Install Actions', () => {
it('should call onStartToInstall when install is clicked', async () => {
render(<Install {...defaultProps} />)
// Select a plugin first
await act(async () => {
fireEvent.click(screen.getByTestId('select-all-plugins'))
})
// Click install button
const installButton = screen.getByText(/plugin\.installModal\.install/i)
await act(async () => {
fireEvent.click(installButton)
})
expect(defaultProps.onStartToInstall).toHaveBeenCalled()
})
it('should call installOrUpdate with correct payload', async () => {
render(<Install {...defaultProps} />)
// Select all plugins
await act(async () => {
fireEvent.click(screen.getByTestId('select-all-plugins'))
})
// Click install
const installButton = screen.getByText(/plugin\.installModal\.install/i)
await act(async () => {
fireEvent.click(installButton)
})
expect(mockInstallOrUpdate).toHaveBeenCalled()
})
it('should call onInstalled when installation succeeds', async () => {
render(<Install {...defaultProps} />)
// Select all plugins
await act(async () => {
fireEvent.click(screen.getByTestId('select-all-plugins'))
})
// Click install
const installButton = screen.getByText(/plugin\.installModal\.install/i)
await act(async () => {
fireEvent.click(installButton)
})
await waitFor(() => {
expect(defaultProps.onInstalled).toHaveBeenCalled()
})
})
it('should refresh plugin list on successful installation', async () => {
render(<Install {...defaultProps} />)
// Select all plugins
await act(async () => {
fireEvent.click(screen.getByTestId('select-all-plugins'))
})
// Click install
const installButton = screen.getByText(/plugin\.installModal\.install/i)
await act(async () => {
fireEvent.click(installButton)
})
await waitFor(() => {
expect(mockRefreshPluginList).toHaveBeenCalled()
})
})
it('should emit plugin:install:success event on successful installation', async () => {
render(<Install {...defaultProps} />)
// Select all plugins
await act(async () => {
fireEvent.click(screen.getByTestId('select-all-plugins'))
})
// Click install
const installButton = screen.getByText(/plugin\.installModal\.install/i)
await act(async () => {
fireEvent.click(installButton)
})
await waitFor(() => {
expect(mockEmit).toHaveBeenCalledWith('plugin:install:success', expect.any(Array))
})
})
it('should disable install button when no plugins are selected', async () => {
render(<Install {...defaultProps} />)
// Deselect all
await act(async () => {
fireEvent.click(screen.getByTestId('deselect-all-plugins'))
})
const installButton = screen.getByText(/plugin\.installModal\.install/i).closest('button')
expect(installButton).toBeDisabled()
})
})
// ==================== Cancel Action Tests ====================
describe('Cancel Actions', () => {
it('should call stop and onCancel when cancel is clicked', async () => {
// Need to test when canInstall is false
// For now, the cancel button appears only before loading completes
// After loading, it disappears
render(<Install {...defaultProps} />)
// The cancel button should not be visible after loading
// This is the expected behavior based on the component logic
await waitFor(() => {
expect(screen.queryByText(/common\.operation\.cancel/i)).not.toBeInTheDocument()
})
})
it('should trigger handleCancel when cancel button is visible and clicked', async () => {
// Override the mock to NOT call onLoadedAllPlugin immediately
// This keeps canInstall = false so the cancel button is visible
vi.doMock('./install-multi', () => ({
default: vi.fn().mockImplementation(() => (
<div data-testid="install-multi-no-load">Loading...</div>
)),
}))
// For this test, we just verify the cancel behavior
// The actual cancel button appears when canInstall is false
render(<Install {...defaultProps} />)
// Initially before loading completes, cancel should be visible
// After loading completes in our mock, it disappears
expect(document.body).toBeInTheDocument()
})
})
// ==================== Edge Cases ====================
describe('Edge Cases', () => {
it('should handle empty plugins array', () => {
render(<Install {...defaultProps} allPlugins={[]} />)
expect(screen.getByTestId('all-plugins-count')).toHaveTextContent('0')
})
it('should handle single plugin', () => {
render(<Install {...defaultProps} allPlugins={[createMockDependency('marketplace', 0)]} />)
expect(screen.getByTestId('all-plugins-count')).toHaveTextContent('1')
})
it('should handle mixed dependency types', () => {
const mixedPlugins = [
createMockDependency('marketplace', 0),
createMockDependency('github', 1),
createMockDependency('package', 2),
]
render(<Install {...defaultProps} allPlugins={mixedPlugins} />)
expect(screen.getByTestId('all-plugins-count')).toHaveTextContent('3')
})
it('should handle failed installation', async () => {
mockInstallResponse = 'failed'
render(<Install {...defaultProps} />)
// Select all plugins
await act(async () => {
fireEvent.click(screen.getByTestId('select-all-plugins'))
})
// Click install
const installButton = screen.getByText(/plugin\.installModal\.install/i)
await act(async () => {
fireEvent.click(installButton)
})
// onInstalled should still be called with failure status
await waitFor(() => {
expect(defaultProps.onInstalled).toHaveBeenCalled()
})
// Reset for other tests
mockInstallResponse = 'success'
})
it('should handle running status and check task', async () => {
mockInstallResponse = 'running'
mockCheck.mockResolvedValue({ status: TaskStatus.success })
render(<Install {...defaultProps} />)
// Select all plugins
await act(async () => {
fireEvent.click(screen.getByTestId('select-all-plugins'))
})
// Click install
const installButton = screen.getByText(/plugin\.installModal\.install/i)
await act(async () => {
fireEvent.click(installButton)
})
await waitFor(() => {
expect(mockHandleRefetch).toHaveBeenCalled()
})
await waitFor(() => {
expect(mockCheck).toHaveBeenCalled()
})
// Reset for other tests
mockInstallResponse = 'success'
})
it('should handle mixed status (some success/failed, some running)', async () => {
// Override mock to return mixed statuses
const mixedMockInstallOrUpdate = vi.fn()
vi.doMock('@/service/use-plugins', () => ({
useInstallOrUpdate: (options: { onSuccess: (res: InstallStatusResponse[]) => void }) => {
mixedMockInstallOrUpdate.mockImplementation((_params: { payload: Dependency[] }) => {
// Return mixed statuses: first one is success, second is running
const mockResponse: InstallStatusResponse[] = [
{ status: TaskStatus.success, taskId: 'task-1', uniqueIdentifier: 'uid-1' },
{ status: TaskStatus.running, taskId: 'task-2', uniqueIdentifier: 'uid-2' },
]
options.onSuccess(mockResponse)
})
return {
mutate: mixedMockInstallOrUpdate,
isPending: false,
}
},
usePluginTaskList: () => ({
handleRefetch: mockHandleRefetch,
}),
}))
// The actual test logic would need to trigger this scenario
// For now, we verify the component renders correctly
render(<Install {...defaultProps} />)
expect(screen.getByTestId('install-multi')).toBeInTheDocument()
})
it('should not refresh plugin list when all installations fail', async () => {
mockInstallResponse = 'failed'
mockRefreshPluginList.mockClear()
render(<Install {...defaultProps} />)
// Select all plugins
await act(async () => {
fireEvent.click(screen.getByTestId('select-all-plugins'))
})
// Click install
const installButton = screen.getByText(/plugin\.installModal\.install/i)
await act(async () => {
fireEvent.click(installButton)
})
await waitFor(() => {
expect(defaultProps.onInstalled).toHaveBeenCalled()
})
// refreshPluginList should not be called when all fail
expect(mockRefreshPluginList).not.toHaveBeenCalled()
// Reset for other tests
mockInstallResponse = 'success'
})
})
// ==================== Selection State Management ====================
describe('Selection State Management', () => {
it('should set isSelectAll to false and isIndeterminate to false when all plugins are deselected', async () => {
render(<Install {...defaultProps} />)
// First select all
await act(async () => {
fireEvent.click(screen.getByTestId('select-all-plugins'))
})
// Then deselect using the mock button
await act(async () => {
fireEvent.click(screen.getByTestId('deselect-all-plugins'))
})
// Should show selectAll text (not deSelectAll)
await waitFor(() => {
expect(screen.getByText(/common\.operation\.selectAll/i)).toBeInTheDocument()
})
})
it('should set isIndeterminate to true when some but not all plugins are selected', async () => {
const threePlugins = [
createMockDependency('marketplace', 0),
createMockDependency('marketplace', 1),
createMockDependency('marketplace', 2),
]
render(<Install {...defaultProps} allPlugins={threePlugins} />)
// After loading, all 3 plugins are selected
await waitFor(() => {
expect(screen.getByTestId('selected-plugins-count')).toHaveTextContent('3')
})
// Deselect one plugin to get to indeterminate state (2 selected out of 3)
await act(async () => {
fireEvent.click(screen.getByTestId('toggle-plugin-0'))
})
// Component should be in indeterminate state (2 out of 3)
expect(screen.getByTestId('selected-plugins-count')).toHaveTextContent('2')
})
it('should toggle plugin selection correctly - deselect previously selected', async () => {
render(<Install {...defaultProps} />)
// After loading, all plugins (2) are selected via handleLoadedAllPlugin -> handleClickSelectAll
await waitFor(() => {
expect(screen.getByTestId('selected-plugins-count')).toHaveTextContent('2')
})
// Click toggle to deselect plugin 0 (toggle behavior)
await act(async () => {
fireEvent.click(screen.getByTestId('toggle-plugin-0'))
})
// Should have 1 selected now
expect(screen.getByTestId('selected-plugins-count')).toHaveTextContent('1')
})
it('should set isSelectAll true when selecting last remaining plugin', async () => {
const twoPlugins = [
createMockDependency('marketplace', 0),
createMockDependency('marketplace', 1),
]
render(<Install {...defaultProps} allPlugins={twoPlugins} />)
// After loading, all plugins are selected
await waitFor(() => {
expect(screen.getByTestId('selected-plugins-count')).toHaveTextContent('2')
})
// Should show deSelectAll since all are selected
await waitFor(() => {
expect(screen.getByText(/common\.operation\.deSelectAll/i)).toBeInTheDocument()
})
})
it('should handle selection when nextSelectedPlugins.length equals allPluginsLength', async () => {
const twoPlugins = [
createMockDependency('marketplace', 0),
createMockDependency('marketplace', 1),
]
render(<Install {...defaultProps} allPlugins={twoPlugins} />)
// After loading, all plugins are selected via handleLoadedAllPlugin -> handleClickSelectAll
// Wait for initial selection
await waitFor(() => {
expect(screen.getByTestId('selected-plugins-count')).toHaveTextContent('2')
})
// Both should be selected
expect(screen.getByTestId('selected-plugins-count')).toHaveTextContent('2')
})
it('should handle deselection to zero plugins', async () => {
render(<Install {...defaultProps} />)
// After loading, all plugins are selected via handleLoadedAllPlugin
await waitFor(() => {
expect(screen.getByTestId('selected-plugins-count')).toHaveTextContent('2')
})
// Use the deselect-all-plugins button to deselect all
await act(async () => {
fireEvent.click(screen.getByTestId('deselect-all-plugins'))
})
// Should have 0 selected
expect(screen.getByTestId('selected-plugins-count')).toHaveTextContent('0')
// Should show selectAll
await waitFor(() => {
expect(screen.getByText(/common\.operation\.selectAll/i)).toBeInTheDocument()
})
})
})
// ==================== Memoization Test ====================
describe('Memoization', () => {
it('should be memoized', async () => {
const InstallModule = await import('./install')
// memo returns an object with $$typeof
expect(typeof InstallModule.default).toBe('object')
})
})
})

View File

@ -1,502 +0,0 @@
import type { PluginDeclaration, PluginManifestInMarket } from '../types'
import { describe, expect, it, vi } from 'vitest'
import { PluginCategoryEnum } from '../types'
import {
convertRepoToUrl,
parseGitHubUrl,
pluginManifestInMarketToPluginProps,
pluginManifestToCardPluginProps,
} from './utils'
// Mock es-toolkit/compat
vi.mock('es-toolkit/compat', () => ({
isEmpty: (obj: unknown) => {
if (obj === null || obj === undefined)
return true
if (typeof obj === 'object')
return Object.keys(obj).length === 0
return false
},
}))
describe('pluginManifestToCardPluginProps', () => {
const createMockPluginDeclaration = (overrides?: Partial<PluginDeclaration>): PluginDeclaration => ({
plugin_unique_identifier: 'test-plugin-123',
version: '1.0.0',
author: 'test-author',
icon: '/test-icon.png',
name: 'test-plugin',
category: PluginCategoryEnum.tool,
label: { 'en-US': 'Test Plugin' } as Record<string, string>,
description: { 'en-US': 'Test description' } as Record<string, string>,
created_at: '2024-01-01',
resource: {},
plugins: {},
verified: true,
endpoint: { settings: [], endpoints: [] },
model: {},
tags: ['search', 'api'],
agent_strategy: {},
meta: { version: '1.0.0' },
trigger: {} as PluginDeclaration['trigger'],
...overrides,
})
describe('Basic Conversion', () => {
it('should convert plugin_unique_identifier to plugin_id', () => {
const manifest = createMockPluginDeclaration()
const result = pluginManifestToCardPluginProps(manifest)
expect(result.plugin_id).toBe('test-plugin-123')
})
it('should convert category to type', () => {
const manifest = createMockPluginDeclaration({ category: PluginCategoryEnum.model })
const result = pluginManifestToCardPluginProps(manifest)
expect(result.type).toBe(PluginCategoryEnum.model)
expect(result.category).toBe(PluginCategoryEnum.model)
})
it('should map author to org', () => {
const manifest = createMockPluginDeclaration({ author: 'my-org' })
const result = pluginManifestToCardPluginProps(manifest)
expect(result.org).toBe('my-org')
expect(result.author).toBe('my-org')
})
it('should map label correctly', () => {
const manifest = createMockPluginDeclaration({
label: { 'en-US': 'My Plugin', 'zh-Hans': '我的插件' } as Record<string, string>,
})
const result = pluginManifestToCardPluginProps(manifest)
expect(result.label).toEqual({ 'en-US': 'My Plugin', 'zh-Hans': '我的插件' })
})
it('should map description to brief and description', () => {
const manifest = createMockPluginDeclaration({
description: { 'en-US': 'Plugin description' } as Record<string, string>,
})
const result = pluginManifestToCardPluginProps(manifest)
expect(result.brief).toEqual({ 'en-US': 'Plugin description' })
expect(result.description).toEqual({ 'en-US': 'Plugin description' })
})
})
describe('Tags Conversion', () => {
it('should convert tags array to objects with name property', () => {
const manifest = createMockPluginDeclaration({
tags: ['search', 'image', 'api'],
})
const result = pluginManifestToCardPluginProps(manifest)
expect(result.tags).toEqual([
{ name: 'search' },
{ name: 'image' },
{ name: 'api' },
])
})
it('should handle empty tags array', () => {
const manifest = createMockPluginDeclaration({ tags: [] })
const result = pluginManifestToCardPluginProps(manifest)
expect(result.tags).toEqual([])
})
it('should handle single tag', () => {
const manifest = createMockPluginDeclaration({ tags: ['single'] })
const result = pluginManifestToCardPluginProps(manifest)
expect(result.tags).toEqual([{ name: 'single' }])
})
})
describe('Default Values', () => {
it('should set latest_version to empty string', () => {
const manifest = createMockPluginDeclaration()
const result = pluginManifestToCardPluginProps(manifest)
expect(result.latest_version).toBe('')
})
it('should set latest_package_identifier to empty string', () => {
const manifest = createMockPluginDeclaration()
const result = pluginManifestToCardPluginProps(manifest)
expect(result.latest_package_identifier).toBe('')
})
it('should set introduction to empty string', () => {
const manifest = createMockPluginDeclaration()
const result = pluginManifestToCardPluginProps(manifest)
expect(result.introduction).toBe('')
})
it('should set repository to empty string', () => {
const manifest = createMockPluginDeclaration()
const result = pluginManifestToCardPluginProps(manifest)
expect(result.repository).toBe('')
})
it('should set install_count to 0', () => {
const manifest = createMockPluginDeclaration()
const result = pluginManifestToCardPluginProps(manifest)
expect(result.install_count).toBe(0)
})
it('should set empty badges array', () => {
const manifest = createMockPluginDeclaration()
const result = pluginManifestToCardPluginProps(manifest)
expect(result.badges).toEqual([])
})
it('should set verification with langgenius category', () => {
const manifest = createMockPluginDeclaration()
const result = pluginManifestToCardPluginProps(manifest)
expect(result.verification).toEqual({ authorized_category: 'langgenius' })
})
it('should set from to package', () => {
const manifest = createMockPluginDeclaration()
const result = pluginManifestToCardPluginProps(manifest)
expect(result.from).toBe('package')
})
})
describe('Icon Handling', () => {
it('should map icon correctly', () => {
const manifest = createMockPluginDeclaration({ icon: '/custom-icon.png' })
const result = pluginManifestToCardPluginProps(manifest)
expect(result.icon).toBe('/custom-icon.png')
})
it('should map icon_dark when provided', () => {
const manifest = createMockPluginDeclaration({
icon: '/light-icon.png',
icon_dark: '/dark-icon.png',
})
const result = pluginManifestToCardPluginProps(manifest)
expect(result.icon).toBe('/light-icon.png')
expect(result.icon_dark).toBe('/dark-icon.png')
})
})
describe('Endpoint Settings', () => {
it('should set endpoint with empty settings array', () => {
const manifest = createMockPluginDeclaration()
const result = pluginManifestToCardPluginProps(manifest)
expect(result.endpoint).toEqual({ settings: [] })
})
})
})
describe('pluginManifestInMarketToPluginProps', () => {
const createMockPluginManifestInMarket = (overrides?: Partial<PluginManifestInMarket>): PluginManifestInMarket => ({
plugin_unique_identifier: 'market-plugin-123',
name: 'market-plugin',
org: 'market-org',
icon: '/market-icon.png',
label: { 'en-US': 'Market Plugin' } as Record<string, string>,
category: PluginCategoryEnum.tool,
version: '1.0.0',
latest_version: '1.2.0',
brief: { 'en-US': 'Market plugin description' } as Record<string, string>,
introduction: 'Full introduction text',
verified: true,
install_count: 5000,
badges: ['partner', 'verified'],
verification: { authorized_category: 'langgenius' },
from: 'marketplace',
...overrides,
})
describe('Basic Conversion', () => {
it('should convert plugin_unique_identifier to plugin_id', () => {
const manifest = createMockPluginManifestInMarket()
const result = pluginManifestInMarketToPluginProps(manifest)
expect(result.plugin_id).toBe('market-plugin-123')
})
it('should convert category to type', () => {
const manifest = createMockPluginManifestInMarket({ category: PluginCategoryEnum.model })
const result = pluginManifestInMarketToPluginProps(manifest)
expect(result.type).toBe(PluginCategoryEnum.model)
expect(result.category).toBe(PluginCategoryEnum.model)
})
it('should use latest_version for version', () => {
const manifest = createMockPluginManifestInMarket({
version: '1.0.0',
latest_version: '2.0.0',
})
const result = pluginManifestInMarketToPluginProps(manifest)
expect(result.version).toBe('2.0.0')
expect(result.latest_version).toBe('2.0.0')
})
it('should map org correctly', () => {
const manifest = createMockPluginManifestInMarket({ org: 'my-organization' })
const result = pluginManifestInMarketToPluginProps(manifest)
expect(result.org).toBe('my-organization')
})
})
describe('Brief and Description', () => {
it('should map brief to both brief and description', () => {
const manifest = createMockPluginManifestInMarket({
brief: { 'en-US': 'Brief description' } as Record<string, string>,
})
const result = pluginManifestInMarketToPluginProps(manifest)
expect(result.brief).toEqual({ 'en-US': 'Brief description' })
expect(result.description).toEqual({ 'en-US': 'Brief description' })
})
})
describe('Badges and Verification', () => {
it('should map badges array', () => {
const manifest = createMockPluginManifestInMarket({
badges: ['partner', 'premium'],
})
const result = pluginManifestInMarketToPluginProps(manifest)
expect(result.badges).toEqual(['partner', 'premium'])
})
it('should map verification when provided', () => {
const manifest = createMockPluginManifestInMarket({
verification: { authorized_category: 'partner' },
})
const result = pluginManifestInMarketToPluginProps(manifest)
expect(result.verification).toEqual({ authorized_category: 'partner' })
})
it('should use default verification when empty', () => {
const manifest = createMockPluginManifestInMarket({
verification: {} as PluginManifestInMarket['verification'],
})
const result = pluginManifestInMarketToPluginProps(manifest)
expect(result.verification).toEqual({ authorized_category: 'langgenius' })
})
})
describe('Default Values', () => {
it('should set verified to true', () => {
const manifest = createMockPluginManifestInMarket()
const result = pluginManifestInMarketToPluginProps(manifest)
expect(result.verified).toBe(true)
})
it('should set latest_package_identifier to empty string', () => {
const manifest = createMockPluginManifestInMarket()
const result = pluginManifestInMarketToPluginProps(manifest)
expect(result.latest_package_identifier).toBe('')
})
it('should set repository to empty string', () => {
const manifest = createMockPluginManifestInMarket()
const result = pluginManifestInMarketToPluginProps(manifest)
expect(result.repository).toBe('')
})
it('should set install_count to 0', () => {
const manifest = createMockPluginManifestInMarket()
const result = pluginManifestInMarketToPluginProps(manifest)
expect(result.install_count).toBe(0)
})
it('should set empty tags array', () => {
const manifest = createMockPluginManifestInMarket()
const result = pluginManifestInMarketToPluginProps(manifest)
expect(result.tags).toEqual([])
})
it('should set endpoint with empty settings', () => {
const manifest = createMockPluginManifestInMarket()
const result = pluginManifestInMarketToPluginProps(manifest)
expect(result.endpoint).toEqual({ settings: [] })
})
})
describe('From Property', () => {
it('should map from property correctly', () => {
const manifest = createMockPluginManifestInMarket({ from: 'marketplace' })
const result = pluginManifestInMarketToPluginProps(manifest)
expect(result.from).toBe('marketplace')
})
it('should handle github from type', () => {
const manifest = createMockPluginManifestInMarket({ from: 'github' })
const result = pluginManifestInMarketToPluginProps(manifest)
expect(result.from).toBe('github')
})
})
})
describe('parseGitHubUrl', () => {
describe('Valid URLs', () => {
it('should parse valid GitHub URL', () => {
const result = parseGitHubUrl('https://github.com/owner/repo')
expect(result.isValid).toBe(true)
expect(result.owner).toBe('owner')
expect(result.repo).toBe('repo')
})
it('should parse URL with trailing slash', () => {
const result = parseGitHubUrl('https://github.com/owner/repo/')
expect(result.isValid).toBe(true)
expect(result.owner).toBe('owner')
expect(result.repo).toBe('repo')
})
it('should handle hyphenated owner and repo names', () => {
const result = parseGitHubUrl('https://github.com/my-org/my-repo')
expect(result.isValid).toBe(true)
expect(result.owner).toBe('my-org')
expect(result.repo).toBe('my-repo')
})
it('should handle underscored names', () => {
const result = parseGitHubUrl('https://github.com/my_org/my_repo')
expect(result.isValid).toBe(true)
expect(result.owner).toBe('my_org')
expect(result.repo).toBe('my_repo')
})
it('should handle numeric characters in names', () => {
const result = parseGitHubUrl('https://github.com/org123/repo456')
expect(result.isValid).toBe(true)
expect(result.owner).toBe('org123')
expect(result.repo).toBe('repo456')
})
})
describe('Invalid URLs', () => {
it('should return invalid for non-GitHub URL', () => {
const result = parseGitHubUrl('https://gitlab.com/owner/repo')
expect(result.isValid).toBe(false)
expect(result.owner).toBeUndefined()
expect(result.repo).toBeUndefined()
})
it('should return invalid for URL with extra path segments', () => {
const result = parseGitHubUrl('https://github.com/owner/repo/tree/main')
expect(result.isValid).toBe(false)
})
it('should return invalid for URL without repo', () => {
const result = parseGitHubUrl('https://github.com/owner')
expect(result.isValid).toBe(false)
})
it('should return invalid for empty string', () => {
const result = parseGitHubUrl('')
expect(result.isValid).toBe(false)
})
it('should return invalid for malformed URL', () => {
const result = parseGitHubUrl('not-a-url')
expect(result.isValid).toBe(false)
})
it('should return invalid for http URL', () => {
// Testing invalid http protocol - construct URL dynamically to avoid lint error
const httpUrl = `${'http'}://github.com/owner/repo`
const result = parseGitHubUrl(httpUrl)
expect(result.isValid).toBe(false)
})
it('should return invalid for URL with www', () => {
const result = parseGitHubUrl('https://www.github.com/owner/repo')
expect(result.isValid).toBe(false)
})
})
})
describe('convertRepoToUrl', () => {
describe('Valid Repos', () => {
it('should convert repo to GitHub URL', () => {
const result = convertRepoToUrl('owner/repo')
expect(result).toBe('https://github.com/owner/repo')
})
it('should handle hyphenated names', () => {
const result = convertRepoToUrl('my-org/my-repo')
expect(result).toBe('https://github.com/my-org/my-repo')
})
it('should handle complex repo strings', () => {
const result = convertRepoToUrl('organization_name/repository-name')
expect(result).toBe('https://github.com/organization_name/repository-name')
})
})
describe('Edge Cases', () => {
it('should return empty string for empty repo', () => {
const result = convertRepoToUrl('')
expect(result).toBe('')
})
it('should return empty string for undefined-like values', () => {
// TypeScript would normally prevent this, but testing runtime behavior
const result = convertRepoToUrl(undefined as unknown as string)
expect(result).toBe('')
})
it('should return empty string for null-like values', () => {
const result = convertRepoToUrl(null as unknown as string)
expect(result).toBe('')
})
it('should handle repo with special characters', () => {
const result = convertRepoToUrl('org/repo.js')
expect(result).toBe('https://github.com/org/repo.js')
})
})
})

View File

@ -1,837 +0,0 @@
import type { Credential } from '../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CredentialTypeEnum } from '../types'
import Item from './item'
// ==================== Test Utilities ====================
const createCredential = (overrides: Partial<Credential> = {}): Credential => ({
id: 'test-credential-id',
name: 'Test Credential',
provider: 'test-provider',
credential_type: CredentialTypeEnum.API_KEY,
is_default: false,
credentials: { api_key: 'test-key' },
...overrides,
})
// ==================== Item Component Tests ====================
describe('Item Component', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// ==================== Rendering Tests ====================
describe('Rendering', () => {
it('should render credential name', () => {
const credential = createCredential({ name: 'My API Key' })
render(<Item credential={credential} />)
expect(screen.getByText('My API Key')).toBeInTheDocument()
})
it('should render default badge when is_default is true', () => {
const credential = createCredential({ is_default: true })
render(<Item credential={credential} />)
expect(screen.getByText('plugin.auth.default')).toBeInTheDocument()
})
it('should not render default badge when is_default is false', () => {
const credential = createCredential({ is_default: false })
render(<Item credential={credential} />)
expect(screen.queryByText('plugin.auth.default')).not.toBeInTheDocument()
})
it('should render enterprise badge when from_enterprise is true', () => {
const credential = createCredential({ from_enterprise: true })
render(<Item credential={credential} />)
expect(screen.getByText('Enterprise')).toBeInTheDocument()
})
it('should not render enterprise badge when from_enterprise is false', () => {
const credential = createCredential({ from_enterprise: false })
render(<Item credential={credential} />)
expect(screen.queryByText('Enterprise')).not.toBeInTheDocument()
})
it('should render selected icon when showSelectedIcon is true and credential is selected', () => {
const credential = createCredential({ id: 'selected-id' })
render(
<Item
credential={credential}
showSelectedIcon={true}
selectedCredentialId="selected-id"
/>,
)
// RiCheckLine should be rendered
expect(document.querySelector('.text-text-accent')).toBeInTheDocument()
})
it('should not render selected icon when credential is not selected', () => {
const credential = createCredential({ id: 'not-selected-id' })
render(
<Item
credential={credential}
showSelectedIcon={true}
selectedCredentialId="other-id"
/>,
)
// Check icon should not be visible
expect(document.querySelector('.text-text-accent')).not.toBeInTheDocument()
})
it('should render with gray indicator when not_allowed_to_use is true', () => {
const credential = createCredential({ not_allowed_to_use: true })
const { container } = render(<Item credential={credential} />)
// The item should have tooltip wrapper with data-state attribute for unavailable credential
const tooltipTrigger = container.querySelector('[data-state]')
expect(tooltipTrigger).toBeInTheDocument()
// The item should have disabled styles
expect(container.querySelector('.cursor-not-allowed')).toBeInTheDocument()
})
it('should apply disabled styles when disabled is true', () => {
const credential = createCredential()
const { container } = render(<Item credential={credential} disabled={true} />)
const itemDiv = container.querySelector('.cursor-not-allowed')
expect(itemDiv).toBeInTheDocument()
})
it('should apply disabled styles when not_allowed_to_use is true', () => {
const credential = createCredential({ not_allowed_to_use: true })
const { container } = render(<Item credential={credential} />)
const itemDiv = container.querySelector('.cursor-not-allowed')
expect(itemDiv).toBeInTheDocument()
})
})
// ==================== Click Interaction Tests ====================
describe('Click Interactions', () => {
it('should call onItemClick with credential id when clicked', () => {
const onItemClick = vi.fn()
const credential = createCredential({ id: 'click-test-id' })
const { container } = render(
<Item credential={credential} onItemClick={onItemClick} />,
)
const itemDiv = container.querySelector('.group')
fireEvent.click(itemDiv!)
expect(onItemClick).toHaveBeenCalledWith('click-test-id')
})
it('should call onItemClick with empty string for workspace default credential', () => {
const onItemClick = vi.fn()
const credential = createCredential({ id: '__workspace_default__' })
const { container } = render(
<Item credential={credential} onItemClick={onItemClick} />,
)
const itemDiv = container.querySelector('.group')
fireEvent.click(itemDiv!)
expect(onItemClick).toHaveBeenCalledWith('')
})
it('should not call onItemClick when disabled', () => {
const onItemClick = vi.fn()
const credential = createCredential()
const { container } = render(
<Item credential={credential} onItemClick={onItemClick} disabled={true} />,
)
const itemDiv = container.querySelector('.group')
fireEvent.click(itemDiv!)
expect(onItemClick).not.toHaveBeenCalled()
})
it('should not call onItemClick when not_allowed_to_use is true', () => {
const onItemClick = vi.fn()
const credential = createCredential({ not_allowed_to_use: true })
const { container } = render(
<Item credential={credential} onItemClick={onItemClick} />,
)
const itemDiv = container.querySelector('.group')
fireEvent.click(itemDiv!)
expect(onItemClick).not.toHaveBeenCalled()
})
})
// ==================== Rename Mode Tests ====================
describe('Rename Mode', () => {
it('should enter rename mode when rename button is clicked', () => {
const credential = createCredential()
const { container } = render(
<Item
credential={credential}
disableRename={false}
disableEdit={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
// Since buttons are hidden initially, we need to find the ActionButton
// In the actual implementation, they are rendered but hidden
const actionButtons = container.querySelectorAll('button')
const renameBtn = Array.from(actionButtons).find(btn =>
btn.querySelector('.ri-edit-line') || btn.innerHTML.includes('RiEditLine'),
)
if (renameBtn) {
fireEvent.click(renameBtn)
// Should show input for rename
expect(screen.getByRole('textbox')).toBeInTheDocument()
}
})
it('should show save and cancel buttons in rename mode', () => {
const onRename = vi.fn()
const credential = createCredential({ name: 'Original Name' })
const { container } = render(
<Item
credential={credential}
onRename={onRename}
disableRename={false}
disableEdit={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
// Find and click rename button to enter rename mode
const actionButtons = container.querySelectorAll('button')
// Find the rename action button by looking for RiEditLine icon
actionButtons.forEach((btn) => {
if (btn.querySelector('svg')) {
fireEvent.click(btn)
}
})
// If we're in rename mode, there should be save/cancel buttons
const buttons = screen.queryAllByRole('button')
if (buttons.length >= 2) {
expect(screen.getByText('common.operation.save')).toBeInTheDocument()
expect(screen.getByText('common.operation.cancel')).toBeInTheDocument()
}
})
it('should call onRename with new name when save is clicked', () => {
const onRename = vi.fn()
const credential = createCredential({ id: 'rename-test-id', name: 'Original' })
const { container } = render(
<Item
credential={credential}
onRename={onRename}
disableRename={false}
disableEdit={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
// Trigger rename mode by clicking the rename button
const editIcon = container.querySelector('svg.ri-edit-line')
if (editIcon) {
fireEvent.click(editIcon.closest('button')!)
// Now in rename mode, change input and save
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'New Name' } })
// Click save
const saveButton = screen.getByText('common.operation.save')
fireEvent.click(saveButton)
expect(onRename).toHaveBeenCalledWith({
credential_id: 'rename-test-id',
name: 'New Name',
})
}
})
it('should call onRename and exit rename mode when save button is clicked', () => {
const onRename = vi.fn()
const credential = createCredential({ id: 'rename-save-test', name: 'Original Name' })
const { container } = render(
<Item
credential={credential}
onRename={onRename}
disableRename={false}
disableEdit={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
// Find and click rename button to enter rename mode
// The button contains RiEditLine svg
const allButtons = Array.from(container.querySelectorAll('button'))
let renameButton: Element | null = null
for (const btn of allButtons) {
if (btn.querySelector('svg')) {
renameButton = btn
break
}
}
if (renameButton) {
fireEvent.click(renameButton)
// Should be in rename mode now
const input = screen.queryByRole('textbox')
if (input) {
expect(input).toHaveValue('Original Name')
// Change the value
fireEvent.change(input, { target: { value: 'Updated Name' } })
expect(input).toHaveValue('Updated Name')
// Click save button
const saveButton = screen.getByText('common.operation.save')
fireEvent.click(saveButton)
// Verify onRename was called with correct parameters
expect(onRename).toHaveBeenCalledTimes(1)
expect(onRename).toHaveBeenCalledWith({
credential_id: 'rename-save-test',
name: 'Updated Name',
})
// Should exit rename mode - input should be gone
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
}
}
})
it('should exit rename mode when cancel is clicked', () => {
const credential = createCredential({ name: 'Original' })
const { container } = render(
<Item
credential={credential}
disableRename={false}
disableEdit={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
// Enter rename mode
const editIcon = container.querySelector('svg')?.closest('button')
if (editIcon) {
fireEvent.click(editIcon)
// If in rename mode, cancel button should exist
const cancelButton = screen.queryByText('common.operation.cancel')
if (cancelButton) {
fireEvent.click(cancelButton)
// Should exit rename mode - input should be gone
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
}
}
})
it('should update rename value when input changes', () => {
const credential = createCredential({ name: 'Original' })
const { container } = render(
<Item
credential={credential}
disableRename={false}
disableEdit={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
// We need to get into rename mode first
// The rename button appears on hover in the actions area
const allButtons = container.querySelectorAll('button')
if (allButtons.length > 0) {
fireEvent.click(allButtons[0])
const input = screen.queryByRole('textbox')
if (input) {
fireEvent.change(input, { target: { value: 'Updated Value' } })
expect(input).toHaveValue('Updated Value')
}
}
})
it('should stop propagation when clicking input in rename mode', () => {
const onItemClick = vi.fn()
const credential = createCredential()
const { container } = render(
<Item
credential={credential}
onItemClick={onItemClick}
disableRename={false}
disableEdit={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
// Enter rename mode and click on input
const allButtons = container.querySelectorAll('button')
if (allButtons.length > 0) {
fireEvent.click(allButtons[0])
const input = screen.queryByRole('textbox')
if (input) {
fireEvent.click(input)
// onItemClick should not be called when clicking the input
expect(onItemClick).not.toHaveBeenCalled()
}
}
})
})
// ==================== Action Button Tests ====================
describe('Action Buttons', () => {
it('should call onSetDefault when set default button is clicked', () => {
const onSetDefault = vi.fn()
const credential = createCredential({ is_default: false })
render(
<Item
credential={credential}
onSetDefault={onSetDefault}
disableSetDefault={false}
disableRename={true}
disableEdit={true}
disableDelete={true}
/>,
)
// Find set default button
const setDefaultButton = screen.queryByText('plugin.auth.setDefault')
if (setDefaultButton) {
fireEvent.click(setDefaultButton)
expect(onSetDefault).toHaveBeenCalledWith('test-credential-id')
}
})
it('should not show set default button when credential is already default', () => {
const onSetDefault = vi.fn()
const credential = createCredential({ is_default: true })
render(
<Item
credential={credential}
onSetDefault={onSetDefault}
disableSetDefault={false}
disableRename={true}
disableEdit={true}
disableDelete={true}
/>,
)
expect(screen.queryByText('plugin.auth.setDefault')).not.toBeInTheDocument()
})
it('should not show set default button when disableSetDefault is true', () => {
const onSetDefault = vi.fn()
const credential = createCredential({ is_default: false })
render(
<Item
credential={credential}
onSetDefault={onSetDefault}
disableSetDefault={true}
disableRename={true}
disableEdit={true}
disableDelete={true}
/>,
)
expect(screen.queryByText('plugin.auth.setDefault')).not.toBeInTheDocument()
})
it('should not show set default button when not_allowed_to_use is true', () => {
const credential = createCredential({ is_default: false, not_allowed_to_use: true })
render(
<Item
credential={credential}
disableSetDefault={false}
disableRename={true}
disableEdit={true}
disableDelete={true}
/>,
)
expect(screen.queryByText('plugin.auth.setDefault')).not.toBeInTheDocument()
})
it('should call onEdit with credential id and values when edit button is clicked', () => {
const onEdit = vi.fn()
const credential = createCredential({
id: 'edit-test-id',
name: 'Edit Test',
credential_type: CredentialTypeEnum.API_KEY,
credentials: { api_key: 'secret' },
})
const { container } = render(
<Item
credential={credential}
onEdit={onEdit}
disableEdit={false}
disableRename={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
// Find the edit button (RiEqualizer2Line icon)
const editButton = container.querySelector('svg')?.closest('button')
if (editButton) {
fireEvent.click(editButton)
expect(onEdit).toHaveBeenCalledWith('edit-test-id', {
api_key: 'secret',
__name__: 'Edit Test',
__credential_id__: 'edit-test-id',
})
}
})
it('should not show edit button for OAuth credentials', () => {
const onEdit = vi.fn()
const credential = createCredential({ credential_type: CredentialTypeEnum.OAUTH2 })
render(
<Item
credential={credential}
onEdit={onEdit}
disableEdit={false}
disableRename={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
// Edit button should not appear for OAuth
const editTooltip = screen.queryByText('common.operation.edit')
expect(editTooltip).not.toBeInTheDocument()
})
it('should not show edit button when from_enterprise is true', () => {
const onEdit = vi.fn()
const credential = createCredential({ from_enterprise: true })
render(
<Item
credential={credential}
onEdit={onEdit}
disableEdit={false}
disableRename={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
// Edit button should not appear for enterprise credentials
const editTooltip = screen.queryByText('common.operation.edit')
expect(editTooltip).not.toBeInTheDocument()
})
it('should call onDelete when delete button is clicked', () => {
const onDelete = vi.fn()
const credential = createCredential({ id: 'delete-test-id' })
const { container } = render(
<Item
credential={credential}
onDelete={onDelete}
disableDelete={false}
disableRename={true}
disableEdit={true}
disableSetDefault={true}
/>,
)
// Find delete button (RiDeleteBinLine icon)
const deleteButton = container.querySelector('svg')?.closest('button')
if (deleteButton) {
fireEvent.click(deleteButton)
expect(onDelete).toHaveBeenCalledWith('delete-test-id')
}
})
it('should not show delete button when disableDelete is true', () => {
const onDelete = vi.fn()
const credential = createCredential()
render(
<Item
credential={credential}
onDelete={onDelete}
disableDelete={true}
disableRename={true}
disableEdit={true}
disableSetDefault={true}
/>,
)
// Delete tooltip should not be present
expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
})
it('should not show delete button for enterprise credentials', () => {
const onDelete = vi.fn()
const credential = createCredential({ from_enterprise: true })
render(
<Item
credential={credential}
onDelete={onDelete}
disableDelete={false}
disableRename={true}
disableEdit={true}
disableSetDefault={true}
/>,
)
// Delete tooltip should not be present for enterprise
expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
})
it('should not show rename button for enterprise credentials', () => {
const onRename = vi.fn()
const credential = createCredential({ from_enterprise: true })
render(
<Item
credential={credential}
onRename={onRename}
disableRename={false}
disableEdit={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
// Rename tooltip should not be present for enterprise
expect(screen.queryByText('common.operation.rename')).not.toBeInTheDocument()
})
it('should not show rename button when not_allowed_to_use is true', () => {
const onRename = vi.fn()
const credential = createCredential({ not_allowed_to_use: true })
render(
<Item
credential={credential}
onRename={onRename}
disableRename={false}
disableEdit={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
// Rename tooltip should not be present when not allowed to use
expect(screen.queryByText('common.operation.rename')).not.toBeInTheDocument()
})
it('should not show edit button when not_allowed_to_use is true', () => {
const onEdit = vi.fn()
const credential = createCredential({ not_allowed_to_use: true })
render(
<Item
credential={credential}
onEdit={onEdit}
disableEdit={false}
disableRename={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
// Edit tooltip should not be present when not allowed to use
expect(screen.queryByText('common.operation.edit')).not.toBeInTheDocument()
})
it('should stop propagation when clicking action buttons', () => {
const onItemClick = vi.fn()
const onDelete = vi.fn()
const credential = createCredential()
const { container } = render(
<Item
credential={credential}
onItemClick={onItemClick}
onDelete={onDelete}
disableDelete={false}
disableRename={true}
disableEdit={true}
disableSetDefault={true}
/>,
)
// Find delete button and click
const deleteButton = container.querySelector('svg')?.closest('button')
if (deleteButton) {
fireEvent.click(deleteButton)
// onDelete should be called but not onItemClick (due to stopPropagation)
expect(onDelete).toHaveBeenCalled()
// Note: onItemClick might still be called due to event bubbling in test environment
}
})
it('should disable action buttons when disabled prop is true', () => {
const onSetDefault = vi.fn()
const credential = createCredential({ is_default: false })
render(
<Item
credential={credential}
onSetDefault={onSetDefault}
disabled={true}
disableSetDefault={false}
disableRename={true}
disableEdit={true}
disableDelete={true}
/>,
)
// Set default button should be disabled
const setDefaultButton = screen.queryByText('plugin.auth.setDefault')
if (setDefaultButton) {
const button = setDefaultButton.closest('button')
expect(button).toBeDisabled()
}
})
})
// ==================== showAction Logic Tests ====================
describe('Show Action Logic', () => {
it('should not show action area when all actions are disabled', () => {
const credential = createCredential()
const { container } = render(
<Item
credential={credential}
disableRename={true}
disableEdit={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
// Should not have action area with hover:flex
const actionArea = container.querySelector('.group-hover\\:flex')
expect(actionArea).not.toBeInTheDocument()
})
it('should show action area when at least one action is enabled', () => {
const credential = createCredential()
const { container } = render(
<Item
credential={credential}
disableRename={false}
disableEdit={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
// Should have action area
const actionArea = container.querySelector('.group-hover\\:flex')
expect(actionArea).toBeInTheDocument()
})
})
// ==================== Edge Cases ====================
describe('Edge Cases', () => {
it('should handle credential with empty name', () => {
const credential = createCredential({ name: '' })
render(<Item credential={credential} />)
// Should render without crashing
expect(document.querySelector('.group')).toBeInTheDocument()
})
it('should handle credential with undefined credentials object', () => {
const credential = createCredential({ credentials: undefined })
render(
<Item
credential={credential}
disableEdit={false}
disableRename={true}
disableDelete={true}
disableSetDefault={true}
/>,
)
// Should render without crashing
expect(document.querySelector('.group')).toBeInTheDocument()
})
it('should handle all optional callbacks being undefined', () => {
const credential = createCredential()
expect(() => {
render(<Item credential={credential} />)
}).not.toThrow()
})
it('should properly display long credential names with truncation', () => {
const longName = 'A'.repeat(100)
const credential = createCredential({ name: longName })
const { container } = render(<Item credential={credential} />)
const nameElement = container.querySelector('.truncate')
expect(nameElement).toBeInTheDocument()
expect(nameElement?.getAttribute('title')).toBe(longName)
})
})
// ==================== Memoization Test ====================
describe('Memoization', () => {
it('should be memoized', async () => {
const ItemModule = await import('./item')
// memo returns an object with $$typeof
expect(typeof ItemModule.default).toBe('object')
})
})
})

View File

@ -23,8 +23,8 @@ const PAGE_SIZE = 20
type Props = {
value?: {
app_id: string
inputs: Record<string, unknown>
files?: unknown[]
inputs: Record<string, any>
files?: any[]
}
scope?: string
disabled?: boolean
@ -32,8 +32,8 @@ type Props = {
offset?: OffsetOptions
onSelect: (app: {
app_id: string
inputs: Record<string, unknown>
files?: unknown[]
inputs: Record<string, any>
files?: any[]
}) => void
supportAddCustomTool?: boolean
}
@ -63,12 +63,12 @@ const AppSelector: FC<Props> = ({
name: searchText,
})
const pages = data?.pages ?? []
const displayedApps = useMemo(() => {
const pages = data?.pages ?? []
if (!pages.length)
return []
return pages.flatMap(({ data: apps }) => apps)
}, [data?.pages])
}, [pages])
// fetch selected app by id to avoid pagination gaps
const { data: selectedAppDetail } = useAppDetail(value?.app_id || '')
@ -130,7 +130,7 @@ const AppSelector: FC<Props> = ({
setIsShowChooseApp(false)
}
const handleFormChange = (inputs: Record<string, unknown>) => {
const handleFormChange = (inputs: Record<string, any>) => {
const newFiles = inputs['#image#']
delete inputs['#image#']
const newValue = {

View File

@ -1,8 +0,0 @@
export { default as ReasoningConfigForm } from './reasoning-config-form'
export { default as SchemaModal } from './schema-modal'
export { default as ToolAuthorizationSection } from './tool-authorization-section'
export { default as ToolBaseForm } from './tool-base-form'
export { default as ToolCredentialsForm } from './tool-credentials-form'
export { default as ToolItem } from './tool-item'
export { default as ToolSettingsPanel } from './tool-settings-panel'
export { default as ToolTrigger } from './tool-trigger'

View File

@ -1,48 +0,0 @@
'use client'
import type { FC } from 'react'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import Divider from '@/app/components/base/divider'
import {
AuthCategory,
PluginAuthInAgent,
} from '@/app/components/plugins/plugin-auth'
import { CollectionType } from '@/app/components/tools/types'
type ToolAuthorizationSectionProps = {
currentProvider?: ToolWithProvider
credentialId?: string
onAuthorizationItemClick: (id: string) => void
}
const ToolAuthorizationSection: FC<ToolAuthorizationSectionProps> = ({
currentProvider,
credentialId,
onAuthorizationItemClick,
}) => {
// Only show for built-in providers that allow deletion
const shouldShow = currentProvider
&& currentProvider.type === CollectionType.builtIn
&& currentProvider.allow_delete
if (!shouldShow)
return null
return (
<>
<Divider className="my-1 w-full" />
<div className="px-4 py-2">
<PluginAuthInAgent
pluginPayload={{
provider: currentProvider.name,
category: AuthCategory.tool,
providerType: currentProvider.type,
}}
credentialId={credentialId}
onAuthorizationItemClick={onAuthorizationItemClick}
/>
</div>
</>
)
}
export default ToolAuthorizationSection

View File

@ -1,98 +0,0 @@
'use client'
import type { OffsetOptions } from '@floating-ui/react'
import type { FC } from 'react'
import type { PluginDetail } from '@/app/components/plugins/types'
import type { ToolDefaultValue, ToolValue } from '@/app/components/workflow/block-selector/types'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { useTranslation } from 'react-i18next'
import Textarea from '@/app/components/base/textarea'
import ToolPicker from '@/app/components/workflow/block-selector/tool-picker'
import { ReadmeEntrance } from '../../../readme-panel/entrance'
import ToolTrigger from './tool-trigger'
type ToolBaseFormProps = {
value?: ToolValue
currentProvider?: ToolWithProvider
offset?: OffsetOptions
scope?: string
selectedTools?: ToolValue[]
isShowChooseTool: boolean
panelShowState?: boolean
hasTrigger: boolean
onShowChange: (show: boolean) => void
onPanelShowStateChange?: (state: boolean) => void
onSelectTool: (tool: ToolDefaultValue) => void
onSelectMultipleTool: (tools: ToolDefaultValue[]) => void
onDescriptionChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
}
const ToolBaseForm: FC<ToolBaseFormProps> = ({
value,
currentProvider,
offset = 4,
scope,
selectedTools,
isShowChooseTool,
panelShowState,
hasTrigger,
onShowChange,
onPanelShowStateChange,
onSelectTool,
onSelectMultipleTool,
onDescriptionChange,
}) => {
const { t } = useTranslation()
return (
<div className="flex flex-col gap-3 px-4 py-2">
{/* Tool picker */}
<div className="flex flex-col gap-1">
<div className="system-sm-semibold flex h-6 items-center justify-between text-text-secondary">
{t('detailPanel.toolSelector.toolLabel', { ns: 'plugin' })}
{currentProvider?.plugin_unique_identifier && (
<ReadmeEntrance
pluginDetail={currentProvider as unknown as PluginDetail}
showShortTip
className="pb-0"
/>
)}
</div>
<ToolPicker
placement="bottom"
offset={offset}
trigger={(
<ToolTrigger
open={panelShowState || isShowChooseTool}
value={value}
provider={currentProvider}
/>
)}
isShow={panelShowState || isShowChooseTool}
onShowChange={hasTrigger ? (onPanelShowStateChange || (() => {})) : onShowChange}
disabled={false}
supportAddCustomTool
onSelect={onSelectTool}
onSelectMultiple={onSelectMultipleTool}
scope={scope}
selectedTools={selectedTools}
/>
</div>
{/* Description */}
<div className="flex flex-col gap-1">
<div className="system-sm-semibold flex h-6 items-center text-text-secondary">
{t('detailPanel.toolSelector.descriptionLabel', { ns: 'plugin' })}
</div>
<Textarea
className="resize-none"
placeholder={t('detailPanel.toolSelector.descriptionPlaceholder', { ns: 'plugin' })}
value={value?.extra?.description || ''}
onChange={onDescriptionChange}
disabled={!value?.provider_name}
/>
</div>
</div>
)
}
export default ToolBaseForm

View File

@ -1,157 +0,0 @@
'use client'
import type { FC } from 'react'
import type { Node } from 'reactflow'
import type { TabType } from '../hooks/use-tool-selector-state'
import type { ReasoningConfigValue } from './reasoning-config-form'
import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { ToolFormSchema } from '@/app/components/tools/utils/to-form-schema'
import type { ToolValue } from '@/app/components/workflow/block-selector/types'
import type { ToolVarInputs } from '@/app/components/workflow/nodes/tool/types'
import type { NodeOutPutVar, ToolWithProvider } from '@/app/components/workflow/types'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import TabSlider from '@/app/components/base/tab-slider-plain'
import ToolForm from '@/app/components/workflow/nodes/tool/components/tool-form'
import ReasoningConfigForm from './reasoning-config-form'
type ToolSettingsPanelProps = {
value?: ToolValue
currentProvider?: ToolWithProvider
nodeId: string
currType: TabType
settingsFormSchemas: ToolFormSchema[]
paramsFormSchemas: ToolFormSchema[]
settingsValue: ToolVarInputs
showTabSlider: boolean
userSettingsOnly: boolean
reasoningConfigOnly: boolean
nodeOutputVars: NodeOutPutVar[]
availableNodes: Node[]
onCurrTypeChange: (type: TabType) => void
onSettingsFormChange: (v: ToolVarInputs) => void
onParamsFormChange: (v: ReasoningConfigValue) => void
}
/**
* Renders the settings/params tips section
*/
const ParamsTips: FC = () => {
const { t } = useTranslation()
return (
<div className="pb-1">
<div className="system-xs-regular text-text-tertiary">
{t('detailPanel.toolSelector.paramsTip1', { ns: 'plugin' })}
</div>
<div className="system-xs-regular text-text-tertiary">
{t('detailPanel.toolSelector.paramsTip2', { ns: 'plugin' })}
</div>
</div>
)
}
const ToolSettingsPanel: FC<ToolSettingsPanelProps> = ({
value,
currentProvider,
nodeId,
currType,
settingsFormSchemas,
paramsFormSchemas,
settingsValue,
showTabSlider,
userSettingsOnly,
reasoningConfigOnly,
nodeOutputVars,
availableNodes,
onCurrTypeChange,
onSettingsFormChange,
onParamsFormChange,
}) => {
const { t } = useTranslation()
// Check if panel should be shown
const hasSettings = settingsFormSchemas.length > 0
const hasParams = paramsFormSchemas.length > 0
const isTeamAuthorized = currentProvider?.is_team_authorization
if ((!hasSettings && !hasParams) || !isTeamAuthorized)
return null
return (
<>
<Divider className="my-1 w-full" />
{/* Tab slider - shown only when both settings and params exist */}
{nodeId && showTabSlider && (
<TabSlider
className="mt-1 shrink-0 px-4"
itemClassName="py-3"
noBorderBottom
smallItem
value={currType}
onChange={(v) => {
if (v === 'settings' || v === 'params')
onCurrTypeChange(v)
}}
options={[
{ value: 'settings', text: t('detailPanel.toolSelector.settings', { ns: 'plugin' })! },
{ value: 'params', text: t('detailPanel.toolSelector.params', { ns: 'plugin' })! },
]}
/>
)}
{/* Params tips when tab slider and params tab is active */}
{nodeId && showTabSlider && currType === 'params' && (
<div className="px-4 py-2">
<ParamsTips />
</div>
)}
{/* User settings only header */}
{userSettingsOnly && (
<div className="p-4 pb-1">
<div className="system-sm-semibold-uppercase text-text-primary">
{t('detailPanel.toolSelector.settings', { ns: 'plugin' })}
</div>
</div>
)}
{/* Reasoning config only header */}
{nodeId && reasoningConfigOnly && (
<div className="mb-1 p-4 pb-1">
<div className="system-sm-semibold-uppercase text-text-primary">
{t('detailPanel.toolSelector.params', { ns: 'plugin' })}
</div>
<ParamsTips />
</div>
)}
{/* User settings form */}
{(currType === 'settings' || userSettingsOnly) && (
<div className="px-4 py-2">
<ToolForm
inPanel
readOnly={false}
nodeId={nodeId}
schema={settingsFormSchemas as CredentialFormSchema[]}
value={settingsValue}
onChange={onSettingsFormChange}
/>
</div>
)}
{/* Reasoning config form */}
{nodeId && (currType === 'params' || reasoningConfigOnly) && (
<ReasoningConfigForm
value={(value?.parameters || {}) as ReasoningConfigValue}
onChange={onParamsFormChange}
schemas={paramsFormSchemas}
nodeOutputVars={nodeOutputVars}
availableNodes={availableNodes}
nodeId={nodeId}
/>
)}
</>
)
}
export default ToolSettingsPanel

View File

@ -10,6 +10,5 @@ export const usePluginInstalledCheck = (providerName = '') => {
return {
inMarketPlace: !!manifest,
manifest: manifest?.data.plugin,
pluginID,
}
}

View File

@ -1,3 +0,0 @@
export { usePluginInstalledCheck } from './use-plugin-installed-check'
export { useToolSelectorState } from './use-tool-selector-state'
export type { TabType, ToolSelectorState, UseToolSelectorStateProps } from './use-tool-selector-state'

View File

@ -1,250 +0,0 @@
'use client'
import type { ReasoningConfigValue } from '../components/reasoning-config-form'
import type { ToolParameter } from '@/app/components/tools/types'
import type { ToolDefaultValue, ToolValue } from '@/app/components/workflow/block-selector/types'
import type { ResourceVarInputs } from '@/app/components/workflow/nodes/_base/types'
import { useCallback, useMemo, useState } from 'react'
import { generateFormValue, getPlainValue, getStructureValue, toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
import { useInvalidateInstalledPluginList } from '@/service/use-plugins'
import {
useAllBuiltInTools,
useAllCustomTools,
useAllMCPTools,
useAllWorkflowTools,
useInvalidateAllBuiltInTools,
} from '@/service/use-tools'
import { getIconFromMarketPlace } from '@/utils/get-icon'
import { usePluginInstalledCheck } from './use-plugin-installed-check'
export type TabType = 'settings' | 'params'
export type UseToolSelectorStateProps = {
value?: ToolValue
onSelect: (tool: ToolValue) => void
onSelectMultiple?: (tool: ToolValue[]) => void
}
/**
* Custom hook for managing tool selector state and computed values.
* Consolidates state management, data fetching, and event handlers.
*/
export const useToolSelectorState = ({
value,
onSelect,
onSelectMultiple,
}: UseToolSelectorStateProps) => {
// Panel visibility states
const [isShow, setIsShow] = useState(false)
const [isShowChooseTool, setIsShowChooseTool] = useState(false)
const [currType, setCurrType] = useState<TabType>('settings')
// Fetch all tools data
const { data: buildInTools } = useAllBuiltInTools()
const { data: customTools } = useAllCustomTools()
const { data: workflowTools } = useAllWorkflowTools()
const { data: mcpTools } = useAllMCPTools()
const invalidateAllBuiltinTools = useInvalidateAllBuiltInTools()
const invalidateInstalledPluginList = useInvalidateInstalledPluginList()
// Plugin info check
const { inMarketPlace, manifest, pluginID } = usePluginInstalledCheck(value?.provider_name)
// Merge all tools and find current provider
const currentProvider = useMemo(() => {
const mergedTools = [
...(buildInTools || []),
...(customTools || []),
...(workflowTools || []),
...(mcpTools || []),
]
return mergedTools.find(toolWithProvider => toolWithProvider.id === value?.provider_name)
}, [value, buildInTools, customTools, workflowTools, mcpTools])
// Current tool from provider
const currentTool = useMemo(() => {
return currentProvider?.tools.find(tool => tool.name === value?.tool_name)
}, [currentProvider?.tools, value?.tool_name])
// Tool settings and params
const currentToolSettings = useMemo(() => {
if (!currentProvider)
return []
return currentProvider.tools
.find(tool => tool.name === value?.tool_name)
?.parameters
.filter(param => param.form !== 'llm') || []
}, [currentProvider, value])
const currentToolParams = useMemo(() => {
if (!currentProvider)
return []
return currentProvider.tools
.find(tool => tool.name === value?.tool_name)
?.parameters
.filter(param => param.form === 'llm') || []
}, [currentProvider, value])
// Form schemas
const settingsFormSchemas = useMemo(
() => toolParametersToFormSchemas(currentToolSettings),
[currentToolSettings],
)
const paramsFormSchemas = useMemo(
() => toolParametersToFormSchemas(currentToolParams),
[currentToolParams],
)
// Tab visibility flags
const showTabSlider = currentToolSettings.length > 0 && currentToolParams.length > 0
const userSettingsOnly = currentToolSettings.length > 0 && !currentToolParams.length
const reasoningConfigOnly = currentToolParams.length > 0 && !currentToolSettings.length
// Manifest icon URL
const manifestIcon = useMemo(() => {
if (!manifest || !pluginID)
return ''
return getIconFromMarketPlace(pluginID)
}, [manifest, pluginID])
// Convert tool default value to tool value format
const getToolValue = useCallback((tool: ToolDefaultValue): ToolValue => {
const settingValues = generateFormValue(
tool.params,
toolParametersToFormSchemas((tool.paramSchemas as ToolParameter[]).filter(param => param.form !== 'llm')),
)
const paramValues = generateFormValue(
tool.params,
toolParametersToFormSchemas((tool.paramSchemas as ToolParameter[]).filter(param => param.form === 'llm')),
true,
)
return {
provider_name: tool.provider_id,
provider_show_name: tool.provider_name,
tool_name: tool.tool_name,
tool_label: tool.tool_label,
tool_description: tool.tool_description,
settings: settingValues,
parameters: paramValues,
enabled: tool.is_team_authorization,
extra: {
description: tool.tool_description,
},
}
}, [])
// Event handlers
const handleSelectTool = useCallback((tool: ToolDefaultValue) => {
const toolValue = getToolValue(tool)
onSelect(toolValue)
}, [getToolValue, onSelect])
const handleSelectMultipleTool = useCallback((tools: ToolDefaultValue[]) => {
const toolValues = tools.map(item => getToolValue(item))
onSelectMultiple?.(toolValues)
}, [getToolValue, onSelectMultiple])
const handleDescriptionChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
if (!value)
return
onSelect({
...value,
extra: {
...value.extra,
description: e.target.value || '',
},
})
}, [value, onSelect])
const handleSettingsFormChange = useCallback((v: ResourceVarInputs) => {
if (!value)
return
const newValue = getStructureValue(v)
onSelect({
...value,
settings: newValue,
})
}, [value, onSelect])
const handleParamsFormChange = useCallback((v: ReasoningConfigValue) => {
if (!value)
return
onSelect({
...value,
parameters: v,
})
}, [value, onSelect])
const handleEnabledChange = useCallback((state: boolean) => {
if (!value)
return
onSelect({
...value,
enabled: state,
})
}, [value, onSelect])
const handleAuthorizationItemClick = useCallback((id: string) => {
if (!value)
return
onSelect({
...value,
credential_id: id,
})
}, [value, onSelect])
const handleInstall = useCallback(async () => {
try {
await invalidateAllBuiltinTools()
}
catch (error) {
console.error('Failed to invalidate built-in tools cache', error)
}
try {
await invalidateInstalledPluginList()
}
catch (error) {
console.error('Failed to invalidate installed plugin list cache', error)
}
}, [invalidateAllBuiltinTools, invalidateInstalledPluginList])
const getSettingsValue = useCallback((): ResourceVarInputs => {
return getPlainValue((value?.settings || {}) as Record<string, { value: unknown }>) as ResourceVarInputs
}, [value?.settings])
return {
// State
isShow,
setIsShow,
isShowChooseTool,
setIsShowChooseTool,
currType,
setCurrType,
// Computed values
currentProvider,
currentTool,
currentToolSettings,
currentToolParams,
settingsFormSchemas,
paramsFormSchemas,
showTabSlider,
userSettingsOnly,
reasoningConfigOnly,
manifestIcon,
inMarketPlace,
manifest,
// Event handlers
handleSelectTool,
handleSelectMultipleTool,
handleDescriptionChange,
handleSettingsFormChange,
handleParamsFormChange,
handleEnabledChange,
handleAuthorizationItemClick,
handleInstall,
getSettingsValue,
}
}
export type ToolSelectorState = ReturnType<typeof useToolSelectorState>

View File

@ -5,26 +5,43 @@ import type {
} from '@floating-ui/react'
import type { FC } from 'react'
import type { Node } from 'reactflow'
import type { ToolValue } from '@/app/components/workflow/block-selector/types'
import type { ToolDefaultValue, ToolValue } from '@/app/components/workflow/block-selector/types'
import type { NodeOutPutVar } from '@/app/components/workflow/types'
import Link from 'next/link'
import * as React from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { CollectionType } from '@/app/components/tools/types'
import { cn } from '@/utils/classnames'
import TabSlider from '@/app/components/base/tab-slider-plain'
import Textarea from '@/app/components/base/textarea'
import {
ToolAuthorizationSection,
ToolBaseForm,
ToolItem,
ToolSettingsPanel,
ToolTrigger,
} from './components'
import { useToolSelectorState } from './hooks/use-tool-selector-state'
AuthCategory,
PluginAuthInAgent,
} from '@/app/components/plugins/plugin-auth'
import { usePluginInstalledCheck } from '@/app/components/plugins/plugin-detail-panel/tool-selector/hooks'
import ReasoningConfigForm from '@/app/components/plugins/plugin-detail-panel/tool-selector/reasoning-config-form'
import ToolItem from '@/app/components/plugins/plugin-detail-panel/tool-selector/tool-item'
import ToolTrigger from '@/app/components/plugins/plugin-detail-panel/tool-selector/tool-trigger'
import { CollectionType } from '@/app/components/tools/types'
import { generateFormValue, getPlainValue, getStructureValue, toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
import ToolPicker from '@/app/components/workflow/block-selector/tool-picker'
import ToolForm from '@/app/components/workflow/nodes/tool/components/tool-form'
import { MARKETPLACE_API_PREFIX } from '@/config'
import { useInvalidateInstalledPluginList } from '@/service/use-plugins'
import {
useAllBuiltInTools,
useAllCustomTools,
useAllMCPTools,
useAllWorkflowTools,
useInvalidateAllBuiltInTools,
} from '@/service/use-tools'
import { cn } from '@/utils/classnames'
import { ReadmeEntrance } from '../../readme-panel/entrance'
type Props = {
disabled?: boolean
@ -48,7 +65,6 @@ type Props = {
availableNodes: Node[]
nodeId?: string
}
const ToolSelector: FC<Props> = ({
value,
selectedTools,
@ -71,177 +87,321 @@ const ToolSelector: FC<Props> = ({
nodeId = '',
}) => {
const { t } = useTranslation()
// Use custom hook for state management
const state = useToolSelectorState({ value, onSelect, onSelectMultiple })
const {
isShow,
setIsShow,
isShowChooseTool,
setIsShowChooseTool,
currType,
setCurrType,
currentProvider,
currentTool,
settingsFormSchemas,
paramsFormSchemas,
showTabSlider,
userSettingsOnly,
reasoningConfigOnly,
manifestIcon,
inMarketPlace,
manifest,
handleSelectTool,
handleSelectMultipleTool,
handleDescriptionChange,
handleSettingsFormChange,
handleParamsFormChange,
handleEnabledChange,
handleAuthorizationItemClick,
handleInstall,
getSettingsValue,
} = state
const [isShow, onShowChange] = useState(false)
const handleTriggerClick = () => {
if (disabled)
return
setIsShow(true)
onShowChange(true)
}
// Determine portal open state based on controlled vs uncontrolled mode
const portalOpen = trigger ? controlledState : isShow
const onPortalOpenChange = trigger ? onControlledStateChange : setIsShow
const { data: buildInTools } = useAllBuiltInTools()
const { data: customTools } = useAllCustomTools()
const { data: workflowTools } = useAllWorkflowTools()
const { data: mcpTools } = useAllMCPTools()
const invalidateAllBuiltinTools = useInvalidateAllBuiltInTools()
const invalidateInstalledPluginList = useInvalidateInstalledPluginList()
// Build error tooltip content
const renderErrorTip = () => (
<div className="max-w-[240px] space-y-1 text-xs">
<h3 className="font-semibold text-text-primary">
{currentTool
? t('detailPanel.toolSelector.uninstalledTitle', { ns: 'plugin' })
: t('detailPanel.toolSelector.unsupportedTitle', { ns: 'plugin' })}
</h3>
<p className="tracking-tight text-text-secondary">
{currentTool
? t('detailPanel.toolSelector.uninstalledContent', { ns: 'plugin' })
: t('detailPanel.toolSelector.unsupportedContent', { ns: 'plugin' })}
</p>
<p>
<Link href="/plugins" className="tracking-tight text-text-accent">
{t('detailPanel.toolSelector.uninstalledLink', { ns: 'plugin' })}
</Link>
</p>
</div>
)
// plugin info check
const { inMarketPlace, manifest } = usePluginInstalledCheck(value?.provider_name)
const currentProvider = useMemo(() => {
const mergedTools = [...(buildInTools || []), ...(customTools || []), ...(workflowTools || []), ...(mcpTools || [])]
return mergedTools.find((toolWithProvider) => {
return toolWithProvider.id === value?.provider_name
})
}, [value, buildInTools, customTools, workflowTools, mcpTools])
const [isShowChooseTool, setIsShowChooseTool] = useState(false)
const getToolValue = (tool: ToolDefaultValue) => {
const settingValues = generateFormValue(tool.params, toolParametersToFormSchemas(tool.paramSchemas.filter(param => param.form !== 'llm') as any))
const paramValues = generateFormValue(tool.params, toolParametersToFormSchemas(tool.paramSchemas.filter(param => param.form === 'llm') as any), true)
return {
provider_name: tool.provider_id,
provider_show_name: tool.provider_name,
type: tool.provider_type,
tool_name: tool.tool_name,
tool_label: tool.tool_label,
tool_description: tool.tool_description,
settings: settingValues,
parameters: paramValues,
enabled: tool.is_team_authorization,
extra: {
description: tool.tool_description,
},
schemas: tool.paramSchemas,
}
}
const handleSelectTool = (tool: ToolDefaultValue) => {
const toolValue = getToolValue(tool)
onSelect(toolValue)
// setIsShowChooseTool(false)
}
const handleSelectMultipleTool = (tool: ToolDefaultValue[]) => {
const toolValues = tool.map(item => getToolValue(item))
onSelectMultiple?.(toolValues)
}
const handleDescriptionChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onSelect({
...value,
extra: {
...value?.extra,
description: e.target.value || '',
},
} as any)
}
// tool settings & params
const currentToolSettings = useMemo(() => {
if (!currentProvider)
return []
return currentProvider.tools.find(tool => tool.name === value?.tool_name)?.parameters.filter(param => param.form !== 'llm') || []
}, [currentProvider, value])
const currentToolParams = useMemo(() => {
if (!currentProvider)
return []
return currentProvider.tools.find(tool => tool.name === value?.tool_name)?.parameters.filter(param => param.form === 'llm') || []
}, [currentProvider, value])
const [currType, setCurrType] = useState('settings')
const showTabSlider = currentToolSettings.length > 0 && currentToolParams.length > 0
const userSettingsOnly = currentToolSettings.length > 0 && !currentToolParams.length
const reasoningConfigOnly = currentToolParams.length > 0 && !currentToolSettings.length
const settingsFormSchemas = useMemo(() => toolParametersToFormSchemas(currentToolSettings), [currentToolSettings])
const paramsFormSchemas = useMemo(() => toolParametersToFormSchemas(currentToolParams), [currentToolParams])
const handleSettingsFormChange = (v: Record<string, any>) => {
const newValue = getStructureValue(v)
const toolValue = {
...value,
settings: newValue,
}
onSelect(toolValue as any)
}
const handleParamsFormChange = (v: Record<string, any>) => {
const toolValue = {
...value,
parameters: v,
}
onSelect(toolValue as any)
}
const handleEnabledChange = (state: boolean) => {
onSelect({
...value,
enabled: state,
} as any)
}
// install from marketplace
const currentTool = useMemo(() => {
return currentProvider?.tools.find(tool => tool.name === value?.tool_name)
}, [currentProvider?.tools, value?.tool_name])
const manifestIcon = useMemo(() => {
if (!manifest)
return ''
return `${MARKETPLACE_API_PREFIX}/plugins/${(manifest as any).plugin_id}/icon`
}, [manifest])
const handleInstall = async () => {
invalidateAllBuiltinTools()
invalidateInstalledPluginList()
}
const handleAuthorizationItemClick = (id: string) => {
onSelect({
...value,
credential_id: id,
} as any)
}
return (
<PortalToFollowElem
placement={placement}
offset={offset}
open={portalOpen}
onOpenChange={onPortalOpenChange}
>
<PortalToFollowElemTrigger
className="w-full"
onClick={() => {
if (!currentProvider || !currentTool)
return
handleTriggerClick()
}}
<>
<PortalToFollowElem
placement={placement}
offset={offset}
open={trigger ? controlledState : isShow}
onOpenChange={trigger ? onControlledStateChange : onShowChange}
>
{trigger}
{/* Default trigger - no value */}
{!trigger && !value?.provider_name && (
<ToolTrigger
isConfigure
open={isShow}
value={value}
provider={currentProvider}
/>
)}
{/* Default trigger - with value */}
{!trigger && value?.provider_name && (
<ToolItem
open={isShow}
icon={currentProvider?.icon || manifestIcon}
isMCPTool={currentProvider?.type === CollectionType.mcp}
providerName={value.provider_name}
providerShowName={value.provider_show_name}
toolLabel={value.tool_label || value.tool_name}
showSwitch={supportEnableSwitch}
switchValue={value.enabled}
onSwitchChange={handleEnabledChange}
onDelete={onDelete}
noAuth={currentProvider && currentTool && !currentProvider.is_team_authorization}
uninstalled={!currentProvider && inMarketPlace}
versionMismatch={currentProvider && inMarketPlace && !currentTool}
installInfo={manifest?.latest_package_identifier}
onInstall={handleInstall}
isError={(!currentProvider || !currentTool) && !inMarketPlace}
errorTip={renderErrorTip()}
/>
)}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-10">
<div className={cn(
'relative max-h-[642px] min-h-20 w-[361px] rounded-xl',
'border-[0.5px] border-components-panel-border bg-components-panel-bg-blur',
'overflow-y-auto pb-2 pb-4 shadow-lg backdrop-blur-sm',
)}
<PortalToFollowElemTrigger
className="w-full"
onClick={() => {
if (!currentProvider || !currentTool)
return
handleTriggerClick()
}}
>
{/* Header */}
<div className="system-xl-semibold px-4 pb-1 pt-3.5 text-text-primary">
{t(`detailPanel.toolSelector.${isEdit ? 'toolSetting' : 'title'}`, { ns: 'plugin' })}
{trigger}
{!trigger && !value?.provider_name && (
<ToolTrigger
isConfigure
open={isShow}
value={value}
provider={currentProvider}
/>
)}
{!trigger && value?.provider_name && (
<ToolItem
open={isShow}
icon={currentProvider?.icon || manifestIcon}
isMCPTool={currentProvider?.type === CollectionType.mcp}
providerName={value.provider_name}
providerShowName={value.provider_show_name}
toolLabel={value.tool_label || value.tool_name}
showSwitch={supportEnableSwitch}
switchValue={value.enabled}
onSwitchChange={handleEnabledChange}
onDelete={onDelete}
noAuth={currentProvider && currentTool && !currentProvider.is_team_authorization}
uninstalled={!currentProvider && inMarketPlace}
versionMismatch={currentProvider && inMarketPlace && !currentTool}
installInfo={manifest?.latest_package_identifier}
onInstall={() => handleInstall()}
isError={(!currentProvider || !currentTool) && !inMarketPlace}
errorTip={(
<div className="max-w-[240px] space-y-1 text-xs">
<h3 className="font-semibold text-text-primary">{currentTool ? t('detailPanel.toolSelector.uninstalledTitle', { ns: 'plugin' }) : t('detailPanel.toolSelector.unsupportedTitle', { ns: 'plugin' })}</h3>
<p className="tracking-tight text-text-secondary">{currentTool ? t('detailPanel.toolSelector.uninstalledContent', { ns: 'plugin' }) : t('detailPanel.toolSelector.unsupportedContent', { ns: 'plugin' })}</p>
<p>
<Link href="/plugins" className="tracking-tight text-text-accent">{t('detailPanel.toolSelector.uninstalledLink', { ns: 'plugin' })}</Link>
</p>
</div>
)}
/>
)}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-10">
<div className={cn('relative max-h-[642px] min-h-20 w-[361px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur pb-4 shadow-lg backdrop-blur-sm', 'overflow-y-auto pb-2')}>
<>
<div className="system-xl-semibold px-4 pb-1 pt-3.5 text-text-primary">{t(`detailPanel.toolSelector.${isEdit ? 'toolSetting' : 'title'}`, { ns: 'plugin' })}</div>
{/* base form */}
<div className="flex flex-col gap-3 px-4 py-2">
<div className="flex flex-col gap-1">
<div className="system-sm-semibold flex h-6 items-center justify-between text-text-secondary">
{t('detailPanel.toolSelector.toolLabel', { ns: 'plugin' })}
<ReadmeEntrance pluginDetail={currentProvider as any} showShortTip className="pb-0" />
</div>
<ToolPicker
placement="bottom"
offset={offset}
trigger={(
<ToolTrigger
open={panelShowState || isShowChooseTool}
value={value}
provider={currentProvider}
/>
)}
isShow={panelShowState || isShowChooseTool}
onShowChange={trigger ? onPanelShowStateChange as any : setIsShowChooseTool}
disabled={false}
supportAddCustomTool
onSelect={handleSelectTool}
onSelectMultiple={handleSelectMultipleTool}
scope={scope}
selectedTools={selectedTools}
/>
</div>
<div className="flex flex-col gap-1">
<div className="system-sm-semibold flex h-6 items-center text-text-secondary">{t('detailPanel.toolSelector.descriptionLabel', { ns: 'plugin' })}</div>
<Textarea
className="resize-none"
placeholder={t('detailPanel.toolSelector.descriptionPlaceholder', { ns: 'plugin' })}
value={value?.extra?.description || ''}
onChange={handleDescriptionChange}
disabled={!value?.provider_name}
/>
</div>
</div>
{/* authorization */}
{currentProvider && currentProvider.type === CollectionType.builtIn && currentProvider.allow_delete && (
<>
<Divider className="my-1 w-full" />
<div className="px-4 py-2">
<PluginAuthInAgent
pluginPayload={{
provider: currentProvider.name,
category: AuthCategory.tool,
providerType: currentProvider.type,
detail: currentProvider as any,
}}
credentialId={value?.credential_id}
onAuthorizationItemClick={handleAuthorizationItemClick}
/>
</div>
</>
)}
{/* tool settings */}
{(currentToolSettings.length > 0 || currentToolParams.length > 0) && currentProvider?.is_team_authorization && (
<>
<Divider className="my-1 w-full" />
{/* tabs */}
{nodeId && showTabSlider && (
<TabSlider
className="mt-1 shrink-0 px-4"
itemClassName="py-3"
noBorderBottom
smallItem
value={currType}
onChange={(value) => {
setCurrType(value)
}}
options={[
{ value: 'settings', text: t('detailPanel.toolSelector.settings', { ns: 'plugin' })! },
{ value: 'params', text: t('detailPanel.toolSelector.params', { ns: 'plugin' })! },
]}
/>
)}
{nodeId && showTabSlider && currType === 'params' && (
<div className="px-4 py-2">
<div className="system-xs-regular text-text-tertiary">{t('detailPanel.toolSelector.paramsTip1', { ns: 'plugin' })}</div>
<div className="system-xs-regular text-text-tertiary">{t('detailPanel.toolSelector.paramsTip2', { ns: 'plugin' })}</div>
</div>
)}
{/* user settings only */}
{userSettingsOnly && (
<div className="p-4 pb-1">
<div className="system-sm-semibold-uppercase text-text-primary">{t('detailPanel.toolSelector.settings', { ns: 'plugin' })}</div>
</div>
)}
{/* reasoning config only */}
{nodeId && reasoningConfigOnly && (
<div className="mb-1 p-4 pb-1">
<div className="system-sm-semibold-uppercase text-text-primary">{t('detailPanel.toolSelector.params', { ns: 'plugin' })}</div>
<div className="pb-1">
<div className="system-xs-regular text-text-tertiary">{t('detailPanel.toolSelector.paramsTip1', { ns: 'plugin' })}</div>
<div className="system-xs-regular text-text-tertiary">{t('detailPanel.toolSelector.paramsTip2', { ns: 'plugin' })}</div>
</div>
</div>
)}
{/* user settings form */}
{(currType === 'settings' || userSettingsOnly) && (
<div className="px-4 py-2">
<ToolForm
inPanel
readOnly={false}
nodeId={nodeId}
schema={settingsFormSchemas as any}
value={getPlainValue(value?.settings || {})}
onChange={handleSettingsFormChange}
/>
</div>
)}
{/* reasoning config form */}
{nodeId && (currType === 'params' || reasoningConfigOnly) && (
<ReasoningConfigForm
value={value?.parameters || {}}
onChange={handleParamsFormChange}
schemas={paramsFormSchemas as any}
nodeOutputVars={nodeOutputVars}
availableNodes={availableNodes}
nodeId={nodeId}
/>
)}
</>
)}
</>
</div>
{/* Base form: tool picker + description */}
<ToolBaseForm
value={value}
currentProvider={currentProvider}
offset={offset}
scope={scope}
selectedTools={selectedTools}
isShowChooseTool={isShowChooseTool}
panelShowState={panelShowState}
hasTrigger={!!trigger}
onShowChange={setIsShowChooseTool}
onPanelShowStateChange={onPanelShowStateChange}
onSelectTool={handleSelectTool}
onSelectMultipleTool={handleSelectMultipleTool}
onDescriptionChange={handleDescriptionChange}
/>
{/* Authorization section */}
<ToolAuthorizationSection
currentProvider={currentProvider}
credentialId={value?.credential_id}
onAuthorizationItemClick={handleAuthorizationItemClick}
/>
{/* Settings panel */}
<ToolSettingsPanel
value={value}
currentProvider={currentProvider}
nodeId={nodeId}
currType={currType}
settingsFormSchemas={settingsFormSchemas}
paramsFormSchemas={paramsFormSchemas}
settingsValue={getSettingsValue()}
showTabSlider={showTabSlider}
userSettingsOnly={userSettingsOnly}
reasoningConfigOnly={reasoningConfigOnly}
nodeOutputVars={nodeOutputVars}
availableNodes={availableNodes}
onCurrTypeChange={setCurrType}
onSettingsFormChange={handleSettingsFormChange}
onParamsFormChange={handleParamsFormChange}
/>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PortalToFollowElemContent>
</PortalToFollowElem>
</>
)
}
export default React.memo(ToolSelector)

View File

@ -1,12 +1,9 @@
import type { Node } from 'reactflow'
import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { ToolFormSchema } from '@/app/components/tools/utils/to-form-schema'
import type { SchemaRoot } from '@/app/components/workflow/nodes/llm/types'
import type { ToolVarInputs } from '@/app/components/workflow/nodes/tool/types'
import type {
NodeOutPutVar,
ValueSelector,
Var,
} from '@/app/components/workflow/types'
import {
RiArrowRightUpLine,
@ -35,22 +32,10 @@ import { VarType } from '@/app/components/workflow/types'
import { cn } from '@/utils/classnames'
import SchemaModal from './schema-modal'
type ReasoningConfigInputValue = {
type?: VarKindType
value?: unknown
} | null
type ReasoningConfigInput = {
value: ReasoningConfigInputValue
auto?: 0 | 1
}
export type ReasoningConfigValue = Record<string, ReasoningConfigInput>
type Props = {
value: ReasoningConfigValue
onChange: (val: ReasoningConfigValue) => void
schemas: ToolFormSchema[]
value: Record<string, any>
onChange: (val: Record<string, any>) => void
schemas: any[]
nodeOutputVars: NodeOutPutVar[]
availableNodes: Node[]
nodeId: string
@ -66,7 +51,7 @@ const ReasoningConfigForm: React.FC<Props> = ({
}) => {
const { t } = useTranslation()
const language = useLanguage()
const getVarKindType = (type: string) => {
const getVarKindType = (type: FormTypeEnum) => {
if (type === FormTypeEnum.file || type === FormTypeEnum.files)
return VarKindType.variable
if (type === FormTypeEnum.select || type === FormTypeEnum.checkbox || type === FormTypeEnum.textNumber || type === FormTypeEnum.array || type === FormTypeEnum.object)
@ -75,7 +60,7 @@ const ReasoningConfigForm: React.FC<Props> = ({
return VarKindType.mixed
}
const handleAutomatic = (key: string, val: boolean, type: string) => {
const handleAutomatic = (key: string, val: any, type: FormTypeEnum) => {
onChange({
...value,
[key]: {
@ -84,7 +69,7 @@ const ReasoningConfigForm: React.FC<Props> = ({
},
})
}
const handleTypeChange = useCallback((variable: string, defaultValue: unknown) => {
const handleTypeChange = useCallback((variable: string, defaultValue: any) => {
return (newType: VarKindType) => {
const res = produce(value, (draft: ToolVarInputs) => {
draft[variable].value = {
@ -95,8 +80,8 @@ const ReasoningConfigForm: React.FC<Props> = ({
onChange(res)
}
}, [onChange, value])
const handleValueChange = useCallback((variable: string, varType: string) => {
return (newValue: unknown) => {
const handleValueChange = useCallback((variable: string, varType: FormTypeEnum) => {
return (newValue: any) => {
const res = produce(value, (draft: ToolVarInputs) => {
draft[variable].value = {
type: getVarKindType(varType),
@ -109,23 +94,22 @@ const ReasoningConfigForm: React.FC<Props> = ({
const handleAppChange = useCallback((variable: string) => {
return (app: {
app_id: string
inputs: Record<string, unknown>
files?: unknown[]
inputs: Record<string, any>
files?: any[]
}) => {
const newValue = produce(value, (draft: ToolVarInputs) => {
draft[variable].value = app
draft[variable].value = app as any
})
onChange(newValue)
}
}, [onChange, value])
const handleModelChange = useCallback((variable: string) => {
return (model: Record<string, unknown>) => {
return (model: any) => {
const newValue = produce(value, (draft: ToolVarInputs) => {
const currentValue = draft[variable].value as Record<string, unknown> | undefined
draft[variable].value = {
...currentValue,
...draft[variable].value,
...model,
}
} as any
})
onChange(newValue)
}
@ -150,7 +134,7 @@ const ReasoningConfigForm: React.FC<Props> = ({
const [schema, setSchema] = useState<SchemaRoot | null>(null)
const [schemaRootName, setSchemaRootName] = useState<string>('')
const renderField = (schema: ToolFormSchema, showSchema: (schema: SchemaRoot, rootName: string) => void) => {
const renderField = (schema: any, showSchema: (schema: SchemaRoot, rootName: string) => void) => {
const {
default: defaultValue,
variable,
@ -210,17 +194,17 @@ const ReasoningConfigForm: React.FC<Props> = ({
}
const getFilterVar = () => {
if (isNumber)
return (varPayload: Var) => varPayload.type === VarType.number
return (varPayload: any) => varPayload.type === VarType.number
else if (isString)
return (varPayload: Var) => [VarType.string, VarType.number, VarType.secret].includes(varPayload.type)
return (varPayload: any) => [VarType.string, VarType.number, VarType.secret].includes(varPayload.type)
else if (isFile)
return (varPayload: Var) => [VarType.file, VarType.arrayFile].includes(varPayload.type)
return (varPayload: any) => [VarType.file, VarType.arrayFile].includes(varPayload.type)
else if (isBoolean)
return (varPayload: Var) => varPayload.type === VarType.boolean
return (varPayload: any) => varPayload.type === VarType.boolean
else if (isObject)
return (varPayload: Var) => varPayload.type === VarType.object
return (varPayload: any) => varPayload.type === VarType.object
else if (isArray)
return (varPayload: Var) => [VarType.array, VarType.arrayString, VarType.arrayNumber, VarType.arrayObject].includes(varPayload.type)
return (varPayload: any) => [VarType.array, VarType.arrayString, VarType.arrayNumber, VarType.arrayObject].includes(varPayload.type)
return undefined
}
@ -280,7 +264,7 @@ const ReasoningConfigForm: React.FC<Props> = ({
<Input
className="h-8 grow"
type="number"
value={(varInput?.value as string | number) || ''}
value={varInput?.value || ''}
onChange={e => handleValueChange(variable, type)(e.target.value)}
placeholder={placeholder?.[language] || placeholder?.en_US}
/>
@ -291,16 +275,16 @@ const ReasoningConfigForm: React.FC<Props> = ({
onChange={handleValueChange(variable, type)}
/>
)}
{isSelect && options && (
{isSelect && (
<SimpleSelect
wrapperClassName="h-8 grow"
defaultValue={varInput?.value as string | number | undefined}
items={options.filter((option) => {
defaultValue={varInput?.value}
items={options.filter((option: { show_on: any[] }) => {
if (option.show_on.length)
return option.show_on.every(showOnItem => value[showOnItem.variable]?.value?.value === showOnItem.value)
return option.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value)
return true
}).map(option => ({ value: option.value, name: option.label[language] || option.label.en_US }))}
}).map((option: { value: any, label: { [x: string]: any, en_US: any } }) => ({ value: option.value, name: option.label[language] || option.label.en_US }))}
onSelect={item => handleValueChange(variable, type)(item.value as string)}
placeholder={placeholder?.[language] || placeholder?.en_US}
/>
@ -309,7 +293,7 @@ const ReasoningConfigForm: React.FC<Props> = ({
<div className="mt-1 w-full">
<CodeEditor
title="JSON"
value={varInput?.value as string}
value={varInput?.value as any}
isExpand
isInNode
height={100}
@ -324,7 +308,7 @@ const ReasoningConfigForm: React.FC<Props> = ({
<AppSelector
disabled={false}
scope={scope || 'all'}
value={varInput as { app_id: string, inputs: Record<string, unknown>, files?: unknown[] } | undefined}
value={varInput as any}
onSelect={handleAppChange(variable)}
/>
)}
@ -345,10 +329,10 @@ const ReasoningConfigForm: React.FC<Props> = ({
readonly={false}
isShowNodeName
nodeId={nodeId}
value={(varInput?.value as string | ValueSelector) || []}
value={varInput?.value || []}
onChange={handleVariableSelectorChange(variable)}
filterVar={getFilterVar()}
schema={schema as Partial<CredentialFormSchema>}
schema={schema}
valueTypePlaceHolder={targetVarType()}
/>
)}

View File

@ -1,7 +1,6 @@
'use client'
import type { FC } from 'react'
import type { Collection } from '@/app/components/tools/types'
import type { ToolCredentialFormSchema } from '@/app/components/tools/utils/to-form-schema'
import {
RiArrowRightUpLine,
} from '@remixicon/react'
@ -20,7 +19,7 @@ import { cn } from '@/utils/classnames'
type Props = {
collection: Collection
onCancel: () => void
onSaved: (value: Record<string, unknown>) => void
onSaved: (value: Record<string, any>) => void
}
const ToolCredentialForm: FC<Props> = ({
@ -30,9 +29,9 @@ const ToolCredentialForm: FC<Props> = ({
}) => {
const getValueFromI18nObject = useRenderI18nObject()
const { t } = useTranslation()
const [credentialSchema, setCredentialSchema] = useState<ToolCredentialFormSchema[] | null>(null)
const [credentialSchema, setCredentialSchema] = useState<any>(null)
const { name: collectionName } = collection
const [tempCredential, setTempCredential] = React.useState<Record<string, unknown>>({})
const [tempCredential, setTempCredential] = React.useState<any>({})
useEffect(() => {
fetchBuiltInToolCredentialSchema(collectionName).then(async (res) => {
const toolCredentialSchemas = toolCredentialToFormSchemas(res)
@ -45,8 +44,6 @@ const ToolCredentialForm: FC<Props> = ({
}, [])
const handleSave = () => {
if (!credentialSchema)
return
for (const field of credentialSchema) {
if (field.required && !tempCredential[field.name]) {
Toast.notify({ type: 'error', message: t('errorMsg.fieldRequired', { ns: 'common', field: getValueFromI18nObject(field.label) }) })

View File

@ -22,7 +22,7 @@ import { SwitchPluginVersion } from '@/app/components/workflow/nodes/_base/compo
import { cn } from '@/utils/classnames'
type Props = {
icon?: string | { content?: string, background?: string }
icon?: any
providerName?: string
isMCPTool?: boolean
providerShowName?: string
@ -33,7 +33,7 @@ type Props = {
onDelete?: () => void
noAuth?: boolean
isError?: boolean
errorTip?: React.ReactNode
errorTip?: any
uninstalled?: boolean
installInfo?: string
onInstall?: () => void

View File

@ -19,9 +19,8 @@ vi.mock('@/service/use-plugins', () => ({
}))
// Mock useLanguage hook
let mockLanguage = 'en-US'
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useLanguage: () => mockLanguage,
useLanguage: () => 'en-US',
}))
// Mock DetailHeader component (complex component with many dependencies)
@ -694,23 +693,6 @@ describe('ReadmePanel', () => {
expect(currentPluginDetail).toBeDefined()
})
})
it('should not close panel when content area is clicked in modal mode', async () => {
const mockDetail = createMockPluginDetail()
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.modal)
renderWithQueryClient(<ReadmePanel />)
// Click on the content container in modal mode (should stop propagation)
const contentContainer = document.querySelector('.pointer-events-auto')
fireEvent.click(contentContainer!)
await waitFor(() => {
const { currentPluginDetail } = useReadmePanelStore.getState()
expect(currentPluginDetail).toBeDefined()
})
})
})
// ================================
@ -733,25 +715,20 @@ describe('ReadmePanel', () => {
})
it('should pass undefined language for zh-Hans locale', () => {
// Set language to zh-Hans
mockLanguage = 'zh-Hans'
// Re-mock useLanguage to return zh-Hans
vi.doMock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useLanguage: () => 'zh-Hans',
}))
const mockDetail = createMockPluginDetail({
plugin_unique_identifier: 'zh-plugin@1.0.0',
})
const mockDetail = createMockPluginDetail()
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
setCurrentPluginDetail(mockDetail, ReadmeShowType.drawer)
// This test verifies the language handling logic exists in the component
renderWithQueryClient(<ReadmePanel />)
// The component should pass undefined for language when zh-Hans
expect(mockUsePluginReadme).toHaveBeenCalledWith({
plugin_unique_identifier: 'zh-plugin@1.0.0',
language: undefined,
})
// Reset language
mockLanguage = 'en-US'
// The component should have called the hook
expect(mockUsePluginReadme).toHaveBeenCalled()
})
it('should handle empty plugin_unique_identifier', () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,5 @@
'use client'
import type { Collection, CustomCollectionBackend, Tool, WorkflowToolProviderRequest, WorkflowToolProviderResponse } from '../types'
import type { WorkflowToolModalPayload } from '@/app/components/tools/workflow-tool'
import {
RiCloseLine,
} from '@remixicon/react'
@ -413,7 +412,7 @@ const ProviderDetail = ({
)}
{isShowEditWorkflowToolModal && (
<WorkflowToolModal
payload={customCollection as unknown as WorkflowToolModalPayload}
payload={customCollection}
onHide={() => setIsShowEditWorkflowToolModal(false)}
onRemove={onClickWorkflowToolDelete}
onSave={updateWorkflowToolProvider}

View File

@ -1,70 +1,8 @@
import type { TriggerEventParameter } from '../../plugins/types'
import type { ToolCredential, ToolParameter } from '../types'
import type { TypeWithI18N } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { SchemaRoot } from '@/app/components/workflow/nodes/llm/types'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types'
// Type for form value input with type and value properties
type FormValueInput = {
type?: string
value?: unknown
}
/**
* Form schema type for tool credentials.
* This type represents the schema returned by toolCredentialToFormSchemas.
*/
export type ToolCredentialFormSchema = {
name: string
variable: string
label: TypeWithI18N
type: string
required: boolean
default?: string
tooltip?: TypeWithI18N
placeholder?: TypeWithI18N
show_on: { variable: string, value: string }[]
options?: {
label: TypeWithI18N
value: string
show_on: { variable: string, value: string }[]
}[]
help?: TypeWithI18N | null
url?: string
}
/**
* Form schema type for tool parameters.
* This type represents the schema returned by toolParametersToFormSchemas.
*/
export type ToolFormSchema = {
name: string
variable: string
label: TypeWithI18N
type: string
_type: string
form: string
required: boolean
default?: string
tooltip?: TypeWithI18N
show_on: { variable: string, value: string }[]
options?: {
label: TypeWithI18N
value: string
show_on: { variable: string, value: string }[]
}[]
placeholder?: TypeWithI18N
min?: number
max?: number
llm_description?: string
human_description?: TypeWithI18N
multiple?: boolean
url?: string
scope?: string
input_schema?: SchemaRoot
}
export const toType = (type: string) => {
switch (type) {
case 'string':
@ -92,11 +30,11 @@ export const triggerEventParametersToFormSchemas = (parameters: TriggerEventPara
})
}
export const toolParametersToFormSchemas = (parameters: ToolParameter[]): ToolFormSchema[] => {
export const toolParametersToFormSchemas = (parameters: ToolParameter[]) => {
if (!parameters)
return []
const formSchemas = parameters.map((parameter): ToolFormSchema => {
const formSchemas = parameters.map((parameter) => {
return {
...parameter,
variable: parameter.name,
@ -115,17 +53,17 @@ export const toolParametersToFormSchemas = (parameters: ToolParameter[]): ToolFo
return formSchemas
}
export const toolCredentialToFormSchemas = (parameters: ToolCredential[]): ToolCredentialFormSchema[] => {
export const toolCredentialToFormSchemas = (parameters: ToolCredential[]) => {
if (!parameters)
return []
const formSchemas = parameters.map((parameter): ToolCredentialFormSchema => {
const formSchemas = parameters.map((parameter) => {
return {
...parameter,
variable: parameter.name,
type: toType(parameter.type),
label: parameter.label,
tooltip: parameter.help ?? undefined,
tooltip: parameter.help,
show_on: [],
options: parameter.options?.map((option) => {
return {
@ -138,7 +76,7 @@ export const toolCredentialToFormSchemas = (parameters: ToolCredential[]): ToolC
return formSchemas
}
export const addDefaultValue = (value: Record<string, unknown>, formSchemas: { variable: string, type: string, default?: unknown }[]) => {
export const addDefaultValue = (value: Record<string, any>, formSchemas: { variable: string, type: string, default?: any }[]) => {
const newValues = { ...value }
formSchemas.forEach((formSchema) => {
const itemValue = value[formSchema.variable]
@ -158,7 +96,7 @@ export const addDefaultValue = (value: Record<string, unknown>, formSchemas: { v
return newValues
}
const correctInitialData = (type: string, target: FormValueInput, defaultValue: unknown): FormValueInput => {
const correctInitialData = (type: string, target: any, defaultValue: any) => {
if (type === 'text-input' || type === 'secret-input')
target.type = 'mixed'
@ -184,39 +122,39 @@ const correctInitialData = (type: string, target: FormValueInput, defaultValue:
return target
}
export const generateFormValue = (value: Record<string, unknown>, formSchemas: { variable: string, default?: unknown, type: string }[], isReasoning = false) => {
const newValues: Record<string, unknown> = {}
export const generateFormValue = (value: Record<string, any>, formSchemas: { variable: string, default?: any, type: string }[], isReasoning = false) => {
const newValues = {} as any
formSchemas.forEach((formSchema) => {
const itemValue = value[formSchema.variable]
if ((formSchema.default !== undefined) && (value === undefined || itemValue === null || itemValue === '' || itemValue === undefined)) {
const defaultVal = formSchema.default
if (isReasoning) {
newValues[formSchema.variable] = { auto: 1, value: null }
}
else {
const initialValue: FormValueInput = { type: 'constant', value: formSchema.default }
newValues[formSchema.variable] = {
value: correctInitialData(formSchema.type, initialValue, defaultVal),
}
const value = formSchema.default
newValues[formSchema.variable] = {
value: {
type: 'constant',
value: formSchema.default,
},
...(isReasoning ? { auto: 1, value: null } : {}),
}
if (!isReasoning)
newValues[formSchema.variable].value = correctInitialData(formSchema.type, newValues[formSchema.variable].value, value)
}
})
return newValues
}
export const getPlainValue = (value: Record<string, { value: unknown }>) => {
const plainValue: Record<string, unknown> = {}
Object.keys(value).forEach((key) => {
export const getPlainValue = (value: Record<string, any>) => {
const plainValue = { ...value }
Object.keys(plainValue).forEach((key) => {
plainValue[key] = {
...(value[key].value as object),
...value[key].value,
}
})
return plainValue
}
export const getStructureValue = (value: Record<string, unknown>): Record<string, { value: unknown }> => {
const newValue: Record<string, { value: unknown }> = {}
Object.keys(value).forEach((key) => {
export const getStructureValue = (value: Record<string, any>) => {
const newValue = { ...value } as any
Object.keys(newValue).forEach((key) => {
newValue[key] = {
value: value[key],
}
@ -224,17 +162,17 @@ export const getStructureValue = (value: Record<string, unknown>): Record<string
return newValue
}
export const getConfiguredValue = (value: Record<string, unknown>, formSchemas: { variable: string, type: string, default?: unknown }[]) => {
const newValues: Record<string, unknown> = { ...value }
export const getConfiguredValue = (value: Record<string, any>, formSchemas: { variable: string, type: string, default?: any }[]) => {
const newValues = { ...value }
formSchemas.forEach((formSchema) => {
const itemValue = value[formSchema.variable]
if ((formSchema.default !== undefined) && (value === undefined || itemValue === null || itemValue === '' || itemValue === undefined)) {
const defaultVal = formSchema.default
const initialValue: FormValueInput = {
const value = formSchema.default
newValues[formSchema.variable] = {
type: 'constant',
value: typeof formSchema.default === 'string' ? formSchema.default.replace(/\n/g, '\\n') : formSchema.default,
}
newValues[formSchema.variable] = correctInitialData(formSchema.type, initialValue, defaultVal)
newValues[formSchema.variable] = correctInitialData(formSchema.type, newValues[formSchema.variable], value)
}
})
return newValues
@ -249,24 +187,24 @@ const getVarKindType = (type: FormTypeEnum) => {
return VarKindType.mixed
}
export const generateAgentToolValue = (value: Record<string, { value?: unknown, auto?: 0 | 1 }>, formSchemas: { variable: string, default?: unknown, type: string }[], isReasoning = false) => {
const newValues: Record<string, { value: FormValueInput | null, auto?: 0 | 1 }> = {}
export const generateAgentToolValue = (value: Record<string, any>, formSchemas: { variable: string, default?: any, type: string }[], isReasoning = false) => {
const newValues = {} as any
if (!isReasoning) {
formSchemas.forEach((formSchema) => {
const itemValue = value[formSchema.variable]
newValues[formSchema.variable] = {
value: {
type: 'constant',
value: itemValue?.value,
value: itemValue.value,
},
}
newValues[formSchema.variable].value = correctInitialData(formSchema.type, newValues[formSchema.variable].value!, itemValue?.value)
newValues[formSchema.variable].value = correctInitialData(formSchema.type, newValues[formSchema.variable].value, itemValue.value)
})
}
else {
formSchemas.forEach((formSchema) => {
const itemValue = value[formSchema.variable]
if (itemValue?.auto === 1) {
if (itemValue.auto === 1) {
newValues[formSchema.variable] = {
auto: 1,
value: null,
@ -275,7 +213,7 @@ export const generateAgentToolValue = (value: Record<string, { value?: unknown,
else {
newValues[formSchema.variable] = {
auto: 0,
value: (itemValue?.value as FormValueInput) || {
value: itemValue.value || {
type: getVarKindType(formSchema.type as FormTypeEnum),
value: null,
},

View File

@ -1,6 +1,6 @@
'use client'
import type { FC } from 'react'
import type { Emoji, WorkflowToolProviderOutputParameter, WorkflowToolProviderOutputSchema, WorkflowToolProviderParameter, WorkflowToolProviderRequest } from '../types'
import type { Emoji, WorkflowToolProviderOutputParameter, WorkflowToolProviderParameter, WorkflowToolProviderRequest } from '../types'
import { RiErrorWarningLine } from '@remixicon/react'
import { produce } from 'immer'
import * as React from 'react'
@ -21,25 +21,9 @@ import { VarType } from '@/app/components/workflow/types'
import { cn } from '@/utils/classnames'
import { buildWorkflowOutputParameters } from './utils'
export type WorkflowToolModalPayload = {
icon: Emoji
label: string
name: string
description: string
parameters: WorkflowToolProviderParameter[]
outputParameters: WorkflowToolProviderOutputParameter[]
labels: string[]
privacy_policy: string
tool?: {
output_schema?: WorkflowToolProviderOutputSchema
}
workflow_tool_id?: string
workflow_app_id?: string
}
type Props = {
isAdd?: boolean
payload: WorkflowToolModalPayload
payload: any
onHide: () => void
onRemove?: () => void
onCreate?: (payload: WorkflowToolProviderRequest & { workflow_app_id: string }) => void
@ -89,7 +73,7 @@ const WorkflowToolAsModal: FC<Props> = ({
},
]
const handleParameterChange = (key: string, value: string, index: number) => {
const handleParameterChange = (key: string, value: any, index: number) => {
const newData = produce(parameters, (draft: WorkflowToolProviderParameter[]) => {
if (key === 'description')
draft[index].description = value
@ -152,13 +136,13 @@ const WorkflowToolAsModal: FC<Props> = ({
if (!isAdd) {
onSave?.({
...requestParams,
workflow_tool_id: payload.workflow_tool_id!,
workflow_tool_id: payload.workflow_tool_id,
})
}
else {
onCreate?.({
...requestParams,
workflow_app_id: payload.workflow_app_id!,
workflow_app_id: payload.workflow_app_id,
})
}
}

View File

@ -0,0 +1,705 @@
/**
* Test Suite for useNodesSyncDraft Hook
*
* PURPOSE:
* This hook handles syncing workflow draft to the server. The key fix being tested
* is the error handling behavior when `draft_workflow_not_sync` error occurs.
*
* MULTI-TAB PROBLEM SCENARIO:
* 1. User opens the same workflow in Tab A and Tab B (both have hash: v1)
* 2. Tab A saves successfully, server returns new hash: v2
* 3. Tab B tries to save with old hash: v1, server returns 400 error with code
* 'draft_workflow_not_sync'
* 4. BEFORE FIX: handleRefreshWorkflowDraft() was called without args, which fetched
* draft AND overwrote canvas - user lost unsaved changes in Tab B
* 5. AFTER FIX: handleRefreshWorkflowDraft(true) is called, which fetches draft but
* only updates hash (notUpdateCanvas=true), preserving user's canvas changes
*
* TESTING STRATEGY:
* We don't simulate actual tab switching UI behavior. Instead, we mock the API to
* return `draft_workflow_not_sync` error and verify:
* - The hook calls handleRefreshWorkflowDraft(true) - not handleRefreshWorkflowDraft()
* - This ensures canvas data is preserved while hash is updated for retry
*
* This is behavior-driven testing - we verify "what the code does when receiving
* specific API errors" rather than simulating complete user interaction flows.
* True multi-tab integration testing would require E2E frameworks like Playwright.
*/
import { act, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useNodesSyncDraft } from './use-nodes-sync-draft'
// Mock reactflow store
const mockGetNodes = vi.fn()
type MockEdge = {
id: string
source: string
target: string
data: Record<string, unknown>
}
const mockStoreState: {
getNodes: ReturnType<typeof vi.fn>
edges: MockEdge[]
transform: number[]
} = {
getNodes: mockGetNodes,
edges: [],
transform: [0, 0, 1],
}
vi.mock('reactflow', () => ({
useStoreApi: () => ({
getState: () => mockStoreState,
}),
}))
// Mock features store
const mockFeaturesState = {
features: {
opening: { enabled: false, opening_statement: '', suggested_questions: [] },
suggested: {},
text2speech: {},
speech2text: {},
citation: {},
moderation: {},
file: {},
},
}
vi.mock('@/app/components/base/features/hooks', () => ({
useFeaturesStore: () => ({
getState: () => mockFeaturesState,
}),
}))
// Mock workflow service
const mockSyncWorkflowDraft = vi.fn()
vi.mock('@/service/workflow', () => ({
syncWorkflowDraft: (...args: unknown[]) => mockSyncWorkflowDraft(...args),
}))
// Mock useNodesReadOnly
const mockGetNodesReadOnly = vi.fn()
vi.mock('@/app/components/workflow/hooks/use-workflow', () => ({
useNodesReadOnly: () => ({
getNodesReadOnly: mockGetNodesReadOnly,
}),
}))
// Mock useSerialAsyncCallback - pass through the callback
vi.mock('@/app/components/workflow/hooks/use-serial-async-callback', () => ({
useSerialAsyncCallback: (callback: (...args: unknown[]) => unknown) => callback,
}))
// Mock workflow store
const mockSetSyncWorkflowDraftHash = vi.fn()
const mockSetDraftUpdatedAt = vi.fn()
const createMockWorkflowStoreState = (overrides = {}) => ({
appId: 'test-app-id',
conversationVariables: [],
environmentVariables: [],
syncWorkflowDraftHash: 'current-hash-123',
isWorkflowDataLoaded: true,
setSyncWorkflowDraftHash: mockSetSyncWorkflowDraftHash,
setDraftUpdatedAt: mockSetDraftUpdatedAt,
...overrides,
})
const mockWorkflowStoreGetState = vi.fn()
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: mockWorkflowStoreGetState,
}),
}))
// Mock useWorkflowRefreshDraft (THE KEY DEPENDENCY FOR THIS TEST)
const mockHandleRefreshWorkflowDraft = vi.fn()
vi.mock('.', () => ({
useWorkflowRefreshDraft: () => ({
handleRefreshWorkflowDraft: mockHandleRefreshWorkflowDraft,
}),
}))
// Mock API_PREFIX
vi.mock('@/config', () => ({
API_PREFIX: '/api',
}))
// Create a mock error response that mimics the actual API error
const createMockErrorResponse = (code: string) => {
const errorBody = { code, message: 'Draft not in sync' }
let bodyUsed = false
return {
json: vi.fn().mockImplementation(() => {
bodyUsed = true
return Promise.resolve(errorBody)
}),
get bodyUsed() {
return bodyUsed
},
}
}
describe('useNodesSyncDraft', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetNodesReadOnly.mockReturnValue(false)
mockGetNodes.mockReturnValue([
{ id: 'node-1', type: 'start', data: { type: 'start' } },
{ id: 'node-2', type: 'llm', data: { type: 'llm' } },
])
mockStoreState.edges = [
{ id: 'edge-1', source: 'node-1', target: 'node-2', data: {} },
]
mockWorkflowStoreGetState.mockReturnValue(createMockWorkflowStoreState())
mockSyncWorkflowDraft.mockResolvedValue({
hash: 'new-hash-456',
updated_at: Date.now(),
})
})
afterEach(() => {
vi.resetAllMocks()
})
describe('doSyncWorkflowDraft function', () => {
it('should return doSyncWorkflowDraft function', () => {
const { result } = renderHook(() => useNodesSyncDraft())
expect(result.current.doSyncWorkflowDraft).toBeDefined()
expect(typeof result.current.doSyncWorkflowDraft).toBe('function')
})
it('should return syncWorkflowDraftWhenPageClose function', () => {
const { result } = renderHook(() => useNodesSyncDraft())
expect(result.current.syncWorkflowDraftWhenPageClose).toBeDefined()
expect(typeof result.current.syncWorkflowDraftWhenPageClose).toBe('function')
})
})
describe('successful sync', () => {
it('should call syncWorkflowDraft service on successful sync', async () => {
const { result } = renderHook(() => useNodesSyncDraft())
await act(async () => {
await result.current.doSyncWorkflowDraft()
})
expect(mockSyncWorkflowDraft).toHaveBeenCalledWith({
url: '/apps/test-app-id/workflows/draft',
params: expect.objectContaining({
hash: 'current-hash-123',
graph: expect.objectContaining({
nodes: expect.any(Array),
edges: expect.any(Array),
viewport: expect.any(Object),
}),
}),
})
})
it('should update syncWorkflowDraftHash on success', async () => {
mockSyncWorkflowDraft.mockResolvedValue({
hash: 'new-hash-789',
updated_at: 1234567890,
})
const { result } = renderHook(() => useNodesSyncDraft())
await act(async () => {
await result.current.doSyncWorkflowDraft()
})
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('new-hash-789')
})
it('should update draftUpdatedAt on success', async () => {
const updatedAt = 1234567890
mockSyncWorkflowDraft.mockResolvedValue({
hash: 'new-hash',
updated_at: updatedAt,
})
const { result } = renderHook(() => useNodesSyncDraft())
await act(async () => {
await result.current.doSyncWorkflowDraft()
})
expect(mockSetDraftUpdatedAt).toHaveBeenCalledWith(updatedAt)
})
it('should call onSuccess callback on success', async () => {
const onSuccess = vi.fn()
const { result } = renderHook(() => useNodesSyncDraft())
await act(async () => {
await result.current.doSyncWorkflowDraft(false, { onSuccess })
})
expect(onSuccess).toHaveBeenCalled()
})
it('should call onSettled callback after success', async () => {
const onSettled = vi.fn()
const { result } = renderHook(() => useNodesSyncDraft())
await act(async () => {
await result.current.doSyncWorkflowDraft(false, { onSettled })
})
expect(onSettled).toHaveBeenCalled()
})
})
describe('sync error handling - draft_workflow_not_sync (THE KEY FIX)', () => {
/**
* This is THE KEY TEST for the bug fix.
*
* SCENARIO: Multi-tab editing
* 1. User opens workflow in Tab A and Tab B
* 2. Tab A saves draft successfully, gets new hash
* 3. Tab B tries to save with old hash
* 4. Server returns 400 with code 'draft_workflow_not_sync'
*
* BEFORE FIX:
* - handleRefreshWorkflowDraft() was called without arguments
* - This would fetch draft AND overwrite the canvas
* - User loses their unsaved changes in Tab B
*
* AFTER FIX:
* - handleRefreshWorkflowDraft(true) is called
* - This fetches draft but DOES NOT overwrite canvas
* - Only hash is updated for the next sync attempt
* - User's unsaved changes are preserved
*/
it('should call handleRefreshWorkflowDraft with notUpdateCanvas=true when draft_workflow_not_sync error occurs', async () => {
const mockError = createMockErrorResponse('draft_workflow_not_sync')
mockSyncWorkflowDraft.mockRejectedValue(mockError)
const { result } = renderHook(() => useNodesSyncDraft())
await act(async () => {
await result.current.doSyncWorkflowDraft()
})
// THE KEY ASSERTION: handleRefreshWorkflowDraft must be called with true
await waitFor(() => {
expect(mockHandleRefreshWorkflowDraft).toHaveBeenCalledWith(true)
})
})
it('should NOT call handleRefreshWorkflowDraft when notRefreshWhenSyncError is true', async () => {
const mockError = createMockErrorResponse('draft_workflow_not_sync')
mockSyncWorkflowDraft.mockRejectedValue(mockError)
const { result } = renderHook(() => useNodesSyncDraft())
await act(async () => {
// First parameter is notRefreshWhenSyncError
await result.current.doSyncWorkflowDraft(true)
})
// Wait a bit for async operations
await new Promise(resolve => setTimeout(resolve, 100))
expect(mockHandleRefreshWorkflowDraft).not.toHaveBeenCalled()
})
it('should call onError callback when draft_workflow_not_sync error occurs', async () => {
const mockError = createMockErrorResponse('draft_workflow_not_sync')
mockSyncWorkflowDraft.mockRejectedValue(mockError)
const onError = vi.fn()
const { result } = renderHook(() => useNodesSyncDraft())
await act(async () => {
await result.current.doSyncWorkflowDraft(false, { onError })
})
expect(onError).toHaveBeenCalled()
})
it('should call onSettled callback after error', async () => {
const mockError = createMockErrorResponse('draft_workflow_not_sync')
mockSyncWorkflowDraft.mockRejectedValue(mockError)
const onSettled = vi.fn()
const { result } = renderHook(() => useNodesSyncDraft())
await act(async () => {
await result.current.doSyncWorkflowDraft(false, { onSettled })
})
expect(onSettled).toHaveBeenCalled()
})
})
describe('other error handling', () => {
it('should NOT call handleRefreshWorkflowDraft for non-draft_workflow_not_sync errors', async () => {
const mockError = createMockErrorResponse('some_other_error')
mockSyncWorkflowDraft.mockRejectedValue(mockError)
const { result } = renderHook(() => useNodesSyncDraft())
await act(async () => {
await result.current.doSyncWorkflowDraft()
})
// Wait a bit for async operations
await new Promise(resolve => setTimeout(resolve, 100))
expect(mockHandleRefreshWorkflowDraft).not.toHaveBeenCalled()
})
it('should handle error without json method', async () => {
const mockError = new Error('Network error')
mockSyncWorkflowDraft.mockRejectedValue(mockError)
const { result } = renderHook(() => useNodesSyncDraft())
const onError = vi.fn()
await act(async () => {
await result.current.doSyncWorkflowDraft(false, { onError })
})
expect(onError).toHaveBeenCalled()
expect(mockHandleRefreshWorkflowDraft).not.toHaveBeenCalled()
})
it('should handle error with bodyUsed already true', async () => {
const mockError = {
json: vi.fn(),
bodyUsed: true,
}
mockSyncWorkflowDraft.mockRejectedValue(mockError)
const { result } = renderHook(() => useNodesSyncDraft())
await act(async () => {
await result.current.doSyncWorkflowDraft()
})
// Should not call json() when bodyUsed is true
expect(mockError.json).not.toHaveBeenCalled()
expect(mockHandleRefreshWorkflowDraft).not.toHaveBeenCalled()
})
})
describe('read-only mode', () => {
it('should not sync when nodes are read-only', async () => {
mockGetNodesReadOnly.mockReturnValue(true)
const { result } = renderHook(() => useNodesSyncDraft())
await act(async () => {
await result.current.doSyncWorkflowDraft()
})
expect(mockSyncWorkflowDraft).not.toHaveBeenCalled()
})
it('should not sync on page close when nodes are read-only', () => {
mockGetNodesReadOnly.mockReturnValue(true)
// Mock sendBeacon
const mockSendBeacon = vi.fn()
Object.defineProperty(navigator, 'sendBeacon', {
value: mockSendBeacon,
writable: true,
})
const { result } = renderHook(() => useNodesSyncDraft())
act(() => {
result.current.syncWorkflowDraftWhenPageClose()
})
expect(mockSendBeacon).not.toHaveBeenCalled()
})
})
describe('workflow data not loaded', () => {
it('should not sync when workflow data is not loaded', async () => {
mockWorkflowStoreGetState.mockReturnValue(
createMockWorkflowStoreState({ isWorkflowDataLoaded: false }),
)
const { result } = renderHook(() => useNodesSyncDraft())
await act(async () => {
await result.current.doSyncWorkflowDraft()
})
expect(mockSyncWorkflowDraft).not.toHaveBeenCalled()
})
})
describe('no appId', () => {
it('should not sync when appId is not set', async () => {
mockWorkflowStoreGetState.mockReturnValue(
createMockWorkflowStoreState({ appId: null }),
)
const { result } = renderHook(() => useNodesSyncDraft())
await act(async () => {
await result.current.doSyncWorkflowDraft()
})
expect(mockSyncWorkflowDraft).not.toHaveBeenCalled()
})
})
describe('node filtering', () => {
it('should filter out temp nodes', async () => {
mockGetNodes.mockReturnValue([
{ id: 'node-1', type: 'start', data: { type: 'start' } },
{ id: 'node-temp', type: 'custom', data: { type: 'custom', _isTempNode: true } },
{ id: 'node-2', type: 'llm', data: { type: 'llm' } },
])
const { result } = renderHook(() => useNodesSyncDraft())
await act(async () => {
await result.current.doSyncWorkflowDraft()
})
expect(mockSyncWorkflowDraft).toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({
graph: expect.objectContaining({
nodes: expect.not.arrayContaining([
expect.objectContaining({ id: 'node-temp' }),
]),
}),
}),
}),
)
})
it('should remove internal underscore properties from nodes', async () => {
mockGetNodes.mockReturnValue([
{
id: 'node-1',
type: 'start',
data: {
type: 'start',
_internalProp: 'should be removed',
_anotherInternal: true,
publicProp: 'should remain',
},
},
])
const { result } = renderHook(() => useNodesSyncDraft())
await act(async () => {
await result.current.doSyncWorkflowDraft()
})
const callArgs = mockSyncWorkflowDraft.mock.calls[0][0]
const sentNode = callArgs.params.graph.nodes[0]
expect(sentNode.data).not.toHaveProperty('_internalProp')
expect(sentNode.data).not.toHaveProperty('_anotherInternal')
expect(sentNode.data).toHaveProperty('publicProp', 'should remain')
})
})
describe('edge filtering', () => {
it('should filter out temp edges', async () => {
mockStoreState.edges = [
{ id: 'edge-1', source: 'node-1', target: 'node-2', data: {} },
{ id: 'edge-temp', source: 'node-1', target: 'node-3', data: { _isTemp: true } },
]
const { result } = renderHook(() => useNodesSyncDraft())
await act(async () => {
await result.current.doSyncWorkflowDraft()
})
const callArgs = mockSyncWorkflowDraft.mock.calls[0][0]
const sentEdges = callArgs.params.graph.edges
expect(sentEdges).toHaveLength(1)
expect(sentEdges[0].id).toBe('edge-1')
})
it('should remove internal underscore properties from edges', async () => {
mockStoreState.edges = [
{
id: 'edge-1',
source: 'node-1',
target: 'node-2',
data: {
_internalEdgeProp: 'should be removed',
publicEdgeProp: 'should remain',
},
},
]
const { result } = renderHook(() => useNodesSyncDraft())
await act(async () => {
await result.current.doSyncWorkflowDraft()
})
const callArgs = mockSyncWorkflowDraft.mock.calls[0][0]
const sentEdge = callArgs.params.graph.edges[0]
expect(sentEdge.data).not.toHaveProperty('_internalEdgeProp')
expect(sentEdge.data).toHaveProperty('publicEdgeProp', 'should remain')
})
})
describe('viewport handling', () => {
it('should send current viewport from transform', async () => {
mockStoreState.transform = [100, 200, 1.5]
const { result } = renderHook(() => useNodesSyncDraft())
await act(async () => {
await result.current.doSyncWorkflowDraft()
})
expect(mockSyncWorkflowDraft).toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({
graph: expect.objectContaining({
viewport: { x: 100, y: 200, zoom: 1.5 },
}),
}),
}),
)
})
})
describe('multi-tab concurrent editing scenario (END-TO-END TEST)', () => {
/**
* Simulates the complete multi-tab scenario to verify the fix works correctly.
*
* Scenario:
* 1. Tab A and Tab B both have the workflow open with hash 'hash-v1'
* 2. Tab A saves successfully, server returns 'hash-v2'
* 3. Tab B tries to save with 'hash-v1', gets 'draft_workflow_not_sync' error
* 4. Tab B should only update hash to 'hash-v2', not overwrite canvas
* 5. Tab B can now retry save with correct hash
*/
it('should preserve canvas data during hash conflict resolution', async () => {
// Initial state: both tabs have hash-v1
mockWorkflowStoreGetState.mockReturnValue(
createMockWorkflowStoreState({ syncWorkflowDraftHash: 'hash-v1' }),
)
// Tab B tries to save with old hash, server returns error
const syncError = createMockErrorResponse('draft_workflow_not_sync')
mockSyncWorkflowDraft.mockRejectedValue(syncError)
const { result } = renderHook(() => useNodesSyncDraft())
// Tab B attempts to sync
await act(async () => {
await result.current.doSyncWorkflowDraft()
})
// Verify the sync was attempted with old hash
expect(mockSyncWorkflowDraft).toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({
hash: 'hash-v1',
}),
}),
)
// Verify handleRefreshWorkflowDraft was called with true (not overwrite canvas)
await waitFor(() => {
expect(mockHandleRefreshWorkflowDraft).toHaveBeenCalledWith(true)
})
// The key assertion: only one argument (true) was passed
expect(mockHandleRefreshWorkflowDraft).toHaveBeenCalledTimes(1)
expect(mockHandleRefreshWorkflowDraft.mock.calls[0]).toEqual([true])
})
it('should handle multiple consecutive sync failures gracefully', async () => {
// Create fresh error for each call to avoid bodyUsed issue
mockSyncWorkflowDraft
.mockRejectedValueOnce(createMockErrorResponse('draft_workflow_not_sync'))
.mockRejectedValueOnce(createMockErrorResponse('draft_workflow_not_sync'))
const { result } = renderHook(() => useNodesSyncDraft())
// First sync attempt
await act(async () => {
await result.current.doSyncWorkflowDraft()
})
// Wait for first refresh call
await waitFor(() => {
expect(mockHandleRefreshWorkflowDraft).toHaveBeenCalledTimes(1)
})
// Second sync attempt
await act(async () => {
await result.current.doSyncWorkflowDraft()
})
// Both should call handleRefreshWorkflowDraft with true
await waitFor(() => {
expect(mockHandleRefreshWorkflowDraft).toHaveBeenCalledTimes(2)
})
mockHandleRefreshWorkflowDraft.mock.calls.forEach((call) => {
expect(call).toEqual([true])
})
})
})
describe('callbacks behavior', () => {
it('should not call onSuccess when sync fails', async () => {
const syncError = createMockErrorResponse('draft_workflow_not_sync')
mockSyncWorkflowDraft.mockRejectedValue(syncError)
const onSuccess = vi.fn()
const onError = vi.fn()
const { result } = renderHook(() => useNodesSyncDraft())
await act(async () => {
await result.current.doSyncWorkflowDraft(false, { onSuccess, onError })
})
expect(onSuccess).not.toHaveBeenCalled()
expect(onError).toHaveBeenCalled()
})
it('should always call onSettled regardless of success or failure', async () => {
const onSettled = vi.fn()
const { result } = renderHook(() => useNodesSyncDraft())
// Test success case
await act(async () => {
await result.current.doSyncWorkflowDraft(false, { onSettled })
})
expect(onSettled).toHaveBeenCalledTimes(1)
// Reset
onSettled.mockClear()
// Test failure case
const syncError = createMockErrorResponse('draft_workflow_not_sync')
mockSyncWorkflowDraft.mockRejectedValue(syncError)
await act(async () => {
await result.current.doSyncWorkflowDraft(false, { onSettled })
})
expect(onSettled).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -115,7 +115,7 @@ export const useNodesSyncDraft = () => {
if (error && error.json && !error.bodyUsed) {
error.json().then((err: any) => {
if (err.code === 'draft_workflow_not_sync' && !notRefreshWhenSyncError)
handleRefreshWorkflowDraft()
handleRefreshWorkflowDraft(true)
})
}
callback?.onError?.()

View File

@ -0,0 +1,556 @@
/**
* Test Suite for useWorkflowRefreshDraft Hook
*
* PURPOSE:
* This hook is responsible for refreshing workflow draft data from the server.
* The key fix being tested is the `notUpdateCanvas` parameter behavior.
*
* MULTI-TAB PROBLEM SCENARIO:
* 1. User opens the same workflow in Tab A and Tab B (both have hash: v1)
* 2. Tab A saves successfully, server returns new hash: v2
* 3. Tab B tries to save with old hash: v1, server returns 400 error (draft_workflow_not_sync)
* 4. BEFORE FIX: handleRefreshWorkflowDraft() was called without args, which fetched
* draft AND overwrote canvas - user lost unsaved changes in Tab B
* 5. AFTER FIX: handleRefreshWorkflowDraft(true) is called, which fetches draft but
* only updates hash, preserving user's canvas changes
*
* TESTING STRATEGY:
* We don't simulate actual tab switching UI behavior. Instead, we test the hook's
* response to specific inputs:
* - When notUpdateCanvas=true: should NOT call handleUpdateWorkflowCanvas
* - When notUpdateCanvas=false/undefined: should call handleUpdateWorkflowCanvas
*
* This is behavior-driven testing - we verify "what the code does when given specific
* inputs" rather than simulating complete user interaction flows.
*/
import { act, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useWorkflowRefreshDraft } from './use-workflow-refresh-draft'
// Mock the workflow service
const mockFetchWorkflowDraft = vi.fn()
vi.mock('@/service/workflow', () => ({
fetchWorkflowDraft: (...args: unknown[]) => mockFetchWorkflowDraft(...args),
}))
// Mock the workflow update hook
const mockHandleUpdateWorkflowCanvas = vi.fn()
vi.mock('@/app/components/workflow/hooks', () => ({
useWorkflowUpdate: () => ({
handleUpdateWorkflowCanvas: mockHandleUpdateWorkflowCanvas,
}),
}))
// Mock store state
const mockSetSyncWorkflowDraftHash = vi.fn()
const mockSetIsSyncingWorkflowDraft = vi.fn()
const mockSetEnvironmentVariables = vi.fn()
const mockSetEnvSecrets = vi.fn()
const mockSetConversationVariables = vi.fn()
const mockSetIsWorkflowDataLoaded = vi.fn()
const mockCancelDebouncedSync = vi.fn()
const createMockStoreState = (overrides = {}) => ({
appId: 'test-app-id',
setSyncWorkflowDraftHash: mockSetSyncWorkflowDraftHash,
setIsSyncingWorkflowDraft: mockSetIsSyncingWorkflowDraft,
setEnvironmentVariables: mockSetEnvironmentVariables,
setEnvSecrets: mockSetEnvSecrets,
setConversationVariables: mockSetConversationVariables,
setIsWorkflowDataLoaded: mockSetIsWorkflowDataLoaded,
isWorkflowDataLoaded: true,
debouncedSyncWorkflowDraft: {
cancel: mockCancelDebouncedSync,
},
...overrides,
})
const mockWorkflowStoreGetState = vi.fn()
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: mockWorkflowStoreGetState,
}),
}))
// Default mock response from fetchWorkflowDraft
const createMockDraftResponse = (overrides = {}) => ({
hash: 'new-hash-12345',
graph: {
nodes: [{ id: 'node-1', type: 'start', data: {} }],
edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2' }],
viewport: { x: 100, y: 200, zoom: 1.5 },
},
environment_variables: [
{ id: 'env-1', name: 'API_KEY', value: 'secret-key', value_type: 'secret' },
{ id: 'env-2', name: 'BASE_URL', value: 'https://api.example.com', value_type: 'string' },
],
conversation_variables: [
{ id: 'conv-1', name: 'user_input', value: 'test' },
],
...overrides,
})
describe('useWorkflowRefreshDraft', () => {
beforeEach(() => {
vi.clearAllMocks()
mockWorkflowStoreGetState.mockReturnValue(createMockStoreState())
mockFetchWorkflowDraft.mockResolvedValue(createMockDraftResponse())
})
afterEach(() => {
vi.resetAllMocks()
})
describe('handleRefreshWorkflowDraft function', () => {
it('should return handleRefreshWorkflowDraft function', () => {
const { result } = renderHook(() => useWorkflowRefreshDraft())
expect(result.current.handleRefreshWorkflowDraft).toBeDefined()
expect(typeof result.current.handleRefreshWorkflowDraft).toBe('function')
})
})
describe('notUpdateCanvas parameter behavior (THE KEY FIX)', () => {
it('should NOT call handleUpdateWorkflowCanvas when notUpdateCanvas is true', async () => {
const { result } = renderHook(() => useWorkflowRefreshDraft())
act(() => {
result.current.handleRefreshWorkflowDraft(true)
})
await waitFor(() => {
expect(mockFetchWorkflowDraft).toHaveBeenCalledWith('/apps/test-app-id/workflows/draft')
})
await waitFor(() => {
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('new-hash-12345')
})
// THE KEY ASSERTION: Canvas should NOT be updated when notUpdateCanvas is true
expect(mockHandleUpdateWorkflowCanvas).not.toHaveBeenCalled()
})
it('should call handleUpdateWorkflowCanvas when notUpdateCanvas is false', async () => {
const { result } = renderHook(() => useWorkflowRefreshDraft())
act(() => {
result.current.handleRefreshWorkflowDraft(false)
})
await waitFor(() => {
expect(mockFetchWorkflowDraft).toHaveBeenCalledWith('/apps/test-app-id/workflows/draft')
})
await waitFor(() => {
// Canvas SHOULD be updated when notUpdateCanvas is false
expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({
nodes: [{ id: 'node-1', type: 'start', data: {} }],
edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2' }],
viewport: { x: 100, y: 200, zoom: 1.5 },
})
})
await waitFor(() => {
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('new-hash-12345')
})
})
it('should call handleUpdateWorkflowCanvas when notUpdateCanvas is undefined (default)', async () => {
const { result } = renderHook(() => useWorkflowRefreshDraft())
act(() => {
result.current.handleRefreshWorkflowDraft()
})
await waitFor(() => {
expect(mockFetchWorkflowDraft).toHaveBeenCalled()
})
await waitFor(() => {
// Canvas SHOULD be updated when notUpdateCanvas is undefined
expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalled()
})
})
it('should still update hash even when notUpdateCanvas is true', async () => {
const { result } = renderHook(() => useWorkflowRefreshDraft())
act(() => {
result.current.handleRefreshWorkflowDraft(true)
})
await waitFor(() => {
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('new-hash-12345')
})
// Verify canvas was NOT updated
expect(mockHandleUpdateWorkflowCanvas).not.toHaveBeenCalled()
})
it('should still update environment variables when notUpdateCanvas is true', async () => {
const { result } = renderHook(() => useWorkflowRefreshDraft())
act(() => {
result.current.handleRefreshWorkflowDraft(true)
})
await waitFor(() => {
expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([
{ id: 'env-1', name: 'API_KEY', value: '[__HIDDEN__]', value_type: 'secret' },
{ id: 'env-2', name: 'BASE_URL', value: 'https://api.example.com', value_type: 'string' },
])
})
expect(mockHandleUpdateWorkflowCanvas).not.toHaveBeenCalled()
})
it('should still update env secrets when notUpdateCanvas is true', async () => {
const { result } = renderHook(() => useWorkflowRefreshDraft())
act(() => {
result.current.handleRefreshWorkflowDraft(true)
})
await waitFor(() => {
expect(mockSetEnvSecrets).toHaveBeenCalledWith({
'env-1': 'secret-key',
})
})
expect(mockHandleUpdateWorkflowCanvas).not.toHaveBeenCalled()
})
it('should still update conversation variables when notUpdateCanvas is true', async () => {
const { result } = renderHook(() => useWorkflowRefreshDraft())
act(() => {
result.current.handleRefreshWorkflowDraft(true)
})
await waitFor(() => {
expect(mockSetConversationVariables).toHaveBeenCalledWith([
{ id: 'conv-1', name: 'user_input', value: 'test' },
])
})
expect(mockHandleUpdateWorkflowCanvas).not.toHaveBeenCalled()
})
})
describe('syncing state management', () => {
it('should set isSyncingWorkflowDraft to true before fetch', () => {
const { result } = renderHook(() => useWorkflowRefreshDraft())
act(() => {
result.current.handleRefreshWorkflowDraft()
})
expect(mockSetIsSyncingWorkflowDraft).toHaveBeenCalledWith(true)
})
it('should set isSyncingWorkflowDraft to false after fetch completes', async () => {
const { result } = renderHook(() => useWorkflowRefreshDraft())
act(() => {
result.current.handleRefreshWorkflowDraft()
})
await waitFor(() => {
expect(mockSetIsSyncingWorkflowDraft).toHaveBeenCalledWith(false)
})
})
it('should set isSyncingWorkflowDraft to false even when fetch fails', async () => {
mockFetchWorkflowDraft.mockRejectedValue(new Error('Network error'))
const { result } = renderHook(() => useWorkflowRefreshDraft())
act(() => {
result.current.handleRefreshWorkflowDraft()
})
await waitFor(() => {
expect(mockSetIsSyncingWorkflowDraft).toHaveBeenCalledWith(false)
})
})
})
describe('isWorkflowDataLoaded flag management', () => {
it('should set isWorkflowDataLoaded to false before fetch when it was true', () => {
mockWorkflowStoreGetState.mockReturnValue(
createMockStoreState({ isWorkflowDataLoaded: true }),
)
const { result } = renderHook(() => useWorkflowRefreshDraft())
act(() => {
result.current.handleRefreshWorkflowDraft()
})
expect(mockSetIsWorkflowDataLoaded).toHaveBeenCalledWith(false)
})
it('should set isWorkflowDataLoaded to true after fetch succeeds', async () => {
const { result } = renderHook(() => useWorkflowRefreshDraft())
act(() => {
result.current.handleRefreshWorkflowDraft()
})
await waitFor(() => {
expect(mockSetIsWorkflowDataLoaded).toHaveBeenCalledWith(true)
})
})
it('should restore isWorkflowDataLoaded when fetch fails and it was previously loaded', async () => {
mockWorkflowStoreGetState.mockReturnValue(
createMockStoreState({ isWorkflowDataLoaded: true }),
)
mockFetchWorkflowDraft.mockRejectedValue(new Error('Network error'))
const { result } = renderHook(() => useWorkflowRefreshDraft())
act(() => {
result.current.handleRefreshWorkflowDraft()
})
await waitFor(() => {
// Should restore to true because wasLoaded was true
expect(mockSetIsWorkflowDataLoaded).toHaveBeenLastCalledWith(true)
})
})
})
describe('debounced sync cancellation', () => {
it('should cancel debounced sync before fetching draft', () => {
const { result } = renderHook(() => useWorkflowRefreshDraft())
act(() => {
result.current.handleRefreshWorkflowDraft()
})
expect(mockCancelDebouncedSync).toHaveBeenCalled()
})
it('should handle case when debouncedSyncWorkflowDraft has no cancel method', () => {
mockWorkflowStoreGetState.mockReturnValue(
createMockStoreState({ debouncedSyncWorkflowDraft: {} }),
)
const { result } = renderHook(() => useWorkflowRefreshDraft())
// Should not throw
expect(() => {
act(() => {
result.current.handleRefreshWorkflowDraft()
})
}).not.toThrow()
})
})
describe('edge cases', () => {
it('should handle empty graph in response', async () => {
mockFetchWorkflowDraft.mockResolvedValue({
hash: 'hash-empty',
graph: null,
environment_variables: [],
conversation_variables: [],
})
const { result } = renderHook(() => useWorkflowRefreshDraft())
act(() => {
result.current.handleRefreshWorkflowDraft(false)
})
await waitFor(() => {
expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
})
})
})
it('should handle missing viewport in response', async () => {
mockFetchWorkflowDraft.mockResolvedValue({
hash: 'hash-no-viewport',
graph: {
nodes: [{ id: 'node-1' }],
edges: [],
viewport: null,
},
environment_variables: [],
conversation_variables: [],
})
const { result } = renderHook(() => useWorkflowRefreshDraft())
act(() => {
result.current.handleRefreshWorkflowDraft(false)
})
await waitFor(() => {
expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({
nodes: [{ id: 'node-1' }],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
})
})
})
it('should handle missing environment_variables in response', async () => {
mockFetchWorkflowDraft.mockResolvedValue({
hash: 'hash-no-env',
graph: { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } },
environment_variables: undefined,
conversation_variables: [],
})
const { result } = renderHook(() => useWorkflowRefreshDraft())
act(() => {
result.current.handleRefreshWorkflowDraft(true)
})
await waitFor(() => {
expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([])
expect(mockSetEnvSecrets).toHaveBeenCalledWith({})
})
})
it('should handle missing conversation_variables in response', async () => {
mockFetchWorkflowDraft.mockResolvedValue({
hash: 'hash-no-conv',
graph: { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } },
environment_variables: [],
conversation_variables: undefined,
})
const { result } = renderHook(() => useWorkflowRefreshDraft())
act(() => {
result.current.handleRefreshWorkflowDraft(true)
})
await waitFor(() => {
expect(mockSetConversationVariables).toHaveBeenCalledWith([])
})
})
it('should filter only secret type for envSecrets', async () => {
mockFetchWorkflowDraft.mockResolvedValue({
hash: 'hash-mixed-env',
graph: { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } },
environment_variables: [
{ id: 'env-1', name: 'SECRET_KEY', value: 'secret-value', value_type: 'secret' },
{ id: 'env-2', name: 'PUBLIC_URL', value: 'https://example.com', value_type: 'string' },
{ id: 'env-3', name: 'ANOTHER_SECRET', value: 'another-secret', value_type: 'secret' },
],
conversation_variables: [],
})
const { result } = renderHook(() => useWorkflowRefreshDraft())
act(() => {
result.current.handleRefreshWorkflowDraft(true)
})
await waitFor(() => {
expect(mockSetEnvSecrets).toHaveBeenCalledWith({
'env-1': 'secret-value',
'env-3': 'another-secret',
})
})
})
it('should hide secret values in environment variables', async () => {
mockFetchWorkflowDraft.mockResolvedValue({
hash: 'hash-secrets',
graph: { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } },
environment_variables: [
{ id: 'env-1', name: 'SECRET_KEY', value: 'super-secret', value_type: 'secret' },
{ id: 'env-2', name: 'PUBLIC_URL', value: 'https://example.com', value_type: 'string' },
],
conversation_variables: [],
})
const { result } = renderHook(() => useWorkflowRefreshDraft())
act(() => {
result.current.handleRefreshWorkflowDraft(true)
})
await waitFor(() => {
expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([
{ id: 'env-1', name: 'SECRET_KEY', value: '[__HIDDEN__]', value_type: 'secret' },
{ id: 'env-2', name: 'PUBLIC_URL', value: 'https://example.com', value_type: 'string' },
])
})
})
})
describe('multi-tab scenario simulation (THE BUG FIX VERIFICATION)', () => {
/**
* This test verifies the fix for the multi-tab scenario:
* 1. User opens workflow in Tab A and Tab B
* 2. Tab A saves draft successfully
* 3. Tab B tries to save but gets 'draft_workflow_not_sync' error (hash mismatch)
* 4. BEFORE FIX: Tab B would fetch draft and overwrite canvas with old data
* 5. AFTER FIX: Tab B only updates hash, preserving user's canvas changes
*/
it('should only update hash when called with notUpdateCanvas=true (simulating sync error recovery)', async () => {
const mockResponse = createMockDraftResponse()
mockFetchWorkflowDraft.mockResolvedValue(mockResponse)
const { result } = renderHook(() => useWorkflowRefreshDraft())
// Simulate the sync error recovery scenario where notUpdateCanvas is true
act(() => {
result.current.handleRefreshWorkflowDraft(true)
})
await waitFor(() => {
expect(mockFetchWorkflowDraft).toHaveBeenCalled()
})
await waitFor(() => {
// Hash should be updated for next sync attempt
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('new-hash-12345')
})
// Canvas should NOT be updated - user's changes are preserved
expect(mockHandleUpdateWorkflowCanvas).not.toHaveBeenCalled()
// Other states should still be updated
expect(mockSetEnvironmentVariables).toHaveBeenCalled()
expect(mockSetConversationVariables).toHaveBeenCalled()
})
it('should update canvas when called with notUpdateCanvas=false (normal refresh)', async () => {
const mockResponse = createMockDraftResponse()
mockFetchWorkflowDraft.mockResolvedValue(mockResponse)
const { result } = renderHook(() => useWorkflowRefreshDraft())
// Simulate normal refresh scenario
act(() => {
result.current.handleRefreshWorkflowDraft(false)
})
await waitFor(() => {
expect(mockFetchWorkflowDraft).toHaveBeenCalled()
})
await waitFor(() => {
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('new-hash-12345')
})
// Canvas SHOULD be updated in normal refresh
await waitFor(() => {
expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalled()
})
})
})
})

View File

@ -8,7 +8,7 @@ export const useWorkflowRefreshDraft = () => {
const workflowStore = useWorkflowStore()
const { handleUpdateWorkflowCanvas } = useWorkflowUpdate()
const handleRefreshWorkflowDraft = useCallback(() => {
const handleRefreshWorkflowDraft = useCallback((notUpdateCanvas?: boolean) => {
const {
appId,
setSyncWorkflowDraftHash,
@ -31,12 +31,14 @@ export const useWorkflowRefreshDraft = () => {
fetchWorkflowDraft(`/apps/${appId}/workflows/draft`)
.then((response) => {
// Ensure we have a valid workflow structure with viewport
const workflowData: WorkflowDataUpdater = {
nodes: response.graph?.nodes || [],
edges: response.graph?.edges || [],
viewport: response.graph?.viewport || { x: 0, y: 0, zoom: 1 },
if (!notUpdateCanvas) {
const workflowData: WorkflowDataUpdater = {
nodes: response.graph?.nodes || [],
edges: response.graph?.edges || [],
viewport: response.graph?.viewport || { x: 0, y: 0, zoom: 1 },
}
handleUpdateWorkflowCanvas(workflowData)
}
handleUpdateWorkflowCanvas(workflowData)
setSyncWorkflowDraftHash(response.hash)
setEnvSecrets((response.environment_variables || []).filter(env => env.value_type === 'secret').reduce((acc, env) => {
acc[env.id] = env.value

View File

@ -12,7 +12,7 @@ import Button from '@/app/components/base/button'
import Tooltip from '@/app/components/base/tooltip'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { SchemaModal } from '@/app/components/plugins/plugin-detail-panel/tool-selector/components'
import SchemaModal from '@/app/components/plugins/plugin-detail-panel/tool-selector/schema-modal'
import FormInputItem from '@/app/components/workflow/nodes/_base/components/form-input-item'
type Props = {

View File

@ -174,7 +174,7 @@ const useConfig = (id: string, payload: ToolNodeType) => {
draft.tool_configurations = getConfiguredValue(
tool_configurations,
toolSettingSchema,
) as ToolVarInputs
)
}
if (
!draft.tool_parameters
@ -183,7 +183,7 @@ const useConfig = (id: string, payload: ToolNodeType) => {
draft.tool_parameters = getConfiguredValue(
tool_parameters,
toolInputVarSchema,
) as ToolVarInputs
)
}
})
return inputsWithDefaultValue

View File

@ -12,7 +12,7 @@ import Button from '@/app/components/base/button'
import Tooltip from '@/app/components/base/tooltip'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { SchemaModal } from '@/app/components/plugins/plugin-detail-panel/tool-selector/components'
import SchemaModal from '@/app/components/plugins/plugin-detail-panel/tool-selector/schema-modal'
import FormInputItem from '@/app/components/workflow/nodes/_base/components/form-input-item'
type Props = {

View File

@ -1787,6 +1787,14 @@
"count": 1
}
},
"app/components/datasets/documents/detail/completed/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 6
},
"ts/no-explicit-any": {
"count": 1
}
},
"app/components/datasets/documents/detail/completed/new-child-segment.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -2156,6 +2164,11 @@
"count": 3
}
},
"app/components/header/account-setting/model-provider-page/utils.ts": {
"ts/no-explicit-any": {
"count": 5
}
},
"app/components/header/account-setting/plugin-page/utils.ts": {
"ts/no-explicit-any": {
"count": 4
@ -2285,6 +2298,11 @@
"count": 8
}
},
"app/components/plugins/plugin-detail-panel/app-selector/index.tsx": {
"ts/no-explicit-any": {
"count": 5
}
},
"app/components/plugins/plugin-detail-panel/datasource-action-list.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -2353,6 +2371,26 @@
"count": 2
}
},
"app/components/plugins/plugin-detail-panel/tool-selector/index.tsx": {
"ts/no-explicit-any": {
"count": 15
}
},
"app/components/plugins/plugin-detail-panel/tool-selector/reasoning-config-form.tsx": {
"ts/no-explicit-any": {
"count": 24
}
},
"app/components/plugins/plugin-detail-panel/tool-selector/tool-credentials-form.tsx": {
"ts/no-explicit-any": {
"count": 3
}
},
"app/components/plugins/plugin-detail-panel/tool-selector/tool-item.tsx": {
"ts/no-explicit-any": {
"count": 2
}
},
"app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.tsx": {
"ts/no-explicit-any": {
"count": 5
@ -2650,6 +2688,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
@ -2675,6 +2726,16 @@
"count": 4
}
},
"app/components/tools/utils/to-form-schema.ts": {
"ts/no-explicit-any": {
"count": 15
}
},
"app/components/tools/workflow-tool/index.tsx": {
"ts/no-explicit-any": {
"count": 2
}
},
"app/components/workflow-app/components/workflow-children.tsx": {
"no-console": {
"count": 1
@ -4274,6 +4335,11 @@
"count": 3
}
},
"service/tools.ts": {
"ts/no-explicit-any": {
"count": 2
}
},
"service/use-apps.ts": {
"ts/no-explicit-any": {
"count": 1

View File

@ -351,7 +351,7 @@
"modelProvider.card.quota": "حصة",
"modelProvider.card.quotaExhausted": "نفدت الحصة",
"modelProvider.card.removeKey": "إزالة مفتاح API",
"modelProvider.card.tip": "تدعم أرصدة الرسائل نماذج من {{modelNames}}. ستعطى الأولوية للحصة المدفوعة. سيتم استخدام الحصة المجانية بعد نفاد الحصة المدفوعة.",
"modelProvider.card.tip": "تدعم أرصدة الرسائل نماذج من OpenAI. ستعطى الأولوية للحصة المدفوعة. سيتم استخدام الحصة المجانية بعد نفاد الحصة المدفوعة.",
"modelProvider.card.tokens": "رموز",
"modelProvider.collapse": "طي",
"modelProvider.config": "تكوين",

View File

@ -351,7 +351,7 @@
"modelProvider.card.quota": "KONTINGENT",
"modelProvider.card.quotaExhausted": "Kontingent erschöpft",
"modelProvider.card.removeKey": "API-Schlüssel entfernen",
"modelProvider.card.tip": "Nachrichtenguthaben unterstützen Modelle von {{modelNames}}. Der bezahlten Kontingent wird Vorrang gegeben. Das kostenlose Kontingent wird nach dem Verbrauch des bezahlten Kontingents verwendet.",
"modelProvider.card.tip": "Nachrichtenguthaben unterstützen Modelle von OpenAI. Der bezahlten Kontingent wird Vorrang gegeben. Das kostenlose Kontingent wird nach dem Verbrauch des bezahlten Kontingents verwendet.",
"modelProvider.card.tokens": "Token",
"modelProvider.collapse": "Einklappen",
"modelProvider.config": "Konfigurieren",

View File

@ -351,7 +351,7 @@
"modelProvider.card.quota": "QUOTA",
"modelProvider.card.quotaExhausted": "Quota exhausted",
"modelProvider.card.removeKey": "Remove API Key",
"modelProvider.card.tip": "Message Credits supports models from {{modelNames}}. Priority will be given to the paid quota. The free quota will be used after the paid quota is exhausted.",
"modelProvider.card.tip": "Message Credits supports models from OpenAI. Priority will be given to the paid quota. The free quota will be used after the paid quota is exhausted.",
"modelProvider.card.tokens": "Tokens",
"modelProvider.collapse": "Collapse",
"modelProvider.config": "Config",

View File

@ -351,7 +351,7 @@
"modelProvider.card.quota": "CUOTA",
"modelProvider.card.quotaExhausted": "Cuota agotada",
"modelProvider.card.removeKey": "Eliminar CLAVE API",
"modelProvider.card.tip": "Créditos de mensajes admite modelos de {{modelNames}}. Se dará prioridad a la cuota pagada. La cuota gratuita se utilizará después de que se agote la cuota pagada.",
"modelProvider.card.tip": "Créditos de mensajes admite modelos de OpenAI. Se dará prioridad a la cuota pagada. La cuota gratuita se utilizará después de que se agote la cuota pagada.",
"modelProvider.card.tokens": "Tokens",
"modelProvider.collapse": "Colapsar",
"modelProvider.config": "Configurar",

View File

@ -351,7 +351,7 @@
"modelProvider.card.quota": "سهمیه",
"modelProvider.card.quotaExhausted": "سهمیه تمام شده",
"modelProvider.card.removeKey": "حذف کلید API",
"modelProvider.card.tip": "اعتبار پیام از مدل‌های {{modelNames}} پشتیبانی می‌کند. اولویت به سهمیه پرداخت شده داده می‌شود. سهمیه رایگان پس از اتمام سهمیه پرداخت شده استفاده خواهد شد.",
"modelProvider.card.tip": "اعتبار پیام از مدل‌های OpenAI پشتیبانی می‌کند. اولویت به سهمیه پرداخت شده داده می‌شود. سهمیه رایگان پس از اتمام سهمیه پرداخت شده استفاده خواهد شد.",
"modelProvider.card.tokens": "توکن‌ها",
"modelProvider.collapse": "جمع کردن",
"modelProvider.config": "پیکربندی",

View File

@ -351,7 +351,7 @@
"modelProvider.card.quota": "QUOTA",
"modelProvider.card.quotaExhausted": "Quota épuisé",
"modelProvider.card.removeKey": "Supprimer la clé API",
"modelProvider.card.tip": "Les crédits de messages prennent en charge les modèles de {{modelNames}}. La priorité sera donnée au quota payant. Le quota gratuit sera utilisé après épuisement du quota payant.",
"modelProvider.card.tip": "Les crédits de messages prennent en charge les modèles d'OpenAI. La priorité sera donnée au quota payant. Le quota gratuit sera utilisé après épuisement du quota payant.",
"modelProvider.card.tokens": "Jetons",
"modelProvider.collapse": "Effondrer",
"modelProvider.config": "Configuration",

View File

@ -351,7 +351,7 @@
"modelProvider.card.quota": "कोटा",
"modelProvider.card.quotaExhausted": "कोटा समाप्त",
"modelProvider.card.removeKey": "API कुंजी निकालें",
"modelProvider.card.tip": "संदेश क्रेडिट {{modelNames}} के मॉडल का समर्थन करते हैं। भुगतान किए गए कोटा को प्राथमिकता दी जाएगी। भुगतान किए गए कोटा के समाप्त होने के बाद मुफ्त कोटा का उपयोग किया जाएगा।",
"modelProvider.card.tip": "संदेश क्रेडिट OpenAI के मॉडल का समर्थन करते हैं। भुगतान किए गए कोटा को प्राथमिकता दी जाएगी। भुगतान किए गए कोटा के समाप्त होने के बाद मुफ्त कोटा का उपयोग किया जाएगा।",
"modelProvider.card.tokens": "टोकन",
"modelProvider.collapse": "संक्षिप्त करें",
"modelProvider.config": "कॉन्फ़िग",

View File

@ -351,7 +351,7 @@
"modelProvider.card.quota": "KUOTA",
"modelProvider.card.quotaExhausted": "Kuota habis",
"modelProvider.card.removeKey": "Menghapus Kunci API",
"modelProvider.card.tip": "Kredit pesan mendukung model dari {{modelNames}}. Prioritas akan diberikan pada kuota yang dibayarkan. Kuota gratis akan digunakan setelah kuota yang dibayarkan habis.",
"modelProvider.card.tip": "Kredit pesan mendukung model dari OpenAI. Prioritas akan diberikan pada kuota yang dibayarkan. Kuota gratis akan digunakan setelah kuota yang dibayarkan habis.",
"modelProvider.card.tokens": "Token",
"modelProvider.collapse": "Roboh",
"modelProvider.config": "Konfigurasi",

Some files were not shown because too many files have changed in this diff Show More