refactor(editor): Move node issues to workflow document store (no-changelog) (#29390)

This commit is contained in:
Alex Grozav
2026-04-28 13:53:59 +00:00
committed by GitHub
parent e04f027b5d
commit 39154b9037
17 changed files with 323 additions and 306 deletions
@@ -132,7 +132,6 @@ describe('WorkflowHeaderDraftPublishActions', () => {
const setupEnabledPublishButton = (overrides = {}) => {
workflowsStore.workflowTriggerNodes = [triggerNode];
workflowsStore.nodesIssuesExist = false;
Object.assign(workflowsStore, overrides);
};
@@ -93,6 +93,12 @@ const containsTrigger = computed((): boolean => {
return foundTriggers.value.length > 0;
});
const nodesWithValidationIssues = computed(
() => workflowDocumentStore.value.nodesWithValidationIssues,
);
const hasNodeIssues = computed(() => workflowDocumentStore.value.hasNodeValidationIssues);
type WorkflowPublishState =
| 'not-published-not-eligible' // No trigger nodes or has errors
| 'not-published-eligible' // Can be published for first time
@@ -109,7 +115,7 @@ const workflowPublishState = computed((): WorkflowPublishState => {
// Not published states
if (!hasBeenPublished) {
const canPublish = containsTrigger.value && !workflowsStore.nodesIssuesExist;
const canPublish = containsTrigger.value && !hasNodeIssues.value;
return canPublish ? 'not-published-eligible' : 'not-published-not-eligible';
}
@@ -118,7 +124,7 @@ const workflowPublishState = computed((): WorkflowPublishState => {
return 'published-invalid-trigger';
}
if (workflowsStore.nodesIssuesExist) {
if (hasNodeIssues.value) {
return 'published-node-issues';
}
@@ -207,15 +213,15 @@ const publishButtonConfig = computed(() => {
if (props.isNewWorkflow) {
return {
text: i18n.baseText('workflows.publish'),
enabled: containsTrigger.value && !workflowsStore.nodesIssuesExist,
enabled: containsTrigger.value && !hasNodeIssues.value,
showIndicator: false,
indicatorClass: '',
tooltip: !containsTrigger.value
? i18n.baseText('workflows.publishModal.noTriggerMessage')
: workflowsStore.nodesIssuesExist
: hasNodeIssues.value
? i18n.baseText('workflowActivator.showMessage.activeChangedNodesIssuesExistTrue.title', {
interpolate: { count: workflowsStore.nodesWithIssues.length },
adjustToNumber: workflowsStore.nodesWithIssues.length,
interpolate: { count: nodesWithValidationIssues.value.length },
adjustToNumber: nodesWithValidationIssues.value.length,
})
: '',
showVersionInfo: false,
@@ -232,8 +238,8 @@ const publishButtonConfig = computed(() => {
tooltip: !containsTrigger.value
? i18n.baseText('workflows.publishModal.noTriggerMessage')
: i18n.baseText('workflowActivator.showMessage.activeChangedNodesIssuesExistTrue.title', {
interpolate: { count: workflowsStore.nodesWithIssues.length },
adjustToNumber: workflowsStore.nodesWithIssues.length,
interpolate: { count: nodesWithValidationIssues.value.length },
adjustToNumber: nodesWithValidationIssues.value.length,
}),
showVersionInfo: false,
},
@@ -269,8 +275,8 @@ const publishButtonConfig = computed(() => {
tooltip: i18n.baseText(
'workflowActivator.showMessage.activeChangedNodesIssuesExistTrue.title',
{
interpolate: { count: workflowsStore.nodesWithIssues.length },
adjustToNumber: workflowsStore.nodesWithIssues.length,
interpolate: { count: nodesWithValidationIssues.value.length },
adjustToNumber: nodesWithValidationIssues.value.length,
},
),
showVersionInfo: true,
@@ -144,9 +144,6 @@ describe('WorkflowPublishModal', () => {
},
];
workflowsStore.nodesIssuesExist = false;
workflowsStore.nodesWithIssues = [];
mockPublishWorkflow.mockReset().mockResolvedValue({
success: true,
errorHandled: false,
@@ -64,7 +64,11 @@ const wfHasAnyChanges = computed(() => {
);
});
const hasNodeIssues = computed(() => workflowsStore.nodesIssuesExist);
const nodesWithValidationIssues = computed(
() => workflowDocumentStore.value.nodesWithValidationIssues,
);
const hasNodeIssues = computed(() => workflowDocumentStore.value.hasNodeValidationIssues);
const inputsDisabled = computed(() => {
return (
@@ -289,12 +293,12 @@ async function handlePublish() {
<N8nCallout v-else-if="activeCalloutId === 'nodeIssues'" theme="danger" icon="status-error">
{{
i18n.baseText('workflowActivator.showMessage.activeChangedNodesIssuesExistTrue.title', {
interpolate: { count: workflowsStore.nodesWithIssues.length },
adjustToNumber: workflowsStore.nodesWithIssues.length,
interpolate: { count: nodesWithValidationIssues.length },
adjustToNumber: nodesWithValidationIssues.length,
})
}}
<ul :class="$style.nodeLinks">
<li v-for="node in workflowsStore.nodesWithIssues" :key="node.id">
<li v-for="node in nodesWithValidationIssues" :key="node.id">
<N8nLink
size="small"
:to="`/workflow/${workflowsStore.workflowId}/${node.id}`"
@@ -63,6 +63,8 @@ const { mockDocumentStore } = vi.hoisted(() => {
getPinnedDataLastUpdate: vi.fn(),
getPinnedDataLastRemovedAt: vi.fn(),
getSnapshot: vi.fn(),
hasNodeValidationIssues: false,
nodeValidationIssues: [],
serialize: vi.fn(),
};
store.getSnapshot.mockReturnValue({
@@ -90,9 +92,7 @@ vi.mock('@/app/stores/workflows.store', () => {
workflowExecutionData: null,
activeExecutionId: undefined,
previousExecutionId: undefined,
nodesIssuesExist: false,
executionWaitingForWebhook: false,
workflowValidationIssues: [],
workflow: {
nodes: [],
id: '',
@@ -315,7 +315,7 @@ describe('useRunWorkflow({ router })', () => {
it('should not prevent running a webhook-based workflow that has issues', async () => {
const { runWorkflowApi } = useRunWorkflow({ router });
vi.mocked(workflowsStore).nodesIssuesExist = true;
mockDocumentStore.hasNodeValidationIssues = true;
vi.mocked(workflowsStore).runWorkflow.mockResolvedValue({
executionId: '123',
waitingForWebhook: true,
@@ -323,7 +323,7 @@ describe('useRunWorkflow({ router })', () => {
await expect(runWorkflowApi({} as IStartRunData)).resolves.not.toThrow();
vi.mocked(workflowsStore).nodesIssuesExist = false;
mockDocumentStore.hasNodeValidationIssues = false;
});
it('should handle workflow run failure', async () => {
@@ -365,7 +365,7 @@ describe('useRunWorkflow({ router })', () => {
vi.mocked(uiStore).activeActions = [''];
vi.mocked(workflowsStore).runWorkflow.mockResolvedValue(mockExecutionResponse);
vi.mocked(workflowsStore).nodesIssuesExist = true;
mockDocumentStore.hasNodeValidationIssues = true;
mockDocumentStore.serialize.mockReturnValue({
id: 'workflowId',
nodes: [],
@@ -384,7 +384,7 @@ describe('useRunWorkflow({ router })', () => {
vi.mocked(pushConnectionStore).isConnected = true;
vi.mocked(workflowsStore).runWorkflow.mockResolvedValue(mockExecutionResponse);
vi.mocked(workflowsStore).nodesIssuesExist = false;
mockDocumentStore.hasNodeValidationIssues = false;
mockDocumentStore.serialize.mockReturnValue({
id: 'workflowId',
nodes: [],
@@ -448,7 +448,7 @@ describe('useRunWorkflow({ router })', () => {
vi.mocked(pushConnectionStore).isConnected = true;
vi.mocked(workflowsStore).runWorkflow.mockResolvedValue(mockExecutionResponse);
vi.mocked(workflowsStore).nodesIssuesExist = false;
mockDocumentStore.hasNodeValidationIssues = false;
mockDocumentStore.serialize.mockReturnValue({
id: 'workflowId',
nodes: [],
@@ -681,7 +681,7 @@ describe('useRunWorkflow({ router })', () => {
vi.mocked(pushConnectionStore).isConnected = true;
vi.mocked(workflowsStore).runWorkflow.mockResolvedValue(mockExecutionResponse);
vi.mocked(workflowsStore).nodesIssuesExist = false;
mockDocumentStore.hasNodeValidationIssues = false;
mockDocumentStore.serialize.mockReturnValue(workflowData);
vi.mocked(workflowsStore).getWorkflowRunData = mockRunData;
vi.mocked(agentRequestStore).getAgentRequest.mockReturnValue(agentRequest);
@@ -729,7 +729,7 @@ describe('useRunWorkflow({ router })', () => {
vi.mocked(pushConnectionStore).isConnected = true;
vi.mocked(workflowsStore).runWorkflow.mockResolvedValue(mockExecutionResponse);
vi.mocked(workflowsStore).nodesIssuesExist = false;
mockDocumentStore.hasNodeValidationIssues = false;
mockDocumentStore.serialize.mockReturnValue(
mock<WorkflowData>({ id: 'workflowId', nodes: [] }),
);
@@ -758,7 +758,7 @@ describe('useRunWorkflow({ router })', () => {
vi.mocked(pushConnectionStore).isConnected = true;
vi.mocked(workflowsStore).runWorkflow.mockResolvedValue(mockExecutionResponse);
vi.mocked(workflowsStore).nodesIssuesExist = false;
mockDocumentStore.hasNodeValidationIssues = false;
mockDocumentStore.serialize.mockReturnValue(
mock<WorkflowData>({ id: 'workflowId', nodes: [] }),
);
@@ -813,7 +813,7 @@ describe('useRunWorkflow({ router })', () => {
beforeEach(() => {
vi.mocked(pushConnectionStore).isConnected = true;
vi.mocked(workflowsStore).runWorkflow.mockResolvedValue({ executionId: 'exec-123' });
vi.mocked(workflowsStore).nodesIssuesExist = false;
mockDocumentStore.hasNodeValidationIssues = false;
mockDocumentStore.checkIfNodeHasChatParent.mockReturnValue(false);
mockDocumentStore.checkIfToolNodeHasChatParent.mockReturnValue(false);
});
@@ -873,7 +873,7 @@ describe('useRunWorkflow({ router })', () => {
beforeEach(() => {
vi.mocked(pushConnectionStore).isConnected = true;
vi.mocked(workflowsStore).runWorkflow.mockResolvedValue(mockExecutionResponse);
vi.mocked(workflowsStore).nodesIssuesExist = false;
mockDocumentStore.hasNodeValidationIssues = false;
vi.mocked(workflowsStore).getWorkflowRunData = {
NodeName: [],
};
@@ -8,6 +8,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
import { NodeConnectionTypes } from 'n8n-workflow';
import type { IConnections } from 'n8n-workflow';
import type { ITag, WorkflowHistory } from '@n8n/rest-api-client';
import type { Scope } from '@n8n/permissions';
import {
@@ -100,6 +101,127 @@ describe('workflowDocument.store orchestration', () => {
expect(uiStore.stateIsDirty).toBe(true);
});
describe('nodeValidationIssues', () => {
it('collects issues only from connected, enabled nodes', () => {
const workflowDocumentStore = useWorkflowDocumentStore(createWorkflowDocumentId('test-wf'));
const connections: IConnections = {
Start: {
main: [[{ node: 'Fetch', type: NodeConnectionTypes.Main, index: 0 }]],
},
};
workflowDocumentStore.setNodes([
createNode({ name: 'Start', type: 'n8n-nodes-base.manualTrigger' }),
createNode({
name: 'Fetch',
type: 'n8n-nodes-base.httpRequest',
issues: {
parameters: {
url: ['Missing URL', 'Invalid URL.'],
},
credentials: {
httpBasicAuth: ['Credentials not set'],
},
},
}),
createNode({
name: 'Disconnected',
type: 'n8n-nodes-base.set',
issues: {
parameters: { field: ['Should be ignored'] },
},
}),
createNode({
name: 'Disabled Node',
type: 'n8n-nodes-base.set',
disabled: true,
issues: {
parameters: { field: ['Disabled issue'] },
},
}),
]);
workflowDocumentStore.setConnections(connections);
const issues = workflowDocumentStore.nodeValidationIssues;
expect(issues).toEqual([
{ node: 'Fetch', type: 'parameters', value: ['Missing URL', 'Invalid URL.'] },
{ node: 'Fetch', type: 'credentials', value: ['Credentials not set'] },
]);
});
});
describe('formatNodeIssueMessage', () => {
it('joins array entries and trims trailing period', () => {
const workflowDocumentStore = useWorkflowDocumentStore(createWorkflowDocumentId('test-wf'));
const message = workflowDocumentStore.formatNodeIssueMessage([
'Missing URL',
'Invalid value.',
]);
expect(message).toBe('Missing URL, Invalid value');
});
it('returns string representation for non-array values', () => {
const workflowDocumentStore = useWorkflowDocumentStore(createWorkflowDocumentId('test-wf'));
expect(workflowDocumentStore.formatNodeIssueMessage('Simple issue.')).toBe('Simple issue.');
});
});
describe('hasNodeValidationIssues', () => {
it('should return true when a node has issues and connected', () => {
const workflowDocumentStore = useWorkflowDocumentStore(createWorkflowDocumentId('test-wf'));
workflowDocumentStore.setNodes([
createNode({ name: 'Node1', issues: { parameters: { field: ['Error message'] } } }),
createNode({ name: 'Node2' }),
]);
workflowDocumentStore.setConnections({
Node1: { main: [[{ node: 'Node2', type: NodeConnectionTypes.Main, index: 0 }]] },
});
const hasIssues = workflowDocumentStore.hasNodeValidationIssues;
expect(hasIssues).toBe(true);
});
it('should return false when node has issues but it is not connected', () => {
const workflowDocumentStore = useWorkflowDocumentStore(createWorkflowDocumentId('test-wf'));
workflowDocumentStore.setNodes([
createNode({ name: 'Node1', issues: { parameters: { field: ['Error message'] } } }),
createNode({ name: 'Node2' }),
]);
const hasIssues = workflowDocumentStore.hasNodeValidationIssues;
expect(hasIssues).toBe(false);
});
it('should return false when no nodes have issues', () => {
const workflowDocumentStore = useWorkflowDocumentStore(createWorkflowDocumentId('test-wf'));
workflowDocumentStore.setNodes([
createNode({ name: 'Node1' }),
createNode({ name: 'Node2' }),
]);
workflowDocumentStore.setConnections({
Node1: { main: [[{ node: 'Node2', type: NodeConnectionTypes.Main, index: 0 }]] },
});
const hasIssues = workflowDocumentStore.hasNodeValidationIssues;
expect(hasIssues).toBe(false);
});
it('should return false when there are no nodes', () => {
const workflowDocumentStore = useWorkflowDocumentStore(createWorkflowDocumentId('test-wf'));
workflowDocumentStore.setNodes([]);
const hasIssues = workflowDocumentStore.hasNodeValidationIssues;
expect(hasIssues).toBe(false);
});
});
describe('serialize', () => {
it('assembles every doc field into WorkflowData', () => {
const workflowDocumentStore = useWorkflowDocumentStore(createWorkflowDocumentId('wf-42'));
@@ -28,6 +28,7 @@ import { useWorkflowDocumentExpression } from './workflowDocument/useWorkflowDoc
import { useWorkflowDocumentName } from './workflowDocument/useWorkflowDocumentName';
import { useWorkflowDocumentWorkflowObject } from './workflowDocument/useWorkflowDocumentWorkflowObject';
import { useWorkflowDocumentNodeMetadata } from './workflowDocument/useWorkflowDocumentNodeMetadata';
import { useWorkflowDocumentNodesIssues } from './workflowDocument/useWorkflowDocumentNodesIssues';
import { useUIStore } from '@/app/stores/ui.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
@@ -79,6 +80,7 @@ type MetaReturn = ReturnType<typeof useWorkflowDocumentMeta>;
type PinDataReturn = ReturnType<typeof useWorkflowDocumentPinData>;
type SettingsReturn = ReturnType<typeof useWorkflowDocumentSettings>;
type NodeMetadataReturn = ReturnType<typeof useWorkflowDocumentNodeMetadata>;
type NodesIssuesReturn = ReturnType<typeof useWorkflowDocumentNodesIssues>;
// Pairwise collision checks — add new composables here when they are created.
// If any pair shares a key, the corresponding tuple slot becomes an error type
@@ -101,6 +103,9 @@ void (0 as unknown as [
AssertNoOverlap<NodeMetadataReturn, PinDataReturn>,
AssertNoOverlap<NodeMetadataReturn, MetaReturn>,
AssertNoOverlap<NodeMetadataReturn, SettingsReturn>,
AssertNoOverlap<NodesIssuesReturn, NodesReturn>,
AssertNoOverlap<NodesIssuesReturn, ConnectionsReturn>,
AssertNoOverlap<NodesIssuesReturn, GraphReturn>,
]);
export type WorkflowDocumentId = `${string}@${string}`;
@@ -182,6 +187,11 @@ export function useWorkflowDocumentStore(id: WorkflowDocumentId) {
});
const workflowDocumentGraph = useWorkflowDocumentGraph(workflowObject);
const workflowDocumentExpression = useWorkflowDocumentExpression(workflowObject);
const workflowDocumentNodesIssues = useWorkflowDocumentNodesIssues({
allNodes: workflowDocumentNodes.allNodes,
outgoingConnectionsByNodeName: workflowDocumentConnections.outgoingConnectionsByNodeName,
incomingConnectionsByNodeName: workflowDocumentConnections.incomingConnectionsByNodeName,
});
// --- Cross-cut orchestration ---
// Each composable is self-contained and unaware of its siblings. This
@@ -355,6 +365,7 @@ export function useWorkflowDocumentStore(id: WorkflowDocumentId) {
...workflowDocumentGraph,
...workflowDocumentExpression,
...workflowDocumentNodeMetadata,
...workflowDocumentNodesIssues,
removeAllNodes,
hydrate,
reset,
@@ -0,0 +1,83 @@
import { computed, type ComputedRef } from 'vue';
import type { INodeUi, WorkflowValidationIssue } from '@/Interface';
import type { INodeConnections } from 'n8n-workflow';
export type WorkflowDocumentNodesIssuesDeps = {
allNodes: ComputedRef<INodeUi[]>;
outgoingConnectionsByNodeName: (nodeName: string) => INodeConnections;
incomingConnectionsByNodeName: (nodeName: string) => INodeConnections;
};
export function useWorkflowDocumentNodesIssues(deps: WorkflowDocumentNodesIssuesDeps) {
const nodesWithValidationIssues = computed<INodeUi[]>(() =>
deps.allNodes.value.filter((node) => {
const nodeHasIssues = Object.keys(node.issues ?? {}).length > 0;
const isConnected =
Object.keys(deps.outgoingConnectionsByNodeName(node.name)).length > 0 ||
Object.keys(deps.incomingConnectionsByNodeName(node.name)).length > 0;
return !node.disabled && isConnected && nodeHasIssues;
}),
);
const nodesWithValidationIssuesCount = computed(() => nodesWithValidationIssues.value.length);
const hasNodeValidationIssues = computed(() => nodesWithValidationIssuesCount.value > 0);
const nodeValidationIssues = computed(() => {
const issues: WorkflowValidationIssue[] = [];
const isStringOrStringArray = (value: unknown): value is string | string[] =>
typeof value === 'string' || Array.isArray(value);
deps.allNodes.value.forEach((node) => {
if (!node.issues || node.disabled) return;
const isConnected =
Object.keys(deps.outgoingConnectionsByNodeName(node.name)).length > 0 ||
Object.keys(deps.incomingConnectionsByNodeName(node.name)).length > 0;
if (!isConnected) return;
Object.entries(node.issues).forEach(([issueType, issueValue]) => {
if (!issueValue) return;
if (typeof issueValue === 'object' && !Array.isArray(issueValue)) {
Object.entries(issueValue).forEach(([_key, value]) => {
if (value) {
issues.push({
node: node.name,
type: issueType,
value,
});
}
});
} else {
issues.push({
node: node.name,
type: issueType,
value: isStringOrStringArray(issueValue) ? issueValue : String(issueValue),
});
}
});
});
return issues;
});
function formatNodeIssueMessage(issue: string | string[]): string {
if (Array.isArray(issue)) {
return issue.join(', ').replace(/\.$/, '');
}
return String(issue);
}
return {
nodesWithValidationIssues,
nodesWithValidationIssuesCount,
hasNodeValidationIssues,
nodeValidationIssues,
formatNodeIssueMessage,
};
}
@@ -16,7 +16,7 @@ import type { INodeUi, IWorkflowDb, IWorkflowSettings } from '@/Interface';
import type { IExecutionResponse } from '@/features/execution/executions/executions.types';
import { createEmptyRunExecutionData, createRunExecutionData, deepCopy } from 'n8n-workflow';
import type { IConnection, IConnections, INodeTypeDescription } from 'n8n-workflow';
import type { INodeTypeDescription } from 'n8n-workflow';
import { useUIStore } from '@/app/stores/ui.store';
import type { PushPayload } from '@n8n/api-types';
import { flushPromises } from '@vue/test-utils';
@@ -113,92 +113,6 @@ describe('useWorkflowsStore', () => {
expect(workflowsStore.workflow.id).toBe('');
});
describe('workflowValidationIssues', () => {
it('collects issues only from connected, enabled nodes', () => {
const connections: IConnections = {
Start: {
main: [
[
{
node: 'Fetch',
type: 'main',
index: 0,
},
],
],
},
};
workflowsStore.workflow.nodes = [
{
id: 'start',
name: 'Start',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
parameters: {},
position: [0, 0],
},
{
id: 'fetch',
name: 'Fetch',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 1,
parameters: {},
issues: {
parameters: {
url: ['Missing URL', 'Invalid URL.'],
},
credentials: {
httpBasicAuth: ['Credentials not set'],
},
},
position: [300, 0],
},
{
id: 'orphan',
name: 'Disconnected',
type: 'n8n-nodes-base.set',
typeVersion: 1,
parameters: {},
issues: {
parameters: { field: ['Should be ignored'] },
},
position: [0, 400],
},
{
id: 'disabled',
name: 'Disabled Node',
type: 'n8n-nodes-base.set',
typeVersion: 1,
disabled: true,
parameters: {},
issues: {
parameters: { field: ['Disabled issue'] },
},
position: [0, 600],
},
];
workflowsStore.workflow.connections = connections;
const issues = workflowsStore.workflowValidationIssues;
expect(issues).toEqual([
{ node: 'Fetch', type: 'parameters', value: ['Missing URL', 'Invalid URL.'] },
{ node: 'Fetch', type: 'credentials', value: ['Credentials not set'] },
]);
});
});
describe('formatIssueMessage', () => {
it('joins array entries and trims trailing period', () => {
const message = workflowsStore.formatIssueMessage(['Missing URL', 'Invalid value.']);
expect(message).toBe('Missing URL, Invalid value');
});
it('returns string representation for non-array values', () => {
expect(workflowsStore.formatIssueMessage('Simple issue.')).toBe('Simple issue.');
});
});
describe('allWorkflows', () => {
it('should return sorted workflows by name', () => {
const workflowsListStore = useWorkflowsListStore();
@@ -329,53 +243,6 @@ describe('useWorkflowsStore', () => {
});
});
describe('nodesIssuesExist', () => {
it('should return true when a node has issues and connected', () => {
workflowsStore.workflow.nodes = [
{ name: 'Node1', issues: { error: ['Error message'] } },
{ name: 'Node2' },
] as unknown as IWorkflowDb['nodes'];
workflowsStore.workflow.connections = {
Node1: { main: [[{ node: 'Node2' } as IConnection]] },
};
const hasIssues = workflowsStore.nodesIssuesExist;
expect(hasIssues).toBe(true);
});
it('should return false when node has issues but it is not connected', () => {
workflowsStore.workflow.nodes = [
{ name: 'Node1', issues: { error: ['Error message'] } },
{ name: 'Node2' },
] as unknown as IWorkflowDb['nodes'];
const hasIssues = workflowsStore.nodesIssuesExist;
expect(hasIssues).toBe(false);
});
it('should return false when no nodes have issues', () => {
workflowsStore.workflow.nodes = [
{ name: 'Node1' },
{ name: 'Node2' },
] as unknown as IWorkflowDb['nodes'];
workflowsStore.workflow.connections = {
Node1: { main: [[{ node: 'Node2' } as IConnection]] },
};
const hasIssues = workflowsStore.nodesIssuesExist;
expect(hasIssues).toBe(false);
});
it('should return false when there are no nodes', () => {
workflowsStore.workflow.nodes = [];
const hasIssues = workflowsStore.nodesIssuesExist;
expect(hasIssues).toBe(false);
});
});
describe('getWorkflowResultDataByNodeName()', () => {
it('should return null when no workflow run data is present', () => {
workflowsStore.workflowExecutionData = null;
@@ -5,7 +5,7 @@ import {
MAX_WORKFLOW_NAME_LENGTH,
} from '@/app/constants';
import { STORES } from '@n8n/stores';
import type { INodeUi, IStartRunData, IWorkflowDb, WorkflowValidationIssue } from '@/Interface';
import type { INodeUi, IStartRunData, IWorkflowDb } from '@/Interface';
import type {
IExecutionPushResponse,
IExecutionResponse,
@@ -184,76 +184,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
}, {});
});
const nodesWithIssues = computed(() =>
workflow.value.nodes.filter((node) => {
const nodeHasIssues = Object.keys(node.issues ?? {}).length > 0;
const isConnected =
Object.keys(outgoingConnectionsByNodeName(node.name)).length > 0 ||
Object.keys(incomingConnectionsByNodeName(node.name)).length > 0;
return !node.disabled && isConnected && nodeHasIssues;
}),
);
const nodesWithIssuesCount = computed(() => nodesWithIssues.value.length);
const nodesIssuesExist = computed(() => nodesWithIssuesCount.value > 0);
/**
* Get detailed validation issues for all connected, enabled nodes
*/
const workflowValidationIssues = computed(() => {
const issues: WorkflowValidationIssue[] = [];
const isStringOrStringArray = (value: unknown): value is string | string[] =>
typeof value === 'string' || Array.isArray(value);
workflow.value.nodes.forEach((node) => {
if (!node.issues || node.disabled) return;
const isConnected =
Object.keys(outgoingConnectionsByNodeName(node.name)).length > 0 ||
Object.keys(incomingConnectionsByNodeName(node.name)).length > 0;
if (!isConnected) return;
Object.entries(node.issues).forEach(([issueType, issueValue]) => {
if (!issueValue) return;
if (typeof issueValue === 'object' && !Array.isArray(issueValue)) {
// Handle nested issues (parameters, credentials)
Object.entries(issueValue).forEach(([_key, value]) => {
if (value) {
issues.push({
node: node.name,
type: issueType,
value,
});
}
});
} else {
// Handle direct issues
issues.push({
node: node.name,
type: issueType,
value: isStringOrStringArray(issueValue) ? issueValue : String(issueValue),
});
}
});
});
return issues;
});
/**
* Format issue message for display
*/
function formatIssueMessage(issue: string | string[]): string {
if (Array.isArray(issue)) {
return issue.join(', ').replace(/\.$/, '');
}
return String(issue);
}
const executedNode = computed(() => workflowExecutionData.value?.executedNode);
const getAllLoadedFinishedExecutions = computed(() => {
@@ -1248,11 +1178,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
connectionsByDestinationNode,
isWorkflowRunning,
nodesByName,
nodesWithIssuesCount,
nodesWithIssues,
nodesIssuesExist,
workflowValidationIssues,
formatIssueMessage,
executedNode,
getAllLoadedFinishedExecutions,
getWorkflowExecution,
@@ -148,6 +148,7 @@ vi.mock('vue-router', () => ({
}));
let workflowState: WorkflowState;
describe('AI Builder store', () => {
beforeEach(() => {
mockDocumentState = undefined;
@@ -1582,7 +1583,6 @@ describe('AI Builder store', () => {
describe('workflowTodos', () => {
it('returns empty array when no validation issues exist', () => {
workflowsStore.workflowValidationIssues = [];
workflowsStore.workflow.nodes = [];
const builderStore = useBuilderStore();
@@ -1590,9 +1590,16 @@ describe('AI Builder store', () => {
});
it('includes credential validation issues', () => {
workflowsStore.workflowValidationIssues = [
{ node: 'HTTP Request', type: 'credentials', value: 'Missing credentials' },
workflowsStore.workflow.nodes = [
{
...createTestNode({ name: 'HTTP Request' }),
issues: { credentials: { value: ['Missing credentials'] } },
},
createTestNode({ name: 'Issue Target' }),
];
workflowsStore.workflow.connections = {
'HTTP Request': { main: [[{ node: 'Issue Target', type: 'main', index: 0 }]] },
};
const builderStore = useBuilderStore();
expect(builderStore.workflowTodos).toContainEqual(
@@ -1601,7 +1608,6 @@ describe('AI Builder store', () => {
});
it('includes placeholder issues from node parameters', () => {
workflowsStore.workflowValidationIssues = [];
workflowsStore.workflow.nodes = [
{
id: 'node-1',
@@ -1622,9 +1628,6 @@ describe('AI Builder store', () => {
});
it('combines credential and placeholder issues', () => {
workflowsStore.workflowValidationIssues = [
{ node: 'HTTP Request', type: 'credentials', value: 'Missing credentials' },
];
workflowsStore.workflow.nodes = [
{
id: 'node-1',
@@ -1635,8 +1638,15 @@ describe('AI Builder store', () => {
parameters: {
url: '<__PLACEHOLDER_VALUE__Enter URL__>',
},
issues: {
credentials: { value: ['Missing credentials'] },
},
},
createTestNode({ id: 'issue-target-node', name: 'Issue Target' }),
];
workflowsStore.workflow.connections = {
'HTTP Request': { main: [[{ node: 'Issue Target', type: 'main', index: 0 }]] },
};
const builderStore = useBuilderStore();
expect(builderStore.workflowTodos.length).toBeGreaterThanOrEqual(2);
@@ -1651,7 +1661,6 @@ describe('AI Builder store', () => {
describe('placeholderIssues', () => {
it('returns empty array when nodes have no parameters', () => {
workflowsStore.workflowValidationIssues = [];
workflowsStore.workflow.nodes = [
{
id: 'node-1',
@@ -1668,7 +1677,6 @@ describe('AI Builder store', () => {
});
it('returns empty array when node has undefined parameters', () => {
workflowsStore.workflowValidationIssues = [];
workflowsStore.workflow.nodes = [
{
id: 'node-1',
@@ -1684,7 +1692,6 @@ describe('AI Builder store', () => {
});
it('detects placeholders in nested object parameters', () => {
workflowsStore.workflowValidationIssues = [];
workflowsStore.workflow.nodes = [
{
id: 'node-1',
@@ -1712,7 +1719,6 @@ describe('AI Builder store', () => {
});
it('detects placeholders in array parameters', () => {
workflowsStore.workflowValidationIssues = [];
workflowsStore.workflow.nodes = [
{
id: 'node-1',
@@ -1739,7 +1745,6 @@ describe('AI Builder store', () => {
});
it('detects multiple placeholders in the same node', () => {
workflowsStore.workflowValidationIssues = [];
workflowsStore.workflow.nodes = [
{
id: 'node-1',
@@ -1760,7 +1765,6 @@ describe('AI Builder store', () => {
});
it('detects placeholders across multiple nodes', () => {
workflowsStore.workflowValidationIssues = [];
workflowsStore.workflow.nodes = [
{
id: 'node-1',
@@ -1792,7 +1796,6 @@ describe('AI Builder store', () => {
});
it('deduplicates identical placeholder issues (same node, path, and label)', () => {
workflowsStore.workflowValidationIssues = [];
// Simulate a scenario where the same placeholder appears twice
// (which shouldn't happen in practice but tests the deduplication)
workflowsStore.workflow.nodes = [
@@ -1837,8 +1840,6 @@ describe('AI Builder store', () => {
},
];
workflowsStore.workflowValidationIssues = [];
const builderStore = useBuilderStore();
const placeholderIssues = builderStore.workflowTodos.filter((t) => t.type === 'parameters');
// Should be skipped because the message already exists
@@ -1864,8 +1865,6 @@ describe('AI Builder store', () => {
},
];
workflowsStore.workflowValidationIssues = [];
const builderStore = useBuilderStore();
const placeholderIssues = builderStore.workflowTodos.filter((t) => t.type === 'parameters');
// Should still create the placeholder issue
@@ -1873,7 +1872,6 @@ describe('AI Builder store', () => {
});
it('ignores non-string parameter values', () => {
workflowsStore.workflowValidationIssues = [];
workflowsStore.workflow.nodes = [
{
id: 'node-1',
@@ -1894,7 +1892,6 @@ describe('AI Builder store', () => {
});
it('ignores strings that do not match placeholder format', () => {
workflowsStore.workflowValidationIssues = [];
workflowsStore.workflow.nodes = [
{
id: 'node-1',
@@ -1916,7 +1913,6 @@ describe('AI Builder store', () => {
});
it('ignores placeholder with empty label', () => {
workflowsStore.workflowValidationIssues = [];
workflowsStore.workflow.nodes = [
{
id: 'node-1',
@@ -1936,13 +1932,20 @@ describe('AI Builder store', () => {
});
it('filters out non-credential and non-parameter validation issues', () => {
workflowsStore.workflowValidationIssues = [
{ node: 'HTTP Request', type: 'credentials', value: 'Missing credentials' },
{ node: 'HTTP Request', type: 'parameters', value: 'Missing parameter' },
{ node: 'HTTP Request', type: 'execution', value: 'Execution error' },
{ node: 'HTTP Request', type: 'unknown' as 'parameters', value: 'Unknown issue' },
workflowsStore.workflow.nodes = [
{
...createTestNode({ name: 'HTTP Request' }),
issues: {
credentials: { value: ['Missing credentials'] },
parameters: { value: ['Missing parameter'] },
execution: true,
},
},
createTestNode({ name: 'Issue Target' }),
];
workflowsStore.workflow.nodes = [];
workflowsStore.workflow.connections = {
'HTTP Request': { main: [[{ node: 'Issue Target', type: 'main', index: 0 }]] },
};
const builderStore = useBuilderStore();
// Should only include credentials and parameters types
@@ -17,10 +17,9 @@ import { useLogsStore } from '@/app/stores/logs.store';
import { useUIStore } from '@/app/stores/ui.store';
import { useBuilderStore } from '../../builder.store';
const workflowValidationIssuesRef = ref<
const builderWorkflowTodosRef = ref<
Array<{ node: string; type: string; value: string | string[] }>
>([]);
const workflowTodosRef = ref<Array<{ node: string; type: string; value: string | string[] }>>([]);
const executionWaitingForWebhookRef = ref(false);
const selectedTriggerNodeNameRef = ref<string | undefined>(undefined);
const hasNoCreditsRemainingRef = ref(false);
@@ -101,8 +100,7 @@ describe('ExecuteMessage', () => {
vi.clearAllMocks();
runWorkflowMock.mockReset();
showMessageMock.mockReset();
workflowValidationIssuesRef.value = [];
workflowTodosRef.value = [];
builderWorkflowTodosRef.value = [];
executionWaitingForWebhookRef.value = false;
selectedTriggerNodeNameRef.value = undefined;
hasNoCreditsRemainingRef.value = false;
@@ -129,12 +127,6 @@ describe('ExecuteMessage', () => {
workflowsStore.workflow.nodes = workflowNodes as unknown as INodeUi[];
workflowsStore.workflow.connections = {} as never;
Object.defineProperty(workflowsStore, 'workflowValidationIssues', {
get: () => workflowValidationIssuesRef.value,
});
workflowsStore.formatIssueMessage = vi.fn((value: string | string[]) =>
Array.isArray(value) ? value.join(', ') : String(value),
);
Object.defineProperty(workflowsStore, 'workflowExecutionData', {
get: () => workflowExecutionDataRef,
});
@@ -158,7 +150,7 @@ describe('ExecuteMessage', () => {
get: () => hasNoCreditsRemainingRef.value,
});
Object.defineProperty(builderStore, 'workflowTodos', {
get: () => workflowTodosRef.value,
get: () => builderWorkflowTodosRef.value,
});
builderStore.trackWorkflowBuilderJourney = vi.fn();
@@ -167,8 +159,7 @@ describe('ExecuteMessage', () => {
it('disables execution when validation issues exist', () => {
const issue = { node: 'Start Trigger', type: 'parameters', value: 'Missing field' };
workflowValidationIssuesRef.value = [issue];
workflowTodosRef.value = [issue];
builderWorkflowTodosRef.value = [issue];
const { getAllByTestId, getByText } = renderExecuteMessage();
@@ -181,7 +172,7 @@ describe('ExecuteMessage', () => {
workflowNodes[0].parameters = {
url: '<__PLACEHOLDER_VALUE__API endpoint URL__>',
};
workflowTodosRef.value = [
builderWorkflowTodosRef.value = [
{ node: 'Start Trigger', type: 'parameters', value: 'Fill in placeholder value' },
];
@@ -304,7 +295,7 @@ describe('ExecuteMessage', () => {
it('disables execution when no credits remaining and validation issues exist', () => {
hasNoCreditsRemainingRef.value = true;
workflowValidationIssuesRef.value = [
builderWorkflowTodosRef.value = [
{ node: 'Start Trigger', type: 'parameters', value: 'Missing field' },
];
@@ -352,7 +343,7 @@ describe('ExecuteMessage', () => {
url: ['Some other validation error'],
},
};
workflowTodosRef.value = [
builderWorkflowTodosRef.value = [
{ node: 'Start Trigger', type: 'parameters', value: 'Some other validation error' },
{ node: 'Start Trigger', type: 'parameters', value: 'Fill in placeholder value' },
];
@@ -369,7 +360,7 @@ describe('ExecuteMessage', () => {
it('tracks user_clicked_todo when clicking on an issue item', async () => {
const todoIssue = { node: 'HTTP Request', type: 'parameters', value: 'Missing URL' };
workflowTodosRef.value = [todoIssue];
builderWorkflowTodosRef.value = [todoIssue];
workflowNodes.push({
id: '2',
name: 'HTTP Request',
@@ -397,7 +388,7 @@ describe('ExecuteMessage', () => {
type: 'credentials',
value: "Credentials for 'OpenAI' are not set",
};
workflowTodosRef.value = [credentialIssue];
builderWorkflowTodosRef.value = [credentialIssue];
workflowNodes.push({
id: '2',
name: 'OpenAI Model',
@@ -426,12 +417,12 @@ describe('ExecuteMessage', () => {
it('tracks no_placeholder_values_left when all todos are resolved', async () => {
const todoIssue = { node: 'Start Trigger', type: 'parameters', value: 'Missing field' };
workflowTodosRef.value = [todoIssue];
builderWorkflowTodosRef.value = [todoIssue];
renderExecuteMessage();
// Simulate resolving all todos
workflowTodosRef.value = [];
builderWorkflowTodosRef.value = [];
await nextTick();
await flushPromises();
@@ -441,7 +432,7 @@ describe('ExecuteMessage', () => {
});
it('does not track no_placeholder_values_left when component mounts without issues', async () => {
workflowTodosRef.value = [];
builderWorkflowTodosRef.value = [];
renderExecuteMessage();
await nextTick();
@@ -96,8 +96,8 @@ const parameterRequiredPattern = /Parameter\s+".+"\s+is\s+required/i;
* Custom formatter for issue messages in the execute panel.
* Transforms verbose validation messages into user-friendly action prompts.
*/
function formatIssueMessage(issue: string | string[]): string {
const baseMessage = workflowsStore.formatIssueMessage(issue);
function formatNodeIssueMessage(issue: string | string[]): string {
const baseMessage = workflowDocumentStore.value.formatNodeIssueMessage(issue);
// Transform "Parameter "X" is required" → "Choose model" (for Model) or keep original
if (parameterRequiredPattern.test(baseMessage)) {
@@ -256,10 +256,10 @@ watch(hasValidationIssues, (hasIssues, hadIssues) => {
>
<NodeIssueItem
v-for="issue in issuesByType.other"
:key="`${formatIssueMessage(issue.value)}_${issue.node}`"
:key="`${formatNodeIssueMessage(issue.value)}_${issue.node}`"
:issue="issue"
:get-node-type="getNodeTypeByName"
:format-issue-message="formatIssueMessage"
:format-node-issue-message="formatNodeIssueMessage"
@click="() => trackBuilderPlaceholders(issue)"
/>
</TransitionGroup>
@@ -17,7 +17,7 @@ vi.mock('@/app/components/NodeIcon.vue', () => ({
const renderComponent = createComponentRenderer(NodeIssueItem);
function formatIssueMessage(value: string | string[]) {
function formatNodeIssueMessage(value: string | string[]) {
return Array.isArray(value) ? value.join(', ') : value;
}
@@ -38,7 +38,7 @@ describe('NodeIssueItem', () => {
props: {
issue: { node: 'Linear', type: 'parameters', value: 'Missing API key' },
getNodeType: vi.fn(),
formatIssueMessage,
formatNodeIssueMessage,
},
});
@@ -56,7 +56,7 @@ describe('NodeIssueItem', () => {
props: {
issue: { node: 'Linear', type: 'parameters', value: 'Missing API key' },
getNodeType: vi.fn(() => nodeType),
formatIssueMessage,
formatNodeIssueMessage,
},
});
@@ -74,7 +74,7 @@ describe('NodeIssueItem', () => {
props: {
issue: { node: 'Linear', type: 'parameters', value: 'Missing API key' },
getNodeType: vi.fn(() => nodeType),
formatIssueMessage,
formatNodeIssueMessage,
},
});
@@ -16,7 +16,7 @@ interface Props {
/** Function to get node type information */
getNodeType: (nodeName: string) => INodeTypeDescription | null;
/** Function to format issue messages */
formatIssueMessage: (value: WorkflowNodeIssue['value']) => string;
formatNodeIssueMessage: (value: WorkflowNodeIssue['value']) => string;
}
interface Emits {
@@ -54,9 +54,9 @@ function handleEditClick() {
/>
<!-- Issue message -->
<div :class="$style.issueMessage" :aria-label="`Issue: ${formatIssueMessage(issue.value)}`">
<div :class="$style.issueMessage" :aria-label="`Issue: ${formatNodeIssueMessage(issue.value)}`">
<span :class="$style.nodeName">{{ issue.node }}:</span>
{{ formatIssueMessage(issue.value) }}
{{ formatNodeIssueMessage(issue.value) }}
</div>
<!-- Navigate chevron -->
@@ -451,8 +451,11 @@ describe('useBuilderTodos', () => {
'OpenAI GPT-4o-mini': [{ json: { response: 'pinned AI response' } }],
});
// Verify the issue exists in workflowValidationIssues before filtering
const validationIssues = workflowsStore.workflowValidationIssues;
// Verify the issue exists in nodeValidationIssues before filtering
const workflowDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId(workflowsStore.workflow.id),
);
const validationIssues = workflowDocumentStore.nodeValidationIssues;
expect(validationIssues.some((i) => i.node === 'OpenAI GPT-4o-mini')).toBe(true);
const { workflowTodos } = useBuilderTodos();
@@ -499,7 +502,10 @@ describe('useBuilderTodos', () => {
});
// Verify validation issue exists for the sub-node
const validationIssues = workflowsStore.workflowValidationIssues;
const workflowDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId(workflowsStore.workflow.id),
);
const validationIssues = workflowDocumentStore.nodeValidationIssues;
expect(validationIssues.some((i) => i.node === 'OpenAI GPT-4.1-mini')).toBe(true);
const { workflowTodos } = useBuilderTodos();
@@ -736,7 +742,10 @@ describe('useBuilderTodos', () => {
setPinData({});
// Verify validation issue exists for the sub-node
const validationIssues = workflowsStore.workflowValidationIssues;
const workflowDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId(workflowsStore.workflow.id),
);
const validationIssues = workflowDocumentStore.nodeValidationIssues;
expect(validationIssues.some((i) => i.node === 'OpenAI GPT-4.1-mini')).toBe(true);
const { workflowTodos } = useBuilderTodos();
@@ -142,7 +142,7 @@ export function useBuilderTodos() {
void _pinData;
void _nodes;
return workflowsStore.workflowValidationIssues.filter(
return workflowDocumentStore.value.nodeValidationIssues.filter(
(issue) =>
['credentials', 'parameters'].includes(issue.type) &&
!nodeHasPinnedData(issue.node) &&
@@ -225,7 +225,7 @@ export function useBuilderTodos() {
if (!pinData || Object.keys(pinData).length === 0) return false;
// Check base workflow issues that would show if not for pinned data
const wouldHaveBaseIssues = workflowsStore.workflowValidationIssues.some(
const wouldHaveBaseIssues = workflowDocumentStore.value.nodeValidationIssues.some(
(issue) =>
['credentials', 'parameters'].includes(issue.type) &&
nodeHasPinnedData(issue.node) &&
@@ -251,7 +251,7 @@ export function useBuilderTodos() {
* Returns todos data formatted for telemetry tracking.
*/
function getTodosToTrack(): TodosTrackingPayload {
const credentials_todo_count = workflowsStore.workflowValidationIssues.filter(
const credentials_todo_count = workflowDocumentStore.value.nodeValidationIssues.filter(
(issue) => issue.type === 'credentials',
).length;
const placeholders_todo_count = placeholderIssues.value.length;