From c638e91aec3e2215fee6351094792de7437553f1 Mon Sep 17 00:00:00 2001 From: Svetoslav Dekov Date: Fri, 15 May 2026 18:07:28 +0300 Subject: [PATCH] fix(editor): Stabilize workflow setup credentials state (#30513) --- .../WorkflowSetupSectionBody.test.ts | 1 + .../WorkflowSetupSectionBody.test.ts | 169 ++++++++++++++++++ .../components/WorkflowSetupSectionBody.vue | 18 +- 3 files changed, 183 insertions(+), 5 deletions(-) create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/workflowSetup/components/WorkflowSetupSectionBody.test.ts diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/WorkflowSetupSectionBody.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/WorkflowSetupSectionBody.test.ts index c2f276f28b6..08abcc45c97 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/WorkflowSetupSectionBody.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/WorkflowSetupSectionBody.test.ts @@ -51,6 +51,7 @@ vi.mock('@/app/stores/nodeTypes.store', () => ({ vi.mock('@/features/settings/environments.ee/environments.store', () => ({ default: () => ({ variablesAsObject: {} }), + useEnvironmentsStore: () => ({ variablesAsObject: {} }), })); function makeContext(): WorkflowSetupContext { diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/workflowSetup/components/WorkflowSetupSectionBody.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/workflowSetup/components/WorkflowSetupSectionBody.test.ts new file mode 100644 index 00000000000..ad10e8bf0fc --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/workflowSetup/components/WorkflowSetupSectionBody.test.ts @@ -0,0 +1,169 @@ +import { computed, nextTick, ref } from 'vue'; +import { fireEvent } from '@testing-library/vue'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createComponentRenderer } from '@/__tests__/render'; +import type { INodeUi } from '@/Interface'; +import WorkflowSetupSectionBody from './WorkflowSetupSectionBody.vue'; +import { makeWorkflowSetupSection } from '../__tests__/factories'; +import type { WorkflowSetupContext } from '../composables/useWorkflowSetupContext'; +import type { WorkflowSetupSection } from '../workflowSetup.types'; + +const workflowSetupContext = vi.hoisted(() => ({ + current: undefined as unknown as WorkflowSetupContext, +})); + +const credentialsStore = vi.hoisted(() => ({ + getCredentialById: vi.fn(), +})); + +const nodeTypesStore = vi.hoisted(() => ({ + getNodeType: vi.fn(), + getAllNodeTypes: vi.fn(), +})); + +const renderedCredentials = vi.hoisted(() => [] as unknown[]); + +vi.mock('../composables/useWorkflowSetupContext', () => ({ + useWorkflowSetupContext: () => workflowSetupContext.current, +})); + +vi.mock('@/features/credentials/credentials.store', () => ({ + useCredentialsStore: () => credentialsStore, +})); + +vi.mock('@/app/stores/nodeTypes.store', () => ({ + useNodeTypesStore: () => nodeTypesStore, +})); + +vi.mock('@/features/settings/environments.ee/environments.store', () => ({ + default: () => ({ variablesAsObject: {} }), + useEnvironmentsStore: () => ({ variablesAsObject: {} }), +})); + +vi.mock('@/features/credentials/components/NodeCredentials.vue', () => ({ + default: { + props: ['node'], + template: '
', + }, +})); + +vi.mock('@/features/ndv/parameters/components/ParameterInputList.vue', async () => { + const { defineComponent, h } = await import('vue'); + + return { + default: defineComponent({ + props: ['node'], + emits: ['valueChanged', 'parameterBlur'], + setup(props, { emit }) { + return () => { + renderedCredentials.push((props.node as INodeUi | undefined)?.credentials); + + return h( + 'button', + { + 'data-test-id': 'change-parameter', + onClick: () => + emit('valueChanged', { + name: 'parameters.formId', + value: 'form-1', + }), + }, + 'Change parameter', + ); + }; + }, + }), + }; +}); + +const renderComponent = createComponentRenderer(WorkflowSetupSectionBody); + +function makeContext(section: WorkflowSetupSection): WorkflowSetupContext { + const parameters = ref({ formId: '' }); + + return { + sections: computed(() => [section]), + steps: computed(() => [{ kind: 'section', section }]), + currentStepIndex: ref(0), + activeStep: computed(() => ({ kind: 'section', section })), + hasOtherUnhandledSteps: computed(() => false), + canAdvanceToNextIncomplete: computed(() => false), + credentialSelections: ref({ [section.targetNodeName]: { typeformApi: 'cred-1' } }), + terminalState: ref(null), + isReady: ref(true), + projectId: computed(() => 'project-1'), + credentialFlow: computed(() => undefined), + isActionPending: ref(false), + setCredential: vi.fn(), + setParameterValue: vi.fn((_setupSection, parameterName: string, value: unknown) => { + parameters.value = { ...parameters.value, [parameterName]: value }; + }), + getDisplayNode: (setupSection) => + ({ + ...setupSection.node, + parameters: parameters.value, + }) as INodeUi, + isSectionComplete: () => false, + isCredentialTestFailed: () => false, + isSectionSkipped: () => false, + isStepComplete: () => false, + isStepSkipped: () => false, + isStepHandled: () => false, + goToStep: vi.fn(), + goToNext: vi.fn(), + goToPrev: vi.fn(), + goToNextIncomplete: vi.fn(), + apply: vi.fn(async () => {}), + skipCurrentStep: vi.fn(async () => {}), + }; +} + +describe('WorkflowSetupSectionBody', () => { + beforeEach(() => { + vi.clearAllMocks(); + renderedCredentials.length = 0; + credentialsStore.getCredentialById.mockReturnValue({ id: 'cred-1', name: 'Typeform account' }); + nodeTypesStore.getNodeType.mockReturnValue({ + name: 'n8n-nodes-base.typeformTrigger', + properties: [ + { + displayName: 'Form Name or ID', + name: 'formId', + type: 'options', + default: '', + }, + ], + }); + nodeTypesStore.getAllNodeTypes.mockReturnValue({ + nodeTypes: {}, + init: async () => {}, + getByNameAndVersion: () => undefined, + }); + }); + + it('keeps the synthetic credentials object stable when parameter values change', async () => { + const section = makeWorkflowSetupSection({ + id: 'Typeform Trigger:typeformApi', + targetNodeName: 'Typeform Trigger', + credentialType: 'typeformApi', + parameterNames: ['formId'], + node: { + id: 'typeform-trigger', + name: 'Typeform Trigger', + type: 'n8n-nodes-base.typeformTrigger', + typeVersion: 1, + parameters: { formId: '' }, + }, + }); + workflowSetupContext.current = makeContext(section); + + const { getByTestId } = renderComponent({ props: { section } }); + await nextTick(); + + const credentialsBeforeParameterChange = renderedCredentials.at(-1); + await fireEvent.click(getByTestId('change-parameter')); + await nextTick(); + + expect(renderedCredentials.at(-1)).toBe(credentialsBeforeParameterChange); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/workflowSetup/components/WorkflowSetupSectionBody.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/workflowSetup/components/WorkflowSetupSectionBody.vue index acbdf0e8c07..5f2b04fc535 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/workflowSetup/components/WorkflowSetupSectionBody.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/workflowSetup/components/WorkflowSetupSectionBody.vue @@ -6,7 +6,7 @@ import NodeCredentials from '@/features/credentials/components/NodeCredentials.v import ParameterInputList from '@/features/ndv/parameters/components/ParameterInputList.vue'; import { useCredentialsStore } from '@/features/credentials/credentials.store'; import { useNodeTypesStore } from '@/app/stores/nodeTypes.store'; -import useEnvironmentsStore from '@/features/settings/environments.ee/environments.store'; +import { useEnvironmentsStore } from '@/features/settings/environments.ee/environments.store'; import { ExpressionLocalResolveContextSymbol } from '@/app/constants'; import { Workflow, type IConnections, type INodeProperties } from 'n8n-workflow'; import type { ExpressionLocalResolveContext } from '@/app/types/expressions'; @@ -32,6 +32,17 @@ const selectedCredentialId = computed(() => : null, ); +const selectedCredentials = computed(() => { + const type = credentialType.value; + if (!type) return undefined; + + const cred = selectedCredentialId.value + ? credentialsStore.getCredentialById(selectedCredentialId.value) + : undefined; + + return cred ? { [type]: { id: cred.id, name: cred.name } } : {}; +}); + const targetNodeNames = computed(() => props.section.credentialTargetNodes.map((node) => node.name), ); @@ -79,12 +90,9 @@ function getRootParameterName(parameterName: string) { const displayNode = computed(() => { const node = ctx.getDisplayNode(props.section); if (!credentialType.value) return node; - const cred = selectedCredentialId.value - ? credentialsStore.getCredentialById(selectedCredentialId.value) - : undefined; return { ...node, - credentials: cred ? { [credentialType.value]: { id: cred.id, name: cred.name } } : {}, + credentials: selectedCredentials.value, } as INodeUi; });