diff --git a/packages/frontend/@n8n/design-system/src/components/N8nIcon/custom/node-validation-error.svg b/packages/frontend/@n8n/design-system/src/components/N8nIcon/custom/node-validation-error.svg new file mode 100644 index 0000000000000..333ec09c3126a --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nIcon/custom/node-validation-error.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts b/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts index 17bc277cfcfc0..4edba7221b322 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts @@ -11,6 +11,7 @@ import NodePlay from './custom/node-play.svg'; import NodePower from './custom/node-power.svg'; import NodeSuccess from './custom/node-success.svg'; import NodeTrash from './custom/node-trash.svg'; +import NodeValidationError from './custom/node-validation-error.svg'; import PopOut from './custom/pop-out.svg'; import Retry from './custom/retry.svg'; import RunOnce from './custom/run-once.svg'; @@ -435,6 +436,7 @@ export const updatedIconSet = { 'node-dirty': NodeDirty, 'node-ellipsis': NodeEllipsis, 'node-error': NodeError, + 'node-validation-error': NodeValidationError, 'node-pin': NodePin, 'node-play': NodePlay, 'node-power': NodePower, diff --git a/packages/frontend/editor-ui/src/__tests__/data/canvas.ts b/packages/frontend/editor-ui/src/__tests__/data/canvas.ts index 91591e648a094..d28f29ba9f057 100644 --- a/packages/frontend/editor-ui/src/__tests__/data/canvas.ts +++ b/packages/frontend/editor-ui/src/__tests__/data/canvas.ts @@ -28,7 +28,7 @@ export function createCanvasNodeData({ outputs = [], connections = { [CanvasConnectionMode.Input]: {}, [CanvasConnectionMode.Output]: {} }, execution = { running: false }, - issues = { items: [], visible: false }, + issues = { execution: [], validation: [], visible: false }, pinnedData = { count: 0, visible: false }, runData = { outputMap: {}, iterations: 0, visible: false }, render = { diff --git a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue index ee1d7465633d8..4c9aec478cbc0 100644 --- a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue +++ b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue @@ -35,7 +35,7 @@ const { executionWaitingForNext, executionRunning, hasRunData, - hasIssues, + hasExecutionErrors, render, } = useCanvasNode(); const { mainOutputs, mainOutputConnections, mainInputs, mainInputConnections, nonMainInputs } = @@ -54,7 +54,7 @@ const classes = computed(() => { [$style.selected]: isSelected.value, [$style.disabled]: isDisabled.value, [$style.success]: hasRunData.value, - [$style.error]: hasIssues.value, + [$style.error]: hasExecutionErrors.value, [$style.pinned]: hasPinnedData.value, [$style.waiting]: executionWaiting.value ?? executionStatus.value === 'waiting', [$style.running]: executionRunning.value || executionWaitingForNext.value, diff --git a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/parts/CanvasNodeStatusIcons.vue b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/parts/CanvasNodeStatusIcons.vue index f8ed31a9d857c..dac69f6e5a471 100644 --- a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/parts/CanvasNodeStatusIcons.vue +++ b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/parts/CanvasNodeStatusIcons.vue @@ -25,7 +25,8 @@ const $style = useCssModule(); const { hasPinnedData, issues, - hasIssues, + hasExecutionErrors, + hasValidationErrors, executionStatus, executionWaiting, executionWaitingForNext, @@ -66,12 +67,6 @@ const commonClasses = computed(() => [ -
- -
[
@@ -95,6 +90,18 @@ const commonClasses = computed(() => [
+
+ + + + +
@@ -152,7 +159,6 @@ const commonClasses = computed(() => [ color: var(--color-secondary); } -.node-waiting-spinner, .running { color: hsl(var(--color-primary-h), var(--color-primary-s), var(--color-primary-l)); diff --git a/packages/frontend/editor-ui/src/composables/useCanvasMapping.test.ts b/packages/frontend/editor-ui/src/composables/useCanvasMapping.test.ts index 377b1a965d719..6c135e70ec768 100644 --- a/packages/frontend/editor-ui/src/composables/useCanvasMapping.test.ts +++ b/packages/frontend/editor-ui/src/composables/useCanvasMapping.test.ts @@ -116,7 +116,8 @@ describe('useCanvasMapping', () => { waitingForNext: false, }, issues: { - items: [], + execution: [], + validation: [], visible: false, }, pinnedData: { @@ -721,7 +722,7 @@ describe('useCanvasMapping', () => { workflowObject: ref(workflowObject) as Ref, }); - expect(nodeIssuesById.value[node.id]).toEqual([]); + expect(nodeIssuesById.value[node.id]).toEqual({ execution: [], validation: [] }); }); it('should handle execution errors', () => { @@ -754,7 +755,10 @@ describe('useCanvasMapping', () => { workflowObject: ref(workflowObject) as Ref, }); - expect(nodeIssuesById.value[node.id]).toEqual([`${errorMessage} (${errorDescription})`]); + expect(nodeIssuesById.value[node.id]).toEqual({ + execution: [`${errorMessage} (${errorDescription})`], + validation: [], + }); }); it('should handle execution error without description', () => { @@ -786,7 +790,10 @@ describe('useCanvasMapping', () => { workflowObject: ref(workflowObject) as Ref, }); - expect(nodeIssuesById.value[node.id]).toEqual([errorMessage]); + expect(nodeIssuesById.value[node.id]).toEqual({ + execution: [errorMessage], + validation: [], + }); }); it('should handle multiple execution errors', () => { @@ -827,10 +834,10 @@ describe('useCanvasMapping', () => { workflowObject: ref(workflowObject) as Ref, }); - expect(nodeIssuesById.value[node.id]).toEqual([ - 'Error 1 (Description 1)', - 'Error 2 (Description 2)', - ]); + expect(nodeIssuesById.value[node.id]).toEqual({ + execution: ['Error 1 (Description 1)', 'Error 2 (Description 2)'], + validation: [], + }); }); it('should handle node issues', () => { @@ -853,9 +860,10 @@ describe('useCanvasMapping', () => { workflowObject: ref(workflowObject) as Ref, }); - expect(nodeIssuesById.value[node.id]).toEqual([ - 'Node Type "n8n-nodes-base.set" is not known.', - ]); + expect(nodeIssuesById.value[node.id]).toEqual({ + execution: [], + validation: ['Node Type "n8n-nodes-base.set" is not known.'], + }); }); it('should combine execution errors and node issues', () => { @@ -891,10 +899,10 @@ describe('useCanvasMapping', () => { workflowObject: ref(workflowObject) as Ref, }); - expect(nodeIssuesById.value[node.id]).toEqual([ - 'Execution error (Error description)', - 'Node Type "n8n-nodes-base.set" is not known.', - ]); + expect(nodeIssuesById.value[node.id]).toEqual({ + execution: ['Execution error (Error description)'], + validation: ['Node Type "n8n-nodes-base.set" is not known.'], + }); }); it('should handle multiple nodes with different issues', () => { @@ -931,10 +939,14 @@ describe('useCanvasMapping', () => { workflowObject: ref(workflowObject) as Ref, }); - expect(nodeIssuesById.value[node1.id]).toEqual([ - 'Node Type "n8n-nodes-base.set" is not known.', - ]); - expect(nodeIssuesById.value[node2.id]).toEqual(['Execution error (Error description)']); + expect(nodeIssuesById.value[node1.id]).toEqual({ + execution: [], + validation: ['Node Type "n8n-nodes-base.set" is not known.'], + }); + expect(nodeIssuesById.value[node2.id]).toEqual({ + execution: ['Execution error (Error description)'], + validation: [], + }); }); }); diff --git a/packages/frontend/editor-ui/src/composables/useCanvasMapping.ts b/packages/frontend/editor-ui/src/composables/useCanvasMapping.ts index 6b47b040ecce2..319a5eb610d43 100644 --- a/packages/frontend/editor-ui/src/composables/useCanvasMapping.ts +++ b/packages/frontend/editor-ui/src/composables/useCanvasMapping.ts @@ -395,41 +395,69 @@ export function useCanvasMapping({ { throttle: CANVAS_EXECUTION_DATA_THROTTLE_DURATION, immediate: true }, ); - const nodeIssuesById = computed(() => + const nodeExecutionErrorsById = computed(() => nodes.value.reduce>((acc, node) => { - const issues: string[] = []; + const executionErrors: string[] = []; const nodeExecutionRunData = workflowsStore.getWorkflowRunData?.[node.name]; if (nodeExecutionRunData) { nodeExecutionRunData.forEach((executionRunData) => { if (executionRunData?.error) { const { message, description } = executionRunData.error; const issue = `${message}${description ? ` (${description})` : ''}`; - issues.push(sanitizeHtml(issue)); + executionErrors.push(sanitizeHtml(issue)); } }); } + acc[node.id] = executionErrors; + + return acc; + }, {}), + ); + + const nodeValidationErrorsById = computed(() => + nodes.value.reduce>((acc, node) => { + const validationErrors: string[] = []; + if (node?.issues !== undefined) { - issues.push(...nodeHelpers.nodeIssuesToString(node.issues, node)); + validationErrors.push(...nodeHelpers.nodeIssuesToString(node.issues, node)); } - acc[node.id] = issues; + acc[node.id] = validationErrors; return acc; }, {}), ); + const nodeIssuesById = computed(() => + nodes.value.reduce>( + (acc, node) => { + acc[node.id] = { + execution: nodeExecutionErrorsById.value[node.id] ?? [], + validation: nodeValidationErrorsById.value[node.id] ?? [], + }; + + return acc; + }, + {}, + ), + ); + const nodeHasIssuesById = computed(() => nodes.value.reduce>((acc, node) => { + const hasExecutionErrors = nodeExecutionErrorsById.value[node.id]?.length > 0; + const hasValidationErrors = nodeValidationErrorsById.value[node.id]?.length > 0; + if (['crashed', 'error'].includes(nodeExecutionStatusById.value[node.id])) { acc[node.id] = true; } else if (nodePinnedDataById.value[node.id]) { acc[node.id] = false; - } else if (node.issues && nodeHelpers.nodeIssuesToString(node.issues, node).length) { + } else if (hasValidationErrors) { + acc[node.id] = true; + } else if (hasExecutionErrors) { acc[node.id] = true; } else { const tasks = workflowsStore.getWorkflowRunData?.[node.name] ?? []; - acc[node.id] = Boolean(tasks.at(-1)?.error); } @@ -605,7 +633,8 @@ export function useCanvasMapping({ [CanvasConnectionMode.Output]: outputConnections, }, issues: { - items: nodeIssuesById.value[node.id], + execution: nodeIssuesById.value[node.id].execution, + validation: nodeIssuesById.value[node.id].validation, visible: nodeHasIssuesById.value[node.id], }, pinnedData: { diff --git a/packages/frontend/editor-ui/src/composables/useCanvasNode.test.ts b/packages/frontend/editor-ui/src/composables/useCanvasNode.test.ts index 426088b5bb829..9f2000d27a412 100644 --- a/packages/frontend/editor-ui/src/composables/useCanvasNode.test.ts +++ b/packages/frontend/editor-ui/src/composables/useCanvasNode.test.ts @@ -53,7 +53,11 @@ describe('useCanvasNode', () => { [CanvasConnectionMode.Input]: { '0': [] }, [CanvasConnectionMode.Output]: {}, }, - issues: { items: ['issue1'], visible: true }, + issues: { + execution: ['execution_error1'], + validation: ['validation_error1'], + visible: true, + }, execution: { status: 'running', waiting: 'waiting', running: true }, runData: { outputMap: {}, iterations: 1, visible: true }, pinnedData: { count: 1, visible: true }, @@ -90,7 +94,7 @@ describe('useCanvasNode', () => { expect(result.runDataOutputMap.value).toEqual({}); expect(result.runDataIterations.value).toBe(1); expect(result.hasRunData.value).toBe(true); - expect(result.issues.value).toEqual(['issue1']); + expect(result.issues.value).toEqual(['execution_error1', 'validation_error1']); expect(result.hasIssues.value).toBe(true); expect(result.executionStatus.value).toBe('running'); expect(result.executionWaiting.value).toBe('waiting'); diff --git a/packages/frontend/editor-ui/src/composables/useCanvasNode.ts b/packages/frontend/editor-ui/src/composables/useCanvasNode.ts index f35aa578e82b9..e8af4dbc88d38 100644 --- a/packages/frontend/editor-ui/src/composables/useCanvasNode.ts +++ b/packages/frontend/editor-ui/src/composables/useCanvasNode.ts @@ -18,7 +18,7 @@ export function useCanvasNode() { inputs: [], outputs: [], connections: { [CanvasConnectionMode.Input]: {}, [CanvasConnectionMode.Output]: {} }, - issues: { items: [], visible: false }, + issues: { execution: [], validation: [], visible: false }, pinnedData: { count: 0, visible: false }, execution: { running: false, @@ -47,8 +47,12 @@ export function useCanvasNode() { const pinnedDataCount = computed(() => data.value.pinnedData.count); const hasPinnedData = computed(() => data.value.pinnedData.count > 0); - const issues = computed(() => data.value.issues.items ?? []); + const issues = computed(() => [...data.value.issues.execution, ...data.value.issues.validation]); + const executionErrors = computed(() => data.value.issues.execution ?? []); + const validationErrors = computed(() => data.value.issues.validation ?? []); const hasIssues = computed(() => data.value.issues.visible); + const hasExecutionErrors = computed(() => data.value.issues.execution.length > 0); + const hasValidationErrors = computed(() => data.value.issues.validation.length > 0); const executionStatus = computed(() => data.value.execution.status); const executionWaiting = computed(() => data.value.execution.waiting); @@ -81,7 +85,11 @@ export function useCanvasNode() { runDataOutputMap, hasRunData, issues, + executionErrors, + validationErrors, hasIssues, + hasExecutionErrors, + hasValidationErrors, executionStatus, executionWaiting, executionWaitingForNext, diff --git a/packages/frontend/editor-ui/src/features/workflow-diff/useWorkflowDiff.test.ts b/packages/frontend/editor-ui/src/features/workflow-diff/useWorkflowDiff.test.ts index c658ff3d4068d..cc7f6a6443554 100644 --- a/packages/frontend/editor-ui/src/features/workflow-diff/useWorkflowDiff.test.ts +++ b/packages/frontend/editor-ui/src/features/workflow-diff/useWorkflowDiff.test.ts @@ -37,7 +37,9 @@ vi.mock('@/composables/useCanvasMapping', () => ({ additionalNodePropertiesById: computed(() => ({})), nodeExecutionRunDataOutputMapById: computed(() => ({})), nodeExecutionWaitingForNextById: computed(() => ({})), - nodeIssuesById: computed(() => ({})), + nodeIssuesById: computed( + () => ({}) as Record, + ), nodeHasIssuesById: computed(() => ({})), nodes: computed(() => []), connections: computed(() => []), @@ -401,7 +403,9 @@ describe('useWorkflowDiff', () => { additionalNodePropertiesById: computed(() => ({}) as Record>), nodeExecutionRunDataOutputMapById: computed(() => ({}) as Record), nodeExecutionWaitingForNextById: computed(() => ({}) as Record), - nodeIssuesById: computed(() => ({}) as Record), + nodeIssuesById: computed( + () => ({}) as Record, + ), nodeHasIssuesById: computed(() => ({}) as Record), nodes: computed(() => nodes as CanvasNode[]), connections: computed(() => connections as CanvasConnection[]), diff --git a/packages/frontend/editor-ui/src/types/canvas.ts b/packages/frontend/editor-ui/src/types/canvas.ts index 1e278cb864739..4bfee032673ff 100644 --- a/packages/frontend/editor-ui/src/types/canvas.ts +++ b/packages/frontend/editor-ui/src/types/canvas.ts @@ -111,7 +111,8 @@ export interface CanvasNodeData { [CanvasConnectionMode.Output]: INodeConnections; }; issues: { - items: string[]; + execution: string[]; + validation: string[]; visible: boolean; }; pinnedData: {