feat(workflow): add selection context menu helpers and integrate with context menu component (#34013)

Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: lif <1835304752@qq.com>
Co-authored-by: hjlarry <hjlarry@163.com>
Co-authored-by: Stephen Zhou <hi@hyoban.cc>
Co-authored-by: tmimmanuel <14046872+tmimmanuel@users.noreply.github.com>
Co-authored-by: Desel72 <pedroluiscolmenares722@gmail.com>
Co-authored-by: Renzo <170978465+RenzoMXD@users.noreply.github.com>
Co-authored-by: Krishna Chaitanya <krishnabkc15@gmail.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Coding On Star
2026-03-25 17:21:48 +08:00
committed by GitHub
parent f87dafa229
commit 7fbb1c96db
87 changed files with 13256 additions and 2105 deletions

View File

@ -0,0 +1,149 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { UserActionButtonType } from '../../types'
import ButtonStyleDropdown from '../button-style-dropdown'
const mockUseTranslation = vi.hoisted(() => vi.fn())
const mockButton = vi.hoisted(() => vi.fn())
vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation(),
}))
vi.mock('@/app/components/base/button', () => ({
__esModule: true,
default: (props: {
variant?: string
children?: React.ReactNode
className?: string
}) => {
mockButton(props)
return <div data-testid={`button-${props.variant ?? 'default'}`}>{props.children}</div>
},
}))
vi.mock('@/app/components/base/portal-to-follow-elem', () => {
const OpenContext = React.createContext(false)
return {
PortalToFollowElem: ({
open,
children,
}: {
open: boolean
children?: React.ReactNode
}) => (
<OpenContext value={open}>
<div data-testid="portal" data-open={String(open)}>{children}</div>
</OpenContext>
),
PortalToFollowElemTrigger: ({
children,
onClick,
}: {
children?: React.ReactNode
onClick?: () => void
}) => (
<button type="button" data-testid="portal-trigger" onClick={onClick}>
{children}
</button>
),
PortalToFollowElemContent: ({
children,
}: {
children?: React.ReactNode
}) => {
const open = React.use(OpenContext)
return open ? <div data-testid="portal-content">{children}</div> : null
},
}
})
describe('ButtonStyleDropdown', () => {
const onChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockUseTranslation.mockReturnValue({
t: (key: string) => key,
})
})
it('should map the current style to the trigger button and update the selected style', () => {
render(
<ButtonStyleDropdown
text="Approve"
data={UserActionButtonType.Ghost}
onChange={onChange}
/>,
)
expect(mockButton).toHaveBeenCalledWith(expect.objectContaining({
variant: 'ghost',
}))
expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'false')
fireEvent.click(screen.getByTestId('portal-trigger'))
expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'true')
expect(screen.getByText('nodes.humanInput.userActions.chooseStyle')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('button-primary').parentElement as HTMLElement)
fireEvent.click(screen.getByTestId('button-secondary').parentElement as HTMLElement)
fireEvent.click(screen.getByTestId('button-secondary-accent').parentElement as HTMLElement)
fireEvent.click(screen.getAllByTestId('button-ghost')[1].parentElement as HTMLElement)
expect(onChange).toHaveBeenNthCalledWith(1, UserActionButtonType.Primary)
expect(onChange).toHaveBeenNthCalledWith(2, UserActionButtonType.Default)
expect(onChange).toHaveBeenNthCalledWith(3, UserActionButtonType.Accent)
expect(onChange).toHaveBeenNthCalledWith(4, UserActionButtonType.Ghost)
})
it('should keep the dropdown closed in readonly mode', () => {
render(
<ButtonStyleDropdown
text="Approve"
data={UserActionButtonType.Default}
onChange={onChange}
readonly
/>,
)
expect(mockButton).toHaveBeenCalledWith(expect.objectContaining({
variant: 'secondary',
}))
fireEvent.click(screen.getByTestId('portal-trigger'))
expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'false')
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
expect(onChange).not.toHaveBeenCalled()
})
it('should map the accent style to the secondary-accent trigger button', () => {
render(
<ButtonStyleDropdown
text="Approve"
data={UserActionButtonType.Accent}
onChange={onChange}
/>,
)
expect(mockButton).toHaveBeenCalledWith(expect.objectContaining({
variant: 'secondary-accent',
}))
})
it('should map the primary style to the primary trigger button', () => {
render(
<ButtonStyleDropdown
text="Approve"
data={UserActionButtonType.Primary}
onChange={onChange}
/>,
)
expect(mockButton).toHaveBeenCalledWith(expect.objectContaining({
variant: 'primary',
}))
})
})

View File

@ -0,0 +1,135 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { UserActionButtonType } from '../../types'
import FormContentPreview from '../form-content-preview'
const mockUseTranslation = vi.hoisted(() => vi.fn())
const mockUseStore = vi.hoisted(() => vi.fn())
const mockUseNodes = vi.hoisted(() => vi.fn())
const mockGetButtonStyle = vi.hoisted(() => vi.fn())
vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation(),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: { panelWidth: number }) => unknown) => mockUseStore(selector),
}))
vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({
__esModule: true,
default: () => mockUseNodes(),
}))
vi.mock('@/app/components/base/action-button', () => ({
__esModule: true,
default: ({ children, onClick }: { children?: ReactNode, onClick?: () => void }) => (
<button type="button" aria-label="close-preview" onClick={onClick}>
{children}
</button>
),
}))
vi.mock('@/app/components/base/badge', () => ({
__esModule: true,
default: ({ children }: { children?: ReactNode }) => <div data-testid="badge">{children}</div>,
}))
vi.mock('@/app/components/base/button', () => ({
__esModule: true,
default: ({ children, variant }: { children?: ReactNode, variant?: string }) => (
<button type="button" data-testid={`action-${variant}`}>{children}</button>
),
}))
vi.mock('@/app/components/base/chat/chat/answer/human-input-content/utils', () => ({
getButtonStyle: (...args: unknown[]) => mockGetButtonStyle(...args),
}))
vi.mock('@/app/components/base/markdown', () => ({
Markdown: ({ customComponents }: {
customComponents: {
variable: (props: { node: { properties: { dataPath: string } } }) => ReactNode
section: (props: { node: { properties: { dataName: string } } }) => ReactNode
}
}) => (
<div>
{customComponents.variable({ node: { properties: { dataPath: '#node-1.answer#' } } })}
{customComponents.section({ node: { properties: { dataName: 'field_1' } } })}
{customComponents.section({ node: { properties: { dataName: 'missing_field' } } })}
</div>
),
}))
vi.mock('../variable-in-markdown', () => ({
rehypeNotes: vi.fn(),
rehypeVariable: vi.fn(),
Variable: ({ path }: { path: string }) => <div data-testid="variable-path">{path}</div>,
Note: ({ defaultInput, nodeName }: {
defaultInput: { selector: string[] }
nodeName: (nodeId: string) => string
}) => <div data-testid="note">{nodeName(defaultInput.selector[0])}</div>,
}))
describe('FormContentPreview', () => {
const onClose = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockUseTranslation.mockReturnValue({
t: (key: string) => key,
})
mockUseStore.mockImplementation((selector: (state: { panelWidth: number }) => unknown) => selector({ panelWidth: 320 }))
mockUseNodes.mockReturnValue([{
id: 'node-1',
data: { title: 'Classifier' },
}])
mockGetButtonStyle.mockImplementation((style: UserActionButtonType) => style.toLowerCase())
})
it('should render preview content with resolved node names, note fallbacks, and action buttons', () => {
const { container } = render(
<FormContentPreview
content="content"
formInputs={[{
type: 'text-input' as never,
output_variable_name: 'field_1',
default: {
type: 'variable',
selector: ['node-1', 'answer'],
value: '',
},
}]}
userActions={[{
id: 'approve',
title: 'Approve',
button_style: UserActionButtonType.Primary,
}]}
onClose={onClose}
/>,
)
expect(container.firstChild).toHaveStyle({ right: '328px' })
expect(screen.getByTestId('badge')).toHaveTextContent('nodes.humanInput.formContent.preview')
expect(screen.getByTestId('variable-path')).toHaveTextContent('#Classifier.answer#')
expect(screen.getByTestId('note')).toHaveTextContent('Classifier')
expect(screen.getByText(/Can't find note:/)).toHaveTextContent('missing_field')
expect(screen.getByTestId('action-primary')).toHaveTextContent('Approve')
expect(screen.getByText('nodes.humanInput.editor.previewTip')).toBeInTheDocument()
})
it('should close the preview when the close action is clicked', () => {
render(
<FormContentPreview
content="content"
formInputs={[]}
userActions={[]}
onClose={onClose}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'close-preview' }))
expect(onClose).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,258 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import FormContent from '../form-content'
const mockUseTranslation = vi.hoisted(() => vi.fn())
const mockUseWorkflowVariableType = vi.hoisted(() => vi.fn())
const mockIsMac = vi.hoisted(() => vi.fn())
const mockPromptEditor = vi.hoisted(() => vi.fn())
const mockAddInputField = vi.hoisted(() => vi.fn())
const mockOnInsert = vi.hoisted(() => vi.fn())
vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation(),
Trans: ({
i18nKey,
components,
}: {
i18nKey: string
components?: Record<string, ReactNode>
}) => (
<div>
<div>{i18nKey}</div>
{components?.CtrlKey}
{components?.Key}
</div>
),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useWorkflowVariableType: () => mockUseWorkflowVariableType(),
}))
vi.mock('@/app/components/workflow/utils', () => ({
isMac: () => mockIsMac(),
}))
vi.mock('@/app/components/base/prompt-editor', () => ({
__esModule: true,
default: (props: {
onChange: (value: string) => void
onFocus: () => void
onBlur: () => void
shortcutPopups?: Array<{
Popup: (props: { onClose: () => void, onInsert: typeof mockOnInsert }) => ReactNode
}>
editable?: boolean
hitlInputBlock: {
workflowNodesMap: Record<string, unknown>
}
}) => {
mockPromptEditor(props)
const popup = props.shortcutPopups?.[0]
return (
<div>
<button type="button" onClick={props.onFocus}>focus-editor</button>
<button type="button" onClick={props.onBlur}>blur-editor</button>
<button type="button" onClick={() => props.onChange('updated value')}>change-editor</button>
{popup && popup.Popup({ onClose: vi.fn(), onInsert: mockOnInsert })}
</div>
)
},
}))
vi.mock('../add-input-field', () => ({
__esModule: true,
default: (props: {
onSave: (payload: {
type: string
output_variable_name: string
default: {
type: string
selector: string[]
value: string
}
}) => void
onCancel: () => void
}) => {
mockAddInputField(props)
return (
<div>
<button
type="button"
onClick={() => props.onSave({
type: 'text-input',
output_variable_name: 'approval',
default: {
type: 'variable',
selector: ['node-1', 'answer'],
value: '',
},
})}
>
save-input
</button>
<button type="button" onClick={props.onCancel}>cancel-input</button>
</div>
)
},
}))
vi.mock('@/app/components/base/prompt-editor/plugins/hitl-input-block', () => ({
INSERT_HITL_INPUT_BLOCK_COMMAND: 'INSERT_HITL_INPUT_BLOCK_COMMAND',
}))
describe('FormContent', () => {
const onChange = vi.fn()
const onFormInputsChange = vi.fn()
const onFormInputItemRename = vi.fn()
const onFormInputItemRemove = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockUseTranslation.mockReturnValue({
t: (key: string) => key,
})
mockUseWorkflowVariableType.mockReturnValue(() => 'string')
mockIsMac.mockReturnValue(false)
})
it('should build workflow node maps, show the hotkey tip on focus, and defer form-input sync until value changes', async () => {
const { rerender } = render(
<FormContent
nodeId="node-2"
value="Initial content"
onChange={onChange}
formInputs={[]}
onFormInputsChange={onFormInputsChange}
onFormInputItemRename={onFormInputItemRename}
onFormInputItemRemove={onFormInputItemRemove}
editorKey={1}
isExpand={false}
availableVars={[]}
availableNodes={[
{
id: 'node-1',
data: { title: 'Start', type: 'start' },
position: { x: 0, y: 0 },
width: 100,
height: 40,
} as never,
{
id: 'node-2',
data: { title: 'Classifier', type: 'code' },
position: { x: 120, y: 0 },
width: 100,
height: 40,
} as never,
]}
/>,
)
expect(mockPromptEditor).toHaveBeenCalledWith(expect.objectContaining({
editable: true,
hitlInputBlock: expect.objectContaining({
workflowNodesMap: expect.objectContaining({
'node-1': expect.objectContaining({ title: 'Start' }),
'node-2': expect.objectContaining({ title: 'Classifier' }),
'sys': expect.objectContaining({ title: 'blocks.start' }),
}),
}),
}))
fireEvent.click(screen.getByText('focus-editor'))
expect(screen.getByText('nodes.humanInput.formContent.hotkeyTip')).toBeInTheDocument()
fireEvent.click(screen.getByText('save-input'))
expect(mockOnInsert).toHaveBeenCalledWith('INSERT_HITL_INPUT_BLOCK_COMMAND', expect.objectContaining({
variableName: 'approval',
nodeId: 'node-2',
formInputs: [expect.objectContaining({ output_variable_name: 'approval' })],
onFormInputsChange,
onFormInputItemRename,
onFormInputItemRemove,
}))
expect(onFormInputsChange).not.toHaveBeenCalled()
rerender(
<FormContent
nodeId="node-2"
value="Initial content {{approval}}"
onChange={onChange}
formInputs={[]}
onFormInputsChange={onFormInputsChange}
onFormInputItemRename={onFormInputItemRename}
onFormInputItemRemove={onFormInputItemRemove}
editorKey={1}
isExpand={false}
availableVars={[]}
availableNodes={[
{
id: 'node-1',
data: { title: 'Start', type: 'start' },
position: { x: 0, y: 0 },
width: 100,
height: 40,
} as never,
]}
/>,
)
await waitFor(() => {
expect(onFormInputsChange).toHaveBeenCalledWith([
expect.objectContaining({ output_variable_name: 'approval' }),
])
})
})
it('should disable editing helpers in readonly mode', () => {
const { container } = render(
<FormContent
nodeId="node-2"
value="Initial content"
onChange={onChange}
formInputs={[]}
onFormInputsChange={onFormInputsChange}
onFormInputItemRename={onFormInputItemRename}
onFormInputItemRemove={onFormInputItemRemove}
editorKey={1}
isExpand={false}
availableVars={[]}
availableNodes={[]}
readonly
/>,
)
expect(mockPromptEditor).toHaveBeenCalledWith(expect.objectContaining({
editable: false,
shortcutPopups: [],
}))
expect(screen.queryByText('save-input')).not.toBeInTheDocument()
expect(container.firstChild).toHaveClass('pointer-events-none')
})
it('should render the mac hotkey hint when focused on macOS', () => {
mockIsMac.mockReturnValue(true)
render(
<FormContent
nodeId="node-2"
value="Initial content"
onChange={onChange}
formInputs={[]}
onFormInputsChange={onFormInputsChange}
onFormInputItemRename={onFormInputItemRename}
onFormInputItemRemove={onFormInputItemRemove}
editorKey={1}
isExpand={false}
availableVars={[]}
availableNodes={[]}
/>,
)
fireEvent.click(screen.getByText('focus-editor'))
expect(screen.getByText('⌘')).toBeInTheDocument()
expect(screen.getByText('/')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,77 @@
import { fireEvent, render, screen } from '@testing-library/react'
import TimeoutInput from '../timeout'
const mockUseTranslation = vi.hoisted(() => vi.fn())
vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation(),
}))
vi.mock('@/app/components/base/input', () => ({
__esModule: true,
default: (props: {
value: number
disabled?: boolean
onChange: (event: { target: { value: string } }) => void
}) => (
<input
data-testid="timeout-input"
value={props.value}
disabled={props.disabled}
onChange={e => props.onChange({ target: { value: e.target.value } })}
/>
),
}))
describe('TimeoutInput', () => {
const onChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockUseTranslation.mockReturnValue({
t: (key: string) => key,
})
})
it('should update the numeric timeout value and switch units', () => {
render(
<TimeoutInput
timeout={3}
unit="day"
onChange={onChange}
/>,
)
fireEvent.change(screen.getByTestId('timeout-input'), { target: { value: '12' } })
fireEvent.click(screen.getByText('nodes.humanInput.timeout.hours'))
expect(onChange).toHaveBeenNthCalledWith(1, { timeout: 12, unit: 'day' })
expect(onChange).toHaveBeenNthCalledWith(2, { timeout: 3, unit: 'hour' })
})
it('should fall back to 1 on invalid input and stay read-only when disabled', () => {
const { rerender } = render(
<TimeoutInput
timeout={5}
unit="hour"
onChange={onChange}
/>,
)
fireEvent.change(screen.getByTestId('timeout-input'), { target: { value: 'abc' } })
expect(onChange).toHaveBeenCalledWith({ timeout: 1, unit: 'hour' })
rerender(
<TimeoutInput
timeout={5}
unit="hour"
onChange={onChange}
readonly
/>,
)
fireEvent.click(screen.getByText('nodes.humanInput.timeout.days'))
expect(onChange).toHaveBeenCalledTimes(1)
expect(screen.getByTestId('timeout-input')).toBeDisabled()
})
})

View File

@ -0,0 +1,146 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { UserActionButtonType } from '../../types'
import UserActionItem from '../user-action'
const mockUseTranslation = vi.hoisted(() => vi.fn())
const mockNotify = vi.hoisted(() => vi.fn())
vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation(),
}))
vi.mock('@/app/components/base/input', () => ({
__esModule: true,
default: (props: {
value: string
placeholder?: string
disabled?: boolean
onChange: (event: { target: { value: string } }) => void
}) => (
<input
data-testid={props.placeholder}
value={props.value}
disabled={props.disabled}
onChange={e => props.onChange({ target: { value: e.target.value } })}
/>
),
}))
vi.mock('@/app/components/base/button', () => ({
__esModule: true,
default: (props: {
children?: ReactNode
onClick?: () => void
}) => (
<button type="button" onClick={props.onClick}>
{props.children}
</button>
),
}))
vi.mock('@/app/components/base/ui/toast', () => ({
__esModule: true,
toast: {
success: (message: string) => mockNotify({ type: 'success', message }),
error: (message: string) => mockNotify({ type: 'error', message }),
warning: (message: string) => mockNotify({ type: 'warning', message }),
info: (message: string) => mockNotify({ type: 'info', message }),
},
}))
vi.mock('../button-style-dropdown', () => ({
__esModule: true,
default: (props: {
onChange: (type: UserActionButtonType) => void
}) => (
<button type="button" onClick={() => props.onChange(UserActionButtonType.Ghost)}>
change-style
</button>
),
}))
describe('UserActionItem', () => {
const onChange = vi.fn()
const onDelete = vi.fn()
const action = {
id: 'approve',
title: 'Approve',
button_style: UserActionButtonType.Primary,
}
beforeEach(() => {
vi.clearAllMocks()
mockUseTranslation.mockReturnValue({
t: (key: string) => key,
})
})
it('should sanitize ids, enforce length limits, and update the button text', () => {
render(
<UserActionItem
data={action}
onChange={onChange}
onDelete={onDelete}
/>,
)
fireEvent.change(screen.getByTestId('nodes.humanInput.userActions.actionNamePlaceholder'), { target: { value: 'Approve action' } })
fireEvent.change(screen.getByTestId('nodes.humanInput.userActions.actionNamePlaceholder'), { target: { value: '1invalid' } })
fireEvent.change(screen.getByTestId('nodes.humanInput.userActions.actionNamePlaceholder'), { target: { value: 'averyveryveryverylongidentifier' } })
fireEvent.change(screen.getByTestId('nodes.humanInput.userActions.buttonTextPlaceholder'), { target: { value: 'A very very very long button title' } })
expect(onChange).toHaveBeenNthCalledWith(1, expect.objectContaining({
id: 'Approve_action',
}))
expect(onChange).toHaveBeenNthCalledWith(2, expect.objectContaining({
id: 'averyveryveryverylon',
}))
expect(onChange).toHaveBeenNthCalledWith(3, expect.objectContaining({
title: 'A very very very lon',
}))
expect(mockNotify).toHaveBeenNthCalledWith(1, expect.objectContaining({
type: 'error',
message: 'nodes.humanInput.userActions.actionIdFormatTip',
}))
expect(mockNotify).toHaveBeenNthCalledWith(2, expect.objectContaining({
type: 'error',
message: 'nodes.humanInput.userActions.actionIdTooLong',
}))
expect(mockNotify).toHaveBeenNthCalledWith(3, expect.objectContaining({
type: 'error',
message: 'nodes.humanInput.userActions.buttonTextTooLong',
}))
})
it('should support clearing ids, updating button style, deleting, and readonly mode', () => {
const { rerender } = render(
<UserActionItem
data={action}
onChange={onChange}
onDelete={onDelete}
/>,
)
fireEvent.change(screen.getByTestId('nodes.humanInput.userActions.actionNamePlaceholder'), { target: { value: ' ' } })
fireEvent.click(screen.getByText('change-style'))
fireEvent.click(screen.getAllByRole('button')[1])
expect(onChange).toHaveBeenNthCalledWith(1, expect.objectContaining({ id: '' }))
expect(onChange).toHaveBeenNthCalledWith(2, expect.objectContaining({ button_style: UserActionButtonType.Ghost }))
expect(onDelete).toHaveBeenCalledWith('approve')
rerender(
<UserActionItem
data={action}
onChange={onChange}
onDelete={onDelete}
readonly
/>,
)
expect(screen.getByTestId('nodes.humanInput.userActions.actionNamePlaceholder')).toBeDisabled()
expect(screen.getByTestId('nodes.humanInput.userActions.buttonTextPlaceholder')).toBeDisabled()
expect(screen.getAllByRole('button')).toHaveLength(1)
})
})

View File

@ -0,0 +1,150 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { DeliveryMethodType } from '../../../types'
import DeliveryMethodForm from '../index'
const mockUseTranslation = vi.hoisted(() => vi.fn())
const mockUseNodesSyncDraft = vi.hoisted(() => vi.fn())
vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation(),
}))
vi.mock('@/app/components/base/tooltip', () => ({
__esModule: true,
default: ({ popupContent }: { popupContent: string }) => <div data-testid="tooltip">{popupContent}</div>,
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesSyncDraft: () => mockUseNodesSyncDraft(),
}))
vi.mock('../method-selector', () => ({
__esModule: true,
default: (props: {
onAdd: (method: { id: string, type: DeliveryMethodType, enabled: boolean }) => void
onShowUpgradeTip: () => void
}) => (
<div>
<button
type="button"
onClick={() => props.onAdd({ id: 'email-1', type: DeliveryMethodType.Email, enabled: false })}
>
add-method
</button>
<button type="button" onClick={props.onShowUpgradeTip}>
show-upgrade
</button>
</div>
),
}))
vi.mock('../method-item', () => ({
__esModule: true,
default: (props: {
method: { type: DeliveryMethodType, enabled: boolean }
onChange: (method: { type: DeliveryMethodType, enabled: boolean }) => void
onDelete: (type: DeliveryMethodType) => void
}) => (
<div data-testid={`method-${props.method.type}`}>
<button
type="button"
onClick={() => props.onChange({ ...props.method, enabled: !props.method.enabled })}
>
change-method
</button>
<button
type="button"
onClick={() => props.onDelete(props.method.type)}
>
delete-method
</button>
</div>
),
}))
vi.mock('../upgrade-modal', () => ({
__esModule: true,
default: ({ onClose }: { onClose: () => void }) => (
<button type="button" onClick={onClose}>
upgrade-modal
</button>
),
}))
describe('DeliveryMethodForm', () => {
const onChange = vi.fn()
const mockHandleSyncWorkflowDraft = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockUseTranslation.mockReturnValue({
t: (key: string) => key,
})
mockUseNodesSyncDraft.mockReturnValue({
handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft,
})
})
it('should render the empty state and add methods through the selector', () => {
render(
<DeliveryMethodForm
nodeId="node-1"
value={[]}
onChange={onChange}
/>,
)
expect(screen.getByText('nodes.humanInput.deliveryMethod.emptyTip')).toBeInTheDocument()
fireEvent.click(screen.getByText('add-method'))
expect(onChange).toHaveBeenCalledWith([
{
id: 'email-1',
type: DeliveryMethodType.Email,
enabled: false,
},
])
expect(mockHandleSyncWorkflowDraft).not.toHaveBeenCalled()
})
it('should change and delete methods, syncing the draft after updates', () => {
render(
<DeliveryMethodForm
nodeId="node-1"
value={[{
id: 'email-1',
type: DeliveryMethodType.Email,
enabled: false,
}]}
onChange={onChange}
/>,
)
fireEvent.click(screen.getByText('change-method'))
fireEvent.click(screen.getByText('delete-method'))
expect(onChange).toHaveBeenNthCalledWith(1, [{
id: 'email-1',
type: DeliveryMethodType.Email,
enabled: true,
}])
expect(onChange).toHaveBeenNthCalledWith(2, [])
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true, true)
})
it('should open and close the upgrade modal', () => {
render(
<DeliveryMethodForm
nodeId="node-1"
value={[]}
onChange={onChange}
/>,
)
fireEvent.click(screen.getByText('show-upgrade'))
expect(screen.getByText('upgrade-modal')).toBeInTheDocument()
fireEvent.click(screen.getByText('upgrade-modal'))
expect(screen.queryByText('upgrade-modal')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,156 @@
import { fireEvent, render, screen } from '@testing-library/react'
import Recipient from '../index'
const mockUseTranslation = vi.hoisted(() => vi.fn())
const mockUseAppContext = vi.hoisted(() => vi.fn())
const mockUseMembers = vi.hoisted(() => vi.fn())
vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation(),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => mockUseAppContext(),
}))
vi.mock('@/service/use-common', () => ({
useMembers: () => mockUseMembers(),
}))
vi.mock('@/app/components/base/switch', () => ({
__esModule: true,
default: (props: {
value: boolean
onChange: (value: boolean) => void
}) => (
<button type="button" onClick={() => props.onChange(!props.value)}>
toggle-workspace
</button>
),
}))
vi.mock('../member-selector', () => ({
__esModule: true,
default: ({ onSelect }: { onSelect: (id: string) => void }) => (
<button type="button" onClick={() => onSelect('member-2')}>
add-member
</button>
),
}))
vi.mock('../email-input', () => ({
__esModule: true,
default: (props: {
onAdd: (email: string) => void
onSelect: (id: string) => void
onDelete: (recipient: { type: 'member' | 'external', user_id?: string, email?: string }) => void
}) => (
<div>
<button type="button" onClick={() => props.onAdd('new@example.com')}>
add-email
</button>
<button type="button" onClick={() => props.onSelect('member-3')}>
add-email-member
</button>
<button type="button" onClick={() => props.onDelete({ type: 'member', user_id: 'member-1' })}>
delete-member
</button>
<button type="button" onClick={() => props.onDelete({ type: 'external', email: 'external@example.com' })}>
delete-external
</button>
</div>
),
}))
describe('Recipient', () => {
const onChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockUseTranslation.mockReturnValue({
t: (key: string, options?: { workspaceName?: string }) => options?.workspaceName ?? key,
})
mockUseAppContext.mockReturnValue({
userProfile: { email: 'owner@example.com' },
currentWorkspace: { name: 'Dify\'s Lab' },
})
mockUseMembers.mockReturnValue({
data: {
accounts: [
{ id: 'member-1', email: 'member-1@example.com', name: 'Member One' },
{ id: 'member-2', email: 'member-2@example.com', name: 'Member Two' },
{ id: 'member-3', email: 'member-3@example.com', name: 'Member Three' },
],
},
})
})
it('should render workspace details and update recipients through member/email actions', () => {
render(
<Recipient
data={{
whole_workspace: false,
items: [
{ type: 'member', user_id: 'member-1' },
{ type: 'external', email: 'external@example.com' },
],
}}
onChange={onChange}
/>,
)
expect(screen.getByText('D')).toBeInTheDocument()
expect(screen.getByText('Difys Lab')).toBeInTheDocument()
fireEvent.click(screen.getByText('add-member'))
fireEvent.click(screen.getByText('add-email'))
fireEvent.click(screen.getByText('add-email-member'))
fireEvent.click(screen.getByText('delete-member'))
fireEvent.click(screen.getByText('delete-external'))
fireEvent.click(screen.getByText('toggle-workspace'))
expect(onChange).toHaveBeenNthCalledWith(1, {
whole_workspace: false,
items: [
{ type: 'member', user_id: 'member-1' },
{ type: 'external', email: 'external@example.com' },
{ type: 'member', user_id: 'member-2' },
],
})
expect(onChange).toHaveBeenNthCalledWith(2, {
whole_workspace: false,
items: [
{ type: 'member', user_id: 'member-1' },
{ type: 'external', email: 'external@example.com' },
{ type: 'external', email: 'new@example.com' },
],
})
expect(onChange).toHaveBeenNthCalledWith(3, {
whole_workspace: false,
items: [
{ type: 'member', user_id: 'member-1' },
{ type: 'external', email: 'external@example.com' },
{ type: 'member', user_id: 'member-3' },
],
})
expect(onChange).toHaveBeenNthCalledWith(4, {
whole_workspace: false,
items: [
{ type: 'external', email: 'external@example.com' },
],
})
expect(onChange).toHaveBeenNthCalledWith(5, {
whole_workspace: false,
items: [
{ type: 'member', user_id: 'member-1' },
],
})
expect(onChange).toHaveBeenNthCalledWith(6, {
whole_workspace: true,
items: [
{ type: 'member', user_id: 'member-1' },
{ type: 'external', email: 'external@example.com' },
],
})
})
})

View File

@ -0,0 +1,156 @@
import type { DeliveryMethod, HumanInputNodeType, UserAction } from '../../types'
import { act, renderHook } from '@testing-library/react'
import { BlockEnum } from '@/app/components/workflow/types'
import useConfig from '../use-config'
const mockUseUpdateNodeInternals = vi.hoisted(() => vi.fn())
const mockUseNodesReadOnly = vi.hoisted(() => vi.fn())
const mockUseEdgesInteractions = vi.hoisted(() => vi.fn())
const mockUseNodeCrud = vi.hoisted(() => vi.fn())
const mockUseFormContent = vi.hoisted(() => vi.fn())
vi.mock('reactflow', () => ({
useUpdateNodeInternals: () => mockUseUpdateNodeInternals(),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesReadOnly: () => mockUseNodesReadOnly(),
}))
vi.mock('@/app/components/workflow/hooks/use-edges-interactions', () => ({
useEdgesInteractions: () => mockUseEdgesInteractions(),
}))
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
__esModule: true,
default: (...args: unknown[]) => mockUseNodeCrud(...args),
}))
vi.mock('../use-form-content', () => ({
__esModule: true,
default: (...args: unknown[]) => mockUseFormContent(...args),
}))
const createPayload = (overrides: Partial<HumanInputNodeType> = {}): HumanInputNodeType => ({
title: 'Human Input',
desc: '',
type: BlockEnum.HumanInput,
delivery_methods: [{
id: 'webapp',
type: 'webapp',
enabled: true,
} as DeliveryMethod],
form_content: 'Body',
inputs: [],
user_actions: [{
id: 'approve',
title: 'Approve',
button_style: 'primary',
} as UserAction],
timeout: 3,
timeout_unit: 'day',
...overrides,
})
describe('human-input/hooks/use-config', () => {
const mockSetInputs = vi.fn()
const mockHandleEdgeDeleteByDeleteBranch = vi.fn()
const mockHandleEdgeSourceHandleChange = vi.fn()
const mockUpdateNodeInternals = vi.fn()
const formContentHook = {
editorKey: 3,
handleFormContentChange: vi.fn(),
handleFormInputsChange: vi.fn(),
handleFormInputItemRename: vi.fn(),
handleFormInputItemRemove: vi.fn(),
}
let currentInputs = createPayload()
beforeEach(() => {
vi.clearAllMocks()
currentInputs = createPayload()
mockUseUpdateNodeInternals.mockReturnValue(mockUpdateNodeInternals)
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false })
mockUseEdgesInteractions.mockReturnValue({
handleEdgeDeleteByDeleteBranch: mockHandleEdgeDeleteByDeleteBranch,
handleEdgeSourceHandleChange: mockHandleEdgeSourceHandleChange,
})
mockUseNodeCrud.mockImplementation(() => ({
inputs: currentInputs,
setInputs: mockSetInputs,
}))
mockUseFormContent.mockReturnValue(formContentHook)
})
it('should expose form-content helpers and update delivery methods, timeout, and collapsed state', () => {
const { result } = renderHook(() => useConfig('human-input-node', currentInputs))
const methods = [{
id: 'email',
type: 'email',
enabled: true,
} as DeliveryMethod]
expect(result.current.editorKey).toBe(3)
expect(result.current.readOnly).toBe(false)
expect(result.current.structuredOutputCollapsed).toBe(true)
act(() => {
result.current.handleDeliveryMethodChange(methods)
result.current.handleTimeoutChange({ timeout: 12, unit: 'hour' })
result.current.setStructuredOutputCollapsed(false)
})
expect(mockSetInputs).toHaveBeenNthCalledWith(1, expect.objectContaining({
delivery_methods: methods,
}))
expect(mockSetInputs).toHaveBeenNthCalledWith(2, expect.objectContaining({
timeout: 12,
timeout_unit: 'hour',
}))
expect(result.current.structuredOutputCollapsed).toBe(false)
})
it('should append and delete user actions while syncing branch-edge cleanup', () => {
const { result } = renderHook(() => useConfig('human-input-node', currentInputs))
const newAction = {
id: 'reject',
title: 'Reject',
button_style: 'default',
} as UserAction
act(() => {
result.current.handleUserActionAdd(newAction)
result.current.handleUserActionDelete('approve')
})
expect(mockSetInputs).toHaveBeenNthCalledWith(1, expect.objectContaining({
user_actions: [
expect.objectContaining({ id: 'approve' }),
newAction,
],
}))
expect(mockSetInputs).toHaveBeenNthCalledWith(2, expect.objectContaining({
user_actions: [],
}))
expect(mockHandleEdgeDeleteByDeleteBranch).toHaveBeenCalledWith('human-input-node', 'approve')
})
it('should update user action ids and refresh source handles when the branch key changes', () => {
const { result } = renderHook(() => useConfig('human-input-node', currentInputs))
const renamedAction = {
id: 'approved',
title: 'Approve',
button_style: 'primary',
} as UserAction
act(() => {
result.current.handleUserActionChange(0, renamedAction)
})
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
user_actions: [renamedAction],
}))
expect(mockHandleEdgeSourceHandleChange).toHaveBeenCalledWith('human-input-node', 'approve', 'approved')
expect(mockUpdateNodeInternals).toHaveBeenCalledWith('human-input-node')
})
})

View File

@ -0,0 +1,112 @@
import type { FormInputItem, HumanInputNodeType } from '../../types'
import { act, renderHook } from '@testing-library/react'
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
import useFormContent from '../use-form-content'
const mockUseWorkflow = vi.hoisted(() => vi.fn())
const mockUseNodeCrud = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/workflow/hooks', () => ({
useWorkflow: () => mockUseWorkflow(),
}))
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
__esModule: true,
default: (...args: unknown[]) => mockUseNodeCrud(...args),
}))
const createFormInput = (overrides: Partial<FormInputItem> = {}): FormInputItem => ({
type: InputVarType.textInput,
output_variable_name: 'old_name',
default: {
selector: [],
type: 'constant',
value: '',
},
...overrides,
})
const createPayload = (overrides: Partial<HumanInputNodeType> = {}): HumanInputNodeType => ({
title: 'Human Input',
desc: '',
type: BlockEnum.HumanInput,
delivery_methods: [],
form_content: 'Hello {{#$output.old_name#}}',
inputs: [createFormInput()],
user_actions: [],
timeout: 1,
timeout_unit: 'day',
...overrides,
})
describe('human-input/use-form-content', () => {
const mockSetInputs = vi.fn()
const mockHandleOutVarRenameChange = vi.fn()
let currentInputs = createPayload()
beforeEach(() => {
vi.clearAllMocks()
currentInputs = createPayload()
mockUseWorkflow.mockReturnValue({
handleOutVarRenameChange: mockHandleOutVarRenameChange,
})
mockUseNodeCrud.mockImplementation(() => ({
inputs: currentInputs,
setInputs: mockSetInputs,
}))
})
it('should update raw form content and replace the form input list', () => {
const { result } = renderHook(() => useFormContent('human-input-node', currentInputs))
const nextInputs = [
createFormInput({
output_variable_name: 'approval',
}),
]
act(() => {
result.current.handleFormContentChange('Updated body')
result.current.handleFormInputsChange(nextInputs)
})
expect(mockSetInputs).toHaveBeenNthCalledWith(1, expect.objectContaining({
form_content: 'Updated body',
}))
expect(mockSetInputs).toHaveBeenNthCalledWith(2, expect.objectContaining({
inputs: nextInputs,
}))
expect(result.current.editorKey).toBe(1)
})
it('should rename input placeholders inside markdown and notify downstream references', () => {
const { result } = renderHook(() => useFormContent('human-input-node', currentInputs))
const renamedInput = createFormInput({
output_variable_name: 'new_name',
})
act(() => {
result.current.handleFormInputItemRename(renamedInput, 'old_name')
})
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
form_content: 'Hello {{#$output.new_name#}}',
inputs: [renamedInput],
}))
expect(mockHandleOutVarRenameChange).toHaveBeenCalledWith('human-input-node', ['human-input-node', 'old_name'], ['human-input-node', 'new_name'])
expect(result.current.editorKey).toBe(1)
})
it('should remove an input placeholder and its form input metadata', () => {
const { result } = renderHook(() => useFormContent('human-input-node', currentInputs))
act(() => {
result.current.handleFormInputItemRemove('old_name')
})
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
form_content: 'Hello ',
inputs: [],
}))
expect(result.current.editorKey).toBe(1)
})
})

View File

@ -0,0 +1,234 @@
import type { HumanInputNodeType } from '../../types'
import type { InputVar } from '@/app/components/workflow/types'
import type { HumanInputFormData } from '@/types/workflow'
import { act, renderHook } from '@testing-library/react'
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
import { AppModeEnum } from '@/types/app'
import useSingleRunFormParams from '../use-single-run-form-params'
const mockUseTranslation = vi.hoisted(() => vi.fn())
const mockUseAppStore = vi.hoisted(() => vi.fn())
const mockFetchHumanInputNodeStepRunForm = vi.hoisted(() => vi.fn())
const mockSubmitHumanInputNodeStepRunForm = vi.hoisted(() => vi.fn())
const mockUseNodeCrud = vi.hoisted(() => vi.fn())
vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation(),
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: { appDetail?: { id?: string, mode?: AppModeEnum } }) => unknown) => mockUseAppStore(selector),
}))
vi.mock('@/service/workflow', () => ({
fetchHumanInputNodeStepRunForm: (...args: unknown[]) => mockFetchHumanInputNodeStepRunForm(...args),
submitHumanInputNodeStepRunForm: (...args: unknown[]) => mockSubmitHumanInputNodeStepRunForm(...args),
}))
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
__esModule: true,
default: (...args: unknown[]) => mockUseNodeCrud(...args),
}))
const createPayload = (overrides: Partial<HumanInputNodeType> = {}): HumanInputNodeType => ({
title: 'Human Input',
desc: '',
type: BlockEnum.HumanInput,
delivery_methods: [],
form_content: 'Summary: {{#start.topic#}}',
inputs: [{
type: InputVarType.textInput,
output_variable_name: 'summary',
default: {
type: 'variable',
selector: ['start', 'topic'],
value: '',
},
}],
user_actions: [],
timeout: 1,
timeout_unit: 'day',
...overrides,
})
const createInputVar = (overrides: Partial<InputVar> = {}): InputVar => ({
type: InputVarType.textInput,
label: 'Topic',
variable: '#start.topic#',
required: false,
value_selector: ['start', 'topic'],
...overrides,
})
const mockFormData: HumanInputFormData = {
form_id: 'form-1',
node_id: 'node-1',
node_title: 'Human Input',
form_content: 'Rendered content',
inputs: [],
actions: [],
form_token: 'token-1',
resolved_default_values: {
topic: 'AI',
},
display_in_ui: true,
expiration_time: 1000,
}
describe('human-input/hooks/use-single-run-form-params', () => {
const mockSetRunInputData = vi.fn()
const getInputVars = vi.fn()
let currentInputs = createPayload()
let appDetail: { id?: string, mode?: AppModeEnum } | undefined
beforeEach(() => {
vi.clearAllMocks()
currentInputs = createPayload()
appDetail = {
id: 'app-1',
mode: AppModeEnum.WORKFLOW,
}
mockUseTranslation.mockReturnValue({
t: (key: string) => key,
})
mockUseAppStore.mockImplementation((selector: (state: { appDetail?: { id?: string, mode?: AppModeEnum } }) => unknown) => selector({ appDetail }))
mockUseNodeCrud.mockImplementation(() => ({
inputs: currentInputs,
}))
getInputVars.mockReturnValue([
createInputVar(),
createInputVar({
label: 'Output',
variable: '#$output.answer#',
value_selector: ['$output', 'answer'],
}),
{
...createInputVar({
label: 'Broken',
}),
variable: undefined,
} as unknown as InputVar,
])
mockFetchHumanInputNodeStepRunForm.mockResolvedValue(mockFormData)
mockSubmitHumanInputNodeStepRunForm.mockResolvedValue({})
})
it('should build a single before-run form, filter output vars, and expose dependent vars', () => {
const { result } = renderHook(() => useSingleRunFormParams({
id: 'node-1',
payload: currentInputs,
runInputData: { topic: 'AI' },
getInputVars,
setRunInputData: mockSetRunInputData,
}))
expect(getInputVars).toHaveBeenCalledWith([
'{{#start.topic#}}',
'Summary: {{#start.topic#}}',
])
expect(result.current.forms).toHaveLength(1)
expect(result.current.forms[0]).toEqual(expect.objectContaining({
label: 'nodes.humanInput.singleRun.label',
values: { topic: 'AI' },
inputs: [
expect.objectContaining({ variable: '#start.topic#' }),
expect.objectContaining({ label: 'Broken' }),
],
}))
act(() => {
result.current.forms[0].onChange?.({ topic: 'Updated' })
})
expect(mockSetRunInputData).toHaveBeenCalledWith({ topic: 'Updated' })
expect(result.current.getDependentVars()).toEqual([
['start', 'topic'],
])
})
it('should fetch and submit generated forms in workflow mode while keeping required inputs', async () => {
const { result } = renderHook(() => useSingleRunFormParams({
id: 'node-1',
payload: currentInputs,
runInputData: {},
getInputVars,
setRunInputData: mockSetRunInputData,
}))
await act(async () => {
await result.current.handleShowGeneratedForm({
topic: 'AI',
ignored: undefined as unknown as string,
})
})
expect(result.current.showGeneratedForm).toBe(true)
expect(mockFetchHumanInputNodeStepRunForm).toHaveBeenCalledWith(
'/apps/app-1/workflows/draft/human-input/nodes/node-1/form',
{
inputs: { topic: 'AI' },
},
)
expect(result.current.formData).toEqual(mockFormData)
await act(async () => {
await result.current.handleSubmitHumanInputForm({
inputs: { answer: 'approved' },
form_inputs: { ignored: 'value' },
action: 'approve',
})
})
expect(mockSubmitHumanInputNodeStepRunForm).toHaveBeenCalledWith(
'/apps/app-1/workflows/draft/human-input/nodes/node-1/form',
{
inputs: { topic: 'AI' },
form_inputs: { answer: 'approved' },
action: 'approve',
},
)
act(() => {
result.current.handleHideGeneratedForm()
})
expect(result.current.showGeneratedForm).toBe(false)
})
it('should use the advanced-chat endpoint and skip remote fetches when app detail is missing', async () => {
appDetail = {
id: 'app-2',
mode: AppModeEnum.ADVANCED_CHAT,
}
const { result, rerender } = renderHook(() => useSingleRunFormParams({
id: 'node-9',
payload: currentInputs,
runInputData: {},
getInputVars,
setRunInputData: mockSetRunInputData,
}))
await act(async () => {
await result.current.handleFetchFormContent({ topic: 'hello' })
})
expect(mockFetchHumanInputNodeStepRunForm).toHaveBeenCalledWith(
'/apps/app-2/advanced-chat/workflows/draft/human-input/nodes/node-9/form',
{
inputs: { topic: 'hello' },
},
)
appDetail = undefined
rerender()
await act(async () => {
const data = await result.current.handleFetchFormContent({ topic: 'skip' })
expect(data).toBeNull()
})
expect(mockFetchHumanInputNodeStepRunForm).toHaveBeenCalledTimes(1)
})
})