mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-25 04:34:06 -08:00
fix(editor): Provide autocomplete for nodes, even when intermediate node has not run (#10036)
This commit is contained in:
parent
fd833ec079
commit
46d6edc2a4
|
@ -0,0 +1,46 @@
|
|||
import { expressionWithFirstItem } from '../utils';
|
||||
import { javascriptLanguage } from '@codemirror/lang-javascript';
|
||||
|
||||
describe('completion utils', () => {
|
||||
describe('expressionWithFirstItem', () => {
|
||||
it('should replace $input.item', () => {
|
||||
const source = '$input.item.json.foo.bar';
|
||||
const expected = '$input.first().json.foo.bar';
|
||||
const tree = javascriptLanguage.parser.parse(source);
|
||||
const result = expressionWithFirstItem(tree, source);
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it('should replace $input.itemMatching()', () => {
|
||||
const source = '$input.itemMatching(4).json.foo.bar';
|
||||
const expected = '$input.first().json.foo.bar';
|
||||
const tree = javascriptLanguage.parser.parse(source);
|
||||
const result = expressionWithFirstItem(tree, source);
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it('should replace $("Node Name").itemMatching()', () => {
|
||||
const source = '$("Node Name").itemMatching(4).json.foo.bar';
|
||||
const expected = '$("Node Name").first().json.foo.bar';
|
||||
const tree = javascriptLanguage.parser.parse(source);
|
||||
const result = expressionWithFirstItem(tree, source);
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it('should replace $("Node Name").item', () => {
|
||||
const source = '$("Node Name").item.json.foo.bar';
|
||||
const expected = '$("Node Name").first().json.foo.bar';
|
||||
const tree = javascriptLanguage.parser.parse(source);
|
||||
const result = expressionWithFirstItem(tree, source);
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it('should not replace anything in unrelated expressions', () => {
|
||||
const source = '$input.first().foo.item.fn($json.item.foo)';
|
||||
const expected = '$input.first().foo.item.fn($json.item.foo)';
|
||||
const tree = javascriptLanguage.parser.parse(source);
|
||||
const result = expressionWithFirstItem(tree, source);
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -36,6 +36,8 @@ import type { AutocompleteInput, ExtensionTypeName, FnToDoc, Resolved } from './
|
|||
import {
|
||||
applyBracketAccessCompletion,
|
||||
applyCompletion,
|
||||
attempt,
|
||||
expressionWithFirstItem,
|
||||
getDefaultArgs,
|
||||
getDisplayType,
|
||||
hasNoParams,
|
||||
|
@ -52,6 +54,8 @@ import {
|
|||
splitBaseTail,
|
||||
stripExcessParens,
|
||||
} from './utils';
|
||||
import { javascriptLanguage } from '@codemirror/lang-javascript';
|
||||
import { isPairedItemIntermediateNodesError } from '@/utils/expressions';
|
||||
|
||||
/**
|
||||
* Resolution-based completions offered according to datatype.
|
||||
|
@ -63,7 +67,8 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul
|
|||
|
||||
if (word.from === word.to && !context.explicit) return null;
|
||||
|
||||
const [base, tail] = splitBaseTail(word.text);
|
||||
const syntaxTree = javascriptLanguage.parser.parse(word.text);
|
||||
const [base, tail] = splitBaseTail(syntaxTree, word.text);
|
||||
|
||||
let options: Completion[] = [];
|
||||
|
||||
|
@ -80,21 +85,26 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul
|
|||
} else if (base === '$secrets' && isCredential) {
|
||||
options = secretProvidersOptions();
|
||||
} else {
|
||||
let resolved: Resolved;
|
||||
const resolved = attempt(
|
||||
(): Resolved => resolveAutocompleteExpression(`={{ ${base} }}`),
|
||||
(error) => {
|
||||
if (!isPairedItemIntermediateNodesError(error)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
resolved = resolveAutocompleteExpression(`={{ ${base} }}`);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
// Fallback on first item to provide autocomplete when intermediate nodes have not run
|
||||
return attempt(() =>
|
||||
resolveAutocompleteExpression(`={{ ${expressionWithFirstItem(syntaxTree, base)} }}`),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (resolved === null) return null;
|
||||
|
||||
try {
|
||||
options = datatypeOptions({ resolved, base, tail }).map(stripExcessParens(context));
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
options = attempt(
|
||||
() => datatypeOptions({ resolved, base, tail }).map(stripExcessParens(context)),
|
||||
() => [],
|
||||
);
|
||||
}
|
||||
|
||||
if (tail !== '') {
|
||||
|
@ -125,17 +135,18 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul
|
|||
}
|
||||
|
||||
function explicitDataTypeOptions(expression: string): Completion[] {
|
||||
try {
|
||||
const resolved = resolveAutocompleteExpression(`={{ ${expression} }}`);
|
||||
return datatypeOptions({
|
||||
resolved,
|
||||
base: expression,
|
||||
tail: '',
|
||||
transformLabel: (label) => '.' + label,
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return attempt(
|
||||
() => {
|
||||
const resolved = resolveAutocompleteExpression(`={{ ${expression} }}`);
|
||||
return datatypeOptions({
|
||||
resolved,
|
||||
base: expression,
|
||||
tail: '',
|
||||
transformLabel: (label) => '.' + label,
|
||||
});
|
||||
},
|
||||
() => [],
|
||||
);
|
||||
}
|
||||
|
||||
function datatypeOptions(input: AutocompleteInput): Completion[] {
|
||||
|
@ -155,7 +166,13 @@ function datatypeOptions(input: AutocompleteInput): Completion[] {
|
|||
return booleanOptions();
|
||||
}
|
||||
|
||||
if (DateTime.isDateTime(resolved)) {
|
||||
if (
|
||||
attempt(
|
||||
// This can throw when resolved is a proxy
|
||||
() => DateTime.isDateTime(resolved),
|
||||
() => false,
|
||||
)
|
||||
) {
|
||||
return luxonOptions(input as AutocompleteInput<DateTime>);
|
||||
}
|
||||
|
||||
|
@ -453,12 +470,13 @@ const applySections = ({
|
|||
};
|
||||
|
||||
const isUrl = (url: string): boolean => {
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
return attempt(
|
||||
() => {
|
||||
new URL(url);
|
||||
return true;
|
||||
},
|
||||
() => false,
|
||||
);
|
||||
};
|
||||
|
||||
const stringOptions = (input: AutocompleteInput<string>): Completion[] => {
|
||||
|
|
|
@ -16,8 +16,7 @@ import {
|
|||
} from '@codemirror/autocomplete';
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import type { TransactionSpec } from '@codemirror/state';
|
||||
import type { SyntaxNode } from '@lezer/common';
|
||||
import { javascriptLanguage } from '@codemirror/lang-javascript';
|
||||
import type { SyntaxNode, Tree } from '@lezer/common';
|
||||
import { useRouter } from 'vue-router';
|
||||
import type { DocMetadata } from 'n8n-workflow';
|
||||
import { escapeMappingString } from '@/utils/mappingUtils';
|
||||
|
@ -25,23 +24,93 @@ import { escapeMappingString } from '@/utils/mappingUtils';
|
|||
/**
|
||||
* Split user input into base (to resolve) and tail (to filter).
|
||||
*/
|
||||
export function splitBaseTail(userInput: string): [string, string] {
|
||||
const read = (node: SyntaxNode | null) => (node ? userInput.slice(node.from, node.to) : '');
|
||||
const lastNode = javascriptLanguage.parser.parse(userInput).resolveInner(userInput.length, -1);
|
||||
export function splitBaseTail(syntaxTree: Tree, userInput: string): [string, string] {
|
||||
const lastNode = syntaxTree.resolveInner(userInput.length, -1);
|
||||
|
||||
switch (lastNode.type.name) {
|
||||
case '.':
|
||||
return [read(lastNode.parent).slice(0, -1), ''];
|
||||
return [read(lastNode.parent, userInput).slice(0, -1), ''];
|
||||
case 'MemberExpression':
|
||||
return [read(lastNode.parent), read(lastNode)];
|
||||
return [read(lastNode.parent, userInput), read(lastNode, userInput)];
|
||||
case 'PropertyName':
|
||||
const tail = read(lastNode);
|
||||
return [read(lastNode.parent).slice(0, -(tail.length + 1)), tail];
|
||||
const tail = read(lastNode, userInput);
|
||||
return [read(lastNode.parent, userInput).slice(0, -(tail.length + 1)), tail];
|
||||
default:
|
||||
return ['', ''];
|
||||
}
|
||||
}
|
||||
|
||||
function replaceSyntaxNode(source: string, node: SyntaxNode, replacement: string) {
|
||||
return source.slice(0, node.from) + replacement + source.slice(node.to);
|
||||
}
|
||||
|
||||
function isInputNodeCall(node: SyntaxNode, source: string): node is SyntaxNode {
|
||||
return (
|
||||
node.name === 'VariableName' &&
|
||||
read(node, source) === '$' &&
|
||||
node.parent?.name === 'CallExpression'
|
||||
);
|
||||
}
|
||||
|
||||
function isInputVariable(node: SyntaxNode | null | undefined, source: string): node is SyntaxNode {
|
||||
return node?.name === 'VariableName' && read(node, source) === '$input';
|
||||
}
|
||||
|
||||
function isItemProperty(node: SyntaxNode | null | undefined, source: string): node is SyntaxNode {
|
||||
return (
|
||||
node?.parent?.name === 'MemberExpression' &&
|
||||
node.name === 'PropertyName' &&
|
||||
read(node, source) === 'item'
|
||||
);
|
||||
}
|
||||
|
||||
function isItemMatchingCall(
|
||||
node: SyntaxNode | null | undefined,
|
||||
source: string,
|
||||
): node is SyntaxNode {
|
||||
return (
|
||||
node?.name === 'CallExpression' &&
|
||||
node.firstChild?.lastChild?.name === 'PropertyName' &&
|
||||
read(node.firstChild.lastChild, source) === 'itemMatching'
|
||||
);
|
||||
}
|
||||
|
||||
function read(node: SyntaxNode | null, source: string) {
|
||||
return node ? source.slice(node.from, node.to) : '';
|
||||
}
|
||||
/**
|
||||
* Replace expressions that depend on pairedItem with the first item when possible
|
||||
* $input.item.json.foo -> $input.first().json.foo
|
||||
* $('Node').item.json.foo -> $('Node').item.json.foo
|
||||
*/
|
||||
export function expressionWithFirstItem(syntaxTree: Tree, expression: string): string {
|
||||
let result = expression;
|
||||
|
||||
syntaxTree.cursor().iterate(({ node }) => {
|
||||
if (isInputVariable(node, expression)) {
|
||||
if (isItemProperty(node.parent?.lastChild, expression)) {
|
||||
result = replaceSyntaxNode(expression, node.parent.lastChild, 'first()');
|
||||
} else if (isItemMatchingCall(node.parent?.parent, expression)) {
|
||||
result = replaceSyntaxNode(expression, node.parent.parent, '$input.first()');
|
||||
}
|
||||
}
|
||||
|
||||
if (isInputNodeCall(node, expression)) {
|
||||
if (isItemProperty(node.parent?.parent?.lastChild, expression)) {
|
||||
result = replaceSyntaxNode(expression, node.parent.parent.lastChild, 'first()');
|
||||
} else if (isItemMatchingCall(node.parent?.parent?.parent, expression)) {
|
||||
result = replaceSyntaxNode(
|
||||
expression,
|
||||
node.parent.parent.parent,
|
||||
`${read(node.parent, expression)}.first()`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function longestCommonPrefix(...strings: string[]) {
|
||||
if (strings.length < 2) {
|
||||
throw new Error('Expected at least two strings');
|
||||
|
@ -285,3 +354,22 @@ export const getDisplayType = (value: unknown): string => {
|
|||
if (typeof value === 'object') return 'Object';
|
||||
return (typeof value).toLocaleLowerCase();
|
||||
};
|
||||
|
||||
export function attempt<T, TDefault>(
|
||||
fn: () => T,
|
||||
onError: (error: unknown) => TDefault,
|
||||
): T | TDefault;
|
||||
export function attempt<T>(fn: () => T): T | null;
|
||||
export function attempt<T, TDefault>(
|
||||
fn: () => T,
|
||||
onError?: (error: unknown) => TDefault,
|
||||
): T | TDefault | null {
|
||||
try {
|
||||
return fn();
|
||||
} catch (error) {
|
||||
if (onError) {
|
||||
return onError(error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue