feat(editor): Separate node execution and validation error states (#19029)

This commit is contained in:
Tuukka Kantola
2025-09-11 13:23:37 +02:00
committed by GitHub
parent ff1f9ff69e
commit 477dd27b08
12 changed files with 121 additions and 61 deletions

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.0506 2.38452C10.9161 0.882058 13.0845 0.882058 13.95 2.38452L23.3065 18.6267C24.1706 20.1267 23.0883 21.9997 21.3572 21.9998H2.6424C0.911559 21.9994 -0.170877 20.1266 0.693176 18.6267L10.0506 2.38452ZM11.9998 15.9998C11.1715 15.9999 10.4999 16.6715 10.4998 17.4998C10.4998 18.3281 11.1715 18.9997 11.9998 18.9998C12.8282 18.9998 13.4998 18.3282 13.4998 17.4998C13.4997 16.6714 12.8282 15.9998 11.9998 15.9998ZM11.9998 7.49976C11.1715 7.49986 10.4999 8.17148 10.4998 8.99976V12.4998C10.4998 13.3281 11.1715 13.9997 11.9998 13.9998C12.8282 13.9998 13.4998 13.3282 13.4998 12.4998V8.99976C13.4997 8.17142 12.8282 7.49976 11.9998 7.49976Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 774 B

View File

@@ -5,12 +5,13 @@ import EmptyOutput from './custom/empty-output.svg';
import GripLinesVertical from './custom/grip-lines-vertical.svg';
import NodeDirty from './custom/node-dirty.svg';
import NodeEllipsis from './custom/node-ellipsis.svg';
import NodeError from './custom/node-error.svg';
import NodeExecutionError from './custom/node-execution-error.svg';
import NodePin from './custom/node-pin.svg';
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';
@@ -434,7 +435,8 @@ export const updatedIconSet = {
spinner: Spinner,
'node-dirty': NodeDirty,
'node-ellipsis': NodeEllipsis,
'node-error': NodeError,
'node-execution-error': NodeExecutionError,
'node-validation-error': NodeValidationError,
'node-pin': NodePin,
'node-play': NodePlay,
'node-power': NodePower,

View File

@@ -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 = {

View File

@@ -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,

View File

@@ -24,8 +24,10 @@ const $style = useCssModule();
const {
hasPinnedData,
issues,
hasIssues,
executionErrors,
validationErrors,
hasExecutionErrors,
hasValidationErrors,
executionStatus,
executionWaiting,
executionWaitingForNext,
@@ -66,12 +68,6 @@ const commonClasses = computed(() => [
<N8nIcon icon="clock" :size="size" />
</N8nTooltip>
</div>
<div
v-if="spinnerLayout === 'absolute'"
:class="[...commonClasses, $style['node-waiting-spinner']]"
>
<N8nIcon icon="refresh-cw" spin />
</div>
</div>
<div
v-else-if="isNodeExecuting"
@@ -84,15 +80,27 @@ const commonClasses = computed(() => [
<N8nIcon icon="power" :size="size" />
</div>
<div
v-else-if="hasIssues && !hideNodeIssues"
v-else-if="hasExecutionErrors && !hideNodeIssues"
:class="[...commonClasses, $style.issues]"
data-test-id="node-issues"
>
<N8nTooltip :show-after="500" placement="bottom">
<template #content>
<TitledList :title="`${i18n.baseText('node.issues')}:`" :items="issues" />
<TitledList :title="`${i18n.baseText('node.issues')}:`" :items="executionErrors" />
</template>
<N8nIcon icon="node-error" :size="size" />
<N8nIcon icon="node-execution-error" :size="size" />
</N8nTooltip>
</div>
<div
v-else-if="hasValidationErrors && !hideNodeIssues"
:class="[...commonClasses, $style.issues]"
data-test-id="node-issues"
>
<N8nTooltip :show-after="500" placement="bottom">
<template #content>
<TitledList :title="`${i18n.baseText('node.issues')}:`" :items="validationErrors" />
</template>
<N8nIcon icon="node-validation-error" :size="size" />
</N8nTooltip>
</div>
<div v-else-if="executionStatus === 'unknown'">
@@ -152,7 +160,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));

View File

@@ -116,7 +116,8 @@ describe('useCanvasMapping', () => {
waitingForNext: false,
},
issues: {
items: [],
execution: [],
validation: [],
visible: false,
},
pinnedData: {
@@ -705,8 +706,8 @@ describe('useCanvasMapping', () => {
});
});
describe('nodeIssuesById', () => {
it('should return empty array when node has no issues', () => {
describe('node issues', () => {
it('should return empty arrays when node has no issues', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const node = createTestNode({ name: 'Test Node' });
const nodes = [node];
@@ -715,13 +716,17 @@ describe('useCanvasMapping', () => {
workflowsStore.getWorkflowRunData = {};
const { nodeIssuesById } = useCanvasMapping({
const { nodes: mappedNodes } = useCanvasMapping({
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(nodeIssuesById.value[node.id]).toEqual([]);
expect(mappedNodes.value[0]?.data?.issues).toEqual({
execution: [],
validation: [],
visible: false,
});
});
it('should handle execution errors', () => {
@@ -748,13 +753,17 @@ describe('useCanvasMapping', () => {
],
};
const { nodeIssuesById } = useCanvasMapping({
const { nodes: mappedNodes } = useCanvasMapping({
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(nodeIssuesById.value[node.id]).toEqual([`${errorMessage} (${errorDescription})`]);
expect(mappedNodes.value[0]?.data?.issues).toEqual({
execution: [`${errorMessage} (${errorDescription})`],
validation: [],
visible: true,
});
});
it('should handle execution error without description', () => {
@@ -780,13 +789,17 @@ describe('useCanvasMapping', () => {
],
};
const { nodeIssuesById } = useCanvasMapping({
const { nodes: mappedNodes } = useCanvasMapping({
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(nodeIssuesById.value[node.id]).toEqual([errorMessage]);
expect(mappedNodes.value[0]?.data?.issues).toEqual({
execution: [errorMessage],
validation: [],
visible: true,
});
});
it('should handle multiple execution errors', () => {
@@ -821,16 +834,17 @@ describe('useCanvasMapping', () => {
],
};
const { nodeIssuesById } = useCanvasMapping({
const { nodes: mappedNodes } = useCanvasMapping({
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(nodeIssuesById.value[node.id]).toEqual([
'Error 1 (Description 1)',
'Error 2 (Description 2)',
]);
expect(mappedNodes.value[0]?.data?.issues).toEqual({
execution: ['Error 1 (Description 1)', 'Error 2 (Description 2)'],
validation: [],
visible: true,
});
});
it('should handle node issues', () => {
@@ -847,15 +861,17 @@ describe('useCanvasMapping', () => {
workflowsStore.getWorkflowRunData = {};
const { nodeIssuesById } = useCanvasMapping({
const { nodes: mappedNodes } = useCanvasMapping({
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(nodeIssuesById.value[node.id]).toEqual([
'Node Type "n8n-nodes-base.set" is not known.',
]);
expect(mappedNodes.value[0]?.data?.issues).toEqual({
execution: [],
validation: ['Node Type "n8n-nodes-base.set" is not known.'],
visible: true,
});
});
it('should combine execution errors and node issues', () => {
@@ -885,16 +901,17 @@ describe('useCanvasMapping', () => {
],
};
const { nodeIssuesById } = useCanvasMapping({
const { nodes: mappedNodes } = useCanvasMapping({
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(nodeIssuesById.value[node.id]).toEqual([
'Execution error (Error description)',
'Node Type "n8n-nodes-base.set" is not known.',
]);
expect(mappedNodes.value[0]?.data?.issues).toEqual({
execution: ['Execution error (Error description)'],
validation: ['Node Type "n8n-nodes-base.set" is not known.'],
visible: true,
});
});
it('should handle multiple nodes with different issues', () => {
@@ -925,16 +942,22 @@ describe('useCanvasMapping', () => {
],
};
const { nodeIssuesById } = useCanvasMapping({
const { nodes: mappedNodes } = useCanvasMapping({
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
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(mappedNodes.value[0]?.data?.issues).toEqual({
execution: [],
validation: ['Node Type "n8n-nodes-base.set" is not known.'],
visible: true,
});
expect(mappedNodes.value[1]?.data?.issues).toEqual({
execution: ['Execution error (Error description)'],
validation: [],
visible: true,
});
});
});

View File

@@ -395,25 +395,35 @@ export function useCanvasMapping({
{ throttle: CANVAS_EXECUTION_DATA_THROTTLE_DURATION, immediate: true },
);
const nodeIssuesById = computed(() =>
const nodeExecutionErrorsById = computed(() =>
nodes.value.reduce<Record<string, string[]>>((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<Record<string, string[]>>((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;
}, {}),
@@ -421,15 +431,19 @@ export function useCanvasMapping({
const nodeHasIssuesById = computed(() =>
nodes.value.reduce<Record<string, boolean>>((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 +619,8 @@ export function useCanvasMapping({
[CanvasConnectionMode.Output]: outputConnections,
},
issues: {
items: nodeIssuesById.value[node.id],
execution: nodeExecutionErrorsById.value[node.id],
validation: nodeValidationErrorsById.value[node.id],
visible: nodeHasIssuesById.value[node.id],
},
pinnedData: {
@@ -734,7 +749,6 @@ export function useCanvasMapping({
additionalNodePropertiesById,
nodeExecutionRunDataOutputMapById,
nodeExecutionWaitingForNextById,
nodeIssuesById,
nodeHasIssuesById,
connections: mappedConnections,
nodes: mappedNodes,

View File

@@ -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');

View File

@@ -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,

View File

@@ -37,7 +37,6 @@ vi.mock('@/composables/useCanvasMapping', () => ({
additionalNodePropertiesById: computed(() => ({})),
nodeExecutionRunDataOutputMapById: computed(() => ({})),
nodeExecutionWaitingForNextById: computed(() => ({})),
nodeIssuesById: computed(() => ({})),
nodeHasIssuesById: computed(() => ({})),
nodes: computed(() => []),
connections: computed(() => []),
@@ -401,7 +400,6 @@ describe('useWorkflowDiff', () => {
additionalNodePropertiesById: computed(() => ({}) as Record<string, Partial<CanvasNode>>),
nodeExecutionRunDataOutputMapById: computed(() => ({}) as Record<string, ExecutionOutputMap>),
nodeExecutionWaitingForNextById: computed(() => ({}) as Record<string, boolean>),
nodeIssuesById: computed(() => ({}) as Record<string, string[]>),
nodeHasIssuesById: computed(() => ({}) as Record<string, boolean>),
nodes: computed(() => nodes as CanvasNode[]),
connections: computed(() => connections as CanvasConnection[]),

View File

@@ -111,7 +111,8 @@ export interface CanvasNodeData {
[CanvasConnectionMode.Output]: INodeConnections;
};
issues: {
items: string[];
execution: string[];
validation: string[];
visible: boolean;
};
pinnedData: {