feat(editor): Add drag and drop data mapping (#3708)

* commit package lock

* refactor param options out

* use action toggle

* handle click on toggle

* update color toggle

* fix toggle

* show options

* update expression color

* update pointer

* fix readonly

* fix readonly

* fix expression spacing

* refactor input label

* show icon for headers

* center icon

* fix multi params

* add credential options

* increase spacing

* update expression view

* update transition

* update el padding

* rename side to options

* fix label overflow

* fix bug with unnessary lines

* add overlay

* fix bug affecting other pages

* clean up spacing

* rename

* update icon size

* fix toggle in users

* clean up func

* clean up css

* use css var

* fix overlay bug

* clean up input

* clean up input

* clean up unnessary css

* revert

* update quotes

* rename method

* remove console errors

* refactor data table

* add drag button

* make hoverable cells

* add drag hint

* disabel for output panel

* add drag

* disable for readonly

* Add dragging

* add draggable pill

* add mapping targets

* remove font color

* Transferable

* fix linting issue

* teleport component

* fix line

* disable for readonly

* fix position of data pill

* fix position of data pill

* ignore import

* add droppable state

* remove draggable key

* update bg color

* add value drop

* use direct input

* remove transition

* add animation

* shorten name

* handle empty value

* fix switch bug

* fix up animation

* add notification

* add hint

* add tooltip

* show draggable hintm

* fix multiple expre

* fix hoverable

* keep options on focus

* increase timeouts

* fix bug in set node

* add transition on hover out

* fix tooltip onboarding bug

* only update expression if changes

* add open delay

* fix header highlight issue

* update text

* dont show tooltip always

* update docs url

* update ee border

* add sticky behav

* hide error highlight if dropping

* switch out grip icon

* increase timeout

* add delay

* show hint on execprev

* add telemetry event

* add telemetry event

* add telemetry event

* fire event on hint showing

* fix telemetry event

* add path

* fix drag hint issue

* decrease bottom margin

* update mapping keys

* remove file

* hide overflow

* sort params

* add space

* prevent scrolling

* remove dropshadow

* force cursor

* address some comments

* add thead tbody

* add size opt
This commit is contained in:
Mutasem Aldmour 2022-07-20 13:32:51 +02:00 committed by GitHub
parent 2997711e00
commit 577c73ee25
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 1490 additions and 599 deletions

View file

@ -7,7 +7,11 @@ export default {
argTypes: {
placement: {
type: 'select',
options: ['top', 'bottom'],
options: ['top', 'top-start', 'top-end', 'bottom', 'bottom-end'],
},
size: {
type: 'select',
options: ['mini', 'small', 'medium'],
},
},
parameters: {

View file

@ -1,9 +1,10 @@
<template>
<span :class="$style.container">
<el-dropdown :placement="placement" trigger="click" @command="onCommand">
<span :class="$style.button">
<el-dropdown :placement="placement" :size="size" trigger="click" @command="onCommand" @visible-change="onVisibleChange">
<span :class="{[$style.button]: true, [$style[theme]]: !!theme}">
<component :is="$options.components.N8nIcon"
icon="ellipsis-v"
:size="iconSize"
/>
</span>
<el-dropdown-menu slot="dropdown">
@ -11,6 +12,7 @@
v-for="action in actions"
:key="action.value"
:command="action.value"
:disabled="action.disabled"
>
{{action.label}}
</el-dropdown-item>
@ -42,12 +44,30 @@ export default {
type: String,
default: 'bottom',
validator: (value: string): boolean =>
['top', 'bottom'].includes(value),
['top', 'top-end', 'top-start', 'bottom', 'bottom-end', 'bottom-start'].includes(value),
},
size: {
type: String,
default: 'medium',
validator: (value: string): boolean =>
['mini', 'small', 'medium'].includes(value),
},
iconSize: {
type: String,
},
theme: {
type: String,
default: 'default',
validator: (value: string): boolean =>
['default', 'dark'].includes(value),
},
},
methods: {
onCommand(value: string) {
this.$emit('action', value) ;
this.$emit('action', value);
},
onVisibleChange(value: boolean) {
this.$emit('visible-change', value);
},
},
};
@ -62,6 +82,18 @@ export default {
cursor: pointer;
padding: var(--spacing-4xs);
border-radius: var(--border-radius-base);
&:hover {
color: var(--color-primary);
cursor: pointer;
}
&:focus {
color: var(--color-primary);
}
}
.dark {
color: var(--color-text-dark);
&:focus {

View file

@ -1,16 +1,26 @@
<template functional>
<div :class="{[$style.inputLabelContainer]: !props.labelHoverableOnly}">
<div :class="$options.methods.getLabelClass(props, $style)">
<component v-if="props.label" :is="$options.components.N8nText" :bold="props.bold" :size="props.size" :compact="!props.underline">
{{ props.label }}
<component :is="$options.components.N8nText" color="primary" :bold="props.bold" :size="props.size" v-if="props.required">*</component>
</component>
<span :class="[$style.infoIcon, props.showTooltip ? $style.showIcon: $style.hiddenIcon]" v-if="props.tooltipText">
<component :is="$options.components.N8nTooltip" placement="top" :popper-class="$style.tooltipPopper">
<component :is="$options.components.N8nIcon" icon="question-circle" size="small" />
<div slot="content" v-html="$options.methods.addTargetBlank(props.tooltipText)"></div>
</component>
<template>
<div :class="$style.container">
<div :class="{
[this.$style.label]: !!this.label,
[this.$style.underline]: this.underline,
[this.$style[this.size]]: true,
}">
<div :class="$style.title" v-if="label">
<n8n-text :bold="bold" :size="size" :compact="!underline">
{{ label }}
<n8n-text color="primary" :bold="bold" :size="size" v-if="required">*</n8n-text>
</n8n-text>
</div>
<span :class="[$style.infoIcon, showTooltip ? $style.visible: $style.hidden]" v-if="tooltipText && label">
<n8n-tooltip placement="top" :popper-class="$style.tooltipPopper">
<n8n-icon icon="question-circle" size="small" />
<div slot="content" v-html="addTargetBlank(tooltipText)"></div>
</n8n-tooltip>
</span>
<div v-if="$slots.options && label" :class="{[$style.overlay]: true, [$style.visible]: showOptions}"><div></div></div>
<div v-if="$slots.options" :class="{[$style.options]: true, [$style.visible]: showOptions}">
<slot name="options"></slot>
</div>
</div>
<slot></slot>
</div>
@ -56,74 +66,104 @@ export default {
showTooltip: {
type: Boolean,
},
labelHoverableOnly: {
showOptions: {
type: Boolean,
default: false,
},
},
methods: {
addTargetBlank,
getLabelClass(props: {label: string, size: string, underline: boolean}, $style: any) {
if (!props.label) {
return '';
}
const classes = [];
if (props.underline) {
classes.push($style[`label-${props.size}-underline`]);
}
else {
classes.push($style[`label-${props.size}`]);
}
if (props.labelHoverableOnly) {
classes.push($style.inputLabel);
}
return classes;
},
},
};
</script>
<style lang="scss" module>
.inputLabelContainer:hover {
> div > .infoIcon {
display: inline-block;
.container {
display: flex;
flex-direction: column;
}
.container:hover,.inputLabel:hover {
.infoIcon {
opacity: 1;
}
.options {
opacity: 1;
transition: opacity 100ms ease-in; // transition on hover in
}
.overlay {
opacity: 1;
transition: opacity 100ms ease-in; // transition on hover in
}
}
.inputLabel:hover {
> .infoIcon {
display: inline-block;
.title {
display: flex;
align-items: center;
min-width: 0;
> * {
white-space: nowrap;
}
}
.infoIcon {
display: flex;
align-items: center;
color: var(--color-text-light);
padding-left: var(--spacing-4xs);
background-color: var(--color-background-xlight);
z-index: 1;
}
.showIcon {
display: inline-block;
}
.options {
opacity: 0;
background-color: var(--color-background-xlight);
transition: opacity 250ms cubic-bezier(.98,-0.06,.49,-0.2); // transition on hover out
.hiddenIcon {
display: none;
}
.label {
* {
margin-right: var(--spacing-5xs);
> * {
float: right;
}
}
.label-small {
composes: label;
margin-bottom: var(--spacing-4xs);
.overlay {
position: relative;
flex-grow: 1;
opacity: 0;
transition: opacity 250ms cubic-bezier(.98,-0.06,.49,-0.2); // transition on hover out
> div {
position: absolute;
width: 60px;
height: 19px;
top: 0;
right: 0;
z-index: 0;
background: linear-gradient(270deg, var(--color-foreground-xlight) 72.19%, rgba(255, 255, 255, 0) 107.45%);
}
}
.label-medium {
composes: label;
.hidden {
opacity: 0;
}
.visible {
opacity: 1;
}
.label {
display: flex;
overflow-x: hidden;
overflow-y: clip;
}
.small {
margin-bottom: var(--spacing-5xs);
}
.medium {
margin-bottom: var(--spacing-2xs);
}
@ -131,16 +171,6 @@ export default {
border-bottom: var(--border-base);
}
.label-small-underline {
composes: label-small;
composes: underline;
}
.label-medium-underline {
composes: label-medium;
composes: underline;
}
.tooltipPopper {
max-width: 400px;
@ -148,4 +178,5 @@ export default {
margin-left: var(--spacing-s);
}
}
</style>

View file

@ -1,7 +1,7 @@
<template>
<label role="radio" tabindex="-1" :class="$style.container" aria-checked="true">
<label role="radio" tabindex="-1" :class="{[$style.container]: true, [$style.hoverable]: !this.disabled}" aria-checked="true">
<input type="radio" tabindex="-1" autocomplete="off" :class="$style.input" :value="value">
<div :class="{[$style.button]: true, [$style.active]: active}" @click="$emit('click')">{{ label }}</div>
<div :class="{[$style.button]: true, [$style.active]: active, [$style[size]]: true, [$style.disabled]: disabled}" @click="$emit('click')">{{ label }}</div>
</label>
</template>
@ -21,6 +21,15 @@ export default {
type: Boolean,
default: false,
},
size: {
type: String,
default: 'medium',
validator: (value: string): boolean =>
['small', 'medium'].includes(value),
},
disabled: {
type: Boolean,
},
},
};
</script>
@ -30,11 +39,11 @@ export default {
display: inline-block;
outline: 0;
position: relative;
}
&:hover {
.button:not(.active) {
color: var(--color-primary);
}
.hoverable:hover {
.button:not(.active) {
color: var(--color-primary);
}
}
@ -47,16 +56,29 @@ export default {
.button {
border-radius: 0;
padding: 0 var(--spacing-xs);
display: flex;
align-items: center;
height: 26px;
font-size: var(--font-size-2xs);
border-radius: var(--border-radius-base);
font-weight: var(--font-weight-bold);
color: var(--color-text-base);
cursor: pointer;
transition: background-color 0.2s ease;
cursor: pointer;
}
.disabled {
cursor: not-allowed;
}
.medium {
height: 26px;
font-size: var(--font-size-2xs);
padding: 0 var(--spacing-xs);
}
.small {
font-size: var(--font-size-3xs);
height: 15px;
padding: 0 var(--spacing-4xs);
}
.active {

View file

@ -6,6 +6,10 @@ export default {
title: 'Atoms/RadioButtons',
component: N8nRadioButtons,
argTypes: {
size: {
type: 'select',
options: ['small', 'medium'],
},
},
parameters: {
backgrounds: { default: '--color-background-xlight' },

View file

@ -1,10 +1,12 @@
<template>
<div role="radiogroup" :class="$style.radioGroup">
<div role="radiogroup" :class="{[$style.radioGroup]: true, [$style.disabled]: disabled}">
<RadioButton
v-for="option in options"
:key="option.value"
v-bind="option"
:active="value === option.value"
:size="size"
:disabled="disabled"
@click="(e) => onClick(option.value, e)"
/>
</div>
@ -21,12 +23,21 @@ export default {
},
options: {
},
size: {
type: String,
},
disabled: {
type: Boolean,
},
},
components: {
RadioButton,
},
methods: {
onClick(value) {
if (this.disabled) {
return;
}
this.$emit('input', value);
},
},
@ -45,5 +56,9 @@ export default {
border-radius: var(--border-radius-base);
}
.disabled {
cursor: not-allowed;
}
</style>

View file

@ -12,6 +12,7 @@
v-if="!user.isOwner"
placement="bottom"
:actions="getActions(user)"
theme="dark"
@action="(action) => onUserAction(user, action)"
/>
</div>

View file

@ -11,7 +11,7 @@ import VariableTable from './VariableTable.vue';
<Canvas>
<Story name="font-size">
{{
template: `<sizes :variables="['--font-size-2xs','--font-size-xs','--font-size-s','--font-size-m','--font-size-l','--font-size-xl','--font-size-2xl']" attr="font-size" />`,
template: `<sizes :variables="['--font-size-3xs', '--font-size-2xs','--font-size-xs','--font-size-s','--font-size-m','--font-size-l','--font-size-xl','--font-size-2xl']" attr="font-size" />`,
components: {
Sizes,
},

View file

@ -70,6 +70,23 @@
var(--color-secondary-l)
);
--color-secondary-tint-1-s: 45%;
--color-secondary-tint-1-l: 70%;
--color-secondary-tint-1: hsl(
var(--color-secondary-h),
var(--color-secondary-tint-1-s),
var(--color-secondary-tint-1-l)
);
--color-secondary-tint-2-s: 46.7%;
--color-secondary-tint-2-l: 97%;
--color-secondary-tint-2: hsl(
var(--color-secondary-h),
var(--color-secondary-tint-2-s),
var(--color-secondary-tint-2-l)
);
--color-success-h: 150.4;
--color-success-s: 60%;
--color-success-l: 40.4%;

View file

@ -114,7 +114,6 @@ video {
padding: 0;
border: 0;
outline: 0;
vertical-align: baseline;
background: transparent;
}

View file

@ -34,6 +34,7 @@
"vue-fragment": "1.5.1",
"vue-i18n": "^8.26.7",
"vue2-boring-avatars": "0.3.4",
"vue2-teleport": "^1.0.1",
"xss": "^1.0.10"
},
"devDependencies": {

View file

@ -870,8 +870,17 @@ export interface IUiState {
output: {
displayMode: IRunDataDisplayMode;
};
focusedMappableInput: string;
mappingTelemetry: {[key: string]: string | number | boolean};
};
mainPanelPosition: number;
draggable: {
isDragging: boolean;
type: string;
data: string;
canDrop: boolean;
stickyPosition: null | XYPosition;
};
}
export type ILogLevel = 'info' | 'debug' | 'warn' | 'error' | 'verbose';

View file

@ -4,37 +4,106 @@
@mousedown="onDragStart"
>
<slot :isDragging="isDragging"></slot>
<Teleport to="body">
<div
ref="draggable"
:class="$style.draggable"
:style="draggableStyle"
v-show="isDragging"
>
<slot name="preview" :canDrop="canDrop"></slot>
</div>
</Teleport>
</div>
</template>
<script lang="ts">
import { XYPosition } from '@/Interface';
import Vue from 'vue';
// @ts-ignore
import Teleport from 'vue2-teleport';
export default Vue.extend({
components: {
Teleport,
},
props: {
disabled: {
type: Boolean,
},
type: {
type: String,
},
data: {
type: String,
},
},
data() {
return {
isDragging: false,
draggablePosition: {
x: -100,
y: -100,
},
};
},
computed: {
draggableStyle(): { top: string; left: string; } {
return {
top: `${this.draggablePosition.y}px`,
left: `${this.draggablePosition.x}px`,
};
},
canDrop(): boolean {
return this.$store.getters['ui/canDraggableDrop'];
},
stickyPosition(): XYPosition | null {
return this.$store.getters['ui/draggableStickyPos'];
},
},
methods: {
onDragStart(e: MouseEvent) {
onDragStart(e: DragEvent) {
if (this.disabled) {
return;
}
e.preventDefault();
e.stopPropagation();
this.isDragging = true;
this.$store.commit('ui/draggableStartDragging', {type: this.type, data: this.data || ''});
this.$emit('dragstart');
document.body.style.cursor = 'grabbing';
window.addEventListener('mousemove', this.onDrag);
window.addEventListener('mouseup', this.onDragEnd);
this.draggablePosition = { x: e.pageX, y: e.pageY };
},
onDrag(e: MouseEvent) {
if (this.disabled) {
return;
}
e.preventDefault();
e.stopPropagation();
this.$emit('drag', {x: e.pageX, y: e.pageY});
if (this.canDrop && this.stickyPosition) {
this.draggablePosition = { x: this.stickyPosition[0], y: this.stickyPosition[1]};
}
else {
this.draggablePosition = { x: e.pageX, y: e.pageY };
}
this.$emit('drag', this.draggablePosition);
},
onDragEnd(e: MouseEvent) {
if (this.disabled) {
return;
}
e.preventDefault();
e.stopPropagation();
@ -45,6 +114,7 @@ export default Vue.extend({
setTimeout(() => {
this.$emit('dragend');
this.isDragging = false;
this.$store.commit('ui/draggableStopDragging');
}, 0);
},
},
@ -56,4 +126,14 @@ export default Vue.extend({
visibility: visible;
cursor: grabbing;
}
.draggable {
position: fixed;
z-index: 9999999;
}
.draggable-data-transfer {
width: 0px;
height: 0px;
}
</style>

View file

@ -0,0 +1,80 @@
<template>
<div ref="target">
<slot :droppable="droppable" :activeDrop="activeDrop"></slot>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: {
type: {
type: String,
},
disabled: {
type: Boolean,
},
sticky: {
type: Boolean,
},
stickyOffset: {
type: Number,
default: 0,
},
},
data() {
return {
hovering: false,
};
},
mounted() {
window.addEventListener('mousemove', this.onMouseMove);
window.addEventListener('mouseup', this.onMouseUp);
},
destroyed() {
window.removeEventListener('mousemove', this.onMouseMove);
window.removeEventListener('mouseup', this.onMouseUp);
},
computed: {
isDragging(): boolean {
return this.$store.getters['ui/isDraggableDragging'];
},
draggableType(): string {
return this.$store.getters['ui/draggableType'];
},
droppable(): boolean {
return !this.disabled && this.isDragging && this.draggableType === this.type;
},
activeDrop(): boolean {
return this.droppable && this.hovering;
},
},
methods: {
onMouseMove(e: MouseEvent) {
const target = this.$refs.target as HTMLElement;
if (target) {
const dim = target.getBoundingClientRect();
this.hovering = e.clientX >= dim.left && e.clientX <= dim.right && e.clientY >= dim.top && e.clientY <= dim.bottom;
if (this.sticky && this.hovering) {
this.$store.commit('ui/setDraggableStickyPos', [dim.left + this.stickyOffset, dim.top + this.stickyOffset]);
}
}
},
onMouseUp(e: MouseEvent) {
if (this.activeDrop) {
const data = this.$store.getters['ui/draggableData'];
this.$emit('drop', data);
}
},
},
watch: {
activeDrop(active) {
this.$store.commit('ui/setDraggableCanDrop', active);
},
},
});
</script>

View file

@ -50,8 +50,7 @@ import { externalHooks } from '@/components/mixins/externalHooks';
import { genericHelpers } from '@/components/mixins/genericHelpers';
import mixins from 'vue-typed-mixins';
const MAPPING_PARAMS = [`$evaluateExpression`, `$item`, `$jmespath`, `$node`, `$binary`, `$data`, `$env`, `$json`, `$now`, `$parameters`, `$position`, `$resumeWebhookUrl`, `$runIndex`, `$today`, `$workflow`];
import { hasExpressionMapping } from './helpers';
export default mixins(
externalHooks,
@ -92,9 +91,11 @@ export default mixins(
},
closeDialog () {
// Handle the close externally as the visible parameter is an external prop
// and is so not allowed to be changed here.
this.$emit('valueChanged', this.latestValue);
if (this.latestValue !== this.value) {
// Handle the close externally as the visible parameter is an external prop
// and is so not allowed to be changed here.
this.$emit('valueChanged', this.latestValue);
}
this.$emit('closeDialog');
return false;
},
@ -172,7 +173,7 @@ export default mixins(
source: this.eventSource,
session_id: this.$store.getters['ui/ndvSessionId'],
has_parameter: this.value.includes('$parameter'),
has_mapping: !!MAPPING_PARAMS.find((param) => this.value.includes(param)),
has_mapping: hasExpressionMapping(this.value),
});
}
},

View file

@ -354,7 +354,7 @@ export default mixins(
.ql-disabled .ql-editor {
border-width: 1px;
border: 1px dashed $--custom-expression-text;
border: 1px solid $--custom-expression-text;
color: $--custom-expression-text;
background-color: $--custom-expression-background;
cursor: not-allowed;

View file

@ -10,73 +10,72 @@
class="fixed-collection-parameter-property"
>
<n8n-input-label
:label="property.displayName === '' || parameter.options.length === 1 ? '' : $locale.nodeText().inputLabelDisplayName(property, path)"
v-if="property.displayName !== '' && (parameter.options && parameter.options.length !== 1)"
:label="$locale.nodeText().inputLabelDisplayName(property, path)"
:underline="true"
:labelHoverableOnly="true"
size="small"
>
<div v-if="multipleValues === true">
<div
v-for="(value, index) in values[property.name]"
:key="property.name + index"
class="parameter-item"
>
<div class="parameter-item-wrapper">
<div class="delete-option" v-if="!isReadOnly">
<font-awesome-icon
icon="trash"
class="reset-icon clickable"
:title="$locale.baseText('fixedCollectionParameter.deleteItem')"
@click="deleteOption(property.name, index)"
/>
<div v-if="sortable" class="sort-icon">
<font-awesome-icon
v-if="index !== 0"
icon="angle-up"
class="clickable"
:title="$locale.baseText('fixedCollectionParameter.moveUp')"
@click="moveOptionUp(property.name, index)"
/>
<font-awesome-icon
v-if="index !== (values[property.name].length - 1)"
icon="angle-down"
class="clickable"
:title="$locale.baseText('fixedCollectionParameter.moveDown')"
@click="moveOptionDown(property.name, index)"
/>
</div>
</div>
<parameter-input-list
:parameters="property.values"
:nodeValues="nodeValues"
:path="getPropertyPath(property.name, index)"
:hideDelete="true"
@valueChanged="valueChanged"
/>
</div>
</div>
</div>
<div v-else class="parameter-item">
/>
<div v-if="multipleValues === true">
<div
v-for="(value, index) in values[property.name]"
:key="property.name + index"
class="parameter-item"
>
<div class="parameter-item-wrapper">
<div class="delete-option" v-if="!isReadOnly">
<font-awesome-icon
icon="trash"
class="reset-icon clickable"
:title="$locale.baseText('fixedCollectionParameter.deleteItem')"
@click="deleteOption(property.name)"
@click="deleteOption(property.name, index)"
/>
<div v-if="sortable" class="sort-icon">
<font-awesome-icon
v-if="index !== 0"
icon="angle-up"
class="clickable"
:title="$locale.baseText('fixedCollectionParameter.moveUp')"
@click="moveOptionUp(property.name, index)"
/>
<font-awesome-icon
v-if="index !== (values[property.name].length - 1)"
icon="angle-down"
class="clickable"
:title="$locale.baseText('fixedCollectionParameter.moveDown')"
@click="moveOptionDown(property.name, index)"
/>
</div>
</div>
<parameter-input-list
:parameters="property.values"
:nodeValues="nodeValues"
:path="getPropertyPath(property.name)"
class="parameter-item"
@valueChanged="valueChanged"
:path="getPropertyPath(property.name, index)"
:hideDelete="true"
@valueChanged="valueChanged"
/>
</div>
</div>
</n8n-input-label>
</div>
<div v-else class="parameter-item">
<div class="parameter-item-wrapper">
<div class="delete-option" v-if="!isReadOnly">
<font-awesome-icon
icon="trash"
class="reset-icon clickable"
:title="$locale.baseText('fixedCollectionParameter.deleteItem')"
@click="deleteOption(property.name)"
/>
</div>
<parameter-input-list
:parameters="property.values"
:nodeValues="nodeValues"
:path="getPropertyPath(property.name)"
class="parameter-item"
@valueChanged="valueChanged"
:hideDelete="true"
/>
</div>
</div>
</div>
<div v-if="parameterOptions.length > 0 && !isReadOnly">

View file

@ -10,6 +10,9 @@
:executingMessage="$locale.baseText('ndv.input.executingPrevious')"
:sessionId="sessionId"
:overrideOutputs="connectedCurrentNodeOutputs"
:mappingEnabled="!readOnly"
:showMappingHint="draggableHintShown"
:distanceFromActive="currentNodeDepth"
paneType="input"
@linkRun="onLinkRun"
@unlinkRun="onUnlinkRun"
@ -32,8 +35,11 @@
<template v-slot:node-not-run>
<div :class="$style.noOutputData" v-if="parentNodes.length">
<n8n-text tag="div" :bold="true" color="text-dark" size="large">{{ $locale.baseText('ndv.input.noOutputData.title') }}</n8n-text>
<NodeExecuteButton v-if="!readOnly" type="outline" :transparent="true" :nodeName="currentNodeName" :label="$locale.baseText('ndv.input.noOutputData.executePrevious')" @execute="onNodeExecute" telemetrySource="inputs" />
<n8n-text v-if="!readOnly" tag="div" size="small">
<n8n-tooltip v-if="!readOnly" :manual="true" :value="showDraggableHint && showDraggableHintWithDelay">
<div slot="content" v-html="$locale.baseText('dataMapping.dragFromPreviousHint', { interpolate: { name: focusedMappableInput } })"></div>
<NodeExecuteButton type="outline" :transparent="true" :nodeName="currentNodeName" :label="$locale.baseText('ndv.input.noOutputData.executePrevious')" @execute="onNodeExecute" telemetrySource="inputs" />
</n8n-tooltip>
<n8n-text v-if="!readOnly" tag="div" size="small">
{{ $locale.baseText('ndv.input.noOutputData.hint') }}
</n8n-text>
</div>
@ -65,6 +71,7 @@ import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import mixins from 'vue-typed-mixins';
import NodeExecuteButton from './NodeExecuteButton.vue';
import WireMeUp from './WireMeUp.vue';
import { CRON_NODE_TYPE, INTERVAL_NODE_TYPE, LOCAL_STORAGE_MAPPING_FLAG, START_NODE_TYPE } from '@/constants';
export default mixins(
workflowHelpers,
@ -93,7 +100,27 @@ export default mixins(
type: Boolean,
},
},
data() {
return {
showDraggableHintWithDelay: false,
draggableHintShown: false,
};
},
computed: {
focusedMappableInput(): string {
return this.$store.getters['ui/focusedMappableInput'];
},
isUserOnboarded(): boolean {
return window.localStorage.getItem(LOCAL_STORAGE_MAPPING_FLAG) === 'true';
},
showDraggableHint(): boolean {
const toIgnore = [START_NODE_TYPE, CRON_NODE_TYPE, INTERVAL_NODE_TYPE];
if (toIgnore.includes(this.currentNode.type)) {
return false;
}
return !!this.focusedMappableInput && !this.isUserOnboarded;
},
isExecutingPrevious(): boolean {
if (!this.workflowRunning) {
return false;
@ -136,6 +163,10 @@ export default mixins(
return nodes.filter(({name}, i) => (this.activeNode && (name !== this.activeNode.name)) && nodes.findIndex((node) => node.name === name) === i);
},
currentNodeDepth (): number {
const node = this.parentNodes.find((node) => node.name === this.currentNode.name);
return node ? node.depth: -1;
},
},
methods: {
onNodeExecute() {
@ -182,6 +213,26 @@ export default mixins(
return truncated;
},
},
watch: {
showDraggableHint(curr: boolean, prev: boolean) {
if (curr && !prev) {
setTimeout(() => {
if (this.draggableHintShown) {
return;
}
this.showDraggableHintWithDelay = this.showDraggableHint;
if (this.showDraggableHintWithDelay) {
this.draggableHintShown = true;
this.$telemetry.track('User viewed data mapping tooltip', { type: 'unexecuted input pane' });
}
}, 1000);
}
else if (!curr) {
this.showDraggableHintWithDelay = false;
}
},
},
});
</script>

View file

@ -4,34 +4,31 @@
:label="$locale.nodeText().inputLabelDisplayName(parameter, path)"
:tooltipText="$locale.nodeText().inputLabelDescription(parameter, path)"
:underline="true"
:labelHoverableOnly="true"
size="small"
>
/>
<div v-for="(value, index) in values" :key="index" class="duplicate-parameter-item" :class="parameter.type">
<div class="delete-item clickable" v-if="!isReadOnly">
<font-awesome-icon icon="trash" :title="$locale.baseText('multipleParameter.deleteItem')" @click="deleteItem(index)" />
<div v-if="sortable">
<font-awesome-icon v-if="index !== 0" icon="angle-up" class="clickable" :title="$locale.baseText('multipleParameter.moveUp')" @click="moveOptionUp(index)" />
<font-awesome-icon v-if="index !== (values.length -1)" icon="angle-down" class="clickable" :title="$locale.baseText('multipleParameter.moveDown')" @click="moveOptionDown(index)" />
</div>
</div>
<div v-if="parameter.type === 'collection'">
<collection-parameter :parameter="parameter" :values="value" :nodeValues="nodeValues" :path="getPath(index)" :hideDelete="hideDelete" @valueChanged="valueChanged" />
</div>
<div v-else>
<parameter-input class="duplicate-parameter-input-item" :parameter="parameter" :value="value" :displayOptions="true" :path="getPath(index)" @valueChanged="valueChanged" inputSize="small" :isReadOnly="isReadOnly" />
<div v-for="(value, index) in values" :key="index" class="duplicate-parameter-item" :class="parameter.type">
<div class="delete-item clickable" v-if="!isReadOnly">
<font-awesome-icon icon="trash" :title="$locale.baseText('multipleParameter.deleteItem')" @click="deleteItem(index)" />
<div v-if="sortable">
<font-awesome-icon v-if="index !== 0" icon="angle-up" class="clickable" :title="$locale.baseText('multipleParameter.moveUp')" @click="moveOptionUp(index)" />
<font-awesome-icon v-if="index !== (values.length -1)" icon="angle-down" class="clickable" :title="$locale.baseText('multipleParameter.moveDown')" @click="moveOptionDown(index)" />
</div>
</div>
<div class="add-item-wrapper">
<div v-if="values && Object.keys(values).length === 0 || isReadOnly" class="no-items-exist">
<n8n-text size="small">{{ $locale.baseText('multipleParameter.currentlyNoItemsExist') }}</n8n-text>
</div>
<n8n-button v-if="!isReadOnly" type="tertiary" fullWidth @click="addItem()" :label="addButtonText" />
<div v-if="parameter.type === 'collection'">
<collection-parameter :parameter="parameter" :values="value" :nodeValues="nodeValues" :path="getPath(index)" :hideDelete="hideDelete" @valueChanged="valueChanged" />
</div>
<div v-else>
<parameter-input-full class="duplicate-parameter-input-item" :parameter="parameter" :value="value" :displayOptions="true" :hideLabel="true" :path="getPath(index)" @valueChanged="valueChanged" inputSize="small" :isReadOnly="isReadOnly" />
</div>
</div>
</n8n-input-label>
<div class="add-item-wrapper">
<div v-if="values && Object.keys(values).length === 0 || isReadOnly" class="no-items-exist">
<n8n-text size="small">{{ $locale.baseText('multipleParameter.currentlyNoItemsExist') }}</n8n-text>
</div>
<n8n-button v-if="!isReadOnly" type="tertiary" fullWidth @click="addItem()" :label="addButtonText" />
</div>
</div>
</template>
@ -41,7 +38,7 @@ import {
} from '@/Interface';
import CollectionParameter from '@/components/CollectionParameter.vue';
import ParameterInput from '@/components/ParameterInput.vue';
import ParameterInputFull from '@/components/ParameterInputFull.vue';
import { get } from 'lodash';
@ -54,7 +51,7 @@ export default mixins(genericHelpers)
name: 'MultipleParameter',
components: {
CollectionParameter,
ParameterInput,
ParameterInputFull,
},
props: [
'nodeValues', // NodeParameters

View file

@ -1,5 +1,5 @@
<template>
<Draggable @drag="onDrag" @dragstart="onDragStart" @dragend="onDragEnd">
<Draggable type="panel-resize" @drag="onDrag" @dragstart="onDragStart" @dragend="onDragEnd">
<template v-slot="{ isDragging }">
<div
:class="{ [$style.dragButton]: true }"

View file

@ -1,220 +1,262 @@
<template>
<div @keydown.stop :class="parameterInputClasses">
<expression-edit :dialogVisible="expressionEditDialogVisible" :value="value" :parameter="parameter" :path="path" :eventSource="eventSource || 'ndv'" @closeDialog="closeExpressionEditDialog" @valueChanged="expressionUpdated"></expression-edit>
<div class="parameter-input ignore-key-press" :style="parameterInputWrapperStyle" @click="openExpressionEdit">
<n8n-input
v-if="isValueExpression && showExpressionAsTextInput"
:size="inputSize"
:value="expressionDisplayValue"
:disabled="isReadOnly"
:title="displayTitle"
@keydown.stop
/>
<div v-else-if="['json', 'string'].includes(parameter.type) || remoteParameterOptionsLoadingIssues !== null">
<code-edit v-if="codeEditDialogVisible" :value="value" :parameter="parameter" :type="editorType" :codeAutocomplete="codeAutocomplete" :path="path" @closeDialog="closeCodeEditDialog" @valueChanged="expressionUpdated"></code-edit>
<text-edit :dialogVisible="textEditDialogVisible" :value="value" :parameter="parameter" :path="path" @closeDialog="closeTextEditDialog" @valueChanged="expressionUpdated"></text-edit>
<div v-if="isEditor === true" class="code-edit clickable" @click="displayEditDialog()">
<prism-editor v-if="!codeEditDialogVisible" :lineNumbers="true" :readonly="true" :code="displayValue" language="js"></prism-editor>
</div>
<expression-edit
:dialogVisible="expressionEditDialogVisible"
:value="value"
:parameter="parameter"
:path="path"
:eventSource="eventSource || 'ndv'"
@closeDialog="closeExpressionEditDialog"
@valueChanged="expressionUpdated"
></expression-edit>
<div
class="parameter-input ignore-key-press"
:style="parameterInputWrapperStyle"
@click="openExpressionEdit"
>
<n8n-input
v-else
v-model="tempValue"
ref="inputField"
v-if="isValueExpression || droppable || forceShowExpression"
:size="inputSize"
:type="getStringInputType"
:rows="getArgument('rows')"
:value="displayValue"
:disabled="isReadOnly"
@input="onTextInputChange"
@change="valueChanged"
@keydown.stop
@focus="setFocus"
@blur="onBlur"
:value="activeDrop || forceShowExpression? '': expressionDisplayValue"
:title="displayTitle"
:placeholder="isValueExpression ? '' : getPlaceholder()"
@keydown.stop
/>
<div
v-else-if="
['json', 'string'].includes(parameter.type) ||
remoteParameterOptionsLoadingIssues !== null
"
>
<div slot="suffix" class="expand-input-icon-container">
<font-awesome-icon v-if="!isValueExpression && !isReadOnly" icon="external-link-alt" class="edit-window-button clickable" :title="$locale.baseText('parameterInput.openEditWindow')" @click="displayEditDialog()" />
</div>
</n8n-input>
</div>
<div v-else-if="parameter.type === 'color'" ref="inputField" class="color-input">
<el-color-picker
size="small"
class="color-picker"
:value="displayValue"
:disabled="isReadOnly"
@focus="setFocus"
@blur="onBlur"
@change="valueChanged"
:title="displayTitle"
:show-alpha="getArgument('showAlpha')"
/>
<n8n-input
v-model="tempValue"
:size="inputSize"
type="text"
:value="tempValue"
:disabled="isReadOnly"
@change="valueChanged"
@keydown.stop
@focus="setFocus"
@blur="onBlur"
:title="displayTitle"
/>
</div>
<el-date-picker
v-else-if="parameter.type === 'dateTime'"
v-model="tempValue"
ref="inputField"
type="datetime"
:size="inputSize"
:value="displayValue"
:title="displayTitle"
:disabled="isReadOnly"
:placeholder="parameter.placeholder ? getPlaceholder() : $locale.baseText('parameterInput.selectDateAndTime')"
:picker-options="dateTimePickerOptions"
@change="valueChanged"
@focus="setFocus"
@blur="onBlur"
@keydown.stop
/>
<n8n-input-number
v-else-if="parameter.type === 'number'"
ref="inputField" :size="inputSize"
:value="displayValue"
:controls="false"
:max="getArgument('maxValue')"
:min="getArgument('minValue')"
:precision="getArgument('numberPrecision')"
:disabled="isReadOnly"
@change="valueChanged"
@input="onTextInputChange"
@focus="setFocus"
@blur="onBlur"
@keydown.stop
:title="displayTitle"
:placeholder="parameter.placeholder"
/>
<credentials-select
v-else-if="parameter.type === 'credentialsSelect' || (parameter.name === 'genericAuthType')"
ref="inputField"
:parameter="parameter"
:node="node"
:activeCredentialType="activeCredentialType"
:inputSize="inputSize"
:displayValue="displayValue"
:isReadOnly="isReadOnly"
:displayTitle="displayTitle"
@credentialSelected="credentialSelected"
@valueChanged="valueChanged"
@setFocus="setFocus"
@onBlur="onBlur"
>
<template v-slot:issues-and-options>
<parameter-issues
:issues="getIssues"
/>
<parameter-options
v-if="displayOptionsComputed"
:displayOptionsComputed="displayOptionsComputed"
<code-edit
v-if="codeEditDialogVisible"
:value="value"
:parameter="parameter"
:isValueExpression="isValueExpression"
:isDefault="isDefault"
:hasRemoteMethod="hasRemoteMethod"
@optionSelected="optionSelected"
/>
</template>
</credentials-select>
:type="editorType"
:codeAutocomplete="codeAutocomplete"
:path="path"
@closeDialog="closeCodeEditDialog"
@valueChanged="expressionUpdated"
></code-edit>
<text-edit
:dialogVisible="textEditDialogVisible"
:value="value"
:parameter="parameter"
:path="path"
@closeDialog="closeTextEditDialog"
@valueChanged="expressionUpdated"
></text-edit>
<n8n-select
v-else-if="parameter.type === 'options'"
ref="inputField"
:size="inputSize"
filterable
:value="displayValue"
:placeholder="parameter.placeholder ? getPlaceholder() : $locale.baseText('parameterInput.select')"
:loading="remoteParameterOptionsLoading"
:disabled="isReadOnly || remoteParameterOptionsLoading"
:title="displayTitle"
@change="valueChanged"
@keydown.stop
@focus="setFocus"
@blur="onBlur"
>
<n8n-option
v-for="option in parameterOptions"
:value="option.value"
:key="option.value"
:label="getOptionsOptionDisplayName(option)"
>
<div class="list-option">
<div class="option-headline">
{{ getOptionsOptionDisplayName(option) }}
<div v-if="isEditor === true" class="code-edit clickable" @click="displayEditDialog()">
<prism-editor
v-if="!codeEditDialogVisible"
:lineNumbers="true"
:readonly="true"
:code="displayValue"
language="js"
></prism-editor>
</div>
<n8n-input
v-else
v-model="tempValue"
ref="inputField"
:size="inputSize"
:type="getStringInputType"
:rows="getArgument('rows')"
:value="displayValue"
:disabled="isReadOnly"
@input="onTextInputChange"
@change="valueChanged"
@keydown.stop
@focus="setFocus"
@blur="onBlur"
:title="displayTitle"
:placeholder="getPlaceholder()"
>
<div slot="suffix" class="expand-input-icon-container">
<font-awesome-icon
v-if="!isReadOnly"
icon="external-link-alt"
class="edit-window-button clickable"
:title="$locale.baseText('parameterInput.openEditWindow')"
@click="displayEditDialog()"
/>
</div>
<div v-if="option.description" class="option-description" v-html="getOptionsOptionDescription(option)"></div>
</div>
</n8n-option>
</n8n-select>
</n8n-input>
</div>
<n8n-select
v-else-if="parameter.type === 'multiOptions'"
ref="inputField"
:size="inputSize"
filterable
multiple
:value="displayValue"
:loading="remoteParameterOptionsLoading"
:disabled="isReadOnly || remoteParameterOptionsLoading"
:title="displayTitle"
:placeholder="$locale.baseText('parameterInput.select')"
@change="valueChanged"
@keydown.stop
@focus="setFocus"
@blur="onBlur"
>
<n8n-option v-for="option in parameterOptions" :value="option.value" :key="option.value" :label="getOptionsOptionDisplayName(option)">
<div class="list-option">
<div class="option-headline">{{ getOptionsOptionDisplayName(option) }}</div>
<div v-if="option.description" class="option-description" v-html="getOptionsOptionDescription(option)"></div>
</div>
</n8n-option>
</n8n-select>
<div v-else-if="parameter.type === 'color'" ref="inputField" class="color-input">
<el-color-picker
size="small"
class="color-picker"
:value="displayValue"
:disabled="isReadOnly"
@focus="setFocus"
@blur="onBlur"
@change="valueChanged"
:title="displayTitle"
:show-alpha="getArgument('showAlpha')"
/>
<n8n-input
v-model="tempValue"
:size="inputSize"
type="text"
:value="tempValue"
:disabled="isReadOnly"
@change="valueChanged"
@keydown.stop
@focus="setFocus"
@blur="onBlur"
:title="displayTitle"
/>
</div>
<el-switch
v-else-if="parameter.type === 'boolean'"
class="switch-input"
ref="inputField"
active-color="#13ce66"
:value="displayValue"
:disabled="isReadOnly"
@change="valueChanged"
/>
</div>
<el-date-picker
v-else-if="parameter.type === 'dateTime'"
v-model="tempValue"
ref="inputField"
type="datetime"
:size="inputSize"
:value="displayValue"
:title="displayTitle"
:disabled="isReadOnly"
:placeholder="
parameter.placeholder
? getPlaceholder()
: $locale.baseText('parameterInput.selectDateAndTime')
"
:picker-options="dateTimePickerOptions"
@change="valueChanged"
@focus="setFocus"
@blur="onBlur"
@keydown.stop
/>
<parameter-issues
v-if="parameter.type !== 'credentialsSelect'"
:issues="getIssues"
/>
<n8n-input-number
v-else-if="parameter.type === 'number'"
ref="inputField"
:size="inputSize"
:value="displayValue"
:controls="false"
:max="getArgument('maxValue')"
:min="getArgument('minValue')"
:precision="getArgument('numberPrecision')"
:disabled="isReadOnly"
@change="valueChanged"
@input="onTextInputChange"
@focus="setFocus"
@blur="onBlur"
@keydown.stop
:title="displayTitle"
:placeholder="parameter.placeholder"
/>
<parameter-options
v-if="displayOptionsComputed && parameter.type !== 'credentialsSelect'"
:displayOptionsComputed="displayOptionsComputed"
:parameter="parameter"
:isValueExpression="isValueExpression"
:isDefault="isDefault"
:hasRemoteMethod="hasRemoteMethod"
@optionSelected="optionSelected"
/>
<credentials-select
v-else-if="
parameter.type === 'credentialsSelect' || parameter.name === 'genericAuthType'
"
ref="inputField"
:parameter="parameter"
:node="node"
:activeCredentialType="activeCredentialType"
:inputSize="inputSize"
:displayValue="displayValue"
:isReadOnly="isReadOnly"
:displayTitle="displayTitle"
@credentialSelected="credentialSelected"
@valueChanged="valueChanged"
@setFocus="setFocus"
@onBlur="onBlur"
>
<template v-slot:issues-and-options>
<parameter-issues :issues="getIssues" />
</template>
</credentials-select>
<n8n-select
v-else-if="parameter.type === 'options'"
ref="inputField"
:size="inputSize"
filterable
:value="displayValue"
:placeholder="
parameter.placeholder ? getPlaceholder() : $locale.baseText('parameterInput.select')
"
:loading="remoteParameterOptionsLoading"
:disabled="isReadOnly || remoteParameterOptionsLoading"
:title="displayTitle"
@change="valueChanged"
@keydown.stop
@focus="setFocus"
@blur="onBlur"
>
<n8n-option
v-for="option in parameterOptions"
:value="option.value"
:key="option.value"
:label="getOptionsOptionDisplayName(option)"
>
<div class="list-option">
<div class="option-headline">
{{ getOptionsOptionDisplayName(option) }}
</div>
<div
v-if="option.description"
class="option-description"
v-html="getOptionsOptionDescription(option)"
></div>
</div>
</n8n-option>
</n8n-select>
<n8n-select
v-else-if="parameter.type === 'multiOptions'"
ref="inputField"
:size="inputSize"
filterable
multiple
:value="displayValue"
:loading="remoteParameterOptionsLoading"
:disabled="isReadOnly || remoteParameterOptionsLoading"
:title="displayTitle"
:placeholder="$locale.baseText('parameterInput.select')"
@change="valueChanged"
@keydown.stop
@focus="setFocus"
@blur="onBlur"
>
<n8n-option
v-for="option in parameterOptions"
:value="option.value"
:key="option.value"
:label="getOptionsOptionDisplayName(option)"
>
<div class="list-option">
<div class="option-headline">{{ getOptionsOptionDisplayName(option) }}</div>
<div
v-if="option.description"
class="option-description"
v-html="getOptionsOptionDescription(option)"
></div>
</div>
</n8n-option>
</n8n-select>
<el-switch
v-else-if="parameter.type === 'boolean'"
class="switch-input"
ref="inputField"
active-color="#13ce66"
:value="displayValue"
:disabled="isReadOnly"
@change="valueChanged"
/>
</div>
<parameter-issues v-if="parameter.type !== 'credentialsSelect'" :issues="getIssues" />
</div>
</template>
@ -253,6 +295,7 @@ import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import mixins from 'vue-typed-mixins';
import { CUSTOM_API_CALL_KEY } from '@/constants';
import { mapGetters } from 'vuex';
import { hasExpressionMapping } from './helpers';
export default mixins(
externalHooks,
@ -274,7 +317,6 @@ export default mixins(
TextEdit,
},
props: [
'displayOptions', // boolean
'inputSize',
'isReadOnly',
'documentationUrl',
@ -285,6 +327,9 @@ export default mixins(
'errorHighlight',
'isForCredential', // boolean
'eventSource', // string
'activeDrop',
'droppable',
'forceShowExpression',
],
data () {
return {
@ -352,11 +397,6 @@ export default mixins(
codeAutocomplete (): string | undefined {
return this.getArgument('codeAutocomplete') as string | undefined;
},
showExpressionAsTextInput(): boolean {
const types = ['number', 'boolean', 'dateTime', 'options', 'multiOptions'];
return types.includes(this.parameter.type);
},
dependentParametersValues (): string | null {
const loadOptionsDependsOn = this.getArgument('loadOptionsDependsOn') as string[] | undefined;
@ -462,20 +502,6 @@ export default mixins(
return value;
},
displayOptionsComputed (): boolean {
if (this.isReadOnly === true) {
return false;
}
if (this.parameter.type === 'collection') {
return false;
}
if (this.displayOptions === true) {
return true;
}
return false;
},
expressionValueComputed (): NodeParameterValue | string[] | null {
if (this.areExpressionsDisabled) {
return this.value;
@ -513,6 +539,10 @@ export default mixins(
return 'textarea';
}
if (this.parameter.type === 'code') {
return 'textarea';
}
return 'text';
},
getIssues (): string[] {
@ -581,9 +611,6 @@ export default mixins(
return [];
},
isDefault (): boolean {
return this.parameter.default === this.value;
},
isEditor (): boolean {
return ['code', 'json'].includes(this.editorType);
},
@ -609,20 +636,26 @@ export default mixins(
return this.remoteParameterOptions;
},
parameterInputClasses () {
const classes = [];
const classes: {[c: string]: boolean} = {
droppable: this.droppable,
activeDrop: this.activeDrop,
};
const rows = this.getArgument('rows');
const isTextarea = this.parameter.type === 'string' && rows !== undefined;
const isSwitch = this.parameter.type === 'boolean' && !this.isValueExpression;
if (!isTextarea && !isSwitch) {
classes.push('parameter-value-container');
classes['parameter-value-container'] = true;
}
if (this.isValueExpression) {
classes.push('expression');
if (this.isValueExpression || this.forceShowExpression) {
classes['expression'] = true;
}
if (this.getIssues.length || this.errorHighlight) {
classes.push('has-issues');
if (!this.droppable && !this.activeDrop && (this.getIssues.length || this.errorHighlight)) {
classes['has-issues'] = true;
}
return classes;
},
parameterInputWrapperStyle () {
@ -633,9 +666,6 @@ export default mixins(
if (this.parameter.type === 'credentialsSelect') {
return styles;
}
if (this.displayOptionsComputed === true) {
deductWidth += 25;
}
if (this.getIssues.length) {
deductWidth += 20;
}
@ -866,8 +896,12 @@ export default mixins(
}
},
optionSelected (command: string) {
const prevValue = this.value;
if (command === 'resetValue') {
this.valueChanged(this.parameter.default);
} else if (command === 'openExpression') {
this.expressionEditDialogVisible = true;
} else if (command === 'addExpression') {
if (this.parameter.type === 'number' || this.parameter.type === 'boolean') {
this.valueChanged(`={{${this.value}}}`);
@ -876,8 +910,10 @@ export default mixins(
this.valueChanged(`=${this.value}`);
}
this.expressionEditDialogVisible = true;
this.trackExpressionEditOpen();
setTimeout(() => {
this.expressionEditDialogVisible = true;
this.trackExpressionEditOpen();
}, 375);
} else if (command === 'removeExpression') {
let value = this.expressionValueComputed;
@ -890,9 +926,23 @@ export default mixins(
} else if (command === 'refreshOptions') {
this.loadRemoteParameterOptions();
}
if (this.node && (command === 'addExpression' || command === 'removeExpression')) {
this.$telemetry.track('User switched parameter mode', {
node_type: this.node.type,
parameter: this.path,
old_mode: command === 'addExpression' ? 'fixed': 'expression',
new_mode: command === 'removeExpression' ? 'fixed': 'expression',
was_parameter_empty: prevValue === '' || prevValue === undefined,
had_mapping: hasExpressionMapping(prevValue),
had_parameter: typeof prevValue === 'string' && prevValue.includes('$parameter'),
});
}
},
},
mounted () {
this.$on('optionSelected', this.optionSelected);
this.tempValue = this.displayValue as string;
if (this.node !== null) {
this.nodeName = this.node.name;
@ -986,18 +1036,30 @@ export default mixins(
}
.expression {
textarea[disabled], input[disabled] {
textarea, input {
cursor: pointer !important;
}
.el-switch__core {
border: 1px dashed $--custom-expression-text;
}
--input-border-color: var(--color-secondary-tint-1);
--input-background-color: var(--color-secondary-tint-2);
--input-font-color: var(--color-secondary);
}
--input-border-color: #{$--custom-expression-text};
.droppable {
--input-border-color: var(--color-secondary-tint-1);
--input-background-color: var(--color-secondary-tint-2);
--input-border-style: dashed;
--input-background-color: #{$--custom-expression-background};
--disabled-border: #{$--custom-expression-text};
}
.activeDrop {
--input-border-color: var(--color-success);
--input-background-color: var(--color-success-tint-2);
--input-border-style: solid;
textarea, input {
cursor: grabbing !important;
}
}
.has-issues {

View file

@ -4,38 +4,53 @@
:tooltipText="$locale.credText().inputLabelDescription(parameter)"
:required="parameter.required"
:showTooltip="focused"
:showOptions="menuExpanded"
>
<parameter-input
:parameter="parameter"
:value="value"
:path="parameter.name"
:hideIssues="true"
:displayOptions="true"
:documentationUrl="documentationUrl"
:errorHighlight="showRequiredErrors"
:isForCredential="true"
@focus="onFocus"
@blur="onBlur"
@textInput="valueChanged"
@valueChanged="valueChanged"
inputSize="large"
:eventSource="eventSource"
/>
<div :class="$style.errors" v-if="showRequiredErrors">
<n8n-text color="danger" size="small">
{{ $locale.baseText('parameterInputExpanded.thisFieldIsRequired') }}
<n8n-link v-if="documentationUrl" :to="documentationUrl" size="small" :underline="true" @click="onDocumentationUrlClick">
{{ $locale.baseText('parameterInputExpanded.openDocs') }}
</n8n-link>
</n8n-text>
</div>
<input-hint :class="$style.hint" :hint="$locale.credText().hint(parameter)" />
<template #options>
<parameter-options
:parameter="parameter"
:value="value"
:isReadOnly="false"
:showOptions="true"
@optionSelected="optionSelected"
@menu-expanded="onMenuExpanded"
/>
</template>
<template>
<parameter-input
ref="param"
inputSize="large"
:parameter="parameter"
:value="value"
:path="parameter.name"
:hideIssues="true"
:displayOptions="true"
:documentationUrl="documentationUrl"
:errorHighlight="showRequiredErrors"
:isForCredential="true"
:eventSource="eventSource"
@focus="onFocus"
@blur="onBlur"
@textInput="valueChanged"
@valueChanged="valueChanged"
/>
<div :class="$style.errors" v-if="showRequiredErrors">
<n8n-text color="danger" size="small">
{{ $locale.baseText('parameterInputExpanded.thisFieldIsRequired') }}
<n8n-link v-if="documentationUrl" :to="documentationUrl" size="small" :underline="true" @click="onDocumentationUrlClick">
{{ $locale.baseText('parameterInputExpanded.openDocs') }}
</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';
@ -44,6 +59,7 @@ export default Vue.extend({
components: {
ParameterInput,
InputHint,
ParameterOptions,
},
props: {
parameter: {
@ -64,6 +80,7 @@ export default Vue.extend({
return {
focused: false,
blurredEver: false,
menuExpanded: false,
};
},
computed: {
@ -93,6 +110,14 @@ export default Vue.extend({
this.blurredEver = true;
this.focused = false;
},
onMenuExpanded(expanded: boolean) {
this.menuExpanded = expanded;
},
optionSelected (command: string) {
if (this.$refs.param) {
(this.$refs.param as Vue).$emit('optionSelected', command);
}
},
valueChanged(parameterData: IUpdateInformation) {
this.$emit('change', parameterData);
},

View file

@ -1,22 +1,43 @@
<template>
<n8n-input-label
:label="$locale.nodeText().inputLabelDisplayName(parameter, path)"
:tooltipText="$locale.nodeText().inputLabelDescription(parameter, path)"
:label="hideLabel? '': $locale.nodeText().inputLabelDisplayName(parameter, path)"
:tooltipText="hideLabel? '': $locale.nodeText().inputLabelDescription(parameter, path)"
:showTooltip="focused"
:showOptions="menuExpanded || focused || forceShowExpression"
:bold="false"
size="small"
>
<parameter-input
:parameter="parameter"
:value="value"
:displayOptions="displayOptions"
:path="path"
:isReadOnly="isReadOnly"
@valueChanged="valueChanged"
@focus="focused = true"
@blur="focused = false"
inputSize="small" />
<input-hint :class="$style.hint" :hint="$locale.nodeText().hint(parameter, path)" />
<template #options>
<parameter-options
:parameter="parameter"
:value="value"
:isReadOnly="isReadOnly"
:showOptions="displayOptions"
@optionSelected="optionSelected"
@menu-expanded="onMenuExpanded"
/>
</template>
<template>
<DraggableTarget type="mapping" :disabled="parameter.noDataExpression || isReadOnly" :sticky="true" :stickyOffset="4" @drop="onDrop">
<template v-slot="{ droppable, activeDrop }">
<parameter-input
ref="param"
:parameter="parameter"
:value="value"
:displayOptions="displayOptions"
:path="path"
:isReadOnly="isReadOnly"
:droppable="droppable"
:activeDrop="activeDrop"
:forceShowExpression="forceShowExpression"
@valueChanged="valueChanged"
@focus="onFocus"
@blur="onBlur"
inputSize="small" />
</template>
</DraggableTarget>
<input-hint :class="$style.hint" :hint="$locale.nodeText().hint(parameter, path)" />
</template>
</n8n-input-label>
</template>
@ -24,22 +45,35 @@
import Vue from 'vue';
import {
INodeUi,
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';
import mixins from 'vue-typed-mixins';
import { showMessage } from './mixins/showMessage';
import { LOCAL_STORAGE_MAPPING_FLAG } from '@/constants';
import { hasExpressionMapping } from './helpers';
export default Vue
export default mixins(
showMessage,
)
.extend({
name: 'ParameterInputFull',
components: {
ParameterInput,
InputHint,
ParameterOptions,
DraggableTarget,
},
data() {
return {
focused: false,
menuExpanded: false,
forceShowExpression: false,
};
},
props: [
@ -48,22 +82,80 @@ export default Vue
'parameter',
'path',
'value',
'hideLabel',
],
computed: {
node (): INodeUi | null {
return this.$store.getters.activeNode;
},
},
methods: {
getArgument (argumentName: string): string | number | boolean | undefined {
if (this.parameter.typeOptions === undefined) {
return undefined;
onFocus() {
this.focused = true;
if (!this.parameter.noDataExpression) {
this.$store.commit('ui/setMappableNDVInputFocus', this.parameter.displayName);
}
if (this.parameter.typeOptions[argumentName] === undefined) {
return undefined;
},
onBlur() {
this.focused = false;
if (!this.parameter.noDataExpression) {
this.$store.commit('ui/setMappableNDVInputFocus', '');
}
},
onMenuExpanded(expanded: boolean) {
this.menuExpanded = expanded;
},
optionSelected (command: string) {
if (this.$refs.param) {
(this.$refs.param as Vue).$emit('optionSelected', command);
}
return this.parameter.typeOptions[argumentName];
},
valueChanged (parameterData: IUpdateInformation) {
this.$emit('valueChanged', parameterData);
},
onDrop(data: string) {
this.forceShowExpression = true;
setTimeout(() => {
if (this.node) {
const prevValue = this.value;
let updatedValue: string;
if (typeof prevValue === 'string' && prevValue.startsWith('=') && prevValue.length > 1) {
updatedValue = `${prevValue} ${data}`;
}
else {
updatedValue = `=${data}`;
}
const parameterData = {
node: this.node.name,
name: this.path,
value: updatedValue,
};
this.$emit('valueChanged', parameterData);
if (window.localStorage.getItem(LOCAL_STORAGE_MAPPING_FLAG) !== 'true') {
this.$showMessage({
title: this.$locale.baseText('dataMapping.success.title'),
message: this.$locale.baseText('dataMapping.success.moreInfo'),
type: 'success',
});
window.localStorage.setItem(LOCAL_STORAGE_MAPPING_FLAG, 'true');
}
this.$store.commit('ui/setMappingTelemetry', {
dest_node_type: this.node.type,
dest_parameter: this.path,
dest_parameter_mode: typeof prevValue === 'string' && prevValue.startsWith('=')? 'expression': 'fixed',
dest_parameter_empty: prevValue === '' || prevValue === undefined,
dest_parameter_had_mapping: typeof prevValue === 'string' && prevValue.startsWith('=') && hasExpressionMapping(prevValue),
success: true,
});
}
this.forceShowExpression = false;
}, 200);
},
},
});
</script>

View file

@ -40,25 +40,23 @@
:tooltipText="$locale.nodeText().inputLabelDescription(parameter, path)"
size="small"
:underline="true"
:labelHoverableOnly="true"
>
<collection-parameter
v-if="parameter.type === 'collection'"
:parameter="parameter"
:values="getParameterValue(nodeValues, parameter.name, path)"
:nodeValues="nodeValues"
:path="getPath(parameter.name)"
@valueChanged="valueChanged"
/>
<fixed-collection-parameter
v-else-if="parameter.type === 'fixedCollection'"
:parameter="parameter"
:values="getParameterValue(nodeValues, parameter.name, path)"
:nodeValues="nodeValues"
:path="getPath(parameter.name)"
@valueChanged="valueChanged"
/>
</n8n-input-label>
/>
<collection-parameter
v-if="parameter.type === 'collection'"
:parameter="parameter"
:values="getParameterValue(nodeValues, parameter.name, path)"
:nodeValues="nodeValues"
:path="getPath(parameter.name)"
@valueChanged="valueChanged"
/>
<fixed-collection-parameter
v-else-if="parameter.type === 'fixedCollection'"
:parameter="parameter"
:values="getParameterValue(nodeValues, parameter.name, path)"
:nodeValues="nodeValues"
:path="getPath(parameter.name)"
@valueChanged="valueChanged"
/>
</div>
<div v-else-if="displayNodeParameter(parameter)" class="parameter-item">

View file

@ -1,45 +1,26 @@
<template>
<div :class="$style['parameter-options']">
<el-dropdown
trigger="click"
@command="(opt) => $emit('optionSelected', opt)"
size="mini"
>
<span class="el-dropdown-link">
<font-awesome-icon
icon="cogs"
class="reset-icon clickable"
:title="$locale.baseText('parameterInput.parameterOptions')"
/>
</span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item
v-if="parameter.noDataExpression !== true && !isValueExpression"
command="addExpression"
>
{{ $locale.baseText('parameterInput.addExpression') }}
</el-dropdown-item>
<el-dropdown-item
v-if="parameter.noDataExpression !== true && isValueExpression"
command="removeExpression"
>
{{ $locale.baseText('parameterInput.removeExpression') }}
</el-dropdown-item>
<el-dropdown-item
v-if="hasRemoteMethod"
command="refreshOptions"
>
{{ $locale.baseText('parameterInput.refreshList') }}
</el-dropdown-item>
<el-dropdown-item
command="resetValue"
:disabled="isDefault"
divided
>
{{ $locale.baseText('parameterInput.resetValue') }}
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
<div :class="$style.container">
<n8n-action-toggle
v-if="shouldShowOptions"
placement="bottom-end"
size="small"
color="foreground-xdark"
iconSize="small"
:actions="actions"
@action="(action) => $emit('optionSelected', action)"
@visible-change="onMenuToggle"
/>
<n8n-radio-buttons
v-if="parameter.noDataExpression !== true"
size="small"
:value="selectedView"
:disabled="isReadOnly"
@input="onViewSelected"
:options="[
{ label: $locale.baseText('parameterInput.fixed'), value: 'fixed'},
{ label: $locale.baseText('parameterInput.expression'), value: 'expression'},
]"
/>
</div>
</template>
@ -49,20 +30,101 @@ import Vue from 'vue';
export default Vue.extend({
name: 'ParameterOptions',
props: [
'displayOptionsComputed',
'optionSelected',
'parameter',
'isValueExpression',
'isDefault',
'hasRemoteMethod',
'isReadOnly',
'value',
'showOptions',
],
computed: {
isDefault (): boolean {
return this.parameter.default === this.value;
},
shouldShowOptions (): boolean {
if (this.isReadOnly === true) {
return false;
}
if (this.parameter.type === 'collection' || this.parameter.type === 'credentialsSelect') {
return false;
}
if (this.showOptions === true) {
return true;
}
return false;
},
selectedView () {
if (this.isValueExpression) {
return 'expression';
}
return 'fixed';
},
isValueExpression () {
if (this.parameter.noDataExpression === true) {
return false;
}
if (typeof this.value === 'string' && this.value.charAt(0) === '=') {
return true;
}
return false;
},
hasRemoteMethod (): boolean {
return !!this.getArgument('loadOptionsMethod') || !!this.getArgument('loadOptions');
},
actions (): Array<{label: string, value: string, disabled?: boolean}> {
const actions = [
{
label: this.$locale.baseText('parameterInput.resetValue'),
value: 'resetValue',
disabled: this.isDefault,
},
];
if (this.hasRemoteMethod) {
return [
{
label: this.$locale.baseText('parameterInput.refreshList'),
value: 'refreshOptions',
},
...actions,
];
}
return actions;
},
},
methods: {
onMenuToggle(visible: boolean) {
this.$emit('menu-expanded', visible);
},
onViewSelected(selected: string) {
if (selected === 'expression' ) {
this.$emit('optionSelected', this.isValueExpression? 'openExpression': 'addExpression');
}
if (selected === 'fixed' && this.isValueExpression) {
this.$emit('optionSelected', 'removeExpression');
}
},
getArgument (argumentName: string): string | number | boolean | undefined {
if (this.parameter.typeOptions === undefined) {
return undefined;
}
if (this.parameter.typeOptions[argumentName] === undefined) {
return undefined;
}
return this.parameter.typeOptions[argumentName];
},
},
});
</script>
<style module lang="scss">
.parameter-options {
width: 25px;
text-align: right;
float: right;
<style lang="scss" module>
.container {
display: flex;
}
</style>

View file

@ -102,28 +102,8 @@
</n8n-text>
</div>
<div v-else-if="hasNodeRun && displayMode === 'table' && tableData && tableData.columns && tableData.columns.length === 0" :class="$style.dataDisplay">
<table :class="$style.table">
<tr>
<th :class="$style.emptyCell"></th>
</tr>
<tr v-for="(row, index1) in tableData.data" :key="index1">
<td>
<n8n-text>{{ $locale.baseText('runData.emptyItemHint') }}</n8n-text>
</td>
</tr>
</table>
</div>
<div v-else-if="hasNodeRun && displayMode === 'table' && tableData" :class="$style.dataDisplay">
<table :class="$style.table">
<tr>
<th v-for="column in (tableData.columns || [])" :key="column">{{column}}</th>
</tr>
<tr v-for="(row, index1) in tableData.data" :key="index1">
<td v-for="(data, index2) in row" :key="index2">{{ [null, undefined].includes(data) ? '&nbsp;' : data }}</td>
</tr>
</table>
<RunDataTable :node="node" :tableData="tableData" :mappingEnabled="mappingEnabled" :distanceFromActive="distanceFromActive" :showMappingHint="showMappingHint" :runIndex="runIndex" :totalRuns="maxRunIndex" />
</div>
<div v-else-if="hasNodeRun && displayMode === 'json'" :class="$style.jsonDisplay">
@ -261,6 +241,7 @@ import { nodeHelpers } from '@/components/mixins/nodeHelpers';
import mixins from 'vue-typed-mixins';
import { saveAs } from 'file-saver';
import RunDataTable from './RunDataTable.vue';
// A path that does not exist so that nothing is selected by default
const deselectedPlaceholder = '_!^&*';
@ -278,6 +259,7 @@ export default mixins(
NodeErrorView,
VueJsonPretty,
WarningTooltip,
RunDataTable,
},
props: {
nodeUi: {
@ -312,6 +294,15 @@ export default mixins(
overrideOutputs: {
type: Array,
},
mappingEnabled: {
type: Boolean,
},
distanceFromActive: {
type: Number,
},
showMappingHint: {
type: Boolean,
},
},
data () {
return {
@ -924,41 +915,6 @@ export default mixins(
margin-bottom: var(--spacing-s);
}
.table {
border-collapse: separate;
text-align: left;
width: calc(100% - var(--spacing-s));
margin-right: var(--spacing-s);
font-size: var(--font-size-s);
th {
padding: var(--spacing-2xs);
background-color: var(--color-background-base);
border-top: var(--border-base);
border-bottom: var(--border-base);
border-left: var(--border-base);
position: sticky;
top: 0;
}
td {
padding: var(--spacing-2xs);
border-bottom: var(--border-base);
border-left: var(--border-base);
overflow-wrap: break-word;
max-width: 300px;
white-space: pre-wrap;
}
th:last-child, td:last-child {
border-right: var(--border-base);
}
}
.emptyCell {
height: 32px;
}
.itemsCount {
margin-left: var(--spacing-s);
margin-bottom: var(--spacing-s);

View file

@ -0,0 +1,287 @@
<template>
<div>
<table :class="$style.table" v-if="tableData.columns && tableData.columns.length === 0">
<tr>
<th :class="$style.emptyCell"></th>
</tr>
<tr v-for="(row, index1) in tableData.data" :key="index1">
<td>
<n8n-text>{{ $locale.baseText('runData.emptyItemHint') }}</n8n-text>
</td>
</tr>
</table>
<table :class="$style.table" v-else>
<thead>
<tr>
<th v-for="(column, i) in tableData.columns || []" :key="column">
<n8n-tooltip placement="bottom-start" :disabled="!mappingEnabled || showHintWithDelay" :open-delay="1000">
<div slot="content">{{ $locale.baseText('dataMapping.dragColumnToFieldHint') }}</div>
<Draggable type="mapping" :data="getExpression(column)" :disabled="!mappingEnabled" @dragstart="onDragStart" @dragend="(column) => onDragEnd(column)">
<template v-slot:preview="{ canDrop }">
<div :class="[$style.dragPill, canDrop ? $style.droppablePill: $style.defaultPill]">
{{ $locale.baseText('dataMapping.mapSpecificColumnToField', { interpolate: { name: shorten(column, 16, 2) } }) }}
</div>
</template>
<template v-slot="{ isDragging }">
<div
:class="{
[$style.header]: true,
[$style.draggableHeader]: mappingEnabled,
[$style.activeHeader]: (i === activeColumn || forceShowGrip) && mappingEnabled,
[$style.draggingHeader]: isDragging,
}"
>
<span>{{ column || "&nbsp;" }}</span>
<n8n-tooltip v-if="mappingEnabled" placement="bottom-start" :manual="true" :value="i === 0 && showHintWithDelay">
<div v-if="focusedMappableInput" slot="content" v-html="$locale.baseText('dataMapping.tableHint', { interpolate: { name: focusedMappableInput } })"></div>
<div v-else slot="content" v-html="$locale.baseText('dataMapping.dragColumnToFieldHint')"></div>
<div :class="$style.dragButton">
<font-awesome-icon icon="grip-vertical" />
</div>
</n8n-tooltip>
</div>
</template>
</Draggable>
</n8n-tooltip>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index1) in tableData.data" :key="index1">
<td
v-for="(data, index2) in row"
:key="index2"
:data-col="index2"
@mouseenter="onMouseEnterCell"
@mouseleave="onMouseLeaveCell"
>
{{ [null, undefined].includes(data) ? '&nbsp;' : data }}
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script lang="ts">
import { LOCAL_STORAGE_MAPPING_FLAG } from '@/constants';
import { INodeUi, ITableData } from '@/Interface';
import Vue from 'vue';
import Draggable from './Draggable.vue';
import { shorten } from './helpers';
export default Vue.extend({
name: 'RunDataTable',
components: { Draggable },
props: {
node: {
type: Object as () => INodeUi,
},
tableData: {
type: Object as () => ITableData,
},
mappingEnabled: {
type: Boolean,
},
distanceFromActive: {
type: Number,
},
showMappingHint: {
type: Boolean,
},
runIndex: {
type: Number,
},
totalRuns: {
type: Number,
},
},
data() {
return {
activeColumn: -1,
showHintWithDelay: false,
forceShowGrip: false,
draggedColumn: false,
};
},
mounted() {
if (this.showMappingHint && this.showHint) {
setTimeout(() => {
this.showHintWithDelay = this.showHint;
this.$telemetry.track('User viewed data mapping tooltip', { type: 'param focus' });
}, 500);
}
},
computed: {
focusedMappableInput (): string {
return this.$store.getters['ui/focusedMappableInput'];
},
showHint (): boolean {
return !this.draggedColumn && (this.showMappingHint || (!!this.focusedMappableInput && window.localStorage.getItem(LOCAL_STORAGE_MAPPING_FLAG) !== 'true'));
},
},
methods: {
shorten,
onMouseEnterCell(e: MouseEvent) {
const target = e.target;
if (target && this.mappingEnabled) {
const col = (target as HTMLElement).dataset.col;
if (col && !isNaN(parseInt(col, 10))) {
this.activeColumn = parseInt(col, 10);
}
}
},
onMouseLeaveCell() {
this.activeColumn = -1;
},
getExpression(column: string) {
if (!this.node) {
return '';
}
if (this.distanceFromActive === 1) {
return `{{ $json["${column}"] }}`;
}
return `{{ $node["${this.node.name}"].json["${column}"] }}`;
},
onDragStart() {
this.draggedColumn = true;
this.$store.commit('ui/resetMappingTelemetry');
},
onDragEnd(column: string) {
setTimeout(() => {
const mappingTelemetry = this.$store.getters['ui/mappingTelemetry'];
this.$telemetry.track('User dragged data for mapping', {
src_node_type: this.node.type,
src_field_name: column,
src_nodes_back: this.distanceFromActive,
src_run_index: this.runIndex,
src_runs_total: this.totalRuns,
src_view: 'table',
src_element: 'column',
success: false,
...mappingTelemetry,
});
}, 1000); // ensure dest data gets set if drop
},
},
watch: {
focusedMappableInput (curr: boolean) {
setTimeout(() => {
this.forceShowGrip = !!this.focusedMappableInput;
}, curr? 300: 150);
},
showHint (curr: boolean, prev: boolean) {
if (curr) {
setTimeout(() => {
this.showHintWithDelay = this.showHint;
if (this.showHintWithDelay) {
this.$telemetry.track('User viewed data mapping tooltip', { type: 'param focus' });
}
}, 1000);
}
else {
this.showHintWithDelay = false;
}
},
},
});
</script>
<style lang="scss" module>
.table {
border-collapse: separate;
text-align: left;
width: calc(100% - var(--spacing-s));
margin-right: var(--spacing-s);
font-size: var(--font-size-s);
th {
background-color: var(--color-background-base);
border-top: var(--border-base);
border-bottom: var(--border-base);
border-left: var(--border-base);
position: sticky;
top: 0;
max-width: 300px;
}
td {
padding: var(--spacing-2xs);
border-bottom: var(--border-base);
border-left: var(--border-base);
overflow-wrap: break-word;
max-width: 300px;
white-space: pre-wrap;
}
th:last-child,
td:last-child {
border-right: var(--border-base);
}
}
.emptyCell {
height: 32px;
}
.header {
display: flex;
align-items: center;
padding: var(--spacing-2xs);
span {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
flex-grow: 1;
}
}
.draggableHeader {
&:hover {
cursor: grab;
background-color: var(--color-foreground-base);
.dragButton {
opacity: 1;
}
}
}
.draggingHeader {
background-color: var(--color-primary-tint-2);
}
.activeHeader {
.dragButton {
opacity: 1;
}
}
.dragButton {
opacity: 0;
margin-left: var(--spacing-2xs);
}
.dragPill {
padding: var(--spacing-4xs) var(--spacing-4xs) var(--spacing-3xs) var(--spacing-4xs);
color: var(--color-text-xlight);
font-weight: var(--font-weight-bold);
font-size: var(--font-size-2xs);
border-radius: var(--border-radius-base);
white-space: nowrap;
}
.droppablePill {
background-color: var(--color-success);
}
.defaultPill {
background-color: var(--color-primary);
transform: translate(-50%, -100%);
box-shadow: 0px 2px 6px rgba(68, 28, 23, 0.2);
}
</style>

View file

@ -6,6 +6,7 @@
<script lang="ts">
import Vue from "vue";
import { shorten } from "./helpers";
const DEFAULT_WORKFLOW_NAME_LIMIT = 25;
const WORKFLOW_NAME_END_COUNT_TO_KEEP = 4;
@ -15,17 +16,7 @@ export default Vue.extend({
props: ["name", "limit"],
computed: {
shortenedName(): string {
const name = this.$props.name;
const limit = this.$props.limit || DEFAULT_WORKFLOW_NAME_LIMIT;
if (name.length <= limit) {
return name;
}
const first = name.slice(0, limit - WORKFLOW_NAME_END_COUNT_TO_KEEP);
const last = name.slice(name.length - WORKFLOW_NAME_END_COUNT_TO_KEEP, name.length);
return `${first}...${last}`;
return shorten(this.name, this.limit || DEFAULT_WORKFLOW_NAME_LIMIT, WORKFLOW_NAME_END_COUNT_TO_KEEP);
},
},
});

View file

@ -1,4 +1,4 @@
import { CORE_NODES_CATEGORY, ERROR_TRIGGER_NODE_TYPE, TEMPLATES_NODES_FILTER } from '@/constants';
import { CORE_NODES_CATEGORY, ERROR_TRIGGER_NODE_TYPE, MAPPING_PARAMS, TEMPLATES_NODES_FILTER } from '@/constants';
import { INodeUi, ITemplatesNode } from '@/Interface';
import dateformat from 'dateformat';
import { INodeTypeDescription } from 'n8n-workflow';
@ -68,3 +68,18 @@ export function isString(value: unknown): value is string {
export function isNumber(value: unknown): value is number {
return typeof value === 'number';
}
export function shorten(s: string, limit: number, keep: number) {
if (s.length <= limit) {
return s;
}
const first = s.slice(0, limit - keep);
const last = s.slice(s.length - keep, s.length);
return `${first}...${last}`;
}
export function hasExpressionMapping(value: unknown) {
return typeof value === 'string' && !!MAPPING_PARAMS.find((param) => value.includes(param));
}

View file

@ -63,6 +63,7 @@ export const EXECUTE_COMMAND_NODE_TYPE = 'n8n-nodes-base.executeCommand';
export const HTTP_REQUEST_NODE_TYPE = 'n8n-nodes-base.httpRequest';
export const HUBSPOT_TRIGGER_NODE_TYPE = 'n8n-nodes-base.hubspotTrigger';
export const IF_NODE_TYPE = 'n8n-nodes-base.if';
export const INTERVAL_NODE_TYPE = 'n8n-nodes-base.interval';
export const ITEM_LISTS_NODE_TYPE = 'n8n-nodes-base.itemLists';
export const JIRA_NODE_TYPE = 'n8n-nodes-base.jira';
export const JIRA_TRIGGER_NODE_TYPE = 'n8n-nodes-base.jiraTrigger';
@ -193,6 +194,7 @@ export const MODAL_CONFIRMED = 'confirmed';
export const VALID_EMAIL_REGEX = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
export const LOCAL_STORAGE_ACTIVATION_FLAG = 'N8N_HIDE_ACTIVATION_ALERT';
export const LOCAL_STORAGE_MAPPING_FLAG = 'N8N_MAPPING_ONBOARDED';
export const BASE_NODE_SURVEY_URL = 'https://n8n-community.typeform.com/to/BvmzxqYv#nodename=';
export const HIRING_BANNER = `
@ -242,3 +244,5 @@ export enum VIEWS {
API_SETTINGS = "APISettings",
NOT_FOUND = "NotFoundView",
}
export const MAPPING_PARAMS = [`$evaluateExpression`, `$item`, `$jmespath`, `$node`, `$binary`, `$data`, `$env`, `$json`, `$now`, `$parameters`, `$position`, `$resumeWebhookUrl`, `$runIndex`, `$today`, `$workflow`, '$parameter'];

View file

@ -24,6 +24,7 @@ import {
IRootState,
IRunDataDisplayMode,
IUiState,
XYPosition,
} from '../Interface';
const module: Module<IUiState, IRootState> = {
@ -97,8 +98,17 @@ const module: Module<IUiState, IRootState> = {
output: {
displayMode: 'table',
},
focusedMappableInput: '',
mappingTelemetry: {},
},
mainPanelPosition: 0.5,
draggable: {
isDragging: false,
type: '',
data: '',
canDrop: false,
stickyPosition: null,
},
},
getters: {
areExpressionsDisabled(state: IUiState) {
@ -127,6 +137,13 @@ const module: Module<IUiState, IRootState> = {
inputPanelDispalyMode: (state: IUiState) => state.ndv.input.displayMode,
outputPanelDispalyMode: (state: IUiState) => state.ndv.output.displayMode,
mainPanelPosition: (state: IUiState) => state.mainPanelPosition,
focusedMappableInput: (state: IUiState) => state.ndv.focusedMappableInput,
isDraggableDragging: (state: IUiState) => state.draggable.isDragging,
draggableType: (state: IUiState) => state.draggable.type,
draggableData: (state: IUiState) => state.draggable.data,
canDraggableDrop: (state: IUiState) => state.draggable.canDrop,
draggableStickyPos: (state: IUiState) => state.draggable.stickyPosition,
mappingTelemetry: (state: IUiState) => state.ndv.mappingTelemetry,
},
mutations: {
setMode: (state: IUiState, params: {name: string, mode: string}) => {
@ -173,7 +190,39 @@ const module: Module<IUiState, IRootState> = {
setMainPanelRelativePosition(state: IUiState, relativePosition: number) {
state.mainPanelPosition = relativePosition;
},
setMappableNDVInputFocus(state: IUiState, paramName: string) {
Vue.set(state.ndv, 'focusedMappableInput', paramName);
},
draggableStartDragging(state: IUiState, {type, data}: {type: string, data: string}) {
state.draggable = {
isDragging: true,
type,
data,
canDrop: false,
stickyPosition: null,
};
},
draggableStopDragging(state: IUiState) {
state.draggable = {
isDragging: false,
type: '',
data: '',
canDrop: false,
stickyPosition: null,
};
},
setDraggableStickyPos(state: IUiState, position: XYPosition | null) {
Vue.set(state.draggable, 'stickyPosition', position);
},
setDraggableCanDrop(state: IUiState, canDrop: boolean) {
Vue.set(state.draggable, 'canDrop', canDrop);
},
setMappingTelemetry(state: IUiState, telemetery: {[key: string]: string | number | boolean}) {
state.ndv.mappingTelemetry = {...state.ndv.mappingTelemetry, ...telemetery};
},
resetMappingTelemetry(state: IUiState) {
state.ndv.mappingTelemetry = {};
},
},
actions: {
openModal: async (context: ActionContext<IUiState, IRootState>, modalKey: string) => {

View file

@ -152,6 +152,12 @@
"dataDisplay.needHelp": "Need help?",
"dataDisplay.nodeDocumentation": "Node Documentation",
"dataDisplay.openDocumentationFor": "Open {nodeTypeDisplayName} documentation",
"dataMapping.dragColumnToFieldHint": "Drag onto a field to map column to that field",
"dataMapping.dragFromPreviousHint": "Map data from previous nodes to <b>{name}</b><br/> by first clicking this button",
"dataMapping.success.title": "You just mapped some data!",
"dataMapping.success.moreInfo": "Check out our <a href=\"https://docs.n8n.io/data/data-mapping\" target=\"_blank\">docs</a> for more details on mapping data in n8n",
"dataMapping.tableHint": "Drag a column onto <b>{name}</b> to map it",
"dataMapping.mapSpecificColumnToField": "Map {name} to field",
"displayWithChange.cancelEdit": "Cancel Edit",
"displayWithChange.clickToChange": "Click to Change",
"displayWithChange.setValue": "Set Value",
@ -521,9 +527,10 @@
"onboardingWorkflow.stickyContent": "## 👇 Get started faster \nLightning tour of the key concepts \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.addExpression": "Add Expression",
"parameterInput.customApiCall": "Custom API Call",
"parameterInput.error": "ERROR",
"parameterInput.expression": "Expression",
"parameterInput.fixed": "Fixed",
"parameterInput.issues": "Issues",
"parameterInput.loadingOptions": "Loading options...",
"parameterInput.openEditWindow": "Open Edit Window",
@ -531,9 +538,7 @@
"parameterInput.parameterHasExpression": "Parameter: \"{shortPath}\" has an expression",
"parameterInput.parameterHasIssues": "Parameter: \"{shortPath}\" has issues",
"parameterInput.parameterHasIssuesAndExpression": "Parameter: \"{shortPath}\" has issues and an expression",
"parameterInput.parameterOptions": "Parameter Options",
"parameterInput.refreshList": "Refresh List",
"parameterInput.removeExpression": "Remove Expression",
"parameterInput.resetValue": "Reset Value",
"parameterInput.select": "Select",
"parameterInput.selectDateAndTime": "Select date and time",

View file

@ -48,6 +48,7 @@ import {
faFolderOpen,
faGift,
faGraduationCap,
faGripVertical,
faHdd,
faHome,
faHourglass,
@ -137,6 +138,7 @@ addIcon(faCloudDownloadAlt);
addIcon(faCopy);
addIcon(faCut);
addIcon(faDotCircle);
addIcon(faGripVertical);
addIcon(faEdit);
addIcon(faEllipsisV);
addIcon(faEnvelope);