mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-19 19:11:13 +00:00
feat(editor): Show expression infobox on hover and cursor position (#9507)
Co-authored-by: Giulio Andreini <andreini@netseven.it>
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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'),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -94,39 +94,54 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.autocomplete-info-container {
|
.ͼ2 .cm-tooltip.cm-completionInfo,
|
||||||
display: flex;
|
.ͼ2 .cm-tooltip.cm-cursorInfo,
|
||||||
flex-direction: column;
|
.ͼ2 .cm-tooltip-hover {
|
||||||
gap: var(--spacing-xs);
|
|
||||||
padding: var(--spacing-xs) 0;
|
|
||||||
height: 100%;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ͼ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;
|
|
||||||
|
|
||||||
// Add padding when infobox only contains text
|
// Add padding when infobox only contains text
|
||||||
&:not(:has(div)) {
|
&:not(:has(div)) {
|
||||||
padding: var(--spacing-xs);
|
padding: var(--spacing-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Overwrite codemirror positioning
|
.autocomplete-info-container {
|
||||||
top: 0 !important;
|
display: flex;
|
||||||
left: 100% !important;
|
flex-direction: column;
|
||||||
right: auto !important;
|
gap: var(--spacing-xs);
|
||||||
max-width: 320px !important;
|
padding: var(--spacing-xs) 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong.autocomplete-info-arg,
|
||||||
|
strong.autocomplete-info-arg-name {
|
||||||
|
font-weight: var(--font-weight-regular);
|
||||||
|
text-decoration: underline;
|
||||||
|
color: var(--color-text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user