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

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