mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-24 23:22:38 +08:00
refactor(editor): Move node issues to workflow document store (no-changelog) (#29390)
This commit is contained in:
-1
@@ -132,7 +132,6 @@ describe('WorkflowHeaderDraftPublishActions', () => {
|
||||
|
||||
const setupEnabledPublishButton = (overrides = {}) => {
|
||||
workflowsStore.workflowTriggerNodes = [triggerNode];
|
||||
workflowsStore.nodesIssuesExist = false;
|
||||
Object.assign(workflowsStore, overrides);
|
||||
};
|
||||
|
||||
|
||||
+16
-10
@@ -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,
|
||||
|
||||
+83
@@ -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
|
||||
|
||||
+12
-21
@@ -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();
|
||||
|
||||
+4
-4
@@ -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>
|
||||
|
||||
+4
-4
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+3
-3
@@ -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 -->
|
||||
|
||||
+13
-4
@@ -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();
|
||||
|
||||
+3
-3
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user