feat(editor): Add sections to autocomplete dropdown (#8720)

Co-authored-by: Giulio Andreini <andreini@netseven.it>
This commit is contained in:
Elias Meire 2024-03-07 17:01:05 +01:00 committed by GitHub
parent ed6dc86d60
commit 9b4618dd5e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 1308 additions and 468 deletions

View file

@ -195,5 +195,7 @@ function setExpressionOnStringValueInSet(expression: string) {
ndv.getters
.inlineExpressionEditorInput()
.clear()
.type(expression, { parseSpecialCharSequences: false });
.type(expression, { parseSpecialCharSequences: false })
// hide autocomplete
.type('{esc}');
}

View file

@ -69,7 +69,7 @@
--color-sticky-background-7: var(--prim-gray-740);
--color-sticky-border-7: var(--prim-gray-670);
// Expressions
// Expressions and autocomplete
--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);
@ -78,6 +78,8 @@
--color-pending-resolvable-background: var(--prim-gray-70-alpha-01);
--color-expression-editor-background: var(--prim-gray-800);
--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);
// Code
--color-code-tags-string: var(--prim-color-alt-f-tint-150);

View file

@ -102,7 +102,7 @@
--color-sticky-background-7: var(--prim-gray-10);
--color-sticky-border-7: var(--prim-gray-120);
// Expressions
// Expressions and autocomplete
--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);
@ -111,6 +111,8 @@
--color-pending-resolvable-background: var(--prim-gray-40);
--color-expression-editor-background: var(--prim-gray-0);
--color-expression-syntax-example: var(--prim-gray-40);
--color-autocomplete-item-selected: var(--color-secondary);
--color-autocomplete-section-header-border: var(--color-foreground-light);
// Code
--color-code-tags-string: var(--prim-color-alt-f);
@ -138,8 +140,6 @@
--color-code-gutterBackground: var(--prim-gray-0);
--color-code-gutterForeground: var(--prim-gray-320);
--color-code-tags-comment: var(--prim-gray-420);
--color-autocomplete-selected-background: var(--prim-color-alt-e);
--color-autocomplete-selected-font: var(--prim-gray-0);
// Variables
--color-variables-usage-font: var(--color-success);

View file

@ -107,6 +107,10 @@ declare global {
};
// eslint-disable-next-line @typescript-eslint/naming-convention
Cypress: unknown;
Sentry?: {
captureException: (error: Error, metadata?: unknown) => void;
};
}
}

View file

@ -6,24 +6,19 @@ import {
highlightSpecialChars,
keymap,
lineNumbers,
type KeyBinding,
} from '@codemirror/view';
import { bracketMatching, foldGutter, indentOnInput } from '@codemirror/language';
import { acceptCompletion, selectedCompletion } from '@codemirror/autocomplete';
import {
history,
indentLess,
indentMore,
insertNewlineAndIndent,
toggleComment,
redo,
deleteCharBackward,
undo,
} from '@codemirror/commands';
import { history, toggleComment, deleteCharBackward } from '@codemirror/commands';
import { lintGutter } from '@codemirror/lint';
import { type Extension, Prec } from '@codemirror/state';
import { codeInputHandler } from '@/plugins/codemirror/inputHandlers/code.inputHandler';
import {
autocompleteKeyMap,
enterKeyMap,
historyKeyMap,
tabKeyMap,
} from '@/plugins/codemirror/keymap';
export const readOnlyEditorExtensions: readonly Extension[] = [
lineNumbers(),
@ -31,42 +26,6 @@ export const readOnlyEditorExtensions: readonly Extension[] = [
highlightSpecialChars(),
];
export const tabKeyMap: KeyBinding[] = [
{
any(editor, event) {
if (event.key === 'Tab' || (event.key === 'Escape' && selectedCompletion(editor.state))) {
event.stopPropagation();
}
return false;
},
},
{
key: 'Tab',
run: (editor) => {
if (selectedCompletion(editor.state)) {
return acceptCompletion(editor);
}
return indentMore(editor);
},
},
{ key: 'Shift-Tab', run: indentLess },
];
export const enterKeyMap: KeyBinding[] = [
{
key: 'Enter',
run: (editor) => {
if (selectedCompletion(editor.state)) {
return acceptCompletion(editor);
}
return insertNewlineAndIndent(editor);
},
},
];
export const writableEditorExtensions: readonly Extension[] = [
history(),
lintGutter(),
@ -79,11 +38,11 @@ export const writableEditorExtensions: readonly Extension[] = [
highlightActiveLineGutter(),
Prec.highest(
keymap.of([
...tabKeyMap,
...tabKeyMap(),
...enterKeyMap,
...autocompleteKeyMap,
...historyKeyMap,
{ key: 'Mod-/', run: toggleComment },
{ key: 'Mod-z', run: undo },
{ key: 'Mod-Shift-z', run: redo },
{ key: 'Backspace', run: deleteCharBackward, shift: deleteCharBackward },
]),
),

View file

@ -37,6 +37,7 @@ export const completerExtension = defineComponent({
}
return autocompletion({
icons: false,
compareCompletions: (a: Completion, b: Completion) => {
if (/\.json$|id$|id['"]\]$/.test(a.label)) return 0;

View file

@ -6,18 +6,24 @@
import { defineComponent } from 'vue';
import { EditorView, keymap } from '@codemirror/view';
import { EditorState, Prec } from '@codemirror/state';
import { history, redo, undo } from '@codemirror/commands';
import { history } from '@codemirror/commands';
import { expressionManager } from '@/mixins/expressionManager';
import { completionManager } from '@/mixins/completionManager';
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
import { n8nLang } from '@/plugins/codemirror/n8nLang';
import { n8nAutocompletion, n8nLang } from '@/plugins/codemirror/n8nLang';
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
import { inputTheme } from './theme';
import { forceParse } from '@/utils/forceParse';
import { acceptCompletion, autocompletion } from '@codemirror/autocomplete';
import { completionStatus } from '@codemirror/autocomplete';
import type { IVariableItemSelected } from '@/Interface';
import {
autocompleteKeyMap,
enterKeyMap,
historyKeyMap,
tabKeyMap,
} from '@/plugins/codemirror/keymap';
export default defineComponent({
name: 'ExpressionEditorModalInput',
@ -44,13 +50,15 @@ export default defineComponent({
mounted() {
const extensions = [
inputTheme(),
autocompletion(),
Prec.highest(
keymap.of([
{ key: 'Tab', run: acceptCompletion },
...tabKeyMap(),
...historyKeyMap,
...enterKeyMap,
...autocompleteKeyMap,
{
any: (_: EditorView, event: KeyboardEvent) => {
if (event.key === 'Escape') {
any: (view, event) => {
if (event.key === 'Escape' && completionStatus(view.state) === null) {
event.stopPropagation();
this.$emit('close');
}
@ -58,11 +66,10 @@ export default defineComponent({
return false;
},
},
{ key: 'Mod-z', run: undo },
{ key: 'Mod-Shift-z', run: redo },
]),
),
n8nLang(),
n8nAutocompletion(),
history(),
expressionInputHandler(),
EditorView.lineWrapping,
@ -71,7 +78,11 @@ export default defineComponent({
EditorView.contentAttributes.of({ 'data-gramm': 'false' }), // disable grammarly
EditorView.domEventHandlers({ scroll: forceParse }),
EditorView.updateListener.of((viewUpdate) => {
if (!this.editor || !viewUpdate.docChanged) return;
if (!this.editor) return;
this.completionStatus = completionStatus(viewUpdate.view.state);
if (!viewUpdate.docChanged) return;
this.editorState = this.editor.state;

View file

@ -6,8 +6,7 @@
</template>
<script lang="ts">
import { autocompletion } from '@codemirror/autocomplete';
import { history, redo, undo } from '@codemirror/commands';
import { history } from '@codemirror/commands';
import {
LanguageSupport,
bracketMatching,
@ -39,11 +38,18 @@ import { expressionManager } from '@/mixins/expressionManager';
import { n8nCompletionSources } from '@/plugins/codemirror/completions/addCompletions';
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
import { enterKeyMap, tabKeyMap } from '../CodeNodeEditor/baseExtensions';
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
import type { Range, Section } from './types';
import { nonTakenRanges } from './utils';
import { isEqual } from 'lodash-es';
import {
autocompleteKeyMap,
enterKeyMap,
historyKeyMap,
tabKeyMap,
} from '@/plugins/codemirror/keymap';
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
import { completionStatus } from '@codemirror/autocomplete';
export default defineComponent({
name: 'HtmlEditor',
@ -105,17 +111,12 @@ export default defineComponent({
return [
bracketMatching(),
autocompletion(),
n8nAutocompletion(),
this.disableExpressionCompletions ? html() : htmlWithCompletions(),
autoCloseTags,
expressionInputHandler(),
Prec.highest(
keymap.of([
...tabKeyMap,
...enterKeyMap,
{ key: 'Mod-z', run: undo },
{ key: 'Mod-Shift-z', run: redo },
]),
keymap.of([...tabKeyMap(), ...enterKeyMap, ...historyKeyMap, ...autocompleteKeyMap]),
),
indentOnInput(),
codeNodeEditorTheme({
@ -135,7 +136,11 @@ export default defineComponent({
EditorView.editable.of(!this.isReadOnly),
EditorState.readOnly.of(this.isReadOnly),
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
if (!this.editor || !viewUpdate.docChanged) return;
if (!this.editor) return;
this.completionStatus = completionStatus(viewUpdate.view.state);
if (!viewUpdate.docChanged) return;
// Force segments value update by keeping track of editor state
this.editorState = this.editor.state;

View file

@ -3,8 +3,7 @@
</template>
<script lang="ts">
import { acceptCompletion, autocompletion, completionStatus } from '@codemirror/autocomplete';
import { history, redo, undo } from '@codemirror/commands';
import { history } from '@codemirror/commands';
import { Compartment, EditorState, Prec } from '@codemirror/state';
import { EditorView, keymap } from '@codemirror/view';
import type { PropType } from 'vue';
@ -13,11 +12,18 @@ import { defineComponent } from 'vue';
import { completionManager } from '@/mixins/completionManager';
import { expressionManager } from '@/mixins/expressionManager';
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
import { n8nLang } from '@/plugins/codemirror/n8nLang';
import { n8nAutocompletion, n8nLang } from '@/plugins/codemirror/n8nLang';
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
import { isEqual } from 'lodash-es';
import type { IDataObject } from 'n8n-workflow';
import { inputTheme } from './theme';
import {
autocompleteKeyMap,
enterKeyMap,
historyKeyMap,
tabKeyMap,
} from '@/plugins/codemirror/keymap';
import { completionStatus } from '@codemirror/autocomplete';
const editableConf = new Compartment();
@ -81,25 +87,12 @@ export default defineComponent({
},
mounted() {
const extensions = [
n8nLang(),
inputTheme({ rows: this.rows }),
Prec.highest(
keymap.of([
{ key: 'Tab', run: acceptCompletion },
{
any(view: EditorView, event: KeyboardEvent) {
if (event.key === 'Escape' && completionStatus(view.state) !== null) {
event.stopPropagation();
}
return false;
},
},
{ key: 'Mod-z', run: undo },
{ key: 'Mod-Shift-z', run: redo },
]),
keymap.of([...tabKeyMap(true), ...enterKeyMap, ...autocompleteKeyMap, ...historyKeyMap]),
),
autocompletion(),
n8nLang(),
n8nAutocompletion(),
inputTheme({ rows: this.rows }),
history(),
expressionInputHandler(),
EditorView.lineWrapping,
@ -111,7 +104,11 @@ export default defineComponent({
},
}),
EditorView.updateListener.of((viewUpdate) => {
if (!this.editor || !viewUpdate.docChanged) return;
if (!this.editor) return;
this.completionStatus = completionStatus(viewUpdate.view.state);
if (!viewUpdate.docChanged) return;
// Force segments value update by keeping track of editor state
this.editorState = this.editor.state;

View file

@ -6,8 +6,7 @@
</template>
<script lang="ts">
import { autocompletion } from '@codemirror/autocomplete';
import { history, redo, toggleComment, undo } from '@codemirror/commands';
import { history, toggleComment } from '@codemirror/commands';
import { javascript } from '@codemirror/lang-javascript';
import { foldGutter, indentOnInput } from '@codemirror/language';
import { lintGutter } from '@codemirror/lint';
@ -24,8 +23,14 @@ import {
} from '@codemirror/view';
import { defineComponent } from 'vue';
import { enterKeyMap, tabKeyMap } from '../CodeNodeEditor/baseExtensions';
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
import {
autocompleteKeyMap,
enterKeyMap,
historyKeyMap,
tabKeyMap,
} from '@/plugins/codemirror/keymap';
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
export default defineComponent({
name: 'JsEditor',
@ -77,15 +82,15 @@ export default defineComponent({
history(),
Prec.highest(
keymap.of([
...tabKeyMap,
...tabKeyMap(),
...enterKeyMap,
{ key: 'Mod-z', run: undo },
{ key: 'Mod-Shift-z', run: redo },
...historyKeyMap,
...autocompleteKeyMap,
{ key: 'Mod-/', run: toggleComment },
]),
),
lintGutter(),
autocompletion(),
n8nAutocompletion(),
indentOnInput(),
highlightActiveLine(),
highlightActiveLineGutter(),

View file

@ -6,8 +6,7 @@
</template>
<script lang="ts">
import { autocompletion } from '@codemirror/autocomplete';
import { history, redo, undo } from '@codemirror/commands';
import { history } from '@codemirror/commands';
import { json, jsonParseLinter } from '@codemirror/lang-json';
import { bracketMatching, foldGutter, indentOnInput } from '@codemirror/language';
import { linter as createLinter, lintGutter } from '@codemirror/lint';
@ -24,8 +23,14 @@ import {
} from '@codemirror/view';
import { defineComponent } from 'vue';
import { enterKeyMap, tabKeyMap } from '../CodeNodeEditor/baseExtensions';
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
import {
autocompleteKeyMap,
enterKeyMap,
historyKeyMap,
tabKeyMap,
} from '@/plugins/codemirror/keymap';
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
export default defineComponent({
name: 'JsonEditor',
@ -76,16 +81,11 @@ export default defineComponent({
extensions.push(
history(),
Prec.highest(
keymap.of([
...tabKeyMap,
...enterKeyMap,
{ key: 'Mod-z', run: undo },
{ key: 'Mod-Shift-z', run: redo },
]),
keymap.of([...tabKeyMap(), ...enterKeyMap, ...historyKeyMap, ...autocompleteKeyMap]),
),
createLinter(jsonParseLinter()),
lintGutter(),
autocompletion(),
n8nAutocompletion(),
indentOnInput(),
highlightActiveLine(),
highlightActiveLineGutter(),

View file

@ -20,8 +20,8 @@ import { expressionManager } from '@/mixins/expressionManager';
import { n8nCompletionSources } from '@/plugins/codemirror/completions/addCompletions';
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
import { autocompletion, ifNotIn } from '@codemirror/autocomplete';
import { history, redo, toggleComment, undo } from '@codemirror/commands';
import { ifNotIn } from '@codemirror/autocomplete';
import { history, toggleComment } from '@codemirror/commands';
import { LanguageSupport, bracketMatching, foldGutter, indentOnInput } from '@codemirror/language';
import { type Extension, type Line, Prec } from '@codemirror/state';
import { EditorState } from '@codemirror/state';
@ -47,9 +47,15 @@ import {
keywordCompletionSource,
} from '@n8n/codemirror-lang-sql';
import { defineComponent } from 'vue';
import { enterKeyMap, tabKeyMap } from '../CodeNodeEditor/baseExtensions';
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
import { isEqual } from 'lodash-es';
import {
autocompleteKeyMap,
enterKeyMap,
historyKeyMap,
tabKeyMap,
} from '@/plugins/codemirror/keymap';
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
const SQL_DIALECTS = {
StandardSQL,
@ -157,14 +163,14 @@ export default defineComponent({
history(),
Prec.highest(
keymap.of([
...tabKeyMap,
...tabKeyMap(),
...enterKeyMap,
{ key: 'Mod-z', run: undo },
{ key: 'Mod-Shift-z', run: redo },
...historyKeyMap,
...autocompleteKeyMap,
{ key: 'Mod-/', run: toggleComment },
]),
),
autocompletion(),
n8nAutocompletion(),
indentOnInput(),
highlightActiveLine(),
highlightActiveLineGutter(),

View file

@ -3,6 +3,9 @@ import { ExpressionExtensions } from 'n8n-workflow';
import type { EditorView, ViewUpdate } from '@codemirror/view';
import { expressionManager } from './expressionManager';
import { mapStores } from 'pinia';
import { useNDVStore } from '@/stores/ndv.store';
import { useRootStore } from '@/stores/n8nRoot.store';
export const completionManager = defineComponent({
mixins: [expressionManager],
@ -12,6 +15,7 @@ export const completionManager = defineComponent({
};
},
computed: {
...mapStores(useNDVStore, useRootStore),
expressionExtensionsCategories() {
return ExpressionExtensions.reduce<Record<string, string | undefined>>((acc, cur) => {
for (const fnName of Object.keys(cur.functions)) {

View file

@ -16,6 +16,7 @@ import type { Html, Plaintext, RawSegment, Resolvable, Segment } from '@/types/e
import type { EditorView } from '@codemirror/view';
import { isEqual } from 'lodash-es';
import { getExpressionErrorMessage, getResolvableState } from '@/utils/expressions';
import type { EditorState } from '@codemirror/state';
export const expressionManager = defineComponent({
props: {
@ -27,10 +28,16 @@ export const expressionManager = defineComponent({
default: () => ({}),
},
},
data() {
data(): {
editor: EditorView;
skipSegments: string[];
editorState: EditorState | undefined;
completionStatus: 'active' | 'pending' | null;
} {
return {
editor: {} as EditorView,
skipSegments: [] as string[],
skipSegments: [],
completionStatus: null,
editorState: undefined,
};
},
@ -70,15 +77,12 @@ export const expressionManager = defineComponent({
},
segments(): Segment[] {
if (!this.editorState || !this.editorState) return [];
const state = this.editorState as EditorState;
if (!state) return [];
const rawSegments: RawSegment[] = [];
const fullTree = ensureSyntaxTree(
this.editorState,
this.editorState.doc.length,
EXPRESSION_EDITOR_PARSER_TIMEOUT,
);
const fullTree = ensureSyntaxTree(state, state.doc.length, EXPRESSION_EDITOR_PARSER_TIMEOUT);
if (fullTree === null) {
throw new Error(`Failed to parse expression: ${this.editorValue}`);
@ -87,7 +91,7 @@ export const expressionManager = defineComponent({
const skipSegments = ['Program', 'Script', 'Document', ...this.skipSegments];
fullTree.cursor().iterate((node) => {
const text = this.editorState.sliceDoc(node.from, node.to);
const text = state.sliceDoc(node.from, node.to);
if (skipSegments.includes(node.type.name)) return;
@ -108,8 +112,7 @@ export const expressionManager = defineComponent({
const { from, to, text, token } = segment;
if (token === 'Resolvable') {
const { resolved, fullError } = this.resolve(text, this.hoveringItem);
const { resolved, error, fullError } = this.resolve(text, this.hoveringItem);
acc.push({
kind: 'resolvable',
from,
@ -119,7 +122,7 @@ export const expressionManager = defineComponent({
// For some reason, expressions that resolve to a number 0 are breaking preview in the SQL editor
// This fixes that but as as TODO we should figure out why this is happening
resolved: String(resolved),
state: getResolvableState(fullError),
state: getResolvableState(fullError ?? error, this.completionStatus !== null),
error: fullError,
});

View file

@ -1,5 +1,5 @@
import { createTestingPinia } from '@pinia/testing';
import { createPinia, setActivePinia } from 'pinia';
import { setActivePinia } from 'pinia';
import { DateTime } from 'luxon';
import * as workflowHelpers from '@/composables/useWorkflowHelpers';
@ -21,20 +21,22 @@ import { useExternalSecretsStore } from '@/stores/externalSecrets.ee.store';
import { useUIStore } from '@/stores/ui.store';
import { useSettingsStore } from '@/stores/settings.store';
import { CREDENTIAL_EDIT_MODAL_KEY, EnterpriseEditionFeature } from '@/constants';
import { setupServer } from '@/__tests__/server';
import {
ARRAY_NUMBER_ONLY_METHODS,
LUXON_RECOMMENDED_OPTIONS,
METADATA_SECTION,
METHODS_SECTION,
RECOMMENDED_SECTION,
STRING_RECOMMENDED_OPTIONS,
} from '../constants';
import { set, uniqBy } from 'lodash-es';
let externalSecretsStore: ReturnType<typeof useExternalSecretsStore>;
let uiStore: ReturnType<typeof useUIStore>;
let settingsStore: ReturnType<typeof useSettingsStore>;
let server: ReturnType<typeof setupServer>;
beforeAll(() => {
server = setupServer();
});
beforeEach(async () => {
setActivePinia(createPinia());
setActivePinia(createTestingPinia());
externalSecretsStore = useExternalSecretsStore();
uiStore = useUIStore();
@ -43,12 +45,6 @@ beforeEach(async () => {
vi.spyOn(utils, 'receivesNoBinaryData').mockReturnValue(true); // hide $binary
vi.spyOn(utils, 'isSplitInBatchesAbsent').mockReturnValue(false); // show context
vi.spyOn(utils, 'hasActiveNode').mockReturnValue(true);
await settingsStore.getSettings();
});
afterAll(() => {
server.shutdown();
});
describe('No completions', () => {
@ -63,7 +59,18 @@ describe('No completions', () => {
describe('Top-level completions', () => {
test('should return dollar completions for blank position: {{ | }}', () => {
expect(completions('{{ | }}')).toHaveLength(dollarOptions().length);
const result = completions('{{ | }}');
expect(result).toHaveLength(dollarOptions().length);
expect(result?.[0]).toEqual(
expect.objectContaining({ label: '$json', section: RECOMMENDED_SECTION }),
);
expect(result?.[4]).toEqual(
expect.objectContaining({ label: '$execution', section: METADATA_SECTION }),
);
expect(result?.[14]).toEqual(
expect.objectContaining({ label: '$max()', section: METHODS_SECTION }),
);
});
test('should return DateTime completion for: {{ D| }}', () => {
@ -98,77 +105,73 @@ describe('Top-level completions', () => {
});
test('should return node selector completions for: {{ $(| }}', () => {
const initialState = { workflows: { workflow: { nodes: mockNodes } } };
setActivePinia(createTestingPinia({ initialState }));
vi.spyOn(utils, 'autocompletableNodeNames').mockReturnValue(mockNodes.map((node) => node.name));
expect(completions('{{ $(| }}')).toHaveLength(mockNodes.length);
});
});
describe('Luxon method completions', () => {
const resolveParameterSpy = vi.spyOn(workflowHelpers, 'resolveParameter');
test('should return class completions for: {{ DateTime.| }}', () => {
// @ts-expect-error Spied function is mistyped
resolveParameterSpy.mockReturnValueOnce(DateTime);
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(DateTime);
expect(completions('{{ DateTime.| }}')).toHaveLength(luxonStaticOptions().length);
});
test('should return instance completions for: {{ $now.| }}', () => {
// @ts-expect-error Spied function is mistyped
resolveParameterSpy.mockReturnValueOnce(DateTime.now());
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(DateTime.now());
expect(completions('{{ $now.| }}')).toHaveLength(
luxonInstanceOptions().length + extensions('date').length,
uniqBy(luxonInstanceOptions().concat(extensions('date')), (option) => option.label).length +
LUXON_RECOMMENDED_OPTIONS.length,
);
});
test('should return instance completions for: {{ $today.| }}', () => {
// @ts-expect-error Spied function is mistyped
resolveParameterSpy.mockReturnValueOnce(DateTime.now());
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(DateTime.now());
expect(completions('{{ $today.| }}')).toHaveLength(
luxonInstanceOptions().length + extensions('date').length,
uniqBy(luxonInstanceOptions().concat(extensions('date')), (option) => option.label).length +
LUXON_RECOMMENDED_OPTIONS.length,
);
});
});
describe('Resolution-based completions', () => {
const resolveParameterSpy = vi.spyOn(workflowHelpers, 'resolveParameter');
describe('literals', () => {
test('should return completions for string literal: {{ "abc".| }}', () => {
// @ts-expect-error Spied function is mistyped
resolveParameterSpy.mockReturnValueOnce('abc');
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce('abc');
expect(completions('{{ "abc".| }}')).toHaveLength(
natives('string').length + extensions('string').length,
natives('string').length + extensions('string').length + STRING_RECOMMENDED_OPTIONS.length,
);
});
test('should properly handle string that contain dollar signs', () => {
// @ts-expect-error Spied function is mistyped
resolveParameterSpy.mockReturnValueOnce('"You \'owe\' me 200$"');
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce("You 'owe' me 200$ ");
expect(completions('{{ "You \'owe\' me 200$".| }}')).toHaveLength(
natives('string').length + extensions('string').length,
);
const result = completions('{{ "You \'owe\' me 200$".| }}');
expect(result).toHaveLength(natives('string').length + extensions('string').length + 1);
});
test('should return completions for number literal: {{ (123).| }}', () => {
// @ts-expect-error Spied function is mistyped
resolveParameterSpy.mockReturnValueOnce(123);
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(123);
expect(completions('{{ (123).| }}')).toHaveLength(
natives('number').length + extensions('number').length,
natives('number').length + extensions('number').length + ['isEven()', 'isOdd()'].length,
);
});
test('should return completions for array literal: {{ [1, 2, 3].| }}', () => {
// @ts-expect-error Spied function is mistyped
resolveParameterSpy.mockReturnValueOnce([1, 2, 3]);
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce([1, 2, 3]);
expect(completions('{{ [1, 2, 3].| }}')).toHaveLength(
natives('array').length + extensions('array').length,
@ -177,7 +180,7 @@ describe('Resolution-based completions', () => {
test('should return completions for Object methods: {{ Object.values({ abc: 123 }).| }}', () => {
// @ts-expect-error Spied function is mistyped
resolveParameterSpy.mockReturnValueOnce([123]);
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce([123]);
const found = completions('{{ Object.values({ abc: 123 }).| }}');
@ -189,10 +192,10 @@ describe('Resolution-based completions', () => {
test('should return completions for object literal', () => {
const object = { a: 1 };
resolveParameterSpy.mockReturnValueOnce(object);
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(object);
expect(completions('{{ ({ a: 1 }).| }}')).toHaveLength(
Object.keys(object).length + natives('object').length + extensions('object').length,
Object.keys(object).length + extensions('object').length,
);
});
});
@ -200,23 +203,24 @@ describe('Resolution-based completions', () => {
describe('indexed access completions', () => {
test('should return string completions for indexed access that resolves to string literal: {{ "abc"[0].| }}', () => {
// @ts-expect-error Spied function is mistyped
resolveParameterSpy.mockReturnValueOnce('a');
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce('a');
expect(completions('{{ "abc"[0].| }}')).toHaveLength(
natives('string').length + extensions('string').length,
natives('string').length + extensions('string').length + STRING_RECOMMENDED_OPTIONS.length,
);
});
});
describe('complex expression completions', () => {
const resolveParameterSpy = vi.spyOn(workflowHelpers, 'resolveParameter');
const { $input } = mockProxy;
test('should return completions when $input is used as a function parameter', () => {
resolveParameterSpy.mockReturnValue($input.item.json.num);
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.item.json.num);
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);
expect(found).toHaveLength(
extensions('number').length + natives('number').length + ['isEven()', 'isOdd()'].length,
);
});
test('should return completions when node reference is used as a function parameter', () => {
@ -228,69 +232,74 @@ 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(
natives('date').length + extensions('object').length,
uniqBy(luxonInstanceOptions().concat(extensions('date')), (option) => option.label).length +
LUXON_RECOMMENDED_OPTIONS.length,
);
});
test('should return completions for complex expression: {{ $execution.resumeUrl.includes($json.) }}', () => {
resolveParameterSpy.mockReturnValue($input.item.json);
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce($input.item.json);
const { $json } = mockProxy;
const found = completions('{{ $execution.resumeUrl.includes($json.|) }}');
if (!found) throw new Error('Expected to find completions');
expect(found).toHaveLength(Object.keys($json).length + natives('object').length);
expect(found).toHaveLength(Object.keys($json).length + extensions('object').length);
});
test('should return completions for operation expression: {{ $now.day + $json. }}', () => {
resolveParameterSpy.mockReturnValue($input.item.json);
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce($input.item.json);
const { $json } = mockProxy;
const found = completions('{{ $now.day + $json.| }}');
if (!found) throw new Error('Expected to find completions');
expect(found).toHaveLength(Object.keys($json).length + natives('object').length);
expect(found).toHaveLength(Object.keys($json).length + extensions('object').length);
});
test('should return completions for operation expression: {{ Math.abs($now.day) >= 10 ? $now : Math.abs($json.). }}', () => {
resolveParameterSpy.mockReturnValue($input.item.json);
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.item.json);
const { $json } = mockProxy;
const found = completions('{{ Math.abs($now.day) >= 10 ? $now : Math.abs($json.|) }}');
if (!found) throw new Error('Expected to find completions');
expect(found).toHaveLength(Object.keys($json).length + natives('object').length);
expect(found).toHaveLength(Object.keys($json).length + extensions('object').length);
});
});
describe('bracket-aware completions', () => {
const resolveParameterSpy = vi.spyOn(workflowHelpers, 'resolveParameter');
const { $input } = mockProxy;
test('should return bracket-aware completions for: {{ $input.item.json.str.|() }}', () => {
resolveParameterSpy.mockReturnValue($input.item.json.str);
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.item.json.str);
const found = completions('{{ $input.item.json.str.|() }}');
if (!found) throw new Error('Expected to find completions');
expect(found).toHaveLength(extensions('string').length + natives('string').length);
expect(found).toHaveLength(
extensions('string').length + natives('string').length + STRING_RECOMMENDED_OPTIONS.length,
);
expect(found.map((c) => c.label).every((l) => !l.endsWith('()')));
});
test('should return bracket-aware completions for: {{ $input.item.json.num.|() }}', () => {
resolveParameterSpy.mockReturnValue($input.item.json.num);
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.item.json.num);
const found = completions('{{ $input.item.json.num.|() }}');
if (!found) throw new Error('Expected to find completions');
expect(found).toHaveLength(extensions('number').length + natives('number').length);
expect(found).toHaveLength(
extensions('number').length + natives('number').length + ['isEven()', 'isOdd()'].length,
);
expect(found.map((c) => c.label).every((l) => !l.endsWith('()')));
});
test('should return bracket-aware completions for: {{ $input.item.json.arr.| }}', () => {
resolveParameterSpy.mockReturnValue($input.item.json.arr);
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.item.json.arr);
const found = completions('{{ $input.item.json.arr.|() }}');
@ -302,17 +311,18 @@ describe('Resolution-based completions', () => {
});
describe('secrets', () => {
const resolveParameterSpy = vi.spyOn(workflowHelpers, 'resolveParameter');
const { $input, $ } = mockProxy;
const { $input } = mockProxy;
beforeEach(() => {});
test('should return completions for: {{ $secrets.| }}', () => {
const provider = 'infisical';
const secrets = ['SECRET'];
resolveParameterSpy.mockReturnValue($input);
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input);
uiStore.modals[CREDENTIAL_EDIT_MODAL_KEY].open = true;
settingsStore.settings.enterprise[EnterpriseEditionFeature.ExternalSecrets] = true;
set(settingsStore.settings, ['enterprise', EnterpriseEditionFeature.ExternalSecrets], true);
externalSecretsStore.state.secrets = {
[provider]: secrets,
};
@ -324,6 +334,7 @@ describe('Resolution-based completions', () => {
info: expect.any(Function),
label: provider,
type: 'keyword',
apply: expect.any(Function),
},
]);
});
@ -332,10 +343,10 @@ describe('Resolution-based completions', () => {
const provider = 'infisical';
const secrets = ['SECRET1', 'SECRET2'];
resolveParameterSpy.mockReturnValue($input);
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input);
uiStore.modals[CREDENTIAL_EDIT_MODAL_KEY].open = true;
settingsStore.settings.enterprise[EnterpriseEditionFeature.ExternalSecrets] = true;
set(settingsStore.settings, ['enterprise', EnterpriseEditionFeature.ExternalSecrets], true);
externalSecretsStore.state.secrets = {
[provider]: secrets,
};
@ -347,138 +358,141 @@ 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),
},
]);
});
});
describe('references', () => {
const resolveParameterSpy = vi.spyOn(workflowHelpers, 'resolveParameter');
const { $input, $ } = mockProxy;
test('should return completions for: {{ $input.| }}', () => {
resolveParameterSpy.mockReturnValue($input);
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input);
expect(completions('{{ $input.| }}')).toHaveLength(
Reflect.ownKeys($input).length + natives('object').length,
);
expect(completions('{{ $input.| }}')).toHaveLength(Reflect.ownKeys($input).length);
});
test('should return completions for: {{ "hello"+input.| }}', () => {
resolveParameterSpy.mockReturnValue($input);
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input);
expect(completions('{{ "hello"+$input.| }}')).toHaveLength(
Reflect.ownKeys($input).length + natives('object').length,
);
expect(completions('{{ "hello"+$input.| }}')).toHaveLength(Reflect.ownKeys($input).length);
});
test("should return completions for: {{ $('nodeName').| }}", () => {
resolveParameterSpy.mockReturnValue($('Rename'));
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($('Rename'));
expect(completions('{{ $("Rename").| }}')).toHaveLength(
Reflect.ownKeys($('Rename')).length + natives('object').length - ['pairedItem'].length,
Reflect.ownKeys($('Rename')).length - ['pairedItem'].length,
);
});
test("should return completions for: {{ $('(Complex) \"No\\'de\" name').| }}", () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($('Rename'));
expect(completions("{{ $('(Complex) \"No\\'de\" name').| }}")).toHaveLength(
Reflect.ownKeys($('Rename')).length - ['pairedItem'].length,
);
});
test('should return completions for: {{ $input.item.| }}', () => {
resolveParameterSpy.mockReturnValue($input.item);
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.item);
const found = completions('{{ $input.item.| }}');
if (!found) throw new Error('Expected to find completion');
expect(found).toHaveLength(3);
expect(found).toHaveLength(1);
expect(found[0].label).toBe('json');
});
test('should return completions for: {{ $input.first().| }}', () => {
resolveParameterSpy.mockReturnValue($input.first());
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.first());
const found = completions('{{ $input.first().| }}');
if (!found) throw new Error('Expected to find completion');
expect(found).toHaveLength(3);
expect(found).toHaveLength(1);
expect(found[0].label).toBe('json');
});
test('should return completions for: {{ $input.last().| }}', () => {
resolveParameterSpy.mockReturnValue($input.last());
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.last());
const found = completions('{{ $input.last().| }}');
if (!found) throw new Error('Expected to find completion');
expect(found).toHaveLength(3);
expect(found).toHaveLength(1);
expect(found[0].label).toBe('json');
});
test('should return no completions for: {{ $input.all().| }}', () => {
test('should return completions for: {{ $input.all().| }}', () => {
// @ts-expect-error
resolveParameterSpy.mockReturnValue([$input.item]);
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue([$input.item]);
expect(completions('{{ $input.all().| }}')).toBeNull();
expect(completions('{{ $input.all().| }}')).toHaveLength(
extensions('array').length + natives('array').length - ARRAY_NUMBER_ONLY_METHODS.length,
);
});
test("should return completions for: '{{ $input.item.| }}'", () => {
resolveParameterSpy.mockReturnValue($input.item.json);
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.item.json);
expect(completions('{{ $input.item.| }}')).toHaveLength(
Object.keys($input.item.json).length +
(extensions('object').length + natives('object').length),
Object.keys($input.item.json).length + extensions('object').length,
);
});
test("should return completions for: '{{ $input.first().| }}'", () => {
resolveParameterSpy.mockReturnValue($input.first().json);
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.first().json);
expect(completions('{{ $input.first().| }}')).toHaveLength(
Object.keys($input.first().json).length +
(extensions('object').length + natives('object').length),
Object.keys($input.first().json).length + extensions('object').length,
);
});
test("should return completions for: '{{ $input.last().| }}'", () => {
resolveParameterSpy.mockReturnValue($input.last().json);
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.last().json);
expect(completions('{{ $input.last().| }}')).toHaveLength(
Object.keys($input.last().json).length +
(extensions('object').length + natives('object').length),
Object.keys($input.last().json).length + extensions('object').length,
);
});
test("should return completions for: '{{ $input.all()[0].| }}'", () => {
resolveParameterSpy.mockReturnValue($input.all()[0].json);
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 + natives('object').length),
Object.keys($input.all()[0].json).length + extensions('object').length,
);
});
test('should return completions for: {{ $input.item.json.str.| }}', () => {
resolveParameterSpy.mockReturnValue($input.item.json.str);
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.item.json.str);
expect(completions('{{ $input.item.json.str.| }}')).toHaveLength(
extensions('string').length + natives('string').length,
extensions('string').length + natives('string').length + STRING_RECOMMENDED_OPTIONS.length,
);
});
test('should return completions for: {{ $input.item.json.num.| }}', () => {
resolveParameterSpy.mockReturnValue($input.item.json.num);
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.item.json.num);
expect(completions('{{ $input.item.json.num.| }}')).toHaveLength(
extensions('number').length + natives('number').length,
extensions('number').length + natives('number').length + ['isEven()', 'isOdd()'].length,
);
});
test('should return completions for: {{ $input.item.json.arr.| }}', () => {
resolveParameterSpy.mockReturnValue($input.item.json.arr);
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.item.json.arr);
expect(completions('{{ $input.item.json.arr.| }}')).toHaveLength(
extensions('array').length + natives('array').length,
@ -486,22 +500,20 @@ describe('Resolution-based completions', () => {
});
test('should return completions for: {{ $input.item.json.obj.| }}', () => {
resolveParameterSpy.mockReturnValue($input.item.json.obj);
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 + natives('object').length),
Object.keys($input.item.json.obj).length + extensions('object').length,
);
});
});
describe('bracket access', () => {
const resolveParameterSpy = vi.spyOn(workflowHelpers, 'resolveParameter');
const { $input } = mockProxy;
['{{ $input.item.json[| }}', '{{ $json[| }}'].forEach((expression) => {
test(`should return completions for: ${expression}`, () => {
resolveParameterSpy.mockReturnValue($input.item.json);
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.item.json);
const found = completions(expression);
@ -514,7 +526,7 @@ describe('Resolution-based completions', () => {
["{{ $input.item.json['obj'][| }}", "{{ $json['obj'][| }}"].forEach((expression) => {
test(`should return completions for: ${expression}`, () => {
resolveParameterSpy.mockReturnValue($input.item.json.obj);
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.item.json.obj);
const found = completions(expression);
@ -525,6 +537,68 @@ describe('Resolution-based completions', () => {
});
});
});
describe('recommended completions', () => {
test('should recommended toDate() for {{ "1-Feb-2024".| }}', () => {
// @ts-expect-error Spied function is mistyped
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce('1-Feb-2024');
expect(completions('{{ "1-Feb-2024".| }}')?.[0]).toEqual(
expect.objectContaining({ label: 'toDate()', section: RECOMMENDED_SECTION }),
);
});
test('should recommended toInt(),toFloat() 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 }),
);
});
test('should recommended extractEmail() for: {{ "string with test@n8n.io in it".| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(
// @ts-expect-error Spied function is mistyped
'string with test@n8n.io in it',
);
const options = completions('{{ "string with test@n8n.io in it".| }}');
expect(options?.[0]).toEqual(
expect.objectContaining({ label: 'extractEmail()', section: RECOMMENDED_SECTION }),
);
});
test('should recommended extractDomain() for: {{ "test@n8n.io".| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(
// @ts-expect-error Spied function is mistyped
'test@n8n.io',
);
const options = completions('{{ "test@n8n.io".| }}');
expect(options?.[0]).toEqual(
expect.objectContaining({ label: 'extractDomain()', section: RECOMMENDED_SECTION }),
);
});
test('should recommended round(),floor(),ceil() for: {{ (5.46).| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(
// @ts-expect-error Spied function is mistyped
5.46,
);
const options = completions('{{ (5.46).| }}');
expect(options?.[0]).toEqual(
expect.objectContaining({ label: 'round()', section: RECOMMENDED_SECTION }),
);
expect(options?.[1]).toEqual(
expect.objectContaining({ label: 'floor()', section: RECOMMENDED_SECTION }),
);
expect(options?.[2]).toEqual(
expect.objectContaining({ label: 'ceil()', section: RECOMMENDED_SECTION }),
);
});
});
});
export function completions(docWithCursor: string) {

View file

@ -0,0 +1,192 @@
import type { Completion, CompletionSection } from '@codemirror/autocomplete';
import { i18n } from '@/plugins/i18n';
import { withSectionHeader } from './utils';
export const FIELDS_SECTION: CompletionSection = withSectionHeader({
name: i18n.baseText('codeNodeEditor.completer.section.fields'),
rank: -1,
});
export const RECOMMENDED_SECTION: CompletionSection = withSectionHeader({
name: i18n.baseText('codeNodeEditor.completer.section.recommended'),
rank: 0,
});
export const RECOMMENDED_METHODS_SECTION: CompletionSection = withSectionHeader({
name: i18n.baseText('codeNodeEditor.completer.section.recommendedMethods'),
rank: 0,
});
export const PREVIOUS_NODES_SECTION: CompletionSection = withSectionHeader({
name: i18n.baseText('codeNodeEditor.completer.section.prevNodes'),
rank: 1,
});
export const PROPERTIES_SECTION: CompletionSection = withSectionHeader({
name: i18n.baseText('codeNodeEditor.completer.section.properties'),
rank: 2,
});
export const METHODS_SECTION: CompletionSection = withSectionHeader({
name: i18n.baseText('codeNodeEditor.completer.section.methods'),
rank: 3,
});
export const METADATA_SECTION: CompletionSection = withSectionHeader({
name: i18n.baseText('codeNodeEditor.completer.section.metadata'),
rank: 4,
});
export const OTHER_METHODS_SECTION: CompletionSection = withSectionHeader({
name: i18n.baseText('codeNodeEditor.completer.section.otherMethods'),
rank: 100,
});
export const OTHER_SECTION: CompletionSection = withSectionHeader({
name: i18n.baseText('codeNodeEditor.completer.section.other'),
rank: 101,
});
export const ROOT_DOLLAR_COMPLETIONS: Completion[] = [
{
label: '$json',
section: RECOMMENDED_SECTION,
info: i18n.rootVars.$json,
},
{
label: '$binary',
section: RECOMMENDED_SECTION,
info: i18n.rootVars.$binary,
},
{
label: '$now',
section: RECOMMENDED_SECTION,
info: i18n.rootVars.$now,
},
{
label: '$if()',
section: RECOMMENDED_SECTION,
info: i18n.rootVars.$if,
},
{
label: '$ifEmpty()',
section: RECOMMENDED_SECTION,
info: i18n.rootVars.$ifEmpty,
},
{
label: '$execution',
section: METADATA_SECTION,
info: i18n.rootVars.$execution,
},
{
label: '$itemIndex',
section: METADATA_SECTION,
info: i18n.rootVars.$itemIndex,
},
{
label: '$input',
section: METADATA_SECTION,
info: i18n.rootVars.$input,
},
{
label: '$parameter',
section: METADATA_SECTION,
info: i18n.rootVars.$parameter,
},
{
label: '$prevNode',
section: METADATA_SECTION,
info: i18n.rootVars.$prevNode,
},
{
label: '$runIndex',
section: METADATA_SECTION,
info: i18n.rootVars.$runIndex,
},
{
label: '$today',
section: METADATA_SECTION,
info: i18n.rootVars.$today,
},
{
label: '$vars',
section: METADATA_SECTION,
info: i18n.rootVars.$vars,
},
{
label: '$workflow',
section: METADATA_SECTION,
info: i18n.rootVars.$workflow,
},
{
label: '$jmespath()',
section: METHODS_SECTION,
info: i18n.rootVars.$jmespath,
},
{
label: '$max()',
section: METHODS_SECTION,
info: i18n.rootVars.$max,
},
{
label: '$min()',
section: METHODS_SECTION,
info: i18n.rootVars.$min,
},
];
export const STRING_RECOMMENDED_OPTIONS = [
'includes()',
'split()',
'startsWith()',
'replaceAll()',
'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()'];
export const ARRAY_RECOMMENDED_OPTIONS = ['length', 'last()', 'includes()', 'map()', 'filter()'];
export const ARRAY_NUMBER_ONLY_METHODS = ['max()', 'min()', 'sum()', 'average()'];
export const LUXON_SECTIONS: Record<string, CompletionSection> = {
edit: withSectionHeader({
name: i18n.baseText('codeNodeEditor.completer.section.edit'),
rank: 1,
}),
compare: withSectionHeader({
name: i18n.baseText('codeNodeEditor.completer.section.compare'),
rank: 2,
}),
format: withSectionHeader({
name: i18n.baseText('codeNodeEditor.completer.section.format'),
rank: 3,
}),
query: withSectionHeader({
name: i18n.baseText('codeNodeEditor.completer.section.component'),
rank: 4,
}),
};
export const STRING_SECTIONS: Record<string, CompletionSection> = {
edit: withSectionHeader({
name: i18n.baseText('codeNodeEditor.completer.section.edit'),
rank: 1,
}),
query: withSectionHeader({
name: i18n.baseText('codeNodeEditor.completer.section.query'),
rank: 2,
}),
validation: withSectionHeader({
name: i18n.baseText('codeNodeEditor.completer.section.validation'),
rank: 3,
}),
case: withSectionHeader({
name: i18n.baseText('codeNodeEditor.completer.section.case'),
rank: 4,
}),
cast: withSectionHeader({
name: i18n.baseText('codeNodeEditor.completer.section.cast'),
rank: 5,
}),
};

View file

@ -1,5 +1,5 @@
import type { IDataObject, DocMetadata, NativeDoc } from 'n8n-workflow';
import { Expression, ExpressionExtensions, NativeMethods } 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';
@ -14,15 +14,48 @@ import {
isPseudoParam,
stripExcessParens,
isCredentialsModalOpen,
applyCompletion,
sortCompletionsAlpha,
hasRequiredArgs,
} from './utils';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import type { AutocompleteOptionType, ExtensionTypeName, FnToDoc, Resolved } from './types';
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 {
ARRAY_NUMBER_ONLY_METHODS,
ARRAY_RECOMMENDED_OPTIONS,
DATE_RECOMMENDED_OPTIONS,
FIELDS_SECTION,
LUXON_RECOMMENDED_OPTIONS,
LUXON_SECTIONS,
METHODS_SECTION,
OBJECT_RECOMMENDED_OPTIONS,
OTHER_METHODS_SECTION,
OTHER_SECTION,
PROPERTIES_SECTION,
RECOMMENDED_METHODS_SECTION,
RECOMMENDED_SECTION,
STRING_RECOMMENDED_OPTIONS,
STRING_SECTIONS,
} from './constants';
import { VALID_EMAIL_REGEX } from '@/constants';
import { uniqBy } from 'lodash-es';
/**
* Resolution-based completions offered according to datatype.
@ -63,7 +96,7 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul
if (resolved === null) return null;
try {
options = datatypeOptions(resolved, base).map(stripExcessParens(context));
options = datatypeOptions({ resolved, base, tail }).map(stripExcessParens(context));
} catch {
return null;
}
@ -87,40 +120,33 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul
};
}
function datatypeOptions(resolved: Resolved, toResolve: string) {
function datatypeOptions(input: AutocompleteInput): Completion[] {
const { resolved } = input;
if (resolved === null) return [];
if (typeof resolved === 'number') {
return [...natives('number'), ...extensions('number')];
return numberOptions(input as AutocompleteInput<number>);
}
if (typeof resolved === 'string') {
return [...natives('string'), ...extensions('string')];
return stringOptions(input as AutocompleteInput<string>);
}
if (['$now', '$today'].includes(toResolve)) {
return [...luxonInstanceOptions(), ...extensions('date')];
if (resolved instanceof DateTime) {
return luxonOptions();
}
if (resolved instanceof Date) {
return [...natives('date'), ...extensions('date')];
return dateOptions();
}
if (Array.isArray(resolved)) {
if (/all\(.*?\)/.test(toResolve)) return [];
const arrayMethods = [...natives('array'), ...extensions('array')];
if (resolved.length > 0 && resolved.some((i) => typeof i !== 'number')) {
const NUMBER_ONLY_ARRAY_EXTENSIONS = new Set(['max()', 'min()', 'sum()', 'average()']);
return arrayMethods.filter((m) => !NUMBER_ONLY_ARRAY_EXTENSIONS.has(m.label));
}
return arrayMethods;
return arrayOptions(input as AutocompleteInput<unknown[]>);
}
if (typeof resolved === 'object') {
return objectOptions(toResolve, resolved);
return objectOptions(input as AutocompleteInput<IDataObject>);
}
return [];
@ -137,7 +163,7 @@ export const natives = (typeName: ExtensionTypeName): Completion[] => {
return [...nativeProps, ...nativeMethods];
};
export const extensions = (typeName: ExtensionTypeName) => {
export const extensions = (typeName: ExtensionTypeName, includeHidden = false) => {
const extensions = ExpressionExtensions.find((ee) => ee.typeName.toLowerCase() === typeName);
if (!extensions) return [];
@ -146,18 +172,20 @@ export const extensions = (typeName: ExtensionTypeName) => {
return { ...acc, [fnName]: { doc: fn.doc } };
}, {});
return toOptions(fnToDoc, typeName, 'extension-function');
return toOptions(fnToDoc, typeName, 'extension-function', includeHidden);
};
export const toOptions = (
fnToDoc: FnToDoc,
typeName: ExtensionTypeName,
optionType: AutocompleteOptionType = 'native-function',
includeHidden = false,
) => {
return Object.entries(fnToDoc)
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([fnName, fn]) => {
return createCompletionOption(typeName, fnName, optionType, fn);
.filter(([, docInfo]) => !docInfo.doc?.hidden || includeHidden)
.map(([fnName, docInfo]) => {
return createCompletionOption(typeName, fnName, optionType, docInfo);
});
};
@ -167,9 +195,13 @@ const createCompletionOption = (
optionType: AutocompleteOptionType,
docInfo: { doc?: DocMetadata | undefined },
): Completion => {
const isFunction = isFunctionOption(optionType);
const label = isFunction ? name + '()' : name;
const option: Completion = {
label: isFunctionOption(optionType) ? name + '()' : name,
label,
type: optionType,
section: docInfo.doc?.section,
apply: applyCompletion(hasRequiredArgs(docInfo?.doc)),
};
option.info = () => {
@ -271,15 +303,16 @@ const createPropHeader = (typeName: string, property: { doc?: DocMetadata | unde
return header;
};
const objectOptions = (toResolve: string, resolved: IDataObject) => {
const objectOptions = (input: AutocompleteInput<IDataObject>): Completion[] => {
const { base, resolved } = input;
const rank = setRank(['item', 'all', 'first', 'last']);
const SKIP = new Set(['__ob__', 'pairedItem']);
if (isSplitInBatchesAbsent()) SKIP.add('context');
const name = toResolve.startsWith('$(') ? '$()' : toResolve;
const name = /^\$\(.*\)$/.test(base) ? '$()' : base;
if (['$input', '$()'].includes(name) && hasNoParams(toResolve)) SKIP.add('params');
if (['$input', '$()'].includes(name) && hasNoParams(base)) SKIP.add('params');
let rawKeys = Object.keys(resolved);
@ -287,7 +320,7 @@ const objectOptions = (toResolve: string, resolved: IDataObject) => {
rawKeys = Reflect.ownKeys(resolved) as string[];
}
if (toResolve === 'Math') {
if (base === 'Math') {
const descriptors = Object.getOwnPropertyDescriptors(Math);
rawKeys = Object.keys(descriptors).sort((a, b) => a.localeCompare(b));
}
@ -296,27 +329,26 @@ const objectOptions = (toResolve: string, resolved: IDataObject) => {
.filter((key) => !SKIP.has(key) && isAllowedInDotNotation(key) && !isPseudoParam(key))
.map((key) => {
ensureKeyCanBeResolved(resolved, key);
const resolvedProp = resolved[key];
const isFunction = typeof resolved[key] === 'function';
const isFunction = typeof resolvedProp === 'function';
const hasArgs = isFunction && resolvedProp.length > 0 && name !== '$()';
const option: Completion = {
label: isFunction ? key + '()' : key,
type: isFunction ? 'function' : 'keyword',
section: getObjectPropertySection({ name, key, isFunction }),
apply: applyCompletion(hasArgs),
};
const infoKey = [name, key].join('.');
option.info = createCompletionOption(
'Object',
key,
isFunction ? 'native-function' : 'keyword',
{
doc: {
name: key,
returnType: typeof resolved[key],
description: i18n.proxyVars[infoKey],
},
option.info = createCompletionOption('', key, isFunction ? 'native-function' : 'keyword', {
doc: {
name: key,
returnType: typeof resolvedProp,
description: i18n.proxyVars[infoKey],
},
).info;
}).info;
return option;
});
@ -324,14 +356,192 @@ const objectOptions = (toResolve: string, resolved: IDataObject) => {
const skipObjectExtensions =
resolved.isProxy ||
resolved.json ||
/json('])?$/.test(toResolve) ||
toResolve === '$execution' ||
toResolve.endsWith('params') ||
toResolve === 'Math';
/json('])$/.test(base) ||
base === '$execution' ||
base.endsWith('params') ||
base === 'Math';
if (skipObjectExtensions) return [...localKeys, ...natives('object')];
if (skipObjectExtensions) {
return sortCompletionsAlpha([...localKeys, ...natives('object')]);
}
return [...localKeys, ...natives('object'), ...extensions('object')];
return applySections({
options: sortCompletionsAlpha([...localKeys, ...natives('object'), ...extensions('object')]),
recommended: OBJECT_RECOMMENDED_OPTIONS,
recommendedSection: RECOMMENDED_METHODS_SECTION,
methodsSection: OTHER_METHODS_SECTION,
propSection: FIELDS_SECTION,
excludeRecommended: true,
});
};
const getObjectPropertySection = ({
name,
key,
isFunction,
}: {
name: string;
key: string;
isFunction: boolean;
}): CompletionSection => {
if (name === '$input' || name === '$()') {
if (key === 'item') return RECOMMENDED_SECTION;
return OTHER_SECTION;
}
return isFunction ? METHODS_SECTION : FIELDS_SECTION;
};
const applySections = ({
options,
sections,
recommended = [],
excludeRecommended = false,
methodsSection = METHODS_SECTION,
propSection = PROPERTIES_SECTION,
recommendedSection = RECOMMENDED_SECTION,
}: {
options: Completion[];
recommended?: string[];
recommendedSection?: CompletionSection;
methodsSection?: CompletionSection;
propSection?: CompletionSection;
sections?: Record<string, CompletionSection>;
excludeRecommended?: boolean;
}) => {
const recommendedSet = new Set(recommended);
const optionByLabel = options.reduce(
(acc, option) => {
acc[option.label] = option;
return acc;
},
{} as Record<string, Completion>,
);
return recommended
.map(
(reco): Completion => ({
...optionByLabel[reco],
section: recommendedSection,
}),
)
.concat(
options
.filter((option) => !excludeRecommended || !recommendedSet.has(option.label))
.map((option) => {
if (sections) {
option.section = sections[option.section as string] ?? OTHER_SECTION;
} else {
option.section = option.label.endsWith('()') ? methodsSection : propSection;
}
return option;
}),
);
};
const isUrl = (url: string): boolean => {
try {
new URL(url);
return true;
} catch (error) {
return false;
}
};
const stringOptions = (input: AutocompleteInput<string>): Completion[] => {
const { resolved, tail } = input;
const options = sortCompletionsAlpha([...natives('string'), ...extensions('string')]);
if (validateFieldType('string', resolved, 'number').valid) {
return applySections({
options,
recommended: ['toInt()', 'toFloat()'],
sections: STRING_SECTIONS,
});
}
if (validateFieldType('string', resolved, 'dateTime').valid) {
return applySections({
options,
recommended: ['toDate()'],
sections: STRING_SECTIONS,
});
}
if (VALID_EMAIL_REGEX.test(resolved) || isUrl(resolved)) {
return applySections({
options,
recommended: ['extractDomain()', 'isEmail()', ...STRING_RECOMMENDED_OPTIONS],
sections: STRING_SECTIONS,
});
}
if (resolved.split(/\s/).find((token) => VALID_EMAIL_REGEX.test(token))) {
return applySections({
options,
recommended: ['extractEmail()', ...STRING_RECOMMENDED_OPTIONS],
sections: STRING_SECTIONS,
});
}
return applySections({
options,
recommended: STRING_RECOMMENDED_OPTIONS,
sections: STRING_SECTIONS,
});
};
const numberOptions = (input: AutocompleteInput<number>): Completion[] => {
const { resolved } = input;
const options = sortCompletionsAlpha([...natives('number'), ...extensions('number')]);
const ONLY_INTEGER = ['isEven()', 'isOdd()'];
if (Number.isInteger(resolved)) {
return applySections({
options,
recommended: ONLY_INTEGER,
});
} else {
const exclude = new Set(ONLY_INTEGER);
return applySections({
options: options.filter((option) => !exclude.has(option.label)),
recommended: ['round()', 'floor()', 'ceil()', 'toFixed()'],
});
}
};
const dateOptions = (): Completion[] => {
return applySections({
options: sortCompletionsAlpha([...natives('date'), ...extensions('date', true)]),
recommended: DATE_RECOMMENDED_OPTIONS,
});
};
const luxonOptions = (): Completion[] => {
return applySections({
options: sortCompletionsAlpha(
uniqBy([...extensions('date'), ...luxonInstanceOptions()], (option) => option.label),
),
recommended: LUXON_RECOMMENDED_OPTIONS,
sections: LUXON_SECTIONS,
});
};
const arrayOptions = (input: AutocompleteInput<unknown[]>): Completion[] => {
const { resolved } = input;
const options = applySections({
options: sortCompletionsAlpha([...natives('array'), ...extensions('array')]),
recommended: ARRAY_RECOMMENDED_OPTIONS,
methodsSection: OTHER_SECTION,
propSection: OTHER_SECTION,
excludeRecommended: true,
});
if (resolved.length > 0 && resolved.some((i) => typeof i !== 'number')) {
const NUMBER_ONLY_ARRAY_EXTENSIONS = new Set(ARRAY_NUMBER_ONLY_METHODS);
return options.filter((m) => !NUMBER_ONLY_ARRAY_EXTENSIONS.has(m.label));
}
return options;
};
function ensureKeyCanBeResolved(obj: IDataObject, key: string) {
@ -410,7 +620,7 @@ export const secretProvidersOptions = () => {
/**
* Methods and fields defined on a Luxon `DateTime` class instance.
*/
export const luxonInstanceOptions = () => {
export const luxonInstanceOptions = (includeHidden = false) => {
const SKIP = new Set(['constructor', 'get', 'invalidExplanation', 'invalidReason']);
return Object.entries(Object.getOwnPropertyDescriptors(DateTime.prototype))
@ -419,8 +629,15 @@ export const luxonInstanceOptions = () => {
.map(([key, descriptor]) => {
const isFunction = typeof descriptor.value === 'function';
const optionType = isFunction ? 'native-function' : 'keyword';
return createLuxonAutocompleteOption(key, optionType, luxonInstanceDocs, i18n.luxonInstance);
});
return createLuxonAutocompleteOption(
key,
optionType,
luxonInstanceDocs,
i18n.luxonInstance,
includeHidden,
) as Completion;
})
.filter(Boolean);
};
/**
@ -429,17 +646,19 @@ export const luxonInstanceOptions = () => {
export const luxonStaticOptions = () => {
const SKIP = new Set(['prototype', 'name', 'length', 'invalid']);
return Object.keys(Object.getOwnPropertyDescriptors(DateTime))
.filter((key) => !SKIP.has(key) && !key.includes('_'))
.sort((a, b) => a.localeCompare(b))
.map((key) => {
return createLuxonAutocompleteOption(
key,
'native-function',
luxonStaticDocs,
i18n.luxonStatic,
);
});
return sortCompletionsAlpha(
Object.keys(Object.getOwnPropertyDescriptors(DateTime))
.filter((key) => !SKIP.has(key) && !key.includes('_'))
.map((key) => {
return createLuxonAutocompleteOption(
key,
'native-function',
luxonStaticDocs,
i18n.luxonStatic,
) as Completion;
})
.filter(Boolean),
);
};
const createLuxonAutocompleteOption = (
@ -447,11 +666,10 @@ const createLuxonAutocompleteOption = (
type: AutocompleteOptionType,
docDefinition: NativeDoc,
translations: Record<string, string | undefined>,
): Completion => {
const option: Completion = {
label: isFunctionOption(type) ? name + '()' : name,
type,
};
includeHidden = false,
): Completion | null => {
const isFunction = isFunctionOption(type);
const label = isFunction ? name + '()' : name;
let doc: DocMetadata | undefined;
if (docDefinition.properties && docDefinition.properties.hasOwnProperty(name)) {
@ -469,6 +687,17 @@ const createLuxonAutocompleteOption = (
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetime',
};
}
if (doc?.hidden && !includeHidden) {
return null;
}
const option: Completion = {
label,
type,
section: doc?.section,
apply: applyCompletion(hasRequiredArgs(doc)),
};
option.info = createCompletionOption('DateTime', name, type, {
// Add translated description
doc: { ...doc, description: translations[name] } as DocMetadata,
@ -495,20 +724,20 @@ export const objectGlobalOptions = () => {
};
const regexes = {
generalRef: /\$[^$'"]+\.([^{\s])*/, // $input. or $json. or similar ones
selectorRef: /\$\(['"][\S\s]+['"]\)\.([^{\s])*/, // $('nodeName').
generalRef: /\$[^$'"]+\.(.*)/, // $input. or $json. or similar ones
selectorRef: /\$\(['"][\S\s]+['"]\)\.(.*)/, // $('nodeName').
numberLiteral: /\((\d+)\.?(\d*)\)\.([^{\s])*/, // (123). or (123.4).
numberLiteral: /\((\d+)\.?(\d*)\)\.(.*)/, // (123). or (123.4).
singleQuoteStringLiteral: /('.+')\.([^'{\s])*/, // 'abc'.
doubleQuoteStringLiteral: /(".+")\.([^"{\s])*/, // "abc".
dateLiteral: /\(?new Date\(\(?.*?\)\)?\.([^{\s])*/, // new Date(). or (new Date()).
arrayLiteral: /(\[.+\])\.([^{\s])*/, // [1, 2, 3].
indexedAccess: /([^{\s]+\[.+\])\.([^{\s])*/, // 'abc'[0]. or 'abc'.split('')[0] or similar ones
objectLiteral: /\(\{.*\}\)\.([^{\s])*/, // ({}).
dateLiteral: /\(?new Date\(\(?.*?\)\)?\.(.*)/, // new Date(). or (new Date()).
arrayLiteral: /(\[.*\])\.(.*)/, // [1, 2, 3].
indexedAccess: /([^"{\s]+\[.+\])\.(.*)/, // 'abc'[0]. or 'abc'.split('')[0] or similar ones
objectLiteral: /\(\{.*\}\)\.(.*)/, // ({}).
mathGlobal: /Math\.([^{\s])*/, // Math.
datetimeGlobal: /DateTime\.[^.}]*/, // DateTime.
objectGlobal: /Object\.(\w+\(.*\)\.[^{\s]*)?/, // Object. or Object.method(arg).
mathGlobal: /Math\.(.*)/, // Math.
datetimeGlobal: /DateTime\.(.*)/, // DateTime.
objectGlobal: /Object\.(.*)/, // Object. or Object.method(arg).
};
const DATATYPE_REGEX = new RegExp(

View file

@ -3,15 +3,16 @@ import {
autocompletableNodeNames,
receivesNoBinaryData,
longestCommonPrefix,
setRank,
prefixMatch,
stripExcessParens,
hasActiveNode,
isCredentialsModalOpen,
applyCompletion,
} from './utils';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import { useExternalSecretsStore } from '@/stores/externalSecrets.ee.store';
import { escapeMappingString } from '@/utils/mappingUtils';
import { PREVIOUS_NODES_SECTION, ROOT_DOLLAR_COMPLETIONS } from './constants';
/**
* Completions offered at the dollar position: `$|`
@ -45,10 +46,8 @@ export function dollarCompletions(context: CompletionContext): CompletionResult
};
}
export function dollarOptions() {
const rank = setRank(['$json', '$input']);
export function dollarOptions(): Completion[] {
const SKIP = new Set();
const DOLLAR_FUNCTIONS = ['$jmespath', '$ifEmpty'];
if (isCredentialsModalOpen()) {
return useExternalSecretsStore().isEnterpriseExternalSecretsEnabled
@ -71,29 +70,14 @@ export function dollarOptions() {
if (receivesNoBinaryData()) SKIP.add('$binary');
const keys = Object.keys(i18n.rootVars).sort((a, b) => a.localeCompare(b));
const previousNodesCompletions = autocompletableNodeNames().map((nodeName) => ({
label: `$('${escapeMappingString(nodeName)}')`,
type: 'keyword',
info: i18n.baseText('codeNodeEditor.completer.$()', { interpolate: { nodeName } }),
section: PREVIOUS_NODES_SECTION,
}));
return rank(keys)
.filter((key) => !SKIP.has(key))
.map((key) => {
const isFunction = DOLLAR_FUNCTIONS.includes(key);
const option: Completion = {
label: isFunction ? key + '()' : key,
type: isFunction ? 'function' : 'keyword',
};
const info = i18n.rootVars[key];
if (info) option.info = info;
return option;
})
.concat(
autocompletableNodeNames().map((nodeName) => ({
label: `$('${escapeMappingString(nodeName)}')`,
type: 'keyword',
info: i18n.baseText('codeNodeEditor.completer.$()', { interpolate: { nodeName } }),
})),
);
return ROOT_DOLLAR_COMPLETIONS.filter(({ label }) => !SKIP.has(label))
.concat(previousNodesCompletions)
.map((completion) => ({ ...completion, apply: applyCompletion() }));
}

View file

@ -8,6 +8,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
day: {
doc: {
name: 'day',
section: 'query',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeday',
returnType: 'number',
},
@ -15,6 +16,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
daysInMonth: {
doc: {
name: 'daysInMonth',
section: 'query',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimedaysinmonth',
returnType: 'number',
},
@ -22,6 +25,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
daysInYear: {
doc: {
name: 'daysInYear',
hidden: true,
section: 'query',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimedaysinyear',
returnType: 'number',
},
@ -29,6 +34,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
hour: {
doc: {
name: 'hour',
section: 'query',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimehour',
returnType: 'number',
},
@ -36,6 +42,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
locale: {
doc: {
name: 'locale',
section: 'query',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimelocale',
returnType: 'string',
},
@ -43,6 +50,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
millisecond: {
doc: {
name: 'millisecond',
section: 'query',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimemillisecond',
returnType: 'number',
},
@ -50,6 +58,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
minute: {
doc: {
name: 'minute',
section: 'query',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeminute',
returnType: 'number',
},
@ -57,6 +66,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
month: {
doc: {
name: 'month',
section: 'query',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimemonth',
returnType: 'number',
},
@ -64,6 +74,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
monthLong: {
doc: {
name: 'monthLong',
section: 'query',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimemonthlong',
returnType: 'string',
},
@ -71,6 +82,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
monthShort: {
doc: {
name: 'monthShort',
section: 'query',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimemonthshort',
returnType: 'string',
},
@ -78,6 +90,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
numberingSystem: {
doc: {
name: 'numberingSystem',
section: 'query',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimenumberingsystem',
returnType: 'string',
},
@ -85,6 +99,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
offset: {
doc: {
name: 'offset',
section: 'query',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeoffset',
returnType: 'number',
},
@ -92,6 +108,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
offsetNameLong: {
doc: {
name: 'offsetNameLong',
section: 'query',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeoffsetnamelong',
returnType: 'string',
},
@ -99,6 +117,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
offsetNameShort: {
doc: {
name: 'offsetNameShort',
section: 'query',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeoffsetnameshort',
returnType: 'string',
},
@ -106,6 +126,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
ordinal: {
doc: {
name: 'ordinal',
section: 'query',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeordinal',
returnType: 'string',
},
@ -113,6 +135,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
outputCalendar: {
doc: {
name: 'outputCalendar',
section: 'query',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeoutputcalendar',
returnType: 'string',
},
@ -120,6 +144,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
quarter: {
doc: {
name: 'quarter',
section: 'query',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimequarter',
returnType: 'number',
},
@ -127,6 +152,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
second: {
doc: {
name: 'second',
section: 'query',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimesecond',
returnType: 'number',
},
@ -134,6 +160,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
weekday: {
doc: {
name: 'weekday',
section: 'query',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeweekday',
returnType: 'number',
},
@ -141,6 +168,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
weekdayLong: {
doc: {
name: 'weekdayLong',
section: 'query',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeweekdaylong',
returnType: 'string',
},
@ -148,6 +176,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
weekdayShort: {
doc: {
name: 'weekdayShort',
section: 'query',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeweekdayshort',
returnType: 'string',
},
@ -155,6 +184,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
weekNumber: {
doc: {
name: 'weekNumber',
section: 'query',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeweeknumber',
returnType: 'number',
},
@ -162,6 +192,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
weeksInWeekYear: {
doc: {
name: 'weeksInWeekYear',
section: 'query',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeweeksinweekyear',
returnType: 'number',
},
@ -169,6 +201,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
weekYear: {
doc: {
name: 'weekYear',
section: 'query',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeweekyear',
returnType: 'number',
},
@ -176,6 +209,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
year: {
doc: {
name: 'year',
section: 'query',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeyear',
returnType: 'number',
},
@ -183,6 +217,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
zone: {
doc: {
name: 'zone',
section: 'query',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimezone',
returnType: 'Zone',
},
@ -190,6 +225,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
zoneName: {
doc: {
name: 'zoneName',
section: 'query',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimezonename',
returnType: 'string',
},
@ -197,6 +233,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
isInDST: {
doc: {
name: 'isInDST',
section: 'query',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeisindst',
returnType: 'boolean',
},
@ -204,6 +241,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
isInLeapYear: {
doc: {
name: 'isInLeapYear',
section: 'query',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeisinleapyear',
returnType: 'boolean',
},
@ -211,6 +249,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
isOffsetFixed: {
doc: {
name: 'isOffsetFixed',
section: 'query',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeisoffsetfixed',
returnType: 'boolean',
},
@ -218,6 +258,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
isValid: {
doc: {
name: 'isValid',
hidden: true,
section: 'query',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeisvalid',
returnType: 'boolean',
},
@ -227,6 +269,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
diff: {
doc: {
name: 'diff',
section: 'compare',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimediff',
returnType: 'Duration',
args: [
@ -239,6 +282,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
diffNow: {
doc: {
name: 'diffNow',
section: 'compare',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimediffnow',
returnType: 'Duration',
args: [
@ -250,6 +294,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
endOf: {
doc: {
name: 'endOf',
section: 'edit',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeendof',
returnType: 'DateTime',
args: [{ name: 'unit', type: 'string' }],
@ -258,6 +303,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
equals: {
doc: {
name: 'equals',
section: 'compare',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeequals',
returnType: 'boolean',
args: [{ name: 'other', type: 'DateTime' }],
@ -266,6 +312,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
hasSame: {
doc: {
name: 'hasSame',
section: 'compare',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimehassame',
returnType: 'boolean',
args: [
@ -277,6 +324,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
minus: {
doc: {
name: 'minus',
section: 'edit',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeminus',
returnType: 'DateTime',
args: [{ name: 'duration', type: 'Duration|object|number' }],
@ -285,6 +333,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
plus: {
doc: {
name: 'plus',
section: 'edit',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeplus',
returnType: 'DateTime',
args: [{ name: 'duration', type: 'Duration|object|number' }],
@ -293,6 +342,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
reconfigure: {
doc: {
name: 'reconfigure',
section: 'other',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimereconfigure',
returnType: 'DateTime',
args: [{ name: 'properties', type: 'object' }],
@ -301,6 +352,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
resolvedLocaleOptions: {
doc: {
name: 'resolvedLocaleOptions',
section: 'other',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeresolvedlocaleoptions',
returnType: 'object',
args: [{ name: 'opts', type: 'object' }],
@ -309,6 +362,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
set: {
doc: {
name: 'set',
section: 'edit',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeset',
returnType: 'DateTime',
args: [{ name: 'values', type: 'object' }],
@ -317,6 +371,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
setLocale: {
doc: {
name: 'setLocale',
section: 'edit',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimesetlocale',
returnType: 'DateTime',
args: [{ name: 'locale', type: 'any' }],
@ -325,6 +380,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
setZone: {
doc: {
name: 'setZone',
section: 'edit',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimesetzone',
returnType: 'DateTime',
args: [
@ -336,6 +392,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
startOf: {
doc: {
name: 'startOf',
section: 'edit',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimestartof',
returnType: 'DateTime',
args: [{ name: 'unit', type: 'string' }],
@ -344,6 +401,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toBSON: {
doc: {
name: 'toBSON',
section: 'format',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetobson',
returnType: 'Date',
},
@ -351,6 +410,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toFormat: {
doc: {
name: 'toFormat',
section: 'format',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetime',
returnType: 'string',
args: [
@ -362,6 +423,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toHTTP: {
doc: {
name: 'toHTTP',
section: 'format',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetohttp',
returnType: 'string',
},
@ -369,6 +432,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toISO: {
doc: {
name: 'toISO',
section: 'format',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetoiso',
returnType: 'string',
args: [{ name: 'opts', type: 'object' }],
@ -377,6 +441,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toISODate: {
doc: {
name: 'toISODate',
section: 'format',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetoisodate',
returnType: 'string',
args: [{ name: 'opts', type: 'object' }],
@ -385,6 +451,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toISOTime: {
doc: {
name: 'toISOTime',
section: 'format',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetoisotime',
returnType: 'string',
args: [{ name: 'opts', type: 'object' }],
@ -393,6 +461,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toISOWeekDate: {
doc: {
name: 'toISOWeekDate',
section: 'format',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetoisoweekdate',
returnType: 'string',
},
@ -400,6 +470,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toJSDate: {
doc: {
name: 'toJSDate',
section: 'format',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetojsdate',
returnType: 'Date',
},
@ -407,6 +479,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toJSON: {
doc: {
name: 'toJSON',
section: 'format',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetojson',
returnType: 'string',
},
@ -414,6 +488,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toLocal: {
doc: {
name: 'toLocal',
section: 'format',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetolocal',
returnType: 'DateTime',
},
@ -421,6 +496,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toLocaleParts: {
doc: {
name: 'toLocaleParts',
section: 'format',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetolocaleparts',
returnType: 'string',
args: [
@ -432,6 +509,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toLocaleString: {
doc: {
name: 'toLocaleString',
section: 'format',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetolocalestring',
returnType: 'string',
args: [
@ -443,6 +521,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toMillis: {
doc: {
name: 'toMillis',
section: 'format',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetomillis',
returnType: 'number',
},
@ -450,6 +529,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toObject: {
doc: {
name: 'toObject',
section: 'format',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetoobject',
returnType: 'object',
args: [{ name: 'opts', type: 'any' }],
@ -458,6 +539,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toRelative: {
doc: {
name: 'toRelative',
section: 'format',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetorelative',
returnType: 'string',
args: [{ name: 'options', type: 'object' }],
@ -466,6 +548,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toRelativeCalendar: {
doc: {
name: 'toRelativeCalendar',
section: 'format',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetorelativecalendar',
returnType: 'string',
args: [{ name: 'options', type: 'object' }],
@ -474,6 +558,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toRFC2822: {
doc: {
name: 'toRFC2822',
section: 'format',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetorfc2822',
returnType: 'string',
},
@ -481,6 +567,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toSeconds: {
doc: {
name: 'toSeconds',
section: 'format',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetoseconds',
returnType: 'number',
},
@ -488,14 +575,18 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toSQL: {
doc: {
name: 'toSQL',
section: 'format',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetosql',
returnType: 'string',
hidden: true,
args: [{ name: 'options', type: 'object' }],
},
},
toSQLDate: {
doc: {
name: 'toSQLDate',
section: 'format',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetosqldate',
returnType: 'string',
},
@ -503,6 +594,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toSQLTime: {
doc: {
name: 'toSQLTime',
section: 'format',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetosqltime',
returnType: 'string',
},
@ -510,6 +603,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toString: {
doc: {
name: 'toString',
section: 'format',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetostring',
returnType: 'string',
},
@ -517,6 +611,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toUnixInteger: {
doc: {
name: 'toUnixInteger',
section: 'format',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetounixinteger',
returnType: 'number',
},
@ -524,6 +620,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toUTC: {
doc: {
name: 'toUTC',
section: 'edit',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetoutc',
returnType: 'DateTime',
args: [
@ -535,6 +632,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
until: {
doc: {
name: 'until',
section: 'compare',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeuntil',
returnType: 'Interval',
args: [{ name: 'other', type: 'DateTime' }],
@ -543,6 +641,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
valueOf: {
doc: {
name: 'valueOf',
section: 'format',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimevalueof',
returnType: 'number',
},

View file

@ -1,7 +1,6 @@
import type { resolveParameter } from '@/composables/useWorkflowHelpers';
import type { DocMetadata } from 'n8n-workflow';
export type Resolved = ReturnType<typeof resolveParameter>;
export type Resolved = unknown;
export type ExtensionTypeName = 'number' | 'string' | 'date' | 'array' | 'object';
@ -10,3 +9,8 @@ export type FnToDoc = { [fnName: string]: { doc?: DocMetadata } };
export type FunctionOptionType = 'native-function' | 'extension-function';
export type KeywordOptionType = 'keyword';
export type AutocompleteOptionType = FunctionOptionType | KeywordOptionType;
export type AutocompleteInput<R = Resolved> = {
resolved: R;
base: string;
tail: string;
};

View file

@ -1,24 +1,40 @@
import { NODE_TYPES_EXCLUDED_FROM_AUTOCOMPLETION } from '@/components/CodeNodeEditor/constants';
import { CREDENTIAL_EDIT_MODAL_KEY, SPLIT_IN_BATCHES_NODE_TYPE } from '@/constants';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { resolveParameter } from '@/composables/useWorkflowHelpers';
import { resolveParameter, useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { useNDVStore } from '@/stores/ndv.store';
import { useUIStore } from '@/stores/ui.store';
import type { Completion, CompletionContext } from '@codemirror/autocomplete';
import {
insertCompletionText,
type Completion,
type CompletionContext,
pickedCompletion,
type CompletionSection,
} from '@codemirror/autocomplete';
import type { EditorView } from '@codemirror/view';
import type { TransactionSpec } from '@codemirror/state';
import type { SyntaxNode } from '@lezer/common';
import { javascriptLanguage } from '@codemirror/lang-javascript';
import { useRouter } from 'vue-router';
import type { DocMetadata } from 'n8n-workflow';
// String literal expression is everything enclosed in single, double or tick quotes following a dot
const stringLiteralRegex = /^"[^"]+"|^'[^']+'|^`[^`]+`\./;
// JavaScript operands
const operandsRegex = /[+\-*/><<==>**!=?]/;
/**
* Split user input into base (to resolve) and tail (to filter).
*/
export function splitBaseTail(userInput: string): [string, string] {
const processedInput = extractSubExpression(userInput);
const parts = processedInput.split('.');
const tail = parts.pop() ?? '';
const read = (node: SyntaxNode | null) => (node ? userInput.slice(node.from, node.to) : '');
const lastNode = javascriptLanguage.parser.parse(userInput).resolveInner(userInput.length, -1);
return [parts.join('.'), tail];
switch (lastNode.type.name) {
case '.':
return [read(lastNode.parent).slice(0, -1), ''];
case 'MemberExpression':
return [read(lastNode.parent), read(lastNode)];
case 'PropertyName':
const tail = read(lastNode);
return [read(lastNode.parent).slice(0, -(tail.length + 1)), tail];
default:
return ['', ''];
}
}
export function longestCommonPrefix(...strings: string[]) {
@ -37,29 +53,6 @@ export function longestCommonPrefix(...strings: string[]) {
}, '');
}
// Process user input if expressions are used as part of complex expression
// i.e. as a function parameter or an operation expression
// this function will extract expression that is currently typed so autocomplete
// suggestions can be matched based on it.
function extractSubExpression(userInput: string): string {
const dollarSignIndex = userInput.indexOf('$');
if (dollarSignIndex === -1) {
return userInput;
} else if (!stringLiteralRegex.test(userInput)) {
// If there is a dollar sign in the input and input is not a string literal,
// extract part of following the last $
const expressionParts = userInput.split('$');
userInput = `$${expressionParts[expressionParts.length - 1]}`;
// If input is part of a complex operation expression and extract last operand
const operationPart = userInput.split(operandsRegex).pop()?.trim() || '';
const lastOperand = operationPart.split(' ').pop();
if (lastOperand) {
userInput = lastOperand;
}
}
return userInput;
}
export const prefixMatch = (first: string, second: string) =>
first.startsWith(second) && first !== second;
@ -133,15 +126,15 @@ export const isSplitInBatchesAbsent = () =>
!useWorkflowsStore().workflow.nodes.some((node) => node.type === SPLIT_IN_BATCHES_NODE_TYPE);
export function autocompletableNodeNames() {
return useWorkflowsStore()
.allNodes.filter((node) => {
const activeNodeName = useNDVStore().activeNode?.name;
const activeNodeName = useNDVStore().activeNode?.name;
return (
!NODE_TYPES_EXCLUDED_FROM_AUTOCOMPLETION.includes(node.type) && node.name !== activeNodeName
);
})
.map((node) => node.name);
if (!activeNodeName) return [];
return useWorkflowHelpers({ router: useRouter() })
.getCurrentWorkflow()
.getParentNodesByDepth(activeNodeName)
.map((node) => node.name)
.filter((name) => name !== activeNodeName);
}
/**
@ -157,3 +150,50 @@ export const stripExcessParens = (context: CompletionContext) => (option: Comple
return option;
};
/**
* When a function completion is selected, set the cursor correctly
* @example `.includes()` -> `.includes(<cursor>)`
* @example `$max()` -> `$max()<cursor>`
*/
export const applyCompletion =
(hasArgs = true) =>
(view: EditorView, completion: Completion, from: number, to: number): void => {
const tx: TransactionSpec = {
...insertCompletionText(view.state, completion.label, from, to),
annotations: pickedCompletion.of(completion),
};
if (completion.label.endsWith('()') && hasArgs) {
const cursorPosition = from + completion.label.length - 1;
tx.selection = { anchor: cursorPosition, head: cursorPosition };
}
view.dispatch(tx);
};
export const hasRequiredArgs = (doc?: DocMetadata): boolean => {
if (!doc) return false;
const requiredArgs = doc?.args?.filter((arg) => !arg.name.endsWith('?')) ?? [];
return requiredArgs.length > 0;
};
export const sortCompletionsAlpha = (completions: Completion[]): Completion[] => {
return completions.sort((a, b) => a.label.localeCompare(b.label));
};
export const renderSectionHeader = (section: CompletionSection): HTMLElement => {
const container = document.createElement('li');
container.classList.add('cm-section-header');
const inner = document.createElement('div');
inner.classList.add('cm-section-title');
inner.textContent = section.name;
container.appendChild(inner);
return container;
};
export const withSectionHeader = (section: CompletionSection): CompletionSection => {
section.header = renderSectionHeader;
return section;
};

View file

@ -0,0 +1,76 @@
import {
acceptCompletion,
completionStatus,
moveCompletionSelection,
selectedCompletion,
} from '@codemirror/autocomplete';
import { indentLess, indentMore, insertNewlineAndIndent, redo, undo } from '@codemirror/commands';
import type { EditorView, KeyBinding } from '@codemirror/view';
export const tabKeyMap = (singleLine = false): KeyBinding[] => [
{
any(view, event) {
if (
event.key === 'Tab' ||
(event.key === 'Escape' && completionStatus(view.state) !== null)
) {
event.stopPropagation();
}
return false;
},
},
{
key: 'Tab',
run: (view) => {
if (selectedCompletion(view.state)) {
return acceptCompletion(view);
}
if (!singleLine) return indentMore(view);
return false;
},
},
{ key: 'Shift-Tab', run: indentLess },
];
export const enterKeyMap: KeyBinding[] = [
{
key: 'Enter',
run: (view) => {
if (selectedCompletion(view.state)) {
return acceptCompletion(view);
}
return insertNewlineAndIndent(view);
},
},
];
const SELECTED_AUTOCOMPLETE_OPTION_SELECTOR = '.cm-tooltip-autocomplete li[aria-selected]';
const onAutocompleteNavigate = (dir: 'up' | 'down') => (view: EditorView) => {
if (completionStatus(view.state) !== null) {
moveCompletionSelection(dir === 'down')(view);
document
.querySelector(SELECTED_AUTOCOMPLETE_OPTION_SELECTOR)
?.scrollIntoView({ block: 'nearest' });
return true;
}
return false;
};
export const autocompleteKeyMap: KeyBinding[] = [
{
key: 'ArrowDown',
run: onAutocompleteNavigate('down'),
},
{
key: 'ArrowUp',
run: onAutocompleteNavigate('up'),
},
];
export const historyKeyMap: KeyBinding[] = [
{ key: 'Mod-z', run: undo },
{ key: 'Mod-Shift-z', run: redo },
];

View file

@ -4,6 +4,7 @@ import { parseMixed } from '@lezer/common';
import { javascriptLanguage } from '@codemirror/lang-javascript';
import { n8nCompletionSources } from './completions/addCompletions';
import { autocompletion } from '@codemirror/autocomplete';
const n8nParserWithNestedJsParser = n8nParser.configure({
wrap: parseMixed((node) => {
@ -23,3 +24,5 @@ export function n8nLang() {
...n8nCompletionSources().map((source) => n8nLanguage.data.of(source)),
]);
}
export const n8nAutocompletion = () => autocompletion({ icons: false });

View file

@ -63,28 +63,32 @@ const coloringStateField = StateField.define<DecorationSet>({
return Decoration.none;
},
update(colorings, transaction) {
colorings = colorings.map(transaction.changes); // recalculate positions for new doc
try {
colorings = colorings.map(transaction.changes); // recalculate positions for new doc
for (const txEffect of transaction.effects) {
if (txEffect.is(coloringStateEffects.removeColorEffect)) {
colorings = colorings.update({
filter: (from, to) => txEffect.value.from !== from && txEffect.value.to !== to,
});
}
if (txEffect.is(coloringStateEffects.addColorEffect)) {
colorings = colorings.update({
filter: (from, to) => txEffect.value.from !== from && txEffect.value.to !== to,
});
const decoration = resolvableStateToDecoration[txEffect.value.state ?? 'pending'];
if (txEffect.value.from === 0 && txEffect.value.to === 0) continue;
colorings = colorings.update({
add: [decoration.range(txEffect.value.from, txEffect.value.to)],
});
for (const txEffect of transaction.effects) {
if (txEffect.is(coloringStateEffects.removeColorEffect)) {
colorings = colorings.update({
filter: (from, to) => txEffect.value.from !== from && txEffect.value.to !== to,
});
}
if (txEffect.is(coloringStateEffects.addColorEffect)) {
colorings = colorings.update({
filter: (from, to) => txEffect.value.from !== from && txEffect.value.to !== to,
});
const decoration = resolvableStateToDecoration[txEffect.value.state ?? 'pending'];
if (txEffect.value.from === 0 && txEffect.value.to === 0) continue;
colorings = colorings.update({
add: [decoration.range(txEffect.value.from, txEffect.value.to)],
});
}
}
} catch (error) {
window?.Sentry?.captureException(error);
}
return colorings;

View file

@ -357,7 +357,7 @@ export class I18nClass {
});
}
rootVars: Record<string, string | undefined> = {
rootVars = {
$binary: this.baseText('codeNodeEditor.completer.binary'),
$execution: this.baseText('codeNodeEditor.completer.$execution'),
$ifEmpty: this.baseText('codeNodeEditor.completer.$ifEmpty'),
@ -375,7 +375,8 @@ export class I18nClass {
$today: this.baseText('codeNodeEditor.completer.$today'),
$vars: this.baseText('codeNodeEditor.completer.$vars'),
$workflow: this.baseText('codeNodeEditor.completer.$workflow'),
};
DateTime: this.baseText('codeNodeEditor.completer.dateTime'),
} as const satisfies Record<string, string | undefined>;
proxyVars: Record<string, string | undefined> = {
'$input.all': this.baseText('codeNodeEditor.completer.$input.all'),
@ -390,6 +391,7 @@ export class I18nClass {
'$().itemMatching': this.baseText('codeNodeEditor.completer.selector.itemMatching'),
'$().last': this.baseText('codeNodeEditor.completer.selector.last'),
'$().params': this.baseText('codeNodeEditor.completer.selector.params'),
'$().isExecuted': this.baseText('codeNodeEditor.completer.selector.isExecuted'),
'$prevNode.name': this.baseText('codeNodeEditor.completer.$prevNode.name'),
'$prevNode.outputIndex': this.baseText('codeNodeEditor.completer.$prevNode.outputIndex'),

View file

@ -205,6 +205,7 @@
"codeNodeEditor.completer.$workflow.active": "Whether the workflow is active or not (boolean)",
"codeNodeEditor.completer.$workflow.id": "The ID of the workflow",
"codeNodeEditor.completer.$workflow.name": "The name of the workflow",
"codeNodeEditor.completer.dateTime": "Luxon DateTime. Use this object to parse, format and manipulate dates and times",
"codeNodeEditor.completer.binary": "The item's binary (file) data",
"codeNodeEditor.completer.globalObject.assign": "Copy of the object containing all enumerable own properties",
"codeNodeEditor.completer.globalObject.entries": "The object's keys and values",
@ -314,6 +315,25 @@
"codeNodeEditor.completer.selector.itemMatching": "@:_reusableBaseText.codeNodeEditor.completer.itemMatching",
"codeNodeEditor.completer.selector.last": "@:_reusableBaseText.codeNodeEditor.completer.last",
"codeNodeEditor.completer.selector.params": "The parameters of the node",
"codeNodeEditor.completer.selector.isExecuted": "Whether the node has executed",
"codeNodeEditor.completer.section.input": "Input",
"codeNodeEditor.completer.section.prevNodes": "Earlier nodes",
"codeNodeEditor.completer.section.metadata": "Metadata",
"codeNodeEditor.completer.section.fields": "Fields",
"codeNodeEditor.completer.section.properties": "Properties",
"codeNodeEditor.completer.section.methods": "Methods",
"codeNodeEditor.completer.section.otherMethods": "Other methods",
"codeNodeEditor.completer.section.recommended": "Suggested",
"codeNodeEditor.completer.section.recommendedMethods": "Suggested methods",
"codeNodeEditor.completer.section.other": "Other",
"codeNodeEditor.completer.section.edit": "Edit",
"codeNodeEditor.completer.section.query": "Query",
"codeNodeEditor.completer.section.format": "Format",
"codeNodeEditor.completer.section.component": "Component",
"codeNodeEditor.completer.section.case": "Case",
"codeNodeEditor.completer.section.cast": "Cast",
"codeNodeEditor.completer.section.compare": "Compare",
"codeNodeEditor.completer.section.validation": "Validate",
"codeNodeEditor.linter.allItems.firstOrLastCalledWithArg": "expects no argument.",
"codeNodeEditor.linter.allItems.emptyReturn": "Code doesn't return items properly. Please return an array of objects, one for each item you would like to output.",
"codeNodeEditor.linter.allItems.itemCall": "`item` is a property to access, not a method to call. Did you mean `.item` without brackets?",

View file

@ -8,55 +8,90 @@
content: 'n8n supports all JavaScript functions, including those not listed.';
}
// Custom autocomplete item type icons
// 1. Native and n8n extension functions:
.cm-completionIcon-extension-function,
.cm-completionIcon-native-function {
&::after {
content: 'ƒ';
}
}
.cm-tooltip-autocomplete {
.ͼ2 .cm-tooltip-autocomplete {
background-color: var(--color-background-xlight) !important;
box-shadow: var(--box-shadow-light);
border: none;
.cm-tooltip {
overflow: hidden;
text-overflow: ellipsis;
}
li .cm-completionLabel {
color: var(--color-success);
> ul[role='listbox'] {
font-family: var(--font-family-monospace);
max-height: min(220px, 50vh);
width: min(260px, 50vw);
min-width: 100%;
max-width: none;
border: var(--border-base);
border-radius: var(--border-radius-base);
li[role='option'] {
color: var(--color-text-base);
display: flex;
font-size: var(--font-size-2xs);
justify-content: space-between;
padding: var(--spacing-5xs) var(--spacing-2xs);
scroll-padding: 40px;
scroll-margin: 40px;
}
li .cm-completionLabel {
line-height: var(--font-line-height-xloose);
}
li[aria-selected] {
background-color: var(--color-background-base);
color: var(--color-autocomplete-item-selected);
}
> .cm-section-header {
padding: var(--spacing-4xs) var(--spacing-3xs);
border-bottom: 1px solid var(--color-autocomplete-section-header-border);
background-color: var(--color-background-xlight);
position: sticky;
top: 0;
}
> .cm-section-header:not(:first-child) {
margin-top: var(--spacing-2xs);
}
.cm-section-title {
color: var(--color-text-dark);
font-family: var(--font-family);
font-weight: var(--font-weight-bold);
font-size: var(--font-size-3xs);
text-transform: uppercase;
padding: var(--spacing-5xs) var(--spacing-4xs);
}
}
}
.ͼ2 .cm-tooltip-autocomplete ul li[aria-selected] {
background-color: var(--color-autocomplete-selected-background);
}
.ͼ2 .cm-tooltip-autocomplete ul li[aria-selected] .cm-completionLabel {
color: var(--color-autocomplete-selected-font) !important;
font-weight: 600;
}
.autocomplete-info-container {
display: flex;
flex-direction: column;
padding: var(--spacing-4xs) 0;
}
.cm-completionInfo {
background-color: var(--color-background-xlight) !important;
.ͼ2 .cm-completionInfo {
background-color: var(--color-background-xlight);
border: var(--border-base);
margin-left: var(--spacing-5xs);
border-radius: var(--border-radius-base);
line-height: var(--font-line-height-loose);
.autocomplete-info-header {
color: var(--color-text-dark);
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-success);
font-weight: var(--font-weight-bold);
color: var(--color-autocomplete-item-selected);
}
.autocomplete-info-description {

View file

@ -51,13 +51,14 @@ export const isAnyPairedItemError = (error: unknown): error is ExpressionError =
return error instanceof ExpressionError && error.context.functionality === 'pairedItem';
};
export const getResolvableState = (error: unknown): ResolvableState => {
export const getResolvableState = (error: unknown, ignoreError = false): ResolvableState => {
if (!error) return 'valid';
if (
isNoExecDataExpressionError(error) ||
isNoNodeExecDataExpressionError(error) ||
isPairedItemIntermediateNodesError(error)
isPairedItemIntermediateNodesError(error) ||
ignoreError
) {
return 'pending';
}

View file

@ -219,27 +219,35 @@ function plus(
endOfMonth.doc = {
name: 'endOfMonth',
returnType: 'Date',
hidden: true,
description: 'Transforms a date to the last possible moment that lies within the month.',
section: 'edit',
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/dates/#date-endOfMonth',
};
isDst.doc = {
name: 'isDst',
returnType: 'boolean',
hidden: true,
description: 'Checks if a Date is within Daylight Savings Time.',
section: 'query',
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/dates/#date-isDst',
};
isWeekend.doc = {
name: 'isWeekend',
returnType: 'boolean',
hidden: true,
description: 'Checks if the Date falls on a Saturday or Sunday.',
section: 'query',
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/dates/#date-isWeekend',
};
beginningOf.doc = {
name: 'beginningOf',
description: 'Transform a Date to the start of the given time period. Default unit is `week`.',
section: 'edit',
hidden: true,
returnType: 'Date',
args: [{ name: 'unit?', type: 'DurationUnit' }],
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/dates/#date-beginningOf',
@ -248,6 +256,7 @@ beginningOf.doc = {
extract.doc = {
name: 'extract',
description: 'Extracts the part defined in `datePart` from a Date. Default unit is `week`.',
section: 'query',
returnType: 'number',
args: [{ name: 'datePart?', type: 'DurationUnit' }],
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/dates/#date-extract',
@ -257,6 +266,7 @@ format.doc = {
name: 'format',
description: 'Formats a Date in the given structure.',
returnType: 'string',
section: 'format',
args: [{ name: 'fmt', type: 'TimeFormat' }],
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/dates/#date-format',
};
@ -264,6 +274,7 @@ format.doc = {
isBetween.doc = {
name: 'isBetween',
description: 'Checks if a Date is between two given dates.',
section: 'query',
returnType: 'boolean',
args: [
{ name: 'date1', type: 'Date|string' },
@ -275,6 +286,7 @@ isBetween.doc = {
isInLast.doc = {
name: 'isInLast',
description: 'Checks if a Date is within a given time period. Default unit is `minute`.',
section: 'query',
returnType: 'boolean',
args: [
{ name: 'n', type: 'number' },
@ -286,6 +298,7 @@ isInLast.doc = {
minus.doc = {
name: 'minus',
description: 'Subtracts a given time period from a Date. Default unit is `milliseconds`.',
section: 'edit',
returnType: 'Date',
args: [
{ name: 'n', type: 'number' },
@ -297,6 +310,7 @@ minus.doc = {
plus.doc = {
name: 'plus',
description: 'Adds a given time period to a Date. Default unit is `milliseconds`.',
section: 'edit',
returnType: 'Date',
args: [
{ name: 'n', type: 'number' },

View file

@ -1,9 +1,11 @@
export interface ExtensionMap {
typeName: string;
// eslint-disable-next-line @typescript-eslint/ban-types
functions: Record<string, Function & { doc?: DocMetadata }>;
functions: Record<string, Extension>;
}
// eslint-disable-next-line @typescript-eslint/ban-types
export type Extension = Function & { doc?: DocMetadata };
export type NativeDoc = {
typeName: string;
properties?: Record<string, { doc?: DocMetadata }>;
@ -14,6 +16,8 @@ export type DocMetadata = {
name: string;
returnType: string;
description?: string;
section?: string;
hidden?: boolean;
aliases?: string[];
args?: Array<{ name: string; type?: string }>;
docURL?: string;

View file

@ -9,6 +9,14 @@ function isNotEmpty(value: object): boolean {
return !isEmpty(value);
}
function keys(value: object): string[] {
return Object.keys(value);
}
function values(value: object): unknown[] {
return Object.values(value);
}
function hasField(value: object, extraArgs: string[]): boolean {
const [name] = extraArgs;
return name in value;
@ -146,6 +154,20 @@ keepFieldsContaining.doc = {
'https://docs.n8n.io/code/builtin/data-transformation-functions/objects/#object-keepFieldsContaining',
};
keys.doc = {
name: 'keys',
description: "Returns an array of a given object's own enumerable string-keyed property names.",
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/objects/#object-keys',
returnType: 'Array',
};
values.doc = {
name: 'values',
description: "Returns an array of a given object's own enumerable string-keyed property values.",
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/objects/#object-values',
returnType: 'Array',
};
export const objectExtensions: ExtensionMap = {
typeName: 'Object',
functions: {
@ -157,5 +179,7 @@ export const objectExtensions: ExtensionMap = {
keepFieldsContaining,
compact,
urlEncode,
keys,
values,
},
};

View file

@ -2,7 +2,7 @@ import SHA from 'jssha';
import MD5 from 'md5';
import { encode } from 'js-base64';
import { titleCase } from 'title-case';
import type { ExtensionMap } from './Extensions';
import type { Extension, ExtensionMap } from './Extensions';
import { transliterate } from 'transliteration';
import { ExpressionExtensionError } from '../errors/expression-extension.error';
@ -362,6 +362,7 @@ function extractUrl(value: string) {
removeMarkdown.doc = {
name: 'removeMarkdown',
description: 'Removes Markdown formatting from a string.',
section: 'edit',
returnType: 'string',
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-removeMarkdown',
@ -370,6 +371,7 @@ removeMarkdown.doc = {
removeTags.doc = {
name: 'removeTags',
description: 'Removes tags, such as HTML or XML, from a string.',
section: 'edit',
returnType: 'string',
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-removeTags',
@ -378,6 +380,7 @@ removeTags.doc = {
toDate.doc = {
name: 'toDate',
description: 'Converts a string to a date.',
section: 'cast',
returnType: 'Date',
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-toDate',
};
@ -385,6 +388,7 @@ toDate.doc = {
toFloat.doc = {
name: 'toFloat',
description: 'Converts a string to a decimal number.',
section: 'cast',
returnType: 'number',
aliases: ['toDecimalNumber'],
docURL:
@ -394,6 +398,7 @@ toFloat.doc = {
toInt.doc = {
name: 'toInt',
description: 'Converts a string to an integer.',
section: 'cast',
returnType: 'number',
args: [{ name: 'radix?', type: 'number' }],
aliases: ['toWholeNumber'],
@ -403,6 +408,7 @@ toInt.doc = {
toSentenceCase.doc = {
name: 'toSentenceCase',
description: 'Formats a string to sentence case. Example: "This is a sentence".',
section: 'case',
returnType: 'string',
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-toSentenceCase',
@ -411,6 +417,7 @@ toSentenceCase.doc = {
toSnakeCase.doc = {
name: 'toSnakeCase',
description: 'Formats a string to snake case. Example: "this_is_snake_case".',
section: 'case',
returnType: 'string',
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-toSnakeCase',
@ -420,6 +427,7 @@ 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.',
section: 'case',
returnType: 'string',
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-toTitleCase',
@ -428,6 +436,7 @@ toTitleCase.doc = {
urlEncode.doc = {
name: 'urlEncode',
description: 'Encodes a string to be used/included in a URL.',
section: 'edit',
args: [{ name: 'entireString?', type: 'boolean' }],
returnType: 'string',
docURL:
@ -438,6 +447,7 @@ 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.',
section: 'edit',
returnType: 'string',
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-urlDecode',
@ -446,6 +456,7 @@ urlDecode.doc = {
replaceSpecialChars.doc = {
name: 'replaceSpecialChars',
description: 'Replaces non-ASCII characters in a string with an ASCII representation.',
section: 'edit',
returnType: 'string',
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-replaceSpecialChars',
@ -453,6 +464,8 @@ replaceSpecialChars.doc = {
length.doc = {
name: 'length',
section: 'query',
hidden: true,
description: 'Returns the character count of a string.',
returnType: 'number',
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings',
@ -461,6 +474,7 @@ length.doc = {
isDomain.doc = {
name: 'isDomain',
description: 'Checks if a string is a domain.',
section: 'validation',
returnType: 'boolean',
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-isDomain',
};
@ -468,13 +482,15 @@ isDomain.doc = {
isEmail.doc = {
name: 'isEmail',
description: 'Checks if a string is an email.',
section: 'validation',
returnType: 'boolean',
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-isEmail',
};
isNumeric.doc = {
name: 'isEmail',
name: 'isNumeric',
description: 'Checks if a string only contains digits.',
section: 'validation',
returnType: 'boolean',
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-isNumeric',
@ -483,6 +499,7 @@ isNumeric.doc = {
isUrl.doc = {
name: 'isUrl',
description: 'Checks if a string is a valid URL.',
section: 'validation',
returnType: 'boolean',
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-isUrl',
};
@ -490,6 +507,7 @@ isUrl.doc = {
isEmpty.doc = {
name: 'isEmpty',
description: 'Checks if a string is empty.',
section: 'validation',
returnType: 'boolean',
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-isEmpty',
};
@ -497,6 +515,7 @@ isEmpty.doc = {
isNotEmpty.doc = {
name: 'isNotEmpty',
description: 'Checks if a string has content.',
section: 'validation',
returnType: 'boolean',
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-isNotEmpty',
@ -505,6 +524,7 @@ isNotEmpty.doc = {
extractEmail.doc = {
name: 'extractEmail',
description: 'Extracts an email from a string. Returns undefined if none is found.',
section: 'edit',
returnType: 'string',
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-extractEmail',
@ -514,6 +534,7 @@ extractDomain.doc = {
name: 'extractDomain',
description:
'Extracts a domain from a string containing a valid URL. Returns undefined if none is found.',
section: 'edit',
returnType: 'string',
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-extractDomain',
@ -522,6 +543,7 @@ extractDomain.doc = {
extractUrl.doc = {
name: 'extractUrl',
description: 'Extracts a URL from a string. Returns undefined if none is found.',
section: 'edit',
returnType: 'string',
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-extractUrl',
@ -530,6 +552,7 @@ extractUrl.doc = {
hash.doc = {
name: 'hash',
description: 'Returns a string hashed with the given algorithm. Default algorithm is `md5`.',
section: 'edit',
returnType: 'string',
args: [{ name: 'algo?', type: 'Algorithm' }],
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-hash',
@ -538,11 +561,17 @@ hash.doc = {
quote.doc = {
name: 'quote',
description: 'Returns a string wrapped in the quotation marks. Default quotation is `"`.',
section: 'edit',
returnType: 'string',
args: [{ name: 'mark?', type: 'string' }],
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-quote',
};
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',
functions: {
@ -550,10 +579,10 @@ export const stringExtensions: ExtensionMap = {
removeMarkdown,
removeTags,
toDate,
toDecimalNumber: toFloat,
toDecimalNumber,
toFloat,
toInt,
toWholeNumber: toInt,
toWholeNumber,
toSentenceCase,
toSnakeCase,
toTitleCase,

View file

@ -2,26 +2,5 @@ import type { NativeDoc } from '@/Extensions/Extensions';
export const objectMethods: NativeDoc = {
typeName: 'Object',
functions: {
keys: {
doc: {
name: 'keys',
description:
"Returns an array of a given object's own enumerable string-keyed property names.",
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/keys',
returnType: 'Array',
},
},
values: {
doc: {
name: 'values',
description:
"Returns an array of a given object's own enumerable string-keyed property values.",
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/values',
returnType: 'Array',
},
},
},
functions: {},
};

View file

@ -7,6 +7,7 @@ export const stringMethods: NativeDoc = {
doc: {
name: 'length',
description: 'Returns the number of characters in the string.',
section: 'query',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/length',
returnType: 'number',
@ -18,6 +19,7 @@ export const stringMethods: NativeDoc = {
doc: {
name: 'concat',
description: 'Concatenates the string arguments to the calling string.',
section: 'edit',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/concat',
returnType: 'string',
@ -27,6 +29,7 @@ export const stringMethods: NativeDoc = {
doc: {
name: 'endsWith',
description: 'Checks if a string ends with `searchString`.',
section: 'query',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/endsWith',
returnType: 'boolean',
@ -37,6 +40,7 @@ export const stringMethods: NativeDoc = {
doc: {
name: 'indexOf',
description: 'Returns the index of the first occurrence of `searchString`.',
section: 'query',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/indexOf',
returnType: 'number',
@ -50,6 +54,7 @@ export const stringMethods: NativeDoc = {
doc: {
name: 'lastIndexOf',
description: 'Returns the index of the last occurrence of `searchString`.',
section: 'query',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/lastIndexOf',
returnType: 'number',
@ -63,6 +68,7 @@ export const stringMethods: NativeDoc = {
doc: {
name: 'match',
description: 'Retrieves the result of matching a string against a regular expression.',
section: 'query',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/match',
returnType: 'Array',
@ -73,6 +79,7 @@ export const stringMethods: NativeDoc = {
doc: {
name: 'includes',
description: 'Checks if `searchString` may be found within the calling string.',
section: 'query',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/includes',
returnType: 'boolean',
@ -87,6 +94,7 @@ export const stringMethods: NativeDoc = {
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.',
section: 'edit',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace',
returnType: 'string',
@ -100,6 +108,7 @@ export const stringMethods: NativeDoc = {
doc: {
name: 'replaceAll',
description: 'Returns a string with matches of a `pattern` replaced by a `replacement`.',
section: 'edit',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replaceAll',
returnType: 'string',
@ -113,6 +122,7 @@ export const stringMethods: NativeDoc = {
doc: {
name: 'search',
description: 'Returns a string that matches `pattern` within the given string.',
section: 'query',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/search',
returnType: 'string',
@ -124,6 +134,7 @@ export const stringMethods: NativeDoc = {
name: 'slice',
description:
'Returns a section of a string. `indexEnd` defaults to the length of the string if not given.',
section: 'edit',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/slice',
returnType: 'string',
@ -138,6 +149,7 @@ export const stringMethods: NativeDoc = {
name: 'split',
description:
'Returns the substrings that result from dividing the given string with `separator`.',
section: 'edit',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/split',
returnType: 'Array',
@ -151,6 +163,7 @@ export const stringMethods: NativeDoc = {
doc: {
name: 'startsWith',
description: 'Checks if the string begins with `searchString`.',
section: 'query',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/startsWith',
returnType: 'boolean',
@ -165,6 +178,7 @@ export const stringMethods: NativeDoc = {
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.',
section: 'edit',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/substring',
returnType: 'string',
@ -178,6 +192,7 @@ export const stringMethods: NativeDoc = {
doc: {
name: 'toLowerCase',
description: 'Formats a string to lowercase. Example: "this is lowercase”.',
section: 'case',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/toLowerCase',
returnType: 'string',
@ -187,6 +202,7 @@ export const stringMethods: NativeDoc = {
doc: {
name: 'toUpperCase',
description: 'Formats a string to lowercase. Example: "THIS IS UPPERCASE”.',
section: 'case',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/toUpperCase',
returnType: 'string',
@ -196,6 +212,7 @@ export const stringMethods: NativeDoc = {
doc: {
name: 'trim',
description: 'Removes whitespace from both ends of a string and returns a new string.',
section: 'edit',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/Trim',
returnType: 'string',
@ -205,6 +222,7 @@ export const stringMethods: NativeDoc = {
doc: {
name: 'trimEnd',
description: 'Removes whitespace from the end of a string and returns a new string.',
section: 'edit',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/trimEnd',
returnType: 'string',
@ -214,6 +232,7 @@ export const stringMethods: NativeDoc = {
doc: {
name: 'trimStart',
description: 'Removes whitespace from the beginning of a string and returns a new string.',
section: 'edit',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/trimStart',
returnType: 'string',

View file

@ -81,5 +81,13 @@ describe('Data Transformation Functions', () => {
test('.urlEncode should work on an object', () => {
expect(evaluate('={{ ({ test1: 1, test2: "2" }).urlEncode() }}')).toEqual('test1=1&test2=2');
});
test('.keys should work on an object', () => {
expect(evaluate('={{ ({ test1: 1, test2: "2" }).keys() }}')).toEqual(['test1', 'test2']);
});
test('.values should work on an object', () => {
expect(evaluate('={{ ({ test1: 1, test2: "2" }).values() }}')).toEqual([1, '2']);
});
});
});