fix: preserve workflow tool confirmation dialog lifecycle

This commit is contained in:
yyh
2026-10-06 04:19:57 +08:00
parent c9f246afc7
commit b96e523be5
6 changed files with 270 additions and 69 deletions

View File

@@ -0,0 +1,82 @@
import type { WorkflowToolDrawerPayload } from '../index'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { page, userEvent } from 'vite-plus/test/browser'
import { cleanup, render } from 'vitest-browser-react'
import { WorkflowToolDrawer } from '../index'
const payload: WorkflowToolDrawerPayload = {
icon: { content: '🔧', background: '#ffffff' },
label: 'Existing workflow tool',
name: 'existing_tool',
description: 'Original description',
parameters: [],
outputParameters: [],
labels: [],
privacy_policy: '',
workflow_tool_id: 'tool-1',
}
afterEach(async () => {
await cleanup()
vi.unstubAllGlobals()
})
it.each([
{ action: 'Escape', width: 1000 },
{ action: 'Cancel', width: 414 },
])(
'returns from $action to the real Drawer Save without losing its draft at $width px',
async ({ action, width }) => {
vi.stubGlobal('BASE_UI_ANIMATIONS_DISABLED', false)
await page.viewport(width, 760)
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const onHide = vi.fn()
const onSave = vi.fn()
const screen = await render(
<QueryClientProvider client={client}>
<WorkflowToolDrawer payload={payload} onHide={onHide} onSave={onSave} />
</QueryClientProvider>,
)
const name = screen.getByPlaceholder('tools.createTool.toolNamePlaceHolder')
await name.fill('Edited workflow tool')
const save = screen.getByRole('button', { name: 'common.operation.save' })
await save.click()
const dialog = screen.getByRole('dialog', { name: 'tools.createTool.confirmTitle' })
await expect.element(dialog).toBeVisible()
const popup = dialog.element()
await expect
.poll(
() =>
!popup.hasAttribute('data-starting-style') &&
popup.getAnimations().every((animation) => animation.playState === 'finished'),
)
.toBe(true)
if (width === 414) {
const rect = popup.getBoundingClientRect()
expect(rect.left).toBeGreaterThanOrEqual(16)
expect(rect.right).toBeLessThanOrEqual(width - 16)
}
if (width === 1000) expect(popup.getBoundingClientRect().width).toBe(600)
let titleDuringExit: string | undefined
popup.addEventListener('transitionrun', () => {
if (popup.hasAttribute('data-ending-style'))
titleDuringExit = popup.querySelector('h2')?.textContent ?? undefined
})
if (action === 'Escape') await userEvent.keyboard('{Escape}')
else await dialog.getByRole('button', { name: 'common.operation.cancel' }).click()
await expect.element(dialog).not.toBeInTheDocument()
await expect.poll(() => titleDuringExit).toBe('tools.createTool.confirmTitle')
await expect.poll(() => popup.isConnected).toBe(false)
await expect.element(save).toHaveFocus()
await expect.element(name).toHaveValue('Edited workflow tool')
expect(onSave).not.toHaveBeenCalled()
expect(onHide).not.toHaveBeenCalled()
await userEvent.keyboard('{Enter}')
await expect.element(dialog).toBeVisible()
const reopenedPopup = dialog.element()
await dialog.getByRole('button', { name: 'common.operation.close' }).click()
await expect.poll(() => reopenedPopup.isConnected).toBe(false)
await expect.element(save).toHaveFocus()
client.clear()
},
)

View File

@@ -6,39 +6,6 @@ import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { emojiCatalogOptions } from '@/app/components/base/icon-picker/emoji-data'
import { WorkflowToolDrawer } from '../index'
vi.mock('@/app/components/tools/labels/selector', () => ({
default: ({ value, onChange }: { value: string[]; onChange: (labels: string[]) => void }) => (
<div data-testid="label-selector">
<span>{value.join(',')}</span>
<button data-testid="append-label" onClick={() => onChange([...value, 'new-label'])}>
Add
</button>
</div>
),
}))
vi.mock('../confirm-modal', () => ({
default: ({
show,
onClose,
onConfirm,
}: {
show: boolean
onClose: () => void
onConfirm: () => void
}) =>
show ? (
<div data-testid="confirm-modal">
<button data-testid="confirm-save" onClick={onConfirm}>
Confirm
</button>
<button data-testid="close-confirm" onClick={onClose}>
Close
</button>
</div>
) : null,
}))
const mockToastNotify = vi.fn()
vi.mock('@/app/notifications', () => ({
toast: {
@@ -95,7 +62,9 @@ describe('WorkflowToolDrawer', () => {
screen.getByPlaceholderText('tools.createTool.toolNamePlaceHolder'),
'Created Tool',
)
await user.click(screen.getByTestId('append-label'))
await user.click(screen.getByRole('button', { name: 'Label 1' }))
await user.click(screen.getByRole('checkbox', { name: 'Label 2' }))
await user.keyboard('{Escape}')
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onCreate).toHaveBeenCalledWith(
@@ -103,7 +72,7 @@ describe('WorkflowToolDrawer', () => {
workflow_app_id: 'workflow-app-1',
label: 'Created Tool',
icon: { content: '🔧', background: '#ffffff' },
labels: ['label1', 'new-label'],
labels: ['label1', 'label2'],
}),
)
expect(onCreate.mock.calls[0]![0]).not.toHaveProperty('outputParameters')
@@ -166,9 +135,11 @@ describe('WorkflowToolDrawer', () => {
render(<WorkflowToolDrawer payload={createPayload()} onHide={vi.fn()} onSave={onSave} />)
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(screen.getByTestId('confirm-modal')).toBeInTheDocument()
expect(
screen.getByRole('dialog', { name: 'tools.createTool.confirmTitle' }),
).toBeInTheDocument()
await user.click(screen.getByTestId('confirm-save'))
await user.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
await waitFor(() => {
expect(onSave).toHaveBeenCalledWith(

View File

@@ -1,11 +1,11 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vite-plus/test'
import ConfirmModal from '../index'
import { ConfirmModal } from '../index'
describe('ConfirmModal', () => {
it('does not expose the dialog while hidden', () => {
render(<ConfirmModal show={false} onClose={vi.fn()} />)
render(<ConfirmModal open={false} onOpenChange={vi.fn()} />)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
@@ -14,20 +14,23 @@ describe('ConfirmModal', () => {
['common.operation.close', 'close button'],
['common.operation.cancel', 'cancel button'],
])('closes from the %s action', async (name) => {
const onClose = vi.fn()
render(<ConfirmModal show onClose={onClose} />)
const onOpenChange = vi.fn()
render(<ConfirmModal open onOpenChange={onOpenChange} />)
await userEvent.click(screen.getByRole('button', { name }))
expect(onClose).toHaveBeenCalledOnce()
expect(onOpenChange).toHaveBeenCalledWith(false, expect.anything())
})
it('confirms the destructive update', async () => {
const onConfirm = vi.fn()
render(<ConfirmModal show onClose={vi.fn()} onConfirm={onConfirm} />)
const onOpenChange = vi.fn()
render(<ConfirmModal open onOpenChange={onOpenChange} onConfirm={onConfirm} />)
await userEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
expect(onConfirm).toHaveBeenCalledOnce()
expect(onOpenChange).not.toHaveBeenCalled()
expect(screen.getByRole('dialog')).toHaveAccessibleDescription('tools.createTool.confirmTip')
})
})

View File

@@ -1,30 +1,33 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import { useTranslation } from 'react-i18next'
type ConfirmModalProps = {
show: boolean
open: boolean
onConfirm?: () => void
onClose: () => void
onOpenChange: (open: boolean) => void
}
const ConfirmModal = ({ show, onConfirm, onClose }: ConfirmModalProps) => {
export function ConfirmModal({ open, onConfirm, onOpenChange }: ConfirmModalProps) {
const { t } = useTranslation(['common', 'tools'])
return (
<Dialog open={show} disablePointerDismissal>
<DialogContent backdropProps={{ forceRender: true }} className={cn('w-150! max-w-150! p-8!')}>
<button
type="button"
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
<DialogContent backdropProps={{ forceRender: true }} className="w-150! p-8!">
<DialogClose
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
className="absolute top-4 right-4 cursor-pointer border-none bg-transparent p-2"
onClick={onClose}
className="absolute top-4 right-4 cursor-pointer rounded-md border-none bg-transparent p-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current"
>
<span aria-hidden className="i-ri-close-line size-4 text-text-tertiary" />
</button>
</DialogClose>
<div className="h-12 w-12 rounded-xl border-[0.5px] border-divider-regular bg-background-section p-3 shadow-xl">
<span
aria-hidden
@@ -34,14 +37,14 @@ const ConfirmModal = ({ show, onConfirm, onClose }: ConfirmModalProps) => {
<DialogTitle className="relative mt-3 text-xl leading-7.5 font-semibold text-text-primary">
{t(($) => $['createTool.confirmTitle'], { ns: 'tools' })}
</DialogTitle>
<div className="my-1 text-sm/5 text-text-tertiary">
<DialogDescription className="my-1 text-sm/5 text-text-tertiary">
{t(($) => $['createTool.confirmTip'], { ns: 'tools' })}
</div>
</DialogDescription>
<div className="flex items-center justify-end pt-6">
<div className="flex items-center">
<Button className="mr-2" onClick={onClose}>
<DialogClose render={<Button className="mr-2" />}>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</Button>
</DialogClose>
<Button variant="primary" tone="destructive" onClick={onConfirm}>
{t(($) => $['operation.confirm'], { ns: 'common' })}
</Button>
@@ -51,5 +54,3 @@ const ConfirmModal = ({ show, onConfirm, onClose }: ConfirmModalProps) => {
</Dialog>
)
}
export default ConfirmModal

View File

@@ -0,0 +1,146 @@
import type { WorkflowToolProviderResponse } from '@/app/components/tools/types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { WorkflowToolDrawer } from '../../index'
import { useConfigureButton } from '../use-configure-button'
const { get, post, success, error } = vi.hoisted(() => ({
get: vi.fn(),
post: vi.fn(),
success: vi.fn(),
error: vi.fn(),
}))
vi.mock('@/service/base', () => ({
get,
post,
put: vi.fn(),
del: vi.fn(),
patch: vi.fn(),
}))
vi.mock('@/app/notifications', () => ({ toast: { success, error } }))
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason: Error) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
const detail: WorkflowToolProviderResponse = {
workflow_app_id: 'app-1',
workflow_tool_id: 'tool-1',
name: 'existing_tool',
label: 'Existing Tool',
description: 'Original description',
icon: { content: '🔧', background: '#ffffff' },
synced: true,
tool: {
author: 'Dify',
name: 'existing_tool',
label: { en_US: 'Existing Tool', zh_Hans: 'Existing Tool' },
description: { en_US: 'Original description', zh_Hans: 'Original description' },
labels: [],
parameters: [],
output_schema: { type: 'object', properties: {} },
},
privacy_policy: '',
}
function ConfiguredTool({
publish,
onRefresh,
}: {
publish: () => Promise<void>
onRefresh: () => void
}) {
const [open, setOpen] = useState(true)
const configuration = useConfigureButton({
enabled: true,
published: true,
detailNeedUpdate: false,
workflowAppId: 'app-1',
icon: detail.icon,
name: 'Workflow',
description: '',
inputs: [],
outputs: [],
handlePublish: publish,
onRefreshData: onRefresh,
onConfigured: () => setOpen(false),
})
return open && !configuration.isLoading ? (
<WorkflowToolDrawer
payload={configuration.payload}
onHide={() => setOpen(false)}
onSave={configuration.handleUpdate}
/>
) : null
}
it('keeps the real confirmation and draft after save failure, and closes on POST success without waiting for refresh', async () => {
const user = userEvent.setup()
const publishing = deferred<void>()
const saving = deferred<object>()
const refresh = deferred<WorkflowToolProviderResponse>()
const publish = vi.fn().mockReturnValueOnce(publishing.promise).mockResolvedValue(undefined)
const onRefresh = vi.fn()
post.mockReturnValueOnce(saving.promise).mockResolvedValueOnce({ result: 'success' })
get.mockResolvedValueOnce(detail).mockReturnValue(refresh.promise)
const client = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
})
render(
<QueryClientProvider client={client}>
<ConfiguredTool publish={publish} onRefresh={onRefresh} />
</QueryClientProvider>,
)
const label = await screen.findByPlaceholderText('tools.createTool.toolNamePlaceHolder')
await user.clear(label)
await user.type(label, 'Edited label')
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
const confirmation = screen.getByRole('dialog', { name: 'tools.createTool.confirmTitle' })
await user.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
expect(publish).toHaveBeenCalledOnce()
expect(post).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: 'common.operation.confirm' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeEnabled()
await act(async () => publishing.resolve())
await waitFor(() =>
expect(post).toHaveBeenCalledExactlyOnceWith(
'/workspaces/current/tool-provider/workflow/update',
{
body: expect.objectContaining({
label: 'Edited label',
name: 'existing_tool',
workflow_tool_id: 'tool-1',
}),
},
),
)
expect(confirmation).toBeInTheDocument()
await act(async () => saving.reject(new Error('Save failed')))
expect(error).toHaveBeenCalledWith('Save failed')
expect(confirmation).toBeInTheDocument()
expect(onRefresh).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
await waitFor(() => expect(confirmation).not.toBeInTheDocument())
expect(label).toHaveValue('Edited label')
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
await user.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(post).toHaveBeenCalledTimes(2)
expect(publish).toHaveBeenCalledTimes(2)
expect(onRefresh).toHaveBeenCalledOnce()
expect(success).toHaveBeenCalledWith('common.api.actionSuccess')
expect(get).toHaveBeenLastCalledWith(
'/workspaces/current/tool-provider/workflow/get?workflow_app_id=app-1',
)
expect(get).toHaveBeenCalledTimes(2)
await act(async () => refresh.resolve(detail))
client.clear()
})

View File

@@ -31,7 +31,7 @@ import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { IconPickerDialog } from '@/app/components/base/icon-picker'
import LabelSelector from '@/app/components/tools/labels/selector'
import ConfirmModal from '@/app/components/tools/workflow-tool/confirm-modal'
import { ConfirmModal } from '@/app/components/tools/workflow-tool/confirm-modal'
import MethodSelector from '@/app/components/tools/workflow-tool/method-selector'
import { normalizeWorkflowOutputName } from '@/app/components/workflow/utils/variable'
import { toast } from '@/app/notifications'
@@ -590,13 +590,11 @@ export function WorkflowToolDrawer({
</form>
</WorkflowToolDrawerFrame>
{confirmModalOpen && (
<ConfirmModal
show={confirmModalOpen}
onClose={() => setConfirmModalOpen(false)}
onConfirm={onConfirm}
/>
)}
<ConfirmModal
open={confirmModalOpen}
onOpenChange={setConfirmModalOpen}
onConfirm={onConfirm}
/>
</>
)
}