fix(web): support dynamic selector in human input step run & email configure

This commit is contained in:
JzoNg 2026-05-08 11:06:32 +08:00
parent 23d39beeed
commit d65cc21e85
5 changed files with 151 additions and 20 deletions

View File

@ -146,7 +146,7 @@ const createConfig = (overrides: Partial<EmailConfig> = {}): EmailConfig => ({
})
const createFormInput = (overrides: Partial<FormInputItem> = {}): FormInputItem => ({
type: InputVarType.textInput,
type: InputVarType.paragraph,
output_variable_name: 'user_name',
default: {
type: 'variable',
@ -156,6 +156,16 @@ const createFormInput = (overrides: Partial<FormInputItem> = {}): FormInputItem
...overrides,
})
const createDynamicSelectInput = (): FormInputItem => ({
type: InputVarType.select,
output_variable_name: 'decision',
option_source: {
type: 'variable',
selector: ['start', 'choices'],
value: [],
},
})
describe('human-input/delivery-method/test-email-sender', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -243,6 +253,68 @@ describe('human-input/delivery-method/test-email-sender', () => {
expect(handleOpenChange).toHaveBeenCalledWith(false)
})
it('should submit variables referenced by dynamic select option sources', async () => {
const user = userEvent.setup()
const { requests } = setupFetch()
renderWithProviders(
<EmailSenderModal
nodeId="human-node"
deliveryId="delivery-1"
open
onOpenChange={vi.fn()}
jumpToEmailConfigModal={vi.fn()}
config={createConfig({
body: '{{#url#}}',
})}
formInputs={[createDynamicSelectInput()]}
availableNodes={[
{
id: 'start',
type: 'custom',
position: { x: 0, y: 0 },
data: {
title: 'Start',
desc: '',
type: BlockEnum.Start,
},
},
]}
nodesOutputVars={[
{
nodeId: 'start',
title: 'Start',
vars: [
{
variable: 'choices',
type: VarType.arrayString,
},
],
},
]}
/>,
)
const sendButton = screen.getByRole('button', { name: 'workflow.nodes.humanInput.deliveryMethod.emailSender.send' })
expect(sendButton).toBeDisabled()
await user.type(screen.getByPlaceholderText('choices'), 'approve,reject')
expect(sendButton).toBeEnabled()
await user.click(sendButton)
await waitFor(() => expect(requests).toContainEqual(expect.objectContaining({
url: 'http://localhost:5001/console/api/apps/app-1/workflows/draft/human-input/nodes/human-node/delivery-test',
method: 'POST',
body: {
delivery_method_id: 'delivery-1',
inputs: {
'#start.choices#': 'approve,reject',
},
},
})))
})
it('should render fallback variable inputs and allow cancelling', async () => {
const user = userEvent.setup()
setupFetch()

View File

@ -2,7 +2,6 @@ import type { EmailConfig, FormInputItem } from '../../types'
import type {
Node,
NodeOutPutVar,
ValueSelector,
Var,
} from '@/app/components/workflow/types'
import { Button } from '@langgenius/dify-ui/button'
@ -26,8 +25,7 @@ import { InputVarType, VarType } from '@/app/components/workflow/types'
import { useAppContext } from '@/context/app-context'
import { useMembers } from '@/service/use-common'
import { useTestEmailSender } from '@/service/use-workflow'
import { isParagraphFormInput } from '../../types'
import { isOutput } from '../../utils'
import { getHumanInputFormDependencySelectors, isOutput } from '../../utils'
import EmailInput from './recipient/email-input'
const i18nPrefix = 'nodes.humanInput'
@ -97,14 +95,9 @@ const EmailSenderModal = ({
const accounts = members?.accounts || []
const generatedInputs = useMemo(() => {
const defaultValueSelectors = (formInputs || []).reduce((acc, input) => {
if (isParagraphFormInput(input) && input.default.type === 'variable') {
acc.push(input.default.selector)
}
return acc
}, [] as ValueSelector[])
const formInputDependencySelectors = getHumanInputFormDependencySelectors(formInputs || [])
const valueSelectors = doGetInputVars((formContent || '') + (config?.body || ''))
const variables = unionBy([...valueSelectors, ...defaultValueSelectors], item => item.join('.')).map((item) => {
const variables = unionBy([...valueSelectors, ...formInputDependencySelectors], item => item.join('.')).map((item) => {
const varInfo = getNodeInfoById(availableNodes, item[0]!)?.data
return {

View File

@ -170,6 +170,61 @@ describe('human-input/hooks/use-single-run-form-params', () => {
])
})
it('should include variables referenced by dynamic select option sources', () => {
currentInputs = createPayload({
inputs: [
{
type: InputVarType.paragraph,
output_variable_name: 'summary',
default: {
type: 'variable',
selector: ['start', 'topic'],
value: '',
},
},
{
type: InputVarType.select,
output_variable_name: 'choice',
option_source: {
type: 'variable',
selector: ['start', 'choices'],
value: [],
},
},
],
})
getInputVars.mockReturnValue([
createInputVar(),
createInputVar({
label: 'Choices',
variable: '#start.choices#',
value_selector: ['start', 'choices'],
}),
])
const { result } = renderHook(() => useSingleRunFormParams({
id: 'node-1',
payload: currentInputs,
runInputData: {},
getInputVars,
setRunInputData: mockSetRunInputData,
}))
expect(getInputVars).toHaveBeenCalledWith([
'{{#start.topic#}}',
'{{#start.choices#}}',
'Summary: {{#start.topic#}}',
])
expect(result.current.forms[0]!.inputs).toEqual([
expect.objectContaining({ variable: '#start.topic#' }),
expect.objectContaining({ variable: '#start.choices#' }),
])
expect(result.current.getDependentVars()).toEqual([
['start', 'topic'],
['start', 'choices'],
])
})
it('should fetch and submit generated forms in workflow mode while keeping required inputs', async () => {
const formDataWithFiles = {
...mockFormData,

View File

@ -10,8 +10,7 @@ import { getProcessedHumanInputFormInputs } from '@/app/components/base/chat/cha
import { fetchHumanInputNodeStepRunForm, submitHumanInputNodeStepRunForm } from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
import useNodeCrud from '../../_base/hooks/use-node-crud'
import { isParagraphFormInput } from '../types'
import { isOutput } from '../utils'
import { getHumanInputFormDependencySelectors, isOutput } from '../utils'
const i18nPrefix = 'nodes.humanInput'
@ -36,13 +35,9 @@ const useSingleRunFormParams = ({
const [formData, setFormData] = useState<HumanInputFormData | null>(null)
const [requiredInputs, setRequiredInputs] = useState<Record<string, string>>({})
const generatedInputs = useMemo(() => {
const defaultInputs = inputs.inputs.reduce((acc, input) => {
if (isParagraphFormInput(input) && input.default.type === 'variable') {
acc.push(`{{#${input.default.selector.join('.')}#}}`)
}
return acc
}, [] as string[])
const allInputs = getInputVars([...defaultInputs, inputs.form_content || '']).filter(item => !isOutput(item.value_selector || []))
const formInputDependencyInputs = getHumanInputFormDependencySelectors(inputs.inputs)
.map(selector => `{{#${selector.join('.')}#}}`)
const allInputs = getInputVars([...formInputDependencyInputs, inputs.form_content || '']).filter(item => !isOutput(item.value_selector || []))
return allInputs
}, [getInputVars, inputs.form_content, inputs.inputs])

View File

@ -1,3 +1,19 @@
import type { FormInputItem } from './types'
import type { ValueSelector } from '@/app/components/workflow/types'
import { isParagraphFormInput, isSelectFormInput } from './types'
export const isOutput = (valueSelector: string[]) => {
return valueSelector[0] === '$output'
}
export const getHumanInputFormDependencySelectors = (inputs: FormInputItem[]): ValueSelector[] => {
return inputs.flatMap((input) => {
if (isParagraphFormInput(input) && input.default.type === 'variable' && input.default.selector.length > 0)
return [input.default.selector]
if (isSelectFormInput(input) && input.option_source.type === 'variable' && input.option_source.selector.length > 0)
return [input.option_source.selector]
return []
})
}