From 3f372352d8b5791448ee35c36e5f9aca7f1f5fc7 Mon Sep 17 00:00:00 2001 From: JzoNg Date: Tue, 12 May 2026 17:27:14 +0800 Subject: [PATCH] fix(web): input fields variable name can not be duplicated --- .../__tests__/component-ui.spec.tsx | 23 ++++++++++++ .../__tests__/component.spec.tsx | 25 +++++++++++++ .../__tests__/input-field.spec.tsx | 25 +++++++++++++ .../plugins/hitl-input-block/component-ui.tsx | 8 ++++- .../plugins/hitl-input-block/component.tsx | 9 ++++- .../plugins/hitl-input-block/input-field.tsx | 26 ++++++++++---- .../__tests__/form-content.spec.tsx | 36 +++++++++++++++++++ .../components/add-input-field.tsx | 3 ++ .../human-input/components/form-content.tsx | 4 +++ .../hooks/__tests__/use-form-content.spec.ts | 20 +++++++++++ .../human-input/hooks/use-form-content.ts | 7 ++++ web/i18n/en-US/workflow.json | 1 + web/i18n/zh-Hans/workflow.json | 1 + 13 files changed, 179 insertions(+), 9 deletions(-) diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/component-ui.spec.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/component-ui.spec.tsx index aa97e32509..15930b9e7a 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/component-ui.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/component-ui.spec.tsx @@ -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', () => { diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/component.spec.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/component.spec.tsx index d17495dfde..dc97ee05a5 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/component.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/component.spec.tsx @@ -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( + , + ) + + 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() diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/input-field.spec.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/input-field.spec.tsx index d59bae5e53..4af00aa704 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/input-field.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/input-field.spec.tsx @@ -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( + , + ) + + 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() diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/component-ui.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/component-ui.tsx index aae8081c15..3b15e347ef 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/component-ui.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/component-ui.tsx @@ -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 = ({ nodeId, varName, formInput, + unavailableVariableNames = [], onChange, onRename, onRemove, @@ -91,12 +93,15 @@ const HITLInputComponentUI: FC = ({ }, [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 = ({ nodeId={nodeId} isEdit payload={formInput} + unavailableVariableNames={unavailableVariableNames} onChange={handleChange} onCancel={hideEditModal} /> diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/component.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/component.tsx index e13ac2aee3..48f16b188c 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/component.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/component.tsx @@ -45,8 +45,14 @@ const HITLInputComponent: FC = ({ }) => { 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 = ({ return } onChange(formInputs.map(item => item.output_variable_name === varName ? newPayload : item)) - }, [formInputs, onChange, payload, varName]) + }, [formInputs, onChange, payload, unavailableVariableNames, varName]) return (
= ({ nodeId={nodeId} varName={varName} formInput={payload} + unavailableVariableNames={unavailableVariableNames} onChange={handleChange} onRename={onRename} onRemove={onRemove} diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/input-field.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/input-field.tsx index 2938dd082c..5a8b3fa927 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/input-field.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/input-field.tsx @@ -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 = ({ nodeId, isEdit, payload, + unavailableVariableNames = [], onChange, onCancel, }) => { @@ -73,14 +75,24 @@ const InputField: React.FC = ({ 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 = ({ }} autoFocus /> - {tempPayload.output_variable_name && !nameValid && ( + {tempPayload.output_variable_name && variableNameError && (
- {t(`${i18nPrefix}.variableNameInvalid`, { ns: 'workflow' })} + {t(`${i18nPrefix}.${variableNameError}`, { ns: 'workflow' })}
)}
diff --git a/web/app/components/workflow/nodes/human-input/components/__tests__/form-content.spec.tsx b/web/app/components/workflow/nodes/human-input/components/__tests__/form-content.spec.tsx index 218da57fbb..9c1912d71b 100644 --- a/web/app/components/workflow/nodes/human-input/components/__tests__/form-content.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/__tests__/form-content.spec.tsx @@ -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( + , + ) + + 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) diff --git a/web/app/components/workflow/nodes/human-input/components/add-input-field.tsx b/web/app/components/workflow/nodes/human-input/components/add-input-field.tsx index 35049f683b..b8fe4ebf9a 100644 --- a/web/app/components/workflow/nodes/human-input/components/add-input-field.tsx +++ b/web/app/components/workflow/nodes/human-input/components/add-input-field.tsx @@ -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 = ({ nodeId, + unavailableVariableNames, onSave, onCancel, }) => { @@ -19,6 +21,7 @@ const AddInputField: FC = ({ diff --git a/web/app/components/workflow/nodes/human-input/components/form-content.tsx b/web/app/components/workflow/nodes/human-input/components/form-content.tsx index 3a27904e7d..0946d47f4c 100644 --- a/web/app/components/workflow/nodes/human-input/components/form-content.tsx +++ b/web/app/components/workflow/nodes/human-input/components/form-content.tsx @@ -60,6 +60,9 @@ const FormContent: FC = ({ const [newFormInputs, setNewFormInputs] = useState([]) const handleInsertHITLNode = (onInsert: (command: LexicalCommand, 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 = ({ Popup: ({ onClose, onInsert }) => ( input.output_variable_name)} onSave={handleInsertHITLNode(onInsert!)} onCancel={onClose} /> diff --git a/web/app/components/workflow/nodes/human-input/hooks/__tests__/use-form-content.spec.ts b/web/app/components/workflow/nodes/human-input/hooks/__tests__/use-form-content.spec.ts index c676fb55d4..f31c24ae61 100644 --- a/web/app/components/workflow/nodes/human-input/hooks/__tests__/use-form-content.spec.ts +++ b/web/app/components/workflow/nodes/human-input/hooks/__tests__/use-form-content.spec.ts @@ -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)) diff --git a/web/app/components/workflow/nodes/human-input/hooks/use-form-content.ts b/web/app/components/workflow/nodes/human-input/hooks/use-form-content.ts index c1a7591437..476c814c16 100644 --- a/web/app/components/workflow/nodes/human-input/hooks/use-form-content.ts +++ b/web/app/components/workflow/nodes/human-input/hooks/use-form-content.ts @@ -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) diff --git a/web/i18n/en-US/workflow.json b/web/i18n/en-US/workflow.json index d78c38f4e0..4588a2e4df 100644 --- a/web/i18n/en-US/workflow.json +++ b/web/i18n/en-US/workflow.json @@ -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:", diff --git a/web/i18n/zh-Hans/workflow.json b/web/i18n/zh-Hans/workflow.json index 6cc5c6783d..289f1a54d7 100644 --- a/web/i18n/zh-Hans/workflow.json +++ b/web/i18n/zh-Hans/workflow.json @@ -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": "原因:",