mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-19 16:55:02 +02:00
output selector
This commit is contained in:
parent
72e8b4c70f
commit
b075c38e4f
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user