- {[
- { title: t(`${i18nPrefix}.whoCanInstall`, { ns: 'plugin' }), key: 'install_permission', value: tempPrivilege?.install_permission || PermissionType.noOne },
- { title: t(`${i18nPrefix}.whoCanDebug`, { ns: 'plugin' }), key: 'debug_permission', value: tempPrivilege?.debug_permission || PermissionType.noOne },
- ].map(({ title, key, value }) => (
-
-
-
- {[PermissionType.everyone, PermissionType.admin, PermissionType.noOne].map(option => (
-
handlePrivilegeChange(key)(option)}
- selected={value === option}
- className="flex-1"
- />
- ))}
+
+
+
+
+
+ {t(`${i18nPrefix}.title`, { ns: 'plugin' })}
+
+
+ {[
+ { title: t(`${i18nPrefix}.whoCanInstall`, { ns: 'plugin' }), key: 'install_permission', value: tempPrivilege?.install_permission || PermissionType.noOne },
+ { title: t(`${i18nPrefix}.whoCanDebug`, { ns: 'plugin' }), key: 'debug_permission', value: tempPrivilege?.debug_permission || PermissionType.noOne },
+ ].map(({ title, key, value }) => (
+
+
+
+ {[PermissionType.everyone, PermissionType.admin, PermissionType.noOne].map(option => (
+ handlePrivilegeChange(key)(option)}
+ selected={value === option}
+ className="flex-1"
+ />
+ ))}
+
-
- ))}
+ ))}
+
+ {
+ enable_marketplace && (
+
+ )
+ }
+
+
+
+
- {
- enable_marketplace && (
-
- )
- }
-
-
-
-
-
-
+
+
)
}
diff --git a/web/app/components/rag-pipeline/components/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/index.spec.tsx
index 0eec89b8b8..30b503899e 100644
--- a/web/app/components/rag-pipeline/components/__tests__/index.spec.tsx
+++ b/web/app/components/rag-pipeline/components/__tests__/index.spec.tsx
@@ -420,7 +420,7 @@ function getDescriptionTextarea() {
}
// Helper to find the AppIcon span in PublishAsKnowledgePipelineModal
-// HeadlessUI Dialog renders via portal to document.body, so we search the full document
+// The modal renders via portal to document.body, so we search the full document.
function getAppIcon() {
const emoji = document.querySelector('em-emoji')
return emoji?.closest('span') as HTMLElement
@@ -687,7 +687,7 @@ describe('PublishAsKnowledgePipelineModal', () => {
render(
)
// Real AppIcon renders an em-emoji custom element inside a span
- // HeadlessUI Dialog renders via portal, so search the full document
+ // The modal renders via portal, so search the full document.
expect(document.querySelector('em-emoji')).toBeInTheDocument()
})
@@ -845,7 +845,7 @@ describe('PublishAsKnowledgePipelineModal', () => {
const { rerender } = render(
)
rerender(
)
- // HeadlessUI Dialog renders via portal, so search the full document
+ // The modal renders via portal, so search the full document.
expect(document.querySelector('em-emoji')).toBeInTheDocument()
})
})
diff --git a/web/app/components/rag-pipeline/components/__tests__/publish-as-knowledge-pipeline-modal.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/publish-as-knowledge-pipeline-modal.spec.tsx
index 7a99b7ab90..e1a4af0410 100644
--- a/web/app/components/rag-pipeline/components/__tests__/publish-as-knowledge-pipeline-modal.spec.tsx
+++ b/web/app/components/rag-pipeline/components/__tests__/publish-as-knowledge-pipeline-modal.spec.tsx
@@ -17,9 +17,12 @@ vi.mock('@/app/components/workflow/store', () => ({
}),
}))
-vi.mock('@/app/components/base/modal', () => ({
- default: ({ children, isShow }: { children: React.ReactNode, isShow: boolean }) =>
- isShow ?
{children}
: null,
+vi.mock('@langgenius/dify-ui/dialog', () => ({
+ Dialog: ({ children, open }: { children: React.ReactNode, open?: boolean }) =>
+ open === false ? null : <>{children}>,
+ DialogContent: ({ children, className }: { children: React.ReactNode, className?: string }) => (
+
{children}
+ ),
}))
vi.mock('@langgenius/dify-ui/button', () => ({
diff --git a/web/app/components/rag-pipeline/components/__tests__/update-dsl-modal.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/update-dsl-modal.spec.tsx
index d31a06f421..815b21ba98 100644
--- a/web/app/components/rag-pipeline/components/__tests__/update-dsl-modal.spec.tsx
+++ b/web/app/components/rag-pipeline/components/__tests__/update-dsl-modal.spec.tsx
@@ -121,18 +121,14 @@ vi.mock('@langgenius/dify-ui/button', () => ({
),
}))
-vi.mock('@/app/components/base/modal', () => ({
- default: ({ children, isShow, _onClose, className }: PropsWithChildren<{
- isShow: boolean
- _onClose: () => void
- className?: string
- }>) => isShow
- ? (
-
- {children}
-
- )
- : null,
+vi.mock('@langgenius/dify-ui/dialog', () => ({
+ Dialog: ({ children, open }: PropsWithChildren<{ open?: boolean }>) =>
+ open === false ? null : <>{children}>,
+ DialogContent: ({ children, className }: PropsWithChildren<{ className?: string }>) => (
+
+ {children}
+
+ ),
}))
vi.mock('@/app/components/workflow/constants', () => ({
diff --git a/web/app/components/rag-pipeline/components/__tests__/version-mismatch-modal.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/version-mismatch-modal.spec.tsx
index 56ed88e2ae..b4b29fd0a8 100644
--- a/web/app/components/rag-pipeline/components/__tests__/version-mismatch-modal.spec.tsx
+++ b/web/app/components/rag-pipeline/components/__tests__/version-mismatch-modal.spec.tsx
@@ -31,13 +31,13 @@ describe('VersionMismatchModal', () => {
it('should render dialog when isShow is true', () => {
render(
)
- expect(screen.getByRole('dialog')).toBeInTheDocument()
+ expect(screen.getByRole('alertdialog')).toBeInTheDocument()
})
it('should not render dialog when isShow is false', () => {
render(
)
- expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
})
it('should render error title', () => {
diff --git a/web/app/components/rag-pipeline/components/publish-as-knowledge-pipeline-modal.tsx b/web/app/components/rag-pipeline/components/publish-as-knowledge-pipeline-modal.tsx
index faa71f0f4e..eeb6337847 100644
--- a/web/app/components/rag-pipeline/components/publish-as-knowledge-pipeline-modal.tsx
+++ b/web/app/components/rag-pipeline/components/publish-as-knowledge-pipeline-modal.tsx
@@ -2,14 +2,13 @@
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import type { IconInfo } from '@/models/datasets'
import { Button } from '@langgenius/dify-ui/button'
+import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { RiCloseLine } from '@remixicon/react'
-import { noop } from 'es-toolkit/function'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import AppIconPicker from '@/app/components/base/app-icon-picker'
import Input from '@/app/components/base/input'
-import Modal from '@/app/components/base/modal'
import Textarea from '@/app/components/base/textarea'
import { useWorkflowStore } from '@/app/components/workflow/store'
@@ -77,77 +76,76 @@ const PublishAsKnowledgePipelineModal = ({
return (
<>
-
-
- {t('common.publishAs', { ns: 'pipeline' })}
-
-
+
-
-
-
-
- {t('common.publishAsPipeline.name', { ns: 'pipeline' })}
+
+
+
+
+ {t('common.publishAsPipeline.name', { ns: 'pipeline' })}
+
+
setPipelineName(e.target.value)}
+ placeholder={t('common.publishAsPipeline.namePlaceholder', { ns: 'pipeline' }) || ''}
+ />
-
setPipelineName(e.target.value)}
- placeholder={t('common.publishAsPipeline.namePlaceholder', { ns: 'pipeline' }) || ''}
+
{ setShowAppIconPicker(true) }}
+ className="mt-2 shrink-0 cursor-pointer"
+ iconType={pipelineIcon?.icon_type}
+ icon={pipelineIcon?.icon}
+ background={pipelineIcon?.icon_background}
+ imageUrl={pipelineIcon?.icon_url}
/>
-
{ setShowAppIconPicker(true) }}
- className="mt-2 shrink-0 cursor-pointer"
- iconType={pipelineIcon?.icon_type}
- icon={pipelineIcon?.icon}
- background={pipelineIcon?.icon_background}
- imageUrl={pipelineIcon?.icon_url}
- />
-
-
-
- {t('common.publishAsPipeline.description', { ns: 'pipeline' })}
+
+
+ {t('common.publishAsPipeline.description', { ns: 'pipeline' })}
+
+
-
-
-
-
-
-
-
- {showAppIconPicker && (
-
- )}
+
+
+
+
+ {showAppIconPicker && (
+
+ )}
+
+
>
)
}
diff --git a/web/app/components/rag-pipeline/components/update-dsl-modal.tsx b/web/app/components/rag-pipeline/components/update-dsl-modal.tsx
index 742c29a6da..da75e03b5c 100644
--- a/web/app/components/rag-pipeline/components/update-dsl-modal.tsx
+++ b/web/app/components/rag-pipeline/components/update-dsl-modal.tsx
@@ -1,6 +1,7 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
+import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import {
RiAlertFill,
RiCloseLine,
@@ -9,7 +10,6 @@ import {
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import Uploader from '@/app/components/app/create-from-dsl-modal/uploader'
-import Modal from '@/app/components/base/modal'
import { useUpdateDSLModal } from '../hooks/use-update-dsl-modal'
import VersionMismatchModal from './version-mismatch-modal'
@@ -39,66 +39,71 @@ const UpdateDSLModal = ({
return (
<>
-
{
+ if (!open)
+ onCancel()
+ }}
>
-
-
{t('common.importDSL', { ns: 'workflow' })}
-
-
-
-
-
-
-
-
-
-
-
{t('common.importDSLTip', { ns: 'workflow' })}
-
-
+
+
+
+
{t('common.importDSL', { ns: 'workflow' })}
+
+
-
-
-
- {t('common.chooseDSL', { ns: 'workflow' })}
+
+
+
+
+
+
+
{t('common.importDSLTip', { ns: 'workflow' })}
+
+
+
+
-
-
+
+
+ {t('common.chooseDSL', { ns: 'workflow' })}
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
{
+ if (!open)
+ onClose()
+ }}
>
-
-
{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}
-
-
{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}
-
{t('newApp.appCreateDSLErrorPart2', { ns: 'app' })}
-
-
- {t('newApp.appCreateDSLErrorPart3', { ns: 'app' })}
- {versions?.importedVersion}
-
-
- {t('newApp.appCreateDSLErrorPart4', { ns: 'app' })}
- {versions?.systemVersion}
-
+
+
+
{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}
+
} className="flex grow flex-col system-md-regular text-text-secondary">
+
{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}
+
{t('newApp.appCreateDSLErrorPart2', { ns: 'app' })}
+
+
+ {t('newApp.appCreateDSLErrorPart3', { ns: 'app' })}
+ {versions?.importedVersion}
+
+
+ {t('newApp.appCreateDSLErrorPart4', { ns: 'app' })}
+ {versions?.systemVersion}
+
+
-
-
-
-
-
-
+
+ {t('newApp.Cancel', { ns: 'app' })}
+ {t('newApp.Confirm', { ns: 'app' })}
+
+
+
)
}
diff --git a/web/app/components/rag-pipeline/hooks/__tests__/use-update-dsl-modal.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-update-dsl-modal.spec.ts
index 2449bcc605..6876df9014 100644
--- a/web/app/components/rag-pipeline/hooks/__tests__/use-update-dsl-modal.spec.ts
+++ b/web/app/components/rag-pipeline/hooks/__tests__/use-update-dsl-modal.spec.ts
@@ -340,6 +340,7 @@ describe('useUpdateDSLModal', () => {
id: 'import-id',
status: DSLImportStatus.FAILED,
pipeline_id: 'test-pipeline-id',
+ error: 'Missing rag_pipeline data in YAML content',
})
const { result } = renderUpdateDSLModal()
@@ -351,7 +352,10 @@ describe('useUpdateDSLModal', () => {
await (result.current.handleImport as unknown as AsyncFn)()
})
- expect(toastMocks.call).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
+ expect(toastMocks.call).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'Missing rag_pipeline data in YAML content',
+ })
})
it('should notify error when importDSL throws', async () => {
@@ -369,6 +373,29 @@ describe('useUpdateDSLModal', () => {
expect(toastMocks.call).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
})
+ it('should notify response error when importDSL rejects with a response body', async () => {
+ mockImportDSL.mockRejectedValue(new Response(JSON.stringify({
+ error: 'Missing rag_pipeline data in YAML content',
+ }), {
+ status: 400,
+ headers: { 'Content-Type': 'application/json' },
+ }))
+
+ const { result } = renderUpdateDSLModal()
+ act(() => {
+ result.current.handleFile(createFile())
+ })
+
+ await act(async () => {
+ await (result.current.handleImport as unknown as AsyncFn)()
+ })
+
+ expect(toastMocks.call).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'Missing rag_pipeline data in YAML content',
+ })
+ })
+
it('should notify error when pipeline_id is missing on success', async () => {
mockImportDSL.mockResolvedValue({
id: 'import-id',
@@ -468,6 +495,7 @@ describe('useUpdateDSLModal', () => {
mockImportDSLConfirm.mockResolvedValue({
status: DSLImportStatus.FAILED,
pipeline_id: 'test-pipeline-id',
+ error: 'Import information expired or does not exist',
})
const { result } = renderUpdateDSLModal()
@@ -477,7 +505,10 @@ describe('useUpdateDSLModal', () => {
await (result.current.onUpdateDSLConfirm as unknown as AsyncFn)()
})
- expect(toastMocks.call).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
+ expect(toastMocks.call).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'Import information expired or does not exist',
+ })
})
it('should notify error when confirm throws exception', async () => {
@@ -493,6 +524,27 @@ describe('useUpdateDSLModal', () => {
expect(toastMocks.call).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
})
+ it('should notify response error when confirm rejects with a response body', async () => {
+ mockImportDSLConfirm.mockRejectedValue(new Response(JSON.stringify({
+ error: 'Import information expired or does not exist',
+ }), {
+ status: 400,
+ headers: { 'Content-Type': 'application/json' },
+ }))
+
+ const { result } = renderUpdateDSLModal()
+ await setupPendingState(result)
+
+ await act(async () => {
+ await (result.current.onUpdateDSLConfirm as unknown as AsyncFn)()
+ })
+
+ expect(toastMocks.call).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'Import information expired or does not exist',
+ })
+ })
+
it('should notify error when confirm succeeds but pipeline_id is missing', async () => {
mockImportDSLConfirm.mockResolvedValue({
status: DSLImportStatus.COMPLETED,
diff --git a/web/app/components/rag-pipeline/hooks/use-update-dsl-modal.ts b/web/app/components/rag-pipeline/hooks/use-update-dsl-modal.ts
index 7271279996..0d12a0a881 100644
--- a/web/app/components/rag-pipeline/hooks/use-update-dsl-modal.ts
+++ b/web/app/components/rag-pipeline/hooks/use-update-dsl-modal.ts
@@ -15,11 +15,36 @@ type VersionInfo = {
importedVersion: string
systemVersion: string
}
+type ImportErrorResponse = {
+ message?: unknown
+ error?: unknown
+}
type UseUpdateDSLModalParams = {
onCancel: () => void
onImport?: () => void
}
const isCompletedStatus = (status: DSLImportStatus): boolean => status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS
+const getNonEmptyString = (value: unknown): string | undefined => {
+ if (typeof value !== 'string')
+ return undefined
+
+ const trimmedValue = value.trim()
+ return trimmedValue || undefined
+}
+const getImportErrorMessage = async (error: unknown): Promise
=> {
+ if (error instanceof Response && !error.bodyUsed) {
+ try {
+ const errorData = await error.clone().json() as ImportErrorResponse
+ return getNonEmptyString(errorData.message) ?? getNonEmptyString(errorData.error)
+ }
+ catch {}
+ }
+
+ if (error instanceof Error)
+ return getNonEmptyString(error.message)
+
+ return undefined
+}
export const useUpdateDSLModal = ({ onCancel, onImport }: UseUpdateDSLModalParams) => {
const { t } = useTranslation()
const { eventEmitter } = useEventEmitterContextContext()
@@ -52,9 +77,9 @@ export const useUpdateDSLModal = ({ onCancel, onImport }: UseUpdateDSLModalParam
if (!file)
setFileContent('')
}
- const notifyError = useCallback(() => {
+ const notifyError = useCallback((message?: string) => {
setLoading(false)
- toast.error(t('common.importFailure', { ns: 'workflow' }))
+ toast.error(message || t('common.importFailure', { ns: 'workflow' }))
}, [t])
const updateWorkflow = useCallback(async (pipelineId: string) => {
const { graph, hash, rag_pipeline_variables } = await fetchWorkflowDraft(`/rag/pipelines/${pipelineId}/workflows/draft`)
@@ -117,10 +142,10 @@ export const useUpdateDSLModal = ({ onCancel, onImport }: UseUpdateDSLModalParam
else if (status === DSLImportStatus.PENDING)
showVersionMismatch(id, imported_dsl_version, current_dsl_version)
else
- notifyError()
+ notifyError(response.error)
}
- catch {
- notifyError()
+ catch (error) {
+ notifyError(await getImportErrorMessage(error))
}
isCreatingRef.current = false
}, [currentFile, fileContent, workflowStore, importDSL, completeImport, showVersionMismatch, notifyError])
@@ -128,16 +153,16 @@ export const useUpdateDSLModal = ({ onCancel, onImport }: UseUpdateDSLModalParam
if (!importId)
return
try {
- const { status, pipeline_id } = await importDSLConfirm(importId)
+ const { status, pipeline_id, error } = await importDSLConfirm(importId)
if (status === DSLImportStatus.COMPLETED) {
await completeImport(pipeline_id)
return
}
if (status === DSLImportStatus.FAILED)
- notifyError()
+ notifyError(error)
}
- catch {
- notifyError()
+ catch (error) {
+ notifyError(await getImportErrorMessage(error))
}
}, [importId, importDSLConfirm, completeImport, notifyError])
return {
diff --git a/web/app/components/share/text-generation/info-modal.tsx b/web/app/components/share/text-generation/info-modal.tsx
index 29471548ef..f3b2bef5ad 100644
--- a/web/app/components/share/text-generation/info-modal.tsx
+++ b/web/app/components/share/text-generation/info-modal.tsx
@@ -1,8 +1,8 @@
import type { SiteInfo } from '@/models/share'
import { cn } from '@langgenius/dify-ui/cn'
+import { Dialog, DialogCloseButton, DialogContent } from '@langgenius/dify-ui/dialog'
import * as React from 'react'
import AppIcon from '@/app/components/base/app-icon'
-import Modal from '@/app/components/base/modal'
import { appDefaultIconBackground } from '@/config'
type Props = {
@@ -17,37 +17,42 @@ const InfoModal = ({
data,
}: Props) => {
return (
- {
+ if (!open)
+ onClose()
+ }}
>
-
-
-
{data?.title}
-
- {/* copyright */}
- {data?.copyright && (
-
- ©
- {(new Date()).getFullYear()}
- {' '}
- {data?.copyright}
-
- )}
- {data?.custom_disclaimer && (
-
{data.custom_disclaimer}
- )}
+
+
+
+
+
+
{data?.title}
+
+ {/* copyright */}
+ {data?.copyright && (
+
+ ©
+ {(new Date()).getFullYear()}
+ {' '}
+ {data?.copyright}
+
+ )}
+ {data?.custom_disclaimer && (
+
{data.custom_disclaimer}
+ )}
+
-
-
+
+
)
}
diff --git a/web/app/components/tools/mcp/__tests__/mcp-server-modal.spec.tsx b/web/app/components/tools/mcp/__tests__/mcp-server-modal.spec.tsx
index 9029ee56a2..c496dd9adf 100644
--- a/web/app/components/tools/mcp/__tests__/mcp-server-modal.spec.tsx
+++ b/web/app/components/tools/mcp/__tests__/mcp-server-modal.spec.tsx
@@ -3,9 +3,12 @@ import type { MCPServerDetail } from '@/app/components/tools/types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
-import { describe, expect, it, vi } from 'vitest'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
import MCPServerModal from '../mcp-server-modal'
+const mockGetSocket = vi.hoisted(() => vi.fn())
+const mockSocketEmit = vi.hoisted(() => vi.fn())
+
// Mock the services
vi.mock('@/service/use-tools', () => ({
useCreateMCPServer: () => ({
@@ -19,6 +22,12 @@ vi.mock('@/service/use-tools', () => ({
useInvalidateMCPServerDetail: () => vi.fn(),
}))
+vi.mock('@/app/components/workflow/collaboration/core/websocket-manager', () => ({
+ webSocketClient: {
+ getSocket: mockGetSocket,
+ },
+}))
+
describe('MCPServerModal', () => {
const createWrapper = () => {
const queryClient = new QueryClient({
@@ -38,6 +47,11 @@ describe('MCPServerModal', () => {
onHide: vi.fn(),
}
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockGetSocket.mockReturnValue(null)
+ })
+
describe('Rendering', () => {
it('should render without crashing', () => {
render(
, { wrapper: createWrapper() })
@@ -168,6 +182,15 @@ describe('MCPServerModal', () => {
}
})
+ it('should call onHide when the dialog requests close', () => {
+ const onHide = vi.fn()
+ render(
, { wrapper: createWrapper() })
+
+ fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
+
+ expect(onHide).toHaveBeenCalledTimes(1)
+ })
+
it('should disable confirm button when description is empty', () => {
render(
, { wrapper: createWrapper() })
@@ -346,6 +369,48 @@ describe('MCPServerModal', () => {
})
})
+ it('should ignore parameters without variables when rendering and submitting', async () => {
+ const onHide = vi.fn()
+ const latestParams = [
+ { label: 'Missing variable', type: 'string' },
+ ]
+
+ render(
+
,
+ { wrapper: createWrapper() },
+ )
+
+ expect(screen.queryByText('Missing variable')).not.toBeInTheDocument()
+
+ fireEvent.change(screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder'), {
+ target: { value: 'Test description' },
+ })
+ fireEvent.click(screen.getByText('tools.mcp.server.modal.confirm'))
+
+ await waitFor(() => {
+ expect(onHide).toHaveBeenCalled()
+ })
+ })
+
+ it('should emit a created update when socket exists', async () => {
+ const onHide = vi.fn()
+ mockGetSocket.mockReturnValue({ emit: mockSocketEmit })
+
+ render(
, { wrapper: createWrapper() })
+
+ fireEvent.change(screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder'), {
+ target: { value: 'Test description' },
+ })
+ fireEvent.click(screen.getByText('tools.mcp.server.modal.confirm'))
+
+ await waitFor(() => {
+ expect(mockSocketEmit).toHaveBeenCalledWith('collaboration_event', expect.objectContaining({
+ type: 'mcp_server_update',
+ data: expect.objectContaining({ action: 'created' }),
+ }))
+ })
+ })
+
it('should handle empty description submission', async () => {
const onHide = vi.fn()
render(
, { wrapper: createWrapper() })
diff --git a/web/app/components/tools/mcp/mcp-server-modal.tsx b/web/app/components/tools/mcp/mcp-server-modal.tsx
index ab4a5c36f4..cfb33f3839 100644
--- a/web/app/components/tools/mcp/mcp-server-modal.tsx
+++ b/web/app/components/tools/mcp/mcp-server-modal.tsx
@@ -3,12 +3,11 @@ import type {
MCPServerDetail,
} from '@/app/components/tools/types'
import { Button } from '@langgenius/dify-ui/button'
-import { cn } from '@langgenius/dify-ui/cn'
+import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { RiCloseLine } from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
-import Modal from '@/app/components/base/modal'
import Textarea from '@/app/components/base/textarea'
import MCPServerParamItem from '@/app/components/tools/mcp/mcp-server-param-item'
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
@@ -20,11 +19,19 @@ import {
type ModalProps = {
appID: string
- latestParams?: any[]
+ latestParams?: MCPServerParam[]
data?: MCPServerDetail
show: boolean
onHide: () => void
- appInfo?: any
+ appInfo?: {
+ description?: string
+ }
+}
+
+type MCPServerParam = {
+ variable?: string
+ label?: string
+ type?: string
}
const MCPServerModal = ({
@@ -42,7 +49,7 @@ const MCPServerModal = ({
const defaultDescription = data?.description || appInfo?.description || ''
const [description, setDescription] = React.useState(defaultDescription)
- const [params, setParams] = React.useState(data?.parameters || {})
+ const [params, setParams] = React.useState
>(data?.parameters || {})
const handleParamChange = (variable: string, value: string) => {
setParams(prev => ({
@@ -52,10 +59,14 @@ const MCPServerModal = ({
}
const getParamValue = () => {
- const res = {} as any
- latestParams.map((param) => {
- res[param.variable] = params[param.variable]
- return param
+ const res: Record = {}
+ latestParams.forEach((param) => {
+ if (!param.variable)
+ return
+
+ const value = params[param.variable]
+ if (value !== undefined)
+ res[param.variable] = value
})
return res
}
@@ -78,7 +89,11 @@ const MCPServerModal = ({
const submit = async () => {
if (!data) {
- const payload: any = {
+ const payload: {
+ appID: string
+ description?: string
+ parameters: Record
+ } = {
appID,
parameters: getParamValue(),
}
@@ -92,13 +107,18 @@ const MCPServerModal = ({
onHide()
}
else {
- const payload: any = {
+ const payload: {
+ appID: string
+ id: string
+ description: string
+ parameters: Record
+ } = {
appID,
id: data.id,
parameters: getParamValue(),
+ description,
}
- payload.description = description
await updateMCPServer(payload)
invalidateMCPServerDetail(appID)
emitMcpServerUpdate('updated')
@@ -107,56 +127,67 @@ const MCPServerModal = ({
}
return (
- {
+ if (!open)
+ onHide()
+ }}
>
-
-
-
-
- {!data ? t('mcp.server.modal.addTitle', { ns: 'tools' }) : t('mcp.server.modal.editTitle', { ns: 'tools' })}
-
-
-
-
-
{t('mcp.server.modal.description', { ns: 'tools' })}
-
*
-
-
+
+
+
- {latestParams.length > 0 && (
-
-
-
{t('mcp.server.modal.parameters', { ns: 'tools' })}
-
-
-
{t('mcp.server.modal.parametersTip', { ns: 'tools' })}
-
- {latestParams.map(paramItem => (
-
handleParamChange(paramItem.variable, value)}
- />
- ))}
+
+ {!data ? t('mcp.server.modal.addTitle', { ns: 'tools' }) : t('mcp.server.modal.editTitle', { ns: 'tools' })}
+
+
+
+
+
{t('mcp.server.modal.description', { ns: 'tools' })}
+
*
+
- )}
-
-
-
-
-
-
+ {latestParams.length > 0 && (
+
+
+
{t('mcp.server.modal.parameters', { ns: 'tools' })}
+
+
+
{t('mcp.server.modal.parametersTip', { ns: 'tools' })}
+
+ {latestParams.map((paramItem) => {
+ if (!paramItem.variable)
+ return null
+
+ const { variable } = paramItem
+
+ return (
+ handleParamChange(variable, value)}
+ />
+ )
+ })}
+
+
+ )}
+
+
+
+
+
+
+
)
}
diff --git a/web/app/components/tools/mcp/modal.tsx b/web/app/components/tools/mcp/modal.tsx
index 165535127d..79179ae3dc 100644
--- a/web/app/components/tools/mcp/modal.tsx
+++ b/web/app/components/tools/mcp/modal.tsx
@@ -4,17 +4,15 @@ 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 { cn } from '@langgenius/dify-ui/cn'
+import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { RiCloseLine, RiEditLine } from '@remixicon/react'
import { useHover } from 'ahooks'
-import { noop } from 'es-toolkit/function'
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 Input from '@/app/components/base/input'
-import Modal from '@/app/components/base/modal'
import TabSlider from '@/app/components/base/tab-slider'
import { MCPAuthMethod } from '@/app/components/tools/types'
import { shouldUseMcpIconForAppIcon } from '@/utils/mcp'
@@ -281,18 +279,16 @@ const MCPModal: FC
= ({
const formKey = data?.id ?? 'create'
return (
-
-
-
+
)
}
diff --git a/web/app/components/workflow/__tests__/update-dsl-modal.spec.tsx b/web/app/components/workflow/__tests__/update-dsl-modal.spec.tsx
index 241cd7d762..684c700648 100644
--- a/web/app/components/workflow/__tests__/update-dsl-modal.spec.tsx
+++ b/web/app/components/workflow/__tests__/update-dsl-modal.spec.tsx
@@ -126,6 +126,15 @@ describe('UpdateDSLModal', () => {
expect(defaultProps.onBackup).toHaveBeenCalledTimes(1)
})
+ it('should call cancel handler when the import dialog requests close', () => {
+ const onCancel = vi.fn()
+ renderModal({ ...defaultProps, onCancel })
+
+ fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
+
+ expect(onCancel).toHaveBeenCalledTimes(1)
+ })
+
it('should import a valid file and emit workflow update payload', async () => {
renderModal()
@@ -228,6 +237,32 @@ describe('UpdateDSLModal', () => {
})
})
+ it('should close the pending modal when dialog requests close', async () => {
+ mockImportDSL.mockResolvedValue({
+ id: 'import-8',
+ status: DSLImportStatus.PENDING,
+ imported_dsl_version: '1.0.0',
+ current_dsl_version: '2.0.0',
+ })
+
+ renderModal()
+
+ fireEvent.change(screen.getByTestId('dsl-file-input'), {
+ target: { files: [new File(['workflow'], 'workflow.yml', { type: 'text/yaml' })] },
+ })
+ fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' }))
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: 'app.newApp.Confirm' })).toBeInTheDocument()
+ })
+
+ fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
+
+ await waitFor(() => {
+ expect(screen.queryByRole('button', { name: 'app.newApp.Confirm' })).not.toBeInTheDocument()
+ })
+ })
+
it('should show an error when the selected file content is invalid for the current app mode', async () => {
class InvalidDSLFileReader extends MockFileReader {
override readAsText(_file: Blob) {
diff --git a/web/app/components/workflow/header/online-users.tsx b/web/app/components/workflow/header/online-users.tsx
index 17e1de3feb..93e9d1fa85 100644
--- a/web/app/components/workflow/header/online-users.tsx
+++ b/web/app/components/workflow/header/online-users.tsx
@@ -189,7 +189,6 @@ const OnlineUsers = () => {
placement="bottom-start"
sideOffset={8}
alignOffset={-48}
- className="z-[9999]"
popupClassName={cn(
'mt-1.5 flex max-h-[200px] w-[240px] flex-col overflow-y-auto',
'rounded-xl border-[0.5px] border-components-panel-border',
diff --git a/web/app/components/workflow/nodes/http/components/authorization/index.tsx b/web/app/components/workflow/nodes/http/components/authorization/index.tsx
index b72e52911d..684f943d5f 100644
--- a/web/app/components/workflow/nodes/http/components/authorization/index.tsx
+++ b/web/app/components/workflow/nodes/http/components/authorization/index.tsx
@@ -4,12 +4,12 @@ import type { Authorization as AuthorizationPayloadType } from '../../types'
import type { Var } from '@/app/components/workflow/types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
+import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { produce } from 'immer'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import BaseInput from '@/app/components/base/input'
-import Modal from '@/app/components/base/modal'
import Input from '@/app/components/workflow/nodes/_base/components/input-support-select-var'
import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list'
import { VarType } from '@/app/components/workflow/types'
@@ -115,70 +115,78 @@ const Authorization: FC = ({
onHide()
}, [tempPayload, onChange, onHide])
return (
- {
+ if (!open)
+ onHide()
+ }}
>
-