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.getters.inlineExpressionEditorInput().should('have.text', '{{ $json.input[0].count }}');
ndv.getters.inlineExpressionEditorInput().type('{esc}');
ndv.getters.parameterExpressionPreview('value').should('include.text', '0');
ndv.getters.inputTbodyCell(1, 0).realHover();

View file

@ -9,6 +9,7 @@
--prim-gray-820: hsl(var(--prim-gray-h), 1%, 18%);
--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-670: hsl(var(--prim-gray-h), 2%, 33%);
--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-alpha-01: hsla(var(--prim-gray-h), 32%, 93%, 0.1);
--prim-gray-40: hsl(var(--prim-gray-h), 40%, 96%);
--prim-gray-30: hsl(var(--prim-gray-h), 40%, 97%);
--prim-gray-10: hsl(var(--prim-gray-h), 47%, 99%);
--prim-gray-30: hsl(var(--prim-gray-h), 43%, 97%);
--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-025: hsla(var(--prim-gray-h), 50%, 100%, 0.25);
--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-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-background: var(--prim-color-alt-a-alpha-025);
--color-invalid-resolvable-foreground: var(--prim-color-alt-c-tint-250);
@ -83,6 +83,8 @@
--color-expression-syntax-example: var(--prim-gray-670);
--color-autocomplete-item-selected: var(--prim-color-secondary-tint-200);
--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
--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-medium: var(--prim-gray-120);
--color-background-base: var(--prim-gray-40);
--color-background-light-base: var(--prim-gray-25);
--color-background-light: var(--prim-gray-10);
--color-background-xlight: var(--prim-gray-0);
@ -105,7 +106,7 @@
--color-sticky-background-7: var(--prim-gray-10);
--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-background: var(--prim-color-alt-a-tint-500);
--color-invalid-resolvable-foreground: var(--prim-color-alt-c);
@ -116,6 +117,8 @@
--color-expression-syntax-example: var(--prim-gray-40);
--color-autocomplete-item-selected: var(--color-secondary);
--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
--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",
maxHeight: '400px',
tooltip: {
maxWidth: '300px',
maxWidth: '250px',
lineHeight: '1.3em',
},
diagnosticButton: {

View file

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

View file

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

View file

@ -130,8 +130,10 @@ describe('Luxon method completions', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(DateTime.now());
expect(completions('{{ $now.| }}')).toHaveLength(
uniqBy(luxonInstanceOptions().concat(extensions('date')), (option) => option.label).length +
LUXON_RECOMMENDED_OPTIONS.length,
uniqBy(
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());
expect(completions('{{ $today.| }}')).toHaveLength(
uniqBy(luxonInstanceOptions().concat(extensions('date')), (option) => option.label).length +
LUXON_RECOMMENDED_OPTIONS.length,
uniqBy(
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');
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$".| }}');
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).| }}', () => {
@ -171,7 +179,9 @@ describe('Resolution-based completions', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(123);
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]);
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');
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', () => {
@ -201,7 +213,7 @@ describe('Resolution-based completions', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(object);
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');
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).| }}');
if (!found) throw new Error('Expected to find completions');
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.|)) }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(DateTime.now());
expect(completions('{{ $now.diff($now.diff($now.|)) }}')).toHaveLength(
uniqBy(luxonInstanceOptions().concat(extensions('date')), (option) => option.label).length +
LUXON_RECOMMENDED_OPTIONS.length,
uniqBy(
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.|) }}');
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. }}', () => {
@ -261,7 +281,9 @@ describe('Resolution-based 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.). }}', () => {
@ -271,7 +293,9 @@ describe('Resolution-based 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');
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('()')));
});
@ -299,7 +325,9 @@ describe('Resolution-based completions', () => {
if (!found) throw new Error('Expected to find completions');
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('()')));
});
@ -311,7 +339,9 @@ describe('Resolution-based 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('()')));
});
});
@ -339,7 +369,6 @@ describe('Resolution-based completions', () => {
{
info: expect.any(Function),
label: provider,
type: 'keyword',
apply: expect.any(Function),
},
]);
@ -363,13 +392,11 @@ describe('Resolution-based completions', () => {
{
info: expect.any(Function),
label: secrets[0],
type: 'keyword',
apply: expect.any(Function),
},
{
info: expect.any(Function),
label: secrets[1],
type: 'keyword',
apply: expect.any(Function),
},
]);
@ -445,7 +472,9 @@ describe('Resolution-based completions', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue([$input.item]);
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);
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);
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);
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);
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);
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);
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);
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);
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
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce('5.3');
const options = completions('{{ "5.3".| }}');
expect(options?.[0]).toEqual(
expect.objectContaining({ label: 'toInt()', section: RECOMMENDED_SECTION }),
);
expect(options?.[1]).toEqual(
expect.objectContaining({ label: 'toFloat()', section: RECOMMENDED_SECTION }),
expect.objectContaining({ label: 'toNumber()', 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(
// @ts-expect-error Spied function is mistyped
1900062210,
);
const options = completions('{{ (1900062210).| }}');
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(
// @ts-expect-error Spied function is mistyped
1900062210000,
);
const options = completions('{{ (1900062210000).| }}');
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);
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 { i18n } from '@/plugins/i18n';
import { withSectionHeader } from './utils';
import { createInfoBoxRenderer } from './infoBoxRenderer';
export const FIELDS_SECTION: CompletionSection = withSectionHeader({
name: i18n.baseText('codeNodeEditor.completer.section.fields'),
@ -51,87 +52,234 @@ export const ROOT_DOLLAR_COMPLETIONS: Completion[] = [
{
label: '$json',
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',
section: RECOMMENDED_SECTION,
info: i18n.rootVars.$binary,
info: createInfoBoxRenderer({
name: '$binary',
returnType: 'object',
description: i18n.rootVars.$binary,
}),
},
{
label: '$now',
section: RECOMMENDED_SECTION,
info: i18n.rootVars.$now,
info: createInfoBoxRenderer({
name: '$now',
returnType: 'DateTime',
description: i18n.rootVars.$now,
}),
},
{
label: '$if()',
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()',
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',
section: METADATA_SECTION,
info: i18n.rootVars.$execution,
info: createInfoBoxRenderer({
name: '$execution',
returnType: 'object',
description: i18n.rootVars.$execution,
}),
},
{
label: '$itemIndex',
section: METADATA_SECTION,
info: i18n.rootVars.$itemIndex,
info: createInfoBoxRenderer({
name: '$itemIndex',
returnType: 'number',
description: i18n.rootVars.$itemIndex,
}),
},
{
label: '$input',
section: METADATA_SECTION,
info: i18n.rootVars.$input,
info: createInfoBoxRenderer({
name: '$input',
returnType: 'object',
description: i18n.rootVars.$input,
}),
},
{
label: '$parameter',
section: METADATA_SECTION,
info: i18n.rootVars.$parameter,
info: createInfoBoxRenderer({
name: '$parameter',
returnType: 'object',
description: i18n.rootVars.$parameter,
}),
},
{
label: '$prevNode',
section: METADATA_SECTION,
info: i18n.rootVars.$prevNode,
info: createInfoBoxRenderer({
name: '$prevNode',
returnType: 'object',
description: i18n.rootVars.$prevNode,
}),
},
{
label: '$runIndex',
section: METADATA_SECTION,
info: i18n.rootVars.$runIndex,
info: createInfoBoxRenderer({
name: '$runIndex',
returnType: 'number',
description: i18n.rootVars.$runIndex,
}),
},
{
label: '$today',
section: METADATA_SECTION,
info: i18n.rootVars.$today,
info: createInfoBoxRenderer({
name: '$today',
returnType: 'DateTime',
description: i18n.rootVars.$today,
}),
},
{
label: '$vars',
section: METADATA_SECTION,
info: i18n.rootVars.$vars,
info: createInfoBoxRenderer({
name: '$vars',
returnType: 'object',
description: i18n.rootVars.$vars,
}),
},
{
label: '$workflow',
section: METADATA_SECTION,
info: i18n.rootVars.$workflow,
info: createInfoBoxRenderer({
name: '$workflow',
returnType: 'object',
description: i18n.rootVars.$workflow,
}),
},
{
label: '$jmespath()',
section: METHODS_SECTION,
info: i18n.rootVars.$jmespath,
info: createInfoBoxRenderer(
{
name: '$jmespath',
returnType: 'any',
description: i18n.rootVars.$jmespath,
},
true,
),
},
{
label: '$max()',
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()',
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',
@ -148,7 +296,6 @@ export const STRING_RECOMMENDED_OPTIONS = [
'length',
];
export const DATE_RECOMMENDED_OPTIONS = ['format()', 'minus()', 'plus()', 'extract()'];
export const LUXON_RECOMMENDED_OPTIONS = ['format()', 'minus()', 'plus()', 'diff()', 'extract()'];
export const OBJECT_RECOMMENDED_OPTIONS = ['keys()', 'values()', 'isEmpty()', 'hasField()'];
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 {
setRank,
hasNoParams,
prefixMatch,
isAllowedInDotNotation,
isSplitInBatchesAbsent,
longestCommonPrefix,
splitBaseTail,
isPseudoParam,
stripExcessParens,
isCredentialsModalOpen,
applyCompletion,
sortCompletionsAlpha,
hasRequiredArgs,
getDefaultArgs,
insertDefaultArgs,
applyBracketAccessCompletion,
applyBracketAccess,
} from './utils';
import { VALID_EMAIL_REGEX } from '@/constants';
import { i18n } from '@/plugins/i18n';
import { useEnvironmentsStore } from '@/stores/environments.ee.store';
import { useExternalSecretsStore } from '@/stores/externalSecrets.ee.store';
import type {
Completion,
CompletionContext,
CompletionResult,
CompletionSection,
} from '@codemirror/autocomplete';
import type {
AutocompleteInput,
AutocompleteOptionType,
ExtensionTypeName,
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 { uniqBy } from 'lodash-es';
import { DateTime } from 'luxon';
import type { DocMetadata, IDataObject, NativeDoc } from 'n8n-workflow';
import { Expression, ExpressionExtensions, NativeMethods, validateFieldType } from 'n8n-workflow';
import {
ARRAY_NUMBER_ONLY_METHODS,
ARRAY_RECOMMENDED_OPTIONS,
DATE_RECOMMENDED_OPTIONS,
FIELDS_SECTION,
LUXON_RECOMMENDED_OPTIONS,
LUXON_SECTIONS,
@ -58,8 +30,29 @@ import {
STRING_RECOMMENDED_OPTIONS,
STRING_SECTIONS,
} from './constants';
import { VALID_EMAIL_REGEX } from '@/constants';
import { uniqBy } from 'lodash-es';
import { createInfoBoxRenderer } from './infoBoxRenderer';
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.
@ -92,7 +85,7 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul
try {
resolved = resolveParameter(`={{ ${base} }}`);
} catch {
} catch (error) {
return null;
}
@ -100,7 +93,7 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul
try {
options = datatypeOptions({ resolved, base, tail }).map(stripExcessParens(context));
} catch {
} catch (error) {
return null;
}
}
@ -163,7 +156,7 @@ function datatypeOptions(input: AutocompleteInput): Completion[] {
return booleanOptions();
}
if (resolved instanceof DateTime) {
if (DateTime.isDateTime(resolved)) {
return luxonOptions(input as AutocompleteInput<DateTime>);
}
@ -182,42 +175,61 @@ function datatypeOptions(input: AutocompleteInput): Completion[] {
return [];
}
export const natives = (
typeName: ExtensionTypeName,
transformLabel: (label: string) => string = (label) => label,
): Completion[] => {
const natives: NativeDoc = NativeMethods.find((ee) => ee.typeName.toLowerCase() === typeName);
export const natives = ({
typeName,
transformLabel = (label) => label,
}: {
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
? toOptions(natives.properties, typeName, 'keyword', false, transformLabel)
const nativeProps = nativeDocs.properties
? toOptions({
fnToDoc: nativeDocs.properties,
includeHidden: false,
isFunction: false,
transformLabel,
})
: [];
const nativeMethods = toOptions(
natives.functions,
typeName,
'native-function',
false,
const nativeMethods = toOptions({
fnToDoc: nativeDocs.functions,
includeHidden: false,
isFunction: true,
transformLabel,
);
});
return [...nativeProps, ...nativeMethods];
};
export const extensions = (
typeName: ExtensionTypeName,
export const extensions = ({
typeName,
includeHidden = false,
transformLabel: (label: string) => string = (label) => label,
) => {
const extensions = ExpressionExtensions.find((ee) => ee.typeName.toLowerCase() === typeName);
transformLabel = (label) => label,
}: {
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]) => {
return { ...acc, [fnName]: { doc: fn.doc } };
}, {});
const fnToDoc = Object.entries(expressionExtensions.functions).reduce<FnToDoc>(
(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 => {
@ -238,144 +250,56 @@ export const getDetail = (base: string, value: unknown): string | undefined => {
return type;
};
export const toOptions = (
fnToDoc: FnToDoc,
typeName: ExtensionTypeName,
optionType: AutocompleteOptionType = 'native-function',
export const toOptions = ({
fnToDoc,
isFunction = 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)
.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]) => {
return createCompletionOption(typeName, fnName, optionType, docInfo, transformLabel);
return createCompletionOption({
name: fnName,
doc: docInfo.doc,
isFunction,
transformLabel,
});
});
};
const createCompletionOption = (
typeName: string,
name: string,
optionType: AutocompleteOptionType,
docInfo: { doc?: DocMetadata | undefined },
transformLabel: (label: string) => string = (label) => label,
): Completion => {
const isFunction = isFunctionOption(optionType);
const createCompletionOption = ({
name,
doc,
isFunction = false,
transformLabel = (label) => label,
}: {
name: string;
doc?: DocMetadata;
isFunction?: boolean;
transformLabel?: (label: string) => string;
}): Completion => {
const label = isFunction ? name + '()' : name;
const option: Completion = {
label,
type: optionType,
section: docInfo.doc?.section,
section: doc?.section,
apply: applyCompletion({
hasArgs: hasRequiredArgs(docInfo?.doc),
defaultArgs: getDefaultArgs(docInfo?.doc),
hasArgs: hasRequiredArgs(doc),
defaultArgs: getDefaultArgs(doc),
transformLabel,
}),
};
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;
};
option.info = createInfoBoxRenderer(doc, isFunction);
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 { base, resolved, transformLabel = (label) => label } = input;
const rank = setRank(['item', 'all', 'first', 'last']);
@ -422,19 +346,16 @@ const objectOptions = (input: AutocompleteInput<IDataObject>): Completion[] => {
const infoKey = [name, key].join('.');
const infoName = needsBracketAccess ? applyBracketAccess(key) : key;
option.info = createCompletionOption(
'',
infoName,
isFunction ? 'native-function' : 'keyword',
{
doc: {
name: infoName,
returnType: getType(resolvedProp),
description: i18n.proxyVars[infoKey],
},
option.info = createCompletionOption({
name: infoName,
doc: {
name: infoName,
returnType: isFunction ? 'any' : getType(resolvedProp),
description: i18n.proxyVars[infoKey],
},
isFunction,
transformLabel,
).info;
}).info;
return option;
});
@ -448,15 +369,19 @@ const objectOptions = (input: AutocompleteInput<IDataObject>): Completion[] => {
base === 'Math';
if (skipObjectExtensions) {
return sortCompletionsAlpha([...localKeys, ...natives('object')]);
return sortCompletionsAlpha([...localKeys, ...natives({ typeName: 'object' })]);
}
return applySections({
options: sortCompletionsAlpha([...localKeys, ...natives('object'), ...extensions('object')]),
options: sortCompletionsAlpha([
...localKeys,
...natives({ typeName: 'object' }),
...extensions({ typeName: 'object' }),
]),
recommended: OBJECT_RECOMMENDED_OPTIONS,
recommendedSection: RECOMMENDED_METHODS_SECTION,
methodsSection: OTHER_METHODS_SECTION,
propSection: FIELDS_SECTION,
methodsSection: OTHER_METHODS_SECTION,
excludeRecommended: true,
});
};
@ -536,14 +461,14 @@ const isUrl = (url: string): boolean => {
const stringOptions = (input: AutocompleteInput<string>): Completion[] => {
const { resolved, transformLabel } = input;
const options = sortCompletionsAlpha([
...natives('string', transformLabel),
...extensions('string', false, transformLabel),
...natives({ typeName: 'string', transformLabel }),
...extensions({ typeName: 'string', includeHidden: false, transformLabel }),
]);
if (validateFieldType('string', resolved, 'number').valid) {
if (resolved && validateFieldType('string', resolved, 'number').valid) {
return applySections({
options,
recommended: ['toInt()', 'toFloat()'],
recommended: ['toNumber()'],
sections: STRING_SECTIONS,
});
}
@ -609,15 +534,18 @@ const stringOptions = (input: AutocompleteInput<string>): Completion[] => {
const booleanOptions = (): Completion[] => {
return applySections({
options: sortCompletionsAlpha([...natives('boolean'), ...extensions('boolean')]),
options: sortCompletionsAlpha([
...natives({ typeName: 'boolean' }),
...extensions({ typeName: 'boolean' }),
]),
});
};
const numberOptions = (input: AutocompleteInput<number>): Completion[] => {
const { resolved, transformLabel } = input;
const options = sortCompletionsAlpha([
...natives('number', transformLabel),
...extensions('number', false, transformLabel),
...natives({ typeName: 'number', transformLabel }),
...extensions({ typeName: 'number', includeHidden: false, transformLabel }),
]);
const ONLY_INTEGER = ['isEven()', 'isOdd()'];
@ -630,7 +558,7 @@ const numberOptions = (input: AutocompleteInput<number>): Completion[] => {
if (isPlausableMillisDateTime) {
return applySections({
options,
recommended: [{ label: 'toDateTime()', args: ['ms'] }],
recommended: [{ label: 'toDateTime()', args: ["'ms'"] }],
});
}
@ -641,7 +569,7 @@ const numberOptions = (input: AutocompleteInput<number>): Completion[] => {
if (isPlausableSecondsDateTime) {
return applySections({
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[] => {
return applySections({
options: sortCompletionsAlpha([
...natives('date', input.transformLabel),
...extensions('date', true, input.transformLabel),
]),
recommended: DATE_RECOMMENDED_OPTIONS,
});
const { transformLabel } = input;
return extensions({ typeName: 'date', includeHidden: true, transformLabel }).filter(
(ext) => ext.label === 'toDateTime()',
);
};
const luxonOptions = (input: AutocompleteInput<DateTime>): Completion[] => {
return applySections({
const { transformLabel } = input;
const result = applySections({
options: sortCompletionsAlpha(
uniqBy(
[
...extensions('date', false, input.transformLabel),
...luxonInstanceOptions(false, input.transformLabel),
...extensions({ typeName: 'date', includeHidden: false, transformLabel }),
...luxonInstanceOptions({ includeHidden: false, transformLabel }),
],
(option) => option.label,
),
@ -689,14 +615,16 @@ const luxonOptions = (input: AutocompleteInput<DateTime>): Completion[] => {
recommended: LUXON_RECOMMENDED_OPTIONS,
sections: LUXON_SECTIONS,
});
return result;
};
const arrayOptions = (input: AutocompleteInput<unknown[]>): Completion[] => {
const { resolved, transformLabel } = input;
const options = applySections({
options: sortCompletionsAlpha([
...natives('array', transformLabel),
...extensions('array', false, transformLabel),
...natives({ typeName: 'array', transformLabel }),
...extensions({ typeName: 'array', includeHidden: false, transformLabel }),
]),
recommended: ARRAY_RECOMMENDED_OPTIONS,
methodsSection: OTHER_SECTION,
@ -726,7 +654,8 @@ export const variablesOptions = () => {
const variables = environmentsStore.variables;
return variables.map((variable) =>
createCompletionOption('Object', variable.key, 'keyword', {
createCompletionOption({
name: variable.key,
doc: {
name: variable.key,
returnType: 'string',
@ -756,7 +685,8 @@ export const secretOptions = (base: string) => {
return [];
}
return Object.entries(resolved).map(([secret, value]) =>
createCompletionOption('', secret, 'keyword', {
createCompletionOption({
name: secret,
doc: {
name: secret,
returnType: typeof value,
@ -774,7 +704,8 @@ export const secretProvidersOptions = () => {
const externalSecretsStore = useExternalSecretsStore();
return Object.keys(externalSecretsStore.secretsAsObject).map((provider) =>
createCompletionOption('Object', provider, 'keyword', {
createCompletionOption({
name: provider,
doc: {
name: provider,
returnType: 'object',
@ -788,10 +719,13 @@ export const secretProvidersOptions = () => {
/**
* Methods and fields defined on a Luxon `DateTime` class instance.
*/
export const luxonInstanceOptions = (
export const luxonInstanceOptions = ({
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']);
return Object.entries(Object.getOwnPropertyDescriptors(DateTime.prototype))
@ -799,15 +733,14 @@ export const luxonInstanceOptions = (
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, descriptor]) => {
const isFunction = typeof descriptor.value === 'function';
const optionType = isFunction ? 'native-function' : 'keyword';
return createLuxonAutocompleteOption(
key,
optionType,
luxonInstanceDocs,
i18n.luxonInstance,
return createLuxonAutocompleteOption({
name: key,
isFunction,
docs: luxonInstanceDocs,
translations: i18n.luxonInstance,
includeHidden,
transformLabel,
) as Completion;
}) as Completion;
})
.filter(Boolean);
};
@ -822,33 +755,39 @@ export const luxonStaticOptions = () => {
Object.keys(Object.getOwnPropertyDescriptors(DateTime))
.filter((key) => !SKIP.has(key) && !key.includes('_'))
.map((key) => {
return createLuxonAutocompleteOption(
key,
'native-function',
luxonStaticDocs,
i18n.luxonStatic,
) as Completion;
return createLuxonAutocompleteOption({
name: key,
isFunction: true,
docs: luxonStaticDocs,
translations: i18n.luxonStatic,
}) as Completion;
})
.filter(Boolean),
);
};
const createLuxonAutocompleteOption = (
name: string,
type: AutocompleteOptionType,
docDefinition: NativeDoc,
translations: Record<string, string | undefined>,
const createLuxonAutocompleteOption = ({
name,
docs,
translations,
isFunction = false,
includeHidden = false,
transformLabel: (label: string) => string = (label) => label,
): Completion | null => {
const isFunction = isFunctionOption(type);
transformLabel = (label) => label,
}: {
name: string;
docs: NativeDoc;
translations: Record<string, string | undefined>;
isFunction?: boolean;
includeHidden?: boolean;
transformLabel?: (label: string) => string;
}): Completion | null => {
const label = isFunction ? name + '()' : name;
let doc: DocMetadata | undefined;
if (docDefinition.properties && docDefinition.properties.hasOwnProperty(name)) {
doc = docDefinition.properties[name].doc;
} else if (docDefinition.functions.hasOwnProperty(name)) {
doc = docDefinition.functions[name].doc;
if (docs.properties && docs.properties.hasOwnProperty(name)) {
doc = docs.properties[name].doc;
} else if (docs.functions.hasOwnProperty(name)) {
doc = docs.functions[name].doc;
} else {
// Use inferred/default values if docs are still not updated
// This should happen when our doc specification becomes
@ -867,7 +806,6 @@ const createLuxonAutocompleteOption = (
const option: Completion = {
label,
type,
section: doc?.section,
apply: applyCompletion({
hasArgs: hasRequiredArgs(doc),
@ -875,16 +813,13 @@ const createLuxonAutocompleteOption = (
transformLabel,
}),
};
option.info = createCompletionOption(
'DateTime',
option.info = createCompletionOption({
name,
type,
{
// Add translated description
doc: { ...doc, description: translations[name] } as DocMetadata,
},
isFunction,
// Add translated description
doc: { ...doc, description: translations[name] } as DocMetadata,
transformLabel,
).info;
}).info;
return option;
};
@ -911,8 +846,8 @@ const regexes = {
selectorRef: /\$\(['"][\S\s]+['"]\)\.(.*)/, // $('nodeName').
numberLiteral: /\((\d+)\.?(\d*)\)\.(.*)/, // (123). or (123.4).
singleQuoteStringLiteral: /('.+')\.([^'{\s])*/, // 'abc'.
doubleQuoteStringLiteral: /(".+")\.([^"{\s])*/, // "abc".
singleQuoteStringLiteral: /('.*')\.([^'{\s])*/, // 'abc'.
doubleQuoteStringLiteral: /(".*")\.([^"{\s])*/, // "abc".
dateLiteral: /\(?new Date\(\(?.*?\)\)?\.(.*)/, // new Date(). or (new Date()).
arrayLiteral: /\(?(\[.*\])\)?\.(.*)/, // [1, 2, 3].
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 { escapeMappingString } from '@/utils/mappingUtils';
import { PREVIOUS_NODES_SECTION, RECOMMENDED_SECTION, ROOT_DOLLAR_COMPLETIONS } from './constants';
import { createInfoBoxRenderer } from './infoBoxRenderer';
/**
* Completions offered at the dollar position: `$|`
@ -53,9 +54,33 @@ export function dollarOptions(): Completion[] {
if (isInHttpNodePagination()) {
recommendedCompletions = [
{ label: '$pageCount', section: RECOMMENDED_SECTION, info: i18n.rootVars.$pageCount },
{ label: '$response', section: RECOMMENDED_SECTION, info: i18n.rootVars.$response },
{ label: '$request', section: RECOMMENDED_SECTION, info: i18n.rootVars.$request },
{
label: '$pageCount',
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');
const previousNodesCompletions = autocompletableNodeNames().map((nodeName) => ({
label: `$('${escapeMappingString(nodeName)}')`,
type: 'keyword',
info: i18n.baseText('codeNodeEditor.completer.$()', { interpolate: { nodeName } }),
section: PREVIOUS_NODES_SECTION,
}));
const previousNodesCompletions = autocompletableNodeNames().map((nodeName) => {
const label = `$('${escapeMappingString(nodeName)}')`;
return {
label,
info: createInfoBoxRenderer({
name: label,
returnType: 'object',
description: i18n.baseText('codeNodeEditor.completer.$()', { interpolate: { nodeName } }),
}),
section: PREVIOUS_NODES_SECTION,
};
});
return recommendedCompletions
.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: {
name: 'weekYear',
section: 'query',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeweekyear',
returnType: 'number',
},
@ -226,6 +227,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
doc: {
name: 'zoneName',
section: 'query',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimezonename',
returnType: 'string',
},
@ -297,7 +299,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
section: 'edit',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeendof',
returnType: 'DateTime',
args: [{ name: 'unit', type: 'string' }],
args: [{ name: 'unit', type: 'string', default: "'month'" }],
},
},
equals: {
@ -395,7 +397,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
section: 'edit',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimestartof',
returnType: 'DateTime',
args: [{ name: 'unit', type: 'string' }],
args: [{ name: 'unit', type: 'string', default: "'month'" }],
},
},
toBSON: {
@ -488,7 +490,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toLocal: {
doc: {
name: 'toLocal',
section: 'format',
section: 'edit',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetolocal',
returnType: 'DateTime',
},
@ -633,6 +635,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
doc: {
name: 'until',
section: 'compare',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeuntil',
returnType: 'Interval',
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;
};
export const getDefaultArgs = (doc?: DocMetadata): unknown[] => {
return doc?.args?.map((arg) => arg.default).filter(Boolean) ?? [];
export const getDefaultArgs = (doc?: DocMetadata): string[] => {
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 => {
if (!label.endsWith('()')) return label;
const argList = args.map((arg) => JSON.stringify(arg)).join(', ');
const argList = args.join(', ');
const fnName = label.replace('()', '');
return `${fnName}(${argList})`;
@ -239,7 +244,7 @@ export const applyBracketAccessCompletion = (
export const hasRequiredArgs = (doc?: DocMetadata): boolean => {
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;
};

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.tabs.askAi": "✨ Ask AI",
"codeNodeEditor.tabs.code": "Code",
"codeNodeEditor.examples": "Examples",
"codeNodeEditor.parameters": "Parameters",
"codeNodeEditor.optional": "optional",
"codeNodeEditor.defaultsTo": "Defaults to {default}.",
"collectionParameter.choose": "Choose...",
"collectionParameter.noProperties": "No properties",
"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 {
padding: var(--spacing-2xs) var(--spacing-s);
border: var(--border-base);
border-bottom-left-radius: var(--border-radius-base);
display: block;
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 {
background-color: var(--color-background-xlight) !important;
box-shadow: var(--box-shadow-light);
@ -20,12 +24,22 @@
> ul[role='listbox'] {
font-family: var(--font-family-monospace);
max-height: min(220px, 50vh);
width: min(260px, 50vw);
min-width: 100%;
max-width: none;
height: min(250px, 50vh);
max-height: none;
max-width: 200px;
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'] {
color: var(--color-text-base);
@ -83,37 +97,194 @@
.autocomplete-info-container {
display: flex;
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);
border: var(--border-base);
margin-left: var(--spacing-5xs);
border-radius: var(--border-radius-base);
box-shadow: var(--box-shadow-light);
clip-path: inset(-12px -12px -12px 0); // Clip box-shadow on the left
border-left: none;
border-bottom-right-radius: var(--border-radius-base);
border-top-right-radius: var(--border-radius-base);
border-top-left-radius: 0;
border-bottom-left-radius: 0;
line-height: var(--font-line-height-loose);
padding: 0;
// Add padding when infobox only contains text
&: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 {
padding: 0 var(--spacing-xs);
color: var(--color-text-base);
font-size: var(--font-size-2xs);
font-family: var(--font-family-monospace);
line-height: var(--font-line-height-compact);
margin-bottom: var(--spacing-2xs);
}
.autocomplete-info-name {
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 {
code {
background-color: var(--color-background-base);
padding: 0 2px;
padding: 0 var(--spacing-xs);
color: var(--color-text-base);
font-size: var(--font-size-2xs);
.autocomplete-info-example {
border-radius: var(--border-radius-base);
border: 1px solid var(--color-infobox-examples-border-color);
color: var(--color-text-base);
margin-top: var(--spacing-xs);
}
code {
padding: 0;
color: var(--color-text-base);
font-size: var(--font-size-2xs);
font-family: var(--font-family);
background-color: transparent;
}
p {
line-height: var(--font-line-height-compact);
margin-top: 0;
margin-bottom: var(--spacing-4xs);
line-height: var(--font-line-height-loose);
}
}
.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 { ExpressionExtensionError } from '../errors/expression-extension.error';
import type { ExtensionMap } from './Extensions';
import type { Extension, ExtensionMap } from './Extensions';
import { compact as oCompact } from './ObjectExtensions';
import deepEqual from 'deep-equal';
@ -511,10 +511,13 @@ toJsonString.doc = {
returnType: 'string',
};
const removeDuplicates: Extension = unique.bind({});
removeDuplicates.doc = { ...unique.doc, hidden: true };
export const arrayExtensions: ExtensionMap = {
typeName: 'Array',
functions: {
removeDuplicates: unique,
removeDuplicates,
unique,
first,
last,

View file

@ -286,7 +286,7 @@ format.doc = {
description: 'Formats a Date in the given structure.',
returnType: 'string',
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',
};
@ -304,6 +304,7 @@ isBetween.doc = {
isInLast.doc = {
name: 'isInLast',
hidden: true,
description: 'Checks if a Date is within a given time period. Default unit is `minute`.',
section: 'query',
returnType: 'boolean',
@ -317,7 +318,6 @@ isInLast.doc = {
toDateTime.doc = {
name: 'toDateTime',
description: 'Convert a JavaScript Date to a Luxon DateTime.',
section: 'query',
returnType: 'DateTime',
hidden: true,
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 { joinExpression, splitExpression } from './ExpressionParser';
import { booleanExtensions } from './BooleanExtensions';
import type { ExtensionMap } from './Extensions';
const EXPRESSION_EXTENDER = 'extend';
const EXPRESSION_EXTENDER_OPTIONAL = 'extendOptional';
@ -28,7 +29,7 @@ function isNotEmpty(value: unknown) {
return !isEmpty(value);
}
export const EXTENSION_OBJECTS = [
export const EXTENSION_OBJECTS: ExtensionMap[] = [
arrayExtensions,
dateExtensions,
numberExtensions,

View file

@ -12,6 +12,20 @@ export type NativeDoc = {
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 = {
name: string;
returnType: string;
@ -19,6 +33,7 @@ export type DocMetadata = {
section?: string;
hidden?: boolean;
aliases?: string[];
args?: Array<{ name: string; type?: string }>;
args?: DocMetadataArgument[];
examples?: DocMetadataExample[];
docURL?: string;
};

View file

@ -53,10 +53,16 @@ function toFloat(value: number) {
return value;
}
type DateTimeFormat = 'ms' | 's' | 'excel';
type DateTimeFormat = 'ms' | 's' | 'us' | 'excel';
function toDateTime(value: number, extraArgs: [DateTimeFormat]) {
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) {
// Excel format is days since 1900
// 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':
return DateTime.fromSeconds(value);
case 'us':
return DateTime.fromMillis(value / 1000);
case 'ms':
default:
return DateTime.fromMillis(value);
@ -107,7 +115,7 @@ isOdd.doc = {
format.doc = {
name: 'format',
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',
args: [
{ name: 'locales?', type: 'LanguageCode' },
@ -137,7 +145,7 @@ toBoolean.doc = {
toDateTime.doc = {
name: 'toDateTime',
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',
returnType: 'DateTime',
args: [{ name: 'format?', type: 'string' }],

View file

@ -265,6 +265,16 @@ function toFloat(value: string) {
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[]) {
const [quoteChar = '"'] = extraArgs;
return `${quoteChar}${value
@ -405,20 +415,22 @@ function base64Decode(value: string): string {
removeMarkdown.doc = {
name: 'removeMarkdown',
description: 'Removes Markdown formatting from a string.',
description: 'Removes any Markdown formatting from the string. Also removes HTML tags.',
section: 'edit',
returnType: 'string',
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-removeMarkdown',
examples: [{ example: '"*bold*, [link]()".removeMarkdown()', evaluated: '"bold, link"' }],
};
removeTags.doc = {
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',
returnType: 'string',
docURL:
'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 = {
@ -432,20 +444,34 @@ toDate.doc = {
toDateTime.doc = {
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',
returnType: 'DateTime',
docURL:
'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 = {
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',
returnType: 'boolean',
docURL:
'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 = {
@ -454,6 +480,7 @@ toFloat.doc = {
section: 'cast',
returnType: 'number',
aliases: ['toDecimalNumber'],
hidden: true,
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-toDecimalNumber',
};
@ -465,12 +492,15 @@ toInt.doc = {
returnType: 'number',
args: [{ name: 'radix?', type: 'number' }],
aliases: ['toWholeNumber'],
hidden: true,
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-toInt',
};
toSentenceCase.doc = {
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',
returnType: 'string',
docURL:
@ -479,7 +509,9 @@ toSentenceCase.doc = {
toSnakeCase.doc = {
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',
returnType: 'string',
docURL:
@ -489,7 +521,8 @@ toSnakeCase.doc = {
toTitleCase.doc = {
name: 'toTitleCase',
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',
returnType: 'string',
docURL:
@ -498,31 +531,60 @@ toTitleCase.doc = {
urlEncode.doc = {
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',
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',
docURL:
'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 = {
name: 'urlDecode',
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',
returnType: 'string',
docURL:
'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 = {
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',
returnType: 'string',
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-replaceSpecialChars',
examples: [{ example: '"déjà".replaceSpecialChars()', evaluated: '"deja"' }],
};
length.doc = {
@ -536,122 +598,199 @@ length.doc = {
isDomain.doc = {
name: 'isDomain',
description: 'Checks if a string is a domain.',
description: 'Returns <code>true</code> if a string is a domain.',
section: 'validation',
returnType: 'boolean',
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 = {
name: 'isEmail',
description: 'Checks if a string is an email.',
description: 'Returns <code>true</code> if the string is an email.',
section: 'validation',
returnType: 'boolean',
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 = {
name: 'isNumeric',
description: 'Checks if a string only contains digits.',
description: 'Returns <code>true</code> if the string represents a number.',
section: 'validation',
returnType: 'boolean',
docURL:
'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 = {
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',
returnType: 'boolean',
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 = {
name: 'isEmpty',
description: 'Checks if a string is empty.',
description: 'Returns <code>true</code> if the string has no characters.',
section: 'validation',
returnType: 'boolean',
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 = {
name: 'isNotEmpty',
description: 'Checks if a string has content.',
description: 'Returns <code>true</code> if the string has at least one character.',
section: 'validation',
returnType: 'boolean',
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-isNotEmpty',
examples: [
{ example: '"hello".isNotEmpty()', evaluated: 'true' },
{ example: '"".isNotEmpty()', evaluated: 'false' },
],
};
extractEmail.doc = {
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',
returnType: 'string',
docURL:
'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 = {
name: 'extractDomain',
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',
returnType: 'string',
docURL:
'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 = {
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',
returnType: 'string',
docURL:
'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 = {
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',
returnType: 'string',
docURL:
'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 = {
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',
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',
examples: [{ example: '"hello".hash()', evaluated: "'5d41402abc4b2a76b9719d911017c592'" }],
};
quote.doc = {
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',
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',
examples: [{ example: '\'Nathan says "hi"\'.quote()', evaluated: '\'"Nathan says \\"hi\\""\'' }],
};
parseJson.doc = {
name: 'parseJson',
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',
returnType: 'any',
docURL:
'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 = {
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',
returnType: 'string',
docURL:
@ -660,17 +799,29 @@ base64Encode.doc = {
base64Decode.doc = {
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',
returnType: 'string',
docURL:
'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({});
toDecimalNumber.doc = { ...toFloat.doc, hidden: true };
const toWholeNumber: Extension = toInt.bind({});
toWholeNumber.doc = { ...toInt.doc, hidden: true };
export const stringExtensions: ExtensionMap = {
typeName: 'String',
@ -682,6 +833,7 @@ export const stringExtensions: ExtensionMap = {
toDateTime,
toBoolean,
toDecimalNumber,
toNumber,
toFloat,
toInt,
toWholeNumber,

View file

@ -7,4 +7,10 @@ export {
EXTENSION_OBJECTS as ExpressionExtensions,
} 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: {
doc: {
name: 'findIndex',
hidden: true,
description:
'Returns the index of the first element in an array that passes the test `fn`. If none are found, -1 is returned.',
docURL:
@ -64,6 +65,7 @@ export const arrayMethods: NativeDoc = {
findLast: {
doc: {
name: 'findLast',
hidden: true,
description: 'Returns the value of the last element that passes the test `fn`.',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/findLast',
@ -74,6 +76,7 @@ export const arrayMethods: NativeDoc = {
findLastIndex: {
doc: {
name: 'findLastIndex',
hidden: true,
description:
'Returns the index of the last element that satisfies the provided testing function. If none are found, -1 is returned.',
docURL:
@ -183,6 +186,7 @@ export const arrayMethods: NativeDoc = {
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/splice',
returnType: 'Array',
hidden: true,
args: [
{ name: 'start', type: 'number' },
{ name: 'deleteCount?', type: 'number' },
@ -195,11 +199,22 @@ export const arrayMethods: NativeDoc = {
toString: {
doc: {
name: 'toString',
hidden: true,
description: 'Returns a string representing the specified array and its elements.',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/toString',
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: {
doc: {
name: 'toString',
description: 'returns a string representing this number value.',
description: 'Returns a string representing this number value.',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/toString',
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: {
doc: {
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',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/length',
@ -18,74 +19,208 @@ export const stringMethods: NativeDoc = {
concat: {
doc: {
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',
docURL:
'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',
},
},
endsWith: {
doc: {
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',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/endsWith',
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: {
doc: {
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',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/indexOf',
returnType: 'number',
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: {
doc: {
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',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/lastIndexOf',
returnType: 'number',
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: {
doc: {
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',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/match',
returnType: 'Array',
args: [{ name: 'regexp', type: 'string|RegExp' }],
returnType: 'string[]',
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: {
doc: {
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',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/includes',
returnType: 'boolean',
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: {
name: 'replace',
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',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace',
returnType: 'string',
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: {
doc: {
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',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replaceAll',
returnType: 'string',
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: {
doc: {
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',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/search',
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: {
doc: {
name: 'slice',
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',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/slice',
returnType: 'string',
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: {
name: 'split',
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',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/split',
returnType: 'Array',
returnType: 'string[]',
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: {
doc: {
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',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/startsWith',
returnType: 'boolean',
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: {
name: 'substring',
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',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/substring',
returnType: 'string',
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: {
doc: {
name: 'toLowerCase',
description: 'Formats a string to lowercase. Example: "this is lowercase”.',
description: 'Converts all letters in the string to lower case',
section: 'case',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/toLowerCase',
returnType: 'string',
examples: [{ example: '"I\'m SHOUTing".toLowerCase()', evaluated: '"i\'m shouting"' }],
},
},
toUpperCase: {
doc: {
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',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/toUpperCase',
@ -211,7 +505,9 @@ export const stringMethods: NativeDoc = {
trim: {
doc: {
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',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/Trim',
@ -221,7 +517,9 @@ export const stringMethods: NativeDoc = {
trimEnd: {
doc: {
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',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/trimEnd',
@ -231,7 +529,9 @@ export const stringMethods: NativeDoc = {
trimStart: {
doc: {
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',
docURL:
'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 * from './NodeParameters/FilterParameter';
export type { DocMetadata, NativeDoc } from './Extensions';
export type {
DocMetadata,
NativeDoc,
DocMetadataArgument,
DocMetadataExample,
Extension,
} from './Extensions';
declare module 'http' {
export interface IncomingMessage {

View file

@ -77,6 +77,12 @@ describe('Data Transformation Functions', () => {
'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', () => {