feat(editor): Surface execution status on canvas group title bars (no-changelog) (#31594)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daria
2026-06-15 08:54:55 +00:00
committed by GitHub
co-authored by Claude Opus 4.7
parent f161bf0786
commit bb8ac5acef
14 changed files with 845 additions and 127 deletions
@@ -18,6 +18,7 @@ import {
CanvasConnectionMode,
CanvasNodeRenderType,
} from '@/features/workflows/canvas/canvas.types';
import type { NodeExecutionSnapshot } from '@/features/workflows/canvas/canvas.types';
import type { NodeConnectionType } from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
import type { GraphEdge, GraphNode, ViewportTransform } from '@vue-flow/core';
@@ -98,6 +99,22 @@ export function createCanvasGroupElement({
};
}
export function createNodeExecutionSnapshot(
overrides: Partial<NodeExecutionSnapshot> = {},
): NodeExecutionSnapshot {
return {
running: false,
waitingForNext: false,
waiting: undefined,
hasExecutionError: false,
hasValidationError: false,
status: undefined,
dirty: false,
iterations: 0,
...overrides,
};
}
export function createCanvasGraphNode({
id = '1',
type = 'default',
@@ -141,10 +141,36 @@ export const CANVAS_NODE_GROUP_ID_PREFIX = 'group:';
export const CANVAS_NODE_GROUP_HANDLE_LEFT = 'left';
export const CANVAS_NODE_GROUP_HANDLE_RIGHT = 'right';
/**
* The only execution states a group can surface — node-level statuses like
* `crashed` are folded into these during aggregation.
*/
export type GroupExecutionStatus =
| 'waiting'
| 'running'
| 'error'
| 'issues'
| 'warning'
| 'success';
/** Per-node execution state used to roll a group up into one status. */
export interface NodeExecutionSnapshot {
running: boolean;
waitingForNext: boolean;
waiting: string | undefined;
hasExecutionError: boolean;
hasValidationError: boolean;
status: ExecutionStatus | undefined;
/** Parameters changed since the last run — the single-node "dirty" warning. */
dirty: boolean;
iterations: number;
}
export interface CanvasGroupNodeData {
group: IWorkflowGroup;
nodesRect: { x: number; y: number; width: number; height: number };
isCollapsed: boolean;
executionStatus?: GroupExecutionStatus;
}
export type CanvasGroupNode = Node<CanvasGroupNodeData>;
@@ -114,6 +114,7 @@ const {
nodes: mappedWorkflowNodes,
connections: mappedConnections,
nodeDisplaySizeById,
getNodeExecutionSnapshot,
} = useCanvasMapping({
nodes,
connections,
@@ -130,6 +131,7 @@ const mappedGroupVueFlowNodes = computed(() =>
getNodeDisplaySize: (id) => nodeDisplaySizeById.value[id],
isGroupCollapsed: (id) => nodeGroupView.isGroupCollapsed(id),
readOnly: readOnlyRef.value || suppressInteractionRef.value,
getNodeExecutionSnapshot,
}),
);
@@ -136,6 +136,78 @@ describe('CanvasNodeGroupTitleBar', () => {
});
});
describe('execution-status classes', () => {
it('applies no status class when executionStatus is undefined (idle)', () => {
const wrapper = render({ data: makeData({ executionStatus: undefined }) });
const root = wrapper.getByTestId('canvas-node-group');
// No status icon and no .success / .error / .running class semantics.
expect(wrapper.queryByTestId('canvas-node-group-status-success')).toBeNull();
expect(wrapper.queryByTestId('canvas-node-group-status-error')).toBeNull();
// status classes are CSS module hashed; we can only check via test ids.
expect(root).toBeTruthy();
});
it('shows success icon when executionStatus is success', () => {
const wrapper = render({
data: makeData({ executionStatus: 'success' }),
});
expect(wrapper.getByTestId('canvas-node-group-status-success')).toBeTruthy();
});
it('shows error icon when executionStatus is error', () => {
const wrapper = render({
data: makeData({ executionStatus: 'error' }),
});
expect(wrapper.getByTestId('canvas-node-group-status-error')).toBeTruthy();
});
it('shows warning icon when executionStatus is warning (a member node is dirty)', () => {
const wrapper = render({
data: makeData({ executionStatus: 'warning' }),
});
expect(wrapper.getByTestId('canvas-node-group-status-warning')).toBeTruthy();
});
it('shows the validation issues triangle when executionStatus is issues', () => {
const wrapper = render({
data: makeData({ executionStatus: 'issues' }),
});
expect(wrapper.getByTestId('canvas-node-group-status-issues')).toBeTruthy();
// Issues must not render the execution-error mark.
expect(wrapper.queryByTestId('canvas-node-group-status-error')).toBeNull();
});
it('hides the status mark when the group is expanded (member nodes show their own)', () => {
const wrapper = render({
data: makeData({ executionStatus: 'success', isCollapsed: false }),
});
expect(wrapper.queryByTestId('canvas-node-group-status-success')).toBeNull();
});
it('hides the validation issues triangle when the group is expanded', () => {
const wrapper = render({
data: makeData({ executionStatus: 'issues', isCollapsed: false }),
});
expect(wrapper.queryByTestId('canvas-node-group-status-issues')).toBeNull();
});
it('applies a hashed `running` class when executionStatus is running', () => {
const wrapper = render({
data: makeData({ executionStatus: 'running' }),
});
const root = wrapper.getByTestId('canvas-node-group');
expect([...root.classList].some((c) => /running/i.test(c))).toBe(true);
});
it('applies a hashed `waiting` class when executionStatus is waiting', () => {
const wrapper = render({
data: makeData({ executionStatus: 'waiting' }),
});
const root = wrapper.getByTestId('canvas-node-group');
expect([...root.classList].some((c) => /waiting/i.test(c))).toBe(true);
});
});
describe('title rename + ungroup parity with old overlay', () => {
it('emits update:name on commit', async () => {
const wrapper = render({ data: makeData({ isCollapsed: false }) });
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, useTemplateRef, watch } from 'vue';
import { computed, nextTick, onMounted, ref, useCssModule, useTemplateRef, watch } from 'vue';
import { useI18n } from '@n8n/i18n';
import { N8nIconButton, N8nInlineTextEdit, N8nTooltip } from '@n8n/design-system';
import { N8nIcon, N8nIconButton, N8nInlineTextEdit, N8nTooltip } from '@n8n/design-system';
import { Handle, Position, useVueFlow } from '@vue-flow/core';
import KeyboardShortcutTooltip from '@/app/components/KeyboardShortcutTooltip.vue';
import CanvasNodeStatusMark from '../nodes/render-types/parts/CanvasNodeStatusMark.vue';
import {
GROUP_HEADER_HEIGHT as HEADER_HEIGHT,
GROUP_PADDING_Y_BOTTOM as PADDING_Y_BOTTOM,
@@ -45,6 +46,7 @@ const emit = defineEmits<{
}>();
const i18n = useI18n();
const $style = useCssModule();
const titleEdit = useTemplateRef<InstanceType<typeof N8nInlineTextEdit>>('titleEdit');
const titleText = useTemplateRef<HTMLElement>('titleText');
@@ -53,6 +55,24 @@ const isAutofocusReady = computed(
() => !props.dimensions || (props.dimensions.width > 0 && props.dimensions.height > 0),
);
const isCollapsed = computed(() => props.data.isCollapsed);
const executionStatus = computed(() => props.data.executionStatus);
// Statuses rendered as a status mark; running/waiting render as the animated border.
const MARK_STATUSES = ['success', 'error', 'warning'] as const;
const markStatus = computed(() => MARK_STATUSES.find((status) => status === executionStatus.value));
const wrapperClasses = computed(() => [
$style.wrapper,
{
[$style.collapsed]: isCollapsed.value,
[$style.selected]: props.selected,
[$style.success]: executionStatus.value === 'success',
[$style.error]: executionStatus.value === 'error',
[$style.warning]: executionStatus.value === 'warning',
[$style.running]: executionStatus.value === 'running',
[$style.waiting]: executionStatus.value === 'waiting',
},
]);
const frameStyle = computed(() => ({
top: `${HEADER_HEIGHT}px`,
@@ -141,7 +161,7 @@ function onWrapperPointerDown(event: PointerEvent) {
<template>
<div
:class="[$style.wrapper, { [$style.collapsed]: isCollapsed, [$style.selected]: selected }]"
:class="wrapperClasses"
:style="{
width: '100%',
height: `${HEADER_HEIGHT}px`,
@@ -222,6 +242,20 @@ function onWrapperPointerDown(event: PointerEvent) {
</div>
</N8nTooltip>
</div>
<div
v-if="isCollapsed && markStatus"
:class="$style.statusIcons"
:data-test-id="`canvas-node-group-status-${markStatus}`"
>
<CanvasNodeStatusMark :status="markStatus" />
</div>
<div
v-else-if="isCollapsed && executionStatus === 'issues'"
:class="[$style.statusIcons, $style.issues]"
data-test-id="canvas-node-group-status-issues"
>
<N8nIcon icon="node-validation-error" size="large" />
</div>
</div>
</div>
@@ -250,7 +284,7 @@ function onWrapperPointerDown(event: PointerEvent) {
height: 100%;
background: var(--background--surface);
background-clip: padding-box;
border: var(--canvas-node--border-width) solid var(--canvas-node--border-color);
@include styles.canvas-node-border;
border-radius: var(--radius--lg) var(--radius--lg) 0 0;
box-sizing: border-box;
.wrapper.collapsed & {
@@ -260,8 +294,42 @@ function onWrapperPointerDown(event: PointerEvent) {
.wrapper.selected & {
@include styles.canvas-node-selected-ring;
}
// Status only manifests when the group is collapsed — when expanded
// the nodes render their own outlines.
.wrapper.collapsed.success & {
@include styles.status-success;
}
.wrapper.collapsed.error & {
@include styles.status-error;
}
.wrapper.collapsed.warning & {
@include styles.status-warning;
}
.wrapper.collapsed.running & {
@include styles.status-running-border;
}
.wrapper.collapsed.waiting & {
@include styles.status-waiting-border;
}
}
/* stylelint-disable */
.wrapper.collapsed.running .titleBar::after,
.wrapper.collapsed.waiting .titleBar::after {
@include styles.status-animated-after;
border-radius: var(--radius--lg);
}
.wrapper.collapsed.running .titleBar::after {
@include styles.status-running-animation;
}
.wrapper.collapsed.waiting .titleBar::after {
@include styles.status-waiting-animation;
}
@include styles.status-animation-definitions;
/* stylelint-enable */
.content {
display: flex;
align-items: center;
@@ -294,6 +362,18 @@ function onWrapperPointerDown(event: PointerEvent) {
overflow-clip-margin: var(--spacing--2xs);
}
.statusIcons {
display: flex;
align-items: center;
margin-left: var(--spacing--xs);
flex-shrink: 0;
}
// Validation issues mirror the single node: red triangle, no status border.
.issues {
color: var(--color--danger);
}
.toolbar {
position: absolute;
bottom: 100%;
@@ -322,7 +402,7 @@ function onWrapperPointerDown(event: PointerEvent) {
left: 0;
width: 100%;
background: transparent;
border: var(--canvas-node--border-width, 1.5px) dashed var(--canvas-node--border-color);
@include styles.canvas-node-border(dashed);
border-top: none;
border-radius: 0 0 var(--radius--lg) var(--radius--lg);
pointer-events: none;
@@ -67,23 +67,25 @@ const renderOptions = computed(() => render.value.options as CanvasNodeDefaultRe
const isDemoRoute = computed(() => route.name === VIEWS.DEMO);
const classes = computed(() => {
const waiting = Boolean(executionWaiting.value || executionStatus.value === 'waiting');
const running = Boolean(executionRunning.value || executionWaitingForNext.value);
return {
[$style.node]: true,
[$style.selected]: isSelected.value,
[$style.disabled]:
isDisabled.value || (isNotInstalledCommunityNode.value && !isDemoRoute.value),
[$style.success]: hasRunData.value && executionStatus.value === 'success',
[$style.success]: Boolean(hasRunData.value && executionStatus.value === 'success'),
[$style.error]: hasExecutionErrors.value,
[$style.running]: running,
[$style.waiting]: waiting,
[$style.pinned]: hasPinnedData.value,
[$style.waiting]: executionWaiting.value || executionStatus.value === 'waiting',
[$style.running]: executionRunning.value || executionWaitingForNext.value,
[$style.configurable]: renderOptions.value.configurable,
[$style.configuration]: renderOptions.value.configuration,
[$style.trigger]: renderOptions.value.trigger,
[$style.warning]: renderOptions.value.dirtiness !== undefined,
[$style.placeholder]: renderOptions.value.placeholder,
waiting: executionWaiting.value || executionStatus.value === 'waiting',
running: executionRunning.value || executionWaitingForNext.value,
waiting,
running,
};
});
@@ -227,7 +229,7 @@ function onActivate(event: MouseEvent) {
@use './_canvasNodeStyles.scss' as styles;
.node {
--canvas-node--border-width: 1.5px;
@include styles.canvas-node-border-defaults;
--trigger-node--radius: 36px;
--canvas-node--status-icons--margin: var(--spacing--3xs);
--node--icon--color: var(--color--foreground--shade-1);
@@ -240,18 +242,7 @@ function onActivate(event: MouseEvent) {
justify-content: center;
background: var(--canvas-node--color--background, var(--node--color--background));
background-clip: padding-box;
border: var(--canvas-node--border-width) solid
var(
--canvas-node--border-color,
light-dark(
oklch(
from var(--color--neutral-black) l c h / var(--canvas-node--border--opacity-light, 0.1)
),
oklch(
from var(--color--neutral-white) l c h / var(--canvas-node--border--opacity-dark, 0.15)
)
)
);
@include styles.canvas-node-border;
border-radius: var(--radius--lg);
&.trigger {
@@ -336,20 +327,15 @@ function onActivate(event: MouseEvent) {
}
&.success {
--canvas-node--border-width: 2px;
--canvas-node--border-color: var(
--color-canvas-node-success-border-color,
var(--color--success)
);
@include styles.status-success;
}
&.warning {
--canvas-node--border-width: 2px;
--canvas-node--border-color: var(--color--warning);
@include styles.status-warning;
}
&.error {
--canvas-node--border-color: var(--canvas-node--border-color--error, var(--color--danger));
@include styles.status-error;
}
&.pinned {
@@ -368,15 +354,11 @@ function onActivate(event: MouseEvent) {
}
&.running {
border-color: transparent;
--canvas-node--border-color: var(
--color-canvas-node-running-border-color,
var(--node--border-color--running)
);
@include styles.status-running-border;
}
&.waiting {
--canvas-node--border-color: transparent;
@include styles.status-waiting-border;
}
&.placeholder {
@@ -395,43 +377,17 @@ function onActivate(event: MouseEvent) {
/* stylelint-disable */
.running::after,
.waiting::after {
content: '';
position: absolute;
inset: -3px;
border-radius: 10px;
z-index: -1;
background: conic-gradient(
from var(--node--gradient-angle),
rgba(255, 109, 90, 1),
rgba(255, 109, 90, 1) 20%,
rgba(255, 109, 90, 0.2) 35%,
rgba(255, 109, 90, 0.2) 65%,
rgba(255, 109, 90, 1) 90%,
rgba(255, 109, 90, 1)
);
@include styles.status-animated-after;
}
.running::after {
animation: border-rotate 1.5s linear infinite;
@include styles.status-running-animation;
}
.waiting::after {
animation: border-rotate 4.5s linear infinite;
@include styles.status-waiting-animation;
}
@property --node--gradient-angle {
syntax: '<angle>';
initial-value: 0deg;
inherits: false;
}
@keyframes border-rotate {
from {
--node--gradient-angle: 0deg;
}
to {
--node--gradient-angle: 360deg;
}
}
@include styles.status-animation-definitions;
/* stylelint-enable */
.description {
@@ -2,10 +2,23 @@
@mixin canvas-node-border-defaults {
--canvas-node--border-width: 1.5px;
--canvas-node--border-color: light-dark(
oklch(from var(--color--neutral-black) l c h / var(--canvas-node--border--opacity-light, 0.1)),
oklch(from var(--color--neutral-white) l c h / var(--canvas-node--border--opacity-dark, 0.15))
);
}
// The color default must stay a var() fallback — defining it on the element
// would mask --canvas-node--border-color set by ancestors (e.g. diff views).
@mixin canvas-node-border($line-style: solid) {
border: var(--canvas-node--border-width) $line-style
var(
--canvas-node--border-color,
light-dark(
oklch(
from var(--color--neutral-black) l c h / var(--canvas-node--border--opacity-light, 0.1)
),
oklch(
from var(--color--neutral-white) l c h / var(--canvas-node--border--opacity-dark, 0.15)
)
)
);
}
@mixin canvas-node-selected-ring {
@@ -13,3 +26,75 @@
box-shadow: 0 0 0 calc(6px * var(--canvas-zoom-compensation-factor, 1))
var(--canvas--color--selected-transparent);
}
@mixin status-success {
--canvas-node--border-width: 2px;
--canvas-node--border-color: var(--color-canvas-node-success-border-color, var(--color--success));
}
@mixin status-error {
--canvas-node--border-color: var(--canvas-node--border-color--error, var(--color--danger));
}
@mixin status-warning {
--canvas-node--border-width: 2px;
--canvas-node--border-color: var(--color--warning);
}
@mixin status-running-border {
border-color: transparent;
--canvas-node--border-color: var(
--color-canvas-node-running-border-color,
var(--node--border-color--running)
);
}
@mixin status-waiting-border {
--canvas-node--border-color: transparent;
}
@mixin status-animated-after {
content: '';
position: absolute;
inset: -3px;
border-radius: 10px;
z-index: -1;
background: conic-gradient(
from var(--node--gradient-angle),
rgba(255, 109, 90, 1),
rgba(255, 109, 90, 1) 20%,
rgba(255, 109, 90, 0.2) 35%,
rgba(255, 109, 90, 0.2) 65%,
rgba(255, 109, 90, 1) 90%,
rgba(255, 109, 90, 1)
);
}
@mixin status-running-animation {
animation: border-rotate 1.5s linear infinite;
}
@mixin status-waiting-animation {
animation: border-rotate 4.5s linear infinite;
}
// @keyframes names are scoped per CSS module, so every module that uses the
// running/waiting animation mixins must include this once at the top level.
@mixin status-animation-definitions {
/* stylelint-disable */
@property --node--gradient-angle {
syntax: '<angle>';
initial-value: 0deg;
inherits: false;
}
@keyframes border-rotate {
from {
--node--gradient-angle: 0deg;
}
to {
--node--gradient-angle: 360deg;
}
}
/* stylelint-enable */
}
@@ -10,6 +10,7 @@ import { useRoute } from 'vue-router';
import { VIEWS } from '@/app/constants';
import { N8nIcon, N8nTooltip } from '@n8n/design-system';
import CanvasNodeStatusMark from './CanvasNodeStatusMark.vue';
const {
size = 'large',
spinnerScrim = false,
@@ -93,7 +94,7 @@ const groupedExecutionErrors = computed(() => {
<template #content>
<TitledList :title="`${i18n.baseText('node.issues')}:`" :items="groupedExecutionErrors" />
</template>
<N8nIcon icon="node-execution-error" :size="size" />
<CanvasNodeStatusMark status="error" :size="size" />
</N8nTooltip>
</div>
<div
@@ -130,18 +131,16 @@ const groupedExecutionErrors = computed(() => {
}}
</template>
<div data-test-id="canvas-node-status-warning" :class="[...commonClasses, $style.warning]">
<N8nIcon icon="node-dirty" :size="size" />
<span v-if="runDataIterations > 1" :class="$style.count"> {{ runDataIterations }}</span>
<CanvasNodeStatusMark status="warning" :iterations="runDataIterations" :size="size" />
</div>
</N8nTooltip>
</div>
<div
v-else-if="hasRunData && executionStatus === 'success'"
data-test-id="canvas-node-status-success"
:class="[...commonClasses, $style.runData]"
:class="commonClasses"
>
<N8nIcon icon="node-success" :size="size" />
<span v-if="runDataIterations > 1" :class="$style.count"> {{ runDataIterations }}</span>
<CanvasNodeStatusMark status="success" :iterations="runDataIterations" :size="size" />
</div>
</template>
@@ -153,10 +152,6 @@ const groupedExecutionErrors = computed(() => {
font-weight: var(--font-weight--bold);
}
.runData {
color: var(--color--success);
}
.waiting {
color: var(--color--secondary);
}
@@ -194,10 +189,6 @@ const groupedExecutionErrors = computed(() => {
cursor: default;
}
.count {
font-size: var(--font-size--sm);
}
.warning {
color: var(--color--warning);
}
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { N8nIcon } from '@n8n/design-system';
import type { IconSize } from '@n8n/design-system/types';
const STATUS_ICONS = {
success: 'node-success',
error: 'node-execution-error',
warning: 'node-dirty',
} as const;
const {
status,
iterations = 0,
size = 'large',
} = defineProps<{
status: keyof typeof STATUS_ICONS;
iterations?: number;
size?: IconSize;
}>();
</script>
<template>
<div :class="[$style.mark, $style[status]]" :data-test-id="`canvas-node-status-mark-${status}`">
<N8nIcon :icon="STATUS_ICONS[status]" :size="size" />
<span v-if="status !== 'error' && iterations > 1" :class="$style.count">
{{ iterations }}
</span>
</div>
</template>
<style lang="scss" module>
.mark {
display: flex;
align-items: center;
gap: var(--spacing--5xs);
font-weight: var(--font-weight--bold);
}
.success {
color: var(--color--success);
}
.error {
color: var(--color--danger);
}
.warning {
color: var(--color--warning);
}
.count {
font-size: var(--font-size--sm);
}
</style>
@@ -1,8 +1,9 @@
import { describe, expect, it } from 'vitest';
import type { IWorkflowGroup } from 'n8n-workflow';
import type { INodeUi } from '@/Interface';
import type { CanvasConnection } from '../canvas.types';
import type { CanvasConnection, NodeExecutionSnapshot } from '../canvas.types';
import {
aggregateGroupExecution,
buildCollapsedGroupByNodeId,
computeNodesRectFromStore,
mapGroupsToVueFlowNodes,
@@ -16,6 +17,7 @@ import {
} from '../stores/canvasNodeGroups.constants';
import { GRID_SIZE } from '@/app/utils/nodeViewUtils';
import { STICKY_NODE_TYPE } from '@/app/constants/nodeTypes';
import { createNodeExecutionSnapshot } from '../__tests__/utils';
const snapToGrid = (v: number) => Math.round(v / GRID_SIZE) * GRID_SIZE;
@@ -48,6 +50,10 @@ function nodeStore(...nodes: INodeUi[]) {
return (id: string) => map.get(id);
}
function snapshotGetter(byId: Record<string, Partial<NodeExecutionSnapshot>> = {}) {
return (id: string): NodeExecutionSnapshot => createNodeExecutionSnapshot(byId[id]);
}
describe('computeNodesRectFromStore', () => {
// Same defaults used by the design system canvas grid (16 × 6).
const NODE_W = 96;
@@ -105,6 +111,119 @@ describe('computeNodesRectFromStore', () => {
});
});
describe('aggregateGroupExecution', () => {
function statusOf(nodeIds: string[], byId: Record<string, Partial<NodeExecutionSnapshot>> = {}) {
return aggregateGroupExecution(nodeIds, snapshotGetter(byId));
}
it('returns running when any node is running', () => {
expect(statusOf(['a', 'b'], { a: { running: true } })).toBe('running');
});
it('returns running when any node is waitingForNext', () => {
expect(statusOf(['a'], { a: { waitingForNext: true } })).toBe('running');
});
it('returns error when any node has an execution error', () => {
expect(statusOf(['a', 'b'], { b: { hasExecutionError: true } })).toBe('error');
});
it('returns issues (not error) when a node has only validation errors and never ran', () => {
expect(statusOf(['a', 'b'], { b: { hasValidationError: true } })).toBe('issues');
});
it('execution error beats validation issues', () => {
expect(
statusOf(['a', 'b'], {
a: { hasExecutionError: true },
b: { hasValidationError: true },
}),
).toBe('error');
});
it('validation issues beat warning (dirty) and success', () => {
expect(
statusOf(['a', 'b'], {
a: { hasValidationError: true },
b: { status: 'success', dirty: true },
}),
).toBe('issues');
});
it('ignores canceled / new for the success-success rollup (treated as idle, mirroring single-node)', () => {
expect(statusOf(['a', 'b'], { a: { status: 'success' }, b: { status: 'canceled' } })).toBe(
'success',
);
expect(
statusOf(['a', 'b'], { a: { status: 'canceled' }, b: { status: 'new' } }),
).toBeUndefined();
});
it('returns success when all nodes are success', () => {
expect(statusOf(['a', 'b'], { a: { status: 'success' }, b: { status: 'success' } })).toBe(
'success',
);
});
it('returns success when one node is success and others never ran (unknown — e.g. untaken conditional branch)', () => {
expect(statusOf(['a', 'b'], { a: { status: 'success' }, b: { status: 'unknown' } })).toBe(
'success',
);
});
it('returns undefined (idle) when all nodes are unknown — workflow has never executed', () => {
expect(
statusOf(['a', 'b'], { a: { status: 'unknown' }, b: { status: 'unknown' } }),
).toBeUndefined();
});
it('returns undefined when no node status is set', () => {
expect(statusOf(['a', 'b'])).toBeUndefined();
});
it('returns waiting when any node has a waiting reason (form/webhook/etc.)', () => {
expect(statusOf(['a', 'b'], { a: { waiting: 'waiting for webhook' } })).toBe('waiting');
});
it('returns waiting when any node has executionStatus waiting', () => {
expect(statusOf(['a'], { a: { status: 'waiting' } })).toBe('waiting');
});
it('running beats error', () => {
expect(statusOf(['a', 'b'], { a: { running: true }, b: { hasExecutionError: true } })).toBe(
'running',
);
});
it('error beats success', () => {
expect(statusOf(['a', 'b'], { a: { status: 'success' }, b: { hasExecutionError: true } })).toBe(
'error',
);
});
it('returns warning when any node is dirty (parameters changed since its last run)', () => {
expect(
statusOf(['a', 'b'], { a: { status: 'success' }, b: { status: 'success', dirty: true } }),
).toBe('warning');
});
it('error beats warning, warning beats success — mirrors single-node CSS rule order', () => {
expect(
statusOf(['a', 'b'], {
a: { hasExecutionError: true },
b: { status: 'success', dirty: true },
}),
).toBe('error');
expect(statusOf(['a', 'b'], { a: { status: 'success' }, b: { dirty: true } })).toBe('warning');
});
it('waiting beats running — mirrors single-node CSS rule order', () => {
expect(statusOf(['a', 'b'], { a: { running: true }, b: { waiting: 'waiting for form' } })).toBe(
'waiting',
);
});
});
describe('mapGroupsToVueFlowNodes', () => {
const group: IWorkflowGroup = { id: 'g1', name: 'G', nodeIds: ['a', 'b'] };
@@ -115,6 +234,7 @@ describe('mapGroupsToVueFlowNodes', () => {
getNodeById: getById,
isGroupCollapsed: () => isCollapsed,
readOnly: false,
getNodeExecutionSnapshot: snapshotGetter(),
});
}
@@ -158,12 +278,14 @@ describe('mapGroupsToVueFlowNodes', () => {
getNodeById: getById,
isGroupCollapsed: () => true,
readOnly: false,
getNodeExecutionSnapshot: snapshotGetter(),
});
const expanded = mapGroupsToVueFlowNodes({
allGroups: [group],
getNodeById: getById,
isGroupCollapsed: () => false,
readOnly: false,
getNodeExecutionSnapshot: snapshotGetter(),
});
expect(collapsed[0].width).toBe(GROUP_HEADER_WIDTH_COLLAPSED);
expect(expanded[0].width).toBe(GROUP_HEADER_WIDTH_COLLAPSED);
@@ -187,6 +309,7 @@ describe('mapGroupsToVueFlowNodes', () => {
getNodeById: getById,
isGroupCollapsed: () => true,
readOnly: true,
getNodeExecutionSnapshot: snapshotGetter(),
});
expect(out[0].selectable).toBe(true);
expect(out[0].draggable).toBe(false);
@@ -199,6 +322,7 @@ describe('mapGroupsToVueFlowNodes', () => {
getNodeById: getById,
isGroupCollapsed: () => true,
readOnly: false,
getNodeExecutionSnapshot: snapshotGetter(),
});
expect(out).toHaveLength(0);
});
@@ -210,6 +334,7 @@ describe('mapGroupsToVueFlowNodes', () => {
getNodeById: getById,
isGroupCollapsed: () => true,
readOnly: true,
getNodeExecutionSnapshot: snapshotGetter(),
});
expect(out[0].draggable).toBe(false);
});
@@ -221,6 +346,7 @@ describe('mapGroupsToVueFlowNodes', () => {
getNodeById: getById,
isGroupCollapsed: () => false,
readOnly: false,
getNodeExecutionSnapshot: snapshotGetter(),
});
expect(Math.abs(out[0].position.x % GRID_SIZE)).toBe(0);
expect(Math.abs(out[0].position.y % GRID_SIZE)).toBe(0);
@@ -1,6 +1,12 @@
import type { IWorkflowGroup } from 'n8n-workflow';
import type { ExecutionStatus, IWorkflowGroup } from 'n8n-workflow';
import type { INodeUi } from '@/Interface';
import type { CanvasConnection, CanvasGroupNode, CanvasGroupNodeData } from '../canvas.types';
import type {
CanvasConnection,
CanvasGroupNode,
CanvasGroupNodeData,
GroupExecutionStatus,
NodeExecutionSnapshot,
} from '../canvas.types';
import {
CANVAS_NODE_GROUP_HANDLE_LEFT,
CANVAS_NODE_GROUP_HANDLE_RIGHT,
@@ -127,12 +133,62 @@ export function computeNodesRectFromStore(
};
}
// Highest priority first. `success` is resolved separately.
const GROUP_STATUS_PRIORITY: readonly GroupExecutionStatus[] = [
'waiting',
'running',
'error',
'issues',
'warning',
];
const IDLE_STATUSES: readonly ExecutionStatus[] = ['new', 'unknown', 'canceled'];
/**
* Classify a single member for the group rollup by this priority:
* waiting > running > error > issues > warning > success > idle.
* Validation issues are kept distinct from execution errors.
* Other is an active-but-unhandled status that must block a misleading success.
* Idle statuses return undefined (they neither paint nor veto).
*/
function classifyNodeForGroup(
snapshot: NodeExecutionSnapshot,
): GroupExecutionStatus | 'other' | undefined {
const { status } = snapshot;
if (snapshot.waiting || status === 'waiting') return 'waiting';
if (snapshot.running || snapshot.waitingForNext) return 'running';
if (snapshot.hasExecutionError) return 'error';
if (snapshot.hasValidationError) return 'issues';
if (snapshot.dirty) return 'warning';
if (status === 'success') return 'success';
if (status === undefined || IDLE_STATUSES.includes(status)) return undefined;
return 'other';
}
/** Reduce a group's per-node state into one dominant status. */
export function aggregateGroupExecution(
nodeIds: string[],
getNodeExecutionSnapshot: (id: string) => NodeExecutionSnapshot,
): GroupExecutionStatus | undefined {
const seen = new Set<GroupExecutionStatus | 'other' | undefined>();
for (const id of nodeIds) {
seen.add(classifyNodeForGroup(getNodeExecutionSnapshot(id)));
}
for (const status of GROUP_STATUS_PRIORITY) {
if (seen.has(status)) return status;
}
// success is the only status that speaks for every member
return seen.has('success') && !seen.has('other') ? 'success' : undefined;
}
export interface MapGroupsToVueFlowNodesInputs {
allGroups: IWorkflowGroup[];
getNodeById: (id: string) => INodeUi | undefined;
getNodeDisplaySize?: GetNodeDisplaySize;
isGroupCollapsed: (id: string) => boolean;
readOnly: boolean;
getNodeExecutionSnapshot: (id: string) => NodeExecutionSnapshot;
}
/**
@@ -145,6 +201,7 @@ export function mapGroupsToVueFlowNodes({
getNodeDisplaySize,
isGroupCollapsed,
readOnly,
getNodeExecutionSnapshot,
}: MapGroupsToVueFlowNodesInputs): CanvasGroupNode[] {
const out: CanvasGroupNode[] = [];
for (const group of allGroups) {
@@ -159,6 +216,7 @@ export function mapGroupsToVueFlowNodes({
group,
nodesRect,
isCollapsed: collapsed,
executionStatus: aggregateGroupExecution(group.nodeIds, getNodeExecutionSnapshot),
};
const titleBar = titleBarFromNodesRect(nodesRect, collapsed);
@@ -9,7 +9,7 @@
* `executionData.store.test.ts`. These tests verify the shape of the canvas
* output and that renderData values flow into the right fields.
*/
import type { ITaskData, IConnections } from 'n8n-workflow';
import type { ITaskData, IConnections, IWorkflowGroup } from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
import { createPinia, setActivePinia } from 'pinia';
import { computed, ref, shallowRef } from 'vue';
@@ -18,6 +18,7 @@ import {
type CanvasRenderData,
} from '@/features/workflows/canvas/canvas.utils';
import { useCanvasMapping } from '@/features/workflows/canvas/composables/useCanvasMapping';
import type { CanvasNodeGroupView } from './useCanvasNodeGroupView';
import { createTestNode } from '@/__tests__/mocks';
import type { INodeUi } from '@/Interface';
import { CanvasNodeRenderType, type CanvasNodeData } from '../canvas.types';
@@ -275,6 +276,77 @@ describe('useCanvasMapping — mapped nodes', () => {
});
});
describe('useCanvasMapping — getNodeExecutionSnapshot', () => {
it('reads hasExecutionError from executionIssuesByNodeName (single-node parity)', () => {
const node = createTestNode({ id: 'a', name: 'Alpha' }) as INodeUi;
const rd = createEmptyCanvasRenderData();
rd.executionIssuesByNodeName.set(
'Alpha',
computed(() => ['Boom']),
);
const { getNodeExecutionSnapshot } = useCanvasMapping({
nodes: ref([node]),
connections: ref({}),
renderData: shallowRef(rd),
});
const snapshot = getNodeExecutionSnapshot('a');
expect(snapshot.hasExecutionError).toBe(true);
expect(snapshot.hasValidationError).toBe(false);
});
it('reads hasValidationError without flagging an execution error', () => {
const node = createTestNode({ id: 'a', name: 'Alpha' }) as INodeUi;
const rd = createEmptyCanvasRenderData();
rd.validationErrorsByNodeId.set(
'a',
computed(() => ['Missing parameter']),
);
const { getNodeExecutionSnapshot } = useCanvasMapping({
nodes: ref([node]),
connections: ref({}),
renderData: shallowRef(rd),
});
const snapshot = getNodeExecutionSnapshot('a');
expect(snapshot.hasValidationError).toBe(true);
expect(snapshot.hasExecutionError).toBe(false);
});
it.each(['error', 'crashed'] as const)(
'flags hasExecutionError on %s status even without execution-issue text',
(status) => {
const node = createTestNode({ id: 'a', name: 'Alpha' }) as INodeUi;
const rd = createEmptyCanvasRenderData();
setStatus(rd, 'a', status);
const { getNodeExecutionSnapshot } = useCanvasMapping({
nodes: ref([node]),
connections: ref({}),
renderData: shallowRef(rd),
});
expect(getNodeExecutionSnapshot('a').hasExecutionError).toBe(true);
},
);
it('flags hasExecutionError from a last-task error when no issue text exists', () => {
const node = createTestNode({ id: 'a', name: 'Alpha' }) as INodeUi;
const rd = createEmptyCanvasRenderData();
setRunData(rd, 'a', [{ error: { message: 'Boom' } } as unknown as ITaskData]);
const { getNodeExecutionSnapshot } = useCanvasMapping({
nodes: ref([node]),
connections: ref({}),
renderData: shallowRef(rd),
});
expect(getNodeExecutionSnapshot('a').hasExecutionError).toBe(true);
});
});
describe('useCanvasMapping — mapped connections', () => {
function makeWorkflow(connections: IConnections, nodes: INodeUi[] = []) {
const alpha = createTestNode({ id: 'a', name: 'Alpha' }) as INodeUi;
@@ -517,4 +589,115 @@ describe('useCanvasMapping — mapped connections', () => {
expect(mapped.value[0].data?.status).toBe('running');
});
});
describe('collapsed group merged edge status', () => {
// Two grouped nodes feeding the same external input merge into a single
// edge when the group is collapsed; the edge must surface the
// highest-priority status among the underlying connections.
const group: IWorkflowGroup = { id: 'g1', name: 'G', nodeIds: ['m1', 'm2'] };
const collapsedView = { isGroupCollapsed: () => true } as unknown as CanvasNodeGroupView;
function fanInWorkflow() {
const m1 = createTestNode({ id: 'm1', name: 'M1' }) as INodeUi;
const m2 = createTestNode({ id: 'm2', name: 'M2' }) as INodeUi;
const external = createTestNode({ id: 'x', name: 'X' }) as INodeUi;
const connections: IConnections = {
M1: { main: [[{ node: 'X', type: NodeConnectionTypes.Main, index: 0 }]] },
M2: { main: [[{ node: 'X', type: NodeConnectionTypes.Main, index: 0 }]] },
};
return { nodes: [m1, m2, external], connections };
}
it('surfaces the status of a non-first merged connection (only the second member ran)', () => {
const { nodes, connections } = fanInWorkflow();
const rd = createEmptyCanvasRenderData();
setRunData(rd, 'm2', [{ executionStatus: 'success' } as ITaskData]);
rd.executionRunDataOutputMapByNodeId.set('m2', {
main: { '0': { total: 1, iterations: 1 } },
});
const { connections: mapped } = useCanvasMapping({
nodes: ref(nodes),
connections: ref(connections),
renderData: shallowRef(rd),
allGroups: ref([group]),
nodeGroupView: collapsedView,
});
expect(mapped.value).toHaveLength(1);
expect(mapped.value[0].source).toBe('group:g1');
expect(mapped.value[0].target).toBe('x');
expect(mapped.value[0].data?.status).toBe('success');
});
it('picks the highest-priority status across merged connections, not the first one', () => {
const { nodes, connections } = fanInWorkflow();
const rd = createEmptyCanvasRenderData();
// Both members ran, the second one is pinned — pinned outranks success,
// so it must win even though the first connection comes first.
setRunData(rd, 'm1', [{ executionStatus: 'success' } as ITaskData]);
setRunData(rd, 'm2', [{ executionStatus: 'success' } as ITaskData]);
rd.executionRunDataOutputMapByNodeId.set('m1', {
main: { '0': { total: 1, iterations: 1 } },
});
rd.executionRunDataOutputMapByNodeId.set('m2', {
main: { '0': { total: 1, iterations: 1 } },
});
rd.pinnedDataByNodeId.set(
'm2',
computed(() => [{ json: {} }]),
);
const { connections: mapped } = useCanvasMapping({
nodes: ref(nodes),
connections: ref(connections),
renderData: shallowRef(rd),
allGroups: ref([group]),
nodeGroupView: collapsedView,
});
expect(mapped.value).toHaveLength(1);
expect(mapped.value[0].data?.status).toBe('pinned');
});
it('resolves the item-count label through the canonical source (not the group id)', () => {
const { nodes, connections } = fanInWorkflow();
const rd = createEmptyCanvasRenderData();
setRunData(rd, 'm1', [{ executionStatus: 'success' } as ITaskData]);
rd.executionRunDataOutputMapByNodeId.set('m1', {
main: { '0': { total: 5, iterations: 1 } },
});
const { connections: mapped } = useCanvasMapping({
nodes: ref(nodes),
connections: ref(connections),
renderData: shallowRef(rd),
allGroups: ref([group]),
nodeGroupView: collapsedView,
});
expect(mapped.value).toHaveLength(1);
expect(mapped.value[0].source).toBe('group:g1');
expect(mapped.value[0].label).toBe('5 items');
});
it('resolves the pinned item-count label through the canonical source', () => {
const { nodes, connections } = fanInWorkflow();
const rd = createEmptyCanvasRenderData();
rd.pinnedDataByNodeId.set(
'm1',
computed(() => [{ json: {} }, { json: {} }]),
);
const { connections: mapped } = useCanvasMapping({
nodes: ref(nodes),
connections: ref(connections),
renderData: shallowRef(rd),
allGroups: ref([group]),
nodeGroupView: collapsedView,
});
expect(mapped.value[0].label).toBe('2 items');
});
});
});
@@ -12,6 +12,7 @@ import type {
CanvasConnectionData,
CanvasNode,
CanvasNodeData,
NodeExecutionSnapshot,
} from '../canvas.types';
import { CanvasConnectionMode, CanvasNodeRenderType } from '../canvas.types';
import type { CanvasNodeGroupView } from './useCanvasNodeGroupView';
@@ -28,8 +29,12 @@ import type { IConnections, ITaskData, IWorkflowGroup } from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
import type { INodeUi } from '@/Interface';
import { MarkerType } from '@vue-flow/core';
import type { Connection } from '@vue-flow/core';
import * as workflowUtils from 'n8n-workflow/common';
// Highest priority first — single source of precedence for connection status.
const CONNECTION_STATUS_PRIORITY = ['running', 'pinned', 'error', 'success'] as const;
/**
* Maps workflow nodes and connections into the vue-flow canvas shape.
*
@@ -56,9 +61,49 @@ export function useCanvasMapping({
}) {
const i18n = useI18n();
function filterOutCanceled(tasks: ITaskData[] | null): ITaskData[] | null {
if (!tasks) return null;
return tasks.filter((task) => task.executionStatus !== 'canceled');
// `executionIssuesByNodeName` is keyed by name; groups address nodes by id.
const nodeNameById = computed(() => {
const map = new Map<string, string>();
for (const node of nodes.value) map.set(node.id, node.name);
return map;
});
function countNonCanceledIterations(tasks: ITaskData[] | null | undefined): number {
if (!tasks) return 0;
let count = 0;
for (const task of tasks) {
if (task.executionStatus !== 'canceled') count++;
}
return count;
}
// Per-node execution projection feeding the group-status aggregation.
function getNodeExecutionSnapshot(id: string): NodeExecutionSnapshot {
const rd = renderData.value;
const render = rd.renderTypeByNodeId.get(id)?.value;
const name = nodeNameById.value.get(id);
const status = rd.executionStatusByNodeId.get(id)?.value;
const tasks = rd.executionRunDataByNodeId.get(id)?.value;
// Mirror the single-node `computeHasIssues`
const executionIssues = name ? rd.executionIssuesByNodeName.get(name)?.value : undefined;
const hasExecutionError =
status === 'error' ||
status === 'crashed' ||
(executionIssues?.length ?? 0) > 0 ||
Boolean(tasks?.at(-1)?.error);
return {
running: rd.executionRunningByNodeId.get(id)?.value ?? false,
waitingForNext: rd.executionWaitingForNextByNodeId.get(id)?.value ?? false,
waiting: rd.executionWaitingByNodeId.get(id)?.value,
hasExecutionError,
hasValidationError: (rd.validationErrorsByNodeId.get(id)?.value?.length ?? 0) > 0,
status,
dirty:
render?.type === CanvasNodeRenderType.Default && render.options.dirtiness !== undefined,
iterations: countNonCanceledIterations(tasks),
};
}
// Node id → its collapsed group, for nodes hidden by a collapsed group.
@@ -101,6 +146,7 @@ export function useCanvasMapping({
const inputConnections = connectionsByDestinationNode[node.name] ?? {};
const runData = rd.executionRunDataByNodeId.get(node.id)?.value ?? null;
const executionSnapshot = getNodeExecutionSnapshot(node.id);
const data: CanvasNodeData = {
id: node.id,
@@ -118,14 +164,14 @@ export function useCanvasMapping({
visible: rd.hasIssuesByNodeId.get(node.id)?.value ?? false,
},
execution: {
status: rd.executionStatusByNodeId.get(node.id)?.value,
waiting: rd.executionWaitingByNodeId.get(node.id)?.value,
waitingForNext: rd.executionWaitingForNextByNodeId.get(node.id)?.value ?? false,
running: rd.executionRunningByNodeId.get(node.id)?.value ?? false,
status: executionSnapshot.status,
waiting: executionSnapshot.waiting,
waitingForNext: executionSnapshot.waitingForNext,
running: executionSnapshot.running,
},
runData: {
outputMap: rd.executionRunDataOutputMapByNodeId.get(node.id),
iterations: filterOutCanceled(runData)?.length ?? 0,
iterations: executionSnapshot.iterations,
visible: !!runData,
},
render:
@@ -158,43 +204,56 @@ export function useCanvasMapping({
}));
});
function getConnectionData(connection: CanvasConnection): CanvasConnectionData {
function getConnectionStatus(connection: Connection): CanvasConnectionData['status'] {
const rd = renderData.value;
const { type, index } = parseCanvasConnectionHandleString(connection.sourceHandle);
const outputMap = rd.executionRunDataOutputMapByNodeId.get(connection.source);
const runData = outputMap?.[type]?.[index];
const runData = rd.executionRunDataOutputMapByNodeId.get(connection.source)?.[type]?.[index];
const runDataTotal = runData?.total ?? 0;
const sourceTasks = rd.executionRunDataByNodeId.get(connection.source)?.value ?? [];
let lastSourceTask: ITaskData | undefined = sourceTasks[sourceTasks.length - 1];
if (lastSourceTask?.executionStatus === 'canceled' && sourceTasks.length > 1) {
const sourceTasks = rd.executionRunDataByNodeId.get(connection.source)?.value;
let lastSourceTask: ITaskData | undefined = sourceTasks?.[sourceTasks.length - 1];
if (lastSourceTask?.executionStatus === 'canceled' && sourceTasks && sourceTasks.length > 1) {
lastSourceTask = sourceTasks[sourceTasks.length - 2];
}
const sourcePinned = rd.pinnedDataByNodeId.get(connection.source)?.value;
const sourceRunData = rd.executionRunDataByNodeId.get(connection.source)?.value;
const targetRunData = rd.executionRunDataByNodeId.get(connection.target)?.value;
const sourceRunning = rd.executionRunningByNodeId.get(connection.source)?.value ?? false;
const sourceHasIssues = rd.hasIssuesByNodeId.get(connection.source)?.value ?? false;
// Non-main connections (model, memory, tool) are passive — count as
// executed only if the target node also ran.
const targetExecuted =
type === NodeConnectionTypes.Main ||
Boolean(rd.executionRunDataByNodeId.get(connection.target)?.value);
const matches: Record<(typeof CONNECTION_STATUS_PRIORITY)[number], boolean> = {
running:
(rd.executionRunningByNodeId.get(connection.source)?.value ?? false) && runDataTotal === 0,
pinned: Boolean(rd.pinnedDataByNodeId.get(connection.source)?.value && sourceTasks),
error: rd.hasIssuesByNodeId.get(connection.source)?.value ?? false,
success: runDataTotal > 0 && lastSourceTask?.executionStatus !== 'canceled' && targetExecuted,
};
return CONNECTION_STATUS_PRIORITY.find((status) => matches[status]);
}
function getConnectionData(connection: CanvasConnection): CanvasConnectionData {
const rd = renderData.value;
// For edges remapped to `group:*` ids, the real endpoints live on
// `data.canonicals` (multiple when same-endpoint edges were merged).
// The edge surfaces the highest-priority status among them.
const canonicals: Connection[] = connection.data?.canonicals ?? [connection];
let status: CanvasConnectionData['status'];
if (sourceRunning && runDataTotal === 0) {
status = 'running';
} else if (sourcePinned && sourceRunData) {
status = 'pinned';
} else if (sourceHasIssues) {
status = 'error';
} else if (runDataTotal > 0 && lastSourceTask?.executionStatus !== 'canceled') {
// Non-main connections (model/memory/tool) are passive — only mark
// success when the target node also produced run data.
const isMainConnection = type === NodeConnectionTypes.Main;
if (isMainConnection || targetRunData) {
status = 'success';
}
if (canonicals.length === 1) {
status = getConnectionStatus(canonicals[0]);
} else {
const statuses = canonicals.map(getConnectionStatus);
status = CONNECTION_STATUS_PRIORITY.find((s) => statuses.includes(s));
}
const sourceInputs = rd.nodeInputsByNodeId.get(connection.source)?.value ?? [];
const targetInputs = rd.nodeInputsByNodeId.get(connection.target)?.value ?? [];
const { source: sourceNodeId, target: targetNodeId, sourceHandle } = canonicals[0];
const { type } = parseCanvasConnectionHandleString(sourceHandle);
const sourceInputs = rd.nodeInputsByNodeId.get(sourceNodeId)?.value ?? [];
const targetInputs = rd.nodeInputsByNodeId.get(targetNodeId)?.value ?? [];
const maxConnections = [...sourceInputs, ...targetInputs]
.filter((port) => port.type === type)
.reduce<number | undefined>((acc, port) => {
@@ -211,7 +270,13 @@ export function useCanvasMapping({
function getConnectionLabel(connection: CanvasConnection): string {
const rd = renderData.value;
const sourceId = connection.source;
// For edges remapped to `group:*` ids, the real endpoints live on
// `data.canonicals`; the label describes the underlying node, like the status.
const {
source: sourceId,
target: targetId,
sourceHandle,
} = connection.data?.canonicals?.[0] ?? connection;
const pinned = rd.pinnedDataByNodeId.get(sourceId)?.value;
if (pinned) {
@@ -227,17 +292,17 @@ export function useCanvasMapping({
const sourceRunData = rd.executionRunDataByNodeId.get(sourceId)?.value;
if (!sourceRunData) return '';
const { type, index } = parseCanvasConnectionHandleString(connection.sourceHandle);
const { type, index } = parseCanvasConnectionHandleString(sourceHandle);
const outputMap = rd.executionRunDataOutputMapByNodeId.get(sourceId);
const outputData = outputMap?.[type]?.[index];
const isMainConnection = type === NodeConnectionTypes.Main;
const targetRunData = rd.executionRunDataByNodeId.get(connection.target)?.value;
const targetRunData = rd.executionRunDataByNodeId.get(targetId)?.value;
// Non-main connections (AI tool/memory/embedding) track per-target counts
// when the target has run data; otherwise stay quiet.
if (!isMainConnection && outputData?.byTarget) {
const targetData = outputData.byTarget[connection.target];
const targetData = outputData.byTarget[targetId];
if (targetData && targetData.total > 0 && targetRunData) {
return i18n.baseText(
targetData.iterations > 1 ? 'ndv.output.itemsTotal' : 'ndv.output.items',
@@ -265,5 +330,6 @@ export function useCanvasMapping({
nodes: mappedNodes,
connections: mappedConnections,
nodeDisplaySizeById,
getNodeExecutionSnapshot,
};
}
@@ -6,6 +6,7 @@ import type { ExecutionOutputMap } from '@/app/types/executionData';
import type { INodeUi, IWorkflowDb } from '@/Interface';
import { NodeDiffStatus, type IConnections } from 'n8n-workflow';
import { useCanvasMapping } from '@/features/workflows/canvas/composables/useCanvasMapping';
import { createNodeExecutionSnapshot } from '@/features/workflows/canvas/__tests__/utils';
import { disposeWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import { disposeWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
@@ -172,6 +173,7 @@ describe('useWorkflowDiff', () => {
nodeDisplaySizeById: computed(
() => ({}) as Record<string, { width: number; height: number }>,
),
getNodeExecutionSnapshot: () => createNodeExecutionSnapshot(),
nodes: computed(() => nodes as CanvasNode[]),
connections: computed(() => connections as CanvasConnection[]),
});