Skip to content

Commit

Permalink
feat(editor): Add object keys that need bracket access to autocomplete (
Browse files Browse the repository at this point in the history
  • Loading branch information
elsmr committed Apr 10, 2024
1 parent feffc7f commit 98bcd50
Show file tree
Hide file tree
Showing 4 changed files with 84 additions and 9 deletions.
Expand Up @@ -542,6 +542,43 @@ describe('Resolution-based completions', () => {
expect(found.map((c) => c.label).every((l) => l.endsWith(']')));
});
});

test('should give completions for keys that need bracket access', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue({
foo: 'bar',
'Key with spaces': 1,
'Key with spaces and \'quotes"': 1,
});

const found = completions('{{ $json.| }}');
if (!found) throw new Error('Expected to find completions');
expect(found).toContainEqual(
expect.objectContaining({
label: 'Key with spaces',
apply: utils.applyBracketAccessCompletion,
}),
);
expect(found).toContainEqual(
expect.objectContaining({
label: 'Key with spaces and \'quotes"',
apply: utils.applyBracketAccessCompletion,
}),
);
});

test('should escape keys with quotes', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue({
'Key with spaces and \'quotes"': 1,
});

const found = completions('{{ $json[| }}');
if (!found) throw new Error('Expected to find completions');
expect(found).toContainEqual(
expect.objectContaining({
label: "'Key with spaces and \\'quotes\"']",
}),
);
});
});

describe('recommended completions', () => {
Expand Down
Expand Up @@ -3,6 +3,7 @@ import { prefixMatch, longestCommonPrefix } from './utils';
import type { IDataObject } from 'n8n-workflow';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import type { Resolved } from './types';
import { escapeMappingString } from '@/utils/mappingUtils';

/**
* Resolution-based completions offered at the start of bracket access notation.
Expand Down Expand Up @@ -67,7 +68,7 @@ function bracketAccessOptions(resolved: IDataObject) {
const isNumber = !isNaN(parseInt(key)); // array or string index

return {
label: isNumber ? `${key}]` : `'${key}']`,
label: isNumber ? `${key}]` : `'${escapeMappingString(key)}']`,
type: 'keyword',
};
});
Expand Down
Expand Up @@ -19,6 +19,8 @@ import {
hasRequiredArgs,
getDefaultArgs,
insertDefaultArgs,
applyBracketAccessCompletion,
applyBracketAccess,
} from './utils';
import type {
Completion,
Expand Down Expand Up @@ -354,7 +356,11 @@ const createPropHeader = (typeName: string, property: { doc?: DocMetadata | unde
const header = document.createElement('div');
if (property.doc) {
const typeNameSpan = document.createElement('span');
typeNameSpan.innerHTML = typeName.slice(0, 1).toUpperCase() + typeName.slice(1) + '.';
typeNameSpan.innerHTML = typeName.charAt(0).toUpperCase() + typeName.slice(1);

if (!property.doc.name.startsWith("['")) {
typeNameSpan.innerHTML += '.';
}

const propNameSpan = document.createElement('span');
propNameSpan.classList.add('autocomplete-info-name');
Expand All @@ -371,7 +377,7 @@ const createPropHeader = (typeName: string, property: { doc?: DocMetadata | unde
};

const objectOptions = (input: AutocompleteInput<IDataObject>): Completion[] => {
const { base, resolved, transformLabel } = input;
const { base, resolved, transformLabel = (label) => label } = input;
const rank = setRank(['item', 'all', 'first', 'last']);
const SKIP = new Set(['__ob__', 'pairedItem']);

Expand All @@ -393,30 +399,36 @@ const objectOptions = (input: AutocompleteInput<IDataObject>): Completion[] => {
}

const localKeys = rank(rawKeys)
.filter((key) => !SKIP.has(key) && isAllowedInDotNotation(key) && !isPseudoParam(key))
.filter((key) => !SKIP.has(key) && !isPseudoParam(key))
.map((key) => {
ensureKeyCanBeResolved(resolved, key);
const needsBracketAccess = !isAllowedInDotNotation(key);
const resolvedProp = resolved[key];

const isFunction = typeof resolvedProp === 'function';
const hasArgs = isFunction && resolvedProp.length > 0 && name !== '$()';

const option: Completion = {
label: isFunction ? key + '()' : key,
type: isFunction ? 'function' : 'keyword',
section: getObjectPropertySection({ name, key, isFunction }),
apply: applyCompletion({ hasArgs, transformLabel }),
apply: needsBracketAccess
? applyBracketAccessCompletion
: applyCompletion({
hasArgs,
transformLabel,
}),
detail: getDetail(name, resolvedProp),
};

const infoKey = [name, key].join('.');
const infoName = needsBracketAccess ? applyBracketAccess(key) : key;
option.info = createCompletionOption(
'',
key,
infoName,
isFunction ? 'native-function' : 'keyword',
{
doc: {
name: key,
name: infoName,
returnType: getType(resolvedProp),
description: i18n.proxyVars[infoKey],
},
Expand Down
27 changes: 26 additions & 1 deletion packages/editor-ui/src/plugins/codemirror/completions/utils.ts
Expand Up @@ -20,6 +20,7 @@ import type { SyntaxNode } from '@lezer/common';
import { javascriptLanguage } from '@codemirror/lang-javascript';
import { useRouter } from 'vue-router';
import type { DocMetadata } from 'n8n-workflow';
import { escapeMappingString } from '@/utils/mappingUtils';

/**
* Split user input into base (to resolve) and tail (to filter).
Expand Down Expand Up @@ -194,7 +195,6 @@ export const applyCompletion =
(view: EditorView, completion: Completion, from: number, to: number): void => {
const isFunction = completion.label.endsWith('()');
const label = insertDefaultArgs(transformLabel(completion.label), defaultArgs);

const tx: TransactionSpec = {
...insertCompletionText(view.state, label, from, to),
annotations: pickedCompletion.of(completion),
Expand All @@ -212,6 +212,31 @@ export const applyCompletion =
view.dispatch(tx);
};

export const applyBracketAccess = (key: string): string => {
return `['${escapeMappingString(key)}']`;
};

/**
* Apply a bracket-access completion
*
* @example `$json.` -> `$json['key with spaces']`
* @example `$json` -> `$json['key with spaces']`
*/
export const applyBracketAccessCompletion = (
view: EditorView,
completion: Completion,
from: number,
to: number,
): void => {
const label = applyBracketAccess(completion.label);
const completionAtDot = view.state.sliceDoc(from - 1, from) === '.';

view.dispatch({
...insertCompletionText(view.state, label, completionAtDot ? from - 1 : from, to),
annotations: pickedCompletion.of(completion),
});
};

export const hasRequiredArgs = (doc?: DocMetadata): boolean => {
if (!doc) return false;
const requiredArgs = doc?.args?.filter((arg) => !arg.name.endsWith('?')) ?? [];
Expand Down

0 comments on commit 98bcd50

Please sign in to comment.