feat(editor): Overhaul input selector in NDV (#9520)

This commit is contained in:
Elias Meire 2024-05-31 18:04:57 +02:00 committed by GitHub
parent 2e9bd6739b
commit c0ec990f4c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 428 additions and 216 deletions

View file

@ -63,7 +63,9 @@ export class NDV extends BasePage {
httpRequestNotice: () => cy.getByTestId('node-parameters-http-notice'), httpRequestNotice: () => cy.getByTestId('node-parameters-http-notice'),
nthParam: (n: number) => cy.getByTestId('node-parameters').find('.parameter-item').eq(n), nthParam: (n: number) => cy.getByTestId('node-parameters').find('.parameter-item').eq(n),
inputRunSelector: () => this.getters.inputPanel().findChildByTestId('run-selector'), inputRunSelector: () => this.getters.inputPanel().findChildByTestId('run-selector'),
inputLinkRun: () => this.getters.inputPanel().findChildByTestId('link-run'),
outputRunSelector: () => this.getters.outputPanel().findChildByTestId('run-selector'), outputRunSelector: () => this.getters.outputPanel().findChildByTestId('run-selector'),
outputLinkRun: () => this.getters.outputPanel().findChildByTestId('link-run'),
outputHoveringItem: () => this.getters.outputPanel().findChildByTestId('hovering-item'), outputHoveringItem: () => this.getters.outputPanel().findChildByTestId('hovering-item'),
inputHoveringItem: () => this.getters.inputPanel().findChildByTestId('hovering-item'), inputHoveringItem: () => this.getters.inputPanel().findChildByTestId('hovering-item'),
outputBranches: () => this.getters.outputPanel().findChildByTestId('branches'), outputBranches: () => this.getters.outputPanel().findChildByTestId('branches'),
@ -228,10 +230,10 @@ export class NDV extends BasePage {
getVisibleSelect().find('.el-select-dropdown__item').contains(runName).click(); getVisibleSelect().find('.el-select-dropdown__item').contains(runName).click();
}, },
toggleOutputRunLinking: () => { toggleOutputRunLinking: () => {
this.getters.outputRunSelector().find('button').click(); this.getters.outputLinkRun().click();
}, },
toggleInputRunLinking: () => { toggleInputRunLinking: () => {
this.getters.inputRunSelector().find('button').click(); this.getters.inputLinkRun().click();
}, },
switchOutputBranch: (name: string) => { switchOutputBranch: (name: string) => {
this.getters.outputBranches().get('span').contains(name).click(); this.getters.outputBranches().get('span').contains(name).click();

View file

@ -0,0 +1,193 @@
<script lang="ts" setup>
import { useI18n } from '@/composables/useI18n';
import { useNDVStore } from '@/stores/ndv.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { isPresent } from '@/utils/typesUtils';
import type { IConnectedNode, Workflow } from 'n8n-workflow';
import { computed } from 'vue';
import NodeIcon from './NodeIcon.vue';
type Props = {
nodes: IConnectedNode[];
workflow: Workflow;
modelValue: string | null;
};
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'update:model-value', value: string): void;
}>();
const i18n = useI18n();
const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore();
const ndvStore = useNDVStore();
const selectedInputNode = computed(() => workflowsStore.getNodeByName(props.modelValue ?? ''));
const selectedInputNodeType = computed(() => {
const node = selectedInputNode.value;
if (!node) return null;
return nodeTypesStore.getNodeType(node.type, node.typeVersion);
});
const inputNodes = computed(() =>
props.nodes
.map((node) => {
const fullNode = workflowsStore.getNodeByName(node.name);
if (!fullNode) return null;
return {
node: fullNode,
type: nodeTypesStore.getNodeType(fullNode.type, fullNode.typeVersion),
depth: node.depth,
};
})
.filter(isPresent),
);
const activeNode = computed(() => ndvStore.activeNode);
const activeNodeType = computed(() => {
const node = activeNode.value;
if (!node) return null;
return nodeTypesStore.getNodeType(node.type, node.typeVersion);
});
const isMultiInputNode = computed(() => {
const nodeType = activeNodeType.value;
return nodeType !== null && nodeType.inputs.length > 1;
});
function getMultipleNodesText(nodeName: string): string {
if (
!nodeName ||
!isMultiInputNode.value ||
!activeNode.value ||
!activeNodeType.value?.inputNames
)
return '';
const activeNodeConnections =
props.workflow.connectionsByDestinationNode[activeNode.value.name].main || [];
// Collect indexes of connected nodes
const connectedInputIndexes = activeNodeConnections.reduce((acc: number[], node, index) => {
if (node[0] && node[0].node === nodeName) return [...acc, index];
return acc;
}, []);
// Match connected input indexes to their names specified by active node
const connectedInputs = connectedInputIndexes.map(
(inputIndex) => activeNodeType.value?.inputNames?.[inputIndex],
);
if (connectedInputs.length === 0) return '';
return `(${connectedInputs.join(' & ')})`;
}
function title(nodeName: string) {
const truncated = nodeName.substring(0, 30);
if (truncated.length < nodeName.length) {
return `${truncated}...`;
}
return truncated;
}
function subtitle(nodeName: string, depth: number) {
const multipleNodesText = getMultipleNodesText(nodeName);
if (multipleNodesText) return multipleNodesText;
return i18n.baseText('ndv.input.nodeDistance', { adjustToNumber: depth });
}
function onInputNodeChange(value: string) {
emit('update:model-value', value);
}
</script>
<template>
<n8n-select
:model-value="modelValue"
:no-data-text="i18n.baseText('ndv.input.noNodesFound')"
:placeholder="i18n.baseText('ndv.input.parentNodes')"
:class="$style.select"
teleported
size="small"
filterable
data-test-id="ndv-input-select"
@update:model-value="onInputNodeChange"
>
<template #prefix>
<NodeIcon
:disabled="selectedInputNode?.disabled"
:node-type="selectedInputNodeType"
:size="14"
:shrink="false"
/>
</template>
<n8n-option
v-for="{ node, type, depth } of inputNodes"
:key="node.name"
:value="node.name"
:class="[$style.node, { [$style.disabled]: node.disabled }]"
:label="`${title(node.name)} ${getMultipleNodesText(node.name)}`"
data-test-id="ndv-input-option"
>
<NodeIcon
:disabled="node.disabled"
:node-type="type"
:size="14"
:shrink="false"
:class="$style.icon"
/>
<span :class="$style.title">
{{ title(node.name) }}
<span v-if="node.disabled">({{ i18n.baseText('node.disabled') }})</span>
</span>
<span :class="$style.subtitle">{{ subtitle(node.name, depth) }}</span>
</n8n-option>
</n8n-select>
</template>
<style lang="scss" module>
.select {
max-width: 224px;
:global(.el-input--suffix .el-input__inner) {
padding-left: calc(var(--spacing-l) + var(--spacing-4xs));
padding-right: var(--spacing-l);
}
}
.node {
--select-option-padding: 0 var(--spacing-s);
display: flex;
align-items: center;
font-size: var(--font-size-2xs);
gap: var(--spacing-4xs);
}
.icon {
padding-right: var(--spacing-4xs);
}
.title {
color: var(--color-text-dark);
font-weight: var(--font-weight-regular);
}
.disabled .title {
color: var(--color-text-light);
}
.subtitle {
color: var(--color-text-light);
font-weight: var(--font-weight-regular);
}
</style>

View file

@ -1,6 +1,7 @@
<template> <template>
<RunData <RunData
:node="currentNode" :node="currentNode"
:workflow="workflow"
:run-index="runIndex" :run-index="runIndex"
:linked-runs="linkedRuns" :linked-runs="linkedRuns"
:can-link-runs="!mappedNode && canLinkRuns" :can-link-runs="!mappedNode && canLinkRuns"
@ -26,38 +27,7 @@
> >
<template #header> <template #header>
<div :class="$style.titleSection"> <div :class="$style.titleSection">
<n8n-select
v-if="parentNodes.length"
teleported
size="small"
:model-value="currentNodeName"
:no-data-text="$locale.baseText('ndv.input.noNodesFound')"
:placeholder="$locale.baseText('ndv.input.parentNodes')"
filterable
data-test-id="ndv-input-select"
@update:model-value="onInputNodeChange"
>
<template #prepend>
<span :class="$style.title">{{ $locale.baseText('ndv.input') }}</span> <span :class="$style.title">{{ $locale.baseText('ndv.input') }}</span>
</template>
<n8n-option
v-for="node of parentNodes"
:key="node.name"
:value="node.name"
class="node-option"
:label="`${truncate(node.name)} ${getMultipleNodesText(node.name)}`"
data-test-id="ndv-input-option"
>
<span>{{ truncate(node.name) }}&nbsp;</span>
<span v-if="getMultipleNodesText(node.name)">{{
getMultipleNodesText(node.name)
}}</span>
<span v-else>{{
$locale.baseText('ndv.input.nodeDistance', { adjustToNumber: node.depth })
}}</span>
</n8n-option>
</n8n-select>
<span v-else :class="$style.title">{{ $locale.baseText('ndv.input') }}</span>
<n8n-radio-buttons <n8n-radio-buttons
v-if="isActiveNodeConfig && !readOnly" v-if="isActiveNodeConfig && !readOnly"
:options="inputModes" :options="inputModes"
@ -66,6 +36,15 @@
/> />
</div> </div>
</template> </template>
<template #input-select>
<InputNodeSelect
v-if="parentNodes.length && currentNodeName"
:model-value="currentNodeName"
:workflow="workflow"
:nodes="parentNodes"
@update:model-value="onInputNodeChange"
/>
</template>
<template v-if="isMappingMode" #before-data> <template v-if="isMappingMode" #before-data>
<!-- <!--
Hide the run linking buttons for both input and ouput panels when in 'Mapping Mode' because the run indices wouldn't match. Hide the run linking buttons for both input and ouput panels when in 'Mapping Mode' because the run indices wouldn't match.
@ -73,21 +52,12 @@
--> -->
<component :is="'style'">button.linkRun { display: none }</component> <component :is="'style'">button.linkRun { display: none }</component>
<div :class="$style.mappedNode"> <div :class="$style.mappedNode">
<n8n-select <InputNodeSelect
:model-value="mappedNode" :model-value="mappedNode"
size="small" :workflow="workflow"
teleported :nodes="rootNodesParents"
@update:model-value="onMappedNodeSelected" @update:model-value="onMappedNodeSelected"
@click.stop
>
<template #prepend>{{ $locale.baseText('ndv.input.previousNode') }}</template>
<n8n-option
v-for="nodeName in rootNodesParents"
:key="nodeName"
:label="nodeName"
:value="nodeName"
/> />
</n8n-select>
</div> </div>
</template> </template>
<template #node-not-run> <template #node-not-run>
@ -163,10 +133,17 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent } from 'vue';
import { mapStores } from 'pinia';
import type { INodeUi } from '@/Interface'; import type { INodeUi } from '@/Interface';
import { NodeHelpers, NodeConnectionType } from 'n8n-workflow'; import {
CRON_NODE_TYPE,
INTERVAL_NODE_TYPE,
MANUAL_TRIGGER_NODE_TYPE,
START_NODE_TYPE,
} from '@/constants';
import { useNDVStore } from '@/stores/ndv.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useUIStore } from '@/stores/ui.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import type { import type {
ConnectionTypes, ConnectionTypes,
IConnectedNode, IConnectedNode,
@ -174,25 +151,19 @@ import type {
INodeTypeDescription, INodeTypeDescription,
Workflow, Workflow,
} from 'n8n-workflow'; } from 'n8n-workflow';
import RunData from './RunData.vue'; import { NodeConnectionType, NodeHelpers } from 'n8n-workflow';
import { mapStores } from 'pinia';
import { defineComponent, type PropType } from 'vue';
import InputNodeSelect from './InputNodeSelect.vue';
import NodeExecuteButton from './NodeExecuteButton.vue'; import NodeExecuteButton from './NodeExecuteButton.vue';
import RunData from './RunData.vue';
import WireMeUp from './WireMeUp.vue'; import WireMeUp from './WireMeUp.vue';
import {
CRON_NODE_TYPE,
INTERVAL_NODE_TYPE,
MANUAL_TRIGGER_NODE_TYPE,
START_NODE_TYPE,
} from '@/constants';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useNDVStore } from '@/stores/ndv.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useUIStore } from '@/stores/ui.store';
type MappingMode = 'debugging' | 'mapping'; type MappingMode = 'debugging' | 'mapping';
export default defineComponent({ export default defineComponent({
name: 'InputPanel', name: 'InputPanel',
components: { RunData, NodeExecuteButton, WireMeUp }, components: { RunData, NodeExecuteButton, WireMeUp, InputNodeSelect },
props: { props: {
currentNodeName: { currentNodeName: {
type: String, type: String,
@ -204,7 +175,10 @@ export default defineComponent({
linkedRuns: { linkedRuns: {
type: Boolean, type: Boolean,
}, },
workflow: {}, workflow: {
type: Object as PropType<Workflow>,
required: true,
},
canLinkRuns: { canLinkRuns: {
type: Boolean, type: Boolean,
}, },
@ -223,6 +197,17 @@ export default defineComponent({
default: false, default: false,
}, },
}, },
emits: [
'itemHover',
'tableMounted',
'linkRun',
'unlinkRun',
'runChange',
'search',
'changeInputNode',
'execute',
'activatePane',
],
data() { data() {
return { return {
showDraggableHintWithDelay: false, showDraggableHintWithDelay: false,
@ -262,10 +247,10 @@ export default defineComponent({
isActiveNodeConfig(): boolean { isActiveNodeConfig(): boolean {
let inputs = this.activeNodeType?.inputs ?? []; let inputs = this.activeNodeType?.inputs ?? [];
let outputs = this.activeNodeType?.outputs ?? []; let outputs = this.activeNodeType?.outputs ?? [];
if (this.activeNode !== null && this.currentWorkflow !== null) { if (this.activeNode !== null && this.workflow !== null) {
const node = this.currentWorkflow.getNode(this.activeNode.name); const node = this.workflow.getNode(this.activeNode.name);
inputs = NodeHelpers.getNodeInputs(this.currentWorkflow, node!, this.activeNodeType!); inputs = NodeHelpers.getNodeInputs(this.workflow, node!, this.activeNodeType!);
outputs = NodeHelpers.getNodeOutputs(this.currentWorkflow, node!, this.activeNodeType!); outputs = NodeHelpers.getNodeOutputs(this.workflow, node!, this.activeNodeType!);
} else { } else {
// If we can not figure out the node type we set no outputs // If we can not figure out the node type we set no outputs
if (!Array.isArray(inputs)) { if (!Array.isArray(inputs)) {
@ -319,22 +304,22 @@ export default defineComponent({
workflowRunning(): boolean { workflowRunning(): boolean {
return this.uiStore.isActionActive('workflowRunning'); return this.uiStore.isActionActive('workflowRunning');
}, },
currentWorkflow(): Workflow {
return this.workflow as Workflow;
},
activeNode(): INodeUi | null { activeNode(): INodeUi | null {
return this.ndvStore.activeNode; return this.ndvStore.activeNode;
}, },
rootNode(): string { rootNode(): string {
const workflow = this.currentWorkflow; const workflow = this.workflow;
const rootNodes = workflow.getChildNodes(this.activeNode?.name ?? '', 'ALL_NON_MAIN'); const rootNodes = workflow.getChildNodes(this.activeNode?.name ?? '', 'ALL_NON_MAIN');
return rootNodes[0]; return rootNodes[0];
}, },
rootNodesParents(): string[] { rootNodesParents() {
const workflow = this.currentWorkflow; const workflow = this.workflow;
const parentNodes = [...workflow.getParentNodes(this.rootNode, 'main')].reverse(); const parentNodes = [...workflow.getParentNodes(this.rootNode, 'main')]
.reverse()
.map((parent): IConnectedNode => ({ name: parent, depth: 1, indicies: [] }));
return parentNodes; return parentNodes;
}, },
@ -363,9 +348,7 @@ export default defineComponent({
if (!this.activeNode) { if (!this.activeNode) {
return []; return [];
} }
const nodes: IConnectedNode[] = (this.workflow as Workflow).getParentNodesByDepth( const nodes = this.workflow.getParentNodesByDepth(this.activeNode.name);
this.activeNode.name,
);
return nodes.filter( return nodes.filter(
({ name }, i) => ({ name }, i) =>
@ -376,7 +359,7 @@ export default defineComponent({
}, },
currentNodeDepth(): number { currentNodeDepth(): number {
const node = this.parentNodes.find( const node = this.parentNodes.find(
(node) => this.currentNode && node.name === this.currentNode.name, (parent) => this.currentNode && parent.name === this.currentNode.name,
); );
return node ? node.depth : -1; return node ? node.depth : -1;
}, },
@ -395,7 +378,7 @@ export default defineComponent({
this.onRunIndexChange(-1); this.onRunIndexChange(-1);
if (val === 'mapping') { if (val === 'mapping') {
this.onUnlinkRun(); this.onUnlinkRun();
this.mappedNode = this.rootNodesParents[0]; this.mappedNode = this.rootNodesParents[0]?.name ?? null;
} else { } else {
this.mappedNode = null; this.mappedNode = null;
} }
@ -440,32 +423,6 @@ export default defineComponent({
this.onRunIndexChange(0); this.onRunIndexChange(0);
this.onUnlinkRun(); this.onUnlinkRun();
}, },
getMultipleNodesText(nodeName?: string): string {
if (
!nodeName ||
!this.isMultiInputNode ||
!this.activeNode ||
this.activeNodeType?.inputNames === undefined
)
return '';
const activeNodeConnections =
this.currentWorkflow.connectionsByDestinationNode[this.activeNode.name].main || [];
// Collect indexes of connected nodes
const connectedInputIndexes = activeNodeConnections.reduce((acc: number[], node, index) => {
if (node[0] && node[0].node === nodeName) return [...acc, index];
return acc;
}, []);
// Match connected input indexes to their names specified by active node
const connectedInputs = connectedInputIndexes.map(
(inputIndex) => this.activeNodeType?.inputNames?.[inputIndex],
);
if (connectedInputs.length === 0) return '';
return `(${connectedInputs.join(' & ')})`;
},
onNodeExecute() { onNodeExecute() {
this.$emit('execute'); this.$emit('execute');
if (this.activeNode) { if (this.activeNode) {
@ -502,13 +459,6 @@ export default defineComponent({
}); });
} }
}, },
truncate(nodeName: string) {
const truncated = nodeName.substring(0, 30);
if (truncated.length < nodeName.length) {
return `${truncated}...`;
}
return truncated;
},
activatePane() { activatePane() {
this.$emit('activatePane'); this.$emit('activatePane');
}, },
@ -518,9 +468,9 @@ export default defineComponent({
<style lang="scss" module> <style lang="scss" module>
.mappedNode { .mappedNode {
width: max-content;
padding: 0 var(--spacing-s) var(--spacing-s); padding: 0 var(--spacing-s) var(--spacing-s);
} }
.titleSection { .titleSection {
display: flex; display: flex;
max-width: 300px; max-width: 300px;
@ -575,17 +525,3 @@ export default defineComponent({
font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold);
} }
</style> </style>
<style lang="scss" scoped>
.node-option {
font-weight: var(--font-weight-regular) !important;
span {
color: var(--color-text-light);
}
&.selected > span {
color: var(--color-primary);
}
}
</style>

View file

@ -85,6 +85,7 @@
<template #output> <template #output>
<OutputPanel <OutputPanel
data-test-id="output-panel" data-test-id="output-panel"
:workflow="workflow"
:can-link-runs="canLinkRuns" :can-link-runs="canLinkRuns"
:run-index="outputRun" :run-index="outputRun"
:linked-runs="linked" :linked-runs="linked"

View file

@ -2,6 +2,7 @@
<RunData <RunData
ref="runData" ref="runData"
:node="node" :node="node"
:workflow="workflow"
:run-index="runIndex" :run-index="runIndex"
:linked-runs="linkedRuns" :linked-runs="linkedRuns"
:can-link-runs="canLinkRuns" :can-link-runs="canLinkRuns"
@ -100,9 +101,15 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent } from 'vue'; import { type PropType, defineComponent } from 'vue';
import type { IExecutionResponse, INodeUi } from '@/Interface'; import type { IExecutionResponse, INodeUi } from '@/Interface';
import type { INodeTypeDescription, IRunData, IRunExecutionData, ITaskData } from 'n8n-workflow'; import type {
INodeTypeDescription,
IRunData,
IRunExecutionData,
ITaskData,
Workflow,
} from 'n8n-workflow';
import RunData from './RunData.vue'; import RunData from './RunData.vue';
import RunInfo from './RunInfo.vue'; import RunInfo from './RunInfo.vue';
import { mapStores, storeToRefs } from 'pinia'; import { mapStores, storeToRefs } from 'pinia';
@ -129,6 +136,10 @@ export default defineComponent({
name: 'OutputPanel', name: 'OutputPanel',
components: { RunData, RunInfo, RunDataAi }, components: { RunData, RunInfo, RunDataAi },
props: { props: {
workflow: {
type: Object as PropType<Workflow>,
required: true,
},
runIndex: { runIndex: {
type: Number, type: Number,
required: true, required: true,

View file

@ -58,6 +58,7 @@
data-test-id="ndv-run-data-display-mode" data-test-id="ndv-run-data-display-mode"
@update:model-value="onDisplayModeChange" @update:model-value="onDisplayModeChange"
/> />
<n8n-icon-button <n8n-icon-button
v-if="canPinData && !isReadOnlyRoute && !readOnlyEnv" v-if="canPinData && !isReadOnlyRoute && !readOnlyEnv"
v-show="!editMode.enabled" v-show="!editMode.enabled"
@ -105,17 +106,19 @@
</div> </div>
</div> </div>
<div <div v-if="extraControlsLocation === 'header'" :class="$style.inputSelect">
v-if="maxRunIndex > 0" <slot name="input-select"></slot>
v-show="!editMode.enabled" </div>
:class="$style.runSelector"
data-test-id="run-selector" <div v-if="maxRunIndex > 0" v-show="!editMode.enabled" :class="$style.runSelector">
> <slot v-if="extraControlsLocation === 'runs'" name="input-select"></slot>
<div :class="$style.runSelectorWrapper">
<n8n-select <n8n-select
size="small"
:model-value="runIndex" :model-value="runIndex"
:class="$style.runSelectorInner"
size="small"
teleported teleported
data-test-id="run-selector"
@update:model-value="onRunIndexChange" @update:model-value="onRunIndexChange"
@click.stop @click.stop
> >
@ -127,29 +130,34 @@
:value="option - 1" :value="option - 1"
></n8n-option> ></n8n-option>
</n8n-select> </n8n-select>
<n8n-tooltip v-if="canLinkRuns" placement="right"> <n8n-tooltip v-if="canLinkRuns" placement="right">
<template #content> <template #content>
{{ $locale.baseText(linkedRuns ? 'runData.unlinking.hint' : 'runData.linking.hint') }} {{ $locale.baseText(linkedRuns ? 'runData.unlinking.hint' : 'runData.linking.hint') }}
</template> </template>
<n8n-icon-button <n8n-icon-button
class="linkRun"
:icon="linkedRuns ? 'unlink' : 'link'" :icon="linkedRuns ? 'unlink' : 'link'"
class="linkRun"
text text
type="tertiary" type="tertiary"
size="small" size="small"
data-test-id="link-run"
@click="toggleLinkRuns" @click="toggleLinkRuns"
/> />
</n8n-tooltip> </n8n-tooltip>
<slot name="run-info"></slot> <slot name="run-info"></slot>
</div>
<RunDataSearch <RunDataSearch
v-if="showIOSearch" v-if="showIOSearch && extraControlsLocation === 'runs'"
v-model="search" v-model="search"
:class="$style.search"
:pane-type="paneType" :pane-type="paneType"
:is-area-active="isPaneActive" :is-area-active="isPaneActive"
@focus="activatePane" @focus="activatePane"
/> />
</div> </div>
<slot name="before-data" /> <slot name="before-data" />
<n8n-callout <n8n-callout
@ -163,22 +171,27 @@
<div <div
v-if="maxOutputIndex > 0 && branches.length > 1" v-if="maxOutputIndex > 0 && branches.length > 1"
:class="$style.tabs" :class="$style.outputs"
data-test-id="branches" data-test-id="branches"
> >
<slot v-if="extraControlsLocation === 'outputs'" name="input-select"></slot>
<div :class="$style.tabs">
<n8n-tabs <n8n-tabs
:model-value="currentOutputIndex" :model-value="currentOutputIndex"
:options="branches" :options="branches"
@update:model-value="onBranchChange" @update:model-value="onBranchChange"
/> />
<RunDataSearch <RunDataSearch
v-if="showIOSearch" v-if="showIOSearch && extraControlsLocation === 'outputs'"
v-model="search" v-model="search"
:pane-type="paneType" :pane-type="paneType"
:is-area-active="isPaneActive" :is-area-active="isPaneActive"
@focus="activatePane" @focus="activatePane"
/> />
</div> </div>
</div>
<div <div
v-else-if=" v-else-if="
@ -188,10 +201,12 @@
!isArtificialRecoveredEventItem !isArtificialRecoveredEventItem
" "
v-show="!editMode.enabled && !hasRunError" v-show="!editMode.enabled && !hasRunError"
:class="$style.itemsCount" :class="[$style.itemsCount, { [$style.muted]: paneType === 'input' && maxRunIndex === 0 }]"
data-test-id="ndv-items-count" data-test-id="ndv-items-count"
> >
<n8n-text v-if="search"> <slot v-if="extraControlsLocation === 'items'" name="input-select"></slot>
<n8n-text v-if="search" :class="$style.itemsText">
{{ {{
$locale.baseText('ndv.search.items', { $locale.baseText('ndv.search.items', {
adjustToNumber: unfilteredDataCount, adjustToNumber: unfilteredDataCount,
@ -199,7 +214,7 @@
}) })
}} }}
</n8n-text> </n8n-text>
<n8n-text v-else> <n8n-text v-else :class="$style.itemsText">
{{ {{
$locale.baseText('ndv.output.items', { $locale.baseText('ndv.output.items', {
adjustToNumber: dataCount, adjustToNumber: dataCount,
@ -207,9 +222,11 @@
}) })
}} }}
</n8n-text> </n8n-text>
<RunDataSearch <RunDataSearch
v-if="showIOSearch" v-if="showIOSearch && extraControlsLocation === 'items'"
v-model="search" v-model="search"
:class="$style.search"
:pane-type="paneType" :pane-type="paneType"
:is-area-active="isPaneActive" :is-area-active="isPaneActive"
@focus="activatePane" @focus="activatePane"
@ -568,6 +585,7 @@ import type {
IRunExecutionData, IRunExecutionData,
NodeHint, NodeHint,
NodeError, NodeError,
Workflow,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { NodeHelpers, NodeConnectionType } from 'n8n-workflow'; import { NodeHelpers, NodeConnectionType } from 'n8n-workflow';
@ -647,6 +665,10 @@ export default defineComponent({
type: Object as PropType<INodeUi | null>, type: Object as PropType<INodeUi | null>,
default: null, default: null,
}, },
workflow: {
type: Object as PropType<Workflow>,
required: true,
},
runIndex: { runIndex: {
type: Number, type: Number,
required: true, required: true,
@ -800,7 +822,7 @@ export default defineComponent({
} }
const schemaView = { label: this.$locale.baseText('runData.schema'), value: 'schema' }; const schemaView = { label: this.$locale.baseText('runData.schema'), value: 'schema' };
if (this.isPaneTypeInput && !isEmpty(this.jsonData)) { if (this.isPaneTypeInput) {
defaults.unshift(schemaView); defaults.unshift(schemaView);
} else { } else {
defaults.push(schemaView); defaults.push(schemaView);
@ -925,9 +947,11 @@ export default defineComponent({
rawInputData(): INodeExecutionData[] { rawInputData(): INodeExecutionData[] {
return this.getRawInputData(this.runIndex, this.currentOutputIndex, this.connectionType); return this.getRawInputData(this.runIndex, this.currentOutputIndex, this.connectionType);
}, },
unfilteredInputData(): INodeExecutionData[] {
return this.getPinDataOrLiveData(this.rawInputData);
},
inputData(): INodeExecutionData[] { inputData(): INodeExecutionData[] {
const pinOrLiveData = this.getPinDataOrLiveData(this.rawInputData); return this.getFilteredData(this.unfilteredInputData);
return this.getFilteredData(pinOrLiveData);
}, },
inputDataPage(): INodeExecutionData[] { inputDataPage(): INodeExecutionData[] {
const offset = this.pageSize * (this.currentPage - 1); const offset = this.pageSize * (this.currentPage - 1);
@ -1016,20 +1040,27 @@ export default defineComponent({
return this.sourceControlStore.preferences.branchReadOnly; return this.sourceControlStore.preferences.branchReadOnly;
}, },
showIOSearch(): boolean { showIOSearch(): boolean {
return this.hasNodeRun && !this.hasRunError; return this.hasNodeRun && !this.hasRunError && this.unfilteredInputData.length > 0;
},
extraControlsLocation() {
if (!this.hasNodeRun) return 'header';
if (this.maxRunIndex > 0) return 'runs';
if (this.maxOutputIndex > 0 && this.branches.length > 1) {
return 'outputs';
}
return 'items';
}, },
showIoSearchNoMatchContent(): boolean { showIoSearchNoMatchContent(): boolean {
return this.hasNodeRun && !this.inputData.length && !!this.search; return this.hasNodeRun && !this.inputData.length && !!this.search;
}, },
parentNodeOutputData(): INodeExecutionData[] { parentNodeOutputData(): INodeExecutionData[] {
const workflow = this.workflowsStore.getCurrentWorkflow(); const parentNode = this.workflow.getParentNodesByDepth(this.node?.name ?? '')[0];
const parentNode = workflow.getParentNodesByDepth(this.node?.name ?? '')[0];
let parentNodeData: INodeExecutionData[] = []; let parentNodeData: INodeExecutionData[] = [];
if (parentNode?.name) { if (parentNode?.name) {
parentNodeData = this.nodeHelpers.getNodeInputData( parentNodeData = this.nodeHelpers.getNodeInputData(
workflow.getNode(parentNode?.name), this.workflow.getNode(parentNode?.name),
this.runIndex, this.runIndex,
this.outputIndex, this.outputIndex,
'input', 'input',
@ -1124,11 +1155,10 @@ export default defineComponent({
methods: { methods: {
getResolvedNodeOutputs() { getResolvedNodeOutputs() {
if (this.node && this.nodeType) { if (this.node && this.nodeType) {
const workflow = this.workflowsStore.getCurrentWorkflow(); const workflowNode = this.workflow.getNode(this.node.name);
const workflowNode = workflow.getNode(this.node.name);
if (workflowNode) { if (workflowNode) {
const outputs = NodeHelpers.getNodeOutputs(workflow, workflowNode, this.nodeType); const outputs = NodeHelpers.getNodeOutputs(this.workflow, workflowNode, this.nodeType);
return outputs; return outputs;
} }
} }
@ -1162,12 +1192,11 @@ export default defineComponent({
}, },
getNodeHints(): NodeHint[] { getNodeHints(): NodeHint[] {
if (this.node && this.nodeType) { if (this.node && this.nodeType) {
const workflow = this.workflowsStore.getCurrentWorkflow(); const workflowNode = this.workflow.getNode(this.node.name);
const workflowNode = workflow.getNode(this.node.name);
if (workflowNode) { if (workflowNode) {
const executionHints = this.executionHints; const executionHints = this.executionHints;
const nodeHints = NodeHelpers.getNodeHints(workflow, workflowNode, this.nodeType, { const nodeHints = NodeHelpers.getNodeHints(this.workflow, workflowNode, this.nodeType, {
runExecutionData: this.workflowExecution?.data ?? null, runExecutionData: this.workflowExecution?.data ?? null,
runIndex: this.runIndex, runIndex: this.runIndex,
connectionInputData: this.parentNodeOutputData, connectionInputData: this.parentNodeOutputData,
@ -1716,33 +1745,68 @@ export default defineComponent({
height: 100%; height: 100%;
} }
.outputs {
display: flex;
flex-direction: column;
gap: var(--spacing-s);
padding-left: var(--spacing-s);
padding-right: var(--spacing-s);
padding-bottom: var(--spacing-s);
}
.tabs { .tabs {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: var(--spacing-s); min-height: 30px;
} }
.itemsCount { .itemsCount {
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
margin-left: var(--spacing-s); gap: var(--spacing-2xs);
margin-bottom: var(--spacing-s); padding-left: var(--spacing-s);
padding-right: var(--spacing-s);
padding-bottom: var(--spacing-s);
.itemsText {
flex-shrink: 0;
overflow-x: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
&.muted .itemsText {
color: var(--color-text-light);
font-size: var(--font-size-2xs);
}
}
.inputSelect {
padding-left: var(--spacing-s);
padding-right: var(--spacing-s);
padding-bottom: var(--spacing-s);
} }
.runSelector { .runSelector {
padding-left: var(--spacing-s); padding-left: var(--spacing-s);
padding-right: var(--spacing-s);
padding-bottom: var(--spacing-s); padding-bottom: var(--spacing-s);
display: flex; display: flex;
width: 100%; gap: var(--spacing-4xs);
align-items: center; align-items: center;
justify-content: space-between;
:global(.el-input--suffix .el-input__inner) {
padding-right: var(--spacing-l);
}
} }
.runSelectorWrapper { .search {
display: flex; margin-left: auto;
align-items: center; }
.runSelectorInner {
max-width: 172px;
} }
.pagination { .pagination {

View file

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from 'vue'; import { computed, ref, onMounted, onUnmounted, type StyleValue } from 'vue';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import type { NodePanelType } from '@/Interface'; import type { NodePanelType } from '@/Interface';
@ -9,7 +9,9 @@ type Props = {
isAreaActive?: boolean; isAreaActive?: boolean;
}; };
const INITIAL_WIDTH = '34px'; const COLLAPSED_WIDTH = '34px';
const OPEN_WIDTH = '200px';
const OPEN_MIN_WIDTH = '120px';
const emit = defineEmits<{ const emit = defineEmits<{
(event: 'update:modelValue', value: Props['modelValue']): void; (event: 'update:modelValue', value: Props['modelValue']): void;
@ -24,7 +26,6 @@ const props = withDefaults(defineProps<Props>(), {
const locale = useI18n(); const locale = useI18n();
const inputRef = ref<HTMLInputElement | null>(null); const inputRef = ref<HTMLInputElement | null>(null);
const maxWidth = ref(INITIAL_WIDTH);
const opened = ref(false); const opened = ref(false);
const placeholder = computed(() => const placeholder = computed(() =>
props.paneType === 'input' props.paneType === 'input'
@ -32,6 +33,10 @@ const placeholder = computed(() =>
: locale.baseText('ndv.search.placeholder.output'), : locale.baseText('ndv.search.placeholder.output'),
); );
const style = computed<StyleValue>(() =>
opened.value ? { maxWidth: OPEN_WIDTH, minWidth: OPEN_MIN_WIDTH } : { maxWidth: COLLAPSED_WIDTH },
);
const documentKeyHandler = (event: KeyboardEvent) => { const documentKeyHandler = (event: KeyboardEvent) => {
const isTargetFormElementOrEditable = const isTargetFormElementOrEditable =
event.target instanceof HTMLInputElement || event.target instanceof HTMLInputElement ||
@ -50,14 +55,12 @@ const onSearchUpdate = (value: string) => {
}; };
const onFocus = () => { const onFocus = () => {
opened.value = true; opened.value = true;
maxWidth.value = '30%';
inputRef.value?.select(); inputRef.value?.select();
emit('focus'); emit('focus');
}; };
const onBlur = () => { const onBlur = () => {
if (!props.modelValue) { if (!props.modelValue) {
opened.value = false; opened.value = false;
maxWidth.value = INITIAL_WIDTH;
} }
}; };
onMounted(() => { onMounted(() => {
@ -76,7 +79,7 @@ onUnmounted(() => {
[$style.ioSearch]: true, [$style.ioSearch]: true,
[$style.ioSearchOpened]: opened, [$style.ioSearchOpened]: opened,
}" }"
:style="{ maxWidth }" :style="style"
:model-value="modelValue" :model-value="modelValue"
:placeholder="placeholder" :placeholder="placeholder"
size="small" size="small"
@ -94,7 +97,6 @@ onUnmounted(() => {
@import '@/styles/variables'; @import '@/styles/variables';
.ioSearch { .ioSearch {
margin-right: var(--spacing-s);
transition: max-width 0.3s $ease-out-expo; transition: max-width 0.3s $ease-out-expo;
.ioSearchIcon { .ioSearchIcon {

View file

@ -893,12 +893,11 @@
"ndv.execute.workflowAlreadyRunning": "Workflow is already running", "ndv.execute.workflowAlreadyRunning": "Workflow is already running",
"ndv.featureRequest": "I wish this node would...", "ndv.featureRequest": "I wish this node would...",
"ndv.input": "Input", "ndv.input": "Input",
"ndv.input.nodeDistance": "({count} node back) | ({count} nodes back)", "ndv.input.nodeDistance": "{count} node back | {count} nodes back",
"ndv.input.noNodesFound": "No nodes found", "ndv.input.noNodesFound": "No nodes found",
"ndv.input.mapping": "Mapping", "ndv.input.mapping": "Mapping",
"ndv.input.debugging": "Debugging", "ndv.input.debugging": "Debugging",
"ndv.input.parentNodes": "Parent nodes", "ndv.input.parentNodes": "Parent nodes",
"ndv.input.previousNode": "Previous node",
"ndv.input.tooMuchData.title": "Input data is huge", "ndv.input.tooMuchData.title": "Input data is huge",
"ndv.input.noOutputDataInBranch": "No input data in this branch", "ndv.input.noOutputDataInBranch": "No input data in this branch",
"ndv.input.noOutputDataInNode": "Node did not output any data. n8n stops executing the workflow when a node has no output data.", "ndv.input.noOutputDataInNode": "Node did not output any data. n8n stops executing the workflow when a node has no output data.",
@ -926,7 +925,7 @@
"ndv.output.noOutputData.message.settingsOption": "> \"Always Output Data\".", "ndv.output.noOutputData.message.settingsOption": "> \"Always Output Data\".",
"ndv.output.noOutputData.title": "No output data returned", "ndv.output.noOutputData.title": "No output data returned",
"ndv.output.noOutputDataInBranch": "No output data in this branch", "ndv.output.noOutputDataInBranch": "No output data in this branch",
"ndv.output.of": " of ", "ndv.output.of": "{current} of {total}",
"ndv.output.pageSize": "Page Size", "ndv.output.pageSize": "Page Size",
"ndv.output.run": "Run", "ndv.output.run": "Run",
"ndv.output.runNodeHint": "Execute this node to view data", "ndv.output.runNodeHint": "Execute this node to view data",

View file

@ -161,3 +161,7 @@ export const getObjectKeys = <T extends object, K extends keyof T>(o: T): K[] =>
export const tryToParseNumber = (value: string): number | string => { export const tryToParseNumber = (value: string): number | string => {
return isNaN(+value) ? value : +value; return isNaN(+value) ? value : +value;
}; };
export function isPresent<T>(arg: T): arg is Exclude<T, null | undefined> {
return arg !== null && arg !== undefined;
}