feat(editor): Autocomplete info box: improve structure and add examples (#9019)

Co-authored-by: Giulio Andreini <andreini@netseven.it>
This commit is contained in:
Elias Meire 2024-05-10 14:39:06 +02:00 committed by GitHub
parent 4ed585040b
commit c92c870c73
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1596 additions and 457 deletions

View file

@ -73,6 +73,7 @@ describe('Data mapping', () => {
ndv.actions.mapToParameter('value'); ndv.actions.mapToParameter('value');
ndv.getters.inlineExpressionEditorInput().should('have.text', '{{ $json.input[0].count }}'); ndv.getters.inlineExpressionEditorInput().should('have.text', '{{ $json.input[0].count }}');
ndv.getters.inlineExpressionEditorInput().type('{esc}');
ndv.getters.parameterExpressionPreview('value').should('include.text', '0'); ndv.getters.parameterExpressionPreview('value').should('include.text', '0');
ndv.getters.inputTbodyCell(1, 0).realHover(); ndv.getters.inputTbodyCell(1, 0).realHover();

View file

@ -9,6 +9,7 @@
--prim-gray-820: hsl(var(--prim-gray-h), 1%, 18%); --prim-gray-820: hsl(var(--prim-gray-h), 1%, 18%);
--prim-gray-800: hsl(var(--prim-gray-h), 1%, 20%); --prim-gray-800: hsl(var(--prim-gray-h), 1%, 20%);
--prim-gray-780: hsl(var(--prim-gray-h), 1%, 22%);
--prim-gray-740: hsl(var(--prim-gray-h), 2%, 26%); --prim-gray-740: hsl(var(--prim-gray-h), 2%, 26%);
--prim-gray-670: hsl(var(--prim-gray-h), 2%, 33%); --prim-gray-670: hsl(var(--prim-gray-h), 2%, 33%);
--prim-gray-540: hsl(var(--prim-gray-h), 4%, 46%); --prim-gray-540: hsl(var(--prim-gray-h), 4%, 46%);
@ -20,8 +21,9 @@
--prim-gray-70: hsl(var(--prim-gray-h), 32%, 93%); --prim-gray-70: hsl(var(--prim-gray-h), 32%, 93%);
--prim-gray-70-alpha-01: hsla(var(--prim-gray-h), 32%, 93%, 0.1); --prim-gray-70-alpha-01: hsla(var(--prim-gray-h), 32%, 93%, 0.1);
--prim-gray-40: hsl(var(--prim-gray-h), 40%, 96%); --prim-gray-40: hsl(var(--prim-gray-h), 40%, 96%);
--prim-gray-30: hsl(var(--prim-gray-h), 40%, 97%); --prim-gray-30: hsl(var(--prim-gray-h), 43%, 97%);
--prim-gray-10: hsl(var(--prim-gray-h), 47%, 99%); --prim-gray-25: hsl(var(--prim-gray-h), 50%, 97.5%);
--prim-gray-10: hsl(var(--prim-gray-h), 50%, 99%);
--prim-gray-0-alpha-075: hsla(var(--prim-gray-h), 50%, 100%, 0.75); --prim-gray-0-alpha-075: hsla(var(--prim-gray-h), 50%, 100%, 0.75);
--prim-gray-0-alpha-025: hsla(var(--prim-gray-h), 50%, 100%, 0.25); --prim-gray-0-alpha-025: hsla(var(--prim-gray-h), 50%, 100%, 0.25);
--prim-gray-0: hsl(var(--prim-gray-h), 50%, 100%); --prim-gray-0: hsl(var(--prim-gray-h), 50%, 100%);

View file

@ -72,7 +72,7 @@
--color-sticky-background-7: var(--prim-gray-740); --color-sticky-background-7: var(--prim-gray-740);
--color-sticky-border-7: var(--prim-gray-670); --color-sticky-border-7: var(--prim-gray-670);
// Expressions and autocomplete // Expressions, autocomplete, infobox
--color-valid-resolvable-foreground: var(--prim-color-alt-a-tint-300); --color-valid-resolvable-foreground: var(--prim-color-alt-a-tint-300);
--color-valid-resolvable-background: var(--prim-color-alt-a-alpha-025); --color-valid-resolvable-background: var(--prim-color-alt-a-alpha-025);
--color-invalid-resolvable-foreground: var(--prim-color-alt-c-tint-250); --color-invalid-resolvable-foreground: var(--prim-color-alt-c-tint-250);
@ -83,6 +83,8 @@
--color-expression-syntax-example: var(--prim-gray-670); --color-expression-syntax-example: var(--prim-gray-670);
--color-autocomplete-item-selected: var(--prim-color-secondary-tint-200); --color-autocomplete-item-selected: var(--prim-color-secondary-tint-200);
--color-autocomplete-section-header-border: var(--prim-gray-540); --color-autocomplete-section-header-border: var(--prim-gray-540);
--color-infobox-background: var(--prim-gray-780);
--color-infobox-examples-border-color: var(--prim-gray-670);
// Code // Code
--color-code-tags-string: var(--prim-color-alt-f-tint-150); --color-code-tags-string: var(--prim-color-alt-f-tint-150);

View file

@ -59,6 +59,7 @@
--color-background-dark: var(--prim-gray-820); --color-background-dark: var(--prim-gray-820);
--color-background-medium: var(--prim-gray-120); --color-background-medium: var(--prim-gray-120);
--color-background-base: var(--prim-gray-40); --color-background-base: var(--prim-gray-40);
--color-background-light-base: var(--prim-gray-25);
--color-background-light: var(--prim-gray-10); --color-background-light: var(--prim-gray-10);
--color-background-xlight: var(--prim-gray-0); --color-background-xlight: var(--prim-gray-0);
@ -105,7 +106,7 @@
--color-sticky-background-7: var(--prim-gray-10); --color-sticky-background-7: var(--prim-gray-10);
--color-sticky-border-7: var(--prim-gray-120); --color-sticky-border-7: var(--prim-gray-120);
// Expressions and autocomplete // Expressions, autocomplete, infobox
--color-valid-resolvable-foreground: var(--prim-color-alt-a); --color-valid-resolvable-foreground: var(--prim-color-alt-a);
--color-valid-resolvable-background: var(--prim-color-alt-a-tint-500); --color-valid-resolvable-background: var(--prim-color-alt-a-tint-500);
--color-invalid-resolvable-foreground: var(--prim-color-alt-c); --color-invalid-resolvable-foreground: var(--prim-color-alt-c);
@ -116,6 +117,8 @@
--color-expression-syntax-example: var(--prim-gray-40); --color-expression-syntax-example: var(--prim-gray-40);
--color-autocomplete-item-selected: var(--color-secondary); --color-autocomplete-item-selected: var(--color-secondary);
--color-autocomplete-section-header-border: var(--color-foreground-light); --color-autocomplete-section-header-border: var(--color-foreground-light);
--color-infobox-background: var(--color-background-light-base);
--color-infobox-examples-border-color: var(--color-foreground-light);
// Code // Code
--color-code-tags-string: var(--prim-color-alt-f); --color-code-tags-string: var(--prim-color-alt-f);

View file

@ -15,7 +15,7 @@ const BASE_STYLING = {
fontFamily: "Menlo, Consolas, 'DejaVu Sans Mono', monospace !important", fontFamily: "Menlo, Consolas, 'DejaVu Sans Mono', monospace !important",
maxHeight: '400px', maxHeight: '400px',
tooltip: { tooltip: {
maxWidth: '300px', maxWidth: '250px',
lineHeight: '1.3em', lineHeight: '1.3em',
}, },
diagnosticButton: { diagnosticButton: {

View file

@ -131,7 +131,7 @@ watchDebounced(
<style lang="scss" module> <style lang="scss" module>
.tip { .tip {
display: flex; display: flex;
align-items: flex-start; align-items: center;
gap: var(--spacing-4xs); gap: var(--spacing-4xs);
line-height: var(--font-line-height-regular); line-height: var(--font-line-height-regular);
color: var(--color-text-base); color: var(--color-text-base);

View file

@ -7,6 +7,7 @@ import {
type Ref, type Ref,
toValue, toValue,
watch, watch,
onMounted,
} from 'vue'; } from 'vue';
import { ensureSyntaxTree } from '@codemirror/language'; import { ensureSyntaxTree } from '@codemirror/language';
@ -24,7 +25,7 @@ import {
getResolvableState, getResolvableState,
isEmptyExpression, isEmptyExpression,
} from '@/utils/expressions'; } from '@/utils/expressions';
import { completionStatus } from '@codemirror/autocomplete'; import { closeCompletion, completionStatus } from '@codemirror/autocomplete';
import { import {
Compartment, Compartment,
EditorState, EditorState,
@ -158,6 +159,19 @@ export const useExpressionEditor = ({
debouncedUpdateSegments(); debouncedUpdateSegments();
} }
function blur() {
if (editor.value) {
editor.value.contentDOM.blur();
closeCompletion(editor.value);
}
}
function blurOnClickOutside(event: MouseEvent) {
if (event.target && !editor.value?.dom.contains(event.target as Node)) {
blur();
}
}
watch(editorRef, () => { watch(editorRef, () => {
const parent = toValue(editorRef); const parent = toValue(editorRef);
@ -176,6 +190,10 @@ export const useExpressionEditor = ({
EditorView.focusChangeEffect.of((_, newHasFocus) => { EditorView.focusChangeEffect.of((_, newHasFocus) => {
hasFocus.value = newHasFocus; hasFocus.value = newHasFocus;
selection.value = state.selection.ranges[0]; selection.value = state.selection.ranges[0];
if (!newHasFocus) {
autocompleteStatus.value = null;
debouncedUpdateSegments();
}
return null; return null;
}), }),
EditorView.contentAttributes.of({ 'data-gramm': 'false' }), // disable grammarly EditorView.contentAttributes.of({ 'data-gramm': 'false' }), // disable grammarly
@ -231,7 +249,12 @@ export const useExpressionEditor = ({
}); });
}); });
onMounted(() => {
document.addEventListener('click', blurOnClickOutside);
});
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener('click', blurOnClickOutside);
editor.value?.destroy(); editor.value?.destroy();
}); });

View file

@ -130,8 +130,10 @@ describe('Luxon method completions', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(DateTime.now()); vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(DateTime.now());
expect(completions('{{ $now.| }}')).toHaveLength( expect(completions('{{ $now.| }}')).toHaveLength(
uniqBy(luxonInstanceOptions().concat(extensions('date')), (option) => option.label).length + uniqBy(
LUXON_RECOMMENDED_OPTIONS.length, luxonInstanceOptions().concat(extensions({ typeName: 'date' })),
(option) => option.label,
).length + LUXON_RECOMMENDED_OPTIONS.length,
); );
}); });
@ -140,8 +142,10 @@ describe('Luxon method completions', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(DateTime.now()); vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(DateTime.now());
expect(completions('{{ $today.| }}')).toHaveLength( expect(completions('{{ $today.| }}')).toHaveLength(
uniqBy(luxonInstanceOptions().concat(extensions('date')), (option) => option.label).length + uniqBy(
LUXON_RECOMMENDED_OPTIONS.length, luxonInstanceOptions().concat(extensions({ typeName: 'date' })),
(option) => option.label,
).length + LUXON_RECOMMENDED_OPTIONS.length,
); );
}); });
}); });
@ -153,7 +157,9 @@ describe('Resolution-based completions', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce('abc'); vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce('abc');
expect(completions('{{ "abc".| }}')).toHaveLength( expect(completions('{{ "abc".| }}')).toHaveLength(
natives('string').length + extensions('string').length + STRING_RECOMMENDED_OPTIONS.length, natives({ typeName: 'string' }).length +
extensions({ typeName: 'string' }).length +
STRING_RECOMMENDED_OPTIONS.length,
); );
}); });
@ -163,7 +169,9 @@ describe('Resolution-based completions', () => {
const result = completions('{{ "You \'owe\' me 200$".| }}'); const result = completions('{{ "You \'owe\' me 200$".| }}');
expect(result).toHaveLength(natives('string').length + extensions('string').length + 1); expect(result).toHaveLength(
natives({ typeName: 'string' }).length + extensions({ typeName: 'string' }).length + 1,
);
}); });
test('should return completions for number literal: {{ (123).| }}', () => { test('should return completions for number literal: {{ (123).| }}', () => {
@ -171,7 +179,9 @@ describe('Resolution-based completions', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(123); vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(123);
expect(completions('{{ (123).| }}')).toHaveLength( expect(completions('{{ (123).| }}')).toHaveLength(
natives('number').length + extensions('number').length + ['isEven()', 'isOdd()'].length, natives({ typeName: 'number' }).length +
extensions({ typeName: 'number' }).length +
['isEven()', 'isOdd()'].length,
); );
}); });
@ -180,7 +190,7 @@ describe('Resolution-based completions', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce([1, 2, 3]); vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce([1, 2, 3]);
expect(completions('{{ [1, 2, 3].| }}')).toHaveLength( expect(completions('{{ [1, 2, 3].| }}')).toHaveLength(
natives('array').length + extensions('array').length, natives({ typeName: 'array' }).length + extensions({ typeName: 'array' }).length,
); );
}); });
@ -192,7 +202,9 @@ describe('Resolution-based completions', () => {
if (!found) throw new Error('Expected to find completion'); if (!found) throw new Error('Expected to find completion');
expect(found).toHaveLength(natives('array').length + extensions('array').length); expect(found).toHaveLength(
natives({ typeName: 'array' }).length + extensions({ typeName: 'array' }).length,
);
}); });
test('should return completions for object literal', () => { test('should return completions for object literal', () => {
@ -201,7 +213,7 @@ describe('Resolution-based completions', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(object); vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(object);
expect(completions('{{ ({ a: 1 }).| }}')).toHaveLength( expect(completions('{{ ({ a: 1 }).| }}')).toHaveLength(
Object.keys(object).length + extensions('object').length, Object.keys(object).length + extensions({ typeName: 'object' }).length,
); );
}); });
}); });
@ -212,7 +224,9 @@ describe('Resolution-based completions', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce('a'); vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce('a');
expect(completions('{{ "abc"[0].| }}')).toHaveLength( expect(completions('{{ "abc"[0].| }}')).toHaveLength(
natives('string').length + extensions('string').length + STRING_RECOMMENDED_OPTIONS.length, natives({ typeName: 'string' }).length +
extensions({ typeName: 'string' }).length +
STRING_RECOMMENDED_OPTIONS.length,
); );
}); });
}); });
@ -225,7 +239,9 @@ describe('Resolution-based completions', () => {
const found = completions('{{ Math.abs($input.item.json.num1).| }}'); const found = completions('{{ Math.abs($input.item.json.num1).| }}');
if (!found) throw new Error('Expected to find completions'); if (!found) throw new Error('Expected to find completions');
expect(found).toHaveLength( expect(found).toHaveLength(
extensions('number').length + natives('number').length + ['isEven()', 'isOdd()'].length, extensions({ typeName: 'number' }).length +
natives({ typeName: 'number' }).length +
['isEven()', 'isOdd()'].length,
); );
}); });
@ -240,8 +256,10 @@ describe('Resolution-based completions', () => {
test('should return completions for complex expression: {{ $now.diff($now.diff($now.|)) }}', () => { test('should return completions for complex expression: {{ $now.diff($now.diff($now.|)) }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(DateTime.now()); vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(DateTime.now());
expect(completions('{{ $now.diff($now.diff($now.|)) }}')).toHaveLength( expect(completions('{{ $now.diff($now.diff($now.|)) }}')).toHaveLength(
uniqBy(luxonInstanceOptions().concat(extensions('date')), (option) => option.label).length + uniqBy(
LUXON_RECOMMENDED_OPTIONS.length, luxonInstanceOptions().concat(extensions({ typeName: 'date' })),
(option) => option.label,
).length + LUXON_RECOMMENDED_OPTIONS.length,
); );
}); });
@ -251,7 +269,9 @@ describe('Resolution-based completions', () => {
const found = completions('{{ $execution.resumeUrl.includes($json.|) }}'); const found = completions('{{ $execution.resumeUrl.includes($json.|) }}');
if (!found) throw new Error('Expected to find completions'); if (!found) throw new Error('Expected to find completions');
expect(found).toHaveLength(Object.keys($json).length + extensions('object').length); expect(found).toHaveLength(
Object.keys($json).length + extensions({ typeName: 'object' }).length,
);
}); });
test('should return completions for operation expression: {{ $now.day + $json. }}', () => { test('should return completions for operation expression: {{ $now.day + $json. }}', () => {
@ -261,7 +281,9 @@ describe('Resolution-based completions', () => {
if (!found) throw new Error('Expected to find completions'); if (!found) throw new Error('Expected to find completions');
expect(found).toHaveLength(Object.keys($json).length + extensions('object').length); expect(found).toHaveLength(
Object.keys($json).length + extensions({ typeName: 'object' }).length,
);
}); });
test('should return completions for operation expression: {{ Math.abs($now.day) >= 10 ? $now : Math.abs($json.). }}', () => { test('should return completions for operation expression: {{ Math.abs($now.day) >= 10 ? $now : Math.abs($json.). }}', () => {
@ -271,7 +293,9 @@ describe('Resolution-based completions', () => {
if (!found) throw new Error('Expected to find completions'); if (!found) throw new Error('Expected to find completions');
expect(found).toHaveLength(Object.keys($json).length + extensions('object').length); expect(found).toHaveLength(
Object.keys($json).length + extensions({ typeName: 'object' }).length,
);
}); });
}); });
@ -286,7 +310,9 @@ describe('Resolution-based completions', () => {
if (!found) throw new Error('Expected to find completions'); if (!found) throw new Error('Expected to find completions');
expect(found).toHaveLength( expect(found).toHaveLength(
extensions('string').length + natives('string').length + STRING_RECOMMENDED_OPTIONS.length, extensions({ typeName: 'string' }).length +
natives({ typeName: 'string' }).length +
STRING_RECOMMENDED_OPTIONS.length,
); );
expect(found.map((c) => c.label).every((l) => !l.endsWith('()'))); expect(found.map((c) => c.label).every((l) => !l.endsWith('()')));
}); });
@ -299,7 +325,9 @@ describe('Resolution-based completions', () => {
if (!found) throw new Error('Expected to find completions'); if (!found) throw new Error('Expected to find completions');
expect(found).toHaveLength( expect(found).toHaveLength(
extensions('number').length + natives('number').length + ['isEven()', 'isOdd()'].length, extensions({ typeName: 'number' }).length +
natives({ typeName: 'number' }).length +
['isEven()', 'isOdd()'].length,
); );
expect(found.map((c) => c.label).every((l) => !l.endsWith('()'))); expect(found.map((c) => c.label).every((l) => !l.endsWith('()')));
}); });
@ -311,7 +339,9 @@ describe('Resolution-based completions', () => {
if (!found) throw new Error('Expected to find completions'); if (!found) throw new Error('Expected to find completions');
expect(found).toHaveLength(extensions('array').length + natives('array').length); expect(found).toHaveLength(
extensions({ typeName: 'array' }).length + natives({ typeName: 'array' }).length,
);
expect(found.map((c) => c.label).every((l) => !l.endsWith('()'))); expect(found.map((c) => c.label).every((l) => !l.endsWith('()')));
}); });
}); });
@ -339,7 +369,6 @@ describe('Resolution-based completions', () => {
{ {
info: expect.any(Function), info: expect.any(Function),
label: provider, label: provider,
type: 'keyword',
apply: expect.any(Function), apply: expect.any(Function),
}, },
]); ]);
@ -363,13 +392,11 @@ describe('Resolution-based completions', () => {
{ {
info: expect.any(Function), info: expect.any(Function),
label: secrets[0], label: secrets[0],
type: 'keyword',
apply: expect.any(Function), apply: expect.any(Function),
}, },
{ {
info: expect.any(Function), info: expect.any(Function),
label: secrets[1], label: secrets[1],
type: 'keyword',
apply: expect.any(Function), apply: expect.any(Function),
}, },
]); ]);
@ -445,7 +472,9 @@ describe('Resolution-based completions', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue([$input.item]); vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue([$input.item]);
expect(completions('{{ $input.all().| }}')).toHaveLength( expect(completions('{{ $input.all().| }}')).toHaveLength(
extensions('array').length + natives('array').length - ARRAY_NUMBER_ONLY_METHODS.length, extensions({ typeName: 'array' }).length +
natives({ typeName: 'array' }).length -
ARRAY_NUMBER_ONLY_METHODS.length,
); );
}); });
@ -453,7 +482,7 @@ describe('Resolution-based completions', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.item.json); vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.item.json);
expect(completions('{{ $input.item.| }}')).toHaveLength( expect(completions('{{ $input.item.| }}')).toHaveLength(
Object.keys($input.item.json).length + extensions('object').length, Object.keys($input.item.json).length + extensions({ typeName: 'object' }).length,
); );
}); });
@ -461,7 +490,7 @@ describe('Resolution-based completions', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.first().json); vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.first().json);
expect(completions('{{ $input.first().| }}')).toHaveLength( expect(completions('{{ $input.first().| }}')).toHaveLength(
Object.keys($input.first().json).length + extensions('object').length, Object.keys($input.first().json).length + extensions({ typeName: 'object' }).length,
); );
}); });
@ -469,7 +498,7 @@ describe('Resolution-based completions', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.last().json); vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.last().json);
expect(completions('{{ $input.last().| }}')).toHaveLength( expect(completions('{{ $input.last().| }}')).toHaveLength(
Object.keys($input.last().json).length + extensions('object').length, Object.keys($input.last().json).length + extensions({ typeName: 'object' }).length,
); );
}); });
@ -477,7 +506,7 @@ describe('Resolution-based completions', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.all()[0].json); vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.all()[0].json);
expect(completions('{{ $input.all()[0].| }}')).toHaveLength( expect(completions('{{ $input.all()[0].| }}')).toHaveLength(
Object.keys($input.all()[0].json).length + extensions('object').length, Object.keys($input.all()[0].json).length + extensions({ typeName: 'object' }).length,
); );
}); });
@ -485,7 +514,9 @@ describe('Resolution-based completions', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.item.json.str); vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.item.json.str);
expect(completions('{{ $input.item.json.str.| }}')).toHaveLength( expect(completions('{{ $input.item.json.str.| }}')).toHaveLength(
extensions('string').length + natives('string').length + STRING_RECOMMENDED_OPTIONS.length, extensions({ typeName: 'string' }).length +
natives({ typeName: 'string' }).length +
STRING_RECOMMENDED_OPTIONS.length,
); );
}); });
@ -493,7 +524,9 @@ describe('Resolution-based completions', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.item.json.num); vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.item.json.num);
expect(completions('{{ $input.item.json.num.| }}')).toHaveLength( expect(completions('{{ $input.item.json.num.| }}')).toHaveLength(
extensions('number').length + natives('number').length + ['isEven()', 'isOdd()'].length, extensions({ typeName: 'number' }).length +
natives({ typeName: 'number' }).length +
['isEven()', 'isOdd()'].length,
); );
}); });
@ -501,7 +534,7 @@ describe('Resolution-based completions', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.item.json.arr); vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.item.json.arr);
expect(completions('{{ $input.item.json.arr.| }}')).toHaveLength( expect(completions('{{ $input.item.json.arr.| }}')).toHaveLength(
extensions('array').length + natives('array').length, extensions({ typeName: 'array' }).length + natives({ typeName: 'array' }).length,
); );
}); });
@ -509,7 +542,7 @@ describe('Resolution-based completions', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.item.json.obj); vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.item.json.obj);
expect(completions('{{ $input.item.json.obj.| }}')).toHaveLength( expect(completions('{{ $input.item.json.obj.| }}')).toHaveLength(
Object.keys($input.item.json.obj).length + extensions('object').length, Object.keys($input.item.json.obj).length + extensions({ typeName: 'object' }).length,
); );
}); });
}); });
@ -591,15 +624,12 @@ describe('Resolution-based completions', () => {
); );
}); });
test('should recommend toInt(),toFloat() for: {{ "5.3".| }}', () => { test('should recommend toNumber() for: {{ "5.3".| }}', () => {
// @ts-expect-error Spied function is mistyped // @ts-expect-error Spied function is mistyped
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce('5.3'); vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce('5.3');
const options = completions('{{ "5.3".| }}'); const options = completions('{{ "5.3".| }}');
expect(options?.[0]).toEqual( expect(options?.[0]).toEqual(
expect.objectContaining({ label: 'toInt()', section: RECOMMENDED_SECTION }), expect.objectContaining({ label: 'toNumber()', section: RECOMMENDED_SECTION }),
);
expect(options?.[1]).toEqual(
expect.objectContaining({ label: 'toFloat()', section: RECOMMENDED_SECTION }),
); );
}); });
@ -659,25 +689,25 @@ describe('Resolution-based completions', () => {
); );
}); });
test('should recommend toDateTime("s") for: {{ (1900062210).| }}', () => { test("should recommend toDateTime('s') for: {{ (1900062210).| }}", () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce( vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(
// @ts-expect-error Spied function is mistyped // @ts-expect-error Spied function is mistyped
1900062210, 1900062210,
); );
const options = completions('{{ (1900062210).| }}'); const options = completions('{{ (1900062210).| }}');
expect(options?.[0]).toEqual( expect(options?.[0]).toEqual(
expect.objectContaining({ label: 'toDateTime("s")', section: RECOMMENDED_SECTION }), expect.objectContaining({ label: "toDateTime('s')", section: RECOMMENDED_SECTION }),
); );
}); });
test('should recommend toDateTime("ms") for: {{ (1900062210000).| }}', () => { test("should recommend toDateTime('ms') for: {{ (1900062210000).| }}", () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce( vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(
// @ts-expect-error Spied function is mistyped // @ts-expect-error Spied function is mistyped
1900062210000, 1900062210000,
); );
const options = completions('{{ (1900062210000).| }}'); const options = completions('{{ (1900062210000).| }}');
expect(options?.[0]).toEqual( expect(options?.[0]).toEqual(
expect.objectContaining({ label: 'toDateTime("ms")', section: RECOMMENDED_SECTION }), expect.objectContaining({ label: "toDateTime('ms')", section: RECOMMENDED_SECTION }),
); );
}); });
@ -714,7 +744,9 @@ describe('Resolution-based completions', () => {
const result = completions('{{ $json.foo| }}', true); const result = completions('{{ $json.foo| }}', true);
expect(result).toHaveLength( expect(result).toHaveLength(
extensions('string').length + natives('string').length + STRING_RECOMMENDED_OPTIONS.length, extensions({ typeName: 'string' }).length +
natives({ typeName: 'string' }).length +
STRING_RECOMMENDED_OPTIONS.length,
); );
}); });
}); });

View file

@ -1,6 +1,7 @@
import type { Completion, CompletionSection } from '@codemirror/autocomplete'; import type { Completion, CompletionSection } from '@codemirror/autocomplete';
import { i18n } from '@/plugins/i18n'; import { i18n } from '@/plugins/i18n';
import { withSectionHeader } from './utils'; import { withSectionHeader } from './utils';
import { createInfoBoxRenderer } from './infoBoxRenderer';
export const FIELDS_SECTION: CompletionSection = withSectionHeader({ export const FIELDS_SECTION: CompletionSection = withSectionHeader({
name: i18n.baseText('codeNodeEditor.completer.section.fields'), name: i18n.baseText('codeNodeEditor.completer.section.fields'),
@ -51,87 +52,234 @@ export const ROOT_DOLLAR_COMPLETIONS: Completion[] = [
{ {
label: '$json', label: '$json',
section: RECOMMENDED_SECTION, section: RECOMMENDED_SECTION,
info: i18n.rootVars.$json, info: createInfoBoxRenderer({
name: '$json',
returnType: 'object',
description: i18n.rootVars.$json,
docURL: 'https://docs.n8n.io/data/data-structure/',
}),
}, },
{ {
label: '$binary', label: '$binary',
section: RECOMMENDED_SECTION, section: RECOMMENDED_SECTION,
info: i18n.rootVars.$binary, info: createInfoBoxRenderer({
name: '$binary',
returnType: 'object',
description: i18n.rootVars.$binary,
}),
}, },
{ {
label: '$now', label: '$now',
section: RECOMMENDED_SECTION, section: RECOMMENDED_SECTION,
info: i18n.rootVars.$now, info: createInfoBoxRenderer({
name: '$now',
returnType: 'DateTime',
description: i18n.rootVars.$now,
}),
}, },
{ {
label: '$if()', label: '$if()',
section: RECOMMENDED_SECTION, section: RECOMMENDED_SECTION,
info: i18n.rootVars.$if, info: createInfoBoxRenderer(
{
name: '$if',
returnType: 'boolean',
description: i18n.rootVars.$if,
args: [
{
name: 'condition',
optional: false,
type: 'boolean',
},
{
name: 'valueIfTrue',
optional: false,
type: 'any',
},
{
name: 'valueIfFalse',
optional: false,
type: 'any',
},
],
},
true,
),
}, },
{ {
label: '$ifEmpty()', label: '$ifEmpty()',
section: RECOMMENDED_SECTION, section: RECOMMENDED_SECTION,
info: i18n.rootVars.$ifEmpty, info: createInfoBoxRenderer(
{
name: '$ifEmpty',
returnType: 'boolean',
description: i18n.rootVars.$ifEmpty,
args: [
{
name: 'value',
optional: false,
type: 'any',
},
{
name: 'valueIfEmpty',
optional: false,
type: 'any',
},
],
},
true,
),
}, },
{ {
label: '$execution', label: '$execution',
section: METADATA_SECTION, section: METADATA_SECTION,
info: i18n.rootVars.$execution, info: createInfoBoxRenderer({
name: '$execution',
returnType: 'object',
description: i18n.rootVars.$execution,
}),
}, },
{ {
label: '$itemIndex', label: '$itemIndex',
section: METADATA_SECTION, section: METADATA_SECTION,
info: i18n.rootVars.$itemIndex, info: createInfoBoxRenderer({
name: '$itemIndex',
returnType: 'number',
description: i18n.rootVars.$itemIndex,
}),
}, },
{ {
label: '$input', label: '$input',
section: METADATA_SECTION, section: METADATA_SECTION,
info: i18n.rootVars.$input, info: createInfoBoxRenderer({
name: '$input',
returnType: 'object',
description: i18n.rootVars.$input,
}),
}, },
{ {
label: '$parameter', label: '$parameter',
section: METADATA_SECTION, section: METADATA_SECTION,
info: i18n.rootVars.$parameter, info: createInfoBoxRenderer({
name: '$parameter',
returnType: 'object',
description: i18n.rootVars.$parameter,
}),
}, },
{ {
label: '$prevNode', label: '$prevNode',
section: METADATA_SECTION, section: METADATA_SECTION,
info: i18n.rootVars.$prevNode, info: createInfoBoxRenderer({
name: '$prevNode',
returnType: 'object',
description: i18n.rootVars.$prevNode,
}),
}, },
{ {
label: '$runIndex', label: '$runIndex',
section: METADATA_SECTION, section: METADATA_SECTION,
info: i18n.rootVars.$runIndex, info: createInfoBoxRenderer({
name: '$runIndex',
returnType: 'number',
description: i18n.rootVars.$runIndex,
}),
}, },
{ {
label: '$today', label: '$today',
section: METADATA_SECTION, section: METADATA_SECTION,
info: i18n.rootVars.$today, info: createInfoBoxRenderer({
name: '$today',
returnType: 'DateTime',
description: i18n.rootVars.$today,
}),
}, },
{ {
label: '$vars', label: '$vars',
section: METADATA_SECTION, section: METADATA_SECTION,
info: i18n.rootVars.$vars, info: createInfoBoxRenderer({
name: '$vars',
returnType: 'object',
description: i18n.rootVars.$vars,
}),
}, },
{ {
label: '$workflow', label: '$workflow',
section: METADATA_SECTION, section: METADATA_SECTION,
info: i18n.rootVars.$workflow, info: createInfoBoxRenderer({
name: '$workflow',
returnType: 'object',
description: i18n.rootVars.$workflow,
}),
}, },
{ {
label: '$jmespath()', label: '$jmespath()',
section: METHODS_SECTION, section: METHODS_SECTION,
info: i18n.rootVars.$jmespath, info: createInfoBoxRenderer(
{
name: '$jmespath',
returnType: 'any',
description: i18n.rootVars.$jmespath,
},
true,
),
}, },
{ {
label: '$max()', label: '$max()',
section: METHODS_SECTION, section: METHODS_SECTION,
info: i18n.rootVars.$max, info: createInfoBoxRenderer(
{
name: '$max',
returnType: 'number',
description: i18n.rootVars.$max,
args: [
{
name: 'number1',
optional: false,
type: 'number',
},
{
name: 'number2',
optional: true,
type: 'number',
},
{
name: 'numberN',
optional: true,
type: 'number',
},
],
},
true,
),
}, },
{ {
label: '$min()', label: '$min()',
section: METHODS_SECTION, section: METHODS_SECTION,
info: i18n.rootVars.$min, info: createInfoBoxRenderer(
{
name: '$min',
returnType: 'number',
description: i18n.rootVars.$min,
args: [
{
name: 'number1',
optional: false,
type: 'number',
},
{
name: 'number2',
optional: true,
type: 'number',
},
{
name: 'numberN',
optional: true,
type: 'number',
},
],
},
true,
),
}, },
{ {
label: '$nodeVersion', label: '$nodeVersion',
@ -148,7 +296,6 @@ export const STRING_RECOMMENDED_OPTIONS = [
'length', 'length',
]; ];
export const DATE_RECOMMENDED_OPTIONS = ['format()', 'minus()', 'plus()', 'extract()'];
export const LUXON_RECOMMENDED_OPTIONS = ['format()', 'minus()', 'plus()', 'diff()', 'extract()']; export const LUXON_RECOMMENDED_OPTIONS = ['format()', 'minus()', 'plus()', 'diff()', 'extract()'];
export const OBJECT_RECOMMENDED_OPTIONS = ['keys()', 'values()', 'isEmpty()', 'hasField()']; export const OBJECT_RECOMMENDED_OPTIONS = ['keys()', 'values()', 'isEmpty()', 'hasField()'];
export const ARRAY_RECOMMENDED_OPTIONS = ['length', 'last()', 'includes()', 'map()', 'filter()']; export const ARRAY_RECOMMENDED_OPTIONS = ['length', 'last()', 'includes()', 'map()', 'filter()'];

View file

@ -1,50 +1,22 @@
import type { IDataObject, DocMetadata, NativeDoc } from 'n8n-workflow';
import { Expression, ExpressionExtensions, NativeMethods, validateFieldType } from 'n8n-workflow';
import { DateTime } from 'luxon';
import { i18n } from '@/plugins/i18n';
import { resolveParameter } from '@/composables/useWorkflowHelpers'; import { resolveParameter } from '@/composables/useWorkflowHelpers';
import { import { VALID_EMAIL_REGEX } from '@/constants';
setRank, import { i18n } from '@/plugins/i18n';
hasNoParams, import { useEnvironmentsStore } from '@/stores/environments.ee.store';
prefixMatch, import { useExternalSecretsStore } from '@/stores/externalSecrets.ee.store';
isAllowedInDotNotation,
isSplitInBatchesAbsent,
longestCommonPrefix,
splitBaseTail,
isPseudoParam,
stripExcessParens,
isCredentialsModalOpen,
applyCompletion,
sortCompletionsAlpha,
hasRequiredArgs,
getDefaultArgs,
insertDefaultArgs,
applyBracketAccessCompletion,
applyBracketAccess,
} from './utils';
import type { import type {
Completion, Completion,
CompletionContext, CompletionContext,
CompletionResult, CompletionResult,
CompletionSection, CompletionSection,
} from '@codemirror/autocomplete'; } from '@codemirror/autocomplete';
import type { import { uniqBy } from 'lodash-es';
AutocompleteInput, import { DateTime } from 'luxon';
AutocompleteOptionType, import type { DocMetadata, IDataObject, NativeDoc } from 'n8n-workflow';
ExtensionTypeName, import { Expression, ExpressionExtensions, NativeMethods, validateFieldType } from 'n8n-workflow';
FnToDoc,
Resolved,
} from './types';
import { sanitizeHtml } from '@/utils/htmlUtils';
import { isFunctionOption } from './typeGuards';
import { luxonInstanceDocs } from './nativesAutocompleteDocs/luxon.instance.docs';
import { luxonStaticDocs } from './nativesAutocompleteDocs/luxon.static.docs';
import { useEnvironmentsStore } from '@/stores/environments.ee.store';
import { useExternalSecretsStore } from '@/stores/externalSecrets.ee.store';
import { import {
ARRAY_NUMBER_ONLY_METHODS, ARRAY_NUMBER_ONLY_METHODS,
ARRAY_RECOMMENDED_OPTIONS, ARRAY_RECOMMENDED_OPTIONS,
DATE_RECOMMENDED_OPTIONS,
FIELDS_SECTION, FIELDS_SECTION,
LUXON_RECOMMENDED_OPTIONS, LUXON_RECOMMENDED_OPTIONS,
LUXON_SECTIONS, LUXON_SECTIONS,
@ -58,8 +30,29 @@ import {
STRING_RECOMMENDED_OPTIONS, STRING_RECOMMENDED_OPTIONS,
STRING_SECTIONS, STRING_SECTIONS,
} from './constants'; } from './constants';
import { VALID_EMAIL_REGEX } from '@/constants'; import { createInfoBoxRenderer } from './infoBoxRenderer';
import { uniqBy } from 'lodash-es'; import { luxonInstanceDocs } from './nativesAutocompleteDocs/luxon.instance.docs';
import { luxonStaticDocs } from './nativesAutocompleteDocs/luxon.static.docs';
import type { AutocompleteInput, ExtensionTypeName, FnToDoc, Resolved } from './types';
import {
applyBracketAccess,
applyBracketAccessCompletion,
applyCompletion,
getDefaultArgs,
hasNoParams,
hasRequiredArgs,
insertDefaultArgs,
isAllowedInDotNotation,
isCredentialsModalOpen,
isPseudoParam,
isSplitInBatchesAbsent,
longestCommonPrefix,
prefixMatch,
setRank,
sortCompletionsAlpha,
splitBaseTail,
stripExcessParens,
} from './utils';
/** /**
* Resolution-based completions offered according to datatype. * Resolution-based completions offered according to datatype.
@ -92,7 +85,7 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul
try { try {
resolved = resolveParameter(`={{ ${base} }}`); resolved = resolveParameter(`={{ ${base} }}`);
} catch { } catch (error) {
return null; return null;
} }
@ -100,7 +93,7 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul
try { try {
options = datatypeOptions({ resolved, base, tail }).map(stripExcessParens(context)); options = datatypeOptions({ resolved, base, tail }).map(stripExcessParens(context));
} catch { } catch (error) {
return null; return null;
} }
} }
@ -163,7 +156,7 @@ function datatypeOptions(input: AutocompleteInput): Completion[] {
return booleanOptions(); return booleanOptions();
} }
if (resolved instanceof DateTime) { if (DateTime.isDateTime(resolved)) {
return luxonOptions(input as AutocompleteInput<DateTime>); return luxonOptions(input as AutocompleteInput<DateTime>);
} }
@ -182,42 +175,61 @@ function datatypeOptions(input: AutocompleteInput): Completion[] {
return []; return [];
} }
export const natives = ( export const natives = ({
typeName: ExtensionTypeName, typeName,
transformLabel: (label: string) => string = (label) => label, transformLabel = (label) => label,
): Completion[] => { }: {
const natives: NativeDoc = NativeMethods.find((ee) => ee.typeName.toLowerCase() === typeName); typeName: ExtensionTypeName;
transformLabel?: (label: string) => string;
}): Completion[] => {
const nativeDocs: NativeDoc = NativeMethods.find((ee) => ee.typeName.toLowerCase() === typeName);
if (!natives) return []; if (!nativeDocs) return [];
const nativeProps = natives.properties const nativeProps = nativeDocs.properties
? toOptions(natives.properties, typeName, 'keyword', false, transformLabel) ? toOptions({
fnToDoc: nativeDocs.properties,
includeHidden: false,
isFunction: false,
transformLabel,
})
: []; : [];
const nativeMethods = toOptions(
natives.functions, const nativeMethods = toOptions({
typeName, fnToDoc: nativeDocs.functions,
'native-function', includeHidden: false,
false, isFunction: true,
transformLabel, transformLabel,
); });
return [...nativeProps, ...nativeMethods]; return [...nativeProps, ...nativeMethods];
}; };
export const extensions = ( export const extensions = ({
typeName: ExtensionTypeName, typeName,
includeHidden = false, includeHidden = false,
transformLabel: (label: string) => string = (label) => label, transformLabel = (label) => label,
) => { }: {
const extensions = ExpressionExtensions.find((ee) => ee.typeName.toLowerCase() === typeName); typeName: ExtensionTypeName;
includeHidden?: boolean;
transformLabel?: (label: string) => string;
}) => {
const expressionExtensions = ExpressionExtensions.find(
(ee) => ee.typeName.toLowerCase() === typeName,
);
if (!extensions) return []; if (!expressionExtensions) return [];
const fnToDoc = Object.entries(extensions.functions).reduce<FnToDoc>((acc, [fnName, fn]) => { const fnToDoc = Object.entries(expressionExtensions.functions).reduce<FnToDoc>(
return { ...acc, [fnName]: { doc: fn.doc } }; (acc, [fnName, fn]) => {
}, {}); // Extension method docs do not have more info than info box, do not show
delete fn.doc?.docURL;
return { ...acc, [fnName]: { doc: fn.doc } };
},
{},
);
return toOptions(fnToDoc, typeName, 'extension-function', includeHidden, transformLabel); return toOptions({ fnToDoc, isFunction: true, includeHidden, transformLabel });
}; };
export const getType = (value: unknown): string => { export const getType = (value: unknown): string => {
@ -238,144 +250,56 @@ export const getDetail = (base: string, value: unknown): string | undefined => {
return type; return type;
}; };
export const toOptions = ( export const toOptions = ({
fnToDoc: FnToDoc, fnToDoc,
typeName: ExtensionTypeName, isFunction = false,
optionType: AutocompleteOptionType = 'native-function',
includeHidden = false, includeHidden = false,
transformLabel: (label: string) => string = (label) => label, transformLabel = (label) => label,
) => { }: {
fnToDoc: FnToDoc;
isFunction?: boolean;
includeHidden?: boolean;
transformLabel?: (label: string) => string;
}) => {
return Object.entries(fnToDoc) return Object.entries(fnToDoc)
.sort((a, b) => a[0].localeCompare(b[0])) .sort((a, b) => a[0].localeCompare(b[0]))
.filter(([, docInfo]) => (docInfo.doc && !docInfo.doc?.hidden) || includeHidden) .filter(([, docInfo]) => Boolean(docInfo.doc && !docInfo.doc?.hidden) || includeHidden)
.map(([fnName, docInfo]) => { .map(([fnName, docInfo]) => {
return createCompletionOption(typeName, fnName, optionType, docInfo, transformLabel); return createCompletionOption({
name: fnName,
doc: docInfo.doc,
isFunction,
transformLabel,
});
}); });
}; };
const createCompletionOption = ( const createCompletionOption = ({
typeName: string, name,
name: string, doc,
optionType: AutocompleteOptionType, isFunction = false,
docInfo: { doc?: DocMetadata | undefined }, transformLabel = (label) => label,
transformLabel: (label: string) => string = (label) => label, }: {
): Completion => { name: string;
const isFunction = isFunctionOption(optionType); doc?: DocMetadata;
isFunction?: boolean;
transformLabel?: (label: string) => string;
}): Completion => {
const label = isFunction ? name + '()' : name; const label = isFunction ? name + '()' : name;
const option: Completion = { const option: Completion = {
label, label,
type: optionType, section: doc?.section,
section: docInfo.doc?.section,
apply: applyCompletion({ apply: applyCompletion({
hasArgs: hasRequiredArgs(docInfo?.doc), hasArgs: hasRequiredArgs(doc),
defaultArgs: getDefaultArgs(docInfo?.doc), defaultArgs: getDefaultArgs(doc),
transformLabel, transformLabel,
}), }),
}; };
option.info = createInfoBoxRenderer(doc, isFunction);
option.info = () => {
const tooltipContainer = document.createElement('div');
tooltipContainer.classList.add('autocomplete-info-container');
if (!docInfo.doc) return null;
const header = isFunctionOption(optionType)
? createFunctionHeader(typeName, docInfo)
: createPropHeader(typeName, docInfo);
header.classList.add('autocomplete-info-header');
tooltipContainer.appendChild(header);
if (docInfo.doc.description) {
const descriptionBody = document.createElement('div');
descriptionBody.classList.add('autocomplete-info-description');
const descriptionText = document.createElement('p');
descriptionText.innerHTML = sanitizeHtml(
docInfo.doc.description.replace(/`(.*?)`/g, '<code>$1</code>'),
);
descriptionBody.appendChild(descriptionText);
if (docInfo.doc.docURL) {
const descriptionLink = document.createElement('a');
descriptionLink.setAttribute('target', '_blank');
descriptionLink.setAttribute('href', docInfo.doc.docURL);
descriptionLink.innerText = i18n.autocompleteUIValues.docLinkLabel || 'Learn more';
descriptionLink.addEventListener('mousedown', (event: MouseEvent) => {
// This will prevent documentation popup closing before click
// event gets to links
event.preventDefault();
});
descriptionLink.classList.add('autocomplete-info-doc-link');
descriptionBody.appendChild(descriptionLink);
}
tooltipContainer.appendChild(descriptionBody);
}
return tooltipContainer;
};
return option; return option;
}; };
const createFunctionHeader = (typeName: string, fn: { doc?: DocMetadata | undefined }) => {
const header = document.createElement('div');
if (fn.doc) {
const typeNameSpan = document.createElement('span');
typeNameSpan.innerHTML = typeName.slice(0, 1).toUpperCase() + typeName.slice(1) + '.';
header.appendChild(typeNameSpan);
const functionNameSpan = document.createElement('span');
functionNameSpan.classList.add('autocomplete-info-name');
functionNameSpan.innerHTML = `${fn.doc.name}`;
header.appendChild(functionNameSpan);
let functionArgs = '(';
if (fn.doc.args) {
functionArgs += fn.doc.args
.map((arg) => {
let argString = `${arg.name}`;
if (arg.type) {
argString += `: ${arg.type}`;
}
return argString;
})
.join(', ');
}
functionArgs += ')';
const argsSpan = document.createElement('span');
argsSpan.classList.add('autocomplete-info-name-args');
argsSpan.innerText = functionArgs;
header.appendChild(argsSpan);
if (fn.doc.returnType) {
const returnTypeSpan = document.createElement('span');
returnTypeSpan.innerHTML = ': ' + fn.doc.returnType;
header.appendChild(returnTypeSpan);
}
}
return header;
};
const createPropHeader = (typeName: string, property: { doc?: DocMetadata | undefined }) => {
const header = document.createElement('div');
if (property.doc) {
const typeNameSpan = document.createElement('span');
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');
propNameSpan.innerText = property.doc.name;
const returnTypeSpan = document.createElement('span');
returnTypeSpan.innerHTML = ': ' + property.doc.returnType;
header.appendChild(typeNameSpan);
header.appendChild(propNameSpan);
header.appendChild(returnTypeSpan);
}
return header;
};
const objectOptions = (input: AutocompleteInput<IDataObject>): Completion[] => { const objectOptions = (input: AutocompleteInput<IDataObject>): Completion[] => {
const { base, resolved, transformLabel = (label) => label } = input; const { base, resolved, transformLabel = (label) => label } = input;
const rank = setRank(['item', 'all', 'first', 'last']); const rank = setRank(['item', 'all', 'first', 'last']);
@ -422,19 +346,16 @@ const objectOptions = (input: AutocompleteInput<IDataObject>): Completion[] => {
const infoKey = [name, key].join('.'); const infoKey = [name, key].join('.');
const infoName = needsBracketAccess ? applyBracketAccess(key) : key; const infoName = needsBracketAccess ? applyBracketAccess(key) : key;
option.info = createCompletionOption( option.info = createCompletionOption({
'', name: infoName,
infoName, doc: {
isFunction ? 'native-function' : 'keyword', name: infoName,
{ returnType: isFunction ? 'any' : getType(resolvedProp),
doc: { description: i18n.proxyVars[infoKey],
name: infoName,
returnType: getType(resolvedProp),
description: i18n.proxyVars[infoKey],
},
}, },
isFunction,
transformLabel, transformLabel,
).info; }).info;
return option; return option;
}); });
@ -448,15 +369,19 @@ const objectOptions = (input: AutocompleteInput<IDataObject>): Completion[] => {
base === 'Math'; base === 'Math';
if (skipObjectExtensions) { if (skipObjectExtensions) {
return sortCompletionsAlpha([...localKeys, ...natives('object')]); return sortCompletionsAlpha([...localKeys, ...natives({ typeName: 'object' })]);
} }
return applySections({ return applySections({
options: sortCompletionsAlpha([...localKeys, ...natives('object'), ...extensions('object')]), options: sortCompletionsAlpha([
...localKeys,
...natives({ typeName: 'object' }),
...extensions({ typeName: 'object' }),
]),
recommended: OBJECT_RECOMMENDED_OPTIONS, recommended: OBJECT_RECOMMENDED_OPTIONS,
recommendedSection: RECOMMENDED_METHODS_SECTION, recommendedSection: RECOMMENDED_METHODS_SECTION,
methodsSection: OTHER_METHODS_SECTION,
propSection: FIELDS_SECTION, propSection: FIELDS_SECTION,
methodsSection: OTHER_METHODS_SECTION,
excludeRecommended: true, excludeRecommended: true,
}); });
}; };
@ -536,14 +461,14 @@ const isUrl = (url: string): boolean => {
const stringOptions = (input: AutocompleteInput<string>): Completion[] => { const stringOptions = (input: AutocompleteInput<string>): Completion[] => {
const { resolved, transformLabel } = input; const { resolved, transformLabel } = input;
const options = sortCompletionsAlpha([ const options = sortCompletionsAlpha([
...natives('string', transformLabel), ...natives({ typeName: 'string', transformLabel }),
...extensions('string', false, transformLabel), ...extensions({ typeName: 'string', includeHidden: false, transformLabel }),
]); ]);
if (validateFieldType('string', resolved, 'number').valid) { if (resolved && validateFieldType('string', resolved, 'number').valid) {
return applySections({ return applySections({
options, options,
recommended: ['toInt()', 'toFloat()'], recommended: ['toNumber()'],
sections: STRING_SECTIONS, sections: STRING_SECTIONS,
}); });
} }
@ -609,15 +534,18 @@ const stringOptions = (input: AutocompleteInput<string>): Completion[] => {
const booleanOptions = (): Completion[] => { const booleanOptions = (): Completion[] => {
return applySections({ return applySections({
options: sortCompletionsAlpha([...natives('boolean'), ...extensions('boolean')]), options: sortCompletionsAlpha([
...natives({ typeName: 'boolean' }),
...extensions({ typeName: 'boolean' }),
]),
}); });
}; };
const numberOptions = (input: AutocompleteInput<number>): Completion[] => { const numberOptions = (input: AutocompleteInput<number>): Completion[] => {
const { resolved, transformLabel } = input; const { resolved, transformLabel } = input;
const options = sortCompletionsAlpha([ const options = sortCompletionsAlpha([
...natives('number', transformLabel), ...natives({ typeName: 'number', transformLabel }),
...extensions('number', false, transformLabel), ...extensions({ typeName: 'number', includeHidden: false, transformLabel }),
]); ]);
const ONLY_INTEGER = ['isEven()', 'isOdd()']; const ONLY_INTEGER = ['isEven()', 'isOdd()'];
@ -630,7 +558,7 @@ const numberOptions = (input: AutocompleteInput<number>): Completion[] => {
if (isPlausableMillisDateTime) { if (isPlausableMillisDateTime) {
return applySections({ return applySections({
options, options,
recommended: [{ label: 'toDateTime()', args: ['ms'] }], recommended: [{ label: 'toDateTime()', args: ["'ms'"] }],
}); });
} }
@ -641,7 +569,7 @@ const numberOptions = (input: AutocompleteInput<number>): Completion[] => {
if (isPlausableSecondsDateTime) { if (isPlausableSecondsDateTime) {
return applySections({ return applySections({
options, options,
recommended: [{ label: 'toDateTime()', args: ['s'] }], recommended: [{ label: 'toDateTime()', args: ["'s'"] }],
}); });
} }
@ -666,22 +594,20 @@ const numberOptions = (input: AutocompleteInput<number>): Completion[] => {
}; };
const dateOptions = (input: AutocompleteInput<Date>): Completion[] => { const dateOptions = (input: AutocompleteInput<Date>): Completion[] => {
return applySections({ const { transformLabel } = input;
options: sortCompletionsAlpha([ return extensions({ typeName: 'date', includeHidden: true, transformLabel }).filter(
...natives('date', input.transformLabel), (ext) => ext.label === 'toDateTime()',
...extensions('date', true, input.transformLabel), );
]),
recommended: DATE_RECOMMENDED_OPTIONS,
});
}; };
const luxonOptions = (input: AutocompleteInput<DateTime>): Completion[] => { const luxonOptions = (input: AutocompleteInput<DateTime>): Completion[] => {
return applySections({ const { transformLabel } = input;
const result = applySections({
options: sortCompletionsAlpha( options: sortCompletionsAlpha(
uniqBy( uniqBy(
[ [
...extensions('date', false, input.transformLabel), ...extensions({ typeName: 'date', includeHidden: false, transformLabel }),
...luxonInstanceOptions(false, input.transformLabel), ...luxonInstanceOptions({ includeHidden: false, transformLabel }),
], ],
(option) => option.label, (option) => option.label,
), ),
@ -689,14 +615,16 @@ const luxonOptions = (input: AutocompleteInput<DateTime>): Completion[] => {
recommended: LUXON_RECOMMENDED_OPTIONS, recommended: LUXON_RECOMMENDED_OPTIONS,
sections: LUXON_SECTIONS, sections: LUXON_SECTIONS,
}); });
return result;
}; };
const arrayOptions = (input: AutocompleteInput<unknown[]>): Completion[] => { const arrayOptions = (input: AutocompleteInput<unknown[]>): Completion[] => {
const { resolved, transformLabel } = input; const { resolved, transformLabel } = input;
const options = applySections({ const options = applySections({
options: sortCompletionsAlpha([ options: sortCompletionsAlpha([
...natives('array', transformLabel), ...natives({ typeName: 'array', transformLabel }),
...extensions('array', false, transformLabel), ...extensions({ typeName: 'array', includeHidden: false, transformLabel }),
]), ]),
recommended: ARRAY_RECOMMENDED_OPTIONS, recommended: ARRAY_RECOMMENDED_OPTIONS,
methodsSection: OTHER_SECTION, methodsSection: OTHER_SECTION,
@ -726,7 +654,8 @@ export const variablesOptions = () => {
const variables = environmentsStore.variables; const variables = environmentsStore.variables;
return variables.map((variable) => return variables.map((variable) =>
createCompletionOption('Object', variable.key, 'keyword', { createCompletionOption({
name: variable.key,
doc: { doc: {
name: variable.key, name: variable.key,
returnType: 'string', returnType: 'string',
@ -756,7 +685,8 @@ export const secretOptions = (base: string) => {
return []; return [];
} }
return Object.entries(resolved).map(([secret, value]) => return Object.entries(resolved).map(([secret, value]) =>
createCompletionOption('', secret, 'keyword', { createCompletionOption({
name: secret,
doc: { doc: {
name: secret, name: secret,
returnType: typeof value, returnType: typeof value,
@ -774,7 +704,8 @@ export const secretProvidersOptions = () => {
const externalSecretsStore = useExternalSecretsStore(); const externalSecretsStore = useExternalSecretsStore();
return Object.keys(externalSecretsStore.secretsAsObject).map((provider) => return Object.keys(externalSecretsStore.secretsAsObject).map((provider) =>
createCompletionOption('Object', provider, 'keyword', { createCompletionOption({
name: provider,
doc: { doc: {
name: provider, name: provider,
returnType: 'object', returnType: 'object',
@ -788,10 +719,13 @@ export const secretProvidersOptions = () => {
/** /**
* Methods and fields defined on a Luxon `DateTime` class instance. * Methods and fields defined on a Luxon `DateTime` class instance.
*/ */
export const luxonInstanceOptions = ( export const luxonInstanceOptions = ({
includeHidden = false, includeHidden = false,
transformLabel: (label: string) => string = (label) => label, transformLabel = (label) => label,
) => { }: {
includeHidden?: boolean;
transformLabel?: (label: string) => string;
} = {}) => {
const SKIP = new Set(['constructor', 'get', 'invalidExplanation', 'invalidReason']); const SKIP = new Set(['constructor', 'get', 'invalidExplanation', 'invalidReason']);
return Object.entries(Object.getOwnPropertyDescriptors(DateTime.prototype)) return Object.entries(Object.getOwnPropertyDescriptors(DateTime.prototype))
@ -799,15 +733,14 @@ export const luxonInstanceOptions = (
.sort(([a], [b]) => a.localeCompare(b)) .sort(([a], [b]) => a.localeCompare(b))
.map(([key, descriptor]) => { .map(([key, descriptor]) => {
const isFunction = typeof descriptor.value === 'function'; const isFunction = typeof descriptor.value === 'function';
const optionType = isFunction ? 'native-function' : 'keyword'; return createLuxonAutocompleteOption({
return createLuxonAutocompleteOption( name: key,
key, isFunction,
optionType, docs: luxonInstanceDocs,
luxonInstanceDocs, translations: i18n.luxonInstance,
i18n.luxonInstance,
includeHidden, includeHidden,
transformLabel, transformLabel,
) as Completion; }) as Completion;
}) })
.filter(Boolean); .filter(Boolean);
}; };
@ -822,33 +755,39 @@ export const luxonStaticOptions = () => {
Object.keys(Object.getOwnPropertyDescriptors(DateTime)) Object.keys(Object.getOwnPropertyDescriptors(DateTime))
.filter((key) => !SKIP.has(key) && !key.includes('_')) .filter((key) => !SKIP.has(key) && !key.includes('_'))
.map((key) => { .map((key) => {
return createLuxonAutocompleteOption( return createLuxonAutocompleteOption({
key, name: key,
'native-function', isFunction: true,
luxonStaticDocs, docs: luxonStaticDocs,
i18n.luxonStatic, translations: i18n.luxonStatic,
) as Completion; }) as Completion;
}) })
.filter(Boolean), .filter(Boolean),
); );
}; };
const createLuxonAutocompleteOption = ( const createLuxonAutocompleteOption = ({
name: string, name,
type: AutocompleteOptionType, docs,
docDefinition: NativeDoc, translations,
translations: Record<string, string | undefined>, isFunction = false,
includeHidden = false, includeHidden = false,
transformLabel: (label: string) => string = (label) => label, transformLabel = (label) => label,
): Completion | null => { }: {
const isFunction = isFunctionOption(type); name: string;
docs: NativeDoc;
translations: Record<string, string | undefined>;
isFunction?: boolean;
includeHidden?: boolean;
transformLabel?: (label: string) => string;
}): Completion | null => {
const label = isFunction ? name + '()' : name; const label = isFunction ? name + '()' : name;
let doc: DocMetadata | undefined; let doc: DocMetadata | undefined;
if (docDefinition.properties && docDefinition.properties.hasOwnProperty(name)) { if (docs.properties && docs.properties.hasOwnProperty(name)) {
doc = docDefinition.properties[name].doc; doc = docs.properties[name].doc;
} else if (docDefinition.functions.hasOwnProperty(name)) { } else if (docs.functions.hasOwnProperty(name)) {
doc = docDefinition.functions[name].doc; doc = docs.functions[name].doc;
} else { } else {
// Use inferred/default values if docs are still not updated // Use inferred/default values if docs are still not updated
// This should happen when our doc specification becomes // This should happen when our doc specification becomes
@ -867,7 +806,6 @@ const createLuxonAutocompleteOption = (
const option: Completion = { const option: Completion = {
label, label,
type,
section: doc?.section, section: doc?.section,
apply: applyCompletion({ apply: applyCompletion({
hasArgs: hasRequiredArgs(doc), hasArgs: hasRequiredArgs(doc),
@ -875,16 +813,13 @@ const createLuxonAutocompleteOption = (
transformLabel, transformLabel,
}), }),
}; };
option.info = createCompletionOption( option.info = createCompletionOption({
'DateTime',
name, name,
type, isFunction,
{ // Add translated description
// Add translated description doc: { ...doc, description: translations[name] } as DocMetadata,
doc: { ...doc, description: translations[name] } as DocMetadata,
},
transformLabel, transformLabel,
).info; }).info;
return option; return option;
}; };
@ -911,8 +846,8 @@ const regexes = {
selectorRef: /\$\(['"][\S\s]+['"]\)\.(.*)/, // $('nodeName'). selectorRef: /\$\(['"][\S\s]+['"]\)\.(.*)/, // $('nodeName').
numberLiteral: /\((\d+)\.?(\d*)\)\.(.*)/, // (123). or (123.4). numberLiteral: /\((\d+)\.?(\d*)\)\.(.*)/, // (123). or (123.4).
singleQuoteStringLiteral: /('.+')\.([^'{\s])*/, // 'abc'. singleQuoteStringLiteral: /('.*')\.([^'{\s])*/, // 'abc'.
doubleQuoteStringLiteral: /(".+")\.([^"{\s])*/, // "abc". doubleQuoteStringLiteral: /(".*")\.([^"{\s])*/, // "abc".
dateLiteral: /\(?new Date\(\(?.*?\)\)?\.(.*)/, // new Date(). or (new Date()). dateLiteral: /\(?new Date\(\(?.*?\)\)?\.(.*)/, // new Date(). or (new Date()).
arrayLiteral: /\(?(\[.*\])\)?\.(.*)/, // [1, 2, 3]. arrayLiteral: /\(?(\[.*\])\)?\.(.*)/, // [1, 2, 3].
indexedAccess: /([^"{\s]+\[.+\])\.(.*)/, // 'abc'[0]. or 'abc'.split('')[0] or similar ones indexedAccess: /([^"{\s]+\[.+\])\.(.*)/, // 'abc'[0]. or 'abc'.split('')[0] or similar ones

View file

@ -14,6 +14,7 @@ import type { Completion, CompletionContext, CompletionResult } from '@codemirro
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 { PREVIOUS_NODES_SECTION, RECOMMENDED_SECTION, ROOT_DOLLAR_COMPLETIONS } from './constants';
import { createInfoBoxRenderer } from './infoBoxRenderer';
/** /**
* Completions offered at the dollar position: `$|` * Completions offered at the dollar position: `$|`
@ -53,9 +54,33 @@ export function dollarOptions(): Completion[] {
if (isInHttpNodePagination()) { if (isInHttpNodePagination()) {
recommendedCompletions = [ recommendedCompletions = [
{ label: '$pageCount', section: RECOMMENDED_SECTION, info: i18n.rootVars.$pageCount }, {
{ label: '$response', section: RECOMMENDED_SECTION, info: i18n.rootVars.$response }, label: '$pageCount',
{ label: '$request', section: RECOMMENDED_SECTION, info: i18n.rootVars.$request }, section: RECOMMENDED_SECTION,
info: createInfoBoxRenderer({
name: '$pageCount',
returnType: 'number',
description: i18n.rootVars.$pageCount,
}),
},
{
label: '$response',
section: RECOMMENDED_SECTION,
info: createInfoBoxRenderer({
name: '$response',
returnType: 'object',
description: i18n.rootVars.$response,
}),
},
{
label: '$request',
section: RECOMMENDED_SECTION,
info: createInfoBoxRenderer({
name: '$request',
returnType: 'object',
description: i18n.rootVars.$request,
}),
},
]; ];
} }
@ -80,12 +105,18 @@ export function dollarOptions(): Completion[] {
if (receivesNoBinaryData()) SKIP.add('$binary'); if (receivesNoBinaryData()) SKIP.add('$binary');
const previousNodesCompletions = autocompletableNodeNames().map((nodeName) => ({ const previousNodesCompletions = autocompletableNodeNames().map((nodeName) => {
label: `$('${escapeMappingString(nodeName)}')`, const label = `$('${escapeMappingString(nodeName)}')`;
type: 'keyword', return {
info: i18n.baseText('codeNodeEditor.completer.$()', { interpolate: { nodeName } }), label,
section: PREVIOUS_NODES_SECTION, info: createInfoBoxRenderer({
})); name: label,
returnType: 'object',
description: i18n.baseText('codeNodeEditor.completer.$()', { interpolate: { nodeName } }),
}),
section: PREVIOUS_NODES_SECTION,
};
});
return recommendedCompletions return recommendedCompletions
.concat(ROOT_DOLLAR_COMPLETIONS) .concat(ROOT_DOLLAR_COMPLETIONS)

View file

@ -0,0 +1,263 @@
import type { DocMetadata, DocMetadataArgument, DocMetadataExample } from 'n8n-workflow';
import { sanitizeHtml } from '@/utils/htmlUtils';
import { i18n } from '@/plugins/i18n';
const renderFunctionHeader = (doc?: DocMetadata) => {
const header = document.createElement('div');
if (doc) {
const functionNameSpan = document.createElement('span');
functionNameSpan.classList.add('autocomplete-info-name');
functionNameSpan.textContent = doc.name;
header.appendChild(functionNameSpan);
const openBracketsSpan = document.createElement('span');
openBracketsSpan.textContent = '(';
header.appendChild(openBracketsSpan);
const argsSpan = document.createElement('span');
doc.args?.forEach((arg, index, array) => {
const optional = arg.optional && !arg.name.endsWith('?');
const argSpan = document.createElement('span');
argSpan.textContent = arg.name;
if (optional) {
argSpan.textContent += '?';
}
if (arg.variadic) {
argSpan.textContent = '...' + argSpan.textContent;
}
argSpan.classList.add('autocomplete-info-arg');
argsSpan.appendChild(argSpan);
if (index !== array.length - 1) {
const separatorSpan = document.createElement('span');
separatorSpan.textContent = ', ';
argsSpan.appendChild(separatorSpan);
} else {
argSpan.textContent += ')';
}
});
header.appendChild(argsSpan);
const preTypeInfo = document.createElement('span');
preTypeInfo.textContent = !doc.args || doc.args.length === 0 ? '): ' : ': ';
header.appendChild(preTypeInfo);
const returnTypeSpan = document.createElement('span');
returnTypeSpan.textContent = doc.returnType;
returnTypeSpan.classList.add('autocomplete-info-return');
header.appendChild(returnTypeSpan);
}
return header;
};
const renderPropHeader = (doc?: DocMetadata) => {
const header = document.createElement('div');
if (doc) {
const propNameSpan = document.createElement('span');
propNameSpan.classList.add('autocomplete-info-name');
propNameSpan.innerText = doc.name;
const returnTypeSpan = document.createElement('span');
returnTypeSpan.textContent = ': ' + doc.returnType;
header.appendChild(propNameSpan);
header.appendChild(returnTypeSpan);
}
return header;
};
const renderDescription = ({
description,
docUrl,
example,
}: {
description: string;
docUrl?: string;
example?: DocMetadataExample;
}) => {
const descriptionBody = document.createElement('div');
descriptionBody.classList.add('autocomplete-info-description');
const descriptionText = document.createElement('p');
const separator = !description.endsWith('.') && docUrl ? '. ' : ' ';
descriptionText.innerHTML = sanitizeHtml(
description.replace(/`(.*?)`/g, '<code>$1</code>') + separator,
);
descriptionBody.appendChild(descriptionText);
if (docUrl) {
const descriptionLink = document.createElement('a');
descriptionLink.setAttribute('target', '_blank');
descriptionLink.setAttribute('href', docUrl);
descriptionLink.innerText =
i18n.autocompleteUIValues.docLinkLabel ?? i18n.baseText('generic.learnMore');
descriptionLink.addEventListener('mousedown', (event: MouseEvent) => {
// This will prevent documentation popup closing before click
// event gets to links
event.preventDefault();
});
descriptionLink.classList.add('autocomplete-info-doc-link');
descriptionText.appendChild(descriptionLink);
}
if (example) {
const renderedExample = renderExample(example);
descriptionBody.appendChild(renderedExample);
}
return descriptionBody;
};
const renderArgs = (args: DocMetadataArgument[]) => {
const argsContainer = document.createElement('div');
argsContainer.classList.add('autocomplete-info-args-container');
const argsTitle = document.createElement('div');
argsTitle.classList.add('autocomplete-info-section-title');
argsTitle.textContent = i18n.baseText('codeNodeEditor.parameters');
argsContainer.appendChild(argsTitle);
const argsList = document.createElement('ul');
argsList.classList.add('autocomplete-info-args');
for (const arg of args.filter((a) => a.name !== '...')) {
const argItem = document.createElement('li');
const argName = document.createElement('span');
argName.classList.add('autocomplete-info-arg-name');
argName.textContent = arg.name.replaceAll('?', '');
const tags = [];
if (arg.type) {
tags.push(arg.type);
}
if (arg.optional || arg.name.endsWith('?')) {
tags.push(i18n.baseText('codeNodeEditor.optional'));
}
if (args.length > 0) {
argName.textContent += ` (${tags.join(', ')})`;
}
if (arg.description) {
argName.textContent += ':';
}
argItem.appendChild(argName);
if (arg.description) {
const argDescription = document.createElement('span');
argDescription.classList.add('autocomplete-info-arg-description');
if (arg.default && !arg.description.toLowerCase().includes('default')) {
const separator = arg.description.endsWith('.') ? ' ' : '. ';
arg.description +=
separator +
i18n.baseText('codeNodeEditor.defaultsTo', {
interpolate: { default: arg.default },
});
}
argDescription.innerHTML = sanitizeHtml(
arg.description.replace(/`(.*?)`/g, '<code>$1</code>'),
);
argItem.appendChild(argDescription);
}
argsList.appendChild(argItem);
}
argsContainer.appendChild(argsList);
return argsContainer;
};
const renderExample = (example: DocMetadataExample) => {
const examplePre = document.createElement('pre');
examplePre.classList.add('autocomplete-info-example');
const exampleCode = document.createElement('code');
examplePre.appendChild(exampleCode);
if (example.description) {
const exampleDescription = document.createElement('span');
exampleDescription.classList.add('autocomplete-info-example-comment');
exampleDescription.textContent = `// ${example.description}\n`;
exampleCode.appendChild(exampleDescription);
}
const exampleExpression = document.createElement('span');
exampleExpression.classList.add('autocomplete-info-example-expr');
exampleExpression.textContent = example.example + '\n';
exampleCode.appendChild(exampleExpression);
if (example.evaluated) {
const exampleEvaluated = document.createElement('span');
exampleEvaluated.classList.add('autocomplete-info-example-comment');
exampleEvaluated.textContent = `// => ${example.evaluated}\n`;
exampleCode.appendChild(exampleEvaluated);
}
return examplePre;
};
const renderExamples = (examples: DocMetadataExample[]) => {
const examplesContainer = document.createElement('div');
examplesContainer.classList.add('autocomplete-info-examples');
const examplesTitle = document.createElement('div');
examplesTitle.classList.add('autocomplete-info-section-title');
examplesTitle.textContent = i18n.baseText('codeNodeEditor.examples');
examplesContainer.appendChild(examplesTitle);
const examplesList = document.createElement('div');
examplesList.classList.add('autocomplete-info-examples-list');
for (const example of examples) {
const renderedExample = renderExample(example);
examplesList.appendChild(renderedExample);
}
examplesContainer.appendChild(examplesList);
return examplesContainer;
};
export const createInfoBoxRenderer =
(doc?: DocMetadata, isFunction = false) =>
() => {
const tooltipContainer = document.createElement('div');
tooltipContainer.setAttribute('tabindex', '-1');
tooltipContainer.setAttribute('title', '');
tooltipContainer.classList.add('autocomplete-info-container');
if (!doc) return null;
const { examples, args } = doc;
const hasArgs = args && args.length > 0;
const hasExamples = examples && examples.length > 0;
const header = isFunction ? renderFunctionHeader(doc) : renderPropHeader(doc);
header.classList.add('autocomplete-info-header');
tooltipContainer.appendChild(header);
if (doc.description) {
const descriptionBody = renderDescription({
description: doc.description,
docUrl: doc.docURL,
example: hasArgs && hasExamples ? examples[0] : undefined,
});
tooltipContainer.appendChild(descriptionBody);
}
if (hasArgs) {
const argsContainer = renderArgs(args);
tooltipContainer.appendChild(argsContainer);
}
if (hasExamples && (examples.length > 1 || !hasArgs)) {
const examplesContainer = renderExamples(examples);
tooltipContainer.appendChild(examplesContainer);
}
return tooltipContainer;
};

View file

@ -202,6 +202,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
doc: { doc: {
name: 'weekYear', name: 'weekYear',
section: 'query', section: 'query',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeweekyear', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeweekyear',
returnType: 'number', returnType: 'number',
}, },
@ -226,6 +227,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
doc: { doc: {
name: 'zoneName', name: 'zoneName',
section: 'query', section: 'query',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimezonename', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimezonename',
returnType: 'string', returnType: 'string',
}, },
@ -297,7 +299,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
section: 'edit', section: 'edit',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeendof', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeendof',
returnType: 'DateTime', returnType: 'DateTime',
args: [{ name: 'unit', type: 'string' }], args: [{ name: 'unit', type: 'string', default: "'month'" }],
}, },
}, },
equals: { equals: {
@ -395,7 +397,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
section: 'edit', section: 'edit',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimestartof', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimestartof',
returnType: 'DateTime', returnType: 'DateTime',
args: [{ name: 'unit', type: 'string' }], args: [{ name: 'unit', type: 'string', default: "'month'" }],
}, },
}, },
toBSON: { toBSON: {
@ -488,7 +490,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toLocal: { toLocal: {
doc: { doc: {
name: 'toLocal', name: 'toLocal',
section: 'format', section: 'edit',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetolocal', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetolocal',
returnType: 'DateTime', returnType: 'DateTime',
}, },
@ -633,6 +635,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
doc: { doc: {
name: 'until', name: 'until',
section: 'compare', section: 'compare',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeuntil', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeuntil',
returnType: 'Interval', returnType: 'Interval',
args: [{ name: 'other', type: 'DateTime' }], args: [{ name: 'other', type: 'DateTime' }],

View file

@ -1,5 +0,0 @@
import type { AutocompleteOptionType, FunctionOptionType } from './types';
export const isFunctionOption = (value: AutocompleteOptionType): value is FunctionOptionType => {
return value === 'native-function' || value === 'extension-function';
};

View file

@ -164,13 +164,18 @@ export const stripExcessParens = (context: CompletionContext) => (option: Comple
return option; return option;
}; };
export const getDefaultArgs = (doc?: DocMetadata): unknown[] => { export const getDefaultArgs = (doc?: DocMetadata): string[] => {
return doc?.args?.map((arg) => arg.default).filter(Boolean) ?? []; return (
doc?.args
?.filter((arg) => !arg.optional)
.map((arg) => arg.default)
.filter((def): def is string => !!def) ?? []
);
}; };
export const insertDefaultArgs = (label: string, args: unknown[]): string => { export const insertDefaultArgs = (label: string, args: unknown[]): string => {
if (!label.endsWith('()')) return label; if (!label.endsWith('()')) return label;
const argList = args.map((arg) => JSON.stringify(arg)).join(', '); const argList = args.join(', ');
const fnName = label.replace('()', ''); const fnName = label.replace('()', '');
return `${fnName}(${argList})`; return `${fnName}(${argList})`;
@ -239,7 +244,7 @@ export const applyBracketAccessCompletion = (
export const hasRequiredArgs = (doc?: DocMetadata): boolean => { export const hasRequiredArgs = (doc?: DocMetadata): boolean => {
if (!doc) return false; if (!doc) return false;
const requiredArgs = doc?.args?.filter((arg) => !arg.name.endsWith('?')) ?? []; const requiredArgs = doc?.args?.filter((arg) => !arg.name.endsWith('?') && !arg.optional) ?? [];
return requiredArgs.length > 0; return requiredArgs.length > 0;
}; };

View file

@ -25,4 +25,5 @@ export function n8nLang() {
]); ]);
} }
export const n8nAutocompletion = () => autocompletion({ icons: false }); export const n8nAutocompletion = () =>
autocompletion({ icons: false, aboveCursor: true, closeOnBlur: false });

View file

@ -406,6 +406,10 @@
"codeNodeEditor.askAi.generationFailedTooLarge": "Your workflow data is too large for AI to process. Simplify the data being sent into the Code node and retry.", "codeNodeEditor.askAi.generationFailedTooLarge": "Your workflow data is too large for AI to process. Simplify the data being sent into the Code node and retry.",
"codeNodeEditor.tabs.askAi": "✨ Ask AI", "codeNodeEditor.tabs.askAi": "✨ Ask AI",
"codeNodeEditor.tabs.code": "Code", "codeNodeEditor.tabs.code": "Code",
"codeNodeEditor.examples": "Examples",
"codeNodeEditor.parameters": "Parameters",
"codeNodeEditor.optional": "optional",
"codeNodeEditor.defaultsTo": "Defaults to {default}.",
"collectionParameter.choose": "Choose...", "collectionParameter.choose": "Choose...",
"collectionParameter.noProperties": "No properties", "collectionParameter.noProperties": "No properties",
"credentialEdit.credentialConfig.accountConnected": "Account connected", "credentialEdit.credentialConfig.accountConnected": "Account connected",

View file

@ -1,13 +1,17 @@
.cm-tooltip-autocomplete:after {
padding: var(--spacing-2xs) var(--spacing-s);
border-top: 1px solid var(--color-foreground-dark);
}
.code-node-editor .cm-tooltip-autocomplete:after { .code-node-editor .cm-tooltip-autocomplete:after {
padding: var(--spacing-2xs) var(--spacing-s);
border: var(--border-base);
border-bottom-left-radius: var(--border-radius-base);
display: block; display: block;
content: 'n8n supports all JavaScript functions, including those not listed.'; content: 'n8n supports all JavaScript functions, including those not listed.';
} }
.code-node-editor .ͼ2 .cm-tooltip-autocomplete > ul[role='listbox'] {
border-bottom: none;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.ͼ2 .cm-tooltip-autocomplete { .ͼ2 .cm-tooltip-autocomplete {
background-color: var(--color-background-xlight) !important; background-color: var(--color-background-xlight) !important;
box-shadow: var(--box-shadow-light); box-shadow: var(--box-shadow-light);
@ -20,12 +24,22 @@
> ul[role='listbox'] { > ul[role='listbox'] {
font-family: var(--font-family-monospace); font-family: var(--font-family-monospace);
max-height: min(220px, 50vh); height: min(250px, 50vh);
width: min(260px, 50vw); max-height: none;
min-width: 100%; max-width: 200px;
max-width: none;
border: var(--border-base); border: var(--border-base);
border-radius: var(--border-radius-base); border-top-left-radius: var(--border-radius-base);
border-bottom-left-radius: var(--border-radius-base);
border-top-right-radius: 0;
border-bottom-right-radius: 0;
&:has(+ .cm-completionInfo-left) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-top-right-radius: var(--border-radius-base);
border-bottom-right-radius: var(--border-radius-base);
}
li[role='option'] { li[role='option'] {
color: var(--color-text-base); color: var(--color-text-base);
@ -83,37 +97,194 @@
.autocomplete-info-container { .autocomplete-info-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: var(--spacing-4xs) 0; gap: var(--spacing-xs);
padding: var(--spacing-xs) 0;
height: 100%;
overflow-y: auto;
} }
.ͼ2 .cm-completionInfo { .ͼ2 .cm-tooltip.cm-completionInfo {
background-color: var(--color-background-xlight); background-color: var(--color-background-xlight);
border: var(--border-base); border: var(--border-base);
margin-left: var(--spacing-5xs); box-shadow: var(--box-shadow-light);
border-radius: var(--border-radius-base); 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); 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
top: 0 !important;
left: 100% !important;
right: auto !important;
max-width: 320px !important;
height: 100%;
a {
color: var(--color-text-dark);
&:hover,
&:active {
text-decoration: underline;
}
}
.autocomplete-info-header { .autocomplete-info-header {
padding: 0 var(--spacing-xs);
color: var(--color-text-base); color: var(--color-text-base);
font-size: var(--font-size-2xs); font-size: var(--font-size-2xs);
font-family: var(--font-family-monospace); font-family: var(--font-family-monospace);
line-height: var(--font-line-height-compact); line-height: var(--font-line-height-compact);
margin-bottom: var(--spacing-2xs);
} }
.autocomplete-info-name { .autocomplete-info-name {
color: var(--color-autocomplete-item-selected); color: var(--color-autocomplete-item-selected);
} }
.autocomplete-info-arg {
color: var(--color-text-base);
font-weight: var(--font-weight-regular);
padding: 0;
display: inline-block;
}
.autocomplete-info-return {
display: inline-block;
}
.autocomplete-info-description { .autocomplete-info-description {
code { padding: 0 var(--spacing-xs);
background-color: var(--color-background-base); color: var(--color-text-base);
padding: 0 2px; 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 { p {
line-height: var(--font-line-height-compact); line-height: var(--font-line-height-loose);
margin-top: 0;
margin-bottom: var(--spacing-4xs);
} }
} }
.autocomplete-info-args {
padding: 0 var(--spacing-xs);
list-style: none;
li {
text-indent: calc(var(--spacing-2xs) * -1);
margin-left: var(--spacing-2xs);
}
li + li {
margin-top: var(--spacing-4xs);
}
}
.autocomplete-info-arg-name {
font-size: var(--font-size-2xs);
color: var(--color-text-base);
}
.autocomplete-info-arg-description {
color: var(--color-text-base);
margin-left: var(--spacing-4xs);
font-size: var(--font-size-2xs);
code {
padding: 0;
color: var(--color-text-base);
font-size: var(--font-size-2xs);
font-family: var(--font-family);
background-color: transparent;
}
}
.autocomplete-info-examples {
pre {
line-height: 1;
}
code {
background: inherit;
}
}
.autocomplete-info-examples-list {
margin: var(--spacing-xs) var(--spacing-xs) 0 var(--spacing-xs);
border-radius: var(--border-radius-base);
border: 1px solid var(--color-infobox-examples-border-color);
}
.autocomplete-info-example {
color: var(--color-text-base);
}
.autocomplete-info-example code {
color: var(--color-text-base);
display: block;
padding: var(--spacing-3xs) var(--spacing-2xs);
font-size: 0.688rem; // Equals 11px. Add a new token in _tokens.scss if this size is needed elsewhere
font-family: var(--font-family-monospace);
line-height: var(--font-line-height-compact);
word-break: break-all;
white-space: pre-wrap;
}
.autocomplete-info-example-comment {
color: var(--color-text-light);
}
.autocomplete-info-example + .autocomplete-info-example {
margin-top: var(--spacing-4xs);
}
.autocomplete-info-section-title {
margin: var(--spacing-3xs) 0;
padding: 0 var(--spacing-xs) var(--spacing-3xs) var(--spacing-xs);
border-bottom: 1px solid var(--color-autocomplete-section-header-border);
text-transform: uppercase;
color: var(--color-text-dark);
font-size: var(--font-size-3xs);
font-weight: var(--font-weight-bold);
}
&.cm-completionInfo-left-narrow,
&.cm-completionInfo-right-narrow {
display: none;
}
&.cm-completionInfo-left {
left: auto !important;
right: 100% !important;
border-bottom-right-radius: 0;
border-top-right-radius: 0;
border-top-left-radius: var(--border-radius-base);
border-bottom-left-radius: var(--border-radius-base);
border-left: var(--border-base);
border-right: none;
}
&.cm-completionInfo-right {
background-color: var(--color-infobox-background);
}
} }

View file

@ -1,6 +1,6 @@
import { ExpressionError } from '../errors/expression.error'; import { ExpressionError } from '../errors/expression.error';
import { ExpressionExtensionError } from '../errors/expression-extension.error'; import { ExpressionExtensionError } from '../errors/expression-extension.error';
import type { ExtensionMap } from './Extensions'; import type { Extension, ExtensionMap } from './Extensions';
import { compact as oCompact } from './ObjectExtensions'; import { compact as oCompact } from './ObjectExtensions';
import deepEqual from 'deep-equal'; import deepEqual from 'deep-equal';
@ -511,10 +511,13 @@ toJsonString.doc = {
returnType: 'string', returnType: 'string',
}; };
const removeDuplicates: Extension = unique.bind({});
removeDuplicates.doc = { ...unique.doc, hidden: true };
export const arrayExtensions: ExtensionMap = { export const arrayExtensions: ExtensionMap = {
typeName: 'Array', typeName: 'Array',
functions: { functions: {
removeDuplicates: unique, removeDuplicates,
unique, unique,
first, first,
last, last,

View file

@ -286,7 +286,7 @@ format.doc = {
description: 'Formats a Date in the given structure.', description: 'Formats a Date in the given structure.',
returnType: 'string', returnType: 'string',
section: 'format', section: 'format',
args: [{ name: 'fmt', default: 'yyyy-MM-dd', type: 'TimeFormat' }], args: [{ name: 'fmt', default: "'yyyy-MM-dd'", type: 'TimeFormat' }],
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/dates/#date-format', docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/dates/#date-format',
}; };
@ -304,6 +304,7 @@ isBetween.doc = {
isInLast.doc = { isInLast.doc = {
name: 'isInLast', name: 'isInLast',
hidden: true,
description: 'Checks if a Date is within a given time period. Default unit is `minute`.', description: 'Checks if a Date is within a given time period. Default unit is `minute`.',
section: 'query', section: 'query',
returnType: 'boolean', returnType: 'boolean',
@ -317,7 +318,6 @@ isInLast.doc = {
toDateTime.doc = { toDateTime.doc = {
name: 'toDateTime', name: 'toDateTime',
description: 'Convert a JavaScript Date to a Luxon DateTime.', description: 'Convert a JavaScript Date to a Luxon DateTime.',
section: 'query',
returnType: 'DateTime', returnType: 'DateTime',
hidden: true, hidden: true,
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/dates/#date-toDateTime', docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/dates/#date-toDateTime',

View file

@ -16,6 +16,7 @@ import type { ExpressionKind } from 'ast-types/gen/kinds';
import type { ExpressionChunk, ExpressionCode } from './ExpressionParser'; import type { ExpressionChunk, ExpressionCode } from './ExpressionParser';
import { joinExpression, splitExpression } from './ExpressionParser'; import { joinExpression, splitExpression } from './ExpressionParser';
import { booleanExtensions } from './BooleanExtensions'; import { booleanExtensions } from './BooleanExtensions';
import type { ExtensionMap } from './Extensions';
const EXPRESSION_EXTENDER = 'extend'; const EXPRESSION_EXTENDER = 'extend';
const EXPRESSION_EXTENDER_OPTIONAL = 'extendOptional'; const EXPRESSION_EXTENDER_OPTIONAL = 'extendOptional';
@ -28,7 +29,7 @@ function isNotEmpty(value: unknown) {
return !isEmpty(value); return !isEmpty(value);
} }
export const EXTENSION_OBJECTS = [ export const EXTENSION_OBJECTS: ExtensionMap[] = [
arrayExtensions, arrayExtensions,
dateExtensions, dateExtensions,
numberExtensions, numberExtensions,

View file

@ -12,6 +12,20 @@ export type NativeDoc = {
functions: Record<string, { doc?: DocMetadata }>; functions: Record<string, { doc?: DocMetadata }>;
}; };
export type DocMetadataArgument = {
name: string;
type?: string;
optional?: boolean;
variadic?: boolean;
description?: string;
default?: string;
};
export type DocMetadataExample = {
example: string;
evaluated?: string;
description?: string;
};
export type DocMetadata = { export type DocMetadata = {
name: string; name: string;
returnType: string; returnType: string;
@ -19,6 +33,7 @@ export type DocMetadata = {
section?: string; section?: string;
hidden?: boolean; hidden?: boolean;
aliases?: string[]; aliases?: string[];
args?: Array<{ name: string; type?: string }>; args?: DocMetadataArgument[];
examples?: DocMetadataExample[];
docURL?: string; docURL?: string;
}; };

View file

@ -53,10 +53,16 @@ function toFloat(value: number) {
return value; return value;
} }
type DateTimeFormat = 'ms' | 's' | 'excel'; type DateTimeFormat = 'ms' | 's' | 'us' | 'excel';
function toDateTime(value: number, extraArgs: [DateTimeFormat]) { function toDateTime(value: number, extraArgs: [DateTimeFormat]) {
const [valueFormat = 'ms'] = extraArgs; const [valueFormat = 'ms'] = extraArgs;
if (!['ms', 's', 'us', 'excel'].includes(valueFormat)) {
throw new ExpressionExtensionError(
`Unsupported format '${String(valueFormat)}'. toDateTime() supports 'ms', 's', 'us' and 'excel'.`,
);
}
switch (valueFormat) { switch (valueFormat) {
// Excel format is days since 1900 // Excel format is days since 1900
// There is a bug where 1900 is incorrectly treated as a leap year // There is a bug where 1900 is incorrectly treated as a leap year
@ -70,6 +76,8 @@ function toDateTime(value: number, extraArgs: [DateTimeFormat]) {
} }
case 's': case 's':
return DateTime.fromSeconds(value); return DateTime.fromSeconds(value);
case 'us':
return DateTime.fromMillis(value / 1000);
case 'ms': case 'ms':
default: default:
return DateTime.fromMillis(value); return DateTime.fromMillis(value);
@ -107,7 +115,7 @@ isOdd.doc = {
format.doc = { format.doc = {
name: 'format', name: 'format',
description: description:
'Returns a formatted string of a number based on the given `LanguageCode` and `FormatOptions`. When no arguments are given, transforms the number in a like format `1.234`.', 'Returns a formatted string of a number based on the given `LanguageCode` and `FormatOptions`. When no arguments are given, transforms the number in a format like `1.234`.',
returnType: 'string', returnType: 'string',
args: [ args: [
{ name: 'locales?', type: 'LanguageCode' }, { name: 'locales?', type: 'LanguageCode' },
@ -137,7 +145,7 @@ toBoolean.doc = {
toDateTime.doc = { toDateTime.doc = {
name: 'toDateTime', name: 'toDateTime',
description: description:
"Converts a number to a DateTime. Defaults to milliseconds. Format can be 'ms' (milliseconds), 's' (seconds) or 'excel' (Excel 1900 format).", "Converts a number to a DateTime. Defaults to milliseconds. Format can be 'ms' (milliseconds), 's' (seconds), 'us' (microseconds) or 'excel' (Excel 1900 format).",
section: 'cast', section: 'cast',
returnType: 'DateTime', returnType: 'DateTime',
args: [{ name: 'format?', type: 'string' }], args: [{ name: 'format?', type: 'string' }],

View file

@ -265,6 +265,16 @@ function toFloat(value: string) {
return float; return float;
} }
function toNumber(value: string) {
const num = Number(value.replace(CURRENCY_REGEXP, ''));
if (isNaN(num)) {
throw new ExpressionExtensionError('cannot convert to number');
}
return num;
}
function quote(value: string, extraArgs: string[]) { function quote(value: string, extraArgs: string[]) {
const [quoteChar = '"'] = extraArgs; const [quoteChar = '"'] = extraArgs;
return `${quoteChar}${value return `${quoteChar}${value
@ -405,20 +415,22 @@ function base64Decode(value: string): string {
removeMarkdown.doc = { removeMarkdown.doc = {
name: 'removeMarkdown', name: 'removeMarkdown',
description: 'Removes Markdown formatting from a string.', description: 'Removes any Markdown formatting from the string. Also removes HTML tags.',
section: 'edit', section: 'edit',
returnType: 'string', returnType: 'string',
docURL: docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-removeMarkdown', 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-removeMarkdown',
examples: [{ example: '"*bold*, [link]()".removeMarkdown()', evaluated: '"bold, link"' }],
}; };
removeTags.doc = { removeTags.doc = {
name: 'removeTags', name: 'removeTags',
description: 'Removes tags, such as HTML or XML, from a string.', description: 'Removes tags, such as HTML or XML, from the string.',
section: 'edit', section: 'edit',
returnType: 'string', returnType: 'string',
docURL: docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-removeTags', 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-removeTags',
examples: [{ example: '"<b>bold</b>, <a>link</a>".removeTags()', evaluated: '"bold, link"' }],
}; };
toDate.doc = { toDate.doc = {
@ -432,20 +444,34 @@ toDate.doc = {
toDateTime.doc = { toDateTime.doc = {
name: 'toDateTime', name: 'toDateTime',
description: 'Converts a string to a Luxon DateTime.', description:
'Converts the string to a DateTime. Useful for further transformation. Supported formats for the string are ISO 8601, HTTP, RFC2822, SQL and Unix timestamp in milliseconds. To parse other formats, use <a target="_blank" href=”https://moment.github.io/luxon/api-docs/index.html#datetimefromformat”> <code>DateTime.fromFormat()</code></a>.',
section: 'cast', section: 'cast',
returnType: 'DateTime', returnType: 'DateTime',
docURL: docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-toDateTime', 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-toDateTime',
examples: [
{ example: '"2024-03-29T18:06:31.798+01:00".toDateTime()' },
{ example: '"Fri, 29 Mar 2024 18:08:01 +0100".toDateTime()' },
{ example: '"20240329".toDateTime()' },
{ example: '"1711732132990".toDateTime()' },
],
}; };
toBoolean.doc = { toBoolean.doc = {
name: 'toBoolean', name: 'toBoolean',
description: 'Converts a string to a boolean.', description:
'Converts the string to a boolean value. <code>0</code>, <code>false</code> and <code>no</code> resolve to <code>false</code>, everything else to <code>true</code>. Case-insensitive.',
section: 'cast', section: 'cast',
returnType: 'boolean', returnType: 'boolean',
docURL: docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-toBoolean', 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-toBoolean',
examples: [
{ example: '"true".toBoolean()', evaluated: 'true' },
{ example: '"false".toBoolean()', evaluated: 'false' },
{ example: '"0".toBoolean()', evaluated: 'false' },
{ example: '"hello".toBoolean()', evaluated: 'true' },
],
}; };
toFloat.doc = { toFloat.doc = {
@ -454,6 +480,7 @@ toFloat.doc = {
section: 'cast', section: 'cast',
returnType: 'number', returnType: 'number',
aliases: ['toDecimalNumber'], aliases: ['toDecimalNumber'],
hidden: true,
docURL: docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-toDecimalNumber', 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-toDecimalNumber',
}; };
@ -465,12 +492,15 @@ toInt.doc = {
returnType: 'number', returnType: 'number',
args: [{ name: 'radix?', type: 'number' }], args: [{ name: 'radix?', type: 'number' }],
aliases: ['toWholeNumber'], aliases: ['toWholeNumber'],
hidden: true,
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-toInt', docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-toInt',
}; };
toSentenceCase.doc = { toSentenceCase.doc = {
name: 'toSentenceCase', name: 'toSentenceCase',
description: 'Formats a string to sentence case. Example: "This is a sentence".', description:
'Changes the capitalization of the string to sentence case. The first letter of each sentence is capitalized and all others are lowercased.',
examples: [{ example: '"quick! brown FOX".toSentenceCase()', evaluated: '"Quick! Brown fox"' }],
section: 'case', section: 'case',
returnType: 'string', returnType: 'string',
docURL: docURL:
@ -479,7 +509,9 @@ toSentenceCase.doc = {
toSnakeCase.doc = { toSnakeCase.doc = {
name: 'toSnakeCase', name: 'toSnakeCase',
description: 'Formats a string to snake case. Example: "this_is_snake_case".', description:
'Changes the format of the string to snake case. Spaces and dashes are replaced by <code>_</code>, symbols are removed and all letters are lowercased.',
examples: [{ example: '"quick brown $FOX".toSnakeCase()', evaluated: '"quick_brown_fox"' }],
section: 'case', section: 'case',
returnType: 'string', returnType: 'string',
docURL: docURL:
@ -489,7 +521,8 @@ toSnakeCase.doc = {
toTitleCase.doc = { toTitleCase.doc = {
name: 'toTitleCase', name: 'toTitleCase',
description: description:
'Formats a string to title case. Example: "This Is a Title". Will not change already uppercase letters to prevent losing information from acronyms and trademarks such as iPhone or FAANG.', "Changes the capitalization of the string to title case. The first letter of each word is capitalized and the others left unchanged. Short prepositions and conjunctions aren't capitalized (e.g. 'a', 'the').",
examples: [{ example: '"quick a brown FOX".toTitleCase()', evaluated: '"Quick a Brown Fox"' }],
section: 'case', section: 'case',
returnType: 'string', returnType: 'string',
docURL: docURL:
@ -498,31 +531,60 @@ toTitleCase.doc = {
urlEncode.doc = { urlEncode.doc = {
name: 'urlEncode', name: 'urlEncode',
description: 'Encodes a string to be used/included in a URL.', description:
'Encodes the string so that it can be used in a URL. Spaces and special characters are replaced with codes of the form <code>%XX</code>.',
section: 'edit', section: 'edit',
args: [{ name: 'entireString?', type: 'boolean' }], args: [
{
name: 'allChars',
optional: true,
description:
'Whether to encode characters that are part of the URI syntax (e.g. <code>=</code>, <code>?</code>)',
default: 'false',
type: 'boolean',
},
],
returnType: 'string', returnType: 'string',
docURL: docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-urlEncode', 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-urlEncode',
examples: [
{ example: '"name=Nathan Automat".urlEncode()', evaluated: '"name%3DNathan%20Automat"' },
{ example: '"name=Nathan Automat".urlEncode(true)', evaluated: '"name=Nathan%20Automat"' },
],
}; };
urlDecode.doc = { urlDecode.doc = {
name: 'urlDecode', name: 'urlDecode',
description: description:
'Decodes a URL-encoded string. It decodes any percent-encoded characters in the input string, and replaces them with their original characters.', 'Decodes a URL-encoded string. Replaces any character codes in the form of <code>%XX</code> with their corresponding characters.',
args: [
{
name: 'allChars',
optional: true,
description:
'Whether to decode characters that are part of the URI syntax (e.g. <code>=</code>, <code>?</code>)',
default: 'false',
type: 'boolean',
},
],
section: 'edit', section: 'edit',
returnType: 'string', returnType: 'string',
docURL: docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-urlDecode', 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-urlDecode',
examples: [
{ example: '"name%3DNathan%20Automat".urlDecode()', evaluated: '"name=Nathan Automat"' },
{ example: '"name%3DNathan%20Automat".urlDecode(true)', evaluated: '"name%3DNathan Automat"' },
],
}; };
replaceSpecialChars.doc = { replaceSpecialChars.doc = {
name: 'replaceSpecialChars', name: 'replaceSpecialChars',
description: 'Replaces non-ASCII characters in a string with an ASCII representation.', description: 'Replaces special characters in the string with the closest ASCII character',
section: 'edit', section: 'edit',
returnType: 'string', returnType: 'string',
docURL: docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-replaceSpecialChars', 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-replaceSpecialChars',
examples: [{ example: '"déjà".replaceSpecialChars()', evaluated: '"deja"' }],
}; };
length.doc = { length.doc = {
@ -536,122 +598,199 @@ length.doc = {
isDomain.doc = { isDomain.doc = {
name: 'isDomain', name: 'isDomain',
description: 'Checks if a string is a domain.', description: 'Returns <code>true</code> if a string is a domain.',
section: 'validation', section: 'validation',
returnType: 'boolean', returnType: 'boolean',
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-isDomain', docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-isDomain',
examples: [
{ example: '"n8n.io".isDomain()', evaluated: 'true' },
{ example: '"http://n8n.io".isDomain()', evaluated: 'false' },
{ example: '"hello".isDomain()', evaluated: 'false' },
],
}; };
isEmail.doc = { isEmail.doc = {
name: 'isEmail', name: 'isEmail',
description: 'Checks if a string is an email.', description: 'Returns <code>true</code> if the string is an email.',
section: 'validation', section: 'validation',
returnType: 'boolean', returnType: 'boolean',
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-isEmail', docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-isEmail',
examples: [
{ example: '"me@example.com".isEmail()', evaluated: 'true' },
{ example: '"It\'s me@example.com".isEmail()', evaluated: 'false' },
{ example: '"hello".isEmail()', evaluated: 'false' },
],
}; };
isNumeric.doc = { isNumeric.doc = {
name: 'isNumeric', name: 'isNumeric',
description: 'Checks if a string only contains digits.', description: 'Returns <code>true</code> if the string represents a number.',
section: 'validation', section: 'validation',
returnType: 'boolean', returnType: 'boolean',
docURL: docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-isNumeric', 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-isNumeric',
examples: [
{ example: '"1.2234".isNumeric()', evaluated: 'true' },
{ example: '"hello".isNumeric()', evaluated: 'false' },
{ example: '"123E23".isNumeric()', evaluated: 'true' },
],
}; };
isUrl.doc = { isUrl.doc = {
name: 'isUrl', name: 'isUrl',
description: 'Checks if a string is a valid URL.', description: 'Returns <code>true</code> if a string is a valid URL',
section: 'validation', section: 'validation',
returnType: 'boolean', returnType: 'boolean',
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-isUrl', docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-isUrl',
examples: [
{ example: '"https://n8n.io".isUrl()', evaluated: 'true' },
{ example: '"n8n.io".isUrl()', evaluated: 'false' },
{ example: '"hello".isUrl()', evaluated: 'false' },
],
}; };
isEmpty.doc = { isEmpty.doc = {
name: 'isEmpty', name: 'isEmpty',
description: 'Checks if a string is empty.', description: 'Returns <code>true</code> if the string has no characters.',
section: 'validation', section: 'validation',
returnType: 'boolean', returnType: 'boolean',
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-isEmpty', docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-isEmpty',
examples: [
{ example: '"".isEmpty()', evaluated: 'true' },
{ example: '"hello".isEmpty()', evaluated: 'false' },
],
}; };
isNotEmpty.doc = { isNotEmpty.doc = {
name: 'isNotEmpty', name: 'isNotEmpty',
description: 'Checks if a string has content.', description: 'Returns <code>true</code> if the string has at least one character.',
section: 'validation', section: 'validation',
returnType: 'boolean', returnType: 'boolean',
docURL: docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-isNotEmpty', 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-isNotEmpty',
examples: [
{ example: '"hello".isNotEmpty()', evaluated: 'true' },
{ example: '"".isNotEmpty()', evaluated: 'false' },
],
}; };
extractEmail.doc = { extractEmail.doc = {
name: 'extractEmail', name: 'extractEmail',
description: 'Extracts an email from a string. Returns undefined if none is found.', description:
'Extracts the first email found in the string. Returns <code>undefined</code> if none is found.',
section: 'edit', section: 'edit',
returnType: 'string', returnType: 'string',
docURL: docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-extractEmail', 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-extractEmail',
examples: [
{ example: '"My email is me@example.com".extractEmail()', evaluated: "'me@example.com'" },
],
}; };
extractDomain.doc = { extractDomain.doc = {
name: 'extractDomain', name: 'extractDomain',
description: description:
'Extracts a domain from a string containing a valid URL. Returns undefined if none is found.', 'If the string is an email address or URL, returns its domain (or <code>undefined</code> if nothing found). If the string also contains other content, try using <code>extractEmail()</code> or <code>extractUrl()</code> first.',
section: 'edit', section: 'edit',
returnType: 'string', returnType: 'string',
docURL: docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-extractDomain', 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-extractDomain',
examples: [
{ example: '"me@example.com".extractDomain()', evaluated: "'example.com'" },
{ example: '"http://n8n.io/workflows".extractDomain()', evaluated: "'n8n.io'" },
{
example: '"It\'s me@example.com".extractEmail().extractDomain()',
evaluated: "'example.com'",
},
],
}; };
extractUrl.doc = { extractUrl.doc = {
name: 'extractUrl', name: 'extractUrl',
description: 'Extracts a URL from a string. Returns undefined if none is found.', description:
'Extracts the first URL found in the string. Returns <code>undefined</code> if none is found. Only recognizes full URLs, e.g. those starting with <code>http</code>.',
section: 'edit', section: 'edit',
returnType: 'string', returnType: 'string',
docURL: docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-extractUrl', 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-extractUrl',
examples: [{ example: '"Check out http://n8n.io".extractUrl()', evaluated: "'http://n8n.io'" }],
}; };
extractUrlPath.doc = { extractUrlPath.doc = {
name: 'extractUrlPath', name: 'extractUrlPath',
description: 'Extracts the path from a URL. Returns undefined if none is found.', description:
'Returns the part of a URL after the domain, or <code>undefined</code> if no URL found. If the string also contains other content, try using <code>extractUrl()</code> first.',
section: 'edit', section: 'edit',
returnType: 'string', returnType: 'string',
docURL: docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-extractUrlPath', 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-extractUrlPath',
examples: [
{ example: '"http://n8n.io/workflows".extractUrlPath()', evaluated: "'/workflows'" },
{
example: '"Check out http://n8n.io/workflows".extractUrl().extractUrlPath()',
evaluated: "'/workflows'",
},
],
}; };
hash.doc = { hash.doc = {
name: 'hash', name: 'hash',
description: 'Returns a string hashed with the given algorithm. Default algorithm is `md5`.', description:
'Returns the string hashed with the given algorithm. Defaults to md5 if not specified.',
section: 'edit', section: 'edit',
returnType: 'string', returnType: 'string',
args: [{ name: 'algo?', type: 'Algorithm' }], args: [
{
name: 'algo',
optional: true,
description:
'The hashing algorithm to use. One of <code>md5</code>, <code>base64</code>, <code>sha1</code>, <code>sha224</code>, <code>sha256</code>, <code>sha384</code>, <code>sha512</code>, <code>sha3</code>, <code>ripemd160</code>\n ',
default: '"md5"',
type: 'string',
},
],
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-hash', docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-hash',
examples: [{ example: '"hello".hash()', evaluated: "'5d41402abc4b2a76b9719d911017c592'" }],
}; };
quote.doc = { quote.doc = {
name: 'quote', name: 'quote',
description: 'Returns a string wrapped in the quotation marks. Default quotation is `"`.', description:
'Wraps a string in quotation marks, and escapes any quotation marks already in the string. Useful when constructing JSON, SQL, etc.',
section: 'edit', section: 'edit',
returnType: 'string', returnType: 'string',
args: [{ name: 'mark?', type: 'string' }], args: [
{
name: 'mark',
optional: true,
description: 'The type of quotation mark to use',
default: '"',
type: 'string',
},
],
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-quote', docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-quote',
examples: [{ example: '\'Nathan says "hi"\'.quote()', evaluated: '\'"Nathan says \\"hi\\""\'' }],
}; };
parseJson.doc = { parseJson.doc = {
name: 'parseJson', name: 'parseJson',
description: description:
'Parses a JSON string, constructing the JavaScript value or object described by the string.', "Returns the JavaScript value or object represented by the string, or <code>undefined</code> if the string isn't valid JSON. Single-quoted JSON is not supported.",
section: 'cast', section: 'cast',
returnType: 'any', returnType: 'any',
docURL: docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-parseJson', 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-parseJson',
examples: [
{ example: '\'{"name":"Nathan"}\'.parseJson()', evaluated: '\'{"name":"Nathan"}\'' },
{ example: "\"{'name':'Nathan'}\".parseJson()", evaluated: 'undefined' },
{ example: "'hello'.parseJson()", evaluated: 'undefined' },
],
}; };
base64Encode.doc = { base64Encode.doc = {
name: 'base64Encode', name: 'base64Encode',
description: 'Converts a UTF-8-encoded string to a Base64 string.', description: 'Converts plain text to a base64-encoded string',
examples: [{ example: '"hello".base64Encode()', evaluated: '"aGVsbG8="' }],
section: 'edit', section: 'edit',
returnType: 'string', returnType: 'string',
docURL: docURL:
@ -660,17 +799,29 @@ base64Encode.doc = {
base64Decode.doc = { base64Decode.doc = {
name: 'base64Decode', name: 'base64Decode',
description: 'Converts a Base64 string to a UTF-8 string.', description: 'Converts a base64-encoded string to plain text',
examples: [{ example: '"aGVsbG8=".base64Decode()', evaluated: '"hello"' }],
section: 'edit', section: 'edit',
returnType: 'string', returnType: 'string',
docURL: docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-base64Decode', 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-base64Decode',
}; };
toNumber.doc = {
name: 'toNumber',
description:
"Converts a string representing a number to a number. Errors if the string doesn't start with a valid number.",
section: 'cast',
returnType: 'number',
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-toNumber',
examples: [
{ example: '"123".toNumber()', evaluated: '123' },
{ example: '"1.23E10".toNumber()', evaluated: '12300000000' },
],
};
const toDecimalNumber: Extension = toFloat.bind({}); const toDecimalNumber: Extension = toFloat.bind({});
toDecimalNumber.doc = { ...toFloat.doc, hidden: true };
const toWholeNumber: Extension = toInt.bind({}); const toWholeNumber: Extension = toInt.bind({});
toWholeNumber.doc = { ...toInt.doc, hidden: true };
export const stringExtensions: ExtensionMap = { export const stringExtensions: ExtensionMap = {
typeName: 'String', typeName: 'String',
@ -682,6 +833,7 @@ export const stringExtensions: ExtensionMap = {
toDateTime, toDateTime,
toBoolean, toBoolean,
toDecimalNumber, toDecimalNumber,
toNumber,
toFloat, toFloat,
toInt, toInt,
toWholeNumber, toWholeNumber,

View file

@ -7,4 +7,10 @@ export {
EXTENSION_OBJECTS as ExpressionExtensions, EXTENSION_OBJECTS as ExpressionExtensions,
} from './ExpressionExtension'; } from './ExpressionExtension';
export type { DocMetadata, NativeDoc } from './Extensions'; export type {
DocMetadata,
NativeDoc,
Extension,
DocMetadataArgument,
DocMetadataExample,
} from './Extensions';

View file

@ -53,6 +53,7 @@ export const arrayMethods: NativeDoc = {
findIndex: { findIndex: {
doc: { doc: {
name: 'findIndex', name: 'findIndex',
hidden: true,
description: description:
'Returns the index of the first element in an array that passes the test `fn`. If none are found, -1 is returned.', 'Returns the index of the first element in an array that passes the test `fn`. If none are found, -1 is returned.',
docURL: docURL:
@ -64,6 +65,7 @@ export const arrayMethods: NativeDoc = {
findLast: { findLast: {
doc: { doc: {
name: 'findLast', name: 'findLast',
hidden: true,
description: 'Returns the value of the last element that passes the test `fn`.', description: 'Returns the value of the last element that passes the test `fn`.',
docURL: docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/findLast', 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/findLast',
@ -74,6 +76,7 @@ export const arrayMethods: NativeDoc = {
findLastIndex: { findLastIndex: {
doc: { doc: {
name: 'findLastIndex', name: 'findLastIndex',
hidden: true,
description: description:
'Returns the index of the last element that satisfies the provided testing function. If none are found, -1 is returned.', 'Returns the index of the last element that satisfies the provided testing function. If none are found, -1 is returned.',
docURL: docURL:
@ -183,6 +186,7 @@ export const arrayMethods: NativeDoc = {
docURL: docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/splice', 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/splice',
returnType: 'Array', returnType: 'Array',
hidden: true,
args: [ args: [
{ name: 'start', type: 'number' }, { name: 'start', type: 'number' },
{ name: 'deleteCount?', type: 'number' }, { name: 'deleteCount?', type: 'number' },
@ -195,11 +199,22 @@ export const arrayMethods: NativeDoc = {
toString: { toString: {
doc: { doc: {
name: 'toString', name: 'toString',
hidden: true,
description: 'Returns a string representing the specified array and its elements.', description: 'Returns a string representing the specified array and its elements.',
docURL: docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/toString', 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/toString',
returnType: 'string', returnType: 'string',
}, },
}, },
toSpliced: {
doc: {
name: 'toSpliced',
description:
'Returns a new array with some elements removed and/or replaced at a given index. <code>toSpliced()</code> is the copying version of the <code>splice()</code> method',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/toSpliced',
returnType: 'Array',
},
},
}, },
}; };

View file

@ -29,11 +29,20 @@ export const numberMethods: NativeDoc = {
toString: { toString: {
doc: { doc: {
name: 'toString', name: 'toString',
description: 'returns a string representing this number value.', description: 'Returns a string representing this number value.',
docURL: docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/toString', 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/toString',
returnType: 'string', returnType: 'string',
}, },
}, },
toLocaleString: {
doc: {
name: 'toLocaleString',
description: 'Returns a string with a language-sensitive representation of this number.',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/toLocaleString',
returnType: 'string',
},
},
}, },
}; };

View file

@ -6,7 +6,8 @@ export const stringMethods: NativeDoc = {
length: { length: {
doc: { doc: {
name: 'length', name: 'length',
description: 'Returns the number of characters in the string.', description: 'The number of characters in the string',
examples: [{ example: '"hello".length', evaluated: '5' }],
section: 'query', section: 'query',
docURL: docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/length', 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/length',
@ -18,74 +19,208 @@ export const stringMethods: NativeDoc = {
concat: { concat: {
doc: { doc: {
name: 'concat', name: 'concat',
description: 'Concatenates the string arguments to the calling string.', description:
'Joins one or more strings onto the end of the base string. Alternatively, use the <code>+</code> operator (see examples).',
examples: [
{ example: "'sea'.concat('food')", evaluated: "'seafood'" },
{ example: "'sea' + 'food'", evaluated: "'seafood'" },
{ example: "'work'.concat('a', 'holic')", evaluated: "'workaholic'" },
],
section: 'edit', section: 'edit',
docURL: docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/concat', 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/concat',
args: [
{
name: 'strings',
optional: false,
variadic: true,
description: 'The strings to append, in order',
type: 'string[]',
},
],
returnType: 'string', returnType: 'string',
}, },
}, },
endsWith: { endsWith: {
doc: { doc: {
name: 'endsWith', name: 'endsWith',
description: 'Checks if a string ends with `searchString`.', description:
'Returns <code>true</code> if the string ends with <code>searchString</code>. Case-sensitive.',
examples: [
{ example: "'team'.endsWith('eam')", evaluated: 'true' },
{ example: "'team'.endsWith('Eam')", evaluated: 'false' },
{
example: "'teaM'.toLowerCase().endsWith('eam')",
evaluated: 'true',
description:
"Returns false if the case doesn't match, so consider using .toLowerCase() first",
},
],
section: 'query', section: 'query',
docURL: docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/endsWith', 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/endsWith',
returnType: 'boolean', returnType: 'boolean',
args: [{ name: 'searchString', type: 'string' }], args: [
{
name: 'searchString',
optional: false,
description: 'The text to check against the end of the base string',
type: 'string',
},
{
name: 'end',
optional: true,
description: 'The end position (index) to start searching from',
type: 'number',
},
],
}, },
}, },
indexOf: { indexOf: {
doc: { doc: {
name: 'indexOf', name: 'indexOf',
description: 'Returns the index of the first occurrence of `searchString`.', description:
'Returns the index (position) of the first occurrence of <code>searchString</code> within the base string, or -1 if not found. Case-sensitive.',
examples: [
{ example: "'steam'.indexOf('tea')", evaluated: '1' },
{ example: "'steam'.indexOf('i')", evaluated: '-1' },
{
example: "'STEAM'.indexOf('tea')",
evaluated: '-1',
description:
"Returns -1 if the case doesn't match, so consider using .toLowerCase() first",
},
{ example: "'STEAM'.toLowerCase().indexOf('tea')", evaluated: '1' },
],
section: 'query', section: 'query',
docURL: docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/indexOf', 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/indexOf',
returnType: 'number', returnType: 'number',
args: [ args: [
{ name: 'searchString', type: 'string' }, {
{ name: 'position?', type: 'number' }, name: 'searchString',
optional: false,
description: 'The text to search for',
type: 'string',
},
{
name: 'start',
optional: true,
description: 'The position (index) to start searching from',
default: '0',
type: 'number',
},
], ],
}, },
}, },
lastIndexOf: { lastIndexOf: {
doc: { doc: {
name: 'lastIndexOf', name: 'lastIndexOf',
description: 'Returns the index of the last occurrence of `searchString`.', description:
'Returns the index (position) of the last occurrence of <code>searchString</code> within the base string, or -1 if not found. Case-sensitive.',
examples: [
{ example: "'canal'.lastIndexOf('a')", evaluated: '3' },
{ example: "'canal'.lastIndexOf('i')", evaluated: '-1' },
{
example: "'CANAL'.lastIndexOf('a')",
evaluated: '-1',
description:
"Returns -1 if the case doesn't match, so consider using .toLowerCase() first",
},
{ example: "'CANAL'.toLowerCase().lastIndexOf('a')", evaluated: '3' },
],
section: 'query', section: 'query',
docURL: docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/lastIndexOf', 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/lastIndexOf',
returnType: 'number', returnType: 'number',
args: [ args: [
{ name: 'searchString', type: 'string' }, {
{ name: 'position?', type: 'number' }, name: 'searchString',
optional: false,
description: 'The text to search for',
type: 'string',
},
{
name: 'end',
optional: true,
description: 'The position (index) to stop searching at',
default: '0',
type: 'number',
},
], ],
}, },
}, },
match: { match: {
doc: { doc: {
name: 'match', name: 'match',
description: 'Retrieves the result of matching a string against a regular expression.', description:
'Matches the string against a <a target="_blank" href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions">regular expression</a>. Returns an array containing the first match, or all matches if the <code>g</code> flag is set in the regular expression. Returns <code>null</code> if no matches are found. \n\nFor checking whether text is present, consider <code>includes()</code> instead.',
examples: [
{
example: '"rock and roll".match(/r[^ ]*/g)',
evaluated: "['rock', 'roll']",
description: "Match all words starting with 'r'",
},
{
example: '"rock and roll".match(/r[^ ]*/)',
evaluated: "['rock']",
description: "Match first word starting with 'r' (no 'g' flag)",
},
{
example: '"ROCK and roll".match(/r[^ ]*/ig)',
evaluated: "['ROCK', 'roll']",
description: "For case-insensitive, add 'i' flag",
},
],
section: 'query', section: 'query',
docURL: docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/match', 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/match',
returnType: 'Array', returnType: 'string[]',
args: [{ name: 'regexp', type: 'string|RegExp' }], args: [
{
name: 'regexp',
optional: false,
description:
'A <a target="_blank" href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions">regular expression</a> with the pattern to look for. Will look for multiple matches if the <code>g</code> flag is present (see examples).',
type: 'RegExp',
},
],
}, },
}, },
includes: { includes: {
doc: { doc: {
name: 'includes', name: 'includes',
description: 'Checks if `searchString` may be found within the calling string.', description:
'Returns <code>true</code> if the string contains the <code>searchString</code>. Case-sensitive.',
section: 'query', section: 'query',
docURL: docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/includes', 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/includes',
returnType: 'boolean', returnType: 'boolean',
args: [ args: [
{ name: 'searchString', type: 'string' }, {
{ name: 'position?', type: 'number' }, name: 'searchString',
optional: false,
description: 'The text to search for',
type: 'string',
},
{
name: 'start',
optional: true,
description: 'The position (index) to start searching from',
default: '0',
type: 'number',
},
],
examples: [
{ example: "'team'.includes('tea')", evaluated: 'true' },
{ example: "'team'.includes('i')", evaluated: 'false' },
{
example: "'team'.includes('Tea')",
evaluated: 'false',
description:
"Returns false if the case doesn't match, so consider using .toLowerCase() first",
},
{ example: "'Team'.toLowerCase().includes('tea')", evaluated: 'true' },
], ],
}, },
}, },
@ -93,54 +228,153 @@ export const stringMethods: NativeDoc = {
doc: { doc: {
name: 'replace', name: 'replace',
description: description:
'Returns a string with matches of a `pattern` replaced by a `replacement`. If `pattern` is a string, only the first occurrence will be replaced.', 'Returns a string with the first occurrence of <code>pattern</code> replaced by <code>replacement</code>. \n\nTo replace all occurrences, use <code>replaceAll()</code> instead.',
section: 'edit', section: 'edit',
docURL: docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace', 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace',
returnType: 'string', returnType: 'string',
args: [ args: [
{ name: 'pattern', type: 'string|RegExp' }, {
{ name: 'replacement', type: 'string' }, name: 'pattern',
optional: false,
description:
'The pattern in the string to replace. Can be a string to match or a <a target="_blank" href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions">regular expression</a>.',
type: 'string|RegExp',
},
{
name: 'replacement',
optional: false,
description: 'The new text to replace with',
type: 'string',
},
],
examples: [
{
example: "'Red or blue or green'.replace('or', 'and')",
evaluated: "'Red and blue or green'",
},
{
example:
'let text = "Mr Blue has a blue house and a blue car";\ntext.replace(/blue/gi, "red");',
evaluated: "'Mr red has a red house and a red car'",
description: 'A global, case-insensitive replacement:',
},
{
example:
'let text = "Mr Blue has a blue house and a blue car";\ntext.replace(/blue|house|car/gi, (t) => t.toUpperCase());',
evaluated: "'Mr BLUE has a BLUE HOUSE and a BLUE CAR'",
description: 'A function to return the replacement text:',
},
], ],
}, },
}, },
replaceAll: { replaceAll: {
doc: { doc: {
name: 'replaceAll', name: 'replaceAll',
description: 'Returns a string with matches of a `pattern` replaced by a `replacement`.', description:
'Returns a string with all occurrences of <code>pattern</code> replaced by <code>replacement</code>',
examples: [
{
example: "'Red or blue or green'.replaceAll('or', 'and')",
evaluated: "'Red and blue and green'",
},
{
example:
"text = 'Mr Blue has a blue car';\ntext.replaceAll(/blue|car/gi, t => t.toUpperCase())",
description:
"Uppercase any occurrences of 'blue' or 'car' (You must include the 'g' flag when using a regex)",
evaluated: "'Mr BLUE has a BLUE CAR'",
},
{
example: 'text.replaceAll(/blue|car/gi, function(x){return x.toUpperCase()})',
evaluated: "'Mr BLUE has a BLUE CAR'",
description: 'Or with traditional function notation:',
},
],
section: 'edit', section: 'edit',
docURL: docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replaceAll', 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replaceAll',
returnType: 'string', returnType: 'string',
args: [ args: [
{ name: 'pattern', type: 'string|RegExp' }, {
{ name: 'replacement', type: 'string' }, name: 'pattern',
optional: false,
description:
'The pattern in the string to replace. Can be a string to match or a <a target="_blank" href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions">regular expression</a>.',
type: 'string|RegExp',
},
{
name: 'replacement',
optional: false,
description:
'The new text to replace with. Can be a string or a function that returns a string (see examples).',
type: 'string|Function',
},
], ],
}, },
}, },
search: { search: {
doc: { doc: {
name: 'search', name: 'search',
description: 'Returns a string that matches `pattern` within the given string.', description:
'Returns the index (position) of the first occurrence of a pattern within the string, or -1 if not found. The pattern is specified using a <a target="_blank" href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions">regular expression</a>. To use text instead, see <code>indexOf()</code>.',
examples: [
{
example: '"Neat n8n node".search(/n[^ ]*/)',
evaluated: '5',
description: "Pos of first word starting with 'n'",
},
{
example: '"Neat n8n node".search(/n[^ ]*/i)',
evaluated: '0',
description:
"Case-insensitive match with 'i'\nPos of first word starting with 'n' or 'N'",
},
],
section: 'query', section: 'query',
docURL: docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/search', 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/search',
returnType: 'string', returnType: 'string',
args: [{ name: 'pattern', type: 'string|RegExp' }], args: [
{
name: 'regexp',
optional: false,
description:
'A <a target="_blank" href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions">regular expression</a> with the pattern to look for',
type: 'RegExp',
},
],
}, },
}, },
slice: { slice: {
doc: { doc: {
name: 'slice', name: 'slice',
description: description:
'Returns a section of a string. `indexEnd` defaults to the length of the string if not given.', 'Extracts a fragment of the string at the given position. For more advanced extraction, see <code>match()</code>.',
examples: [
{ example: "'Hello from n8n'.slice(0, 5)", evaluated: "'Hello'" },
{ example: "'Hello from n8n'.slice(6)", evaluated: "'from n8n'" },
{ example: "'Hello from n8n'.slice(-3)", evaluated: "'n8n'" },
],
section: 'edit', section: 'edit',
docURL: docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/slice', 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/slice',
returnType: 'string', returnType: 'string',
args: [ args: [
{ name: 'indexStart', type: 'number' }, {
{ name: 'indexEnd?', type: 'number' }, name: 'start',
optional: false,
description:
'The position to start from. Positions start at 0. Negative numbers count back from the end of the string.',
type: 'number',
},
{
name: 'end',
optional: true,
description:
'The position to select up to. The character at the end position is not included. Negative numbers select from the end of the string. If omitted, will extract to the end of the string.',
type: 'string',
},
], ],
}, },
}, },
@ -148,28 +382,71 @@ export const stringMethods: NativeDoc = {
doc: { doc: {
name: 'split', name: 'split',
description: description:
'Returns the substrings that result from dividing the given string with `separator`.', "Splits the string into an array of substrings. Each split is made at the <code>separator</code>, and the separator isn't included in the output. \n\nThe opposite of using <code>join()</code> on an array.",
examples: [
{ example: '"wind,fire,water".split(",")', evaluated: "['wind', 'fire', 'water']" },
{ example: '"me and you and her".split("and")', evaluated: "['me ', ' you ', ' her']" },
{
example: '"me? you, and her".split(/[ ,?]+/)',
evaluated: "['me', 'you', 'and', 'her']",
description: "Split one or more of space, comma and '?' using a regular expression",
},
],
section: 'edit', section: 'edit',
docURL: docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/split', 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/split',
returnType: 'Array', returnType: 'string[]',
args: [ args: [
{ name: 'separator', type: 'string|RegExp' }, {
{ name: 'limit?', type: 'number' }, name: 'separator',
optional: true,
description:
'The string (or regular expression) to use for splitting. If omitted, an array with the original string is returned.',
type: 'string',
},
{
name: 'limit',
optional: true,
description:
'The max number of array elements to return. Returns all elements if omitted.',
type: 'number',
},
], ],
}, },
}, },
startsWith: { startsWith: {
doc: { doc: {
name: 'startsWith', name: 'startsWith',
description: 'Checks if the string begins with `searchString`.', description:
'Returns <code>true</code> if the string starts with <code>searchString</code>. Case-sensitive.',
examples: [
{ example: "'team'.startsWith('tea')", evaluated: 'true' },
{ example: "'team'.startsWith('Tea')", evaluated: 'false' },
{
example: "'Team'.toLowerCase().startsWith('tea')",
evaluated: 'true',
description:
"Returns false if the case doesn't match, so consider using .toLowerCase() first",
},
],
section: 'query', section: 'query',
docURL: docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/startsWith', 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/startsWith',
returnType: 'boolean', returnType: 'boolean',
args: [ args: [
{ name: 'searchString', type: 'string' }, {
{ name: 'position?', type: 'number' }, name: 'searchString',
optional: false,
description: 'The text to check against the start of the base string',
type: 'string',
},
{
name: 'start',
optional: true,
description: 'The position (index) to start searching from',
default: '0',
type: 'number',
},
], ],
}, },
}, },
@ -177,31 +454,48 @@ export const stringMethods: NativeDoc = {
doc: { doc: {
name: 'substring', name: 'substring',
description: description:
'Returns the part of the string from the start index up to and excluding the end index, or to the end of the string if no end index is supplied.', 'Extracts a fragment of the string at the given position. For more advanced extraction, see <code>match()</code>.',
examples: [
{ example: "'Hello from n8n'.substring(0, 5)", evaluated: "'Hello'" },
{ example: "'Hello from n8n'.substring(6)", evaluated: "'from n8n'" },
],
section: 'edit', section: 'edit',
docURL: docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/substring', 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/substring',
returnType: 'string', returnType: 'string',
args: [ args: [
{ name: 'indexStart', type: 'number' }, {
{ name: 'indexEnd?', type: 'number' }, name: 'start',
optional: false,
description: 'The position to start from. Positions start at 0.',
type: 'number',
},
{
name: 'end',
optional: true,
description:
'The position to select up to. The character at the end position is not included. If omitted, will extract to the end of the string.',
type: 'string',
},
], ],
}, },
}, },
toLowerCase: { toLowerCase: {
doc: { doc: {
name: 'toLowerCase', name: 'toLowerCase',
description: 'Formats a string to lowercase. Example: "this is lowercase”.', description: 'Converts all letters in the string to lower case',
section: 'case', section: 'case',
docURL: docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/toLowerCase', 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/toLowerCase',
returnType: 'string', returnType: 'string',
examples: [{ example: '"I\'m SHOUTing".toLowerCase()', evaluated: '"i\'m shouting"' }],
}, },
}, },
toUpperCase: { toUpperCase: {
doc: { doc: {
name: 'toUpperCase', name: 'toUpperCase',
description: 'Formats a string to lowercase. Example: "THIS IS UPPERCASE”.', description: 'Converts all letters in the string to upper case (capitals)',
examples: [{ example: '"I\'m not angry".toUpperCase()', evaluated: '"I\'M NOT ANGRY"' }],
section: 'case', section: 'case',
docURL: docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/toUpperCase', 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/toUpperCase',
@ -211,7 +505,9 @@ export const stringMethods: NativeDoc = {
trim: { trim: {
doc: { doc: {
name: 'trim', name: 'trim',
description: 'Removes whitespace from both ends of a string and returns a new string.', description:
'Removes whitespace from both ends of the string. Whitespace includes new lines, tabs, spaces, etc.',
examples: [{ example: "' lonely '.trim()", evaluated: "'lonely'" }],
section: 'edit', section: 'edit',
docURL: docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/Trim', 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/Trim',
@ -221,7 +517,9 @@ export const stringMethods: NativeDoc = {
trimEnd: { trimEnd: {
doc: { doc: {
name: 'trimEnd', name: 'trimEnd',
description: 'Removes whitespace from the end of a string and returns a new string.', description:
'Removes whitespace from the end of a string and returns a new string. Whitespace includes new lines, tabs, spaces, etc.',
examples: [{ example: "' lonely '.trimEnd()", evaluated: "' lonely'" }],
section: 'edit', section: 'edit',
docURL: docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/trimEnd', 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/trimEnd',
@ -231,7 +529,9 @@ export const stringMethods: NativeDoc = {
trimStart: { trimStart: {
doc: { doc: {
name: 'trimStart', name: 'trimStart',
description: 'Removes whitespace from the beginning of a string and returns a new string.', description:
'Removes whitespace from the beginning of a string and returns a new string. Whitespace includes new lines, tabs, spaces, etc.',
examples: [{ example: "' lonely '.trimStart()", evaluated: "'lonely '" }],
section: 'edit', section: 'edit',
docURL: docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/trimStart', 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/trimStart',

View file

@ -51,7 +51,13 @@ export * as ExpressionParser from './Extensions/ExpressionParser';
export { NativeMethods } from './NativeMethods'; export { NativeMethods } from './NativeMethods';
export * from './NodeParameters/FilterParameter'; export * from './NodeParameters/FilterParameter';
export type { DocMetadata, NativeDoc } from './Extensions'; export type {
DocMetadata,
NativeDoc,
DocMetadataArgument,
DocMetadataExample,
Extension,
} from './Extensions';
declare module 'http' { declare module 'http' {
export interface IncomingMessage { export interface IncomingMessage {

View file

@ -77,6 +77,12 @@ describe('Data Transformation Functions', () => {
'2015-05-19T20:00:00.000-04:00', '2015-05-19T20:00:00.000-04:00',
); );
}); });
test('from microseconds', () => {
expect(evaluate('={{ (1704085200000000).toDateTime("us").toISO() }}')).toEqual(
'2024-01-01T00:00:00.000-05:00',
);
});
}); });
describe('toInt', () => { describe('toInt', () => {