fix(editor): Hide setup parameter issue icons until user interacts with input (#28010)

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
Svetoslav Dekov
2026-04-14 11:14:37 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 094a5b403e
commit 00b0558c2b
2 changed files with 188 additions and 1 deletions
@@ -0,0 +1,160 @@
import { createComponentRenderer } from '@/__tests__/render';
import { createTestNode } from '@/__tests__/mocks';
import { mockedStore } from '@/__tests__/utils';
import { createTestingPinia } from '@pinia/testing';
import { fireEvent } from '@testing-library/vue';
import SetupCardBody from './SetupCardBody.vue';
import type { NodeSetupState } from '@/features/setupPanel/setupPanel.types';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import type { INodeUi } from '@/Interface';
import { jsonParse } from 'n8n-workflow';
vi.mock('@/features/ndv/parameters/components/ParameterInputList.vue', () => ({
default: {
template: `<div data-test-id="parameter-input-list" :data-hidden-issues="JSON.stringify(hiddenIssuesInputs)">
<button data-test-id="change-param1" @click="$emit('valueChanged', { name: 'param1', value: 'v' })">Change param1</button>
<button data-test-id="change-param2" @click="$emit('valueChanged', { name: 'param2', value: 'v' })">Change param2</button>
<button data-test-id="blur-param1" @click="$emit('parameterBlur', 'param1')">Blur param1</button>
<button data-test-id="blur-param2" @click="$emit('parameterBlur', 'param2')">Blur param2</button>
</div>`,
props: [
'parameters',
'nodeValues',
'node',
'hideDelete',
'hiddenIssuesInputs',
'path',
'optionsOverrides',
'removeFirstParameterMargin',
'removeLastParameterMargin',
],
emits: ['valueChanged', 'parameterBlur'],
},
}));
const { mockUpdateNodeProperties } = vi.hoisted(() => ({
mockUpdateNodeProperties: vi.fn(),
}));
vi.mock('@/app/composables/useWorkflowState', () => ({
injectWorkflowState: vi.fn(() => ({
updateNodeProperties: mockUpdateNodeProperties,
})),
}));
const renderComponent = createComponentRenderer(SetupCardBody);
const NODE_PROPERTIES = [
{
name: 'param1',
displayName: 'Param 1',
type: 'string' as const,
required: true,
default: '',
},
{
name: 'param2',
displayName: 'Param 2',
type: 'string' as const,
required: true,
default: '',
},
];
const createState = (overrides: Partial<NodeSetupState> = {}): NodeSetupState => ({
node: createTestNode({
name: 'TestNode',
type: 'n8n-nodes-base.openAi',
parameters: {},
}) as INodeUi,
parameterIssues: { param1: ['Required'], param2: ['Required'] },
isTrigger: false,
isComplete: false,
...overrides,
});
function getHiddenIssues(container: Element): string[] {
const el = container.querySelector('[data-test-id="parameter-input-list"]');
return jsonParse<string[]>(el?.getAttribute('data-hidden-issues') ?? '[]', { fallbackValue: [] });
}
describe('SetupCardBody', () => {
let nodeTypesStore: ReturnType<typeof mockedStore<typeof useNodeTypesStore>>;
beforeEach(() => {
mockUpdateNodeProperties.mockClear();
createTestingPinia();
nodeTypesStore = mockedStore(useNodeTypesStore);
nodeTypesStore.getNodeType = vi.fn().mockReturnValue({ properties: NODE_PROPERTIES });
});
describe('hidden issues', () => {
it('should hide all parameter issues initially', () => {
const { container } = renderComponent({
props: { state: createState() },
});
expect(getHiddenIssues(container)).toEqual(expect.arrayContaining(['param1', 'param2']));
});
it('should reveal parameter issues when a value is changed', async () => {
const { container, getByTestId } = renderComponent({
props: { state: createState() },
});
await fireEvent.click(getByTestId('change-param1'));
const hidden = getHiddenIssues(container);
expect(hidden).not.toContain('param1');
expect(hidden).toContain('param2');
});
it('should reveal parameter issues on blur', async () => {
const { container, getByTestId } = renderComponent({
props: { state: createState() },
});
await fireEvent.click(getByTestId('blur-param2'));
const hidden = getHiddenIssues(container);
expect(hidden).toContain('param1');
expect(hidden).not.toContain('param2');
});
it('should keep revealed state when new parameters appear', async () => {
const { container, getByTestId, rerender } = renderComponent({
props: { state: createState() },
});
// Reveal param1
await fireEvent.click(getByTestId('change-param1'));
expect(getHiddenIssues(container)).not.toContain('param1');
// Add a new parameter via updated node type properties
nodeTypesStore.getNodeType = vi.fn().mockReturnValue({
properties: [
...NODE_PROPERTIES,
{ name: 'param3', displayName: 'Param 3', type: 'string', required: true, default: '' },
],
});
await rerender({
state: createState({
parameterIssues: {
param1: ['Required'],
param2: ['Required'],
param3: ['Required'],
},
}),
});
const hidden = getHiddenIssues(container);
// param1 was revealed and should stay revealed
expect(hidden).not.toContain('param1');
// param2 was never interacted with, stays hidden
expect(hidden).toContain('param2');
// param3 is new and should start hidden
expect(hidden).toContain('param3');
});
});
});
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, watch } from 'vue';
import { computed, ref, watch } from 'vue';
import { useI18n, type BaseTextKey } from '@n8n/i18n';
import { N8nLink, N8nText, N8nTooltip } from '@n8n/design-system';
import { type INodeProperties, NodeHelpers } from 'n8n-workflow';
@@ -117,6 +117,29 @@ const openNdv = () => {
ndvStore.setActiveNodeName(props.state.node.name, 'other');
};
// Hide parameter issues until the user interacts with a parameter.
const hideIssuesMap = ref(new Map<string, boolean>());
watch(
simpleParameters,
(params) => {
for (const p of params) {
if (!hideIssuesMap.value.has(p.name)) {
hideIssuesMap.value.set(p.name, true);
}
}
},
{ immediate: true },
);
const hiddenIssuesInputs = computed(() =>
[...hideIssuesMap.value.entries()].filter(([, hidden]) => hidden).map(([name]) => name),
);
const revealParameterIssues = (parameterName: string) => {
hideIssuesMap.value.set(parameterName, false);
};
const onCredentialSelected = (updateInfo: INodeUpdatePropertiesInformation) => {
if (!props.state.credentialType) return;
emit('interacted');
@@ -145,6 +168,8 @@ const onValueChanged = (parameterData: IUpdateInformation) => {
? parameterData.name.replace(/^parameters\./, '')
: parameterData.name;
revealParameterIssues(paramName);
workflowState.updateNodeProperties({
name: props.state.node.name,
properties: {
@@ -205,9 +230,11 @@ const onValueChanged = (parameterData: IUpdateInformation) => {
:remove-last-parameter-margin="true"
:node="state.node"
:hide-delete="true"
:hidden-issues-inputs="hiddenIssuesInputs"
:path="isWizard ? 'parameters' : undefined"
:options-overrides="{ hideExpressionSelector: true, hideFocusPanelButton: true }"
@value-changed="onValueChanged"
@parameter-blur="revealParameterIssues"
/>
<N8nLink