mirror of
https://github.com/langgenius/dify.git
synced 2026-07-31 17:29:37 +08:00
fix(web): isolate MCP card action overlays (#39797)
This commit is contained in:
parent
95166806ae
commit
e8b17345c3
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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() })
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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()}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user