feat(editor): Show input panel for mapping in embedded NDV (no-changelog) (#17227)

This commit is contained in:
Suguru Inoue
2025-07-15 12:10:53 +02:00
committed by GitHub
parent ec69bcc3fd
commit ded2e71d41
8 changed files with 185 additions and 79 deletions

View File

@@ -1,7 +1,6 @@
import { createTestNode, createTestWorkflow, createTestWorkflowObject } from '@/__tests__/mocks';
import { createComponentRenderer } from '@/__tests__/render';
import InputPanel, { type Props } from '@/components/InputPanel.vue';
import { STORES } from '@n8n/stores';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { createTestingPinia } from '@pinia/testing';
import { waitFor } from '@testing-library/vue';
@@ -50,7 +49,6 @@ const render = (props: Partial<Props> = {}, pinData?: INodeExecutionData[], runD
const pinia = createTestingPinia({
stubActions: false,
initialState: { [STORES.NDV]: { activeNodeName: props.currentNodeName ?? nodes[1].name } },
});
setActivePinia(pinia);
@@ -97,9 +95,12 @@ const render = (props: Partial<Props> = {}, pinData?: INodeExecutionData[], runD
props: {
pushRef: 'pushRef',
runIndex: 0,
currentNodeName: nodes[1].name,
currentNodeName: nodes[0].name,
activeNodeName: nodes[1].name,
workflow: workflowObject,
displayMode: 'schema',
focusedMappableInput: '',
isMappingOnboarded: false,
},
global: {
stubs: {
@@ -118,14 +119,14 @@ describe('InputPanel', () => {
});
it("opens mapping tab by default if the node hasn't run yet", async () => {
const { findByTestId } = render({ currentNodeName: 'Tool' });
const { findByTestId } = render({ activeNodeName: 'Tool' });
expect((await findByTestId('radio-button-mapping')).parentNode).toBeChecked();
expect((await findByTestId('radio-button-debugging')).parentNode).not.toBeChecked();
});
it('opens debugging tab by default if the node has already run', async () => {
const { findByTestId } = render({ currentNodeName: 'Tool' }, undefined, {
const { findByTestId } = render({ activeNodeName: 'Tool' }, undefined, {
Tool: [
{
startTime: 0,

View File

@@ -10,7 +10,6 @@ import {
} from '@/constants';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useNDVStore } from '@/stores/ndv.store';
import { waitingNodeTooltip } from '@/utils/executionUtils';
import uniqBy from 'lodash/uniqBy';
import { N8nIcon, N8nRadioButtons, N8nText, N8nTooltip } from '@n8n/design-system';
@@ -22,7 +21,6 @@ import {
NodeConnectionTypes,
NodeHelpers,
} from 'n8n-workflow';
import { storeToRefs } from 'pinia';
import { computed, ref, watch } from 'vue';
import InputNodeSelect from './InputNodeSelect.vue';
import NodeExecuteButton from './NodeExecuteButton.vue';
@@ -38,6 +36,7 @@ export type Props = {
runIndex: number;
workflow: Workflow;
pushRef: string;
activeNodeName: string;
currentNodeName?: string;
canLinkRuns?: boolean;
linkedRuns?: boolean;
@@ -45,6 +44,10 @@ export type Props = {
isProductionExecutionPreview?: boolean;
isPaneActive?: boolean;
displayMode: IRunDataDisplayMode;
compact?: boolean;
disableDisplayModeSelection?: boolean;
focusedMappableInput: string;
isMappingOnboarded: boolean;
};
const props = withDefaults(defineProps<Props>(), {
@@ -91,15 +94,10 @@ const inputModes = [
];
const nodeTypesStore = useNodeTypesStore();
const ndvStore = useNDVStore();
const workflowsStore = useWorkflowsStore();
const posthogStore = usePostHog();
const {
activeNode,
focusedMappableInput,
isMappingOnboarded: isUserOnboarded,
} = storeToRefs(ndvStore);
const activeNode = computed(() => workflowsStore.getNodeByName(props.activeNodeName));
const rootNode = computed(() => {
if (!activeNode.value) return null;
@@ -128,7 +126,7 @@ const showDraggableHint = computed(() => {
return false;
}
return !!focusedMappableInput.value && !isUserOnboarded.value;
return !!props.focusedMappableInput && !props.isMappingOnboarded;
});
const isActiveNodeConfig = computed(() => {
@@ -396,6 +394,8 @@ function handleChangeCollapsingColumn(columnName: string | null) {
data-test-id="ndv-input-panel"
:disable-ai-content="true"
:collapsing-table-column-name="collapsingColumnName"
:compact="compact"
:disable-display-mode-selection="disableDisplayModeSelection"
@activate-pane="activatePane"
@item-hover="onItemHover"
@link-run="onLinkRun"
@@ -408,9 +408,14 @@ function handleChangeCollapsingColumn(columnName: string | null) {
>
<template #header>
<div :class="[$style.titleSection, { [$style.titleSectionV2]: isNDVV2 }]">
<span :class="[$style.title, { [$style.titleV2]: isNDVV2 }]">{{
i18n.baseText('ndv.input')
}}</span>
<N8nText
:bold="true"
color="text-light"
:size="compact ? 'small' : 'medium'"
:class="[$style.title, { [$style.titleV2]: isNDVV2 }]"
>
{{ i18n.baseText('ndv.input') }}
</N8nText>
<N8nRadioButtons
v-if="isActiveNodeConfig && !readOnly"
data-test-id="input-panel-mode"
@@ -686,10 +691,7 @@ function handleChangeCollapsingColumn(columnName: string | null) {
.title {
text-transform: uppercase;
color: var(--color-text-light);
letter-spacing: 3px;
font-size: var(--font-size-s);
font-weight: var(--font-weight-bold);
}
.titleV2 {

View File

@@ -783,12 +783,15 @@ onBeforeUnmount(() => {
:can-link-runs="canLinkRuns"
:run-index="inputRun"
:linked-runs="linked"
:active-node-name="activeNode.name"
:current-node-name="inputNodeName"
:push-ref="pushRef"
:read-only="readOnly || hasForeignCredential"
:is-production-execution-preview="isProductionExecutionPreview"
:is-pane-active="isInputPaneActive"
:display-mode="inputPanelDisplayMode"
:is-mapping-onboarded="ndvStore.isMappingOnboarded"
:focused-mappable-input="ndvStore.focusedMappableInput"
@activate-pane="activateInputPane"
@link-run="onLinkRunToInput"
@unlink-run="() => onUnlinkRun('input')"

View File

@@ -759,6 +759,7 @@ onBeforeUnmount(() => {
:can-link-runs="canLinkRuns"
:run-index="inputRun"
:linked-runs="linked"
:active-node-name="activeNode.name"
:current-node-name="inputNodeName"
:push-ref="pushRef"
:read-only="readOnly || hasForeignCredential"
@@ -766,6 +767,8 @@ onBeforeUnmount(() => {
:is-pane-active="isInputPaneActive"
:display-mode="inputPanelDisplayMode"
:class="$style.input"
:is-mapping-onboarded="ndvStore.isMappingOnboarded"
:focused-mappable-input="ndvStore.focusedMappableInput"
@activate-pane="activateInputPane"
@link-run="onLinkRunToInput"
@unlink-run="() => onUnlinkRun('input')"

View File

@@ -1126,7 +1126,7 @@ function handleWheelEvent(event: WheelEvent) {
&::-webkit-scrollbar-thumb {
border-radius: var(--spacing-2xs);
background: var(--color-foreground-dark);
border: var(--spacing-5xs) solid white;
border: var(--spacing-5xs) solid var(--color-background-xlight);
}
}
}

View File

@@ -146,6 +146,7 @@ type Props = {
hidePagination?: boolean;
calloutMessage?: string;
disableRunIndexSelection?: boolean;
disableDisplayModeSelection?: boolean;
disableEdit?: boolean;
disablePin?: boolean;
compact?: boolean;
@@ -168,6 +169,7 @@ const props = withDefaults(defineProps<Props>(), {
hidePagination: false,
calloutMessage: undefined,
disableRunIndexSelection: false,
disableDisplayModeSelection: false,
disableEdit: false,
disablePin: false,
disableHoverHighlight: false,
@@ -1447,6 +1449,7 @@ defineExpose({ enterEditMode });
/>
<RunDataDisplayModeSelect
v-if="!disableDisplayModeSelection"
v-show="
hasPreviewSchema ||
(hasNodeRun &&

View File

@@ -22,9 +22,11 @@ exports[`InputPanel > should render 1`] = `
class="titleSection"
>
<span
class="title"
class="n8n-text text-light size-medium bold title title"
>
Input
</span>
<div
class="n8n-radio-buttons radioGroup"

View File

@@ -1,16 +1,18 @@
<script setup lang="ts">
import ExperimentalCanvasNodeSettings from './ExperimentalCanvasNodeSettings.vue';
import { onBeforeUnmount, ref, computed, provide } from 'vue';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useExperimentalNdvStore } from '../experimentalNdv.store';
import InputPanel from '@/components/InputPanel.vue';
import NodeTitle from '@/components/NodeTitle.vue';
import { N8nIcon, N8nIconButton } from '@n8n/design-system';
import { useVueFlow } from '@vue-flow/core';
import { watchOnce } from '@vueuse/core';
import { ExpressionLocalResolveContextSymbol } from '@/constants';
import { useEnvironmentsStore } from '@/stores/environments.ee.store';
import { useNDVStore } from '@/stores/ndv.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import type { ExpressionLocalResolveContext } from '@/types/expressions';
import { N8nIcon, N8nIconButton } from '@n8n/design-system';
import { useVueFlow } from '@vue-flow/core';
import { useActiveElement, watchOnce } from '@vueuse/core';
import { computed, onBeforeUnmount, provide, ref, useTemplateRef, watch } from 'vue';
import { useExperimentalNdvStore } from '../experimentalNdv.store';
import ExperimentalCanvasNodeSettings from './ExperimentalCanvasNodeSettings.vue';
const { nodeId, isReadOnly, isConfigurable } = defineProps<{
nodeId: string;
@@ -18,6 +20,7 @@ const { nodeId, isReadOnly, isConfigurable } = defineProps<{
isConfigurable: boolean;
}>();
const ndvStore = useNDVStore();
const experimentalNdvStore = useExperimentalNdvStore();
const isExpanded = computed(() => !experimentalNdvStore.collapsedNodes[nodeId]);
const nodeTypesStore = useNodeTypesStore();
@@ -58,63 +61,81 @@ const isVisible = computed(() =>
),
);
const isOnceVisible = ref(isVisible.value);
const shouldShowInputPanel = ref(false);
provide(
ExpressionLocalResolveContextSymbol,
computed<ExpressionLocalResolveContext | undefined>(() => {
if (!node.value) {
return undefined;
const containerRef = useTemplateRef('container');
const inputPanelContainerRef = useTemplateRef('inputPanelContainer');
const activeElement = useActiveElement();
const expressionResolveCtx = computed<ExpressionLocalResolveContext | undefined>(() => {
if (!node.value) {
return undefined;
}
const runIndex = 0; // not changeable for now
const execution = workflowsStore.workflowExecutionData;
const nodeName = node.value.name;
function findInputNode(): ExpressionLocalResolveContext['inputNode'] {
const taskData = (execution?.data?.resultData.runData[nodeName] ?? [])[runIndex];
const source = taskData?.source[0];
if (source) {
return {
name: source.previousNode,
branchIndex: source.previousNodeOutput ?? 0,
runIndex: source.previousNodeRun ?? 0,
};
}
const workflow = workflowsStore.getCurrentWorkflow();
const runIndex = 0; // not changeable for now
const execution = workflowsStore.workflowExecutionData;
const nodeName = node.value.name;
const inputs = workflow.value.getParentNodesByDepth(nodeName, 1);
function findInputNode(): ExpressionLocalResolveContext['inputNode'] {
const taskData = (execution?.data?.resultData.runData[nodeName] ?? [])[runIndex];
const source = taskData?.source[0];
if (source) {
return {
name: source.previousNode,
branchIndex: source.previousNodeOutput ?? 0,
runIndex: source.previousNodeRun ?? 0,
};
}
const inputs = workflow.getParentNodesByDepth(nodeName, 1);
if (inputs.length > 0) {
return {
name: inputs[0].name,
branchIndex: inputs[0].indicies[0] ?? 0,
runIndex: 0,
};
}
return undefined;
if (inputs.length > 0) {
return {
name: inputs[0].name,
branchIndex: inputs[0].indicies[0] ?? 0,
runIndex: 0,
};
}
return {
localResolve: true,
envVars: useEnvironmentsStore().variablesAsObject,
workflow,
execution,
nodeName,
additionalKeys: {},
inputNode: findInputNode(),
};
}),
);
return undefined;
}
return {
localResolve: true,
envVars: useEnvironmentsStore().variablesAsObject,
workflow: workflow.value,
execution,
nodeName,
additionalKeys: {},
inputNode: findInputNode(),
};
});
const workflow = computed(() => workflowsStore.getCurrentWorkflow());
function handleToggleExpand() {
experimentalNdvStore.setNodeExpanded(nodeId);
}
provide(ExpressionLocalResolveContextSymbol, expressionResolveCtx);
watchOnce(isVisible, (visible) => {
isOnceVisible.value = isOnceVisible.value || visible;
});
function handleToggleExpand() {
experimentalNdvStore.setNodeExpanded(nodeId);
}
watch([activeElement, vf.getSelectedNodes], ([active, selected]) => {
if (active && containerRef.value?.contains(active)) {
// TODO: find a way to implement this without depending on test ID
shouldShowInputPanel.value =
!!active.closest('[data-test-id=inline-expression-editor-input]') ||
!!inputPanelContainerRef.value?.contains(active);
}
if (selected.every((sel) => sel.id !== node.value?.id)) {
shouldShowInputPanel.value = false;
}
});
</script>
<template>
@@ -129,6 +150,7 @@ function handleToggleExpand() {
<template v-if="isOnceVisible">
<ExperimentalCanvasNodeSettings
v-if="isExpanded"
tabindex="-1"
:node-id="nodeId"
:class="$style.settingsView"
:no-wheel="
@@ -158,6 +180,34 @@ function handleToggleExpand() {
/>
<N8nIcon icon="maximize-2" size="large" />
</div>
<Transition name="input">
<div
v-if="shouldShowInputPanel && node"
ref="inputPanelContainer"
:class="$style.inputPanelContainer"
:tabindex="-1"
>
<InputPanel
:class="$style.inputPanel"
:workflow="workflow"
:run-index="0"
compact
push-ref=""
display-mode="schema"
disable-display-mode-selection
:active-node-name="node.name"
:current-node-name="expressionResolveCtx?.inputNode?.name"
:is-mapping-onboarded="ndvStore.isMappingOnboarded"
:focused-mappable-input="ndvStore.focusedMappableInput"
>
<template #header>
<N8nText :class="$style.inputPanelTitle" :bold="true" color="text-light" size="small">
Input
</N8nText>
</template>
</InputPanel>
</div>
</Transition>
</template>
</div>
</template>
@@ -167,7 +217,6 @@ function handleToggleExpand() {
position: relative;
align-items: flex-start;
justify-content: stretch;
overflow: hidden;
border-width: 1px !important;
border-radius: var(--border-radius-base) !important;
width: calc(var(--canvas-node--width) * var(--node-width-scaler));
@@ -175,9 +224,12 @@ function handleToggleExpand() {
&.expanded {
cursor: default;
height: auto;
max-height: min(calc(var(--canvas-node--height) * 2), 300px);
min-height: var(--spacing-3xl);
}
&.collapsed {
height: calc(16px * 4);
overflow: hidden;
height: var(--spacing-3xl);
}
}
@@ -193,9 +245,10 @@ function handleToggleExpand() {
:root .settingsView {
z-index: 1000;
width: 100%;
border-radius: var(--border-radius-base);
height: auto;
max-height: min(calc(var(--canvas-node--height) * 2), 300px);
max-height: calc(min(calc(var(--canvas-node--height) * 2), 300px) - var(--border-width-base) * 2);
min-height: var(--spacing-3xl); // should be multiple of GRID_SIZE
}
@@ -221,4 +274,43 @@ function handleToggleExpand() {
zoom: var(--zoom);
}
}
.inputPanelContainer {
position: absolute;
right: 100%;
top: 0;
padding-right: var(--spacing-4xs);
margin-top: calc(-1 * var(--border-width-base));
width: 180px;
z-index: 2000;
max-height: 80vh;
}
.inputPanel {
border: var(--border-base);
border-width: 1px;
background-color: var(--color-background-light);
border-radius: var(--border-radius-large);
zoom: var(--zoom);
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.05);
padding: var(--spacing-2xs);
height: 100%;
}
.inputPanelTitle {
text-transform: uppercase;
letter-spacing: 3px;
}
</style>
<style lang="scss" scoped>
.input-enter-active,
.input-leave-active {
transition: opacity 0.3s ease;
}
.input-enter-from,
.input-leave-to {
opacity: 0;
}
</style>