mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-24 04:04:06 -08:00
feat(editor): Show tip when user can type dot after an expression (#8931)
This commit is contained in:
parent
372d5c7d01
commit
160dfd383d
|
@ -255,6 +255,7 @@ describe('Data mapping', () => {
|
|||
ndv.actions.typeIntoParameterInput('value', 'delete me');
|
||||
|
||||
ndv.actions.typeIntoParameterInput('name', 'test');
|
||||
ndv.getters.parameterInput('name').find('input').blur();
|
||||
|
||||
ndv.actions.typeIntoParameterInput('value', 'fun');
|
||||
ndv.actions.clearParameterInput('value'); // keep focus on param
|
||||
|
|
|
@ -1247,6 +1247,7 @@ export interface NDVState {
|
|||
};
|
||||
isMappingOnboarded: boolean;
|
||||
isAutocompleteOnboarded: boolean;
|
||||
highlightDraggables: boolean;
|
||||
}
|
||||
|
||||
export interface NotificationOptions extends Partial<ElementNotificationOptions> {
|
||||
|
|
|
@ -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>
|
||||
<div
|
||||
v-on-click-outside="onBlur"
|
||||
|
@ -24,7 +145,8 @@
|
|||
:event-bus="eventBus"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@change="onChange"
|
||||
@update:model-value="onValueChange"
|
||||
@update:selection="onSelectionChange"
|
||||
/>
|
||||
<n8n-button
|
||||
v-if="!isDragging"
|
||||
|
@ -35,10 +157,13 @@
|
|||
size="xsmall"
|
||||
:class="$style['expression-editor-modal-opener']"
|
||||
data-test-id="expander"
|
||||
@click="$emit('modal-opener-click')"
|
||||
@click="emit('modal-opener-click')"
|
||||
/>
|
||||
</div>
|
||||
<InlineExpressionEditorOutput
|
||||
:unresolved-expression="modelValue"
|
||||
:selection="selection"
|
||||
:editor-state="editorState"
|
||||
:segments="segments"
|
||||
:is-read-only="isReadOnly"
|
||||
:no-input-data="noInputData"
|
||||
|
@ -48,137 +173,6 @@
|
|||
</div>
|
||||
</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>
|
||||
.expression-parameter-input {
|
||||
position: relative;
|
||||
|
|
|
@ -1,11 +1,7 @@
|
|||
<template>
|
||||
<div ref="root" :class="$style.editor" data-test-id="inline-expression-editor-input"></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { startCompletion } from '@codemirror/autocomplete';
|
||||
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 { computed, nextTick, onBeforeUnmount, onMounted, ref, toValue, watch } from 'vue';
|
||||
|
||||
|
@ -42,7 +38,8 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
});
|
||||
|
||||
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;
|
||||
}>();
|
||||
|
||||
|
@ -64,6 +61,7 @@ const editorValue = ref<string>(removeExpressionPrefix(props.modelValue));
|
|||
const {
|
||||
editor: editorRef,
|
||||
segments,
|
||||
selection,
|
||||
readEditorValue,
|
||||
setCursorPosition,
|
||||
hasFocus,
|
||||
|
@ -79,22 +77,27 @@ const {
|
|||
|
||||
defineExpose({
|
||||
focus: () => {
|
||||
if (!hasFocus.value) {
|
||||
setCursorPosition('lastExpression');
|
||||
focus();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
async function onDrop() {
|
||||
await nextTick();
|
||||
|
||||
const editor = toValue(editorRef);
|
||||
if (!editor) return;
|
||||
|
||||
await nextTick();
|
||||
focus();
|
||||
|
||||
setCursorPosition('lastExpression');
|
||||
|
||||
if (!ndvStore.isAutocompleteOnboarded) {
|
||||
setTimeout(() => {
|
||||
startCompletion(editor);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -106,12 +109,21 @@ watch(
|
|||
);
|
||||
|
||||
watch(segments.display, (newSegments) => {
|
||||
emit('change', {
|
||||
emit('update:model-value', {
|
||||
value: '=' + readEditorValue(),
|
||||
segments: newSegments,
|
||||
});
|
||||
});
|
||||
|
||||
watch(selection, (newSelection: SelectionRange) => {
|
||||
if (editorRef.value) {
|
||||
emit('update:selection', {
|
||||
state: editorRef.value.state,
|
||||
selection: newSelection,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
watch(hasFocus, (focused) => {
|
||||
if (focused) emit('focus');
|
||||
});
|
||||
|
@ -125,6 +137,10 @@ onBeforeUnmount(() => {
|
|||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" :class="$style.editor" data-test-id="inline-expression-editor-input"></div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.editor div[contenteditable='false'] {
|
||||
background-color: var(--disabled-fill, var(--color-background-light));
|
||||
|
|
|
@ -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">
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { EditorState, type SelectionRange } from '@codemirror/state';
|
||||
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import type { Plaintext, Resolved, Segment } from '@/types/expressions';
|
||||
|
@ -25,7 +11,10 @@ import InlineExpressionTip from './InlineExpressionTip.vue';
|
|||
|
||||
interface InlineExpressionEditorOutputProps {
|
||||
segments: Segment[];
|
||||
unresolvedExpression: string;
|
||||
hoveringItemNumber: number;
|
||||
editorState?: EditorState;
|
||||
selection?: SelectionRange;
|
||||
isReadOnly?: boolean;
|
||||
visible?: boolean;
|
||||
noInputData?: boolean;
|
||||
|
@ -35,6 +24,8 @@ const props = withDefaults(defineProps<InlineExpressionEditorOutputProps>(), {
|
|||
readOnly: false,
|
||||
visible: false,
|
||||
noInputData: false,
|
||||
editorState: undefined,
|
||||
selection: undefined,
|
||||
});
|
||||
|
||||
const i18n = useI18n();
|
||||
|
@ -115,6 +106,24 @@ onBeforeUnmount(() => {
|
|||
});
|
||||
</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>
|
||||
.hidden {
|
||||
display: none;
|
||||
|
|
|
@ -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">
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { computed } from 'vue';
|
||||
import { EXPRESSIONS_DOCS_URL } from '@/constants';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
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 ndvStore = useNDVStore();
|
||||
|
||||
const props = defineProps<{ tip?: 'drag' | 'default' }>();
|
||||
const canAddDotToExpression = ref(false);
|
||||
const resolvedExpressionHasFields = ref(false);
|
||||
|
||||
const tip = computed(() => {
|
||||
if (!ndvStore.hasInputData) {
|
||||
const canDragToFocusedInput = computed(
|
||||
() => !ndvStore.isNDVDataEmpty('input') && ndvStore.focusedMappableInput,
|
||||
);
|
||||
|
||||
const emptyExpression = computed(() => props.unresolvedExpression.trim().length === 0);
|
||||
|
||||
const tip = computed<TipId>(() => {
|
||||
if (!ndvStore.hasInputData && ndvStore.isInputParentOfActiveNode) {
|
||||
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';
|
||||
});
|
||||
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>
|
||||
|
||||
<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>
|
||||
.tip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-4xs);
|
||||
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 {
|
||||
.content {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.tipText {
|
||||
display: inline;
|
||||
color: var(--color-text-dark);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
flex-shrink: 0;
|
||||
.drag .tipText {
|
||||
line-height: 21px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
flex-shrink: 1;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
.text {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
code {
|
||||
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;
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--color-text-dark);
|
||||
|
||||
|
@ -115,13 +173,13 @@ const expressionsDocsUrl = EXPRESSIONS_DOCS_URL;
|
|||
border-color: var(--color-foreground-light);
|
||||
background-color: var(--color-background-xlight);
|
||||
padding: var(--spacing-5xs) var(--spacing-3xs);
|
||||
margin: 0 var(--spacing-4xs);
|
||||
border-radius: var(--border-radius-base);
|
||||
}
|
||||
}
|
||||
|
||||
.highlight {
|
||||
.highlight {
|
||||
color: var(--color-primary);
|
||||
background-color: var(--color-primary-tint-3);
|
||||
border-color: var(--color-primary-tint-1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -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',
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -230,7 +230,7 @@ export default defineComponent({
|
|||
|
||||
async onClick() {
|
||||
// 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);
|
||||
nodeViewEventBus.emit('openChat');
|
||||
} else if (this.isListeningForEvents) {
|
||||
|
|
|
@ -55,7 +55,7 @@
|
|||
</template>
|
||||
</DraggableTarget>
|
||||
<div v-if="showDragnDropTip" :class="$style.tip">
|
||||
<InlineExpressionTip tip="drag" />
|
||||
<InlineExpressionTip />
|
||||
</div>
|
||||
<div
|
||||
:class="{
|
||||
|
@ -209,7 +209,7 @@ export default defineComponent({
|
|||
return this.isResourceLocator ? !hasOnlyListMode(this.parameter) : true;
|
||||
},
|
||||
isInputDataEmpty(): boolean {
|
||||
return this.ndvStore.isDNVDataEmpty('input');
|
||||
return this.ndvStore.isNDVDataEmpty('input');
|
||||
},
|
||||
displayMode(): IRunDataDisplayMode {
|
||||
return this.ndvStore.inputPanelDisplayMode;
|
||||
|
@ -220,7 +220,9 @@ export default defineComponent({
|
|||
(this.isInputTypeString || this.isInputTypeNumber) &&
|
||||
!this.isValueExpression &&
|
||||
!this.isDropDisabled &&
|
||||
!this.ndvStore.isMappingOnboarded
|
||||
(!this.ndvStore.hasInputData || !this.isInputDataEmpty) &&
|
||||
!this.ndvStore.isMappingOnboarded &&
|
||||
this.ndvStore.isInputParentOfActiveNode
|
||||
);
|
||||
},
|
||||
},
|
||||
|
|
|
@ -158,7 +158,7 @@ export default defineComponent({
|
|||
return executionDataToJson(this.inputData);
|
||||
},
|
||||
highlight(): boolean {
|
||||
return !this.ndvStore.isMappingOnboarded && Boolean(this.ndvStore.focusedMappableInput);
|
||||
return this.ndvStore.highlightDraggables;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
|
|
|
@ -35,9 +35,7 @@ const schema = computed(() => getSchemaForExecutionData(props.data));
|
|||
|
||||
const isDataEmpty = computed(() => isEmpty(props.data));
|
||||
|
||||
const highlight = computed(() => {
|
||||
return !ndvStore.isMappingOnboarded && Boolean(ndvStore.focusedMappableInput);
|
||||
});
|
||||
const highlight = computed(() => ndvStore.highlightDraggables);
|
||||
|
||||
const onDragStart = (el: HTMLElement) => {
|
||||
if (el?.dataset?.path) {
|
||||
|
|
|
@ -260,7 +260,7 @@ export default defineComponent({
|
|||
return this.ndvStore.focusedMappableInput;
|
||||
},
|
||||
highlight(): boolean {
|
||||
return !this.ndvStore.isMappingOnboarded && Boolean(this.ndvStore.focusedMappableInput);
|
||||
return this.ndvStore.highlightDraggables;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
|
|
|
@ -25,7 +25,13 @@ import {
|
|||
isEmptyExpression,
|
||||
} from '@/utils/expressions';
|
||||
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 { debounce, isEqual } from 'lodash-es';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
@ -59,6 +65,7 @@ export const useExpressionEditor = ({
|
|||
const editor = ref<EditorView>();
|
||||
const hasFocus = ref(false);
|
||||
const segments = ref<Segment[]>([]);
|
||||
const selection = ref<SelectionRange>(EditorSelection.cursor(0)) as Ref<SelectionRange>;
|
||||
const customExtensions = ref<Compartment>(new Compartment());
|
||||
const readOnlyExtensions = 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
|
||||
// This fixes that but as as TODO we should figure out why this is happening
|
||||
resolved: String(resolved),
|
||||
state: getResolvableState(fullError ?? error, completionStatus !== null),
|
||||
state: getResolvableState(fullError ?? error, autocompleteStatus.value !== null),
|
||||
error: fullError,
|
||||
});
|
||||
|
||||
|
@ -131,11 +138,22 @@ export const useExpressionEditor = ({
|
|||
highlighter.addColor(editor.value, resolvableSegments.value);
|
||||
}
|
||||
|
||||
const debouncedUpdateSegments = debounce(updateSegments, 200);
|
||||
function onEditorUpdate(viewUpdate: ViewUpdate) {
|
||||
if (!viewUpdate.docChanged || !editor.value) return;
|
||||
function updateSelection(viewUpdate: ViewUpdate) {
|
||||
const currentSelection = selection.value;
|
||||
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);
|
||||
updateSelection(viewUpdate);
|
||||
|
||||
if (!viewUpdate.docChanged) return;
|
||||
|
||||
debouncedUpdateSegments();
|
||||
}
|
||||
|
@ -157,6 +175,7 @@ export const useExpressionEditor = ({
|
|||
EditorView.updateListener.of(onEditorUpdate),
|
||||
EditorView.focusChangeEffect.of((_, newHasFocus) => {
|
||||
hasFocus.value = newHasFocus;
|
||||
selection.value = state.selection.ranges[0];
|
||||
return null;
|
||||
}),
|
||||
EditorView.contentAttributes.of({ 'data-gramm': 'false' }), // disable grammarly
|
||||
|
@ -389,6 +408,7 @@ export const useExpressionEditor = ({
|
|||
return {
|
||||
editor,
|
||||
hasFocus,
|
||||
selection,
|
||||
segments: {
|
||||
all: segments,
|
||||
html: htmlSegments,
|
||||
|
|
|
@ -902,7 +902,7 @@ const regexes = {
|
|||
singleQuoteStringLiteral: /('.+')\.([^'{\s])*/, // 'abc'.
|
||||
doubleQuoteStringLiteral: /(".+")\.([^"{\s])*/, // "abc".
|
||||
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
|
||||
objectLiteral: /\(\{.*\}\)\.(.*)/, // ({}).
|
||||
|
||||
|
|
|
@ -237,3 +237,9 @@ export const withSectionHeader = (section: CompletionSection): CompletionSection
|
|||
section.header = renderSectionHeader;
|
||||
return section;
|
||||
};
|
||||
|
||||
export const isCompletionSection = (
|
||||
section: CompletionSection | string | undefined,
|
||||
): section is CompletionSection => {
|
||||
return typeof section === 'object';
|
||||
};
|
||||
|
|
|
@ -688,6 +688,9 @@
|
|||
"expressionModalInput.undefined": "[undefined]",
|
||||
"expressionModalInput.null": "null",
|
||||
"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.noNodeExecutionData": "Execute node ‘{node}’ for preview",
|
||||
"expressionModalInput.noInputConnection": "No input connected",
|
||||
|
@ -1219,8 +1222,6 @@
|
|||
"openWorkflow.workflowNotFoundError": "Could not find workflow",
|
||||
"parameterInput.expressionResult": "e.g. {result}",
|
||||
"parameterInput.tip": "Tip",
|
||||
"parameterInput.anythingInside": "Anything inside ",
|
||||
"parameterInput.isJavaScript": " is JavaScript.",
|
||||
"parameterInput.dragTipBeforePill": "Drag an",
|
||||
"parameterInput.inputField": "input field",
|
||||
"parameterInput.dragTipAfterPill": "from the left to use it here.",
|
||||
|
|
|
@ -56,6 +56,7 @@ export const useNDVStore = defineStore(STORES.NDV, {
|
|||
},
|
||||
isMappingOnboarded: useStorage(LOCAL_STORAGE_MAPPING_IS_ONBOARDED).value === 'true',
|
||||
isAutocompleteOnboarded: useStorage(LOCAL_STORAGE_AUTOCOMPLETE_IS_ONBOARDED).value === 'true',
|
||||
highlightDraggables: false,
|
||||
}),
|
||||
getters: {
|
||||
activeNode(): INodeUi | null {
|
||||
|
@ -129,7 +130,7 @@ export const useNDVStore = defineStore(STORES.NDV, {
|
|||
ndvInputBranchIndex(): number | undefined {
|
||||
return this.input.branch;
|
||||
},
|
||||
isDNVDataEmpty() {
|
||||
isNDVDataEmpty() {
|
||||
return (panel: 'input' | 'output'): boolean => this[panel].data.isEmpty;
|
||||
},
|
||||
isInputParentOfActiveNode(): boolean {
|
||||
|
@ -252,6 +253,9 @@ export const useNDVStore = defineStore(STORES.NDV, {
|
|||
this.isAutocompleteOnboarded = true;
|
||||
useStorage(LOCAL_STORAGE_AUTOCOMPLETE_IS_ONBOARDED).value = 'true';
|
||||
},
|
||||
setHighlightDraggables(highlight: boolean) {
|
||||
this.highlightDraggables = highlight;
|
||||
},
|
||||
updateNodeParameterIssues(issues: INodeIssues): void {
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const activeNode = workflowsStore.getNodeByName(this.activeNodeName || '');
|
||||
|
|
Loading…
Reference in a new issue