feat(editor): update expressions display (#4171)

* N8n 4673 expressions res1 (#4149)

* hide hints if necessary

* refactor out parameter input

* refactor param input in creds

* remove any

* add expression result before

* update case

* add types

* fix spacing

* update types

* update expr

* update parameter input

* update param input

* update param input

* remove import

* fix typo

* update value

* fix drop for rl

* add state to track hovering item

* add hover behavior to resolve values

* update index

* fix run selector bug

* add run item to eval expr

* add paired item mappings

* fix rec bug

* Fix for loops

* handle pinned data

* add missing pinned

* fix bug

* support parent

* add input

* map back from output

* clean up

* fix output bug

* fix branching bug

* update preview

* only if expr

* fix output

* fix expr eval for outputs

* add default hover state

* fix hover state

* fix branching

* hide hint if expr

* remove duplicate logic

* update style

* allow opening expr in demo

* update expr

* update row hover

* update param name

* clean up

* update hovering state

* update default output

* fix duplicate import

* update hover behavior

* update package lock

* fix pinned data case

* address case when no input
This commit is contained in:
Mutasem Aldmour 2022-10-12 14:06:28 +02:00 committed by GitHub
parent fe7c8a85ce
commit 6b538494ce
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 842 additions and 228 deletions

106
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "n8n",
"version": "0.196.0",
"version": "0.197.1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "n8n",
"version": "0.196.0",
"version": "0.197.1",
"hasInstallScript": true,
"workspaces": [
"packages/*",
@ -43257,7 +43257,7 @@
},
"packages/cli": {
"name": "n8n",
"version": "0.196.0",
"version": "0.197.1",
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {
"@oclif/command": "^1.5.18",
@ -43303,10 +43303,10 @@
"lodash.split": "^4.4.2",
"lodash.unset": "^4.5.2",
"mysql2": "~2.3.0",
"n8n-core": "~0.136.0",
"n8n-editor-ui": "~0.162.0",
"n8n-nodes-base": "~0.194.0",
"n8n-workflow": "~0.118.0",
"n8n-core": "~0.137.0",
"n8n-editor-ui": "~0.163.1",
"n8n-nodes-base": "~0.195.1",
"n8n-workflow": "~0.119.0",
"nodemailer": "^6.7.1",
"oauth-1.0a": "^2.2.6",
"open": "^7.0.0",
@ -44453,7 +44453,7 @@
},
"packages/core": {
"name": "n8n-core",
"version": "0.136.0",
"version": "0.137.0",
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {
"axios": "^0.21.1",
@ -44465,7 +44465,7 @@
"form-data": "^4.0.0",
"lodash.get": "^4.4.2",
"mime-types": "^2.1.27",
"n8n-workflow": "~0.118.0",
"n8n-workflow": "~0.119.0",
"oauth-1.0a": "^2.2.6",
"p-cancelable": "^2.0.0",
"qs": "^6.10.1",
@ -45521,7 +45521,7 @@
},
"packages/design-system": {
"name": "n8n-design-system",
"version": "0.36.0",
"version": "0.37.0",
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {
"element-ui": "~2.15.7",
@ -45704,7 +45704,7 @@
},
"packages/editor-ui": {
"name": "n8n-editor-ui",
"version": "0.162.0",
"version": "0.163.1",
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {
"@fontsource/open-sans": "^4.5.0",
@ -45728,8 +45728,8 @@
"lodash.set": "^4.3.2",
"luxon": "^2.3.0",
"monaco-editor": "^0.30.1",
"n8n-design-system": "~0.36.0",
"n8n-workflow": "~0.118.0",
"n8n-design-system": "~0.37.0",
"n8n-workflow": "~0.119.0",
"normalize-wheel": "^1.0.1",
"prismjs": "^1.17.1",
"quill": "2.0.0-dev.4",
@ -46156,7 +46156,7 @@
},
"packages/node-dev": {
"name": "n8n-node-dev",
"version": "0.75.0",
"version": "0.76.0",
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {
"@oclif/command": "^1.5.18",
@ -46164,8 +46164,8 @@
"change-case": "^4.1.1",
"fast-glob": "^3.2.5",
"inquirer": "^7.0.1",
"n8n-core": "~0.136.0",
"n8n-workflow": "~0.118.0",
"n8n-core": "~0.137.0",
"n8n-workflow": "~0.119.0",
"oauth-1.0a": "^2.2.6",
"replace-in-file": "^6.0.0",
"request": "^2.88.2",
@ -46185,21 +46185,9 @@
"@types/vorpal": "^1.11.0"
}
},
"packages/node-dev/node_modules/typescript": {
"version": "4.6.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.4.tgz",
"integrity": "sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg==",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=4.2.0"
}
},
"packages/nodes-base": {
"name": "n8n-nodes-base",
"version": "0.194.0",
"version": "0.195.1",
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {
"@kafkajs/confluent-schema-registry": "1.0.6",
@ -46237,7 +46225,7 @@
"mqtt": "4.2.6",
"mssql": "^8.1.2",
"mysql2": "~2.3.0",
"n8n-core": "~0.136.0",
"n8n-core": "~0.137.0",
"node-html-markdown": "^1.1.3",
"node-ssh": "^12.0.0",
"nodemailer": "^6.7.1",
@ -46292,7 +46280,7 @@
"eslint-plugin-n8n-nodes-base": "^1.9.3",
"gulp": "^4.0.0",
"jest": "^27.4.7",
"n8n-workflow": "~0.118.0",
"n8n-workflow": "~0.119.0",
"ts-jest": "^27.1.3",
"tslint": "^6.1.2",
"typescript": "~4.8.0"
@ -47329,7 +47317,7 @@
},
"packages/workflow": {
"name": "n8n-workflow",
"version": "0.118.0",
"version": "0.119.0",
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {
"@n8n_io/riot-tmpl": "^1.0.1",
@ -52127,7 +52115,7 @@
"@oclif/errors": "^1.3.5",
"@oclif/parser": "^3.8.0",
"debug": "^4.1.1",
"globby": "^11.0.2",
"globby": "^11.0.1",
"is-wsl": "^2.1.1",
"tslib": "^2.3.1"
},
@ -52153,10 +52141,10 @@
"clean-stack": "^3.0.1",
"cli-progress": "^3.10.0",
"debug": "^4.3.4",
"ejs": "^3.1.8",
"ejs": "^3.1.6",
"fs-extra": "^9.1.0",
"get-package-type": "^0.1.0",
"globby": "^11.0.2",
"globby": "^11.1.0",
"hyperlinker": "^1.0.0",
"indent-string": "^4.0.0",
"is-wsl": "^2.2.0",
@ -52368,7 +52356,7 @@
"@oclif/errors": "^1.3.3",
"@oclif/parser": "^3.8.0",
"debug": "^4.1.1",
"globby": "^11.0.2",
"globby": "^11.0.1",
"is-wsl": "^2.1.1",
"tslib": "^2.0.0"
}
@ -52561,7 +52549,7 @@
"@oclif/errors": "^1.3.3",
"@oclif/parser": "^3.8.0",
"debug": "^4.1.1",
"globby": "^11.0.2",
"globby": "^11.0.1",
"is-wsl": "^2.1.1",
"tslib": "^2.0.0"
}
@ -53067,7 +53055,7 @@
"css-loader": "^3.6.0",
"file-loader": "^6.2.0",
"find-up": "^5.0.0",
"fork-ts-checker-webpack-plugin": "^6.0.4",
"fork-ts-checker-webpack-plugin": "^4.1.6",
"glob": "^7.1.6",
"glob-promise": "^3.4.0",
"global": "^4.4.0",
@ -55589,7 +55577,7 @@
"@typescript-eslint/types": "5.38.1",
"@typescript-eslint/visitor-keys": "5.38.1",
"debug": "^4.3.4",
"globby": "^11.0.2",
"globby": "^11.1.0",
"is-glob": "^4.0.3",
"semver": "^7.3.7",
"tsutils": "^3.21.0"
@ -56959,7 +56947,7 @@
"integrity": "sha512-eM9d/swFopRt5gdJ7jrpCwgvEMIayITpojhkkSMRsFHYuH5bkSQ4p/9qTEHtmNudUZh22Tehu7I6CxAW0IXTKA==",
"dev": true,
"requires": {
"browserslist": "^4.21.3",
"browserslist": "^4.12.0",
"caniuse-lite": "^1.0.30001109",
"normalize-range": "^0.1.2",
"num2fraction": "^1.2.2",
@ -59784,7 +59772,7 @@
"integrity": "sha512-xVtYpJQ5grszDHEUU9O7XbjjcZ0ccX3LgQsyqSvTnjX97ZqEgn9F5srmrwwwMtbKzDllyFPL+O+2OFMl1lU4TQ==",
"dev": true,
"requires": {
"browserslist": "^4.21.3"
"browserslist": "^4.21.4"
}
},
"core-js-pure": {
@ -59858,7 +59846,7 @@
"requires": {
"arrify": "^2.0.1",
"cp-file": "^7.0.0",
"globby": "^11.0.2",
"globby": "^9.2.0",
"has-glob": "^1.0.0",
"junk": "^3.1.0",
"nested-error-stacks": "^2.1.0",
@ -61462,7 +61450,7 @@
"functional-red-black-tree": "^1.0.1",
"glob-parent": "^6.0.1",
"globals": "^13.15.0",
"globby": "^11.0.2",
"globby": "^11.1.0",
"grapheme-splitter": "^1.0.4",
"ignore": "^5.2.0",
"import-fresh": "^3.0.0",
@ -71914,10 +71902,10 @@
"lodash.split": "^4.4.2",
"lodash.unset": "^4.5.2",
"mysql2": "~2.3.0",
"n8n-core": "~0.136.0",
"n8n-editor-ui": "~0.162.0",
"n8n-nodes-base": "~0.194.0",
"n8n-workflow": "~0.118.0",
"n8n-core": "~0.137.0",
"n8n-editor-ui": "~0.163.1",
"n8n-nodes-base": "~0.195.1",
"n8n-workflow": "~0.119.0",
"nodemailer": "^6.7.1",
"nodemon": "^2.0.2",
"oauth-1.0a": "^2.2.6",
@ -72807,7 +72795,7 @@
"jest": "^27.4.7",
"lodash.get": "^4.4.2",
"mime-types": "^2.1.27",
"n8n-workflow": "~0.118.0",
"n8n-workflow": "~0.119.0",
"oauth-1.0a": "^2.2.6",
"p-cancelable": "^2.0.0",
"qs": "^6.10.1",
@ -73791,8 +73779,8 @@
"lodash.set": "^4.3.2",
"luxon": "^2.3.0",
"monaco-editor": "^0.30.1",
"n8n-design-system": "~0.36.0",
"n8n-workflow": "~0.118.0",
"n8n-design-system": "~0.37.0",
"n8n-workflow": "~0.119.0",
"normalize-wheel": "^1.0.1",
"prismjs": "^1.17.1",
"quill": "2.0.0-dev.4",
@ -74109,19 +74097,13 @@
"change-case": "^4.1.1",
"fast-glob": "^3.2.5",
"inquirer": "^7.0.1",
"n8n-core": "~0.136.0",
"n8n-workflow": "~0.118.0",
"n8n-core": "~0.137.0",
"n8n-workflow": "~0.119.0",
"oauth-1.0a": "^2.2.6",
"replace-in-file": "^6.0.0",
"request": "^2.88.2",
"tmp-promise": "^3.0.2",
"typescript": "~4.8.0"
},
"dependencies": {
"typescript": {
"version": "https://registry.npmjs.org/typescript/-/typescript-4.6.4.tgz",
"integrity": "sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg=="
}
}
},
"n8n-nodes-base": {
@ -74194,8 +74176,8 @@
"mqtt": "4.2.6",
"mssql": "^8.1.2",
"mysql2": "~2.3.0",
"n8n-core": "~0.136.0",
"n8n-workflow": "~0.118.0",
"n8n-core": "~0.137.0",
"n8n-workflow": "~0.119.0",
"node-html-markdown": "^1.1.3",
"node-ssh": "^12.0.0",
"nodemailer": "^6.7.1",
@ -78476,7 +78458,7 @@
"fs-extra": "^6.0.1",
"get-stream": "^5.1.0",
"glob": "^7.1.2",
"globby": "^11.0.2",
"globby": "^10.0.1",
"http-call": "^5.1.2",
"load-json-file": "^6.2.0",
"pkg-dir": "^4.2.0",
@ -84173,7 +84155,7 @@
"consola": "^2.15.3",
"dotenv": "^16.0.0",
"dotenv-expand": "^8.0.2",
"ejs": "^3.1.8",
"ejs": "^3.1.6",
"fast-glob": "^3.2.11",
"fs-extra": "^10.0.1",
"html-minifier-terser": "^6.1.0",

View file

@ -72,33 +72,34 @@
var(--color-secondary-l)
);
--color-secondary-tint-1-h: 247;
--color-secondary-tint-1-s: 49%;
--color-secondary-tint-1-l: 85%;
--color-secondary-tint-1: hsl(
var(--color-secondary-tint-1-h),
var(--color-secondary-tint-1-s),
var(--color-secondary-h),
var(--color-secondary-s),
var(--color-secondary-tint-1-l)
);
--color-secondary-tint-2-h: 247;
--color-secondary-tint-2-s: 49%;
--color-secondary-tint-2-l: 92%;
--color-secondary-tint-2: hsl(
var(--color-secondary-tint-2-h),
var(--color-secondary-tint-2-s),
var(--color-secondary-h),
var(--color-secondary-s),
var(--color-secondary-tint-2-l)
);
--color-secondary-tint-3-h: 247;
--color-secondary-tint-3-s: 49%;
--color-secondary-tint-3-l: 95%;
--color-secondary-tint-3: hsl(
var(--color-secondary-tint-3-h),
var(--color-secondary-tint-3-s),
var(--color-secondary-h),
var(--color-secondary-s),
var(--color-secondary-tint-3-l)
);
--color-secondary-tint-4-l: 98%;
--color-secondary-tint-4: hsl(
var(--color-secondary-h),
var(--color-secondary-s),
var(--color-secondary-tint-4-l)
);
--color-success-h: 150.4;
--color-success-s: 60%;
--color-success-l: 40.4%;

View file

@ -31,7 +31,7 @@ import ColorCircles from './ColorCircles.vue';
<Canvas>
<Story name="secondary">
{{
template: `<color-circles :colors="['--color-secondary', '--color-secondary-tint-1', '--color-secondary-tint-2']" />`,
template: `<color-circles :colors="['--color-secondary', '--color-secondary-tint-1', '--color-secondary-tint-2', '--color-secondary-tint-3', '--color-secondary-tint-4']" />`,
components: {
ColorCircles,
},

View file

@ -212,11 +212,6 @@ export interface IStartRunData {
pinData?: IPinData;
}
export interface IRunDataUi {
node?: string;
workflowData: IWorkflowData;
}
export interface ITableData {
columns: string[];
data: GenericValue[][];
@ -863,6 +858,7 @@ export interface IRootState {
oauthCallbackUrls: object;
n8nMetadata: object;
workflowExecutionData: IExecutionResponse | null;
workflowExecutionPairedItemMappings: {[itemId: string]: Set<string>};
lastSelectedNode: string | null;
lastSelectedNodeOutputIndex: number | null;
nodeViewOffsetPosition: XYPosition;
@ -912,6 +908,13 @@ export interface IModalState {
export type IRunDataDisplayMode = 'table' | 'json' | 'binary';
export interface TargetItem {
nodeName: string;
itemIndex: number;
runIndex: number;
outputIndex: number;
}
export interface IUiState {
sidebarMenuCollapsed: boolean;
modalStack: string[];
@ -925,11 +928,15 @@ export interface IUiState {
sessionId: string;
input: {
displayMode: IRunDataDisplayMode;
nodeName?: string;
run?: number;
branch?: number;
data: {
isEmpty: boolean;
}
};
output: {
branch?: number;
displayMode: IRunDataDisplayMode;
data: {
isEmpty: boolean;
@ -941,6 +948,7 @@ export interface IUiState {
};
focusedMappableInput: string;
mappingTelemetry: {[key: string]: string | number | boolean};
hoveringItem: null | TargetItem;
};
mainPanelPosition: number;
draggable: {

View file

@ -2,7 +2,7 @@
<div @keydown.stop :class="$style.container" v-if="credentialProperties.length">
<form v-for="parameter in credentialProperties" :key="parameter.name" autocomplete="off">
<!-- Why form? to break up inputs, to prevent Chrome autofill -->
<ParameterInputExpanded
<parameter-input-expanded
:parameter="parameter"
:value="credentialData[parameter.name]"
:documentationUrl="documentationUrl"

View file

@ -14,6 +14,7 @@
:label="$locale.nodeText().inputLabelDisplayName(property, path)"
:underline="true"
size="small"
color="text-dark"
/>
<div v-if="multipleValues === true">
<div

View file

@ -8,7 +8,7 @@
>
<template slot="content">
<div :class="$style.container">
<n8n-input-label :label="$locale.baseText('importCurlModal.input.label')">
<n8n-input-label :label="$locale.baseText('importCurlModal.input.label')" color="text-dark">
<n8n-input
:value="curlCommand"
type="textarea"

View file

@ -14,6 +14,7 @@
:showMappingHint="draggableHintShown"
:distanceFromActive="currentNodeDepth"
paneType="input"
@itemHover="$emit('itemHover', $event)"
@linkRun="onLinkRun"
@unlinkRun="onUnlinkRun"
@runChange="onRunIndexChange"

View file

@ -5,6 +5,7 @@
:tooltipText="$locale.nodeText().inputLabelDescription(parameter, path)"
:underline="true"
size="small"
color="text-dark"
/>
<div v-for="(value, index) in values" :key="index" class="duplicate-parameter-item" :class="parameter.type">

View file

@ -13,6 +13,7 @@
:bold="false"
:set="issues = getIssues(credentialTypeDescription.name)"
size="small"
color="text-dark"
>
<div v-if="isReadOnly">
<n8n-input

View file

@ -62,6 +62,7 @@
@select="onInputSelect"
@execute="onNodeExecute"
@tableMounted="onInputTableMounted"
@itemHover="onInputItemHover"
/>
</template>
<template #output>
@ -76,6 +77,7 @@
@runChange="onRunOutputIndexChange"
@openSettings="openSettings"
@tableMounted="onOutputTableMounted"
@itemHover="onOutputItemHover"
/>
</template>
<template #main>
@ -111,7 +113,7 @@ import {
IRunExecutionData,
Workflow,
} from 'n8n-workflow';
import { IExecutionResponse, INodeUi, IUpdateInformation } from '../Interface';
import { IExecutionResponse, INodeUi, IUpdateInformation, TargetItem } from '../Interface';
import { externalHooks } from '@/components/mixins/externalHooks';
import { nodeHelpers } from '@/components/mixins/nodeHelpers';
@ -200,7 +202,7 @@ export default mixins(
this.executionWaitingForWebhook
);
},
activeNode(): INodeUi {
activeNode(): INodeUi | null {
return this.$store.getters.activeNode;
},
inputNodeName(): string | undefined {
@ -394,8 +396,45 @@ export default mixins(
maxInputRun() {
this.runInputIndex = -1;
},
inputNodeName(nodeName: string | undefined) {
this.$store.commit('ui/setInputNodeName', nodeName);
},
inputRun() {
this.$store.commit('ui/setInputRunIndex', this.inputRun);
},
},
methods: {
onInputItemHover(e: {itemIndex: number, outputIndex: number} | null) {
if (!this.inputNodeName) {
return;
}
if (e === null) {
this.$store.commit('ui/setHoveringItem', null);
return;
}
const item: TargetItem = {
nodeName: this.inputNodeName,
runIndex: this.inputRun,
outputIndex: e.outputIndex,
itemIndex: e.itemIndex,
};
this.$store.commit('ui/setHoveringItem', item);
},
onOutputItemHover(e: {itemIndex: number, outputIndex: number} | null) {
if (e === null || !this.activeNode) {
this.$store.commit('ui/setHoveringItem', null);
return;
}
const item: TargetItem = {
nodeName: this.activeNode.name,
runIndex: this.outputRun,
outputIndex: e.outputIndex,
itemIndex: e.itemIndex,
};
this.$store.commit('ui/setHoveringItem', item);
},
onInputTableMounted(e: { avgRowHeight: number }) {
this.avgInputRowHeight = e.avgRowHeight;
},
@ -410,13 +449,15 @@ export default mixins(
},
onFeatureRequestClick() {
window.open(this.featureRequestUrl, '_blank');
this.$telemetry.track('User clicked ndv link', {
node_type: this.activeNode.type,
workflow_id: this.$store.getters.workflowId,
session_id: this.sessionId,
pane: 'main',
type: 'i-wish-this-node-would',
});
if (this.activeNode) {
this.$telemetry.track('User clicked ndv link', {
node_type: this.activeNode.type,
workflow_id: this.$store.getters.workflowId,
session_id: this.sessionId,
pane: 'main',
type: 'i-wish-this-node-would',
});
}
},
onPanelsInit(e: { position: number }) {
this.mainPanelPosition = e.position;

View file

@ -15,6 +15,7 @@
@linkRun="onLinkRun"
@unlinkRun="onUnlinkRun"
@tableMounted="$emit('tableMounted', $event)"
@itemHover="$emit('itemHover', $event)"
ref="runData"
>
<template v-slot:header>

View file

@ -21,6 +21,7 @@
:value="value"
:displayTitle="displayTitle"
:expressionDisplayValue="expressionDisplayValue"
:expressionComputedValue="expressionEvaluated"
:isValueExpression="isValueExpression"
:isReadOnly="isReadOnly"
:parameterIssues="getIssues"
@ -37,7 +38,7 @@
:size="inputSize"
:type="getStringInputType"
:rows="getArgument('rows')"
:value="activeDrop || forceShowExpression? '': expressionDisplayValue"
:value="expressionDisplayValue"
:title="displayTitle"
@keydown.stop
/>
@ -297,6 +298,8 @@ import {
INodeParameters,
INodePropertyOptions,
Workflow,
INodeProperties,
INodePropertyCollection,
NodeParameterValueType,
} from 'n8n-workflow';
@ -316,12 +319,13 @@ import { externalHooks } from '@/components/mixins/externalHooks';
import { nodeHelpers } from '@/components/mixins/nodeHelpers';
import { showMessage } from '@/components/mixins/showMessage';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import { hasExpressionMapping, isValueExpression } from './helpers';
import { isResourceLocatorValue } from '@/typeGuards';
import mixins from 'vue-typed-mixins';
import { CUSTOM_API_CALL_KEY } from '@/constants';
import { mapGetters } from 'vuex';
import { hasExpressionMapping, isValueExpression } from './helpers';
import { isResourceLocatorValue } from '@/typeGuards';
import { PropType } from 'vue';
export default mixins(
externalHooks,
@ -344,21 +348,56 @@ export default mixins(
TextEdit,
ImportParameter,
},
props: [
'inputSize',
'isReadOnly',
'documentationUrl',
'parameter', // NodeProperties
'path', // string
'value',
'hideIssues', // boolean
'errorHighlight',
'isForCredential', // boolean
'eventSource', // string
'activeDrop',
'droppable',
'forceShowExpression',
],
props: {
isReadOnly: {
type: Boolean,
},
parameter: {
type: Object as PropType<INodeProperties>,
},
path: {
type: String,
},
value: {
type: [String, Number, Boolean, Array, Object] as PropType<NodeParameterValueType>,
},
hideLabel: {
type: Boolean,
},
droppable: {
type: Boolean,
},
activeDrop: {
type: Boolean,
},
forceShowExpression: {
type: Boolean,
},
hint: {
type: String as PropType<string | undefined>,
},
inputSize: {
type: String,
},
hideIssues: {
type: Boolean,
},
documentationUrl: {
type: String as PropType<string | undefined>,
},
errorHighlight: {
type: Boolean,
},
isForCredential: {
type: Boolean,
},
eventSource: {
type: String,
},
expressionEvaluated: {
type: String as PropType<string | undefined>,
},
},
data () {
return {
codeEditDialogVisible: false,
@ -419,12 +458,21 @@ export default mixins(
},
computed: {
...mapGetters('credentials', ['allCredentialTypes']),
expressionDisplayValue(): string {
if (this.activeDrop || this.forceShowExpression) {
return '';
}
const value = isResourceLocatorValue(this.value) ? this.value.value : this.value;
if (typeof value === 'string' && value.startsWith('=')) {
return value.slice(1);
}
return '';
},
isValueExpression(): boolean {
return isValueExpression(this.parameter, this.value);
},
areExpressionsDisabled(): boolean {
return this.$store.getters['ui/areExpressionsDisabled'];
},
codeAutocomplete (): string | undefined {
return this.getArgument('codeAutocomplete') as string | undefined;
},
@ -486,9 +534,9 @@ export default mixins(
let returnValue;
if (this.isValueExpression === false) {
returnValue = this.isResourceLocatorParameter ? (this.value ? this.value.value: '') : this.value;
returnValue = this.isResourceLocatorParameter ? (isResourceLocatorValue(this.value) ? this.value.value: '') : this.value;
} else {
returnValue = this.expressionValueComputed;
returnValue = this.expressionEvaluated;
}
if (this.parameter.type === 'credentialsSelect') {
@ -519,39 +567,6 @@ export default mixins(
return returnValue;
},
expressionDisplayValue (): string {
const value = this.displayValue;
// address type errors for text input
if (typeof value === 'number' || typeof value === 'boolean') {
return JSON.stringify(value);
}
if (value === null) {
return '';
}
return value;
},
expressionValueComputed (): NodeParameterValue | string[] | null {
if (this.areExpressionsDisabled) {
return this.value;
}
if (this.node === null) {
return null;
}
let computedValue: NodeParameterValue;
try {
computedValue = this.resolveExpression(this.value.value || this.value) as NodeParameterValue;
} catch (error) {
computedValue = `[${this.$locale.baseText('parameterInput.error')}}: ${error.message}]`;
}
return computedValue;
},
getStringInputType () {
if (this.getArgument('password') === true) {
return 'password';
@ -562,7 +577,7 @@ export default mixins(
return 'textarea';
}
if (this.parameter.type === 'code') {
if (this.parameter.typeOptions && this.parameter.typeOptions.editor === 'code') {
return 'textarea';
}
@ -587,13 +602,14 @@ export default mixins(
} else if (
['options', 'multiOptions'].includes(this.parameter.type) &&
this.remoteParameterOptionsLoading === false &&
this.remoteParameterOptionsLoadingIssues === null
this.remoteParameterOptionsLoadingIssues === null &&
this.parameterOptions
) {
// Check if the value resolves to a valid option
// Currently it only displays an error in the node itself in
// case the value is not valid. The workflow can still be executed
// and the error is not displayed on the node in the workflow
const validOptions = this.parameterOptions!.map((options: INodePropertyOptions) => options.value);
const validOptions = this.parameterOptions.map((options) => (options as INodePropertyOptions).value);
const checkValues: string[] = [];
@ -640,7 +656,7 @@ export default mixins(
editorType (): string {
return this.getArgument('editor') as string;
},
parameterOptions (): INodePropertyOptions[] {
parameterOptions (): Array<INodePropertyOptions | INodeProperties | INodePropertyCollection> | undefined {
if (this.hasRemoteMethod === false) {
// Options are already given
return this.parameter.options;
@ -828,10 +844,6 @@ export default mixins(
this.valueChanged(val);
},
openExpressionEdit() {
if (this.areExpressionsDisabled) {
return;
}
if (this.isValueExpression) {
this.expressionEditDialogVisible = true;
this.trackExpressionEditOpen();
@ -896,7 +908,7 @@ export default mixins(
this.$emit('textInput', parameterData);
},
valueChanged (value: string[] | string | number | boolean | Date | {} | null) {
valueChanged (value: NodeParameterValueType | {} | Date) {
if (this.parameter.name === 'nodeCredentialType') {
this.activeCredentialType = value as string;
}
@ -905,7 +917,7 @@ export default mixins(
value = value.toISOString();
}
if (this.parameter.type === 'color' && this.getArgument('showAlpha') === true && value !== null && value.toString().charAt(0) !== '#') {
if (this.parameter.type === 'color' && this.getArgument('showAlpha') === true && value !== null && value !== undefined && value.toString().charAt(0) !== '#') {
const newValue = this.rgbaToHex(value as string);
if (newValue !== null) {
this.tempValue = newValue;
@ -959,14 +971,14 @@ export default mixins(
this.trackExpressionEditOpen();
}, 375);
} else if (command === 'removeExpression') {
let value = this.expressionValueComputed;
let value: NodeParameterValueType = this.expressionEvaluated;
if (this.parameter.type === 'multiOptions' && typeof value === 'string') {
value = (value || '').split(',')
.filter((value) => (this.parameterOptions || []).find((option) => option.value === value));
.filter((value) => (this.parameterOptions || []).find((option) => (option as INodePropertyOptions).value === value));
}
if (this.isResourceLocatorParameter) {
if (this.isResourceLocatorParameter && isResourceLocatorValue(this.value)) {
this.valueChanged({ __rl: true, value, mode: this.value.mode });
} else {
this.valueChanged(typeof value !== 'undefined' ? value : null);

View file

@ -18,19 +18,18 @@
/>
</template>
<template>
<parameter-input
<parameter-input-wrapper
ref="param"
inputSize="large"
:parameter="parameter"
:value="value"
:path="parameter.name"
:hideIssues="true"
:displayOptions="true"
:documentationUrl="documentationUrl"
:errorHighlight="showRequiredErrors"
:isForCredential="true"
:eventSource="eventSource"
:isValueExpression="isValueExpression"
:hint="!showRequiredErrors? hint: ''"
@focus="onFocus"
@blur="onBlur"
@textInput="valueChanged"
@ -44,30 +43,27 @@
</n8n-link>
</n8n-text>
</div>
<input-hint :class="$style.hint" :hint="$locale.credText().hint(parameter)" />
</template>
</n8n-input-label>
</template>
<script lang="ts">
import { IUpdateInformation } from '@/Interface';
import ParameterInput from './ParameterInput.vue';
import ParameterOptions from './ParameterOptions.vue';
import InputHint from './ParameterInputHint.vue';
import Vue from 'vue';
import Vue, { PropType } from 'vue';
import ParameterInputWrapper from './ParameterInputWrapper.vue';
import { isValueExpression } from './helpers';
import { INodeParameterResourceLocator, INodeProperties } from 'n8n-workflow';
export default Vue.extend({
name: 'ParameterInputExpanded',
name: 'parameter-input-expanded',
components: {
ParameterInput,
InputHint,
ParameterOptions,
ParameterInputWrapper,
},
props: {
parameter: {
type: Object as () => INodeProperties,
type: Object as PropType<INodeProperties>,
},
value: {
},
@ -106,6 +102,13 @@ export default Vue.extend({
return false;
},
hint(): string | null {
if (this.isValueExpression) {
return null;
}
return this.$locale.credText().hint(this.parameter);
},
isValueExpression (): boolean {
return isValueExpression(this.parameter, this.value as string | INodeParameterResourceLocator);
},

View file

@ -6,6 +6,7 @@
:showOptions="menuExpanded || focused || forceShowExpression"
:bold="false"
size="small"
color="text-dark"
>
<template #options>
<parameter-options
@ -34,16 +35,16 @@
:buttons="dataMappingTooltipButtons"
>
<span slot="content" v-html="$locale.baseText(`dataMapping.${displayMode}Hint`, { interpolate: { name: parameter.displayName } })" />
<parameter-input
<parameter-input-wrapper
ref="param"
:parameter="parameter"
:value="value"
:displayOptions="displayOptions"
:path="path"
:isReadOnly="isReadOnly"
:droppable="droppable"
:activeDrop="activeDrop"
:forceShowExpression="forceShowExpression"
:hint="hint"
@valueChanged="valueChanged"
@focus="onFocus"
@blur="onBlur"
@ -53,7 +54,6 @@
</n8n-tooltip>
</template>
</draggable-target>
<input-hint :class="$style.hint" :hint="$locale.nodeText().hint(parameter, path)" />
</template>
</n8n-input-label>
</template>
@ -68,7 +68,6 @@ import {
IUpdateInformation,
} from '@/Interface';
import ParameterInput from '@/components/ParameterInput.vue';
import InputHint from './ParameterInputHint.vue';
import ParameterOptions from './ParameterOptions.vue';
import DraggableTarget from '@/components/DraggableTarget.vue';
@ -76,6 +75,7 @@ import mixins from 'vue-typed-mixins';
import { showMessage } from './mixins/showMessage';
import { LOCAL_STORAGE_MAPPING_FLAG } from '@/constants';
import { hasExpressionMapping } from './helpers';
import ParameterInputWrapper from './ParameterInputWrapper.vue';
import { hasOnlyListMode } from './ResourceLocator/helpers';
import { INodePropertyMode } from 'n8n-workflow';
import { isResourceLocatorValue } from '@/typeGuards';
@ -87,10 +87,10 @@ export default mixins(
.extend({
name: 'parameter-input-full',
components: {
ParameterInput,
InputHint,
ParameterOptions,
DraggableTarget,
ParameterInputWrapper,
},
data() {
return {
@ -125,6 +125,9 @@ export default mixins(
node (): INodeUi | null {
return this.$store.getters.activeNode;
},
hint (): string | null {
return this.$locale.nodeText().hint(this.parameter, this.path);
},
isResourceLocator (): boolean {
return this.parameter.type === 'resourceLocator';
},
@ -256,9 +259,3 @@ export default mixins(
},
});
</script>
<style lang="scss" module>
.hint {
margin-top: var(--spacing-4xs);
}
</style>

View file

@ -1,9 +1,7 @@
<template>
<div>
<n8n-text size="xsmall" color="text-base" v-if="hint">
<div ref="hint" v-html="hint"></div>
</n8n-text>
</div>
<n8n-text size="small" color="text-base" tag="div" v-if="hint">
<div ref="hint" :class="{[$style.hint]: true, [$style.highlight]: highlight}" v-html="hint"></div>
</n8n-text>
</template>
<script lang="ts">
@ -11,7 +9,14 @@ import Vue from "vue";
export default Vue.extend({
name: 'InputHint',
props: ['hint'],
props: {
hint: {
type: String,
},
highlight: {
type: Boolean,
},
},
mounted(){
if(this.$refs.hint){
(this.$refs.hint as Element).querySelectorAll('a').forEach(a => a.target = "_blank");
@ -20,3 +25,17 @@ export default Vue.extend({
});
</script>
<style lang="scss" module>
.hint {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.highlight {
color: var(--color-secondary);
}
</style>

View file

@ -45,6 +45,7 @@
:tooltipText="$locale.nodeText().inputLabelDescription(parameter, path)"
size="small"
:underline="true"
color="text-dark"
/>
<collection-parameter
v-if="parameter.type === 'collection'"

View file

@ -0,0 +1,210 @@
<template>
<div>
<parameter-input
ref="param"
:inputSize="inputSize"
:parameter="parameter"
:value="value"
:path="path"
:isReadOnly="isReadOnly"
:droppable="droppable"
:activeDrop="activeDrop"
:forceShowExpression="forceShowExpression"
:hideIssues="hideIssues"
:documentationUrl="documentationUrl"
:errorHighlight="errorHighlight"
:isForCredential="isForCredential"
:eventSource="eventSource"
:expressionEvaluated="expressionValueComputed"
@focus="onFocus"
@blur="onBlur"
@drop="onDrop"
@textInput="onTextInput"
@valueChanged="onValueChanged" />
<input-hint v-if="expressionOutput || parameterHint" :class="$style.hint" :highlight="!!(expressionOutput && targetItem)" :hint="expressionOutput || parameterHint" />
</div>
</template>
<script lang="ts">
import Vue, { PropType } from 'vue';
import ParameterInput from '@/components/ParameterInput.vue';
import InputHint from './ParameterInputHint.vue';
import mixins from 'vue-typed-mixins';
import { showMessage } from './mixins/showMessage';
import { INodeProperties, INodePropertyMode, IRunData, isResourceLocatorValue, NodeParameterValue, NodeParameterValueType } from 'n8n-workflow';
import { INodeUi, IUiState, IUpdateInformation, TargetItem } from '@/Interface';
import { workflowHelpers } from './mixins/workflowHelpers';
import { isValueExpression } from './helpers';
export default mixins(
showMessage,
workflowHelpers,
)
.extend({
name: 'parameter-input-wrapper',
components: {
ParameterInput,
InputHint,
},
mounted() {
this.$on('optionSelected', this.optionSelected);
},
props: {
isReadOnly: {
type: Boolean,
},
parameter: {
type: Object as PropType<INodeProperties>,
},
path: {
type: String,
},
value: {
type: [String, Number, Boolean, Array, Object] as PropType<NodeParameterValueType>,
},
hideLabel: {
type: Boolean,
},
droppable: {
type: Boolean,
},
activeDrop: {
type: Boolean,
},
forceShowExpression: {
type: Boolean,
},
hint: {
type: String,
required: false,
},
inputSize: {
type: String,
},
hideIssues: {
type: Boolean,
},
documentationUrl: {
type: String as PropType<string | undefined>,
},
errorHighlight: {
type: Boolean,
},
isForCredential: {
type: Boolean,
},
eventSource: {
type: String,
},
},
computed: {
isValueExpression () {
return isValueExpression(this.parameter, this.value);
},
activeNode(): INodeUi | null {
return this.$store.getters.activeNode;
},
selectedRLMode(): INodePropertyMode | undefined {
if (typeof this.value !== 'object' ||this.parameter.type !== 'resourceLocator' || !isResourceLocatorValue(this.value)) {
return undefined;
}
const mode = this.value.mode;
if (mode) {
return this.parameter.modes?.find((m: INodePropertyMode) => m.name === mode);
}
return undefined;
},
parameterHint(): string | undefined {
if (this.isValueExpression) {
return undefined;
}
if (this.selectedRLMode && this.selectedRLMode.hint) {
return this.selectedRLMode.hint;
}
return this.hint;
},
targetItem(): TargetItem | null {
return this.$store.getters['ui/hoveringItem'];
},
expressionValueComputed (): string | null {
const inputNodeName: string | undefined = this.$store.getters['ui/ndvInputNodeName'];
const value = isResourceLocatorValue(this.value)? this.value.value: this.value;
if (this.activeNode === null || !this.isValueExpression || typeof value !== 'string') {
return null;
}
const inputRunIndex: number | undefined = this.$store.getters['ui/ndvInputRunIndex'];
const inputBranchIndex: number | undefined = this.$store.getters['ui/ndvInputBranchIndex'];
let computedValue: NodeParameterValue;
try {
const targetItem = this.targetItem ?? undefined;
computedValue = this.resolveExpression(value, undefined, {targetItem, inputNodeName, inputRunIndex, inputBranchIndex});
if (computedValue === null) {
return null;
}
if (typeof computedValue === 'string' && computedValue.trim().length === 0) {
computedValue = this.$locale.baseText('parameterInput.emptyString');
}
} catch (error) {
computedValue = `[${this.$locale.baseText('parameterInput.error')}}: ${error.message}]`;
}
return typeof computedValue === 'string' ? computedValue : JSON.stringify(computedValue);
},
expressionOutput(): string | null {
if (this.isValueExpression && this.expressionValueComputed) {
const inputData = this.$store.getters['ui/ndvInputData'];
if (!inputData || (inputData && inputData.length <= 1)) {
return this.expressionValueComputed;
}
return this.$locale.baseText(`parameterInput.expressionResult`, {
interpolate: {
result: this.expressionValueComputed,
},
});
}
return null;
},
},
methods: {
onFocus() {
this.$emit('focus');
},
onBlur() {
this.$emit('blur');
},
onDrop(data: string) {
this.$emit('drop', data);
},
optionSelected(command: string) {
if (this.$refs.param) {
(this.$refs.param as Vue).$emit('optionSelected', command);
}
},
onValueChanged(parameterData: IUpdateInformation) {
this.$emit('valueChanged', parameterData);
},
onTextInput(parameterData: IUpdateInformation) {
this.$emit('textInput', parameterData);
},
},
});
</script>
<style lang="scss" module>
.hint {
margin-top: var(--spacing-4xs);
}
.hovering {
color: var(--color-secondary);
}
</style>

View file

@ -82,7 +82,7 @@
v-if="isValueExpression || droppable || forceShowExpression"
type="text"
:size="inputSize"
:value="activeDrop || forceShowExpression ? '' : expressionDisplayValue"
:value="expressionDisplayValue"
:title="displayTitle"
@keydown.stop
ref="input"
@ -137,7 +137,6 @@
</div>
</div>
</resource-locator-dropdown>
<parameter-input-hint v-if="infoText" class="mt-4xs" :hint="infoText" />
</div>
</template>
@ -163,7 +162,6 @@ import {
import DraggableTarget from '@/components/DraggableTarget.vue';
import ExpressionEdit from '@/components/ExpressionEdit.vue';
import ParameterIssues from '@/components/ParameterIssues.vue';
import ParameterInputHint from '@/components/ParameterInputHint.vue';
import ResourceLocatorDropdown from './ResourceLocatorDropdown.vue';
import Vue, { PropType } from 'vue';
import { INodeUi, IResourceLocatorReqParams, IResourceLocatorResultExpanded } from '@/Interface';
@ -172,7 +170,6 @@ import stringify from 'fast-json-stable-stringify';
import { workflowHelpers } from '../mixins/workflowHelpers';
import { nodeHelpers } from '../mixins/nodeHelpers';
import { getAppNameFromNodeName } from '../helpers';
import { type } from 'os';
import { isResourceLocatorValue } from '@/typeGuards';
interface IResourceLocatorQuery {
@ -188,7 +185,6 @@ export default mixins(debounceHelper, workflowHelpers, nodeHelpers).extend({
DraggableTarget,
ExpressionEdit,
ParameterIssues,
ParameterInputHint,
ResourceLocatorDropdown,
},
props: {
@ -216,7 +212,7 @@ export default mixins(debounceHelper, workflowHelpers, nodeHelpers).extend({
type: String,
default: '',
},
expressionDisplayValue: {
expressionComputedValue: {
type: String,
default: '',
},
@ -224,6 +220,9 @@ export default mixins(debounceHelper, workflowHelpers, nodeHelpers).extend({
type: Boolean,
default: false,
},
expressionDisplayValue: {
type: String,
},
forceShowExpression: {
type: Boolean,
default: false,
@ -298,9 +297,6 @@ export default mixins(debounceHelper, workflowHelpers, nodeHelpers).extend({
return defaults[this.selectedMode] || '';
},
infoText(): string {
return this.currentMode.hint ? this.currentMode.hint : '';
},
currentMode(): INodePropertyMode {
return this.findModeByName(this.selectedMode) || ({} as INodePropertyMode);
},
@ -327,8 +323,8 @@ export default mixins(debounceHelper, workflowHelpers, nodeHelpers).extend({
}
if (this.selectedMode === 'url') {
if (this.isValueExpression && typeof this.expressionDisplayValue === 'string' && this.expressionDisplayValue.startsWith('http')) {
return this.expressionDisplayValue;
if (this.isValueExpression && typeof this.expressionComputedValue === 'string' && this.expressionComputedValue.startsWith('http')) {
return this.expressionComputedValue;
}
if (typeof this.valueToDisplay === 'string' && this.valueToDisplay.startsWith('http')) {
@ -337,7 +333,7 @@ export default mixins(debounceHelper, workflowHelpers, nodeHelpers).extend({
}
if (this.currentMode.url) {
const value = this.isValueExpression? this.expressionDisplayValue : this.valueToDisplay;
const value = this.isValueExpression? this.expressionComputedValue : this.valueToDisplay;
if (typeof value === 'string') {
const expression = this.currentMode.url.replace(/\{\{\$value\}\}/g, value);
const resolved = this.resolveExpression(expression);

View file

@ -220,8 +220,11 @@
:distanceFromActive="distanceFromActive"
:showMappingHint="showMappingHint"
:runIndex="runIndex"
:pageOffset="currentPageOffset"
:totalRuns="maxRunIndex"
:hasDefaultHoverState="paneType === 'input'"
@mounted="$emit('tableMounted', $event)"
@activeRowChanged="onItemHover"
/>
<run-data-json
@ -419,7 +422,7 @@ export default mixins(
type: String,
},
overrideOutputs: {
type: Array,
type: Array as PropType<number[]>,
},
mappingEnabled: {
type: Boolean,
@ -463,6 +466,10 @@ export default mixins(
this.showPinDataDiscoveryTooltip(this.jsonData);
}
}
this.$store.commit('ui/setNDVBranchIndex', {
pane: this.paneType,
branchIndex: this.currentOutputIndex,
});
},
destroyed() {
this.hidePinDataDiscoveryTooltip();
@ -561,6 +568,9 @@ export default mixins(
return 0;
},
currentPageOffset(): number {
return this.pageSize * (this.currentPage - 1);
},
maxRunIndex (): number {
if (this.node === null) {
return 0;
@ -662,6 +672,17 @@ export default mixins(
},
},
methods: {
onItemHover(itemIndex: number | null) {
if (itemIndex === null) {
this.$emit('itemHover', null);
return;
}
this.$emit('itemHover', {
outputIndex: this.currentOutputIndex,
itemIndex,
});
},
onClickDataPinningDocsLink() {
this.$telemetry.track('User clicked ndv link', {
workflow_id: this.$store.getters.workflowId,
@ -1094,6 +1115,12 @@ export default mixins(
this.onDisplayModeChange('table');
}
},
currentOutputIndex(branchIndex: number) {
this.$store.commit('ui/setNDVBranchIndex', {
pane: this.paneType,
branchIndex,
});
},
},
});
</script>

View file

@ -5,8 +5,13 @@
<th :class="$style.emptyCell"></th>
<th :class="$style.tableRightMargin"></th>
</tr>
<tr v-for="(row, index1) in tableData.data" :key="index1">
<td>
<tr v-for="(row, index1) in tableData.data" :key="index1" :class="{[$style.hoveringRow]: isHoveringRow(index1)}">
<td
:data-row="index1"
:data-col="0"
@mouseenter="onMouseEnterCell"
@mouseleave="onMouseLeaveCell"
>
<n8n-text>{{ $locale.baseText('runData.emptyItemHint') }}</n8n-text>
</td>
<td :class="$style.tableRightMargin"></td>
@ -88,10 +93,11 @@
</div>
</template>
<template>
<tr v-for="(row, index1) in tableData.data" :key="index1">
<tr v-for="(row, index1) in tableData.data" :key="index1" :class="{[$style.hoveringRow]: isHoveringRow(index1)}">
<td
v-for="(data, index2) in row"
:key="index2"
:data-row="index1"
:data-col="index2"
@mouseenter="onMouseEnterCell"
@mouseleave="onMouseLeaveCell"
@ -136,9 +142,10 @@
<script lang="ts">
/* eslint-disable prefer-spread */
import { INodeUi, IRootState, ITableData, IUiState } from '@/Interface';
import { getPairedItemId } from '@/pairedItemUtils';
import Vue, { PropType } from 'vue';
import mixins from 'vue-typed-mixins';
import { INodeUi, ITableData } from '@/Interface';
import { GenericValue, IDataObject, INodeExecutionData } from 'n8n-workflow';
import Draggable from './Draggable.vue';
import { shorten } from './helpers';
@ -163,9 +170,18 @@ export default mixins(externalHooks).extend({
runIndex: {
type: Number,
},
outputIndex: {
type: Number,
},
totalRuns: {
type: Number,
},
pageOffset: {
type: Number,
},
hasDefaultHoverState: {
type: Boolean,
},
},
data() {
return {
@ -174,6 +190,7 @@ export default mixins(externalHooks).extend({
draggingPath: null as null | string,
hoveringPath: null as null | string,
mappingHintVisible: false,
activeRow: null as number | null,
};
},
mounted() {
@ -187,12 +204,35 @@ export default mixins(externalHooks).extend({
}
},
computed: {
hoveringItem(): IUiState['ndv']['hoveringItem'] {
return this.$store.getters['ui/hoveringItem'];
},
pairedItemMappings(): IRootState['workflowExecutionPairedItemMappings'] {
return this.$store.getters['workflowExecutionPairedItemMappings'];
},
tableData(): ITableData {
return this.convertToTable(this.inputData);
},
},
methods: {
shorten,
isHoveringRow(row: number): boolean {
if (row === this.activeRow) {
return true;
}
const itemIndex = this.pageOffset + row;
if (itemIndex === 0 && !this.hoveringItem && this.hasDefaultHoverState && this.distanceFromActive === 1) {
return true;
}
const itemNodeId = getPairedItemId(this.node.name, this.runIndex || 0, this.outputIndex || 0, itemIndex);
if (!this.hoveringItem || !this.pairedItemMappings[itemNodeId]) {
return false;
}
const hoveringItemId = getPairedItemId(this.hoveringItem.nodeName, this.hoveringItem.runIndex, this.hoveringItem.outputIndex, this.hoveringItem.itemIndex);
return this.pairedItemMappings[itemNodeId].has(hoveringItemId);
},
onMouseEnterCell(e: MouseEvent) {
const target = e.target;
if (target && this.mappingEnabled) {
@ -201,9 +241,19 @@ export default mixins(externalHooks).extend({
this.activeColumn = parseInt(col, 10);
}
}
if (target) {
const row = (target as HTMLElement).dataset.row;
if (row && !isNaN(parseInt(row, 10))) {
this.activeRow = parseInt(row, 10);
this.$emit('activeRowChanged', this.pageOffset + this.activeRow);
}
}
},
onMouseLeaveCell() {
this.activeColumn = -1;
this.activeRow = null;
this.$emit('activeRowChanged', null);
},
onMouseEnterKey(path: string[], colIndex: number) {
this.hoveringPath = this.getCellExpression(path, colIndex);
@ -438,6 +488,7 @@ export default mixins(externalHooks).extend({
position: sticky;
top: 0;
color: var(--color-text-dark);
z-index: 1;
}
td {
@ -449,6 +500,27 @@ export default mixins(externalHooks).extend({
white-space: pre-wrap;
}
td:first-child, td:nth-last-child(2) {
position: relative;
z-index: 0;
&:after { // add border without shifting content
content: '';
position: absolute;
height: 100%;
width: 2px;
top: 0;
}
}
td:nth-last-child(2):after {
right: -1px;
}
td:first-child:after {
left: -1px;
}
th:last-child,
td:last-child {
border-right: var(--border-base);
@ -565,9 +637,16 @@ export default mixins(externalHooks).extend({
.tableRightMargin {
// becomes necessary with large tables
background-color: var(--color-background-base) !important;
width: var(--spacing-s);
border-right: none !important;
border-top: none !important;
border-bottom: none !important;
}
.hoveringRow {
td:first-child:after, td:nth-last-child(2):after {
background-color: var(--color-secondary);
}
}
</style>

View file

@ -43,6 +43,7 @@ import {
XYPosition,
ITag,
IUpdateInformation,
TargetItem,
} from '../../Interface';
import { externalHooks } from '@/components/mixins/externalHooks';
@ -54,6 +55,7 @@ import { isEqual } from 'lodash';
import mixins from 'vue-typed-mixins';
import { v4 as uuid } from 'uuid';
import { getSourceItems } from '@/pairedItemUtils';
let cachedWorkflowKey: string | null = '';
let cachedWorkflow: Workflow | null = null;
@ -98,6 +100,7 @@ export const workflowHelpers = mixins(
if (!workflowRunData[parentNodeName] ||
workflowRunData[parentNodeName].length <= runIndex ||
!workflowRunData[parentNodeName][runIndex] ||
!workflowRunData[parentNodeName][runIndex].hasOwnProperty('data') ||
workflowRunData[parentNodeName][runIndex].data === undefined ||
!workflowRunData[parentNodeName][runIndex].data!.hasOwnProperty(inputName)
@ -526,24 +529,47 @@ export const workflowHelpers = mixins(
},
resolveParameter(parameter: NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[]) {
const itemIndex = 0;
resolveParameter(parameter: NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[], opts: {targetItem?: TargetItem, inputNodeName?: string, inputRunIndex?: number, inputBranchIndex?: number} = {}): IDataObject | null {
let itemIndex = opts?.targetItem?.itemIndex || 0;
const inputName = 'main';
const activeNode = this.$store.getters.activeNode;
const workflow = this.getCurrentWorkflow();
const parentNode = workflow.getParentNodes(activeNode.name, inputName, 1);
const workflowRunData = this.$store.getters.getWorkflowRunData as IRunData | null;
let parentNode = workflow.getParentNodes(activeNode.name, inputName, 1);
const executionData = this.$store.getters.getWorkflowExecution as IExecutionResponse | null;
const workflowRunData = this.$store.getters.getWorkflowRunData as IRunData | null;
let runIndexParent = 0;
if (workflowRunData !== null && parentNode.length) {
const firstParentWithWorkflowRunData = parentNode.find((parentNodeName) => workflowRunData[parentNodeName]);
if (firstParentWithWorkflowRunData) {
runIndexParent = workflowRunData[firstParentWithWorkflowRunData].length - 1;
if (opts?.inputNodeName && !parentNode.includes(opts.inputNodeName)) {
return null;
}
let runIndexParent = opts?.inputRunIndex ?? 0;
const nodeConnection = workflow.getNodeConnectionIndexes(activeNode!.name, parentNode[0]);
if (opts.targetItem && opts?.targetItem?.nodeName === activeNode.name && executionData) {
const sourceItems = getSourceItems(executionData, opts.targetItem);
if (!sourceItems.length) {
return null;
}
parentNode = [sourceItems[0].nodeName];
runIndexParent = sourceItems[0].runIndex;
itemIndex = sourceItems[0].itemIndex;
if (nodeConnection) {
nodeConnection.sourceIndex = sourceItems[0].outputIndex;
}
} else {
parentNode = opts.inputNodeName ? [opts.inputNodeName] : parentNode;
if (nodeConnection) {
nodeConnection.sourceIndex = opts.inputBranchIndex ?? nodeConnection.sourceIndex;
}
if (opts?.inputRunIndex === undefined && workflowRunData !== null && parentNode.length) {
const firstParentWithWorkflowRunData = parentNode.find((parentNodeName) => workflowRunData[parentNodeName]);
if (firstParentWithWorkflowRunData) {
runIndexParent = workflowRunData[firstParentWithWorkflowRunData].length - 1;
}
}
}
const nodeConnection = workflow.getNodeConnectionIndexes(activeNode!.name, parentNode[0]);
let connectionInputData = this.connectionInputData(parentNode, activeNode.name, inputName, runIndexParent, nodeConnection);
let runExecutionData: IRunExecutionData;
@ -601,8 +627,8 @@ export const workflowHelpers = mixins(
$resumeWebhookUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
};
let runIndexCurrent = 0;
if (workflowRunData !== null && workflowRunData[activeNode.name]) {
let runIndexCurrent = opts?.targetItem?.runIndex ?? 0;
if (opts?.targetItem === undefined && workflowRunData !== null && workflowRunData[activeNode.name]) {
runIndexCurrent = workflowRunData[activeNode.name].length -1;
}
const executeData = this.executeData(parentNode, activeNode.name, inputName, runIndexCurrent);
@ -610,12 +636,15 @@ export const workflowHelpers = mixins(
return workflow.expression.getParameterValue(parameter, runExecutionData, runIndexCurrent, itemIndex, activeNode.name, connectionInputData, 'manual', this.$store.getters.timezone, additionalKeys, executeData, false) as IDataObject;
},
resolveExpression(expression: string, siblingParameters: INodeParameters = {}) {
resolveExpression(expression: string, siblingParameters: INodeParameters = {}, opts: {targetItem?: TargetItem, inputNodeName?: string, inputRunIndex?: number, inputBranchIndex?: number, c?: number} = {}) {
const parameters = {
'__xxxxxxx__': expression,
...siblingParameters,
};
const returnData = this.resolveParameter(parameters) as IDataObject;
const returnData: IDataObject | null = this.resolveParameter(parameters, opts);
if (!returnData) {
return null;
}
if (typeof returnData['__xxxxxxx__'] === 'object') {
const workflow = this.getCurrentWorkflow();

View file

@ -28,6 +28,7 @@ import {
import Vue from 'vue';
import { ActionContext, Module } from 'vuex';
import {
IExecutionResponse,
IFakeDoor,
IFakeDoorLocation,
IRootState,
@ -117,12 +118,16 @@ const module: Module<IUiState, IRootState> = {
sessionId: '',
input: {
displayMode: 'table',
nodeName: undefined,
run: undefined,
branch: undefined,
data: {
isEmpty: true,
},
},
output: {
displayMode: 'table',
branch: undefined,
data: {
isEmpty: true,
},
@ -133,6 +138,7 @@ const module: Module<IUiState, IRootState> = {
},
focusedMappableInput: '',
mappingTelemetry: {},
hoveringItem: null,
},
mainPanelPosition: 0.5,
draggable: {
@ -174,8 +180,17 @@ const module: Module<IUiState, IRootState> = {
],
},
getters: {
areExpressionsDisabled(state: IUiState) {
return state.currentView === VIEWS.DEMO;
ndvInputData: (state: IUiState, getters, rootState: IRootState, rootGetters) => {
const executionData = rootGetters.getWorkflowExecution as IExecutionResponse | null;
const inputNodeName: string | undefined = state.ndv.input.nodeName;
const inputRunIndex: number = state.ndv.input.run ?? 0;
const inputBranchIndex: number = state.ndv.input.branch?? 0;
if (!executionData || !inputNodeName || inputRunIndex === undefined || inputBranchIndex === undefined) {
return [];
}
return executionData.data?.resultData?.runData?.[inputNodeName]?.[inputRunIndex]?.data?.main?.[inputBranchIndex];
},
isVersionsOpen: (state: IUiState) => {
return state.modals[VERSIONS_MODAL_KEY].open;
@ -230,9 +245,19 @@ const module: Module<IUiState, IRootState> = {
mappingTelemetry: (state: IUiState) => state.ndv.mappingTelemetry,
getCurrentView: (state: IUiState) => state.currentView,
isNodeView: (state: IUiState) => [VIEWS.NEW_WORKFLOW.toString(), VIEWS.WORKFLOW.toString(), VIEWS.EXECUTION.toString()].includes(state.currentView),
hoveringItem: (state: IUiState) => state.ndv.hoveringItem,
ndvInputNodeName: (state: IUiState) => state.ndv.input.nodeName,
ndvInputRunIndex: (state: IUiState) => state.ndv.input.run,
ndvInputBranchIndex: (state: IUiState) => state.ndv.input.branch,
getNDVDataIsEmpty: (state: IUiState) => (panel: 'input' | 'output'): boolean => state.ndv[panel].data.isEmpty,
},
mutations: {
setInputNodeName: (state: IUiState, name: string | undefined) => {
Vue.set(state.ndv.input, 'nodeName', name);
},
setInputRunIndex: (state: IUiState, run?: string) => {
Vue.set(state.ndv.input, 'run', run);
},
setMainPanelDimensions: (state: IUiState, params: { panelType:string, dimensions: { relativeLeft?: number, relativeRight?: number, relativeWidth?: number }}) => {
Vue.set(
state.mainPanelDimensions,
@ -337,6 +362,12 @@ const module: Module<IUiState, IRootState> = {
resetMappingTelemetry(state: IUiState) {
state.ndv.mappingTelemetry = {};
},
setHoveringItem(state: IUiState, item: null | IUiState['ndv']['hoveringItem']) {
Vue.set(state.ndv, 'hoveringItem', item);
},
setNDVBranchIndex(state: IUiState, e: {pane: 'input' | 'output', branchIndex: number}) {
Vue.set(state.ndv[e.pane], 'branch', e.branchIndex);
},
setNDVPanelDataIsEmpty(state: IUiState, payload: {panel: 'input' | 'output', isEmpty: boolean}) {
Vue.set(state.ndv[payload.panel].data, 'isEmpty', payload.isEmpty);
},

View file

@ -0,0 +1,159 @@
import { INodeExecutionData, IPairedItemData, IRunData, ITaskData } from "n8n-workflow";
import { IExecutionResponse, TargetItem } from "./Interface";
import { isNotNull } from "./typeGuards";
export function getPairedItemId(node: string, run: number, output: number, item: number): string {
return `${node}_r${run}_o${output}_i${item}`;
}
export function getSourceItems(data: IExecutionResponse, target: TargetItem): TargetItem[] {
if (!data?.data?.resultData?.runData) {
return [];
}
const runData = data.data.resultData.runData;
const taskData: ITaskData | undefined = runData[target.nodeName]?.[target.runIndex];
const source = taskData?.source || [];
if (source.length === 0) {
return [];
}
const item = taskData?.data?.main?.[target.outputIndex]?.[target.itemIndex];
if (!item || item.pairedItem === undefined) {
return [];
}
const pairedItem: IPairedItemData[] = Array.isArray(item.pairedItem) ? item.pairedItem : (typeof item.pairedItem === 'object' ? [item.pairedItem] : [{item: item.pairedItem}]);
const sourceItems = pairedItem.map((item) => {
const input = item.input || 0;
return {
nodeName: source?.[input]?.previousNode,
runIndex: source?.[input]?.previousNodeRun || 0,
itemIndex: item.item,
outputIndex: source[input]?.previousNodeOutput || 0,
};
});
return sourceItems.filter((item): item is TargetItem => isNotNull(item));
}
function addPairing(paths: {[item: string]: string[][]}, pairedItemId: string, pairedItem: IPairedItemData, sources: ITaskData['source']) {
paths[pairedItemId] = paths[pairedItemId] || [];
const input = pairedItem.input || 0;
const sourceNode = sources[input]?.previousNode;
if (!sourceNode) { // trigger nodes for example
paths[pairedItemId].push([pairedItemId]);
return;
}
const sourceNodeOutput = sources[input]?.previousNodeOutput || 0;
const sourceNodeRun = sources[input]?.previousNodeRun || 0;
const sourceItem = getPairedItemId(sourceNode, sourceNodeRun, sourceNodeOutput, pairedItem.item);
if (!paths[sourceItem]) {
paths[sourceItem] = [[sourceItem]]; // pinned data case
}
paths[sourceItem]?.forEach((path) => {
paths?.[pairedItemId]?.push([...path, pairedItemId]);
});
}
function addPairedItemIdsRec(node: string, runIndex: number, runData: IRunData, seen: Set<string>, paths: {[item: string]: string[][]}, pinned: Set<string>) {
const key = `${node}_r${runIndex}`;
if (seen.has(key)) {
return;
}
seen.add(key);
if (pinned.has(node)) {
return;
}
const nodeRunData = runData[node];
if (!Array.isArray(nodeRunData)) {
return;
}
const data = nodeRunData[runIndex];
if (!data?.data?.main) {
return;
}
const sources = data.source || [];
sources.forEach((source) => {
if (source?.previousNode) {
addPairedItemIdsRec(source.previousNode, source.previousNodeRun ?? 0, runData, seen, paths, pinned);
}
});
const mainData = data.data.main || [];
mainData.forEach((outputData, output: number) => {
if (!outputData) {
return;
}
outputData.forEach((executionData, item: number) => {
const pairedItemId = getPairedItemId(node, runIndex, output, item);
if (!executionData.pairedItem) {
paths[pairedItemId] = [];
return;
}
const pairedItem = executionData.pairedItem;
if (Array.isArray(pairedItem)) {
pairedItem.forEach((item) => {
addPairing(paths, pairedItemId, item, sources);
});
return;
}
if (typeof pairedItem === 'object') {
addPairing(paths, pairedItemId, pairedItem, sources);
return;
}
addPairing(paths, pairedItemId, {item: pairedItem}, sources);
});
});
}
function getMapping(paths: {[item: string]: string[][]}): {[item: string]: Set<string>} {
const mapping: {[itemId: string]: Set<string>} = {};
Object.keys(paths).forEach((item) => {
paths?.[item]?.forEach((path) => {
path.forEach((otherItem) => {
if (otherItem !== item) {
mapping[otherItem] = mapping[otherItem] || new Set();
mapping[otherItem].add(item);
mapping[item] = mapping[item] || new Set();
mapping[item].add(otherItem);
}
});
});
});
return mapping;
}
export function getPairedItemsMapping(executionResponse: IExecutionResponse | null): {[itemId: string]: Set<string>} {
if (!executionResponse?.data?.resultData?.runData) {
return {};
}
const seen = new Set<string>();
const runData = executionResponse.data.resultData.runData;
const pinned = new Set(Object.keys(executionResponse.data.resultData.pinData || {}));
const paths: {[item: string]: string[][]} = {};
Object.keys(runData).forEach((node) => {
runData[node].forEach((_, runIndex: number) => {
addPairedItemIdsRec(node, runIndex, runData, seen, paths, pinned);
});
});
return getMapping(paths);
}

View file

@ -124,11 +124,11 @@ export class I18nClass {
* Hint for a top-level param.
*/
hint(
{ name: parameterName, hint }: { name: string; hint: string; },
{ name: parameterName, hint }: { name: string; hint?: string; },
) {
return context.dynamicRender({
key: `${credentialPrefix}.${parameterName}.hint`,
fallback: hint,
fallback: hint || '',
});
},
@ -174,11 +174,11 @@ export class I18nClass {
* Placeholder for a `string` param.
*/
placeholder(
{ name: parameterName, placeholder }: { name: string; placeholder: string; },
{ name: parameterName, placeholder }: { name: string; placeholder?: string; },
) {
return context.dynamicRender({
key: `${credentialPrefix}.${parameterName}.placeholder`,
fallback: placeholder,
fallback: placeholder || '',
});
},
};
@ -247,7 +247,7 @@ export class I18nClass {
* - For a `collection` or `fixedCollection`, the placeholder is the button text.
*/
placeholder(
parameter: { name: string; placeholder: string; type: string },
parameter: { name: string; placeholder?: string; type: string },
path: string,
) {
let middleKey = parameter.name;
@ -259,7 +259,7 @@ export class I18nClass {
return context.dynamicRender({
key: `${initialKey}.${middleKey}.placeholder`,
fallback: parameter.placeholder,
fallback: parameter.placeholder || '',
});
},

View file

@ -635,6 +635,8 @@
"onboardingWorkflow.stickyContent": "## 👇 Get started faster \nLightning tour of the key concepts [3 min] \n\n[![n8n quickstart video](/static/quickstart_thumbnail.png#full-width)](https://www.youtube.com/watch?v=RpjQTGKm-ok)",
"openWorkflow.workflowImportError": "Could not import workflow",
"openWorkflow.workflowNotFoundError": "Could not find workflow",
"parameterInput.expressionResult": "e.g. {result}",
"parameterInput.emptyString": "[empty]",
"parameterInput.customApiCall": "Custom API Call",
"parameterInput.error": "ERROR",
"parameterInput.expression": "Expression",

View file

@ -3,7 +3,6 @@ import Vuex from 'vuex';
import {
PLACEHOLDER_EMPTY_WORKFLOW_ID,
DEFAULT_NODETYPE_VERSION,
} from '@/constants';
import {
@ -48,6 +47,7 @@ import {stringSizeInBytes} from "@/components/helpers";
import {dataPinningEventBus} from "@/event-bus/data-pinning-event-bus";
import communityNodes from './modules/communityNodes';
import { isJsonKeyObject } from './utils';
import { getPairedItemsMapping } from './pairedItemUtils';
Vue.use(Vuex);
@ -77,6 +77,7 @@ const state: IRootState = {
oauthCallbackUrls: {},
n8nMetadata: {},
workflowExecutionData: null,
workflowExecutionPairedItemMappings: {},
lastSelectedNode: null,
lastSelectedNodeOutputIndex: null,
nodeViewOffsetPosition: [0, 0],
@ -379,6 +380,8 @@ export const store = new Vuex.Store({
Vue.set(state.workflow.pinData, nameData.new, state.workflow.pinData[nameData.old]);
Vue.delete(state.workflow.pinData, nameData.old);
}
state.workflowExecutionPairedItemMappings = getPairedItemsMapping(state.workflowExecutionData);
},
resetAllNodesIssues(state) {
@ -633,6 +636,7 @@ export const store = new Vuex.Store({
setWorkflowExecutionData(state, workflowResultData: IExecutionResponse | null) {
state.workflowExecutionData = workflowResultData;
state.workflowExecutionPairedItemMappings = getPairedItemsMapping(state.workflowExecutionData);
},
addNodeExecutionData(state, pushData: IPushDataNodeExecuteAfter): void {
if (state.workflowExecutionData === null || !state.workflowExecutionData.data) {
@ -642,6 +646,7 @@ export const store = new Vuex.Store({
Vue.set(state.workflowExecutionData.data.resultData.runData, pushData.nodeName, []);
}
state.workflowExecutionData.data.resultData.runData[pushData.nodeName].push(pushData.data);
state.workflowExecutionPairedItemMappings = getPairedItemsMapping(state.workflowExecutionData);
},
clearNodeExecutionData(state, nodeName: string): void {
if (state.workflowExecutionData === null || !state.workflowExecutionData.data) {
@ -709,6 +714,9 @@ export const store = new Vuex.Store({
},
},
getters: {
workflowExecutionPairedItemMappings: (state): IRootState['workflowExecutionPairedItemMappings'] => {
return state.workflowExecutionPairedItemMappings;
},
executedNode: (state): string | undefined => {
return state.workflowExecutionData ? state.workflowExecutionData.executedNode : undefined;
},

View file

@ -3,3 +3,7 @@ import { INodeParameterResourceLocator } from "n8n-workflow";
export function isResourceLocatorValue(value: unknown): value is INodeParameterResourceLocator {
return Boolean(typeof value === 'object' && value && 'mode' in value && 'value' in value);
}
export function isNotNull<T>(value: T | null): value is T {
return value !== null;
}