fix(web): isolate MCP card action overlays (#39797)

This commit is contained in:
Jingyi 2026-07-30 00:51:20 -07:00 committed by GitHub
parent 95166806ae
commit e8b17345c3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 435 additions and 748 deletions

View File

@ -13,6 +13,8 @@ type MockDetail = MockProvider | undefined
// Mock dependencies
const mockRefetch = vi.fn()
const mockUpdateMCP = vi.fn()
const mockDeleteMCP = vi.fn()
const mockUseAllToolProviders = vi.fn()
let mockProviders: MockProvider[] = []
let mockIsLoadingToolProviders = false
@ -29,6 +31,13 @@ vi.mock('@/service/use-tools', () => ({
refetch: mockRefetch,
}
},
useUpdateMCP: () => ({
mutateAsync: mockUpdateMCP,
}),
useDeleteMCP: () => ({
mutateAsync: mockDeleteMCP,
isPending: false,
}),
}))
vi.mock('@/context/permission-state', async () => {
@ -72,13 +81,13 @@ vi.mock('../provider-card', () => ({
default: ({
data,
handleSelect,
onUpdate,
onDeleted,
onEdit,
onDelete,
}: {
data: MockProvider
handleSelect: (id: string) => void
onUpdate: (id: string) => void
onDeleted: () => void
onEdit: (id: string) => void
onDelete: (id: string) => void
}) => {
const displayName = typeof data.name === 'string' ? data.name : Object.values(data.name)[0]
return (
@ -86,10 +95,10 @@ vi.mock('../provider-card', () => ({
<button type="button" onClick={() => handleSelect(data.id)}>
{displayName}
</button>
<button data-testid={`update-btn-${data.id}`} onClick={() => onUpdate(data.id)}>
Update
<button data-testid={`edit-btn-${data.id}`} onClick={() => onEdit(data.id)}>
Edit
</button>
<button data-testid={`delete-btn-${data.id}`} onClick={onDeleted}>
<button data-testid={`delete-btn-${data.id}`} onClick={() => onDelete(data.id)}>
Delete
</button>
</div>
@ -102,12 +111,16 @@ vi.mock('../detail/provider-detail', () => ({
detail,
onHide,
onUpdate,
onEdit,
onDelete,
isTriggerAuthorize,
onFirstCreate,
}: {
detail: MockDetail
onHide: () => void
onUpdate: () => void
onEdit: (id: string) => void
onDelete: (id: string) => void
isTriggerAuthorize: boolean
onFirstCreate: () => void
}) => {
@ -126,6 +139,12 @@ vi.mock('../detail/provider-detail', () => ({
<button data-testid="update-detail" onClick={onUpdate}>
Update List
</button>
<button data-testid="edit-detail" onClick={() => detail && onEdit(detail.id)}>
Edit
</button>
<button data-testid="delete-detail" onClick={() => detail && onDelete(detail.id)}>
Delete
</button>
<button data-testid="first-create-done" onClick={onFirstCreate}>
First Create Done
</button>
@ -134,6 +153,34 @@ vi.mock('../detail/provider-detail', () => ({
},
}))
vi.mock('../modal', () => ({
default: ({
show,
data,
onConfirm,
onHide,
}: {
show: boolean
data?: MockProvider
onConfirm: (form: { name: string; server_url: string }) => void
onHide: () => void
}) =>
show ? (
<div role="dialog" aria-label="Edit MCP">
<div>{data?.name as string}</div>
<button
type="button"
onClick={() => onConfirm({ name: 'Updated MCP', server_url: 'https://updated.com' })}
>
Save
</button>
<button type="button" onClick={onHide}>
Cancel
</button>
</div>
) : null,
}))
describe('MCPList', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -142,6 +189,8 @@ describe('MCPList', () => {
mockIsLoadingToolProviders = false
mockConsoleState.workspacePermissionKeys = ['mcp.manage']
mockRefetch.mockResolvedValue(undefined)
mockUpdateMCP.mockResolvedValue({ result: 'success' })
mockDeleteMCP.mockResolvedValue({ result: 'success' })
})
afterEach(() => {
@ -379,31 +428,49 @@ describe('MCPList', () => {
mockProviders = [{ id: '1', name: 'Provider 1', type: 'mcp' }]
})
it('should call refetch and set provider after update', async () => {
it('should open only the edit dialog when edit is selected from a card', async () => {
render(<MCPList searchText="" />)
const updateBtn = screen.getByTestId('update-btn-1')
fireEvent.click(screen.getByTestId('edit-btn-1'))
await act(async () => {
fireEvent.click(updateBtn)
vi.advanceTimersByTime(10)
await Promise.resolve()
})
expect(screen.getByRole('dialog', { name: 'Edit MCP' })).toBeInTheDocument()
expect(screen.queryByTestId('detail-panel')).not.toBeInTheDocument()
})
expect(mockRefetch).toHaveBeenCalled()
it('should replace detail with the edit dialog and restore detail on cancel', () => {
render(<MCPList searchText="" />)
fireEvent.click(screen.getByText('Provider 1'))
expect(screen.getByTestId('detail-panel')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('edit-detail'))
expect(screen.getByRole('dialog', { name: 'Edit MCP' })).toBeInTheDocument()
expect(screen.queryByTestId('detail-panel')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(screen.queryByRole('dialog', { name: 'Edit MCP' })).not.toBeInTheDocument()
expect(screen.getByTestId('detail-panel')).toBeInTheDocument()
})
it('should show detail panel with trigger authorize after update', async () => {
render(<MCPList searchText="" />)
const updateBtn = screen.getByTestId('update-btn-1')
const updateBtn = screen.getByTestId('edit-btn-1')
fireEvent.click(updateBtn)
await act(async () => {
fireEvent.click(updateBtn)
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
vi.advanceTimersByTime(10)
await Promise.resolve()
})
expect(mockUpdateMCP).toHaveBeenCalledWith({
name: 'Updated MCP',
server_url: 'https://updated.com',
provider_id: '1',
})
expect(mockRefetch).toHaveBeenCalled()
expect(screen.getByTestId('detail-panel')).toBeInTheDocument()
expect(screen.getByTestId('trigger-authorize')).toHaveTextContent('true')
})
@ -414,17 +481,62 @@ describe('MCPList', () => {
mockProviders = [{ id: '1', name: 'Provider 1', type: 'mcp' }]
})
it('should call refetch after delete', async () => {
it('should replace detail with delete confirmation and restore detail on cancel', () => {
render(<MCPList searchText="" />)
const deleteBtn = screen.getByTestId('delete-btn-1')
fireEvent.click(screen.getByText('Provider 1'))
expect(screen.getByTestId('detail-panel')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('delete-detail'))
expect(screen.getByText('tools.mcp.delete')).toBeInTheDocument()
expect(screen.queryByTestId('detail-panel')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(screen.getByTestId('detail-panel')).toBeInTheDocument()
})
it('should restore detail when the delete dialog requests close', () => {
render(<MCPList searchText="" />)
fireEvent.click(screen.getByText('Provider 1'))
fireEvent.click(screen.getByTestId('delete-detail'))
expect(screen.queryByTestId('detail-panel')).not.toBeInTheDocument()
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
expect(screen.getByTestId('detail-panel')).toBeInTheDocument()
})
it('should delete from a card without selecting it', async () => {
render(<MCPList searchText="" />)
fireEvent.click(screen.getByTestId('delete-btn-1'))
expect(screen.getByText('tools.mcp.delete')).toBeInTheDocument()
await act(async () => {
fireEvent.click(deleteBtn)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
vi.advanceTimersByTime(10)
await Promise.resolve()
})
expect(mockDeleteMCP).toHaveBeenCalledWith('1')
expect(mockRefetch).toHaveBeenCalled()
expect(screen.queryByTestId('detail-panel')).not.toBeInTheDocument()
})
it('should keep delete confirmation open when deletion fails', async () => {
mockDeleteMCP.mockResolvedValue({ result: 'error' })
render(<MCPList searchText="" />)
fireEvent.click(screen.getByTestId('delete-btn-1'))
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
await Promise.resolve()
})
expect(mockRefetch).not.toHaveBeenCalled()
expect(screen.getByText('tools.mcp.delete')).toBeInTheDocument()
})
})

View File

@ -47,6 +47,7 @@ describe('MCPModal', () => {
it('should render create title when no data is provided', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.modal.title'))!.toBeInTheDocument()
expect(screen.getByRole('dialog')).toHaveAccessibleName('tools.mcp.modal.title')
})
it('should render edit title when data is provided', () => {
@ -188,6 +189,15 @@ describe('MCPModal', () => {
expect(onHide).toHaveBeenCalled()
})
it('should call onHide when the dialog requests close', () => {
const onHide = vi.fn()
render(<MCPModal {...defaultProps} onHide={onHide} />, { wrapper: createWrapper() })
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
expect(onHide).toHaveBeenCalledTimes(1)
})
it('should have confirm button disabled when form is empty', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })

View File

@ -1,57 +1,12 @@
import type { ReactNode } from 'react'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { fireEvent, screen } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render } from '@/test/console/render'
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 OperationDropdown
type OperationDropdownProps = {
onEdit: () => void
@ -59,30 +14,44 @@ type OperationDropdownProps = {
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>
),
}))
vi.mock('../detail/operation-dropdown', async () => {
const { createPortal } = await import('react-dom')
return {
default: ({ onEdit, onRemove, onOpenChange }: OperationDropdownProps) => (
<>
<div data-testid="operation-dropdown">
<button data-testid="operation-trigger" onClick={() => onOpenChange(true)}>
<svg data-testid="operation-icon" />
</button>
</div>
{createPortal(
<>
<button
data-testid="edit-btn"
onClick={() => {
onOpenChange(false)
onEdit()
}}
>
Edit
</button>
<button
data-testid="remove-btn"
onClick={() => {
onOpenChange(false)
onRemove()
}}
>
Remove
</button>
</>,
document.body,
)}
</>
),
}
})
const mockConsoleState = vi.hoisted(() => ({
workspacePermissionKeys: ['mcp.manage'] as string[],
@ -151,20 +120,11 @@ describe('MCPCard', () => {
const defaultProps = {
data: createMockData(),
handleSelect: vi.fn(),
onUpdate: vi.fn(),
onDeleted: vi.fn(),
onEdit: vi.fn(),
onDelete: vi.fn(),
}
const getDeleteConfirmButton = () =>
screen.getByRole('button', { name: 'common.operation.confirm' })
const getDeleteCancelButton = () =>
screen.getByRole('button', { name: 'common.operation.cancel' })
beforeEach(() => {
mockUpdateMCP.mockClear()
mockDeleteMCP.mockClear()
mockUpdateMCP.mockResolvedValue({ result: 'success' })
mockDeleteMCP.mockResolvedValue({ result: 'success' })
mockConsoleState.workspacePermissionKeys = ['mcp.manage']
})
@ -264,11 +224,9 @@ describe('MCPCard', () => {
wrapper: createWrapper(),
})
const card = screen.getByText('Test MCP Server').closest('[class*="cursor-pointer"]')
if (card) {
fireEvent.click(card)
expect(handleSelect).toHaveBeenCalledWith('mcp-1')
}
fireEvent.click(screen.getByRole('button', { name: /Test MCP Server/ }))
expect(handleSelect).toHaveBeenCalledWith('mcp-1')
})
})
@ -336,185 +294,43 @@ describe('MCPCard', () => {
expect(screen.queryByTestId('operation-dropdown')).not.toBeInTheDocument()
})
it('should stop propagation when clicking on dropdown container', () => {
it('should not select the card when clicking the dropdown icon', () => {
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()
}
fireEvent.click(screen.getByTestId('operation-icon'))
expect(handleSelect).not.toHaveBeenCalled()
})
it('should request edit without selecting the card', () => {
const handleSelect = vi.fn()
const onEdit = vi.fn()
render(<MCPCard {...defaultProps} handleSelect={handleSelect} onEdit={onEdit} />, {
wrapper: createWrapper(),
})
fireEvent.click(screen.getByTestId('edit-btn'))
expect(onEdit).toHaveBeenCalledWith('mcp-1')
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()
describe('Delete Action', () => {
it('should request delete without selecting the card', () => {
const handleSelect = vi.fn()
const onDelete = vi.fn()
render(<MCPCard {...defaultProps} handleSelect={handleSelect} onDelete={onDelete} />, {
wrapper: createWrapper(),
})
// Close the modal
const closeBtn = screen.getByTestId('modal-close-btn')
fireEvent.click(closeBtn)
fireEvent.click(screen.getByTestId('remove-btn'))
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.getByText('tools.mcp.delete')).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.getByText('tools.mcp.delete')).toBeInTheDocument()
})
// Cancel
fireEvent.click(getDeleteCancelButton())
await waitFor(() => {
expect(screen.queryByText('tools.mcp.delete')).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.getByText('tools.mcp.delete')).toBeInTheDocument()
})
// Confirm delete
fireEvent.click(getDeleteConfirmButton())
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.getByText('tools.mcp.delete')).toBeInTheDocument()
})
// Confirm delete
fireEvent.click(getDeleteConfirmButton())
await waitFor(() => {
expect(mockDeleteMCP).toHaveBeenCalled()
})
// onDeleted should not be called because result is not 'success'
expect(onDeleted).not.toHaveBeenCalled()
expect(onDelete).toHaveBeenCalledWith('mcp-1')
expect(handleSelect).not.toHaveBeenCalled()
})
})
})

View File

@ -10,8 +10,6 @@ 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 mockInvalidateAllMCPTools = vi.fn()
const mockOpenOAuthPopup = vi.fn()
@ -44,12 +42,6 @@ vi.mock('@/service/use-tools', () => ({
mutateAsync: mockAuthorizeMcp,
isPending: mockIsAuthorizing,
}),
useUpdateMCP: () => ({
mutateAsync: mockUpdateMCP,
}),
useDeleteMCP: () => ({
mutateAsync: mockDeleteMCP,
}),
}))
// Mock OAuth hook
@ -58,37 +50,6 @@ 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 OperationDropdown
vi.mock('../operation-dropdown', () => ({
default: ({ onEdit, onRemove }: { onEdit: () => void; onRemove: () => void }) => (
@ -179,6 +140,8 @@ describe('MCPDetailContent', () => {
const defaultProps = {
detail: createMockDetail(),
onUpdate: vi.fn(),
onEdit: vi.fn(),
onDelete: vi.fn(),
onHide: vi.fn(),
isTriggerAuthorize: false,
onFirstCreate: vi.fn(),
@ -188,8 +151,6 @@ describe('MCPDetailContent', () => {
// Reset mocks
mockUpdateTools.mockClear()
mockAuthorizeMcp.mockClear()
mockUpdateMCP.mockClear()
mockDeleteMCP.mockClear()
mockInvalidateMCPTools.mockClear()
mockInvalidateAllMCPTools.mockClear()
mockOpenOAuthPopup.mockClear()
@ -197,8 +158,6 @@ describe('MCPDetailContent', () => {
// Reset mock return values
mockUpdateTools.mockResolvedValue({})
mockAuthorizeMcp.mockResolvedValue({ result: 'success' })
mockUpdateMCP.mockResolvedValue({ result: 'success' })
mockDeleteMCP.mockResolvedValue({ result: 'success' })
// Reset state
mockToolsData = { tools: [] }
@ -500,170 +459,29 @@ describe('MCPDetailContent', () => {
})
})
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} />, {
describe('Edit MCP Flow', () => {
it('should request editing the current provider', () => {
const onEdit = vi.fn()
render(<MCPDetailContent {...defaultProps} onEdit={onEdit} />, {
wrapper: createWrapper(),
})
// Open modal
const editBtn = screen.getByTestId('edit-btn')
fireEvent.click(editBtn)
fireEvent.click(screen.getByTestId('edit-btn'))
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()
expect(onEdit).toHaveBeenCalledWith('mcp-1')
})
})
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.getByText('tools.mcp.delete'))!.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.getByText('tools.mcp.delete'))!.toBeInTheDocument()
})
// Cancel
fireEvent.click(getCancelButton())
await waitFor(() => {
expect(screen.queryByText('tools.mcp.delete')).not.toBeInTheDocument()
})
})
it('should call deleteMCP when delete is confirmed', async () => {
const onUpdate = vi.fn()
render(<MCPDetailContent {...defaultProps} onUpdate={onUpdate} />, {
describe('Delete MCP Action', () => {
it('should request delete for the current provider', () => {
const onDelete = vi.fn()
render(<MCPDetailContent {...defaultProps} onDelete={onDelete} />, {
wrapper: createWrapper(),
})
// Open confirm
const removeBtn = screen.getByTestId('remove-btn')
fireEvent.click(removeBtn)
fireEvent.click(screen.getByTestId('remove-btn'))
await waitFor(() => {
expect(screen.getByText('tools.mcp.delete'))!.toBeInTheDocument()
})
// Confirm delete
fireEvent.click(getConfirmButton())
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.getByText('tools.mcp.delete'))!.toBeInTheDocument()
})
// Confirm delete
fireEvent.click(getConfirmButton())
await waitFor(() => {
expect(mockDeleteMCP).toHaveBeenCalled()
})
expect(onUpdate).not.toHaveBeenCalled()
expect(onDelete).toHaveBeenCalledWith('mcp-1')
})
})

View File

@ -123,10 +123,9 @@ describe('OperationDropdown', () => {
describe('Rendering', () => {
it('should render trigger button with more icon', () => {
render(<OperationDropdown {...defaultProps} />)
const button = screen.getByTestId('dropdown-trigger')
const button = screen.getByRole('button', { name: 'common.operation.more' })
expect(button).toBeInTheDocument()
const svg = button?.querySelector('svg')
expect(svg).toBeInTheDocument()
expect(button.querySelector('.i-ri-more-fill')).toBeInTheDocument()
})
it('should render medium size by default', () => {

View File

@ -1,5 +1,5 @@
'use client'
import type { ComponentProps, FC } from 'react'
import type { FC } from 'react'
import type { ToolWithProvider } from '../../../workflow/types'
import {
AlertDialog,
@ -17,7 +17,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/too
import { useBoolean } from 'ahooks'
import copy from 'copy-to-clipboard'
import * as React from 'react'
import { useCallback, useEffect } from 'react'
import { useCallback, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import Icon from '@/app/components/plugins/card/base/card-icon'
@ -25,34 +25,30 @@ import { useCanManageMCP } from '@/app/components/tools/hooks/use-tool-permissio
import { openOAuthPopup } from '@/hooks/use-oauth'
import {
useAuthorizeMCP,
useDeleteMCP,
useInvalidateAllMCPTools,
useInvalidateMCPTools,
useMCPTools,
useUpdateMCP,
useUpdateMCPTools,
} from '@/service/use-tools'
import MCPModal from '../modal'
import ListLoading from './list-loading'
import OperationDropdown from './operation-dropdown'
import ToolItem from './tool-item'
type Props = Readonly<{
detail: ToolWithProvider
onUpdate: (isDelete?: boolean) => void
onUpdate: () => void
onEdit: (providerID: string) => void
onDelete: (providerID: string) => void
onHide: () => void
isTriggerAuthorize: boolean
onFirstCreate: () => void
}>
type MCPModalConfirmPayload = Parameters<ComponentProps<typeof MCPModal>['onConfirm']>[0]
type MutationResult = {
result?: string
}
const MCPDetailContent: FC<Props> = ({
detail,
onUpdate,
onEdit,
onDelete,
onHide,
isTriggerAuthorize,
onFirstCreate,
@ -89,16 +85,7 @@ const MCPDetailContent: FC<Props> = ({
updateTools,
])
const { mutateAsync: updateMCP } = useUpdateMCP({})
const { mutateAsync: deleteMCP } = useDeleteMCP({})
const [isShowUpdateModal, { setTrue: showUpdateModal, setFalse: hideUpdateModal }] =
useBoolean(false)
const [isShowDeleteConfirm, { setTrue: showDeleteConfirm, setFalse: hideDeleteConfirm }] =
useBoolean(false)
const [deleting, { setTrue: showDeleting, setFalse: hideDeleting }] = useBoolean(false)
const hasTriggeredAuthorizeRef = useRef(false)
const handleOAuthCallback = useCallback(() => {
if (!canManageMCP) return
@ -131,36 +118,12 @@ const MCPDetailContent: FC<Props> = ({
onUpdate,
])
const handleUpdate = useCallback(
async (data: MCPModalConfirmPayload) => {
if (!canManageMCP || !detail) return
const res = (await updateMCP({
...data,
provider_id: detail.id,
})) as MutationResult
if (res.result === 'success') {
hideUpdateModal()
onUpdate()
handleAuthorize()
}
},
[canManageMCP, detail, updateMCP, hideUpdateModal, onUpdate, handleAuthorize],
)
const handleDelete = useCallback(async () => {
if (!canManageMCP || !detail) return
showDeleting()
const res = (await deleteMCP(detail.id)) as MutationResult
hideDeleting()
if (res.result === 'success') {
hideDeleteConfirm()
onUpdate(true)
}
}, [canManageMCP, detail, showDeleting, deleteMCP, hideDeleting, hideDeleteConfirm, onUpdate])
useEffect(() => {
if (isTriggerAuthorize) handleAuthorize()
}, [])
if (!isTriggerAuthorize || hasTriggeredAuthorizeRef.current) return
hasTriggeredAuthorizeRef.current = true
handleAuthorize()
}, [handleAuthorize, isTriggerAuthorize])
if (!detail) return null
const identifierLabel = t(($) => $['mcp.identifier'], { ns: 'tools' })
@ -215,7 +178,10 @@ const MCPDetailContent: FC<Props> = ({
</div>
<div className="flex gap-1">
{canManageMCP && (
<OperationDropdown onEdit={showUpdateModal} onRemove={showDeleteConfirm} />
<OperationDropdown
onEdit={() => onEdit(detail.id)}
onRemove={() => onDelete(detail.id)}
/>
)}
<ActionButton
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
@ -336,37 +302,6 @@ const MCPDetailContent: FC<Props> = ({
</div>
)}
</div>
{canManageMCP && isShowUpdateModal && (
<MCPModal
data={detail}
show={isShowUpdateModal}
onConfirm={handleUpdate}
onHide={hideUpdateModal}
/>
)}
<AlertDialog
open={canManageMCP && isShowDeleteConfirm}
onOpenChange={(open) => !open && hideDeleteConfirm()}
>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t(($) => $['mcp.delete'], { ns: 'tools' })}
</AlertDialogTitle>
<div className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t(($) => $['mcp.deleteConfirmTitle'], { ns: 'tools', mcp: detail.name })}
</div>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton loading={deleting} disabled={deleting} onClick={handleDelete}>
{t(($) => $['operation.confirm'], { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
<AlertDialog
open={canManageMCP && isShowUpdateConfirm}
onOpenChange={(open) => !open && hideUpdateConfirm()}

View File

@ -7,7 +7,6 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { RiDeleteBinLine, RiEditLine, RiMoreFill } from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
@ -26,14 +25,18 @@ const OperationDropdown: FC<Props> = ({ inCard, onOpenChange, onEdit, onRemove }
<DropdownMenu onOpenChange={onOpenChange}>
<DropdownMenuTrigger
render={
<ActionButton size={inCard ? 'l' : 'm'} className="data-popup-open:bg-state-base-hover" />
<ActionButton
size={inCard ? 'l' : 'm'}
aria-label={t(($) => $['operation.more'], { ns: 'common' })}
className="data-popup-open:bg-state-base-hover"
/>
}
>
<RiMoreFill className={cn('size-4', inCard && 'size-5')} />
<span aria-hidden className={cn('i-ri-more-fill size-4', inCard && 'size-5')} />
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-[160px]">
<DropdownMenuItem onClick={onEdit}>
<RiEditLine className="size-4 shrink-0 text-text-tertiary" />
<span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" />
<div className="ml-2 system-md-regular text-text-secondary">
{t(($) => $['mcp.operation.edit'], { ns: 'tools' })}
</div>
@ -42,7 +45,7 @@ const OperationDropdown: FC<Props> = ({ inCard, onOpenChange, onEdit, onRemove }
className="data-highlighted:bg-state-destructive-hover data-highlighted:text-text-destructive"
onClick={onRemove}
>
<RiDeleteBinLine className="size-4 shrink-0 text-inherit" />
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0 text-inherit" />
<div className="ml-2 system-md-regular text-inherit">
{t(($) => $['mcp.operation.remove'], { ns: 'tools' })}
</div>

View File

@ -16,6 +16,8 @@ import MCPDetailContent from './content'
type Props = Readonly<{
detail?: ToolWithProvider
onUpdate: () => void
onEdit: (providerID: string) => void
onDelete: (providerID: string) => void
onHide: () => void
isTriggerAuthorize: boolean
onFirstCreate: () => void
@ -24,15 +26,12 @@ type Props = Readonly<{
const MCPDetailPanel: FC<Props> = ({
detail,
onUpdate,
onEdit,
onDelete,
onHide,
isTriggerAuthorize,
onFirstCreate,
}) => {
const handleUpdate = (isDelete = false) => {
if (isDelete) onHide()
onUpdate()
}
if (!detail) return null
return (
@ -57,7 +56,9 @@ const MCPDetailPanel: FC<Props> = ({
<MCPDetailContent
detail={detail}
onHide={onHide}
onUpdate={handleUpdate}
onUpdate={onUpdate}
onEdit={onEdit}
onDelete={onDelete}
isTriggerAuthorize={isTriggerAuthorize}
onFirstCreate={onFirstCreate}
/>

View File

@ -1,15 +1,27 @@
'use client'
import type { ComponentProps } from 'react'
import type { ToolsContentInset } from '../content-inset'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { cn } from '@langgenius/dify-ui/cn'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
import { useCanManageMCP } from '@/app/components/tools/hooks/use-tool-permissions'
import ToolCardSkeletonGrid from '@/app/components/tools/provider/tool-card-skeleton'
import { useAllToolProviders } from '@/service/use-tools'
import { useAllToolProviders, useDeleteMCP, useUpdateMCP } from '@/service/use-tools'
import { toolsContentInsetClassNames, toolsUnifiedContentFrameClassName } from '../content-inset'
import NewMCPCard from './create-card'
import MCPDetailPanel from './detail/provider-detail'
import MCPModal from './modal'
import MCPCard from './provider-card'
type Props = Readonly<{
@ -20,6 +32,11 @@ type Props = Readonly<{
showCreateCard?: boolean
}>
type MCPModalConfirmPayload = Parameters<ComponentProps<typeof MCPModal>['onConfirm']>[0]
type MutationResult = {
result?: string
}
const MCPList = ({
searchText,
contentInset = 'default',
@ -27,6 +44,7 @@ const MCPList = ({
onCreatedProviderHandled,
showCreateCard = true,
}: Props) => {
const { t } = useTranslation()
const canManageMCP = useCanManageMCP()
const { data: list = [] as ToolWithProvider[], isLoading, refetch } = useAllToolProviders()
const [isTriggerAuthorize, setIsTriggerAuthorize] = useState<boolean>(false)
@ -43,10 +61,15 @@ const MCPList = ({
}, [list, searchText])
const [currentProviderID, setCurrentProviderID] = useState<string>()
const [editingProviderID, setEditingProviderID] = useState<string>()
const [deletingProviderID, setDeletingProviderID] = useState<string>()
const currentProvider = useMemo(() => {
return list.find((provider) => provider.id === currentProviderID)
}, [list, currentProviderID])
const currentProvider = list.find((provider) => provider.id === currentProviderID)
const editingProvider = list.find((provider) => provider.id === editingProviderID)
const deletingProvider = list.find((provider) => provider.id === deletingProviderID)
const detailProvider = editingProvider || deletingProvider ? undefined : currentProvider
const { mutateAsync: updateMCP } = useUpdateMCP({})
const { mutateAsync: deleteMCP, isPending: isDeleting } = useDeleteMCP({})
const handleCreate = async (provider: ToolWithProvider) => {
if (!canManageMCP) return
@ -80,12 +103,42 @@ const MCPList = ({
}
}, [canManageMCP, createdProviderId, onCreatedProviderHandled, refetch])
const handleUpdate = async (providerID: string) => {
const handleEdit = (providerID: string) => {
if (!canManageMCP) return
setEditingProviderID(providerID)
}
const handleEditConfirm = async (form: MCPModalConfirmPayload) => {
if (!canManageMCP || !editingProvider) return
const res = (await updateMCP({
...form,
provider_id: editingProvider.id,
})) as MutationResult
if (res.result !== 'success') return
await refetch() // update list
setCurrentProviderID(providerID)
setCurrentProviderID(editingProvider.id)
setIsTriggerAuthorize(true)
setEditingProviderID(undefined)
}
const handleDelete = (providerID: string) => {
if (!canManageMCP) return
setDeletingProviderID(providerID)
}
const handleDeleteConfirm = async () => {
if (!canManageMCP || !deletingProvider) return
const res = (await deleteMCP(deletingProvider.id)) as MutationResult
if (res.result !== 'success') return
await refetch()
setCurrentProviderID(undefined)
setDeletingProviderID(undefined)
}
const contentPaddingClassName = toolsContentInsetClassNames[contentInset]
const contentFrameClassName = cn(contentPaddingClassName, toolsUnifiedContentFrameClassName)
@ -111,24 +164,60 @@ const MCPList = ({
>
<MCPCard
data={provider}
currentProvider={currentProvider as ToolWithProvider}
currentProvider={detailProvider as ToolWithProvider}
handleSelect={setCurrentProviderID}
onUpdate={handleUpdate}
onDeleted={refetch}
onEdit={handleEdit}
onDelete={handleDelete}
/>
</div>
))
)}
</div>
{currentProvider && (
{detailProvider && (
<MCPDetailPanel
detail={currentProvider as ToolWithProvider}
detail={detailProvider as ToolWithProvider}
onHide={() => setCurrentProviderID(undefined)}
onUpdate={refetch}
onEdit={handleEdit}
onDelete={handleDelete}
isTriggerAuthorize={isTriggerAuthorize}
onFirstCreate={() => setIsTriggerAuthorize(false)}
/>
)}
{editingProvider && (
<MCPModal
data={editingProvider as ToolWithProvider}
show
onConfirm={handleEditConfirm}
onHide={() => setEditingProviderID(undefined)}
/>
)}
{deletingProvider && (
<AlertDialog open onOpenChange={(open) => !open && setDeletingProviderID(undefined)}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t(($) => $['mcp.delete'], { ns: 'tools' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t(($) => $['mcp.deleteConfirmTitle'], { ns: 'tools', mcp: deletingProvider.name })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
loading={isDeleting}
disabled={isDeleting}
onClick={handleDeleteConfirm}
>
{t(($) => $['operation.confirm'], { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
)}
</>
)
}

View File

@ -4,18 +4,16 @@ 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 { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { Input } from '@langgenius/dify-ui/input'
import { SegmentedControl, SegmentedControlItem } from '@langgenius/dify-ui/segmented-control'
import { Switch } from '@langgenius/dify-ui/switch'
import { toast } from '@langgenius/dify-ui/toast'
import { RiCloseLine, RiEditLine } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useHover } from 'ahooks'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import AppIconPicker from '@/app/components/base/app-icon-picker'
import { Mcp } from '@/app/components/base/icons/src/vender/other'
import { MCPAuthMethod } from '@/app/components/tools/types'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { shouldUseMcpIconForAppIcon } from '@/utils/mcp'
@ -150,13 +148,13 @@ const MCPModalContent: FC<MCPModalContentProps> = ({ data, onConfirm, onHide })
className="absolute top-5 right-5 z-10 cursor-pointer border-none bg-transparent p-1.5 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
onClick={onHide}
>
<RiCloseLine className="size-5 text-text-tertiary" aria-hidden="true" />
<span aria-hidden className="i-ri-close-line size-5 text-text-tertiary" />
</button>
<div className="relative pb-3 title-2xl-semi-bold text-xl text-text-primary">
<DialogTitle className="relative pb-3 title-2xl-semi-bold text-xl text-text-primary">
{!isCreate
? t(($) => $['mcp.modal.editTitle'], { ns: 'tools' })
: t(($) => $['mcp.modal.title'], { ns: 'tools' })}
</div>
</DialogTitle>
<div className="space-y-5 py-3">
{/* Server URL */}
@ -206,7 +204,10 @@ const MCPModalContent: FC<MCPModalContentProps> = ({ data, onConfirm, onHide })
state.appIcon.type,
state.appIcon.type === 'emoji' ? state.appIcon.icon : '',
) ? (
<Mcp className="size-8 text-text-primary-on-surface" />
<span
aria-hidden
className="i-custom-vender-other-mcp size-8 text-text-primary-on-surface"
/>
) : undefined
}
size="xxl"
@ -214,7 +215,10 @@ const MCPModalContent: FC<MCPModalContentProps> = ({ data, onConfirm, onHide })
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" />
<span
aria-hidden
className="i-ri-edit-line size-6 text-text-primary-on-surface"
/>
</div>
) : null
}
@ -355,7 +359,12 @@ const MCPModal: FC<DuplicateAppModalProps> = ({ data, show, onConfirm, onHide })
const formKey = data?.id ?? 'create'
return (
<Dialog open={show}>
<Dialog
open={show}
onOpenChange={(open) => {
if (!open) onHide()
}}
>
<DialogContent className="w-full max-w-130! border-none p-6 text-left align-middle">
<MCPModalContent key={formKey} data={data} onConfirm={onConfirm} onHide={onHide} />
</DialogContent>

View File

@ -1,44 +1,23 @@
'use client'
import type { ComponentProps, KeyboardEvent, MouseEvent } from 'react'
import type { ToolWithProvider } from '../../workflow/types'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { cn } from '@langgenius/dify-ui/cn'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { useBoolean } from 'ahooks'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Icon from '@/app/components/plugins/card/base/card-icon'
import { useCanManageMCP } from '@/app/components/tools/hooks/use-tool-permissions'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { useDeleteMCP, useUpdateMCP } from '@/service/use-tools'
import OperationDropdown from './detail/operation-dropdown'
import MCPModal from './modal'
type Props = Readonly<{
currentProvider?: ToolWithProvider
data: ToolWithProvider
handleSelect: (providerID: string) => void
onUpdate: (providerID: string) => void
onDeleted: () => void
onEdit: (providerID: string) => void
onDelete: (providerID: string) => void
}>
type MCPModalConfirmPayload = Parameters<ComponentProps<typeof MCPModal>['onConfirm']>[0]
type MutationResult = {
result?: string
}
const isMCPCardActionTarget = (target: EventTarget | null) => {
return target instanceof HTMLElement && Boolean(target.closest('[data-mcp-card-action]'))
}
const MCPCard = ({ currentProvider, data, onUpdate, handleSelect, onDeleted }: Props) => {
const MCPCard = ({ currentProvider, data, onEdit, onDelete, handleSelect }: Props) => {
const { t } = useTranslation()
const { formatTimeFromNow } = useFormatTimeFromNow()
const canManageMCP = useCanManageMCP()
@ -47,175 +26,91 @@ const MCPCard = ({ currentProvider, data, onUpdate, handleSelect, onDeleted }: P
? `${t(($) => $['mcp.updateTime'], { ns: 'tools' })} ${formatTimeFromNow(data.updated_at * 1000)}`
: undefined
const { mutateAsync: updateMCP } = useUpdateMCP({})
const { mutateAsync: deleteMCP } = useDeleteMCP({})
const [isOperationShow, setIsOperationShow] = useState(false)
const [isShowUpdateModal, { setTrue: showUpdateModal, setFalse: hideUpdateModal }] =
useBoolean(false)
const [isShowDeleteConfirm, { setTrue: showDeleteConfirm, setFalse: hideDeleteConfirm }] =
useBoolean(false)
const [deleting, { setTrue: showDeleting, setFalse: hideDeleting }] = useBoolean(false)
const handleUpdate = useCallback(
async (form: MCPModalConfirmPayload) => {
if (!canManageMCP) return
const res = (await updateMCP({
...form,
provider_id: data.id,
})) as MutationResult
if (res.result === 'success') {
hideUpdateModal()
onUpdate(data.id)
}
},
[canManageMCP, data, updateMCP, hideUpdateModal, onUpdate],
)
const handleDelete = useCallback(async () => {
if (!canManageMCP) return
showDeleting()
const res = (await deleteMCP(data.id)) as MutationResult
hideDeleting()
if (res.result === 'success') {
hideDeleteConfirm()
onDeleted()
}
}, [canManageMCP, showDeleting, deleteMCP, data.id, hideDeleting, hideDeleteConfirm, onDeleted])
const handleSelectProvider = useCallback(() => {
handleSelect(data.id)
}, [data.id, handleSelect])
const handleCardClick = useCallback(
(event: MouseEvent<HTMLDivElement>) => {
if (isMCPCardActionTarget(event.target)) return
handleSelectProvider()
},
[handleSelectProvider],
)
const handleCardKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => {
if (isMCPCardActionTarget(event.target)) return
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
handleSelectProvider()
},
[handleSelectProvider],
)
return (
<div
role="button"
tabIndex={0}
onClick={handleCardClick}
onKeyDown={handleCardKeyDown}
className={cn(
'group relative flex cursor-pointer flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg shadow-xs hover:bg-components-panel-on-panel-item-bg-hover',
'group relative flex flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg shadow-xs hover:bg-components-panel-on-panel-item-bg-hover',
currentProvider?.id === data.id &&
'border-components-option-card-option-selected-border bg-components-panel-on-panel-item-bg-hover',
)}
>
<div className="flex shrink-0 items-center gap-3 rounded-t-xl p-4">
<div className="shrink-0 overflow-hidden rounded-lg border-[0.5px] border-effects-icon-border">
<Icon src={data.icon} />
</div>
<div className="min-w-0 grow">
<div className="mb-1 truncate system-md-semibold text-text-secondary" title={data.name}>
{data.name}
<button
type="button"
aria-haspopup="dialog"
aria-expanded={currentProvider?.id === data.id}
onClick={handleSelectProvider}
className="flex w-full cursor-pointer flex-col rounded-xl text-left outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:ring-inset"
>
<div className="flex shrink-0 items-center gap-3 rounded-t-xl p-4">
<div className="shrink-0 overflow-hidden rounded-lg border-[0.5px] border-effects-icon-border">
<Icon src={data.icon} />
</div>
<div
className="truncate system-xs-regular text-text-tertiary"
title={data.server_identifier}
>
{data.server_identifier}
<div className="min-w-0 grow">
<div className="mb-1 truncate system-md-semibold text-text-secondary" title={data.name}>
{data.name}
</div>
<div
className="truncate system-xs-regular text-text-tertiary"
title={data.server_identifier}
>
{data.server_identifier}
</div>
</div>
</div>
</div>
<div className="flex items-center gap-1 rounded-b-xl pt-1.5 pr-2.5 pb-2.5 pl-4">
<div className="flex w-0 grow items-center gap-2">
{data.tools.length > 0 && (
<div className="shrink-0 system-xs-regular text-text-tertiary">
{t(($) => $['mcp.toolsCount'], { ns: 'tools', count: data.tools.length })}
</div>
)}
{!data.tools.length && (
<div className="shrink-0 system-xs-regular text-text-tertiary">
{t(($) => $['mcp.noTools'], { ns: 'tools' })}
</div>
)}
{updatedAtText && (
<>
<div className="system-xs-regular text-divider-deep">·</div>
<div className="truncate system-xs-regular text-text-tertiary" title={updatedAtText}>
{updatedAtText}
<div className="flex items-center gap-1 rounded-b-xl pt-1.5 pr-2.5 pb-2.5 pl-4">
<div className="flex w-0 grow items-center gap-2">
{data.tools.length > 0 && (
<div className="shrink-0 system-xs-regular text-text-tertiary">
{t(($) => $['mcp.toolsCount'], { ns: 'tools', count: data.tools.length })}
</div>
</>
)}
{!data.tools.length && (
<div className="shrink-0 system-xs-regular text-text-tertiary">
{t(($) => $['mcp.noTools'], { ns: 'tools' })}
</div>
)}
{updatedAtText && (
<>
<div className="system-xs-regular text-divider-deep">·</div>
<div
className="truncate system-xs-regular text-text-tertiary"
title={updatedAtText}
>
{updatedAtText}
</div>
</>
)}
</div>
{isConfigured && <StatusDot status="success" size="small" className="shrink-0" />}
{!isConfigured && (
<div className="flex shrink-0 items-center gap-1 rounded-md border border-util-colors-red-red-500 bg-components-badge-bg-red-soft px-1.5 py-0.5 system-xs-medium text-util-colors-red-red-500">
{t(($) => $['mcp.noConfigured'], { ns: 'tools' })}
<StatusDot status="error" size="small" />
</div>
)}
</div>
{isConfigured && <StatusDot status="success" size="small" className="shrink-0" />}
{!isConfigured && (
<div className="flex shrink-0 items-center gap-1 rounded-md border border-util-colors-red-red-500 bg-components-badge-bg-red-soft px-1.5 py-0.5 system-xs-medium text-util-colors-red-red-500">
{t(($) => $['mcp.noConfigured'], { ns: 'tools' })}
<StatusDot status="error" size="small" />
</div>
)}
</div>
</button>
{canManageMCP && (
<div
data-mcp-card-action
className={cn(
'absolute top-2.5 right-2.5 hidden group-hover:block',
'absolute top-2.5 right-2.5 z-10 hidden group-focus-within:block group-hover:block',
isOperationShow && 'block',
)}
>
<OperationDropdown
inCard
onOpenChange={setIsOperationShow}
onEdit={showUpdateModal}
onRemove={showDeleteConfirm}
onEdit={() => onEdit(data.id)}
onRemove={() => onDelete(data.id)}
/>
</div>
)}
{canManageMCP && isShowUpdateModal && (
<MCPModal
data={data}
show={isShowUpdateModal}
onConfirm={handleUpdate}
onHide={hideUpdateModal}
/>
)}
<AlertDialog
open={canManageMCP && isShowDeleteConfirm}
onOpenChange={(open) => !open && hideDeleteConfirm()}
>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t(($) => $['mcp.delete'], { ns: 'tools' })}
</AlertDialogTitle>
<div className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t(($) => $['mcp.deleteConfirmTitle'], { ns: 'tools', mcp: data.name })}
</div>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton loading={deleting} disabled={deleting} onClick={handleDelete}>
{t(($) => $['operation.confirm'], { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</div>
)
}