output selector

This commit is contained in:
Rob 2026-06-26 07:20:27 +01:00
parent 72e8b4c70f
commit b075c38e4f
10 changed files with 635 additions and 6 deletions

View File

@ -3,6 +3,7 @@ import LogsViewExecutionSummary from '@/features/execution/logs/components/LogsV
import LogsViewConsumedTokenCountText from '@/features/execution/logs/components/LogsViewConsumedTokenCountText.vue';
import LogsPanelHeader from '@/features/execution/logs/components/LogsPanelHeader.vue';
import LogsViewRunData from '@/features/execution/logs/components/LogsViewRunData.vue';
import LogsGroupRunData from '@/features/execution/logs/components/LogsGroupRunData.vue';
import { useResizablePanel } from '@/app/composables/useResizablePanel';
import {
isLogGroupEntry,
@ -17,6 +18,8 @@ import LogsViewNodeName from '@/features/execution/logs/components/LogsViewNodeN
import { computed, useTemplateRef } from 'vue';
import KeyboardShortcutTooltip from '@/app/components/KeyboardShortcutTooltip.vue';
import {
getGroupInputEntries,
getGroupOutputEntries,
getSubtreeTotalConsumedTokens,
isPlaceholderLog,
} from '@/features/execution/logs/logs.utils';
@ -76,6 +79,14 @@ const inputEntry = computed(() => (isLogGroupEntry(logEntry) ? logEntry.inputLog
const outputEntry = computed(() =>
isLogGroupEntry(logEntry) ? logEntry.outputLogEntry : logEntry,
);
// A group can have multiple boundary members on either side; the panes let the
// user switch between them (run selection is handled by RunData below).
const groupInputEntries = computed(() =>
isLogGroupEntry(logEntry) ? getGroupInputEntries(logEntry) : [],
);
const groupOutputEntries = computed(() =>
isLogGroupEntry(logEntry) ? getGroupOutputEntries(logEntry) : [],
);
const type = computed(() =>
isLogGroupEntry(logEntry) ? undefined : nodeTypeStore.getNodeType(logEntry.node.type),
);
@ -223,7 +234,19 @@ function handleResizeEnd() {
@resize="resizer.onResize"
@resizeend="handleResizeEnd"
>
<LogsGroupRunData
v-if="isGroup"
pane-type="input"
:title="locale.baseText('logs.details.header.actions.input')"
:entries="groupInputEntries"
:default-entry="inputEntry"
:collapsing-table-column-name="collapsingInputTableColumnName"
:search-shortcut="searchShortcutPriorityPanel === 'input' ? 'ctrl+f' : undefined"
:show-redacted-overlay="panels !== LOG_DETAILS_PANEL_STATE.BOTH"
@collapsing-table-column-changed="emit('collapsingInputTableColumnChanged', $event)"
/>
<LogsViewRunData
v-else
data-test-id="log-details-input"
pane-type="input"
:title="locale.baseText('logs.details.header.actions.input')"
@ -234,8 +257,20 @@ function handleResizeEnd() {
@collapsing-table-column-changed="emit('collapsingInputTableColumnChanged', $event)"
/>
</N8nResizeWrapper>
<LogsGroupRunData
v-if="isGroup && panels !== LOG_DETAILS_PANEL_STATE.INPUT"
pane-type="output"
:class="$style.outputPanel"
:title="locale.baseText('logs.details.header.actions.output')"
:entries="groupOutputEntries"
:default-entry="outputEntry"
:collapsing-table-column-name="collapsingOutputTableColumnName"
:search-shortcut="searchShortcutPriorityPanel === 'output' ? 'ctrl+f' : undefined"
:show-redacted-overlay="panels !== LOG_DETAILS_PANEL_STATE.BOTH"
@collapsing-table-column-changed="emit('collapsingOutputTableColumnChanged', $event)"
/>
<LogsViewRunData
v-if="isTriggerNode || panels !== LOG_DETAILS_PANEL_STATE.INPUT"
v-else-if="isTriggerNode || panels !== LOG_DETAILS_PANEL_STATE.INPUT"
data-test-id="log-details-output"
pane-type="output"
:class="$style.outputPanel"

View File

@ -0,0 +1,88 @@
<script setup lang="ts">
import { computed } from 'vue';
import { N8nOption, N8nSelect } from '@n8n/design-system';
import NodeIcon from '@/app/components/NodeIcon.vue';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import type { LogEntry } from '@/features/execution/logs/logs.types';
const props = defineProps<{
entries: LogEntry[];
modelValue: number;
ariaLabel?: string;
}>();
const emit = defineEmits<{
'update:model-value': [value: number];
}>();
const nodeTypesStore = useNodeTypesStore();
const options = computed(() =>
props.entries.map((entry, index) => ({
index,
name: entry.node.name,
type: nodeTypesStore.getNodeType(entry.node.type, entry.node.typeVersion),
})),
);
const selectedType = computed(() => options.value[props.modelValue]?.type ?? null);
function onChange(value: unknown) {
emit('update:model-value', Number(value));
}
</script>
<template>
<N8nSelect
:model-value="modelValue"
:aria-label="ariaLabel"
:class="$style.select"
size="small"
teleported
data-test-id="logs-group-port-select"
@update:model-value="onChange"
>
<template #prefix>
<NodeIcon :node-type="selectedType" :size="14" :shrink="false" />
</template>
<N8nOption
v-for="option of options"
:key="option.index"
:value="option.index"
:label="option.name"
:class="$style.option"
data-test-id="logs-group-port-option"
>
<NodeIcon :node-type="option.type" :size="14" :shrink="false" :class="$style.icon" />
<span :class="$style.title">{{ option.name }}</span>
</N8nOption>
</N8nSelect>
</template>
<style lang="scss" module>
.select {
max-width: 224px;
:global(.el-input--suffix .el-input__inner) {
padding-left: calc(var(--spacing--lg) + var(--spacing--4xs));
}
}
.option {
display: flex;
align-items: center;
gap: var(--spacing--4xs);
font-size: var(--font-size--2xs);
}
.icon {
padding-right: var(--spacing--4xs);
}
.title {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--color--text--shade-1);
}
</style>

View File

@ -0,0 +1,91 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import type { NodePanelType } from '@/features/ndv/shared/ndv.types';
import type { LogEntry } from '@/features/execution/logs/logs.types';
import type { SearchShortcut } from '@/features/workflows/canvas/canvas.types';
import LogsViewRunData from '@/features/execution/logs/components/LogsViewRunData.vue';
import LogsGroupPortSelect from '@/features/execution/logs/components/LogsGroupPortSelect.vue';
const props = withDefaults(
defineProps<{
entries: LogEntry[];
defaultEntry: LogEntry;
paneType: NodePanelType;
title: string;
collapsingTableColumnName: string | null;
searchShortcut?: SearchShortcut;
showRedactedOverlay?: boolean;
}>(),
{ showRedactedOverlay: true },
);
const emit = defineEmits<{
collapsingTableColumnChanged: [columnName: string | null];
}>();
const defaultIndex = computed(() => {
const idx = props.entries.findIndex((e) => e.node.id === props.defaultEntry.node.id);
return idx >= 0 ? idx : 0;
});
const selectedIndex = ref(defaultIndex.value);
// Keep the selection valid as the group's members change between runs, and
// re-default to the boundary member when the list changes.
watch(
() => props.entries,
(entries) => {
if (selectedIndex.value >= entries.length) {
selectedIndex.value = defaultIndex.value;
}
},
);
const selectedEntry = computed(() => props.entries[selectedIndex.value]);
const dataTestId = computed(() =>
props.paneType === 'input' ? 'log-details-input' : 'log-details-output',
);
</script>
<template>
<div :class="$style.container">
<LogsViewRunData
v-if="selectedEntry"
:class="$style.runData"
:data-test-id="dataTestId"
:pane-type="paneType"
:title="title"
:log-entry="selectedEntry"
:enable-run-selection="true"
:collapsing-table-column-name="collapsingTableColumnName"
:search-shortcut="searchShortcut"
:show-redacted-overlay="showRedactedOverlay"
@collapsing-table-column-changed="emit('collapsingTableColumnChanged', $event)"
>
<template v-if="entries.length > 1" #run-selector-prepend>
<LogsGroupPortSelect
:entries="entries"
:model-value="selectedIndex"
:aria-label="title"
@update:model-value="selectedIndex = $event"
/>
</template>
</LogsViewRunData>
</div>
</template>
<style lang="scss" module>
.container {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
width: 100%;
overflow: hidden;
}
.runData {
flex: 1;
min-height: 0;
}
</style>

View File

@ -25,6 +25,7 @@ const {
paneType,
collapsingTableColumnName,
showRedactedOverlay = true,
enableRunSelection = false,
} = defineProps<{
title: string;
paneType: NodePanelType;
@ -32,6 +33,7 @@ const {
collapsingTableColumnName: string | null;
searchShortcut?: SearchShortcut;
showRedactedOverlay?: boolean;
enableRunSelection?: boolean;
}>();
const emit = defineEmits<{
@ -72,6 +74,15 @@ const runDataProps = computed<
overrideOutputs: [source.previousNodeOutput ?? 0],
};
});
// For the source-based path (input pane), RunData must read the source node's
// OUTPUT data (taskData.data), not its inputOverride (what it received). Using
// paneType="output" makes getNodeInputData skip inputOverride and read taskData.data,
// which is the data the selected node actually receives as input.
const effectivePaneType = computed<NodePanelType>(() =>
paneType === 'input' && !isSubNodeLog(logEntry) && !!runDataProps.value?.overrideOutputs
? 'output'
: paneType,
);
const isExecuting = computed(
() =>
paneType === 'output' &&
@ -92,14 +103,14 @@ function handleChangeDisplayMode(value: IRunDataDisplayMode) {
<RunData
v-if="runDataProps"
v-bind="runDataProps"
:key="`run-data${popOutWindow ? '-pop-out' : ''}`"
:key="`run-data-${logEntry.id}${popOutWindow ? '-pop-out' : ''}`"
:class="$style.component"
:workflow-object="logEntry.workflow"
:workflow-execution="logEntry.execution"
:no-data-in-branch-message="locale.baseText('ndv.output.noOutputDataInBranch')"
:executing-message="locale.baseText('ndv.output.executing')"
:pane-type="paneType"
:disable-run-index-selection="true"
:pane-type="effectivePaneType"
:disable-run-index-selection="!enableRunSelection"
:compact="true"
:show-actions-on-hover="true"
:disable-pin="true"
@ -115,6 +126,10 @@ function handleChangeDisplayMode(value: IRunDataDisplayMode) {
@display-mode-change="handleChangeDisplayMode"
@collapsing-table-column-changed="emit('collapsingTableColumnChanged', $event)"
>
<template v-if="$slots['run-selector-prepend']" #run-selector-prepend>
<slot name="run-selector-prepend" />
</template>
<template #header>
<N8nText :class="$style.title" :bold="true" color="text-light" size="small">
{{ title }}

View File

@ -16,6 +16,8 @@ import {
processFiles,
extractBotResponse,
wrapLogEntriesInGroups,
getGroupInputEntries,
getGroupOutputEntries,
} from './logs.utils';
import {
AGENT_LANGCHAIN_NODE_TYPE,
@ -1958,3 +1960,193 @@ describe(wrapLogEntriesInGroups, () => {
expect(getDefaultCollapsedEntries(result)).toEqual({});
});
});
describe('group input / output entries', () => {
const triggerNode = createTestNode({ name: 'Trigger', id: 'id-trigger' });
const nodeA = createTestNode({ name: 'A', id: 'id-a' });
const nodeB = createTestNode({ name: 'B', id: 'id-b' });
const nodeC = createTestNode({ name: 'C', id: 'id-c' });
const nodeD = createTestNode({ name: 'D', id: 'id-d' });
const nodeE = createTestNode({ name: 'E', id: 'id-e' });
const mainTo = (node: string) => ({
main: [[{ node, type: NodeConnectionTypes.Main, index: 0 }]],
});
const mainBranch = (...nodes: string[]) => ({
main: nodes.map((node) => [{ node, type: NodeConnectionTypes.Main, index: 0 }]),
});
function buildGroup(
runData: IRunExecutionData['resultData']['runData'],
nodeIds: string[],
options?: { nodes?: INodeUi[]; connections?: IConnections },
) {
const nodes = options?.nodes ?? [triggerNode, nodeA, nodeB, nodeC];
const connections = options?.connections ?? {
Trigger: mainTo('A'),
A: mainTo('B'),
B: mainTo('C'),
};
const workflow = createTestWorkflowObject({ id: 'wf-1', nodes, connections });
const response = createTestWorkflowExecutionResponse({
id: 'exec-1',
workflowData: createTestWorkflow({ id: 'wf-1', nodes }),
data: createRunExecutionData({ resultData: { runData } }),
});
const [group] = wrapLogEntriesInGroups(createLogTree(workflow, response), [
{ id: 'g1', name: 'G', nodeIds },
]).filter(isLogGroupEntry);
return group;
}
const baseRunData = {
Trigger: [createTestTaskData({ startTime: 1 })],
A: [createTestTaskData({ startTime: 2 })],
B: [createTestTaskData({ startTime: 3 })],
C: [createTestTaskData({ startTime: 4 })],
D: [createTestTaskData({ startTime: 5 })],
};
describe(getGroupInputEntries, () => {
it('returns only root members (not targeted by any internal connection)', () => {
// a → b → [c, d], group [a, b, c, d]
const group = buildGroup(baseRunData, ['id-a', 'id-b', 'id-c', 'id-d'], {
nodes: [triggerNode, nodeA, nodeB, nodeC, nodeD],
connections: {
Trigger: mainTo('A'),
A: mainTo('B'),
B: mainBranch('C', 'D'),
},
});
expect(getGroupInputEntries(group).map((e) => e.node.name)).toEqual(['A']);
});
it('returns a single root for a linear group', () => {
// a → b → c, group [b, c]
const group = buildGroup(baseRunData, ['id-b', 'id-c'], {
nodes: [triggerNode, nodeA, nodeB, nodeC],
connections: { Trigger: mainTo('A'), A: mainTo('B'), B: mainTo('C') },
});
expect(getGroupInputEntries(group).map((e) => e.node.name)).toEqual(['B']);
});
it('returns the single member for a one-member group', () => {
const group = buildGroup(baseRunData, ['id-b'], {
nodes: [triggerNode, nodeA, nodeB, nodeC],
connections: { Trigger: mainTo('A'), A: mainTo('B'), B: mainTo('C') },
});
expect(getGroupInputEntries(group).map((e) => e.node.name)).toEqual(['B']);
});
it('deduplicates a member with multiple runs to its first run', () => {
const group = buildGroup(
{
Trigger: [createTestTaskData({ startTime: 1 })],
A: [createTestTaskData({ startTime: 2 })],
B: [
createTestTaskData({ startTime: 3, executionIndex: 1 }),
createTestTaskData({ startTime: 5, executionIndex: 3 }),
],
C: [createTestTaskData({ startTime: 4, executionIndex: 2 })],
},
['id-b', 'id-c'],
{
nodes: [triggerNode, nodeA, nodeB, nodeC],
connections: { Trigger: mainTo('A'), A: mainTo('B'), B: mainTo('C') },
},
);
const entries = getGroupInputEntries(group);
expect(entries).toHaveLength(1);
expect(entries[0].node.name).toBe('B');
expect(entries[0].runIndex).toBe(0);
});
it('falls back to inputLogEntry when no root is found', () => {
// Cyclic group where every member is targeted by another member.
// In practice validation prevents this, but the fallback keeps the pane non-empty.
const group = buildGroup(baseRunData, ['id-a', 'id-b'], {
nodes: [triggerNode, nodeA, nodeB],
connections: {
Trigger: mainTo('A'),
A: mainTo('B'),
B: mainTo('A'),
},
});
expect(getGroupInputEntries(group)).toEqual([group.inputLogEntry]);
});
});
describe(getGroupOutputEntries, () => {
it('returns leaf members for a branched group', () => {
// a → b → [c, d], group [a, b, c, d]
const group = buildGroup(baseRunData, ['id-a', 'id-b', 'id-c', 'id-d'], {
nodes: [triggerNode, nodeA, nodeB, nodeC, nodeD],
connections: {
Trigger: mainTo('A'),
A: mainTo('B'),
B: mainBranch('C', 'D'),
},
});
expect(getGroupOutputEntries(group).map((e) => e.node.name)).toEqual(['C', 'D']);
});
it('includes leaves that pass data externally', () => {
// a → b → [c → e (external), d], group [a, b, c, d]
const group = buildGroup(
{ ...baseRunData, E: [createTestTaskData({ startTime: 6 })] },
['id-a', 'id-b', 'id-c', 'id-d'],
{
nodes: [triggerNode, nodeA, nodeB, nodeC, nodeD, nodeE],
connections: {
Trigger: mainTo('A'),
A: mainTo('B'),
B: mainBranch('C', 'D'),
C: mainTo('E'),
},
},
);
expect(getGroupOutputEntries(group).map((e) => e.node.name)).toEqual(['C', 'D']);
});
it('returns a single leaf for a linear group', () => {
// a → b → c, group [b, c]
const group = buildGroup(baseRunData, ['id-b', 'id-c'], {
nodes: [triggerNode, nodeA, nodeB, nodeC],
connections: { Trigger: mainTo('A'), A: mainTo('B'), B: mainTo('C') },
});
expect(getGroupOutputEntries(group).map((e) => e.node.name)).toEqual(['C']);
});
it('returns the single member for a one-member group', () => {
const group = buildGroup(baseRunData, ['id-b'], {
nodes: [triggerNode, nodeA, nodeB, nodeC],
connections: { Trigger: mainTo('A'), A: mainTo('B'), B: mainTo('C') },
});
expect(getGroupOutputEntries(group).map((e) => e.node.name)).toEqual(['B']);
});
it('falls back to outputLogEntry when no leaf is found', () => {
// Cyclic group where every member has an outgoing internal connection.
const group = buildGroup(baseRunData, ['id-a', 'id-b'], {
nodes: [triggerNode, nodeA, nodeB],
connections: {
Trigger: mainTo('A'),
A: mainTo('B'),
B: mainTo('A'),
},
});
expect(getGroupOutputEntries(group)).toEqual([group.outputLogEntry]);
});
});
});

View File

@ -416,6 +416,69 @@ export function wrapLogEntriesInGroups(
return result.sort(sortLogEntries);
}
/**
* Root member entries of a group — members not targeted by any main connection
* from another member. These are the entry points where data first enters the
* group's internal flow. Deduplicated by node ID (first run only); RunData's
* own run selector handles additional runs. Falls back to
* {@link LogGroupEntry.inputLogEntry} when no root is found.
*/
export function getGroupInputEntries(group: LogGroupEntry): LogEntry[] {
const members = dedupeMembersByNodeId(group.children);
const memberNames = new Set(members.map((m) => m.node.name));
// Collect every node name that is targeted by a main connection from a member.
const internalTargets = new Set<string>();
for (const member of members) {
const outputs = group.workflow.connectionsBySourceNode[member.node.name]?.main ?? [];
for (const connections of outputs) {
for (const connection of connections ?? []) {
if (memberNames.has(connection.node)) {
internalTargets.add(connection.node);
}
}
}
}
const roots = members.filter((m) => !internalTargets.has(m.node.name));
return roots.length > 0 ? roots : [group.inputLogEntry];
}
/**
* Leaf member entries of a group — members with no outgoing main connection to
* another member. These are the last nodes in each internal branch, regardless
* of whether they pass data externally. Deduplicated by node ID (first run
* only); RunData's own run selector handles additional runs. Falls back to
* {@link LogGroupEntry.outputLogEntry} when no leaf is found.
*/
export function getGroupOutputEntries(group: LogGroupEntry): LogEntry[] {
const members = dedupeMembersByNodeId(group.children);
const memberNames = new Set(members.map((m) => m.node.name));
const leaves = members.filter((member) => {
const outputs = group.workflow.connectionsBySourceNode[member.node.name]?.main ?? [];
// A leaf has no main output targeting another member.
return !outputs.some((connections) =>
(connections ?? []).some((connection) => memberNames.has(connection.node)),
);
});
return leaves.length > 0 ? leaves : [group.outputLogEntry];
}
function dedupeMembersByNodeId(children: LogEntry[]): LogEntry[] {
const seen = new Set<string>();
const result: LogEntry[] = [];
for (const child of children) {
if (seen.has(child.node.id)) continue;
seen.add(child.node.id);
result.push(child);
}
return result;
}
export function findLogEntryById(id: string, entries: LogTreeEntry[]) {
return findLogEntryRec((entry) => entry.id === id, entries);
}

View File

@ -1635,18 +1635,19 @@ defineExpose({ enterEditMode });
<div
v-if="
maxRunIndex > 0 &&
(maxRunIndex > 0 || $slots['run-selector-prepend']) &&
!displaysMultipleNodes &&
!props.disableRunIndexSelection &&
!isExecutionRedacted
"
v-show="!editMode.enabled"
:class="$style.runSelector"
>
<div :class="$style.runSelectorInner">
<slot name="run-selector-prepend" />
<slot v-if="inputSelectLocation === 'runs'" name="input-select"></slot>
<N8nSelect
v-if="maxRunIndex > 0 && !props.disableRunIndexSelection"
:model-value="runIndex"
:class="$style.runSelectorSelect"
size="small"

View File

@ -48,6 +48,10 @@ export class LogsPanel {
return this.getGroupLogEntries().filter({ hasText: name });
}
getGroupPortSelects(): Locator {
return this.root.getByTestId('logs-group-port-select');
}
getSelectedLogEntry(): Locator {
return this.root.getByTestId('logs-overview-body').getByRole('treeitem', { selected: true });
}

View File

@ -257,6 +257,9 @@ test.describe(
await groupRow.click();
await expect(n8n.canvas.logsPanel.inputPanel.get()).toBeVisible();
await expect(n8n.canvas.logsPanel.outputPanel.get()).toBeVisible();
// Linear group: single root (Set A) and single leaf (Set B), so no port selectors.
await expect(n8n.canvas.logsPanel.getGroupPortSelects()).toHaveCount(0);
});
test('auto-extends the group when a new connection would otherwise invalidate it', async ({
@ -436,3 +439,53 @@ test.describe(
});
},
);
test.describe(
'Canvas node groups — leaf port selection in logs',
{ annotation: [{ type: 'owner', description: 'Adore' }] },
() => {
const BRANCHED_FIXTURE = 'Canvas-node-groups-branched-fixture.json';
const BRANCHED_GROUP_TITLE = 'Branched group';
test.beforeEach(async ({ n8n, setupRequirements }) => {
await setupRequirements(requirements);
await n8n.start.fromImportedWorkflow(BRANCHED_FIXTURE);
await n8n.canvas.clickZoomToFitButton();
await n8n.canvas.deselectAll();
});
test('shows an output port selector for a group with multiple leaves', async ({ n8n }) => {
await n8n.canvas.logsPanel.open();
await n8n.canvas.clickExecuteWorkflowButton();
const groupRow = n8n.canvas.logsPanel.getGroupLogEntryByName(BRANCHED_GROUP_TITLE);
await expect(groupRow).toBeVisible();
await groupRow.click();
// Single root (a) → no input port selector.
// Two leaves (c, d) → one output port selector.
await expect(n8n.canvas.logsPanel.getGroupPortSelects()).toHaveCount(1);
});
test('switching output port changes the visible leaf member', async ({ n8n }) => {
await n8n.canvas.logsPanel.open();
await n8n.canvas.clickExecuteWorkflowButton();
const groupRow = n8n.canvas.logsPanel.getGroupLogEntryByName(BRANCHED_GROUP_TITLE);
await groupRow.click();
const portSelect = n8n.canvas.logsPanel.getGroupPortSelects();
await expect(portSelect).toHaveCount(1);
// Open the output port selector and switch to the second leaf (d)
await portSelect.click();
const options = n8n.page.getByTestId('logs-group-port-option');
await expect(options).toHaveCount(2);
await options.nth(1).click();
// The output pane now shows node d's output data
await expect(n8n.canvas.logsPanel.outputPanel.get().getByText('d')).toBeVisible();
});
},
);

View File

@ -0,0 +1,87 @@
{
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [32, 144],
"id": "f1c5b6a8-1f5b-4a92-9bb2-3e6e8a8a0001",
"name": "When clicking ‘Execute workflow’"
},
{
"parameters": {
"category": "randomData",
"randomDataCount": 1
},
"type": "n8n-nodes-base.debugHelper",
"typeVersion": 1,
"position": [272, 144],
"id": "5809aa61-863c-4086-a5e2-deb824f7bc00",
"name": "DebugHelper"
},
{
"parameters": {},
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [560, 128],
"id": "5f47ab06-70a3-4cab-9ff7-3071da5809f2",
"name": "a"
},
{
"parameters": {},
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [768, 128],
"id": "563d1de9-efda-4b91-bf23-a85d55b4dc08",
"name": "b"
},
{
"parameters": {},
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [1024, 0],
"id": "26b484e0-461b-4f21-bb20-abbe9695e441",
"name": "c"
},
{
"parameters": {},
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [1024, 224],
"id": "6e1a8a52-482a-4860-8301-b7bf7a3c46fb",
"name": "d"
}
],
"connections": {
"When clicking ‘Execute workflow’": {
"main": [[{ "node": "DebugHelper", "type": "main", "index": 0 }]]
},
"DebugHelper": {
"main": [[{ "node": "a", "type": "main", "index": 0 }]]
},
"a": {
"main": [[{ "node": "b", "type": "main", "index": 0 }]]
},
"b": {
"main": [
[{ "node": "c", "type": "main", "index": 0 }, { "node": "d", "type": "main", "index": 0 }]
]
}
},
"nodeGroups": [
{
"id": "229926ec-f89d-44a8-b46c-502538dabead",
"name": "Branched group",
"nodeIds": [
"5f47ab06-70a3-4cab-9ff7-3071da5809f2",
"563d1de9-efda-4b91-bf23-a85d55b4dc08",
"26b484e0-461b-4f21-bb20-abbe9695e441",
"6e1a8a52-482a-4860-8301-b7bf7a3c46fb"
]
}
],
"pinData": {},
"meta": {
"instanceId": "canvas-node-groups-branched-fixture"
}
}