feat(editor): mapping expressions from input table (#3864)

* implement tree render

* update styles

* implement slots

* fix recursive tree rendering

* make not recursive

* Revert "make not recursive"

f064fc14f4

* enable dragging

* fix dragging name

* fix col bug

* update values and styles

* update style

* update colors

* update design

* add hover state

* add dragging behavior

* format file

* update pill text

* add depth field

* typo

* add avg height

* update event name

* update expr at distance

* add right margin always

* add space

* handle long values

* update types

* update messages

* update keys styling

* update spacing size

* fix hover bug

* update switch spacing

* fix wrap issue

* update spacing issues

* remove br

* update hoverable

* reduce event

* replace tree

* update prop name

* update tree story

* update tree

* refactor run data

* add unit tests

* add test for nodeclass

* remove number check

* bring back hook

* address review comments

* update margin

* update tests

* address max's feedback

* update tslint issues

* if empty, remove min width

* update spacing back
This commit is contained in:
Mutasem Aldmour 2022-08-24 14:47:42 +02:00 committed by GitHub
parent 7d74ddab29
commit ce076dca48
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 736 additions and 126 deletions

View file

@ -0,0 +1,57 @@
/* tslint:disable:variable-name */
import N8nTree from './Tree.vue';
import {StoryFn} from "@storybook/vue";
export default {
title: 'Atoms/Tree',
component: N8nTree,
};
export const Default: StoryFn = (args, {argTypes}) => ({
props: Object.keys(argTypes),
components: {
N8nTree,
},
template: `<n8n-tree v-bind="$props">
<template v-slot:label="{ label }">
<span>{{ label }}</span>
</template>
<template v-slot:value="{ value }">
<span>{{ value }}</span>
</template>
</n8n-tree>`,
});
Default.args = {
value: {
objectKey: {
nestedArrayKey: [
'in progress',
33958053,
],
stringKey: 'word',
aLongKey: 'Lorem ipsum dolor sit consectetur adipiscing elit. Sed dignissim aliquam ipsum mattis pellentesque. Phasellus ut ligula fermentum orci elementum dignissim. Vivamus interdum risus eget nibh placerat ultrices. Vivamus orci arcu, iaculis in nulla non, blandit molestie magna. Praesent tristique feugiat odio non vehicula. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse fermentum purus diam, nec auctor elit consectetur nec. Vestibulum ultrices diam magna, in faucibus odio bibendum id. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut sollicitudin lacus neque.',
objectKey: {
myKey: 'what\'s for lunch',
yourKey: 'prolle rewe wdyt',
},
id: 123,
},
hello: "world",
test: {
label: "A cool folder",
children: [
{
label: "A cool sub-folder 1",
children: [
{ label: "A cool sub-sub-folder 1" },
{ label: "A cool sub-sub-folder 2" },
],
},
{ label: "This one is not that cool" },
],
},
},
};

View file

@ -0,0 +1,82 @@
<template>
<div class="n8n-tree">
<div v-for="(label, i) in Object.keys(value || {})" :key="i" :class="{[nodeClass]: !!nodeClass, [$style.indent]: depth > 0}">
<div :class="$style.simple" v-if="isSimple(value[label])">
<slot v-if="$scopedSlots.label" name="label" v-bind:label="label" v-bind:path="getPath(label)" />
<span v-else>{{ label }}</span>
<span>:</span>
<slot v-if="$scopedSlots.value" name="value" v-bind:value="value[label]" />
<span v-else>{{ value[label] }}</span>
</div>
<div v-else>
<slot v-if="$scopedSlots.label" name="label" v-bind:label="label" v-bind:path="getPath(label)" />
<span v-else>{{ label }}</span>
<n8n-tree :path="getPath(label)" :depth="depth + 1" :value="value[label]" :nodeClass="nodeClass">
<template v-for="(index, name) in $scopedSlots" v-slot:[name]="data">
<slot :name="name" v-bind="data"></slot>
</template>
</n8n-tree>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
name: 'n8n-tree',
components: {
},
props: {
value: {
},
path: {
type: Array,
default: () => [],
},
depth: {
type: Number,
default: 0,
},
nodeClass: {
type: String,
},
},
methods: {
isSimple(data: unkown): boolean {
if (typeof data === 'object' && Object.keys(data).length === 0) {
return true;
}
if (Array.isArray(data) && data.length === 0) {
return true;
}
return typeof data !== 'object';
},
getPath(key: string): string[] {
if (Array.isArray(this.value)) {
return [...this.path, parseInt(key, 10)];
}
return [...this.path, key];
},
},
});
</script>
<style lang="scss" module>
$--spacing: var(--spacing-s);
.indent {
margin-left: $--spacing;
}
.simple {
text-indent: calc($--spacing * -1);
margin-left: $--spacing;
max-width: 300px;
}
</style>

View file

@ -0,0 +1,74 @@
import { render } from '@testing-library/vue';
import N8nTree from '../Tree.vue';
describe('components', () => {
describe('N8nTree', () => {
it('should render simple tree', () => {
const wrapper = render(N8nTree, {
props: {
value: {
"hello": "world",
},
},
});
expect(wrapper.html()).toMatchSnapshot();
});
it('should render tree', () => {
const wrapper = render(N8nTree, {
props: {
value: {
"hello": {
"test": "world",
},
"options": [
"yes",
"no",
],
},
},
});
expect(wrapper.html()).toMatchSnapshot();
});
it('should render tree with slots', () => {
const wrapper = render(N8nTree, {
props: {
value: {
"hello": {
"test": "world",
},
"options": [
"yes",
"no",
],
},
},
slots: {
label: "<span>label</span>",
value: "<span>value</span>",
},
});
expect(wrapper.html()).toMatchSnapshot();
});
it('should render each tree with node class', () => {
const wrapper = render(N8nTree, {
props: {
value: {
"hello": {
"test": "world",
},
"options": [
"yes",
"no",
],
},
nodeClass: "nodeClass",
},
});
expect(wrapper.html()).toMatchSnapshot();
});
});
});

View file

@ -0,0 +1,87 @@
// Vitest Snapshot v1
exports[`components > N8nTree > should render each tree with node class 1`] = `
"<div class=\\"n8n-tree\\">
<div class=\\"nodeClass\\">
<div><span>hello</span>
<div class=\\"n8n-tree\\">
<div class=\\"nodeClass _indent_1y4uu_1\\">
<div class=\\"_simple_1y4uu_5\\"><span>test</span><span>:</span><span>world</span></div>
</div>
</div>
</div>
</div>
<div class=\\"nodeClass\\">
<div><span>options</span>
<div class=\\"n8n-tree\\">
<div class=\\"nodeClass _indent_1y4uu_1\\">
<div class=\\"_simple_1y4uu_5\\"><span>0</span><span>:</span><span>yes</span></div>
</div>
<div class=\\"nodeClass _indent_1y4uu_1\\">
<div class=\\"_simple_1y4uu_5\\"><span>1</span><span>:</span><span>no</span></div>
</div>
</div>
</div>
</div>
</div>"
`;
exports[`components > N8nTree > should render simple tree 1`] = `
"<div class=\\"n8n-tree\\">
<div class=\\"\\">
<div class=\\"_simple_1y4uu_5\\"><span>hello</span><span>:</span><span>world</span></div>
</div>
</div>"
`;
exports[`components > N8nTree > should render tree 1`] = `
"<div class=\\"n8n-tree\\">
<div class=\\"\\">
<div><span>hello</span>
<div class=\\"n8n-tree\\">
<div class=\\"_indent_1y4uu_1\\">
<div class=\\"_simple_1y4uu_5\\"><span>test</span><span>:</span><span>world</span></div>
</div>
</div>
</div>
</div>
<div class=\\"\\">
<div><span>options</span>
<div class=\\"n8n-tree\\">
<div class=\\"_indent_1y4uu_1\\">
<div class=\\"_simple_1y4uu_5\\"><span>0</span><span>:</span><span>yes</span></div>
</div>
<div class=\\"_indent_1y4uu_1\\">
<div class=\\"_simple_1y4uu_5\\"><span>1</span><span>:</span><span>no</span></div>
</div>
</div>
</div>
</div>
</div>"
`;
exports[`components > N8nTree > should render tree with slots 1`] = `
"<div class=\\"n8n-tree\\">
<div class=\\"\\">
<div><span>label</span>
<div class=\\"n8n-tree\\">
<div class=\\"_indent_1y4uu_1\\">
<div class=\\"_simple_1y4uu_5\\"><span>label</span><span>:</span><span>value</span></div>
</div>
</div>
</div>
</div>
<div class=\\"\\">
<div><span>label</span>
<div class=\\"n8n-tree\\">
<div class=\\"_indent_1y4uu_1\\">
<div class=\\"_simple_1y4uu_5\\"><span>label</span><span>:</span><span>value</span></div>
</div>
<div class=\\"_indent_1y4uu_1\\">
<div class=\\"_simple_1y4uu_5\\"><span>label</span><span>:</span><span>value</span></div>
</div>
</div>
</div>
</div>
</div>"
`;

View file

@ -0,0 +1,3 @@
import Tree from './Tree.vue';
export default Tree;

View file

@ -35,6 +35,7 @@ import N8nTabs from '../components/N8nTabs';
import N8nTag from '../components/N8nTag';
import N8nText from '../components/N8nText';
import N8nTooltip from '../components/N8nTooltip';
import N8nTree from '../components/N8nTree';
import N8nUsersList from '../components/N8nUsersList';
import N8nUserSelect from '../components/N8nUserSelect';
@ -76,6 +77,7 @@ export default {
app.component('n8n-tag', N8nTag);
app.component('n8n-text', N8nText);
app.component('n8n-tooltip', N8nTooltip);
app.component('n8n-tree', N8nTree);
app.component('n8n-users-list', N8nUsersList);
app.component('n8n-user-select', N8nUserSelect);
},

View file

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

View file

@ -384,8 +384,8 @@
--color-json-default: #5045A1;
--color-json-null: var(--color-danger);
--color-json-boolean: #1d8ce0;
--color-json-number: #1d8ce0;
--color-json-boolean: var(--color-success);
--color-json-number: var(--color-success);
--color-json-string: #5045A1;
--color-json-key: var(--color-text-dark);
--color-json-brackets: var(--color-text-dark);

View file

@ -221,6 +221,7 @@ export interface IRunDataUi {
export interface ITableData {
columns: string[];
data: GenericValue[][];
hasJson: {[key: string]: boolean};
}
export interface IVariableItemSelected {

View file

@ -1,7 +1,8 @@
<template>
<div
<component :is="tag"
:class="{[$style.dragging]: isDragging }"
@mousedown="onDragStart"
ref="wrapper"
>
<slot :isDragging="isDragging"></slot>
@ -12,10 +13,10 @@
:style="draggableStyle"
v-show="isDragging"
>
<slot name="preview" :canDrop="canDrop"></slot>
<slot name="preview" :canDrop="canDrop" :el="draggingEl"></slot>
</div>
</Teleport>
</div>
</component>
</template>
<script lang="ts">
@ -39,6 +40,13 @@ export default Vue.extend({
data: {
type: String,
},
tag: {
type: String,
default: 'div',
},
targetDataKey: {
type: String,
},
},
data() {
return {
@ -47,6 +55,7 @@ export default Vue.extend({
x: -100,
y: -100,
},
draggingEl: null as null | HTMLElement,
};
},
computed: {
@ -69,12 +78,21 @@ export default Vue.extend({
return;
}
const target = e.target as HTMLElement;
if (this.targetDataKey && target && target.dataset.target !== this.targetDataKey) {
return;
}
this.draggingEl = target;
e.preventDefault();
e.stopPropagation();
this.isDragging = true;
this.$store.commit('ui/draggableStartDragging', {type: this.type, data: this.data || ''});
this.$emit('dragstart');
const data = this.targetDataKey ? target.dataset.value : (this.data || '');
this.$store.commit('ui/draggableStartDragging', {type: this.type, data });
this.$emit('dragstart', this.draggingEl);
document.body.style.cursor = 'grabbing';
window.addEventListener('mousemove', this.onDrag);
@ -112,8 +130,9 @@ export default Vue.extend({
window.removeEventListener('mouseup', this.onDragEnd);
setTimeout(() => {
this.$emit('dragend');
this.$emit('dragend', this.draggingEl);
this.isDragging = false;
this.draggingEl = null;
this.$store.commit('ui/draggableStopDragging');
}, 0);
},

View file

@ -54,7 +54,7 @@ export default Vue.extend({
onMouseMove(e: MouseEvent) {
const target = this.$refs.target as HTMLElement;
if (target) {
if (target && this.isDragging) {
const dim = target.getBoundingClientRect();
this.hovering = e.clientX >= dim.left && e.clientX <= dim.right && e.clientY >= dim.top && e.clientY <= dim.bottom;

View file

@ -16,7 +16,9 @@
paneType="input"
@linkRun="onLinkRun"
@unlinkRun="onUnlinkRun"
@runChange="onRunIndexChange">
@runChange="onRunIndexChange"
@tableMounted="$emit('tableMounted', $event)"
>
<template v-slot:header>
<div :class="$style.titleSection">
<n8n-select v-if="parentNodes.length" :popper-append-to-body="true" size="small" :value="currentNodeName" @input="onSelect" :no-data-text="$locale.baseText('ndv.input.noNodesFound')" :placeholder="$locale.baseText('ndv.input.parentNodes')" filterable>

View file

@ -60,6 +60,7 @@
@openSettings="openSettings"
@select="onInputSelect"
@execute="onNodeExecute"
@tableMounted="onInputTableMounted"
/>
</template>
<template #output>
@ -73,6 +74,7 @@
@unlinkRun="() => onUnlinkRun('output')"
@runChange="onRunOutputIndexChange"
@openSettings="openSettings"
@tableMounted="onOutputTableMounted"
/>
</template>
<template #main>
@ -165,6 +167,8 @@ export default mixins(
isDragging: false,
mainPanelPosition: 0,
pinDataDiscoveryTooltipVisible: false,
avgInputRowHeight: 0,
avgOutputRowHeight: 0,
};
},
mounted() {
@ -341,6 +345,8 @@ export default mixins(
this.isLinkingEnabled = true;
this.selectedInput = undefined;
this.triggerWaitingWarningEnabled = false;
this.avgOutputRowHeight = 0;
this.avgInputRowHeight = 0;
this.$store.commit('ui/setNDVSessionId');
this.$externalHooks().run('dataDisplay.nodeTypeChanged', {
@ -362,14 +368,16 @@ export default mixins(
output_first_connector_runs: this.maxOutputRun,
selected_view_inputs: this.isTriggerNode
? 'trigger'
: this.$store.getters['ui/inputPanelDispalyMode'],
selected_view_outputs: this.$store.getters['ui/outputPanelDispalyMode'],
: this.$store.getters['ui/inputPanelDisplayMode'],
selected_view_outputs: this.$store.getters['ui/outputPanelDisplayMode'],
input_connectors: this.parentNodes.length,
output_connectors:
outogingConnections && outogingConnections.main && outogingConnections.main.length,
input_displayed_run_index: this.inputRun,
output_displayed_run_index: this.outputRun,
data_pinning_tooltip_presented: this.pinDataDiscoveryTooltipVisible,
input_displayed_row_height_avg: this.avgInputRowHeight,
output_displayed_row_height_avg: this.avgOutputRowHeight,
});
}
}, 2000); // wait for RunData to mount and present pindata discovery tooltip
@ -386,6 +394,12 @@ export default mixins(
},
},
methods: {
onInputTableMounted(e: { avgRowHeight: number }) {
this.avgInputRowHeight = e.avgRowHeight;
},
onOutputTableMounted(e: { avgRowHeight: number }) {
this.avgOutputRowHeight = e.avgRowHeight;
},
onWorkflowActivate() {
this.$store.commit('setActiveNode', null);
setTimeout(() => {

View file

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

View file

@ -522,14 +522,6 @@ export default mixins(
computedValue = `[${this.$locale.baseText('parameterInput.error')}}: ${error.message}]`;
}
// Try to convert it into the corret type
if (this.parameter.type === 'number') {
computedValue = parseInt(computedValue as string, 10);
if (isNaN(computedValue)) {
return null;
}
}
return computedValue;
},
getStringInputType () {
@ -1031,7 +1023,7 @@ export default mixins(
}
.switch-input {
margin: 2px 0;
margin: var(--spacing-5xs) 0 var(--spacing-2xs) 0;
}
.parameter-value-container {

View file

@ -224,7 +224,7 @@
/>
</div>
<div v-else-if="hasNodeRun && displayMode === 'table' && tableData && tableData.columns && tableData.columns.length === 0 && binaryData.length > 0" :class="$style.center">
<div v-else-if="hasNodeRun && displayMode === 'table' && binaryData.length > 0 && jsonData.length === 1 && Object.keys(jsonData[0] || {}).length === 0" :class="$style.center">
<n8n-text>
{{ $locale.baseText('runData.switchToBinary.info') }}
<a @click="switchToBinary">
@ -233,8 +233,8 @@
</n8n-text>
</div>
<div v-else-if="hasNodeRun && displayMode === 'table' && tableData" :class="$style.dataDisplay">
<RunDataTable :node="node" :tableData="tableData" :mappingEnabled="mappingEnabled" :distanceFromActive="distanceFromActive" :showMappingHint="showMappingHint" :runIndex="runIndex" :totalRuns="maxRunIndex" />
<div v-else-if="hasNodeRun && displayMode === 'table'" :class="$style.dataDisplay">
<RunDataTable :node="node" :inputData="inputData" :mappingEnabled="mappingEnabled" :distanceFromActive="distanceFromActive" :showMappingHint="showMappingHint" :runIndex="runIndex" :totalRuns="maxRunIndex" @mounted="$emit('tableMounted', $event)" />
</div>
<div v-else-if="hasNodeRun && displayMode === 'json'" :class="$style.jsonDisplay">
@ -649,9 +649,6 @@ export default mixins(
jsonData (): IDataObject[] {
return this.convertToJson(this.inputData);
},
tableData (): ITableData | undefined {
return this.convertToTable(this.inputData);
},
binaryData (): IBinaryKeyData[] {
if (!this.node) {
return [];
@ -1037,60 +1034,6 @@ export default mixins(
return returnData;
},
convertToTable (inputData: INodeExecutionData[]): ITableData | undefined {
const tableData: GenericValue[][] = [];
const tableColumns: string[] = [];
let leftEntryColumns: string[], entryRows: GenericValue[];
// Go over all entries
let entry: IDataObject;
inputData.forEach((data) => {
if (!data.hasOwnProperty('json')) {
return;
}
entry = data.json;
// Go over all keys of entry
entryRows = [];
leftEntryColumns = Object.keys(entry);
// Go over all the already existing column-keys
tableColumns.forEach((key) => {
if (entry.hasOwnProperty(key)) {
// Entry does have key so add its value
entryRows.push(entry[key]);
// Remove key so that we know that it got added
leftEntryColumns.splice(leftEntryColumns.indexOf(key), 1);
} else {
// Entry does not have key so add null
entryRows.push(null);
}
});
// Go over all the columns the entry has but did not exist yet
leftEntryColumns.forEach((key) => {
// Add the key for all runs in the future
tableColumns.push(key);
// Add the value
entryRows.push(entry[key]);
});
// Add the data of the entry
tableData.push(entryRows);
});
// Make sure that all entry-rows have the same length
tableData.forEach((entryRows) => {
if (tableColumns.length > entryRows.length) {
// Has to less entries so add the missing ones
entryRows.push.apply(entryRows, new Array(tableColumns.length - entryRows.length));
}
});
return {
columns: tableColumns,
data: tableData,
};
},
clearExecutionData () {
this.$store.commit('setWorkflowExecutionData', null);
this.updateNodesExecutionIssues();

View file

@ -3,23 +3,44 @@
<table :class="$style.table" v-if="tableData.columns && tableData.columns.length === 0">
<tr>
<th :class="$style.emptyCell"></th>
<th :class="$style.tableRightMargin"></th>
</tr>
<tr v-for="(row, index1) in tableData.data" :key="index1">
<td>
<n8n-text>{{ $locale.baseText('runData.emptyItemHint') }}</n8n-text>
</td>
<td :class="$style.tableRightMargin"></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" v-html="$locale.baseText('dataMapping.dragColumnToFieldHint')"></div>
<Draggable type="mapping" :data="getExpression(column)" :disabled="!mappingEnabled" @dragstart="onDragStart" @dragend="(column) => onDragEnd(column)">
<n8n-tooltip
placement="bottom-start"
:disabled="!mappingEnabled || showHintWithDelay"
:open-delay="1000"
>
<div
slot="content"
v-html="$locale.baseText('dataMapping.dragColumnToFieldHint')"
></div>
<Draggable
type="mapping"
:data="getExpression(column)"
:disabled="!mappingEnabled"
@dragstart="onDragStart"
@dragend="(column) => onDragEnd(column, '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
:class="[$style.dragPill, canDrop ? $style.droppablePill : $style.defaultPill]"
>
{{
$locale.baseText('dataMapping.mapSpecificColumnToField', {
interpolate: { name: shorten(column, 16, 2) },
})
}}
</div>
</template>
<template v-slot="{ isDragging }">
@ -27,14 +48,32 @@
:class="{
[$style.header]: true,
[$style.draggableHeader]: mappingEnabled,
[$style.activeHeader]: (i === activeColumn || forceShowGrip) && 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>
<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>
@ -44,9 +83,33 @@
</Draggable>
</n8n-tooltip>
</th>
<th :class="$style.tableRightMargin"></th>
</tr>
</thead>
<tbody>
<Draggable
tag="tbody"
type="mapping"
targetDataKey="mappable"
:disabled="!mappingEnabled"
@dragstart="onCellDragStart"
@dragend="onCellDragEnd"
ref="draggable"
>
<template v-slot:preview="{ canDrop, el }">
<div :class="[$style.dragPill, canDrop ? $style.droppablePill : $style.defaultPill]">
{{
$locale.baseText(
tableData.data.length > 1
? 'dataMapping.mapAllKeysToField'
: 'dataMapping.mapSpecificColumnToField',
{
interpolate: { name: shorten(getPathNameFromTarget(el) || '', 16, 2) },
},
)
}}
</div>
</template>
<template>
<tr v-for="(row, index1) in tableData.data" :key="index1">
<td
v-for="(data, index2) in row"
@ -54,9 +117,40 @@
:data-col="index2"
@mouseenter="onMouseEnterCell"
@mouseleave="onMouseLeaveCell"
>{{ [null, undefined].includes(data) ? '&nbsp;' : data }}</td>
:class="hasJsonInColumn(index2) ? $style.minColWidth : $style.limitColWidth"
>
<span v-if="isSimple(data)" :class="$style.value">{{
[null, undefined].includes(data) ? '&nbsp;' : data
}}</span>
<n8n-tree :nodeClass="$style.nodeClass" v-else :value="data">
<template v-slot:label="{ label, path }">
<span
@mouseenter="() => onMouseEnterKey(path, index2)"
@mouseleave="onMouseLeaveKey"
:class="{
[$style.hoveringKey]: mappingEnabled && isHovering(path, index2),
[$style.draggingKey]: isDraggingKey(path, index2),
[$style.dataKey]: true,
[$style.mappable]: mappingEnabled,
}"
data-target="mappable"
:data-name="getCellPathName(path, index2)"
:data-value="getCellExpression(path, index2)"
:data-depth="path.length"
>{{ label || $locale.baseText('runData.unnamedField') }}</span
>
</template>
<template v-slot:value="{ value }">
<span :class="{ [$style.nestedValue]: true, [$style.empty]: isEmpty(value) }">{{
getValueToRender(value)
}}</span>
</template>
</n8n-tree>
</td>
<td :class="$style.tableRightMargin"></td>
</tr>
</tbody>
</template>
</Draggable>
</table>
</div>
</template>
@ -64,6 +158,7 @@
<script lang="ts">
import { LOCAL_STORAGE_MAPPING_FLAG } from '@/constants';
import { INodeUi, ITableData } from '@/Interface';
import { GenericValue, IDataObject, INodeExecutionData } from 'n8n-workflow';
import Vue from 'vue';
import mixins from 'vue-typed-mixins';
import Draggable from './Draggable.vue';
@ -77,8 +172,8 @@ export default mixins(externalHooks).extend({
node: {
type: Object as () => INodeUi,
},
tableData: {
type: Object as () => ITableData,
inputData: {
type: Object as () => INodeExecutionData[],
},
mappingEnabled: {
type: Boolean,
@ -102,6 +197,8 @@ export default mixins(externalHooks).extend({
showHintWithDelay: false,
forceShowGrip: false,
draggedColumn: false,
draggingPath: null as null | string,
hoveringPath: null as null | string,
};
},
mounted() {
@ -111,13 +208,30 @@ export default mixins(externalHooks).extend({
this.$telemetry.track('User viewed data mapping tooltip', { type: 'param focus' });
}, 500);
}
if (this.tableData && this.tableData.columns && this.$refs.draggable) {
const tbody = (this.$refs.draggable as Vue).$refs.wrapper as HTMLElement;
if (tbody) {
this.$emit('mounted', {
avgRowHeight: tbody.offsetHeight / this.tableData.data.length,
});
}
}
},
computed: {
focusedMappableInput (): string {
tableData(): ITableData {
return this.convertToTable(this.inputData);
},
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'));
showHint(): boolean {
return (
!this.draggedColumn &&
(this.showMappingHint ||
(!!this.focusedMappableInput &&
window.localStorage.getItem(LOCAL_STORAGE_MAPPING_FLAG) !== 'true'))
);
},
},
methods: {
@ -134,6 +248,17 @@ export default mixins(externalHooks).extend({
onMouseLeaveCell() {
this.activeColumn = -1;
},
onMouseEnterKey(path: string[], colIndex: number) {
this.hoveringPath = this.getCellExpression(path, colIndex);
},
onMouseLeaveKey() {
this.hoveringPath = null;
},
isHovering(path: string[], colIndex: number) {
const expr = this.getCellExpression(path, colIndex);
return this.hoveringPath === expr;
},
getExpression(column: string) {
if (!this.node) {
return '';
@ -145,12 +270,94 @@ export default mixins(externalHooks).extend({
return `{{ $node["${this.node.name}"].json["${column}"] }}`;
},
getPathNameFromTarget(el: HTMLElement) {
if (!el) {
return '';
}
return el.dataset.name;
},
getCellPathName(path: Array<string | number>, colIndex: number) {
const lastKey = path[path.length - 1];
if (typeof lastKey === 'string') {
return lastKey;
}
if (path.length > 1) {
const prevKey = path[path.length - 2];
return `${prevKey}[${lastKey}]`;
}
const column = this.tableData.columns[colIndex];
return `${column}[${lastKey}]`;
},
getCellExpression(path: Array<string | number>, colIndex: number) {
if (!this.node) {
return '';
}
const expr = path.reduce((accu: string, key: string | number) => {
if (typeof key === 'number') {
return `${accu}[${key}]`;
}
return `${accu}["${key}"]`;
}, '');
const column = this.tableData.columns[colIndex];
if (this.distanceFromActive === 1) {
return `{{ $json["${column}"]${expr} }}`;
}
return `{{ $node["${this.node.name}"].json["${column}"]${expr} }}`;
},
isEmpty(value: unknown) {
return (
value === '' ||
(Array.isArray(value) && value.length === 0) ||
(typeof value === 'object' && value !== null && Object.keys(value).length === 0)
);
},
getValueToRender(value: unknown) {
if (value === '') {
return this.$locale.baseText('runData.emptyString');
}
if (typeof value === 'string') {
return value.replaceAll('\n', '\\n');
}
if (Array.isArray(value) && value.length === 0) {
return this.$locale.baseText('runData.emptyArray');
}
if (typeof value === 'object' && value !== null && Object.keys(value).length === 0) {
return this.$locale.baseText('runData.emptyObject');
}
return value;
},
onDragStart() {
this.draggedColumn = true;
this.$store.commit('ui/resetMappingTelemetry');
},
onDragEnd(column: string) {
onCellDragStart(el: HTMLElement) {
if (el && el.dataset.value) {
this.draggingPath = el.dataset.value;
}
this.onDragStart();
},
onCellDragEnd(el: HTMLElement) {
this.draggingPath = null;
this.onDragEnd(el.dataset.name || '', 'tree', el.dataset.depth || '0');
},
isDraggingKey(path: Array<string | number>, colIndex: number) {
if (!this.draggingPath) {
return;
}
return this.draggingPath === this.getCellExpression(path, colIndex);
},
onDragEnd(column: string, src: string, depth = '0') {
setTimeout(() => {
const mappingTelemetry = this.$store.getters['ui/mappingTelemetry'];
const telemetryPayload = {
@ -159,8 +366,9 @@ export default mixins(externalHooks).extend({
src_nodes_back: this.distanceFromActive,
src_run_index: this.runIndex,
src_runs_total: this.totalRuns,
src_field_nest_level: parseInt(depth, 10),
src_view: 'table',
src_element: 'column',
src_element: src,
success: false,
...mappingTelemetry,
};
@ -170,14 +378,82 @@ export default mixins(externalHooks).extend({
this.$telemetry.track('User dragged data for mapping', telemetryPayload);
}, 1000); // ensure dest data gets set if drop
},
isSimple(data: unknown): boolean {
return typeof data !== 'object';
},
hasJsonInColumn(colIndex: number): boolean {
return this.tableData.hasJson[this.tableData.columns[colIndex]];
},
convertToTable(inputData: INodeExecutionData[]): ITableData {
const tableData: GenericValue[][] = [];
const tableColumns: string[] = [];
let leftEntryColumns: string[], entryRows: GenericValue[];
// Go over all entries
let entry: IDataObject;
const hasJson: { [key: string]: boolean } = {};
inputData.forEach((data) => {
if (!data.hasOwnProperty('json')) {
return;
}
entry = data.json;
// Go over all keys of entry
entryRows = [];
leftEntryColumns = Object.keys(entry);
// Go over all the already existing column-keys
tableColumns.forEach((key) => {
if (entry.hasOwnProperty(key)) {
// Entry does have key so add its value
entryRows.push(entry[key]);
// Remove key so that we know that it got added
leftEntryColumns.splice(leftEntryColumns.indexOf(key), 1);
hasJson[key] = typeof entry[key] === 'object' || hasJson[key] || false;
} else {
// Entry does not have key so add null
entryRows.push(null);
}
});
// Go over all the columns the entry has but did not exist yet
leftEntryColumns.forEach((key) => {
// Add the key for all runs in the future
tableColumns.push(key);
// Add the value
entryRows.push(entry[key]);
hasJson[key] = hasJson[key] || (entry[key] === 'object' && Object.keys(entry[key] || {}).length > 0);
});
// Add the data of the entry
tableData.push(entryRows);
});
// Make sure that all entry-rows have the same length
tableData.forEach((entryRows) => {
if (tableColumns.length > entryRows.length) {
// Has to less entries so add the missing ones
entryRows.push.apply(entryRows, new Array(tableColumns.length - entryRows.length));
}
});
return {
hasJson,
columns: tableColumns,
data: tableData,
};
},
},
watch: {
focusedMappableInput (curr: boolean) {
setTimeout(() => {
focusedMappableInput(curr: boolean) {
setTimeout(
() => {
this.forceShowGrip = !!this.focusedMappableInput;
}, curr? 300: 150);
},
showHint (curr: boolean, prev: boolean) {
curr ? 300 : 150,
);
},
showHint(curr: boolean, prev: boolean) {
if (curr) {
setTimeout(() => {
this.showHintWithDelay = this.showHint;
@ -185,8 +461,7 @@ export default mixins(externalHooks).extend({
this.$telemetry.track('User viewed data mapping tooltip', { type: 'param focus' });
}
}, 1000);
}
else {
} else {
this.showHintWithDelay = false;
}
},
@ -198,8 +473,7 @@ export default mixins(externalHooks).extend({
.table {
border-collapse: separate;
text-align: left;
width: calc(100% - var(--spacing-s));
margin-right: var(--spacing-s);
width: calc(100%);
font-size: var(--font-size-s);
th {
@ -209,15 +483,15 @@ export default mixins(externalHooks).extend({
border-left: var(--border-base);
position: sticky;
top: 0;
max-width: 300px;
color: var(--color-text-dark);
}
td {
padding: var(--spacing-2xs);
vertical-align: top;
padding: var(--spacing-2xs) var(--spacing-2xs) var(--spacing-2xs) var(--spacing-3xs);
border-bottom: var(--border-base);
border-left: var(--border-base);
overflow-wrap: break-word;
max-width: 300px;
white-space: pre-wrap;
}
@ -227,6 +501,10 @@ export default mixins(externalHooks).extend({
}
}
.nodeClass {
margin-bottom: var(--spacing-5xs);
}
.emptyCell {
height: 32px;
}
@ -288,4 +566,54 @@ export default mixins(externalHooks).extend({
transform: translate(-50%, -100%);
box-shadow: 0px 2px 6px rgba(68, 28, 23, 0.2);
}
.dataKey {
color: var(--color-text-dark);
line-height: 1.7;
font-weight: var(--font-weight-bold);
border-radius: var(--border-radius-base);
padding: 0 var(--spacing-5xs) 0 var(--spacing-5xs);
margin-right: var(--spacing-5xs);
}
.value {
line-height: var(--font-line-height-regular);
}
.nestedValue {
composes: value;
margin-left: var(--spacing-4xs);
}
.mappable {
cursor: grab;
}
.empty {
color: var(--color-danger);
}
.limitColWidth {
max-width: 300px;
}
.minColWidth {
min-width: 240px;
}
.hoveringKey {
background-color: var(--color-foreground-base);
}
.draggingKey {
background-color: var(--color-primary-tint-2);
}
.tableRightMargin {
// becomes necessary with large tables
width: var(--spacing-s);
border-right: none !important;
border-top: none !important;
border-bottom: none !important;
}
</style>

View file

@ -187,8 +187,8 @@ const module: Module<IUiState, IRootState> = {
getPanelDisplayMode: (state: IUiState) => {
return (panel: 'input' | 'output') => state.ndv[panel].displayMode;
},
inputPanelDispalyMode: (state: IUiState) => state.ndv.input.displayMode,
outputPanelDispalyMode: (state: IUiState) => state.ndv.output.displayMode,
inputPanelDisplayMode: (state: IUiState) => state.ndv.input.displayMode,
outputPanelDisplayMode: (state: IUiState) => state.ndv.output.displayMode,
outputPanelEditMode: (state: IUiState): IUiState['ndv']['output']['editMode'] => state.ndv.output.editMode,
mainPanelPosition: (state: IUiState) => state.mainPanelPosition,
getFakeDoorFeatures: (state: IUiState) => state.fakeDoorFeatures,

View file

@ -162,11 +162,12 @@
"dataDisplay.nodeDocumentation": "Node Documentation",
"dataDisplay.openDocumentationFor": "Open {nodeTypeDisplayName} documentation",
"dataMapping.dragColumnToFieldHint": "<img src='/static/data-mapping-gif.gif'/> 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.dragFromPreviousHint": "Map data from previous nodes to <b>{name}</b> 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": "<img src='/static/data-mapping-gif.gif'/> Drag a column onto <b>{name}</b> to map it",
"dataMapping.mapSpecificColumnToField": "Map {name} to field",
"dataMapping.mapSpecificColumnToField": "Map '{name}' to a field",
"dataMapping.mapAllKeysToField": "Map every '{name}' to a field",
"displayWithChange.cancelEdit": "Cancel Edit",
"displayWithChange.clickToChange": "Click to Change",
"displayWithChange.setValue": "Set Value",
@ -672,6 +673,10 @@
"pushConnection.pollingNode.dataNotFound": "No {service} data found",
"pushConnection.pollingNode.dataNotFound.message": "We didnt find any data in {service} to simulate an event. Please create one in {service} and try again.",
"runData.emptyItemHint": "This is an item, but it's empty.",
"runData.emptyArray": "[empty array]",
"runData.emptyString": "[empty]",
"runData.emptyObject": "[empty object]",
"runData.unnamedField": "[Unnamed field]",
"runData.switchToBinary.info": "This item only has",
"runData.switchToBinary.binary": "binary data",
"runData.linking.hint": "Link displayed input and output runs",