feat(editor): Help users discover expressions when using drag n drop (#8869)

This commit is contained in:
Elias Meire 2024-03-13 12:57:08 +01:00 committed by GitHub
parent 71f1b23771
commit e78cc2d8d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 559 additions and 323 deletions

View file

@ -253,7 +253,6 @@ describe('Data mapping', () => {
workflowPage.actions.openNode('Set');
ndv.actions.typeIntoParameterInput('value', 'delete me');
ndv.actions.dismissMappingTooltip();
ndv.actions.typeIntoParameterInput('name', 'test');

View file

@ -42,7 +42,8 @@ describe('n8n Form Trigger', () => {
':nth-child(3) > .border-top-dashed > .parameter-input-list-wrapper > :nth-child(1) > .parameter-item',
)
.find('input[placeholder*="e.g. What is your name?"]')
.type('Test Field 3');
.type('Test Field 3')
.blur();
cy.get(
':nth-child(3) > .border-top-dashed > .parameter-input-list-wrapper > :nth-child(2) > .parameter-item',
).click();
@ -53,7 +54,8 @@ describe('n8n Form Trigger', () => {
':nth-child(4) > .border-top-dashed > .parameter-input-list-wrapper > :nth-child(1) > .parameter-item',
)
.find('input[placeholder*="e.g. What is your name?"]')
.type('Test Field 4');
.type('Test Field 4')
.blur();
cy.get(
':nth-child(4) > .border-top-dashed > .parameter-input-list-wrapper > :nth-child(2) > .parameter-item',
).click();
@ -65,12 +67,14 @@ describe('n8n Form Trigger', () => {
':nth-child(4) > :nth-child(1) > :nth-child(2) > :nth-child(3) > .multi-parameter > .fixed-collection-parameter > .fixed-collection-parameter-property > :nth-child(1) > :nth-child(1)',
)
.find('input')
.type('Option 1');
.type('Option 1')
.blur();
cy.get(
':nth-child(4) > :nth-child(1) > :nth-child(2) > :nth-child(3) > .multi-parameter > .fixed-collection-parameter > .fixed-collection-parameter-property > :nth-child(1) > :nth-child(2)',
)
.find('input')
.type('Option 2');
.type('Option 2')
.blur();
//add optional submitted message
cy.get('.param-options').click();

View file

@ -182,7 +182,7 @@ describe('Webhook Trigger node', async () => {
workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME);
workflowPage.actions.openNode(EDIT_FIELDS_SET_NODE_NAME);
ndv.getters.assignmentCollectionAdd('assignments').click();
ndv.getters.assignmentName('assignments').type('data');
ndv.getters.assignmentName('assignments').type('data').find('input').blur();
ndv.getters.assignmentType('assignments').click();
ndv.getters.assignmentValue('assignments').paste(cowBase64);
@ -313,7 +313,7 @@ const addEditFields = () => {
workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME);
workflowPage.actions.openNode(EDIT_FIELDS_SET_NODE_NAME);
ndv.getters.assignmentCollectionAdd('assignments').click();
ndv.getters.assignmentName('assignments').type('MyValue');
ndv.getters.assignmentName('assignments').type('MyValue').find('input').blur();
ndv.getters.assignmentType('assignments').click();
getVisibleSelect().find('li').contains('Number').click();
ndv.getters.assignmentValue('assignments').type('1234');

View file

@ -186,6 +186,7 @@ describe('NDV', () => {
ndv.getters.inputTableRow(1).invoke('attr', 'data-test-id').should('equal', 'hovering-item');
ndv.getters.inputTableRow(1).realHover();
cy.wait(100);
ndv.getters.outputHoveringItem().should('not.exist');
ndv.getters.parameterExpressionPreview('value').should('include.text', '1111');
@ -200,6 +201,7 @@ describe('NDV', () => {
ndv.actions.selectInputNode('Code');
ndv.getters.inputTableRow(1).realHover();
cy.wait(100);
ndv.getters.inputTableRow(1).should('have.text', '6666');
ndv.getters.inputTableRow(1).invoke('attr', 'data-test-id').should('equal', 'hovering-item');

View file

@ -167,10 +167,6 @@ export class NDV extends BasePage {
selectOptionInParameterDropdown: (parameterName: string, content: string) => {
getVisibleSelect().find('.option-headline').contains(content).click();
},
dismissMappingTooltip: () => {
cy.getByTestId('dismiss-mapping-tooltip').click();
cy.getByTestId('dismiss-mapping-tooltip').should('not.be.visible');
},
rename: (newName: string) => {
this.getters.nodeNameContainer().click();
this.getters.nodeRenameInput().should('be.visible').type('{selectall}').type(newName);
@ -244,7 +240,7 @@ export class NDV extends BasePage {
getVisiblePopper().find('li').last().click();
},
addFilterCondition: (paramName: string) => {
this.getters.filterConditionAdd(paramName).click();
this.getters.filterConditionAdd(paramName).click({ force: true });
},
removeFilterCondition: (paramName: string, index: number) => {
this.getters.filterConditionRemove(paramName, index).click();

View file

@ -97,7 +97,7 @@
--color-json-brackets: var(--prim-gray-670);
--color-json-brackets-hover: var(--prim-color-alt-e);
--color-json-line: var(--prim-gray-200);
--color-json-highlight: var(--prim-gray-70);
--color-json-highlight: var(--color-background-base);
--color-code-background: var(--prim-gray-800);
--color-code-background-readonly: var(--prim-gray-740);
--color-code-lineHighlight: var(--prim-gray-740);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 363 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 464 KiB

View file

@ -1245,6 +1245,7 @@ export interface NDVState {
activeTarget: { id: string; stickyPosition: null | XYPosition } | null;
};
isMappingOnboarded: boolean;
isAutocompleteOnboarded: boolean;
}
export interface NotificationOptions extends Partial<ElementNotificationOptions> {

View file

@ -58,7 +58,7 @@ const assignmentTypeToNodeProperty = (
const nameParameter = computed<INodeProperties>(() => ({
name: 'name',
displayName: '',
displayName: 'Name',
default: '',
requiresDataPath: 'single',
placeholder: 'name',
@ -68,7 +68,7 @@ const nameParameter = computed<INodeProperties>(() => ({
const valueParameter = computed<INodeProperties>(() => {
return {
name: 'value',
displayName: '',
displayName: 'Value',
default: '',
placeholder: 'value',
...assignmentTypeToNodeProperty(assignment.value.type ?? 'string'),

View file

@ -22,6 +22,7 @@
:rows="rows"
:additional-data="additionalExpressionData"
:path="path"
:event-bus="eventBus"
@focus="onFocus"
@blur="onBlur"
@change="onChange"
@ -64,6 +65,7 @@ import type { Segment } from '@/types/expressions';
import type { TargetItem } from '@/Interface';
import type { IDataObject } from 'n8n-workflow';
import { useDebounce } from '@/composables/useDebounce';
import { type EventBus, createEventBus } from 'n8n-design-system/utils';
type InlineExpressionEditorInputRef = InstanceType<typeof InlineExpressionEditorInput>;
@ -97,6 +99,10 @@ export default defineComponent({
type: Object as PropType<IDataObject>,
default: () => ({}),
},
eventBus: {
type: Object as PropType<EventBus>,
default: () => createEventBus(),
},
},
setup() {
const { callDebounced } = useDebounce();

View file

@ -3,27 +3,30 @@
</template>
<script lang="ts">
import { completionStatus, startCompletion } from '@codemirror/autocomplete';
import { history } from '@codemirror/commands';
import { Compartment, EditorState, Prec } from '@codemirror/state';
import { EditorView, keymap } from '@codemirror/view';
import type { PropType } from 'vue';
import { defineComponent } from 'vue';
import { defineComponent, nextTick } from 'vue';
import { completionManager } from '@/mixins/completionManager';
import { expressionManager } from '@/mixins/expressionManager';
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
import { n8nAutocompletion, n8nLang } from '@/plugins/codemirror/n8nLang';
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
import { isEqual } from 'lodash-es';
import type { IDataObject } from 'n8n-workflow';
import { inputTheme } from './theme';
import {
autocompleteKeyMap,
enterKeyMap,
historyKeyMap,
tabKeyMap,
} from '@/plugins/codemirror/keymap';
import { completionStatus } from '@codemirror/autocomplete';
import { n8nAutocompletion, n8nLang } from '@/plugins/codemirror/n8nLang';
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
import { isEqual } from 'lodash-es';
import { createEventBus, type EventBus } from 'n8n-design-system/utils';
import type { IDataObject } from 'n8n-workflow';
import { inputTheme } from './theme';
import { useNDVStore } from '@/stores/ndv.store';
import { mapStores } from 'pinia';
const editableConf = new Compartment();
@ -51,6 +54,13 @@ export default defineComponent({
type: Object as PropType<IDataObject>,
default: () => ({}),
},
eventBus: {
type: Object as PropType<EventBus>,
default: () => createEventBus(),
},
},
computed: {
...mapStores(useNDVStore),
},
watch: {
isReadOnly(newValue: boolean) {
@ -132,14 +142,34 @@ export default defineComponent({
this.editorState = this.editor.state;
highlighter.addColor(this.editor, this.resolvableSegments);
this.eventBus.on('drop', this.onDrop);
},
beforeUnmount() {
this.editor?.destroy();
this.eventBus.off('drop', this.onDrop);
},
methods: {
focus() {
this.editor?.focus();
},
setCursorPosition(pos: number) {
this.editor.dispatch({ selection: { anchor: pos, head: pos } });
},
async onDrop() {
await nextTick();
this.focus();
const END_OF_EXPRESSION = ' }}';
const value = this.editor.state.sliceDoc(0);
const cursorPosition = Math.max(value.lastIndexOf(END_OF_EXPRESSION), 0);
this.setCursorPosition(cursorPosition);
if (!this.ndvStore.isAutocompleteOnboarded) {
startCompletion(this.editor as EditorView);
}
},
},
});
</script>

View file

@ -7,130 +7,111 @@
<div ref="root" data-test-id="inline-expression-editor-output"></div>
</n8n-text>
<div :class="$style.footer">
<n8n-text size="small" compact>
{{ i18n.baseText('parameterInput.anythingInside') }}
</n8n-text>
<div :class="$style['expression-syntax-example']" v-text="`{{ }}`"></div>
<n8n-text size="small" compact>
{{ i18n.baseText('parameterInput.isJavaScript') }}
</n8n-text>
{{ ' ' }}
<n8n-link
:class="$style['learn-more']"
size="small"
underline
theme="text"
:to="expressionsDocsUrl"
>
{{ i18n.baseText('parameterInput.learnMore') }}
</n8n-link>
<InlineExpressionTip />
</div>
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { EditorView } from '@codemirror/view';
import { EditorState } from '@codemirror/state';
import { defineComponent } from 'vue';
import type { PropType } from 'vue';
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
import { outputTheme } from './theme';
import type { Plaintext, Resolved, Segment } from '@/types/expressions';
import { EXPRESSIONS_DOCS_URL } from '@/constants';
import { useI18n } from '@/composables/useI18n';
import type { Plaintext, Resolved, Segment } from '@/types/expressions';
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue';
import { outputTheme } from './theme';
import InlineExpressionTip from './InlineExpressionTip.vue';
export default defineComponent({
name: 'InlineExpressionEditorOutput',
props: {
segments: {
type: Array as PropType<Segment[]>,
required: true,
},
isReadOnly: {
type: Boolean,
default: false,
},
visible: {
type: Boolean,
default: false,
},
noInputData: {
type: Boolean,
default: false,
},
hoveringItemNumber: {
type: Number,
required: true,
},
},
setup() {
const i18n = useI18n();
interface InlineExpressionEditorOutputProps {
segments: Segment[];
hoveringItemNumber: number;
isReadOnly?: boolean;
visible?: boolean;
noInputData?: boolean;
}
return {
i18n,
};
},
data() {
return {
editor: null as EditorView | null,
expressionsDocsUrl: EXPRESSIONS_DOCS_URL,
};
},
computed: {
resolvedExpression(): string {
return this.segments.reduce((acc, segment) => {
acc += segment.kind === 'resolvable' ? segment.resolved : segment.plaintext;
return acc;
}, '');
},
plaintextSegments(): Plaintext[] {
return this.segments.filter((s): s is Plaintext => s.kind === 'plaintext');
},
resolvedSegments(): Resolved[] {
let cursor = 0;
const props = withDefaults(defineProps<InlineExpressionEditorOutputProps>(), {
readOnly: false,
visible: false,
noInputData: false,
});
return this.segments
.map((segment) => {
segment.from = cursor;
cursor +=
segment.kind === 'plaintext'
? segment.plaintext.length
: segment.resolved
? segment.resolved.toString().length
: 0;
segment.to = cursor;
return segment;
})
.filter((segment): segment is Resolved => segment.kind === 'resolvable');
},
},
watch: {
segments() {
if (!this.editor) return;
const i18n = useI18n();
this.editor.dispatch({
changes: { from: 0, to: this.editor.state.doc.length, insert: this.resolvedExpression },
});
const editor = ref<EditorView | null>(null);
const root = ref<HTMLElement | null>(null);
highlighter.addColor(this.editor, this.resolvedSegments);
highlighter.removeColor(this.editor, this.plaintextSegments);
},
},
mounted() {
this.editor = new EditorView({
parent: this.$refs.root as HTMLDivElement,
state: EditorState.create({
doc: this.resolvedExpression,
extensions: [outputTheme(), EditorState.readOnly.of(true), EditorView.lineWrapping],
}),
const resolvedExpression = computed(() => {
if (props.segments.length === 0) {
return i18n.baseText('parameterInput.emptyString');
}
return props.segments.reduce((acc, segment) => {
acc += segment.kind === 'resolvable' ? (segment.resolved as string) : segment.plaintext;
return acc;
}, '');
});
const plaintextSegments = computed<Plaintext[]>(() => {
if (props.segments.length === 0) {
return [
{
from: 0,
to: resolvedExpression.value.length - 1,
plaintext: resolvedExpression.value,
kind: 'plaintext',
},
];
}
return props.segments.filter((s): s is Plaintext => s.kind === 'plaintext');
});
const resolvedSegments = computed<Resolved[]>(() => {
let cursor = 0;
return props.segments
.map((segment) => {
segment.from = cursor;
cursor +=
segment.kind === 'plaintext'
? segment.plaintext.length
: segment.resolved
? (segment.resolved as string | number | boolean).toString().length
: 0;
segment.to = cursor;
return segment;
})
.filter((segment): segment is Resolved => segment.kind === 'resolvable');
});
watch(
() => props.segments,
() => {
if (!editor.value) return;
editor.value.dispatch({
changes: { from: 0, to: editor.value.state.doc.length, insert: resolvedExpression.value },
});
highlighter.addColor(editor.value as EditorView, resolvedSegments.value);
highlighter.removeColor(editor.value as EditorView, plaintextSegments.value);
},
beforeUnmount() {
this.editor?.destroy();
},
);
onMounted(() => {
editor.value = new EditorView({
parent: root.value as HTMLElement,
state: EditorState.create({
doc: resolvedExpression.value,
extensions: [outputTheme(), EditorState.readOnly.of(true), EditorView.lineWrapping],
}),
});
});
onBeforeUnmount(() => {
editor.value?.destroy();
});
</script>
@ -157,11 +138,14 @@ export default defineComponent({
}
.header,
.body,
.footer {
.body {
padding: var(--spacing-3xs);
}
.footer {
border-top: var(--border-base);
}
.header {
color: var(--color-text-dark);
font-weight: var(--font-weight-bold);
@ -178,28 +162,5 @@ export default defineComponent({
padding-top: var(--spacing-2xs);
}
}
.footer {
border-top: var(--border-base);
padding: var(--spacing-4xs);
padding-left: var(--spacing-2xs);
padding-top: 0;
line-height: var(--font-line-height-regular);
color: var(--color-text-base);
.expression-syntax-example {
display: inline-block;
font-size: var(--font-size-2xs);
height: var(--font-size-m);
background-color: var(--color-expression-syntax-example);
margin-left: var(--spacing-5xs);
margin-right: var(--spacing-5xs);
}
.learn-more {
line-height: 1;
white-space: nowrap;
}
}
}
</style>

View file

@ -0,0 +1,127 @@
<template>
<div v-if="tip === 'drag'" :class="$style.tip">
<n8n-text size="small" :class="$style.tipText"
>{{ $locale.baseText('parameterInput.tip') }}:
</n8n-text>
<n8n-text size="small" :class="$style.text">
{{ $locale.baseText('parameterInput.dragTipBeforePill') }}
</n8n-text>
<div :class="[$style.pill, { [$style.highlight]: !ndvStore.isMappingOnboarded }]">
{{ $locale.baseText('parameterInput.inputField') }}
</div>
<n8n-text size="small" :class="$style.text">
{{ $locale.baseText('parameterInput.dragTipAfterPill') }}
</n8n-text>
</div>
<div v-else-if="tip === 'executePrevious'" :class="$style.tip">
<n8n-text size="small" :class="$style.tipText"
>{{ $locale.baseText('parameterInput.tip') }}:
</n8n-text>
<n8n-text size="small" :class="$style.text"
>{{ $locale.baseText('expressionTip.noExecutionData') }}
</n8n-text>
</div>
<div v-else :class="$style.tip">
<n8n-text size="small" :class="$style.tipText"
>{{ $locale.baseText('parameterInput.tip') }}:
</n8n-text>
<n8n-text size="small" :class="$style.text">
{{ i18n.baseText('parameterInput.anythingInside') }}
</n8n-text>
<code v-text="`{{ }}`"></code>
<n8n-text size="small" :class="$style.text">
{{ i18n.baseText('parameterInput.isJavaScript') }}
</n8n-text>
<n8n-link
:class="$style['learn-more']"
size="small"
underline
theme="text"
:to="expressionsDocsUrl"
>
{{ i18n.baseText('parameterInput.learnMore') }}
</n8n-link>
</div>
</template>
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n';
import { useNDVStore } from '@/stores/ndv.store';
import { computed } from 'vue';
import { EXPRESSIONS_DOCS_URL } from '@/constants';
const i18n = useI18n();
const ndvStore = useNDVStore();
const props = defineProps<{ tip?: 'drag' | 'default' }>();
const tip = computed(() => {
if (!ndvStore.hasInputData) {
return 'executePrevious';
}
if (props.tip) return props.tip;
if (ndvStore.focusedMappableInput) return 'drag';
return 'default';
});
const expressionsDocsUrl = EXPRESSIONS_DOCS_URL;
</script>
<style lang="scss" module>
.tip {
display: inline-flex;
align-items: center;
line-height: var(--font-line-height-regular);
color: var(--color-text-base);
font-size: var(--font-size-2xs);
padding: var(--spacing-2xs);
gap: var(--spacing-4xs);
.tipText {
color: var(--color-text-dark);
font-weight: var(--font-weight-bold);
}
.text {
flex-shrink: 0;
&:last-child {
flex-shrink: 1;
white-space: nowrap;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
}
code {
font-size: var(--font-size-3xs);
background: var(--color-background-base);
padding: var(--spacing-5xs);
border-radius: var(--border-radius-base);
}
.pill {
flex-shrink: 0;
display: flex;
align-items: center;
color: var(--color-text-dark);
border: var(--border-base);
border-color: var(--color-foreground-light);
background-color: var(--color-background-xlight);
padding: var(--spacing-5xs) var(--spacing-3xs);
border-radius: var(--border-radius-base);
}
.highlight {
color: var(--color-primary);
background-color: var(--color-primary-tint-3);
border-color: var(--color-primary-tint-1);
}
}
</style>

View file

@ -51,6 +51,7 @@
:path="path"
:additional-expression-data="additionalExpressionData"
:class="{ 'ph-no-capture': shouldRedactValue }"
:event-bus="eventBus"
@update:model-value="expressionUpdated"
@modalOpenerClick="openExpressionEditorModal"
@focus="setFocus"

View file

@ -29,46 +29,34 @@
@drop="onDrop"
>
<template #default="{ droppable, activeDrop }">
<n8n-tooltip
placement="left"
:visible="showMappingTooltip"
:buttons="dataMappingTooltipButtons"
>
<template #content>
<span
v-html="
i18n.baseText(`dataMapping.${displayMode}Hint`, {
interpolate: { name: parameter.displayName },
})
"
/>
</template>
<ParameterInputWrapper
ref="param"
:parameter="parameter"
:model-value="value"
:path="path"
:is-read-only="isReadOnly"
:is-assignment="isAssignment"
:rows="rows"
:droppable="droppable"
:active-drop="activeDrop"
:force-show-expression="forceShowExpression"
:hint="hint"
:hide-hint="hideHint"
:hide-issues="hideIssues"
:label="label"
:event-bus="eventBus"
input-size="small"
@update="valueChanged"
@textInput="onTextInput"
@focus="onFocus"
@blur="onBlur"
@drop="onDrop"
/>
</n8n-tooltip>
<ParameterInputWrapper
ref="param"
:parameter="parameter"
:model-value="value"
:path="path"
:is-read-only="isReadOnly"
:is-assignment="isAssignment"
:rows="rows"
:droppable="droppable"
:active-drop="activeDrop"
:force-show-expression="forceShowExpression"
:hint="hint"
:hide-hint="hideHint"
:hide-issues="hideIssues"
:label="label"
:event-bus="eventBus"
input-size="small"
@update="valueChanged"
@textInput="onTextInput"
@focus="onFocus"
@blur="onBlur"
@drop="onDrop"
/>
</template>
</DraggableTarget>
<div v-if="showDragnDropTip" :class="$style.tip">
<InlineExpressionTip tip="drag" />
</div>
<div
:class="{
[$style.options]: true,
@ -94,7 +82,7 @@ import { defineComponent } from 'vue';
import type { PropType } from 'vue';
import { mapStores } from 'pinia';
import type { IN8nButton, INodeUi, IRunDataDisplayMode, IUpdateInformation } from '@/Interface';
import type { INodeUi, IRunDataDisplayMode, IUpdateInformation } from '@/Interface';
import ParameterOptions from '@/components/ParameterOptions.vue';
import DraggableTarget from '@/components/DraggableTarget.vue';
@ -109,13 +97,11 @@ import type {
IParameterLabel,
NodeParameterValueType,
} from 'n8n-workflow';
import type { BaseTextKey } from '@/plugins/i18n';
import { useNDVStore } from '@/stores/ndv.store';
import { useSegment } from '@/stores/segment.store';
import { getMappedResult } from '@/utils/mappingUtils';
import { createEventBus } from 'n8n-design-system/utils';
const DISPLAY_MODES_WITH_DATA_MAPPING = ['table', 'json', 'schema'];
import InlineExpressionTip from './InlineExpressionEditor/InlineExpressionTip.vue';
export default defineComponent({
name: 'ParameterInputFull',
@ -123,6 +109,7 @@ export default defineComponent({
ParameterOptions,
DraggableTarget,
ParameterInputWrapper,
InlineExpressionTip,
},
props: {
displayOptions: {
@ -159,6 +146,7 @@ export default defineComponent({
},
parameter: {
type: Object as PropType<INodeProperties>,
required: true,
},
path: {
type: String,
@ -192,24 +180,8 @@ export default defineComponent({
focused: false,
menuExpanded: false,
forceShowExpression: false,
dataMappingTooltipButtons: [] as IN8nButton[],
mappingTooltipEnabled: false,
};
},
mounted() {
const mappingTooltipDismissHandler = this.onMappingTooltipDismissed.bind(this);
this.dataMappingTooltipButtons = [
{
attrs: {
label: this.i18n.baseText('_reusableBaseText.dismiss' as BaseTextKey),
'data-test-id': 'dismiss-mapping-tooltip',
},
listeners: {
onClick: mappingTooltipDismissHandler,
},
},
];
},
computed: {
...mapStores(useNDVStore),
node(): INodeUi | null {
@ -221,6 +193,9 @@ export default defineComponent({
isInputTypeString(): boolean {
return this.parameter.type === 'string';
},
isInputTypeNumber(): boolean {
return this.parameter.type === 'number';
},
isResourceLocator(): boolean {
return this.parameter.type === 'resourceLocator';
},
@ -239,31 +214,29 @@ export default defineComponent({
displayMode(): IRunDataDisplayMode {
return this.ndvStore.inputPanelDisplayMode;
},
showMappingTooltip(): boolean {
showDragnDropTip(): boolean {
return (
this.mappingTooltipEnabled &&
!this.ndvStore.isMappingOnboarded &&
this.focused &&
this.isInputTypeString &&
!this.isInputDataEmpty &&
DISPLAY_MODES_WITH_DATA_MAPPING.includes(this.displayMode)
(this.isInputTypeString || this.isInputTypeNumber) &&
!this.isValueExpression &&
!this.isDropDisabled &&
!this.ndvStore.isMappingOnboarded
);
},
},
methods: {
onFocus() {
this.focused = true;
setTimeout(() => {
this.mappingTooltipEnabled = true;
}, 500);
if (!this.parameter.noDataExpression) {
this.ndvStore.setMappableNDVInputFocus(this.parameter.displayName);
}
},
onBlur() {
this.focused = false;
this.mappingTooltipEnabled = false;
if (!this.parameter.noDataExpression) {
if (
!this.parameter.noDataExpression &&
this.ndvStore.focusedMappableInput === this.parameter.displayName
) {
this.ndvStore.setMappableNDVInputFocus('');
}
this.$emit('blur');
@ -333,6 +306,7 @@ export default defineComponent({
}
this.valueChanged(parameterData);
this.eventBus.emit('drop', updatedValue);
if (!this.ndvStore.isMappingOnboarded) {
this.showMessage({
@ -342,7 +316,7 @@ export default defineComponent({
dangerouslyUseHTMLString: true,
});
this.ndvStore.disableMappingHint();
this.ndvStore.setMappingOnboarded();
}
this.ndvStore.setMappingTelemetry({
@ -364,21 +338,11 @@ export default defineComponent({
this.forceShowExpression = false;
}, 200);
},
onMappingTooltipDismissed() {
this.ndvStore.disableMappingHint(false);
},
},
watch: {
showMappingTooltip(newValue: boolean) {
if (!newValue) {
this.$telemetry.track('User viewed data mapping tooltip', { type: 'param focus' });
}
},
},
});
</script>
<style module>
<style lang="scss" module>
.wrapper {
position: relative;
@ -388,6 +352,20 @@ export default defineComponent({
}
}
}
.tip {
position: absolute;
z-index: 2;
top: 100%;
background: var(--color-code-background);
border: var(--border-base);
border-top: none;
width: 100%;
box-shadow: 0 2px 6px 0 rgba(#441c17, 0.1);
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
}
.options {
position: absolute;
bottom: -22px;

View file

@ -1,5 +1,5 @@
<template>
<div :class="$style.jsonDisplay">
<div :class="[$style.jsonDisplay, { [$style.highlight]: highlight }]">
<Suspense>
<RunDataJsonActions
v-if="!editMode.enabled"
@ -157,6 +157,9 @@ export default defineComponent({
jsonData(): IDataObject[] {
return executionDataToJson(this.inputData);
},
highlight(): boolean {
return !this.ndvStore.isMappingOnboarded && Boolean(this.ndvStore.focusedMappableInput);
},
},
methods: {
getShortKey(el: HTMLElement): string {
@ -200,7 +203,9 @@ export default defineComponent({
setTimeout(() => {
void this.externalHooks.run('runDataJson.onDragEnd', telemetryPayload);
this.$telemetry.track('User dragged data for mapping', telemetryPayload);
this.$telemetry.track('User dragged data for mapping', telemetryPayload, {
withPostHog: true,
});
}, 1000); // ensure dest data gets set if drop
},
getContent(value: unknown): string {
@ -232,20 +237,22 @@ export default defineComponent({
opacity: 1;
}
}
}
.mappable {
cursor: grab;
.mappable {
cursor: grab;
&:hover {
background-color: var(--color-json-highlight);
&:hover {
background-color: var(--color-json-highlight);
}
}
}
.dragged {
&,
&:hover {
background-color: var(--color-primary-tint-2);
&.highlight .mappable,
.dragged {
&,
&:hover {
background-color: var(--color-primary-tint-2);
color: var(--color-primary);
}
}
}
</style>

View file

@ -35,6 +35,10 @@ const schema = computed(() => getSchemaForExecutionData(props.data));
const isDataEmpty = computed(() => isEmpty(props.data));
const highlight = computed(() => {
return !ndvStore.isMappingOnboarded && Boolean(ndvStore.focusedMappableInput);
});
const onDragStart = (el: HTMLElement) => {
if (el?.dataset?.path) {
draggingPath.value = el.dataset.path;
@ -62,13 +66,13 @@ const onDragEnd = (el: HTMLElement) => {
void useExternalHooks().run('runDataJson.onDragEnd', telemetryPayload);
telemetry.track('User dragged data for mapping', telemetryPayload);
telemetry.track('User dragged data for mapping', telemetryPayload, { withPostHog: true });
}, 1000); // ensure dest data gets set if drop
};
</script>
<template>
<div :class="$style.schemaWrapper">
<div :class="[$style.schemaWrapper, { highlightSchema: highlight }]">
<n8n-info-tip v-if="isDataEmpty">{{
i18n.baseText('dataMapping.schemaView.emptyData')
}}</n8n-info-tip>

View file

@ -39,6 +39,8 @@ const text = computed(() =>
Array.isArray(props.schema.value) ? '' : shorten(props.schema.value, 600, 0),
);
const dragged = computed(() => props.draggingPath === props.schema.path);
const getJsonParameterPath = (path: string): string =>
getMappedExpression({
nodeName: props.node!.name,
@ -83,7 +85,7 @@ const getIconBySchemaType = (type: Schema['type']): string => {
:class="{
[$style.pill]: true,
[$style.mappable]: mappingEnabled,
[$style.dragged]: draggingPath === schema.path,
[$style.highlight]: dragged,
}"
>
<span
@ -203,6 +205,25 @@ const getIconBySchemaType = (type: Schema['type']): string => {
}
}
:global(.highlightSchema) {
.pill.mappable {
&,
&:hover,
span,
&:hover span span {
color: var(--color-primary);
border-color: var(--color-primary-tint-1);
background-color: var(--color-primary-tint-3);
svg {
path {
fill: var(--color-primary);
}
}
}
}
}
.pill {
float: left;
display: inline-flex;
@ -237,22 +258,6 @@ const getIconBySchemaType = (type: Schema['type']): string => {
}
}
}
&.dragged {
&,
&:hover,
span {
color: var(--color-primary);
border-color: var(--color-primary-tint-1);
background-color: var(--color-primary-tint-3);
svg {
path {
fill: var(--color-primary);
}
}
}
}
}
.label {

View file

@ -1,5 +1,5 @@
<template>
<div :class="$style.dataDisplay">
<div :class="[$style.dataDisplay, { [$style.highlight]: highlight }]">
<table v-if="tableData.columns && tableData.columns.length === 0" :class="$style.table">
<tr>
<th :class="$style.emptyCell"></th>
@ -259,6 +259,9 @@ export default defineComponent({
focusedMappableInput(): string {
return this.ndvStore.focusedMappableInput;
},
highlight(): boolean {
return !this.ndvStore.isMappingOnboarded && Boolean(this.ndvStore.focusedMappableInput);
},
},
methods: {
shorten,
@ -438,7 +441,9 @@ export default defineComponent({
void this.externalHooks.run('runDataTable.onDragEnd', telemetryPayload);
this.$telemetry.track('User dragged data for mapping', telemetryPayload);
this.$telemetry.track('User dragged data for mapping', telemetryPayload, {
withPostHog: true,
});
}, 1000); // ensure dest data gets set if drop
},
isSimple(data: unknown): boolean {
@ -642,7 +647,12 @@ export default defineComponent({
}
}
.highlight .draggableHeader {
color: var(--color-primary);
}
.draggingHeader {
color: var(--color-primary);
background-color: var(--color-primary-tint-2);
}

View file

@ -395,6 +395,7 @@ export const LOCAL_STORAGE_ACTIVATION_FLAG = 'N8N_HIDE_ACTIVATION_ALERT';
export const LOCAL_STORAGE_PIN_DATA_DISCOVERY_NDV_FLAG = 'N8N_PIN_DATA_DISCOVERY_NDV';
export const LOCAL_STORAGE_PIN_DATA_DISCOVERY_CANVAS_FLAG = 'N8N_PIN_DATA_DISCOVERY_CANVAS';
export const LOCAL_STORAGE_MAPPING_IS_ONBOARDED = 'N8N_MAPPING_ONBOARDED';
export const LOCAL_STORAGE_AUTOCOMPLETE_IS_ONBOARDED = 'N8N_AUTOCOMPLETE_ONBOARDED';
export const LOCAL_STORAGE_MAIN_PANEL_RELATIVE_WIDTH = 'N8N_MAIN_PANEL_RELATIVE_WIDTH';
export const LOCAL_STORAGE_ACTIVE_MODAL = 'N8N_ACTIVE_MODAL';
export const LOCAL_STORAGE_THEME = 'N8N_THEME';

View file

@ -32,6 +32,8 @@ export const completionManager = defineComponent({
if (!completionTx) return;
this.ndvStore.setAutocompleteOnboarded();
let completion = '';
let completionBase = '';

View file

@ -599,9 +599,24 @@ describe('Resolution-based completions', () => {
);
});
});
describe('explicit completions (opened by Ctrl+Space or programatically)', () => {
test('should return completions for: {{ $json.foo| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter')
// @ts-expect-error Spied function is mistyped
.mockReturnValueOnce(undefined)
// @ts-expect-error Spied function is mistyped
.mockReturnValueOnce('foo');
const result = completions('{{ $json.foo| }}', true);
expect(result).toHaveLength(
extensions('string').length + natives('string').length + STRING_RECOMMENDED_OPTIONS.length,
);
});
});
});
export function completions(docWithCursor: string) {
export function completions(docWithCursor: string, explicit = false) {
const cursorPosition = docWithCursor.indexOf('|');
const doc = docWithCursor.slice(0, cursorPosition) + docWithCursor.slice(cursorPosition + 1);
@ -612,7 +627,7 @@ export function completions(docWithCursor: string) {
extensions: [n8nLang()],
});
const context = new CompletionContext(state, cursorPosition, false);
const context = new CompletionContext(state, cursorPosition, explicit);
for (const completionSource of state.languageDataAt<CompletionSource>(
'autocomplete',

View file

@ -67,8 +67,7 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul
if (word.from === word.to && !context.explicit) return null;
// eslint-disable-next-line prefer-const
let [base, tail] = splitBaseTail(word.text);
const [base, tail] = splitBaseTail(word.text);
let options: Completion[] = [];
@ -102,14 +101,23 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul
}
}
if (options.length === 0) return null;
if (tail !== '') {
options = options.filter((o) => prefixMatch(o.label, tail) && o.label !== tail);
}
let from = word.to - tail.length;
// When autocomplete is explicitely opened (by Ctrl+Space or programatically), add completions for the current word with '.' prefix
// example: {{ $json.str| }} -> ['length', 'includes()'...] (would usually need a '.' suffix)
if (context.explicit && !word.text.endsWith('.') && options.length === 0) {
options = explicitDataTypeOptions(word.text);
from = word.to;
}
if (options.length === 0) return null;
return {
from: word.to - tail.length,
from,
options,
filter: false,
getMatch(completion: Completion) {
@ -120,6 +128,20 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul
};
}
function explicitDataTypeOptions(expression: string): Completion[] {
try {
const resolved = resolveParameter(`={{ ${expression} }}`);
return datatypeOptions({
resolved,
base: expression,
tail: '',
transformLabel: (label) => '.' + label,
});
} catch {
return [];
}
}
function datatypeOptions(input: AutocompleteInput): Completion[] {
const { resolved } = input;
@ -134,11 +156,11 @@ function datatypeOptions(input: AutocompleteInput): Completion[] {
}
if (resolved instanceof DateTime) {
return luxonOptions();
return luxonOptions(input as AutocompleteInput<DateTime>);
}
if (resolved instanceof Date) {
return dateOptions();
return dateOptions(input as AutocompleteInput<Date>);
}
if (Array.isArray(resolved)) {
@ -152,18 +174,33 @@ function datatypeOptions(input: AutocompleteInput): Completion[] {
return [];
}
export const natives = (typeName: ExtensionTypeName): Completion[] => {
export const natives = (
typeName: ExtensionTypeName,
transformLabel: (label: string) => string = (label) => label,
): Completion[] => {
const natives: NativeDoc = NativeMethods.find((ee) => ee.typeName.toLowerCase() === typeName);
if (!natives) return [];
const nativeProps = natives.properties ? toOptions(natives.properties, typeName, 'keyword') : [];
const nativeMethods = toOptions(natives.functions, typeName, 'native-function');
const nativeProps = natives.properties
? toOptions(natives.properties, typeName, 'keyword', false, transformLabel)
: [];
const nativeMethods = toOptions(
natives.functions,
typeName,
'native-function',
false,
transformLabel,
);
return [...nativeProps, ...nativeMethods];
};
export const extensions = (typeName: ExtensionTypeName, includeHidden = false) => {
export const extensions = (
typeName: ExtensionTypeName,
includeHidden = false,
transformLabel: (label: string) => string = (label) => label,
) => {
const extensions = ExpressionExtensions.find((ee) => ee.typeName.toLowerCase() === typeName);
if (!extensions) return [];
@ -172,7 +209,7 @@ export const extensions = (typeName: ExtensionTypeName, includeHidden = false) =
return { ...acc, [fnName]: { doc: fn.doc } };
}, {});
return toOptions(fnToDoc, typeName, 'extension-function', includeHidden);
return toOptions(fnToDoc, typeName, 'extension-function', includeHidden, transformLabel);
};
export const toOptions = (
@ -180,12 +217,13 @@ export const toOptions = (
typeName: ExtensionTypeName,
optionType: AutocompleteOptionType = 'native-function',
includeHidden = false,
transformLabel: (label: string) => string = (label) => label,
) => {
return Object.entries(fnToDoc)
.sort((a, b) => a[0].localeCompare(b[0]))
.filter(([, docInfo]) => !docInfo.doc?.hidden || includeHidden)
.map(([fnName, docInfo]) => {
return createCompletionOption(typeName, fnName, optionType, docInfo);
return createCompletionOption(typeName, fnName, optionType, docInfo, transformLabel);
});
};
@ -194,6 +232,7 @@ const createCompletionOption = (
name: string,
optionType: AutocompleteOptionType,
docInfo: { doc?: DocMetadata | undefined },
transformLabel: (label: string) => string = (label) => label,
): Completion => {
const isFunction = isFunctionOption(optionType);
const label = isFunction ? name + '()' : name;
@ -201,7 +240,7 @@ const createCompletionOption = (
label,
type: optionType,
section: docInfo.doc?.section,
apply: applyCompletion(hasRequiredArgs(docInfo?.doc)),
apply: applyCompletion(hasRequiredArgs(docInfo?.doc), transformLabel),
};
option.info = () => {
@ -304,7 +343,7 @@ const createPropHeader = (typeName: string, property: { doc?: DocMetadata | unde
};
const objectOptions = (input: AutocompleteInput<IDataObject>): Completion[] => {
const { base, resolved } = input;
const { base, resolved, transformLabel } = input;
const rank = setRank(['item', 'all', 'first', 'last']);
const SKIP = new Set(['__ob__', 'pairedItem']);
@ -338,17 +377,23 @@ const objectOptions = (input: AutocompleteInput<IDataObject>): Completion[] => {
label: isFunction ? key + '()' : key,
type: isFunction ? 'function' : 'keyword',
section: getObjectPropertySection({ name, key, isFunction }),
apply: applyCompletion(hasArgs),
apply: applyCompletion(hasArgs, transformLabel),
};
const infoKey = [name, key].join('.');
option.info = createCompletionOption('', key, isFunction ? 'native-function' : 'keyword', {
doc: {
name: key,
returnType: typeof resolvedProp,
description: i18n.proxyVars[infoKey],
option.info = createCompletionOption(
'',
key,
isFunction ? 'native-function' : 'keyword',
{
doc: {
name: key,
returnType: typeof resolvedProp,
description: i18n.proxyVars[infoKey],
},
},
}).info;
transformLabel,
).info;
return option;
});
@ -448,8 +493,11 @@ const isUrl = (url: string): boolean => {
};
const stringOptions = (input: AutocompleteInput<string>): Completion[] => {
const { resolved, tail } = input;
const options = sortCompletionsAlpha([...natives('string'), ...extensions('string')]);
const { resolved, transformLabel } = input;
const options = sortCompletionsAlpha([
...natives('string', transformLabel),
...extensions('string', false, transformLabel),
]);
if (validateFieldType('string', resolved, 'number').valid) {
return applySections({
@ -491,8 +539,11 @@ const stringOptions = (input: AutocompleteInput<string>): Completion[] => {
};
const numberOptions = (input: AutocompleteInput<number>): Completion[] => {
const { resolved } = input;
const options = sortCompletionsAlpha([...natives('number'), ...extensions('number')]);
const { resolved, transformLabel } = input;
const options = sortCompletionsAlpha([
...natives('number', transformLabel),
...extensions('number', false, transformLabel),
]);
const ONLY_INTEGER = ['isEven()', 'isOdd()'];
if (Number.isInteger(resolved)) {
@ -509,17 +560,26 @@ const numberOptions = (input: AutocompleteInput<number>): Completion[] => {
}
};
const dateOptions = (): Completion[] => {
const dateOptions = (input: AutocompleteInput<Date>): Completion[] => {
return applySections({
options: sortCompletionsAlpha([...natives('date'), ...extensions('date', true)]),
options: sortCompletionsAlpha([
...natives('date', input.transformLabel),
...extensions('date', true, input.transformLabel),
]),
recommended: DATE_RECOMMENDED_OPTIONS,
});
};
const luxonOptions = (): Completion[] => {
const luxonOptions = (input: AutocompleteInput<DateTime>): Completion[] => {
return applySections({
options: sortCompletionsAlpha(
uniqBy([...extensions('date'), ...luxonInstanceOptions()], (option) => option.label),
uniqBy(
[
...extensions('date', false, input.transformLabel),
...luxonInstanceOptions(false, input.transformLabel),
],
(option) => option.label,
),
),
recommended: LUXON_RECOMMENDED_OPTIONS,
sections: LUXON_SECTIONS,
@ -527,9 +587,12 @@ const luxonOptions = (): Completion[] => {
};
const arrayOptions = (input: AutocompleteInput<unknown[]>): Completion[] => {
const { resolved } = input;
const { resolved, transformLabel } = input;
const options = applySections({
options: sortCompletionsAlpha([...natives('array'), ...extensions('array')]),
options: sortCompletionsAlpha([
...natives('array', transformLabel),
...extensions('array', false, transformLabel),
]),
recommended: ARRAY_RECOMMENDED_OPTIONS,
methodsSection: OTHER_SECTION,
propSection: OTHER_SECTION,
@ -620,7 +683,10 @@ export const secretProvidersOptions = () => {
/**
* Methods and fields defined on a Luxon `DateTime` class instance.
*/
export const luxonInstanceOptions = (includeHidden = false) => {
export const luxonInstanceOptions = (
includeHidden = false,
transformLabel: (label: string) => string = (label) => label,
) => {
const SKIP = new Set(['constructor', 'get', 'invalidExplanation', 'invalidReason']);
return Object.entries(Object.getOwnPropertyDescriptors(DateTime.prototype))
@ -635,6 +701,7 @@ export const luxonInstanceOptions = (includeHidden = false) => {
luxonInstanceDocs,
i18n.luxonInstance,
includeHidden,
transformLabel,
) as Completion;
})
.filter(Boolean);
@ -667,6 +734,7 @@ const createLuxonAutocompleteOption = (
docDefinition: NativeDoc,
translations: Record<string, string | undefined>,
includeHidden = false,
transformLabel: (label: string) => string = (label) => label,
): Completion | null => {
const isFunction = isFunctionOption(type);
const label = isFunction ? name + '()' : name;
@ -696,12 +764,18 @@ const createLuxonAutocompleteOption = (
label,
type,
section: doc?.section,
apply: applyCompletion(hasRequiredArgs(doc)),
apply: applyCompletion(hasRequiredArgs(doc), transformLabel),
};
option.info = createCompletionOption('DateTime', name, type, {
// Add translated description
doc: { ...doc, description: translations[name] } as DocMetadata,
}).info;
option.info = createCompletionOption(
'DateTime',
name,
type,
{
// Add translated description
doc: { ...doc, description: translations[name] } as DocMetadata,
},
transformLabel,
).info;
return option;
};

View file

@ -13,4 +13,5 @@ export type AutocompleteInput<R = Resolved> = {
resolved: R;
base: string;
tail: string;
transformLabel?: (label: string) => string;
};

View file

@ -157,15 +157,16 @@ export const stripExcessParens = (context: CompletionContext) => (option: Comple
* @example `$max()` -> `$max()<cursor>`
*/
export const applyCompletion =
(hasArgs = true) =>
(hasArgs = true, transform: (label: string) => string = (label) => label) =>
(view: EditorView, completion: Completion, from: number, to: number): void => {
const label = transform(completion.label);
const tx: TransactionSpec = {
...insertCompletionText(view.state, completion.label, from, to),
...insertCompletionText(view.state, label, from, to),
annotations: pickedCompletion.of(completion),
};
if (completion.label.endsWith('()') && hasArgs) {
const cursorPosition = from + completion.label.length - 1;
if (label.endsWith('()') && hasArgs) {
const cursorPosition = from + label.length - 1;
tx.selection = { anchor: cursorPosition, head: cursorPosition };
}

View file

@ -510,9 +510,6 @@
"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'/><br/> Drag a column onto <b>{name}</b> to map it",
"dataMapping.jsonHint": "<img src='/static/json-mapping-gif.gif'/><br/> Drag a JSON key onto <b>{name}</b> to map data",
"dataMapping.schemaHint": "<img src='/static/schema-mapping-gif.gif'/><br/> Drag a datapill onto <b>{name}</b> to map data",
"dataMapping.tableView.tableColumnsExceeded": "Some columns are hidden",
"dataMapping.tableView.tableColumnsExceeded.tooltip": "Your data has more than {columnLimit} columns so some are hidden. Switch to {link} to see all data.",
"dataMapping.tableView.tableColumnsExceeded.tooltip.link": "JSON view",
@ -682,6 +679,7 @@
"expressionModalInput.empty": "[empty]",
"expressionModalInput.undefined": "[undefined]",
"expressionModalInput.null": "null",
"expressionTip.noExecutionData": "Execute previous nodes to use input data",
"expressionModalInput.noExecutionData": "Execute previous nodes for preview",
"expressionModalInput.noNodeExecutionData": "Execute node {node} for preview",
"expressionModalInput.noInputConnection": "No input connected",
@ -1197,8 +1195,12 @@
"openWorkflow.workflowImportError": "Could not import workflow",
"openWorkflow.workflowNotFoundError": "Could not find workflow",
"parameterInput.expressionResult": "e.g. {result}",
"parameterInput.tip": "Tip",
"parameterInput.anythingInside": "Anything inside ",
"parameterInput.isJavaScript": " is JavaScript.",
"parameterInput.dragTipBeforePill": "Drag an",
"parameterInput.inputField": "input field",
"parameterInput.dragTipAfterPill": "from the left to use it here.",
"parameterInput.learnMore": "Learn more",
"parameterInput.resultForItem": "Result for Item",
"parameterInput.emptyString": "[empty]",

View file

@ -7,7 +7,11 @@ import type {
XYPosition,
} from '@/Interface';
import { useStorage } from '@/composables/useStorage';
import { LOCAL_STORAGE_MAPPING_IS_ONBOARDED, STORES } from '@/constants';
import {
LOCAL_STORAGE_AUTOCOMPLETE_IS_ONBOARDED,
LOCAL_STORAGE_MAPPING_IS_ONBOARDED,
STORES,
} from '@/constants';
import type { INodeExecutionData, INodeIssues } from 'n8n-workflow';
import { NodeConnectionType } from 'n8n-workflow';
import { defineStore } from 'pinia';
@ -50,6 +54,7 @@ export const useNDVStore = defineStore(STORES.NDV, {
activeTarget: null,
},
isMappingOnboarded: useStorage(LOCAL_STORAGE_MAPPING_IS_ONBOARDED).value === 'true',
isAutocompleteOnboarded: useStorage(LOCAL_STORAGE_AUTOCOMPLETE_IS_ONBOARDED).value === 'true',
}),
getters: {
activeNode(): INodeUi | null {
@ -77,7 +82,9 @@ export const useNDVStore = defineStore(STORES.NDV, {
},
hasInputData(): boolean {
const data = this.ndvInputData;
return data && data.length > 0;
const pinData =
this.ndvInputNodeName && useWorkflowsStore().pinDataByNodeName(this.ndvInputNodeName);
return !!(data && data.length > 0) || !!(pinData && pinData.length > 0);
},
getPanelDisplayMode() {
return (panel: NodePanelType) => this[panel].displayMode;
@ -236,11 +243,13 @@ export const useNDVStore = defineStore(STORES.NDV, {
setNDVPanelDataIsEmpty(payload: { panel: 'input' | 'output'; isEmpty: boolean }): void {
this[payload.panel].data.isEmpty = payload.isEmpty;
},
disableMappingHint(store = true) {
setMappingOnboarded() {
this.isMappingOnboarded = true;
if (store) {
useStorage(LOCAL_STORAGE_MAPPING_IS_ONBOARDED).value = 'true';
}
useStorage(LOCAL_STORAGE_MAPPING_IS_ONBOARDED).value = 'true';
},
setAutocompleteOnboarded() {
this.isAutocompleteOnboarded = true;
useStorage(LOCAL_STORAGE_AUTOCOMPLETE_IS_ONBOARDED).value = 'true';
},
updateNodeParameterIssues(issues: INodeIssues): void {
const workflowsStore = useWorkflowsStore();