feat(editor): Show expression infobox on hover and cursor position (#9507)

Co-authored-by: Giulio Andreini <andreini@netseven.it>
This commit is contained in:
Elias Meire
2024-05-28 16:58:44 +02:00
committed by GitHub
parent ac4e0fbb47
commit ec0373f666
14 changed files with 658 additions and 137 deletions

View File

@@ -73,7 +73,6 @@ function useJsonFieldCompletions() {
* - Complete `$input.item.json[` to `['field']`. * - Complete `$input.item.json[` to `['field']`.
*/ */
const inputJsonFieldCompletions = (context: CompletionContext): CompletionResult | null => { const inputJsonFieldCompletions = (context: CompletionContext): CompletionResult | null => {
console.log('🚀 ~ inputJsonFieldCompletions ~ context:', context);
const patterns = { const patterns = {
first: /\$input\.first\(\)\.json(\[|\.).*/, first: /\$input\.first\(\)\.json(\[|\.).*/,
last: /\$input\.last\(\)\.json(\[|\.).*/, last: /\$input\.last\(\)\.json(\[|\.).*/,
@@ -158,7 +157,6 @@ function useJsonFieldCompletions() {
if (name === 'all') { if (name === 'all') {
const regexMatch = preCursor.text.match(regex); const regexMatch = preCursor.text.match(regex);
console.log('🚀 ~ selectorJsonFieldCompletions ~ regexMatch:', regexMatch);
if (!regexMatch?.groups?.index) continue; if (!regexMatch?.groups?.index) continue;
const { index } = regexMatch.groups; const { index } = regexMatch.groups;

View File

@@ -24,6 +24,7 @@ import {
} from '@/plugins/codemirror/keymap'; } from '@/plugins/codemirror/keymap';
import type { Segment } from '@/types/expressions'; import type { Segment } from '@/types/expressions';
import { removeExpressionPrefix } from '@/utils/expressions'; import { removeExpressionPrefix } from '@/utils/expressions';
import { infoBoxTooltips } from '@/plugins/codemirror/tooltips/InfoBoxTooltip';
type Props = { type Props = {
modelValue: string; modelValue: string;
@@ -68,6 +69,7 @@ const extensions = computed(() => [
expressionInputHandler(), expressionInputHandler(),
EditorView.lineWrapping, EditorView.lineWrapping,
EditorView.domEventHandlers({ scroll: forceParse }), EditorView.domEventHandlers({ scroll: forceParse }),
infoBoxTooltips(),
]); ]);
const editorValue = ref<string>(removeExpressionPrefix(props.modelValue)); const editorValue = ref<string>(removeExpressionPrefix(props.modelValue));
const { const {

View File

@@ -20,6 +20,7 @@ import { removeExpressionPrefix } from '@/utils/expressions';
import { createEventBus, type EventBus } from 'n8n-design-system/utils'; import { createEventBus, type EventBus } from 'n8n-design-system/utils';
import type { IDataObject } from 'n8n-workflow'; import type { IDataObject } from 'n8n-workflow';
import { inputTheme } from './theme'; import { inputTheme } from './theme';
import { infoBoxTooltips } from '@/plugins/codemirror/tooltips/InfoBoxTooltip';
type Props = { type Props = {
modelValue: string; modelValue: string;
@@ -56,6 +57,7 @@ const extensions = computed(() => [
history(), history(),
expressionInputHandler(), expressionInputHandler(),
EditorView.lineWrapping, EditorView.lineWrapping,
infoBoxTooltips(),
]); ]);
const editorValue = ref<string>(removeExpressionPrefix(props.modelValue)); const editorValue = ref<string>(removeExpressionPrefix(props.modelValue));
const { const {
@@ -138,7 +140,12 @@ onBeforeUnmount(() => {
</script> </script>
<template> <template>
<div ref="root" :class="$style.editor" data-test-id="inline-expression-editor-input"></div> <div
ref="root"
title=""
:class="$style.editor"
data-test-id="inline-expression-editor-input"
></div>
</template> </template>
<style lang="scss" module> <style lang="scss" module>

View File

@@ -1,13 +1,13 @@
import { import {
computed, computed,
type MaybeRefOrGetter,
onBeforeUnmount, onBeforeUnmount,
onMounted,
ref, ref,
watchEffect,
type Ref,
toValue, toValue,
watch, watch,
onMounted, watchEffect,
type MaybeRefOrGetter,
type Ref,
} from 'vue'; } from 'vue';
import { ensureSyntaxTree } from '@codemirror/language'; import { ensureSyntaxTree } from '@codemirror/language';
@@ -19,6 +19,8 @@ import { useNDVStore } from '@/stores/ndv.store';
import type { TargetItem } from '@/Interface'; import type { TargetItem } from '@/Interface';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers'; import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
import { closeCursorInfoBox } from '@/plugins/codemirror/tooltips/InfoBoxTooltip';
import type { Html, Plaintext, RawSegment, Resolvable, Segment } from '@/types/expressions'; import type { Html, Plaintext, RawSegment, Resolvable, Segment } from '@/types/expressions';
import { import {
getExpressionErrorMessage, getExpressionErrorMessage,
@@ -28,16 +30,15 @@ import {
import { closeCompletion, completionStatus } from '@codemirror/autocomplete'; import { closeCompletion, completionStatus } from '@codemirror/autocomplete';
import { import {
Compartment, Compartment,
EditorState,
type SelectionRange,
type Extension,
EditorSelection, EditorSelection,
EditorState,
type Extension,
type SelectionRange,
} from '@codemirror/state'; } 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';
import { useI18n } from '../composables/useI18n'; import { useI18n } from '../composables/useI18n';
import { highlighter } from '../plugins/codemirror/resolvableHighlighter';
import { useWorkflowsStore } from '../stores/workflows.store'; import { useWorkflowsStore } from '../stores/workflows.store';
import { useAutocompleteTelemetry } from './useAutocompleteTelemetry'; import { useAutocompleteTelemetry } from './useAutocompleteTelemetry';
@@ -163,6 +164,7 @@ export const useExpressionEditor = ({
if (editor.value) { if (editor.value) {
editor.value.contentDOM.blur(); editor.value.contentDOM.blur();
closeCompletion(editor.value); closeCompletion(editor.value);
closeCursorInfoBox(editor.value);
} }
} }

View File

@@ -735,8 +735,8 @@ describe('Resolution-based completions', () => {
const result = completions('{{ $json.obj.| }}'); const result = completions('{{ $json.obj.| }}');
expect(result).toContainEqual(expect.objectContaining({ label: 'str', detail: 'string' })); expect(result).toContainEqual(expect.objectContaining({ label: 'str', detail: 'string' }));
expect(result).toContainEqual(expect.objectContaining({ label: 'empty', detail: 'null' })); expect(result).toContainEqual(expect.objectContaining({ label: 'empty', detail: 'null' }));
expect(result).toContainEqual(expect.objectContaining({ label: 'arr', detail: 'array' })); expect(result).toContainEqual(expect.objectContaining({ label: 'arr', detail: 'Array' }));
expect(result).toContainEqual(expect.objectContaining({ label: 'obj', detail: 'object' })); expect(result).toContainEqual(expect.objectContaining({ label: 'obj', detail: 'Object' }));
}); });
test('should display type information for: {{ $input.item.json.| }}', () => { test('should display type information for: {{ $input.item.json.| }}', () => {
@@ -750,8 +750,8 @@ describe('Resolution-based completions', () => {
const result = completions('{{ $json.item.json.| }}'); const result = completions('{{ $json.item.json.| }}');
expect(result).toContainEqual(expect.objectContaining({ label: 'str', detail: 'string' })); expect(result).toContainEqual(expect.objectContaining({ label: 'str', detail: 'string' }));
expect(result).toContainEqual(expect.objectContaining({ label: 'empty', detail: 'null' })); expect(result).toContainEqual(expect.objectContaining({ label: 'empty', detail: 'null' }));
expect(result).toContainEqual(expect.objectContaining({ label: 'arr', detail: 'array' })); expect(result).toContainEqual(expect.objectContaining({ label: 'arr', detail: 'Array' }));
expect(result).toContainEqual(expect.objectContaining({ label: 'obj', detail: 'object' })); expect(result).toContainEqual(expect.objectContaining({ label: 'obj', detail: 'Object' }));
}); });
test('should display type information for: {{ $("My Node").item.json.| }}', () => { test('should display type information for: {{ $("My Node").item.json.| }}', () => {
@@ -765,8 +765,8 @@ describe('Resolution-based completions', () => {
const result = completions('{{ $("My Node").item.json.| }}'); const result = completions('{{ $("My Node").item.json.| }}');
expect(result).toContainEqual(expect.objectContaining({ label: 'str', detail: 'string' })); expect(result).toContainEqual(expect.objectContaining({ label: 'str', detail: 'string' }));
expect(result).toContainEqual(expect.objectContaining({ label: 'empty', detail: 'null' })); expect(result).toContainEqual(expect.objectContaining({ label: 'empty', detail: 'null' }));
expect(result).toContainEqual(expect.objectContaining({ label: 'arr', detail: 'array' })); expect(result).toContainEqual(expect.objectContaining({ label: 'arr', detail: 'Array' }));
expect(result).toContainEqual(expect.objectContaining({ label: 'obj', detail: 'object' })); expect(result).toContainEqual(expect.objectContaining({ label: 'obj', detail: 'Object' }));
}); });
test('should not display type information for other completions', () => { test('should not display type information for other completions', () => {

View File

@@ -54,7 +54,7 @@ export const ROOT_DOLLAR_COMPLETIONS: Completion[] = [
section: RECOMMENDED_SECTION, section: RECOMMENDED_SECTION,
info: createInfoBoxRenderer({ info: createInfoBoxRenderer({
name: '$json', name: '$json',
returnType: 'object', returnType: 'Object',
description: i18n.baseText('codeNodeEditor.completer.json'), description: i18n.baseText('codeNodeEditor.completer.json'),
docURL: 'https://docs.n8n.io/data/data-structure/', docURL: 'https://docs.n8n.io/data/data-structure/',
}), }),
@@ -64,7 +64,7 @@ export const ROOT_DOLLAR_COMPLETIONS: Completion[] = [
section: RECOMMENDED_SECTION, section: RECOMMENDED_SECTION,
info: createInfoBoxRenderer({ info: createInfoBoxRenderer({
name: '$binary', name: '$binary',
returnType: 'object', returnType: 'Object',
description: i18n.baseText('codeNodeEditor.completer.binary'), description: i18n.baseText('codeNodeEditor.completer.binary'),
}), }),
}, },
@@ -170,7 +170,7 @@ export const ROOT_DOLLAR_COMPLETIONS: Completion[] = [
section: METADATA_SECTION, section: METADATA_SECTION,
info: createInfoBoxRenderer({ info: createInfoBoxRenderer({
name: '$input', name: '$input',
returnType: 'object', returnType: 'Object',
description: i18n.baseText('codeNodeEditor.completer.$input'), description: i18n.baseText('codeNodeEditor.completer.$input'),
}), }),
}, },
@@ -179,7 +179,7 @@ export const ROOT_DOLLAR_COMPLETIONS: Completion[] = [
section: METADATA_SECTION, section: METADATA_SECTION,
info: createInfoBoxRenderer({ info: createInfoBoxRenderer({
name: '$parameter', name: '$parameter',
returnType: 'object', returnType: 'Object',
description: i18n.baseText('codeNodeEditor.completer.$parameter'), description: i18n.baseText('codeNodeEditor.completer.$parameter'),
}), }),
}, },
@@ -215,7 +215,7 @@ export const ROOT_DOLLAR_COMPLETIONS: Completion[] = [
section: METADATA_SECTION, section: METADATA_SECTION,
info: createInfoBoxRenderer({ info: createInfoBoxRenderer({
name: '$vars', name: '$vars',
returnType: 'object', returnType: 'Object',
description: i18n.baseText('codeNodeEditor.completer.$vars'), description: i18n.baseText('codeNodeEditor.completer.$vars'),
}), }),
}, },

View File

@@ -35,10 +35,10 @@ import { luxonInstanceDocs } from './nativesAutocompleteDocs/luxon.instance.docs
import { luxonStaticDocs } from './nativesAutocompleteDocs/luxon.static.docs'; import { luxonStaticDocs } from './nativesAutocompleteDocs/luxon.static.docs';
import type { AutocompleteInput, ExtensionTypeName, FnToDoc, Resolved } from './types'; import type { AutocompleteInput, ExtensionTypeName, FnToDoc, Resolved } from './types';
import { import {
applyBracketAccess,
applyBracketAccessCompletion, applyBracketAccessCompletion,
applyCompletion, applyCompletion,
getDefaultArgs, getDefaultArgs,
getDisplayType,
hasNoParams, hasNoParams,
hasRequiredArgs, hasRequiredArgs,
insertDefaultArgs, insertDefaultArgs,
@@ -181,7 +181,7 @@ export const natives = ({
typeName: ExtensionTypeName; typeName: ExtensionTypeName;
transformLabel?: (label: string) => string; transformLabel?: (label: string) => string;
}): Completion[] => { }): Completion[] => {
const nativeDocs: NativeDoc = NativeMethods.find((ee) => ee.typeName.toLowerCase() === typeName); const nativeDocs = NativeMethods.find((ee) => ee.typeName.toLowerCase() === typeName);
if (!nativeDocs) return []; if (!nativeDocs) return [];
@@ -231,12 +231,6 @@ export const extensions = ({
return toOptions({ fnToDoc, isFunction: true, includeHidden, transformLabel }); return toOptions({ fnToDoc, isFunction: true, includeHidden, transformLabel });
}; };
export const getType = (value: unknown): string => {
if (Array.isArray(value)) return 'array';
if (value === null) return 'null';
return (typeof value).toLocaleLowerCase();
};
export const isInputData = (base: string): boolean => { export const isInputData = (base: string): boolean => {
return ( return (
/^\$input\..*\.json]/.test(base) || /^\$json/.test(base) || /^\$\(.*\)\..*\.json/.test(base) /^\$input\..*\.json]/.test(base) || /^\$json/.test(base) || /^\$\(.*\)\..*\.json/.test(base)
@@ -258,7 +252,7 @@ export const isBinary = (input: AutocompleteInput<IDataObject>): boolean => {
}; };
export const getDetail = (base: string, value: unknown): string | undefined => { export const getDetail = (base: string, value: unknown): string | undefined => {
const type = getType(value); const type = getDisplayType(value);
if (!isInputData(base) || type === 'function') return undefined; if (!isInputData(base) || type === 'function') return undefined;
return type; return type;
}; };
@@ -382,17 +376,6 @@ const objectOptions = (input: AutocompleteInput<IDataObject>): Completion[] => {
detail: getDetail(base, resolvedProp), detail: getDetail(base, resolvedProp),
}; };
const infoName = needsBracketAccess ? applyBracketAccess(key) : key;
option.info = createCompletionOption({
name: infoName,
doc: {
name: infoName,
returnType: isFunction ? 'any' : getType(resolvedProp),
},
isFunction,
transformLabel,
}).info;
return option; return option;
}); });
@@ -821,7 +804,7 @@ export const customDataOptions = () => {
}, },
{ {
name: 'getAll', name: 'getAll',
returnType: 'object', returnType: 'Object',
docURL: 'https://docs.n8n.io/workflows/executions/custom-executions-data/', docURL: 'https://docs.n8n.io/workflows/executions/custom-executions-data/',
description: i18n.baseText('codeNodeEditor.completer.$execution.customData.getAll'), description: i18n.baseText('codeNodeEditor.completer.$execution.customData.getAll'),
examples: [ examples: [
@@ -1046,13 +1029,13 @@ export const itemOptions = () => {
return [ return [
{ {
name: 'json', name: 'json',
returnType: 'object', returnType: 'Object',
docURL: 'https://docs.n8n.io/data/data-structure/', docURL: 'https://docs.n8n.io/data/data-structure/',
description: i18n.baseText('codeNodeEditor.completer.item.json'), description: i18n.baseText('codeNodeEditor.completer.item.json'),
}, },
{ {
name: 'binary', name: 'binary',
returnType: 'object', returnType: 'Object',
docURL: 'https://docs.n8n.io/data/data-structure/', docURL: 'https://docs.n8n.io/data/data-structure/',
description: i18n.baseText('codeNodeEditor.completer.item.binary'), description: i18n.baseText('codeNodeEditor.completer.item.binary'),
}, },
@@ -1161,7 +1144,7 @@ export const secretProvidersOptions = () => {
name: provider, name: provider,
doc: { doc: {
name: provider, name: provider,
returnType: 'object', returnType: 'Object',
description: i18n.baseText('codeNodeEditor.completer.$secrets.provider'), description: i18n.baseText('codeNodeEditor.completer.$secrets.provider'),
docURL: i18n.baseText('settings.externalSecrets.docs'), docURL: i18n.baseText('settings.externalSecrets.docs'),
}, },
@@ -1296,7 +1279,7 @@ export const objectGlobalOptions = () => {
evaluated: "{ id: 1, name: 'Banana' }", evaluated: "{ id: 1, name: 'Banana' }",
}, },
], ],
returnType: 'object', returnType: 'Object',
docURL: docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign', 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign',
}, },

View File

@@ -13,7 +13,12 @@ import {
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import { useExternalSecretsStore } from '@/stores/externalSecrets.ee.store'; import { useExternalSecretsStore } from '@/stores/externalSecrets.ee.store';
import { escapeMappingString } from '@/utils/mappingUtils'; import { escapeMappingString } from '@/utils/mappingUtils';
import { PREVIOUS_NODES_SECTION, RECOMMENDED_SECTION, ROOT_DOLLAR_COMPLETIONS } from './constants'; import {
METADATA_SECTION,
PREVIOUS_NODES_SECTION,
RECOMMENDED_SECTION,
ROOT_DOLLAR_COMPLETIONS,
} from './constants';
import { createInfoBoxRenderer } from './infoBoxRenderer'; import { createInfoBoxRenderer } from './infoBoxRenderer';
/** /**
@@ -79,7 +84,7 @@ export function dollarOptions(): Completion[] {
section: RECOMMENDED_SECTION, section: RECOMMENDED_SECTION,
info: createInfoBoxRenderer({ info: createInfoBoxRenderer({
name: '$request', name: '$request',
returnType: 'object', returnType: 'Object',
docURL: 'https://docs.n8n.io/code/builtin/http-node-variables/', docURL: 'https://docs.n8n.io/code/builtin/http-node-variables/',
description: i18n.baseText('codeNodeEditor.completer.$request'), description: i18n.baseText('codeNodeEditor.completer.$request'),
}), }),
@@ -91,12 +96,22 @@ export function dollarOptions(): Completion[] {
return useExternalSecretsStore().isEnterpriseExternalSecretsEnabled return useExternalSecretsStore().isEnterpriseExternalSecretsEnabled
? [ ? [
{ {
label: '$secrets', label: '$vars',
type: 'keyword', section: METADATA_SECTION,
info: createInfoBoxRenderer({
name: '$vars',
returnType: 'Object',
description: i18n.baseText('codeNodeEditor.completer.$vars'),
}),
}, },
{ {
label: '$vars', label: '$secrets',
type: 'keyword', section: METADATA_SECTION,
info: createInfoBoxRenderer({
name: '$secrets',
returnType: 'Object',
description: i18n.baseText('codeNodeEditor.completer.$secrets'),
}),
}, },
] ]
: []; : [];
@@ -114,7 +129,7 @@ export function dollarOptions(): Completion[] {
label, label,
info: createInfoBoxRenderer({ info: createInfoBoxRenderer({
name: label, name: label,
returnType: 'object', returnType: 'Object',
description: i18n.baseText('codeNodeEditor.completer.$()', { interpolate: { nodeName } }), description: i18n.baseText('codeNodeEditor.completer.$()', { interpolate: { nodeName } }),
}), }),
section: PREVIOUS_NODES_SECTION, section: PREVIOUS_NODES_SECTION,

View File

@@ -1,8 +1,21 @@
import type { Completion } from '@codemirror/autocomplete';
import type { DocMetadata, DocMetadataArgument, DocMetadataExample } from 'n8n-workflow'; import type { DocMetadata, DocMetadataArgument, DocMetadataExample } from 'n8n-workflow';
import { sanitizeHtml } from '@/utils/htmlUtils'; import { sanitizeHtml } from '@/utils/htmlUtils';
import { i18n } from '@/plugins/i18n'; import { i18n } from '@/plugins/i18n';
const renderFunctionHeader = (doc?: DocMetadata) => { const shouldHighlightArgument = (
arg: DocMetadataArgument,
index: number,
highlightArgIndex?: number,
) => {
if (arg.variadic) {
return (highlightArgIndex ?? 0) >= index;
}
return highlightArgIndex === index;
};
const renderFunctionHeader = (doc?: DocMetadata, highlightArgIndex?: number) => {
const header = document.createElement('div'); const header = document.createElement('div');
if (doc) { if (doc) {
const functionNameSpan = document.createElement('span'); const functionNameSpan = document.createElement('span');
@@ -17,7 +30,10 @@ const renderFunctionHeader = (doc?: DocMetadata) => {
const argsSpan = document.createElement('span'); const argsSpan = document.createElement('span');
doc.args?.forEach((arg, index, array) => { doc.args?.forEach((arg, index, array) => {
const optional = arg.optional && !arg.name.endsWith('?'); const optional = arg.optional && !arg.name.endsWith('?');
const argSpan = document.createElement('span'); const argSpan = document.createElement(
shouldHighlightArgument(arg, index, highlightArgIndex) ? 'strong' : 'span',
);
argSpan.classList.add('autocomplete-info-arg');
argSpan.textContent = arg.name; argSpan.textContent = arg.name;
if (optional) { if (optional) {
@@ -28,27 +44,19 @@ const renderFunctionHeader = (doc?: DocMetadata) => {
argSpan.textContent = '...' + argSpan.textContent; argSpan.textContent = '...' + argSpan.textContent;
} }
argSpan.classList.add('autocomplete-info-arg');
argsSpan.appendChild(argSpan); argsSpan.appendChild(argSpan);
if (index !== array.length - 1) { if (index !== array.length - 1) {
const separatorSpan = document.createElement('span'); const separatorSpan = document.createElement('span');
separatorSpan.textContent = ', '; separatorSpan.textContent = ', ';
argsSpan.appendChild(separatorSpan); argsSpan.appendChild(separatorSpan);
} else {
argSpan.textContent += ')';
} }
}); });
header.appendChild(argsSpan); header.appendChild(argsSpan);
const preTypeInfo = document.createElement('span'); const closingBracket = document.createElement('span');
preTypeInfo.textContent = !doc.args || doc.args.length === 0 ? '): ' : ': '; closingBracket.textContent = ')';
header.appendChild(preTypeInfo); header.appendChild(closingBracket);
const returnTypeSpan = document.createElement('span');
returnTypeSpan.textContent = doc.returnType;
returnTypeSpan.classList.add('autocomplete-info-return');
header.appendChild(returnTypeSpan);
} }
return header; return header;
}; };
@@ -58,13 +66,9 @@ const renderPropHeader = (doc?: DocMetadata) => {
if (doc) { if (doc) {
const propNameSpan = document.createElement('span'); const propNameSpan = document.createElement('span');
propNameSpan.classList.add('autocomplete-info-name'); propNameSpan.classList.add('autocomplete-info-name');
propNameSpan.innerText = doc.name; propNameSpan.textContent = doc.name;
const returnTypeSpan = document.createElement('span');
returnTypeSpan.textContent = ': ' + doc.returnType;
header.appendChild(propNameSpan); header.appendChild(propNameSpan);
header.appendChild(returnTypeSpan);
} }
return header; return header;
}; };
@@ -110,9 +114,9 @@ const renderDescription = ({
return descriptionBody; return descriptionBody;
}; };
const renderArg = (arg: DocMetadataArgument) => { const renderArg = (arg: DocMetadataArgument, highlight: boolean) => {
const argItem = document.createElement('li'); const argItem = document.createElement('li');
const argName = document.createElement('span'); const argName = document.createElement(highlight ? 'strong' : 'span');
argName.classList.add('autocomplete-info-arg-name'); argName.classList.add('autocomplete-info-arg-name');
argName.textContent = arg.name.replaceAll('?', ''); argName.textContent = arg.name.replaceAll('?', '');
const tags = []; const tags = [];
@@ -159,18 +163,18 @@ const renderArg = (arg: DocMetadataArgument) => {
return argItem; return argItem;
}; };
const renderArgList = (args: DocMetadataArgument[]) => { const renderArgList = (args: DocMetadataArgument[], highlightArgIndex?: number) => {
const argsList = document.createElement('ul'); const argsList = document.createElement('ul');
argsList.classList.add('autocomplete-info-args'); argsList.classList.add('autocomplete-info-args');
for (const arg of args) { args.forEach((arg, index) => {
argsList.appendChild(renderArg(arg)); argsList.appendChild(renderArg(arg, shouldHighlightArgument(arg, index, highlightArgIndex)));
} });
return argsList; return argsList;
}; };
const renderArgs = (args: DocMetadataArgument[]) => { const renderArgs = (args: DocMetadataArgument[], highlightArgIndex?: number) => {
const argsContainer = document.createElement('div'); const argsContainer = document.createElement('div');
argsContainer.classList.add('autocomplete-info-args-container'); argsContainer.classList.add('autocomplete-info-args-container');
@@ -178,7 +182,7 @@ const renderArgs = (args: DocMetadataArgument[]) => {
argsTitle.classList.add('autocomplete-info-section-title'); argsTitle.classList.add('autocomplete-info-section-title');
argsTitle.textContent = i18n.baseText('codeNodeEditor.parameters'); argsTitle.textContent = i18n.baseText('codeNodeEditor.parameters');
argsContainer.appendChild(argsTitle); argsContainer.appendChild(argsTitle);
argsContainer.appendChild(renderArgList(args)); argsContainer.appendChild(renderArgList(args, highlightArgIndex));
return argsContainer; return argsContainer;
}; };
@@ -233,7 +237,7 @@ const renderExamples = (examples: DocMetadataExample[]) => {
export const createInfoBoxRenderer = export const createInfoBoxRenderer =
(doc?: DocMetadata, isFunction = false) => (doc?: DocMetadata, isFunction = false) =>
() => { (_completion: Completion, highlightArgIndex = -1) => {
const tooltipContainer = document.createElement('div'); const tooltipContainer = document.createElement('div');
tooltipContainer.setAttribute('tabindex', '-1'); tooltipContainer.setAttribute('tabindex', '-1');
tooltipContainer.setAttribute('title', ''); tooltipContainer.setAttribute('title', '');
@@ -245,7 +249,9 @@ export const createInfoBoxRenderer =
const hasArgs = args && args.length > 0; const hasArgs = args && args.length > 0;
const hasExamples = examples && examples.length > 0; const hasExamples = examples && examples.length > 0;
const header = isFunction ? renderFunctionHeader(doc) : renderPropHeader(doc); const header = isFunction
? renderFunctionHeader(doc, highlightArgIndex)
: renderPropHeader(doc);
header.classList.add('autocomplete-info-header'); header.classList.add('autocomplete-info-header');
tooltipContainer.appendChild(header); tooltipContainer.appendChild(header);
@@ -259,7 +265,7 @@ export const createInfoBoxRenderer =
} }
if (hasArgs) { if (hasArgs) {
const argsContainer = renderArgs(args); const argsContainer = renderArgs(args, highlightArgIndex);
tooltipContainer.appendChild(argsContainer); tooltipContainer.appendChild(argsContainer);
} }

View File

@@ -258,3 +258,15 @@ export const isCompletionSection = (
): section is CompletionSection => { ): section is CompletionSection => {
return typeof section === 'object'; return typeof section === 'object';
}; };
export const getDisplayType = (value: unknown): string => {
if (Array.isArray(value)) {
if (value.length > 0) {
return `${getDisplayType(value[0])}[]`;
}
return 'Array';
}
if (value === null) return 'null';
if (typeof value === 'object') return 'Object';
return (typeof value).toLocaleLowerCase();
};

View File

@@ -0,0 +1,317 @@
import {
CompletionContext,
completionStatus,
type Completion,
type CompletionInfo,
type CompletionResult,
} from '@codemirror/autocomplete';
import { javascriptLanguage } from '@codemirror/lang-javascript';
import { syntaxTree } from '@codemirror/language';
import { StateEffect, StateField, type EditorState, type Extension } from '@codemirror/state';
import {
hoverTooltip,
keymap,
showTooltip,
type Command,
type EditorView,
type Tooltip,
} from '@codemirror/view';
import type { SyntaxNode } from '@lezer/common';
import type { createInfoBoxRenderer } from '../completions/infoBoxRenderer';
const findNearestParentOfType =
(type: string) =>
(node: SyntaxNode): SyntaxNode | null => {
if (node.name === type) {
return node;
}
if (node.parent) {
return findNearestParentOfType(type)(node.parent);
}
return null;
};
const findNearestArgList = findNearestParentOfType('ArgList');
const findNearestCallExpression = findNearestParentOfType('CallExpression');
function completionToTooltip(
completion: Completion | null,
pos: number,
options: { argIndex?: number; end?: number } = {},
): Tooltip | null {
if (!completion) return null;
return {
pos,
end: options.end,
above: true,
create: () => {
const element = document.createElement('div');
element.classList.add('cm-cursorInfo');
const info = completion.info;
if (typeof info === 'string') {
element.textContent = info;
} else if (isInfoBoxRenderer(info)) {
const infoResult = info(completion, options.argIndex ?? -1);
if (infoResult) {
element.appendChild(infoResult);
}
}
return { dom: element };
},
};
}
function findActiveArgIndex(node: SyntaxNode, index: number) {
let currentIndex = 1;
let argIndex = 0;
let child: SyntaxNode | null = null;
do {
child = node.childAfter(currentIndex);
if (child) {
currentIndex = child.to;
if (index >= child.from && index <= child.to) {
return argIndex;
}
if (child.name !== ',' && child.name !== '(') argIndex++;
}
} while (child);
return -1;
}
const createStateReader = (state: EditorState) => (node?: SyntaxNode | null) => {
return node ? state.sliceDoc(node.from, node.to) : '';
};
const createStringReader = (str: string) => (node?: SyntaxNode | null) => {
return node ? str.slice(node.from, node.to) : '';
};
function getJsNodeAtPosition(state: EditorState, pos: number, anchor?: number) {
// Syntax node in the n8n language (Resolvable | Plaintext)
const rootNode = syntaxTree(state).resolveInner(pos, -1);
if (rootNode.name !== 'Resolvable') {
return null;
}
const read = createStateReader(state);
const resolvable = read(rootNode);
const jsCode = resolvable.replace(/^{{\s*(.*)\s*}}$/, '$1');
const prefixLength = resolvable.indexOf(jsCode);
const jsOffset = rootNode.from + prefixLength;
const jsPos = pos - jsOffset;
const jsAnchor = anchor ? anchor - jsOffset : jsPos;
const getGlobalPosition = (jsPosition: number) => jsPosition + jsOffset;
const isSelectionWithinNode = (n: SyntaxNode) => {
return jsPos >= n.from && jsPos <= n.to && jsAnchor >= n.from && jsAnchor <= n.to;
};
// Cursor or selection is outside of JS code
if (jsPos >= jsCode.length || jsAnchor >= jsCode.length) {
return null;
}
// Syntax node in JavaScript
const jsNode = javascriptLanguage.parser
.parse(jsCode)
.resolveInner(jsPos, typeof anchor === 'number' ? 0 : -1);
return {
node: jsNode,
pos: jsPos,
readNode: createStringReader(jsCode),
isSelectionWithinNode,
getGlobalPosition,
};
}
function getCompletion(
state: EditorState,
pos: number,
filter: (completion: Completion) => boolean,
): Completion | null {
const context = new CompletionContext(state, pos, true);
const sources = state.languageDataAt<(context: CompletionContext) => CompletionResult>(
'autocomplete',
pos,
);
for (const source of sources) {
const result = source(context);
const options = result?.options.filter(filter);
if (options && options.length > 0) {
return options[0];
}
}
return null;
}
const isInfoBoxRenderer = (
info: string | ((completion: Completion) => CompletionInfo | Promise<CompletionInfo>) | undefined,
): info is ReturnType<typeof createInfoBoxRenderer> => {
return typeof info === 'function';
};
function getInfoBoxTooltip(state: EditorState): Tooltip | null {
const { head, anchor } = state.selection.ranges[0];
const jsNodeResult = getJsNodeAtPosition(state, head, anchor);
if (!jsNodeResult) {
return null;
}
const { node, pos, isSelectionWithinNode, getGlobalPosition, readNode } = jsNodeResult;
const argList = findNearestArgList(node);
if (!argList || !isSelectionWithinNode(argList)) {
return null;
}
const callExpression = findNearestCallExpression(argList);
if (!callExpression) {
return null;
}
const argIndex = findActiveArgIndex(argList, pos);
const subject = callExpression?.firstChild;
switch (subject?.name) {
case 'MemberExpression': {
const methodName = readNode(subject.lastChild);
const completion = getCompletion(
state,
getGlobalPosition(subject.to - 1),
(c) => c.label === methodName + '()',
);
return completionToTooltip(completion, head, { argIndex });
}
case 'VariableName': {
const methodName = readNode(subject);
const completion = getCompletion(
state,
getGlobalPosition(subject.to - 1),
(c) => c.label === methodName + '()',
);
return completionToTooltip(completion, head, { argIndex });
}
default:
return null;
}
}
const cursorInfoBoxTooltip = StateField.define<{ tooltip: Tooltip | null }>({
create(state) {
return { tooltip: getInfoBoxTooltip(state) };
},
update(value, tr) {
if (
tr.state.selection.ranges.length !== 1 ||
tr.state.selection.ranges[0].head === 0 ||
completionStatus(tr.state) === 'active'
) {
return { tooltip: null };
}
if (tr.effects.find((effect) => effect.is(closeInfoBoxEffect))) {
return { tooltip: null };
}
if (!tr.docChanged && !tr.selection) return { tooltip: value.tooltip };
return { ...value, tooltip: getInfoBoxTooltip(tr.state) };
},
provide: (f) => showTooltip.compute([f], (state) => state.field(f).tooltip),
});
export const hoverTooltipSource = (view: EditorView, pos: number) => {
const state = view.state.field(cursorInfoBoxTooltip, false);
const cursorTooltipOpen = !!state?.tooltip;
const jsNodeResult = getJsNodeAtPosition(view.state, pos);
if (!jsNodeResult) {
return null;
}
const { node, getGlobalPosition, readNode } = jsNodeResult;
const tooltipForNode = (subject: SyntaxNode) => {
const completion = getCompletion(
view.state,
getGlobalPosition(subject.to - 1),
(c) => c.label === readNode(subject) || c.label === readNode(subject) + '()',
);
const newHoverTooltip = completionToTooltip(completion, getGlobalPosition(subject.from), {
end: getGlobalPosition(subject.to),
});
if (newHoverTooltip && cursorTooltipOpen) {
closeCursorInfoBox(view);
}
return newHoverTooltip;
};
switch (node.name) {
case 'VariableName':
case 'PropertyName': {
return tooltipForNode(node);
}
case 'String':
case 'Number':
case 'Boolean':
case 'CallExpression': {
const callExpression = findNearestCallExpression(node);
if (!callExpression) return null;
return tooltipForNode(callExpression);
}
default:
return null;
}
};
const hoverInfoBoxTooltip = hoverTooltip(hoverTooltipSource, {
hideOnChange: true,
hoverTime: 500,
});
const closeInfoBoxEffect = StateEffect.define<null>();
export const closeCursorInfoBox: Command = (view) => {
const state = view.state.field(cursorInfoBoxTooltip, false);
if (!state?.tooltip) return false;
view.dispatch({ effects: closeInfoBoxEffect.of(null) });
return true;
};
export const infoBoxTooltips = (): Extension[] => {
return [
cursorInfoBoxTooltip,
hoverInfoBoxTooltip,
keymap.of([
{
key: 'Escape',
run: closeCursorInfoBox,
},
]),
];
};

View File

@@ -0,0 +1,152 @@
import { EditorState } from '@codemirror/state';
import { EditorView, getTooltip, showTooltip, type Tooltip } from '@codemirror/view';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { n8nLang } from '@/plugins/codemirror/n8nLang';
import { hoverTooltipSource, infoBoxTooltips } from '../InfoBoxTooltip';
import * as utils from '@/plugins/codemirror/completions/utils';
import * as workflowHelpers from '@/composables/useWorkflowHelpers';
describe('Infobox tooltips', () => {
beforeEach(() => {
setActivePinia(createTestingPinia());
vi.spyOn(utils, 'hasActiveNode').mockReturnValue(true);
});
describe('Cursor tooltips', () => {
test('should NOT show a tooltip for: {{ $max(1,2) }} foo|', () => {
const tooltips = cursorTooltips('{{ $max(1,2) }} foo|');
expect(tooltips.length).toBe(0);
});
test('should NOT show a tooltip for: {{ $ma|x() }}', () => {
const tooltips = cursorTooltips('{{ $ma|x() }}');
expect(tooltips.length).toBe(0);
});
test('should show a tooltip for: {{ $max(|) }}', () => {
const tooltips = cursorTooltips('{{ $max(|) }}');
expect(tooltips.length).toBe(1);
expect(infoBoxHeader(tooltips[0].view)).toHaveTextContent('$max(...numbers)');
expect(highlightedArgIndex(tooltips[0].view)).toBe(0);
});
test('should show a tooltip for: {{ $max(1,2,3,|) }}', () => {
const tooltips = cursorTooltips('{{ $max(1, 2|) }}');
expect(tooltips.length).toBe(1);
expect(infoBoxHeader(tooltips[0].view)).toHaveTextContent('$max(...numbers)');
expect(highlightedArgIndex(tooltips[0].view)).toBe(0);
});
test('should NOT show a tooltip for: {{ $json.str|.includes("test") }}', () => {
const tooltips = cursorTooltips('{{ $json.str|.includes("test") }}');
expect(tooltips.length).toBe(0);
});
test('should show a tooltip for: {{ $json.str.includes(|) }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue('a string');
const tooltips = cursorTooltips('{{ $json.str.includes(|) }}');
expect(tooltips.length).toBe(1);
expect(infoBoxHeader(tooltips[0].view)).toHaveTextContent('includes(searchString, start?)');
expect(highlightedArgIndex(tooltips[0].view)).toBe(0);
});
test('should show a tooltip for: {{ $json.str.includes("tes|t") }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue('a string');
const tooltips = cursorTooltips('{{ $json.str.includes("tes|t") }}');
expect(tooltips.length).toBe(1);
expect(infoBoxHeader(tooltips[0].view)).toHaveTextContent('includes(searchString, start?)');
expect(highlightedArgIndex(tooltips[0].view)).toBe(0);
});
test('should show a tooltip for: {{ $json.str.includes("test",|) }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue('a string');
const tooltips = cursorTooltips('{{ $json.str.includes("test",|) }}');
expect(tooltips.length).toBe(1);
expect(infoBoxHeader(tooltips[0].view)).toHaveTextContent('includes(searchString, start?)');
expect(highlightedArgIndex(tooltips[0].view)).toBe(1);
});
});
describe('Hover tooltips', () => {
test('should NOT show a tooltip for: {{ $max(1,2) }} foo|', () => {
const tooltip = hoverTooltip('{{ $max(1,2) }} foo|');
expect(tooltip).toBeNull();
});
test('should show a tooltip for: {{ $jso|n }}', () => {
const tooltip = hoverTooltip('{{ $jso|n }}');
expect(tooltip).not.toBeNull();
expect(infoBoxHeader(tooltip?.view)).toHaveTextContent('$json');
});
test('should show a tooltip for: {{ $execution.mo|de }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue({ mode: 'foo' });
const tooltip = hoverTooltip('{{ $execution.mo|de }}');
expect(tooltip).not.toBeNull();
expect(infoBoxHeader(tooltip?.view)).toHaveTextContent('mode');
});
test('should show a tooltip for: {{ $jmespa|th() }}', () => {
const tooltip = hoverTooltip('{{ $jmespa|th() }}');
expect(tooltip).not.toBeNull();
expect(infoBoxHeader(tooltip?.view)).toHaveTextContent('$jmespath(obj, expression)');
});
test('should show a tooltip for: {{ $json.str.includ|es() }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue('foo');
const tooltip = hoverTooltip('{{ $json.str.includ|es() }}');
expect(tooltip).not.toBeNull();
expect(infoBoxHeader(tooltip?.view)).toHaveTextContent('includes(searchString, start?)');
});
});
});
function highlightedArgIndex(infoBox: HTMLElement | undefined) {
return Array.from(infoBox?.querySelectorAll('.autocomplete-info-arg-name') ?? []).findIndex(
(arg) => arg.localName === 'strong',
);
}
function infoBoxHeader(infoBox: HTMLElement | undefined) {
return infoBox?.querySelector('.autocomplete-info-header');
}
function cursorTooltips(docWithCursor: string) {
const cursorPosition = docWithCursor.indexOf('|');
const doc = docWithCursor.slice(0, cursorPosition) + docWithCursor.slice(cursorPosition + 1);
const state = EditorState.create({
doc,
selection: { anchor: cursorPosition },
extensions: [n8nLang(), infoBoxTooltips()],
});
const view = new EditorView({ parent: document.createElement('div'), state });
return state
.facet(showTooltip)
.filter((t): t is Tooltip => !!t)
.map((tooltip) => ({ tooltip, view: getTooltip(view, tooltip)?.dom }));
}
function hoverTooltip(docWithCursor: string) {
const hoverPosition = docWithCursor.indexOf('|');
const doc = docWithCursor.slice(0, hoverPosition) + docWithCursor.slice(hoverPosition + 1);
const state = EditorState.create({
doc,
extensions: [n8nLang(), infoBoxTooltips()],
});
const view = new EditorView({ state, parent: document.createElement('div') });
const tooltip = hoverTooltipSource(view, hoverPosition);
if (!tooltip) {
return null;
}
return { tooltip, view: tooltip.create(view).dom };
}

View File

@@ -243,7 +243,7 @@
"codeNodeEditor.completer.$today": "A DateTime representing midnight at the start of the current day. \n\nUses the instance's time zone (unless overridden in the workflow's settings).", "codeNodeEditor.completer.$today": "A DateTime representing midnight at the start of the current day. \n\nUses the instance's time zone (unless overridden in the workflow's settings).",
"codeNodeEditor.completer.$vars": "The <a target=\"_blank\" href=\"https://docs.n8n.io/code/variables/\">variables</a> available to the workflow", "codeNodeEditor.completer.$vars": "The <a target=\"_blank\" href=\"https://docs.n8n.io/code/variables/\">variables</a> available to the workflow",
"codeNodeEditor.completer.$vars.varName": "Variable set on this n8n instance. All variables evaluate to strings.", "codeNodeEditor.completer.$vars.varName": "Variable set on this n8n instance. All variables evaluate to strings.",
"codeNodeEditor.completer.$secrets": "The external secrets connected to your instance", "codeNodeEditor.completer.$secrets": "The secrets from an <a target=\"_blank\" href=\"https://docs.n8n.io/external-secrets/\">external secrets vault</a>, if configured. Secret values are never displayed to the user. Only available in credential fields.",
"codeNodeEditor.completer.$secrets.provider": "External secrets providers connected to this n8n instance.", "codeNodeEditor.completer.$secrets.provider": "External secrets providers connected to this n8n instance.",
"codeNodeEditor.completer.$secrets.provider.varName": "External secrets connected to this n8n instance. All secrets evaluate to strings.", "codeNodeEditor.completer.$secrets.provider.varName": "External secrets connected to this n8n instance. All secrets evaluate to strings.",
"codeNodeEditor.completer.$workflow": "Information about the current workflow", "codeNodeEditor.completer.$workflow": "Information about the current workflow",

View File

@@ -94,6 +94,14 @@
} }
} }
.ͼ2 .cm-tooltip.cm-completionInfo,
.ͼ2 .cm-tooltip.cm-cursorInfo,
.ͼ2 .cm-tooltip-hover {
// Add padding when infobox only contains text
&:not(:has(div)) {
padding: var(--spacing-xs);
}
.autocomplete-info-container { .autocomplete-info-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -103,30 +111,37 @@
overflow-y: auto; overflow-y: auto;
} }
.ͼ2 .cm-tooltip.cm-completionInfo { strong.autocomplete-info-arg,
background-color: var(--color-background-xlight); strong.autocomplete-info-arg-name {
border: var(--border-base); font-weight: var(--font-weight-regular);
box-shadow: var(--box-shadow-light); text-decoration: underline;
clip-path: inset(-12px -12px -12px 0); // Clip box-shadow on the left color: var(--color-text-dark);
border-left: none;
border-bottom-right-radius: var(--border-radius-base);
border-top-right-radius: var(--border-radius-base);
border-top-left-radius: 0;
border-bottom-left-radius: 0;
line-height: var(--font-line-height-loose);
padding: 0;
// Add padding when infobox only contains text
&:not(:has(div)) {
padding: var(--spacing-xs);
} }
// Overwrite codemirror positioning .autocomplete-info-description {
top: 0 !important; padding: 0 var(--spacing-xs);
left: 100% !important; color: var(--color-text-base);
right: auto !important; font-size: var(--font-size-2xs);
max-width: 320px !important;
height: 100%; .autocomplete-info-example {
border-radius: var(--border-radius-base);
border: 1px solid var(--color-infobox-examples-border-color);
color: var(--color-text-base);
margin-top: var(--spacing-xs);
}
code {
padding: 0;
color: var(--color-text-base);
font-size: var(--font-size-2xs);
font-family: var(--font-family);
background-color: transparent;
}
p {
line-height: var(--font-line-height-loose);
}
}
a { a {
color: var(--color-text-dark); color: var(--color-text-dark);
@@ -160,31 +175,6 @@
display: inline-block; display: inline-block;
} }
.autocomplete-info-description {
padding: 0 var(--spacing-xs);
color: var(--color-text-base);
font-size: var(--font-size-2xs);
.autocomplete-info-example {
border-radius: var(--border-radius-base);
border: 1px solid var(--color-infobox-examples-border-color);
color: var(--color-text-base);
margin-top: var(--spacing-xs);
}
code {
padding: 0;
color: var(--color-text-base);
font-size: var(--font-size-2xs);
font-family: var(--font-family);
background-color: transparent;
}
p {
line-height: var(--font-line-height-loose);
}
}
.autocomplete-info-args { .autocomplete-info-args {
padding: 0 var(--spacing-xs); padding: 0 var(--spacing-xs);
list-style: none; list-style: none;
@@ -272,6 +262,27 @@
font-size: var(--font-size-3xs); font-size: var(--font-size-3xs);
font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold);
} }
}
.ͼ2 .cm-tooltip.cm-completionInfo {
background-color: var(--color-background-xlight);
border: var(--border-base);
box-shadow: var(--box-shadow-light);
clip-path: inset(-12px -12px -12px 0); // Clip box-shadow on the left
border-left: none;
border-bottom-right-radius: var(--border-radius-base);
border-top-right-radius: var(--border-radius-base);
border-top-left-radius: 0;
border-bottom-left-radius: 0;
line-height: var(--font-line-height-loose);
padding: 0;
// Overwrite codemirror positioning
top: 0 !important;
left: 100% !important;
right: auto !important;
max-width: 320px !important;
height: 100%;
&.cm-completionInfo-left-narrow, &.cm-completionInfo-left-narrow,
&.cm-completionInfo-right-narrow { &.cm-completionInfo-right-narrow {
@@ -293,3 +304,19 @@
background-color: var(--color-infobox-background); background-color: var(--color-infobox-background);
} }
} }
.ͼ2 .cm-tooltip.cm-cursorInfo,
.ͼ2 .cm-tooltip-hover {
background-color: var(--color-infobox-background);
border: var(--border-base);
box-shadow: var(--box-shadow-light);
border-radius: var(--border-radius-base);
line-height: var(--font-line-height-loose);
padding: 0;
max-width: 320px;
.autocomplete-info-container {
height: auto;
max-height: min(250px, 50vh);
}
}