fix: preserve workflow tool confirmation dialog lifecycle
This commit is contained in:
@@ -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()
|
||||
},
|
||||
)
|
||||
@@ -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(
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user