fix(web): input fields variable name can not be duplicated

This commit is contained in:
JzoNg 2026-05-12 17:27:14 +08:00
parent c86fd4cba0
commit 3f372352d8
13 changed files with 179 additions and 9 deletions

View File

@ -213,6 +213,29 @@ describe('HITLInputComponentUI', () => {
expect(queryByRole('textbox')).not.toBeInTheDocument()
})
it('should prevent renaming to an existing variable name', async () => {
const {
findByRole,
onChange,
onRename,
} = renderComponent({
unavailableVariableNames: ['existing_name'],
})
fireEvent.click(await screen.findByRole('button', { name: 'common.operation.edit' }))
const textbox = await findByRole('textbox')
fireEvent.change(textbox, { target: { value: 'existing_name' } })
expect(screen.getByText('workflow.nodes.humanInput.insertInputField.variableNameDuplicated')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onChange).not.toHaveBeenCalled()
expect(onRename).not.toHaveBeenCalled()
})
})
describe('Default formInput', () => {

View File

@ -129,6 +129,31 @@ describe('HITLInputComponent', () => {
expect(onChange.mock.calls[0]![0][0].output_variable_name).toBe('renamed_name')
})
it('should ignore rename when the target variable name already exists', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<HITLInputComponent
nodeKey="node-key-duplicate"
nodeId="node-duplicate"
varName="user_name"
formInputs={[
createInput(),
createInput({ output_variable_name: 'renamed_name' }),
]}
onChange={onChange}
onRename={vi.fn()}
onRemove={vi.fn()}
workflowNodesMap={{}}
/>,
)
await user.click(screen.getByRole('button', { name: 'emit-rename' }))
expect(onChange).not.toHaveBeenCalled()
})
it('should update existing payload when variable name stays the same', async () => {
const user = userEvent.setup()
const onChange = vi.fn()

View File

@ -116,6 +116,31 @@ describe('InputField', () => {
expect(onChange).not.toHaveBeenCalled()
})
it('should disable save and show validation error when variable name already exists', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<InputField
nodeId="node-duplicate-name"
isEdit={false}
payload={createPayload()}
unavailableVariableNames={['existing_name']}
onChange={onChange}
onCancel={vi.fn()}
/>,
)
const inputs = screen.getAllByRole('textbox')
await user.clear(inputs[0]!)
await user.type(inputs[0]!, 'existing_name')
expect(screen.getByText('workflow.nodes.humanInput.insertInputField.variableNameDuplicated')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i })).toBeDisabled()
await user.keyboard('{Control>}{Enter}{/Control}')
expect(onChange).not.toHaveBeenCalled()
})
it('should call onChange when saving a valid payload in edit mode', async () => {
const user = userEvent.setup()
const onChange = vi.fn()

View File

@ -26,6 +26,7 @@ type HITLInputComponentUIProps = {
nodeId: string
varName: string
formInput?: FormInputItem
unavailableVariableNames?: string[]
onChange: (input: FormInputItem) => void
onRename: (payload: FormInputItem, oldName: string) => void
onRemove: (varName: string) => void
@ -44,6 +45,7 @@ const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({
nodeId,
varName,
formInput,
unavailableVariableNames = [],
onChange,
onRename,
onRemove,
@ -91,12 +93,15 @@ const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({
}, [onRemove, varName])
const handleChange = useCallback((newPayload: FormInputItem) => {
if (newPayload.output_variable_name !== varName && unavailableVariableNames.includes(newPayload.output_variable_name))
return
if (varName === newPayload.output_variable_name)
onChange(newPayload)
else
onRename(newPayload, varName)
hideEditModal()
}, [hideEditModal, onChange, onRename, varName])
}, [hideEditModal, onChange, onRename, unavailableVariableNames, varName])
const isDefaultValueVariable = useMemo(() => {
return paragraphDefault?.type === 'variable'
@ -203,6 +208,7 @@ const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({
nodeId={nodeId}
isEdit
payload={formInput}
unavailableVariableNames={unavailableVariableNames}
onChange={handleChange}
onCancel={hideEditModal}
/>

View File

@ -45,8 +45,14 @@ const HITLInputComponent: FC<HITLInputComponentProps> = ({
}) => {
const [ref] = useSelectOrDelete(nodeKey, DELETE_HITL_INPUT_BLOCK_COMMAND)
const payload = formInputs.find(item => item.output_variable_name === varName)
const unavailableVariableNames = formInputs
.map(item => item.output_variable_name)
.filter(name => name !== varName)
const handleChange = useCallback((newPayload: FormInputItem) => {
if (newPayload.output_variable_name !== varName && unavailableVariableNames.includes(newPayload.output_variable_name))
return
if (!payload) {
onChange([...formInputs, newPayload])
return
@ -58,7 +64,7 @@ const HITLInputComponent: FC<HITLInputComponentProps> = ({
return
}
onChange(formInputs.map(item => item.output_variable_name === varName ? newPayload : item))
}, [formInputs, onChange, payload, varName])
}, [formInputs, onChange, payload, unavailableVariableNames, varName])
return (
<div
@ -69,6 +75,7 @@ const HITLInputComponent: FC<HITLInputComponentProps> = ({
nodeId={nodeId}
varName={varName}
formInput={payload}
unavailableVariableNames={unavailableVariableNames}
onChange={handleChange}
onRename={onRename}
onRemove={onRemove}

View File

@ -31,6 +31,7 @@ type InputFieldProps = {
nodeId: string
isEdit: boolean
payload?: FormInputItem
unavailableVariableNames?: string[]
onChange: (newPayload: FormInputItem) => void
onCancel: () => void
}
@ -38,6 +39,7 @@ const InputField: React.FC<InputFieldProps> = ({
nodeId,
isEdit,
payload,
unavailableVariableNames = [],
onChange,
onCancel,
}) => {
@ -73,14 +75,24 @@ const InputField: React.FC<InputFieldProps> = ({
return createDefaultParagraphFormInput(tempPayload.output_variable_name)
}, [tempPayload])
const nameValid = useMemo(() => {
const unavailableVariableNameSet = useMemo(() => {
return new Set(unavailableVariableNames.map(name => name.trim()).filter(Boolean))
}, [unavailableVariableNames])
const variableNameError = useMemo(() => {
const name = tempPayload.output_variable_name.trim()
if (!name)
return false
return null
if (name.includes(' '))
return false
return /^[a-z_]\w{0,29}$/.test(name)
}, [tempPayload.output_variable_name])
return 'variableNameInvalid'
if (!/^[a-z_]\w{0,29}$/.test(name))
return 'variableNameInvalid'
if (unavailableVariableNameSet.has(name))
return 'variableNameDuplicated'
return null
}, [tempPayload.output_variable_name, unavailableVariableNameSet])
const nameValid = useMemo(() => {
return !!tempPayload.output_variable_name.trim() && !variableNameError
}, [tempPayload.output_variable_name, variableNameError])
const handleSave = useCallback(() => {
if (!nameValid)
return
@ -223,9 +235,9 @@ const InputField: React.FC<InputFieldProps> = ({
}}
autoFocus
/>
{tempPayload.output_variable_name && !nameValid && (
{tempPayload.output_variable_name && variableNameError && (
<div className="mt-1 px-1 system-xs-regular text-text-destructive-secondary">
{t(`${i18nPrefix}.variableNameInvalid`, { ns: 'workflow' })}
{t(`${i18nPrefix}.${variableNameError}`, { ns: 'workflow' })}
</div>
)}
</div>

View File

@ -64,6 +64,7 @@ vi.mock('@/app/components/base/prompt-editor', () => ({
vi.mock('../add-input-field', () => ({
__esModule: true,
default: (props: {
unavailableVariableNames?: string[]
onSave: (payload: {
type: string
output_variable_name: string
@ -231,6 +232,41 @@ describe('FormContent', () => {
expect(container.firstChild).toHaveClass('pointer-events-none')
})
it('should not insert a new input when the variable name already exists', () => {
render(
<FormContent
nodeId="node-2"
value="Initial content"
onChange={onChange}
formInputs={[{
type: 'paragraph',
output_variable_name: 'approval',
default: {
type: 'constant',
selector: [],
value: '',
},
} as never]}
onFormInputsChange={onFormInputsChange}
onFormInputItemRename={onFormInputItemRename}
onFormInputItemRemove={onFormInputItemRemove}
editorKey={1}
isExpand={false}
availableVars={[]}
availableNodes={[]}
/>,
)
expect(mockAddInputField).toHaveBeenCalledWith(expect.objectContaining({
unavailableVariableNames: ['approval'],
}))
fireEvent.click(screen.getByText('save-input'))
expect(mockOnInsert).not.toHaveBeenCalled()
expect(onFormInputsChange).not.toHaveBeenCalled()
})
it('should render the mac hotkey hint when focused on macOS', () => {
mockIsMac.mockReturnValue(true)

View File

@ -6,12 +6,14 @@ import InputField from '@/app/components/base/prompt-editor/plugins/hitl-input-b
type Props = {
nodeId: string
unavailableVariableNames?: string[]
onSave: (newPayload: FormInputItem) => void
onCancel: () => void
}
const AddInputField: FC<Props> = ({
nodeId,
unavailableVariableNames,
onSave,
onCancel,
}) => {
@ -19,6 +21,7 @@ const AddInputField: FC<Props> = ({
<InputField
nodeId={nodeId}
isEdit={false}
unavailableVariableNames={unavailableVariableNames}
onChange={onSave}
onCancel={onCancel}
/>

View File

@ -60,6 +60,9 @@ const FormContent: FC<FormContentProps> = ({
const [newFormInputs, setNewFormInputs] = useState<FormInputItem[]>([])
const handleInsertHITLNode = (onInsert: (command: LexicalCommand<unknown>, params: any) => void) => {
return (payload: FormInputItem) => {
if (formInputs.some(input => input.output_variable_name === payload.output_variable_name))
return
const newFormInputs = [...(formInputs || []), payload]
onInsert(INSERT_HITL_INPUT_BLOCK_COMMAND, {
variableName: payload.output_variable_name,
@ -148,6 +151,7 @@ const FormContent: FC<FormContentProps> = ({
Popup: ({ onClose, onInsert }) => (
<AddInputField
nodeId={nodeId}
unavailableVariableNames={formInputs.map(input => input.output_variable_name)}
onSave={handleInsertHITLNode(onInsert!)}
onCancel={onClose}
/>

View File

@ -96,6 +96,26 @@ describe('human-input/use-form-content', () => {
expect(result.current.editorKey).toBe(1)
})
it('should not rename an input to an existing variable name', () => {
currentInputs = createPayload({
inputs: [
createFormInput(),
createFormInput({ output_variable_name: 'existing_name' }),
],
})
const { result } = renderHook(() => useFormContent('human-input-node', currentInputs))
act(() => {
result.current.handleFormInputItemRename(createFormInput({
output_variable_name: 'existing_name',
}), 'old_name')
})
expect(mockSetInputs).not.toHaveBeenCalled()
expect(mockHandleOutVarRenameChange).not.toHaveBeenCalled()
expect(result.current.editorKey).toBe(0)
})
it('should remove an input placeholder and its form input metadata', () => {
const { result } = renderHook(() => useFormContent('human-input-node', currentInputs))

View File

@ -29,6 +29,13 @@ const useFormContent = (id: string, payload: HumanInputNodeType) => {
const handleFormInputItemRename = useCallback((payload: FormInputItem, oldName: string) => {
const inputs = inputsRef.current
if (
oldName !== payload.output_variable_name
&& inputs.inputs.some(item => item.output_variable_name === payload.output_variable_name)
) {
return
}
const newInputs = produce(inputs, (draft) => {
draft.form_content = draft.form_content.replaceAll(`{{#$output.${oldName}#}}`, `{{#$output.${payload.output_variable_name}#}}`)
draft.inputs = draft.inputs.map(item => item.output_variable_name === oldName ? payload : item)

View File

@ -637,6 +637,7 @@
"nodes.humanInput.insertInputField.useConstantInstead": "Use Constant Instead",
"nodes.humanInput.insertInputField.useVarInstead": "Use Variable Instead",
"nodes.humanInput.insertInputField.variable": "variable",
"nodes.humanInput.insertInputField.variableNameDuplicated": "Variable name already exists",
"nodes.humanInput.insertInputField.variableNameInvalid": "Variable name can only contain letters, numbers, and underscores, and cannot start with a number",
"nodes.humanInput.log.backstageInputURL": "Backstage input URL:",
"nodes.humanInput.log.reason": "Reason:",

View File

@ -637,6 +637,7 @@
"nodes.humanInput.insertInputField.useConstantInstead": "使用常量代替",
"nodes.humanInput.insertInputField.useVarInstead": "使用变量代替",
"nodes.humanInput.insertInputField.variable": "变量",
"nodes.humanInput.insertInputField.variableNameDuplicated": "变量名已存在",
"nodes.humanInput.insertInputField.variableNameInvalid": "只能包含字母、数字和下划线,且不能以数字开头",
"nodes.humanInput.log.backstageInputURL": "表单输入 URL",
"nodes.humanInput.log.reason": "原因:",