fix(editor): Restrict assignment fields in AI Assistant workflow setup (#33968)

This commit is contained in:
oleg 2026-07-10 14:10:27 +02:00 committed by GitHub
parent 008852c608
commit 678b9e00a0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 129 additions and 10 deletions

View File

@ -21,6 +21,7 @@ import type { INodeUi, INodeUpdatePropertiesInformation, IUpdateInformation } fr
import type { WorkflowSetupSection } from '../workflowSetup.types';
import { useWorkflowSetupContext } from '../composables/useWorkflowSetupContext';
import { AI_GATEWAY_MANAGED_TAG } from '../../constants';
import { findPlaceholderDetails } from '@n8n/utils/placeholder';
const props = defineProps<{
section: WorkflowSetupSection;
@ -75,6 +76,25 @@ const parameterDefinitions = computed<INodeProperties[]>(() => {
return nodeType.value.properties.filter((property) => names.has(property.name));
});
const assignmentCollectionEditableValueIndices = computed<Record<string, number[]>>(() => {
const result: Record<string, number[]> = {};
for (const parameter of parameterDefinitions.value) {
if (parameter.type !== 'assignmentCollection') continue;
const indices = new Set<number>();
const placeholderDetails = findPlaceholderDetails(
props.section.node.parameters[parameter.name],
);
for (const detail of placeholderDetails) {
if (detail.path[0] !== 'assignments' || detail.path[2] !== 'value') continue;
const match = /^\[(\d+)\]$/.exec(detail.path[1] ?? '');
if (match?.[1]) indices.add(Number.parseInt(match[1], 10));
}
result[parameter.name] = [...indices];
}
return result;
});
const revealedIssues = ref(new Set<string>());
watch(
@ -212,6 +232,7 @@ function onParameterValueChanged(update: IUpdateInformation) {
:remove-first-parameter-margin="true"
:remove-last-parameter-margin="true"
:options-overrides="{ hideExpressionSelector: true, hideFocusPanelButton: true }"
:assignment-collection-editable-value-indices="assignmentCollectionEditableValueIndices"
@value-changed="onParameterValueChanged"
@parameter-blur="revealParameterIssues"
/>

View File

@ -18,6 +18,7 @@ import { propertyNameFromExpression } from '@/app/utils/mappingUtils';
import { N8nIconButton, N8nTooltip } from '@n8n/design-system';
import { typeFromExpression } from '../../utils/assignmentCollection.utils';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import type { ParameterOptionsOverrides } from '@/features/ndv/shared/ndv.utils';
interface Props {
path: string;
@ -27,6 +28,8 @@ interface Props {
disableType?: boolean;
isReadOnly?: boolean;
index?: number;
valueOnly?: boolean;
optionsOverrides?: ParameterOptionsOverrides;
}
const props = defineProps<Props>();
@ -91,7 +94,7 @@ const valueParameter = computed<INodeProperties>(() => {
: 'value';
return {
name: 'value',
displayName: 'Value',
displayName: props.valueOnly && assignment.value.name ? assignment.value.name : 'Value',
default: '',
placeholder,
...assignmentTypeToNodeProperty(assignment.value.type ?? 'string'),
@ -165,14 +168,14 @@ const onValueDrop = async (droppedExpression: string) => {
>
<N8nIconButton
variant="ghost"
v-if="!isReadOnly"
v-if="!isReadOnly && !valueOnly"
size="small"
icon="grip-vertical"
:class="[$style.iconButton, $style.defaultTopPadding, 'drag-handle']"
/>
<N8nIconButton
variant="ghost"
v-if="!isReadOnly"
v-if="!isReadOnly && !valueOnly"
size="small"
icon="trash-2"
data-test-id="assignment-remove"
@ -181,7 +184,32 @@ const onValueDrop = async (droppedExpression: string) => {
/>
<div :class="$style.inputs">
<InputTriple middle-width="100px">
<div v-if="valueOnly" :class="$style.value">
<ParameterInputFull
display-options
hide-issues
hide-hint
is-assignment
:is-read-only="isReadOnly"
:options-overrides="optionsOverrides"
:parameter="valueParameter"
:value="assignment.value"
:path="`${path}.value`"
data-test-id="assignment-value"
@update="onAssignmentValueChange"
@blur="onBlur"
@drop="onValueDrop"
/>
<ParameterInputHint
v-if="resolvedExpressionString"
data-test-id="parameter-expression-preview-value"
:class="[$style.hint]"
:highlight="highlightHint"
:hint="hint"
single-line
/>
</div>
<InputTriple v-else middle-width="100px">
<template #left>
<ParameterInputFull
display-options

View File

@ -133,6 +133,42 @@ describe('AssignmentCollection.vue', () => {
expect(getInput(within(assignments[1]).getByTestId('assignment-value'))).toHaveValue('value2');
});
it('keeps source assignments unchanged while editing restricted values', async () => {
const sourceValue: AssignmentCollectionValue = {
assignments: [
{
name: 'teamId',
value: '<__PLACEHOLDER_VALUE__Linear team ID__>',
type: 'string',
} as AssignmentValue,
],
};
const { findByTestId, queryByTestId } = renderComponent({
props: {
value: sourceValue,
editableValueIndices: [0],
},
});
const assignment = await findByTestId('assignment');
const valueInput = getInput(within(assignment).getByTestId('assignment-value'));
await userEvent.type(valueInput, 'team-1');
await fireEvent.blur(valueInput);
expect(sourceValue).toEqual({
assignments: [
{
name: 'teamId',
value: '<__PLACEHOLDER_VALUE__Linear team ID__>',
type: 'string',
},
],
});
expect(within(assignment).queryByTestId('assignment-remove')).not.toBeInTheDocument();
expect(queryByTestId('assignment-collection-drop-area')).not.toBeInTheDocument();
});
it('renders empty state properly', async () => {
const { getByTestId, queryByTestId } = renderComponent();
expect(getByTestId('assignment-collection-fields')).toBeInTheDocument();

View File

@ -20,6 +20,7 @@ import Draggable from 'vuedraggable';
import ExperimentalEmbeddedNdvMapper from '@/features/workflows/canvas/experimental/components/ExperimentalEmbeddedNdvMapper.vue';
import { ExpressionLocalResolveContextSymbol } from '@/app/constants';
import { useExperimentalNdvStore } from '@/features/workflows/canvas/experimental/experimentalNdv.store';
import type { ParameterOptionsOverrides } from '@/features/ndv/shared/ndv.utils';
import { N8nInputLabel } from '@n8n/design-system';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
@ -31,6 +32,8 @@ interface Props {
disableType?: boolean;
node: INode | null;
isReadOnly?: boolean;
editableValueIndices?: number[];
optionsOverrides?: ParameterOptionsOverrides;
}
const props = withDefaults(defineProps<Props>(), {
@ -50,10 +53,10 @@ const dropAreaContainer = useTemplateRef('dropArea');
function createParamValue(value: AssignmentCollectionValue): AssignmentCollectionValue {
return {
assignments:
value.assignments?.map((assignment) => {
if (!assignment.id) assignment.id = crypto.randomUUID();
return assignment;
}) ?? [],
value.assignments?.map((assignment) => ({
...assignment,
id: assignment.id ?? crypto.randomUUID(),
})) ?? [],
};
}
@ -72,6 +75,18 @@ const issues = computed(() => {
});
const empty = computed(() => state.paramValue.assignments.length === 0);
const hasRestrictedAssignments = computed(() => props.editableValueIndices !== undefined);
const visibleAssignments = computed(() => {
const editableIndices = props.editableValueIndices;
if (editableIndices === undefined) {
return state.paramValue.assignments.map((assignment, index) => ({ assignment, index }));
}
return editableIndices.flatMap((index) => {
const assignment = state.paramValue.assignments[index];
return assignment ? [{ assignment, index }] : [];
});
});
const activeDragField = computed(() => propertyNameFromExpression(ndvStore.value.draggableData));
const inputData = computed(() => ndvStore.value.ndvInputData?.[0]?.json);
const actions = computed(() => {
@ -166,7 +181,7 @@ function optionSelected(action: string) {
underline
color="text-dark"
>
<template #options>
<template v-if="!hasRestrictedAssignments" #options>
<ParameterOptions
:parameter="parameter"
:value="value"
@ -180,6 +195,7 @@ function optionSelected(action: string) {
<ExperimentalEmbeddedNdvMapper
v-if="
!hasRestrictedAssignments &&
experimentalNdvStore.isNdvInFocusPanelEnabled &&
dropAreaContainer?.$el &&
node &&
@ -194,6 +210,7 @@ function optionSelected(action: string) {
<div :class="$style.content">
<div :class="$style.assignments">
<Draggable
v-if="!hasRestrictedAssignments"
v-model="state.paramValue.assignments"
item-key="id"
handle=".drag-handle"
@ -215,9 +232,23 @@ function optionSelected(action: string) {
</Assignment>
</template>
</Draggable>
<Assignment
v-for="{ assignment, index } in visibleAssignments"
v-else
:key="assignment.id ?? index"
:model-value="assignment"
:index="index"
:path="`${path}.assignments.${index}`"
:issues="getIssues(index)"
:is-read-only="isReadOnly"
disable-type
:options-overrides="optionsOverrides"
value-only
@update:model-value="(value) => onAssignmentUpdate(index, value)"
/>
</div>
<div
v-if="!isReadOnly"
v-if="!isReadOnly && !hasRestrictedAssignments"
:class="$style.dropAreaWrapper"
data-test-id="assignment-collection-drop-area"
@click="addAssignment"

View File

@ -86,6 +86,7 @@ type Props = {
removeLastParameterMargin?: boolean;
newlyAddedParameters?: Set<string>;
optionsOverrides?: ParameterOptionsOverrides;
assignmentCollectionEditableValueIndices?: Record<string, number[]>;
layout?: 'inline';
};
@ -922,6 +923,8 @@ watch(
:is-read-only="isReadOnly"
:default-type="item.parameter.typeOptions?.assignment?.defaultType"
:disable-type="item.parameter.typeOptions?.assignment?.disableType"
:options-overrides="optionsOverrides"
:editable-value-indices="assignmentCollectionEditableValueIndices?.[item.parameter.name]"
@value-changed="valueChanged"
/>
<div v-else-if="credentialsParameterIndex !== index" class="parameter-item">