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'); workflowPage.actions.openNode('Set');
ndv.actions.typeIntoParameterInput('value', 'delete me'); ndv.actions.typeIntoParameterInput('value', 'delete me');
ndv.actions.dismissMappingTooltip();
ndv.actions.typeIntoParameterInput('name', 'test'); 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', ':nth-child(3) > .border-top-dashed > .parameter-input-list-wrapper > :nth-child(1) > .parameter-item',
) )
.find('input[placeholder*="e.g. What is your name?"]') .find('input[placeholder*="e.g. What is your name?"]')
.type('Test Field 3'); .type('Test Field 3')
.blur();
cy.get( cy.get(
':nth-child(3) > .border-top-dashed > .parameter-input-list-wrapper > :nth-child(2) > .parameter-item', ':nth-child(3) > .border-top-dashed > .parameter-input-list-wrapper > :nth-child(2) > .parameter-item',
).click(); ).click();
@ -53,7 +54,8 @@ describe('n8n Form Trigger', () => {
':nth-child(4) > .border-top-dashed > .parameter-input-list-wrapper > :nth-child(1) > .parameter-item', ':nth-child(4) > .border-top-dashed > .parameter-input-list-wrapper > :nth-child(1) > .parameter-item',
) )
.find('input[placeholder*="e.g. What is your name?"]') .find('input[placeholder*="e.g. What is your name?"]')
.type('Test Field 4'); .type('Test Field 4')
.blur();
cy.get( cy.get(
':nth-child(4) > .border-top-dashed > .parameter-input-list-wrapper > :nth-child(2) > .parameter-item', ':nth-child(4) > .border-top-dashed > .parameter-input-list-wrapper > :nth-child(2) > .parameter-item',
).click(); ).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)', ':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') .find('input')
.type('Option 1'); .type('Option 1')
.blur();
cy.get( 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)', ':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') .find('input')
.type('Option 2'); .type('Option 2')
.blur();
//add optional submitted message //add optional submitted message
cy.get('.param-options').click(); 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.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME);
workflowPage.actions.openNode(EDIT_FIELDS_SET_NODE_NAME); workflowPage.actions.openNode(EDIT_FIELDS_SET_NODE_NAME);
ndv.getters.assignmentCollectionAdd('assignments').click(); 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.assignmentType('assignments').click();
ndv.getters.assignmentValue('assignments').paste(cowBase64); ndv.getters.assignmentValue('assignments').paste(cowBase64);
@ -313,7 +313,7 @@ const addEditFields = () => {
workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME); workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME);
workflowPage.actions.openNode(EDIT_FIELDS_SET_NODE_NAME); workflowPage.actions.openNode(EDIT_FIELDS_SET_NODE_NAME);
ndv.getters.assignmentCollectionAdd('assignments').click(); 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(); ndv.getters.assignmentType('assignments').click();
getVisibleSelect().find('li').contains('Number').click(); getVisibleSelect().find('li').contains('Number').click();
ndv.getters.assignmentValue('assignments').type('1234'); 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).invoke('attr', 'data-test-id').should('equal', 'hovering-item');
ndv.getters.inputTableRow(1).realHover(); ndv.getters.inputTableRow(1).realHover();
cy.wait(100);
ndv.getters.outputHoveringItem().should('not.exist'); ndv.getters.outputHoveringItem().should('not.exist');
ndv.getters.parameterExpressionPreview('value').should('include.text', '1111'); ndv.getters.parameterExpressionPreview('value').should('include.text', '1111');
@ -200,6 +201,7 @@ describe('NDV', () => {
ndv.actions.selectInputNode('Code'); ndv.actions.selectInputNode('Code');
ndv.getters.inputTableRow(1).realHover(); ndv.getters.inputTableRow(1).realHover();
cy.wait(100);
ndv.getters.inputTableRow(1).should('have.text', '6666'); ndv.getters.inputTableRow(1).should('have.text', '6666');
ndv.getters.inputTableRow(1).invoke('attr', 'data-test-id').should('equal', 'hovering-item'); 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) => { selectOptionInParameterDropdown: (parameterName: string, content: string) => {
getVisibleSelect().find('.option-headline').contains(content).click(); 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) => { rename: (newName: string) => {
this.getters.nodeNameContainer().click(); this.getters.nodeNameContainer().click();
this.getters.nodeRenameInput().should('be.visible').type('{selectall}').type(newName); this.getters.nodeRenameInput().should('be.visible').type('{selectall}').type(newName);
@ -244,7 +240,7 @@ export class NDV extends BasePage {
getVisiblePopper().find('li').last().click(); getVisiblePopper().find('li').last().click();
}, },
addFilterCondition: (paramName: string) => { addFilterCondition: (paramName: string) => {
this.getters.filterConditionAdd(paramName).click(); this.getters.filterConditionAdd(paramName).click({ force: true });
}, },
removeFilterCondition: (paramName: string, index: number) => { removeFilterCondition: (paramName: string, index: number) => {
this.getters.filterConditionRemove(paramName, index).click(); this.getters.filterConditionRemove(paramName, index).click();

View file

@ -97,7 +97,7 @@
--color-json-brackets: var(--prim-gray-670); --color-json-brackets: var(--prim-gray-670);
--color-json-brackets-hover: var(--prim-color-alt-e); --color-json-brackets-hover: var(--prim-color-alt-e);
--color-json-line: var(--prim-gray-200); --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: var(--prim-gray-800);
--color-code-background-readonly: var(--prim-gray-740); --color-code-background-readonly: var(--prim-gray-740);
--color-code-lineHighlight: 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; activeTarget: { id: string; stickyPosition: null | XYPosition } | null;
}; };
isMappingOnboarded: boolean; isMappingOnboarded: boolean;
isAutocompleteOnboarded: boolean;
} }
export interface NotificationOptions extends Partial<ElementNotificationOptions> { export interface NotificationOptions extends Partial<ElementNotificationOptions> {

View file

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

View file

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

View file

@ -3,27 +3,30 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { completionStatus, startCompletion } from '@codemirror/autocomplete';
import { history } from '@codemirror/commands'; import { history } from '@codemirror/commands';
import { Compartment, EditorState, Prec } from '@codemirror/state'; import { Compartment, EditorState, Prec } from '@codemirror/state';
import { EditorView, keymap } from '@codemirror/view'; import { EditorView, keymap } from '@codemirror/view';
import type { PropType } from 'vue'; import type { PropType } from 'vue';
import { defineComponent } from 'vue'; import { defineComponent, nextTick } from 'vue';
import { completionManager } from '@/mixins/completionManager'; import { completionManager } from '@/mixins/completionManager';
import { expressionManager } from '@/mixins/expressionManager'; import { expressionManager } from '@/mixins/expressionManager';
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler'; 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 { import {
autocompleteKeyMap, autocompleteKeyMap,
enterKeyMap, enterKeyMap,
historyKeyMap, historyKeyMap,
tabKeyMap, tabKeyMap,
} from '@/plugins/codemirror/keymap'; } 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(); const editableConf = new Compartment();
@ -51,6 +54,13 @@ export default defineComponent({
type: Object as PropType<IDataObject>, type: Object as PropType<IDataObject>,
default: () => ({}), default: () => ({}),
}, },
eventBus: {
type: Object as PropType<EventBus>,
default: () => createEventBus(),
},
},
computed: {
...mapStores(useNDVStore),
}, },
watch: { watch: {
isReadOnly(newValue: boolean) { isReadOnly(newValue: boolean) {
@ -132,14 +142,34 @@ export default defineComponent({
this.editorState = this.editor.state; this.editorState = this.editor.state;
highlighter.addColor(this.editor, this.resolvableSegments); highlighter.addColor(this.editor, this.resolvableSegments);
this.eventBus.on('drop', this.onDrop);
}, },
beforeUnmount() { beforeUnmount() {
this.editor?.destroy(); this.editor?.destroy();
this.eventBus.off('drop', this.onDrop);
}, },
methods: { methods: {
focus() { focus() {
this.editor?.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> </script>

View file

@ -7,130 +7,111 @@
<div ref="root" data-test-id="inline-expression-editor-output"></div> <div ref="root" data-test-id="inline-expression-editor-output"></div>
</n8n-text> </n8n-text>
<div :class="$style.footer"> <div :class="$style.footer">
<n8n-text size="small" compact> <InlineExpressionTip />
{{ 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>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { EditorView } from '@codemirror/view'; import { EditorView } from '@codemirror/view';
import { EditorState } from '@codemirror/state'; 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 { 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({ interface InlineExpressionEditorOutputProps {
name: 'InlineExpressionEditorOutput', segments: Segment[];
props: { hoveringItemNumber: number;
segments: { isReadOnly?: boolean;
type: Array as PropType<Segment[]>, visible?: boolean;
required: true, noInputData?: boolean;
}, }
isReadOnly: {
type: Boolean,
default: false,
},
visible: {
type: Boolean,
default: false,
},
noInputData: {
type: Boolean,
default: false,
},
hoveringItemNumber: {
type: Number,
required: true,
},
},
setup() {
const i18n = useI18n();
return { const props = withDefaults(defineProps<InlineExpressionEditorOutputProps>(), {
i18n, readOnly: false,
}; visible: false,
}, noInputData: false,
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;
return this.segments const i18n = useI18n();
.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;
this.editor.dispatch({ const editor = ref<EditorView | null>(null);
changes: { from: 0, to: this.editor.state.doc.length, insert: this.resolvedExpression }, const root = ref<HTMLElement | null>(null);
});
highlighter.addColor(this.editor, this.resolvedSegments); const resolvedExpression = computed(() => {
highlighter.removeColor(this.editor, this.plaintextSegments); if (props.segments.length === 0) {
}, return i18n.baseText('parameterInput.emptyString');
}, }
mounted() {
this.editor = new EditorView({ return props.segments.reduce((acc, segment) => {
parent: this.$refs.root as HTMLDivElement, acc += segment.kind === 'resolvable' ? (segment.resolved as string) : segment.plaintext;
state: EditorState.create({ return acc;
doc: this.resolvedExpression, }, '');
extensions: [outputTheme(), EditorState.readOnly.of(true), EditorView.lineWrapping], });
}),
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> </script>
@ -157,11 +138,14 @@ export default defineComponent({
} }
.header, .header,
.body, .body {
.footer {
padding: var(--spacing-3xs); padding: var(--spacing-3xs);
} }
.footer {
border-top: var(--border-base);
}
.header { .header {
color: var(--color-text-dark); color: var(--color-text-dark);
font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold);
@ -178,28 +162,5 @@ export default defineComponent({
padding-top: var(--spacing-2xs); 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> </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" :path="path"
:additional-expression-data="additionalExpressionData" :additional-expression-data="additionalExpressionData"
:class="{ 'ph-no-capture': shouldRedactValue }" :class="{ 'ph-no-capture': shouldRedactValue }"
:event-bus="eventBus"
@update:model-value="expressionUpdated" @update:model-value="expressionUpdated"
@modalOpenerClick="openExpressionEditorModal" @modalOpenerClick="openExpressionEditorModal"
@focus="setFocus" @focus="setFocus"

View file

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

View file

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

View file

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

View file

@ -39,6 +39,8 @@ const text = computed(() =>
Array.isArray(props.schema.value) ? '' : shorten(props.schema.value, 600, 0), Array.isArray(props.schema.value) ? '' : shorten(props.schema.value, 600, 0),
); );
const dragged = computed(() => props.draggingPath === props.schema.path);
const getJsonParameterPath = (path: string): string => const getJsonParameterPath = (path: string): string =>
getMappedExpression({ getMappedExpression({
nodeName: props.node!.name, nodeName: props.node!.name,
@ -83,7 +85,7 @@ const getIconBySchemaType = (type: Schema['type']): string => {
:class="{ :class="{
[$style.pill]: true, [$style.pill]: true,
[$style.mappable]: mappingEnabled, [$style.mappable]: mappingEnabled,
[$style.dragged]: draggingPath === schema.path, [$style.highlight]: dragged,
}" }"
> >
<span <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 { .pill {
float: left; float: left;
display: inline-flex; 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 { .label {

View file

@ -1,5 +1,5 @@
<template> <template>
<div :class="$style.dataDisplay"> <div :class="[$style.dataDisplay, { [$style.highlight]: highlight }]">
<table v-if="tableData.columns && tableData.columns.length === 0" :class="$style.table"> <table v-if="tableData.columns && tableData.columns.length === 0" :class="$style.table">
<tr> <tr>
<th :class="$style.emptyCell"></th> <th :class="$style.emptyCell"></th>
@ -259,6 +259,9 @@ export default defineComponent({
focusedMappableInput(): string { focusedMappableInput(): string {
return this.ndvStore.focusedMappableInput; return this.ndvStore.focusedMappableInput;
}, },
highlight(): boolean {
return !this.ndvStore.isMappingOnboarded && Boolean(this.ndvStore.focusedMappableInput);
},
}, },
methods: { methods: {
shorten, shorten,
@ -438,7 +441,9 @@ export default defineComponent({
void this.externalHooks.run('runDataTable.onDragEnd', telemetryPayload); 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 }, 1000); // ensure dest data gets set if drop
}, },
isSimple(data: unknown): boolean { isSimple(data: unknown): boolean {
@ -642,7 +647,12 @@ export default defineComponent({
} }
} }
.highlight .draggableHeader {
color: var(--color-primary);
}
.draggingHeader { .draggingHeader {
color: var(--color-primary);
background-color: var(--color-primary-tint-2); 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_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_PIN_DATA_DISCOVERY_CANVAS_FLAG = 'N8N_PIN_DATA_DISCOVERY_CANVAS';
export const LOCAL_STORAGE_MAPPING_IS_ONBOARDED = 'N8N_MAPPING_ONBOARDED'; 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_MAIN_PANEL_RELATIVE_WIDTH = 'N8N_MAIN_PANEL_RELATIVE_WIDTH';
export const LOCAL_STORAGE_ACTIVE_MODAL = 'N8N_ACTIVE_MODAL'; export const LOCAL_STORAGE_ACTIVE_MODAL = 'N8N_ACTIVE_MODAL';
export const LOCAL_STORAGE_THEME = 'N8N_THEME'; export const LOCAL_STORAGE_THEME = 'N8N_THEME';

View file

@ -32,6 +32,8 @@ export const completionManager = defineComponent({
if (!completionTx) return; if (!completionTx) return;
this.ndvStore.setAutocompleteOnboarded();
let completion = ''; let completion = '';
let completionBase = ''; 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 cursorPosition = docWithCursor.indexOf('|');
const doc = docWithCursor.slice(0, cursorPosition) + docWithCursor.slice(cursorPosition + 1); const doc = docWithCursor.slice(0, cursorPosition) + docWithCursor.slice(cursorPosition + 1);
@ -612,7 +627,7 @@ export function completions(docWithCursor: string) {
extensions: [n8nLang()], extensions: [n8nLang()],
}); });
const context = new CompletionContext(state, cursorPosition, false); const context = new CompletionContext(state, cursorPosition, explicit);
for (const completionSource of state.languageDataAt<CompletionSource>( for (const completionSource of state.languageDataAt<CompletionSource>(
'autocomplete', 'autocomplete',

View file

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

View file

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

View file

@ -157,15 +157,16 @@ export const stripExcessParens = (context: CompletionContext) => (option: Comple
* @example `$max()` -> `$max()<cursor>` * @example `$max()` -> `$max()<cursor>`
*/ */
export const applyCompletion = export const applyCompletion =
(hasArgs = true) => (hasArgs = true, transform: (label: string) => string = (label) => label) =>
(view: EditorView, completion: Completion, from: number, to: number): void => { (view: EditorView, completion: Completion, from: number, to: number): void => {
const label = transform(completion.label);
const tx: TransactionSpec = { const tx: TransactionSpec = {
...insertCompletionText(view.state, completion.label, from, to), ...insertCompletionText(view.state, label, from, to),
annotations: pickedCompletion.of(completion), annotations: pickedCompletion.of(completion),
}; };
if (completion.label.endsWith('()') && hasArgs) { if (label.endsWith('()') && hasArgs) {
const cursorPosition = from + completion.label.length - 1; const cursorPosition = from + label.length - 1;
tx.selection = { anchor: cursorPosition, head: cursorPosition }; 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.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.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.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": "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": "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", "dataMapping.tableView.tableColumnsExceeded.tooltip.link": "JSON view",
@ -682,6 +679,7 @@
"expressionModalInput.empty": "[empty]", "expressionModalInput.empty": "[empty]",
"expressionModalInput.undefined": "[undefined]", "expressionModalInput.undefined": "[undefined]",
"expressionModalInput.null": "null", "expressionModalInput.null": "null",
"expressionTip.noExecutionData": "Execute previous nodes to use input data",
"expressionModalInput.noExecutionData": "Execute previous nodes for preview", "expressionModalInput.noExecutionData": "Execute previous nodes for preview",
"expressionModalInput.noNodeExecutionData": "Execute node {node} for preview", "expressionModalInput.noNodeExecutionData": "Execute node {node} for preview",
"expressionModalInput.noInputConnection": "No input connected", "expressionModalInput.noInputConnection": "No input connected",
@ -1197,8 +1195,12 @@
"openWorkflow.workflowImportError": "Could not import workflow", "openWorkflow.workflowImportError": "Could not import workflow",
"openWorkflow.workflowNotFoundError": "Could not find workflow", "openWorkflow.workflowNotFoundError": "Could not find workflow",
"parameterInput.expressionResult": "e.g. {result}", "parameterInput.expressionResult": "e.g. {result}",
"parameterInput.tip": "Tip",
"parameterInput.anythingInside": "Anything inside ", "parameterInput.anythingInside": "Anything inside ",
"parameterInput.isJavaScript": " is JavaScript.", "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.learnMore": "Learn more",
"parameterInput.resultForItem": "Result for Item", "parameterInput.resultForItem": "Result for Item",
"parameterInput.emptyString": "[empty]", "parameterInput.emptyString": "[empty]",

View file

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