mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-24 23:22:38 +08:00
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:
@@ -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>;
|
||||
|
||||
+2
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
+72
@@ -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 }) });
|
||||
|
||||
+85
-5
@@ -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;
|
||||
|
||||
+18
-62
@@ -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 {
|
||||
|
||||
+89
-4
@@ -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 */
|
||||
}
|
||||
|
||||
+5
-14
@@ -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);
|
||||
}
|
||||
|
||||
+54
@@ -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>
|
||||
+127
-1
@@ -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);
|
||||
|
||||
+60
-2
@@ -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);
|
||||
|
||||
+184
-1
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+104
-38
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
+2
@@ -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[]),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user