mirror of
https://github.com/langgenius/dify.git
synced 2026-05-05 01:48:04 +08:00
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:
@ -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',
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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('Dify’s 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' },
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user