Refactor human input form item types

This commit is contained in:
JzoNg 2026-04-22 07:31:52 +08:00
parent 0c8dec3315
commit 6c4f293719
15 changed files with 171 additions and 79 deletions

View File

@ -15,7 +15,7 @@ const createInput = (overrides: Partial<FormInputItem>): FormInputItem => ({
variable: 'field',
required: false,
max_length: 128,
type: InputVarType.textInput,
type: InputVarType.paragraph,
default: {
type: 'constant' as const,
value: '',
@ -57,7 +57,7 @@ describe('human-input utils', () => {
it('should initialize text fields with constants and variable defaults', () => {
const formInputs = [
createInput({
type: InputVarType.textInput,
type: InputVarType.paragraph,
output_variable_name: 'name',
default: { type: 'constant', value: 'John', selector: [] },
}),
@ -90,7 +90,7 @@ describe('human-input utils', () => {
it('should fallback to empty string when variable default is missing', () => {
const formInputs = [
createInput({
type: InputVarType.textInput,
type: InputVarType.paragraph,
output_variable_name: 'summary',
default: { type: 'variable', value: '', selector: [] },
}),

View File

@ -4,7 +4,10 @@ import dayjs from 'dayjs'
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'
import relativeTime from 'dayjs/plugin/relativeTime'
import utc from 'dayjs/plugin/utc'
import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
import {
isParagraphFormInput,
UserActionButtonType,
} from '@/app/components/workflow/nodes/human-input/types'
import 'dayjs/locale/en'
import 'dayjs/locale/zh-cn'
import 'dayjs/locale/ja'
@ -32,9 +35,9 @@ export const splitByOutputVar = (content: string): string[] => {
}
export const initializeInputs = (formInputs: FormInputItem[], defaultValues: Record<string, string> = {}) => {
const initialInputs: Record<string, any> = {}
const initialInputs: Record<string, string | undefined> = {}
formInputs.forEach((item) => {
if (item.type === 'text-input' || item.type === 'paragraph')
if (isParagraphFormInput(item))
initialInputs[item.output_variable_name] = item.default.type === 'variable' ? defaultValues[item.output_variable_name] || '' : item.default.value
else
initialInputs[item.output_variable_name] = undefined

View File

@ -8,7 +8,10 @@ import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { InputVarType } from '@/app/components/workflow/types'
import {
createDefaultParagraphFormInput,
isParagraphFormInput,
} from '@/app/components/workflow/nodes/human-input/types'
import ActionButton from '../../../action-button'
import { VariableX } from '../../../icons/src/vender/workflow'
import Modal from '../../../modal'
@ -36,15 +39,7 @@ type HITLInputComponentUIProps = {
const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({
nodeId,
varName,
formInput = {
type: InputVarType.paragraph,
output_variable_name: varName,
default: {
type: 'constant',
selector: [],
value: '',
},
},
formInput,
onChange,
onRename,
onRemove,
@ -56,6 +51,10 @@ const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({
readonly,
}) => {
const { t } = useTranslation()
const resolvedFormInput = formInput || createDefaultParagraphFormInput(varName)
const paragraphDefault = isParagraphFormInput(resolvedFormInput)
? resolvedFormInput.default
: null
const [isShowEditModal, {
setTrue: showEditModal,
setFalse: hideEditModal,
@ -72,8 +71,7 @@ const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({
if (editBtn)
editBtn.removeEventListener('click', showEditModal)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
}, [showEditModal])
const removeBtnRef = useRef<HTMLDivElement>(null)
useEffect(() => {
@ -97,8 +95,8 @@ const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({
}, [hideEditModal, onChange, onRename, varName])
const isDefaultValueVariable = useMemo(() => {
return formInput.default?.type === 'variable'
}, [formInput.default?.type])
return paragraphDefault?.type === 'variable'
}, [paragraphDefault])
return (
<div
@ -117,7 +115,7 @@ const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({
{/* Default Value Info */}
{isDefaultValueVariable && (
<VariableBlock
variables={formInput.default?.selector}
variables={paragraphDefault?.selector || []}
workflowNodesMap={workflowNodesMap}
getVarType={getVarType}
environmentVariables={environmentVariables}
@ -126,7 +124,9 @@ const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({
/>
)}
{!isDefaultValueVariable && (
<div className="max-w-full truncate system-xs-medium text-components-input-text-filled">{formInput.default?.value}</div>
<div className="max-w-full truncate system-xs-medium text-components-input-text-filled">
{paragraphDefault?.value ?? resolvedFormInput.type}
</div>
)}
</div>
@ -166,7 +166,7 @@ const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({
<InputField
nodeId={nodeId}
isEdit
payload={formInput}
payload={resolvedFormInput}
onChange={handleChange}
onCancel={hideEditModal}
/>

View File

@ -1,4 +1,4 @@
import type { FormInputItem, FormInputItemDefault } from '@/app/components/workflow/nodes/human-input/types'
import type { FormInputItem, FormInputItemDefault, ParagraphFormInput } from '@/app/components/workflow/nodes/human-input/types'
import type { ValueSelector } from '@/app/components/workflow/types'
import { Button } from '@langgenius/dify-ui/button'
import { produce } from 'immer'
@ -6,7 +6,10 @@ import * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import { InputVarType } from '@/app/components/workflow/types'
import {
createDefaultParagraphFormInput,
isParagraphFormInput,
} from '@/app/components/workflow/nodes/human-input/types'
import { getKeyboardKeyNameBySystem } from '@/app/components/workflow/utils'
import PrePopulate from './pre-populate'
@ -19,11 +22,6 @@ type InputFieldProps = {
onChange: (newPayload: FormInputItem) => void
onCancel: () => void
}
const defaultPayload: FormInputItem = {
type: InputVarType.paragraph,
output_variable_name: '',
default: { type: 'constant', selector: [], value: '' },
}
const InputField: React.FC<InputFieldProps> = ({
nodeId,
isEdit,
@ -32,7 +30,13 @@ const InputField: React.FC<InputFieldProps> = ({
onCancel,
}) => {
const { t } = useTranslation()
const [tempPayload, setTempPayload] = useState(payload || defaultPayload)
const [tempPayload, setTempPayload] = useState<FormInputItem>(() => payload || createDefaultParagraphFormInput())
const paragraphPayload = useMemo<ParagraphFormInput>(() => {
if (isParagraphFormInput(tempPayload))
return tempPayload
return createDefaultParagraphFormInput(tempPayload.output_variable_name)
}, [tempPayload])
const nameValid = useMemo(() => {
const name = tempPayload.output_variable_name.trim()
if (!name)
@ -46,12 +50,9 @@ const InputField: React.FC<InputFieldProps> = ({
return
onChange(tempPayload)
}, [nameValid, onChange, tempPayload])
const defaultValueConfig = tempPayload.default
const handleDefaultValueChange = useCallback((key: keyof FormInputItemDefault) => {
return (value: ValueSelector | string) => {
const nextValue = produce(tempPayload, (draft) => {
if (!draft.default)
draft.default = { type: 'constant', selector: [], value: '' }
const nextValue = produce(paragraphPayload, (draft) => {
if (key === 'selector') {
draft.default.type = 'variable'
draft.default.selector = value as ValueSelector
@ -66,7 +67,7 @@ const InputField: React.FC<InputFieldProps> = ({
})
setTempPayload(nextValue)
}
}, [tempPayload])
}, [paragraphPayload])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@ -109,14 +110,14 @@ const InputField: React.FC<InputFieldProps> = ({
{t(`${i18nPrefix}.prePopulateField`, { ns: 'workflow' })}
</div>
<PrePopulate
isVariable={defaultValueConfig?.type === 'variable'}
isVariable={paragraphPayload.default.type === 'variable'}
onIsVariableChange={(isVariable) => {
handleDefaultValueChange('type')(isVariable ? 'variable' : 'constant')
}}
nodeId={nodeId}
valueSelector={defaultValueConfig?.selector}
valueSelector={paragraphPayload.default.selector}
onValueSelectorChange={handleDefaultValueChange('selector')}
value={defaultValueConfig?.value}
value={paragraphPayload.default.value}
onValueChange={handleDefaultValueChange('value')}
/>
</div>

View File

@ -23,7 +23,7 @@ const createData = (overrides: Partial<HumanInputNodeType> = {}): HumanInputNode
}],
form_content: 'Please review this request',
inputs: [{
type: InputVarType.textInput,
type: InputVarType.paragraph,
output_variable_name: 'review_result',
default: {
selector: [],

View File

@ -103,7 +103,7 @@ vi.mock('../components/form-content', () => ({
<button
type="button"
onClick={() => props.onFormInputsChange([{
type: InputVarType.textInput,
type: InputVarType.paragraph,
output_variable_name: 'email',
default: {
selector: [],
@ -230,7 +230,7 @@ const createData = (overrides: Partial<HumanInputNodeType> = {}): HumanInputNode
}],
form_content: 'Please review this request',
inputs: [{
type: InputVarType.textInput,
type: InputVarType.paragraph,
output_variable_name: 'review_result',
default: {
selector: [],

View File

@ -86,10 +86,14 @@ describe('variable-in-markdown', () => {
it('should render note values and replace node ids with labels for variable defaults', () => {
const { rerender } = render(
<Note
defaultInput={{
type: 'variable',
selector: ['node-1', 'output'],
value: '',
input={{
type: 'paragraph',
output_variable_name: 'approval',
default: {
type: 'variable',
selector: ['node-1', 'output'],
value: '',
},
}}
nodeName={nodeId => nodeId === 'node-1' ? 'Start Node' : nodeId}
/>,
@ -99,10 +103,14 @@ describe('variable-in-markdown', () => {
rerender(
<Note
defaultInput={{
type: 'constant',
value: 'Plain value',
selector: [],
input={{
type: 'paragraph',
output_variable_name: 'approval',
default: {
type: 'constant',
value: 'Plain value',
selector: [],
},
}}
nodeName={nodeId => nodeId}
/>,

View File

@ -77,7 +77,7 @@ const createEmailConfig = (overrides: Partial<EmailConfig> = {}): EmailConfig =>
})
const formInputs: FormInputItem[] = [{
type: InputVarType.textInput,
type: InputVarType.paragraph,
output_variable_name: 'name',
default: {
selector: ['start', 'name'],

View File

@ -25,6 +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 EmailInput from './recipient/email-input'
@ -92,7 +93,7 @@ const EmailSenderModal = ({
const generatedInputs = useMemo(() => {
const defaultValueSelectors = (formInputs || []).reduce((acc, input) => {
if (input.default.type === 'variable') {
if (isParagraphFormInput(input) && input.default.type === 'variable') {
acc.push(input.default.selector)
}
return acc

View File

@ -38,6 +38,21 @@ const FormContentPreview: FC<FormContentPreviewProps> = ({
return node?.data.title || nodeId
}, [nodes])
const renderInputPreview = React.useCallback(({ node }: { node?: { properties?: Record<string, unknown> } }) => {
const name = String(node?.properties?.dataName ?? '')
const input = formInputs.find(i => i.output_variable_name === name)
if (!input) {
return (
<div>
Can't find note:
{name}
</div>
)
}
return <Note input={input} nodeName={nodeName} />
}, [formInputs, nodeName])
return (
<div
className="fixed top-[112px] z-10 max-h-[calc(100vh-116px)] w-[600px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg py-3 shadow-xl"
@ -64,22 +79,7 @@ const FormContentPreview: FC<FormContentPreviewProps> = ({
}
return <Variable path={newPath} />
},
section: ({ node }) => (() => {
const name = String(node?.properties?.dataName ?? '')
const input = formInputs.find(i => i.output_variable_name === name)
if (!input) {
return (
<div>
Can't find note:
{name}
</div>
)
}
const defaultInput = input.default
return (
<Note defaultInput={defaultInput!} nodeName={nodeName} />
)
})(),
section: renderInputPreview,
}}
/>
<div className="mt-3 flex flex-wrap gap-1 py-1">

View File

@ -1,5 +1,6 @@
import type * as React from 'react'
import type { FormInputItemDefault } from '../types'
import type { FormInputItem } from '../types'
import { isParagraphFormInput } from '../types'
const variableRegex = /\{\{#(.+?)#\}\}/g
const noteRegex = /\{\{#\$(.+?)#\}\}/g
@ -132,13 +133,21 @@ export const Variable: React.FC<{ path: string }> = ({ path }) => {
)
}
export const Note: React.FC<{ defaultInput: FormInputItemDefault, nodeName: (nodeId: string) => string }> = ({ defaultInput, nodeName }) => {
const isVariable = defaultInput.type === 'variable'
const path = `{{#${defaultInput.selector.join('.')}#}}`
export const Note: React.FC<{ input: FormInputItem, nodeName: (nodeId: string) => string }> = ({ input, nodeName }) => {
if (!isParagraphFormInput(input)) {
return (
<div className="my-3 rounded-[10px] bg-components-input-bg-normal px-2.5 py-2">
<span>{input.type}</span>
</div>
)
}
const isVariable = input.default.type === 'variable'
const path = `{{#${input.default.selector.join('.')}#}}`
const newPath = path ? replaceNodeIdsWithNames(path, nodeName) : path
return (
<div className="my-3 rounded-[10px] bg-components-input-bg-normal px-2.5 py-2">
{isVariable ? <Variable path={newPath} /> : <span>{defaultInput.value}</span>}
{isVariable ? <Variable path={newPath} /> : <span>{input.default.value}</span>}
</div>
)
}

View File

@ -16,7 +16,7 @@ vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
}))
const createFormInput = (overrides: Partial<FormInputItem> = {}): FormInputItem => ({
type: InputVarType.textInput,
type: InputVarType.paragraph,
output_variable_name: 'old_name',
default: {
selector: [],

View File

@ -37,7 +37,7 @@ const createPayload = (overrides: Partial<HumanInputNodeType> = {}): HumanInputN
delivery_methods: [],
form_content: 'Summary: {{#start.topic#}}',
inputs: [{
type: InputVarType.textInput,
type: InputVarType.paragraph,
output_variable_name: 'summary',
default: {
type: 'variable',

View File

@ -8,6 +8,7 @@ import { useStore as useAppStore } from '@/app/components/app/store'
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'
const i18nPrefix = 'nodes.humanInput'
@ -33,7 +34,7 @@ const useSingleRunFormParams = ({
const [requiredInputs, setRequiredInputs] = useState<Record<string, string>>({})
const generatedInputs = useMemo(() => {
const defaultInputs = inputs.inputs.reduce((acc, input) => {
if (input.default.type === 'variable') {
if (isParagraphFormInput(input) && input.default.type === 'variable') {
acc.push(`{{#${input.default.selector.join('.')}#}}`)
}
return acc

View File

@ -1,8 +1,8 @@
import type {
CommonNodeType,
InputVarType,
ValueSelector,
} from '@/app/components/workflow/types'
import { InputVarType } from '@/app/components/workflow/types'
export type HumanInputNodeType = CommonNodeType & {
delivery_methods: DeliveryMethod[]
@ -65,8 +65,77 @@ export type FormInputItemDefault = {
value: string
}
export type FormInputItem = {
type: InputVarType
type BaseFormInputItem = {
output_variable_name: string
}
export type ParagraphFormInput = BaseFormInputItem & {
type: InputVarType.paragraph
default: FormInputItemDefault
}
export type SelectFormInput = BaseFormInputItem & {
type: InputVarType.select
}
export type FileFormInput = BaseFormInputItem & {
type: InputVarType.singleFile
}
export type FileListFormInput = BaseFormInputItem & {
type: InputVarType.multiFiles
}
export type FormInputItem
= | ParagraphFormInput
| SelectFormInput
| FileFormInput
| FileListFormInput
export const isParagraphFormInput = (
input: FormInputItem,
): input is ParagraphFormInput => {
return input.type === InputVarType.paragraph
}
export const isSelectFormInput = (
input: FormInputItem,
): input is SelectFormInput => {
return input.type === InputVarType.select
}
export const isFileFormInput = (
input: FormInputItem,
): input is FileFormInput => {
return input.type === InputVarType.singleFile
}
export const isFileListFormInput = (
input: FormInputItem,
): input is FileListFormInput => {
return input.type === InputVarType.multiFiles
}
export const isFileLikeFormInput = (
input: FormInputItem,
): input is FileFormInput | FileListFormInput => {
return input.type === InputVarType.singleFile || input.type === InputVarType.multiFiles
}
export const supportsDefaultValue = (
input: FormInputItem,
): input is ParagraphFormInput => {
return isParagraphFormInput(input)
}
export const createDefaultParagraphFormInput = (
output_variable_name = '',
): ParagraphFormInput => ({
type: InputVarType.paragraph,
output_variable_name,
default: {
type: 'constant',
selector: [],
value: '',
},
})