mirror of
https://github.com/langgenius/dify.git
synced 2026-05-13 08:57:28 +08:00
fix(web): input fields variable name can not be duplicated
This commit is contained in:
parent
c86fd4cba0
commit
3f372352d8
@ -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', () => {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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))
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:",
|
||||
|
||||
@ -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": "原因:",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user