fix(editor): Data in input/output panel incorrectly mapped (#14878)

This commit is contained in:
Suguru Inoue
2025-04-28 14:30:44 +02:00
committed by GitHub
parent 2212aeba30
commit 0a2b740063
16 changed files with 358 additions and 68 deletions

View File

@@ -0,0 +1,41 @@
/**
* Accessors
*/
export function getLogEntryAtRow(rowIndex: number) {
return cy.getByTestId('logs-overview-body').find('[role=treeitem]').eq(rowIndex);
}
export function getInputTableRows() {
return cy.getByTestId('log-details-input').find('table tr');
}
export function getInputTbodyCell(row: number, col: number) {
return cy.getByTestId('log-details-input').find('table tr').eq(row).find('td').eq(col);
}
/**
* Actions
*/
export function openLogsPanel() {
cy.getByTestId('logs-overview-header').click();
}
export function clickLogEntryAtRow(rowIndex: number) {
getLogEntryAtRow(rowIndex).click();
}
export function toggleInputPanel() {
cy.getByTestId('log-details-header').contains('Input').click();
}
export function clickOpenNdvAtRow(rowIndex: number) {
getLogEntryAtRow(rowIndex).realHover();
getLogEntryAtRow(rowIndex).find('[aria-label="Open..."]').click();
}
export function setInputDisplayMode(mode: 'table') {
cy.getByTestId('log-details-input').realHover();
cy.getByTestId('log-details-input').findChildByTestId(`radio-button-${mode}`).click();
}

View File

@@ -32,6 +32,10 @@ export function getInputPanel() {
return cy.getByTestId('ndv-input-panel');
}
export function getInputSelect() {
return cy.getByTestId('ndv-input-select').find('input');
}
export function getMainPanel() {
return cy.getByTestId('node-parameters');
}
@@ -53,11 +57,19 @@ export function getResourceLocatorInput(paramName: string) {
}
export function getInputPanelDataContainer() {
return getInputPanel().getByTestId('ndv-data-container');
return getInputPanel().findChildByTestId('ndv-data-container');
}
export function getInputTableRows() {
return getInputPanelDataContainer().find('table tr');
}
export function getInputTbodyCell(row: number, col: number) {
return getInputTableRows().eq(row).find('td').eq(col);
}
export function getOutputPanelDataContainer() {
return getOutputPanel().getByTestId('ndv-data-container');
return getOutputPanel().findChildByTestId('ndv-data-container');
}
export function getOutputTableRows() {
@@ -278,7 +290,7 @@ export function assertInlineExpressionValid() {
}
export function hoverInputItemByText(text: string) {
return getInputPanelDataContainer().contains(text).trigger('mouseover', { force: true });
return getInputPanelDataContainer().contains(text).realHover();
}
export function verifyInputHoverState(expectedText: string) {
@@ -296,5 +308,5 @@ export function verifyOutputHoverState(expectedText: string) {
}
export function resetHoverState() {
getBackToCanvasButton().trigger('mouseover');
getBackToCanvasButton().realHover();
}

View File

@@ -25,10 +25,12 @@ export type EndpointType =
* Getters
*/
export function executeWorkflowAndWait() {
export function executeWorkflowAndWait(waitForSuccessBannerToDisappear = true) {
cy.get('[data-test-id="execute-workflow-button"]').click();
cy.contains('Workflow executed successfully', { timeout: 4000 }).should('be.visible');
cy.contains('Workflow executed successfully', { timeout: 10000 }).should('not.exist');
if (waitForSuccessBannerToDisappear) {
cy.contains('Workflow executed successfully', { timeout: 10000 }).should('not.exist');
}
}
export function getCanvas() {

View File

@@ -87,23 +87,18 @@ describe('NDV', () => {
ndv.actions.selectInputNode('Set1');
ndvComposables.verifyInputHoverState('1000');
ndv.actions.dragMainPanelToRight();
ndvComposables.resetHoverState();
ndvComposables.hoverInputItemByText('1000');
ndvComposables.verifyOutputHoverState('1000');
// BUG(ADO-3469): Expression preview is not updated when input node is changed it uses the old value
// ndv.getters.parameterExpressionPreview('value').should('include.text', '1000');
ndv.getters.parameterExpressionPreview('value').should('include.text', '1000');
ndv.actions.selectInputNode('Sort');
ndv.actions.dragMainPanelToLeft();
ndv.actions.changeOutputRunSelector('1 of 2 (6 items)');
ndvComposables.resetHoverState();
ndvComposables.verifyInputHoverState('1111');
ndv.actions.dragMainPanelToRight();
ndvComposables.resetHoverState();
ndvComposables.hoverInputItemByText('1111');
ndvComposables.verifyOutputHoverState('1111');
ndv.getters.parameterExpressionPreview('value').should('include.text', '1111');

View File

@@ -1,4 +1,57 @@
import * as logs from '../composables/logs';
import * as ndv from '../composables/ndv';
import * as workflow from '../composables/workflow';
import Workflow from '../fixtures/Workflow_if.json';
describe('Logs', () => {
// TODO: the test can be written without AI nodes once https://linear.app/n8n/issue/SUG-39 is implemented
it('should open NDV with the run index that corresponds to clicked log entry');
beforeEach(() => {
cy.overrideSettings({ logsView: { enabled: true } });
});
it('should show input and output data of correct run index and branch', () => {
workflow.navigateToNewWorkflowPage();
workflow.pasteWorkflow(Workflow);
workflow.clickZoomToFit();
logs.openLogsPanel();
workflow.executeWorkflowAndWait(false);
logs.clickLogEntryAtRow(2); // Run #1 of 'Edit Fields' node; input is 'Code' node
logs.toggleInputPanel();
logs.setInputDisplayMode('table');
logs.getInputTableRows().should('have.length', 11);
logs.getInputTbodyCell(1, 0).should('contain.text', '0');
logs.getInputTbodyCell(10, 0).should('contain.text', '9');
logs.clickOpenNdvAtRow(2);
ndv.getInputSelect().should('have.value', 'Code ');
ndv.getInputTableRows().should('have.length', 11);
ndv.getInputTbodyCell(1, 0).should('contain.text', '0');
ndv.getInputTbodyCell(10, 0).should('contain.text', '9');
ndv.getOutputRunSelectorInput().should('have.value', '1 of 3 (10 items)');
ndv.clickGetBackToCanvas();
logs.clickLogEntryAtRow(4); // Run #2 of 'Edit Fields' node; input is false branch of 'If' node
logs.getInputTableRows().should('have.length', 6);
logs.getInputTbodyCell(1, 0).should('contain.text', '5');
logs.getInputTbodyCell(5, 0).should('contain.text', '9');
logs.clickOpenNdvAtRow(4);
ndv.getInputSelect().should('have.value', 'If ');
ndv.getInputTableRows().should('have.length', 6);
ndv.getInputTbodyCell(1, 0).should('contain.text', '5');
ndv.getInputTbodyCell(5, 0).should('contain.text', '9');
ndv.getOutputRunSelectorInput().should('have.value', '2 of 3 (5 items)');
ndv.clickGetBackToCanvas();
logs.clickLogEntryAtRow(5); // Run #3 of 'Edit Fields' node; input is true branch of 'If' node
logs.getInputTableRows().should('have.length', 6);
logs.getInputTbodyCell(1, 0).should('contain.text', '0');
logs.getInputTbodyCell(5, 0).should('contain.text', '4');
logs.clickOpenNdvAtRow(5);
ndv.getInputSelect().should('have.value', 'If ');
ndv.getInputTableRows().should('have.length', 6);
ndv.getInputTbodyCell(1, 0).should('contain.text', '0');
ndv.getInputTbodyCell(5, 0).should('contain.text', '4');
ndv.getOutputRunSelectorInput().should('have.value', '3 of 3 (5 items)');
});
});

View File

@@ -0,0 +1,127 @@
{
"nodes": [
{
"parameters": {
"rule": {
"interval": [{}]
}
},
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [-900, 60],
"id": "e6b8fc7c-442e-4283-a0cd-604dc7c9e816",
"name": "Schedule Trigger"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "553f50d9-5023-433f-8f62-eebc9c9e2269",
"leftValue": "={{ $json.data }}",
"rightValue": 5,
"operator": {
"type": "number",
"operation": "lt"
}
}
],
"combinator": "and"
},
"options": {}
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [-460, 135],
"id": "f5c96b5b-9e22-4348-a258-fdb0417f5ff5",
"name": "If"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "71475f04-571e-4e99-bdf8-adff367533fb",
"name": "data",
"value": "={{ $json.data }}",
"type": "number"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [-240, 60],
"id": "2a6fc40d-5d8c-4c35-bf53-ee910267619f",
"name": "Edit Fields"
},
{
"parameters": {
"jsCode": "return Array.from({length:10}).map((_,i)=>({data:i}))"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [-680, 60],
"id": "12ae07e7-be34-43b6-806b-4c24be169ee6",
"name": "Code"
}
],
"connections": {
"Schedule Trigger": {
"main": [
[
{
"node": "Code",
"type": "main",
"index": 0
}
]
]
},
"If": {
"main": [
[
{
"node": "Edit Fields",
"type": "main",
"index": 0
}
],
[
{
"node": "Edit Fields",
"type": "main",
"index": 0
}
]
]
},
"Code": {
"main": [
[
{
"node": "If",
"type": "main",
"index": 0
},
{
"node": "Edit Fields",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {},
"meta": {
"instanceId": "db1f26b45a71ad9a8df79dde8d35bf1be13616c3b23eb55be8ecf642dd31500c"
}
}

View File

@@ -4,15 +4,15 @@ interface RadioButtonProps {
value: string;
active?: boolean;
disabled?: boolean;
size?: 'small' | 'medium';
noPadding?: boolean;
size?: 'small' | 'small-medium' | 'medium';
square?: boolean;
}
withDefaults(defineProps<RadioButtonProps>(), {
active: false,
disabled: false,
size: 'medium',
noPadding: false,
square: false,
});
defineSlots<{ default?: {} }>();
@@ -26,7 +26,7 @@ defineSlots<{ default?: {} }>();
'n8n-radio-button': true,
[$style.container]: true,
[$style.hoverable]: !disabled,
[$style.noPadding]: noPadding,
[$style.square]: square,
}"
:aria-checked="active"
>
@@ -76,8 +76,10 @@ defineSlots<{ default?: {} }>();
cursor: pointer;
user-select: none;
.noPadding & {
padding-inline: 0;
.square & {
display: flex;
align-items: center;
justify-content: center;
}
}
@@ -89,12 +91,33 @@ defineSlots<{ default?: {} }>();
height: 26px;
font-size: var(--font-size-2xs);
padding: 0 var(--spacing-xs);
.square & {
width: 26px;
padding: 0;
}
}
.small-medium {
height: 22px;
font-size: var(--font-size-3xs);
padding: 0 var(--spacing-2xs);
.square & {
width: 22px;
padding: 0;
}
}
.small {
font-size: var(--font-size-3xs);
height: 15px;
padding: 0 var(--spacing-4xs);
.square & {
width: 15px;
padding: 0;
}
}
.active {

View File

@@ -11,14 +11,16 @@ interface RadioButtonsProps {
modelValue?: Value;
options?: RadioOption[];
/** @default medium */
size?: 'small' | 'medium';
size?: 'small' | 'small-medium' | 'medium';
disabled?: boolean;
squareButtons?: boolean;
}
const props = withDefaults(defineProps<RadioButtonsProps>(), {
active: false,
disabled: false,
size: 'medium',
squareButtons: false,
});
const emit = defineEmits<{
@@ -50,7 +52,7 @@ const onClick = (
:active="modelValue === option.value"
:size="size"
:disabled="disabled || option.disabled"
:no-padding="!!slots.option"
:square="squareButtons"
@click.prevent.stop="onClick(option, $event)"
>
<slot name="option" v-bind="option" />

View File

@@ -17,10 +17,12 @@ interface TabOptions {
interface TabsProps {
modelValue?: Value;
options?: TabOptions[];
size?: 'small' | 'medium';
}
withDefaults(defineProps<TabsProps>(), {
options: () => [],
size: 'medium',
});
const scrollPosition = ref(0);
@@ -74,7 +76,7 @@ const scrollRight = () => scroll(50);
</script>
<template>
<div :class="['n8n-tabs', $style.container]">
<div :class="['n8n-tabs', $style.container, size === 'small' ? $style.small : '']">
<div v-if="scrollPosition > 0" :class="$style.back" @click="scrollLeft">
<N8nIcon icon="chevron-left" size="small" />
</div>
@@ -172,6 +174,10 @@ const scrollRight = () => scroll(50);
span + span {
margin-left: var(--spacing-4xs);
}
.small & {
font-size: var(--font-size-2xs);
}
}
.activeTab {

View File

@@ -34,6 +34,7 @@ describe('LogDetailsPanel', () => {
executionStatus: 'success',
executionTime: 10,
data: { main: [[{ json: { response: 'Hello!' } }]] },
source: [{ previousNode: 'Chat Trigger' }],
});
function render(props: Partial<InstanceType<typeof LogDetailsPanel>['$props']>) {
@@ -116,7 +117,7 @@ describe('LogDetailsPanel', () => {
it('should toggle input and output panel when the button is clicked', async () => {
const rendered = render({
isOpen: true,
logEntry: createTestLogEntry({ node: aiNode, runIndex: 0 }),
logEntry: createTestLogEntry({ node: aiNode, runIndex: 0, runData: aiNodeRunData }),
});
const header = within(rendered.getByTestId('log-details-header'));
@@ -140,7 +141,7 @@ describe('LogDetailsPanel', () => {
const rendered = render({
isOpen: true,
logEntry: createTestLogEntry({ node: aiNode, runIndex: 0 }),
logEntry: createTestLogEntry({ node: aiNode, runIndex: 0, runData: aiNodeRunData }),
});
await fireEvent.mouseDown(rendered.getByTestId('resize-handle'));
@@ -159,7 +160,7 @@ describe('LogDetailsPanel', () => {
const rendered = render({
isOpen: true,
logEntry: createTestLogEntry({ node: aiNode, runIndex: 0 }),
logEntry: createTestLogEntry({ node: aiNode, runIndex: 0, runData: aiNodeRunData }),
});
await fireEvent.mouseDown(rendered.getByTestId('resize-handle'));

View File

@@ -19,6 +19,7 @@ import {
type LogEntry,
} from '@/components/RunDataAi/utils';
import { useVirtualList } from '@vueuse/core';
import { ndvEventBus } from '@/event-bus';
const { isOpen, isReadOnly, selected, isCompact, execution, latestNodeInfo, scrollToSelection } =
defineProps<{
@@ -88,7 +89,14 @@ function handleToggleExpanded(treeNode: LogEntry) {
async function handleOpenNdv(treeNode: LogEntry) {
ndvStore.setActiveNodeName(treeNode.node.name);
await nextTick(() => ndvStore.setOutputRunIndex(treeNode.runIndex));
await nextTick(() => {
const source = treeNode.runData.source[0];
const inputBranch = source?.previousNodeOutput ?? 0;
ndvEventBus.emit('updateInputNodeName', source?.previousNode);
ndvEventBus.emit('setInputBranchIndex', inputBranch);
ndvStore.setOutputRunIndex(treeNode.runIndex);
});
}
async function handleTriggerPartialExecution(treeNode: LogEntry) {
@@ -190,7 +198,7 @@ watch(
</div>
</div>
<N8nRadioButtons
size="small"
size="small-medium"
:class="$style.switchViewButtons"
:model-value="selected ? 'details' : 'overview'"
:options="switchViewOptions"
@@ -257,7 +265,7 @@ watch(
z-index: 10; /* higher than log entry rows background */
right: 0;
top: 0;
margin: var(--spacing-2xs);
margin: var(--spacing-4xs) var(--spacing-2xs);
visibility: hidden;
opacity: 0;
transition: opacity 0.3s $ease-out-expo;

View File

@@ -5,7 +5,6 @@ import { useI18n } from '@/composables/useI18n';
import { type IExecutionResponse, type NodePanelType } from '@/Interface';
import { useNDVStore } from '@/stores/ndv.store';
import { N8nLink, N8nText } from '@n8n/design-system';
import { uniq } from 'lodash-es';
import { type Workflow } from 'n8n-workflow';
import { computed } from 'vue';
import { I18nT } from 'vue-i18n';
@@ -20,17 +19,27 @@ const { title, logEntry, paneType, workflow, execution } = defineProps<{
const locale = useI18n();
const ndvStore = useNDVStore();
const parentNodeNames = computed(() =>
uniq(workflow.getParentNodesByDepth(logEntry.node.name, 1)).map((c) => c.name),
);
const node = computed(() => {
const isMultipleInput = computed(() => paneType === 'input' && logEntry.runData.source.length > 1);
const runDataProps = computed<
Pick<InstanceType<typeof RunData>['$props'], 'node' | 'runIndex' | 'overrideOutputs'> | undefined
>(() => {
if (logEntry.depth > 0 || paneType === 'output') {
return logEntry.node;
return { node: logEntry.node, runIndex: logEntry.runIndex };
}
return parentNodeNames.value.length > 0 ? workflow.getNode(parentNodeNames.value[0]) : undefined;
const source = logEntry.runData.source[0];
const node = source && workflow.getNode(source.previousNode);
if (!source || !node) {
return undefined;
}
return {
node,
runIndex: source.previousNodeRun ?? 0,
overrideOutputs: [source.previousNodeOutput ?? 0],
};
});
const isMultipleInput = computed(() => paneType === 'input' && parentNodeNames.value.length > 1);
function handleClickOpenNdv() {
ndvStore.setActiveNodeName(logEntry.node.name);
@@ -39,11 +48,10 @@ function handleClickOpenNdv() {
<template>
<RunData
v-if="node"
:node="node"
v-if="runDataProps"
v-bind="runDataProps"
:workflow="workflow"
:workflow-execution="execution"
:run-index="logEntry.runIndex"
:too-much-data-title="locale.baseText('ndv.output.tooMuchData.title')"
:no-data-in-branch-message="locale.baseText('ndv.output.noOutputDataInBranch')"
:executing-message="locale.baseText('ndv.output.executing')"

View File

@@ -22,7 +22,7 @@ import {
} from '@/constants';
import { useWorkflowActivate } from '@/composables/useWorkflowActivate';
import type { DataPinningDiscoveryEvent } from '@/event-bus';
import { dataPinningEventBus } from '@/event-bus';
import { dataPinningEventBus, ndvEventBus } from '@/event-bus';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useNDVStore } from '@/stores/ndv.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
@@ -80,8 +80,8 @@ const settingsEventBus = createEventBus();
const redrawRequired = ref(false);
const runInputIndex = ref(-1);
const runOutputIndex = computed(() => ndvStore.output.run ?? -1);
const isLinkingEnabled = ref(true);
const selectedInput = ref<string | undefined>();
const isLinkingEnabled = ref(true);
const triggerWaitingWarningEnabled = ref(false);
const isDragging = ref(false);
const mainPanelPosition = ref(0);
@@ -615,6 +615,10 @@ const unregisterKeyboardListener = () => {
document.removeEventListener('keydown', onKeyDown, true);
};
const setSelectedInput = (value: string | undefined) => {
selectedInput.value = value;
};
//watchers
watch(
@@ -702,10 +706,12 @@ watch(inputRun, (inputRun) => {
onMounted(() => {
dataPinningEventBus.on('data-pinning-discovery', setIsTooltipVisible);
ndvEventBus.on('updateInputNodeName', setSelectedInput);
});
onBeforeUnmount(() => {
dataPinningEventBus.off('data-pinning-discovery', setIsTooltipVisible);
ndvEventBus.off('updateInputNodeName', setSelectedInput);
unregisterKeyboardListener();
});
</script>

View File

@@ -60,7 +60,7 @@ import type { PinDataSource, UnpinDataSource } from '@/composables/usePinnedData
import { usePinnedData } from '@/composables/usePinnedData';
import { useTelemetry } from '@/composables/useTelemetry';
import { useToast } from '@/composables/useToast';
import { dataPinningEventBus } from '@/event-bus';
import { dataPinningEventBus, ndvEventBus } from '@/event-bus';
import { useNDVStore } from '@/stores/ndv.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useRootStore } from '@/stores/root.store';
@@ -617,6 +617,12 @@ const itemsCountProps = computed<InstanceType<typeof RunDataItemCount>['$props']
subExecutionsCount: activeTaskMetadata.value?.subExecutionsCount,
}));
function setInputBranchIndex(value: number) {
if (props.paneType === 'input') {
outputIndex.value = value;
}
}
watch(node, (newNode, prevNode) => {
if (newNode?.id === prevNode?.id) return;
init();
@@ -674,6 +680,8 @@ watch(search, (newSearch) => {
onMounted(() => {
init();
ndvEventBus.on('setInputBranchIndex', setInputBranchIndex);
if (!isPaneTypeInput.value) {
showPinDataDiscoveryTooltip(jsonData.value);
}
@@ -710,6 +718,7 @@ onMounted(() => {
onBeforeUnmount(() => {
hidePinDataDiscoveryTooltip();
ndvEventBus.off('setInputBranchIndex', setInputBranchIndex);
});
function getResolvedNodeOutputs() {
@@ -1551,6 +1560,7 @@ defineExpose({ enterEditMode });
<div :class="$style.tabs">
<N8nTabs
size="small"
:model-value="currentOutputIndex"
:options="branches"
@update:model-value="onBranchChange"
@@ -2049,6 +2059,13 @@ defineExpose({ enterEditMode });
padding-left: var(--spacing-s);
padding-right: var(--spacing-s);
padding-bottom: var(--spacing-s);
.compact & {
padding-left: var(--spacing-2xs);
padding-right: var(--spacing-2xs);
padding-bottom: var(--spacing-2xs);
font-size: var(--font-size-2xs);
}
}
.tabs {

View File

@@ -39,32 +39,17 @@ const options = computed(() => {
:model-value="value"
:options="options"
data-test-id="ndv-run-data-display-mode"
:size="compact ? 'small' : 'medium'"
:size="compact ? 'small-medium' : 'medium'"
:square-buttons="compact"
@update:model-value="(selected) => emit('change', selected)"
>
<template v-if="compact" #option="option">
<N8nIcon v-if="option.value === 'table'" icon="table" size="small" :class="$style.icon" />
<N8nIcon v-else-if="option.value === 'json'" icon="json" size="small" :class="$style.icon" />
<N8nIcon
v-else-if="option.value === 'binary'"
icon="binary"
size="small"
:class="$style.icon"
/>
<N8nIcon
v-else-if="option.value === 'schema'"
icon="schema"
size="small"
:class="$style.icon"
/>
<N8nIcon v-else-if="option.value === 'html'" icon="html" size="small" :class="$style.icon" />
<N8nIcon v-if="option.value === 'table'" icon="table" size="small" />
<N8nIcon v-else-if="option.value === 'json'" icon="json" size="small" />
<N8nIcon v-else-if="option.value === 'binary'" icon="binary" size="small" />
<N8nIcon v-else-if="option.value === 'schema'" icon="schema" size="small" />
<N8nIcon v-else-if="option.value === 'html'" icon="html" size="small" />
<span v-else>{{ option.label }}</span>
</template>
</N8nRadioButtons>
</template>
<style lang="scss" module>
.icon {
padding-inline: var(--spacing-4xs);
}
</style>

View File

@@ -16,6 +16,10 @@ export interface NdvEventBusEvents {
setPositionByName: Position;
updateParameterValue: IUpdateInformation;
updateInputNodeName: string | undefined;
setInputBranchIndex: number;
}
export const ndvEventBus = createEventBus<NdvEventBusEvents>();