feat(editor): Show tip when user can type dot after an expression (#8931)

This commit is contained in:
Elias Meire 2024-03-26 15:23:30 +01:00 committed by GitHub
parent 372d5c7d01
commit 160dfd383d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 510 additions and 272 deletions

View file

@ -255,6 +255,7 @@ describe('Data mapping', () => {
ndv.actions.typeIntoParameterInput('value', 'delete me'); ndv.actions.typeIntoParameterInput('value', 'delete me');
ndv.actions.typeIntoParameterInput('name', 'test'); ndv.actions.typeIntoParameterInput('name', 'test');
ndv.getters.parameterInput('name').find('input').blur();
ndv.actions.typeIntoParameterInput('value', 'fun'); ndv.actions.typeIntoParameterInput('value', 'fun');
ndv.actions.clearParameterInput('value'); // keep focus on param ndv.actions.clearParameterInput('value'); // keep focus on param

View file

@ -1247,6 +1247,7 @@ export interface NDVState {
}; };
isMappingOnboarded: boolean; isMappingOnboarded: boolean;
isAutocompleteOnboarded: boolean; isAutocompleteOnboarded: boolean;
highlightDraggables: boolean;
} }
export interface NotificationOptions extends Partial<ElementNotificationOptions> { export interface NotificationOptions extends Partial<ElementNotificationOptions> {

View file

@ -1,3 +1,124 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import ExpressionFunctionIcon from '@/components/ExpressionFunctionIcon.vue';
import InlineExpressionEditorInput from '@/components/InlineExpressionEditor/InlineExpressionEditorInput.vue';
import InlineExpressionEditorOutput from '@/components/InlineExpressionEditor/InlineExpressionEditorOutput.vue';
import { useNDVStore } from '@/stores/ndv.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { createExpressionTelemetryPayload } from '@/utils/telemetryUtils';
import { useTelemetry } from '@/composables/useTelemetry';
import type { Segment } from '@/types/expressions';
import { createEventBus, type EventBus } from 'n8n-design-system/utils';
import type { IDataObject } from 'n8n-workflow';
import type { EditorState, SelectionRange } from '@codemirror/state';
const isFocused = ref(false);
const segments = ref<Segment[]>([]);
const editorState = ref<EditorState>();
const selection = ref<SelectionRange>();
const inlineInput = ref<InstanceType<typeof InlineExpressionEditorInput>>();
type Props = {
path: string;
modelValue: string;
isReadOnly: boolean;
rows: number;
isAssignment: boolean;
additionalExpressionData: IDataObject;
eventBus: EventBus;
};
const props = withDefaults(defineProps<Props>(), {
rows: 5,
isAssignment: false,
additionalExpressionData: () => ({}),
eventBus: () => createEventBus(),
});
const emit = defineEmits<{
(event: 'modal-opener-click'): void;
(event: 'update:model-value', value: string): void;
(event: 'focus'): void;
(event: 'blur'): void;
}>();
const telemetry = useTelemetry();
const ndvStore = useNDVStore();
const workflowsStore = useWorkflowsStore();
const hoveringItemNumber = computed(() => ndvStore.hoveringItemNumber);
const isDragging = computed(() => ndvStore.isDraggableDragging);
const noInputData = computed(() => ndvStore.hasInputData);
function focus() {
if (inlineInput.value) {
inlineInput.value.focus();
}
}
function onFocus() {
isFocused.value = true;
emit('focus');
}
function onBlur(event?: FocusEvent | KeyboardEvent) {
if (
event?.target instanceof Element &&
Array.from(event.target.classList).some((_class) => _class.includes('resizer'))
) {
return; // prevent blur on resizing
}
const wasFocused = isFocused.value;
isFocused.value = false;
if (wasFocused) {
emit('blur');
const telemetryPayload = createExpressionTelemetryPayload(
segments.value,
props.modelValue,
workflowsStore.workflowId,
ndvStore.sessionId,
ndvStore.activeNode?.type ?? '',
);
telemetry.track('User closed Expression Editor', telemetryPayload);
}
}
function onValueChange({ value, segments: newSegments }: { value: string; segments: Segment[] }) {
segments.value = newSegments;
if (isDragging.value) return;
if (value === '=' + props.modelValue) return; // prevent report on change of target item
emit('update:model-value', value);
}
function onSelectionChange({
state: newState,
selection: newSelection,
}: {
state: EditorState;
selection: SelectionRange;
}) {
editorState.value = newState;
selection.value = newSelection;
}
watch(isDragging, (newIsDragging) => {
if (newIsDragging) {
onBlur();
}
});
defineExpose({ focus });
</script>
<template> <template>
<div <div
v-on-click-outside="onBlur" v-on-click-outside="onBlur"
@ -24,7 +145,8 @@
:event-bus="eventBus" :event-bus="eventBus"
@focus="onFocus" @focus="onFocus"
@blur="onBlur" @blur="onBlur"
@change="onChange" @update:model-value="onValueChange"
@update:selection="onSelectionChange"
/> />
<n8n-button <n8n-button
v-if="!isDragging" v-if="!isDragging"
@ -35,10 +157,13 @@
size="xsmall" size="xsmall"
:class="$style['expression-editor-modal-opener']" :class="$style['expression-editor-modal-opener']"
data-test-id="expander" data-test-id="expander"
@click="$emit('modal-opener-click')" @click="emit('modal-opener-click')"
/> />
</div> </div>
<InlineExpressionEditorOutput <InlineExpressionEditorOutput
:unresolved-expression="modelValue"
:selection="selection"
:editor-state="editorState"
:segments="segments" :segments="segments"
:is-read-only="isReadOnly" :is-read-only="isReadOnly"
:no-input-data="noInputData" :no-input-data="noInputData"
@ -48,137 +173,6 @@
</div> </div>
</template> </template>
<script lang="ts">
import { mapStores } from 'pinia';
import type { PropType } from 'vue';
import { defineComponent } from 'vue';
import { useNDVStore } from '@/stores/ndv.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import InlineExpressionEditorInput from '@/components/InlineExpressionEditor/InlineExpressionEditorInput.vue';
import InlineExpressionEditorOutput from '@/components/InlineExpressionEditor/InlineExpressionEditorOutput.vue';
import ExpressionFunctionIcon from '@/components/ExpressionFunctionIcon.vue';
import { createExpressionTelemetryPayload } from '@/utils/telemetryUtils';
import type { Segment } from '@/types/expressions';
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>;
export default defineComponent({
name: 'ExpressionParameterInput',
components: {
InlineExpressionEditorInput,
InlineExpressionEditorOutput,
ExpressionFunctionIcon,
},
props: {
path: {
type: String,
required: true,
},
modelValue: {
type: String,
required: true,
},
isReadOnly: {
type: Boolean,
default: false,
},
rows: {
type: Number,
default: 5,
},
isAssignment: {
type: Boolean,
default: false,
},
additionalExpressionData: {
type: Object as PropType<IDataObject>,
default: () => ({}),
},
eventBus: {
type: Object as PropType<EventBus>,
default: () => createEventBus(),
},
},
emits: ['focus', 'blur', 'update:model-value', 'modal-opener-click'],
setup() {
const { callDebounced } = useDebounce();
return { callDebounced };
},
data() {
return {
isFocused: false,
segments: [] as Segment[],
};
},
computed: {
...mapStores(useNDVStore, useWorkflowsStore),
hoveringItemNumber(): number {
return this.ndvStore.hoveringItemNumber;
},
isDragging(): boolean {
return this.ndvStore.isDraggableDragging;
},
noInputData(): boolean {
return !this.ndvStore.hasInputData;
},
},
methods: {
focus() {
const inlineInputRef = this.$refs.inlineInput as InlineExpressionEditorInputRef | undefined;
if (inlineInputRef?.$el) {
inlineInputRef.focus();
}
},
onFocus() {
this.isFocused = true;
this.$emit('focus');
},
onBlur(event?: FocusEvent | KeyboardEvent) {
if (
event?.target instanceof Element &&
Array.from(event.target.classList).some((_class) => _class.includes('resizer'))
) {
return; // prevent blur on resizing
}
if (this.isDragging) return; // prevent blur on dragging
const wasFocused = this.isFocused;
this.isFocused = false;
if (wasFocused) {
this.$emit('blur');
const telemetryPayload = createExpressionTelemetryPayload(
this.segments,
this.modelValue,
this.workflowsStore.workflowId,
this.ndvStore.sessionId,
this.ndvStore.activeNode?.type ?? '',
);
this.$telemetry.track('User closed Expression Editor', telemetryPayload);
}
},
onChange({ value, segments }: { value: string; segments: Segment[] }) {
this.segments = segments;
if (this.isDragging) return;
if (value === '=' + this.modelValue) return; // prevent report on change of target item
this.$emit('update:model-value', value);
},
},
});
</script>
<style lang="scss" module> <style lang="scss" module>
.expression-parameter-input { .expression-parameter-input {
position: relative; position: relative;

View file

@ -1,11 +1,7 @@
<template>
<div ref="root" :class="$style.editor" data-test-id="inline-expression-editor-input"></div>
</template>
<script setup lang="ts"> <script setup lang="ts">
import { startCompletion } from '@codemirror/autocomplete'; import { startCompletion } from '@codemirror/autocomplete';
import { history } from '@codemirror/commands'; import { history } from '@codemirror/commands';
import { Prec } from '@codemirror/state'; import { type EditorState, Prec, type SelectionRange } from '@codemirror/state';
import { EditorView, keymap } from '@codemirror/view'; import { EditorView, keymap } from '@codemirror/view';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, toValue, watch } from 'vue'; import { computed, nextTick, onBeforeUnmount, onMounted, ref, toValue, watch } from 'vue';
@ -42,7 +38,8 @@ const props = withDefaults(defineProps<Props>(), {
}); });
const emit = defineEmits<{ const emit = defineEmits<{
(event: 'change', value: { value: string; segments: Segment[] }): void; (event: 'update:model-value', value: { value: string; segments: Segment[] }): void;
(event: 'update:selection', value: { state: EditorState; selection: SelectionRange }): void;
(event: 'focus'): void; (event: 'focus'): void;
}>(); }>();
@ -64,6 +61,7 @@ const editorValue = ref<string>(removeExpressionPrefix(props.modelValue));
const { const {
editor: editorRef, editor: editorRef,
segments, segments,
selection,
readEditorValue, readEditorValue,
setCursorPosition, setCursorPosition,
hasFocus, hasFocus,
@ -79,22 +77,27 @@ const {
defineExpose({ defineExpose({
focus: () => { focus: () => {
if (!hasFocus.value) {
setCursorPosition('lastExpression'); setCursorPosition('lastExpression');
focus(); focus();
}
}, },
}); });
async function onDrop() { async function onDrop() {
await nextTick();
const editor = toValue(editorRef); const editor = toValue(editorRef);
if (!editor) return; if (!editor) return;
await nextTick();
focus(); focus();
setCursorPosition('lastExpression'); setCursorPosition('lastExpression');
if (!ndvStore.isAutocompleteOnboarded) { if (!ndvStore.isAutocompleteOnboarded) {
setTimeout(() => {
startCompletion(editor); startCompletion(editor);
});
} }
} }
@ -106,12 +109,21 @@ watch(
); );
watch(segments.display, (newSegments) => { watch(segments.display, (newSegments) => {
emit('change', { emit('update:model-value', {
value: '=' + readEditorValue(), value: '=' + readEditorValue(),
segments: newSegments, segments: newSegments,
}); });
}); });
watch(selection, (newSelection: SelectionRange) => {
if (editorRef.value) {
emit('update:selection', {
state: editorRef.value.state,
selection: newSelection,
});
}
});
watch(hasFocus, (focused) => { watch(hasFocus, (focused) => {
if (focused) emit('focus'); if (focused) emit('focus');
}); });
@ -125,6 +137,10 @@ onBeforeUnmount(() => {
}); });
</script> </script>
<template>
<div ref="root" :class="$style.editor" data-test-id="inline-expression-editor-input"></div>
</template>
<style lang="scss" module> <style lang="scss" module>
.editor div[contenteditable='false'] { .editor div[contenteditable='false'] {
background-color: var(--disabled-fill, var(--color-background-light)); background-color: var(--disabled-fill, var(--color-background-light));

View file

@ -1,20 +1,6 @@
<template>
<div :class="visible ? $style.dropdown : $style.hidden">
<n8n-text v-if="!noInputData" size="small" compact :class="$style.header">
{{ i18n.baseText('parameterInput.resultForItem') }} {{ hoveringItemNumber }}
</n8n-text>
<n8n-text :class="$style.body">
<div ref="root" data-test-id="inline-expression-editor-output"></div>
</n8n-text>
<div :class="$style.footer">
<InlineExpressionTip />
</div>
</div>
</template>
<script setup lang="ts"> <script setup lang="ts">
import { EditorView } from '@codemirror/view'; import { EditorView } from '@codemirror/view';
import { EditorState } from '@codemirror/state'; import { EditorState, type SelectionRange } from '@codemirror/state';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import type { Plaintext, Resolved, Segment } from '@/types/expressions'; import type { Plaintext, Resolved, Segment } from '@/types/expressions';
@ -25,7 +11,10 @@ import InlineExpressionTip from './InlineExpressionTip.vue';
interface InlineExpressionEditorOutputProps { interface InlineExpressionEditorOutputProps {
segments: Segment[]; segments: Segment[];
unresolvedExpression: string;
hoveringItemNumber: number; hoveringItemNumber: number;
editorState?: EditorState;
selection?: SelectionRange;
isReadOnly?: boolean; isReadOnly?: boolean;
visible?: boolean; visible?: boolean;
noInputData?: boolean; noInputData?: boolean;
@ -35,6 +24,8 @@ const props = withDefaults(defineProps<InlineExpressionEditorOutputProps>(), {
readOnly: false, readOnly: false,
visible: false, visible: false,
noInputData: false, noInputData: false,
editorState: undefined,
selection: undefined,
}); });
const i18n = useI18n(); const i18n = useI18n();
@ -115,6 +106,24 @@ onBeforeUnmount(() => {
}); });
</script> </script>
<template>
<div :class="visible ? $style.dropdown : $style.hidden">
<n8n-text v-if="!noInputData" size="small" compact :class="$style.header">
{{ i18n.baseText('parameterInput.resultForItem') }} {{ hoveringItemNumber }}
</n8n-text>
<n8n-text :class="$style.body">
<div ref="root" data-test-id="inline-expression-editor-output"></div>
</n8n-text>
<div :class="$style.footer">
<InlineExpressionTip
:editor-state="editorState"
:selection="selection"
:unresolved-expression="unresolvedExpression"
/>
</div>
</div>
</template>
<style lang="scss" module> <style lang="scss" module>
.hidden { .hidden {
display: none; display: none;

View file

@ -1,113 +1,171 @@
<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"> <script setup lang="ts">
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { useNDVStore } from '@/stores/ndv.store'; import { useNDVStore } from '@/stores/ndv.store';
import { computed } from 'vue'; import { computed, ref, watch } from 'vue';
import { EXPRESSIONS_DOCS_URL } from '@/constants'; import { EditorSelection, EditorState, type SelectionRange } from '@codemirror/state';
import { type Completion, CompletionContext } from '@codemirror/autocomplete';
import { datatypeCompletions } from '@/plugins/codemirror/completions/datatype.completions';
import { watchDebounced } from '@vueuse/core';
import { FIELDS_SECTION } from '@/plugins/codemirror/completions/constants';
import { isCompletionSection } from '@/plugins/codemirror/completions/utils';
type TipId = 'executePrevious' | 'drag' | 'default' | 'dotObject' | 'dotPrimitive';
type Props = {
editorState?: EditorState;
unresolvedExpression?: string;
selection?: SelectionRange;
};
const props = withDefaults(defineProps<Props>(), {
editorState: undefined,
unresolvedExpression: '',
selection: () => EditorSelection.cursor(0),
});
const i18n = useI18n(); const i18n = useI18n();
const ndvStore = useNDVStore(); const ndvStore = useNDVStore();
const props = defineProps<{ tip?: 'drag' | 'default' }>(); const canAddDotToExpression = ref(false);
const resolvedExpressionHasFields = ref(false);
const tip = computed(() => { const canDragToFocusedInput = computed(
if (!ndvStore.hasInputData) { () => !ndvStore.isNDVDataEmpty('input') && ndvStore.focusedMappableInput,
);
const emptyExpression = computed(() => props.unresolvedExpression.trim().length === 0);
const tip = computed<TipId>(() => {
if (!ndvStore.hasInputData && ndvStore.isInputParentOfActiveNode) {
return 'executePrevious'; return 'executePrevious';
} }
if (props.tip) return props.tip; if (canAddDotToExpression.value) {
return resolvedExpressionHasFields.value ? 'dotObject' : 'dotPrimitive';
}
if (ndvStore.focusedMappableInput) return 'drag'; if (canDragToFocusedInput.value && emptyExpression.value) return 'drag';
return 'default'; return 'default';
}); });
const expressionsDocsUrl = EXPRESSIONS_DOCS_URL;
function getCompletionsWithDot(): readonly Completion[] {
if (!props.editorState || !props.selection || !props.unresolvedExpression) {
return [];
}
const cursorAfterDot = props.selection.from + 1;
const docWithDot =
props.editorState.sliceDoc(0, props.selection.from) +
'.' +
props.editorState.sliceDoc(props.selection.to);
const selectionWithDot = EditorSelection.create([EditorSelection.cursor(cursorAfterDot)]);
if (cursorAfterDot >= docWithDot.length) {
return [];
}
const stateWithDot = EditorState.create({
doc: docWithDot,
selection: selectionWithDot,
});
const context = new CompletionContext(stateWithDot, cursorAfterDot, true);
const completionResult = datatypeCompletions(context);
return completionResult?.options ?? [];
}
watch(tip, (newTip) => {
ndvStore.setHighlightDraggables(!ndvStore.isMappingOnboarded && newTip === 'drag');
});
watchDebounced(
[() => props.selection, () => props.unresolvedExpression],
() => {
const completions = getCompletionsWithDot();
canAddDotToExpression.value = completions.length > 0;
resolvedExpressionHasFields.value = completions.some(
({ section }) => isCompletionSection(section) && section.name === FIELDS_SECTION.name,
);
},
{ debounce: 200 },
);
</script> </script>
<template>
<div :class="[$style.tip, { [$style.drag]: tip === 'drag' }]">
<n8n-text size="small" :class="$style.tipText"
>{{ i18n.baseText('parameterInput.tip') }}:
</n8n-text>
<div v-if="tip === 'drag'" :class="$style.content">
<n8n-text size="small" :class="$style.text">
{{ i18n.baseText('parameterInput.dragTipBeforePill') }}
</n8n-text>
<div :class="[$style.pill, { [$style.highlight]: !ndvStore.isMappingOnboarded }]">
{{ i18n.baseText('parameterInput.inputField') }}
</div>
<n8n-text size="small" :class="$style.text">
{{ i18n.baseText('parameterInput.dragTipAfterPill') }}
</n8n-text>
</div>
<div v-else-if="tip === 'executePrevious'" :class="$style.content">
<span> {{ i18n.baseText('expressionTip.noExecutionData') }} </span>
</div>
<div v-else-if="tip === 'dotPrimitive'" :class="$style.content">
<span v-html="i18n.baseText('expressionTip.typeDotPrimitive')" />
</div>
<div v-else-if="tip === 'dotObject'" :class="$style.content">
<span v-html="i18n.baseText('expressionTip.typeDotObject')" />
</div>
<div v-else :class="$style.content">
<span v-html="i18n.baseText('expressionTip.javascript')" />
</div>
</div>
</template>
<style lang="scss" module> <style lang="scss" module>
.tip { .tip {
display: inline-flex; display: flex;
align-items: center; align-items: flex-start;
gap: var(--spacing-4xs);
line-height: var(--font-line-height-regular); line-height: var(--font-line-height-regular);
color: var(--color-text-base); color: var(--color-text-base);
font-size: var(--font-size-2xs); font-size: var(--font-size-2xs);
padding: var(--spacing-2xs); padding: var(--spacing-2xs);
gap: var(--spacing-4xs); }
.tipText { .content {
display: inline-block;
}
.tipText {
display: inline;
color: var(--color-text-dark); color: var(--color-text-dark);
font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold);
} }
.text { .drag .tipText {
flex-shrink: 0; line-height: 21px;
}
&:last-child { .text {
flex-shrink: 1; display: inline;
white-space: nowrap; }
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
}
code { code {
font-size: var(--font-size-3xs); font-size: var(--font-size-3xs);
background: var(--color-background-base); background: var(--color-background-base);
padding: var(--spacing-5xs); padding: var(--spacing-5xs);
border-radius: var(--border-radius-base); border-radius: var(--border-radius-base);
} }
.pill { .pill {
flex-shrink: 0; display: inline-flex;
display: flex;
align-items: center; align-items: center;
color: var(--color-text-dark); color: var(--color-text-dark);
@ -115,13 +173,13 @@ const expressionsDocsUrl = EXPRESSIONS_DOCS_URL;
border-color: var(--color-foreground-light); border-color: var(--color-foreground-light);
background-color: var(--color-background-xlight); background-color: var(--color-background-xlight);
padding: var(--spacing-5xs) var(--spacing-3xs); padding: var(--spacing-5xs) var(--spacing-3xs);
margin: 0 var(--spacing-4xs);
border-radius: var(--border-radius-base); border-radius: var(--border-radius-base);
} }
.highlight { .highlight {
color: var(--color-primary); color: var(--color-primary);
background-color: var(--color-primary-tint-3); background-color: var(--color-primary-tint-3);
border-color: var(--color-primary-tint-1); border-color: var(--color-primary-tint-1);
}
} }
</style> </style>

View file

@ -0,0 +1,128 @@
import { renderComponent } from '@/__tests__/render';
import InlineExpressionTip from '@/components/InlineExpressionEditor/InlineExpressionTip.vue';
import { FIELDS_SECTION } from '@/plugins/codemirror/completions/constants';
import type { useNDVStore } from '@/stores/ndv.store';
import type { CompletionResult } from '@codemirror/autocomplete';
import { EditorSelection, EditorState } from '@codemirror/state';
import { createTestingPinia } from '@pinia/testing';
import { waitFor } from '@testing-library/vue';
let mockNdvState: Partial<ReturnType<typeof useNDVStore>>;
let mockCompletionResult: Partial<CompletionResult>;
vi.mock('@/stores/ndv.store', () => {
return {
useNDVStore: vi.fn(() => mockNdvState),
};
});
vi.mock('@/plugins/codemirror/completions/datatype.completions', () => {
return {
datatypeCompletions: vi.fn(() => mockCompletionResult),
};
});
describe('InlineExpressionTip.vue', () => {
beforeEach(() => {
mockNdvState = {
hasInputData: true,
isNDVDataEmpty: vi.fn(() => true),
};
});
test('should show the default tip', async () => {
const { container } = renderComponent(InlineExpressionTip, {
pinia: createTestingPinia(),
});
expect(container).toHaveTextContent('Tip: Anything inside {{ }} is JavaScript. Learn more');
});
describe('When the NDV input is not empty and a mappable input is focused', () => {
test('should show the drag-n-drop tip', async () => {
mockNdvState = {
hasInputData: true,
isNDVDataEmpty: vi.fn(() => false),
focusedMappableInput: 'Some Input',
};
const { container } = renderComponent(InlineExpressionTip, {
pinia: createTestingPinia(),
});
expect(container).toHaveTextContent('Tip: Drag aninput fieldfrom the left to use it here.');
});
});
describe('When the node has no input data', () => {
test('should show the execute previous nodes tip', async () => {
mockNdvState = {
hasInputData: false,
isInputParentOfActiveNode: true,
isNDVDataEmpty: vi.fn(() => false),
focusedMappableInput: 'Some Input',
};
const { container } = renderComponent(InlineExpressionTip, {
pinia: createTestingPinia(),
});
expect(container).toHaveTextContent('Tip: Execute previous nodes to use input data');
});
});
describe('When the expression can be autocompleted with a dot', () => {
test('should show the correct tip for objects', async () => {
mockNdvState = {
hasInputData: true,
isNDVDataEmpty: vi.fn(() => false),
focusedMappableInput: 'Some Input',
setHighlightDraggables: vi.fn(),
};
mockCompletionResult = { options: [{ label: 'foo', section: FIELDS_SECTION }] };
const selection = EditorSelection.cursor(8);
const expression = '{{ $json }}';
const { rerender, container } = renderComponent(InlineExpressionTip, {
pinia: createTestingPinia(),
});
await rerender({
editorState: EditorState.create({
doc: expression,
selection: EditorSelection.create([selection]),
}),
selection,
unresolvedExpression: expression,
});
await waitFor(() =>
expect(container).toHaveTextContent(
'Tip: Type . for data transformation options, or to access fields. Learn more',
),
);
});
test('should show the correct tip for primitives', async () => {
mockNdvState = {
hasInputData: true,
isNDVDataEmpty: vi.fn(() => false),
focusedMappableInput: 'Some Input',
setHighlightDraggables: vi.fn(),
};
mockCompletionResult = { options: [{ label: 'foo' }] };
const selection = EditorSelection.cursor(12);
const expression = '{{ $json.foo }}';
const { rerender, container } = renderComponent(InlineExpressionTip, {
pinia: createTestingPinia(),
});
await rerender({
editorState: EditorState.create({
doc: expression,
selection: EditorSelection.create([selection]),
}),
selection,
unresolvedExpression: expression,
});
await waitFor(() =>
expect(container).toHaveTextContent(
'Tip: Type . for data transformation options. Learn more',
),
);
});
});
});

View file

@ -230,7 +230,7 @@ export default defineComponent({
async onClick() { async onClick() {
// Show chat if it's a chat node or a child of a chat node with no input data // Show chat if it's a chat node or a child of a chat node with no input data
if (this.isChatNode || (this.isChatChild && this.ndvStore.isDNVDataEmpty('input'))) { if (this.isChatNode || (this.isChatChild && this.ndvStore.isNDVDataEmpty('input'))) {
this.ndvStore.setActiveNodeName(null); this.ndvStore.setActiveNodeName(null);
nodeViewEventBus.emit('openChat'); nodeViewEventBus.emit('openChat');
} else if (this.isListeningForEvents) { } else if (this.isListeningForEvents) {

View file

@ -55,7 +55,7 @@
</template> </template>
</DraggableTarget> </DraggableTarget>
<div v-if="showDragnDropTip" :class="$style.tip"> <div v-if="showDragnDropTip" :class="$style.tip">
<InlineExpressionTip tip="drag" /> <InlineExpressionTip />
</div> </div>
<div <div
:class="{ :class="{
@ -209,7 +209,7 @@ export default defineComponent({
return this.isResourceLocator ? !hasOnlyListMode(this.parameter) : true; return this.isResourceLocator ? !hasOnlyListMode(this.parameter) : true;
}, },
isInputDataEmpty(): boolean { isInputDataEmpty(): boolean {
return this.ndvStore.isDNVDataEmpty('input'); return this.ndvStore.isNDVDataEmpty('input');
}, },
displayMode(): IRunDataDisplayMode { displayMode(): IRunDataDisplayMode {
return this.ndvStore.inputPanelDisplayMode; return this.ndvStore.inputPanelDisplayMode;
@ -220,7 +220,9 @@ export default defineComponent({
(this.isInputTypeString || this.isInputTypeNumber) && (this.isInputTypeString || this.isInputTypeNumber) &&
!this.isValueExpression && !this.isValueExpression &&
!this.isDropDisabled && !this.isDropDisabled &&
!this.ndvStore.isMappingOnboarded (!this.ndvStore.hasInputData || !this.isInputDataEmpty) &&
!this.ndvStore.isMappingOnboarded &&
this.ndvStore.isInputParentOfActiveNode
); );
}, },
}, },

View file

@ -158,7 +158,7 @@ export default defineComponent({
return executionDataToJson(this.inputData); return executionDataToJson(this.inputData);
}, },
highlight(): boolean { highlight(): boolean {
return !this.ndvStore.isMappingOnboarded && Boolean(this.ndvStore.focusedMappableInput); return this.ndvStore.highlightDraggables;
}, },
}, },
methods: { methods: {

View file

@ -35,9 +35,7 @@ const schema = computed(() => getSchemaForExecutionData(props.data));
const isDataEmpty = computed(() => isEmpty(props.data)); const isDataEmpty = computed(() => isEmpty(props.data));
const highlight = computed(() => { const highlight = computed(() => ndvStore.highlightDraggables);
return !ndvStore.isMappingOnboarded && Boolean(ndvStore.focusedMappableInput);
});
const onDragStart = (el: HTMLElement) => { const onDragStart = (el: HTMLElement) => {
if (el?.dataset?.path) { if (el?.dataset?.path) {

View file

@ -260,7 +260,7 @@ export default defineComponent({
return this.ndvStore.focusedMappableInput; return this.ndvStore.focusedMappableInput;
}, },
highlight(): boolean { highlight(): boolean {
return !this.ndvStore.isMappingOnboarded && Boolean(this.ndvStore.focusedMappableInput); return this.ndvStore.highlightDraggables;
}, },
}, },
methods: { methods: {

View file

@ -25,7 +25,13 @@ import {
isEmptyExpression, isEmptyExpression,
} from '@/utils/expressions'; } from '@/utils/expressions';
import { completionStatus } from '@codemirror/autocomplete'; import { completionStatus } from '@codemirror/autocomplete';
import { Compartment, EditorState, type Extension } from '@codemirror/state'; import {
Compartment,
EditorState,
type SelectionRange,
type Extension,
EditorSelection,
} from '@codemirror/state';
import { EditorView, type ViewUpdate } from '@codemirror/view'; import { EditorView, type ViewUpdate } from '@codemirror/view';
import { debounce, isEqual } from 'lodash-es'; import { debounce, isEqual } from 'lodash-es';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
@ -59,6 +65,7 @@ export const useExpressionEditor = ({
const editor = ref<EditorView>(); const editor = ref<EditorView>();
const hasFocus = ref(false); const hasFocus = ref(false);
const segments = ref<Segment[]>([]); const segments = ref<Segment[]>([]);
const selection = ref<SelectionRange>(EditorSelection.cursor(0)) as Ref<SelectionRange>;
const customExtensions = ref<Compartment>(new Compartment()); const customExtensions = ref<Compartment>(new Compartment());
const readOnlyExtensions = ref<Compartment>(new Compartment()); const readOnlyExtensions = ref<Compartment>(new Compartment());
const telemetryExtensions = ref<Compartment>(new Compartment()); const telemetryExtensions = ref<Compartment>(new Compartment());
@ -108,7 +115,7 @@ export const useExpressionEditor = ({
// For some reason, expressions that resolve to a number 0 are breaking preview in the SQL editor // For some reason, expressions that resolve to a number 0 are breaking preview in the SQL editor
// This fixes that but as as TODO we should figure out why this is happening // This fixes that but as as TODO we should figure out why this is happening
resolved: String(resolved), resolved: String(resolved),
state: getResolvableState(fullError ?? error, completionStatus !== null), state: getResolvableState(fullError ?? error, autocompleteStatus.value !== null),
error: fullError, error: fullError,
}); });
@ -131,11 +138,22 @@ export const useExpressionEditor = ({
highlighter.addColor(editor.value, resolvableSegments.value); highlighter.addColor(editor.value, resolvableSegments.value);
} }
const debouncedUpdateSegments = debounce(updateSegments, 200); function updateSelection(viewUpdate: ViewUpdate) {
function onEditorUpdate(viewUpdate: ViewUpdate) { const currentSelection = selection.value;
if (!viewUpdate.docChanged || !editor.value) return; const newSelection = viewUpdate.state.selection.ranges[0];
if (!currentSelection?.eq(newSelection)) {
selection.value = newSelection;
}
}
const debouncedUpdateSegments = debounce(updateSegments, 200);
function onEditorUpdate(viewUpdate: ViewUpdate) {
autocompleteStatus.value = completionStatus(viewUpdate.view.state); autocompleteStatus.value = completionStatus(viewUpdate.view.state);
updateSelection(viewUpdate);
if (!viewUpdate.docChanged) return;
debouncedUpdateSegments(); debouncedUpdateSegments();
} }
@ -157,6 +175,7 @@ export const useExpressionEditor = ({
EditorView.updateListener.of(onEditorUpdate), EditorView.updateListener.of(onEditorUpdate),
EditorView.focusChangeEffect.of((_, newHasFocus) => { EditorView.focusChangeEffect.of((_, newHasFocus) => {
hasFocus.value = newHasFocus; hasFocus.value = newHasFocus;
selection.value = state.selection.ranges[0];
return null; return null;
}), }),
EditorView.contentAttributes.of({ 'data-gramm': 'false' }), // disable grammarly EditorView.contentAttributes.of({ 'data-gramm': 'false' }), // disable grammarly
@ -389,6 +408,7 @@ export const useExpressionEditor = ({
return { return {
editor, editor,
hasFocus, hasFocus,
selection,
segments: { segments: {
all: segments, all: segments,
html: htmlSegments, html: htmlSegments,

View file

@ -902,7 +902,7 @@ const regexes = {
singleQuoteStringLiteral: /('.+')\.([^'{\s])*/, // 'abc'. singleQuoteStringLiteral: /('.+')\.([^'{\s])*/, // 'abc'.
doubleQuoteStringLiteral: /(".+")\.([^"{\s])*/, // "abc". doubleQuoteStringLiteral: /(".+")\.([^"{\s])*/, // "abc".
dateLiteral: /\(?new Date\(\(?.*?\)\)?\.(.*)/, // new Date(). or (new Date()). dateLiteral: /\(?new Date\(\(?.*?\)\)?\.(.*)/, // new Date(). or (new Date()).
arrayLiteral: /(\[.*\])\.(.*)/, // [1, 2, 3]. arrayLiteral: /\(?(\[.*\])\)?\.(.*)/, // [1, 2, 3].
indexedAccess: /([^"{\s]+\[.+\])\.(.*)/, // 'abc'[0]. or 'abc'.split('')[0] or similar ones indexedAccess: /([^"{\s]+\[.+\])\.(.*)/, // 'abc'[0]. or 'abc'.split('')[0] or similar ones
objectLiteral: /\(\{.*\}\)\.(.*)/, // ({}). objectLiteral: /\(\{.*\}\)\.(.*)/, // ({}).

View file

@ -237,3 +237,9 @@ export const withSectionHeader = (section: CompletionSection): CompletionSection
section.header = renderSectionHeader; section.header = renderSectionHeader;
return section; return section;
}; };
export const isCompletionSection = (
section: CompletionSection | string | undefined,
): section is CompletionSection => {
return typeof section === 'object';
};

View file

@ -688,6 +688,9 @@
"expressionModalInput.undefined": "[undefined]", "expressionModalInput.undefined": "[undefined]",
"expressionModalInput.null": "null", "expressionModalInput.null": "null",
"expressionTip.noExecutionData": "Execute previous nodes to use input data", "expressionTip.noExecutionData": "Execute previous nodes to use input data",
"expressionTip.typeDotPrimitive": "Type <code>.</code> for data transformation options. <a target=\"_blank\" href=\"https://docs.n8n.io/code/builtin/data-transformation-functions/\">Learn more</a>",
"expressionTip.typeDotObject": "Type <code>.</code> for data transformation options, or to access fields. <a target=\"_blank\" href=\"https://docs.n8n.io/code/builtin/data-transformation-functions/\">Learn more</a>",
"expressionTip.javascript": "Anything inside <code>{'{{ }}'}</code> is JavaScript. <a target=\"_blank\" href=\"https://docs.n8n.io/code-examples/expressions/\">Learn more</a>",
"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",
@ -1219,8 +1222,6 @@
"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.tip": "Tip",
"parameterInput.anythingInside": "Anything inside ",
"parameterInput.isJavaScript": " is JavaScript.",
"parameterInput.dragTipBeforePill": "Drag an", "parameterInput.dragTipBeforePill": "Drag an",
"parameterInput.inputField": "input field", "parameterInput.inputField": "input field",
"parameterInput.dragTipAfterPill": "from the left to use it here.", "parameterInput.dragTipAfterPill": "from the left to use it here.",

View file

@ -56,6 +56,7 @@ export const useNDVStore = defineStore(STORES.NDV, {
}, },
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', isAutocompleteOnboarded: useStorage(LOCAL_STORAGE_AUTOCOMPLETE_IS_ONBOARDED).value === 'true',
highlightDraggables: false,
}), }),
getters: { getters: {
activeNode(): INodeUi | null { activeNode(): INodeUi | null {
@ -129,7 +130,7 @@ export const useNDVStore = defineStore(STORES.NDV, {
ndvInputBranchIndex(): number | undefined { ndvInputBranchIndex(): number | undefined {
return this.input.branch; return this.input.branch;
}, },
isDNVDataEmpty() { isNDVDataEmpty() {
return (panel: 'input' | 'output'): boolean => this[panel].data.isEmpty; return (panel: 'input' | 'output'): boolean => this[panel].data.isEmpty;
}, },
isInputParentOfActiveNode(): boolean { isInputParentOfActiveNode(): boolean {
@ -252,6 +253,9 @@ export const useNDVStore = defineStore(STORES.NDV, {
this.isAutocompleteOnboarded = true; this.isAutocompleteOnboarded = true;
useStorage(LOCAL_STORAGE_AUTOCOMPLETE_IS_ONBOARDED).value = 'true'; useStorage(LOCAL_STORAGE_AUTOCOMPLETE_IS_ONBOARDED).value = 'true';
}, },
setHighlightDraggables(highlight: boolean) {
this.highlightDraggables = highlight;
},
updateNodeParameterIssues(issues: INodeIssues): void { updateNodeParameterIssues(issues: INodeIssues): void {
const workflowsStore = useWorkflowsStore(); const workflowsStore = useWorkflowsStore();
const activeNode = workflowsStore.getNodeByName(this.activeNodeName || ''); const activeNode = workflowsStore.getNodeByName(this.activeNodeName || '');