feat: refactor modals to use Dialog component and add tests for ApiKeyModal and ProviderConfigModal (#35550)

Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Coding On Star 2026-04-24 16:43:03 +08:00 committed by GitHub
parent c3aebb8403
commit 7002512106
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 769 additions and 119 deletions

View File

@ -124,11 +124,6 @@
"count": 1
}
},
"web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-config-modal.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-panel.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -3248,14 +3243,6 @@
"count": 2
}
},
"web/app/components/plugins/plugin-auth/authorize/api-key-modal.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 2
}
},
"web/app/components/plugins/plugin-auth/authorize/index.tsx": {
"no-restricted-imports": {
"count": 1

View File

@ -0,0 +1,346 @@
import type { AliyunConfig, ArizeConfig, DatabricksConfig, LangFuseConfig, LangSmithConfig, MLflowConfig, OpikConfig, PhoenixConfig, TencentConfig, WeaveConfig } from '../type'
import { toast } from '@langgenius/dify-ui/toast'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { addTracingConfig, removeTracingConfig, updateTracingConfig } from '@/service/apps'
import ConfigBtn from '../config-button'
import ProviderConfigModal from '../provider-config-modal'
import { TracingProvider } from '../type'
vi.mock('@/service/apps', () => ({
addTracingConfig: vi.fn(),
removeTracingConfig: vi.fn(),
updateTracingConfig: vi.fn(),
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: vi.fn(),
}))
type ProviderPayload = AliyunConfig | ArizeConfig | DatabricksConfig | LangFuseConfig | LangSmithConfig | MLflowConfig | OpikConfig | PhoenixConfig | TencentConfig | WeaveConfig
const validConfigs = {
[TracingProvider.arize]: {
api_key: 'arize-api-key',
space_id: 'space-id',
project: 'arize-project',
endpoint: 'https://otlp.arize.com',
},
[TracingProvider.phoenix]: {
api_key: 'phoenix-api-key',
project: 'phoenix-project',
endpoint: 'https://app.phoenix.arize.com',
},
[TracingProvider.langSmith]: {
api_key: 'langsmith-api-key',
project: 'langsmith-project',
endpoint: 'https://api.smith.langchain.com',
},
[TracingProvider.langfuse]: {
public_key: 'public-key',
secret_key: 'secret-key',
host: 'https://cloud.langfuse.com',
},
[TracingProvider.opik]: {
api_key: 'opik-api-key',
project: 'opik-project',
workspace: 'default',
url: 'https://www.comet.com/opik/api/',
},
[TracingProvider.weave]: {
api_key: 'weave-api-key',
entity: 'wandb-entity',
project: 'weave-project',
endpoint: 'https://trace.wandb.ai/',
host: 'https://api.wandb.ai',
},
[TracingProvider.aliyun]: {
app_name: 'aliyun-app',
license_key: 'license-key',
endpoint: 'https://tracing.arms.aliyuncs.com',
},
[TracingProvider.mlflow]: {
tracking_uri: 'http://localhost:5000',
experiment_id: 'experiment-id',
username: 'mlflow-user',
password: 'mlflow-password',
},
[TracingProvider.databricks]: {
experiment_id: 'experiment-id',
host: 'https://workspace.cloud.databricks.com',
client_id: 'client-id',
client_secret: 'client-secret',
personal_access_token: 'personal-access-token',
},
[TracingProvider.tencent]: {
token: 'tencent-token',
endpoint: 'https://your-region.cls.tencentcs.com',
service_name: 'dify_app',
},
} satisfies Record<TracingProvider, ProviderPayload>
const providerFieldLabels = [
[TracingProvider.arize, ['API Key', 'Space ID', 'app.tracing.configProvider.project', 'Endpoint']],
[TracingProvider.phoenix, ['API Key', 'app.tracing.configProvider.project', 'Endpoint']],
[TracingProvider.langSmith, ['API Key', 'app.tracing.configProvider.project', 'Endpoint']],
[TracingProvider.langfuse, ['app.tracing.configProvider.secretKey', 'app.tracing.configProvider.publicKey', 'Host']],
[TracingProvider.opik, ['API Key', 'app.tracing.configProvider.project', 'Workspace', 'Url']],
[TracingProvider.weave, ['API Key', 'app.tracing.configProvider.project', 'Entity', 'Endpoint', 'Host']],
[TracingProvider.aliyun, ['License Key', 'Endpoint', 'App Name']],
[TracingProvider.mlflow, ['app.tracing.configProvider.trackingUri', 'app.tracing.configProvider.experimentId', 'app.tracing.configProvider.username', 'app.tracing.configProvider.password']],
[TracingProvider.databricks, ['app.tracing.configProvider.experimentId', 'app.tracing.configProvider.databricksHost', 'app.tracing.configProvider.clientId', 'app.tracing.configProvider.clientSecret', 'app.tracing.configProvider.personalAccessToken']],
[TracingProvider.tencent, ['Token', 'Endpoint', 'Service Name']],
] as const
const invalidConfigCases: Array<{
provider: TracingProvider
payload: ProviderPayload
missingField: string
}> = [
{ provider: TracingProvider.arize, payload: { ...validConfigs[TracingProvider.arize], api_key: '' }, missingField: 'API Key' },
{ provider: TracingProvider.arize, payload: { ...validConfigs[TracingProvider.arize], space_id: '' }, missingField: 'Space ID' },
{ provider: TracingProvider.arize, payload: { ...validConfigs[TracingProvider.arize], project: '' }, missingField: 'app.tracing.configProvider.project' },
{ provider: TracingProvider.phoenix, payload: { ...validConfigs[TracingProvider.phoenix], api_key: '' }, missingField: 'API Key' },
{ provider: TracingProvider.phoenix, payload: { ...validConfigs[TracingProvider.phoenix], project: '' }, missingField: 'app.tracing.configProvider.project' },
{ provider: TracingProvider.langSmith, payload: { ...validConfigs[TracingProvider.langSmith], api_key: '' }, missingField: 'API Key' },
{ provider: TracingProvider.langSmith, payload: { ...validConfigs[TracingProvider.langSmith], project: '' }, missingField: 'app.tracing.configProvider.project' },
{ provider: TracingProvider.langfuse, payload: { ...validConfigs[TracingProvider.langfuse], secret_key: '' }, missingField: 'app.tracing.configProvider.secretKey' },
{ provider: TracingProvider.langfuse, payload: { ...validConfigs[TracingProvider.langfuse], public_key: '' }, missingField: 'app.tracing.configProvider.publicKey' },
{ provider: TracingProvider.langfuse, payload: { ...validConfigs[TracingProvider.langfuse], host: '' }, missingField: 'Host' },
{ provider: TracingProvider.weave, payload: { ...validConfigs[TracingProvider.weave], api_key: '' }, missingField: 'API Key' },
{ provider: TracingProvider.weave, payload: { ...validConfigs[TracingProvider.weave], project: '' }, missingField: 'app.tracing.configProvider.project' },
{ provider: TracingProvider.aliyun, payload: { ...validConfigs[TracingProvider.aliyun], app_name: '' }, missingField: 'App Name' },
{ provider: TracingProvider.aliyun, payload: { ...validConfigs[TracingProvider.aliyun], license_key: '' }, missingField: 'License Key' },
{ provider: TracingProvider.aliyun, payload: { ...validConfigs[TracingProvider.aliyun], endpoint: '' }, missingField: 'Endpoint' },
{ provider: TracingProvider.mlflow, payload: { ...validConfigs[TracingProvider.mlflow], tracking_uri: '' }, missingField: 'Tracking URI' },
{ provider: TracingProvider.databricks, payload: { ...validConfigs[TracingProvider.databricks], experiment_id: '' }, missingField: 'Experiment ID' },
{ provider: TracingProvider.databricks, payload: { ...validConfigs[TracingProvider.databricks], host: '' }, missingField: 'Host' },
{ provider: TracingProvider.tencent, payload: { ...validConfigs[TracingProvider.tencent], token: '' }, missingField: 'Token' },
{ provider: TracingProvider.tencent, payload: { ...validConfigs[TracingProvider.tencent], endpoint: '' }, missingField: 'Endpoint' },
{ provider: TracingProvider.tencent, payload: { ...validConfigs[TracingProvider.tencent], service_name: '' }, missingField: 'Service Name' },
]
const renderConfigButton = () => {
return render(
<ConfigBtn
appId="app-id"
readOnly={false}
hasConfigured={false}
enabled={false}
onStatusChange={vi.fn()}
chosenProvider={null}
onChooseProvider={vi.fn()}
arizeConfig={null}
phoenixConfig={null}
langSmithConfig={null}
langFuseConfig={null}
opikConfig={null}
weaveConfig={null}
aliyunConfig={null}
mlflowConfig={null}
databricksConfig={null}
tencentConfig={null}
onConfigUpdated={vi.fn()}
onConfigRemoved={vi.fn()}
>
<button type="button">Open tracing</button>
</ConfigBtn>,
)
}
const renderProviderConfigModal = ({
type = TracingProvider.langfuse,
payload,
}: {
type?: TracingProvider
payload?: ProviderPayload | null
} = {}) => {
const callbacks = {
onCancel: vi.fn(),
onSaved: vi.fn(),
onChosen: vi.fn(),
onRemoved: vi.fn(),
}
render(
<ProviderConfigModal
appId="app-id"
type={type}
payload={payload}
onCancel={callbacks.onCancel}
onSaved={callbacks.onSaved}
onChosen={callbacks.onChosen}
onRemoved={callbacks.onRemoved}
/>,
)
return callbacks
}
describe('ProviderConfigModal', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(addTracingConfig).mockResolvedValue({ result: 'success' })
vi.mocked(updateTracingConfig).mockResolvedValue({ result: 'success' })
vi.mocked(removeTracingConfig).mockResolvedValue({ result: 'success' })
})
describe('Nested Overlay Behavior', () => {
it('should keep the provider config modal open when clicking inside it', async () => {
const user = userEvent.setup()
renderConfigButton()
await user.click(screen.getByRole('button', { name: 'Open tracing' }))
await waitFor(() => {
expect(screen.getByText('app.tracing.tracing')).toBeInTheDocument()
})
const configActions = screen.getAllByText('app.tracing.config')
expect(configActions.length).toBeGreaterThan(0)
await user.click(configActions[0]!)
await waitFor(() => {
expect(screen.getByText('app.tracing.configProvider.titleapp.tracing.langfuse.title')).toBeInTheDocument()
})
expect(screen.getByRole('dialog')).toBeInTheDocument()
await user.click(screen.getByPlaceholderText('https://cloud.langfuse.com'))
expect(screen.getByText('app.tracing.tracing')).toBeInTheDocument()
expect(screen.getByText('app.tracing.configProvider.titleapp.tracing.langfuse.title')).toBeInTheDocument()
})
})
describe('Rendering', () => {
it.each(providerFieldLabels)('should render %s fields when adding a provider', (provider, expectedLabels) => {
renderProviderConfigModal({ type: provider })
expect(screen.getByText(`app.tracing.configProvider.titleapp.tracing.${provider}.title`)).toBeInTheDocument()
expectedLabels.forEach((label) => {
expect(screen.getByText(label)).toBeInTheDocument()
})
expect(screen.getByRole('button', { name: 'common.operation.saveAndEnable' })).toBeInTheDocument()
})
})
describe('Saving', () => {
it('should add and choose the provider when saving a new config', async () => {
const user = userEvent.setup()
const callbacks = renderProviderConfigModal({ type: TracingProvider.langfuse })
const textboxes = screen.getAllByRole('textbox')
await user.type(textboxes[0]!, 'secret-key')
await user.type(textboxes[1]!, 'public-key')
await user.type(textboxes[2]!, 'https://cloud.langfuse.com')
await user.click(screen.getByRole('button', { name: 'common.operation.saveAndEnable' }))
await waitFor(() => {
expect(addTracingConfig).toHaveBeenCalledWith({
appId: 'app-id',
body: {
tracing_provider: TracingProvider.langfuse,
tracing_config: validConfigs[TracingProvider.langfuse],
},
})
})
expect(callbacks.onSaved).toHaveBeenCalledWith(validConfigs[TracingProvider.langfuse])
expect(callbacks.onChosen).toHaveBeenCalledWith(TracingProvider.langfuse)
expect(toast).toHaveBeenCalledWith('common.api.success', { type: 'success' })
})
it.each(Object.values(TracingProvider))('should update valid %s config in edit mode', async (provider) => {
const user = userEvent.setup()
const callbacks = renderProviderConfigModal({
type: provider,
payload: validConfigs[provider],
})
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
await waitFor(() => {
expect(updateTracingConfig).toHaveBeenCalledWith({
appId: 'app-id',
body: {
tracing_provider: provider,
tracing_config: validConfigs[provider],
},
})
})
expect(callbacks.onSaved).toHaveBeenCalledWith(validConfigs[provider])
expect(callbacks.onChosen).not.toHaveBeenCalled()
})
it.each(invalidConfigCases)('should reject $provider config when $missingField is missing', async ({ provider, payload, missingField }) => {
const user = userEvent.setup()
renderProviderConfigModal({
type: provider,
payload,
})
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(updateTracingConfig).not.toHaveBeenCalled()
expect(toast).toHaveBeenCalledWith(
expect.stringContaining(missingField),
{ type: 'error' },
)
})
})
describe('Closing And Removing', () => {
it('should cancel when the cancel button is clicked', async () => {
const user = userEvent.setup()
const callbacks = renderProviderConfigModal()
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(callbacks.onCancel).toHaveBeenCalledTimes(1)
})
it('should cancel when the dialog is closed with Escape', async () => {
const user = userEvent.setup()
const callbacks = renderProviderConfigModal()
await user.keyboard('{Escape}')
await waitFor(() => {
expect(callbacks.onCancel).toHaveBeenCalledTimes(1)
})
})
it('should remove an existing provider after confirmation', async () => {
const user = userEvent.setup()
const callbacks = renderProviderConfigModal({
type: TracingProvider.langfuse,
payload: validConfigs[TracingProvider.langfuse],
})
await user.click(screen.getByRole('button', { name: 'common.operation.remove' }))
expect(screen.getByText('app.tracing.configProvider.removeConfirmTitle:{"key":"app.tracing.langfuse.title"}')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
await waitFor(() => {
expect(removeTracingConfig).toHaveBeenCalledWith({
appId: 'app-id',
provider: TracingProvider.langfuse,
})
})
expect(callbacks.onRemoved).toHaveBeenCalledTimes(1)
expect(toast).toHaveBeenCalledWith('common.api.remove', { type: 'success' })
})
it('should return to the edit dialog when remove confirmation is canceled', async () => {
const user = userEvent.setup()
renderProviderConfigModal({
type: TracingProvider.langfuse,
payload: validConfigs[TracingProvider.langfuse],
})
await user.click(screen.getByRole('button', { name: 'common.operation.remove' }))
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(removeTracingConfig).not.toHaveBeenCalled()
expect(screen.getByText('app.tracing.configProvider.titleapp.tracing.langfuse.title')).toBeInTheDocument()
})
})
})

View File

@ -11,6 +11,10 @@ import {
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import {
Dialog,
DialogContent,
} from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { useBoolean } from 'ahooks'
import * as React from 'react'
@ -19,10 +23,6 @@ import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import { LinkExternal02 } from '@/app/components/base/icons/src/vender/line/general'
import { Lock01 } from '@/app/components/base/icons/src/vender/solid/security'
import {
PortalToFollowElem,
PortalToFollowElemContent,
} from '@/app/components/base/portal-to-follow-elem'
import { addTracingConfig, removeTracingConfig, updateTracingConfig } from '@/service/apps'
import { docURL } from './config'
import Field from './field'
@ -153,7 +153,11 @@ const ProviderConfigModal: FC<Props> = ({
return weaveConfigTemplate
})())
const [isShowRemoveConfirm, {
const [isConfigDialogOpen, {
set: setIsConfigDialogOpen,
}] = useBoolean(true)
const [isRemoveDialogOpen, {
set: setIsRemoveDialogOpen,
setTrue: showRemoveConfirm,
setFalse: hideRemoveConfirm,
}] = useBoolean(false)
@ -291,13 +295,24 @@ const ProviderConfigModal: FC<Props> = ({
}
}, [appId, checkValid, config, isAdd, isEdit, isSaving, onChosen, onSaved, t, type])
// Defer onCancel to onOpenChangeComplete so the dialog's exit animation
// (scale/opacity transition) can finish before the parent unmounts this modal.
const handleConfigDialogOpenChangeComplete = useCallback((open: boolean) => {
if (!open)
onCancel()
}, [onCancel])
return (
<>
{!isShowRemoveConfirm
{!isRemoveDialogOpen
? (
<PortalToFollowElem open>
<PortalToFollowElemContent className="z-60 h-full w-full">
<div className="fixed inset-0 flex items-center justify-center bg-background-overlay">
<Dialog
open={isConfigDialogOpen}
onOpenChange={setIsConfigDialogOpen}
onOpenChangeComplete={handleConfigDialogOpenChangeComplete}
>
<DialogContent className="w-auto max-w-[calc(100vw-1rem)] overflow-visible border-none bg-transparent p-0 shadow-none">
<div className="flex items-center justify-center">
<div className="mx-2 max-h-[calc(100vh-120px)] w-[640px] overflow-y-auto rounded-2xl bg-components-panel-bg shadow-xl">
<div className="px-8 pt-8">
<div className="mb-4 flex items-center justify-between">
@ -650,7 +665,7 @@ const ProviderConfigModal: FC<Props> = ({
)}
<Button
className="mr-2 h-9 text-sm font-medium text-text-secondary"
onClick={onCancel}
onClick={() => setIsConfigDialogOpen(false)}
>
{t('operation.cancel', { ns: 'common' })}
</Button>
@ -683,11 +698,11 @@ const ProviderConfigModal: FC<Props> = ({
</div>
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</DialogContent>
</Dialog>
)
: (
<AlertDialog open onOpenChange={open => !open && hideRemoveConfirm()}>
<AlertDialog open={isRemoveDialogOpen} onOpenChange={setIsRemoveDialogOpen}>
<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">

View File

@ -5,11 +5,29 @@ import AddApiKeyButton from '../add-api-key-button'
let _mockModalOpen = false
vi.mock('../api-key-modal', () => ({
default: ({ onClose, onUpdate }: { onClose: () => void, onUpdate?: () => void }) => {
_mockModalOpen = true
default: ({
open = true,
onClose,
onOpenChange,
onUpdate,
}: {
open?: boolean
onClose: () => void
onOpenChange?: (open: boolean) => void
onUpdate?: () => void
}) => {
_mockModalOpen = open
if (!open)
return null
const handleClose = () => {
onOpenChange?.(false)
onClose()
}
return (
<div data-testid="api-key-modal">
<button data-testid="modal-close" onClick={onClose}>Close</button>
<button data-testid="modal-close" onClick={handleClose}>Close</button>
<button data-testid="modal-update" onClick={onUpdate}>Update</button>
</div>
)

View File

@ -1,5 +1,8 @@
import type { ApiKeyModalProps } from '../api-key-modal'
import type { FormSchema } from '@/app/components/base/form/types'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AuthCategory } from '../../types'
@ -20,17 +23,27 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
}))
const mockAddPluginCredential = vi.fn().mockResolvedValue({})
const mockUpdatePluginCredential = vi.fn().mockResolvedValue({})
const mockFormValues = { isCheckValidated: true, values: { __name__: 'My Key', api_key: 'sk-123' } }
const defaultCredentialSchemas = [
{ name: 'api_key', label: 'API Key', type: 'secret-input', required: true },
]
type MockFormValues = {
isCheckValidated: boolean
values: Record<string, unknown>
}
const defaultFormValues: MockFormValues = { isCheckValidated: true, values: { __name__: 'My Key', api_key: 'sk-123' } }
let mockCredentialSchemas = defaultCredentialSchemas
let mockIsSchemaLoading = false
let mockFormValues = defaultFormValues
const mockAuthFormProps = vi.fn()
vi.mock('../../hooks/use-credential', () => ({
useAddPluginCredentialHook: () => ({
mutateAsync: mockAddPluginCredential,
}),
useGetPluginCredentialSchemaHook: () => ({
data: [
{ name: 'api_key', label: 'API Key', type: 'secret-input', required: true },
],
isLoading: false,
data: mockCredentialSchemas,
isLoading: mockIsSchemaLoading,
}),
useUpdatePluginCredentialHook: () => ({
mutateAsync: mockUpdatePluginCredential,
@ -49,36 +62,19 @@ vi.mock('@/app/components/base/encrypted-bottom', () => ({
EncryptedBottom: () => <div data-testid="encrypted-bottom" />,
}))
vi.mock('@/app/components/base/modal/modal', () => ({
default: ({ children, title, onClose, onConfirm, onExtraButtonClick, showExtraButton, disabled }: {
children: React.ReactNode
title: string
onClose?: () => void
onCancel?: () => void
onConfirm?: () => void
onExtraButtonClick?: () => void
showExtraButton?: boolean
disabled?: boolean
[key: string]: unknown
}) => (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
{children}
<button data-testid="modal-confirm" onClick={onConfirm} disabled={disabled}>Confirm</button>
<button data-testid="modal-close" onClick={onClose}>Close</button>
{showExtraButton && <button data-testid="modal-extra" onClick={onExtraButtonClick}>Remove</button>}
</div>
),
}))
vi.mock('@/app/components/base/form/form-scenarios/auth', () => ({
default: React.forwardRef((_props: Record<string, unknown>, ref: React.Ref<unknown>) => {
vi.mock('@/app/components/base/form/form-scenarios/auth', () => {
const MockAuthForm = ({ ref, ...props }: { ref?: React.Ref<unknown> } & Record<string, unknown>) => {
mockAuthFormProps(props)
React.useImperativeHandle(ref, () => ({
getFormValues: () => mockFormValues,
}))
return <div data-testid="auth-form" />
}),
}))
}
return {
default: MockAuthForm,
}
})
vi.mock('@/app/components/base/form/types', () => ({
FormTypeEnum: { textInput: 'text-input' },
@ -89,11 +85,73 @@ const basePayload = {
provider: 'test-provider',
}
const PopoverModalHarness = ({
ApiKeyModal,
onClose,
onPopoverClose,
}: {
ApiKeyModal: React.FC<ApiKeyModalProps>
onClose: () => void
onPopoverClose: () => void
}) => {
const [open, setOpen] = React.useState(true)
return (
<Popover
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen)
if (!nextOpen)
onPopoverClose()
}}
>
<PopoverTrigger render={<button type="button">Credentials</button>} />
<PopoverContent>
<div data-testid="credential-popover">
<ApiKeyModal
open={open}
onOpenChange={setOpen}
pluginPayload={basePayload}
onClose={onClose}
/>
</div>
</PopoverContent>
</Popover>
)
}
const ControlledModalHarness = ({
ApiKeyModal,
onClose,
}: {
ApiKeyModal: React.FC<ApiKeyModalProps>
onClose: () => void
}) => {
const [open, setOpen] = React.useState(true)
return (
<>
<div data-testid="modal-open-state">{String(open)}</div>
<ApiKeyModal
open={open}
onOpenChange={setOpen}
pluginPayload={basePayload}
onClose={onClose}
/>
</>
)
}
describe('ApiKeyModal', () => {
let ApiKeyModal: React.FC<ApiKeyModalProps>
beforeEach(async () => {
vi.clearAllMocks()
mockCredentialSchemas = defaultCredentialSchemas
mockIsSchemaLoading = false
mockFormValues = defaultFormValues
mockAddPluginCredential.mockResolvedValue({})
mockUpdatePluginCredential.mockResolvedValue({})
const mod = await import('../api-key-modal')
ApiKeyModal = mod.default
})
@ -110,6 +168,56 @@ describe('ApiKeyModal', () => {
expect(screen.getByTestId('auth-form')).toBeInTheDocument()
})
it('should prefer formSchemas prop and apply schema defaults', () => {
const customSchemas: FormSchema[] = [
{
name: 'custom_api_key',
label: 'Custom API Key',
type: 'secret-input' as FormSchema['type'],
required: true,
default: 'default-key',
},
]
render(<ApiKeyModal pluginPayload={basePayload} formSchemas={customSchemas} />)
expect(mockAuthFormProps).toHaveBeenCalledWith(expect.objectContaining({
formSchemas: expect.arrayContaining([
expect.objectContaining({ name: 'custom_api_key' }),
]),
defaultValues: expect.objectContaining({
custom_api_key: 'default-key',
}),
}))
})
it('should not render auth form when credential schema is empty', () => {
mockCredentialSchemas = []
render(<ApiKeyModal pluginPayload={basePayload} />)
expect(screen.queryByTestId('auth-form')).not.toBeInTheDocument()
})
it('should not submit when form ref is unavailable', () => {
mockCredentialSchemas = []
render(<ApiKeyModal pluginPayload={basePayload} />)
fireEvent.click(screen.getByTestId('modal-confirm'))
expect(mockAddPluginCredential).not.toHaveBeenCalled()
})
it('should disable actions while loading credential schema', () => {
mockIsSchemaLoading = true
render(<ApiKeyModal pluginPayload={basePayload} />)
expect(screen.queryByTestId('auth-form')).not.toBeInTheDocument()
expect(screen.getByTestId('modal-confirm')).toBeDisabled()
})
it('should show remove button when editValues is provided', () => {
render(<ApiKeyModal pluginPayload={basePayload} editValues={{ api_key: 'existing' }} />)
@ -130,6 +238,18 @@ describe('ApiKeyModal', () => {
expect(mockOnClose).toHaveBeenCalled()
})
it('should close through controlled open state when cancel is clicked', async () => {
const mockOnClose = vi.fn()
render(<ControlledModalHarness ApiKeyModal={ApiKeyModal} onClose={mockOnClose} />)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
await waitFor(() => {
expect(screen.getByTestId('modal-open-state')).toHaveTextContent('false')
})
expect(mockOnClose).toHaveBeenCalled()
})
it('should call addPluginCredential on confirm in add mode', async () => {
const mockOnClose = vi.fn()
const mockOnUpdate = vi.fn()
@ -145,6 +265,50 @@ describe('ApiKeyModal', () => {
})
})
it('should use empty credential name when authorization name is blank in add mode', async () => {
mockFormValues = { isCheckValidated: true, values: { api_key: 'sk-123' } }
render(<ApiKeyModal pluginPayload={basePayload} />)
fireEvent.click(screen.getByTestId('modal-confirm'))
await waitFor(() => {
expect(mockAddPluginCredential).toHaveBeenCalledWith(expect.objectContaining({
name: '',
}))
})
})
it('should not submit when form validation fails', () => {
mockFormValues = { isCheckValidated: false, values: {} }
render(<ApiKeyModal pluginPayload={basePayload} />)
fireEvent.click(screen.getByTestId('modal-confirm'))
expect(mockAddPluginCredential).not.toHaveBeenCalled()
expect(mockUpdatePluginCredential).not.toHaveBeenCalled()
})
it('should ignore repeated confirm while an action is in progress', async () => {
let repeatedClickTriggered = false
mockAddPluginCredential.mockImplementationOnce(async () => {
if (!repeatedClickTriggered) {
repeatedClickTriggered = true
fireEvent.click(screen.getByTestId('modal-confirm'))
}
return {}
})
render(<ApiKeyModal pluginPayload={basePayload} />)
fireEvent.click(screen.getByTestId('modal-confirm'))
await waitFor(() => {
expect(mockAddPluginCredential).toHaveBeenCalledTimes(1)
})
})
it('should call updatePluginCredential on confirm in edit mode', async () => {
render(<ApiKeyModal pluginPayload={basePayload} editValues={{ api_key: 'existing', __credential_id__: 'cred-1' }} />)
@ -155,6 +319,20 @@ describe('ApiKeyModal', () => {
})
})
it('should use empty credential name when authorization name is blank in edit mode', async () => {
mockFormValues = { isCheckValidated: true, values: { api_key: 'updated', __credential_id__: 'cred-1' } }
render(<ApiKeyModal pluginPayload={basePayload} editValues={{ api_key: 'existing', __credential_id__: 'cred-1' }} />)
fireEvent.click(screen.getByTestId('modal-confirm'))
await waitFor(() => {
expect(mockUpdatePluginCredential).toHaveBeenCalledWith(expect.objectContaining({
name: '',
}))
})
})
it('should call onRemove when remove button clicked', () => {
const mockOnRemove = vi.fn()
render(<ApiKeyModal pluginPayload={basePayload} editValues={{ api_key: 'existing' }} onRemove={mockOnRemove} />)
@ -163,6 +341,49 @@ describe('ApiKeyModal', () => {
expect(mockOnRemove).toHaveBeenCalled()
})
it('should stay open when clicking inside the modal from a popover', async () => {
// Use userEvent instead of fireEvent to avoid CI flakiness: userEvent
// awaits React act() between pointer/mouse/click so base-ui's dialog
// popup ref is guaranteed committed before outside-click detection runs.
const user = userEvent.setup()
const mockOnClose = vi.fn()
const mockOnPopoverClose = vi.fn()
render(
<PopoverModalHarness
ApiKeyModal={ApiKeyModal}
onClose={mockOnClose}
onPopoverClose={mockOnPopoverClose}
/>,
)
const form = await screen.findByTestId('auth-form')
await user.click(form)
expect(mockOnClose).not.toHaveBeenCalled()
expect(mockOnPopoverClose).not.toHaveBeenCalled()
expect(screen.getByTestId('modal')).toBeInTheDocument()
})
it('should close on backdrop click through controlled open state', async () => {
const mockOnClose = vi.fn()
render(<ControlledModalHarness ApiKeyModal={ApiKeyModal} onClose={mockOnClose} />)
const backdrop = document.querySelector('.bg-background-overlay')
if (!backdrop)
throw new Error('Expected dialog backdrop to render')
fireEvent.pointerDown(backdrop)
fireEvent.mouseDown(backdrop)
fireEvent.click(backdrop)
await waitFor(() => {
expect(screen.getByTestId('modal-open-state')).toHaveTextContent('false')
})
expect(mockOnClose).toHaveBeenCalled()
})
it('should render readme entrance when detail is provided', () => {
const payload = { ...basePayload, detail: { name: 'Test' } as never }
render(<ApiKeyModal pluginPayload={payload} />)

View File

@ -25,20 +25,26 @@ const AddApiKeyButton = ({
formSchemas = [],
}: AddApiKeyButtonProps) => {
const [isApiKeyModalOpen, setIsApiKeyModalOpen] = useState(false)
const [isApiKeyModalMounted, setIsApiKeyModalMounted] = useState(false)
return (
<>
<Button
className="w-full"
variant={buttonVariant}
onClick={() => setIsApiKeyModalOpen(true)}
onClick={() => {
setIsApiKeyModalMounted(true)
setIsApiKeyModalOpen(true)
}}
disabled={disabled}
>
{buttonText}
</Button>
{
isApiKeyModalOpen && (
isApiKeyModalMounted && (
<ApiKeyModal
open={isApiKeyModalOpen}
onOpenChange={setIsApiKeyModalOpen}
pluginPayload={pluginPayload}
onClose={() => setIsApiKeyModalOpen(false)}
onUpdate={onUpdate}

View File

@ -3,6 +3,8 @@ import type {
FormRefObject,
FormSchema,
} from '@/app/components/base/form/types'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import {
memo,
@ -16,7 +18,6 @@ import { EncryptedBottom } from '@/app/components/base/encrypted-bottom'
import AuthForm from '@/app/components/base/form/form-scenarios/auth'
import { FormTypeEnum } from '@/app/components/base/form/types'
import Loading from '@/app/components/base/loading'
import Modal from '@/app/components/base/modal/modal'
import { ReadmeEntrance } from '../../readme-panel/entrance'
import { ReadmeShowType } from '../../readme-panel/store'
import {
@ -28,8 +29,10 @@ import { CredentialTypeEnum } from '../types'
export type ApiKeyModalProps = {
pluginPayload: PluginPayload
open?: boolean
onOpenChange?: (open: boolean) => void
onClose?: () => void
editValues?: Record<string, any>
editValues?: Record<string, unknown>
onRemove?: () => void
disabled?: boolean
onUpdate?: () => void
@ -37,6 +40,8 @@ export type ApiKeyModalProps = {
}
const ApiKeyModal = ({
pluginPayload,
open = true,
onOpenChange,
onClose,
editValues,
onRemove,
@ -73,7 +78,7 @@ const ApiKeyModal = ({
if (schema.default)
acc[schema.name] = schema.default
return acc
}, {} as Record<string, any>)
}, {} as Record<string, unknown>)
const { mutateAsync: addPluginCredential } = useAddPluginCredentialHook(pluginPayload)
const { mutateAsync: updatePluginCredential } = useUpdatePluginCredentialHook(pluginPayload)
const formRef = useRef<FormRefObject>(null)
@ -114,53 +119,102 @@ const ApiKeyModal = ({
}
toast.success(t('api.actionSuccess', { ns: 'common' }))
onOpenChange?.(false)
onClose?.()
onUpdate?.()
}
finally {
handleSetDoingAction(false)
}
}, [addPluginCredential, onClose, onUpdate, updatePluginCredential, t, editValues, handleSetDoingAction])
}, [addPluginCredential, onClose, onOpenChange, onUpdate, updatePluginCredential, t, editValues, handleSetDoingAction])
const isDisabled = disabled || isLoading || doingAction
const handleOpenChange = useCallback((nextOpen: boolean) => {
onOpenChange?.(nextOpen)
if (!nextOpen)
onClose?.()
}, [onClose, onOpenChange])
return (
<Modal
size="md"
title={t('auth.useApiAuth', { ns: 'plugin' })}
subTitle={t('auth.useApiAuthDesc', { ns: 'plugin' })}
onClose={onClose}
onCancel={onClose}
footerSlot={
(<div></div>)
}
bottomSlot={<EncryptedBottom />}
onConfirm={handleConfirm}
showExtraButton={!!editValues}
onExtraButtonClick={onRemove}
disabled={disabled || isLoading || doingAction}
clickOutsideNotClose={true}
wrapperClassName="z-1002!"
<Dialog
open={open}
onOpenChange={handleOpenChange}
>
{pluginPayload.detail && (
<ReadmeEntrance pluginDetail={pluginPayload.detail} showType={ReadmeShowType.modal} />
)}
{
isLoading && (
<div className="flex h-40 items-center justify-center">
<Loading />
<DialogContent className="w-[640px]! max-w-[calc(100vw-2rem)]! p-0!">
<div data-testid="modal" className="flex max-h-[80dvh] flex-col">
<div className="relative shrink-0 p-6 pr-14 pb-3">
<DialogTitle data-testid="modal-title" className="title-2xl-semi-bold text-text-primary">
{t('auth.useApiAuth', { ns: 'plugin' })}
</DialogTitle>
<div className="mt-1 system-xs-regular text-text-tertiary">
{t('auth.useApiAuthDesc', { ns: 'plugin' })}
</div>
<DialogCloseButton
data-testid="modal-close"
className="top-5 right-5 h-8 w-8 rounded-lg"
/>
</div>
)
}
{
!isLoading && !!mergedData.length && (
<AuthForm
ref={formRef}
formSchemas={formSchemas}
defaultValues={editValues || defaultValues}
disabled={disabled}
/>
)
}
</Modal>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-3">
{pluginPayload.detail && (
<ReadmeEntrance pluginDetail={pluginPayload.detail} showType={ReadmeShowType.modal} />
)}
{
isLoading && (
<div className="flex h-40 items-center justify-center">
<Loading />
</div>
)
}
{
!isLoading && !!mergedData.length && (
<AuthForm
ref={formRef}
formSchemas={formSchemas}
defaultValues={editValues || defaultValues}
disabled={disabled}
/>
)
}
</div>
<div className="flex shrink-0 justify-between p-6 pt-5">
<div />
<div className="flex items-center">
{editValues && (
<>
<Button
data-testid="modal-extra"
variant="primary"
onClick={onRemove}
disabled={isDisabled}
>
{t('operation.remove', { ns: 'common' })}
</Button>
<div className="mx-3 h-4 w-px bg-divider-regular"></div>
</>
)}
<Button
onClick={() => handleOpenChange(false)}
disabled={isDisabled}
>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button
data-testid="modal-confirm"
className="ml-2"
variant="primary"
onClick={handleConfirm}
disabled={isDisabled}
>
{t('operation.save', { ns: 'common' })}
</Button>
</div>
</div>
<div className="shrink-0">
<EncryptedBottom />
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -19,9 +19,6 @@ import {
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { toast } from '@langgenius/dify-ui/toast'
import {
RiArrowDownSLine,
} from '@remixicon/react'
import {
memo,
useCallback,
@ -93,19 +90,19 @@ const Authorized = ({
}, [onOpenChange])
const oAuthCredentials = credentials.filter(credential => credential.credential_type === CredentialTypeEnum.OAUTH2)
const apiKeyCredentials = credentials.filter(credential => credential.credential_type === CredentialTypeEnum.API_KEY)
const pendingOperationCredentialId = useRef<string | null>(null)
const pendingOperationCredentialIdRef = useRef<string | null>(null)
const [deleteCredentialId, setDeleteCredentialId] = useState<string | null>(null)
const { mutateAsync: deletePluginCredential } = useDeletePluginCredentialHook(pluginPayload)
const openConfirm = useCallback((credentialId?: string) => {
setMergedIsOpen(false)
if (credentialId)
pendingOperationCredentialId.current = credentialId
pendingOperationCredentialIdRef.current = credentialId
setDeleteCredentialId(pendingOperationCredentialId.current)
setDeleteCredentialId(pendingOperationCredentialIdRef.current)
}, [setMergedIsOpen])
const closeConfirm = useCallback(() => {
setDeleteCredentialId(null)
pendingOperationCredentialId.current = null
pendingOperationCredentialIdRef.current = null
}, [])
const [doingAction, setDoingAction] = useState(false)
const doingActionRef = useRef(doingAction)
@ -116,30 +113,37 @@ const Authorized = ({
const handleConfirm = useCallback(async () => {
if (doingActionRef.current)
return
if (!pendingOperationCredentialId.current) {
if (!pendingOperationCredentialIdRef.current) {
setDeleteCredentialId(null)
return
}
try {
handleSetDoingAction(true)
await deletePluginCredential({ credential_id: pendingOperationCredentialId.current })
await deletePluginCredential({ credential_id: pendingOperationCredentialIdRef.current })
toast.success(t('api.actionSuccess', { ns: 'common' }))
onUpdate?.()
setDeleteCredentialId(null)
pendingOperationCredentialId.current = null
pendingOperationCredentialIdRef.current = null
}
finally {
handleSetDoingAction(false)
}
}, [deletePluginCredential, onUpdate, t, handleSetDoingAction])
const [editValues, setEditValues] = useState<Record<string, unknown> | null>(null)
const [isApiKeyModalOpen, setIsApiKeyModalOpen] = useState(false)
const handleEdit = useCallback((id: string, values: Record<string, unknown>) => {
setMergedIsOpen(false)
pendingOperationCredentialId.current = id
pendingOperationCredentialIdRef.current = id
setEditValues(values)
setIsApiKeyModalOpen(true)
}, [setMergedIsOpen])
const handleApiKeyModalOpenChange = useCallback((open: boolean) => {
setIsApiKeyModalOpen(open)
if (!open)
pendingOperationCredentialIdRef.current = null
}, [])
const handleRemove = useCallback(() => {
setDeleteCredentialId(pendingOperationCredentialId.current)
setDeleteCredentialId(pendingOperationCredentialIdRef.current)
}, [])
const { mutateAsync: setPluginDefaultCredential } = useSetPluginDefaultCredentialHook(pluginPayload)
const handleSetDefault = useCallback(async (id: string) => {
@ -213,7 +217,7 @@ const Authorized = ({
` (${unavailableCredentials.length} ${t('auth.unavailable', { ns: 'plugin' })})`
)
}
<RiArrowDownSLine className="ml-0.5 h-4 w-4" />
<span className="ml-0.5 i-ri-arrow-down-s-line h-4 w-4" />
</Button>
)
}
@ -356,12 +360,11 @@ const Authorized = ({
{
!!editValues && (
<ApiKeyModal
open={isApiKeyModalOpen}
onOpenChange={handleApiKeyModalOpenChange}
pluginPayload={pluginPayload}
editValues={editValues}
onClose={() => {
setEditValues(null)
pendingOperationCredentialId.current = null
}}
onClose={() => handleApiKeyModalOpenChange(false)}
onRemove={handleRemove}
disabled={disabled || doingAction}
onUpdate={onUpdate}