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
28 changed files with 559 additions and 323 deletions

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>